From 6bca3d9622ef1e60b0346866e27dfa7788a09144 Mon Sep 17 00:00:00 2001 From: Omri Katz Date: Tue, 4 Aug 2026 02:36:58 +0300 Subject: [PATCH] The docs-only door repacks too: every commit means every commit The pack-every-commit law says every successful commit runs the same repack, so the store a reader sees is always one pack, its index, and HEAD. Only the code-kind door honored it. The docs-only door published its prompt blob, module state, transition, event, and snapshot and then returned without ever calling repack_if_loose, so every docs-only commit left its loose objects behind. PR 74 leaked five that way and the S3 branch accumulated fifteen, healed by hand with gc --apply in PR 75. run_docs now runs the same repack after install_docs returns, outside the bounded lock section with the pointer already moved, and records the attempt under repack or repackSkipped exactly as the code path does. The law's prose already said every successful commit, so the pinned tests grow instead: a docs-only commit repacks through the same seam and leaves one pack, its index, and HEAD. Co-Authored-By: Claude Fable 5 --- .napl/attribution/napl.yaml | 26 +- .napl/store/HEAD | 2 +- ...89dcdceda176b846a90169d3fe4b3daa94dc00.idx | 1 - ...53995320c151ea4b038069eec7bd04b5ff3475.idx | 1 + ...20c151ea4b038069eec7bd04b5ff3475.naplpack} | 88535 ++++++++-------- crates/napl/napl.napl | 6 + crates/napl/src/commit.rs | 3 +- crates/napl/tests/packfiles.rs | 50 +- 8 files changed, 47006 insertions(+), 41618 deletions(-) delete mode 100644 .napl/store/objects/pack/pack-38ee1e7d21312479a2fffe9b2989dcdceda176b846a90169d3fe4b3daa94dc00.idx create mode 100644 .napl/store/objects/pack/pack-9d671ec2793ecad5e1bd1884aa53995320c151ea4b038069eec7bd04b5ff3475.idx rename .napl/store/objects/pack/{pack-38ee1e7d21312479a2fffe9b2989dcdceda176b846a90169d3fe4b3daa94dc00.naplpack => pack-9d671ec2793ecad5e1bd1884aa53995320c151ea4b038069eec7bd04b5ff3475.naplpack} (98%) diff --git a/.napl/attribution/napl.yaml b/.napl/attribution/napl.yaml index 3262cec1..5bb25cee 100644 --- a/.napl/attribution/napl.yaml +++ b/.napl/attribution/napl.yaml @@ -298,73 +298,73 @@ entries: note: "the staged record carries its attribution mode so a reference-only record commits through the same door add and commit already use" - promptLines: - 1527 - - 2007 + - 2013 file: "crates/napl/src/blame.rs" lines: whole note: "derived line ancestry where surviving lines trace through accepted patches, and a path no committed module owns answers tracked false naming the absent baseline rather than an empty ancestry that would read as success" - promptLines: - 1527 - - 2007 + - 2013 file: "crates/napl/src/cost.rs" lines: whole note: "cost as a pure calculation over an explicit receipt set where verb-staged session windows resolve against the log at read time and unknown stays unknown" - promptLines: - 1527 - - 2007 + - 2013 file: "crates/napl/src/evidence.rs" lines: whole note: "evidence validators keyed by media type including the usage-proxy jsonl log with compiled-in bounds" - promptLines: - 1527 - - 2007 + - 2013 file: "crates/napl/src/git.rs" lines: whole note: "what the toolchain observes about the enclosing git repository by reading files and never by linking a git library, and which refusal the retired store ref earns: the whole road back when the objects are still on the branch, and deleting the ref alone when the store already holds them, each carrying the exact commands it names" - promptLines: - 1527 - - 2007 + - 2013 file: "crates/napl/src/remove.rs" lines: whole note: "remove ends a module's tenancy through the same guarded door, publishing a snapshot without its state, deleting the files it owned, re-deriving the leaving crate's entry file and no other crate's from the states the critical section itself read so no module landing in that crate meanwhile is left unnamed, closing its attribution record only where one existed, and refusing an unknown module, drift, and dependents read off the committed prompts rather than the working tree, a prompt that will not parse counting as silence and never as a verdict, while leaving the prompt file, the manifest, and an emptied crate to the user" - promptLines: - 1527 - - 2007 + - 2013 file: "crates/napl/src/lint.rs" lines: whole note: "unreachable modules under declared roots where absent roots refuse loudly with the exact declaration rather than an empty findings list, and unconsumed surface entries" - promptLines: - 1527 - - 2007 + - 2013 file: "crates/napl/src/lsp.rs" lines: whole note: "the language server hover definition references code lens and the diagnostics it publishes from reference staleness over the mapping" - promptLines: - 1527 - - 2007 + - 2013 file: "crates/napl/src/maintenance.rs" lines: whole note: "gc fsck and restore, where reachable means the transitive closure from every named head through every event and every hash edge any reachable object carries, so an edge gc does not understand is a reason to keep and a store healthy before a sweep is healthy after it" - promptLines: - 1527 - - 2007 + - 2013 file: "crates/napl/src/mv.rs" lines: whole note: "relocation as a semantic operation re-rooting a module through the guarded path" - promptLines: - 1527 - - 2007 + - 2013 file: "crates/napl/src/porcelain.rs" lines: whole note: "the observational porcelain status log show diff blame plus the maintenance verbs, where the plan labels every projection path tool-derived and names one concrete legal first output, show lists a transition's reference entries inline exactly as it lists notes, blame skips a transition that names no new state because a removal records an absence, and the automatic repack reports what it swept beside what it packed and, when another repack holds the objects lock, names the contention in the payload rather than leaving an operator to read silence" - promptLines: - 1527 - - 2007 + - 2013 file: "crates/napl/src/textdiff.rs" lines: whole note: "diff renders drift as a canonical byte-stable patch" - promptLines: - - 2008 - - 2042 + - 2014 + - 2048 file: "crates/napl/src/canon.rs" lines: whole note: "the determinism contract canonical bytes and byte-pinned serialization" diff --git a/.napl/store/HEAD b/.napl/store/HEAD index e5ed3b4f..f6f36d40 100644 --- a/.napl/store/HEAD +++ b/.napl/store/HEAD @@ -1 +1 @@ -8e90fde170fdfbdb66e60ba86860b0542b2f64e24aa2d3a2c3c68c00d416778a +8c76aa2f987c521bc0946e50fed9e3be0aa82552f40164f53340d4e22c7d034f diff --git a/.napl/store/objects/pack/pack-38ee1e7d21312479a2fffe9b2989dcdceda176b846a90169d3fe4b3daa94dc00.idx b/.napl/store/objects/pack/pack-38ee1e7d21312479a2fffe9b2989dcdceda176b846a90169d3fe4b3daa94dc00.idx deleted file mode 100644 index 4f5bd436..00000000 --- a/.napl/store/objects/pack/pack-38ee1e7d21312479a2fffe9b2989dcdceda176b846a90169d3fe4b3daa94dc00.idx +++ /dev/null @@ -1 +0,0 @@ -{"00096b1da36dcfe77df971b2aa418d8711d62fb1a76f3e26456a36daa48b0f41":[24,4703],"000cffe9c906d9c9596908467a282aac70302a97ec45ffcd35c0fa1371a610a6":[4727,962],"00174165b1b450f65bb824d5199eef437608bd0b6033d640cf758c4192389e8f":[5689,9836],"003dfa7b8045ce14ca57c31215434fe8034f78efa7c2f6660e635ae4dec26152":[15525,2679],"003ff2e611d823369c217a8516567db73b36275c47eb1567db203fc8271dab8c":[18204,4387],"004169b2891eccf80efc107d5fdca1836dedf116b1bed2892ef7df8609cf2fad":[22591,698],"00683c0cf523fa455cfe23fafc04aa03bb8f405053d46d9f3c9d3cdc4dba2287":[23289,153],"00b471b31107981f48c6968244d3b44ef2f290d9b1e82004e41a788edde89df3":[23442,182],"00cdb80540048e9d0f724db2c204b96824cf086e1b420ddd4f4bb7e8392738ac":[23624,5780],"0126d87dc0868061874b660c05239142e08adbd465e04c37bbb6bd0373c34810":[29404,159],"019ebb86cfd22853f06d4a6e2b72d706673ea15197dc4df4dc6814295d030af7":[29563,4578],"01ca35a882d63c2d2509a7c0890b0c588a743757cc873b5814e57d275dee997e":[34141,666],"01ddb503a74ffeb3b068cd1ec996f1c6045fb5ff725993c303552ce20b3f4c3a":[34807,590],"02051dd93f1be5de0dcfc96ef2d9c28c8bd05c00d65c9d9b3ffca1a3fa2cf893":[35397,593],"0207e2dc62438497e50c98959a61a632ba40e7aa39965acb895da931f1671948":[35990,4866],"02292969e5eb3209dc6de000dc41717c2727ce1c6e7ffaf45f45c6d55d607758":[40856,80987],"025f8c6c51fda9f50332661abb42819c70425b34766f917e04168b78bfb44250":[121843,1307],"02d2ea0745e17a3fdc17f2657ee532dfb748423bced9c1302e3178f45dfab10a":[123150,4871],"02e254acd53d8ca4fc0546f450fccf998781e1c0e4e175d1d3915dbeecd8903a":[128021,1178],"0340a5f98d50aa16b99442f13842884d896cbf776a4253b69e6533d599724e89":[129199,153],"034d55b362bdce1a9553163c4da0dafb52b55674d579314f9f1b4c23d2516574":[129352,610021],"038e11125282e8ab61efd9329fbed787ba6109879f27c796cc31e75b61369f73":[739373,1475],"03ae2f50f83057626789dcd81f69f677d91867f8de919452b821bccff80378aa":[740848,918],"03fe3f5e4a91808e19dc90caa8acfd9bae64b394ae1037e7b4629333e3973196":[741766,277],"0419c7b10b1f868ce08327c9100b6f200051b35b536189612545e65314bdff2e":[742043,1797],"0479a5be6a51a7191b47168b7f5bbb14ac2168809a25f33b3135dd11d0882a37":[743840,824],"0488bd1ad406ad2da40f6d1b0c87ff1b7a1410275cca5d9a346ba242b76eb74d":[744664,182],"048d6d8df29595dbe6d7db215483a19c43b7554febe021b28c9cd4f9bf1c9551":[744846,962],"04a648a127be983c89a99181ee26e28401ee00f84ea0b10b6c050ecd263a1b2c":[745808,20701],"0508c952df26ea2ffe5ab27a5dafc05d843d76948f77370745ec28cdba6d5260":[766509,74524],"054396979714b8dcccd39d588482285738eccce7b57c8971fa6cb0f050b33094":[841033,29352],"058597fd336d6eb0cff43ce21c1f5cbcab4e9732e2df7e9d12e4925618ec1d34":[870385,155],"05a97eed911395990a830c49a08c05077521b13d76b904a26136403769c0da75":[870540,22757],"05b37a6e9c078e2fec2fe0c0ae3d24e9117c25e7557f1317b4b133ca7d1f90d5":[893297,2377],"05e2486c9b7f5ca45e052cfda14cbb9d513d75f06b28e72c5c1f13640e4d376f":[895674,1937],"05f44ac11e9808b57f775c0ffedd617fc59c8a13cef9839ed551a535e36b7148":[897611,155],"0603270836a7ddcfdc6d233149b2564094a9bc3226aa11f996b9d182681a68a5":[897766,922],"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6":[898688,1129],"061f8a1ca4ec539e4a8e0bbd659c7c32659393284f11e3ff35ccf68d7082cde2":[899817,922],"063b97dde6290a3340bde25e9f97c01e0c3a823df834ab96a5f3a23efbb4c156":[900739,54695],"0662f8e8e279bcfa220840ab1933b3f473b1ba338d1f72ad99927819734f12c2":[955434,841],"0671b9a60e3cd8c1f49c921433447a199ee383c6d357a1cbac9bc971d30d001d":[956275,3894],"0683a2f5623022e219ad446de610a80a5b77956fa5d2bf9ea9df38037f9bdae4":[960169,1283],"069c2728f8a4fd65fe20f47e6a3d13c0ed56b7f45f47f325935ee81b346fed45":[961452,182],"06ba48400af2d528a6d3e6ca2c0c71f1f20d3cf4aa7b44d600fe18e7a2738fc8":[961634,18272],"06e076725f8e997478f279e24eaa7ed1c09d8fb7765ec4f5aa4f783e35fdbd3b":[979906,590],"06e3f065a550fb0b90ed404cde31ffd42855cc924ac38fd493961034ad99ae9e":[980496,808],"06ea8b59aa2e892091970f13eb8cb88f621256b3f5ec25cc90edb2927ebf723f":[981304,31060],"071229fbf6f104ee3bf5ff0bdc708b625f6bd0010c0901cb8b8db98dc169b165":[1012364,182],"0712f884eca0556804578624ad3bac3a484c701241fc2b7655ab9c54049bc30a":[1012546,922],"0733317c8f30d785be80fae7a227e8fbc09360a50e084afc86d2c6771f9c135c":[1013468,2984],"074f56c36ff545aee1feaf90d675f2ae36927525ee65ae964e75a52fa364a139":[1016452,841],"074f79d004b82eddbcb9baa87700dbf0cb5ae826f94c1d886dceaa4ec79e23d8":[1017293,13022],"076a47507f5d0a1fc1675d13cbe2157f9d22794e566253d738fb1ab08f5aa63c":[1030315,81220],"0783c8f36f80781687e35e71a3d2318128af9d29ff9ac0343a8b301891a8d0af":[1111535,989],"07a7c9cb2d830899137a1e048d5fe1b503b384ecb04a75f0a6f7d84d8edac986":[1112524,590],"07b33cbb1d11aaee5822d3f5b943eaab8474f95eca7ef1387bf03a386c453849":[1113114,729],"07bbff14dd05de911eb6c74a1fe52dc711c2d95c9759b1f093593b2e7d4e518e":[1113843,904],"07c940ba4b8297935f1bdad7898d0317e140ed38548d190ac9df34887332899e":[1114747,106010],"080757642d90307690a874caa9a1aae6390859725056300b75f44472cdfdef69":[1220757,3978],"085835ac5b8d823bfbd272d83c7475dc21658f11b634129df0abce0174d5dce0":[1224735,902],"085f7e2d366c1756ff8f4e7bfdccbb8139c49779fd8ab00f91e3d6c787569c23":[1225637,36288],"0894f6a4f01379c3dab9a8bf8e5a53c1fa81d39e1e11f8ad10a624eedaec736e":[1261925,155],"089dfbcd5bef3850c537ede919fc6741a53ec1066060ffc198c7faa192d51c49":[1262080,841],"08c3fdd38a3617c060b1242701f43bdb1b152d1334af774ada74000338272a54":[1262921,13316],"091b68f54eda0bf34578932ce3448fa14951dae141d5ad5eec3b277e877db56e":[1276237,922],"09365b85316ccca597ade8729dd287cb5c193cbb40bd38e35e66e60d45924ef0":[1277159,152],"0946e8b9e45a8056927d3d2332812dc5e7f0fd1a789fc5374866798da6189a82":[1277311,182],"09505a62339f70c9c9482cd29727f2390ffc80438c762f776fee236e39ddca82":[1277493,922],"0994c1ef586f7d5255edfec72258723b0569bede2d73b5421481d9928069faff":[1278415,155],"0a124edcb870b6922f6bea894b96b1dc7c3d56ef2f0b16175252c0450351ce22":[1278570,6332],"0a53f13c5e29172205697a166ba275f3517875ed8eef390067a8bd98659f5991":[1284902,2489],"0a628f0bdefc7b4301b984613f9c9f1a06baca75e8dbc2c4a055141fa4b48b74":[1287391,152],"0a742d0232e4b7000bd79ff29b39af501675254174d2c98163f8b7337cf7f0b7":[1287543,1805],"0a757fac5d572e4fc554b6ff0d3fe77b65b01622f0b7cc503ea93f84880a5633":[1289348,182],"0a9b80114c0bd6d0b4e6a7bb9fc251ae3e2af773b5cbf313cc1e8c5e86321568":[1289530,153],"0adaa7a3a40693f6d0850446b7802cdb832adad923d7738f2f1bd8017eaa9042":[1289683,155],"0ae1392435259b1f4298bb44f0e8d081876288581db1671d6eb6bdffecf851ac":[1289838,2377],"0ae54b37fa83b04b9917ee181c086a761acc7556d980686be4e8e10b3e77d803":[1292215,182],"0ae99e4bc95cfbe4b5544b5b4214abe60c8502801b7367eeb52e1f3146ae21d9":[1292397,46908],"0afee74fa07030ba25ae0ad8141b04cec7380af3d20977b6c9bc60a876ef303a":[1339305,1458],"0b1977de1e02cb1bdab8e3e3b7110aa2ae4e06b37f5b08757c4f437510fde386":[1340763,277],"0b443cd4703dc57218f7c2858b1a66913833e5d99d02310bc30f26373a77c434":[1341040,27809],"0b48a7281fe754ac52eb28ef5baccee30bba16601ae3bf047ef2540475c9803c":[1368849,2599],"0b67041f4bf73a1d6532d8bcf4f3728c909c452431600101e7e3dd1a1bc51ead":[1371448,808],"0b71431883adea0e4f3dc3a77b968b69ac4f18348387362cd3c48fbc023d8bb4":[1372256,3619],"0b8190e7989129e7baf4eed7046e19b7608067eb5d13bed95ad416118bf34a25":[1375875,35693],"0b97a1aa726215eab3402f8d7d6c5def336381ee6897d298692a5fa19699fc51":[1411568,729],"0bae24f716e10910ab80aa7b128b67c7c57a6df7cd2c92daf447a09bd7d2dc41":[1412297,1155],"0bfaac92fd735f0afe003a99021211a8154b66123c3dc7d07ba882e48ba64bdd":[1413452,21308],"0c05e20a61e945e0cf7a4bc43dda9538a5f221203412bc20276aab2c1da42bd1":[1434760,2491],"0c397805b455c1d3be81fdf03a21c4fdce97d5226b673c2b23efc2b33dd4a90e":[1437251,155],"0c3ac12916df4a4bc62aafdcd4e9707c3e28239277df3a1e02293df663420336":[1437406,278],"0c81050701bdfb6262bfdc66d55595f24ee77c14201c90b66cf895fba273892a":[1437684,182],"0c982b0c4e8e2c82ca6bcd45f953364d7b638713d68bd6db67288f6b48c8e5b7":[1437866,155],"0cb51b8d14efa25b6444cdaee8b22e9611ce56bc1b9c20683d430590167c803f":[1438021,9417],"0cc9f6deb3819268377d5cd3b50b42adc297a013a9c8c9dcf5c77eebc0cd0f8e":[1447438,9382],"0ccfd4213b325ead9e7de194b86e32322a281d222b27a5af1f2cd57db8f49bde":[1456820,11613],"0d1617c7565e6590c54408b00caf1d696fb8740a3d996c151b98b01dbf3181a6":[1468433,153],"0d9c017cf2140f1b865bfc058e6ab2b23f1fadfbc6873e57a9fe804a9d4c604f":[1468586,30841],"0d9de5138a0e51434c732db16fde21ad6ecfb1f4b0f8fb2789c8b538784b1bda":[1499427,666],"0db6b5f037afd93d29ab26e91c9295b6ab53fd90f9a345680135a58696262a11":[1500093,970],"0deb3e918644f69c5dd0dfb1434002e896b38efddd4aabc76a778fe02a06b704":[1501063,13581],"0e272939e7a863bbee54ef712788b44f60c1a6ec306a5e23530dd2cdff0a6c12":[1514644,922],"0e979f14174777d495141f1638be430cd20960c15eeebc27a1889a562c771dab":[1515566,2857],"0eea5540821dae7e1b0bfac2288b074227c4d4333a6c8cb2cd2fd86f2f15e70f":[1518423,32151],"0ef99195476c7251619345064e2dc74fa005197438592370d03b0b5e5a688b55":[1550574,665],"0f2e50ef0121924926bdadaa29157d5abb2159c2308a44edb3e3e8fde77fbc0c":[1551239,31665],"0f3756f4e4507027e1ed76f9e0ef890738b3fe60b53eae65468551943cb63929":[1582904,1101],"0fd40dc47b12d4aa203037436f4d970fdd57723de1a2d331a462f4db8a84143e":[1584005,918],"0fed7b302648aeb697d047c52d0787b0a80a5fee7c8f5d72ea9397c906e42105":[1584923,841],"100066c55f52ebfce2ff78594c75bc2c1300b4b20be9a47c49a6a6ec1c02907b":[1585764,582],"102aca285c1d51a4330e7ab8dade561f303b3b049983b884615e7288934654bf":[1586346,153],"108d835a43c85ff63d216396de96d6475cb4f5859b85454bff142a1c47d0c589":[1586499,25090],"10a367a3bd7a952c8c7c6b52c5a3b88628c9bfd74187380c766a75fa8b1af753":[1611589,44467],"10df13f5214ccfa57f086032061ff35bf445a9b2da972f3d7d99016eb2d83199":[1656056,593],"10e6ecbb2cf94bd488d9b800284ed2e991d1f6ee5ab3156d8b8d6a711e784c87":[1656649,155],"10f2dab5f5f728c46c2288ca139961bb2c79a9d3a6c3285dbe5f5b0da0c65a89":[1656804,182],"110d3387047f4309d3538e36fea44506dad2b7092f69a3228d6ae3ace6432593":[1656986,530],"11275f041fe929c8f9f3ddc3f9536871ea1a3c4f4cabd66c39bd48ea8a619205":[1657516,16234],"112df64307c695d6e80359ccbbcf9100579692216d90fd08be281e7c82c07574":[1673750,666],"114a8363fdc58dd405f84a29842009214d6e61255cd78338358778f278e935f8":[1674416,80081],"11998c72fb387d68e04513ed35d6d2e673cc7e5e7832f6f294e340b56fc1968f":[1754497,582],"11b47daa7b6384032d19bfbfb73f0c64a5903cf25a7e2117cc6555e32ed26c57":[1755079,3224],"11cedb6c47fd6b9324becafe20fd7481501edf2acd0629b2703c570975a699ab":[1758303,661],"11fda949115e3dc730289ef1cfb394839bff61d0a86c39a7dac3901df3a50b56":[1758964,1033],"1201bc9b06d4a70ba97a8b3548827f31d419f3cda471bb42793c4c9e143c9888":[1759997,409],"12112f11e38294a5f7198f720981c24e3333f262bbfa971383f8c9b0119d932b":[1760406,988],"12166b5fd9554e4781dd2efb828ac24b17228fab1a08ad7df51069e20f8efb0f":[1761394,48881],"1220d3a189dd0d0afaae8e9abeba1164ac7a6ad300f49ac9bbffbd8e34d115f4":[1810275,24272],"122f4c73e2da50e064556b961af451cd327f27c22ef3463de797461df18af6f1":[1834547,152],"123f84b63a7115f4cd5b6ec7f1914827a2a01a529ef8525b6193881bacec6c1a":[1834699,182],"1243a30383b122b303ec2bae058167b2541d8b9805b3eab569011d2cf15ad607":[1834881,595],"1245a0fa562eed3fdf083f3e1a0bbc8ff97f7cf562da009c8a7a7cd101bf6963":[1835476,922],"124ed5713880d0384df7559ae31df44e93a52c0e4db92c6f5cac91f6fc86a5c9":[1836398,182],"127285deaacf052e180769f0b6ce6ac03d3faf777d6ae7aa18e4cd77d6f95fe6":[1836580,589],"1275cb6426b796f0a3c417a683db7cd640e80d2623819c39c102a47c2b53245a":[1837169,872],"12b15414365406659b76de146e724018ba993d4dbe050a13e630080c33618140":[1838041,3479],"12c16b47d6c11312d275a81a2890f4f30d72955e8a7f5210dc871bdf022bdab1":[1841520,590],"130234ef88f4edad8a5dff4feec7d2e004274965f1fda3be64919b36f0439e8d":[1842110,1797],"13217685058c60796e4b9eaa2e5c51dea475150e6aba30df00ce6f7f5221a9ec":[1843907,152],"1349bace91a76b8363dbb9a49aa8453021c24e92e890979cb751f91544891f99":[1844059,152],"13d3a998548340de5e83ea0e546213b0c7682fa6cd0f09c7ca24fee62fcd93d3":[1844211,155],"1408b31febc029dea450f094b33a469c7e48326fab4011b229adad43c44fa5db":[1844366,182],"140e3cc084e292a68b31c833a0b59f371539d73a6ea459a64b40ee2b2515ded9":[1844548,902],"14555e6e5bd987bf718c8972b6feedb898fc7e3dcad1c29ea36aba10edd91be5":[1845450,4944],"145d84afd1892b41d64f472a44ed100f908d8255144a8c7ba0335159325e56d5":[1850394,665],"1462afe206e4e726d5d4c290a540088aca451a8561a0a9f3f4ad1cd26cc7ba7c":[1851059,1937],"146da17e738b7cbb53594172251cf63aaaf368e85b37bb1157d635411988e403":[1852996,278],"14768a9b96b8dd25e1efa36ddad54dd48c5feab01b57a4be8883f7ce8b524eb8":[1853274,5724],"147acc4ef08e492359e839d3e850644590dcf51fe4a3600c2af6f2ad406a8a40":[1858998,3869],"1481e8baf23521c6a5a3cc57943beb97ae15afc539d6350940b9296c741a7bca":[1862867,26810],"14a676ac3f4385d126255771acbaf09ebe129363b5ec8d4e59ab5083548ca581":[1889677,153],"14b41f0f24375744aa018236c3a0f448ec928d00bc05fa6806e6982b063b410b":[1889830,182],"14b8691cba402cd26123fe68b1e58f819c1952d2afa7bb37be393d1d084353c8":[1890012,7828],"14ff0479bb3f853bf2112803d70270a067efe3284125166203ab87d2106afa05":[1897840,152],"1506a85bc6ef2b6f1cc8426700959b114ea5532cd02cf43e57d2a82ab7b4fb8a":[1897992,595],"151b12d2bd41a7d935f2b0b874a7f3ade8eaf6e4f0685f587a9aaa9c5e332571":[1898587,841],"155528aea66782fbd67555be27e392c8cee0d0ce7022ec10eecb2f39cb50ecf4":[1899428,182],"156ab704b4d1d385105fd45dfe9ffa5af5cdbf7344eac0c70c3c4d26b6583167":[1899610,7070],"156f98abe07108226a3ddf02593af477a2ea84b119a13d8761551ca8c7e9e1ab":[1906680,1910],"1581007c655c52f2757975b1df927d00af92fd7d8ffdf8fb86c1a1ac4017bfa8":[1908590,530],"15ce7fcc3a890869080c340a7b701d6a0a2ff47f1511f9dd5ce07b6297140be2":[1909120,11027],"16022ce4ca0f16dbdca6546700a2348fe4f7e66448351a8a0e29dbe532eec294":[1920147,28730],"16755422638e33f0263ecef9dafe44779f2e61c21159b5648a0ec2b1b93d1310":[1948877,12504],"167749ecbb655a214510c849c92be88cb536cc7f57a330d21b0314780457b0a1":[1961381,21093],"16a45516f47b798c4962802561fa5d6ca8191c629845e747f946ae99328482e7":[1982474,15395],"171347ae89a0f8568711ccc55fa323b54f931ef8fa11d19212757f34c3ab865d":[1997869,153],"1777b1d905b34f7277c66dde1ec4d78dd48972fdde8c00ab2cfc289a5dd80cc5":[1998022,155],"17783b48fe0888a535aed30a280cae63ba8d0559f202b60a1b386ab438c7c982":[1998177,12373],"17a29332c8d6f136c791734e08866d894949c4ce533c1929a5584b352f528408":[2010550,153],"17ebcc121b23401d4580f3a6a36b49c11ae698c382eaeae3221d4bb5b7a05def":[2010703,182],"17ed1dc53d580a008dda11cb6e942898107713d57a919e7f9a9283660ba6982c":[2010885,530],"1806278892f55df111c941f3b5769c8e4d1837932f7baf373b42b48976b98174":[2011415,5746],"1820f647bb5dbd74c8db7f5519d404ef0ce1520c833a579aaca893b5cad7ba08":[2017161,32125],"18271374c88f2f8a91d1f43e020fba6462d9e55409dfc71cac4a6649245dad71":[2049286,155],"185d9a1825d2467ff626fbfc7bdf7ab588c44f793cca3702784eb1e6d356c031":[2049441,4406],"1867c9ffde2d11725fb957b113689035085c55ef856d494e6347e3d05f75b803":[2053847,729],"1871d435a6fb00aabe7e8d6006b75cbcdfcb55487f4f3ae001295753468868c9":[2054576,970],"1896e0a51af57972a16f38a4576ab92aa13b702c29f1dbc41e716ed47e7c6ecb":[2055546,155],"1898b2bf4f51856fee06982feccd96667b7aa50a76871e054f741e8c4e2d34f0":[2055701,902],"18b5c41016570bfea295efa37a4905ee4e8037ddd7a7791a516a10a2cd57ca91":[2056603,4703],"18e09d803f8ee40503ae1b61169f47555f69c2ce8177d203b0f3e97c29be25c1":[2061306,244],"1909cb3276e2bf38ad88a7d563d53e4defba8c3d417354233dee803a23dc1f7b":[2061550,182],"1928a415a91d1e20984e5e753011cc82bf0a1438ae2b96899930801f828289f5":[2061732,2333],"1947cc58da81461c77f0cdeb07aa08507c1220af44a26cfd308d2f251cfe124f":[2064065,935],"1956f111d43045440859d7c57671e90970af57d99e55280e80ee66a347293147":[2065000,4578],"1974c1f6b4111b2a851edbc26ec3dc47fddfde41e65cb2bb2ae471e91b621849":[2069578,589],"197fc31f98d770e101c9aca800812e76d89d1c8af77ee1a2443080cf94a3ed48":[2070167,922],"1983d2bf91bbcc59706fd11a0609f56551072e728b90e5db9dc8e7b19bcb0a8f":[2071089,22888],"19842377a5884b5b4dc94a7445d915f6aa844478502b531010361228d326a2ed":[2093977,83912],"19d6b37fb117a87453cb5a44abc7d1a25c49c6eb8129289cacace49a08429f58":[2177889,922],"19f4168a27ef3d98ecf9780d65c22990ad51ba16572473ef8de3f0b894cef777":[2178811,2377],"1a3eb8a85e1d9905dd35f074dace13f08ddcd15c4b345c449bd73143ea71fb03":[2181188,17190],"1a41ed979430da7694d9e4d22bdaec2807267edc8a603bcb17cc0c9513226f7a":[2198378,472],"1a5de49301e4c6d47619ec0e44eab914b1846735830b07483c031e6a8c3dc67b":[2198850,49799],"1a7d9717858433611af1408c35494fb4e9fe642c97f00baa0b183bc1eed609c7":[2248649,904],"1a90570994829d35b84b3d0afb4c1add8c874b698abe4ebb9acdb218ecf30c77":[2249553,841],"1aaaedc7aa9496f141a027bafb8445988f2d2d0bb6f35f750eede0ed8e88a72b":[2250394,152],"1ab67315130550b27f4b8eee050e771e8db747003656263c2808a46a10543e5a":[2250546,155],"1aecc859ca75cf20491dbeefe86cb7860a19694ebad81f15251088092028d199":[2250701,808],"1b2d2993c56d677c075155e52f044fbd0a82656c173256464968b0a6ff62cce6":[2251509,159],"1bbb864c8af2f143baea3f07bb11ecf52771f0107db428edafe9476dd401d187":[2251668,1155],"1bdfb8de4cdb3f230bfaac9c3e24ab6005ff73a1df56b08e7b1530ac43049009":[2252823,182],"1be861f1957f3d7279e9bdab5250aed28818832abe1508bc2854cf04222cfb23":[2253005,4703],"1bef0976e67d9c88366a636f94d1f838e7eed135831902d3b0f82cd98ac99f4b":[2257708,155],"1bf5f8faf7324981cb7a48d8ce322ae402a5c7ab510cb1312ab41fde203df11e":[2257863,152],"1c0c279cfb6be52934cc4341ac294d868e17aeac08d2f15b3dc63d6376192520":[2258015,155],"1c2950ad30050bd50563a0a2c749bc5432d1ab6fe17b1af403a3a1ecc9609fe0":[2258170,1307],"1c67b12e1b8b26fe646dee9d76fc02a892715d97a14fc819107a1177defc5f84":[2259477,1937],"1c7c754a9870be661e3ada83f5192a6c7843c671e55c36d53baa5ec9279643f9":[2261414,665],"1c8c728679cb8198348a993eb0d305dd1c38a46fdd43eece16988317e7b8422a":[2262079,20663],"1c9027a76e242965501c9d639d69099add69b059b264ef6b815bf03630443f30":[2282742,589],"1ce7e989784003841525cf8232e846bf082760e578a9ce7c743b344ab7e3953f":[2283331,155],"1d363c106d7c95e8eb6a65a51bc01cbacc3fe7db3a98917f01f7e7c60288db9d":[2283486,182],"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c":[2283668,435],"1d93e039abeb6a0ff6dfcbc1a0b7cf5f034a0d040115ea5d09ae1a89051eb978":[2284103,7016],"1d97df9c83911b518441c83d48706f67c21b5e4573a828c05a4c469c1de2a089":[2291119,33531],"1de719dc1404ec3b290ad06d820a1617ed3f78aae2c1baa54d1a2ca1c22858ad":[2324650,152],"1dec6b9fe622b28f871dae12c000036fd0604aef104c3fdcf95f38e1edcb3733":[2324802,8596],"1e45ae83e11feebc35bb9bf3255e931fb70e54e12c268d7231b4e1dee1df278e":[2333398,1283],"1e5aa3b47af1025e6ce688e131390b96f008c1ab4810f1068f23de1a96d2cf69":[2334681,3720],"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7":[2338401,14854],"1eab4fcec1746de996240b145f35707eeea8526ddffcfbe0afa5c7ab0f1da3cc":[2353255,26061],"1f53a2b74964af97e58ff6a531ae1c218c3d35d5c8af54f10be8854256644d7f":[2379316,1069],"1f662ac905f485e56b796fadeab41facb08ad03f1099230f43e23f529db1d81e":[2380385,670],"1f70489bb644bef7e033b0d1c58f888dde57e9874e3045580feee00633ec748b":[2381055,40217],"1f9bac1c043e5ab12e9c972dc7a10c527295b4816dab4592aed52c76b4c23e47":[2421272,962],"1fb2b7cf4a7ce32fbde1e9defb66cd94e1f236a251319dc325521dbc23b88d8b":[2422234,1033],"1fbb16a66bf359bd7194dcdb0b4cef9765c236d42c8288203e5ca62e2b53ff9f":[2423267,5780],"1fbcc29c397ff1896fd000eb2f9c9a1c511d549ba5ff8a4e9a6aec3c24962292":[2429047,35191],"1fcb86a59eea340c2f7a89a1e3c20800e3835113a8ac20b20871373803dd04d7":[2464238,155],"1fcd217651dafda58033d28735afb1c39dc5c2c4e23344beee26e308b5f773e0":[2464393,841],"1fcf234b466da231e6c4677a0a8fe5f8aecc43e2e41f3311bd024ca511c8cd8b":[2465234,589],"1fde8b4853de481fa49af30a9b0efe8dbb5536428e236c49a46ea94feb3c0628":[2465823,1178],"200a53c30509d69172c04ba2d00cf8d35c35a6e31bdd402e7218cf6da0d73cf8":[2467001,17967],"204a4adb44da6c09cd66887596baf423a9e81cbbf5e3dac02d8a80ba21e0a7a1":[2484968,11488],"208ecdf81d30781a72b8494907b681c1e164d3a9cd43ed9668192754fa0ba46e":[2496456,962],"20b2e6829a8ebdfd38ef09505a5a4f978dd5c83f40711ec1cd09bfbd0ecc4372":[2497418,182],"20b515293b4ef553a1a9a8050fe899858420ea8314a0a8fa403de17b79788eef":[2497600,105701],"20c7ee6abd1773105f29445f2b57801e522375285d592d91fa2563d6f024b9b9":[2603301,100756],"20ca78370f49d639d5dabbf8358ff0623d6c1302f6c0f52779341dd0bb9814cd":[2704057,2117],"20d9c2f99f1638ae66357c19e2e437cd2d4c2741cc7698a87bea76bb08104f5b":[2706174,2377],"20f58bbd341c207f90b29e67468b38a9115a0e2d9937d095a1313c88d4876d9b":[2708551,486231],"2103641a673aa4715c8088cc96c75df6c0c67bef599d2ee8c78a3dc851635cad":[3194782,182],"2103a2f9f3054ea0913e16c84c302e0a8869263533ecf7a91204288feb146d85":[3194964,63812],"21542fad1b2350f252b3d3f63d450da0f83e390eb02c4ec1546c7febcdbe1c9b":[3258776,1178],"21631447bbac552e78b823a151bc6b9b6132a82db42e636c7befdfc081f3ed2d":[3259954,589],"216aced8afe5e06450884c404ddbc48e5c5a88bf50ebfe755f7126403784f175":[3260543,582],"218df1e9210c1005c21244fa59c8fc4b8c40de0dcec17e35a33f32cc1cf25018":[3261125,698],"21964b817f6ebe4d1ff2a798b40b824dd21cac84149d6e06ab89fa9061f57655":[3261823,593],"21aab4c73cda415373ffe323d94339059df2f336712a901c2dee766012d0115c":[3262416,4866],"220528417ab84fb68a3b21eef82fb259a47e8c9c0b5552e216260bfde8da0cc5":[3267282,182],"223dba28d5122c5397afe0db331a37630311bfbca95bb1fb77eff39d8580fbbd":[3267464,904],"224143f43c677543d244eb6821d5bc0db64b646a45ac305ca21d7d5bfafa9b3b":[3268368,922],"226d2a965911245d7b23dc6e878c6c444e53ba52e5b84ec4a69efb81bcd1c181":[3269290,19814],"2297837c4e3941e251dee4cd7ad9c53759fcc8c961ceb362926b8318cc485e03":[3289104,153],"22fc2724280f6c915aec2960ad3925621cc0ff8d118e5d09ba3db318fa507b4c":[3289257,182],"23047facf0e4f522af446d5b1947d683c2248f6d1075f8ff6034c407fe597095":[3289439,58522],"2307ccec6af2c2087ef769a86cf51a8f2784d06ed3a14229f8d7228d7909a8af":[3347961,9021],"233ec02bee819fb1ddd577138385455eec538c4a4159d73183c77c0df7b361c6":[3356982,872],"2354c58048f64831cc4e160fab474657cd729c89a21d81320e7f221d3b1bc127":[3357854,593],"238c18b92e31b06a4562f1edc779047d71b85249c7bfe8ef8d86b3bbc928e903":[3358447,403],"238ef6825ee18fff56a4c086d41e1460c2e47cce51bdf7158ca93165f041e75a":[3358850,5067],"23a0361da2d7d67e620a6f21bd4a4446f81454038931740efdf679acf7b020c3":[3363917,59315],"23ca12927b61f5ebfffdad8841c5d0794acbced0f8444d1866d3d9e68814f28f":[3423232,661],"240c925c4c35e40420a6cdd8bc141531bbffa9c832d330b660bca27d365db8b9":[3423893,182],"24eab2760e3f8503a1354d17ba6bf6fc7eaed527f59f787c602083ef989fb028":[3424075,14291],"2507f26208d4c6efc71f3506a5ee94e70e8a69e9941137abad82037fdd5fb8f2":[3438366,182],"251e929b559b13c10ed81879a0c554baf0e7e6358f744bbc556a4f0a4c6ba4b1":[3438548,3024],"25259d5a35f16fb807d6e38756d9363ca351b667985cdaa8b1e17cbf584fa9fe":[3441572,153],"25843e4601273b7d674393f5fbe7267f518253b3bbea419aa7f1d41c4b42fc74":[3441725,666],"25ae6ee7a09f85f547b15a7d203b8c0594a4a4c5e03174a51f2d1b25fd5bede4":[3442391,182],"265cf12ba8ee56dfed0bd3583bfb13ac6a3ebe5a94b9fd18f8d61f3c77f79d63":[3442573,904],"26ce34d11ec46e08675dc1ccf8e2d1d508c1d9cd2f6babdcd0acceec21363ecd":[3443477,1060],"26e26696fa18accc362da9494d522e4ab3f3e70babc7ad776335ce8125582ef8":[3444537,590],"271b52c2ec09469469e510d0c21be9948e7132d5be7f7776356dccf6a4647ef3":[3445127,12849],"273d4e3a2810f5e328772aff4f262b228dcdae379b229e47daab5fe9b46c9f74":[3457976,4640],"274c01389555494a1d4e8f0b28bb304b3410b6ec9d27ad3fdb94577dd28ff636":[3462616,3197],"275af46b02c655934cdf8daf15ef060ab6e8c992dfb4231c69fbdb365f4a5fc4":[3465813,3481],"279b09a95275fcdffed7d04f7abeac151929523eeb3f1773ca01a4b042570dc9":[3469294,4455],"27b0346e3af4ec0f518188cbb76703ea8a26bfc2820b57da4d2525ff420db4f6":[3473749,153],"27c6b4bcf3e99119bed53d3b72df5de1db1d04247d2d78363138b8b9f209e617":[3473902,666],"27dfbdd69eb22c92abd15f1328d41164a9b211a10e8bc8ca46d40466a390953a":[3474568,841],"27f99f77efba2e01099146445e937bc1bb78c7b1d7977be96c072fd5c95b88c3":[3475409,182],"28059a89256aad1de78ecd31d7b77cae21772bbc6e96fe8ba1cdb9044048f410":[3475591,7603],"281071fa8c7d2d44b3b569e99c71d7e5a13488af033e2267eb7afea1685811b9":[3483194,1458],"28143b59f55f809bd2c51eb77d4ae2f50451f9d629da80f38d1e6e684f297338":[3484652,5214],"284bd9b55a9f7d63a3999072f3104abe2071629417dc7d95f121a3d11e9c1211":[3489866,152],"288d3818abfe76a26efcbfac1191a77c4a9a7ea34821261d9f61e65c5d9c5ee1":[3490018,1906],"28adf2e72a47aeff8cbfb4b8b863ba339fa27a5c5e5e9de456c8d91e396cab2a":[3491924,155],"28bccc83435bfa1404f7092fc174d909c1b332197b757fed91fbe3f53260fb18":[3492079,530],"28cbfe345445aebe451c9f97d60fba062e914d95a137c8fa070600ccb83cb111":[3492609,2377],"28d508ecf0b691ea42b07250b0ee9e48a9eab5c25860929375850b19ab090fc9":[3494986,152],"29707d3a43418a68ac08aba457d9f226c9de1b0dc45fcef084243d43d02d351b":[3495138,155],"2975b666a1e14396e50d46ef6f7acd23e7f93c5dd5abb07f8138f14b557a4d68":[3495293,153],"2976e06095e0f6120f2d54537fa703810311f7596e2a820ae0ae9ea8819e48b4":[3495446,152],"299eaacb181cfd4fbc0c5f62d170d0dc392ac5ee30f097ef5b5cedd7d59b869c":[3495598,100710],"29a9fb29de0d60356c28e3837acf39cbe484997e37664c2d3c421e984bc54b23":[3596308,922],"29bc2c2d393d06490ddc3b24eb27a3a149e100eb5d4db00d0ef5f911054c89ed":[3597230,155],"2a09b4a4d54d80bf6ab4bc4b808859f549c599685946b713a32caf21f0d68192":[3597385,1990],"2a30b011aabee6f0df8b85ce2488e2b2d852cf348ce6b18300e6b417d7d330db":[3599375,904],"2a345a96a9b632ddfb78cf17b496cb7d6fe2e226a0c3d1ad129a9a3c20820ac9":[3600279,155],"2a8ec239d786d46d6bc7953121850de4aea1db75f15f4b5660469bb86eef5417":[3600434,657],"2adf2cf15d8fadd7bc90e420ce19eecfed7608f3e6c5c59e063ef7a8297d768d":[3601091,698],"2ae6798de316c0329c0065549e004404e7e8210de385fe846469e40095fa3a31":[3601789,1817],"2aeba094700b60cf2ab6e0c88dd79228028a72f6b9b0e2da489dce572f2d2641":[3603606,8646],"2b0062c307d06bcfddea5c54875369dfc5e324298c125879b5598eedc522c57b":[3612252,182],"2b06f37abc3b0f54b2084c11c8ab544f01f4e298e1e905203d80302fb3a50d43":[3612434,582],"2b13d6ec8cbab9f08af736c8bb31ad56ab01429c0b8e775e5301167fa0e77b5c":[3613016,582],"2b3feb80cd701b183f001a4b6848669e0e3999d8f4111519dacb885fe8b5dfea":[3613598,808],"2b575f190d29ea201c4a9bfe15946a09d72779845be078b0164173f9f1cf1973":[3614406,153],"2b720e48dff0ed702a448fc2aeabf816dff20774f62a58d08e877549d3efab39":[3614559,589],"2b721eb1b0e9e2cd16bbe95d61628ccbeb283e5eefafba31c6246748f6de465b":[3615148,182],"2b806ccda147575f59a8fc847b2656b26af400aa3cc7cf8733ae2c5f34ea402c":[3615330,85354],"2bf3758689f32771e9e743efee05f3023fa0f74445bc7b0034abc8ace4bf2262":[3700684,661],"2c1a72aae3bb765a0492e2cfd5889560b185b0f593a02fac544f0c11b3c452d0":[3701345,89467],"2c24abb95918f8bd106460536d1b502075a3e7cd6dcd9bf6e94a7e5d703d1140":[3790812,25287],"2c38197e3585a3cdca90f0eb7b8227aceef3120cf064ec0651f611690c7e3bad":[3816099,729],"2c5288fdade2ae24a0742956900bb4025b342d81fea645e7bab0b0fa4a47377a":[3816828,153],"2c5e5d555ec16edcc0cdbe133b51c09629a8154a714ce75baba59f0f595cca38":[3816981,729],"2c67c4e7d9746584274b1fc7dcd3fec9a9bb837f8bdbca8e0d31ae9c40a11e7e":[3817710,275],"2c69107b7db7bf7c0b074ae598725925b50e349c57779aa5a3f91bcf71a23e92":[3817985,52647],"2c7ac3d8f01c5c4fd114bb758f824ef4e47029fb88fab1390abb6aa550d40a25":[3870632,8134],"2ca2a560a3bc8fad6c3f2ec275c30b75de39c893b96179f9371b7409f5356d8e":[3878766,841],"2cb96cadde609cc1395760def27e5befa4e0c77e227dcb70f05ba732a3d64ea8":[3879607,12724],"2cb9d97b96212f4eb76f98a9cc6f901b2813a3a9482df622cba1c38b62982ae7":[3892331,1937],"2ce2cd607609f26f2d53bd99535985a2d825771ec242500b80d4488de3916e4a":[3894268,935],"2ceea09f82f0700bbe48909b0c56aa0e69cc477dd51287bd88cd2e87f6f7a1e1":[3895203,666],"2cf19fa37976e1e26a35d5a2dffb0a7d1f9bf1a39538078e9f53e88366646f5b":[3895869,1069],"2cfa7807b9e55b5e943987474c43959a77f4da2ca21c6e4454f42ce9fb2988c7":[3896938,59],"2d01e78a0f4c89e52fbd1e0900319698eeb96e01d6743276dbb995b3131c60f3":[3896997,16208],"2d0c694f15a861a6a0c5981b606c2f19e6b2726210249c0329c0ed6a55ef70f8":[3913205,66101],"2d233595d3d6a9d32355c9ae167585c20301454c1895d19ad86f1e68920350fd":[3979306,182],"2d346d7bca5fc245e422350a9a39c1e8e7561455f768a4aee85d9e1fd443154e":[3979488,62568],"2d54b5f1401e42078911c9f0bf9d9cd2043831e1ebbe756ef89a90eb9784cd6a":[4042056,935],"2dcee63f56ac5cb3558a2a7f3fc8604bf61cd545edae29f9d57aed42170ed1b1":[4042991,153],"2ddcb5890375142467f3f4427d25e891dbddd961b809a88d919b5a627f68f605":[4043144,1033],"2e1b675f4a30854a24e637cf09b4e00615240ce52a71872287ce67f9f4d3e9e8":[4044177,182],"2e1fab9d52956d48914d71e78f6f4e283fbc0b2c75f02a345c6b716f1d3adc04":[4044359,182],"2e3f299e45ed65bf15d0f097a62d53f6e5c4904a0f6e551a7010200e195e96a8":[4044541,182],"2e5738d1b2a4b6e735e362e28d99c9629cafa7404f0155a893f2124b12eb8dae":[4044723,153],"2e5b5b54aebea3db240acc2f2dc944df07b908bf97ba2067517a9757ed8c7dcb":[4044876,38879],"2e6328cde4c885e073cae7722b8da9557cedfb471f20f3b766215224d7d99edd":[4083755,589],"2e7c0133af4e58e2c4d0d31b52bfc61ca63d6268fb88905397cea0c802456e1f":[4084344,40141],"2e84007b600de3257549b12e35b45e779d85751c59dd2ec7db7abde7ecd7e4c2":[4124485,20197],"2e8484291bc0154964cc7342b8e7c948064bcc7b946470343fc8b7711261c24d":[4144682,182],"2e88434ca61348aa7f7a71203f65f08fd9421ce60ace0ff5d2cea854bf3f60c3":[4144864,69182],"2e91212d9799b000dbe1870c6fa9f7d17e430ba9552430ca5c670448966f9740":[4214046,922],"2e9786d10a18a23deefc7761f52ecce829f35ba82947d5e22eaf0e45c7af8e08":[4214968,155],"2ea08d107bc434790a03307dacea1397a16ba7ce0777b8930774af528b9da342":[4215123,4866],"2ea965827e5c5073cbcacacd22bf2318a9067874c901b4f05f67268e99baf92c":[4219989,32355],"2ee0a6d9178c2e955b0161b96357bd7b52cb3e25b5469f800e7c909fb9e0254b":[4252344,530],"2f4def880281f987518186a75836278fd0c2286ca7cd8a63094c9467bc92f5d6":[4252874,666],"2f5a01d387b32f69bd9232fe5ce95d8b08b7e9db9b93c11d42c4dd32cde94f1f":[4253540,1937],"2f758393be8d57d08eebe5fe3d3bf0d16052b88380aa2d49da2dd801a7a91d65":[4255477,872],"2fbb3667fe723242b634008a9291cde399784174501f60125158b0ed9f041e46":[4256349,278],"2fcc70d6e18e1ff69e48de4a0384b6f1ef91d135841fdee0a0fa24ee22f40a86":[4256627,158],"2fd0b4910faef6da958d13df54cf29ab0a21147d5f2f7411fb24ce7e04d1f6b2":[4256785,277],"301a7b4095f6ea2b3a9f40d893759c818624d8cc56a19cea7454dc2d47fd2e48":[4257062,1069],"302d4ea6cf62115a60bc981a8ddd5fb154b024a3a2bf7c0b06c11abf27b764cf":[4258131,182],"302f88d8a2cb814f96c9eb6e8e9deb7ed6cbd62c458c1dd92fc096612d7f48af":[4258313,33327],"303b0798f7d68368d0e2fd049ea1822280309b6079e6f82306bc8e126cc71268":[4291640,155],"304764e6b260309c18ca882241d972fbf47a6f32fcddb8a340971fe92fac8c93":[4291795,808],"3050b7652a1a7070197d7fd954cf55c585dc328306ae68a91b27d24b6b84dcfd":[4292603,12196],"30858d5503624111402ba8e920ea67ea17305ae3fcd02bd8307d391bc7bb0c7b":[4304799,4866],"30b6266ed0e5721af16cc02db29b907d5eb8bd5db19d5f690803ff81749f7a4e":[4309665,18967],"30c55ddce8492804cc71e6832202b1bf7a8f0bf9d4f19134ae72120d85628ee2":[4328632,922],"30eb3434c660a905230df4f863e6dc3ed4e0776b1d1a359f48e40f1ff3bdd460":[4329554,698],"30fbda234b36ed47fe51cddcdcb8565b26b7616fd3b7223a0d3469eb2bc82c74":[4330252,38392],"3107172db0f2cf7a2f9eaddfb70f25e2702f7cd4f2689a30fe1d76aa460a771c":[4368644,1069],"311e706759d3af6e0ade22d42cd9326ffffa1b82f066e44ec958eeea442b18df":[4369713,159],"31507b7687b2c47d7a03127bc4a0af7fbac8862794bc9b83c6d97472f270269e":[4369872,673],"315f6fa79c7c675b848f1308087ef5e1c328e63cdbb2ad88929b0c89023a27df":[4370545,12017],"319886635ab0c36e47b910b33bec23135fcd7ec0adf23c236d1ccd021f0822b7":[4382562,277],"31e889d315c07b1da4defa39141118e66f8a4424b5b1780d9b134759b6c69af3":[4382839,159],"31f544ccd3948fdcf12434aa4b6a091d41ed27ac9f9868c3c897ed8ed731957d":[4382998,152],"3212decb008a5e27180a1b70fe935021d353fedc897e347ec37c46ab1bd01287":[4383150,4949],"322c29d05ec662468b8d2446ec47b826a7ab3bc8b651229e5935cc0e0371e6be":[4388099,5715],"322f1b958a1c9564a4343bd63684723eba642d37cf5c2ae1eb3fc18109a1b0f7":[4393814,153],"32610e2adfeb061e090ab73463d2ca6a36ad76d866b122d8b55ca9e3b6b429a9":[4393967,49649],"326826be3fd8aaa84d4869493b7e41f886a07bea8960df79b61c87f557f4cbd0":[4443616,1622],"327df4533c69e9df80fe2e89dc208ef1194ce9529a11c8a6c21086bdbc1e918e":[4445238,155],"32812b7dc48cdba647dd60846a7204e6e6a76eee25b75a16aab0e8779497d214":[4445393,590],"3288747bf43501a729ef81fd0c69314316f9f7ef2e5dfe2d3234f61fa4cd6b6a":[4445983,155],"3290d204a2016025d1a73358aad472889ca34f4b34c1dc68c95202ecbfd5648e":[4446138,4866],"32a158224d4278f8ec8a4a8dfe165370fc2fa602b09d7af234989c4adb329447":[4451004,1069],"32a60da398e271d27851e65e50bd39acd294f92882501e7d5a3d2c95910809ff":[4452073,153],"32ac4d8d54f9f5e936aab338fb31dcb84a56b862089a973b4ee393f3ce422f62":[4452226,962],"32b2bc8368291893a4f835b48272fd05d92f8da062a940e530b6df725070f563":[4453188,922],"33183d6045e7007f306765b630e17d834d6fa5198dbca437ae470e1af5d7ed19":[4454110,904],"334f0a27f85b5686937e407156ee9e78ab652ebc52523529223dee1ea8174e54":[4455014,962],"335cdc8da2bee5a1ea22ebd01ce3aeb652efa002f052617fb3123eb318193597":[4455976,590],"338c9b575a24883622bf3cb1e3276c24a26c31ef34a5097927420523727b6d26":[4456566,589],"33a141441be88c6825e85a1495467d599a016bae61a678358f9d680a52017c99":[4457155,594],"33b67f34012e2dc851c4bde74caf98bd70c33123696e6169a4bbd7f9f9c32e69":[4457749,962],"33b9b868629947a37ba69a16cdd88b5f91fe1c54853c6910d0b22aeab7b22800":[4458711,589],"342bd33e8d08dec713feb5979249463f25ad382dd960e0c156b019c1e6de0e60":[4459300,31063],"34310ae6a65b35a5c9f474392cb3cddbd6adcb05314b2b280a0014f4a40da94b":[4490363,922],"3448e19e121d0f55b9a0f58f38d0ad2a5849ea8e6676688fb18401987dccdf2a":[4491285,1548],"3449a4f70b81e24d112bc3287490470a526c9c728bba82cc7ee4dbf4597b354c":[4492833,13876],"348374002b03bfc25933bdfa89d0fc466588caf29b753c1500ac9ee13e2adda0":[4506709,152],"34a2c120093522f53a43e877eb05d338a96d093ce30d34c9b8b5caaf288a2122":[4506861,152],"34af262bdb9e210661534e5954107df8da9354c9e616f791c2dd3346242be742":[4507013,665],"34c0d55e435530ebbf3fa3462a1eacf4b4369bf4410f598647ca4c429fb86889":[4507678,5085],"34d59d5e9be7e7dedcb507f74b29b3434ebe2163e84bd2631669c1ffb1974a3d":[4512763,104432],"34dc975ee82f961df8e9e8723e0bbb65cf8bb9f89d8420d74f87db0bef307fe8":[4617195,4830],"34fedce495add63f17780987cada11ab593ba090a1f84264714a8d94b7794edf":[4622025,15924],"35abf1f06bfe878e4cbeb68072a6f75664b756ee46e65a64003889b78e366013":[4637949,19435],"35e0441f1615143a541a81bc7e391f17c286a2478404887e589423452f49e07d":[4657384,152],"35e58a71895a9981f01ee4dec4bb6bd1a8464d507c1d4bd91a2fd07759f85f6f":[4657536,68817],"360317b6771257379b9f895e5ea5f8f1a8015c238b60dce2886d95cfda2b82cb":[4726353,182],"360a819b87eecfefab1a3831b4340ac7fada4ccb2f8ccef52208b6042f556d1e":[4726535,841],"3664f2c03dd7abe8e32f2910dd9088f8f493a28a507f4cd07c02d20f68d742fc":[4727376,590],"366a22294f2c4a9eccda8fd05f4b6cbf265bb94cc47827f9bfa114df64e5695a":[4727966,153],"3691606e4e8c5b310d3148cff5211f28da63b0d2dff14c4696b4b53c1d018e77":[4728119,589],"369b5bd74bf4b48144d30e7cc7fde168aa82839ee80cc90977d36decc8b0ec1a":[4728708,153],"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516":[4728861,4545],"36ca67807d1b9706467139566db3b4e87ae63d44dbfc10f8d95eb88aa78a3331":[4733406,1458],"36cb6c034405714880e39433d450c6ee1890fe9bad5e062eae3b704ac17adee0":[4734864,8966],"36d2b54824e4c772331483e437c0bd014fa4fbf2e49dd652b9bad3f95e3787f4":[4743830,4866],"36d2d73b7b40e5492caadd5105769ca257a277969c7864e48a4dcf45f3161a38":[4748696,182],"36f63e3f88a68693d694c2116b15ab13fadb6764a3a3cc60f3a0ab8f404b6044":[4748878,31547],"3702edbbd4471a6eae2edbfe5b2aee2590b59cd3b7bec130953998fad79a3353":[4780425,582],"37062565229e5510c0680ffe830192348251ac35f6327b1f17f3771b0d82daaf":[4781007,5214],"372aa89d2794983c0d281ce275b94fc4dcad23ba9dcd8c075b91b79097e1ef93":[4786221,13269],"37359faccafef4a4af863130f729665c20e86545566beed1e3266ca785f3a916":[4799490,1458],"377caf3a09cb1dcaa7e33ec3a831a01056ed846576c6037c97b5de4694d8d76d":[4800948,743],"37985e1e1289f96f00be156b4fff0e75a2b00894f5ef2e9816ddca84e594c486":[4801691,1644],"37bb2e5b41537a9a7aa513d5400219415ec3bb196dbfdde9ca8abcb1ae0c9090":[4803335,5214],"37c8fd471686081921990562b09b0ff44896b5b844ca1b8b4e2768a5ee13f25c":[4808549,152],"37fc6230a31e6efbc940e618a1ccc6af767b5660a9961c987cc04018ac20076a":[4808701,666],"3808efee99d8af834c52c0f732c9a199b90a427dd0e299c875eb9661ddff642d":[4809367,182],"381b9d58e0d074ae71e09997e47fa0e561481461aaaa5e66265f9c68c778122b":[4809549,15228],"38a049eeb34faba7c1bbe5a01b2ae6cff75423164de6ffb6c2230983688d2c39":[4824777,904],"38a0e54ec2dfa9612b1b6604dbd3d186dbf914e855b1973ce498548b3a75e834":[4825681,182],"38eaa9d6b15a1bb30970cfd4c3c2f98fab73f4a40ce774a7d622cbadc210ac55":[4825863,182],"39142327a46cd4805056d9c6fc3f86d0c937a895ae8dc5ab63b22f45fa584941":[4826045,530],"3960d70a07476e0ca5ad272606f66b22890a9d9169b4b411397290260c51e4a9":[4826575,590],"39678ca222e48dc1ca873843707397a6a468dff245c9e8b95077608e5d535b14":[4827165,152],"39e5ab8f541f394c51a609c501e6b5031c8cb5e5e773dd5e0a0b3e36be453e15":[4827317,1877],"3a2211ac2276e6ec65042806e5a5c730548039e0ff45a87740c881902b327d71":[4829194,7194],"3a30967b16c2939468c2489864d8a8dd2d5b61d675a13c35933154e2d68b2832":[4836388,153],"3a3c8e64df696c063037233fe95f9f6a3e1b674dafe10cc13b72e1361464164f":[4836541,18570],"3a52de9b3d6d111ff63576e2d826cce3b1f9b080dd5ba6e4e3a2dadc15d7dea8":[4855111,2984],"3a69a0cc3aa49fbec350965319958e557ff7c8ecb703a83ba0b7bca6f0d40d01":[4858095,10175],"3a7291ee4281e0a6975b1834ddf7df28355de340b0bd3548a9d872dee5540bd7":[4868270,44466],"3a903ab01371afd8ea0ce5158677cce4442b006349cf1abc852afbf0395bdb85":[4912736,16519],"3a9237b02a70d94eff691582c3de700ec20de6c7a08d66986e281072c6fde476":[4929255,42734],"3ab13ab242a313adbbb7497df3c3ace16ccaddafcb8a5d1cb3022cedb8438010":[4971989,155],"3ac22853b1c9092a24844209da8b7db1cce3440ac2aa795411a1712942e4fae7":[4972144,162],"3ae089b923327032845c386f2332ff653a08f25ccec28e0786094fa05fd77a00":[4972306,12225],"3b01f1d3d8a8bb09dfd5419820915dbb36b8dcf3d0e4abf4b246d86650696e8f":[4984531,152],"3b3b46d4f8f832a2b67e59d481fce3269061f0c79cb335d3ddc5a51d8a1ce915":[4984683,12726],"3b6f41207904f483005d7142ed58684e22582bcc7cce2b4ff17a2cd75b25c01c":[4997409,472],"3b7421e189adae6dd39c27d3f509f3c936d35aaff975a6dc42ce3c7f16bbf4f5":[4997881,115],"3b78f51def8fc7f7508c5d006eaf10772144a9e1aad228a6aae48abf40da1d3a":[4997996,589],"3ba80460a79eb621e605030de794812e70e475bb59ad01750d03c5d35524b1a3":[4998585,17345],"3bf9a4b0e8a429450fc7d3fe321ef68f7a7382e827cd76136be68a2c2fed4c31":[5015930,666],"3c0db9b5d179e2f3b7123cd8e08844ea9ad28a0a6ffbc7e5794738709f5af1f4":[5016596,21246],"3c0eca07d2853979c3a1d9ab48a42c62272a9dfcea8c3e0ba67bac13ae3f8220":[5037842,1493],"3c118ad2c847b2b045ffb1ec6763c0d967ad2a26b8fb53c7cf3794ea7a41a7c7":[5039335,1877],"3c1aaaf11e4d33c0e3bb4e15434c1c4f24328f76c25c41193a9a8fb4119629fe":[5041212,152],"3c251b1b1947263918fe20db4e1c83fae7baee926b3953aa244925e187430015":[5041364,3832],"3c512923df554d1876eed8803ad3d4f0f34029f937b19529b6245a687d9b6a59":[5045196,4609],"3c72ec744785180719afc98553f9aa7fc4b47c427647818dbf75ba4e03cc4045":[5049805,23722],"3c9fff356bee3170325d822db83df61b585e687ee020d82b9a96911e03a2b73d":[5073527,666],"3cc26f37e3316f864826a833c72014e7a6e93eb36428d9725912e011f680d9ca":[5074193,26808],"3ce6a557f9ad7fa73171729d266c7880bf257d102284949125316c587f654cc0":[5101001,1155],"3d5cd58171741859583db71033d74bf63ddc6e7f31ea85244fde32102db2bf1c":[5102156,155],"3d738b3e5691e0d4aade9cb39acbaac03bf51631d8b017dada36c707a0485333":[5102311,182],"3d8b5d6a28405c3b603bbba90cb134eb470c7745724d5de60983098da1061562":[5102493,1069],"3d8ed73b166acfb1f4b4d9e25eaf578825f3c41a45e987563029d7169d2c5e1d":[5103562,277],"3de19aab9278ad2c576bf9ddf7924b58669863b917bd1d71f525e7ba1ad53a07":[5103839,472],"3e35ea99e2e810867410dc1a4e0d9018bcc0501aa69e5d983e7845806457d7ff":[5104311,922],"3e39956abc40bd1fc752de94bcc85e2277b07b6faa3a191e6cb669486e4ae0aa":[5105233,922],"3e400f98b792a0aa88f6588aa92f936a6032bea833ad6e0970969d472de2c09d":[5106155,582],"3e4e355f363f65fcf5b32b5fa19412bb6ebc8b2b3b4638cc75023112afcaf971":[5106737,4703],"3e5b8b025c0e48abef1ef000ab8b9725ae1529969cfcebfbfa6050b48ae5acd6":[5111440,12509],"3ed685ad71d2d7db241c36e48d101dd8e73e8d4054151fc4500158d6a2a16dd4":[5123949,2377],"3f12f7c4213500f6bd42951b13eadea6d9b147ae2becf1c14f0f607cef888efd":[5126326,2024],"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb":[5128350,1718],"3fc1ef96d843853d33c27d2b9dd981b4cf7c539a24f2905aad915c5efa4d2350":[5130068,19896],"3fcb5204c268cce11faab7b46b4018d39102cbf0a5af73c86f79baeea0c0b42f":[5149964,182],"3fd706ccdfb86ea269dae81a3d6d1a4e0a7eab7f69ff74117d2d9864767c6226":[5150146,158],"40602bb2e8e06bd384895319bc98b8ed659e32d428b4b7c9688380bc7aca41d3":[5150304,155],"40689b2560d6c445afd552c2f470ba3ce0d427622d96ed2cf2ad8cfdb4286321":[5150459,32414],"40706c4444f1a3de44e590cf67828b953e2f0ce55ddd5416e93712e04f58155b":[5182873,3193],"40b632faa37573d7db0a0c650476d17f883dd90d00c9ef2d4b2ea57ab955b6ac":[5186066,841],"40b8a1f46b44594d944a719491341f5e04e0cc118af0e9e549dbe266351c7da3":[5186907,153],"40c854d398579fb06513a18280ecb48df88b6679c8b75604bc0f53bb4310a601":[5187060,16051],"40ddb00b3be320909acf0ae1440e2bc5662e3002dcf77dc3367bcb215b1710fd":[5203111,666],"40ef4eda0910dbb946d3cd16a6e2eef1ac24ebfd7b57fbf0f688222b3826be2f":[5203777,155],"40faaca519e9d3e7292f35c83f9b09b37aff1e1b0a08ffcd6ae1e0fed5df31bf":[5203932,50086],"40fd2bf36b3e6df760a1127e79f3bd1e363f34f7c6d0247369df8ef5870ff550":[5254018,808],"410a628af3831ec0633f0a6b9e68d88a162d6aa6e83d1c8eb26c1f4bac6ec140":[5254826,155],"4119220a20c100391d224d06c2614d3e3632eec40ded0cfa402e35ea70949204":[5254981,1937],"4133e6af279ca1a1df4fde841f7e256b34be2291b3efdbb1a0cd2852acb07966":[5256918,182],"4139939d5ce1e1118cbf72e76f8e3122007ebcdb2fb4d533fe418cd45833ed3c":[5257100,182],"415305923a64a5a6797ad1da831c320be66a4dbd6e25ac09bf8aea77c82fd7ba":[5257282,962],"416ba4dcab352004073c12e294ae4034ae9b25d7375568e42a779db08e9a2c2c":[5258244,153],"41831e2909f88aaf88451b2435b6bcd41be1ffb1066197bf1ed0fe5e2107876f":[5258397,182],"4184266c3322e534d08af35a422433e9ad39cbf1c471643a855a0d48be278fac":[5258579,1060],"41a414d3b2e272411faa535acedfa49a12831ea4d417083ef16e16422bc9bb23":[5259639,922],"41c6b8a4a20ec965be159ec3cdb464fe669a30bd6015ceeefed4ef6113ef3a8b":[5260561,182],"41cfc0992c2a7a5286093b0e3adf55e8c8cb226f8cf52f78c6edeec958cbcdb1":[5260743,152],"4217c3fa6991f4d52147264164b45f69094b4b170f7e4bd3187416b6aa9ba5be":[5260895,182],"426725b67e2d43f8c3f88f507b0583d8284b96b94e42aac3523707502eee55a2":[5261077,152],"427bf8f1bb7ac29049e57dee9221077958aab3a27bcea85a51d6044373140447":[5261229,105747],"42cee1b00043646e7e0ebed264397212c4ae136d44233aee64af6bebcab11d53":[5366976,665],"42ec4bb29457e915f5242da17977cfe5fa5873d3f2c7b42885e4f68549a3d36c":[5367641,13985],"42ec5951151046f227be896e37e6906718443065a0a99c05bc7cf6bb85dbce45":[5381626,153],"431297c192b21494609614fc617c35cdcbeae92e9b6e8f656f7f6c22742709bb":[5381779,153],"4316a0ee1e4f6061a6ab25eed8e494799c62d033a790fe1ab629ce4989318039":[5381932,182],"4336fd97c83d636ce2ccf237d2a61546434ad4d5dae5f90f0196b036fdb4943a":[5382114,530],"436222ec9de04bcb28f6d32f34ba5be42a161de58691f0e3f3478303fd569532":[5382644,582],"43b1245f514e01498961d0fb53b8543fff9303363c309346031fdb23984f9a18":[5383226,33612],"43bebfde8565291d15bf22413e94353a586461816d0b7a36cd510478aaa1bf7e":[5416838,1142],"4412c25cc3d2e087ea403f71f7c906cbd3696186cf20cc018b701ef31b1e6ce7":[5417980,158],"44199809fd5ea9b54ec433c5af22bb369b05958fa875045a5be35cb8cfd90884":[5418138,182],"44704a3a2c842e344fcd7218c1b9da9621dc645340891cafe396a92a52386991":[5418320,28274],"44c459f618e462fb4ad039176c11b28e63c24457487dfd3a5483617749d91dc3":[5446594,1069],"4510e428e7bab17df63925c70fa73e7c1041fc551b3b5f5decbeaeb7b8a9d380":[5447663,4578],"452e58b6f20f2839c3df8f97f12f118bcea90e5a2690660cf21f28b9108db1fb":[5452241,808],"455f4d5289ded3555a1f0ac2f3d59d85163692066f0365941f0b615b8d8feef3":[5453049,152],"45954b48a420f74d202cca3b0f6d605c3b57263e59e47dd57dcedda4c5cc8c78":[5453201,902],"459f958ed6eacab9583156c2c8c94584734ab5630baa7d0096ac659a09ab99e0":[5454103,152],"4658c5aeee06f1671bd926c8da0f1f468d0515898728b08d975e3a150e89f557":[5454255,152],"465bbe98b63516136f54d2a623616a0030660a132be3b51aa96d7dad6351d717":[5454407,922],"46af6cd38f66211dc1398049554ea020d713fb1a4a1f1d5b2e532d78a33f0304":[5455329,729],"46d9e035e6286b85d9cf3f0ce2696fff23ecbd1f86cefe94228b92ca4fcda4d0":[5456058,808],"46ff0e780baa0c9a533ca65a9d6427268f69fe5ca1f1caa9b08a6d9e0010cb24":[5456866,614723],"471d1ecc800a68c64ea464590b52a6d49e03375d902be67b20450c2a0b745b19":[6071589,918],"472a6986704fccc2a1f592d6fa2dd293195eea5e59e2bd6637e9a8647296b465":[6072507,665],"47495a00b1e0eb932e5ec2c130b76b0e6bc48f51e9689d8f2067b2e98b4dae47":[6073172,1937],"476917d9ed89b9fb76844ef0a20187142687eb250a4752d300e98a7fe1363f41":[6075109,50304],"476d976420715ff3f3825cb42c54b56a8fb74e437495b13b0072b8dd33a84c2b":[6125413,922],"4796bf8fb85297f41813bae890471f5ebdc7d49f872b63674d31fb88ad40cbb1":[6126335,988],"479c5f17d9f151e39c2d1c1934a4713fd1b4df6d7f9db4430da802f00294b7e3":[6127323,841],"47bd411ac79f933d342d0232810d9babc88ce83b02c97a10b1d23375ff0d91c8":[6128164,25227],"481ab7bd18388d6e245e376c8e6e0de8f4602a01eaf555786dc0316dd5bc1ba3":[6153391,841],"484834e15a6a069f624bd07e000413704177c6a6fbb2225a74b29a3ca7e4bb73":[6154232,182],"4849348275e3c2a55836d85f5b720c855b8acd6db6a2aad4f418cf407419621d":[6154414,1458],"48514467b0cb3d391351e1ea360494fa8ee24e1ec8e3634a749df6c99faf739e":[6155872,3180],"48d165c0c10ed38a4c594954631c4aa3b9a3c5fa562cc0891b4343fc3d32dd14":[6159052,22250],"48e5624ef415a6044dcc36f8f24c51662620a5ff31cdedaefb76aefecad4fc63":[6181302,1010],"48e6ed48a2091eabfe74eddea5b1eb6aef94314f6e3670a0ac0b6a23fc1449e1":[6182312,698],"49356305465fb49ebb7cdd91222fcc3d651ccbc201d4f1d32eaecace29e3e8ae":[6183010,25147],"4935806b1fd8ae8a2d0d93597213ab55ebfe7965cbdfaef8663b6e24c80f9bf4":[6208157,666],"49444e7934b9431dd7f4c8b63c158344c7521dd59af343f8307d515a3b7d250c":[6208823,962],"499af8408b94358aea052787b188d490b6bd38d42ec822bc32d4c60f198f33dd":[6209785,182],"49df94955a774a846f86e6ad00fb41b31c036e2783d542282aaea36b77633362":[6209967,152],"49fb56a448e3a6fda34c106f9c211b2e6c0e17c366e430bf25970e3695febce3":[6210119,57165],"4a14851b79c394f50d27ff1cbe4bc1f57267d19134b65ef2cc2b0a861998238c":[6267284,34598],"4a1a04b3590930e1ba33b9cca6ae1ee3b551877bd47ad625ee2c0c67d0d226fb":[6301882,182],"4a906db760f295b9fc794dcf67695379ccb77268bbad8d4081a0f1886537486b":[6302064,922],"4a9548b8bbf4314ef96622e1f070b80cb46b89c192217b2a863b20a0aa0142ad":[6302986,182],"4b14b273d01558abe3560da549f380b808673d119fa0b3f23343a210e6098aee":[6303168,1996],"4b2409c81529d14c453849a1dc74bb0487bdefd72b744108ab6dce7a5516157c":[6305164,155],"4b2fe9210499da6b7cd566c3d2e587c1bebad47ef3f0d83aebe6c0358ef056cf":[6305319,18380],"4b445320f81bd7a35f96dd118169b2f02dd112966610fb0d60ccd79e9ddc6e46":[6323699,16903],"4b44f76ec9cece4314358f54861f937a5c4e875e93646399a3abbb09c2a51bea":[6340602,3008],"4b6068f81e4c4b61ddce95caca8772ee1d39c15dad180a6ab93db5ab101d9b41":[6343610,962],"4b74101c836595b3e54307478809b5453cdd0662bddece17991ddf0781e4f982":[6344572,935],"4baae1ba37755ca07c48e6632bd1e01dd48543b8849e10a1576d5ba9596dccb5":[6345507,1937],"4bcfb0aeb73ad7612fe49e0b28735eaa27dc2feaed973b0d7cb211594448bf6f":[6347444,8819],"4be7212bebd28fc0aa4a937abc253a552464f375fce3e4ff4f33abb22902da57":[6356263,182],"4c17993a6ad9ed7e39ca4382a9147e6f52454e54cc0ab7ce91814b2c96a735da":[6356445,20751],"4c77da5491ceaa3c773bf3235901b5c3fd827634bd0d9435510c5505dc06cd09":[6377196,153],"4c9a9521b76a633b9cab4105ade4dc339df9abaab8c0f4ea045fe66e022c4c35":[6377349,1458],"4ce91446078aa8686a56c4c665b6d8fda58b19d6ec02721577e7fb70d95d284d":[6378807,4578],"4d311c2a892a05d64e1842768363dd60597693c828bd017e82627692d36d2183":[6383385,590],"4d3a24c7508dab64e1f8b97865b1876b537918ee9dfd0e33617c6163d5f3b61e":[6383975,2377],"4d4a4fa11c064ae1a658904756a7ce2caa9bc1540b3cd9459b5525c7d283db8b":[6386352,922],"4d61f4b64e499d0a55fc3da5cb95b634d9298e0881064c1ca353f8474730c6d7":[6387274,182],"4d9cf244e9305700cfee27d61fc9f8d168041c5a216a94719ce226e56e5522af":[6387456,155],"4de0a158c3cb4b8cab1f34dabeadbd77c2311eec6182235d0c8e39d50c14ccd1":[6387611,153],"4df745d53cdf794e90eb98b84242a5575da64075ca21716afe7efca51caa0a30":[6387764,8172],"4e49e64e836296c17902eaca956851b9cf95ca891f70024b31995ce25dc3731b":[6395936,50900],"4e6a7977f8e79a2abac7fed68a409e8a72353b8223ca84984cc743ab9407cac0":[6446836,23689],"4e824e27683f5d080669059f13638d525c487f908177b242acf5b4feb022d7db":[6470525,593],"4eb474e4d77d82252b33eaf51d48a20d885fed317bb795ef2dd7f33860f0840e":[6471118,14652],"4ebd91883712ded92ef893e8cc61514ace87ab4ca2f5bd1bcc5369495376a50b":[6485770,109132],"4ec43628c3c71515c3b6fd7ca31ec4cc5b4c37b1260a541f7fb4068be5769dc2":[6594902,661],"4ede33df8b04ca4243178cfa1dc5d46036133d8f5d248e7ea32c990e74e881e3":[6595563,7564],"4f56c4b69a351ea26b9cf5b20fdcc8006e7c44eaff2e7b3ac06db556f8838cba":[6603127,988],"4f953bce7cbec12491ab51672a4b30b5cb233aeaa444881c2a06282b75263bba":[6604115,9932],"4fa84652188075702534ec13ddf0f25a982cfe982e9b60d7ae8c6092d9faf48c":[6614047,904],"4faf6920975609fec02c2c646a54241557a8663ee5b3e9154ee7597c46af899e":[6614951,153],"4fe564cda80aa44ea7573c1a828ceb8e198daf6549c61e1475025d53f4a35238":[6615104,5818],"4feeb1aaa662e39cb05ec5a1c351a2dcdf43f072ce232487923bddb8d50ee4e0":[6620922,153],"4ffa6ce120f7fa01d77d681e1372468c49ad95116657996f006a983e3daa74f2":[6621075,530],"5036a861ae00692b7f0812985f2bcd4aae5fade9b28fda19d23855c1de7c5a43":[6621605,152],"503ce69816a25637fa759ebc9ee0e90d64903dde221729eb06e6f8edb26bf571":[6621757,3513],"50521f151292fea87249aedf54832c1dbdc76f0a5ba285fa0af770ae10e67ae0":[6625270,182],"505d5f544144525d73def85bf0b3b9e16230ea634599f4d343080af78d359024":[6625452,155],"50b05ec0aa3ab799f26c549c799dc776f16c3cd9bd5eb3ca833a70f03ceb4fd7":[6625607,13316],"50b23bade4b4047e5666236ce7532082909defe62ab7cba73b661164974de6d3":[6638923,808],"50b745f71f63d7fd5b8a05fdf84474524004af8f75d47f8346b7404f99981fab":[6639731,45810],"50be7058362059baa2d256106c6b1ba3095154e689d02154c8972ab4846caf22":[6685541,590],"513fdbb16b9f7a1e1793f2c320111b2dc34e4559013c25d2c4ec92f7b2bb6e66":[6686131,182],"51817c7e1e3e6e351e85b6f8f5123fa042f2381f73e104ea2ff9a6acad29baf9":[6686313,153],"51a52efb3c1d20e32af8c5097f4e5e4adfe186ccb7d16b3689b844dd1244cd76":[6686466,589],"51a5efd09235e6a66de7381faaa217a51136ca9c7d2b10285f99af8ebe4ffc6f":[6687055,9312],"51bb437ce9ebb45c4381ed2f33805b71f99b6952618f1f1e3332108f5daf519c":[6696367,614143],"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed":[7310510,672],"51ea9c40dc0d97e9bfe016daed54dee7f9b82b544ca8d76349e9b8020d9cfe40":[7311182,182],"5207d1257c3bb673c75b2010e254441d33a270de69716b521f3198629702b49f":[7311364,14552],"520ca02fb2a0fb05deb02a3386c9aa14433e7218e6e6abf11fbaa13c273bee21":[7325916,589],"52106855f89556e0493f5130406968308202e7662990682a0236dd5bcc02f1e1":[7326505,155],"52447c9716884a1aafc41be8e6df9fc59fd5ab593c3936c5f8b4734a40d6308a":[7326660,808],"5247e34ebd33f612010e0c38d0bf1b4f2b001de64888ab0443c9d38810546cec":[7327468,25150],"5267d01ec740e8bd16aee21fcc3b1793b34625a15aad070c4f99a9c1fee469de":[7352618,922],"5287b1e356febbfb1bb42750c5d0e218597176a3fee19f387b975d84aa0cc11b":[7353540,20359],"528c92a4e15aff9b851e1e519d1f6055dccbc152e7a4db8aaa27039e4414995c":[7373899,4561],"528d7341585cffbfaba2fa06bac80302b4fc93a2b2afeb544c45ac7dc362f01b":[7378460,729],"52cf17c04dccef9f3e7106db729cf1555210c1726fdeb087e94651e7e60674df":[7379189,155],"530e8dda424f5f47d6a8f7544076b488a86d416f8e5146d2afd4b971970fac74":[7379344,582],"534e3c41c707c447866e27019b48dc0890e2778d3f1105610b09c81337e4e91c":[7379926,12192],"53509adfb5ae0f67f9f6fac51d35dcf4f609eb293fe7412b1a367b5e8090aa5d":[7392118,152],"5378c8ec94287266d153db6a4f4419211016af1acbf8f7432fd71358433d5915":[7392270,858],"539a182ea541e60a1f754a9c3205d681a91304592e430f56ee35f04c9b32cbbb":[7393128,153],"53a0ccab9dfa85e4f9715bc963de833a064081e2a7c96dc788bee20f3d9b650d":[7393281,589],"53c48ec27ce7a00111dfbb21518c97d00daa66cd30143d9c9819eaafe56b6a2c":[7393870,152],"54051f2bb4c101541cf5694e1abdd113365346c71c8356cb3bbe9516085fe66b":[7394022,24278],"54466d71840faf6fafba87099ed5a51a284f481fe896c6bc355fc6ed92f884d8":[7418300,918],"545448f5f281b3d978c84772405a2e2979cbe26d7e10828e964e795587135039":[7419218,590],"545712a25e324714c39360819df5fcb4fc3b8ac2bc8148e2d65944dba8cfca0e":[7419808,1155],"5483f3f9a779b78bb9e4f60e9bed6e0051c7a0b5b519ac46aa69d5dd9851ac98":[7420963,18917],"54a3c05fc007e882fc12006667603e955548c8d9109ba81217955a247e3ade47":[7439880,10996],"54b3f96a87353e6f431a05117d9a3390e2e386494ad40004cc31a7f96243ba75":[7450876,922],"54b8106aaa41111b04f37b836cfd971d1310b8ac4fad81f551e5ab2d02ce355b":[7451798,28697],"54c977d622859326bce3237310e05f81007e9dffea579c9ac97457a07a1e8d5b":[7480495,743],"54d53ea574dd74da4263771f7fbe99734f4b9dbfdd419debce4462c1d47f2975":[7481238,582],"54e74a9ea88a542e39c45393ba16f55f2204810b4beb411e87dbb1552d5fb2ac":[7481820,8753],"54ee4b6482adebafa7f97a6abf9d81d0213742b6ca9c48168b07f0634105a72c":[7490573,37463],"551c46e13db8ff52bf6e01e1a7e8ccb5bdec52e51ab6b3720aa10df87f88d84e":[7528036,872],"55200a2e25b2f3bb2dabe0c229a2cd0757c12ecb1a4f455f3956fbe4a394ee6c":[7528908,872],"5521a1f18a7e90cd2b61be3ae7557245fd71cd03251ffb1f2dd105f79bb3128e":[7529780,21369],"5523f25c93225c10d09b247f1e832c61e58b505b80a4254d7f54f4a488b3acba":[7551149,1204],"5528dfebf3371b364acc09803985a7626ad6f1c390e6b0c939ebbbd7020a4f2e":[7552353,152],"552d4c4d5476b26a91d581f0c430dfaf8b2d1f3d7eed2bc6c808e4148a666cc7":[7552505,7338],"55364e2082e98689c139ec7c389f101f6577004f82381d1dd02934031305e601":[7559843,182],"5545548c5bf7e6af4ce19faddb71564419861b3e6fa79c36af0916a223ed8045":[7560025,182],"55624d397b545370b9a689c3d3c5b310494cec822516711be7cb2d9ed2b864f6":[7560207,4376],"5563be96bcc9c0213fa8edf66ba8e00ed3eb1764c7a3de14f4043b2035e8d9dc":[7564583,182],"5577592b64ed66ed8bd6911d322d138c119734e148c0ff8106afa475413c5eed":[7564765,182],"558721ec5962d23ef0cbc9eb4771329d7f2cde12546f6d5167e49c1b85907063":[7564947,182],"558ce04941b79c0426b3bff8860277c9bf6f03553e0f22eaa795e603a0eeb9bb":[7565129,9527],"55b8614a73ee118bd8731c37ce598fe2b9fcc799e89ead46639d0e57c4307472":[7574656,589],"55dcd593f88458e1f905b49f62a233148d4dca46aa12f410eecb953ae93a48b5":[7575245,4944],"562e142c3a950b916ff1d8cebfdd0ab3a63fb61ebb42d766e6cd8215190b007f":[7580189,155],"56462ecce45319af5400663af795f90505191e50ce9f033b7f8a254bae4d0081":[7580344,32175],"564a8af92be44bb3471d0b1e88aacb5b791b42c7aabb46a781622dfd07d806d6":[7612519,661],"564e5b055969e81897a058d693249d318db72780818790a14614a480e9aae292":[7613180,182],"564ecc0f6a253c3229aa3f95806ca4d9b0e894a9a71740153859f2e62b4d02ee":[7613362,10372],"567693a405040527f9cdd9b7e2485894ec65ac3017f1030d303ecf289587b7ba":[7623734,3610],"56a79144d46eb5353c3b032d36525ebba5c24de91b85b09ee61667b1bd2ea2d0":[7627344,1937],"56ba5b231314314403cbf36d206e130ac50ae3ae179de172e62acaa8ce12e17c":[7629281,858],"56e953f37cb5363c713a80779f2d9c53ed61639c3b0ec2591272f695fb39dfed":[7630139,665],"571414daa0db8ed1603a5cee9dfdf393a2b95a16315317ae52481be25bec96db":[7630804,182],"5720bfa6d7c0b790f6e43277d6bfb2d58aa4204ba3706d76e37e90e49a7e2a66":[7630986,4703],"572f3c58158b893fa23e36c3fe00d2dc8ca64dd5cc2ed7c2b8c9dc90f3b4d421":[7635689,103015],"575d2a1891f0d8cb790bfe515c4c9aa065a75d23e0269e9d29b843e664a57952":[7738704,1069],"5783fa10d0a6de649ab37dee1f87fdc7cdb383827376597ea746f1097ec78489":[7739773,7822],"579fd528b1e827c610ec17e2575e2ae07148ed7427d8d3d55a328545423058f9":[7747595,158],"57a28985e69e1f275780fbb1ca1671841de5a84045e803895425a8174a5df839":[7747753,153],"57a320ce682b1c107c6ada601f524b7c5e1cba4dce841ef20cada1f721e229c1":[7747906,962],"57e89f67d8f577c3d02c04f0e83ae79cad645b88ec9c2be490c8515a29c1a778":[7748868,182],"57edfecd5b655ed4dc051a769c0a03144c003d88ddaed3ffd805a378929e8e9c":[7749050,152],"582dc66d4cbe8957f2b566d2aa4052cee721d416cd4fdb122ec24a5982c4516d":[7749202,19462],"58629fc6d27eff2274235fc6802652fd1392832e25af2fd6a18d4e73bff9c8ac":[7768664,277],"588d32eae8f52db4af717af6ac4018d44286f5cef18d42e836cc81ffdb0be62d":[7768941,155],"58bebd45b6efd83a69351bdcd9bb3d609691e7d9b778341fe21937f8d8c1064f":[7769096,159],"58c24d8581d65981c93806a8d6469fb445f7e32a24e78952c54eeacaf7b08e24":[7769255,904],"59031b197fee6b0fb26202af0ac7fb131dfed6dab22637b1c161359f9ce6f77f":[7770159,1033],"5934e49fb4e7ff5d932984b2ae35bf02405608a3343f1eccf9308e7c52ccec03":[7771192,904],"593720d310b290ed164410f0dc33277e10006a41f90e438d46fae8579387f73c":[7772096,12874],"59e51f9ceeccb7464b32c50db2f23dafe489a63af8a69ccd67bf00b780b8b1a9":[7784970,182],"59fcfc2f99926abf6732a360c5f557a26791a7f4e8ffc87cf84c174acda75eb1":[7785152,4482],"5a09e64917e9fb3d96860794b946e5d78dc49be0bbd69eead6410d664ae4d911":[7789634,729],"5a3783dae0e5134d66b9e0eeddcc8932d78497f25001edb986f066a75866d679":[7790363,1937],"5a4e7106834b3a3db3abef041ffb7fa12b4ad79ec2ee5a045dcd3358016baccc":[7792300,182],"5a7720f12127eff147249aac85ccebab13c8db985113f59bff0b188f360a2a27":[7792482,182],"5ad165dacdbe325ba460c64e627cf6d1f3100193d18c9f69eaf2c3630fea5099":[7792664,24822],"5b0965e1e1b42621568efa91b7b4a52ada8617e6eecdfb8dfc8de1e49305c927":[7817486,56533],"5b31bc17073168a486d731a3449104425fba85cf934c231830061401e1d92866":[7874019,1033],"5b440cf4a96bb938a7c0555ad2a77bdbbc8210380feacdeb3918c58476fbf81f":[7875052,4866],"5b4d0bb87257685f662870deaafa6f3241634a454a5d8706ef40dd42d07f5226":[7879918,872],"5b722ef19c64875fb995c008f05677ff8f1589ed2bfe611020e5f6fb44a344cf":[7880790,922],"5b749ec1e5f1229f41a4a69cc0c4529c13418d4cac1d331ef009d70086d55089":[7881712,38447],"5b7c0553530cac82c017fc61327a6c120e284d0a6d0966e24f0d63fe07d134f5":[7920159,7198],"5bc26005b54aed577f46a290f1285957bcb146162eea96164249931559929703":[7927357,3513],"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa":[7930870,59],"5c60271283f38bcfa776fd57a8c9e8559e5f43946a8fd1e2ff3fbb5626c74779":[7930929,1069],"5c85f9c974c1e41c9d2702163845b4cc5076dd694300a3122f98daac8e283aa2":[7931998,590],"5c9687b0b683b34bcb823c08473e0bb5b941fc81ccf943666acc7640b948d3da":[7932588,669],"5cd14a3a37d01a5e61930bc67d85466d6a6026fdd03752126d1e62dabc079e53":[7933257,182],"5d14eaeb4dfb33d8251ae6917abd8757c40ff2e3d24eb064437fbfbd29f0ffa6":[7933439,50571],"5d706f09c50537806891a517f212f282129ab8df7a0647fadcc1b1cba414c15e":[7984010,182],"5d710e71c5606166c6ed3f471ba86a411990b186acea3ca8aa8ca388253539dc":[7984192,4425],"5d89930704d369a610e43fce23a4730ebff31aa008b22c9c0cc1b864ce8d5666":[7988617,808],"5d9de157fb5d2e54f52d528a794ffa9c6bc3472c2547ba4c5a98e9b82691cde9":[7989425,918],"5e6d95977066d4654c90b04b5924524324c7572a9c6ec7d315046f7503d8c413":[7990343,5310],"5e8236bbbab61a203edc7ec98abf9b4f8aa3fd0725d693954b815f89be6ae690":[7995653,4703],"5e9e418d1a1e8dccd7207bdf85f39d21e510760135c6cc2be2f149e4b82a6f78":[8000356,30869],"5ec2c2137f45d02c8e8bf78ecf0652966850772097b41f3835a99116f3b78f57":[8031225,19479],"5f092617b9a7347acd404ca5d8e55e7289bc937814b8dcc144f15e86f579b833":[8050704,153],"5f16a74b72f630430cee47f8a08027d37b37c236a96a88ea667b274d5aaff812":[8050857,84052],"5f1a051a0292fccc46d67769af0db69e261bf199a6b6ee850f704275365e0374":[8134909,1471],"5f1c9b541e2c41fd0010eefce51efefc117e4ccd5a352d99a13b99aac0426e0a":[8136380,665],"5f297de90bb2132fd2c23d189880e879ecad0a15543b2f4b690d64f0486173f4":[8137045,152],"5f7088714d13f49b2b5e1fa022fd18a6456eb6dcadf4b6cb9151230f8255bbba":[8137197,155],"5f73992cea307e9f68c8743a03fb53fbf239e17a7494c81ec33973b471bc9d70":[8137352,9229],"5fb0ffeb1bc3ebccac5859949aebf37e27e6da910e1056265494100489a685b7":[8146581,278],"5fea07446ceb73282789afdacd67fe7fb272122a07ddb8003709d80eec220398":[8146859,808],"5ffa93adec2ca32502038e1f948f03b22189c4099936ec6b4d0a39831d2c3e1a":[8147667,4455],"5ffc124faaf33a047ed806d69782e58ec50f5bac472139fec60eee8e76034898":[8152122,182],"6001b66283a3ad378f89ca03ec007f7bf590e0447644802c843102ebd1683ad0":[8152304,808],"601c6e849ad327d69a1f0ff9ce25e55bb720ecf1a592493b73f18e51459b698c":[8153112,275],"601d3df374ee130f2b5faadc2a9dcbd8d21ec04d5f0b8e0c5c20533d1a6ff012":[8153387,182],"6025f02a1c96ebcf20672d2b0bc229f3b8f63c440a13946f3431eabb5823c8bb":[8153569,610017],"602a48948cf79800a53be2ae86949dd07f6808fc9cd905a0d4347428cdfe81f1":[8763586,182],"603fc2eacc3a1803a8e5c6216e9e2072bef30770cc6c7e15217aa4c12595d0f1":[8763768,155],"60578ca11b1f49f82e8920b232ecad85866cd992828e73e17b9c21dee5e8d952":[8763923,7633],"605b5a715d8bd33b2a34a76d53a390be4c3bc8f2dbff69eb29962a9717690b1d":[8771556,808],"606861d8a74ab37a851e6dfcf4a3bab1f824dcf8bef9e34bf9c6b27bc74a827e":[8772364,593],"606caf20a0e08fd8f28e890318616f7beab90f95d4f3a7ebcf89b24fd7b4eaf0":[8772957,669],"60703eac1bb42950b90412134040c2284cf824c7f6b058faf835f204639eb5e9":[8773626,590],"6082f8faf8471d056527c0b18f3396e9662b83a27b926af904f846768ceaf845":[8774216,841],"609166b43266d9b4aa1acc6fdce158d5823434a06c5020f181e0b6882027c55e":[8775057,729],"60a20d50a88af8c2d7b7e3c97fd2908232b4ecbac657e66057b8ffcf372d0cab":[8775786,1458],"6102c0100b6cf296ea7bc233d05e4ebf9efdc88d1f5d126a7f38675739d6aadc":[8777244,182],"6108a66a5d8409dc8c024d9fc69fff32608d44d774457acbdbda3aba864100bc":[8777426,182],"61163ea302c5a5b6ea57a03f2634bde5cbbb2a4a055e8994d8edb880af06df71":[8777608,1033],"6121a30bf21aa00b4424bb2b2badd08665f03995a57b5548afdf3ccb7810264f":[8778641,530],"6132b58875d0b959de5162b96ac1af7c2fd15990fec1ba8dd10a507fadab0b62":[8779171,595],"619895ec62acae41dd1253bea6d7cf73f56ad99bcf9163a2ba130c2806133944":[8779766,918],"61ad3c65ee18fbe32a3631935ba0b94228a8caab082f57c19774ad381155fbcc":[8780684,155],"61b7f0f1a91d3dce662480cd63d3b7a69a2227aaa2c5b299338c626e3a91778e":[8780839,3995],"61c81af035bed2e1255db7546ce0842eda9e60cf632f0e7eb870277caa837c53":[8784834,182],"61e8b6efad6476fce80ae02829837faedd4a8659d47c23e9d69d77eed1c47dbb":[8785016,904],"62057fc5dfc055ba68954cfabeb89a8b9ae17ce13738e5d468d7e64df6b442bb":[8785920,153],"620b16a7002bbf9ad5cc411b511b0bbf15cded08d2e858a4c0cc4d72541f7d55":[8786073,357],"621295411a54c711bb2002b25e35ee2504da96049cb9f1a4e375d21d96bbde31":[8786430,152],"62258119745f0a50e2e85bf0c785de15c4debb337165d751bb1069f164f72f60":[8786582,33198],"622c7fa0c52ad144e6e256ce7bd2a826424943e7381912cbb0e715990c143a66":[8819780,666],"6233f8936ac115e1b7194c2828c5ebf24b8cd6496d6bedf9e299c9a4134cfbdd":[8820446,808],"624d3f68f9560f086f941c2f3042fe21c09fa5ded391e74f5ec888c63a0237b7":[8821254,1937],"6260dd0f3f8757a6fcbd1c6049df47ff714828822079322176853e9dbc120f0e":[8823191,435],"62644eb38fbe04d0ed1b7e932d79b21c2e53c72728cc55e145aa9eabc817a82c":[8823626,729],"635a7c5dd6c78a119dbfbad595651c63609c8db0e99bdd45d29e22ded00c03ac":[8824355,4851],"6392b5e10c77607cffefd6ce9a3755c019659bf7fc66fe676936e4f44db09c24":[8829206,153],"63d1ad20cae734287f27529e5aa7631f0408be643e72cd6a7dbc6ea20d19932e":[8829359,158],"6416a62e5030635642d27ec7cd4f5c29fd9ed27f0d59fdde204b6fb0dbe64124":[8829517,808],"64290ec18310b95eba12be3848ca466c2f106b1920f73a98850174ce1a27d77b":[8830325,666],"64686bfd654c1ed3421d8bb4cbf0df32ed07a41687609136ea5ccfae3852ae7b":[8830991,808],"6496be0db3e57121c30fcf9b7e6ef0a0a8b359b0d400321f6f4f56d4517e4cd0":[8831799,182],"64b56cf2b7d7ca2adabecac52a1ee9b89a9f73308c14849cee7c2a28ee494166":[8831981,918],"64c61aab8a05129d7cfb2c92fa935690161e6ee523c5f1328fcee2e0c899cff2":[8832899,38525],"64cf47061b94758c022b0d27f6d528fc316ee9ea287ac0818545a29aebfe777f":[8871424,10554],"64dc2a19e65f0caaff5668d614612d09ca2b044e338fe382eae811b3aea2c388":[8881978,155],"650290e25b5b0bd221925ceecf54cff61fb9747ecccd26c8cb014341c88a8938":[8882133,1372],"651686af1f5c9f3aad425d1ca7f0de325e4dc362cb59d9a71dc7d75f3216972c":[8883505,590],"651d8279375483f2d4c4f3fb7d4bbe3b8b878238604a8df28a7c8614451e65d8":[8884095,1990],"653b4f1dd07b5fb3b75f63b36d9cbdc074869f11d4e6b63e3d305dae54b1e527":[8886085,152],"653c03e84a35c5b5456fac1a327f8a29ebcc4c19d96ee0f5e166aa3c73d5b7d4":[8886237,918],"657ac3e3f3363a76e675e5847f2cedab5b5415cad46388fc8e3508173ac2351e":[8887155,153],"659bee27ac76f1462f4e7e5275edac75a6975daa7d2c9e349d92c9a9351e6cbd":[8887308,29693],"65a7254890fcb0844c78f9967465fd1c93c843479a6617c192291da44994f62f":[8917001,28279],"65db758ec2a584e3212468b2e31c51e0d38e488754e07209aebc86c68817a318":[8945280,872],"65df7ffdbd3cd76c122cbe2ade93418f22fa7a944c7e1ac3fe0e55bdc66e12eb":[8946152,904],"65ee4e7c4fb2eb1af467a3198df19fe8b5ff5479cec1993721e874f60cfeb146":[8947056,20847],"660a5e0effff93e2ceba4a98481d45cfd6764e83777c890361d53c8ef91c1c3f":[8967903,153],"66205a79dee145b87125b71a266ceb068c582c34ff7f6a7b4a75197983ce28b1":[8968056,958],"66231b96a9e053f0ebff52897351802dbf3e6e3ed6d0614c9274aa12fdfa88bf":[8969014,153],"6635eac4a43af8268014538e9eaf28c2ca6fc6d23889e9f79fa0a49203538491":[8969167,922],"664271626f562daa87989461e8d10f3eff40a284b4810755bbbd7aca221ff81d":[8970089,25959],"66a523db941ff54ffc630638a4dfc3f6ed487344f60cc5397d231d5702fb4aa2":[8996048,922],"66ed0748c75e72a1b3b042f8565e4addbea98698da9a00c311f245f0980e3142":[8996970,977],"671b6290852a9a7352ed4a1492c34ec8e935b83723b04e51b7eebb624cb52f4c":[8997947,45866],"6771d238ea5e5fc5c8f741b560515bc9cdc65eebcb55f10dacac50273f555262":[9043813,81353],"677f17fe7785b47e49d6d61038d10a7ce2e777853a568e8cc9f39a7e1d45983a":[9125166,153],"67d1c111cde6722ecdc2e41d89e0436caca95519da393ab625b8198f872cedcd":[9125319,922],"67d29f3452dc33ea626392f33c0d2f405f0047a410f0da75306a93e5c354df21":[9126241,42394],"67df6d3a1c20bac424e4a0104bf02ea3dd17d728c930e641c212ce0bfd3260d1":[9168635,666],"68378dcd485c981dfab72e81c875ccb531259c1a37a97b80ebda0c5f0ca12ce2":[9169301,729],"6844a11e7ff59e2b07df44e86039349fdba2b82241a712904027c6f01fb5a215":[9170030,3327],"684fb53ae4247df02f3db61d8ad62214d3127d2c1e5ae26d4d7e3cca7afcd2a8":[9173357,6915],"68a1e1db6c568b454f359b22991e0afd2f106215d6fbc0ac4819984304ae52c8":[9180272,3773],"68a54f4c5a8e97e13acb259cb1757ccf8247bbd516eb49f208ff4247529ddfc7":[9184045,988],"68be37dcd4b57fdfbfcd0ac160bd5cf63e4066585d837d20a6b4fe3f4d380de9":[9185033,32156],"68d04bcfe6181a63cf354adcc536111c7434c2d31a95aeb00d6ec48369077f0f":[9217189,698],"6940f3bafc76c9b6cad5a5ba9a1539f68bea5a2808488d09a9b4f0167c55bf90":[9217887,808],"696f3106e3ece785a017361e35bc943b44d9d2c5c857de94c1e16491ce5b31c3":[9218695,1069],"697b0b828643d41b8ce208d441a0e11fdfe044cc743514830d3c36287b46a778":[9219764,7557],"69bf9de1d96653068bee489d514b4ce0c80219a74bb74f8c0385f79d1196fe42":[9227321,155],"6a542326f971cb64936d3c2c3eafcd8b4fcbde5324c1987c8660201be2e2513b":[9227476,4640],"6a5d9b61ffdaed3a3114f65cbf05beae1f376860b585901c664ada68ecbf392f":[9232116,4703],"6a6b6619adb81e47a798af960b6abd39cd2e15ee17715a322d35b46beb080d55":[9236819,152],"6ad1366f0e3584b6be40a3b210ed7ca6dbb0fd8c6923a89600ed1a332b2dd8c3":[9236971,590],"6adf6880cac78c89d1a92012d4d64572beb4bedc32f168e37cebdcdda3d8e988":[9237561,1937],"6adfff136c3ab55b6c0c4c3394aff5afa2ce8d92d6609cab94b015f914efcda1":[9239498,153],"6af57a562a2a7d1e0c5f6ccb12cd258730e77ac54aa6962e54325b0107fd22af":[9239651,153],"6b3a20274889219c8639ee3d1288c40db546164620c8e2be73a0e7223c6bff28":[9239804,2491],"6b4b2bc5847de3661a5c3620e7e5879c5245bda4da75ca62479c39bbd1342e12":[9242295,182],"6b501fc1cdfbfe71c5608d119b4109202ab9688a82cb95ec19fec299a08b93d9":[9242477,904],"6b6c6627ae4d677f7564e492a3dce4c9b6a3dc2ed3dcec0dc3dfd0fda439753c":[9243381,660],"6b8be00337414bd860ff5b608eb64683e31935185d6f385253f43feb035f35d2":[9244041,4703],"6b98457c4a34f4460528fd9867a16bd87069eb66875a351fb23205533e511bbe":[9248744,2379],"6bb9db0a07e5a48cc3e503e2840b07f8415b98ef5825523f939ddb38479b2f95":[9251123,918],"6bcd56affb9c34552c2a27389cb81b291c1d9e4849b43d64ba9c36481483ba08":[9252041,182],"6bf336d39d22a8c6030aee19a8196eeed9175ad3b878994141925ad62558dffe":[9252223,18431],"6c1a472d3027472359ea63cc983f826a948f4842722a82fab531fb6531e6aefc":[9270654,590],"6c1eec3069dd2180b4efc441017aee3036dc0c3776e8f328f416c34848495a09":[9271244,1906],"6c5e24c3d70b969102ad6c52d78286c46b978b073021657e8782cb19e8c6c5ff":[9273150,808],"6c623b324e376142b4f553e8f691d5cca6cc11b787b8fdaadf3ccd75da721996":[9273958,808],"6c743bf47b6f7c61be2d365b61f7143d7d1045e5f683095cce63010c64586d56":[9274766,13916],"6c76641a8b37f5f214bb69a79f10e7e2001600f0e6b2a7c6b10341322b076738":[9288682,182],"6c8c4298914b04ebb38d049d5491992e4bc548c869b10f09009fa449b1488d47":[9288864,841],"6ca9013528a44500b780923fe8bf0897118372b61aae2c251713f28e4b32dc17":[9289705,5214],"6cb21bfc68622856056fac18b252e6ee0ffe650bda0923fb0745b26e10c18a06":[9294919,19848],"6d2edab1f41f8626463656ad92e21da6d59e6dfdc42bc4f6863574fe19ca1ac2":[9314767,1116],"6d3d5f796701f72b83a9e496cd95ab717b808eab8e67a8e0ce71ba5f1c871855":[9315883,182],"6d44d704c68324ea25ff3bcdc9bb7fe1814aab2299b55fc04a7da4f19a006f5a":[9316065,68664],"6d4798b1c65398c11513e3f38c5ce2f841b6d0e7f4a7c7274728fb106d666b4b":[9384729,666],"6d936a0e261cd28357178855ce558832bb46b36d0c3ca5fecee7424a3b0e8034":[9385395,589],"6d9476b3631e6122304c2c6a89413429beaace6d619564aeb82f589cced81421":[9385984,904],"6e205a05c8b2c97f536dbf79af7ff9b68e73c20b5235e6a2a4c02f29487d778b":[9386888,182],"6e23642d8357ffec6e50f5c11c2788c67c0f8e64527c77601207ac9bfd43cd8a":[9387070,275],"6e4cbba61fd21cdcd1b1e6b55c721c6aeebe399a4b21da167b505c922dfff6f3":[9387345,4752],"6e4e4c5aa3976236133ac5bd5afb5ea6038ee074a4ffc01e7dc6f7480068da76":[9392097,42734],"6e5fc7c645dc91d9cf4cc415d91749bf9eb96cf1a13b17cbffbf7d549cfb3c76":[9434831,6649],"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631":[9441480,671],"6f476c32e3963379942cb774adb729063493af424e7dd6aeb88fa7e7a5f05339":[9442151,958],"6f4c23581ee3f6b48a4ab78a30f25f9db16844644244f25b24c33dc8fa13ea01":[9443109,4752],"6f55d363e3a416eaf75132899fd014b9b08a467d3d8e5a45d2501ed52d078c19":[9447861,182],"6f59cf61c11b2590df13b604d16baad2eac373cf823dcb053228eec905b1a12b":[9448043,76572],"6f6137dbee970697f9755be183c8791e61a914e05e56088d22d5c257a942d1b7":[9524615,904],"6f9cb496dfb5bdf61d6135b05a0bb296a7e89b97e87b7b47a9cff2cd527eae4c":[9525519,182],"6fc1b58dad295d780c917c10ac0f793667e46c455349cff3d42e6855501a91bd":[9525701,16366],"6fcae7d7c9c291da8e2decb732721c5aac8c24355c6f6446add4868947d4172e":[9542067,872],"6fee8edbf82d3009af21c5baa1213cb1c7ce38ee54fdf03772e50d6bd398ec5c":[9542939,155],"6ffc6466a8595d2c095631bb4e83ee15014b405072b0d39e9b6514a0e58b15fa":[9543094,9794],"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998":[9552888,4258],"7011890f658fb28bd29684802e5e53bb5d54d54d3c5a64cc926a56fae7ef944d":[9557146,595],"705b59af56340d68ea1a4013142b18ba36d0a804fdf6aa41772f7670d5bfa188":[9557741,5085],"70b1afc80bcc662378f9d33a711fae3de4acaa72b7b03247095911bcae0d81fc":[9562826,962],"70c656393697d4192e4f4cadf2217370c3556a0ec85655605fb63deb7a400973":[9563788,666],"70f66c8e4c3cd38f3cda7cbf81fa3dfb8090b766f637732677c0ee9c246e3420":[9564454,31126],"717c6c9ca5e5a66e3259378328118cfc63e688bb38d21e1b8859fa8fd0555a3f":[9595580,182],"71828b51490969f48e682658f9b45024a0e91a6afffdb4fb0f16c4adfb1e6454":[9595762,162],"718c81a492a1f7a234bee77d7eee3f7bd324ebc5a98242d54290b1dd3c3928c8":[9595924,182],"71c162a3542afe744e62fc939b3dd97d81d0ade4004514d08b52fae4805a791a":[9596106,841],"71e3f1a828b0b9746242477a49eb317f232cff863691b0fd0d24975000586437":[9596947,153],"720802b41963076d1e095426f1329712ad875239f810b742b73ea3fb2e74b750":[9597100,182],"72264f7c581abc0e39f643be5a26d2e6e3921853b6276836e77df90b359e8fb2":[9597282,1155],"7230e35b63a6e73c43fbe209beb2ce01f229aed222f844a91d5986a90c6da014":[9598437,729],"723676083558c83d83926d043c8edd16bb2d48cfe3722fde330cf0351870b1a5":[9599166,669],"7267938248a8ebef12055a569e5a2e2aee73422c9eef4f21fe487f509eb47cba":[9599835,841],"726a20b8361501f4c26633cd92835ffb2e5f1acd584c0af76895d29bf620a7b1":[9600676,31137],"72e3b5e1b55961905a023c2d37321813d21891c19a8fe681208137acc810c710":[9631813,103069],"72fa7c252c24654c4b70b21f44048c170b79b60a2c535fda6b57d6aeb0b6ce0f":[9734882,12504],"7323d2fe542079a93b6ea9f1224b3c2b9f275f6723481fa361f90bb094b1d7ea":[9747386,808],"7364f327220b018b78ef285cd6b29821232634319f7ec524710a9bae3d0f2815":[9748194,277],"73a6d090ea83ac2eafc5df7c7dbbb3b0c04de2f11e5e1b54c8a478a6d12b0a52":[9748471,23162],"73c7b0d30b3283cf87c1dcebfca770a61d530918bfc1327077b62fd25e25a2c7":[9771633,10286],"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264":[9781919,596],"73e1fb6a0dd8c2f46641755ccb23626e2a98588527aadf13c5d87df0caaac4cb":[9782515,182],"73f3b212b484c10b408360bb9a77fe4b96dc4c88ea5051db77c94f4bc25de05f":[9782697,962],"74587c825092170c843a7baac4e6debaad7cd117b9b0117ba732b3c97df8a151":[9783659,1116],"74a15a982db3032ced5807cf9fff902af82cd3bc14c35efabba79f79be8fea64":[9784775,918],"74a4b92dc8563949900bff882300afe0a481df48da7ad1907270d6447ebde728":[9785693,244],"74b714673933773aead0d1f648439136d48ccd363f15be10afc5bef8c50e3d15":[9785937,152],"74ca02512262b14fdaf325e5c7a6ee935d7a05f30a49c2243c48194eef9ab7a3":[9786089,808],"74d3954960c65f0ec53a7a51e149ffc77d3ccd25b342dc633380c116c9c5dbd9":[9786897,593],"74f67c7538797091e6d19bd6f87099d0b414743cc4cc151db667619ff4a48d03":[9787490,669],"751db63a5de2b6fa48a0ae017615e77f92c52f4473a15abc234beaddb7968e5e":[9788159,962],"75382ce3f665c0d4ee845b428d96a5eb0d1916b8255b74a9a54d9cedd53e67f4":[9789121,16208],"75c4d1e31fe764e6151a83efcfb61be507c1c1835b2de95f862b394c721ea805":[9805329,275],"75f3d1bbebf021252cad42fc4027fce6cde4a59bae6b77b8b6a0f3f00277d0cd":[9805604,152],"761077c4ddd0bd5bb9464321bcaa81b7914e3fb3c6d4f60df9948c8968de6300":[9805756,159],"76182b7a2b30b87ec08d8c926655418e212e19ca0b5931bc8379e692be991ad5":[9805915,918],"7685bd530be51ae184f7778e312213d6858ad20b47dbe8f0ca31cf12a76fe3a3":[9806833,4866],"76b3525c65c77bfd6f14d1c4754fcbe07fbe7c3b4acd8e9b176dc8a9f9676fe6":[9811699,666],"76d4786247eda40df033ca949531803a28d3fde9ab0a8b10f2a392b0943a4578":[9812365,275],"76f91323327cf2f0a66946473c1176b3988c304d57f2c6c67626991dd449cdd1":[9812640,665],"77458eacdb5880172bf030655dde4736e0a1a87a063ea2042a920a49f23b1486":[9813305,1283],"77565d07fb22fc466e60fea5b82afe55c1069ef9418926bde659b29270d68a10":[9814588,1937],"77b321d0dd911de45787b1c235e91ee661d8fc9ebe0b4058e3d47da9633ce406":[9816525,56117],"77d34b8ff3a0bb5ad8af03bb496eb11c282abf7644ae1cb2de26da409182b8d8":[9872642,530],"77d618c8c50adb842ef5341b68fed618dbdf80a941af8d4cb512c13f6ca5c861":[9873172,1458],"77eb315db67aaeaf395373e2441d4f3e3145ef6be9a31dfbea1786d9600873f5":[9874630,14291],"7818022ee9e7fa7e2e1e65a48542b4d2c45c65133af81874691616862148704c":[9888921,155],"782340da0a7d9facec2d0d42bc2fc55bab1d1cbddc3d888a91eba6f9ef9bc7a1":[9889076,666],"7829c0fa25e6f25e20d616b6a7e27014e2db27a7362e56f790a9b98fa953a2f4":[9889742,25431],"798085b32f0ab8768c8501da59a1fa68a4238682f2b20fc42b22e56810edcb7c":[9915173,1033],"798cdb510ad310c837372681f20606da6374c6ac6b7295ebb3750da7a29adddf":[9916206,182],"79b5813169ed5adeb10cc35fc52c22e390bdef3e22fb2a3dd0b3a300ec844dc4":[9916388,590],"79e9a04ffb5b7ee4fc6b60fe73f5e8db31b0bf541a06a0ba30b85e41fc6dc96e":[9916978,2377],"7a087b2d7673515a1187be0a27048a44753403ad0eaae3c729b2eb033803ae7d":[9919355,5128],"7a2a946320055ad4f7d38c78ab28b76b7df4bd2e20faac4b97bd2847b91ccd42":[9924483,594],"7a316ac4a039302ffc0c5389f62f25c44be80e3e389047fda6d4e1cd17278eca":[9925077,47107],"7aa7e0b4e8cc02142a8aa14ca243a5f2058d72de2f65d7ddb7c59e3d201b692a":[9972184,666],"7aade65c295fd7c6a375f7ec73b6d0714c4c4357d60a39932e9d0c1d3ae05c8d":[9972850,182],"7ad40738c7b69c35a896d1b946f4b782095e8a4b9c7c551124a798d34783fd4f":[9973032,182],"7ad4dba399677a4a801444f26c938bc4594731235bcccd9a979e1728b21f1822":[9973214,595],"7af536757cf900283681a64345fcb85d37cff8cdfcaf67730fd3018c833d4bf7":[9973809,155],"7b0479db5ce647aba9cde47f5b90528bd604603d4f45944be354903b4ce0f641":[9973964,153],"7b3286fa83b5448660bb53d46ac4c7b3677688ac5fb3be569d1ecdd31223dbb0":[9974117,4455],"7b41f77a9bcaf705eb5b427904a4a1241698d878c1751e97e606b77f95fcacb6":[9978572,4752],"7b54deca43ff224cc112166abd958048b53b2762431c41a9fdc1044144d9c96a":[9983324,3734],"7b7289a3f229df873c393172b4262385ab095200cce5fb9665654bedecbfa838":[9987058,698],"7bb567f43aa5e1bae696ab6c876428642972c38a84f01607d6c865d4ea8add0a":[9987756,1259],"7bc64170f34a7e544a0492e00af3a957eca837cd1ea6b40d49c4edda9cef5021":[9989015,970],"7bfc8437757380e5783a1381f2b78ff6a753c5db50b8afdb02095deca4527799":[9989985,590],"7c0f8d97523601ef6320fee4befb63165c423413150ad0501e4fbfbe989cf093":[9990575,667],"7c18bc5ca3c1328abb843b694d26436192f235ec2f71387ebb0d44f81a3b3f58":[9991242,729],"7c1c8d5f94018fa641204b7583e5d2eabf1ed6568d4848ac3f7d632db7abde4f":[9991971,872],"7c26384c4adc5b5c857d0811275db450218c8f05177ae35a898ec59a5a5a5e03":[9992843,22488],"7c568214148bdacf0c078e2420e1c8355c24100c703f3490f65ca9bcf1f64535":[10015331,590],"7c7e8183b9fbf594f3d61a4ca43f5154c404f912f32b8789d32c697ae883859b":[10015921,593],"7c8a02c0cb7a845df0259b0c8cf930737f4ba70bf3bd0d01b2c03b4702e6bad1":[10016514,2644],"7c8f402a0ba50871aacb4a33b939f1b56ef4f0d0ca80f305eff6b62e15140d86":[10019158,7467],"7c91a44260d7ec6f97d7ab86c6df36209f4c85d361588974f0d6ee7afbb1ee99":[10026625,152],"7cc8aae4f60d40a47c0d61025d850803a6367f406e77d336fed1494ec4cc66f7":[10026777,10451],"7cd4b1ed38095f7a1f90c73725887799cb7dd694216ddad461640d2ffae0894a":[10037228,12499],"7cd5a9929b27c817096d66c2bc06bf86dbe2aac8f9f5c8b0d5b60f2784fc8108":[10049727,14291],"7ce7a06750d59a5b6d453d4347cde232d6ae0759d03588d22c4f91cc66e68a75":[10064018,962],"7ceb7e024b769e255bc6f1a9ada45327087cab66a296c78db60c95593a13fc95":[10064980,49713],"7cf9c419b2c4f7c7b17e2c1e4362861de714f4ffe54727c1a0fcb749b2e6975b":[10114693,14700],"7d1bba27fd84f0edae130f1902b00abab524e3a67d0428b1a2fe7a2c01af5273":[10129393,53348],"7d34cff492eb5680631c7e2814e0ce99d26211d0fb48fa4f7e679c530aecd7b1":[10182741,162],"7d7a798cdc969969bfc1a0db6eb87e02bcf2f96de636da10f24e4dc97700e2bb":[10182903,182],"7d8366f090457ae3d2608097213a8f56ed50bb5141644333c0192d93202dbd0b":[10183085,155],"7d83755f6ce960c2841cdb724eb546edf3bb4c2029e733c9bb4f1fb97979f2c4":[10183240,16180],"7d93a802662e32fcf39c96ae1a512d7c159acb157f7cd565c36a9c03f030533b":[10199420,12670],"7db5dde61083a0f2604dd4146d097c08eea778c6ec755a14b9e81d56297fd963":[10212090,32597],"7dbaa07d186e7327cee5e1ecd80892d64b8404f041c6c2d5de6dd26c70b58f92":[10244687,98294],"7dbbaba46fdbfb1a9803d4b627e73727baaa8c487dcf773847e823c0d0c65c4b":[10342981,1990],"7dd5a527d42227e42aa5a6453ba0d2772134ef33870220d7825c08d0fceeb01a":[10344971,904],"7e1b36be43ead83db29e25bc1f71bdb506bed15168d155187a319aae27a3538c":[10345875,8517],"7e3b06cf4d98fb400fec3b556244dfb5ab962b8e2e4f6d1b6c7696dfc0f68e95":[10354392,729],"7e4bc429059e07577a9482c20d837092c1e6dd30afd36acafcc5ddcc3b5e844e":[10355121,27246],"7e4ffa4c9dbd8fd1d67596d20bf09dddb21963b405313f57ae2a7162263e26f2":[10382367,4866],"7e7629d63c65e993d9fe5ed8f2a1e7af247e812144a5ea00d4b316b48edce7f3":[10387233,182],"7e8961fdf02dfffeaaa9e4ca5d864936f16e35bea7354d2f0a95a3b4194b3fc2":[10387415,858],"7f1eabaa9393b09448bdbe36d5344e24ab8ad290ef6b197447d81880327a5097":[10388273,589],"7f226786a1ee41ed714d62cc1cdba29826ab5b14709754135c2d3e341ba18583":[10388862,155],"7f2a30fc6032b866f25c942d94553c9a0c3c1699104b0a3bc02a91782e1d4763":[10389017,650],"7f362809b6d0f959bc8479adebdde987a718107d3e70636e879e44d0d4c59bb5":[10389667,593],"7f7e00683ff02b159e26ddae0c5a328bc874f56440e769619915f14a5924a243":[10390260,54199],"7f8fe1b370188a60f00e524cc5c8059ca17a347b97915097286fff2c0ec7e290":[10444459,28001],"7f9e31d9f6b963263ec5d0570ae2b5a166ab1fdb71ae5fe9910a015ba36d2a6c":[10472460,922],"7fc4e9e264497d30024b29d7e961fe5f9e61c5503b957e94ad69c8bbb43aef22":[10473382,808],"7ff7966c981cea6ab644f6693067d84a31f79cea3c74165b763ac75a5d1fa125":[10474190,153],"804a2e8c904267bfc26a9aa02b4f8fefe358c7f0b7e72fff06a2175eae58d613":[10474343,4866],"80a5ca3cb250b5aefac03a5ef11becc14107d0f2d40459b0bc408394a44b6df0":[10479209,182],"80a976ef85f0ec1ddea407f891d0d027cf2423bb67b1d1d1a96282b803ab47a6":[10479391,182],"80a9dcd58f6661718787329caf61ecb59da0ff7dc99d6843cd8c53f67e5c3539":[10479573,4944],"80de7af4801195c06c66e89a828f74f14235714ed0c201592f1f498c0be72fa4":[10484517,962],"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271":[10485479,613],"811632fc99d3866ea5023edd4e3250e7989a769e987cd0ebc1a6ce8cc3800669":[10486092,153],"8143fb1d2c2022405f5c7e77e08ea12da13b2379521f98fd50131e71c184219e":[10486245,19932],"816c24ef0a2731b58914c69769bb52b312d0e0af8e1eeb189b4f213963c656ee":[10506177,3675],"81ad50bbece9f59b5017fd32dec882bdb591f38fab4f4651d59ff739e144aa1a":[10509852,23638],"81ae8209c3650fbf3b2e48b344c53e389ae845e2a0d4eb02e5e4bd46a2d22927":[10533490,16407],"81b727d0fdbdcbf9412b7798b650076b811080051e9b4598ce3677811b30443a":[10549897,589],"81cec1743279e9e2ae0ba8ae558ed533add7a90726fd1445b2f08c87ba8f6e6e":[10550486,31927],"82230860fc74fcb5883ddb1ec611853b4fcf15c6e885a9bc5f92072460de3e40":[10582413,665],"824c95b7229ac2ad5b1cc518b296443883fb56a110c86c2a2e67923c0a19ea07":[10583078,9826],"8260e1c905d4a8aeebad31997c947c730bc2e0ee5aca223106e8c9f624251ddd":[10592904,24662],"8297ffde0929fd0718524194af9d541a222a05abfcd455c3db5a0d7ed33c3318":[10617566,1069],"82acfbd2af961a35f71f855f259486c4b37d79987b0fcf83ed5470ec43fcfc46":[10618635,14875],"82ecc7c3b03532b8e56b1d3b77b5ced7286b28d7df5ae739abe7b8516a32cceb":[10633510,153],"830001785de125922d57970caf0af5bcf679e6bdc24f008fd39f80867db57535":[10633663,670],"830cb1dea8bac30fabe6ccd94440f6801106adc77b473dd96a0c10c464024b7c":[10634333,15624],"83603a421d55207846c9b25009242c586258bfe1cd62e7ed7323c05a31bb9c3e":[10649957,904],"83836a41a8a134e584f026364293adc11280fb67ca24b8ad503c8bb6d1bddc3e":[10650861,872],"83e7eb0b234bbffd0573d91bcf92e594e91a0a15b536e6d5c915a965fe0d4309":[10651733,596],"841bd6ad2147ff29f49633ceddc832a76537590daecb7b9d081695f67411d753":[10652329,182],"84428602c5cbe86749e6c4629d39f0b5aba863df62c3995134411ae5e016d853":[10652511,593],"844768193785656d809128aade8b3718631c2fa633fe38b1ef555f7eeb2c919b":[10653104,841],"8476e5211cc1a7464dab8106d6617bc834a6feef4a368910ed8d8947d5ee5447":[10653945,922],"84896d8b74c52c4a66c9b611c2aed6450c9abb79faddcb140433fd2c3ba1ab21":[10654867,590],"848b1e09fb14a041e5d7a3032d00b422c59c748cbc9582f6ff16a6efe95537c4":[10655457,545],"84919e32e6e88e6734ae75dbee62ceac10287370142e4a6245e3cd831a454f45":[10656002,106068],"84d84837ba95cfad52fe647f42369fe836c16d20ed24afe909f74ec298b6c82c":[10762070,1283],"84ef791effdcc0caa4ec5007e81a066e64134ac3f9ccf602e80417fcadfb48dc":[10763353,1458],"850028e21105eeaecf405311977bb901721d8285ea8cd64de68cd5988981f7b6":[10764811,21323],"85123faf2048e67027d34bf0103f7845d09d03a86f5673f3f08279223a89c783":[10786134,277],"8517233f579ca01d75c8bac2057c00cb856294ba56afdf26f56b6a8423640ac5":[10786411,277],"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262":[10786688,10356],"852ba5064cf1a143f95368cde399c49891bd7a420454fc1fabac2556a9960eff":[10797044,1506],"854dc3f816957d3a40ee1d16be7b44fb2a161a33e4919cccb846046979cdcca5":[10798550,45910],"85762dcc9d35a28a66d8b5dedb94b8d9897dcbffb0c7d9a65159f44101942b8a":[10844460,27322],"85a337713f60111fece6cdda3168eed43966c95adb1458d324acba9336110b24":[10871782,155],"85a80192d0fc2c8aeef56a7699f30dbdf2fbb9f6dd6910396bbf4f0192d1c9a0":[10871937,661],"85ae92bdd7c73f83c234c94cf4cbecabb78e5d915ac63d6c3c54fa3c42ab8baf":[10872598,152],"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e":[10872750,5605],"85e15abb29288f75bb50f1bd7108032016714dab0c7f5c47618da58412ddc4c4":[10878355,9828],"85eab36f412936d247f59dc688116dd31246d27c0b86a7b3df52829d27e2d90a":[10888183,614091],"860d72453c7c06ddc72fc7b94a2e5550f8c1a2487e16b801660b3934678a964c":[11502274,4866],"8634eb7965cf1af14dfe1b6636267db0c9ecceede5a774ea744290cfc7fc8d57":[11507140,20491],"86365b7132c4baa27aa65731c758b4e6c8611bf9d5d933b1b333af3ca8353124":[11527631,1069],"864391598ee91f825cfe21eb337df2a32d03ad7b9146288d6d14bc13547a2e0e":[11528700,9978],"867d5dbb9cda97e64ac015724d182c88499cda8c9d01a4387f22a3c70a3354b2":[11538678,472],"867ec849989971ad3b587def426e8e53cbf2bc811540dd0424581ad7d02d17aa":[11539150,73470],"868f76d2362b5f6fb8bff7869364c296f049682fd49966a8a0a1631fa77e7eb2":[11612620,590],"8693e822f44a2d7e65cb5fd1e13babef8d7e44492a91b8ff979f8899a6ffad43":[11613210,922],"8694f250fff819568098ec727ed32c5bdec06668409e88d8f7bd9c4d2be2d919":[11614132,1857],"86ce9ff7f8600deecaa439037b1216adc22343d0d71a6db28bc9f7f563f8f7f5":[11615989,922],"86cf27e1f5896a81f28f1c71ec9658129a767c388c0a01f8a30bbd5196c83ad8":[11616911,182],"86e052278b694f3b9f1c9181b34cc36311bf633c7a8f0a48ade6642c14330e3b":[11617093,1458],"86f4be868470b027912b9e0320d791dc18d5d2a598607604112db0932ca06366":[11618551,1069],"86fabd45da78a46bbfa7e92465788159360c0abaa4402f10ca433779b419ee0d":[11619620,12496],"8714a562f48aaae01b0823b7a72777cacdcc73b790345d99da60f74d2df1d05b":[11632116,23403],"873378d3138b0baad075c5f7dbd2199724a15cbefb539ea81b8c2985a4348ff8":[11655519,23644],"87384fb9465ce6341cb179768f2b35a68b7a4f7ab1ff58a7e5cc220b82a22793":[11679163,593],"87494fd4d2a072cc5e3c104f999ede297ae356811a0892c93da6159099aca97a":[11679756,152],"87551eabcbe13be60e49b8f02f2f9fcd5979541174591103a9e2d65894ab67b3":[11679908,904],"876dd3322766a222bc3c0ad9906a8de9b4d33d3dc94005254ef267a820d88c4d":[11680812,808],"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812":[11681620,6412],"879f0430a2ee9ed4bf1d2c55ae4110f1d318fa24680188aa68bb7aa85c787092":[11688032,3051],"87b30324db24b63bbec64509b4d03ead8cd8c4b512decc5dc9cd8298615ff274":[11691083,6757],"87dd9ca6b265f5f60f9a6a6a1f6e24134423830ba25de102f8ea3847df5a61a8":[11697840,155],"88239a17d6925822a5e3d231bfc4ede33c1a014be151105af72beaadd6963188":[11697995,841],"882d7366ef80efa4072aa4deadfc97b09d908cffc113fe0f0893940eb7d9f7cb":[11698836,98008],"885c63a11e5bf9dfdcf7b1130d14b1e863a399c613b09c093735ef51dfc4b132":[11796844,4703],"886950009a2a1948725b36b18291c466a4f1b0328bc99382d43779273a96863a":[11801547,1033],"88a51481760d8a0f4d417b213aaf78603811e36b46733ab15d2e2c0d93aef194":[11802580,26074],"88dbf24e17ee341f45a146a0683053d9706ef241609da09e9a4b13da7b5a27ed":[11828654,935],"88f0f1f699766f992a694cc1e3c3fd039f513433940c6fe084e06ed7f800efea":[11829589,182],"890ff6b355337e5a3bedc9b35ebf8f02ba051ed71d9bc0de9a01951efa5888eb":[11829771,275],"89357fb686e206c4b0d3ea4d2a898a8b97d07c1755ea3e585bf57a6c6259f918":[11830046,872],"8941de4db9e015428c1891c039d0a5dc7fba8b8e7c0139e935a1a78719b01d59":[11830918,922],"895b8ef3acbd7dec3f6e70bc5fc6336b48dff4fbb8de6765f778769521b5e2a8":[11831840,182],"89851fc63b0ccf4e6195180c55003c1f7167429591abfed2419b8efd6f314968":[11832022,15795],"89a8ede09b8ffa8f381102b76577f42e29f18a6dc4d3c8a5f41717dda3a24114":[11847817,7909],"89ce0ed1b8cafa32406d8e276252a3f566f1c244ece71df861bc7e01a0ec6412":[11855726,61434],"89dabac5675823ec0685f49e4693685e44addde0e4a1a33949b87812009c3902":[11917160,278],"8a214291d385d0008c8c11387def1d4c226bfb970b090415dc921466ff3e21ca":[11917438,155],"8a470e936db4658560be52c82f247c8c3b927437f47b3b0c0578af132368ac26":[11917593,872],"8a531c225444ee9eacf6b1e281cc90d27656ac4fb9e1b4298aaf29dee579df6e":[11918465,24827],"8a60ef3baadc7921283e0776df0b32d14d509fd15a1351d7fb95eea287ff7956":[11943292,4866],"8a81e3a82a81613db48a5edd4c57e8a42fbaf33694a373e3daec2c01b299d12f":[11948158,944],"8a994f45374c06653ee66017afb1d7e04c464ca7d56291a44606c73a7ef0ecf6":[11949102,9946],"8ab9ede450981df17573f2b1300982c33a1cdaa569ba6019ba2b2162412185b3":[11959048,63133],"8b14d99c5a5821ae24cc95869c32d920f5e3de4cd7cb0ba43498cbfaa93130e8":[12022181,1877],"8b202e4063ebc7862d8d4ab894cd4d2a4874961b5e33a6b7f692615f373f1b33":[12024058,590],"8b31e47aad33789775fb32ae56868e37db7a9dd174e6df3a0262781a7ed2f6ae":[12024648,666],"8b531a893d5d481d843167ae47ca2c5ee6e25a3e12eea9d280d15b72ce6721fc":[12025314,152],"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad":[12025466,670],"8b794d306e3fd039b0db3349e3b52501b680e2b1433ed3d3407cbf57721a0dbe":[12026136,808],"8b827a70205c5aadce37f553b10f080fe5c618cf23c3f7954859011d329dbe7c":[12026944,1840],"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344":[12028784,9077],"8bd8a1458e24576589aa8b3079e2bab2bba5d65f751345d01d89660744ded514":[12037861,1033],"8bea9f8a3c2cf920438a9c1f02a7a348ca68ae4d1e70c5012f6d13b10ca8bbd7":[12038894,153],"8c6419013e3541562c694dc1ef2e89b9f6e2f6063f03b88628d69add60a5b8a6":[12039047,922],"8c88c9d9441329da0cfb5a19a3b073bc7ca96e5a25a2b04f1a9625a6042e68b7":[12039969,13011],"8c8a4a7fddaf37822cf48c2dca7f8f4f11279f85c144fc105e996bc7dfd0cd48":[12052980,922],"8ca414d70d8cd5e289af3895f429af3db4e8d6d35333c741e2c8e6733adfa1da":[12053902,808],"8cc9fdf61c244e993fcf85dc32e4cef5a0147627be93b11e67c48d94d439b232":[12054710,1069],"8ce95d35e19033994f098b4b2521488dfbdbe4b9e886d75fa470e5304935ebe9":[12055779,14291],"8cfc31729380ff097f86a9e7082eb0da3291b87bb70953919a582915d3cce947":[12070070,182],"8dab0fd7eb464c0286fc7a93f3bfd5cf16bf5423d394b74d89e48f512d9cd1f9":[12070252,13300],"8daeeda83551a0dfc2bef4abcda82b04efbfc2033736740fbfabfa715f116042":[12083552,841],"8de82553f5e9c70c2794f09aff4bc7718200713bbb71121e3b5780bdf22b762d":[12084393,663],"8df3ac8e0604ab839c829a8f25bbe65b20d19aa7ec8b60a7d143d8429738c2a4":[12085056,808],"8df850827bf5528280c4fdf4dc3249e018af6b89625b95921d22dc35daf96f82":[12085864,661],"8e0041629b58cb5f197dfc8bda886af7ba35a32f3cd7dd91396b139607cbf22b":[12086525,152],"8e42f30f56767a144a2092fbc091a02fd2e03123da93ff3d5bc50586331d0b32":[12086677,3895],"8e473c44da5b454765bf8f2d1bce38a1d96283090f6ecb57eecbf8b701e381ab":[12090572,50304],"8e4c1ffa253e4e36400d4a6c2338b1fda5ac7f6cce26d39fbeef91fc007e9124":[12140876,530],"8e90fde170fdfbdb66e60ba86860b0542b2f64e24aa2d3a2c3c68c00d416778a":[12141406,918],"8edd24e2f34190335f925f3136dd6649b5d69f3030ae93914cb1f448dffce74f":[12142324,182],"8edf96568badc4c32de793d955606d35a09830127405021e7529b644fc274de7":[12142506,1010],"8ee92d005a0b7518f483e0a1545dbadbccf9d7031f36ad9b0ad91341d6afebee":[12143516,403],"8ef0d60befb8a6c444e8a5e0e22a1691167422a114f4e5bbcb088c6f59f73860":[12143919,671],"8f423269902053db9df7b3fa0f957361cc9b9b62fae489afbb79fd8044cd9fd9":[12144590,589],"8f6de835f1153df3afc1fc865df71466276c7183b640df8d42fcd5760abd1e76":[12145179,4703],"8f9c70df379b3ab58f979c191dc1ae5fb0e5b2199c84757b61c254bb05241ebd":[12149882,27268],"8fad8d332eb735491899cc14b9b5cd9196dd4d8fd1ed6c24376d49682161d21d":[12177150,808],"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a":[12177958,5862],"8fc2e97e808fcac95a2f5b30ad6a354a62928876610eaba62aa9c33f907ce604":[12183820,66957],"8fcd8d08242f4aa793463f5525edcd55cd1f3429dbb5b94621b0731fc0cf6a6c":[12250777,15068],"8fe322010f750ec76a70bf5649a21ad5dc2ddf4d5b3798deeee92ec2174829dd":[12265845,669],"8fe3ea8a3dd647910505e230d5c261594b65926d6521a5c7ad5ddaf6bc752fbc":[12266514,29930],"8fe81532366f2a1068b4c5ef12dc77e40f13f19fbe54213e87bbb3c5dd663d54":[12296444,152],"8fe8bbde4a104e5cc7374083d3edd45f24af6af193d6c3204c0baf2ebc716d2e":[12296596,472],"8febd0b5b5494fd230183698d8b54851cad9cd4b608607440d6f3c63a8ef9c85":[12297068,1463],"900527545a28a0e216c8a1296a1c3c0d100c4da8d2d62c947f30ea2741566459":[12298531,159],"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68":[12298690,595],"9093a24f5cccf37f4f2c54a38808cece50e46b033d5c47ec6bb673aa7bb667f6":[12299285,1783],"90c2a12221a5d13b21346e2818bea79b2a17f3efc39ecdf23c483a6fd73e4cd5":[12301068,25490],"90fb58d8a43b9b44499368ca77d4becb58619ca4fa939b91fdf244499911b867":[12326558,153],"90fda17ed1ecacdd0eb3e1071f96f3e6554f8428099e3e46215c70b7b5b2c6ee":[12326711,841],"915af99a7d73f21daf12b6df3aee28d7765dc6795625058063d00d70ef37114d":[12327552,1033],"9180d3558d4e4f214b67bfcf1d286444ede42028ae79323de5182144099312b1":[12328585,153],"918d286bd31376475d30c41b6ebd2927639cb3c096a08987c6fccbafc45a8467":[12328738,841],"919c26120be167a2a8575bf26f1533438a6b28f35b0757677bb6236b0df7c1f2":[12329579,918],"922a54930e3affda2ce0caa8792c72701e96bf24f401241581977a3aff62d9a0":[12330497,159],"924779fde841601c0dea96a44a01657c360a1010b3e6bcf847f5af378ec56bd4":[12330656,872],"92763225271bd8b28a9e0138e215ab0a1135b310467b9f5d77ef542150b87ce0":[12331528,182],"9299d41dbfe68315e529bb35b5979a2b48f3b5b911d117b684a05cecc8010e4d":[12331710,153],"92d354f0a491ff544847ab42387fe54aa459f84daf67ae1b4ae57e4218aa7b7c":[12331863,922],"92d59dac95564d35163ea10c3b10bf3e769adbc50ad7bc5d5574b6f562d2cbd4":[12332785,1937],"931914762aa99348f186b8cea5af01d5cf1f22a4e9240b112ab2efc3c122a9f3":[12334722,153],"9321a742b82fb57726c27849b15e2d1865e3521dd95838968055836eca5ac06f":[12334875,34957],"936d34b95038d04261f146c3bdaeeb2ed43d465b2b48158bab16b42ccd240d8c":[12369832,904],"937ba4b41de7564b24b82f0a73c4682155a931e651b629000414a8eb912c84cd":[12370736,698],"93bbadd9800acdd4b01ec77020982562dce3e81fc0250ce794d78654c94a1238":[12371434,666],"93deec77e97bdb6dd1c752388827a4e8df0be09ea04ef70c848188299a91a284":[12372100,918],"93f11780827b58057b742550c206f180d5fa1659b216e90b8cfded514400be21":[12373018,62486],"94697ec1fb16dd5f899f438ef2b257f8b6c5cb611e93acf940a8f2fe94a3713f":[12435504,593],"948b44256a86924b4ce83e4186eab09c3a76f43399b8f1cd3833e408794b2f25":[12436097,4569],"948ef7cd994335320ebef7a5d3ba1380ecdbdcf64b79ac90a8d451bc3aeab382":[12440666,60870],"948ef8e6e6fc7941bf52777ee3f417e54e62b0fc6e5a2653edbb92e487bf921d":[12501536,841],"949e0f0bfd6d49113bff3ee9fe554c97548767a31ac1ec20acd4efa3b3051c87":[12502377,988],"94a48069d0b3d0b8deea53f54a9826ba0c934c231d8092f57a51e2618f0b1991":[12503365,12684],"94c66c78dbd5b7ea370055521330ad92d3a4dd6c1a6a61ebace3e14dd3b1a9e6":[12516049,1307],"94d677eadb125252e6496f82e1fbcdccf4c5cd4ef47e4f4fc9e4123bcae971e5":[12517356,153],"9514ab311dfbe2de23886f033241f2176b748a360358d717cf07341bb9761fbc":[12517509,590],"9524b0a6803ac5b89d554b2b315ab4bad50bdcfa69866332ee5f642a62749fe0":[12518099,582],"953a5312661e1d886229af4e8b9c4d1ba4f999c0f68dab2e97a4ad590c394c5f":[12518681,26003],"954f7aee41d730dd4add46db96ea9eb6626abe3737367ce24f33a08bcb1c847a":[12544684,661],"9571159c722eefc5942386364dc0af248621054973e5f74d0f077255883d66c9":[12545345,12485],"959646479f5bb9ebecae46d55409a347989044ca14d09fa8a58ec33c5261cd9d":[12557830,5111],"95ac3a3eb2c35b5fe7f2290bcc8938ac638c4bb448094a5bf329bcb6775c7b2b":[12562941,275],"95f8e490106a8adb4acd16061d24ba95161340b852e5e7c19c1e31dd79c517df":[12563216,1033],"96176977e3fed384189ea64792ee358a188d1a69c04d71ab7b9b39c250515acc":[12564249,7419],"9618d11c1310d5a64c174ae986ddbbf5bb154dec9531d2603b01a5adc012af36":[12571668,15243],"964288b7e42b93abdf400ed613288e91abdd7743b80a98304f1069e8a62adc6a":[12586911,17665],"965abf626c9e73aa1b51b9d2a13800b261e8148d165b18e31893da8ba4853f63":[12604576,155],"9691d156af5563769cd7f60b8dfc22f0b2cafddc9dbc63b1a2147d8333caad4d":[12604731,82830],"96c5a0160ef7cd5bbc63f60aee0e1584e4ade7104cc98dfc1a134ff19b7411fc":[12687561,5124],"9730e59dd2be0d358121d43d87b81e6125a07d020279eca43740b48ddd0c01bd":[12692685,729],"97a8e7c78658da2307e10d114106e14ca9258fbfa5db73f980a627e18ef27bd7":[12693414,4703],"9853f46abd1fcdca3b3eaad6a5970de328a6dd30d982591cdfe46507193679c8":[12698117,155],"985ad0289abe71d78be6986f66e458dcbb06a8c9d12b2f2e7034223351b7a862":[12698272,812],"986a298b6293326971aff7e849b8b51340ebe3941d1b79008a16b883883ecf40":[12699084,590],"987051556b15f00bb36dc30735ca20a94b4e40eb0f56dfc387daf4379da7a7f7":[12699674,14457],"987584cdd70b8564932600e72503f389a6d9a087f425a596c53d19bc8dd28f0f":[12714131,808],"9884824a5dfc9c8cf97a055d9293424ae7e580a6a6ddbacb45a9f1f91f400aea":[12714939,743],"98b42ae47d4f2e1a604af5e2dd574838431f481106ddf2ffa3ca141314664325":[12715682,182],"98d0b7b615fef2222e2e849f057800a50039f5760044440940f6537fa141d791":[12715864,5700],"98d0fa0d5ad367b5987e6034c4a5aa988959fee475592cc49bdf39038d1b0194":[12721564,841],"993897502d1e7b84ca9914c016c743f7d163c68b2abe026c953d21736dbdf21c":[12722405,182],"994601f68461022dd7cdf9e181bb4468d9ee9e3d365b020d1f07c407c2c48e10":[12722587,1307],"99654967a9c0c27da23b08f49759c35b1b8ab1bb1d220d4c144a76229c1a4487":[12723894,582],"9965ec5aa4c7a73cb0378cc029d663771c723552b101181146344936c58cf6d0":[12724476,82605],"99ba59b82cace2c48a2ba31b8f13d653858d72726e718e3c342ecb1a39b50873":[12807081,18868],"99c12346f1cd6d71b7cd26fa9473d05fd9aff05f14a89da0648d75551de9f6e9":[12825949,590],"9a0874ef2db46a62ef0c1dcfab628e25866f100a97fa8ada6850a90ce771335c":[12826539,182],"9a0caff48b5cf4bc11b1b489b1e9ee8b2c1e98bb3ee5a3bc45fb240d9bf6d7f4":[12826721,582],"9a16a6f0520451376f7c5ef7dc56164853cdd86b54780e14de506e0a2d0be678":[12827303,182],"9a941aafe5dcd0afb546da021ad7f6ea2583dbf4398e88cd73799b5c124865e7":[12827485,589],"9ae420e34160e7c2a6c2ba092aaa9c9ff1df622ece595849d95bf5c5eb7cbe81":[12828074,24890],"9af214086489872612de8c2c0288770bf2efa19fb01201977e6384d2e2bb4246":[12852964,162],"9af4edf6b4d78efa8c3f5ec8e4797e5c18de1fdced44a7edb07d90cfb9159653":[12853126,182],"9b0ba912fef5e3a2fd28159d13c550f0d62d854327521825d4d886a2870fa4f1":[12853308,698],"9b23b62a07e1249fc078c3851c727ecf191bfe6c99ff2f5644f5eedbd2ff94e2":[12854006,593],"9b63f6982a759ae6b7602c9b1518deca00f2da9ef63637a72945d026e07297ee":[12854599,155],"9b66ef6344ee0079f5a1e9c1ffc08f1aebfdc54e2948c77fa63a2389cd2dc254":[12854754,922],"9b91fbfa4d93556c0d4d369d8993506d0266f5cec902b298e43255981dda73d0":[12855676,918],"9ba926330dbf4d7a576b59849ae1af836d19b81e34795bfb97f55325b2a552fc":[12856594,182],"9bcccd9f57798a51cd3db9aba65fbcfd5345870798a86a8b86e19d9e81eddfe8":[12856776,14135],"9beb3e454574273a3d48bc7ed9df870b0118a0cb4fb556fc748f407e7af75993":[12870911,4640],"9bed63f9e7100822535fd2bc0c085be96b13a8ac66d2716495a5adf7252d3369":[12875551,153],"9bf4a8079cb3753f18b11cf8cffc2930d5afe10bf54d473d89ef4efabe7cf9be":[12875704,182],"9c187268acfc021be505e952ab135fccccd38d1168593caf63899afe84a8238e":[12875886,998],"9c221fe39e0c20b550d7819c50aba99a7dbab2d65f242dd101b3e494a0a31bc0":[12876884,182],"9c5fba6f870a2521ffcd0d392161983051a1f01af37656c018bcc71f482c279c":[12877066,52961],"9ca97ad272ef096af5d4768c733e14afe852ae25a3e15c907441906d73534d79":[12930027,808],"9cd95c8a9e803db62057ea14d9e2c6d0c842a000968610425ed9392f4fcebc67":[12930835,155],"9ce4529ab45512f49bccbb8d295ba58e666a1e4d2aa51eedd9eb0b7abc832d95":[12930990,1033],"9cfbf7744c50c75afae2fee55e0b247f3b6e31b9f534cab133150257c072316b":[12932023,7714],"9cff2b0892f0b162d1154c2c40241862f71545009a168e05b9928c40fd016df5":[12939737,152],"9d5b22e0c8befdc44bcf7eedf973b53e8b03468a904eed0306369785f87283b1":[12939889,669],"9d62cdfe887811181783f1c4a2b2876691ab6a3564c5d06fb7f2bfc0d25751f9":[12940558,182],"9d897617acebf69cdb6e944d077bbaf2c0441a4bf5c26615d86b3e250b06b900":[12940740,22132],"9da3bd272976984693f0eca3ccff22b31a8bc6db9e7c17f8e9eaba3fe1e3fee9":[12962872,661],"9dba2b7e944612017cd2ef781bf922c040b81f97b3129d3273f0bced2624ae23":[12963533,922],"9dbe588f84189cefc3641090eae198ca1a8c6d3a3f4fbeb6f4f175c2209de4d0":[12964455,152],"9dded912f9ba9ed4664495aef4d429833cb724a593a1f9b54a4d635308732bd8":[12964607,808],"9e00e61d6f1540b8e8df86860f916f64845386ecf68bed2ff8eb33e358883f48":[12965415,155],"9e204c59695c6aed88c0addef5eea89231043d6db19af09df3699f1c41cfe714":[12965570,17413],"9e61adfd41379f342fe2c0c049bb46e4c532d1d488456abadb4de85c767660f6":[12982983,32851],"9e7b90a24d35bb3d42c0476c6d37e380c905ed227e86d8cf44e8342e96265a33":[13015834,669],"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168":[13016503,5370],"9efd47155112472dea6fbbaf77e5300d8bf22ff10ad71b8e3c9d332c813a776b":[13021873,1178],"9efde04ca59f862e2539e45fa1d77431771e07b528d7147112e9d9dde44526f3":[13023051,808],"9f4012e8a99b2f4b02d181e9b143d433c28e42d021ae54a95e4f0532441bc34f":[13023859,155],"9f50b324e87cedc88923d2d0515fa872cc8cce89cb3ecb7bccf61ea3149ddde4":[13024014,152],"9f74928956693748a8d6dc438f7b167b6f5c605090c12e5941e73993283d5471":[13024166,18488],"9f78dd771bb243204f6fc5ad9672f6e122ad9b851b24906bd2512c5b8b67866e":[13042654,4703],"9fc585d114dc436f4d3006061a5ec8f6a2e09ba6259b5139aa3d863309c0d67e":[13047357,2682],"9fc688c8e5b5b174007eea177f93d5a275e3990312aaaa83a183d5876c0b3892":[13050039,1178],"9fead7dc46d6716420afc8e9f4c3876bd96334979f9626f12e80c76d13fd197f":[13051217,1458],"9feb636cec0e2ae7d212f9cec48fbdc4bff05b107511e358e5aa982eee2c2b7e":[13052675,1030],"a0067bbb90d44e5a1ac95735c7b11bc86b47a83c754d500301c799bfd4cfc524":[13053705,958],"a034e80f4db21f36cf947289bd2138b25b28663c1852df08def0a585d8651418":[13054663,977],"a04e24f0b3d024a070668b058668f9a754e189015bd23d5073205902e548d816":[13055640,4360],"a08d68fc718668f5a8b28d2f264bae7da893511cb070aa6d4070653aa5e302a3":[13060000,582],"a09df5325c5c158cdfde8ad413f197bc03ed5c9ce3d07ea628147503aedd9917":[13060582,87058],"a0b4aa7a2a71c4ee9fd3e278f873062407cad54a59c86648e2f27643c90b1a45":[13147640,4915],"a0b7d269b7b2becf67ddee6176acf6f5288043b3e89e5b901ba8a923ac0df8d0":[13152555,152],"a0df85381685eae826884b948f00b62fc8812f6d665a88cc0e3592d3ad246ae5":[13152707,275],"a0ec2a24f9b6d11f7c11da90096a91a48c2dc84369b750842c7087f84195a45d":[13152982,182],"a138ee9edcf3d5fd205e4abb0ee7d7141764a8d9fb20de2fcaaac177287db2d0":[13153164,153],"a14dd6dfce9155411a941b4a6d70650ab7b5ad430be0ed7193a1fcef2a2b2aa5":[13153317,34028],"a16682ed65a00dc087653788e150a33a77e5bb14f19140ff31218d5ac5dc8b96":[13187345,593],"a16fdf3bc7ab489d5610d2f9cf9917b2501ce06cc5b8dd236cc5464f6c93d7f1":[13187938,590],"a1717fc97344f3857284f93e3d3733dccbf0799eda1113027241c6bd23b89218":[13188528,650],"a19fbd9efd17e835870025c57af93cad988ff9f5dec1c0fe849ae7b5ef9884e0":[13189178,159],"a1f3daf72c16406f6f36cd0cf9b29ce9ddbb2f4a8be396e32e9acacea258d787":[13189337,4866],"a1f82db40755ed46e068ff446b2737497478fb6e4c29e739bade5e8c53f3e26a":[13194203,22955],"a1ffa0765647bba4a5800b7b586b2fabbc059f4ef87d92634909d2b563fe4a59":[13217158,3697],"a235548cd5e250c814f00d12cad75fbda70662c6a154fa977cc3a8bf9514dac7":[13220855,182],"a24eb1b3fc59a87a34e7f343c3396da60352ece50b6688c36b04da55351f1312":[13221037,14331],"a258b748a1c40209cc4b9d748a9257c8b314224856c232ed8982aa62002e9c31":[13235368,3851],"a28cf2ae000353b242f8e6ee5edf18ef3e78a13e517e458f7b7a40973e7e0d9a":[13239219,975],"a2b19291124d1d18c47113f3877f001ec83a3f7cd94647bbb59e4114ec188fb4":[13240194,3513],"a2b246a9807bcf882e8761b99d04391b5e742d20fb77468d9e0ecbdc2acb6c7b":[13243707,1031],"a2c25e6d7aa8dab2c129f662991afa8d79ff647831687a5759a8ddae398d7041":[13244738,665],"a2da8502c17775fd09cf0f6c314674688fab51a644b5a5c3ce058c4286de69d9":[13245403,1176],"a2f5418bb67e8935230c6d3c54fd332626a091169395143f048fd8c5d0a8e837":[13246579,8652],"a317336099e5dc29744ceac7f15620d45b7b326c513939a91f93f007de0f997b":[13255231,7385],"a31930aedf98b73f96a250cfa332830e2028fbd7c4cd44645805e59ee075063b":[13262616,4455],"a3707d0c37b7be86384daa2cedd8d96389e59bb0ab5a50bf7d50d1001ad7f623":[13267071,1877],"a376d26e5f8de69d8cb90f959edc75dcf7631ac57bf981ba4b0d699d1f5e2b90":[13268948,589],"a38d322afe4fa30f9d73cf7f101d26cb366e2dd382463b60fc5a76b5c3d62d02":[13269537,708],"a38e6c331db0c24a485fb4ef39c6996a957e8a068a5bc4a125f99f885a551455":[13270245,7813],"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715":[13278058,7203],"a3ebf3249a0d13b8b6d8fbfae95a896bb8c148b98b23e11aef070b51b624058d":[13285261,589],"a4450523be71a6263351ed62b93d319dc5fb83bb31faa6d77dd1a371bc1b1361":[13285850,72402],"a46f1dc2a6668f3431a4af7ea4da100f5c414ea3f5667c54610ecc195b41108b":[13358252,155],"a4a446bd747baa6df62b269be255f3f30ffed7aeaa4946e66d1e3e873fb58b50":[13358407,1033],"a4bc5d7661940a64762ccbbcf869ee0d2dfeeac963bbe4f9bc4ff223b77d30eb":[13359440,841],"a4c4930e36ed7600015ee14c856ca28054acfc41986b307545b595401c72ff72":[13360281,182],"a4f4a79b7ce050c447d6a3fdfb08a280223ec4addb7cc78a7e97105746fa73f2":[13360463,152],"a5070273b32eae0d49e75fbdc7e55c8fa81bba0a207f00d7948e21207a12ac78":[13360615,153],"a52ddc4758f68c09fafed6935ddf18caf51278cd5a65c2b7173327174e5e59b7":[13360768,1857],"a54024d1282f8856c52c0dc501f57baaff7ec7ce40385feb6f274542da93224d":[13362625,152],"a579adb331dfa4919056a7a216cf71eeb7070f230542a1a626260c74e8690cb3":[13362777,922],"a59da2d932c6cc9c5b056035b9f08b7a0f363b066c5f09f697f366a256b6cd5e":[13363699,19960],"a5c9bc18ff43ed0d3a7dfecb4736f5d100b012bda88da4d8f033eb026f8fea89":[13383659,7209],"a5de1f5c63cb95d1ac984176ad9cc83ed637d5adffd06c60cc0e6fa7d65ed794":[13390868,4866],"a5fd9724bdd40364367ad0cf719e4f9fef78b36029e004e48450b29648131b84":[13395734,153],"a60e0c3e9cf0995bdba6dd0bc8308e4ff71133350cb9245cbb8849e43cae5480":[13395887,182],"a61d537854cde4d6695314470a0db0edf1d81ba681a55fb0a900e5a591f236c8":[13396069,182],"a62fe106b32dc3de547524218b44804da9d8465fcacb49a1341f12d964fee972":[13396251,403],"a676ad03a2294b333a3ee3e44486c505f1938d39321f810b08680bfbe646f6f3":[13396654,1069],"a6855879c51efe80834499bf8047acf5ad9186e66a9dc630dd5da884933b4b50":[13397723,872],"a6894e79331ca6e992707d75b15556fd7ecf11559c91bf7e867ed95582ae4a4d":[13398595,152],"a703b40aa767add1ca05b764e502f4e59d3b7a26709763792b043476a2c720b5":[13398747,152],"a706f9b4072a492092d5d33e04aa0a6c957daf3e52e57011497053ddf9416ad5":[13398899,275],"a7629933323a1d1cecfcf575c968f20e3f6567251af57ae7211167e6a1aaa025":[13399174,35830],"a77a52ea7194afb3fc51cc5406448580a7548879fba743cd68d8f5faf39396e9":[13435004,5214],"a7967c79bf6d68ea8c83227184634731160e25b312628adc21e0772c50f70b3b":[13440218,1910],"a7c9e9d31b6e607131ee2e9ab67a7bab812bbd82e9af137bdf034ab00fcde4fb":[13442128,589],"a8043f623adce433aa837922fd6213bcafa0f187c6fcf5550727d03b61fc169b":[13442717,665],"a86584b58beba24c20637e43be2ea3009d4486d6884d97d5e487304b1a1c1868":[13443382,922],"a8c9ba970b0523ecaf508781084207b9a8bc2fb0ed939e7c55622c37d76e7681":[13444304,922],"a8fba66cc1793229e7c9f92be45bb40f49138a4390a4795f4d478e3b33493584":[13445226,902],"a900845d439d06d089f055cc5ed3f48eb3bb5d82075e63e7772d937eb683bbef":[13446128,182],"a932ed569a1e8819c2cc106dc3d240989ff838e598981a0ec3ffe85c1b2f4f10":[13446310,182],"a939de9095ad62f52d77a4e472a0cf040966d14c98b860308bec79e6f8cf5bc1":[13446492,25534],"a9968cca3d5e718781aa07dfc91a9e68495df5b964c5d5b50622038329c145cc":[13472026,590],"a9a8fa301aae3ea23ba79008e130333745a36d1136a4d92814fa1fcc08373cad":[13472616,4703],"a9c901629723d93d415a23eff7f221a428595727c866f60f0a4ef1574c08d389":[13477319,26317],"aa1c6feb509424d6daaf1184f30c91f91fa1840466d1b59b2b379fbed233080a":[13503636,595],"aa4420a1006749f811179378f41e7b377525beeed7587391ca0c661025c6bcaf":[13504231,152],"aa6042b835f4524335eed482eced09ec112e60adb78b5b196e405d6845c3fc2a":[13504383,1937],"aad0b567bb392d399f384a0046f5eb188b7aa0bd3a5f60adb510813d0ee724f2":[13506320,7467],"ab4ebac89ef6f5459c34b98a99f6e62f834e8b01d447bfe3a2349e0f3ae3ec37":[13513787,182],"ab97733506282c17e4ee242d4a63eb254d9b96590bb96b0bc1b8eb4de5fd0b8c":[13513969,11317],"ab9aba5c093920082ceb592429a727d642cf559ac435b93db5e525ee4ba1b2a4":[13525286,669],"ab9d4b2a2435ae23403e6ac26f114675376f2a63ecfdda023db9d56fb35c103d":[13525955,808],"aba0e2ad90d303597524ad8a80f52107566e037fe5b16f1a7f70c814c01f3516":[13526763,808],"abad4283136e7fd0e880f49148d5422d673d5d1e4267976fd2a3dec47d7f34bd":[13527571,26886],"abd9678be77834afc30adb367555a39cee0d590b67d041507a4655ae4d9cd55e":[13554457,988],"ac0d1f9a7f0d9c2b2d2607b167174ef422dac7de92f2efe3cd18a1fe67e1a80e":[13555445,152],"ac293dd1ced6dbf092b095a02b3b3f36958de7eb75e3d2236d4908494f385bc4":[13555597,13930],"ac4bfcb0b0742cac92ede9b3965b4fa587a2625a99ac32f8f8cc65513d13e873":[13569527,918],"ac4c44cf99400bee4d13948478730c55d2d080903183f2fe3dd78f5f35e52591":[13570445,743],"ac7d92c561ec9fa19f0e28e1392bd90c1d95ca663b038424bd8ce760c6f4cd9e":[13571188,669],"acbdb27b7aae3986f6e1f9d893b2c49e84e999b01e9223319f12ae772925cfff":[13571857,1069],"accaf93691b84c6794df99c7643c710529c765f12cc9faedede4b6b598a8c6b0":[13572926,666],"acd8f8cd1e71585d8b25804d42455806f39659af2949e7ed369deedcb63abbb2":[13573592,672],"ad1581ae209ba3feb19322408bfff544be7a29bc6a7db44c672b705f1afcd120":[13574264,312],"ad325c51ef6afc696eb5e656129787c12b7e5048f387269a0e40419a73a9879e":[13574576,922],"ad36923c534a5c894684500496ed663331fd9cf98e3024fd987519403dd54c29":[13575498,10478],"ada150c613fba5947ada47cc6e694d518c13a89626ed80bf665ed3a9b2f373b6":[13585976,7882],"add27b49dd4b55c54e03c42b54962c7e9172e51a9b83b7e721f1fca8588b768a":[13593858,14290],"add5db46ec6172da4c17d09d8d03c853ebf950acfe02d6715623a8335a3f230c":[13608148,4080],"adda0df2f4e91b324545e1e4b994d2d5f6bcdc72b551194591032b9411a1ea88":[13612228,159],"ae84275fd397e90e7bd5908e314c5e069eeb8133820cb7ceb15bfb8be2e61f3b":[13612387,962],"ae8d57ba56ccc764c467b2893d56eed1324c6e80aadcb7d8b5581e26fba42161":[13613349,3063],"af07b7070a481376ca9ea244e436ba90cfc234c2e516d1800be2b2ae7cb22b3f":[13616412,155],"af5f2ddacab2e1aeabb910fa5bb7df1345d97b7c89e16793c9277d1fd114df30":[13616567,26083],"af6f565407a2580790e24aa56131b1dde103ad9839138da918c53e1b2b994d19":[13642650,4752],"af9c0678b4bf03a2c5e889e3225eb4ee28592428e2ae43a700628db05f502cd9":[13647402,872],"af9d1eae9bfaefa334e6340ff18644274fa3c401436ff6913752469dd958eeb6":[13648274,182],"afbc6e45909d792173da8ef0de23c5ad14f022e39e692e560d510f46a65e797f":[13648456,2333],"afcca2e774aff26c74e661080077d47251c9d8d4ca5220aa4d7ec8f213403249":[13650789,13799],"afd89e2c931d08eca6a2030a2c098322e09c32eb9e4b70405f2fcc67e728dba0":[13664588,152],"aff9bd5b095cfbcd34114cb0c34ac87b1716fef02f0b452888c502e5301476d2":[13664740,14546],"b0055681d363c62dbfc1a7d790a3d534181beb0bf4f88b12acc8432891821eb2":[13679286,1506],"b07d0609d1ed08018cb513f72fa3afcbc204989993eb266a2550dbba9ba091f7":[13680792,31640],"b0a8d8b52ccb7ad3e5b791e0791df5baefde058ae25183f7350aa0f575959abf":[13712432,31306],"b0b65006cf4cd6e0a9a34dc67aad479af2b3790a125899882e57c1533725e30a":[13743738,922],"b0c8912441924b00cf8f72a05d9f0b25a11101037562e942ca17d6461702dc30":[13744660,1116],"b12ddacfb88864021dfc8604dcd05d549abddb7c814ff1306b8263b7693a701d":[13745776,808],"b13c631be36294a1bf14c669e6a216e10b4a11cc107b29258aa54e399799b60c":[13746584,182],"b1442ce84b38b49334d1d208ea0383284f144f546142a25043e44ad15e119420":[13746766,155],"b151c3a18fb9dcae20fbaaddabd221bf11d62aa67578d2a100e67aa8b41408d4":[13746921,14749],"b164c6110c4b163fd7d28b03f1f81984452da3282aeb087000e4117f47614ca2":[13761670,21272],"b1884c0dda826ee579c0cedf56ec34289dd945e1f551f31a6563c11fbcd71d8c":[13782942,1178],"b1f5c8abad265138daae87c973aa7ec0fbb9b9b4489736d18e92650bc7f807e5":[13784120,698],"b1fc4f868fa1fb34887b7178b121ade5f30f82407db13d657a14cd830f619e4e":[13784818,589],"b2021ad68c2504499cfb69d06a5907cbb7f02e697534858ae8a07436b9b19e12":[13785407,1178],"b234a71b43518e1fac23f8b600230deca8ca847391e80393f26eb556fca4c8c2":[13786585,27189],"b2597834e873c0d235fa9b2e3cd216a3642b70fdc2060ba47996699eb3f157cb":[13813774,1033],"b25dee7918c3d4270d2612e4e07b9a57d1edaffc1771b7d30931299ee5765b0f":[13814807,25750],"b260a2fdfd546f30e7123695754da1bc177f26f76048a2c7b8bcce9285fb8ae2":[13840557,182],"b28c3ce823ddaca68bc90ef229f8b01c86fbd79261ba17e33a127123970e1f0f":[13840739,153],"b299eb3f797a2507b549da8e76381fcd17a6b395fbc8c957950e4e72d0f402d3":[13840892,808],"b2d51d99cdd092e1b5fe72acda00392fb3161492ca881f3b1c69a40408a94881":[13841700,472],"b310e25e513458374a10df3f891dc97c9a7f70a2701360cf8cdf198455c2c5ad":[13842172,182],"b32516cc072c444757ccc009c8c2b2955e6ae258d63ed94ce5dabaa5ce29a64b":[13842354,152],"b33dca2b802e668813996ab89f19b5240badb523565f3230638cad68e59c0895":[13842506,3419],"b36cfb9654d3094df47e47c45be1d8c4fdfd9f79d8a4b32ad08a73de9a19b41d":[13845925,153],"b3904051aaf60e9552872fb032c99f7f9b9c1a628c9ccfba5632b209569c005c":[13846078,922],"b3a0c978dc9b999e4843fb5706cb19ac43aa28b71a27697a7d7e0db49945c771":[13847000,155],"b3abc9f9c902897188124ed990c6f51a18932f26a69c3ac3884da4d43444c4e9":[13847155,59],"b3d7ae339e60525bbc912602c4df1aa26fda7fba4d5e9b0f6e92446287ded032":[13847214,182],"b4028bc17ae5c3731a71f455680437a8763efc63eb23192774de0fb0024c665a":[13847396,2088],"b43ba65ea367784624e55c0661023eb4a279c7f5623d71294f7fb09abb70fdf7":[13849484,7796],"b43c2561686d273b4efd89008885212596d7a20468f7bed551b19a922df79b22":[13857280,19289],"b46f4816109b4da6184b3f263388e81ca8242c632bb89eda091b963ef2b5de2e":[13876569,872],"b4cef078e13d957d39bdf7f364698154a91d009be3db5237174bca92694d6ac9":[13877441,152],"b4df455283ade5d031757e4a192db01fea9cc5b0a96c18b9786aaaec3195b611":[13877593,152],"b50ad2ab0bf8397cc7732d275ff9ebd03bfbc1490207adfabd9b9e283b2f8123":[13877745,19238],"b51c1f1b979f8de43b2133337e9581c07959f11244336ef673416deeb2fc9c40":[13896983,582],"b53949f3b0b0613bd782b79251ac4bf63874b5abcd241bfd4d059cf388774928":[13897565,403],"b58c9905a5161853627760881b34a4296428afb5e07610dee4a3a908a5a63559":[13897968,7573],"b5cde0e371a87ce3810de7c7b7af9e48bf453094540fce927bde21dde6b576b9":[13905541,666],"b5e4e2782fa3e7b35714d43c6a5dd578e6adc40a53e872bce582dd3d76dc27c2":[13906207,25176],"b69ce271606959c296ee7b69d4524d49717712720bbfb1f2b1b18a50d86cd34c":[13931383,4455],"b6ab7ee76ef6a054f59c640a774ae5b174ee477e0e453dce7c06b98a3a0af939":[13935838,182],"b6b715abe15651e84e721413a888252a9be4363e6a9f6ecb6fdf44f08db124ac":[13936020,9072],"b7187851019bf10bd035d9a90e8e3f95639231fc4566e93436640b01f1ae47c2":[13945092,39182],"b73e4b4242b073030bdaf119b375999eec7f383111a0b02f8e73505eb0ecc76d":[13984274,26157],"b74ea057ad4517a069dc4afad4437976ef722f4b3cc7a25f89bac19e0af3b427":[14010431,2907],"b7505e3d5938cfd781db87a3052bd34a6aeed587d72d790079a70939cb6355ac":[14013338,872],"b7c5c5ed6856448f5819474a42db6f9cf3040394abebc3e0f43ac4040e6dccca":[14014210,970],"b7c5e83622f2ca2795aea65d534096e8f553a88140bd5d67cdcb939d9a63207a":[14015180,4866],"b7cc868978d415a10f41789683c9c719704a81dd6053219a90dbd33369137155":[14020046,435],"b800cffc348ab214da593a2012c683a87b09cf4c3bb82b07f9941e076798aa72":[14020481,944],"b80e7f58c197c39559016db047f7059a80e36c504ec831a3f712c70e67dff592":[14021425,155],"b80f51a2346d8c26453d9dbc03e4717f7783ce817b01758f5b45c1feef214a3f":[14021580,4287],"b843a3b026a4cfa0a25ebdda521d1c4e32406f60e1657d0549be8af38cb846a2":[14025867,988],"b86a4431e286225b32bae5cebc03298584fce063e8d4ee2df4633e18143f0aa7":[14026855,13639],"b8a49bce6929cd49f8283af6ee7199677f58b4790c3a4a1ffa0f47d36dc8e9bb":[14040494,4703],"b8af01f12f1e0f4b6a2c6e255ed98185e9a91033fd9102ed651ae14039b8e749":[14045197,12686],"b8bcb9a04baed735c6a3e32d41a53d4789162bcc320c911e375eb0261f3d0c52":[14057883,104478],"b8cc0db620cad03ea09682909ece1143c84d2837bda22513e02dd149b3b202ed":[14162361,808],"b8edcb6c1283d85bd998e1026c71293298451ab4e9b4a0047e5441cfcdedf28f":[14163169,5146],"b92d10f23649a6ab076e0a23eeda2a922fbb3e779486a81b37c96c11f63d7873":[14168315,155],"b92e2470b3059b4b8a3b7208613048e0a8910a62e84c6c65f73fd66eea161930":[14168470,155],"b97591be8ec1aff3a50e94048346ccb0c401877607908c11864dad26a5270dfd":[14168625,962],"b9f0b1e190079ede7e50108bc879954f0aae5ef7419b004909da23587f7d26f6":[14169587,5085],"ba19292fc1f3d688a585b06965de172490cdea63123e6d6d4d536d2592be9025":[14174672,671],"ba1c5035ed5d02088e38bd72fb9b3545bdc4e67aaf793a9ba0690cbb18c06e9f":[14175343,596],"ba257e4e665b2cf38b618422e44e185b97222ea869c70e9f556669bce0205965":[14175939,988],"ba76c0de045799d37f3afcafec8d65ccea0ed8e388f5d4a38031e29472e947a2":[14176927,4866],"ba84804a2c2611c8af1663f0da09b4107e8b76b628dbf411441c3945b09b6e73":[14181793,472],"baa5544bb061f7876c0291c3ab9f8982a0c341a6565f25b4d83d4560edda1889":[14182265,182],"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2":[14182447,3154],"bb4f9b34bdecb0cb2a95ffe437cda2b5f86606f59bf3a2d6c28c8139963e2d31":[14185601,3950],"bb50ae621e9f99faeab901a7ee376eb44a5d36aa26babc3ddcc3d039dcc6fb32":[14189551,988],"bb697c13a7df86325725a0bc05269c8197416b2b34f5e7b496ea3a3eb9a07976":[14190539,155],"bb9427b4fb38556ba2a3796ab01cb0788ec221ca4080a050049e5f5b4ecece2b":[14190694,182],"bba24de15a64304a2c47ff84ea2d957dd65f77a450ab1f813ef2d51fb69e7cc0":[14190876,6313],"bbb30f41bacd80761f03f1c07e19c25a8b9f4dcfc089d680e22e7d43dd294c57":[14197189,1761],"bbeceb9309a6f2379e4c2cc7cfc0708f2049169233e152c694784548ff85db30":[14198950,82837],"bc2585b5754bd932813ce42d34f4e55f09ce2f5287d64d696c94ef5727518e0f":[14281787,472],"bc274cef6c4e5aca63e7c46d81e1f86e118c6ba079e7a4ccd4e9f14a8d3eb21d":[14282259,34741],"bc50ee68f42f4d44f9e73d600b7c4cad1cf49e45af287c14f4d51fdcd5c0826f":[14317000,1655],"bc53b1a015afda4574efb81103b417c0bf8de879a57bfb3bcc3bcdfb3f2b639f":[14318655,590],"bc6995a2438e7ed1740d2d806d137e433b3ce07708bf2941f9b6774812f0af3d":[14319245,34487],"bc761ba43afa78e6c811cbc6b9ad03d55a375bd9b2bb4de44361eda01e28f99c":[14353732,1069],"bc9f18a34748d245185298746e0db6fcac33afe57ddff8836927bb668f4172b6":[14354801,590],"bca3825967b1bb2eb84915332c33b3d227843d592c9b32e07046646a205280d2":[14355391,11817],"bcacc1842bc9440afe08e7d396622023b8ce1db9d04b796d2842fb704c7b2b6d":[14367208,9482],"bcb41a480457775ca3f239c29b7f6ec05cb48e121a6b04172b3b2fe63afaca68":[14376690,2691],"bcb5129ddf978eaa9256b6d6d13577728857c783d9b4d3f6a9a2fc3b36e3fce1":[14379381,42734],"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055":[14422115,7869],"bcd92cb1f2335798a1c44ba0949c437b8273a798a03e253f20e17a93567d000b":[14429984,3512],"bcf3b734b4eb2794f34c707ff8873ecfcfc07db56bad3acd669fb5335b9a8a1b":[14433496,729],"bd0265873d9fdf1b44926114603c229fd4894a45eb57bb9b606599f2da7ba9a2":[14434225,904],"bd6e5a6f69691c8c9a48ca08df9d3e3272c3527d8a14a51e859f6fef036ab062":[14435129,32551],"bd74e32d2ae9adaa9a35c1c72a82f11b7880789949f41626250a1ade81c0bea9":[14467680,152],"bd76dba1325cd5ada93eb3918f684a58223858c8b5d55d852827f86e24b084fb":[14467832,1471],"bda10e2c6986afa23de0ad734908c12805d9928841e4034f3d6f52cadc584627":[14469303,582],"bdf6f640eb1d3c3fa80b7d5f92e4c33fd7823ce6e3126d2749a62658ffb113c3":[14469885,25448],"be1aa84062ac4ac42ecb2e283c8609abb4c6fb11349c33233912df90af957d68":[14495333,3514],"be2a56ff9d16650df9a1b6ca6aae9e3e1b88c013c2a0548bf364a7bad344b0bd":[14498847,155],"be629fe4418f4226c3119cf486d936741c11b644241c9c358df2172131c722c1":[14499002,22327],"be800204355c4540abd61095d59cbd948a6d75bbcb0f6240fa7895e224a7b754":[14521329,962],"beaeb5b9dda43dc386c7ebe7a59a551d193e4de5ea854e97caa451561656adc4":[14522291,1877],"bee6b652d354540cf46b2e977959c746c73e36eaefa83a4c5ed0a1a6b494221c":[14524168,918],"bef4b4b9a7cc2d2bec9b9c721e05b6fbcc7cb3cf6c12edd64e78717cf5c2abe1":[14525086,935],"bf0a8c7b340444d4b90676f0b092ef6edc32167f0a81f19da7d8cf7889860e80":[14526021,2377],"bf3330a173dff38e48dc6eeff2febf104173956f1aa381ef4f370bddfbc60973":[14528398,841],"bf444f2b3d0529fc8f613d0c0a3fe1176d20e31cef04150f55a513ae14d578f3":[14529239,904],"bf985f6e8dea8c5ba087d85225cf7c4b1cf05840e536dd8d4e03606bb9b177bf":[14530143,152],"bfc02cf29c4cd774aecb05b26bc930506bd9f9384576a24fa43044ebafbfd1b6":[14530295,244],"bfd8ff52f2306a963616bda4a97971a9daebe88e75f34bb2f06960ea92feac39":[14530539,18780],"bfe7aa0cbf3ee61c07103850b73649ac98ddd6a054d94cd4d6384ff4aced8e5e":[14549319,35205],"c08708ff75ed17d90f4f3599c72e9ea0cc8ccf08358b5d9662b2f8f2b3ebe2e2":[14584524,153],"c0ccfcd602d7642444995972d12d97e440e7917823401d6f3e175b4cbdc6cc94":[14584677,904],"c0e1835e12c0dab7e9bb223a61914b9b89cc6e5a88698093977abacbec733a74":[14585581,59],"c0e4e1cae60c82e5a73e59334d91bf63ee6dce21fa6c02a12956e32b8727fb9e":[14585640,4406],"c0e5baf1155ff2fae103665fc9da111a6ba751285155b99fa34590f812544c00":[14590046,103010],"c1241a97e398daf297bb860ae57869cc162b0ea4ec05d206864055dd62583bad":[14693056,4866],"c143cb29d5df5c031d80990e2053cd849769dfc65f0551b5e92d6439a78bcf48":[14697922,918],"c17df4ccb76254e84f30d1f19a98183858355af8e2034fd78e81d4602af4f380":[14698840,152],"c1a6ba3fce9918dc77114ee7fa8a2a5ed211ad39e3ffffbb76d924ca7f89e808":[14698992,11488],"c1acdeb6c69a367530f99473054a6943f361a727fdadfbedbad65ea0031b5f64":[14710480,808],"c1d23f637ba514ce61a14500549419fdb6798ec084a8434001b665f7232dc8a4":[14711288,7881],"c1dd3f9c9bbc93ecb455626b3e8bde411addce408149e45a084fbfc328066287":[14719169,153],"c1f58faa5007c077191608b945ee4825cb05120b638bd7ee1d490a2b0f2e9835":[14719322,743],"c20b40e3787bf1a2d4c95a5b9e903fd0883cfe75cbd7f7ee90bb9f5a5a506727":[14720065,153],"c21927fa38d31d70135585bace74db198943f9507fc0bd009aaadf340ab1a815":[14720218,1906],"c2690840af51faafd65929bdca676747ab87806e1e05ec50f2d3f676156373e5":[14722124,20653],"c2f3d06a11d1d2ea6b9bc5278645cc0f19af59d475ca4652c9f4cf7e94c14526":[14742777,1990],"c2fa30034482a973d867b5dad2dc637b129a189132dd9c88b692c7949aac842d":[14744767,153],"c2fb559ece032272261d8ca4d47e3c745daba878894591f7eb16214b3ec2c5fb":[14744920,4752],"c3046f08ff9a47d6fed4c8a13f061535448f206dbdd80cea6fdc89364f8e5a47":[14749672,244],"c34719963c6609475a3a32e33030cfa0b5c3cc087a1e73fce9dc4c801acebc5b":[14749916,152],"c358c112be0cc51b3469a63388a4f9bf597eddaf9b3512a1b6ae3a1ceab8849d":[14750068,4703],"c37a92de726de8ff2e0f92bed4b7ce044b4ca04767f196625a9b969725d702c2":[14754771,4703],"c37bdc6dad472197e94ec4869831a8a33b1882a76db554df72ae53cc5da1c082":[14759474,153],"c388ef7c8f50dbe60db408bbf8628e4353b1b62882ff7fd1b0aa09c26a631aa1":[14759627,1937],"c38df1128a297e15c512cf45a5c041588f37f1fea97b2ab011b6edc9b5bb945f":[14761564,18031],"c391af8ad3e6d410248d9e4ca9db4c671f804aff7e4bbe529433dae311725c89":[14779595,729],"c3993724e49fc45b40c14e6ea42067ad55c2844dbd4c92f7d912162cbb8e6ed0":[14780324,918],"c3a7241b135bc24dc5b8ce9e5e1b75a7758fca92b4eaa3cd57eb90b77cc9e9fd":[14781242,582],"c3afb4920ff015aa905f425f837ce3077a962e43fa26032639bff461f71ef8c8":[14781824,4387],"c40d8ea8d968ed041eec0e34f83689cf1e6e28085a801dbeb712567603e04162":[14786211,153],"c454973cfede0d796d3f02041f3a13cf755dd5e6e0ad3b594ecec5f60f2c3a1c":[14786364,152],"c469942c7ef144c5ce27b14ac3d60379f1273dba60fd73a72147a7ce22ce8095":[14786516,666],"c47a40c3211c7c349ffae5b69c3698f9a7818190e661d9391194acc19568d38f":[14787182,666],"c4a6b40c4b9a320ba777a362e89879226168f945521920465e91fdca96ed7a8d":[14787848,2479],"c53f62274ad61974780d29fbb80baf6d4b397ee4df757b68e64383975c3a9241":[14790327,666],"c540e70bdc94b9d5b28ee404d2a64ca8feec1cd93da85611a2ca191ed29280e6":[14790993,10341],"c57897501b2b8f629c52d361cdb6acfe8fcd991936f4d9bbf6ab3690b92ec79f":[14801334,9799],"c57be71783d74af54997681d613e350cb9003075768fdb52b2aeb9949d9015cd":[14811133,922],"c5d18dea669bde6fa3cb8cba5ec78677b23a24c4433f0212457760003c4aa6d5":[14812055,70422],"c5ecc12f7f61f91ed5d9851e9db40ab51eb6aa35aa2b8b9beaab238c4df546e5":[14882477,182],"c65f25b8fe16ab74d385aea88a06d6225b1499b177c56edb9605fb74e0e043d6":[14882659,58262],"c680466aaf516d3473a9d48613670e483da46e026200b4d6599aa84573af77e0":[14940921,182],"c6b8ce166bc273993feec781ce54da25499ac925715435ffb9d9dc32a2fda9e3":[14941103,36654],"c6e3d6ae65fe825c8c888ac9be4afe84f6b4f13cc448a4ebd529240c872dbb64":[14977757,582],"c6e984f3367bbcd224e16de533fa8c357d320625ec698fb566526ceb887a50b8":[14978339,155],"c6f6c1a1b70b83d972eba898ce2cb4f632834b3283b9347d2197fcd8046d66b2":[14978494,922],"c701fe9f171d35b1fceb84f7597d00c90f9caf1b61bff3fdbb5836947ffafc4c":[14979416,4866],"c72d4643fdefbd7404bde108356e0dd4479d820d7848a6e1d7e1ea3308877c06":[14984282,2827],"c74d68784a6aa1e4c7f8612c6b863bf1d9cbace27d94ba58837943b5b8f5f1a8":[14987109,1178],"c755b0455255522af9965d547eb67f529690e8b30e08ff38b4c501230bf108e3":[14988287,182],"c777943d5bb9e51d363d75a4b45ef5b79311f0ccc4c4de6b471d93664ac0540c":[14988469,8717],"c7ddd4b6f1ceddbb414b6f80d805474f1d09f6a6943dacec3a015d4c83ad58c1":[14997186,1069],"c7e315411ed6c1f301cd891144f8d4d5765a457b15c4a8a088818ebf7d6554de":[14998255,4703],"c7fa5d9057e826b453c174e8df7d01ad98658f003a89a87434700060ba9906e0":[15002958,918],"c81cbb1b9a30c2cecdbc29dd30844ccc5cf6d02313174932e1c13040f8840b7a":[15003876,73383],"c860fd32bad3ae33b45e0f308f2c6f2b19ef7e918bedc18fced0d8c5d86c2c00":[15077259,729],"c86af3f9a18666b2e585cd6ea11a7a5075f800906cd6e3be9a67d33270c8a7b6":[15077988,530],"c8cf73ffb7c118e5ad378bc58783365f8b56c59299ef2359aebad52205080d68":[15078518,155],"c8dcbdfbc591df30b59794e35b13b1507ea0e021db780d176a9c368539015a2c":[15078673,1937],"c8e653f1b0829c5c3c0bbaaede6c9f9936ea20e203d4b77c7270c95f1723c810":[15080610,673],"c9063ddd48a4e7e0a6b8a9bdb7df19045acfa4af63e5d1ce443e97d68f7e34a9":[15081283,904],"c91526f0014f317ebda8caf718d2852a76b5b8c35807a152f4e64cec807c2a4e":[15082187,155],"c97f60652e2d8e2c87280754a9deb72202bcbf7f392d6bf1bf12538f2dbd2963":[15082342,4851],"c999fac101f5f4e151cfe26cbc1e8af53c82a96184361f723771e1a160f20c11":[15087193,182],"c9a40ce822dd0e27e639d7b82832e6d2cd29c9fddf315b3602374865063b2a14":[15087375,80127],"c9a951d0919f6128f1aceccf23fe1eae355d5557c7bd12e317178cafde1eb7ea":[15167502,98248],"c9b19428a067036863248c563011734528e64170ca525ded3f9a7d984af54a0d":[15265750,666],"ca0243a22eba8ab85f4c18c5f305f90de489fa4d02161d2d2ca4f2291c4cebf4":[15266416,3995],"ca1bdac62d8c3409a54378a02ae90ca229216c6024ba44e23ce62ce6f4d45f07":[15270411,67660],"ca273fd00044e3dced90813add221493b04fca8416e2204554b9feb932e7a9fb":[15338071,1033],"ca3b024b26d4e1d3bf829d5bee3dad71d9d1b564f5a4004be956e19d636f1b8b":[15339104,153],"ca46ca78bcb2940f23d3aede83fdee2d10b6910dfb0eaa9de6a8ca83a2209427":[15339257,21007],"ca4d0b8217a1164f7884e0195489d4139c836c6b32035adb683b27c3552548dc":[15360264,4482],"ca8d95337fc298f1371b39698f15bbe9e2b22a99a79b1aba2b4bf52325834df0":[15364746,16601],"caab5920acfe98748f585c7254c4a49d94c4185165e3115dc54dae6855e85099":[15381347,153],"cae150dea39f895fe547dc54b23789028aa44560d21f76af0c6b3c4c3af9b3d8":[15381500,590],"cae15a623b3b1af88de62f1557c920d5d535794db796045c4886f1eef12de37f":[15382090,904],"caef8569931c064539b0eefb8e2f2bb502dd1183a37d43acb2e704c2180e88d5":[15382994,182],"cafd4f0b7d66cf892d62bfaf7726489008e093e105929c3a7ce30bc8ea35138d":[15383176,1877],"cb2b0ee10d67af3091437305664b3ea3b0419b83dee9436cb4ebea480fef83d3":[15385053,801],"cb41524e720c19147fdf0b322c7b5f99d503e1a821d8d71139522c21f4855f26":[15385854,182],"cb4a371433bd6721613afa223fb5e3fd962eb9d58272937e6b632c21a0a351be":[15386036,15235],"cb6598323fcdec8bd57032496380f45d7fd66af5be69a2389c77f3769bf0fd85":[15401271,6909],"cb80dd3ee7609ce722a8e8b79d31fbf143395720303e37009733c7e02c844bdb":[15408180,1178],"cb82ac8a35d902954b841fa6b0c14cd03d99e15d69b8005c3986d5ad66e22b29":[15409358,593],"cbced4f8d7d03c3d09400209512a41d9900fd3f8c473ba474b3b9fcdebfa5288":[15409951,102969],"cbfb23278e8b5f753ae86d8bab26ef9d9daed71fab69228cfa99003cec5bdcb4":[15512920,1857],"cc139cb212aa5b627f552beba044c6d8b0c778729dbe3a85497c9ce68b888170":[15514777,15337],"cc1b8713650f5fa9aabd1f43b96cc999725f0552b614e90c3c25a8e9ada70d25":[15530114,8194],"cca37ce0ab52aabdb236250fe7211d03ae415524090fe1ea8d26ed32f63a0714":[15538308,593],"cce5ac73d851cced890fa7995b1fded990d08389ed7390a6f8b2c0254d1ee302":[15538901,665],"cce5bb006c491728a71ef0b2443a25b6d7942d4660a5d914f1077524d49d016a":[15539566,155],"ccef8c05e2e325c5627320612073e20444cc5639b5bf1cc99349c915e0799eb5":[15539721,902],"cd15f0d80168975552d87c075819d485d3bb65daa0da7f4b3958aa9abd2ecd05":[15540623,20663],"cd67c700a387c4fd20907440b1d1525ad7f86e632a76469f488364d5820383b7":[15561286,14291],"cda23242a5fb8aa243b577c8d7fb4e666dd23757c9c1f8919f20af2224dadec3":[15575577,590],"cdb6c046470cd343325f2f0b4ab31705ebd2b180103986cceaefe0de819636dd":[15576167,665],"cdd19a589e0fab4c0e39ada09a7926a7d3c4139070e4711cb116fe62c9c3d280":[15576832,152],"cdd487c8ec08a7bc6a1e6877fbfa49e52675df90d5c1aa50d0f332f5277bd3c9":[15576984,6995],"ce845a9e5d14f391f955b836d94119c54041efcf8661908b6df2c356fe9ab5ab":[15583979,32481],"ce99ef705a576f1429bbc54c67b79b49a638c0ff6b7b5d84bda01263c76f1a7d":[15616460,182],"cee85716e1ce6299ed1940849c8dad3383be1a81fc34b1294adffa54b6192791":[15616642,182],"cef231b63be8cd73caf4301ecde64d5208447292f897a839d4d19cec74d229a5":[15616824,357],"cf096d276756e704a019f2243c3c18f4e508304ca9e28327c1c44dd5462e196a":[15617181,153],"cf1e5d09f1252cc721afbfc19399086c70ef6ac2a08712ac49a8bec5e5763383":[15617334,962],"cf45ddf741d5ee4ffeaeb2cd2d7a7e3ada98ae7d9998d633170ec09db1174b6f":[15618296,1877],"cf56f7691c86395bc17b7cbae67016229b1904a40e87ca392ca06a80ce643f42":[15620173,182],"cfa7aa5cab2183d0fb951afb38ebba6665713f8582d65c3741b185acc5d6ab31":[15620355,182],"cfbdfdce716fc1b0828ea03ee5d4b1cde37529c7011f1216862eccd1cc6acc4e":[15620537,1113],"cfd16e4d74e97b7983f6359e38dd6a603f53d0302711d5bcfe37a8ad5106d6fd":[15621650,20290],"cfd50142d103b67eddb6c4d23d2f62db6072632a4161d172569d09109ffdf57b":[15641940,56864],"d01e76b19239943b86ce7cad17d9a65bc6aa747b53bd0a07f6ce36d87136e150":[15698804,5206],"d047f8965ed54f9bd12784e0946b2c9095d2e26b93930b136889a20ab6d40696":[15704010,5085],"d0c13ac14ee9addbd79c32d232c2c2af8564fd64f8bce57f7aa1f3d506b28127":[15709095,95308],"d0c5fd5627e68e799b5d4c77926743a976b08d34ee58aaaa2ba2d490f12e6a6d":[15804403,904],"d0cb678b59d59753c90739a02e860d4d69877e5bb9aa59259d627fa09e9b5011":[15805307,182],"d0d406dd4f30ea6840a808bcbc3ddeea8ec8cc10c84de12ab5d0cfeef7bb6696":[15805489,666],"d1080af38c29d19262077832d371592d1332c753ce06453e18bb9800c30e2c77":[15806155,904],"d1089505402b4cb871070387efe3c83ef74b8efdbf4fd45440c2ff8b00b513c1":[15807059,593],"d14bdf8caefad65ff735cff07223c8d3a740cb2fba79eccd5daba36cfaaa1d09":[15807652,729],"d18238b45189f5583b384cb01e30a51d7d2918e8c08fb75d21eee4f10550ca21":[15808381,182],"d1d65a35524fa9e7716908f525fe47c5726d2d3b9bad4943566d96f07ef4f20f":[15808563,436],"d2144b53ac5692a9b9c6729faa592bf9ab5b16924277f525abc2a1bdf2efdf85":[15808999,105964],"d232c2ddd56357ab317ca883f82abe3e07c9f583d203fbb2ff724a49714568f2":[15914963,153],"d232ece0a59822bbdf20923ab2986c34deb1408d797fb4d440a908648cfab148":[15915116,182],"d23730d646841bfd57f2a20525e59d19b68b1f236b2e82ec6f549b2de07f1b00":[15915298,1906],"d261cf32efb85d15c5256b019fd404387167830c5495b8ce6350d77f7cb0dcdb":[15917204,594],"d28bbc1711efd04df7065302bd89119c7a41618e0060574a2931fa60f170bdec":[15917798,152],"d2af7eb28bd334a8c338672ddc304d208ea2b45b5a695dd6f10a408de9099e07":[15917950,4640],"d2f090fd385a125365de46a16b6bef6ab47c70bfae523edccd0b418a25bdc68f":[15922590,153],"d36ad7efe8a3920167be664c69adf97f22d108fc3c9235cb5ab0085773449eef":[15922743,6915],"d380779a0494c86c4c7effe8d6d518e0788e1fecbc5b6d4fd57098d1044de548":[15929658,6291],"d38a4fa24587bc625b1a53b56cc75556a7115b44046755862542feba7262fa47":[15935949,1178],"d39735240357ac3e02ecf4fa40a2f80f1c839334e8767148a1c475974d27122a":[15937127,1937],"d3a191152202896e23c1c12f57f7875cc55c10020980f904128242658145c55a":[15939064,729],"d3b2ea92ec386819cf5437dfdc1c7edae62cc79bb1ca259139462b71af4fea13":[15939793,18642],"d3b4f7308b35a3105c66bbbbec005cb91288ecd8476e45e4e46019e1dda2d3f6":[15958435,6824],"d4051a49eb5d78bb051b0d4f667186e5511bf0dd1da070c9de577e083d6572c5":[15965259,27290],"d40ce1419539932ad2e15bbb2827c9f0aa6e64986030b54a69656ba64b443d31":[15992549,182],"d4321d502f35b62d0ec2c5db7e944154459175042ae4de66ef217a1ea61e5dd3":[15992731,11413],"d44a4c27419eba87b14959e93b02a42046e22fc5843a5586605b593f8d9f24a2":[16004144,11550],"d47357ed61545c7d9edc4042cb85ceaf6c1abdbf376231f99c8d9890396d62b0":[16015694,45598],"d4ba550f18859b6ab91c5454015ae1c7d06bff4fe2e423aadc4a84ff0a9701d8":[16061292,153],"d4cc41c19e53c63f550605e1eefac8093367c9f00157eae8709bafe24d3a316a":[16061445,1033],"d4d6cd71f40ee3022f53809adc9337b6004d1b745d3337288a2954b9e87254b8":[16062478,155],"d4dbea19ab919229b6832acd0796169bf55b0265eb7f3219637d272fba260f13":[16062633,665],"d4ef5888a7ecc406d8eb5173dc215c7b1a1ef1e1ddd3ab8b3a8aa6639d125017":[16063298,26480],"d512c3e78967e6a3e2750ea7311592d5210d24fc0aa8ca6dd7e360c985778cea":[16089778,922],"d5141dcf22807eb4d7314871e99dd9b1f165a355d2ba0c088f6d2f4e2b9cf7ac":[16090700,595],"d524c18f61e1a3d56c02b12319b23d0e96319f01e535647a95e490eb4e4880cd":[16091295,13117],"d577a2ccc2fae447f2c539201f6f3c90bbc50a5bf80ae3f4e6bd7941deb09c67":[16104412,530],"d5b9e511ff7701f72a25c74f3f5b4dd43f33ae686f0900b51651fa605c0f91cc":[16104942,922],"d5ec7d242925fb3425b1a0117bb220929635ebfb089163103a987aa520b5d1ac":[16105864,666],"d627ee05b2f4f062faaea7f1ae5e4be35cd3d53d720842eed3d6fcdb90c11fd1":[16106530,661],"d62a27133f68d67d30147518eac5099c001898f03485c72159296bb25fc44bfa":[16107191,48669],"d62a7f31200118d1e66630605780a79ebe0c941a33a6e1c596ff31b2d8c95c59":[16155860,155],"d6b353ac99e954bfef9652d252070d693beab485511bca3b09148ec52a1a38fb":[16156015,1937],"d6d79f2130efcc425bb0177dc83a0093cb305233932544e161162ba9474b938d":[16157952,165],"d6f8e36947df60a7eb714da2a8bd90ca48d88fd415c533ca24381e05a6ca9575":[16158117,5943],"d6fd7c0f72ece55d52229d300030de9f49a3a9908961e50c8af30bb819d0a768":[16164060,669],"d7354c0d67366a2eb474118a1161ad9778fbfdba47b9a20a2b28dccd61d3206f":[16164729,808],"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261":[16165537,5097],"d773565b95bc43598e3b52a3c65361a955d99dd8b392f8d6f303a5aba8860b80":[16170634,922],"d775bfe0794f1e1f77397c174907874ca85290c3ad1b6de336bfa9a9078fc650":[16171556,808],"d7b208f5e8fa6433390ffb23a9bbdc9ee469abd360c19ed56f115a2c4c40cbd1":[16172364,55767],"d81313a01339ff38716f33679a83fbf737aa1a98dba600122a9348a43fad9dcb":[16228131,182],"d81574238d071c45bc3abf20f0e99b11b61faabdc984be286a90888aec7ee6f0":[16228313,10229],"d819535b170965a2083000f6caa6b12f90884261a18117d948aafd98bdbe53db":[16238542,11552],"d81c438d06c05e8bffbcfdc17ea45c873a6292f6e34df1f08367c0a95f7e27b0":[16250094,3243],"d83d2467beb91b948fff71e8ad6912f421053f3c456f4ef0d652253c7a1b8ce7":[16253337,155],"d85bfa83452d95a4eeb9b36dce2774cf0d64189608cf962441406db9b7fe5a3b":[16253492,31218],"d85c26f2e28e4e08f480904215e2c10f6a9a7d6d71e7a46a7003290290c1fa68":[16284710,155],"d8753add6fb4489381d764e2d8abd5964e1eb8a5435669bfc1ac296d9739ffcf":[16284865,590],"d89223508651890042b9338909cf6dc863e232e498f9e9398ca13b02a94fcb1d":[16285455,182],"d8a608347b0ef54712d363215f56ea66c416625a758e1306b23644d22757b5cb":[16285637,8730],"d8be5aa9d960f24ec7931e2cc94e5cafb2e5a94aa8870fc39c34351f14225858":[16294367,808],"d8cbb549ed651915f8d0e273a5967e6243279f3a9bf79eed5e099135c535828c":[16295175,182],"d8f1404ff37c120ba753827f21ccb70d3a8829aa5c6f7e96b746a12df4d7e4bc":[16295357,4569],"d9032b03ee7b166d66159ff59d7e98ba35a3a66b35cbae983d43f0630747ad65":[16299926,545],"d90c5f37984e18050b3787d79d1a962170be74f757f224d6b04b4437c53b0867":[16300471,15770],"d916985a1f0a824fd16eb55c25869d582b3e2b04817ca160293dcec692e5754d":[16316241,743],"d92d120e7fe5a890320f920b59b437771bf413514563369ca2ea76e785116491":[16316984,153],"d968b82d1a1bb25a066835f489ddc6190f930f351a38582558f2ecc26ff57d97":[16317137,182],"d99b6625294e2e89f80c1de9dd39c78d22bd4866fa032a7a3f3112fb960f33b1":[16317319,182],"d9d38fda324dab1b9595cfae45e2279ae57a11fc0d4ea4ebe2ac92e411dd61e0":[16317501,182],"d9e7cf7aaff6afd819af4338ad677da4a161234f425644e90e80584a4961f06d":[16317683,4866],"d9fe6d7d747bd80c3397dfd35c08576b2292a07c3b67df4db1cefcbbf4789131":[16322549,1410],"da1c903c41cb859fd644195e7d1f62ec89ee7a2ec0ecb122e8b3e350cade9cb5":[16323959,11531],"da286140d6c351c7ba922289ca77444bdd47787c572290a1970287f550e47604":[16335490,872],"da2f011cafa328905636f1c49fe397e38ac42269963da6915171fde6742bbf28":[16336362,8652],"da8a3892d2c52feb9cacbf22c5eda37121e61a008156ad137cde153d47d02479":[16345014,278],"da950d82ade0fa72b89309097696a7eadbed77eb84fd8eb8d9854ad97e7eafdd":[16345292,4703],"daaa43e4aca5e9c5c2fc956c45b4b66bca3f516ce29fddb040a522b345c468aa":[16349995,50978],"daaebc3892e29dab92ff0d2dd0d095260c462544c23b01d64e02820562d05b60":[16400973,182],"dab7e5cdd910bfcd109f678031396be642c8572cde12b012e8f1974a7b0d32af":[16401155,11981],"dade6619a1307376f7ae9b79a7161d9229653eda499c6c998b1e91eb0ea8c8fe":[16413136,399],"dae3164867637c0c6037876c0f51daa687f399facda41b6c5bffd716e8d52b00":[16413535,4703],"daef7cc60fa39b19cf46b45552d23f265e50e117668550fae297395dd9461668":[16418238,158],"dafd313128b4a131dfdea7424a367fe466d7bc6d5d36b051bfa2eeb50faf12d5":[16418396,1069],"dafeec5de06b3e87181db2b23c533de998620f89ab7acbe91aedd565cb895f11":[16419465,182],"db2f4d10f41b998b3f6a2f53ec50ad31175d69a1f602717caaac12b48fc231ef":[16419647,1069],"db3f9fa1d945724e36b516220a63c147d357e3ff7e7a5f9ee1257066b9a00fe5":[16420716,472],"dba1ec45bb7980891ef29cc8c87986d2af3951f62588b0bd91056dc8e813f938":[16421188,7851],"dbce866b1b9d517008083a7600fc3c888713cd277fd7021cbe3801b3d99a70e2":[16429039,152],"dbce9c50584f9a0fb1077d51e3537b1e533e3cf15790d0af98e1fb6d629819a6":[16429191,1937],"dbde4d2cbdc1b75f778f86ad50034169573c5ff3078b367ca228e976fd62cdcc":[16431128,182],"dbe30b7023b2d753497fb908a9fa0cd39ad08db9d459a242ddffeb23824feb52":[16431310,56847],"dbfab38538dadc71c378dc0a667c5863af32020801bdbdc84594a12fef3a43da":[16488157,922],"dc261d72bdcdbfd8dd154f8c7c70161f8ad7651102e037c41086b79d7eeb8192":[16489079,182],"dc401e0ecc5024066f82c03f08cff32a610714c2a1e6d2b5b625d32f43e151cb":[16489261,153],"dc5148fdca793e695a353ca1c39a33be663ff138cb6c55dc1aef4680733ded7a":[16489414,152],"dc62dc04b6d519a8cccdd4d4a3f1b1fa1a0fd830ca0ae5b01058e9d7b5347b98":[16489566,666],"dc7fe3abf8cd2fa4b8684b2bff66d8bcceb47f3bae0be86f5c7f43f770a1baeb":[16490232,182],"dc9b64823a026d2a5f24bc4ce2ff604ad5ff615594ea94034017078a57dc3309":[16490414,4640],"dcc86386414393f586278a8b126437c069dff05fddde11d394088c7325688b4c":[16495054,182],"dcce5233548eca145eda59af57d62e425367acbd25161abfe5fcef634547fb7d":[16495236,152],"dd477d0f6a8458d7f08017856ea567c847968a47135ac24ed6afafb6b20b2b8f":[16495388,666],"dd5b21ef4dfb44f68ed82f9b84e73330a2a7341bf5a5dc350bdeb1980104fcea":[16496054,922],"ddbbd2e77f026e42ea002ce1b43457362f9cb8b4f444487ed662ad8d7c22d9b5":[16496976,182],"de0377465c067b511f8b1f2727ceee7caf1ec3d1f896a6c31766ce05a72b5f1c":[16497158,5612],"de046ff003c2e252ddd982d03ac86fd4b8b63050d8d18178d99131d6d1f2ef38":[16502770,650],"de4d7e77c634604c6cddfe7c80705957de98c3c698b07caf255f8fecc544e336":[16503420,1339],"de6dd4b27954b14f84fda74885defc437b9c8944ee786d310269ccb01d2d1ae4":[16504759,182],"deb0aed714e2ab0ceddbe578339bce101f141dc7be912a664ebd69e5906d8c67":[16504941,872],"deb69bccb82997a4b0625b06dfdc406b74c91ea9a7189ec1653f646d5e62cbc2":[16505813,1906],"dec446844fb177d2d11b3efb7db584da136e3995f95b8505470a1cc381ba5129":[16507719,182],"deff13703dd0b3c630077edaffafc1fa9f2fdfde297b9a84b45841a428715965":[16507901,12219],"df1120b999460860e47949b15f330c2467636af660f9de24708fb2960d6e5a56":[16520120,3995],"df66f616938ca912e5d2c6863253a81463b12856de8e781a29997a46e8f5bada":[16524115,155],"dfc2adefb687a9f0243769321f66f0b4d203dd2aee2ef525da1ec9a86dedaa05":[16524270,152],"dfe384bca0e1e6245624b6a79f81a99c5fa0bf60f5bc0e04856a53d888e92a48":[16524422,155],"dfe9cd70e8d2cf3a7fa5c821c87acac71cf6aaa83f30f8e75bf6e933196d90dd":[16524577,667],"dffd45a3f68c9fb49cd7ff325cc2f552ab71f5292b4445e3ca1ac6e79e8e509f":[16525244,1990],"e016638c1bc324a751f2e28ffcf8f963a61bfbbcade64fb654ee88ec22f5ac64":[16527234,76991],"e06293a226b1c96b12b2ce6037f5ffdbca05c34b57e0493823456f2c146a4ca1":[16604225,672],"e0666c9e544cbe45d3e5509292dedcfb88a651b85b381a9c5a2d5610c98cc8cb":[16604897,45675],"e07b29e75087e6429a5bc12f0da76e5f028523ad2a5b75b7c4772285a4039fca":[16650572,962],"e08333feae7f79b4db8395c7d506f5f0e4ec468c5da1f2d3508b29c18fa37c17":[16651534,922],"e09c02c9e6a72aa35c4ac60ad8622970105b21646d146930ef108bb9b6f4607e":[16652456,500],"e0a00209354290a7c4eefd30dd0175e1fc8a6bb68adf3045522ea2fa0ba8d5a7":[16652956,153],"e0b723198b53c60945084cd4c1c83ca0be2529934d482a3d017ff99f6b6f31d0":[16653109,3995],"e0c5985d4a805d33e1264ea4f6570a0b299cc14c439ab791e2f696a6c202a919":[16657104,922],"e0c61506bbf3fe83ae5425120174331a4abfe75f3e794691484414af3a0acd53":[16658026,595],"e0df9866c22a298bfa3516f2ebf9a3e893b13a14b0127a8e5b928330713f9de6":[16658621,182],"e0e32e1058db61695a065366843168ae13ad755344ec880947b2705de227e4c2":[16658803,182],"e13bcdd6719ebb0599d7be0bcce88b7d6561e800b003209109d46f09d8f436a2":[16658985,808],"e1672ee0ba4ac36553d0ff6c16a64901e0a1b968aab3461acb943f7cda452156":[16659793,76516],"e1d3d8958ac3bb2bdcd6896cd27d2a494c86d5b58d7f066d4d61cc33cb85099b":[16736309,19486],"e1f129394abac0092112b5009c7fa602f2cfd3b87eb370bb7a2ffa22982badf5":[16755795,409],"e20acdf832cad3bc079707aaf551c092913c18ba7dcd19fa1d702d07ed899b5c":[16756204,659],"e2354eb096af829e15b3d1138cd3348e1789672ae4b10d26c0e4b696027a2b86":[16756863,666],"e29d53182f44d0b8e9430615e3c2ea9c50e8b024f73b75f52128cd3d0f5d0537":[16757529,152],"e2a5f6651931c32bcfc421d394da9781fe753cd069f7664a9cd0622ea69be1d9":[16757681,872],"e2a9f42937b3f0d8707495381fc30ba5373c7bd1c8dbea2d599147df7793aac9":[16758553,155],"e2ca1d72e4bf84acad7ac89414782a630681f6b447044317fb900418ea2b8fed":[16758708,8461],"e2e63fc725000f036533110366b2bc711836e51e37ed6645ab598f43235a0939":[16767169,152],"e2f31ced3a1b76c901c9deb8cd5d1a0ec6fe05db8d840ed252ea7a382ab53601":[16767321,22680],"e32f2d92d370dc99601eb1d645fbafa5bfe70fd8c4652a22fe593bc49b1e90da":[16790001,182],"e32fe519cfc4d592be1ed0f69e0132aedcb9513308f8c35a2c120e834afa2644":[16790183,182],"e3374a27d7d0f882064cf00e4f95f29623db5d5ff23a06dfb5050d5f6639463a":[16790365,67450],"e37f73767e580ee03606df2b87dcc2567375ad06175b4ba49ac0068f81abb57b":[16857815,801],"e3fd98fd28ebc7ff92befcd40a0f2d7b202619645398362d7b789bcadb724ebd":[16858616,666],"e43356dfaf1c8a8c196910ed80570a7e89368f1e40cfc393804d7e979fe8a44b":[16859282,278],"e47c7af7f38adcfd758a20f08f76eb3d1e53d1ae19744b09394fca65a8b23a15":[16859560,153],"e492f211021df73f9689d12b1466ade19ad8fe6724da5e11881b7b80cdd1a15d":[16859713,661],"e4c0d33d6e00a0bfdee49912f428e375f3d6451a31e9d753134963c174b5c1f4":[16860374,152],"e4c8187d77c9dd5244177f069cd14579037c79ae5a6e6c78668a30c4303e9f4b":[16860526,1069],"e4e11f0f7cc8f5e702a90f8860a56f3220e0e7deb04a9383b462857040616a2c":[16861595,79088],"e4f46d71391ba3be4f5014938d0bc53d78ed66404dc7011676038c47578d28bc":[16940683,2377],"e4fc96d1341dea05e8651f9fae4d8ba7c291762cf19f664b32e0bc7fda804679":[16943060,962],"e5131bdd6b4ef5ce70708d9ef024ccd741fa35c91a6d8b772b32300a18156584":[16944022,29754],"e5181ff4a21adb9dbd040fac3f0a5c958ff8273c4c141fe9f4a438eb2b02d800":[16973776,665],"e5230ab4272f679628e6ea4dcdb97335ef0ba5c7f4786f7e472332290f0722d5":[16974441,962],"e539c65a4600ed1784b9cc176cb28f24c140f514f9783360f1f5a27142f89f68":[16975403,9287],"e57b2d656e8e43fb060ab63d0a74195b3e9a119779280db4ddd9b139eec54611":[16984690,26293],"e57c429bf690aa2bcdc7b070e82350d415d259bac4abb53567e4a321738ec110":[17010983,841],"e582e40a241346ddc9877893e0b6f583abeb112dbc20113867dc72ec3c767379":[17011824,152],"e58e7102ceb8b63fb8f9e7e43d0c02a0ebcd04b66bd1edefca593b5ae44558e4":[17011976,182],"e62f08c62335d7b934fc0263d14fa1185d36ce0ae0378b4741885d298f51fa3d":[17012158,16699],"e632e7db9837baf1f1d1dd672102551d1bd163bcf54eae66e2e23b6d67c86c5e":[17028857,729],"e6448ff36c8767678aa7e2cc0c3a1300850d42e5b5e42107b85f93168c0ff856":[17029586,590],"e66b428aa26fbaeea433257ef5e726883dfa9aefc62d1f9eccec8d27e556489a":[17030176,153],"e68676c37123944364fbb8ef4e165c0c594ce7cbb7146245fb16cc06739cac92":[17030329,1937],"e68774b8d234360e2272140ebb9cf5697b41cddf1f0786d876af36b1f9964e6c":[17032266,4944],"e69889078f43de5c958a75fc18b5cd16b9ada2088fe9fe249fc0d9444291706d":[17037210,666],"e698f109b25b624b505af8daa1c188d47206a460612184c7bed933cea5d5ffc9":[17037876,152],"e72d2ba3480d0e61da07f690da851f7a787e309d14fcb50718bbbf79734f188f":[17038028,1069],"e73fd11df1617647e40a8f64b8821734605bbc23317840b08a06c8af1ccafb76":[17039097,858],"e77188b2530a95b93d9a774eab577093c4acfe4cd7261d3dc8077deee00ea279":[17039955,669],"e79a58ac8ad0c50f897901451c0b0b0f44757ab69818fe49282b3d41efd1b1bf":[17040624,593],"e7b38c4393ac64edbc646dfcc237b3551d3841c13880bce234cae11ff9e7801d":[17041217,922],"e7f77ce60242821d2476bde34f57188576003e896a93c78bd7dee7b304ea5c4d":[17042139,182],"e80ebde443bd0ea50df4239ec1df80abd5ea59b8d70d485890d705e9490b5597":[17042321,4866],"e82549941deb92b03ae1705394fb4eff3da520f69700b2eed761b3ae78ea4e2f":[17047187,1857],"e835eab10ec5f271af85dce966538f70e343c0d2e6442c3b736ef58a5647d5b9":[17049044,589],"e85933b18f5a167c1874c983d1881675534802627ea26260939d1ce89e2d9ce6":[17049633,155],"e88c3509dbd1a91038d5daf1fcc667c45517350ed43900a7bc0727c62246f878":[17049788,661],"e89a4aa8206c9906d45977b99b7ae7afc9f1744c775da52674393fd992dc1fd4":[17050449,182],"e8f6d3ba7c698371ab1315fbbb8eeae30cfd8683ef58ed66e3f3718874446403":[17050631,4803],"e9007f3c3ed117b885b5693d3b5aa763f563b986c61a05b4a77b70ca7193d3d5":[17055434,15175],"e91ea9b0a83fee9811b57145b3ca8d43516db0c0f062f7ea700704e40d4a8aa7":[17070609,182],"e92c8773dc9d09a3289e877e97f76a056b8dd9d4822b6c224b327cf747972b35":[17070791,9033],"e9997799e9a16e02275f9ccc7d7f893af10b00537cadfc93f0a782b116610f0f":[17079824,8650],"e9de02bca877750e753900056301f520ced66c94128b50d1256e7ad1d674bb9c":[17088474,3835],"e9e8397be3fefaa27b6009ddfcd9f9949a06d531dc07c64c1f6f13e8d43a33d0":[17092309,5872],"ea26a080c3011366389c9d864758536e882ce366e3fbaf099593512e7a6215e7":[17098181,808],"ea51f7c8f4ec8145e2bd9243ab232dc7b2640bb09c2b3949f8d4da3b70940adc":[17098989,182],"ea6ea6c09f9660286497a07b7b30aa762658eb33cd0e46299885e48594ca5a72":[17099171,3956],"eb4ca92f81de67ad4082efbb23d7619bf0f206dd607122b4e1872a24ee1787ee":[17103127,8838],"eb6503d8550e8ae440f0a6a9f4f6c18b23d18c43af4b08904ee17055aaf509a9":[17111965,152],"eb982c4abd90f069ed07ba89b7fd809d2eff0d277a3d7240b2e781af90d33399":[17112117,4640],"eba0c9432525af646bd212cf7103c0cb9f7726adc11db5352779581741a8f8db":[17116757,4944],"ebbbbb69543ab7a9b2aff37516faa28bb297f07acbdced6d8a10e88e8c5074d6":[17121701,1042],"ebc2416c3e4bb42c11b32453ea5eb24de8406f3fec04eb0ad555af039f4ce0ef":[17122743,182],"ebe6ac8de6f0ff18ea2c6413db64b29150ae29add0b5107d68c449f67b7fbb72":[17122925,729],"ebec0415a1c5b7e3ee50db6fb3f47307ac0ef3120ec26f41ba2d307cadfe93df":[17123654,472],"ec3719633a6e910e550f100d8393823afd477b930ca45ff7da752a1a944df986":[17124126,155],"ec558800ae0e5d2cb0cc76d27acf7a03b5e98f69b8ad7b2076a0c7d19a29538a":[17124281,13040],"ec7f6eba08072fca5e00c2518a34a059f3768a18473cf7a19452b476c4855776":[17137321,58463],"ec9a5f979f25df7c1df263537bdf27ff1b6292dbc33c8519294f29bd49af1594":[17195784,5047],"ecbc38aadb2916a816395ee7d44524504cad2f2ea64a0eba433ec74d48a48b7a":[17200831,182],"ecd1131e1202aed052065e161f8c2c5043e926ac08422d41bfaf0b9f194827ab":[17201013,729],"ece25ee101157204c9742e958dae8fe898c5f77f400d6513d8a14742a464d6e0":[17201742,106114],"ed1c68648258a7ac19741b966c321db9c100222da858a64cc0604b108b7998fa":[17307856,15149],"ed23f5df7751f7f17942106f335c30c55d774e87f4a5054a9e017a1bdd39dace":[17323005,76912],"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8":[17399917,4891],"ed4e1767d9f257439d2fe1a157104d2bbe511b8296bacd6027ba490122386f3f":[17404808,18805],"ed651466451038201adf25860b87aa94a937b65e8ce2ba972c2d5b0bd1a1bb99":[17423613,152],"ed675c888ee889dd4e8624316da01312a485713821b40990f9cd83083d9ae7b0":[17423765,3192],"ed967f8823b1bff8c81aedf107b73a8a27abf20aefc5db3a1d75985cee972719":[17426957,590],"edab73e25649966f3c5760edfcaa50cf91af0a6cae38a013d243e7e05c5a0089":[17427547,158],"edca14b88b8b1c49625f4a6c1445716b16ec09c67fc22a01136492500d7e9f46":[17427705,155],"edd2ba44a2a0c8e6b1f6a328a617821ef6949cf4455658fe9c7b9f80935a0b97":[17427860,3419],"ede5d6f7c642bc1ae88d4737fc899bdcc20ef363ef468792804cd36f02350c41":[17431279,589],"ede6f68b1d885a44d4fc2effd563a93d50c5b5ad8c31cdf2f07bb0f849109d1e":[17431868,671],"eded3d59d3f2c0ac9197b869730e53b944303a7822cdafb8a76846afe275d687":[17432539,182],"edee39d7c9ff1e799ddf7780b34f986440e6b4cebb0b31540d5cd2d4b47d785f":[17432721,14291],"ee02743510500bfa14b0a2681fb3b6b98ef02eb0de78c90ef8f16a6b471f1051":[17447012,409],"ee120f47ae972eb522d7434f8b88090ac6330b202121d45c9c500357a03b8815":[17447421,22820],"ee175e2e4c9f2898cbf0a83cbfecfaa24d8bf7d687f0f3b64de43326f24b0f30":[17470241,26820],"ee196cb5bed0034930f494dffdbed15ac16bc57339d26a6fd206713e9d194ad2":[17497061,29574],"ee7bf934d16f6b3f86c33a5d2066db0cfa3a85deca612c4af76a61d747b1a64d":[17526635,278],"eead739ea7706ca2fab7728b911d11932c67adeae8bb9189c5fbcaa1c289bd34":[17526913,4482],"eed098835377ac911f9dedac8e0b75c887f769152cd967da9283982b7e07f127":[17531395,962],"eee0fc991bc4a3fc88b79d587ac7ad8847a161ade7cc3b964a8ca39df6a2ee8e":[17532357,278],"ef16ffdabf6cb60bd41555b9be440af9f4ebb7da1205042624221a571ec54edc":[17532635,153],"ef2786a3f8eec1008d2d4bfd5f82a7bf88c7d2677c480044c2b12fa02487819b":[17532788,872],"ef27d6d6ba3bf370d126a77943834af31b3e3e0343b0c17822728b6cdd12d080":[17533660,922],"ef2d1f453a40c830d37c5321508c316591aa751e63dc1ebacb2a09399e14fbe0":[17534582,72885],"ef343d029e354c10a2f22447d656512d90b4727b851871c5320b05ee09c97f74":[17607467,5814],"ef3e42097fa92ad4cd0d6f4d5b4d96e61e120cdbfc89f9b256058abdece4042a":[17613281,5085],"ef4381b6fbe72701191f5c172bac76b9685d22a10568c06335a0e4acf148c405":[17618366,155],"ef495f370d7f0fbc524fa3b79e902b371593f7511f5b81a7f400a5df8ba82f64":[17618521,155],"ef7454910e2f661fc31a1f1d6d6a58c8824f0c3ed137fde4bc40f94b8cae6b73":[17618676,2377],"ef8a8e2f8e545de2a663607bba55de512815c8f948c21176a48210c201aae4a6":[17621053,22354],"ef98b9d4b10876cf71d6e2cba71b88fa89dcdac7a6ab1f0eb6fa6fcd84c48135":[17643407,808],"efa6524c8c27067b544639282da4a97e582211d448adb53d8ed055ecdab63794":[17644215,152],"efeb55ee16b2b444a3502f2e63424bcaaec34c9f676dd2c74f00944dceec5a8d":[17644367,593],"f001fa5e3a08a732e14645aaebe1fb7f3836645a9d7294a36b80f33fe52617b5":[17644960,152],"f008b8020b7c740eb4c43b4c44a4b4287cf81306265bdf5e69cb4c5a5e23257c":[17645112,182],"f028eb74ab77ecef9deb6a45f63ee4e2859f116596a6530c42b77ec906edb85c":[17645294,182],"f05c2d51658eebd191c57902044bc19990526921802dd989fd9e0e5efcf933fc":[17645476,152],"f0641fc8a646a08a653d7a2ba1b8fe68da672f027919a1fccb53254e7bcd456a":[17645628,7918],"f0987b243dce1a039a0c78f8100965a895c03501bc378b4217765c5c84982d34":[17653546,50947],"f0b8a3c47a244f6054199d9672f8fb7eb24f451e467af370d2026e72b30601d2":[17704493,182],"f109830c9aefdcc025bc0022016474782c060eca6461a0c84a186369d8f711f6":[17704675,102964],"f15c18793c01fa41a0ee170332697318156a0f87e826ccbdc824a05358febd6e":[17807639,103023],"f1b16bab91743a5c12abaf26150bf40b513df99019b03e4c70825208238a8121":[17910662,15000],"f1cafd936d04cd6626ed160a0ebb94f07837f9df690ab2ccd91c0458c188a882":[17925662,23695],"f1d46a49b8c7584c9c0b045c724a51a7e5399d17e15d08ebd0a6a1317c15a7bd":[17949357,6088],"f21d63b776076c12d02ee2c6d5427c8da7c007863544d2f6ed2a57754bf597ab":[17955445,152],"f22e593a636a23cc73d78d2e0e687cd1c4f566ab623b6a7f49e279fe88286fe5":[17955597,182],"f23a56b7192bb76b2bca2e1e30587339024960bc963f00249e35eda44620ee52":[17955779,4866],"f246cece5441ae02652e05a6c32e26a2f08f9a62b8051b0be33fb411565166fe":[17960645,530],"f25d150b567e0cfb48f30845e7bcd655d6f4ed39d93f70eea4f932ede27f3905":[17961175,872],"f26d6fde72e9fc4f86b6909dc51455aaf37c8a17ed31d6db11e94d9a0408b324":[17962047,666],"f2f812fb8d99b1aa1f62d3d1dafdff4cf049d0956a4031a4e9450db3f5210a37":[17962713,409],"f34bea987b5ac98205b3d8a31c0e5851b4bf2f8067abd6d2f97a007daf76fc3c":[17963122,958],"f35cae407d0fad05d77ef4fe3112b740e89daee846d915b1ea1747d2e75cd138":[17964080,918],"f374c6dd1e259329f8fceed545639baf1b4230bcbba57ac79b0f45c40218ce0e":[17964998,962],"f38b0d5a644c8edfdf8d88f5387838f0df2f638ef556620264f3a6900afd8c6b":[17965960,666],"f39ead3f6661b23c1064f1c5bade9ae4df82bfca5cab5cfe393bae23b8b7be11":[17966626,83672],"f39faa23f2f789a5958bc84112d67d9f6c02d6a38c72eb3ea1d3ac1465e5a4b3":[18050298,32462],"f3aa3c42590c24319c16d0f64ee1cd6c4a12eb93b72acd10cf265adb26bb84b0":[18082760,32043],"f3b88a7a02788d3c15aa142f2b1ec6a89ac03423a4f19e3f9333a7b636eef3a4":[18114803,182],"f3e5457d8b0cef8103196acea0f440190fbfd138a9d4575ca9ef43406d1cf54d":[18114985,808],"f40dd5e818129f69b162f581c57cba6d44d8a39e62862dd20894cd5fefcb8e3e":[18115793,155],"f441f533be6663dad0eb64343326e0172207f01a22d2a8d0cfacb685499c9d7b":[18115948,590],"f4600cb7805c125686fda60c33a201db7269cffb155e15d915b571708dd1315f":[18116538,841],"f4968ee7d1cde3c20ca3568f05c0b43ccd25267f67c787ad7b1c4bb717dbd598":[18117379,1069],"f4a057e6cfa9336afdd9a3af3763847e6445fa75da78ade52abdadd8f887bfa1":[18118448,182],"f4aedb26827f9fdc044e31654e7c1345c2f6d6e43b12108a089808e8b324bb7f":[18118630,5741],"f4bc3c0aaba474dfba963d9729462073ef3f8ce3de7c947ca2625a1125acfc79":[18124371,34852],"f581419a200dbec0190a3d8e3451537c8071adf85cb0c8d7b0d947cfcca529ca":[18159223,219],"f59384faaa19ba34d628eaf6763d49fa637c76fc260fa524c26bbe6dc7979e28":[18159442,182],"f59ab0124f75ab4268b8ba98d6af5ccf12ef64b61ee58885fee69d77246808f8":[18159624,9303],"f5b1be7dd933943a445e4b99312ab544f1c8b2296ac2fe14f3a3ee4fe733e324":[18168927,278],"f5e2132da4e24805a9c5d93e78db873618fea004116575dac3a9e60446b01c20":[18169205,38103],"f60ebd2b77e0dd55b8e4464e324f5818b6cc6cb79e0dfca8dfd7edd135c128b1":[18207308,403],"f612c5438a6c82375502bfb7a7bb2a487301c7460e39ce9b9c26a7db3b959fc6":[18207711,155],"f622e04cd14d3558fdc638dc1ce64ee7198a43d6bfb7c9e91df1dff816f4c87c":[18207866,33883],"f63c5831667ca09770a34280e05d6a4e3603739ce57fc1f441edf5b5d65b80b0":[18241749,1069],"f6465908b435afd08e3c96ede0ea49889aa5147f2bb215faf03fb983d317a3b1":[18242818,872],"f64e1f03a1390adb272fdc6562a83d949641740535efa1281cdbddd85f3506e4":[18243690,182],"f66e5812f0b1d528c46f9377b7f6ff2595f21ee248ff0c9e799672f01b1edabd":[18243872,590],"f6fb47544e45265d49a84ec0e6440d1a79491e454de5d3e3024b171f8c85f7ba":[18244462,698],"f70d09070025af1be89f4b378c9f4fc65f94b03ffc98002d56c0f99aeb3f03b8":[18245160,152],"f762bd207827748390f0f6b80d2ee0cce44aed0b609affc3ca7cf39e2c11e958":[18245312,25232],"f776d8022f5672fa9a1ac17c722c6680a117080e136dff149a269ef1631ea71f":[18270544,872],"f7f0bd506f6bd2bd041a15769a12f54886397742d41488290a72d630710e95a0":[18271416,2333],"f8147193d1c39b345155fef4e2983a2fd3c5650462487a5c17d091524385b77d":[18273749,182],"f84539ea81b308565fd53d88f6a17a2ae8f19739d635ed0cba0406a099b531ab":[18273931,152],"f89a2d60ee2e674719a0d08ed737185af1010c7db7dd074987e77472997121ad":[18274083,155],"f8a07d06d082e8397ec1873c4c0c2a8f8b80f60a080c4bb35799fad2b7c74207":[18274238,18872],"f8bca04c89b28f477a53e2ac2d1f05b9f39cdc59e72e93f4c89cf9eb8571b733":[18293110,153],"f9040ff53a098c7360f034b85614337ea60e2734c3d7eea01e24de0d1f02ac6a":[18293263,155],"f90491ac8edc64950fe971f83c79c65a5ad1962df110bf88d9da7cfb3cd19217":[18293418,8946],"f914c6c615c7de8cf24b0bb25e3120e97c68602b7ac669d9c5cd8383b97d8415":[18302364,182],"f92335b9c79afc0ff0a8e0ace24bcb4a22505814a1010fdc733fbf279f134188":[18302546,5085],"f994912e9571d5e1345399971b95b9d00ac87292ba40b5ea9e09e4ae63dc08cf":[18307631,904],"f9b963cff641b6476b8a60a34e87be76cb9a071cfe5db598fc71260799fab532":[18308535,12017],"f9d5fcc236e02cf32822d0f5fdf9275858baafd5b61f5a969544bbd1d62c2e2b":[18320552,182],"f9dee44d3c4849bd2fa732cea4dd6bc8f34f18e990f39a63d7931b8d712b47e3":[18320734,988],"fa155fed083b1b146df1b5c5999c69472b231adc8164568689b6bde5ad3279ba":[18321722,17396],"fa311aacbde75f55a6ac61c809c9cd9f0509eeac287c1031361d7cb9e1ec4b02":[18339118,153],"fa65c5aedbcd4a662a647b70a87930b760a7938cae03fe50f4aac15c9131197b":[18339271,4640],"fa7caa9a01ba44eafd24751ec593eb5345fa649631ebc095b27033c6bd0f62be":[18343911,729],"fafa93bee551f7e4b615c2145426fb49c7b7e316e6fc7ad701a2352905a1736e":[18344640,153],"fafd87076d293a026ae6a6414d9a59f31d90c8a201c16a6167e90d03fe60aebc":[18344793,590],"fb028707189565339b2dbd7e63e8baa221db33dcbec0f98f87ac4196f3dbe874":[18345383,8442],"fb2f3b76342a100eff769e91c7cad640ebd4ac531fc85d525378c99d3da870b0":[18353825,4578],"fb3ec1a36ff4c5e21ad2f5507b94a0ff4090e40c3393dd18d2dbf739622ed185":[18358403,152],"fb4aa1182902dca4e0ae0f15304e23b6fa162fdfcfd9b20c54d16696eea10017":[18358555,42734],"fb57ae93bdeb367b88291bad6b384c33387ea9f546a7faf666118dfdb7d4dd27":[18401289,155],"fb81e18918af40d88b17a6a973d0066643feea1f6796d5dd3b61bc4f2f41e570":[18401444,182],"fbd3980f82ce5a6f04c9cc969a11073bd64eb043a529f609e68e98c0e6e06778":[18401626,9553],"fbe075025e33525f74179f1505d87984295e5b10d3c021a45a938e2a27af97ba":[18411179,182],"fbf8fe3708c15523629120f6646531120478289e95316ca467a4d7d1047c5745":[18411361,40070],"fc294d11cf0be572ae2a77ae15fde2954d2d151c259ed0221e1e9e0c0d5dcff0":[18451431,1069],"fc3191cb528703710273fa565c934931fc18f232a84bb4d2570b37eb4385546f":[18452500,182],"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003":[18452682,4997],"fc6eaf5be5a810dbdefd1181a4c7073b79ca42579aea16f1da5a50ed30f913ad":[18457679,24271],"fc84533dbe7fc3dac061433bc0c1f8fc5bde8245ab04b55fa0c742bcbe63d0a2":[18481950,407],"fce5755175a8d8c7fd2007fda50c0fa054115d645a9208c963f942d9fcc0ed4b":[18482357,661],"fd236e23009498b9ddfa0efa3ea438ea1cc8bcc6f35892a2aaca6670eb8d0f4f":[18483018,155],"fd2efeead51f9f91643b59ebf5aa5496e26e312a2e546ae064d2b246cc4d4e69":[18483173,970],"fd36e7178bf2daed94449cb4848d8b6b2247b100a4d36596f207b80792d16199":[18484143,47063],"fd37131bec55479dbd369869d2c0fa525f6512ea20d50eaa734acf5fc3421eab":[18531206,52643],"fda8e102710267399bcd26c421539e9d5288656c95b851a7d8b9dbfcc8504668":[18583849,1990],"fdfbf6430f6d09d0fc735ceda08f6ba029c00346c4548cce398b647da0ccd809":[18585839,958],"fe22be01a52440e397c0e13e610615292ca0c85a09bcdba1e6760a5461b3861f":[18586797,958],"fe440cd1ff613b544411bcbb210d7fbeac16036b945a163437a9462cc7f2896d":[18587755,155],"fe6954097a004d01c836063d2833c9bc8476458ec8019470104e81054b94fa0d":[18587910,60412],"fe7d25f2758feacfc7ae9e974cf7ee9c5ac93dc57bebddca126a05ac5516b728":[18648322,35126],"fe951b7bd4a25490821f5174777888dd2c87066733b393bf3009954694f9db0e":[18683448,922],"fe9d5609076f29cc80118ccf016b9dc3c2fb2ed8d180eb2a22e8c14aa2734ff4":[18684370,24268],"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef":[18708638,8958],"fecb57f6b2ccc05274afe43e822922e812a353ef428c458a16622273777ee424":[18717596,153],"fee27dced79dbef4520b78e312f985a8004462fba1a8d8633d2ea05639a979c5":[18717749,7003],"ff0e2e5e8660e7185fc33bd4fbcb40c4c8ddfcd4b5c5231c31cc4ade5c1d238c":[18724752,278],"ff3f0a63185e043d2544437535dab59d81521929f72698be5466d633d4be0833":[18725030,671],"ff531de02aecf6fbec0a3580babdbc0e08954c903843377182445f02478b5d86":[18725701,698],"ff83494adc1c5e3514e322b085e3a4d0de257ed1eed408a0d4ae9d6e37dbbc4e":[18726399,155],"ffaec1ae092ceb8d7206f52322a06859a341fbfab7c17e2f553b746090d2a0d2":[18726554,9002],"ffc8ba118af754d5a181d2754f1ceb69fa6a2e58bd47aa16a977289acf72e363":[18735556,399],"ffdb05f25535d6ae17fa0ff5c66bf263d282104ab8de00f922d885f1ab4fcf59":[18735955,858]} diff --git a/.napl/store/objects/pack/pack-9d671ec2793ecad5e1bd1884aa53995320c151ea4b038069eec7bd04b5ff3475.idx b/.napl/store/objects/pack/pack-9d671ec2793ecad5e1bd1884aa53995320c151ea4b038069eec7bd04b5ff3475.idx new file mode 100644 index 00000000..6e68d482 --- /dev/null +++ b/.napl/store/objects/pack/pack-9d671ec2793ecad5e1bd1884aa53995320c151ea4b038069eec7bd04b5ff3475.idx @@ -0,0 +1 @@ +{"00096b1da36dcfe77df971b2aa418d8711d62fb1a76f3e26456a36daa48b0f41":[24,4703],"000cffe9c906d9c9596908467a282aac70302a97ec45ffcd35c0fa1371a610a6":[4727,962],"00174165b1b450f65bb824d5199eef437608bd0b6033d640cf758c4192389e8f":[5689,9836],"003dfa7b8045ce14ca57c31215434fe8034f78efa7c2f6660e635ae4dec26152":[15525,2679],"003ff2e611d823369c217a8516567db73b36275c47eb1567db203fc8271dab8c":[18204,4387],"004169b2891eccf80efc107d5fdca1836dedf116b1bed2892ef7df8609cf2fad":[22591,698],"00683c0cf523fa455cfe23fafc04aa03bb8f405053d46d9f3c9d3cdc4dba2287":[23289,153],"00b471b31107981f48c6968244d3b44ef2f290d9b1e82004e41a788edde89df3":[23442,182],"00cdb80540048e9d0f724db2c204b96824cf086e1b420ddd4f4bb7e8392738ac":[23624,5780],"0126d87dc0868061874b660c05239142e08adbd465e04c37bbb6bd0373c34810":[29404,159],"019ebb86cfd22853f06d4a6e2b72d706673ea15197dc4df4dc6814295d030af7":[29563,4578],"01ca35a882d63c2d2509a7c0890b0c588a743757cc873b5814e57d275dee997e":[34141,666],"01ddb503a74ffeb3b068cd1ec996f1c6045fb5ff725993c303552ce20b3f4c3a":[34807,590],"02051dd93f1be5de0dcfc96ef2d9c28c8bd05c00d65c9d9b3ffca1a3fa2cf893":[35397,593],"0207e2dc62438497e50c98959a61a632ba40e7aa39965acb895da931f1671948":[35990,4866],"02292969e5eb3209dc6de000dc41717c2727ce1c6e7ffaf45f45c6d55d607758":[40856,80987],"025f8c6c51fda9f50332661abb42819c70425b34766f917e04168b78bfb44250":[121843,1307],"02d2ea0745e17a3fdc17f2657ee532dfb748423bced9c1302e3178f45dfab10a":[123150,4871],"02e254acd53d8ca4fc0546f450fccf998781e1c0e4e175d1d3915dbeecd8903a":[128021,1178],"0340a5f98d50aa16b99442f13842884d896cbf776a4253b69e6533d599724e89":[129199,153],"034d55b362bdce1a9553163c4da0dafb52b55674d579314f9f1b4c23d2516574":[129352,610021],"038e11125282e8ab61efd9329fbed787ba6109879f27c796cc31e75b61369f73":[739373,1475],"03ae2f50f83057626789dcd81f69f677d91867f8de919452b821bccff80378aa":[740848,918],"03fe3f5e4a91808e19dc90caa8acfd9bae64b394ae1037e7b4629333e3973196":[741766,277],"0419c7b10b1f868ce08327c9100b6f200051b35b536189612545e65314bdff2e":[742043,1797],"0479a5be6a51a7191b47168b7f5bbb14ac2168809a25f33b3135dd11d0882a37":[743840,824],"0488bd1ad406ad2da40f6d1b0c87ff1b7a1410275cca5d9a346ba242b76eb74d":[744664,182],"048d6d8df29595dbe6d7db215483a19c43b7554febe021b28c9cd4f9bf1c9551":[744846,962],"04a648a127be983c89a99181ee26e28401ee00f84ea0b10b6c050ecd263a1b2c":[745808,20701],"0508c952df26ea2ffe5ab27a5dafc05d843d76948f77370745ec28cdba6d5260":[766509,74524],"054396979714b8dcccd39d588482285738eccce7b57c8971fa6cb0f050b33094":[841033,29352],"058597fd336d6eb0cff43ce21c1f5cbcab4e9732e2df7e9d12e4925618ec1d34":[870385,155],"05a97eed911395990a830c49a08c05077521b13d76b904a26136403769c0da75":[870540,22757],"05b37a6e9c078e2fec2fe0c0ae3d24e9117c25e7557f1317b4b133ca7d1f90d5":[893297,2377],"05e2486c9b7f5ca45e052cfda14cbb9d513d75f06b28e72c5c1f13640e4d376f":[895674,1937],"05f44ac11e9808b57f775c0ffedd617fc59c8a13cef9839ed551a535e36b7148":[897611,155],"0603270836a7ddcfdc6d233149b2564094a9bc3226aa11f996b9d182681a68a5":[897766,922],"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6":[898688,1129],"061f8a1ca4ec539e4a8e0bbd659c7c32659393284f11e3ff35ccf68d7082cde2":[899817,922],"063b97dde6290a3340bde25e9f97c01e0c3a823df834ab96a5f3a23efbb4c156":[900739,54695],"0662f8e8e279bcfa220840ab1933b3f473b1ba338d1f72ad99927819734f12c2":[955434,841],"0671b9a60e3cd8c1f49c921433447a199ee383c6d357a1cbac9bc971d30d001d":[956275,3894],"0683a2f5623022e219ad446de610a80a5b77956fa5d2bf9ea9df38037f9bdae4":[960169,1283],"069c2728f8a4fd65fe20f47e6a3d13c0ed56b7f45f47f325935ee81b346fed45":[961452,182],"06ba48400af2d528a6d3e6ca2c0c71f1f20d3cf4aa7b44d600fe18e7a2738fc8":[961634,18272],"06e076725f8e997478f279e24eaa7ed1c09d8fb7765ec4f5aa4f783e35fdbd3b":[979906,590],"06e3f065a550fb0b90ed404cde31ffd42855cc924ac38fd493961034ad99ae9e":[980496,808],"06ea8b59aa2e892091970f13eb8cb88f621256b3f5ec25cc90edb2927ebf723f":[981304,31060],"071229fbf6f104ee3bf5ff0bdc708b625f6bd0010c0901cb8b8db98dc169b165":[1012364,182],"0712f884eca0556804578624ad3bac3a484c701241fc2b7655ab9c54049bc30a":[1012546,922],"0733317c8f30d785be80fae7a227e8fbc09360a50e084afc86d2c6771f9c135c":[1013468,2984],"074f56c36ff545aee1feaf90d675f2ae36927525ee65ae964e75a52fa364a139":[1016452,841],"074f79d004b82eddbcb9baa87700dbf0cb5ae826f94c1d886dceaa4ec79e23d8":[1017293,13022],"076a47507f5d0a1fc1675d13cbe2157f9d22794e566253d738fb1ab08f5aa63c":[1030315,81220],"0783c8f36f80781687e35e71a3d2318128af9d29ff9ac0343a8b301891a8d0af":[1111535,989],"07a7c9cb2d830899137a1e048d5fe1b503b384ecb04a75f0a6f7d84d8edac986":[1112524,590],"07b33cbb1d11aaee5822d3f5b943eaab8474f95eca7ef1387bf03a386c453849":[1113114,729],"07bbff14dd05de911eb6c74a1fe52dc711c2d95c9759b1f093593b2e7d4e518e":[1113843,904],"07c940ba4b8297935f1bdad7898d0317e140ed38548d190ac9df34887332899e":[1114747,106010],"080757642d90307690a874caa9a1aae6390859725056300b75f44472cdfdef69":[1220757,3978],"085835ac5b8d823bfbd272d83c7475dc21658f11b634129df0abce0174d5dce0":[1224735,902],"085f7e2d366c1756ff8f4e7bfdccbb8139c49779fd8ab00f91e3d6c787569c23":[1225637,36288],"0894f6a4f01379c3dab9a8bf8e5a53c1fa81d39e1e11f8ad10a624eedaec736e":[1261925,155],"089dfbcd5bef3850c537ede919fc6741a53ec1066060ffc198c7faa192d51c49":[1262080,841],"08c3fdd38a3617c060b1242701f43bdb1b152d1334af774ada74000338272a54":[1262921,13316],"091b68f54eda0bf34578932ce3448fa14951dae141d5ad5eec3b277e877db56e":[1276237,922],"09365b85316ccca597ade8729dd287cb5c193cbb40bd38e35e66e60d45924ef0":[1277159,152],"0946e8b9e45a8056927d3d2332812dc5e7f0fd1a789fc5374866798da6189a82":[1277311,182],"09505a62339f70c9c9482cd29727f2390ffc80438c762f776fee236e39ddca82":[1277493,922],"0994c1ef586f7d5255edfec72258723b0569bede2d73b5421481d9928069faff":[1278415,155],"0a124edcb870b6922f6bea894b96b1dc7c3d56ef2f0b16175252c0450351ce22":[1278570,6332],"0a53f13c5e29172205697a166ba275f3517875ed8eef390067a8bd98659f5991":[1284902,2489],"0a628f0bdefc7b4301b984613f9c9f1a06baca75e8dbc2c4a055141fa4b48b74":[1287391,152],"0a742d0232e4b7000bd79ff29b39af501675254174d2c98163f8b7337cf7f0b7":[1287543,1805],"0a757fac5d572e4fc554b6ff0d3fe77b65b01622f0b7cc503ea93f84880a5633":[1289348,182],"0a9b80114c0bd6d0b4e6a7bb9fc251ae3e2af773b5cbf313cc1e8c5e86321568":[1289530,153],"0adaa7a3a40693f6d0850446b7802cdb832adad923d7738f2f1bd8017eaa9042":[1289683,155],"0ae1392435259b1f4298bb44f0e8d081876288581db1671d6eb6bdffecf851ac":[1289838,2377],"0ae54b37fa83b04b9917ee181c086a761acc7556d980686be4e8e10b3e77d803":[1292215,182],"0ae99e4bc95cfbe4b5544b5b4214abe60c8502801b7367eeb52e1f3146ae21d9":[1292397,46908],"0afee74fa07030ba25ae0ad8141b04cec7380af3d20977b6c9bc60a876ef303a":[1339305,1458],"0b1977de1e02cb1bdab8e3e3b7110aa2ae4e06b37f5b08757c4f437510fde386":[1340763,277],"0b443cd4703dc57218f7c2858b1a66913833e5d99d02310bc30f26373a77c434":[1341040,27809],"0b48a7281fe754ac52eb28ef5baccee30bba16601ae3bf047ef2540475c9803c":[1368849,2599],"0b67041f4bf73a1d6532d8bcf4f3728c909c452431600101e7e3dd1a1bc51ead":[1371448,808],"0b71431883adea0e4f3dc3a77b968b69ac4f18348387362cd3c48fbc023d8bb4":[1372256,3619],"0b8190e7989129e7baf4eed7046e19b7608067eb5d13bed95ad416118bf34a25":[1375875,35693],"0b97a1aa726215eab3402f8d7d6c5def336381ee6897d298692a5fa19699fc51":[1411568,729],"0bae24f716e10910ab80aa7b128b67c7c57a6df7cd2c92daf447a09bd7d2dc41":[1412297,1155],"0bfaac92fd735f0afe003a99021211a8154b66123c3dc7d07ba882e48ba64bdd":[1413452,21308],"0c05e20a61e945e0cf7a4bc43dda9538a5f221203412bc20276aab2c1da42bd1":[1434760,2491],"0c397805b455c1d3be81fdf03a21c4fdce97d5226b673c2b23efc2b33dd4a90e":[1437251,155],"0c3ac12916df4a4bc62aafdcd4e9707c3e28239277df3a1e02293df663420336":[1437406,278],"0c81050701bdfb6262bfdc66d55595f24ee77c14201c90b66cf895fba273892a":[1437684,182],"0c982b0c4e8e2c82ca6bcd45f953364d7b638713d68bd6db67288f6b48c8e5b7":[1437866,155],"0cb51b8d14efa25b6444cdaee8b22e9611ce56bc1b9c20683d430590167c803f":[1438021,9417],"0cc9f6deb3819268377d5cd3b50b42adc297a013a9c8c9dcf5c77eebc0cd0f8e":[1447438,9382],"0ccfd4213b325ead9e7de194b86e32322a281d222b27a5af1f2cd57db8f49bde":[1456820,11613],"0d1617c7565e6590c54408b00caf1d696fb8740a3d996c151b98b01dbf3181a6":[1468433,153],"0d9c017cf2140f1b865bfc058e6ab2b23f1fadfbc6873e57a9fe804a9d4c604f":[1468586,30841],"0d9de5138a0e51434c732db16fde21ad6ecfb1f4b0f8fb2789c8b538784b1bda":[1499427,666],"0db6b5f037afd93d29ab26e91c9295b6ab53fd90f9a345680135a58696262a11":[1500093,970],"0deb3e918644f69c5dd0dfb1434002e896b38efddd4aabc76a778fe02a06b704":[1501063,13581],"0e272939e7a863bbee54ef712788b44f60c1a6ec306a5e23530dd2cdff0a6c12":[1514644,922],"0e979f14174777d495141f1638be430cd20960c15eeebc27a1889a562c771dab":[1515566,2857],"0eea5540821dae7e1b0bfac2288b074227c4d4333a6c8cb2cd2fd86f2f15e70f":[1518423,32151],"0ef99195476c7251619345064e2dc74fa005197438592370d03b0b5e5a688b55":[1550574,665],"0f2e50ef0121924926bdadaa29157d5abb2159c2308a44edb3e3e8fde77fbc0c":[1551239,31665],"0f3756f4e4507027e1ed76f9e0ef890738b3fe60b53eae65468551943cb63929":[1582904,1101],"0fd40dc47b12d4aa203037436f4d970fdd57723de1a2d331a462f4db8a84143e":[1584005,918],"0fed7b302648aeb697d047c52d0787b0a80a5fee7c8f5d72ea9397c906e42105":[1584923,841],"100066c55f52ebfce2ff78594c75bc2c1300b4b20be9a47c49a6a6ec1c02907b":[1585764,582],"102aca285c1d51a4330e7ab8dade561f303b3b049983b884615e7288934654bf":[1586346,153],"108d835a43c85ff63d216396de96d6475cb4f5859b85454bff142a1c47d0c589":[1586499,25090],"10a367a3bd7a952c8c7c6b52c5a3b88628c9bfd74187380c766a75fa8b1af753":[1611589,44467],"10df13f5214ccfa57f086032061ff35bf445a9b2da972f3d7d99016eb2d83199":[1656056,593],"10e6ecbb2cf94bd488d9b800284ed2e991d1f6ee5ab3156d8b8d6a711e784c87":[1656649,155],"10f2dab5f5f728c46c2288ca139961bb2c79a9d3a6c3285dbe5f5b0da0c65a89":[1656804,182],"110d3387047f4309d3538e36fea44506dad2b7092f69a3228d6ae3ace6432593":[1656986,530],"11275f041fe929c8f9f3ddc3f9536871ea1a3c4f4cabd66c39bd48ea8a619205":[1657516,16234],"112df64307c695d6e80359ccbbcf9100579692216d90fd08be281e7c82c07574":[1673750,666],"114a8363fdc58dd405f84a29842009214d6e61255cd78338358778f278e935f8":[1674416,80081],"11998c72fb387d68e04513ed35d6d2e673cc7e5e7832f6f294e340b56fc1968f":[1754497,582],"11b47daa7b6384032d19bfbfb73f0c64a5903cf25a7e2117cc6555e32ed26c57":[1755079,3224],"11cedb6c47fd6b9324becafe20fd7481501edf2acd0629b2703c570975a699ab":[1758303,661],"11fda949115e3dc730289ef1cfb394839bff61d0a86c39a7dac3901df3a50b56":[1758964,1033],"1201bc9b06d4a70ba97a8b3548827f31d419f3cda471bb42793c4c9e143c9888":[1759997,409],"12112f11e38294a5f7198f720981c24e3333f262bbfa971383f8c9b0119d932b":[1760406,988],"12166b5fd9554e4781dd2efb828ac24b17228fab1a08ad7df51069e20f8efb0f":[1761394,48881],"1220d3a189dd0d0afaae8e9abeba1164ac7a6ad300f49ac9bbffbd8e34d115f4":[1810275,24272],"122f4c73e2da50e064556b961af451cd327f27c22ef3463de797461df18af6f1":[1834547,152],"123f84b63a7115f4cd5b6ec7f1914827a2a01a529ef8525b6193881bacec6c1a":[1834699,182],"1243a30383b122b303ec2bae058167b2541d8b9805b3eab569011d2cf15ad607":[1834881,595],"1245a0fa562eed3fdf083f3e1a0bbc8ff97f7cf562da009c8a7a7cd101bf6963":[1835476,922],"124ed5713880d0384df7559ae31df44e93a52c0e4db92c6f5cac91f6fc86a5c9":[1836398,182],"127285deaacf052e180769f0b6ce6ac03d3faf777d6ae7aa18e4cd77d6f95fe6":[1836580,589],"1275cb6426b796f0a3c417a683db7cd640e80d2623819c39c102a47c2b53245a":[1837169,872],"12b15414365406659b76de146e724018ba993d4dbe050a13e630080c33618140":[1838041,3479],"12c16b47d6c11312d275a81a2890f4f30d72955e8a7f5210dc871bdf022bdab1":[1841520,590],"130234ef88f4edad8a5dff4feec7d2e004274965f1fda3be64919b36f0439e8d":[1842110,1797],"13217685058c60796e4b9eaa2e5c51dea475150e6aba30df00ce6f7f5221a9ec":[1843907,152],"1349bace91a76b8363dbb9a49aa8453021c24e92e890979cb751f91544891f99":[1844059,152],"13d3a998548340de5e83ea0e546213b0c7682fa6cd0f09c7ca24fee62fcd93d3":[1844211,155],"1408b31febc029dea450f094b33a469c7e48326fab4011b229adad43c44fa5db":[1844366,182],"140e3cc084e292a68b31c833a0b59f371539d73a6ea459a64b40ee2b2515ded9":[1844548,902],"14555e6e5bd987bf718c8972b6feedb898fc7e3dcad1c29ea36aba10edd91be5":[1845450,4944],"145d84afd1892b41d64f472a44ed100f908d8255144a8c7ba0335159325e56d5":[1850394,665],"1462afe206e4e726d5d4c290a540088aca451a8561a0a9f3f4ad1cd26cc7ba7c":[1851059,1937],"146da17e738b7cbb53594172251cf63aaaf368e85b37bb1157d635411988e403":[1852996,278],"14768a9b96b8dd25e1efa36ddad54dd48c5feab01b57a4be8883f7ce8b524eb8":[1853274,5724],"147acc4ef08e492359e839d3e850644590dcf51fe4a3600c2af6f2ad406a8a40":[1858998,3869],"1481e8baf23521c6a5a3cc57943beb97ae15afc539d6350940b9296c741a7bca":[1862867,26810],"14a676ac3f4385d126255771acbaf09ebe129363b5ec8d4e59ab5083548ca581":[1889677,153],"14b41f0f24375744aa018236c3a0f448ec928d00bc05fa6806e6982b063b410b":[1889830,182],"14b8691cba402cd26123fe68b1e58f819c1952d2afa7bb37be393d1d084353c8":[1890012,7828],"14ff0479bb3f853bf2112803d70270a067efe3284125166203ab87d2106afa05":[1897840,152],"1506a85bc6ef2b6f1cc8426700959b114ea5532cd02cf43e57d2a82ab7b4fb8a":[1897992,595],"151b12d2bd41a7d935f2b0b874a7f3ade8eaf6e4f0685f587a9aaa9c5e332571":[1898587,841],"155528aea66782fbd67555be27e392c8cee0d0ce7022ec10eecb2f39cb50ecf4":[1899428,182],"156ab704b4d1d385105fd45dfe9ffa5af5cdbf7344eac0c70c3c4d26b6583167":[1899610,7070],"156f98abe07108226a3ddf02593af477a2ea84b119a13d8761551ca8c7e9e1ab":[1906680,1910],"1581007c655c52f2757975b1df927d00af92fd7d8ffdf8fb86c1a1ac4017bfa8":[1908590,530],"158c417b1a04825ffd7f472547f6a358d25888bb76d2cf5ab498dae69de9b252":[1909120,152],"15ce7fcc3a890869080c340a7b701d6a0a2ff47f1511f9dd5ce07b6297140be2":[1909272,11027],"16022ce4ca0f16dbdca6546700a2348fe4f7e66448351a8a0e29dbe532eec294":[1920299,28730],"16755422638e33f0263ecef9dafe44779f2e61c21159b5648a0ec2b1b93d1310":[1949029,12504],"167749ecbb655a214510c849c92be88cb536cc7f57a330d21b0314780457b0a1":[1961533,21093],"16a45516f47b798c4962802561fa5d6ca8191c629845e747f946ae99328482e7":[1982626,15395],"171347ae89a0f8568711ccc55fa323b54f931ef8fa11d19212757f34c3ab865d":[1998021,153],"1777b1d905b34f7277c66dde1ec4d78dd48972fdde8c00ab2cfc289a5dd80cc5":[1998174,155],"17783b48fe0888a535aed30a280cae63ba8d0559f202b60a1b386ab438c7c982":[1998329,12373],"17a29332c8d6f136c791734e08866d894949c4ce533c1929a5584b352f528408":[2010702,153],"17ebcc121b23401d4580f3a6a36b49c11ae698c382eaeae3221d4bb5b7a05def":[2010855,182],"17ed1dc53d580a008dda11cb6e942898107713d57a919e7f9a9283660ba6982c":[2011037,530],"1806278892f55df111c941f3b5769c8e4d1837932f7baf373b42b48976b98174":[2011567,5746],"1820f647bb5dbd74c8db7f5519d404ef0ce1520c833a579aaca893b5cad7ba08":[2017313,32125],"18271374c88f2f8a91d1f43e020fba6462d9e55409dfc71cac4a6649245dad71":[2049438,155],"185d9a1825d2467ff626fbfc7bdf7ab588c44f793cca3702784eb1e6d356c031":[2049593,4406],"1867c9ffde2d11725fb957b113689035085c55ef856d494e6347e3d05f75b803":[2053999,729],"1871d435a6fb00aabe7e8d6006b75cbcdfcb55487f4f3ae001295753468868c9":[2054728,970],"1896e0a51af57972a16f38a4576ab92aa13b702c29f1dbc41e716ed47e7c6ecb":[2055698,155],"1898b2bf4f51856fee06982feccd96667b7aa50a76871e054f741e8c4e2d34f0":[2055853,902],"18b5c41016570bfea295efa37a4905ee4e8037ddd7a7791a516a10a2cd57ca91":[2056755,4703],"18e09d803f8ee40503ae1b61169f47555f69c2ce8177d203b0f3e97c29be25c1":[2061458,244],"1909cb3276e2bf38ad88a7d563d53e4defba8c3d417354233dee803a23dc1f7b":[2061702,182],"1928a415a91d1e20984e5e753011cc82bf0a1438ae2b96899930801f828289f5":[2061884,2333],"1947cc58da81461c77f0cdeb07aa08507c1220af44a26cfd308d2f251cfe124f":[2064217,935],"1956f111d43045440859d7c57671e90970af57d99e55280e80ee66a347293147":[2065152,4578],"1974c1f6b4111b2a851edbc26ec3dc47fddfde41e65cb2bb2ae471e91b621849":[2069730,589],"197fc31f98d770e101c9aca800812e76d89d1c8af77ee1a2443080cf94a3ed48":[2070319,922],"1983d2bf91bbcc59706fd11a0609f56551072e728b90e5db9dc8e7b19bcb0a8f":[2071241,22888],"19842377a5884b5b4dc94a7445d915f6aa844478502b531010361228d326a2ed":[2094129,83912],"19d6b37fb117a87453cb5a44abc7d1a25c49c6eb8129289cacace49a08429f58":[2178041,922],"19f4168a27ef3d98ecf9780d65c22990ad51ba16572473ef8de3f0b894cef777":[2178963,2377],"1a3eb8a85e1d9905dd35f074dace13f08ddcd15c4b345c449bd73143ea71fb03":[2181340,17190],"1a41ed979430da7694d9e4d22bdaec2807267edc8a603bcb17cc0c9513226f7a":[2198530,472],"1a5de49301e4c6d47619ec0e44eab914b1846735830b07483c031e6a8c3dc67b":[2199002,49799],"1a7d9717858433611af1408c35494fb4e9fe642c97f00baa0b183bc1eed609c7":[2248801,904],"1a90570994829d35b84b3d0afb4c1add8c874b698abe4ebb9acdb218ecf30c77":[2249705,841],"1aaaedc7aa9496f141a027bafb8445988f2d2d0bb6f35f750eede0ed8e88a72b":[2250546,152],"1ab67315130550b27f4b8eee050e771e8db747003656263c2808a46a10543e5a":[2250698,155],"1aecc859ca75cf20491dbeefe86cb7860a19694ebad81f15251088092028d199":[2250853,808],"1b2d2993c56d677c075155e52f044fbd0a82656c173256464968b0a6ff62cce6":[2251661,159],"1bbb864c8af2f143baea3f07bb11ecf52771f0107db428edafe9476dd401d187":[2251820,1155],"1bdfb8de4cdb3f230bfaac9c3e24ab6005ff73a1df56b08e7b1530ac43049009":[2252975,182],"1be861f1957f3d7279e9bdab5250aed28818832abe1508bc2854cf04222cfb23":[2253157,4703],"1bef0976e67d9c88366a636f94d1f838e7eed135831902d3b0f82cd98ac99f4b":[2257860,155],"1bf5f8faf7324981cb7a48d8ce322ae402a5c7ab510cb1312ab41fde203df11e":[2258015,152],"1c0c279cfb6be52934cc4341ac294d868e17aeac08d2f15b3dc63d6376192520":[2258167,155],"1c2950ad30050bd50563a0a2c749bc5432d1ab6fe17b1af403a3a1ecc9609fe0":[2258322,1307],"1c67b12e1b8b26fe646dee9d76fc02a892715d97a14fc819107a1177defc5f84":[2259629,1937],"1c7c754a9870be661e3ada83f5192a6c7843c671e55c36d53baa5ec9279643f9":[2261566,665],"1c8c728679cb8198348a993eb0d305dd1c38a46fdd43eece16988317e7b8422a":[2262231,20663],"1c9027a76e242965501c9d639d69099add69b059b264ef6b815bf03630443f30":[2282894,589],"1ce7e989784003841525cf8232e846bf082760e578a9ce7c743b344ab7e3953f":[2283483,155],"1d363c106d7c95e8eb6a65a51bc01cbacc3fe7db3a98917f01f7e7c60288db9d":[2283638,182],"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c":[2283820,435],"1d93e039abeb6a0ff6dfcbc1a0b7cf5f034a0d040115ea5d09ae1a89051eb978":[2284255,7016],"1d97df9c83911b518441c83d48706f67c21b5e4573a828c05a4c469c1de2a089":[2291271,33531],"1de719dc1404ec3b290ad06d820a1617ed3f78aae2c1baa54d1a2ca1c22858ad":[2324802,152],"1dec6b9fe622b28f871dae12c000036fd0604aef104c3fdcf95f38e1edcb3733":[2324954,8596],"1e45ae83e11feebc35bb9bf3255e931fb70e54e12c268d7231b4e1dee1df278e":[2333550,1283],"1e5aa3b47af1025e6ce688e131390b96f008c1ab4810f1068f23de1a96d2cf69":[2334833,3720],"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7":[2338553,14854],"1eab4fcec1746de996240b145f35707eeea8526ddffcfbe0afa5c7ab0f1da3cc":[2353407,26061],"1f53a2b74964af97e58ff6a531ae1c218c3d35d5c8af54f10be8854256644d7f":[2379468,1069],"1f662ac905f485e56b796fadeab41facb08ad03f1099230f43e23f529db1d81e":[2380537,670],"1f70489bb644bef7e033b0d1c58f888dde57e9874e3045580feee00633ec748b":[2381207,40217],"1f9bac1c043e5ab12e9c972dc7a10c527295b4816dab4592aed52c76b4c23e47":[2421424,962],"1fb2b7cf4a7ce32fbde1e9defb66cd94e1f236a251319dc325521dbc23b88d8b":[2422386,1033],"1fbb16a66bf359bd7194dcdb0b4cef9765c236d42c8288203e5ca62e2b53ff9f":[2423419,5780],"1fbcc29c397ff1896fd000eb2f9c9a1c511d549ba5ff8a4e9a6aec3c24962292":[2429199,35191],"1fcb86a59eea340c2f7a89a1e3c20800e3835113a8ac20b20871373803dd04d7":[2464390,155],"1fcd217651dafda58033d28735afb1c39dc5c2c4e23344beee26e308b5f773e0":[2464545,841],"1fcf234b466da231e6c4677a0a8fe5f8aecc43e2e41f3311bd024ca511c8cd8b":[2465386,589],"1fde8b4853de481fa49af30a9b0efe8dbb5536428e236c49a46ea94feb3c0628":[2465975,1178],"200a53c30509d69172c04ba2d00cf8d35c35a6e31bdd402e7218cf6da0d73cf8":[2467153,17967],"204a4adb44da6c09cd66887596baf423a9e81cbbf5e3dac02d8a80ba21e0a7a1":[2485120,11488],"208ecdf81d30781a72b8494907b681c1e164d3a9cd43ed9668192754fa0ba46e":[2496608,962],"20b2e6829a8ebdfd38ef09505a5a4f978dd5c83f40711ec1cd09bfbd0ecc4372":[2497570,182],"20b515293b4ef553a1a9a8050fe899858420ea8314a0a8fa403de17b79788eef":[2497752,105701],"20c7ee6abd1773105f29445f2b57801e522375285d592d91fa2563d6f024b9b9":[2603453,100756],"20ca78370f49d639d5dabbf8358ff0623d6c1302f6c0f52779341dd0bb9814cd":[2704209,2117],"20d9c2f99f1638ae66357c19e2e437cd2d4c2741cc7698a87bea76bb08104f5b":[2706326,2377],"20f58bbd341c207f90b29e67468b38a9115a0e2d9937d095a1313c88d4876d9b":[2708703,486231],"2103641a673aa4715c8088cc96c75df6c0c67bef599d2ee8c78a3dc851635cad":[3194934,182],"2103a2f9f3054ea0913e16c84c302e0a8869263533ecf7a91204288feb146d85":[3195116,63812],"21542fad1b2350f252b3d3f63d450da0f83e390eb02c4ec1546c7febcdbe1c9b":[3258928,1178],"21631447bbac552e78b823a151bc6b9b6132a82db42e636c7befdfc081f3ed2d":[3260106,589],"216aced8afe5e06450884c404ddbc48e5c5a88bf50ebfe755f7126403784f175":[3260695,582],"218df1e9210c1005c21244fa59c8fc4b8c40de0dcec17e35a33f32cc1cf25018":[3261277,698],"21964b817f6ebe4d1ff2a798b40b824dd21cac84149d6e06ab89fa9061f57655":[3261975,593],"21aab4c73cda415373ffe323d94339059df2f336712a901c2dee766012d0115c":[3262568,4866],"220528417ab84fb68a3b21eef82fb259a47e8c9c0b5552e216260bfde8da0cc5":[3267434,182],"223dba28d5122c5397afe0db331a37630311bfbca95bb1fb77eff39d8580fbbd":[3267616,904],"224143f43c677543d244eb6821d5bc0db64b646a45ac305ca21d7d5bfafa9b3b":[3268520,922],"226d2a965911245d7b23dc6e878c6c444e53ba52e5b84ec4a69efb81bcd1c181":[3269442,19814],"2297837c4e3941e251dee4cd7ad9c53759fcc8c961ceb362926b8318cc485e03":[3289256,153],"22fc2724280f6c915aec2960ad3925621cc0ff8d118e5d09ba3db318fa507b4c":[3289409,182],"23047facf0e4f522af446d5b1947d683c2248f6d1075f8ff6034c407fe597095":[3289591,58522],"2307ccec6af2c2087ef769a86cf51a8f2784d06ed3a14229f8d7228d7909a8af":[3348113,9021],"233ec02bee819fb1ddd577138385455eec538c4a4159d73183c77c0df7b361c6":[3357134,872],"2354c58048f64831cc4e160fab474657cd729c89a21d81320e7f221d3b1bc127":[3358006,593],"238c18b92e31b06a4562f1edc779047d71b85249c7bfe8ef8d86b3bbc928e903":[3358599,403],"238ef6825ee18fff56a4c086d41e1460c2e47cce51bdf7158ca93165f041e75a":[3359002,5067],"23a0361da2d7d67e620a6f21bd4a4446f81454038931740efdf679acf7b020c3":[3364069,59315],"23ca12927b61f5ebfffdad8841c5d0794acbced0f8444d1866d3d9e68814f28f":[3423384,661],"240c925c4c35e40420a6cdd8bc141531bbffa9c832d330b660bca27d365db8b9":[3424045,182],"24eab2760e3f8503a1354d17ba6bf6fc7eaed527f59f787c602083ef989fb028":[3424227,14291],"2507f26208d4c6efc71f3506a5ee94e70e8a69e9941137abad82037fdd5fb8f2":[3438518,182],"251e929b559b13c10ed81879a0c554baf0e7e6358f744bbc556a4f0a4c6ba4b1":[3438700,3024],"25259d5a35f16fb807d6e38756d9363ca351b667985cdaa8b1e17cbf584fa9fe":[3441724,153],"25843e4601273b7d674393f5fbe7267f518253b3bbea419aa7f1d41c4b42fc74":[3441877,666],"25ae6ee7a09f85f547b15a7d203b8c0594a4a4c5e03174a51f2d1b25fd5bede4":[3442543,182],"265cf12ba8ee56dfed0bd3583bfb13ac6a3ebe5a94b9fd18f8d61f3c77f79d63":[3442725,904],"26ce34d11ec46e08675dc1ccf8e2d1d508c1d9cd2f6babdcd0acceec21363ecd":[3443629,1060],"26e26696fa18accc362da9494d522e4ab3f3e70babc7ad776335ce8125582ef8":[3444689,590],"271b52c2ec09469469e510d0c21be9948e7132d5be7f7776356dccf6a4647ef3":[3445279,12849],"273d4e3a2810f5e328772aff4f262b228dcdae379b229e47daab5fe9b46c9f74":[3458128,4640],"274c01389555494a1d4e8f0b28bb304b3410b6ec9d27ad3fdb94577dd28ff636":[3462768,3197],"275af46b02c655934cdf8daf15ef060ab6e8c992dfb4231c69fbdb365f4a5fc4":[3465965,3481],"279b09a95275fcdffed7d04f7abeac151929523eeb3f1773ca01a4b042570dc9":[3469446,4455],"27b0346e3af4ec0f518188cbb76703ea8a26bfc2820b57da4d2525ff420db4f6":[3473901,153],"27c6b4bcf3e99119bed53d3b72df5de1db1d04247d2d78363138b8b9f209e617":[3474054,666],"27dfbdd69eb22c92abd15f1328d41164a9b211a10e8bc8ca46d40466a390953a":[3474720,841],"27f99f77efba2e01099146445e937bc1bb78c7b1d7977be96c072fd5c95b88c3":[3475561,182],"28059a89256aad1de78ecd31d7b77cae21772bbc6e96fe8ba1cdb9044048f410":[3475743,7603],"281071fa8c7d2d44b3b569e99c71d7e5a13488af033e2267eb7afea1685811b9":[3483346,1458],"28143b59f55f809bd2c51eb77d4ae2f50451f9d629da80f38d1e6e684f297338":[3484804,5214],"284bd9b55a9f7d63a3999072f3104abe2071629417dc7d95f121a3d11e9c1211":[3490018,152],"288d3818abfe76a26efcbfac1191a77c4a9a7ea34821261d9f61e65c5d9c5ee1":[3490170,1906],"28adf2e72a47aeff8cbfb4b8b863ba339fa27a5c5e5e9de456c8d91e396cab2a":[3492076,155],"28bccc83435bfa1404f7092fc174d909c1b332197b757fed91fbe3f53260fb18":[3492231,530],"28cbfe345445aebe451c9f97d60fba062e914d95a137c8fa070600ccb83cb111":[3492761,2377],"28d508ecf0b691ea42b07250b0ee9e48a9eab5c25860929375850b19ab090fc9":[3495138,152],"29707d3a43418a68ac08aba457d9f226c9de1b0dc45fcef084243d43d02d351b":[3495290,155],"2975b666a1e14396e50d46ef6f7acd23e7f93c5dd5abb07f8138f14b557a4d68":[3495445,153],"2976e06095e0f6120f2d54537fa703810311f7596e2a820ae0ae9ea8819e48b4":[3495598,152],"299eaacb181cfd4fbc0c5f62d170d0dc392ac5ee30f097ef5b5cedd7d59b869c":[3495750,100710],"29a9fb29de0d60356c28e3837acf39cbe484997e37664c2d3c421e984bc54b23":[3596460,922],"29bc2c2d393d06490ddc3b24eb27a3a149e100eb5d4db00d0ef5f911054c89ed":[3597382,155],"2a09b4a4d54d80bf6ab4bc4b808859f549c599685946b713a32caf21f0d68192":[3597537,1990],"2a30b011aabee6f0df8b85ce2488e2b2d852cf348ce6b18300e6b417d7d330db":[3599527,904],"2a345a96a9b632ddfb78cf17b496cb7d6fe2e226a0c3d1ad129a9a3c20820ac9":[3600431,155],"2a8ec239d786d46d6bc7953121850de4aea1db75f15f4b5660469bb86eef5417":[3600586,657],"2adf2cf15d8fadd7bc90e420ce19eecfed7608f3e6c5c59e063ef7a8297d768d":[3601243,698],"2ae6798de316c0329c0065549e004404e7e8210de385fe846469e40095fa3a31":[3601941,1817],"2aeba094700b60cf2ab6e0c88dd79228028a72f6b9b0e2da489dce572f2d2641":[3603758,8646],"2b0062c307d06bcfddea5c54875369dfc5e324298c125879b5598eedc522c57b":[3612404,182],"2b06f37abc3b0f54b2084c11c8ab544f01f4e298e1e905203d80302fb3a50d43":[3612586,582],"2b13d6ec8cbab9f08af736c8bb31ad56ab01429c0b8e775e5301167fa0e77b5c":[3613168,582],"2b3feb80cd701b183f001a4b6848669e0e3999d8f4111519dacb885fe8b5dfea":[3613750,808],"2b575f190d29ea201c4a9bfe15946a09d72779845be078b0164173f9f1cf1973":[3614558,153],"2b720e48dff0ed702a448fc2aeabf816dff20774f62a58d08e877549d3efab39":[3614711,589],"2b721eb1b0e9e2cd16bbe95d61628ccbeb283e5eefafba31c6246748f6de465b":[3615300,182],"2b806ccda147575f59a8fc847b2656b26af400aa3cc7cf8733ae2c5f34ea402c":[3615482,85354],"2bf3758689f32771e9e743efee05f3023fa0f74445bc7b0034abc8ace4bf2262":[3700836,661],"2c1a72aae3bb765a0492e2cfd5889560b185b0f593a02fac544f0c11b3c452d0":[3701497,89467],"2c24abb95918f8bd106460536d1b502075a3e7cd6dcd9bf6e94a7e5d703d1140":[3790964,25287],"2c38197e3585a3cdca90f0eb7b8227aceef3120cf064ec0651f611690c7e3bad":[3816251,729],"2c5288fdade2ae24a0742956900bb4025b342d81fea645e7bab0b0fa4a47377a":[3816980,153],"2c5e5d555ec16edcc0cdbe133b51c09629a8154a714ce75baba59f0f595cca38":[3817133,729],"2c67c4e7d9746584274b1fc7dcd3fec9a9bb837f8bdbca8e0d31ae9c40a11e7e":[3817862,275],"2c69107b7db7bf7c0b074ae598725925b50e349c57779aa5a3f91bcf71a23e92":[3818137,52647],"2c7ac3d8f01c5c4fd114bb758f824ef4e47029fb88fab1390abb6aa550d40a25":[3870784,8134],"2ca2a560a3bc8fad6c3f2ec275c30b75de39c893b96179f9371b7409f5356d8e":[3878918,841],"2cb96cadde609cc1395760def27e5befa4e0c77e227dcb70f05ba732a3d64ea8":[3879759,12724],"2cb9d97b96212f4eb76f98a9cc6f901b2813a3a9482df622cba1c38b62982ae7":[3892483,1937],"2ce2cd607609f26f2d53bd99535985a2d825771ec242500b80d4488de3916e4a":[3894420,935],"2ceea09f82f0700bbe48909b0c56aa0e69cc477dd51287bd88cd2e87f6f7a1e1":[3895355,666],"2cf19fa37976e1e26a35d5a2dffb0a7d1f9bf1a39538078e9f53e88366646f5b":[3896021,1069],"2cfa7807b9e55b5e943987474c43959a77f4da2ca21c6e4454f42ce9fb2988c7":[3897090,59],"2d01e78a0f4c89e52fbd1e0900319698eeb96e01d6743276dbb995b3131c60f3":[3897149,16208],"2d0c694f15a861a6a0c5981b606c2f19e6b2726210249c0329c0ed6a55ef70f8":[3913357,66101],"2d233595d3d6a9d32355c9ae167585c20301454c1895d19ad86f1e68920350fd":[3979458,182],"2d346d7bca5fc245e422350a9a39c1e8e7561455f768a4aee85d9e1fd443154e":[3979640,62568],"2d54b5f1401e42078911c9f0bf9d9cd2043831e1ebbe756ef89a90eb9784cd6a":[4042208,935],"2dcee63f56ac5cb3558a2a7f3fc8604bf61cd545edae29f9d57aed42170ed1b1":[4043143,153],"2ddcb5890375142467f3f4427d25e891dbddd961b809a88d919b5a627f68f605":[4043296,1033],"2e1b675f4a30854a24e637cf09b4e00615240ce52a71872287ce67f9f4d3e9e8":[4044329,182],"2e1fab9d52956d48914d71e78f6f4e283fbc0b2c75f02a345c6b716f1d3adc04":[4044511,182],"2e3f299e45ed65bf15d0f097a62d53f6e5c4904a0f6e551a7010200e195e96a8":[4044693,182],"2e5738d1b2a4b6e735e362e28d99c9629cafa7404f0155a893f2124b12eb8dae":[4044875,153],"2e5b5b54aebea3db240acc2f2dc944df07b908bf97ba2067517a9757ed8c7dcb":[4045028,38879],"2e6328cde4c885e073cae7722b8da9557cedfb471f20f3b766215224d7d99edd":[4083907,589],"2e7c0133af4e58e2c4d0d31b52bfc61ca63d6268fb88905397cea0c802456e1f":[4084496,40141],"2e84007b600de3257549b12e35b45e779d85751c59dd2ec7db7abde7ecd7e4c2":[4124637,20197],"2e8484291bc0154964cc7342b8e7c948064bcc7b946470343fc8b7711261c24d":[4144834,182],"2e88434ca61348aa7f7a71203f65f08fd9421ce60ace0ff5d2cea854bf3f60c3":[4145016,69182],"2e91212d9799b000dbe1870c6fa9f7d17e430ba9552430ca5c670448966f9740":[4214198,922],"2e9786d10a18a23deefc7761f52ecce829f35ba82947d5e22eaf0e45c7af8e08":[4215120,155],"2ea08d107bc434790a03307dacea1397a16ba7ce0777b8930774af528b9da342":[4215275,4866],"2ea965827e5c5073cbcacacd22bf2318a9067874c901b4f05f67268e99baf92c":[4220141,32355],"2ee0a6d9178c2e955b0161b96357bd7b52cb3e25b5469f800e7c909fb9e0254b":[4252496,530],"2f4def880281f987518186a75836278fd0c2286ca7cd8a63094c9467bc92f5d6":[4253026,666],"2f5a01d387b32f69bd9232fe5ce95d8b08b7e9db9b93c11d42c4dd32cde94f1f":[4253692,1937],"2f758393be8d57d08eebe5fe3d3bf0d16052b88380aa2d49da2dd801a7a91d65":[4255629,872],"2fbb3667fe723242b634008a9291cde399784174501f60125158b0ed9f041e46":[4256501,278],"2fcc70d6e18e1ff69e48de4a0384b6f1ef91d135841fdee0a0fa24ee22f40a86":[4256779,158],"2fd0b4910faef6da958d13df54cf29ab0a21147d5f2f7411fb24ce7e04d1f6b2":[4256937,277],"301a7b4095f6ea2b3a9f40d893759c818624d8cc56a19cea7454dc2d47fd2e48":[4257214,1069],"302d4ea6cf62115a60bc981a8ddd5fb154b024a3a2bf7c0b06c11abf27b764cf":[4258283,182],"302f88d8a2cb814f96c9eb6e8e9deb7ed6cbd62c458c1dd92fc096612d7f48af":[4258465,33327],"303b0798f7d68368d0e2fd049ea1822280309b6079e6f82306bc8e126cc71268":[4291792,155],"304764e6b260309c18ca882241d972fbf47a6f32fcddb8a340971fe92fac8c93":[4291947,808],"3050b7652a1a7070197d7fd954cf55c585dc328306ae68a91b27d24b6b84dcfd":[4292755,12196],"30858d5503624111402ba8e920ea67ea17305ae3fcd02bd8307d391bc7bb0c7b":[4304951,4866],"30b6266ed0e5721af16cc02db29b907d5eb8bd5db19d5f690803ff81749f7a4e":[4309817,18967],"30c55ddce8492804cc71e6832202b1bf7a8f0bf9d4f19134ae72120d85628ee2":[4328784,922],"30eb3434c660a905230df4f863e6dc3ed4e0776b1d1a359f48e40f1ff3bdd460":[4329706,698],"30fbda234b36ed47fe51cddcdcb8565b26b7616fd3b7223a0d3469eb2bc82c74":[4330404,38392],"3107172db0f2cf7a2f9eaddfb70f25e2702f7cd4f2689a30fe1d76aa460a771c":[4368796,1069],"311e706759d3af6e0ade22d42cd9326ffffa1b82f066e44ec958eeea442b18df":[4369865,159],"31507b7687b2c47d7a03127bc4a0af7fbac8862794bc9b83c6d97472f270269e":[4370024,673],"315f6fa79c7c675b848f1308087ef5e1c328e63cdbb2ad88929b0c89023a27df":[4370697,12017],"319886635ab0c36e47b910b33bec23135fcd7ec0adf23c236d1ccd021f0822b7":[4382714,277],"31e889d315c07b1da4defa39141118e66f8a4424b5b1780d9b134759b6c69af3":[4382991,159],"31f544ccd3948fdcf12434aa4b6a091d41ed27ac9f9868c3c897ed8ed731957d":[4383150,152],"3212decb008a5e27180a1b70fe935021d353fedc897e347ec37c46ab1bd01287":[4383302,4949],"322c29d05ec662468b8d2446ec47b826a7ab3bc8b651229e5935cc0e0371e6be":[4388251,5715],"322f1b958a1c9564a4343bd63684723eba642d37cf5c2ae1eb3fc18109a1b0f7":[4393966,153],"32610e2adfeb061e090ab73463d2ca6a36ad76d866b122d8b55ca9e3b6b429a9":[4394119,49649],"326826be3fd8aaa84d4869493b7e41f886a07bea8960df79b61c87f557f4cbd0":[4443768,1622],"327df4533c69e9df80fe2e89dc208ef1194ce9529a11c8a6c21086bdbc1e918e":[4445390,155],"32812b7dc48cdba647dd60846a7204e6e6a76eee25b75a16aab0e8779497d214":[4445545,590],"3288747bf43501a729ef81fd0c69314316f9f7ef2e5dfe2d3234f61fa4cd6b6a":[4446135,155],"3290d204a2016025d1a73358aad472889ca34f4b34c1dc68c95202ecbfd5648e":[4446290,4866],"32a158224d4278f8ec8a4a8dfe165370fc2fa602b09d7af234989c4adb329447":[4451156,1069],"32a60da398e271d27851e65e50bd39acd294f92882501e7d5a3d2c95910809ff":[4452225,153],"32ac4d8d54f9f5e936aab338fb31dcb84a56b862089a973b4ee393f3ce422f62":[4452378,962],"32b2bc8368291893a4f835b48272fd05d92f8da062a940e530b6df725070f563":[4453340,922],"33183d6045e7007f306765b630e17d834d6fa5198dbca437ae470e1af5d7ed19":[4454262,904],"334f0a27f85b5686937e407156ee9e78ab652ebc52523529223dee1ea8174e54":[4455166,962],"335cdc8da2bee5a1ea22ebd01ce3aeb652efa002f052617fb3123eb318193597":[4456128,590],"338c9b575a24883622bf3cb1e3276c24a26c31ef34a5097927420523727b6d26":[4456718,589],"33a141441be88c6825e85a1495467d599a016bae61a678358f9d680a52017c99":[4457307,594],"33b67f34012e2dc851c4bde74caf98bd70c33123696e6169a4bbd7f9f9c32e69":[4457901,962],"33b9b868629947a37ba69a16cdd88b5f91fe1c54853c6910d0b22aeab7b22800":[4458863,589],"342bd33e8d08dec713feb5979249463f25ad382dd960e0c156b019c1e6de0e60":[4459452,31063],"34310ae6a65b35a5c9f474392cb3cddbd6adcb05314b2b280a0014f4a40da94b":[4490515,922],"3448e19e121d0f55b9a0f58f38d0ad2a5849ea8e6676688fb18401987dccdf2a":[4491437,1548],"3449a4f70b81e24d112bc3287490470a526c9c728bba82cc7ee4dbf4597b354c":[4492985,13876],"348374002b03bfc25933bdfa89d0fc466588caf29b753c1500ac9ee13e2adda0":[4506861,152],"34a2c120093522f53a43e877eb05d338a96d093ce30d34c9b8b5caaf288a2122":[4507013,152],"34af262bdb9e210661534e5954107df8da9354c9e616f791c2dd3346242be742":[4507165,665],"34c0d55e435530ebbf3fa3462a1eacf4b4369bf4410f598647ca4c429fb86889":[4507830,5085],"34d59d5e9be7e7dedcb507f74b29b3434ebe2163e84bd2631669c1ffb1974a3d":[4512915,104432],"34dc975ee82f961df8e9e8723e0bbb65cf8bb9f89d8420d74f87db0bef307fe8":[4617347,4830],"34fedce495add63f17780987cada11ab593ba090a1f84264714a8d94b7794edf":[4622177,15924],"35abf1f06bfe878e4cbeb68072a6f75664b756ee46e65a64003889b78e366013":[4638101,19435],"35e0441f1615143a541a81bc7e391f17c286a2478404887e589423452f49e07d":[4657536,152],"35e58a71895a9981f01ee4dec4bb6bd1a8464d507c1d4bd91a2fd07759f85f6f":[4657688,68817],"360317b6771257379b9f895e5ea5f8f1a8015c238b60dce2886d95cfda2b82cb":[4726505,182],"360a819b87eecfefab1a3831b4340ac7fada4ccb2f8ccef52208b6042f556d1e":[4726687,841],"3664f2c03dd7abe8e32f2910dd9088f8f493a28a507f4cd07c02d20f68d742fc":[4727528,590],"366a22294f2c4a9eccda8fd05f4b6cbf265bb94cc47827f9bfa114df64e5695a":[4728118,153],"3691606e4e8c5b310d3148cff5211f28da63b0d2dff14c4696b4b53c1d018e77":[4728271,589],"369b5bd74bf4b48144d30e7cc7fde168aa82839ee80cc90977d36decc8b0ec1a":[4728860,153],"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516":[4729013,4545],"36ca67807d1b9706467139566db3b4e87ae63d44dbfc10f8d95eb88aa78a3331":[4733558,1458],"36cb6c034405714880e39433d450c6ee1890fe9bad5e062eae3b704ac17adee0":[4735016,8966],"36d2b54824e4c772331483e437c0bd014fa4fbf2e49dd652b9bad3f95e3787f4":[4743982,4866],"36d2d73b7b40e5492caadd5105769ca257a277969c7864e48a4dcf45f3161a38":[4748848,182],"36f63e3f88a68693d694c2116b15ab13fadb6764a3a3cc60f3a0ab8f404b6044":[4749030,31547],"3702edbbd4471a6eae2edbfe5b2aee2590b59cd3b7bec130953998fad79a3353":[4780577,582],"37062565229e5510c0680ffe830192348251ac35f6327b1f17f3771b0d82daaf":[4781159,5214],"372aa89d2794983c0d281ce275b94fc4dcad23ba9dcd8c075b91b79097e1ef93":[4786373,13269],"37359faccafef4a4af863130f729665c20e86545566beed1e3266ca785f3a916":[4799642,1458],"377caf3a09cb1dcaa7e33ec3a831a01056ed846576c6037c97b5de4694d8d76d":[4801100,743],"37985e1e1289f96f00be156b4fff0e75a2b00894f5ef2e9816ddca84e594c486":[4801843,1644],"37bb2e5b41537a9a7aa513d5400219415ec3bb196dbfdde9ca8abcb1ae0c9090":[4803487,5214],"37c8fd471686081921990562b09b0ff44896b5b844ca1b8b4e2768a5ee13f25c":[4808701,152],"37fc6230a31e6efbc940e618a1ccc6af767b5660a9961c987cc04018ac20076a":[4808853,666],"3808efee99d8af834c52c0f732c9a199b90a427dd0e299c875eb9661ddff642d":[4809519,182],"381b9d58e0d074ae71e09997e47fa0e561481461aaaa5e66265f9c68c778122b":[4809701,15228],"38a049eeb34faba7c1bbe5a01b2ae6cff75423164de6ffb6c2230983688d2c39":[4824929,904],"38a0e54ec2dfa9612b1b6604dbd3d186dbf914e855b1973ce498548b3a75e834":[4825833,182],"38eaa9d6b15a1bb30970cfd4c3c2f98fab73f4a40ce774a7d622cbadc210ac55":[4826015,182],"39142327a46cd4805056d9c6fc3f86d0c937a895ae8dc5ab63b22f45fa584941":[4826197,530],"3960d70a07476e0ca5ad272606f66b22890a9d9169b4b411397290260c51e4a9":[4826727,590],"39678ca222e48dc1ca873843707397a6a468dff245c9e8b95077608e5d535b14":[4827317,152],"39e5ab8f541f394c51a609c501e6b5031c8cb5e5e773dd5e0a0b3e36be453e15":[4827469,1877],"3a2211ac2276e6ec65042806e5a5c730548039e0ff45a87740c881902b327d71":[4829346,7194],"3a30967b16c2939468c2489864d8a8dd2d5b61d675a13c35933154e2d68b2832":[4836540,153],"3a3c8e64df696c063037233fe95f9f6a3e1b674dafe10cc13b72e1361464164f":[4836693,18570],"3a52de9b3d6d111ff63576e2d826cce3b1f9b080dd5ba6e4e3a2dadc15d7dea8":[4855263,2984],"3a69a0cc3aa49fbec350965319958e557ff7c8ecb703a83ba0b7bca6f0d40d01":[4858247,10175],"3a7291ee4281e0a6975b1834ddf7df28355de340b0bd3548a9d872dee5540bd7":[4868422,44466],"3a903ab01371afd8ea0ce5158677cce4442b006349cf1abc852afbf0395bdb85":[4912888,16519],"3a9237b02a70d94eff691582c3de700ec20de6c7a08d66986e281072c6fde476":[4929407,42734],"3ab13ab242a313adbbb7497df3c3ace16ccaddafcb8a5d1cb3022cedb8438010":[4972141,155],"3ac22853b1c9092a24844209da8b7db1cce3440ac2aa795411a1712942e4fae7":[4972296,162],"3ae089b923327032845c386f2332ff653a08f25ccec28e0786094fa05fd77a00":[4972458,12225],"3b01f1d3d8a8bb09dfd5419820915dbb36b8dcf3d0e4abf4b246d86650696e8f":[4984683,152],"3b3b46d4f8f832a2b67e59d481fce3269061f0c79cb335d3ddc5a51d8a1ce915":[4984835,12726],"3b6f41207904f483005d7142ed58684e22582bcc7cce2b4ff17a2cd75b25c01c":[4997561,472],"3b7421e189adae6dd39c27d3f509f3c936d35aaff975a6dc42ce3c7f16bbf4f5":[4998033,115],"3b78f51def8fc7f7508c5d006eaf10772144a9e1aad228a6aae48abf40da1d3a":[4998148,589],"3ba80460a79eb621e605030de794812e70e475bb59ad01750d03c5d35524b1a3":[4998737,17345],"3bf9a4b0e8a429450fc7d3fe321ef68f7a7382e827cd76136be68a2c2fed4c31":[5016082,666],"3c0db9b5d179e2f3b7123cd8e08844ea9ad28a0a6ffbc7e5794738709f5af1f4":[5016748,21246],"3c0eca07d2853979c3a1d9ab48a42c62272a9dfcea8c3e0ba67bac13ae3f8220":[5037994,1493],"3c118ad2c847b2b045ffb1ec6763c0d967ad2a26b8fb53c7cf3794ea7a41a7c7":[5039487,1877],"3c1aaaf11e4d33c0e3bb4e15434c1c4f24328f76c25c41193a9a8fb4119629fe":[5041364,152],"3c251b1b1947263918fe20db4e1c83fae7baee926b3953aa244925e187430015":[5041516,3832],"3c512923df554d1876eed8803ad3d4f0f34029f937b19529b6245a687d9b6a59":[5045348,4609],"3c72ec744785180719afc98553f9aa7fc4b47c427647818dbf75ba4e03cc4045":[5049957,23722],"3c9fff356bee3170325d822db83df61b585e687ee020d82b9a96911e03a2b73d":[5073679,666],"3cc26f37e3316f864826a833c72014e7a6e93eb36428d9725912e011f680d9ca":[5074345,26808],"3ce6a557f9ad7fa73171729d266c7880bf257d102284949125316c587f654cc0":[5101153,1155],"3d5cd58171741859583db71033d74bf63ddc6e7f31ea85244fde32102db2bf1c":[5102308,155],"3d738b3e5691e0d4aade9cb39acbaac03bf51631d8b017dada36c707a0485333":[5102463,182],"3d8b5d6a28405c3b603bbba90cb134eb470c7745724d5de60983098da1061562":[5102645,1069],"3d8ed73b166acfb1f4b4d9e25eaf578825f3c41a45e987563029d7169d2c5e1d":[5103714,277],"3de19aab9278ad2c576bf9ddf7924b58669863b917bd1d71f525e7ba1ad53a07":[5103991,472],"3e35ea99e2e810867410dc1a4e0d9018bcc0501aa69e5d983e7845806457d7ff":[5104463,922],"3e39956abc40bd1fc752de94bcc85e2277b07b6faa3a191e6cb669486e4ae0aa":[5105385,922],"3e400f98b792a0aa88f6588aa92f936a6032bea833ad6e0970969d472de2c09d":[5106307,582],"3e4e355f363f65fcf5b32b5fa19412bb6ebc8b2b3b4638cc75023112afcaf971":[5106889,4703],"3e5b8b025c0e48abef1ef000ab8b9725ae1529969cfcebfbfa6050b48ae5acd6":[5111592,12509],"3ed685ad71d2d7db241c36e48d101dd8e73e8d4054151fc4500158d6a2a16dd4":[5124101,2377],"3f12f7c4213500f6bd42951b13eadea6d9b147ae2becf1c14f0f607cef888efd":[5126478,2024],"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb":[5128502,1718],"3fc1ef96d843853d33c27d2b9dd981b4cf7c539a24f2905aad915c5efa4d2350":[5130220,19896],"3fcb5204c268cce11faab7b46b4018d39102cbf0a5af73c86f79baeea0c0b42f":[5150116,182],"3fd706ccdfb86ea269dae81a3d6d1a4e0a7eab7f69ff74117d2d9864767c6226":[5150298,158],"40602bb2e8e06bd384895319bc98b8ed659e32d428b4b7c9688380bc7aca41d3":[5150456,155],"40689b2560d6c445afd552c2f470ba3ce0d427622d96ed2cf2ad8cfdb4286321":[5150611,32414],"40706c4444f1a3de44e590cf67828b953e2f0ce55ddd5416e93712e04f58155b":[5183025,3193],"40b632faa37573d7db0a0c650476d17f883dd90d00c9ef2d4b2ea57ab955b6ac":[5186218,841],"40b8a1f46b44594d944a719491341f5e04e0cc118af0e9e549dbe266351c7da3":[5187059,153],"40c854d398579fb06513a18280ecb48df88b6679c8b75604bc0f53bb4310a601":[5187212,16051],"40ddb00b3be320909acf0ae1440e2bc5662e3002dcf77dc3367bcb215b1710fd":[5203263,666],"40ef4eda0910dbb946d3cd16a6e2eef1ac24ebfd7b57fbf0f688222b3826be2f":[5203929,155],"40faaca519e9d3e7292f35c83f9b09b37aff1e1b0a08ffcd6ae1e0fed5df31bf":[5204084,50086],"40fd2bf36b3e6df760a1127e79f3bd1e363f34f7c6d0247369df8ef5870ff550":[5254170,808],"410a628af3831ec0633f0a6b9e68d88a162d6aa6e83d1c8eb26c1f4bac6ec140":[5254978,155],"4119220a20c100391d224d06c2614d3e3632eec40ded0cfa402e35ea70949204":[5255133,1937],"4133e6af279ca1a1df4fde841f7e256b34be2291b3efdbb1a0cd2852acb07966":[5257070,182],"4139939d5ce1e1118cbf72e76f8e3122007ebcdb2fb4d533fe418cd45833ed3c":[5257252,182],"415305923a64a5a6797ad1da831c320be66a4dbd6e25ac09bf8aea77c82fd7ba":[5257434,962],"416ba4dcab352004073c12e294ae4034ae9b25d7375568e42a779db08e9a2c2c":[5258396,153],"41831e2909f88aaf88451b2435b6bcd41be1ffb1066197bf1ed0fe5e2107876f":[5258549,182],"4184266c3322e534d08af35a422433e9ad39cbf1c471643a855a0d48be278fac":[5258731,1060],"41a414d3b2e272411faa535acedfa49a12831ea4d417083ef16e16422bc9bb23":[5259791,922],"41c6b8a4a20ec965be159ec3cdb464fe669a30bd6015ceeefed4ef6113ef3a8b":[5260713,182],"41cfc0992c2a7a5286093b0e3adf55e8c8cb226f8cf52f78c6edeec958cbcdb1":[5260895,152],"4217c3fa6991f4d52147264164b45f69094b4b170f7e4bd3187416b6aa9ba5be":[5261047,182],"426725b67e2d43f8c3f88f507b0583d8284b96b94e42aac3523707502eee55a2":[5261229,152],"427bf8f1bb7ac29049e57dee9221077958aab3a27bcea85a51d6044373140447":[5261381,105747],"42cee1b00043646e7e0ebed264397212c4ae136d44233aee64af6bebcab11d53":[5367128,665],"42ec4bb29457e915f5242da17977cfe5fa5873d3f2c7b42885e4f68549a3d36c":[5367793,13985],"42ec5951151046f227be896e37e6906718443065a0a99c05bc7cf6bb85dbce45":[5381778,153],"431297c192b21494609614fc617c35cdcbeae92e9b6e8f656f7f6c22742709bb":[5381931,153],"4316a0ee1e4f6061a6ab25eed8e494799c62d033a790fe1ab629ce4989318039":[5382084,182],"4336fd97c83d636ce2ccf237d2a61546434ad4d5dae5f90f0196b036fdb4943a":[5382266,530],"436222ec9de04bcb28f6d32f34ba5be42a161de58691f0e3f3478303fd569532":[5382796,582],"43b1245f514e01498961d0fb53b8543fff9303363c309346031fdb23984f9a18":[5383378,33612],"43bebfde8565291d15bf22413e94353a586461816d0b7a36cd510478aaa1bf7e":[5416990,1142],"4412c25cc3d2e087ea403f71f7c906cbd3696186cf20cc018b701ef31b1e6ce7":[5418132,158],"44199809fd5ea9b54ec433c5af22bb369b05958fa875045a5be35cb8cfd90884":[5418290,182],"44704a3a2c842e344fcd7218c1b9da9621dc645340891cafe396a92a52386991":[5418472,28274],"44c459f618e462fb4ad039176c11b28e63c24457487dfd3a5483617749d91dc3":[5446746,1069],"4510e428e7bab17df63925c70fa73e7c1041fc551b3b5f5decbeaeb7b8a9d380":[5447815,4578],"452e58b6f20f2839c3df8f97f12f118bcea90e5a2690660cf21f28b9108db1fb":[5452393,808],"455f4d5289ded3555a1f0ac2f3d59d85163692066f0365941f0b615b8d8feef3":[5453201,152],"45954b48a420f74d202cca3b0f6d605c3b57263e59e47dd57dcedda4c5cc8c78":[5453353,902],"459f958ed6eacab9583156c2c8c94584734ab5630baa7d0096ac659a09ab99e0":[5454255,152],"4658c5aeee06f1671bd926c8da0f1f468d0515898728b08d975e3a150e89f557":[5454407,152],"465bbe98b63516136f54d2a623616a0030660a132be3b51aa96d7dad6351d717":[5454559,922],"46af6cd38f66211dc1398049554ea020d713fb1a4a1f1d5b2e532d78a33f0304":[5455481,729],"46d9e035e6286b85d9cf3f0ce2696fff23ecbd1f86cefe94228b92ca4fcda4d0":[5456210,808],"46ff0e780baa0c9a533ca65a9d6427268f69fe5ca1f1caa9b08a6d9e0010cb24":[5457018,614723],"471d1ecc800a68c64ea464590b52a6d49e03375d902be67b20450c2a0b745b19":[6071741,918],"472a6986704fccc2a1f592d6fa2dd293195eea5e59e2bd6637e9a8647296b465":[6072659,665],"47495a00b1e0eb932e5ec2c130b76b0e6bc48f51e9689d8f2067b2e98b4dae47":[6073324,1937],"476917d9ed89b9fb76844ef0a20187142687eb250a4752d300e98a7fe1363f41":[6075261,50304],"476d976420715ff3f3825cb42c54b56a8fb74e437495b13b0072b8dd33a84c2b":[6125565,922],"4796bf8fb85297f41813bae890471f5ebdc7d49f872b63674d31fb88ad40cbb1":[6126487,988],"479c5f17d9f151e39c2d1c1934a4713fd1b4df6d7f9db4430da802f00294b7e3":[6127475,841],"47bd411ac79f933d342d0232810d9babc88ce83b02c97a10b1d23375ff0d91c8":[6128316,25227],"481ab7bd18388d6e245e376c8e6e0de8f4602a01eaf555786dc0316dd5bc1ba3":[6153543,841],"484834e15a6a069f624bd07e000413704177c6a6fbb2225a74b29a3ca7e4bb73":[6154384,182],"4849348275e3c2a55836d85f5b720c855b8acd6db6a2aad4f418cf407419621d":[6154566,1458],"48514467b0cb3d391351e1ea360494fa8ee24e1ec8e3634a749df6c99faf739e":[6156024,3180],"48d165c0c10ed38a4c594954631c4aa3b9a3c5fa562cc0891b4343fc3d32dd14":[6159204,22250],"48e5624ef415a6044dcc36f8f24c51662620a5ff31cdedaefb76aefecad4fc63":[6181454,1010],"48e6ed48a2091eabfe74eddea5b1eb6aef94314f6e3670a0ac0b6a23fc1449e1":[6182464,698],"49356305465fb49ebb7cdd91222fcc3d651ccbc201d4f1d32eaecace29e3e8ae":[6183162,25147],"4935806b1fd8ae8a2d0d93597213ab55ebfe7965cbdfaef8663b6e24c80f9bf4":[6208309,666],"49444e7934b9431dd7f4c8b63c158344c7521dd59af343f8307d515a3b7d250c":[6208975,962],"499af8408b94358aea052787b188d490b6bd38d42ec822bc32d4c60f198f33dd":[6209937,182],"49df94955a774a846f86e6ad00fb41b31c036e2783d542282aaea36b77633362":[6210119,152],"49fb56a448e3a6fda34c106f9c211b2e6c0e17c366e430bf25970e3695febce3":[6210271,57165],"4a14851b79c394f50d27ff1cbe4bc1f57267d19134b65ef2cc2b0a861998238c":[6267436,34598],"4a1a04b3590930e1ba33b9cca6ae1ee3b551877bd47ad625ee2c0c67d0d226fb":[6302034,182],"4a906db760f295b9fc794dcf67695379ccb77268bbad8d4081a0f1886537486b":[6302216,922],"4a9548b8bbf4314ef96622e1f070b80cb46b89c192217b2a863b20a0aa0142ad":[6303138,182],"4b14b273d01558abe3560da549f380b808673d119fa0b3f23343a210e6098aee":[6303320,1996],"4b2409c81529d14c453849a1dc74bb0487bdefd72b744108ab6dce7a5516157c":[6305316,155],"4b2fe9210499da6b7cd566c3d2e587c1bebad47ef3f0d83aebe6c0358ef056cf":[6305471,18380],"4b445320f81bd7a35f96dd118169b2f02dd112966610fb0d60ccd79e9ddc6e46":[6323851,16903],"4b44f76ec9cece4314358f54861f937a5c4e875e93646399a3abbb09c2a51bea":[6340754,3008],"4b6068f81e4c4b61ddce95caca8772ee1d39c15dad180a6ab93db5ab101d9b41":[6343762,962],"4b74101c836595b3e54307478809b5453cdd0662bddece17991ddf0781e4f982":[6344724,935],"4baae1ba37755ca07c48e6632bd1e01dd48543b8849e10a1576d5ba9596dccb5":[6345659,1937],"4bcfb0aeb73ad7612fe49e0b28735eaa27dc2feaed973b0d7cb211594448bf6f":[6347596,8819],"4be7212bebd28fc0aa4a937abc253a552464f375fce3e4ff4f33abb22902da57":[6356415,182],"4c17993a6ad9ed7e39ca4382a9147e6f52454e54cc0ab7ce91814b2c96a735da":[6356597,20751],"4c77da5491ceaa3c773bf3235901b5c3fd827634bd0d9435510c5505dc06cd09":[6377348,153],"4c9a9521b76a633b9cab4105ade4dc339df9abaab8c0f4ea045fe66e022c4c35":[6377501,1458],"4ce91446078aa8686a56c4c665b6d8fda58b19d6ec02721577e7fb70d95d284d":[6378959,4578],"4d311c2a892a05d64e1842768363dd60597693c828bd017e82627692d36d2183":[6383537,590],"4d3a24c7508dab64e1f8b97865b1876b537918ee9dfd0e33617c6163d5f3b61e":[6384127,2377],"4d4a4fa11c064ae1a658904756a7ce2caa9bc1540b3cd9459b5525c7d283db8b":[6386504,922],"4d61f4b64e499d0a55fc3da5cb95b634d9298e0881064c1ca353f8474730c6d7":[6387426,182],"4d9cf244e9305700cfee27d61fc9f8d168041c5a216a94719ce226e56e5522af":[6387608,155],"4de0a158c3cb4b8cab1f34dabeadbd77c2311eec6182235d0c8e39d50c14ccd1":[6387763,153],"4df745d53cdf794e90eb98b84242a5575da64075ca21716afe7efca51caa0a30":[6387916,8172],"4e49e64e836296c17902eaca956851b9cf95ca891f70024b31995ce25dc3731b":[6396088,50900],"4e6a7977f8e79a2abac7fed68a409e8a72353b8223ca84984cc743ab9407cac0":[6446988,23689],"4e824e27683f5d080669059f13638d525c487f908177b242acf5b4feb022d7db":[6470677,593],"4eb474e4d77d82252b33eaf51d48a20d885fed317bb795ef2dd7f33860f0840e":[6471270,14652],"4ebd91883712ded92ef893e8cc61514ace87ab4ca2f5bd1bcc5369495376a50b":[6485922,109132],"4ec43628c3c71515c3b6fd7ca31ec4cc5b4c37b1260a541f7fb4068be5769dc2":[6595054,661],"4ede33df8b04ca4243178cfa1dc5d46036133d8f5d248e7ea32c990e74e881e3":[6595715,7564],"4f56c4b69a351ea26b9cf5b20fdcc8006e7c44eaff2e7b3ac06db556f8838cba":[6603279,988],"4f953bce7cbec12491ab51672a4b30b5cb233aeaa444881c2a06282b75263bba":[6604267,9932],"4fa84652188075702534ec13ddf0f25a982cfe982e9b60d7ae8c6092d9faf48c":[6614199,904],"4faf6920975609fec02c2c646a54241557a8663ee5b3e9154ee7597c46af899e":[6615103,153],"4fe564cda80aa44ea7573c1a828ceb8e198daf6549c61e1475025d53f4a35238":[6615256,5818],"4feeb1aaa662e39cb05ec5a1c351a2dcdf43f072ce232487923bddb8d50ee4e0":[6621074,153],"4ffa6ce120f7fa01d77d681e1372468c49ad95116657996f006a983e3daa74f2":[6621227,530],"5036a861ae00692b7f0812985f2bcd4aae5fade9b28fda19d23855c1de7c5a43":[6621757,152],"503ce69816a25637fa759ebc9ee0e90d64903dde221729eb06e6f8edb26bf571":[6621909,3513],"50521f151292fea87249aedf54832c1dbdc76f0a5ba285fa0af770ae10e67ae0":[6625422,182],"505d5f544144525d73def85bf0b3b9e16230ea634599f4d343080af78d359024":[6625604,155],"50b05ec0aa3ab799f26c549c799dc776f16c3cd9bd5eb3ca833a70f03ceb4fd7":[6625759,13316],"50b23bade4b4047e5666236ce7532082909defe62ab7cba73b661164974de6d3":[6639075,808],"50b745f71f63d7fd5b8a05fdf84474524004af8f75d47f8346b7404f99981fab":[6639883,45810],"50be7058362059baa2d256106c6b1ba3095154e689d02154c8972ab4846caf22":[6685693,590],"513fdbb16b9f7a1e1793f2c320111b2dc34e4559013c25d2c4ec92f7b2bb6e66":[6686283,182],"51817c7e1e3e6e351e85b6f8f5123fa042f2381f73e104ea2ff9a6acad29baf9":[6686465,153],"51a52efb3c1d20e32af8c5097f4e5e4adfe186ccb7d16b3689b844dd1244cd76":[6686618,589],"51a5efd09235e6a66de7381faaa217a51136ca9c7d2b10285f99af8ebe4ffc6f":[6687207,9312],"51bb437ce9ebb45c4381ed2f33805b71f99b6952618f1f1e3332108f5daf519c":[6696519,614143],"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed":[7310662,672],"51ea9c40dc0d97e9bfe016daed54dee7f9b82b544ca8d76349e9b8020d9cfe40":[7311334,182],"5207d1257c3bb673c75b2010e254441d33a270de69716b521f3198629702b49f":[7311516,14552],"520ca02fb2a0fb05deb02a3386c9aa14433e7218e6e6abf11fbaa13c273bee21":[7326068,589],"52106855f89556e0493f5130406968308202e7662990682a0236dd5bcc02f1e1":[7326657,155],"52447c9716884a1aafc41be8e6df9fc59fd5ab593c3936c5f8b4734a40d6308a":[7326812,808],"5247e34ebd33f612010e0c38d0bf1b4f2b001de64888ab0443c9d38810546cec":[7327620,25150],"5267d01ec740e8bd16aee21fcc3b1793b34625a15aad070c4f99a9c1fee469de":[7352770,922],"5287b1e356febbfb1bb42750c5d0e218597176a3fee19f387b975d84aa0cc11b":[7353692,20359],"528c92a4e15aff9b851e1e519d1f6055dccbc152e7a4db8aaa27039e4414995c":[7374051,4561],"528d7341585cffbfaba2fa06bac80302b4fc93a2b2afeb544c45ac7dc362f01b":[7378612,729],"52cf17c04dccef9f3e7106db729cf1555210c1726fdeb087e94651e7e60674df":[7379341,155],"530e8dda424f5f47d6a8f7544076b488a86d416f8e5146d2afd4b971970fac74":[7379496,582],"534e3c41c707c447866e27019b48dc0890e2778d3f1105610b09c81337e4e91c":[7380078,12192],"53509adfb5ae0f67f9f6fac51d35dcf4f609eb293fe7412b1a367b5e8090aa5d":[7392270,152],"5378c8ec94287266d153db6a4f4419211016af1acbf8f7432fd71358433d5915":[7392422,858],"539a182ea541e60a1f754a9c3205d681a91304592e430f56ee35f04c9b32cbbb":[7393280,153],"53a0ccab9dfa85e4f9715bc963de833a064081e2a7c96dc788bee20f3d9b650d":[7393433,589],"53c48ec27ce7a00111dfbb21518c97d00daa66cd30143d9c9819eaafe56b6a2c":[7394022,152],"54051f2bb4c101541cf5694e1abdd113365346c71c8356cb3bbe9516085fe66b":[7394174,24278],"54466d71840faf6fafba87099ed5a51a284f481fe896c6bc355fc6ed92f884d8":[7418452,918],"545448f5f281b3d978c84772405a2e2979cbe26d7e10828e964e795587135039":[7419370,590],"545712a25e324714c39360819df5fcb4fc3b8ac2bc8148e2d65944dba8cfca0e":[7419960,1155],"5483f3f9a779b78bb9e4f60e9bed6e0051c7a0b5b519ac46aa69d5dd9851ac98":[7421115,18917],"54a3c05fc007e882fc12006667603e955548c8d9109ba81217955a247e3ade47":[7440032,10996],"54b3f96a87353e6f431a05117d9a3390e2e386494ad40004cc31a7f96243ba75":[7451028,922],"54b8106aaa41111b04f37b836cfd971d1310b8ac4fad81f551e5ab2d02ce355b":[7451950,28697],"54c977d622859326bce3237310e05f81007e9dffea579c9ac97457a07a1e8d5b":[7480647,743],"54d53ea574dd74da4263771f7fbe99734f4b9dbfdd419debce4462c1d47f2975":[7481390,582],"54e74a9ea88a542e39c45393ba16f55f2204810b4beb411e87dbb1552d5fb2ac":[7481972,8753],"54ee4b6482adebafa7f97a6abf9d81d0213742b6ca9c48168b07f0634105a72c":[7490725,37463],"551c46e13db8ff52bf6e01e1a7e8ccb5bdec52e51ab6b3720aa10df87f88d84e":[7528188,872],"55200a2e25b2f3bb2dabe0c229a2cd0757c12ecb1a4f455f3956fbe4a394ee6c":[7529060,872],"5521a1f18a7e90cd2b61be3ae7557245fd71cd03251ffb1f2dd105f79bb3128e":[7529932,21369],"5523f25c93225c10d09b247f1e832c61e58b505b80a4254d7f54f4a488b3acba":[7551301,1204],"5528dfebf3371b364acc09803985a7626ad6f1c390e6b0c939ebbbd7020a4f2e":[7552505,152],"552d4c4d5476b26a91d581f0c430dfaf8b2d1f3d7eed2bc6c808e4148a666cc7":[7552657,7338],"55364e2082e98689c139ec7c389f101f6577004f82381d1dd02934031305e601":[7559995,182],"5545548c5bf7e6af4ce19faddb71564419861b3e6fa79c36af0916a223ed8045":[7560177,182],"55624d397b545370b9a689c3d3c5b310494cec822516711be7cb2d9ed2b864f6":[7560359,4376],"5563be96bcc9c0213fa8edf66ba8e00ed3eb1764c7a3de14f4043b2035e8d9dc":[7564735,182],"5577592b64ed66ed8bd6911d322d138c119734e148c0ff8106afa475413c5eed":[7564917,182],"558721ec5962d23ef0cbc9eb4771329d7f2cde12546f6d5167e49c1b85907063":[7565099,182],"558ce04941b79c0426b3bff8860277c9bf6f03553e0f22eaa795e603a0eeb9bb":[7565281,9527],"55b8614a73ee118bd8731c37ce598fe2b9fcc799e89ead46639d0e57c4307472":[7574808,589],"55dcd593f88458e1f905b49f62a233148d4dca46aa12f410eecb953ae93a48b5":[7575397,4944],"562e142c3a950b916ff1d8cebfdd0ab3a63fb61ebb42d766e6cd8215190b007f":[7580341,155],"56462ecce45319af5400663af795f90505191e50ce9f033b7f8a254bae4d0081":[7580496,32175],"564a8af92be44bb3471d0b1e88aacb5b791b42c7aabb46a781622dfd07d806d6":[7612671,661],"564e5b055969e81897a058d693249d318db72780818790a14614a480e9aae292":[7613332,182],"564ecc0f6a253c3229aa3f95806ca4d9b0e894a9a71740153859f2e62b4d02ee":[7613514,10372],"567693a405040527f9cdd9b7e2485894ec65ac3017f1030d303ecf289587b7ba":[7623886,3610],"56a79144d46eb5353c3b032d36525ebba5c24de91b85b09ee61667b1bd2ea2d0":[7627496,1937],"56ba5b231314314403cbf36d206e130ac50ae3ae179de172e62acaa8ce12e17c":[7629433,858],"56e953f37cb5363c713a80779f2d9c53ed61639c3b0ec2591272f695fb39dfed":[7630291,665],"571414daa0db8ed1603a5cee9dfdf393a2b95a16315317ae52481be25bec96db":[7630956,182],"5720bfa6d7c0b790f6e43277d6bfb2d58aa4204ba3706d76e37e90e49a7e2a66":[7631138,4703],"572f3c58158b893fa23e36c3fe00d2dc8ca64dd5cc2ed7c2b8c9dc90f3b4d421":[7635841,103015],"575d2a1891f0d8cb790bfe515c4c9aa065a75d23e0269e9d29b843e664a57952":[7738856,1069],"5783fa10d0a6de649ab37dee1f87fdc7cdb383827376597ea746f1097ec78489":[7739925,7822],"579fd528b1e827c610ec17e2575e2ae07148ed7427d8d3d55a328545423058f9":[7747747,158],"57a28985e69e1f275780fbb1ca1671841de5a84045e803895425a8174a5df839":[7747905,153],"57a320ce682b1c107c6ada601f524b7c5e1cba4dce841ef20cada1f721e229c1":[7748058,962],"57e89f67d8f577c3d02c04f0e83ae79cad645b88ec9c2be490c8515a29c1a778":[7749020,182],"57edfecd5b655ed4dc051a769c0a03144c003d88ddaed3ffd805a378929e8e9c":[7749202,152],"582dc66d4cbe8957f2b566d2aa4052cee721d416cd4fdb122ec24a5982c4516d":[7749354,19462],"58629fc6d27eff2274235fc6802652fd1392832e25af2fd6a18d4e73bff9c8ac":[7768816,277],"588d32eae8f52db4af717af6ac4018d44286f5cef18d42e836cc81ffdb0be62d":[7769093,155],"58bebd45b6efd83a69351bdcd9bb3d609691e7d9b778341fe21937f8d8c1064f":[7769248,159],"58c24d8581d65981c93806a8d6469fb445f7e32a24e78952c54eeacaf7b08e24":[7769407,904],"59031b197fee6b0fb26202af0ac7fb131dfed6dab22637b1c161359f9ce6f77f":[7770311,1033],"5934e49fb4e7ff5d932984b2ae35bf02405608a3343f1eccf9308e7c52ccec03":[7771344,904],"593720d310b290ed164410f0dc33277e10006a41f90e438d46fae8579387f73c":[7772248,12874],"59e51f9ceeccb7464b32c50db2f23dafe489a63af8a69ccd67bf00b780b8b1a9":[7785122,182],"59fcfc2f99926abf6732a360c5f557a26791a7f4e8ffc87cf84c174acda75eb1":[7785304,4482],"5a09e64917e9fb3d96860794b946e5d78dc49be0bbd69eead6410d664ae4d911":[7789786,729],"5a3783dae0e5134d66b9e0eeddcc8932d78497f25001edb986f066a75866d679":[7790515,1937],"5a4e7106834b3a3db3abef041ffb7fa12b4ad79ec2ee5a045dcd3358016baccc":[7792452,182],"5a7720f12127eff147249aac85ccebab13c8db985113f59bff0b188f360a2a27":[7792634,182],"5ad165dacdbe325ba460c64e627cf6d1f3100193d18c9f69eaf2c3630fea5099":[7792816,24822],"5b0965e1e1b42621568efa91b7b4a52ada8617e6eecdfb8dfc8de1e49305c927":[7817638,56533],"5b31bc17073168a486d731a3449104425fba85cf934c231830061401e1d92866":[7874171,1033],"5b440cf4a96bb938a7c0555ad2a77bdbbc8210380feacdeb3918c58476fbf81f":[7875204,4866],"5b4d0bb87257685f662870deaafa6f3241634a454a5d8706ef40dd42d07f5226":[7880070,872],"5b722ef19c64875fb995c008f05677ff8f1589ed2bfe611020e5f6fb44a344cf":[7880942,922],"5b749ec1e5f1229f41a4a69cc0c4529c13418d4cac1d331ef009d70086d55089":[7881864,38447],"5b7c0553530cac82c017fc61327a6c120e284d0a6d0966e24f0d63fe07d134f5":[7920311,7198],"5bc26005b54aed577f46a290f1285957bcb146162eea96164249931559929703":[7927509,3513],"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa":[7931022,59],"5c60271283f38bcfa776fd57a8c9e8559e5f43946a8fd1e2ff3fbb5626c74779":[7931081,1069],"5c85f9c974c1e41c9d2702163845b4cc5076dd694300a3122f98daac8e283aa2":[7932150,590],"5c9687b0b683b34bcb823c08473e0bb5b941fc81ccf943666acc7640b948d3da":[7932740,669],"5cd14a3a37d01a5e61930bc67d85466d6a6026fdd03752126d1e62dabc079e53":[7933409,182],"5d14eaeb4dfb33d8251ae6917abd8757c40ff2e3d24eb064437fbfbd29f0ffa6":[7933591,50571],"5d706f09c50537806891a517f212f282129ab8df7a0647fadcc1b1cba414c15e":[7984162,182],"5d710e71c5606166c6ed3f471ba86a411990b186acea3ca8aa8ca388253539dc":[7984344,4425],"5d89930704d369a610e43fce23a4730ebff31aa008b22c9c0cc1b864ce8d5666":[7988769,808],"5d9de157fb5d2e54f52d528a794ffa9c6bc3472c2547ba4c5a98e9b82691cde9":[7989577,918],"5e6d95977066d4654c90b04b5924524324c7572a9c6ec7d315046f7503d8c413":[7990495,5310],"5e8236bbbab61a203edc7ec98abf9b4f8aa3fd0725d693954b815f89be6ae690":[7995805,4703],"5e9e418d1a1e8dccd7207bdf85f39d21e510760135c6cc2be2f149e4b82a6f78":[8000508,30869],"5ec2c2137f45d02c8e8bf78ecf0652966850772097b41f3835a99116f3b78f57":[8031377,19479],"5f092617b9a7347acd404ca5d8e55e7289bc937814b8dcc144f15e86f579b833":[8050856,153],"5f16a74b72f630430cee47f8a08027d37b37c236a96a88ea667b274d5aaff812":[8051009,84052],"5f1a051a0292fccc46d67769af0db69e261bf199a6b6ee850f704275365e0374":[8135061,1471],"5f1c9b541e2c41fd0010eefce51efefc117e4ccd5a352d99a13b99aac0426e0a":[8136532,665],"5f297de90bb2132fd2c23d189880e879ecad0a15543b2f4b690d64f0486173f4":[8137197,152],"5f7088714d13f49b2b5e1fa022fd18a6456eb6dcadf4b6cb9151230f8255bbba":[8137349,155],"5f73992cea307e9f68c8743a03fb53fbf239e17a7494c81ec33973b471bc9d70":[8137504,9229],"5fb0ffeb1bc3ebccac5859949aebf37e27e6da910e1056265494100489a685b7":[8146733,278],"5fea07446ceb73282789afdacd67fe7fb272122a07ddb8003709d80eec220398":[8147011,808],"5ffa93adec2ca32502038e1f948f03b22189c4099936ec6b4d0a39831d2c3e1a":[8147819,4455],"5ffc124faaf33a047ed806d69782e58ec50f5bac472139fec60eee8e76034898":[8152274,182],"6001b66283a3ad378f89ca03ec007f7bf590e0447644802c843102ebd1683ad0":[8152456,808],"601c6e849ad327d69a1f0ff9ce25e55bb720ecf1a592493b73f18e51459b698c":[8153264,275],"601d3df374ee130f2b5faadc2a9dcbd8d21ec04d5f0b8e0c5c20533d1a6ff012":[8153539,182],"6025f02a1c96ebcf20672d2b0bc229f3b8f63c440a13946f3431eabb5823c8bb":[8153721,610017],"602a48948cf79800a53be2ae86949dd07f6808fc9cd905a0d4347428cdfe81f1":[8763738,182],"603fc2eacc3a1803a8e5c6216e9e2072bef30770cc6c7e15217aa4c12595d0f1":[8763920,155],"60578ca11b1f49f82e8920b232ecad85866cd992828e73e17b9c21dee5e8d952":[8764075,7633],"605b5a715d8bd33b2a34a76d53a390be4c3bc8f2dbff69eb29962a9717690b1d":[8771708,808],"606861d8a74ab37a851e6dfcf4a3bab1f824dcf8bef9e34bf9c6b27bc74a827e":[8772516,593],"606caf20a0e08fd8f28e890318616f7beab90f95d4f3a7ebcf89b24fd7b4eaf0":[8773109,669],"60703eac1bb42950b90412134040c2284cf824c7f6b058faf835f204639eb5e9":[8773778,590],"6082f8faf8471d056527c0b18f3396e9662b83a27b926af904f846768ceaf845":[8774368,841],"609166b43266d9b4aa1acc6fdce158d5823434a06c5020f181e0b6882027c55e":[8775209,729],"60a20d50a88af8c2d7b7e3c97fd2908232b4ecbac657e66057b8ffcf372d0cab":[8775938,1458],"6102c0100b6cf296ea7bc233d05e4ebf9efdc88d1f5d126a7f38675739d6aadc":[8777396,182],"6108a66a5d8409dc8c024d9fc69fff32608d44d774457acbdbda3aba864100bc":[8777578,182],"61163ea302c5a5b6ea57a03f2634bde5cbbb2a4a055e8994d8edb880af06df71":[8777760,1033],"6121a30bf21aa00b4424bb2b2badd08665f03995a57b5548afdf3ccb7810264f":[8778793,530],"6132b58875d0b959de5162b96ac1af7c2fd15990fec1ba8dd10a507fadab0b62":[8779323,595],"619895ec62acae41dd1253bea6d7cf73f56ad99bcf9163a2ba130c2806133944":[8779918,918],"61ad3c65ee18fbe32a3631935ba0b94228a8caab082f57c19774ad381155fbcc":[8780836,155],"61b7f0f1a91d3dce662480cd63d3b7a69a2227aaa2c5b299338c626e3a91778e":[8780991,3995],"61c81af035bed2e1255db7546ce0842eda9e60cf632f0e7eb870277caa837c53":[8784986,182],"61e8b6efad6476fce80ae02829837faedd4a8659d47c23e9d69d77eed1c47dbb":[8785168,904],"62057fc5dfc055ba68954cfabeb89a8b9ae17ce13738e5d468d7e64df6b442bb":[8786072,153],"620b16a7002bbf9ad5cc411b511b0bbf15cded08d2e858a4c0cc4d72541f7d55":[8786225,357],"621295411a54c711bb2002b25e35ee2504da96049cb9f1a4e375d21d96bbde31":[8786582,152],"62258119745f0a50e2e85bf0c785de15c4debb337165d751bb1069f164f72f60":[8786734,33198],"622c7fa0c52ad144e6e256ce7bd2a826424943e7381912cbb0e715990c143a66":[8819932,666],"6233f8936ac115e1b7194c2828c5ebf24b8cd6496d6bedf9e299c9a4134cfbdd":[8820598,808],"624d3f68f9560f086f941c2f3042fe21c09fa5ded391e74f5ec888c63a0237b7":[8821406,1937],"6260dd0f3f8757a6fcbd1c6049df47ff714828822079322176853e9dbc120f0e":[8823343,435],"62644eb38fbe04d0ed1b7e932d79b21c2e53c72728cc55e145aa9eabc817a82c":[8823778,729],"635a7c5dd6c78a119dbfbad595651c63609c8db0e99bdd45d29e22ded00c03ac":[8824507,4851],"6392b5e10c77607cffefd6ce9a3755c019659bf7fc66fe676936e4f44db09c24":[8829358,153],"63d1ad20cae734287f27529e5aa7631f0408be643e72cd6a7dbc6ea20d19932e":[8829511,158],"6416a62e5030635642d27ec7cd4f5c29fd9ed27f0d59fdde204b6fb0dbe64124":[8829669,808],"64290ec18310b95eba12be3848ca466c2f106b1920f73a98850174ce1a27d77b":[8830477,666],"64686bfd654c1ed3421d8bb4cbf0df32ed07a41687609136ea5ccfae3852ae7b":[8831143,808],"6496be0db3e57121c30fcf9b7e6ef0a0a8b359b0d400321f6f4f56d4517e4cd0":[8831951,182],"64b56cf2b7d7ca2adabecac52a1ee9b89a9f73308c14849cee7c2a28ee494166":[8832133,918],"64c61aab8a05129d7cfb2c92fa935690161e6ee523c5f1328fcee2e0c899cff2":[8833051,38525],"64cf47061b94758c022b0d27f6d528fc316ee9ea287ac0818545a29aebfe777f":[8871576,10554],"64dc2a19e65f0caaff5668d614612d09ca2b044e338fe382eae811b3aea2c388":[8882130,155],"650290e25b5b0bd221925ceecf54cff61fb9747ecccd26c8cb014341c88a8938":[8882285,1372],"651686af1f5c9f3aad425d1ca7f0de325e4dc362cb59d9a71dc7d75f3216972c":[8883657,590],"651d8279375483f2d4c4f3fb7d4bbe3b8b878238604a8df28a7c8614451e65d8":[8884247,1990],"653b4f1dd07b5fb3b75f63b36d9cbdc074869f11d4e6b63e3d305dae54b1e527":[8886237,152],"653c03e84a35c5b5456fac1a327f8a29ebcc4c19d96ee0f5e166aa3c73d5b7d4":[8886389,918],"657ac3e3f3363a76e675e5847f2cedab5b5415cad46388fc8e3508173ac2351e":[8887307,153],"659bee27ac76f1462f4e7e5275edac75a6975daa7d2c9e349d92c9a9351e6cbd":[8887460,29693],"65a7254890fcb0844c78f9967465fd1c93c843479a6617c192291da44994f62f":[8917153,28279],"65db758ec2a584e3212468b2e31c51e0d38e488754e07209aebc86c68817a318":[8945432,872],"65df7ffdbd3cd76c122cbe2ade93418f22fa7a944c7e1ac3fe0e55bdc66e12eb":[8946304,904],"65ee4e7c4fb2eb1af467a3198df19fe8b5ff5479cec1993721e874f60cfeb146":[8947208,20847],"660a5e0effff93e2ceba4a98481d45cfd6764e83777c890361d53c8ef91c1c3f":[8968055,153],"66205a79dee145b87125b71a266ceb068c582c34ff7f6a7b4a75197983ce28b1":[8968208,958],"66231b96a9e053f0ebff52897351802dbf3e6e3ed6d0614c9274aa12fdfa88bf":[8969166,153],"6635eac4a43af8268014538e9eaf28c2ca6fc6d23889e9f79fa0a49203538491":[8969319,922],"664271626f562daa87989461e8d10f3eff40a284b4810755bbbd7aca221ff81d":[8970241,25959],"66a523db941ff54ffc630638a4dfc3f6ed487344f60cc5397d231d5702fb4aa2":[8996200,922],"66ed0748c75e72a1b3b042f8565e4addbea98698da9a00c311f245f0980e3142":[8997122,977],"671b6290852a9a7352ed4a1492c34ec8e935b83723b04e51b7eebb624cb52f4c":[8998099,45866],"6771d238ea5e5fc5c8f741b560515bc9cdc65eebcb55f10dacac50273f555262":[9043965,81353],"677f17fe7785b47e49d6d61038d10a7ce2e777853a568e8cc9f39a7e1d45983a":[9125318,153],"67d1c111cde6722ecdc2e41d89e0436caca95519da393ab625b8198f872cedcd":[9125471,922],"67d29f3452dc33ea626392f33c0d2f405f0047a410f0da75306a93e5c354df21":[9126393,42394],"67df6d3a1c20bac424e4a0104bf02ea3dd17d728c930e641c212ce0bfd3260d1":[9168787,666],"68378dcd485c981dfab72e81c875ccb531259c1a37a97b80ebda0c5f0ca12ce2":[9169453,729],"6844a11e7ff59e2b07df44e86039349fdba2b82241a712904027c6f01fb5a215":[9170182,3327],"684fb53ae4247df02f3db61d8ad62214d3127d2c1e5ae26d4d7e3cca7afcd2a8":[9173509,6915],"68a1e1db6c568b454f359b22991e0afd2f106215d6fbc0ac4819984304ae52c8":[9180424,3773],"68a54f4c5a8e97e13acb259cb1757ccf8247bbd516eb49f208ff4247529ddfc7":[9184197,988],"68be37dcd4b57fdfbfcd0ac160bd5cf63e4066585d837d20a6b4fe3f4d380de9":[9185185,32156],"68d04bcfe6181a63cf354adcc536111c7434c2d31a95aeb00d6ec48369077f0f":[9217341,698],"6940f3bafc76c9b6cad5a5ba9a1539f68bea5a2808488d09a9b4f0167c55bf90":[9218039,808],"696f3106e3ece785a017361e35bc943b44d9d2c5c857de94c1e16491ce5b31c3":[9218847,1069],"697b0b828643d41b8ce208d441a0e11fdfe044cc743514830d3c36287b46a778":[9219916,7557],"69bf9de1d96653068bee489d514b4ce0c80219a74bb74f8c0385f79d1196fe42":[9227473,155],"6a542326f971cb64936d3c2c3eafcd8b4fcbde5324c1987c8660201be2e2513b":[9227628,4640],"6a5d9b61ffdaed3a3114f65cbf05beae1f376860b585901c664ada68ecbf392f":[9232268,4703],"6a6b6619adb81e47a798af960b6abd39cd2e15ee17715a322d35b46beb080d55":[9236971,152],"6ad1366f0e3584b6be40a3b210ed7ca6dbb0fd8c6923a89600ed1a332b2dd8c3":[9237123,590],"6adf6880cac78c89d1a92012d4d64572beb4bedc32f168e37cebdcdda3d8e988":[9237713,1937],"6adffc623010c612d8a7069a6b3f35b881e02b01086e81d10774882039ec4cf2":[9239650,1990],"6adfff136c3ab55b6c0c4c3394aff5afa2ce8d92d6609cab94b015f914efcda1":[9241640,153],"6af57a562a2a7d1e0c5f6ccb12cd258730e77ac54aa6962e54325b0107fd22af":[9241793,153],"6b3a20274889219c8639ee3d1288c40db546164620c8e2be73a0e7223c6bff28":[9241946,2491],"6b4b2bc5847de3661a5c3620e7e5879c5245bda4da75ca62479c39bbd1342e12":[9244437,182],"6b501fc1cdfbfe71c5608d119b4109202ab9688a82cb95ec19fec299a08b93d9":[9244619,904],"6b6c6627ae4d677f7564e492a3dce4c9b6a3dc2ed3dcec0dc3dfd0fda439753c":[9245523,660],"6b8be00337414bd860ff5b608eb64683e31935185d6f385253f43feb035f35d2":[9246183,4703],"6b98457c4a34f4460528fd9867a16bd87069eb66875a351fb23205533e511bbe":[9250886,2379],"6bb9db0a07e5a48cc3e503e2840b07f8415b98ef5825523f939ddb38479b2f95":[9253265,918],"6bcd56affb9c34552c2a27389cb81b291c1d9e4849b43d64ba9c36481483ba08":[9254183,182],"6bf336d39d22a8c6030aee19a8196eeed9175ad3b878994141925ad62558dffe":[9254365,18431],"6c1a472d3027472359ea63cc983f826a948f4842722a82fab531fb6531e6aefc":[9272796,590],"6c1eec3069dd2180b4efc441017aee3036dc0c3776e8f328f416c34848495a09":[9273386,1906],"6c5e24c3d70b969102ad6c52d78286c46b978b073021657e8782cb19e8c6c5ff":[9275292,808],"6c623b324e376142b4f553e8f691d5cca6cc11b787b8fdaadf3ccd75da721996":[9276100,808],"6c743bf47b6f7c61be2d365b61f7143d7d1045e5f683095cce63010c64586d56":[9276908,13916],"6c76641a8b37f5f214bb69a79f10e7e2001600f0e6b2a7c6b10341322b076738":[9290824,182],"6c8c4298914b04ebb38d049d5491992e4bc548c869b10f09009fa449b1488d47":[9291006,841],"6ca9013528a44500b780923fe8bf0897118372b61aae2c251713f28e4b32dc17":[9291847,5214],"6cb21bfc68622856056fac18b252e6ee0ffe650bda0923fb0745b26e10c18a06":[9297061,19848],"6d2edab1f41f8626463656ad92e21da6d59e6dfdc42bc4f6863574fe19ca1ac2":[9316909,1116],"6d3d5f796701f72b83a9e496cd95ab717b808eab8e67a8e0ce71ba5f1c871855":[9318025,182],"6d44d704c68324ea25ff3bcdc9bb7fe1814aab2299b55fc04a7da4f19a006f5a":[9318207,68664],"6d4798b1c65398c11513e3f38c5ce2f841b6d0e7f4a7c7274728fb106d666b4b":[9386871,666],"6d936a0e261cd28357178855ce558832bb46b36d0c3ca5fecee7424a3b0e8034":[9387537,589],"6d9476b3631e6122304c2c6a89413429beaace6d619564aeb82f589cced81421":[9388126,904],"6dab721be0eba11dbf2919eb60718ca9689e4dbbf5e9b6abd233a795144a3cb8":[9389030,25563],"6e205a05c8b2c97f536dbf79af7ff9b68e73c20b5235e6a2a4c02f29487d778b":[9414593,182],"6e23642d8357ffec6e50f5c11c2788c67c0f8e64527c77601207ac9bfd43cd8a":[9414775,275],"6e4cbba61fd21cdcd1b1e6b55c721c6aeebe399a4b21da167b505c922dfff6f3":[9415050,4752],"6e4e4c5aa3976236133ac5bd5afb5ea6038ee074a4ffc01e7dc6f7480068da76":[9419802,42734],"6e5fc7c645dc91d9cf4cc415d91749bf9eb96cf1a13b17cbffbf7d549cfb3c76":[9462536,6649],"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631":[9469185,671],"6f476c32e3963379942cb774adb729063493af424e7dd6aeb88fa7e7a5f05339":[9469856,958],"6f4c23581ee3f6b48a4ab78a30f25f9db16844644244f25b24c33dc8fa13ea01":[9470814,4752],"6f55d363e3a416eaf75132899fd014b9b08a467d3d8e5a45d2501ed52d078c19":[9475566,182],"6f59cf61c11b2590df13b604d16baad2eac373cf823dcb053228eec905b1a12b":[9475748,76572],"6f6137dbee970697f9755be183c8791e61a914e05e56088d22d5c257a942d1b7":[9552320,904],"6f9cb496dfb5bdf61d6135b05a0bb296a7e89b97e87b7b47a9cff2cd527eae4c":[9553224,182],"6fc1b58dad295d780c917c10ac0f793667e46c455349cff3d42e6855501a91bd":[9553406,16366],"6fcae7d7c9c291da8e2decb732721c5aac8c24355c6f6446add4868947d4172e":[9569772,872],"6fee8edbf82d3009af21c5baa1213cb1c7ce38ee54fdf03772e50d6bd398ec5c":[9570644,155],"6ffc6466a8595d2c095631bb4e83ee15014b405072b0d39e9b6514a0e58b15fa":[9570799,9794],"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998":[9580593,4258],"7011890f658fb28bd29684802e5e53bb5d54d54d3c5a64cc926a56fae7ef944d":[9584851,595],"705b59af56340d68ea1a4013142b18ba36d0a804fdf6aa41772f7670d5bfa188":[9585446,5085],"70b1afc80bcc662378f9d33a711fae3de4acaa72b7b03247095911bcae0d81fc":[9590531,962],"70c656393697d4192e4f4cadf2217370c3556a0ec85655605fb63deb7a400973":[9591493,666],"70f66c8e4c3cd38f3cda7cbf81fa3dfb8090b766f637732677c0ee9c246e3420":[9592159,31126],"717c6c9ca5e5a66e3259378328118cfc63e688bb38d21e1b8859fa8fd0555a3f":[9623285,182],"71828b51490969f48e682658f9b45024a0e91a6afffdb4fb0f16c4adfb1e6454":[9623467,162],"718c81a492a1f7a234bee77d7eee3f7bd324ebc5a98242d54290b1dd3c3928c8":[9623629,182],"71c162a3542afe744e62fc939b3dd97d81d0ade4004514d08b52fae4805a791a":[9623811,841],"71e3f1a828b0b9746242477a49eb317f232cff863691b0fd0d24975000586437":[9624652,153],"720802b41963076d1e095426f1329712ad875239f810b742b73ea3fb2e74b750":[9624805,182],"72264f7c581abc0e39f643be5a26d2e6e3921853b6276836e77df90b359e8fb2":[9624987,1155],"7230e35b63a6e73c43fbe209beb2ce01f229aed222f844a91d5986a90c6da014":[9626142,729],"723676083558c83d83926d043c8edd16bb2d48cfe3722fde330cf0351870b1a5":[9626871,669],"7267938248a8ebef12055a569e5a2e2aee73422c9eef4f21fe487f509eb47cba":[9627540,841],"726a20b8361501f4c26633cd92835ffb2e5f1acd584c0af76895d29bf620a7b1":[9628381,31137],"72e3b5e1b55961905a023c2d37321813d21891c19a8fe681208137acc810c710":[9659518,103069],"72fa7c252c24654c4b70b21f44048c170b79b60a2c535fda6b57d6aeb0b6ce0f":[9762587,12504],"7323d2fe542079a93b6ea9f1224b3c2b9f275f6723481fa361f90bb094b1d7ea":[9775091,808],"7364f327220b018b78ef285cd6b29821232634319f7ec524710a9bae3d0f2815":[9775899,277],"73a6d090ea83ac2eafc5df7c7dbbb3b0c04de2f11e5e1b54c8a478a6d12b0a52":[9776176,23162],"73c7b0d30b3283cf87c1dcebfca770a61d530918bfc1327077b62fd25e25a2c7":[9799338,10286],"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264":[9809624,596],"73e1fb6a0dd8c2f46641755ccb23626e2a98588527aadf13c5d87df0caaac4cb":[9810220,182],"73f3b212b484c10b408360bb9a77fe4b96dc4c88ea5051db77c94f4bc25de05f":[9810402,962],"74587c825092170c843a7baac4e6debaad7cd117b9b0117ba732b3c97df8a151":[9811364,1116],"74a15a982db3032ced5807cf9fff902af82cd3bc14c35efabba79f79be8fea64":[9812480,918],"74a4b92dc8563949900bff882300afe0a481df48da7ad1907270d6447ebde728":[9813398,244],"74b714673933773aead0d1f648439136d48ccd363f15be10afc5bef8c50e3d15":[9813642,152],"74ca02512262b14fdaf325e5c7a6ee935d7a05f30a49c2243c48194eef9ab7a3":[9813794,808],"74d3954960c65f0ec53a7a51e149ffc77d3ccd25b342dc633380c116c9c5dbd9":[9814602,593],"74f67c7538797091e6d19bd6f87099d0b414743cc4cc151db667619ff4a48d03":[9815195,669],"751db63a5de2b6fa48a0ae017615e77f92c52f4473a15abc234beaddb7968e5e":[9815864,962],"75382ce3f665c0d4ee845b428d96a5eb0d1916b8255b74a9a54d9cedd53e67f4":[9816826,16208],"75c4d1e31fe764e6151a83efcfb61be507c1c1835b2de95f862b394c721ea805":[9833034,275],"75f3d1bbebf021252cad42fc4027fce6cde4a59bae6b77b8b6a0f3f00277d0cd":[9833309,152],"761077c4ddd0bd5bb9464321bcaa81b7914e3fb3c6d4f60df9948c8968de6300":[9833461,159],"76182b7a2b30b87ec08d8c926655418e212e19ca0b5931bc8379e692be991ad5":[9833620,918],"7685bd530be51ae184f7778e312213d6858ad20b47dbe8f0ca31cf12a76fe3a3":[9834538,4866],"76b3525c65c77bfd6f14d1c4754fcbe07fbe7c3b4acd8e9b176dc8a9f9676fe6":[9839404,666],"76d4786247eda40df033ca949531803a28d3fde9ab0a8b10f2a392b0943a4578":[9840070,275],"76f91323327cf2f0a66946473c1176b3988c304d57f2c6c67626991dd449cdd1":[9840345,665],"77458eacdb5880172bf030655dde4736e0a1a87a063ea2042a920a49f23b1486":[9841010,1283],"77565d07fb22fc466e60fea5b82afe55c1069ef9418926bde659b29270d68a10":[9842293,1937],"77b321d0dd911de45787b1c235e91ee661d8fc9ebe0b4058e3d47da9633ce406":[9844230,56117],"77d34b8ff3a0bb5ad8af03bb496eb11c282abf7644ae1cb2de26da409182b8d8":[9900347,530],"77d618c8c50adb842ef5341b68fed618dbdf80a941af8d4cb512c13f6ca5c861":[9900877,1458],"77eb315db67aaeaf395373e2441d4f3e3145ef6be9a31dfbea1786d9600873f5":[9902335,14291],"7818022ee9e7fa7e2e1e65a48542b4d2c45c65133af81874691616862148704c":[9916626,155],"782340da0a7d9facec2d0d42bc2fc55bab1d1cbddc3d888a91eba6f9ef9bc7a1":[9916781,666],"7829c0fa25e6f25e20d616b6a7e27014e2db27a7362e56f790a9b98fa953a2f4":[9917447,25431],"798085b32f0ab8768c8501da59a1fa68a4238682f2b20fc42b22e56810edcb7c":[9942878,1033],"798cdb510ad310c837372681f20606da6374c6ac6b7295ebb3750da7a29adddf":[9943911,182],"79b5813169ed5adeb10cc35fc52c22e390bdef3e22fb2a3dd0b3a300ec844dc4":[9944093,590],"79e9a04ffb5b7ee4fc6b60fe73f5e8db31b0bf541a06a0ba30b85e41fc6dc96e":[9944683,2377],"7a087b2d7673515a1187be0a27048a44753403ad0eaae3c729b2eb033803ae7d":[9947060,5128],"7a2a946320055ad4f7d38c78ab28b76b7df4bd2e20faac4b97bd2847b91ccd42":[9952188,594],"7a316ac4a039302ffc0c5389f62f25c44be80e3e389047fda6d4e1cd17278eca":[9952782,47107],"7aa7e0b4e8cc02142a8aa14ca243a5f2058d72de2f65d7ddb7c59e3d201b692a":[9999889,666],"7aade65c295fd7c6a375f7ec73b6d0714c4c4357d60a39932e9d0c1d3ae05c8d":[10000555,182],"7ad40738c7b69c35a896d1b946f4b782095e8a4b9c7c551124a798d34783fd4f":[10000737,182],"7ad4dba399677a4a801444f26c938bc4594731235bcccd9a979e1728b21f1822":[10000919,595],"7af536757cf900283681a64345fcb85d37cff8cdfcaf67730fd3018c833d4bf7":[10001514,155],"7b0479db5ce647aba9cde47f5b90528bd604603d4f45944be354903b4ce0f641":[10001669,153],"7b3286fa83b5448660bb53d46ac4c7b3677688ac5fb3be569d1ecdd31223dbb0":[10001822,4455],"7b41f77a9bcaf705eb5b427904a4a1241698d878c1751e97e606b77f95fcacb6":[10006277,4752],"7b54deca43ff224cc112166abd958048b53b2762431c41a9fdc1044144d9c96a":[10011029,3734],"7b7289a3f229df873c393172b4262385ab095200cce5fb9665654bedecbfa838":[10014763,698],"7bb567f43aa5e1bae696ab6c876428642972c38a84f01607d6c865d4ea8add0a":[10015461,1259],"7bc64170f34a7e544a0492e00af3a957eca837cd1ea6b40d49c4edda9cef5021":[10016720,970],"7bfc8437757380e5783a1381f2b78ff6a753c5db50b8afdb02095deca4527799":[10017690,590],"7c0f8d97523601ef6320fee4befb63165c423413150ad0501e4fbfbe989cf093":[10018280,667],"7c18bc5ca3c1328abb843b694d26436192f235ec2f71387ebb0d44f81a3b3f58":[10018947,729],"7c1c8d5f94018fa641204b7583e5d2eabf1ed6568d4848ac3f7d632db7abde4f":[10019676,872],"7c26384c4adc5b5c857d0811275db450218c8f05177ae35a898ec59a5a5a5e03":[10020548,22488],"7c568214148bdacf0c078e2420e1c8355c24100c703f3490f65ca9bcf1f64535":[10043036,590],"7c7e8183b9fbf594f3d61a4ca43f5154c404f912f32b8789d32c697ae883859b":[10043626,593],"7c8a02c0cb7a845df0259b0c8cf930737f4ba70bf3bd0d01b2c03b4702e6bad1":[10044219,2644],"7c8f402a0ba50871aacb4a33b939f1b56ef4f0d0ca80f305eff6b62e15140d86":[10046863,7467],"7c91a44260d7ec6f97d7ab86c6df36209f4c85d361588974f0d6ee7afbb1ee99":[10054330,152],"7cc8aae4f60d40a47c0d61025d850803a6367f406e77d336fed1494ec4cc66f7":[10054482,10451],"7cd4b1ed38095f7a1f90c73725887799cb7dd694216ddad461640d2ffae0894a":[10064933,12499],"7cd5a9929b27c817096d66c2bc06bf86dbe2aac8f9f5c8b0d5b60f2784fc8108":[10077432,14291],"7ce7a06750d59a5b6d453d4347cde232d6ae0759d03588d22c4f91cc66e68a75":[10091723,962],"7ceb7e024b769e255bc6f1a9ada45327087cab66a296c78db60c95593a13fc95":[10092685,49713],"7cf9c419b2c4f7c7b17e2c1e4362861de714f4ffe54727c1a0fcb749b2e6975b":[10142398,14700],"7d1bba27fd84f0edae130f1902b00abab524e3a67d0428b1a2fe7a2c01af5273":[10157098,53348],"7d34cff492eb5680631c7e2814e0ce99d26211d0fb48fa4f7e679c530aecd7b1":[10210446,162],"7d7a798cdc969969bfc1a0db6eb87e02bcf2f96de636da10f24e4dc97700e2bb":[10210608,182],"7d8366f090457ae3d2608097213a8f56ed50bb5141644333c0192d93202dbd0b":[10210790,155],"7d83755f6ce960c2841cdb724eb546edf3bb4c2029e733c9bb4f1fb97979f2c4":[10210945,16180],"7d93a802662e32fcf39c96ae1a512d7c159acb157f7cd565c36a9c03f030533b":[10227125,12670],"7db5dde61083a0f2604dd4146d097c08eea778c6ec755a14b9e81d56297fd963":[10239795,32597],"7dbaa07d186e7327cee5e1ecd80892d64b8404f041c6c2d5de6dd26c70b58f92":[10272392,98294],"7dbbaba46fdbfb1a9803d4b627e73727baaa8c487dcf773847e823c0d0c65c4b":[10370686,1990],"7dd5a527d42227e42aa5a6453ba0d2772134ef33870220d7825c08d0fceeb01a":[10372676,904],"7e1b36be43ead83db29e25bc1f71bdb506bed15168d155187a319aae27a3538c":[10373580,8517],"7e3b06cf4d98fb400fec3b556244dfb5ab962b8e2e4f6d1b6c7696dfc0f68e95":[10382097,729],"7e4bc429059e07577a9482c20d837092c1e6dd30afd36acafcc5ddcc3b5e844e":[10382826,27246],"7e4ffa4c9dbd8fd1d67596d20bf09dddb21963b405313f57ae2a7162263e26f2":[10410072,4866],"7e7629d63c65e993d9fe5ed8f2a1e7af247e812144a5ea00d4b316b48edce7f3":[10414938,182],"7e8961fdf02dfffeaaa9e4ca5d864936f16e35bea7354d2f0a95a3b4194b3fc2":[10415120,858],"7f1eabaa9393b09448bdbe36d5344e24ab8ad290ef6b197447d81880327a5097":[10415978,589],"7f226786a1ee41ed714d62cc1cdba29826ab5b14709754135c2d3e341ba18583":[10416567,155],"7f2a30fc6032b866f25c942d94553c9a0c3c1699104b0a3bc02a91782e1d4763":[10416722,650],"7f362809b6d0f959bc8479adebdde987a718107d3e70636e879e44d0d4c59bb5":[10417372,593],"7f7e00683ff02b159e26ddae0c5a328bc874f56440e769619915f14a5924a243":[10417965,54199],"7f8fe1b370188a60f00e524cc5c8059ca17a347b97915097286fff2c0ec7e290":[10472164,28001],"7f9e31d9f6b963263ec5d0570ae2b5a166ab1fdb71ae5fe9910a015ba36d2a6c":[10500165,922],"7fc4e9e264497d30024b29d7e961fe5f9e61c5503b957e94ad69c8bbb43aef22":[10501087,808],"7ff7966c981cea6ab644f6693067d84a31f79cea3c74165b763ac75a5d1fa125":[10501895,153],"804a2e8c904267bfc26a9aa02b4f8fefe358c7f0b7e72fff06a2175eae58d613":[10502048,4866],"80a5ca3cb250b5aefac03a5ef11becc14107d0f2d40459b0bc408394a44b6df0":[10506914,182],"80a976ef85f0ec1ddea407f891d0d027cf2423bb67b1d1d1a96282b803ab47a6":[10507096,182],"80a9dcd58f6661718787329caf61ecb59da0ff7dc99d6843cd8c53f67e5c3539":[10507278,4944],"80de7af4801195c06c66e89a828f74f14235714ed0c201592f1f498c0be72fa4":[10512222,962],"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271":[10513184,613],"811632fc99d3866ea5023edd4e3250e7989a769e987cd0ebc1a6ce8cc3800669":[10513797,153],"8143fb1d2c2022405f5c7e77e08ea12da13b2379521f98fd50131e71c184219e":[10513950,19932],"816c24ef0a2731b58914c69769bb52b312d0e0af8e1eeb189b4f213963c656ee":[10533882,3675],"81ad50bbece9f59b5017fd32dec882bdb591f38fab4f4651d59ff739e144aa1a":[10537557,23638],"81ae8209c3650fbf3b2e48b344c53e389ae845e2a0d4eb02e5e4bd46a2d22927":[10561195,16407],"81b727d0fdbdcbf9412b7798b650076b811080051e9b4598ce3677811b30443a":[10577602,589],"81cec1743279e9e2ae0ba8ae558ed533add7a90726fd1445b2f08c87ba8f6e6e":[10578191,31927],"82230860fc74fcb5883ddb1ec611853b4fcf15c6e885a9bc5f92072460de3e40":[10610118,665],"824c95b7229ac2ad5b1cc518b296443883fb56a110c86c2a2e67923c0a19ea07":[10610783,9826],"8260e1c905d4a8aeebad31997c947c730bc2e0ee5aca223106e8c9f624251ddd":[10620609,24662],"8297ffde0929fd0718524194af9d541a222a05abfcd455c3db5a0d7ed33c3318":[10645271,1069],"82acfbd2af961a35f71f855f259486c4b37d79987b0fcf83ed5470ec43fcfc46":[10646340,14875],"82ecc7c3b03532b8e56b1d3b77b5ced7286b28d7df5ae739abe7b8516a32cceb":[10661215,153],"830001785de125922d57970caf0af5bcf679e6bdc24f008fd39f80867db57535":[10661368,670],"830cb1dea8bac30fabe6ccd94440f6801106adc77b473dd96a0c10c464024b7c":[10662038,15624],"83603a421d55207846c9b25009242c586258bfe1cd62e7ed7323c05a31bb9c3e":[10677662,904],"83836a41a8a134e584f026364293adc11280fb67ca24b8ad503c8bb6d1bddc3e":[10678566,872],"83e7eb0b234bbffd0573d91bcf92e594e91a0a15b536e6d5c915a965fe0d4309":[10679438,596],"841bd6ad2147ff29f49633ceddc832a76537590daecb7b9d081695f67411d753":[10680034,182],"84428602c5cbe86749e6c4629d39f0b5aba863df62c3995134411ae5e016d853":[10680216,593],"844768193785656d809128aade8b3718631c2fa633fe38b1ef555f7eeb2c919b":[10680809,841],"8476e5211cc1a7464dab8106d6617bc834a6feef4a368910ed8d8947d5ee5447":[10681650,922],"84896d8b74c52c4a66c9b611c2aed6450c9abb79faddcb140433fd2c3ba1ab21":[10682572,590],"848b1e09fb14a041e5d7a3032d00b422c59c748cbc9582f6ff16a6efe95537c4":[10683162,545],"84919e32e6e88e6734ae75dbee62ceac10287370142e4a6245e3cd831a454f45":[10683707,106068],"84d84837ba95cfad52fe647f42369fe836c16d20ed24afe909f74ec298b6c82c":[10789775,1283],"84ef791effdcc0caa4ec5007e81a066e64134ac3f9ccf602e80417fcadfb48dc":[10791058,1458],"84f6b94966d68b7682f665cddbe9d8e2843c00d8040563c2f5e2c2e7882d4778":[10792516,106286],"850028e21105eeaecf405311977bb901721d8285ea8cd64de68cd5988981f7b6":[10898802,21323],"85123faf2048e67027d34bf0103f7845d09d03a86f5673f3f08279223a89c783":[10920125,277],"8517233f579ca01d75c8bac2057c00cb856294ba56afdf26f56b6a8423640ac5":[10920402,277],"8517b3e9703a1d334fa7abda775c92fb82cc21b953ae890ba52cc02ad12d3c66":[10920679,155],"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262":[10920834,10356],"852ba5064cf1a143f95368cde399c49891bd7a420454fc1fabac2556a9960eff":[10931190,1506],"854dc3f816957d3a40ee1d16be7b44fb2a161a33e4919cccb846046979cdcca5":[10932696,45910],"85762dcc9d35a28a66d8b5dedb94b8d9897dcbffb0c7d9a65159f44101942b8a":[10978606,27322],"85a337713f60111fece6cdda3168eed43966c95adb1458d324acba9336110b24":[11005928,155],"85a80192d0fc2c8aeef56a7699f30dbdf2fbb9f6dd6910396bbf4f0192d1c9a0":[11006083,661],"85ae92bdd7c73f83c234c94cf4cbecabb78e5d915ac63d6c3c54fa3c42ab8baf":[11006744,152],"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e":[11006896,5605],"85e15abb29288f75bb50f1bd7108032016714dab0c7f5c47618da58412ddc4c4":[11012501,9828],"85eab36f412936d247f59dc688116dd31246d27c0b86a7b3df52829d27e2d90a":[11022329,614091],"860d72453c7c06ddc72fc7b94a2e5550f8c1a2487e16b801660b3934678a964c":[11636420,4866],"8634eb7965cf1af14dfe1b6636267db0c9ecceede5a774ea744290cfc7fc8d57":[11641286,20491],"86365b7132c4baa27aa65731c758b4e6c8611bf9d5d933b1b333af3ca8353124":[11661777,1069],"864391598ee91f825cfe21eb337df2a32d03ad7b9146288d6d14bc13547a2e0e":[11662846,9978],"867d5dbb9cda97e64ac015724d182c88499cda8c9d01a4387f22a3c70a3354b2":[11672824,472],"867ec849989971ad3b587def426e8e53cbf2bc811540dd0424581ad7d02d17aa":[11673296,73470],"868f76d2362b5f6fb8bff7869364c296f049682fd49966a8a0a1631fa77e7eb2":[11746766,590],"8693e822f44a2d7e65cb5fd1e13babef8d7e44492a91b8ff979f8899a6ffad43":[11747356,922],"8694f250fff819568098ec727ed32c5bdec06668409e88d8f7bd9c4d2be2d919":[11748278,1857],"86ce9ff7f8600deecaa439037b1216adc22343d0d71a6db28bc9f7f563f8f7f5":[11750135,922],"86cf27e1f5896a81f28f1c71ec9658129a767c388c0a01f8a30bbd5196c83ad8":[11751057,182],"86e052278b694f3b9f1c9181b34cc36311bf633c7a8f0a48ade6642c14330e3b":[11751239,1458],"86f4be868470b027912b9e0320d791dc18d5d2a598607604112db0932ca06366":[11752697,1069],"86fabd45da78a46bbfa7e92465788159360c0abaa4402f10ca433779b419ee0d":[11753766,12496],"8714a562f48aaae01b0823b7a72777cacdcc73b790345d99da60f74d2df1d05b":[11766262,23403],"873378d3138b0baad075c5f7dbd2199724a15cbefb539ea81b8c2985a4348ff8":[11789665,23644],"87384fb9465ce6341cb179768f2b35a68b7a4f7ab1ff58a7e5cc220b82a22793":[11813309,593],"87494fd4d2a072cc5e3c104f999ede297ae356811a0892c93da6159099aca97a":[11813902,152],"87551eabcbe13be60e49b8f02f2f9fcd5979541174591103a9e2d65894ab67b3":[11814054,904],"876dd3322766a222bc3c0ad9906a8de9b4d33d3dc94005254ef267a820d88c4d":[11814958,808],"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812":[11815766,6412],"879f0430a2ee9ed4bf1d2c55ae4110f1d318fa24680188aa68bb7aa85c787092":[11822178,3051],"87a81273601f87562d1d76e3cabb77aae24f69d0ce565b9026b1b57802971049":[11825229,182],"87b30324db24b63bbec64509b4d03ead8cd8c4b512decc5dc9cd8298615ff274":[11825411,6757],"87dd9ca6b265f5f60f9a6a6a1f6e24134423830ba25de102f8ea3847df5a61a8":[11832168,155],"88239a17d6925822a5e3d231bfc4ede33c1a014be151105af72beaadd6963188":[11832323,841],"882d7366ef80efa4072aa4deadfc97b09d908cffc113fe0f0893940eb7d9f7cb":[11833164,98008],"885c63a11e5bf9dfdcf7b1130d14b1e863a399c613b09c093735ef51dfc4b132":[11931172,4703],"886950009a2a1948725b36b18291c466a4f1b0328bc99382d43779273a96863a":[11935875,1033],"88a51481760d8a0f4d417b213aaf78603811e36b46733ab15d2e2c0d93aef194":[11936908,26074],"88dbf24e17ee341f45a146a0683053d9706ef241609da09e9a4b13da7b5a27ed":[11962982,935],"88f0f1f699766f992a694cc1e3c3fd039f513433940c6fe084e06ed7f800efea":[11963917,182],"890ff6b355337e5a3bedc9b35ebf8f02ba051ed71d9bc0de9a01951efa5888eb":[11964099,275],"89357fb686e206c4b0d3ea4d2a898a8b97d07c1755ea3e585bf57a6c6259f918":[11964374,872],"8941de4db9e015428c1891c039d0a5dc7fba8b8e7c0139e935a1a78719b01d59":[11965246,922],"895b8ef3acbd7dec3f6e70bc5fc6336b48dff4fbb8de6765f778769521b5e2a8":[11966168,182],"89851fc63b0ccf4e6195180c55003c1f7167429591abfed2419b8efd6f314968":[11966350,15795],"89a8ede09b8ffa8f381102b76577f42e29f18a6dc4d3c8a5f41717dda3a24114":[11982145,7909],"89ce0ed1b8cafa32406d8e276252a3f566f1c244ece71df861bc7e01a0ec6412":[11990054,61434],"89dabac5675823ec0685f49e4693685e44addde0e4a1a33949b87812009c3902":[12051488,278],"8a214291d385d0008c8c11387def1d4c226bfb970b090415dc921466ff3e21ca":[12051766,155],"8a470e936db4658560be52c82f247c8c3b927437f47b3b0c0578af132368ac26":[12051921,872],"8a531c225444ee9eacf6b1e281cc90d27656ac4fb9e1b4298aaf29dee579df6e":[12052793,24827],"8a60ef3baadc7921283e0776df0b32d14d509fd15a1351d7fb95eea287ff7956":[12077620,4866],"8a81e3a82a81613db48a5edd4c57e8a42fbaf33694a373e3daec2c01b299d12f":[12082486,944],"8a8bb9882f17707d200e852a7f3b97a6d5cf64c77afb651fac4bfdb7694fed8e":[12083430,4944],"8a994f45374c06653ee66017afb1d7e04c464ca7d56291a44606c73a7ef0ecf6":[12088374,9946],"8ab9ede450981df17573f2b1300982c33a1cdaa569ba6019ba2b2162412185b3":[12098320,63133],"8b14d99c5a5821ae24cc95869c32d920f5e3de4cd7cb0ba43498cbfaa93130e8":[12161453,1877],"8b202e4063ebc7862d8d4ab894cd4d2a4874961b5e33a6b7f692615f373f1b33":[12163330,590],"8b31e47aad33789775fb32ae56868e37db7a9dd174e6df3a0262781a7ed2f6ae":[12163920,666],"8b531a893d5d481d843167ae47ca2c5ee6e25a3e12eea9d280d15b72ce6721fc":[12164586,152],"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad":[12164738,670],"8b794d306e3fd039b0db3349e3b52501b680e2b1433ed3d3407cbf57721a0dbe":[12165408,808],"8b827a70205c5aadce37f553b10f080fe5c618cf23c3f7954859011d329dbe7c":[12166216,1840],"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344":[12168056,9077],"8bd8a1458e24576589aa8b3079e2bab2bba5d65f751345d01d89660744ded514":[12177133,1033],"8bea9f8a3c2cf920438a9c1f02a7a348ca68ae4d1e70c5012f6d13b10ca8bbd7":[12178166,153],"8c6419013e3541562c694dc1ef2e89b9f6e2f6063f03b88628d69add60a5b8a6":[12178319,922],"8c76aa2f987c521bc0946e50fed9e3be0aa82552f40164f53340d4e22c7d034f":[12179241,918],"8c88c9d9441329da0cfb5a19a3b073bc7ca96e5a25a2b04f1a9625a6042e68b7":[12180159,13011],"8c8a4a7fddaf37822cf48c2dca7f8f4f11279f85c144fc105e996bc7dfd0cd48":[12193170,922],"8ca414d70d8cd5e289af3895f429af3db4e8d6d35333c741e2c8e6733adfa1da":[12194092,808],"8cc9fdf61c244e993fcf85dc32e4cef5a0147627be93b11e67c48d94d439b232":[12194900,1069],"8ce95d35e19033994f098b4b2521488dfbdbe4b9e886d75fa470e5304935ebe9":[12195969,14291],"8cfc31729380ff097f86a9e7082eb0da3291b87bb70953919a582915d3cce947":[12210260,182],"8dab0fd7eb464c0286fc7a93f3bfd5cf16bf5423d394b74d89e48f512d9cd1f9":[12210442,13300],"8daeeda83551a0dfc2bef4abcda82b04efbfc2033736740fbfabfa715f116042":[12223742,841],"8de82553f5e9c70c2794f09aff4bc7718200713bbb71121e3b5780bdf22b762d":[12224583,663],"8df3ac8e0604ab839c829a8f25bbe65b20d19aa7ec8b60a7d143d8429738c2a4":[12225246,808],"8df850827bf5528280c4fdf4dc3249e018af6b89625b95921d22dc35daf96f82":[12226054,661],"8e0041629b58cb5f197dfc8bda886af7ba35a32f3cd7dd91396b139607cbf22b":[12226715,152],"8e42f30f56767a144a2092fbc091a02fd2e03123da93ff3d5bc50586331d0b32":[12226867,3895],"8e473c44da5b454765bf8f2d1bce38a1d96283090f6ecb57eecbf8b701e381ab":[12230762,50304],"8e4c1ffa253e4e36400d4a6c2338b1fda5ac7f6cce26d39fbeef91fc007e9124":[12281066,530],"8e90fde170fdfbdb66e60ba86860b0542b2f64e24aa2d3a2c3c68c00d416778a":[12281596,918],"8edd24e2f34190335f925f3136dd6649b5d69f3030ae93914cb1f448dffce74f":[12282514,182],"8edf96568badc4c32de793d955606d35a09830127405021e7529b644fc274de7":[12282696,1010],"8ee92d005a0b7518f483e0a1545dbadbccf9d7031f36ad9b0ad91341d6afebee":[12283706,403],"8ef0d60befb8a6c444e8a5e0e22a1691167422a114f4e5bbcb088c6f59f73860":[12284109,671],"8f423269902053db9df7b3fa0f957361cc9b9b62fae489afbb79fd8044cd9fd9":[12284780,589],"8f6de835f1153df3afc1fc865df71466276c7183b640df8d42fcd5760abd1e76":[12285369,4703],"8f9c70df379b3ab58f979c191dc1ae5fb0e5b2199c84757b61c254bb05241ebd":[12290072,27268],"8fad8d332eb735491899cc14b9b5cd9196dd4d8fd1ed6c24376d49682161d21d":[12317340,808],"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a":[12318148,5862],"8fc2e97e808fcac95a2f5b30ad6a354a62928876610eaba62aa9c33f907ce604":[12324010,66957],"8fcd8d08242f4aa793463f5525edcd55cd1f3429dbb5b94621b0731fc0cf6a6c":[12390967,15068],"8fe322010f750ec76a70bf5649a21ad5dc2ddf4d5b3798deeee92ec2174829dd":[12406035,669],"8fe3ea8a3dd647910505e230d5c261594b65926d6521a5c7ad5ddaf6bc752fbc":[12406704,29930],"8fe81532366f2a1068b4c5ef12dc77e40f13f19fbe54213e87bbb3c5dd663d54":[12436634,152],"8fe8bbde4a104e5cc7374083d3edd45f24af6af193d6c3204c0baf2ebc716d2e":[12436786,472],"8febd0b5b5494fd230183698d8b54851cad9cd4b608607440d6f3c63a8ef9c85":[12437258,1463],"900527545a28a0e216c8a1296a1c3c0d100c4da8d2d62c947f30ea2741566459":[12438721,159],"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68":[12438880,595],"9093a24f5cccf37f4f2c54a38808cece50e46b033d5c47ec6bb673aa7bb667f6":[12439475,1783],"90c2a12221a5d13b21346e2818bea79b2a17f3efc39ecdf23c483a6fd73e4cd5":[12441258,25490],"90fb58d8a43b9b44499368ca77d4becb58619ca4fa939b91fdf244499911b867":[12466748,153],"90fda17ed1ecacdd0eb3e1071f96f3e6554f8428099e3e46215c70b7b5b2c6ee":[12466901,841],"915af99a7d73f21daf12b6df3aee28d7765dc6795625058063d00d70ef37114d":[12467742,1033],"9180d3558d4e4f214b67bfcf1d286444ede42028ae79323de5182144099312b1":[12468775,153],"918d286bd31376475d30c41b6ebd2927639cb3c096a08987c6fccbafc45a8467":[12468928,841],"919c26120be167a2a8575bf26f1533438a6b28f35b0757677bb6236b0df7c1f2":[12469769,918],"922a54930e3affda2ce0caa8792c72701e96bf24f401241581977a3aff62d9a0":[12470687,159],"924779fde841601c0dea96a44a01657c360a1010b3e6bcf847f5af378ec56bd4":[12470846,872],"92763225271bd8b28a9e0138e215ab0a1135b310467b9f5d77ef542150b87ce0":[12471718,182],"9299d41dbfe68315e529bb35b5979a2b48f3b5b911d117b684a05cecc8010e4d":[12471900,153],"92d354f0a491ff544847ab42387fe54aa459f84daf67ae1b4ae57e4218aa7b7c":[12472053,922],"92d59dac95564d35163ea10c3b10bf3e769adbc50ad7bc5d5574b6f562d2cbd4":[12472975,1937],"931914762aa99348f186b8cea5af01d5cf1f22a4e9240b112ab2efc3c122a9f3":[12474912,153],"9321a742b82fb57726c27849b15e2d1865e3521dd95838968055836eca5ac06f":[12475065,34957],"936d34b95038d04261f146c3bdaeeb2ed43d465b2b48158bab16b42ccd240d8c":[12510022,904],"937ba4b41de7564b24b82f0a73c4682155a931e651b629000414a8eb912c84cd":[12510926,698],"93bbadd9800acdd4b01ec77020982562dce3e81fc0250ce794d78654c94a1238":[12511624,666],"93deec77e97bdb6dd1c752388827a4e8df0be09ea04ef70c848188299a91a284":[12512290,918],"93f11780827b58057b742550c206f180d5fa1659b216e90b8cfded514400be21":[12513208,62486],"94697ec1fb16dd5f899f438ef2b257f8b6c5cb611e93acf940a8f2fe94a3713f":[12575694,593],"948b44256a86924b4ce83e4186eab09c3a76f43399b8f1cd3833e408794b2f25":[12576287,4569],"948ef7cd994335320ebef7a5d3ba1380ecdbdcf64b79ac90a8d451bc3aeab382":[12580856,60870],"948ef8e6e6fc7941bf52777ee3f417e54e62b0fc6e5a2653edbb92e487bf921d":[12641726,841],"949e0f0bfd6d49113bff3ee9fe554c97548767a31ac1ec20acd4efa3b3051c87":[12642567,988],"94a48069d0b3d0b8deea53f54a9826ba0c934c231d8092f57a51e2618f0b1991":[12643555,12684],"94c66c78dbd5b7ea370055521330ad92d3a4dd6c1a6a61ebace3e14dd3b1a9e6":[12656239,1307],"94d677eadb125252e6496f82e1fbcdccf4c5cd4ef47e4f4fc9e4123bcae971e5":[12657546,153],"9514ab311dfbe2de23886f033241f2176b748a360358d717cf07341bb9761fbc":[12657699,590],"9524b0a6803ac5b89d554b2b315ab4bad50bdcfa69866332ee5f642a62749fe0":[12658289,582],"953a5312661e1d886229af4e8b9c4d1ba4f999c0f68dab2e97a4ad590c394c5f":[12658871,26003],"954f7aee41d730dd4add46db96ea9eb6626abe3737367ce24f33a08bcb1c847a":[12684874,661],"9571159c722eefc5942386364dc0af248621054973e5f74d0f077255883d66c9":[12685535,12485],"959646479f5bb9ebecae46d55409a347989044ca14d09fa8a58ec33c5261cd9d":[12698020,5111],"95ac3a3eb2c35b5fe7f2290bcc8938ac638c4bb448094a5bf329bcb6775c7b2b":[12703131,275],"95f8e490106a8adb4acd16061d24ba95161340b852e5e7c19c1e31dd79c517df":[12703406,1033],"96176977e3fed384189ea64792ee358a188d1a69c04d71ab7b9b39c250515acc":[12704439,7419],"9618d11c1310d5a64c174ae986ddbbf5bb154dec9531d2603b01a5adc012af36":[12711858,15243],"964288b7e42b93abdf400ed613288e91abdd7743b80a98304f1069e8a62adc6a":[12727101,17665],"965abf626c9e73aa1b51b9d2a13800b261e8148d165b18e31893da8ba4853f63":[12744766,155],"9691d156af5563769cd7f60b8dfc22f0b2cafddc9dbc63b1a2147d8333caad4d":[12744921,82830],"96c5a0160ef7cd5bbc63f60aee0e1584e4ade7104cc98dfc1a134ff19b7411fc":[12827751,5124],"9730e59dd2be0d358121d43d87b81e6125a07d020279eca43740b48ddd0c01bd":[12832875,729],"97a8e7c78658da2307e10d114106e14ca9258fbfa5db73f980a627e18ef27bd7":[12833604,4703],"9853f46abd1fcdca3b3eaad6a5970de328a6dd30d982591cdfe46507193679c8":[12838307,155],"985ad0289abe71d78be6986f66e458dcbb06a8c9d12b2f2e7034223351b7a862":[12838462,812],"986a298b6293326971aff7e849b8b51340ebe3941d1b79008a16b883883ecf40":[12839274,590],"987051556b15f00bb36dc30735ca20a94b4e40eb0f56dfc387daf4379da7a7f7":[12839864,14457],"987584cdd70b8564932600e72503f389a6d9a087f425a596c53d19bc8dd28f0f":[12854321,808],"9884824a5dfc9c8cf97a055d9293424ae7e580a6a6ddbacb45a9f1f91f400aea":[12855129,743],"98b42ae47d4f2e1a604af5e2dd574838431f481106ddf2ffa3ca141314664325":[12855872,182],"98d0b7b615fef2222e2e849f057800a50039f5760044440940f6537fa141d791":[12856054,5700],"98d0fa0d5ad367b5987e6034c4a5aa988959fee475592cc49bdf39038d1b0194":[12861754,841],"993897502d1e7b84ca9914c016c743f7d163c68b2abe026c953d21736dbdf21c":[12862595,182],"994601f68461022dd7cdf9e181bb4468d9ee9e3d365b020d1f07c407c2c48e10":[12862777,1307],"99654967a9c0c27da23b08f49759c35b1b8ab1bb1d220d4c144a76229c1a4487":[12864084,582],"9965ec5aa4c7a73cb0378cc029d663771c723552b101181146344936c58cf6d0":[12864666,82605],"99ba59b82cace2c48a2ba31b8f13d653858d72726e718e3c342ecb1a39b50873":[12947271,18868],"99c12346f1cd6d71b7cd26fa9473d05fd9aff05f14a89da0648d75551de9f6e9":[12966139,590],"9a0874ef2db46a62ef0c1dcfab628e25866f100a97fa8ada6850a90ce771335c":[12966729,182],"9a0caff48b5cf4bc11b1b489b1e9ee8b2c1e98bb3ee5a3bc45fb240d9bf6d7f4":[12966911,582],"9a16a6f0520451376f7c5ef7dc56164853cdd86b54780e14de506e0a2d0be678":[12967493,182],"9a941aafe5dcd0afb546da021ad7f6ea2583dbf4398e88cd73799b5c124865e7":[12967675,589],"9ae420e34160e7c2a6c2ba092aaa9c9ff1df622ece595849d95bf5c5eb7cbe81":[12968264,24890],"9af214086489872612de8c2c0288770bf2efa19fb01201977e6384d2e2bb4246":[12993154,162],"9af4edf6b4d78efa8c3f5ec8e4797e5c18de1fdced44a7edb07d90cfb9159653":[12993316,182],"9b0ba912fef5e3a2fd28159d13c550f0d62d854327521825d4d886a2870fa4f1":[12993498,698],"9b23b62a07e1249fc078c3851c727ecf191bfe6c99ff2f5644f5eedbd2ff94e2":[12994196,593],"9b63f6982a759ae6b7602c9b1518deca00f2da9ef63637a72945d026e07297ee":[12994789,155],"9b66ef6344ee0079f5a1e9c1ffc08f1aebfdc54e2948c77fa63a2389cd2dc254":[12994944,922],"9b91fbfa4d93556c0d4d369d8993506d0266f5cec902b298e43255981dda73d0":[12995866,918],"9ba926330dbf4d7a576b59849ae1af836d19b81e34795bfb97f55325b2a552fc":[12996784,182],"9bcccd9f57798a51cd3db9aba65fbcfd5345870798a86a8b86e19d9e81eddfe8":[12996966,14135],"9beb3e454574273a3d48bc7ed9df870b0118a0cb4fb556fc748f407e7af75993":[13011101,4640],"9bed63f9e7100822535fd2bc0c085be96b13a8ac66d2716495a5adf7252d3369":[13015741,153],"9bf4a8079cb3753f18b11cf8cffc2930d5afe10bf54d473d89ef4efabe7cf9be":[13015894,182],"9c187268acfc021be505e952ab135fccccd38d1168593caf63899afe84a8238e":[13016076,998],"9c221fe39e0c20b550d7819c50aba99a7dbab2d65f242dd101b3e494a0a31bc0":[13017074,182],"9c5fba6f870a2521ffcd0d392161983051a1f01af37656c018bcc71f482c279c":[13017256,52961],"9ca97ad272ef096af5d4768c733e14afe852ae25a3e15c907441906d73534d79":[13070217,808],"9cd95c8a9e803db62057ea14d9e2c6d0c842a000968610425ed9392f4fcebc67":[13071025,155],"9ce4529ab45512f49bccbb8d295ba58e666a1e4d2aa51eedd9eb0b7abc832d95":[13071180,1033],"9cfbf7744c50c75afae2fee55e0b247f3b6e31b9f534cab133150257c072316b":[13072213,7714],"9cff2b0892f0b162d1154c2c40241862f71545009a168e05b9928c40fd016df5":[13079927,152],"9d5b22e0c8befdc44bcf7eedf973b53e8b03468a904eed0306369785f87283b1":[13080079,669],"9d62cdfe887811181783f1c4a2b2876691ab6a3564c5d06fb7f2bfc0d25751f9":[13080748,182],"9d897617acebf69cdb6e944d077bbaf2c0441a4bf5c26615d86b3e250b06b900":[13080930,22132],"9da3bd272976984693f0eca3ccff22b31a8bc6db9e7c17f8e9eaba3fe1e3fee9":[13103062,661],"9dba2b7e944612017cd2ef781bf922c040b81f97b3129d3273f0bced2624ae23":[13103723,922],"9dbe588f84189cefc3641090eae198ca1a8c6d3a3f4fbeb6f4f175c2209de4d0":[13104645,152],"9dded912f9ba9ed4664495aef4d429833cb724a593a1f9b54a4d635308732bd8":[13104797,808],"9e00e61d6f1540b8e8df86860f916f64845386ecf68bed2ff8eb33e358883f48":[13105605,155],"9e204c59695c6aed88c0addef5eea89231043d6db19af09df3699f1c41cfe714":[13105760,17413],"9e61adfd41379f342fe2c0c049bb46e4c532d1d488456abadb4de85c767660f6":[13123173,32851],"9e7b90a24d35bb3d42c0476c6d37e380c905ed227e86d8cf44e8342e96265a33":[13156024,669],"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168":[13156693,5370],"9efd47155112472dea6fbbaf77e5300d8bf22ff10ad71b8e3c9d332c813a776b":[13162063,1178],"9efde04ca59f862e2539e45fa1d77431771e07b528d7147112e9d9dde44526f3":[13163241,808],"9f4012e8a99b2f4b02d181e9b143d433c28e42d021ae54a95e4f0532441bc34f":[13164049,155],"9f50b324e87cedc88923d2d0515fa872cc8cce89cb3ecb7bccf61ea3149ddde4":[13164204,152],"9f74928956693748a8d6dc438f7b167b6f5c605090c12e5941e73993283d5471":[13164356,18488],"9f78dd771bb243204f6fc5ad9672f6e122ad9b851b24906bd2512c5b8b67866e":[13182844,4703],"9fc585d114dc436f4d3006061a5ec8f6a2e09ba6259b5139aa3d863309c0d67e":[13187547,2682],"9fc688c8e5b5b174007eea177f93d5a275e3990312aaaa83a183d5876c0b3892":[13190229,1178],"9fead7dc46d6716420afc8e9f4c3876bd96334979f9626f12e80c76d13fd197f":[13191407,1458],"9feb636cec0e2ae7d212f9cec48fbdc4bff05b107511e358e5aa982eee2c2b7e":[13192865,1030],"a0067bbb90d44e5a1ac95735c7b11bc86b47a83c754d500301c799bfd4cfc524":[13193895,958],"a034e80f4db21f36cf947289bd2138b25b28663c1852df08def0a585d8651418":[13194853,977],"a04e24f0b3d024a070668b058668f9a754e189015bd23d5073205902e548d816":[13195830,4360],"a08d68fc718668f5a8b28d2f264bae7da893511cb070aa6d4070653aa5e302a3":[13200190,582],"a09df5325c5c158cdfde8ad413f197bc03ed5c9ce3d07ea628147503aedd9917":[13200772,87058],"a0b4aa7a2a71c4ee9fd3e278f873062407cad54a59c86648e2f27643c90b1a45":[13287830,4915],"a0b7d269b7b2becf67ddee6176acf6f5288043b3e89e5b901ba8a923ac0df8d0":[13292745,152],"a0df85381685eae826884b948f00b62fc8812f6d665a88cc0e3592d3ad246ae5":[13292897,275],"a0ec2a24f9b6d11f7c11da90096a91a48c2dc84369b750842c7087f84195a45d":[13293172,182],"a138ee9edcf3d5fd205e4abb0ee7d7141764a8d9fb20de2fcaaac177287db2d0":[13293354,153],"a14dd6dfce9155411a941b4a6d70650ab7b5ad430be0ed7193a1fcef2a2b2aa5":[13293507,34028],"a16682ed65a00dc087653788e150a33a77e5bb14f19140ff31218d5ac5dc8b96":[13327535,593],"a16fdf3bc7ab489d5610d2f9cf9917b2501ce06cc5b8dd236cc5464f6c93d7f1":[13328128,590],"a1717fc97344f3857284f93e3d3733dccbf0799eda1113027241c6bd23b89218":[13328718,650],"a19fbd9efd17e835870025c57af93cad988ff9f5dec1c0fe849ae7b5ef9884e0":[13329368,159],"a1f3daf72c16406f6f36cd0cf9b29ce9ddbb2f4a8be396e32e9acacea258d787":[13329527,4866],"a1f82db40755ed46e068ff446b2737497478fb6e4c29e739bade5e8c53f3e26a":[13334393,22955],"a1ffa0765647bba4a5800b7b586b2fabbc059f4ef87d92634909d2b563fe4a59":[13357348,3697],"a235548cd5e250c814f00d12cad75fbda70662c6a154fa977cc3a8bf9514dac7":[13361045,182],"a24eb1b3fc59a87a34e7f343c3396da60352ece50b6688c36b04da55351f1312":[13361227,14331],"a258b748a1c40209cc4b9d748a9257c8b314224856c232ed8982aa62002e9c31":[13375558,3851],"a28cf2ae000353b242f8e6ee5edf18ef3e78a13e517e458f7b7a40973e7e0d9a":[13379409,975],"a2b19291124d1d18c47113f3877f001ec83a3f7cd94647bbb59e4114ec188fb4":[13380384,3513],"a2b246a9807bcf882e8761b99d04391b5e742d20fb77468d9e0ecbdc2acb6c7b":[13383897,1031],"a2c25e6d7aa8dab2c129f662991afa8d79ff647831687a5759a8ddae398d7041":[13384928,665],"a2da8502c17775fd09cf0f6c314674688fab51a644b5a5c3ce058c4286de69d9":[13385593,1176],"a2f5418bb67e8935230c6d3c54fd332626a091169395143f048fd8c5d0a8e837":[13386769,8652],"a317336099e5dc29744ceac7f15620d45b7b326c513939a91f93f007de0f997b":[13395421,7385],"a31930aedf98b73f96a250cfa332830e2028fbd7c4cd44645805e59ee075063b":[13402806,4455],"a3707d0c37b7be86384daa2cedd8d96389e59bb0ab5a50bf7d50d1001ad7f623":[13407261,1877],"a376d26e5f8de69d8cb90f959edc75dcf7631ac57bf981ba4b0d699d1f5e2b90":[13409138,589],"a38d322afe4fa30f9d73cf7f101d26cb366e2dd382463b60fc5a76b5c3d62d02":[13409727,708],"a38e6c331db0c24a485fb4ef39c6996a957e8a068a5bc4a125f99f885a551455":[13410435,7813],"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715":[13418248,7203],"a3ebf3249a0d13b8b6d8fbfae95a896bb8c148b98b23e11aef070b51b624058d":[13425451,589],"a4450523be71a6263351ed62b93d319dc5fb83bb31faa6d77dd1a371bc1b1361":[13426040,72402],"a46f1dc2a6668f3431a4af7ea4da100f5c414ea3f5667c54610ecc195b41108b":[13498442,155],"a4a446bd747baa6df62b269be255f3f30ffed7aeaa4946e66d1e3e873fb58b50":[13498597,1033],"a4bc5d7661940a64762ccbbcf869ee0d2dfeeac963bbe4f9bc4ff223b77d30eb":[13499630,841],"a4c4930e36ed7600015ee14c856ca28054acfc41986b307545b595401c72ff72":[13500471,182],"a4f4a79b7ce050c447d6a3fdfb08a280223ec4addb7cc78a7e97105746fa73f2":[13500653,152],"a5070273b32eae0d49e75fbdc7e55c8fa81bba0a207f00d7948e21207a12ac78":[13500805,153],"a510adf944d1727d99e474b29cb4556e0cb647c01287bffc083e4ec8a49d32fb":[13500958,109454],"a52ddc4758f68c09fafed6935ddf18caf51278cd5a65c2b7173327174e5e59b7":[13610412,1857],"a54024d1282f8856c52c0dc501f57baaff7ec7ce40385feb6f274542da93224d":[13612269,152],"a579adb331dfa4919056a7a216cf71eeb7070f230542a1a626260c74e8690cb3":[13612421,922],"a59da2d932c6cc9c5b056035b9f08b7a0f363b066c5f09f697f366a256b6cd5e":[13613343,19960],"a5c9bc18ff43ed0d3a7dfecb4736f5d100b012bda88da4d8f033eb026f8fea89":[13633303,7209],"a5de1f5c63cb95d1ac984176ad9cc83ed637d5adffd06c60cc0e6fa7d65ed794":[13640512,4866],"a5fd9724bdd40364367ad0cf719e4f9fef78b36029e004e48450b29648131b84":[13645378,153],"a60e0c3e9cf0995bdba6dd0bc8308e4ff71133350cb9245cbb8849e43cae5480":[13645531,182],"a61d537854cde4d6695314470a0db0edf1d81ba681a55fb0a900e5a591f236c8":[13645713,182],"a62fe106b32dc3de547524218b44804da9d8465fcacb49a1341f12d964fee972":[13645895,403],"a676ad03a2294b333a3ee3e44486c505f1938d39321f810b08680bfbe646f6f3":[13646298,1069],"a6855879c51efe80834499bf8047acf5ad9186e66a9dc630dd5da884933b4b50":[13647367,872],"a6894e79331ca6e992707d75b15556fd7ecf11559c91bf7e867ed95582ae4a4d":[13648239,152],"a703b40aa767add1ca05b764e502f4e59d3b7a26709763792b043476a2c720b5":[13648391,152],"a706f9b4072a492092d5d33e04aa0a6c957daf3e52e57011497053ddf9416ad5":[13648543,275],"a7629933323a1d1cecfcf575c968f20e3f6567251af57ae7211167e6a1aaa025":[13648818,35830],"a77a52ea7194afb3fc51cc5406448580a7548879fba743cd68d8f5faf39396e9":[13684648,5214],"a7967c79bf6d68ea8c83227184634731160e25b312628adc21e0772c50f70b3b":[13689862,1910],"a7c9e9d31b6e607131ee2e9ab67a7bab812bbd82e9af137bdf034ab00fcde4fb":[13691772,589],"a8043f623adce433aa837922fd6213bcafa0f187c6fcf5550727d03b61fc169b":[13692361,665],"a86584b58beba24c20637e43be2ea3009d4486d6884d97d5e487304b1a1c1868":[13693026,922],"a8c9ba970b0523ecaf508781084207b9a8bc2fb0ed939e7c55622c37d76e7681":[13693948,922],"a8fba66cc1793229e7c9f92be45bb40f49138a4390a4795f4d478e3b33493584":[13694870,902],"a900845d439d06d089f055cc5ed3f48eb3bb5d82075e63e7772d937eb683bbef":[13695772,182],"a9231bb7eaaf6d32d3a8179fc0925d9602207968fd5d49a0548b9d2817f9820e":[13695954,665],"a932ed569a1e8819c2cc106dc3d240989ff838e598981a0ec3ffe85c1b2f4f10":[13696619,182],"a939de9095ad62f52d77a4e472a0cf040966d14c98b860308bec79e6f8cf5bc1":[13696801,25534],"a9968cca3d5e718781aa07dfc91a9e68495df5b964c5d5b50622038329c145cc":[13722335,590],"a9a8fa301aae3ea23ba79008e130333745a36d1136a4d92814fa1fcc08373cad":[13722925,4703],"a9c901629723d93d415a23eff7f221a428595727c866f60f0a4ef1574c08d389":[13727628,26317],"aa1c6feb509424d6daaf1184f30c91f91fa1840466d1b59b2b379fbed233080a":[13753945,595],"aa4420a1006749f811179378f41e7b377525beeed7587391ca0c661025c6bcaf":[13754540,152],"aa6042b835f4524335eed482eced09ec112e60adb78b5b196e405d6845c3fc2a":[13754692,1937],"aad0b567bb392d399f384a0046f5eb188b7aa0bd3a5f60adb510813d0ee724f2":[13756629,7467],"ab4ebac89ef6f5459c34b98a99f6e62f834e8b01d447bfe3a2349e0f3ae3ec37":[13764096,182],"ab97733506282c17e4ee242d4a63eb254d9b96590bb96b0bc1b8eb4de5fd0b8c":[13764278,11317],"ab9aba5c093920082ceb592429a727d642cf559ac435b93db5e525ee4ba1b2a4":[13775595,669],"ab9d4b2a2435ae23403e6ac26f114675376f2a63ecfdda023db9d56fb35c103d":[13776264,808],"aba0e2ad90d303597524ad8a80f52107566e037fe5b16f1a7f70c814c01f3516":[13777072,808],"abad4283136e7fd0e880f49148d5422d673d5d1e4267976fd2a3dec47d7f34bd":[13777880,26886],"abd9678be77834afc30adb367555a39cee0d590b67d041507a4655ae4d9cd55e":[13804766,988],"ac0d1f9a7f0d9c2b2d2607b167174ef422dac7de92f2efe3cd18a1fe67e1a80e":[13805754,152],"ac293dd1ced6dbf092b095a02b3b3f36958de7eb75e3d2236d4908494f385bc4":[13805906,13930],"ac4bfcb0b0742cac92ede9b3965b4fa587a2625a99ac32f8f8cc65513d13e873":[13819836,918],"ac4c44cf99400bee4d13948478730c55d2d080903183f2fe3dd78f5f35e52591":[13820754,743],"ac7d92c561ec9fa19f0e28e1392bd90c1d95ca663b038424bd8ce760c6f4cd9e":[13821497,669],"acbdb27b7aae3986f6e1f9d893b2c49e84e999b01e9223319f12ae772925cfff":[13822166,1069],"accaf93691b84c6794df99c7643c710529c765f12cc9faedede4b6b598a8c6b0":[13823235,666],"acd8f8cd1e71585d8b25804d42455806f39659af2949e7ed369deedcb63abbb2":[13823901,672],"ad1581ae209ba3feb19322408bfff544be7a29bc6a7db44c672b705f1afcd120":[13824573,312],"ad325c51ef6afc696eb5e656129787c12b7e5048f387269a0e40419a73a9879e":[13824885,922],"ad36923c534a5c894684500496ed663331fd9cf98e3024fd987519403dd54c29":[13825807,10478],"ada150c613fba5947ada47cc6e694d518c13a89626ed80bf665ed3a9b2f373b6":[13836285,7882],"add27b49dd4b55c54e03c42b54962c7e9172e51a9b83b7e721f1fca8588b768a":[13844167,14290],"add5db46ec6172da4c17d09d8d03c853ebf950acfe02d6715623a8335a3f230c":[13858457,4080],"adda0df2f4e91b324545e1e4b994d2d5f6bcdc72b551194591032b9411a1ea88":[13862537,159],"ae84275fd397e90e7bd5908e314c5e069eeb8133820cb7ceb15bfb8be2e61f3b":[13862696,962],"ae8d57ba56ccc764c467b2893d56eed1324c6e80aadcb7d8b5581e26fba42161":[13863658,3063],"af07b7070a481376ca9ea244e436ba90cfc234c2e516d1800be2b2ae7cb22b3f":[13866721,155],"af5f2ddacab2e1aeabb910fa5bb7df1345d97b7c89e16793c9277d1fd114df30":[13866876,26083],"af6f565407a2580790e24aa56131b1dde103ad9839138da918c53e1b2b994d19":[13892959,4752],"af9c0678b4bf03a2c5e889e3225eb4ee28592428e2ae43a700628db05f502cd9":[13897711,872],"af9d1eae9bfaefa334e6340ff18644274fa3c401436ff6913752469dd958eeb6":[13898583,182],"afbc6e45909d792173da8ef0de23c5ad14f022e39e692e560d510f46a65e797f":[13898765,2333],"afcca2e774aff26c74e661080077d47251c9d8d4ca5220aa4d7ec8f213403249":[13901098,13799],"afd89e2c931d08eca6a2030a2c098322e09c32eb9e4b70405f2fcc67e728dba0":[13914897,152],"aff9bd5b095cfbcd34114cb0c34ac87b1716fef02f0b452888c502e5301476d2":[13915049,14546],"b0055681d363c62dbfc1a7d790a3d534181beb0bf4f88b12acc8432891821eb2":[13929595,1506],"b07d0609d1ed08018cb513f72fa3afcbc204989993eb266a2550dbba9ba091f7":[13931101,31640],"b0a8d8b52ccb7ad3e5b791e0791df5baefde058ae25183f7350aa0f575959abf":[13962741,31306],"b0b65006cf4cd6e0a9a34dc67aad479af2b3790a125899882e57c1533725e30a":[13994047,922],"b0c8912441924b00cf8f72a05d9f0b25a11101037562e942ca17d6461702dc30":[13994969,1116],"b12ddacfb88864021dfc8604dcd05d549abddb7c814ff1306b8263b7693a701d":[13996085,808],"b13c631be36294a1bf14c669e6a216e10b4a11cc107b29258aa54e399799b60c":[13996893,182],"b1442ce84b38b49334d1d208ea0383284f144f546142a25043e44ad15e119420":[13997075,155],"b151c3a18fb9dcae20fbaaddabd221bf11d62aa67578d2a100e67aa8b41408d4":[13997230,14749],"b164c6110c4b163fd7d28b03f1f81984452da3282aeb087000e4117f47614ca2":[14011979,21272],"b1884c0dda826ee579c0cedf56ec34289dd945e1f551f31a6563c11fbcd71d8c":[14033251,1178],"b1f5c8abad265138daae87c973aa7ec0fbb9b9b4489736d18e92650bc7f807e5":[14034429,698],"b1fc4f868fa1fb34887b7178b121ade5f30f82407db13d657a14cd830f619e4e":[14035127,589],"b2021ad68c2504499cfb69d06a5907cbb7f02e697534858ae8a07436b9b19e12":[14035716,1178],"b234a71b43518e1fac23f8b600230deca8ca847391e80393f26eb556fca4c8c2":[14036894,27189],"b2597834e873c0d235fa9b2e3cd216a3642b70fdc2060ba47996699eb3f157cb":[14064083,1033],"b25dee7918c3d4270d2612e4e07b9a57d1edaffc1771b7d30931299ee5765b0f":[14065116,25750],"b260a2fdfd546f30e7123695754da1bc177f26f76048a2c7b8bcce9285fb8ae2":[14090866,182],"b28c3ce823ddaca68bc90ef229f8b01c86fbd79261ba17e33a127123970e1f0f":[14091048,153],"b299eb3f797a2507b549da8e76381fcd17a6b395fbc8c957950e4e72d0f402d3":[14091201,808],"b2d51d99cdd092e1b5fe72acda00392fb3161492ca881f3b1c69a40408a94881":[14092009,472],"b310e25e513458374a10df3f891dc97c9a7f70a2701360cf8cdf198455c2c5ad":[14092481,182],"b32516cc072c444757ccc009c8c2b2955e6ae258d63ed94ce5dabaa5ce29a64b":[14092663,152],"b33dca2b802e668813996ab89f19b5240badb523565f3230638cad68e59c0895":[14092815,3419],"b36cfb9654d3094df47e47c45be1d8c4fdfd9f79d8a4b32ad08a73de9a19b41d":[14096234,153],"b3904051aaf60e9552872fb032c99f7f9b9c1a628c9ccfba5632b209569c005c":[14096387,922],"b3a0c978dc9b999e4843fb5706cb19ac43aa28b71a27697a7d7e0db49945c771":[14097309,155],"b3abc9f9c902897188124ed990c6f51a18932f26a69c3ac3884da4d43444c4e9":[14097464,59],"b3d7ae339e60525bbc912602c4df1aa26fda7fba4d5e9b0f6e92446287ded032":[14097523,182],"b4028bc17ae5c3731a71f455680437a8763efc63eb23192774de0fb0024c665a":[14097705,2088],"b43ba65ea367784624e55c0661023eb4a279c7f5623d71294f7fb09abb70fdf7":[14099793,7796],"b43c2561686d273b4efd89008885212596d7a20468f7bed551b19a922df79b22":[14107589,19289],"b46f4816109b4da6184b3f263388e81ca8242c632bb89eda091b963ef2b5de2e":[14126878,872],"b4cef078e13d957d39bdf7f364698154a91d009be3db5237174bca92694d6ac9":[14127750,152],"b4df455283ade5d031757e4a192db01fea9cc5b0a96c18b9786aaaec3195b611":[14127902,152],"b50ad2ab0bf8397cc7732d275ff9ebd03bfbc1490207adfabd9b9e283b2f8123":[14128054,19238],"b51c1f1b979f8de43b2133337e9581c07959f11244336ef673416deeb2fc9c40":[14147292,582],"b53949f3b0b0613bd782b79251ac4bf63874b5abcd241bfd4d059cf388774928":[14147874,403],"b58c9905a5161853627760881b34a4296428afb5e07610dee4a3a908a5a63559":[14148277,7573],"b5cde0e371a87ce3810de7c7b7af9e48bf453094540fce927bde21dde6b576b9":[14155850,666],"b5e4e2782fa3e7b35714d43c6a5dd578e6adc40a53e872bce582dd3d76dc27c2":[14156516,25176],"b69ce271606959c296ee7b69d4524d49717712720bbfb1f2b1b18a50d86cd34c":[14181692,4455],"b6ab7ee76ef6a054f59c640a774ae5b174ee477e0e453dce7c06b98a3a0af939":[14186147,182],"b6b715abe15651e84e721413a888252a9be4363e6a9f6ecb6fdf44f08db124ac":[14186329,9072],"b7187851019bf10bd035d9a90e8e3f95639231fc4566e93436640b01f1ae47c2":[14195401,39182],"b73e4b4242b073030bdaf119b375999eec7f383111a0b02f8e73505eb0ecc76d":[14234583,26157],"b74ea057ad4517a069dc4afad4437976ef722f4b3cc7a25f89bac19e0af3b427":[14260740,2907],"b7505e3d5938cfd781db87a3052bd34a6aeed587d72d790079a70939cb6355ac":[14263647,872],"b7c5c5ed6856448f5819474a42db6f9cf3040394abebc3e0f43ac4040e6dccca":[14264519,970],"b7c5e83622f2ca2795aea65d534096e8f553a88140bd5d67cdcb939d9a63207a":[14265489,4866],"b7cc868978d415a10f41789683c9c719704a81dd6053219a90dbd33369137155":[14270355,435],"b800cffc348ab214da593a2012c683a87b09cf4c3bb82b07f9941e076798aa72":[14270790,944],"b80e7f58c197c39559016db047f7059a80e36c504ec831a3f712c70e67dff592":[14271734,155],"b80f51a2346d8c26453d9dbc03e4717f7783ce817b01758f5b45c1feef214a3f":[14271889,4287],"b843a3b026a4cfa0a25ebdda521d1c4e32406f60e1657d0549be8af38cb846a2":[14276176,988],"b86a4431e286225b32bae5cebc03298584fce063e8d4ee2df4633e18143f0aa7":[14277164,13639],"b8a49bce6929cd49f8283af6ee7199677f58b4790c3a4a1ffa0f47d36dc8e9bb":[14290803,4703],"b8af01f12f1e0f4b6a2c6e255ed98185e9a91033fd9102ed651ae14039b8e749":[14295506,12686],"b8bcb9a04baed735c6a3e32d41a53d4789162bcc320c911e375eb0261f3d0c52":[14308192,104478],"b8c3e4ef1c2a1293952c001f474e835153bfafdd23d5173be4fdca923dff8543":[14412670,153],"b8cc0db620cad03ea09682909ece1143c84d2837bda22513e02dd149b3b202ed":[14412823,808],"b8edcb6c1283d85bd998e1026c71293298451ab4e9b4a0047e5441cfcdedf28f":[14413631,5146],"b92d10f23649a6ab076e0a23eeda2a922fbb3e779486a81b37c96c11f63d7873":[14418777,155],"b92e2470b3059b4b8a3b7208613048e0a8910a62e84c6c65f73fd66eea161930":[14418932,155],"b97591be8ec1aff3a50e94048346ccb0c401877607908c11864dad26a5270dfd":[14419087,962],"b9f0b1e190079ede7e50108bc879954f0aae5ef7419b004909da23587f7d26f6":[14420049,5085],"ba19292fc1f3d688a585b06965de172490cdea63123e6d6d4d536d2592be9025":[14425134,671],"ba1c5035ed5d02088e38bd72fb9b3545bdc4e67aaf793a9ba0690cbb18c06e9f":[14425805,596],"ba257e4e665b2cf38b618422e44e185b97222ea869c70e9f556669bce0205965":[14426401,988],"ba76c0de045799d37f3afcafec8d65ccea0ed8e388f5d4a38031e29472e947a2":[14427389,4866],"ba84804a2c2611c8af1663f0da09b4107e8b76b628dbf411441c3945b09b6e73":[14432255,472],"baa5544bb061f7876c0291c3ab9f8982a0c341a6565f25b4d83d4560edda1889":[14432727,182],"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2":[14432909,3154],"bb4f9b34bdecb0cb2a95ffe437cda2b5f86606f59bf3a2d6c28c8139963e2d31":[14436063,3950],"bb50ae621e9f99faeab901a7ee376eb44a5d36aa26babc3ddcc3d039dcc6fb32":[14440013,988],"bb697c13a7df86325725a0bc05269c8197416b2b34f5e7b496ea3a3eb9a07976":[14441001,155],"bb9427b4fb38556ba2a3796ab01cb0788ec221ca4080a050049e5f5b4ecece2b":[14441156,182],"bba24de15a64304a2c47ff84ea2d957dd65f77a450ab1f813ef2d51fb69e7cc0":[14441338,6313],"bbb30f41bacd80761f03f1c07e19c25a8b9f4dcfc089d680e22e7d43dd294c57":[14447651,1761],"bbeceb9309a6f2379e4c2cc7cfc0708f2049169233e152c694784548ff85db30":[14449412,82837],"bc2585b5754bd932813ce42d34f4e55f09ce2f5287d64d696c94ef5727518e0f":[14532249,472],"bc274cef6c4e5aca63e7c46d81e1f86e118c6ba079e7a4ccd4e9f14a8d3eb21d":[14532721,34741],"bc50ee68f42f4d44f9e73d600b7c4cad1cf49e45af287c14f4d51fdcd5c0826f":[14567462,1655],"bc53b1a015afda4574efb81103b417c0bf8de879a57bfb3bcc3bcdfb3f2b639f":[14569117,590],"bc6995a2438e7ed1740d2d806d137e433b3ce07708bf2941f9b6774812f0af3d":[14569707,34487],"bc761ba43afa78e6c811cbc6b9ad03d55a375bd9b2bb4de44361eda01e28f99c":[14604194,1069],"bc9f18a34748d245185298746e0db6fcac33afe57ddff8836927bb668f4172b6":[14605263,590],"bca3825967b1bb2eb84915332c33b3d227843d592c9b32e07046646a205280d2":[14605853,11817],"bcacc1842bc9440afe08e7d396622023b8ce1db9d04b796d2842fb704c7b2b6d":[14617670,9482],"bcb41a480457775ca3f239c29b7f6ec05cb48e121a6b04172b3b2fe63afaca68":[14627152,2691],"bcb5129ddf978eaa9256b6d6d13577728857c783d9b4d3f6a9a2fc3b36e3fce1":[14629843,42734],"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055":[14672577,7869],"bcd92cb1f2335798a1c44ba0949c437b8273a798a03e253f20e17a93567d000b":[14680446,3512],"bcf3b734b4eb2794f34c707ff8873ecfcfc07db56bad3acd669fb5335b9a8a1b":[14683958,729],"bd0265873d9fdf1b44926114603c229fd4894a45eb57bb9b606599f2da7ba9a2":[14684687,904],"bd6e5a6f69691c8c9a48ca08df9d3e3272c3527d8a14a51e859f6fef036ab062":[14685591,32551],"bd74e32d2ae9adaa9a35c1c72a82f11b7880789949f41626250a1ade81c0bea9":[14718142,152],"bd76dba1325cd5ada93eb3918f684a58223858c8b5d55d852827f86e24b084fb":[14718294,1471],"bda10e2c6986afa23de0ad734908c12805d9928841e4034f3d6f52cadc584627":[14719765,582],"bdf6f640eb1d3c3fa80b7d5f92e4c33fd7823ce6e3126d2749a62658ffb113c3":[14720347,25448],"be1aa84062ac4ac42ecb2e283c8609abb4c6fb11349c33233912df90af957d68":[14745795,3514],"be2a56ff9d16650df9a1b6ca6aae9e3e1b88c013c2a0548bf364a7bad344b0bd":[14749309,155],"be629fe4418f4226c3119cf486d936741c11b644241c9c358df2172131c722c1":[14749464,22327],"be800204355c4540abd61095d59cbd948a6d75bbcb0f6240fa7895e224a7b754":[14771791,962],"beaeb5b9dda43dc386c7ebe7a59a551d193e4de5ea854e97caa451561656adc4":[14772753,1877],"bee6b652d354540cf46b2e977959c746c73e36eaefa83a4c5ed0a1a6b494221c":[14774630,918],"bef4b4b9a7cc2d2bec9b9c721e05b6fbcc7cb3cf6c12edd64e78717cf5c2abe1":[14775548,935],"bf0a8c7b340444d4b90676f0b092ef6edc32167f0a81f19da7d8cf7889860e80":[14776483,2377],"bf3330a173dff38e48dc6eeff2febf104173956f1aa381ef4f370bddfbc60973":[14778860,841],"bf444f2b3d0529fc8f613d0c0a3fe1176d20e31cef04150f55a513ae14d578f3":[14779701,904],"bf985f6e8dea8c5ba087d85225cf7c4b1cf05840e536dd8d4e03606bb9b177bf":[14780605,152],"bfc02cf29c4cd774aecb05b26bc930506bd9f9384576a24fa43044ebafbfd1b6":[14780757,244],"bfd8ff52f2306a963616bda4a97971a9daebe88e75f34bb2f06960ea92feac39":[14781001,18780],"bfe7aa0cbf3ee61c07103850b73649ac98ddd6a054d94cd4d6384ff4aced8e5e":[14799781,35205],"c08708ff75ed17d90f4f3599c72e9ea0cc8ccf08358b5d9662b2f8f2b3ebe2e2":[14834986,153],"c0ccfcd602d7642444995972d12d97e440e7917823401d6f3e175b4cbdc6cc94":[14835139,904],"c0e1835e12c0dab7e9bb223a61914b9b89cc6e5a88698093977abacbec733a74":[14836043,59],"c0e4e1cae60c82e5a73e59334d91bf63ee6dce21fa6c02a12956e32b8727fb9e":[14836102,4406],"c0e5baf1155ff2fae103665fc9da111a6ba751285155b99fa34590f812544c00":[14840508,103010],"c1241a97e398daf297bb860ae57869cc162b0ea4ec05d206864055dd62583bad":[14943518,4866],"c143cb29d5df5c031d80990e2053cd849769dfc65f0551b5e92d6439a78bcf48":[14948384,918],"c17df4ccb76254e84f30d1f19a98183858355af8e2034fd78e81d4602af4f380":[14949302,152],"c1a6ba3fce9918dc77114ee7fa8a2a5ed211ad39e3ffffbb76d924ca7f89e808":[14949454,11488],"c1acdeb6c69a367530f99473054a6943f361a727fdadfbedbad65ea0031b5f64":[14960942,808],"c1d23f637ba514ce61a14500549419fdb6798ec084a8434001b665f7232dc8a4":[14961750,7881],"c1dd3f9c9bbc93ecb455626b3e8bde411addce408149e45a084fbfc328066287":[14969631,153],"c1f58faa5007c077191608b945ee4825cb05120b638bd7ee1d490a2b0f2e9835":[14969784,743],"c20b40e3787bf1a2d4c95a5b9e903fd0883cfe75cbd7f7ee90bb9f5a5a506727":[14970527,153],"c21927fa38d31d70135585bace74db198943f9507fc0bd009aaadf340ab1a815":[14970680,1906],"c2690840af51faafd65929bdca676747ab87806e1e05ec50f2d3f676156373e5":[14972586,20653],"c2f3d06a11d1d2ea6b9bc5278645cc0f19af59d475ca4652c9f4cf7e94c14526":[14993239,1990],"c2fa30034482a973d867b5dad2dc637b129a189132dd9c88b692c7949aac842d":[14995229,153],"c2fb559ece032272261d8ca4d47e3c745daba878894591f7eb16214b3ec2c5fb":[14995382,4752],"c3046f08ff9a47d6fed4c8a13f061535448f206dbdd80cea6fdc89364f8e5a47":[15000134,244],"c34719963c6609475a3a32e33030cfa0b5c3cc087a1e73fce9dc4c801acebc5b":[15000378,152],"c358c112be0cc51b3469a63388a4f9bf597eddaf9b3512a1b6ae3a1ceab8849d":[15000530,4703],"c37a92de726de8ff2e0f92bed4b7ce044b4ca04767f196625a9b969725d702c2":[15005233,4703],"c37bdc6dad472197e94ec4869831a8a33b1882a76db554df72ae53cc5da1c082":[15009936,153],"c388ef7c8f50dbe60db408bbf8628e4353b1b62882ff7fd1b0aa09c26a631aa1":[15010089,1937],"c38df1128a297e15c512cf45a5c041588f37f1fea97b2ab011b6edc9b5bb945f":[15012026,18031],"c391af8ad3e6d410248d9e4ca9db4c671f804aff7e4bbe529433dae311725c89":[15030057,729],"c3993724e49fc45b40c14e6ea42067ad55c2844dbd4c92f7d912162cbb8e6ed0":[15030786,918],"c3a7241b135bc24dc5b8ce9e5e1b75a7758fca92b4eaa3cd57eb90b77cc9e9fd":[15031704,582],"c3afb4920ff015aa905f425f837ce3077a962e43fa26032639bff461f71ef8c8":[15032286,4387],"c40d8ea8d968ed041eec0e34f83689cf1e6e28085a801dbeb712567603e04162":[15036673,153],"c454973cfede0d796d3f02041f3a13cf755dd5e6e0ad3b594ecec5f60f2c3a1c":[15036826,152],"c469942c7ef144c5ce27b14ac3d60379f1273dba60fd73a72147a7ce22ce8095":[15036978,666],"c47a40c3211c7c349ffae5b69c3698f9a7818190e661d9391194acc19568d38f":[15037644,666],"c4a6b40c4b9a320ba777a362e89879226168f945521920465e91fdca96ed7a8d":[15038310,2479],"c53f62274ad61974780d29fbb80baf6d4b397ee4df757b68e64383975c3a9241":[15040789,666],"c540e70bdc94b9d5b28ee404d2a64ca8feec1cd93da85611a2ca191ed29280e6":[15041455,10341],"c57897501b2b8f629c52d361cdb6acfe8fcd991936f4d9bbf6ab3690b92ec79f":[15051796,9799],"c57be71783d74af54997681d613e350cb9003075768fdb52b2aeb9949d9015cd":[15061595,922],"c5d18dea669bde6fa3cb8cba5ec78677b23a24c4433f0212457760003c4aa6d5":[15062517,70422],"c5ecc12f7f61f91ed5d9851e9db40ab51eb6aa35aa2b8b9beaab238c4df546e5":[15132939,182],"c65f25b8fe16ab74d385aea88a06d6225b1499b177c56edb9605fb74e0e043d6":[15133121,58262],"c680466aaf516d3473a9d48613670e483da46e026200b4d6599aa84573af77e0":[15191383,182],"c6b8ce166bc273993feec781ce54da25499ac925715435ffb9d9dc32a2fda9e3":[15191565,36654],"c6e3d6ae65fe825c8c888ac9be4afe84f6b4f13cc448a4ebd529240c872dbb64":[15228219,582],"c6e984f3367bbcd224e16de533fa8c357d320625ec698fb566526ceb887a50b8":[15228801,155],"c6f6c1a1b70b83d972eba898ce2cb4f632834b3283b9347d2197fcd8046d66b2":[15228956,922],"c701fe9f171d35b1fceb84f7597d00c90f9caf1b61bff3fdbb5836947ffafc4c":[15229878,4866],"c72d4643fdefbd7404bde108356e0dd4479d820d7848a6e1d7e1ea3308877c06":[15234744,2827],"c74d68784a6aa1e4c7f8612c6b863bf1d9cbace27d94ba58837943b5b8f5f1a8":[15237571,1178],"c755b0455255522af9965d547eb67f529690e8b30e08ff38b4c501230bf108e3":[15238749,182],"c777943d5bb9e51d363d75a4b45ef5b79311f0ccc4c4de6b471d93664ac0540c":[15238931,8717],"c7ddd4b6f1ceddbb414b6f80d805474f1d09f6a6943dacec3a015d4c83ad58c1":[15247648,1069],"c7e315411ed6c1f301cd891144f8d4d5765a457b15c4a8a088818ebf7d6554de":[15248717,4703],"c7fa5d9057e826b453c174e8df7d01ad98658f003a89a87434700060ba9906e0":[15253420,918],"c81cbb1b9a30c2cecdbc29dd30844ccc5cf6d02313174932e1c13040f8840b7a":[15254338,73383],"c860fd32bad3ae33b45e0f308f2c6f2b19ef7e918bedc18fced0d8c5d86c2c00":[15327721,729],"c86af3f9a18666b2e585cd6ea11a7a5075f800906cd6e3be9a67d33270c8a7b6":[15328450,530],"c8cf73ffb7c118e5ad378bc58783365f8b56c59299ef2359aebad52205080d68":[15328980,155],"c8dcbdfbc591df30b59794e35b13b1507ea0e021db780d176a9c368539015a2c":[15329135,1937],"c8e653f1b0829c5c3c0bbaaede6c9f9936ea20e203d4b77c7270c95f1723c810":[15331072,673],"c9063ddd48a4e7e0a6b8a9bdb7df19045acfa4af63e5d1ce443e97d68f7e34a9":[15331745,904],"c91526f0014f317ebda8caf718d2852a76b5b8c35807a152f4e64cec807c2a4e":[15332649,155],"c97f60652e2d8e2c87280754a9deb72202bcbf7f392d6bf1bf12538f2dbd2963":[15332804,4851],"c999fac101f5f4e151cfe26cbc1e8af53c82a96184361f723771e1a160f20c11":[15337655,182],"c9a40ce822dd0e27e639d7b82832e6d2cd29c9fddf315b3602374865063b2a14":[15337837,80127],"c9a951d0919f6128f1aceccf23fe1eae355d5557c7bd12e317178cafde1eb7ea":[15417964,98248],"c9b19428a067036863248c563011734528e64170ca525ded3f9a7d984af54a0d":[15516212,666],"ca0243a22eba8ab85f4c18c5f305f90de489fa4d02161d2d2ca4f2291c4cebf4":[15516878,3995],"ca1bdac62d8c3409a54378a02ae90ca229216c6024ba44e23ce62ce6f4d45f07":[15520873,67660],"ca273fd00044e3dced90813add221493b04fca8416e2204554b9feb932e7a9fb":[15588533,1033],"ca3b024b26d4e1d3bf829d5bee3dad71d9d1b564f5a4004be956e19d636f1b8b":[15589566,153],"ca46ca78bcb2940f23d3aede83fdee2d10b6910dfb0eaa9de6a8ca83a2209427":[15589719,21007],"ca4d0b8217a1164f7884e0195489d4139c836c6b32035adb683b27c3552548dc":[15610726,4482],"ca8d95337fc298f1371b39698f15bbe9e2b22a99a79b1aba2b4bf52325834df0":[15615208,16601],"caab5920acfe98748f585c7254c4a49d94c4185165e3115dc54dae6855e85099":[15631809,153],"cae150dea39f895fe547dc54b23789028aa44560d21f76af0c6b3c4c3af9b3d8":[15631962,590],"cae15a623b3b1af88de62f1557c920d5d535794db796045c4886f1eef12de37f":[15632552,904],"caef8569931c064539b0eefb8e2f2bb502dd1183a37d43acb2e704c2180e88d5":[15633456,182],"cafd4f0b7d66cf892d62bfaf7726489008e093e105929c3a7ce30bc8ea35138d":[15633638,1877],"cb122db70f0461d8663923a7fdd714308d84b4ce56827f84aab7cfbdf01d806c":[15635515,16208],"cb2b0ee10d67af3091437305664b3ea3b0419b83dee9436cb4ebea480fef83d3":[15651723,801],"cb41524e720c19147fdf0b322c7b5f99d503e1a821d8d71139522c21f4855f26":[15652524,182],"cb4a371433bd6721613afa223fb5e3fd962eb9d58272937e6b632c21a0a351be":[15652706,15235],"cb6598323fcdec8bd57032496380f45d7fd66af5be69a2389c77f3769bf0fd85":[15667941,6909],"cb80dd3ee7609ce722a8e8b79d31fbf143395720303e37009733c7e02c844bdb":[15674850,1178],"cb82ac8a35d902954b841fa6b0c14cd03d99e15d69b8005c3986d5ad66e22b29":[15676028,593],"cbced4f8d7d03c3d09400209512a41d9900fd3f8c473ba474b3b9fcdebfa5288":[15676621,102969],"cbfb23278e8b5f753ae86d8bab26ef9d9daed71fab69228cfa99003cec5bdcb4":[15779590,1857],"cc139cb212aa5b627f552beba044c6d8b0c778729dbe3a85497c9ce68b888170":[15781447,15337],"cc1b8713650f5fa9aabd1f43b96cc999725f0552b614e90c3c25a8e9ada70d25":[15796784,8194],"cca37ce0ab52aabdb236250fe7211d03ae415524090fe1ea8d26ed32f63a0714":[15804978,593],"cce5ac73d851cced890fa7995b1fded990d08389ed7390a6f8b2c0254d1ee302":[15805571,665],"cce5bb006c491728a71ef0b2443a25b6d7942d4660a5d914f1077524d49d016a":[15806236,155],"ccef8c05e2e325c5627320612073e20444cc5639b5bf1cc99349c915e0799eb5":[15806391,902],"cd15f0d80168975552d87c075819d485d3bb65daa0da7f4b3958aa9abd2ecd05":[15807293,20663],"cd67c700a387c4fd20907440b1d1525ad7f86e632a76469f488364d5820383b7":[15827956,14291],"cda23242a5fb8aa243b577c8d7fb4e666dd23757c9c1f8919f20af2224dadec3":[15842247,590],"cdb6c046470cd343325f2f0b4ab31705ebd2b180103986cceaefe0de819636dd":[15842837,665],"cdd19a589e0fab4c0e39ada09a7926a7d3c4139070e4711cb116fe62c9c3d280":[15843502,152],"cdd487c8ec08a7bc6a1e6877fbfa49e52675df90d5c1aa50d0f332f5277bd3c9":[15843654,6995],"ce845a9e5d14f391f955b836d94119c54041efcf8661908b6df2c356fe9ab5ab":[15850649,32481],"ce99ef705a576f1429bbc54c67b79b49a638c0ff6b7b5d84bda01263c76f1a7d":[15883130,182],"cee85716e1ce6299ed1940849c8dad3383be1a81fc34b1294adffa54b6192791":[15883312,182],"cef231b63be8cd73caf4301ecde64d5208447292f897a839d4d19cec74d229a5":[15883494,357],"cf096d276756e704a019f2243c3c18f4e508304ca9e28327c1c44dd5462e196a":[15883851,153],"cf1e5d09f1252cc721afbfc19399086c70ef6ac2a08712ac49a8bec5e5763383":[15884004,962],"cf45ddf741d5ee4ffeaeb2cd2d7a7e3ada98ae7d9998d633170ec09db1174b6f":[15884966,1877],"cf56f7691c86395bc17b7cbae67016229b1904a40e87ca392ca06a80ce643f42":[15886843,182],"cfa7aa5cab2183d0fb951afb38ebba6665713f8582d65c3741b185acc5d6ab31":[15887025,182],"cfbdfdce716fc1b0828ea03ee5d4b1cde37529c7011f1216862eccd1cc6acc4e":[15887207,1113],"cfd16e4d74e97b7983f6359e38dd6a603f53d0302711d5bcfe37a8ad5106d6fd":[15888320,20290],"cfd50142d103b67eddb6c4d23d2f62db6072632a4161d172569d09109ffdf57b":[15908610,56864],"d01e76b19239943b86ce7cad17d9a65bc6aa747b53bd0a07f6ce36d87136e150":[15965474,5206],"d047f8965ed54f9bd12784e0946b2c9095d2e26b93930b136889a20ab6d40696":[15970680,5085],"d0c13ac14ee9addbd79c32d232c2c2af8564fd64f8bce57f7aa1f3d506b28127":[15975765,95308],"d0c5fd5627e68e799b5d4c77926743a976b08d34ee58aaaa2ba2d490f12e6a6d":[16071073,904],"d0cb678b59d59753c90739a02e860d4d69877e5bb9aa59259d627fa09e9b5011":[16071977,182],"d0d406dd4f30ea6840a808bcbc3ddeea8ec8cc10c84de12ab5d0cfeef7bb6696":[16072159,666],"d1080af38c29d19262077832d371592d1332c753ce06453e18bb9800c30e2c77":[16072825,904],"d1089505402b4cb871070387efe3c83ef74b8efdbf4fd45440c2ff8b00b513c1":[16073729,593],"d14bdf8caefad65ff735cff07223c8d3a740cb2fba79eccd5daba36cfaaa1d09":[16074322,729],"d18238b45189f5583b384cb01e30a51d7d2918e8c08fb75d21eee4f10550ca21":[16075051,182],"d1d65a35524fa9e7716908f525fe47c5726d2d3b9bad4943566d96f07ef4f20f":[16075233,436],"d2144b53ac5692a9b9c6729faa592bf9ab5b16924277f525abc2a1bdf2efdf85":[16075669,105964],"d232c2ddd56357ab317ca883f82abe3e07c9f583d203fbb2ff724a49714568f2":[16181633,153],"d232ece0a59822bbdf20923ab2986c34deb1408d797fb4d440a908648cfab148":[16181786,182],"d23730d646841bfd57f2a20525e59d19b68b1f236b2e82ec6f549b2de07f1b00":[16181968,1906],"d261cf32efb85d15c5256b019fd404387167830c5495b8ce6350d77f7cb0dcdb":[16183874,594],"d28bbc1711efd04df7065302bd89119c7a41618e0060574a2931fa60f170bdec":[16184468,152],"d2af7eb28bd334a8c338672ddc304d208ea2b45b5a695dd6f10a408de9099e07":[16184620,4640],"d2f090fd385a125365de46a16b6bef6ab47c70bfae523edccd0b418a25bdc68f":[16189260,153],"d36ad7efe8a3920167be664c69adf97f22d108fc3c9235cb5ab0085773449eef":[16189413,6915],"d380779a0494c86c4c7effe8d6d518e0788e1fecbc5b6d4fd57098d1044de548":[16196328,6291],"d38a4fa24587bc625b1a53b56cc75556a7115b44046755862542feba7262fa47":[16202619,1178],"d39735240357ac3e02ecf4fa40a2f80f1c839334e8767148a1c475974d27122a":[16203797,1937],"d3a191152202896e23c1c12f57f7875cc55c10020980f904128242658145c55a":[16205734,729],"d3b2ea92ec386819cf5437dfdc1c7edae62cc79bb1ca259139462b71af4fea13":[16206463,18642],"d3b4f7308b35a3105c66bbbbec005cb91288ecd8476e45e4e46019e1dda2d3f6":[16225105,6824],"d4051a49eb5d78bb051b0d4f667186e5511bf0dd1da070c9de577e083d6572c5":[16231929,27290],"d40ce1419539932ad2e15bbb2827c9f0aa6e64986030b54a69656ba64b443d31":[16259219,182],"d4321d502f35b62d0ec2c5db7e944154459175042ae4de66ef217a1ea61e5dd3":[16259401,11413],"d44a4c27419eba87b14959e93b02a42046e22fc5843a5586605b593f8d9f24a2":[16270814,11550],"d47357ed61545c7d9edc4042cb85ceaf6c1abdbf376231f99c8d9890396d62b0":[16282364,45598],"d4ba550f18859b6ab91c5454015ae1c7d06bff4fe2e423aadc4a84ff0a9701d8":[16327962,153],"d4cc41c19e53c63f550605e1eefac8093367c9f00157eae8709bafe24d3a316a":[16328115,1033],"d4d6cd71f40ee3022f53809adc9337b6004d1b745d3337288a2954b9e87254b8":[16329148,155],"d4dbea19ab919229b6832acd0796169bf55b0265eb7f3219637d272fba260f13":[16329303,665],"d4ef5888a7ecc406d8eb5173dc215c7b1a1ef1e1ddd3ab8b3a8aa6639d125017":[16329968,26480],"d512c3e78967e6a3e2750ea7311592d5210d24fc0aa8ca6dd7e360c985778cea":[16356448,922],"d5141dcf22807eb4d7314871e99dd9b1f165a355d2ba0c088f6d2f4e2b9cf7ac":[16357370,595],"d524c18f61e1a3d56c02b12319b23d0e96319f01e535647a95e490eb4e4880cd":[16357965,13117],"d577a2ccc2fae447f2c539201f6f3c90bbc50a5bf80ae3f4e6bd7941deb09c67":[16371082,530],"d5b9e511ff7701f72a25c74f3f5b4dd43f33ae686f0900b51651fa605c0f91cc":[16371612,922],"d5ec7d242925fb3425b1a0117bb220929635ebfb089163103a987aa520b5d1ac":[16372534,666],"d627ee05b2f4f062faaea7f1ae5e4be35cd3d53d720842eed3d6fcdb90c11fd1":[16373200,661],"d62a27133f68d67d30147518eac5099c001898f03485c72159296bb25fc44bfa":[16373861,48669],"d62a7f31200118d1e66630605780a79ebe0c941a33a6e1c596ff31b2d8c95c59":[16422530,155],"d6b353ac99e954bfef9652d252070d693beab485511bca3b09148ec52a1a38fb":[16422685,1937],"d6d79f2130efcc425bb0177dc83a0093cb305233932544e161162ba9474b938d":[16424622,165],"d6f8e36947df60a7eb714da2a8bd90ca48d88fd415c533ca24381e05a6ca9575":[16424787,5943],"d6fd7c0f72ece55d52229d300030de9f49a3a9908961e50c8af30bb819d0a768":[16430730,669],"d7354c0d67366a2eb474118a1161ad9778fbfdba47b9a20a2b28dccd61d3206f":[16431399,808],"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261":[16432207,5097],"d773565b95bc43598e3b52a3c65361a955d99dd8b392f8d6f303a5aba8860b80":[16437304,922],"d775bfe0794f1e1f77397c174907874ca85290c3ad1b6de336bfa9a9078fc650":[16438226,808],"d7b208f5e8fa6433390ffb23a9bbdc9ee469abd360c19ed56f115a2c4c40cbd1":[16439034,55767],"d81313a01339ff38716f33679a83fbf737aa1a98dba600122a9348a43fad9dcb":[16494801,182],"d81574238d071c45bc3abf20f0e99b11b61faabdc984be286a90888aec7ee6f0":[16494983,10229],"d819535b170965a2083000f6caa6b12f90884261a18117d948aafd98bdbe53db":[16505212,11552],"d81c438d06c05e8bffbcfdc17ea45c873a6292f6e34df1f08367c0a95f7e27b0":[16516764,3243],"d83d2467beb91b948fff71e8ad6912f421053f3c456f4ef0d652253c7a1b8ce7":[16520007,155],"d85bfa83452d95a4eeb9b36dce2774cf0d64189608cf962441406db9b7fe5a3b":[16520162,31218],"d85c26f2e28e4e08f480904215e2c10f6a9a7d6d71e7a46a7003290290c1fa68":[16551380,155],"d8753add6fb4489381d764e2d8abd5964e1eb8a5435669bfc1ac296d9739ffcf":[16551535,590],"d89223508651890042b9338909cf6dc863e232e498f9e9398ca13b02a94fcb1d":[16552125,182],"d8a608347b0ef54712d363215f56ea66c416625a758e1306b23644d22757b5cb":[16552307,8730],"d8be5aa9d960f24ec7931e2cc94e5cafb2e5a94aa8870fc39c34351f14225858":[16561037,808],"d8cbb549ed651915f8d0e273a5967e6243279f3a9bf79eed5e099135c535828c":[16561845,182],"d8f1404ff37c120ba753827f21ccb70d3a8829aa5c6f7e96b746a12df4d7e4bc":[16562027,4569],"d9032b03ee7b166d66159ff59d7e98ba35a3a66b35cbae983d43f0630747ad65":[16566596,545],"d90c5f37984e18050b3787d79d1a962170be74f757f224d6b04b4437c53b0867":[16567141,15770],"d916985a1f0a824fd16eb55c25869d582b3e2b04817ca160293dcec692e5754d":[16582911,743],"d92d120e7fe5a890320f920b59b437771bf413514563369ca2ea76e785116491":[16583654,153],"d968b82d1a1bb25a066835f489ddc6190f930f351a38582558f2ecc26ff57d97":[16583807,182],"d99b6625294e2e89f80c1de9dd39c78d22bd4866fa032a7a3f3112fb960f33b1":[16583989,182],"d9d38fda324dab1b9595cfae45e2279ae57a11fc0d4ea4ebe2ac92e411dd61e0":[16584171,182],"d9e7cf7aaff6afd819af4338ad677da4a161234f425644e90e80584a4961f06d":[16584353,4866],"d9fe6d7d747bd80c3397dfd35c08576b2292a07c3b67df4db1cefcbbf4789131":[16589219,1410],"da1c903c41cb859fd644195e7d1f62ec89ee7a2ec0ecb122e8b3e350cade9cb5":[16590629,11531],"da286140d6c351c7ba922289ca77444bdd47787c572290a1970287f550e47604":[16602160,872],"da2f011cafa328905636f1c49fe397e38ac42269963da6915171fde6742bbf28":[16603032,8652],"da8a3892d2c52feb9cacbf22c5eda37121e61a008156ad137cde153d47d02479":[16611684,278],"da950d82ade0fa72b89309097696a7eadbed77eb84fd8eb8d9854ad97e7eafdd":[16611962,4703],"daaa43e4aca5e9c5c2fc956c45b4b66bca3f516ce29fddb040a522b345c468aa":[16616665,50978],"daaebc3892e29dab92ff0d2dd0d095260c462544c23b01d64e02820562d05b60":[16667643,182],"dab7e5cdd910bfcd109f678031396be642c8572cde12b012e8f1974a7b0d32af":[16667825,11981],"dade6619a1307376f7ae9b79a7161d9229653eda499c6c998b1e91eb0ea8c8fe":[16679806,399],"dae3164867637c0c6037876c0f51daa687f399facda41b6c5bffd716e8d52b00":[16680205,4703],"daef7cc60fa39b19cf46b45552d23f265e50e117668550fae297395dd9461668":[16684908,158],"dafd313128b4a131dfdea7424a367fe466d7bc6d5d36b051bfa2eeb50faf12d5":[16685066,1069],"dafeec5de06b3e87181db2b23c533de998620f89ab7acbe91aedd565cb895f11":[16686135,182],"db2f4d10f41b998b3f6a2f53ec50ad31175d69a1f602717caaac12b48fc231ef":[16686317,1069],"db3f9fa1d945724e36b516220a63c147d357e3ff7e7a5f9ee1257066b9a00fe5":[16687386,472],"dba1ec45bb7980891ef29cc8c87986d2af3951f62588b0bd91056dc8e813f938":[16687858,7851],"dbce866b1b9d517008083a7600fc3c888713cd277fd7021cbe3801b3d99a70e2":[16695709,152],"dbce9c50584f9a0fb1077d51e3537b1e533e3cf15790d0af98e1fb6d629819a6":[16695861,1937],"dbde4d2cbdc1b75f778f86ad50034169573c5ff3078b367ca228e976fd62cdcc":[16697798,182],"dbe30b7023b2d753497fb908a9fa0cd39ad08db9d459a242ddffeb23824feb52":[16697980,56847],"dbfab38538dadc71c378dc0a667c5863af32020801bdbdc84594a12fef3a43da":[16754827,922],"dc261d72bdcdbfd8dd154f8c7c70161f8ad7651102e037c41086b79d7eeb8192":[16755749,182],"dc401e0ecc5024066f82c03f08cff32a610714c2a1e6d2b5b625d32f43e151cb":[16755931,153],"dc5148fdca793e695a353ca1c39a33be663ff138cb6c55dc1aef4680733ded7a":[16756084,152],"dc62dc04b6d519a8cccdd4d4a3f1b1fa1a0fd830ca0ae5b01058e9d7b5347b98":[16756236,666],"dc7fe3abf8cd2fa4b8684b2bff66d8bcceb47f3bae0be86f5c7f43f770a1baeb":[16756902,182],"dc9b64823a026d2a5f24bc4ce2ff604ad5ff615594ea94034017078a57dc3309":[16757084,4640],"dcc86386414393f586278a8b126437c069dff05fddde11d394088c7325688b4c":[16761724,182],"dcce5233548eca145eda59af57d62e425367acbd25161abfe5fcef634547fb7d":[16761906,152],"dd477d0f6a8458d7f08017856ea567c847968a47135ac24ed6afafb6b20b2b8f":[16762058,666],"dd5b21ef4dfb44f68ed82f9b84e73330a2a7341bf5a5dc350bdeb1980104fcea":[16762724,922],"ddbbd2e77f026e42ea002ce1b43457362f9cb8b4f444487ed662ad8d7c22d9b5":[16763646,182],"de0377465c067b511f8b1f2727ceee7caf1ec3d1f896a6c31766ce05a72b5f1c":[16763828,5612],"de046ff003c2e252ddd982d03ac86fd4b8b63050d8d18178d99131d6d1f2ef38":[16769440,650],"de4d7e77c634604c6cddfe7c80705957de98c3c698b07caf255f8fecc544e336":[16770090,1339],"de6dd4b27954b14f84fda74885defc437b9c8944ee786d310269ccb01d2d1ae4":[16771429,182],"deb0aed714e2ab0ceddbe578339bce101f141dc7be912a664ebd69e5906d8c67":[16771611,872],"deb69bccb82997a4b0625b06dfdc406b74c91ea9a7189ec1653f646d5e62cbc2":[16772483,1906],"dec446844fb177d2d11b3efb7db584da136e3995f95b8505470a1cc381ba5129":[16774389,182],"deff13703dd0b3c630077edaffafc1fa9f2fdfde297b9a84b45841a428715965":[16774571,12219],"df1120b999460860e47949b15f330c2467636af660f9de24708fb2960d6e5a56":[16786790,3995],"df66f616938ca912e5d2c6863253a81463b12856de8e781a29997a46e8f5bada":[16790785,155],"dfc2adefb687a9f0243769321f66f0b4d203dd2aee2ef525da1ec9a86dedaa05":[16790940,152],"dfe384bca0e1e6245624b6a79f81a99c5fa0bf60f5bc0e04856a53d888e92a48":[16791092,155],"dfe9cd70e8d2cf3a7fa5c821c87acac71cf6aaa83f30f8e75bf6e933196d90dd":[16791247,667],"dffd45a3f68c9fb49cd7ff325cc2f552ab71f5292b4445e3ca1ac6e79e8e509f":[16791914,1990],"e016638c1bc324a751f2e28ffcf8f963a61bfbbcade64fb654ee88ec22f5ac64":[16793904,76991],"e06293a226b1c96b12b2ce6037f5ffdbca05c34b57e0493823456f2c146a4ca1":[16870895,672],"e0666c9e544cbe45d3e5509292dedcfb88a651b85b381a9c5a2d5610c98cc8cb":[16871567,45675],"e07b29e75087e6429a5bc12f0da76e5f028523ad2a5b75b7c4772285a4039fca":[16917242,962],"e08333feae7f79b4db8395c7d506f5f0e4ec468c5da1f2d3508b29c18fa37c17":[16918204,922],"e09c02c9e6a72aa35c4ac60ad8622970105b21646d146930ef108bb9b6f4607e":[16919126,500],"e0a00209354290a7c4eefd30dd0175e1fc8a6bb68adf3045522ea2fa0ba8d5a7":[16919626,153],"e0b723198b53c60945084cd4c1c83ca0be2529934d482a3d017ff99f6b6f31d0":[16919779,3995],"e0c5985d4a805d33e1264ea4f6570a0b299cc14c439ab791e2f696a6c202a919":[16923774,922],"e0c61506bbf3fe83ae5425120174331a4abfe75f3e794691484414af3a0acd53":[16924696,595],"e0df9866c22a298bfa3516f2ebf9a3e893b13a14b0127a8e5b928330713f9de6":[16925291,182],"e0e32e1058db61695a065366843168ae13ad755344ec880947b2705de227e4c2":[16925473,182],"e13bcdd6719ebb0599d7be0bcce88b7d6561e800b003209109d46f09d8f436a2":[16925655,808],"e1672ee0ba4ac36553d0ff6c16a64901e0a1b968aab3461acb943f7cda452156":[16926463,76516],"e1d3d8958ac3bb2bdcd6896cd27d2a494c86d5b58d7f066d4d61cc33cb85099b":[17002979,19486],"e1f129394abac0092112b5009c7fa602f2cfd3b87eb370bb7a2ffa22982badf5":[17022465,409],"e20acdf832cad3bc079707aaf551c092913c18ba7dcd19fa1d702d07ed899b5c":[17022874,659],"e2354eb096af829e15b3d1138cd3348e1789672ae4b10d26c0e4b696027a2b86":[17023533,666],"e29d53182f44d0b8e9430615e3c2ea9c50e8b024f73b75f52128cd3d0f5d0537":[17024199,152],"e2a5f6651931c32bcfc421d394da9781fe753cd069f7664a9cd0622ea69be1d9":[17024351,872],"e2a9f42937b3f0d8707495381fc30ba5373c7bd1c8dbea2d599147df7793aac9":[17025223,155],"e2ca1d72e4bf84acad7ac89414782a630681f6b447044317fb900418ea2b8fed":[17025378,8461],"e2e63fc725000f036533110366b2bc711836e51e37ed6645ab598f43235a0939":[17033839,152],"e2f31ced3a1b76c901c9deb8cd5d1a0ec6fe05db8d840ed252ea7a382ab53601":[17033991,22680],"e32f2d92d370dc99601eb1d645fbafa5bfe70fd8c4652a22fe593bc49b1e90da":[17056671,182],"e32fe519cfc4d592be1ed0f69e0132aedcb9513308f8c35a2c120e834afa2644":[17056853,182],"e3374a27d7d0f882064cf00e4f95f29623db5d5ff23a06dfb5050d5f6639463a":[17057035,67450],"e37f73767e580ee03606df2b87dcc2567375ad06175b4ba49ac0068f81abb57b":[17124485,801],"e3fd2aee0cf6a9fb3ce3122d9151a16cb38dee7ee99bb26601aca3058ed1438b":[17125286,5214],"e3fd98fd28ebc7ff92befcd40a0f2d7b202619645398362d7b789bcadb724ebd":[17130500,666],"e43356dfaf1c8a8c196910ed80570a7e89368f1e40cfc393804d7e979fe8a44b":[17131166,278],"e47c7af7f38adcfd758a20f08f76eb3d1e53d1ae19744b09394fca65a8b23a15":[17131444,153],"e492f211021df73f9689d12b1466ade19ad8fe6724da5e11881b7b80cdd1a15d":[17131597,661],"e4c0d33d6e00a0bfdee49912f428e375f3d6451a31e9d753134963c174b5c1f4":[17132258,152],"e4c8187d77c9dd5244177f069cd14579037c79ae5a6e6c78668a30c4303e9f4b":[17132410,1069],"e4e11f0f7cc8f5e702a90f8860a56f3220e0e7deb04a9383b462857040616a2c":[17133479,79088],"e4f46d71391ba3be4f5014938d0bc53d78ed66404dc7011676038c47578d28bc":[17212567,2377],"e4fc96d1341dea05e8651f9fae4d8ba7c291762cf19f664b32e0bc7fda804679":[17214944,962],"e5131bdd6b4ef5ce70708d9ef024ccd741fa35c91a6d8b772b32300a18156584":[17215906,29754],"e5181ff4a21adb9dbd040fac3f0a5c958ff8273c4c141fe9f4a438eb2b02d800":[17245660,665],"e5230ab4272f679628e6ea4dcdb97335ef0ba5c7f4786f7e472332290f0722d5":[17246325,962],"e539c65a4600ed1784b9cc176cb28f24c140f514f9783360f1f5a27142f89f68":[17247287,9287],"e57b2d656e8e43fb060ab63d0a74195b3e9a119779280db4ddd9b139eec54611":[17256574,26293],"e57c429bf690aa2bcdc7b070e82350d415d259bac4abb53567e4a321738ec110":[17282867,841],"e582e40a241346ddc9877893e0b6f583abeb112dbc20113867dc72ec3c767379":[17283708,152],"e58e7102ceb8b63fb8f9e7e43d0c02a0ebcd04b66bd1edefca593b5ae44558e4":[17283860,182],"e62f08c62335d7b934fc0263d14fa1185d36ce0ae0378b4741885d298f51fa3d":[17284042,16699],"e632e7db9837baf1f1d1dd672102551d1bd163bcf54eae66e2e23b6d67c86c5e":[17300741,729],"e6448ff36c8767678aa7e2cc0c3a1300850d42e5b5e42107b85f93168c0ff856":[17301470,590],"e66b428aa26fbaeea433257ef5e726883dfa9aefc62d1f9eccec8d27e556489a":[17302060,153],"e68676c37123944364fbb8ef4e165c0c594ce7cbb7146245fb16cc06739cac92":[17302213,1937],"e68774b8d234360e2272140ebb9cf5697b41cddf1f0786d876af36b1f9964e6c":[17304150,4944],"e69889078f43de5c958a75fc18b5cd16b9ada2088fe9fe249fc0d9444291706d":[17309094,666],"e698f109b25b624b505af8daa1c188d47206a460612184c7bed933cea5d5ffc9":[17309760,152],"e72d2ba3480d0e61da07f690da851f7a787e309d14fcb50718bbbf79734f188f":[17309912,1069],"e73fd11df1617647e40a8f64b8821734605bbc23317840b08a06c8af1ccafb76":[17310981,858],"e77188b2530a95b93d9a774eab577093c4acfe4cd7261d3dc8077deee00ea279":[17311839,669],"e79a58ac8ad0c50f897901451c0b0b0f44757ab69818fe49282b3d41efd1b1bf":[17312508,593],"e7b38c4393ac64edbc646dfcc237b3551d3841c13880bce234cae11ff9e7801d":[17313101,922],"e7f77ce60242821d2476bde34f57188576003e896a93c78bd7dee7b304ea5c4d":[17314023,182],"e80ebde443bd0ea50df4239ec1df80abd5ea59b8d70d485890d705e9490b5597":[17314205,4866],"e82549941deb92b03ae1705394fb4eff3da520f69700b2eed761b3ae78ea4e2f":[17319071,1857],"e835eab10ec5f271af85dce966538f70e343c0d2e6442c3b736ef58a5647d5b9":[17320928,589],"e85933b18f5a167c1874c983d1881675534802627ea26260939d1ce89e2d9ce6":[17321517,155],"e88c3509dbd1a91038d5daf1fcc667c45517350ed43900a7bc0727c62246f878":[17321672,661],"e89a4aa8206c9906d45977b99b7ae7afc9f1744c775da52674393fd992dc1fd4":[17322333,182],"e8f6d3ba7c698371ab1315fbbb8eeae30cfd8683ef58ed66e3f3718874446403":[17322515,4803],"e9007f3c3ed117b885b5693d3b5aa763f563b986c61a05b4a77b70ca7193d3d5":[17327318,15175],"e91ea9b0a83fee9811b57145b3ca8d43516db0c0f062f7ea700704e40d4a8aa7":[17342493,182],"e92c8773dc9d09a3289e877e97f76a056b8dd9d4822b6c224b327cf747972b35":[17342675,9033],"e9997799e9a16e02275f9ccc7d7f893af10b00537cadfc93f0a782b116610f0f":[17351708,8650],"e9de02bca877750e753900056301f520ced66c94128b50d1256e7ad1d674bb9c":[17360358,3835],"e9e8397be3fefaa27b6009ddfcd9f9949a06d531dc07c64c1f6f13e8d43a33d0":[17364193,5872],"ea26a080c3011366389c9d864758536e882ce366e3fbaf099593512e7a6215e7":[17370065,808],"ea51f7c8f4ec8145e2bd9243ab232dc7b2640bb09c2b3949f8d4da3b70940adc":[17370873,182],"ea6ea6c09f9660286497a07b7b30aa762658eb33cd0e46299885e48594ca5a72":[17371055,3956],"eb4ca92f81de67ad4082efbb23d7619bf0f206dd607122b4e1872a24ee1787ee":[17375011,8838],"eb6503d8550e8ae440f0a6a9f4f6c18b23d18c43af4b08904ee17055aaf509a9":[17383849,152],"eb982c4abd90f069ed07ba89b7fd809d2eff0d277a3d7240b2e781af90d33399":[17384001,4640],"eba0c9432525af646bd212cf7103c0cb9f7726adc11db5352779581741a8f8db":[17388641,4944],"ebbbbb69543ab7a9b2aff37516faa28bb297f07acbdced6d8a10e88e8c5074d6":[17393585,1042],"ebc2416c3e4bb42c11b32453ea5eb24de8406f3fec04eb0ad555af039f4ce0ef":[17394627,182],"ebe6ac8de6f0ff18ea2c6413db64b29150ae29add0b5107d68c449f67b7fbb72":[17394809,729],"ebec0415a1c5b7e3ee50db6fb3f47307ac0ef3120ec26f41ba2d307cadfe93df":[17395538,472],"ebfe35fb0dc78c17d86093b56b18a85de3b27c6e5ae6ca5570760a847498a8db":[17396010,5085],"ec3719633a6e910e550f100d8393823afd477b930ca45ff7da752a1a944df986":[17401095,155],"ec558800ae0e5d2cb0cc76d27acf7a03b5e98f69b8ad7b2076a0c7d19a29538a":[17401250,13040],"ec7f6eba08072fca5e00c2518a34a059f3768a18473cf7a19452b476c4855776":[17414290,58463],"ec9a5f979f25df7c1df263537bdf27ff1b6292dbc33c8519294f29bd49af1594":[17472753,5047],"ecbc38aadb2916a816395ee7d44524504cad2f2ea64a0eba433ec74d48a48b7a":[17477800,182],"ecd1131e1202aed052065e161f8c2c5043e926ac08422d41bfaf0b9f194827ab":[17477982,729],"ece25ee101157204c9742e958dae8fe898c5f77f400d6513d8a14742a464d6e0":[17478711,106114],"ed1c68648258a7ac19741b966c321db9c100222da858a64cc0604b108b7998fa":[17584825,15149],"ed23f5df7751f7f17942106f335c30c55d774e87f4a5054a9e017a1bdd39dace":[17599974,76912],"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8":[17676886,4891],"ed4e1767d9f257439d2fe1a157104d2bbe511b8296bacd6027ba490122386f3f":[17681777,18805],"ed651466451038201adf25860b87aa94a937b65e8ce2ba972c2d5b0bd1a1bb99":[17700582,152],"ed675c888ee889dd4e8624316da01312a485713821b40990f9cd83083d9ae7b0":[17700734,3192],"ed967f8823b1bff8c81aedf107b73a8a27abf20aefc5db3a1d75985cee972719":[17703926,590],"edab73e25649966f3c5760edfcaa50cf91af0a6cae38a013d243e7e05c5a0089":[17704516,158],"edca14b88b8b1c49625f4a6c1445716b16ec09c67fc22a01136492500d7e9f46":[17704674,155],"edd2ba44a2a0c8e6b1f6a328a617821ef6949cf4455658fe9c7b9f80935a0b97":[17704829,3419],"ede5d6f7c642bc1ae88d4737fc899bdcc20ef363ef468792804cd36f02350c41":[17708248,589],"ede6f68b1d885a44d4fc2effd563a93d50c5b5ad8c31cdf2f07bb0f849109d1e":[17708837,671],"eded3d59d3f2c0ac9197b869730e53b944303a7822cdafb8a76846afe275d687":[17709508,182],"edee39d7c9ff1e799ddf7780b34f986440e6b4cebb0b31540d5cd2d4b47d785f":[17709690,14291],"ee02743510500bfa14b0a2681fb3b6b98ef02eb0de78c90ef8f16a6b471f1051":[17723981,409],"ee120f47ae972eb522d7434f8b88090ac6330b202121d45c9c500357a03b8815":[17724390,22820],"ee175e2e4c9f2898cbf0a83cbfecfaa24d8bf7d687f0f3b64de43326f24b0f30":[17747210,26820],"ee196cb5bed0034930f494dffdbed15ac16bc57339d26a6fd206713e9d194ad2":[17774030,29574],"ee7bf934d16f6b3f86c33a5d2066db0cfa3a85deca612c4af76a61d747b1a64d":[17803604,278],"eead739ea7706ca2fab7728b911d11932c67adeae8bb9189c5fbcaa1c289bd34":[17803882,4482],"eed098835377ac911f9dedac8e0b75c887f769152cd967da9283982b7e07f127":[17808364,962],"eee0fc991bc4a3fc88b79d587ac7ad8847a161ade7cc3b964a8ca39df6a2ee8e":[17809326,278],"ef16ffdabf6cb60bd41555b9be440af9f4ebb7da1205042624221a571ec54edc":[17809604,153],"ef2786a3f8eec1008d2d4bfd5f82a7bf88c7d2677c480044c2b12fa02487819b":[17809757,872],"ef27d6d6ba3bf370d126a77943834af31b3e3e0343b0c17822728b6cdd12d080":[17810629,922],"ef2d1f453a40c830d37c5321508c316591aa751e63dc1ebacb2a09399e14fbe0":[17811551,72885],"ef343d029e354c10a2f22447d656512d90b4727b851871c5320b05ee09c97f74":[17884436,5814],"ef3e42097fa92ad4cd0d6f4d5b4d96e61e120cdbfc89f9b256058abdece4042a":[17890250,5085],"ef4381b6fbe72701191f5c172bac76b9685d22a10568c06335a0e4acf148c405":[17895335,155],"ef495f370d7f0fbc524fa3b79e902b371593f7511f5b81a7f400a5df8ba82f64":[17895490,155],"ef7454910e2f661fc31a1f1d6d6a58c8824f0c3ed137fde4bc40f94b8cae6b73":[17895645,2377],"ef8a8e2f8e545de2a663607bba55de512815c8f948c21176a48210c201aae4a6":[17898022,22354],"ef98b9d4b10876cf71d6e2cba71b88fa89dcdac7a6ab1f0eb6fa6fcd84c48135":[17920376,808],"efa6524c8c27067b544639282da4a97e582211d448adb53d8ed055ecdab63794":[17921184,152],"efeb55ee16b2b444a3502f2e63424bcaaec34c9f676dd2c74f00944dceec5a8d":[17921336,593],"f001fa5e3a08a732e14645aaebe1fb7f3836645a9d7294a36b80f33fe52617b5":[17921929,152],"f008b8020b7c740eb4c43b4c44a4b4287cf81306265bdf5e69cb4c5a5e23257c":[17922081,182],"f028eb74ab77ecef9deb6a45f63ee4e2859f116596a6530c42b77ec906edb85c":[17922263,182],"f05c2d51658eebd191c57902044bc19990526921802dd989fd9e0e5efcf933fc":[17922445,152],"f0641fc8a646a08a653d7a2ba1b8fe68da672f027919a1fccb53254e7bcd456a":[17922597,7918],"f0987b243dce1a039a0c78f8100965a895c03501bc378b4217765c5c84982d34":[17930515,50947],"f0b8a3c47a244f6054199d9672f8fb7eb24f451e467af370d2026e72b30601d2":[17981462,182],"f109830c9aefdcc025bc0022016474782c060eca6461a0c84a186369d8f711f6":[17981644,102964],"f15c18793c01fa41a0ee170332697318156a0f87e826ccbdc824a05358febd6e":[18084608,103023],"f1b16bab91743a5c12abaf26150bf40b513df99019b03e4c70825208238a8121":[18187631,15000],"f1cafd936d04cd6626ed160a0ebb94f07837f9df690ab2ccd91c0458c188a882":[18202631,23695],"f1d46a49b8c7584c9c0b045c724a51a7e5399d17e15d08ebd0a6a1317c15a7bd":[18226326,6088],"f21d63b776076c12d02ee2c6d5427c8da7c007863544d2f6ed2a57754bf597ab":[18232414,152],"f22e593a636a23cc73d78d2e0e687cd1c4f566ab623b6a7f49e279fe88286fe5":[18232566,182],"f23a56b7192bb76b2bca2e1e30587339024960bc963f00249e35eda44620ee52":[18232748,4866],"f246cece5441ae02652e05a6c32e26a2f08f9a62b8051b0be33fb411565166fe":[18237614,530],"f25d150b567e0cfb48f30845e7bcd655d6f4ed39d93f70eea4f932ede27f3905":[18238144,872],"f26d6fde72e9fc4f86b6909dc51455aaf37c8a17ed31d6db11e94d9a0408b324":[18239016,666],"f2f812fb8d99b1aa1f62d3d1dafdff4cf049d0956a4031a4e9450db3f5210a37":[18239682,409],"f34bea987b5ac98205b3d8a31c0e5851b4bf2f8067abd6d2f97a007daf76fc3c":[18240091,958],"f35cae407d0fad05d77ef4fe3112b740e89daee846d915b1ea1747d2e75cd138":[18241049,918],"f374c6dd1e259329f8fceed545639baf1b4230bcbba57ac79b0f45c40218ce0e":[18241967,962],"f38b0d5a644c8edfdf8d88f5387838f0df2f638ef556620264f3a6900afd8c6b":[18242929,666],"f39ead3f6661b23c1064f1c5bade9ae4df82bfca5cab5cfe393bae23b8b7be11":[18243595,83672],"f39faa23f2f789a5958bc84112d67d9f6c02d6a38c72eb3ea1d3ac1465e5a4b3":[18327267,32462],"f3aa3c42590c24319c16d0f64ee1cd6c4a12eb93b72acd10cf265adb26bb84b0":[18359729,32043],"f3b88a7a02788d3c15aa142f2b1ec6a89ac03423a4f19e3f9333a7b636eef3a4":[18391772,182],"f3e5457d8b0cef8103196acea0f440190fbfd138a9d4575ca9ef43406d1cf54d":[18391954,808],"f40dd5e818129f69b162f581c57cba6d44d8a39e62862dd20894cd5fefcb8e3e":[18392762,155],"f441f533be6663dad0eb64343326e0172207f01a22d2a8d0cfacb685499c9d7b":[18392917,590],"f4600cb7805c125686fda60c33a201db7269cffb155e15d915b571708dd1315f":[18393507,841],"f4968ee7d1cde3c20ca3568f05c0b43ccd25267f67c787ad7b1c4bb717dbd598":[18394348,1069],"f4a057e6cfa9336afdd9a3af3763847e6445fa75da78ade52abdadd8f887bfa1":[18395417,182],"f4aedb26827f9fdc044e31654e7c1345c2f6d6e43b12108a089808e8b324bb7f":[18395599,5741],"f4bc3c0aaba474dfba963d9729462073ef3f8ce3de7c947ca2625a1125acfc79":[18401340,34852],"f581419a200dbec0190a3d8e3451537c8071adf85cb0c8d7b0d947cfcca529ca":[18436192,219],"f59384faaa19ba34d628eaf6763d49fa637c76fc260fa524c26bbe6dc7979e28":[18436411,182],"f59ab0124f75ab4268b8ba98d6af5ccf12ef64b61ee58885fee69d77246808f8":[18436593,9303],"f5b1be7dd933943a445e4b99312ab544f1c8b2296ac2fe14f3a3ee4fe733e324":[18445896,278],"f5e2132da4e24805a9c5d93e78db873618fea004116575dac3a9e60446b01c20":[18446174,38103],"f60ebd2b77e0dd55b8e4464e324f5818b6cc6cb79e0dfca8dfd7edd135c128b1":[18484277,403],"f612c5438a6c82375502bfb7a7bb2a487301c7460e39ce9b9c26a7db3b959fc6":[18484680,155],"f622e04cd14d3558fdc638dc1ce64ee7198a43d6bfb7c9e91df1dff816f4c87c":[18484835,33883],"f63c5831667ca09770a34280e05d6a4e3603739ce57fc1f441edf5b5d65b80b0":[18518718,1069],"f6465908b435afd08e3c96ede0ea49889aa5147f2bb215faf03fb983d317a3b1":[18519787,872],"f64e1f03a1390adb272fdc6562a83d949641740535efa1281cdbddd85f3506e4":[18520659,182],"f66e5812f0b1d528c46f9377b7f6ff2595f21ee248ff0c9e799672f01b1edabd":[18520841,590],"f6fb47544e45265d49a84ec0e6440d1a79491e454de5d3e3024b171f8c85f7ba":[18521431,698],"f70d09070025af1be89f4b378c9f4fc65f94b03ffc98002d56c0f99aeb3f03b8":[18522129,152],"f762bd207827748390f0f6b80d2ee0cce44aed0b609affc3ca7cf39e2c11e958":[18522281,25232],"f776d8022f5672fa9a1ac17c722c6680a117080e136dff149a269ef1631ea71f":[18547513,872],"f7f0bd506f6bd2bd041a15769a12f54886397742d41488290a72d630710e95a0":[18548385,2333],"f8147193d1c39b345155fef4e2983a2fd3c5650462487a5c17d091524385b77d":[18550718,182],"f84539ea81b308565fd53d88f6a17a2ae8f19739d635ed0cba0406a099b531ab":[18550900,152],"f89a2d60ee2e674719a0d08ed737185af1010c7db7dd074987e77472997121ad":[18551052,155],"f8a07d06d082e8397ec1873c4c0c2a8f8b80f60a080c4bb35799fad2b7c74207":[18551207,18872],"f8bca04c89b28f477a53e2ac2d1f05b9f39cdc59e72e93f4c89cf9eb8571b733":[18570079,153],"f9040ff53a098c7360f034b85614337ea60e2734c3d7eea01e24de0d1f02ac6a":[18570232,155],"f90491ac8edc64950fe971f83c79c65a5ad1962df110bf88d9da7cfb3cd19217":[18570387,8946],"f914c6c615c7de8cf24b0bb25e3120e97c68602b7ac669d9c5cd8383b97d8415":[18579333,182],"f92335b9c79afc0ff0a8e0ace24bcb4a22505814a1010fdc733fbf279f134188":[18579515,5085],"f994912e9571d5e1345399971b95b9d00ac87292ba40b5ea9e09e4ae63dc08cf":[18584600,904],"f9b963cff641b6476b8a60a34e87be76cb9a071cfe5db598fc71260799fab532":[18585504,12017],"f9d5fcc236e02cf32822d0f5fdf9275858baafd5b61f5a969544bbd1d62c2e2b":[18597521,182],"f9dee44d3c4849bd2fa732cea4dd6bc8f34f18e990f39a63d7931b8d712b47e3":[18597703,988],"fa155fed083b1b146df1b5c5999c69472b231adc8164568689b6bde5ad3279ba":[18598691,17396],"fa311aacbde75f55a6ac61c809c9cd9f0509eeac287c1031361d7cb9e1ec4b02":[18616087,153],"fa65c5aedbcd4a662a647b70a87930b760a7938cae03fe50f4aac15c9131197b":[18616240,4640],"fa7caa9a01ba44eafd24751ec593eb5345fa649631ebc095b27033c6bd0f62be":[18620880,729],"fafa93bee551f7e4b615c2145426fb49c7b7e316e6fc7ad701a2352905a1736e":[18621609,153],"fafd87076d293a026ae6a6414d9a59f31d90c8a201c16a6167e90d03fe60aebc":[18621762,590],"fb028707189565339b2dbd7e63e8baa221db33dcbec0f98f87ac4196f3dbe874":[18622352,8442],"fb2f3b76342a100eff769e91c7cad640ebd4ac531fc85d525378c99d3da870b0":[18630794,4578],"fb3ec1a36ff4c5e21ad2f5507b94a0ff4090e40c3393dd18d2dbf739622ed185":[18635372,152],"fb4aa1182902dca4e0ae0f15304e23b6fa162fdfcfd9b20c54d16696eea10017":[18635524,42734],"fb57ae93bdeb367b88291bad6b384c33387ea9f546a7faf666118dfdb7d4dd27":[18678258,155],"fb81e18918af40d88b17a6a973d0066643feea1f6796d5dd3b61bc4f2f41e570":[18678413,182],"fbd3980f82ce5a6f04c9cc969a11073bd64eb043a529f609e68e98c0e6e06778":[18678595,9553],"fbe075025e33525f74179f1505d87984295e5b10d3c021a45a938e2a27af97ba":[18688148,182],"fbf8fe3708c15523629120f6646531120478289e95316ca467a4d7d1047c5745":[18688330,40070],"fc294d11cf0be572ae2a77ae15fde2954d2d151c259ed0221e1e9e0c0d5dcff0":[18728400,1069],"fc3191cb528703710273fa565c934931fc18f232a84bb4d2570b37eb4385546f":[18729469,182],"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003":[18729651,4997],"fc6eaf5be5a810dbdefd1181a4c7073b79ca42579aea16f1da5a50ed30f913ad":[18734648,24271],"fc84533dbe7fc3dac061433bc0c1f8fc5bde8245ab04b55fa0c742bcbe63d0a2":[18758919,407],"fce5755175a8d8c7fd2007fda50c0fa054115d645a9208c963f942d9fcc0ed4b":[18759326,661],"fd236e23009498b9ddfa0efa3ea438ea1cc8bcc6f35892a2aaca6670eb8d0f4f":[18759987,155],"fd2efeead51f9f91643b59ebf5aa5496e26e312a2e546ae064d2b246cc4d4e69":[18760142,970],"fd36e7178bf2daed94449cb4848d8b6b2247b100a4d36596f207b80792d16199":[18761112,47063],"fd37131bec55479dbd369869d2c0fa525f6512ea20d50eaa734acf5fc3421eab":[18808175,52643],"fda8e102710267399bcd26c421539e9d5288656c95b851a7d8b9dbfcc8504668":[18860818,1990],"fdfbf6430f6d09d0fc735ceda08f6ba029c00346c4548cce398b647da0ccd809":[18862808,958],"fe22be01a52440e397c0e13e610615292ca0c85a09bcdba1e6760a5461b3861f":[18863766,958],"fe440cd1ff613b544411bcbb210d7fbeac16036b945a163437a9462cc7f2896d":[18864724,155],"fe6954097a004d01c836063d2833c9bc8476458ec8019470104e81054b94fa0d":[18864879,60412],"fe7d25f2758feacfc7ae9e974cf7ee9c5ac93dc57bebddca126a05ac5516b728":[18925291,35126],"fe951b7bd4a25490821f5174777888dd2c87066733b393bf3009954694f9db0e":[18960417,922],"fe9d5609076f29cc80118ccf016b9dc3c2fb2ed8d180eb2a22e8c14aa2734ff4":[18961339,24268],"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef":[18985607,8958],"fecb57f6b2ccc05274afe43e822922e812a353ef428c458a16622273777ee424":[18994565,153],"fee27dced79dbef4520b78e312f985a8004462fba1a8d8633d2ea05639a979c5":[18994718,7003],"ff0e2e5e8660e7185fc33bd4fbcb40c4c8ddfcd4b5c5231c31cc4ade5c1d238c":[19001721,278],"ff3f0a63185e043d2544437535dab59d81521929f72698be5466d633d4be0833":[19001999,671],"ff531de02aecf6fbec0a3580babdbc0e08954c903843377182445f02478b5d86":[19002670,698],"ff83494adc1c5e3514e322b085e3a4d0de257ed1eed408a0d4ae9d6e37dbbc4e":[19003368,155],"ffaec1ae092ceb8d7206f52322a06859a341fbfab7c17e2f553b746090d2a0d2":[19003523,9002],"ffc8ba118af754d5a181d2754f1ceb69fa6a2e58bd47aa16a977289acf72e363":[19012525,399],"ffdb05f25535d6ae17fa0ff5c66bf263d282104ab8de00f922d885f1ab4fcf59":[19012924,858]} diff --git a/.napl/store/objects/pack/pack-38ee1e7d21312479a2fffe9b2989dcdceda176b846a90169d3fe4b3daa94dc00.naplpack b/.napl/store/objects/pack/pack-9d671ec2793ecad5e1bd1884aa53995320c151ea4b038069eec7bd04b5ff3475.naplpack similarity index 98% rename from .napl/store/objects/pack/pack-38ee1e7d21312479a2fffe9b2989dcdceda176b846a90169d3fe4b3daa94dc00.naplpack rename to .napl/store/objects/pack/pack-9d671ec2793ecad5e1bd1884aa53995320c151ea4b038069eec7bd04b5ff3475.naplpack index 8f07d5e1..4aa4b9d3 100644 --- a/.napl/store/objects/pack/pack-38ee1e7d21312479a2fffe9b2989dcdceda176b846a90169d3fe4b3daa94dc00.naplpack +++ b/.napl/store/objects/pack/pack-9d671ec2793ecad5e1bd1884aa53995320c151ea4b038069eec7bd04b5ff3475.naplpack @@ -1,4 +1,4 @@ -napl-store pack v1 1888 +napl-store pack v1 1902 napl-store tree v1 4679 {"entries":[{"hash":"f0987b243dce1a039a0c78f8100965a895c03501bc378b4217765c5c84982d34","kind":"file","mode":33188,"name":"attribution.rs"},{"hash":"e539c65a4600ed1784b9cc176cb28f24c140f514f9783360f1f5a27142f89f68","kind":"file","mode":33188,"name":"authoring.rs"},{"hash":"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2","kind":"file","mode":33188,"name":"blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","kind":"file","mode":33188,"name":"candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","kind":"file","mode":33188,"name":"canon.rs"},{"hash":"0b8190e7989129e7baf4eed7046e19b7608067eb5d13bed95ad416118bf34a25","kind":"file","mode":33188,"name":"capture.rs"},{"hash":"2e5b5b54aebea3db240acc2f2dc944df07b908bf97ba2067517a9757ed8c7dcb","kind":"file","mode":33188,"name":"cli.rs"},{"hash":"7f8fe1b370188a60f00e524cc5c8059ca17a347b97915097286fff2c0ec7e290","kind":"file","mode":33188,"name":"commandset.rs"},{"hash":"f762bd207827748390f0f6b80d2ee0cce44aed0b609affc3ca7cf39e2c11e958","kind":"file","mode":33188,"name":"commit.rs"},{"hash":"3c0db9b5d179e2f3b7123cd8e08844ea9ad28a0a6ffbc7e5794738709f5af1f4","kind":"file","mode":33188,"name":"compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","kind":"file","mode":33188,"name":"cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","kind":"file","mode":33188,"name":"decision.rs"},{"hash":"bc274cef6c4e5aca63e7c46d81e1f86e118c6ba079e7a4ccd4e9f14a8d3eb21d","kind":"file","mode":33188,"name":"directive.rs"},{"hash":"b86a4431e286225b32bae5cebc03298584fce063e8d4ee2df4633e18143f0aa7","kind":"file","mode":33188,"name":"disk.rs"},{"hash":"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812","kind":"file","mode":33188,"name":"error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","kind":"file","mode":33188,"name":"evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","kind":"file","mode":33188,"name":"graph.rs"},{"hash":"824c95b7229ac2ad5b1cc518b296443883fb56a110c86c2a2e67923c0a19ea07","kind":"file","mode":33188,"name":"lint.rs"},{"hash":"2ea965827e5c5073cbcacacd22bf2318a9067874c901b4f05f67268e99baf92c","kind":"file","mode":33188,"name":"lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","kind":"file","mode":33188,"name":"main.rs"},{"hash":"6fc1b58dad295d780c917c10ac0f793667e46c455349cff3d42e6855501a91bd","kind":"file","mode":33188,"name":"maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","kind":"file","mode":33188,"name":"mv.rs"},{"hash":"aff9bd5b095cfbcd34114cb0c34ac87b1716fef02f0b452888c502e5301476d2","kind":"file","mode":33188,"name":"objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","kind":"file","mode":33188,"name":"paths.rs"},{"hash":"93f11780827b58057b742550c206f180d5fa1659b216e90b8cfded514400be21","kind":"file","mode":33188,"name":"porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","kind":"file","mode":33188,"name":"receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","kind":"file","mode":33188,"name":"slice.rs"},{"hash":"48d165c0c10ed38a4c594954631c4aa3b9a3c5fa562cc0891b4343fc3d32dd14","kind":"file","mode":33188,"name":"source.rs"},{"hash":"e3374a27d7d0f882064cf00e4f95f29623db5d5ff23a06dfb5050d5f6639463a","kind":"file","mode":33188,"name":"staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","kind":"file","mode":33188,"name":"store_view.rs"},{"hash":"1820f647bb5dbd74c8db7f5519d404ef0ce1520c833a579aaca893b5cad7ba08","kind":"file","mode":33188,"name":"surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","kind":"file","mode":33188,"name":"target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","kind":"file","mode":33188,"name":"textdiff.rs"},{"hash":"167749ecbb655a214510c849c92be88cb536cc7f57a330d21b0314780457b0a1","kind":"file","mode":33188,"name":"transaction.rs"},{"hash":"d4ef5888a7ecc406d8eb5173dc215c7b1a1ef1e1ddd3ab8b3a8aa6639d125017","kind":"file","mode":33188,"name":"transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","kind":"file","mode":33188,"name":"trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","kind":"file","mode":33188,"name":"version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","kind":"file","mode":33188,"name":"workspace.rs"}]} napl-store transition v1 933 @@ -34134,6 +34134,8 @@ napl-store transition v1 1880 {"baseStateKey":"8ee92d005a0b7518f483e0a1545dbadbccf9d7031f36ad9b0ad91341d6afebee","contractVersion":1,"derivedProjections":["Cargo.toml"],"inputKey":"ca5b1967770c28629e18ac06fa023b37ce9888f8c1304234652ea5f6dfed7217","installPaths":["crates/napl/Cargo.toml","crates/napl/src/attribution.rs","crates/napl/src/authoring.rs","crates/napl/src/blame.rs","crates/napl/src/candidate.rs","crates/napl/src/canon.rs","crates/napl/src/cli.rs","crates/napl/src/commandset.rs","crates/napl/src/commit.rs","crates/napl/src/compile.rs","crates/napl/src/cost.rs","crates/napl/src/decision.rs","crates/napl/src/disk.rs","crates/napl/src/error.rs","crates/napl/src/evidence.rs","crates/napl/src/graph.rs","crates/napl/src/init.rs","crates/napl/src/lint.rs","crates/napl/src/lsp.rs","crates/napl/src/main.rs","crates/napl/src/maintenance.rs","crates/napl/src/mv.rs","crates/napl/src/objects.rs","crates/napl/src/paths.rs","crates/napl/src/porcelain.rs","crates/napl/src/query.rs","crates/napl/src/receipt.rs","crates/napl/src/slice.rs","crates/napl/src/source.rs","crates/napl/src/store_view.rs","crates/napl/src/surface.rs","crates/napl/src/target.rs","crates/napl/src/textdiff.rs","crates/napl/src/transaction.rs","crates/napl/src/trees.rs","crates/napl/src/version.rs","crates/napl/src/watch.rs","crates/napl/src/workspace.rs"],"module":"napl","newState":"b1fc4f868fa1fb34887b7178b121ade5f30f82407db13d657a14cd830f619e4e","readSet":["base-state:8ee92d005a0b7518f483e0a1545dbadbccf9d7031f36ad9b0ad91341d6afebee","input-key:ca5b1967770c28629e18ac06fa023b37ce9888f8c1304234652ea5f6dfed7217","target-def:24836906360841beb373080103c8b6c1fe791d495a88247b70267efffbbf9f10"],"receipt":"c97f60652e2d8e2c87280754a9deb72202bcbf7f392d6bf1bf12538f2dbd2963","surface":"d62a27133f68d67d30147518eac5099c001898f03485c72159296bb25fc44bfa","target":"rust","writeSet":["owned:crates/napl","module-state:rust/napl"]} napl-store tree v1 507 {"entries":[{"hash":"a2f5418bb67e8935230c6d3c54fd332626a091169395143f048fd8c5d0a8e837","kind":"file","mode":33188,"name":"check.rs"},{"hash":"a1f82db40755ed46e068ff446b2737497478fb6e4c29e739bade5e8c53f3e26a","kind":"file","mode":33188,"name":"document.rs"},{"hash":"6cb21bfc68622856056fac18b252e6ee0ffe650bda0923fb0745b26e10c18a06","kind":"file","mode":33188,"name":"promptbuild.rs"},{"hash":"fa155fed083b1b146df1b5c5999c69472b231adc8164568689b6bde5ad3279ba","kind":"file","mode":33188,"name":"query.rs"}]} +napl-store tree v1 129 +{"entries":[{"hash":"8a8bb9882f17707d200e852a7f3b97a6d5cf64c77afb651fac4bfdb7694fed8e","kind":"dir","mode":16384,"name":"src"}]} napl-store blob v1 11002 //! The store shell: the compiler's read seam and write path, over real bytes. //! @@ -173656,6 +173658,8 @@ napl-store module-state v1 559 {"attribution":{"promptHash":"20ca78370f49d639d5dabbf8358ff0623d6c1302f6c0f52779341dd0bb9814cd","record":"ebbbbb69543ab7a9b2aff37516faa28bb297f07acbdced6d8a10e88e8c5074d6","stale":false},"contractVersion":4,"inputKey":"67dfd12c6c5ac7b03b785aed22add94f4212f2dedd288d1ef50565e67339b65a","module":"agent","outputTree":"28adf2e72a47aeff8cbfb4b8b863ba339fa27a5c5e5e9de456c8d91e396cab2a","sliceHash":"42560cb94bed1ea8ca81fc0bd5ba11c9b0ddf2aefe64f55410ed68ef37c20242","surfaceHash":"ad1581ae209ba3feb19322408bfff544be7a29bc6a7db44c672b705f1afcd120","target":"rust"} napl-store transition v1 1907 {"baseStateKey":"a376d26e5f8de69d8cb90f959edc75dcf7631ac57bf981ba4b0d699d1f5e2b90","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"cd02fc9c49f663d10356725e1ec925b8a8746202120b6dfb8d5d6a75ed57c8ff","installPaths":["crates/napl/src/attribution.rs","crates/napl/src/authoring.rs","crates/napl/src/blame.rs","crates/napl/src/candidate.rs","crates/napl/src/canon.rs","crates/napl/src/capture.rs","crates/napl/src/cli.rs","crates/napl/src/commandset.rs","crates/napl/src/commit.rs","crates/napl/src/compile.rs","crates/napl/src/cost.rs","crates/napl/src/decision.rs","crates/napl/src/directive.rs","crates/napl/src/disk.rs","crates/napl/src/error.rs","crates/napl/src/evidence.rs","crates/napl/src/graph.rs","crates/napl/src/lint.rs","crates/napl/src/lsp.rs","crates/napl/src/main.rs","crates/napl/src/maintenance.rs","crates/napl/src/mv.rs","crates/napl/src/objects.rs","crates/napl/src/paths.rs","crates/napl/src/porcelain.rs","crates/napl/src/receipt.rs","crates/napl/src/slice.rs","crates/napl/src/source.rs","crates/napl/src/staging.rs","crates/napl/src/store_view.rs","crates/napl/src/surface.rs","crates/napl/src/target.rs","crates/napl/src/textdiff.rs","crates/napl/src/transaction.rs","crates/napl/src/transport.rs","crates/napl/src/trees.rs","crates/napl/src/version.rs","crates/napl/src/workspace.rs"],"module":"napl","newState":"127285deaacf052e180769f0b6ce6ac03d3faf777d6ae7aa18e4cd77d6f95fe6","readSet":["base-state:a376d26e5f8de69d8cb90f959edc75dcf7631ac57bf981ba4b0d699d1f5e2b90","input-key:cd02fc9c49f663d10356725e1ec925b8a8746202120b6dfb8d5d6a75ed57c8ff","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"a5de1f5c63cb95d1ac984176ad9cc83ed637d5adffd06c60cc0e6fa7d65ed794","surface":"c5d18dea669bde6fa3cb8cba5ec78677b23a24c4433f0212457760003c4aa6d5","target":"rust","writeSet":["owned:crates/napl","module-state:rust/napl"]} +napl-store transition v1 1960 +{"baseStateKey":"76f91323327cf2f0a66946473c1176b3988c304d57f2c6c67626991dd449cdd1","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"fb8bca658e68497a7f4c93ec86167669020fd6ecec1655366e918526221b1a74","installPaths":["crates/napl/src/attribution.rs","crates/napl/src/authoring.rs","crates/napl/src/blame.rs","crates/napl/src/candidate.rs","crates/napl/src/canon.rs","crates/napl/src/capture.rs","crates/napl/src/cli.rs","crates/napl/src/commandset.rs","crates/napl/src/commit.rs","crates/napl/src/compile.rs","crates/napl/src/cost.rs","crates/napl/src/decision.rs","crates/napl/src/directive.rs","crates/napl/src/disk.rs","crates/napl/src/error.rs","crates/napl/src/evidence.rs","crates/napl/src/git.rs","crates/napl/src/graph.rs","crates/napl/src/lint.rs","crates/napl/src/lsp.rs","crates/napl/src/main.rs","crates/napl/src/maintenance.rs","crates/napl/src/mv.rs","crates/napl/src/objects.rs","crates/napl/src/paths.rs","crates/napl/src/porcelain.rs","crates/napl/src/receipt.rs","crates/napl/src/remove.rs","crates/napl/src/slice.rs","crates/napl/src/source.rs","crates/napl/src/staging.rs","crates/napl/src/store_view.rs","crates/napl/src/surface.rs","crates/napl/src/target.rs","crates/napl/src/textdiff.rs","crates/napl/src/transaction.rs","crates/napl/src/transport.rs","crates/napl/src/trees.rs","crates/napl/src/version.rs","crates/napl/src/workspace.rs"],"module":"napl","newState":"a9231bb7eaaf6d32d3a8179fc0925d9602207968fd5d49a0548b9d2817f9820e","readSet":["base-state:76f91323327cf2f0a66946473c1176b3988c304d57f2c6c67626991dd449cdd1","input-key:fb8bca658e68497a7f4c93ec86167669020fd6ecec1655366e918526221b1a74","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"ebfe35fb0dc78c17d86093b56b18a85de3b27c6e5ae6ca5570760a847498a8db","surface":"19842377a5884b5b4dc94a7445d915f6aa844478502b531010361228d326a2ed","target":"rust","writeSet":["owned:crates/napl","module-state:rust/napl"]} napl-store tree v1 130 {"entries":[{"hash":"f70d09070025af1be89f4b378c9f4fc65f94b03ffc98002d56c0f99aeb3f03b8","kind":"dir","mode":16384,"name":"napl"}]} napl-store tree v1 130 @@ -175129,6 +175133,748 @@ napl-store module-state v1 558 {"attribution":{"promptHash":"f3aa3c42590c24319c16d0f64ee1cd6c4a12eb93b72acd10cf265adb26bb84b0","record":"d36ad7efe8a3920167be664c69adf97f22d108fc3c9235cb5ab0085773449eef","stale":false},"contractVersion":2,"inputKey":"caef81ac82e2ba5e6dfb6d972b8c6f7f3cea1b348742eff708014f998ce1e6b2","module":"napl","outputTree":"ef4381b6fbe72701191f5c172bac76b9685d22a10568c06335a0e4acf148c405","sliceHash":"3e5104ccc879fe35ca58365ab99efea8967e010d73c872fcd9b575cda6327649","surfaceHash":"40faaca519e9d3e7292f35c83f9b09b37aff1e1b0a08ffcd6ae1e0fed5df31bf","target":"rust"} napl-store transition v1 875 {"baseStateKey":"2354c58048f64831cc4e160fab474657cd729c89a21d81320e7f221d3b1bc127","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"4c49290859feeb9ffb3a83ca97efb33e1aed34843ac077ba95cfbbfb166e7433","installPaths":["crates/napl/src/init.rs","crates/napl/src/watch.rs"],"module":"commands","newState":"efeb55ee16b2b444a3502f2e63424bcaaec34c9f676dd2c74f00944dceec5a8d","readSet":["base-state:2354c58048f64831cc4e160fab474657cd729c89a21d81320e7f221d3b1bc127","input-key:4c49290859feeb9ffb3a83ca97efb33e1aed34843ac077ba95cfbbfb166e7433","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"f4600cb7805c125686fda60c33a201db7269cffb155e15d915b571708dd1315f","surface":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust","writeSet":["owned:crates/napl","module-state:rust/commands"]} +napl-store blob v1 25538 +//! `napl commit`: the one guarded write path. +//! +//! The shape of this file is the contract's pipeline, in order: ingest, verify, +//! extract, derive, stage — all outside any lock — and then one bounded critical +//! section that rechecks the footprint and swaps the pointer and the directory. +//! Nothing inside the lock hashes content, and the number of steps it applies is +//! fixed before it is taken. +//! +//! Every decision it makes is the pure compiler's. This file supplies bytes and +//! performs writes. + +use std::collections::BTreeMap; +use std::path::Path; + +use crate::candidate::{default_limits, ingest, Candidate}; +use crate::compile::{head_states, slice_at, state_of}; +use crate::decision::{decide, rejection, Arriving, Head}; +use crate::error::{error, error_payload, OutcomeCode, Result}; +use crate::receipt::{parse_receipt, Receipt}; +use crate::source::SourceModule; +use crate::store_view::{get_blob, ObjectSource}; +use crate::target::{builtin_target, placement_root, TargetDef}; +use crate::transaction::{ + accept, check_candidate_shape, derive_commit, intervening_write_set, success_payload, + BuiltAttribution, DerivedCommit, PendingObject, +}; +use crate::attribution; +use crate::objects::AttributionState; +use napl_store::store_txn_core::CommitMarker; +use serde_json::Value; + +use crate::disk::{self, Store, POINTER_LOCK}; +use crate::workspace::{apply_step, discover_sources, scan_candidate}; + +/// The machine-readable result of a commit attempt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitReport { + pub outcome: OutcomeCode, + pub payload: Value, +} + +/// What had already reached the store when a failure occurred. +#[derive(Default, Debug, Clone, Copy)] +struct Published { + blobs: bool, + provenance: bool, +} + +/// How a commit treats the attribution update it carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttributionMode { + /// Prove every claim against the ingested candidate and the current prompt + /// body before anything installs. The default, and what `add` requires. + Prove, + /// Install an update whose claims were already proven, referentially and in + /// full, against the exact bytes they name. This is the capture verbs' mode: + /// a reference-bearing edit proves its ranges against the post-edit working + /// tree, which is the content the reference is about, and the candidate it + /// re-commits is the module's unchanged committed output. Re-proving the + /// reference against those older bytes would judge a claim nobody made. The + /// guard is untouched: the whole transaction, decision table, journal, and + /// recovery run exactly as they do for `commit`. + PreProven, + /// The explicit escape hatch: install unproven and record the module's + /// attribution as stale. + Escape, +} + +/// Run a commit. It never panics and never returns an error: every path +/// produces a canonical payload naming whether blobs or provenance were +/// published, and no path publishes semantic state partially. +pub fn commit( + workspace: &Path, + receipt_path: &Path, + candidate_dir: &Path, + attribution_path: Option<&Path>, + no_verify: bool, +) -> CommitReport { + let mode = if no_verify { + AttributionMode::Escape + } else { + AttributionMode::Prove + }; + commit_with_mode( + workspace, + receipt_path, + candidate_dir, + attribution_path, + mode, + None, + ) +} + +/// Run a commit under an explicit attribution mode. +/// +/// `staged_prompt` is the module's prompt file as `add` captured it. A record +/// staged through `add` always carries one, and commit installs those bytes, so +/// a prompt edited between add and commit is not absorbed unjudged. A caller +/// with no staged copy, which is every capture verb, passes `None` and the +/// working tree answers. +pub fn commit_with_mode( + workspace: &Path, + receipt_path: &Path, + candidate_dir: &Path, + attribution_path: Option<&Path>, + mode: AttributionMode, + staged_prompt: Option<&Path>, +) -> CommitReport { + let mut published = Published::default(); + match run( + workspace, + receipt_path, + candidate_dir, + attribution_path, + mode, + staged_prompt, + &mut published, + ) { + Ok(payload) => CommitReport { + outcome: OutcomeCode::Success, + payload, + }, + Err(err) => CommitReport { + outcome: err.code, + payload: error_payload(&err, published.blobs, published.provenance), + }, + } +} + +#[allow(clippy::too_many_arguments)] +fn run( + workspace: &Path, + receipt_path: &Path, + candidate_dir: &Path, + attribution_path: Option<&Path>, + mode: AttributionMode, + staged_prompt: Option<&Path>, + published: &mut Published, +) -> Result { + let store = disk::init(workspace)?; + recover(&store)?; + + let receipt_bytes = + std::fs::read(receipt_path).map_err(|e| disk::io(receipt_path, &e))?; + let receipt = parse_receipt(&receipt_bytes)?; + let target = builtin_target(&receipt.target)?; + let sources = discover_sources(workspace)?; + + let candidate = ingest(&scan_candidate(candidate_dir)?, default_limits())?; + let root = placement_of(&sources, &receipt.module)?; + check_candidate_shape(&target, &root, &candidate)?; + + crate::transaction::verify_receipt(&store, &sources, &receipt, &candidate)?; + + let built = build_attribution( + &sources, + &receipt, + &target, + &root, + &candidate, + attribution_path, + mode, + )?; + + let source = sources.iter().find(|s| s.module == receipt.module); + let prompt_file = match (staged_prompt, source) { + (Some(staged), _) => Some(std::fs::read(staged).map_err(|e| disk::io(staged, &e))?), + (None, Some(src)) => { + let path = workspace.join(&src.path); + Some(std::fs::read(&path).map_err(|e| disk::io(&path, &e))?) + } + (None, None) => None, + }; + if let Some(bytes) = &prompt_file { + let spelled = source.map(|src| src.path.clone()).unwrap_or_default(); + let text = std::str::from_utf8(bytes).map_err(|_| { + error( + OutcomeCode::InvalidCandidate, + format!( + "the staged prompt for module {:?} is not valid UTF-8", + receipt.module + ), + ) + })?; + let parsed = crate::source::parse_source(&spelled, text)?; + if parsed.module != receipt.module { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "the staged prompt declares module {:?} but this record commits {:?}", + parsed.module, receipt.module + ), + )); + } + } + let prompt_object = prompt_file.as_ref().map(|bytes| { + napl_store::store_hash::object_name(napl_store::store_hash::ObjectKind::Blob, bytes) + }); + let derived = derive_commit( + &target, + &root, + &receipt, + &candidate, + built.as_ref(), + prompt_object, + )?; + + publish_all(&store, &derived.objects, &derived.transition_id)?; + if let Some(src) = sources.iter().find(|s| s.module == receipt.module) { + disk::publish( + &store, + napl_store::store_hash::ObjectKind::Blob, + src.normalized_body.as_bytes(), + &derived.transition_id, + )?; + } + if let Some(bytes) = &prompt_file { + disk::publish( + &store, + napl_store::store_hash::ObjectKind::Blob, + bytes, + &derived.transition_id, + )?; + } + published.blobs = true; + published.provenance = true; + + let mut payload = + install(&store, workspace, &target, &root, &receipt, &sources, &candidate, &derived)?; + + crate::porcelain::repack_if_loose(&store)?.record(&mut payload); + Ok(payload) +} + +#[allow(clippy::too_many_arguments)] +fn install( + store: &Store, + workspace: &Path, + target: &TargetDef, + root: &str, + receipt: &Receipt, + sources: &[SourceModule], + candidate: &Candidate, + derived: &DerivedCommit, +) -> Result { + let module_lock = disk::take_lock(store, &lock_key(&target.name, &receipt.module))?; + let pointer_lock = match disk::take_lock(store, POINTER_LOCK) { + Ok(guard) => guard, + Err(err) => { + disk::release_lock(module_lock)?; + return Err(err); + } + }; + let outcome = + locked_install(store, workspace, target, root, receipt, sources, candidate, derived); + disk::release_lock(pointer_lock)?; + disk::release_lock(module_lock)?; + outcome +} + +#[allow(clippy::too_many_arguments)] +fn locked_install( + store: &Store, + workspace: &Path, + target: &TargetDef, + root: &str, + receipt: &Receipt, + sources: &[SourceModule], + candidate: &Candidate, + derived: &DerivedCommit, +) -> Result { + let head = store.head()?; + + let head_slice = slice_at( + store, + sources, + &target.name, + &receipt.module, + head.as_deref(), + )?; + let states = head_states(store, head.as_deref())?; + let head_state_key = state_of(store, &states, target, &receipt.module)?.1; + let arriving = Arriving { + input_key: receipt.input_key.clone(), + base_state_key: receipt.base_state_key.clone(), + base_snapshot: receipt.base_store_snapshot.clone(), + write_set: derived.transition.footprint.write_set.iter().cloned().collect(), + }; + let head_facts = Head { + snapshot: head.clone(), + input_key: head_slice.input_key, + base_state_key: head_state_key, + intervening_write_set: intervening_write_set( + store, + head.as_deref(), + receipt.base_store_snapshot.as_deref(), + )?, + }; + if let Some(rejected) = rejection(&decide(&arriving, &head_facts)) { + return Err(rejected); + } + + let accepted = accept( + store, + target, + root, + candidate, + derived, + head.as_deref(), + receipt.base_store_snapshot.as_deref(), + )?; + publish_all(store, &accepted.objects, &derived.transition_id)?; + + let marker = CommitMarker { + domain: receipt.module.clone(), + owner: derived.transition_id.clone(), + snapshot: accepted.snapshot.clone(), + steps: accepted.steps.clone(), + }; + disk::write_marker(store, &marker)?; + disk::compare_and_set_head(store, head.as_deref(), &accepted.snapshot)?; + apply_steps(store, workspace, &marker)?; + disk::delete_marker(store, &accepted.snapshot)?; + + Ok(success_payload(derived, &accepted)) +} + +/// Install a docs record: write the new prompt object and re-point the module's +/// prompt at it, while every derived fact holds. +/// +/// The module keeps its inputKey, its attribution map, its endpoints, its surface +/// and its output tree, because none of them is a function of prose. What moves +/// is the state's prompt object and the head that names the state. The transition +/// says `docs-only`, so history never confuses prose with semantics. +pub fn commit_docs(workspace: &Path, record: &Path) -> CommitReport { + let mut published = Published::default(); + match run_docs(workspace, record, &mut published) { + Ok(payload) => CommitReport { + outcome: OutcomeCode::Success, + payload, + }, + Err(err) => CommitReport { + outcome: err.code, + payload: error_payload(&err, published.blobs, published.provenance), + }, + } +} + +fn run_docs(workspace: &Path, record: &Path, published: &mut Published) -> Result { + let store = disk::init(workspace)?; + recover(&store)?; + + let record_dir = crate::staging::record_directory(record)?; + let meta = crate::staging::record_meta(&record_dir)?; + let module = meta["module"].as_str().unwrap_or_default().to_string(); + let target_name = meta["target"].as_str().unwrap_or("rust").to_string(); + builtin_target(&target_name)?; + let prompt_path = meta["promptPath"].as_str().unwrap_or_default().to_string(); + let base_state_key = meta["baseStateKey"].as_str().unwrap_or_default().to_string(); + if base_state_key.is_empty() { + return Err(error( + OutcomeCode::InvalidReceipt, + format!("the staged docs record for module {module:?} names no base state; re-run `napl add {prompt_path}`"), + )); + } + + let staged = record_dir.join(crate::staging::DOCS_PROMPT_FILE); + let bytes = std::fs::read(&staged).map_err(|e| disk::io(&staged, &e))?; + let text = String::from_utf8(bytes).map_err(|_| { + error( + OutcomeCode::InvalidCandidate, + format!("the staged prompt for module {module:?} is not valid UTF-8"), + ) + })?; + let parsed = crate::source::parse_source(&prompt_path, &text)?; + if parsed.module != module { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "the staged prompt declares module {:?} but the record names {module:?}", + parsed.module + ), + )); + } + + let object = + napl_store::store_hash::object_name(napl_store::store_hash::ObjectKind::Blob, text.as_bytes()); + + let head = store.head()?; + let committed_now = crate::porcelain::committed_state(&store, &target_name, &module)?; + if committed_now.as_ref().and_then(|s| s.prompt.as_deref()) == Some(object.as_str()) { + return Ok(serde_json::json!({ + "alreadyRecorded": true, + "blobsPublished": false, + "contractVersion": crate::version::CONTRACT_VERSION, + "detail": "the committed prompt already is these bytes, so this record had nothing left to install and was swept", + "head": head, + "installed": [], + "kind": Value::Null, + "module": module, + "outcome": "success", + "prompt": object, + "promptPath": prompt_path, + "provenancePublished": false, + "semanticStatePublished": false, + "target": target_name, + })); + } + + let state = crate::objects::read_module_state(&store, &base_state_key)?; + let Some(baseline) = &state.prompt else { + return Err(error( + OutcomeCode::InvalidCandidate, + format!("the base state of module {module:?} names no prompt object, so there is no committed prompt to prove this docs edit against"), + )); + }; + let committed = crate::staging::committed_prompt(&store, baseline, &module)?; + match crate::staging::judge_prompt(&prompt_path, &committed, &text)? { + crate::staging::PromptDrift::DocsOnly => {} + other => { + return Err(error( + OutcomeCode::InvalidCandidate, + format!("the staged prompt for module {module:?} is not a docs-only edit against the base state it names ({other:?}), and no docs record may install one"), + )); + } + } + + let new_state = crate::objects::ModuleState { + prompt: Some(object.clone()), + ..state + }; + let new_state_key = crate::objects::module_state_hash(&new_state); + let transition = crate::objects::Transition { + module: module.clone(), + target: target_name.clone(), + input_key: new_state.input_key.clone().unwrap_or_default(), + base_state_key: base_state_key.clone(), + new_state: new_state_key.clone(), + receipt: String::new(), + surface: new_state.surface_hash.clone(), + footprint: crate::objects::Footprint { + read_set: vec![format!("prompt:{baseline}")], + write_set: vec![crate::objects::state_write_key(&target_name, &module)], + derived_projections: Vec::new(), + install_paths: Vec::new(), + }, + kind: Some(crate::objects::DOCS_ONLY.to_string()), + prompt: Some(object.clone()), + }; + let transition_id = crate::objects::transition_hash(&transition); + + let objects = vec![ + PendingObject { + name: object.clone(), + kind: napl_store::store_hash::ObjectKind::Blob, + payload: text.as_bytes().to_vec(), + }, + json_pending( + napl_store::store_hash::ObjectKind::ModuleState, + &crate::objects::module_state_json(&new_state), + ), + json_pending( + napl_store::store_hash::ObjectKind::Transition, + &crate::objects::transition_json(&transition), + ), + ]; + publish_all(&store, &objects, &transition_id)?; + published.blobs = true; + published.provenance = true; + + let mut payload = install_docs( + &store, + &target_name, + &module, + &base_state_key, + &new_state_key, + &transition_id, + &object, + &prompt_path, + )?; + crate::porcelain::repack_if_loose(&store)?.record(&mut payload); + Ok(payload) +} + +#[allow(clippy::too_many_arguments)] +fn install_docs( + store: &Store, + target_name: &str, + module: &str, + base_state_key: &str, + new_state_key: &str, + transition_id: &str, + prompt_object: &str, + prompt_path: &str, +) -> Result { + let module_lock = disk::take_lock(store, &lock_key(target_name, module))?; + let pointer_lock = match disk::take_lock(store, POINTER_LOCK) { + Ok(guard) => guard, + Err(err) => { + disk::release_lock(module_lock)?; + return Err(err); + } + }; + let outcome = locked_docs_install( + store, + target_name, + module, + base_state_key, + new_state_key, + transition_id, + prompt_object, + prompt_path, + ); + disk::release_lock(pointer_lock)?; + disk::release_lock(module_lock)?; + outcome +} + +#[allow(clippy::too_many_arguments)] +fn locked_docs_install( + store: &Store, + target_name: &str, + module: &str, + base_state_key: &str, + new_state_key: &str, + transition_id: &str, + prompt_object: &str, + prompt_path: &str, +) -> Result { + let head = store.head()?; + let mut states = head_states(store, head.as_deref())?; + let key = (target_name.to_string(), module.to_string()); + if states.get(&key).map(String::as_str) != Some(base_state_key) { + return Err(error( + OutcomeCode::WriteConflict, + format!("module {module:?} moved under this docs record; re-run `napl add {prompt_path}`"), + )); + } + states.insert(key, new_state_key.to_string()); + + let parents: Vec = match head.as_deref() { + Some(snapshot) => vec![crate::objects::snapshot_event(store, snapshot)?], + None => Vec::new(), + }; + let event = crate::objects::event_hash(transition_id, &parents); + let snapshot = crate::objects::snapshot_hash(&event, &states); + publish_all( + store, + &[ + json_pending( + napl_store::store_hash::ObjectKind::Event, + &crate::objects::event_json(transition_id, &parents), + ), + json_pending( + napl_store::store_hash::ObjectKind::Snapshot, + &crate::objects::snapshot_json(&event, &states), + ), + ], + transition_id, + )?; + + let marker = CommitMarker { + domain: module.to_string(), + owner: transition_id.to_string(), + snapshot: snapshot.clone(), + steps: Vec::new(), + }; + disk::write_marker(store, &marker)?; + disk::compare_and_set_head(store, head.as_deref(), &snapshot)?; + disk::delete_marker(store, &snapshot)?; + + Ok(serde_json::json!({ + "attribution": "fresh", + "blobsPublished": true, + "contractVersion": crate::version::CONTRACT_VERSION, + "head": snapshot, + "installed": [], + "kind": crate::objects::DOCS_ONLY, + "module": module, + "outcome": "success", + "prompt": prompt_object, + "promptPath": prompt_path, + "provenancePublished": true, + "semanticStatePublished": true, + "target": target_name, + "transition": transition_id, + })) +} + +fn json_pending(kind: napl_store::store_hash::ObjectKind, value: &Value) -> PendingObject { + let (name, payload) = crate::canon::hash_json(kind, value); + PendingObject { + name, + kind, + payload, + } +} + +/// Roll every recoverable commit marker forward, then drop it. +/// +/// The disposition is `napl-store`'s: a marker whose snapshot is HEAD or an +/// ancestor of HEAD rolls forward; a marker ahead of HEAD is a commit that never +/// landed and is collected. Neither branch consults liveness or wall time. +pub fn recover(store: &Store) -> Result> { + let head = store.head()?; + let mut applied: Vec = Vec::new(); + for snapshot in disk::marker_snapshots(store)? { + let Some(marker) = disk::read_marker(store, &snapshot)? else { + continue; + }; + if head.as_deref() == Some(snapshot.as_str()) { + apply_steps(store, &store.workspace, &marker)?; + applied.push(snapshot.clone()); + } + disk::delete_marker(store, &snapshot)?; + } + Ok(applied) +} + +fn apply_steps(store: &Store, workspace: &Path, marker: &CommitMarker) -> Result<()> { + let payload = |hash: &str| get_blob(store, hash); + for step in &marker.steps { + apply_step(workspace, step, &payload)?; + } + Ok(()) +} + +fn publish_all(store: &Store, objects: &[PendingObject], owner: &str) -> Result<()> { + let mut seen: BTreeMap<&str, ()> = BTreeMap::new(); + for object in objects { + if seen.insert(object.name.as_str(), ()).is_some() { + continue; + } + disk::publish(store, object.kind, &object.payload, owner)?; + } + Ok(()) +} + +/// Prove the attribution update a commit carries, or refuse the commit. +/// +/// A commit without an attribution update fails loudly, naming what is missing, +/// unless the escape hatch is set. With the escape hatch, an update installs +/// unproven and is recorded stale, and a commit with no update at all leaves the +/// module's attribution absent. A proven update is validated against the ingested +/// candidate and the current normalized prompt body before anything installs. A +/// pre-proven update installs fresh without a second validation, because its +/// claims were already proven against the exact bytes they name. +#[allow(clippy::too_many_arguments)] +fn build_attribution( + sources: &[SourceModule], + receipt: &Receipt, + target: &TargetDef, + root: &str, + candidate: &Candidate, + attribution_path: Option<&Path>, + mode: AttributionMode, +) -> Result> { + let no_verify = mode == AttributionMode::Escape; + let bytes = match attribution_path { + Some(path) => std::fs::read(path).map_err(|e| disk::io(path, &e))?, + None => { + if no_verify { + return Ok(None); + } + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "commit of module {:?} for target {:?} carries no attribution update; supply --attribution or --no-verify", + receipt.module, target.name + ), + )); + } + }; + let text = String::from_utf8(bytes).map_err(|_| { + error( + OutcomeCode::InvalidCandidate, + "attribution update is not valid UTF-8", + ) + })?; + let mut map = attribution::parse(&text).ok_or_else(|| { + error( + OutcomeCode::InvalidCandidate, + "attribution update is not a parseable record", + ) + })?; + map.stale = no_verify; + + let source = sources + .iter() + .find(|source| source.module == receipt.module) + .ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {:?} has no prompt file in the workspace", receipt.module), + ) + })?; + if mode == AttributionMode::Prove { + let body_lines = source.normalized_body.lines().count() as u32; + attribution::validate( + target, + root, + &receipt.module, + &candidate.files, + body_lines, + &map, + )?; + } + + let yaml = attribution::to_yaml(&map); + let state = AttributionState { + record: attribution::record_hash(&map), + prompt_hash: source.normalized_source_hash.clone(), + stale: no_verify, + }; + Ok(Some(BuiltAttribution { + state, + yaml, + view_path: attribution::view_path(&receipt.module), + })) +} + +/// The lock name a module's domain is keyed by. There is no global write lock: +/// two modules commit concurrently without ever contending. +pub fn lock_key(target: &str, module: &str) -> String { + format!("module-{target}-{}", crate::paths::encode_name(module)) +} + +/// The crate directory a module's prompt sits in, read off the discovered +/// sources so the placement follows the prompt's location and nothing else. +fn placement_of(sources: &[SourceModule], module: &str) -> Result { + sources + .iter() + .find(|source| source.module == module) + .map(placement_root) + .ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} has no prompt file in the workspace"), + ) + }) +} napl-store event v1 158 {"content":"5f1a051a0292fccc46d67769af0db69e261bf199a6b6ee850f704275365e0374","parents":["14b41f0f24375744aa018236c3a0f448ec928d00bc05fa6806e6982b063b410b"]} napl-store tree v1 252 @@ -203426,6251 +204172,6031 @@ napl-store transition v1 1253 {"baseStateKey":"ba1c5035ed5d02088e38bd72fb9b3545bdc4e67aaf793a9ba0690cbb18c06e9f","contractVersion":4,"derivedProjections":["crates/napl-usage-proxy/src/lib.rs"],"inputKey":"6835ec98ced3cbe0800dfe401499d8b54826b74f8a598f81ac694d0e7f80b93c","installPaths":["crates/napl-usage-proxy/src/agent.rs","crates/napl-usage-proxy/src/args.rs","crates/napl-usage-proxy/src/canonical.rs","crates/napl-usage-proxy/src/capture.rs","crates/napl-usage-proxy/src/logline.rs","crates/napl-usage-proxy/src/main.rs","crates/napl-usage-proxy/src/proxy.rs","crates/napl-usage-proxy/src/session.rs","crates/napl-usage-proxy/src/state.rs","crates/napl-usage-proxy/src/upstream.rs"],"module":"usage_proxy","newState":"83e7eb0b234bbffd0573d91bcf92e594e91a0a15b536e6d5c915a965fe0d4309","readSet":["base-state:ba1c5035ed5d02088e38bd72fb9b3545bdc4e67aaf793a9ba0690cbb18c06e9f","input-key:6835ec98ced3cbe0800dfe401499d8b54826b74f8a598f81ac694d0e7f80b93c","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"a52ddc4758f68c09fafed6935ddf18caf51278cd5a65c2b7173327174e5e59b7","surface":"1fbb16a66bf359bd7194dcdb0b4cef9765c236d42c8288203e5ca62e2b53ff9f","target":"rust","writeSet":["owned:crates/napl-usage-proxy","module-state:rust/usage_proxy"]} napl-store transition v1 1428 {"baseStateKey":"7ad4dba399677a4a801444f26c938bc4594731235bcccd9a979e1728b21f1822","contractVersion":4,"derivedProjections":["crates/napl-store/src/lib.rs"],"inputKey":"ec636504966c4aad2a27769b28c322232b1d293394d9d9651646cdd3ae92bd74","installPaths":["crates/napl-store/src/hash.rs","crates/napl-store/src/store_blocks/mod.rs","crates/napl-store/src/store_blocks/tests.rs","crates/napl-store/src/store_diff/mod.rs","crates/napl-store/src/store_diff/tests.rs","crates/napl-store/src/store_hash.rs","crates/napl-store/src/store_io/mod.rs","crates/napl-store/src/store_io/tests.rs","crates/napl-store/src/store_objects/mod.rs","crates/napl-store/src/store_objects/tests.rs","crates/napl-store/src/store_reduce/mod.rs","crates/napl-store/src/store_reduce/tests.rs","crates/napl-store/src/store_tree.rs","crates/napl-store/src/store_txn_core.rs"],"module":"napl_store","newState":"7011890f658fb28bd29684802e5e53bb5d54d54d3c5a64cc926a56fae7ef944d","readSet":["base-state:7ad4dba399677a4a801444f26c938bc4594731235bcccd9a979e1728b21f1822","input-key:ec636504966c4aad2a27769b28c322232b1d293394d9d9651646cdd3ae92bd74","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"28cbfe345445aebe451c9f97d60fba062e914d95a137c8fa070600ccb83cb111","surface":"6e4e4c5aa3976236133ac5bd5afb5ea6038ee074a4ffc01e7dc6f7480068da76","target":"rust","writeSet":["owned:crates/napl-store","module-state:rust/napl_store"]} -napl-store blob v1 21298 -//! The commit transaction, up to but not including the syscalls. -//! -//! Everything the pipeline decides lives here: what the candidate is allowed to -//! contain, whether the receipt reproduces, what surface the candidate records, -//! what transition that derives, and which install steps materialize it. The -//! binary supplies the bytes and performs the writes; it makes no decision of -//! its own. -//! -//! The ordering is the contract's ordering, and it is what makes the critical -//! section bounded: ingest, verify, extract, derive, and plan all happen before -//! any lock is taken, so the locked region only rechecks the footprint and swaps -//! pointers, bounded by the transition's file count and hashing nothing. +napl-store blob v1 106260 +# NAPL: the deterministic compiler -use std::collections::{BTreeMap, BTreeSet}; +This prompt describes the entire NAPL toolchain, rebuilt as one small Cargo +workspace of exactly three crates. It replaces the legacy 76-crate +implementation completely; git history holds the original. This prompt is +the governing contract: the workspace's `.napl` prompts and the committed +code are the two truths, and no external document outranks them. -use napl_store::store_hash::ObjectKind; -use napl_store::store_tree::{diff_trees, Tree, TreeChange, FILE_MODE}; -use napl_store::store_txn_core::{plan_install_steps, InstallStep}; -use serde_json::{json, Value}; +## Layout: this module generates two crates on top of the ported store -use crate::candidate::Candidate; -use crate::canon::{hash_json, is_object_name}; -use crate::compile::{base_output_files, head_states, read_surface_items, slice_at, state_of}; -use crate::error::{error, OutcomeCode, Result}; -use crate::evidence::{evidence_report, validate_all}; -use crate::objects::{ - event_hash, event_json, module_state_hash, module_state_json, ownership_write_key, - read_module_state, snapshot_hash, snapshot_json, state_write_key, transition_hash, - transition_json, Footprint, ModuleState, Transition, -}; -use crate::paths::is_under; -use crate::receipt::{receipt_json, receipt_semantic_hash, Receipt, RECEIPT_VERSION}; -use crate::slice::CompileSlice; -use crate::source::SourceModule; -use crate::store_view::{load_tree_objects, ObjectSource}; -use crate::surface::{extract_surface, surface_hash, surface_json, SurfaceItem}; -use crate::target::{ - derive_lib_projection, derive_projection, is_owned_path, projection_owned_paths, - target_def_hash, TargetDef, -}; +The workspace layout is fixed: the records directory `.napl/` lives at the +repository root; generated code installs under `crates/`; a module's +prompt file lives inside the crate directory that receives its code, and +the crate is the folder: every module whose `.napl` file sits in +`crates//` compiles into the `` crate as its own source +module, so one crate may be described by several prompt files +(`crates/napl/napl.napl` and `crates/napl/commands.napl` both build +`crates/napl/`). Module discovery finds prompts by walking the workspace +for `*.napl` files, and the workspace root is the nearest ancestor +directory containing `.napl`. A subtree that contains its own `.napl` +directory is a separate workspace: discovery never descends into it, the +way git never descends into a nested repository. Hidden directories and +`node_modules` are never descended into. -/// One object the transaction wants published, kind and payload. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PendingObject { - pub name: String, - pub kind: ObjectKind, - pub payload: Vec, -} +The proven store already exists as a hand-ported crate at +`crates/napl-store/` (same envelope format `napl-store v1 +\n`, SHA-256 names, canonical JSON payloads, same object +kinds, same `.napl/store/` on-disk layout with HEAD compare-and-swap, +module-keyed locks, and dead-holder steal, ported code and tests from the +legacy store crates). Use its public API through a path dependency; do not +reimplement, wrap thinly, or fork any of its types or logic. The four new +object kinds the compiler needs (`transition`, `receipt`, `surface`, +`target-def`) already exist in it additively. -/// Everything a commit derived, ready to be applied. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DerivedCommit { - pub transition: Transition, - pub transition_id: String, - pub new_state_key: String, - pub surface_hash: String, - pub surface_items: Vec, - /// The recorded receipt object, provenance included. Distinct from the - /// transition's `receipt`, which names the semantic body alone. - pub receipt_object: String, - /// The verdict on every evidence item the receipt carried. Provenance: it is - /// reported, and it changes nothing about what installs. - pub evidence: Value, - /// Blobs and provenance, published before the critical section. - pub objects: Vec, -} +::: test +name: the ported store hashes objects exactly as the original store crates did +given: { vectors: ported_store_hash_test_corpus } +expect: { all_pass_unmodified: true } +::: -/// The state the accepted transition writes, plus how to materialize it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Accepted { - pub snapshot: String, - pub event: String, - pub steps: Vec, - /// The derived projection: recomputed from the merged head, never reused. - pub projection_path: String, - pub projection_bytes: Vec, - pub objects: Vec, - pub rebased: bool, -} +::: test +name: identical payload under two object kinds gets two different names +given: { payload: "X", kind_a: blob, kind_b: snapshot } +expect: { equal: false } +::: -/// Check a candidate against its module's placement and allowed outputs. -pub fn check_candidate_shape( - target: &TargetDef, - root: &str, - candidate: &Candidate, -) -> Result<()> { - if candidate.manifest.is_empty() { - return Err(error(OutcomeCode::InvalidCandidate, "candidate is empty")); - } - let projections = projection_owned_paths(target, root); - for path in candidate.manifest.keys() { - if !is_under(path, root) { - return Err(error( - OutcomeCode::InvalidCandidate, - format!("candidate path {path:?} is outside the placement root {root:?}"), - )); - } - if projections.iter().any(|p| p == path) { - return Err(error( - OutcomeCode::InvalidCandidate, - format!("candidate path {path:?} is a derived projection and cannot be owned"), - )); - } - if !is_owned_path(target, root, path) { - return Err(error( - OutcomeCode::InvalidCandidate, - format!("candidate path {path:?} is not an allowed output for the crate {root:?}"), - )); - } - } - let manifest = format!("{root}/{}", target.manifest_file); - if !candidate.manifest.contains_key(&manifest) { - return Err(error( - OutcomeCode::InvalidCandidate, - format!("candidate is missing its manifest {manifest:?}"), - )); - } - Ok(()) -} +This module's generated output is as many crates as the design needs, side +by side in the module's owned directory. Keep it a small, clearly separated +workspace (this replaces a 76-crate explosion; do not recreate one), with +`napl-store` staying its own untouched crate. The natural split, which you +may adjust if the design genuinely wants another boundary: -/// Verify a receipt against independently recomputed identities. -pub fn verify_receipt( - source: &dyn ObjectSource, - sources: &[SourceModule], - receipt: &Receipt, - candidate: &Candidate, -) -> Result { - if receipt.receipt_version != RECEIPT_VERSION { - return Err(error( - OutcomeCode::UnsupportedVersion, - format!("receiptVersion {} is not supported", receipt.receipt_version), - )); - } - if receipt.candidate_tree_hash != candidate.tree_hash { - return Err(error( - OutcomeCode::InvalidReceipt, - format!( - "candidateTreeHash {} does not match the ingested candidate {}", - receipt.candidate_tree_hash, candidate.tree_hash - ), - )); - } - if receipt.output_manifest != candidate.manifest { - return Err(error( - OutcomeCode::InvalidReceipt, - "outputManifest does not match the ingested candidate", - )); - } - if !is_object_name(&receipt.base_workspace_hash) { - return Err(error( - OutcomeCode::InvalidReceipt, - "baseWorkspaceHash is not an object name", - )); - } - let slice = slice_at( - source, - sources, - &receipt.target, - &receipt.module, - receipt.base_store_snapshot.as_deref(), - )?; - let mismatch = |field: &str, claimed: &str, recomputed: &str| { - error( - OutcomeCode::InvalidReceipt, - format!("{field} {claimed} does not reproduce at the receipt's bases (recomputed {recomputed})"), - ) - }; - if slice.slice_hash != receipt.slice_hash { - return Err(mismatch("sliceHash", &receipt.slice_hash, &slice.slice_hash)); - } - if slice.input_key != receipt.input_key { - return Err(mismatch("inputKey", &receipt.input_key, &slice.input_key)); - } - if slice.base_state_key != receipt.base_state_key { - return Err(mismatch( - "baseStateKey", - &receipt.base_state_key, - &slice.base_state_key, - )); - } - Ok(slice) -} +1. **`napl-core/`** (lib): the pure compiler. Parsing `.napl` files (YAML + frontmatter: module, deps, targets, tests; markdown body), discovery and + dependency graph, canonical path grammar, the identity vocabulary, + per-module artifact slices, wave planning, tree-sitter surface extraction + for Rust and TypeScript targets, the surface grammar, placement rules + and entry-file projection derivation, receipt verification logic, the commit + decision table as pure functions, blame line-ancestry replay, lint + diagnosis, cost calculation, evidence validation. No filesystem writes, + no locks, no clocks, no environment reads: everything enters as + arguments and leaves as values. +2. **`napl-cli/`** (bin `napl`): every command, each a thin composition of + napl-core derivations and napl-store reads plus the one guarded write + path. No private logic in the binary beyond argument parsing and + formatting. -/// Extract the candidate's surface and derive the whole transition. -pub fn derive_commit( - target: &TargetDef, - root: &str, - receipt: &Receipt, - candidate: &Candidate, -) -> Result { - let items = extract_surface(target, root, &candidate.files)?; - // Extraction is claimed to be a pure function of the bytes; a second pass - // over the same ingested bytes is the cheapest way to keep that claim honest. - if items != extract_surface(target, root, &candidate.files)? { - return Err(error( - OutcomeCode::MappingIntegrity, - "surface extraction is not reproducible for this candidate", - )); - } - let surface_value = surface_json(&receipt.module, &target.name, &items); - let recorded_surface = surface_hash(&receipt.module, &target.name, &items); +The crates depend on `napl-store` by path, `serde_json`, `sha2`, +`tree-sitter`, `tree-sitter-rust`, `tree-sitter-typescript`, `clap` +(builder API only, driving the command line contract below), and nothing +that talks to a network, spawns a process, or reads clocks; those +declarations live in the user-owned manifests, not in any module's +output. Ensure `cargo test` passes for both crates and `napl-store` from +their workspace before finishing. Generated code is authored against its +final installed location, never a staging tree: acceptance runs at the +final location, so green certifies the bytes where they live. The +working-tree add forms are the operational spelling of this law: author +and verify in place, then stage from where the bytes live; assembling a +separate candidate directory is the exception, for staging bytes that +are not the working tree, never the routine. - let new_state = ModuleState { - module: receipt.module.clone(), - target: target.name.clone(), - input_key: Some(receipt.input_key.clone()), - slice_hash: Some(receipt.slice_hash.clone()), - surface_hash: recorded_surface.clone(), - output_tree: candidate.tree_hash.clone(), - }; - let new_state_key = module_state_hash(&new_state); +When several modules share one crate, the crate's shared entry files are +derived projections owned by no module: the crate root `lib.rs` module +list is recomputed at commit from the union of the crate's committed +module file sets, and the binary's command registry is recomputed from +the command tables the crate's modules expose under one fixed convention +(each module may expose `pub fn commands() -> &'static [CommandSpec]` +from its own files; the dispatch loop iterates the derived registry and +contains no per-command match arms of its own). A module's owned output +therefore never includes the shared entry files, two modules can never +claim the same path, and a commit that would write a projection-owned +path is rejected as invalid-candidate. - let mut read_set = vec![ - format!("input-key:{}", receipt.input_key), - format!("base-state:{}", receipt.base_state_key), - format!("target-def:{}", target_def_hash(target)), - ]; - read_set.sort(); - let footprint = Footprint { - read_set, - write_set: vec![ - ownership_write_key(root), - state_write_key(&target.name, &receipt.module), - ], - derived_projections: vec![target.projection_path.clone()], - install_paths: candidate.manifest.keys().cloned().collect(), - }; - let transition = Transition { - module: receipt.module.clone(), - target: target.name.clone(), - input_key: receipt.input_key.clone(), - base_state_key: receipt.base_state_key.clone(), - new_state: new_state_key.clone(), - receipt: receipt_semantic_hash(receipt), - surface: recorded_surface.clone(), - footprint, - }; - let transition_id = transition_hash(&transition); +A prompt at the workspace root is legal and means the workspace root is +the crate directory, exactly as git treats the directory it was +initialized in: the placement root is the empty workspace-relative path, +every workspace-relative path is inside it, and every join against it +produces clean relative paths, never a fabricated leading separator. A +cold workspace whose first prompt sits beside `.napl` therefore commits +its first module with no configuration; placement never needs declaring +because the prompt's location is the declaration. - let mut objects: Vec = Vec::new(); - for bytes in candidate.files.values() { - objects.push(blob_object(bytes)); - } - for tree in candidate.tree_objects.values() { - objects.push(json_object(ObjectKind::Tree, &tree_value(tree))); - } - objects.push(json_object(ObjectKind::Surface, &surface_value)); - objects.push(json_object( - ObjectKind::ModuleState, - &module_state_json(&new_state), - )); - objects.push(json_object(ObjectKind::Receipt, &receipt_json(receipt))); - objects.push(json_object( - ObjectKind::Receipt, - &crate::receipt::receipt_semantic_json(receipt), - )); - objects.push(json_object( - ObjectKind::TargetDef, - &crate::target::target_def_json(target), - )); - objects.push(json_object( - ObjectKind::Transition, - &transition_json(&transition), - )); +::: test +name: a root-level prompt places at the workspace root and its module commits +given: { workspace: fresh_init_root_prompt, candidate: valid_root_level } +expect: { placement_root: "", owned_globs_relative: true, outcome: installed } +::: - Ok(DerivedCommit { - transition, - transition_id, - new_state_key, - surface_hash: recorded_surface, - surface_items: items, - receipt_object: crate::receipt::receipt_hash(receipt), - evidence: evidence_report(&validate_all(&receipt.evidence)), - objects, - }) -} +::: test +name: status for a root-level module reports a workspace-root plan with clean globs +given: { workspace: fresh_init_root_prompt, flag: module_root } +expect: { plan_placement_root: "", owned_globs_leading_separator: false } +::: -/// Derive the new snapshot, the install steps, and the re-derived projection. -/// -/// Called with the winning head already resolved, so a rebase and a -/// straight-line install go through exactly the same derivation and the -/// projection can never be carried across. -#[allow(clippy::too_many_arguments)] -pub fn accept( - source: &dyn ObjectSource, - target: &TargetDef, - root: &str, - candidate: &Candidate, - derived: &DerivedCommit, - head: Option<&str>, - base_snapshot: Option<&str>, -) -> Result { - let mut states = head_states(source, head)?; - let key = (target.name.clone(), derived.transition.module.clone()); - let previous = state_of(source, &states, target, &derived.transition.module)?.0; - states.insert(key, derived.new_state_key.clone()); +## The command line contract - let parents: Vec = match head { - Some(snapshot) => vec![crate::objects::snapshot_event(source, snapshot)?], - None => Vec::new(), - }; - let event = event_hash(&derived.transition_id, &parents); - let snapshot = snapshot_hash(&event, &states); +Every command the registry serves declares its full grammar in its +`CommandSpec`: name, a one-line summary, the exact usage line, and the +closed table of flags it accepts, each with arity and meaning. The +dispatch loop owns two behaviors ahead of every command, so no command +can get them wrong. First, help: `napl help` lists every registered +command with its summary, `napl help ` and `--help` or `-h` +anywhere in a command's arguments print that command's usage line, flag +table, and summary, exit 0, and never invoke the command. Help speaks +human by default and machine on request: when stdout is a terminal or +`--human` is passed, help renders readable text from the registry, +usage line, summary, flag table with meanings, and one worked example +per command; when stdout is piped or `--json` is passed, help emits +the canonical JSON registry exactly as today, byte-stable. Help is +the only command with a human rendering; every other command speaks +canonical JSON everywhere, and the two help renderings derive from +the one registry so they can never disagree. Help is a pure +read: no store access, no filesystem write, no state change of any kind, +and this holds for every command including `init` and `watch`. Second, +strict parsing: an argument beginning with `--` that the command's flag +table does not name is a usage error, never ignored; the error goes to +stderr as canonical JSON naming the unknown flag, listing the command's +valid flags, and suggesting the nearest declared flag by edit distance +when one is close, and the command does not execute. Exit 2 is the +pinned usage-error code for the whole binary: unknown command, unknown +flag, missing required flag or positional, malformed flag value. A +usage error always restates the command's exact usage line, so the +error itself teaches the grammar. - let mut objects = vec![ - json_object(ObjectKind::Event, &event_json(&derived.transition_id, &parents)), - json_object(ObjectKind::Snapshot, &snapshot_json(&event, &states)), - ]; +The parser behind this contract is clap's builder API, constructed at +dispatch from the CommandSpec registry, never derive macros and never +per-command clap declarations: the registry stays the single source of +grammar and clap is its execution engine. clap's own renderings are +never emitted; every parse failure is caught and served as the +canonical JSON usage error above, byte-shaped by this contract alone. +`--flag value` and `--flag=value` parse identically; no command +declares short flags today, so clustering is outside the contract +until one does. The registry pre-pass, not clap, is what refuses an +undeclared flag, because the canonical JSON payload with its +nearest-name suggestion is pinned by this contract; clap normalizes +spellings and values behind it. `napl completions ` emits a completion script for +`bash`, `zsh`, or `fish`, generated from the same registry, a pure +read on stdout; an unknown shell name is a usage error. - // The projection is recomputed from the merged member set, never reused. - // Members are the crate directories the committed modules occupy, read off - // each module's own output, so the workspace manifest lists placement paths. - let members = member_roots(source, &states, target)?; - let (projection_path, projection_bytes) = derive_projection(target, &members); - objects.push(blob_object(&projection_bytes)); +The commands whose meaning requires a committed head are exactly +`diff`, `blame`, and `notes add`; in a workspace that has none they +answer with a prerequisite error, never an empty success shape: the +payload names what is missing and the exact sequence that creates it, +from `napl init` through writing a prompt beside `.napl`, `add`, and +`commit`. `log` and `show` keep their committed contracts and answer +empty honestly. `napl blame` on a path no committed module owns, and +`napl diff` for a target under which no committed module owns output, +say so: the payload carries `tracked: false` and a reason naming the +absent baseline, never an empty patch or empty ancestry that reads as +success. Diff's operand is a target name and its usage line spells it +``; an operand that is not one (a module name, a file +path) is a usage error naming the target names that exist, so the +argument teaches its own vocabulary. The `napl notes add` refusal for a working path +that is not an object name states how an object name is obtained, +naming `napl log` and `napl show` with a one-line example. - // The crate's shared entry files are derived projections too. The crate root - // `lib.rs` is recomputed from the union of the crate's committed module file - // sets and the command tables those modules expose, so no module owns it. - let (crate_files, providers) = crate_module_facts(source, target, &states, root)?; - let (lib_path, lib_bytes) = derive_lib_projection(target, root, &crate_files, &providers); - objects.push(blob_object(&lib_bytes)); - let crate_projections = projection_owned_paths(target, root); +::: test +name: flag equals value and separated value parse identically +given: { workspace: fixture_two_modules, invocations: ["status --module leaf", "status --module=leaf"] } +expect: { outputs_byte_identical: true } +::: - // Diffing the committed output tree against the candidate tree is what - // bounds the critical section: the steps are counted before the lock, and - // applying one never hashes content. - let mut trees: BTreeMap = BTreeMap::new(); - // A module with no committed output has an output tree that was never - // published; there is nothing to diff against, which is exactly "no old - // side" rather than a missing object. - let previous_root = load_tree_objects(source, &previous.output_tree, &mut trees) - .ok() - .map(|()| previous.output_tree.clone()); - for (name, tree) in &candidate.tree_objects { - trees.insert(name.clone(), tree.clone()); - } - let mut changes: Vec = diff_trees( - previous_root.as_deref(), - Some(&candidate.tree_hash), - &trees, - ) - .map_err(|e| { - error( - OutcomeCode::StoreIntegrity, - format!("cannot diff the module's owned output: {e}"), - ) - })? - .into_iter() - // A projection path never rides the owned diff: an earlier regime may have - // installed it as an owned file, and its removal from this module's output - // must not delete the projection the same plan is about to write. - .filter(|change| !crate_projections.iter().any(|p| p == change_path(change))) - .collect(); - // Both projections ride the same plan. The workspace manifest classifies as - // the snapshot-wide derived view; the crate root `lib.rs` classifies as an - // ordinary owned write and is authored here rather than by any module. - changes.push(TreeChange::Added { - path: projection_path.clone(), - hash: napl_store::store_hash::object_name(ObjectKind::Blob, &projection_bytes), - mode: FILE_MODE, - }); - changes.push(TreeChange::Added { - path: lib_path.clone(), - hash: napl_store::store_hash::object_name(ObjectKind::Blob, &lib_bytes), - mode: FILE_MODE, - }); - let steps = plan_install_steps(&changes); +::: test +name: completions emits a script for a known shell and refuses an unknown one +given: { workspace: fixture_two_modules, invocations: ["completions zsh", "completions tcsh"] } +expect: { zsh_exit_code: 0, zsh_output_nonempty: true, tcsh_exit_code: 2, store_bytes_unchanged: true } +::: - Ok(Accepted { - snapshot, - event, - steps, - projection_path, - projection_bytes, - objects, - rebased: head != base_snapshot, - }) -} +::: test +name: blame on an unowned path and diff on an outputless target say untracked +given: { workspace: fixture_two_modules, blame_path: file_no_module_owns, diff_target: target_without_outputs } +expect: { tracked: false, reason_names_baseline: true, exit_code: 0 } +::: -/// The crate directories the committed modules of a target occupy, sorted and -/// deduplicated. Each root is read off a module's own committed output as the -/// directory holding its manifest, so the workspace manifest lists placement -/// paths rather than module names, and several modules sharing one crate -/// contribute that crate exactly once. -fn member_roots( - source: &dyn ObjectSource, - states: &BTreeMap<(String, String), String>, - target: &TargetDef, -) -> Result> { - let suffix = format!("/{}", target.manifest_file); - let mut roots: Vec = Vec::new(); - for ((state_target, _module), state_key) in states { - if state_target != &target.name { - continue; - } - let state = read_module_state(source, state_key)?; - for (path, _blob) in base_output_files(source, &state)? { - if let Some(root) = path.strip_suffix(&suffix) { - if !root.is_empty() { - roots.push(root.to_string()); - } - } - } - } - roots.sort(); - roots.dedup(); - Ok(roots) -} +::: test +name: status reports code-side drift and stale references beside prompt freshness +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } +expect: { module_drift: true, stale_reference_count: 1, prompt_side_fresh: true } +::: -/// The path a tree change touches, regardless of its variant. -fn change_path(change: &TreeChange) -> &str { - match change { - TreeChange::Added { path, .. } => path, - TreeChange::Removed { path, .. } => path, - TreeChange::Modified { path, .. } => path, - } -} +::: test +name: a headless workspace answers baseline commands with the prerequisite sequence +given: { workspace: fresh_init_no_commits, invocation: "blame some/file.rs" } +expect: { error_names_missing_head: true, sequence_named: [init, prompt, add, commit] } +::: -/// The union of a crate's committed module file sets and the command-table -/// providers among them, read off the merged states. -/// -/// A module belongs to the crate when its committed output carries the crate -/// manifest, the one shared path every module of the crate holds. A module -/// provides a command table when its recorded surface exposes a nullary -/// `commands` function; its file stem is the path the registry aggregator calls. -fn crate_module_facts( - source: &dyn ObjectSource, - target: &TargetDef, - states: &BTreeMap<(String, String), String>, - root: &str, -) -> Result<(Vec, Vec)> { - let manifest = format!("{root}/{}", target.manifest_file); - let mut files: Vec = Vec::new(); - let mut providers: Vec = Vec::new(); - for ((state_target, _module), state_key) in states { - if state_target != &target.name { - continue; - } - let state = read_module_state(source, state_key)?; - let owned = base_output_files(source, &state)?; - if !owned.iter().any(|(path, _)| path == &manifest) { - continue; - } - for (path, _hash) in &owned { - files.push(path.clone()); - } - for item in read_surface_items(source, &state.surface_hash)? { - if item.kind != "fn" { - continue; - } - let Some(prefix) = item.path.strip_suffix("::commands") else { - continue; - }; - if !prefix.is_empty() && !prefix.contains(' ') { - providers.push(prefix.to_string()); - } - } - } - Ok((files, providers)) -} +::: test +name: init with a help flag prints usage and mutates nothing +given: { directory: empty, invocation: "init --help" } +expect: { exit_code: 0, created: [], usage_printed: true } +::: -/// Reject a candidate whose crate manifest disagrees with a sibling module's -/// committed manifest bytes. -/// -/// The crate manifest is owned and shared: every module of a crate carries -/// identical bytes. A disagreement is a defect rather than an install, because -/// installing it would overwrite another module's committed manifest. -pub fn check_manifest_identity( - source: &dyn ObjectSource, - target: &TargetDef, - root: &str, - module: &str, - candidate: &Candidate, -) -> Result<()> { - let manifest = format!("{root}/{}", target.manifest_file); - let Some(mine) = candidate.files.get(&manifest) else { - return Ok(()); - }; - let states = head_states(source, source.head()?.as_deref())?; - for ((state_target, state_module), state_key) in &states { - if state_target != &target.name || state_module == module { - continue; - } - let state = read_module_state(source, state_key)?; - for (path, hash) in base_output_files(source, &state)? { - if path != manifest { - continue; - } - let theirs = crate::store_view::get_blob(source, &hash)?; - if &theirs != mine { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "candidate manifest {manifest:?} disagrees with module {state_module:?}'s committed manifest bytes" - ), - )); - } - } - } - Ok(()) -} +::: test +name: an unknown flag is a usage error naming the flag and suggesting the nearest +given: { workspace: fixture_two_modules, invocation: "add cand --modul m" } +expect: { exit_code: 2, error_names_unknown: "--modul", suggested: "--module", executed: false } +::: -/// The canonical success payload of a commit. -pub fn success_payload(derived: &DerivedCommit, accepted: &Accepted) -> Value { - json!({ - "blobsPublished": true, - "contractVersion": crate::version::CONTRACT_VERSION, - "derivedProjections": [accepted.projection_path.clone()], - "evidence": derived.evidence, - "head": accepted.snapshot, - "installed": derived.transition.footprint.install_paths, - "outcome": "success", - "provenancePublished": true, - "rebased": accepted.rebased, - "receipt": derived.receipt_object, - "semanticStatePublished": true, - "surfaceHash": derived.surface_hash, - "transition": derived.transition_id, - }) -} +::: test +name: help lists every registered command and per-command help states exact usage +given: { workspace: fixture_two_modules, invocations: ["help", "help add"] } +expect: { all_commands_listed: true, add_usage_line_present: true, exit_code: 0, store_bytes_unchanged: true } +::: -/// The union of write sets committed between `base` and `head`. -pub fn intervening_write_set( - source: &dyn ObjectSource, - head: Option<&str>, - base: Option<&str>, -) -> Result> { - let Some(head) = head else { - return Ok(BTreeSet::new()); - }; - let head_event = crate::objects::snapshot_event(source, head)?; - let base_event = match base { - Some(snapshot) => Some(crate::objects::snapshot_event(source, snapshot)?), - None => None, - }; - let mut out: BTreeSet = BTreeSet::new(); - for event in crate::objects::walk_events(source, &head_event, base_event.as_deref())? { - let transition = crate::objects::event_transition(source, &event)?; - out.extend(crate::objects::transition_write_set(source, &transition)?); - } - Ok(out) -} +::: test +name: help renders human text under the human flag and identical json when piped +given: { workspace: fixture_two_modules, invocations: ["help --human", "help --json"] } +expect: { human_not_json: true, human_carries_example: true, json_byte_stable: true } +::: -fn blob_object(bytes: &[u8]) -> PendingObject { - PendingObject { - name: napl_store::store_hash::object_name(ObjectKind::Blob, bytes), - kind: ObjectKind::Blob, - payload: bytes.to_vec(), - } -} +Manifests are user-owned glue seams, not records: `Cargo.toml` at the +workspace root and in every crate belongs to the user, edited by hand +like any Rust project, tracked by git, consumed by cargo, and NEVER +owned, installed, validated, or rewritten by the toolchain. A candidate +containing a manifest path is rejected as invalid-candidate; no module's +owned output includes one; nothing in the store records manifest bytes. +The toolchain's only relationship to manifests is diagnosis: `status` +reports, as observations never as writes, a module placed in a crate the +workspace manifest does not list, or a crate directory missing a +manifest. Every manifest diagnostic names the exact workspace-relative +manifest path it means and says the file is the user's to edit, so the +manifest is never a thing status alludes to without locating. A dependency needed by generated code is added by editing the +manifest, and the build battery is what proves it correct. -fn json_object(kind: ObjectKind, value: &Value) -> PendingObject { - let (name, payload) = hash_json(kind, value); - PendingObject { - name, - kind, - payload, - } -} +::: test +name: a candidate containing a manifest path is rejected +given: { workspace: fixture_two_modules, candidate: contains_cargo_toml } +expect: { outcome: invalid_candidate, manifest_bytes_unchanged: true } +::: -fn tree_value(tree: &Tree) -> Value { - serde_json::to_value(tree).expect("a tree always serializes") -} -napl-store tree v1 254 -{"entries":[{"hash":"16022ce4ca0f16dbdca6546700a2348fe4f7e66448351a8a0e29dbe532eec294","kind":"file","mode":33188,"name":"mod.rs"},{"hash":"302f88d8a2cb814f96c9eb6e8e9deb7ed6cbd62c458c1dd92fc096612d7f48af","kind":"file","mode":33188,"name":"tests.rs"}]} -napl-store tree v1 254 -{"entries":[{"hash":"4b2fe9210499da6b7cd566c3d2e587c1bebad47ef3f0d83aebe6c0358ef056cf","kind":"file","mode":33188,"name":"mod.rs"},{"hash":"62258119745f0a50e2e85bf0c785de15c4debb337165d751bb1069f164f72f60","kind":"file","mode":33188,"name":"tests.rs"}]} -napl-store blob v1 10331 -//! Evidence validators: a registry keyed by media type, plus compiled-in bounds. -//! -//! Evidence is provenance a producer attaches to a receipt. It never enters a -//! semantic identity, so a validator can only ever change what the report says, -//! never what gets installed. An invalid item stays visible as failed rather -//! than being dropped, because silently discarding failed evidence would make a -//! bad run look like a clean one. +## The two principles -use serde_json::{json, Value}; +P1 ownership: the toolchain owns `.napl` source, the derived layer, the +store, and the mappings between them; every owned fact is provable from +content alone. It contains no LLM invocation, no provider identifier, no +model selection, no completion parsing, no process execution (the only +process is `napl` itself), no clock reads in any semantic path, no +randomness in any semantic path. Generation happens outside: the user's +agent session reads the plan from `napl status`, the specification through +the query surface, writes candidate code, and calls `napl add` then +`napl commit`. -use crate::canon::canonical_bytes; -use crate::error::{error, OutcomeCode, Result}; -use crate::receipt::UNAVAILABLE; +::: test +name: the binaries spawn no process and name no provider +given: { crate_source: self } +expect: { spawns_processes: false, provider_identifiers: [] } +::: -/// The most evidence items one receipt may carry. -pub const MAX_EVIDENCE_ITEMS: usize = 8; +P2 footprint scoping: every operation declares a read set (content +identities consumed), a semantic write set (owned records changed), and its +derived projections plus physical install paths. Two operations conflict +when semantic write sets overlap or one's accepted write changes a content +identity in the other's read set. Derived projections (the crate-root +lib.rs module list, the command registry) are never conflict-bearing: +they are recomputed exactly once from the winning merged head inside +commit and rebase. Manifests are outside footprints entirely: user-owned +seams no operation reads as identity or writes at all. -/// The largest a single evidence body may be, in bytes. -pub const MAX_EVIDENCE_ITEM_BYTES: usize = 64 * 1024; +## Identity -/// The largest every evidence body may be together, in bytes. -pub const MAX_EVIDENCE_TOTAL_BYTES: usize = 256 * 1024; +Expose `pub const CONTRACT_VERSION: u32 = 4` in napl-core (bumped from 1 +by the body-directive grammar, which changes how the same source bytes +are interpreted, from 2 by the relaxed module-name rule below, which +accepts sources contract 2 rejected, and from 3 by the capture verbs, +whose three refusal outcomes join the closed terminal-outcome table). Per module and +target: `surfaceHash` (hash of the recorded public surface), `inputKey` +(hash of the canonical closure manifest over normalized source hash, direct +dependencies' surfaceHashes, placement facts, entry-point version, target +name plus target-definition hash, CONTRACT_VERSION), `baseStateKey` (the +module's committed owned-output state hash), `sliceHash` (hash of the +module's canonical compile slice). Whole-compile `artifactHash` includes +scope and serves determinism tests only, never a commit precondition. Event +identity is `H(content hash + sorted parent hashes)`; ordinals and +timestamps are display metadata, never identity. Semantic identifiers are +content hashes, never a timestamp, pid, hostname, random value, model, or +provider. Telemetry lives in provenance records linking to semantic objects +by hash. -/// The media type of the Claude CLI JSON envelope, the first shipped validator. -pub const CLAUDE_CLI_JSON: &str = "application/vnd.claude-cli+json"; +::: test +name: "a module's inputKey covers source, direct dep surfaces, placement, target identity, and contract version" +given: { workspace: fixture_two_modules, module: consumer } +expect: { input_key_components: [source_hash, direct_dep_surface_hashes, placement_facts, target_name, target_definition_hash, entry_point_version, contract_version] } +::: -/// The media type of the usage-proxy log, the second shipped validator. The -/// usage-proxy prompt names the type and writes the lines; the registry here is -/// the only thing that judges them. -pub const USAGE_PROXY_JSONL: &str = "application/x-napl-usage-proxy-jsonl-v1"; +::: test +name: changing a leaf's surface changes the direct dependent's inputKey only +given: { workspace: fixture_chain_three, edit: rename_public_item_in_leaf } +expect: { changed_input_keys: [middle], unchanged_input_keys: [top] } +::: -/// The fields every usage-proxy log line carries, in canonical order. A line -/// missing any of them fails the shape. -const USAGE_PROXY_FIELDS: [&str; 7] = [ - "endpoint", - "model", - "requestId", - "session", - "status", - "timestamp", - "usage", -]; +::: test +name: a leaf edit absorbed by an unchanged intermediate surface leaves higher dependents' inputKeys unchanged +given: { workspace: fixture_chain_three, edit: private_body_change_in_leaf } +expect: { changed_input_keys: [], unchanged_input_keys: [middle, top] } +::: -/// The verdict on one evidence item. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EvidenceOutcome { - /// A registered validator ran and accepted the body. - Validated, - /// No validator is registered for the media type. - Unsupported, - /// A registered validator ran and rejected the body. - Invalid, - /// A registered validator could not run at all. - ValidatorError, -} +::: test +name: telemetry differences never change transition identity +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipts: [with_usage, without_usage] } +expect: { transition_identities_equal: true } +::: -/// The stable wire token for a verdict. -pub fn evidence_slug(outcome: EvidenceOutcome) -> &'static str { - match outcome { - EvidenceOutcome::Validated => "validated", - EvidenceOutcome::Unsupported => "unsupported", - EvidenceOutcome::Invalid => "invalid", - EvidenceOutcome::ValidatorError => "validator-error", - } -} +::: test +name: history nodes are causal hashes over content plus sorted parents +given: { workspace: fixture_two_modules, commits: 2 } +expect: { event_id_components: [content_hash, sorted_parent_ids], ordinal_in_identity: false, timestamp_in_identity: false } +::: -/// One evidence item as a receipt carries it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EvidenceItem { - pub media_type: String, - pub body: String, -} +## Module names -/// The media types this build knows how to validate, sorted. -pub fn registered_media_types() -> Vec { - let mut out = vec![CLAUDE_CLI_JSON.to_string(), USAGE_PROXY_JSONL.to_string()]; - out.sort(); - out -} +A module name is any non-empty single-line scalar. The empty string is the only +value rejected, surfaced as `mapping-integrity`. No character class, case, or +identifier shape is required: `todo-app`, `todo_app`, and non-ascii names are all +legal. The same rule governs every name position the parser reads, the `module` +frontmatter value, each `deps` entry, and each declared root, so all three accept +exactly the set the module rule accepts. This relaxation replaces the contract 2 +identifier constraint and rides the CONTRACT_VERSION bump to 3, the sanctioned +identity transition. As with the 1-to-2 bump, every module's inputKey recomputes +because CONTRACT_VERSION is one of its components; no source or surface changes, +only the version does. -/// Read the evidence list out of a receipt's reported block. -/// -/// A reported block with no `evidence` key carries no evidence; an `evidence` -/// key that is not a list of well-formed items is an `invalid-receipt`, because -/// a malformed shape is a producer bug and not something to guess past. -pub fn parse_evidence(reported: &Value) -> Result> { - let raw = match reported.get("evidence") { - None | Some(Value::Null) => return Ok(Vec::new()), - Some(Value::String(_)) => return Ok(Vec::new()), - Some(Value::Array(items)) => items.clone(), - Some(_) => { - return Err(error( - OutcomeCode::InvalidReceipt, - "reported.evidence must be an array", - )) - } - }; - let mut out: Vec = Vec::new(); - for item in &raw { - let media_type = item["mediaType"].as_str().ok_or_else(|| { - error( - OutcomeCode::InvalidReceipt, - "evidence item has no mediaType string", - ) - })?; - let body = item["body"].as_str().ok_or_else(|| { - error( - OutcomeCode::InvalidReceipt, - "evidence item has no body string", - ) - })?; - out.push(EvidenceItem { - media_type: media_type.to_string(), - body: body.to_string(), - }); - } - Ok(out) -} +Because a name is unrestricted, it is never used raw as a filesystem path segment +or a store key. napl-core exposes one injective, reversible encoding used at every +such site: each byte outside `[A-Za-z0-9._-]` is percent-encoded as `%XX` with +uppercase hex, and a name that is exactly `.` or `..` has its dots percent-encoded +too, so an encoded segment is never `/`, `\`, empty, `.`, or `..` and can never +traverse or alias a directory. A name already matching `[A-Za-z0-9._-]` and not +equal to `.` or `..` encodes to itself, so existing records keep their on-disk +names: `todo-app` stays `todo-app`. Every writer that keys a record or scratch +path by module name, the mapl, ir, attribution, and prompt-at-gen documents, the +staging and relocation scratch directories, and the store root map, uses the +encoded segment; decoding recovers the exact name. On a case-insensitive +filesystem two names differing only in ascii case can still share one segment; +that collision is the author's concern, distinct from the traversal safety above. -/// Enforce the compiled-in evidence bounds. Exceeding any is `resource-limit`, -/// checked before anything at all is published. -pub fn check_evidence_bounds(items: &[EvidenceItem]) -> Result<()> { - if items.len() > MAX_EVIDENCE_ITEMS { - return Err(error( - OutcomeCode::ResourceLimit, - format!( - "receipt carries {} evidence items, limit is {MAX_EVIDENCE_ITEMS}", - items.len() - ), - )); - } - let mut total = 0usize; - for item in items { - let len = item.body.len(); - if len > MAX_EVIDENCE_ITEM_BYTES { - return Err(error( - OutcomeCode::ResourceLimit, - format!( - "evidence item of type {:?} is {len} bytes, limit is {MAX_EVIDENCE_ITEM_BYTES}", - item.media_type - ), - )); - } - total += len; - if total > MAX_EVIDENCE_TOTAL_BYTES { - return Err(error( - OutcomeCode::ResourceLimit, - format!("receipt evidence exceeds {MAX_EVIDENCE_TOTAL_BYTES} total bytes"), - )); - } - } - Ok(()) -} +Wherever a module name is serialized into a record it is written as a quoted, +escaped scalar, never interpolated raw, so a name containing YAML or JSON +metacharacters reparses to the identical name. -/// Validate one evidence item through the registry. -pub fn validate_evidence(item: &EvidenceItem) -> EvidenceOutcome { - match item.media_type.as_str() { - CLAUDE_CLI_JSON => validate_claude_cli_json(&item.body), - USAGE_PROXY_JSONL => validate_usage_proxy_jsonl(&item.body), - _ => EvidenceOutcome::Unsupported, - } -} +::: test +name: a non-identifier module name is accepted +given: { workspace: fixture_single_module, module_name: "todo-app" } +expect: { outcome: ok } +::: -/// Validate every item, reporting each verdict in the order the receipt lists -/// them. -pub fn validate_all(items: &[EvidenceItem]) -> Vec<(EvidenceItem, EvidenceOutcome)> { - items - .iter() - .map(|item| (item.clone(), validate_evidence(item))) - .collect() -} +::: test +name: an empty module name is rejected as mapping-integrity +given: { workspace: fixture_single_module, module_name: "" } +expect: { outcome: mapping_integrity } +::: -/// The canonical report block for a validated evidence set. -pub fn evidence_report(items: &[(EvidenceItem, EvidenceOutcome)]) -> Value { - let entries: Vec = items - .iter() - .map(|(item, outcome)| { - json!({ - "bytes": item.body.len() as u64, - "mediaType": item.media_type, - "outcome": evidence_slug(*outcome), - }) - }) - .collect(); - json!({ - "items": entries, - "registeredMediaTypes": registered_media_types(), - }) -} +::: test +name: deps entries and declared roots accept the same names as the module rule +given: { module_name: "todo-app", dep_name: "a-b", root_name: "x.y" } +expect: { all_accepted: true } +::: -/// The Claude CLI JSON envelope validator. -/// -/// It checks shape, never content: a `result` envelope carrying a `usage` object -/// with integer input and output token counts. Anything else is `invalid`, which -/// is deliberately distinguishable from `unsupported`. -fn validate_claude_cli_json(body: &str) -> EvidenceOutcome { - let Ok(value) = serde_json::from_str::(body) else { - return EvidenceOutcome::Invalid; - }; - let Some(object) = value.as_object() else { - return EvidenceOutcome::Invalid; - }; - if object.get("type").and_then(Value::as_str) != Some("result") { - return EvidenceOutcome::Invalid; - } - let Some(usage) = object.get("usage").and_then(Value::as_object) else { - return EvidenceOutcome::Invalid; - }; - let counted = ["input_tokens", "output_tokens"] - .iter() - .all(|key| usage.get(*key).and_then(Value::as_u64).is_some()); - if counted { - EvidenceOutcome::Validated - } else { - EvidenceOutcome::Invalid - } -} +::: test +name: the name encoding is injective reversible and never traverses +given: { names: ["todo-app", "a/b", "..", "café", "a b"] } +expect: { decode_roundtrip_equal: true, no_segment_in: ["/", "\\", "", ".", ".."], safe_subset_unchanged: ["todo-app"] } +::: -/// The usage-proxy log validator. -/// -/// The log is one canonical JSON object per line, so the shape is checked line by -/// line and canonicity is checked by re-serializing: a line that reparses to -/// different bytes is not the line the proxy writes. Every field the proxy -/// promises must be present, an absent value is the string `unavailable` rather -/// than a null or a zero, and a token count that is present must be a -/// non-negative integer. A line failing any of that is `invalid`, which stays -/// visible as failed rather than being read as a cheap run. -fn validate_usage_proxy_jsonl(body: &str) -> EvidenceOutcome { - let mut lines = 0usize; - for line in body.split('\n') { - if line.is_empty() { - continue; - } - let Ok(value) = serde_json::from_str::(line) else { - return EvidenceOutcome::Invalid; - }; - let Some(object) = value.as_object() else { - return EvidenceOutcome::Invalid; - }; - let mut canonical = canonical_bytes(&value); - if canonical.last() == Some(&b'\n') { - canonical.pop(); - } - if canonical != line.as_bytes() { - return EvidenceOutcome::Invalid; - } - if !USAGE_PROXY_FIELDS.iter().all(|key| object.contains_key(*key)) { - return EvidenceOutcome::Invalid; - } - let texts = ["endpoint", "model", "requestId", "session", "timestamp"]; - if !texts - .iter() - .all(|key| object[*key].as_str().is_some_and(|text| !text.is_empty())) - { - return EvidenceOutcome::Invalid; - } - if object["status"].as_u64().is_none() { - return EvidenceOutcome::Invalid; - } - if !usage_shape_holds(&object["usage"]) { - return EvidenceOutcome::Invalid; - } - lines += 1; - } - if lines == 0 { - EvidenceOutcome::Invalid - } else { - EvidenceOutcome::Validated - } -} +::: test +name: a name with yaml metacharacters is written quoted and reparses identically +given: { module_name: "a: {b} #c" } +expect: { record_reparse_equal: true, written_as_quoted_scalar: true } +::: -/// The `usage` field of one log line: the string `unavailable`, or an object -/// whose token counts are non-negative integers wherever they appear. -fn usage_shape_holds(usage: &Value) -> bool { - if usage.as_str() == Some(UNAVAILABLE) { - return true; - } - let Some(object) = usage.as_object() else { - return false; - }; - object - .iter() - .filter(|(key, _)| key.ends_with("tokens")) - .all(|(_, value)| value.as_u64().is_some()) -} +## The compile derivation -/// Enforce the bound on the whole reported block's canonical size. -pub fn check_reported_bounds(reported: &Value, limit: usize) -> Result<()> { - let len = canonical_bytes(reported).len(); - if len > limit { - return Err(error( - OutcomeCode::ResourceLimit, - format!("reported block is {len} bytes, limit is {limit}"), - )); - } - Ok(()) -} -napl-store transition v1 1476 -{"baseStateKey":"71e64c38c6aead99990634a42ca5c8babc3c01b7c4379efde5a8ca121da714fb","contractVersion":1,"derivedProjections":["Cargo.toml"],"inputKey":"b7e0b1e3ed33336e39d9b00f9f89441fa1c3b033336dd874608e4cfdafcb9ddc","installPaths":["napl/Cargo.toml","napl/src/authoring.rs","napl/src/blame.rs","napl/src/candidate.rs","napl/src/canon.rs","napl/src/cli.rs","napl/src/commit.rs","napl/src/compile.rs","napl/src/cost.rs","napl/src/decision.rs","napl/src/disk.rs","napl/src/error.rs","napl/src/evidence.rs","napl/src/graph.rs","napl/src/lib.rs","napl/src/lint.rs","napl/src/lsp.rs","napl/src/main.rs","napl/src/maintenance.rs","napl/src/objects.rs","napl/src/paths.rs","napl/src/porcelain.rs","napl/src/receipt.rs","napl/src/slice.rs","napl/src/source.rs","napl/src/store_view.rs","napl/src/surface.rs","napl/src/target.rs","napl/src/textdiff.rs","napl/src/transaction.rs","napl/src/trees.rs","napl/src/workspace.rs"],"module":"napl","newState":"f60ebd2b77e0dd55b8e4464e324f5818b6cc6cb79e0dfca8dfd7edd135c128b1","readSet":["base-state:71e64c38c6aead99990634a42ca5c8babc3c01b7c4379efde5a8ca121da714fb","input-key:b7e0b1e3ed33336e39d9b00f9f89441fa1c3b033336dd874608e4cfdafcb9ddc","target-def:0b29a1295ab8e708dc85ac080b628852fde73a8fdabf118653328d2d3712d172"],"receipt":"0671b9a60e3cd8c1f49c921433447a199ee383c6d357a1cbac9bc971d30d001d","surface":"671b6290852a9a7352ed4a1492c34ec8e935b83723b04e51b7eebb624cb52f4c","target":"rust","writeSet":["owned:napl","module-state:rust/napl"]} -napl-store surface v1 45882 -{"contractVersion":1,"items":[{"kind":"fn","path":"authoring::apply_fix","signature":"pub fn apply_fix ( workspace : & Path , target : & str , finding : & Finding ) -> Result < CommitReport >"},{"kind":"fn","path":"authoring::draft_receipt","signature":"pub fn draft_receipt ( workspace : & Path , target_name : & str , module : & str , candidate_dir : & Path ) -> Result < Value >"},{"kind":"struct","path":"blame::BlameLine","signature":"pub struct BlameLine"},{"kind":"field","path":"blame::BlameLine.line","signature":"pub line : usize"},{"kind":"field","path":"blame::BlameLine.text","signature":"pub text : String"},{"kind":"field","path":"blame::BlameLine.transition","signature":"pub transition : String"},{"kind":"struct","path":"blame::Revision","signature":"pub struct Revision"},{"kind":"field","path":"blame::Revision.content","signature":"pub content : String"},{"kind":"field","path":"blame::Revision.transition","signature":"pub transition : String"},{"kind":"fn","path":"blame::blame","signature":"pub fn blame ( revisions : & [ Revision ] ) -> Vec < BlameLine >"},{"kind":"fn","path":"blame::blame_json","signature":"pub fn blame_json ( path : & str , lines : & [ BlameLine ] ) -> Value"},{"kind":"struct","path":"candidate::Candidate","signature":"pub struct Candidate"},{"kind":"field","path":"candidate::Candidate.files","signature":"pub files : BTreeMap < String , Vec < u8 > >"},{"kind":"field","path":"candidate::Candidate.manifest","signature":"pub manifest : BTreeMap < String , String >"},{"kind":"field","path":"candidate::Candidate.tree_hash","signature":"pub tree_hash : String"},{"kind":"field","path":"candidate::Candidate.tree_objects","signature":"pub tree_objects : BTreeMap < String , Tree >"},{"kind":"enum","path":"candidate::EntryKind","signature":"pub enum EntryKind { File , Link , Device }"},{"kind":"struct","path":"candidate::Limits","signature":"pub struct Limits"},{"kind":"field","path":"candidate::Limits.max_entries","signature":"pub max_entries : usize"},{"kind":"field","path":"candidate::Limits.max_entry_bytes","signature":"pub max_entry_bytes : usize"},{"kind":"field","path":"candidate::Limits.max_total_bytes","signature":"pub max_total_bytes : usize"},{"kind":"struct","path":"candidate::ScannedEntry","signature":"pub struct ScannedEntry"},{"kind":"field","path":"candidate::ScannedEntry.bytes","signature":"pub bytes : Vec < u8 >"},{"kind":"field","path":"candidate::ScannedEntry.kind","signature":"pub kind : EntryKind"},{"kind":"field","path":"candidate::ScannedEntry.path","signature":"pub path : String"},{"kind":"fn","path":"candidate::default_limits","signature":"pub fn default_limits ( ) -> Limits"},{"kind":"fn","path":"candidate::ingest","signature":"pub fn ingest ( entries : & [ ScannedEntry ] , limits : Limits ) -> Result < Candidate >"},{"kind":"fn","path":"canon::canonical_bytes","signature":"pub fn canonical_bytes ( value : & Value ) -> Vec < u8 >"},{"kind":"fn","path":"canon::hash_json","signature":"pub fn hash_json ( kind : ObjectKind , value : & Value ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"canon::is_object_name","signature":"pub fn is_object_name ( text : & str ) -> bool"},{"kind":"fn","path":"canon::json_hash","signature":"pub fn json_hash ( kind : ObjectKind , value : & Value ) -> String"},{"kind":"fn","path":"canon::parse_json","signature":"pub fn parse_json ( bytes : & [ u8 ] , context : & str , code : OutcomeCode ) -> Result < Value >"},{"kind":"struct","path":"cli::CliResult","signature":"pub struct CliResult"},{"kind":"field","path":"cli::CliResult.exit_code","signature":"pub exit_code : i32"},{"kind":"field","path":"cli::CliResult.stdout","signature":"pub stdout : Vec < u8 >"},{"kind":"struct","path":"cli::CommandSpec","signature":"pub struct CommandSpec"},{"kind":"field","path":"cli::CommandSpec.name","signature":"pub name : & ' static str"},{"kind":"field","path":"cli::CommandSpec.run","signature":"pub run : fn ( & Path , & [ String ] ) -> Result < CliResult >"},{"kind":"fn","path":"cli::run_cli","signature":"pub fn run_cli ( cwd : & Path , args : & [ String ] ) -> CliResult"},{"kind":"fn","path":"commandset::commands","signature":"pub fn commands ( ) -> & ' static [ CommandSpec ]"},{"kind":"struct","path":"commit::CommitReport","signature":"pub struct CommitReport"},{"kind":"field","path":"commit::CommitReport.outcome","signature":"pub outcome : OutcomeCode"},{"kind":"field","path":"commit::CommitReport.payload","signature":"pub payload : Value"},{"kind":"fn","path":"commit::commit","signature":"pub fn commit ( workspace : & Path , receipt_path : & Path , candidate_dir : & Path ) -> CommitReport"},{"kind":"fn","path":"commit::lock_key","signature":"pub fn lock_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"commit::recover","signature":"pub fn recover ( store : & Store ) -> Result < Vec < String > >"},{"kind":"struct","path":"compile::CompileArtifact","signature":"pub struct CompileArtifact"},{"kind":"field","path":"compile::CompileArtifact.artifact_hash","signature":"pub artifact_hash : String"},{"kind":"field","path":"compile::CompileArtifact.slices","signature":"pub slices : Vec < CompileSlice >"},{"kind":"field","path":"compile::CompileArtifact.value","signature":"pub value : Value"},{"kind":"fn","path":"compile::base_output_files","signature":"pub fn base_output_files ( source : & dyn ObjectSource , state : & ModuleState ) -> Result < Vec < ( String , String ) > >"},{"kind":"fn","path":"compile::compile","signature":"pub fn compile ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , only_module : Option < & str > ) -> Result < CompileArtifact >"},{"kind":"fn","path":"compile::graph_for","signature":"pub fn graph_for ( sources : & [ SourceModule ] , target_name : & str ) -> Result < Graph >"},{"kind":"fn","path":"compile::head_states","signature":"pub fn head_states ( source : & dyn ObjectSource , snapshot : Option < & str > ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"compile::read_surface_items","signature":"pub fn read_surface_items ( source : & dyn ObjectSource , hash : & str ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"compile::slice_at","signature":"pub fn slice_at ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , module : & str , snapshot : Option < & str > ) -> Result < CompileSlice >"},{"kind":"fn","path":"compile::state_of","signature":"pub fn state_of ( source : & dyn ObjectSource , states : & BTreeMap < ( String , String ) , String > , target : & TargetDef , module : & str ) -> Result < ( ModuleState , String ) >"},{"kind":"fn","path":"compile::workspace_source_hash","signature":"pub fn workspace_source_hash ( sources : & [ SourceModule ] ) -> Result < String >"},{"kind":"struct","path":"cost::CostLine","signature":"pub struct CostLine"},{"kind":"field","path":"cost::CostLine.cost_usd","signature":"pub cost_usd : Option < String >"},{"kind":"field","path":"cost::CostLine.model","signature":"pub model : Option < String >"},{"kind":"field","path":"cost::CostLine.module","signature":"pub module : String"},{"kind":"field","path":"cost::CostLine.receipt","signature":"pub receipt : String"},{"kind":"field","path":"cost::CostLine.tokens","signature":"pub tokens : Option < TokenUsage >"},{"kind":"const","path":"cost::UNKNOWN","signature":"pub const UNKNOWN : & str = \" unknown \" ;"},{"kind":"fn","path":"cost::cost_report","signature":"pub fn cost_report ( lines : & [ CostLine ] ) -> Value"},{"kind":"fn","path":"cost::line_from_reported","signature":"pub fn line_from_reported ( receipt : & str , module : & str , reported : & Value ) -> Result < CostLine >"},{"kind":"fn","path":"cost::pricing_provenance","signature":"pub fn pricing_provenance ( ) -> Value"},{"kind":"struct","path":"decision::Arriving","signature":"pub struct Arriving"},{"kind":"field","path":"decision::Arriving.base_snapshot","signature":"pub base_snapshot : Option < String >"},{"kind":"field","path":"decision::Arriving.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Arriving.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Arriving.write_set","signature":"pub write_set : BTreeSet < String >"},{"kind":"enum","path":"decision::Decision","signature":"pub enum Decision { Install , Rebase , StaleInput { expected : String , found : String } , BaseOutputChanged { expected : String , found : String } , WriteConflict { overlap : Vec < String > } }"},{"kind":"struct","path":"decision::Head","signature":"pub struct Head"},{"kind":"field","path":"decision::Head.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Head.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Head.intervening_write_set","signature":"pub intervening_write_set : BTreeSet < String >"},{"kind":"field","path":"decision::Head.snapshot","signature":"pub snapshot : Option < String >"},{"kind":"fn","path":"decision::conflicts","signature":"pub fn conflicts ( write_set_a : & BTreeSet < String > , read_set_a : & BTreeSet < String > , write_set_b : & BTreeSet < String > , changed_identities_b : & BTreeSet < String > ) -> bool"},{"kind":"fn","path":"decision::decide","signature":"pub fn decide ( arriving : & Arriving , head : & Head ) -> Decision"},{"kind":"fn","path":"decision::is_accepted","signature":"pub fn is_accepted ( decision : & Decision ) -> bool"},{"kind":"fn","path":"decision::rejection","signature":"pub fn rejection ( decision : & Decision ) -> Option < CompilerError >"},{"kind":"const","path":"disk::POINTER_LOCK","signature":"pub const POINTER_LOCK : & str = \" pointer \" ;"},{"kind":"const","path":"disk::STORE_DIR","signature":"pub const STORE_DIR : & str = \" .napl/store \" ;"},{"kind":"struct","path":"disk::Store","signature":"pub struct Store"},{"kind":"field","path":"disk::Store.root","signature":"pub root : PathBuf"},{"kind":"field","path":"disk::Store.workspace","signature":"pub workspace : PathBuf"},{"kind":"fn","path":"disk::compare_and_set_head","signature":"pub fn compare_and_set_head ( store : & Store , expected : Option < & str > , next : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::delete_marker","signature":"pub fn delete_marker ( store : & Store , snapshot : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::init","signature":"pub fn init ( workspace : & Path ) -> Result < Store >"},{"kind":"fn","path":"disk::io","signature":"pub fn io ( path : & Path , err : & std :: io :: Error ) -> CompilerError"},{"kind":"fn","path":"disk::marker_snapshots","signature":"pub fn marker_snapshots ( store : & Store ) -> Result < Vec < String > >"},{"kind":"fn","path":"disk::open","signature":"pub fn open ( workspace : & Path ) -> Store"},{"kind":"fn","path":"disk::publish","signature":"pub fn publish ( store : & Store , kind : ObjectKind , payload : & [ u8 ] , owner : & str ) -> Result < String >"},{"kind":"fn","path":"disk::read_marker","signature":"pub fn read_marker ( store : & Store , snapshot : & str ) -> Result < Option < CommitMarker > >"},{"kind":"fn","path":"disk::release_lock","signature":"pub fn release_lock ( guard : LockGuard ) -> Result < ( ) >"},{"kind":"fn","path":"disk::remove_object","signature":"pub fn remove_object ( store : & Store , name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::take_lock","signature":"pub fn take_lock ( store : & Store , name : & str ) -> Result < LockGuard >"},{"kind":"fn","path":"disk::write_marker","signature":"pub fn write_marker ( store : & Store , marker : & CommitMarker ) -> Result < ( ) >"},{"kind":"struct","path":"error::CompilerError","signature":"pub struct CompilerError"},{"kind":"field","path":"error::CompilerError.code","signature":"pub code : OutcomeCode"},{"kind":"field","path":"error::CompilerError.detail","signature":"pub detail : String"},{"kind":"enum","path":"error::OutcomeCode","signature":"pub enum OutcomeCode { Success , StaleInput , BaseOutputChanged , WriteConflict , LockContended , InvalidCandidate , InvalidReceipt , MappingIntegrity , ResourceLimit , UnsupportedVersion , StoreIntegrity , IoError }"},{"kind":"type","path":"error::Result","signature":"pub type Result < T > = std :: result :: Result < T , CompilerError > ;"},{"kind":"fn","path":"error::error","signature":"pub fn error ( code : OutcomeCode , detail : impl Into < String > ) -> CompilerError"},{"kind":"fn","path":"error::error_payload","signature":"pub fn error_payload ( err : & CompilerError , blobs_published : bool , provenance_published : bool ) -> Value"},{"kind":"fn","path":"error::outcome_exit_code","signature":"pub fn outcome_exit_code ( code : OutcomeCode ) -> i32"},{"kind":"fn","path":"error::outcome_is_retryable","signature":"pub fn outcome_is_retryable ( code : OutcomeCode ) -> bool"},{"kind":"fn","path":"error::outcome_slug","signature":"pub fn outcome_slug ( code : OutcomeCode ) -> & ' static str"},{"kind":"const","path":"evidence::CLAUDE_CLI_JSON","signature":"pub const CLAUDE_CLI_JSON : & str = \" application/vnd.claude-cli+json \" ;"},{"kind":"struct","path":"evidence::EvidenceItem","signature":"pub struct EvidenceItem"},{"kind":"field","path":"evidence::EvidenceItem.body","signature":"pub body : String"},{"kind":"field","path":"evidence::EvidenceItem.media_type","signature":"pub media_type : String"},{"kind":"enum","path":"evidence::EvidenceOutcome","signature":"pub enum EvidenceOutcome { Validated , Unsupported , Invalid , ValidatorError }"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEMS","signature":"pub const MAX_EVIDENCE_ITEMS : usize = 8 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEM_BYTES","signature":"pub const MAX_EVIDENCE_ITEM_BYTES : usize = 64 * 1024 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_TOTAL_BYTES","signature":"pub const MAX_EVIDENCE_TOTAL_BYTES : usize = 256 * 1024 ;"},{"kind":"fn","path":"evidence::check_evidence_bounds","signature":"pub fn check_evidence_bounds ( items : & [ EvidenceItem ] ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::check_reported_bounds","signature":"pub fn check_reported_bounds ( reported : & Value , limit : usize ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::evidence_report","signature":"pub fn evidence_report ( items : & [ ( EvidenceItem , EvidenceOutcome ) ] ) -> Value"},{"kind":"fn","path":"evidence::evidence_slug","signature":"pub fn evidence_slug ( outcome : EvidenceOutcome ) -> & ' static str"},{"kind":"fn","path":"evidence::parse_evidence","signature":"pub fn parse_evidence ( reported : & Value ) -> Result < Vec < EvidenceItem > >"},{"kind":"fn","path":"evidence::registered_media_types","signature":"pub fn registered_media_types ( ) -> Vec < String >"},{"kind":"fn","path":"evidence::validate_all","signature":"pub fn validate_all ( items : & [ EvidenceItem ] ) -> Vec < ( EvidenceItem , EvidenceOutcome ) >"},{"kind":"fn","path":"evidence::validate_evidence","signature":"pub fn validate_evidence ( item : & EvidenceItem ) -> EvidenceOutcome"},{"kind":"type","path":"graph::Graph","signature":"pub type Graph = BTreeMap < String , SourceModule > ;"},{"kind":"fn","path":"graph::build_graph","signature":"pub fn build_graph ( sources : & [ SourceModule ] , target : & TargetDef ) -> Result < Graph >"},{"kind":"fn","path":"graph::changed_frontier","signature":"pub fn changed_frontier ( graph : & Graph , committed_input_keys : & BTreeMap < String , Option < String > > , input_keys : & BTreeMap < String , String > , only_module : Option < & str > ) -> Result < Vec < String > >"},{"kind":"fn","path":"graph::dependents_of","signature":"pub fn dependents_of ( graph : & Graph , module : & str ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::index_sources","signature":"pub fn index_sources ( sources : & [ SourceModule ] ) -> Result < BTreeMap < String , SourceModule > >"},{"kind":"fn","path":"graph::reachable_from","signature":"pub fn reachable_from ( graph : & Graph , roots : & [ String ] ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::topological_levels","signature":"pub fn topological_levels ( graph : & Graph ) -> Result < BTreeMap < String , usize > >"},{"kind":"enum","path":"lint::Analysis","signature":"pub enum Analysis { Performed , Refused }"},{"kind":"enum","path":"lint::Finding","signature":"pub enum Finding { UnreachableModule { module : String , cascade : Vec < String > } , UnconsumedSurfaceEntry { module : String , path : String } , StaleModule { module : String } , UnusedDependency { module : String , dep : String } }"},{"kind":"struct","path":"lint::LintInput","signature":"pub struct LintInput"},{"kind":"field","path":"lint::LintInput.committed_input_keys","signature":"pub committed_input_keys : BTreeMap < String , Option < String > >"},{"kind":"field","path":"lint::LintInput.current_input_keys","signature":"pub current_input_keys : BTreeMap < String , String >"},{"kind":"field","path":"lint::LintInput.referenced_names","signature":"pub referenced_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"field","path":"lint::LintInput.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"lint::LintInput.surface_names","signature":"pub surface_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"struct","path":"lint::LintReport","signature":"pub struct LintReport"},{"kind":"field","path":"lint::LintReport.findings","signature":"pub findings : Vec < Finding >"},{"kind":"field","path":"lint::LintReport.unreachable_analysis","signature":"pub unreachable_analysis : Analysis"},{"kind":"fn","path":"lint::finding_json","signature":"pub fn finding_json ( finding : & Finding ) -> Value"},{"kind":"fn","path":"lint::is_auto_fixable","signature":"pub fn is_auto_fixable ( finding : & Finding ) -> bool"},{"kind":"fn","path":"lint::lint","signature":"pub fn lint ( graph : & Graph , input : & LintInput ) -> LintReport"},{"kind":"fn","path":"lint::lint_json","signature":"pub fn lint_json ( report : & LintReport ) -> Value"},{"kind":"struct","path":"lsp::Attribution","signature":"pub struct Attribution"},{"kind":"field","path":"lsp::Attribution.entries","signature":"pub entries : Vec < Entry >"},{"kind":"field","path":"lsp::Attribution.module","signature":"pub module : String"},{"kind":"field","path":"lsp::Attribution.target","signature":"pub target : String"},{"kind":"struct","path":"lsp::Entry","signature":"pub struct Entry"},{"kind":"field","path":"lsp::Entry.file","signature":"pub file : String"},{"kind":"field","path":"lsp::Entry.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"lsp::Entry.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"lsp::Entry.note","signature":"pub note : String"},{"kind":"field","path":"lsp::Entry.prompt_end","signature":"pub prompt_end : u32"},{"kind":"field","path":"lsp::Entry.prompt_start","signature":"pub prompt_start : u32"},{"kind":"fn","path":"lsp::parse_attribution","signature":"pub fn parse_attribution ( text : & str ) -> Option < Attribution >"},{"kind":"fn","path":"lsp::serve","signature":"pub fn serve < R : BufRead , W : Write > ( reader : & mut R , writer : & mut W )"},{"kind":"struct","path":"maintenance::FileFact","signature":"pub struct FileFact"},{"kind":"field","path":"maintenance::FileFact.hash","signature":"pub hash : String"},{"kind":"field","path":"maintenance::FileFact.path","signature":"pub path : String"},{"kind":"enum","path":"maintenance::FsckFinding","signature":"pub enum FsckFinding { HashMismatch { object : String , detail : String } , MissingParent { event : String , parent : String } , MissingObject { from : String , missing : String } }"},{"kind":"enum","path":"maintenance::RestorePlan","signature":"pub enum RestorePlan { Apply ( Vec < RestoreStep > ) , Blocked { path : String , candidates : Vec < String > } }"},{"kind":"enum","path":"maintenance::RestoreStep","signature":"pub enum RestoreStep { Move { from : String , to : String } , Write { path : String , hash : String } , Remove { path : String } }"},{"kind":"fn","path":"maintenance::fsck","signature":"pub fn fsck ( source : & dyn ObjectSource ) -> Result < Vec < FsckFinding > >"},{"kind":"fn","path":"maintenance::fsck_json","signature":"pub fn fsck_json ( findings : & [ FsckFinding ] ) -> Value"},{"kind":"fn","path":"maintenance::gc_json","signature":"pub fn gc_json ( swept : & [ String ] , kept : usize ) -> Value"},{"kind":"fn","path":"maintenance::plan_restore","signature":"pub fn plan_restore ( expected : & [ FileFact ] , observed : & [ FileFact ] ) -> RestorePlan"},{"kind":"fn","path":"maintenance::plan_sweep","signature":"pub fn plan_sweep ( all : & BTreeSet < String > , reachable : & BTreeSet < String > ) -> Vec < String >"},{"kind":"fn","path":"maintenance::reachable_from_head","signature":"pub fn reachable_from_head ( source : & dyn ObjectSource , head : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"maintenance::restore_json","signature":"pub fn restore_json ( plan : & RestorePlan ) -> Value"},{"kind":"fn","path":"mv::relative_dir","signature":"pub fn relative_dir ( from : & str , to : & str ) -> String"},{"kind":"fn","path":"mv::relocate_files","signature":"pub fn relocate_files ( files : & BTreeMap < String , Vec < u8 > > , old_root : & str , dest : & str , target : & TargetDef , placements : & BTreeMap < String , String > ) -> Result < BTreeMap < String , Vec < u8 > > >"},{"kind":"fn","path":"mv::relocate_module","signature":"pub fn relocate_module ( workspace : & Path , target_name : & str , module : & str , dest : & str ) -> CommitReport"},{"kind":"struct","path":"objects::Footprint","signature":"pub struct Footprint"},{"kind":"field","path":"objects::Footprint.derived_projections","signature":"pub derived_projections : Vec < String >"},{"kind":"field","path":"objects::Footprint.install_paths","signature":"pub install_paths : Vec < String >"},{"kind":"field","path":"objects::Footprint.read_set","signature":"pub read_set : Vec < String >"},{"kind":"field","path":"objects::Footprint.write_set","signature":"pub write_set : Vec < String >"},{"kind":"struct","path":"objects::ModuleState","signature":"pub struct ModuleState"},{"kind":"field","path":"objects::ModuleState.input_key","signature":"pub input_key : Option < String >"},{"kind":"field","path":"objects::ModuleState.module","signature":"pub module : String"},{"kind":"field","path":"objects::ModuleState.output_tree","signature":"pub output_tree : String"},{"kind":"field","path":"objects::ModuleState.slice_hash","signature":"pub slice_hash : Option < String >"},{"kind":"field","path":"objects::ModuleState.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"objects::ModuleState.target","signature":"pub target : String"},{"kind":"struct","path":"objects::Transition","signature":"pub struct Transition"},{"kind":"field","path":"objects::Transition.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"objects::Transition.footprint","signature":"pub footprint : Footprint"},{"kind":"field","path":"objects::Transition.input_key","signature":"pub input_key : String"},{"kind":"field","path":"objects::Transition.module","signature":"pub module : String"},{"kind":"field","path":"objects::Transition.new_state","signature":"pub new_state : String"},{"kind":"field","path":"objects::Transition.receipt","signature":"pub receipt : String"},{"kind":"field","path":"objects::Transition.surface","signature":"pub surface : String"},{"kind":"field","path":"objects::Transition.target","signature":"pub target : String"},{"kind":"fn","path":"objects::empty_module_state","signature":"pub fn empty_module_state ( module : & str , target : & str , empty_tree : & str ) -> ModuleState"},{"kind":"fn","path":"objects::event_hash","signature":"pub fn event_hash ( content : & str , parents : & [ String ] ) -> String"},{"kind":"fn","path":"objects::event_json","signature":"pub fn event_json ( content : & str , parents : & [ String ] ) -> Value"},{"kind":"fn","path":"objects::event_transition","signature":"pub fn event_transition ( source : & dyn ObjectSource , event : & str ) -> Result < String >"},{"kind":"fn","path":"objects::module_state_hash","signature":"pub fn module_state_hash ( state : & ModuleState ) -> String"},{"kind":"fn","path":"objects::module_state_json","signature":"pub fn module_state_json ( state : & ModuleState ) -> Value"},{"kind":"fn","path":"objects::ownership_write_key","signature":"pub fn ownership_write_key ( root : & str ) -> String"},{"kind":"fn","path":"objects::read_module_state","signature":"pub fn read_module_state ( source : & dyn ObjectSource , hash : & str ) -> Result < ModuleState >"},{"kind":"fn","path":"objects::snapshot_event","signature":"pub fn snapshot_event ( source : & dyn ObjectSource , snapshot : & str ) -> Result < String >"},{"kind":"fn","path":"objects::snapshot_hash","signature":"pub fn snapshot_hash ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> String"},{"kind":"fn","path":"objects::snapshot_json","signature":"pub fn snapshot_json ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> Value"},{"kind":"fn","path":"objects::snapshot_members","signature":"pub fn snapshot_members ( states : & BTreeMap < ( String , String ) , String > , target : & str ) -> Vec < String >"},{"kind":"fn","path":"objects::snapshot_states","signature":"pub fn snapshot_states ( source : & dyn ObjectSource , snapshot : & str ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"objects::state_write_key","signature":"pub fn state_write_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"objects::transition_hash","signature":"pub fn transition_hash ( transition : & Transition ) -> String"},{"kind":"fn","path":"objects::transition_json","signature":"pub fn transition_json ( transition : & Transition ) -> Value"},{"kind":"fn","path":"objects::transition_write_set","signature":"pub fn transition_write_set ( source : & dyn ObjectSource , transition : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"objects::walk_events","signature":"pub fn walk_events ( source : & dyn ObjectSource , event : & str , until : Option < & str > ) -> Result < Vec < String > >"},{"kind":"const","path":"paths::MAX_PATH_LEN","signature":"pub const MAX_PATH_LEN : usize = 1024 ;"},{"kind":"const","path":"paths::MAX_SEGMENT_LEN","signature":"pub const MAX_SEGMENT_LEN : usize = 128 ;"},{"kind":"fn","path":"paths::check_canonical_path","signature":"pub fn check_canonical_path ( path : & str , code : OutcomeCode ) -> Result < ( ) >"},{"kind":"fn","path":"paths::is_under","signature":"pub fn is_under ( path : & str , prefix : & str ) -> bool"},{"kind":"fn","path":"paths::normalize_path_key","signature":"pub fn normalize_path_key ( path : & str ) -> String"},{"kind":"fn","path":"porcelain::add_note","signature":"pub fn add_note ( workspace : & Path , transition : & str , note : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::blame_path","signature":"pub fn blame_path ( workspace : & Path , path : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::cat_file","signature":"pub fn cat_file ( workspace : & Path , hash : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"porcelain::causal_order","signature":"pub fn causal_order ( store : & Store , head : & str ) -> Result < Vec < String > >"},{"kind":"fn","path":"porcelain::committed_manifest","signature":"pub fn committed_manifest ( store : & Store , target : & str ) -> Result < BTreeMap < String , String > >"},{"kind":"fn","path":"porcelain::committed_state","signature":"pub fn committed_state ( store : & Store , target : & str , module : & str ) -> Result < Option < ModuleState > >"},{"kind":"fn","path":"porcelain::cost","signature":"pub fn cost ( workspace : & Path , names : & [ String ] , files : & [ String ] ) -> Result < Value >"},{"kind":"fn","path":"porcelain::diff","signature":"pub fn diff ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::fsck_store","signature":"pub fn fsck_store ( workspace : & Path ) -> Result < ( bool , Value ) >"},{"kind":"fn","path":"porcelain::gc","signature":"pub fn gc ( workspace : & Path , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::lint_workspace","signature":"pub fn lint_workspace ( workspace : & Path , target : & str ) -> Result < ( Graph , LintReport , Value ) >"},{"kind":"fn","path":"porcelain::log","signature":"pub fn log ( workspace : & Path , module : Option < & str > ) -> Result < Value >"},{"kind":"fn","path":"porcelain::restore","signature":"pub fn restore ( workspace : & Path , target : & str , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::show","signature":"pub fn show ( workspace : & Path , hash : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::status","signature":"pub fn status ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::surface_of","signature":"pub fn surface_of ( workspace : & Path , target : & str , module : & str ) -> Result < Value >"},{"kind":"const","path":"receipt::MAX_REPORTED_BYTES","signature":"pub const MAX_REPORTED_BYTES : usize = 512 * 1024 ;"},{"kind":"const","path":"receipt::RECEIPT_VERSION","signature":"pub const RECEIPT_VERSION : u32 = 1 ;"},{"kind":"struct","path":"receipt::Receipt","signature":"pub struct Receipt"},{"kind":"field","path":"receipt::Receipt.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"receipt::Receipt.base_store_snapshot","signature":"pub base_store_snapshot : Option < String >"},{"kind":"field","path":"receipt::Receipt.base_workspace_hash","signature":"pub base_workspace_hash : String"},{"kind":"field","path":"receipt::Receipt.candidate_tree_hash","signature":"pub candidate_tree_hash : String"},{"kind":"field","path":"receipt::Receipt.evidence","signature":"pub evidence : Vec < EvidenceItem >"},{"kind":"field","path":"receipt::Receipt.input_key","signature":"pub input_key : String"},{"kind":"field","path":"receipt::Receipt.module","signature":"pub module : String"},{"kind":"field","path":"receipt::Receipt.output_manifest","signature":"pub output_manifest : BTreeMap < String , String >"},{"kind":"field","path":"receipt::Receipt.receipt_version","signature":"pub receipt_version : u32"},{"kind":"field","path":"receipt::Receipt.reported","signature":"pub reported : Value"},{"kind":"field","path":"receipt::Receipt.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"receipt::Receipt.target","signature":"pub target : String"},{"kind":"const","path":"receipt::UNAVAILABLE","signature":"pub const UNAVAILABLE : & str = \" unavailable \" ;"},{"kind":"fn","path":"receipt::normalize_reported","signature":"pub fn normalize_reported ( value : & Value ) -> Value"},{"kind":"fn","path":"receipt::parse_receipt","signature":"pub fn parse_receipt ( bytes : & [ u8 ] ) -> Result < Receipt >"},{"kind":"fn","path":"receipt::receipt_hash","signature":"pub fn receipt_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_json","signature":"pub fn receipt_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::receipt_semantic_hash","signature":"pub fn receipt_semantic_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_semantic_json","signature":"pub fn receipt_semantic_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::reported_usage","signature":"pub fn reported_usage ( receipt : & Receipt ) -> Value"},{"kind":"struct","path":"slice::CompileSlice","signature":"pub struct CompileSlice"},{"kind":"field","path":"slice::CompileSlice.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"slice::CompileSlice.body","signature":"pub body : Value"},{"kind":"field","path":"slice::CompileSlice.input_key","signature":"pub input_key : String"},{"kind":"field","path":"slice::CompileSlice.module","signature":"pub module : String"},{"kind":"field","path":"slice::CompileSlice.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"slice::CompileSlice.target","signature":"pub target : String"},{"kind":"field","path":"slice::CompileSlice.wave","signature":"pub wave : usize"},{"kind":"struct","path":"slice::DepSurface","signature":"pub struct DepSurface"},{"kind":"field","path":"slice::DepSurface.items","signature":"pub items : Vec < SurfaceItem >"},{"kind":"field","path":"slice::DepSurface.module","signature":"pub module : String"},{"kind":"field","path":"slice::DepSurface.surface_hash","signature":"pub surface_hash : String"},{"kind":"fn","path":"slice::build_slice","signature":"pub fn build_slice ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] , base : & ModuleState , base_state_key : & str , base_files : & [ ( String , String ) ] , wave : usize ) -> CompileSlice"},{"kind":"fn","path":"slice::closure_manifest","signature":"pub fn closure_manifest ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> Value"},{"kind":"fn","path":"slice::input_key","signature":"pub fn input_key ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> String"},{"kind":"fn","path":"slice::receipt_requirements","signature":"pub fn receipt_requirements ( ) -> Value"},{"kind":"fn","path":"slice::recompute_slice_hash","signature":"pub fn recompute_slice_hash ( body : & Value ) -> String"},{"kind":"const","path":"source::MAX_SOURCE_BYTES","signature":"pub const MAX_SOURCE_BYTES : usize = 4 * 1024 * 1024 ;"},{"kind":"const","path":"source::SOURCE_EXTENSION","signature":"pub const SOURCE_EXTENSION : & str = \" napl \" ;"},{"kind":"struct","path":"source::SourceModule","signature":"pub struct SourceModule"},{"kind":"field","path":"source::SourceModule.deps","signature":"pub deps : Vec < String >"},{"kind":"field","path":"source::SourceModule.module","signature":"pub module : String"},{"kind":"field","path":"source::SourceModule.normalized_body","signature":"pub normalized_body : String"},{"kind":"field","path":"source::SourceModule.normalized_source_hash","signature":"pub normalized_source_hash : String"},{"kind":"field","path":"source::SourceModule.path","signature":"pub path : String"},{"kind":"field","path":"source::SourceModule.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"source::SourceModule.targets","signature":"pub targets : Vec < String >"},{"kind":"field","path":"source::SourceModule.tests","signature":"pub tests : Vec < String >"},{"kind":"fn","path":"source::check_module_name","signature":"pub fn check_module_name ( name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"source::normalize_body","signature":"pub fn normalize_body ( raw : & str ) -> String"},{"kind":"fn","path":"source::parse_source","signature":"pub fn parse_source ( path : & str , text : & str ) -> Result < SourceModule >"},{"kind":"fn","path":"source::rewrite_deps","signature":"pub fn rewrite_deps ( text : & str , deps : & [ String ] ) -> Result < String >"},{"kind":"trait","path":"store_view::ObjectSource","signature":"pub trait ObjectSource { fn envelope ( & self , name : & str ) -> Result < Option < Vec < u8 > > > ; fn head ( & self ) -> Result < Option < String > > ; fn names ( & self ) -> Result < Vec < String > > ; }"},{"kind":"fn","path":"store_view::flatten_tree","signature":"pub fn flatten_tree ( source : & dyn ObjectSource , root : & str ) -> Result < BTreeMap < String , ( String , u32 ) > >"},{"kind":"fn","path":"store_view::get_blob","signature":"pub fn get_blob ( source : & dyn ObjectSource , name : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"store_view::get_json","signature":"pub fn get_json ( source : & dyn ObjectSource , name : & str , expected : ObjectKind ) -> Result < Value >"},{"kind":"fn","path":"store_view::get_object","signature":"pub fn get_object ( source : & dyn ObjectSource , name : & str ) -> Result < ( ObjectKind , Vec < u8 > ) >"},{"kind":"fn","path":"store_view::get_tree","signature":"pub fn get_tree ( source : & dyn ObjectSource , name : & str ) -> Result < Tree >"},{"kind":"fn","path":"store_view::has_object","signature":"pub fn has_object ( source : & dyn ObjectSource , name : & str ) -> bool"},{"kind":"fn","path":"store_view::load_tree_objects","signature":"pub fn load_tree_objects ( source : & dyn ObjectSource , root : & str , out : & mut BTreeMap < String , Tree > ) -> Result < ( ) >"},{"kind":"struct","path":"surface::SurfaceItem","signature":"pub struct SurfaceItem"},{"kind":"field","path":"surface::SurfaceItem.kind","signature":"pub kind : String"},{"kind":"field","path":"surface::SurfaceItem.path","signature":"pub path : String"},{"kind":"field","path":"surface::SurfaceItem.signature","signature":"pub signature : String"},{"kind":"fn","path":"surface::empty_surface_hash","signature":"pub fn empty_surface_hash ( module : & str , target : & str ) -> String"},{"kind":"fn","path":"surface::extract_surface","signature":"pub fn extract_surface ( target : & TargetDef , root : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"surface::surface_hash","signature":"pub fn surface_hash ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> String"},{"kind":"fn","path":"surface::surface_items_from_json","signature":"pub fn surface_items_from_json ( value : & Value ) -> Vec < SurfaceItem >"},{"kind":"fn","path":"surface::surface_json","signature":"pub fn surface_json ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> Value"},{"kind":"struct","path":"target::TargetDef","signature":"pub struct TargetDef"},{"kind":"field","path":"target::TargetDef.entry_point_version","signature":"pub entry_point_version : u32"},{"kind":"field","path":"target::TargetDef.manifest_file","signature":"pub manifest_file : String"},{"kind":"field","path":"target::TargetDef.name","signature":"pub name : String"},{"kind":"field","path":"target::TargetDef.projection_path","signature":"pub projection_path : String"},{"kind":"field","path":"target::TargetDef.source_dir","signature":"pub source_dir : String"},{"kind":"field","path":"target::TargetDef.source_extension","signature":"pub source_extension : String"},{"kind":"fn","path":"target::builtin_target","signature":"pub fn builtin_target ( name : & str ) -> Result < TargetDef >"},{"kind":"fn","path":"target::builtin_target_names","signature":"pub fn builtin_target_names ( ) -> Vec < String >"},{"kind":"fn","path":"target::derive_lib_projection","signature":"pub fn derive_lib_projection ( target : & TargetDef , root : & str , file_paths : & [ String ] , command_providers : & [ String ] ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"target::derive_projection","signature":"pub fn derive_projection ( target : & TargetDef , members : & [ String ] ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"target::is_owned_path","signature":"pub fn is_owned_path ( target : & TargetDef , root : & str , path : & str ) -> bool"},{"kind":"fn","path":"target::owned_globs","signature":"pub fn owned_globs ( target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::placement_facts","signature":"pub fn placement_facts ( target : & TargetDef , root : & str ) -> Value"},{"kind":"fn","path":"target::placement_root","signature":"pub fn placement_root ( source : & SourceModule ) -> String"},{"kind":"fn","path":"target::projection_owned_paths","signature":"pub fn projection_owned_paths ( target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::surface_grammar","signature":"pub fn surface_grammar ( target : & TargetDef ) -> Value"},{"kind":"fn","path":"target::target_def_hash","signature":"pub fn target_def_hash ( target : & TargetDef ) -> String"},{"kind":"fn","path":"target::target_def_json","signature":"pub fn target_def_json ( target : & TargetDef ) -> Value"},{"kind":"enum","path":"textdiff::Edit","signature":"pub enum Edit { Keep ( usize , usize ) , Delete ( usize ) , Insert ( usize ) }"},{"kind":"fn","path":"textdiff::diff_lines","signature":"pub fn diff_lines ( old : & [ String ] , new : & [ String ] ) -> Vec < Edit >"},{"kind":"fn","path":"textdiff::lines","signature":"pub fn lines ( text : & str ) -> Vec < String >"},{"kind":"fn","path":"textdiff::render_drift","signature":"pub fn render_drift ( committed : & BTreeMap < String , String > , working : & BTreeMap < String , String > ) -> String"},{"kind":"fn","path":"textdiff::render_patch","signature":"pub fn render_patch ( path : & str , old : & str , new : & str ) -> String"},{"kind":"struct","path":"transaction::Accepted","signature":"pub struct Accepted"},{"kind":"field","path":"transaction::Accepted.event","signature":"pub event : String"},{"kind":"field","path":"transaction::Accepted.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::Accepted.projection_bytes","signature":"pub projection_bytes : Vec < u8 >"},{"kind":"field","path":"transaction::Accepted.projection_path","signature":"pub projection_path : String"},{"kind":"field","path":"transaction::Accepted.rebased","signature":"pub rebased : bool"},{"kind":"field","path":"transaction::Accepted.snapshot","signature":"pub snapshot : String"},{"kind":"field","path":"transaction::Accepted.steps","signature":"pub steps : Vec < InstallStep >"},{"kind":"struct","path":"transaction::DerivedCommit","signature":"pub struct DerivedCommit"},{"kind":"field","path":"transaction::DerivedCommit.evidence","signature":"pub evidence : Value"},{"kind":"field","path":"transaction::DerivedCommit.new_state_key","signature":"pub new_state_key : String"},{"kind":"field","path":"transaction::DerivedCommit.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::DerivedCommit.receipt_object","signature":"pub receipt_object : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_items","signature":"pub surface_items : Vec < SurfaceItem >"},{"kind":"field","path":"transaction::DerivedCommit.transition","signature":"pub transition : Transition"},{"kind":"field","path":"transaction::DerivedCommit.transition_id","signature":"pub transition_id : String"},{"kind":"struct","path":"transaction::PendingObject","signature":"pub struct PendingObject"},{"kind":"field","path":"transaction::PendingObject.kind","signature":"pub kind : ObjectKind"},{"kind":"field","path":"transaction::PendingObject.name","signature":"pub name : String"},{"kind":"field","path":"transaction::PendingObject.payload","signature":"pub payload : Vec < u8 >"},{"kind":"fn","path":"transaction::accept","signature":"pub fn accept ( source : & dyn ObjectSource , target : & TargetDef , root : & str , candidate : & Candidate , derived : & DerivedCommit , head : Option < & str > , base_snapshot : Option < & str > ) -> Result < Accepted >"},{"kind":"fn","path":"transaction::check_candidate_shape","signature":"pub fn check_candidate_shape ( target : & TargetDef , root : & str , candidate : & Candidate ) -> Result < ( ) >"},{"kind":"fn","path":"transaction::check_manifest_identity","signature":"pub fn check_manifest_identity ( source : & dyn ObjectSource , target : & TargetDef , root : & str , module : & str , candidate : & Candidate ) -> Result < ( ) >"},{"kind":"fn","path":"transaction::derive_commit","signature":"pub fn derive_commit ( target : & TargetDef , root : & str , receipt : & Receipt , candidate : & Candidate ) -> Result < DerivedCommit >"},{"kind":"fn","path":"transaction::intervening_write_set","signature":"pub fn intervening_write_set ( source : & dyn ObjectSource , head : Option < & str > , base : Option < & str > ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"transaction::success_payload","signature":"pub fn success_payload ( derived : & DerivedCommit , accepted : & Accepted ) -> Value"},{"kind":"fn","path":"transaction::verify_receipt","signature":"pub fn verify_receipt ( source : & dyn ObjectSource , sources : & [ SourceModule ] , receipt : & Receipt , candidate : & Candidate ) -> Result < CompileSlice >"},{"kind":"fn","path":"trees::empty_tree_hash","signature":"pub fn empty_tree_hash ( ) -> String"},{"kind":"fn","path":"trees::tree_of","signature":"pub fn tree_of ( manifest : & BTreeMap < String , String > ) -> Result < BuiltTree >"},{"kind":"const","path":"version::CONTRACT_VERSION","signature":"pub const CONTRACT_VERSION : u32 = 1 ;"},{"kind":"fn","path":"workspace::apply_step","signature":"pub fn apply_step ( root : & Path , step : & InstallStep , payload : & dyn Fn ( & str ) -> Result < Vec < u8 > > ) -> Result < ( ) >"},{"kind":"fn","path":"workspace::discover_sources","signature":"pub fn discover_sources ( root : & Path ) -> Result < Vec < SourceModule > >"},{"kind":"fn","path":"workspace::find_workspace_root","signature":"pub fn find_workspace_root ( start : & Path ) -> Option < PathBuf >"},{"kind":"fn","path":"workspace::read_working_text","signature":"pub fn read_working_text ( root : & Path , paths : & [ String ] ) -> BTreeMap < String , String >"},{"kind":"fn","path":"workspace::require_workspace_root","signature":"pub fn require_workspace_root ( start : & Path ) -> Result < PathBuf >"},{"kind":"fn","path":"workspace::scan_candidate","signature":"pub fn scan_candidate ( dir : & Path ) -> Result < Vec < ScannedEntry > >"},{"kind":"fn","path":"workspace::step_path","signature":"pub fn step_path ( step : & InstallStep ) -> & str"},{"kind":"fn","path":"workspace::write_file","signature":"pub fn write_file ( path : & Path , bytes : & [ u8 ] ) -> Result < ( ) >"}],"module":"napl","target":"rust"} -napl-store blob v1 27297 -//! `napl edit` and `napl write`: the deterministic capture layer in front of the -//! same guarded door. -//! -//! The verbs carry the familiar tool signatures and add capture, never -//! exclusivity: either side is editable, a prompt or a generated file, by a -//! human or an LLM, and a hand edit through an ordinary editor stays equally -//! legal. Nothing here synthesizes or rewrites prose beyond the bytes given, -//! invokes a model, decides placement, repairs a reference, or bypasses the -//! guard. `add` and `commit` remain exactly what they are; a reference-bearing -//! call goes through them. -//! -//! Each call is validate, apply, capture, in that order. Only a call that will -//! succeed touches the working tree, so every refusal leaves every file -//! byte-identical, and a refusal payload says so. -//! -//! The verbs read no clock. Model identity and timestamp come from the session -//! window's log lines or read `unavailable`, so the same call over the same -//! working tree, store state, and log bytes produces byte-identical records. +Compilation is a pure derivation and NOT a command: parse the graph, +compute per-module slices for the changed frontier, derive +dependency-ordered waves. Never persisted, and never spelled on the CLI — +it is the internal engine of `status`, `add`, `commit`, `watch`, and the +lsp. Per module the slice carries: normalized source body and hash, direct +dependency surfaces and surfaceHashes, placement and allowed-output +constraints including the surface grammar, the owned-output base and +baseStateKey, inputKey, sliceHash, wave position, receipt requirements. It +contains no provider, model, prompt assembly, credentials, timestamps, +retry policy, pricing, or test results. `napl status --module ` +reports the module's PLAN from this derivation — owned globs, placement +root, projection ownership, receipt requirements, wave position — the +invariant requirements only, a pure function of the working tree's +prompts; candidate-dependent validation belongs to `add`, never the plan. -use std::io::Read as _; -use std::path::{Path, PathBuf}; +Pending is never a bare boolean. A pending module names its reason +from a closed vocabulary: `never-committed`, `source-changed`, +`dependency-surface-moved`, `placement-changed`, `target-changed`, +`contract-version-changed`; the reason is derived by comparing the +current derivation's inputKey components against the identities the +committed state records, the same facts the store already holds, +never a guess. And because an undrifted working tree stages exactly +the committed bytes, a pending module with no drift additionally +says the landing would re-stamp identical bytes, so whether a +pending module needs a real regeneration or a no-op landing is read +off status, never discovered by staging and comparing tree hashes. -use serde_json::{json, Value}; +::: test +name: a pending module names dependency-surface-moved as its reason +given: { workspace: fixture_chain_three, intervening_commit: leaf_surface_change, flag: module_middle } +expect: { pending: true, pending_reason: dependency_surface_moved } +::: -use crate::attribution::{self, Attribution, Direction, Entry, Provenance, UsageRef, UsageWindow}; -use crate::canon::canonical_bytes; -use crate::cli::{flag, has_switch, positional, require_flag, resolve_workspace, CliResult}; -use crate::commit::AttributionMode; -use crate::disk; -use crate::error::{error, CompilerError, OutcomeCode, Result}; -use crate::paths::is_under; -use crate::source::{parse_source, SourceModule}; -use crate::store_view::get_blob; -use crate::target::{builtin_target, is_owned_path, placement_root, TargetDef}; -use crate::workspace::{discover_sources, write_file}; +::: test +name: a pending undrifted module says the landing re-stamps identical bytes +given: { workspace: fixture_chain_three, intervening_commit: leaf_surface_change, flag: module_middle } +expect: { pending: true, drift: false, reland_identical_bytes: true } +::: -/// The usage-proxy log, workspace-relative. A recorded window names this path -/// rather than an absolute one, so a record is the same on any machine. -pub const USAGE_LOG_PATH: &str = ".napl/usage-proxy.jsonl"; +::: test +name: a never committed module reads pending with its reason +given: { workspace: fixture_two_modules, module: pending_never_committed, flag: module_pending } +expect: { pending: true, pending_reason: never_committed } +::: -/// The media type of that log, as the usage-proxy prompt names it. -pub const USAGE_LOG_MEDIA_TYPE: &str = "application/x-napl-usage-proxy-jsonl-v1"; +::: test +name: a prompt edit reads pending as source-changed +given: { workspace: fixture_two_modules, working_tree_edit: prompt_body_touched, flag: module_edited } +expect: { pending: true, pending_reason: source_changed } +::: -/// The environment variable naming the session window, injected by `napl agent` -/// into every child it wraps. `--session` overrides it. -pub const SESSION_VARIABLE: &str = "NAPL_SESSION"; +::: test +name: status reports a module plan derived from the working tree +given: { workspace: fixture_two_modules, flag: module_leaf } +expect: { plan_fields: [owned_globs, placement_root, receipt_requirements, wave_position], candidate_dependent_fields: absent } +::: -/// What a verb will write, once every validation has passed. -struct Application { - /// The target's workspace-relative path. - path: String, - bytes: Vec, -} +::: test +name: status module answers live state and the derived projection beside the plan +given: { workspace: fixture_two_modules, flag: module_leaf, working_tree_edit: leaf_output_touched } +expect: { state_fields: [attribution, drift, pending, stale_references, tracked], drift: true, projection_path_present: true, projection_content_present: true } +::: -pub(crate) fn run_edit(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - let given = positional(args, 1)?; - let old = require_flag(args, "--old-string")?; - let new = require_flag(args, "--new-string")?; - let replace_all = has_switch(args, "--replace-all"); - let path = match relative_target(&workspace, cwd, &given) { - Ok(path) => path, - Err(err) => return Ok(refusal(&err)), - }; - let current = match std::fs::read(workspace.join(&path)) { - Ok(bytes) => bytes, - Err(e) => return Ok(refusal(&disk::io(&workspace.join(&path), &e))), - }; - let replaced = match replace_exact(¤t, old.as_bytes(), new.as_bytes(), replace_all) { - Ok(bytes) => bytes, - Err(err) => return Ok(refusal(&err)), - }; - apply_and_capture(&workspace, args, Application { path, bytes: replaced }) -} +::: test +name: show lists attached notes inline and log counts them +given: { workspace: fixture_two_modules, history: [commit, note_attached] } +expect: { show_notes_listed: 1, show_note_text_present: true, log_note_count: 1 } +::: -pub(crate) fn run_write(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - let given = positional(args, 1)?; - let path = match relative_target(&workspace, cwd, &given) { - Ok(path) => path, - Err(err) => return Ok(refusal(&err)), - }; - let mut bytes: Vec = Vec::new(); - if std::io::stdin().read_to_end(&mut bytes).is_err() { - return Ok(refusal(&error( - OutcomeCode::IoError, - "napl write reads the file's full content from stdin, which could not be read", - ))); - } - apply_and_capture(&workspace, args, Application { path, bytes }) -} +## Body directives -/// Write a file's full content, as `napl write` does, from a caller that already -/// holds the bytes. The one seam a test or an embedding driver uses instead of -/// stdin; every rule below is the same. -pub fn write_bytes(workspace: &Path, path: &str, bytes: &[u8], reference: Option<&str>, session: Option<&str>) -> Result { - let application = Application { - path: path.to_string(), - bytes: bytes.to_vec(), - }; - capture(workspace, application, reference, session) -} +The prompt body grammar reserves directive blocks, `directiveVersion 1`, +and recognizing them is part of the identity contract: text that older +binaries read as inert prose can be a compile error under this grammar, so +this recognition rides a CONTRACT_VERSION bump, the sanctioned identity +transition. The directives are `test` and the docs family stated below. +For `test`, an opener is a line that is exactly `::: test`; the closer +is a line that is exactly `:::`; nothing nests inside a test block. An +unknown `::: ` opener is a compile error, never prose, so future +directive names are non-breaking. Test blocks are body text: they count +in normalized body lines and attribution ranges may cover them. Docs +fences are the one exception, display content excluded from the +semantic body, and their own section states that law in full. -/// Replace an exact occurrence, as `napl edit` does, from a caller that already -/// holds the strings. -pub fn edit_bytes( - workspace: &Path, - path: &str, - old: &str, - new: &str, - replace_all: bool, - reference: Option<&str>, - session: Option<&str>, -) -> Result { - let current = std::fs::read(workspace.join(path)) - .map_err(|e| disk::io(&workspace.join(path), &e))?; - let replaced = match replace_exact(¤t, old.as_bytes(), new.as_bytes(), replace_all) { - Ok(bytes) => bytes, - Err(err) => return Ok(refusal(&err)), - }; - capture( - workspace, - Application { - path: path.to_string(), - bytes: replaced, - }, - reference, - session, - ) -} +Block content is one case in an exact value grammar mapping 1:1 onto +canonical JSON, so two binaries cannot disagree on a value: one mapping +with keys `name` (required, non-empty, single line), `given` (optional, +mapping), `expect` (required, mapping). Block mappings and sequences +indent by exactly two spaces; flow `{}` and `[]` forms are allowed. +Strings are double-quoted with JSON escapes, or plain scalars ended by a +structural character (`:`, `,`, `{`, `}`, `[`, `]`, `#`) or end of line +with no leading or trailing space. Numbers are JSON number syntax; +`true`, `false`, `null` are keywords; every other plain scalar is a +string. Nothing else from YAML exists here: no comments, no duplicate +mapping keys, no anchors, aliases, tags, block scalars, multiline +scalars, multi-document markers, octal or hex, timestamps, or merge keys. +A case needing prose belongs in prose. Diagnostics are compile errors +naming the exact body line, judged in this precedence: unclosed block, +unknown directive name, nested opener, content outside the value grammar, +missing or empty name, missing expect, duplicate case name within the +module across both positions after NFC normalization and whitespace trim. +Frontmatter `tests:` remains legal; a module's case set is the union of +both positions; extraction of cases from body bytes is deterministic — +same bytes, same cases. -fn apply_and_capture( - workspace: &Path, - args: &[String], - application: Application, -) -> Result { - let reference = flag(args, "--ref")?; - let session = flag(args, "--session")?; - capture( - workspace, - application, - reference.as_deref(), - session.as_deref(), - ) -} +::: test +name: an unclosed test directive block is a compile error naming its opener line +given: { body: directive_without_closer } +expect: { outcome: compile_error, line_named: opener } +::: -/// Validate, apply, capture. -fn capture( - workspace: &Path, - application: Application, - reference: Option<&str>, - session: Option<&str>, -) -> Result { - let sources = discover_sources(workspace)?; +::: test +name: "an unknown directive name is a compile error, never prose" +given: { body: unknown_directive_opener } +expect: { outcome: compile_error, treated_as_prose: false } +::: - // A prompt target's RESULT is validated first, by the same reader `check` and - // `add` use. A result that fails refuses and applies nothing. - if is_prompt(&application.path) { - if let Err(err) = validate_prompt(&application.path, &application.bytes) { - return Ok(refusal(&err)); - } - } +::: test +name: a duplicate case name across frontmatter and body errors +given: { frontmatter_case: alpha, body_directive_case: alpha } +expect: { outcome: compile_error, diagnostic: duplicate_case_name } +::: - // The reference is validated against the bytes that will exist after the - // edit, which is the content it is a claim about. It is validated - // referentially only, exactly as strong as attribution validation. - let claim = match reference { - None => None, - Some(text) => match parse_reference(text) { - Ok(claim) => Some(claim), - Err(err) => return Ok(refusal(&err)), - }, - }; - let resolved = match &claim { - None => None, - Some(claim) => match resolve_reference(workspace, &sources, &application, claim) { - Ok(resolved) => Some(resolved), - Err(err) => return Ok(refusal(&err)), - }, - }; +::: test +name: directive case extraction is deterministic +given: { body: fixture_with_two_directives, runs: 2 } +expect: { cases: 2, bytes_equal: true } +::: - // Every validation passed, so the working tree may be touched. - write_file(&workspace.join(&application.path), &application.bytes)?; +### The docs directive - let Some(resolved) = resolved else { - // A call without a reference is a plain edit: apply, no record, no store - // touch. Any existing reference whose endpoint this moved needs no - // marking, because both-ends staleness is computed at read time. - return Ok(ok_payload(json!({ - "applied": true, - "contractVersion": crate::version::CONTRACT_VERSION, - "outcome": "applied", - "path": application.path, - "recorded": false, - "semanticStatePublished": false, - }))); - }; +A prompt section may carry `::: docs` fences. The body of a docs fence +is display prose for human documentation: plain markdown plus the +language's own directive vocabulary, never a renderer's component +syntax, so any consumer that can render markdown and the named +directives can render a prompt, and no prompt ever names a website's +component. - record_reference(workspace, &resolved, session) -} +The docs vocabulary is language-owned and deliberately tiny: `docs` +for prose, `example` for runnable content, `note` and `warning` as +admonitions. An example body is validated: napl source in it must pass +check and CLI invocations in it must parse under the live command +grammar, so a published example cannot rot. An example fence may +carry a `lang` attribute naming what its body is, `lang="napl"` for +prompt source and `lang="cli"` for command invocations; when `lang` +is present it selects the validator, and when it is absent the +validator is chosen by inspection as before, so every existing fence +keeps its meaning. A fenced code block inside the example keeps the +validator its own info string names; the declared `lang` covers only +content that carries no annotation of its own, so a `lang` can never +switch off a validation the block already asked for. A `lang` the +language does not know is refused +naming the line, because a validated vocabulary with an unvalidated +discriminator would let examples rot behind a typo. Directives named `x-` +followed by anything are the extension namespace: carried verbatim, +never validated, a renderer's private experiment and a candidate for +promotion. A renderer meeting an unknown directive renders its body as +a plain block, never an error, so old renderers survive new +vocabulary; check keeps refusing unknown names, so tolerance is a +renderer rule, never a check rule. -// --------------------------------------------------------------- the edit rule +Directive names follow the micromark grammar: alphanumeric plus `-` +and `_`, never ending in `-` or `_`, so `x-tabs` is a name and a bare +`x-` is not. Attributes are HTML-like braces per the same grammar, +the `:::example{name="..."}` spelling: `{a}`, `{a=b}`, `{a="b"}`, and +`{a='b'}` are equivalent, `.class` shorthands accumulate, and a later +`#id` overrides an earlier one. -/// Replace an exact occurrence, or refuse. -/// -/// An `--old-string` that matches nowhere, matches more than once without -/// `--replace-all`, or equals `--new-string` is `edit-mismatch`, so a no-op edit -/// can never mint a record. -fn replace_exact( - current: &[u8], - old: &[u8], - new: &[u8], - replace_all: bool, -) -> Result> { - if old == new { - return Err(error( - OutcomeCode::EditMismatch, - "--old-string equals --new-string, so the edit is a no-op", - )); - } - let hits = occurrences(current, old); - if hits.is_empty() { - return Err(error( - OutcomeCode::EditMismatch, - "--old-string matches nowhere in the file", - )); - } - if hits.len() > 1 && !replace_all { - return Err(error( - OutcomeCode::EditMismatch, - format!( - "--old-string matches {} times; pass --replace-all to replace every occurrence", - hits.len() - ), - )); - } - let chosen = if replace_all { &hits[..] } else { &hits[..1] }; - let mut out: Vec = Vec::with_capacity(current.len()); - let mut cursor = 0usize; - for start in chosen { - out.extend_from_slice(¤t[cursor..*start]); - out.extend_from_slice(new); - cursor = start + old.len(); - } - out.extend_from_slice(¤t[cursor..]); - Ok(out) -} +Fence lines are unambiguous by shape: an opening line names its +directive, a closing line is bare colons. The parser keeps a fence +stack, and a closing line of N colons closes the innermost open fence +whose opening count is at most N, the same discipline code fences +use. But directive fences DO NOT NEST: a directive fence opened +inside another open directive fence is a compile error naming its +line, because no contract parses children, and a nested fence riding +verbatim serializes as broken directive syntax in every downstream +rendering of the prose. The lesson was paid for: fences are ordered +siblings, prose then example then prose, and order is meaning. A +future contract that parses children lifts this refusal explicitly; +until then refusing at the door beats emitting garbage from it. Code +fences suspend the stack, so a code block may show `:::` lines and +directive syntax freely; directive syntax shown as prose must sit in +a code fence for the same reason. Where micromark lets an unclosed +container run silently to the end of its parent, check refuses it +naming the opening line: deliberate strictness, not incompatibility. -/// The start offsets of every non-overlapping occurrence, left to right. An -/// empty needle occurs nowhere: it would otherwise "match" at every boundary and -/// make the count meaningless. -fn occurrences(haystack: &[u8], needle: &[u8]) -> Vec { - let mut out: Vec = Vec::new(); - if needle.is_empty() || needle.len() > haystack.len() { - return out; - } - let mut index = 0usize; - while index + needle.len() <= haystack.len() { - if &haystack[index..index + needle.len()] == needle { - out.push(index); - index += needle.len(); - } else { - index += 1; - } - } - out -} +Where that strictness trips differs by fence kind, because the prose +fences, docs, note, and warning, hold fully functioning markdown and +prose owns headings. A `# ` or `## ` line inside an open example or +test fence still refuses immediately as an unclosed fence, nothing +but directive content belongs there. Inside an open prose fence the +same line is prose, a heading the document renders like any other +markdown, and the unclosed refusal for a prose fence fires where the +fence provably cannot close: at the end of the body, naming the +opening line. The trade is error locality for expressive prose, and +it is the right trade exactly for the fences whose whole purpose is +prose. -// ------------------------------------------------------------- prompt validity +::: test +name: a heading inside a prose fence is prose not an unclosed fence +given: { body_with: "a docs fence, a note fence, and a warning fence each holding `## Sub` lines before their closers" } +expect: { compiles: true, heading_lines_inside_prose_bodies: true, semantic_body_excludes_them: true } +::: -fn is_prompt(path: &str) -> bool { - path.ends_with(&format!(".{}", crate::source::SOURCE_EXTENSION)) -} +::: test +name: a heading inside an example or test fence still refuses immediately +given: { body_with: "an example fence holding a `# ` line" } +expect: { compile_error: true, error_names_opener_line: true } +::: -/// Validate a prompt target's result with the same reader `check` and `add` use. -fn validate_prompt(path: &str, bytes: &[u8]) -> Result<()> { - let text = std::str::from_utf8(bytes).map_err(|_| { - error( - OutcomeCode::PromptInvalid, - format!("the result for {path:?} is not valid UTF-8"), - ) - })?; - parse_source(path, text).map(|_| ()).map_err(|err| { - error( - OutcomeCode::PromptInvalid, - format!("the result for {path:?} does not parse: {}", err.detail), - ) - }) -} +::: test +name: an unclosed docs fence refuses at the end of the body naming its opener +given: { body_with: "a docs fence never closed, followed by section headings to EOF" } +expect: { compile_error: true, error_names_opener_line: true, later_sections_not_swallowed_silently: true } +::: -// ------------------------------------------------------------- the reference +::: test +name: the preamble title and description come from its markdown +given: { preamble_body: "# Reading the store\n\nOne verb reads everything.\n" } +expect: { title: "Reading the store", description: "One verb reads everything.", from_attrs: false } +::: -/// A reference exactly as a caller spells it: a direction and both endpoints. -struct ReferenceClaim { - direction: Direction, - module: String, - file: String, - line_start: u32, - line_end: u32, - prompt_start: u32, - prompt_end: u32, - note: String, -} +::: test +name: docs fences accept no attributes +given: { body_with: "a docs fence opened with a title attribute" } +expect: { compile_error: true, error_names_attribute: title } +::: -/// A reference proven against the post-edit bytes, with the module it belongs to. -struct ResolvedReference { - module: String, - target: TargetDef, - entry: Entry, -} +Fences are ordered siblings within their section, and order is +meaning: prose, then a warning, then more prose renders as that +interleaved flow, which is how a caveat lands mid-explanation without +nesting. Adjacent example fences sharing a name and differing by a +variant attribute are one example in several spellings; how a +renderer groups them is the renderer's business. -fn parse_reference(text: &str) -> Result { - let bad = |why: String| error(OutcomeCode::ReferenceUnresolvable, why); - let value: Value = serde_json::from_str(text) - .map_err(|e| bad(format!("--ref is not JSON: {e}")))?; - if !value.is_object() { - return Err(bad("--ref must be a single JSON object".to_string())); - } - let text_field = |name: &str| -> Result { - value[name] - .as_str() - .map(str::to_string) - .ok_or_else(|| bad(format!("--ref has no {name} string"))) - }; - let pair = |name: &str| -> Result<(u32, u32)> { - let entries = value[name] - .as_array() - .ok_or_else(|| bad(format!("--ref has no {name} pair")))?; - if entries.len() != 2 { - return Err(bad(format!("--ref {name} is not a pair of line numbers"))); - } - let numbers: Vec = entries - .iter() - .filter_map(|entry| entry.as_u64()) - .filter_map(|entry| u32::try_from(entry).ok()) - .collect(); - if numbers.len() != 2 { - return Err(bad(format!("--ref {name} is not a pair of line numbers"))); - } - Ok((numbers[0].min(numbers[1]), numbers[0].max(numbers[1]))) - }; - let direction = match value["direction"].as_str() { - Some("code_to_prompt") => Direction::CodeToPrompt, - Some("prompt_to_code") => Direction::PromptToCode, - Some(other) => { - return Err(bad(format!( - "--ref names direction {other:?}, which the reference contract does not have" - ))) - } - None => return Err(bad("--ref has no direction".to_string())), - }; - let (line_start, line_end) = pair("lines")?; - let (prompt_start, prompt_end) = pair("promptLines")?; - Ok(ReferenceClaim { - direction, - module: text_field("module")?, - file: text_field("file")?, - line_start, - line_end, - prompt_start, - prompt_end, - note: value["note"].as_str().unwrap_or_default().to_string(), - }) -} +The first docs fence of a module, when it sits before the module's +first section heading, is the module preamble: its body introduces the +module, and its markdown carries the module's document metadata the +way prose always has. The preamble's first heading, whatever its +level, is the module's display title; the first paragraph after that +heading is its description; any renderer may use both for a page +head, an index card, or a search snippet. There are no `title` or +`description` attributes: metadata spelled outside the prose would be +a second way to say what the markdown already says, and the docs +fence accepts no attributes at all. A module without a preamble +fence, or whose preamble carries no heading, simply has no display +title beyond its name. -/// Prove a reference referentially, and no more: the file path inside the owning -/// module's placement, both line ranges present in the post-edit bytes of their -/// files. Nothing semantic is examined, so the record never implies the sentence -/// describes the code. -fn resolve_reference( - workspace: &Path, - sources: &[SourceModule], - application: &Application, - claim: &ReferenceClaim, -) -> Result { - let bad = |why: String| error(OutcomeCode::ReferenceUnresolvable, why); - let source = sources - .iter() - .find(|source| source.module == claim.module) - .ok_or_else(|| { - bad(format!( - "--ref names module {:?}, which has no prompt file in the workspace", - claim.module - )) - })?; - let target = builtin_target("rust")?; - let root = placement_root(source); - if !is_under(&claim.file, &root) || !is_owned_path(&target, &root, &claim.file) { - return Err(bad(format!( - "--ref names {:?}, which is outside the placement of module {:?}", - claim.file, claim.module - ))); - } +Docs fences are display content, not contract. The semantic body of a +prompt, the bytes that inputKey, attribution prompt hashes, and every +freshness derivation consume, is the body with all docs fences +removed, and attribution endpoints name lines of that semantic body, +so inserting or growing a docs fence shifts no endpoint. Editing, +adding, or deleting a docs fence therefore never re-keys a module, +never marks code pending, and never moves an attribution endpoint. A +prompt whose only change is inside docs fences produces the same +inputKey and the same attribution verdicts, and the store records the +new prompt bytes as a new object whose module state is unchanged. - // The prompt endpoint is judged against the prompt as it will be, which is - // the edited bytes when the edit is the prompt's own. - let body = if application.path == source.path { - let text = std::str::from_utf8(&application.bytes).map_err(|_| { - bad(format!("the result for {:?} is not valid UTF-8", source.path)) - })?; - parse_source(&source.path, text)?.normalized_body - } else { - source.normalized_body.clone() - }; - let body_lines = body.lines().count() as u32; - check_range( - claim.prompt_start, - claim.prompt_end, - body_lines, - &format!("the normalized body of {:?}", source.path), - )?; +::: test +name: "a docs-only edit changes prompt bytes but no key, pending, or endpoint" +given: { edit: inside_docs_fence_only } +expect: { input_key_changed: false, pending: [], endpoints_moved: 0, prompt_bytes_changed: true } +::: - // The code endpoint is judged the same way, against post-edit bytes. - let code_bytes = if application.path == claim.file { - application.bytes.clone() - } else { - std::fs::read(workspace.join(&claim.file)).map_err(|_| { - bad(format!( - "--ref names {:?}, which the working tree does not contain", - claim.file - )) - })? - }; - let code_lines = String::from_utf8_lossy(&code_bytes).lines().count() as u32; - check_range( - claim.line_start, - claim.line_end, - code_lines, - &format!("the post-edit bytes of {:?}", claim.file), - )?; +::: test +name: a docs fence containing a code fence containing three colons parses as one fence +given: { body: docs_fence_with_code_fence_colons } +expect: { docs_payloads: 1 } +::: - Ok(ResolvedReference { - module: claim.module.clone(), - target, - entry: Entry { - prompt_start: claim.prompt_start, - prompt_end: claim.prompt_end, - file: Some(claim.file.clone()), - line_start: claim.line_start, - line_end: claim.line_end, - note: claim.note.clone(), - direction: claim.direction, - provenance: Provenance::default(), - }, - }) -} +::: test +name: a directive fence inside an open docs fence is a compile error naming its line +given: { body: docs_fence_with_inner_example_same_count } +expect: { compile_error: true, error_names_inner_fence_line: true } +::: -fn check_range(start: u32, end: u32, available: u32, what: &str) -> Result<()> { - let bad = |why: String| error(OutcomeCode::ReferenceUnresolvable, why); - if start == 0 || start > end { - return Err(bad(format!("--ref names the empty range {start}-{end}"))); - } - if end > available { - return Err(bad(format!( - "--ref names lines {start}-{end} but {what} has {available} lines" - ))); - } - Ok(()) -} +::: test +name: a four-colon docs fence wrapping a three-colon example refuses the same way +given: { body: four_colon_docs_wrapping_three_colon_example } +expect: { compile_error: true, error_names_inner_fence_line: true } +::: -// ------------------------------------------------------------ the capture step +::: test +name: a fence left open at section end is refused naming the opening line +given: { body: docs_fence_without_closer } +expect: { outcome: compile_error, line_named: opener } +::: -/// Stage a reference-only record and commit it through the guarded transaction, -/// in the same invocation. -/// -/// The candidate is the module's own committed output, byte for byte, so the -/// transition carries the reference entry and its provenance with no output-tree -/// change and no surface change. Every rule of the decision table, journaled -/// atomicity, and recovery applies to it unchanged. -fn record_reference( - workspace: &Path, - resolved: &ResolvedReference, - session: Option<&str>, -) -> Result { - let store = disk::init(workspace)?; - let target = resolved.target.name.clone(); - let state = crate::porcelain::committed_state(&store, &target, &resolved.module)? - .ok_or_else(|| { - error( - OutcomeCode::ReferenceUnresolvable, - format!( - "module {:?} has no committed output for a reference to attach to", - resolved.module - ), - ) - })?; +::: test +name: an invalid directive name ending in a hyphen is refused naming the line +given: { body: docs_fence_named_x_dash } +expect: { outcome: compile_error } +::: - // Materialize the committed output as the candidate. The reference-only - // transition changes the attribution record and nothing else. - let scratch = store - .root - .join("tmp") - .join(format!("capture-{}", crate::paths::encode_name(&resolved.module))); - if scratch.exists() { - std::fs::remove_dir_all(&scratch).map_err(|e| disk::io(&scratch, &e))?; - } - let owned = crate::store_view::flatten_tree(&store, &state.output_tree)?; - if owned.is_empty() { - return Ok(refusal(&error( - OutcomeCode::ReferenceUnresolvable, - format!( - "module {:?} has no committed files for a reference to attach to", - resolved.module - ), - ))); - } - for (path, (blob, _mode)) in &owned { - write_file(&scratch.join(path), &get_blob(&store, blob)?)?; - } +::: test +name: an example lang attribute selects the validator +given: { body: example_lang_napl_with_cli_looking_body } +expect: { validated_as: napl_source } +::: - // Carry the module's recorded references forward and add this one, with the - // provenance the session window supplies. - let mut entry = resolved.entry.clone(); - let provenance = window_provenance(workspace, session); - entry.provenance = provenance.clone(); - let mut record = match crate::porcelain::committed_attribution_yaml(&store, &target, &resolved.module)? { - Some(bytes) => attribution::parse(&String::from_utf8_lossy(&bytes)).ok_or_else(|| { - error( - OutcomeCode::StoreIntegrity, - format!( - "the committed attribution of module {:?} is not a parseable record", - resolved.module - ), - ) - })?, - None => Attribution { - module: resolved.module.clone(), - target: target.clone(), - stale: false, - entries: Vec::new(), - }, - }; - record.stale = false; - if !record.entries.contains(&entry) { - record.entries.push(entry); - } - let yaml = attribution::to_yaml(&record); +::: test +name: an unknown example lang is refused naming the line +given: { body: example_lang_unknown } +expect: { outcome: compile_error } +::: - // Stage exactly as `add` stages, then commit exactly as `commit --staged` - // commits. The staged record carries the mode, so the reference the verb - // already proved is installed fresh rather than judged a second time against - // bytes it never claimed anything about. - let candidate = crate::candidate::ingest( - &crate::workspace::scan_candidate(&scratch)?, - crate::candidate::default_limits(), - )?; - let record_dir = crate::staging::record_dir(workspace, &resolved.module, &candidate.tree_hash); - if record_dir.exists() { - std::fs::remove_dir_all(&record_dir).map_err(|e| disk::io(&record_dir, &e))?; - } - for (path, bytes) in &candidate.files { - write_file(&record_dir.join("candidate").join(path), bytes)?; - } - // The staged record reports its session window, never a total: the window is - // the reference, and `napl cost` sums the log's lines at read time. Reported - // telemetry never enters a receipt's semantic identity, so this changes what - // the record says it cost and nothing about what it installs. - let mut receipt = crate::authoring::draft_receipt( - workspace, - &target, - &resolved.module, - &scratch, - None, - )?; - if let Some(window @ UsageRef::Window(_)) = &provenance.usage { - receipt["reported"] = json!({ "usage": { "window": window.as_json() } }); - } - write_file( - &record_dir.join("receipt.json"), - &canonical_bytes(&receipt), - )?; - write_file(&record_dir.join("attribution.yaml"), &yaml)?; - crate::staging::write_record( - &record_dir, - &resolved.module, - &target, - &candidate.tree_hash, - true, - AttributionMode::PreProven, - )?; +::: test +name: a declared lang never switches off a validation a block asked for +given: { body: example_lang_cli_containing_annotated_napl_block } +expect: { napl_block_still_check_validated: true, unannotated_content_validated_as: cli } +::: - let report = crate::staging::commit_staged(workspace, &record_dir); - let mut payload = report.payload; - if let Some(object) = payload.as_object_mut() { - object.insert("applied".to_string(), Value::Bool(true)); - object.insert("recorded".to_string(), Value::Bool(true)); - object.insert( - "staged".to_string(), - Value::String(crate::staging::relative_record(workspace, &record_dir)), - ); - } - Ok(CliResult { - stdout: canonical_bytes(&payload), - exit_code: crate::error::outcome_exit_code(report.outcome), - }) -} +::: test +name: a napl code block inside a docs fence that fails check is refused naming the block line +given: { body: docs_fence_with_broken_napl_example } +expect: { outcome: compile_error, line_named: example_block } +::: -// -------------------------------------------------------------- the window +::: test +name: a CLI line inside an example fence must parse under the live command grammar +given: { body: docs_example_with_ghost_cli_flag } +expect: { outcome: compile_error } +::: -/// The session window a call records, read from the log the usage proxy appends -/// to. -/// -/// The recorded usage reference is the window itself, never a total: media type, -/// workspace-relative log path, session id, and the line span observed here. -/// Model identity and timestamp come from the window's log lines; a call with no -/// window records neither, and every absent field reads `unavailable`. -fn window_provenance(workspace: &Path, session: Option<&str>) -> Provenance { - let Some(session) = session_id(session) else { - return Provenance::default(); - }; - let Ok(text) = std::fs::read_to_string(workspace.join(USAGE_LOG_PATH)) else { - return Provenance::default(); - }; - let mut span: Option<(u32, u32)> = None; - let mut model: Option = None; - let mut timestamp: Option = None; - for (index, line) in text.lines().enumerate() { - let Ok(value) = serde_json::from_str::(line) else { - continue; - }; - if value["session"].as_str() != Some(session.as_str()) { - continue; - } - let number = (index + 1) as u32; - span = Some(match span { - None => (number, number), - Some((start, _)) => (start, number), - }); - // The window's own lines name the model and the moment; the later line - // wins, so the record names the call this edit most closely followed. - if let Some(named) = reported(&value["model"]) { - model = Some(named); - } - if let Some(named) = reported(&value["timestamp"]) { - timestamp = Some(named); - } - } - let Some((line_start, line_end)) = span else { - return Provenance::default(); - }; - Provenance { - model, - timestamp, - tool_version: None, - usage: Some(UsageRef::Window(UsageWindow { - media_type: USAGE_LOG_MEDIA_TYPE.to_string(), - log_path: USAGE_LOG_PATH.to_string(), - session, - line_start, - line_end, - })), - } -} - -/// The session this call belongs to: the flag, or the environment variable the -/// wrapper injects, or none at all. -fn session_id(session: Option<&str>) -> Option { - if let Some(explicit) = session { - let trimmed = explicit.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - return None; - } - // The one environment read in the toolchain, and the contract names it: a - // call made under `napl agent` carries its window with no extra argument. - let inherited = std::env::var(SESSION_VARIABLE).ok()?; - let trimmed = inherited.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - -/// A log field that actually names something. The proxy writes `unavailable` -/// where it read nothing, and that is not a value to carry forward. -fn reported(value: &Value) -> Option { - let text = value.as_str()?; - if text.is_empty() || text == attribution::UNAVAILABLE { - None - } else { - Some(text.to_string()) - } -} - -// ---------------------------------------------------------------- plumbing - -/// The workspace-relative path a verb targets, refused when it escapes the -/// workspace. -/// -/// An absolute path is honored as given. A relative one is resolved against the -/// working directory first, which is what a caller standing inside the workspace -/// means, and against the workspace root otherwise, which is how every record in -/// the store already spells a path. -fn relative_target(workspace: &Path, cwd: &Path, given: &str) -> Result { - let raw = PathBuf::from(given); - let absolute = if raw.is_absolute() { - raw - } else { - let from_cwd = cwd.join(&raw); - if from_cwd.starts_with(workspace) { - from_cwd - } else { - workspace.join(&raw) - } - }; - let relative = absolute.strip_prefix(workspace).map_err(|_| { - error( - OutcomeCode::IoError, - format!("{given}: the path is outside the workspace"), - ) - })?; - let mut parts: Vec = Vec::new(); - for component in relative.components() { - match component { - std::path::Component::Normal(os) => { - parts.push(os.to_string_lossy().into_owned()) - } - std::path::Component::CurDir => {} - _ => { - return Err(error( - OutcomeCode::IoError, - format!("{given}: the path is not canonical"), - )) - } - } - } - Ok(parts.join("/")) -} - -fn ok_payload(value: Value) -> CliResult { - CliResult { - stdout: canonical_bytes(&value), - exit_code: 0, - } -} - -/// A refusal: the canonical payload naming what failed and that nothing was -/// applied or published, plus the outcome's pinned exit code. -fn refusal(err: &CompilerError) -> CliResult { - let mut payload = crate::error::error_payload(err, false, false); - if let Some(object) = payload.as_object_mut() { - object.insert("applied".to_string(), Value::Bool(false)); - object.insert("recorded".to_string(), Value::Bool(false)); - } - CliResult { - stdout: canonical_bytes(&payload), - exit_code: crate::error::outcome_exit_code(err.code), - } -} -napl-store tree v1 132 -{"entries":[{"hash":"922a54930e3affda2ce0caa8792c72701e96bf24f401241581977a3aff62d9a0","kind":"dir","mode":16384,"name":"crates"}]} -napl-store transition v1 632 -{"baseStateKey":"67df6d3a1c20bac424e4a0104bf02ea3dd17d728c930e641c212ce0bfd3260d1","contractVersion":4,"derivedProjections":[],"inputKey":"f0f530591a0d6bbdd53045e2a0f34b4f37c578b4602f318e114904c6d189e0b4","installPaths":[],"kind":"docs-only","module":"agent","newState":"f26d6fde72e9fc4f86b6909dc51455aaf37c8a17ed31d6db11e94d9a0408b324","prompt":"11b47daa7b6384032d19bfbfb73f0c64a5903cf25a7e2117cc6555e32ed26c57","readSet":["prompt:d81c438d06c05e8bffbcfdc17ea45c873a6292f6e34df1f08367c0a95f7e27b0"],"surface":"ad1581ae209ba3feb19322408bfff544be7a29bc6a7db44c672b705f1afcd120","target":"rust","writeSet":["module-state:rust/agent"]} -napl-store tree v1 129 -{"entries":[{"hash":"f2f812fb8d99b1aa1f62d3d1dafdff4cf049d0956a4031a4e9450db3f5210a37","kind":"dir","mode":16384,"name":"src"}]} -napl-store blob v1 5581 -//! Discovery's product: the target-restricted dependency graph and its waves. - -use std::collections::{BTreeMap, BTreeSet}; - -use crate::error::{error, OutcomeCode, Result}; -use crate::source::SourceModule; -use crate::target::TargetDef; - -/// The modules that declare a target, keyed by module name. -pub type Graph = BTreeMap; - -/// Reject a source set whose modules collide, returning them keyed by name. -/// -/// Filesystem enumeration order never reaches this function's output: the map is -/// keyed and therefore sorted, and a duplicate is a loud failure rather than a -/// last-writer-wins. -pub fn index_sources(sources: &[SourceModule]) -> Result> { - let mut out: BTreeMap = BTreeMap::new(); - for source in sources { - if let Some(prior) = out.insert(source.module.clone(), source.clone()) { - return Err(error( - OutcomeCode::MappingIntegrity, - format!( - "module {:?} is declared by both {} and {}", - source.module, prior.path, source.path - ), - )); - } - } - Ok(out) -} - -/// Build the target-restricted dependency graph. -pub fn build_graph(sources: &[SourceModule], target: &TargetDef) -> Result { - let indexed = index_sources(sources)?; - let graph: Graph = indexed - .into_iter() - .filter(|(_, source)| source.targets.iter().any(|t| t == &target.name)) - .collect(); - for source in graph.values() { - for dep in &source.deps { - if !graph.contains_key(dep) { - return Err(error( - OutcomeCode::MappingIntegrity, - format!( - "module {:?} depends on {dep:?}, which does not declare target {:?}", - source.module, target.name - ), - )); - } - } - } - Ok(graph) -} - -/// Assign each module a dependency level; error on a cycle. -pub fn topological_levels(graph: &Graph) -> Result> { - let mut levels: BTreeMap = BTreeMap::new(); - let mut remaining: BTreeSet = graph.keys().cloned().collect(); - while !remaining.is_empty() { - let ready: Vec = remaining - .iter() - .filter(|module| graph[*module].deps.iter().all(|dep| levels.contains_key(dep))) - .cloned() - .collect(); - if ready.is_empty() { - let cycle: Vec = remaining.iter().cloned().collect(); - return Err(error( - OutcomeCode::MappingIntegrity, - format!("dependency cycle among {cycle:?}"), - )); - } - for module in ready { - let level = graph[&module] - .deps - .iter() - .map(|dep| levels[dep] + 1) - .max() - .unwrap_or(0); - levels.insert(module.clone(), level); - remaining.remove(&module); - } - } - Ok(levels) -} - -/// The modules that depend on `module`, directly or transitively. -pub fn dependents_of(graph: &Graph, module: &str) -> BTreeSet { - let mut out: BTreeSet = BTreeSet::new(); - loop { - let grown: BTreeSet = graph - .iter() - .filter(|(name, source)| { - !out.contains(*name) - && source - .deps - .iter() - .any(|dep| dep == module || out.contains(dep)) - }) - .map(|(name, _)| name.clone()) - .collect(); - if grown.is_empty() { - return out; - } - out.extend(grown); - } -} - -/// The modules reachable from a declared root set, roots included. -pub fn reachable_from(graph: &Graph, roots: &[String]) -> BTreeSet { - let mut out: BTreeSet = BTreeSet::new(); - let mut queue: Vec = roots.to_vec(); - while let Some(module) = queue.pop() { - if !graph.contains_key(&module) || !out.insert(module.clone()) { - continue; - } - queue.extend(graph[&module].deps.iter().cloned()); - } - out -} - -/// The changed frontier: modules whose `inputKey` moved, plus their dependents. -pub fn changed_frontier( - graph: &Graph, - committed_input_keys: &BTreeMap>, - input_keys: &BTreeMap, - only_module: Option<&str>, -) -> Result> { - if let Some(module) = only_module { - if !graph.contains_key(module) { - return Err(error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} is not in the graph for this target"), - )); - } - return Ok(vec![module.to_string()]); - } - let mut frontier: BTreeSet = graph - .keys() - .filter(|module| { - committed_input_keys - .get(*module) - .and_then(Option::as_deref) - != Some(input_keys[*module].as_str()) - }) - .cloned() - .collect(); - loop { - let grown: BTreeSet = graph - .iter() - .filter(|(module, source)| { - !frontier.contains(*module) && source.deps.iter().any(|dep| frontier.contains(dep)) - }) - .map(|(module, _)| module.clone()) - .collect(); - if grown.is_empty() { - break; - } - frontier.extend(grown); - } - Ok(frontier.into_iter().collect()) -} -napl-store blob v1 9804 -# The git-backed store - -On 2026-07-31 the owner approved building this module for local -evaluation, which rules the gix dependency in and narrows the -remaining gates to what they truly guard: the provider lands behind a -feature flag, off by default, and flipping the default for git -workspaces stays gated below. Every measured claim comes from the -2026-07-31 spike (scratchpad spike-git-store), whose scripts and -outputs are the evidence. The packfile layout, its index, the repack -operation, and the auto-repack threshold are superseded by this -module and are deleted in the wave that flips the default, not -before, because they remain the floor for workspaces the gates still -exclude. - -## Goal - -A napl user's pull request shows zero store paths. The store still -clones with the repository, travels on every push, merges without -human judgment, and loses nothing the tracked-file design keeps. No -new servers, no per-clone configuration, no command a user must -learn. Object identity, the envelope format, the commit decision -table, attribution, and every porcelain semantic are untouched: this -is a storage backend swap behind the existing store trait. - -## Shape - -Committed objects move out of the working tree and into git's own -object database, held together by one ordinary branch named -`napl-store`. - -- Every committed napl object becomes a git blob whose bytes are the - envelope verbatim. The napl name stays the SHA-256 of those bytes; - git's oid is a second, never-user-visible name. The spike - round-tripped 1000 realistic envelopes through git blobs with zero - mismatches. -- A two-level git tree mirrors the fanout, `` directories - of `` entries, mode 100644. -- One git commit per napl store transaction, parent the previous - store commit, so store history is a real, inspectable commit - chain. The commit message is `napl-store: ` with - trailers `Napl-Snapshot: ` and `Napl-Contract: `. -- napl HEAD is no longer a file: it is the snapshot trailer of the - `napl-store` branch tip, so reading HEAD is one ref read. +## Recorded surfaces -## Determinism +At commit, extract the candidate's public API with tree-sitter (pinned +grammars) and record it as a `surface` object; dependents' inputKeys pin +recorded surfaceHashes, store facts, never demand scans. The surface +grammar makes extraction sound by construction and violations reject as +`mapping-integrity`: for Rust, public items must be named `pub` +declarations at statically visible paths, no glob re-exports, no +macro-generated `pub` items; a named `pub use` is allowed and is recorded +as the unresolved written binding, never a resolved signature. For +TypeScript: explicit named exports only, no `export *`. Extraction is +syntactic, deterministic, and loud on malformed input. Loud means a +payload, never silence: extraction over bytes that do not parse +answers a parse failure naming the file and the first error's line, +distinct from a genuinely empty surface, in every seat that extracts, +the commit recorder, the `surface` read, and the query layer, so an +empty item list always means parsed and nothing public. Re-extraction from +committed output must reproduce the recorded surface byte-identically. -The commit signature is the fixed identity `napl-store ` -and the fixed timestamp epoch 0 +0000: two workspaces performing the -same transaction over the same base produce byte-identical git -commits. Epoch dates in a branch listing are cosmetic and honest, -display metadata that never enters identity, exactly as telemetry -already behaves. The commit object is built explicitly, never through -a convenience API that stamps wall-clock time; the spike measured -gix's `repo.commit()` doing exactly that, so it is forbidden here. +The `surface` read is honest about absence the same way: a module with +no committed state under the target answers `tracked: false` naming +the absent baseline, never an empty item list that reads as a real +surface with nothing public. ::: test -name: the same transaction over the same base yields byte-identical git commits -given: { workspaces: 2, transaction: same_over_same_base } -expect: { git_commit_bytes_equal: true, timestamps_fixed: true } +name: extraction over unparseable bytes answers parse-failed never an empty surface +given: { source_bytes: syntactically_invalid_rust, extraction: rust_surface } +expect: { outcome: parse_failed, error_names_line: true, served_as_empty_surface: false } ::: -## Concurrency - -The hand-rolled HEAD compare-and-swap, lock file, and dead-owner -steal are replaced by a git ref transaction with expected old value: -move `refs/heads/napl-store` from exactly commit A to commit B or -fail. The napl decision table is untouched; a lost ref race surfaces -as the same head-moved outcome the table already judges. Local -coordination state that never travels, staging records, INSTALLED, -markers, attempt files, stays as plain gitignored files exactly as -today. - ::: test -name: two concurrent transactions race the ref and the loser reports head moved -given: { concurrent_transactions: 2, same_base: true } -expect: { winners: 1, loser_outcome: head_moved, store_consistent: true } +name: surface for an uncommitted module says untracked never an empty list +given: { workspace: fixture_two_modules, module: pending_never_committed, read: surface_for_target } +expect: { tracked: false, reason_names_baseline: true, empty_item_list_served: false } ::: -## Append-only forever - -A store commit's tree holds the full object set at that moment; -structural sharing makes unchanged fanout subtrees free. Every -historical tree stays reachable from the commit chain, so git gc can -never prune a store object, and a napl-level sweep would require -rewriting branch history, which force-push protection forbids. The -git-backed store is append-only by construction: the lost-blob scar -becomes impossible, and gc keeps only its staging-litter cleanup and -dry report. - -## Travel - -Branches fetch by default, so a plain clone arrives with the -complete store; the spike measured this over file and git transports -and measured the hidden-ref alternative failing it silently, which -rejects hidden refs permanently. `napl init` installs a pre-push hook -when `.git/hooks/pre-push` is absent, never overwriting an existing -hook and printing the one line to add when one exists. The hook -pushes `napl-store` before the user's push proceeds, and ordering -carries the safety: code can never land without its records, and a -store push landing without its code is harmless surplus nothing -references yet. The atomic dual push -`git push --atomic origin napl-store` is the belt for CI. -The branch needs delete and force-push protection, the only measured -way to lose objects; init teaches both rules. - -Two writers can only disagree about ref position, never content, -because identical objects get identical names on both sides. The -reconciliation is a union of the two fanout trees, computed directly -over the two object maps and never through an index, so there is no -working tree to touch and zero unmerged entries by construction, run -automatically when the remote-tracking ref has diverged; no human -ever resolves the store. The merge commit carries both tips as -parents and republishes our own snapshot trailer, because a union -changes which objects are reachable and never which snapshot is -HEAD. - ::: test -name: a union merge of two diverged store tips carries every object from both -given: { diverged_tips: 2, shared_base: true } -expect: { unmerged_entries: 0, objects_from_both_present: true, working_tree_untouched: true } +name: a candidate with a glob re-export is rejected as mapping-integrity +given: { workspace: fixture_two_modules, candidate: contains_glob_reexport } +expect: { outcome: mapping_integrity, published_semantic_state: false } ::: -## Reading and writing - -All in-process through the gix crate; spawning git is forbidden by -the no-process law and condemned independently by measurement, 143 -milliseconds per added object against 0.4 in-process. Publication -becomes write-blob plus a tree update, idempotent by content -address, and read-back bytes must re-hash to the expected napl name -or the read is refused, the same integrity law as today. The store -trait gains a GitProvider beside DiskProvider and the in-memory -provider, parity-tested three ways; DiskProvider remains the backend -for gitless workspaces, and a workspace inside a git repository -defaults to GitProvider after migration. - -The dependency is gix with its default features off and no feature -added back: writing loose blobs and trees, building a commit object -field by field, moving a ref under an expected old value, and -reading refs and trees are all core surface, so the minimal set is -the empty set. gix persists a loose object by flushing a temporary -file and renaming it, and calls fsync on neither the file nor its -directory, read out of the gix-odb source on 2026-07-31. Durability -is therefore this provider's own obligation and it is paid at the -provider boundary: every object written and every ref moved is -forced to durable storage before the call returns. - -Publication writes the blob and records the object in a local, -gitignored pending file until the next transaction folds it into a -tree, so an object is readable the instant it is published, exactly -as it is in the tracked layout. That file is coordination state that -never travels, like the staging records and markers beside it. - ::: test -name: the git provider answers identically to the disk and memory providers -given: { fixture: PARITY, providers: [memory, disk, git] } -expect: { providers_agree: true } +name: a named pub use is recorded in the surface as the written binding +given: { workspace: fixture_two_modules, candidate: valid_with_named_reexport } +expect: { surface_contains_written_binding: true, surface_contains_resolved_signature: false } ::: ::: test -name: publishing an existing object through the git provider is an idempotent no-op -given: { provider: git, publish: same_object_twice } -expect: { second_outcome: idempotent_success, object_count_unchanged: true } +name: recorded surface re-extraction from committed output is byte-identical +given: { workspace: fixture_two_modules, candidate: valid_for_consumer } +expect: { reextraction_equal: true } ::: -## Migration - -GitProvider lands behind the `git-store` cargo feature, off by -default, so a workspace that has not opted in neither compiles the -provider nor carries gix at all; the parity battery is green three -ways under that feature. Nothing in the toolchain selects the -provider in this wave: the feature is the whole door, and the -wiring that lets a git workspace pick it up belongs to the flip -wave. A one-shot converter walks the tracked objects and -any pack into blobs, tree, and one commit, re-hashing every blob to -its napl name, measured at 0.355 seconds per 1000 objects. Flipping -the default for git workspaces, letting the tracked files leave the -working tree, and deleting the packfile machinery all belong to a -later wave that the remaining gates below guard; until then the -tracked layout stays the default and the flag is the only door in. -The tracked-file reader survives one release past the flip for old -checkouts. - -## The gates - -The build gates are cleared: the owner ruled the gix dependency in on -2026-07-31 and approved a local evaluation build, and the fsync -obligation is paid: gix fsyncs nothing it writes, so the provider -fsyncs every object and every ref at its own boundary, which is what -lets the parity battery be called green. What remains gated is -flipping the default provider for git workspaces: - -1. A GitHub reality spike on a throwaway repository: atomic dual - push acceptance, branch protection interaction, direct-push - policy, shallow and blobless clone behavior of the store branch. -2. An answer for organizations requiring pull requests on every - branch, where direct pushes are forbidden and this design fails; - the tracked layout remains the floor there, which is why its - machinery outlives this module's landing. - -Open questions carried from the spike: shallow and partial clone -completeness, growth at one hundred thousand store commits, archive -and tarball consumers who lose the store by construction, and -whether git worktrees share the store branch cleanly. -napl-store blob v1 614065 - -# napl-store: the content-addressed store +## add, then commit -This prompt describes the napl-store crate, migrated from ten legacy module -prompts into one module. The code was hand-ported from the legacy store -crates and adopted as this prompt's committed output; the sections below -are the ported prompts, merged. Section names preserve the legacy module -boundaries, which survive as source modules inside the crate. +Git parity is the mental model: `add` stages, `commit` installs. The +receipt is an internal record between them, never a user-facing noun. +`napl commit` with no arguments installs every staged record in +dependency-wave order, rebasing where the decision table allows, one +outcome reported per record; `--staged ` installs one. A +multi-module add followed by a bare commit is therefore one command +each, with the sequencing and retry the table already defines owned by +the tool, never hand-looped; and when an earlier record in the same +invocation moves the head so that a later one returns write-conflict +or stale-input against it, bare commit re-derives that record against +the new head, exactly what re-running add would produce, and retries +it once, so modules sharing one crate land in one invocation instead +of hand-ordered pairs. A retry that fails again returns its outcome +honestly. A bare commit that finds nothing staged says so instead of +dressing emptiness as success: the payload names that zero records +were staged, and when any module carries working-tree drift it names +each drifted module beside the exact `napl add` invocation that +would stage it, so the empty commit is the teacher, never the trap. +Commit sweeps the staged record it installed, so a later +bare commit never re-attempts history; `napl gc` reports staged +records among the unreachable, and `napl gc --apply` sweeps them: +they are disposable litter by the git-state law, re-creatable by +re-running add, and no one should ever hand-edit `.napl` to be rid of +them. -## store_hash -# Store object hashing: the typed envelope and the read verifier +`napl add` stages a module state from any of three sources, and the +working tree is the default one, exactly as in git: -This module defines the lowest layer of the NAPL content-addressed store: how an -object's bytes are framed into a typed, versioned, length-prefixed envelope, how -that envelope is named by its SHA-256, the canonical JSON encoding every -non-blob object payload obeys, and the read verifier that validates an object's -bytes against its name before any consumer trusts them. It is pure: no -filesystem, no I/O, no clocks, no randomness, and no dependency on any other -project module. Bring in `serde_json` for JSON values and the `sha2` crate for -SHA-256; do not reimplement SHA-256 and do not substitute a different SHA-256 -crate. +- `napl add [...]` names working-tree files. Each path + resolves to its owning module through the owned globs (unique by + commit invariant, so no `--module` is needed), and the staged state + is that module's committed output with exactly the named files + overlaid from the working tree. Paths owned by different modules + stage one record per module in one invocation. A path no module owns + is a usage error naming the owned globs that do exist. +- `napl add --module ` stages the module's whole owned tree as + it sits in the working tree. +- `napl add --module ` stages explicit candidate + bytes, for the rare case where the bytes to stage are not the + working tree. -## Where this code lives +A module's own prompt path is the one path add accepts that no owned +glob names, and it is accepted for exactly one purpose: recording a +docs edit. The committed prompt is the whole prompt file, frontmatter +included, so the judgment is one pure comparison of two files with +three outcomes. When the files differ only inside docs fences, so the +frontmatter and the semantic body are both byte-identical, +`napl add ` stages a docs record: the new prompt bytes +and nothing else, no candidate tree, no attribution work, no usage. +When the semantic body moved, the invocation refuses and names the +first semantic line that moved, teaching `napl add --module` as the +path for real changes, because a docs record that smuggled a semantic +edit would be an unreviewed module change. When the frontmatter +moved, the invocation refuses naming the frontmatter line, under the +same teaching, because frontmatter feeds the identities the module is +keyed on and is never prose. The docs record names the base state it +was drafted against, and commit fails it on mismatch rather than +recalculating, the same base-naming rule every state transition obeys. +Commit installs it by writing the new prompt object and re-pointing +the module's prompt at it while every derived fact holds: same +inputKey, same attribution map, same endpoints, same pending state, +and the transition says `docs-only` so history never confuses prose +with semantics. The two doors supersede each other rather than stack: +a module record already carries the working-tree prompt, so staging a +docs record beside it is refused as already-carried, staging a module +record sweeps a staged docs record for the same module, and a second +docs add replaces the first, so at most one record can ever speak for +a module's prompt and no ordering of ordinary commands can wedge the +staging area. `napl status` reports docs drift per module by the same +judgment, silent for work a staged record already carries, so status +and the empty-commit teacher can never disagree. The teacher includes +docs drift: a bare commit finding only docs-drifted prompts names +each one, keyed by prompt path, beside the `napl add ` +that would record it. -The working directory is a Cargo workspace whose root manifest is written and -owned by the toolchain, leave it alone. Create this module as its own member -crate in a subdirectory named `store_hash/`: `store_hash/Cargo.toml` (package -name `store_hash`) and `store_hash/src/lib.rs`. Touch nothing outside -`store_hash/`. Ensure `cargo test` passes from the workspace root before -finishing. +::: test +name: a docs-only prompt edit stages and commits as a docs record leaving module state untouched +given: { edit: inside_docs_fence_only, then: [add_prompt_path, commit] } +expect: { transition: docs_only, input_key_changed: false, endpoints_moved: 0, pending: [], prompt_object_updated: true } +::: -## The store-wide hash format version +::: test +name: add of a prompt path with semantic drift refuses naming the first semantic line +given: { edit: outside_docs_fence, then: [add_prompt_path] } +expect: { outcome: refused, line_named: first_semantic_change, teaches: add_module } +::: -Expose the public constant `pub const HASH_FORMAT_VERSION: u32 = 1`, the -store-wide hash format version. Its name is `HASH_FORMAT_VERSION` and its type is -`u32`. It is baked into every object's envelope, so it is part of every object's -name. It is store-wide: one value for the whole store, never per object or per -kind. +::: test +name: a bare commit names docs-drifted prompts beside the add that records them +given: { edit: inside_docs_fence_only, then: [commit] } +expect: { installed: 0, docs_drifted_named: true, teaches: add_prompt_path } +::: -## Object kinds +::: test +name: a frontmatter edit refuses the docs door naming the frontmatter line +given: { edit: frontmatter_only, then: [add_prompt_path] } +expect: { outcome: refused, frontmatter_line_named: true, teaches: add_module } +::: -Expose a public enum `ObjectKind` (deriving `Debug`, `Clone`, `Copy`, -`PartialEq`, and `Eq`) with exactly these variants and wire tokens: +::: test +name: a docs record names its base and commit fails it on mismatch +given: { staged: docs_record, then: [module_state_moves, commit] } +expect: { outcome: write_conflict, semantic_state_published: false } +::: -- `Blob` renders as `blob` -- `BlockIndex` renders as `block-index` -- `Tree` renders as `tree` -- `ModuleState` renders as `module-state` -- `Event` renders as `event` -- `Snapshot` renders as `snapshot` +::: test +name: a second docs add replaces the first so older prose can never win +given: { then: [add_prompt_path, edit_docs_again, add_prompt_path, commit] } +expect: { records_installed: 1, prompt_object: latest_bytes } +::: -Render a kind to its wire token through the inherent method `as_wire(self) -> -&'static str`, and parse a wire token back into a kind through the inherent -associated function `from_wire(token: &str) -> Option`, which yields -`None` for any token not in this exact set. Use these two inherent methods with -exactly these names; do not implement the `Display` or `FromStr` traits for this -purpose. A wire token never contains a space; it may contain a hyphen -(`block-index`, `module-state`). +::: test +name: a module add supersedes a staged docs record and a docs add refuses beside a staged module record +given: { then: [add_prompt_path, add_module] } +expect: { docs_record_swept: true, reverse_order_refused: already_carried, commit_never_wedges: true } +::: -Only `Blob` carries raw payload bytes. Every other kind carries a canonical JSON -payload (see below); this distinction is what the read verifier enforces. +::: test +name: status reports docs drift by the same judgment and stays silent for staged work +given: { edit: inside_docs_fence_only } +expect: { status_docs_drift: true, after_add: { status_docs_drift: false } } +::: -## The envelope +All three forms feed one pipeline with `[--attribution ] +[--usage ]`: ingest the staged bytes into immutable content +addressed blobs (reject links, devices, duplicate normalized paths, +absolute or parent-escaping paths, oversized entries or totals; all +later steps read ingested blobs, never the source files); parse every +staged file the target claims as source with the target's pinned +tree-sitter grammar, and refuse as `invalid-candidate` when any parse +tree carries an error node, naming the file, the first error's line, +and the malformed region, because a surface extracted from a broken +tree is not a fact worth recording; the gate is syntax only, judged by +the same grammar the surface extractor uses, and everything past +syntax, types, borrows, semantics, stays the acceptance battery's job; +commit re-runs the same gate when it recomputes the staged record, so +bytes that do not parse can never install through any door; compute +the slice and validate against it; resolve attribution as below; validate +and attach usage evidence when supplied; then write the staged record — +the internal receipt: identities generated against, base snapshot +hashes, candidate tree hash, output manifest, attribution, optional +reported fields. Validation failures surface at add time with the same +outcome classes commit uses. Staged records are transient coordination +state, disposable litter by the git-state law: never promoted to +history, safely deletable, re-creatable by re-running add. -An object's bytes are its full envelope, never bare payload. The envelope is the -concatenation, in order, of: +Attribution transports instead of being demanded. Add computes the +byte diff between the module's committed output and the staged bytes, +and the prompt's committed body and its pending body, and transports +every committed attribution claim through those diffs +deterministically: a range the diff proves untouched survives +verbatim, a range below an insertion or deletion shifts by its size, +and a claim landing in a rewritten or ambiguous region does not +transport, because the transport never guesses; a range it cannot +prove through the diff is asked for, never invented. When every claim +transports, add stages with the transported map and no `--attribution` +flag is needed: the paperwork updates itself as a consequence of the +diff already being content-addressed on both sides. When any claim +fails to transport, add refuses as `invalid-candidate` listing exactly +the claims that need affirmation, each beside the observed bound or +region it was checked against, and `--attribution` supplies +affirmations for the listed claims only, layered over the transported +remainder. Any attribution refusal, transport or explicit, names the +observed bound (the actual line count or surviving range) next to the +rejected claim, so a wrong range never costs a manual count. Each +listed claim also carries its index in the recorded map and its note +verbatim, so an affirmation is written against named prose, never +positional trust or a separate query join. -- the literal ASCII magic `napl-store` -- a single space -- the kind's wire token -- a single space, then the ASCII letter `v`, then the hash format version as - minimal decimal digits (the value `1`) -- a single space -- the payload's length in bytes, as minimal decimal ASCII digits (no leading - zeros; the single digit `0` for an empty payload) -- a single newline byte (`\n`, `0x0A`) -- the payload bytes, verbatim +::: test +name: a transport refusal lists each claim with its map index and note +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } +expect: { listed_claims: 1, claim_index_present: true, claim_note_verbatim: true } +::: -So a blob whose payload is the two bytes `hi` has the envelope bytes -`napl-store blob v1 2\nhi`, and an empty blob has the envelope bytes -`napl-store blob v1 0\n`. Expose the function `envelope_bytes(kind: ObjectKind, -payload: &[u8]) -> Vec`, returning the envelope bytes. The payload is copied -in verbatim: its bytes are never re-encoded, escaped, or altered, and it may -contain spaces, newlines, or arbitrary binary. +A module's first add has no committed map to transport, and it demands +no hand-authored one either: absent `--attribution`, add stages the +trivial whole map, every staged file claimed in full by the whole +prompt body, recorded as whole-file claims and honestly coarse. The +cold path to a verified first commit is therefore write the prompt, +write the code, `add --module`, `commit`, with attribution present and +fresh, never `absent`, and `--no-verify` never required to get started. +A claim, in any map, may be spelled whole: naming a file with no +numbers claims all of it, and naming a prompt range as whole claims +the whole body; bounds are derived at validation time from the staged +blob and the pending body, so no line count is ever transcribed by +hand and a whole-file claim survives any growth of its file. -Because the kind and version sit inside the envelope, and therefore inside the -name, an identical payload framed under two different kinds produces two -different names, and the same payload under a future version would too. +Two more spellings close the remaining gaps: `napl add +--module ` stages a path under a module explicitly, for the file +that is new in a crate several modules share, where neither the +committed manifest nor the globs can disambiguate it alone; and +`napl add --check` with any form runs the entire pipeline, transport +verdict and refusal listing included, and writes nothing, not even a +staged record, the dry run that answers before anything lands. The +check verdict says exactly what it judged and what it did not: the +payload enumerates the validations that ran (placement, ingest rules, +the parse gate, attribution transport, evidence) and names what stays +unjudged, everything past syntax, the acceptance battery's job, so +`would-stage` means the record is sound and the syntax parses, never +that the code is correct. A transport refusal +reports each endpoint's verdict separately: a claim whose prompt side +failed but whose code side transported serves the transported code +range beside the failed prompt range, and the affirmation supplies +only the side that actually needs a human, never both because one +refused. An unparseable or wrong-shaped `--attribution` file is +answered like a bad ref payload: the refusal carries the complete map +contract, every field and one complete valid example, never a bare +"not parseable". The map reader speaks the same value grammar as body +directives, so block and flow spellings of one map are one map: +`lines: [4, 9]` and its two-line block form parse identically. A map +that parses but yields no entries, or that fails at any construct, is +refused naming the exact line and the construct that stopped the +reader, beside the full contract, never a bare count of zero. The +contract is also taught before any failure: `help add` and +`add --help` serve the same complete map contract and worked example +the refusal carries, so the map is learnable from help instead of +from trial and error. ::: test -name: a blob envelope is the exact length-prefixed byte string -given: { kind: blob, payload: "hi" } -expect: { envelope_bytes: "napl-store blob v1 2\nhi" } +name: flow and block spellings of one attribution map parse identically +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, maps: [flow_sequences, block_sequences] } +expect: { parsed_entries_equal: true, both_stage: true } ::: ::: test -name: an empty blob envelope carries a zero length and a trailing newline -given: { kind: blob, payload: "" } -expect: { envelope_bytes: "napl-store blob v1 0\n" } +name: a map failing mid-parse names the line and construct beside the contract +given: { workspace: fixture_two_modules, attribution_file: bad_construct_on_line_3 } +expect: { error_names_line: 3, error_names_construct: true, error_carries_valid_example: true } ::: ::: test -name: identical payload under two kinds gets two different names -given: { payload: "X", kind_a: blob, kind_b: snapshot } -expect: { name_a: "d11071939b166fd9bb74e2937789b0c7b224fd56d341024d34729dfe9c521073", name_b: "88bb4c116d992830f1d7a387af00aaaa660e4f9e6482f0f33c1bf5dbeabbbdcc", equal: false } +name: add help carries the complete attribution map contract and example +given: { workspace: fixture_two_modules, invocation: "help add" } +expect: { map_fields_listed: true, map_example_present: true, exit_code: 0, store_bytes_unchanged: true } ::: -## Object naming - -Expose the function `object_name(kind: ObjectKind, payload: &[u8]) -> String`, -returning the object's name: the SHA-256 digest of the envelope bytes, encoded -as a lowercase hexadecimal string. The name is always 64 characters, each a -lowercase hex digit -(`0` through `9`, `a` through `f`), with no uppercase. For example, the blob -whose payload is `hi` is named -`29757fd72ed1d3f68debb794c6ce43b16f16de6b2256c504cd7caff92b40c4da`. This must -match exactly what a standard SHA-256 implementation produces over the envelope -bytes. - ::: test -name: the object name is the sha256 of the envelope bytes -given: { kind: blob, payload: "hi" } -expect: { object_name: "29757fd72ed1d3f68debb794c6ce43b16f16de6b2256c504cd7caff92b40c4da" } +name: a first add with no flag stages the whole map and commits verified +given: { workspace: fresh_init_root_prompt, candidate: valid_root_level, attribution_flag: absent } +expect: { add_outcome: staged, map_kind: whole_file_claims, commit_outcome: success, attribution: fresh, no_verify_used: false } ::: ::: test -name: the empty blob has its own stable name -given: { kind: blob, payload: "" } -expect: { object_name: "588ecb60283f38e891a374bef0c39523cabb9a6b49d1d6e8f5605b799f0eda59" } +name: a whole spelling claims the entire file and survives file growth +given: { workspace: fixture_two_modules, map_entry: whole_file_no_numbers, then: file_grows } +expect: { claim_valid: true, bounds_derived: true, retranscription_needed: false } ::: ::: test -name: an object name is 64 lowercase hex characters -given: { kind: blob, payload: "anything" } -expect: { length: 64, lowercaseHex: true } +name: add check reports the full verdict and writes nothing +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_check } +expect: { verdict_listed_claims: 1, staged_records: 0, staging_dir_untouched: true, store_bytes_unchanged: true } ::: -## Canonical JSON - -Every object kind other than `Blob` serializes its payload as canonical JSON. -Expose the function `canonical_json(value: &serde_json::Value) -> Vec`, -returning its canonical encoding, defined as: - -- UTF-8 bytes. -- Object keys sorted, recursively at every level, in ascending lexicographic - order of their UTF-8 bytes. Sort the keys explicitly inside this function - rather than relying on any map type's iteration order, so that a - `preserve_order` feature enabled elsewhere in the workspace can never change - the output. -- Compact: no insignificant whitespace anywhere, so a comma or a key-value colon - is never followed or preceded by a space, and the whole value is a single line - with no embedded formatting newlines. -- Scalars, numbers included, are emitted exactly as `serde_json`'s compact - serializer renders them; this function applies no separate number - normalization, so a caller who needs two numerically equal values to - canonicalize identically is responsible for supplying them in the same form. -- Exactly one trailing newline byte (`\n`, `0x0A`) appended after the value. - -So the value `{"b":1,"a":2}` encodes to the bytes `{"a":2,"b":1}\n`. This -canonical form is what makes a JSON object's bytes, and therefore its name, -independent of the key order or whitespace of whatever produced the value. - ::: test -name: "canonical json sorts keys, drops insignificant whitespace, and ends in one newline" -given: { value: "{\"b\":1,\"a\":2}" } -expect: { canonical_bytes: "{\"a\":2,\"b\":1}\n" } +name: an explicit module stages a new path in a shared crate +given: { workspace: fixture_shared_crate_two_modules, working_tree: new_uncommitted_file_in_shared_glob, add_path_with_module: true } +expect: { add_outcome: staged, module_as_named: true } ::: ::: test -name: a canonical json object under a json kind pins its envelope and name -given: { kind: snapshot, value: "{\"b\":1,\"a\":2}" } -expect: { envelope_bytes: "napl-store snapshot v1 14\n{\"a\":2,\"b\":1}\n", object_name: "4a8a7b1b5f14247c9d31bd887c545c831518d4cdb0fa5694b3cfcbeb21446fe4" } +name: add of one working-tree path stages the committed state with that file overlaid +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, add_paths: [edited_file] } +expect: { add_outcome: staged, staged_state: committed_plus_overlay, unnamed_files_at_committed_bytes: true } ::: -## The read verifier - -Before any consumer trusts an object read from the store, it passes the object's -claimed name and its raw bytes through the verifier. Expose the function -`verify_object(name: &str, bytes: &[u8]) -> Result`. -On success it returns the public struct `VerifiedObject` with public fields `kind: -ObjectKind` and `payload: Vec` (the struct deriving `Debug`, `Clone`, -`PartialEq`, `Eq`), carrying the parsed kind and the payload bytes. On failure it -returns the typed error enum below. - -The verifier performs these checks in exactly this order, returning the first -failure it encounters, so that each failure mode is reachable independently: - -1. **Envelope well-formedness.** Locate the first newline byte. If there is - none, the bytes are not an envelope: fail as `MalformedEnvelope`. Everything - before that first newline is the header; everything after it is the payload. - The header bytes must be valid UTF-8; a header that is not valid UTF-8 fails - as `MalformedEnvelope`. This UTF-8 requirement is part of well-formedness, so - a non-UTF-8 byte anywhere in the header, including inside the kind token, is a - malformed envelope and never reaches the kind check. Split the header on - single space bytes; it must yield exactly four fields. The first field must be - exactly `napl-store`. The third field must be the letter `v` followed by a - non-empty run of ASCII decimal digits in minimal form: no leading zeros, the - lone digit `0` being the only run that may begin with `0`. The fourth field - must be a non-empty run of ASCII decimal digits in that same minimal form, and - must fit in a `u64`; a length digit run too large to fit `u64` fails here as - `MalformedEnvelope`, and no fabricated byte count is ever produced or reported - for it. Any deviation fails as `MalformedEnvelope`. -2. **Kind.** Parse the second header field as an `ObjectKind` wire token. A token - outside the known set fails as `UnknownKind`. -3. **Version.** Parse the minimal-decimal digits after the `v` in the third - field as a `u64`. Any value other than the store-wide hash format version, - including a digit run too large to fit `u64`, fails as `UnsupportedVersion`. -4. **Declared length.** Interpret the fourth field as the declared payload byte - count, a `u64` already confirmed minimal and in range in step 1. If it does - not equal the actual number of payload bytes (also taken as a `u64`), fail as - `LengthMismatch`, carrying both the declared and the actual counts. -5. **Name.** Compute the SHA-256 of the whole envelope bytes as lowercase hex. If - it does not equal the supplied name, fail as `HashMismatch`. -6. **Canonical payload.** If the kind is not `Blob`, the payload must be - canonical JSON: parsing the payload bytes as a JSON value and re-encoding that - value through the canonical JSON function must reproduce the payload bytes - exactly. If the payload does not parse as JSON, or the re-encoding differs by - even one byte (unsorted keys, stray whitespace, a missing or extra trailing - newline), fail as `NonCanonicalJson`. A `Blob` payload is raw bytes and is - never subjected to this check; a blob of arbitrary non-JSON binary verifies - successfully once its name matches. - -On success the returned kind and payload let the caller decode the object -without reparsing the envelope. The error enum is named `VerifyError` and has one -variant per failure mode above: `MalformedEnvelope`, `UnknownKind`, -`UnsupportedVersion`, `LengthMismatch` carrying the declared and actual byte -counts (both `u64`), `HashMismatch`, and `NonCanonicalJson`. It derives `Debug`, -`Clone`, `PartialEq`, and `Eq`. - ::: test -name: verify round-trips a freshly built blob object -given: { kind: blob, payload: "hi", name: "29757fd72ed1d3f68debb794c6ce43b16f16de6b2256c504cd7caff92b40c4da" } -expect: { ok: true, kind: blob, payload: "hi" } +name: paths owned by two modules stage one record per module +given: { workspace: fixture_two_modules, working_tree_edit: both_modules_touched, add_paths: [file_of_leaf, file_of_consumer] } +expect: { staged_records: 2, modules: [consumer, leaf] } ::: ::: test -name: verify accepts a canonical json object -given: { name: "4a8a7b1b5f14247c9d31bd887c545c831518d4cdb0fa5694b3cfcbeb21446fe4", envelope_bytes: "napl-store snapshot v1 14\n{\"a\":2,\"b\":1}\n" } -expect: { ok: true, kind: snapshot } +name: a path no module owns is a usage error naming the owned globs +given: { workspace: fixture_two_modules, add_paths: [unowned_file] } +expect: { exit_code: 2, error_names_owned_globs: true, staged_records: 0 } ::: ::: test -name: verify accepts a blob whose payload is not json at all -given: { kind: blob, payload: " not json {" } -expect: { ok: true, kind: blob } +name: a pure line-shift edit transports the whole map and needs no attribution flag +given: { workspace: fixture_two_modules, working_tree_edit: insert_lines_above_attributed_range, add: module_form_without_attribution } +expect: { add_outcome: staged, map_transported: true, ranges_shifted: true, attribution_flag_needed: false } ::: ::: test -name: verify rejects bytes that are not an envelope at all -given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "not-an-envelope" } -expect: { error: MalformedEnvelope } +name: a claim in a rewritten region refuses listing that claim with its observed bound +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } +expect: { outcome: invalid_candidate, listed_claims: 1, observed_bound_present: true, untouched_claims_not_listed: true } ::: ::: test -name: verify rejects a header with no payload newline -given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "napl-store blob v1 2 hi" } -expect: { error: MalformedEnvelope } +name: affirmations layer over the transported remainder and stage +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_affirmation_for_listed_claim } +expect: { add_outcome: staged, transported_claims_kept: true, affirmed_claim_recorded: true } ::: ::: test -name: verify rejects a declared length that disagrees with the payload -given: { name: "a84cd278760eb18441b94fc4b4c34c87e008b1296e5f78bd76e417f40f0f085d", bytes: "napl-store blob v1 5\nhi" } -expect: { error: LengthMismatch, declared: 5, actual: 2 } +name: add stages a valid candidate and commit installs it +given: { workspace: fixture_two_modules, candidate: valid_with_attribution } +expect: { add_outcome: staged, commit_outcome: success } ::: ::: test -name: verify rejects an unknown kind token -given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "napl-store frobnicate v1 2\nhi" } -expect: { error: UnknownKind } +name: an explicit candidate whose transport is incomplete and unaffirmed fails loudly +given: { workspace: fixture_two_modules, candidate: rewritten_region_without_affirmation } +expect: { outcome: invalid_candidate, listed_claims_present: true } ::: ::: test -name: verify rejects a header carrying a non-UTF-8 byte in the kind token -given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "the envelope napl-store v1 2 then newline then hi, where the kind token holds a raw 0xFF byte that is not valid UTF-8" } -expect: { error: MalformedEnvelope } +name: a candidate with a parent-escaping path is rejected as invalid-candidate +given: { workspace: fixture_two_modules, candidate: contains_dotdot_path } +expect: { outcome: invalid_candidate, published_semantic_state: false } ::: ::: test -name: verify rejects a non-minimal version with a leading zero -given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "napl-store blob v01 2\nhi" } -expect: { error: MalformedEnvelope } +name: tampering a staged blob after ingestion never mixes into the install +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, tamper: candidate_file_after_ingest } +expect: { outcome_in: [installed_from_immutable_blobs, store_integrity], mixed_content: false } ::: +`napl commit --staged ` is the one guarded +transaction over what add staged. Pipeline: verify the staged record +(recompute sliceHash, inputKey, baseStateKey at the recorded bases, +reject mismatches, enforce evidence bounds); extract and record the +surface; derive the complete transition (three-part footprint, member-set +delta, shared projections recomputed from the merged head); stage all +materialization outside any lock; then one bounded critical section under +the pointer lock performing only the footprint recheck and the pointer +and directory swaps, bounded by the transition's file count, never +hashing content inside the lock. + ::: test -name: verify rejects a non-minimal declared length with a leading zero -given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "napl-store blob v1 02\nhi" } -expect: { error: MalformedEnvelope } +name: a crash between staged materialization and pointer swap leaves a deterministic recovery plan +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, crash_after: staging } +expect: { store_readable: true, recovery_plan_deterministic: true, semantic_state_unchanged: true } ::: ::: test -name: verify rejects a declared length too large to fit u64 -given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "napl-store blob v1 999999999999999999999\nhi" } -expect: { error: MalformedEnvelope } +name: a receipt whose recomputed slice hash disagrees is rejected as invalid-receipt +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: tampered_slice_hash } +expect: { outcome: invalid_receipt, published_semantic_state: false } ::: +The decision table, judged per arriving transition against the current +head: identities reproduce with head unmoved installs; head moved with +identities reproducing and footprints disjoint rebases then installs +(projections re-derived, never reused); inputKey failure returns +`stale-input`; baseStateKey failure returns `base-output-changed`; semantic +write overlap returns `write-conflict`; lock contention returns +`lock-contended`. Terminal outcomes, never retried unchanged: +`invalid-candidate`, `invalid-receipt`, `mapping-integrity`, +`resource-limit`, `unsupported-version`, `store-integrity`, `io-error`. +Every outcome is a canonical machine-readable JSON payload naming whether +blobs or provenance were published; no outcome publishes semantic state +partially. Exit codes: 0 success, 10 through 13 for the four returned +concurrency outcomes in table order, 20 through 26 for the seven terminal +outcomes in the order above. Staged records carry `receiptVersion`, the +per-module identities generated against, base snapshot hashes, the +candidate tree hash, the output manifest, then optional reported fields; +the compiler recomputes everything derivable and rejects on mismatch; +absent telemetry is `unavailable`, never zero. `napl notes add --to +` attaches later provenance without touching semantic state. + ::: test -name: verify rejects an unsupported hash format version -given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "napl-store blob v2 2\nhi" } -expect: { error: UnsupportedVersion } +name: commit installs a valid candidate against an unmoved head +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, head: unmoved } +expect: { outcome: installed, head_advanced: true, surface_recorded: true } ::: ::: test -name: verify rejects a name that does not match the envelope bytes -given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "napl-store blob v1 2\nhi" } -expect: { error: HashMismatch } +name: a bare commit installs every staged record in wave order +given: { workspace: fixture_two_modules, staged: [record_for_leaf, record_for_consumer], commit_args: none } +expect: { outcomes: [success, success], order: wave_order, hand_sequencing_needed: false } ::: ::: test -name: verify rejects a json kind whose payload keys are not sorted -given: { name: "324c5a38a26b99ba50a001a7abdf368cb43ce356112be2a0525582b2694c553e", bytes: "napl-store snapshot v1 14\n{\"b\":1,\"a\":2}\n" } -expect: { error: NonCanonicalJson } +name: bare commit re-derives and lands records for modules sharing one crate +given: { workspace: fixture_shared_crate_two_modules, staged: [record_for_first, record_for_second], commit_args: none } +expect: { outcomes: [success, success], retried_after_head_move: 1, hand_sequencing_needed: false } ::: -## Rendering a verification failure - -Implement `std::fmt::Display` for `VerifyError` so a consumer can render a -verification failure as a bare, human-readable message that carries no `Debug` -artifacts: no variant name, no quotes, and no braces. Each variant renders exactly -one deterministic line. `MalformedEnvelope` renders `malformed object envelope`. -`UnknownKind` renders `unknown object kind`. `UnsupportedVersion` renders -`unsupported store format version`. `LengthMismatch` renders `object length -mismatch: declared {declared} bytes, actual {actual} bytes`, interpolating its two -byte counts as plain decimals in that order. `HashMismatch` renders `object hash -does not match its name`. `NonCanonicalJson` renders `object payload is not -canonical JSON`. This addition is purely additive: the `Debug` derive is retained -for the test assertions that pin variant equality, and `ObjectKind` keeps its -standing prohibition on a `Display` impl. The `Display` impl and its `fmt` method -carry no doc comment, matching the crate's existing trait-impl style. - -## Test and code discipline - -The frontmatter test suite is a contract, not a sketch. Each case in it is -realized by its own dedicated test function that exercises only that case, so the -number of dedicated tests is at least the number of frontmatter cases. Never fold -two or more cases into one shared test, and a single test that loops over a table -of inputs never counts as the dedicated test any of those inputs requires: such a -combined test may exist only as an extra. Cases that differ only in their input -while sharing an expected value are still distinct cases, each owed its own -dedicated test: the two envelope-shape cases (the two-byte blob and the empty -blob) get one test each, and every case that expects `MalformedEnvelope` (bytes -that are not an envelope at all, a header with no payload newline, a non-UTF-8 -byte in the kind token, a leading-zero version, a leading-zero length, and a -length too large for u64) and every case that expects `UnsupportedVersion` gets -its own dedicated test, whatever shared loop may also exist as an extra. - -Inside a case's dedicated test, hand the code exactly the fixture that case -gives, byte for byte, and assert its pinned expectation verbatim: no shorter or -more convenient stand-in, no re-derivation of a value or object the case pins as -a literal, and no weakening of a pinned equality into an inequality-only or -recomputed check. Where a case pins a `name`, pass exactly that name string, -whether it is the all-zeros placeholder several rejection cases use or a specific -digest such as the length-mismatch case's, never an arbitrary word and never -normalized to zeros. Where a case pins `bytes`, verify those exact bytes and never -substitute a re-derived object or a different kind: the canonical-json accept -case verifies the pinned snapshot envelope -`napl-store snapshot v1 14\n{"a":2,"b":1}\n` under its pinned name, and the -hash-mismatch case verifies the pinned bytes `napl-store blob v1 2\nhi` under its -all-zeros name. Where a case describes its bytes in prose rather than as a -literal (the non-UTF-8 kind token), construct exactly the bytes it describes. - -So the unknown-kind case frames the exact token `frobnicate`, the lowercase-hex -case uses payload `anything`, the non-JSON blob case uses payload ` not json {`, -the not-an-envelope case uses bytes `not-an-envelope`, the no-payload-newline -case uses its exact bytes `napl-store blob v1 2 hi`, the length-too-large case -uses the digit run `999999999999999999999`, the freshly-built-blob verify case -passes its pinned name -`29757fd72ed1d3f68debb794c6ce43b16f16de6b2256c504cd7caff92b40c4da`, and the -unsorted-keys case uses the snapshot envelope bytes -`napl-store snapshot v1 14\n{"b":1,"a":2}\n` against `NonCanonicalJson`, each in -its own test. The pinned digests are asserted -verbatim: the empty-blob digest -`588ecb60283f38e891a374bef0c39523cabb9a6b49d1d6e8f5605b799f0eda59`, the two -different names for payload `X` under `blob` -(`d11071939b166fd9bb74e2937789b0c7b224fd56d341024d34729dfe9c521073`) and under -`snapshot` (`88bb4c116d992830f1d7a387af00aaaa660e4f9e6482f0f33c1bf5dbeabbbdcc`), -and the snapshot envelope bytes `napl-store snapshot v1 14\n{"a":2,"b":1}\n` with -its digest `4a8a7b1b5f14247c9d31bd887c545c831518d4cdb0fa5694b3cfcbeb21446fe4`. -A dedicated test asserts only its own case and nothing more: the blob-name case -for payload `hi` asserts only the pinned digest equality, leaving the -sixty-four-lowercase-hex shape to the `anything` case that owns it, and the -canonical-json case asserts only that `{"b":1,"a":2}` canonicalizes to -`{"a":2,"b":1}\n`. Any further name-shape or canonicalization fixture, such as -nested objects, arrays, null, or escaped strings, lives in its own separately -named supplemental test rather than riding along inside a suite case's test. -Additional tests beyond this suite are welcome once every case has its dedicated -test, but they never replace or subsume a suite case. - -Beyond the suite, one dedicated supplemental test per `VerifyError` variant -asserts that variant's exact `Display` line verbatim through `to_string()`: -`MalformedEnvelope` renders `malformed object envelope`, `UnknownKind` renders -`unknown object kind`, `UnsupportedVersion` renders `unsupported store format -version`, `HashMismatch` renders `object hash does not match its name`, -`NonCanonicalJson` renders `object payload is not canonical JSON`, and a -`LengthMismatch` constructed with declared `5` and actual `9` renders -`object length mismatch: declared 5 bytes, actual 9 bytes`. Each of these six is -its own dedicated test asserting only its own line, and none replaces or subsumes -a suite case. - -Write no explanatory line or block comments in the generated code. Doc comments -restating this prompt's contract on public items are the only commentary the -code carries; there are no other comments of any kind. - -## store_objects -# Store object model: strict serde types for module-state, event, and snapshot - -This module defines the strict, canonical-JSON serde types for the three -provenance and state object kinds of the NAPL content-addressed store: -`module-state`, `event`, and `snapshot`. It defines the distinguished unknown -state sentinel that a state reference may hold, the full set of event kinds -including the migrated `legacy` kind, the non-advancing outcome rule that a -failed or interrupted step obeys, and the optional model-provenance record a gen -or derivation step carries together with the pinned price table that estimates -its cost. It is pure: no filesystem, no I/O, no clocks, no randomness. Bring in -`serde` and `serde_json`. - -## Scope boundary: which object kinds live here - -The store has six object kinds. This module owns the serde types for exactly -three of them, the provenance and state kinds: `module-state`, `event`, and -`snapshot`. The other three are out of scope here and are never redefined in this -crate: - -- `blob` is raw payload bytes and has no typed payload struct; it is handled - entirely by the `store_hash` layer. -- `block-index` is defined by the dedicated `store_blocks` module. -- `tree` is defined by the dedicated `store_tree` module. - -Every reference this module makes to another object, whether a prompt blob, a -block index, a generated tree, an attribution document, a mapl document, an ir -document, a task bundle, a diagnostic, a prior event, a parent snapshot, or a -module-state, is a **content-addressed object name**: the sixty-four-character -lowercase hexadecimal SHA-256 hash string that `store_hash` produces. This module -never embeds another object's structure inline; it stores that object's hash. So -this module needs neither the block-index nor the tree payload types: a -module-state references them by hash. - -## Where this code lives - -The working directory is a Cargo workspace whose root manifest is written and -owned by the toolchain, leave it alone. Create this module as its own member crate -in a subdirectory named `store_objects/`: `store_objects/Cargo.toml` (package name -`store_objects`) and `store_objects/src/lib.rs`. Touch nothing outside -`store_objects/`. Ensure `cargo test` passes from the workspace root before -finishing. - -## Builds on the `store_hash` module of this workspace - -This crate composes on the generated sibling crate **`store_hash`** -(`../store_hash`) for object naming, envelope framing, canonical JSON, and read -verification. Use its public API, do not reimplement its types or logic, and do -not depend on any hand-written crate. The items this module uses from it: - -- `store_hash::ObjectKind`, the object-kind enum whose variants - `ObjectKind::ModuleState`, `ObjectKind::Event`, and `ObjectKind::Snapshot` this - module names its three kinds with; their wire tokens `module-state`, `event`, - and `snapshot` come from `store_hash`, never re-spelled here. -- `store_hash::canonical_json(value: &serde_json::Value) -> Vec`, the - canonical JSON encoder (sorted keys, compact, one trailing newline) that turns a - serialized value into its canonical bytes. -- `store_hash::envelope_bytes(kind, payload) -> Vec` and - `store_hash::object_name(kind, payload) -> String`, the typed envelope framing - and the SHA-256 object name over those envelope bytes. -- `store_hash::verify_object(name, bytes) -> Result`, - the read verifier the round-trip tests pass a freshly built object through. - -## Content hashes are plain strings - -A content-addressed object name is carried as a `String` throughout this module, -matching the rest of the workspace, never a newtype. A well-formed object name is -sixty-four lowercase hexadecimal characters (`0` through `9`, `a` through `f`). -The validators below reject any hash-typed field whose string is not exactly that -shape. This module never computes a hash except by delegating to `store_hash`. - -## The module-state kind - -Expose a public struct `ModuleState` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`, `serde::Serialize`, and `serde::Deserialize`, with -`#[serde(deny_unknown_fields)]`), one module's authoritative state. Its prompt -side is target independent; its generated artifacts are per target. Public fields: - -- `prompt_blob: String`, serde field name `promptBlob`. The prompt blob's hash; - this is the module's prompt identity, the concept the rest of the toolchain - calls the prompt hash. -- `block_index: String`, serde field name `blockIndex`. The block-index object's - hash. -- `mapl: Option`, serde field name `mapl`, skipped from serialization when - `None`. The machine-layer document's hash. It sits at the module level, not per - target (see the derivation-layout ruling below). -- `ir: Option`, serde field name `ir`, skipped from serialization when - `None`. The contract-level IR document's hash. It too sits at the module level, - not per target (see the ruling below). -- `targets: std::collections::BTreeMap`, serde field name - `targets`. The per-target artifacts, keyed by target name, always present even - when empty (an empty map serializes as `{}`). A `BTreeMap` is used so the map's - keys serialize in sorted order, matching the store's canonical ordering. - -Expose a public struct `TargetArtifacts` (same derives, same -`#[serde(deny_unknown_fields)]`), the artifacts generated for one target. Public -fields: - -- `generated_tree: String`, serde field name `generatedTree`. The generated - directory tree object's hash for this target. -- `attribution: Option`, serde field name `attribution`, skipped from - serialization when `None`. The attribution document's hash for this target. +::: test +name: commit sweeps the record it installed +given: { workspace: fixture_two_modules, staged: [record_for_leaf], commit: bare_then_bare_again } +expect: { first_outcomes: [success], second_outcomes: [], reattempted_history: 0 } +::: -### The derivation-layout ruling that fixes mapl and ir at the module level +::: test +name: a bare commit with nothing staged names the drifted modules and their add commands +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, staged: none, commit_args: none } +expect: { installed: 0, nothing_staged_named: true, drifted_modules_named: [consumer], add_command_named: true } +::: -The current toolchain's generator (`cmd_gen_io`) writes every per-module -derivation document keyed by module name alone, with no target segment in the -path: the machine-layer document at `.napl/mapl/.mapl`, the IR document at -`.napl/ir/.yaml`, the attribution document at -`.napl/attribution/.yaml`, and the prompt-at-gen body at -`.napl/prompts-at-gen/.md`. There is exactly one mapl and one ir file per -module on disk, keyed per module, never per (module, target). The IR document -itself carries only a module field and no target field, confirming it is the -target-independent contract. Therefore, in this schema, both `mapl` and `ir` sit -at the module level of `ModuleState`, and the per-target `TargetArtifacts` record -carries only `generatedTree` and `attribution`. The machine-layer document does -carry an internal target field, yet its on-disk file is keyed per module, so the -store follows the writer's keying and holds one `mapl` hash per module. +::: test +name: a per-side transport refusal serves the surviving side's transported range +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_prompt_section_only, add: module_form_with_check } +expect: { prompt_side: refused, code_side: transported, transported_code_range_served: true, affirmation_needed_sides: [prompt] } +::: -### Validating a module-state +::: test +name: check enumerates the parse gate and refuses a file that does not parse +given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, add: module_form_with_check } +expect: { validations_enumerated: true, parse_gate_listed: true, outcome: invalid_candidate, error_names_file_and_line: true, staged_records: 0 } +::: -Expose `validate_module_state(value: serde_json::Value) -> Result`: deserialize the JSON value into a `ModuleState`, returning -`StoreObjectError::Malformed` (see below) on any deserialization failure (an -unknown field, a missing required field, a value of the wrong JSON type), then -apply these semantic checks, returning the first failure: +::: test +name: add refuses a candidate whose source does not parse naming file and line +given: { workspace: fixture_two_modules, candidate: syntactically_invalid_rust } +expect: { outcome: invalid_candidate, error_names_file_and_line: true, published_semantic_state: false } +::: -- `promptBlob`, `blockIndex`, a present `mapl`, a present `ir`, and, for every - target record, its `generatedTree` and a present `attribution`, must each be a - well-formed object name (sixty-four lowercase hex). Any that is not fails as - `StoreObjectError::MalformedHash`. -- Every key of `targets` must be a non-empty string. An empty target key fails as - `StoreObjectError::EmptyKey`. +::: test +name: commit recomputation refuses staged bytes that do not parse +given: { workspace: fixture_two_modules, staged: record_with_tampered_unparseable_blob } +expect: { outcome_in: [invalid_candidate, store_integrity], installed: false } +::: ::: test -name: a module-state with an unknown field is rejected -given: { json: "the MS_MIN object with an extra top-level key \"bogus\": 1" } -expect: { error: Malformed } +name: a bad attribution file answers with the complete map contract and example +given: { workspace: fixture_two_modules, attribution_file: empty_object, add: module_form_with_attribution } +expect: { error_lists_all_fields: true, error_carries_valid_example: true } ::: ::: test -name: a module-state missing promptBlob is rejected -given: { json: "the MS_MIN object with the promptBlob key removed" } -expect: { error: Malformed } +name: the plan labels projections and names a legal first output +given: { workspace: fresh_init_root_prompt, flag: module_root } +expect: { projection_labeled_tool_derived: true, legal_first_output_named: true } ::: ::: test -name: a module-state missing blockIndex is rejected -given: { json: "the MS_MIN object with the blockIndex key removed" } -expect: { error: Malformed } +name: gc apply sweeps staged records and touches nothing reachable +given: { workspace: fixture_two_modules, staged: [stale_record], gc: apply } +expect: { staging_empty: true, reachable_objects_untouched: true, store_healthy: true } ::: ::: test -name: a module-state whose targets is the wrong JSON type is rejected -given: { json: "the MS_MIN object with targets set to the array []" } -expect: { error: Malformed } +name: commit against a moved head with disjoint footprints rebases and installs +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: to_unrelated_module } +expect: { outcome: installed, rebased: true } ::: ::: test -name: a module-state whose promptBlob is not sixty-four lowercase hex is rejected -given: { json: "the MS_MIN object with promptBlob set to \"xyz\"" } -expect: { error: MalformedHash } +name: commit whose direct dep surface moved underneath returns stale-input +given: { workspace: fixture_chain_three, candidate: valid_for_middle, intervening_commit: leaf_surface_change } +expect: { outcome: stale_input, head_advanced: false } ::: ::: test -name: a module-state with an empty target key is rejected -given: { json: "the MS_RICH object with the target key \"rust\" renamed to the empty string" } -expect: { error: EmptyKey } +name: commit whose own outputs moved underneath returns base-output-changed +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: same_module_other_candidate } +expect: { outcome: base_output_changed } ::: ::: test -name: a target-artifacts record with an unknown field is rejected -given: { json: "the MS_RICH object whose one target record carries an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: two concurrent disjoint commits both land and neither touches a manifest +given: { workspace: fixture_two_modules, concurrent_candidates: [module_a, module_b], orders: [ab, ba] } +expect: { both_installed: true, manifest_bytes_unchanged: true } ::: -## State references and the unknown sentinel +::: test +name: "absent telemetry reads as unavailable, never zero" +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: without_usage } +expect: { usage: unavailable } +::: -A step's state before and after is either a concrete module-state object name, or -the distinguished **unknown** sentinel that migrated legacy steps use when the -real state cannot be reconstructed. Expose a public enum `StateRef` (deriving -`Debug`, `Clone`, `PartialEq`, and `Eq`) with exactly two forms: +Attribution rides the staged record; there is no other write path for it. +`napl add --attribution ` carries the module's mapping of prompt body +line ranges to generated file line ranges with notes; add validates every +claim before staging: each file path inside the module's owned globs, +each code line range present in the candidate tree, each prompt line +range present in the current normalized prompt body; a mapping that lies +is `invalid-candidate`. An entry may own no file at all: contract-level +prose — a law — that governs the module rather than any single path; +validation accepts a file-less entry (its prompt range must still exist) +and the query layer serves it flagged as law. An add whose transport is +incomplete and whose affirmations do not cover the listed claims fails +loudly as `invalid-candidate`, naming exactly which claims are missing; +`napl add --no-verify` is the explicit escape hatch that stages anyway +and records the module's attribution as stale on install. Any prompt +edit, code transition, or move that changes what a recorded mapping +points at marks it stale the same way; nothing ever repairs a mapping +automatically. `napl status` reports attribution freshness per +module (`fresh`, `stale`, `absent`) and, for stale or absent, emits a +machine-readable directive naming the exact fix: commit with a fresh +attribution for that module. Relocation recomputes only the path prefix +of a mapping, never its line content. The lsp serves a stale mapping +marked stale rather than hiding it; absent answers empty. -- `StateRef::State(String)`, a concrete module-state object name. -- `StateRef::Unknown`, the distinguished unknown state sentinel. +An attribution entry is a reference, and a reference has a direction: +prompt-to-code, a prompt body line range naming generated file line +ranges, or code-to-prompt, a generated file line range naming a prompt +body line range. Every entry recorded before directions existed reads as +prompt-to-code; migration is a reading, never a rewrite of stored +records. Validation is identical for both directions and referential +only, the same claims checked the same way, and nothing about a +direction implies the sentence describes the code: a reference is a +claim with provenance and freshness, and the record vocabulary never +says more. -`StateRef` serializes and deserializes as a single JSON **string**, not an object: -`State(h)` is the bare hash string `h`, and `Unknown` is the exact literal string -`unknown`. On deserialization, the exact string `unknown` yields -`StateRef::Unknown` and any other string yields `StateRef::State` carrying that -string verbatim. This is unambiguous because a well-formed object name is -sixty-four lowercase hex and can never be the seven-letter word `unknown`. -Implement this string representation directly; do not derive the default enum -representation. +A reference carries a provenance block, recorded by the compiler at +staging time from what the caller supplied: recording model identity, +timestamp, tool version, and an optional usage-envelope reference +through the existing evidence seam. Every absent field is `unavailable`, +never zero, never invented, and provenance is display and pricing +metadata that never enters any identity, exactly as telemetry already +behaves. The query layer and the lsp serve the provenance block with the +entry. -An event's state fields are each `Option`, so the three situations stay -distinct: `None` is an **absent** state (a derivation that legitimately has no -before-state, such as an unborn module), `Some(StateRef::Unknown)` is a legacy gap -whose state is unknown, and `Some(StateRef::State(h))` is a concrete state. Absent -is never conflated with unknown. +Staleness covers both ends of a reference, and working-tree drift is one +of the ways an end changes: a reference whose code side no longer +matches the committed bytes, or whose prompt side changed in the working +tree, reads stale on both endpoints, computed at read time from the +records and the working tree, never stored as a transition. A hand edit +to generated source is drift like any other: legal, visible through the +same status and diff machinery, healable by restore or by re-recording +the reference; nothing repairs a reference automatically and nothing +polices the edit that staled it. ::: test -name: a known state ref serializes as its bare sixty-four hex hash string -given: { state_ref: "known(H2)" } -expect: { serialized_json: "\"2222222222222222222222222222222222222222222222222222222222222222\"" } +name: a code-to-prompt entry stages and is served with its direction +given: { workspace: fixture_two_modules, candidate: valid_with_code_to_prompt_reference } +expect: { add_outcome: staged, served_direction: code_to_prompt } ::: ::: test -name: the unknown state sentinel serializes as the literal string unknown -given: { state_ref: unknown } -expect: { serialized_json: "\"unknown\"" } +name: entries recorded before directions read as prompt-to-code unchanged +given: { workspace: fixture_two_modules, history: [pre_direction_attribution] } +expect: { served_direction: prompt_to_code, stored_records_rewritten: false } ::: ::: test -name: the literal string unknown deserializes to the unknown state sentinel -given: { serialized_json: "\"unknown\"" } -expect: { is_unknown_sentinel: true } +name: provenance is served with the entry and absent fields read unavailable +given: { workspace: fixture_two_modules, candidate: reference_without_usage_envelope } +expect: { provenance_served: true, usage: unavailable, cost_component: unavailable } ::: ::: test -name: a bare hash string deserializes to a known state ref -given: { serialized_json: "\"2222222222222222222222222222222222222222222222222222222222222222\"" } -expect: { is_known: true, hash: "2222222222222222222222222222222222222222222222222222222222222222" } +name: a hand edit to the code side reads stale on both endpoints +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } +expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } ::: ::: test -name: an event distinguishes an absent state from the unknown sentinel from a known hash -given: { absent: none, unknown: sentinel, known: H2 } -expect: { absent_is_none: true, unknown_is_sentinel: true, known_is_hash: true, all_distinct: true } +name: a working-tree prompt edit reads stale on both endpoints +given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched } +expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } ::: -## The event kind +::: test +name: a code-to-prompt reference naming a missing prompt range is invalid-candidate +given: { workspace: fixture_two_modules, candidate: code_to_prompt_reference_to_missing_range } +expect: { outcome: invalid_candidate, published_semantic_state: false } +::: -Expose a public struct `Event` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, -`serde::Serialize`, and `serde::Deserialize`, with -`#[serde(deny_unknown_fields)]`), one provenance step for one module. Public -fields, in this shape: +## edit and write: the capture verbs -- `parents: Vec`, serde field name `parents`. The prior event hashes on - this module's single linear chain; always present, an empty chain serializes as - `[]`. -- `module: String`, serde field name `module`. -- `target: Option`, serde field name `target`, skipped from serialization - when `None`. Present only where the step is target scoped. -- `ordinal: u64`, serde field name `ordinal`. The global gen number, allocated at - commit. It is a `u64`. -- `timestamp: String`, serde field name `timestamp`. -- `engine: String`, serde field name `engine`. The engine identifier; it may be - the empty string on a migrated legacy event whose original entry named no - engine. -- `state_before: Option`, serde field name `stateBefore`, skipped from - serialization when `None`. -- `state_after: Option`, serde field name `stateAfter`, skipped from - serialization when `None`. -- `payload: EventPayload`, serde field name `payload`. The kind-specific payload, - described next. -- `attempt_id: Option`, serde field name `attemptId`, skipped from - serialization when `None`. The identifier of the attempt marker this event was - recorded from, present on every event the transaction protocol publishes - (whether a normal commit or a recovery-recorded interrupted event) so the - idempotency guard can find whether an event already carries a given attempt id, - and absent on a legacy or migrated event that names no attempt. It is an opaque - short token such as `att-0001`, not a content hash, and is exempt from the hash - check exactly like the diagnostics field below. +`napl edit` and `napl write` are the deterministic capture layer in +front of the same guarded door. They carry the familiar tool +signatures: `napl edit --old-string --new-string +[--replace-all]` replaces an exact occurrence in a file, and +`napl write ` writes stdin as the file's full content; its usage +line says the content comes from stdin, and an empty stdin refuses as +a usage error naming `--empty`, the explicit flag that authorizes +writing a zero-byte file, so a forgotten pipe can never silently +truncate. Either +side is editable, a prompt or a generated file, by a human or an LLM; +a hand edit through an ordinary editor stays equally legal, and the +verbs add capture, never exclusivity. The verbs never synthesize or +rewrite prose beyond the bytes given, never invoke a model, never +decide placement, never repair a reference, and never bypass the +guard: `add` and `commit` remain exactly what they are. -The `engine` field stays the coarse engine identifier and is never overloaded to -carry the finer model id. The finer model id lives inside a gen or derivation -step's `provenance` record (defined below) and may differ per event under the -same engine; `engine` and `provenance.model` are two separate observations and -neither is derived from the other. +Each call, deterministically and in this order: validate, apply, +capture. When the target is a prompt file, the result of the edit is +validated first by the same reader `check` and `add` use; a result +that fails validation refuses as `prompt-invalid` and applies nothing. +An `--old-string` that matches nowhere, matches more than once +without `--replace-all`, or equals `--new-string` refuses as +`edit-mismatch` and applies nothing, so a no-op edit can never mint a +record. Only a call that will succeed touches the working tree, so +every refusal leaves every file byte-identical. -### The kind-specific payload +A call may carry one reference: `--ref `, a single object naming +a direction and both endpoints, prompt body line range and generated +file line range, in either direction per the reference contract above. +The contract is taught, never reverse engineered: the verbs' usage +lines and flag meanings name the object's fields, and the FIRST +validation failure of a ref payload answers with the complete +contract in one refusal, every field, the closed list of valid +`direction` values, and one complete valid example, never one missing +field per attempt. +The reference is validated referentially only, exactly as strong as +attribution validation and no stronger: the file path inside the +owning module's placement, both line ranges present in the +post-edit bytes of their files. A reference naming a missing line or +a path outside placement refuses as `reference-unresolvable` and +applies nothing. A valid reference-bearing call stages a +reference-only record and commits it through the guarded transaction +in the same invocation: the transition carries the reference entry and +its provenance, no output-tree change and no surface change, and every +rule of the decision table, journaled atomicity, and recovery applies +to it unchanged. The success payload carries the recorded reference +object verbatim and its transition hash, and says plainly that the +edited file remains working-tree drift until its module is committed: +recording a reference and advancing the committed baseline are +different acts, and the payload spells the sequence that performs the +second, `napl add` for the module then `napl commit`, so nobody reads +`recorded: true` as committed and nobody feeds a bare commit nothing. +A plain apply teaches the same way: a written or edited file is +working-tree drift until staged and committed, and the success +payload of both verbs names both verbs in order. `napl show ` lists the +transition's reference entries inline exactly as it lists notes. A +call without `--ref` is a plain edit: apply, no +record, no store touch. Any existing reference whose endpoint the edit +moved needs no marking, because both-ends staleness is already +computed at read time from the working tree; the verbs store nothing +about it. -Expose a public enum `EventPayload` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`, and `serde::Serialize`) carrying the kind discriminant and each kind's own -fields. On the wire it is serde's **adjacently tagged** shape, tag key `kind` and -content key `fields`, so it serializes as an object with exactly two keys: `kind`, -the wire token, and `fields`, the kind's data object. +A reference the verb records is evidence, and evidence serves itself. +The record carries the content hash of each endpoint's post-edit +bytes, the file as the verb wrote it and the normalized prompt body it +validated against. Freshness reads those hashes first: a verb-recorded +reference whose endpoint bytes still match the working tree reads +fresh, and it goes stale only when the tree moves off the bytes the +record names, never merely because the committed baseline lags the +edit, so `recorded: true` is never followed by a stale count the verb +itself caused. At the next add, transport seeds from the same +evidence: a committed claim refusing in a rewritten region is affirmed +automatically when a reference transition recorded since the module's +committed output covers that region and its endpoint hashes match the +staged bytes, because the tool that applied the edit owns its diff; +the affirmation is the record, and no hand-authored map is ever +demanded for a range a capture verb already recorded. A recorded +reference whose hashes no longer match affirms nothing and is listed +in the refusal like any other claim, so the shortcut never guesses. -The wrapper object is strict. On deserialization the payload object must carry -exactly the two keys `kind` and `fields` and nothing else; a payload object that -carries any third sibling key beside `kind` and `fields` is a deserialization -failure, surfacing through `validate_event` as `StoreObjectError::Malformed`. -Serde's default adjacently tagged deserializer does not give you this: it silently -ignores unknown sibling keys, and `#[serde(deny_unknown_fields)]` is not accepted -on an adjacently tagged enum and would not help. So implement the wrapper's -deserialization explicitly to enforce the strictness, for example a hand-written -`Deserialize` for `EventPayload` that reads the payload as a map, rejects any key -other than `kind` and `fields`, and then decodes the `fields` object under the -`kind` token. This wrapper-level strictness never weakens the per-`fields` -`#[serde(deny_unknown_fields)]`: both layers hold at once, the wrapper rejecting a -stray sibling key beside `kind` and `fields`, and each fields struct rejecting a -stray field inside its own `fields` object. +::: test +name: a verb-recorded reference reads fresh until the tree moves off its bytes +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: status_module } +expect: { stale_reference_count: 0, drift: true } +::: -Each variant is a newtype wrapping a dedicated fields struct that itself derives -`Debug`, `Clone`, `PartialEq`, `Eq`, `serde::Serialize`, and `serde::Deserialize` -and carries `#[serde(deny_unknown_fields)]`, so an unknown field inside a -payload's `fields` object is rejected. The variants, their wire tokens, and their -fields structs: +::: test +name: add after a referenced edit self-affirms the rewritten region and stages without a map +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, add: module_form_without_attribution } +expect: { add_outcome: staged, affirmed_from_reference_record: true, attribution_flag_needed: false } +::: -- `EventPayload::Gen(GenFields)`, wire token `gen`. -- `EventPayload::Reword(RewordFields)`, wire token `reword`. -- `EventPayload::Reconcile(ReconcileFields)`, wire token `reconcile`. -- `EventPayload::Move(MoveFields)`, wire token `move`. -- `EventPayload::Derivation(DerivationFields)`, wire token `derivation`. -- `EventPayload::Audit(AuditFields)`, wire token `audit`. -- `EventPayload::Revert(RevertFields)`, wire token `revert`. -- `EventPayload::Legacy(LegacyFields)`, wire token `legacy`. +::: test +name: a recorded reference whose bytes moved again affirms nothing +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: hand_edit_same_region, add: module_form_without_attribution } +expect: { outcome: invalid_candidate, listed_claims_present: true, affirmed_from_reference_record: false } +::: -The fields structs, each `#[serde(deny_unknown_fields)]` and deriving `Debug`, -`Clone`, `PartialEq`, `Eq`, `Serialize`, `Deserialize`: +Provenance rides the session window: `--session ` names the +usage-proxy session whose log lines are this call's evidence, and when +the flag is absent the verb reads the `NAPL_SESSION` environment +variable, so a call made under `napl agent` carries its window with no +extra argument. The recorded usage reference is the window itself, +media type, workspace-relative log path, session id, and the line span +observed at staging, priced lazily by `napl cost` at read time, never +aggregated at record time. Model identity and timestamp come from the +window's log lines, or read `unavailable` when there is no window; +the verbs read no clock, so the same call over the same working tree, +store state, and log bytes produces byte-identical records. Absent is +`unavailable`, never zero, never invented, and provenance never +enters identity. -- `GenFields`: `mode: GenMode` (`mode`), `outcome: GenOutcome` (`outcome`), - `attempts: u32` (`attempts`), `task_bundle: String` (`taskBundle`, the task - bundle blob's hash), `diagnostics: Vec` (`diagnostics`, the - diagnostic references an interrupted or failed gen carried over from its attempt - marker, defaulting to an empty vector when absent and skipped from serialization - when empty), `provenance: Option` (`provenance`, skipped - from serialization when `None`, the model-provenance record for the agent run, - described below), and `usage: Vec` (`usage`, the per-attempt usage - log of this gen's coding-agent attempts, burned attempts included, defaulting to - an empty vector when absent and skipped from serialization when empty, described - under "The per-attempt usage log" below). See the diagnostics-field ruling below - for the serialization of `diagnostics` and its deliberate exemption from the hash - check, and the usage ruling below for the matching treatment of `usage`. -- `RewordFields`: `verdict: String` (`verdict`), `batch_id: String` (`batchId`), - `bundle: String` (`bundle`, the bundle blob's hash). -- `ReconcileFields`: `drift_summary: String` (`driftSummary`), `amendment_bundle: - String` (`amendmentBundle`, the amendment bundle blob's hash). -- `MoveFields`: `context: String` (`context`, the location-heal context). -- `DerivationFields`: `derivation: String` (`derivation`, which derivation, for - example `mapl` or `ir`), `outcome: DerivationOutcome` (`outcome`), `attempts: - u32` (`attempts`), `error: String` (`error`, defaulting to the empty string when - absent), `diagnostic: Option` (`diagnostic`, skipped when `None`, the - diagnostic blob's hash on failure), `diagnostics: Vec` - (`diagnostics`, the diagnostic references an interrupted or failed derivation - carried over from its attempt marker, defaulting to an empty vector when absent - and skipped from serialization when empty), and `provenance: Option` - (`provenance`, skipped from serialization when `None`, the model-provenance - record for the derivation's model call, described below). This new plural - `diagnostics` field sits alongside the pre-existing singular `diagnostic` field - and never replaces it; the two are independent, and a derivation event may carry - either, both, or neither. See the diagnostics-field ruling below. -- `AuditFields`: `noop: bool` (`noop`, the expected-no-op assertion result, true - when the code-side bundle was empty). -- `RevertFields`: `reason: String` (`reason`, why the revert was made; the what is - captured by the event's state fields). -- `LegacyFields`: `original_line: String` (`originalLine`, the hash of the blob - holding the original journal line verbatim), `classification: - LegacyClassification` (`classification`). +The three refusals join the closed terminal-outcome table under the +contract bump to 4, pinned exit codes continuing the table order: +`edit-mismatch` 27, `reference-unresolvable` 28, `prompt-invalid` 29. +None reuses `mapping-integrity`, and each is a canonical +machine-readable JSON payload naming what failed and that nothing was +applied or published. ::: test -name: an event with an unknown field inside its payload fields is rejected -given: { json: "the EV_GEN object whose payload fields carry an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: an edit with a valid code-to-prompt reference stages and commits in one call +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } +expect: { outcome: installed, served_direction: code_to_prompt, output_tree_changed: false } ::: ::: test -name: an event whose payload wrapper carries a sibling key beside kind and fields is rejected -given: { json: "the EV_GEN object whose payload object carries an extra key \"bogus\": 1 beside its kind and fields keys" } -expect: { error: Malformed } +name: the same edit call twice produces byte-identical records +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, runs_from_same_base: 2 } +expect: { records_byte_identical: true } ::: ::: test -name: an event whose payload kind is an unknown token is rejected -given: { json: "the EV_GEN object with payload.kind set to \"frobnicate\"" } -expect: { error: Malformed } +name: a reference to a missing line refuses as reference-unresolvable and applies nothing +given: { workspace: fixture_two_modules, edit: ref_names_missing_line } +expect: { outcome: reference_unresolvable, exit_code: 28, files_byte_identical: true } ::: ::: test -name: an event payload of kind gen carries the tag gen -given: { payload_kind: gen } -expect: { tag: gen } +name: a prompt edit that breaks the prompt refuses as prompt-invalid and applies nothing +given: { workspace: fixture_two_modules, edit: prompt_edit_breaking_directive } +expect: { outcome: prompt_invalid, exit_code: 29, files_byte_identical: true } ::: ::: test -name: an event payload of kind reword carries the tag reword -given: { payload_kind: reword } -expect: { tag: reword } +name: an ambiguous old-string without replace-all refuses as edit-mismatch +given: { workspace: fixture_two_modules, edit: old_string_matches_twice } +expect: { outcome: edit_mismatch, exit_code: 27, files_byte_identical: true } ::: ::: test -name: an event payload of kind reconcile carries the tag reconcile -given: { payload_kind: reconcile } -expect: { tag: reconcile } +name: the first bad ref payload answers with the whole contract at once +given: { workspace: fixture_two_modules, edit: ref_payload_bare_string } +expect: { error_lists_all_fields: true, error_lists_direction_values: true, error_carries_valid_example: true, files_byte_identical: true } ::: ::: test -name: an event payload of kind move carries the tag move -given: { payload_kind: move } -expect: { tag: move } +name: a recorded edit serves its reference in the payload and in show +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } +expect: { payload_reference_verbatim: true, payload_names_drift_until_commit: true, show_lists_reference: true } ::: ::: test -name: an event payload of kind derivation carries the tag derivation -given: { payload_kind: derivation } -expect: { tag: derivation } +name: a prose-only edit needs no reference and touches no store state +given: { workspace: fixture_two_modules, edit: plain_prompt_edit_valid } +expect: { applied: true, store_bytes_unchanged: true } ::: ::: test -name: an event payload of kind audit carries the tag audit -given: { payload_kind: audit } -expect: { tag: audit } +name: verb success payloads spell the add then commit sequence +given: { workspace: fixture_two_modules, invocations: [plain_write_owned_file, referenced_code_edit_with_ref] } +expect: { write_payload_names: [add, commit], edit_payload_names: [add, commit] } ::: ::: test -name: an event payload of kind revert carries the tag revert -given: { payload_kind: revert } -expect: { tag: revert } +name: write with empty stdin refuses naming the empty flag and writes nothing +given: { workspace: fixture_two_modules, write: target_with_empty_stdin } +expect: { exit_code: 2, error_names_flag: "--empty", files_byte_identical: true } ::: ::: test -name: an event payload of kind legacy carries the tag legacy -given: { payload_kind: legacy } -expect: { tag: legacy } +name: an edit touching a referenced line reads stale on both endpoints afterward +given: { workspace: fixture_two_modules, edit: plain_edit_on_referenced_code_line } +expect: { reference_status: stale, stale_endpoints: [prompt, code] } ::: ::: test -name: a reword event round-trips back to an equal value -given: { event: "reword, verdict approved, batchId H1, bundle H4" } -expect: { round_trip_equal: true } +name: a session window from the environment is recorded as the usage reference +given: { workspace: fixture_two_modules, env: { NAPL_SESSION: s1 }, log: proxy_log_with_s1_lines, edit: referenced_code_edit_with_ref } +expect: { provenance_usage_session: s1, usage_span_recorded: true, aggregated_at_record: false } ::: ::: test -name: a reconcile event round-trips back to an equal value -given: { event: "reconcile, driftSummary one file, amendmentBundle H4" } -expect: { round_trip_equal: true } +name: an edit with no session window records provenance unavailable +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, env: {} } +expect: { provenance_model: unavailable, provenance_usage: unavailable } ::: +## The rest of the porcelain + +`napl status` observational classification, zero mutation, and it +judges both ends: per module it reports `drift: true` when the +committed output differs from the working tree, and the module's stale +reference count, the same derivations diff and the lsp already serve, +so status can never read clean while the query surface reads stale. +A module fresh on the prompt side and drifted on the code side says +so in one payload. `napl status --module ` answers with the +module's live state beside its plan, tracked or pending, drift, stale +reference count, attribution status, in the same payload as the owned +globs and wave position, so the command named status never answers +with a plan alone; and the payload carries the derived entry-file +projection, path and exact content, so the line a new file needs in a +shared entry file is read from the tool, never composed by hand. +Every projection path the plan mentions is labeled what it is, +tool-derived and never user-supplied, and the plan names one concrete +legal first output path (the module name under the target's source +dir), so the obvious wrong first move, hand-writing the entry file, +is warned against before it is made. The add refusal for a module +whose owned tree holds nothing stageable says the same thing: which +paths present are projections, and what the first legal file would +be. +`napl log` entries carry each transition's note count and reference +count, and the log payload names `napl show ` as where +the full note and reference text is served, so provenance is one +named hop away, never a secret; `napl show ` lists its +attached notes inline, hash and text, so a note attached is a note +findable without plumbing. + ::: test -name: a move event round-trips back to an equal value -given: { event: "move, context relocated greeting.rs" } -expect: { round_trip_equal: true } +name: log counts references and names show as the inspection verb +given: { workspace: fixture_two_modules, history: [commit, referenced_edit] } +expect: { log_reference_count: 1, show_named_in_payload: true } ::: +`napl log` +per-module or store-wide history. `napl show ` inspect one object. +`napl diff` drift between committed outputs and working tree as a canonical +byte-stable patch. `napl blame ` derived ancestry: surviving lines +trace through accepted patches by canonical replay; rewritten regions blame +the rewriting transition, honestly coarse. Blame reads committed +bytes only, and says so when it matters: a blamed path whose working +tree drifts from the committed output carries a drift notice naming +`napl diff` as where the uncommitted change lives, so ancestry over +the committed bytes is never mistaken for the present file. + ::: test -name: an audit event round-trips back to an equal value -given: { event: "audit, noop true" } -expect: { round_trip_equal: true } +name: blame over a drifted path names the drift and points at diff +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, blame_path: drifted_file } +expect: { drift_noticed: true, diff_named: true, ancestry_served: true } ::: +`napl lint` unreachable modules +under an explicit `roots` declaration; when roots are absent the refusal +is loud, exit 2 with canonical JSON naming the missing prerequisite and +the exact declaration that would satisfy it, never an empty findings +list under exit 0; unconsumed surface entries (diagnosis only), module staleness; +findings are proposals with cascade previews; `napl lint --fix` applies +only complete deterministic mapping-safe edits through the guarded commit +path and never removes lines. `napl lint ` is the +source-language scan, a different question needing no roots: it parses +every committed module's owned working-tree file under that target +with the pinned grammar and reports one finding per file that fails, +naming the file and the first error's line. Every lint payload names +the scope it judged, graph or source syntax, so an empty findings +list is never read wider than the scan that produced it. Source +syntax findings are facts, not proposals: a target scan carrying any +finding exits 1, so automation can never read broken source as +success, and a clean scan exits 0; the graph scope keeps its +advisory exit. + ::: test -name: a revert event round-trips back to an equal value -given: { event: "revert, reason superseded by newer gen" } -expect: { round_trip_equal: true } +name: lint of a target exits nonzero on source findings and zero when clean +given: { workspace: fixture_two_modules, runs: [clean_tree, syntactically_invalid_owned_file], lint: target_form } +expect: { clean_exit_code: 0, broken_exit_code: 1, findings_when_broken: 1 } ::: ::: test -name: an ok derivation event round-trips back to an equal value -given: { event: "derivation mapl, outcome ok, attempts 1, error empty, diagnostic absent" } -expect: { round_trip_equal: true } +name: lint of a target reports a parse finding per malformed working-tree file +given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, lint: target_form } +expect: { findings: 1, error_names_file_and_line: true, scope_named: source_syntax, roots_required: false } ::: -### The diagnostics-field ruling on GenFields and DerivationFields - -The plural `diagnostics: Vec` field added to both `GenFields` and -`DerivationFields` is an additive, backward-compatible superset field, and it obeys -exactly this contract: - -- It carries `#[serde(default)]` so an event whose payload omits the `diagnostics` - key deserializes to an empty vector, and `#[serde(skip_serializing_if = - "Vec::is_empty")]` so an empty vector serializes to no key at all. An event that - carries no diagnostics is therefore byte-identical, before and after this - amendment, to the event it was: a `gen` payload of an ok gen serializes with - exactly its four pre-existing keys `attempts`, `mode`, `outcome`, `taskBundle` - and no `diagnostics` key, so every gen-event object name pinned before this - amendment is unchanged. When the vector is non-empty it serializes as the JSON - key `diagnostics` whose value is the array of its entries in order, sorted among - the sibling `fields` keys by the canonical encoder like every other key. -- Its entries are free-form strings and are deliberately NOT subjected to the - sixty-four-lowercase-hex object-name check that the singular `diagnostic` field - and the other hash-typed fields undergo. A `diagnostics` entry is normally a - diagnostic blob's hash carried verbatim from an attempt marker, but the - transaction protocol also uses this list to preserve a verbatim operation token - for an attempt whose operation is neither the agent run nor a derivation, and - such a token is not a hash. Validation accepts any string in the list, so no data - is lost and the conversion of an interrupted attempt never fails on a - non-hash-shaped diagnostics entry. This exemption is confined to the plural - `diagnostics` list on these two payloads and to the event-level `attemptId`; it - never weakens the hash check on the singular `diagnostic`, on `taskBundle`, or on - any other hash-typed field, each of which is still validated exactly as before. - -The pre-existing singular `diagnostic: Option` field of `DerivationFields` -is unchanged by this amendment: its wire name, its skip-when-`None` serialization, -and its hash check all stay exactly as they were. Unifying the singular and plural -diagnostic fields is intentionally deferred; both coexist, independent, until a -later cleanup pass reconciles them. +::: test +name: lint of a target over a clean tree answers empty and names its scope +given: { workspace: fixture_two_modules, lint: target_form } +expect: { findings: 0, scope_named: source_syntax, exit_code: 0 } +::: -Only the `Gen` and `Derivation` fields structs carry a `provenance` field, because -only those two step kinds invoke a model. The other six kinds carry no provenance -and gain no such field. Adding `provenance` as a known optional field to -`GenFields` and `DerivationFields` never weakens their -`#[serde(deny_unknown_fields)]`: a field named anything other than the listed -fields, `provenance` included, is still rejected. +`napl cost` pure calculation over an explicit +receipt set and a pinned pricing-table provenance object; unknown is +unknown, never zero. Edits price the way generations price: records the +capture verbs staged enter the same receipt set, their session-window +usage references resolve at read time against the named log's bytes, +summing the window's lines under the same pinned pricing table, and +`napl cost --session ` reports one session's calls directly from +the log. A record without a window prices `unavailable`; a window whose +log file or session lines are absent prices `unavailable` and says +which; a model the pricing table does not name prices unknown; nothing +is ever silently zero and nothing is aggregated at record time. The +same receipt set, log bytes, and pricing table produce byte-identical +reports. `napl gc` stages sweeps outside locks, swaps pointers +inside the bounded section, never blocks writers, never deletes anything +reachable from a named head. `napl gc --apply` also repacks: the +reachable closure lands in one pack beside its index under +`objects/pack/` through the store's repack operation, loose files and +older packs are removed after the pack is durable, and the payload +names the pack written, the objects packed, and the files removed. +The dry `napl gc` reports what a repack would write. Every reachable +read answers identically before and after a repack, unreachable +objects are swept with the sweep it subsumes, fsck is healthy after +exactly when it was healthy before, and publication keeps writing +loose objects, so a store is always the sum of its packs plus its +loose files. A repack runs under its own objects lock, which buys +exactly one thing: two repacks, or a repack and a sweeping gc, never +rewrite the layout at once. Publication takes no lock by design, so +a publisher racing a repack can still lose a just-written loose +object before any snapshot names it; that window is open, named +here, and closes only when the store learns a waiting lock. The +objects lock holds no invariant a crashed holder could corrupt, the +pack being staged and linked into place, so an abandoned one is +stolen, never a wedge. Nobody is +required to know any of this: every successful commit runs the same +repack automatically, after the critical section, and reports what +it packed under a `repack` key in the commit payload. On 2026-08-03 +the owner ruled the loose-threshold out and every-commit in, because +the store a reader sees is then always three paths, one pack, its +index, and HEAD, and a pull request's store footprint is always the +same three quiet lines; loose objects exist only inside the window +between a publication and the commit that lands it. The pack a +commit writes is byte-identical to the one gc would have written +over the same closure. gc remains the explicit verb for sweeping +without committing. Reachable means the transitive closure: +from every named head, through every event in its chain, through every +object any reachable object references by hash, output trees, +surfaces, attribution records, prompt blobs, receipts, at every +contract version history carries. `fsck`'s definition of a present +object IS gc's definition of a kept one; a store that was healthy +before a sweep is healthy after it, and blame replays the same +ancestry after a sweep as before. An edge gc does not understand is a +reason to keep, never to sweep. ::: test -name: a gen event with no diagnostics omits the diagnostics key from its canonical bytes -given: { fixture: EV_GEN } -expect: { has_diagnostics_key: false } +name: gc apply on a workspace with history leaves fsck healthy and blame replaying +given: { workspace: fixture_with_multi_commit_history, gc: apply } +expect: { fsck_findings: 0, blame_replays_history: true, head_intact: true } ::: ::: test -name: an event with no attempt id omits the attemptId key from its canonical bytes -given: { fixture: EV_GEN } -expect: { has_attempt_id_key: false } +name: gc apply repacks the reachable closure into one pack and the store still answers +given: { workspace: fixture_with_multi_commit_history, gc: apply } +expect: { packs: 1, loose_reachable_remaining: 0, all_commands_answer_identically: true, fsck_findings: 0 } ::: ::: test -name: a derivation event with an empty diagnostics list omits the diagnostics key from its canonical bytes -given: { event: "derivation mapl, outcome ok, attempts 1, error empty, diagnostic absent, diagnostics empty" } -expect: { has_diagnostics_key: false } +name: "every successful commit repacks and leaves exactly one pack, its index, and no reachable loose" +given: { workspace: fixture_with_multi_commit_history, then: valid_commit } +expect: { commit_outcome: installed, repack_reported: true, packs: 1, loose_reachable_remaining: 0 } ::: -### Model provenance on a gen or derivation step - -Expose a public struct `Provenance` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`, `serde::Serialize`, and `serde::Deserialize`, with -`#[serde(deny_unknown_fields)]`), the record of what a single model invocation ran -and what we believed it cost at write time. It is optional on a gen or derivation -step: its absence means the invocation predates this feature or the engine -reported no usage, and a reader never fabricates any of its values. Public fields: +::: test +name: "a docs-only commit repacks through the same seam and leaves one pack, its index, and HEAD" +given: { workspace: fixture_with_multi_commit_history, then: docs_only_commit } +expect: { commit_kind: docs-only, repack_reported: true, packs: 1, loose_reachable_remaining: 0, store_paths: [pack, idx, HEAD] } +::: -- `model: String`, serde field name `model`. The exact model id string as reported - by the engine invocation, for example `claude-opus-4-8`. It is recorded verbatim - and is never inferred, normalized, or synthesized from the coarse engine name. -- `tokens: TokenUsage`, serde field name `tokens`. The token counts the engine - reported, described next. -- `estimated_cost_usd: Option`, serde field name `estimatedCostUsd`, - skipped from serialization when `None`. A decimal cost string in US dollars, - computed at write time from the pinned price table below over `tokens`. It is a - decimal **string**, never a floating-point number, so an event's canonical bytes - stay stable. It is `None` exactly when `model` is not in the price table. This is - a record of what we believed the invocation cost when the event was written, not - a billing authority; historical events are never re-priced. -- `wall_ms: u64`, serde field name `wallMs`. The wall-clock milliseconds the model - invocation took, as measured by the engine wrapper around the model call. It is a - `u64`. -- `price_table_version: u32`, serde field name `priceTableVersion`. The version of - the price table that produced (or, for an unknown model, failed to produce) - `estimatedCostUsd`, so a reader can tell which table was consulted. It is always - present when a `Provenance` is present, whether or not a cost was produced. +::: test +name: two commits in a row each land the whole closure in one pack and the store answers identically +given: { workspace: fixture_with_multi_commit_history, then: [valid_commit, valid_commit] } +expect: { packs: 1, all_commands_answer_identically: true, fsck_findings: 0 } +::: -Expose a public struct `TokenUsage` (same derives, same -`#[serde(deny_unknown_fields)]`), the reported token counts of one invocation. -Public fields: +::: test +name: the automatic repack writes the byte-identical pack gc would have written +given: { workspace: fixture_with_multi_commit_history, compare: [auto_repack_at_commit, explicit_gc_apply] } +expect: { pack_bytes_equal: true, pack_names_equal: true } +::: -- `input: u64`, serde field name `input`. Input tokens reported by the engine. -- `output: u64`, serde field name `output`. Output tokens reported by the engine. -- `cache_read: Option`, serde field name `cacheRead`, skipped from - serialization when `None`. Cache-read tokens, present only when the engine - reports them, absent otherwise; never a fabricated zero standing in for an - unreported count. -- `cache_write: Option`, serde field name `cacheWrite`, skipped from - serialization when `None`. Cache-write tokens, present only when the engine - reports them, absent otherwise; never a fabricated zero. +On 2026-08-03 the owner reversed the 2026-07-31 git-store ruling: the +store's one home is the working tree, in every workspace, git or not. +The end user's story decided it: adopting napl must mean committing +files, never adopting hook managers, CI fetch wiring, or branch +policies. The `napl store migrate` verb is retired, the git-backed +provider is deleted with its module, and the tracked layout with its +packfile mitigation is promoted from floor to the only shape. What +the side-ref bought is repaid in kind: pull-request silence by the +`.gitattributes` lines init writes, store objects and packs marked +generated, undiffed, and never text, because an eol-converting +checkout would corrupt every envelope against its own name; append-only +not by construction but by fsck, which counts every reachable object +and reports absence loudly; and merging by git's ordinary file +semantics, safe for objects because they are content-addressed, +identical on both sides or absent, never in conflict. The one pointer +that can conflict is `HEAD`, so init marks it binary as well: a merge +never writes conflict markers into it, git holds it unmerged with one +side's hash in place, and resolving is choosing which snapshot the +merged branch is on, safe in either direction because a merge's store +holds both sides' objects. -A `Provenance` carries no hash-typed field, so `validate_event` gains no new -hash check for it, and the schema adds no new `StoreObjectError` variant. The -provenance record is written only by the engine capture seam, which is the layer -that guarantees `model` is a real reported id; on the read side a present -`Provenance` deserializes and round-trips exactly as written, and its only -strictness is the `#[serde(deny_unknown_fields)]` on `Provenance` and on -`TokenUsage`, so a stray key inside either object is rejected as -`StoreObjectError::Malformed` through the normal deserialization path. +A workspace inside a git repository that carries a +`refs/heads/napl-store` ref while its `.napl/store/objects/` holds no +objects is a migrated legacy clone, and every store-writing verb +refuses it with the road back, verbatim: create the objects directory, +check out the store branch's tree into it, commit it on the code +branch, delete the ref locally and on every remote after lifting any +delete protection the old teaching installed. The refusal names the +exact commands and touches nothing; the reader performs them with +ordinary git. The same ref beside a store that already holds objects +is only stale wiring, and the refusal says exactly that: delete the +ref, nothing else. ::: test -name: a provenance carrying an unknown field is rejected -given: { json: "the EV_GEN_PROV object whose provenance object carries an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: "store-writing verbs refuse a legacy clone, ref present and objects absent, and name the recipe" +given: { workspace: fixture_two_modules, git_repo: with_legacy_napl_store_ref, store_objects: absent, then: valid_commit_attempt } +expect: { outcome: refused, nothing_written: true, recipe_names_mkdir_checkout_commit_ref_delete: true } ::: ::: test -name: a token usage carrying an unknown field is rejected -given: { json: "the EV_GEN_PROV object whose provenance tokens object carries an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: "a stale ref beside a store that holds objects refuses with delete-the-ref alone, never the migration" +given: { workspace: fixture_two_modules, git_repo: with_legacy_napl_store_ref, store_objects: present, then: valid_commit_attempt } +expect: { outcome: refused, teaches_ref_delete_only: true, migration_recipe_absent: true } ::: -### The pinned price table and cost estimation - -This module owns the price table and the pure cost estimator; the capture seam -consults them through this module rather than carrying its own copy, so the prices -and the estimation arithmetic live in exactly one place. - -Expose a public constant `PRICE_TABLE_VERSION: u32` equal to `1`. Bump it on any -change to the table's contents or shape; a reader compares it against a -provenance record's `priceTableVersion` to know which table produced a cost. - -Hold the table as a private constant mapping a model id to its two prices, each a -rate in US dollars per one million tokens. Store each rate not as a float but as -an integer count of **micro-dollars per million tokens** (the dollar rate times -one million), so all cost arithmetic is exact integer arithmetic with no floating -point anywhere. The entries of version `1`, model id then input rate then output -rate in US dollars per million tokens: +::: test +name: a workspace whose legacy ref was materialized and deleted answers status and fsck clean +given: { workspace: fixture_two_modules, history: [legacy_ref_materialized_in_tree, ref_deleted] } +expect: { status_answers: true, fsck_findings: 0, objects_readable: true } +::: -- `claude-opus-4-8`: input `15`, output `75`. Stored as input micros `15000000`, - output micros `75000000`. -- `claude-sonnet-4-5`: input `3`, output `15`. Stored as input micros `3000000`, - output micros `15000000`. -- `claude-haiku-4-5`: input `1`, output `5`. Stored as input micros `1000000`, - output micros `5000000`. - -The table carries no separate cache-read or cache-write rates in version `1`, so -by the folding rule below, cache-read and cache-write tokens are priced at the -model's input rate. These prices are provenance estimates, not billing authority; -drift from a vendor's real pricing is acceptable and is disclosed by -`PRICE_TABLE_VERSION`. - -Expose the pure estimator `estimate_cost(model: &str, tokens: &TokenUsage) -> -Option`: - -- Look the `model` up in the table. If it is absent, return `None`: an unknown - model gets no cost, though its tokens are still recorded on the provenance. -- Otherwise compute, using integer arithmetic wide enough not to overflow (compute - in `u128`), the total in dollar-scaled integer units: - - billable_input = input + cache_read.unwrap_or(0) + cache_write.unwrap_or(0) - n = billable_input * input_micros + output * output_micros - - where `input_micros` and `output_micros` are this model's stored rates. This is - the folding rule: absent a dedicated cache rate, cache-read and cache-write - tokens join the plain input tokens and are priced at the input rate. - -- The exact cost in dollars is `n` divided by `1_000_000_000_000` (one million, - for the per-million-token rate, times one million, for the micro-dollar rate - scaling), which is always an exact terminating decimal because the divisor is a - power of ten. Render it to a canonical decimal string with no floating point: - - - Let `whole = n / 1_000_000_000_000` and `frac = n % 1_000_000_000_000` by - integer division and remainder. - - If `frac` is zero, the string is `whole` rendered as a decimal integer, with - no decimal point and no fractional digits: for example `0`, or `18`. - - Otherwise render `frac` as exactly twelve digits, zero-padded on the left, then - strip its trailing zeros, and the string is `whole`, a `.`, then those - remaining fractional digits: for example `0.0525`, or `0.084`. - - Return `Some` of that string. - -Expose a public constructor `provenance_from_usage(model: String, tokens: -TokenUsage, wall_ms: u64) -> Provenance` that the capture seam calls to build a -provenance record in one place: it sets `estimated_cost_usd` to -`estimate_cost(&model, &tokens)`, sets `price_table_version` to -`PRICE_TABLE_VERSION`, and carries the `model`, `tokens`, and `wall_ms` through -unchanged. This is the only place `PRICE_TABLE_VERSION` is stamped onto a record, -so the table version and the prices that produced a cost can never disagree. - -::: test -name: estimating the cost of a known model yields its pinned decimal string -given: { cost_model: "claude-opus-4-8", cost_tokens: "input 1000, output 500" } -expect: { estimated_cost: "0.0525" } -::: - -::: test -name: estimating the cost of a zero-token usage of a known model yields the string 0 -given: { cost_model: "claude-opus-4-8", cost_tokens: "input 0, output 0" } -expect: { estimated_cost: "0" } -::: - -::: test -name: estimating a whole-dollar cost of a known model renders a bare integer with no fractional part -given: { cost_model: "claude-sonnet-4-5", cost_tokens: "input 1000000, output 1000000" } -expect: { estimated_cost: "18" } -::: - -::: test -name: estimating the cost folds cache-read and cache-write tokens into the input rate -given: { cost_model: "claude-opus-4-8", cost_tokens: "input 1000, output 500, cacheRead 2000, cacheWrite 100" } -expect: { estimated_cost: "0.084" } -::: +`napl fsck` envelope hashes, kind validity, +causal parent links. `napl restore` deterministic repair plans from +persisted evidence; unambiguous moves install, ambiguity blocks; liveness +and wall time never decide recovery. `napl mv ` relocation +as a semantic operation: moves a module's installed files to a new +placement, recomputes every placement-derived fact (derived entry-file +projections, attribution file paths) from the destination, passes +through the guarded commit path, and re-verifies there; failed +verification at the destination installs nothing; manifests are the +user's to update, and status diagnoses a placement the workspace +manifest no longer lists. A move made outside `napl mv` is drift like any other: +the store still holds the committed bytes, `napl restore` re-materializes +the module at its recorded placement, and when the drifted content matches +a committed state exactly the plan may instead adopt the new placement +through the commit path; anything ambiguous blocks. `napl remove +` ends a module's tenancy as a semantic operation through the +same guarded door: it publishes a new snapshot without the module's +state, deletes the installed files the module owned, re-derives every +projection that mentioned it (a crate's derived entry file stops +naming the module the moment no committed state owns its files), and +closes the module's attribution record. It refuses when another +committed module depends on the one leaving, naming the dependents; +it refuses an unknown module; and it refuses when the module's +working tree diverges from its committed state, naming the drift, +because what is removed is the committed module, never unseen edits. +The prompt file is the user's, like the manifest: remove reports it +as ready to delete and touches neither. Removal removes tenancy, +never history: the store is append-only, every prior snapshot still +holds the module's states and objects, and the read verbs answer +about them exactly as before. ::: test -name: estimating the cost of a model absent from the price table yields no cost -given: { cost_model: "gpt-4o", cost_tokens: "input 1000, output 500" } -expect: { estimated_cost: none } +name: "removing a leaf module drops its state, deletes its files, and re-derives the entry projection" +given: { workspace: fixture_two_modules, then: remove_leaf_module } +expect: { outcome: installed, module_files_deleted: true, snapshot_lacks_module_state: true, entry_projection_re_derived: true, fsck_findings: 0 } ::: -### The per-attempt usage log - -Expose a public struct `AttemptUsage` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`, `serde::Serialize`, and `serde::Deserialize`, with -`#[serde(deny_unknown_fields)]`), one coding-agent attempt's reported usage. It is -the immutable per-attempt record the gen event carries in its `usage` list, one -entry per attempt of that gen, burned attempts included, so a reader can see what -every attempt of a gen cost even when only the last attempt succeeded. It carries no -nested array, so a gen's `usage` list stays bounded by the gen's attempt count. -Public fields: - -- `attempt: u32`, serde field name `attempt`. The 1-based attempt index within this - gen. -- `outcome: String`, serde field name `outcome`. This attempt's own loop verdict, a - free-form short token such as `ok`, `tests-failed`, `deps-gate`, or `agent-error`, - recorded verbatim from the attempt loop; it is not the gen's `GenOutcome` and is - not constrained to that enum's tokens. -- `requested_model: String`, serde field name `requestedModel`. The model this - attempt asked the engine to run, after precedence resolution. -- `resolved_model: String`, serde field name `resolvedModel`. The exact model id the - engine reported running, or the empty string when the engine reported none. -- `source: String`, serde field name `source`. Which layer supplied the requested - model, one of `cli-flag`, `frontmatter`, or `lock`. -- `engine: String`, serde field name `engine`. The coarse engine identifier this - attempt ran on. -- `tokens: TokenUsage`, serde field name `tokens`. The four token counts this - attempt reported, using the same `TokenUsage` struct the provenance record uses, - so the token shape lives in exactly one place. -- `cost_usd: Option`, serde field name `costUsd`, skipped from serialization - when `None`. The cost the engine reported for this attempt as a decimal string in - US dollars, never a floating-point number so the event's canonical bytes stay - stable; `None` when the engine reported no cost. -- `duration_ms: u64`, serde field name `durationMs`. The wall-clock milliseconds the - engine reported for this attempt. -- `num_turns: u32`, serde field name `numTurns`. The model turns the engine reported - for this attempt. - -The `usage: Vec` field on `GenFields` obeys exactly the additive -contract the plural `diagnostics` field obeys: it carries `#[serde(default)]` so a -gen payload that omits the `usage` key deserializes to an empty vector, and -`#[serde(skip_serializing_if = "Vec::is_empty")]` so an empty vector serializes to -no key at all. A gen event that carries no usage is therefore byte-identical, before -and after this amendment, to the event it was, so every gen-event object name pinned -before this amendment is unchanged. When the vector is non-empty it serializes as the -JSON key `usage` whose value is the array of its entries in order, each a canonical -object, sorted among the sibling `fields` keys by the canonical encoder like every -other key. An `AttemptUsage` carries no hash-typed field, so `validate_event` gains -no new hash check for it and the schema adds no new `StoreObjectError` variant; its -only strictness is the `#[serde(deny_unknown_fields)]` on `AttemptUsage` (and on the -`TokenUsage` it nests), so a stray key inside either object is rejected as -`StoreObjectError::Malformed` through the normal deserialization path. The `usage` -list is confined to `GenFields`; the other seven payload kinds gain no such field, -and adding `usage` as a known optional field to `GenFields` never weakens its -`#[serde(deny_unknown_fields)]`. - ::: test -name: a usage entry carrying an unknown field is rejected -given: { json: "the EV_GEN_USAGE object whose first usage entry carries an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: remove refuses a module another committed module depends on and names the dependents +given: { workspace: fixture_two_modules_dependent, then: remove_the_dependency } +expect: { outcome: refused, dependents_named: true, nothing_written: true } ::: ::: test -name: a usage-entry token object carrying an unknown field is rejected -given: { json: "the EV_GEN_USAGE object whose first usage entry's tokens object carries an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: remove refuses drift and a removed module's history stays readable +given: { workspace: fixture_two_modules, runs: [remove_with_drifted_file, remove_clean_then_read_history] } +expect: { drifted_outcome: refused, drift_named: true, history_tree_at_readable: true, prior_snapshots_hold_module: true } ::: -### The mode, outcome, and classification enums - -Each of these derives `Debug`, `Clone`, `Copy`, `PartialEq`, `Eq`, -`serde::Serialize`, and `serde::Deserialize`, and serializes as a single JSON -string. Pin each variant to its exact wire token; an input string outside a set is -a deserialization failure: - -- `GenMode`: `Full` renders `full`, `Incremental` renders `incremental`, `Force` - renders `force`. -- `GenOutcome`: `Ok` renders `ok`, `Failed` renders `failed`, `Interrupted` - renders `interrupted`. -- `DerivationOutcome`: `Ok` renders `ok`, `Retried` renders `retried`, `Failed` - renders `failed`, `Interrupted` renders `interrupted`. -- `LegacyClassification`: `Exact` renders `exact`, `MissingPrefix` renders - `missing-prefix`, `InconsistentBefore` renders `inconsistent-before`, - `InconsistentAfter` renders `inconsistent-after`, `Unreconstructable` renders - `unreconstructable`. - -::: test -name: gen mode full serializes to the string full -given: { gen_mode: full } -expect: { serialized_json: "\"full\"" } -::: +`napl cat-file ` +raw plumbing. `napl lsp` a language server over stdio for editor +extensions: hover, definition, references, and code lens over the +prompt-to-code mapping. Everything it serves derives from committed +records (attribution entries, module surfaces, the ir) resolved against +the workspace root; hover on a prompt line presents the attributed +generated spans with their notes, hover on a generated line presents the +owning prompt lines and note, definition jumps across the mapping in both +directions, references enumerates every attributed span, code lens counts +attributed files per prompt section. The server publishes diagnostics +derived from reference staleness: for every stale reference it emits one +diagnostic on each endpoint, the prompt span whose code moved and the +code span whose prompt changed, so sync debt is visible where the edit +happens, like a type error for documentation. Publication is a pure +derivation from committed records plus the working tree at the moment +the protocol asks, no background mutation, no clocks, no scanning loops +beyond what the editor protocol requires; opening or changing a +document refreshes that document's diagnostics and its counterparts'. +Severity is warning, never error: drift is a legal state, marked, not +policed. The diagnostic message states exactly what is known and +nothing more: which end drifted, the reference's direction, recorded +model or `unavailable`, referentially valid, and the healing verbs +(re-record through `napl edit`, or restore); no wording implies the +sentence describes the code. A fresh reference publishes nothing. +Resolving is always a human or LLM re-affirming or re-pointing the +reference through the capture verbs; the server proposes nothing and +repairs nothing. Pure reads only: no mutation, no +clocks, no background scanning. A request whose records are missing or +unparseable answers empty rather than erroring or blocking, and missing +or unparseable records degrade to no diagnostics, never an error; a stale +mapping is served marked stale, consistent with the attribution contract; +the server never hangs on a malformed or absent record. Record resolution is +anchored per document: walk up from the document's filesystem path to the +nearest ancestor directory containing `.napl` and read records there; the +server's working directory and the client's rootUri carry no meaning. +Every location the server emits that points at a module's prompt resolves +to the prompt file's actual placement found by workspace prompt discovery, +where the prompt sits inside the crate directory its code lands in; a +module name is never joined against the workspace root to fabricate a +prompt path, and a module whose prompt cannot be discovered answers empty. +Crash safety: every persisted application step leaves markers such that +recovery derives a deterministic plan from content alone. Evidence +validators are a registry keyed by media type (Claude CLI JSON envelope +ships first, and `application/x-napl-usage-proxy-jsonl-v1` validates +usage-proxy logs: one canonical JSON object per line, required fields +present, absent values the string `unavailable`, token counts +non-negative integers when present, a line failing the shape is +`invalid`); outcomes `validated`, `unsupported`, `invalid`, +`validator-error`; invalid stays visible as failed. Evidence bounds are +compiled-in constants; exceeding any is `resource-limit` before anything +publishes. ::: test -name: gen mode incremental serializes to the string incremental -given: { gen_mode: incremental } -expect: { serialized_json: "\"incremental\"" } +name: a stale reference publishes one diagnostic on each endpoint +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, lsp: [open_prompt, open_code] } +expect: { prompt_diagnostics: 1, code_diagnostics: 1, severity: warning } ::: ::: test -name: gen mode force serializes to the string force -given: { gen_mode: force } -expect: { serialized_json: "\"force\"" } +name: a fresh reference publishes nothing +given: { workspace: fixture_two_modules, lsp: [open_prompt, open_code] } +expect: { prompt_diagnostics: 0, code_diagnostics: 0 } ::: ::: test -name: gen outcome ok serializes to the string ok -given: { gen_outcome: ok } -expect: { serialized_json: "\"ok\"" } +name: "the diagnostic message carries drifted end, direction, and provenance and nothing semantic" +given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched, lsp: [open_code] } +expect: { message_names_drifted_end: true, message_names_direction: true, message_model_or_unavailable: true, message_semantic_claims: 0 } ::: ::: test -name: gen outcome failed serializes to the string failed -given: { gen_outcome: failed } -expect: { serialized_json: "\"failed\"" } +name: absent or unparseable records publish no diagnostics and answer empty +given: { workspace: fixture_two_modules, records: attribution_file_corrupted, lsp: [open_prompt] } +expect: { diagnostics: 0, error_responses: 0, server_alive: true } ::: ::: test -name: gen outcome interrupted serializes to the string interrupted -given: { gen_outcome: interrupted } -expect: { serialized_json: "\"interrupted\"" } +name: committing the drifted code clears both endpoints' diagnostics on refresh +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, then: commit_module, lsp: [change_code_document] } +expect: { prompt_diagnostics: 0, code_diagnostics: 0 } ::: ::: test -name: derivation outcome ok serializes to the string ok -given: { derivation_outcome: ok } -expect: { serialized_json: "\"ok\"" } +name: diff renders drift between committed output and working tree as a canonical patch +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched } +expect: { diff_nonempty: true, canonical_patch: true, byte_stable: true } ::: ::: test -name: derivation outcome retried serializes to the string retried -given: { derivation_outcome: retried } -expect: { serialized_json: "\"retried\"" } +name: "blame over a whole-file regeneration coarsens to the transition, line ancestry survives accepted patches" +given: { workspace: fixture_two_modules, history: [patch_commit, full_rewrite_commit] } +expect: { patched_lines_trace_to_patch: true, rewritten_region_blames_transition: true } ::: ::: test -name: derivation outcome failed serializes to the string failed -given: { derivation_outcome: failed } -expect: { serialized_json: "\"failed\"" } +name: lint reports an unreachable module only under declared roots and refuses without them +given: { workspace: fixture_orphan_module, roots: absent } +expect: { unreachable_analysis: refused, exit_code: 2, reason_names_roots: true, findings_field_absent: true } ::: ::: test -name: derivation outcome interrupted serializes to the string interrupted -given: { derivation_outcome: interrupted } -expect: { serialized_json: "\"interrupted\"" } +name: lint with declared roots proposes removal of the orphan with a cascade preview and never auto-applies +given: { workspace: fixture_orphan_module, roots: [main_module] } +expect: { findings: [unreachable_napl], auto_applied: false, cascade_preview: true } ::: ::: test -name: legacy classification exact serializes to the string exact -given: { legacy_classification: exact } -expect: { serialized_json: "\"exact\"" } +name: lint --fix drops an unreferenced declared dependency through the guarded commit path +given: { workspace: fixture_unused_dep, fix: drop_unused_dep } +expect: { outcome: installed, transition_recorded: true, dep_removed: true } ::: ::: test -name: legacy classification missing-prefix serializes to the string missing-prefix -given: { legacy_classification: MissingPrefix } -expect: { serialized_json: "\"missing-prefix\"" } +name: a cost report over a receipt set containing verb-staged records includes the edits +given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines } +expect: { report_rows: 2, edit_row_priced: true, same_pricing_table: true } ::: ::: test -name: legacy classification inconsistent-before serializes to the string inconsistent-before -given: { legacy_classification: InconsistentBefore } -expect: { serialized_json: "\"inconsistent-before\"" } +name: an edit without a window and a window with missing log lines both price unavailable naming why +given: { workspace: fixture_two_modules, receipts: [edit_without_window, edit_with_window_to_missing_lines] } +expect: { priced_unavailable: 2, reason_named_per_row: true, zeros: 0 } ::: ::: test -name: legacy classification inconsistent-after serializes to the string inconsistent-after -given: { legacy_classification: InconsistentAfter } -expect: { serialized_json: "\"inconsistent-after\"" } +name: a model absent from the pricing table prices unknown never zero +given: { workspace: fixture_two_modules, receipts: [edit_with_unknown_model_window], log: proxy_log_unknown_model } +expect: { priced_unknown: 1, zeros: 0 } ::: ::: test -name: legacy classification unreconstructable serializes to the string unreconstructable -given: { legacy_classification: Unreconstructable } -expect: { serialized_json: "\"unreconstructable\"" } +name: cost reports are byte-identical across runs over the same receipts log and table +given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines, runs: 2 } +expect: { bytes_equal: true } ::: ::: test -name: an unknown gen mode string is rejected -given: { json: "a gen payload with mode set to \"bogus\"" } -expect: { error: Malformed } +name: cost session reports one session's calls directly from the log +given: { workspace: fixture_two_modules, log: proxy_log_two_sessions, session: s1 } +expect: { rows_for_session: 2, rows_for_other_sessions: 0, priced_from_log: true } ::: ::: test -name: an unknown gen outcome string is rejected -given: { json: "a gen payload with outcome set to \"bogus\"" } -expect: { error: Malformed } +name: a proxy log line failing the shape validates invalid and stays visible as failed +given: { evidence: proxy_log_with_malformed_line, media_type: application_x_napl_usage_proxy_jsonl_v1 } +expect: { outcome: invalid, visible_as_failed: true } ::: ::: test -name: an unknown derivation outcome string is rejected -given: { json: "a derivation payload with outcome set to \"bogus\"" } -expect: { error: Malformed } +name: cost over one receipt set and pricing version is byte-identical across runs and unknown for unknown models +given: { receipts: fixture_receipts, pricing_version: pinned, runs: 2, unknown_model_receipt: included } +expect: { reports_equal: true, unknown_model_cost: unknown } ::: ::: test -name: an unknown legacy classification string is rejected -given: { json: "a legacy payload with classification set to \"bogus\"" } -expect: { error: Malformed } +name: gc never deletes an object reachable from the current head and never blocks a concurrent commit +given: { workspace: fixture_two_modules, concurrent: [gc, valid_commit] } +expect: { reachable_deleted: false, commit_blocked: false, unreachable_swept: true } ::: -### The non-advancing outcome rule - -A failed or interrupted step never advances authority: its after-state equals its -before-state. Concretely, both are equal, or both are absent. This rule applies to -exactly two payloads: - -- a `Gen` payload whose outcome is `Failed` or `Interrupted`, and -- a `Derivation` payload whose outcome is `Failed` or `Interrupted`. - -For such an event, `state_before` must equal `state_after` as options: both -`None`, or both `Some` and equal. A `Gen` with outcome `Ok`, and a `Derivation` -with outcome `Ok` or `Retried`, may advance and are not constrained this way. - ::: test -name: a failed gen event whose stateBefore equals stateAfter is accepted -given: { event: "gen, outcome failed, stateBefore known(H2), stateAfter known(H2)" } -expect: { accepts: true } +name: fsck detects a corrupted object and a broken parent link +given: { workspace: fixture_two_modules, corrupt: [flip_byte_in_object, remove_parent_event] } +expect: { findings: [hash_mismatch, missing_parent], exit_nonzero: true } ::: ::: test -name: a failed gen event whose stateBefore differs from stateAfter is rejected -given: { event: "gen, outcome failed, stateBefore known(H2), stateAfter known(H3)" } -expect: { error: NonAdvancingOutcome } +name: restore applies an unambiguous move-repair plan and blocks on ambiguity +given: { workspace: fixture_moved_output, ambiguity: [none, two_candidates] } +expect: { unambiguous: installed, ambiguous: blocked } ::: ::: test -name: an interrupted gen event whose states differ is rejected -given: { event: "gen, outcome interrupted, stateBefore known(H2), stateAfter known(H3)" } -expect: { error: NonAdvancingOutcome } +name: hover on an attributed prompt line resolves its generated spans +given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: within_entry } } +expect: { spans_resolved: true, notes_included: true } ::: ::: test -name: an ok gen event whose stateBefore differs from stateAfter is accepted -given: { event: "gen, outcome ok, stateBefore known(H2), stateAfter known(H3)" } -expect: { accepts: true } +name: "hover on an unattributed line answers empty, not error" +given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: outside_entries } } +expect: { result: empty, error: false } ::: ::: test -name: a failed derivation event whose states differ is rejected -given: { event: "derivation, outcome failed, stateBefore known(H2), stateAfter known(H3)" } -expect: { error: NonAdvancingOutcome } +name: missing attribution records degrade to empty responses +given: { attribution: absent, request: hover } +expect: { result: empty, error: false, blocked: false } ::: ::: test -name: an interrupted derivation event whose states differ is rejected -given: { event: "derivation, outcome interrupted, stateBefore known(H2), stateAfter known(H3)" } -expect: { error: NonAdvancingOutcome } +name: definition from a generated line targets the prompt at its discovered placement +given: { attribution: fixture_attribution_small, prompt_placement: inside_crate_dir, request: definition, position: { file: generated_within_entry } } +expect: { location_file: discovered_prompt_path, root_joined_module_name: false } ::: ::: test -name: a retried derivation event whose stateBefore differs from stateAfter is accepted -given: { event: "derivation, outcome retried, stateBefore known(H2), stateAfter known(H3)" } -expect: { accepts: true } +name: evidence failing its validator is invalid and distinguishable from unsupported +given: { receipt_evidence: [valid_claude_json, malformed_claude_json, unknown_media_type] } +expect: { outcomes: [validated, invalid, unsupported] } ::: ::: test -name: a failed gen event with both states absent is accepted -given: { event: "gen, outcome failed, stateBefore absent, stateAfter absent" } -expect: { accepts: true } +name: a receipt exceeding evidence bounds is rejected as resource-limit with nothing published +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: oversized_evidence } +expect: { outcome: resource_limit, published_semantic_state: false, published_provenance: false } ::: -### Validating an event - -Expose `validate_event(value: serde_json::Value) -> Result`: deserialize the JSON value into an `Event`, returning -`StoreObjectError::Malformed` on any deserialization failure (an unknown top-level -field, an unknown field inside the payload's `fields`, an unknown field inside a -`provenance`, `tokens`, or `usage`-entry object, an unknown `kind` token, an unknown mode, -outcome, or classification token, a value of the wrong JSON type), then apply -these semantic checks, returning the first failure: +## Determinism contract -- `module` must be non-empty, else `StoreObjectError::EmptyModule`. -- `ordinal` must be at least 1, else `StoreObjectError::InvalidOrdinal`. -- Every hash-typed reference must be a well-formed object name (sixty-four - lowercase hex): each entry of `parents`; each `Some(StateRef::State(_))` among - `state_before` and `state_after` (the `Unknown` sentinel and `None` are exempt); - and the payload's hash fields (`taskBundle`; `bundle`; `amendmentBundle`; a - present `diagnostic`; `originalLine`). Any malformed hash fails as - `StoreObjectError::MalformedHash`. The event-level `attemptId`, when present, and - every entry of the plural `diagnostics` list on a `Gen` or `Derivation` payload - are free-form strings and are NOT subjected to this hash check, per the - diagnostics-field ruling above; validation never rejects a non-hash `attemptId` - or a non-hash `diagnostics` entry. A `provenance` record carries no hash-typed - field and contributes no hash check, and neither does any `AttemptUsage` entry of a - `Gen` payload's `usage` list, so validation never rejects a usage entry on hash - grounds. -- The non-advancing outcome rule above must hold; a violating event fails as - `StoreObjectError::NonAdvancingOutcome`. +Every collection canonically ordered; every input explicit and content +identified; serialization byte-pinned; ambient environment excluded +(locale, timezone, machine identity, CPU count, enumeration order); reads +never write; a state transition names its base and fails on mismatch per +the decision table rather than recalculating. Same workspace compiled +twice, from different absolute paths, under different discovery orders, +locales, and timezones produces byte-identical artifacts. The test suite +proves the vectors in the frontmatter with deterministic fixtures +constructed in each crate's `tests/`. ::: test -name: an event with an unknown top-level field is rejected -given: { json: "the EV_GEN object with an extra top-level key \"bogus\": 1" } -expect: { error: Malformed } +name: compiling the same workspace twice yields byte-identical artifacts +given: { workspace: fixture_two_modules, runs: 2 } +expect: { artifacts_equal: true } ::: ::: test -name: an event whose ordinal is the wrong JSON type is rejected -given: { json: "the EV_GEN object with ordinal set to the string \"5\"" } -expect: { error: Malformed } +name: artifact bytes are independent of discovery order +given: { workspace: fixture_two_modules, discovery_orders: [sorted, reversed, shuffled_seed_7] } +expect: { artifacts_equal: true } ::: ::: test -name: an event with an empty module is rejected -given: { json: "the EV_GEN object with module set to the empty string" } -expect: { error: EmptyModule } +name: artifact bytes are independent of the absolute workspace path +given: { workspace: fixture_two_modules, mounted_at: [rootA, rootB] } +expect: { artifacts_equal: true } ::: ::: test -name: an event with ordinal zero is rejected -given: { json: "the EV_GEN object with ordinal set to 0" } -expect: { error: InvalidOrdinal } +name: canonical json sorts keys and is byte-stable +given: { value: "{\"b\":1,\"a\":2}", encodings: 2 } +expect: { bytes_equal: true, sorted_keys: true } ::: +napl-store blob v1 21298 +//! The commit transaction, up to but not including the syscalls. +//! +//! Everything the pipeline decides lives here: what the candidate is allowed to +//! contain, whether the receipt reproduces, what surface the candidate records, +//! what transition that derives, and which install steps materialize it. The +//! binary supplies the bytes and performs the writes; it makes no decision of +//! its own. +//! +//! The ordering is the contract's ordering, and it is what makes the critical +//! section bounded: ingest, verify, extract, derive, and plan all happen before +//! any lock is taken, so the locked region only rechecks the footprint and swaps +//! pointers, bounded by the transition's file count and hashing nothing. -::: test -name: an event whose ordinal is the largest u64 is accepted -given: { json: "the EV_GEN object with ordinal set to 18446744073709551615" } -expect: { accepts: true } -::: +use std::collections::{BTreeMap, BTreeSet}; -::: test -name: an event whose ordinal exceeds the u64 range is rejected -given: { json: "the EV_GEN object with ordinal set to 18446744073709551616" } -expect: { error: Malformed } -::: +use napl_store::store_hash::ObjectKind; +use napl_store::store_tree::{diff_trees, Tree, TreeChange, FILE_MODE}; +use napl_store::store_txn_core::{plan_install_steps, InstallStep}; +use serde_json::{json, Value}; -::: test -name: a gen event whose attempts is the largest u32 is accepted -given: { json: "the EV_GEN object with its payload fields attempts set to 4294967295" } -expect: { accepts: true } -::: +use crate::candidate::Candidate; +use crate::canon::{hash_json, is_object_name}; +use crate::compile::{base_output_files, head_states, read_surface_items, slice_at, state_of}; +use crate::error::{error, OutcomeCode, Result}; +use crate::evidence::{evidence_report, validate_all}; +use crate::objects::{ + event_hash, event_json, module_state_hash, module_state_json, ownership_write_key, + read_module_state, snapshot_hash, snapshot_json, state_write_key, transition_hash, + transition_json, Footprint, ModuleState, Transition, +}; +use crate::paths::is_under; +use crate::receipt::{receipt_json, receipt_semantic_hash, Receipt, RECEIPT_VERSION}; +use crate::slice::CompileSlice; +use crate::source::SourceModule; +use crate::store_view::{load_tree_objects, ObjectSource}; +use crate::surface::{extract_surface, surface_hash, surface_json, SurfaceItem}; +use crate::target::{ + derive_lib_projection, derive_projection, is_owned_path, projection_owned_paths, + target_def_hash, TargetDef, +}; -::: test -name: a gen event whose attempts exceeds the u32 range is rejected -given: { json: "the EV_GEN object with its payload fields attempts set to 4294967296" } -expect: { error: Malformed } -::: +/// One object the transaction wants published, kind and payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingObject { + pub name: String, + pub kind: ObjectKind, + pub payload: Vec, +} -::: test -name: an event with a malformed hash in parents is rejected -given: { json: "the EV_GEN object with parents set to [\"xyz\"]" } -expect: { error: MalformedHash } -::: +/// Everything a commit derived, ready to be applied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DerivedCommit { + pub transition: Transition, + pub transition_id: String, + pub new_state_key: String, + pub surface_hash: String, + pub surface_items: Vec, + /// The recorded receipt object, provenance included. Distinct from the + /// transition's `receipt`, which names the semantic body alone. + pub receipt_object: String, + /// The verdict on every evidence item the receipt carried. Provenance: it is + /// reported, and it changes nothing about what installs. + pub evidence: Value, + /// Blobs and provenance, published before the critical section. + pub objects: Vec, +} -## The snapshot kind +/// The state the accepted transition writes, plus how to materialize it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Accepted { + pub snapshot: String, + pub event: String, + pub steps: Vec, + /// The derived projection: recomputed from the merged head, never reused. + pub projection_path: String, + pub projection_bytes: Vec, + pub objects: Vec, + pub rebased: bool, +} -Expose a public struct `Snapshot` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, -`serde::Serialize`, and `serde::Deserialize`, with -`#[serde(deny_unknown_fields)]`), a commit. Public fields: +/// Check a candidate against its module's placement and allowed outputs. +pub fn check_candidate_shape( + target: &TargetDef, + root: &str, + candidate: &Candidate, +) -> Result<()> { + if candidate.manifest.is_empty() { + return Err(error(OutcomeCode::InvalidCandidate, "candidate is empty")); + } + let projections = projection_owned_paths(target, root); + for path in candidate.manifest.keys() { + if !is_under(path, root) { + return Err(error( + OutcomeCode::InvalidCandidate, + format!("candidate path {path:?} is outside the placement root {root:?}"), + )); + } + if projections.iter().any(|p| p == path) { + return Err(error( + OutcomeCode::InvalidCandidate, + format!("candidate path {path:?} is a derived projection and cannot be owned"), + )); + } + if !is_owned_path(target, root, path) { + return Err(error( + OutcomeCode::InvalidCandidate, + format!("candidate path {path:?} is not an allowed output for the crate {root:?}"), + )); + } + } + let manifest = format!("{root}/{}", target.manifest_file); + if !candidate.manifest.contains_key(&manifest) { + return Err(error( + OutcomeCode::InvalidCandidate, + format!("candidate is missing its manifest {manifest:?}"), + )); + } + Ok(()) +} -- `parents: Vec`, serde field name `parents`. One parent, or two at a - merge. Always present. -- `seq: u64`, serde field name `seq`. The sequence number, equal to the committed - ordinal. It is a `u64`. -- `root: std::collections::BTreeMap`, serde field name `root`. - The root map, module name to its `RootEntry`, keyed and serialized in sorted - order via `BTreeMap`. +/// Verify a receipt against independently recomputed identities. +pub fn verify_receipt( + source: &dyn ObjectSource, + sources: &[SourceModule], + receipt: &Receipt, + candidate: &Candidate, +) -> Result { + if receipt.receipt_version != RECEIPT_VERSION { + return Err(error( + OutcomeCode::UnsupportedVersion, + format!("receiptVersion {} is not supported", receipt.receipt_version), + )); + } + if receipt.candidate_tree_hash != candidate.tree_hash { + return Err(error( + OutcomeCode::InvalidReceipt, + format!( + "candidateTreeHash {} does not match the ingested candidate {}", + receipt.candidate_tree_hash, candidate.tree_hash + ), + )); + } + if receipt.output_manifest != candidate.manifest { + return Err(error( + OutcomeCode::InvalidReceipt, + "outputManifest does not match the ingested candidate", + )); + } + if !is_object_name(&receipt.base_workspace_hash) { + return Err(error( + OutcomeCode::InvalidReceipt, + "baseWorkspaceHash is not an object name", + )); + } + let slice = slice_at( + source, + sources, + &receipt.target, + &receipt.module, + receipt.base_store_snapshot.as_deref(), + )?; + let mismatch = |field: &str, claimed: &str, recomputed: &str| { + error( + OutcomeCode::InvalidReceipt, + format!("{field} {claimed} does not reproduce at the receipt's bases (recomputed {recomputed})"), + ) + }; + if slice.slice_hash != receipt.slice_hash { + return Err(mismatch("sliceHash", &receipt.slice_hash, &slice.slice_hash)); + } + if slice.input_key != receipt.input_key { + return Err(mismatch("inputKey", &receipt.input_key, &slice.input_key)); + } + if slice.base_state_key != receipt.base_state_key { + return Err(mismatch( + "baseStateKey", + &receipt.base_state_key, + &slice.base_state_key, + )); + } + Ok(slice) +} -Expose a public struct `RootEntry` (same derives, same -`#[serde(deny_unknown_fields)]`), one module's entry in the root map. Public -fields: +/// Extract the candidate's surface and derive the whole transition. +pub fn derive_commit( + target: &TargetDef, + root: &str, + receipt: &Receipt, + candidate: &Candidate, +) -> Result { + let items = extract_surface(target, root, &candidate.files)?; + // Extraction is claimed to be a pure function of the bytes; a second pass + // over the same ingested bytes is the cheapest way to keep that claim honest. + if items != extract_surface(target, root, &candidate.files)? { + return Err(error( + OutcomeCode::MappingIntegrity, + "surface extraction is not reproducible for this candidate", + )); + } + let surface_value = surface_json(&receipt.module, &target.name, &items); + let recorded_surface = surface_hash(&receipt.module, &target.name, &items); -- `module_state: String`, serde field name `moduleState`. The module-state - object's hash. -- `event_head: String`, serde field name `eventHead`. The module's event-head - object hash. + let new_state = ModuleState { + module: receipt.module.clone(), + target: target.name.clone(), + input_key: Some(receipt.input_key.clone()), + slice_hash: Some(receipt.slice_hash.clone()), + surface_hash: recorded_surface.clone(), + output_tree: candidate.tree_hash.clone(), + }; + let new_state_key = module_state_hash(&new_state); -### Validating a snapshot + let mut read_set = vec![ + format!("input-key:{}", receipt.input_key), + format!("base-state:{}", receipt.base_state_key), + format!("target-def:{}", target_def_hash(target)), + ]; + read_set.sort(); + let footprint = Footprint { + read_set, + write_set: vec![ + ownership_write_key(root), + state_write_key(&target.name, &receipt.module), + ], + derived_projections: vec![target.projection_path.clone()], + install_paths: candidate.manifest.keys().cloned().collect(), + }; + let transition = Transition { + module: receipt.module.clone(), + target: target.name.clone(), + input_key: receipt.input_key.clone(), + base_state_key: receipt.base_state_key.clone(), + new_state: new_state_key.clone(), + receipt: receipt_semantic_hash(receipt), + surface: recorded_surface.clone(), + footprint, + }; + let transition_id = transition_hash(&transition); -Expose `validate_snapshot(value: serde_json::Value) -> Result`: deserialize into a `Snapshot`, returning -`StoreObjectError::Malformed` on any deserialization failure, then apply these -semantic checks, returning the first failure: + let mut objects: Vec = Vec::new(); + for bytes in candidate.files.values() { + objects.push(blob_object(bytes)); + } + for tree in candidate.tree_objects.values() { + objects.push(json_object(ObjectKind::Tree, &tree_value(tree))); + } + objects.push(json_object(ObjectKind::Surface, &surface_value)); + objects.push(json_object( + ObjectKind::ModuleState, + &module_state_json(&new_state), + )); + objects.push(json_object(ObjectKind::Receipt, &receipt_json(receipt))); + objects.push(json_object( + ObjectKind::Receipt, + &crate::receipt::receipt_semantic_json(receipt), + )); + objects.push(json_object( + ObjectKind::TargetDef, + &crate::target::target_def_json(target), + )); + objects.push(json_object( + ObjectKind::Transition, + &transition_json(&transition), + )); -- `parents` must have length 1 or 2, else `StoreObjectError::InvalidParentCount`. -- Every parent hash, and every root entry's `moduleState` and `eventHead`, must be - a well-formed object name (sixty-four lowercase hex), else - `StoreObjectError::MalformedHash`. -- Every key of `root` must be a non-empty string, else - `StoreObjectError::EmptyKey`. + Ok(DerivedCommit { + transition, + transition_id, + new_state_key, + surface_hash: recorded_surface, + surface_items: items, + receipt_object: crate::receipt::receipt_hash(receipt), + evidence: evidence_report(&validate_all(&receipt.evidence)), + objects, + }) +} -::: test -name: a snapshot with an unknown field is rejected -given: { json: "the SNAP object with an extra top-level key \"bogus\": 1" } -expect: { error: Malformed } -::: +/// Derive the new snapshot, the install steps, and the re-derived projection. +/// +/// Called with the winning head already resolved, so a rebase and a +/// straight-line install go through exactly the same derivation and the +/// projection can never be carried across. +#[allow(clippy::too_many_arguments)] +pub fn accept( + source: &dyn ObjectSource, + target: &TargetDef, + root: &str, + candidate: &Candidate, + derived: &DerivedCommit, + head: Option<&str>, + base_snapshot: Option<&str>, +) -> Result { + let mut states = head_states(source, head)?; + let key = (target.name.clone(), derived.transition.module.clone()); + let previous = state_of(source, &states, target, &derived.transition.module)?.0; + states.insert(key, derived.new_state_key.clone()); -::: test -name: a snapshot with zero parents is rejected -given: { json: "the SNAP object with parents set to []" } -expect: { error: InvalidParentCount } -::: + let parents: Vec = match head { + Some(snapshot) => vec![crate::objects::snapshot_event(source, snapshot)?], + None => Vec::new(), + }; + let event = event_hash(&derived.transition_id, &parents); + let snapshot = snapshot_hash(&event, &states); -::: test -name: a snapshot with three parents is rejected -given: { json: "the SNAP object with parents set to [H6, H7, H8]" } -expect: { error: InvalidParentCount } -::: + let mut objects = vec![ + json_object(ObjectKind::Event, &event_json(&derived.transition_id, &parents)), + json_object(ObjectKind::Snapshot, &snapshot_json(&event, &states)), + ]; -::: test -name: a snapshot whose seq is the largest u64 is accepted -given: { json: "the SNAP object with seq set to 18446744073709551615" } -expect: { accepts: true } -::: + // The projection is recomputed from the merged member set, never reused. + // Members are the crate directories the committed modules occupy, read off + // each module's own output, so the workspace manifest lists placement paths. + let members = member_roots(source, &states, target)?; + let (projection_path, projection_bytes) = derive_projection(target, &members); + objects.push(blob_object(&projection_bytes)); -::: test -name: a snapshot whose seq exceeds the u64 range is rejected -given: { json: "the SNAP object with seq set to 18446744073709551616" } -expect: { error: Malformed } -::: + // The crate's shared entry files are derived projections too. The crate root + // `lib.rs` is recomputed from the union of the crate's committed module file + // sets and the command tables those modules expose, so no module owns it. + let (crate_files, providers) = crate_module_facts(source, target, &states, root)?; + let (lib_path, lib_bytes) = derive_lib_projection(target, root, &crate_files, &providers); + objects.push(blob_object(&lib_bytes)); + let crate_projections = projection_owned_paths(target, root); -::: test -name: a snapshot with a malformed moduleState hash is rejected -given: { json: "the SNAP object whose one root entry moduleState is set to \"xyz\"" } -expect: { error: MalformedHash } -::: + // Diffing the committed output tree against the candidate tree is what + // bounds the critical section: the steps are counted before the lock, and + // applying one never hashes content. + let mut trees: BTreeMap = BTreeMap::new(); + // A module with no committed output has an output tree that was never + // published; there is nothing to diff against, which is exactly "no old + // side" rather than a missing object. + let previous_root = load_tree_objects(source, &previous.output_tree, &mut trees) + .ok() + .map(|()| previous.output_tree.clone()); + for (name, tree) in &candidate.tree_objects { + trees.insert(name.clone(), tree.clone()); + } + let mut changes: Vec = diff_trees( + previous_root.as_deref(), + Some(&candidate.tree_hash), + &trees, + ) + .map_err(|e| { + error( + OutcomeCode::StoreIntegrity, + format!("cannot diff the module's owned output: {e}"), + ) + })? + .into_iter() + // A projection path never rides the owned diff: an earlier regime may have + // installed it as an owned file, and its removal from this module's output + // must not delete the projection the same plan is about to write. + .filter(|change| !crate_projections.iter().any(|p| p == change_path(change))) + .collect(); + // Both projections ride the same plan. The workspace manifest classifies as + // the snapshot-wide derived view; the crate root `lib.rs` classifies as an + // ordinary owned write and is authored here rather than by any module. + changes.push(TreeChange::Added { + path: projection_path.clone(), + hash: napl_store::store_hash::object_name(ObjectKind::Blob, &projection_bytes), + mode: FILE_MODE, + }); + changes.push(TreeChange::Added { + path: lib_path.clone(), + hash: napl_store::store_hash::object_name(ObjectKind::Blob, &lib_bytes), + mode: FILE_MODE, + }); + let steps = plan_install_steps(&changes); -::: test -name: a snapshot with an empty root key is rejected -given: { json: "the SNAP object with the root key \"greeting\" renamed to the empty string" } -expect: { error: EmptyKey } -::: + Ok(Accepted { + snapshot, + event, + steps, + projection_path, + projection_bytes, + objects, + rebased: head != base_snapshot, + }) +} -::: test -name: a root-map entry with an unknown field is rejected -given: { json: "the SNAP object whose one root entry carries an extra key \"bogus\": 1" } -expect: { error: Malformed } -::: +/// The crate directories the committed modules of a target occupy, sorted and +/// deduplicated. Each root is read off a module's own committed output as the +/// directory holding its manifest, so the workspace manifest lists placement +/// paths rather than module names, and several modules sharing one crate +/// contribute that crate exactly once. +fn member_roots( + source: &dyn ObjectSource, + states: &BTreeMap<(String, String), String>, + target: &TargetDef, +) -> Result> { + let suffix = format!("/{}", target.manifest_file); + let mut roots: Vec = Vec::new(); + for ((state_target, _module), state_key) in states { + if state_target != &target.name { + continue; + } + let state = read_module_state(source, state_key)?; + for (path, _blob) in base_output_files(source, &state)? { + if let Some(root) = path.strip_suffix(&suffix) { + if !root.is_empty() { + roots.push(root.to_string()); + } + } + } + } + roots.sort(); + roots.dedup(); + Ok(roots) +} -## The error type +/// The path a tree change touches, regardless of its variant. +fn change_path(change: &TreeChange) -> &str { + match change { + TreeChange::Added { path, .. } => path, + TreeChange::Removed { path, .. } => path, + TreeChange::Modified { path, .. } => path, + } +} -Expose a public enum `StoreObjectError` (deriving `Debug`, `Clone`, `PartialEq`, -and `Eq`) with exactly these variants, one per failure mode above: +/// The union of a crate's committed module file sets and the command-table +/// providers among them, read off the merged states. +/// +/// A module belongs to the crate when its committed output carries the crate +/// manifest, the one shared path every module of the crate holds. A module +/// provides a command table when its recorded surface exposes a nullary +/// `commands` function; its file stem is the path the registry aggregator calls. +fn crate_module_facts( + source: &dyn ObjectSource, + target: &TargetDef, + states: &BTreeMap<(String, String), String>, + root: &str, +) -> Result<(Vec, Vec)> { + let manifest = format!("{root}/{}", target.manifest_file); + let mut files: Vec = Vec::new(); + let mut providers: Vec = Vec::new(); + for ((state_target, _module), state_key) in states { + if state_target != &target.name { + continue; + } + let state = read_module_state(source, state_key)?; + let owned = base_output_files(source, &state)?; + if !owned.iter().any(|(path, _)| path == &manifest) { + continue; + } + for (path, _hash) in &owned { + files.push(path.clone()); + } + for item in read_surface_items(source, &state.surface_hash)? { + if item.kind != "fn" { + continue; + } + let Some(prefix) = item.path.strip_suffix("::commands") else { + continue; + }; + if !prefix.is_empty() && !prefix.contains(' ') { + providers.push(prefix.to_string()); + } + } + } + Ok((files, providers)) +} -- `Malformed(String)`, carrying the underlying deserialization message, for any - structural or type failure surfaced by serde (an unknown field, a missing - required field, an unknown enum token, a wrong JSON type). -- `EmptyModule`, an event whose module is empty. -- `InvalidOrdinal`, an event whose ordinal is less than 1. -- `MalformedHash`, a hash-typed field that is not sixty-four lowercase hex. -- `NonAdvancingOutcome`, a failed or interrupted step whose before and after - states are not equal. -- `InvalidParentCount`, a snapshot whose parent count is not 1 or 2. -- `EmptyKey`, an empty key in a `targets` or `root` map. - -## Naming a built object through `store_hash` - -For each of the three kinds, expose a function that returns the object's name, the -SHA-256 of its canonical-JSON envelope, by composing `store_hash`. Each serializes -the value to a `serde_json::Value`, and returns `store_hash::object_name(kind, -&store_hash::canonical_json(&value))` for the matching `ObjectKind`: - -- `module_state_object_name(state: &ModuleState) -> String`, with - `ObjectKind::ModuleState`. -- `event_object_name(event: &Event) -> String`, with `ObjectKind::Event`. -- `snapshot_object_name(snapshot: &Snapshot) -> String`, with - `ObjectKind::Snapshot`. - -These do not reimplement canonical JSON, envelope framing, or hashing; they call -`store_hash`. The canonical bytes fed to the envelope are exactly -`store_hash::canonical_json` applied to the serialized value, so a round-trip -through `store_hash::verify_object` over the framed envelope succeeds and reports -the matching kind. +/// Reject a candidate whose crate manifest disagrees with a sibling module's +/// committed manifest bytes. +/// +/// The crate manifest is owned and shared: every module of a crate carries +/// identical bytes. A disagreement is a defect rather than an install, because +/// installing it would overwrite another module's committed manifest. +pub fn check_manifest_identity( + source: &dyn ObjectSource, + target: &TargetDef, + root: &str, + module: &str, + candidate: &Candidate, +) -> Result<()> { + let manifest = format!("{root}/{}", target.manifest_file); + let Some(mine) = candidate.files.get(&manifest) else { + return Ok(()); + }; + let states = head_states(source, source.head()?.as_deref())?; + for ((state_target, state_module), state_key) in &states { + if state_target != &target.name || state_module == module { + continue; + } + let state = read_module_state(source, state_key)?; + for (path, hash) in base_output_files(source, &state)? { + if path != manifest { + continue; + } + let theirs = crate::store_view::get_blob(source, &hash)?; + if &theirs != mine { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "candidate manifest {manifest:?} disagrees with module {state_module:?}'s committed manifest bytes" + ), + )); + } + } + } + Ok(()) +} -## Fixtures the tests pin +/// The canonical success payload of a commit. +pub fn success_payload(derived: &DerivedCommit, accepted: &Accepted) -> Value { + json!({ + "blobsPublished": true, + "contractVersion": crate::version::CONTRACT_VERSION, + "derivedProjections": [accepted.projection_path.clone()], + "evidence": derived.evidence, + "head": accepted.snapshot, + "installed": derived.transition.footprint.install_paths, + "outcome": "success", + "provenancePublished": true, + "rebased": accepted.rebased, + "receipt": derived.receipt_object, + "semanticStatePublished": true, + "surfaceHash": derived.surface_hash, + "transition": derived.transition_id, + }) +} -These fixtures and hash constants are referenced by name in the test suite. The -hash constants are each a sixty-four-character string of a single repeated -character: `A` is sixty-four `a` characters, `B` sixty-four `b`, `C` sixty-four -`c`, `D` sixty-four `d`, `E` sixty-four `e`, `F` sixty-four `f`, and `H1` through -`H8` are sixty-four repetitions of the digits `1` through `8` respectively. All -are well-formed lowercase-hex object names. +/// The union of write sets committed between `base` and `head`. +pub fn intervening_write_set( + source: &dyn ObjectSource, + head: Option<&str>, + base: Option<&str>, +) -> Result> { + let Some(head) = head else { + return Ok(BTreeSet::new()); + }; + let head_event = crate::objects::snapshot_event(source, head)?; + let base_event = match base { + Some(snapshot) => Some(crate::objects::snapshot_event(source, snapshot)?), + None => None, + }; + let mut out: BTreeSet = BTreeSet::new(); + for event in crate::objects::walk_events(source, &head_event, base_event.as_deref())? { + let transition = crate::objects::event_transition(source, &event)?; + out.extend(crate::objects::transition_write_set(source, &transition)?); + } + Ok(out) +} -- **MS_MIN**: a `ModuleState` with `promptBlob` = `A`, `blockIndex` = `B`, `mapl` - = `None`, `ir` = `None`, and empty `targets`. Its canonical bytes are exactly - `{"blockIndex":"","promptBlob":"","targets":{}}` followed by one newline; - its envelope is `napl-store module-state v1 175\n` followed by those canonical - bytes; its object name is - `ab63db013784f331f93f17786b8ea92f9a89736c44686ea112223ee03854dc94`. +fn blob_object(bytes: &[u8]) -> PendingObject { + PendingObject { + name: napl_store::store_hash::object_name(ObjectKind::Blob, bytes), + kind: ObjectKind::Blob, + payload: bytes.to_vec(), + } +} -::: test -name: a minimal module-state canonicalizes to its pinned bytes -given: { fixture: MS_MIN } -expect: { canonical_bytes: "{\"blockIndex\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"promptBlob\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"targets\":{}}\n" } -::: +fn json_object(kind: ObjectKind, value: &Value) -> PendingObject { + let (name, payload) = hash_json(kind, value); + PendingObject { + name, + kind, + payload, + } +} -::: test -name: a minimal module-state names itself by its pinned digest -given: { fixture: MS_MIN } -expect: { object_name: "ab63db013784f331f93f17786b8ea92f9a89736c44686ea112223ee03854dc94" } -::: +fn tree_value(tree: &Tree) -> Value { + serde_json::to_value(tree).expect("a tree always serializes") +} +napl-store tree v1 254 +{"entries":[{"hash":"16022ce4ca0f16dbdca6546700a2348fe4f7e66448351a8a0e29dbe532eec294","kind":"file","mode":33188,"name":"mod.rs"},{"hash":"302f88d8a2cb814f96c9eb6e8e9deb7ed6cbd62c458c1dd92fc096612d7f48af","kind":"file","mode":33188,"name":"tests.rs"}]} +napl-store tree v1 254 +{"entries":[{"hash":"4b2fe9210499da6b7cd566c3d2e587c1bebad47ef3f0d83aebe6c0358ef056cf","kind":"file","mode":33188,"name":"mod.rs"},{"hash":"62258119745f0a50e2e85bf0c785de15c4debb337165d751bb1069f164f72f60","kind":"file","mode":33188,"name":"tests.rs"}]} +napl-store tree v1 132 +{"entries":[{"hash":"b8c3e4ef1c2a1293952c001f474e835153bfafdd23d5173be4fdca923dff8543","kind":"dir","mode":16384,"name":"crates"}]} +napl-store blob v1 10331 +//! Evidence validators: a registry keyed by media type, plus compiled-in bounds. +//! +//! Evidence is provenance a producer attaches to a receipt. It never enters a +//! semantic identity, so a validator can only ever change what the report says, +//! never what gets installed. An invalid item stays visible as failed rather +//! than being dropped, because silently discarding failed evidence would make a +//! bad run look like a clean one. -::: test -name: a minimal module-state frames its pinned envelope bytes -given: { fixture: MS_MIN } -expect: { envelope_bytes: "napl-store module-state v1 175\n{\"blockIndex\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"promptBlob\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"targets\":{}}\n" } -::: +use serde_json::{json, Value}; -::: test -name: a minimal module-state round-trips through the read verifier -given: { fixture: MS_MIN } -expect: { verify_ok: true, kind: module-state } -::: +use crate::canon::canonical_bytes; +use crate::error::{error, OutcomeCode, Result}; +use crate::receipt::UNAVAILABLE; -::: test -name: an absent mapl and ir are omitted from a module-state's canonical bytes -given: { fixture: MS_MIN } -expect: { has_mapl_key: false, has_ir_key: false } -::: +/// The most evidence items one receipt may carry. +pub const MAX_EVIDENCE_ITEMS: usize = 8; -- **MS_RICH**: a `ModuleState` with `promptBlob` = `A`, `blockIndex` = `B`, `mapl` - = `Some(C)`, `ir` = `Some(D)`, and one target `rust` whose `TargetArtifacts` has - `generatedTree` = `E` and `attribution` = `Some(F)`. Its object name is - `9dca13259e9b91d24853a8b187dd9b309b2e2bd2c614c2edec089da5ad2fa9af`. +/// The largest a single evidence body may be, in bytes. +pub const MAX_EVIDENCE_ITEM_BYTES: usize = 64 * 1024; -::: test -name: "a rich module-state with mapl, ir, and one target canonicalizes to its pinned bytes" -given: { fixture: MS_RICH } -expect: { canonical_bytes: "{\"blockIndex\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"ir\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"mapl\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"promptBlob\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"targets\":{\"rust\":{\"attribution\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"generatedTree\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\"}}}\n" } -::: +/// The largest every evidence body may be together, in bytes. +pub const MAX_EVIDENCE_TOTAL_BYTES: usize = 256 * 1024; -::: test -name: a rich module-state names itself by its pinned digest -given: { fixture: MS_RICH } -expect: { object_name: "9dca13259e9b91d24853a8b187dd9b309b2e2bd2c614c2edec089da5ad2fa9af" } -::: +/// The media type of the Claude CLI JSON envelope, the first shipped validator. +pub const CLAUDE_CLI_JSON: &str = "application/vnd.claude-cli+json"; -::: test -name: a rich module-state round-trips back to an equal value -given: { fixture: MS_RICH } -expect: { round_trip_equal: true } -::: +/// The media type of the usage-proxy log, the second shipped validator. The +/// usage-proxy prompt names the type and writes the lines; the registry here is +/// the only thing that judges them. +pub const USAGE_PROXY_JSONL: &str = "application/x-napl-usage-proxy-jsonl-v1"; -- **EV_GEN**: an `Event` with `parents` = `[H1]`, `module` = `greeting`, `target` - = `Some("rust")`, `ordinal` = 5, `timestamp` = `2026-07-25T00:00:00Z`, `engine` - = `opus`, `state_before` = `Some(State(H2))`, `state_after` = `Some(State(H3))`, - and payload `Gen` with `mode` = `Full`, `outcome` = `Ok`, `attempts` = 1, - `taskBundle` = `H4`, and `provenance` = `None`. Its envelope is - `napl-store event v1 499\n` followed by its canonical bytes; its object name is - `dc807c63cc0b542fde0132415814a66183931ce2b7334662dc8fb3810948ae9f`. Because its - `provenance` is `None` and that field is skipped when `None`, its canonical bytes, - envelope, and object name are exactly the same as before provenance existed: this - fixture is unchanged by the provenance addition and its pins are byte-identical. +/// The fields every usage-proxy log line carries, in canonical order. A line +/// missing any of them fails the shape. +const USAGE_PROXY_FIELDS: [&str; 7] = [ + "endpoint", + "model", + "requestId", + "session", + "status", + "timestamp", + "usage", +]; -::: test -name: a gen ok event canonicalizes to its pinned bytes -given: { fixture: EV_GEN } -expect: { canonical_bytes: "{\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"mode\":\"full\",\"outcome\":\"ok\",\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } -::: +/// The verdict on one evidence item. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EvidenceOutcome { + /// A registered validator ran and accepted the body. + Validated, + /// No validator is registered for the media type. + Unsupported, + /// A registered validator ran and rejected the body. + Invalid, + /// A registered validator could not run at all. + ValidatorError, +} -::: test -name: a gen ok event names itself by its pinned digest -given: { fixture: EV_GEN } -expect: { object_name: "dc807c63cc0b542fde0132415814a66183931ce2b7334662dc8fb3810948ae9f" } -::: +/// The stable wire token for a verdict. +pub fn evidence_slug(outcome: EvidenceOutcome) -> &'static str { + match outcome { + EvidenceOutcome::Validated => "validated", + EvidenceOutcome::Unsupported => "unsupported", + EvidenceOutcome::Invalid => "invalid", + EvidenceOutcome::ValidatorError => "validator-error", + } +} -::: test -name: a gen ok event frames its pinned envelope bytes -given: { fixture: EV_GEN } -expect: { envelope_bytes: "napl-store event v1 499\n{\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"mode\":\"full\",\"outcome\":\"ok\",\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } -::: +/// One evidence item as a receipt carries it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EvidenceItem { + pub media_type: String, + pub body: String, +} -::: test -name: a gen ok event round-trips through the read verifier -given: { fixture: EV_GEN } -expect: { verify_ok: true, kind: event } -::: +/// The media types this build knows how to validate, sorted. +pub fn registered_media_types() -> Vec { + let mut out = vec![CLAUDE_CLI_JSON.to_string(), USAGE_PROXY_JSONL.to_string()]; + out.sort(); + out +} -::: test -name: a gen event without provenance omits the provenance key from its canonical bytes -given: { fixture: EV_GEN } -expect: { has_provenance_key: false } -::: +/// Read the evidence list out of a receipt's reported block. +/// +/// A reported block with no `evidence` key carries no evidence; an `evidence` +/// key that is not a list of well-formed items is an `invalid-receipt`, because +/// a malformed shape is a producer bug and not something to guess past. +pub fn parse_evidence(reported: &Value) -> Result> { + let raw = match reported.get("evidence") { + None | Some(Value::Null) => return Ok(Vec::new()), + Some(Value::String(_)) => return Ok(Vec::new()), + Some(Value::Array(items)) => items.clone(), + Some(_) => { + return Err(error( + OutcomeCode::InvalidReceipt, + "reported.evidence must be an array", + )) + } + }; + let mut out: Vec = Vec::new(); + for item in &raw { + let media_type = item["mediaType"].as_str().ok_or_else(|| { + error( + OutcomeCode::InvalidReceipt, + "evidence item has no mediaType string", + ) + })?; + let body = item["body"].as_str().ok_or_else(|| { + error( + OutcomeCode::InvalidReceipt, + "evidence item has no body string", + ) + })?; + out.push(EvidenceItem { + media_type: media_type.to_string(), + body: body.to_string(), + }); + } + Ok(out) +} -::: test -name: a gen event without a usage log omits the usage key from its canonical bytes -given: { fixture: EV_GEN } -expect: { has_usage_key: false } -::: +/// Enforce the compiled-in evidence bounds. Exceeding any is `resource-limit`, +/// checked before anything at all is published. +pub fn check_evidence_bounds(items: &[EvidenceItem]) -> Result<()> { + if items.len() > MAX_EVIDENCE_ITEMS { + return Err(error( + OutcomeCode::ResourceLimit, + format!( + "receipt carries {} evidence items, limit is {MAX_EVIDENCE_ITEMS}", + items.len() + ), + )); + } + let mut total = 0usize; + for item in items { + let len = item.body.len(); + if len > MAX_EVIDENCE_ITEM_BYTES { + return Err(error( + OutcomeCode::ResourceLimit, + format!( + "evidence item of type {:?} is {len} bytes, limit is {MAX_EVIDENCE_ITEM_BYTES}", + item.media_type + ), + )); + } + total += len; + if total > MAX_EVIDENCE_TOTAL_BYTES { + return Err(error( + OutcomeCode::ResourceLimit, + format!("receipt evidence exceeds {MAX_EVIDENCE_TOTAL_BYTES} total bytes"), + )); + } + } + Ok(()) +} -- **EV_LEGACY**: an `Event` with `parents` = `[]`, `module` = `greeting`, `target` - = `None`, `ordinal` = 1, `timestamp` = `2026-07-25T00:00:00Z`, `engine` = `""`, - `state_before` = `Some(Unknown)`, `state_after` = `Some(Unknown)`, and payload - `Legacy` with `originalLine` = `H5` and `classification` = `MissingPrefix`. Its - object name is - `fba5e4f9532e4dd01d1f9571b46afafced354533db1730806acb703643fd05f4`. +/// Validate one evidence item through the registry. +pub fn validate_evidence(item: &EvidenceItem) -> EvidenceOutcome { + match item.media_type.as_str() { + CLAUDE_CLI_JSON => validate_claude_cli_json(&item.body), + USAGE_PROXY_JSONL => validate_usage_proxy_jsonl(&item.body), + _ => EvidenceOutcome::Unsupported, + } +} -::: test -name: an absent target and absent states are omitted from an event's canonical bytes -given: { fixture: EV_LEGACY } -expect: { has_target_key: false } -::: +/// Validate every item, reporting each verdict in the order the receipt lists +/// them. +pub fn validate_all(items: &[EvidenceItem]) -> Vec<(EvidenceItem, EvidenceOutcome)> { + items + .iter() + .map(|item| (item.clone(), validate_evidence(item))) + .collect() +} -::: test -name: a legacy event with unknown states canonicalizes to its pinned bytes -given: { fixture: EV_LEGACY } -expect: { canonical_bytes: "{\"engine\":\"\",\"module\":\"greeting\",\"ordinal\":1,\"parents\":[],\"payload\":{\"fields\":{\"classification\":\"missing-prefix\",\"originalLine\":\"5555555555555555555555555555555555555555555555555555555555555555\"},\"kind\":\"legacy\"},\"stateAfter\":\"unknown\",\"stateBefore\":\"unknown\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } -::: +/// The canonical report block for a validated evidence set. +pub fn evidence_report(items: &[(EvidenceItem, EvidenceOutcome)]) -> Value { + let entries: Vec = items + .iter() + .map(|(item, outcome)| { + json!({ + "bytes": item.body.len() as u64, + "mediaType": item.media_type, + "outcome": evidence_slug(*outcome), + }) + }) + .collect(); + json!({ + "items": entries, + "registeredMediaTypes": registered_media_types(), + }) +} -::: test -name: a legacy event names itself by its pinned digest -given: { fixture: EV_LEGACY } -expect: { object_name: "fba5e4f9532e4dd01d1f9571b46afafced354533db1730806acb703643fd05f4" } -::: +/// The Claude CLI JSON envelope validator. +/// +/// It checks shape, never content: a `result` envelope carrying a `usage` object +/// with integer input and output token counts. Anything else is `invalid`, which +/// is deliberately distinguishable from `unsupported`. +fn validate_claude_cli_json(body: &str) -> EvidenceOutcome { + let Ok(value) = serde_json::from_str::(body) else { + return EvidenceOutcome::Invalid; + }; + let Some(object) = value.as_object() else { + return EvidenceOutcome::Invalid; + }; + if object.get("type").and_then(Value::as_str) != Some("result") { + return EvidenceOutcome::Invalid; + } + let Some(usage) = object.get("usage").and_then(Value::as_object) else { + return EvidenceOutcome::Invalid; + }; + let counted = ["input_tokens", "output_tokens"] + .iter() + .all(|key| usage.get(*key).and_then(Value::as_u64).is_some()); + if counted { + EvidenceOutcome::Validated + } else { + EvidenceOutcome::Invalid + } +} -::: test -name: a legacy event round-trips back to an equal value preserving both unknown sentinels -given: { fixture: EV_LEGACY } -expect: { round_trip_equal: true } -::: +/// The usage-proxy log validator. +/// +/// The log is one canonical JSON object per line, so the shape is checked line by +/// line and canonicity is checked by re-serializing: a line that reparses to +/// different bytes is not the line the proxy writes. Every field the proxy +/// promises must be present, an absent value is the string `unavailable` rather +/// than a null or a zero, and a token count that is present must be a +/// non-negative integer. A line failing any of that is `invalid`, which stays +/// visible as failed rather than being read as a cheap run. +fn validate_usage_proxy_jsonl(body: &str) -> EvidenceOutcome { + let mut lines = 0usize; + for line in body.split('\n') { + if line.is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(line) else { + return EvidenceOutcome::Invalid; + }; + let Some(object) = value.as_object() else { + return EvidenceOutcome::Invalid; + }; + let mut canonical = canonical_bytes(&value); + if canonical.last() == Some(&b'\n') { + canonical.pop(); + } + if canonical != line.as_bytes() { + return EvidenceOutcome::Invalid; + } + if !USAGE_PROXY_FIELDS.iter().all(|key| object.contains_key(*key)) { + return EvidenceOutcome::Invalid; + } + let texts = ["endpoint", "model", "requestId", "session", "timestamp"]; + if !texts + .iter() + .all(|key| object[*key].as_str().is_some_and(|text| !text.is_empty())) + { + return EvidenceOutcome::Invalid; + } + if object["status"].as_u64().is_none() { + return EvidenceOutcome::Invalid; + } + if !usage_shape_holds(&object["usage"]) { + return EvidenceOutcome::Invalid; + } + lines += 1; + } + if lines == 0 { + EvidenceOutcome::Invalid + } else { + EvidenceOutcome::Validated + } +} -- **EV_GEN_INT**: an `Event` identical in shape to EV_GEN but carrying the two new - amendment fields, an interrupted gen recovered from an attempt. Its `attempt_id` - is `Some("att-0001")`; `parents` = `[H1]`, `module` = `greeting`, `target` = - `Some("rust")`, `ordinal` = 5, `timestamp` = `2026-07-25T00:00:00Z`, `engine` = - `opus`; `state_before` = `Some(State(H2))` and `state_after` = `Some(State(H2))`, - equal per the non-advancing rule; and payload `Gen` with `mode` = `Full`, - `outcome` = `Interrupted`, `attempts` = 1, `taskBundle` = `H4`, and `diagnostics` - = `vec![H3]` (one diagnostic blob hash carried over). Its canonical bytes are - exactly - `{"attemptId":"att-0001","engine":"opus","module":"greeting","ordinal":5,"parents":["

"],"payload":{"fields":{"attempts":1,"diagnostics":["

"],"mode":"full","outcome":"interrupted","taskBundle":"

"},"kind":"gen"},"stateAfter":"

","stateBefore":"

","target":"rust","timestamp":"2026-07-25T00:00:00Z"}` - followed by one newline; its envelope is `napl-store event v1 614\n` followed by - those canonical bytes; and its object name is - `17f796f1f623dc65d2f596336befb1e062c619ca1d6f87aa3e76245c5e37420e`. It proves the - new `attemptId` sorts to the front of the event's canonical keys and the new - `diagnostics` sorts between `attempts` and `mode` inside the gen `fields`. +/// The `usage` field of one log line: the string `unavailable`, or an object +/// whose token counts are non-negative integers wherever they appear. +fn usage_shape_holds(usage: &Value) -> bool { + if usage.as_str() == Some(UNAVAILABLE) { + return true; + } + let Some(object) = usage.as_object() else { + return false; + }; + object + .iter() + .filter(|(key, _)| key.ends_with("tokens")) + .all(|(_, value)| value.as_u64().is_some()) +} -::: test -name: a gen event carrying an attempt id and diagnostics canonicalizes to its pinned bytes -given: { fixture: EV_GEN_INT } -expect: { canonical_bytes: "{\"attemptId\":\"att-0001\",\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"diagnostics\":[\"3333333333333333333333333333333333333333333333333333333333333333\"],\"mode\":\"full\",\"outcome\":\"interrupted\",\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } -::: +/// Enforce the bound on the whole reported block's canonical size. +pub fn check_reported_bounds(reported: &Value, limit: usize) -> Result<()> { + let len = canonical_bytes(reported).len(); + if len > limit { + return Err(error( + OutcomeCode::ResourceLimit, + format!("reported block is {len} bytes, limit is {limit}"), + )); + } + Ok(()) +} +napl-store transition v1 1476 +{"baseStateKey":"71e64c38c6aead99990634a42ca5c8babc3c01b7c4379efde5a8ca121da714fb","contractVersion":1,"derivedProjections":["Cargo.toml"],"inputKey":"b7e0b1e3ed33336e39d9b00f9f89441fa1c3b033336dd874608e4cfdafcb9ddc","installPaths":["napl/Cargo.toml","napl/src/authoring.rs","napl/src/blame.rs","napl/src/candidate.rs","napl/src/canon.rs","napl/src/cli.rs","napl/src/commit.rs","napl/src/compile.rs","napl/src/cost.rs","napl/src/decision.rs","napl/src/disk.rs","napl/src/error.rs","napl/src/evidence.rs","napl/src/graph.rs","napl/src/lib.rs","napl/src/lint.rs","napl/src/lsp.rs","napl/src/main.rs","napl/src/maintenance.rs","napl/src/objects.rs","napl/src/paths.rs","napl/src/porcelain.rs","napl/src/receipt.rs","napl/src/slice.rs","napl/src/source.rs","napl/src/store_view.rs","napl/src/surface.rs","napl/src/target.rs","napl/src/textdiff.rs","napl/src/transaction.rs","napl/src/trees.rs","napl/src/workspace.rs"],"module":"napl","newState":"f60ebd2b77e0dd55b8e4464e324f5818b6cc6cb79e0dfca8dfd7edd135c128b1","readSet":["base-state:71e64c38c6aead99990634a42ca5c8babc3c01b7c4379efde5a8ca121da714fb","input-key:b7e0b1e3ed33336e39d9b00f9f89441fa1c3b033336dd874608e4cfdafcb9ddc","target-def:0b29a1295ab8e708dc85ac080b628852fde73a8fdabf118653328d2d3712d172"],"receipt":"0671b9a60e3cd8c1f49c921433447a199ee383c6d357a1cbac9bc971d30d001d","surface":"671b6290852a9a7352ed4a1492c34ec8e935b83723b04e51b7eebb624cb52f4c","target":"rust","writeSet":["owned:napl","module-state:rust/napl"]} +napl-store surface v1 45882 +{"contractVersion":1,"items":[{"kind":"fn","path":"authoring::apply_fix","signature":"pub fn apply_fix ( workspace : & Path , target : & str , finding : & Finding ) -> Result < CommitReport >"},{"kind":"fn","path":"authoring::draft_receipt","signature":"pub fn draft_receipt ( workspace : & Path , target_name : & str , module : & str , candidate_dir : & Path ) -> Result < Value >"},{"kind":"struct","path":"blame::BlameLine","signature":"pub struct BlameLine"},{"kind":"field","path":"blame::BlameLine.line","signature":"pub line : usize"},{"kind":"field","path":"blame::BlameLine.text","signature":"pub text : String"},{"kind":"field","path":"blame::BlameLine.transition","signature":"pub transition : String"},{"kind":"struct","path":"blame::Revision","signature":"pub struct Revision"},{"kind":"field","path":"blame::Revision.content","signature":"pub content : String"},{"kind":"field","path":"blame::Revision.transition","signature":"pub transition : String"},{"kind":"fn","path":"blame::blame","signature":"pub fn blame ( revisions : & [ Revision ] ) -> Vec < BlameLine >"},{"kind":"fn","path":"blame::blame_json","signature":"pub fn blame_json ( path : & str , lines : & [ BlameLine ] ) -> Value"},{"kind":"struct","path":"candidate::Candidate","signature":"pub struct Candidate"},{"kind":"field","path":"candidate::Candidate.files","signature":"pub files : BTreeMap < String , Vec < u8 > >"},{"kind":"field","path":"candidate::Candidate.manifest","signature":"pub manifest : BTreeMap < String , String >"},{"kind":"field","path":"candidate::Candidate.tree_hash","signature":"pub tree_hash : String"},{"kind":"field","path":"candidate::Candidate.tree_objects","signature":"pub tree_objects : BTreeMap < String , Tree >"},{"kind":"enum","path":"candidate::EntryKind","signature":"pub enum EntryKind { File , Link , Device }"},{"kind":"struct","path":"candidate::Limits","signature":"pub struct Limits"},{"kind":"field","path":"candidate::Limits.max_entries","signature":"pub max_entries : usize"},{"kind":"field","path":"candidate::Limits.max_entry_bytes","signature":"pub max_entry_bytes : usize"},{"kind":"field","path":"candidate::Limits.max_total_bytes","signature":"pub max_total_bytes : usize"},{"kind":"struct","path":"candidate::ScannedEntry","signature":"pub struct ScannedEntry"},{"kind":"field","path":"candidate::ScannedEntry.bytes","signature":"pub bytes : Vec < u8 >"},{"kind":"field","path":"candidate::ScannedEntry.kind","signature":"pub kind : EntryKind"},{"kind":"field","path":"candidate::ScannedEntry.path","signature":"pub path : String"},{"kind":"fn","path":"candidate::default_limits","signature":"pub fn default_limits ( ) -> Limits"},{"kind":"fn","path":"candidate::ingest","signature":"pub fn ingest ( entries : & [ ScannedEntry ] , limits : Limits ) -> Result < Candidate >"},{"kind":"fn","path":"canon::canonical_bytes","signature":"pub fn canonical_bytes ( value : & Value ) -> Vec < u8 >"},{"kind":"fn","path":"canon::hash_json","signature":"pub fn hash_json ( kind : ObjectKind , value : & Value ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"canon::is_object_name","signature":"pub fn is_object_name ( text : & str ) -> bool"},{"kind":"fn","path":"canon::json_hash","signature":"pub fn json_hash ( kind : ObjectKind , value : & Value ) -> String"},{"kind":"fn","path":"canon::parse_json","signature":"pub fn parse_json ( bytes : & [ u8 ] , context : & str , code : OutcomeCode ) -> Result < Value >"},{"kind":"struct","path":"cli::CliResult","signature":"pub struct CliResult"},{"kind":"field","path":"cli::CliResult.exit_code","signature":"pub exit_code : i32"},{"kind":"field","path":"cli::CliResult.stdout","signature":"pub stdout : Vec < u8 >"},{"kind":"struct","path":"cli::CommandSpec","signature":"pub struct CommandSpec"},{"kind":"field","path":"cli::CommandSpec.name","signature":"pub name : & ' static str"},{"kind":"field","path":"cli::CommandSpec.run","signature":"pub run : fn ( & Path , & [ String ] ) -> Result < CliResult >"},{"kind":"fn","path":"cli::run_cli","signature":"pub fn run_cli ( cwd : & Path , args : & [ String ] ) -> CliResult"},{"kind":"fn","path":"commandset::commands","signature":"pub fn commands ( ) -> & ' static [ CommandSpec ]"},{"kind":"struct","path":"commit::CommitReport","signature":"pub struct CommitReport"},{"kind":"field","path":"commit::CommitReport.outcome","signature":"pub outcome : OutcomeCode"},{"kind":"field","path":"commit::CommitReport.payload","signature":"pub payload : Value"},{"kind":"fn","path":"commit::commit","signature":"pub fn commit ( workspace : & Path , receipt_path : & Path , candidate_dir : & Path ) -> CommitReport"},{"kind":"fn","path":"commit::lock_key","signature":"pub fn lock_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"commit::recover","signature":"pub fn recover ( store : & Store ) -> Result < Vec < String > >"},{"kind":"struct","path":"compile::CompileArtifact","signature":"pub struct CompileArtifact"},{"kind":"field","path":"compile::CompileArtifact.artifact_hash","signature":"pub artifact_hash : String"},{"kind":"field","path":"compile::CompileArtifact.slices","signature":"pub slices : Vec < CompileSlice >"},{"kind":"field","path":"compile::CompileArtifact.value","signature":"pub value : Value"},{"kind":"fn","path":"compile::base_output_files","signature":"pub fn base_output_files ( source : & dyn ObjectSource , state : & ModuleState ) -> Result < Vec < ( String , String ) > >"},{"kind":"fn","path":"compile::compile","signature":"pub fn compile ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , only_module : Option < & str > ) -> Result < CompileArtifact >"},{"kind":"fn","path":"compile::graph_for","signature":"pub fn graph_for ( sources : & [ SourceModule ] , target_name : & str ) -> Result < Graph >"},{"kind":"fn","path":"compile::head_states","signature":"pub fn head_states ( source : & dyn ObjectSource , snapshot : Option < & str > ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"compile::read_surface_items","signature":"pub fn read_surface_items ( source : & dyn ObjectSource , hash : & str ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"compile::slice_at","signature":"pub fn slice_at ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , module : & str , snapshot : Option < & str > ) -> Result < CompileSlice >"},{"kind":"fn","path":"compile::state_of","signature":"pub fn state_of ( source : & dyn ObjectSource , states : & BTreeMap < ( String , String ) , String > , target : & TargetDef , module : & str ) -> Result < ( ModuleState , String ) >"},{"kind":"fn","path":"compile::workspace_source_hash","signature":"pub fn workspace_source_hash ( sources : & [ SourceModule ] ) -> Result < String >"},{"kind":"struct","path":"cost::CostLine","signature":"pub struct CostLine"},{"kind":"field","path":"cost::CostLine.cost_usd","signature":"pub cost_usd : Option < String >"},{"kind":"field","path":"cost::CostLine.model","signature":"pub model : Option < String >"},{"kind":"field","path":"cost::CostLine.module","signature":"pub module : String"},{"kind":"field","path":"cost::CostLine.receipt","signature":"pub receipt : String"},{"kind":"field","path":"cost::CostLine.tokens","signature":"pub tokens : Option < TokenUsage >"},{"kind":"const","path":"cost::UNKNOWN","signature":"pub const UNKNOWN : & str = \" unknown \" ;"},{"kind":"fn","path":"cost::cost_report","signature":"pub fn cost_report ( lines : & [ CostLine ] ) -> Value"},{"kind":"fn","path":"cost::line_from_reported","signature":"pub fn line_from_reported ( receipt : & str , module : & str , reported : & Value ) -> Result < CostLine >"},{"kind":"fn","path":"cost::pricing_provenance","signature":"pub fn pricing_provenance ( ) -> Value"},{"kind":"struct","path":"decision::Arriving","signature":"pub struct Arriving"},{"kind":"field","path":"decision::Arriving.base_snapshot","signature":"pub base_snapshot : Option < String >"},{"kind":"field","path":"decision::Arriving.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Arriving.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Arriving.write_set","signature":"pub write_set : BTreeSet < String >"},{"kind":"enum","path":"decision::Decision","signature":"pub enum Decision { Install , Rebase , StaleInput { expected : String , found : String } , BaseOutputChanged { expected : String , found : String } , WriteConflict { overlap : Vec < String > } }"},{"kind":"struct","path":"decision::Head","signature":"pub struct Head"},{"kind":"field","path":"decision::Head.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Head.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Head.intervening_write_set","signature":"pub intervening_write_set : BTreeSet < String >"},{"kind":"field","path":"decision::Head.snapshot","signature":"pub snapshot : Option < String >"},{"kind":"fn","path":"decision::conflicts","signature":"pub fn conflicts ( write_set_a : & BTreeSet < String > , read_set_a : & BTreeSet < String > , write_set_b : & BTreeSet < String > , changed_identities_b : & BTreeSet < String > ) -> bool"},{"kind":"fn","path":"decision::decide","signature":"pub fn decide ( arriving : & Arriving , head : & Head ) -> Decision"},{"kind":"fn","path":"decision::is_accepted","signature":"pub fn is_accepted ( decision : & Decision ) -> bool"},{"kind":"fn","path":"decision::rejection","signature":"pub fn rejection ( decision : & Decision ) -> Option < CompilerError >"},{"kind":"const","path":"disk::POINTER_LOCK","signature":"pub const POINTER_LOCK : & str = \" pointer \" ;"},{"kind":"const","path":"disk::STORE_DIR","signature":"pub const STORE_DIR : & str = \" .napl/store \" ;"},{"kind":"struct","path":"disk::Store","signature":"pub struct Store"},{"kind":"field","path":"disk::Store.root","signature":"pub root : PathBuf"},{"kind":"field","path":"disk::Store.workspace","signature":"pub workspace : PathBuf"},{"kind":"fn","path":"disk::compare_and_set_head","signature":"pub fn compare_and_set_head ( store : & Store , expected : Option < & str > , next : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::delete_marker","signature":"pub fn delete_marker ( store : & Store , snapshot : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::init","signature":"pub fn init ( workspace : & Path ) -> Result < Store >"},{"kind":"fn","path":"disk::io","signature":"pub fn io ( path : & Path , err : & std :: io :: Error ) -> CompilerError"},{"kind":"fn","path":"disk::marker_snapshots","signature":"pub fn marker_snapshots ( store : & Store ) -> Result < Vec < String > >"},{"kind":"fn","path":"disk::open","signature":"pub fn open ( workspace : & Path ) -> Store"},{"kind":"fn","path":"disk::publish","signature":"pub fn publish ( store : & Store , kind : ObjectKind , payload : & [ u8 ] , owner : & str ) -> Result < String >"},{"kind":"fn","path":"disk::read_marker","signature":"pub fn read_marker ( store : & Store , snapshot : & str ) -> Result < Option < CommitMarker > >"},{"kind":"fn","path":"disk::release_lock","signature":"pub fn release_lock ( guard : LockGuard ) -> Result < ( ) >"},{"kind":"fn","path":"disk::remove_object","signature":"pub fn remove_object ( store : & Store , name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::take_lock","signature":"pub fn take_lock ( store : & Store , name : & str ) -> Result < LockGuard >"},{"kind":"fn","path":"disk::write_marker","signature":"pub fn write_marker ( store : & Store , marker : & CommitMarker ) -> Result < ( ) >"},{"kind":"struct","path":"error::CompilerError","signature":"pub struct CompilerError"},{"kind":"field","path":"error::CompilerError.code","signature":"pub code : OutcomeCode"},{"kind":"field","path":"error::CompilerError.detail","signature":"pub detail : String"},{"kind":"enum","path":"error::OutcomeCode","signature":"pub enum OutcomeCode { Success , StaleInput , BaseOutputChanged , WriteConflict , LockContended , InvalidCandidate , InvalidReceipt , MappingIntegrity , ResourceLimit , UnsupportedVersion , StoreIntegrity , IoError }"},{"kind":"type","path":"error::Result","signature":"pub type Result < T > = std :: result :: Result < T , CompilerError > ;"},{"kind":"fn","path":"error::error","signature":"pub fn error ( code : OutcomeCode , detail : impl Into < String > ) -> CompilerError"},{"kind":"fn","path":"error::error_payload","signature":"pub fn error_payload ( err : & CompilerError , blobs_published : bool , provenance_published : bool ) -> Value"},{"kind":"fn","path":"error::outcome_exit_code","signature":"pub fn outcome_exit_code ( code : OutcomeCode ) -> i32"},{"kind":"fn","path":"error::outcome_is_retryable","signature":"pub fn outcome_is_retryable ( code : OutcomeCode ) -> bool"},{"kind":"fn","path":"error::outcome_slug","signature":"pub fn outcome_slug ( code : OutcomeCode ) -> & ' static str"},{"kind":"const","path":"evidence::CLAUDE_CLI_JSON","signature":"pub const CLAUDE_CLI_JSON : & str = \" application/vnd.claude-cli+json \" ;"},{"kind":"struct","path":"evidence::EvidenceItem","signature":"pub struct EvidenceItem"},{"kind":"field","path":"evidence::EvidenceItem.body","signature":"pub body : String"},{"kind":"field","path":"evidence::EvidenceItem.media_type","signature":"pub media_type : String"},{"kind":"enum","path":"evidence::EvidenceOutcome","signature":"pub enum EvidenceOutcome { Validated , Unsupported , Invalid , ValidatorError }"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEMS","signature":"pub const MAX_EVIDENCE_ITEMS : usize = 8 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEM_BYTES","signature":"pub const MAX_EVIDENCE_ITEM_BYTES : usize = 64 * 1024 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_TOTAL_BYTES","signature":"pub const MAX_EVIDENCE_TOTAL_BYTES : usize = 256 * 1024 ;"},{"kind":"fn","path":"evidence::check_evidence_bounds","signature":"pub fn check_evidence_bounds ( items : & [ EvidenceItem ] ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::check_reported_bounds","signature":"pub fn check_reported_bounds ( reported : & Value , limit : usize ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::evidence_report","signature":"pub fn evidence_report ( items : & [ ( EvidenceItem , EvidenceOutcome ) ] ) -> Value"},{"kind":"fn","path":"evidence::evidence_slug","signature":"pub fn evidence_slug ( outcome : EvidenceOutcome ) -> & ' static str"},{"kind":"fn","path":"evidence::parse_evidence","signature":"pub fn parse_evidence ( reported : & Value ) -> Result < Vec < EvidenceItem > >"},{"kind":"fn","path":"evidence::registered_media_types","signature":"pub fn registered_media_types ( ) -> Vec < String >"},{"kind":"fn","path":"evidence::validate_all","signature":"pub fn validate_all ( items : & [ EvidenceItem ] ) -> Vec < ( EvidenceItem , EvidenceOutcome ) >"},{"kind":"fn","path":"evidence::validate_evidence","signature":"pub fn validate_evidence ( item : & EvidenceItem ) -> EvidenceOutcome"},{"kind":"type","path":"graph::Graph","signature":"pub type Graph = BTreeMap < String , SourceModule > ;"},{"kind":"fn","path":"graph::build_graph","signature":"pub fn build_graph ( sources : & [ SourceModule ] , target : & TargetDef ) -> Result < Graph >"},{"kind":"fn","path":"graph::changed_frontier","signature":"pub fn changed_frontier ( graph : & Graph , committed_input_keys : & BTreeMap < String , Option < String > > , input_keys : & BTreeMap < String , String > , only_module : Option < & str > ) -> Result < Vec < String > >"},{"kind":"fn","path":"graph::dependents_of","signature":"pub fn dependents_of ( graph : & Graph , module : & str ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::index_sources","signature":"pub fn index_sources ( sources : & [ SourceModule ] ) -> Result < BTreeMap < String , SourceModule > >"},{"kind":"fn","path":"graph::reachable_from","signature":"pub fn reachable_from ( graph : & Graph , roots : & [ String ] ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::topological_levels","signature":"pub fn topological_levels ( graph : & Graph ) -> Result < BTreeMap < String , usize > >"},{"kind":"enum","path":"lint::Analysis","signature":"pub enum Analysis { Performed , Refused }"},{"kind":"enum","path":"lint::Finding","signature":"pub enum Finding { UnreachableModule { module : String , cascade : Vec < String > } , UnconsumedSurfaceEntry { module : String , path : String } , StaleModule { module : String } , UnusedDependency { module : String , dep : String } }"},{"kind":"struct","path":"lint::LintInput","signature":"pub struct LintInput"},{"kind":"field","path":"lint::LintInput.committed_input_keys","signature":"pub committed_input_keys : BTreeMap < String , Option < String > >"},{"kind":"field","path":"lint::LintInput.current_input_keys","signature":"pub current_input_keys : BTreeMap < String , String >"},{"kind":"field","path":"lint::LintInput.referenced_names","signature":"pub referenced_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"field","path":"lint::LintInput.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"lint::LintInput.surface_names","signature":"pub surface_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"struct","path":"lint::LintReport","signature":"pub struct LintReport"},{"kind":"field","path":"lint::LintReport.findings","signature":"pub findings : Vec < Finding >"},{"kind":"field","path":"lint::LintReport.unreachable_analysis","signature":"pub unreachable_analysis : Analysis"},{"kind":"fn","path":"lint::finding_json","signature":"pub fn finding_json ( finding : & Finding ) -> Value"},{"kind":"fn","path":"lint::is_auto_fixable","signature":"pub fn is_auto_fixable ( finding : & Finding ) -> bool"},{"kind":"fn","path":"lint::lint","signature":"pub fn lint ( graph : & Graph , input : & LintInput ) -> LintReport"},{"kind":"fn","path":"lint::lint_json","signature":"pub fn lint_json ( report : & LintReport ) -> Value"},{"kind":"struct","path":"lsp::Attribution","signature":"pub struct Attribution"},{"kind":"field","path":"lsp::Attribution.entries","signature":"pub entries : Vec < Entry >"},{"kind":"field","path":"lsp::Attribution.module","signature":"pub module : String"},{"kind":"field","path":"lsp::Attribution.target","signature":"pub target : String"},{"kind":"struct","path":"lsp::Entry","signature":"pub struct Entry"},{"kind":"field","path":"lsp::Entry.file","signature":"pub file : String"},{"kind":"field","path":"lsp::Entry.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"lsp::Entry.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"lsp::Entry.note","signature":"pub note : String"},{"kind":"field","path":"lsp::Entry.prompt_end","signature":"pub prompt_end : u32"},{"kind":"field","path":"lsp::Entry.prompt_start","signature":"pub prompt_start : u32"},{"kind":"fn","path":"lsp::parse_attribution","signature":"pub fn parse_attribution ( text : & str ) -> Option < Attribution >"},{"kind":"fn","path":"lsp::serve","signature":"pub fn serve < R : BufRead , W : Write > ( reader : & mut R , writer : & mut W )"},{"kind":"struct","path":"maintenance::FileFact","signature":"pub struct FileFact"},{"kind":"field","path":"maintenance::FileFact.hash","signature":"pub hash : String"},{"kind":"field","path":"maintenance::FileFact.path","signature":"pub path : String"},{"kind":"enum","path":"maintenance::FsckFinding","signature":"pub enum FsckFinding { HashMismatch { object : String , detail : String } , MissingParent { event : String , parent : String } , MissingObject { from : String , missing : String } }"},{"kind":"enum","path":"maintenance::RestorePlan","signature":"pub enum RestorePlan { Apply ( Vec < RestoreStep > ) , Blocked { path : String , candidates : Vec < String > } }"},{"kind":"enum","path":"maintenance::RestoreStep","signature":"pub enum RestoreStep { Move { from : String , to : String } , Write { path : String , hash : String } , Remove { path : String } }"},{"kind":"fn","path":"maintenance::fsck","signature":"pub fn fsck ( source : & dyn ObjectSource ) -> Result < Vec < FsckFinding > >"},{"kind":"fn","path":"maintenance::fsck_json","signature":"pub fn fsck_json ( findings : & [ FsckFinding ] ) -> Value"},{"kind":"fn","path":"maintenance::gc_json","signature":"pub fn gc_json ( swept : & [ String ] , kept : usize ) -> Value"},{"kind":"fn","path":"maintenance::plan_restore","signature":"pub fn plan_restore ( expected : & [ FileFact ] , observed : & [ FileFact ] ) -> RestorePlan"},{"kind":"fn","path":"maintenance::plan_sweep","signature":"pub fn plan_sweep ( all : & BTreeSet < String > , reachable : & BTreeSet < String > ) -> Vec < String >"},{"kind":"fn","path":"maintenance::reachable_from_head","signature":"pub fn reachable_from_head ( source : & dyn ObjectSource , head : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"maintenance::restore_json","signature":"pub fn restore_json ( plan : & RestorePlan ) -> Value"},{"kind":"fn","path":"mv::relative_dir","signature":"pub fn relative_dir ( from : & str , to : & str ) -> String"},{"kind":"fn","path":"mv::relocate_files","signature":"pub fn relocate_files ( files : & BTreeMap < String , Vec < u8 > > , old_root : & str , dest : & str , target : & TargetDef , placements : & BTreeMap < String , String > ) -> Result < BTreeMap < String , Vec < u8 > > >"},{"kind":"fn","path":"mv::relocate_module","signature":"pub fn relocate_module ( workspace : & Path , target_name : & str , module : & str , dest : & str ) -> CommitReport"},{"kind":"struct","path":"objects::Footprint","signature":"pub struct Footprint"},{"kind":"field","path":"objects::Footprint.derived_projections","signature":"pub derived_projections : Vec < String >"},{"kind":"field","path":"objects::Footprint.install_paths","signature":"pub install_paths : Vec < String >"},{"kind":"field","path":"objects::Footprint.read_set","signature":"pub read_set : Vec < String >"},{"kind":"field","path":"objects::Footprint.write_set","signature":"pub write_set : Vec < String >"},{"kind":"struct","path":"objects::ModuleState","signature":"pub struct ModuleState"},{"kind":"field","path":"objects::ModuleState.input_key","signature":"pub input_key : Option < String >"},{"kind":"field","path":"objects::ModuleState.module","signature":"pub module : String"},{"kind":"field","path":"objects::ModuleState.output_tree","signature":"pub output_tree : String"},{"kind":"field","path":"objects::ModuleState.slice_hash","signature":"pub slice_hash : Option < String >"},{"kind":"field","path":"objects::ModuleState.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"objects::ModuleState.target","signature":"pub target : String"},{"kind":"struct","path":"objects::Transition","signature":"pub struct Transition"},{"kind":"field","path":"objects::Transition.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"objects::Transition.footprint","signature":"pub footprint : Footprint"},{"kind":"field","path":"objects::Transition.input_key","signature":"pub input_key : String"},{"kind":"field","path":"objects::Transition.module","signature":"pub module : String"},{"kind":"field","path":"objects::Transition.new_state","signature":"pub new_state : String"},{"kind":"field","path":"objects::Transition.receipt","signature":"pub receipt : String"},{"kind":"field","path":"objects::Transition.surface","signature":"pub surface : String"},{"kind":"field","path":"objects::Transition.target","signature":"pub target : String"},{"kind":"fn","path":"objects::empty_module_state","signature":"pub fn empty_module_state ( module : & str , target : & str , empty_tree : & str ) -> ModuleState"},{"kind":"fn","path":"objects::event_hash","signature":"pub fn event_hash ( content : & str , parents : & [ String ] ) -> String"},{"kind":"fn","path":"objects::event_json","signature":"pub fn event_json ( content : & str , parents : & [ String ] ) -> Value"},{"kind":"fn","path":"objects::event_transition","signature":"pub fn event_transition ( source : & dyn ObjectSource , event : & str ) -> Result < String >"},{"kind":"fn","path":"objects::module_state_hash","signature":"pub fn module_state_hash ( state : & ModuleState ) -> String"},{"kind":"fn","path":"objects::module_state_json","signature":"pub fn module_state_json ( state : & ModuleState ) -> Value"},{"kind":"fn","path":"objects::ownership_write_key","signature":"pub fn ownership_write_key ( root : & str ) -> String"},{"kind":"fn","path":"objects::read_module_state","signature":"pub fn read_module_state ( source : & dyn ObjectSource , hash : & str ) -> Result < ModuleState >"},{"kind":"fn","path":"objects::snapshot_event","signature":"pub fn snapshot_event ( source : & dyn ObjectSource , snapshot : & str ) -> Result < String >"},{"kind":"fn","path":"objects::snapshot_hash","signature":"pub fn snapshot_hash ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> String"},{"kind":"fn","path":"objects::snapshot_json","signature":"pub fn snapshot_json ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> Value"},{"kind":"fn","path":"objects::snapshot_members","signature":"pub fn snapshot_members ( states : & BTreeMap < ( String , String ) , String > , target : & str ) -> Vec < String >"},{"kind":"fn","path":"objects::snapshot_states","signature":"pub fn snapshot_states ( source : & dyn ObjectSource , snapshot : & str ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"objects::state_write_key","signature":"pub fn state_write_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"objects::transition_hash","signature":"pub fn transition_hash ( transition : & Transition ) -> String"},{"kind":"fn","path":"objects::transition_json","signature":"pub fn transition_json ( transition : & Transition ) -> Value"},{"kind":"fn","path":"objects::transition_write_set","signature":"pub fn transition_write_set ( source : & dyn ObjectSource , transition : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"objects::walk_events","signature":"pub fn walk_events ( source : & dyn ObjectSource , event : & str , until : Option < & str > ) -> Result < Vec < String > >"},{"kind":"const","path":"paths::MAX_PATH_LEN","signature":"pub const MAX_PATH_LEN : usize = 1024 ;"},{"kind":"const","path":"paths::MAX_SEGMENT_LEN","signature":"pub const MAX_SEGMENT_LEN : usize = 128 ;"},{"kind":"fn","path":"paths::check_canonical_path","signature":"pub fn check_canonical_path ( path : & str , code : OutcomeCode ) -> Result < ( ) >"},{"kind":"fn","path":"paths::is_under","signature":"pub fn is_under ( path : & str , prefix : & str ) -> bool"},{"kind":"fn","path":"paths::normalize_path_key","signature":"pub fn normalize_path_key ( path : & str ) -> String"},{"kind":"fn","path":"porcelain::add_note","signature":"pub fn add_note ( workspace : & Path , transition : & str , note : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::blame_path","signature":"pub fn blame_path ( workspace : & Path , path : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::cat_file","signature":"pub fn cat_file ( workspace : & Path , hash : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"porcelain::causal_order","signature":"pub fn causal_order ( store : & Store , head : & str ) -> Result < Vec < String > >"},{"kind":"fn","path":"porcelain::committed_manifest","signature":"pub fn committed_manifest ( store : & Store , target : & str ) -> Result < BTreeMap < String , String > >"},{"kind":"fn","path":"porcelain::committed_state","signature":"pub fn committed_state ( store : & Store , target : & str , module : & str ) -> Result < Option < ModuleState > >"},{"kind":"fn","path":"porcelain::cost","signature":"pub fn cost ( workspace : & Path , names : & [ String ] , files : & [ String ] ) -> Result < Value >"},{"kind":"fn","path":"porcelain::diff","signature":"pub fn diff ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::fsck_store","signature":"pub fn fsck_store ( workspace : & Path ) -> Result < ( bool , Value ) >"},{"kind":"fn","path":"porcelain::gc","signature":"pub fn gc ( workspace : & Path , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::lint_workspace","signature":"pub fn lint_workspace ( workspace : & Path , target : & str ) -> Result < ( Graph , LintReport , Value ) >"},{"kind":"fn","path":"porcelain::log","signature":"pub fn log ( workspace : & Path , module : Option < & str > ) -> Result < Value >"},{"kind":"fn","path":"porcelain::restore","signature":"pub fn restore ( workspace : & Path , target : & str , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::show","signature":"pub fn show ( workspace : & Path , hash : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::status","signature":"pub fn status ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::surface_of","signature":"pub fn surface_of ( workspace : & Path , target : & str , module : & str ) -> Result < Value >"},{"kind":"const","path":"receipt::MAX_REPORTED_BYTES","signature":"pub const MAX_REPORTED_BYTES : usize = 512 * 1024 ;"},{"kind":"const","path":"receipt::RECEIPT_VERSION","signature":"pub const RECEIPT_VERSION : u32 = 1 ;"},{"kind":"struct","path":"receipt::Receipt","signature":"pub struct Receipt"},{"kind":"field","path":"receipt::Receipt.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"receipt::Receipt.base_store_snapshot","signature":"pub base_store_snapshot : Option < String >"},{"kind":"field","path":"receipt::Receipt.base_workspace_hash","signature":"pub base_workspace_hash : String"},{"kind":"field","path":"receipt::Receipt.candidate_tree_hash","signature":"pub candidate_tree_hash : String"},{"kind":"field","path":"receipt::Receipt.evidence","signature":"pub evidence : Vec < EvidenceItem >"},{"kind":"field","path":"receipt::Receipt.input_key","signature":"pub input_key : String"},{"kind":"field","path":"receipt::Receipt.module","signature":"pub module : String"},{"kind":"field","path":"receipt::Receipt.output_manifest","signature":"pub output_manifest : BTreeMap < String , String >"},{"kind":"field","path":"receipt::Receipt.receipt_version","signature":"pub receipt_version : u32"},{"kind":"field","path":"receipt::Receipt.reported","signature":"pub reported : Value"},{"kind":"field","path":"receipt::Receipt.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"receipt::Receipt.target","signature":"pub target : String"},{"kind":"const","path":"receipt::UNAVAILABLE","signature":"pub const UNAVAILABLE : & str = \" unavailable \" ;"},{"kind":"fn","path":"receipt::normalize_reported","signature":"pub fn normalize_reported ( value : & Value ) -> Value"},{"kind":"fn","path":"receipt::parse_receipt","signature":"pub fn parse_receipt ( bytes : & [ u8 ] ) -> Result < Receipt >"},{"kind":"fn","path":"receipt::receipt_hash","signature":"pub fn receipt_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_json","signature":"pub fn receipt_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::receipt_semantic_hash","signature":"pub fn receipt_semantic_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_semantic_json","signature":"pub fn receipt_semantic_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::reported_usage","signature":"pub fn reported_usage ( receipt : & Receipt ) -> Value"},{"kind":"struct","path":"slice::CompileSlice","signature":"pub struct CompileSlice"},{"kind":"field","path":"slice::CompileSlice.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"slice::CompileSlice.body","signature":"pub body : Value"},{"kind":"field","path":"slice::CompileSlice.input_key","signature":"pub input_key : String"},{"kind":"field","path":"slice::CompileSlice.module","signature":"pub module : String"},{"kind":"field","path":"slice::CompileSlice.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"slice::CompileSlice.target","signature":"pub target : String"},{"kind":"field","path":"slice::CompileSlice.wave","signature":"pub wave : usize"},{"kind":"struct","path":"slice::DepSurface","signature":"pub struct DepSurface"},{"kind":"field","path":"slice::DepSurface.items","signature":"pub items : Vec < SurfaceItem >"},{"kind":"field","path":"slice::DepSurface.module","signature":"pub module : String"},{"kind":"field","path":"slice::DepSurface.surface_hash","signature":"pub surface_hash : String"},{"kind":"fn","path":"slice::build_slice","signature":"pub fn build_slice ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] , base : & ModuleState , base_state_key : & str , base_files : & [ ( String , String ) ] , wave : usize ) -> CompileSlice"},{"kind":"fn","path":"slice::closure_manifest","signature":"pub fn closure_manifest ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> Value"},{"kind":"fn","path":"slice::input_key","signature":"pub fn input_key ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> String"},{"kind":"fn","path":"slice::receipt_requirements","signature":"pub fn receipt_requirements ( ) -> Value"},{"kind":"fn","path":"slice::recompute_slice_hash","signature":"pub fn recompute_slice_hash ( body : & Value ) -> String"},{"kind":"const","path":"source::MAX_SOURCE_BYTES","signature":"pub const MAX_SOURCE_BYTES : usize = 4 * 1024 * 1024 ;"},{"kind":"const","path":"source::SOURCE_EXTENSION","signature":"pub const SOURCE_EXTENSION : & str = \" napl \" ;"},{"kind":"struct","path":"source::SourceModule","signature":"pub struct SourceModule"},{"kind":"field","path":"source::SourceModule.deps","signature":"pub deps : Vec < String >"},{"kind":"field","path":"source::SourceModule.module","signature":"pub module : String"},{"kind":"field","path":"source::SourceModule.normalized_body","signature":"pub normalized_body : String"},{"kind":"field","path":"source::SourceModule.normalized_source_hash","signature":"pub normalized_source_hash : String"},{"kind":"field","path":"source::SourceModule.path","signature":"pub path : String"},{"kind":"field","path":"source::SourceModule.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"source::SourceModule.targets","signature":"pub targets : Vec < String >"},{"kind":"field","path":"source::SourceModule.tests","signature":"pub tests : Vec < String >"},{"kind":"fn","path":"source::check_module_name","signature":"pub fn check_module_name ( name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"source::normalize_body","signature":"pub fn normalize_body ( raw : & str ) -> String"},{"kind":"fn","path":"source::parse_source","signature":"pub fn parse_source ( path : & str , text : & str ) -> Result < SourceModule >"},{"kind":"fn","path":"source::rewrite_deps","signature":"pub fn rewrite_deps ( text : & str , deps : & [ String ] ) -> Result < String >"},{"kind":"trait","path":"store_view::ObjectSource","signature":"pub trait ObjectSource { fn envelope ( & self , name : & str ) -> Result < Option < Vec < u8 > > > ; fn head ( & self ) -> Result < Option < String > > ; fn names ( & self ) -> Result < Vec < String > > ; }"},{"kind":"fn","path":"store_view::flatten_tree","signature":"pub fn flatten_tree ( source : & dyn ObjectSource , root : & str ) -> Result < BTreeMap < String , ( String , u32 ) > >"},{"kind":"fn","path":"store_view::get_blob","signature":"pub fn get_blob ( source : & dyn ObjectSource , name : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"store_view::get_json","signature":"pub fn get_json ( source : & dyn ObjectSource , name : & str , expected : ObjectKind ) -> Result < Value >"},{"kind":"fn","path":"store_view::get_object","signature":"pub fn get_object ( source : & dyn ObjectSource , name : & str ) -> Result < ( ObjectKind , Vec < u8 > ) >"},{"kind":"fn","path":"store_view::get_tree","signature":"pub fn get_tree ( source : & dyn ObjectSource , name : & str ) -> Result < Tree >"},{"kind":"fn","path":"store_view::has_object","signature":"pub fn has_object ( source : & dyn ObjectSource , name : & str ) -> bool"},{"kind":"fn","path":"store_view::load_tree_objects","signature":"pub fn load_tree_objects ( source : & dyn ObjectSource , root : & str , out : & mut BTreeMap < String , Tree > ) -> Result < ( ) >"},{"kind":"struct","path":"surface::SurfaceItem","signature":"pub struct SurfaceItem"},{"kind":"field","path":"surface::SurfaceItem.kind","signature":"pub kind : String"},{"kind":"field","path":"surface::SurfaceItem.path","signature":"pub path : String"},{"kind":"field","path":"surface::SurfaceItem.signature","signature":"pub signature : String"},{"kind":"fn","path":"surface::empty_surface_hash","signature":"pub fn empty_surface_hash ( module : & str , target : & str ) -> String"},{"kind":"fn","path":"surface::extract_surface","signature":"pub fn extract_surface ( target : & TargetDef , root : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"surface::surface_hash","signature":"pub fn surface_hash ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> String"},{"kind":"fn","path":"surface::surface_items_from_json","signature":"pub fn surface_items_from_json ( value : & Value ) -> Vec < SurfaceItem >"},{"kind":"fn","path":"surface::surface_json","signature":"pub fn surface_json ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> Value"},{"kind":"struct","path":"target::TargetDef","signature":"pub struct TargetDef"},{"kind":"field","path":"target::TargetDef.entry_point_version","signature":"pub entry_point_version : u32"},{"kind":"field","path":"target::TargetDef.manifest_file","signature":"pub manifest_file : String"},{"kind":"field","path":"target::TargetDef.name","signature":"pub name : String"},{"kind":"field","path":"target::TargetDef.projection_path","signature":"pub projection_path : String"},{"kind":"field","path":"target::TargetDef.source_dir","signature":"pub source_dir : String"},{"kind":"field","path":"target::TargetDef.source_extension","signature":"pub source_extension : String"},{"kind":"fn","path":"target::builtin_target","signature":"pub fn builtin_target ( name : & str ) -> Result < TargetDef >"},{"kind":"fn","path":"target::builtin_target_names","signature":"pub fn builtin_target_names ( ) -> Vec < String >"},{"kind":"fn","path":"target::derive_lib_projection","signature":"pub fn derive_lib_projection ( target : & TargetDef , root : & str , file_paths : & [ String ] , command_providers : & [ String ] ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"target::derive_projection","signature":"pub fn derive_projection ( target : & TargetDef , members : & [ String ] ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"target::is_owned_path","signature":"pub fn is_owned_path ( target : & TargetDef , root : & str , path : & str ) -> bool"},{"kind":"fn","path":"target::owned_globs","signature":"pub fn owned_globs ( target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::placement_facts","signature":"pub fn placement_facts ( target : & TargetDef , root : & str ) -> Value"},{"kind":"fn","path":"target::placement_root","signature":"pub fn placement_root ( source : & SourceModule ) -> String"},{"kind":"fn","path":"target::projection_owned_paths","signature":"pub fn projection_owned_paths ( target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::surface_grammar","signature":"pub fn surface_grammar ( target : & TargetDef ) -> Value"},{"kind":"fn","path":"target::target_def_hash","signature":"pub fn target_def_hash ( target : & TargetDef ) -> String"},{"kind":"fn","path":"target::target_def_json","signature":"pub fn target_def_json ( target : & TargetDef ) -> Value"},{"kind":"enum","path":"textdiff::Edit","signature":"pub enum Edit { Keep ( usize , usize ) , Delete ( usize ) , Insert ( usize ) }"},{"kind":"fn","path":"textdiff::diff_lines","signature":"pub fn diff_lines ( old : & [ String ] , new : & [ String ] ) -> Vec < Edit >"},{"kind":"fn","path":"textdiff::lines","signature":"pub fn lines ( text : & str ) -> Vec < String >"},{"kind":"fn","path":"textdiff::render_drift","signature":"pub fn render_drift ( committed : & BTreeMap < String , String > , working : & BTreeMap < String , String > ) -> String"},{"kind":"fn","path":"textdiff::render_patch","signature":"pub fn render_patch ( path : & str , old : & str , new : & str ) -> String"},{"kind":"struct","path":"transaction::Accepted","signature":"pub struct Accepted"},{"kind":"field","path":"transaction::Accepted.event","signature":"pub event : String"},{"kind":"field","path":"transaction::Accepted.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::Accepted.projection_bytes","signature":"pub projection_bytes : Vec < u8 >"},{"kind":"field","path":"transaction::Accepted.projection_path","signature":"pub projection_path : String"},{"kind":"field","path":"transaction::Accepted.rebased","signature":"pub rebased : bool"},{"kind":"field","path":"transaction::Accepted.snapshot","signature":"pub snapshot : String"},{"kind":"field","path":"transaction::Accepted.steps","signature":"pub steps : Vec < InstallStep >"},{"kind":"struct","path":"transaction::DerivedCommit","signature":"pub struct DerivedCommit"},{"kind":"field","path":"transaction::DerivedCommit.evidence","signature":"pub evidence : Value"},{"kind":"field","path":"transaction::DerivedCommit.new_state_key","signature":"pub new_state_key : String"},{"kind":"field","path":"transaction::DerivedCommit.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::DerivedCommit.receipt_object","signature":"pub receipt_object : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_items","signature":"pub surface_items : Vec < SurfaceItem >"},{"kind":"field","path":"transaction::DerivedCommit.transition","signature":"pub transition : Transition"},{"kind":"field","path":"transaction::DerivedCommit.transition_id","signature":"pub transition_id : String"},{"kind":"struct","path":"transaction::PendingObject","signature":"pub struct PendingObject"},{"kind":"field","path":"transaction::PendingObject.kind","signature":"pub kind : ObjectKind"},{"kind":"field","path":"transaction::PendingObject.name","signature":"pub name : String"},{"kind":"field","path":"transaction::PendingObject.payload","signature":"pub payload : Vec < u8 >"},{"kind":"fn","path":"transaction::accept","signature":"pub fn accept ( source : & dyn ObjectSource , target : & TargetDef , root : & str , candidate : & Candidate , derived : & DerivedCommit , head : Option < & str > , base_snapshot : Option < & str > ) -> Result < Accepted >"},{"kind":"fn","path":"transaction::check_candidate_shape","signature":"pub fn check_candidate_shape ( target : & TargetDef , root : & str , candidate : & Candidate ) -> Result < ( ) >"},{"kind":"fn","path":"transaction::check_manifest_identity","signature":"pub fn check_manifest_identity ( source : & dyn ObjectSource , target : & TargetDef , root : & str , module : & str , candidate : & Candidate ) -> Result < ( ) >"},{"kind":"fn","path":"transaction::derive_commit","signature":"pub fn derive_commit ( target : & TargetDef , root : & str , receipt : & Receipt , candidate : & Candidate ) -> Result < DerivedCommit >"},{"kind":"fn","path":"transaction::intervening_write_set","signature":"pub fn intervening_write_set ( source : & dyn ObjectSource , head : Option < & str > , base : Option < & str > ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"transaction::success_payload","signature":"pub fn success_payload ( derived : & DerivedCommit , accepted : & Accepted ) -> Value"},{"kind":"fn","path":"transaction::verify_receipt","signature":"pub fn verify_receipt ( source : & dyn ObjectSource , sources : & [ SourceModule ] , receipt : & Receipt , candidate : & Candidate ) -> Result < CompileSlice >"},{"kind":"fn","path":"trees::empty_tree_hash","signature":"pub fn empty_tree_hash ( ) -> String"},{"kind":"fn","path":"trees::tree_of","signature":"pub fn tree_of ( manifest : & BTreeMap < String , String > ) -> Result < BuiltTree >"},{"kind":"const","path":"version::CONTRACT_VERSION","signature":"pub const CONTRACT_VERSION : u32 = 1 ;"},{"kind":"fn","path":"workspace::apply_step","signature":"pub fn apply_step ( root : & Path , step : & InstallStep , payload : & dyn Fn ( & str ) -> Result < Vec < u8 > > ) -> Result < ( ) >"},{"kind":"fn","path":"workspace::discover_sources","signature":"pub fn discover_sources ( root : & Path ) -> Result < Vec < SourceModule > >"},{"kind":"fn","path":"workspace::find_workspace_root","signature":"pub fn find_workspace_root ( start : & Path ) -> Option < PathBuf >"},{"kind":"fn","path":"workspace::read_working_text","signature":"pub fn read_working_text ( root : & Path , paths : & [ String ] ) -> BTreeMap < String , String >"},{"kind":"fn","path":"workspace::require_workspace_root","signature":"pub fn require_workspace_root ( start : & Path ) -> Result < PathBuf >"},{"kind":"fn","path":"workspace::scan_candidate","signature":"pub fn scan_candidate ( dir : & Path ) -> Result < Vec < ScannedEntry > >"},{"kind":"fn","path":"workspace::step_path","signature":"pub fn step_path ( step : & InstallStep ) -> & str"},{"kind":"fn","path":"workspace::write_file","signature":"pub fn write_file ( path : & Path , bytes : & [ u8 ] ) -> Result < ( ) >"}],"module":"napl","target":"rust"} +napl-store blob v1 27297 +//! `napl edit` and `napl write`: the deterministic capture layer in front of the +//! same guarded door. +//! +//! The verbs carry the familiar tool signatures and add capture, never +//! exclusivity: either side is editable, a prompt or a generated file, by a +//! human or an LLM, and a hand edit through an ordinary editor stays equally +//! legal. Nothing here synthesizes or rewrites prose beyond the bytes given, +//! invokes a model, decides placement, repairs a reference, or bypasses the +//! guard. `add` and `commit` remain exactly what they are; a reference-bearing +//! call goes through them. +//! +//! Each call is validate, apply, capture, in that order. Only a call that will +//! succeed touches the working tree, so every refusal leaves every file +//! byte-identical, and a refusal payload says so. +//! +//! The verbs read no clock. Model identity and timestamp come from the session +//! window's log lines or read `unavailable`, so the same call over the same +//! working tree, store state, and log bytes produces byte-identical records. -::: test -name: a gen event carrying an attempt id and diagnostics names itself by its pinned digest -given: { fixture: EV_GEN_INT } -expect: { object_name: "17f796f1f623dc65d2f596336befb1e062c619ca1d6f87aa3e76245c5e37420e" } -::: +use std::io::Read as _; +use std::path::{Path, PathBuf}; -::: test -name: a gen event carrying an attempt id and diagnostics frames its pinned envelope bytes -given: { fixture: EV_GEN_INT } -expect: { envelope_bytes: "napl-store event v1 614\n{\"attemptId\":\"att-0001\",\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"diagnostics\":[\"3333333333333333333333333333333333333333333333333333333333333333\"],\"mode\":\"full\",\"outcome\":\"interrupted\",\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } -::: +use serde_json::{json, Value}; -::: test -name: a gen event carrying an attempt id and diagnostics round-trips back to an equal value -given: { fixture: EV_GEN_INT } -expect: { round_trip_equal: true } -::: +use crate::attribution::{self, Attribution, Direction, Entry, Provenance, UsageRef, UsageWindow}; +use crate::canon::canonical_bytes; +use crate::cli::{flag, has_switch, positional, require_flag, resolve_workspace, CliResult}; +use crate::commit::AttributionMode; +use crate::disk; +use crate::error::{error, CompilerError, OutcomeCode, Result}; +use crate::paths::is_under; +use crate::source::{parse_source, SourceModule}; +use crate::store_view::get_blob; +use crate::target::{builtin_target, is_owned_path, placement_root, TargetDef}; +use crate::workspace::{discover_sources, write_file}; -- **DERIV_BOTH**: an `Event` whose payload is a `Derivation` carrying both the - pre-existing singular `diagnostic` and the new plural `diagnostics`, proving the - two coexist independently. Its `attempt_id` is `Some("att-0002")`; `parents` = - `[H1]`, `module` = `greeting`, `target` = `None`, `ordinal` = 6, `timestamp` = - `2026-07-25T00:00:00Z`, `engine` = `opus`; `state_before` = `Some(State(H2))` and - `state_after` = `Some(State(H2))`, equal per the non-advancing rule; and payload - `Derivation` with `derivation` = `mapl`, `outcome` = `Interrupted`, `attempts` = - 1, `error` = the empty string, `diagnostic` = `Some(H5)`, and `diagnostics` = - `vec![H6, H7]`. The three hashes `H5`, `H6`, and `H7` are pairwise distinct, so - the singular `diagnostic` and each entry of the plural `diagnostics` are provably - different values. Its canonical bytes are exactly - `{"attemptId":"att-0002","engine":"opus","module":"greeting","ordinal":6,"parents":["

"],"payload":{"fields":{"attempts":1,"derivation":"mapl","diagnostic":"

","diagnostics":["
",""],"error":"","outcome":"interrupted"},"kind":"derivation"},"stateAfter":"

","stateBefore":"

","timestamp":"2026-07-25T00:00:00Z"}` - followed by one newline; its envelope is `napl-store event v1 689\n` followed by - those canonical bytes; and its object name is - `2ac016d8ff0d0cd4f01ab9b383989ca448665cfc9fe14299d0d08e61a93cf56b`. +/// The usage-proxy log, workspace-relative. A recorded window names this path +/// rather than an absolute one, so a record is the same on any machine. +pub const USAGE_LOG_PATH: &str = ".napl/usage-proxy.jsonl"; -::: test -name: a derivation event carrying both a singular diagnostic and the diagnostics list canonicalizes to its pinned bytes -given: { fixture: DERIV_BOTH } -expect: { canonical_bytes: "{\"attemptId\":\"att-0002\",\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":6,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"derivation\":\"mapl\",\"diagnostic\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"diagnostics\":[\"6666666666666666666666666666666666666666666666666666666666666666\",\"7777777777777777777777777777777777777777777777777777777777777777\"],\"error\":\"\",\"outcome\":\"interrupted\"},\"kind\":\"derivation\"},\"stateAfter\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } -::: +/// The media type of that log, as the usage-proxy prompt names it. +pub const USAGE_LOG_MEDIA_TYPE: &str = "application/x-napl-usage-proxy-jsonl-v1"; -::: test -name: a derivation event carrying both diagnostic fields names itself by its pinned digest -given: { fixture: DERIV_BOTH } -expect: { object_name: "2ac016d8ff0d0cd4f01ab9b383989ca448665cfc9fe14299d0d08e61a93cf56b" } -::: +/// The environment variable naming the session window, injected by `napl agent` +/// into every child it wraps. `--session` overrides it. +pub const SESSION_VARIABLE: &str = "NAPL_SESSION"; -::: test -name: a derivation event carrying both diagnostic fields round-trips back to an equal value preserving each independently -given: { fixture: DERIV_BOTH } -expect: { round_trip_equal: true } -::: +/// What a verb will write, once every validation has passed. +struct Application { + /// The target's workspace-relative path. + path: String, + bytes: Vec, +} -- **EV_GEN_OPPRES**: an `Event` whose payload is a `Gen` whose `diagnostics` list - carries a single verbatim non-hash operation token, proving the plural - `diagnostics` is not subjected to the hash check. Its `attempt_id` is - `Some("att-0003")`; `parents` = `[H1]`, `module` = `greeting`, `target` = - `Some("rust")`, `ordinal` = 7, `timestamp` = `2026-07-25T00:00:00Z`, `engine` = - `opus`; `state_before` = `Some(State(H2))` and `state_after` = `Some(State(H2))`, - equal per the non-advancing rule; and payload `Gen` with `mode` = `Full`, - `outcome` = `Interrupted`, `attempts` = 1, `taskBundle` = `H4`, and `diagnostics` - = `vec!["vendor-sync".to_string()]`, the literal non-hash string `vendor-sync`. - Its canonical bytes are exactly - `{"attemptId":"att-0003","engine":"opus","module":"greeting","ordinal":7,"parents":["

"],"payload":{"fields":{"attempts":1,"diagnostics":["vendor-sync"],"mode":"full","outcome":"interrupted","taskBundle":"

"},"kind":"gen"},"stateAfter":"

","stateBefore":"

","target":"rust","timestamp":"2026-07-25T00:00:00Z"}` - followed by one newline; its envelope is `napl-store event v1 561\n` followed by - those canonical bytes; and its object name is - `d894b56fc66567fac5290b7ffda3ccddcad0295741a459de417510dc824ac474`. - `validate_event` accepts it, and the non-hash token `vendor-sync` survives the - round trip unchanged. +pub(crate) fn run_edit(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + let given = positional(args, 1)?; + let old = require_flag(args, "--old-string")?; + let new = require_flag(args, "--new-string")?; + let replace_all = has_switch(args, "--replace-all"); + let path = match relative_target(&workspace, cwd, &given) { + Ok(path) => path, + Err(err) => return Ok(refusal(&err)), + }; + let current = match std::fs::read(workspace.join(&path)) { + Ok(bytes) => bytes, + Err(e) => return Ok(refusal(&disk::io(&workspace.join(&path), &e))), + }; + let replaced = match replace_exact(¤t, old.as_bytes(), new.as_bytes(), replace_all) { + Ok(bytes) => bytes, + Err(err) => return Ok(refusal(&err)), + }; + apply_and_capture(&workspace, args, Application { path, bytes: replaced }) +} -::: test -name: a gen event whose diagnostics carries a verbatim non-hash operation token canonicalizes to its pinned bytes -given: { fixture: EV_GEN_OPPRES } -expect: { canonical_bytes: "{\"attemptId\":\"att-0003\",\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":7,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"diagnostics\":[\"vendor-sync\"],\"mode\":\"full\",\"outcome\":\"interrupted\",\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } -::: +pub(crate) fn run_write(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + let given = positional(args, 1)?; + let path = match relative_target(&workspace, cwd, &given) { + Ok(path) => path, + Err(err) => return Ok(refusal(&err)), + }; + let mut bytes: Vec = Vec::new(); + if std::io::stdin().read_to_end(&mut bytes).is_err() { + return Ok(refusal(&error( + OutcomeCode::IoError, + "napl write reads the file's full content from stdin, which could not be read", + ))); + } + apply_and_capture(&workspace, args, Application { path, bytes }) +} -::: test -name: a gen event whose diagnostics carries a verbatim non-hash operation token is accepted and never hash-checked -given: { fixture: EV_GEN_OPPRES } -expect: { accepts: true } -::: +/// Write a file's full content, as `napl write` does, from a caller that already +/// holds the bytes. The one seam a test or an embedding driver uses instead of +/// stdin; every rule below is the same. +pub fn write_bytes(workspace: &Path, path: &str, bytes: &[u8], reference: Option<&str>, session: Option<&str>) -> Result { + let application = Application { + path: path.to_string(), + bytes: bytes.to_vec(), + }; + capture(workspace, application, reference, session) +} -- **EV_GEN_PROV**: the `EV_GEN` event with its `Gen` payload's `provenance` set to - `Some` of a `Provenance` whose `model` = `claude-opus-4-8`, `tokens` = - `TokenUsage { input: 1000, output: 500, cache_read: None, cache_write: None }`, - `estimated_cost_usd` = `Some("0.0525")`, `wall_ms` = `12345`, and - `price_table_version` = `1`. This is exactly the record - `provenance_from_usage("claude-opus-4-8", TokenUsage { input: 1000, output: 500, - cache_read: None, cache_write: None }, 12345)` produces, because - `estimate_cost("claude-opus-4-8", &tokens)` over input `1000` and output `500` at - the opus rate is `0.0525`. Its canonical bytes are exactly - `{"engine":"opus","module":"greeting","ordinal":5,"parents":["

"],"payload":{"fields":{"attempts":1,"mode":"full","outcome":"ok","provenance":{"estimatedCostUsd":"0.0525","model":"claude-opus-4-8","priceTableVersion":1,"tokens":{"input":1000,"output":500},"wallMs":12345},"taskBundle":"

"},"kind":"gen"},"stateAfter":"

","stateBefore":"

","target":"rust","timestamp":"2026-07-25T00:00:00Z"}` - followed by one newline, a payload of six hundred forty-two bytes; its envelope - is `napl-store event v1 642\n` followed by those canonical bytes; its object name - is `ba63bb11bc2e25378f533be8f8b5a019e12418f97c5050c068e28af75b391032`. +/// Replace an exact occurrence, as `napl edit` does, from a caller that already +/// holds the strings. +pub fn edit_bytes( + workspace: &Path, + path: &str, + old: &str, + new: &str, + replace_all: bool, + reference: Option<&str>, + session: Option<&str>, +) -> Result { + let current = std::fs::read(workspace.join(path)) + .map_err(|e| disk::io(&workspace.join(path), &e))?; + let replaced = match replace_exact(¤t, old.as_bytes(), new.as_bytes(), replace_all) { + Ok(bytes) => bytes, + Err(err) => return Ok(refusal(&err)), + }; + capture( + workspace, + Application { + path: path.to_string(), + bytes: replaced, + }, + reference, + session, + ) +} -::: test -name: a gen event carrying provenance canonicalizes to its pinned bytes -given: { fixture: EV_GEN_PROV } -expect: { canonical_bytes: "{\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"mode\":\"full\",\"outcome\":\"ok\",\"provenance\":{\"estimatedCostUsd\":\"0.0525\",\"model\":\"claude-opus-4-8\",\"priceTableVersion\":1,\"tokens\":{\"input\":1000,\"output\":500},\"wallMs\":12345},\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } -::: +fn apply_and_capture( + workspace: &Path, + args: &[String], + application: Application, +) -> Result { + let reference = flag(args, "--ref")?; + let session = flag(args, "--session")?; + capture( + workspace, + application, + reference.as_deref(), + session.as_deref(), + ) +} -::: test -name: a gen event carrying provenance names itself by its pinned digest -given: { fixture: EV_GEN_PROV } -expect: { object_name: "ba63bb11bc2e25378f533be8f8b5a019e12418f97c5050c068e28af75b391032" } -::: +/// Validate, apply, capture. +fn capture( + workspace: &Path, + application: Application, + reference: Option<&str>, + session: Option<&str>, +) -> Result { + let sources = discover_sources(workspace)?; -::: test -name: a gen event carrying provenance frames its pinned envelope bytes -given: { fixture: EV_GEN_PROV } -expect: { envelope_bytes: "napl-store event v1 642\n{\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"mode\":\"full\",\"outcome\":\"ok\",\"provenance\":{\"estimatedCostUsd\":\"0.0525\",\"model\":\"claude-opus-4-8\",\"priceTableVersion\":1,\"tokens\":{\"input\":1000,\"output\":500},\"wallMs\":12345},\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } -::: + // A prompt target's RESULT is validated first, by the same reader `check` and + // `add` use. A result that fails refuses and applies nothing. + if is_prompt(&application.path) { + if let Err(err) = validate_prompt(&application.path, &application.bytes) { + return Ok(refusal(&err)); + } + } -::: test -name: a gen event carrying provenance round-trips through the read verifier -given: { fixture: EV_GEN_PROV } -expect: { verify_ok: true, kind: event } -::: + // The reference is validated against the bytes that will exist after the + // edit, which is the content it is a claim about. It is validated + // referentially only, exactly as strong as attribution validation. + let claim = match reference { + None => None, + Some(text) => match parse_reference(text) { + Ok(claim) => Some(claim), + Err(err) => return Ok(refusal(&err)), + }, + }; + let resolved = match &claim { + None => None, + Some(claim) => match resolve_reference(workspace, &sources, &application, claim) { + Ok(resolved) => Some(resolved), + Err(err) => return Ok(refusal(&err)), + }, + }; -::: test -name: a gen event carrying provenance round-trips back to an equal value -given: { fixture: EV_GEN_PROV } -expect: { round_trip_equal: true } -::: + // Every validation passed, so the working tree may be touched. + write_file(&workspace.join(&application.path), &application.bytes)?; -- **EV_GEN_PROV_CACHE**: the `EV_GEN` event with its `Gen` payload's `provenance` - set to `Some` of a `Provenance` whose `model` = `claude-opus-4-8`, `tokens` = - `TokenUsage { input: 1000, output: 500, cache_read: Some(2000), cache_write: - Some(100) }`, `estimated_cost_usd` = `Some("0.084")`, `wall_ms` = `12345`, and - `price_table_version` = `1`. The cost `0.084` is what `estimate_cost` produces by - folding the `2000` cache-read and `100` cache-write tokens into the input rate - alongside the `1000` input tokens. Its canonical bytes are exactly - `{"engine":"opus","module":"greeting","ordinal":5,"parents":["

"],"payload":{"fields":{"attempts":1,"mode":"full","outcome":"ok","provenance":{"estimatedCostUsd":"0.084","model":"claude-opus-4-8","priceTableVersion":1,"tokens":{"cacheRead":2000,"cacheWrite":100,"input":1000,"output":500},"wallMs":12345},"taskBundle":"

"},"kind":"gen"},"stateAfter":"

","stateBefore":"

","target":"rust","timestamp":"2026-07-25T00:00:00Z"}` - followed by one newline, a payload of six hundred seventy-five bytes; its object - name is `e1588ae3e90973e666629e4ec3873ed095a41df9806b9e229b16471c9acad3be`. + let Some(resolved) = resolved else { + // A call without a reference is a plain edit: apply, no record, no store + // touch. Any existing reference whose endpoint this moved needs no + // marking, because both-ends staleness is computed at read time. + return Ok(ok_payload(json!({ + "applied": true, + "contractVersion": crate::version::CONTRACT_VERSION, + "outcome": "applied", + "path": application.path, + "recorded": false, + "semanticStatePublished": false, + }))); + }; -::: test -name: a gen event whose provenance carries cache token counts canonicalizes to its pinned bytes -given: { fixture: EV_GEN_PROV_CACHE } -expect: { canonical_bytes: "{\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"mode\":\"full\",\"outcome\":\"ok\",\"provenance\":{\"estimatedCostUsd\":\"0.084\",\"model\":\"claude-opus-4-8\",\"priceTableVersion\":1,\"tokens\":{\"cacheRead\":2000,\"cacheWrite\":100,\"input\":1000,\"output\":500},\"wallMs\":12345},\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } -::: + record_reference(workspace, &resolved, session) +} -::: test -name: a gen event whose provenance carries cache token counts round-trips back to an equal value -given: { fixture: EV_GEN_PROV_CACHE } -expect: { round_trip_equal: true } -::: +// --------------------------------------------------------------- the edit rule -- **EV_GEN_PROV_UNKNOWN**: the `EV_GEN` event with its `Gen` payload's `provenance` - set to `Some` of a `Provenance` whose `model` = `gpt-4o`, `tokens` = - `TokenUsage { input: 1000, output: 500, cache_read: None, cache_write: None }`, - `estimated_cost_usd` = `None`, `wall_ms` = `12345`, and `price_table_version` = - `1`. Because `gpt-4o` is not in the price table, `estimate_cost` returns `None` - and the record carries no `estimatedCostUsd`, yet still carries its tokens and - its `priceTableVersion`. Its canonical bytes are exactly - `{"engine":"opus","module":"greeting","ordinal":5,"parents":["

"],"payload":{"fields":{"attempts":1,"mode":"full","outcome":"ok","provenance":{"model":"gpt-4o","priceTableVersion":1,"tokens":{"input":1000,"output":500},"wallMs":12345},"taskBundle":"

"},"kind":"gen"},"stateAfter":"

","stateBefore":"

","target":"rust","timestamp":"2026-07-25T00:00:00Z"}` - followed by one newline, a payload of six hundred five bytes; its object name is - `6557d08b593fff22a1cbb29f5bdd7da893e42d87e42c3290043976668a00bbdf`. +/// Replace an exact occurrence, or refuse. +/// +/// An `--old-string` that matches nowhere, matches more than once without +/// `--replace-all`, or equals `--new-string` is `edit-mismatch`, so a no-op edit +/// can never mint a record. +fn replace_exact( + current: &[u8], + old: &[u8], + new: &[u8], + replace_all: bool, +) -> Result> { + if old == new { + return Err(error( + OutcomeCode::EditMismatch, + "--old-string equals --new-string, so the edit is a no-op", + )); + } + let hits = occurrences(current, old); + if hits.is_empty() { + return Err(error( + OutcomeCode::EditMismatch, + "--old-string matches nowhere in the file", + )); + } + if hits.len() > 1 && !replace_all { + return Err(error( + OutcomeCode::EditMismatch, + format!( + "--old-string matches {} times; pass --replace-all to replace every occurrence", + hits.len() + ), + )); + } + let chosen = if replace_all { &hits[..] } else { &hits[..1] }; + let mut out: Vec = Vec::with_capacity(current.len()); + let mut cursor = 0usize; + for start in chosen { + out.extend_from_slice(¤t[cursor..*start]); + out.extend_from_slice(new); + cursor = start + old.len(); + } + out.extend_from_slice(¤t[cursor..]); + Ok(out) +} -::: test -name: a gen event whose provenance names an unknown model canonicalizes to its pinned bytes without a cost -given: { fixture: EV_GEN_PROV_UNKNOWN } -expect: { canonical_bytes: "{\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"mode\":\"full\",\"outcome\":\"ok\",\"provenance\":{\"model\":\"gpt-4o\",\"priceTableVersion\":1,\"tokens\":{\"input\":1000,\"output\":500},\"wallMs\":12345},\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } -::: +/// The start offsets of every non-overlapping occurrence, left to right. An +/// empty needle occurs nowhere: it would otherwise "match" at every boundary and +/// make the count meaningless. +fn occurrences(haystack: &[u8], needle: &[u8]) -> Vec { + let mut out: Vec = Vec::new(); + if needle.is_empty() || needle.len() > haystack.len() { + return out; + } + let mut index = 0usize; + while index + needle.len() <= haystack.len() { + if &haystack[index..index + needle.len()] == needle { + out.push(index); + index += needle.len(); + } else { + index += 1; + } + } + out +} -::: test -name: a gen event whose provenance names an unknown model names itself by its pinned digest -given: { fixture: EV_GEN_PROV_UNKNOWN } -expect: { object_name: "6557d08b593fff22a1cbb29f5bdd7da893e42d87e42c3290043976668a00bbdf" } -::: +// ------------------------------------------------------------- prompt validity -::: test -name: a gen event whose provenance names an unknown model omits the estimatedCostUsd key from its canonical bytes -given: { fixture: EV_GEN_PROV_UNKNOWN } -expect: { has_estimated_cost_key: false } -::: +fn is_prompt(path: &str) -> bool { + path.ends_with(&format!(".{}", crate::source::SOURCE_EXTENSION)) +} -::: test -name: a gen event whose provenance names an unknown model round-trips back to an equal value -given: { fixture: EV_GEN_PROV_UNKNOWN } -expect: { round_trip_equal: true } -::: +/// Validate a prompt target's result with the same reader `check` and `add` use. +fn validate_prompt(path: &str, bytes: &[u8]) -> Result<()> { + let text = std::str::from_utf8(bytes).map_err(|_| { + error( + OutcomeCode::PromptInvalid, + format!("the result for {path:?} is not valid UTF-8"), + ) + })?; + parse_source(path, text).map(|_| ()).map_err(|err| { + error( + OutcomeCode::PromptInvalid, + format!("the result for {path:?} does not parse: {}", err.detail), + ) + }) +} -- **EV_GEN_USAGE**: the `EV_GEN` event with its `Gen` payload's `usage` set to a - two-entry `Vec` proving the per-attempt log round-trips and carries - burned attempts. The first entry is a burned attempt: `attempt` = 1, `outcome` = - `tests-failed`, `requested_model` = `sonnet`, `resolved_model` = - `claude-sonnet-4-5`, `source` = `frontmatter`, `engine` = `claude`, `tokens` = - `TokenUsage { input: 1000, output: 500, cache_read: None, cache_write: None }`, - `cost_usd` = `Some("0.0525")`, `duration_ms` = `12345`, `num_turns` = 2. The - second entry is the winning attempt: `attempt` = 2, `outcome` = `ok`, - `requested_model` = `sonnet`, `resolved_model` = `claude-sonnet-4-5`, `source` = - `frontmatter`, `engine` = `claude`, `tokens` = `TokenUsage { input: 2000, output: - 800, cache_read: Some(4000), cache_write: Some(200) }`, `cost_usd` = - `Some("0.12")`, `duration_ms` = `20000`, `num_turns` = 3. Its `provenance` stays - `None`. This fixture is pinned by structure only: because its object name depends - on the content hash, no object-name or envelope-length pin is given here, and its - tests assert round-trip equality, that it verifies as an event, that the `usage` - key is present in its canonical bytes while absent from `EV_GEN`'s, and that a - usage entry or its nested `tokens` object carrying a stray key is rejected as - `Malformed`. Within each usage entry the canonical encoder sorts the keys as - `attempt`, `costUsd`, `durationMs`, `engine`, `numTurns`, `outcome`, - `requestedModel`, `resolvedModel`, `source`, `tokens`, and the `usage` key sorts - last among the gen `fields` keys, after `taskBundle`. +// ------------------------------------------------------------- the reference -::: test -name: a gen event carrying a per-attempt usage log round-trips back to an equal value -given: { fixture: EV_GEN_USAGE } -expect: { round_trip_equal: true } -::: +/// A reference exactly as a caller spells it: a direction and both endpoints. +struct ReferenceClaim { + direction: Direction, + module: String, + file: String, + line_start: u32, + line_end: u32, + prompt_start: u32, + prompt_end: u32, + note: String, +} -::: test -name: a gen event carrying a per-attempt usage log verifies as an event through the read verifier -given: { fixture: EV_GEN_USAGE } -expect: { verify_ok: true, kind: event } -::: +/// A reference proven against the post-edit bytes, with the module it belongs to. +struct ResolvedReference { + module: String, + target: TargetDef, + entry: Entry, +} -- **EV_DERIV_PROV**: an `Event` with `parents` = `[H1]`, `module` = `greeting`, - `target` = `Some("rust")`, `ordinal` = 6, `timestamp` = `2026-07-25T00:00:00Z`, - `engine` = `opus`, `state_before` = `Some(State(H2))`, `state_after` = - `Some(State(H3))`, and payload `Derivation` with `derivation` = `mapl`, `outcome` - = `Ok`, `attempts` = 1, `error` = the empty string, `diagnostic` = `None`, and - `provenance` = `Some` of a `Provenance` whose `model` = `claude-sonnet-4-5`, - `tokens` = `TokenUsage { input: 2000, output: 800, cache_read: None, cache_write: - None }`, `estimated_cost_usd` = `Some("0.018")`, `wall_ms` = `4096`, and - `price_table_version` = `1`. The cost `0.018` is what `estimate_cost` produces - over input `2000` and output `800` at the sonnet rate. Its object name is - `d084c97dd4de38f5974f5f8dabfb63e060378b872d6e3483780209b115f95bce`. - -::: test -name: a derivation event carrying provenance names itself by its pinned digest -given: { fixture: EV_DERIV_PROV } -expect: { object_name: "d084c97dd4de38f5974f5f8dabfb63e060378b872d6e3483780209b115f95bce" } -::: +fn parse_reference(text: &str) -> Result { + let bad = |why: String| error(OutcomeCode::ReferenceUnresolvable, why); + let value: Value = serde_json::from_str(text) + .map_err(|e| bad(format!("--ref is not JSON: {e}")))?; + if !value.is_object() { + return Err(bad("--ref must be a single JSON object".to_string())); + } + let text_field = |name: &str| -> Result { + value[name] + .as_str() + .map(str::to_string) + .ok_or_else(|| bad(format!("--ref has no {name} string"))) + }; + let pair = |name: &str| -> Result<(u32, u32)> { + let entries = value[name] + .as_array() + .ok_or_else(|| bad(format!("--ref has no {name} pair")))?; + if entries.len() != 2 { + return Err(bad(format!("--ref {name} is not a pair of line numbers"))); + } + let numbers: Vec = entries + .iter() + .filter_map(|entry| entry.as_u64()) + .filter_map(|entry| u32::try_from(entry).ok()) + .collect(); + if numbers.len() != 2 { + return Err(bad(format!("--ref {name} is not a pair of line numbers"))); + } + Ok((numbers[0].min(numbers[1]), numbers[0].max(numbers[1]))) + }; + let direction = match value["direction"].as_str() { + Some("code_to_prompt") => Direction::CodeToPrompt, + Some("prompt_to_code") => Direction::PromptToCode, + Some(other) => { + return Err(bad(format!( + "--ref names direction {other:?}, which the reference contract does not have" + ))) + } + None => return Err(bad("--ref has no direction".to_string())), + }; + let (line_start, line_end) = pair("lines")?; + let (prompt_start, prompt_end) = pair("promptLines")?; + Ok(ReferenceClaim { + direction, + module: text_field("module")?, + file: text_field("file")?, + line_start, + line_end, + prompt_start, + prompt_end, + note: value["note"].as_str().unwrap_or_default().to_string(), + }) +} -::: test -name: a derivation event carrying provenance round-trips back to an equal value -given: { fixture: EV_DERIV_PROV } -expect: { round_trip_equal: true } -::: +/// Prove a reference referentially, and no more: the file path inside the owning +/// module's placement, both line ranges present in the post-edit bytes of their +/// files. Nothing semantic is examined, so the record never implies the sentence +/// describes the code. +fn resolve_reference( + workspace: &Path, + sources: &[SourceModule], + application: &Application, + claim: &ReferenceClaim, +) -> Result { + let bad = |why: String| error(OutcomeCode::ReferenceUnresolvable, why); + let source = sources + .iter() + .find(|source| source.module == claim.module) + .ok_or_else(|| { + bad(format!( + "--ref names module {:?}, which has no prompt file in the workspace", + claim.module + )) + })?; + let target = builtin_target("rust")?; + let root = placement_root(source); + if !is_under(&claim.file, &root) || !is_owned_path(&target, &root, &claim.file) { + return Err(bad(format!( + "--ref names {:?}, which is outside the placement of module {:?}", + claim.file, claim.module + ))); + } -- **SNAP**: a `Snapshot` with `parents` = `[H6]`, `seq` = 5, and one root entry - `greeting` whose `RootEntry` has `moduleState` = `H7` and `eventHead` = `H8`. - Its object name is - `cb82801a441f25b1a80aa310fe3d4b15c0c64cafff6b0be799c53d22649a1f64`. + // The prompt endpoint is judged against the prompt as it will be, which is + // the edited bytes when the edit is the prompt's own. + let body = if application.path == source.path { + let text = std::str::from_utf8(&application.bytes).map_err(|_| { + bad(format!("the result for {:?} is not valid UTF-8", source.path)) + })?; + parse_source(&source.path, text)?.normalized_body + } else { + source.normalized_body.clone() + }; + let body_lines = body.lines().count() as u32; + check_range( + claim.prompt_start, + claim.prompt_end, + body_lines, + &format!("the normalized body of {:?}", source.path), + )?; -::: test -name: a snapshot canonicalizes to its pinned bytes -given: { fixture: SNAP } -expect: { canonical_bytes: "{\"parents\":[\"6666666666666666666666666666666666666666666666666666666666666666\"],\"root\":{\"greeting\":{\"eventHead\":\"8888888888888888888888888888888888888888888888888888888888888888\",\"moduleState\":\"7777777777777777777777777777777777777777777777777777777777777777\"}},\"seq\":5}\n" } -::: + // The code endpoint is judged the same way, against post-edit bytes. + let code_bytes = if application.path == claim.file { + application.bytes.clone() + } else { + std::fs::read(workspace.join(&claim.file)).map_err(|_| { + bad(format!( + "--ref names {:?}, which the working tree does not contain", + claim.file + )) + })? + }; + let code_lines = String::from_utf8_lossy(&code_bytes).lines().count() as u32; + check_range( + claim.line_start, + claim.line_end, + code_lines, + &format!("the post-edit bytes of {:?}", claim.file), + )?; -::: test -name: a snapshot names itself by its pinned digest -given: { fixture: SNAP } -expect: { object_name: "cb82801a441f25b1a80aa310fe3d4b15c0c64cafff6b0be799c53d22649a1f64" } -::: + Ok(ResolvedReference { + module: claim.module.clone(), + target, + entry: Entry { + prompt_start: claim.prompt_start, + prompt_end: claim.prompt_end, + file: Some(claim.file.clone()), + line_start: claim.line_start, + line_end: claim.line_end, + note: claim.note.clone(), + direction: claim.direction, + provenance: Provenance::default(), + }, + }) +} -::: test -name: a snapshot round-trips through the read verifier -given: { fixture: SNAP } -expect: { verify_ok: true, kind: snapshot } -::: +fn check_range(start: u32, end: u32, available: u32, what: &str) -> Result<()> { + let bad = |why: String| error(OutcomeCode::ReferenceUnresolvable, why); + if start == 0 || start > end { + return Err(bad(format!("--ref names the empty range {start}-{end}"))); + } + if end > available { + return Err(bad(format!( + "--ref names lines {start}-{end} but {what} has {available} lines" + ))); + } + Ok(()) +} -::: test -name: a two-parent merge snapshot round-trips back to an equal value -given: { snapshot: "parents [H6, H7], seq 6, one root entry greeting" } -expect: { round_trip_equal: true } -::: +// ------------------------------------------------------------ the capture step -## Test and code discipline +/// Stage a reference-only record and commit it through the guarded transaction, +/// in the same invocation. +/// +/// The candidate is the module's own committed output, byte for byte, so the +/// transition carries the reference entry and its provenance with no output-tree +/// change and no surface change. Every rule of the decision table, journaled +/// atomicity, and recovery applies to it unchanged. +fn record_reference( + workspace: &Path, + resolved: &ResolvedReference, + session: Option<&str>, +) -> Result { + let store = disk::init(workspace)?; + let target = resolved.target.name.clone(); + let state = crate::porcelain::committed_state(&store, &target, &resolved.module)? + .ok_or_else(|| { + error( + OutcomeCode::ReferenceUnresolvable, + format!( + "module {:?} has no committed output for a reference to attach to", + resolved.module + ), + ) + })?; -The frontmatter test suite is a contract, not a sketch. Each case in it is -realized by its own dedicated test function that exercises only that case, so the -number of dedicated tests is at least the number of frontmatter cases. Never fold -two or more cases into one shared test, and a single test that loops over a table -of inputs never counts as the dedicated test any of those inputs requires: such a -combined test may exist only as an extra. Cases that differ only in their input -while sharing an expected value are still distinct cases, each owed its own -dedicated test. In particular, each wire-string case gets its own test: the three -`GenMode` strings, the three `GenOutcome` strings, the four `DerivationOutcome` -strings, the five `LegacyClassification` strings, and the eight `EventPayload` -kind tokens are each their own dedicated test, even though several share the token -`ok`, `failed`, or `interrupted`. Likewise every case that expects `Malformed` -(the unknown module-state field, the missing promptBlob, the missing blockIndex, -the wrong-typed targets, the unknown top-level event field, the unknown -payload-fields field, the unknown payload-wrapper sibling key, the unknown kind -token, the wrong-typed ordinal, the ordinal over the u64 range, the attempts over -the u32 range, the seq over the u64 range, the four unknown enum-token cases, the -unknown target-artifacts field, the unknown root-entry field, the unknown -provenance field, and the unknown token-usage field), every case that expects -`MalformedHash`, every case that expects `EmptyKey`, every case that expects -`NonAdvancingOutcome`, and every integer-width boundary case that is accepted (the -ordinal at the largest u64, the attempts at the largest u32, and the seq at the -largest u64), gets its own dedicated test whatever shared loop may also exist as an -extra. Each integer-width boundary case pins its exact literal integer in its own -test: `18446744073709551615` for a largest-u64 field accepted, -`18446744073709551616` for a field one past the u64 range rejected as `Malformed`, -`4294967295` for the largest-u32 attempts accepted, and `4294967296` for attempts -one past the u32 range rejected as `Malformed`. + // Materialize the committed output as the candidate. The reference-only + // transition changes the attribution record and nothing else. + let scratch = store + .root + .join("tmp") + .join(format!("capture-{}", crate::paths::encode_name(&resolved.module))); + if scratch.exists() { + std::fs::remove_dir_all(&scratch).map_err(|e| disk::io(&scratch, &e))?; + } + let owned = crate::store_view::flatten_tree(&store, &state.output_tree)?; + if owned.is_empty() { + return Ok(refusal(&error( + OutcomeCode::ReferenceUnresolvable, + format!( + "module {:?} has no committed files for a reference to attach to", + resolved.module + ), + ))); + } + for (path, (blob, _mode)) in &owned { + write_file(&scratch.join(path), &get_blob(&store, blob)?)?; + } -Inside a case's dedicated test, hand the code exactly the fixture that case gives, -byte for byte, and assert its pinned expectation verbatim: no shorter or more -convenient stand-in, no re-derivation of a value the case pins as a literal, and -no weakening of a pinned equality into an inequality-only or recomputed check. The -byte-pinned cases assert their exact literals: MS_MIN canonicalizes to -`{"blockIndex":"","promptBlob":"","targets":{}}` plus one newline, names to -`ab63db013784f331f93f17786b8ea92f9a89736c44686ea112223ee03854dc94`, and frames the -envelope beginning `napl-store module-state v1 175\n`; EV_GEN canonicalizes to its -pinned bytes, names to -`dc807c63cc0b542fde0132415814a66183931ce2b7334662dc8fb3810948ae9f`, and frames the -envelope beginning `napl-store event v1 499\n`; EV_LEGACY canonicalizes to its -pinned bytes with both state fields the literal string `unknown` and names to -`fba5e4f9532e4dd01d1f9571b46afafced354533db1730806acb703643fd05f4`; SNAP -canonicalizes to its pinned bytes and names to -`cb82801a441f25b1a80aa310fe3d4b15c0c64cafff6b0be799c53d22649a1f64`; and MS_RICH -canonicalizes to its pinned bytes -`{"blockIndex":"","ir":"","mapl":"","promptBlob":"","targets":{"rust":{"attribution":"","generatedTree":""}}}` -plus one newline and names to -`9dca13259e9b91d24853a8b187dd9b309b2e2bd2c614c2edec089da5ad2fa9af`. The MS_RICH -canonical-bytes case owns its own dedicated test that asserts that pinned -canonical-bytes literal, separate from the MS_RICH name test and separate from any -semantic round-trip test: a round-trip-equal or validate check never stands in for -the literal byte assertion the canonical-bytes case pins. Where a case pins -canonical bytes, obtain them as `store_hash::canonical_json` applied to the -serialized value and compare to the literal; where a case pins an object name, -obtain it from the matching naming function and compare to the literal; where a -case pins envelope bytes, frame them through `store_hash::envelope_bytes` over the -canonical payload and compare to the literal. + // Carry the module's recorded references forward and add this one, with the + // provenance the session window supplies. + let mut entry = resolved.entry.clone(); + let provenance = window_provenance(workspace, session); + entry.provenance = provenance.clone(); + let mut record = match crate::porcelain::committed_attribution_yaml(&store, &target, &resolved.module)? { + Some(bytes) => attribution::parse(&String::from_utf8_lossy(&bytes)).ok_or_else(|| { + error( + OutcomeCode::StoreIntegrity, + format!( + "the committed attribution of module {:?} is not a parseable record", + resolved.module + ), + ) + })?, + None => Attribution { + module: resolved.module.clone(), + target: target.clone(), + stale: false, + entries: Vec::new(), + }, + }; + record.stale = false; + if !record.entries.contains(&entry) { + record.entries.push(entry); + } + let yaml = attribution::to_yaml(&record); -A dedicated test asserts only its own case and nothing more: the MS_MIN name test -asserts only that pinned digest, leaving the canonical-bytes shape to the case that -owns it, and the absent-mapl-and-ir case asserts only that the canonical bytes -carry no `mapl` and no `ir` key. Each verifier round-trip case builds the exact -fixture its own case names, never a heavier or different fixture substituted for -it: the module-state verifier case builds MS_MIN and never MS_RICH, the event -verifier case builds EV_GEN, and the snapshot verifier case builds SNAP; each names -its object through the matching naming function, frames its envelope, and asserts -`store_hash::verify_object` returns success carrying the matching kind. + // Stage exactly as `add` stages, then commit exactly as `commit --staged` + // commits. The staged record carries the mode, so the reference the verb + // already proved is installed fresh rather than judged a second time against + // bytes it never claimed anything about. + let candidate = crate::candidate::ingest( + &crate::workspace::scan_candidate(&scratch)?, + crate::candidate::default_limits(), + )?; + let record_dir = crate::staging::record_dir(workspace, &resolved.module, &candidate.tree_hash); + if record_dir.exists() { + std::fs::remove_dir_all(&record_dir).map_err(|e| disk::io(&record_dir, &e))?; + } + for (path, bytes) in &candidate.files { + write_file(&record_dir.join("candidate").join(path), bytes)?; + } + // The staged record reports its session window, never a total: the window is + // the reference, and `napl cost` sums the log's lines at read time. Reported + // telemetry never enters a receipt's semantic identity, so this changes what + // the record says it cost and nothing about what it installs. + let mut receipt = crate::authoring::draft_receipt( + workspace, + &target, + &resolved.module, + &scratch, + None, + )?; + if let Some(window @ UsageRef::Window(_)) = &provenance.usage { + receipt["reported"] = json!({ "usage": { "window": window.as_json() } }); + } + write_file( + &record_dir.join("receipt.json"), + &canonical_bytes(&receipt), + )?; + write_file(&record_dir.join("attribution.yaml"), &yaml)?; + crate::staging::write_record( + &record_dir, + &resolved.module, + &target, + &candidate.tree_hash, + true, + AttributionMode::PreProven, + )?; -The absent-target-omission case is distinct from the absent-mapl-and-ir case and -gets its own dedicated test: it builds EV_LEGACY, whose `target` is `None`, and -asserts that its canonical bytes carry no `target` key, asserting nothing else. + let report = crate::staging::commit_staged(workspace, &record_dir); + let mut payload = report.payload; + if let Some(object) = payload.as_object_mut() { + object.insert("applied".to_string(), Value::Bool(true)); + object.insert("recorded".to_string(), Value::Bool(true)); + object.insert( + "staged".to_string(), + Value::String(crate::staging::relative_record(workspace, &record_dir)), + ); + } + Ok(CliResult { + stdout: canonical_bytes(&payload), + exit_code: crate::error::outcome_exit_code(report.outcome), + }) +} -### Provenance case discipline +// -------------------------------------------------------------- the window -The provenance cases are their own distinct cases and are each realized by their -own dedicated test with the fixture's literal values copied verbatim; no -provenance case is discharged by any other. In particular: +/// The session window a call records, read from the log the usage proxy appends +/// to. +/// +/// The recorded usage reference is the window itself, never a total: media type, +/// workspace-relative log path, session id, and the line span observed here. +/// Model identity and timestamp come from the window's log lines; a call with no +/// window records neither, and every absent field reads `unavailable`. +fn window_provenance(workspace: &Path, session: Option<&str>) -> Provenance { + let Some(session) = session_id(session) else { + return Provenance::default(); + }; + let Ok(text) = std::fs::read_to_string(workspace.join(USAGE_LOG_PATH)) else { + return Provenance::default(); + }; + let mut span: Option<(u32, u32)> = None; + let mut model: Option = None; + let mut timestamp: Option = None; + for (index, line) in text.lines().enumerate() { + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + if value["session"].as_str() != Some(session.as_str()) { + continue; + } + let number = (index + 1) as u32; + span = Some(match span { + None => (number, number), + Some((start, _)) => (start, number), + }); + // The window's own lines name the model and the moment; the later line + // wins, so the record names the call this edit most closely followed. + if let Some(named) = reported(&value["model"]) { + model = Some(named); + } + if let Some(named) = reported(&value["timestamp"]) { + timestamp = Some(named); + } + } + let Some((line_start, line_end)) = span else { + return Provenance::default(); + }; + Provenance { + model, + timestamp, + tool_version: None, + usage: Some(UsageRef::Window(UsageWindow { + media_type: USAGE_LOG_MEDIA_TYPE.to_string(), + log_path: USAGE_LOG_PATH.to_string(), + session, + line_start, + line_end, + })), + } +} -- The EV_GEN_PROV canonical-bytes case builds EV_GEN_PROV, obtains its canonical - bytes as `store_hash::canonical_json` of the serialized event, and asserts the - exact six-hundred-forty-two-byte literal the case pins, with the `provenance` - object's keys in the canonical sorted order `estimatedCostUsd`, `model`, - `priceTableVersion`, `tokens`, `wallMs` and the `provenance` key itself sorted - between `outcome` and `taskBundle` inside the gen `fields`. The EV_GEN_PROV name - case asserts only the object name - `ba63bb11bc2e25378f533be8f8b5a019e12418f97c5050c068e28af75b391032`; the - EV_GEN_PROV envelope case frames the envelope beginning `napl-store event v1 - 642\n` through `store_hash::envelope_bytes`; the EV_GEN_PROV verifier case names, - frames, and asserts `store_hash::verify_object` returns success reporting kind - event; and the EV_GEN_PROV round-trip case serializes EV_GEN_PROV, recovers it - through `validate_event`, and asserts the recovered event equals the original - verbatim, its `Provenance` and `TokenUsage` preserved field for field, never - weakened into an `is_ok` acceptance. -- The gen-without-provenance omission case builds EV_GEN, whose `provenance` is - `None`, and asserts only that its canonical bytes carry no `provenance` key, - asserting nothing else; it is distinct from the EV_GEN canonical-bytes, name, - envelope, and verifier cases and never stands in for them, and they never stand - in for it. -- The EV_DERIV_PROV name case asserts only the object name - `d084c97dd4de38f5974f5f8dabfb63e060378b872d6e3483780209b115f95bce`; the - EV_DERIV_PROV round-trip case serializes EV_DERIV_PROV, recovers it through - `validate_event`, and asserts equality verbatim, preserving its derivation - provenance including the `claude-sonnet-4-5` model and the `0.018` cost. -- The EV_GEN_PROV_CACHE canonical-bytes case builds EV_GEN_PROV_CACHE and asserts - its exact six-hundred-seventy-five-byte literal, in which the `tokens` object - carries `cacheRead` and `cacheWrite` in canonical sorted order ahead of `input` - and `output` and the cost is `0.084`; the EV_GEN_PROV_CACHE round-trip case - recovers it through `validate_event` and asserts equality verbatim, its - `cache_read` and `cache_write` `Some` values preserved. -- The EV_GEN_PROV_UNKNOWN canonical-bytes case builds EV_GEN_PROV_UNKNOWN and - asserts its exact six-hundred-five-byte literal, which carries no - `estimatedCostUsd` key; the EV_GEN_PROV_UNKNOWN name case asserts only the object - name `6557d08b593fff22a1cbb29f5bdd7da893e42d87e42c3290043976668a00bbdf`; the - omission case asserts only that its canonical bytes carry no `estimatedCostUsd` - key, asserting nothing else and never standing in for the canonical-bytes or name - case; and the round-trip case recovers it through `validate_event` and asserts - equality verbatim, its `estimated_cost_usd` `None` preserved. -- The five `estimate_cost` cases each call `estimate_cost` directly on the exact - model and token counts the case names and assert the exact returned value: the - known-model case over `claude-opus-4-8` with input `1000` and output `500` - asserts `Some("0.0525")`; the zero-token case over `claude-opus-4-8` with input - `0` and output `0` asserts `Some("0")`, exercising the empty-fractional branch - that renders a bare integer; the whole-dollar case over `claude-sonnet-4-5` with - input `1000000` and output `1000000` asserts `Some("18")`, a second distinct - empty-fractional case whose whole part is nonzero; the cache-fold case over - `claude-opus-4-8` with input `1000`, output `500`, cache-read `2000`, and - cache-write `100` asserts `Some("0.084")`, proving cache tokens join the input - rate; and the unknown-model case over `gpt-4o` with input `1000` and output `500` - asserts `None`, proving an absent model yields no cost. Each of these five is its - own dedicated test with its own literal tokens; the two empty-fractional cases - (`0` and `18`) are distinct and neither discharges the other, and the cache-fold - cost case is distinct from the EV_GEN_PROV_CACHE event byte case even though both - reach `0.084`. -- The unknown-provenance-field rejection case takes the EV_GEN_PROV object's JSON - and inserts the key `bogus` with integer value `1`, exactly `"bogus": 1`, inside - its `provenance` object, and asserts the event is rejected as `Malformed`, - exercising `Provenance`'s `#[serde(deny_unknown_fields)]`. The - unknown-token-usage-field rejection case takes the EV_GEN_PROV object's JSON and - inserts `"bogus": 1` inside its `provenance`'s `tokens` object, and asserts - `Malformed`, exercising `TokenUsage`'s `#[serde(deny_unknown_fields)]`. These two - are distinct cases inserting the stray key at two different depths and neither - discharges the other, and neither is a renamed key such as `extra` nor a - restyled value such as the string `"nope"`. +/// The session this call belongs to: the flag, or the environment variable the +/// wrapper injects, or none at all. +fn session_id(session: Option<&str>) -> Option { + if let Some(explicit) = session { + let trimmed = explicit.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + return None; + } + // The one environment read in the toolchain, and the contract names it: a + // call made under `napl agent` carries its window with no extra argument. + let inherited = std::env::var(SESSION_VARIABLE).ok()?; + let trimmed = inherited.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} -Each round-trip-equal case builds its own named fixture, serializes it, recovers it -through the matching validator, and asserts the recovered value equals the original -verbatim, never weakening that equality into a mere `is_ok` acceptance and never -substituting a lighter or neighboring fixture. These cases are the MS_RICH -round-trip, the EV_LEGACY round-trip that preserves both `unknown` state sentinels, -the two-parent merge-snapshot round-trip, the six base event round-trips, and the -provenance round-trips (EV_GEN_PROV, EV_DERIV_PROV, EV_GEN_PROV_CACHE, and -EV_GEN_PROV_UNKNOWN), one dedicated test each with the case's literal values copied -verbatim: +/// A log field that actually names something. The proxy writes `unavailable` +/// where it read nothing, and that is not a value to carry forward. +fn reported(value: &Value) -> Option { + let text = value.as_str()?; + if text.is_empty() || text == attribution::UNAVAILABLE { + None + } else { + Some(text.to_string()) + } +} -- the reword round-trip builds a reword event with `verdict` = `approved`, `batchId` - = `H1`, and `bundle` = `H4`; -- the reconcile round-trip builds a reconcile event with `driftSummary` = `one file` - and `amendmentBundle` = `H4`; -- the move round-trip builds a move event with `context` = `relocated greeting.rs`; -- the audit round-trip builds an audit event with `noop` = `true`; -- the revert round-trip builds a revert event with `reason` = `superseded by newer - gen`; -- the derivation round-trip builds a derivation event with `derivation` = `mapl`, - `outcome` = `ok`, `attempts` = `1`, `error` the empty string, `diagnostic` - absent, and `provenance` absent. +// ---------------------------------------------------------------- plumbing -None of these round-trip cases is discharged by the like-named payload-kind tag -case: a tag case that only inspects the serialized `kind` token, and that carries -its own unrelated field values such as `accepted`, `batch-1`, `one file drifted`, -`src/greeting.rs`, or `bad gen`, is a separate case and never stands in for the -round-trip case that owns these literal values. The base ok-derivation round-trip, -whose `provenance` is absent, is a separate case from the EV_DERIV_PROV round-trip, -whose `provenance` is present, and neither discharges the other. The two-parent -merge-snapshot round-trip builds a snapshot with `parents` = `[H6, H7]`, `seq` = -`6`, and one root entry `greeting`, serializes it, recovers it through -`validate_snapshot`, and asserts the recovered snapshot equals the original, -preserving both parents and the `seq` value `6`; it never keeps SNAP's single -parent or its `seq` value `5`, and never weakens the equality into an `is_ok` -check. +/// The workspace-relative path a verb targets, refused when it escapes the +/// workspace. +/// +/// An absolute path is honored as given. A relative one is resolved against the +/// working directory first, which is what a caller standing inside the workspace +/// means, and against the workspace root otherwise, which is how every record in +/// the store already spells a path. +fn relative_target(workspace: &Path, cwd: &Path, given: &str) -> Result { + let raw = PathBuf::from(given); + let absolute = if raw.is_absolute() { + raw + } else { + let from_cwd = cwd.join(&raw); + if from_cwd.starts_with(workspace) { + from_cwd + } else { + workspace.join(&raw) + } + }; + let relative = absolute.strip_prefix(workspace).map_err(|_| { + error( + OutcomeCode::IoError, + format!("{given}: the path is outside the workspace"), + ) + })?; + let mut parts: Vec = Vec::new(); + for component in relative.components() { + match component { + std::path::Component::Normal(os) => { + parts.push(os.to_string_lossy().into_owned()) + } + std::path::Component::CurDir => {} + _ => { + return Err(error( + OutcomeCode::IoError, + format!("{given}: the path is not canonical"), + )) + } + } + } + Ok(parts.join("/")) +} -The absent-versus-unknown-versus-known case constructs all three state situations -in its own test and asserts every expectation the case declares: an absent state -that is `None`, an unknown state that is `Some(StateRef::Unknown)` (the sentinel), -and a known state that is `Some(StateRef::State(H2))` carrying that hash, then -asserts the absent one is `None`, the unknown one is the sentinel, the known one -carries `H2`, and that all three are pairwise distinct. It never collapses to a -single absent-versus-unknown comparison and never drops the known state. +fn ok_payload(value: Value) -> CliResult { + CliResult { + stdout: canonical_bytes(&value), + exit_code: 0, + } +} -The non-advancing cases each construct exactly the described event and assert -accept or the pinned `NonAdvancingOutcome` error: the failed-gen-equal-states and -failed-gen-both-absent and retried-derivation-advances and ok-gen-advances cases -accept, while the failed-gen-mismatch, interrupted-gen-mismatch, -failed-derivation-mismatch, and interrupted-derivation-mismatch cases each reject -with `NonAdvancingOutcome`, each in its own test. +/// A refusal: the canonical payload naming what failed and that nothing was +/// applied or published, plus the outcome's pinned exit code. +fn refusal(err: &CompilerError) -> CliResult { + let mut payload = crate::error::error_payload(err, false, false); + if let Some(object) = payload.as_object_mut() { + object.insert("applied".to_string(), Value::Bool(false)); + object.insert("recorded".to_string(), Value::Bool(false)); + } + CliResult { + stdout: canonical_bytes(&payload), + exit_code: crate::error::outcome_exit_code(err.code), + } +} +napl-store tree v1 132 +{"entries":[{"hash":"922a54930e3affda2ce0caa8792c72701e96bf24f401241581977a3aff62d9a0","kind":"dir","mode":16384,"name":"crates"}]} +napl-store transition v1 632 +{"baseStateKey":"67df6d3a1c20bac424e4a0104bf02ea3dd17d728c930e641c212ce0bfd3260d1","contractVersion":4,"derivedProjections":[],"inputKey":"f0f530591a0d6bbdd53045e2a0f34b4f37c578b4602f318e114904c6d189e0b4","installPaths":[],"kind":"docs-only","module":"agent","newState":"f26d6fde72e9fc4f86b6909dc51455aaf37c8a17ed31d6db11e94d9a0408b324","prompt":"11b47daa7b6384032d19bfbfb73f0c64a5903cf25a7e2117cc6555e32ed26c57","readSet":["prompt:d81c438d06c05e8bffbcfdc17ea45c873a6292f6e34df1f08367c0a95f7e27b0"],"surface":"ad1581ae209ba3feb19322408bfff544be7a29bc6a7db44c672b705f1afcd120","target":"rust","writeSet":["module-state:rust/agent"]} +napl-store tree v1 129 +{"entries":[{"hash":"f2f812fb8d99b1aa1f62d3d1dafdff4cf049d0956a4031a4e9450db3f5210a37","kind":"dir","mode":16384,"name":"src"}]} +napl-store blob v1 5581 +//! Discovery's product: the target-restricted dependency graph and its waves. -The rejection cases construct exactly the malformed JSON the case describes, built -by taking the named fixture's JSON and applying only the one described mutation -using that mutation's exact literal tokens, and assert the pinned error variant. -The mutation literal is transcribed verbatim from the case, never paraphrased into -a more convenient stand-in, because the exact bytes are the point of the test. In -particular: +use std::collections::{BTreeMap, BTreeSet}; -- the extra-key cases (the unknown module-state field on MS_MIN, the unknown - target-artifacts field on MS_RICH, the unknown top-level event field on EV_GEN, the - unknown payload-fields field on EV_GEN, the unknown payload-wrapper sibling key on - EV_GEN, the unknown provenance field on EV_GEN_PROV, the unknown token-usage field - on EV_GEN_PROV, the unknown snapshot top-level field on SNAP, and the unknown - root-entry field on SNAP) each insert the key named `bogus` with the integer value - `1`, exactly `"bogus": 1`, never a renamed key such as `extra` and never a restyled - value such as the string `"nope"`; the unknown snapshot top-level field inserts - `"bogus": 1` at the top level of SNAP and is a separate case from the unknown - root-entry field, which instead inserts `"bogus": 1` inside SNAP's one root entry, - and neither discharges the other; -- the three-parent snapshot rejection sets `parents` to exactly the three distinct - hashes `[H6, H7, H8]`, never a repeated hash such as `[H6, H6, H6]`; -- the empty-target-key rejection starts from MS_RICH and renames its one target key - `rust` to the empty string, reaching `EmptyKey`, never a fresh MS_MIN-based state - into which a newly constructed empty-keyed target record is inserted; -- the empty-root-key rejection starts from SNAP and renames its one root key - `greeting` to the empty string, reaching `EmptyKey`; -- the wrong-typed-targets case sets `targets` to the empty JSON array `[]`, never to - a string such as `"rust"`; -- the wrong-typed-ordinal case sets `ordinal` to the JSON string `"5"`, never to a - different string such as `"five"`; -- the unknown-kind case sets `payload.kind` to the exact token `frobnicate`, never - to a different word such as `nope`; -- the four unknown-enum-token cases (gen mode, gen outcome, derivation outcome, and - legacy classification) each set the field to the exact token `bogus`, never to a - different word such as `nope`. +use crate::error::{error, OutcomeCode, Result}; +use crate::source::SourceModule; +use crate::target::TargetDef; -The payload-wrapper sibling case adds the key `bogus` with value `1` to the payload -object itself, beside `kind` and `fields`, and asserts the event is rejected as -`Malformed`; this exercises the wrapper's own strictness and is distinct from the -payload-fields case, which puts `"bogus": 1` inside the `fields` object instead, and -from the provenance and token-usage cases, which put it one and two levels deeper -inside the gen fields. Any further fixture beyond this suite lives in its own -separately named supplemental test rather than riding along inside a suite case's -test; additional tests are welcome once every case has its dedicated test, but they -never replace or subsume a suite case. +/// The modules that declare a target, keyed by module name. +pub type Graph = BTreeMap; -The amendment cases for the additive `diagnostics` and `attemptId` fields are each -owed their own dedicated verbatim test and none discharges another. The two -skip-when-empty and skip-when-absent omission cases build EV_GEN, whose gen payload -carries an empty `diagnostics` and whose event carries no `attemptId`, and assert -respectively that the canonical bytes carry no `diagnostics` key and no `attemptId` -key; each asserts only its own absence and neither restates EV_GEN's full canonical -bytes, which the pre-amendment EV_GEN canonical-bytes case already owns unchanged. -The derivation empty-diagnostics omission case builds the ok derivation event with -`diagnostic` absent and `diagnostics` empty and asserts its canonical bytes carry no -`diagnostics` key, distinct from the derivation round-trip case that shares its -shape. The EV_GEN_INT trio (its canonical-bytes case, its object-name case pinning -`17f796f1f623dc65d2f596336befb1e062c619ca1d6f87aa3e76245c5e37420e`, and its -envelope-bytes case pinning the `napl-store event v1 614\n` frame) are three separate -dedicated tests, each asserting only its own pinned literal, plus a fourth EV_GEN_INT -round-trip case asserting the recovered event equals the original with both new -fields preserved; the byte cases prove the amended key order, `attemptId` sorting to -the front of the event object and `diagnostics` sorting between `attempts` and `mode` -inside the gen `fields`. The DERIV_BOTH canonical-bytes case, the DERIV_BOTH -object-name case pinning -`2ac016d8ff0d0cd4f01ab9b383989ca448665cfc9fe14299d0d08e61a93cf56b`, and the DERIV_BOTH -round-trip case are three separate dedicated tests; the round-trip case asserts the -recovered derivation carries `diagnostic` equal to `H5` and `diagnostics` equal to -`vec![H6, H7]` independently, never collapsing the singular field into the plural one -nor the reverse. The EV_GEN_OPPRES canonical-bytes case and the EV_GEN_OPPRES accepts -case are two separate dedicated tests: the bytes case pins the literal carrying the -verbatim `vendor-sync` token, and the accepts case asserts `validate_event` returns -`Ok` over an event whose sole non-standard feature is that `diagnostics` entry, never -weakening the acceptance into an `is_ok` over a different event and never letting the -hash check reject the non-hash token. Every byte-pinned amendment case obtains its -bytes exactly as the pre-existing byte cases do, `store_hash::canonical_json` for -canonical bytes, the matching naming function for the object name, and -`store_hash::envelope_bytes` for the envelope, comparing to the full pinned literal -and never a prefix or a re-derivation. +/// Reject a source set whose modules collide, returning them keyed by name. +/// +/// Filesystem enumeration order never reaches this function's output: the map is +/// keyed and therefore sorted, and a duplicate is a loud failure rather than a +/// last-writer-wins. +pub fn index_sources(sources: &[SourceModule]) -> Result> { + let mut out: BTreeMap = BTreeMap::new(); + for source in sources { + if let Some(prior) = out.insert(source.module.clone(), source.clone()) { + return Err(error( + OutcomeCode::MappingIntegrity, + format!( + "module {:?} is declared by both {} and {}", + source.module, prior.path, source.path + ), + )); + } + } + Ok(out) +} -Closing mandate on one-to-one case fidelity. Every case in the frontmatter test suite -above, without exception, is realized by exactly one dedicated test that hands the code -that case's own fixture with its literal values copied verbatim from the case, and -asserts that case's own pinned expectation, neither narrower nor recomputed. A test -that inspects a different or narrower property than its case declares, that swaps in a -lighter or neighboring fixture, that weakens a pinned equality into an `is_ok` or -inequality-only check, or that reuses a sibling case's values, does not discharge the -case and leaves it untested. Two cases that share a shape but differ in a fixture value -or in the property asserted are two separate cases, each owed its own verbatim test: a -payload-kind tag case never discharges the like-named round-trip case, a hash-rejection -case never discharges a round-trip case, the snapshot top-level extra-field case never -discharges the root-entry extra-field case, the gen-without-provenance omission case -never discharges the EV_GEN canonical-bytes case, the EV_GEN_PROV canonical-bytes case -never discharges its own name or envelope or round-trip case, the two empty-fractional -cost cases (`0` and `18`) never discharge each other, the two-parent merge round-trip -never reuses SNAP's single parent or its `seq` value `5`, the three-parent rejection uses -`[H6, H7, H8]` and never `[H6, H6, H6]`, and the empty-target-key rejection renames -MS_RICH's `rust` key rather than constructing a new record on MS_MIN. Supplemental -tests beyond the suite are named for what they add and are never counted as, nor -substituted for, any suite case's dedicated test. The number of dedicated tests is -therefore at least the number of frontmatter cases, and each case above is discharged -by its own verbatim test. +/// Build the target-restricted dependency graph. +pub fn build_graph(sources: &[SourceModule], target: &TargetDef) -> Result { + let indexed = index_sources(sources)?; + let graph: Graph = indexed + .into_iter() + .filter(|(_, source)| source.targets.iter().any(|t| t == &target.name)) + .collect(); + for source in graph.values() { + for dep in &source.deps { + if !graph.contains_key(dep) { + return Err(error( + OutcomeCode::MappingIntegrity, + format!( + "module {:?} depends on {dep:?}, which does not declare target {:?}", + source.module, target.name + ), + )); + } + } + } + Ok(graph) +} -Write no explanatory line or block comments in the generated code, and no doc -comment on any private item. The crate keeps its module-level `//!` summary, and a -`///` doc comment restating this prompt's contract belongs only on a public item, -the public types, their public fields, the public enum variants, and the public -functions, where it earns its place. The private helper functions and private -constants of this module, the price table included, carry no doc comment. Beyond -the module summary and these public-item doc comments the code carries no -commentary of any kind. +/// Assign each module a dependency level; error on a cycle. +pub fn topological_levels(graph: &Graph) -> Result> { + let mut levels: BTreeMap = BTreeMap::new(); + let mut remaining: BTreeSet = graph.keys().cloned().collect(); + while !remaining.is_empty() { + let ready: Vec = remaining + .iter() + .filter(|module| graph[*module].deps.iter().all(|dep| levels.contains_key(dep))) + .cloned() + .collect(); + if ready.is_empty() { + let cycle: Vec = remaining.iter().cloned().collect(); + return Err(error( + OutcomeCode::MappingIntegrity, + format!("dependency cycle among {cycle:?}"), + )); + } + for module in ready { + let level = graph[&module] + .deps + .iter() + .map(|dep| levels[dep] + 1) + .max() + .unwrap_or(0); + levels.insert(module.clone(), level); + remaining.remove(&module); + } + } + Ok(levels) +} -## store_blocks -# Store block index: two-level prompt segmentation over the content-addressed store +/// The modules that depend on `module`, directly or transitively. +pub fn dependents_of(graph: &Graph, module: &str) -> BTreeSet { + let mut out: BTreeSet = BTreeSet::new(); + loop { + let grown: BTreeSet = graph + .iter() + .filter(|(name, source)| { + !out.contains(*name) + && source + .deps + .iter() + .any(|dep| dep == module || out.contains(dep)) + }) + .map(|(name, _)| name.clone()) + .collect(); + if grown.is_empty() { + return out; + } + out.extend(grown); + } +} -This module defines the `block-index` object kind of the NAPL content-addressed -store, and the pure segmentation that produces it. A block index is a two-level -occurrence index over a prompt blob's bytes: a first structural level of **atom** -records (frontmatter fields and markdown body blocks) that tile the whole -document, and a second level of **sub-span** records giving sentence precision -inside prose atoms. The index carries byte and line spans against the stored -prompt bytes, so those bytes reconstruct exactly and line-based coordinates map -onto occurrences by span intersection. It is pure: no filesystem, no I/O, no -clocks, no randomness. Bring in `serde` and `serde_json`. +/// The modules reachable from a declared root set, roots included. +pub fn reachable_from(graph: &Graph, roots: &[String]) -> BTreeSet { + let mut out: BTreeSet = BTreeSet::new(); + let mut queue: Vec = roots.to_vec(); + while let Some(module) = queue.pop() { + if !graph.contains_key(&module) || !out.insert(module.clone()) { + continue; + } + queue.extend(graph[&module].deps.iter().cloned()); + } + out +} -## Scope boundary: which object kind lives here +/// The changed frontier: modules whose `inputKey` moved, plus their dependents. +pub fn changed_frontier( + graph: &Graph, + committed_input_keys: &BTreeMap>, + input_keys: &BTreeMap, + only_module: Option<&str>, +) -> Result> { + if let Some(module) = only_module { + if !graph.contains_key(module) { + return Err(error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} is not in the graph for this target"), + )); + } + return Ok(vec![module.to_string()]); + } + let mut frontier: BTreeSet = graph + .keys() + .filter(|module| { + committed_input_keys + .get(*module) + .and_then(Option::as_deref) + != Some(input_keys[*module].as_str()) + }) + .cloned() + .collect(); + loop { + let grown: BTreeSet = graph + .iter() + .filter(|(module, source)| { + !frontier.contains(*module) && source.deps.iter().any(|dep| frontier.contains(dep)) + }) + .map(|(module, _)| module.clone()) + .collect(); + if grown.is_empty() { + break; + } + frontier.extend(grown); + } + Ok(frontier.into_iter().collect()) +} +napl-store blob v1 9804 +# The git-backed store -The store has six object kinds. This module owns exactly one of them, the -`block-index` kind, whose serde types the neighboring `store_objects` module -deliberately deferred to this module. The other five kinds are out of scope here -and are never redefined in this crate: `blob`, `tree`, `module-state`, `event`, -and `snapshot`. This module never references another object by structure; where -it names another object it uses that object's content-addressed name, the -sixty-four-character lowercase hexadecimal SHA-256 string. The block index does -not embed the prompt blob; it indexes bytes the caller supplies. +On 2026-07-31 the owner approved building this module for local +evaluation, which rules the gix dependency in and narrows the +remaining gates to what they truly guard: the provider lands behind a +feature flag, off by default, and flipping the default for git +workspaces stays gated below. Every measured claim comes from the +2026-07-31 spike (scratchpad spike-git-store), whose scripts and +outputs are the evidence. The packfile layout, its index, the repack +operation, and the auto-repack threshold are superseded by this +module and are deleted in the wave that flips the default, not +before, because they remain the floor for workspaces the gates still +exclude. -## Where this code lives +## Goal -The working directory is a Cargo workspace whose root manifest is written and -owned by the toolchain, leave it alone. Create this module as its own member -crate in a subdirectory named `store_blocks/`: `store_blocks/Cargo.toml` (package -name `store_blocks`) and `store_blocks/src/lib.rs`. Touch nothing outside -`store_blocks/`. Ensure `cargo test` passes from the workspace root before -finishing. +A napl user's pull request shows zero store paths. The store still +clones with the repository, travels on every push, merges without +human judgment, and loses nothing the tracked-file design keeps. No +new servers, no per-clone configuration, no command a user must +learn. Object identity, the envelope format, the commit decision +table, attribution, and every porcelain semantic are untouched: this +is a storage backend swap behind the existing store trait. -## Builds on two modules of this workspace +## Shape -This crate composes on two generated sibling crates. Use each one's public API, -do not reimplement its types or logic, and do not depend on any hand-written -crate. Depend on exactly these two, and do not bring in the `sha2` crate directly: -the content digests below come from `hash`, and the object framing comes from -`store_hash`. +Committed objects move out of the working tree and into git's own +object database, held together by one ordinary branch named +`napl-store`. -- **`hash`** (`../hash`) exposes `hash::content_hash(content: &str) -> String`, - the bare SHA-256 content digest: the lowercase-hexadecimal SHA-256 of the - UTF-8 bytes of `content`, sixty-four characters. This is what a block index - stores in every `contentHash` field, an identification digest over an atom's or - a sub-span's exact bytes. It is not an object name (it carries no envelope); use - it for every `contentHash` this module computes, and never reimplement SHA-256. -- **`store_hash`** (`../store_hash`) supplies the object framing for the whole - block-index object: - - `store_hash::ObjectKind`, whose variant `store_hash::ObjectKind::BlockIndex` - names this kind; its wire token `block-index` comes from `store_hash`, never - re-spelled here. - - `store_hash::canonical_json(value: &serde_json::Value) -> Vec`, the - canonical JSON encoder (keys sorted recursively, compact, one trailing - newline) that turns a serialized value into its canonical bytes. - - `store_hash::envelope_bytes(kind: store_hash::ObjectKind, payload: &[u8]) -> - Vec` and `store_hash::object_name(kind: store_hash::ObjectKind, payload: - &[u8]) -> String`, the typed envelope framing and the SHA-256 object name over - those envelope bytes. - - `store_hash::verify_object(name: &str, bytes: &[u8]) -> - Result`, the read - verifier the round-trip test passes a freshly built object through; - `store_hash::VerifiedObject` has public fields `kind: store_hash::ObjectKind` - and `payload: Vec`. +- Every committed napl object becomes a git blob whose bytes are the + envelope verbatim. The napl name stays the SHA-256 of those bytes; + git's oid is a second, never-user-visible name. The spike + round-tripped 1000 realistic envelopes through git blobs with zero + mismatches. +- A two-level git tree mirrors the fanout, `` directories + of `` entries, mode 100644. +- One git commit per napl store transaction, parent the previous + store commit, so store history is a real, inspectable commit + chain. The commit message is `napl-store: ` with + trailers `Napl-Snapshot: ` and `Napl-Contract: `. +- napl HEAD is no longer a file: it is the snapshot trailer of the + `napl-store` branch tip, so reading HEAD is one ref read. -The distinction is load-bearing: a `contentHash` is a bare `hash::content_hash` -digest of raw bytes, while the block-index object's own name is a -`store_hash::object_name` over its canonical-JSON envelope. The two never -substitute for each other. +## Determinism -## Content hashes are plain strings +The commit signature is the fixed identity `napl-store ` +and the fixed timestamp epoch 0 +0000: two workspaces performing the +same transaction over the same base produce byte-identical git +commits. Epoch dates in a branch listing are cosmetic and honest, +display metadata that never enters identity, exactly as telemetry +already behaves. The commit object is built explicitly, never through +a convenience API that stamps wall-clock time; the spike measured +gix's `repo.commit()` doing exactly that, so it is forbidden here. -Every `contentHash` and every content-addressed name is carried as a `String`, -matching the rest of the workspace, never a newtype. A well-formed digest or name -is sixty-four lowercase hexadecimal characters (`0` through `9`, `a` through `f`). -The validator below rejects any hash-typed field whose string is not exactly that -shape. The lowercase rule is exact and intentional: an uppercase hexadecimal -digit makes a digest ill-formed and the validator rejects it as -`MalformedHash`, because `hash::content_hash` emits lowercase and this module -never re-cases a digest. This module never computes a digest except by delegating -to `hash::content_hash`. - -## The block-index object - -Expose a public struct `BlockIndex` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`, `serde::Serialize`, and `serde::Deserialize`, with -`#[serde(deny_unknown_fields)]`), one prompt blob's segmentation index. Public -fields: +::: test +name: the same transaction over the same base yields byte-identical git commits +given: { workspaces: 2, transaction: same_over_same_base } +expect: { git_commit_bytes_equal: true, timestamps_fixed: true } +::: -- `seg_version: u32`, serde field name `segVersion`. The segmentation format - version, versioned independently of the store-wide hash format version. It is a - `u32`, always serialized. A `segVersion` bump regenerates indexes from stored - prompt blobs without changing prompt identity. -- `atoms: Vec`, serde field name `atoms`. The first-level structural - records in document order, always present, an empty document serializing as - `[]`. -- `sub_spans: Vec`, serde field name `subSpans`. The second-level - sentence records, ordered by parent atom then sub-index, always present, an - empty set serializing as `[]`. +## Concurrency -Expose a public struct `AtomRecord` (same derives, same -`#[serde(deny_unknown_fields)]`), one first-level block. Public fields, each a -`u32` except the hash: +The hand-rolled HEAD compare-and-swap, lock file, and dead-owner +steal are replaced by a git ref transaction with expected old value: +move `refs/heads/napl-store` from exactly commit A to commit B or +fail. The napl decision table is untouched; a lost ref race surfaces +as the same head-moved outcome the table already judges. Local +coordination state that never travels, staging records, INSTALLED, +markers, attempt files, stays as plain gitignored files exactly as +today. -- `atom_index: u32`, serde field name `atomIndex`. The atom's zero-based position - in document order. Atom occurrence identity is the pair `(contentHash, - atomIndex)`. -- `content_hash: String`, serde field name `contentHash`. The `hash::content_hash` - of the atom's exact bytes. -- `byte_start: u32`, serde field name `byteStart`, and `byte_end: u32`, serde - field name `byteEnd`. The atom's half-open byte range `[byteStart, byteEnd)` - against the prompt bytes. -- `line_start: u32`, serde field name `lineStart`, and `line_end: u32`, serde - field name `lineEnd`. The atom's inclusive one-based line range. +::: test +name: two concurrent transactions race the ref and the loser reports head moved +given: { concurrent_transactions: 2, same_base: true } +expect: { winners: 1, loser_outcome: head_moved, store_consistent: true } +::: -Expose a public struct `SubSpanRecord` (same derives, same -`#[serde(deny_unknown_fields)]`), one second-level sentence span anchored to its -enclosing atom. Public fields, each a `u32` except the hash: +## Append-only forever -- `parent_atom_index: u32`, serde field name `parentAtomIndex`. The `atomIndex` of - the enclosing atom. -- `sub_index: u32`, serde field name `subIndex`. The sub-span's zero-based - position within its parent atom. Sub-span occurrence identity is the triple - `(parentAtomIndex, subIndex, contentHash)`. -- `content_hash: String`, serde field name `contentHash`. The `hash::content_hash` - of the sub-span's exact bytes. -- `byte_start: u32`, serde field name `byteStart`, and `byte_end: u32`, serde - field name `byteEnd`. The sub-span's half-open byte range against the prompt - bytes. -- `line_start: u32`, serde field name `lineStart`, and `line_end: u32`, serde - field name `lineEnd`. The sub-span's inclusive one-based line range. +A store commit's tree holds the full object set at that moment; +structural sharing makes unchanged fanout subtrees free. Every +historical tree stays reachable from the commit chain, so git gc can +never prune a store object, and a napl-level sweep would require +rewriting branch history, which force-push protection forbids. The +git-backed store is append-only by construction: the lost-blob scar +becomes impossible, and gc keeps only its staging-litter cleanup and +dry report. -## The error type +## Travel -Expose a public enum `StoreBlockError` (deriving `Debug`, `Clone`, `PartialEq`, -and `Eq`) with exactly these variants, one per failure mode of the validator -below: +Branches fetch by default, so a plain clone arrives with the +complete store; the spike measured this over file and git transports +and measured the hidden-ref alternative failing it silently, which +rejects hidden refs permanently. `napl init` installs a pre-push hook +when `.git/hooks/pre-push` is absent, never overwriting an existing +hook and printing the one line to add when one exists. The hook +pushes `napl-store` before the user's push proceeds, and ordering +carries the safety: code can never land without its records, and a +store push landing without its code is harmless surplus nothing +references yet. The atomic dual push +`git push --atomic origin napl-store` is the belt for CI. +The branch needs delete and force-push protection, the only measured +way to lose objects; init teaches both rules. -- `Malformed(String)`, carrying the underlying deserialization message, for any - structural or type failure surfaced by serde (an unknown field, a missing - required field, a value of the wrong JSON type, an integer outside `u32`). -- `MalformedHash`, a `contentHash` that is not sixty-four lowercase hex. -- `InvalidSegVersion`, a `segVersion` below 1. -- `InvalidSpan`, an atom or sub-span whose `byteStart` exceeds its `byteEnd`, or - whose `lineStart` exceeds its `lineEnd`. -- `UnknownParentAtom`, a sub-span whose `parentAtomIndex` names no atom. -- `NonSequentialAtomIndex`, an atom whose `atomIndex` does not equal its - zero-based position in the `atoms` array, so duplicated, skipped, or - out-of-order atom records are all rejected. -- `AtomsNotTiled`, atoms that do not tile the document contiguously: a first atom - whose `byteStart` is not 0, or an atom whose `byteStart` does not equal the - previous atom's `byteEnd` (an overlap or a gap). -- `NonSequentialSubIndex`, a sub-span whose `subIndex` does not equal its - zero-based position within its parent atom's run of sub-spans, so duplicated, - skipped, or out-of-order sibling records are all rejected. -- `SubSpanOutsideParent`, a sub-span whose byte range is not wholly within its - parent atom's byte range. -- `SubSpansNotTiled`, sibling sub-spans of one atom that do not tile that atom's - byte range exactly: a first sibling whose `byteStart` is not the parent atom's - `byteStart`, a sibling whose `byteStart` does not equal the previous sibling's - `byteEnd`, or a last sibling whose `byteEnd` is not the parent atom's `byteEnd`. +Two writers can only disagree about ref position, never content, +because identical objects get identical names on both sides. The +reconciliation is a union of the two fanout trees, computed directly +over the two object maps and never through an index, so there is no +working tree to touch and zero unmerged entries by construction, run +automatically when the remote-tracking ref has diverged; no human +ever resolves the store. The merge commit carries both tips as +parents and republishes our own snapshot trailer, because a union +changes which objects are reachable and never which snapshot is +HEAD. -## Validating a block-index +::: test +name: a union merge of two diverged store tips carries every object from both +given: { diverged_tips: 2, shared_base: true } +expect: { unmerged_entries: 0, objects_from_both_present: true, working_tree_untouched: true } +::: -Expose `validate_block_index(value: serde_json::Value) -> Result`: deserialize the JSON value into a `BlockIndex`, returning -`StoreBlockError::Malformed` (carrying serde's message) on any deserialization -failure, then apply these semantic checks, returning the first failure in this -order: +## Reading and writing -- `segVersion` must be at least 1; since it is a `u32` this fails exactly when it - is 0, as `StoreBlockError::InvalidSegVersion`. -- Every `contentHash`, on every atom and every sub-span, must be a well-formed - digest (sixty-four lowercase hex), else `StoreBlockError::MalformedHash`. -- For every atom and every sub-span, `byteStart` must be less than or equal to - `byteEnd`, and `lineStart` less than or equal to `lineEnd`, else - `StoreBlockError::InvalidSpan`. -- Every sub-span's `parentAtomIndex` must be strictly less than the number of - atoms, else `StoreBlockError::UnknownParentAtom`. -- Every atom's `atomIndex` must equal its zero-based position in the `atoms` - array, else `StoreBlockError::NonSequentialAtomIndex`. This one check rejects a - duplicated `atomIndex`, an `atomIndex` that skips a value, and atom records - serialized out of document order. -- The atoms must tile the document with no gaps and no overlaps: when there is at - least one atom the first atom's `byteStart` is 0, and every later atom's - `byteStart` equals the previous atom's `byteEnd`, else - `StoreBlockError::AtomsNotTiled`. -- Within each parent atom's run of sub-spans (sub-spans sharing a - `parentAtomIndex`, which appear consecutively because sub-spans are ordered by - parent atom then sub-index), each sub-span's `subIndex` must equal its - zero-based position in that run, else `StoreBlockError::NonSequentialSubIndex`. - This one check rejects a duplicated `subIndex`, a `subIndex` that skips a value, - and sibling records serialized out of order. -- Every sub-span's byte range must be wholly within its parent atom's byte range, - that is `byteStart` at least the parent atom's `byteStart` and `byteEnd` at most - the parent atom's `byteEnd`, else `StoreBlockError::SubSpanOutsideParent`. -- The sub-spans of one atom must tile that atom's byte range exactly: the first - sibling's `byteStart` equals the parent atom's `byteStart`, every later - sibling's `byteStart` equals the previous sibling's `byteEnd`, and the last - sibling's `byteEnd` equals the parent atom's `byteEnd`, else - `StoreBlockError::SubSpansNotTiled`. An atom with no sub-spans (a frontmatter, - heading, fenced-block, or whitespace atom) imposes no tiling requirement of its - own; the tiling requirement binds only atoms that carry at least one sub-span. +All in-process through the gix crate; spawning git is forbidden by +the no-process law and condemned independently by measurement, 143 +milliseconds per added object against 0.4 in-process. Publication +becomes write-blob plus a tree update, idempotent by content +address, and read-back bytes must re-hash to the expected napl name +or the read is refused, the same integrity law as today. The store +trait gains a GitProvider beside DiskProvider and the in-memory +provider, parity-tested three ways; DiskProvider remains the backend +for gitless workspaces, and a workspace inside a git repository +defaults to GitProvider after migration. -These occurrence-index invariants make an accepted `BlockIndex` a genuine ordered -two-level tiling, not merely a serde-shaped value: a downstream reader may treat a -validated index as an ordered, tiled occurrence index. The check order is exactly -the order listed above, so `UnknownParentAtom` is decided before any sub-span is -resolved against its parent, and the returned error is the first failure in that -order. +The dependency is gix with its default features off and no feature +added back: writing loose blobs and trees, building a commit object +field by field, moving a ref under an expected old value, and +reading refs and trees are all core surface, so the minimal set is +the empty set. gix persists a loose object by flushing a temporary +file and renaming it, and calls fsync on neither the file nor its +directory, read out of the gix-odb source on 2026-07-31. Durability +is therefore this provider's own obligation and it is paid at the +provider boundary: every object written and every ref moved is +forced to durable storage before the call returns. -The struct deserialization is plain serde with `#[serde(deny_unknown_fields)]` on -all three structs; no hand-written deserializer is needed, and there is no -adjacently tagged enum anywhere in this module. +Publication writes the blob and records the object in a local, +gitignored pending file until the next transaction folds it into a +tree, so an object is readable the instant it is published, exactly +as it is in the tracked layout. That file is coordination state that +never travels, like the staging records and markers beside it. ::: test -name: a block-index with an unknown top-level field is rejected -given: { json: "the BI_MIN object with an extra top-level key \"bogus\": 1" } -expect: { error: Malformed } +name: the git provider answers identically to the disk and memory providers +given: { fixture: PARITY, providers: [memory, disk, git] } +expect: { providers_agree: true } ::: ::: test -name: a block-index missing atoms is rejected -given: { json: "the BI_MIN object with the atoms key removed" } -expect: { error: Malformed } +name: publishing an existing object through the git provider is an idempotent no-op +given: { provider: git, publish: same_object_twice } +expect: { second_outcome: idempotent_success, object_count_unchanged: true } ::: -::: test -name: a block-index whose atoms is the wrong JSON type is rejected -given: { json: "the BI_MIN object with atoms set to the number 1" } -expect: { error: Malformed } -::: +## Migration -::: test -name: an atom record with an unknown field is rejected -given: { json: "the BI_MIN object whose one atom record carries an extra key \"bogus\": 1" } -expect: { error: Malformed } -::: +GitProvider lands behind the `git-store` cargo feature, off by +default, so a workspace that has not opted in neither compiles the +provider nor carries gix at all; the parity battery is green three +ways under that feature. Nothing in the toolchain selects the +provider in this wave: the feature is the whole door, and the +wiring that lets a git workspace pick it up belongs to the flip +wave. A one-shot converter walks the tracked objects and +any pack into blobs, tree, and one commit, re-hashing every blob to +its napl name, measured at 0.355 seconds per 1000 objects. Flipping +the default for git workspaces, letting the tracked files leave the +working tree, and deleting the packfile machinery all belong to a +later wave that the remaining gates below guard; until then the +tracked layout stays the default and the flag is the only door in. +The tracked-file reader survives one release past the flip for old +checkouts. -::: test -name: an atom record missing contentHash is rejected -given: { json: "the BI_MIN object whose one atom record has the contentHash key removed" } -expect: { error: Malformed } -::: +## The gates -::: test -name: a sub-span record with an unknown field is rejected -given: { json: "the BI_RICH object whose first sub-span record carries an extra key \"bogus\": 1" } -expect: { error: Malformed } -::: +The build gates are cleared: the owner ruled the gix dependency in on +2026-07-31 and approved a local evaluation build, and the fsync +obligation is paid: gix fsyncs nothing it writes, so the provider +fsyncs every object and every ref at its own boundary, which is what +lets the parity battery be called green. What remains gated is +flipping the default provider for git workspaces: -::: test -name: a block-index whose segVersion is the wrong JSON type is rejected -given: { json: "the BI_MIN object with segVersion set to the string \"1\"" } -expect: { error: Malformed } -::: +1. A GitHub reality spike on a throwaway repository: atomic dual + push acceptance, branch protection interaction, direct-push + policy, shallow and blobless clone behavior of the store branch. +2. An answer for organizations requiring pull requests on every + branch, where direct pushes are forbidden and this design fails; + the tracked layout remains the floor there, which is why its + machinery outlives this module's landing. -::: test -name: an atom whose contentHash is not sixty-four lowercase hex is rejected -given: { json: "the BI_MIN object with its one atom contentHash set to \"xyz\"" } -expect: { error: MalformedHash } -::: +Open questions carried from the spike: shallow and partial clone +completeness, growth at one hundred thousand store commits, archive +and tarball consumers who lose the store by construction, and +whether git worktrees share the store branch cleanly. +napl-store blob v1 614065 + +# napl-store: the content-addressed store + +This prompt describes the napl-store crate, migrated from ten legacy module +prompts into one module. The code was hand-ported from the legacy store +crates and adopted as this prompt's committed output; the sections below +are the ported prompts, merged. Section names preserve the legacy module +boundaries, which survive as source modules inside the crate. + +## store_hash +# Store object hashing: the typed envelope and the read verifier + +This module defines the lowest layer of the NAPL content-addressed store: how an +object's bytes are framed into a typed, versioned, length-prefixed envelope, how +that envelope is named by its SHA-256, the canonical JSON encoding every +non-blob object payload obeys, and the read verifier that validates an object's +bytes against its name before any consumer trusts them. It is pure: no +filesystem, no I/O, no clocks, no randomness, and no dependency on any other +project module. Bring in `serde_json` for JSON values and the `sha2` crate for +SHA-256; do not reimplement SHA-256 and do not substitute a different SHA-256 +crate. + +## Where this code lives + +The working directory is a Cargo workspace whose root manifest is written and +owned by the toolchain, leave it alone. Create this module as its own member +crate in a subdirectory named `store_hash/`: `store_hash/Cargo.toml` (package +name `store_hash`) and `store_hash/src/lib.rs`. Touch nothing outside +`store_hash/`. Ensure `cargo test` passes from the workspace root before +finishing. + +## The store-wide hash format version + +Expose the public constant `pub const HASH_FORMAT_VERSION: u32 = 1`, the +store-wide hash format version. Its name is `HASH_FORMAT_VERSION` and its type is +`u32`. It is baked into every object's envelope, so it is part of every object's +name. It is store-wide: one value for the whole store, never per object or per +kind. + +## Object kinds + +Expose a public enum `ObjectKind` (deriving `Debug`, `Clone`, `Copy`, +`PartialEq`, and `Eq`) with exactly these variants and wire tokens: + +- `Blob` renders as `blob` +- `BlockIndex` renders as `block-index` +- `Tree` renders as `tree` +- `ModuleState` renders as `module-state` +- `Event` renders as `event` +- `Snapshot` renders as `snapshot` + +Render a kind to its wire token through the inherent method `as_wire(self) -> +&'static str`, and parse a wire token back into a kind through the inherent +associated function `from_wire(token: &str) -> Option`, which yields +`None` for any token not in this exact set. Use these two inherent methods with +exactly these names; do not implement the `Display` or `FromStr` traits for this +purpose. A wire token never contains a space; it may contain a hyphen +(`block-index`, `module-state`). + +Only `Blob` carries raw payload bytes. Every other kind carries a canonical JSON +payload (see below); this distinction is what the read verifier enforces. + +## The envelope + +An object's bytes are its full envelope, never bare payload. The envelope is the +concatenation, in order, of: + +- the literal ASCII magic `napl-store` +- a single space +- the kind's wire token +- a single space, then the ASCII letter `v`, then the hash format version as + minimal decimal digits (the value `1`) +- a single space +- the payload's length in bytes, as minimal decimal ASCII digits (no leading + zeros; the single digit `0` for an empty payload) +- a single newline byte (`\n`, `0x0A`) +- the payload bytes, verbatim + +So a blob whose payload is the two bytes `hi` has the envelope bytes +`napl-store blob v1 2\nhi`, and an empty blob has the envelope bytes +`napl-store blob v1 0\n`. Expose the function `envelope_bytes(kind: ObjectKind, +payload: &[u8]) -> Vec`, returning the envelope bytes. The payload is copied +in verbatim: its bytes are never re-encoded, escaped, or altered, and it may +contain spaces, newlines, or arbitrary binary. + +Because the kind and version sit inside the envelope, and therefore inside the +name, an identical payload framed under two different kinds produces two +different names, and the same payload under a future version would too. ::: test -name: a sub-span whose contentHash is not sixty-four lowercase hex is rejected -given: { json: "the BI_RICH object with its first sub-span contentHash set to \"xyz\"" } -expect: { error: MalformedHash } +name: a blob envelope is the exact length-prefixed byte string +given: { kind: blob, payload: "hi" } +expect: { envelope_bytes: "napl-store blob v1 2\nhi" } ::: ::: test -name: a block-index whose segVersion is zero is rejected -given: { json: "the BI_MIN object with segVersion set to 0" } -expect: { error: InvalidSegVersion } +name: an empty blob envelope carries a zero length and a trailing newline +given: { kind: blob, payload: "" } +expect: { envelope_bytes: "napl-store blob v1 0\n" } ::: ::: test -name: an atom whose byteStart exceeds its byteEnd is rejected -given: { json: "the BI_MIN object with its one atom byteStart set to 9 and byteEnd left at 5" } -expect: { error: InvalidSpan } +name: identical payload under two kinds gets two different names +given: { payload: "X", kind_a: blob, kind_b: snapshot } +expect: { name_a: "d11071939b166fd9bb74e2937789b0c7b224fd56d341024d34729dfe9c521073", name_b: "88bb4c116d992830f1d7a387af00aaaa660e4f9e6482f0f33c1bf5dbeabbbdcc", equal: false } ::: +## Object naming + +Expose the function `object_name(kind: ObjectKind, payload: &[u8]) -> String`, +returning the object's name: the SHA-256 digest of the envelope bytes, encoded +as a lowercase hexadecimal string. The name is always 64 characters, each a +lowercase hex digit +(`0` through `9`, `a` through `f`), with no uppercase. For example, the blob +whose payload is `hi` is named +`29757fd72ed1d3f68debb794c6ce43b16f16de6b2256c504cd7caff92b40c4da`. This must +match exactly what a standard SHA-256 implementation produces over the envelope +bytes. + ::: test -name: an atom whose lineEnd is below its lineStart is rejected -given: { json: "the BI_MIN object with its one atom lineStart set to 4 and lineEnd left unchanged at its BI_MIN value of 1, no other field touched, so the atom carries lineStart 4 and lineEnd 1" } -expect: { error: InvalidSpan } +name: the object name is the sha256 of the envelope bytes +given: { kind: blob, payload: "hi" } +expect: { object_name: "29757fd72ed1d3f68debb794c6ce43b16f16de6b2256c504cd7caff92b40c4da" } ::: ::: test -name: a sub-span whose byteStart exceeds its byteEnd is rejected -given: { json: "the BI_RICH object with its first sub-span byteStart set to 9 and byteEnd left at 5" } -expect: { error: InvalidSpan } +name: the empty blob has its own stable name +given: { kind: blob, payload: "" } +expect: { object_name: "588ecb60283f38e891a374bef0c39523cabb9a6b49d1d6e8f5605b799f0eda59" } ::: ::: test -name: a sub-span whose parentAtomIndex names no atom is rejected -given: { json: "the BI_RICH object with its first sub-span parentAtomIndex set to 5, past the two atoms present" } -expect: { error: UnknownParentAtom } +name: an object name is 64 lowercase hex characters +given: { kind: blob, payload: "anything" } +expect: { length: 64, lowercaseHex: true } ::: +## Canonical JSON + +Every object kind other than `Blob` serializes its payload as canonical JSON. +Expose the function `canonical_json(value: &serde_json::Value) -> Vec`, +returning its canonical encoding, defined as: + +- UTF-8 bytes. +- Object keys sorted, recursively at every level, in ascending lexicographic + order of their UTF-8 bytes. Sort the keys explicitly inside this function + rather than relying on any map type's iteration order, so that a + `preserve_order` feature enabled elsewhere in the workspace can never change + the output. +- Compact: no insignificant whitespace anywhere, so a comma or a key-value colon + is never followed or preceded by a space, and the whole value is a single line + with no embedded formatting newlines. +- Scalars, numbers included, are emitted exactly as `serde_json`'s compact + serializer renders them; this function applies no separate number + normalization, so a caller who needs two numerically equal values to + canonicalize identically is responsible for supplying them in the same form. +- Exactly one trailing newline byte (`\n`, `0x0A`) appended after the value. + +So the value `{"b":1,"a":2}` encodes to the bytes `{"a":2,"b":1}\n`. This +canonical form is what makes a JSON object's bytes, and therefore its name, +independent of the key order or whitespace of whatever produced the value. + ::: test -name: an atom whose atomIndex duplicates an earlier atom is rejected -given: { json: "the BI_RICH object with its second atom atomIndex set to 0, duplicating the first atom" } -expect: { error: NonSequentialAtomIndex } +name: "canonical json sorts keys, drops insignificant whitespace, and ends in one newline" +given: { value: "{\"b\":1,\"a\":2}" } +expect: { canonical_bytes: "{\"a\":2,\"b\":1}\n" } ::: ::: test -name: an atom whose atomIndex skips its position is rejected -given: { json: "the BI_RICH object with its second atom atomIndex set to 2" } -expect: { error: NonSequentialAtomIndex } +name: a canonical json object under a json kind pins its envelope and name +given: { kind: snapshot, value: "{\"b\":1,\"a\":2}" } +expect: { envelope_bytes: "napl-store snapshot v1 14\n{\"a\":2,\"b\":1}\n", object_name: "4a8a7b1b5f14247c9d31bd887c545c831518d4cdb0fa5694b3cfcbeb21446fe4" } ::: +## The read verifier + +Before any consumer trusts an object read from the store, it passes the object's +claimed name and its raw bytes through the verifier. Expose the function +`verify_object(name: &str, bytes: &[u8]) -> Result`. +On success it returns the public struct `VerifiedObject` with public fields `kind: +ObjectKind` and `payload: Vec` (the struct deriving `Debug`, `Clone`, +`PartialEq`, `Eq`), carrying the parsed kind and the payload bytes. On failure it +returns the typed error enum below. + +The verifier performs these checks in exactly this order, returning the first +failure it encounters, so that each failure mode is reachable independently: + +1. **Envelope well-formedness.** Locate the first newline byte. If there is + none, the bytes are not an envelope: fail as `MalformedEnvelope`. Everything + before that first newline is the header; everything after it is the payload. + The header bytes must be valid UTF-8; a header that is not valid UTF-8 fails + as `MalformedEnvelope`. This UTF-8 requirement is part of well-formedness, so + a non-UTF-8 byte anywhere in the header, including inside the kind token, is a + malformed envelope and never reaches the kind check. Split the header on + single space bytes; it must yield exactly four fields. The first field must be + exactly `napl-store`. The third field must be the letter `v` followed by a + non-empty run of ASCII decimal digits in minimal form: no leading zeros, the + lone digit `0` being the only run that may begin with `0`. The fourth field + must be a non-empty run of ASCII decimal digits in that same minimal form, and + must fit in a `u64`; a length digit run too large to fit `u64` fails here as + `MalformedEnvelope`, and no fabricated byte count is ever produced or reported + for it. Any deviation fails as `MalformedEnvelope`. +2. **Kind.** Parse the second header field as an `ObjectKind` wire token. A token + outside the known set fails as `UnknownKind`. +3. **Version.** Parse the minimal-decimal digits after the `v` in the third + field as a `u64`. Any value other than the store-wide hash format version, + including a digit run too large to fit `u64`, fails as `UnsupportedVersion`. +4. **Declared length.** Interpret the fourth field as the declared payload byte + count, a `u64` already confirmed minimal and in range in step 1. If it does + not equal the actual number of payload bytes (also taken as a `u64`), fail as + `LengthMismatch`, carrying both the declared and the actual counts. +5. **Name.** Compute the SHA-256 of the whole envelope bytes as lowercase hex. If + it does not equal the supplied name, fail as `HashMismatch`. +6. **Canonical payload.** If the kind is not `Blob`, the payload must be + canonical JSON: parsing the payload bytes as a JSON value and re-encoding that + value through the canonical JSON function must reproduce the payload bytes + exactly. If the payload does not parse as JSON, or the re-encoding differs by + even one byte (unsorted keys, stray whitespace, a missing or extra trailing + newline), fail as `NonCanonicalJson`. A `Blob` payload is raw bytes and is + never subjected to this check; a blob of arbitrary non-JSON binary verifies + successfully once its name matches. + +On success the returned kind and payload let the caller decode the object +without reparsing the envelope. The error enum is named `VerifyError` and has one +variant per failure mode above: `MalformedEnvelope`, `UnknownKind`, +`UnsupportedVersion`, `LengthMismatch` carrying the declared and actual byte +counts (both `u64`), `HashMismatch`, and `NonCanonicalJson`. It derives `Debug`, +`Clone`, `PartialEq`, and `Eq`. + ::: test -name: atom records out of document order are rejected -given: { json: "the BI_RICH object with its two atom records reversed in the atoms array" } -expect: { error: NonSequentialAtomIndex } +name: verify round-trips a freshly built blob object +given: { kind: blob, payload: "hi", name: "29757fd72ed1d3f68debb794c6ce43b16f16de6b2256c504cd7caff92b40c4da" } +expect: { ok: true, kind: blob, payload: "hi" } ::: ::: test -name: atoms that overlap in bytes are rejected -given: { json: "the BI_RICH object with its second atom byteStart set to 8, overlapping the first atom that ends at 10" } -expect: { error: AtomsNotTiled } +name: verify accepts a canonical json object +given: { name: "4a8a7b1b5f14247c9d31bd887c545c831518d4cdb0fa5694b3cfcbeb21446fe4", envelope_bytes: "napl-store snapshot v1 14\n{\"a\":2,\"b\":1}\n" } +expect: { ok: true, kind: snapshot } ::: ::: test -name: atoms that leave a byte gap are rejected -given: { json: "the BI_RICH object with its second atom byteStart set to 12, leaving a gap after the first atom that ends at 10" } -expect: { error: AtomsNotTiled } +name: verify accepts a blob whose payload is not json at all +given: { kind: blob, payload: " not json {" } +expect: { ok: true, kind: blob } ::: ::: test -name: a first atom whose byteStart is not zero is rejected -given: { json: "the BI_RICH object with its first atom byteStart set to 2, so the first atom no longer starts at byte 0" } -expect: { error: AtomsNotTiled } +name: verify rejects bytes that are not an envelope at all +given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "not-an-envelope" } +expect: { error: MalformedEnvelope } ::: ::: test -name: a sub-span whose subIndex duplicates an earlier sibling is rejected -given: { json: "the BI_RICH object with its second sub-span subIndex set to 0, duplicating the first sibling" } -expect: { error: NonSequentialSubIndex } +name: verify rejects a header with no payload newline +given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "napl-store blob v1 2 hi" } +expect: { error: MalformedEnvelope } ::: ::: test -name: a sub-span whose subIndex skips its position is rejected -given: { json: "the BI_RICH object with its second sub-span subIndex set to 2" } -expect: { error: NonSequentialSubIndex } +name: verify rejects a declared length that disagrees with the payload +given: { name: "a84cd278760eb18441b94fc4b4c34c87e008b1296e5f78bd76e417f40f0f085d", bytes: "napl-store blob v1 5\nhi" } +expect: { error: LengthMismatch, declared: 5, actual: 2 } ::: ::: test -name: a sub-span reaching outside its parent atom byte range is rejected -given: { json: "the BI_RICH object with its second sub-span byteEnd set to 25, past its parent atom that ends at 10" } -expect: { error: SubSpanOutsideParent } +name: verify rejects an unknown kind token +given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "napl-store frobnicate v1 2\nhi" } +expect: { error: UnknownKind } ::: ::: test -name: sibling sub-spans that overlap in bytes are rejected -given: { json: "the BI_RICH object with its second sub-span byteStart set to 3, overlapping the first sibling that ends at 5" } -expect: { error: SubSpansNotTiled } +name: verify rejects a header carrying a non-UTF-8 byte in the kind token +given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "the envelope napl-store v1 2 then newline then hi, where the kind token holds a raw 0xFF byte that is not valid UTF-8" } +expect: { error: MalformedEnvelope } ::: ::: test -name: sibling sub-spans that leave a byte gap are rejected -given: { json: "the BI_RICH object with its second sub-span byteStart set to 7, leaving a gap after the first sibling that ends at 5" } -expect: { error: SubSpansNotTiled } +name: verify rejects a non-minimal version with a leading zero +given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "napl-store blob v01 2\nhi" } +expect: { error: MalformedEnvelope } ::: ::: test -name: a first sibling sub-span whose byteStart is not its parent atom byteStart is rejected -given: { json: "the BI_RICH object with its first sub-span byteStart set to 2, so the first sibling no longer starts at its parent atom byteStart of 0" } -expect: { error: SubSpansNotTiled } +name: verify rejects a non-minimal declared length with a leading zero +given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "napl-store blob v1 02\nhi" } +expect: { error: MalformedEnvelope } ::: ::: test -name: a last sibling sub-span whose byteEnd is not its parent atom byteEnd is rejected -given: { json: "the BI_RICH object with its second sub-span byteEnd set to 8, so the last sibling no longer ends at its parent atom byteEnd of 10" } -expect: { error: SubSpansNotTiled } +name: verify rejects a declared length too large to fit u64 +given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "napl-store blob v1 999999999999999999999\nhi" } +expect: { error: MalformedEnvelope } ::: ::: test -name: a block-index whose segVersion is the largest u32 is accepted -given: { json: "the BI_MIN object with segVersion set to 4294967295" } -expect: { accepts: true } +name: verify rejects an unsupported hash format version +given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "napl-store blob v2 2\nhi" } +expect: { error: UnsupportedVersion } ::: ::: test -name: a block-index whose segVersion exceeds the u32 range is rejected -given: { json: "the BI_MIN object with segVersion set to 4294967296" } -expect: { error: Malformed } +name: verify rejects a name that does not match the envelope bytes +given: { name: "0000000000000000000000000000000000000000000000000000000000000000", bytes: "napl-store blob v1 2\nhi" } +expect: { error: HashMismatch } ::: -## Naming a block-index object through `store_hash` - -Expose `block_index_object_name(index: &BlockIndex) -> String`, the object's name: -serialize the value to a `serde_json::Value`, and return -`store_hash::object_name(store_hash::ObjectKind::BlockIndex, -&store_hash::canonical_json(&value))`. This does not reimplement canonical JSON, -envelope framing, or hashing; it calls `store_hash`. The canonical bytes fed to -the envelope are exactly `store_hash::canonical_json` applied to the serialized -value, so a round-trip through `store_hash::verify_object` over the framed -envelope succeeds and reports `store_hash::ObjectKind::BlockIndex`. - -## The segmentation version constant - -Expose the public constant `pub const SEG_VERSION: u32 = 1`, the current -segmentation format version this module emits by default. - ::: test -name: the default segmentation version constant is one -given: {} -expect: { seg_version_const: 1 } +name: verify rejects a json kind whose payload keys are not sorted +given: { name: "324c5a38a26b99ba50a001a7abdf368cb43ce356112be2a0525582b2694c553e", bytes: "napl-store snapshot v1 14\n{\"b\":1,\"a\":2}\n" } +expect: { error: NonCanonicalJson } ::: -## Segmenting a prompt +## Rendering a verification failure -Expose `segment(prompt: &str, seg_version: u32) -> BlockIndex`, the pure -segmentation. It reads the prompt text, partitions it into atoms that tile the -whole document, computes sentence sub-spans inside prose atoms, and returns a -`BlockIndex` whose `segVersion` is the given `seg_version`. Every `contentHash` is -`hash::content_hash` of the exact bytes the record spans. It is deterministic: no -LLM, no I/O, no randomness. An empty prompt yields a `BlockIndex` with no atoms and -no sub-spans. +Implement `std::fmt::Display` for `VerifyError` so a consumer can render a +verification failure as a bare, human-readable message that carries no `Debug` +artifacts: no variant name, no quotes, and no braces. Each variant renders exactly +one deterministic line. `MalformedEnvelope` renders `malformed object envelope`. +`UnknownKind` renders `unknown object kind`. `UnsupportedVersion` renders +`unsupported store format version`. `LengthMismatch` renders `object length +mismatch: declared {declared} bytes, actual {actual} bytes`, interpolating its two +byte counts as plain decimals in that order. `HashMismatch` renders `object hash +does not match its name`. `NonCanonicalJson` renders `object payload is not +canonical JSON`. This addition is purely additive: the `Debug` derive is retained +for the test assertions that pin variant equality, and `ObjectKind` keeps its +standing prohibition on a `Display` impl. The `Display` impl and its `fmt` method +carry no doc comment, matching the crate's existing trait-impl style. -Input contract: `segment` has one precondition, that the prompt's byte length fit -in a `u32`, that is fewer than 4294967296 bytes. Byte offsets, line numbers, atom -indices, and sub-indices are all carried as `u32`, and NAPL prompt blobs are far -below this bound (kilobytes, not gigabytes), so the precondition holds for every -real prompt. Under this precondition `segment` is total: it returns a `BlockIndex` -for every input, with no panic and no error path, since the design gives `segment` -no fallible return. A prompt at or beyond the `u32` byte bound is outside the -contract and its offsets are not required to be meaningful; callers bound prompt -size before segmenting rather than passing gigabyte inputs. +## Test and code discipline -Byte offsets throughout are UTF-8 byte offsets into `prompt`; line and atom and -sub-span boundaries always fall on character boundaries (they are placed only at -line starts or at ASCII boundary bytes), so every span slices `prompt` into valid -text and the pieces concatenate back to `prompt` exactly. +The frontmatter test suite is a contract, not a sketch. Each case in it is +realized by its own dedicated test function that exercises only that case, so the +number of dedicated tests is at least the number of frontmatter cases. Never fold +two or more cases into one shared test, and a single test that loops over a table +of inputs never counts as the dedicated test any of those inputs requires: such a +combined test may exist only as an extra. Cases that differ only in their input +while sharing an expected value are still distinct cases, each owed its own +dedicated test: the two envelope-shape cases (the two-byte blob and the empty +blob) get one test each, and every case that expects `MalformedEnvelope` (bytes +that are not an envelope at all, a header with no payload newline, a non-UTF-8 +byte in the kind token, a leading-zero version, a leading-zero length, and a +length too large for u64) and every case that expects `UnsupportedVersion` gets +its own dedicated test, whatever shared loop may also exist as an extra. -::: test -name: an empty prompt segments into an empty block-index -given: { prompt: "" } -expect: { atom_count: 0, sub_span_count: 0, seg_version: 1 } -::: +Inside a case's dedicated test, hand the code exactly the fixture that case +gives, byte for byte, and assert its pinned expectation verbatim: no shorter or +more convenient stand-in, no re-derivation of a value or object the case pins as +a literal, and no weakening of a pinned equality into an inequality-only or +recomputed check. Where a case pins a `name`, pass exactly that name string, +whether it is the all-zeros placeholder several rejection cases use or a specific +digest such as the length-mismatch case's, never an arbitrary word and never +normalized to zeros. Where a case pins `bytes`, verify those exact bytes and never +substitute a re-derived object or a different kind: the canonical-json accept +case verifies the pinned snapshot envelope +`napl-store snapshot v1 14\n{"a":2,"b":1}\n` under its pinned name, and the +hash-mismatch case verifies the pinned bytes `napl-store blob v1 2\nhi` under its +all-zeros name. Where a case describes its bytes in prose rather than as a +literal (the non-UTF-8 kind token), construct exactly the bytes it describes. -### Lines +So the unknown-kind case frames the exact token `frobnicate`, the lowercase-hex +case uses payload `anything`, the non-JSON blob case uses payload ` not json {`, +the not-an-envelope case uses bytes `not-an-envelope`, the no-payload-newline +case uses its exact bytes `napl-store blob v1 2 hi`, the length-too-large case +uses the digit run `999999999999999999999`, the freshly-built-blob verify case +passes its pinned name +`29757fd72ed1d3f68debb794c6ce43b16f16de6b2256c504cd7caff92b40c4da`, and the +unsorted-keys case uses the snapshot envelope bytes +`napl-store snapshot v1 14\n{"b":1,"a":2}\n` against `NonCanonicalJson`, each in +its own test. The pinned digests are asserted +verbatim: the empty-blob digest +`588ecb60283f38e891a374bef0c39523cabb9a6b49d1d6e8f5605b799f0eda59`, the two +different names for payload `X` under `blob` +(`d11071939b166fd9bb74e2937789b0c7b224fd56d341024d34729dfe9c521073`) and under +`snapshot` (`88bb4c116d992830f1d7a387af00aaaa660e4f9e6482f0f33c1bf5dbeabbbdcc`), +and the snapshot envelope bytes `napl-store snapshot v1 14\n{"a":2,"b":1}\n` with +its digest `4a8a7b1b5f14247c9d31bd887c545c831518d4cdb0fa5694b3cfcbeb21446fe4`. +A dedicated test asserts only its own case and nothing more: the blob-name case +for payload `hi` asserts only the pinned digest equality, leaving the +sixty-four-lowercase-hex shape to the `anything` case that owns it, and the +canonical-json case asserts only that `{"b":1,"a":2}` canonicalizes to +`{"a":2,"b":1}\n`. Any further name-shape or canonicalization fixture, such as +nested objects, arrays, null, or escaped strings, lives in its own separately +named supplemental test rather than riding along inside a suite case's test. +Additional tests beyond this suite are welcome once every case has its dedicated +test, but they never replace or subsume a suite case. -Split `prompt` into lines on the newline byte `\n` (`0x0A`). Each line consists of -its content bytes plus its terminating `\n` if it has one; the final line has no -terminating `\n` when the document does not end in one. Every byte belongs to -exactly one line, so the lines concatenate back to `prompt`. Lines are numbered -from 1. A line's **content** is its bytes with a single trailing `\n` removed if -present. An empty prompt has zero lines; the prompt `\n` has exactly one line -whose content is empty. +Beyond the suite, one dedicated supplemental test per `VerifyError` variant +asserts that variant's exact `Display` line verbatim through `to_string()`: +`MalformedEnvelope` renders `malformed object envelope`, `UnknownKind` renders +`unknown object kind`, `UnsupportedVersion` renders `unsupported store format +version`, `HashMismatch` renders `object hash does not match its name`, +`NonCanonicalJson` renders `object payload is not canonical JSON`, and a +`LengthMismatch` constructed with declared `5` and actual `9` renders +`object length mismatch: declared 5 bytes, actual 9 bytes`. Each of these six is +its own dedicated test asserting only its own line, and none replaces or subsumes +a suite case. -Classify a line by its content (all byte tests are on ASCII bytes): +Write no explanatory line or block comments in the generated code. Doc comments +restating this prompt's contract on public items are the only commentary the +code carries; there are no other comments of any kind. -- **blank**: content is empty, or every byte of content is a space (`0x20`) or a - tab (`0x09`). -- **fence**: content, after removing any leading spaces and tabs, begins with - three consecutive backtick bytes (each `0x60`). -- **heading**: the first byte of content is `#` (`0x23`). -- **list-item**: content, after removing any leading spaces and tabs, either - begins with one of `-` `*` `+` immediately followed by a space, or begins with a - non-empty run of ASCII digits immediately followed by `.` or `)` and then a - space. -- **frontmatter fence**: content is exactly the three bytes `---`. -- **top-field**: the content's first byte is an ASCII letter, digit, or underscore, - and the maximal leading run of bytes drawn from ASCII letters, digits, - underscore, and hyphen is immediately followed by a colon `:`. The asymmetry is - intentional and exact: a hyphen may appear inside the run but never as the first - byte, so `a-b:` is a top-field while `-b:` is not, which keeps a `- ` list-item - marker from ever reading as a top-field key. +## store_objects +# Store object model: strict serde types for module-state, event, and snapshot -### Regions +This module defines the strict, canonical-JSON serde types for the three +provenance and state object kinds of the NAPL content-addressed store: +`module-state`, `event`, and `snapshot`. It defines the distinguished unknown +state sentinel that a state reference may hold, the full set of event kinds +including the migrated `legacy` kind, the non-advancing outcome rule that a +failed or interrupted step obeys, and the optional model-provenance record a gen +or derivation step carries together with the pinned price table that estimates +its cost. It is pure: no filesystem, no I/O, no clocks, no randomness. Bring in +`serde` and `serde_json`. -If the document has at least one line and line 1's content is exactly `---`, look -for the next line (line 2 onward) whose content is exactly `---`; call it the -closing fence. If a closing fence exists at line `c`, the frontmatter region is -lines 1 through `c` inclusive and the body region is lines `c + 1` onward. If line -1 is `---` but no closing fence exists, or line 1 is not `---`, there is no -frontmatter and the entire document is body. +## Scope boundary: which object kinds live here -### Atoms +The store has six object kinds. This module owns the serde types for exactly +three of them, the provenance and state kinds: `module-state`, `event`, and +`snapshot`. The other three are out of scope here and are never redefined in this +crate: -Atoms tile the whole document: every line belongs to exactly one atom, atoms are -contiguous and in document order, and `atomIndex` numbers them from 0. An atom's -`byteStart` is its first line's starting byte offset, its `byteEnd` is its last -line's ending byte offset, its `lineStart` and `lineEnd` are its first and last -line numbers, and its `contentHash` is `hash::content_hash` of the prompt slice -`[byteStart, byteEnd)`. +- `blob` is raw payload bytes and has no typed payload struct; it is handled + entirely by the `store_hash` layer. +- `block-index` is defined by the dedicated `store_blocks` module. +- `tree` is defined by the dedicated `store_tree` module. -Determine atom boundaries by choosing which lines start a new atom. +Every reference this module makes to another object, whether a prompt blob, a +block index, a generated tree, an attribution document, a mapl document, an ir +document, a task bundle, a diagnostic, a prior event, a parent snapshot, or a +module-state, is a **content-addressed object name**: the sixty-four-character +lowercase hexadecimal SHA-256 hash string that `store_hash` produces. This module +never embeds another object's structure inline; it stores that object's hash. So +this module needs neither the block-index nor the tree payload types: a +module-state references them by hash. -In the frontmatter region (when present): the opening fence line (line 1) starts -an atom; every top-field line strictly between the two fences starts an atom; and -the closing fence line starts an atom. Lines that are neither the fences nor -top-field lines (indented values, list items, blank lines inside the -frontmatter) attach to the atom of the preceding boundary. Frontmatter atoms are -structural and never carry sub-spans. +## Where this code lives -In the body region, scan the body lines in order carrying a boolean -`inside a fenced block` (initially false) and the classification context of the -previous body line. The first body line always starts an atom. For each body line -after the first: +The working directory is a Cargo workspace whose root manifest is written and +owned by the toolchain, leave it alone. Create this module as its own member crate +in a subdirectory named `store_objects/`: `store_objects/Cargo.toml` (package name +`store_objects`) and `store_objects/src/lib.rs`. Touch nothing outside +`store_objects/`. Ensure `cargo test` passes from the workspace root before +finishing. -- If currently inside a fenced block: this line does not start an atom; if its - content is a fence, the fenced block now closes (record that the previous line - closed a fence and clear the inside-a-fenced-block flag), otherwise it is fenced - content. Move on. -- Otherwise the line starts a new atom if and only if any of these hold: its - content is a fence; or its content is a heading; or its content is a list-item; - or its content is non-blank and the previous body line was blank, was a heading, - or closed a fenced block. -- After deciding, update the context: a fence line opens a fenced block (set the - flag); otherwise remember whether this line was blank, a heading, a list-item, - or plain. +## Builds on the `store_hash` module of this workspace -This makes each heading its own atom, each fenced block one atom from its opening -fence through its closing fence (an unterminated fence runs to the end of the -document), each list item its own atom, and each paragraph one atom; trailing -blank lines are absorbed into the atom they follow, and a blank line never starts -an atom. +This crate composes on the generated sibling crate **`store_hash`** +(`../store_hash`) for object naming, envelope framing, canonical JSON, and read +verification. Use its public API, do not reimplement its types or logic, and do +not depend on any hand-written crate. The items this module uses from it: -::: test -name: a prompt of a single newline segments into one blank atom with no sub-spans -given: { prompt: "\n" } -expect: - sub_span_count: 0 - atoms: - - { atomIndex: 0, byteStart: 0, byteEnd: 1, lineStart: 1, lineEnd: 1, text: "\n" } -::: +- `store_hash::ObjectKind`, the object-kind enum whose variants + `ObjectKind::ModuleState`, `ObjectKind::Event`, and `ObjectKind::Snapshot` this + module names its three kinds with; their wire tokens `module-state`, `event`, + and `snapshot` come from `store_hash`, never re-spelled here. +- `store_hash::canonical_json(value: &serde_json::Value) -> Vec`, the + canonical JSON encoder (sorted keys, compact, one trailing newline) that turns a + serialized value into its canonical bytes. +- `store_hash::envelope_bytes(kind, payload) -> Vec` and + `store_hash::object_name(kind, payload) -> String`, the typed envelope framing + and the SHA-256 object name over those envelope bytes. +- `store_hash::verify_object(name, bytes) -> Result`, + the read verifier the round-trip tests pass a freshly built object through. -### Sub-spans +## Content hashes are plain strings -Compute sub-spans only for a **prose atom**: a body atom whose first line is -neither a heading nor a fence, and whose byte slice contains at least one -non-whitespace byte. Frontmatter atoms, heading atoms, fenced-block atoms, and -atoms that are entirely whitespace produce no sub-spans. For a prose atom, run the -sentence splitter below over the atom's byte slice; it yields one or more spans -that tile the atom's bytes. Each becomes a `SubSpanRecord` whose `parentAtomIndex` -is the atom's `atomIndex`, whose `subIndex` numbers the spans from 0 within the -atom, whose `byteStart` and `byteEnd` are the sub-span's absolute byte offsets into -`prompt`, whose `lineStart` and `lineEnd` are the one-based line numbers of the -sub-span's first byte and of its last byte, and whose `contentHash` is -`hash::content_hash` of the sub-span's exact bytes. +A content-addressed object name is carried as a `String` throughout this module, +matching the rest of the workspace, never a newtype. A well-formed object name is +sixty-four lowercase hexadecimal characters (`0` through `9`, `a` through `f`). +The validators below reject any hash-typed field whose string is not exactly that +shape. This module never computes a hash except by delegating to `store_hash`. -A sub-span is never empty: `byteEnd` is strictly greater than `byteStart`, because -the splitter emits a boundary only where a following sentence opener exists and -the final span runs to the non-empty atom's end. Its last byte is therefore the -byte at `byteEnd - 1`, and `lineEnd` is the one-based number of the line -containing that byte. So a sub-span that ends in its own line's terminating -newline has that newline as its last byte and takes its `lineEnd` from that line, -not from the line after it. +## The module-state kind -The splitter is conservative: an atom it cannot split with certainty stays a -single sub-span equal to the whole atom, so ambiguity can only coarsen a bundle, -never corrupt one. The sub-spans of one atom always concatenate back to that -atom's bytes, tiling the atom with no gaps and no overlaps. +Expose a public struct `ModuleState` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`, `serde::Serialize`, and `serde::Deserialize`, with +`#[serde(deny_unknown_fields)]`), one module's authoritative state. Its prompt +side is target independent; its generated artifacts are per target. Public fields: -::: test -name: a single unterminated line segments into one atom and one conservative sub-span -given: { prompt: "Hello there." } -expect: - atoms: - - { atomIndex: 0, byteStart: 0, byteEnd: 12, lineStart: 1, lineEnd: 1, text: "Hello there." } - subSpans: - - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 12, text: "Hello there." } -::: +- `prompt_blob: String`, serde field name `promptBlob`. The prompt blob's hash; + this is the module's prompt identity, the concept the rest of the toolchain + calls the prompt hash. +- `block_index: String`, serde field name `blockIndex`. The block-index object's + hash. +- `mapl: Option`, serde field name `mapl`, skipped from serialization when + `None`. The machine-layer document's hash. It sits at the module level, not per + target (see the derivation-layout ruling below). +- `ir: Option`, serde field name `ir`, skipped from serialization when + `None`. The contract-level IR document's hash. It too sits at the module level, + not per target (see the ruling below). +- `targets: std::collections::BTreeMap`, serde field name + `targets`. The per-target artifacts, keyed by target name, always present even + when empty (an empty map serializes as `{}`). A `BTreeMap` is used so the map's + keys serialize in sorted order, matching the store's canonical ordering. -### The sentence splitter +Expose a public struct `TargetArtifacts` (same derives, same +`#[serde(deny_unknown_fields)]`), the artifacts generated for one target. Public +fields: -Given the atom's bytes `t`, scan left to right carrying a boolean `inside -backticks` (initially false) and the current sub-span start offset (initially 0): +- `generated_tree: String`, serde field name `generatedTree`. The generated + directory tree object's hash for this target. +- `attribution: Option`, serde field name `attribution`, skipped from + serialization when `None`. The attribution document's hash for this target. -- If the current byte is a backtick `` ` `` (`0x60`), flip `inside backticks` and - advance. -- Else if `inside backticks` is true, advance (a terminator inside backticks never - ends a sentence). -- Else if the current byte is a **terminator** (`.` `0x2E`, `!` `0x21`, or `?` - `0x3F`): - - If it is a **protected dot** (rules below), advance. - - Otherwise look ahead from just after the terminator: skip a run of whitespace - bytes (space `0x20`, tab `0x09`, newline `0x0A`, carriage return `0x0D`); if at - least one whitespace byte was skipped and the next byte exists and is an - **opener** (an uppercase ASCII letter `A` through `Z`, or an ASCII digit `0` - through `9`), a sentence boundary falls at that opener: emit the sub-span from - the current start up to (not including) the opener's offset, set the current - start to the opener's offset, and continue scanning from there. - - If the look-ahead does not find whitespace-then-opener, advance. -- Else advance. +### The derivation-layout ruling that fixes mapl and ir at the module level -After the scan, emit the final sub-span from the current start to the end of `t`. -If the scan found no boundary at all, the whole atom is a single sub-span. The -trailing whitespace after a terminator always attaches to the sentence it ends, so -the boundary sits exactly at the next sentence's opener. +The current toolchain's generator (`cmd_gen_io`) writes every per-module +derivation document keyed by module name alone, with no target segment in the +path: the machine-layer document at `.napl/mapl/.mapl`, the IR document at +`.napl/ir/.yaml`, the attribution document at +`.napl/attribution/.yaml`, and the prompt-at-gen body at +`.napl/prompts-at-gen/.md`. There is exactly one mapl and one ir file per +module on disk, keyed per module, never per (module, target). The IR document +itself carries only a module field and no target field, confirming it is the +target-independent contract. Therefore, in this schema, both `mapl` and `ir` sit +at the module level of `ModuleState`, and the per-target `TargetArtifacts` record +carries only `generatedTree` and `attribution`. The machine-layer document does +carry an internal target field, yet its on-disk file is keyed per module, so the +store follows the writer's keying and holds one `mapl` hash per module. -Only the terminator `.` can be protected; `!` and `?` always end a sentence when -followed by whitespace and an opener. A `.` at offset `i` in `t` is a **protected -dot** if any of these hold: +### Validating a module-state -- **version, path, or decimal**: the bytes at `i - 1` and `i + 1` both exist and - are both ASCII alphanumeric (a digit or an ASCII letter), as in `v1.5`, - `main.rs`, `store_hash.rs`, `1.5`, or `a.b`. -- **two-letter initialism**: `i` is at least 3, the byte at `i - 1` is an ASCII - letter, the byte at `i - 2` is `.`, and the byte at `i - 3` is an ASCII letter, - protecting the trailing dot of `e.g.` and `i.e.` (their inner dot is already - protected by the version-path-decimal rule). -- **abbreviation**: the maximal run of ASCII letters ending at `i - 1`, the word - immediately before the dot, exactly equals one of the pinned abbreviations, - matched case-sensitively: `etc`, `vs`, `cf`, `al`, `Mr`, `Mrs`, `Ms`, `Dr`, - `St`, `Fig`, `No`. +Expose `validate_module_state(value: serde_json::Value) -> Result`: deserialize the JSON value into a `ModuleState`, returning +`StoreObjectError::Malformed` (see below) on any deserialization failure (an +unknown field, a missing required field, a value of the wrong JSON type), then +apply these semantic checks, returning the first failure: + +- `promptBlob`, `blockIndex`, a present `mapl`, a present `ir`, and, for every + target record, its `generatedTree` and a present `attribution`, must each be a + well-formed object name (sixty-four lowercase hex). Any that is not fails as + `StoreObjectError::MalformedHash`. +- Every key of `targets` must be a non-empty string. An empty target key fails as + `StoreObjectError::EmptyKey`. ::: test -name: a backticked code span is never split into sub-spans -given: { prompt: "Call `a.b.c` now. Done.\n" } -expect: - subSpans: - - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 18, text: "Call `a.b.c` now. " } - - { parentAtomIndex: 0, subIndex: 1, byteStart: 18, byteEnd: 24, text: "Done.\n" } +name: a module-state with an unknown field is rejected +given: { json: "the MS_MIN object with an extra top-level key \"bogus\": 1" } +expect: { error: Malformed } ::: ::: test -name: a version number is never split into sub-spans -given: { prompt: "Use v1.5 here. Stop.\n" } -expect: - subSpans: - - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 15, text: "Use v1.5 here. " } - - { parentAtomIndex: 0, subIndex: 1, byteStart: 15, byteEnd: 21, text: "Stop.\n" } +name: a module-state missing promptBlob is rejected +given: { json: "the MS_MIN object with the promptBlob key removed" } +expect: { error: Malformed } ::: ::: test -name: a file path is never split into sub-spans -given: { prompt: "Edit main.rs now. Stop.\n" } -expect: - subSpans: - - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 18, text: "Edit main.rs now. " } - - { parentAtomIndex: 0, subIndex: 1, byteStart: 18, byteEnd: 24, text: "Stop.\n" } +name: a module-state missing blockIndex is rejected +given: { json: "the MS_MIN object with the blockIndex key removed" } +expect: { error: Malformed } ::: ::: test -name: the e.g. abbreviation is never split into sub-spans -given: { prompt: "See e.g. this. Next one.\n" } -expect: - subSpans: - - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 15, text: "See e.g. this. " } - - { parentAtomIndex: 0, subIndex: 1, byteStart: 15, byteEnd: 25, text: "Next one.\n" } +name: a module-state whose targets is the wrong JSON type is rejected +given: { json: "the MS_MIN object with targets set to the array []" } +expect: { error: Malformed } ::: ::: test -name: the i.e. abbreviation is never split into sub-spans -given: { prompt: "Use i.e. that. Next one.\n" } -expect: - subSpans: - - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 15, text: "Use i.e. that. " } - - { parentAtomIndex: 0, subIndex: 1, byteStart: 15, byteEnd: 25, text: "Next one.\n" } +name: a module-state whose promptBlob is not sixty-four lowercase hex is rejected +given: { json: "the MS_MIN object with promptBlob set to \"xyz\"" } +expect: { error: MalformedHash } ::: ::: test -name: the etc. abbreviation is never split into sub-spans -given: { prompt: "Foo, etc. bar baz.\n" } -expect: - sub_span_count: 1 - subSpans: - - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 19, text: "Foo, etc. bar baz.\n" } -::: - -::: test -name: a plain two-sentence line splits at its terminator -given: { prompt: "A cat sat. A dog ran.\n" } -expect: - subSpans: - - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 11, text: "A cat sat. " } - - { parentAtomIndex: 0, subIndex: 1, byteStart: 11, byteEnd: 22, text: "A dog ran.\n" } +name: a module-state with an empty target key is rejected +given: { json: "the MS_RICH object with the target key \"rust\" renamed to the empty string" } +expect: { error: EmptyKey } ::: ::: test -name: a digit opener starts a new sentence sub-span -given: { prompt: "Item one. 2 is next.\n" } -expect: - subSpans: - - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 10, text: "Item one. " } - - { parentAtomIndex: 0, subIndex: 1, byteStart: 10, byteEnd: 21, text: "2 is next.\n" } +name: a target-artifacts record with an unknown field is rejected +given: { json: "the MS_RICH object whose one target record carries an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: -### Reconstructing the prompt +## State references and the unknown sentinel -Expose `reconstruct(index: &BlockIndex, prompt: &str) -> String`, the exact byte -reconstruction: concatenate, in `atomIndex` order, the prompt slice `[byteStart, -byteEnd)` of each atom, and return the result. For an index produced by `segment` -over the same `prompt`, `reconstruct` returns a string equal to `prompt`, because -the atoms tile the document with no gaps and no overlaps. +A step's state before and after is either a concrete module-state object name, or +the distinguished **unknown** sentinel that migrated legacy steps use when the +real state cannot be reconstructed. Expose a public enum `StateRef` (deriving +`Debug`, `Clone`, `PartialEq`, and `Eq`) with exactly two forms: -## Fixtures the tests pin +- `StateRef::State(String)`, a concrete module-state object name. +- `StateRef::Unknown`, the distinguished unknown state sentinel. -These fixtures are referenced by name in the test suite. The hash constants are -each a sixty-four-character string of a single repeated character: `A` is -sixty-four `a` characters, `B` sixty-four `b`, `C` sixty-four `c`, and `D` -sixty-four `d`. All are well-formed lowercase-hex digests. +`StateRef` serializes and deserializes as a single JSON **string**, not an object: +`State(h)` is the bare hash string `h`, and `Unknown` is the exact literal string +`unknown`. On deserialization, the exact string `unknown` yields +`StateRef::Unknown` and any other string yields `StateRef::State` carrying that +string verbatim. This is unambiguous because a well-formed object name is +sixty-four lowercase hex and can never be the seven-letter word `unknown`. +Implement this string representation directly; do not derive the default enum +representation. -- **BI_MIN**: a `BlockIndex` with `segVersion` = 1, one atom `{atomIndex: 0, - contentHash: A, byteStart: 0, byteEnd: 5, lineStart: 1, lineEnd: 1}`, and no - sub-spans. Its canonical bytes are exactly - `{"atoms":[{"atomIndex":0,"byteEnd":5,"byteStart":0,"contentHash":"","lineEnd":1,"lineStart":1}],"segVersion":1,"subSpans":[]}` - followed by one newline; its complete envelope bytes are exactly the header - `napl-store block-index v1 190\n` immediately followed by those canonical bytes, - the full byte string with nothing after the payload; its object name is - `1660deacdbb4727b6f456c87b2866dfbcbd95e00bf9eda2b6936c39fcf136d28`. +An event's state fields are each `Option`, so the three situations stay +distinct: `None` is an **absent** state (a derivation that legitimately has no +before-state, such as an unborn module), `Some(StateRef::Unknown)` is a legacy gap +whose state is unknown, and `Some(StateRef::State(h))` is a concrete state. Absent +is never conflated with unknown. ::: test -name: a minimal block-index canonicalizes to its pinned bytes -given: { fixture: BI_MIN } -expect: { canonical_bytes: "{\"atoms\":[{\"atomIndex\":0,\"byteEnd\":5,\"byteStart\":0,\"contentHash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"lineEnd\":1,\"lineStart\":1}],\"segVersion\":1,\"subSpans\":[]}\n" } +name: a known state ref serializes as its bare sixty-four hex hash string +given: { state_ref: "known(H2)" } +expect: { serialized_json: "\"2222222222222222222222222222222222222222222222222222222222222222\"" } ::: ::: test -name: a minimal block-index names itself by its pinned digest -given: { fixture: BI_MIN } -expect: { object_name: "1660deacdbb4727b6f456c87b2866dfbcbd95e00bf9eda2b6936c39fcf136d28" } +name: the unknown state sentinel serializes as the literal string unknown +given: { state_ref: unknown } +expect: { serialized_json: "\"unknown\"" } ::: ::: test -name: a minimal block-index frames its pinned envelope bytes -given: { fixture: BI_MIN } -expect: { envelope_bytes: "napl-store block-index v1 190\n{\"atoms\":[{\"atomIndex\":0,\"byteEnd\":5,\"byteStart\":0,\"contentHash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"lineEnd\":1,\"lineStart\":1}],\"segVersion\":1,\"subSpans\":[]}\n" } +name: the literal string unknown deserializes to the unknown state sentinel +given: { serialized_json: "\"unknown\"" } +expect: { is_unknown_sentinel: true } ::: ::: test -name: a minimal block-index round-trips through the read verifier -given: { fixture: BI_MIN } -expect: { verify_ok: true, kind: block-index } +name: a bare hash string deserializes to a known state ref +given: { serialized_json: "\"2222222222222222222222222222222222222222222222222222222222222222\"" } +expect: { is_known: true, hash: "2222222222222222222222222222222222222222222222222222222222222222" } ::: ::: test -name: a minimal block-index round-trips back to an equal value -given: { fixture: BI_MIN } -expect: { round_trip_equal: true } +name: an event distinguishes an absent state from the unknown sentinel from a known hash +given: { absent: none, unknown: sentinel, known: H2 } +expect: { absent_is_none: true, unknown_is_sentinel: true, known_is_hash: true, all_distinct: true } ::: -- **BI_RICH**: a `BlockIndex` with `segVersion` = 1, two atoms `{0, A, 0, 10, 1, - 2}` and `{1, B, 10, 20, 3, 3}` (in the field order atomIndex, contentHash, - byteStart, byteEnd, lineStart, lineEnd), and two sub-spans `{parentAtomIndex: 0, - subIndex: 0, contentHash: C, byteStart: 0, byteEnd: 5, lineStart: 1, lineEnd: - 1}` and `{parentAtomIndex: 0, subIndex: 1, contentHash: D, byteStart: 5, byteEnd: - 10, lineStart: 1, lineEnd: 2}`. Its object name is - `e9fccb1f9979d73754a520d7622fe2bcd296e7c58193a8fadfbedea12c08c902`. +## The event kind + +Expose a public struct `Event` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, +`serde::Serialize`, and `serde::Deserialize`, with +`#[serde(deny_unknown_fields)]`), one provenance step for one module. Public +fields, in this shape: + +- `parents: Vec`, serde field name `parents`. The prior event hashes on + this module's single linear chain; always present, an empty chain serializes as + `[]`. +- `module: String`, serde field name `module`. +- `target: Option`, serde field name `target`, skipped from serialization + when `None`. Present only where the step is target scoped. +- `ordinal: u64`, serde field name `ordinal`. The global gen number, allocated at + commit. It is a `u64`. +- `timestamp: String`, serde field name `timestamp`. +- `engine: String`, serde field name `engine`. The engine identifier; it may be + the empty string on a migrated legacy event whose original entry named no + engine. +- `state_before: Option`, serde field name `stateBefore`, skipped from + serialization when `None`. +- `state_after: Option`, serde field name `stateAfter`, skipped from + serialization when `None`. +- `payload: EventPayload`, serde field name `payload`. The kind-specific payload, + described next. +- `attempt_id: Option`, serde field name `attemptId`, skipped from + serialization when `None`. The identifier of the attempt marker this event was + recorded from, present on every event the transaction protocol publishes + (whether a normal commit or a recovery-recorded interrupted event) so the + idempotency guard can find whether an event already carries a given attempt id, + and absent on a legacy or migrated event that names no attempt. It is an opaque + short token such as `att-0001`, not a content hash, and is exempt from the hash + check exactly like the diagnostics field below. + +The `engine` field stays the coarse engine identifier and is never overloaded to +carry the finer model id. The finer model id lives inside a gen or derivation +step's `provenance` record (defined below) and may differ per event under the +same engine; `engine` and `provenance.model` are two separate observations and +neither is derived from the other. + +### The kind-specific payload + +Expose a public enum `EventPayload` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`, and `serde::Serialize`) carrying the kind discriminant and each kind's own +fields. On the wire it is serde's **adjacently tagged** shape, tag key `kind` and +content key `fields`, so it serializes as an object with exactly two keys: `kind`, +the wire token, and `fields`, the kind's data object. + +The wrapper object is strict. On deserialization the payload object must carry +exactly the two keys `kind` and `fields` and nothing else; a payload object that +carries any third sibling key beside `kind` and `fields` is a deserialization +failure, surfacing through `validate_event` as `StoreObjectError::Malformed`. +Serde's default adjacently tagged deserializer does not give you this: it silently +ignores unknown sibling keys, and `#[serde(deny_unknown_fields)]` is not accepted +on an adjacently tagged enum and would not help. So implement the wrapper's +deserialization explicitly to enforce the strictness, for example a hand-written +`Deserialize` for `EventPayload` that reads the payload as a map, rejects any key +other than `kind` and `fields`, and then decodes the `fields` object under the +`kind` token. This wrapper-level strictness never weakens the per-`fields` +`#[serde(deny_unknown_fields)]`: both layers hold at once, the wrapper rejecting a +stray sibling key beside `kind` and `fields`, and each fields struct rejecting a +stray field inside its own `fields` object. + +Each variant is a newtype wrapping a dedicated fields struct that itself derives +`Debug`, `Clone`, `PartialEq`, `Eq`, `serde::Serialize`, and `serde::Deserialize` +and carries `#[serde(deny_unknown_fields)]`, so an unknown field inside a +payload's `fields` object is rejected. The variants, their wire tokens, and their +fields structs: + +- `EventPayload::Gen(GenFields)`, wire token `gen`. +- `EventPayload::Reword(RewordFields)`, wire token `reword`. +- `EventPayload::Reconcile(ReconcileFields)`, wire token `reconcile`. +- `EventPayload::Move(MoveFields)`, wire token `move`. +- `EventPayload::Derivation(DerivationFields)`, wire token `derivation`. +- `EventPayload::Audit(AuditFields)`, wire token `audit`. +- `EventPayload::Revert(RevertFields)`, wire token `revert`. +- `EventPayload::Legacy(LegacyFields)`, wire token `legacy`. + +The fields structs, each `#[serde(deny_unknown_fields)]` and deriving `Debug`, +`Clone`, `PartialEq`, `Eq`, `Serialize`, `Deserialize`: + +- `GenFields`: `mode: GenMode` (`mode`), `outcome: GenOutcome` (`outcome`), + `attempts: u32` (`attempts`), `task_bundle: String` (`taskBundle`, the task + bundle blob's hash), `diagnostics: Vec` (`diagnostics`, the + diagnostic references an interrupted or failed gen carried over from its attempt + marker, defaulting to an empty vector when absent and skipped from serialization + when empty), `provenance: Option` (`provenance`, skipped + from serialization when `None`, the model-provenance record for the agent run, + described below), and `usage: Vec` (`usage`, the per-attempt usage + log of this gen's coding-agent attempts, burned attempts included, defaulting to + an empty vector when absent and skipped from serialization when empty, described + under "The per-attempt usage log" below). See the diagnostics-field ruling below + for the serialization of `diagnostics` and its deliberate exemption from the hash + check, and the usage ruling below for the matching treatment of `usage`. +- `RewordFields`: `verdict: String` (`verdict`), `batch_id: String` (`batchId`), + `bundle: String` (`bundle`, the bundle blob's hash). +- `ReconcileFields`: `drift_summary: String` (`driftSummary`), `amendment_bundle: + String` (`amendmentBundle`, the amendment bundle blob's hash). +- `MoveFields`: `context: String` (`context`, the location-heal context). +- `DerivationFields`: `derivation: String` (`derivation`, which derivation, for + example `mapl` or `ir`), `outcome: DerivationOutcome` (`outcome`), `attempts: + u32` (`attempts`), `error: String` (`error`, defaulting to the empty string when + absent), `diagnostic: Option` (`diagnostic`, skipped when `None`, the + diagnostic blob's hash on failure), `diagnostics: Vec` + (`diagnostics`, the diagnostic references an interrupted or failed derivation + carried over from its attempt marker, defaulting to an empty vector when absent + and skipped from serialization when empty), and `provenance: Option` + (`provenance`, skipped from serialization when `None`, the model-provenance + record for the derivation's model call, described below). This new plural + `diagnostics` field sits alongside the pre-existing singular `diagnostic` field + and never replaces it; the two are independent, and a derivation event may carry + either, both, or neither. See the diagnostics-field ruling below. +- `AuditFields`: `noop: bool` (`noop`, the expected-no-op assertion result, true + when the code-side bundle was empty). +- `RevertFields`: `reason: String` (`reason`, why the revert was made; the what is + captured by the event's state fields). +- `LegacyFields`: `original_line: String` (`originalLine`, the hash of the blob + holding the original journal line verbatim), `classification: + LegacyClassification` (`classification`). ::: test -name: a rich block-index canonicalizes to its pinned bytes -given: { fixture: BI_RICH } -expect: { canonical_bytes: "{\"atoms\":[{\"atomIndex\":0,\"byteEnd\":10,\"byteStart\":0,\"contentHash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"lineEnd\":2,\"lineStart\":1},{\"atomIndex\":1,\"byteEnd\":20,\"byteStart\":10,\"contentHash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"lineEnd\":3,\"lineStart\":3}],\"segVersion\":1,\"subSpans\":[{\"byteEnd\":5,\"byteStart\":0,\"contentHash\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"lineEnd\":1,\"lineStart\":1,\"parentAtomIndex\":0,\"subIndex\":0},{\"byteEnd\":10,\"byteStart\":5,\"contentHash\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"lineEnd\":2,\"lineStart\":1,\"parentAtomIndex\":0,\"subIndex\":1}]}\n" } +name: an event with an unknown field inside its payload fields is rejected +given: { json: "the EV_GEN object whose payload fields carry an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: ::: test -name: a rich block-index names itself by its pinned digest -given: { fixture: BI_RICH } -expect: { object_name: "e9fccb1f9979d73754a520d7622fe2bcd296e7c58193a8fadfbedea12c08c902" } +name: an event whose payload wrapper carries a sibling key beside kind and fields is rejected +given: { json: "the EV_GEN object whose payload object carries an extra key \"bogus\": 1 beside its kind and fields keys" } +expect: { error: Malformed } ::: ::: test -name: a rich block-index round-trips back to an equal value -given: { fixture: BI_RICH } -expect: { round_trip_equal: true } +name: an event whose payload kind is an unknown token is rejected +given: { json: "the EV_GEN object with payload.kind set to \"frobnicate\"" } +expect: { error: Malformed } ::: -- **SEG_DOC**: the prompt string - `---\nmodule: greeting\ndeps: []\n---\nGreet a person by name. Be warm.\n\nReturn the string.\n`. - Segmented under `SEG_VERSION`, its block-index object name is - `e8b2c6384b9c3e22af0d49ed99aa4a739f259f32c35035ccbd1d079990fa0e6e`; segmented - under segmentation version 2 it keeps the same atoms and sub-spans but its name - becomes `d7e094e7363b8eeb26302eefcaf0e6cdffda1908e296235b9312b5800e0e94e9`. +::: test +name: an event payload of kind gen carries the tag gen +given: { payload_kind: gen } +expect: { tag: gen } +::: ::: test -name: SEG_DOC segments into six document-tiling atoms -given: { fixture: SEG_DOC } -expect: - atoms: - - { atomIndex: 0, byteStart: 0, byteEnd: 4, lineStart: 1, lineEnd: 1, text: "---\n" } - - { atomIndex: 1, byteStart: 4, byteEnd: 21, lineStart: 2, lineEnd: 2, text: "module: greeting\n" } - - { atomIndex: 2, byteStart: 21, byteEnd: 30, lineStart: 3, lineEnd: 3, text: "deps: []\n" } - - { atomIndex: 3, byteStart: 30, byteEnd: 34, lineStart: 4, lineEnd: 4, text: "---\n" } - - { atomIndex: 4, byteStart: 34, byteEnd: 68, lineStart: 5, lineEnd: 6, text: "Greet a person by name. Be warm.\n\n" } - - { atomIndex: 5, byteStart: 68, byteEnd: 87, lineStart: 7, lineEnd: 7, text: "Return the string.\n" } +name: an event payload of kind reword carries the tag reword +given: { payload_kind: reword } +expect: { tag: reword } ::: ::: test -name: SEG_DOC produces its three sentence sub-spans -given: { fixture: SEG_DOC } -expect: - subSpans: - - { parentAtomIndex: 4, subIndex: 0, byteStart: 34, byteEnd: 58, lineStart: 5, lineEnd: 5, text: "Greet a person by name. " } - - { parentAtomIndex: 4, subIndex: 1, byteStart: 58, byteEnd: 68, lineStart: 5, lineEnd: 6, text: "Be warm.\n\n" } - - { parentAtomIndex: 5, subIndex: 0, byteStart: 68, byteEnd: 87, lineStart: 7, lineEnd: 7, text: "Return the string.\n" } +name: an event payload of kind reconcile carries the tag reconcile +given: { payload_kind: reconcile } +expect: { tag: reconcile } ::: ::: test -name: SEG_DOC reconstructs its exact prompt bytes from its atoms -given: { fixture: SEG_DOC } -expect: { reconstructs: true } +name: an event payload of kind move carries the tag move +given: { payload_kind: move } +expect: { tag: move } ::: ::: test -name: SEG_DOC names its block-index object by its pinned digest -given: { fixture: SEG_DOC } -expect: { object_name: "e8b2c6384b9c3e22af0d49ed99aa4a739f259f32c35035ccbd1d079990fa0e6e" } +name: an event payload of kind derivation carries the tag derivation +given: { payload_kind: derivation } +expect: { tag: derivation } ::: ::: test -name: SEG_DOC re-segmented under segmentation version two keeps its layout but bumps segVersion and its name -given: { fixture: SEG_DOC, seg_version: 2 } -expect: { segVersion: 2, atoms_equal_version_one: true, sub_spans_equal_version_one: true, object_name: "d7e094e7363b8eeb26302eefcaf0e6cdffda1908e296235b9312b5800e0e94e9" } +name: an event payload of kind audit carries the tag audit +given: { payload_kind: audit } +expect: { tag: audit } ::: -- **SEG_PROTECTED**: a single body line and a trailing newline. Its exact bytes are - `Ship v1.5 now. Edit store_hash.rs first. See ` then - `e.g. tests, etc. Keep ` (each ending in a space), then a single backtick byte, - then `a.b`, then a single backtick byte, then ` literal. Fin.` and a final - newline. Equivalently the fixture is the Rust string literal - `"Ship v1.5 now. Edit store_hash.rs first. See e.g. tests, etc. Keep \`a.b\` literal. Fin.\n"`. - This byte string is self-contained here and is the authority; the suite's pinned - sub-span `text` values are derived from it, not the reverse. +::: test +name: an event payload of kind revert carries the tag revert +given: { payload_kind: revert } +expect: { tag: revert } +::: ::: test -name: SEG_PROTECTED keeps its protected spans in one atom and four sub-spans -given: { fixture: SEG_PROTECTED } -expect: - atom_count: 1 - subSpans: - - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 15, text: "Ship v1.5 now. " } - - { parentAtomIndex: 0, subIndex: 1, byteStart: 15, byteEnd: 41, text: "Edit store_hash.rs first. " } - - { parentAtomIndex: 0, subIndex: 2, byteStart: 41, byteEnd: 82, text: "See e.g. tests, etc. Keep `a.b` literal. " } - - { parentAtomIndex: 0, subIndex: 3, byteStart: 82, byteEnd: 87, text: "Fin.\n" } +name: an event payload of kind legacy carries the tag legacy +given: { payload_kind: legacy } +expect: { tag: legacy } ::: ::: test -name: SEG_PROTECTED reconstructs its exact prompt bytes -given: { fixture: SEG_PROTECTED } -expect: { reconstructs: true } +name: a reword event round-trips back to an equal value +given: { event: "reword, verdict approved, batchId H1, bundle H4" } +expect: { round_trip_equal: true } ::: -- **SEG_HEADING_FENCE**: eight lines, exactly the Rust - string literal - `"# Title\n\nBody one. Body two.\n\n\`\`\`rust\nlet x = 1.5;\n\`\`\`\nTail here.\n"`: - a heading atom `# Title\n\n`, a paragraph atom `Body one. Body two.\n\n`, a - fenced-block atom whose first line is three backtick bytes then `rust`, whose - middle line is `let x = 1.5;`, and whose last line is three backtick bytes then a - newline, and a paragraph atom `Tail here.\n`. This byte string is self-contained - here and is the authority; the suite's pinned atom `text` values are derived from - it. +::: test +name: a reconcile event round-trips back to an equal value +given: { event: "reconcile, driftSummary one file, amendmentBundle H4" } +expect: { round_trip_equal: true } +::: ::: test -name: "SEG_HEADING_FENCE segments its heading, paragraph, fenced block, and tail into four atoms" -given: { fixture: SEG_HEADING_FENCE } -expect: - atoms: - - { atomIndex: 0, byteStart: 0, byteEnd: 9, lineStart: 1, lineEnd: 2, text: "# Title\n\n" } - - { atomIndex: 1, byteStart: 9, byteEnd: 30, lineStart: 3, lineEnd: 4, text: "Body one. Body two.\n\n" } - - { atomIndex: 2, byteStart: 30, byteEnd: 55, lineStart: 5, lineEnd: 7, text: "```rust\nlet x = 1.5;\n```\n" } - - { atomIndex: 3, byteStart: 55, byteEnd: 66, lineStart: 8, lineEnd: 8, text: "Tail here.\n" } +name: a move event round-trips back to an equal value +given: { event: "move, context relocated greeting.rs" } +expect: { round_trip_equal: true } ::: ::: test -name: "SEG_HEADING_FENCE gives sub-spans only to its prose atoms, never the heading or fenced block" -given: { fixture: SEG_HEADING_FENCE } -expect: - subSpans: - - { parentAtomIndex: 1, subIndex: 0, byteStart: 9, byteEnd: 19, lineStart: 3, lineEnd: 3, text: "Body one. " } - - { parentAtomIndex: 1, subIndex: 1, byteStart: 19, byteEnd: 30, lineStart: 3, lineEnd: 4, text: "Body two.\n\n" } - - { parentAtomIndex: 3, subIndex: 0, byteStart: 55, byteEnd: 66, lineStart: 8, lineEnd: 8, text: "Tail here.\n" } +name: an audit event round-trips back to an equal value +given: { event: "audit, noop true" } +expect: { round_trip_equal: true } ::: ::: test -name: SEG_HEADING_FENCE reconstructs its exact prompt bytes -given: { fixture: SEG_HEADING_FENCE } -expect: { reconstructs: true } +name: a revert event round-trips back to an equal value +given: { event: "revert, reason superseded by newer gen" } +expect: { round_trip_equal: true } ::: -- **SEG_LIST**: the prompt string - `Intro line. Second sentence.\n\n- First item here. It continues.\n- Second item.\n\nOutro.\n`. +::: test +name: an ok derivation event round-trips back to an equal value +given: { event: "derivation mapl, outcome ok, attempts 1, error empty, diagnostic absent" } +expect: { round_trip_equal: true } +::: + +### The diagnostics-field ruling on GenFields and DerivationFields + +The plural `diagnostics: Vec` field added to both `GenFields` and +`DerivationFields` is an additive, backward-compatible superset field, and it obeys +exactly this contract: + +- It carries `#[serde(default)]` so an event whose payload omits the `diagnostics` + key deserializes to an empty vector, and `#[serde(skip_serializing_if = + "Vec::is_empty")]` so an empty vector serializes to no key at all. An event that + carries no diagnostics is therefore byte-identical, before and after this + amendment, to the event it was: a `gen` payload of an ok gen serializes with + exactly its four pre-existing keys `attempts`, `mode`, `outcome`, `taskBundle` + and no `diagnostics` key, so every gen-event object name pinned before this + amendment is unchanged. When the vector is non-empty it serializes as the JSON + key `diagnostics` whose value is the array of its entries in order, sorted among + the sibling `fields` keys by the canonical encoder like every other key. +- Its entries are free-form strings and are deliberately NOT subjected to the + sixty-four-lowercase-hex object-name check that the singular `diagnostic` field + and the other hash-typed fields undergo. A `diagnostics` entry is normally a + diagnostic blob's hash carried verbatim from an attempt marker, but the + transaction protocol also uses this list to preserve a verbatim operation token + for an attempt whose operation is neither the agent run nor a derivation, and + such a token is not a hash. Validation accepts any string in the list, so no data + is lost and the conversion of an interrupted attempt never fails on a + non-hash-shaped diagnostics entry. This exemption is confined to the plural + `diagnostics` list on these two payloads and to the event-level `attemptId`; it + never weakens the hash check on the singular `diagnostic`, on `taskBundle`, or on + any other hash-typed field, each of which is still validated exactly as before. + +The pre-existing singular `diagnostic: Option` field of `DerivationFields` +is unchanged by this amendment: its wire name, its skip-when-`None` serialization, +and its hash check all stay exactly as they were. Unifying the singular and plural +diagnostic fields is intentionally deferred; both coexist, independent, until a +later cleanup pass reconciles them. + +Only the `Gen` and `Derivation` fields structs carry a `provenance` field, because +only those two step kinds invoke a model. The other six kinds carry no provenance +and gain no such field. Adding `provenance` as a known optional field to +`GenFields` and `DerivationFields` never weakens their +`#[serde(deny_unknown_fields)]`: a field named anything other than the listed +fields, `provenance` included, is still rejected. ::: test -name: SEG_LIST segments each list item into its own atom -given: { fixture: SEG_LIST } -expect: - atoms: - - { atomIndex: 0, byteStart: 0, byteEnd: 30, lineStart: 1, lineEnd: 2, text: "Intro line. Second sentence.\n\n" } - - { atomIndex: 1, byteStart: 30, byteEnd: 63, lineStart: 3, lineEnd: 3, text: "- First item here. It continues.\n" } - - { atomIndex: 2, byteStart: 63, byteEnd: 79, lineStart: 4, lineEnd: 5, text: "- Second item.\n\n" } - - { atomIndex: 3, byteStart: 79, byteEnd: 86, lineStart: 6, lineEnd: 6, text: "Outro.\n" } +name: a gen event with no diagnostics omits the diagnostics key from its canonical bytes +given: { fixture: EV_GEN } +expect: { has_diagnostics_key: false } ::: ::: test -name: SEG_LIST sub-spans each prose atom including each list item -given: { fixture: SEG_LIST } -expect: - subSpans: - - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 12, text: "Intro line. " } - - { parentAtomIndex: 0, subIndex: 1, byteStart: 12, byteEnd: 30, text: "Second sentence.\n\n" } - - { parentAtomIndex: 1, subIndex: 0, byteStart: 30, byteEnd: 49, text: "- First item here. " } - - { parentAtomIndex: 1, subIndex: 1, byteStart: 49, byteEnd: 63, text: "It continues.\n" } - - { parentAtomIndex: 2, subIndex: 0, byteStart: 63, byteEnd: 79, text: "- Second item.\n\n" } - - { parentAtomIndex: 3, subIndex: 0, byteStart: 79, byteEnd: 86, text: "Outro.\n" } +name: an event with no attempt id omits the attemptId key from its canonical bytes +given: { fixture: EV_GEN } +expect: { has_attempt_id_key: false } ::: ::: test -name: SEG_LIST reconstructs its exact prompt bytes -given: { fixture: SEG_LIST } -expect: { reconstructs: true } +name: a derivation event with an empty diagnostics list omits the diagnostics key from its canonical bytes +given: { event: "derivation mapl, outcome ok, attempts 1, error empty, diagnostic absent, diagnostics empty" } +expect: { has_diagnostics_key: false } ::: -## Test and code discipline +### Model provenance on a gen or derivation step -The frontmatter test suite is a contract, not a sketch. Each case in it is -realized by its own dedicated test function that exercises only that case, so the -number of dedicated tests is at least the number of frontmatter cases. Never fold -two or more cases into one shared test, and a single test that loops over a table -of inputs never counts as the dedicated test any of those inputs requires: such a -combined test may exist only as an extra. Cases that differ only in their input -while sharing an expected shape are still distinct cases, each owed its own -dedicated test. In particular each rejection case that expects `Malformed` (the -unknown top-level field, the missing atoms, the wrong-typed atoms, the unknown -atom-record field, the missing atom contentHash, the unknown sub-span-record -field, the wrong-typed segVersion, and the segVersion one past the u32 range), -each case that expects `MalformedHash` (the atom hash and the sub-span hash), each -case that expects `InvalidSpan` (the atom byteStart-over-byteEnd, the atom -lineEnd-under-lineStart, and the sub-span byteStart-over-byteEnd), the -`InvalidSegVersion` case, the `UnknownParentAtom` case, each case that expects -`NonSequentialAtomIndex` (the duplicated atomIndex, the skipped atomIndex, and the -reversed atom records), each case that expects `AtomsNotTiled` (the overlapping -atoms, the gapped atoms, and the first atom whose byteStart is not zero), each case -that expects `NonSequentialSubIndex` (the duplicated subIndex and the skipped -subIndex), the `SubSpanOutsideParent` case, each case that expects -`SubSpansNotTiled` (the overlapping siblings, the gapped siblings, the first sibling -whose byteStart is not its parent atom byteStart, and the last sibling whose byteEnd -is not its parent atom byteEnd), and the segVersion largest-u32 accepted case, gets its own dedicated -test whatever shared loop may also exist as an extra. Each integer-width boundary -case pins its exact literal integer in its own test: `4294967295` for the -largest-u32 segVersion accepted, and `4294967296` for the segVersion one past the -u32 range rejected as `Malformed`. +Expose a public struct `Provenance` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`, `serde::Serialize`, and `serde::Deserialize`, with +`#[serde(deny_unknown_fields)]`), the record of what a single model invocation ran +and what we believed it cost at write time. It is optional on a gen or derivation +step: its absence means the invocation predates this feature or the engine +reported no usage, and a reader never fabricates any of its values. Public fields: -Inside a case's dedicated test, hand the code exactly the fixture that case gives, -byte for byte, and assert its pinned expectation verbatim: no shorter or more -convenient stand-in, no re-derivation of a value the case pins as a literal, and -no weakening of a pinned equality into an inequality-only or recomputed check. The -byte-pinned object cases assert their exact literals: BI_MIN canonicalizes to its -pinned bytes ending in one newline, names to -`1660deacdbb4727b6f456c87b2866dfbcbd95e00bf9eda2b6936c39fcf136d28`, and frames its -complete envelope, the header `napl-store block-index v1 190\n` immediately -followed by its canonical payload, as one whole byte string; BI_RICH canonicalizes -to its pinned bytes and names to -`e9fccb1f9979d73754a520d7622fe2bcd296e7c58193a8fadfbedea12c08c902`. Where a case -pins canonical bytes, obtain them as `store_hash::canonical_json` applied to the -serialized value and compare to the literal; where a case pins an object name, -obtain it from `block_index_object_name` and compare to the literal; where a case -pins envelope bytes, frame them through `store_hash::envelope_bytes` over the -canonical payload and assert the whole framed byte vector equals the pinned -envelope in full, header and payload together, an exact equality of the complete -bytes and never a prefix or header-only comparison. The dedicated BI_MIN envelope -test computes its expected bytes as that header concatenated with the canonical -payload and asserts equality against the entire framed envelope; any header-prefix -check is at most a separately named supplemental test and never discharges the -envelope case. +- `model: String`, serde field name `model`. The exact model id string as reported + by the engine invocation, for example `claude-opus-4-8`. It is recorded verbatim + and is never inferred, normalized, or synthesized from the coarse engine name. +- `tokens: TokenUsage`, serde field name `tokens`. The token counts the engine + reported, described next. +- `estimated_cost_usd: Option`, serde field name `estimatedCostUsd`, + skipped from serialization when `None`. A decimal cost string in US dollars, + computed at write time from the pinned price table below over `tokens`. It is a + decimal **string**, never a floating-point number, so an event's canonical bytes + stay stable. It is `None` exactly when `model` is not in the price table. This is + a record of what we believed the invocation cost when the event was written, not + a billing authority; historical events are never re-priced. +- `wall_ms: u64`, serde field name `wallMs`. The wall-clock milliseconds the model + invocation took, as measured by the engine wrapper around the model call. It is a + `u64`. +- `price_table_version: u32`, serde field name `priceTableVersion`. The version of + the price table that produced (or, for an unknown model, failed to produce) + `estimatedCostUsd`, so a reader can tell which table was consulted. It is always + present when a `Provenance` is present, whether or not a cost was produced. -A dedicated test asserts only its own case and nothing more. The BI_MIN name test -asserts only that pinned digest, leaving the canonical-bytes shape to the case that -owns it. The verifier round-trip case builds BI_MIN, names it through -`block_index_object_name`, frames its envelope through `store_hash::envelope_bytes` -over its canonical payload, and asserts `store_hash::verify_object` returns success -carrying `store_hash::ObjectKind::BlockIndex`; it never substitutes BI_RICH. Each -round-trip-equal case builds its own named fixture, serializes it, recovers it -through `validate_block_index`, and asserts the recovered value equals the original -verbatim, never weakening that equality into a mere `is_ok` acceptance and never -substituting a lighter or neighboring fixture: the BI_MIN round-trip builds BI_MIN -and the BI_RICH round-trip builds BI_RICH. +Expose a public struct `TokenUsage` (same derives, same +`#[serde(deny_unknown_fields)]`), the reported token counts of one invocation. +Public fields: -The rejection cases construct exactly the malformed JSON the case describes, built -by taking the named fixture's JSON and applying only the one described mutation -using that mutation's exact literal tokens, and assert the pinned error variant. -The extra-key cases each insert the key named `bogus` with the integer value `1`, -exactly `"bogus": 1`, never a renamed key and never a restyled value; the unknown -top-level field inserts it at the top level of BI_MIN, the unknown atom-record -field inserts it inside BI_MIN's one atom record, and the unknown sub-span-record -field inserts it inside BI_RICH's first sub-span record, and none discharges -another. The malformed-hash cases set the named `contentHash` to exactly `"xyz"`; -the wrong-typed-segVersion case sets `segVersion` to the JSON string `"1"`; the -wrong-typed-atoms case sets `atoms` to the JSON number `1`; the zero-segVersion -case sets `segVersion` to `0`; the atom byteStart-over-byteEnd span case sets -`byteStart` to `9` and leaves `byteEnd` at `5`; the atom lineEnd-under-lineStart -span case sets `lineStart` to `4` and leaves `lineEnd` unchanged at its BI_MIN -value of `1`, touching no other field, so the mutated atom carries `lineStart` `4` -and `lineEnd` `1` and never `lineEnd` `2`; the sub-span byteStart-over-byteEnd span -case sets `byteStart` to `9` and leaves `byteEnd` at `5`; the unknown-parent case -sets the sub-span `parentAtomIndex` to `5`. +- `input: u64`, serde field name `input`. Input tokens reported by the engine. +- `output: u64`, serde field name `output`. Output tokens reported by the engine. +- `cache_read: Option`, serde field name `cacheRead`, skipped from + serialization when `None`. Cache-read tokens, present only when the engine + reports them, absent otherwise; never a fabricated zero standing in for an + unreported count. +- `cache_write: Option`, serde field name `cacheWrite`, skipped from + serialization when `None`. Cache-write tokens, present only when the engine + reports them, absent otherwise; never a fabricated zero. -The occurrence-index rejection cases each start from BI_RICH and apply their one -described mutation with its exact literal token, and each is its own dedicated -test asserting its pinned variant. The duplicated-atomIndex case sets the second -atom's `atomIndex` to `0`; the skipped-atomIndex case sets the second atom's -`atomIndex` to `2`; the reversed-atom-records case reverses the order of the two -atom records in the `atoms` array; each expects `NonSequentialAtomIndex`. The -overlapping-atoms case sets the second atom's `byteStart` to `8` and the -gapped-atoms case sets the second atom's `byteStart` to `12`; each expects -`AtomsNotTiled`. The first-atom-untiled case sets the first atom's `byteStart` to -`2`, leaving no other field touched, and expects `AtomsNotTiled`. The duplicated-subIndex case sets the second sub-span's `subIndex` -to `0` and the skipped-subIndex case sets the second sub-span's `subIndex` to `2`; -each expects `NonSequentialSubIndex`. The outside-parent case sets the second -sub-span's `byteEnd` to `25`, past its parent atom's `byteEnd` of `10`, and expects -`SubSpanOutsideParent`. The overlapping-siblings case sets the second sub-span's -`byteStart` to `3` and the gapped-siblings case sets the second sub-span's -`byteStart` to `7`; the first-sibling-untiled case sets the first sub-span's -`byteStart` to `2`, leaving no other field touched; the last-sibling-untiled case -sets the second sub-span's `byteEnd` to `8`, leaving no other field touched; each -of these four expects `SubSpansNotTiled`. None of these discharges another: they -share a variant only by class and differ in the exact literal each pins. +A `Provenance` carries no hash-typed field, so `validate_event` gains no new +hash check for it, and the schema adds no new `StoreObjectError` variant. The +provenance record is written only by the engine capture seam, which is the layer +that guarantees `model` is a real reported id; on the read side a present +`Provenance` deserializes and round-trips exactly as written, and its only +strictness is the `#[serde(deny_unknown_fields)]` on `Provenance` and on +`TokenUsage`, so a stray key inside either object is rejected as +`StoreObjectError::Malformed` through the normal deserialization path. -Each segmentation case builds the exact prompt its fixture names and asserts every -value the case pins, transcribed verbatim. For a case that pins a list of atoms, -the dedicated test asserts, for each atom in order, its `atomIndex`, `byteStart`, -`byteEnd`, `lineStart`, and `lineEnd`, that the prompt slice `[byteStart, byteEnd)` -equals the pinned `text`, and that the atom's `contentHash` equals -`hash::content_hash` of that same `text`; it also asserts the atom count equals the -number of atoms listed. For a case that pins a list of sub-spans, the dedicated -test asserts, for each sub-span in order, its `parentAtomIndex`, `subIndex`, -`byteStart`, and `byteEnd` (and its `lineStart` and `lineEnd` where the case lists -them), that the prompt slice equals the pinned `text`, and that its `contentHash` -equals `hash::content_hash` of that `text`; where the case pins a `sub_span_count` -or `atom_count` it asserts that too. The `text` literal is transcribed exactly, -every space and newline included, and the `contentHash` is asserted by comparison -to `hash::content_hash` of that `text`, never by a copied hex literal. A -reconstruction case asserts `reconstruct(index, prompt)` equals the prompt exactly. -An `object_name` case asserts `block_index_object_name` of the segmented index -equals the pinned digest literal. +::: test +name: a provenance carrying an unknown field is rejected +given: { json: "the EV_GEN_PROV object whose provenance object carries an extra key \"bogus\": 1" } +expect: { error: Malformed } +::: -The segmentation version case for SEG_DOC segments the same prompt under -segmentation version 2, asserts its `segVersion` is 2, asserts its `atoms` and -`subSpans` equal those from segmenting under `SEG_VERSION` (same spans and same -hashes), and asserts its object name is the pinned version-two digest, distinct -from the version-one digest; it never keeps the version-one `segVersion` value 1. -The protected-span and boundary cases each hand the code exactly the `prompt` -string its frontmatter case pins, byte for byte, and never a different or more -convenient illustrative string; inventing a stand-in prompt that merely exercises -the same rule does not discharge the case and leaves it untested. The exact -prompts are, verbatim: the backticked-span case uses `Call `a.b.c` now. Done.\n` -and asserts its two sub-spans `Call `a.b.c` now. ` and `Done.\n`; the -version-number case uses `Use v1.5 here. Stop.\n` and asserts `Use v1.5 here. ` and -`Stop.\n`; the file-path case uses `Edit main.rs now. Stop.\n` and asserts -`Edit main.rs now. ` and `Stop.\n`; the `e.g.` case uses `See e.g. this. Next one.\n` -and asserts `See e.g. this. ` and `Next one.\n`; the `i.e.` case uses -`Use i.e. that. Next one.\n` and asserts `Use i.e. that. ` and `Next one.\n`; the -`etc.` case uses `Foo, etc. bar baz.\n` and asserts its single conservative -sub-span `Foo, etc. bar baz.\n` and never a two-way split; the plain two-sentence -case uses `A cat sat. A dog ran.\n` and asserts `A cat sat. ` and `A dog ran.\n`; -the digit-opener case uses `Item one. 2 is next.\n` and asserts `Item one. ` and -`2 is next.\n`. A test built on any other prompt string, such as a re-worded -sentence chosen to illustrate the rule, is at most a separately named supplemental -test and never stands in for the pinned case. +::: test +name: a token usage carrying an unknown field is rejected +given: { json: "the EV_GEN_PROV object whose provenance tokens object carries an extra key \"bogus\": 1" } +expect: { error: Malformed } +::: -SEG_PROTECTED owns two dedicated tests, not one: one asserts its `atom_count` is -exactly 1 alongside its four pinned sub-spans, and a separate dedicated -reconstruction test asserts `reconstruct(index, SEG_PROTECTED)` equals the whole -SEG_PROTECTED prompt exactly. The empty-prompt case asserts no atoms, no sub-spans, -and that the returned `segVersion` is 1; the single-newline case asserts one blank -atom and no sub-spans; the unterminated-line case uses exactly the prompt -`Hello there.` and asserts one atom `Hello there.` and one conservative sub-span -`Hello there.` equal to it. +### The pinned price table and cost estimation -Closing mandate on one-to-one case fidelity. Every case in the frontmatter test -suite above, without exception, is realized by exactly one dedicated test that -hands the code that case's own fixture with its literal values copied verbatim from -the case, and asserts that case's own pinned expectation, neither narrower nor -recomputed. A test that inspects a different or narrower property than its case -declares, that swaps in a lighter or neighboring fixture, that weakens a pinned -equality into an `is_ok` or inequality-only check, or that reuses a sibling case's -values, does not discharge the case and leaves it untested. Two cases that share a -shape but differ in a fixture value or in the property asserted are two separate -cases, each owed its own verbatim test: the atom-hash rejection never discharges -the sub-span-hash rejection, the atom-span rejections never discharge each other, -the BI_MIN round-trip never discharges the BI_RICH round-trip, and a version-number -protected case never discharges a file-path protected case. Supplemental tests -beyond the suite are named for what they add and are never counted as, nor -substituted for, any suite case's dedicated test. The number of dedicated tests is -therefore at least the number of frontmatter cases, and each case above is -discharged by its own verbatim test. +This module owns the price table and the pure cost estimator; the capture seam +consults them through this module rather than carrying its own copy, so the prices +and the estimation arithmetic live in exactly one place. -Write no explanatory line or block comments in the generated code, and no doc -comment on any private item. The crate keeps its module-level `//!` summary. Every -public type, every public enum variant, and every public function carries a `///` -doc comment restating its contract; a public struct field carries a `///` doc -comment only where its name alone does not convey the contract, and simply named -fields may carry none. The private helper functions and private constants of this -module carry no doc comment. Beyond the module summary and these public-item doc -comments the code carries no commentary of any kind. +Expose a public constant `PRICE_TABLE_VERSION: u32` equal to `1`. Bump it on any +change to the table's contents or shape; a reader compares it against a +provenance record's `priceTableVersion` to know which table produced a cost. -## store_tree -# Store tree: git-style Merkle directory trees over generated files +Hold the table as a private constant mapping a model id to its two prices, each a +rate in US dollars per one million tokens. Store each rate not as a float but as +an integer count of **micro-dollars per million tokens** (the dollar rate times +one million), so all cost arithmetic is exact integer arithmetic with no floating +point anywhere. The entries of version `1`, model id then input rate then output +rate in US dollars per million tokens: -This module defines the `tree` object kind of the NAPL content-addressed store, -the git-style Merkle directory tree over generated files, together with the pure -functions that build a tree from a flat file set and diff two trees. A tree is an -ordered set of entries, each naming one immediate child by its content-addressed -hash: a file child by its blob object name, a directory child by its subtree -object name. Because every directory entry carries the hash of its subtree, and -every subtree hash covers its own children's hashes, a single changed leaf -changes exactly one path of hashes from that leaf to the root, and a diff walks -only the hash-differing paths, in time proportional to the change rather than the -tree size. It is pure: no filesystem, no I/O, no clocks, no randomness. Bring in -`serde` and `serde_json`. +- `claude-opus-4-8`: input `15`, output `75`. Stored as input micros `15000000`, + output micros `75000000`. +- `claude-sonnet-4-5`: input `3`, output `15`. Stored as input micros `3000000`, + output micros `15000000`. +- `claude-haiku-4-5`: input `1`, output `5`. Stored as input micros `1000000`, + output micros `5000000`. -## Scope boundary: which object kind lives here +The table carries no separate cache-read or cache-write rates in version `1`, so +by the folding rule below, cache-read and cache-write tokens are priced at the +model's input rate. These prices are provenance estimates, not billing authority; +drift from a vendor's real pricing is acceptable and is disclosed by +`PRICE_TABLE_VERSION`. -The store has six object kinds. This module owns exactly one of them, the `tree` -kind, whose serde types the neighboring `store_objects` module deliberately -deferred to this module. The other five kinds are out of scope here and are never -redefined in this crate: `blob`, `block-index`, `module-state`, `event`, and -`snapshot`. This module never references another object by structure; where it -names another object, whether a file's blob or a subtree, it uses that object's -content-addressed name, the sixty-four-character lowercase hexadecimal SHA-256 -string. Trees are used for generated output only; they are the `generatedTree` -that a module-state references per target. +Expose the pure estimator `estimate_cost(model: &str, tokens: &TokenUsage) -> +Option`: -## Where this code lives +- Look the `model` up in the table. If it is absent, return `None`: an unknown + model gets no cost, though its tokens are still recorded on the provenance. +- Otherwise compute, using integer arithmetic wide enough not to overflow (compute + in `u128`), the total in dollar-scaled integer units: -The working directory is a Cargo workspace whose root manifest is written and -owned by the toolchain, leave it alone. Create this module as its own member crate -in a subdirectory named `store_tree/`: `store_tree/Cargo.toml` (package name -`store_tree`) and `store_tree/src/lib.rs`. Touch nothing outside `store_tree/`. -Ensure `cargo test` passes from the workspace root before finishing. + billable_input = input + cache_read.unwrap_or(0) + cache_write.unwrap_or(0) + n = billable_input * input_micros + output * output_micros -## Builds on the `store_hash` module of this workspace + where `input_micros` and `output_micros` are this model's stored rates. This is + the folding rule: absent a dedicated cache rate, cache-read and cache-write + tokens join the plain input tokens and are priced at the input rate. -This crate composes on the generated sibling crate **`store_hash`** -(`../store_hash`) for object naming, envelope framing, canonical JSON, and read -verification. Use its public API, do not reimplement its types or logic, and do -not depend on any hand-written crate. This module needs no content-digest helper -and does not bring in the `sha2` crate directly or the `hash` crate: every hash a -tree stores is either a caller-supplied object name or a subtree object name -produced by `store_hash`. The items this module uses from `store_hash`: +- The exact cost in dollars is `n` divided by `1_000_000_000_000` (one million, + for the per-million-token rate, times one million, for the micro-dollar rate + scaling), which is always an exact terminating decimal because the divisor is a + power of ten. Render it to a canonical decimal string with no floating point: -- `store_hash::ObjectKind`, whose variant `store_hash::ObjectKind::Tree` names - this kind; its wire token `tree` comes from `store_hash`, never re-spelled here. -- `store_hash::canonical_json(value: &serde_json::Value) -> Vec`, the - canonical JSON encoder (keys sorted recursively, compact, one trailing newline) - that turns a serialized value into its canonical bytes. -- `store_hash::envelope_bytes(kind: store_hash::ObjectKind, payload: &[u8]) -> - Vec` and `store_hash::object_name(kind: store_hash::ObjectKind, payload: - &[u8]) -> String`, the typed envelope framing and the SHA-256 object name over - those envelope bytes. -- `store_hash::verify_object(name: &str, bytes: &[u8]) -> - Result`, the read verifier - the round-trip tests pass a freshly built object through; - `store_hash::VerifiedObject` has public fields `kind: store_hash::ObjectKind` - and `payload: Vec`. + - Let `whole = n / 1_000_000_000_000` and `frac = n % 1_000_000_000_000` by + integer division and remainder. + - If `frac` is zero, the string is `whole` rendered as a decimal integer, with + no decimal point and no fractional digits: for example `0`, or `18`. + - Otherwise render `frac` as exactly twelve digits, zero-padded on the left, then + strip its trailing zeros, and the string is `whole`, a `.`, then those + remaining fractional digits: for example `0.0525`, or `0.084`. -## Content hashes are plain strings + Return `Some` of that string. -Every content-addressed name a tree carries is a `String`, matching the rest of -the workspace, never a newtype. A well-formed object name is sixty-four lowercase -hexadecimal characters (`0` through `9`, `a` through `f`). The validator below -rejects any hash-typed field whose string is not exactly that shape. This module -never computes a hash except by delegating to `store_hash`. +Expose a public constructor `provenance_from_usage(model: String, tokens: +TokenUsage, wall_ms: u64) -> Provenance` that the capture seam calls to build a +provenance record in one place: it sets `estimated_cost_usd` to +`estimate_cost(&model, &tokens)`, sets `price_table_version` to +`PRICE_TABLE_VERSION`, and carries the `model`, `tokens`, and `wall_ms` through +unchanged. This is the only place `PRICE_TABLE_VERSION` is stamped onto a record, +so the table version and the prices that produced a cost can never disagree. -## The tree object +::: test +name: estimating the cost of a known model yields its pinned decimal string +given: { cost_model: "claude-opus-4-8", cost_tokens: "input 1000, output 500" } +expect: { estimated_cost: "0.0525" } +::: -Expose a public struct `Tree` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, -`serde::Serialize`, and `serde::Deserialize`, with -`#[serde(deny_unknown_fields)]`), one directory level. It serializes as a JSON -object with exactly one field: +::: test +name: estimating the cost of a zero-token usage of a known model yields the string 0 +given: { cost_model: "claude-opus-4-8", cost_tokens: "input 0, output 0" } +expect: { estimated_cost: "0" } +::: -- `entries: Vec`, serde field name `entries`. The immediate children of - this directory level in ascending name order, always present, an empty directory - serializing as `[]`. The tree wraps its entries in an object rather than being a - bare array so the top level rejects unknown fields and the format can grow a - sibling field later without a name collision. +::: test +name: estimating a whole-dollar cost of a known model renders a bare integer with no fractional part +given: { cost_model: "claude-sonnet-4-5", cost_tokens: "input 1000000, output 1000000" } +expect: { estimated_cost: "18" } +::: -Expose a public struct `TreeEntry` (same derives, same -`#[serde(deny_unknown_fields)]`), one immediate child. Public fields: +::: test +name: estimating the cost folds cache-read and cache-write tokens into the input rate +given: { cost_model: "claude-opus-4-8", cost_tokens: "input 1000, output 500, cacheRead 2000, cacheWrite 100" } +expect: { estimated_cost: "0.084" } +::: -- `name: String`, serde field name `name`. The child's single path component, not - a multi-component path: it is never empty, never contains the byte `/` (`0x2F`), - and is never the single dot `.` or the double dot `..`. -- `kind: EntryKind`, serde field name `kind`. Whether this child is a file or a - directory. -- `hash: String`, serde field name `hash`. The child's content-addressed object - name: a file child's blob object name, or a directory child's subtree object - name. Sixty-four lowercase hex. -- `mode: u32`, serde field name `mode`. The child's POSIX file mode, in the numeric - git-style form described next. +::: test +name: estimating the cost of a model absent from the price table yields no cost +given: { cost_model: "gpt-4o", cost_tokens: "input 1000, output 500" } +expect: { estimated_cost: none } +::: -The canonical bytes of a tree therefore sort each entry's keys as `hash`, `kind`, -`mode`, `name`, and order the entries themselves by ascending `name`. +### The per-attempt usage log -## The entry kind +Expose a public struct `AttemptUsage` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`, `serde::Serialize`, and `serde::Deserialize`, with +`#[serde(deny_unknown_fields)]`), one coding-agent attempt's reported usage. It is +the immutable per-attempt record the gen event carries in its `usage` list, one +entry per attempt of that gen, burned attempts included, so a reader can see what +every attempt of a gen cost even when only the last attempt succeeded. It carries no +nested array, so a gen's `usage` list stays bounded by the gen's attempt count. +Public fields: -Expose a public enum `EntryKind` (deriving `Debug`, `Clone`, `Copy`, `PartialEq`, -`Eq`, `serde::Serialize`, and `serde::Deserialize`), serializing as a single JSON -string. Pin each variant to its exact wire token; an input string outside this set -is a deserialization failure: +- `attempt: u32`, serde field name `attempt`. The 1-based attempt index within this + gen. +- `outcome: String`, serde field name `outcome`. This attempt's own loop verdict, a + free-form short token such as `ok`, `tests-failed`, `deps-gate`, or `agent-error`, + recorded verbatim from the attempt loop; it is not the gen's `GenOutcome` and is + not constrained to that enum's tokens. +- `requested_model: String`, serde field name `requestedModel`. The model this + attempt asked the engine to run, after precedence resolution. +- `resolved_model: String`, serde field name `resolvedModel`. The exact model id the + engine reported running, or the empty string when the engine reported none. +- `source: String`, serde field name `source`. Which layer supplied the requested + model, one of `cli-flag`, `frontmatter`, or `lock`. +- `engine: String`, serde field name `engine`. The coarse engine identifier this + attempt ran on. +- `tokens: TokenUsage`, serde field name `tokens`. The four token counts this + attempt reported, using the same `TokenUsage` struct the provenance record uses, + so the token shape lives in exactly one place. +- `cost_usd: Option`, serde field name `costUsd`, skipped from serialization + when `None`. The cost the engine reported for this attempt as a decimal string in + US dollars, never a floating-point number so the event's canonical bytes stay + stable; `None` when the engine reported no cost. +- `duration_ms: u64`, serde field name `durationMs`. The wall-clock milliseconds the + engine reported for this attempt. +- `num_turns: u32`, serde field name `numTurns`. The model turns the engine reported + for this attempt. -- `EntryKind::File` renders `file`. -- `EntryKind::Dir` renders `dir`. +The `usage: Vec` field on `GenFields` obeys exactly the additive +contract the plural `diagnostics` field obeys: it carries `#[serde(default)]` so a +gen payload that omits the `usage` key deserializes to an empty vector, and +`#[serde(skip_serializing_if = "Vec::is_empty")]` so an empty vector serializes to +no key at all. A gen event that carries no usage is therefore byte-identical, before +and after this amendment, to the event it was, so every gen-event object name pinned +before this amendment is unchanged. When the vector is non-empty it serializes as the +JSON key `usage` whose value is the array of its entries in order, each a canonical +object, sorted among the sibling `fields` keys by the canonical encoder like every +other key. An `AttemptUsage` carries no hash-typed field, so `validate_event` gains +no new hash check for it and the schema adds no new `StoreObjectError` variant; its +only strictness is the `#[serde(deny_unknown_fields)]` on `AttemptUsage` (and on the +`TokenUsage` it nests), so a stray key inside either object is rejected as +`StoreObjectError::Malformed` through the normal deserialization path. The `usage` +list is confined to `GenFields`; the other seven payload kinds gain no such field, +and adding `usage` as a known optional field to `GenFields` never weakens its +`#[serde(deny_unknown_fields)]`. ::: test -name: entry kind file serializes to the string file -given: { entry_kind: file } -expect: { serialized_json: "\"file\"" } +name: a usage entry carrying an unknown field is rejected +given: { json: "the EV_GEN_USAGE object whose first usage entry carries an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: ::: test -name: entry kind dir serializes to the string dir -given: { entry_kind: dir } -expect: { serialized_json: "\"dir\"" } +name: a usage-entry token object carrying an unknown field is rejected +given: { json: "the EV_GEN_USAGE object whose first usage entry's tokens object carries an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: +### The mode, outcome, and classification enums + +Each of these derives `Debug`, `Clone`, `Copy`, `PartialEq`, `Eq`, +`serde::Serialize`, and `serde::Deserialize`, and serializes as a single JSON +string. Pin each variant to its exact wire token; an input string outside a set is +a deserialization failure: + +- `GenMode`: `Full` renders `full`, `Incremental` renders `incremental`, `Force` + renders `force`. +- `GenOutcome`: `Ok` renders `ok`, `Failed` renders `failed`, `Interrupted` + renders `interrupted`. +- `DerivationOutcome`: `Ok` renders `ok`, `Retried` renders `retried`, `Failed` + renders `failed`, `Interrupted` renders `interrupted`. +- `LegacyClassification`: `Exact` renders `exact`, `MissingPrefix` renders + `missing-prefix`, `InconsistentBefore` renders `inconsistent-before`, + `InconsistentAfter` renders `inconsistent-after`, `Unreconstructable` renders + `unreconstructable`. + ::: test -name: the string file deserializes to entry kind file -given: { serialized_json: "\"file\"" } -expect: { is_file: true } +name: gen mode full serializes to the string full +given: { gen_mode: full } +expect: { serialized_json: "\"full\"" } ::: ::: test -name: the string dir deserializes to entry kind dir -given: { serialized_json: "\"dir\"" } -expect: { is_dir: true } +name: gen mode incremental serializes to the string incremental +given: { gen_mode: incremental } +expect: { serialized_json: "\"incremental\"" } ::: -## The mode constants +::: test +name: gen mode force serializes to the string force +given: { gen_mode: force } +expect: { serialized_json: "\"force\"" } +::: -A tree entry's `mode` is the child's POSIX mode carried as a `u32` in the numeric -form git uses, the full mode including the file-type bits, so a single integer -distinguishes a regular file, an executable file, and a directory. Expose exactly -these three public constants, and constrain every entry's `mode` to one of them: +::: test +name: gen outcome ok serializes to the string ok +given: { gen_outcome: ok } +expect: { serialized_json: "\"ok\"" } +::: -- `pub const FILE_MODE: u32 = 0o100644` (the decimal value `33188`), a regular - file. -- `pub const EXEC_MODE: u32 = 0o100755` (the decimal value `33261`), an executable - regular file. -- `pub const DIR_MODE: u32 = 0o040000` (the decimal value `16384`), a directory. +::: test +name: gen outcome failed serializes to the string failed +given: { gen_outcome: failed } +expect: { serialized_json: "\"failed\"" } +::: -The mode and the kind are coupled: a `File` entry's mode must be `FILE_MODE` or -`EXEC_MODE`, and a `Dir` entry's mode must be `DIR_MODE`. Any other pairing, and -any mode value outside these three constants, is rejected by the validator below. +::: test +name: gen outcome interrupted serializes to the string interrupted +given: { gen_outcome: interrupted } +expect: { serialized_json: "\"interrupted\"" } +::: ::: test -name: the file mode constant is thirty-three thousand one hundred eighty-eight -given: {} -expect: { file_mode_const: 33188 } +name: derivation outcome ok serializes to the string ok +given: { derivation_outcome: ok } +expect: { serialized_json: "\"ok\"" } ::: ::: test -name: the executable mode constant is thirty-three thousand two hundred sixty-one -given: {} -expect: { exec_mode_const: 33261 } +name: derivation outcome retried serializes to the string retried +given: { derivation_outcome: retried } +expect: { serialized_json: "\"retried\"" } ::: ::: test -name: the directory mode constant is sixteen thousand three hundred eighty-four -given: {} -expect: { dir_mode_const: 16384 } +name: derivation outcome failed serializes to the string failed +given: { derivation_outcome: failed } +expect: { serialized_json: "\"failed\"" } ::: ::: test -name: a file entry carrying the directory mode is rejected -given: { json: "the TR_FLAT object with its first entry mode set to 16384 while its kind stays file" } -expect: { error: InvalidMode } +name: derivation outcome interrupted serializes to the string interrupted +given: { derivation_outcome: interrupted } +expect: { serialized_json: "\"interrupted\"" } ::: ::: test -name: a directory entry carrying a file mode is rejected -given: { json: "the TR_NESTED object with its sub entry mode set to 33188 while its kind stays dir" } -expect: { error: InvalidMode } +name: legacy classification exact serializes to the string exact +given: { legacy_classification: exact } +expect: { serialized_json: "\"exact\"" } ::: ::: test -name: an entry carrying an unrecognized mode value is rejected -given: { json: "the TR_FLAT object with its first entry mode set to 33152" } -expect: { error: InvalidMode } +name: legacy classification missing-prefix serializes to the string missing-prefix +given: { legacy_classification: MissingPrefix } +expect: { serialized_json: "\"missing-prefix\"" } ::: ::: test -name: an entry whose mode is the largest u32 is rejected -given: { json: "the TR_FLAT object with its first entry mode set to 4294967295" } -expect: { error: InvalidMode } +name: legacy classification inconsistent-before serializes to the string inconsistent-before +given: { legacy_classification: InconsistentBefore } +expect: { serialized_json: "\"inconsistent-before\"" } ::: -## The error type +::: test +name: legacy classification inconsistent-after serializes to the string inconsistent-after +given: { legacy_classification: InconsistentAfter } +expect: { serialized_json: "\"inconsistent-after\"" } +::: -Expose a public enum `StoreTreeError` (deriving `Debug`, `Clone`, `PartialEq`, -and `Eq`) with exactly these variants, one per failure mode of the functions -below: +::: test +name: legacy classification unreconstructable serializes to the string unreconstructable +given: { legacy_classification: Unreconstructable } +expect: { serialized_json: "\"unreconstructable\"" } +::: -- `Malformed(String)`, carrying the underlying deserialization message, for any - structural or type failure surfaced by serde (an unknown field, a missing - required field, an unknown `kind` token, a value of the wrong JSON type, an - integer outside `u32`). -- `MalformedHash`, an entry `hash` that is not sixty-four lowercase hex. -- `EmptyName`, an entry whose `name` is the empty string. -- `InvalidName`, an entry whose `name` contains a `/`, or is `.` or `..`. -- `UnsortedEntries`, a tree whose entries are not in strictly ascending `name` - order. -- `DuplicateName`, a tree with two entries sharing a `name`. -- `InvalidMode`, an entry whose `mode` does not match its `kind` under the coupling - rule above, or a build input file whose mode is not `FILE_MODE` or `EXEC_MODE`. -- `InvalidPath`, a build input whose path is empty or has an empty, `.`, or `..` - component. -- `PathConflict`, two build inputs where one path duplicates another, or where one - path is a file at a location that another path uses as a directory prefix. -- `MissingObject`, a diff whose walk references a tree object name that the - supplied object map does not contain. +::: test +name: an unknown gen mode string is rejected +given: { json: "a gen payload with mode set to \"bogus\"" } +expect: { error: Malformed } +::: -Implement `std::fmt::Display` for `StoreTreeError` so a consumer can render a tree -failure as a bare, human-readable message that carries no `Debug` artifacts: no -variant name, no quotes, and no braces. Each variant renders exactly one -deterministic line. `Malformed` renders `malformed tree object: {message}`, -interpolating its carried deserialization message verbatim after the colon and a -single space. `MalformedHash` renders `tree entry hash is not sixty-four lowercase -hex`. `EmptyName` renders `tree entry name is empty`. `InvalidName` renders `tree -entry name contains a slash or is a dot component`. `UnsortedEntries` renders `tree -entries are not in ascending name order`. `DuplicateName` renders `tree has two -entries with the same name`. `InvalidMode` renders `tree entry mode does not match -its kind`. `InvalidPath` renders `build input path is empty or has a dot component`. -`PathConflict` renders `build input paths conflict`. `MissingObject` renders -`referenced tree object is missing from the object map`. This addition is purely -additive: the `Debug` derive is retained for the test assertions that pin variant -equality. The `Display` impl and its `fmt` method carry no doc comment, matching the -crate's existing trait-impl style. +::: test +name: an unknown gen outcome string is rejected +given: { json: "a gen payload with outcome set to \"bogus\"" } +expect: { error: Malformed } +::: -## Validating a tree +::: test +name: an unknown derivation outcome string is rejected +given: { json: "a derivation payload with outcome set to \"bogus\"" } +expect: { error: Malformed } +::: -Expose `validate_tree(value: serde_json::Value) -> Result`: deserialize the JSON value into a `Tree`, returning -`StoreTreeError::Malformed` (carrying serde's message) on any deserialization -failure, then apply these semantic checks, returning the first failure in exactly -this order: +::: test +name: an unknown legacy classification string is rejected +given: { json: "a legacy payload with classification set to \"bogus\"" } +expect: { error: Malformed } +::: -- For each entry in array order, in this per-entry order: its `name` must be - non-empty, else `StoreTreeError::EmptyName`; its `name` must not contain `/` and - must be neither `.` nor `..`, else `StoreTreeError::InvalidName`; its `hash` must - be a well-formed object name (sixty-four lowercase hex), else - `StoreTreeError::MalformedHash`; its `mode` must match its `kind` under the - coupling rule (a `File` mode is `FILE_MODE` or `EXEC_MODE`, a `Dir` mode is - `DIR_MODE`), else `StoreTreeError::InvalidMode`. -- Then a single left-to-right pass over adjacent entry pairs enforces ordering and - uniqueness: for each adjacent pair, if the two names are equal fail as - `StoreTreeError::DuplicateName`, otherwise if the earlier name is greater than - the later name (by byte-lexicographic comparison of the `name` strings) fail as - `StoreTreeError::UnsortedEntries`. +### The non-advancing outcome rule -Name comparison throughout this module is byte-lexicographic over the UTF-8 bytes -of the `name` strings, the same ordering canonical JSON uses for keys, so a tree -that validates is already in the order its canonical bytes present. +A failed or interrupted step never advances authority: its after-state equals its +before-state. Concretely, both are equal, or both are absent. This rule applies to +exactly two payloads: -The struct deserialization is plain serde with `#[serde(deny_unknown_fields)]` on -both structs, so no hand-written deserializer is needed. This module uses no -adjacently tagged enum on purpose: serde does not enforce `deny_unknown_fields` on -the tag-and-content wrapper of an adjacently tagged enum, so such a shape would -demand a hand-written strict `Deserialize` to reject unknown fields, and this -module deliberately avoids that by keeping every type a plain derive. +- a `Gen` payload whose outcome is `Failed` or `Interrupted`, and +- a `Derivation` payload whose outcome is `Failed` or `Interrupted`. + +For such an event, `state_before` must equal `state_after` as options: both +`None`, or both `Some` and equal. A `Gen` with outcome `Ok`, and a `Derivation` +with outcome `Ok` or `Retried`, may advance and are not constrained this way. ::: test -name: a tree with an unknown top-level field is rejected -given: { json: "the TR_FLAT object with an extra top-level key \"bogus\": 1" } -expect: { error: Malformed } +name: a failed gen event whose stateBefore equals stateAfter is accepted +given: { event: "gen, outcome failed, stateBefore known(H2), stateAfter known(H2)" } +expect: { accepts: true } ::: ::: test -name: a tree missing entries is rejected -given: { json: "the TR_FLAT object with the entries key removed" } -expect: { error: Malformed } +name: a failed gen event whose stateBefore differs from stateAfter is rejected +given: { event: "gen, outcome failed, stateBefore known(H2), stateAfter known(H3)" } +expect: { error: NonAdvancingOutcome } ::: ::: test -name: a tree whose entries is the wrong JSON type is rejected -given: { json: "the TR_FLAT object with entries set to the number 1" } -expect: { error: Malformed } +name: an interrupted gen event whose states differ is rejected +given: { event: "gen, outcome interrupted, stateBefore known(H2), stateAfter known(H3)" } +expect: { error: NonAdvancingOutcome } ::: ::: test -name: an entry with an unknown field is rejected -given: { json: "the TR_FLAT object whose first entry carries an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: an ok gen event whose stateBefore differs from stateAfter is accepted +given: { event: "gen, outcome ok, stateBefore known(H2), stateAfter known(H3)" } +expect: { accepts: true } ::: ::: test -name: an entry missing hash is rejected -given: { json: "the TR_FLAT object whose first entry has the hash key removed" } -expect: { error: Malformed } +name: a failed derivation event whose states differ is rejected +given: { event: "derivation, outcome failed, stateBefore known(H2), stateAfter known(H3)" } +expect: { error: NonAdvancingOutcome } ::: ::: test -name: an entry missing name is rejected -given: { json: "the TR_FLAT object whose first entry has the name key removed" } -expect: { error: Malformed } +name: an interrupted derivation event whose states differ is rejected +given: { event: "derivation, outcome interrupted, stateBefore known(H2), stateAfter known(H3)" } +expect: { error: NonAdvancingOutcome } ::: ::: test -name: an entry whose kind is an unknown token is rejected -given: { json: "the TR_FLAT object with its first entry kind set to \"symlink\"" } -expect: { error: Malformed } +name: a retried derivation event whose stateBefore differs from stateAfter is accepted +given: { event: "derivation, outcome retried, stateBefore known(H2), stateAfter known(H3)" } +expect: { accepts: true } ::: ::: test -name: an entry whose mode is the wrong JSON type is rejected -given: { json: "the TR_FLAT object with its first entry mode set to the string \"33188\"" } +name: a failed gen event with both states absent is accepted +given: { event: "gen, outcome failed, stateBefore absent, stateAfter absent" } +expect: { accepts: true } +::: + +### Validating an event + +Expose `validate_event(value: serde_json::Value) -> Result`: deserialize the JSON value into an `Event`, returning +`StoreObjectError::Malformed` on any deserialization failure (an unknown top-level +field, an unknown field inside the payload's `fields`, an unknown field inside a +`provenance`, `tokens`, or `usage`-entry object, an unknown `kind` token, an unknown mode, +outcome, or classification token, a value of the wrong JSON type), then apply +these semantic checks, returning the first failure: + +- `module` must be non-empty, else `StoreObjectError::EmptyModule`. +- `ordinal` must be at least 1, else `StoreObjectError::InvalidOrdinal`. +- Every hash-typed reference must be a well-formed object name (sixty-four + lowercase hex): each entry of `parents`; each `Some(StateRef::State(_))` among + `state_before` and `state_after` (the `Unknown` sentinel and `None` are exempt); + and the payload's hash fields (`taskBundle`; `bundle`; `amendmentBundle`; a + present `diagnostic`; `originalLine`). Any malformed hash fails as + `StoreObjectError::MalformedHash`. The event-level `attemptId`, when present, and + every entry of the plural `diagnostics` list on a `Gen` or `Derivation` payload + are free-form strings and are NOT subjected to this hash check, per the + diagnostics-field ruling above; validation never rejects a non-hash `attemptId` + or a non-hash `diagnostics` entry. A `provenance` record carries no hash-typed + field and contributes no hash check, and neither does any `AttemptUsage` entry of a + `Gen` payload's `usage` list, so validation never rejects a usage entry on hash + grounds. +- The non-advancing outcome rule above must hold; a violating event fails as + `StoreObjectError::NonAdvancingOutcome`. + +::: test +name: an event with an unknown top-level field is rejected +given: { json: "the EV_GEN object with an extra top-level key \"bogus\": 1" } expect: { error: Malformed } ::: ::: test -name: an entry whose mode exceeds the u32 range is rejected -given: { json: "the TR_FLAT object with its first entry mode set to 4294967296" } +name: an event whose ordinal is the wrong JSON type is rejected +given: { json: "the EV_GEN object with ordinal set to the string \"5\"" } expect: { error: Malformed } ::: ::: test -name: an entry whose hash is not sixty-four lowercase hex is rejected -given: { json: "the TR_FLAT object with its first entry hash set to \"xyz\"" } -expect: { error: MalformedHash } +name: an event with an empty module is rejected +given: { json: "the EV_GEN object with module set to the empty string" } +expect: { error: EmptyModule } ::: ::: test -name: an entry with an empty name is rejected -given: { json: "the TR_FLAT object with its first entry name set to the empty string" } -expect: { error: EmptyName } +name: an event with ordinal zero is rejected +given: { json: "the EV_GEN object with ordinal set to 0" } +expect: { error: InvalidOrdinal } ::: ::: test -name: an entry whose name contains a slash is rejected -given: { json: "the TR_FLAT object with its first entry name set to \"a/b\"" } -expect: { error: InvalidName } +name: an event whose ordinal is the largest u64 is accepted +given: { json: "the EV_GEN object with ordinal set to 18446744073709551615" } +expect: { accepts: true } ::: ::: test -name: an entry whose name is a single dot is rejected -given: { json: "the TR_FLAT object with its first entry name set to \".\"" } -expect: { error: InvalidName } +name: an event whose ordinal exceeds the u64 range is rejected +given: { json: "the EV_GEN object with ordinal set to 18446744073709551616" } +expect: { error: Malformed } ::: ::: test -name: an entry whose name is a double dot is rejected -given: { json: "the TR_FLAT object with its first entry name set to \"..\"" } -expect: { error: InvalidName } +name: a gen event whose attempts is the largest u32 is accepted +given: { json: "the EV_GEN object with its payload fields attempts set to 4294967295" } +expect: { accepts: true } ::: ::: test -name: a tree whose entries are not in ascending name order is rejected -given: { json: "the TR_FLAT object with its two entries swapped so b.rs precedes a.rs" } -expect: { error: UnsortedEntries } +name: a gen event whose attempts exceeds the u32 range is rejected +given: { json: "the EV_GEN object with its payload fields attempts set to 4294967296" } +expect: { error: Malformed } ::: ::: test -name: a tree with two entries of the same name is rejected -given: { json: "the TR_FLAT object whose second entry name is set to a.rs, equal to the first" } -expect: { error: DuplicateName } +name: an event with a malformed hash in parents is rejected +given: { json: "the EV_GEN object with parents set to [\"xyz\"]" } +expect: { error: MalformedHash } ::: -## Naming a tree object through `store_hash` +## The snapshot kind -Expose `tree_object_name(tree: &Tree) -> String`, the object's name: serialize the -value to a `serde_json::Value`, and return -`store_hash::object_name(store_hash::ObjectKind::Tree, -&store_hash::canonical_json(&value))`. This does not reimplement canonical JSON, -envelope framing, or hashing; it calls `store_hash`. The canonical bytes fed to -the envelope are exactly `store_hash::canonical_json` applied to the serialized -value, so a round-trip through `store_hash::verify_object` over the framed -envelope succeeds and reports `store_hash::ObjectKind::Tree`. +Expose a public struct `Snapshot` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, +`serde::Serialize`, and `serde::Deserialize`, with +`#[serde(deny_unknown_fields)]`), a commit. Public fields: -## Building a tree from a flat file set +- `parents: Vec`, serde field name `parents`. One parent, or two at a + merge. Always present. +- `seq: u64`, serde field name `seq`. The sequence number, equal to the committed + ordinal. It is a `u64`. +- `root: std::collections::BTreeMap`, serde field name `root`. + The root map, module name to its `RootEntry`, keyed and serialized in sorted + order via `BTreeMap`. -A generated target's output is a flat set of files, each at a slash-separated -relative path, each already published as a blob and known by its blob object name, -each with a mode. Building turns that flat set into the nested tree objects that -represent it, computing every subtree's object name so the caller can publish -every object. +Expose a public struct `RootEntry` (same derives, same +`#[serde(deny_unknown_fields)]`), one module's entry in the root map. Public +fields: -Expose a public struct `TreeFile` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`), -one build input. Public fields: +- `module_state: String`, serde field name `moduleState`. The module-state + object's hash. +- `event_head: String`, serde field name `eventHead`. The module's event-head + object hash. -- `path: String`, the file's slash-separated relative path, for example `lib.rs` - or `sub/inner.rs`. -- `hash: String`, the file's blob object name. -- `mode: u32`, the file's mode, which must be `FILE_MODE` or `EXEC_MODE`; a build - input never carries `DIR_MODE`, because directories are synthesized, not - supplied. +### Validating a snapshot -Expose a public struct `BuiltTree` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`), the build result. Public fields: +Expose `validate_snapshot(value: serde_json::Value) -> Result`: deserialize into a `Snapshot`, returning +`StoreObjectError::Malformed` on any deserialization failure, then apply these +semantic checks, returning the first failure: -- `root: String`, the root tree object's name. -- `objects: std::collections::BTreeMap`, every tree object the build - produced, keyed by its object name: the root tree and every nested subtree. A - `BTreeMap` is used so iteration is deterministic. Blobs are not included; the - caller already holds them. +- `parents` must have length 1 or 2, else `StoreObjectError::InvalidParentCount`. +- Every parent hash, and every root entry's `moduleState` and `eventHead`, must be + a well-formed object name (sixty-four lowercase hex), else + `StoreObjectError::MalformedHash`. +- Every key of `root` must be a non-empty string, else + `StoreObjectError::EmptyKey`. -Expose `build_tree(files: &[TreeFile]) -> Result`. It -validates the inputs, then constructs the tree bottom-up: +::: test +name: a snapshot with an unknown field is rejected +given: { json: "the SNAP object with an extra top-level key \"bogus\": 1" } +expect: { error: Malformed } +::: -- Validate the inputs one at a time in slice order, and within a single input - validate its path before its mode: split the path on `/` into components; the path - must be non-empty and every component must be non-empty and neither `.` nor `..`, - else `StoreTreeError::InvalidPath`; then that same input's mode must be `FILE_MODE` - or `EXEC_MODE`, else `StoreTreeError::InvalidMode`. Because an input's path is - checked before its mode, a single input carrying both a bad path and a bad mode - reports `StoreTreeError::InvalidPath`. (`build_tree` does not validate an input - `hash`: hash validity is the caller's contract, and `build_tree` never returns - `StoreTreeError::MalformedHash`. It copies each input `hash` verbatim into the - entry it builds, so a built tree satisfies `validate_tree` only when every input - `hash` is a valid sixty-four-character lowercase-hex object name; an input carrying - a malformed `hash` yields a tree that `validate_tree` would reject as - `MalformedHash`. The suite supplies only well-formed input hashes.) -- Detect path conflicts: two inputs with the identical path, or one input whose - full path equals a strict directory prefix of another input's path (so one path - names a file where another needs a directory), both fail as - `StoreTreeError::PathConflict`. Both kinds of conflict raise the same variant, so - which kind is detected first is unobservable and is not part of the contract; an - implementation may detect either first, and no test depends on the choice. -- Construct each directory level from the inputs whose paths share that level's - prefix. At each level, an input whose remaining path is a single component - becomes a `File` entry named by that component, carrying the input's `hash` and - `mode`. Inputs whose remaining path has more than one component are grouped by - their next component into a child directory; recursively build that child level, - name the resulting subtree through `tree_object_name`, and emit a `Dir` entry - named by the component, carrying that subtree name as its `hash` and `DIR_MODE` - as its `mode`. Every subtree, including the root, is inserted into `objects` - keyed by its own `tree_object_name`. -- Sort every level's entries by ascending `name` before naming it, so a built tree - over well-formed input hashes satisfies `validate_tree`, and the order of the input - slice never affects the result. -- An empty input slice yields the empty tree: `root` is the empty tree's object - name and `objects` contains exactly that one empty tree. +::: test +name: a snapshot with zero parents is rejected +given: { json: "the SNAP object with parents set to []" } +expect: { error: InvalidParentCount } +::: -Building is total and deterministic given valid inputs: no LLM, no I/O, no -randomness. Its object names are exactly those `tree_object_name` produces for the -trees it builds, so a caller may publish every entry of `objects` under its key and -read them back through `store_hash::verify_object`. +::: test +name: a snapshot with three parents is rejected +given: { json: "the SNAP object with parents set to [H6, H7, H8]" } +expect: { error: InvalidParentCount } +::: ::: test -name: building an unsorted flat file set yields the pinned flat tree name and sorted entries -given: - files: - - { path: "b.rs", hash: B, mode: 33261 } - - { path: "a.rs", hash: A, mode: 33188 } -expect: - root_name: "5261e5143f1c9d28a42242a93dde34caf3949409524beee39d77bc1e35844dc2" - object_names: ["5261e5143f1c9d28a42242a93dde34caf3949409524beee39d77bc1e35844dc2"] - root_entries: - - { name: "a.rs", kind: file, hash: A, mode: 33188 } - - { name: "b.rs", kind: file, hash: B, mode: 33261 } +name: a snapshot whose seq is the largest u64 is accepted +given: { json: "the SNAP object with seq set to 18446744073709551615" } +expect: { accepts: true } ::: ::: test -name: building a nested file set yields its pinned subtree and root names and object map -given: - files: - - { path: "lib.rs", hash: A, mode: 33188 } - - { path: "sub/inner.rs", hash: B, mode: 33188 } -expect: - root_name: "cb965100c5cc41bf5b918ee41261379fd920b409294cff6fc2b7e0d9281cd4d2" - subtree_name: "41a134a67bf30067699690165d522d171e7585177d5df0ae0e03850f90253065" - object_names: ["41a134a67bf30067699690165d522d171e7585177d5df0ae0e03850f90253065", "cb965100c5cc41bf5b918ee41261379fd920b409294cff6fc2b7e0d9281cd4d2"] - root_entries: - - { name: "lib.rs", kind: file, hash: A, mode: 33188 } - - { name: "sub", kind: dir, hash: "41a134a67bf30067699690165d522d171e7585177d5df0ae0e03850f90253065", mode: 16384 } - subtree_entries: - - { name: "inner.rs", kind: file, hash: B, mode: 33188 } +name: a snapshot whose seq exceeds the u64 range is rejected +given: { json: "the SNAP object with seq set to 18446744073709551616" } +expect: { error: Malformed } ::: ::: test -name: building a nested file set assigns the directory mode to its synthesized directory entry -given: - files: - - { path: "lib.rs", hash: A, mode: 33188 } - - { path: "sub/inner.rs", hash: B, mode: 33188 } -expect: { sub_entry_kind: dir, sub_entry_mode: 16384 } +name: a snapshot with a malformed moduleState hash is rejected +given: { json: "the SNAP object whose one root entry moduleState is set to \"xyz\"" } +expect: { error: MalformedHash } ::: ::: test -name: building an empty file set yields the empty tree -given: { files: [] } -expect: - root_name: "a8874cb576274d29e603d4e720fe336a01b8f7b1a75af221c6bbba9b4ed47898" - object_names: ["a8874cb576274d29e603d4e720fe336a01b8f7b1a75af221c6bbba9b4ed47898"] - root_entry_count: 0 +name: a snapshot with an empty root key is rejected +given: { json: "the SNAP object with the root key \"greeting\" renamed to the empty string" } +expect: { error: EmptyKey } ::: ::: test -name: building a file set with an empty path component is rejected -given: - files: - - { path: "sub//inner.rs", hash: A, mode: 33188 } -expect: { error: InvalidPath } +name: a root-map entry with an unknown field is rejected +given: { json: "the SNAP object whose one root entry carries an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: +## The error type + +Expose a public enum `StoreObjectError` (deriving `Debug`, `Clone`, `PartialEq`, +and `Eq`) with exactly these variants, one per failure mode above: + +- `Malformed(String)`, carrying the underlying deserialization message, for any + structural or type failure surfaced by serde (an unknown field, a missing + required field, an unknown enum token, a wrong JSON type). +- `EmptyModule`, an event whose module is empty. +- `InvalidOrdinal`, an event whose ordinal is less than 1. +- `MalformedHash`, a hash-typed field that is not sixty-four lowercase hex. +- `NonAdvancingOutcome`, a failed or interrupted step whose before and after + states are not equal. +- `InvalidParentCount`, a snapshot whose parent count is not 1 or 2. +- `EmptyKey`, an empty key in a `targets` or `root` map. + +## Naming a built object through `store_hash` + +For each of the three kinds, expose a function that returns the object's name, the +SHA-256 of its canonical-JSON envelope, by composing `store_hash`. Each serializes +the value to a `serde_json::Value`, and returns `store_hash::object_name(kind, +&store_hash::canonical_json(&value))` for the matching `ObjectKind`: + +- `module_state_object_name(state: &ModuleState) -> String`, with + `ObjectKind::ModuleState`. +- `event_object_name(event: &Event) -> String`, with `ObjectKind::Event`. +- `snapshot_object_name(snapshot: &Snapshot) -> String`, with + `ObjectKind::Snapshot`. + +These do not reimplement canonical JSON, envelope framing, or hashing; they call +`store_hash`. The canonical bytes fed to the envelope are exactly +`store_hash::canonical_json` applied to the serialized value, so a round-trip +through `store_hash::verify_object` over the framed envelope succeeds and reports +the matching kind. + +## Fixtures the tests pin + +These fixtures and hash constants are referenced by name in the test suite. The +hash constants are each a sixty-four-character string of a single repeated +character: `A` is sixty-four `a` characters, `B` sixty-four `b`, `C` sixty-four +`c`, `D` sixty-four `d`, `E` sixty-four `e`, `F` sixty-four `f`, and `H1` through +`H8` are sixty-four repetitions of the digits `1` through `8` respectively. All +are well-formed lowercase-hex object names. + +- **MS_MIN**: a `ModuleState` with `promptBlob` = `A`, `blockIndex` = `B`, `mapl` + = `None`, `ir` = `None`, and empty `targets`. Its canonical bytes are exactly + `{"blockIndex":"","promptBlob":"","targets":{}}` followed by one newline; + its envelope is `napl-store module-state v1 175\n` followed by those canonical + bytes; its object name is + `ab63db013784f331f93f17786b8ea92f9a89736c44686ea112223ee03854dc94`. + ::: test -name: building a file set with a dot path component is rejected -given: - files: - - { path: "sub/./inner.rs", hash: A, mode: 33188 } -expect: { error: InvalidPath } +name: a minimal module-state canonicalizes to its pinned bytes +given: { fixture: MS_MIN } +expect: { canonical_bytes: "{\"blockIndex\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"promptBlob\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"targets\":{}}\n" } ::: ::: test -name: building a file set where a file path is a prefix of a directory path is rejected -given: - files: - - { path: "a", hash: A, mode: 33188 } - - { path: "a/b", hash: B, mode: 33188 } -expect: { error: PathConflict } +name: a minimal module-state names itself by its pinned digest +given: { fixture: MS_MIN } +expect: { object_name: "ab63db013784f331f93f17786b8ea92f9a89736c44686ea112223ee03854dc94" } ::: ::: test -name: building a file set with a duplicate path is rejected -given: - files: - - { path: "a.rs", hash: A, mode: 33188 } - - { path: "a.rs", hash: B, mode: 33188 } -expect: { error: PathConflict } +name: a minimal module-state frames its pinned envelope bytes +given: { fixture: MS_MIN } +expect: { envelope_bytes: "napl-store module-state v1 175\n{\"blockIndex\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"promptBlob\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"targets\":{}}\n" } ::: ::: test -name: building a file whose mode is the directory mode is rejected -given: - files: - - { path: "a.rs", hash: A, mode: 16384 } -expect: { error: InvalidMode } +name: a minimal module-state round-trips through the read verifier +given: { fixture: MS_MIN } +expect: { verify_ok: true, kind: module-state } ::: ::: test -name: building a file whose mode is unrecognized is rejected -given: - files: - - { path: "a.rs", hash: A, mode: 33152 } -expect: { error: InvalidMode } +name: an absent mapl and ir are omitted from a module-state's canonical bytes +given: { fixture: MS_MIN } +expect: { has_mapl_key: false, has_ir_key: false } ::: -## Diffing two trees +- **MS_RICH**: a `ModuleState` with `promptBlob` = `A`, `blockIndex` = `B`, `mapl` + = `Some(C)`, `ir` = `Some(D)`, and one target `rust` whose `TargetArtifacts` has + `generatedTree` = `E` and `attribution` = `Some(F)`. Its object name is + `9dca13259e9b91d24853a8b187dd9b309b2e2bd2c614c2edec089da5ad2fa9af`. -A diff reports, as a flat list of per-leaf changes, how one tree differs from -another, walking only the parts of the two trees whose hashes differ. It is the -Merkle payoff: an unchanged subtree, recognized because both sides carry the same -directory-entry `hash`, is skipped whole. +::: test +name: "a rich module-state with mapl, ir, and one target canonicalizes to its pinned bytes" +given: { fixture: MS_RICH } +expect: { canonical_bytes: "{\"blockIndex\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"ir\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"mapl\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"promptBlob\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"targets\":{\"rust\":{\"attribution\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"generatedTree\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\"}}}\n" } +::: -Expose a public enum `TreeChange` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) -with exactly these variants, each naming a leaf file by its full slash-joined path -from the diff root: +::: test +name: a rich module-state names itself by its pinned digest +given: { fixture: MS_RICH } +expect: { object_name: "9dca13259e9b91d24853a8b187dd9b309b2e2bd2c614c2edec089da5ad2fa9af" } +::: -- `TreeChange::Added { path: String, hash: String, mode: u32 }`, a file present in - the new tree and not the old. -- `TreeChange::Removed { path: String, hash: String, mode: u32 }`, a file present - in the old tree and not the new. -- `TreeChange::Modified { path: String, old_hash: String, old_mode: u32, new_hash: - String, new_mode: u32 }`, a file present in both trees at the same path whose - `hash` or `mode` differs between them. +::: test +name: a rich module-state round-trips back to an equal value +given: { fixture: MS_RICH } +expect: { round_trip_equal: true } +::: -Expose `diff_trees(old_root: Option<&str>, new_root: Option<&str>, objects: -&std::collections::BTreeMap) -> Result, -StoreTreeError>`. The two roots are object names, or `None` for a side that has no -tree at all (a target never generated, on the old side, or removed entirely, on -the new side). The `objects` map resolves every tree object name the walk needs, -the root and every subtree, on both sides; it is the in-memory provider for this -pure function. The walk: +- **EV_GEN**: an `Event` with `parents` = `[H1]`, `module` = `greeting`, `target` + = `Some("rust")`, `ordinal` = 5, `timestamp` = `2026-07-25T00:00:00Z`, `engine` + = `opus`, `state_before` = `Some(State(H2))`, `state_after` = `Some(State(H3))`, + and payload `Gen` with `mode` = `Full`, `outcome` = `Ok`, `attempts` = 1, + `taskBundle` = `H4`, and `provenance` = `None`. Its envelope is + `napl-store event v1 499\n` followed by its canonical bytes; its object name is + `dc807c63cc0b542fde0132415814a66183931ce2b7334662dc8fb3810948ae9f`. Because its + `provenance` is `None` and that field is skipped when `None`, its canonical bytes, + envelope, and object name are exactly the same as before provenance existed: this + fixture is unchanged by the provenance addition and its pins are byte-identical. -- Resolve each non-`None` root through `objects` first, before any equal-roots - check. A root name absent from the map fails as `StoreTreeError::MissingObject`, - including the case where both roots are the same name and that name is absent, so - `diff_trees(Some(x), Some(x), empty_map)` fails as `MissingObject` rather than - returning an empty list. A `None` root is treated as an empty directory with no - entries. -- After both roots have resolved, if `old_root` and `new_root` are both `Some` and - equal, the trees are identical: return no changes without walking their entries. -- Compare the two directory levels by merging their entries on `name` in ascending - order. For each name: - - Present in the new level only: if it is a file, emit `Added` for it; if it is a - directory, resolve its subtree and enumerate every leaf beneath it, emitting an - `Added` per leaf at that leaf's full path. - - Present in the old level only: symmetrically, emit `Removed` for the file, or a - `Removed` per leaf beneath the directory. - - Present in both: if both are files, emit `Modified` when the `hash` or the - `mode` differs and nothing when both are equal; if both are directories with - equal `hash`, skip the whole subtree (the Merkle prune); if both are - directories with differing `hash`, resolve both subtrees and recurse into that - child level; if one is a file and the other a directory, emit a `Removed` for - the old side (the file, or every old leaf) and an `Added` for the new side (the - file, or every new leaf). -- Resolving any directory entry's subtree, whether to recurse or to enumerate its - leaves, that is absent from `objects` fails as `StoreTreeError::MissingObject`. -- Every full path is its ancestor directory names joined by `/` ending in the leaf - name. Return all collected changes sorted by ascending `path` (byte-lexicographic - over the path strings). Because a path names a single leaf, at most one change - exists per path, so the ordering is total and deterministic. +::: test +name: a gen ok event canonicalizes to its pinned bytes +given: { fixture: EV_GEN } +expect: { canonical_bytes: "{\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"mode\":\"full\",\"outcome\":\"ok\",\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } +::: -Diffing computes no hashes and publishes nothing; it only reads the supplied trees -and compares their stored hashes. Renames are not a `TreeChange` variant: a file -whose content moved appears as a `Removed` at its old path and an `Added` at its -new path carrying the same `hash`, and a higher layer that wants rename detection -composes it on top of this raw path-level diff. +::: test +name: a gen ok event names itself by its pinned digest +given: { fixture: EV_GEN } +expect: { object_name: "dc807c63cc0b542fde0132415814a66183931ce2b7334662dc8fb3810948ae9f" } +::: ::: test -name: diffing a tree against itself yields no changes -given: { old: FLAT_AB, new: FLAT_AB } -expect: { changes: [] } +name: a gen ok event frames its pinned envelope bytes +given: { fixture: EV_GEN } +expect: { envelope_bytes: "napl-store event v1 499\n{\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"mode\":\"full\",\"outcome\":\"ok\",\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } ::: ::: test -name: diffing yields an added file -given: { old: FLAT_A, new: FLAT_AB } -expect: - changes: - - { change: added, path: "b.rs", hash: B, mode: 33261 } +name: a gen ok event round-trips through the read verifier +given: { fixture: EV_GEN } +expect: { verify_ok: true, kind: event } ::: ::: test -name: diffing yields a removed file -given: { old: FLAT_AB, new: FLAT_A } -expect: - changes: - - { change: removed, path: "b.rs", hash: B, mode: 33261 } +name: a gen event without provenance omits the provenance key from its canonical bytes +given: { fixture: EV_GEN } +expect: { has_provenance_key: false } ::: ::: test -name: diffing yields a modified file when its hash changes -given: { old: FLAT_A, new: FLAT_A_TO_B } -expect: - changes: - - { change: modified, path: "a.rs", oldHash: A, oldMode: 33188, newHash: B, newMode: 33188 } +name: a gen event without a usage log omits the usage key from its canonical bytes +given: { fixture: EV_GEN } +expect: { has_usage_key: false } ::: +- **EV_LEGACY**: an `Event` with `parents` = `[]`, `module` = `greeting`, `target` + = `None`, `ordinal` = 1, `timestamp` = `2026-07-25T00:00:00Z`, `engine` = `""`, + `state_before` = `Some(Unknown)`, `state_after` = `Some(Unknown)`, and payload + `Legacy` with `originalLine` = `H5` and `classification` = `MissingPrefix`. Its + object name is + `fba5e4f9532e4dd01d1f9571b46afafced354533db1730806acb703643fd05f4`. + ::: test -name: diffing yields a modified file when only its mode changes -given: { old: FLAT_A, new: FLAT_A_EXEC } -expect: - changes: - - { change: modified, path: "a.rs", oldHash: A, oldMode: 33188, newHash: A, newMode: 33261 } +name: an absent target and absent states are omitted from an event's canonical bytes +given: { fixture: EV_LEGACY } +expect: { has_target_key: false } ::: ::: test -name: diffing prunes an unchanged subtree and reports only the changed sibling leaf -given: { old: TWO_DIRS_OLD, new: TWO_DIRS_NEW } -expect: - changes: - - { change: modified, path: "chg/y.rs", oldHash: B, oldMode: 33188, newHash: C, newMode: 33188 } +name: a legacy event with unknown states canonicalizes to its pinned bytes +given: { fixture: EV_LEGACY } +expect: { canonical_bytes: "{\"engine\":\"\",\"module\":\"greeting\",\"ordinal\":1,\"parents\":[],\"payload\":{\"fields\":{\"classification\":\"missing-prefix\",\"originalLine\":\"5555555555555555555555555555555555555555555555555555555555555555\"},\"kind\":\"legacy\"},\"stateAfter\":\"unknown\",\"stateBefore\":\"unknown\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } ::: ::: test -name: diffing an added directory expands to one added change per leaf file -given: { old: FLAT_LIB, new: LIB_PLUS_DIR } -expect: - changes: - - { change: added, path: "sub/inner.rs", hash: B, mode: 33188 } - - { change: added, path: "sub/more.rs", hash: C, mode: 33188 } +name: a legacy event names itself by its pinned digest +given: { fixture: EV_LEGACY } +expect: { object_name: "fba5e4f9532e4dd01d1f9571b46afafced354533db1730806acb703643fd05f4" } ::: ::: test -name: diffing a removed directory expands to one removed change per leaf file -given: { old: LIB_PLUS_DIR, new: FLAT_LIB } -expect: - changes: - - { change: removed, path: "sub/inner.rs", hash: B, mode: 33188 } - - { change: removed, path: "sub/more.rs", hash: C, mode: 33188 } +name: a legacy event round-trips back to an equal value preserving both unknown sentinels +given: { fixture: EV_LEGACY } +expect: { round_trip_equal: true } +::: + +- **EV_GEN_INT**: an `Event` identical in shape to EV_GEN but carrying the two new + amendment fields, an interrupted gen recovered from an attempt. Its `attempt_id` + is `Some("att-0001")`; `parents` = `[H1]`, `module` = `greeting`, `target` = + `Some("rust")`, `ordinal` = 5, `timestamp` = `2026-07-25T00:00:00Z`, `engine` = + `opus`; `state_before` = `Some(State(H2))` and `state_after` = `Some(State(H2))`, + equal per the non-advancing rule; and payload `Gen` with `mode` = `Full`, + `outcome` = `Interrupted`, `attempts` = 1, `taskBundle` = `H4`, and `diagnostics` + = `vec![H3]` (one diagnostic blob hash carried over). Its canonical bytes are + exactly + `{"attemptId":"att-0001","engine":"opus","module":"greeting","ordinal":5,"parents":["

"],"payload":{"fields":{"attempts":1,"diagnostics":["

"],"mode":"full","outcome":"interrupted","taskBundle":"

"},"kind":"gen"},"stateAfter":"

","stateBefore":"

","target":"rust","timestamp":"2026-07-25T00:00:00Z"}` + followed by one newline; its envelope is `napl-store event v1 614\n` followed by + those canonical bytes; and its object name is + `17f796f1f623dc65d2f596336befb1e062c619ca1d6f87aa3e76245c5e37420e`. It proves the + new `attemptId` sorts to the front of the event's canonical keys and the new + `diagnostics` sorts between `attempts` and `mode` inside the gen `fields`. + +::: test +name: a gen event carrying an attempt id and diagnostics canonicalizes to its pinned bytes +given: { fixture: EV_GEN_INT } +expect: { canonical_bytes: "{\"attemptId\":\"att-0001\",\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"diagnostics\":[\"3333333333333333333333333333333333333333333333333333333333333333\"],\"mode\":\"full\",\"outcome\":\"interrupted\",\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } ::: ::: test -name: diffing a name that changes from file to directory yields a removed leaf and added leaves -given: { old: X_FILE, new: X_DIR } -expect: - changes: - - { change: removed, path: "x", hash: A, mode: 33188 } - - { change: added, path: "x/a.rs", hash: B, mode: 33188 } +name: a gen event carrying an attempt id and diagnostics names itself by its pinned digest +given: { fixture: EV_GEN_INT } +expect: { object_name: "17f796f1f623dc65d2f596336befb1e062c619ca1d6f87aa3e76245c5e37420e" } ::: ::: test -name: diffing an absent old root reports every new leaf as added -given: { old: none, new: FLAT_AB } -expect: - changes: - - { change: added, path: "a.rs", hash: A, mode: 33188 } - - { change: added, path: "b.rs", hash: B, mode: 33261 } +name: a gen event carrying an attempt id and diagnostics frames its pinned envelope bytes +given: { fixture: EV_GEN_INT } +expect: { envelope_bytes: "napl-store event v1 614\n{\"attemptId\":\"att-0001\",\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"diagnostics\":[\"3333333333333333333333333333333333333333333333333333333333333333\"],\"mode\":\"full\",\"outcome\":\"interrupted\",\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } ::: ::: test -name: diffing an absent new root reports every old leaf as removed -given: { old: FLAT_AB, new: none } -expect: - changes: - - { change: removed, path: "a.rs", hash: A, mode: 33188 } - - { change: removed, path: "b.rs", hash: B, mode: 33261 } +name: a gen event carrying an attempt id and diagnostics round-trips back to an equal value +given: { fixture: EV_GEN_INT } +expect: { round_trip_equal: true } ::: +- **DERIV_BOTH**: an `Event` whose payload is a `Derivation` carrying both the + pre-existing singular `diagnostic` and the new plural `diagnostics`, proving the + two coexist independently. Its `attempt_id` is `Some("att-0002")`; `parents` = + `[H1]`, `module` = `greeting`, `target` = `None`, `ordinal` = 6, `timestamp` = + `2026-07-25T00:00:00Z`, `engine` = `opus`; `state_before` = `Some(State(H2))` and + `state_after` = `Some(State(H2))`, equal per the non-advancing rule; and payload + `Derivation` with `derivation` = `mapl`, `outcome` = `Interrupted`, `attempts` = + 1, `error` = the empty string, `diagnostic` = `Some(H5)`, and `diagnostics` = + `vec![H6, H7]`. The three hashes `H5`, `H6`, and `H7` are pairwise distinct, so + the singular `diagnostic` and each entry of the plural `diagnostics` are provably + different values. Its canonical bytes are exactly + `{"attemptId":"att-0002","engine":"opus","module":"greeting","ordinal":6,"parents":["

"],"payload":{"fields":{"attempts":1,"derivation":"mapl","diagnostic":"

","diagnostics":["
",""],"error":"","outcome":"interrupted"},"kind":"derivation"},"stateAfter":"

","stateBefore":"

","timestamp":"2026-07-25T00:00:00Z"}` + followed by one newline; its envelope is `napl-store event v1 689\n` followed by + those canonical bytes; and its object name is + `2ac016d8ff0d0cd4f01ab9b383989ca448665cfc9fe14299d0d08e61a93cf56b`. + ::: test -name: diffing a root hash absent from the object map is rejected -given: { old: MISSING_ROOT, new: FLAT_AB } -expect: { error: MissingObject } +name: a derivation event carrying both a singular diagnostic and the diagnostics list canonicalizes to its pinned bytes +given: { fixture: DERIV_BOTH } +expect: { canonical_bytes: "{\"attemptId\":\"att-0002\",\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":6,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"derivation\":\"mapl\",\"diagnostic\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"diagnostics\":[\"6666666666666666666666666666666666666666666666666666666666666666\",\"7777777777777777777777777777777777777777777777777777777777777777\"],\"error\":\"\",\"outcome\":\"interrupted\"},\"kind\":\"derivation\"},\"stateAfter\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } ::: ::: test -name: diffing a changed subtree hash absent from the object map is rejected -given: { old: DIR_WITH_MISSING_CHILD, new: LIB_PLUS_DIR } -expect: { error: MissingObject } +name: a derivation event carrying both diagnostic fields names itself by its pinned digest +given: { fixture: DERIV_BOTH } +expect: { object_name: "2ac016d8ff0d0cd4f01ab9b383989ca448665cfc9fe14299d0d08e61a93cf56b" } ::: ::: test -name: diffing reports its changes in ascending path order -given: { old: FLAT_B, new: FLAT_A_AND_C } -expect: - changes: - - { change: added, path: "a.rs", hash: A, mode: 33188 } - - { change: removed, path: "b.rs", hash: B, mode: 33188 } - - { change: added, path: "c.rs", hash: C, mode: 33188 } +name: a derivation event carrying both diagnostic fields round-trips back to an equal value preserving each independently +given: { fixture: DERIV_BOTH } +expect: { round_trip_equal: true } ::: -## Fixtures the tests pin +- **EV_GEN_OPPRES**: an `Event` whose payload is a `Gen` whose `diagnostics` list + carries a single verbatim non-hash operation token, proving the plural + `diagnostics` is not subjected to the hash check. Its `attempt_id` is + `Some("att-0003")`; `parents` = `[H1]`, `module` = `greeting`, `target` = + `Some("rust")`, `ordinal` = 7, `timestamp` = `2026-07-25T00:00:00Z`, `engine` = + `opus`; `state_before` = `Some(State(H2))` and `state_after` = `Some(State(H2))`, + equal per the non-advancing rule; and payload `Gen` with `mode` = `Full`, + `outcome` = `Interrupted`, `attempts` = 1, `taskBundle` = `H4`, and `diagnostics` + = `vec!["vendor-sync".to_string()]`, the literal non-hash string `vendor-sync`. + Its canonical bytes are exactly + `{"attemptId":"att-0003","engine":"opus","module":"greeting","ordinal":7,"parents":["

"],"payload":{"fields":{"attempts":1,"diagnostics":["vendor-sync"],"mode":"full","outcome":"interrupted","taskBundle":"

"},"kind":"gen"},"stateAfter":"

","stateBefore":"

","target":"rust","timestamp":"2026-07-25T00:00:00Z"}` + followed by one newline; its envelope is `napl-store event v1 561\n` followed by + those canonical bytes; and its object name is + `d894b56fc66567fac5290b7ffda3ccddcad0295741a459de417510dc824ac474`. + `validate_event` accepts it, and the non-hash token `vendor-sync` survives the + round trip unchanged. -These fixtures and symbols are referenced by name in the test suite. The hash -constants are each a sixty-four-character string of a single repeated character: -`A` is sixty-four `a` characters, `B` sixty-four `b`, `C` sixty-four `c`, and `D` -sixty-four `d`. All are well-formed lowercase-hex object names. The modes named in -the suite are the three constants: `33188` is `FILE_MODE`, `33261` is `EXEC_MODE`, -and `16384` is `DIR_MODE`. +::: test +name: a gen event whose diagnostics carries a verbatim non-hash operation token canonicalizes to its pinned bytes +given: { fixture: EV_GEN_OPPRES } +expect: { canonical_bytes: "{\"attemptId\":\"att-0003\",\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":7,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"diagnostics\":[\"vendor-sync\"],\"mode\":\"full\",\"outcome\":\"interrupted\",\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } +::: -Object fixtures: +::: test +name: a gen event whose diagnostics carries a verbatim non-hash operation token is accepted and never hash-checked +given: { fixture: EV_GEN_OPPRES } +expect: { accepts: true } +::: -- **TR_EMPTY**: a `Tree` with no entries. Its canonical bytes are exactly - `{"entries":[]}` followed by one newline; its envelope is `napl-store tree v1 - 15\n` followed by those canonical bytes; its object name is - `a8874cb576274d29e603d4e720fe336a01b8f7b1a75af221c6bbba9b4ed47898`. +- **EV_GEN_PROV**: the `EV_GEN` event with its `Gen` payload's `provenance` set to + `Some` of a `Provenance` whose `model` = `claude-opus-4-8`, `tokens` = + `TokenUsage { input: 1000, output: 500, cache_read: None, cache_write: None }`, + `estimated_cost_usd` = `Some("0.0525")`, `wall_ms` = `12345`, and + `price_table_version` = `1`. This is exactly the record + `provenance_from_usage("claude-opus-4-8", TokenUsage { input: 1000, output: 500, + cache_read: None, cache_write: None }, 12345)` produces, because + `estimate_cost("claude-opus-4-8", &tokens)` over input `1000` and output `500` at + the opus rate is `0.0525`. Its canonical bytes are exactly + `{"engine":"opus","module":"greeting","ordinal":5,"parents":["

"],"payload":{"fields":{"attempts":1,"mode":"full","outcome":"ok","provenance":{"estimatedCostUsd":"0.0525","model":"claude-opus-4-8","priceTableVersion":1,"tokens":{"input":1000,"output":500},"wallMs":12345},"taskBundle":"

"},"kind":"gen"},"stateAfter":"

","stateBefore":"

","target":"rust","timestamp":"2026-07-25T00:00:00Z"}` + followed by one newline, a payload of six hundred forty-two bytes; its envelope + is `napl-store event v1 642\n` followed by those canonical bytes; its object name + is `ba63bb11bc2e25378f533be8f8b5a019e12418f97c5050c068e28af75b391032`. ::: test -name: an empty tree canonicalizes to its pinned bytes -given: { fixture: TR_EMPTY } -expect: { canonical_bytes: "{\"entries\":[]}\n" } +name: a gen event carrying provenance canonicalizes to its pinned bytes +given: { fixture: EV_GEN_PROV } +expect: { canonical_bytes: "{\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"mode\":\"full\",\"outcome\":\"ok\",\"provenance\":{\"estimatedCostUsd\":\"0.0525\",\"model\":\"claude-opus-4-8\",\"priceTableVersion\":1,\"tokens\":{\"input\":1000,\"output\":500},\"wallMs\":12345},\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } ::: ::: test -name: an empty tree names itself by its pinned digest -given: { fixture: TR_EMPTY } -expect: { object_name: "a8874cb576274d29e603d4e720fe336a01b8f7b1a75af221c6bbba9b4ed47898" } +name: a gen event carrying provenance names itself by its pinned digest +given: { fixture: EV_GEN_PROV } +expect: { object_name: "ba63bb11bc2e25378f533be8f8b5a019e12418f97c5050c068e28af75b391032" } ::: ::: test -name: an empty tree frames its pinned envelope bytes -given: { fixture: TR_EMPTY } -expect: { envelope_bytes: "napl-store tree v1 15\n{\"entries\":[]}\n" } +name: a gen event carrying provenance frames its pinned envelope bytes +given: { fixture: EV_GEN_PROV } +expect: { envelope_bytes: "napl-store event v1 642\n{\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"mode\":\"full\",\"outcome\":\"ok\",\"provenance\":{\"estimatedCostUsd\":\"0.0525\",\"model\":\"claude-opus-4-8\",\"priceTableVersion\":1,\"tokens\":{\"input\":1000,\"output\":500},\"wallMs\":12345},\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } ::: ::: test -name: an empty tree round-trips through the read verifier -given: { fixture: TR_EMPTY } -expect: { verify_ok: true, kind: tree } +name: a gen event carrying provenance round-trips through the read verifier +given: { fixture: EV_GEN_PROV } +expect: { verify_ok: true, kind: event } ::: ::: test -name: an empty tree round-trips back to an equal value -given: { fixture: TR_EMPTY } +name: a gen event carrying provenance round-trips back to an equal value +given: { fixture: EV_GEN_PROV } expect: { round_trip_equal: true } ::: -- **TR_FLAT**: a `Tree` with two `File` entries in order, `{name: a.rs, kind: File, - hash: A, mode: FILE_MODE}` and `{name: b.rs, kind: File, hash: B, mode: - EXEC_MODE}`. Its canonical bytes are exactly - `{"entries":[{"hash":"","kind":"file","mode":33188,"name":"a.rs"},{"hash":"","kind":"file","mode":33261,"name":"b.rs"}]}` - followed by one newline; its envelope begins `napl-store tree v1 248\n`; its - object name is - `5261e5143f1c9d28a42242a93dde34caf3949409524beee39d77bc1e35844dc2`. +- **EV_GEN_PROV_CACHE**: the `EV_GEN` event with its `Gen` payload's `provenance` + set to `Some` of a `Provenance` whose `model` = `claude-opus-4-8`, `tokens` = + `TokenUsage { input: 1000, output: 500, cache_read: Some(2000), cache_write: + Some(100) }`, `estimated_cost_usd` = `Some("0.084")`, `wall_ms` = `12345`, and + `price_table_version` = `1`. The cost `0.084` is what `estimate_cost` produces by + folding the `2000` cache-read and `100` cache-write tokens into the input rate + alongside the `1000` input tokens. Its canonical bytes are exactly + `{"engine":"opus","module":"greeting","ordinal":5,"parents":["

"],"payload":{"fields":{"attempts":1,"mode":"full","outcome":"ok","provenance":{"estimatedCostUsd":"0.084","model":"claude-opus-4-8","priceTableVersion":1,"tokens":{"cacheRead":2000,"cacheWrite":100,"input":1000,"output":500},"wallMs":12345},"taskBundle":"

"},"kind":"gen"},"stateAfter":"

","stateBefore":"

","target":"rust","timestamp":"2026-07-25T00:00:00Z"}` + followed by one newline, a payload of six hundred seventy-five bytes; its object + name is `e1588ae3e90973e666629e4ec3873ed095a41df9806b9e229b16471c9acad3be`. ::: test -name: a flat two-file tree canonicalizes to its pinned bytes -given: { fixture: TR_FLAT } -expect: { canonical_bytes: "{\"entries\":[{\"hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"kind\":\"file\",\"mode\":33188,\"name\":\"a.rs\"},{\"hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"kind\":\"file\",\"mode\":33261,\"name\":\"b.rs\"}]}\n" } +name: a gen event whose provenance carries cache token counts canonicalizes to its pinned bytes +given: { fixture: EV_GEN_PROV_CACHE } +expect: { canonical_bytes: "{\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"mode\":\"full\",\"outcome\":\"ok\",\"provenance\":{\"estimatedCostUsd\":\"0.084\",\"model\":\"claude-opus-4-8\",\"priceTableVersion\":1,\"tokens\":{\"cacheRead\":2000,\"cacheWrite\":100,\"input\":1000,\"output\":500},\"wallMs\":12345},\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } ::: ::: test -name: a flat two-file tree names itself by its pinned digest -given: { fixture: TR_FLAT } -expect: { object_name: "5261e5143f1c9d28a42242a93dde34caf3949409524beee39d77bc1e35844dc2" } +name: a gen event whose provenance carries cache token counts round-trips back to an equal value +given: { fixture: EV_GEN_PROV_CACHE } +expect: { round_trip_equal: true } ::: +- **EV_GEN_PROV_UNKNOWN**: the `EV_GEN` event with its `Gen` payload's `provenance` + set to `Some` of a `Provenance` whose `model` = `gpt-4o`, `tokens` = + `TokenUsage { input: 1000, output: 500, cache_read: None, cache_write: None }`, + `estimated_cost_usd` = `None`, `wall_ms` = `12345`, and `price_table_version` = + `1`. Because `gpt-4o` is not in the price table, `estimate_cost` returns `None` + and the record carries no `estimatedCostUsd`, yet still carries its tokens and + its `priceTableVersion`. Its canonical bytes are exactly + `{"engine":"opus","module":"greeting","ordinal":5,"parents":["

"],"payload":{"fields":{"attempts":1,"mode":"full","outcome":"ok","provenance":{"model":"gpt-4o","priceTableVersion":1,"tokens":{"input":1000,"output":500},"wallMs":12345},"taskBundle":"

"},"kind":"gen"},"stateAfter":"

","stateBefore":"

","target":"rust","timestamp":"2026-07-25T00:00:00Z"}` + followed by one newline, a payload of six hundred five bytes; its object name is + `6557d08b593fff22a1cbb29f5bdd7da893e42d87e42c3290043976668a00bbdf`. + ::: test -name: a flat two-file tree frames its pinned envelope bytes -given: { fixture: TR_FLAT } -expect: { envelope_bytes: "napl-store tree v1 248\n{\"entries\":[{\"hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"kind\":\"file\",\"mode\":33188,\"name\":\"a.rs\"},{\"hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"kind\":\"file\",\"mode\":33261,\"name\":\"b.rs\"}]}\n" } +name: a gen event whose provenance names an unknown model canonicalizes to its pinned bytes without a cost +given: { fixture: EV_GEN_PROV_UNKNOWN } +expect: { canonical_bytes: "{\"engine\":\"opus\",\"module\":\"greeting\",\"ordinal\":5,\"parents\":[\"1111111111111111111111111111111111111111111111111111111111111111\"],\"payload\":{\"fields\":{\"attempts\":1,\"mode\":\"full\",\"outcome\":\"ok\",\"provenance\":{\"model\":\"gpt-4o\",\"priceTableVersion\":1,\"tokens\":{\"input\":1000,\"output\":500},\"wallMs\":12345},\"taskBundle\":\"4444444444444444444444444444444444444444444444444444444444444444\"},\"kind\":\"gen\"},\"stateAfter\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"stateBefore\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n" } ::: ::: test -name: a flat two-file tree round-trips back to an equal value -given: { fixture: TR_FLAT } +name: a gen event whose provenance names an unknown model names itself by its pinned digest +given: { fixture: EV_GEN_PROV_UNKNOWN } +expect: { object_name: "6557d08b593fff22a1cbb29f5bdd7da893e42d87e42c3290043976668a00bbdf" } +::: + +::: test +name: a gen event whose provenance names an unknown model omits the estimatedCostUsd key from its canonical bytes +given: { fixture: EV_GEN_PROV_UNKNOWN } +expect: { has_estimated_cost_key: false } +::: + +::: test +name: a gen event whose provenance names an unknown model round-trips back to an equal value +given: { fixture: EV_GEN_PROV_UNKNOWN } expect: { round_trip_equal: true } ::: -- **TR_NESTED**: a `Tree` with a `File` entry `{name: lib.rs, kind: File, hash: A, - mode: FILE_MODE}` and a `Dir` entry `{name: sub, kind: Dir, hash: C, mode: - DIR_MODE}`, in that order. Its object name is - `fa9b36a98af04fdaa9450e0bc37bf207faa00b84a4583654f00dd7e82836630a`. +- **EV_GEN_USAGE**: the `EV_GEN` event with its `Gen` payload's `usage` set to a + two-entry `Vec` proving the per-attempt log round-trips and carries + burned attempts. The first entry is a burned attempt: `attempt` = 1, `outcome` = + `tests-failed`, `requested_model` = `sonnet`, `resolved_model` = + `claude-sonnet-4-5`, `source` = `frontmatter`, `engine` = `claude`, `tokens` = + `TokenUsage { input: 1000, output: 500, cache_read: None, cache_write: None }`, + `cost_usd` = `Some("0.0525")`, `duration_ms` = `12345`, `num_turns` = 2. The + second entry is the winning attempt: `attempt` = 2, `outcome` = `ok`, + `requested_model` = `sonnet`, `resolved_model` = `claude-sonnet-4-5`, `source` = + `frontmatter`, `engine` = `claude`, `tokens` = `TokenUsage { input: 2000, output: + 800, cache_read: Some(4000), cache_write: Some(200) }`, `cost_usd` = + `Some("0.12")`, `duration_ms` = `20000`, `num_turns` = 3. Its `provenance` stays + `None`. This fixture is pinned by structure only: because its object name depends + on the content hash, no object-name or envelope-length pin is given here, and its + tests assert round-trip equality, that it verifies as an event, that the `usage` + key is present in its canonical bytes while absent from `EV_GEN`'s, and that a + usage entry or its nested `tokens` object carrying a stray key is rejected as + `Malformed`. Within each usage entry the canonical encoder sorts the keys as + `attempt`, `costUsd`, `durationMs`, `engine`, `numTurns`, `outcome`, + `requestedModel`, `resolvedModel`, `source`, `tokens`, and the `usage` key sorts + last among the gen `fields` keys, after `taskBundle`. ::: test -name: a nested tree canonicalizes to its pinned bytes -given: { fixture: TR_NESTED } -expect: { canonical_bytes: "{\"entries\":[{\"hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"kind\":\"file\",\"mode\":33188,\"name\":\"lib.rs\"},{\"hash\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"kind\":\"dir\",\"mode\":16384,\"name\":\"sub\"}]}\n" } +name: a gen event carrying a per-attempt usage log round-trips back to an equal value +given: { fixture: EV_GEN_USAGE } +expect: { round_trip_equal: true } ::: ::: test -name: a nested tree names itself by its pinned digest -given: { fixture: TR_NESTED } -expect: { object_name: "fa9b36a98af04fdaa9450e0bc37bf207faa00b84a4583654f00dd7e82836630a" } +name: a gen event carrying a per-attempt usage log verifies as an event through the read verifier +given: { fixture: EV_GEN_USAGE } +expect: { verify_ok: true, kind: event } ::: +- **EV_DERIV_PROV**: an `Event` with `parents` = `[H1]`, `module` = `greeting`, + `target` = `Some("rust")`, `ordinal` = 6, `timestamp` = `2026-07-25T00:00:00Z`, + `engine` = `opus`, `state_before` = `Some(State(H2))`, `state_after` = + `Some(State(H3))`, and payload `Derivation` with `derivation` = `mapl`, `outcome` + = `Ok`, `attempts` = 1, `error` = the empty string, `diagnostic` = `None`, and + `provenance` = `Some` of a `Provenance` whose `model` = `claude-sonnet-4-5`, + `tokens` = `TokenUsage { input: 2000, output: 800, cache_read: None, cache_write: + None }`, `estimated_cost_usd` = `Some("0.018")`, `wall_ms` = `4096`, and + `price_table_version` = `1`. The cost `0.018` is what `estimate_cost` produces + over input `2000` and output `800` at the sonnet rate. Its object name is + `d084c97dd4de38f5974f5f8dabfb63e060378b872d6e3483780209b115f95bce`. + ::: test -name: a nested tree round-trips through the read verifier -given: { fixture: TR_NESTED } -expect: { verify_ok: true, kind: tree } +name: a derivation event carrying provenance names itself by its pinned digest +given: { fixture: EV_DERIV_PROV } +expect: { object_name: "d084c97dd4de38f5974f5f8dabfb63e060378b872d6e3483780209b115f95bce" } ::: ::: test -name: a nested tree round-trips back to an equal value -given: { fixture: TR_NESTED } +name: a derivation event carrying provenance round-trips back to an equal value +given: { fixture: EV_DERIV_PROV } expect: { round_trip_equal: true } ::: -Build results the suite pins, computed by naming the built trees through -`tree_object_name` (verified against the reference values): the nested build over -files `lib.rs` (hash `A`, `FILE_MODE`) and `sub/inner.rs` (hash `B`, `FILE_MODE`) -produces a subtree `{entries: [{name: inner.rs, kind: File, hash: B, mode: -FILE_MODE}]}` named -`41a134a67bf30067699690165d522d171e7585177d5df0ae0e03850f90253065` and a root -`{entries: [{name: lib.rs, kind: File, hash: A, mode: FILE_MODE}, {name: sub, kind: -Dir, hash: , mode: DIR_MODE}]}` named -`cb965100c5cc41bf5b918ee41261379fd920b409294cff6fc2b7e0d9281cd4d2`; the flat build -over files `a.rs` (hash `A`, `FILE_MODE`) and `b.rs` (hash `B`, `EXEC_MODE`) -produces exactly TR_FLAT and so is named -`5261e5143f1c9d28a42242a93dde34caf3949409524beee39d77bc1e35844dc2`; the empty build -produces TR_EMPTY, named -`a8874cb576274d29e603d4e720fe336a01b8f7b1a75af221c6bbba9b4ed47898`. +- **SNAP**: a `Snapshot` with `parents` = `[H6]`, `seq` = 5, and one root entry + `greeting` whose `RootEntry` has `moduleState` = `H7` and `eventHead` = `H8`. + Its object name is + `cb82801a441f25b1a80aa310fe3d4b15c0c64cafff6b0be799c53d22649a1f64`. -Diff fixtures are built in the test as trees plus an `objects` map that resolves -each tree by its own `tree_object_name`, and the roots passed to `diff_trees` are -those same names. Each fixture below names the trees by content; the test names -each through `tree_object_name`, inserts every tree the walk needs into the map, -and passes the corresponding root names: +::: test +name: a snapshot canonicalizes to its pinned bytes +given: { fixture: SNAP } +expect: { canonical_bytes: "{\"parents\":[\"6666666666666666666666666666666666666666666666666666666666666666\"],\"root\":{\"greeting\":{\"eventHead\":\"8888888888888888888888888888888888888888888888888888888888888888\",\"moduleState\":\"7777777777777777777777777777777777777777777777777777777777777777\"}},\"seq\":5}\n" } +::: -- **FLAT_A**: a single-file tree `{a.rs, File, A, FILE_MODE}`. -- **FLAT_AB**: the two-file tree equal to TR_FLAT (`a.rs` hash `A` `FILE_MODE`; - `b.rs` hash `B` `EXEC_MODE`). -- **FLAT_A_TO_B**: a single-file tree `{a.rs, File, B, FILE_MODE}` (same name as - FLAT_A, different hash). -- **FLAT_A_EXEC**: a single-file tree `{a.rs, File, A, EXEC_MODE}` (same name and - hash as FLAT_A, different mode). -- **FLAT_LIB**: a single-file tree `{lib.rs, File, A, FILE_MODE}`. -- **LIB_PLUS_DIR**: a tree with `{lib.rs, File, A, FILE_MODE}` and a `Dir` entry - `sub` whose subtree is `{inner.rs, File, B, FILE_MODE}` and `{more.rs, File, C, - FILE_MODE}`. -- **TWO_DIRS_OLD**: a tree with two `Dir` entries, `keep` whose subtree is `{x.rs, - File, A, FILE_MODE}`, and `chg` whose subtree is `{y.rs, File, B, FILE_MODE}`. -- **TWO_DIRS_NEW**: the same tree except `chg`'s subtree is `{y.rs, File, C, - FILE_MODE}`; the `keep` directory entry is byte-identical to TWO_DIRS_OLD's and - so carries the same subtree hash. -- **X_FILE**: a single-file tree `{x, File, A, FILE_MODE}`. -- **X_DIR**: a tree with a single `Dir` entry `x` whose subtree is `{a.rs, File, B, - FILE_MODE}`. -- **FLAT_B**: a single-file tree `{b.rs, File, B, FILE_MODE}`. -- **FLAT_A_AND_C**: a two-file tree `{a.rs, File, A, FILE_MODE}` and `{c.rs, File, - C, FILE_MODE}`. -- **MISSING_ROOT**: a root object name (sixty-four lowercase hex, use `D`) that is - deliberately not inserted into the `objects` map, so `diff_trees` fails as - `MissingObject`. -- **DIR_WITH_MISSING_CHILD**: a tree with a single `Dir` entry `sub` whose `hash` - is a sixty-four-hex name (use `D`) that is not inserted into the map, diffed - against LIB_PLUS_DIR so the differing `sub` entry forces resolution of the absent - subtree and fails as `MissingObject`. +::: test +name: a snapshot names itself by its pinned digest +given: { fixture: SNAP } +expect: { object_name: "cb82801a441f25b1a80aa310fe3d4b15c0c64cafff6b0be799c53d22649a1f64" } +::: + +::: test +name: a snapshot round-trips through the read verifier +given: { fixture: SNAP } +expect: { verify_ok: true, kind: snapshot } +::: + +::: test +name: a two-parent merge snapshot round-trips back to an equal value +given: { snapshot: "parents [H6, H7], seq 6, one root entry greeting" } +expect: { round_trip_equal: true } +::: ## Test and code discipline @@ -209681,2379 +210207,2497 @@ two or more cases into one shared test, and a single test that loops over a tabl of inputs never counts as the dedicated test any of those inputs requires: such a combined test may exist only as an extra. Cases that differ only in their input while sharing an expected value are still distinct cases, each owed its own -dedicated test. In particular, each wire-string case gets its own test: the two -`EntryKind` serialize cases (`file`, `dir`) and the two `EntryKind` deserialize -cases are each their own dedicated test. Likewise every case that expects -`Malformed` (the unknown top-level field, the missing entries, the wrong-typed -entries, the unknown entry field, the missing hash, the missing name, the unknown -kind token, the wrong-typed mode, and the mode over the u32 range), every case that -expects `MalformedHash`, `EmptyName`, `InvalidName`, `UnsortedEntries`, -`DuplicateName`, `InvalidMode`, `InvalidPath`, `PathConflict`, or `MissingObject`, -gets its own dedicated test whatever shared loop may also exist as an extra. Each -integer-width case pins its exact literal integer in its own test: `4294967296` for -a mode one past the u32 range rejected as `Malformed`, and `4294967295` for the -largest-u32 mode rejected as `InvalidMode` (the largest u32 parses as an integer -but is not one of the three mode constants). Each mode-constant case pins its exact -decimal literal in its own dedicated test: `FILE_MODE` equals `33188`, `EXEC_MODE` -equals `33261`, and `DIR_MODE` equals `16384`, one test per constant, none -discharging another. - -Inside a case's dedicated test, hand the code exactly the fixture that case gives, -byte for byte, and assert its pinned expectation verbatim: no shorter or more -convenient stand-in, no re-derivation of a value the case pins as a literal, and no -weakening of a pinned equality into an inequality-only or recomputed check. The -byte-pinned object cases assert their exact literals: TR_EMPTY canonicalizes to -`{"entries":[]}` plus one newline, names to -`a8874cb576274d29e603d4e720fe336a01b8f7b1a75af221c6bbba9b4ed47898`, and frames the -envelope `napl-store tree v1 15\n{"entries":[]}\n`; TR_FLAT canonicalizes to its +dedicated test. In particular, each wire-string case gets its own test: the three +`GenMode` strings, the three `GenOutcome` strings, the four `DerivationOutcome` +strings, the five `LegacyClassification` strings, and the eight `EventPayload` +kind tokens are each their own dedicated test, even though several share the token +`ok`, `failed`, or `interrupted`. Likewise every case that expects `Malformed` +(the unknown module-state field, the missing promptBlob, the missing blockIndex, +the wrong-typed targets, the unknown top-level event field, the unknown +payload-fields field, the unknown payload-wrapper sibling key, the unknown kind +token, the wrong-typed ordinal, the ordinal over the u64 range, the attempts over +the u32 range, the seq over the u64 range, the four unknown enum-token cases, the +unknown target-artifacts field, the unknown root-entry field, the unknown +provenance field, and the unknown token-usage field), every case that expects +`MalformedHash`, every case that expects `EmptyKey`, every case that expects +`NonAdvancingOutcome`, and every integer-width boundary case that is accepted (the +ordinal at the largest u64, the attempts at the largest u32, and the seq at the +largest u64), gets its own dedicated test whatever shared loop may also exist as an +extra. Each integer-width boundary case pins its exact literal integer in its own +test: `18446744073709551615` for a largest-u64 field accepted, +`18446744073709551616` for a field one past the u64 range rejected as `Malformed`, +`4294967295` for the largest-u32 attempts accepted, and `4294967296` for attempts +one past the u32 range rejected as `Malformed`. + +Inside a case's dedicated test, hand the code exactly the fixture that case gives, +byte for byte, and assert its pinned expectation verbatim: no shorter or more +convenient stand-in, no re-derivation of a value the case pins as a literal, and +no weakening of a pinned equality into an inequality-only or recomputed check. The +byte-pinned cases assert their exact literals: MS_MIN canonicalizes to +`{"blockIndex":"","promptBlob":"","targets":{}}` plus one newline, names to +`ab63db013784f331f93f17786b8ea92f9a89736c44686ea112223ee03854dc94`, and frames the +envelope beginning `napl-store module-state v1 175\n`; EV_GEN canonicalizes to its pinned bytes, names to -`5261e5143f1c9d28a42242a93dde34caf3949409524beee39d77bc1e35844dc2`, and frames the -complete envelope, the header `napl-store tree v1 248\n` followed by its pinned -canonical bytes, asserted in full as TR_EMPTY is and never a prefix comparison; -TR_NESTED canonicalizes to its -pinned bytes and names to -`fa9b36a98af04fdaa9450e0bc37bf207faa00b84a4583654f00dd7e82836630a`. Where a case -pins canonical bytes, obtain them as `store_hash::canonical_json` applied to the +`dc807c63cc0b542fde0132415814a66183931ce2b7334662dc8fb3810948ae9f`, and frames the +envelope beginning `napl-store event v1 499\n`; EV_LEGACY canonicalizes to its +pinned bytes with both state fields the literal string `unknown` and names to +`fba5e4f9532e4dd01d1f9571b46afafced354533db1730806acb703643fd05f4`; SNAP +canonicalizes to its pinned bytes and names to +`cb82801a441f25b1a80aa310fe3d4b15c0c64cafff6b0be799c53d22649a1f64`; and MS_RICH +canonicalizes to its pinned bytes +`{"blockIndex":"","ir":"","mapl":"","promptBlob":"","targets":{"rust":{"attribution":"","generatedTree":""}}}` +plus one newline and names to +`9dca13259e9b91d24853a8b187dd9b309b2e2bd2c614c2edec089da5ad2fa9af`. The MS_RICH +canonical-bytes case owns its own dedicated test that asserts that pinned +canonical-bytes literal, separate from the MS_RICH name test and separate from any +semantic round-trip test: a round-trip-equal or validate check never stands in for +the literal byte assertion the canonical-bytes case pins. Where a case pins +canonical bytes, obtain them as `store_hash::canonical_json` applied to the serialized value and compare to the literal; where a case pins an object name, -obtain it from `tree_object_name` and compare to the literal; where a case pins -envelope bytes, frame them through `store_hash::envelope_bytes` over the canonical -payload and compare to the literal. +obtain it from the matching naming function and compare to the literal; where a +case pins envelope bytes, frame them through `store_hash::envelope_bytes` over the +canonical payload and compare to the literal. -A dedicated test asserts only its own case and nothing more: the TR_EMPTY name test +A dedicated test asserts only its own case and nothing more: the MS_MIN name test asserts only that pinned digest, leaving the canonical-bytes shape to the case that -owns it, and the TR_FLAT name test asserts only that pinned digest. Each verifier -round-trip case builds the exact fixture its own case names, never a heavier or -different fixture substituted for it: the empty-tree verifier case builds TR_EMPTY -and the nested-tree verifier case builds TR_NESTED; each names its object through -`tree_object_name`, frames its envelope through `store_hash::envelope_bytes` over -its canonical payload, and asserts `store_hash::verify_object` returns success -carrying `store_hash::ObjectKind::Tree`. Each round-trip-equal case builds its own -named fixture, serializes it, recovers it through `validate_tree`, and asserts the -recovered value equals the original verbatim, never weakening that equality into a -mere `is_ok` acceptance and never substituting a lighter or neighboring fixture: -the TR_EMPTY round-trip builds TR_EMPTY, the TR_FLAT round-trip builds TR_FLAT, and -the TR_NESTED round-trip builds TR_NESTED. +owns it, and the absent-mapl-and-ir case asserts only that the canonical bytes +carry no `mapl` and no `ir` key. Each verifier round-trip case builds the exact +fixture its own case names, never a heavier or different fixture substituted for +it: the module-state verifier case builds MS_MIN and never MS_RICH, the event +verifier case builds EV_GEN, and the snapshot verifier case builds SNAP; each names +its object through the matching naming function, frames its envelope, and asserts +`store_hash::verify_object` returns success carrying the matching kind. + +The absent-target-omission case is distinct from the absent-mapl-and-ir case and +gets its own dedicated test: it builds EV_LEGACY, whose `target` is `None`, and +asserts that its canonical bytes carry no `target` key, asserting nothing else. + +### Provenance case discipline + +The provenance cases are their own distinct cases and are each realized by their +own dedicated test with the fixture's literal values copied verbatim; no +provenance case is discharged by any other. In particular: + +- The EV_GEN_PROV canonical-bytes case builds EV_GEN_PROV, obtains its canonical + bytes as `store_hash::canonical_json` of the serialized event, and asserts the + exact six-hundred-forty-two-byte literal the case pins, with the `provenance` + object's keys in the canonical sorted order `estimatedCostUsd`, `model`, + `priceTableVersion`, `tokens`, `wallMs` and the `provenance` key itself sorted + between `outcome` and `taskBundle` inside the gen `fields`. The EV_GEN_PROV name + case asserts only the object name + `ba63bb11bc2e25378f533be8f8b5a019e12418f97c5050c068e28af75b391032`; the + EV_GEN_PROV envelope case frames the envelope beginning `napl-store event v1 + 642\n` through `store_hash::envelope_bytes`; the EV_GEN_PROV verifier case names, + frames, and asserts `store_hash::verify_object` returns success reporting kind + event; and the EV_GEN_PROV round-trip case serializes EV_GEN_PROV, recovers it + through `validate_event`, and asserts the recovered event equals the original + verbatim, its `Provenance` and `TokenUsage` preserved field for field, never + weakened into an `is_ok` acceptance. +- The gen-without-provenance omission case builds EV_GEN, whose `provenance` is + `None`, and asserts only that its canonical bytes carry no `provenance` key, + asserting nothing else; it is distinct from the EV_GEN canonical-bytes, name, + envelope, and verifier cases and never stands in for them, and they never stand + in for it. +- The EV_DERIV_PROV name case asserts only the object name + `d084c97dd4de38f5974f5f8dabfb63e060378b872d6e3483780209b115f95bce`; the + EV_DERIV_PROV round-trip case serializes EV_DERIV_PROV, recovers it through + `validate_event`, and asserts equality verbatim, preserving its derivation + provenance including the `claude-sonnet-4-5` model and the `0.018` cost. +- The EV_GEN_PROV_CACHE canonical-bytes case builds EV_GEN_PROV_CACHE and asserts + its exact six-hundred-seventy-five-byte literal, in which the `tokens` object + carries `cacheRead` and `cacheWrite` in canonical sorted order ahead of `input` + and `output` and the cost is `0.084`; the EV_GEN_PROV_CACHE round-trip case + recovers it through `validate_event` and asserts equality verbatim, its + `cache_read` and `cache_write` `Some` values preserved. +- The EV_GEN_PROV_UNKNOWN canonical-bytes case builds EV_GEN_PROV_UNKNOWN and + asserts its exact six-hundred-five-byte literal, which carries no + `estimatedCostUsd` key; the EV_GEN_PROV_UNKNOWN name case asserts only the object + name `6557d08b593fff22a1cbb29f5bdd7da893e42d87e42c3290043976668a00bbdf`; the + omission case asserts only that its canonical bytes carry no `estimatedCostUsd` + key, asserting nothing else and never standing in for the canonical-bytes or name + case; and the round-trip case recovers it through `validate_event` and asserts + equality verbatim, its `estimated_cost_usd` `None` preserved. +- The five `estimate_cost` cases each call `estimate_cost` directly on the exact + model and token counts the case names and assert the exact returned value: the + known-model case over `claude-opus-4-8` with input `1000` and output `500` + asserts `Some("0.0525")`; the zero-token case over `claude-opus-4-8` with input + `0` and output `0` asserts `Some("0")`, exercising the empty-fractional branch + that renders a bare integer; the whole-dollar case over `claude-sonnet-4-5` with + input `1000000` and output `1000000` asserts `Some("18")`, a second distinct + empty-fractional case whose whole part is nonzero; the cache-fold case over + `claude-opus-4-8` with input `1000`, output `500`, cache-read `2000`, and + cache-write `100` asserts `Some("0.084")`, proving cache tokens join the input + rate; and the unknown-model case over `gpt-4o` with input `1000` and output `500` + asserts `None`, proving an absent model yields no cost. Each of these five is its + own dedicated test with its own literal tokens; the two empty-fractional cases + (`0` and `18`) are distinct and neither discharges the other, and the cache-fold + cost case is distinct from the EV_GEN_PROV_CACHE event byte case even though both + reach `0.084`. +- The unknown-provenance-field rejection case takes the EV_GEN_PROV object's JSON + and inserts the key `bogus` with integer value `1`, exactly `"bogus": 1`, inside + its `provenance` object, and asserts the event is rejected as `Malformed`, + exercising `Provenance`'s `#[serde(deny_unknown_fields)]`. The + unknown-token-usage-field rejection case takes the EV_GEN_PROV object's JSON and + inserts `"bogus": 1` inside its `provenance`'s `tokens` object, and asserts + `Malformed`, exercising `TokenUsage`'s `#[serde(deny_unknown_fields)]`. These two + are distinct cases inserting the stray key at two different depths and neither + discharges the other, and neither is a renamed key such as `extra` nor a + restyled value such as the string `"nope"`. + +Each round-trip-equal case builds its own named fixture, serializes it, recovers it +through the matching validator, and asserts the recovered value equals the original +verbatim, never weakening that equality into a mere `is_ok` acceptance and never +substituting a lighter or neighboring fixture. These cases are the MS_RICH +round-trip, the EV_LEGACY round-trip that preserves both `unknown` state sentinels, +the two-parent merge-snapshot round-trip, the six base event round-trips, and the +provenance round-trips (EV_GEN_PROV, EV_DERIV_PROV, EV_GEN_PROV_CACHE, and +EV_GEN_PROV_UNKNOWN), one dedicated test each with the case's literal values copied +verbatim: + +- the reword round-trip builds a reword event with `verdict` = `approved`, `batchId` + = `H1`, and `bundle` = `H4`; +- the reconcile round-trip builds a reconcile event with `driftSummary` = `one file` + and `amendmentBundle` = `H4`; +- the move round-trip builds a move event with `context` = `relocated greeting.rs`; +- the audit round-trip builds an audit event with `noop` = `true`; +- the revert round-trip builds a revert event with `reason` = `superseded by newer + gen`; +- the derivation round-trip builds a derivation event with `derivation` = `mapl`, + `outcome` = `ok`, `attempts` = `1`, `error` the empty string, `diagnostic` + absent, and `provenance` absent. + +None of these round-trip cases is discharged by the like-named payload-kind tag +case: a tag case that only inspects the serialized `kind` token, and that carries +its own unrelated field values such as `accepted`, `batch-1`, `one file drifted`, +`src/greeting.rs`, or `bad gen`, is a separate case and never stands in for the +round-trip case that owns these literal values. The base ok-derivation round-trip, +whose `provenance` is absent, is a separate case from the EV_DERIV_PROV round-trip, +whose `provenance` is present, and neither discharges the other. The two-parent +merge-snapshot round-trip builds a snapshot with `parents` = `[H6, H7]`, `seq` = +`6`, and one root entry `greeting`, serializes it, recovers it through +`validate_snapshot`, and asserts the recovered snapshot equals the original, +preserving both parents and the `seq` value `6`; it never keeps SNAP's single +parent or its `seq` value `5`, and never weakens the equality into an `is_ok` +check. + +The absent-versus-unknown-versus-known case constructs all three state situations +in its own test and asserts every expectation the case declares: an absent state +that is `None`, an unknown state that is `Some(StateRef::Unknown)` (the sentinel), +and a known state that is `Some(StateRef::State(H2))` carrying that hash, then +asserts the absent one is `None`, the unknown one is the sentinel, the known one +carries `H2`, and that all three are pairwise distinct. It never collapses to a +single absent-versus-unknown comparison and never drops the known state. + +The non-advancing cases each construct exactly the described event and assert +accept or the pinned `NonAdvancingOutcome` error: the failed-gen-equal-states and +failed-gen-both-absent and retried-derivation-advances and ok-gen-advances cases +accept, while the failed-gen-mismatch, interrupted-gen-mismatch, +failed-derivation-mismatch, and interrupted-derivation-mismatch cases each reject +with `NonAdvancingOutcome`, each in its own test. The rejection cases construct exactly the malformed JSON the case describes, built by taking the named fixture's JSON and applying only the one described mutation using that mutation's exact literal tokens, and assert the pinned error variant. The mutation literal is transcribed verbatim from the case, never paraphrased into a more convenient stand-in, because the exact bytes are the point of the test. In -particular: the extra-key cases (the unknown top-level field on TR_FLAT and the -unknown entry field on TR_FLAT's first entry) each insert the key named `bogus` -with the integer value `1`, exactly `"bogus": 1`, never a renamed key and never a -restyled value, and neither discharges the other; the unknown-kind case sets the -first entry's `kind` to the exact token `symlink`; the wrong-typed-mode case sets -`mode` to the JSON string `"33188"`; the wrong-typed-entries case sets `entries` to -the JSON number `1`; the malformed-hash case sets the first entry's `hash` to -exactly `"xyz"`; the empty-name case sets the first entry's `name` to the empty -string; the slash-name, dot-name, and dotdot-name cases set the first entry's -`name` to exactly `a/b`, `.`, and `..` respectively, each its own dedicated -`InvalidName` test and none discharging another; the unsorted case swaps TR_FLAT's -two entries so `b.rs` precedes `a.rs`, reaching `UnsortedEntries`; the duplicate -case sets TR_FLAT's second entry `name` to `a.rs` so the two names are equal, -reaching `DuplicateName`; the file-with-dir-mode case sets TR_FLAT's first entry -`mode` to `16384` with its kind left `file`, the dir-with-file-mode case sets -TR_NESTED's `sub` entry `mode` to `33188` with its kind left `dir`, the -unrecognized-mode case sets a file entry `mode` to `33152`, and the largest-u32 -case sets a file entry `mode` to `4294967295`, each its own `InvalidMode` test. +particular: -Each build case builds the exact `TreeFile` list its fixture names, in the order -the fixture lists them, and asserts every value the case pins. The unsorted-flat -build passes its two files in the order `b.rs` then `a.rs` and asserts the result's -`root` equals -`5261e5143f1c9d28a42242a93dde34caf3949409524beee39d77bc1e35844dc2`, that its -`objects` map contains exactly that one key, and that the root tree's entries are -`a.rs` then `b.rs` in that sorted order with the pinned kinds, hashes, and modes; -it never asserts the input order survives. The nested build passes `lib.rs` and -`sub/inner.rs` and asserts the root name -`cb965100c5cc41bf5b918ee41261379fd920b409294cff6fc2b7e0d9281cd4d2`, the subtree -name `41a134a67bf30067699690165d522d171e7585177d5df0ae0e03850f90253065`, that -`objects` contains exactly those two keys, and the complete pinned `root_entries` -list in order, asserting every field of every root entry and not merely one of -them: the first entry is `lib.rs` as a `File` with hash `A` and `FILE_MODE`, and -the second entry is `sub` as a `Dir` carrying the subtree name as its `hash` and -`DIR_MODE` as its `mode`; and it asserts the subtree's single entry is -`inner.rs` as a `File` with hash `B` and `FILE_MODE`. The directory-mode build case -is distinct: it passes the same two-file build input the nested build names, -`lib.rs` (hash `A`, `FILE_MODE`) and `sub/inner.rs` (hash `B`, `FILE_MODE`), never -`sub/inner.rs` alone nor any lighter single-file input, and asserts only that the -synthesized `sub` entry is a `Dir` with `mode` `16384`. The empty build asserts the `root` is -`a8874cb576274d29e603d4e720fe336a01b8f7b1a75af221c6bbba9b4ed47898`, that `objects` -holds exactly that one empty tree, and that the root tree has no entries. The -build-rejection cases each pass exactly the pinned input list and assert the pinned -error: the empty-component path `sub//inner.rs` and the dot-component path -`sub/./inner.rs` each reach `InvalidPath` in their own test; the file-versus-prefix -inputs `a` and `a/b` reach `PathConflict`; the duplicate inputs `a.rs` and `a.rs` -reach `PathConflict`; the input file with `mode` `16384` reaches `InvalidMode`; and -the input file with `mode` `33152` reaches `InvalidMode`. +- the extra-key cases (the unknown module-state field on MS_MIN, the unknown + target-artifacts field on MS_RICH, the unknown top-level event field on EV_GEN, the + unknown payload-fields field on EV_GEN, the unknown payload-wrapper sibling key on + EV_GEN, the unknown provenance field on EV_GEN_PROV, the unknown token-usage field + on EV_GEN_PROV, the unknown snapshot top-level field on SNAP, and the unknown + root-entry field on SNAP) each insert the key named `bogus` with the integer value + `1`, exactly `"bogus": 1`, never a renamed key such as `extra` and never a restyled + value such as the string `"nope"`; the unknown snapshot top-level field inserts + `"bogus": 1` at the top level of SNAP and is a separate case from the unknown + root-entry field, which instead inserts `"bogus": 1` inside SNAP's one root entry, + and neither discharges the other; +- the three-parent snapshot rejection sets `parents` to exactly the three distinct + hashes `[H6, H7, H8]`, never a repeated hash such as `[H6, H6, H6]`; +- the empty-target-key rejection starts from MS_RICH and renames its one target key + `rust` to the empty string, reaching `EmptyKey`, never a fresh MS_MIN-based state + into which a newly constructed empty-keyed target record is inserted; +- the empty-root-key rejection starts from SNAP and renames its one root key + `greeting` to the empty string, reaching `EmptyKey`; +- the wrong-typed-targets case sets `targets` to the empty JSON array `[]`, never to + a string such as `"rust"`; +- the wrong-typed-ordinal case sets `ordinal` to the JSON string `"5"`, never to a + different string such as `"five"`; +- the unknown-kind case sets `payload.kind` to the exact token `frobnicate`, never + to a different word such as `nope`; +- the four unknown-enum-token cases (gen mode, gen outcome, derivation outcome, and + legacy classification) each set the field to the exact token `bogus`, never to a + different word such as `nope`. -Each diff case builds the exact old and new fixtures its case names, wires the -`objects` map by naming every tree the walk touches through `tree_object_name`, -passes the matching root names (or `None` where the case names `none`), and asserts -the returned `Vec` equals the pinned list exactly, element for element -in the pinned order, never weakening the equality into a length-only or membership -check and never reordering. The identical-tree case passes the same root on both -sides and asserts an empty change list. The added-file, removed-file, -modified-by-hash, and modified-by-mode cases each assert their single pinned -change, and the modified-by-mode case asserts the change is a `Modified` carrying -the same hash on both sides with `old_mode` `33188` and `new_mode` `33261`, never -an `Added` plus `Removed`. The subtree-prune case builds TWO_DIRS_OLD and -TWO_DIRS_NEW, asserts the only change is `Modified` at path `chg/y.rs` from hash `B` -to hash `C`, and asserts explicitly that no change mentions the pruned `keep` -subtree, so the Merkle prune is proven by the absence of any `keep/x.rs` change and -not merely by a happy total. The added-directory and removed-directory cases assert -one change per leaf, `sub/inner.rs` before `sub/more.rs` in ascending path order. -The file-to-directory case asserts a `Removed` at path `x` and an `Added` at path -`x/a.rs`, in that ascending path order. The absent-old-root case diffs `None` as -`old_root` against FLAT_AB as `new_root` and asserts exactly FLAT_AB's two flat -leaves are `Added`, `a.rs` hash `A` mode `33188` then `b.rs` hash `B` mode `33261` -in ascending path order, never a heavier or nested fixture such as LIB_PLUS_DIR -substituted for FLAT_AB; the absent-new-root case diffs FLAT_AB as `old_root` -against `None` as `new_root` and asserts exactly those same two leaves are -`Removed`, `a.rs` hash `A` mode `33188` then `b.rs` hash `B` mode `33261`, again -never substituting a different fixture for FLAT_AB. The missing-root case -diffs `MISSING_ROOT` as `old_root` against FLAT_AB as `new_root`, building FLAT_AB -as its own new fixture and never substituting FLAT_A or any lighter fixture for it, -and asserts `MissingObject`; the -missing-child case diffs `DIR_WITH_MISSING_CHILD` against LIB_PLUS_DIR so the -differing directory entry forces resolution of an absent subtree and asserts -`MissingObject`. The ascending-order case builds FLAT_B and FLAT_A_AND_C and asserts -the changes are exactly `Added a.rs`, `Removed b.rs`, `Added c.rs` in that path -order, proving the result is sorted by path across mixed change kinds. +The payload-wrapper sibling case adds the key `bogus` with value `1` to the payload +object itself, beside `kind` and `fields`, and asserts the event is rejected as +`Malformed`; this exercises the wrapper's own strictness and is distinct from the +payload-fields case, which puts `"bogus": 1` inside the `fields` object instead, and +from the provenance and token-usage cases, which put it one and two levels deeper +inside the gen fields. Any further fixture beyond this suite lives in its own +separately named supplemental test rather than riding along inside a suite case's +test; additional tests are welcome once every case has its dedicated test, but they +never replace or subsume a suite case. -Closing mandate on one-to-one case fidelity. Every case in the frontmatter test -suite above, without exception, is realized by exactly one dedicated test that -hands the code that case's own fixture with its literal values copied verbatim from -the case, and asserts that case's own pinned expectation, neither narrower nor -recomputed. A test that inspects a different or narrower property than its case -declares, that swaps in a lighter or neighboring fixture, that weakens a pinned -equality into an `is_ok` or inequality-only check, or that reuses a sibling case's -values, does not discharge the case and leaves it untested. Two cases that share a -shape but differ in a fixture value or in the property asserted are two separate -cases, each owed its own verbatim test: the slash-name, dot-name, and dotdot-name -`InvalidName` cases never discharge one another; the file-with-dir-mode, -dir-with-file-mode, unrecognized-mode, and largest-u32 `InvalidMode` cases never -discharge one another; the unsorted-entries case never discharges the -duplicate-name case; the added-directory case never discharges the added-file case; -and the mode-only `Modified` case never collapses into an add-and-remove pair. -Supplemental tests beyond the suite are named for what they add and are never -counted as, nor substituted for, any suite case's dedicated test. The number of -dedicated tests is therefore at least the number of frontmatter cases, and each -case above is discharged by its own verbatim test. +The amendment cases for the additive `diagnostics` and `attemptId` fields are each +owed their own dedicated verbatim test and none discharges another. The two +skip-when-empty and skip-when-absent omission cases build EV_GEN, whose gen payload +carries an empty `diagnostics` and whose event carries no `attemptId`, and assert +respectively that the canonical bytes carry no `diagnostics` key and no `attemptId` +key; each asserts only its own absence and neither restates EV_GEN's full canonical +bytes, which the pre-amendment EV_GEN canonical-bytes case already owns unchanged. +The derivation empty-diagnostics omission case builds the ok derivation event with +`diagnostic` absent and `diagnostics` empty and asserts its canonical bytes carry no +`diagnostics` key, distinct from the derivation round-trip case that shares its +shape. The EV_GEN_INT trio (its canonical-bytes case, its object-name case pinning +`17f796f1f623dc65d2f596336befb1e062c619ca1d6f87aa3e76245c5e37420e`, and its +envelope-bytes case pinning the `napl-store event v1 614\n` frame) are three separate +dedicated tests, each asserting only its own pinned literal, plus a fourth EV_GEN_INT +round-trip case asserting the recovered event equals the original with both new +fields preserved; the byte cases prove the amended key order, `attemptId` sorting to +the front of the event object and `diagnostics` sorting between `attempts` and `mode` +inside the gen `fields`. The DERIV_BOTH canonical-bytes case, the DERIV_BOTH +object-name case pinning +`2ac016d8ff0d0cd4f01ab9b383989ca448665cfc9fe14299d0d08e61a93cf56b`, and the DERIV_BOTH +round-trip case are three separate dedicated tests; the round-trip case asserts the +recovered derivation carries `diagnostic` equal to `H5` and `diagnostics` equal to +`vec![H6, H7]` independently, never collapsing the singular field into the plural one +nor the reverse. The EV_GEN_OPPRES canonical-bytes case and the EV_GEN_OPPRES accepts +case are two separate dedicated tests: the bytes case pins the literal carrying the +verbatim `vendor-sync` token, and the accepts case asserts `validate_event` returns +`Ok` over an event whose sole non-standard feature is that `diagnostics` entry, never +weakening the acceptance into an `is_ok` over a different event and never letting the +hash check reject the non-hash token. Every byte-pinned amendment case obtains its +bytes exactly as the pre-existing byte cases do, `store_hash::canonical_json` for +canonical bytes, the matching naming function for the object name, and +`store_hash::envelope_bytes` for the envelope, comparing to the full pinned literal +and never a prefix or a re-derivation. -Beyond the suite, one dedicated supplemental test per `StoreTreeError` variant -asserts that variant's exact `Display` line verbatim through `to_string()`: a -`Malformed("bad")` renders `malformed tree object: bad`, `MalformedHash` renders -`tree entry hash is not sixty-four lowercase hex`, `EmptyName` renders `tree entry -name is empty`, `InvalidName` renders `tree entry name contains a slash or is a dot -component`, `UnsortedEntries` renders `tree entries are not in ascending name -order`, `DuplicateName` renders `tree has two entries with the same name`, -`InvalidMode` renders `tree entry mode does not match its kind`, `InvalidPath` -renders `build input path is empty or has a dot component`, `PathConflict` renders -`build input paths conflict`, and `MissingObject` renders `referenced tree object is -missing from the object map`. Each of these ten is its own dedicated test asserting -only its own line, and none replaces or subsumes a suite case. +Closing mandate on one-to-one case fidelity. Every case in the frontmatter test suite +above, without exception, is realized by exactly one dedicated test that hands the code +that case's own fixture with its literal values copied verbatim from the case, and +asserts that case's own pinned expectation, neither narrower nor recomputed. A test +that inspects a different or narrower property than its case declares, that swaps in a +lighter or neighboring fixture, that weakens a pinned equality into an `is_ok` or +inequality-only check, or that reuses a sibling case's values, does not discharge the +case and leaves it untested. Two cases that share a shape but differ in a fixture value +or in the property asserted are two separate cases, each owed its own verbatim test: a +payload-kind tag case never discharges the like-named round-trip case, a hash-rejection +case never discharges a round-trip case, the snapshot top-level extra-field case never +discharges the root-entry extra-field case, the gen-without-provenance omission case +never discharges the EV_GEN canonical-bytes case, the EV_GEN_PROV canonical-bytes case +never discharges its own name or envelope or round-trip case, the two empty-fractional +cost cases (`0` and `18`) never discharge each other, the two-parent merge round-trip +never reuses SNAP's single parent or its `seq` value `5`, the three-parent rejection uses +`[H6, H7, H8]` and never `[H6, H6, H6]`, and the empty-target-key rejection renames +MS_RICH's `rust` key rather than constructing a new record on MS_MIN. Supplemental +tests beyond the suite are named for what they add and are never counted as, nor +substituted for, any suite case's dedicated test. The number of dedicated tests is +therefore at least the number of frontmatter cases, and each case above is discharged +by its own verbatim test. Write no explanatory line or block comments in the generated code, and no doc comment on any private item. The crate keeps its module-level `//!` summary, and a `///` doc comment restating this prompt's contract belongs only on a public item, the public types, their public fields, the public enum variants, and the public -functions, where it earns its place. A public struct field carries a `///` doc -comment only where its name alone does not convey the contract, and simply named -fields may carry none. The private helper functions and private constants of this -module carry no doc comment. Beyond the module summary and these public-item doc -comments the code carries no commentary of any kind. Use no em dashes anywhere in -the code this module authors, including inside string literals and doc comments. -This rule governs authored code only; it does not reach the toolchain-emitted -generation banner on the first line of the output file, which this module does not -write. +functions, where it earns its place. The private helper functions and private +constants of this module, the price table included, carry no doc comment. Beyond +the module summary and these public-item doc comments the code carries no +commentary of any kind. -## store_reduce -# Store reduce: the typed query layer over the content-addressed store +## store_blocks +# Store block index: two-level prompt segmentation over the content-addressed store -This module defines the derived-view reducer of the NAPL content-addressed store: -the typed, spine-walking query layer every store consumer reads through instead of -the retired journal. It is a rebuildable cache keyed by the current HEAD, never -authority and never a full scan. It never walks the whole object set; it follows -spines only, the snapshot parent chain and each module's linear event chain, and it -reads every object it touches through a single trusted gate that verifies the -object against its name before any field is believed. +This module defines the `block-index` object kind of the NAPL content-addressed +store, and the pure segmentation that produces it. A block index is a two-level +occurrence index over a prompt blob's bytes: a first structural level of **atom** +records (frontmatter fields and markdown body blocks) that tile the whole +document, and a second level of **sub-span** records giving sentence precision +inside prose atoms. The index carries byte and line spans against the stored +prompt bytes, so those bytes reconstruct exactly and line-based coordinates map +onto occurrences by span intersection. It is pure: no filesystem, no I/O, no +clocks, no randomness. Bring in `serde` and `serde_json`. -It answers exactly the queries the design's binding consumer matrix names, and no -universal fold: a status and LSP classification, the head prompt bytes, a change -bundle since a baseline state, a file's tree-diff history, an event by its global -ordinal, the prompt diff of one event, a drift baseline, the head generated tree, the -resolved view of one arbitrary state for a target, a -module's move and reword events, and a materialized-where-available event graph. -Every query declares its behavior over migrated `legacy` events that carry the -`unknown` state sentinel: it returns an exact result, an explicitly partial result -marked as such in the payload, or a typed unsupported-for-legacy error, and it never -fabricates state to satisfy the modern shape. +## Scope boundary: which object kind lives here -Bring in `serde` and `serde_json`. +The store has six object kinds. This module owns exactly one of them, the +`block-index` kind, whose serde types the neighboring `store_objects` module +deliberately deferred to this module. The other five kinds are out of scope here +and are never redefined in this crate: `blob`, `tree`, `module-state`, `event`, +and `snapshot`. This module never references another object by structure; where +it names another object it uses that object's content-addressed name, the +sixty-four-character lowercase hexadecimal SHA-256 string. The block index does +not embed the prompt blob; it indexes bytes the caller supplies. ## Where this code lives -The working directory is a Cargo workspace whose root manifest is written and owned -by the toolchain, leave it alone. Create this module as its own member crate in a -subdirectory named `store_reduce/`: `store_reduce/Cargo.toml` (package name -`store_reduce`) and `store_reduce/src/lib.rs`. Touch nothing outside -`store_reduce/`. Ensure `cargo test` passes from the workspace root before +The working directory is a Cargo workspace whose root manifest is written and +owned by the toolchain, leave it alone. Create this module as its own member +crate in a subdirectory named `store_blocks/`: `store_blocks/Cargo.toml` (package +name `store_blocks`) and `store_blocks/src/lib.rs`. Touch nothing outside +`store_blocks/`. Ensure `cargo test` passes from the workspace root before finishing. -## Builds on five modules of this workspace +## Builds on two modules of this workspace -This crate composes on five generated sibling crates. Use each one's public API, do -not reimplement its types or logic, and do not depend on any hand-written crate. -Depend on exactly these five, and do not bring in the `sha2` crate directly. +This crate composes on two generated sibling crates. Use each one's public API, +do not reimplement its types or logic, and do not depend on any hand-written +crate. Depend on exactly these two, and do not bring in the `sha2` crate directly: +the content digests below come from `hash`, and the object framing comes from +`store_hash`. -- **`store_hash`** (`../store_hash`) supplies the object gate. It uses - `store_hash::ObjectKind` with its variants `Blob`, `BlockIndex`, `Tree`, - `ModuleState`, `Event`, and `Snapshot`; and `store_hash::verify_object(name: - &str, bytes: &[u8]) -> Result` - which returns a `store_hash::VerifiedObject` with public fields `kind: - store_hash::ObjectKind` and `payload: Vec`. This module never reimplements the - envelope framing, the canonical JSON, or the SHA-256 naming; it verifies every - object it reads through `verify_object` and trusts no bytes that fail. -- **`store_objects`** (`../store_objects`) supplies the provenance and state types. - It uses `store_objects::Snapshot` (public fields `parents: Vec`, `seq: - u64`, `root: std::collections::BTreeMap`), - `store_objects::RootEntry` (public fields `module_state: String`, `event_head: - String`), `store_objects::ModuleState` (public fields `prompt_blob: String`, - `block_index: String`, `mapl: Option`, `ir: Option`, `targets: - std::collections::BTreeMap`), - `store_objects::TargetArtifacts` (public fields `generated_tree: String`, - `attribution: Option`), `store_objects::Event` (public fields `parents: - Vec`, `module: String`, `target: Option`, `ordinal: u64`, - `timestamp: String`, `engine: String`, `state_before: Option`, - `state_after: Option`, `payload: store_objects::EventPayload`), - the `store_objects::StateRef` enum with variants `State(String)` and `Unknown`, the - `store_objects::EventPayload` enum with variants `Gen`, `Reword`, `Reconcile`, - `Move`, `Derivation`, `Audit`, `Revert`, and `Legacy` carrying the matching field - structs, the `store_objects::GenOutcome` enum with variants `Ok`, `Failed`, and - `Interrupted`, and the `store_objects::DerivationOutcome` enum with variants `Ok`, - `Retried`, `Failed`, and `Interrupted`; and the validators - `store_objects::validate_snapshot(value: serde_json::Value) -> Result`, `store_objects::validate_module_state(value: - serde_json::Value) -> Result`, - and `store_objects::validate_event(value: serde_json::Value) -> Result`. This module resolves object names to these typed - values through the validators and never reimplements their shapes or checks. -- **`store_blocks`** (`../store_blocks`) supplies the block-index type the prompt - diff aligns. It uses `store_blocks::BlockIndex` and - `store_blocks::validate_block_index(value: serde_json::Value) -> Result`. This module never re-segments a prompt; it loads - the stored block-index object and validates it. -- **`store_tree`** (`../store_tree`) supplies the generated-tree type and diff. It - uses `store_tree::Tree`, `store_tree::validate_tree(value: serde_json::Value) -> - Result`, `store_tree::TreeEntry` with - its public fields (including `hash: String` and `kind: store_tree::EntryKind` whose - variant `store_tree::EntryKind::Dir` names a subtree), and - `store_tree::diff_trees(old_root: Option<&str>, new_root: Option<&str>, objects: - &std::collections::BTreeMap) -> - Result, store_tree::StoreTreeError>` with the - `store_tree::TreeChange` enum (variants `Added { path, hash, mode }`, `Removed { - path, hash, mode }`, `Modified { path, old_hash, old_mode, new_hash, new_mode }`). - This module loads tree objects into the object map and calls `diff_trees`; it never - reimplements tree diffing. -- **`store_diff`** (`../store_diff`) supplies the prompt alignment and the bundle. It - uses `store_diff::ChangedBlock`, `store_diff::Attribution`, - `store_diff::validate_attribution(value: serde_json::Value) -> Result`, `store_diff::Bundle`, - `store_diff::diff_prompt(old_index: &store_blocks::BlockIndex, old_prompt: &str, - new_index: &store_blocks::BlockIndex, new_prompt: &str) -> Vec`, - `store_diff::build_bundle(module: &str, prompt_changes: &[store_diff::ChangedBlock], - old_index: &store_blocks::BlockIndex, old_attribution: &store_diff::Attribution, - new_index: &store_blocks::BlockIndex, new_attribution: &store_diff::Attribution, - old_tree_root: Option<&str>, new_tree_root: Option<&str>, tree_objects: - &std::collections::BTreeMap) -> Result`, and `store_diff::bundle_blob_name(bundle: - &store_diff::Bundle) -> String`. The reducer is exactly the caller `store_diff` - describes: it holds two module-states, resolves their `blockIndex`, - `generatedTree`, and `attribution` hashes to concrete values, and then composes on - `store_diff` for the prompt diff and the bundle; it never reimplements alignment, - ownership, or bundle assembly. +- **`hash`** (`../hash`) exposes `hash::content_hash(content: &str) -> String`, + the bare SHA-256 content digest: the lowercase-hexadecimal SHA-256 of the + UTF-8 bytes of `content`, sixty-four characters. This is what a block index + stores in every `contentHash` field, an identification digest over an atom's or + a sub-span's exact bytes. It is not an object name (it carries no envelope); use + it for every `contentHash` this module computes, and never reimplement SHA-256. +- **`store_hash`** (`../store_hash`) supplies the object framing for the whole + block-index object: + - `store_hash::ObjectKind`, whose variant `store_hash::ObjectKind::BlockIndex` + names this kind; its wire token `block-index` comes from `store_hash`, never + re-spelled here. + - `store_hash::canonical_json(value: &serde_json::Value) -> Vec`, the + canonical JSON encoder (keys sorted recursively, compact, one trailing + newline) that turns a serialized value into its canonical bytes. + - `store_hash::envelope_bytes(kind: store_hash::ObjectKind, payload: &[u8]) -> + Vec` and `store_hash::object_name(kind: store_hash::ObjectKind, payload: + &[u8]) -> String`, the typed envelope framing and the SHA-256 object name over + those envelope bytes. + - `store_hash::verify_object(name: &str, bytes: &[u8]) -> + Result`, the read + verifier the round-trip test passes a freshly built object through; + `store_hash::VerifiedObject` has public fields `kind: store_hash::ObjectKind` + and `payload: Vec`. + +The distinction is load-bearing: a `contentHash` is a bare `hash::content_hash` +digest of raw bytes, while the block-index object's own name is a +`store_hash::object_name` over its canonical-JSON envelope. The two never +substitute for each other. ## Content hashes are plain strings -Every content hash and every content-addressed object name is carried as a `String`, -matching the rest of the workspace, never a newtype. A well-formed hash is -sixty-four lowercase hexadecimal characters. This module does not itself re-validate -hash shape; the upstream validators and `store_hash::verify_object` already reject a -malformed name or a mismatched object, and the reducer only ever passes names it -read out of an already-verified parent object. +Every `contentHash` and every content-addressed name is carried as a `String`, +matching the rest of the workspace, never a newtype. A well-formed digest or name +is sixty-four lowercase hexadecimal characters (`0` through `9`, `a` through `f`). +The validator below rejects any hash-typed field whose string is not exactly that +shape. The lowercase rule is exact and intentional: an uppercase hexadecimal +digit makes a digest ill-formed and the validator rejects it as +`MalformedHash`, because `hash::content_hash` emits lowercase and this module +never re-cases a digest. This module never computes a digest except by delegating +to `hash::content_hash`. -## The object provider +## The block-index object -The store's read surface is a single synchronous trait, pinned by the store rewrite -plan before any Phase A prompt was authored so that no prompt invents its own -cross-crate contract. The plan fixes exactly two methods, conceptually -`objectBytes(hash) -> Option` returning an object's full envelope bytes for -the caller to verify through `store_hash`, and `headSnapshotHash() -> Option` -returning the current HEAD snapshot's name. Expose that trait, and only that trait, -as the reducer's entire dependency on storage. +Expose a public struct `BlockIndex` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`, `serde::Serialize`, and `serde::Deserialize`, with +`#[serde(deny_unknown_fields)]`), one prompt blob's segmentation index. Public +fields: -Expose a public trait `ObjectProvider` with exactly these two methods and no others: +- `seg_version: u32`, serde field name `segVersion`. The segmentation format + version, versioned independently of the store-wide hash format version. It is a + `u32`, always serialized. A `segVersion` bump regenerates indexes from stored + prompt blobs without changing prompt identity. +- `atoms: Vec`, serde field name `atoms`. The first-level structural + records in document order, always present, an empty document serializing as + `[]`. +- `sub_spans: Vec`, serde field name `subSpans`. The second-level + sentence records, ordered by parent atom then sub-index, always present, an + empty set serializing as `[]`. -- `fn object_bytes(&self, hash: &str) -> Option>`: the full envelope bytes of - the object named `hash`, or `None` when the provider does not hold it. The bytes are - unverified; the reducer verifies them. -- `fn head_snapshot_hash(&self) -> Option`: the current HEAD snapshot's object - name, or `None` when the store has no HEAD yet. +Expose a public struct `AtomRecord` (same derives, same +`#[serde(deny_unknown_fields)]`), one first-level block. Public fields, each a +`u32` except the hash: -The reducer takes a `&P` where `P: ObjectProvider` on every query. It never mutates -the provider, never writes, and holds no other capability: no filesystem, no clock, -no randomness. A unit corpus feeds an in-memory provider; Phase B's `store_io` -implements the same trait over the on-disk store, and an integration test proves the -two providers answer identically over a fixture store. Because the reducer's only -inputs are this trait and the caller's arguments, it is deterministic and pure -relative to the provider's contents. +- `atom_index: u32`, serde field name `atomIndex`. The atom's zero-based position + in document order. Atom occurrence identity is the pair `(contentHash, + atomIndex)`. +- `content_hash: String`, serde field name `contentHash`. The `hash::content_hash` + of the atom's exact bytes. +- `byte_start: u32`, serde field name `byteStart`, and `byte_end: u32`, serde + field name `byteEnd`. The atom's half-open byte range `[byteStart, byteEnd)` + against the prompt bytes. +- `line_start: u32`, serde field name `lineStart`, and `line_end: u32`, serde + field name `lineEnd`. The atom's inclusive one-based line range. -## The error type +Expose a public struct `SubSpanRecord` (same derives, same +`#[serde(deny_unknown_fields)]`), one second-level sentence span anchored to its +enclosing atom. Public fields, each a `u32` except the hash: -Expose a public enum `ReduceError` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) -with exactly these variants: +- `parent_atom_index: u32`, serde field name `parentAtomIndex`. The `atomIndex` of + the enclosing atom. +- `sub_index: u32`, serde field name `subIndex`. The sub-span's zero-based + position within its parent atom. Sub-span occurrence identity is the triple + `(parentAtomIndex, subIndex, contentHash)`. +- `content_hash: String`, serde field name `contentHash`. The `hash::content_hash` + of the sub-span's exact bytes. +- `byte_start: u32`, serde field name `byteStart`, and `byte_end: u32`, serde + field name `byteEnd`. The sub-span's half-open byte range against the prompt + bytes. +- `line_start: u32`, serde field name `lineStart`, and `line_end: u32`, serde + field name `lineEnd`. The sub-span's inclusive one-based line range. -- `CorruptObject(String)`, carrying the offending object name, for any object the - provider returned whose bytes fail `store_hash::verify_object`, whose verified kind - is not the kind the reference demanded, or whose payload fails the matching upstream - validator. A corrupt store is a loud, hard error: the reducer never silently skips a - bad object and never degrades a corrupt read into an empty answer. -- `MissingObject(String)`, carrying the object name, for a name a query must resolve - to answer that the provider does not hold. This is distinct from the graceful spine - boundary below: a missing object that the current answer depends on is an error, not - an empty result. -- `UnsupportedForLegacy`, for a query that cannot answer across an `unknown` state - sentinel, namely a `bundle_since` whose baseline is the `unknown` state. The caller - translates this to its own fallback, which for the gen consumer is full mode. +## The error type -The error type does not model consumer error policy. Whether a consumer fails closed, -degrades to no diagnostics, or falls back to full mode is that consumer's decision in -a later phase; the reducer only reports the typed fact. +Expose a public enum `StoreBlockError` (deriving `Debug`, `Clone`, `PartialEq`, +and `Eq`) with exactly these variants, one per failure mode of the validator +below: -Implement `std::fmt::Display` for `ReduceError` so a consumer can render a reducer -failure as a bare, human-readable message that carries no `Debug` artifacts: no variant -name, no quotes, and no braces. `CorruptObject` renders `corrupt store object {name}`, -interpolating its carried object name verbatim. `MissingObject` renders `missing store -object {name}`, interpolating its carried object name verbatim. `UnsupportedForLegacy` -renders `query unsupported across a legacy state boundary`. This addition is purely -additive: the `Debug` derive is retained for the test assertions that pin variant -equality, and the reducer's own error construction and propagation are unchanged. The -`Display` impl and its `fmt` method carry no doc comment, matching the crate's existing -trait-impl style. +- `Malformed(String)`, carrying the underlying deserialization message, for any + structural or type failure surfaced by serde (an unknown field, a missing + required field, a value of the wrong JSON type, an integer outside `u32`). +- `MalformedHash`, a `contentHash` that is not sixty-four lowercase hex. +- `InvalidSegVersion`, a `segVersion` below 1. +- `InvalidSpan`, an atom or sub-span whose `byteStart` exceeds its `byteEnd`, or + whose `lineStart` exceeds its `lineEnd`. +- `UnknownParentAtom`, a sub-span whose `parentAtomIndex` names no atom. +- `NonSequentialAtomIndex`, an atom whose `atomIndex` does not equal its + zero-based position in the `atoms` array, so duplicated, skipped, or + out-of-order atom records are all rejected. +- `AtomsNotTiled`, atoms that do not tile the document contiguously: a first atom + whose `byteStart` is not 0, or an atom whose `byteStart` does not equal the + previous atom's `byteEnd` (an overlap or a gap). +- `NonSequentialSubIndex`, a sub-span whose `subIndex` does not equal its + zero-based position within its parent atom's run of sub-spans, so duplicated, + skipped, or out-of-order sibling records are all rejected. +- `SubSpanOutsideParent`, a sub-span whose byte range is not wholly within its + parent atom's byte range. +- `SubSpansNotTiled`, sibling sub-spans of one atom that do not tile that atom's + byte range exactly: a first sibling whose `byteStart` is not the parent atom's + `byteStart`, a sibling whose `byteStart` does not equal the previous sibling's + `byteEnd`, or a last sibling whose `byteEnd` is not the parent atom's `byteEnd`. -## The trusted read gate +## Validating a block-index -Every object the reducer reads passes one private gate, so no downstream code ever -touches unverified bytes. Given a name and the `store_hash::ObjectKind` the reference -demands, the gate: +Expose `validate_block_index(value: serde_json::Value) -> Result`: deserialize the JSON value into a `BlockIndex`, returning +`StoreBlockError::Malformed` (carrying serde's message) on any deserialization +failure, then apply these semantic checks, returning the first failure in this +order: -1. Fetches the bytes with `object_bytes`; `None` is `ReduceError::MissingObject` with - that name. -2. Verifies the bytes with `store_hash::verify_object(name, &bytes)`; any - `store_hash::VerifyError` is `ReduceError::CorruptObject` with that name. -3. Checks the verified kind equals the demanded kind; a mismatch is - `ReduceError::CorruptObject` with that name. A `blob` referenced where a `tree` - was expected, or any other cross-kind confusion, is corruption. -4. Returns the verified payload bytes for the caller to interpret. +- `segVersion` must be at least 1; since it is a `u32` this fails exactly when it + is 0, as `StoreBlockError::InvalidSegVersion`. +- Every `contentHash`, on every atom and every sub-span, must be a well-formed + digest (sixty-four lowercase hex), else `StoreBlockError::MalformedHash`. +- For every atom and every sub-span, `byteStart` must be less than or equal to + `byteEnd`, and `lineStart` less than or equal to `lineEnd`, else + `StoreBlockError::InvalidSpan`. +- Every sub-span's `parentAtomIndex` must be strictly less than the number of + atoms, else `StoreBlockError::UnknownParentAtom`. +- Every atom's `atomIndex` must equal its zero-based position in the `atoms` + array, else `StoreBlockError::NonSequentialAtomIndex`. This one check rejects a + duplicated `atomIndex`, an `atomIndex` that skips a value, and atom records + serialized out of document order. +- The atoms must tile the document with no gaps and no overlaps: when there is at + least one atom the first atom's `byteStart` is 0, and every later atom's + `byteStart` equals the previous atom's `byteEnd`, else + `StoreBlockError::AtomsNotTiled`. +- Within each parent atom's run of sub-spans (sub-spans sharing a + `parentAtomIndex`, which appear consecutively because sub-spans are ordered by + parent atom then sub-index), each sub-span's `subIndex` must equal its + zero-based position in that run, else `StoreBlockError::NonSequentialSubIndex`. + This one check rejects a duplicated `subIndex`, a `subIndex` that skips a value, + and sibling records serialized out of order. +- Every sub-span's byte range must be wholly within its parent atom's byte range, + that is `byteStart` at least the parent atom's `byteStart` and `byteEnd` at most + the parent atom's `byteEnd`, else `StoreBlockError::SubSpanOutsideParent`. +- The sub-spans of one atom must tile that atom's byte range exactly: the first + sibling's `byteStart` equals the parent atom's `byteStart`, every later + sibling's `byteStart` equals the previous sibling's `byteEnd`, and the last + sibling's `byteEnd` equals the parent atom's `byteEnd`, else + `StoreBlockError::SubSpansNotTiled`. An atom with no sub-spans (a frontmatter, + heading, fenced-block, or whitespace atom) imposes no tiling requirement of its + own; the tiling requirement binds only atoms that carry at least one sub-span. -For the JSON object kinds, a second private helper parses the verified payload bytes -into `serde_json::Value` and hands the value to the matching upstream validator -(`validate_snapshot`, `validate_module_state`, `validate_event`, -`validate_block_index`, `validate_tree`, or `validate_attribution`); a parse failure -or any validator error is `ReduceError::CorruptObject` with that name. A `blob` -payload is returned as raw bytes and never re-encoded. This gate is the module's only -deserialization of store bytes, and it is always strict: the reducer deserializes no -untrusted input into its own result types, so the deny-unknown-fields discipline the -store lane enforces at every nesting level is discharged upstream by these validators, -each of which already carries that discipline and its own rejection corpus, plus the -kind-and-hash gate here. +These occurrence-index invariants make an accepted `BlockIndex` a genuine ordered +two-level tiling, not merely a serde-shaped value: a downstream reader may treat a +validated index as an ordered, tiled occurrence index. The check order is exactly +the order listed above, so `UnknownParentAtom` is decided before any sub-span is +resolved against its parent, and the returned error is the first failure in that +order. + +The struct deserialization is plain serde with `#[serde(deny_unknown_fields)]` on +all three structs; no hand-written deserializer is needed, and there is no +adjacently tagged enum anywhere in this module. ::: test -name: a query whose head-referenced object is absent is a missing-object error -given: { fixture: RS_CLEAN_MISSING_STATE, query: classify, observed: matching } -expect: { error: MissingObject } +name: a block-index with an unknown top-level field is rejected +given: { json: "the BI_MIN object with an extra top-level key \"bogus\": 1" } +expect: { error: Malformed } ::: ::: test -name: a query whose head object bytes do not verify is a corrupt-object error -given: { fixture: RS_CLEAN_CORRUPT_HEAD, query: classify, observed: matching } -expect: { error: CorruptObject } +name: a block-index missing atoms is rejected +given: { json: "the BI_MIN object with the atoms key removed" } +expect: { error: Malformed } ::: -## Spine walking versus the genesis boundary - -Two walks exist and they treat an absent object differently, deliberately: - -- A **head-referent** read, an object named directly by the HEAD snapshot or by an - object the current answer must materialize (the head snapshot itself, a module's - head module-state, its block index, its prompt blob, its generated tree and - subtrees, its attribution, its head event), is required: absence is - `ReduceError::MissingObject`. -- A **spine ancestor** read, a parent snapshot or an ancestor event reached while - walking backward through history, terminates the walk gracefully when absent: the - provider legitimately retains a bounded window, so an absent ancestor is the genesis - boundary of retained history, not corruption. The walk stops and the query answers - from what it found, never erroring on a missing ancestor. - -A walk follows the first parent of a snapshot and the first parent of an event; the -chains are linear for a single module because its conflict domain covers all its -targets, and the snapshot spine's first parent is the mainline history. - -One head-referent read has a single, deliberate boundary value: a root entry's -`event_head`. The genesis sentinel is the all-zeros object name, sixty-four `0` -characters, the never-published boundary marker the migration writes as the -`event_head` of a current-state-only module (one whose current state survives on disk -but whose recorded history was lost, the emptied-mapl scar). A root entry whose -`event_head` is exactly that all-zeros sentinel denotes empty recorded history, not a -missing object: it is the module's genesis boundary, answered gracefully exactly as an -absent spine ancestor is, never `MissingObject`. This is a value refinement of the -head-referent rule, not a weakening of it: an `event_head` that is any other name yet -absent from the provider remains a loud `MissingObject`, so genuine corruption of a -real head event is still caught; only the one deliberate, never-published sentinel is -read as the empty-history boundary. Recognize it by that exact value before any load; -never fetch the sentinel from the provider. Every query that loads a root entry's head -event honors this boundary uniformly: the module's event chain over such a root is -empty, its classification is `CurrentStateOnly`, and an ordinal lookup skips the -historyless root. - ::: test -name: classify over a head whose nonzero event head is absent from the provider is a missing-object error -given: { fixture: RS_CLEAN_MISSING_EVENT, query: classify, observed: matching } -expect: { error: MissingObject } +name: a block-index whose atoms is the wrong JSON type is rejected +given: { json: "the BI_MIN object with atoms set to the number 1" } +expect: { error: Malformed } ::: ::: test -name: event_graph over a head whose nonzero event head is absent from the provider is a missing-object error -given: { fixture: RS_CLEAN_MISSING_EVENT, query: event_graph } -expect: { error: MissingObject } +name: an atom record with an unknown field is rejected +given: { json: "the BI_MIN object whose one atom record carries an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: ::: test -name: event_by_ordinal over a head whose nonzero event head is absent from the provider is a missing-object error -given: { fixture: RS_CLEAN_MISSING_EVENT, query: event_by_ordinal, ordinal: 1 } -expect: { error: MissingObject } +name: an atom record missing contentHash is rejected +given: { json: "the BI_MIN object whose one atom record has the contentHash key removed" } +expect: { error: Malformed } ::: -## The classification query - -Expose a public enum `Classification` (deriving `Debug`, `Clone`, `Copy`, -`PartialEq`, `Eq`, `serde::Serialize`, `serde::Deserialize`), serializing as a single -JSON string, each variant pinned to its exact wire token; a string outside the set is -a deserialization failure: - -- `Classification::Clean` renders `clean`. -- `Classification::NeverGenerated` renders `never-generated`. -- `Classification::PromptStale` renders `prompt-stale`. -- `Classification::Drifted` renders `drifted`. -- `Classification::Unattributed` renders `unattributed`. -- `Classification::Reworded` renders `reworded`. -- `Classification::Interrupted` renders `interrupted`. -- `Classification::Moved` renders `moved`. -- `Classification::CurrentStateOnly` renders `current-state-only`. The module has - current state at HEAD (a seeded module-state and a target) but no recorded - generating event: its root entry's `event_head` is the genesis sentinel, the - emptied-mapl scar carried through migration. This is distinct from - `NeverGenerated`, which has no artifacts at all. - -These are the reducer's own canonical tokens. The `drifted` token is deliberately not -the CLI render label `DRIFT`; mapping this rich classification down to the exact -today-CLI label is the `statusclass_io` consumer's job in a later phase, out of scope -here. The reducer produces the classification; the renderer maps it. - -Because the reducer cannot see the working tree (it holds only the provider), it -receives the observed working state as an argument. Expose a public struct `Observed` -(deriving `Debug`, `Clone`, `PartialEq`, `Eq`, `serde::Serialize`, -`serde::Deserialize`, with `#[serde(deny_unknown_fields)]`), the caller's snapshot of -the module's current on-disk hashes. Public fields: - -- `prompt_blob: Option`, serde field name `promptBlob`, skipped from - serialization when `None`. The hash of the module's current on-disk prompt bytes, or - `None` when the prompt file is absent. -- `generated_tree: Option`, serde field name `generatedTree`, skipped when - `None`. The hash of the target's current on-disk generated tree, or `None` when it - is absent. - -Expose `classify(provider: &P, module: &str, target: &str, -observed: &Observed) -> Result`. It loads the HEAD -snapshot (head-referent; a missing or corrupt head is an error) and applies this -total, pinned precedence, returning at the first matching rule: - -1. If HEAD is `None`, or `module` is absent from the head snapshot's root, or the - module's head module-state has no entry for `target` in its `targets` map, return - `Classification::NeverGenerated`. No generated artifacts exist for this target. -2. Otherwise, if the module's root entry `eventHead` is the genesis sentinel (the - all-zeros never-published name, empty recorded history), return - `Classification::CurrentStateOnly`. The module has current state and a target but no - generating event, so there is no head event to read for the nature rules below and - nothing a working comparison could restate; this is decided by the sentinel value - without fetching any event object. This rule sits above the event-nature and - working-comparison rules because a historyless HEAD has no event to classify. -3. Otherwise load the module's head event (the root entry's `eventHead`). If that - event's payload is a `Gen` whose outcome is `Interrupted`, or a `Derivation` whose - outcome is `Interrupted`, return `Classification::Interrupted`. An interrupted - writer left the module mid-flight, and that qualifies the HEAD baseline before any - working comparison. -4. Otherwise, if the head event's payload is a `Move`, return `Classification::Moved`. -5. Otherwise, if the head event's payload is a `Reword`, return - `Classification::Reworded`. -6. Otherwise compare the observation to the head module-state. If - `observed.prompt_blob` is not `Some(state.prompt_blob)` (absent, or a different - hash), return `Classification::PromptStale`. -7. Otherwise, if `observed.generated_tree` is not `Some(target.generated_tree)`, - return `Classification::Drifted`. -8. Otherwise, if the target's `attribution` is `None`, return - `Classification::Unattributed`. The generated files exist and match but were never - attributed. -9. Otherwise return `Classification::Clean`. - -Rules three through five read the head event's recorded nature and take precedence over -the working comparison in rules six through eight, because a reworded, moved, or -interrupted HEAD changes what the baseline itself means. This precedence is observable -and pinned; do not reorder it. Classification is exact over legacy history: it depends -only on the HEAD state, which migration seeds fully, so a chain that contains legacy -events with `unknown` states still classifies exactly at the head. - ::: test -name: the classification clean serializes to the string clean -given: { classification: clean } -expect: { serialized_json: "\"clean\"" } +name: a sub-span record with an unknown field is rejected +given: { json: "the BI_RICH object whose first sub-span record carries an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: ::: test -name: the classification never-generated serializes to the string never-generated -given: { classification: never-generated } -expect: { serialized_json: "\"never-generated\"" } +name: a block-index whose segVersion is the wrong JSON type is rejected +given: { json: "the BI_MIN object with segVersion set to the string \"1\"" } +expect: { error: Malformed } ::: ::: test -name: the classification prompt-stale serializes to the string prompt-stale -given: { classification: prompt-stale } -expect: { serialized_json: "\"prompt-stale\"" } +name: an atom whose contentHash is not sixty-four lowercase hex is rejected +given: { json: "the BI_MIN object with its one atom contentHash set to \"xyz\"" } +expect: { error: MalformedHash } ::: ::: test -name: the classification drifted serializes to the string drifted -given: { classification: drifted } -expect: { serialized_json: "\"drifted\"" } +name: a sub-span whose contentHash is not sixty-four lowercase hex is rejected +given: { json: "the BI_RICH object with its first sub-span contentHash set to \"xyz\"" } +expect: { error: MalformedHash } ::: ::: test -name: the classification unattributed serializes to the string unattributed -given: { classification: unattributed } -expect: { serialized_json: "\"unattributed\"" } +name: a block-index whose segVersion is zero is rejected +given: { json: "the BI_MIN object with segVersion set to 0" } +expect: { error: InvalidSegVersion } ::: ::: test -name: the classification reworded serializes to the string reworded -given: { classification: reworded } -expect: { serialized_json: "\"reworded\"" } +name: an atom whose byteStart exceeds its byteEnd is rejected +given: { json: "the BI_MIN object with its one atom byteStart set to 9 and byteEnd left at 5" } +expect: { error: InvalidSpan } ::: ::: test -name: the classification interrupted serializes to the string interrupted -given: { classification: interrupted } -expect: { serialized_json: "\"interrupted\"" } +name: an atom whose lineEnd is below its lineStart is rejected +given: { json: "the BI_MIN object with its one atom lineStart set to 4 and lineEnd left unchanged at its BI_MIN value of 1, no other field touched, so the atom carries lineStart 4 and lineEnd 1" } +expect: { error: InvalidSpan } ::: ::: test -name: the classification moved serializes to the string moved -given: { classification: moved } -expect: { serialized_json: "\"moved\"" } +name: a sub-span whose byteStart exceeds its byteEnd is rejected +given: { json: "the BI_RICH object with its first sub-span byteStart set to 9 and byteEnd left at 5" } +expect: { error: InvalidSpan } ::: ::: test -name: the classification current-state-only serializes to the string current-state-only -given: { classification: current-state-only } -expect: { serialized_json: "\"current-state-only\"" } +name: a sub-span whose parentAtomIndex names no atom is rejected +given: { json: "the BI_RICH object with its first sub-span parentAtomIndex set to 5, past the two atoms present" } +expect: { error: UnknownParentAtom } ::: ::: test -name: classify over a matching head is clean -given: { fixture: RS_CLEAN, query: classify, observed: matching } -expect: { classification: clean } +name: an atom whose atomIndex duplicates an earlier atom is rejected +given: { json: "the BI_RICH object with its second atom atomIndex set to 0, duplicating the first atom" } +expect: { error: NonSequentialAtomIndex } ::: ::: test -name: classify over a module absent from the head root is never-generated -given: { fixture: RS_CLEAN, query: classify, observed: matching, module: absent } -expect: { classification: never-generated } +name: an atom whose atomIndex skips its position is rejected +given: { json: "the BI_RICH object with its second atom atomIndex set to 2" } +expect: { error: NonSequentialAtomIndex } ::: ::: test -name: classify over a present module whose head state lacks the target is never-generated -given: { fixture: RS_CLEAN, query: classify, observed: matching, target: absent } -expect: { classification: never-generated } +name: atom records out of document order are rejected +given: { json: "the BI_RICH object with its two atom records reversed in the atoms array" } +expect: { error: NonSequentialAtomIndex } ::: ::: test -name: classify with an observed prompt that differs from head is prompt-stale -given: { fixture: RS_CLEAN, query: classify, observed: prompt_mismatch } -expect: { classification: prompt-stale } +name: atoms that overlap in bytes are rejected +given: { json: "the BI_RICH object with its second atom byteStart set to 8, overlapping the first atom that ends at 10" } +expect: { error: AtomsNotTiled } ::: ::: test -name: classify with an observed tree that differs from head is drifted -given: { fixture: RS_CLEAN, query: classify, observed: tree_mismatch } -expect: { classification: drifted } +name: atoms that leave a byte gap are rejected +given: { json: "the BI_RICH object with its second atom byteStart set to 12, leaving a gap after the first atom that ends at 10" } +expect: { error: AtomsNotTiled } ::: ::: test -name: classify over a head target with no attribution is unattributed -given: { fixture: RS_UNATTRIB, query: classify, observed: matching } -expect: { classification: unattributed } +name: a first atom whose byteStart is not zero is rejected +given: { json: "the BI_RICH object with its first atom byteStart set to 2, so the first atom no longer starts at byte 0" } +expect: { error: AtomsNotTiled } ::: ::: test -name: classify over a head whose latest event is a reword is reworded -given: { fixture: RS_REWORD, query: classify, observed: matching } -expect: { classification: reworded } +name: a sub-span whose subIndex duplicates an earlier sibling is rejected +given: { json: "the BI_RICH object with its second sub-span subIndex set to 0, duplicating the first sibling" } +expect: { error: NonSequentialSubIndex } ::: ::: test -name: classify over a head whose latest event is an interrupted gen is interrupted -given: { fixture: RS_INTERRUPTED, query: classify, observed: matching } -expect: { classification: interrupted } +name: a sub-span whose subIndex skips its position is rejected +given: { json: "the BI_RICH object with its second sub-span subIndex set to 2" } +expect: { error: NonSequentialSubIndex } ::: ::: test -name: classify over a head whose latest event is a move is moved -given: { fixture: RS_MOVED, query: classify, observed: matching } -expect: { classification: moved } +name: a sub-span reaching outside its parent atom byte range is rejected +given: { json: "the BI_RICH object with its second sub-span byteEnd set to 25, past its parent atom that ends at 10" } +expect: { error: SubSpanOutsideParent } ::: ::: test -name: classify stays exact and clean over a head whose chain carries a legacy event -given: { fixture: RS_LEGACY, query: classify, observed: matching } -expect: { classification: clean } +name: sibling sub-spans that overlap in bytes are rejected +given: { json: "the BI_RICH object with its second sub-span byteStart set to 3, overlapping the first sibling that ends at 5" } +expect: { error: SubSpansNotTiled } ::: ::: test -name: classify over a current-state-only head whose event head is the genesis sentinel is current-state-only -given: { fixture: RS_CURRENT_ONLY, query: classify, observed: matching } -expect: { classification: current-state-only } +name: sibling sub-spans that leave a byte gap are rejected +given: { json: "the BI_RICH object with its second sub-span byteStart set to 7, leaving a gap after the first sibling that ends at 5" } +expect: { error: SubSpansNotTiled } ::: -## The head prompt bytes query +::: test +name: a first sibling sub-span whose byteStart is not its parent atom byteStart is rejected +given: { json: "the BI_RICH object with its first sub-span byteStart set to 2, so the first sibling no longer starts at its parent atom byteStart of 0" } +expect: { error: SubSpansNotTiled } +::: -Expose `prompt_bytes_at_head(provider: &P, module: &str) -> -Result>, ReduceError>`. It loads the HEAD snapshot; if HEAD is `None` -or `module` is absent from its root, return `Ok(None)`. Otherwise load the module's -head module-state, then load its `prompt_blob` as a `Blob` object, and return -`Ok(Some(payload))` with the blob payload bytes exactly, never re-encoded. This -replaces the retired prompts-at-gen store and is byte-exact and exact over legacy, -because HEAD always names a concrete prompt blob. +::: test +name: a last sibling sub-span whose byteEnd is not its parent atom byteEnd is rejected +given: { json: "the BI_RICH object with its second sub-span byteEnd set to 8, so the last sibling no longer ends at its parent atom byteEnd of 10" } +expect: { error: SubSpansNotTiled } +::: ::: test -name: prompt_bytes_at_head returns the exact head prompt blob bytes -given: { fixture: RS_CLEAN, query: prompt_bytes_at_head } -expect: { prompt_bytes: "The greeting module.\n" } +name: a block-index whose segVersion is the largest u32 is accepted +given: { json: "the BI_MIN object with segVersion set to 4294967295" } +expect: { accepts: true } ::: ::: test -name: prompt_bytes_at_head for a module absent from head is none -given: { fixture: RS_CLEAN, query: prompt_bytes_at_head, module: absent } -expect: { is_none: true } +name: a block-index whose segVersion exceeds the u32 range is rejected +given: { json: "the BI_MIN object with segVersion set to 4294967296" } +expect: { error: Malformed } ::: -## The bundle-since query +## Naming a block-index object through `store_hash` -Expose a public enum `BundleMode` (deriving `Debug`, `Clone`, `Copy`, `PartialEq`, -`Eq`, `serde::Serialize`, `serde::Deserialize`), serializing as a single JSON string: +Expose `block_index_object_name(index: &BlockIndex) -> String`, the object's name: +serialize the value to a `serde_json::Value`, and return +`store_hash::object_name(store_hash::ObjectKind::BlockIndex, +&store_hash::canonical_json(&value))`. This does not reimplement canonical JSON, +envelope framing, or hashing; it calls `store_hash`. The canonical bytes fed to +the envelope are exactly `store_hash::canonical_json` applied to the serialized +value, so a round-trip through `store_hash::verify_object` over the framed +envelope succeeds and reports `store_hash::ObjectKind::BlockIndex`. -- `BundleMode::Full` renders `full`. -- `BundleMode::Diff` renders `diff`. +## The segmentation version constant -Expose a public struct `BundleQuery` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, -`serde::Serialize`, `serde::Deserialize`, with `#[serde(deny_unknown_fields)]`), the -answer of a bundle-since query. Public fields: +Expose the public constant `pub const SEG_VERSION: u32 = 1`, the current +segmentation format version this module emits by default. -- `mode: BundleMode`, serde field name `mode`. `Full` when there is no baseline to - diff against, `Diff` when a bundle was assembled. -- `bundle: Option`, serde field name `bundle`, skipped from - serialization when `None`. Present exactly when `mode` is `Diff`, absent exactly when - `mode` is `Full`. +::: test +name: the default segmentation version constant is one +given: {} +expect: { seg_version_const: 1 } +::: -Expose `bundle_since(provider: &P, module: &str, target: &str, -baseline: Option<&store_objects::StateRef>) -> Result`. The -`baseline` is the state to diff HEAD against, taken as an `Option` so that the absence -of any baseline, a first-ever gen, is modeled directly: +## Segmenting a prompt -- If `baseline` is `None`, there is no prior state: return `BundleQuery { mode: - BundleMode::Full, bundle: None }`. The gen consumer reads this as full mode, as - today. -- If `baseline` is `Some(store_objects::StateRef::Unknown)`, the baseline is a migrated - `unknown` sentinel and no bundle can be computed across it: return - `Err(ReduceError::UnsupportedForLegacy)`. The gen consumer falls back to full mode on - this error. -- If `baseline` is `Some(store_objects::StateRef::State(old_name))`, assemble the diff. - Load HEAD; if `module` is absent from the head root, the new side is a first gen and - there is no head state to diff, so return `BundleQuery { mode: BundleMode::Full, - bundle: None }`. Otherwise resolve both the old module-state (named `old_name`, - head-referent: a named-but-absent baseline object is `MissingObject`) and the head - module-state. From each state, for the given `target`, resolve to concrete values: - the block index (load and validate the `block_index` object), the prompt string (load - the `prompt_blob` blob and read its bytes as a UTF-8 string), the attribution (load - the `attribution` blob when present and validate it through - `store_diff::validate_attribution`, else an empty attribution for that module and - target with no entries), and the generated tree root hash. Build the tree object map - by loading, for both the old and the new generated tree root, that root tree and - every subtree reachable through its `Dir` entries, inserting each under its own hash. - Compute the prompt changes with `store_diff::diff_prompt(old_index, &old_prompt, - new_index, &new_prompt)`, then call `store_diff::build_bundle` with the module, those - prompt changes, the two indexes, the two attributions, the two generated tree roots as - `Some`, and the assembled tree object map. Return `BundleQuery { mode: - BundleMode::Diff, bundle: Some(bundle) }`. When the baseline state equals the head - state, the prompt diff is empty and the trees are identical, so the assembled bundle - is the empty no-op bundle, exactly what the audit consumer asserts for an - expected-no-op. +Expose `segment(prompt: &str, seg_version: u32) -> BlockIndex`, the pure +segmentation. It reads the prompt text, partitions it into atoms that tile the +whole document, computes sentence sub-spans inside prose atoms, and returns a +`BlockIndex` whose `segVersion` is the given `seg_version`. Every `contentHash` is +`hash::content_hash` of the exact bytes the record spans. It is deterministic: no +LLM, no I/O, no randomness. An empty prompt yields a `BlockIndex` with no atoms and +no sub-spans. -::: test -name: bundle_since over a present concrete baseline equal to head is a no-op diff bundle -given: { fixture: RS_BUNDLE, query: bundle_since, baseline: head } -expect: { bundle_mode: diff, bundle_blob_name: "6dc9ca1a0fe393bbed56cce83d42aadded789fac58c3f5ef22d2c13bf25d1bdc" } -::: +Input contract: `segment` has one precondition, that the prompt's byte length fit +in a `u32`, that is fewer than 4294967296 bytes. Byte offsets, line numbers, atom +indices, and sub-indices are all carried as `u32`, and NAPL prompt blobs are far +below this bound (kilobytes, not gigabytes), so the precondition holds for every +real prompt. Under this precondition `segment` is total: it returns a `BlockIndex` +for every input, with no panic and no error path, since the design gives `segment` +no fallible return. A prompt at or beyond the `u32` byte bound is outside the +contract and its offsets are not required to be meaningful; callers bound prompt +size before segmenting rather than passing gigabyte inputs. -::: test -name: bundle_since over an absent baseline is full mode -given: { fixture: RS_BUNDLE, query: bundle_since, baseline: none } -expect: { bundle_mode: full, bundle_bytes: "{\"mode\":\"full\"}\n" } -::: +Byte offsets throughout are UTF-8 byte offsets into `prompt`; line and atom and +sub-span boundaries always fall on character boundaries (they are placed only at +line starts or at ASCII boundary bytes), so every span slices `prompt` into valid +text and the pieces concatenate back to `prompt` exactly. ::: test -name: bundle_since across an unknown state is unsupported for legacy -given: { fixture: RS_BUNDLE, query: bundle_since, baseline: unknown } -expect: { error: UnsupportedForLegacy } +name: an empty prompt segments into an empty block-index +given: { prompt: "" } +expect: { atom_count: 0, sub_span_count: 0, seg_version: 1 } ::: -## The file-history query +### Lines -Expose a public struct `FileHistoryEntry` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`, `serde::Serialize`, `serde::Deserialize`, with `#[serde(deny_unknown_fields)]`), -one step at which a generated path changed. Public fields: +Split `prompt` into lines on the newline byte `\n` (`0x0A`). Each line consists of +its content bytes plus its terminating `\n` if it has one; the final line has no +terminating `\n` when the document does not end in one. Every byte belongs to +exactly one line, so the lines concatenate back to `prompt`. Lines are numbered +from 1. A line's **content** is its bytes with a single trailing `\n` removed if +present. An empty prompt has zero lines; the prompt `\n` has exactly one line +whose content is empty. -- `seq: u64`, serde field name `seq`. The global ordinal of the event that committed the - change, the store's gen number for that step. -- `change: store_diff::ChangeKind`, serde field name `change`. Whether the path was - added, removed, or modified at that step. -- `path: String`, serde field name `path`. The generated path, echoing the query's - path. -- `old_hash: Option`, serde field name `oldHash`, skipped when `None`. The - path's hash before the change, present for a modified or removed step. -- `new_hash: Option`, serde field name `newHash`, skipped when `None`. The - path's hash after the change, present for a modified or added step. +Classify a line by its content (all byte tests are on ASCII bytes): -Expose `file_history(provider: &P, module: &str, target: &str, path: -&str) -> Result, ReduceError>`. Trees are per module and per -target, so the query names the module and target whose generated tree carries the -path; the blame consumer already resolves a file to its owning module. A module's whole -recorded history lives on its linear event chain, not on the snapshot spine: after -migration the snapshot spine may hold only a single head snapshot while every recorded -gen survives as an event on the module's chain, so this query walks that chain and never -the snapshot spine. Walking the chain reaches every recorded step both on the migrated -store and on a future store whose gens also extend the snapshot spine, because every gen -appends an event to the module's chain in either regime. Walk the module's event chain -from its head event backward through first parents, the same private module event-chain -walk `event_graph` uses, so every event that ever changed the target's tree is visited -in head-first order. For each event, resolve its `state_after` to the module's generated -tree root for `target` as the new side and its `state_before` to that same target's -generated tree root as the old side, each through the same state resolution `state_view` -performs: a `State` name loads and validates that module-state and takes its target's -`generated_tree` root, an absent side (an added-from-nothing event) contributes a `None` -root, and an `unknown` sentinel side likewise contributes a `None` root so a legacy step -degrades to an added or removed appearance rather than erroring. Load each present root -and its subtrees into the object map, call `store_tree::diff_trees(old_root, new_root, -objects)`, and for every returned `TreeChange` whose `path` equals the queried `path`, -push a `FileHistoryEntry` carrying the event's `ordinal` as `seq`, the projected -`ChangeKind` and hashes (an `Added` change sets `new_hash` only, a `Removed` change sets -`old_hash` only, a `Modified` change sets both), and the path. The head event is -head-referent, so a corrupt or absent head event is an error; an ancestor event reached -through the chain terminates the walk gracefully at the genesis boundary when absent, and -a module whose root entry `event_head` is the genesis sentinel has an empty chain and so -an empty history. Entries are ordered head-first, most recent first, the order the chain -walk yields. Over legacy history the tree-diff entries render exactly where both of a -step's states materialize; where a legacy step referenced an `unknown` state, that side -is the empty tree and the step still surfaces its added or removed change against it. +- **blank**: content is empty, or every byte of content is a space (`0x20`) or a + tab (`0x09`). +- **fence**: content, after removing any leading spaces and tabs, begins with + three consecutive backtick bytes (each `0x60`). +- **heading**: the first byte of content is `#` (`0x23`). +- **list-item**: content, after removing any leading spaces and tabs, either + begins with one of `-` `*` `+` immediately followed by a space, or begins with a + non-empty run of ASCII digits immediately followed by `.` or `)` and then a + space. +- **frontmatter fence**: content is exactly the three bytes `---`. +- **top-field**: the content's first byte is an ASCII letter, digit, or underscore, + and the maximal leading run of bytes drawn from ASCII letters, digits, + underscore, and hyphen is immediately followed by a colon `:`. The asymmetry is + intentional and exact: a hyphen may appear inside the run but never as the first + byte, so `a-b:` is a top-field while `-b:` is not, which keeps a `- ` list-item + marker from ever reading as a top-field key. -::: test -name: file_history over a path that changed reports its change entries -given: { fixture: RS_HISTORY, query: file_history, path: "greeting.rs" } -expect: { history_len: 2, first_change: modified, first_path: "greeting.rs" } -::: +### Regions -::: test -name: file_history over a path that never existed is empty -given: { fixture: RS_HISTORY, query: file_history, path: "missing.rs" } -expect: { history_len: 0 } -::: +If the document has at least one line and line 1's content is exactly `---`, look +for the next line (line 2 onward) whose content is exactly `---`; call it the +closing fence. If a closing fence exists at line `c`, the frontmatter region is +lines 1 through `c` inclusive and the body region is lines `c + 1` onward. If line +1 is `---` but no closing fence exists, or line 1 is not `---`, there is no +frontmatter and the entire document is body. -## The event-by-ordinal query +### Atoms -Expose `event_by_ordinal(provider: &P, ordinal: u64) -> -Result, ReduceError>`. The global ordinal names one event on -one module's event chain, so the lookup walks module event chains, never the snapshot -spine and never a body scan of unrelated objects: after migration every recorded event -lives on a module chain while the snapshot spine may hold only the head snapshot, so a -spine walk would reach only the head ordinal. If `ordinal` is zero return `Ok(None)` -(ordinals start at one). Load HEAD; if HEAD is `None`, return `Ok(None)`. Otherwise, for -each module named in the head snapshot's root, walk that module's event chain from its -head event backward through first parents, the same private module event-chain walk -`event_graph` uses, and return `Ok(Some(event))` for the first event whose `ordinal` -equals the queried ordinal. A root entry whose `event_head` is the genesis sentinel -(empty recorded history) is skipped without a load: it names no event, so it can never -match an ordinal and never raises `MissingObject`. The head event of each walked chain is -head-referent, so a corrupt or absent head event is an error, while an ancestor event -absent on the way back is the genesis boundary and terminates that chain's walk -gracefully. When no chain carries the ordinal, return `Ok(None)`. Because event ordinals -are globally unique across the module chains that partition all recorded events, at most -one event ever matches the queried ordinal. +Atoms tile the whole document: every line belongs to exactly one atom, atoms are +contiguous and in document order, and `atomIndex` numbers them from 0. An atom's +`byteStart` is its first line's starting byte offset, its `byteEnd` is its last +line's ending byte offset, its `lineStart` and `lineEnd` are its first and last +line numbers, and its `contentHash` is `hash::content_hash` of the prompt slice +`[byteStart, byteEnd)`. -::: test -name: event_by_ordinal returns the event committed at that ordinal -given: { fixture: RS_CLEAN, query: event_by_ordinal, ordinal: 1 } -expect: { event_ordinal: 1, event_kind: gen } -::: +Determine atom boundaries by choosing which lines start a new atom. -::: test -name: event_by_ordinal past the head sequence is none -given: { fixture: RS_CLEAN, query: event_by_ordinal, ordinal: 2 } -expect: { is_none: true } -::: +In the frontmatter region (when present): the opening fence line (line 1) starts +an atom; every top-field line strictly between the two fences starts an atom; and +the closing fence line starts an atom. Lines that are neither the fences nor +top-field lines (indented values, list items, blank lines inside the +frontmatter) attach to the atom of the preceding boundary. Frontmatter atoms are +structural and never carry sub-spans. + +In the body region, scan the body lines in order carrying a boolean +`inside a fenced block` (initially false) and the classification context of the +previous body line. The first body line always starts an atom. For each body line +after the first: + +- If currently inside a fenced block: this line does not start an atom; if its + content is a fence, the fenced block now closes (record that the previous line + closed a fence and clear the inside-a-fenced-block flag), otherwise it is fenced + content. Move on. +- Otherwise the line starts a new atom if and only if any of these hold: its + content is a fence; or its content is a heading; or its content is a list-item; + or its content is non-blank and the previous body line was blank, was a heading, + or closed a fenced block. +- After deciding, update the context: a fence line opens a fenced block (set the + flag); otherwise remember whether this line was blank, a heading, a list-item, + or plain. + +This makes each heading its own atom, each fenced block one atom from its opening +fence through its closing fence (an unterminated fence runs to the end of the +document), each list item its own atom, and each paragraph one atom; trailing +blank lines are absorbed into the atom they follow, and a blank line never starts +an atom. ::: test -name: event_by_ordinal over a current-state-only head skips the historyless root and is none -given: { fixture: RS_CURRENT_ONLY, query: event_by_ordinal, ordinal: 1 } -expect: { is_none: true } +name: a prompt of a single newline segments into one blank atom with no sub-spans +given: { prompt: "\n" } +expect: + sub_span_count: 0 + atoms: + - { atomIndex: 0, byteStart: 0, byteEnd: 1, lineStart: 1, lineEnd: 1, text: "\n" } ::: -## The prompt-diff-of-event query +### Sub-spans -Expose a public struct `PromptDiff` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, -`serde::Serialize`, `serde::Deserialize`, with `#[serde(deny_unknown_fields)]`), the -prompt-side diff between one event's before and after states. Public fields: +Compute sub-spans only for a **prose atom**: a body atom whose first line is +neither a heading nor a fence, and whose byte slice contains at least one +non-whitespace byte. Frontmatter atoms, heading atoms, fenced-block atoms, and +atoms that are entirely whitespace produce no sub-spans. For a prose atom, run the +sentence splitter below over the atom's byte slice; it yields one or more spans +that tile the atom's bytes. Each becomes a `SubSpanRecord` whose `parentAtomIndex` +is the atom's `atomIndex`, whose `subIndex` numbers the spans from 0 within the +atom, whose `byteStart` and `byteEnd` are the sub-span's absolute byte offsets into +`prompt`, whose `lineStart` and `lineEnd` are the one-based line numbers of the +sub-span's first byte and of its last byte, and whose `contentHash` is +`hash::content_hash` of the sub-span's exact bytes. -- `legacy_partial: bool`, serde field name `legacyPartial`. `true` when the event's - `state_before` or `state_after` is the `unknown` sentinel, so the diff could not be - materialized and `changes` is empty; `false` when both sides were concrete or absent - and the diff was computed. -- `changes: Vec`, serde field name `changes`. The prompt-side - changes in emission order, empty when `legacy_partial` is `true`. +A sub-span is never empty: `byteEnd` is strictly greater than `byteStart`, because +the splitter emits a boundary only where a following sentence opener exists and +the final span runs to the non-empty atom's end. Its last byte is therefore the +byte at `byteEnd - 1`, and `lineEnd` is the one-based number of the line +containing that byte. So a sub-span that ends in its own line's terminating +newline has that newline as its last byte and takes its `lineEnd` from that line, +not from the line after it. -Expose `prompt_diff_of(provider: &P, event: &store_objects::Event) --> Result`. If either `state_before` or `state_after` is -`Some(store_objects::StateRef::Unknown)`, state materialization is unsupported across -the sentinel: return `PromptDiff { legacy_partial: true, changes: Vec::new() }`. This -is the partial legacy capability: the reducer never fabricates a materialized diff over -an `unknown` state. Otherwise resolve the old side (an absent `state_before` is treated -as an empty prior, so an added-from-nothing event diffs against an empty block index and -empty prompt) and the new side from their concrete state names: load and validate each -block index, and read each prompt blob's bytes as a string. Compute -`store_diff::diff_prompt(&old_index, &old_prompt, &new_index, &new_prompt)` and return -`PromptDiff { legacy_partial: false, changes }`. The event's module is not needed to -diff its prompt sides. +The splitter is conservative: an atom it cannot split with certainty stays a +single sub-span equal to the whole atom, so ambiguity can only coarsen a bundle, +never corrupt one. The sub-spans of one atom always concatenate back to that +atom's bytes, tiling the atom with no gaps and no overlaps. ::: test -name: prompt_diff_of a concrete event returns its prompt changes -given: { fixture: RS_DIFF, query: prompt_diff_of, event: head } -expect: { prompt_diff_matches_build: true, legacy_partial: false } +name: a single unterminated line segments into one atom and one conservative sub-span +given: { prompt: "Hello there." } +expect: + atoms: + - { atomIndex: 0, byteStart: 0, byteEnd: 12, lineStart: 1, lineEnd: 1, text: "Hello there." } + subSpans: + - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 12, text: "Hello there." } ::: -::: test -name: prompt_diff_of a legacy event with unknown states is legacy partial -given: { fixture: RS_LEGACY, query: prompt_diff_of, event: legacy } -expect: { legacy_partial: true } -::: +### The sentence splitter -## The drift-baseline query +Given the atom's bytes `t`, scan left to right carrying a boolean `inside +backticks` (initially false) and the current sub-span start offset (initially 0): -Expose a public struct `DriftBaseline` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, -`serde::Serialize`, `serde::Deserialize`, with `#[serde(deny_unknown_fields)]`), the -exact head-anchored baseline the reconcile consumer diffs against. Public fields: +- If the current byte is a backtick `` ` `` (`0x60`), flip `inside backticks` and + advance. +- Else if `inside backticks` is true, advance (a terminator inside backticks never + ends a sentence). +- Else if the current byte is a **terminator** (`.` `0x2E`, `!` `0x21`, or `?` + `0x3F`): + - If it is a **protected dot** (rules below), advance. + - Otherwise look ahead from just after the terminator: skip a run of whitespace + bytes (space `0x20`, tab `0x09`, newline `0x0A`, carriage return `0x0D`); if at + least one whitespace byte was skipped and the next byte exists and is an + **opener** (an uppercase ASCII letter `A` through `Z`, or an ASCII digit `0` + through `9`), a sentence boundary falls at that opener: emit the sub-span from + the current start up to (not including) the opener's offset, set the current + start to the opener's offset, and continue scanning from there. + - If the look-ahead does not find whitespace-then-opener, advance. +- Else advance. -- `generated_tree: String`, serde field name `generatedTree`. The head generated tree - root hash for the target. -- `tree: store_tree::Tree`, serde field name `tree`. The head generated tree object - itself, resolved and validated. +After the scan, emit the final sub-span from the current start to the end of `t`. +If the scan found no boundary at all, the whole atom is a single sub-span. The +trailing whitespace after a terminator always attaches to the sentence it ends, so +the boundary sits exactly at the next sentence's opener. -Expose `drift_baseline(provider: &P, module: &str, target: &str) -> -Result`. Load HEAD; if `module` is absent from its root, or -the head module-state has no entry for `target`, return `ReduceError::MissingObject` -with the missing module-state name, because a drift check without a baseline is a loud -failure, not silent success. Otherwise resolve the target's `generated_tree` root, load -and validate that tree object, and return the root hash and the tree. This is exact and -head-anchored: no patch replay, the exact blobs to diff against. +Only the terminator `.` can be protected; `!` and `?` always end a sentence when +followed by whitespace and an opener. A `.` at offset `i` in `t` is a **protected +dot** if any of these hold: + +- **version, path, or decimal**: the bytes at `i - 1` and `i + 1` both exist and + are both ASCII alphanumeric (a digit or an ASCII letter), as in `v1.5`, + `main.rs`, `store_hash.rs`, `1.5`, or `a.b`. +- **two-letter initialism**: `i` is at least 3, the byte at `i - 1` is an ASCII + letter, the byte at `i - 2` is `.`, and the byte at `i - 3` is an ASCII letter, + protecting the trailing dot of `e.g.` and `i.e.` (their inner dot is already + protected by the version-path-decimal rule). +- **abbreviation**: the maximal run of ASCII letters ending at `i - 1`, the word + immediately before the dot, exactly equals one of the pinned abbreviations, + matched case-sensitively: `etc`, `vs`, `cf`, `al`, `Mr`, `Mrs`, `Ms`, `Dr`, + `St`, `Fig`, `No`. ::: test -name: drift_baseline returns the head generated tree for the target -given: { fixture: RS_CLEAN, query: drift_baseline } -expect: { drift_baseline_is_head_tree: true } +name: a backticked code span is never split into sub-spans +given: { prompt: "Call `a.b.c` now. Done.\n" } +expect: + subSpans: + - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 18, text: "Call `a.b.c` now. " } + - { parentAtomIndex: 0, subIndex: 1, byteStart: 18, byteEnd: 24, text: "Done.\n" } ::: -## The tree-at query +::: test +name: a version number is never split into sub-spans +given: { prompt: "Use v1.5 here. Stop.\n" } +expect: + subSpans: + - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 15, text: "Use v1.5 here. " } + - { parentAtomIndex: 0, subIndex: 1, byteStart: 15, byteEnd: 21, text: "Stop.\n" } +::: -Expose `tree_at(provider: &P, module: &str, target: &str) -> -Result, ReduceError>`. Load HEAD; if HEAD is `None`, or -`module` is absent from its root, or the head module-state has no entry for `target`, -return `Ok(None)`. Otherwise resolve the target's `generated_tree` root, load and -validate that tree object, and return `Ok(Some(tree))`. This is the head generated -tree, exact at head. +::: test +name: a file path is never split into sub-spans +given: { prompt: "Edit main.rs now. Stop.\n" } +expect: + subSpans: + - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 18, text: "Edit main.rs now. " } + - { parentAtomIndex: 0, subIndex: 1, byteStart: 18, byteEnd: 24, text: "Stop.\n" } +::: ::: test -name: tree_at returns the head generated tree for the target -given: { fixture: RS_CLEAN, query: tree_at } -expect: { tree_entry_name: "greeting.rs" } +name: the e.g. abbreviation is never split into sub-spans +given: { prompt: "See e.g. this. Next one.\n" } +expect: + subSpans: + - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 15, text: "See e.g. this. " } + - { parentAtomIndex: 0, subIndex: 1, byteStart: 15, byteEnd: 25, text: "Next one.\n" } ::: ::: test -name: tree_at for a module absent from head is none -given: { fixture: RS_CLEAN, query: tree_at, module: absent } -expect: { is_none: true } +name: the i.e. abbreviation is never split into sub-spans +given: { prompt: "Use i.e. that. Next one.\n" } +expect: + subSpans: + - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 15, text: "Use i.e. that. " } + - { parentAtomIndex: 0, subIndex: 1, byteStart: 15, byteEnd: 25, text: "Next one.\n" } ::: -## The state-view query +::: test +name: the etc. abbreviation is never split into sub-spans +given: { prompt: "Foo, etc. bar baz.\n" } +expect: + sub_span_count: 1 + subSpans: + - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 19, text: "Foo, etc. bar baz.\n" } +::: -This query is additive over the committed reducer and resolves one arbitrary state -name (an event's `state_before` or `state_after`) to the concrete per-event view its -consumers diff against. It is the public completion of the resolution `bundle_since` -performs internally: HALT 1 BLOCKER B observed that `store_reduce` exposed no public -"resolve a state name to its module-state and generated tree" query, so the blame -render store variant had to hand-walk module-states; `state_view` is that resolution, -so no consumer reimplements it. It resolves both consumer needs from one call: the -per-event prompt body two of these views diff, and the per-event generated tree two of -these views diff for a target through the committed `store_tree` walk. +::: test +name: a plain two-sentence line splits at its terminator +given: { prompt: "A cat sat. A dog ran.\n" } +expect: + subSpans: + - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 11, text: "A cat sat. " } + - { parentAtomIndex: 0, subIndex: 1, byteStart: 11, byteEnd: 22, text: "A dog ran.\n" } +::: -Expose a public struct `StateView` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, -`serde::Serialize`, `serde::Deserialize`, with `#[serde(deny_unknown_fields)]`), the -resolved view of one state for one target. Public fields: +::: test +name: a digit opener starts a new sentence sub-span +given: { prompt: "Item one. 2 is next.\n" } +expect: + subSpans: + - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 10, text: "Item one. " } + - { parentAtomIndex: 0, subIndex: 1, byteStart: 10, byteEnd: 21, text: "2 is next.\n" } +::: -- `legacy_partial: bool`, serde field name `legacyPartial`. `true` when the state is - `Some(store_objects::StateRef::Unknown)`, the migrated `unknown` sentinel, so the - state could not be materialized and every other field is empty or `None`; `false` - when the state was concrete or absent and the view was resolved. This mirrors - `prompt_diff_of`'s partial-legacy contract exactly: the reducer never fabricates a - materialized view over an `unknown` state. -- `prompt_blob: Option`, serde field name `promptBlob`, skipped from - serialization when `None`. The resolved module-state's prompt blob object name; `None` - when the state is absent (no prior) or `legacy_partial`. -- `prompt: String`, serde field name `prompt`. The resolved module-state's prompt body, - read from its prompt blob's bytes as a UTF-8 string exactly as `bundle_since` and - `prompt_diff_of` read a prompt blob; the empty string when the state is absent or - `legacy_partial`. This is the exact prompt body a prompt-diff consumer diffs; it is - never re-encoded and never a lossy round-trip. -- `generated_tree: Option`, serde field name `generatedTree`, skipped when - `None`. The requested `target`'s generated tree root object name in this state; `None` - when the state is absent or `legacy_partial`, or when the resolved module-state has no - entry for `target` in its `targets` map. -- `tree_objects: std::collections::BTreeMap`, serde field - name `treeObjects`. The requested target's generated tree root and every subtree - reachable through its `Dir` entries, each loaded, validated, and inserted under its - own object name, exactly as `bundle_since` builds its tree object map for one root. - Empty when `generated_tree` is `None`. A files-touched consumer merges the - `tree_objects` of two `StateView`s and calls `store_tree::diff_trees(old_root, - new_root, merged)` over them, so it never reimplements the tree walk. +### Reconstructing the prompt -Expose `state_view(provider: &P, state: Option<&store_objects::StateRef>, -target: &str) -> Result`, taking the state as an `Option` so an -absent `state_before` (an added-from-nothing event) is modeled directly: +Expose `reconstruct(index: &BlockIndex, prompt: &str) -> String`, the exact byte +reconstruction: concatenate, in `atomIndex` order, the prompt slice `[byteStart, +byteEnd)` of each atom, and return the result. For an index produced by `segment` +over the same `prompt`, `reconstruct` returns a string equal to `prompt`, because +the atoms tile the document with no gaps and no overlaps. -- If `state` is `Some(store_objects::StateRef::Unknown)`, return `StateView { - legacy_partial: true, prompt_blob: None, prompt: String::new(), generated_tree: None, - tree_objects: empty }` without reading any object, exactly as `prompt_diff_of` and - `bundle_since` short-circuit the `unknown` sentinel before any provider read. -- If `state` is `None`, there is no prior state to resolve: return `StateView { - legacy_partial: false, prompt_blob: None, prompt: String::new(), generated_tree: None, - tree_objects: empty }`, the empty prior an added-from-nothing side diffs against, the - same empty prior `prompt_diff_of` uses for an absent `state_before`. -- If `state` is `Some(store_objects::StateRef::State(name))`, resolve it head-referent: - load and validate the module-state named `name` (a named-but-absent state object is - `ReduceError::MissingObject` with that name, and a corrupt one is - `ReduceError::CorruptObject`, through the trusted gate, never a silent empty view). - Set `prompt_blob` to `Some(state.prompt_blob)`; load that prompt blob as a `Blob` - object and set `prompt` to its bytes read as a UTF-8 string. For the requested - `target`, if the module-state's `targets` map has an entry, set `generated_tree` to - `Some(that target's generated_tree root)` and build `tree_objects` by loading that - root tree and every subtree reachable through its `Dir` entries under their own names; - if the module-state has no entry for `target`, leave `generated_tree` `None` and - `tree_objects` empty. Return the assembled `StateView` with `legacy_partial: false`. +## Fixtures the tests pin -This query is exact over concrete and absent states and explicitly partial over the -`unknown` sentinel, honoring the same legacy contract every other query declares. It -adds no new spine walk: it resolves one named state object head-referent, precisely the -resolution `bundle_since` already performs for its two states, now exposed as one query. +These fixtures are referenced by name in the test suite. The hash constants are +each a sixty-four-character string of a single repeated character: `A` is +sixty-four `a` characters, `B` sixty-four `b`, `C` sixty-four `c`, and `D` +sixty-four `d`. All are well-formed lowercase-hex digests. + +- **BI_MIN**: a `BlockIndex` with `segVersion` = 1, one atom `{atomIndex: 0, + contentHash: A, byteStart: 0, byteEnd: 5, lineStart: 1, lineEnd: 1}`, and no + sub-spans. Its canonical bytes are exactly + `{"atoms":[{"atomIndex":0,"byteEnd":5,"byteStart":0,"contentHash":"","lineEnd":1,"lineStart":1}],"segVersion":1,"subSpans":[]}` + followed by one newline; its complete envelope bytes are exactly the header + `napl-store block-index v1 190\n` immediately followed by those canonical bytes, + the full byte string with nothing after the payload; its object name is + `1660deacdbb4727b6f456c87b2866dfbcbd95e00bf9eda2b6936c39fcf136d28`. ::: test -name: state_view over a present head state resolves the prompt body and the target generated tree -given: { fixture: RS_CLEAN, query: state_view, state: head, target: rust } -expect: { legacy_partial: false, prompt: "The greeting module.\n", generated_tree_is_head_tree: true, tree_entry_name: "greeting.rs" } +name: a minimal block-index canonicalizes to its pinned bytes +given: { fixture: BI_MIN } +expect: { canonical_bytes: "{\"atoms\":[{\"atomIndex\":0,\"byteEnd\":5,\"byteStart\":0,\"contentHash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"lineEnd\":1,\"lineStart\":1}],\"segVersion\":1,\"subSpans\":[]}\n" } ::: ::: test -name: state_view over an absent prior state is the empty prior view -given: { fixture: RS_CLEAN, query: state_view, state: none, target: rust } -expect: { legacy_partial: false, prompt: "", prompt_blob_is_none: true, generated_tree_is_none: true, tree_objects_empty: true } +name: a minimal block-index names itself by its pinned digest +given: { fixture: BI_MIN } +expect: { object_name: "1660deacdbb4727b6f456c87b2866dfbcbd95e00bf9eda2b6936c39fcf136d28" } ::: ::: test -name: state_view over an unknown legacy state is legacy partial -given: { fixture: RS_CLEAN, query: state_view, state: unknown, target: rust } -expect: { legacy_partial: true, prompt: "", prompt_blob_is_none: true, generated_tree_is_none: true, tree_objects_empty: true } +name: a minimal block-index frames its pinned envelope bytes +given: { fixture: BI_MIN } +expect: { envelope_bytes: "napl-store block-index v1 190\n{\"atoms\":[{\"atomIndex\":0,\"byteEnd\":5,\"byteStart\":0,\"contentHash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"lineEnd\":1,\"lineStart\":1}],\"segVersion\":1,\"subSpans\":[]}\n" } ::: ::: test -name: state_view over a present state for a target absent from the state has no generated tree -given: { fixture: RS_CLEAN, query: state_view, state: head, target: typescript } -expect: { legacy_partial: false, prompt: "The greeting module.\n", generated_tree_is_none: true, tree_objects_empty: true } +name: a minimal block-index round-trips through the read verifier +given: { fixture: BI_MIN } +expect: { verify_ok: true, kind: block-index } ::: ::: test -name: state_view over a state whose module-state object is absent is a missing-object error -given: { fixture: RS_CLEAN_MISSING_STATE, query: state_view, state: head, target: rust } -expect: { error: MissingObject } +name: a minimal block-index round-trips back to an equal value +given: { fixture: BI_MIN } +expect: { round_trip_equal: true } ::: -## The move-records and reword-chain queries +- **BI_RICH**: a `BlockIndex` with `segVersion` = 1, two atoms `{0, A, 0, 10, 1, + 2}` and `{1, B, 10, 20, 3, 3}` (in the field order atomIndex, contentHash, + byteStart, byteEnd, lineStart, lineEnd), and two sub-spans `{parentAtomIndex: 0, + subIndex: 0, contentHash: C, byteStart: 0, byteEnd: 5, lineStart: 1, lineEnd: + 1}` and `{parentAtomIndex: 0, subIndex: 1, contentHash: D, byteStart: 5, byteEnd: + 10, lineStart: 1, lineEnd: 2}`. Its object name is + `e9fccb1f9979d73754a520d7622fe2bcd296e7c58193a8fadfbedea12c08c902`. -Expose `move_records(provider: &P, module: &str) -> -Result, ReduceError>` and `reword_chain(provider: -&P, module: &str) -> Result, ReduceError>`. Each walks the -module's linear event chain from its head event backward through first parents, and -collects, in head-first order, the events whose payload is a `Move` for `move_records` -or a `Reword` for `reword_chain`. Load HEAD; if HEAD is `None`, or `module` is absent -from its root, or the module's root entry `eventHead` is the genesis sentinel (empty -recorded history), return an empty vector. The head event is head-referent, but ancestor -events reached through the chain terminate the walk gracefully at the genesis boundary -when absent. A chain with no matching event yields an empty vector. Rewords are -post-migration only, so a legacy chain contributes no reword; `reword_chain` is exact -over post-migration events. +::: test +name: a rich block-index canonicalizes to its pinned bytes +given: { fixture: BI_RICH } +expect: { canonical_bytes: "{\"atoms\":[{\"atomIndex\":0,\"byteEnd\":10,\"byteStart\":0,\"contentHash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"lineEnd\":2,\"lineStart\":1},{\"atomIndex\":1,\"byteEnd\":20,\"byteStart\":10,\"contentHash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"lineEnd\":3,\"lineStart\":3}],\"segVersion\":1,\"subSpans\":[{\"byteEnd\":5,\"byteStart\":0,\"contentHash\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"lineEnd\":1,\"lineStart\":1,\"parentAtomIndex\":0,\"subIndex\":0},{\"byteEnd\":10,\"byteStart\":5,\"contentHash\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"lineEnd\":2,\"lineStart\":1,\"parentAtomIndex\":0,\"subIndex\":1}]}\n" } +::: ::: test -name: move_records returns the module's move events -given: { fixture: RS_MOVED, query: move_records } -expect: { chain_len: 1, chain_kind: move } +name: a rich block-index names itself by its pinned digest +given: { fixture: BI_RICH } +expect: { object_name: "e9fccb1f9979d73754a520d7622fe2bcd296e7c58193a8fadfbedea12c08c902" } ::: ::: test -name: move_records over a chain with no move is empty -given: { fixture: RS_CLEAN, query: move_records } -expect: { chain_len: 0 } +name: a rich block-index round-trips back to an equal value +given: { fixture: BI_RICH } +expect: { round_trip_equal: true } ::: +- **SEG_DOC**: the prompt string + `---\nmodule: greeting\ndeps: []\n---\nGreet a person by name. Be warm.\n\nReturn the string.\n`. + Segmented under `SEG_VERSION`, its block-index object name is + `e8b2c6384b9c3e22af0d49ed99aa4a739f259f32c35035ccbd1d079990fa0e6e`; segmented + under segmentation version 2 it keeps the same atoms and sub-spans but its name + becomes `d7e094e7363b8eeb26302eefcaf0e6cdffda1908e296235b9312b5800e0e94e9`. + ::: test -name: reword_chain returns the module's reword events -given: { fixture: RS_REWORD, query: reword_chain } -expect: { chain_len: 1, chain_kind: reword } +name: SEG_DOC segments into six document-tiling atoms +given: { fixture: SEG_DOC } +expect: + atoms: + - { atomIndex: 0, byteStart: 0, byteEnd: 4, lineStart: 1, lineEnd: 1, text: "---\n" } + - { atomIndex: 1, byteStart: 4, byteEnd: 21, lineStart: 2, lineEnd: 2, text: "module: greeting\n" } + - { atomIndex: 2, byteStart: 21, byteEnd: 30, lineStart: 3, lineEnd: 3, text: "deps: []\n" } + - { atomIndex: 3, byteStart: 30, byteEnd: 34, lineStart: 4, lineEnd: 4, text: "---\n" } + - { atomIndex: 4, byteStart: 34, byteEnd: 68, lineStart: 5, lineEnd: 6, text: "Greet a person by name. Be warm.\n\n" } + - { atomIndex: 5, byteStart: 68, byteEnd: 87, lineStart: 7, lineEnd: 7, text: "Return the string.\n" } ::: ::: test -name: reword_chain over a chain with no reword is empty -given: { fixture: RS_CLEAN, query: reword_chain } -expect: { chain_len: 0 } +name: SEG_DOC produces its three sentence sub-spans +given: { fixture: SEG_DOC } +expect: + subSpans: + - { parentAtomIndex: 4, subIndex: 0, byteStart: 34, byteEnd: 58, lineStart: 5, lineEnd: 5, text: "Greet a person by name. " } + - { parentAtomIndex: 4, subIndex: 1, byteStart: 58, byteEnd: 68, lineStart: 5, lineEnd: 6, text: "Be warm.\n\n" } + - { parentAtomIndex: 5, subIndex: 0, byteStart: 68, byteEnd: 87, lineStart: 7, lineEnd: 7, text: "Return the string.\n" } ::: -## The event-graph query +::: test +name: SEG_DOC reconstructs its exact prompt bytes from its atoms +given: { fixture: SEG_DOC } +expect: { reconstructs: true } +::: -Expose a public struct `EventNode` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, -`serde::Serialize`, `serde::Deserialize`, with `#[serde(deny_unknown_fields)]`), one -node of a module's event graph. Public fields: +::: test +name: SEG_DOC names its block-index object by its pinned digest +given: { fixture: SEG_DOC } +expect: { object_name: "e8b2c6384b9c3e22af0d49ed99aa4a739f259f32c35035ccbd1d079990fa0e6e" } +::: -- `event: store_objects::Event`, serde field name `event`. The event itself. -- `materialized: bool`, serde field name `materialized`. `true` when both the event's - before and after states are concrete or absent and could be materialized, `false` for - a legacy node whose `state_before` or `state_after` is the `unknown` sentinel. -- `state_before: Option`, serde field name `stateBefore`, skipped when `None`. - The concrete before-state name when known; `None` when the before state is absent or - the `unknown` sentinel. -- `state_after: Option`, serde field name `stateAfter`, skipped when `None`. The - concrete after-state name when known; `None` when the after state is absent or the - `unknown` sentinel. +::: test +name: SEG_DOC re-segmented under segmentation version two keeps its layout but bumps segVersion and its name +given: { fixture: SEG_DOC, seg_version: 2 } +expect: { segVersion: 2, atoms_equal_version_one: true, sub_spans_equal_version_one: true, object_name: "d7e094e7363b8eeb26302eefcaf0e6cdffda1908e296235b9312b5800e0e94e9" } +::: -Expose `event_graph(provider: &P, module: &str) -> -Result, ReduceError>`. Load HEAD; if HEAD is `None`, or `module` is -absent from its root, or the module's root entry `eventHead` is the genesis sentinel -(empty recorded history), return an empty vector: a current-state-only module has no -event to graph. Otherwise walk the module's event chain from its head event -backward through first parents, in head-first order. For each event build an `EventNode` -whose `event` is the event; whose `state_before` and `state_after` are the concrete -state names when the corresponding `StateRef` is `State`, and `None` when it is `Unknown` -or absent; and whose `materialized` is `false` when either state is the `unknown` -sentinel and `true` otherwise. A legacy node carries its payload and classification (in -its own `event`) with materialized states unavailable, exactly the partial legacy -capability the site fixture builder and the wasm consumer read. The head event is -head-referent; ancestor events terminate the walk at the genesis boundary when absent. +- **SEG_PROTECTED**: a single body line and a trailing newline. Its exact bytes are + `Ship v1.5 now. Edit store_hash.rs first. See ` then + `e.g. tests, etc. Keep ` (each ending in a space), then a single backtick byte, + then `a.b`, then a single backtick byte, then ` literal. Fin.` and a final + newline. Equivalently the fixture is the Rust string literal + `"Ship v1.5 now. Edit store_hash.rs first. See e.g. tests, etc. Keep \`a.b\` literal. Fin.\n"`. + This byte string is self-contained here and is the authority; the suite's pinned + sub-span `text` values are derived from it, not the reverse. ::: test -name: event_graph over a concrete chain marks the node materialized -given: { fixture: RS_CLEAN, query: event_graph } -expect: { node_count: 1, head_materialized: true } +name: SEG_PROTECTED keeps its protected spans in one atom and four sub-spans +given: { fixture: SEG_PROTECTED } +expect: + atom_count: 1 + subSpans: + - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 15, text: "Ship v1.5 now. " } + - { parentAtomIndex: 0, subIndex: 1, byteStart: 15, byteEnd: 41, text: "Edit store_hash.rs first. " } + - { parentAtomIndex: 0, subIndex: 2, byteStart: 41, byteEnd: 82, text: "See e.g. tests, etc. Keep `a.b` literal. " } + - { parentAtomIndex: 0, subIndex: 3, byteStart: 82, byteEnd: 87, text: "Fin.\n" } ::: ::: test -name: event_graph over a legacy chain marks the legacy node not materialized -given: { fixture: RS_LEGACY, query: event_graph } -expect: { node_count: 2, head_materialized: true, legacy_materialized: false } +name: SEG_PROTECTED reconstructs its exact prompt bytes +given: { fixture: SEG_PROTECTED } +expect: { reconstructs: true } ::: +- **SEG_HEADING_FENCE**: eight lines, exactly the Rust + string literal + `"# Title\n\nBody one. Body two.\n\n\`\`\`rust\nlet x = 1.5;\n\`\`\`\nTail here.\n"`: + a heading atom `# Title\n\n`, a paragraph atom `Body one. Body two.\n\n`, a + fenced-block atom whose first line is three backtick bytes then `rust`, whose + middle line is `let x = 1.5;`, and whose last line is three backtick bytes then a + newline, and a paragraph atom `Tail here.\n`. This byte string is self-contained + here and is the authority; the suite's pinned atom `text` values are derived from + it. + ::: test -name: an event graph node round-trips through its serialization unchanged -given: { fixture: RS_CLEAN, query: event_graph, roundtrip: node } -expect: { round_trip_equal: true } +name: "SEG_HEADING_FENCE segments its heading, paragraph, fenced block, and tail into four atoms" +given: { fixture: SEG_HEADING_FENCE } +expect: + atoms: + - { atomIndex: 0, byteStart: 0, byteEnd: 9, lineStart: 1, lineEnd: 2, text: "# Title\n\n" } + - { atomIndex: 1, byteStart: 9, byteEnd: 30, lineStart: 3, lineEnd: 4, text: "Body one. Body two.\n\n" } + - { atomIndex: 2, byteStart: 30, byteEnd: 55, lineStart: 5, lineEnd: 7, text: "```rust\nlet x = 1.5;\n```\n" } + - { atomIndex: 3, byteStart: 55, byteEnd: 66, lineStart: 8, lineEnd: 8, text: "Tail here.\n" } ::: ::: test -name: event_graph over a current-state-only module walks no events and is empty -given: { fixture: RS_CURRENT_ONLY, query: event_graph } -expect: { node_count: 0 } +name: "SEG_HEADING_FENCE gives sub-spans only to its prose atoms, never the heading or fenced block" +given: { fixture: SEG_HEADING_FENCE } +expect: + subSpans: + - { parentAtomIndex: 1, subIndex: 0, byteStart: 9, byteEnd: 19, lineStart: 3, lineEnd: 3, text: "Body one. " } + - { parentAtomIndex: 1, subIndex: 1, byteStart: 19, byteEnd: 30, lineStart: 3, lineEnd: 4, text: "Body two.\n\n" } + - { parentAtomIndex: 3, subIndex: 0, byteStart: 55, byteEnd: 66, lineStart: 8, lineEnd: 8, text: "Tail here.\n" } ::: -## Fixtures the tests pin +::: test +name: SEG_HEADING_FENCE reconstructs its exact prompt bytes +given: { fixture: SEG_HEADING_FENCE } +expect: { reconstructs: true } +::: -The unit corpus feeds in-memory providers built entirely from the real upstream -functions, so no intermediate object name is transcribed: each fixture serializes its -objects through the upstream types and `store_hash`, indexes them by their real names, -and the provider answers `object_bytes` from that index and `head_snapshot_hash` from -the fixture's HEAD. A provider that answers over a fixed map of name to envelope bytes -plus one HEAD name is the standard in-memory `ObjectProvider` these tests use. +- **SEG_LIST**: the prompt string + `Intro line. Second sentence.\n\n- First item here. It continues.\n- Second item.\n\nOutro.\n`. -A single prose module and target run through the corpus: module `greeting`, target -`rust`, prompt bytes exactly `The greeting module.\n`, and a one-file generated tree -whose only entry is `greeting.rs`. Each fixture builds the objects it needs with the -upstream builders and validators, publishes their envelope bytes into the provider -index, and sets HEAD. +::: test +name: SEG_LIST segments each list item into its own atom +given: { fixture: SEG_LIST } +expect: + atoms: + - { atomIndex: 0, byteStart: 0, byteEnd: 30, lineStart: 1, lineEnd: 2, text: "Intro line. Second sentence.\n\n" } + - { atomIndex: 1, byteStart: 30, byteEnd: 63, lineStart: 3, lineEnd: 3, text: "- First item here. It continues.\n" } + - { atomIndex: 2, byteStart: 63, byteEnd: 79, lineStart: 4, lineEnd: 5, text: "- Second item.\n\n" } + - { atomIndex: 3, byteStart: 79, byteEnd: 86, lineStart: 6, lineEnd: 6, text: "Outro.\n" } +::: -- **RS_CLEAN**: HEAD is a snapshot at `seq` 1 whose root maps `greeting` to a head - module-state and a head event. The module-state's `promptBlob` is the blob of `The - greeting module.\n`, its `blockIndex` is that prompt's validated block index, and its - one target `rust` carries a `generatedTree` (the one-file tree) and a present - `attribution`. The head event is a `gen` event at `ordinal` 1 with outcome `ok`, its - `stateAfter` the head module-state name and `stateBefore` absent. The default - `Observed` for this fixture, named `matching`, carries `promptBlob` equal to the head - module-state's `promptBlob` and `generatedTree` equal to the target's `generatedTree`, - so it matches head exactly. The `prompt_mismatch` observation keeps the matching - `generatedTree` but sets `promptBlob` to a different well-formed hash; the - `tree_mismatch` observation keeps the matching `promptBlob` but sets `generatedTree` - to a different well-formed hash. -- **RS_UNATTRIB**: like RS_CLEAN but the target `rust` has no `attribution`, and the - `matching` observation still matches the prompt and tree. -- **RS_REWORD**: like RS_CLEAN but the head event is a `reword` event, and its parent in - the chain is the RS_CLEAN-style `gen` event; the observation matches head. -- **RS_INTERRUPTED**: like RS_CLEAN but the head event is a `gen` event with outcome - `interrupted` whose `stateBefore` and `stateAfter` are equal per the non-advancing - rule; the observation matches head. -- **RS_MOVED**: like RS_CLEAN but the head event is a `move` event whose parent is the - `gen` event; the observation matches head. -- **RS_BUNDLE**: HEAD names a head module-state for `greeting` and `rust` identical to a - baseline module-state also present in the provider, so a `bundle_since` whose baseline - is that baseline state name diffs a state against its equal and assembles the empty - no-op bundle. The head event references the baseline as `stateBefore` and the head - state as `stateAfter`. -- **RS_DIFF**: HEAD names a head module-state whose `promptBlob` and `blockIndex` are a - new prompt, and a head event whose `stateBefore` is a concrete baseline module-state - with an older prompt and whose `stateAfter` is the head module-state, so - `prompt_diff_of` over the head event returns a non-empty prompt-change list. -- **RS_HISTORY**: two snapshots and the module's two-event chain across them. The parent - snapshot at `seq` 1 maps `greeting` to a module-state whose `rust` target tree has - `greeting.rs` at one blob hash, and its root entry names a `gen` event at `ordinal` 1 - whose `stateAfter` is that older module-state and whose `stateBefore` is absent; the - head snapshot at `seq` 2, whose first parent is the `seq` 1 snapshot, maps `greeting` to - a module-state whose `rust` target tree has `greeting.rs` at a different blob hash, and - its root entry names a `gen` event at `ordinal` 2 whose first parent is the `ordinal` 1 - event, whose `stateBefore` is the older module-state, and whose `stateAfter` is the - newer, so the two events form one linear chain. The `ordinal` 1 event has no first - parent, the genesis boundary of the chain, and the `seq` 1 snapshot's own first parent - is absent from the provider. Walking `greeting`'s event chain from the head event yields - the `ordinal` 2 step (older tree to newer, a `Modified` of `greeting.rs`) then the - `ordinal` 1 step (nothing to older, an `Added` of `greeting.rs`), so `file_history` over - `greeting.rs` reports two entries head-first, the first `Modified`. -- **RS_LEGACY**: HEAD is a snapshot whose root maps `greeting` to a concrete head - module-state (migration seeds the head fully) and a head `gen` event with outcome `ok` - whose first parent is a `legacy` event carrying `unknown` for both `stateBefore` and - `stateAfter`. The observation matches head. The `legacy` event named by the tests is - that parent legacy event. -- **RS_CLEAN_MISSING_STATE**: the RS_CLEAN provider with the head module-state object - removed from the index, so resolving it is `MissingObject`. Its one mutation from - RS_CLEAN is that absent module-state object; the observation it hands `classify` is the - RS_CLEAN `matching` observation unchanged. -- **RS_CLEAN_CORRUPT_HEAD**: the RS_CLEAN provider whose HEAD name maps to bytes that do - not hash to that name, so verifying the head snapshot is `CorruptObject`. Its one - mutation from RS_CLEAN is those corrupted head bytes; the observation it hands - `classify` is the RS_CLEAN `matching` observation unchanged. -- **RS_CLEAN_MISSING_EVENT**: the RS_CLEAN provider with the head event object removed - from the index, so its head module-state resolves but resolving its head event is - `MissingObject`. The head snapshot's `greeting` root entry still names that event by - its real nonzero object name, which is not the genesis sentinel, so its `event_head` - is a valid nonzero name absent from the provider rather than the all-zeros boundary. - Its one mutation from RS_CLEAN is that absent event object; the observation it hands - `classify` is the RS_CLEAN `matching` observation unchanged. This fixture drives every - event-head query loud: `classify`, `event_graph`, and `event_by_ordinal` at ordinal - one each raise `MissingObject`, the exact failing contrast to RS_CURRENT_ONLY's - graceful genesis sentinel. -- **RS_CURRENT_ONLY**: a current-state-only store, exactly the shape migration writes for - an emptied-mapl module. HEAD is a snapshot at `seq` 1 whose root maps `greeting` to a - head module-state identical in shape to RS_CLEAN's (the same `promptBlob`, `blockIndex`, - and one `rust` target with a `generatedTree` and a present `attribution`) and, as its - `eventHead`, the genesis sentinel, the all-zeros object name of sixty-four `0` - characters. The module-state object is published into the provider index, but no event - object is: the sentinel is never published and is absent from the provider by - construction. HEAD is the snapshot's name. The snapshot's own first parent is that same - absent genesis sentinel. The `matching` observation is RS_CLEAN's, carrying the head - module-state's `promptBlob` and the target's `generatedTree`, though `classify` returns - before consulting it. This fixture drives every event-head query: `classify` returns - `CurrentStateOnly`, `event_graph` (and through it the private module event-chain walk) - returns an empty node list, and `event_by_ordinal` at ordinal 1 returns `Ok(None)` by - skipping the historyless root. No object name in this fixture is transcribed; the - genesis sentinel is the literal all-zeros string. +::: test +name: SEG_LIST sub-spans each prose atom including each list item +given: { fixture: SEG_LIST } +expect: + subSpans: + - { parentAtomIndex: 0, subIndex: 0, byteStart: 0, byteEnd: 12, text: "Intro line. " } + - { parentAtomIndex: 0, subIndex: 1, byteStart: 12, byteEnd: 30, text: "Second sentence.\n\n" } + - { parentAtomIndex: 1, subIndex: 0, byteStart: 30, byteEnd: 49, text: "- First item here. " } + - { parentAtomIndex: 1, subIndex: 1, byteStart: 49, byteEnd: 63, text: "It continues.\n" } + - { parentAtomIndex: 2, subIndex: 0, byteStart: 63, byteEnd: 79, text: "- Second item.\n\n" } + - { parentAtomIndex: 3, subIndex: 0, byteStart: 79, byteEnd: 86, text: "Outro.\n" } +::: -## Test and code discipline +::: test +name: SEG_LIST reconstructs its exact prompt bytes +given: { fixture: SEG_LIST } +expect: { reconstructs: true } +::: -The frontmatter test suite is a contract, not a sketch. Each case in it is realized by -its own dedicated test function that exercises only that case, so the number of -dedicated tests is at least the number of frontmatter cases. Never fold two or more -cases into one shared test, and a single test that loops over a table of inputs never -counts as the dedicated test any of those inputs requires: such a combined test may -exist only as an extra. Cases that differ only in their input while sharing a shape are -still distinct cases, each owed its own dedicated test. In particular, each of the nine -`Classification` wire-string cases gets its own dedicated test (`clean`, -`never-generated`, `prompt-stale`, `drifted`, `unattributed`, `reworded`, -`interrupted`, `moved`, `current-state-only`), and each of the `classify` behavior cases -gets its own -dedicated test even where several share a fixture: the clean, the module-absent -never-generated, the target-absent never-generated, prompt-stale, drifted, unattributed, -reworded, interrupted, and moved classify cases are nine separate tests, the -legacy-exact classify case is a tenth `classify` test distinct from the RS_CLEAN clean -case, and the current-state-only classify case over RS_CURRENT_ONLY is an eleventh, -asserting `Classification::CurrentStateOnly` and never weakened into `NeverGenerated`. -The current-state-only downstream cases each get their own dedicated test: the -`event_graph` over RS_CURRENT_ONLY asserting an empty node list (proving the private -module event-chain walk returns the empty chain at the genesis boundary rather than -raising `MissingObject`), and the `event_by_ordinal` at ordinal one over RS_CURRENT_ONLY -asserting `Ok(None)` (proving the historyless root is skipped, not loaded). Neither -discharges the other nor the classify case. The nonzero-absent-event-head cases over -RS_CLEAN_MISSING_EVENT are three separate dedicated tests, one each for `classify`, -`event_graph`, and `event_by_ordinal` at ordinal one, every one asserting -`ReduceError::MissingObject`. They pin that an `event_head` which is any nonzero name -absent from the provider stays a loud `MissingObject` across all three query paths, the -value-refinement's failing half that RS_CURRENT_ONLY's genesis sentinel never reaches; -none discharges another, and none is discharged by the RS_CLEAN_MISSING_STATE case, -whose absent object is the module-state rather than the event. The never-generated classify case whose condition is `module: absent` realizes it -as a module name the head snapshot's root does not contain, the same way the -`prompt_bytes_at_head` absent case does, and never as a target absent from a present -module's `targets` map; it hands `classify` the RS_CLEAN provider, that absent module -name, the target `rust`, and the RS_CLEAN `matching` observation, and asserts -`NeverGenerated`. The never-generated classify case whose condition is `target: absent` -is the distinct target-absent sub-branch: it hands `classify` the RS_CLEAN provider, the -real module `greeting`, a target name the RS_CLEAN head module-state's `targets` map does -not contain (RS_CLEAN carries only the target `rust`, so `typescript` is provably -absent), and the RS_CLEAN `matching` observation, and asserts `NeverGenerated`. This case -pins rule one's clause for a present module-state with no entry for the requested target, -exercising the branch the module-absent case cannot reach; the two never-generated -classify cases are distinct and neither discharges the other. Likewise every case that -expects a `ReduceError` -(`MissingObject`, `CorruptObject`, `UnsupportedForLegacy`) gets its own dedicated test, -every `is_none` case gets its own dedicated test, and every byte-pinned case gets its -own dedicated test. +## Test and code discipline -Inside a case's dedicated test, hand the code exactly the fixture that case names, and -assert its pinned expectation verbatim: no shorter or more convenient stand-in, no -re-derivation of a value the case pins as a literal, and no weakening of a pinned -equality into an `is_ok`, an inequality-only, or a length-only check where the case -pins a value. The byte-pinned cases assert their exact literals. The -`prompt_bytes_at_head` case over RS_CLEAN asserts the returned bytes equal exactly `The -greeting module.\n`, the whole literal including its trailing newline, never a prefix -and never a recomputed hash of it. The no-op bundle case hands `bundle_since` the -RS_BUNDLE fixture and that fixture's present baseline state, which RS_BUNDLE builds equal -to head, and asserts `mode` `diff` and that `store_diff::bundle_blob_name` of the returned -bundle equals exactly -`6dc9ca1a0fe393bbed56cce83d42aadded789fac58c3f5ef22d2c13bf25d1bdc`, the verified empty -bundle blob name, obtained from `bundle_blob_name` and compared to the literal. The -full-mode case hands `bundle_since` the RS_BUNDLE fixture with baseline `None`, never -RS_CLEAN nor any lighter fixture in place of RS_BUNDLE, and asserts `mode` `full` and -that the `BundleQuery` value serializes through `store_hash::canonical_json` to exactly -`{"mode":"full"}` plus one trailing newline, obtained as the canonical JSON of the -serialized value and compared to the literal. Both `bundle_since` mode cases pin RS_BUNDLE -as their fixture and neither substitutes RS_CLEAN or any lighter fixture for it. Each `Classification` wire-string case -obtains the token by serializing the variant with `serde_json` and asserts it equals the -pinned quoted string exactly, `"clean"` for `Clean`, `"never-generated"` for -`NeverGenerated`, `"prompt-stale"` for `PromptStale`, `"drifted"` for `Drifted`, -`"unattributed"` for `Unattributed`, `"reworded"` for `Reworded`, `"interrupted"` for -`Interrupted`, and `"moved"` for `Moved`. +The frontmatter test suite is a contract, not a sketch. Each case in it is +realized by its own dedicated test function that exercises only that case, so the +number of dedicated tests is at least the number of frontmatter cases. Never fold +two or more cases into one shared test, and a single test that loops over a table +of inputs never counts as the dedicated test any of those inputs requires: such a +combined test may exist only as an extra. Cases that differ only in their input +while sharing an expected shape are still distinct cases, each owed its own +dedicated test. In particular each rejection case that expects `Malformed` (the +unknown top-level field, the missing atoms, the wrong-typed atoms, the unknown +atom-record field, the missing atom contentHash, the unknown sub-span-record +field, the wrong-typed segVersion, and the segVersion one past the u32 range), +each case that expects `MalformedHash` (the atom hash and the sub-span hash), each +case that expects `InvalidSpan` (the atom byteStart-over-byteEnd, the atom +lineEnd-under-lineStart, and the sub-span byteStart-over-byteEnd), the +`InvalidSegVersion` case, the `UnknownParentAtom` case, each case that expects +`NonSequentialAtomIndex` (the duplicated atomIndex, the skipped atomIndex, and the +reversed atom records), each case that expects `AtomsNotTiled` (the overlapping +atoms, the gapped atoms, and the first atom whose byteStart is not zero), each case +that expects `NonSequentialSubIndex` (the duplicated subIndex and the skipped +subIndex), the `SubSpanOutsideParent` case, each case that expects +`SubSpansNotTiled` (the overlapping siblings, the gapped siblings, the first sibling +whose byteStart is not its parent atom byteStart, and the last sibling whose byteEnd +is not its parent atom byteEnd), and the segVersion largest-u32 accepted case, gets its own dedicated +test whatever shared loop may also exist as an extra. Each integer-width boundary +case pins its exact literal integer in its own test: `4294967295` for the +largest-u32 segVersion accepted, and `4294967296` for the segVersion one past the +u32 range rejected as `Malformed`. -Where a case asserts a query result over a fixture, it builds that fixture's provider -from the real upstream functions and asserts the query's own pinned outcome. The -`event_by_ordinal` found case asserts the returned event's `ordinal` equals `1` and its -payload is the `gen` variant; the missing case asserts `event_by_ordinal` at ordinal `2` -is `None`, never conflating the two. The `file_history` change case asserts the returned -vector has length `2`, that its first entry's `change` is `Modified` and its `path` is -`greeting.rs`, in that head-first order; the empty case asserts `file_history` over -`missing.rs` is an empty vector, and neither discharges the other. The `move_records` -case asserts one collected event whose payload is the `Move` variant, and the -no-move case asserts an empty vector; the `reword_chain` case asserts one collected event -whose payload is the `Reword` variant, and the no-reword case asserts an empty vector; -`move_records` and `reword_chain` cases never discharge one another and the no-op cases -never substitute for the populated ones. The `event_graph` concrete case asserts one node -whose `materialized` is `true`; the legacy case asserts two nodes, the head node -`materialized` `true` and the legacy node `materialized` `false`, proving the partial -legacy capability, and it never collapses into the concrete case. The `prompt_diff_of` -concrete case asserts the returned `changes` equals `store_diff::diff_prompt` over the -same old and new indexes and prompts the fixture built, element for element in order, and -that `legacy_partial` is `false`; the legacy case asserts `legacy_partial` is `true` and -`changes` is empty, and neither discharges the other. The `drift_baseline` case asserts -the returned `generated_tree` equals the head target's generated tree root the fixture -built and that the returned `tree` equals that same validated tree value; the `tree_at` -present case asserts the returned tree's single entry name is `greeting.rs`, and the -absent case realizes its `module: absent` condition as a module name the head snapshot's -root does not contain, exactly as the never-generated classify case does and never as a -target absent from a present module, hands `tree_at` the RS_CLEAN provider and that absent -module name with target `rust`, and asserts `None`. - -The five `state_view` cases are five separate dedicated tests, none discharging another. -The present-head case hands `state_view` the RS_CLEAN provider, the RS_CLEAN head event's -`state_after` (the head module-state name, obtained from the fixture's own head event or -root entry, never transcribed as a literal name), and target `rust`, and asserts -`legacy_partial` is `false`, that `prompt` equals exactly `The greeting module.\n` (the -whole literal including its trailing newline, never a prefix and never a recomputed hash), -that `generated_tree` equals the head target's generated tree root the fixture built, and -that the single `tree_objects` entry resolved at that root names `greeting.rs`. The -absent-state case hands `state_view` the RS_CLEAN provider, `None` for the state, and target -`rust`, and asserts `legacy_partial` `false`, `prompt` the empty string, `prompt_blob` -`None`, `generated_tree` `None`, and `tree_objects` empty. The unknown-state case hands -`state_view` the RS_CLEAN provider, `Some(store_objects::StateRef::Unknown)`, and target -`rust`, and asserts `legacy_partial` `true`, `prompt` empty, `prompt_blob` `None`, -`generated_tree` `None`, and `tree_objects` empty, proving the sentinel short-circuits -before any provider read exactly as the `prompt_diff_of` legacy case does. The -target-absent case hands `state_view` the RS_CLEAN provider, the head state, and target -`typescript` (which the RS_CLEAN head module-state's `targets` map does not contain), and -asserts `prompt` still equals `The greeting module.\n`, `prompt_blob` `Some`, -`generated_tree` `None`, and `tree_objects` empty, pinning the present-state-but-target-absent -branch distinct from the absent-state branch. The missing-state-object case hands -`state_view` the RS_CLEAN_MISSING_STATE provider, the head state name (now absent from the -index), and target `rust`, and asserts `ReduceError::MissingObject`, never a silent empty -view. The `prompt` value is asserted as its full literal, never re-derived; the -`legacy_partial` boolean is never swapped between the concrete and the unknown cases; and -the missing-state error is never weakened into an empty `StateView`. +Inside a case's dedicated test, hand the code exactly the fixture that case gives, +byte for byte, and assert its pinned expectation verbatim: no shorter or more +convenient stand-in, no re-derivation of a value the case pins as a literal, and +no weakening of a pinned equality into an inequality-only or recomputed check. The +byte-pinned object cases assert their exact literals: BI_MIN canonicalizes to its +pinned bytes ending in one newline, names to +`1660deacdbb4727b6f456c87b2866dfbcbd95e00bf9eda2b6936c39fcf136d28`, and frames its +complete envelope, the header `napl-store block-index v1 190\n` immediately +followed by its canonical payload, as one whole byte string; BI_RICH canonicalizes +to its pinned bytes and names to +`e9fccb1f9979d73754a520d7622fe2bcd296e7c58193a8fadfbedea12c08c902`. Where a case +pins canonical bytes, obtain them as `store_hash::canonical_json` applied to the +serialized value and compare to the literal; where a case pins an object name, +obtain it from `block_index_object_name` and compare to the literal; where a case +pins envelope bytes, frame them through `store_hash::envelope_bytes` over the +canonical payload and assert the whole framed byte vector equals the pinned +envelope in full, header and payload together, an exact equality of the complete +bytes and never a prefix or header-only comparison. The dedicated BI_MIN envelope +test computes its expected bytes as that header concatenated with the canonical +payload and asserts equality against the entire framed envelope; any header-prefix +check is at most a separately named supplemental test and never discharges the +envelope case. -The event-graph node round-trip case serializes one `EventNode`, -recovers it through `serde_json`, and asserts the recovered node equals the original -verbatim, never weakening that equality into an `is_ok` acceptance. +A dedicated test asserts only its own case and nothing more. The BI_MIN name test +asserts only that pinned digest, leaving the canonical-bytes shape to the case that +owns it. The verifier round-trip case builds BI_MIN, names it through +`block_index_object_name`, frames its envelope through `store_hash::envelope_bytes` +over its canonical payload, and asserts `store_hash::verify_object` returns success +carrying `store_hash::ObjectKind::BlockIndex`; it never substitutes BI_RICH. Each +round-trip-equal case builds its own named fixture, serializes it, recovers it +through `validate_block_index`, and asserts the recovered value equals the original +verbatim, never weakening that equality into a mere `is_ok` acceptance and never +substituting a lighter or neighboring fixture: the BI_MIN round-trip builds BI_MIN +and the BI_RICH round-trip builds BI_RICH. -The legacy-capability trio is three distinct dedicated tests that must not be collapsed: -the exact case asserts `classify` over RS_LEGACY is `Clean`, proving a legacy event in the -chain does not taint an exact head classification; the partial case is the `event_graph` -legacy case asserting the legacy node's `materialized` is `false`; the unsupported case -hands `bundle_since` the RS_BUNDLE fixture with baseline -`Some(store_objects::StateRef::Unknown)`, never RS_LEGACY nor any other fixture in place -of RS_BUNDLE, and asserts `Err(ReduceError::UnsupportedForLegacy)`. The `unknown` sentinel -short-circuits before any provider read, yet the case still pins RS_BUNDLE and the test -hands it exactly that fixture with the `Unknown` baseline. -Each names the capability it proves and none stands in for another. +The rejection cases construct exactly the malformed JSON the case describes, built +by taking the named fixture's JSON and applying only the one described mutation +using that mutation's exact literal tokens, and assert the pinned error variant. +The extra-key cases each insert the key named `bogus` with the integer value `1`, +exactly `"bogus": 1`, never a renamed key and never a restyled value; the unknown +top-level field inserts it at the top level of BI_MIN, the unknown atom-record +field inserts it inside BI_MIN's one atom record, and the unknown sub-span-record +field inserts it inside BI_RICH's first sub-span record, and none discharges +another. The malformed-hash cases set the named `contentHash` to exactly `"xyz"`; +the wrong-typed-segVersion case sets `segVersion` to the JSON string `"1"`; the +wrong-typed-atoms case sets `atoms` to the JSON number `1`; the zero-segVersion +case sets `segVersion` to `0`; the atom byteStart-over-byteEnd span case sets +`byteStart` to `9` and leaves `byteEnd` at `5`; the atom lineEnd-under-lineStart +span case sets `lineStart` to `4` and leaves `lineEnd` unchanged at its BI_MIN +value of `1`, touching no other field, so the mutated atom carries `lineStart` `4` +and `lineEnd` `1` and never `lineEnd` `2`; the sub-span byteStart-over-byteEnd span +case sets `byteStart` to `9` and leaves `byteEnd` at `5`; the unknown-parent case +sets the sub-span `parentAtomIndex` to `5`. -The error cases construct exactly the fixture each describes and assert the pinned error -variant. The missing-object case runs `classify` over RS_CLEAN_MISSING_STATE, whose sole -mutation from RS_CLEAN is that the head module-state object is absent from the provider, -and hands `classify` the RS_CLEAN `matching` observation, the same observation the clean -case uses, never a bespoke observation with mutated `promptBlob` or `generatedTree` -hashes; it asserts `ReduceError::MissingObject`. The corrupt-object case runs `classify` -over RS_CLEAN_CORRUPT_HEAD, whose sole mutation from RS_CLEAN is that the HEAD name maps to -bytes that do not verify, and likewise hands `classify` the RS_CLEAN `matching` -observation, never a bespoke observation with mutated hashes; it asserts -`ReduceError::CorruptObject`. The -unsupported-for-legacy case asserts `ReduceError::UnsupportedForLegacy` as above. The three -error variants are never interchanged: a test that pins `MissingObject` never accepts -`CorruptObject`, and neither ever accepts `UnsupportedForLegacy`. +The occurrence-index rejection cases each start from BI_RICH and apply their one +described mutation with its exact literal token, and each is its own dedicated +test asserting its pinned variant. The duplicated-atomIndex case sets the second +atom's `atomIndex` to `0`; the skipped-atomIndex case sets the second atom's +`atomIndex` to `2`; the reversed-atom-records case reverses the order of the two +atom records in the `atoms` array; each expects `NonSequentialAtomIndex`. The +overlapping-atoms case sets the second atom's `byteStart` to `8` and the +gapped-atoms case sets the second atom's `byteStart` to `12`; each expects +`AtomsNotTiled`. The first-atom-untiled case sets the first atom's `byteStart` to +`2`, leaving no other field touched, and expects `AtomsNotTiled`. The duplicated-subIndex case sets the second sub-span's `subIndex` +to `0` and the skipped-subIndex case sets the second sub-span's `subIndex` to `2`; +each expects `NonSequentialSubIndex`. The outside-parent case sets the second +sub-span's `byteEnd` to `25`, past its parent atom's `byteEnd` of `10`, and expects +`SubSpanOutsideParent`. The overlapping-siblings case sets the second sub-span's +`byteStart` to `3` and the gapped-siblings case sets the second sub-span's +`byteStart` to `7`; the first-sibling-untiled case sets the first sub-span's +`byteStart` to `2`, leaving no other field touched; the last-sibling-untiled case +sets the second sub-span's `byteEnd` to `8`, leaving no other field touched; each +of these four expects `SubSpansNotTiled`. None of these discharges another: they +share a variant only by class and differ in the exact literal each pins. -The named-substitution prohibitions for this module's confusable tokens are strict. Never -write `clean` where a case pins `never-generated`, nor `drifted` where a case pins -`prompt-stale`, nor `unattributed` where a case pins `drifted`, nor `reworded` where a -case pins `moved`, nor `interrupted` where a case pins `reworded`, and never any other -substitution among the eight classification tokens; each is transcribed exactly as its -case names it. Never write `full` where a case pins `diff` or the reverse. Never confuse -`promptBlob` with `blockIndex`, `moduleState` with `eventHead`, `generatedTree` with -`attribution`, `stateBefore` with `stateAfter`, `ownedOld` with `ownedNew`, `oldHash` with -`newHash`, or `object_bytes` with `head_snapshot_hash`; each field, method, and serde name -is written exactly as this prompt names it. Never assert `materialized` `true` where a case -pins `false` or the reverse, and never swap the `MissingObject`, `CorruptObject`, and -`UnsupportedForLegacy` variants. The `prompt_mismatch` and `tree_mismatch` observations are -distinct: the prompt-stale case mutates only `promptBlob` and the drifted case mutates only -`generatedTree`, and neither reuses the other's mutation. +Each segmentation case builds the exact prompt its fixture names and asserts every +value the case pins, transcribed verbatim. For a case that pins a list of atoms, +the dedicated test asserts, for each atom in order, its `atomIndex`, `byteStart`, +`byteEnd`, `lineStart`, and `lineEnd`, that the prompt slice `[byteStart, byteEnd)` +equals the pinned `text`, and that the atom's `contentHash` equals +`hash::content_hash` of that same `text`; it also asserts the atom count equals the +number of atoms listed. For a case that pins a list of sub-spans, the dedicated +test asserts, for each sub-span in order, its `parentAtomIndex`, `subIndex`, +`byteStart`, and `byteEnd` (and its `lineStart` and `lineEnd` where the case lists +them), that the prompt slice equals the pinned `text`, and that its `contentHash` +equals `hash::content_hash` of that `text`; where the case pins a `sub_span_count` +or `atom_count` it asserts that too. The `text` literal is transcribed exactly, +every space and newline included, and the `contentHash` is asserted by comparison +to `hash::content_hash` of that `text`, never by a copied hex literal. A +reconstruction case asserts `reconstruct(index, prompt)` equals the prompt exactly. +An `object_name` case asserts `block_index_object_name` of the segmented index +equals the pinned digest literal. -Closing mandate on one-to-one case fidelity. Every case in the frontmatter test suite -above, without exception, is realized by exactly one dedicated test that hands the code -that case's own fixture with its literal values copied verbatim from the case, and asserts -that case's own pinned expectation, neither narrower nor recomputed. A test that inspects a -different or narrower property than its case declares, that swaps in a lighter or -neighboring fixture, that weakens a pinned equality into an `is_ok`, inequality-only, or -length-only check where a value is pinned, or that reuses a sibling case's values, does not -discharge the case and leaves it untested. Two cases that share a shape but differ in a -fixture value or in the property asserted are two separate cases, each owed its own verbatim -test: the eight classification-token cases never discharge one another, the nine classify -behavior cases never discharge one another, the two never-generated classify cases (the -module-absent case and the target-absent case) never discharge one another, the two -`bundle_since` mode cases never discharge -the unsupported case, the two `file_history` cases never discharge one another, the two -`event_by_ordinal` cases never discharge one another, the two `prompt_diff_of` cases never -discharge one another, the two `tree_at` cases never discharge one another, the two -`move_records` cases never discharge one another, the two `reword_chain` cases never -discharge one another, the two `event_graph` cases never discharge one another, the five -`state_view` cases never discharge one another, and the -three legacy-capability cases never discharge one another. Supplemental tests beyond the -suite are named for what they add and are never counted as, nor substituted for, any suite -case's dedicated test. The number of dedicated tests is therefore at least the number of -frontmatter cases, and each case above is discharged by its own verbatim test. +The segmentation version case for SEG_DOC segments the same prompt under +segmentation version 2, asserts its `segVersion` is 2, asserts its `atoms` and +`subSpans` equal those from segmenting under `SEG_VERSION` (same spans and same +hashes), and asserts its object name is the pinned version-two digest, distinct +from the version-one digest; it never keeps the version-one `segVersion` value 1. +The protected-span and boundary cases each hand the code exactly the `prompt` +string its frontmatter case pins, byte for byte, and never a different or more +convenient illustrative string; inventing a stand-in prompt that merely exercises +the same rule does not discharge the case and leaves it untested. The exact +prompts are, verbatim: the backticked-span case uses `Call `a.b.c` now. Done.\n` +and asserts its two sub-spans `Call `a.b.c` now. ` and `Done.\n`; the +version-number case uses `Use v1.5 here. Stop.\n` and asserts `Use v1.5 here. ` and +`Stop.\n`; the file-path case uses `Edit main.rs now. Stop.\n` and asserts +`Edit main.rs now. ` and `Stop.\n`; the `e.g.` case uses `See e.g. this. Next one.\n` +and asserts `See e.g. this. ` and `Next one.\n`; the `i.e.` case uses +`Use i.e. that. Next one.\n` and asserts `Use i.e. that. ` and `Next one.\n`; the +`etc.` case uses `Foo, etc. bar baz.\n` and asserts its single conservative +sub-span `Foo, etc. bar baz.\n` and never a two-way split; the plain two-sentence +case uses `A cat sat. A dog ran.\n` and asserts `A cat sat. ` and `A dog ran.\n`; +the digit-opener case uses `Item one. 2 is next.\n` and asserts `Item one. ` and +`2 is next.\n`. A test built on any other prompt string, such as a re-worded +sentence chosen to illustrate the rule, is at most a separately named supplemental +test and never stands in for the pinned case. -Beyond the suite, one dedicated supplemental test per `ReduceError` variant asserts that -variant's exact `Display` line verbatim through `to_string()`: a `CorruptObject("abc")` -renders `corrupt store object abc`, a `MissingObject("abc")` renders `missing store -object abc`, and `UnsupportedForLegacy` renders `query unsupported across a legacy state -boundary`. Each of these three is its own dedicated test asserting only its own line, and -none replaces or subsumes a suite case. +SEG_PROTECTED owns two dedicated tests, not one: one asserts its `atom_count` is +exactly 1 alongside its four pinned sub-spans, and a separate dedicated +reconstruction test asserts `reconstruct(index, SEG_PROTECTED)` equals the whole +SEG_PROTECTED prompt exactly. The empty-prompt case asserts no atoms, no sub-spans, +and that the returned `segVersion` is 1; the single-newline case asserts one blank +atom and no sub-spans; the unterminated-line case uses exactly the prompt +`Hello there.` and asserts one atom `Hello there.` and one conservative sub-span +`Hello there.` equal to it. -Write no explanatory line or block comments in the generated code, and no doc comment on -any private item. The crate keeps its module-level `//!` summary. Every public type, every -public trait, every public trait method, every public enum variant, and every public -function carries a `///` doc comment restating its contract; a public struct field carries a -`///` doc comment only where its name alone does not convey the contract, and simply named -fields may carry none. The private helper functions and private constants of this module -carry no doc comment. Beyond the module summary and these public-item doc comments the code -carries no commentary of any kind, and no doc comment contains a doctest code block. Use no -em dashes anywhere in the generated code, including inside string literals and doc comments. +Closing mandate on one-to-one case fidelity. Every case in the frontmatter test +suite above, without exception, is realized by exactly one dedicated test that +hands the code that case's own fixture with its literal values copied verbatim from +the case, and asserts that case's own pinned expectation, neither narrower nor +recomputed. A test that inspects a different or narrower property than its case +declares, that swaps in a lighter or neighboring fixture, that weakens a pinned +equality into an `is_ok` or inequality-only check, or that reuses a sibling case's +values, does not discharge the case and leaves it untested. Two cases that share a +shape but differ in a fixture value or in the property asserted are two separate +cases, each owed its own verbatim test: the atom-hash rejection never discharges +the sub-span-hash rejection, the atom-span rejections never discharge each other, +the BI_MIN round-trip never discharges the BI_RICH round-trip, and a version-number +protected case never discharges a file-path protected case. Supplemental tests +beyond the suite are named for what they add and are never counted as, nor +substituted for, any suite case's dedicated test. The number of dedicated tests is +therefore at least the number of frontmatter cases, and each case above is +discharged by its own verbatim test. -## store_txn_core +Write no explanatory line or block comments in the generated code, and no doc +comment on any private item. The crate keeps its module-level `//!` summary. Every +public type, every public enum variant, and every public function carries a `///` +doc comment restating its contract; a public struct field carries a `///` doc +comment only where its name alone does not convey the contract, and simply named +fields may carry none. The private helper functions and private constants of this +module carry no doc comment. Beyond the module summary and these public-item doc +comments the code carries no commentary of any kind. -# Store transaction core: markers, locks, recovery, and install planning as a pure decision core +## store_tree +# Store tree: git-style Merkle directory trees over generated files -This module is the pure decision core of the NAPL store's transaction protocol. It -owns every marker and lock record type and every policy decision the on-disk shell -needs, and it does all of this without touching a filesystem, a clock, or a source -of randomness. The shell (`store_io`) performs the syscalls and observes facts; -this module turns those facts into decisions and owns the exact bytes of the records -the shell writes. It is total, deterministic, and pure relative to its inputs, so -every rule below is a conformance-pinned function, not a described intention. It -covers four areas: +This module defines the `tree` object kind of the NAPL content-addressed store, +the git-style Merkle directory tree over generated files, together with the pure +functions that build a tree from a flat file set and diff two trees. A tree is an +ordered set of entries, each naming one immediate child by its content-addressed +hash: a file child by its blob object name, a directory child by its subtree +object name. Because every directory entry carries the hash of its subtree, and +every subtree hash covers its own children's hashes, a single changed leaf +changes exactly one path of hashes from that leaf to the root, and a diff walks +only the hash-differing paths, in time proportional to the change rather than the +tree size. It is pure: no filesystem, no I/O, no clocks, no randomness. Bring in +`serde` and `serde_json`. -- **The record types**: the commit marker, the attempt marker, the lock file, and - the ordered install step, each with a canonical serialization the shell writes - byte for byte and a strict parse the shell reads back. -- **The recovery decision table**: classifying a commit marker against HEAD, and - converting a durable attempt marker into an interrupted event exactly once. -- **The complete lock lifecycle as a decision core**: exclusive-creation acquisition, - parsing a lock file (with malformed treatment), token-checked release, same-host - liveness, and rename-aside steal serialized under the janitor lock. -- **Install-step planning and path classification**: turning a generated-tree diff - into the complete ordered, stamp-last install plan (domain writes, then snapshot-wide - views, then permission changes, then deletions), and classifying any path into - exactly one of three authority classes as a total function. +## Scope boundary: which object kind lives here -Bring in `serde`, `serde_json`, and use `store_hash` for canonical JSON so the -record bytes match every other object kind's canonical form. +The store has six object kinds. This module owns exactly one of them, the `tree` +kind, whose serde types the neighboring `store_objects` module deliberately +deferred to this module. The other five kinds are out of scope here and are never +redefined in this crate: `blob`, `block-index`, `module-state`, `event`, and +`snapshot`. This module never references another object by structure; where it +names another object, whether a file's blob or a subtree, it uses that object's +content-addressed name, the sixty-four-character lowercase hexadecimal SHA-256 +string. Trees are used for generated output only; they are the `generatedTree` +that a module-state references per target. ## Where this code lives -The working directory is a Cargo workspace whose root manifest is written and owned -by the toolchain, leave it alone. Create this module as its own member crate in a -subdirectory named `store_txn_core/`: `store_txn_core/Cargo.toml` (package name -`store_txn_core`) and `store_txn_core/src/lib.rs`. Touch nothing outside -`store_txn_core/`. Ensure `cargo test` passes from the workspace root before -finishing. - -## Builds on two modules of this workspace +The working directory is a Cargo workspace whose root manifest is written and +owned by the toolchain, leave it alone. Create this module as its own member crate +in a subdirectory named `store_tree/`: `store_tree/Cargo.toml` (package name +`store_tree`) and `store_tree/src/lib.rs`. Touch nothing outside `store_tree/`. +Ensure `cargo test` passes from the workspace root before finishing. -This crate composes on two generated sibling crates. Use each one's public API, do -not reimplement its types or logic, and do not depend on any hand-written crate. -Depend on exactly these two, and do not bring in the `sha2` crate directly. +## Builds on the `store_hash` module of this workspace -- **`store_hash`** (`../store_hash`) supplies the canonical JSON encoding. It uses - `store_hash::canonical_json(value: &serde_json::Value) -> Vec` to serialize - every record type: a record's canonical bytes are the canonical JSON of its - serialized value, sorted keys, compact, one trailing newline. This module never - reimplements canonical JSON. -- **`store_tree`** (`../store_tree`) supplies the generated-tree diff this module - plans installs from. It uses `store_tree::TreeChange` with its three variants - `TreeChange::Added { path: String, hash: String, mode: u32 }`, `TreeChange::Removed - { path: String, hash: String, mode: u32 }`, and `TreeChange::Modified { path: - String, old_hash: String, old_mode: u32, new_hash: String, new_mode: u32 }`. This - module consumes a slice of tree changes and never reimplements tree diffing. +This crate composes on the generated sibling crate **`store_hash`** +(`../store_hash`) for object naming, envelope framing, canonical JSON, and read +verification. Use its public API, do not reimplement its types or logic, and do +not depend on any hand-written crate. This module needs no content-digest helper +and does not bring in the `sha2` crate directly or the `hash` crate: every hash a +tree stores is either a caller-supplied object name or a subtree object name +produced by `store_hash`. The items this module uses from `store_hash`: -This module performs no I/O of any kind. It never opens a file, never reads a clock, -never draws a random token, and never probes a live process; the shell hands it the -host, the token, the process-liveness boolean, and the ancestry boolean, and this -module decides. +- `store_hash::ObjectKind`, whose variant `store_hash::ObjectKind::Tree` names + this kind; its wire token `tree` comes from `store_hash`, never re-spelled here. +- `store_hash::canonical_json(value: &serde_json::Value) -> Vec`, the + canonical JSON encoder (keys sorted recursively, compact, one trailing newline) + that turns a serialized value into its canonical bytes. +- `store_hash::envelope_bytes(kind: store_hash::ObjectKind, payload: &[u8]) -> + Vec` and `store_hash::object_name(kind: store_hash::ObjectKind, payload: + &[u8]) -> String`, the typed envelope framing and the SHA-256 object name over + those envelope bytes. +- `store_hash::verify_object(name: &str, bytes: &[u8]) -> + Result`, the read verifier + the round-trip tests pass a freshly built object through; + `store_hash::VerifiedObject` has public fields `kind: store_hash::ObjectKind` + and `payload: Vec`. -## The record types +## Content hashes are plain strings -### The lock file +Every content-addressed name a tree carries is a `String`, matching the rest of +the workspace, never a newtype. A well-formed object name is sixty-four lowercase +hexadecimal characters (`0` through `9`, `a` through `f`). The validator below +rejects any hash-typed field whose string is not exactly that shape. This module +never computes a hash except by delegating to `store_hash`. -Expose a public struct `LockFile` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, -`serde::Serialize`, `serde::Deserialize`, with `deny_unknown_fields`) with exactly -these public fields, serialized under exactly these wire names: `host: String`, -`pid: u64`, `purpose: String`, `start_time: u64` (wire name `startTime`), and -`token: String`. Its canonical bytes are the canonical JSON of the serialized value. +## The tree object -### The attempt marker +Expose a public struct `Tree` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, +`serde::Serialize`, and `serde::Deserialize`, with +`#[serde(deny_unknown_fields)]`), one directory level. It serializes as a JSON +object with exactly one field: -Expose a public struct `AttemptMarker` (same derives, `deny_unknown_fields`) with -exactly these public fields under these wire names: `attempt_id: String` (wire name -`attemptId`), `diagnostics: Vec` (a list of diagnostic blob hashes, possibly -empty), `module: String`, `operation: String`, `state: AttemptState`, and `target: -Option` serialized only when present (the target key is omitted entirely when -`None`). Expose a public enum `AttemptState` (same derives, serialized as a string) -with exactly the variants `InFlight` (wire token `inFlight`) and `Completed` (wire -token `completed`); an unknown state token is rejected. +- `entries: Vec`, serde field name `entries`. The immediate children of + this directory level in ascending name order, always present, an empty directory + serializing as `[]`. The tree wraps its entries in an object rather than being a + bare array so the top level rejects unknown fields and the format can grow a + sibling field later without a name collision. -### The commit marker and install step +Expose a public struct `TreeEntry` (same derives, same +`#[serde(deny_unknown_fields)]`), one immediate child. Public fields: -Expose a public struct `CommitMarker` (same derives, `deny_unknown_fields`) with -exactly these public fields under these wire names: `domain: String` (the module -whose domain this transaction owns), `owner: String` (the owner token), `snapshot: -String` (the snapshot hash this marker is keyed by), and `steps: Vec` -(the ordered install steps). +- `name: String`, serde field name `name`. The child's single path component, not + a multi-component path: it is never empty, never contains the byte `/` (`0x2F`), + and is never the single dot `.` or the double dot `..`. +- `kind: EntryKind`, serde field name `kind`. Whether this child is a file or a + directory. +- `hash: String`, serde field name `hash`. The child's content-addressed object + name: a file child's blob object name, or a directory child's subtree object + name. Sixty-four lowercase hex. +- `mode: u32`, serde field name `mode`. The child's POSIX file mode, in the numeric + git-style form described next. -Expose a public enum `InstallStep` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) -internally tagged by a field named `op`, with exactly these four variants and wire -tokens: `WriteFile { path: String, hash: String, mode: u32 }` (wire token -`writeFile`), `WriteView { path: String, hash: String }` (wire token `writeView`, a -snapshot-wide derived view with no mode), `SetMode { path: String, mode: u32 }` (wire -token `setMode`), and `DeleteFile { path: String }` (wire token `deleteFile`). -Because serde does not enforce `deny_unknown_fields` through an internally tagged -enum, hand-write a strict `Deserialize` for `InstallStep` that reads the `op` tag, -accepts exactly the fields that variant defines and no others (an unknown sibling key -is rejected), and rejects any `op` token outside the four above. Serialize each -variant as a single JSON object carrying its `op` token and exactly its own fields; -its canonical bytes sort the keys, so a `writeFile` serializes its keys in the order -`hash`, `mode`, `op`, `path`. +The canonical bytes of a tree therefore sort each entry's keys as `hash`, `kind`, +`mode`, `name`, and order the entries themselves by ascending `name`. -## The recovery decision table +## The entry kind -### Classifying a commit marker +Expose a public enum `EntryKind` (deriving `Debug`, `Clone`, `Copy`, `PartialEq`, +`Eq`, `serde::Serialize`, and `serde::Deserialize`), serializing as a single JSON +string. Pin each variant to its exact wire token; an input string outside this set +is a deserialization failure: -Expose `classify_marker(is_ancestor_or_equal: bool, owner_live: bool) -> -MarkerDisposition`, a total function of exactly two booleans. When the marker's -snapshot is HEAD or an ancestor of HEAD the commit succeeded, so the disposition is -`RollForwardDelete` regardless of whether the owner is live: run the installer to -current HEAD, then delete the marker. When the marker's snapshot is not an ancestor, -a live owner means the running writer is inside its deliberate pre-HEAD window, so -the disposition is `LeavePreHeadWindow`; a dead owner means the commit never landed, -so the disposition is `DeleteAndCollect`: delete the marker and let the janitor -collect its objects. Expose a public enum `MarkerDisposition` (deriving `Debug`, -`Clone`, `PartialEq`, `Eq`) with exactly the variants `RollForwardDelete`, -`LeavePreHeadWindow`, and `DeleteAndCollect`. +- `EntryKind::File` renders `file`. +- `EntryKind::Dir` renders `dir`. ::: test -name: an ancestor-or-equal marker with a live owner rolls forward and is deleted -given: { is_ancestor_or_equal: true, owner_live: true } -expect: { disposition: RollForwardDelete } +name: entry kind file serializes to the string file +given: { entry_kind: file } +expect: { serialized_json: "\"file\"" } ::: ::: test -name: an ancestor-or-equal marker with a dead owner still rolls forward and is deleted -given: { is_ancestor_or_equal: true, owner_live: false } -expect: { disposition: RollForwardDelete } +name: entry kind dir serializes to the string dir +given: { entry_kind: dir } +expect: { serialized_json: "\"dir\"" } ::: ::: test -name: a non-ancestor marker with a live owner is left in its pre-HEAD window -given: { is_ancestor_or_equal: false, owner_live: true } -expect: { disposition: LeavePreHeadWindow } +name: the string file deserializes to entry kind file +given: { serialized_json: "\"file\"" } +expect: { is_file: true } ::: ::: test -name: a non-ancestor marker with a dead owner is deleted and its objects collected -given: { is_ancestor_or_equal: false, owner_live: false } -expect: { disposition: DeleteAndCollect } +name: the string dir deserializes to entry kind dir +given: { serialized_json: "\"dir\"" } +expect: { is_dir: true } ::: -### Converting an attempt marker to an event +## The mode constants -Expose `attempt_disposition(state: AttemptState, event_exists: bool) -> -AttemptOutcome`, a total function. An in-flight attempt with no event carrying its -attempt id yet yields `EmitInterrupted`: recovery records a `gen` or `derivation` -event with outcome interrupted. An in-flight attempt whose attempt id already names -an event yields `SkipDuplicate`: a crash between the event commit and the marker -delete must never fabricate a duplicate. A completed attempt yields -`NoEventCompleted` whether or not an event exists, because a completed record names a -finished operation and never fabricates an interruption. Expose a public enum -`AttemptOutcome` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with exactly the -variants `EmitInterrupted`, `SkipDuplicate`, and `NoEventCompleted`. +A tree entry's `mode` is the child's POSIX mode carried as a `u32` in the numeric +form git uses, the full mode including the file-type bits, so a single integer +distinguishes a regular file, an executable file, and a directory. Expose exactly +these three public constants, and constrain every entry's `mode` to one of them: -::: test -name: an in-flight attempt with no matching event emits an interrupted event -given: { attempt_state: inFlight, event_exists: false } -expect: { attempt: EmitInterrupted } -::: +- `pub const FILE_MODE: u32 = 0o100644` (the decimal value `33188`), a regular + file. +- `pub const EXEC_MODE: u32 = 0o100755` (the decimal value `33261`), an executable + regular file. +- `pub const DIR_MODE: u32 = 0o040000` (the decimal value `16384`), a directory. -::: test -name: an in-flight attempt whose event already exists is a skipped duplicate -given: { attempt_state: inFlight, event_exists: true } -expect: { attempt: SkipDuplicate } -::: +The mode and the kind are coupled: a `File` entry's mode must be `FILE_MODE` or +`EXEC_MODE`, and a `Dir` entry's mode must be `DIR_MODE`. Any other pairing, and +any mode value outside these three constants, is rejected by the validator below. ::: test -name: a completed attempt with no matching event emits no event -given: { attempt_state: completed, event_exists: false } -expect: { attempt: NoEventCompleted } +name: the file mode constant is thirty-three thousand one hundred eighty-eight +given: {} +expect: { file_mode_const: 33188 } ::: ::: test -name: a completed attempt whose event already exists still emits no event -given: { attempt_state: completed, event_exists: true } -expect: { attempt: NoEventCompleted } +name: the executable mode constant is thirty-three thousand two hundred sixty-one +given: {} +expect: { exec_mode_const: 33261 } ::: -## The lock lifecycle - -### Exclusive-creation acquisition - -Acquisition is the first rule of the lock lifecycle: a lock is taken by an exclusive -atomic creation of its lock file (the shell's `O_CREAT|O_EXCL`), so exactly one caller -can win a vacant lock and every other caller instead observes a lock file that already -exists. Expose `acquire_decision(observed: AcquireObservation) -> AcquireDecision`, a -total function of the pre-create observation the shell hands it, deciding nothing from -a filesystem and only from the injected observation. Expose a public enum -`AcquireObservation` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with exactly these -variants: `Absent` (no lock file exists, so the exclusive create succeeds), `HeldLive` -(a lock file exists, parses, and its owner is live on this host), `HeldForeignHost` (a -lock file exists, parses, and its owner is on a foreign host, which is never judged -dead here), `HeldDead` (a lock file exists, parses, and its owner is dead on this -host), and `HeldMalformed` (a lock file exists but fails to parse, so it is held-and- -stale-eligible and never trusted). Expose a public enum `AcquireDecision` (deriving -`Debug`, `Clone`, `PartialEq`, `Eq`) with exactly the variants `Acquired`, -`AlreadyHeld`, and `ContendStaleEligible`. - -The decision is total: `Absent` yields `Acquired`, the exclusive create won a vacant -lock and the caller now holds it; `HeldLive` and `HeldForeignHost` each yield -`AlreadyHeld`, a lock a live or foreign-host owner holds is never acquired and never -stolen; `HeldDead` and `HeldMalformed` each yield `ContendStaleEligible`, the existing -lock is stale-eligible so the caller enters the rename-aside steal protocol under the -janitor lock (deciding the steal itself through `steal_decision`) rather than acquiring -directly. Losing the exclusive create to a live owner is exactly the `HeldLive` -observation yielding `AlreadyHeld`; winning a vacant lock is exactly the `Absent` -observation yielding `Acquired`. - ::: test -name: an exclusive create over no existing lock acquires it -given: { observed: Absent } -expect: { acquire: Acquired } +name: the directory mode constant is sixteen thousand three hundred eighty-four +given: {} +expect: { dir_mode_const: 16384 } ::: ::: test -name: an exclusive create that loses the race to a live owner finds it already held -given: { observed: HeldLive } -expect: { acquire: AlreadyHeld } +name: a file entry carrying the directory mode is rejected +given: { json: "the TR_FLAT object with its first entry mode set to 16384 while its kind stays file" } +expect: { error: InvalidMode } ::: ::: test -name: an exclusive create contending a foreign-host owner finds it already held -given: { observed: HeldForeignHost } -expect: { acquire: AlreadyHeld } +name: a directory entry carrying a file mode is rejected +given: { json: "the TR_NESTED object with its sub entry mode set to 33188 while its kind stays dir" } +expect: { error: InvalidMode } ::: ::: test -name: an exclusive create contending a dead same-host owner is stale-eligible -given: { observed: HeldDead } -expect: { acquire: ContendStaleEligible } +name: an entry carrying an unrecognized mode value is rejected +given: { json: "the TR_FLAT object with its first entry mode set to 33152" } +expect: { error: InvalidMode } ::: ::: test -name: an exclusive create contending a malformed lock is stale-eligible -given: { observed: HeldMalformed } -expect: { acquire: ContendStaleEligible } +name: an entry whose mode is the largest u32 is rejected +given: { json: "the TR_FLAT object with its first entry mode set to 4294967295" } +expect: { error: InvalidMode } ::: -### Parsing a lock file +## The error type -Expose `parse_lock(bytes: &[u8]) -> LockState`. It attempts to parse the bytes as a -`LockFile`; on success it returns `LockState::Parsed(LockFile)`, and on any failure -(not valid JSON, missing a required field, or carrying an unknown field under -`deny_unknown_fields`) it returns `LockState::MalformedStaleEligible`. A lock file -that fails to parse is treated as held-and-stale-eligible and is never trusted: it is -never parsed into partial fields and never treated as releasable by token. Expose a -public enum `LockState` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with exactly -the variants `Parsed(LockFile)` and `MalformedStaleEligible`. +Expose a public enum `StoreTreeError` (deriving `Debug`, `Clone`, `PartialEq`, +and `Eq`) with exactly these variants, one per failure mode of the functions +below: + +- `Malformed(String)`, carrying the underlying deserialization message, for any + structural or type failure surfaced by serde (an unknown field, a missing + required field, an unknown `kind` token, a value of the wrong JSON type, an + integer outside `u32`). +- `MalformedHash`, an entry `hash` that is not sixty-four lowercase hex. +- `EmptyName`, an entry whose `name` is the empty string. +- `InvalidName`, an entry whose `name` contains a `/`, or is `.` or `..`. +- `UnsortedEntries`, a tree whose entries are not in strictly ascending `name` + order. +- `DuplicateName`, a tree with two entries sharing a `name`. +- `InvalidMode`, an entry whose `mode` does not match its `kind` under the coupling + rule above, or a build input file whose mode is not `FILE_MODE` or `EXEC_MODE`. +- `InvalidPath`, a build input whose path is empty or has an empty, `.`, or `..` + component. +- `PathConflict`, two build inputs where one path duplicates another, or where one + path is a file at a location that another path uses as a directory prefix. +- `MissingObject`, a diff whose walk references a tree object name that the + supplied object map does not contain. + +Implement `std::fmt::Display` for `StoreTreeError` so a consumer can render a tree +failure as a bare, human-readable message that carries no `Debug` artifacts: no +variant name, no quotes, and no braces. Each variant renders exactly one +deterministic line. `Malformed` renders `malformed tree object: {message}`, +interpolating its carried deserialization message verbatim after the colon and a +single space. `MalformedHash` renders `tree entry hash is not sixty-four lowercase +hex`. `EmptyName` renders `tree entry name is empty`. `InvalidName` renders `tree +entry name contains a slash or is a dot component`. `UnsortedEntries` renders `tree +entries are not in ascending name order`. `DuplicateName` renders `tree has two +entries with the same name`. `InvalidMode` renders `tree entry mode does not match +its kind`. `InvalidPath` renders `build input path is empty or has a dot component`. +`PathConflict` renders `build input paths conflict`. `MissingObject` renders +`referenced tree object is missing from the object map`. This addition is purely +additive: the `Debug` derive is retained for the test assertions that pin variant +equality. The `Display` impl and its `fmt` method carry no doc comment, matching the +crate's existing trait-impl style. + +## Validating a tree + +Expose `validate_tree(value: serde_json::Value) -> Result`: deserialize the JSON value into a `Tree`, returning +`StoreTreeError::Malformed` (carrying serde's message) on any deserialization +failure, then apply these semantic checks, returning the first failure in exactly +this order: + +- For each entry in array order, in this per-entry order: its `name` must be + non-empty, else `StoreTreeError::EmptyName`; its `name` must not contain `/` and + must be neither `.` nor `..`, else `StoreTreeError::InvalidName`; its `hash` must + be a well-formed object name (sixty-four lowercase hex), else + `StoreTreeError::MalformedHash`; its `mode` must match its `kind` under the + coupling rule (a `File` mode is `FILE_MODE` or `EXEC_MODE`, a `Dir` mode is + `DIR_MODE`), else `StoreTreeError::InvalidMode`. +- Then a single left-to-right pass over adjacent entry pairs enforces ordering and + uniqueness: for each adjacent pair, if the two names are equal fail as + `StoreTreeError::DuplicateName`, otherwise if the earlier name is greater than + the later name (by byte-lexicographic comparison of the `name` strings) fail as + `StoreTreeError::UnsortedEntries`. + +Name comparison throughout this module is byte-lexicographic over the UTF-8 bytes +of the `name` strings, the same ordering canonical JSON uses for keys, so a tree +that validates is already in the order its canonical bytes present. + +The struct deserialization is plain serde with `#[serde(deny_unknown_fields)]` on +both structs, so no hand-written deserializer is needed. This module uses no +adjacently tagged enum on purpose: serde does not enforce `deny_unknown_fields` on +the tag-and-content wrapper of an adjacently tagged enum, so such a shape would +demand a hand-written strict `Deserialize` to reject unknown fields, and this +module deliberately avoids that by keeping every type a plain derive. ::: test -name: a well-formed lock parses to a parsed lock -given: { fixture: LOCK } -expect: { parse: Parsed } +name: a tree with an unknown top-level field is rejected +given: { json: "the TR_FLAT object with an extra top-level key \"bogus\": 1" } +expect: { error: Malformed } ::: ::: test -name: an unparseable lock is treated as held-and-stale-eligible -given: { fixture: LOCK_GARBAGE } -expect: { parse: MalformedStaleEligible } +name: a tree missing entries is rejected +given: { json: "the TR_FLAT object with the entries key removed" } +expect: { error: Malformed } ::: ::: test -name: a lock carrying an unknown field is treated as held-and-stale-eligible -given: { fixture: LOCK_EXTRA } -expect: { parse: MalformedStaleEligible } +name: a tree whose entries is the wrong JSON type is rejected +given: { json: "the TR_FLAT object with entries set to the number 1" } +expect: { error: Malformed } ::: -### Token-checked release - -Expose `release_decision(held_token: &str, release_token: &str) -> ReleaseDecision`. -Release is allowed only when the release token equals the held token, yielding -`Released`; any other token yields `Denied`. Expose a public enum `ReleaseDecision` -(deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with exactly the variants `Released` -and `Denied`. - ::: test -name: a release whose token matches the held token is allowed -given: { held_token: "tok-aaaa", release_token: "tok-aaaa" } -expect: { release: Released } +name: an entry with an unknown field is rejected +given: { json: "the TR_FLAT object whose first entry carries an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: ::: test -name: a release whose token differs from the held token is denied -given: { held_token: "tok-aaaa", release_token: "tok-bbbb" } -expect: { release: Denied } +name: an entry missing hash is rejected +given: { json: "the TR_FLAT object whose first entry has the hash key removed" } +expect: { error: Malformed } ::: -### Liveness - -Expose `liveness_decision(lock_host: &str, observer_host: &str, probe_alive: bool) -> -Liveness`. Liveness is judged only on the same host: when the lock's host differs -from the observer's host the result is `ForeignHost` regardless of the probe, because -a process on another machine can never be judged dead here and is therefore never -stealable. On the same host the result is `Live` when the process probe reports the -owner alive and `Dead` when it reports the owner gone. Expose a public enum -`Liveness` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with exactly the variants -`Live`, `Dead`, and `ForeignHost`. +::: test +name: an entry missing name is rejected +given: { json: "the TR_FLAT object whose first entry has the name key removed" } +expect: { error: Malformed } +::: ::: test -name: a same-host owner with a live process probe is live -given: { lock_host: "host-a", observer_host: "host-a", probe_alive: true } -expect: { liveness: Live } +name: an entry whose kind is an unknown token is rejected +given: { json: "the TR_FLAT object with its first entry kind set to \"symlink\"" } +expect: { error: Malformed } ::: ::: test -name: a same-host owner with a dead process probe is dead -given: { lock_host: "host-a", observer_host: "host-a", probe_alive: false } -expect: { liveness: Dead } +name: an entry whose mode is the wrong JSON type is rejected +given: { json: "the TR_FLAT object with its first entry mode set to the string \"33188\"" } +expect: { error: Malformed } ::: ::: test -name: a foreign-host owner with a live probe is foreign-host and never judged dead -given: { lock_host: "host-b", observer_host: "host-a", probe_alive: true } -expect: { liveness: ForeignHost } +name: an entry whose mode exceeds the u32 range is rejected +given: { json: "the TR_FLAT object with its first entry mode set to 4294967296" } +expect: { error: Malformed } ::: ::: test -name: a foreign-host owner with a dead probe is still foreign-host and never judged dead -given: { lock_host: "host-b", observer_host: "host-a", probe_alive: false } -expect: { liveness: ForeignHost } +name: an entry whose hash is not sixty-four lowercase hex is rejected +given: { json: "the TR_FLAT object with its first entry hash set to \"xyz\"" } +expect: { error: MalformedHash } ::: -### Rename-aside steal, serialized under the janitor lock +::: test +name: an entry with an empty name is rejected +given: { json: "the TR_FLAT object with its first entry name set to the empty string" } +expect: { error: EmptyName } +::: -Expose `steal_decision(liveness: Liveness, janitor_held: bool) -> StealDecision`. -Only a `Dead` owner is ever stealable, and only under the janitor lock, so that two -recoverers cannot race: a `Dead` owner with the janitor lock held yields `Steal` -(atomically rename the lock aside), a `Dead` owner without the janitor lock held -yields `NeedJanitorLock` (acquire the janitor lock first), and a `Live` or -`ForeignHost` owner yields `Refuse` regardless of the janitor lock. Expose a public -enum `StealDecision` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with exactly the -variants `Steal`, `Refuse`, and `NeedJanitorLock`. +::: test +name: an entry whose name contains a slash is rejected +given: { json: "the TR_FLAT object with its first entry name set to \"a/b\"" } +expect: { error: InvalidName } +::: ::: test -name: a dead owner under a held janitor lock is stolen -given: { liveness: Dead, janitor_held: true } -expect: { steal: Steal } +name: an entry whose name is a single dot is rejected +given: { json: "the TR_FLAT object with its first entry name set to \".\"" } +expect: { error: InvalidName } ::: ::: test -name: a dead owner without the janitor lock needs the janitor lock first -given: { liveness: Dead, janitor_held: false } -expect: { steal: NeedJanitorLock } +name: an entry whose name is a double dot is rejected +given: { json: "the TR_FLAT object with its first entry name set to \"..\"" } +expect: { error: InvalidName } ::: ::: test -name: a live owner is never stolen even under the janitor lock -given: { liveness: Live, janitor_held: true } -expect: { steal: Refuse } +name: a tree whose entries are not in ascending name order is rejected +given: { json: "the TR_FLAT object with its two entries swapped so b.rs precedes a.rs" } +expect: { error: UnsortedEntries } ::: ::: test -name: a foreign-host owner is never stolen even under the janitor lock -given: { liveness: ForeignHost, janitor_held: true } -expect: { steal: Refuse } +name: a tree with two entries of the same name is rejected +given: { json: "the TR_FLAT object whose second entry name is set to a.rs, equal to the first" } +expect: { error: DuplicateName } ::: -## Install-step planning +## Naming a tree object through `store_hash` -Expose `plan_install_steps(diff: &[store_tree::TreeChange]) -> Vec`. It -turns a generated-tree diff into the complete ordered install plan the shell installs -without reordering, so a shell can never misorder a materialization. The order is the -design's durability order: every domain-owned write first, then every snapshot-wide -derived view, then every permission change, then every deletion, and within each of -those four groups the steps are sorted by path. This is stamp-last by construction: -the durable installed stamp is not an install step and is never part of the plan this -core returns; the installer writes that stamp after every step of this plan is durable, -so the stamp never certifies non-durable contents. The planner never emits a stamp step. +Expose `tree_object_name(tree: &Tree) -> String`, the object's name: serialize the +value to a `serde_json::Value`, and return +`store_hash::object_name(store_hash::ObjectKind::Tree, +&store_hash::canonical_json(&value))`. This does not reimplement canonical JSON, +envelope framing, or hashing; it calls `store_hash`. The canonical bytes fed to +the envelope are exactly `store_hash::canonical_json` applied to the serialized +value, so a round-trip through `store_hash::verify_object` over the framed +envelope succeeds and reports `store_hash::ObjectKind::Tree`. -The planner routes each tree change by classifying its path with `classify_path` and by -whether the change is a content write, a mode-only change, or a removal: +## Building a tree from a flat file set -- An added path, or a modified path whose content hash changed, that classifies as - `DomainOwned` becomes a `WriteFile` step carrying the path, the new hash, and the new - mode. -- An added path, or a modified path whose content hash changed, that classifies as - `SnapshotWideDerived` becomes a `WriteView` step carrying the path and the new hash - and no mode. -- A modified path whose content hash is unchanged but whose mode changed, classifying - as `DomainOwned`, becomes a `SetMode` step carrying the path and the new mode; a - snapshot-wide derived view carries no mode, so a view's mode-only change contributes - no step. -- A removed path that is not build-local ephemera becomes a `DeleteFile` step carrying - the path. -- A path that classifies as `BuildLocalEphemera` is never authoritative and never - installed, so it contributes no step whether it was added, modified, or removed. +A generated target's output is a flat set of files, each at a slash-separated +relative path, each already published as a blob and known by its blob object name, +each with a mode. Building turns that flat set into the nested tree objects that +represent it, computing every subtree's object name so the caller can publish +every object. -Each tree change therefore yields exactly one step or none, and no change yields two. -The returned vector lists all `WriteFile` steps first sorted by path, then all -`WriteView` steps sorted by path, then all `SetMode` steps sorted by path, then all -`DeleteFile` steps sorted by path, so domain content is durable before its snapshot-wide -views, views before permission changes, and every write durable before any obsolete -entry is removed. An empty diff yields an empty plan. +Expose a public struct `TreeFile` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`), +one build input. Public fields: -## Path classification +- `path: String`, the file's slash-separated relative path, for example `lib.rs` + or `sub/inner.rs`. +- `hash: String`, the file's blob object name. +- `mode: u32`, the file's mode, which must be `FILE_MODE` or `EXEC_MODE`; a build + input never carries `DIR_MODE`, because directories are synthesized, not + supplied. -Expose `classify_path(path: &str) -> PathClass`, a total function that maps every -path into exactly one of three authority classes. Build-local ephemera is recognized -first: a path under a `target/` directory, or a `Cargo.lock`, or anything under -`tmp/`, is `BuildLocalEphemera`. Then the snapshot-wide derived views: the `map.json` -cache and the workspace root manifest `Cargo.toml` are `SnapshotWideDerived`. Every -other path, a module's crate file or a per-module derivation document, is -`DomainOwned`. This precedence makes the function total: every input string maps to -exactly one class and no input is unclassified. Expose a public enum `PathClass` -(deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with exactly the variants -`DomainOwned`, `SnapshotWideDerived`, and `BuildLocalEphemera`. +Expose a public struct `BuiltTree` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`), the build result. Public fields: -::: test -name: a module crate file classifies as domain-owned -given: { path: "greeting/src/lib.rs" } -expect: { class: DomainOwned } -::: +- `root: String`, the root tree object's name. +- `objects: std::collections::BTreeMap`, every tree object the build + produced, keyed by its object name: the root tree and every nested subtree. A + `BTreeMap` is used so iteration is deterministic. Blobs are not included; the + caller already holds them. -::: test -name: a per-module derivation document classifies as domain-owned -given: { path: "derivations/greeting/mapl.yaml" } -expect: { class: DomainOwned } -::: +Expose `build_tree(files: &[TreeFile]) -> Result`. It +validates the inputs, then constructs the tree bottom-up: + +- Validate the inputs one at a time in slice order, and within a single input + validate its path before its mode: split the path on `/` into components; the path + must be non-empty and every component must be non-empty and neither `.` nor `..`, + else `StoreTreeError::InvalidPath`; then that same input's mode must be `FILE_MODE` + or `EXEC_MODE`, else `StoreTreeError::InvalidMode`. Because an input's path is + checked before its mode, a single input carrying both a bad path and a bad mode + reports `StoreTreeError::InvalidPath`. (`build_tree` does not validate an input + `hash`: hash validity is the caller's contract, and `build_tree` never returns + `StoreTreeError::MalformedHash`. It copies each input `hash` verbatim into the + entry it builds, so a built tree satisfies `validate_tree` only when every input + `hash` is a valid sixty-four-character lowercase-hex object name; an input carrying + a malformed `hash` yields a tree that `validate_tree` would reject as + `MalformedHash`. The suite supplies only well-formed input hashes.) +- Detect path conflicts: two inputs with the identical path, or one input whose + full path equals a strict directory prefix of another input's path (so one path + names a file where another needs a directory), both fail as + `StoreTreeError::PathConflict`. Both kinds of conflict raise the same variant, so + which kind is detected first is unobservable and is not part of the contract; an + implementation may detect either first, and no test depends on the choice. +- Construct each directory level from the inputs whose paths share that level's + prefix. At each level, an input whose remaining path is a single component + becomes a `File` entry named by that component, carrying the input's `hash` and + `mode`. Inputs whose remaining path has more than one component are grouped by + their next component into a child directory; recursively build that child level, + name the resulting subtree through `tree_object_name`, and emit a `Dir` entry + named by the component, carrying that subtree name as its `hash` and `DIR_MODE` + as its `mode`. Every subtree, including the root, is inserted into `objects` + keyed by its own `tree_object_name`. +- Sort every level's entries by ascending `name` before naming it, so a built tree + over well-formed input hashes satisfies `validate_tree`, and the order of the input + slice never affects the result. +- An empty input slice yields the empty tree: `root` is the empty tree's object + name and `objects` contains exactly that one empty tree. + +Building is total and deterministic given valid inputs: no LLM, no I/O, no +randomness. Its object names are exactly those `tree_object_name` produces for the +trees it builds, so a caller may publish every entry of `objects` under its key and +read them back through `store_hash::verify_object`. ::: test -name: the map cache classifies as a snapshot-wide derived view -given: { path: "map.json" } -expect: { class: SnapshotWideDerived } +name: building an unsorted flat file set yields the pinned flat tree name and sorted entries +given: + files: + - { path: "b.rs", hash: B, mode: 33261 } + - { path: "a.rs", hash: A, mode: 33188 } +expect: + root_name: "5261e5143f1c9d28a42242a93dde34caf3949409524beee39d77bc1e35844dc2" + object_names: ["5261e5143f1c9d28a42242a93dde34caf3949409524beee39d77bc1e35844dc2"] + root_entries: + - { name: "a.rs", kind: file, hash: A, mode: 33188 } + - { name: "b.rs", kind: file, hash: B, mode: 33261 } ::: ::: test -name: the workspace root manifest classifies as a snapshot-wide derived view -given: { path: "Cargo.toml" } -expect: { class: SnapshotWideDerived } +name: building a nested file set yields its pinned subtree and root names and object map +given: + files: + - { path: "lib.rs", hash: A, mode: 33188 } + - { path: "sub/inner.rs", hash: B, mode: 33188 } +expect: + root_name: "cb965100c5cc41bf5b918ee41261379fd920b409294cff6fc2b7e0d9281cd4d2" + subtree_name: "41a134a67bf30067699690165d522d171e7585177d5df0ae0e03850f90253065" + object_names: ["41a134a67bf30067699690165d522d171e7585177d5df0ae0e03850f90253065", "cb965100c5cc41bf5b918ee41261379fd920b409294cff6fc2b7e0d9281cd4d2"] + root_entries: + - { name: "lib.rs", kind: file, hash: A, mode: 33188 } + - { name: "sub", kind: dir, hash: "41a134a67bf30067699690165d522d171e7585177d5df0ae0e03850f90253065", mode: 16384 } + subtree_entries: + - { name: "inner.rs", kind: file, hash: B, mode: 33188 } ::: ::: test -name: a target directory entry classifies as build-local ephemera -given: { path: "target/debug/build" } -expect: { class: BuildLocalEphemera } +name: building a nested file set assigns the directory mode to its synthesized directory entry +given: + files: + - { path: "lib.rs", hash: A, mode: 33188 } + - { path: "sub/inner.rs", hash: B, mode: 33188 } +expect: { sub_entry_kind: dir, sub_entry_mode: 16384 } ::: ::: test -name: a staging lockfile classifies as build-local ephemera -given: { path: "Cargo.lock" } -expect: { class: BuildLocalEphemera } +name: building an empty file set yields the empty tree +given: { files: [] } +expect: + root_name: "a8874cb576274d29e603d4e720fe336a01b8f7b1a75af221c6bbba9b4ed47898" + object_names: ["a8874cb576274d29e603d4e720fe336a01b8f7b1a75af221c6bbba9b4ed47898"] + root_entry_count: 0 ::: ::: test -name: an unmatched path classifies as domain-owned so the function is total -given: { path: "greeting/notes.txt" } -expect: { class: DomainOwned } +name: building a file set with an empty path component is rejected +given: + files: + - { path: "sub//inner.rs", hash: A, mode: 33188 } +expect: { error: InvalidPath } ::: -## The error type - -Expose a public enum `TxnCoreError` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) -with exactly the variant `Malformed(String)`, carrying a human-readable context -string, returned by the strict parse of a record whose JSON carries an unknown field -or an unknown wire token. The strict record parses are exposed as three public -functions, `parse_commit_marker(bytes: &[u8]) -> Result`, -`parse_attempt_marker(bytes: &[u8]) -> Result`, and -`parse_install_step(bytes: &[u8]) -> Result`, each yielding -`Malformed` on a record whose JSON carries an unknown field or an unknown wire token. -Parsing that reaches the lock lifecycle instead of a record constructor uses -`parse_lock`, which never errors and returns `MalformedStaleEligible`. - ::: test -name: a commit marker with an unknown top-level field is rejected -given: { json: "the COMMIT fixture object with an extra top-level key \"bogus\": 1" } -expect: { error: Malformed } +name: building a file set with a dot path component is rejected +given: + files: + - { path: "sub/./inner.rs", hash: A, mode: 33188 } +expect: { error: InvalidPath } ::: ::: test -name: a commit marker whose install step carries an unknown field is rejected -given: { json: "the COMMIT fixture object whose single install step carries an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: building a file set where a file path is a prefix of a directory path is rejected +given: + files: + - { path: "a", hash: A, mode: 33188 } + - { path: "a/b", hash: B, mode: 33188 } +expect: { error: PathConflict } ::: ::: test -name: an attempt marker with an unknown field is rejected -given: { json: "the ATT_INFLIGHT fixture object with an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: building a file set with a duplicate path is rejected +given: + files: + - { path: "a.rs", hash: A, mode: 33188 } + - { path: "a.rs", hash: B, mode: 33188 } +expect: { error: PathConflict } ::: ::: test -name: an install step with an unknown op token is rejected -given: { json: "the STEP_WRITE fixture object with its op set to \"frobnicate\"" } -expect: { error: Malformed } +name: building a file whose mode is the directory mode is rejected +given: + files: + - { path: "a.rs", hash: A, mode: 16384 } +expect: { error: InvalidMode } ::: ::: test -name: an attempt marker with an unknown state token is rejected -given: { json: "the ATT_INFLIGHT fixture object with its state set to \"bogus\"" } -expect: { error: Malformed } +name: building a file whose mode is unrecognized is rejected +given: + files: + - { path: "a.rs", hash: A, mode: 33152 } +expect: { error: InvalidMode } ::: -## Canonical record bytes +## Diffing two trees -The serialization counterpart of the strict parses is exposed as three public -functions that each return one record type's canonical bytes: -`canonical_commit_marker(m: &CommitMarker) -> Vec`, `canonical_attempt_marker(m: -&AttemptMarker) -> Vec`, and `canonical_lock(l: &LockFile) -> Vec`. Each -returns the canonical JSON of the record's serialized value through -`store_hash::canonical_json`, sorted keys, compact, one trailing newline, so its -output is byte for byte the value the matching `parse_commit_marker`, -`parse_attempt_marker`, or `parse_lock` accepts back and byte for byte the canonical -literal this prompt's fixtures pin for that record. These functions never reimplement -canonical JSON and never spell the bytes by hand; they serialize the record and defer -to `store_hash::canonical_json`, keeping this module the sole authority over the -canonical form. +A diff reports, as a flat list of per-leaf changes, how one tree differs from +another, walking only the parts of the two trees whose hashes differ. It is the +Merkle payoff: an unchanged subtree, recognized because both sides carry the same +directory-entry `hash`, is skipped whole. -These three functions are the only sanctioned way for any consumer to obtain a -record's bytes: every consumer, `store_io` and `migrate_io` included, calls -`canonical_commit_marker`, `canonical_attempt_marker`, or `canonical_lock` and never -privately re-derives the bytes of a commit marker, an attempt marker, or a lock file. -Canonical byte authority stays in this module, and the shell writes exactly what these -functions return. +Expose a public enum `TreeChange` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) +with exactly these variants, each naming a leaf file by its full slash-joined path +from the diff root: + +- `TreeChange::Added { path: String, hash: String, mode: u32 }`, a file present in + the new tree and not the old. +- `TreeChange::Removed { path: String, hash: String, mode: u32 }`, a file present + in the old tree and not the new. +- `TreeChange::Modified { path: String, old_hash: String, old_mode: u32, new_hash: + String, new_mode: u32 }`, a file present in both trees at the same path whose + `hash` or `mode` differs between them. + +Expose `diff_trees(old_root: Option<&str>, new_root: Option<&str>, objects: +&std::collections::BTreeMap) -> Result, +StoreTreeError>`. The two roots are object names, or `None` for a side that has no +tree at all (a target never generated, on the old side, or removed entirely, on +the new side). The `objects` map resolves every tree object name the walk needs, +the root and every subtree, on both sides; it is the in-memory provider for this +pure function. The walk: + +- Resolve each non-`None` root through `objects` first, before any equal-roots + check. A root name absent from the map fails as `StoreTreeError::MissingObject`, + including the case where both roots are the same name and that name is absent, so + `diff_trees(Some(x), Some(x), empty_map)` fails as `MissingObject` rather than + returning an empty list. A `None` root is treated as an empty directory with no + entries. +- After both roots have resolved, if `old_root` and `new_root` are both `Some` and + equal, the trees are identical: return no changes without walking their entries. +- Compare the two directory levels by merging their entries on `name` in ascending + order. For each name: + - Present in the new level only: if it is a file, emit `Added` for it; if it is a + directory, resolve its subtree and enumerate every leaf beneath it, emitting an + `Added` per leaf at that leaf's full path. + - Present in the old level only: symmetrically, emit `Removed` for the file, or a + `Removed` per leaf beneath the directory. + - Present in both: if both are files, emit `Modified` when the `hash` or the + `mode` differs and nothing when both are equal; if both are directories with + equal `hash`, skip the whole subtree (the Merkle prune); if both are + directories with differing `hash`, resolve both subtrees and recurse into that + child level; if one is a file and the other a directory, emit a `Removed` for + the old side (the file, or every old leaf) and an `Added` for the new side (the + file, or every new leaf). +- Resolving any directory entry's subtree, whether to recurse or to enumerate its + leaves, that is absent from `objects` fails as `StoreTreeError::MissingObject`. +- Every full path is its ancestor directory names joined by `/` ending in the leaf + name. Return all collected changes sorted by ascending `path` (byte-lexicographic + over the path strings). Because a path names a single leaf, at most one change + exists per path, so the ordering is total and deterministic. + +Diffing computes no hashes and publishes nothing; it only reads the supplied trees +and compares their stored hashes. Renames are not a `TreeChange` variant: a file +whose content moved appears as a `Removed` at its old path and an `Added` at its +new path carrying the same `hash`, and a higher layer that wants rename detection +composes it on top of this raw path-level diff. ::: test -name: canonical_lock returns the lock record's canonical bytes -given: { fixture: LOCK } -expect: { canonical_bytes: "{\"host\":\"host-a\",\"pid\":4321,\"purpose\":\"domain:greeting\",\"startTime\":1000,\"token\":\"tok-aaaa\"}\n" } +name: diffing a tree against itself yields no changes +given: { old: FLAT_AB, new: FLAT_AB } +expect: { changes: [] } ::: ::: test -name: canonical_attempt_marker returns the attempt marker's canonical bytes -given: { fixture: ATT_INFLIGHT } -expect: { canonical_bytes: "{\"attemptId\":\"att-0001\",\"diagnostics\":[],\"module\":\"greeting\",\"operation\":\"agent-run\",\"state\":\"inFlight\",\"target\":\"rust\"}\n" } +name: diffing yields an added file +given: { old: FLAT_A, new: FLAT_AB } +expect: + changes: + - { change: added, path: "b.rs", hash: B, mode: 33261 } ::: ::: test -name: canonical_commit_marker returns the commit marker's canonical bytes -given: { fixture: COMMIT } -expect: { canonical_bytes: "{\"domain\":\"greeting\",\"owner\":\"tok-aaaa\",\"snapshot\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"steps\":[{\"hash\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"mode\":33188,\"op\":\"writeFile\",\"path\":\"greeting/src/lib.rs\"}]}\n" } +name: diffing yields a removed file +given: { old: FLAT_AB, new: FLAT_A } +expect: + changes: + - { change: removed, path: "b.rs", hash: B, mode: 33261 } ::: -## Fixtures the tests pin - -These fixtures are referenced by name in the test suite. The sixty-four-character -hash constants are each a single repeated hex digit: `SNAP` is sixty-four `1`, -`FILEHASH` sixty-four `2`, `DIAG` sixty-four `3`, `VIEWHASH` sixty-four `4`, -`NEWHASH` sixty-four `5`, and `OLDHASH` sixty-four `6`, all well-formed lowercase-hex -tokens compared only for equality. Every canonical byte string below is the exact value the reference -computation `store-txn-ref.py` pins. - -Record fixtures: +::: test +name: diffing yields a modified file when its hash changes +given: { old: FLAT_A, new: FLAT_A_TO_B } +expect: + changes: + - { change: modified, path: "a.rs", oldHash: A, oldMode: 33188, newHash: B, newMode: 33188 } +::: -- **LOCK**: the `LockFile` with host `host-a`, pid `4321`, purpose `domain:greeting`, - start time `1000`, token `tok-aaaa`. Its canonical bytes are exactly - `{"host":"host-a","pid":4321,"purpose":"domain:greeting","startTime":1000,"token":"tok-aaaa"}` - followed by one newline. +::: test +name: diffing yields a modified file when only its mode changes +given: { old: FLAT_A, new: FLAT_A_EXEC } +expect: + changes: + - { change: modified, path: "a.rs", oldHash: A, oldMode: 33188, newHash: A, newMode: 33261 } +::: ::: test -name: a lock file serializes to its canonical bytes -given: { fixture: LOCK } -expect: { canonical_bytes: "{\"host\":\"host-a\",\"pid\":4321,\"purpose\":\"domain:greeting\",\"startTime\":1000,\"token\":\"tok-aaaa\"}\n" } +name: diffing prunes an unchanged subtree and reports only the changed sibling leaf +given: { old: TWO_DIRS_OLD, new: TWO_DIRS_NEW } +expect: + changes: + - { change: modified, path: "chg/y.rs", oldHash: B, oldMode: 33188, newHash: C, newMode: 33188 } ::: ::: test -name: a lock file round-trips back to an equal value -given: { fixture: LOCK } -expect: { round_trip_equal: true } +name: diffing an added directory expands to one added change per leaf file +given: { old: FLAT_LIB, new: LIB_PLUS_DIR } +expect: + changes: + - { change: added, path: "sub/inner.rs", hash: B, mode: 33188 } + - { change: added, path: "sub/more.rs", hash: C, mode: 33188 } ::: -- **ATT_INFLIGHT**: the `AttemptMarker` with attempt id `att-0001`, empty - diagnostics, module `greeting`, operation `agent-run`, state `InFlight`, target - `rust`. Its canonical bytes are exactly - `{"attemptId":"att-0001","diagnostics":[],"module":"greeting","operation":"agent-run","state":"inFlight","target":"rust"}` - followed by one newline. +::: test +name: diffing a removed directory expands to one removed change per leaf file +given: { old: LIB_PLUS_DIR, new: FLAT_LIB } +expect: + changes: + - { change: removed, path: "sub/inner.rs", hash: B, mode: 33188 } + - { change: removed, path: "sub/more.rs", hash: C, mode: 33188 } +::: ::: test -name: an in-flight attempt marker serializes to its canonical bytes -given: { fixture: ATT_INFLIGHT } -expect: { canonical_bytes: "{\"attemptId\":\"att-0001\",\"diagnostics\":[],\"module\":\"greeting\",\"operation\":\"agent-run\",\"state\":\"inFlight\",\"target\":\"rust\"}\n" } +name: diffing a name that changes from file to directory yields a removed leaf and added leaves +given: { old: X_FILE, new: X_DIR } +expect: + changes: + - { change: removed, path: "x", hash: A, mode: 33188 } + - { change: added, path: "x/a.rs", hash: B, mode: 33188 } ::: ::: test -name: an in-flight attempt marker round-trips back to an equal value -given: { fixture: ATT_INFLIGHT } -expect: { round_trip_equal: true } +name: diffing an absent old root reports every new leaf as added +given: { old: none, new: FLAT_AB } +expect: + changes: + - { change: added, path: "a.rs", hash: A, mode: 33188 } + - { change: added, path: "b.rs", hash: B, mode: 33261 } ::: -- **ATT_DONE**: the `AttemptMarker` with attempt id `att-0001`, diagnostics holding - the single hash `DIAG`, module `greeting`, operation `agent-run`, state - `Completed`, target `rust`. Its canonical bytes are exactly - `{"attemptId":"att-0001","diagnostics":["3333333333333333333333333333333333333333333333333333333333333333"],"module":"greeting","operation":"agent-run","state":"completed","target":"rust"}` - followed by one newline. +::: test +name: diffing an absent new root reports every old leaf as removed +given: { old: FLAT_AB, new: none } +expect: + changes: + - { change: removed, path: "a.rs", hash: A, mode: 33188 } + - { change: removed, path: "b.rs", hash: B, mode: 33261 } +::: ::: test -name: a completed attempt marker serializes to its canonical bytes -given: { fixture: ATT_DONE } -expect: { canonical_bytes: "{\"attemptId\":\"att-0001\",\"diagnostics\":[\"3333333333333333333333333333333333333333333333333333333333333333\"],\"module\":\"greeting\",\"operation\":\"agent-run\",\"state\":\"completed\",\"target\":\"rust\"}\n" } +name: diffing a root hash absent from the object map is rejected +given: { old: MISSING_ROOT, new: FLAT_AB } +expect: { error: MissingObject } ::: -- **ATT_NOTARGET**: the `AttemptMarker` with attempt id `att-0002`, empty - diagnostics, module `greeting`, operation `derivation:mapl`, state `InFlight`, and - no target. Its canonical bytes are exactly - `{"attemptId":"att-0002","diagnostics":[],"module":"greeting","operation":"derivation:mapl","state":"inFlight"}` - followed by one newline; the `target` key is omitted entirely. +::: test +name: diffing a changed subtree hash absent from the object map is rejected +given: { old: DIR_WITH_MISSING_CHILD, new: LIB_PLUS_DIR } +expect: { error: MissingObject } +::: ::: test -name: an attempt marker with no target omits the target key -given: { fixture: ATT_NOTARGET } -expect: { canonical_bytes: "{\"attemptId\":\"att-0002\",\"diagnostics\":[],\"module\":\"greeting\",\"operation\":\"derivation:mapl\",\"state\":\"inFlight\"}\n" } +name: diffing reports its changes in ascending path order +given: { old: FLAT_B, new: FLAT_A_AND_C } +expect: + changes: + - { change: added, path: "a.rs", hash: A, mode: 33188 } + - { change: removed, path: "b.rs", hash: B, mode: 33188 } + - { change: added, path: "c.rs", hash: C, mode: 33188 } ::: -- **COMMIT**: the `CommitMarker` with domain `greeting`, owner `tok-aaaa`, snapshot - `SNAP`, and one step, a `WriteFile` of path `greeting/src/lib.rs`, hash `FILEHASH`, - mode `33188`. Its canonical bytes are exactly - `{"domain":"greeting","owner":"tok-aaaa","snapshot":"1111111111111111111111111111111111111111111111111111111111111111","steps":[{"hash":"2222222222222222222222222222222222222222222222222222222222222222","mode":33188,"op":"writeFile","path":"greeting/src/lib.rs"}]}` - followed by one newline. +## Fixtures the tests pin + +These fixtures and symbols are referenced by name in the test suite. The hash +constants are each a sixty-four-character string of a single repeated character: +`A` is sixty-four `a` characters, `B` sixty-four `b`, `C` sixty-four `c`, and `D` +sixty-four `d`. All are well-formed lowercase-hex object names. The modes named in +the suite are the three constants: `33188` is `FILE_MODE`, `33261` is `EXEC_MODE`, +and `16384` is `DIR_MODE`. + +Object fixtures: + +- **TR_EMPTY**: a `Tree` with no entries. Its canonical bytes are exactly + `{"entries":[]}` followed by one newline; its envelope is `napl-store tree v1 + 15\n` followed by those canonical bytes; its object name is + `a8874cb576274d29e603d4e720fe336a01b8f7b1a75af221c6bbba9b4ed47898`. ::: test -name: a commit marker serializes to its canonical bytes -given: { fixture: COMMIT } -expect: { canonical_bytes: "{\"domain\":\"greeting\",\"owner\":\"tok-aaaa\",\"snapshot\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"steps\":[{\"hash\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"mode\":33188,\"op\":\"writeFile\",\"path\":\"greeting/src/lib.rs\"}]}\n" } +name: an empty tree canonicalizes to its pinned bytes +given: { fixture: TR_EMPTY } +expect: { canonical_bytes: "{\"entries\":[]}\n" } ::: ::: test -name: a commit marker round-trips back to an equal value -given: { fixture: COMMIT } -expect: { round_trip_equal: true } +name: an empty tree names itself by its pinned digest +given: { fixture: TR_EMPTY } +expect: { object_name: "a8874cb576274d29e603d4e720fe336a01b8f7b1a75af221c6bbba9b4ed47898" } ::: -- **STEP_WRITE**: the `WriteFile` step of path `greeting/src/lib.rs`, hash - `FILEHASH`, mode `33188`. Its canonical bytes are exactly - `{"hash":"2222222222222222222222222222222222222222222222222222222222222222","mode":33188,"op":"writeFile","path":"greeting/src/lib.rs"}` - followed by one newline. - ::: test -name: a write-file install step serializes to its canonical bytes -given: { fixture: STEP_WRITE } -expect: { canonical_bytes: "{\"hash\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"mode\":33188,\"op\":\"writeFile\",\"path\":\"greeting/src/lib.rs\"}\n" } +name: an empty tree frames its pinned envelope bytes +given: { fixture: TR_EMPTY } +expect: { envelope_bytes: "napl-store tree v1 15\n{\"entries\":[]}\n" } ::: -- **STEP_VIEW**: the `WriteView` step of path `map.json`, hash `VIEWHASH`. Its - canonical bytes are exactly - `{"hash":"4444444444444444444444444444444444444444444444444444444444444444","op":"writeView","path":"map.json"}` - followed by one newline. +::: test +name: an empty tree round-trips through the read verifier +given: { fixture: TR_EMPTY } +expect: { verify_ok: true, kind: tree } +::: ::: test -name: a write-view install step serializes to its canonical bytes -given: { fixture: STEP_VIEW } -expect: { canonical_bytes: "{\"hash\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"op\":\"writeView\",\"path\":\"map.json\"}\n" } +name: an empty tree round-trips back to an equal value +given: { fixture: TR_EMPTY } +expect: { round_trip_equal: true } ::: -- **STEP_SETMODE**: the `SetMode` step of path `greeting/src/lib.rs`, mode `33261`. - Its canonical bytes are exactly - `{"mode":33261,"op":"setMode","path":"greeting/src/lib.rs"}` followed by one - newline. +- **TR_FLAT**: a `Tree` with two `File` entries in order, `{name: a.rs, kind: File, + hash: A, mode: FILE_MODE}` and `{name: b.rs, kind: File, hash: B, mode: + EXEC_MODE}`. Its canonical bytes are exactly + `{"entries":[{"hash":"","kind":"file","mode":33188,"name":"a.rs"},{"hash":"","kind":"file","mode":33261,"name":"b.rs"}]}` + followed by one newline; its envelope begins `napl-store tree v1 248\n`; its + object name is + `5261e5143f1c9d28a42242a93dde34caf3949409524beee39d77bc1e35844dc2`. ::: test -name: a set-mode install step serializes to its canonical bytes -given: { fixture: STEP_SETMODE } -expect: { canonical_bytes: "{\"mode\":33261,\"op\":\"setMode\",\"path\":\"greeting/src/lib.rs\"}\n" } +name: a flat two-file tree canonicalizes to its pinned bytes +given: { fixture: TR_FLAT } +expect: { canonical_bytes: "{\"entries\":[{\"hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"kind\":\"file\",\"mode\":33188,\"name\":\"a.rs\"},{\"hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"kind\":\"file\",\"mode\":33261,\"name\":\"b.rs\"}]}\n" } ::: -- **STEP_DELETE**: the `DeleteFile` step of path `greeting/src/old.rs`. Its canonical - bytes are exactly `{"op":"deleteFile","path":"greeting/src/old.rs"}` followed by one - newline. - ::: test -name: a delete-file install step serializes to its canonical bytes -given: { fixture: STEP_DELETE } -expect: { canonical_bytes: "{\"op\":\"deleteFile\",\"path\":\"greeting/src/old.rs\"}\n" } +name: a flat two-file tree names itself by its pinned digest +given: { fixture: TR_FLAT } +expect: { object_name: "5261e5143f1c9d28a42242a93dde34caf3949409524beee39d77bc1e35844dc2" } ::: -- **LOCK_GARBAGE**: the byte string `not a lock at all`, which is not valid JSON. - `parse_lock` returns `MalformedStaleEligible`. -- **LOCK_EXTRA**: the LOCK canonical JSON with an extra top-level key `"bogus": 1` - added. Under `deny_unknown_fields` it fails to parse, so `parse_lock` returns - `MalformedStaleEligible`. +::: test +name: a flat two-file tree frames its pinned envelope bytes +given: { fixture: TR_FLAT } +expect: { envelope_bytes: "napl-store tree v1 248\n{\"entries\":[{\"hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"kind\":\"file\",\"mode\":33188,\"name\":\"a.rs\"},{\"hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"kind\":\"file\",\"mode\":33261,\"name\":\"b.rs\"}]}\n" } +::: -Planning fixtures: +::: test +name: a flat two-file tree round-trips back to an equal value +given: { fixture: TR_FLAT } +expect: { round_trip_equal: true } +::: -- **PLAN_MIXED**: a tree diff of three changes in this input order: a - `TreeChange::Modified` of path `greeting/src/lib.rs` from old hash `OLDHASH` and old - mode `33188` to new hash `FILEHASH` and new mode `33188` (a content change, since the - hash differs), a `TreeChange::Removed` of path `greeting/src/old.rs`, and a - `TreeChange::Added` of path `greeting/src/new.rs` with hash `NEWHASH` and mode - `33188`. All three paths are domain-owned. `plan_install_steps` returns exactly, in - this order, a `WriteFile` of `greeting/src/lib.rs` (hash `FILEHASH`, mode `33188`), a - `WriteFile` of `greeting/src/new.rs` (hash `NEWHASH`, mode `33188`), and a - `DeleteFile` of `greeting/src/old.rs`: both writes first sorted by path, then the - delete. +- **TR_NESTED**: a `Tree` with a `File` entry `{name: lib.rs, kind: File, hash: A, + mode: FILE_MODE}` and a `Dir` entry `{name: sub, kind: Dir, hash: C, mode: + DIR_MODE}`, in that order. Its object name is + `fa9b36a98af04fdaa9450e0bc37bf207faa00b84a4583654f00dd7e82836630a`. ::: test -name: install-step planning orders writes before deletes each sorted by path -given: { fixture: PLAN_MIXED } -expect: { install_order: "writeFile:greeting/src/lib.rs,writeFile:greeting/src/new.rs,deleteFile:greeting/src/old.rs" } +name: a nested tree canonicalizes to its pinned bytes +given: { fixture: TR_NESTED } +expect: { canonical_bytes: "{\"entries\":[{\"hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"kind\":\"file\",\"mode\":33188,\"name\":\"lib.rs\"},{\"hash\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"kind\":\"dir\",\"mode\":16384,\"name\":\"sub\"}]}\n" } ::: -- **PLAN_EMPTY**: an empty tree diff. `plan_install_steps` returns an empty vector. - ::: test -name: install-step planning over an empty diff yields no steps -given: { fixture: PLAN_EMPTY } -expect: { install_order: "" } +name: a nested tree names itself by its pinned digest +given: { fixture: TR_NESTED } +expect: { object_name: "fa9b36a98af04fdaa9450e0bc37bf207faa00b84a4583654f00dd7e82836630a" } ::: -- **PLAN_FULL**: a tree diff exercising all four install-step kinds and the ephemera - skip, given in this deliberately unsorted input order: a `TreeChange::Removed` of - path `target/debug/junk.o`, a `TreeChange::Modified` of path `map.json` from old hash - `OLDHASH` and old mode `33188` to new hash `VIEWHASH` and new mode `33188`, a - `TreeChange::Removed` of path `greeting/src/old.rs`, a `TreeChange::Added` of path - `greeting/src/new.rs` with hash `NEWHASH` and mode `33188`, a `TreeChange::Modified` - of path `greeting/src/perm.rs` from old hash `FILEHASH` and old mode `33188` to new - hash `FILEHASH` and new mode `33261` (a mode-only change, the hash unchanged on both - sides), a `TreeChange::Modified` of path `greeting/src/lib.rs` from old hash `OLDHASH` - and old mode `33188` to new hash `FILEHASH` and new mode `33188`, a - `TreeChange::Modified` of path `Cargo.toml` from old hash `OLDHASH` and old mode - `33188` to new hash `VIEWHASH` and new mode `33188`, and a `TreeChange::Removed` of - path `greeting/src/gone.rs`. `plan_install_steps` returns exactly, in this order: a - `WriteFile` of `greeting/src/lib.rs` (hash `FILEHASH`, mode `33188`), a `WriteFile` of - `greeting/src/new.rs` (hash `NEWHASH`, mode `33188`), a `WriteView` of `Cargo.toml` - (hash `VIEWHASH`), a `WriteView` of `map.json` (hash `VIEWHASH`), a `SetMode` of - `greeting/src/perm.rs` (mode `33261`), a `DeleteFile` of `greeting/src/gone.rs`, and a - `DeleteFile` of `greeting/src/old.rs`. The `target/debug/junk.o` change contributes no - step because build-local ephemera is never installed. This proves the four groups - appear in the order writes, then views, then permissions, then deletions, and that - each group is sorted by path (`greeting/src/lib.rs` before `greeting/src/new.rs`, - `Cargo.toml` before `map.json`, `greeting/src/gone.rs` before `greeting/src/old.rs`). +::: test +name: a nested tree round-trips through the read verifier +given: { fixture: TR_NESTED } +expect: { verify_ok: true, kind: tree } +::: ::: test -name: install-step planning orders writes then views then permissions then deletes each sorted by path -given: { fixture: PLAN_FULL } -expect: { install_order: "writeFile:greeting/src/lib.rs,writeFile:greeting/src/new.rs,writeView:Cargo.toml,writeView:map.json,setMode:greeting/src/perm.rs,deleteFile:greeting/src/gone.rs,deleteFile:greeting/src/old.rs" } +name: a nested tree round-trips back to an equal value +given: { fixture: TR_NESTED } +expect: { round_trip_equal: true } ::: +Build results the suite pins, computed by naming the built trees through +`tree_object_name` (verified against the reference values): the nested build over +files `lib.rs` (hash `A`, `FILE_MODE`) and `sub/inner.rs` (hash `B`, `FILE_MODE`) +produces a subtree `{entries: [{name: inner.rs, kind: File, hash: B, mode: +FILE_MODE}]}` named +`41a134a67bf30067699690165d522d171e7585177d5df0ae0e03850f90253065` and a root +`{entries: [{name: lib.rs, kind: File, hash: A, mode: FILE_MODE}, {name: sub, kind: +Dir, hash: , mode: DIR_MODE}]}` named +`cb965100c5cc41bf5b918ee41261379fd920b409294cff6fc2b7e0d9281cd4d2`; the flat build +over files `a.rs` (hash `A`, `FILE_MODE`) and `b.rs` (hash `B`, `EXEC_MODE`) +produces exactly TR_FLAT and so is named +`5261e5143f1c9d28a42242a93dde34caf3949409524beee39d77bc1e35844dc2`; the empty build +produces TR_EMPTY, named +`a8874cb576274d29e603d4e720fe336a01b8f7b1a75af221c6bbba9b4ed47898`. + +Diff fixtures are built in the test as trees plus an `objects` map that resolves +each tree by its own `tree_object_name`, and the roots passed to `diff_trees` are +those same names. Each fixture below names the trees by content; the test names +each through `tree_object_name`, inserts every tree the walk needs into the map, +and passes the corresponding root names: + +- **FLAT_A**: a single-file tree `{a.rs, File, A, FILE_MODE}`. +- **FLAT_AB**: the two-file tree equal to TR_FLAT (`a.rs` hash `A` `FILE_MODE`; + `b.rs` hash `B` `EXEC_MODE`). +- **FLAT_A_TO_B**: a single-file tree `{a.rs, File, B, FILE_MODE}` (same name as + FLAT_A, different hash). +- **FLAT_A_EXEC**: a single-file tree `{a.rs, File, A, EXEC_MODE}` (same name and + hash as FLAT_A, different mode). +- **FLAT_LIB**: a single-file tree `{lib.rs, File, A, FILE_MODE}`. +- **LIB_PLUS_DIR**: a tree with `{lib.rs, File, A, FILE_MODE}` and a `Dir` entry + `sub` whose subtree is `{inner.rs, File, B, FILE_MODE}` and `{more.rs, File, C, + FILE_MODE}`. +- **TWO_DIRS_OLD**: a tree with two `Dir` entries, `keep` whose subtree is `{x.rs, + File, A, FILE_MODE}`, and `chg` whose subtree is `{y.rs, File, B, FILE_MODE}`. +- **TWO_DIRS_NEW**: the same tree except `chg`'s subtree is `{y.rs, File, C, + FILE_MODE}`; the `keep` directory entry is byte-identical to TWO_DIRS_OLD's and + so carries the same subtree hash. +- **X_FILE**: a single-file tree `{x, File, A, FILE_MODE}`. +- **X_DIR**: a tree with a single `Dir` entry `x` whose subtree is `{a.rs, File, B, + FILE_MODE}`. +- **FLAT_B**: a single-file tree `{b.rs, File, B, FILE_MODE}`. +- **FLAT_A_AND_C**: a two-file tree `{a.rs, File, A, FILE_MODE}` and `{c.rs, File, + C, FILE_MODE}`. +- **MISSING_ROOT**: a root object name (sixty-four lowercase hex, use `D`) that is + deliberately not inserted into the `objects` map, so `diff_trees` fails as + `MissingObject`. +- **DIR_WITH_MISSING_CHILD**: a tree with a single `Dir` entry `sub` whose `hash` + is a sixty-four-hex name (use `D`) that is not inserted into the map, diffed + against LIB_PLUS_DIR so the differing `sub` entry forces resolution of the absent + subtree and fails as `MissingObject`. + ## Test and code discipline -The frontmatter test suite is a contract, not a sketch. Each case in it is realized -by its own dedicated test function that exercises only that case, so the number of -dedicated tests is at least the number of frontmatter cases. Never fold two or more -cases into one shared test, and a single test that loops over a table of inputs never -counts as the dedicated test any of those inputs requires: such a combined test may -exist only as an extra. Cases that differ only in their input while sharing an -expected shape are still distinct cases, each owed its own dedicated test. +The frontmatter test suite is a contract, not a sketch. Each case in it is +realized by its own dedicated test function that exercises only that case, so the +number of dedicated tests is at least the number of frontmatter cases. Never fold +two or more cases into one shared test, and a single test that loops over a table +of inputs never counts as the dedicated test any of those inputs requires: such a +combined test may exist only as an extra. Cases that differ only in their input +while sharing an expected value are still distinct cases, each owed its own +dedicated test. In particular, each wire-string case gets its own test: the two +`EntryKind` serialize cases (`file`, `dir`) and the two `EntryKind` deserialize +cases are each their own dedicated test. Likewise every case that expects +`Malformed` (the unknown top-level field, the missing entries, the wrong-typed +entries, the unknown entry field, the missing hash, the missing name, the unknown +kind token, the wrong-typed mode, and the mode over the u32 range), every case that +expects `MalformedHash`, `EmptyName`, `InvalidName`, `UnsortedEntries`, +`DuplicateName`, `InvalidMode`, `InvalidPath`, `PathConflict`, or `MissingObject`, +gets its own dedicated test whatever shared loop may also exist as an extra. Each +integer-width case pins its exact literal integer in its own test: `4294967296` for +a mode one past the u32 range rejected as `Malformed`, and `4294967295` for the +largest-u32 mode rejected as `InvalidMode` (the largest u32 parses as an integer +but is not one of the three mode constants). Each mode-constant case pins its exact +decimal literal in its own dedicated test: `FILE_MODE` equals `33188`, `EXEC_MODE` +equals `33261`, and `DIR_MODE` equals `16384`, one test per constant, none +discharging another. -Enumerate the case families by name, each owed its own dedicated verbatim test. The -canonical-bytes cases: the LOCK case, the ATT_INFLIGHT case, the ATT_DONE case, the -ATT_NOTARGET case, the COMMIT case, the STEP_WRITE case, the STEP_VIEW case, the -STEP_SETMODE case, and the STEP_DELETE case, each its own test asserting the full -canonical byte string. The round-trip cases: the LOCK round-trip, the ATT_INFLIGHT -round-trip, and the COMMIT round-trip, each its own test. The marker classification -cases: the two ancestor cases (live owner and dead owner, both `RollForwardDelete`, -proving the owner-liveness is irrelevant when ancestor), the non-ancestor live case -(`LeavePreHeadWindow`), and the non-ancestor dead case (`DeleteAndCollect`), each its -own test. The attempt-disposition cases: in-flight without event -(`EmitInterrupted`), in-flight with event (`SkipDuplicate`), completed without event -(`NoEventCompleted`), and completed with event (`NoEventCompleted`), each its own -test. The lock-parse cases: the well-formed LOCK (`Parsed`), the garbage LOCK_GARBAGE -(`MalformedStaleEligible`), and the extra-key LOCK_EXTRA (`MalformedStaleEligible`), -each its own test. The release cases: matching token (`Released`) and differing token -(`Denied`). The liveness cases: same-host live (`Live`), same-host dead (`Dead`), -foreign-host with a live probe (`ForeignHost`), and foreign-host with a dead probe -(`ForeignHost`), each its own test, the last two proving the host gate overrides the -probe. The steal cases: dead with the janitor lock (`Steal`), dead without it -(`NeedJanitorLock`), live with the janitor lock (`Refuse`), and foreign-host with the -janitor lock (`Refuse`), each its own test. The acquisition cases: exclusive-create -over no existing lock (`observed` `Absent`, `Acquired`), create losing the race to a -live owner (`observed` `HeldLive`, `AlreadyHeld`), contending a foreign-host owner -(`observed` `HeldForeignHost`, `AlreadyHeld`), contending a dead same-host owner -(`observed` `HeldDead`, `ContendStaleEligible`), and contending a malformed lock -(`observed` `HeldMalformed`, `ContendStaleEligible`), each its own test, the two -`AlreadyHeld` cases and the two `ContendStaleEligible` cases proving the input variant -they name and never discharging their sibling. The path-classification cases, each named -by the exact frontmatter given: path it hands `classify_path` and each its own test: -the crate file `greeting/src/lib.rs` (`DomainOwned`), the derivation document -`derivations/greeting/mapl.yaml` (`DomainOwned`), the map cache `map.json` -(`SnapshotWideDerived`), the workspace manifest `Cargo.toml` (`SnapshotWideDerived`), -the target entry `target/debug/build` (`BuildLocalEphemera`), the staging lockfile -`Cargo.lock` (`BuildLocalEphemera`), and the unmatched-path totality case -`greeting/notes.txt` (`DomainOwned`). Each path case's dedicated test passes -`classify_path` that case's own frontmatter given: path byte for byte and asserts its -pinned class; a path of the same class but a different string is a neighboring -stand-in that does not discharge the case, so the derivation-document case is tested -with `derivations/greeting/mapl.yaml` and no other domain-owned path, the target-entry -case with `target/debug/build` and no other ephemeral path, and the unmatched-path -totality case with `greeting/notes.txt` and no other unclassified path. The install-planning -cases: PLAN_MIXED (the ordered write-write-delete plan), PLAN_EMPTY (the empty plan), -and PLAN_FULL (the full ordered plan across all four step kinds with the ephemera -skip), each its own test. The rejection cases: the commit-marker unknown top-level field, the -install-step unknown field, the attempt-marker unknown field, the install-step -unknown op token, and the attempt-marker unknown state token, each its own test. +Inside a case's dedicated test, hand the code exactly the fixture that case gives, +byte for byte, and assert its pinned expectation verbatim: no shorter or more +convenient stand-in, no re-derivation of a value the case pins as a literal, and no +weakening of a pinned equality into an inequality-only or recomputed check. The +byte-pinned object cases assert their exact literals: TR_EMPTY canonicalizes to +`{"entries":[]}` plus one newline, names to +`a8874cb576274d29e603d4e720fe336a01b8f7b1a75af221c6bbba9b4ed47898`, and frames the +envelope `napl-store tree v1 15\n{"entries":[]}\n`; TR_FLAT canonicalizes to its +pinned bytes, names to +`5261e5143f1c9d28a42242a93dde34caf3949409524beee39d77bc1e35844dc2`, and frames the +complete envelope, the header `napl-store tree v1 248\n` followed by its pinned +canonical bytes, asserted in full as TR_EMPTY is and never a prefix comparison; +TR_NESTED canonicalizes to its +pinned bytes and names to +`fa9b36a98af04fdaa9450e0bc37bf207faa00b84a4583654f00dd7e82836630a`. Where a case +pins canonical bytes, obtain them as `store_hash::canonical_json` applied to the +serialized value and compare to the literal; where a case pins an object name, +obtain it from `tree_object_name` and compare to the literal; where a case pins +envelope bytes, frame them through `store_hash::envelope_bytes` over the canonical +payload and compare to the literal. -Inside a case's dedicated test, hand the code exactly the fixture or the argument -tuple that case names, byte for byte, and assert its pinned expectation verbatim: no -shorter or more convenient stand-in, no re-derivation of a value the case pins as a -literal, and no weakening of a pinned equality into an inequality-only or recomputed -check. Every canonical-bytes case serializes the named record with -`store_hash::canonical_json` over its serialized value and compares the result to the -full literal byte string given above, never a prefix and never a re-spelling: the -ATT_NOTARGET case asserts the `target` key is absent by comparing to its exact -literal, the STEP_VIEW case asserts no `mode` key by comparing to its exact literal, -and the COMMIT case asserts the nested step's keys are sorted `hash`, `mode`, `op`, -`path` by comparing to its exact literal. Each round-trip case serializes its named -record to canonical bytes, parses those bytes back through the record's strict -`Deserialize`, and asserts the recovered value equals the original verbatim, never -weakening that equality into a mere `is_ok` acceptance. +A dedicated test asserts only its own case and nothing more: the TR_EMPTY name test +asserts only that pinned digest, leaving the canonical-bytes shape to the case that +owns it, and the TR_FLAT name test asserts only that pinned digest. Each verifier +round-trip case builds the exact fixture its own case names, never a heavier or +different fixture substituted for it: the empty-tree verifier case builds TR_EMPTY +and the nested-tree verifier case builds TR_NESTED; each names its object through +`tree_object_name`, frames its envelope through `store_hash::envelope_bytes` over +its canonical payload, and asserts `store_hash::verify_object` returns success +carrying `store_hash::ObjectKind::Tree`. Each round-trip-equal case builds its own +named fixture, serializes it, recovers it through `validate_tree`, and asserts the +recovered value equals the original verbatim, never weakening that equality into a +mere `is_ok` acceptance and never substituting a lighter or neighboring fixture: +the TR_EMPTY round-trip builds TR_EMPTY, the TR_FLAT round-trip builds TR_FLAT, and +the TR_NESTED round-trip builds TR_NESTED. -The decision-table cases pass exactly the argument tuple the case names and assert -the exact returned variant, never a neighboring variant and never a mere -discriminant check. The named-substitution prohibitions for this module's confusable -tokens are strict: never return `RollForwardDelete` where a case pins -`DeleteAndCollect` or `LeavePreHeadWindow`; never `EmitInterrupted` where a case pins -`SkipDuplicate` or `NoEventCompleted`; never `Live` where a case pins `Dead` or -`ForeignHost`; never `Steal` where a case pins `Refuse` or `NeedJanitorLock`; never -`Released` where a case pins `Denied`; never `DomainOwned` where a case pins -`SnapshotWideDerived` or `BuildLocalEphemera`; never `Parsed` where a case pins -`MalformedStaleEligible`; never `Acquired` where a case pins `AlreadyHeld` or -`ContendStaleEligible`; never `AlreadyHeld` where a case pins `Acquired` or -`ContendStaleEligible`; and never the wire token `inFlight` where a case pins -`completed`, `writeFile` where a case pins `writeView`, `setMode`, or `deleteFile`, -`startTime` where a case pins another field, or `attemptId` where a case pins -`operation`. Each confusable token is transcribed exactly as its case names it. The -two ancestor cases differ only in `owner_live` and each is its own test; the two -foreign-host cases differ only in `probe_alive` and each is its own test; the two -completed attempt cases differ only in `event_exists` and each is its own test; none -of these pairs discharges its sibling. +The rejection cases construct exactly the malformed JSON the case describes, built +by taking the named fixture's JSON and applying only the one described mutation +using that mutation's exact literal tokens, and assert the pinned error variant. +The mutation literal is transcribed verbatim from the case, never paraphrased into +a more convenient stand-in, because the exact bytes are the point of the test. In +particular: the extra-key cases (the unknown top-level field on TR_FLAT and the +unknown entry field on TR_FLAT's first entry) each insert the key named `bogus` +with the integer value `1`, exactly `"bogus": 1`, never a renamed key and never a +restyled value, and neither discharges the other; the unknown-kind case sets the +first entry's `kind` to the exact token `symlink`; the wrong-typed-mode case sets +`mode` to the JSON string `"33188"`; the wrong-typed-entries case sets `entries` to +the JSON number `1`; the malformed-hash case sets the first entry's `hash` to +exactly `"xyz"`; the empty-name case sets the first entry's `name` to the empty +string; the slash-name, dot-name, and dotdot-name cases set the first entry's +`name` to exactly `a/b`, `.`, and `..` respectively, each its own dedicated +`InvalidName` test and none discharging another; the unsorted case swaps TR_FLAT's +two entries so `b.rs` precedes `a.rs`, reaching `UnsortedEntries`; the duplicate +case sets TR_FLAT's second entry `name` to `a.rs` so the two names are equal, +reaching `DuplicateName`; the file-with-dir-mode case sets TR_FLAT's first entry +`mode` to `16384` with its kind left `file`, the dir-with-file-mode case sets +TR_NESTED's `sub` entry `mode` to `33188` with its kind left `dir`, the +unrecognized-mode case sets a file entry `mode` to `33152`, and the largest-u32 +case sets a file entry `mode` to `4294967295`, each its own `InvalidMode` test. -The rejection cases construct exactly the malformed JSON the case describes, built by -taking the named fixture's canonical JSON and applying only the one described -mutation using that mutation's exact literal tokens, and assert `TxnCoreError:: -Malformed`. Every extra-key case inserts the key named `bogus` with the integer value -`1`, exactly `"bogus": 1`, never a renamed key such as `extra` and never a restyled -value such as the string `"nope"`, and no extra-key case discharges another: the -commit-marker case starts from COMMIT and adds the key at the object top level, the -install-step case starts from COMMIT and adds it to the single step object, and the -attempt-marker case starts from ATT_INFLIGHT and adds it at the top level. The -unknown-op case starts from STEP_WRITE and sets its `op` to exactly `"frobnicate"`, -and the unknown-state case starts from ATT_INFLIGHT and sets its `state` to exactly -`"bogus"`; each asserts `TxnCoreError::Malformed` and neither is a mere `is_err` -check. The install-step unknown-field and unknown-op cases both prove the hand-written -strict `Deserialize` of the internally tagged `InstallStep`, which serde would -otherwise let pass. +Each build case builds the exact `TreeFile` list its fixture names, in the order +the fixture lists them, and asserts every value the case pins. The unsorted-flat +build passes its two files in the order `b.rs` then `a.rs` and asserts the result's +`root` equals +`5261e5143f1c9d28a42242a93dde34caf3949409524beee39d77bc1e35844dc2`, that its +`objects` map contains exactly that one key, and that the root tree's entries are +`a.rs` then `b.rs` in that sorted order with the pinned kinds, hashes, and modes; +it never asserts the input order survives. The nested build passes `lib.rs` and +`sub/inner.rs` and asserts the root name +`cb965100c5cc41bf5b918ee41261379fd920b409294cff6fc2b7e0d9281cd4d2`, the subtree +name `41a134a67bf30067699690165d522d171e7585177d5df0ae0e03850f90253065`, that +`objects` contains exactly those two keys, and the complete pinned `root_entries` +list in order, asserting every field of every root entry and not merely one of +them: the first entry is `lib.rs` as a `File` with hash `A` and `FILE_MODE`, and +the second entry is `sub` as a `Dir` carrying the subtree name as its `hash` and +`DIR_MODE` as its `mode`; and it asserts the subtree's single entry is +`inner.rs` as a `File` with hash `B` and `FILE_MODE`. The directory-mode build case +is distinct: it passes the same two-file build input the nested build names, +`lib.rs` (hash `A`, `FILE_MODE`) and `sub/inner.rs` (hash `B`, `FILE_MODE`), never +`sub/inner.rs` alone nor any lighter single-file input, and asserts only that the +synthesized `sub` entry is a `Dir` with `mode` `16384`. The empty build asserts the `root` is +`a8874cb576274d29e603d4e720fe336a01b8f7b1a75af221c6bbba9b4ed47898`, that `objects` +holds exactly that one empty tree, and that the root tree has no entries. The +build-rejection cases each pass exactly the pinned input list and assert the pinned +error: the empty-component path `sub//inner.rs` and the dot-component path +`sub/./inner.rs` each reach `InvalidPath` in their own test; the file-versus-prefix +inputs `a` and `a/b` reach `PathConflict`; the duplicate inputs `a.rs` and `a.rs` +reach `PathConflict`; the input file with `mode` `16384` reaches `InvalidMode`; and +the input file with `mode` `33152` reaches `InvalidMode`. -The install-planning cases assert the returned vector equals the pinned step list -exactly, element for element in the pinned order, never weakening the equality into a -length-only or membership check and never reordering: PLAN_MIXED asserts the exact -three-step vector `WriteFile greeting/src/lib.rs`, `WriteFile greeting/src/new.rs`, -`DeleteFile greeting/src/old.rs` in that order, proving writes precede deletes and -each group is path-sorted, and PLAN_EMPTY asserts an empty vector. PLAN_FULL asserts -the exact seven-step vector `WriteFile greeting/src/lib.rs`, `WriteFile -greeting/src/new.rs`, `WriteView Cargo.toml`, `WriteView map.json`, `SetMode -greeting/src/perm.rs`, `DeleteFile greeting/src/gone.rs`, `DeleteFile greeting/src/old.rs` -in that order, each step carrying its pinned hash or mode, proving the four groups -appear in the order writes, then views, then permissions, then deletions, that each -group is path-sorted, that a mode-only change becomes a `SetMode` step and a -snapshot-wide change becomes a `WriteView` step, and that the `target/debug/junk.o` -build-local ephemera change is dropped entirely. The classify_path -totality case asserts an unmatched path still returns a class, proving the function -is total over every input string. +Each diff case builds the exact old and new fixtures its case names, wires the +`objects` map by naming every tree the walk touches through `tree_object_name`, +passes the matching root names (or `None` where the case names `none`), and asserts +the returned `Vec` equals the pinned list exactly, element for element +in the pinned order, never weakening the equality into a length-only or membership +check and never reordering. The identical-tree case passes the same root on both +sides and asserts an empty change list. The added-file, removed-file, +modified-by-hash, and modified-by-mode cases each assert their single pinned +change, and the modified-by-mode case asserts the change is a `Modified` carrying +the same hash on both sides with `old_mode` `33188` and `new_mode` `33261`, never +an `Added` plus `Removed`. The subtree-prune case builds TWO_DIRS_OLD and +TWO_DIRS_NEW, asserts the only change is `Modified` at path `chg/y.rs` from hash `B` +to hash `C`, and asserts explicitly that no change mentions the pruned `keep` +subtree, so the Merkle prune is proven by the absence of any `keep/x.rs` change and +not merely by a happy total. The added-directory and removed-directory cases assert +one change per leaf, `sub/inner.rs` before `sub/more.rs` in ascending path order. +The file-to-directory case asserts a `Removed` at path `x` and an `Added` at path +`x/a.rs`, in that ascending path order. The absent-old-root case diffs `None` as +`old_root` against FLAT_AB as `new_root` and asserts exactly FLAT_AB's two flat +leaves are `Added`, `a.rs` hash `A` mode `33188` then `b.rs` hash `B` mode `33261` +in ascending path order, never a heavier or nested fixture such as LIB_PLUS_DIR +substituted for FLAT_AB; the absent-new-root case diffs FLAT_AB as `old_root` +against `None` as `new_root` and asserts exactly those same two leaves are +`Removed`, `a.rs` hash `A` mode `33188` then `b.rs` hash `B` mode `33261`, again +never substituting a different fixture for FLAT_AB. The missing-root case +diffs `MISSING_ROOT` as `old_root` against FLAT_AB as `new_root`, building FLAT_AB +as its own new fixture and never substituting FLAT_A or any lighter fixture for it, +and asserts `MissingObject`; the +missing-child case diffs `DIR_WITH_MISSING_CHILD` against LIB_PLUS_DIR so the +differing directory entry forces resolution of an absent subtree and asserts +`MissingObject`. The ascending-order case builds FLAT_B and FLAT_A_AND_C and asserts +the changes are exactly `Added a.rs`, `Removed b.rs`, `Added c.rs` in that path +order, proving the result is sorted by path across mixed change kinds. -Closing mandate on one-to-one case fidelity. Every case in the frontmatter test suite -above, without exception, is realized by exactly one dedicated test that hands the -code that case's own fixture or argument tuple with its literal values copied verbatim -from the case, and asserts that case's own pinned expectation, neither narrower nor +Closing mandate on one-to-one case fidelity. Every case in the frontmatter test +suite above, without exception, is realized by exactly one dedicated test that +hands the code that case's own fixture with its literal values copied verbatim from +the case, and asserts that case's own pinned expectation, neither narrower nor recomputed. A test that inspects a different or narrower property than its case declares, that swaps in a lighter or neighboring fixture, that weakens a pinned equality into an `is_ok` or inequality-only check, or that reuses a sibling case's values, does not discharge the case and leaves it untested. Two cases that share a -shape but differ in an argument or in the property asserted are two separate cases, -each owed its own verbatim test: the four attempt-disposition cases never discharge -one another, the four liveness cases never discharge one another, the four steal cases -never discharge one another, the five acquisition cases never discharge one another, -the seven path cases never discharge one another, the three install-planning cases -never discharge one another, the nine canonical-bytes cases never discharge one -another, and the five rejection cases never discharge one another. Supplemental tests beyond the suite are named for what -they add and are never counted as, nor substituted for, any suite case's dedicated -test. The number of dedicated tests is therefore at least the number of frontmatter -cases, and each case above is discharged by its own verbatim test. - -The canonical-record-byte cases are three, one per public canonical byte function, -each owed its own dedicated verbatim test bound by name and none discharged by any -existing canonical-bytes case. The canonical_lock case hands `canonical_lock` the -LOCK fixture record and asserts the returned bytes equal the full LOCK canonical byte -string given above verbatim. The canonical_attempt_marker case hands -`canonical_attempt_marker` the ATT_INFLIGHT fixture record and asserts the returned -bytes equal the full ATT_INFLIGHT canonical byte string verbatim. The -canonical_commit_marker case hands `canonical_commit_marker` the COMMIT fixture record -and asserts the returned bytes equal the full COMMIT canonical byte string verbatim. -Each asserts the exact pinned literal, never a prefix, never a re-spelling, and never a -re-derivation of the pinned bytes, reusing the same literal its named fixture already -pins rather than recomputing a fresh value. Each test may additionally close the round -trip by parsing the returned bytes back through the record's own parse and asserting the -recovered record equals the original, `parse_lock` returning `LockState::Parsed` of the -equal lock and `parse_commit_marker` and `parse_attempt_marker` each returning `Ok` of -the equal record. These three cases never discharge one another, and none is ever -counted as, nor substituted for, any existing canonical-bytes case's dedicated test. +shape but differ in a fixture value or in the property asserted are two separate +cases, each owed its own verbatim test: the slash-name, dot-name, and dotdot-name +`InvalidName` cases never discharge one another; the file-with-dir-mode, +dir-with-file-mode, unrecognized-mode, and largest-u32 `InvalidMode` cases never +discharge one another; the unsorted-entries case never discharges the +duplicate-name case; the added-directory case never discharges the added-file case; +and the mode-only `Modified` case never collapses into an add-and-remove pair. +Supplemental tests beyond the suite are named for what they add and are never +counted as, nor substituted for, any suite case's dedicated test. The number of +dedicated tests is therefore at least the number of frontmatter cases, and each +case above is discharged by its own verbatim test. -Write no explanatory line or block comments in the generated code, and no doc comment -on any private item. The crate keeps its module-level `//!` summary. Every public -type, every public enum variant, and every public function carries a `///` doc -comment restating its contract; a public struct field carries a `///` doc comment -only where its name alone does not convey the contract, and simply named fields may -carry none. The private helper functions and private constants of this module carry -no doc comment. Beyond the module summary and these public-item doc comments the code -carries no commentary of any kind. Use no em dashes anywhere in the generated code, -including inside string literals and doc comments. +Beyond the suite, one dedicated supplemental test per `StoreTreeError` variant +asserts that variant's exact `Display` line verbatim through `to_string()`: a +`Malformed("bad")` renders `malformed tree object: bad`, `MalformedHash` renders +`tree entry hash is not sixty-four lowercase hex`, `EmptyName` renders `tree entry +name is empty`, `InvalidName` renders `tree entry name contains a slash or is a dot +component`, `UnsortedEntries` renders `tree entries are not in ascending name +order`, `DuplicateName` renders `tree has two entries with the same name`, +`InvalidMode` renders `tree entry mode does not match its kind`, `InvalidPath` +renders `build input path is empty or has a dot component`, `PathConflict` renders +`build input paths conflict`, and `MissingObject` renders `referenced tree object is +missing from the object map`. Each of these ten is its own dedicated test asserting +only its own line, and none replaces or subsumes a suite case. -## store_io +Write no explanatory line or block comments in the generated code, and no doc +comment on any private item. The crate keeps its module-level `//!` summary, and a +`///` doc comment restating this prompt's contract belongs only on a public item, +the public types, their public fields, the public enum variants, and the public +functions, where it earns its place. A public struct field carries a `///` doc +comment only where its name alone does not convey the contract, and simply named +fields may carry none. The private helper functions and private constants of this +module carry no doc comment. Beyond the module summary and these public-item doc +comments the code carries no commentary of any kind. Use no em dashes anywhere in +the code this module authors, including inside string literals and doc comments. +This rule governs authored code only; it does not reach the toolchain-emitted +generation banner on the first line of the output file, which this module does not +write. -# Store I/O: the on-disk object store, publication, HEAD, markers, and recovery +## store_reduce +# Store reduce: the typed query layer over the content-addressed store -This module is the shell of the NAPL content-addressed store: the layer that turns -the pure object model and the pure transaction decision core into real bytes on a -real filesystem. Everything the store keeps lives under a store root directory, -laid out exactly as the design of record fixes it, and every observable effect of -this module is a deterministic function of the store's contents plus the caller's -arguments. It does the actual syscalls; it never decides policy that the decision -core owns. It has five jobs: +This module defines the derived-view reducer of the NAPL content-addressed store: +the typed, spine-walking query layer every store consumer reads through instead of +the retired journal. It is a rebuildable cache keyed by the current HEAD, never +authority and never a full scan. It never walks the whole object set; it follows +spines only, the snapshot parent chain and each module's linear event chain, and it +reads every object it touches through a single trusted gate that verifies the +object against its name before any field is believed. -- **The disk object provider**: it implements the reducer's read trait over the - on-disk layout, so the same typed queries that run against an in-memory provider - run unchanged against a real store. -- **Object publication**: one no-replace publication primitive that writes an - object's envelope bytes into the store exactly once, verifying an already-present - object rather than replacing it. -- **HEAD read and compare-and-set**: reading the current snapshot hash and moving - HEAD forward only from an expected prior value. -- **Markers**: writing the per-transaction commit marker and the per-writer attempt - marker as canonical bytes at their keyed paths, and reading them back for - recovery. -- **Recovery, installation, and the janitor**: the startup recovery pass that - drives the pure decision core over the markers it finds, the monotonic installer - that materializes HEAD into the live tree behind a durable stamp, and the janitor - that collects dead-writer litter. +It answers exactly the queries the design's binding consumer matrix names, and no +universal fold: a status and LSP classification, the head prompt bytes, a change +bundle since a baseline state, a file's tree-diff history, an event by its global +ordinal, the prompt diff of one event, a drift baseline, the head generated tree, the +resolved view of one arbitrary state for a target, a +module's move and reword events, and a materialized-where-available event graph. +Every query declares its behavior over migrated `legacy` events that carry the +`unknown` state sentinel: it returns an exact result, an explicitly partial result +marked as such in the payload, or a typed unsupported-for-legacy error, and it never +fabricates state to satisfy the modern shape. -Bring in `serde_json` for reading marker bytes back into the decision core's types. +Bring in `serde` and `serde_json`. ## Where this code lives The working directory is a Cargo workspace whose root manifest is written and owned by the toolchain, leave it alone. Create this module as its own member crate in a -subdirectory named `store_io/`: `store_io/Cargo.toml` (package name `store_io`) and -`store_io/src/lib.rs`. Touch nothing outside `store_io/`. Ensure `cargo test` -passes from the workspace root before finishing. +subdirectory named `store_reduce/`: `store_reduce/Cargo.toml` (package name +`store_reduce`) and `store_reduce/src/lib.rs`. Touch nothing outside +`store_reduce/`. Ensure `cargo test` passes from the workspace root before +finishing. ## Builds on five modules of this workspace @@ -212061,2062 +212705,1941 @@ This crate composes on five generated sibling crates. Use each one's public API, not reimplement its types or logic, and do not depend on any hand-written crate. Depend on exactly these five, and do not bring in the `sha2` crate directly. -- **`store_hash`** (`../store_hash`) supplies the envelope framing and the read - verifier. It uses `store_hash::ObjectKind` (the variants `ObjectKind::Blob` and - `ObjectKind::Snapshot` among the kinds), `store_hash::envelope_bytes(kind: - store_hash::ObjectKind, payload: &[u8]) -> Vec`, `store_hash::object_name(kind: - store_hash::ObjectKind, payload: &[u8]) -> String`, and - `store_hash::verify_object(name: &str, bytes: &[u8]) -> Result<...>` to verify an - already-present object during publication. This module never reimplements the - envelope format, canonical JSON, or hashing, and never hand-rolls a SHA-256. -- **`store_objects`** (`../store_objects`) supplies the snapshot type whose spine - this module walks during recovery. It uses `store_objects::Snapshot` with its - public `parents` and `seq` fields to decide whether a marker's snapshot is an - ancestor of, or equal to, HEAD, reading each snapshot object through this module's - own provider. This module walks only the snapshot spine (parents and seq) and - never deserializes module-state or event bodies to answer an ancestry question. -- **`store_reduce`** (`../store_reduce`) supplies the read trait this module - implements. It uses `store_reduce::ObjectProvider` with exactly its two methods - `object_bytes(&self, hash: &str) -> Option>` and `head_snapshot_hash(&self) - -> Option`. This module implements that trait, and only that trait, for a - disk-backed provider; it never adds a third method to the trait and never widens - its contract. -- **`store_txn_core`** (`../store_txn_core`) supplies every marker and lock type, the - canonical byte serialization of each, and every recovery, lock-lifecycle, - install-planning, and path-classification decision. It uses - `store_txn_core::CommitMarker`, `store_txn_core::AttemptMarker`, - `store_txn_core::LockFile`, `store_txn_core::InstallStep`; the canonical byte - functions `store_txn_core::canonical_commit_marker(marker: - &store_txn_core::CommitMarker) -> Vec`, - `store_txn_core::canonical_attempt_marker(marker: &store_txn_core::AttemptMarker) -> - Vec`, and `store_txn_core::canonical_lock(lock: &store_txn_core::LockFile) -> - Vec`, which own the on-disk bytes of a marker or lock; and the decision functions - `store_txn_core::classify_marker`, `store_txn_core::attempt_disposition`, - `store_txn_core::plan_install_steps`, `store_txn_core::parse_lock`, - `store_txn_core::acquire_decision`, `store_txn_core::release_decision`, - `store_txn_core::liveness_decision`, `store_txn_core::steal_decision`, and - `store_txn_core::classify_path`. This module performs syscalls and hands their - observed facts to those pure functions; it never reimplements a decision the core - owns, and never re-derives or inlines a marker or lock serialization the core - defines: every marker and lock byte string it writes comes from the core's canonical - byte functions. -- **`store_tree`** (`../store_tree`) supplies the generated-tree diff that the - installer feeds to `plan_install_steps`. It uses `store_tree::Tree`, - `store_tree::diff_trees`, and `store_tree::TreeChange`. This module obtains the - install work list from the tree diff and never reimplements tree diffing. - -## The store layout +- **`store_hash`** (`../store_hash`) supplies the object gate. It uses + `store_hash::ObjectKind` with its variants `Blob`, `BlockIndex`, `Tree`, + `ModuleState`, `Event`, and `Snapshot`; and `store_hash::verify_object(name: + &str, bytes: &[u8]) -> Result` + which returns a `store_hash::VerifiedObject` with public fields `kind: + store_hash::ObjectKind` and `payload: Vec`. This module never reimplements the + envelope framing, the canonical JSON, or the SHA-256 naming; it verifies every + object it reads through `verify_object` and trusts no bytes that fail. +- **`store_objects`** (`../store_objects`) supplies the provenance and state types. + It uses `store_objects::Snapshot` (public fields `parents: Vec`, `seq: + u64`, `root: std::collections::BTreeMap`), + `store_objects::RootEntry` (public fields `module_state: String`, `event_head: + String`), `store_objects::ModuleState` (public fields `prompt_blob: String`, + `block_index: String`, `mapl: Option`, `ir: Option`, `targets: + std::collections::BTreeMap`), + `store_objects::TargetArtifacts` (public fields `generated_tree: String`, + `attribution: Option`), `store_objects::Event` (public fields `parents: + Vec`, `module: String`, `target: Option`, `ordinal: u64`, + `timestamp: String`, `engine: String`, `state_before: Option`, + `state_after: Option`, `payload: store_objects::EventPayload`), + the `store_objects::StateRef` enum with variants `State(String)` and `Unknown`, the + `store_objects::EventPayload` enum with variants `Gen`, `Reword`, `Reconcile`, + `Move`, `Derivation`, `Audit`, `Revert`, and `Legacy` carrying the matching field + structs, the `store_objects::GenOutcome` enum with variants `Ok`, `Failed`, and + `Interrupted`, and the `store_objects::DerivationOutcome` enum with variants `Ok`, + `Retried`, `Failed`, and `Interrupted`; and the validators + `store_objects::validate_snapshot(value: serde_json::Value) -> Result`, `store_objects::validate_module_state(value: + serde_json::Value) -> Result`, + and `store_objects::validate_event(value: serde_json::Value) -> Result`. This module resolves object names to these typed + values through the validators and never reimplements their shapes or checks. +- **`store_blocks`** (`../store_blocks`) supplies the block-index type the prompt + diff aligns. It uses `store_blocks::BlockIndex` and + `store_blocks::validate_block_index(value: serde_json::Value) -> Result`. This module never re-segments a prompt; it loads + the stored block-index object and validates it. +- **`store_tree`** (`../store_tree`) supplies the generated-tree type and diff. It + uses `store_tree::Tree`, `store_tree::validate_tree(value: serde_json::Value) -> + Result`, `store_tree::TreeEntry` with + its public fields (including `hash: String` and `kind: store_tree::EntryKind` whose + variant `store_tree::EntryKind::Dir` names a subtree), and + `store_tree::diff_trees(old_root: Option<&str>, new_root: Option<&str>, objects: + &std::collections::BTreeMap) -> + Result, store_tree::StoreTreeError>` with the + `store_tree::TreeChange` enum (variants `Added { path, hash, mode }`, `Removed { + path, hash, mode }`, `Modified { path, old_hash, old_mode, new_hash, new_mode }`). + This module loads tree objects into the object map and calls `diff_trees`; it never + reimplements tree diffing. +- **`store_diff`** (`../store_diff`) supplies the prompt alignment and the bundle. It + uses `store_diff::ChangedBlock`, `store_diff::Attribution`, + `store_diff::validate_attribution(value: serde_json::Value) -> Result`, `store_diff::Bundle`, + `store_diff::diff_prompt(old_index: &store_blocks::BlockIndex, old_prompt: &str, + new_index: &store_blocks::BlockIndex, new_prompt: &str) -> Vec`, + `store_diff::build_bundle(module: &str, prompt_changes: &[store_diff::ChangedBlock], + old_index: &store_blocks::BlockIndex, old_attribution: &store_diff::Attribution, + new_index: &store_blocks::BlockIndex, new_attribution: &store_diff::Attribution, + old_tree_root: Option<&str>, new_tree_root: Option<&str>, tree_objects: + &std::collections::BTreeMap) -> Result`, and `store_diff::bundle_blob_name(bundle: + &store_diff::Bundle) -> String`. The reducer is exactly the caller `store_diff` + describes: it holds two module-states, resolves their `blockIndex`, + `generatedTree`, and `attribution` hashes to concrete values, and then composes on + `store_diff` for the prompt diff and the bundle; it never reimplements alignment, + ownership, or bundle assembly. -A store root is a directory holding, exactly as the design fixes it: +## Content hashes are plain strings -- `objects//`: one plain file per object, - its bytes the object's full envelope, committed to git. The object's name is the - hex SHA-256 of those envelope bytes; the two-character prefix is the fan-out - directory and the remaining sixty-two characters are the file name. -- `objects/pack/pack-.naplpack` and `objects/pack/pack-.idx`: packed - objects and their index, committed to git, produced only by the repack - operation below. Loose files are the only write path; packs are immutable - once written and named by their own content hash. -- `HEAD`: a single line, the current snapshot hash followed by one newline. The only - mutable committed file in the store. -- `INSTALLED`: a single line, the snapshot hash the live tree was last fully - materialized to, followed by one newline. Gitignored. -- `markers/.json`: per-transaction commit markers. Gitignored. -- `attempts/.json`: per-writer attempt markers. Gitignored. -- `locks/.lock`: lock files. Gitignored. -- `tmp/`: staging and publication scratch. Gitignored and never traversed as store - content. +Every content hash and every content-addressed object name is carried as a `String`, +matching the rest of the workspace, never a newtype. A well-formed hash is +sixty-four lowercase hexadecimal characters. This module does not itself re-validate +hash shape; the upstream validators and `store_hash::verify_object` already reject a +malformed name or a mismatched object, and the reducer only ever passes names it +read out of an already-verified parent object. -Every path this module derives from a name uses the hash-encoded name only; module -and target names never form a filesystem path, and a name is used verbatim, never -re-cased or re-encoded. +## The object provider -## The disk provider +The store's read surface is a single synchronous trait, pinned by the store rewrite +plan before any Phase A prompt was authored so that no prompt invents its own +cross-crate contract. The plan fixes exactly two methods, conceptually +`objectBytes(hash) -> Option` returning an object's full envelope bytes for +the caller to verify through `store_hash`, and `headSnapshotHash() -> Option` +returning the current HEAD snapshot's name. Expose that trait, and only that trait, +as the reducer's entire dependency on storage. -Expose a public struct `DiskProvider` holding the store root path, constructed by a -public `DiskProvider::open(root: &std::path::Path) -> DiskProvider` that records the -root and performs no I/O. Implement `store_reduce::ObjectProvider` for it: +Expose a public trait `ObjectProvider` with exactly these two methods and no others: -- `object_bytes(&self, hash: &str)` reads the file at `objects//` - under the root and returns its raw bytes as `Some(Vec)`; when that file does - not exist it consults each pack index under `objects/pack/` in lexicographic - pack-name order and returns the envelope bytes sliced from the first pack whose - index names the hash, or `None` when neither a loose file nor any pack holds it. - Loose always wins over packed, so a reader racing a repack sees the object in - one place or the other, never in neither. The bytes are returned exactly as they - sit on disk, never re-framed and never verified here; the reducer verifies them. -- `head_snapshot_hash(&self)` reads the `HEAD` file, strips a single trailing - newline if present, and returns `Some(String)` of the remaining hash, or `None` - when `HEAD` does not exist. It trims exactly the trailing newline and returns the - hash without it. +- `fn object_bytes(&self, hash: &str) -> Option>`: the full envelope bytes of + the object named `hash`, or `None` when the provider does not hold it. The bytes are + unverified; the reducer verifies them. +- `fn head_snapshot_hash(&self) -> Option`: the current HEAD snapshot's object + name, or `None` when the store has no HEAD yet. -The disk provider's two answers are byte-for-byte identical to an in-memory -provider built from the same objects and HEAD: a fixture that queries both over the -same store must find them equal on every name and on the head. +The reducer takes a `&P` where `P: ObjectProvider` on every query. It never mutates +the provider, never writes, and holds no other capability: no filesystem, no clock, +no randomness. A unit corpus feeds an in-memory provider; Phase B's `store_io` +implements the same trait over the on-disk store, and an integration test proves the +two providers answer identically over a fixture store. Because the reducer's only +inputs are this trait and the caller's arguments, it is deterministic and pure +relative to the provider's contents. -::: test -name: object bytes over a present blob returns its exact envelope bytes -given: { fixture: READ_BLOB } -expect: { object_bytes: "napl-store blob v1 21\nThe greeting module.\n" } -::: +## The error type -::: test -name: object bytes over a present snapshot returns its exact envelope bytes -given: { fixture: READ_SNAPSHOT } -expect: { object_bytes: "napl-store snapshot v1 99\n{\"parents\":[\"9999999999999999999999999999999999999999999999999999999999999999\"],\"root\":{},\"seq\":0}\n" } -::: +Expose a public enum `ReduceError` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) +with exactly these variants: -::: test -name: object bytes over an absent name returns none -given: { fixture: READ_ABSENT } -expect: { object_bytes: none } -::: +- `CorruptObject(String)`, carrying the offending object name, for any object the + provider returned whose bytes fail `store_hash::verify_object`, whose verified kind + is not the kind the reference demanded, or whose payload fails the matching upstream + validator. A corrupt store is a loud, hard error: the reducer never silently skips a + bad object and never degrades a corrupt read into an empty answer. +- `MissingObject(String)`, carrying the object name, for a name a query must resolve + to answer that the provider does not hold. This is distinct from the graceful spine + boundary below: a missing object that the current answer depends on is an error, not + an empty result. +- `UnsupportedForLegacy`, for a query that cannot answer across an `unknown` state + sentinel, namely a `bundle_since` whose baseline is the `unknown` state. The caller + translates this to its own fallback, which for the gen consumer is full mode. -::: test -name: head snapshot hash over a store with HEAD returns the trimmed hash -given: { fixture: READ_HEAD_PRESENT } -expect: { head_hash: "b7281259c1fa6f5b9fa3b295d6ee09705ddb02a3387bdd111888d697b63a4242" } -::: +The error type does not model consumer error policy. Whether a consumer fails closed, +degrades to no diagnostics, or falls back to full mode is that consumer's decision in +a later phase; the reducer only reports the typed fact. + +Implement `std::fmt::Display` for `ReduceError` so a consumer can render a reducer +failure as a bare, human-readable message that carries no `Debug` artifacts: no variant +name, no quotes, and no braces. `CorruptObject` renders `corrupt store object {name}`, +interpolating its carried object name verbatim. `MissingObject` renders `missing store +object {name}`, interpolating its carried object name verbatim. `UnsupportedForLegacy` +renders `query unsupported across a legacy state boundary`. This addition is purely +additive: the `Debug` derive is retained for the test assertions that pin variant +equality, and the reducer's own error construction and propagation are unchanged. The +`Display` impl and its `fmt` method carry no doc comment, matching the crate's existing +trait-impl style. + +## The trusted read gate + +Every object the reducer reads passes one private gate, so no downstream code ever +touches unverified bytes. Given a name and the `store_hash::ObjectKind` the reference +demands, the gate: + +1. Fetches the bytes with `object_bytes`; `None` is `ReduceError::MissingObject` with + that name. +2. Verifies the bytes with `store_hash::verify_object(name, &bytes)`; any + `store_hash::VerifyError` is `ReduceError::CorruptObject` with that name. +3. Checks the verified kind equals the demanded kind; a mismatch is + `ReduceError::CorruptObject` with that name. A `blob` referenced where a `tree` + was expected, or any other cross-kind confusion, is corruption. +4. Returns the verified payload bytes for the caller to interpret. + +For the JSON object kinds, a second private helper parses the verified payload bytes +into `serde_json::Value` and hands the value to the matching upstream validator +(`validate_snapshot`, `validate_module_state`, `validate_event`, +`validate_block_index`, `validate_tree`, or `validate_attribution`); a parse failure +or any validator error is `ReduceError::CorruptObject` with that name. A `blob` +payload is returned as raw bytes and never re-encoded. This gate is the module's only +deserialization of store bytes, and it is always strict: the reducer deserializes no +untrusted input into its own result types, so the deny-unknown-fields discipline the +store lane enforces at every nesting level is discharged upstream by these validators, +each of which already carries that discipline and its own rejection corpus, plus the +kind-and-hash gate here. ::: test -name: head snapshot hash over a store without HEAD returns none -given: { fixture: READ_HEAD_ABSENT } -expect: { head_hash: none } +name: a query whose head-referenced object is absent is a missing-object error +given: { fixture: RS_CLEAN_MISSING_STATE, query: classify, observed: matching } +expect: { error: MissingObject } ::: ::: test -name: the disk provider and an in-memory provider answer identically over the fixture store -given: { fixture: PARITY } -expect: { providers_agree: true } +name: a query whose head object bytes do not verify is a corrupt-object error +given: { fixture: RS_CLEAN_CORRUPT_HEAD, query: classify, observed: matching } +expect: { error: CorruptObject } ::: -## Packed objects and the repack operation +## Spine walking versus the genesis boundary -A pack is many objects in one file, exactly git's answer to a store of -thousands of loose files, kept radically simpler: one pack holds the -world. The pack file's bytes are a header line `napl-store pack v1 -\n` followed by every member object's envelope bytes -concatenated in ascending object-name order, nothing between them, no -compression, no timestamps. The pack's name is the hex SHA-256 of the -pack file's own bytes, so a pack is self-addressing exactly as an -object is, and two repacks over the same object set produce -byte-identical packs with the same name. Beside every pack sits its -index `pack-.idx`, canonical JSON mapping each member's object -name to `[offset, length]` of its envelope inside the pack, keys -sorted; the index is a pure derivation of the pack and is regenerable -from it, so a missing or corrupt index is repaired by rereading the -pack, never a data loss. +Two walks exist and they treat an absent object differently, deliberately: -Repack is one operation with two callers, the toolchain's gc verb and -the toolchain's automatic threshold below; the store exposes the -operation and holds no policy: it enumerates the -reachable closure (gc's own definition), writes one new pack holding -every reachable object, links it in through the same tmp-then-no-replace -protocol publication uses, and only after the new pack and index are -durable deletes the loose files it packed and every older pack. -Ordering makes the race benign: an object is always readable loose or -packed at every instant, and a crash between link and cleanup leaves -duplicates, never absence; the next repack or fsck reconciles. -Publication after a repack keeps writing loose files, the only write -path; packs are immutable and never appended to. Unreachable objects -are never packed: repack subsumes sweeping, so gc with apply is one -operation, pack the reachable, remove the rest. +- A **head-referent** read, an object named directly by the HEAD snapshot or by an + object the current answer must materialize (the head snapshot itself, a module's + head module-state, its block index, its prompt blob, its generated tree and + subtrees, its attribution, its head event), is required: absence is + `ReduceError::MissingObject`. +- A **spine ancestor** read, a parent snapshot or an ancestor event reached while + walking backward through history, terminates the walk gracefully when absent: the + provider legitimately retains a bounded window, so an absent ancestor is the genesis + boundary of retained history, not corruption. The walk stops and the query answers + from what it found, never erroring on a missing ancestor. -fsck counts an object present when it is loose or when a pack's index -names it and the sliced bytes verify against the object name; it -verifies each pack's own name against the pack bytes and each index -against its pack, and reports a pack and index that disagree loudly. -An object present both loose and packed is healthy duplication, not a -finding. +A walk follows the first parent of a snapshot and the first parent of an event; the +chains are linear for a single module because its conflict domain covers all its +targets, and the snapshot spine's first parent is the mainline history. + +One head-referent read has a single, deliberate boundary value: a root entry's +`event_head`. The genesis sentinel is the all-zeros object name, sixty-four `0` +characters, the never-published boundary marker the migration writes as the +`event_head` of a current-state-only module (one whose current state survives on disk +but whose recorded history was lost, the emptied-mapl scar). A root entry whose +`event_head` is exactly that all-zeros sentinel denotes empty recorded history, not a +missing object: it is the module's genesis boundary, answered gracefully exactly as an +absent spine ancestor is, never `MissingObject`. This is a value refinement of the +head-referent rule, not a weakening of it: an `event_head` that is any other name yet +absent from the provider remains a loud `MissingObject`, so genuine corruption of a +real head event is still caught; only the one deliberate, never-published sentinel is +read as the empty-history boundary. Recognize it by that exact value before any load; +never fetch the sentinel from the provider. Every query that loads a root entry's head +event honors this boundary uniformly: the module's event chain over such a root is +empty, its classification is `CurrentStateOnly`, and an ordinal lookup skips the +historyless root. ::: test -name: a repacked store answers every reachable object identically to before -given: { fixture: REPACK_PARITY } -expect: { all_reachable_object_bytes_equal: true, loose_files_remaining: 0, packs: 1 } +name: classify over a head whose nonzero event head is absent from the provider is a missing-object error +given: { fixture: RS_CLEAN_MISSING_EVENT, query: classify, observed: matching } +expect: { error: MissingObject } ::: ::: test -name: two repacks over the same object set produce byte-identical packs -given: { fixture: REPACK_DETERMINISM, runs: 2 } -expect: { pack_bytes_equal: true, pack_names_equal: true } +name: event_graph over a head whose nonzero event head is absent from the provider is a missing-object error +given: { fixture: RS_CLEAN_MISSING_EVENT, query: event_graph } +expect: { error: MissingObject } ::: ::: test -name: a reader mid-repack finds every object loose or packed never absent -given: { fixture: REPACK_RACE, read_at: after_pack_link_before_loose_cleanup } -expect: { all_objects_readable: true, duplicates_tolerated: true } +name: event_by_ordinal over a head whose nonzero event head is absent from the provider is a missing-object error +given: { fixture: RS_CLEAN_MISSING_EVENT, query: event_by_ordinal, ordinal: 1 } +expect: { error: MissingObject } ::: +## The classification query + +Expose a public enum `Classification` (deriving `Debug`, `Clone`, `Copy`, +`PartialEq`, `Eq`, `serde::Serialize`, `serde::Deserialize`), serializing as a single +JSON string, each variant pinned to its exact wire token; a string outside the set is +a deserialization failure: + +- `Classification::Clean` renders `clean`. +- `Classification::NeverGenerated` renders `never-generated`. +- `Classification::PromptStale` renders `prompt-stale`. +- `Classification::Drifted` renders `drifted`. +- `Classification::Unattributed` renders `unattributed`. +- `Classification::Reworded` renders `reworded`. +- `Classification::Interrupted` renders `interrupted`. +- `Classification::Moved` renders `moved`. +- `Classification::CurrentStateOnly` renders `current-state-only`. The module has + current state at HEAD (a seeded module-state and a target) but no recorded + generating event: its root entry's `event_head` is the genesis sentinel, the + emptied-mapl scar carried through migration. This is distinct from + `NeverGenerated`, which has no artifacts at all. + +These are the reducer's own canonical tokens. The `drifted` token is deliberately not +the CLI render label `DRIFT`; mapping this rich classification down to the exact +today-CLI label is the `statusclass_io` consumer's job in a later phase, out of scope +here. The reducer produces the classification; the renderer maps it. + +Because the reducer cannot see the working tree (it holds only the provider), it +receives the observed working state as an argument. Expose a public struct `Observed` +(deriving `Debug`, `Clone`, `PartialEq`, `Eq`, `serde::Serialize`, +`serde::Deserialize`, with `#[serde(deny_unknown_fields)]`), the caller's snapshot of +the module's current on-disk hashes. Public fields: + +- `prompt_blob: Option`, serde field name `promptBlob`, skipped from + serialization when `None`. The hash of the module's current on-disk prompt bytes, or + `None` when the prompt file is absent. +- `generated_tree: Option`, serde field name `generatedTree`, skipped when + `None`. The hash of the target's current on-disk generated tree, or `None` when it + is absent. + +Expose `classify(provider: &P, module: &str, target: &str, +observed: &Observed) -> Result`. It loads the HEAD +snapshot (head-referent; a missing or corrupt head is an error) and applies this +total, pinned precedence, returning at the first matching rule: + +1. If HEAD is `None`, or `module` is absent from the head snapshot's root, or the + module's head module-state has no entry for `target` in its `targets` map, return + `Classification::NeverGenerated`. No generated artifacts exist for this target. +2. Otherwise, if the module's root entry `eventHead` is the genesis sentinel (the + all-zeros never-published name, empty recorded history), return + `Classification::CurrentStateOnly`. The module has current state and a target but no + generating event, so there is no head event to read for the nature rules below and + nothing a working comparison could restate; this is decided by the sentinel value + without fetching any event object. This rule sits above the event-nature and + working-comparison rules because a historyless HEAD has no event to classify. +3. Otherwise load the module's head event (the root entry's `eventHead`). If that + event's payload is a `Gen` whose outcome is `Interrupted`, or a `Derivation` whose + outcome is `Interrupted`, return `Classification::Interrupted`. An interrupted + writer left the module mid-flight, and that qualifies the HEAD baseline before any + working comparison. +4. Otherwise, if the head event's payload is a `Move`, return `Classification::Moved`. +5. Otherwise, if the head event's payload is a `Reword`, return + `Classification::Reworded`. +6. Otherwise compare the observation to the head module-state. If + `observed.prompt_blob` is not `Some(state.prompt_blob)` (absent, or a different + hash), return `Classification::PromptStale`. +7. Otherwise, if `observed.generated_tree` is not `Some(target.generated_tree)`, + return `Classification::Drifted`. +8. Otherwise, if the target's `attribution` is `None`, return + `Classification::Unattributed`. The generated files exist and match but were never + attributed. +9. Otherwise return `Classification::Clean`. + +Rules three through five read the head event's recorded nature and take precedence over +the working comparison in rules six through eight, because a reworded, moved, or +interrupted HEAD changes what the baseline itself means. This precedence is observable +and pinned; do not reorder it. Classification is exact over legacy history: it depends +only on the HEAD state, which migration seeds fully, so a chain that contains legacy +events with `unknown` states still classifies exactly at the head. + ::: test -name: a corrupt or missing index is regenerated from its pack -given: { fixture: PACK_INDEX_CORRUPT } -expect: { index_regenerated: true, reads_succeed: true, data_loss: false } +name: the classification clean serializes to the string clean +given: { classification: clean } +expect: { serialized_json: "\"clean\"" } ::: ::: test -name: fsck verifies pack and index and counts packed objects present -given: { fixture: FSCK_PACKED } -expect: { packed_objects_present: true, pack_name_verified: true, findings_on_healthy: 0 } +name: the classification never-generated serializes to the string never-generated +given: { classification: never-generated } +expect: { serialized_json: "\"never-generated\"" } ::: ::: test -name: unreachable objects are not packed and are removed by repack -given: { fixture: REPACK_SWEEPS } -expect: { unreachable_in_pack: 0, unreachable_loose_remaining: 0, reachable_kept: true } +name: the classification prompt-stale serializes to the string prompt-stale +given: { classification: prompt-stale } +expect: { serialized_json: "\"prompt-stale\"" } ::: -## Object publication - -Expose a public function `publish_object(root: &std::path::Path, kind: -store_hash::ObjectKind, payload: &[u8], owner_token: &str) -> Result`. It publishes an object exactly once and returns the object's name. -The owner token names the unique temp file, so publication is deterministic under a -caller-supplied token and never invents randomness of its own. The protocol, in -order: - -1. Frame the envelope with `store_hash::envelope_bytes` and compute the name with - `store_hash::object_name`. -2. Write the envelope bytes to a uniquely named file under `tmp/` (named by the - owner token), fsync the file. -3. Ensure the fan-out directory `objects//` exists. -4. No-replace link the temp file into `objects//` using a link - that fails rather than replaces when the target already exists. On success, fsync - the fan-out directory and unlink the temp file, then return the name. -5. On the already-exists condition, read the existing object's bytes and verify them - against the expected name with `store_hash::verify_object`: when they match, the - object was already published, so unlink the temp file and return the name (an - idempotent success that leaves the existing object untouched); when they do not - match, return a loud `StoreIoError::Integrity` naming the object, and never - replace the existing bytes. - -A crash anywhere in this protocol leaves only verified-or-invisible objects: the -store never holds a partially written or unverified object under a real name. - ::: test -name: publishing a new blob returns the object name of its envelope -given: { fixture: PUBLISH_NEW } -expect: { published_name: "0965493c60ab6b118535fc59c5a793c753295acd9fd8d75e084288459624fe93" } +name: the classification drifted serializes to the string drifted +given: { classification: drifted } +expect: { serialized_json: "\"drifted\"" } ::: ::: test -name: publishing a new blob writes its exact envelope bytes at its fanout path -given: { fixture: PUBLISH_NEW } -expect: { object_file_bytes: "napl-store blob v1 21\nThe greeting module.\n" } +name: the classification unattributed serializes to the string unattributed +given: { classification: unattributed } +expect: { serialized_json: "\"unattributed\"" } ::: ::: test -name: publishing an object that already exists with matching bytes is an idempotent success -given: { fixture: PUBLISH_IDEMPOTENT } -expect: { published_name: "0965493c60ab6b118535fc59c5a793c753295acd9fd8d75e084288459624fe93" } +name: the classification reworded serializes to the string reworded +given: { classification: reworded } +expect: { serialized_json: "\"reworded\"" } ::: ::: test -name: publishing over an existing object whose bytes mismatch the expected hash is a loud integrity error -given: { fixture: PUBLISH_MISMATCH } -expect: { error: Integrity } +name: the classification interrupted serializes to the string interrupted +given: { classification: interrupted } +expect: { serialized_json: "\"interrupted\"" } ::: -## HEAD read and compare-and-set +::: test +name: the classification moved serializes to the string moved +given: { classification: moved } +expect: { serialized_json: "\"moved\"" } +::: -Expose `read_head(root: &std::path::Path) -> Result, StoreIoError>` -returning the current snapshot hash (trailing newline trimmed) or `None` when HEAD -is absent, and `write_head(root: &std::path::Path, snapshot: &str) -> Result<(), -StoreIoError>` writing the snapshot hash plus one trailing newline through a temp -file and an atomic rename over `HEAD`. +::: test +name: the classification current-state-only serializes to the string current-state-only +given: { classification: current-state-only } +expect: { serialized_json: "\"current-state-only\"" } +::: -The compare-and-set is the HEAD critical section, and it is a real mutual-exclusion -region, not a bare read-then-write. The critical section is the lock named `HEAD`, -whose file is `locks/HEAD.lock`, taken through the exclusive-creation lock primitive -of the "Locks and stealing" section below. The entire read-compare-write runs while -that lock is held: acquire `locks/HEAD.lock`, re-read HEAD under the lock, compare, -write `next` only on a match, and release the lock before returning on every path, -success or conflict. Two writers can therefore never both observe the expected value -and both win; the second to enter the critical section re-reads the HEAD the first -one wrote and conflicts. +::: test +name: classify over a matching head is clean +given: { fixture: RS_CLEAN, query: classify, observed: matching } +expect: { classification: clean } +::: -Expose `compare_and_set_head(root: &std::path::Path, expected: Option<&str>, next: -&str) -> Result<(), StoreIoError>`. Holding `locks/HEAD.lock` it re-reads HEAD, and -only when the current value equals `expected` (both `None` for a first-ever HEAD, or -both the same hash) does it write `next`; when the current value differs it returns a -loud `StoreIoError::HeadConflict` carrying the expected and the found values and -leaves `HEAD` exactly as it was. The normative durability order for a HEAD move is -exactly: write the temp file, fsync the temp file, rename over HEAD, fsync the store -directory; a compare-and-set that conflicts performs no write at all. The lock is -released before the function returns whether it wrote, conflicted, or failed. +::: test +name: classify over a module absent from the head root is never-generated +given: { fixture: RS_CLEAN, query: classify, observed: matching, module: absent } +expect: { classification: never-generated } +::: -Expose also `compare_and_set_head_observed(root: &std::path::Path, expected: -Option<&str>, next: &str, on_locked: &mut dyn FnMut(&std::path::Path)) -> Result<(), -StoreIoError>`, which is `compare_and_set_head` with one added seam: once the HEAD -lock is held and before it is released, it calls `on_locked` exactly once with the -store root, on both the success and the conflict paths, so a caller can observe that -the critical section is genuinely held across the read-compare-write. Both functions -share one implementation and one lock discipline, and `compare_and_set_head` is -`compare_and_set_head_observed` with a callback that does nothing. +::: test +name: classify over a present module whose head state lacks the target is never-generated +given: { fixture: RS_CLEAN, query: classify, observed: matching, target: absent } +expect: { classification: never-generated } +::: ::: test -name: writing HEAD stores the snapshot hash as one newline-terminated line -given: { fixture: SET_HEAD } -expect: { head_file_bytes: "b7281259c1fa6f5b9fa3b295d6ee09705ddb02a3387bdd111888d697b63a4242\n" } +name: classify with an observed prompt that differs from head is prompt-stale +given: { fixture: RS_CLEAN, query: classify, observed: prompt_mismatch } +expect: { classification: prompt-stale } ::: ::: test -name: compare-and-set HEAD from the expected snapshot to a new one rewrites HEAD -given: { fixture: CAS_SUCCESS } -expect: { head_file_bytes: "289f591aec3ebd915526648ad486d6b028dca5200e9c392b7fdaf4c498f6ec09\n" } +name: classify with an observed tree that differs from head is drifted +given: { fixture: RS_CLEAN, query: classify, observed: tree_mismatch } +expect: { classification: drifted } ::: ::: test -name: compare-and-set HEAD fails loudly when HEAD no longer holds the expected snapshot -given: { fixture: CAS_CONFLICT } -expect: { error: HeadConflict } +name: classify over a head target with no attribution is unattributed +given: { fixture: RS_UNATTRIB, query: classify, observed: matching } +expect: { classification: unattributed } ::: ::: test -name: a conflicting compare-and-set leaves HEAD unchanged -given: { fixture: CAS_CONFLICT } -expect: { head_file_bytes: "b7281259c1fa6f5b9fa3b295d6ee09705ddb02a3387bdd111888d697b63a4242\n" } +name: classify over a head whose latest event is a reword is reworded +given: { fixture: RS_REWORD, query: classify, observed: matching } +expect: { classification: reworded } ::: ::: test -name: compare-and-set holds the HEAD lock across its critical section and releases it before returning -given: { fixture: CAS_LOCK_HELD } -expect: { head_lock_held_during: true } +name: classify over a head whose latest event is an interrupted gen is interrupted +given: { fixture: RS_INTERRUPTED, query: classify, observed: matching } +expect: { classification: interrupted } ::: ::: test -name: under the HEAD lock two serialized compare-and-sets let exactly one win and the loser re-reads and conflicts -given: { fixture: CAS_SERIALIZED_LOSER } -expect: { error: HeadConflict } +name: classify over a head whose latest event is a move is moved +given: { fixture: RS_MOVED, query: classify, observed: matching } +expect: { classification: moved } ::: -## Locks and stealing +::: test +name: classify stays exact and clean over a head whose chain carries a legacy event +given: { fixture: RS_LEGACY, query: classify, observed: matching } +expect: { classification: clean } +::: -All locks share one primitive: a lock named `` is the file `locks/.lock`, -taken by exclusive atomic creation that fails rather than replaces when the file -already exists, its bytes the core's canonical lock serialization of a -`store_txn_core::LockFile` (owner token, pid, process start time, host, and purpose). -Lock keys are hash-encoded or fixed literals; a module or target name never forms a -lock path. The named locks this module takes are `HEAD` (the commit critical section), -`install` (live-tree materialization and recovery), and `janitor` (litter collection -and the sole authority under which a steal happens); a domain lock is named by its -module. +::: test +name: classify over a current-state-only head whose event head is the genesis sentinel is current-state-only +given: { fixture: RS_CURRENT_ONLY, query: classify, observed: matching } +expect: { classification: current-state-only } +::: -Expose `acquire_lock(root: &std::path::Path, name: &str, this_host: &str, is_alive: -&dyn Fn(u64) -> bool) -> Result` and `release_lock(guard: -LockGuard) -> Result<(), StoreIoError>`, where a `LockGuard` is a held-lock handle -carrying the lock name and owner token; releasing it checks ownership through -`store_txn_core::release_decision` and removes `locks/.lock`. Acquisition tries -the exclusive create; on success the lock is held. When the file already exists, the -holder's liveness is judged, never assumed: read the existing bytes, parse them with -`store_txn_core::parse_lock`, and, for a parsed lock, apply -`store_txn_core::liveness_decision` with the lock's host, `this_host`, and the result -of the injected `is_alive` probe over the lock's pid; a lock that fails to parse is -held-and-stale-eligible per the core, and a lock on a foreign host is never judged -dead. The liveness of a lock is exactly what these pure functions and the injected -probe say: this module never reads a wall clock and never shells out to a process tool -to decide it. +## The head prompt bytes query -Feed the liveness verdict to `store_txn_core::steal_decision`. A live or foreign -holder is refused: the lock is left exactly as it was and acquisition fails with -`StoreIoError::Io`. A dead-local or malformed holder is stealable, but a steal is -never a bare delete-then-create and never happens outside the janitor lock. To steal: -acquire the `janitor` lock first; under it, re-read, re-parse, and re-judge the stale -lock so a holder that revived is not stolen; then atomically rename `locks/.lock` -aside to `locks/.lock.stolen`, a rename and never an unlink of the live path, -and exclusively create the fresh `locks/.lock`; then release the janitor lock. -The module never tells `steal_decision` the janitor lock is held unless it holds it. -When the janitor lock itself cannot be taken, the module does not steal: it leaves the -stale lock in place and fails with `StoreIoError::Io`, so two recoverers can never race -a steal. +Expose `prompt_bytes_at_head(provider: &P, module: &str) -> +Result>, ReduceError>`. It loads the HEAD snapshot; if HEAD is `None` +or `module` is absent from its root, return `Ok(None)`. Otherwise load the module's +head module-state, then load its `prompt_blob` as a `Blob` object, and return +`Ok(Some(payload))` with the blob payload bytes exactly, never re-encoded. This +replaces the retired prompts-at-gen store and is byte-exact and exact over legacy, +because HEAD always names a concrete prompt blob. ::: test -name: acquiring a lock over a dead local holder steals it by renaming the stale lock aside under the janitor lock -given: { fixture: STEAL_SUCCESS } -expect: { stolen_aside_present: true } +name: prompt_bytes_at_head returns the exact head prompt blob bytes +given: { fixture: RS_CLEAN, query: prompt_bytes_at_head } +expect: { prompt_bytes: "The greeting module.\n" } ::: ::: test -name: a steal is refused when the janitor lock cannot be taken and the stale lock is left untouched -given: { fixture: STEAL_BLOCKED } -expect: { stolen_aside_absent: true } +name: prompt_bytes_at_head for a module absent from head is none +given: { fixture: RS_CLEAN, query: prompt_bytes_at_head, module: absent } +expect: { is_none: true } ::: -## Markers +## The bundle-since query -Commit and attempt markers are written as the decision core's canonical bytes, -produced only by `store_txn_core::canonical_commit_marker` and -`store_txn_core::canonical_attempt_marker`, never re-derived here, at their keyed -paths, and read back into the core's types for recovery. +Expose a public enum `BundleMode` (deriving `Debug`, `Clone`, `Copy`, `PartialEq`, +`Eq`, `serde::Serialize`, `serde::Deserialize`), serializing as a single JSON string: -- `write_commit_marker(root: &std::path::Path, marker: &store_txn_core::CommitMarker) - -> Result<(), StoreIoError>` publishes the commit marker's canonical bytes at - `markers/.json`. A commit marker is immutable, so publication is - no-replace, exactly like an object: write the canonical bytes to a temp file under - `tmp/`, fsync it, then link or create the marker file with a primitive that fails - rather than replaces when the file already exists, and on success fsync the marker - directory. On the already-exists condition, read the existing marker's bytes and - compare them to this marker's canonical bytes: identical bytes are an idempotent - success that leaves the existing file untouched; differing bytes are a loud - `StoreIoError::Integrity` naming the marker's snapshot, and the existing file is never - replaced. The file is keyed by the marker's snapshot hash, and a marker on disk is - never overwritten in place. -- `write_attempt_marker(root: &std::path::Path, owner_token: &str, marker: - &store_txn_core::AttemptMarker) -> Result<(), StoreIoError>` writes the attempt - marker's canonical bytes to `attempts/.json` through an atomic temp - write, a file fsync, a rename, and a directory fsync, in that order. The attempt - marker is the one marker the transaction deliberately replaces in place, atomically, - once per fallible step, so this write is a replacing atomic rename by design; the - bytes on disk are exactly the core's canonical bytes for that marker, and the file is - keyed by the owner token. -- `read_commit_marker(root, snapshot)` and `read_attempt_marker(root, owner_token)` - read those files and parse them back into the core's types, surfacing a parse - failure of a commit marker as a loud `StoreIoError::Corrupt` naming the marker; - they never silently drop a marker they cannot parse. +- `BundleMode::Full` renders `full`. +- `BundleMode::Diff` renders `diff`. -Markers are immutable and per-transaction: this module never overwrites an existing -commit marker in place and never writes a commit marker whose snapshot names a -finished install; the deliberate pre-HEAD window is honored by writing the marker -before the HEAD rename during a commit. +Expose a public struct `BundleQuery` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, +`serde::Serialize`, `serde::Deserialize`, with `#[serde(deny_unknown_fields)]`), the +answer of a bundle-since query. Public fields: -::: test -name: writing a commit marker stores its canonical bytes -given: { fixture: COMMIT_MARKER } -expect: { marker_file_bytes: "{\"domain\":\"greeting\",\"owner\":\"tok-aaaa\",\"snapshot\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"steps\":[{\"hash\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"mode\":33188,\"op\":\"writeFile\",\"path\":\"greeting/src/lib.rs\"}]}\n" } -::: +- `mode: BundleMode`, serde field name `mode`. `Full` when there is no baseline to + diff against, `Diff` when a bundle was assembled. +- `bundle: Option`, serde field name `bundle`, skipped from + serialization when `None`. Present exactly when `mode` is `Diff`, absent exactly when + `mode` is `Full`. -::: test -name: a commit marker is keyed by its snapshot hash under markers -given: { fixture: COMMIT_MARKER } -expect: { marker_path: "markers/1111111111111111111111111111111111111111111111111111111111111111.json" } -::: +Expose `bundle_since(provider: &P, module: &str, target: &str, +baseline: Option<&store_objects::StateRef>) -> Result`. The +`baseline` is the state to diff HEAD against, taken as an `Option` so that the absence +of any baseline, a first-ever gen, is modeled directly: -::: test -name: writing an attempt marker stores its canonical bytes -given: { fixture: ATTEMPT_MARKER } -expect: { attempt_file_bytes: "{\"attemptId\":\"att-0001\",\"diagnostics\":[],\"module\":\"greeting\",\"operation\":\"agent-run\",\"state\":\"inFlight\",\"target\":\"rust\"}\n" } -::: +- If `baseline` is `None`, there is no prior state: return `BundleQuery { mode: + BundleMode::Full, bundle: None }`. The gen consumer reads this as full mode, as + today. +- If `baseline` is `Some(store_objects::StateRef::Unknown)`, the baseline is a migrated + `unknown` sentinel and no bundle can be computed across it: return + `Err(ReduceError::UnsupportedForLegacy)`. The gen consumer falls back to full mode on + this error. +- If `baseline` is `Some(store_objects::StateRef::State(old_name))`, assemble the diff. + Load HEAD; if `module` is absent from the head root, the new side is a first gen and + there is no head state to diff, so return `BundleQuery { mode: BundleMode::Full, + bundle: None }`. Otherwise resolve both the old module-state (named `old_name`, + head-referent: a named-but-absent baseline object is `MissingObject`) and the head + module-state. From each state, for the given `target`, resolve to concrete values: + the block index (load and validate the `block_index` object), the prompt string (load + the `prompt_blob` blob and read its bytes as a UTF-8 string), the attribution (load + the `attribution` blob when present and validate it through + `store_diff::validate_attribution`, else an empty attribution for that module and + target with no entries), and the generated tree root hash. Build the tree object map + by loading, for both the old and the new generated tree root, that root tree and + every subtree reachable through its `Dir` entries, inserting each under its own hash. + Compute the prompt changes with `store_diff::diff_prompt(old_index, &old_prompt, + new_index, &new_prompt)`, then call `store_diff::build_bundle` with the module, those + prompt changes, the two indexes, the two attributions, the two generated tree roots as + `Some`, and the assembled tree object map. Return `BundleQuery { mode: + BundleMode::Diff, bundle: Some(bundle) }`. When the baseline state equals the head + state, the prompt diff is empty and the trees are identical, so the assembled bundle + is the empty no-op bundle, exactly what the audit consumer asserts for an + expected-no-op. ::: test -name: an attempt marker is keyed by its owner token under attempts -given: { fixture: ATTEMPT_MARKER } -expect: { attempt_path: "attempts/tok-aaaa.json" } +name: bundle_since over a present concrete baseline equal to head is a no-op diff bundle +given: { fixture: RS_BUNDLE, query: bundle_since, baseline: head } +expect: { bundle_mode: diff, bundle_blob_name: "6dc9ca1a0fe393bbed56cce83d42aadded789fac58c3f5ef22d2c13bf25d1bdc" } ::: ::: test -name: rewriting an identical commit marker is an idempotent success that leaves its bytes untouched -given: { fixture: COMMIT_MARKER_REPUBLISH_IDENTICAL } -expect: { marker_file_bytes: "{\"domain\":\"greeting\",\"owner\":\"tok-aaaa\",\"snapshot\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"steps\":[{\"hash\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"mode\":33188,\"op\":\"writeFile\",\"path\":\"greeting/src/lib.rs\"}]}\n" } +name: bundle_since over an absent baseline is full mode +given: { fixture: RS_BUNDLE, query: bundle_since, baseline: none } +expect: { bundle_mode: full, bundle_bytes: "{\"mode\":\"full\"}\n" } ::: ::: test -name: writing a different commit marker over an existing one at the same snapshot is a loud integrity error that replaces nothing -given: { fixture: COMMIT_MARKER_CONFLICT } -expect: { error: Integrity } +name: bundle_since across an unknown state is unsupported for legacy +given: { fixture: RS_BUNDLE, query: bundle_since, baseline: unknown } +expect: { error: UnsupportedForLegacy } ::: -## Installation and the installed stamp +## The file-history query -Expose `install_to_head(root: &std::path::Path, live_tree: &std::path::Path) -> -Result<(), StoreIoError>`, the monotonic stabilized installer. It reads HEAD as the -target snapshot S, reads the `INSTALLED` stamp (absent means nothing installed yet), -diffs the stamped snapshot's tree against S with `store_tree::diff_trees`, obtains -the ordered install steps from `store_txn_core::plan_install_steps`, applies every -step to the live tree (each file install is a copy of the object payload to a -same-directory temp file, an fsync, and an atomic rename; deletions and new -directory entries are followed by their parent-directory fsyncs; mode changes are -followed by an fsync of the affected file), and only after every step of S is -durable writes the `INSTALLED` stamp equal to S through a temp write, a file fsync, -a rename, and a directory fsync. The stamp is written last, so it never certifies -files that are not durably in place; after writing it the installer re-reads HEAD and -loops when HEAD moved past S, so a slow installer never regresses newer work and the -live tree is never a mix of two snapshots at rest. All steps are idempotent, so a -crash mid-install is repaired by reinstalling the diff between `INSTALLED` and HEAD. +Expose a public struct `FileHistoryEntry` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`, `serde::Serialize`, `serde::Deserialize`, with `#[serde(deny_unknown_fields)]`), +one step at which a generated path changed. Public fields: -Every object the installer reads to materialize the live tree lies on the authority -path, and the installer's verification of that path is total and precedes its first -write to the live tree. First it reads and checks the structural authority objects: -the target snapshot S, each module state S's root names, and each generated tree those -states name are checked with `store_hash::verify_object` against the name they are -filed under and validated with their owning validator -(`store_objects::validate_snapshot`, `store_objects::validate_module_state`, and the -tree's own verification). It then obtains the full ordered install step list from -`store_txn_core::plan_install_steps` over the verified tree diff and, before it applies -a single step, preflights every file payload the plan will materialize: for every -planned `writeFile` step, the payload object named by that step's content hash must be -present in the store, must verify against that name with `store_hash::verify_object`, -and must be of kind exactly `store_hash::ObjectKind::Blob`. This payload preflight -walks the entire planned step list to completion before the first payload byte is -written to the live tree, so a healthy payload that sorts before a failing one is never -installed: if any planned step's payload is absent, fails to verify, or is not a -`Blob`, the whole install aborts before any earlier step's payload is applied. Any -object on this authority path, structural or payload, that is absent, whose bytes fail -to verify, whose kind is wrong, or that fails validation aborts the install with -`StoreIoError::Integrity` naming the offending object, before a single install step is -applied and before the `INSTALLED` stamp is written. An abort leaves the live tree and -the `INSTALLED` stamp exactly as they were, byte for byte; the installer never swallows -a missing, corrupt, or wrong-kind authority or payload object into an empty or partial -diff, never applies a partial set of steps, and never certifies an incomplete or -corrupt install by stamping over a diff whose structure it could not fully and validly -read or whose every payload it could not first verify. +- `seq: u64`, serde field name `seq`. The global ordinal of the event that committed the + change, the store's gen number for that step. +- `change: store_diff::ChangeKind`, serde field name `change`. Whether the path was + added, removed, or modified at that step. +- `path: String`, serde field name `path`. The generated path, echoing the query's + path. +- `old_hash: Option`, serde field name `oldHash`, skipped when `None`. The + path's hash before the change, present for a modified or removed step. +- `new_hash: Option`, serde field name `newHash`, skipped when `None`. The + path's hash after the change, present for a modified or added step. -::: test -name: the installer writes the installed stamp as the target snapshot hash -given: { fixture: INSTALL_STAMP } -expect: { installed_bytes: "b7281259c1fa6f5b9fa3b295d6ee09705ddb02a3387bdd111888d697b63a4242\n" } -::: +Expose `file_history(provider: &P, module: &str, target: &str, path: +&str) -> Result, ReduceError>`. Trees are per module and per +target, so the query names the module and target whose generated tree carries the +path; the blame consumer already resolves a file to its owning module. A module's whole +recorded history lives on its linear event chain, not on the snapshot spine: after +migration the snapshot spine may hold only a single head snapshot while every recorded +gen survives as an event on the module's chain, so this query walks that chain and never +the snapshot spine. Walking the chain reaches every recorded step both on the migrated +store and on a future store whose gens also extend the snapshot spine, because every gen +appends an event to the module's chain in either regime. Walk the module's event chain +from its head event backward through first parents, the same private module event-chain +walk `event_graph` uses, so every event that ever changed the target's tree is visited +in head-first order. For each event, resolve its `state_after` to the module's generated +tree root for `target` as the new side and its `state_before` to that same target's +generated tree root as the old side, each through the same state resolution `state_view` +performs: a `State` name loads and validates that module-state and takes its target's +`generated_tree` root, an absent side (an added-from-nothing event) contributes a `None` +root, and an `unknown` sentinel side likewise contributes a `None` root so a legacy step +degrades to an added or removed appearance rather than erroring. Load each present root +and its subtrees into the object map, call `store_tree::diff_trees(old_root, new_root, +objects)`, and for every returned `TreeChange` whose `path` equals the queried `path`, +push a `FileHistoryEntry` carrying the event's `ordinal` as `seq`, the projected +`ChangeKind` and hashes (an `Added` change sets `new_hash` only, a `Removed` change sets +`old_hash` only, a `Modified` change sets both), and the path. The head event is +head-referent, so a corrupt or absent head event is an error; an ancestor event reached +through the chain terminates the walk gracefully at the genesis boundary when absent, and +a module whose root entry `event_head` is the genesis sentinel has an empty chain and so +an empty history. Entries are ordered head-first, most recent first, the order the chain +walk yields. Over legacy history the tree-diff entries render exactly where both of a +step's states materialize; where a legacy step referenced an `unknown` state, that side +is the empty tree and the step still surfaces its added or removed change against it. ::: test -name: the installer aborts with an integrity error when a snapshot names an absent module state and never writes the stamp -given: { fixture: INSTALL_MISSING_MODULE_STATE } -expect: { error: Integrity } +name: file_history over a path that changed reports its change entries +given: { fixture: RS_HISTORY, query: file_history, path: "greeting.rs" } +expect: { history_len: 2, first_change: modified, first_path: "greeting.rs" } ::: ::: test -name: the installer aborts with an integrity error when a generated tree object fails to verify and never writes the stamp -given: { fixture: INSTALL_CORRUPT_TREE } -expect: { error: Integrity } +name: file_history over a path that never existed is empty +given: { fixture: RS_HISTORY, query: file_history, path: "missing.rs" } +expect: { history_len: 0 } ::: +## The event-by-ordinal query + +Expose `event_by_ordinal(provider: &P, ordinal: u64) -> +Result, ReduceError>`. The global ordinal names one event on +one module's event chain, so the lookup walks module event chains, never the snapshot +spine and never a body scan of unrelated objects: after migration every recorded event +lives on a module chain while the snapshot spine may hold only the head snapshot, so a +spine walk would reach only the head ordinal. If `ordinal` is zero return `Ok(None)` +(ordinals start at one). Load HEAD; if HEAD is `None`, return `Ok(None)`. Otherwise, for +each module named in the head snapshot's root, walk that module's event chain from its +head event backward through first parents, the same private module event-chain walk +`event_graph` uses, and return `Ok(Some(event))` for the first event whose `ordinal` +equals the queried ordinal. A root entry whose `event_head` is the genesis sentinel +(empty recorded history) is skipped without a load: it names no event, so it can never +match an ordinal and never raises `MissingObject`. The head event of each walked chain is +head-referent, so a corrupt or absent head event is an error, while an ancestor event +absent on the way back is the genesis boundary and terminates that chain's walk +gracefully. When no chain carries the ordinal, return `Ok(None)`. Because event ordinals +are globally unique across the module chains that partition all recorded events, at most +one event ever matches the queried ordinal. + ::: test -name: the installer preflights every planned payload so an absent later payload aborts before any earlier payload is written -given: { fixture: INSTALL_PAYLOAD_ABSENT } -expect: { error: Integrity } +name: event_by_ordinal returns the event committed at that ordinal +given: { fixture: RS_CLEAN, query: event_by_ordinal, ordinal: 1 } +expect: { event_ordinal: 1, event_kind: gen } ::: ::: test -name: the installer preflights every planned payload so a corrupt later payload aborts before any earlier payload is written -given: { fixture: INSTALL_PAYLOAD_CORRUPT } -expect: { error: Integrity } +name: event_by_ordinal past the head sequence is none +given: { fixture: RS_CLEAN, query: event_by_ordinal, ordinal: 2 } +expect: { is_none: true } ::: ::: test -name: the installer preflights every planned payload so a non-blob later payload aborts before any earlier payload is written -given: { fixture: INSTALL_PAYLOAD_WRONG_KIND } -expect: { error: Integrity } +name: event_by_ordinal over a current-state-only head skips the historyless root and is none +given: { fixture: RS_CURRENT_ONLY, query: event_by_ordinal, ordinal: 1 } +expect: { is_none: true } ::: -## Startup recovery - -Expose `recover(root: &std::path::Path, live_tree: &std::path::Path, this_host: -&str, is_alive: &dyn Fn(u64) -> bool) -> Result<(), StoreIoError>`, the pass every -command runs at startup. Under the install lock it enumerates the commit markers, and -for each one it decides an action through the pure core rather than deciding policy -itself: +## The prompt-diff-of-event query -1. Compute whether the marker's snapshot is HEAD or an ancestor of HEAD by walking - the snapshot spine (parents and seq) through this module's provider, using - `store_objects::Snapshot`. The walk reads only snapshot objects, and a snapshot it - cannot load is the genesis boundary: when a candidate snapshot is itself unloadable - the walk carries no sequence floor and descends the full spine, so it dequeues the - genesis sentinel parent, finds that parent absent from the store, and terminates - with a clean non-ancestor answer rather than erroring. A marker keyed by a snapshot - the store never published is therefore judged a non-ancestor at the genesis - boundary, not a load failure. -2. Determine whether the marker's owner is live: read the owner's lock file, parse it - with `store_txn_core::parse_lock`, and, for a parsed lock, ask - `store_txn_core::liveness_decision` with the lock's host, `this_host`, and the - injected `is_alive` probe over the lock's pid; a lock on a foreign host is never - judged dead and its owner is treated as live for the purpose of leaving the marker - alone. Liveness is never decided by the mere presence of a lock file, by a wall - clock, or by shelling out to a process tool. -3. Call `store_txn_core::classify_marker` with the ancestor and liveness facts and - act on its verdict: a roll-forward-and-delete verdict runs `install_to_head` and - then deletes the marker; a leave verdict leaves the marker in place; a - delete-and-collect verdict deletes the marker and lets the janitor collect its - objects. +Expose a public struct `PromptDiff` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, +`serde::Serialize`, `serde::Deserialize`, with `#[serde(deny_unknown_fields)]`), the +prompt-side diff between one event's before and after states. Public fields: -Recovery is idempotent: every action is either an idempotent install step or an -atomic delete, so rerunning recovery converges. +- `legacy_partial: bool`, serde field name `legacyPartial`. `true` when the event's + `state_before` or `state_after` is the `unknown` sentinel, so the diff could not be + materialized and `changes` is empty; `false` when both sides were concrete or absent + and the diff was computed. +- `changes: Vec`, serde field name `changes`. The prompt-side + changes in emission order, empty when `legacy_partial` is `true`. -A dead writer's in-flight attempt marker is not converted into a private sentinel -file: this disk shell never writes a stand-in such as an `attempts/.interrupted` file and never treats such a file as a store event. The recorded -`interrupted` outcome is a real content-addressed event, allocated an ordinal and -recorded through a normal commit by the commit layer that sits above this shell, so a -crashed attempt becomes a queryable event rather than anonymous litter. This shell's -part is to surface the dead-owner attempt marker and its -`store_txn_core::attempt_disposition` verdict (an in-flight record yields an -interrupted event, a completed record yields none), keyed by attempt id so a crash -between the event's commit and the marker's deletion cannot fabricate a duplicate; it -never fabricates the event itself. +Expose `prompt_diff_of(provider: &P, event: &store_objects::Event) +-> Result`. If either `state_before` or `state_after` is +`Some(store_objects::StateRef::Unknown)`, state materialization is unsupported across +the sentinel: return `PromptDiff { legacy_partial: true, changes: Vec::new() }`. This +is the partial legacy capability: the reducer never fabricates a materialized diff over +an `unknown` state. Otherwise resolve the old side (an absent `state_before` is treated +as an empty prior, so an added-from-nothing event diffs against an empty block index and +empty prompt) and the new side from their concrete state names: load and validate each +block index, and read each prompt blob's bytes as a string. Compute +`store_diff::diff_prompt(&old_index, &old_prompt, &new_index, &new_prompt)` and return +`PromptDiff { legacy_partial: false, changes }`. The event's module is not needed to +diff its prompt sides. ::: test -name: startup recovery over an ancestor-or-equal marker deletes the marker -given: { fixture: RECOVER_ANCESTOR } -expect: { marker_absent: true } +name: prompt_diff_of a concrete event returns its prompt changes +given: { fixture: RS_DIFF, query: prompt_diff_of, event: head } +expect: { prompt_diff_matches_build: true, legacy_partial: false } ::: ::: test -name: startup recovery over an ancestor-or-equal marker rolls the installed stamp forward to HEAD -given: { fixture: RECOVER_ANCESTOR } -expect: { installed_bytes: "289f591aec3ebd915526648ad486d6b028dca5200e9c392b7fdaf4c498f6ec09\n" } +name: prompt_diff_of a legacy event with unknown states is legacy partial +given: { fixture: RS_LEGACY, query: prompt_diff_of, event: legacy } +expect: { legacy_partial: true } ::: -::: test -name: startup recovery leaves a non-ancestor marker whose owner lock is on a foreign host -given: { fixture: RECOVER_FOREIGN } -expect: { marker_present: true } -::: +## The drift-baseline query -::: test -name: recovery over an unloadable marker snapshot walks the spine to the absent genesis parent and leaves the foreign owned marker -given: { fixture: RECOVER_GENESIS_PARENT } -expect: { marker_present: true } -::: +Expose a public struct `DriftBaseline` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, +`serde::Serialize`, `serde::Deserialize`, with `#[serde(deny_unknown_fields)]`), the +exact head-anchored baseline the reconcile consumer diffs against. Public fields: -## The janitor +- `generated_tree: String`, serde field name `generatedTree`. The head generated tree + root hash for the target. +- `tree: store_tree::Tree`, serde field name `tree`. The head generated tree object + itself, resolved and validated. -Expose `janitor(root: &std::path::Path, this_host: &str, is_alive: &dyn Fn(u64) -> -bool) -> Result<(), StoreIoError>`. Under its own lock it deletes `tmp/` entries, -dead-owner attempt markers, dead non-ancestor commit markers, and unreferenced objects -owned by dead writers. It judges a marker's owner exactly as recovery does: it parses -the owner's lock with `store_txn_core::parse_lock` and applies -`store_txn_core::liveness_decision` with the lock's host, `this_host`, and the injected -`is_alive` probe, never by the mere presence of the lock file; a dead-local owner's -non-ancestor marker is collected and a foreign-host owner's marker is left, because a -foreign host is never judged dead. The ancestry judgment is the same spine walk and -the same genesis boundary recovery uses. Like recovery, the janitor never invents a -sentinel file to stand in for an `interrupted` event: the event is recorded through a -normal commit by the layer above, and the janitor collects the dead-owner attempt -marker only after that event exists. In this module's tested surface the janitor -unconditionally clears `tmp/` staging scratch. +Expose `drift_baseline(provider: &P, module: &str, target: &str) -> +Result`. Load HEAD; if `module` is absent from its root, or +the head module-state has no entry for `target`, return `ReduceError::MissingObject` +with the missing module-state name, because a drift check without a baseline is a loud +failure, not silent success. Otherwise resolve the target's `generated_tree` root, load +and validate that tree object, and return the root hash and the tree. This is exact and +head-anchored: no patch replay, the exact blobs to diff against. ::: test -name: the janitor deletes staging temp entries -given: { fixture: JANITOR_TMP } -expect: { tmp_empty: true } +name: drift_baseline returns the head generated tree for the target +given: { fixture: RS_CLEAN, query: drift_baseline } +expect: { drift_baseline_is_head_tree: true } ::: +## The tree-at query + +Expose `tree_at(provider: &P, module: &str, target: &str) -> +Result, ReduceError>`. Load HEAD; if HEAD is `None`, or +`module` is absent from its root, or the head module-state has no entry for `target`, +return `Ok(None)`. Otherwise resolve the target's `generated_tree` root, load and +validate that tree object, and return `Ok(Some(tree))`. This is the head generated +tree, exact at head. + ::: test -name: the janitor collects a dead local owner's non-ancestor commit marker judged by the liveness decision -given: { fixture: JANITOR_DEAD_LOCAL_COLLECTS } -expect: { marker_absent: true } +name: tree_at returns the head generated tree for the target +given: { fixture: RS_CLEAN, query: tree_at } +expect: { tree_entry_name: "greeting.rs" } ::: ::: test -name: the janitor leaves a foreign host owner's non-ancestor commit marker because a foreign host is never judged dead -given: { fixture: JANITOR_FOREIGN_LEAVES } -expect: { marker_present: true } +name: tree_at for a module absent from head is none +given: { fixture: RS_CLEAN, query: tree_at, module: absent } +expect: { is_none: true } ::: -## Dead-attempt surfacing and marker cleanup +## The state-view query -This section is additive over the committed shell and gives the commit layer above this -shell the two things it needs to discharge the interrupted-event obligation without ever -writing a store file by hand: a read-only enumeration of the attempt markers the store -holds together with each owner's liveness verdict, and two idempotent cleanup primitives -for the markers a landed transaction or a completed conversion must remove. Recovery and -the janitor keep their committed commit-marker scope untouched; this is the separate -read-and-cleanup surface the commit layer drives. +This query is additive over the committed reducer and resolves one arbitrary state +name (an event's `state_before` or `state_after`) to the concrete per-event view its +consumers diff against. It is the public completion of the resolution `bundle_since` +performs internally: HALT 1 BLOCKER B observed that `store_reduce` exposed no public +"resolve a state name to its module-state and generated tree" query, so the blame +render store variant had to hand-walk module-states; `state_view` is that resolution, +so no consumer reimplements it. It resolves both consumer needs from one call: the +per-event prompt body two of these views diff, and the per-event generated tree two of +these views diff for a target through the committed `store_tree` walk. -Expose a public struct `SurfacedAttempt` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`), -one attempt marker the store holds paired with the facts the commit layer decides on. -Public fields: +Expose a public struct `StateView` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, +`serde::Serialize`, `serde::Deserialize`, with `#[serde(deny_unknown_fields)]`), the +resolved view of one state for one target. Public fields: -- `owner_token: String`, the token the marker is keyed by under `attempts/`, exactly the - filename stem of `attempts/.json`. It is the token the commit layer passes - back to `delete_attempt_marker` after it converts or cleans up the marker. -- `marker: store_txn_core::AttemptMarker`, the parsed marker record, its bytes read back - through the core's canonical type exactly as `read_attempt_marker` parses it, so the - commit layer reads the marker's `attempt_id`, `operation`, `module`, `target`, `state`, - and `diagnostics` from the core's type and never re-parses the bytes itself. -- `owner_live: bool`, the owner-liveness verdict for this marker, judged exactly as - recovery and the janitor judge a marker owner: the owner's lock is the file - `locks/.lock`; an absent lock is a dead owner (`false`), a lock that fails - to parse is a stale-eligible owner that is never judged dead here (`true`), and a parsed - lock is judged with `store_txn_core::liveness_decision` over the lock's host, `this_host`, - and the injected `is_alive` probe of the lock's pid, `true` for a `Live` or `ForeignHost` - verdict and `false` only for a `Dead` verdict. This is the same owner-liveness decision - the committed recovery and janitor apply to a marker owner, computed here without - deleting or mutating anything. +- `legacy_partial: bool`, serde field name `legacyPartial`. `true` when the state is + `Some(store_objects::StateRef::Unknown)`, the migrated `unknown` sentinel, so the + state could not be materialized and every other field is empty or `None`; `false` + when the state was concrete or absent and the view was resolved. This mirrors + `prompt_diff_of`'s partial-legacy contract exactly: the reducer never fabricates a + materialized view over an `unknown` state. +- `prompt_blob: Option`, serde field name `promptBlob`, skipped from + serialization when `None`. The resolved module-state's prompt blob object name; `None` + when the state is absent (no prior) or `legacy_partial`. +- `prompt: String`, serde field name `prompt`. The resolved module-state's prompt body, + read from its prompt blob's bytes as a UTF-8 string exactly as `bundle_since` and + `prompt_diff_of` read a prompt blob; the empty string when the state is absent or + `legacy_partial`. This is the exact prompt body a prompt-diff consumer diffs; it is + never re-encoded and never a lossy round-trip. +- `generated_tree: Option`, serde field name `generatedTree`, skipped when + `None`. The requested `target`'s generated tree root object name in this state; `None` + when the state is absent or `legacy_partial`, or when the resolved module-state has no + entry for `target` in its `targets` map. +- `tree_objects: std::collections::BTreeMap`, serde field + name `treeObjects`. The requested target's generated tree root and every subtree + reachable through its `Dir` entries, each loaded, validated, and inserted under its + own object name, exactly as `bundle_since` builds its tree object map for one root. + Empty when `generated_tree` is `None`. A files-touched consumer merges the + `tree_objects` of two `StateView`s and calls `store_tree::diff_trees(old_root, + new_root, merged)` over them, so it never reimplements the tree walk. -Expose `surface_attempt_markers(root: &std::path::Path, this_host: &str, is_alive: &dyn -Fn(u64) -> bool) -> Result, StoreIoError>`, a read-only pass that -enumerates the attempt markers under `attempts/` and returns one `SurfacedAttempt` per -marker. It enumerates the `attempts/` directory keys, and a store with no `attempts/` -directory or no markers yields an empty list. For each key in ascending key order it reads -and parses the marker through the core's canonical type exactly as `read_attempt_marker` -does, surfacing a parse failure as a loud `StoreIoError::Corrupt` naming the owner token, -and judges the owner's liveness as the `owner_live` field above describes. The returned -vector is ordered by owner token ascending, deterministically, so the commit layer sees a -stable order. This pass reads only: it deletes no marker, writes no marker, and moves no -HEAD; it never converts a marker into an event and never fabricates a sentinel, exactly as -this shell's recovery contract already forbids. The commit layer that sits above this -shell reads the surfaced markers, decides each one's disposition through -`store_txn_core::attempt_disposition`, records any interrupted event through a normal -commit, and then removes the handled marker through `delete_attempt_marker` below. +Expose `state_view(provider: &P, state: Option<&store_objects::StateRef>, +target: &str) -> Result`, taking the state as an `Option` so an +absent `state_before` (an added-from-nothing event) is modeled directly: -Expose `delete_attempt_marker(root: &std::path::Path, owner_token: &str) -> Result<(), -StoreIoError>`, which removes the attempt marker keyed by `owner_token` at -`attempts/.json`. The delete is atomic and idempotent: removing a marker that -is already absent is a success that returns `Ok(())`, so a crash between an interrupted -event's commit and this delete is repaired by rerunning the delete, and a converted or -completed marker is cleaned up exactly once observationally. It removes only the one keyed -attempt marker file and touches no other store state. +- If `state` is `Some(store_objects::StateRef::Unknown)`, return `StateView { + legacy_partial: true, prompt_blob: None, prompt: String::new(), generated_tree: None, + tree_objects: empty }` without reading any object, exactly as `prompt_diff_of` and + `bundle_since` short-circuit the `unknown` sentinel before any provider read. +- If `state` is `None`, there is no prior state to resolve: return `StateView { + legacy_partial: false, prompt_blob: None, prompt: String::new(), generated_tree: None, + tree_objects: empty }`, the empty prior an added-from-nothing side diffs against, the + same empty prior `prompt_diff_of` uses for an absent `state_before`. +- If `state` is `Some(store_objects::StateRef::State(name))`, resolve it head-referent: + load and validate the module-state named `name` (a named-but-absent state object is + `ReduceError::MissingObject` with that name, and a corrupt one is + `ReduceError::CorruptObject`, through the trusted gate, never a silent empty view). + Set `prompt_blob` to `Some(state.prompt_blob)`; load that prompt blob as a `Blob` + object and set `prompt` to its bytes read as a UTF-8 string. For the requested + `target`, if the module-state's `targets` map has an entry, set `generated_tree` to + `Some(that target's generated_tree root)` and build `tree_objects` by loading that + root tree and every subtree reachable through its `Dir` entries under their own names; + if the module-state has no entry for `target`, leave `generated_tree` `None` and + `tree_objects` empty. Return the assembled `StateView` with `legacy_partial: false`. -Expose `delete_commit_marker(root: &std::path::Path, snapshot: &str) -> Result<(), -StoreIoError>`, which removes the commit marker keyed by `snapshot` at -`markers/.json`. Like the attempt-marker delete it is atomic and idempotent: -removing a commit marker that is already absent returns `Ok(())`, so the deliberate -post-install marker delete a landed commit performs is safe to rerun after a crash. It -removes only the one keyed commit marker file and touches no other store state, and it -never moves HEAD, never touches the `INSTALLED` stamp, and never collects an object. +This query is exact over concrete and absent states and explicitly partial over the +`unknown` sentinel, honoring the same legacy contract every other query declares. It +adds no new spine walk: it resolves one named state object head-referent, precisely the +resolution `bundle_since` already performs for its two states, now exposed as one query. ::: test -name: surfacing a dead-local owner's attempt marker returns it with its parsed record and a not-live verdict -given: { fixture: SURFACE_DEAD } -expect: { surfaced_len: 1, surfaced_owner_token: "tok-aaaa", surfaced_attempt_id: "att-0001", surfaced_owner_live: false } +name: state_view over a present head state resolves the prompt body and the target generated tree +given: { fixture: RS_CLEAN, query: state_view, state: head, target: rust } +expect: { legacy_partial: false, prompt: "The greeting module.\n", generated_tree_is_head_tree: true, tree_entry_name: "greeting.rs" } ::: ::: test -name: surfacing a live same-host owner's attempt marker returns it with a live verdict -given: { fixture: SURFACE_LIVE } -expect: { surfaced_len: 1, surfaced_owner_live: true } +name: state_view over an absent prior state is the empty prior view +given: { fixture: RS_CLEAN, query: state_view, state: none, target: rust } +expect: { legacy_partial: false, prompt: "", prompt_blob_is_none: true, generated_tree_is_none: true, tree_objects_empty: true } ::: ::: test -name: surfacing a foreign-host owner's attempt marker returns it with a live verdict because a foreign host is never judged dead -given: { fixture: SURFACE_FOREIGN } -expect: { surfaced_len: 1, surfaced_owner_live: true } +name: state_view over an unknown legacy state is legacy partial +given: { fixture: RS_CLEAN, query: state_view, state: unknown, target: rust } +expect: { legacy_partial: true, prompt: "", prompt_blob_is_none: true, generated_tree_is_none: true, tree_objects_empty: true } ::: ::: test -name: surfacing an attempt marker whose owner lock is absent returns it with a not-live verdict -given: { fixture: SURFACE_NO_LOCK } -expect: { surfaced_len: 1, surfaced_owner_live: false } +name: state_view over a present state for a target absent from the state has no generated tree +given: { fixture: RS_CLEAN, query: state_view, state: head, target: typescript } +expect: { legacy_partial: false, prompt: "The greeting module.\n", generated_tree_is_none: true, tree_objects_empty: true } ::: ::: test -name: surfacing an attempt marker whose owner lock is malformed returns it with a live verdict because a malformed lock is never judged dead -given: { fixture: SURFACE_MALFORMED } -expect: { surfaced_len: 1, surfaced_owner_live: true } +name: state_view over a state whose module-state object is absent is a missing-object error +given: { fixture: RS_CLEAN_MISSING_STATE, query: state_view, state: head, target: rust } +expect: { error: MissingObject } ::: +## The move-records and reword-chain queries + +Expose `move_records(provider: &P, module: &str) -> +Result, ReduceError>` and `reword_chain(provider: +&P, module: &str) -> Result, ReduceError>`. Each walks the +module's linear event chain from its head event backward through first parents, and +collects, in head-first order, the events whose payload is a `Move` for `move_records` +or a `Reword` for `reword_chain`. Load HEAD; if HEAD is `None`, or `module` is absent +from its root, or the module's root entry `eventHead` is the genesis sentinel (empty +recorded history), return an empty vector. The head event is head-referent, but ancestor +events reached through the chain terminate the walk gracefully at the genesis boundary +when absent. A chain with no matching event yields an empty vector. Rewords are +post-migration only, so a legacy chain contributes no reword; `reword_chain` is exact +over post-migration events. + ::: test -name: surfacing over a store with no attempt markers returns an empty list -given: { fixture: SURFACE_EMPTY } -expect: { surfaced_len: 0 } +name: move_records returns the module's move events +given: { fixture: RS_MOVED, query: move_records } +expect: { chain_len: 1, chain_kind: move } ::: ::: test -name: surfacing two attempt markers returns them ordered by owner token -given: { fixture: SURFACE_TWO } -expect: { surfaced_len: 2, surfaced_owner_tokens: ["tok-aaaa", "tok-bbbb"] } +name: move_records over a chain with no move is empty +given: { fixture: RS_CLEAN, query: move_records } +expect: { chain_len: 0 } ::: ::: test -name: surfacing an unparseable attempt marker is a loud corrupt error naming its owner token -given: { fixture: SURFACE_CORRUPT } -expect: { error: Corrupt } +name: reword_chain returns the module's reword events +given: { fixture: RS_REWORD, query: reword_chain } +expect: { chain_len: 1, chain_kind: reword } ::: ::: test -name: deleting a present attempt marker removes its file -given: { fixture: DELETE_ATTEMPT_PRESENT } -expect: { attempt_absent: true } +name: reword_chain over a chain with no reword is empty +given: { fixture: RS_CLEAN, query: reword_chain } +expect: { chain_len: 0 } ::: +## The event-graph query + +Expose a public struct `EventNode` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, +`serde::Serialize`, `serde::Deserialize`, with `#[serde(deny_unknown_fields)]`), one +node of a module's event graph. Public fields: + +- `event: store_objects::Event`, serde field name `event`. The event itself. +- `materialized: bool`, serde field name `materialized`. `true` when both the event's + before and after states are concrete or absent and could be materialized, `false` for + a legacy node whose `state_before` or `state_after` is the `unknown` sentinel. +- `state_before: Option`, serde field name `stateBefore`, skipped when `None`. + The concrete before-state name when known; `None` when the before state is absent or + the `unknown` sentinel. +- `state_after: Option`, serde field name `stateAfter`, skipped when `None`. The + concrete after-state name when known; `None` when the after state is absent or the + `unknown` sentinel. + +Expose `event_graph(provider: &P, module: &str) -> +Result, ReduceError>`. Load HEAD; if HEAD is `None`, or `module` is +absent from its root, or the module's root entry `eventHead` is the genesis sentinel +(empty recorded history), return an empty vector: a current-state-only module has no +event to graph. Otherwise walk the module's event chain from its head event +backward through first parents, in head-first order. For each event build an `EventNode` +whose `event` is the event; whose `state_before` and `state_after` are the concrete +state names when the corresponding `StateRef` is `State`, and `None` when it is `Unknown` +or absent; and whose `materialized` is `false` when either state is the `unknown` +sentinel and `true` otherwise. A legacy node carries its payload and classification (in +its own `event`) with materialized states unavailable, exactly the partial legacy +capability the site fixture builder and the wasm consumer read. The head event is +head-referent; ancestor events terminate the walk at the genesis boundary when absent. + ::: test -name: deleting an absent attempt marker is an idempotent success -given: { fixture: DELETE_ATTEMPT_ABSENT } -expect: { attempt_absent: true } +name: event_graph over a concrete chain marks the node materialized +given: { fixture: RS_CLEAN, query: event_graph } +expect: { node_count: 1, head_materialized: true } ::: ::: test -name: deleting a present commit marker removes its file -given: { fixture: DELETE_COMMIT_PRESENT } -expect: { marker_absent: true } +name: event_graph over a legacy chain marks the legacy node not materialized +given: { fixture: RS_LEGACY, query: event_graph } +expect: { node_count: 2, head_materialized: true, legacy_materialized: false } ::: ::: test -name: deleting an absent commit marker is an idempotent success -given: { fixture: DELETE_COMMIT_ABSENT } -expect: { marker_absent: true } +name: an event graph node round-trips through its serialization unchanged +given: { fixture: RS_CLEAN, query: event_graph, roundtrip: node } +expect: { round_trip_equal: true } ::: -## Hermetic staging - -Expose `stage_object_payload(root: &std::path::Path, staging: &std::path::Path, -name: &str) -> Result`, which materializes an -object's payload into a staging tree by copying the payload bytes into a new file, -never by hardlinking the store object into a writable tree. A staged file and its -store object are always distinct inodes, so a mutation of a staged file can never -reach the published object. Staging copies payload bytes out of store objects, seeds -a build-local lockfile, and projects the workspace manifest; those broader steps are -build-local ephemera by `store_txn_core::classify_path` and never enter the store. - ::: test -name: a staged object payload is a copy with an inode distinct from its store object -given: { fixture: STAGE_INODE } -expect: { distinct_inodes: true } +name: event_graph over a current-state-only module walks no events and is empty +given: { fixture: RS_CURRENT_ONLY, query: event_graph } +expect: { node_count: 0 } ::: -## The error type - -Expose a public enum `StoreIoError` (deriving `Debug`) with exactly these variants: - -- `Io(String)`: an underlying filesystem error, carrying a human-readable context - string. -- `Integrity(String)`: an already-present object whose bytes fail to verify against - the expected name, carrying the offending object name. Publication never degrades a - mismatch into a silent replacement. -- `HeadConflict { expected: Option, found: Option }`: a - compare-and-set whose expected prior HEAD does not match the current HEAD, carrying - both values; the HEAD is left unchanged. -- `Corrupt(String)`: a marker file whose bytes fail to parse into the decision - core's type, carrying the marker's key. Recovery never silently drops a marker it - cannot parse. - -## Rendering a store I/O failure - -Implement `std::fmt::Display` for `StoreIoError` so a consumer, in particular the recover -seam that today `Debug`-formats it, can render a store I/O failure as a bare, -human-readable message that carries no `Debug` artifacts: no variant name, no quotes, and -no braces. Each variant renders exactly one deterministic line. `Io` renders `store -filesystem error: {context}`, interpolating its carried context string verbatim. -`Integrity` renders `store object failed its integrity check: {name}`, interpolating its -carried object name verbatim. `Corrupt` renders `corrupt store marker: {key}`, -interpolating its carried marker key verbatim. `HeadConflict` renders `head -compare-and-set conflict: expected {expected}, found {found}`, where each of `expected` -and `found` renders its inner string verbatim when it is `Some` and the bare word `none` -when it is `None`. This addition is purely additive: the `Debug` derive is retained for -the existing variant assertions, and the module's error construction and propagation are -unchanged. The `Display` impl and its `fmt` method carry no doc comment, matching the -crate's existing trait-impl style. - ## Fixtures the tests pin -Every case builds its store in a fresh temporary directory, performs the case's one -operation, and asserts the pinned observable. No fixture reads a wall clock or draws -a random value: owner tokens are the literal `tok-aaaa`, every hash is a pinned -literal, and the process-liveness probe is a fixed predicate supplied by the case, -never a real clock, a thread race, or a process query. The object names and envelope byte strings below are the exact values the -reference computation `store-io-ref.py` pins; each is the store_hash envelope of its -payload and the SHA-256 name of that envelope. - -Pinned object constants: - -- **BLOB1**: a `blob` whose payload is the twenty-one bytes `The greeting module.` - followed by one newline. Its envelope bytes are exactly - `napl-store blob v1 21` then one newline then `The greeting module.` then one - newline, and its name is - `0965493c60ab6b118535fc59c5a793c753295acd9fd8d75e084288459624fe93`. -- **BLOB2**: a `blob` whose payload is the six bytes `other` followed by one newline. - Its name is - `ee9b438b4cf07aa2fd3296dcef33b8c72f6c78bd2571ef9f430a4813f080b98d`. It is the - mismatched-bytes source for the integrity case only. -- **SNAP0**: a `snapshot` whose canonical payload is exactly - `{"parents":["9999999999999999999999999999999999999999999999999999999999999999"],"root":{},"seq":0}` followed by one newline. Its envelope bytes are - `napl-store snapshot v1 99` then one newline then that payload, and its name is - `b7281259c1fa6f5b9fa3b295d6ee09705ddb02a3387bdd111888d697b63a4242`. This is the - seq-zero genesis snapshot: its single parent - `9999999999999999999999999999999999999999999999999999999999999999` is a symbolic - ancestor that is never published in any fixture store, so a spine walk that reaches - SNAP0 follows that parent to an absent object and terminates at the genesis - boundary rather than erroring. -- **SNAP1**: a `snapshot` whose canonical payload is - `{"parents":["b7281259c1fa6f5b9fa3b295d6ee09705ddb02a3387bdd111888d697b63a4242"],"root":{},"seq":1}` - followed by one newline, a child of SNAP0 at seq one. Its name is - `289f591aec3ebd915526648ad486d6b028dca5200e9c392b7fdaf4c498f6ec09`. -- **THREES**: the sixty-four-character all-threes hash - `3333333333333333333333333333333333333333333333333333333333333333`, a snapshot name - never published as an object in any fixture store. It keys the genesis-boundary - recovery marker whose candidate snapshot is unloadable. -- **FOURS**: the sixty-four-character all-fours hash - `4444444444444444444444444444444444444444444444444444444444444444`, a generated-tree - name a module state references. The object filed under it in the corrupt-tree fixture - does not verify to it. -- **CORRUPT_TREE**: a `tree` object whose payload is the eight bytes `corrupt` followed - by one newline, so its envelope bytes are `napl-store tree v1 8` then one newline then - `corrupt` then one newline. Its true name is - `88d1a2f9d9f9b4293f90ff789723e66b9cd973d9edffb955d736a7e840766d6e`, not FOURS, so an - object holding these bytes filed at FOURS's fan-out path fails verification against - FOURS. - -Pinned marker constants (reused verbatim from the `store_txn_core` corpus, which -owns their canonical serialization; this module writes and reads the same bytes): - -- **COMMIT_M**: the `CommitMarker` with domain `greeting`, owner `tok-aaaa`, - snapshot the sixty-four-character all-ones hash - `1111111111111111111111111111111111111111111111111111111111111111`, and one - install step, a `writeFile` of path `greeting/src/lib.rs` at mode `33188` with hash - the all-twos hash - `2222222222222222222222222222222222222222222222222222222222222222`. Its canonical - bytes are exactly - `{"domain":"greeting","owner":"tok-aaaa","snapshot":"1111111111111111111111111111111111111111111111111111111111111111","steps":[{"hash":"2222222222222222222222222222222222222222222222222222222222222222","mode":33188,"op":"writeFile","path":"greeting/src/lib.rs"}]}` - followed by one newline. -- **ATTEMPT_M**: the in-flight `AttemptMarker` with attempt id `att-0001`, empty - diagnostics, module `greeting`, operation `agent-run`, state `inFlight`, target - `rust`. Its canonical bytes are exactly - `{"attemptId":"att-0001","diagnostics":[],"module":"greeting","operation":"agent-run","state":"inFlight","target":"rust"}` - followed by one newline. -- **COMMIT_M2**: a second `CommitMarker` keyed by the same all-ones snapshot as COMMIT_M - but with owner `tok-bbbb` and no install steps, so it differs from COMMIT_M. Its - canonical bytes are exactly - `{"domain":"greeting","owner":"tok-bbbb","snapshot":"1111111111111111111111111111111111111111111111111111111111111111","steps":[]}` - followed by one newline. It is the conflicting-marker source for the commit-marker - no-replace case only. - -The store fixtures, each built in its own tempdir: - -- **READ_BLOB**: a store holding BLOB1 at its fan-out path. The provider queries - BLOB1's name and returns BLOB1's envelope bytes. -- **READ_SNAPSHOT**: a store holding SNAP0 at its fan-out path. The provider queries - SNAP0's name and returns SNAP0's envelope bytes. -- **READ_ABSENT**: a store holding BLOB1 only. The provider queries the all-zero - sixty-four-hex name, absent from the store, and returns `None`. -- **READ_HEAD_PRESENT**: a store whose `HEAD` file holds SNAP0's name followed by one - newline. `head_snapshot_hash` returns SNAP0's name with no newline. -- **READ_HEAD_ABSENT**: a store with no `HEAD` file. `head_snapshot_hash` returns - `None`. -- **PARITY**: a store holding BLOB1 and SNAP0 at their fan-out paths with `HEAD` at - SNAP0. An in-memory provider is built from the same two objects and the same head. - The two providers are queried for BLOB1's name, SNAP0's name, the all-zero absent - name, and the head, and every answer is equal between them. -- **PUBLISH_NEW**: an empty store. `publish_object` publishes a `blob` with BLOB1's - payload under owner token `tok-aaaa`. It returns BLOB1's name, and the file at - BLOB1's fan-out path holds BLOB1's exact envelope bytes. -- **PUBLISH_IDEMPOTENT**: a store already holding BLOB1 at its fan-out path. - `publish_object` publishes a `blob` with BLOB1's payload again under owner token - `tok-aaaa`. It returns BLOB1's name and leaves the existing object's bytes - untouched. -- **PUBLISH_MISMATCH**: a store whose file at BLOB1's fan-out path holds BLOB2's - envelope bytes instead of BLOB1's, a corruption. `publish_object` publishing - BLOB1's payload reads the existing bytes, fails verification against BLOB1's name, - and returns `StoreIoError::Integrity` naming BLOB1, replacing nothing. -- **SET_HEAD**: an empty store. `write_head` writes SNAP0's name; the `HEAD` file - then holds SNAP0's name followed by one newline. -- **CAS_SUCCESS**: a store whose `HEAD` holds SNAP0's name. - `compare_and_set_head` with expected SNAP0 and next SNAP1 succeeds; the `HEAD` file - then holds SNAP1's name followed by one newline. -- **CAS_CONFLICT**: a store whose `HEAD` holds SNAP0's name. - `compare_and_set_head` with expected the all-zero sixty-four-hex name (which HEAD - does not hold) and next SNAP1 returns `StoreIoError::HeadConflict`, and the `HEAD` - file still holds SNAP0's name followed by one newline. -- **COMMIT_MARKER**: an empty store. `write_commit_marker` writes COMMIT_M; the file - at `markers/1111111111111111111111111111111111111111111111111111111111111111.json` - holds COMMIT_M's canonical bytes. -- **ATTEMPT_MARKER**: an empty store. `write_attempt_marker` writes ATTEMPT_M under - owner token `tok-aaaa`; the file at `attempts/tok-aaaa.json` holds ATTEMPT_M's - canonical bytes. -- **INSTALL_STAMP**: a store whose `HEAD` holds SNAP0's name and whose live tree the - installer materializes. After `install_to_head` the `INSTALLED` stamp holds - SNAP0's name followed by one newline. -- **RECOVER_ANCESTOR**: a store whose `HEAD` holds SNAP1's name, whose `INSTALLED` - stamp holds SNAP0's name, and which carries a commit marker keyed by SNAP0 (an - ancestor of SNAP1 by the spine). `recover` rolls the installed stamp forward to - SNAP1 and deletes the SNAP0 marker; after recovery the SNAP0 marker file is absent - and the `INSTALLED` stamp holds SNAP1's name followed by one newline. -- **RECOVER_FOREIGN**: a store whose `HEAD` holds SNAP0's name and which carries a - commit marker keyed by SNAP1 (not an ancestor of SNAP0), whose owner lock names a - host different from `this_host`. Because a foreign-host owner is never judged dead, - `recover` leaves the SNAP1 marker in place; after recovery the SNAP1 marker file is - still present. -- **JANITOR_TMP**: a store whose `tmp/` directory holds a scratch entry. After - `janitor` the `tmp/` directory holds no entries. -- **STAGE_INODE**: a store holding BLOB1 at its fan-out path. - `stage_object_payload` materializes BLOB1's payload into a staging tree; the staged - file and the store object file have distinct inode numbers. -- **CAS_LOCK_HELD**: a store whose `HEAD` holds SNAP0's name. - `compare_and_set_head_observed` with expected SNAP0 and next SNAP1 runs with an - observer that records whether `locks/HEAD.lock` exists when it is called. The observer - finds the lock present; after the call returns, `locks/HEAD.lock` is absent and the - `HEAD` file holds SNAP1's name followed by one newline. -- **CAS_SERIALIZED_LOSER**: a store whose `HEAD` holds SNAP0's name. Two - `compare_and_set_head` calls run one after the other, both with expected SNAP0 and - next SNAP1. The first succeeds and moves HEAD to SNAP1. The second re-reads HEAD under - the same HEAD lock, finds SNAP1 rather than the expected SNAP0, and returns - `StoreIoError::HeadConflict` carrying expected SNAP0's name and found SNAP1's name; - after both calls the `HEAD` file still holds SNAP1's name followed by one newline and - `locks/HEAD.lock` is absent. -- **STEAL_SUCCESS**: a store whose `locks/greeting.lock` holds a stale lock owned on - `this_host` whose pid the `is_alive` probe reports dead, with no janitor lock present. - `acquire_lock` for the name `greeting` on `this_host` with that probe steals the stale - lock: afterward the file `locks/greeting.lock.stolen` exists, the stale lock renamed - aside, and `locks/greeting.lock` exists, the freshly acquired lock. -- **STEAL_BLOCKED**: a store whose `locks/greeting.lock` holds the same stale dead-local - lock, but whose `locks/janitor.lock` is already held by an owner on a foreign host, - which is never stealable, so the janitor lock cannot be taken. `acquire_lock` for - `greeting` on `this_host` does not steal: `locks/greeting.lock.stolen` is absent, the - stale `locks/greeting.lock` still holds its original bytes, and the call returns - `StoreIoError::Io`. -- **COMMIT_MARKER_REPUBLISH_IDENTICAL**: a store already holding COMMIT_M at - `markers/1111111111111111111111111111111111111111111111111111111111111111.json`. - `write_commit_marker` writes COMMIT_M again; the call succeeds idempotently and the - file still holds COMMIT_M's canonical bytes, unchanged. -- **COMMIT_MARKER_CONFLICT**: a store already holding COMMIT_M at - `markers/1111111111111111111111111111111111111111111111111111111111111111.json`. - `write_commit_marker` of COMMIT_M2, a different commit marker keyed by the same - all-ones snapshot, returns `StoreIoError::Integrity` naming the all-ones snapshot and - replaces nothing: the file still holds COMMIT_M's original canonical bytes. -- **INSTALL_MISSING_MODULE_STATE**: a store whose `HEAD` holds a snapshot S, published - as a snapshot object, whose root names the greeting module with a module-state hash - absent from the store, and with no `INSTALLED` stamp. `install_to_head` reads S, finds - the named module-state object absent, and aborts with `StoreIoError::Integrity` before - any install step; afterward the `INSTALLED` stamp is absent and the live tree holds no - files. -- **INSTALL_CORRUPT_TREE**: a store whose `HEAD` holds a snapshot S, published as a - snapshot object, whose root names the greeting module with a valid module-state object - present, that module-state naming its generated tree under FOURS, the object at FOURS's - fan-out path holding CORRUPT_TREE's bytes, which do not verify to FOURS, and with no - `INSTALLED` stamp. `install_to_head` reads S and the module-state, reads the generated - tree object, finds it fails to verify against FOURS, and aborts with - `StoreIoError::Integrity` before any install step; afterward the `INSTALLED` stamp is - absent and the live tree holds no files. -- **RECOVER_GENESIS_PARENT**: a store holding SNAP0 and SNAP1 as objects, `HEAD` at - SNAP1, no `INSTALLED` stamp, and a commit marker keyed by THREES, never published as an - object, whose owner lock names a foreign host. `recover` computes ancestry for the - THREES marker: because the THREES candidate is itself unloadable the walk carries no - sequence floor and descends HEAD (SNAP1) to SNAP0 to the genesis sentinel parent, - dequeues that absent parent, and returns a clean non-ancestor answer; the foreign-host - owner is treated as live, so the marker is left. After recovery the THREES marker file - at `markers/3333333333333333333333333333333333333333333333333333333333333333.json` is - still present, `HEAD` still holds SNAP1's name, and no `INSTALLED` stamp exists. This is - the fixture that forces the genesis-parent dequeue: unlike RECOVER_FOREIGN, whose marker - is keyed by the published SNAP1 so the walk prunes at SNAP0 by sequence floor and never - reaches the boundary, this marker's key is unpublished, so the walk must descend the - full spine to the absent genesis parent. -- **JANITOR_DEAD_LOCAL_COLLECTS**: a store holding SNAP0 and SNAP1 as objects, `HEAD` at - SNAP0, and a commit marker keyed by SNAP1, not an ancestor of SNAP0 by the spine, whose - owner lock names `this_host` and whose pid the `is_alive` probe reports dead. `janitor` - on `this_host` with that probe judges the owner dead by the liveness decision and the - marker a non-ancestor, and collects it: after the janitor the SNAP1 marker file is - absent. -- **JANITOR_FOREIGN_LEAVES**: the same store shape but the owner lock names a foreign - host. `janitor` judges the foreign-host owner live, so it leaves the non-ancestor - marker: after the janitor the SNAP1 marker file is still present. - -The three payload-preflight fixtures share one shape and differ only in the failing -payload. Each store holds a valid generated tree for the greeting module naming -exactly two files in ascending name order, both at mode `33188`: `a.rs` whose blob is -BLOB1, which is published at BLOB1's fan-out path and is a healthy payload, and `b.rs` -whose blob is the failing reference the fixture names. The tree is published as a tree -object at its own name; a valid module state names that tree as its `rust` generated -tree and is published at its own name; a snapshot S names the greeting module with that -module state, is published as a snapshot object, `HEAD` holds S, and there is no -`INSTALLED` stamp. In install-step order `greeting/a.rs` sorts before `greeting/b.rs`, -so a healthy BLOB1 for `a.rs` is planned before the failing payload for `b.rs`; a -lazy installer that verified payloads only as it wrote them would materialize -`greeting/a.rs` and only then fail on `b.rs`. Because the installer preflights every -planned payload before writing any, all three fixtures leave the live tree with no -files and no `INSTALLED` stamp. - -- **INSTALL_PAYLOAD_ABSENT**: the `b.rs` entry names BLOB2's blob name, and BLOB2 is - never published, so its payload object is absent. `install_to_head` preflights the - planned payloads, finds BLOB2's object absent, and aborts with - `StoreIoError::Integrity` naming BLOB2's name before any step; afterward the - `INSTALLED` stamp is absent, the live tree holds no files, and in particular - `greeting/a.rs` was never written even though BLOB1 is present. -- **INSTALL_PAYLOAD_CORRUPT**: the `b.rs` entry names FOURS, and the object filed at - FOURS's fan-out path holds BLOB2's envelope bytes, which verify to BLOB2's name and - not to FOURS. `install_to_head` preflights the planned payloads, reads the object at - FOURS, finds it fails to verify against FOURS, and aborts with - `StoreIoError::Integrity` naming FOURS before any step; afterward the `INSTALLED` - stamp is absent, the live tree holds no files, and `greeting/a.rs` was never written. -- **INSTALL_PAYLOAD_WRONG_KIND**: the `b.rs` entry names SNAP0, and SNAP0 is published - as a snapshot object at its fan-out path, so the name resolves to an object that - verifies but whose kind is `snapshot`, not `blob`. `install_to_head` preflights the - planned payloads, reads SNAP0, finds it is not a `store_hash::ObjectKind::Blob`, and - aborts with `StoreIoError::Integrity` naming SNAP0's name before any step; afterward - the `INSTALLED` stamp is absent, the live tree holds no files, and `greeting/a.rs` - was never written. +The unit corpus feeds in-memory providers built entirely from the real upstream +functions, so no intermediate object name is transcribed: each fixture serializes its +objects through the upstream types and `store_hash`, indexes them by their real names, +and the provider answers `object_bytes` from that index and `head_snapshot_hash` from +the fixture's HEAD. A provider that answers over a fixed map of name to envelope bytes +plus one HEAD name is the standard in-memory `ObjectProvider` these tests use. -The dead-attempt surfacing and marker-cleanup fixtures reuse ATTEMPT_M and COMMIT_M -verbatim and add one owner-lock shape per liveness verdict. Every owner lock is a -`store_txn_core::LockFile` written at `locks/.lock` as the core's canonical -lock bytes, its pid judged only by the fixed `is_alive` predicate the case supplies, never -by a real process query; `this_host` is a fixed host identifier the case supplies: +A single prose module and target run through the corpus: module `greeting`, target +`rust`, prompt bytes exactly `The greeting module.\n`, and a one-file generated tree +whose only entry is `greeting.rs`. Each fixture builds the objects it needs with the +upstream builders and validators, publishes their envelope bytes into the provider +index, and sets HEAD. -- **SURFACE_DEAD**: a store holding ATTEMPT_M at `attempts/tok-aaaa.json` and an owner lock - at `locks/tok-aaaa.lock` naming `this_host` and a pid the `is_alive` probe reports dead. - `surface_attempt_markers(root, this_host, is_alive)` returns exactly one `SurfacedAttempt` - whose `owner_token` is `tok-aaaa`, whose `marker` is ATTEMPT_M (its `attempt_id` - `att-0001`), and whose `owner_live` is `false`. -- **SURFACE_LIVE**: the same store but the owner lock's pid the `is_alive` probe reports - alive on `this_host`. The one returned `SurfacedAttempt`'s `owner_live` is `true`. -- **SURFACE_FOREIGN**: the same store but the owner lock names a host different from - `this_host`. The one returned `SurfacedAttempt`'s `owner_live` is `true`, because a - foreign-host owner is never judged dead. -- **SURFACE_NO_LOCK**: a store holding ATTEMPT_M at `attempts/tok-aaaa.json` with no - `locks/tok-aaaa.lock` file at all. The one returned `SurfacedAttempt`'s `owner_live` is - `false`, an absent owner lock being a dead owner. -- **SURFACE_MALFORMED**: a store holding ATTEMPT_M at `attempts/tok-aaaa.json` and a - `locks/tok-aaaa.lock` whose bytes are not a parseable `store_txn_core::LockFile`. The one - returned `SurfacedAttempt`'s `owner_live` is `true`, a malformed lock being stale-eligible - yet never judged dead by this read. -- **SURFACE_EMPTY**: a store with no `attempts/` directory. `surface_attempt_markers` - returns an empty vector. -- **SURFACE_TWO**: a store holding ATTEMPT_M at `attempts/tok-aaaa.json` and a second - in-flight attempt marker (ATTEMPT_M's bytes) at `attempts/tok-bbbb.json`, each with a - dead-local owner lock at its own `locks/.lock`. `surface_attempt_markers` returns - two `SurfacedAttempt`s whose `owner_token`s are `tok-aaaa` then `tok-bbbb`, in that - ascending order. -- **SURFACE_CORRUPT**: a store whose `attempts/tok-aaaa.json` holds bytes that do not parse - into a `store_txn_core::AttemptMarker`. `surface_attempt_markers` returns - `StoreIoError::Corrupt` naming the owner token `tok-aaaa`, dropping no marker silently. -- **DELETE_ATTEMPT_PRESENT**: a store holding ATTEMPT_M at `attempts/tok-aaaa.json`. - `delete_attempt_marker(root, "tok-aaaa")` succeeds and the file at `attempts/tok-aaaa.json` - is afterward absent. -- **DELETE_ATTEMPT_ABSENT**: a store with no `attempts/tok-aaaa.json`. - `delete_attempt_marker(root, "tok-aaaa")` succeeds idempotently and the file at - `attempts/tok-aaaa.json` is afterward absent. -- **DELETE_COMMIT_PRESENT**: a store holding COMMIT_M at - `markers/1111111111111111111111111111111111111111111111111111111111111111.json`. - `delete_commit_marker(root, "1111111111111111111111111111111111111111111111111111111111111111")` - succeeds and that marker file is afterward absent. -- **DELETE_COMMIT_ABSENT**: a store holding no commit marker at the all-ones snapshot. - `delete_commit_marker(root, "1111111111111111111111111111111111111111111111111111111111111111")` - succeeds idempotently and that marker file is afterward absent. +- **RS_CLEAN**: HEAD is a snapshot at `seq` 1 whose root maps `greeting` to a head + module-state and a head event. The module-state's `promptBlob` is the blob of `The + greeting module.\n`, its `blockIndex` is that prompt's validated block index, and its + one target `rust` carries a `generatedTree` (the one-file tree) and a present + `attribution`. The head event is a `gen` event at `ordinal` 1 with outcome `ok`, its + `stateAfter` the head module-state name and `stateBefore` absent. The default + `Observed` for this fixture, named `matching`, carries `promptBlob` equal to the head + module-state's `promptBlob` and `generatedTree` equal to the target's `generatedTree`, + so it matches head exactly. The `prompt_mismatch` observation keeps the matching + `generatedTree` but sets `promptBlob` to a different well-formed hash; the + `tree_mismatch` observation keeps the matching `promptBlob` but sets `generatedTree` + to a different well-formed hash. +- **RS_UNATTRIB**: like RS_CLEAN but the target `rust` has no `attribution`, and the + `matching` observation still matches the prompt and tree. +- **RS_REWORD**: like RS_CLEAN but the head event is a `reword` event, and its parent in + the chain is the RS_CLEAN-style `gen` event; the observation matches head. +- **RS_INTERRUPTED**: like RS_CLEAN but the head event is a `gen` event with outcome + `interrupted` whose `stateBefore` and `stateAfter` are equal per the non-advancing + rule; the observation matches head. +- **RS_MOVED**: like RS_CLEAN but the head event is a `move` event whose parent is the + `gen` event; the observation matches head. +- **RS_BUNDLE**: HEAD names a head module-state for `greeting` and `rust` identical to a + baseline module-state also present in the provider, so a `bundle_since` whose baseline + is that baseline state name diffs a state against its equal and assembles the empty + no-op bundle. The head event references the baseline as `stateBefore` and the head + state as `stateAfter`. +- **RS_DIFF**: HEAD names a head module-state whose `promptBlob` and `blockIndex` are a + new prompt, and a head event whose `stateBefore` is a concrete baseline module-state + with an older prompt and whose `stateAfter` is the head module-state, so + `prompt_diff_of` over the head event returns a non-empty prompt-change list. +- **RS_HISTORY**: two snapshots and the module's two-event chain across them. The parent + snapshot at `seq` 1 maps `greeting` to a module-state whose `rust` target tree has + `greeting.rs` at one blob hash, and its root entry names a `gen` event at `ordinal` 1 + whose `stateAfter` is that older module-state and whose `stateBefore` is absent; the + head snapshot at `seq` 2, whose first parent is the `seq` 1 snapshot, maps `greeting` to + a module-state whose `rust` target tree has `greeting.rs` at a different blob hash, and + its root entry names a `gen` event at `ordinal` 2 whose first parent is the `ordinal` 1 + event, whose `stateBefore` is the older module-state, and whose `stateAfter` is the + newer, so the two events form one linear chain. The `ordinal` 1 event has no first + parent, the genesis boundary of the chain, and the `seq` 1 snapshot's own first parent + is absent from the provider. Walking `greeting`'s event chain from the head event yields + the `ordinal` 2 step (older tree to newer, a `Modified` of `greeting.rs`) then the + `ordinal` 1 step (nothing to older, an `Added` of `greeting.rs`), so `file_history` over + `greeting.rs` reports two entries head-first, the first `Modified`. +- **RS_LEGACY**: HEAD is a snapshot whose root maps `greeting` to a concrete head + module-state (migration seeds the head fully) and a head `gen` event with outcome `ok` + whose first parent is a `legacy` event carrying `unknown` for both `stateBefore` and + `stateAfter`. The observation matches head. The `legacy` event named by the tests is + that parent legacy event. +- **RS_CLEAN_MISSING_STATE**: the RS_CLEAN provider with the head module-state object + removed from the index, so resolving it is `MissingObject`. Its one mutation from + RS_CLEAN is that absent module-state object; the observation it hands `classify` is the + RS_CLEAN `matching` observation unchanged. +- **RS_CLEAN_CORRUPT_HEAD**: the RS_CLEAN provider whose HEAD name maps to bytes that do + not hash to that name, so verifying the head snapshot is `CorruptObject`. Its one + mutation from RS_CLEAN is those corrupted head bytes; the observation it hands + `classify` is the RS_CLEAN `matching` observation unchanged. +- **RS_CLEAN_MISSING_EVENT**: the RS_CLEAN provider with the head event object removed + from the index, so its head module-state resolves but resolving its head event is + `MissingObject`. The head snapshot's `greeting` root entry still names that event by + its real nonzero object name, which is not the genesis sentinel, so its `event_head` + is a valid nonzero name absent from the provider rather than the all-zeros boundary. + Its one mutation from RS_CLEAN is that absent event object; the observation it hands + `classify` is the RS_CLEAN `matching` observation unchanged. This fixture drives every + event-head query loud: `classify`, `event_graph`, and `event_by_ordinal` at ordinal + one each raise `MissingObject`, the exact failing contrast to RS_CURRENT_ONLY's + graceful genesis sentinel. +- **RS_CURRENT_ONLY**: a current-state-only store, exactly the shape migration writes for + an emptied-mapl module. HEAD is a snapshot at `seq` 1 whose root maps `greeting` to a + head module-state identical in shape to RS_CLEAN's (the same `promptBlob`, `blockIndex`, + and one `rust` target with a `generatedTree` and a present `attribution`) and, as its + `eventHead`, the genesis sentinel, the all-zeros object name of sixty-four `0` + characters. The module-state object is published into the provider index, but no event + object is: the sentinel is never published and is absent from the provider by + construction. HEAD is the snapshot's name. The snapshot's own first parent is that same + absent genesis sentinel. The `matching` observation is RS_CLEAN's, carrying the head + module-state's `promptBlob` and the target's `generatedTree`, though `classify` returns + before consulting it. This fixture drives every event-head query: `classify` returns + `CurrentStateOnly`, `event_graph` (and through it the private module event-chain walk) + returns an empty node list, and `event_by_ordinal` at ordinal 1 returns `Ok(None)` by + skipping the historyless root. No object name in this fixture is transcribed; the + genesis sentinel is the literal all-zeros string. ## Test and code discipline -The frontmatter test suite is a contract, not a sketch. Each case in it is realized -by its own dedicated test function that exercises only that case, so the number of +The frontmatter test suite is a contract, not a sketch. Each case in it is realized by +its own dedicated test function that exercises only that case, so the number of dedicated tests is at least the number of frontmatter cases. Never fold two or more cases into one shared test, and a single test that loops over a table of inputs never counts as the dedicated test any of those inputs requires: such a combined test may -exist only as an extra. Cases that differ only in their input or in the property -asserted are still distinct cases, each owed its own dedicated test. +exist only as an extra. Cases that differ only in their input while sharing a shape are +still distinct cases, each owed its own dedicated test. In particular, each of the nine +`Classification` wire-string cases gets its own dedicated test (`clean`, +`never-generated`, `prompt-stale`, `drifted`, `unattributed`, `reworded`, +`interrupted`, `moved`, `current-state-only`), and each of the `classify` behavior cases +gets its own +dedicated test even where several share a fixture: the clean, the module-absent +never-generated, the target-absent never-generated, prompt-stale, drifted, unattributed, +reworded, interrupted, and moved classify cases are nine separate tests, the +legacy-exact classify case is a tenth `classify` test distinct from the RS_CLEAN clean +case, and the current-state-only classify case over RS_CURRENT_ONLY is an eleventh, +asserting `Classification::CurrentStateOnly` and never weakened into `NeverGenerated`. +The current-state-only downstream cases each get their own dedicated test: the +`event_graph` over RS_CURRENT_ONLY asserting an empty node list (proving the private +module event-chain walk returns the empty chain at the genesis boundary rather than +raising `MissingObject`), and the `event_by_ordinal` at ordinal one over RS_CURRENT_ONLY +asserting `Ok(None)` (proving the historyless root is skipped, not loaded). Neither +discharges the other nor the classify case. The nonzero-absent-event-head cases over +RS_CLEAN_MISSING_EVENT are three separate dedicated tests, one each for `classify`, +`event_graph`, and `event_by_ordinal` at ordinal one, every one asserting +`ReduceError::MissingObject`. They pin that an `event_head` which is any nonzero name +absent from the provider stays a loud `MissingObject` across all three query paths, the +value-refinement's failing half that RS_CURRENT_ONLY's genesis sentinel never reaches; +none discharges another, and none is discharged by the RS_CLEAN_MISSING_STATE case, +whose absent object is the module-state rather than the event. The never-generated classify case whose condition is `module: absent` realizes it +as a module name the head snapshot's root does not contain, the same way the +`prompt_bytes_at_head` absent case does, and never as a target absent from a present +module's `targets` map; it hands `classify` the RS_CLEAN provider, that absent module +name, the target `rust`, and the RS_CLEAN `matching` observation, and asserts +`NeverGenerated`. The never-generated classify case whose condition is `target: absent` +is the distinct target-absent sub-branch: it hands `classify` the RS_CLEAN provider, the +real module `greeting`, a target name the RS_CLEAN head module-state's `targets` map does +not contain (RS_CLEAN carries only the target `rust`, so `typescript` is provably +absent), and the RS_CLEAN `matching` observation, and asserts `NeverGenerated`. This case +pins rule one's clause for a present module-state with no entry for the requested target, +exercising the branch the module-absent case cannot reach; the two never-generated +classify cases are distinct and neither discharges the other. Likewise every case that +expects a `ReduceError` +(`MissingObject`, `CorruptObject`, `UnsupportedForLegacy`) gets its own dedicated test, +every `is_none` case gets its own dedicated test, and every byte-pinned case gets its +own dedicated test. -Enumerate the case families by name, each owed its own dedicated verbatim test. The -provider read cases: the present-blob read READ_BLOB, the present-snapshot read -READ_SNAPSHOT, the absent-name read READ_ABSENT, the present-head read -READ_HEAD_PRESENT, the absent-head read READ_HEAD_ABSENT, and the in-memory parity -case PARITY, each its own test. The publication cases: the new-object name case and -the new-object bytes case, both over PUBLISH_NEW but asserting different observables -and therefore two distinct tests; the idempotent PUBLISH_IDEMPOTENT case; and the -integrity PUBLISH_MISMATCH case. The HEAD cases: the SET_HEAD bytes case, the -CAS_SUCCESS bytes case, the CAS_CONFLICT error case and the CAS_CONFLICT -head-unchanged case, the last two over the same fixture but asserting the error and -the untouched HEAD respectively and therefore two distinct tests. The marker cases: -the COMMIT_MARKER bytes case and the COMMIT_MARKER path case, and the ATTEMPT_MARKER -bytes case and the ATTEMPT_MARKER path case, each its own test. The installer stamp -case INSTALL_STAMP. The recovery cases: the RECOVER_ANCESTOR marker-deleted case and -the RECOVER_ANCESTOR installed-advanced case, over the same fixture but asserting -different observables and therefore two distinct tests, and the RECOVER_FOREIGN -leave case. The janitor case JANITOR_TMP. The staging inode case STAGE_INODE. +Inside a case's dedicated test, hand the code exactly the fixture that case names, and +assert its pinned expectation verbatim: no shorter or more convenient stand-in, no +re-derivation of a value the case pins as a literal, and no weakening of a pinned +equality into an `is_ok`, an inequality-only, or a length-only check where the case +pins a value. The byte-pinned cases assert their exact literals. The +`prompt_bytes_at_head` case over RS_CLEAN asserts the returned bytes equal exactly `The +greeting module.\n`, the whole literal including its trailing newline, never a prefix +and never a recomputed hash of it. The no-op bundle case hands `bundle_since` the +RS_BUNDLE fixture and that fixture's present baseline state, which RS_BUNDLE builds equal +to head, and asserts `mode` `diff` and that `store_diff::bundle_blob_name` of the returned +bundle equals exactly +`6dc9ca1a0fe393bbed56cce83d42aadded789fac58c3f5ef22d2c13bf25d1bdc`, the verified empty +bundle blob name, obtained from `bundle_blob_name` and compared to the literal. The +full-mode case hands `bundle_since` the RS_BUNDLE fixture with baseline `None`, never +RS_CLEAN nor any lighter fixture in place of RS_BUNDLE, and asserts `mode` `full` and +that the `BundleQuery` value serializes through `store_hash::canonical_json` to exactly +`{"mode":"full"}` plus one trailing newline, obtained as the canonical JSON of the +serialized value and compared to the literal. Both `bundle_since` mode cases pin RS_BUNDLE +as their fixture and neither substitutes RS_CLEAN or any lighter fixture for it. Each `Classification` wire-string case +obtains the token by serializing the variant with `serde_json` and asserts it equals the +pinned quoted string exactly, `"clean"` for `Clean`, `"never-generated"` for +`NeverGenerated`, `"prompt-stale"` for `PromptStale`, `"drifted"` for `Drifted`, +`"unattributed"` for `Unattributed`, `"reworded"` for `Reworded`, `"interrupted"` for +`Interrupted`, and `"moved"` for `Moved`. -The design-fix cases, each owed its own dedicated verbatim test. The HEAD-lock cases: -CAS_LOCK_HELD, whose observer sees `locks/HEAD.lock` present during the critical section -and which additionally asserts the lock file absent after return and the `HEAD` file -holding SNAP1's name; and CAS_SERIALIZED_LOSER, whose second serialized compare-and-set -returns `StoreIoError::HeadConflict` and which additionally asserts the conflict carries -expected SNAP0's name and found SNAP1's name, the `HEAD` file still holding SNAP1's name, -and `locks/HEAD.lock` absent. The lock-steal cases: STEAL_SUCCESS, which asserts the -renamed-aside `locks/greeting.lock.stolen` present and additionally the fresh -`locks/greeting.lock` present; and STEAL_BLOCKED, which asserts -`locks/greeting.lock.stolen` absent and additionally the stale lock's original bytes -unchanged and a `StoreIoError::Io` return. The commit-marker no-replace cases: -COMMIT_MARKER_REPUBLISH_IDENTICAL, which asserts the marker file still holds COMMIT_M's -canonical bytes after an idempotent rewrite; and COMMIT_MARKER_CONFLICT, which asserts -`StoreIoError::Integrity` naming the all-ones snapshot and additionally that the marker -file still holds COMMIT_M's original bytes, proving the conflicting COMMIT_M2 replaced -nothing. The installer-abort cases: INSTALL_MISSING_MODULE_STATE and INSTALL_CORRUPT_TREE, -each asserting `StoreIoError::Integrity` and additionally that the `INSTALLED` stamp is -absent and the live tree holds no files, proving no step ran and no stamp was written. The -genesis-boundary recovery case RECOVER_GENESIS_PARENT, which asserts the THREES marker -still present and additionally that `HEAD` still holds SNAP1's name and no `INSTALLED` -stamp exists, and whose fixture keys its marker by an unpublished snapshot so the walk -actually dequeues the genesis parent, distinct from RECOVER_FOREIGN, which prunes at SNAP0 -and never reaches that boundary. The janitor liveness cases: JANITOR_DEAD_LOCAL_COLLECTS, -which asserts the SNAP1 marker absent after a dead-local-owner collection; and -JANITOR_FOREIGN_LEAVES, which asserts the SNAP1 marker present after a foreign-host-owner -leave. Each of these eleven is a distinct dedicated test, and none discharges another. +Where a case asserts a query result over a fixture, it builds that fixture's provider +from the real upstream functions and asserts the query's own pinned outcome. The +`event_by_ordinal` found case asserts the returned event's `ordinal` equals `1` and its +payload is the `gen` variant; the missing case asserts `event_by_ordinal` at ordinal `2` +is `None`, never conflating the two. The `file_history` change case asserts the returned +vector has length `2`, that its first entry's `change` is `Modified` and its `path` is +`greeting.rs`, in that head-first order; the empty case asserts `file_history` over +`missing.rs` is an empty vector, and neither discharges the other. The `move_records` +case asserts one collected event whose payload is the `Move` variant, and the +no-move case asserts an empty vector; the `reword_chain` case asserts one collected event +whose payload is the `Reword` variant, and the no-reword case asserts an empty vector; +`move_records` and `reword_chain` cases never discharge one another and the no-op cases +never substitute for the populated ones. The `event_graph` concrete case asserts one node +whose `materialized` is `true`; the legacy case asserts two nodes, the head node +`materialized` `true` and the legacy node `materialized` `false`, proving the partial +legacy capability, and it never collapses into the concrete case. The `prompt_diff_of` +concrete case asserts the returned `changes` equals `store_diff::diff_prompt` over the +same old and new indexes and prompts the fixture built, element for element in order, and +that `legacy_partial` is `false`; the legacy case asserts `legacy_partial` is `true` and +`changes` is empty, and neither discharges the other. The `drift_baseline` case asserts +the returned `generated_tree` equals the head target's generated tree root the fixture +built and that the returned `tree` equals that same validated tree value; the `tree_at` +present case asserts the returned tree's single entry name is `greeting.rs`, and the +absent case realizes its `module: absent` condition as a module name the head snapshot's +root does not contain, exactly as the never-generated classify case does and never as a +target absent from a present module, hands `tree_at` the RS_CLEAN provider and that absent +module name with target `rust`, and asserts `None`. -The payload-preflight cases, each owed its own dedicated verbatim test: -INSTALL_PAYLOAD_ABSENT, which asserts `StoreIoError::Integrity` naming BLOB2's name and -additionally that the `INSTALLED` stamp is absent and the live tree holds no files, so -the healthy BLOB1 for `greeting/a.rs` that sorts before the absent `b.rs` payload was -never written; INSTALL_PAYLOAD_CORRUPT, which asserts `StoreIoError::Integrity` naming -FOURS and additionally the `INSTALLED` stamp absent and the live tree holding no files; -and INSTALL_PAYLOAD_WRONG_KIND, which asserts `StoreIoError::Integrity` naming SNAP0's -name and additionally the `INSTALLED` stamp absent and the live tree holding no files, -proving the installer rejects a payload whose object verifies but is a snapshot rather -than a `store_hash::ObjectKind::Blob`. Each of these three is a distinct dedicated -test, each builds the two-file greeting tree its fixture names with a healthy BLOB1 -payload sorted before the failing payload, and none discharges another. The -live-tree-holds-no-files assertion is the partial-write trap: it fails for any -installer that materializes the earlier healthy payload before verifying the later -failing one. +The five `state_view` cases are five separate dedicated tests, none discharging another. +The present-head case hands `state_view` the RS_CLEAN provider, the RS_CLEAN head event's +`state_after` (the head module-state name, obtained from the fixture's own head event or +root entry, never transcribed as a literal name), and target `rust`, and asserts +`legacy_partial` is `false`, that `prompt` equals exactly `The greeting module.\n` (the +whole literal including its trailing newline, never a prefix and never a recomputed hash), +that `generated_tree` equals the head target's generated tree root the fixture built, and +that the single `tree_objects` entry resolved at that root names `greeting.rs`. The +absent-state case hands `state_view` the RS_CLEAN provider, `None` for the state, and target +`rust`, and asserts `legacy_partial` `false`, `prompt` the empty string, `prompt_blob` +`None`, `generated_tree` `None`, and `tree_objects` empty. The unknown-state case hands +`state_view` the RS_CLEAN provider, `Some(store_objects::StateRef::Unknown)`, and target +`rust`, and asserts `legacy_partial` `true`, `prompt` empty, `prompt_blob` `None`, +`generated_tree` `None`, and `tree_objects` empty, proving the sentinel short-circuits +before any provider read exactly as the `prompt_diff_of` legacy case does. The +target-absent case hands `state_view` the RS_CLEAN provider, the head state, and target +`typescript` (which the RS_CLEAN head module-state's `targets` map does not contain), and +asserts `prompt` still equals `The greeting module.\n`, `prompt_blob` `Some`, +`generated_tree` `None`, and `tree_objects` empty, pinning the present-state-but-target-absent +branch distinct from the absent-state branch. The missing-state-object case hands +`state_view` the RS_CLEAN_MISSING_STATE provider, the head state name (now absent from the +index), and target `rust`, and asserts `ReduceError::MissingObject`, never a silent empty +view. The `prompt` value is asserted as its full literal, never re-derived; the +`legacy_partial` boolean is never swapped between the concrete and the unknown cases; and +the missing-state error is never weakened into an empty `StateView`. -Inside a case's dedicated test, build exactly the store the case names, byte for -byte, perform exactly the one operation the case names, and assert its pinned -observable verbatim: no shorter or more convenient stand-in, no re-derivation of a -value the case pins as a literal, and no weakening of a pinned equality into an -`is_ok`, inequality-only, or recomputed check. Where a case pins envelope bytes, read -the bytes back from the store file or obtain them from `object_bytes` and compare to -the full literal, never a prefix. Where a case pins an object name, obtain it from -`publish_object`'s return or from `store_hash::object_name` over the case's payload -and compare to the literal. Where a case pins a HEAD, INSTALLED, marker, or attempt -file, read that exact file's bytes and compare to the full literal including its -single trailing newline. Where a case pins a path, assert the file exists at exactly -that path relative to the store root. +The event-graph node round-trip case serializes one `EventNode`, +recovers it through `serde_json`, and asserts the recovered node equals the original +verbatim, never weakening that equality into an `is_ok` acceptance. -The byte-pinned literals, each transcribed verbatim and never paraphrased: BLOB1's -envelope `napl-store blob v1 21` then newline then `The greeting module.` then -newline; SNAP0's envelope `napl-store snapshot v1 99` then newline then -`{"parents":["9999999999999999999999999999999999999999999999999999999999999999"],"root":{},"seq":0}` then newline; BLOB1's name -`0965493c60ab6b118535fc59c5a793c753295acd9fd8d75e084288459624fe93`; SNAP0's name and -HEAD line `b7281259c1fa6f5b9fa3b295d6ee09705ddb02a3387bdd111888d697b63a4242`; SNAP1's -name and HEAD line `289f591aec3ebd915526648ad486d6b028dca5200e9c392b7fdaf4c498f6ec09`; -COMMIT_M's canonical bytes and ATTEMPT_M's canonical bytes as given above; the marker -path `markers/1111111111111111111111111111111111111111111111111111111111111111.json`; -and the attempt path `attempts/tok-aaaa.json`. +The legacy-capability trio is three distinct dedicated tests that must not be collapsed: +the exact case asserts `classify` over RS_LEGACY is `Clean`, proving a legacy event in the +chain does not taint an exact head classification; the partial case is the `event_graph` +legacy case asserting the legacy node's `materialized` is `false`; the unsupported case +hands `bundle_since` the RS_BUNDLE fixture with baseline +`Some(store_objects::StateRef::Unknown)`, never RS_LEGACY nor any other fixture in place +of RS_BUNDLE, and asserts `Err(ReduceError::UnsupportedForLegacy)`. The `unknown` sentinel +short-circuits before any provider read, yet the case still pins RS_BUNDLE and the test +hands it exactly that fixture with the `Unknown` baseline. +Each names the capability it proves and none stands in for another. -Named-substitution prohibitions for this module's confusable tokens are strict: never -read a HEAD literal where a case pins an INSTALLED literal or the reverse, never write -SNAP0's name where a case pins SNAP1's name or the reverse, never assert BLOB1's name -where a case pins BLOB2's name, never key a marker under `attempts/` where a case pins -`markers/` or the reverse, never write the owner token as anything but the literal -`tok-aaaa`, never substitute `object_bytes` where a case pins `head_snapshot_hash`, -and never assert a prefix or a length where a case pins full bytes. The two providers -in PARITY are compared for full equality on every queried name and on the head, never -by a subset or a spot check. The PUBLISH_NEW name case asserts only the returned name -and the PUBLISH_NEW bytes case asserts only the stored bytes, so neither discharges -the other; likewise the CAS_CONFLICT error case and the CAS_CONFLICT head-unchanged -case are separate, and the RECOVER_ANCESTOR marker-deleted case and its -installed-advanced case are separate. +The error cases construct exactly the fixture each describes and assert the pinned error +variant. The missing-object case runs `classify` over RS_CLEAN_MISSING_STATE, whose sole +mutation from RS_CLEAN is that the head module-state object is absent from the provider, +and hands `classify` the RS_CLEAN `matching` observation, the same observation the clean +case uses, never a bespoke observation with mutated `promptBlob` or `generatedTree` +hashes; it asserts `ReduceError::MissingObject`. The corrupt-object case runs `classify` +over RS_CLEAN_CORRUPT_HEAD, whose sole mutation from RS_CLEAN is that the HEAD name maps to +bytes that do not verify, and likewise hands `classify` the RS_CLEAN `matching` +observation, never a bespoke observation with mutated hashes; it asserts +`ReduceError::CorruptObject`. The +unsupported-for-legacy case asserts `ReduceError::UnsupportedForLegacy` as above. The three +error variants are never interchanged: a test that pins `MissingObject` never accepts +`CorruptObject`, and neither ever accepts `UnsupportedForLegacy`. -The error cases assert their exact variant and never weaken it: the PUBLISH_MISMATCH -case asserts `StoreIoError::Integrity` and additionally that the existing object's -bytes are unchanged, proving the mismatch replaced nothing; the CAS_CONFLICT case -asserts `StoreIoError::HeadConflict` and, in its sibling case, that HEAD still holds -SNAP0's name. Neither error case is discharged by a mere `is_err`. +The named-substitution prohibitions for this module's confusable tokens are strict. Never +write `clean` where a case pins `never-generated`, nor `drifted` where a case pins +`prompt-stale`, nor `unattributed` where a case pins `drifted`, nor `reworded` where a +case pins `moved`, nor `interrupted` where a case pins `reworded`, and never any other +substitution among the eight classification tokens; each is transcribed exactly as its +case names it. Never write `full` where a case pins `diff` or the reverse. Never confuse +`promptBlob` with `blockIndex`, `moduleState` with `eventHead`, `generatedTree` with +`attribution`, `stateBefore` with `stateAfter`, `ownedOld` with `ownedNew`, `oldHash` with +`newHash`, or `object_bytes` with `head_snapshot_hash`; each field, method, and serde name +is written exactly as this prompt names it. Never assert `materialized` `true` where a case +pins `false` or the reverse, and never swap the `MissingObject`, `CorruptObject`, and +`UnsupportedForLegacy` variants. The `prompt_mismatch` and `tree_mismatch` observations are +distinct: the prompt-stale case mutates only `promptBlob` and the drifted case mutates only +`generatedTree`, and neither reuses the other's mutation. Closing mandate on one-to-one case fidelity. Every case in the frontmatter test suite -above, without exception, is realized by exactly one dedicated test that builds that -case's own store, performs that case's own operation, and asserts that case's own -pinned observable, neither narrower nor recomputed. A test that inspects a different -or narrower property than its case declares, that swaps in a lighter or neighboring -fixture, that weakens a pinned equality into an `is_ok` or inequality-only check, or -that reuses a sibling case's values, does not discharge the case and leaves it -untested. Two cases that share a fixture but assert different observables are two -separate cases, each owed its own verbatim test: the two PUBLISH_NEW cases never -discharge one another, the two CAS_CONFLICT cases never discharge one another, the two -COMMIT_MARKER cases never discharge one another, the two ATTEMPT_MARKER cases never -discharge one another, and the two RECOVER_ANCESTOR cases never discharge one another. -Supplemental tests beyond the suite are named for what they add and are never counted -as, nor substituted for, any suite case's dedicated test. The number of dedicated -tests is therefore at least the number of frontmatter cases, and each case above is -discharged by its own verbatim test. - -The dead-attempt surfacing and marker-cleanup cases, each owed its own dedicated verbatim -test. The surfacing liveness cases SURFACE_DEAD, SURFACE_LIVE, SURFACE_FOREIGN, -SURFACE_NO_LOCK, and SURFACE_MALFORMED each build their one attempt marker and owner-lock -shape, run `surface_attempt_markers`, and assert the one returned `SurfacedAttempt`'s -`owner_live` verdict exactly, and SURFACE_DEAD additionally asserts the returned -`owner_token` is `tok-aaaa` and the returned `marker`'s `attempt_id` is `att-0001`, proving -the parsed record is carried back whole rather than a bool alone; none of these five -discharges another, each pins a distinct liveness verdict, and no verdict is weakened into -an `is_ok` or a length-only check. SURFACE_EMPTY asserts the returned vector is empty over a -store with no `attempts/` directory. SURFACE_TWO asserts the two returned `owner_token`s are -`tok-aaaa` then `tok-bbbb` in that order, proving the ascending owner-token order, and never -a set-membership check that would pass on either order. SURFACE_CORRUPT asserts -`StoreIoError::Corrupt` naming `tok-aaaa` and never weakens it into an `is_err` and never -lets the corrupt marker be silently dropped from the list. The delete cases -DELETE_ATTEMPT_PRESENT and DELETE_ATTEMPT_ABSENT each assert the attempt file at -`attempts/tok-aaaa.json` is absent after the delete, the present case proving the file is -removed and the absent case proving the idempotent success returns `Ok(())` over an already -absent file; DELETE_COMMIT_PRESENT and DELETE_COMMIT_ABSENT each assert the commit marker -file at the all-ones snapshot path is absent after the delete, the present case proving -removal and the absent case proving idempotent success. Each of these twelve is a distinct -dedicated test, and none discharges another; `surface_attempt_markers` writes and deletes -nothing, so a surfacing case that finds a marker removed or a lock rewritten does not -discharge its case and fails. +above, without exception, is realized by exactly one dedicated test that hands the code +that case's own fixture with its literal values copied verbatim from the case, and asserts +that case's own pinned expectation, neither narrower nor recomputed. A test that inspects a +different or narrower property than its case declares, that swaps in a lighter or +neighboring fixture, that weakens a pinned equality into an `is_ok`, inequality-only, or +length-only check where a value is pinned, or that reuses a sibling case's values, does not +discharge the case and leaves it untested. Two cases that share a shape but differ in a +fixture value or in the property asserted are two separate cases, each owed its own verbatim +test: the eight classification-token cases never discharge one another, the nine classify +behavior cases never discharge one another, the two never-generated classify cases (the +module-absent case and the target-absent case) never discharge one another, the two +`bundle_since` mode cases never discharge +the unsupported case, the two `file_history` cases never discharge one another, the two +`event_by_ordinal` cases never discharge one another, the two `prompt_diff_of` cases never +discharge one another, the two `tree_at` cases never discharge one another, the two +`move_records` cases never discharge one another, the two `reword_chain` cases never +discharge one another, the two `event_graph` cases never discharge one another, the five +`state_view` cases never discharge one another, and the +three legacy-capability cases never discharge one another. Supplemental tests beyond the +suite are named for what they add and are never counted as, nor substituted for, any suite +case's dedicated test. The number of dedicated tests is therefore at least the number of +frontmatter cases, and each case above is discharged by its own verbatim test. -Beyond the suite, one dedicated supplemental test per `StoreIoError` variant asserts that -variant's exact `Display` line verbatim through `to_string()`: an `Io("disk full")` -renders `store filesystem error: disk full`, an `Integrity("abc")` renders `store object -failed its integrity check: abc`, a `Corrupt("tok-aaaa")` renders `corrupt store marker: -tok-aaaa`, and a `HeadConflict { expected: Some("aaa"), found: Some("bbb") }` renders -`head compare-and-set conflict: expected aaa, found bbb` while the same test also asserts a -`HeadConflict { expected: None, found: None }` renders `head compare-and-set conflict: -expected none, found none`, pinning both the present and absent arms of its two fields. -Each of these four is its own dedicated test asserting only its own variant's line, and +Beyond the suite, one dedicated supplemental test per `ReduceError` variant asserts that +variant's exact `Display` line verbatim through `to_string()`: a `CorruptObject("abc")` +renders `corrupt store object abc`, a `MissingObject("abc")` renders `missing store +object abc`, and `UnsupportedForLegacy` renders `query unsupported across a legacy state +boundary`. Each of these three is its own dedicated test asserting only its own line, and none replaces or subsumes a suite case. -Write no explanatory line or block comments in the generated code, and no doc comment -on any private item. The crate keeps its module-level `//!` summary. Every public -type, every public enum variant, and every public function carries a `///` doc -comment restating its contract; a public struct field carries a `///` doc comment -only where its name alone does not convey the contract, and simply named fields may -carry none. The private helper functions and private constants of this module carry -no doc comment. Beyond the module summary and these public-item doc comments the code -carries no commentary of any kind. Use no em dashes anywhere in the generated code, -including inside string literals and doc comments. +Write no explanatory line or block comments in the generated code, and no doc comment on +any private item. The crate keeps its module-level `//!` summary. Every public type, every +public trait, every public trait method, every public enum variant, and every public +function carries a `///` doc comment restating its contract; a public struct field carries a +`///` doc comment only where its name alone does not convey the contract, and simply named +fields may carry none. The private helper functions and private constants of this module +carry no doc comment. Beyond the module summary and these public-item doc comments the code +carries no commentary of any kind, and no doc comment contains a doctest code block. Use no +em dashes anywhere in the generated code, including inside string literals and doc comments. -## store_diff -# Store diff: hierarchical prompt alignment, attribution ownership, and the LLM bundle +## store_txn_core -This module defines the pure diff engine of the NAPL content-addressed store: the -structural comparison of two module-state prompt sides that produces the compact, -precise change bundle an agent, the reword judge, and the audit consumer read. It -does three things, all pure, no filesystem, no I/O, no clocks, no randomness: +# Store transaction core: markers, locks, recovery, and install planning as a pure decision core -- **Hierarchical prompt alignment**: given two block indexes and their prompt - bytes, it aligns atoms first, then sub-spans inside a corresponding prose atom - pair, and reports the changed occurrences at both levels with their old and new - occurrence identity, byte-derived text, and line spans. -- **Attribution ownership resolution**: it maps occurrences to the line-based - attribution document by span intersection, deliberately coarsening to the - enclosing atom when a line range cannot uniquely select one sentence. -- **Bundle assembly**: it combines the prompt changes, the generated-tree diff, - and the two ownership sides into one bundle that labels old ownership, new - ownership, and unowned code changes distinctly, and renders that bundle to the - canonical blob bytes the store references. +This module is the pure decision core of the NAPL store's transaction protocol. It +owns every marker and lock record type and every policy decision the on-disk shell +needs, and it does all of this without touching a filesystem, a clock, or a source +of randomness. The shell (`store_io`) performs the syscalls and observes facts; +this module turns those facts into decisions and owns the exact bytes of the records +the shell writes. It is total, deterministic, and pure relative to its inputs, so +every rule below is a conformance-pinned function, not a described intention. It +covers four areas: -Bring in `serde` and `serde_json`. +- **The record types**: the commit marker, the attempt marker, the lock file, and + the ordered install step, each with a canonical serialization the shell writes + byte for byte and a strict parse the shell reads back. +- **The recovery decision table**: classifying a commit marker against HEAD, and + converting a durable attempt marker into an interrupted event exactly once. +- **The complete lock lifecycle as a decision core**: exclusive-creation acquisition, + parsing a lock file (with malformed treatment), token-checked release, same-host + liveness, and rename-aside steal serialized under the janitor lock. +- **Install-step planning and path classification**: turning a generated-tree diff + into the complete ordered, stamp-last install plan (domain writes, then snapshot-wide + views, then permission changes, then deletions), and classifying any path into + exactly one of three authority classes as a total function. + +Bring in `serde`, `serde_json`, and use `store_hash` for canonical JSON so the +record bytes match every other object kind's canonical form. ## Where this code lives -The working directory is a Cargo workspace whose root manifest is written and -owned by the toolchain, leave it alone. Create this module as its own member crate -in a subdirectory named `store_diff/`: `store_diff/Cargo.toml` (package name -`store_diff`) and `store_diff/src/lib.rs`. Touch nothing outside `store_diff/`. -Ensure `cargo test` passes from the workspace root before finishing. +The working directory is a Cargo workspace whose root manifest is written and owned +by the toolchain, leave it alone. Create this module as its own member crate in a +subdirectory named `store_txn_core/`: `store_txn_core/Cargo.toml` (package name +`store_txn_core`) and `store_txn_core/src/lib.rs`. Touch nothing outside +`store_txn_core/`. Ensure `cargo test` passes from the workspace root before +finishing. -## Builds on three modules of this workspace +## Builds on two modules of this workspace -This crate composes on three generated sibling crates. Use each one's public API, -do not reimplement its types or logic, and do not depend on any hand-written crate. -Depend on exactly these three, and do not bring in the `sha2` crate directly. +This crate composes on two generated sibling crates. Use each one's public API, do +not reimplement its types or logic, and do not depend on any hand-written crate. +Depend on exactly these two, and do not bring in the `sha2` crate directly. -- **`store_blocks`** (`../store_blocks`) supplies the block-index types this - module aligns. It uses `store_blocks::BlockIndex` with its public fields - `seg_version: u32`, `atoms: Vec`, and `sub_spans: - Vec`; `store_blocks::AtomRecord` with public fields - `atom_index: u32`, `content_hash: String`, `byte_start: u32`, `byte_end: u32`, - `line_start: u32`, `line_end: u32`; and `store_blocks::SubSpanRecord` with public - fields `parent_atom_index: u32`, `sub_index: u32`, `content_hash: String`, - `byte_start: u32`, `byte_end: u32`, `line_start: u32`, `line_end: u32`. This - module treats every `content_hash` as an opaque occurrence token compared only - for equality; it never recomputes a content hash and so does not depend on the - `hash` crate. -- **`store_tree`** (`../store_tree`) supplies the generated-tree diff for the code - side of the bundle. It uses `store_tree::Tree`, - `store_tree::diff_trees(old_root: Option<&str>, new_root: Option<&str>, objects: - &std::collections::BTreeMap) -> - Result, store_tree::StoreTreeError>`, and the - `store_tree::TreeChange` enum with its three variants `TreeChange::Added { path: - String, hash: String, mode: u32 }`, `TreeChange::Removed { path: String, hash: - String, mode: u32 }`, and `TreeChange::Modified { path: String, old_hash: - String, old_mode: u32, new_hash: String, new_mode: u32 }`. This module calls - `diff_trees` to obtain the code-side changes and never reimplements tree diffing. -- **`store_hash`** (`../store_hash`) supplies the blob framing for the rendered - bundle. It uses `store_hash::ObjectKind` (the variant `ObjectKind::Blob`), - `store_hash::canonical_json(value: &serde_json::Value) -> Vec`, and - `store_hash::object_name(kind: store_hash::ObjectKind, payload: &[u8]) -> - String`. The bundle is a `blob`: its bytes are the canonical JSON of the - serialized bundle, and its name is the blob object name over those bytes. This - module never reimplements canonical JSON or hashing. +- **`store_hash`** (`../store_hash`) supplies the canonical JSON encoding. It uses + `store_hash::canonical_json(value: &serde_json::Value) -> Vec` to serialize + every record type: a record's canonical bytes are the canonical JSON of its + serialized value, sorted keys, compact, one trailing newline. This module never + reimplements canonical JSON. +- **`store_tree`** (`../store_tree`) supplies the generated-tree diff this module + plans installs from. It uses `store_tree::TreeChange` with its three variants + `TreeChange::Added { path: String, hash: String, mode: u32 }`, `TreeChange::Removed + { path: String, hash: String, mode: u32 }`, and `TreeChange::Modified { path: + String, old_hash: String, old_mode: u32, new_hash: String, new_mode: u32 }`. This + module consumes a slice of tree changes and never reimplements tree diffing. -This module does not depend on `store_objects`. It diffs already-resolved -`BlockIndex`, tree, and attribution values that a caller supplies, so it never -resolves an object name and never needs the module-state or event types. A caller -that holds two module-states resolves their `blockIndex`, `generatedTree`, and -`attribution` hashes to concrete values first, then calls this module. +This module performs no I/O of any kind. It never opens a file, never reads a clock, +never draws a random token, and never probes a live process; the shell hands it the +host, the token, the process-liveness boolean, and the ancestry boolean, and this +module decides. -## Content hashes are plain strings +## The record types -Every content hash and every content-addressed name is carried as a `String`, -matching the rest of the workspace, never a newtype. A well-formed hash is -sixty-four lowercase hexadecimal characters (`0` through `9`, `a` through `f`). -The bundle validator rejects any hash-typed field of an occurrence side whose -string is not exactly that shape. +### The lock file -## The change kind and block level enums +Expose a public struct `LockFile` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`, +`serde::Serialize`, `serde::Deserialize`, with `deny_unknown_fields`) with exactly +these public fields, serialized under exactly these wire names: `host: String`, +`pid: u64`, `purpose: String`, `start_time: u64` (wire name `startTime`), and +`token: String`. Its canonical bytes are the canonical JSON of the serialized value. -Expose a public enum `ChangeKind` (deriving `Debug`, `Clone`, `Copy`, -`PartialEq`, `Eq`, `serde::Serialize`, `serde::Deserialize`), serializing as a -single JSON string, each variant pinned to its exact wire token; a string outside -the set is a deserialization failure: +### The attempt marker -- `ChangeKind::Added` renders `added`. -- `ChangeKind::Removed` renders `removed`. -- `ChangeKind::Modified` renders `modified`. +Expose a public struct `AttemptMarker` (same derives, `deny_unknown_fields`) with +exactly these public fields under these wire names: `attempt_id: String` (wire name +`attemptId`), `diagnostics: Vec` (a list of diagnostic blob hashes, possibly +empty), `module: String`, `operation: String`, `state: AttemptState`, and `target: +Option` serialized only when present (the target key is omitted entirely when +`None`). Expose a public enum `AttemptState` (same derives, serialized as a string) +with exactly the variants `InFlight` (wire token `inFlight`) and `Completed` (wire +token `completed`); an unknown state token is rejected. -Expose a public enum `BlockLevel` (same derives), serializing as a single JSON -string, each variant pinned to its exact wire token: +### The commit marker and install step -- `BlockLevel::Atom` renders `atom`. -- `BlockLevel::SubSpan` renders `subSpan`. +Expose a public struct `CommitMarker` (same derives, `deny_unknown_fields`) with +exactly these public fields under these wire names: `domain: String` (the module +whose domain this transaction owns), `owner: String` (the owner token), `snapshot: +String` (the snapshot hash this marker is keyed by), and `steps: Vec` +(the ordered install steps). -::: test -name: change kind added serializes to the string added -given: { change_kind: added } -expect: { serialized_json: "\"added\"" } -::: +Expose a public enum `InstallStep` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) +internally tagged by a field named `op`, with exactly these four variants and wire +tokens: `WriteFile { path: String, hash: String, mode: u32 }` (wire token +`writeFile`), `WriteView { path: String, hash: String }` (wire token `writeView`, a +snapshot-wide derived view with no mode), `SetMode { path: String, mode: u32 }` (wire +token `setMode`), and `DeleteFile { path: String }` (wire token `deleteFile`). +Because serde does not enforce `deny_unknown_fields` through an internally tagged +enum, hand-write a strict `Deserialize` for `InstallStep` that reads the `op` tag, +accepts exactly the fields that variant defines and no others (an unknown sibling key +is rejected), and rejects any `op` token outside the four above. Serialize each +variant as a single JSON object carrying its `op` token and exactly its own fields; +its canonical bytes sort the keys, so a `writeFile` serializes its keys in the order +`hash`, `mode`, `op`, `path`. + +## The recovery decision table + +### Classifying a commit marker + +Expose `classify_marker(is_ancestor_or_equal: bool, owner_live: bool) -> +MarkerDisposition`, a total function of exactly two booleans. When the marker's +snapshot is HEAD or an ancestor of HEAD the commit succeeded, so the disposition is +`RollForwardDelete` regardless of whether the owner is live: run the installer to +current HEAD, then delete the marker. When the marker's snapshot is not an ancestor, +a live owner means the running writer is inside its deliberate pre-HEAD window, so +the disposition is `LeavePreHeadWindow`; a dead owner means the commit never landed, +so the disposition is `DeleteAndCollect`: delete the marker and let the janitor +collect its objects. Expose a public enum `MarkerDisposition` (deriving `Debug`, +`Clone`, `PartialEq`, `Eq`) with exactly the variants `RollForwardDelete`, +`LeavePreHeadWindow`, and `DeleteAndCollect`. ::: test -name: change kind removed serializes to the string removed -given: { change_kind: removed } -expect: { serialized_json: "\"removed\"" } +name: an ancestor-or-equal marker with a live owner rolls forward and is deleted +given: { is_ancestor_or_equal: true, owner_live: true } +expect: { disposition: RollForwardDelete } ::: ::: test -name: change kind modified serializes to the string modified -given: { change_kind: modified } -expect: { serialized_json: "\"modified\"" } +name: an ancestor-or-equal marker with a dead owner still rolls forward and is deleted +given: { is_ancestor_or_equal: true, owner_live: false } +expect: { disposition: RollForwardDelete } ::: ::: test -name: block level atom serializes to the string atom -given: { block_level: atom } -expect: { serialized_json: "\"atom\"" } +name: a non-ancestor marker with a live owner is left in its pre-HEAD window +given: { is_ancestor_or_equal: false, owner_live: true } +expect: { disposition: LeavePreHeadWindow } ::: ::: test -name: block level subSpan serializes to the string subSpan -given: { block_level: subSpan } -expect: { serialized_json: "\"subSpan\"" } +name: a non-ancestor marker with a dead owner is deleted and its objects collected +given: { is_ancestor_or_equal: false, owner_live: false } +expect: { disposition: DeleteAndCollect } ::: +### Converting an attempt marker to an event + +Expose `attempt_disposition(state: AttemptState, event_exists: bool) -> +AttemptOutcome`, a total function. An in-flight attempt with no event carrying its +attempt id yet yields `EmitInterrupted`: recovery records a `gen` or `derivation` +event with outcome interrupted. An in-flight attempt whose attempt id already names +an event yields `SkipDuplicate`: a crash between the event commit and the marker +delete must never fabricate a duplicate. A completed attempt yields +`NoEventCompleted` whether or not an event exists, because a completed record names a +finished operation and never fabricates an interruption. Expose a public enum +`AttemptOutcome` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with exactly the +variants `EmitInterrupted`, `SkipDuplicate`, and `NoEventCompleted`. + ::: test -name: the string added deserializes to change kind added -given: { serialized_json: "\"added\"" } -expect: { is_added: true } +name: an in-flight attempt with no matching event emits an interrupted event +given: { attempt_state: inFlight, event_exists: false } +expect: { attempt: EmitInterrupted } ::: ::: test -name: the string subSpan deserializes to block level subSpan -given: { serialized_json: "\"subSpan\"" } -expect: { is_sub_span: true } +name: an in-flight attempt whose event already exists is a skipped duplicate +given: { attempt_state: inFlight, event_exists: true } +expect: { attempt: SkipDuplicate } ::: -## The occurrence side +::: test +name: a completed attempt with no matching event emits no event +given: { attempt_state: completed, event_exists: false } +expect: { attempt: NoEventCompleted } +::: -Expose a public struct `OccurrenceSide` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`, `serde::Serialize`, `serde::Deserialize`, with -`#[serde(deny_unknown_fields)]`), one side of a changed block. Public fields: +::: test +name: a completed attempt whose event already exists still emits no event +given: { attempt_state: completed, event_exists: true } +expect: { attempt: NoEventCompleted } +::: -- `atom_index: u32`, serde field name `atomIndex`. The atom the occurrence lives - in: the atom's own index at atom level, or the enclosing atom's index at - sub-span level. -- `sub_index: Option`, serde field name `subIndex`, skipped from - serialization when `None`. Present only at sub-span level, the sub-span's index - within its parent atom. -- `content_hash: String`, serde field name `contentHash`. The occurrence's content - hash, copied from the block index verbatim. -- `text: String`, serde field name `text`. The occurrence's exact bytes, the - prompt slice `[byteStart, byteEnd)` of the record, as a string. -- `line_start: u32`, serde field name `lineStart`, and `line_end: u32`, serde - field name `lineEnd`. The occurrence's inclusive one-based line range. +## The lock lifecycle -Occurrence identity is snapshot-local: an atom occurrence is `(contentHash, -atomIndex)` and a sub-span occurrence is `(parentAtomIndex, subIndex, -contentHash)`. This module never uses index equality across versions to match -occurrences; it matches only on content hash. +### Exclusive-creation acquisition -## The changed block +Acquisition is the first rule of the lock lifecycle: a lock is taken by an exclusive +atomic creation of its lock file (the shell's `O_CREAT|O_EXCL`), so exactly one caller +can win a vacant lock and every other caller instead observes a lock file that already +exists. Expose `acquire_decision(observed: AcquireObservation) -> AcquireDecision`, a +total function of the pre-create observation the shell hands it, deciding nothing from +a filesystem and only from the injected observation. Expose a public enum +`AcquireObservation` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with exactly these +variants: `Absent` (no lock file exists, so the exclusive create succeeds), `HeldLive` +(a lock file exists, parses, and its owner is live on this host), `HeldForeignHost` (a +lock file exists, parses, and its owner is on a foreign host, which is never judged +dead here), `HeldDead` (a lock file exists, parses, and its owner is dead on this +host), and `HeldMalformed` (a lock file exists but fails to parse, so it is held-and- +stale-eligible and never trusted). Expose a public enum `AcquireDecision` (deriving +`Debug`, `Clone`, `PartialEq`, `Eq`) with exactly the variants `Acquired`, +`AlreadyHeld`, and `ContendStaleEligible`. -Expose a public struct `ChangedBlock` (same derives, same -`#[serde(deny_unknown_fields)]`), one prompt-side change. Public fields: +The decision is total: `Absent` yields `Acquired`, the exclusive create won a vacant +lock and the caller now holds it; `HeldLive` and `HeldForeignHost` each yield +`AlreadyHeld`, a lock a live or foreign-host owner holds is never acquired and never +stolen; `HeldDead` and `HeldMalformed` each yield `ContendStaleEligible`, the existing +lock is stale-eligible so the caller enters the rename-aside steal protocol under the +janitor lock (deciding the steal itself through `steal_decision`) rather than acquiring +directly. Losing the exclusive create to a live owner is exactly the `HeldLive` +observation yielding `AlreadyHeld`; winning a vacant lock is exactly the `Absent` +observation yielding `Acquired`. -- `change: ChangeKind`, serde field name `change`. Whether the block was added, - removed, or modified. -- `level: BlockLevel`, serde field name `level`. Whether the block is an atom or a - sub-span. -- `old: Option`, serde field name `old`, skipped when `None`. The - old-version occurrence; `None` exactly for an added block. -- `new: Option`, serde field name `new`, skipped when `None`. The - new-version occurrence; `None` exactly for a removed block. +::: test +name: an exclusive create over no existing lock acquires it +given: { observed: Absent } +expect: { acquire: Acquired } +::: -The `change` label and the presence of the two sides are coupled: an `Added` block -has `old` `None` and `new` `Some`; a `Removed` block has `old` `Some` and `new` -`None`; a `Modified` block has both `Some`. The bundle validator enforces this -coupling. +::: test +name: an exclusive create that loses the race to a live owner finds it already held +given: { observed: HeldLive } +expect: { acquire: AlreadyHeld } +::: -## The owner reference and the ownership record +::: test +name: an exclusive create contending a foreign-host owner finds it already held +given: { observed: HeldForeignHost } +expect: { acquire: AlreadyHeld } +::: -Expose a public struct `OwnerRef` (same derives, same -`#[serde(deny_unknown_fields)]`), the occurrence an ownership record attributes a -generated region to. Public fields: +::: test +name: an exclusive create contending a dead same-host owner is stale-eligible +given: { observed: HeldDead } +expect: { acquire: ContendStaleEligible } +::: -- `level: BlockLevel`, serde field name `level`. Atom-level or sub-span-level - ownership. -- `atom_index: u32`, serde field name `atomIndex`. The owning atom's index. -- `sub_index: Option`, serde field name `subIndex`, skipped when `None`. - Present exactly at sub-span level. +::: test +name: an exclusive create contending a malformed lock is stale-eligible +given: { observed: HeldMalformed } +expect: { acquire: ContendStaleEligible } +::: -The `level` and `sub_index` are coupled: an `Atom` owner has `sub_index` `None`, a -`SubSpan` owner has `sub_index` `Some`. The bundle validator enforces this. +### Parsing a lock file -Expose a public struct `OwnershipRecord` (same derives, same -`#[serde(deny_unknown_fields)]`), one attribution link resolved against a block -index. Public fields: +Expose `parse_lock(bytes: &[u8]) -> LockState`. It attempts to parse the bytes as a +`LockFile`; on success it returns `LockState::Parsed(LockFile)`, and on any failure +(not valid JSON, missing a required field, or carrying an unknown field under +`deny_unknown_fields`) it returns `LockState::MalformedStaleEligible`. A lock file +that fails to parse is treated as held-and-stale-eligible and is never trusted: it is +never parsed into partial fields and never treated as releasable by token. Expose a +public enum `LockState` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with exactly +the variants `Parsed(LockFile)` and `MalformedStaleEligible`. -- `owner: OwnerRef`, serde field name `owner`. The occurrence that owns the region. -- `file: String`, serde field name `file`. The generated file path. -- `line_start: u32`, serde field name `lineStart`, and `line_end: u32`, serde - field name `lineEnd`. The owned region's inclusive one-based generated-file line - range, carried verbatim from the attribution entry's `lines`. -- `note: String`, serde field name `note`. The attribution entry's note. +::: test +name: a well-formed lock parses to a parsed lock +given: { fixture: LOCK } +expect: { parse: Parsed } +::: -## The code change +::: test +name: an unparseable lock is treated as held-and-stale-eligible +given: { fixture: LOCK_GARBAGE } +expect: { parse: MalformedStaleEligible } +::: -Expose a public struct `CodeChange` (same derives, same -`#[serde(deny_unknown_fields)]`), one generated-file change projected from a -`store_tree::TreeChange`. Public fields: +::: test +name: a lock carrying an unknown field is treated as held-and-stale-eligible +given: { fixture: LOCK_EXTRA } +expect: { parse: MalformedStaleEligible } +::: -- `change: ChangeKind`, serde field name `change`. -- `path: String`, serde field name `path`. The full slash-joined leaf path. -- `hash: Option`, serde field name `hash`, skipped when `None`. Present for - an added or removed change. -- `mode: Option`, serde field name `mode`, skipped when `None`. Present for an - added or removed change. -- `old_hash: Option`, serde field name `oldHash`, skipped when `None`. -- `old_mode: Option`, serde field name `oldMode`, skipped when `None`. -- `new_hash: Option`, serde field name `newHash`, skipped when `None`. -- `new_mode: Option`, serde field name `newMode`, skipped when `None`. +### Token-checked release -An `Added` code change carries `hash` and `mode` and none of the old or new pair; -a `Removed` code change carries `hash` and `mode`; a `Modified` code change carries -`oldHash`, `oldMode`, `newHash`, `newMode` and no bare `hash` or `mode`. Expose -`code_change_from_tree(change: &store_tree::TreeChange) -> CodeChange` that -projects a `TreeChange` into this shape: `Added` to `change` `Added` with `hash` -and `mode`, `Removed` to `change` `Removed` with `hash` and `mode`, and `Modified` -to `change` `Modified` with the four old and new fields. +Expose `release_decision(held_token: &str, release_token: &str) -> ReleaseDecision`. +Release is allowed only when the release token equals the held token, yielding +`Released`; any other token yields `Denied`. Expose a public enum `ReleaseDecision` +(deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with exactly the variants `Released` +and `Denied`. -## The attribution input types +::: test +name: a release whose token matches the held token is allowed +given: { held_token: "tok-aaaa", release_token: "tok-aaaa" } +expect: { release: Released } +::: -The attribution document is authored line-based, keyed to prompt line ranges, and -this module consumes it as already-parsed values. Expose a public struct -`OwnershipEntry` (same derives, same `#[serde(deny_unknown_fields)]`), one -attribution entry, matching the on-disk shape exactly. Public fields: +::: test +name: a release whose token differs from the held token is denied +given: { held_token: "tok-aaaa", release_token: "tok-bbbb" } +expect: { release: Denied } +::: -- `prompt_lines: [u32; 2]`, serde field name `promptLines`. The inclusive one-based - prompt line range this entry attributes, a two-element array `[start, end]`. -- `file: String`, serde field name `file`. The generated file. -- `lines: [u32; 2]`, serde field name `lines`. The inclusive one-based generated - file line range, a two-element array `[start, end]`. -- `note: String`, serde field name `note`. The human note. +### Liveness -Expose a public struct `Attribution` (same derives, same -`#[serde(deny_unknown_fields)]`), one target's attribution document. Public fields: +Expose `liveness_decision(lock_host: &str, observer_host: &str, probe_alive: bool) -> +Liveness`. Liveness is judged only on the same host: when the lock's host differs +from the observer's host the result is `ForeignHost` regardless of the probe, because +a process on another machine can never be judged dead here and is therefore never +stealable. On the same host the result is `Live` when the process probe reports the +owner alive and `Dead` when it reports the owner gone. Expose a public enum +`Liveness` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with exactly the variants +`Live`, `Dead`, and `ForeignHost`. -- `module: String`, serde field name `module`. -- `target: String`, serde field name `target`. -- `entries: Vec`, serde field name `entries`. +::: test +name: a same-host owner with a live process probe is live +given: { lock_host: "host-a", observer_host: "host-a", probe_alive: true } +expect: { liveness: Live } +::: -Expose `validate_attribution(value: serde_json::Value) -> Result`: deserialize the JSON value into an `Attribution`, returning -`StoreDiffError::Malformed` on any deserialization failure, then check that every -entry's `promptLines` and `lines` are ascending, that is element 0 is less than or -equal to element 1, else `StoreDiffError::InvalidLineRange`. +::: test +name: a same-host owner with a dead process probe is dead +given: { lock_host: "host-a", observer_host: "host-a", probe_alive: false } +expect: { liveness: Dead } +::: ::: test -name: an attribution entry whose promptLines are reversed is rejected -given: { json: "an attribution whose one entry promptLines is set to [4, 1]" } -expect: { error: InvalidLineRange } +name: a foreign-host owner with a live probe is foreign-host and never judged dead +given: { lock_host: "host-b", observer_host: "host-a", probe_alive: true } +expect: { liveness: ForeignHost } ::: ::: test -name: an attribution with an unknown top-level field is rejected -given: { json: "an attribution object with an extra top-level key \"bogus\": 1" } -expect: { error: Malformed } +name: a foreign-host owner with a dead probe is still foreign-host and never judged dead +given: { lock_host: "host-b", observer_host: "host-a", probe_alive: false } +expect: { liveness: ForeignHost } ::: +### Rename-aside steal, serialized under the janitor lock + +Expose `steal_decision(liveness: Liveness, janitor_held: bool) -> StealDecision`. +Only a `Dead` owner is ever stealable, and only under the janitor lock, so that two +recoverers cannot race: a `Dead` owner with the janitor lock held yields `Steal` +(atomically rename the lock aside), a `Dead` owner without the janitor lock held +yields `NeedJanitorLock` (acquire the janitor lock first), and a `Live` or +`ForeignHost` owner yields `Refuse` regardless of the janitor lock. Expose a public +enum `StealDecision` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with exactly the +variants `Steal`, `Refuse`, and `NeedJanitorLock`. + ::: test -name: an ownership entry with an unknown field is rejected -given: { json: "an attribution whose one entry carries an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: a dead owner under a held janitor lock is stolen +given: { liveness: Dead, janitor_held: true } +expect: { steal: Steal } ::: -## The bundle +::: test +name: a dead owner without the janitor lock needs the janitor lock first +given: { liveness: Dead, janitor_held: false } +expect: { steal: NeedJanitorLock } +::: -Expose a public struct `Bundle` (same derives, same -`#[serde(deny_unknown_fields)]`), the assembled diff bundle for one module. Public -fields, in this shape: +::: test +name: a live owner is never stolen even under the janitor lock +given: { liveness: Live, janitor_held: true } +expect: { steal: Refuse } +::: -- `module: String`, serde field name `module`. -- `prompt_changes: Vec`, serde field name `promptChanges`. The - prompt-side changes in emission order. -- `owned_old: Vec`, serde field name `ownedOld`. Generated regions - a changed block owned on the old side and whose file actually changed. -- `owned_new: Vec`, serde field name `ownedNew`. The same on the - new side. -- `unowned: Vec`, serde field name `unowned`. Code changes owned by no - changed prompt block, the audit signal that code moved with no prompt cause. +::: test +name: a foreign-host owner is never stolen even under the janitor lock +given: { liveness: ForeignHost, janitor_held: true } +expect: { steal: Refuse } +::: -## The error type +## Install-step planning -Expose a public enum `StoreDiffError` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`) with exactly these variants: +Expose `plan_install_steps(diff: &[store_tree::TreeChange]) -> Vec`. It +turns a generated-tree diff into the complete ordered install plan the shell installs +without reordering, so a shell can never misorder a materialization. The order is the +design's durability order: every domain-owned write first, then every snapshot-wide +derived view, then every permission change, then every deletion, and within each of +those four groups the steps are sorted by path. This is stamp-last by construction: +the durable installed stamp is not an install step and is never part of the plan this +core returns; the installer writes that stamp after every step of this plan is durable, +so the stamp never certifies non-durable contents. The planner never emits a stamp step. -- `Malformed(String)`, carrying serde's message, for any deserialization failure - (an unknown field at any nesting level, a missing required field, a wrong JSON - type, an integer outside `u32`). -- `MalformedHash`, an occurrence-side `contentHash` that is not sixty-four - lowercase hex. -- `InconsistentChange`, a changed block whose `change` label disagrees with the - presence of its `old` and `new` sides. -- `InconsistentOwner`, an owner whose `level` disagrees with the presence of its - `sub_index`. -- `InvalidLineRange`, an occurrence side, attribution `promptLines`, or attribution - `lines` whose start exceeds its end. -- `MissingObject`, a tree root or subtree the bundle's tree diff needed but the - supplied object map did not contain (surfaced from `store_tree`). +The planner routes each tree change by classifying its path with `classify_path` and by +whether the change is a content write, a mode-only change, or a removal: -## Validating a bundle +- An added path, or a modified path whose content hash changed, that classifies as + `DomainOwned` becomes a `WriteFile` step carrying the path, the new hash, and the new + mode. +- An added path, or a modified path whose content hash changed, that classifies as + `SnapshotWideDerived` becomes a `WriteView` step carrying the path and the new hash + and no mode. +- A modified path whose content hash is unchanged but whose mode changed, classifying + as `DomainOwned`, becomes a `SetMode` step carrying the path and the new mode; a + snapshot-wide derived view carries no mode, so a view's mode-only change contributes + no step. +- A removed path that is not build-local ephemera becomes a `DeleteFile` step carrying + the path. +- A path that classifies as `BuildLocalEphemera` is never authoritative and never + installed, so it contributes no step whether it was added, modified, or removed. -Expose `validate_bundle(value: serde_json::Value) -> Result`: deserialize the JSON value into a `Bundle`, returning -`StoreDiffError::Malformed` on any deserialization failure, then apply these -semantic checks, returning the first failure: +Each tree change therefore yields exactly one step or none, and no change yields two. +The returned vector lists all `WriteFile` steps first sorted by path, then all +`WriteView` steps sorted by path, then all `SetMode` steps sorted by path, then all +`DeleteFile` steps sorted by path, so domain content is durable before its snapshot-wide +views, views before permission changes, and every write durable before any obsolete +entry is removed. An empty diff yields an empty plan. -- For each changed block: every present occurrence side's `contentHash` is a - well-formed hash, else `StoreDiffError::MalformedHash`; every present occurrence - side has `lineStart` less than or equal to `lineEnd`, else - `StoreDiffError::InvalidLineRange`; the `change` label matches the two sides - (`Added` needs `old` absent and `new` present, `Removed` needs `old` present and - `new` absent, `Modified` needs both present), else - `StoreDiffError::InconsistentChange`. -- For each ownership record in `ownedOld` and `ownedNew`, and for every `owner` on - a bundle: an `Atom` owner has no `subIndex` and a `SubSpan` owner has a - `subIndex`, else `StoreDiffError::InconsistentOwner`. +## Path classification -::: test -name: a bundle with an unknown top-level field is rejected -given: { json: "the BD_EMPTY object with an extra top-level key \"bogus\": 1" } -expect: { error: Malformed } -::: +Expose `classify_path(path: &str) -> PathClass`, a total function that maps every +path into exactly one of three authority classes. Build-local ephemera is recognized +first: a path under a `target/` directory, or a `Cargo.lock`, or anything under +`tmp/`, is `BuildLocalEphemera`. Then the snapshot-wide derived views: the `map.json` +cache and the workspace root manifest `Cargo.toml` are `SnapshotWideDerived`. Every +other path, a module's crate file or a per-module derivation document, is +`DomainOwned`. This precedence makes the function total: every input string maps to +exactly one class and no input is unclassified. Expose a public enum `PathClass` +(deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with exactly the variants +`DomainOwned`, `SnapshotWideDerived`, and `BuildLocalEphemera`. ::: test -name: a changed block with an unknown field is rejected -given: { json: "the BD_RICH object whose one promptChanges block carries an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: a module crate file classifies as domain-owned +given: { path: "greeting/src/lib.rs" } +expect: { class: DomainOwned } ::: ::: test -name: an occurrence side with an unknown field is rejected -given: { json: "the BD_RICH object whose one promptChanges block old side carries an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: a per-module derivation document classifies as domain-owned +given: { path: "derivations/greeting/mapl.yaml" } +expect: { class: DomainOwned } ::: ::: test -name: an owner reference with an unknown field is rejected -given: { json: "the BD_RICH object whose first ownedNew owner carries an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: the map cache classifies as a snapshot-wide derived view +given: { path: "map.json" } +expect: { class: SnapshotWideDerived } ::: ::: test -name: an ownership record with an unknown field is rejected -given: { json: "the BD_RICH object whose first ownedNew record carries an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: the workspace root manifest classifies as a snapshot-wide derived view +given: { path: "Cargo.toml" } +expect: { class: SnapshotWideDerived } ::: ::: test -name: a code change with an unknown field is rejected -given: { json: "the BD_RICH object whose one unowned code change carries an extra key \"bogus\": 1" } -expect: { error: Malformed } +name: a target directory entry classifies as build-local ephemera +given: { path: "target/debug/build" } +expect: { class: BuildLocalEphemera } ::: ::: test -name: an occurrence side whose contentHash is not sixty-four lowercase hex is rejected -given: { json: "the BD_RICH object whose one promptChanges block old contentHash is set to \"xyz\"" } -expect: { error: MalformedHash } +name: a staging lockfile classifies as build-local ephemera +given: { path: "Cargo.lock" } +expect: { class: BuildLocalEphemera } ::: ::: test -name: an added block that carries an old side is rejected -given: { json: "a bundle with one added-level block carrying both an old and a new occurrence side" } -expect: { error: InconsistentChange } +name: an unmatched path classifies as domain-owned so the function is total +given: { path: "greeting/notes.txt" } +expect: { class: DomainOwned } ::: +## The error type + +Expose a public enum `TxnCoreError` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) +with exactly the variant `Malformed(String)`, carrying a human-readable context +string, returned by the strict parse of a record whose JSON carries an unknown field +or an unknown wire token. The strict record parses are exposed as three public +functions, `parse_commit_marker(bytes: &[u8]) -> Result`, +`parse_attempt_marker(bytes: &[u8]) -> Result`, and +`parse_install_step(bytes: &[u8]) -> Result`, each yielding +`Malformed` on a record whose JSON carries an unknown field or an unknown wire token. +Parsing that reaches the lock lifecycle instead of a record constructor uses +`parse_lock`, which never errors and returns `MalformedStaleEligible`. + ::: test -name: a removed block that carries a new side is rejected -given: { json: "a bundle with one removed-level block carrying both an old and a new occurrence side" } -expect: { error: InconsistentChange } +name: a commit marker with an unknown top-level field is rejected +given: { json: "the COMMIT fixture object with an extra top-level key \"bogus\": 1" } +expect: { error: Malformed } ::: ::: test -name: a modified block missing its new side is rejected -given: { json: "a bundle with one modified-level block carrying only an old occurrence side" } -expect: { error: InconsistentChange } +name: a commit marker whose install step carries an unknown field is rejected +given: { json: "the COMMIT fixture object whose single install step carries an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: ::: test -name: an atom-level owner carrying a subIndex is rejected -given: { json: "the BD_RICH object whose first ownedNew owner level is set to \"atom\" while it keeps its subIndex 1" } -expect: { error: InconsistentOwner } +name: an attempt marker with an unknown field is rejected +given: { json: "the ATT_INFLIGHT fixture object with an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: ::: test -name: a subSpan-level owner missing its subIndex is rejected -given: { json: "the BD_RICH object whose first ownedNew owner has its subIndex key removed while its level stays subSpan" } -expect: { error: InconsistentOwner } +name: an install step with an unknown op token is rejected +given: { json: "the STEP_WRITE fixture object with its op set to \"frobnicate\"" } +expect: { error: Malformed } ::: ::: test -name: an occurrence side whose lineStart exceeds its lineEnd is rejected -given: { json: "the BD_RICH object whose one promptChanges block old side lineStart is set to 9 and lineEnd left at 2" } -expect: { error: InvalidLineRange } +name: an attempt marker with an unknown state token is rejected +given: { json: "the ATT_INFLIGHT fixture object with its state set to \"bogus\"" } +expect: { error: Malformed } ::: -## The two-stage alignment - -Both the atom level and the sub-span level align two sequences of content-hash -tokens by the same deterministic two-stage rule. Stage one finds the identical -anchors by a longest-common-subsequence over the token sequences. Stage two pairs -the runs of non-anchor tokens between consecutive anchors positionally, so a -changed token keeps its correspondence and duplicate ties break toward the nearest -index. - -The longest-common-subsequence is computed by the standard dynamic-programming -length table over token equality, and the anchors are recovered by a forward -traceback that is fully deterministic: walking from the start of both sequences, -at each step take a diagonal match when the two current tokens are equal, else -advance the old cursor when the table value below is greater than or equal to the -table value to the right, else advance the new cursor. This traceback rule is the -pinned tie-break; do not substitute a different one. - -Given the anchors, emit the alignment operations in this canonical order. Treat the -end of both sequences as a final sentinel anchor. For each anchor in turn, first -resolve the gap of non-anchor tokens that precedes it: pair the old-run and new-run -positionally, emitting a `Modified` operation `(oldIndex, newIndex)` for each of -the first `min(oldRunLen, newRunLen)` pairs in order, then a `Removed` operation -for each leftover old-run index in order, then an `Added` operation for each -leftover new-run index in order; then emit the anchor itself as an `Unchanged` -operation. The final sentinel contributes its preceding gap but no anchor -operation. This order is observable and pinned. - -## Hierarchical prompt diff - -Expose `diff_prompt(old_index: &store_blocks::BlockIndex, old_prompt: &str, -new_index: &store_blocks::BlockIndex, new_prompt: &str) -> Vec`. It -is total: each `content_hash` is treated as an opaque token, each `text` is the -prompt slice of the record's byte range, and the caller supplies prompt bytes -consistent with the index (the same precondition `store_blocks::reconstruct` -carries). - -Run the two-stage alignment over the atoms' `content_hash` sequences and walk the -operations in emission order: - -- An `Unchanged` atom operation emits nothing: the atoms are byte-identical. -- An `Added` atom operation emits one `ChangedBlock` with `change` `Added`, `level` - `Atom`, `old` `None`, and `new` an `OccurrenceSide` for the new atom (its - `atomIndex`, no `subIndex`, its `contentHash`, its prompt slice as `text`, and - its line range). An added atom's own sub-spans are never cross-matched into any - other atom; the whole atom is one added block. -- A `Removed` atom operation emits one `ChangedBlock` with `change` `Removed`, - `level` `Atom`, `old` the removed atom's `OccurrenceSide`, and `new` `None`. -- A `Modified` atom operation names a corresponding atom pair. An atom is - **sub-span-reliable** when it carries at least two sub-spans in the index; an - atom with zero or one sub-span (a frontmatter, heading, fenced-block, whitespace, - protected, or otherwise unsplittable atom) is not reliable. When **both** atoms - of the pair are sub-span-reliable, diff at the sub-span level (below). Otherwise - the pair degrades to an atom-level block: emit one `ChangedBlock` with `change` - `Modified`, `level` `Atom`, `old` the old atom's `OccurrenceSide`, and `new` the - new atom's `OccurrenceSide`. - -The sub-span level runs the same two-stage alignment over the two atoms' sub-span -`content_hash` sequences, taken in `subIndex` order, and walks the operations in -emission order. Sub-span matching stays strictly within this atom pair; a sub-span -is never matched into another atom's sub-spans. Each operation emits a -`ChangedBlock` with `level` `SubSpan`: - -- `Unchanged` emits nothing. -- `Added` emits `change` `Added`, `old` `None`, `new` the new sub-span's - `OccurrenceSide` (its parent atom's index as `atomIndex`, its `subIndex`, its - `contentHash`, its prompt slice as `text`, its line range). -- `Removed` emits `change` `Removed`, `old` the old sub-span's `OccurrenceSide`, - `new` `None`. -- `Modified` emits `change` `Modified`, `old` and `new` the two sub-spans' - occurrence sides. - -Because an insertion adds an atom while the following identical atoms remain -anchors, an insertion near the top adds exactly one atom block and never churns the -suffix. Because a paragraph that splits differently between versions leaves one -side without reliable sub-spans, that atom pair degrades to a single atom-level -block rather than a churn of cross-matched sentences. - -## Attribution ownership resolution - -Expose `resolve_ownership(index: &store_blocks::BlockIndex, attribution: -&Attribution) -> Vec`. For each attribution entry in order, -intersect its `promptLines` range against the index by inclusive line overlap (two -inclusive ranges `[a0, a1]` and `[b0, b1]` overlap when `a0 <= b1` and `b0 <= a1`) -and resolve one owner, coarsening deliberately when a line cannot uniquely select a -sentence: - -- Collect the sub-spans whose line range overlaps the entry's `promptLines`. -- If exactly one sub-span overlaps, the owner is that sub-span: a `SubSpan` - `OwnerRef` with its parent atom's index and its `subIndex`. -- If two or more sub-spans overlap and they all share one parent atom, ownership - **coarsens to the enclosing atom**: an `Atom` `OwnerRef` with that atom's index. - A line range that intersects several sibling sentences deliberately owns at the - atom level rather than guessing a sentence split; this is the two-sentences-on- - one-line rule. -- If the overlapping sub-spans span more than one atom, emit one `Atom`-owner - record per overlapped atom in ascending atom-index order, all carrying the - entry's file, lines, and note. -- If no sub-span overlaps, fall back to atoms: collect the atoms whose line range - overlaps `promptLines`; a single overlapped atom yields one `Atom`-owner record, - several yield one `Atom`-owner record per atom in ascending index order, and no - overlap at all yields no record for this entry (the entry references lines - outside this index version and is dropped). - -Each emitted `OwnershipRecord` carries the resolved `owner`, the entry's `file`, -its `lines[0]` as `lineStart` and `lines[1]` as `lineEnd`, and its `note`. - -## Bundle assembly +## Canonical record bytes -Expose `build_bundle(module: &str, prompt_changes: &[ChangedBlock], old_index: -&store_blocks::BlockIndex, old_attribution: &Attribution, new_index: -&store_blocks::BlockIndex, new_attribution: &Attribution, old_tree_root: -Option<&str>, new_tree_root: Option<&str>, tree_objects: -&std::collections::BTreeMap) -> Result`. It assembles the bundle: +The serialization counterpart of the strict parses is exposed as three public +functions that each return one record type's canonical bytes: +`canonical_commit_marker(m: &CommitMarker) -> Vec`, `canonical_attempt_marker(m: +&AttemptMarker) -> Vec`, and `canonical_lock(l: &LockFile) -> Vec`. Each +returns the canonical JSON of the record's serialized value through +`store_hash::canonical_json`, sorted keys, compact, one trailing newline, so its +output is byte for byte the value the matching `parse_commit_marker`, +`parse_attempt_marker`, or `parse_lock` accepts back and byte for byte the canonical +literal this prompt's fixtures pin for that record. These functions never reimplement +canonical JSON and never spell the bytes by hand; they serialize the record and defer +to `store_hash::canonical_json`, keeping this module the sole authority over the +canonical form. -- Compute the code-side changes by calling `store_tree::diff_trees(old_tree_root, - new_tree_root, tree_objects)`; on `store_tree::StoreTreeError::MissingObject` - return `StoreDiffError::MissingObject`, and on any other `store_tree` error - return `StoreDiffError::MissingObject` as well since a well-formed caller only - reaches the missing-object path here. Project each returned `TreeChange` into a - `CodeChange` through `code_change_from_tree`. -- Let the changed-file set be the `path` of every code change. An `OwnerRef` - **matches** a changed-block occurrence side when they name the same `atomIndex` - and, when both carry a `subIndex`, the same `subIndex` (an atom owner matches any - occurrence in its atom, and a sub-span owner matches an atom-level occurrence in - its atom). -- `owned_old` is every record from `resolve_ownership(old_index, old_attribution)`, - in that function's order, whose `file` is in the changed-file set and whose - `owner` matches the `old` side of some changed block. -- `owned_new` is the same over `resolve_ownership(new_index, new_attribution)` - against the `new` sides. -- Let the owned-file set be the `file` of every record in `owned_old` and - `owned_new`. `unowned` is every code change, in tree-diff order, whose `path` is - not in the owned-file set. -- Return the `Bundle` with the given `module`, the given `prompt_changes`, and the - three assembled lists. +These three functions are the only sanctioned way for any consumer to obtain a +record's bytes: every consumer, `store_io` and `migrate_io` included, calls +`canonical_commit_marker`, `canonical_attempt_marker`, or `canonical_lock` and never +privately re-derives the bytes of a commit marker, an attempt marker, or a lock file. +Canonical byte authority stays in this module, and the shell writes exactly what these +functions return. -For a no-op changeset (no prompt changes and identical trees) every list is empty: -the bundle's code side is empty, which is exactly what the audit consumer asserts -for an expected-no-op. +::: test +name: canonical_lock returns the lock record's canonical bytes +given: { fixture: LOCK } +expect: { canonical_bytes: "{\"host\":\"host-a\",\"pid\":4321,\"purpose\":\"domain:greeting\",\"startTime\":1000,\"token\":\"tok-aaaa\"}\n" } +::: -## Rendering the bundle blob +::: test +name: canonical_attempt_marker returns the attempt marker's canonical bytes +given: { fixture: ATT_INFLIGHT } +expect: { canonical_bytes: "{\"attemptId\":\"att-0001\",\"diagnostics\":[],\"module\":\"greeting\",\"operation\":\"agent-run\",\"state\":\"inFlight\",\"target\":\"rust\"}\n" } +::: -Expose `bundle_bytes(bundle: &Bundle) -> Vec`, the bundle's blob payload: the -`store_hash::canonical_json` of the serialized bundle value. Expose -`bundle_blob_name(bundle: &Bundle) -> String`, the bundle's blob object name: -`store_hash::object_name(store_hash::ObjectKind::Blob, &bundle_bytes(bundle))`. -These do not reimplement canonical JSON or hashing; they call `store_hash`. A -bundle recovered from its canonical bytes through `validate_bundle` equals the -original. +::: test +name: canonical_commit_marker returns the commit marker's canonical bytes +given: { fixture: COMMIT } +expect: { canonical_bytes: "{\"domain\":\"greeting\",\"owner\":\"tok-aaaa\",\"snapshot\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"steps\":[{\"hash\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"mode\":33188,\"op\":\"writeFile\",\"path\":\"greeting/src/lib.rs\"}]}\n" } +::: ## Fixtures the tests pin -These fixtures and hash constants are referenced by name in the test suite. The -hash constants are each a sixty-four-character string of a single repeated -character: `A` is sixty-four `a`, `B` sixty-four `b`, `C` sixty-four `c`, `D` -sixty-four `d`, and `S2`, `S3`, `S4` are sixty-four repetitions of the digits `2`, -`3`, `4` respectively. All are well-formed lowercase-hex tokens. +These fixtures are referenced by name in the test suite. The sixty-four-character +hash constants are each a single repeated hex digit: `SNAP` is sixty-four `1`, +`FILEHASH` sixty-four `2`, `DIAG` sixty-four `3`, `VIEWHASH` sixty-four `4`, +`NEWHASH` sixty-four `5`, and `OLDHASH` sixty-four `6`, all well-formed lowercase-hex +tokens compared only for equality. Every canonical byte string below is the exact value the reference +computation `store-txn-ref.py` pins. -The block-index fixtures are built directly as `store_blocks::BlockIndex` values -(`seg_version` 1) with the atoms and sub-spans named; their `content_hash` tokens -are chosen to drive the alignment and are compared only for equality. +Record fixtures: -- **DIFF_INS**: old prompt `Alpha line.\nBeta line.\nGamma line.\n`, three atoms - with content hashes `A`, `B`, `C` at byte ranges `[0,12)`, `[12,23)`, `[23,35)` - and line ranges `(1,1)`, `(2,2)`, `(3,3)`, no sub-spans; new prompt - `Zeta line.\nAlpha line.\nBeta line.\nGamma line.\n`, four atoms with content - hashes `D`, `A`, `B`, `C` at byte ranges `[0,11)`, `[11,23)`, `[23,34)`, - `[34,46)` and line ranges `(1,1)`, `(2,2)`, `(3,3)`, `(4,4)`, no sub-spans. +- **LOCK**: the `LockFile` with host `host-a`, pid `4321`, purpose `domain:greeting`, + start time `1000`, token `tok-aaaa`. Its canonical bytes are exactly + `{"host":"host-a","pid":4321,"purpose":"domain:greeting","startTime":1000,"token":"tok-aaaa"}` + followed by one newline. ::: test -name: an insertion near the top adds one atom and never churns the suffix -given: { fixture: DIFF_INS } -expect: - prompt_changes: - - { change: added, level: atom, newAtomIndex: 0, newContentHash: D, newText: "Zeta line.\n", newLineStart: 1, newLineEnd: 1 } +name: a lock file serializes to its canonical bytes +given: { fixture: LOCK } +expect: { canonical_bytes: "{\"host\":\"host-a\",\"pid\":4321,\"purpose\":\"domain:greeting\",\"startTime\":1000,\"token\":\"tok-aaaa\"}\n" } ::: -- **DIFF_SUBDIFF**: old prompt `A cat sat. A dog ran.\n`, one atom content hash - `A` at `[0,22)` line `(1,1)` with two sub-spans, `subIndex` 0 content hash - sixty-four `1`, bytes `[0,11)`, line `(1,1)`, and `subIndex` 1 content hash `S2`, - bytes `[11,22)`, line `(1,1)`; new prompt `A cat sat. A fox ran.\n`, one atom - content hash `B` at - `[0,22)` line `(1,1)` with two sub-spans, `subIndex` 0 content hash sixty-four - `1`, bytes `[0,11)`, line `(1,1)`, and `subIndex` 1 content hash `S3`, bytes - `[11,22)`, line `(1,1)`. The shared first sub-span anchors; the second sub-span - is a modified pair whose old text is `A dog ran.\n` and new text is - `A fox ran.\n`. - ::: test -name: a modified prose atom pair with reliable sub-spans diffs at the sub-span level -given: { fixture: DIFF_SUBDIFF } -expect: { prompt_changes: [ { change: modified, level: subSpan, oldAtomIndex: 0, oldSubIndex: 1, oldContentHash: S2, oldText: "A dog ran.\n", newAtomIndex: 0, newSubIndex: 1, newContentHash: S3, newText: "A fox ran.\n" } ] } +name: a lock file round-trips back to an equal value +given: { fixture: LOCK } +expect: { round_trip_equal: true } ::: -- **DIFF_DEGRADE**: old prompt `Single line only.\n`, one atom content hash `A` at - `[0,18)` line `(1,1)` with a single sub-span `subIndex` 0 content hash `S4`, - bytes `[0,18)`, line `(1,1)` (one sub-span, so not reliable); new prompt - `Single line only. And more text.\n`, one atom content hash `B` at `[0,33)` line - `(1,1)` with two sub-spans, `subIndex` 0 content hash sixty-four `1`, bytes - `[0,18)`, line `(1,1)`, and `subIndex` 1 content hash `S2`, bytes `[18,33)`, - line `(1,1)`. The old side is not reliable, so the pair degrades to an atom-level - modified block whose old text is `Single line only.\n` and new text is - `Single line only. And more text.\n`. +- **ATT_INFLIGHT**: the `AttemptMarker` with attempt id `att-0001`, empty + diagnostics, module `greeting`, operation `agent-run`, state `InFlight`, target + `rust`. Its canonical bytes are exactly + `{"attemptId":"att-0001","diagnostics":[],"module":"greeting","operation":"agent-run","state":"inFlight","target":"rust"}` + followed by one newline. ::: test -name: a modified atom pair with an unreliable side degrades to an atom-level block -given: { fixture: DIFF_DEGRADE } -expect: { prompt_changes: [ { change: modified, level: atom, oldAtomIndex: 0, oldText: "Single line only.\n", newAtomIndex: 0, newText: "Single line only. And more text.\n" } ] } +name: an in-flight attempt marker serializes to its canonical bytes +given: { fixture: ATT_INFLIGHT } +expect: { canonical_bytes: "{\"attemptId\":\"att-0001\",\"diagnostics\":[],\"module\":\"greeting\",\"operation\":\"agent-run\",\"state\":\"inFlight\",\"target\":\"rust\"}\n" } ::: -- **DIFF_REMOVE**: old prompt `Alpha line.\nBeta line.\n`, two atoms content hashes - `A`, `B` at `[0,12)`, `[12,23)`, lines `(1,1)`, `(2,2)`, no sub-spans; new prompt - `Alpha line.\n`, one atom content hash `A` at `[0,12)` line `(1,1)`. The second - atom is removed. - ::: test -name: a removed trailing atom is reported at the atom level -given: { fixture: DIFF_REMOVE } -expect: - prompt_changes: - - { change: removed, level: atom, oldAtomIndex: 1, oldContentHash: B, oldText: "Beta line.\n", oldLineStart: 2, oldLineEnd: 2 } +name: an in-flight attempt marker round-trips back to an equal value +given: { fixture: ATT_INFLIGHT } +expect: { round_trip_equal: true } ::: -- **OWN_COARSEN**: an index with one atom (content hash `A`, bytes `[0,22)`, line - `(1,1)`) and two sub-spans both on line 1 (`subIndex` 0 bytes `[0,11)` line - `(1,1)`, `subIndex` 1 bytes `[11,22)` line `(1,1)`), - and an `Attribution` (`module` `greeting`, `target` `rust`) with one entry - `promptLines` `[1,1]`, `file` `greeting.rs`, `lines` `[1,1]`, `note` - `whole line`. The entry overlaps both sub-spans of one atom, so ownership - coarsens to `Atom` owner index 0. +- **ATT_DONE**: the `AttemptMarker` with attempt id `att-0001`, diagnostics holding + the single hash `DIAG`, module `greeting`, operation `agent-run`, state + `Completed`, target `rust`. Its canonical bytes are exactly + `{"attemptId":"att-0001","diagnostics":["3333333333333333333333333333333333333333333333333333333333333333"],"module":"greeting","operation":"agent-run","state":"completed","target":"rust"}` + followed by one newline. ::: test -name: two sentences on one line coarsen attribution ownership to the enclosing atom -given: { fixture: OWN_COARSEN } -expect: - ownership: - - { owner_level: atom, owner_atom_index: 0, file: "greeting.rs", line_start: 1, line_end: 1, note: "whole line" } +name: a completed attempt marker serializes to its canonical bytes +given: { fixture: ATT_DONE } +expect: { canonical_bytes: "{\"attemptId\":\"att-0001\",\"diagnostics\":[\"3333333333333333333333333333333333333333333333333333333333333333\"],\"module\":\"greeting\",\"operation\":\"agent-run\",\"state\":\"completed\",\"target\":\"rust\"}\n" } ::: -- **OWN_UNIQUE**: an index with one atom (bytes `[0,22)`, line `(1,2)`) and two - sub-spans on distinct lines (`subIndex` 0 bytes `[0,11)` line `(1,1)`, `subIndex` - 1 bytes `[11,22)` line `(2,2)`), and an - `Attribution` with one entry `promptLines` `[2,2]`, `file` `greeting.rs`, `lines` - `[5,7]`, `note` `second`. The entry overlaps only the second sub-span, so the - owner is `SubSpan` atom 0 sub-index 1. +- **ATT_NOTARGET**: the `AttemptMarker` with attempt id `att-0002`, empty + diagnostics, module `greeting`, operation `derivation:mapl`, state `InFlight`, and + no target. Its canonical bytes are exactly + `{"attemptId":"att-0002","diagnostics":[],"module":"greeting","operation":"derivation:mapl","state":"inFlight"}` + followed by one newline; the `target` key is omitted entirely. ::: test -name: an attribution line that selects one sub-span owns at the sub-span level -given: { fixture: OWN_UNIQUE } -expect: - ownership: - - { owner_level: subSpan, owner_atom_index: 0, owner_sub_index: 1, file: "greeting.rs", line_start: 5, line_end: 7, note: "second" } +name: an attempt marker with no target omits the target key +given: { fixture: ATT_NOTARGET } +expect: { canonical_bytes: "{\"attemptId\":\"att-0002\",\"diagnostics\":[],\"module\":\"greeting\",\"operation\":\"derivation:mapl\",\"state\":\"inFlight\"}\n" } ::: -- **OWN_NOSUB**: an index with one atom (bytes `[0,10)`, line `(1,1)`) and no - sub-spans, and an - `Attribution` with one entry `promptLines` `[1,1]`, `file` `greeting.rs`, `lines` - `[1,1]`, `note` `heading`. No sub-span overlaps, so ownership falls back to the - atom, an `Atom` owner index 0. +- **COMMIT**: the `CommitMarker` with domain `greeting`, owner `tok-aaaa`, snapshot + `SNAP`, and one step, a `WriteFile` of path `greeting/src/lib.rs`, hash `FILEHASH`, + mode `33188`. Its canonical bytes are exactly + `{"domain":"greeting","owner":"tok-aaaa","snapshot":"1111111111111111111111111111111111111111111111111111111111111111","steps":[{"hash":"2222222222222222222222222222222222222222222222222222222222222222","mode":33188,"op":"writeFile","path":"greeting/src/lib.rs"}]}` + followed by one newline. ::: test -name: an attribution over an atom with no sub-spans falls back to atom ownership -given: { fixture: OWN_NOSUB } -expect: - ownership: - - { owner_level: atom, owner_atom_index: 0, file: "greeting.rs", line_start: 1, line_end: 1, note: "heading" } +name: a commit marker serializes to its canonical bytes +given: { fixture: COMMIT } +expect: { canonical_bytes: "{\"domain\":\"greeting\",\"owner\":\"tok-aaaa\",\"snapshot\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"steps\":[{\"hash\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"mode\":33188,\"op\":\"writeFile\",\"path\":\"greeting/src/lib.rs\"}]}\n" } ::: -- **BUNDLE_NOOP**: `module` `greeting`, empty prompt changes, an index and - attribution on each side, and the same tree root on both sides (or `None` on - both) so the tree diff is empty. Every bundle list is empty. - ::: test -name: a no-op changeset builds an empty bundle with an empty code side -given: { fixture: BUNDLE_NOOP } -expect: { prompt_changes: [], owned_old: [], owned_new: [], unowned: [] } +name: a commit marker round-trips back to an equal value +given: { fixture: COMMIT } +expect: { round_trip_equal: true } ::: -- **BUNDLE_RICH**: `module` `greeting`. Old index one atom (content hash `A`, bytes - `[0,22)`, line `(1,2)`) with two sub-spans, `subIndex` 0 content hash sixty-four - `1` bytes `[0,11)` line `(1,1)`, `subIndex` 1 content hash `S2` bytes `[11,22)` - line `(2,2)`; new index one atom (content hash sixty-four `0`, differing from the - old atom, bytes `[0,22)`, line `(1,2)`) with two sub-spans, `subIndex` 0 content - hash sixty-four `1` bytes `[0,11)` line `(1,1)`, `subIndex` 1 content hash `S3` - bytes `[11,22)` line `(2,2)`. Old prompt - `A cat sat.\nA dog ran.\n`, new prompt `A cat sat.\nA fox ran.\n`. The prompt - changes come from `diff_prompt`: one modified sub-span block, old text - `A dog ran.\n` new text `A fox ran.\n`, both at line `(2,2)`. Old and new - attribution each carry one entry `promptLines` `[2,2]`, `file` `greeting.rs`, - `lines` `[2,2]`, `note` `greet`. The old tree has files `greeting.rs` (hash - sixty-four `a`, mode 33188) and `extra.rs` (hash sixty-four `e`, mode 33188); the - new tree has `greeting.rs` (hash sixty-four `b`, mode 33188) and `extra.rs` (hash - sixty-four `f`, mode 33188); both files change. The build attributes - `greeting.rs` to the changed sub-span on both sides (so it appears in `ownedOld` - and `ownedNew`) and leaves `extra.rs` unowned. Its bundle blob bytes are exactly - the pinned canonical bytes and its blob name is the pinned digest. +- **STEP_WRITE**: the `WriteFile` step of path `greeting/src/lib.rs`, hash + `FILEHASH`, mode `33188`. Its canonical bytes are exactly + `{"hash":"2222222222222222222222222222222222222222222222222222222222222222","mode":33188,"op":"writeFile","path":"greeting/src/lib.rs"}` + followed by one newline. ::: test -name: "a bundle labels owned old, owned new, and unowned code changes distinctly" -given: { fixture: BUNDLE_RICH } -expect: - owned_old: - - { owner_level: subSpan, owner_atom_index: 0, owner_sub_index: 1, file: "greeting.rs", line_start: 2, line_end: 2, note: "greet" } - owned_new: - - { owner_level: subSpan, owner_atom_index: 0, owner_sub_index: 1, file: "greeting.rs", line_start: 2, line_end: 2, note: "greet" } - unowned: - - { change: modified, path: "extra.rs" } +name: a write-file install step serializes to its canonical bytes +given: { fixture: STEP_WRITE } +expect: { canonical_bytes: "{\"hash\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"mode\":33188,\"op\":\"writeFile\",\"path\":\"greeting/src/lib.rs\"}\n" } ::: -- **BD_EMPTY**: the `Bundle` value `module` `greeting` with all four lists empty. - Its canonical bytes are exactly - `{"module":"greeting","ownedNew":[],"ownedOld":[],"promptChanges":[],"unowned":[]}` - followed by one newline; its blob name is - `6dc9ca1a0fe393bbed56cce83d42aadded789fac58c3f5ef22d2c13bf25d1bdc`. +- **STEP_VIEW**: the `WriteView` step of path `map.json`, hash `VIEWHASH`. Its + canonical bytes are exactly + `{"hash":"4444444444444444444444444444444444444444444444444444444444444444","op":"writeView","path":"map.json"}` + followed by one newline. ::: test -name: an empty bundle canonicalizes to its pinned bytes -given: { fixture: BD_EMPTY } -expect: { bundle_bytes: "{\"module\":\"greeting\",\"ownedNew\":[],\"ownedOld\":[],\"promptChanges\":[],\"unowned\":[]}\n" } +name: a write-view install step serializes to its canonical bytes +given: { fixture: STEP_VIEW } +expect: { canonical_bytes: "{\"hash\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"op\":\"writeView\",\"path\":\"map.json\"}\n" } ::: -::: test -name: an empty bundle names its blob by its pinned digest -given: { fixture: BD_EMPTY } -expect: { bundle_blob_name: "6dc9ca1a0fe393bbed56cce83d42aadded789fac58c3f5ef22d2c13bf25d1bdc" } -::: +- **STEP_SETMODE**: the `SetMode` step of path `greeting/src/lib.rs`, mode `33261`. + Its canonical bytes are exactly + `{"mode":33261,"op":"setMode","path":"greeting/src/lib.rs"}` followed by one + newline. ::: test -name: an empty bundle round-trips back to an equal value -given: { fixture: BD_EMPTY } -expect: { round_trip_equal: true } +name: a set-mode install step serializes to its canonical bytes +given: { fixture: STEP_SETMODE } +expect: { canonical_bytes: "{\"mode\":33261,\"op\":\"setMode\",\"path\":\"greeting/src/lib.rs\"}\n" } ::: -- **BD_RICH**: the `Bundle` value that `BUNDLE_RICH` builds. Its canonical bytes - are the pinned rich-bundle literal and its blob name is - `8e42bc109b1229962ccfcecb1aced8316d00145d46129e88e4c0fc71c7f2eee7`. +- **STEP_DELETE**: the `DeleteFile` step of path `greeting/src/old.rs`. Its canonical + bytes are exactly `{"op":"deleteFile","path":"greeting/src/old.rs"}` followed by one + newline. ::: test -name: the rich bundle canonicalizes to its pinned bytes -given: { fixture: BD_RICH } -expect: { bundle_bytes: "{\"module\":\"greeting\",\"ownedNew\":[{\"file\":\"greeting.rs\",\"lineEnd\":2,\"lineStart\":2,\"note\":\"greet\",\"owner\":{\"atomIndex\":0,\"level\":\"subSpan\",\"subIndex\":1}}],\"ownedOld\":[{\"file\":\"greeting.rs\",\"lineEnd\":2,\"lineStart\":2,\"note\":\"greet\",\"owner\":{\"atomIndex\":0,\"level\":\"subSpan\",\"subIndex\":1}}],\"promptChanges\":[{\"change\":\"modified\",\"level\":\"subSpan\",\"new\":{\"atomIndex\":0,\"contentHash\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"lineEnd\":2,\"lineStart\":2,\"subIndex\":1,\"text\":\"A fox ran.\\n\"},\"old\":{\"atomIndex\":0,\"contentHash\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"lineEnd\":2,\"lineStart\":2,\"subIndex\":1,\"text\":\"A dog ran.\\n\"}}],\"unowned\":[{\"change\":\"modified\",\"newHash\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"newMode\":33188,\"oldHash\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"oldMode\":33188,\"path\":\"extra.rs\"}]}\n" } +name: a delete-file install step serializes to its canonical bytes +given: { fixture: STEP_DELETE } +expect: { canonical_bytes: "{\"op\":\"deleteFile\",\"path\":\"greeting/src/old.rs\"}\n" } ::: +- **LOCK_GARBAGE**: the byte string `not a lock at all`, which is not valid JSON. + `parse_lock` returns `MalformedStaleEligible`. +- **LOCK_EXTRA**: the LOCK canonical JSON with an extra top-level key `"bogus": 1` + added. Under `deny_unknown_fields` it fails to parse, so `parse_lock` returns + `MalformedStaleEligible`. + +Planning fixtures: + +- **PLAN_MIXED**: a tree diff of three changes in this input order: a + `TreeChange::Modified` of path `greeting/src/lib.rs` from old hash `OLDHASH` and old + mode `33188` to new hash `FILEHASH` and new mode `33188` (a content change, since the + hash differs), a `TreeChange::Removed` of path `greeting/src/old.rs`, and a + `TreeChange::Added` of path `greeting/src/new.rs` with hash `NEWHASH` and mode + `33188`. All three paths are domain-owned. `plan_install_steps` returns exactly, in + this order, a `WriteFile` of `greeting/src/lib.rs` (hash `FILEHASH`, mode `33188`), a + `WriteFile` of `greeting/src/new.rs` (hash `NEWHASH`, mode `33188`), and a + `DeleteFile` of `greeting/src/old.rs`: both writes first sorted by path, then the + delete. + ::: test -name: the rich bundle names its blob by its pinned digest -given: { fixture: BD_RICH } -expect: { bundle_blob_name: "8e42bc109b1229962ccfcecb1aced8316d00145d46129e88e4c0fc71c7f2eee7" } +name: install-step planning orders writes before deletes each sorted by path +given: { fixture: PLAN_MIXED } +expect: { install_order: "writeFile:greeting/src/lib.rs,writeFile:greeting/src/new.rs,deleteFile:greeting/src/old.rs" } ::: +- **PLAN_EMPTY**: an empty tree diff. `plan_install_steps` returns an empty vector. + ::: test -name: the rich bundle round-trips back to an equal value -given: { fixture: BD_RICH } -expect: { round_trip_equal: true } +name: install-step planning over an empty diff yields no steps +given: { fixture: PLAN_EMPTY } +expect: { install_order: "" } ::: -- **BUNDLE_MISSING_ROOT**: a `build_bundle` call whose `old_tree_root` is a - sixty-four-hex name (use `D`) deliberately absent from `tree_objects`, so the - tree diff fails and the build returns `MissingObject`. +- **PLAN_FULL**: a tree diff exercising all four install-step kinds and the ephemera + skip, given in this deliberately unsorted input order: a `TreeChange::Removed` of + path `target/debug/junk.o`, a `TreeChange::Modified` of path `map.json` from old hash + `OLDHASH` and old mode `33188` to new hash `VIEWHASH` and new mode `33188`, a + `TreeChange::Removed` of path `greeting/src/old.rs`, a `TreeChange::Added` of path + `greeting/src/new.rs` with hash `NEWHASH` and mode `33188`, a `TreeChange::Modified` + of path `greeting/src/perm.rs` from old hash `FILEHASH` and old mode `33188` to new + hash `FILEHASH` and new mode `33261` (a mode-only change, the hash unchanged on both + sides), a `TreeChange::Modified` of path `greeting/src/lib.rs` from old hash `OLDHASH` + and old mode `33188` to new hash `FILEHASH` and new mode `33188`, a + `TreeChange::Modified` of path `Cargo.toml` from old hash `OLDHASH` and old mode + `33188` to new hash `VIEWHASH` and new mode `33188`, and a `TreeChange::Removed` of + path `greeting/src/gone.rs`. `plan_install_steps` returns exactly, in this order: a + `WriteFile` of `greeting/src/lib.rs` (hash `FILEHASH`, mode `33188`), a `WriteFile` of + `greeting/src/new.rs` (hash `NEWHASH`, mode `33188`), a `WriteView` of `Cargo.toml` + (hash `VIEWHASH`), a `WriteView` of `map.json` (hash `VIEWHASH`), a `SetMode` of + `greeting/src/perm.rs` (mode `33261`), a `DeleteFile` of `greeting/src/gone.rs`, and a + `DeleteFile` of `greeting/src/old.rs`. The `target/debug/junk.o` change contributes no + step because build-local ephemera is never installed. This proves the four groups + appear in the order writes, then views, then permissions, then deletions, and that + each group is sorted by path (`greeting/src/lib.rs` before `greeting/src/new.rs`, + `Cargo.toml` before `map.json`, `greeting/src/gone.rs` before `greeting/src/old.rs`). ::: test -name: a bundle build whose tree root is absent from the object map is rejected -given: { fixture: BUNDLE_MISSING_ROOT } -expect: { error: MissingObject } +name: install-step planning orders writes then views then permissions then deletes each sorted by path +given: { fixture: PLAN_FULL } +expect: { install_order: "writeFile:greeting/src/lib.rs,writeFile:greeting/src/new.rs,writeView:Cargo.toml,writeView:map.json,setMode:greeting/src/perm.rs,deleteFile:greeting/src/gone.rs,deleteFile:greeting/src/old.rs" } ::: ## Test and code discipline @@ -214124,7432 +214647,5555 @@ expect: { error: MissingObject } The frontmatter test suite is a contract, not a sketch. Each case in it is realized by its own dedicated test function that exercises only that case, so the number of dedicated tests is at least the number of frontmatter cases. Never fold two or more -cases into one shared test, and a single test that loops over a table of inputs -never counts as the dedicated test any of those inputs requires: such a combined -test may exist only as an extra. Cases that differ only in their input while -sharing an expected shape are still distinct cases, each owed its own dedicated -test. +cases into one shared test, and a single test that loops over a table of inputs never +counts as the dedicated test any of those inputs requires: such a combined test may +exist only as an extra. Cases that differ only in their input while sharing an +expected shape are still distinct cases, each owed its own dedicated test. Enumerate the case families by name, each owed its own dedicated verbatim test. The -wire-string cases: the three `ChangeKind` serialize strings `added`, `removed`, -`modified`; the two `BlockLevel` serialize strings `atom`, `subSpan`; and the two -deserialize cases (`added` to `ChangeKind::Added`, `subSpan` to -`BlockLevel::SubSpan`), each its own test even where a token is shared. The -byte-pinned bundle cases: the BD_EMPTY canonical-bytes case, the BD_EMPTY blob-name -case, the BD_EMPTY round-trip case, the BD_RICH canonical-bytes case, the BD_RICH -blob-name case, and the BD_RICH round-trip case, each its own test. Every case that -expects `Malformed` (the unknown top-level bundle field, the unknown changed-block -field, the unknown occurrence-side field, the unknown owner field, the unknown -ownership-record field, the unknown code-change field, the unknown attribution -top-level field, and the unknown ownership-entry field), the `MalformedHash` case, -every `InconsistentChange` case (the added block with an old side, the removed -block with a new side, the modified block missing its new side), every -`InconsistentOwner` case (the atom owner carrying a subIndex, the subSpan owner -missing its subIndex), every `InvalidLineRange` case (the occurrence side, the -attribution promptLines), and the `MissingObject` case, gets its own dedicated -test. The algorithmic cases: the insertion case DIFF_INS, the sub-span-diff case -DIFF_SUBDIFF, the atom-degradation case DIFF_DEGRADE, the removed-atom case -DIFF_REMOVE, the coarsening case OWN_COARSEN, the unique-sub-span case OWN_UNIQUE, -the no-sub-span fallback case OWN_NOSUB, the no-op bundle case BUNDLE_NOOP, and the -labelled-bundle case BUNDLE_RICH, each its own dedicated test. +canonical-bytes cases: the LOCK case, the ATT_INFLIGHT case, the ATT_DONE case, the +ATT_NOTARGET case, the COMMIT case, the STEP_WRITE case, the STEP_VIEW case, the +STEP_SETMODE case, and the STEP_DELETE case, each its own test asserting the full +canonical byte string. The round-trip cases: the LOCK round-trip, the ATT_INFLIGHT +round-trip, and the COMMIT round-trip, each its own test. The marker classification +cases: the two ancestor cases (live owner and dead owner, both `RollForwardDelete`, +proving the owner-liveness is irrelevant when ancestor), the non-ancestor live case +(`LeavePreHeadWindow`), and the non-ancestor dead case (`DeleteAndCollect`), each its +own test. The attempt-disposition cases: in-flight without event +(`EmitInterrupted`), in-flight with event (`SkipDuplicate`), completed without event +(`NoEventCompleted`), and completed with event (`NoEventCompleted`), each its own +test. The lock-parse cases: the well-formed LOCK (`Parsed`), the garbage LOCK_GARBAGE +(`MalformedStaleEligible`), and the extra-key LOCK_EXTRA (`MalformedStaleEligible`), +each its own test. The release cases: matching token (`Released`) and differing token +(`Denied`). The liveness cases: same-host live (`Live`), same-host dead (`Dead`), +foreign-host with a live probe (`ForeignHost`), and foreign-host with a dead probe +(`ForeignHost`), each its own test, the last two proving the host gate overrides the +probe. The steal cases: dead with the janitor lock (`Steal`), dead without it +(`NeedJanitorLock`), live with the janitor lock (`Refuse`), and foreign-host with the +janitor lock (`Refuse`), each its own test. The acquisition cases: exclusive-create +over no existing lock (`observed` `Absent`, `Acquired`), create losing the race to a +live owner (`observed` `HeldLive`, `AlreadyHeld`), contending a foreign-host owner +(`observed` `HeldForeignHost`, `AlreadyHeld`), contending a dead same-host owner +(`observed` `HeldDead`, `ContendStaleEligible`), and contending a malformed lock +(`observed` `HeldMalformed`, `ContendStaleEligible`), each its own test, the two +`AlreadyHeld` cases and the two `ContendStaleEligible` cases proving the input variant +they name and never discharging their sibling. The path-classification cases, each named +by the exact frontmatter given: path it hands `classify_path` and each its own test: +the crate file `greeting/src/lib.rs` (`DomainOwned`), the derivation document +`derivations/greeting/mapl.yaml` (`DomainOwned`), the map cache `map.json` +(`SnapshotWideDerived`), the workspace manifest `Cargo.toml` (`SnapshotWideDerived`), +the target entry `target/debug/build` (`BuildLocalEphemera`), the staging lockfile +`Cargo.lock` (`BuildLocalEphemera`), and the unmatched-path totality case +`greeting/notes.txt` (`DomainOwned`). Each path case's dedicated test passes +`classify_path` that case's own frontmatter given: path byte for byte and asserts its +pinned class; a path of the same class but a different string is a neighboring +stand-in that does not discharge the case, so the derivation-document case is tested +with `derivations/greeting/mapl.yaml` and no other domain-owned path, the target-entry +case with `target/debug/build` and no other ephemeral path, and the unmatched-path +totality case with `greeting/notes.txt` and no other unclassified path. The install-planning +cases: PLAN_MIXED (the ordered write-write-delete plan), PLAN_EMPTY (the empty plan), +and PLAN_FULL (the full ordered plan across all four step kinds with the ephemera +skip), each its own test. The rejection cases: the commit-marker unknown top-level field, the +install-step unknown field, the attempt-marker unknown field, the install-step +unknown op token, and the attempt-marker unknown state token, each its own test. -Inside a case's dedicated test, hand the code exactly the fixture that case gives, -byte for byte, and assert its pinned expectation verbatim: no shorter or more -convenient stand-in, no re-derivation of a value the case pins as a literal, and no -weakening of a pinned equality into an inequality-only or recomputed check. The -byte-pinned bundle cases assert their exact literals: BD_EMPTY renders -`bundle_bytes` equal to -`{"module":"greeting","ownedNew":[],"ownedOld":[],"promptChanges":[],"unowned":[]}` -plus one newline and names its blob -`6dc9ca1a0fe393bbed56cce83d42aadded789fac58c3f5ef22d2c13bf25d1bdc`; BD_RICH renders -`bundle_bytes` equal to its pinned rich literal and names its blob -`8e42bc109b1229962ccfcecb1aced8316d00145d46129e88e4c0fc71c7f2eee7`. Where a case -pins bundle bytes, obtain them from `bundle_bytes` and compare to the literal; -where a case pins a blob name, obtain it from `bundle_blob_name` and compare to the -literal. The BD_EMPTY blob-name test asserts only that pinned digest, leaving the -canonical-bytes shape to the case that owns it. Each round-trip case builds its own -named bundle, renders its canonical bytes, recovers a `Bundle` through -`validate_bundle` over the parsed bytes, and asserts the recovered value equals the -original verbatim, never weakening that equality into a mere `is_ok` acceptance and -never substituting a lighter or neighboring fixture. +Inside a case's dedicated test, hand the code exactly the fixture or the argument +tuple that case names, byte for byte, and assert its pinned expectation verbatim: no +shorter or more convenient stand-in, no re-derivation of a value the case pins as a +literal, and no weakening of a pinned equality into an inequality-only or recomputed +check. Every canonical-bytes case serializes the named record with +`store_hash::canonical_json` over its serialized value and compares the result to the +full literal byte string given above, never a prefix and never a re-spelling: the +ATT_NOTARGET case asserts the `target` key is absent by comparing to its exact +literal, the STEP_VIEW case asserts no `mode` key by comparing to its exact literal, +and the COMMIT case asserts the nested step's keys are sorted `hash`, `mode`, `op`, +`path` by comparing to its exact literal. Each round-trip case serializes its named +record to canonical bytes, parses those bytes back through the record's strict +`Deserialize`, and asserts the recovered value equals the original verbatim, never +weakening that equality into a mere `is_ok` acceptance. -The rejection cases construct exactly the malformed JSON the case describes, built -by taking the named fixture's JSON and applying only the one described mutation -using that mutation's exact literal tokens, and assert the pinned error variant. -The mutation literal is transcribed verbatim, never paraphrased. Every extra-key -case inserts the key named `bogus` with the integer value `1`, exactly -`"bogus": 1`, never a renamed key such as `extra` and never a restyled value such -as the string `"nope"`, and no extra-key case discharges another. Each extra-key -case starts from its own named base fixture and inserts `bogus` at exactly its own -locus: the bundle top-level case starts from BD_EMPTY and adds the key at the object -top level; the changed-block case starts from BD_RICH and adds it to the single -`promptChanges` block; the occurrence-side case starts from BD_RICH and adds it to -that block's `old` side; the owner case starts from BD_RICH and adds it to the first -`ownedNew` record's `owner`; the ownership-record case starts from BD_RICH and adds -it to the first `ownedNew` record; the code-change case starts from BD_RICH and adds -it to the single `unowned` code change; the attribution top-level case starts from -the attribution object and adds it at the top level; and the ownership-entry case -starts from the attribution object and adds it to the single entry. These are eight -separate dedicated tests. The bundle top-level case never substitutes BD_RICH for -BD_EMPTY, and the ownership-record case never mutates `ownedOld` in place of the -first `ownedNew` record. The malformed-hash case sets the changed -block's old occurrence `contentHash` to exactly `"xyz"`. The named-substitution -prohibitions for this module's confusable tokens are strict: never write `added` -where a case pins `removed` or `modified`, nor `atom` where a case pins `subSpan`, -nor `ownedOld` where a case pins `ownedNew` (or the reverse), nor `oldHash` where a -case pins `newHash`, nor `promptLines` where a case pins `lines`, nor `lineStart` -where a case pins `lineEnd`, nor `atomIndex` where a case pins `subIndex`; each -confusable token is transcribed exactly as its case names it. The -`InconsistentChange` cases each build a bundle with one block: the added-with-old -block sets `change` `added` with both `old` and `new` present; the removed-with-new -block sets `change` `removed` with both present; the modified-missing-new block -sets `change` `modified` with only `old` present; each asserts `InconsistentChange` -and none discharges another. The `InconsistentOwner` cases mutate BD_RICH's first -`ownedNew` owner: the atom-with-subIndex case sets its `level` to `atom` while -keeping its `subIndex` `1`, and the subSpan-without-subIndex case removes its -`subIndex` while keeping `level` `subSpan`; each asserts `InconsistentOwner`. The -occurrence-side `InvalidLineRange` case sets the old side's `lineStart` to `9` with -`lineEnd` left at `2`; the attribution `InvalidLineRange` case sets an entry's -`promptLines` to `[4, 1]`; each asserts `InvalidLineRange` through the matching -validator (`validate_bundle` for the occurrence side, `validate_attribution` for -the attribution). +The decision-table cases pass exactly the argument tuple the case names and assert +the exact returned variant, never a neighboring variant and never a mere +discriminant check. The named-substitution prohibitions for this module's confusable +tokens are strict: never return `RollForwardDelete` where a case pins +`DeleteAndCollect` or `LeavePreHeadWindow`; never `EmitInterrupted` where a case pins +`SkipDuplicate` or `NoEventCompleted`; never `Live` where a case pins `Dead` or +`ForeignHost`; never `Steal` where a case pins `Refuse` or `NeedJanitorLock`; never +`Released` where a case pins `Denied`; never `DomainOwned` where a case pins +`SnapshotWideDerived` or `BuildLocalEphemera`; never `Parsed` where a case pins +`MalformedStaleEligible`; never `Acquired` where a case pins `AlreadyHeld` or +`ContendStaleEligible`; never `AlreadyHeld` where a case pins `Acquired` or +`ContendStaleEligible`; and never the wire token `inFlight` where a case pins +`completed`, `writeFile` where a case pins `writeView`, `setMode`, or `deleteFile`, +`startTime` where a case pins another field, or `attemptId` where a case pins +`operation`. Each confusable token is transcribed exactly as its case names it. The +two ancestor cases differ only in `owner_live` and each is its own test; the two +foreign-host cases differ only in `probe_alive` and each is its own test; the two +completed attempt cases differ only in `event_exists` and each is its own test; none +of these pairs discharges its sibling. -Each algorithmic case builds the exact fixture its case names and asserts the -returned value equals the pinned list exactly, element for element in the pinned -order, never weakening the equality into a length-only or membership check and -never reordering. The DIFF_INS case asserts a single added atom block at new atom -index 0 with new text `Zeta line.\n`, and asserts explicitly that no block mentions -the anchored atoms `A`, `B`, or `C`, so the no-churn property is proven by the -absence of any suffix change and not merely by a happy total. The DIFF_SUBDIFF case -asserts a single modified sub-span block at atom 0 sub-index 1 whose old text is -`A dog ran.\n` and new text is `A fox ran.\n`, and asserts the shared first -sub-span produced no block. The DIFF_DEGRADE case asserts a single modified -atom-level block at atom 0 whose old text is `Single line only.\n` and new text is -`Single line only. And more text.\n`, and asserts the block's `level` is `atom`, -never `subSpan`. The DIFF_REMOVE case asserts a single removed atom block at old -atom index 1 with old text `Beta line.\n`. The OWN_COARSEN case asserts a single -ownership record whose owner is an `Atom` owner at index 0, never a `SubSpan` owner, -proving the two-sentences-on-one-line coarsening. The OWN_UNIQUE case asserts a -single ownership record whose owner is a `SubSpan` owner at atom 0 sub-index 1. The -OWN_NOSUB case asserts a single ownership record whose owner is an `Atom` owner at -index 0. The BUNDLE_NOOP case asserts every bundle list, `promptChanges`, -`ownedOld`, `ownedNew`, and `unowned`, is empty, so the code side is empty. The -BUNDLE_RICH case asserts `ownedOld` and `ownedNew` each carry the one `greeting.rs` -record owned by the changed sub-span, and asserts `unowned` carries exactly the one -`extra.rs` code change, labelling the three sides distinctly. The -BUNDLE_MISSING_ROOT case passes an old tree root absent from the object map and -asserts `MissingObject`. +The rejection cases construct exactly the malformed JSON the case describes, built by +taking the named fixture's canonical JSON and applying only the one described +mutation using that mutation's exact literal tokens, and assert `TxnCoreError:: +Malformed`. Every extra-key case inserts the key named `bogus` with the integer value +`1`, exactly `"bogus": 1`, never a renamed key such as `extra` and never a restyled +value such as the string `"nope"`, and no extra-key case discharges another: the +commit-marker case starts from COMMIT and adds the key at the object top level, the +install-step case starts from COMMIT and adds it to the single step object, and the +attempt-marker case starts from ATT_INFLIGHT and adds it at the top level. The +unknown-op case starts from STEP_WRITE and sets its `op` to exactly `"frobnicate"`, +and the unknown-state case starts from ATT_INFLIGHT and sets its `state` to exactly +`"bogus"`; each asserts `TxnCoreError::Malformed` and neither is a mere `is_err` +check. The install-step unknown-field and unknown-op cases both prove the hand-written +strict `Deserialize` of the internally tagged `InstallStep`, which serde would +otherwise let pass. -Closing mandate on one-to-one case fidelity. Every case in the frontmatter test -suite above, without exception, is realized by exactly one dedicated test that -hands the code that case's own fixture with its literal values copied verbatim from -the case, and asserts that case's own pinned expectation, neither narrower nor +The install-planning cases assert the returned vector equals the pinned step list +exactly, element for element in the pinned order, never weakening the equality into a +length-only or membership check and never reordering: PLAN_MIXED asserts the exact +three-step vector `WriteFile greeting/src/lib.rs`, `WriteFile greeting/src/new.rs`, +`DeleteFile greeting/src/old.rs` in that order, proving writes precede deletes and +each group is path-sorted, and PLAN_EMPTY asserts an empty vector. PLAN_FULL asserts +the exact seven-step vector `WriteFile greeting/src/lib.rs`, `WriteFile +greeting/src/new.rs`, `WriteView Cargo.toml`, `WriteView map.json`, `SetMode +greeting/src/perm.rs`, `DeleteFile greeting/src/gone.rs`, `DeleteFile greeting/src/old.rs` +in that order, each step carrying its pinned hash or mode, proving the four groups +appear in the order writes, then views, then permissions, then deletions, that each +group is path-sorted, that a mode-only change becomes a `SetMode` step and a +snapshot-wide change becomes a `WriteView` step, and that the `target/debug/junk.o` +build-local ephemera change is dropped entirely. The classify_path +totality case asserts an unmatched path still returns a class, proving the function +is total over every input string. + +Closing mandate on one-to-one case fidelity. Every case in the frontmatter test suite +above, without exception, is realized by exactly one dedicated test that hands the +code that case's own fixture or argument tuple with its literal values copied verbatim +from the case, and asserts that case's own pinned expectation, neither narrower nor recomputed. A test that inspects a different or narrower property than its case declares, that swaps in a lighter or neighboring fixture, that weakens a pinned equality into an `is_ok` or inequality-only check, or that reuses a sibling case's values, does not discharge the case and leaves it untested. Two cases that share a -shape but differ in a fixture value or in the property asserted are two separate -cases, each owed its own verbatim test: the three `InconsistentChange` cases never -discharge one another, the two `InconsistentOwner` cases never discharge one -another, the two `InvalidLineRange` cases never discharge one another, the eight -extra-key `Malformed` cases never discharge one another, the DIFF_SUBDIFF sub-span -case never collapses into the DIFF_DEGRADE atom case, and the OWN_COARSEN atom-owner -case never reuses the OWN_UNIQUE sub-span-owner values. Supplemental tests beyond -the suite are named for what they add and are never counted as, nor substituted -for, any suite case's dedicated test. The number of dedicated tests is therefore at -least the number of frontmatter cases, and each case above is discharged by its own -verbatim test. +shape but differ in an argument or in the property asserted are two separate cases, +each owed its own verbatim test: the four attempt-disposition cases never discharge +one another, the four liveness cases never discharge one another, the four steal cases +never discharge one another, the five acquisition cases never discharge one another, +the seven path cases never discharge one another, the three install-planning cases +never discharge one another, the nine canonical-bytes cases never discharge one +another, and the five rejection cases never discharge one another. Supplemental tests beyond the suite are named for what +they add and are never counted as, nor substituted for, any suite case's dedicated +test. The number of dedicated tests is therefore at least the number of frontmatter +cases, and each case above is discharged by its own verbatim test. -Write no explanatory line or block comments in the generated code, and no doc -comment on any private item. The crate keeps its module-level `//!` summary. Every -public type, every public enum variant, and every public function carries a `///` -doc comment restating its contract; a public struct field carries a `///` doc -comment only where its name alone does not convey the contract, and simply named -fields may carry none. The private helper functions and private constants of this -module carry no doc comment. Beyond the module summary and these public-item doc -comments the code carries no commentary of any kind. Use no em dashes anywhere in -the generated code, including inside string literals and doc comments. +The canonical-record-byte cases are three, one per public canonical byte function, +each owed its own dedicated verbatim test bound by name and none discharged by any +existing canonical-bytes case. The canonical_lock case hands `canonical_lock` the +LOCK fixture record and asserts the returned bytes equal the full LOCK canonical byte +string given above verbatim. The canonical_attempt_marker case hands +`canonical_attempt_marker` the ATT_INFLIGHT fixture record and asserts the returned +bytes equal the full ATT_INFLIGHT canonical byte string verbatim. The +canonical_commit_marker case hands `canonical_commit_marker` the COMMIT fixture record +and asserts the returned bytes equal the full COMMIT canonical byte string verbatim. +Each asserts the exact pinned literal, never a prefix, never a re-spelling, and never a +re-derivation of the pinned bytes, reusing the same literal its named fixture already +pins rather than recomputing a fresh value. Each test may additionally close the round +trip by parsing the returned bytes back through the record's own parse and asserting the +recovered record equals the original, `parse_lock` returning `LockState::Parsed` of the +equal lock and `parse_commit_marker` and `parse_attempt_marker` each returning `Ok` of +the equal record. These three cases never discharge one another, and none is ever +counted as, nor substituted for, any existing canonical-bytes case's dedicated test. -## migrate_core +Write no explanatory line or block comments in the generated code, and no doc comment +on any private item. The crate keeps its module-level `//!` summary. Every public +type, every public enum variant, and every public function carries a `///` doc +comment restating its contract; a public struct field carries a `///` doc comment +only where its name alone does not convey the contract, and simply named fields may +carry none. The private helper functions and private constants of this module carry +no doc comment. Beyond the module summary and these public-item doc comments the code +carries no commentary of any kind. Use no em dashes anywhere in the generated code, +including inside string literals and doc comments. -# Migrate core: the audit classifier, the sanction report, and the migration plan +## store_io -This module is the pure brain of the one-shot ledger migration. It reads the legacy -`journal.jsonl` as data, classifies how faithfully each file's history can be -reconstructed, renders the human-facing audit report the maintainer sanctions -before any live migration, and assembles the content-addressed objects that the -migrated store is built from. It does all of this with no filesystem, no clock, and -no randomness: every input is handed in, every output is a value, and the same -inputs always produce the same bytes. The command shell that reads and writes files -lives in the sibling `migrate_io` module; nothing here touches disk. +# Store I/O: the on-disk object store, publication, HEAD, markers, and recovery -Bring in `serde` and `serde_json`. +This module is the shell of the NAPL content-addressed store: the layer that turns +the pure object model and the pure transaction decision core into real bytes on a +real filesystem. Everything the store keeps lives under a store root directory, +laid out exactly as the design of record fixes it, and every observable effect of +this module is a deterministic function of the store's contents plus the caller's +arguments. It does the actual syscalls; it never decides policy that the decision +core owns. It has five jobs: + +- **The disk object provider**: it implements the reducer's read trait over the + on-disk layout, so the same typed queries that run against an in-memory provider + run unchanged against a real store. +- **Object publication**: one no-replace publication primitive that writes an + object's envelope bytes into the store exactly once, verifying an already-present + object rather than replacing it. +- **HEAD read and compare-and-set**: reading the current snapshot hash and moving + HEAD forward only from an expected prior value. +- **Markers**: writing the per-transaction commit marker and the per-writer attempt + marker as canonical bytes at their keyed paths, and reading them back for + recovery. +- **Recovery, installation, and the janitor**: the startup recovery pass that + drives the pure decision core over the markers it finds, the monotonic installer + that materializes HEAD into the live tree behind a durable stamp, and the janitor + that collects dead-writer litter. + +Bring in `serde_json` for reading marker bytes back into the decision core's types. ## Where this code lives The working directory is a Cargo workspace whose root manifest is written and owned by the toolchain, leave it alone. Create this module as its own member crate in a -subdirectory named `migrate_core/`: `migrate_core/Cargo.toml` (package name -`migrate_core`) and `migrate_core/src/lib.rs`. Touch nothing outside -`migrate_core/`. Ensure `cargo test` passes from the workspace root before -finishing. - -## Builds on two modules of this workspace +subdirectory named `store_io/`: `store_io/Cargo.toml` (package name `store_io`) and +`store_io/src/lib.rs`. Touch nothing outside `store_io/`. Ensure `cargo test` +passes from the workspace root before finishing. -This crate composes on two generated sibling crates. Use each one's public API, do -not reimplement its types or logic, and do not depend on any hand-written crate. Do -not bring in the `sha2` crate directly; all hashing goes through `store_hash`. +## Builds on five modules of this workspace -- **`store_hash`** (`../store_hash`) supplies the typed envelope framing, the - canonical JSON encoding, and the SHA-256 object naming. It uses - `store_hash::ObjectKind` (the variants `ObjectKind::Blob`, `ObjectKind::Event`, - `ObjectKind::ModuleState`, and `ObjectKind::Snapshot`), - `store_hash::canonical_json(value: &serde_json::Value) -> Vec`, - `store_hash::envelope_bytes(kind: store_hash::ObjectKind, payload: &[u8]) -> - Vec`, and `store_hash::object_name(kind: store_hash::ObjectKind, payload: - &[u8]) -> String`. This module never reimplements canonical JSON, envelope - framing, or hashing; it calls `store_hash` for all four. -- **`store_objects`** (`../store_objects`) supplies the provenance object types and - the naming functions over them. It uses `store_objects::ModuleState`, - `store_objects::TargetArtifacts`, `store_objects::StateRef` (the variants - `StateRef::State(String)` and `StateRef::Unknown`), `store_objects::Event`, - `store_objects::EventPayload` (the variant `EventPayload::Legacy`), - `store_objects::LegacyFields` (public fields `original_line: String` serde - `originalLine`, and `classification: store_objects::LegacyClassification`), - `store_objects::LegacyClassification` (the variants `Exact`, `MissingPrefix`, - `InconsistentBefore`, `InconsistentAfter`, `Unreconstructable`, serializing to the - wire tokens `exact`, `missing-prefix`, `inconsistent-before`, - `inconsistent-after`, and `unreconstructable`), `store_objects::Snapshot`, and - `store_objects::RootEntry` (public fields `module_state: String` serde - `moduleState`, and `event_head: String` serde `eventHead`). This module reuses - `store_objects::LegacyClassification` as its own classification result type, and - it does not define a second classification enum. A built object's name is obtained - through `store_objects::module_state_object_name`, - `store_objects::event_object_name`, and `store_objects::snapshot_object_name`, and - those names must equal the names this module frames through `store_hash` over the - same canonical payload. +This crate composes on five generated sibling crates. Use each one's public API, do +not reimplement its types or logic, and do not depend on any hand-written crate. +Depend on exactly these five, and do not bring in the `sha2` crate directly. -The legacy classification wire tokens are frozen by `store_objects`; this module -never spells them itself except where the audit report renders them as text, and -those report spellings are exactly those five tokens. +- **`store_hash`** (`../store_hash`) supplies the envelope framing and the read + verifier. It uses `store_hash::ObjectKind` (the variants `ObjectKind::Blob` and + `ObjectKind::Snapshot` among the kinds), `store_hash::envelope_bytes(kind: + store_hash::ObjectKind, payload: &[u8]) -> Vec`, `store_hash::object_name(kind: + store_hash::ObjectKind, payload: &[u8]) -> String`, and + `store_hash::verify_object(name: &str, bytes: &[u8]) -> Result<...>` to verify an + already-present object during publication. This module never reimplements the + envelope format, canonical JSON, or hashing, and never hand-rolls a SHA-256. +- **`store_objects`** (`../store_objects`) supplies the snapshot type whose spine + this module walks during recovery. It uses `store_objects::Snapshot` with its + public `parents` and `seq` fields to decide whether a marker's snapshot is an + ancestor of, or equal to, HEAD, reading each snapshot object through this module's + own provider. This module walks only the snapshot spine (parents and seq) and + never deserializes module-state or event bodies to answer an ancestry question. +- **`store_reduce`** (`../store_reduce`) supplies the read trait this module + implements. It uses `store_reduce::ObjectProvider` with exactly its two methods + `object_bytes(&self, hash: &str) -> Option>` and `head_snapshot_hash(&self) + -> Option`. This module implements that trait, and only that trait, for a + disk-backed provider; it never adds a third method to the trait and never widens + its contract. +- **`store_txn_core`** (`../store_txn_core`) supplies every marker and lock type, the + canonical byte serialization of each, and every recovery, lock-lifecycle, + install-planning, and path-classification decision. It uses + `store_txn_core::CommitMarker`, `store_txn_core::AttemptMarker`, + `store_txn_core::LockFile`, `store_txn_core::InstallStep`; the canonical byte + functions `store_txn_core::canonical_commit_marker(marker: + &store_txn_core::CommitMarker) -> Vec`, + `store_txn_core::canonical_attempt_marker(marker: &store_txn_core::AttemptMarker) -> + Vec`, and `store_txn_core::canonical_lock(lock: &store_txn_core::LockFile) -> + Vec`, which own the on-disk bytes of a marker or lock; and the decision functions + `store_txn_core::classify_marker`, `store_txn_core::attempt_disposition`, + `store_txn_core::plan_install_steps`, `store_txn_core::parse_lock`, + `store_txn_core::acquire_decision`, `store_txn_core::release_decision`, + `store_txn_core::liveness_decision`, `store_txn_core::steal_decision`, and + `store_txn_core::classify_path`. This module performs syscalls and hands their + observed facts to those pure functions; it never reimplements a decision the core + owns, and never re-derives or inlines a marker or lock serialization the core + defines: every marker and lock byte string it writes comes from the core's canonical + byte functions. +- **`store_tree`** (`../store_tree`) supplies the generated-tree diff that the + installer feeds to `plan_install_steps`. It uses `store_tree::Tree`, + `store_tree::diff_trees`, and `store_tree::TreeChange`. This module obtains the + install work list from the tree diff and never reimplements tree diffing. -## Content hashes are plain strings +## The store layout -Every content hash, whether a legacy file hash out of the journal or a -content-addressed object name, is carried as a `String`, never a newtype. A legacy -file hash and an object name are both sixty-four lowercase hexadecimal characters -(`0` through `9`, `a` through `f`), but this module compares them only for equality -and never recomputes a legacy file hash: the journal's `hashBefore` and `hashAfter` -are authoritative recorded values, and reconstructing historical file content is -impossible (the working tree holds only the latest content), so classification is a -pure comparison of recorded hashes and the current file hash, never a patch -application. +A store root is a directory holding, exactly as the design fixes it: -## The parsed journal +- `objects//`: one plain file per object, + its bytes the object's full envelope, committed to git. The object's name is the + hex SHA-256 of those envelope bytes; the two-character prefix is the fan-out + directory and the remaining sixty-two characters are the file name. +- `objects/pack/pack-.naplpack` and `objects/pack/pack-.idx`: packed + objects and their index, committed to git, produced only by the repack + operation below. Loose files are the only write path; packs are immutable + once written and named by their own content hash. +- `HEAD`: a single line, the current snapshot hash followed by one newline. The only + mutable committed file in the store. +- `INSTALLED`: a single line, the snapshot hash the live tree was last fully + materialized to, followed by one newline. Gitignored. +- `markers/.json`: per-transaction commit markers. Gitignored. +- `attempts/.json`: per-writer attempt markers. Gitignored. +- `locks/.lock`: lock files. Gitignored. +- `tmp/`: staging and publication scratch. Gitignored and never traversed as store + content. -A legacy journal is a sequence of newline-delimited JSON lines. Only the fields that -classification needs are read; every other field is ignored, and the untouched line -text is preserved verbatim for its blob. Expose a public struct `FileStep` -(deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with public fields `path: String`, -`hash_before: Option`, and `hash_after: Option`: one file's recorded -before and after hashes in one entry, each `None` when the journal recorded JSON -`null` (a creation has a `None` before; a deletion has a `None` after). +Every path this module derives from a name uses the hash-encoded name only; module +and target names never form a filesystem path, and a name is used verbatim, never +re-cased or re-encoded. -Expose a public struct `JournalEntry` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`) with public fields `line: u64` (the one-based line number in the file), `gen: -u64`, `module: String`, `target: Option`, `files: Vec`, and `raw: -String` (the line's exact original text, without its terminating newline). Expose a -public struct `CorruptLine` (same derives) with public fields `line: u64` and `raw: -String`. +## The disk provider -Expose a public struct `ParsedJournal` (same derives) with public fields `entries: -Vec` and `corrupt: Vec`, both in ascending line order. -Expose `parse_journal(text: &str) -> ParsedJournal`. Split `text` on the newline -character into lines numbered from one; a trailing empty line produced by a final -newline is not a line and is dropped, and an entirely empty input yields an empty -journal. For each line, attempt to read it as a JSON object carrying an integer -`gen`, a non-empty string `module`, and an array `files` whose every element is an -object with a string `path` and both a `hashBefore` field and a `hashAfter` field, -each present and carrying either JSON `null` or a string. Both hash fields must be -present: presence is required and an absent field is never coerced to `null`. A JSON -`null` is a legitimate recorded value that a present field carries, a `None` before -marking a creation and a `None` after marking a deletion, but a file element that -omits either `hashBefore` or `hashAfter` entirely fails the shape check, because a -step whose before or after was never recorded cannot be verified and must not pass as -a creation or a deletion. A line meeting that shape becomes a `JournalEntry` (its -`target` taken from a string `target` field when present, otherwise `None`; its -`raw` the verbatim line text); any line that is not valid JSON, is not a JSON -object, or fails any of those shape checks, including a file element that omits -either hash field, becomes a `CorruptLine`, which the audit counts and reports as -`unreconstructable` under its `line:` key like every other corrupt line. Unknown extra -fields on the object or on a file element are ignored, never a parse failure: the -real ledger carries `timestamp`, `promptHash`, `promptDiff`, `mode`, and per-file -`patch` fields this module does not read. +Expose a public struct `DiskProvider` holding the store root path, constructed by a +public `DiskProvider::open(root: &std::path::Path) -> DiskProvider` that records the +root and performs no I/O. Implement `store_reduce::ObjectProvider` for it: -::: test -name: a well-formed journal line parses into an entry with its path chain steps -given: { fixture: LINE_VALID } -expect: { parsed_ok: true, gen: 1, module: "greeting", target: "rust", paths: ["g.rs"] } -::: +- `object_bytes(&self, hash: &str)` reads the file at `objects//` + under the root and returns its raw bytes as `Some(Vec)`; when that file does + not exist it consults each pack index under `objects/pack/` in lexicographic + pack-name order and returns the envelope bytes sliced from the first pack whose + index names the hash, or `None` when neither a loose file nor any pack holds it. + Loose always wins over packed, so a reader racing a repack sees the object in + one place or the other, never in neither. The bytes are returned exactly as they + sit on disk, never re-framed and never verified here; the reducer verifies them. +- `head_snapshot_hash(&self)` reads the `HEAD` file, strips a single trailing + newline if present, and returns `Some(String)` of the remaining hash, or `None` + when `HEAD` does not exist. It trims exactly the trailing newline and returns the + hash without it. -::: test -name: a journal line that is not valid json is a corrupt line -given: { fixture: LINE_CORRUPT_JSON } -expect: { parsed_ok: false, corrupt: true } -::: +The disk provider's two answers are byte-for-byte identical to an in-memory +provider built from the same objects and HEAD: a fixture that queries both over the +same store must find them equal on every name and on the head. ::: test -name: a json line missing the required gen module or files shape is a corrupt line -given: { fixture: LINE_CORRUPT_SHAPE } -expect: { parsed_ok: false, corrupt: true } +name: object bytes over a present blob returns its exact envelope bytes +given: { fixture: READ_BLOB } +expect: { object_bytes: "napl-store blob v1 21\nThe greeting module.\n" } ::: ::: test -name: a journal line carrying extra fields beside the classification fields still parses -given: { fixture: LINE_EXTRA_FIELDS } -expect: { parsed_ok: true, gen: 3, module: "greeting", paths: ["g.rs"] } +name: object bytes over a present snapshot returns its exact envelope bytes +given: { fixture: READ_SNAPSHOT } +expect: { object_bytes: "napl-store snapshot v1 99\n{\"parents\":[\"9999999999999999999999999999999999999999999999999999999999999999\"],\"root\":{},\"seq\":0}\n" } ::: ::: test -name: a journal line whose file element omits the hashBefore field entirely is a corrupt line -given: { fixture: LINE_MISSING_BEFORE } -expect: { parsed_ok: false, corrupt: true } +name: object bytes over an absent name returns none +given: { fixture: READ_ABSENT } +expect: { object_bytes: none } ::: ::: test -name: a journal line whose file element omits the hashAfter field entirely is a corrupt line -given: { fixture: LINE_MISSING_AFTER } -expect: { parsed_ok: false, corrupt: true } +name: head snapshot hash over a store with HEAD returns the trimmed hash +given: { fixture: READ_HEAD_PRESENT } +expect: { head_hash: "b7281259c1fa6f5b9fa3b295d6ee09705ddb02a3387bdd111888d697b63a4242" } ::: ::: test -name: an explicit null hashBefore parses as a valid creation step keeping its recorded hashAfter -given: { fixture: LINE_NULL_BEFORE } -expect: { parsed_ok: true, gen: 1, module: "greeting", paths: ["c.rs"], hash_before: null, hash_after: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } +name: head snapshot hash over a store without HEAD returns none +given: { fixture: READ_HEAD_ABSENT } +expect: { head_hash: none } ::: ::: test -name: an explicit null hashAfter parses as a valid deletion step keeping its recorded hashBefore -given: { fixture: LINE_NULL_AFTER } -expect: { parsed_ok: true, gen: 1, module: "greeting", paths: ["d.rs"], hash_before: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", hash_after: null } +name: the disk provider and an in-memory provider answer identically over the fixture store +given: { fixture: PARITY } +expect: { providers_agree: true } ::: -## The five classifications - -A **path chain** is every recorded step for one file path across the whole journal, -in ascending `gen` order. Expose a public struct `ChainStep` (deriving `Debug`, -`Clone`, `PartialEq`, `Eq`) with public fields `gen: u64`, `hash_before: -Option`, and `hash_after: Option`. - -Expose `classify_chain(steps: &[ChainStep], current_hash: Option<&str>) -> -store_objects::LegacyClassification`. The `steps` are already in ascending `gen` -order and non-empty. The result is the worst condition that holds, under the -severity order `Exact` (least severe), then `MissingPrefix`, then -`InconsistentBefore`, then `InconsistentAfter`, then `Unreconstructable` (most -severe). A path chain never returns `Unreconstructable`; that classification is -reserved for corrupt lines, which carry no parseable path. Compute the result by -starting at `Exact` and taking the worse of the running result and each condition -that applies: - -- **MissingPrefix** applies when the first step's `hash_before` is not `None`: the - chain begins mid-history, its creating prefix removed from the ledger (this is the - surgical-removal scar's expected shape, accepted and documented). -- **InconsistentBefore** applies when some step after the first has a `hash_before` - that is not equal to the immediately preceding step's `hash_after`: an internal - linkage break. -- **InconsistentAfter** applies when the last step's `hash_after` is not equal to - `current_hash` (comparing an `Option`, so a recorded `None` terminal matches a - `None` current, and any mismatch, including present-versus-absent, applies): the - ledger's final recorded state disagrees with the file on disk now. +## Packed objects and the repack operation -When none of these applies the chain is `Exact`: it begins at a creation, links -step to step, and ends at the current file. Taking the worse of the applicable -conditions means a chain that both begins mid-history and ends disagreeing with the -current file classifies as `InconsistentAfter`, the more severe of the two. +A pack is many objects in one file, exactly git's answer to a store of +thousands of loose files, kept radically simpler: one pack holds the +world. The pack file's bytes are a header line `napl-store pack v1 +\n` followed by every member object's envelope bytes +concatenated in ascending object-name order, nothing between them, no +compression, no timestamps. The pack's name is the hex SHA-256 of the +pack file's own bytes, so a pack is self-addressing exactly as an +object is, and two repacks over the same object set produce +byte-identical packs with the same name. Beside every pack sits its +index `pack-.idx`, canonical JSON mapping each member's object +name to `[offset, length]` of its envelope inside the pack, keys +sorted; the index is a pure derivation of the pack and is regenerable +from it, so a missing or corrupt index is repaired by rereading the +pack, never a data loss. -Expose `classification_severity(classification: store_objects::LegacyClassification) --> u8`, returning `0` for `Exact`, `1` for `MissingPrefix`, `2` for -`InconsistentBefore`, `3` for `InconsistentAfter`, and `4` for `Unreconstructable`, -the total severity order the classifier and the abort comparison both use. +Repack is one operation with two callers, the toolchain's gc verb and +the toolchain's automatic threshold below; the store exposes the +operation and holds no policy: it enumerates the +reachable closure (gc's own definition), writes one new pack holding +every reachable object, links it in through the same tmp-then-no-replace +protocol publication uses, and only after the new pack and index are +durable deletes the loose files it packed and every older pack. +Ordering makes the race benign: an object is always readable loose or +packed at every instant, and a crash between link and cleanup leaves +duplicates, never absence; the next repack or fsck reconciles. +Publication after a repack keeps writing loose files, the only write +path; packs are immutable and never appended to. Unreachable objects +are never packed: repack subsumes sweeping, so gc with apply is one +operation, pack the reachable, remove the rest. -::: test -name: an exact creation chain classifies as exact -given: { fixture: CH_EXACT_CREATE } -expect: { classification: "exact" } -::: +fsck counts an object present when it is loose or when a pack's index +names it and the sliced bytes verify against the object name; it +verifies each pack's own name against the pack bytes and each index +against its pack, and reports a pack and index that disagree loudly. +An object present both loose and packed is healthy duplication, not a +finding. ::: test -name: an exact multi-step chain classifies as exact -given: { fixture: CH_EXACT_MULTI } -expect: { classification: "exact" } +name: a repacked store answers every reachable object identically to before +given: { fixture: REPACK_PARITY } +expect: { all_reachable_object_bytes_equal: true, loose_files_remaining: 0, packs: 1 } ::: ::: test -name: a chain whose first step has a non-null before classifies as missing-prefix -given: { fixture: CH_MISSING_PREFIX } -expect: { classification: "missing-prefix" } +name: two repacks over the same object set produce byte-identical packs +given: { fixture: REPACK_DETERMINISM, runs: 2 } +expect: { pack_bytes_equal: true, pack_names_equal: true } ::: ::: test -name: a chain with an internal linkage break classifies as inconsistent-before -given: { fixture: CH_INCONSIST_BEFORE } -expect: { classification: "inconsistent-before" } +name: a reader mid-repack finds every object loose or packed never absent +given: { fixture: REPACK_RACE, read_at: after_pack_link_before_loose_cleanup } +expect: { all_objects_readable: true, duplicates_tolerated: true } ::: ::: test -name: a chain whose terminal after disagrees with the current hash classifies as inconsistent-after -given: { fixture: CH_INCONSIST_AFTER } -expect: { classification: "inconsistent-after" } +name: a corrupt or missing index is regenerated from its pack +given: { fixture: PACK_INDEX_CORRUPT } +expect: { index_regenerated: true, reads_succeed: true, data_loss: false } ::: ::: test -name: a missing-prefix start whose terminal also disagrees takes the worse inconsistent-after -given: { fixture: CH_MISSING_PREFIX_AND_AFTER } -expect: { classification: "inconsistent-after" } +name: fsck verifies pack and index and counts packed objects present +given: { fixture: FSCK_PACKED } +expect: { packed_objects_present: true, pack_name_verified: true, findings_on_healthy: 0 } ::: ::: test -name: the classification severity order runs exact below missing-prefix below inconsistent-before below inconsistent-after below unreconstructable -given: { probe: severity_order } -expect: { ordered: true } +name: unreachable objects are not packed and are removed by repack +given: { fixture: REPACK_SWEEPS } +expect: { unreachable_in_pack: 0, unreachable_loose_remaining: 0, reachable_kept: true } ::: -## The audit result and its report - -Expose a public struct `AuditResult` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`) with public fields `entries: u64` (the count of parseable entries), -`corrupt_lines: Vec` (the one-based line numbers of corrupt lines, ascending), -`chains: std::collections::BTreeMap` -(each distinct path mapped to its chain classification; a `BTreeMap` so paths are in -sorted order), and `current_state_only: Vec` (the module names that the caller -declared in the module inventory but that appear in no parseable journal entry, in -ascending byte order and deduplicated). A current-state-only module is the emptied-mapl -scar seen from the audit: a module the repository still carries on disk yet whose -recorded history is entirely gone, so the ledger holds no event for it. This is a -distinct fact from any path chain, and it is never folded into `chains`, the corrupt -lines, or the per-classification counts. +## Object publication -Expose `audit(journal: &ParsedJournal, current_hashes: -&std::collections::BTreeMap, inventory: -&std::collections::BTreeSet) -> AuditResult`. Group every entry's -`files` by `path` into path chains, each chain's steps built from the entries in -ascending `gen` order, and classify each chain with `classify_chain` against the -path's current hash (looked up in `current_hashes`, absent meaning `None`). The -`corrupt_lines` are the corrupt lines' numbers, and `entries` is the count of -parseable entries. The `inventory` is the caller's declared module set (the modules the -map declares); `current_state_only` is exactly the inventory modules that are the -`module` of no parseable journal entry, in ascending order, so a module the inventory -declares but the journal never mentions is surfaced as current-state-only. An empty -inventory yields an empty `current_state_only`, and a module present in even one -parseable entry is never current-state-only. Corrupt lines, whose module cannot be -read, never remove a module from the current-state-only set. +Expose a public function `publish_object(root: &std::path::Path, kind: +store_hash::ObjectKind, payload: &[u8], owner_token: &str) -> Result`. It publishes an object exactly once and returns the object's name. +The owner token names the unique temp file, so publication is deterministic under a +caller-supplied token and never invents randomness of its own. The protocol, in +order: -Expose `render_audit_report(result: &AuditResult) -> String`, the maintainer's -sanction artifact, deterministic and fully sorted. The exact grammar, every literal -byte-for-byte: +1. Frame the envelope with `store_hash::envelope_bytes` and compute the name with + `store_hash::object_name`. +2. Write the envelope bytes to a uniquely named file under `tmp/` (named by the + owner token), fsync the file. +3. Ensure the fan-out directory `objects//` exists. +4. No-replace link the temp file into `objects//` using a link + that fails rather than replaces when the target already exists. On success, fsync + the fan-out directory and unlink the temp file, then return the name. +5. On the already-exists condition, read the existing object's bytes and verify them + against the expected name with `store_hash::verify_object`: when they match, the + object was already published, so unlink the temp file and return the name (an + idempotent success that leaves the existing object untouched); when they do not + match, return a loud `StoreIoError::Integrity` naming the object, and never + replace the existing bytes. -- The first line is exactly `napl migrate audit v1`. -- Then `entries ` followed by the decimal `entries` count. -- Then `corrupt-lines ` followed by the decimal count of `corrupt_lines`. -- Then `chains ` followed by the decimal total chain count, which is the number of - distinct path chains plus the number of corrupt lines. -- Then the five per-classification count lines, in the fixed order `exact`, - `missing-prefix`, `inconsistent-before`, `inconsistent-after`, - `unreconstructable`, each the wire token, a space, and the decimal count of chains - in that class. The `unreconstructable` count is the number of corrupt lines, since - only corrupt lines are unreconstructable; the other four count path chains. These - five counts sum to the total chain count. -- Then one blank line. -- Then the anomaly lines, one per non-`exact` chain, each a classification wire - token, a space, and the chain's key: a path chain's key is its path, and a corrupt - line's key is `line:` followed by its one-based line number. All anomaly lines are - sorted together by ascending byte order of the full rendered line. -- Then, after the chain and corrupt anomaly lines and before the verdict, the - current-state-only lines, one per module in `current_state_only`, each exactly the - fixed token `current-state-only`, a space, and the module name, sorted by ascending - byte order of the module name. These are a distinct group from the chain anomaly - lines above: the scar is a module-level fact, so it renders as its own - `current-state-only ` line and is never merged into a path chain's line. -- Then the final line, exactly `verdict clean` when there are no anomaly lines and no - current-state-only lines, and exactly `verdict anomalies` when there is at least one - anomaly line or at least one current-state-only line. A current-state-only module is - a scar the maintainer must see, so it alone flips the verdict to `anomalies`. +A crash anywhere in this protocol leaves only verified-or-invisible objects: the +store never holds a partially written or unverified object under a real name. -Every line, including the last, is terminated by a single newline, so the report -ends in a newline. Lines are joined by single newlines and there is exactly one -blank line, the one before the anomalies. An all-`exact` journal with no corrupt -lines and no current-state-only modules renders the blank line immediately followed by -`verdict clean`; when `current_state_only` is empty the report is byte-for-byte what it -was before the inventory existed, so a healthy ledger's report is unchanged. +::: test +name: publishing a new blob returns the object name of its envelope +given: { fixture: PUBLISH_NEW } +expect: { published_name: "0965493c60ab6b118535fc59c5a793c753295acd9fd8d75e084288459624fe93" } +::: ::: test -name: the audit report of an all-exact journal renders its pinned clean bytes -given: { fixture: JRN_CLEAN } -expect: { report_bytes: "napl migrate audit v1\nentries 2\ncorrupt-lines 0\nchains 2\nexact 2\nmissing-prefix 0\ninconsistent-before 0\ninconsistent-after 0\nunreconstructable 0\n\nverdict clean\n" } +name: publishing a new blob writes its exact envelope bytes at its fanout path +given: { fixture: PUBLISH_NEW } +expect: { object_file_bytes: "napl-store blob v1 21\nThe greeting module.\n" } ::: ::: test -name: the audit report of a mixed journal renders its pinned anomaly bytes -given: { fixture: JRN_MIXED } -expect: { report_bytes: "napl migrate audit v1\nentries 5\ncorrupt-lines 1\nchains 5\nexact 1\nmissing-prefix 1\ninconsistent-before 1\ninconsistent-after 1\nunreconstructable 1\n\ninconsistent-after d.rs\ninconsistent-before c.rs\nmissing-prefix b.rs\nunreconstructable line:6\nverdict anomalies\n" } +name: publishing an object that already exists with matching bytes is an idempotent success +given: { fixture: PUBLISH_IDEMPOTENT } +expect: { published_name: "0965493c60ab6b118535fc59c5a793c753295acd9fd8d75e084288459624fe93" } ::: ::: test -name: an inventory module absent from the journal audits as a distinct current-state-only scar -given: { fixture: AUDIT_CURRENT_ONLY } -expect: { current_state_only: ["greeting"], report_bytes: "napl migrate audit v1\nentries 0\ncorrupt-lines 0\nchains 0\nexact 0\nmissing-prefix 0\ninconsistent-before 0\ninconsistent-after 0\nunreconstructable 0\n\ncurrent-state-only greeting\nverdict anomalies\n" } +name: publishing over an existing object whose bytes mismatch the expected hash is a loud integrity error +given: { fixture: PUBLISH_MISMATCH } +expect: { error: Integrity } ::: -## Building the migration objects +## HEAD read and compare-and-set -Migration turns the ledger into content-addressed objects. Expose a public struct -`StagedObject` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with public fields -`name: String` (the object's SHA-256 name) and `envelope: Vec` (its full -envelope bytes, ready to write). Every builder frames its payload through -`store_hash` and returns a `StagedObject` whose `name` equals the matching -`store_objects` naming function over the same value. +Expose `read_head(root: &std::path::Path) -> Result, StoreIoError>` +returning the current snapshot hash (trailing newline trimmed) or `None` when HEAD +is absent, and `write_head(root: &std::path::Path, snapshot: &str) -> Result<(), +StoreIoError>` writing the snapshot hash plus one trailing newline through a temp +file and an atomic rename over `HEAD`. -- Expose `line_blob(raw_line: &str) -> StagedObject`: a `blob` whose payload is the - exact UTF-8 bytes of `raw_line` (the verbatim original journal line, no trailing - newline). Its `envelope` is `store_hash::envelope_bytes(ObjectKind::Blob, bytes)` - and its `name` is `store_hash::object_name(ObjectKind::Blob, bytes)`. A blob's - payload is raw bytes, never canonical JSON. -- Expose `legacy_event(module: &str, target: Option<&str>, ordinal: u64, - classification: store_objects::LegacyClassification, original_line: &str, - timestamp: &str, parents: Vec) -> store_objects::Event`. It builds the - legacy event for one journal line: `parents` as given (the module's prior legacy - event names, empty at the module's first legacy event); `module` as given; `target` - as given; `ordinal` as given (the entry's `gen`); `timestamp` as given (the entry's - own recorded timestamp, so no clock is read); `engine` the empty string (a legacy - entry named no engine); `state_before` and `state_after` each - `Some(store_objects::StateRef::Unknown)`, the unknown sentinel, because no - historical module-state can be reconstructed; and `payload` - `store_objects::EventPayload::Legacy(store_objects::LegacyFields { original_line: - original_line.to_string(), classification })`, where `original_line` is the name - of the line's verbatim blob. -- Expose `event_object(event: &store_objects::Event) -> StagedObject`: serialize the - event to a `serde_json::Value`, frame its canonical payload through `store_hash` - under `ObjectKind::Event`, and return the `StagedObject`. Its `name` equals - `store_objects::event_object_name(event)`. -- Expose `module_state_object(state: &store_objects::ModuleState) -> StagedObject`: - the same framing under `ObjectKind::ModuleState`; its `name` equals - `store_objects::module_state_object_name(state)`. -- Expose `snapshot_object(snapshot: &store_objects::Snapshot) -> StagedObject`: the - same framing under `ObjectKind::Snapshot`; its `name` equals - `store_objects::snapshot_object_name(snapshot)`. +The compare-and-set is the HEAD critical section, and it is a real mutual-exclusion +region, not a bare read-then-write. The critical section is the lock named `HEAD`, +whose file is `locks/HEAD.lock`, taken through the exclusive-creation lock primitive +of the "Locks and stealing" section below. The entire read-compare-write runs while +that lock is held: acquire `locks/HEAD.lock`, re-read HEAD under the lock, compare, +write `next` only on a match, and release the lock before returning on every path, +success or conflict. Two writers can therefore never both observe the expected value +and both win; the second to enter the critical section re-reads the HEAD the first +one wrote and conflicts. + +Expose `compare_and_set_head(root: &std::path::Path, expected: Option<&str>, next: +&str) -> Result<(), StoreIoError>`. Holding `locks/HEAD.lock` it re-reads HEAD, and +only when the current value equals `expected` (both `None` for a first-ever HEAD, or +both the same hash) does it write `next`; when the current value differs it returns a +loud `StoreIoError::HeadConflict` carrying the expected and the found values and +leaves `HEAD` exactly as it was. The normative durability order for a HEAD move is +exactly: write the temp file, fsync the temp file, rename over HEAD, fsync the store +directory; a compare-and-set that conflicts performs no write at all. The lock is +released before the function returns whether it wrote, conflicted, or failed. + +Expose also `compare_and_set_head_observed(root: &std::path::Path, expected: +Option<&str>, next: &str, on_locked: &mut dyn FnMut(&std::path::Path)) -> Result<(), +StoreIoError>`, which is `compare_and_set_head` with one added seam: once the HEAD +lock is held and before it is released, it calls `on_locked` exactly once with the +store root, on both the success and the conflict paths, so a caller can observe that +the critical section is genuinely held across the read-compare-write. Both functions +share one implementation and one lock discipline, and `compare_and_set_head` is +`compare_and_set_head_observed` with a callback that does nothing. ::: test -name: a verbatim journal line frames its blob envelope and names it -given: { fixture: LINE_A } -expect: { envelope_bytes: "napl-store blob v1 168\n{\"gen\":1,\"module\":\"greeting\",\"target\":\"rust\",\"files\":[{\"path\":\"g.rs\",\"hashBefore\":null,\"hashAfter\":\"2222222222222222222222222222222222222222222222222222222222222222\"}]}", object_name: "ed2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360" } +name: writing HEAD stores the snapshot hash as one newline-terminated line +given: { fixture: SET_HEAD } +expect: { head_file_bytes: "b7281259c1fa6f5b9fa3b295d6ee09705ddb02a3387bdd111888d697b63a4242\n" } ::: ::: test -name: a missing-prefix legacy event canonicalizes to its pinned bytes and name -given: { fixture: LEG_MISSING } -expect: { canonical_bytes: "{\"engine\":\"\",\"module\":\"greeting\",\"ordinal\":1,\"parents\":[],\"payload\":{\"fields\":{\"classification\":\"missing-prefix\",\"originalLine\":\"5555555555555555555555555555555555555555555555555555555555555555\"},\"kind\":\"legacy\"},\"stateAfter\":\"unknown\",\"stateBefore\":\"unknown\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n", object_name: "fba5e4f9532e4dd01d1f9571b46afafced354533db1730806acb703643fd05f4" } +name: compare-and-set HEAD from the expected snapshot to a new one rewrites HEAD +given: { fixture: CAS_SUCCESS } +expect: { head_file_bytes: "289f591aec3ebd915526648ad486d6b028dca5200e9c392b7fdaf4c498f6ec09\n" } ::: ::: test -name: an exact target-scoped chained legacy event canonicalizes to its pinned bytes and name -given: { fixture: LEG_EXACT } -expect: { canonical_bytes: "{\"engine\":\"\",\"module\":\"greeting\",\"ordinal\":2,\"parents\":[\"fba5e4f9532e4dd01d1f9571b46afafced354533db1730806acb703643fd05f4\"],\"payload\":{\"fields\":{\"classification\":\"exact\",\"originalLine\":\"ed2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360\"},\"kind\":\"legacy\"},\"stateAfter\":\"unknown\",\"stateBefore\":\"unknown\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n", object_name: "65407002b376af4c707dbab5e9a668c743cffb1bd14ef427ca64cf49be8c4fff" } +name: compare-and-set HEAD fails loudly when HEAD no longer holds the expected snapshot +given: { fixture: CAS_CONFLICT } +expect: { error: HeadConflict } ::: ::: test -name: a working-file-seeded module-state canonicalizes to its pinned bytes and name -given: { fixture: MS_SEED } -expect: { canonical_bytes: "{\"blockIndex\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"mapl\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"promptBlob\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"targets\":{\"rust\":{\"attribution\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"generatedTree\":\"3333333333333333333333333333333333333333333333333333333333333333\"}}}\n", object_name: "e4351cea324d75b2362a7321008ddc4d9b98b0ea13f57650445dab61bb0aa272" } +name: a conflicting compare-and-set leaves HEAD unchanged +given: { fixture: CAS_CONFLICT } +expect: { head_file_bytes: "b7281259c1fa6f5b9fa3b295d6ee09705ddb02a3387bdd111888d697b63a4242\n" } ::: ::: test -name: the final migration snapshot over a genesis parent canonicalizes to its pinned bytes and name -given: { fixture: SNAP } -expect: { canonical_bytes: "{\"parents\":[\"0000000000000000000000000000000000000000000000000000000000000000\"],\"root\":{\"greeting\":{\"eventHead\":\"65407002b376af4c707dbab5e9a668c743cffb1bd14ef427ca64cf49be8c4fff\",\"moduleState\":\"e4351cea324d75b2362a7321008ddc4d9b98b0ea13f57650445dab61bb0aa272\"}},\"seq\":2}\n", object_name: "8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845" } +name: compare-and-set holds the HEAD lock across its critical section and releases it before returning +given: { fixture: CAS_LOCK_HELD } +expect: { head_lock_held_during: true } ::: -## The staged store +::: test +name: under the HEAD lock two serialized compare-and-sets let exactly one win and the loser re-reads and conflicts +given: { fixture: CAS_SERIALIZED_LOSER } +expect: { error: HeadConflict } +::: -Expose a public struct `StagedStore` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`) with public fields `objects: std::collections::BTreeMap>` -(every object to write, keyed by name to its envelope bytes, so duplicate names -collapse to one entry and iteration is sorted) and `head: String` (the name of the -final snapshot, the value the store's `HEAD` is set to). +## Locks and stealing -Expose `build_staged_store(journal: &ParsedJournal, audit: &AuditResult, seeds: -&std::collections::BTreeMap, genesis_parent: -&str) -> StagedStore`. It assembles, in this order: +All locks share one primitive: a lock named `` is the file `locks/.lock`, +taken by exclusive atomic creation that fails rather than replaces when the file +already exists, its bytes the core's canonical lock serialization of a +`store_txn_core::LockFile` (owner token, pid, process start time, host, and purpose). +Lock keys are hash-encoded or fixed literals; a module or target name never forms a +lock path. The named locks this module takes are `HEAD` (the commit critical section), +`install` (live-tree materialization and recovery), and `janitor` (litter collection +and the sole authority under which a steal happens); a domain lock is named by its +module. -- For every parseable entry, in file order, a verbatim line blob (`line_blob` over - the entry's `raw`) and one legacy event. The event's classification is the worst - classification among the entry's file paths, read from `audit.chains` under the - severity order (an entry touching no files, or only paths absent from - `audit.chains`, takes `Exact`). Its `original_line` is that entry's line blob name. - Its `parents` chain per module: an entry's legacy event lists the immediately - preceding legacy event name for the same `module`, so each module forms one linear - event chain in `gen` order; the first entry of a module has empty `parents`. Each - event is framed through `event_object` and inserted into `objects`; the last - event name for each module is that module's event head. -- For every corrupt line, a verbatim line blob and one legacy event whose module and - ordinal cannot be read from the line: such an event is not emitted into any - module's chain, and the corrupt line's verbatim blob is still inserted into - `objects` so nothing is lost. Corrupt lines contribute no root-map entry. -- For every module present in `seeds`, without exception, the seeded module-state - (built by the caller from the current working files) framed through - `module_state_object` and inserted into `objects`, and a `store_objects::RootEntry` - whose `module_state` is that object's name and whose `event_head` is the module's - last legacy event name when the module has at least one parseable legacy event, or - the `genesis_parent` sentinel when the module has no legacy event at all. A module - present in `seeds` but absent from every journal entry is a current-state-only - module: its history was lost yet its current state survives on disk (the - emptied-mapl scar), and it must still reach the snapshot's root. Every such seed - contributes a root entry pointing its `event_head` at `genesis_parent`, the - all-zeros genesis sentinel, which the store's reducer reads as the empty-history - boundary for that module: a current-state-only root entry has no recorded event, so - the reducer answers its event-chain as empty, its classification as current-state-only, - and its ordinal lookup as absent, all by recognizing that sentinel value rather than - by fetching a nonexistent event. The snapshot's `root` therefore names every seeded - module and drops none. This assembly fabricates no event for a current-state-only - module: it reuses the genesis sentinel already handed in, and it never invents a - legacy event, a timestamp, or a chain the journal did not record. -- One final `store_objects::Snapshot` whose `parents` is the single-element vector - `[genesis_parent.to_string()]`, whose `seq` is the greatest entry `gen` in the - journal (or one when the journal has no entries), and whose `root` is the sorted - map of module name to root entry. It is framed through `snapshot_object`, inserted - into `objects`, and its name is the `StagedStore`'s `head`. +Expose `acquire_lock(root: &std::path::Path, name: &str, this_host: &str, is_alive: +&dyn Fn(u64) -> bool) -> Result` and `release_lock(guard: +LockGuard) -> Result<(), StoreIoError>`, where a `LockGuard` is a held-lock handle +carrying the lock name and owner token; releasing it checks ownership through +`store_txn_core::release_decision` and removes `locks/.lock`. Acquisition tries +the exclusive create; on success the lock is held. When the file already exists, the +holder's liveness is judged, never assumed: read the existing bytes, parse them with +`store_txn_core::parse_lock`, and, for a parsed lock, apply +`store_txn_core::liveness_decision` with the lock's host, `this_host`, and the result +of the injected `is_alive` probe over the lock's pid; a lock that fails to parse is +held-and-stale-eligible per the core, and a lock on a foreign host is never judged +dead. The liveness of a lock is exactly what these pure functions and the injected +probe say: this module never reads a wall clock and never shells out to a process tool +to decide it. -The `genesis_parent` is a parameter, never invented here: it is the name of the -absent parent that marks the genesis boundary, and its production value is supplied -by the caller. The whole assembly is deterministic given its inputs. +Feed the liveness verdict to `store_txn_core::steal_decision`. A live or foreign +holder is refused: the lock is left exactly as it was and acquisition fails with +`StoreIoError::Io`. A dead-local or malformed holder is stealable, but a steal is +never a bare delete-then-create and never happens outside the janitor lock. To steal: +acquire the `janitor` lock first; under it, re-read, re-parse, and re-judge the stale +lock so a holder that revived is not stolen; then atomically rename `locks/.lock` +aside to `locks/.lock.stolen`, a rename and never an unlink of the live path, +and exclusively create the fresh `locks/.lock`; then release the janitor lock. +The module never tells `steal_decision` the janitor lock is held unless it holds it. +When the janitor lock itself cannot be taken, the module does not steal: it leaves the +stale lock in place and fails with `StoreIoError::Io`, so two recoverers can never race +a steal. ::: test -name: the staged store of a two-entry greeting journal names its head snapshot -given: { fixture: STAGED_GREETING } -expect: { head: "8e640fcf87640f7432fa44c8e44b81686ad4b6c67dccebe573c463d7fb8172c0" } +name: acquiring a lock over a dead local holder steals it by renaming the stale lock aside under the janitor lock +given: { fixture: STEAL_SUCCESS } +expect: { stolen_aside_present: true } ::: ::: test -name: a current-state-only seed with no legacy event reaches the snapshot root with a genesis-parent event head -given: { fixture: STAGED_CURRENT_ONLY } -expect: { head: "8ea748a37b92e975faeef61ca5ff0a4fb740c64f48f10f8fb1e706f7ea095a8d", head_object_bytes: "napl-store snapshot v1 271\n{\"parents\":[\"0000000000000000000000000000000000000000000000000000000000000000\"],\"root\":{\"greeting\":{\"eventHead\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"moduleState\":\"e4351cea324d75b2362a7321008ddc4d9b98b0ea13f57650445dab61bb0aa272\"}},\"seq\":1}\n" } +name: a steal is refused when the janitor lock cannot be taken and the stale lock is left untouched +given: { fixture: STEAL_BLOCKED } +expect: { stolen_aside_absent: true } ::: -## The abort comparison - -`napl migrate` refuses to proceed when the live ledger has drifted worse than the -report the maintainer sanctioned. Expose a public enum `MigrateDecision` (deriving -`Debug`, `Clone`, `PartialEq`, `Eq`) with exactly `MigrateDecision::Proceed` and -`MigrateDecision::Abort(Vec)`, the abort carrying the offending keys in -ascending byte order. - -Expose `compare_to_sanctioned(current: &AuditResult, sanctioned: &AuditResult) -> -MigrateDecision`. A key is offending when the current audit is worse there than the -sanctioned audit allows: +## Markers -- For each path in `current.chains`, its classification's severity is compared to - the same path's severity in `sanctioned.chains`; a path absent from - `sanctioned.chains` is treated as sanctioned `Exact` (severity zero). The path is - offending when its current severity is strictly greater than its sanctioned - severity. Its key is the path. -- For each corrupt line number in `current.corrupt_lines` that is not in - `sanctioned.corrupt_lines`, that line is offending. Its key is `line:` followed by - the number. -- For each module in `current.current_state_only` that is not in - `sanctioned.current_state_only`, that module is offending. Its key is - `current-state-only:` followed by the module name. A module in - `sanctioned.current_state_only` is already sanctioned and never offends when it is - also in `current.current_state_only`, and a module in `sanctioned.current_state_only` - that is absent from `current.current_state_only` has disappeared and never offends, - exactly as a chain path or corrupt line present in the sanctioned baseline yet absent - from the current audit is treated as an improvement and never offends: like both other - kinds, this comparison iterates the current audit and measures only where current is - worse than what the sanctioned baseline already accepted. +Commit and attempt markers are written as the decision core's canonical bytes, +produced only by `store_txn_core::canonical_commit_marker` and +`store_txn_core::canonical_attempt_marker`, never re-derived here, at their keyed +paths, and read back into the core's types for recovery. -When there are no offending keys the decision is `Proceed`; otherwise it is -`Abort` carrying the offending keys sorted ascending by byte order across all three key -kinds. A current audit no worse than the sanctioned one at every key proceeds even when -both carry the same anomalies, because the sanctioned report already accepted them. A -current-state-only scar is not merely surfaced for the maintainer: it is a drift the -abort gate measures exactly as a new corrupt line is, so a module that appears -current-state-only in the current audit without the sanctioned baseline having accepted -it aborts, and the new scar is never laundered into a silent proceed. +- `write_commit_marker(root: &std::path::Path, marker: &store_txn_core::CommitMarker) + -> Result<(), StoreIoError>` publishes the commit marker's canonical bytes at + `markers/.json`. A commit marker is immutable, so publication is + no-replace, exactly like an object: write the canonical bytes to a temp file under + `tmp/`, fsync it, then link or create the marker file with a primitive that fails + rather than replaces when the file already exists, and on success fsync the marker + directory. On the already-exists condition, read the existing marker's bytes and + compare them to this marker's canonical bytes: identical bytes are an idempotent + success that leaves the existing file untouched; differing bytes are a loud + `StoreIoError::Integrity` naming the marker's snapshot, and the existing file is never + replaced. The file is keyed by the marker's snapshot hash, and a marker on disk is + never overwritten in place. +- `write_attempt_marker(root: &std::path::Path, owner_token: &str, marker: + &store_txn_core::AttemptMarker) -> Result<(), StoreIoError>` writes the attempt + marker's canonical bytes to `attempts/.json` through an atomic temp + write, a file fsync, a rename, and a directory fsync, in that order. The attempt + marker is the one marker the transaction deliberately replaces in place, atomically, + once per fallible step, so this write is a replacing atomic rename by design; the + bytes on disk are exactly the core's canonical bytes for that marker, and the file is + keyed by the owner token. +- `read_commit_marker(root, snapshot)` and `read_attempt_marker(root, owner_token)` + read those files and parse them back into the core's types, surfacing a parse + failure of a commit marker as a loud `StoreIoError::Corrupt` naming the marker; + they never silently drop a marker they cannot parse. -::: test -name: a current audit equal to the sanctioned baseline proceeds -given: { fixture: CMP_SAME } -expect: { decision: "proceed" } -::: +Markers are immutable and per-transaction: this module never overwrites an existing +commit marker in place and never writes a commit marker whose snapshot names a +finished install; the deliberate pre-HEAD window is honored by writing the marker +before the HEAD rename during a commit. ::: test -name: a current chain worse than its sanctioned classification aborts and names the offending chain -given: { fixture: CMP_WORSE } -expect: { decision: "abort", offending: ["c.rs"] } +name: writing a commit marker stores its canonical bytes +given: { fixture: COMMIT_MARKER } +expect: { marker_file_bytes: "{\"domain\":\"greeting\",\"owner\":\"tok-aaaa\",\"snapshot\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"steps\":[{\"hash\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"mode\":33188,\"op\":\"writeFile\",\"path\":\"greeting/src/lib.rs\"}]}\n" } ::: ::: test -name: a corrupt line absent from the sanctioned baseline aborts and names the offending line -given: { fixture: CMP_NEW_CORRUPT } -expect: { decision: "abort", offending: ["line:4"] } +name: a commit marker is keyed by its snapshot hash under markers +given: { fixture: COMMIT_MARKER } +expect: { marker_path: "markers/1111111111111111111111111111111111111111111111111111111111111111.json" } ::: ::: test -name: an anomalous chain that was exact in the sanctioned baseline aborts and names it -given: { fixture: CMP_NEW_ANOMALY } -expect: { decision: "abort", offending: ["b.rs"] } +name: writing an attempt marker stores its canonical bytes +given: { fixture: ATTEMPT_MARKER } +expect: { attempt_file_bytes: "{\"attemptId\":\"att-0001\",\"diagnostics\":[],\"module\":\"greeting\",\"operation\":\"agent-run\",\"state\":\"inFlight\",\"target\":\"rust\"}\n" } ::: ::: test -name: a current-state-only scar present in current but absent from the sanctioned baseline aborts and names the module -given: { fixture: CMP_NEW_SCAR } -expect: { decision: "abort", offending: ["current-state-only:greeting"] } +name: an attempt marker is keyed by its owner token under attempts +given: { fixture: ATTEMPT_MARKER } +expect: { attempt_path: "attempts/tok-aaaa.json" } ::: ::: test -name: a current-state-only scar present in both current and sanctioned proceeds -given: { fixture: CMP_SAME_SCAR } -expect: { decision: "proceed" } +name: rewriting an identical commit marker is an idempotent success that leaves its bytes untouched +given: { fixture: COMMIT_MARKER_REPUBLISH_IDENTICAL } +expect: { marker_file_bytes: "{\"domain\":\"greeting\",\"owner\":\"tok-aaaa\",\"snapshot\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"steps\":[{\"hash\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"mode\":33188,\"op\":\"writeFile\",\"path\":\"greeting/src/lib.rs\"}]}\n" } ::: ::: test -name: a current-state-only scar present in the sanctioned baseline but gone from current proceeds -given: { fixture: CMP_GONE_SCAR } -expect: { decision: "proceed" } +name: writing a different commit marker over an existing one at the same snapshot is a loud integrity error that replaces nothing +given: { fixture: COMMIT_MARKER_CONFLICT } +expect: { error: Integrity } ::: -## Fixtures the tests pin +## Installation and the installed stamp -The classification fixtures are chains with their current hash. Write the -sixty-four-character hashes as the digit or letter repeated, so `A64` is the letter -`a` sixty-four times, and likewise `B64`, `D64`, `E64`, `F64`, `H1_64` the digit -`1`, `H2_64` the digit `2`, `H9_64` the digit `9`, and `ZERO64` the digit `0`. +Expose `install_to_head(root: &std::path::Path, live_tree: &std::path::Path) -> +Result<(), StoreIoError>`, the monotonic stabilized installer. It reads HEAD as the +target snapshot S, reads the `INSTALLED` stamp (absent means nothing installed yet), +diffs the stamped snapshot's tree against S with `store_tree::diff_trees`, obtains +the ordered install steps from `store_txn_core::plan_install_steps`, applies every +step to the live tree (each file install is a copy of the object payload to a +same-directory temp file, an fsync, and an atomic rename; deletions and new +directory entries are followed by their parent-directory fsyncs; mode changes are +followed by an fsync of the affected file), and only after every step of S is +durable writes the `INSTALLED` stamp equal to S through a temp write, a file fsync, +a rename, and a directory fsync. The stamp is written last, so it never certifies +files that are not durably in place; after writing it the installer re-reads HEAD and +loops when HEAD moved past S, so a slow installer never regresses newer work and the +live tree is never a mix of two snapshots at rest. All steps are idempotent, so a +crash mid-install is repaired by reinstalling the diff between `INSTALLED` and HEAD. -- `CH_EXACT_CREATE`: one step `{ gen 1, before None, after A64 }`, current - `Some(A64)`. -- `CH_EXACT_MULTI`: steps `{ gen 1, before None, after H1_64 }` then `{ gen 2, - before H1_64, after H2_64 }`, current `Some(H2_64)`. -- `CH_MISSING_PREFIX`: one step `{ gen 5, before E64, after B64 }`, current - `Some(B64)`. -- `CH_INCONSIST_BEFORE`: steps `{ gen 1, before None, after H1_64 }` then `{ gen 2, - before H9_64, after H2_64 }`, current `Some(H2_64)`. -- `CH_INCONSIST_AFTER`: one step `{ gen 1, before None, after H1_64 }`, current - `Some(H2_64)`. -- `CH_MISSING_PREFIX_AND_AFTER`: one step `{ gen 5, before E64, after B64 }`, - current `Some(F64)`. Both the missing-prefix start and the terminal mismatch - apply, and the worse `InconsistentAfter` wins. - -The parse fixtures are single lines: - -- `LINE_VALID`: - `{"gen":1,"module":"greeting","target":"rust","files":[{"path":"g.rs","hashBefore":null,"hashAfter":"H2_64"}]}` - with `H2_64` written out, parsing to gen `1`, module `greeting`, target `rust`, - one path `g.rs`. -- `LINE_CORRUPT_JSON`: the text `{ this is not valid json`, not valid JSON. -- `LINE_CORRUPT_SHAPE`: `{"gen":"one","module":"greeting"}`, valid JSON but with a - non-integer `gen` and no `files`. -- `LINE_EXTRA_FIELDS`: - `{"gen":3,"timestamp":"2026-07-24T00:00:00.000Z","module":"greeting","target":"rust","promptHash":"H1_64","promptDiff":"","mode":"full","files":[{"path":"g.rs","patch":"@@ -0,0 +1 @@\n+x","hashBefore":null,"hashAfter":"H2_64"}]}` - with the hashes written out, parsing to gen `3`, module `greeting`, one path - `g.rs`, its extra `timestamp`, `promptHash`, `promptDiff`, `mode`, and per-file - `patch` fields ignored. -- `LINE_MISSING_BEFORE`: - `{"gen":1,"module":"greeting","target":"rust","files":[{"path":"g.rs","hashAfter":"H2_64"}]}` - with `H2_64` written out: valid JSON of otherwise the right shape, except its one - file element omits the `hashBefore` field entirely rather than recording an explicit - `null`, so the line fails the shape check and is a corrupt line, never a creation. -- `LINE_MISSING_AFTER`: - `{"gen":1,"module":"greeting","target":"rust","files":[{"path":"g.rs","hashBefore":"H1_64"}]}` - with `H1_64` written out: the same, its one file element omitting the `hashAfter` - field entirely, so the line is a corrupt line, never a deletion. -- `LINE_NULL_BEFORE`: - `{"gen":1,"module":"greeting","files":[{"path":"c.rs","hashBefore":null,"hashAfter":"A64"}]}` - with `A64` written out: an explicit `null` `hashBefore` present alongside a string - `hashAfter`, parsing to gen `1`, module `greeting`, one path `c.rs`, whose one - `FileStep` has `hash_before` `None` (a valid creation) and `hash_after` `Some` of - `A64`. -- `LINE_NULL_AFTER`: - `{"gen":1,"module":"greeting","files":[{"path":"d.rs","hashBefore":"A64","hashAfter":null}]}` - with `A64` written out: a string `hashBefore` present alongside an explicit `null` - `hashAfter`, parsing to gen `1`, module `greeting`, one path `d.rs`, whose one - `FileStep` has `hash_before` `Some` of `A64` and `hash_after` `None` (a valid - deletion). - -The report fixtures are whole journals with their current-hash map. `JRN_CLEAN` is -these two lines, module `m`, target `rust`: - -- line 1 - `{"gen":1,"module":"m","target":"rust","files":[{"path":"a.rs","hashBefore":null,"hashAfter":"A64"}]}` -- line 2 - `{"gen":2,"module":"m","target":"rust","files":[{"path":"b.rs","hashBefore":null,"hashAfter":"B64"}]}` - -with current hashes `{ a.rs: A64, b.rs: B64 }`, rendering the pinned clean report. -`JRN_MIXED` is these six lines with current hashes `{ a.rs: A64, b.rs: B64, c.rs: -H2_64, d.rs: F64 }`: - -- line 1 - `{"gen":1,"module":"m","target":"rust","files":[{"path":"a.rs","hashBefore":null,"hashAfter":"A64"}]}` - (a.rs exact) -- line 2 - `{"gen":2,"module":"m","target":"rust","files":[{"path":"b.rs","hashBefore":"E64","hashAfter":"B64"}]}` - (b.rs missing-prefix) -- line 3 - `{"gen":3,"module":"m","target":"rust","files":[{"path":"c.rs","hashBefore":null,"hashAfter":"H1_64"}]}` -- line 4 - `{"gen":4,"module":"m","target":"rust","files":[{"path":"c.rs","hashBefore":"H9_64","hashAfter":"H2_64"}]}` - (c.rs inconsistent-before, because `H9_64` is not the prior `H1_64`) -- line 5 - `{"gen":5,"module":"m","target":"rust","files":[{"path":"d.rs","hashBefore":null,"hashAfter":"D64"}]}` - (d.rs inconsistent-after, because the terminal `D64` is not the current `F64`) -- line 6 the text `{ this is not valid json` (corrupt, `line:6`) - -with all sixty-four-character hashes written out in full. - -Both `JRN_CLEAN` and `JRN_MIXED` call `audit` with an inventory equal to the set of -modules their journal already carries, exactly the one module `m`, so no module is -inventory-present but journal-absent, `current_state_only` is empty, and the rendered -report is byte-for-byte the pinned literal, with no `current-state-only` line. The -report fixture `AUDIT_CURRENT_ONLY` is the emptied-mapl scar seen by the audit: an empty -journal (no parseable entries and no corrupt lines), an empty current-hash map, and an -inventory of the one module `greeting`. Because `greeting` is declared in the inventory -yet named by no journal entry, `audit` returns an `AuditResult` whose `entries` is `0`, -whose `chains` is empty, whose `corrupt_lines` is empty, and whose `current_state_only` -is exactly `["greeting"]`. Its rendered report is exactly `napl migrate audit v1` then a -newline, `entries 0`, `corrupt-lines 0`, `chains 0`, `exact 0`, `missing-prefix 0`, -`inconsistent-before 0`, `inconsistent-after 0`, `unreconstructable 0`, a blank line, -`current-state-only greeting`, and `verdict anomalies`, each line followed by one -newline, so the whole report string is -`napl migrate audit v1\nentries 0\ncorrupt-lines 0\nchains 0\nexact 0\nmissing-prefix 0\ninconsistent-before 0\ninconsistent-after 0\nunreconstructable 0\n\ncurrent-state-only greeting\nverdict anomalies\n`. -This proves the scar is visibly distinct in the report and never laundered into a clean -verdict. - -The object fixtures pin bytes and names. `LINE_A` is the verbatim line -`{"gen":1,"module":"greeting","target":"rust","files":[{"path":"g.rs","hashBefore":null,"hashAfter":"H2_64"}]}` -with `H2_64` written out, whose blob envelope is `napl-store blob v1 168\n` followed -by that line and whose name is -`ed2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360`. `LEG_MISSING` -is the legacy event `legacy_event("greeting", None, 1, -LegacyClassification::MissingPrefix, H5_64, "2026-07-25T00:00:00Z", [])` where -`H5_64` is the digit `5` sixty-four times, canonicalizing to its pinned bytes and -naming itself `fba5e4f9532e4dd01d1f9571b46afafced354533db1730806acb703643fd05f4`. -`LEG_EXACT` is `legacy_event("greeting", Some("rust"), 2, -LegacyClassification::Exact, ed2f...5360, "2026-07-25T00:00:00Z", [fba5...05f4])` -whose `original_line` is the `LINE_A` blob name and whose one parent is the -`LEG_MISSING` name, canonicalizing to its pinned bytes and naming itself -`65407002b376af4c707dbab5e9a668c743cffb1bd14ef427ca64cf49be8c4fff`. - -`MS_SEED` is the module-state with `prompt_blob` the digit `1` repeated, -`block_index` the digit `2` repeated, `mapl` `Some` of the digit `5` repeated, `ir` -`None`, and one target `rust` whose `generated_tree` is the digit `3` repeated and -`attribution` is `Some` of the digit `4` repeated, canonicalizing to its pinned -bytes and naming itself -`e4351cea324d75b2362a7321008ddc4d9b98b0ea13f57650445dab61bb0aa272`. `SNAP` is the -snapshot with `parents` the single element `ZERO64` (the genesis-parent placeholder, -the digit `0` sixty-four times), `seq` `2`, and one root entry for `greeting` whose -`module_state` is the `MS_SEED` name and whose `event_head` is the `LEG_EXACT` name, -canonicalizing to its pinned bytes and naming itself -`8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845`. - -`STAGED_GREETING` is `build_staged_store` over a two-entry journal for module -`greeting`. Its first entry is the line -`{"gen":1,"module":"greeting","files":[{"path":"f.rs","hashBefore":"E64","hashAfter":"B64"}]}` -(gen `1`, no `target`, a missing-prefix `f.rs` step) and its second entry is the -`LINE_A`-shaped line -`{"gen":2,"module":"greeting","target":"rust","files":[{"path":"g.rs","hashBefore":null,"hashAfter":"H2_64"}]}` -(gen `2`, `target` `rust`), which differs from `LINE_A` only in its `gen`; both lines -have `E64`, `B64`, and `H2_64` written out in full and neither records a `timestamp`. -The `seeds` map holds `greeting` mapped to `MS_SEED`, the `audit` marks `f.rs` -`MissingPrefix` and `g.rs` `Exact` with an empty `current_state_only`, and -`genesis_parent` is `ZERO64`. `build_staged_store` reads only the audit's `chains`, so -the audit's `current_state_only` never affects the staged store. The first -entry's legacy event takes `ordinal` `1`, empty `parents`, no `target`, -`MissingPrefix`, `original_line` the first line's blob name, and, the entry having -recorded no timestamp, the empty-string `timestamp`; the second entry's legacy event -takes `ordinal` `2`, that first event's name as its one parent, `target` `rust`, -`Exact`, `original_line` the second line's blob name, and the empty-string -`timestamp`; the second event is the module's event head. The `greeting` root entry -names `MS_SEED` and that event head, `seq` is `2`, and the staged store's `head` is -`8e640fcf87640f7432fa44c8e44b81686ad4b6c67dccebe573c463d7fb8172c0`. - -`STAGED_CURRENT_ONLY` is `build_staged_store` over an empty journal (a `ParsedJournal` -with no entries and no corrupt lines), an `audit` over that empty journal computed with -an empty inventory (so its `chains`, `corrupt_lines`, and `current_state_only` are all -empty, and `build_staged_store` reads only `chains`), a -`seeds` map holding `greeting` mapped to `MS_SEED`, and `genesis_parent` `ZERO64` (the -digit `0` sixty-four times). Because the journal records nothing, `greeting` has no -legacy event, so it is the current-state-only scar: the builder still emits its -`MS_SEED` module-state object and a `greeting` root entry whose `module_state` is the -`MS_SEED` name and whose `event_head` is `ZERO64`, the genesis sentinel, not any event -name. With no journal entry the `seq` is `1`. The resulting snapshot canonicalizes to -`{"parents":["ZERO64"],"root":{"greeting":{"eventHead":"ZERO64","moduleState":"e4351cea324d75b2362a7321008ddc4d9b98b0ea13f57650445dab61bb0aa272"}},"seq":1}` -(with `ZERO64` written out as sixty-four `0` characters in both places), framed as the -snapshot envelope `napl-store snapshot v1 271` then a newline then those canonical -bytes then a trailing newline, and the staged store's `head` is -`8ea748a37b92e975faeef61ca5ff0a4fb740c64f48f10f8fb1e706f7ea095a8d`. The seeded module -is therefore reachable from the head: a current-state-only module is never dropped -from the snapshot's root. - -The abort fixtures are pairs of audit results. The four chain-and-line fixtures each -construct both their `AuditResult`s with an empty `current_state_only`, so the -current-state-only comparison contributes no key and only their chains and corrupt lines -decide them. `CMP_SAME` has current and sanctioned -both mapping `c.rs` to `InconsistentBefore` with no corrupt lines, so it proceeds. -`CMP_WORSE` has sanctioned mapping `c.rs` to `InconsistentBefore` and current -mapping `c.rs` to `InconsistentAfter`, so it aborts naming `c.rs`. `CMP_NEW_CORRUPT` -has current carrying corrupt line `4` that the sanctioned baseline does not, so it -aborts naming `line:4`. `CMP_NEW_ANOMALY` has sanctioned with no entry for `b.rs` -(treated as `Exact`) and current mapping `b.rs` to `MissingPrefix`, so it aborts -naming `b.rs`. The three current-state-only fixtures hold their `chains` and -`corrupt_lines` empty so only the current-state-only comparison decides them. -`CMP_NEW_SCAR` has current `current_state_only` exactly `["greeting"]` and sanctioned -`current_state_only` empty, so `greeting` appears in the current audit but not the -sanctioned baseline and it aborts naming `current-state-only:greeting`. `CMP_SAME_SCAR` -has both current and sanctioned `current_state_only` exactly `["greeting"]`, so the scar -is already sanctioned and it proceeds. `CMP_GONE_SCAR` has current `current_state_only` -empty and sanctioned `current_state_only` exactly `["greeting"]`, so the sanctioned scar -has disappeared from the current audit and it proceeds. +Every object the installer reads to materialize the live tree lies on the authority +path, and the installer's verification of that path is total and precedes its first +write to the live tree. First it reads and checks the structural authority objects: +the target snapshot S, each module state S's root names, and each generated tree those +states name are checked with `store_hash::verify_object` against the name they are +filed under and validated with their owning validator +(`store_objects::validate_snapshot`, `store_objects::validate_module_state`, and the +tree's own verification). It then obtains the full ordered install step list from +`store_txn_core::plan_install_steps` over the verified tree diff and, before it applies +a single step, preflights every file payload the plan will materialize: for every +planned `writeFile` step, the payload object named by that step's content hash must be +present in the store, must verify against that name with `store_hash::verify_object`, +and must be of kind exactly `store_hash::ObjectKind::Blob`. This payload preflight +walks the entire planned step list to completion before the first payload byte is +written to the live tree, so a healthy payload that sorts before a failing one is never +installed: if any planned step's payload is absent, fails to verify, or is not a +`Blob`, the whole install aborts before any earlier step's payload is applied. Any +object on this authority path, structural or payload, that is absent, whose bytes fail +to verify, whose kind is wrong, or that fails validation aborts the install with +`StoreIoError::Integrity` naming the offending object, before a single install step is +applied and before the `INSTALLED` stamp is written. An abort leaves the live tree and +the `INSTALLED` stamp exactly as they were, byte for byte; the installer never swallows +a missing, corrupt, or wrong-kind authority or payload object into an empty or partial +diff, never applies a partial set of steps, and never certifies an incomplete or +corrupt install by stamping over a diff whose structure it could not fully and validly +read or whose every payload it could not first verify. -## Test and code discipline +::: test +name: the installer writes the installed stamp as the target snapshot hash +given: { fixture: INSTALL_STAMP } +expect: { installed_bytes: "b7281259c1fa6f5b9fa3b295d6ee09705ddb02a3387bdd111888d697b63a4242\n" } +::: -The frontmatter test suite is a contract, not a sketch. Each case in it is realized -by its own dedicated test function that exercises only that case, so the number of -dedicated tests is at least the number of frontmatter cases. Never fold two or more -cases into one shared test, and a single test that loops over a table of inputs never -counts as the dedicated test any of those inputs requires: such a combined test may -exist only as an extra. Cases that differ only in their input while sharing an -expected shape are still distinct cases, each owed its own dedicated test. +::: test +name: the installer aborts with an integrity error when a snapshot names an absent module state and never writes the stamp +given: { fixture: INSTALL_MISSING_MODULE_STATE } +expect: { error: Integrity } +::: -Enumerate the case families by name, each owed its own dedicated verbatim test. The -classification cases: `CH_EXACT_CREATE`, `CH_EXACT_MULTI`, `CH_MISSING_PREFIX`, -`CH_INCONSIST_BEFORE`, `CH_INCONSIST_AFTER`, and the worse-of case -`CH_MISSING_PREFIX_AND_AFTER`, plus the severity-order case, each its own test. The -parse cases: `LINE_VALID`, `LINE_CORRUPT_JSON`, `LINE_CORRUPT_SHAPE`, -`LINE_EXTRA_FIELDS`, `LINE_MISSING_BEFORE`, `LINE_MISSING_AFTER`, `LINE_NULL_BEFORE`, -and `LINE_NULL_AFTER`, each its own test, where the corrupt cases are distinct (one -not JSON at all, one JSON of the wrong shape, one omitting `hashBefore`, one omitting -`hashAfter`) and none discharges another, and the two null cases are distinct from -the two missing cases because an explicit `null` is a valid recorded value while an -absent field is a shape failure. The -report cases: the `JRN_CLEAN` byte-pinned report and the `JRN_MIXED` byte-pinned -report, each its own test, each calling `audit` with an inventory of its journal's own -modules so `current_state_only` is empty and the pinned bytes are unchanged; and the -`AUDIT_CURRENT_ONLY` case, its own test, calling `audit` with an empty journal and an -inventory of the one absent module `greeting`, asserting both that the returned -`AuditResult.current_state_only` equals exactly `["greeting"]` and that -`render_audit_report` returns the pinned bytes carrying the distinct -`current-state-only greeting` line and `verdict anomalies`; it never asserts a clean -verdict and never folds the module into `chains`. The object cases: the `LINE_A` blob envelope-and-name -case, the `LEG_MISSING` canonical-and-name case, the `LEG_EXACT` canonical-and-name -case, the `MS_SEED` canonical-and-name case, and the `SNAP` canonical-and-name case, -each its own test. The staged-store head case `STAGED_GREETING`, its own test, and the -current-state-only staged-store case `STAGED_CURRENT_ONLY`, its own test, asserting -both the pinned `head` name and that the store's object under that head equals the -pinned snapshot envelope bytes, proving the `greeting` root entry carries the -genesis-parent `event_head` and is reachable rather than dropped; the two staged-store -cases are distinct and neither discharges the other. The -abort cases: `CMP_SAME`, `CMP_WORSE`, `CMP_NEW_CORRUPT`, `CMP_NEW_ANOMALY`, -`CMP_NEW_SCAR`, `CMP_SAME_SCAR`, and `CMP_GONE_SCAR`, each its own test, where the three -current-state-only cases are distinct from one another and from the four chain-and-line -cases and none discharges another. +::: test +name: the installer aborts with an integrity error when a generated tree object fails to verify and never writes the stamp +given: { fixture: INSTALL_CORRUPT_TREE } +expect: { error: Integrity } +::: -Inside a case's dedicated test, hand the code exactly the fixture that case gives, -byte for byte, and assert its pinned expectation verbatim: no shorter or more -convenient stand-in, no re-derivation of a value the case pins as a literal, and no -weakening of a pinned equality into an inequality-only or recomputed check. The -byte-pinned cases assert their exact literals. The `JRN_CLEAN` report equals -`napl migrate audit v1` then a newline, `entries 2`, `corrupt-lines 0`, `chains 2`, -`exact 2`, `missing-prefix 0`, `inconsistent-before 0`, `inconsistent-after 0`, -`unreconstructable 0`, a blank line, and `verdict clean`, each followed by one -newline. The `JRN_MIXED` report equals `napl migrate audit v1`, `entries 5`, -`corrupt-lines 1`, `chains 5`, `exact 1`, `missing-prefix 1`, `inconsistent-before -1`, `inconsistent-after 1`, `unreconstructable 1`, a blank line, then the sorted -anomaly lines `inconsistent-after d.rs`, `inconsistent-before c.rs`, `missing-prefix -b.rs`, `unreconstructable line:6`, then `verdict anomalies`, each followed by one -newline. Obtain report bytes from `render_audit_report` and compare to the literal; -obtain a blob envelope from `line_blob(...).envelope` and its name from -`line_blob(...).name`; obtain a canonical payload by framing the built value through -`store_hash` and compare to the literal; obtain an object name from the builder's -`StagedObject` `name` and, separately in the same test, assert it equals the matching -`store_objects` naming function over the same value, so the two naming paths are -pinned to agree. +::: test +name: the installer preflights every planned payload so an absent later payload aborts before any earlier payload is written +given: { fixture: INSTALL_PAYLOAD_ABSENT } +expect: { error: Integrity } +::: -The classification tests assert the exact `store_objects::LegacyClassification` -variant, never a mere boolean: `CH_EXACT_CREATE` and `CH_EXACT_MULTI` assert -`Exact`; `CH_MISSING_PREFIX` asserts `MissingPrefix`; `CH_INCONSIST_BEFORE` asserts -`InconsistentBefore`; `CH_INCONSIST_AFTER` and `CH_MISSING_PREFIX_AND_AFTER` assert -`InconsistentAfter`. The `CH_MISSING_PREFIX_AND_AFTER` test never asserts -`MissingPrefix`, proving the worse condition wins. The severity-order test asserts -`classification_severity` is strictly increasing across `Exact`, `MissingPrefix`, -`InconsistentBefore`, `InconsistentAfter`, `Unreconstructable`, the exact values `0`, -`1`, `2`, `3`, `4`. The parse tests assert the exact parsed fields for a valid line -and that a corrupt line lands in `corrupt` with its verbatim `raw`, never a weakened -`is_ok`. The `LINE_MISSING_BEFORE` and `LINE_MISSING_AFTER` tests each assert their -line lands in `corrupt` with its verbatim `raw`, proving an absent hash field is a -corrupt line rather than a creation or a deletion. The `LINE_NULL_BEFORE` and -`LINE_NULL_AFTER` tests each assert the parsed `FileStep`'s exact `hash_before` and -`hash_after` `Option` variants, `None` for the field recorded as `null` and `Some` of -the recorded hash for the other, proving an explicit `null` remains a valid creation -or deletion step and is never confused with an absent field. The abort tests assert the exact `MigrateDecision` including the offending -key vector verbatim and in sorted order: `CMP_SAME` asserts `Proceed`; `CMP_WORSE` -asserts `Abort` carrying exactly `["c.rs"]`; `CMP_NEW_CORRUPT` asserts `Abort` -carrying exactly `["line:4"]`; `CMP_NEW_ANOMALY` asserts `Abort` carrying exactly -`["b.rs"]`; `CMP_NEW_SCAR` asserts `Abort` carrying exactly -`["current-state-only:greeting"]`; `CMP_SAME_SCAR` asserts `Proceed`; and -`CMP_GONE_SCAR` asserts `Proceed`. Never assert only that the decision is an abort while ignoring its keys. +::: test +name: the installer preflights every planned payload so a corrupt later payload aborts before any earlier payload is written +given: { fixture: INSTALL_PAYLOAD_CORRUPT } +expect: { error: Integrity } +::: -The named-substitution prohibitions for this module's confusable tokens are strict. -Never write `missing-prefix` where a case pins `inconsistent-before`, nor -`inconsistent-before` where a case pins `inconsistent-after`, nor `exact` where a -case pins any anomaly token, nor the reverse of any of these; each classification -token is transcribed exactly as its case names it, and the five are never -interchanged. Never write `hashBefore` where a case pins `hashAfter` or the reverse, -nor `stateBefore` where a case pins `stateAfter` or the reverse, nor `moduleState` -where a case pins `eventHead` or the reverse, nor `promptBlob` where a case pins -`blockIndex`, nor `generatedTree` where a case pins `attribution`. The -genesis-parent placeholder `ZERO64` is the digit `0` sixty-four times and is never -confused with the absent-hash `None` of a creation step. The verdict tokens are -exactly `clean` and `anomalies`; never write `ok` or `dirty` in their place. The -report's fixed header token is exactly `napl migrate audit v1`; never abbreviate or -reword it, and never drop the trailing newline that ends the report. +::: test +name: the installer preflights every planned payload so a non-blob later payload aborts before any earlier payload is written +given: { fixture: INSTALL_PAYLOAD_WRONG_KIND } +expect: { error: Integrity } +::: -Every case above is realized by exactly one dedicated verbatim test; a narrower, -neighboring, or recomputed test does not discharge a case; any supplemental -table-driven or integration test is separately named and never substitutes for a -dedicated test; and the count of dedicated tests is at least the count of frontmatter -cases. +## Startup recovery -Comment rules: no line or block comments anywhere in the generated code; no doc -comment on a private item; a module `//!` summary and `///` on public items only; no -doctest code blocks in any doc comment; no em dashes in any string, comment, or doc -this module emits. Every struct that deserializes external JSON does so only through -the fields this module reads and never fabricates a value the input did not carry. +Expose `recover(root: &std::path::Path, live_tree: &std::path::Path, this_host: +&str, is_alive: &dyn Fn(u64) -> bool) -> Result<(), StoreIoError>`, the pass every +command runs at startup. Under the install lock it enumerates the commit markers, and +for each one it decides an action through the pure core rather than deciding policy +itself: -## migrate_io +1. Compute whether the marker's snapshot is HEAD or an ancestor of HEAD by walking + the snapshot spine (parents and seq) through this module's provider, using + `store_objects::Snapshot`. The walk reads only snapshot objects, and a snapshot it + cannot load is the genesis boundary: when a candidate snapshot is itself unloadable + the walk carries no sequence floor and descends the full spine, so it dequeues the + genesis sentinel parent, finds that parent absent from the store, and terminates + with a clean non-ancestor answer rather than erroring. A marker keyed by a snapshot + the store never published is therefore judged a non-ancestor at the genesis + boundary, not a load failure. +2. Determine whether the marker's owner is live: read the owner's lock file, parse it + with `store_txn_core::parse_lock`, and, for a parsed lock, ask + `store_txn_core::liveness_decision` with the lock's host, `this_host`, and the + injected `is_alive` probe over the lock's pid; a lock on a foreign host is never + judged dead and its owner is treated as live for the purpose of leaving the marker + alone. Liveness is never decided by the mere presence of a lock file, by a wall + clock, or by shelling out to a process tool. +3. Call `store_txn_core::classify_marker` with the ancestor and liveness facts and + act on its verdict: a roll-forward-and-delete verdict runs `install_to_head` and + then deletes the marker; a leave verdict leaves the marker in place; a + delete-and-collect verdict deletes the marker and lets the janitor collect its + objects. -# Migrate io: the command shell that reads the legacy ledger and writes the staged store +Recovery is idempotent: every action is either an idempotent install step or an +atomic delete, so rerunning recovery converges. -This module is the filesystem shell over the pure `migrate_core` brain. It is the -whole disk-facing surface of the one-shot ledger migration: it reads the legacy -`journal.jsonl` and `map.json`, runs the audit that the maintainer sanctions, and, -on a clean tree that is no worse than the sanctioned report, seeds the migrated -store's module-states from the current working files and materializes the staged -store into a sibling directory in the design-frozen on-disk layout. Every operation -is deterministic and tempdir-testable: the root and the destination are handed in as -paths, the dirty-tree signal and the sanctioned baseline are handed in as values, no -wall clock is read, and no randomness is used. All classification, report rendering, -object framing, and store assembly are delegated to `migrate_core`; this module owns -only the reading of inputs, the walking of working files, and the writing of the -staged store. +A dead writer's in-flight attempt marker is not converted into a private sentinel +file: this disk shell never writes a stand-in such as an `attempts/.interrupted` file and never treats such a file as a store event. The recorded +`interrupted` outcome is a real content-addressed event, allocated an ordinal and +recorded through a normal commit by the commit layer that sits above this shell, so a +crashed attempt becomes a queryable event rather than anonymous litter. This shell's +part is to surface the dead-owner attempt marker and its +`store_txn_core::attempt_disposition` verdict (an in-flight record yields an +interrupted event, a completed record yields none), keyed by attempt id so a crash +between the event's commit and the marker's deletion cannot fabricate a duplicate; it +never fabricates the event itself. -Bring in `serde`, `serde_json`, and `serde_yaml`. +::: test +name: startup recovery over an ancestor-or-equal marker deletes the marker +given: { fixture: RECOVER_ANCESTOR } +expect: { marker_absent: true } +::: -## Where this code lives +::: test +name: startup recovery over an ancestor-or-equal marker rolls the installed stamp forward to HEAD +given: { fixture: RECOVER_ANCESTOR } +expect: { installed_bytes: "289f591aec3ebd915526648ad486d6b028dca5200e9c392b7fdaf4c498f6ec09\n" } +::: -The working directory is a Cargo workspace whose root manifest is written and owned -by the toolchain, leave it alone. Create this module as its own member crate in a -subdirectory named `migrate_io/`: `migrate_io/Cargo.toml` (package name -`migrate_io`) and `migrate_io/src/lib.rs`. Touch nothing outside `migrate_io/`. -Ensure `cargo test` passes from the workspace root before finishing. +::: test +name: startup recovery leaves a non-ancestor marker whose owner lock is on a foreign host +given: { fixture: RECOVER_FOREIGN } +expect: { marker_present: true } +::: -## Builds on nine modules of this workspace +::: test +name: recovery over an unloadable marker snapshot walks the spine to the absent genesis parent and leaves the foreign owned marker +given: { fixture: RECOVER_GENESIS_PARENT } +expect: { marker_present: true } +::: -This crate composes on nine generated sibling crates. Use each one's public API, do -not reimplement its types or logic, and do not depend on any hand-written crate. Do -not bring in the `sha2` crate directly; all hashing goes through `store_hash`. Do -not reimplement journal parsing, classification, report rendering, object framing, or -store assembly; all of those come from `migrate_core`. Do not reimplement attribution -parsing, validation, or the ownership document; the attribution YAML goes through -`schemas_attribution` and the canonical attribution document is `store_diff`'s. Seven of -these are production dependencies used by the module body; the last two, `store_reduce` -and `store_io`, are consumed only by the downstream-consumability integration test, which -reads the store this module materializes back through the real reducer. +## The janitor -- **`migrate_core`** (`../migrate_core`) is the pure brain. It uses - `migrate_core::parse_journal(text: &str) -> migrate_core::ParsedJournal`, - `migrate_core::ParsedJournal`, `migrate_core::AuditResult` (whose public fields now - include `current_state_only: Vec`, the inventory modules absent from the - journal), `migrate_core::audit(journal: &migrate_core::ParsedJournal, current_hashes: - &std::collections::BTreeMap, inventory: - &std::collections::BTreeSet) -> migrate_core::AuditResult`, - `migrate_core::render_audit_report(result: - &migrate_core::AuditResult) -> String`, `migrate_core::compare_to_sanctioned(current: - &migrate_core::AuditResult, sanctioned: &migrate_core::AuditResult) -> - migrate_core::MigrateDecision` with its variants `migrate_core::MigrateDecision::Proceed` - and `migrate_core::MigrateDecision::Abort(Vec)`, - `migrate_core::build_staged_store(journal: &migrate_core::ParsedJournal, audit: - &migrate_core::AuditResult, seeds: &std::collections::BTreeMap, genesis_parent: &str) -> migrate_core::StagedStore`, and - `migrate_core::StagedStore` with its public fields `objects: - std::collections::BTreeMap>` and `head: String`. This module never - reimplements any of these; it calls them. -- **`store_objects`** (`../store_objects`) supplies the module-state types this - module seeds. It uses `store_objects::ModuleState` with public fields `prompt_blob: - String` (serde `promptBlob`), `block_index: String` (serde `blockIndex`), `mapl: - Option`, `ir: Option`, and `targets: - std::collections::BTreeMap`, and - `store_objects::TargetArtifacts` with public fields `generated_tree: String` (serde - `generatedTree`) and `attribution: Option`. This module constructs - `ModuleState` values for the seeds map; it never renames or reshapes them. -- **`store_hash`** (`../store_hash`) supplies the envelope framing, canonical JSON, - and object naming. It uses `store_hash::ObjectKind` (the variants - `ObjectKind::Blob`, `ObjectKind::Tree`, and `ObjectKind::BlockIndex`), - `store_hash::canonical_json(value: &serde_json::Value) -> Vec`, - `store_hash::envelope_bytes(kind: store_hash::ObjectKind, payload: &[u8]) -> - Vec`, and `store_hash::object_name(kind: store_hash::ObjectKind, payload: - &[u8]) -> String`. This module frames every component object it publishes through - `store_hash` and never reimplements canonical JSON, envelope framing, or hashing. -- **`store_blocks`** (`../store_blocks`) supplies the prompt segmentation index. It - uses `store_blocks::segment(prompt: &str, seg_version: u32) -> - store_blocks::BlockIndex`, `store_blocks::block_index_object_name(index: - &store_blocks::BlockIndex) -> String`, and the constant `store_blocks::SEG_VERSION`. - This module segments a prompt through `store_blocks::segment` under - `store_blocks::SEG_VERSION` and never reimplements segmentation. -- **`store_tree`** (`../store_tree`) supplies the generated-tree builder. It uses - `store_tree::TreeFile` with public fields `path: String`, `hash: String`, and - `mode: u32`; `store_tree::build_tree(files: &[store_tree::TreeFile]) -> - Result`; `store_tree::BuiltTree` - with public fields `root: String` and `objects: std::collections::BTreeMap`; `store_tree::Tree`; `store_tree::tree_object_name(tree: - &store_tree::Tree) -> String`; and the constant `store_tree::FILE_MODE` (the decimal - value `33188`). This module builds a generated tree through `store_tree::build_tree` - and never reimplements tree building. -- **`schemas_attribution`** (`../schemas_attribution`) parses and validates the on-disk - attribution YAML into the attribution document. It uses - `schemas_attribution::validate_attribution(value: serde_json::Value) -> - Result` and - `schemas_attribution::Attribution` with its public fields `module: String`, `target: - String`, and `entries: Vec`, and - `schemas_attribution::AttributionEntry` with its public fields `prompt_lines` and - `lines` (each an inclusive line range exposing `start: u32` and `end: u32`), `file: - String`, and `note: String`. This module parses a module's attribution YAML through - `schemas_attribution::validate_attribution`, never reimplementing the parse or its - validation. -- **`store_diff`** (`../store_diff`) supplies the store's canonical attribution document, - the exact form a gen commits. It uses `store_diff::Attribution` with public fields - `module: String`, `target: String`, and `entries: Vec`; - `store_diff::OwnershipEntry` with public fields `prompt_lines: [u32; 2]`, `file: - String`, `lines: [u32; 2]`, and `note: String`; and `store_diff::validate_attribution(value: - serde_json::Value) -> Result`. This module maps a parsed - `schemas_attribution::Attribution` into a `store_diff::Attribution` exactly as a gen's - ownership document does and never reimplements the canonical attribution type. -- **`store_reduce`** (`../store_reduce`) is the typed query layer over the - content-addressed store, used only by the downstream integration test. It uses - `store_reduce::ObjectProvider`, `store_reduce::classify(provider: &P, module: &str, - target: &str, observed: &store_reduce::Observed) -> Result` with the variant `store_reduce::Classification::CurrentStateOnly`, - `store_reduce::Observed`, and `store_reduce::event_graph(provider: &P, module: &str) -> - Result, store_reduce::ReduceError>`. The test calls these to - prove the migrated store is consumable; this module never reimplements the reducer. -- **`store_io`** (`../store_io`) supplies the disk-backed `ObjectProvider`, used only by - the downstream integration test. It uses `store_io::DiskProvider` and - `store_io::DiskProvider::open(root: &std::path::Path) -> store_io::DiskProvider`, which - reads objects and `HEAD` from a materialized store root in exactly the layout - `materialize_staged_store` writes. The test opens a `DiskProvider` over the migration's - `dest` and hands it to the reducer. +Expose `janitor(root: &std::path::Path, this_host: &str, is_alive: &dyn Fn(u64) -> +bool) -> Result<(), StoreIoError>`. Under its own lock it deletes `tmp/` entries, +dead-owner attempt markers, dead non-ancestor commit markers, and unreferenced objects +owned by dead writers. It judges a marker's owner exactly as recovery does: it parses +the owner's lock with `store_txn_core::parse_lock` and applies +`store_txn_core::liveness_decision` with the lock's host, `this_host`, and the injected +`is_alive` probe, never by the mere presence of the lock file; a dead-local owner's +non-ancestor marker is collected and a foreign-host owner's marker is left, because a +foreign host is never judged dead. The ancestry judgment is the same spine walk and +the same genesis boundary recovery uses. Like recovery, the janitor never invents a +sentinel file to stand in for an `interrupted` event: the event is recorded through a +normal commit by the layer above, and the janitor collects the dead-owner attempt +marker only after that event exists. In this module's tested surface the janitor +unconditionally clears `tmp/` staging scratch. -## Bare content hashes versus envelope object names +::: test +name: the janitor deletes staging temp entries +given: { fixture: JANITOR_TMP } +expect: { tmp_empty: true } +::: -Two different hash schemes meet in this module, and confusing them corrupts the -store. The legacy `journal.jsonl` records a file's **bare content hash** in -`hashBefore` and `hashAfter`, and `map.json` records the same bare content hash in -each `files` entry's `hash`. Those bare hashes are the values the audit classifier -compares: `migrate_core::audit` looks up a path's current hash in the map exactly as -recorded and matches it against the journal's recorded terminal hash, and this module -never recomputes a bare content hash (recomputing one would require the `sha2` crate, -which is forbidden here). The migrated store's objects, by contrast, are named by -their full **envelope object name**: the SHA-256 of the typed, versioned, -length-prefixed envelope, obtained through `store_hash::object_name`. A generated -file therefore has two unrelated names in this migration: the bare content hash the -map records (used only by the audit) and the envelope blob object name this module -computes when it seeds that file into a tree. This module reads bare hashes from the -map for the audit and computes envelope object names through `store_hash` for the -store, and it never substitutes one for the other. Consistency between the working -tree and the map is guaranteed upstream: the dirty-tree refusal guarantees the files -on disk are the committed files the map describes, and the audit's inconsistent-after -classification is what compares the recorded terminal hash to the map's current hash, -so this module performs no additional per-file rehashing. +::: test +name: the janitor collects a dead local owner's non-ancestor commit marker judged by the liveness decision +given: { fixture: JANITOR_DEAD_LOCAL_COLLECTS } +expect: { marker_absent: true } +::: -## Reading the legacy inputs +::: test +name: the janitor leaves a foreign host owner's non-ancestor commit marker because a foreign host is never judged dead +given: { fixture: JANITOR_FOREIGN_LEAVES } +expect: { marker_present: true } +::: -Expose a public enum `MigrateIoError` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`) with exactly these variants: +## Dead-attempt surfacing and marker cleanup -- `Io(String)`, carrying a human-readable message, for any filesystem read or write - failure (a missing file, an unreadable path, a failed directory creation or file - write) and for a relative path this module refuses to resolve because it is not - contained by the injected root (an absolute path, or one that climbs above root - through parent traversal). An `std::io::Error` is mapped into this variant by - carrying its display string, so the error type stays `Clone` and `Eq`; a - containment refusal carries a short message naming the offending path, and its - message bytes are not pinned. -- `MalformedMap(String)`, carrying serde's message, for a `map.json` text that is not - a JSON object carrying the shape this module reads. +This section is additive over the committed shell and gives the commit layer above this +shell the two things it needs to discharge the interrupted-event obligation without ever +writing a store file by hand: a read-only enumeration of the attempt markers the store +holds together with each owner's liveness verdict, and two idempotent cleanup primitives +for the markers a landed transaction or a completed conversion must remove. Recovery and +the janitor keep their committed commit-marker scope untouched; this is the separate +read-and-cleanup surface the commit layer drives. -Expose a public struct `ModuleView` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`) with public fields `module: String`, `prompt_path: String` (the map's -`prompts` entry key, retained verbatim as the module's prompt identifier for -provenance; in the live ledger this key is the bare module name, not a filesystem -path, so this module treats it as a label and never resolves a prompt through it, -see the prompt-source rule below), and `targets: -std::collections::BTreeMap>` (each declared target mapped to its -list of generated file paths, both taken verbatim from the map). Expose a public -struct `MapView` (same derives) with public fields `current_hashes: -std::collections::BTreeMap` (every `files` path mapped to its bare -content `hash`) and `modules: Vec` (one per `prompts` entry, in ascending -prompt-path order). +Expose a public struct `SurfacedAttempt` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`), +one attempt marker the store holds paired with the facts the commit layer decides on. +Public fields: -Expose `parse_map(map_text: &str) -> Result`. It reads only -the fields this module needs and ignores every other field of the real ledger's -`map.json`, because `map.json` is a large foreign document this module does not own -and must not reject for carrying fields it does not read. Deserialize the text into a -lenient view that requires a top-level JSON object with a `files` object (each entry -an object carrying a string `hash`) and a `prompts` object (each entry an object -carrying a string `module` and a `targets` object whose every value is an object -carrying a `files` array of strings); on any failure of that shape return -`MigrateIoError::MalformedMap` carrying serde's message. Build `current_hashes` from -the `files` object (path to its `hash`), and build `modules` from the `prompts` -object: one `ModuleView` per prompt entry, its `prompt_path` the entry's key, its -`module` the entry's `module`, and its `targets` the map of each target name to that -target's `files` list, all verbatim. Fields such as `version`, `promptHash`, -`declaredTargets`, `promptHashAtGen`, and a `files` entry's `target` and `prompts` -are present in the real map and are ignored, never a parse failure. The `prompts` -key is retained in `prompt_path` exactly as written and is never interpreted as a -path: the live ledger keys each `prompts` entry by the bare module name (so the key -equals the entry's `module`), and this module accepts that verbatim, resolving the -prompt source through the module's injected path rather than by this key, see the -prompt-source rule below. +- `owner_token: String`, the token the marker is keyed by under `attempts/`, exactly the + filename stem of `attempts/.json`. It is the token the commit layer passes + back to `delete_attempt_marker` after it converts or cleans up the marker. +- `marker: store_txn_core::AttemptMarker`, the parsed marker record, its bytes read back + through the core's canonical type exactly as `read_attempt_marker` parses it, so the + commit layer reads the marker's `attempt_id`, `operation`, `module`, `target`, `state`, + and `diagnostics` from the core's type and never re-parses the bytes itself. +- `owner_live: bool`, the owner-liveness verdict for this marker, judged exactly as + recovery and the janitor judge a marker owner: the owner's lock is the file + `locks/.lock`; an absent lock is a dead owner (`false`), a lock that fails + to parse is a stale-eligible owner that is never judged dead here (`true`), and a parsed + lock is judged with `store_txn_core::liveness_decision` over the lock's host, `this_host`, + and the injected `is_alive` probe of the lock's pid, `true` for a `Live` or `ForeignHost` + verdict and `false` only for a `Dead` verdict. This is the same owner-liveness decision + the committed recovery and janitor apply to a marker owner, computed here without + deleting or mutating anything. -Every relative path this module reads is map-controlled or map-derived: the map's -`prompts` keys, its `files` paths, and the `.napl` artifact paths built from module -names all flow into these readers, so a hostile or corrupt map must never be able to -steer a read outside the injected root. Expose a shared containment guard that every -reader runs before it touches the filesystem: `contained_path(root: -&std::path::Path, rel: &str) -> Result`. It -refuses, returning `MigrateIoError::Io` and touching no file, any `rel` that is an -absolute path (a `rel` whose `std::path::Path` `is_absolute`, or on a rooted or -prefix component) and any `rel` that, once its `.` and `..` components are collapsed -lexically against `root`, does not stay strictly within `root`; a `rel` carrying a -`..` component that would climb to or above `root` is refused. Containment is purely -lexical: the guard normalizes the component sequence itself and never calls -`canonicalize`, never resolves symlinks, and never reads the filesystem to make its -decision, so it is deterministic and cannot be raced. This module does not defend -against a symlink that already lives inside the trusted, freshly committed tree the -migration runs over; its sole job is to stop a map-recorded relative path from -escaping root through an absolute path or a `..` climb. On success it returns the -joined, contained `PathBuf`. Ordinary nested relative paths such as -`.napl/mapl/greeting.mapl` or `.napl/src/rust/greeting.rs` are contained and pass. +Expose `surface_attempt_markers(root: &std::path::Path, this_host: &str, is_alive: &dyn +Fn(u64) -> bool) -> Result, StoreIoError>`, a read-only pass that +enumerates the attempt markers under `attempts/` and returns one `SurfacedAttempt` per +marker. It enumerates the `attempts/` directory keys, and a store with no `attempts/` +directory or no markers yields an empty list. For each key in ascending key order it reads +and parses the marker through the core's canonical type exactly as `read_attempt_marker` +does, surfacing a parse failure as a loud `StoreIoError::Corrupt` naming the owner token, +and judges the owner's liveness as the `owner_live` field above describes. The returned +vector is ordered by owner token ascending, deterministically, so the commit layer sees a +stable order. This pass reads only: it deletes no marker, writes no marker, and moves no +HEAD; it never converts a marker into an event and never fabricates a sentinel, exactly as +this shell's recovery contract already forbids. The commit layer that sits above this +shell reads the surfaced markers, decides each one's disposition through +`store_txn_core::attempt_disposition`, records any interrupted event through a normal +commit, and then removes the handled marker through `delete_attempt_marker` below. -Expose `read_to_string_at(root: &std::path::Path, rel: &str) -> Result`: resolve `rel` through `contained_path` first, then read that file as -text, mapping any `std::io::Error` to `MigrateIoError::Io`. Expose `read_bytes_at(root: -&std::path::Path, rel: &str) -> Result, MigrateIoError>` for the same -contained read as raw bytes. Neither hardcodes a `.napl` path; the root is always -injected, and neither ever reads a path the guard refuses. +Expose `delete_attempt_marker(root: &std::path::Path, owner_token: &str) -> Result<(), +StoreIoError>`, which removes the attempt marker keyed by `owner_token` at +`attempts/.json`. The delete is atomic and idempotent: removing a marker that +is already absent is a success that returns `Ok(())`, so a crash between an interrupted +event's commit and this delete is repaired by rerunning the delete, and a converted or +completed marker is cleaned up exactly once observationally. It removes only the one keyed +attempt marker file and touches no other store state. -The migration's optional artifacts (a module's mapl, ir, and attribution) may be -absent, and absence is a legitimate `None`, but an absent file and an unreadable file -are different facts and this module never conflates them. Expose `read_optional_at(root: -&std::path::Path, rel: &str) -> Result>, MigrateIoError>`. It resolves -`rel` through the same containment guard, then probes existence with an error-surfacing -try-exists: a definite non-existence returns `Ok(None)`, a definite existence reads the -file and returns `Ok(Some(bytes))`, and any error while probing (a permission failure, -a symlink loop, or any other metadata error) is returned as `MigrateIoError::Io`, never -swallowed into `Ok(None)`. It never uses a bare existence check that maps a metadata -error to false, because doing so would silently treat an unreadable optional artifact -as absent and drop it from the seed. An io error is an error, not an absence. +Expose `delete_commit_marker(root: &std::path::Path, snapshot: &str) -> Result<(), +StoreIoError>`, which removes the commit marker keyed by `snapshot` at +`markers/.json`. Like the attempt-marker delete it is atomic and idempotent: +removing a commit marker that is already absent returns `Ok(())`, so the deliberate +post-install marker delete a landed commit performs is safe to rerun after a crash. It +removes only the one keyed commit marker file and touches no other store state, and it +never moves HEAD, never touches the `INSTALLED` stamp, and never collects an object. ::: test -name: a map parses its current file hashes and its module views -given: { fixture: MAP_VIEW } -expect: - current_hashes: - a.rs: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - .napl/src/rust/greeting.rs: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - modules: - - { module: "greeting", prompt_path: "prompts/greeting.md", target: "rust", files: [".napl/src/rust/greeting.rs"] } +name: surfacing a dead-local owner's attempt marker returns it with its parsed record and a not-live verdict +given: { fixture: SURFACE_DEAD } +expect: { surfaced_len: 1, surfaced_owner_token: "tok-aaaa", surfaced_attempt_id: "att-0001", surfaced_owner_live: false } ::: ::: test -name: a map that is not a json object carrying a files map is rejected as malformed -given: { fixture: MAP_BAD } -expect: { error: MalformedMap } +name: surfacing a live same-host owner's attempt marker returns it with a live verdict +given: { fixture: SURFACE_LIVE } +expect: { surfaced_len: 1, surfaced_owner_live: true } ::: ::: test -name: a read whose relative path is absolute is refused before touching the filesystem -given: { fixture: PATH_REJECT_ABSOLUTE } -expect: { error: Io } +name: surfacing a foreign-host owner's attempt marker returns it with a live verdict because a foreign host is never judged dead +given: { fixture: SURFACE_FOREIGN } +expect: { surfaced_len: 1, surfaced_owner_live: true } ::: ::: test -name: a read whose relative path escapes the root through parent traversal is refused -given: { fixture: PATH_REJECT_ESCAPE } -expect: { error: Io } +name: surfacing an attempt marker whose owner lock is absent returns it with a not-live verdict +given: { fixture: SURFACE_NO_LOCK } +expect: { surfaced_len: 1, surfaced_owner_live: false } ::: -## The prompt-source rule - -The migrated store's prompt blob for a module is content-addressed over that module's -authored full `.napl` source file bytes, frontmatter included, the exact bytes the -status classifier hashes when it reads `root` joined with that module's prompt path. -This identity is the whole point: a module's head-recorded prompt blob and the blob the -status classifier later observes are the same hash of the same full-file bytes, so a -frontmatter edit registers as a prompt change and a migrated head never reads as -spuriously prompt-stale against its own source. A body-only hash would carry a different -value than the classifier observes and break that identity. - -The authored full `.napl` is the single prompt source that feeds both the prompt blob -and the block index, exactly the one source a generation reads: the generator -content-addresses the prompt blob over the authored full bytes and segments the block -index over those same bytes, so a migrated module-state is byte-identical to a freshly -generated one, with one segmentation semantic everywhere and no migrate-only variant. - -The authored source is not addressable from the map alone. The live ledger keys each -`prompts` entry by the bare module name, and the map's `files` object records only -generated files, so neither the map's `prompts` key nor its `files` map names the -authored `.napl` path. That path is therefore an injected value, handed to `run_migrate` -as a module-name-to-path mapping exactly as the dirty-tree signal and the sanctioned -baseline are injected, never read from a clock, a live `git` invocation, a directory -walk, or a discovery pass. For each seeded module the migration reads that module's -authored full `.napl` bytes from `root` joined with its injected path through the -contained reader, and derives both the prompt blob and the block index from those bytes. - -This rule is uniform: every seeded module, including a current-state-only module, -content-addresses its prompt blob and segments its block index over its own authored -full `.napl`, and a module whose injected authored source is absent or unreadable -surfaces the same `MigrateIoError::Io` any required read does, never a silent skip. The -body snapshot at `.napl/prompts-at-gen/.md` plays no part in seeding: the -migration derives both the prompt blob and the block index from the authored full -`.napl` and reads no body snapshot for a seed. - -## The audit command - -Expose `audit_report_from(journal_text: &str, map_text: &str) -> Result`, the pure core of `napl migrate --audit`: parse the map through -`parse_map`, parse the journal through `migrate_core::parse_journal`, and audit it -through `migrate_core::audit` against the map's `current_hashes` and the map's module -inventory. The inventory is the set of module names the map declares, built from -`parse_map`'s `modules` (each `ModuleView`'s `module` name); it is exactly this set that -lets the audit surface a map-declared module with no journal history as a -current-state-only scar rather than laundering it into a clean report. Render the -report through `migrate_core::render_audit_report`, returning the exact report string. -It reads no files and touches no disk. - -Expose `run_audit(root: &std::path::Path) -> Result`: read -`journal.jsonl` and `map.json` from `root` (through `read_to_string_at`), then return -`audit_report_from` over those two texts. This is the whole body of the read-only -`napl migrate --audit` command; the caller prints the returned string to standard -output byte for byte. The report is the maintainer's sanction artifact and its bytes -are exactly those `migrate_core::render_audit_report` pins. - ::: test -name: the audit command over an all-exact root prints the pinned clean report bytes -given: { fixture: ROOT_CLEAN } -expect: { report_bytes: "napl migrate audit v1\nentries 2\ncorrupt-lines 0\nchains 2\nexact 2\nmissing-prefix 0\ninconsistent-before 0\ninconsistent-after 0\nunreconstructable 0\n\nverdict clean\n" } +name: surfacing an attempt marker whose owner lock is malformed returns it with a live verdict because a malformed lock is never judged dead +given: { fixture: SURFACE_MALFORMED } +expect: { surfaced_len: 1, surfaced_owner_live: true } ::: ::: test -name: the audit command over a mixed root prints the pinned anomaly report bytes -given: { fixture: ROOT_MIXED } -expect: { report_bytes: "napl migrate audit v1\nentries 5\ncorrupt-lines 1\nchains 5\nexact 1\nmissing-prefix 1\ninconsistent-before 1\ninconsistent-after 1\nunreconstructable 1\n\ninconsistent-after d.rs\ninconsistent-before c.rs\nmissing-prefix b.rs\nunreconstructable line:6\nverdict anomalies\n" } +name: surfacing over a store with no attempt markers returns an empty list +given: { fixture: SURFACE_EMPTY } +expect: { surfaced_len: 0 } ::: -## The object layout and materialization - -The migrated store's on-disk layout is design-frozen. Every object is a plain file -whose bytes are its full envelope, stored at a two-character fan-out path under an -`objects` directory, and the store's single mutable ref is a `HEAD` file holding the -head snapshot's name. - -Expose `object_path(name: &str) -> String`: the object's store-relative path, -exactly `objects/`, then the first two characters of `name`, then `/`, then the -remaining characters of `name`. For a sixty-four-character object name this yields -`objects//`. - -Expose `materialize_staged_store(staged: &migrate_core::StagedStore, dest: -&std::path::Path) -> Result<(), MigrateIoError>`. The `dest` is the store root, a -fresh single-writer directory this migration owns (a sibling temporary directory); -this is a bulk write of a fresh store, not the concurrent publication primitive, so -no locks and no compare-and-swap are involved. Even though this is a bulk write, every file this step publishes appears atomically, -so an interrupted run never leaves a half-written object or, worst of all, a `HEAD` -that names a snapshot whose objects are not all on disk. For every `(name, envelope)` -pair in `staged.objects`, first create the two-character fan-out directory under -`dest` when it does not exist, then write `envelope` to a temporary file in that same -fan-out directory (a sidecar beside the final object path, so the rename that follows -is a same-directory rename and never crosses a filesystem boundary), then rename that -temporary file onto the final path at `dest` joined with `object_path(name)`. The -published object file therefore only ever exists with its complete envelope bytes; a -partial write lives only in the temporary sidecar and is never observable at the -object's real path. After every object has been renamed into place, and only then, -publish `HEAD` the same way: write `staged.head` followed by a single newline to a -temporary file in `dest`, then rename that temporary onto `dest` joined with `HEAD`. -`HEAD` is renamed last, after every object it could name is durably at its path, so a -reader that observes `HEAD` always finds the snapshot it names and every object that -snapshot reaches already present, and a partial `HEAD` temporary never appears as a -published ref. Every filesystem failure (a directory creation, a temporary write, or a -rename) maps to `MigrateIoError::Io`. Object files carry the exact envelope bytes -handed in, never re-encoded, and `HEAD` carries the head name and one trailing newline -and nothing else. After a successful materialize the only files under `dest` are the -object files at their fan-out paths and the single `HEAD`; no temporary sidecar -survives, because each was renamed away. - ::: test -name: an object name shards into its two-character fan-out path -given: { fixture: NAME_SNAP } -expect: { object_path: "objects/81/57965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845" } +name: surfacing two attempt markers returns them ordered by owner token +given: { fixture: SURFACE_TWO } +expect: { surfaced_len: 2, surfaced_owner_tokens: ["tok-aaaa", "tok-bbbb"] } ::: ::: test -name: materializing a staged store writes each object at its sharded path and writes head with a trailing newline -given: { fixture: STORE_MIN } -expect: { head_bytes: "8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845\n", objects: [ { path: "objects/81/57965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845", envelope: "napl-store snapshot v1 271\n{\"parents\":[\"0000000000000000000000000000000000000000000000000000000000000000\"],\"root\":{\"greeting\":{\"eventHead\":\"65407002b376af4c707dbab5e9a668c743cffb1bd14ef427ca64cf49be8c4fff\",\"moduleState\":\"e4351cea324d75b2362a7321008ddc4d9b98b0ea13f57650445dab61bb0aa272\"}},\"seq\":2}\n" }, { path: "objects/ed/2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360", envelope: "napl-store blob v1 168\n{\"gen\":1,\"module\":\"greeting\",\"target\":\"rust\",\"files\":[{\"path\":\"g.rs\",\"hashBefore\":null,\"hashAfter\":\"2222222222222222222222222222222222222222222222222222222222222222\"}]}" } ] } +name: surfacing an unparseable attempt marker is a loud corrupt error naming its owner token +given: { fixture: SURFACE_CORRUPT } +expect: { error: Corrupt } ::: ::: test -name: materializing a staged store leaves only its objects and head with no partial or temporary file -given: { fixture: MAT_NO_PARTIAL } -expect: { head_bytes: "8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845\n", stray_files: 0 } +name: deleting a present attempt marker removes its file +given: { fixture: DELETE_ATTEMPT_PRESENT } +expect: { attempt_absent: true } ::: -## Seeding a module-state from working files +::: test +name: deleting an absent attempt marker is an idempotent success +given: { fixture: DELETE_ATTEMPT_ABSENT } +expect: { attempt_absent: true } +::: -The final migration snapshot's module-states are seeded from the current working -files, so the migrated HEAD is complete and exact even where history is not. Seeding -is pure over the bytes of those files: the caller reads each file and hands its bytes -in, so the function has no filesystem dependency and is fully deterministic. +::: test +name: deleting a present commit marker removes its file +given: { fixture: DELETE_COMMIT_PRESENT } +expect: { marker_absent: true } +::: -Expose a public struct `GeneratedFile` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`) with public fields `path: String` (the generated file's tree-relative path), -`bytes: Vec` (its exact content), and `mode: u32` (its file mode). Expose a -public struct `SeededModule` (same derives) with public fields `state: -store_objects::ModuleState` (the seeded module-state value) and `objects: -std::collections::BTreeMap>` (every component object the state -references, keyed by object name to its envelope bytes, ready to merge into the staged -store). +::: test +name: deleting an absent commit marker is an idempotent success +given: { fixture: DELETE_COMMIT_ABSENT } +expect: { marker_absent: true } +::: -Expose `seed_module_state(prompt_bytes: &[u8], mapl_bytes: Option<&[u8]>, ir_bytes: -Option<&[u8]>, target: &str, generated: &[GeneratedFile], attribution_bytes: -Option<&[u8]>) -> SeededModule`. The `prompt_bytes` are the module's authored full -`.napl` source bytes; both the prompt blob and the block index are derived from them, a -single prompt source feeding both. It composes the frozen builders, never reimplementing -any of them, and assembles both the module-state and its component objects: +## Hermetic staging -- The prompt blob: its envelope is `store_hash::envelope_bytes(ObjectKind::Blob, - prompt_bytes)` and its name is `store_hash::object_name(ObjectKind::Blob, - prompt_bytes)`. The name becomes the state's `prompt_blob`, and the object is - inserted into `objects`. A blob's payload is raw bytes, never canonical JSON. -- The block index: segment the prompt through `store_blocks::segment(prompt, - store_blocks::SEG_VERSION)` where `prompt` is the UTF-8 string of `prompt_bytes`, - name it through `store_blocks::block_index_object_name`, and set that name as the - state's `block_index`. Frame the index's own object by serializing the `BlockIndex` - to a `serde_json::Value` and inserting `store_hash::envelope_bytes(ObjectKind::BlockIndex, - &store_hash::canonical_json(&value))` into `objects` under that same name, which - equals `store_blocks::block_index_object_name` over the index. -- The mapl and ir: when `mapl_bytes` is `Some`, its blob name becomes the state's - `mapl` and its blob object is inserted; when `None`, the state's `mapl` is `None` - and no object is added. The ir follows the identical rule with `ir_bytes` and the - state's `ir`. Both sit at the module level, not inside a target. -- The generated tree for `target`: build one `store_tree::TreeFile` per - `GeneratedFile`, its `path` the file's `path` verbatim, its `hash` the blob object - name `store_hash::object_name(ObjectKind::Blob, &file.bytes)`, and its `mode` the - file's `mode`; call `store_tree::build_tree` over that slice. The build's `root` - becomes the target's `generated_tree`. Insert into `objects` every generated file's - blob object (envelope over its bytes) and, for every `(name, tree)` pair in the - build's `objects`, the tree's envelope - `store_hash::envelope_bytes(ObjectKind::Tree, &store_hash::canonical_json(&value))` - under `name`, where `value` is the serialized `Tree` and `name` equals - `store_tree::tree_object_name` over that tree. -- The attribution: when `attribution_bytes` is `Some`, its blob name becomes the - target's `attribution` and its blob object is inserted verbatim; when `None`, the - target's `attribution` is `None`. The `attribution_bytes` are the store's canonical - attribution blob, the caller having already parsed the on-disk YAML and mapped it to - the canonical form (the attribution-canonical-form rule below), so this function - publishes them exactly as it publishes `mapl_bytes` and `ir_bytes`, byte for byte with - no re-encoding. -- Assemble the `store_objects::ModuleState` with the `prompt_blob`, `block_index`, - `mapl`, and `ir` set above and a `targets` map holding one entry, `target` mapped to - a `store_objects::TargetArtifacts` with the `generated_tree` and `attribution` set - above. The module-state object itself is not inserted into `objects`; it is framed - by `migrate_core::build_staged_store` from the seed, so `objects` carries only the - components the state references. +Expose `stage_object_payload(root: &std::path::Path, staging: &std::path::Path, +name: &str) -> Result`, which materializes an +object's payload into a staging tree by copying the payload bytes into a new file, +never by hardlinking the store object into a writable tree. A staged file and its +store object are always distinct inodes, so a mutation of a staged file can never +reach the published object. Staging copies payload bytes out of store objects, seeds +a build-local lockfile, and projects the workspace manifest; those broader steps are +build-local ephemera by `store_txn_core::classify_path` and never enter the store. ::: test -name: seeding a module names its component blobs and composes its block index and generated tree -given: { fixture: SEED_GREETING } -expect: - prompt_blob: "45b2871a9dd98a8c943d93e345cba9f284f39aec307f3d9c9530561f3ce2382f" - mapl: "4b693e63b8c3b60e9c15db024bf8591e2ebb2af1da3dbe480c1c1a94ec7288ca" - ir: null - attribution_matches_canonical_json: true - block_index_matches_segment: true - generated_tree_matches_build_tree: true - objects_present: true +name: a staged object payload is a copy with an inode distinct from its store object +given: { fixture: STAGE_INODE } +expect: { distinct_inodes: true } ::: -## The attribution canonical form +## The error type -The store's canonical attribution blob is canonical JSON, not the on-disk YAML. A gen -derives a `store_diff::Attribution` ownership document and publishes -`store_hash::canonical_json` of its `serde_json::Value` as the blob, and the sole -store-blob reader, the reducer's attribution loader, JSON-parses that payload and -validates it through `store_diff::validate_attribution`. The object framing does not -police a blob payload's shape, so a YAML blob would pass framing yet fail that reader as -a corrupt object and stall every incremental gen that reads prior attribution. The -migration therefore never seeds the raw YAML bytes; it reconstructs the exact blob a gen -would publish. +Expose a public enum `StoreIoError` (deriving `Debug`) with exactly these variants: -Canonicalizing a module's attribution proceeds in this order, byte-identical to the gen -path, and its result is the attribution blob bytes handed to `seed_module_state`: +- `Io(String)`: an underlying filesystem error, carrying a human-readable context + string. +- `Integrity(String)`: an already-present object whose bytes fail to verify against + the expected name, carrying the offending object name. Publication never degrades a + mismatch into a silent replacement. +- `HeadConflict { expected: Option, found: Option }`: a + compare-and-set whose expected prior HEAD does not match the current HEAD, carrying + both values; the HEAD is left unchanged. +- `Corrupt(String)`: a marker file whose bytes fail to parse into the decision + core's type, carrying the marker's key. Recovery never silently drops a marker it + cannot parse. -- Parse the on-disk attribution YAML text into a `serde_json::Value` the same way a gen - reads a prior attribution back, deserializing the YAML into a `serde_yaml::Value` and - converting that into a `serde_json::Value`. -- Validate and read that value through `schemas_attribution::validate_attribution` into a - `schemas_attribution::Attribution`, the document carrying `module`, `target`, and - `entries`, and tolerating the optional `generatedBy` telemetry block a gen also writes. -- Map that document into a `store_diff::Attribution` exactly as a gen's ownership document - does: `module` and `target` verbatim, and one `store_diff::OwnershipEntry` per - `schemas_attribution::AttributionEntry` with `prompt_lines` the two-element array of the - entry's prompt line range `start` and `end`, `file` the entry's `file`, `lines` the - two-element array of the entry's line range `start` and `end`, and `note` the entry's - `note`. The mapping keeps only these fields and drops the telemetry block, so the - document is the ownership document a gen commits and nothing more. -- Serialize the `store_diff::Attribution` to a `serde_json::Value`, validate it through - `store_diff::validate_attribution` whose `deny_unknown_fields` guarantees the mapping is - lossless, and take `store_hash::canonical_json` of that value as the attribution blob - bytes. These bytes are byte-identical to the blob a fresh gen publishes for the same - attribution. +## Rendering a store I/O failure -Any failure along this path, an attribution that is not valid UTF-8, not valid YAML, -does not validate as an attribution document, or does not map and revalidate as a -canonical ownership document, is a `MigrateIoError::Io` naming the module, carrying a -short message whose bytes are not pinned; the migration never falls back to seeding the -raw YAML bytes. +Implement `std::fmt::Display` for `StoreIoError` so a consumer, in particular the recover +seam that today `Debug`-formats it, can render a store I/O failure as a bare, +human-readable message that carries no `Debug` artifacts: no variant name, no quotes, and +no braces. Each variant renders exactly one deterministic line. `Io` renders `store +filesystem error: {context}`, interpolating its carried context string verbatim. +`Integrity` renders `store object failed its integrity check: {name}`, interpolating its +carried object name verbatim. `Corrupt` renders `corrupt store marker: {key}`, +interpolating its carried marker key verbatim. `HeadConflict` renders `head +compare-and-set conflict: expected {expected}, found {found}`, where each of `expected` +and `found` renders its inner string verbatim when it is `Some` and the bare word `none` +when it is `None`. This addition is purely additive: the `Debug` derive is retained for +the existing variant assertions, and the module's error construction and propagation are +unchanged. The `Display` impl and its `fmt` method carry no doc comment, matching the +crate's existing trait-impl style. -## The genesis parent +## Fixtures the tests pin -Expose `pub const GENESIS_PARENT: &str = -"0000000000000000000000000000000000000000000000000000000000000000"`, the store-wide -genesis sentinel: the all-zeros object name, sixty-four `0` characters. It is -shape-valid, never published, and absent from every store by construction, so the -snapshot spine's absent-ancestor walk terminates at it with no special genesis case. -The same sentinel also stands as the `event_head` of a current-state-only module's root -entry, where it is not an absent ancestor to walk over but the module's empty-history -boundary: the store's reducer recognizes that sentinel value and answers a -current-state-only module's event chain as empty, its classification as -current-state-only, and its ordinal lookup as absent, never fetching a nonexistent event. -This is the exact value `napl migrate` passes as the final snapshot chain root's -parent; `migrate_core::build_staged_store` takes the genesis parent as a parameter and -this module supplies `GENESIS_PARENT` for it in production. It is never confused with -a creation step's absent `hashBefore`, which is a JSON `null`, not this hash string. +Every case builds its store in a fresh temporary directory, performs the case's one +operation, and asserts the pinned observable. No fixture reads a wall clock or draws +a random value: owner tokens are the literal `tok-aaaa`, every hash is a pinned +literal, and the process-liveness probe is a fixed predicate supplied by the case, +never a real clock, a thread race, or a process query. The object names and envelope byte strings below are the exact values the +reference computation `store-io-ref.py` pins; each is the store_hash envelope of its +payload and the SHA-256 name of that envelope. -::: test -name: the genesis parent constant is the all-zeros sentinel -given: { probe: genesis_parent } -expect: { genesis_parent: "0000000000000000000000000000000000000000000000000000000000000000" } -::: +Pinned object constants: -## The migrate command +- **BLOB1**: a `blob` whose payload is the twenty-one bytes `The greeting module.` + followed by one newline. Its envelope bytes are exactly + `napl-store blob v1 21` then one newline then `The greeting module.` then one + newline, and its name is + `0965493c60ab6b118535fc59c5a793c753295acd9fd8d75e084288459624fe93`. +- **BLOB2**: a `blob` whose payload is the six bytes `other` followed by one newline. + Its name is + `ee9b438b4cf07aa2fd3296dcef33b8c72f6c78bd2571ef9f430a4813f080b98d`. It is the + mismatched-bytes source for the integrity case only. +- **SNAP0**: a `snapshot` whose canonical payload is exactly + `{"parents":["9999999999999999999999999999999999999999999999999999999999999999"],"root":{},"seq":0}` followed by one newline. Its envelope bytes are + `napl-store snapshot v1 99` then one newline then that payload, and its name is + `b7281259c1fa6f5b9fa3b295d6ee09705ddb02a3387bdd111888d697b63a4242`. This is the + seq-zero genesis snapshot: its single parent + `9999999999999999999999999999999999999999999999999999999999999999` is a symbolic + ancestor that is never published in any fixture store, so a spine walk that reaches + SNAP0 follows that parent to an absent object and terminates at the genesis + boundary rather than erroring. +- **SNAP1**: a `snapshot` whose canonical payload is + `{"parents":["b7281259c1fa6f5b9fa3b295d6ee09705ddb02a3387bdd111888d697b63a4242"],"root":{},"seq":1}` + followed by one newline, a child of SNAP0 at seq one. Its name is + `289f591aec3ebd915526648ad486d6b028dca5200e9c392b7fdaf4c498f6ec09`. +- **THREES**: the sixty-four-character all-threes hash + `3333333333333333333333333333333333333333333333333333333333333333`, a snapshot name + never published as an object in any fixture store. It keys the genesis-boundary + recovery marker whose candidate snapshot is unloadable. +- **FOURS**: the sixty-four-character all-fours hash + `4444444444444444444444444444444444444444444444444444444444444444`, a generated-tree + name a module state references. The object filed under it in the corrupt-tree fixture + does not verify to it. +- **CORRUPT_TREE**: a `tree` object whose payload is the eight bytes `corrupt` followed + by one newline, so its envelope bytes are `napl-store tree v1 8` then one newline then + `corrupt` then one newline. Its true name is + `88d1a2f9d9f9b4293f90ff789723e66b9cd973d9edffb955d736a7e840766d6e`, not FOURS, so an + object holding these bytes filed at FOURS's fan-out path fails verification against + FOURS. -Expose a public enum `MigrateOutcome` (deriving `Debug`, `Clone`, `PartialEq`, -`Eq`) with exactly these variants: +Pinned marker constants (reused verbatim from the `store_txn_core` corpus, which +owns their canonical serialization; this module writes and reads the same bytes): -- `Migrated { head: String, object_count: usize }`, the head snapshot name the store - was written to and the number of objects written. -- `RefusedDirty`, the migration refused because the working tree is dirty. -- `Aborted(Vec)`, the migration aborted because the current audit is worse - than the sanctioned baseline, carrying the offending keys in ascending byte order. +- **COMMIT_M**: the `CommitMarker` with domain `greeting`, owner `tok-aaaa`, + snapshot the sixty-four-character all-ones hash + `1111111111111111111111111111111111111111111111111111111111111111`, and one + install step, a `writeFile` of path `greeting/src/lib.rs` at mode `33188` with hash + the all-twos hash + `2222222222222222222222222222222222222222222222222222222222222222`. Its canonical + bytes are exactly + `{"domain":"greeting","owner":"tok-aaaa","snapshot":"1111111111111111111111111111111111111111111111111111111111111111","steps":[{"hash":"2222222222222222222222222222222222222222222222222222222222222222","mode":33188,"op":"writeFile","path":"greeting/src/lib.rs"}]}` + followed by one newline. +- **ATTEMPT_M**: the in-flight `AttemptMarker` with attempt id `att-0001`, empty + diagnostics, module `greeting`, operation `agent-run`, state `inFlight`, target + `rust`. Its canonical bytes are exactly + `{"attemptId":"att-0001","diagnostics":[],"module":"greeting","operation":"agent-run","state":"inFlight","target":"rust"}` + followed by one newline. +- **COMMIT_M2**: a second `CommitMarker` keyed by the same all-ones snapshot as COMMIT_M + but with owner `tok-bbbb` and no install steps, so it differs from COMMIT_M. Its + canonical bytes are exactly + `{"domain":"greeting","owner":"tok-bbbb","snapshot":"1111111111111111111111111111111111111111111111111111111111111111","steps":[]}` + followed by one newline. It is the conflicting-marker source for the commit-marker + no-replace case only. -Expose `run_migrate(root: &std::path::Path, dest: &std::path::Path, dirty: bool, -sanctioned: &migrate_core::AuditResult, prompt_paths: &std::collections::BTreeMap) -> Result`. The `dirty` flag, the `sanctioned` -baseline, and the `prompt_paths` mapping are injected values, never read from a clock, a -live `git` invocation, or a directory walk, so the whole command is deterministic and -tempdir-testable. The `prompt_paths` mapping carries each module's authored full `.napl` -source path relative to `root`, the module name mapped to its authored prompt path; it -is the sole source of the authored bytes both the prompt blob and the block index are -derived from, since that path is not recoverable from the map. It proceeds in this -order, writing nothing to `dest` unless it reaches the materialize step: +The store fixtures, each built in its own tempdir: -- Read `journal.jsonl` and `map.json` from `root`, parse the map through `parse_map`, - parse the journal through `migrate_core::parse_journal`, and audit it through - `migrate_core::audit` against the map's `current_hashes` and the map's module - inventory (the set of the map's `ModuleView` module names). The same inventory the - audit command uses, so a current-state-only module surfaces identically whether the - maintainer runs `--audit` first or the migration audits internally. -- If `dirty` is `true`, return `Ok(MigrateOutcome::RefusedDirty)` without writing any - file: a dirty tree is refused before anything is staged. -- Compute `migrate_core::compare_to_sanctioned(¤t_audit, sanctioned)`. If it is - `MigrateDecision::Abort(keys)`, return `Ok(MigrateOutcome::Aborted(keys))` without - writing any file: a live ledger worse than the sanctioned report aborts. -- Otherwise seed each module. For every `ModuleView` in the map, read its authored full - `.napl` source bytes from `root` joined with that module's path in `prompt_paths` - through `read_bytes_at`, the single prompt source both the prompt blob and the block - index are derived from (the prompt-source rule above), refusing with - `MigrateIoError::Io` a module the mapping does not carry rather than silently skipping - it; read its mapl bytes from `root` joined with - `.napl/mapl/.mapl` through - `read_optional_at` (an absent file is `None`, an unreadable file is an `Io` error); - read its ir bytes from `root` joined with `.napl/ir/.yaml` through - `read_optional_at`; read its attribution YAML from `root` joined with - `.napl/attribution/.yaml` through `read_optional_at`, and when it is `Some` - canonicalize those YAML bytes into the store's attribution blob bytes before seeding - (the attribution-canonical-form rule below), surfacing a present attribution that - fails to parse, validate, or map as a `MigrateIoError::Io` naming the module and never - seeding it verbatim, while an absent attribution stays `None`. For the - module's one target and its file list, read each generated file's bytes from `root` - joined with that file's full map-recorded path (the byte read is always - root-relative), and build a `GeneratedFile` whose `bytes` are that content, whose - `mode` is `store_tree::FILE_MODE`, and whose `path` is the map-recorded path with its - leading `.napl/src//` prefix stripped, where `` is the module's target - name. The strip makes `GeneratedFile.path` tree-relative as that field's contract - requires and byte-identical to the module-topped, target-root-relative path a - generation commits, so the seeded generated tree equals the store's generated tree and - the reducer's exact-tree-equality classification never reads the migrated module as - drifted. A map path that does not begin with the expected `.napl/src//` prefix - for its target is a migration error surfaced as `MigrateIoError::Io` carrying a short - message naming the offending path, never a silent verbatim pass-through; the message - bytes are not pinned. Only the tree path is stripped, the byte read keeps the full - root-relative path. Call `seed_module_state` with the authored source bytes and the - canonicalized attribution blob bytes and collect its `SeededModule`. -- Assemble the seeds map (module name to the seeded `store_objects::ModuleState`), - call `migrate_core::build_staged_store(&journal, ¤t_audit, &seeds, - GENESIS_PARENT)`, and merge every seeded module's component objects into the staged - store's `objects` (a shared object name maps to identical bytes, so a merge is - idempotent). Materialize the merged staged store into `dest` through - `materialize_staged_store`, and return `Ok(MigrateOutcome::Migrated { head: - staged.head.clone(), object_count: staged.objects.len() })`. +- **READ_BLOB**: a store holding BLOB1 at its fan-out path. The provider queries + BLOB1's name and returns BLOB1's envelope bytes. +- **READ_SNAPSHOT**: a store holding SNAP0 at its fan-out path. The provider queries + SNAP0's name and returns SNAP0's envelope bytes. +- **READ_ABSENT**: a store holding BLOB1 only. The provider queries the all-zero + sixty-four-hex name, absent from the store, and returns `None`. +- **READ_HEAD_PRESENT**: a store whose `HEAD` file holds SNAP0's name followed by one + newline. `head_snapshot_hash` returns SNAP0's name with no newline. +- **READ_HEAD_ABSENT**: a store with no `HEAD` file. `head_snapshot_hash` returns + `None`. +- **PARITY**: a store holding BLOB1 and SNAP0 at their fan-out paths with `HEAD` at + SNAP0. An in-memory provider is built from the same two objects and the same head. + The two providers are queried for BLOB1's name, SNAP0's name, the all-zero absent + name, and the head, and every answer is equal between them. +- **PUBLISH_NEW**: an empty store. `publish_object` publishes a `blob` with BLOB1's + payload under owner token `tok-aaaa`. It returns BLOB1's name, and the file at + BLOB1's fan-out path holds BLOB1's exact envelope bytes. +- **PUBLISH_IDEMPOTENT**: a store already holding BLOB1 at its fan-out path. + `publish_object` publishes a `blob` with BLOB1's payload again under owner token + `tok-aaaa`. It returns BLOB1's name and leaves the existing object's bytes + untouched. +- **PUBLISH_MISMATCH**: a store whose file at BLOB1's fan-out path holds BLOB2's + envelope bytes instead of BLOB1's, a corruption. `publish_object` publishing + BLOB1's payload reads the existing bytes, fails verification against BLOB1's name, + and returns `StoreIoError::Integrity` naming BLOB1, replacing nothing. +- **SET_HEAD**: an empty store. `write_head` writes SNAP0's name; the `HEAD` file + then holds SNAP0's name followed by one newline. +- **CAS_SUCCESS**: a store whose `HEAD` holds SNAP0's name. + `compare_and_set_head` with expected SNAP0 and next SNAP1 succeeds; the `HEAD` file + then holds SNAP1's name followed by one newline. +- **CAS_CONFLICT**: a store whose `HEAD` holds SNAP0's name. + `compare_and_set_head` with expected the all-zero sixty-four-hex name (which HEAD + does not hold) and next SNAP1 returns `StoreIoError::HeadConflict`, and the `HEAD` + file still holds SNAP0's name followed by one newline. +- **COMMIT_MARKER**: an empty store. `write_commit_marker` writes COMMIT_M; the file + at `markers/1111111111111111111111111111111111111111111111111111111111111111.json` + holds COMMIT_M's canonical bytes. +- **ATTEMPT_MARKER**: an empty store. `write_attempt_marker` writes ATTEMPT_M under + owner token `tok-aaaa`; the file at `attempts/tok-aaaa.json` holds ATTEMPT_M's + canonical bytes. +- **INSTALL_STAMP**: a store whose `HEAD` holds SNAP0's name and whose live tree the + installer materializes. After `install_to_head` the `INSTALLED` stamp holds + SNAP0's name followed by one newline. +- **RECOVER_ANCESTOR**: a store whose `HEAD` holds SNAP1's name, whose `INSTALLED` + stamp holds SNAP0's name, and which carries a commit marker keyed by SNAP0 (an + ancestor of SNAP1 by the spine). `recover` rolls the installed stamp forward to + SNAP1 and deletes the SNAP0 marker; after recovery the SNAP0 marker file is absent + and the `INSTALLED` stamp holds SNAP1's name followed by one newline. +- **RECOVER_FOREIGN**: a store whose `HEAD` holds SNAP0's name and which carries a + commit marker keyed by SNAP1 (not an ancestor of SNAP0), whose owner lock names a + host different from `this_host`. Because a foreign-host owner is never judged dead, + `recover` leaves the SNAP1 marker in place; after recovery the SNAP1 marker file is + still present. +- **JANITOR_TMP**: a store whose `tmp/` directory holds a scratch entry. After + `janitor` the `tmp/` directory holds no entries. +- **STAGE_INODE**: a store holding BLOB1 at its fan-out path. + `stage_object_payload` materializes BLOB1's payload into a staging tree; the staged + file and the store object file have distinct inode numbers. +- **CAS_LOCK_HELD**: a store whose `HEAD` holds SNAP0's name. + `compare_and_set_head_observed` with expected SNAP0 and next SNAP1 runs with an + observer that records whether `locks/HEAD.lock` exists when it is called. The observer + finds the lock present; after the call returns, `locks/HEAD.lock` is absent and the + `HEAD` file holds SNAP1's name followed by one newline. +- **CAS_SERIALIZED_LOSER**: a store whose `HEAD` holds SNAP0's name. Two + `compare_and_set_head` calls run one after the other, both with expected SNAP0 and + next SNAP1. The first succeeds and moves HEAD to SNAP1. The second re-reads HEAD under + the same HEAD lock, finds SNAP1 rather than the expected SNAP0, and returns + `StoreIoError::HeadConflict` carrying expected SNAP0's name and found SNAP1's name; + after both calls the `HEAD` file still holds SNAP1's name followed by one newline and + `locks/HEAD.lock` is absent. +- **STEAL_SUCCESS**: a store whose `locks/greeting.lock` holds a stale lock owned on + `this_host` whose pid the `is_alive` probe reports dead, with no janitor lock present. + `acquire_lock` for the name `greeting` on `this_host` with that probe steals the stale + lock: afterward the file `locks/greeting.lock.stolen` exists, the stale lock renamed + aside, and `locks/greeting.lock` exists, the freshly acquired lock. +- **STEAL_BLOCKED**: a store whose `locks/greeting.lock` holds the same stale dead-local + lock, but whose `locks/janitor.lock` is already held by an owner on a foreign host, + which is never stealable, so the janitor lock cannot be taken. `acquire_lock` for + `greeting` on `this_host` does not steal: `locks/greeting.lock.stolen` is absent, the + stale `locks/greeting.lock` still holds its original bytes, and the call returns + `StoreIoError::Io`. +- **COMMIT_MARKER_REPUBLISH_IDENTICAL**: a store already holding COMMIT_M at + `markers/1111111111111111111111111111111111111111111111111111111111111111.json`. + `write_commit_marker` writes COMMIT_M again; the call succeeds idempotently and the + file still holds COMMIT_M's canonical bytes, unchanged. +- **COMMIT_MARKER_CONFLICT**: a store already holding COMMIT_M at + `markers/1111111111111111111111111111111111111111111111111111111111111111.json`. + `write_commit_marker` of COMMIT_M2, a different commit marker keyed by the same + all-ones snapshot, returns `StoreIoError::Integrity` naming the all-ones snapshot and + replaces nothing: the file still holds COMMIT_M's original canonical bytes. +- **INSTALL_MISSING_MODULE_STATE**: a store whose `HEAD` holds a snapshot S, published + as a snapshot object, whose root names the greeting module with a module-state hash + absent from the store, and with no `INSTALLED` stamp. `install_to_head` reads S, finds + the named module-state object absent, and aborts with `StoreIoError::Integrity` before + any install step; afterward the `INSTALLED` stamp is absent and the live tree holds no + files. +- **INSTALL_CORRUPT_TREE**: a store whose `HEAD` holds a snapshot S, published as a + snapshot object, whose root names the greeting module with a valid module-state object + present, that module-state naming its generated tree under FOURS, the object at FOURS's + fan-out path holding CORRUPT_TREE's bytes, which do not verify to FOURS, and with no + `INSTALLED` stamp. `install_to_head` reads S and the module-state, reads the generated + tree object, finds it fails to verify against FOURS, and aborts with + `StoreIoError::Integrity` before any install step; afterward the `INSTALLED` stamp is + absent and the live tree holds no files. +- **RECOVER_GENESIS_PARENT**: a store holding SNAP0 and SNAP1 as objects, `HEAD` at + SNAP1, no `INSTALLED` stamp, and a commit marker keyed by THREES, never published as an + object, whose owner lock names a foreign host. `recover` computes ancestry for the + THREES marker: because the THREES candidate is itself unloadable the walk carries no + sequence floor and descends HEAD (SNAP1) to SNAP0 to the genesis sentinel parent, + dequeues that absent parent, and returns a clean non-ancestor answer; the foreign-host + owner is treated as live, so the marker is left. After recovery the THREES marker file + at `markers/3333333333333333333333333333333333333333333333333333333333333333.json` is + still present, `HEAD` still holds SNAP1's name, and no `INSTALLED` stamp exists. This is + the fixture that forces the genesis-parent dequeue: unlike RECOVER_FOREIGN, whose marker + is keyed by the published SNAP1 so the walk prunes at SNAP0 by sequence floor and never + reaches the boundary, this marker's key is unpublished, so the walk must descend the + full spine to the absent genesis parent. +- **JANITOR_DEAD_LOCAL_COLLECTS**: a store holding SNAP0 and SNAP1 as objects, `HEAD` at + SNAP0, and a commit marker keyed by SNAP1, not an ancestor of SNAP0 by the spine, whose + owner lock names `this_host` and whose pid the `is_alive` probe reports dead. `janitor` + on `this_host` with that probe judges the owner dead by the liveness decision and the + marker a non-ancestor, and collects it: after the janitor the SNAP1 marker file is + absent. +- **JANITOR_FOREIGN_LEAVES**: the same store shape but the owner lock names a foreign + host. `janitor` judges the foreign-host owner live, so it leaves the non-ancestor + marker: after the janitor the SNAP1 marker file is still present. -Every module the map declares is seeded, including a module whose `prompt_path` and -generated files exist on disk but whose legacy events have vanished from the journal: -the emptied-mapl current-state-only scar. This module seeds such a module exactly like -any other, from its current working files, and hands it into the seeds map. The -reachability of that seed is the builder's contract, not this shell's: `migrate_core:: -build_staged_store` gives every seeded module a root entry, so a current-state-only -module reaches the migrated `HEAD` with its `event_head` pointing at the genesis-parent -sentinel `GENESIS_PARENT`, never dropped from the snapshot. This shell fabricates no -event to make that happen; it only seeds the current state and lets the builder place -it in the root. +The three payload-preflight fixtures share one shape and differ only in the failing +payload. Each store holds a valid generated tree for the greeting module naming +exactly two files in ascending name order, both at mode `33188`: `a.rs` whose blob is +BLOB1, which is published at BLOB1's fan-out path and is a healthy payload, and `b.rs` +whose blob is the failing reference the fixture names. The tree is published as a tree +object at its own name; a valid module state names that tree as its `rust` generated +tree and is published at its own name; a snapshot S names the greeting module with that +module state, is published as a snapshot object, `HEAD` holds S, and there is no +`INSTALLED` stamp. In install-step order `greeting/a.rs` sorts before `greeting/b.rs`, +so a healthy BLOB1 for `a.rs` is planned before the failing payload for `b.rs`; a +lazy installer that verified payloads only as it wrote them would materialize +`greeting/a.rs` and only then fail on `b.rs`. Because the installer preflights every +planned payload before writing any, all three fixtures leave the live tree with no +files and no `INSTALLED` stamp. -Only the `Migrated` path writes to `dest`; the `RefusedDirty` and `Aborted` paths -leave `dest` untouched. +- **INSTALL_PAYLOAD_ABSENT**: the `b.rs` entry names BLOB2's blob name, and BLOB2 is + never published, so its payload object is absent. `install_to_head` preflights the + planned payloads, finds BLOB2's object absent, and aborts with + `StoreIoError::Integrity` naming BLOB2's name before any step; afterward the + `INSTALLED` stamp is absent, the live tree holds no files, and in particular + `greeting/a.rs` was never written even though BLOB1 is present. +- **INSTALL_PAYLOAD_CORRUPT**: the `b.rs` entry names FOURS, and the object filed at + FOURS's fan-out path holds BLOB2's envelope bytes, which verify to BLOB2's name and + not to FOURS. `install_to_head` preflights the planned payloads, reads the object at + FOURS, finds it fails to verify against FOURS, and aborts with + `StoreIoError::Integrity` naming FOURS before any step; afterward the `INSTALLED` + stamp is absent, the live tree holds no files, and `greeting/a.rs` was never written. +- **INSTALL_PAYLOAD_WRONG_KIND**: the `b.rs` entry names SNAP0, and SNAP0 is published + as a snapshot object at its fan-out path, so the name resolves to an object that + verifies but whose kind is `snapshot`, not `blob`. `install_to_head` preflights the + planned payloads, reads SNAP0, finds it is not a `store_hash::ObjectKind::Blob`, and + aborts with `StoreIoError::Integrity` naming SNAP0's name before any step; afterward + the `INSTALLED` stamp is absent, the live tree holds no files, and `greeting/a.rs` + was never written. + +The dead-attempt surfacing and marker-cleanup fixtures reuse ATTEMPT_M and COMMIT_M +verbatim and add one owner-lock shape per liveness verdict. Every owner lock is a +`store_txn_core::LockFile` written at `locks/.lock` as the core's canonical +lock bytes, its pid judged only by the fixed `is_alive` predicate the case supplies, never +by a real process query; `this_host` is a fixed host identifier the case supplies: + +- **SURFACE_DEAD**: a store holding ATTEMPT_M at `attempts/tok-aaaa.json` and an owner lock + at `locks/tok-aaaa.lock` naming `this_host` and a pid the `is_alive` probe reports dead. + `surface_attempt_markers(root, this_host, is_alive)` returns exactly one `SurfacedAttempt` + whose `owner_token` is `tok-aaaa`, whose `marker` is ATTEMPT_M (its `attempt_id` + `att-0001`), and whose `owner_live` is `false`. +- **SURFACE_LIVE**: the same store but the owner lock's pid the `is_alive` probe reports + alive on `this_host`. The one returned `SurfacedAttempt`'s `owner_live` is `true`. +- **SURFACE_FOREIGN**: the same store but the owner lock names a host different from + `this_host`. The one returned `SurfacedAttempt`'s `owner_live` is `true`, because a + foreign-host owner is never judged dead. +- **SURFACE_NO_LOCK**: a store holding ATTEMPT_M at `attempts/tok-aaaa.json` with no + `locks/tok-aaaa.lock` file at all. The one returned `SurfacedAttempt`'s `owner_live` is + `false`, an absent owner lock being a dead owner. +- **SURFACE_MALFORMED**: a store holding ATTEMPT_M at `attempts/tok-aaaa.json` and a + `locks/tok-aaaa.lock` whose bytes are not a parseable `store_txn_core::LockFile`. The one + returned `SurfacedAttempt`'s `owner_live` is `true`, a malformed lock being stale-eligible + yet never judged dead by this read. +- **SURFACE_EMPTY**: a store with no `attempts/` directory. `surface_attempt_markers` + returns an empty vector. +- **SURFACE_TWO**: a store holding ATTEMPT_M at `attempts/tok-aaaa.json` and a second + in-flight attempt marker (ATTEMPT_M's bytes) at `attempts/tok-bbbb.json`, each with a + dead-local owner lock at its own `locks/.lock`. `surface_attempt_markers` returns + two `SurfacedAttempt`s whose `owner_token`s are `tok-aaaa` then `tok-bbbb`, in that + ascending order. +- **SURFACE_CORRUPT**: a store whose `attempts/tok-aaaa.json` holds bytes that do not parse + into a `store_txn_core::AttemptMarker`. `surface_attempt_markers` returns + `StoreIoError::Corrupt` naming the owner token `tok-aaaa`, dropping no marker silently. +- **DELETE_ATTEMPT_PRESENT**: a store holding ATTEMPT_M at `attempts/tok-aaaa.json`. + `delete_attempt_marker(root, "tok-aaaa")` succeeds and the file at `attempts/tok-aaaa.json` + is afterward absent. +- **DELETE_ATTEMPT_ABSENT**: a store with no `attempts/tok-aaaa.json`. + `delete_attempt_marker(root, "tok-aaaa")` succeeds idempotently and the file at + `attempts/tok-aaaa.json` is afterward absent. +- **DELETE_COMMIT_PRESENT**: a store holding COMMIT_M at + `markers/1111111111111111111111111111111111111111111111111111111111111111.json`. + `delete_commit_marker(root, "1111111111111111111111111111111111111111111111111111111111111111")` + succeeds and that marker file is afterward absent. +- **DELETE_COMMIT_ABSENT**: a store holding no commit marker at the all-ones snapshot. + `delete_commit_marker(root, "1111111111111111111111111111111111111111111111111111111111111111")` + succeeds idempotently and that marker file is afterward absent. + +## Test and code discipline + +The frontmatter test suite is a contract, not a sketch. Each case in it is realized +by its own dedicated test function that exercises only that case, so the number of +dedicated tests is at least the number of frontmatter cases. Never fold two or more +cases into one shared test, and a single test that loops over a table of inputs never +counts as the dedicated test any of those inputs requires: such a combined test may +exist only as an extra. Cases that differ only in their input or in the property +asserted are still distinct cases, each owed its own dedicated test. + +Enumerate the case families by name, each owed its own dedicated verbatim test. The +provider read cases: the present-blob read READ_BLOB, the present-snapshot read +READ_SNAPSHOT, the absent-name read READ_ABSENT, the present-head read +READ_HEAD_PRESENT, the absent-head read READ_HEAD_ABSENT, and the in-memory parity +case PARITY, each its own test. The publication cases: the new-object name case and +the new-object bytes case, both over PUBLISH_NEW but asserting different observables +and therefore two distinct tests; the idempotent PUBLISH_IDEMPOTENT case; and the +integrity PUBLISH_MISMATCH case. The HEAD cases: the SET_HEAD bytes case, the +CAS_SUCCESS bytes case, the CAS_CONFLICT error case and the CAS_CONFLICT +head-unchanged case, the last two over the same fixture but asserting the error and +the untouched HEAD respectively and therefore two distinct tests. The marker cases: +the COMMIT_MARKER bytes case and the COMMIT_MARKER path case, and the ATTEMPT_MARKER +bytes case and the ATTEMPT_MARKER path case, each its own test. The installer stamp +case INSTALL_STAMP. The recovery cases: the RECOVER_ANCESTOR marker-deleted case and +the RECOVER_ANCESTOR installed-advanced case, over the same fixture but asserting +different observables and therefore two distinct tests, and the RECOVER_FOREIGN +leave case. The janitor case JANITOR_TMP. The staging inode case STAGE_INODE. + +The design-fix cases, each owed its own dedicated verbatim test. The HEAD-lock cases: +CAS_LOCK_HELD, whose observer sees `locks/HEAD.lock` present during the critical section +and which additionally asserts the lock file absent after return and the `HEAD` file +holding SNAP1's name; and CAS_SERIALIZED_LOSER, whose second serialized compare-and-set +returns `StoreIoError::HeadConflict` and which additionally asserts the conflict carries +expected SNAP0's name and found SNAP1's name, the `HEAD` file still holding SNAP1's name, +and `locks/HEAD.lock` absent. The lock-steal cases: STEAL_SUCCESS, which asserts the +renamed-aside `locks/greeting.lock.stolen` present and additionally the fresh +`locks/greeting.lock` present; and STEAL_BLOCKED, which asserts +`locks/greeting.lock.stolen` absent and additionally the stale lock's original bytes +unchanged and a `StoreIoError::Io` return. The commit-marker no-replace cases: +COMMIT_MARKER_REPUBLISH_IDENTICAL, which asserts the marker file still holds COMMIT_M's +canonical bytes after an idempotent rewrite; and COMMIT_MARKER_CONFLICT, which asserts +`StoreIoError::Integrity` naming the all-ones snapshot and additionally that the marker +file still holds COMMIT_M's original bytes, proving the conflicting COMMIT_M2 replaced +nothing. The installer-abort cases: INSTALL_MISSING_MODULE_STATE and INSTALL_CORRUPT_TREE, +each asserting `StoreIoError::Integrity` and additionally that the `INSTALLED` stamp is +absent and the live tree holds no files, proving no step ran and no stamp was written. The +genesis-boundary recovery case RECOVER_GENESIS_PARENT, which asserts the THREES marker +still present and additionally that `HEAD` still holds SNAP1's name and no `INSTALLED` +stamp exists, and whose fixture keys its marker by an unpublished snapshot so the walk +actually dequeues the genesis parent, distinct from RECOVER_FOREIGN, which prunes at SNAP0 +and never reaches that boundary. The janitor liveness cases: JANITOR_DEAD_LOCAL_COLLECTS, +which asserts the SNAP1 marker absent after a dead-local-owner collection; and +JANITOR_FOREIGN_LEAVES, which asserts the SNAP1 marker present after a foreign-host-owner +leave. Each of these eleven is a distinct dedicated test, and none discharges another. + +The payload-preflight cases, each owed its own dedicated verbatim test: +INSTALL_PAYLOAD_ABSENT, which asserts `StoreIoError::Integrity` naming BLOB2's name and +additionally that the `INSTALLED` stamp is absent and the live tree holds no files, so +the healthy BLOB1 for `greeting/a.rs` that sorts before the absent `b.rs` payload was +never written; INSTALL_PAYLOAD_CORRUPT, which asserts `StoreIoError::Integrity` naming +FOURS and additionally the `INSTALLED` stamp absent and the live tree holding no files; +and INSTALL_PAYLOAD_WRONG_KIND, which asserts `StoreIoError::Integrity` naming SNAP0's +name and additionally the `INSTALLED` stamp absent and the live tree holding no files, +proving the installer rejects a payload whose object verifies but is a snapshot rather +than a `store_hash::ObjectKind::Blob`. Each of these three is a distinct dedicated +test, each builds the two-file greeting tree its fixture names with a healthy BLOB1 +payload sorted before the failing payload, and none discharges another. The +live-tree-holds-no-files assertion is the partial-write trap: it fails for any +installer that materializes the earlier healthy payload before verifying the later +failing one. + +Inside a case's dedicated test, build exactly the store the case names, byte for +byte, perform exactly the one operation the case names, and assert its pinned +observable verbatim: no shorter or more convenient stand-in, no re-derivation of a +value the case pins as a literal, and no weakening of a pinned equality into an +`is_ok`, inequality-only, or recomputed check. Where a case pins envelope bytes, read +the bytes back from the store file or obtain them from `object_bytes` and compare to +the full literal, never a prefix. Where a case pins an object name, obtain it from +`publish_object`'s return or from `store_hash::object_name` over the case's payload +and compare to the literal. Where a case pins a HEAD, INSTALLED, marker, or attempt +file, read that exact file's bytes and compare to the full literal including its +single trailing newline. Where a case pins a path, assert the file exists at exactly +that path relative to the store root. + +The byte-pinned literals, each transcribed verbatim and never paraphrased: BLOB1's +envelope `napl-store blob v1 21` then newline then `The greeting module.` then +newline; SNAP0's envelope `napl-store snapshot v1 99` then newline then +`{"parents":["9999999999999999999999999999999999999999999999999999999999999999"],"root":{},"seq":0}` then newline; BLOB1's name +`0965493c60ab6b118535fc59c5a793c753295acd9fd8d75e084288459624fe93`; SNAP0's name and +HEAD line `b7281259c1fa6f5b9fa3b295d6ee09705ddb02a3387bdd111888d697b63a4242`; SNAP1's +name and HEAD line `289f591aec3ebd915526648ad486d6b028dca5200e9c392b7fdaf4c498f6ec09`; +COMMIT_M's canonical bytes and ATTEMPT_M's canonical bytes as given above; the marker +path `markers/1111111111111111111111111111111111111111111111111111111111111111.json`; +and the attempt path `attempts/tok-aaaa.json`. + +Named-substitution prohibitions for this module's confusable tokens are strict: never +read a HEAD literal where a case pins an INSTALLED literal or the reverse, never write +SNAP0's name where a case pins SNAP1's name or the reverse, never assert BLOB1's name +where a case pins BLOB2's name, never key a marker under `attempts/` where a case pins +`markers/` or the reverse, never write the owner token as anything but the literal +`tok-aaaa`, never substitute `object_bytes` where a case pins `head_snapshot_hash`, +and never assert a prefix or a length where a case pins full bytes. The two providers +in PARITY are compared for full equality on every queried name and on the head, never +by a subset or a spot check. The PUBLISH_NEW name case asserts only the returned name +and the PUBLISH_NEW bytes case asserts only the stored bytes, so neither discharges +the other; likewise the CAS_CONFLICT error case and the CAS_CONFLICT head-unchanged +case are separate, and the RECOVER_ANCESTOR marker-deleted case and its +installed-advanced case are separate. + +The error cases assert their exact variant and never weaken it: the PUBLISH_MISMATCH +case asserts `StoreIoError::Integrity` and additionally that the existing object's +bytes are unchanged, proving the mismatch replaced nothing; the CAS_CONFLICT case +asserts `StoreIoError::HeadConflict` and, in its sibling case, that HEAD still holds +SNAP0's name. Neither error case is discharged by a mere `is_err`. + +Closing mandate on one-to-one case fidelity. Every case in the frontmatter test suite +above, without exception, is realized by exactly one dedicated test that builds that +case's own store, performs that case's own operation, and asserts that case's own +pinned observable, neither narrower nor recomputed. A test that inspects a different +or narrower property than its case declares, that swaps in a lighter or neighboring +fixture, that weakens a pinned equality into an `is_ok` or inequality-only check, or +that reuses a sibling case's values, does not discharge the case and leaves it +untested. Two cases that share a fixture but assert different observables are two +separate cases, each owed its own verbatim test: the two PUBLISH_NEW cases never +discharge one another, the two CAS_CONFLICT cases never discharge one another, the two +COMMIT_MARKER cases never discharge one another, the two ATTEMPT_MARKER cases never +discharge one another, and the two RECOVER_ANCESTOR cases never discharge one another. +Supplemental tests beyond the suite are named for what they add and are never counted +as, nor substituted for, any suite case's dedicated test. The number of dedicated +tests is therefore at least the number of frontmatter cases, and each case above is +discharged by its own verbatim test. + +The dead-attempt surfacing and marker-cleanup cases, each owed its own dedicated verbatim +test. The surfacing liveness cases SURFACE_DEAD, SURFACE_LIVE, SURFACE_FOREIGN, +SURFACE_NO_LOCK, and SURFACE_MALFORMED each build their one attempt marker and owner-lock +shape, run `surface_attempt_markers`, and assert the one returned `SurfacedAttempt`'s +`owner_live` verdict exactly, and SURFACE_DEAD additionally asserts the returned +`owner_token` is `tok-aaaa` and the returned `marker`'s `attempt_id` is `att-0001`, proving +the parsed record is carried back whole rather than a bool alone; none of these five +discharges another, each pins a distinct liveness verdict, and no verdict is weakened into +an `is_ok` or a length-only check. SURFACE_EMPTY asserts the returned vector is empty over a +store with no `attempts/` directory. SURFACE_TWO asserts the two returned `owner_token`s are +`tok-aaaa` then `tok-bbbb` in that order, proving the ascending owner-token order, and never +a set-membership check that would pass on either order. SURFACE_CORRUPT asserts +`StoreIoError::Corrupt` naming `tok-aaaa` and never weakens it into an `is_err` and never +lets the corrupt marker be silently dropped from the list. The delete cases +DELETE_ATTEMPT_PRESENT and DELETE_ATTEMPT_ABSENT each assert the attempt file at +`attempts/tok-aaaa.json` is absent after the delete, the present case proving the file is +removed and the absent case proving the idempotent success returns `Ok(())` over an already +absent file; DELETE_COMMIT_PRESENT and DELETE_COMMIT_ABSENT each assert the commit marker +file at the all-ones snapshot path is absent after the delete, the present case proving +removal and the absent case proving idempotent success. Each of these twelve is a distinct +dedicated test, and none discharges another; `surface_attempt_markers` writes and deletes +nothing, so a surfacing case that finds a marker removed or a lock rewritten does not +discharge its case and fails. + +Beyond the suite, one dedicated supplemental test per `StoreIoError` variant asserts that +variant's exact `Display` line verbatim through `to_string()`: an `Io("disk full")` +renders `store filesystem error: disk full`, an `Integrity("abc")` renders `store object +failed its integrity check: abc`, a `Corrupt("tok-aaaa")` renders `corrupt store marker: +tok-aaaa`, and a `HeadConflict { expected: Some("aaa"), found: Some("bbb") }` renders +`head compare-and-set conflict: expected aaa, found bbb` while the same test also asserts a +`HeadConflict { expected: None, found: None }` renders `head compare-and-set conflict: +expected none, found none`, pinning both the present and absent arms of its two fields. +Each of these four is its own dedicated test asserting only its own variant's line, and +none replaces or subsumes a suite case. + +Write no explanatory line or block comments in the generated code, and no doc comment +on any private item. The crate keeps its module-level `//!` summary. Every public +type, every public enum variant, and every public function carries a `///` doc +comment restating its contract; a public struct field carries a `///` doc comment +only where its name alone does not convey the contract, and simply named fields may +carry none. The private helper functions and private constants of this module carry +no doc comment. Beyond the module summary and these public-item doc comments the code +carries no commentary of any kind. Use no em dashes anywhere in the generated code, +including inside string literals and doc comments. + +## store_diff +# Store diff: hierarchical prompt alignment, attribution ownership, and the LLM bundle + +This module defines the pure diff engine of the NAPL content-addressed store: the +structural comparison of two module-state prompt sides that produces the compact, +precise change bundle an agent, the reword judge, and the audit consumer read. It +does three things, all pure, no filesystem, no I/O, no clocks, no randomness: + +- **Hierarchical prompt alignment**: given two block indexes and their prompt + bytes, it aligns atoms first, then sub-spans inside a corresponding prose atom + pair, and reports the changed occurrences at both levels with their old and new + occurrence identity, byte-derived text, and line spans. +- **Attribution ownership resolution**: it maps occurrences to the line-based + attribution document by span intersection, deliberately coarsening to the + enclosing atom when a line range cannot uniquely select one sentence. +- **Bundle assembly**: it combines the prompt changes, the generated-tree diff, + and the two ownership sides into one bundle that labels old ownership, new + ownership, and unowned code changes distinctly, and renders that bundle to the + canonical blob bytes the store references. + +Bring in `serde` and `serde_json`. + +## Where this code lives + +The working directory is a Cargo workspace whose root manifest is written and +owned by the toolchain, leave it alone. Create this module as its own member crate +in a subdirectory named `store_diff/`: `store_diff/Cargo.toml` (package name +`store_diff`) and `store_diff/src/lib.rs`. Touch nothing outside `store_diff/`. +Ensure `cargo test` passes from the workspace root before finishing. + +## Builds on three modules of this workspace + +This crate composes on three generated sibling crates. Use each one's public API, +do not reimplement its types or logic, and do not depend on any hand-written crate. +Depend on exactly these three, and do not bring in the `sha2` crate directly. + +- **`store_blocks`** (`../store_blocks`) supplies the block-index types this + module aligns. It uses `store_blocks::BlockIndex` with its public fields + `seg_version: u32`, `atoms: Vec`, and `sub_spans: + Vec`; `store_blocks::AtomRecord` with public fields + `atom_index: u32`, `content_hash: String`, `byte_start: u32`, `byte_end: u32`, + `line_start: u32`, `line_end: u32`; and `store_blocks::SubSpanRecord` with public + fields `parent_atom_index: u32`, `sub_index: u32`, `content_hash: String`, + `byte_start: u32`, `byte_end: u32`, `line_start: u32`, `line_end: u32`. This + module treats every `content_hash` as an opaque occurrence token compared only + for equality; it never recomputes a content hash and so does not depend on the + `hash` crate. +- **`store_tree`** (`../store_tree`) supplies the generated-tree diff for the code + side of the bundle. It uses `store_tree::Tree`, + `store_tree::diff_trees(old_root: Option<&str>, new_root: Option<&str>, objects: + &std::collections::BTreeMap) -> + Result, store_tree::StoreTreeError>`, and the + `store_tree::TreeChange` enum with its three variants `TreeChange::Added { path: + String, hash: String, mode: u32 }`, `TreeChange::Removed { path: String, hash: + String, mode: u32 }`, and `TreeChange::Modified { path: String, old_hash: + String, old_mode: u32, new_hash: String, new_mode: u32 }`. This module calls + `diff_trees` to obtain the code-side changes and never reimplements tree diffing. +- **`store_hash`** (`../store_hash`) supplies the blob framing for the rendered + bundle. It uses `store_hash::ObjectKind` (the variant `ObjectKind::Blob`), + `store_hash::canonical_json(value: &serde_json::Value) -> Vec`, and + `store_hash::object_name(kind: store_hash::ObjectKind, payload: &[u8]) -> + String`. The bundle is a `blob`: its bytes are the canonical JSON of the + serialized bundle, and its name is the blob object name over those bytes. This + module never reimplements canonical JSON or hashing. + +This module does not depend on `store_objects`. It diffs already-resolved +`BlockIndex`, tree, and attribution values that a caller supplies, so it never +resolves an object name and never needs the module-state or event types. A caller +that holds two module-states resolves their `blockIndex`, `generatedTree`, and +`attribution` hashes to concrete values first, then calls this module. + +## Content hashes are plain strings + +Every content hash and every content-addressed name is carried as a `String`, +matching the rest of the workspace, never a newtype. A well-formed hash is +sixty-four lowercase hexadecimal characters (`0` through `9`, `a` through `f`). +The bundle validator rejects any hash-typed field of an occurrence side whose +string is not exactly that shape. + +## The change kind and block level enums + +Expose a public enum `ChangeKind` (deriving `Debug`, `Clone`, `Copy`, +`PartialEq`, `Eq`, `serde::Serialize`, `serde::Deserialize`), serializing as a +single JSON string, each variant pinned to its exact wire token; a string outside +the set is a deserialization failure: + +- `ChangeKind::Added` renders `added`. +- `ChangeKind::Removed` renders `removed`. +- `ChangeKind::Modified` renders `modified`. + +Expose a public enum `BlockLevel` (same derives), serializing as a single JSON +string, each variant pinned to its exact wire token: + +- `BlockLevel::Atom` renders `atom`. +- `BlockLevel::SubSpan` renders `subSpan`. ::: test -name: migrate refuses on a dirty tree and writes no store -given: { fixture: MIG_DIRTY } -expect: { outcome: RefusedDirty, store_written: false } +name: change kind added serializes to the string added +given: { change_kind: added } +expect: { serialized_json: "\"added\"" } ::: ::: test -name: migrate aborts when a chain is worse than the sanctioned baseline and writes no store -given: { fixture: MIG_ABORT } -expect: { outcome: Aborted, offending: ["c.rs"], store_written: false } +name: change kind removed serializes to the string removed +given: { change_kind: removed } +expect: { serialized_json: "\"removed\"" } ::: ::: test -name: migrate proceeds on a clean tree and writes a consistent store carrying its head and the seeded prompt blob -given: { fixture: MIG_PROCEED } -expect: { outcome: Migrated, head_file_matches_outcome: true, prompt_blob_object_present: true } +name: change kind modified serializes to the string modified +given: { change_kind: modified } +expect: { serialized_json: "\"modified\"" } ::: ::: test -name: an unreadable optional artifact surfaces an io error rather than a silent absence -given: { fixture: MIG_OPT_UNREADABLE } -expect: { error: Io } +name: block level atom serializes to the string atom +given: { block_level: atom } +expect: { serialized_json: "\"atom\"" } ::: ::: test -name: a current-state-only module is reachable from the migrated head and its audit surfaces the distinct scar -given: { fixture: MIG_CURRENT_ONLY } -expect: { outcome: Migrated, event_head: "0000000000000000000000000000000000000000000000000000000000000000", module_state_object_present: true, audit_report: "napl migrate audit v1\nentries 0\ncorrupt-lines 0\nchains 0\nexact 0\nmissing-prefix 0\ninconsistent-before 0\ninconsistent-after 0\nunreconstructable 0\n\ncurrent-state-only greeting\nverdict anomalies\n" } +name: block level subSpan serializes to the string subSpan +given: { block_level: subSpan } +expect: { serialized_json: "\"subSpan\"" } ::: ::: test -name: a current-state-only migrated store is consumable by the real reducer without a missing-object error -given: { fixture: MIG_CURRENT_ONLY_DOWNSTREAM } -expect: { classification: current-state-only, event_graph_nodes: 0 } +name: the string added deserializes to change kind added +given: { serialized_json: "\"added\"" } +expect: { is_added: true } ::: -## Fixtures the tests pin +::: test +name: the string subSpan deserializes to block level subSpan +given: { serialized_json: "\"subSpan\"" } +expect: { is_sub_span: true } +::: -The sixty-four-character hash constants are each a single character repeated -sixty-four times: `A64` is `a`, `B64` is `b`, `C64` is `c`, `D64` is `d`, `F64` is -`f`, `H2_64` is the digit `2`, and `ZERO64` is the digit `0`. +## The occurrence side -The map fixture `MAP_VIEW` is a `map.json` text whose `version` is `2`, whose -`prompts` object carries one entry keyed `prompts/greeting.md` with `module` -`greeting`, a `promptHash` string, a `declaredTargets` array, and a `targets` object -with one target `rust` carrying a `promptHashAtGen` string and a `files` array -holding the one path `.napl/src/rust/greeting.rs`, and whose `files` object carries -two entries: `a.rs` with `target` `rust`, `hash` `A64`, and a `prompts` array, and -`.napl/src/rust/greeting.rs` with `target` `rust`, `hash` `B64`, and a `prompts` -array. Parsing it yields `current_hashes` mapping `a.rs` to `A64` and -`.napl/src/rust/greeting.rs` to `B64`, and one `ModuleView` for `greeting` with -`prompt_path` `prompts/greeting.md` and target `rust` mapped to the one file path. -The extra `version`, `promptHash`, `declaredTargets`, `promptHashAtGen`, and per-file -`target` and `prompts` fields are ignored, never a parse failure. +Expose a public struct `OccurrenceSide` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`, `serde::Serialize`, `serde::Deserialize`, with +`#[serde(deny_unknown_fields)]`), one side of a changed block. Public fields: -The map fixture `MAP_BAD` is the text `{"version":2}`, a JSON object with no `files` -map, which `parse_map` rejects as `MigrateIoError::MalformedMap`. +- `atom_index: u32`, serde field name `atomIndex`. The atom the occurrence lives + in: the atom's own index at atom level, or the enclosing atom's index at + sub-span level. +- `sub_index: Option`, serde field name `subIndex`, skipped from + serialization when `None`. Present only at sub-span level, the sub-span's index + within its parent atom. +- `content_hash: String`, serde field name `contentHash`. The occurrence's content + hash, copied from the block index verbatim. +- `text: String`, serde field name `text`. The occurrence's exact bytes, the + prompt slice `[byteStart, byteEnd)` of the record, as a string. +- `line_start: u32`, serde field name `lineStart`, and `line_end: u32`, serde + field name `lineEnd`. The occurrence's inclusive one-based line range. -The root fixture `ROOT_CLEAN` is a temporary directory holding a `journal.jsonl` of -these two lines and a `map.json` whose `files` object gives the current hashes: +Occurrence identity is snapshot-local: an atom occurrence is `(contentHash, +atomIndex)` and a sub-span occurrence is `(parentAtomIndex, subIndex, +contentHash)`. This module never uses index equality across versions to match +occurrences; it matches only on content hash. -- line 1 - `{"gen":1,"module":"m","target":"rust","files":[{"path":"a.rs","hashBefore":null,"hashAfter":"A64"}]}` -- line 2 - `{"gen":2,"module":"m","target":"rust","files":[{"path":"b.rs","hashBefore":null,"hashAfter":"B64"}]}` +## The changed block -with the map's `files` object mapping `a.rs` to `A64` and `b.rs` to `B64` (its -`prompts` object may be empty), so `run_audit` returns exactly the pinned clean -report. The root fixture `ROOT_MIXED` is a temporary directory holding a -`journal.jsonl` of these six lines and a `map.json` whose `files` object maps `a.rs` -to `A64`, `b.rs` to `B64`, `c.rs` to `H2_64`, and `d.rs` to `F64`: +Expose a public struct `ChangedBlock` (same derives, same +`#[serde(deny_unknown_fields)]`), one prompt-side change. Public fields: -- line 1 - `{"gen":1,"module":"m","target":"rust","files":[{"path":"a.rs","hashBefore":null,"hashAfter":"A64"}]}` -- line 2 - `{"gen":2,"module":"m","target":"rust","files":[{"path":"b.rs","hashBefore":"E64","hashAfter":"B64"}]}` -- line 3 - `{"gen":3,"module":"m","target":"rust","files":[{"path":"c.rs","hashBefore":null,"hashAfter":"H1_64"}]}` -- line 4 - `{"gen":4,"module":"m","target":"rust","files":[{"path":"c.rs","hashBefore":"H9_64","hashAfter":"H2_64"}]}` -- line 5 - `{"gen":5,"module":"m","target":"rust","files":[{"path":"d.rs","hashBefore":null,"hashAfter":"D64"}]}` -- line 6 the text `{ this is not valid json` +- `change: ChangeKind`, serde field name `change`. Whether the block was added, + removed, or modified. +- `level: BlockLevel`, serde field name `level`. Whether the block is an atom or a + sub-span. +- `old: Option`, serde field name `old`, skipped when `None`. The + old-version occurrence; `None` exactly for an added block. +- `new: Option`, serde field name `new`, skipped when `None`. The + new-version occurrence; `None` exactly for a removed block. -with `E64` the letter `e`, `H1_64` the digit `1`, and `H9_64` the digit `9`, all -written out in full, so `run_audit` returns exactly the pinned anomaly report. These -two report fixtures reuse the identical journals and current-hash maps that -`migrate_core` pins for its `JRN_CLEAN` and `JRN_MIXED` cases, so their reports are -the same pinned bytes, now produced end to end from files on disk. +The `change` label and the presence of the two sides are coupled: an `Added` block +has `old` `None` and `new` `Some`; a `Removed` block has `old` `Some` and `new` +`None`; a `Modified` block has both `Some`. The bundle validator enforces this +coupling. -The name fixture `NAME_SNAP` is the object name -`8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845`, whose -`object_path` is -`objects/81/57965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845`. +## The owner reference and the ownership record -The store fixture `STORE_MIN` is a `migrate_core::StagedStore` value built directly in -the test, not derived from `build_staged_store`, so its bytes are fully pinned. Its -`head` is `8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845`. Its -`objects` map holds exactly two entries at distinct fan-out shards: the snapshot -object named -`8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845` whose envelope -bytes are `napl-store snapshot v1 271\n` followed by the canonical snapshot payload -`{"parents":["ZERO64"],"root":{"greeting":{"eventHead":"65407002b376af4c707dbab5e9a668c743cffb1bd14ef427ca64cf49be8c4fff","moduleState":"e4351cea324d75b2362a7321008ddc4d9b98b0ea13f57650445dab61bb0aa272"}},"seq":2}` -and a trailing newline (with `ZERO64` written out as sixty-four `0` characters), and -the blob object named -`ed2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360` whose envelope -bytes are `napl-store blob v1 168\n` followed by the verbatim line -`{"gen":1,"module":"greeting","target":"rust","files":[{"path":"g.rs","hashBefore":null,"hashAfter":"H2_64"}]}` -with `H2_64` written out and no trailing newline. Materializing it into a temporary -`dest` writes the snapshot envelope at -`objects/81/57965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845`, the blob -envelope at `objects/ed/2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360`, -and `HEAD` holding -`8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845` and one newline. +Expose a public struct `OwnerRef` (same derives, same +`#[serde(deny_unknown_fields)]`), the occurrence an ownership record attributes a +generated region to. Public fields: -The seed fixture `SEED_GREETING` calls `seed_module_state` with `prompt_bytes` the -bytes `# Greeting`, newline, newline, `Say hello to the world.`, newline (the -thirty-five-byte prompt, frontmatter-free, so both the prompt blob and the block index -are derived from these exact bytes and every hash the case pins below is unchanged); -`mapl_bytes` the `Some` of the bytes `module: greeting`, -newline; `ir_bytes` `None`; `target` `rust`; `generated` one `GeneratedFile` with -`path` `greeting.rs`, `bytes` the bytes -`pub fn greet() -> &'static str { "hi" }`, newline, and `mode` `store_tree::FILE_MODE`; -and `attribution_bytes` the `Some` of the store's canonical attribution blob bytes for -this module, the bytes `store_hash::canonical_json` returns for the `serde_json::Value` -of the `store_diff::Attribution` with `module` `greeting`, `target` `rust`, and an empty -`entries` list, the canonical JSON blob a fresh gen publishes and never the on-disk YAML. -The seeded state's `prompt_blob` is -`45b2871a9dd98a8c943d93e345cba9f284f39aec307f3d9c9530561f3ce2382f`, its `mapl` is -`Some("4b693e63b8c3b60e9c15db024bf8591e2ebb2af1da3dbe480c1c1a94ec7288ca")`, its `ir` -is `None`, and its one target `rust` carries `attribution` -`Some(store_hash::object_name(ObjectKind::Blob, &canonical))` where `canonical` is those -same canonical JSON bytes, the canonical blob name this seeder publishes and never a -YAML blob name. The seeded state's `block_index` equals -`store_blocks::block_index_object_name` over `store_blocks::segment(prompt, -store_blocks::SEG_VERSION)`, and the target's `generated_tree` equals the `root` of -`store_tree::build_tree` over the one `store_tree::TreeFile` with `path` `greeting.rs`, -`hash` the blob name -`e642c6c1db8a3afbd4f38967c99aa12fb18839e27fb65db9a3adfaec1d703698`, and `mode` -`store_tree::FILE_MODE`. The `SeededModule.objects` map contains the four blob names (the -prompt blob `45b2871a...`, the mapl blob `4b693e63...`, the canonical attribution blob -just named, and the generated file blob `e642c6c1...`), the block-index object name, and -every tree object name in the build's `objects`, and it does not contain the -module-state object itself. +- `level: BlockLevel`, serde field name `level`. Atom-level or sub-span-level + ownership. +- `atom_index: u32`, serde field name `atomIndex`. The owning atom's index. +- `sub_index: Option`, serde field name `subIndex`, skipped when `None`. + Present exactly at sub-span level. -The migrate fixtures are temporary roots with a `dest` directory. Each is run through -`run_migrate` with an injected `prompt_paths` mapping alongside the `dirty` flag and the -`sanctioned` baseline; the `MIG_DIRTY` and `MIG_ABORT` paths short-circuit before seeding -and never read the mapping, so their mapping may be empty. `MIG_DIRTY` is any -valid root (a minimal `journal.jsonl` and `map.json`) run with `dirty` `true` and any -`sanctioned` baseline, returning `MigrateOutcome::RefusedDirty` and leaving `dest` -with no `HEAD` file and no `objects` directory. `MIG_ABORT` is a root whose one -journal line -`{"gen":1,"module":"m","target":"rust","files":[{"path":"c.rs","hashBefore":null,"hashAfter":"A64"}]}` -paired with a map giving `c.rs` the current hash `B64` audits `c.rs` as -inconsistent-after, run with `dirty` `false` and a `sanctioned` baseline whose -`chains` map holds `c.rs` at `store_objects::LegacyClassification::InconsistentBefore` -and no corrupt lines, so the current inconsistent-after is worse and the outcome is -`MigrateOutcome::Aborted(["c.rs"])`, again leaving `dest` untouched. `MIG_PROCEED` is -a clean root for one module `greeting` target `rust`: a `journal.jsonl` of the one -line -`{"gen":1,"module":"greeting","target":"rust","files":[{"path":".napl/src/rust/greeting.rs","hashBefore":null,"hashAfter":"A64"}]}`, -a `map.json` in the live shape whose `prompts` object keys the bare module name -`greeting` (the key equal to the entry's `module`) to module `greeting` with target -`rust` carrying a `promptHashAtGen` string and listing the one file -`.napl/src/rust/greeting.rs`, and whose `files` object gives that path the current -hash `A64` alongside its own `target` and `prompts` fields, an authored prompt source -file at `greeting.napl` holding the same thirty-five-byte prompt as `SEED_GREETING` -(frontmatter-free, so the seeded prompt blob is unchanged), the generated file at -`.napl/src/rust/greeting.rs` holding the same bytes as `SEED_GREETING`'s generated file -(`pub fn greet() -> &'static str { "hi" }`, newline, so its blob name is -`e642c6c1db8a3afbd4f38967c99aa12fb18839e27fb65db9a3adfaec1d703698`), a mapl at -`.napl/mapl/greeting.mapl`, and an attribution YAML at `.napl/attribution/greeting.yaml` -holding `module: greeting`, newline, `target: rust`, newline, `entries: []`, newline (no -ir file). The `promptHashAtGen` and per-file `prompts` fields are ignored by -`parse_map`; the module's prompt blob and its block index are both derived from the -authored `greeting.napl` bytes read through the injected `prompt_paths`, never from the -`prompts` key, and the seeding reads no `.napl/prompts-at-gen` body. The one generated -file path the map records is `.napl/src/rust/greeting.rs`, root-relative under the -`.napl/src/rust/` prefix; the migration reads its bytes from that full path but strips -the prefix when it builds the tree, so the seeded generated tree is built from the -tree-relative path `greeting.rs`, module-topped, never the `.napl/src/rust/`-topped map -path. Run with `dirty` `false`, a `sanctioned` baseline equal to the current audit (so -the comparison proceeds), and a `prompt_paths` mapping of `greeting` to `greeting.napl`, -the outcome is `MigrateOutcome::Migrated`, the written `HEAD` file holds the outcome's -`head` followed by one newline, the migrated `greeting` module-state's `rust` target -`generated_tree` equals the `root` of `store_tree::build_tree` over the one -`store_tree::TreeFile` with `path` the tree-relative `greeting.rs`, `hash` -`e642c6c1db8a3afbd4f38967c99aa12fb18839e27fb65db9a3adfaec1d703698`, and `mode` -`store_tree::FILE_MODE`, proving the `.napl/src/rust/` prefix was stripped from the tree -path, the store contains the seeded prompt blob object -`45b2871a9dd98a8c943d93e345cba9f284f39aec307f3d9c9530561f3ce2382f` at its sharded path -`objects/45/b2871a9dd98a8c943d93e345cba9f284f39aec307f3d9c9530561f3ce2382f`, and the -seeded attribution blob is the store's canonical JSON form rather than the on-disk YAML: -the migration seeds the module-state target's `attribution` as -`store_hash::object_name(ObjectKind::Blob, &canonical)` where `canonical` is -`store_hash::canonical_json` of the `serde_json::Value` of the `store_diff::Attribution` -with module `greeting`, target `rust`, and empty `entries`, and the blob object at that -name holds exactly `canonical`, so reading that blob's payload back and parsing it the -way the reducer's attribution loader does, `serde_json` into a value validated through -`store_diff::validate_attribution`, yields that `store_diff::Attribution` and never a -corrupt object. +The `level` and `sub_index` are coupled: an `Atom` owner has `sub_index` `None`, a +`SubSpan` owner has `sub_index` `Some`. The bundle validator enforces this. -The containment fixtures probe the reader guard against a temporary root holding a -readable file. `PATH_REJECT_ABSOLUTE` calls `read_bytes_at` (and, in the same test, -`read_to_string_at`) with a `rel` that is an absolute path (a real absolute path on -this platform, for instance the temporary root's own absolute path joined with a -filename, so the string genuinely begins at the filesystem root), and asserts the -result is `Err(MigrateIoError::Io(_))`, proving an absolute `rel` is refused before any -read and the message is not pinned. `PATH_REJECT_ESCAPE` calls the same readers with a -`rel` of `../escape.txt` (a parent-traversal that climbs above root) against a root -that has a sibling file one level up, and asserts the result is -`Err(MigrateIoError::Io(_))`, proving a `..` climb is refused and the sibling above -root is never read. Both assert only the `Io` variant, never the message bytes. +Expose a public struct `OwnershipRecord` (same derives, same +`#[serde(deny_unknown_fields)]`), one attribution link resolved against a block +index. Public fields: -`MAT_NO_PARTIAL` is the same two-object `STORE_MIN` staged store materialized into a -fresh temporary `dest`. After `materialize_staged_store` returns, the test asserts the -`HEAD` file holds exactly `8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845` -and one trailing newline, and, recursively walking `dest`, asserts the only regular -files present are the two object files at their fan-out paths -`objects/81/57965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845` and -`objects/ed/2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360` and the -single `HEAD`, so the count of files that are neither of these two objects nor `HEAD` -is zero: no temporary sidecar and no partial ref survives the atomic materialize. +- `owner: OwnerRef`, serde field name `owner`. The occurrence that owns the region. +- `file: String`, serde field name `file`. The generated file path. +- `line_start: u32`, serde field name `lineStart`, and `line_end: u32`, serde + field name `lineEnd`. The owned region's inclusive one-based generated-file line + range, carried verbatim from the attribution entry's `lines`. +- `note: String`, serde field name `note`. The attribution entry's note. -`MIG_OPT_UNREADABLE` is the clean single-module `MIG_PROCEED` root with one alteration: -the optional mapl artifact at `.napl/mapl/greeting.mapl` is not a readable file but a -symlink loop (a symlink whose target resolves back to itself), so probing its existence -raises a metadata io error rather than a clean present-or-absent answer. Its authored -`greeting.napl` source and its `prompt_paths` mapping of `greeting` to `greeting.napl` -are the same as `MIG_PROCEED`, so the authored source read succeeds and the run reaches -the unreadable mapl. Run with `dirty` `false`, the equal sanctioned baseline, and that -mapping, `run_migrate` returns `Err(MigrateIoError::Io(_))`, proving -the unreadable optional artifact surfaces as an io error and is never silently seeded as -an absent `None`. The test asserts only the `Io` variant, not the message bytes. +## The code change -`MIG_CURRENT_ONLY` is a clean root whose one module `greeting` is current-state-only: -its `journal.jsonl` is empty (the module's legacy events are gone, the emptied-mapl -scar), yet its current working files survive. Its `map.json` is in the live shape: its -`prompts` object keys the bare module name `greeting` to module `greeting` with target -`rust` listing the one file `.napl/src/rust/greeting.rs`, and its `files` object gives -that path the current hash `A64`; the working tree carries the same thirty-five-byte -prompt as `SEED_GREETING` as its authored source at `greeting.napl` (frontmatter-free, -so the seeded prompt blob is unchanged), the generated file at -`.napl/src/rust/greeting.rs`, a mapl at `.napl/mapl/greeting.mapl`, and an attribution -YAML at `.napl/attribution/greeting.yaml` holding `module: greeting`, newline, `target: -rust`, newline, `entries: []`, newline (no ir file), and it is migrated with a -`prompt_paths` mapping of `greeting` to `greeting.napl`; the migration canonicalizes that -attribution YAML into the store's canonical JSON attribution blob just as `MIG_PROCEED` -does. The journal names no path chain and no corrupt line, but `greeting` is -declared in the map inventory and named by no journal entry, so the audit is NOT clean: -it surfaces `greeting` as a distinct current-state-only scar. `run_audit` over this root -returns exactly -`napl migrate audit v1\nentries 0\ncorrupt-lines 0\nchains 0\nexact 0\nmissing-prefix 0\ninconsistent-before 0\ninconsistent-after 0\nunreconstructable 0\n\ncurrent-state-only greeting\nverdict anomalies\n`, -the report carrying the distinct `current-state-only greeting` line and `verdict -anomalies`, never a clean verdict. The migration still proceeds because the scar is not a -drift the abort gate measures: run with `dirty` `false`, a `sanctioned` baseline equal -to the current audit (so the abort comparison, which ignores `current_state_only`, finds -nothing worse), and the `greeting` to `greeting.napl` `prompt_paths` mapping, the outcome -is `MigrateOutcome::Migrated`. The test asserts three things, -each in the same test: first, that `run_audit` returns the pinned scar report above, -byte for byte, proving the audit surfaces the scar rather than laundering it; second, -that the outcome is `Migrated`; and third, that the module is reachable from the written -`HEAD` rather than silently dropped, by reading the `HEAD` file (the head snapshot name -and one newline), reading the snapshot object at its fan-out path under `dest/objects`, -parsing that snapshot object's envelope payload as json, and asserting its `root` object -carries a `greeting` entry whose `eventHead` equals -`0000000000000000000000000000000000000000000000000000000000000000`, the genesis-parent -sentinel, and whose `moduleState` names an object file that is present under -`dest/objects` at its own fan-out path. +Expose a public struct `CodeChange` (same derives, same +`#[serde(deny_unknown_fields)]`), one generated-file change projected from a +`store_tree::TreeChange`. Public fields: -`MIG_CURRENT_ONLY_DOWNSTREAM` is the identical current-state-only root as -`MIG_CURRENT_ONLY`, migrated the same way into a temporary `dest` with the `Migrated` -outcome, but its test then proves the migrated store is consumable by the real reducer -rather than only structurally present. It opens a `store_io::DiskProvider` over `dest` -and drives the downstream reducer APIs that read a root entry's event head: it calls -`store_reduce::classify(&provider, "greeting", "rust", &observed)` (where `observed` is any -`store_reduce::Observed`, since the result is decided before the working comparison) and -asserts the returned `store_reduce::Classification` is exactly -`store_reduce::Classification::CurrentStateOnly`, and it calls -`store_reduce::event_graph(&provider, "greeting")` and asserts the returned node vector is -empty. Both must return their values as `Ok`, never a -`store_reduce::ReduceError::MissingObject` over the all-zeros genesis sentinel: this is -the end-to-end proof that the genesis-sentinel event head migration writes is -consumable downstream, closing the loop the reachability-only assertion left open. This -case never substitutes for `MIG_CURRENT_ONLY`, which pins the audit and the on-disk -reachability; the two are distinct and neither discharges the other. +- `change: ChangeKind`, serde field name `change`. +- `path: String`, serde field name `path`. The full slash-joined leaf path. +- `hash: Option`, serde field name `hash`, skipped when `None`. Present for + an added or removed change. +- `mode: Option`, serde field name `mode`, skipped when `None`. Present for an + added or removed change. +- `old_hash: Option`, serde field name `oldHash`, skipped when `None`. +- `old_mode: Option`, serde field name `oldMode`, skipped when `None`. +- `new_hash: Option`, serde field name `newHash`, skipped when `None`. +- `new_mode: Option`, serde field name `newMode`, skipped when `None`. -## Test and code discipline +An `Added` code change carries `hash` and `mode` and none of the old or new pair; +a `Removed` code change carries `hash` and `mode`; a `Modified` code change carries +`oldHash`, `oldMode`, `newHash`, `newMode` and no bare `hash` or `mode`. Expose +`code_change_from_tree(change: &store_tree::TreeChange) -> CodeChange` that +projects a `TreeChange` into this shape: `Added` to `change` `Added` with `hash` +and `mode`, `Removed` to `change` `Removed` with `hash` and `mode`, and `Modified` +to `change` `Modified` with the four old and new fields. -The frontmatter test suite is a contract, not a sketch. Each case in it is realized -by its own dedicated test function that exercises only that case, so the number of -dedicated tests is at least the number of frontmatter cases. Never fold two or more -cases into one shared test, and a single test that loops over a table of inputs never -counts as the dedicated test any of those inputs requires: such a combined test may -exist only as an extra. Cases that differ only in their input while sharing an -expected shape are still distinct cases, each owed its own dedicated test. +## The attribution input types -Enumerate the case families by name, each owed its own dedicated verbatim test. The -map cases: the `MAP_VIEW` parse case, asserting the exact `current_hashes` entries and -the one `ModuleView`, and the `MAP_BAD` case, asserting `MigrateIoError::MalformedMap`, -each its own test, and the malformed case never discharges the parse case. The audit -cases: the `ROOT_CLEAN` end-to-end report case and the `ROOT_MIXED` end-to-end report -case, each writing its journal and map into a temporary root, calling `run_audit`, and -asserting the returned string equals its pinned literal byte for byte, each its own -test. The layout cases: the `NAME_SNAP` `object_path` case and the `STORE_MIN` -`materialize_staged_store` case, each its own test. The seed case `SEED_GREETING`, its -own test. The genesis-constant case, its own test. The migrate cases: `MIG_DIRTY`, -`MIG_ABORT`, and `MIG_PROCEED`, each its own test, and none discharges another. The -containment cases: `PATH_REJECT_ABSOLUTE` and `PATH_REJECT_ESCAPE`, each its own test -asserting the reader returns `MigrateIoError::Io`, and neither discharges the other nor -any successful-read case. The atomic-materialize case `MAT_NO_PARTIAL`, its own test -asserting the exact `HEAD` bytes and that no file beyond the two objects and `HEAD` -survives; it never weakens the leftover-file assertion into a mere existence check and -never discharges the `STORE_MIN` materialize case. The unreadable-optional case -`MIG_OPT_UNREADABLE`, its own test asserting `run_migrate` returns `MigrateIoError::Io` -rather than seeding a silent `None`. The current-state-only integration case -`MIG_CURRENT_ONLY`, its own test asserting all three of: `run_audit` over the root -returns the pinned scar report byte for byte, carrying the distinct -`current-state-only greeting` line and `verdict anomalies` and never a clean verdict; the -outcome is `MigrateOutcome::Migrated`; and the written head snapshot's `root` carries the -`greeting` entry with `eventHead` the genesis-parent sentinel and a `moduleState` object -present on disk. It never asserts only that the migration succeeded while ignoring the -audit or reachability, never asserts a clean audit, and never discharges the -`MIG_PROCEED` case. The downstream-consumability case `MIG_CURRENT_ONLY_DOWNSTREAM`, its -own test, migrates the same root, opens a `store_io::DiskProvider` over `dest`, and -asserts `store_reduce::classify` returns `Classification::CurrentStateOnly` and -`store_reduce::event_graph` returns an empty node vector, both `Ok` and never a -`MissingObject` over the genesis sentinel; it drives the real reducer over the real -materialized store and never substitutes for `MIG_CURRENT_ONLY`. - -Inside a case's dedicated test, hand the code exactly the fixture that case gives, -byte for byte, and assert its pinned expectation verbatim: no shorter or more -convenient stand-in, no re-derivation of a value the case pins as a literal, and no -weakening of a pinned equality into an inequality-only or recomputed check. The -`ROOT_CLEAN` test asserts the report equals `napl migrate audit v1` then a newline, -`entries 2`, `corrupt-lines 0`, `chains 2`, `exact 2`, `missing-prefix 0`, -`inconsistent-before 0`, `inconsistent-after 0`, `unreconstructable 0`, a blank line, -and `verdict clean`, each followed by one newline. The `ROOT_MIXED` test asserts the -report equals `napl migrate audit v1`, `entries 5`, `corrupt-lines 1`, `chains 5`, -`exact 1`, `missing-prefix 1`, `inconsistent-before 1`, `inconsistent-after 1`, -`unreconstructable 1`, a blank line, then the sorted anomaly lines -`inconsistent-after d.rs`, `inconsistent-before c.rs`, `missing-prefix b.rs`, -`unreconstructable line:6`, then `verdict anomalies`, each followed by one newline. -Obtain each report from `run_audit` over a temporary root and compare to the literal; -never assert a prefix or a contains, always the full bytes. - -The `object_path` test asserts the exact string -`objects/81/57965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845` for the -name `8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845`. The -`materialize_staged_store` test builds the `STORE_MIN` value with its two exact -envelope byte vectors and its head, materializes into a temporary `dest`, then reads -back and asserts three things: the file at -`objects/81/57965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845` holds the -snapshot envelope `napl-store snapshot v1 271\n` followed by the canonical snapshot -payload and its trailing newline byte for byte; the file at -`objects/ed/2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360` holds the -blob envelope `napl-store blob v1 168\n` followed by the verbatim journal line with no -trailing newline byte for byte; and the `HEAD` file holds -`8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845` and exactly one -trailing newline. It never asserts only that the files exist, and never weakens an -envelope equality into a length or prefix check. - -The `SEED_GREETING` test calls `seed_module_state` with the exact fixture bytes and -asserts each pinned value verbatim: `state.prompt_blob` equals -`45b2871a9dd98a8c943d93e345cba9f284f39aec307f3d9c9530561f3ce2382f`, `state.mapl` -equals `Some("4b693e63b8c3b60e9c15db024bf8591e2ebb2af1da3dbe480c1c1a94ec7288ca")`, -`state.ir` equals `None`, and the target `rust`'s `attribution` equals -`Some(store_hash::object_name(ObjectKind::Blob, &canonical))` where `canonical` is the -same canonical JSON attribution bytes handed in, proving the seeded attribution blob is -the canonical form and never a YAML blob hash. It then -asserts the composition agreement, proving the frozen crates are called rather than -reimplemented: `state.block_index` equals `store_blocks::block_index_object_name` -over `store_blocks::segment` of the prompt under `store_blocks::SEG_VERSION`, and the -target's `generated_tree` equals the `root` of `store_tree::build_tree` over the one -`store_tree::TreeFile` naming the generated file's blob. Finally it asserts -`SeededModule.objects` contains the four pinned blob names, the block-index object -name, and the build's tree root name, and does not contain the module-state object. -The `ir` `None` assertion is verbatim and is never weakened into an is-some check, and -the `mapl` and `attribution` assertions carry their exact pinned digests. - -The genesis-constant test asserts `GENESIS_PARENT` equals the sixty-four-character -string of the digit `0`, never a shorter or differently cased value, and never the -`None` of an absent hash. - -The `MIG_DIRTY` test runs `run_migrate` with `dirty` `true` and asserts the outcome is -`MigrateOutcome::RefusedDirty` and, separately in the same test, that the `dest` -directory holds no `HEAD` file and no `objects` directory, proving nothing was -written. The `MIG_ABORT` test runs `run_migrate` with `dirty` `false` and the -`InconsistentBefore` sanctioned baseline and asserts the outcome is exactly -`MigrateOutcome::Aborted(vec!["c.rs".to_string()])`, its offending vector verbatim and -in sorted order, and that `dest` was left untouched; it never asserts only that the -outcome is an abort while ignoring its keys. The `MIG_PROCEED` test runs `run_migrate` -on the clean single-module root with `dirty` `false`, the equal sanctioned baseline, and -the `greeting` to `greeting.napl` `prompt_paths` mapping, asserts the outcome is -`MigrateOutcome::Migrated`, reads the written `HEAD` file and asserts its bytes equal the -outcome's `head` followed by one newline (a self-consistency the materialize step -guarantees), and asserts the store holds the seeded prompt blob object -`45b2871a9dd98a8c943d93e345cba9f284f39aec307f3d9c9530561f3ce2382f` at -`objects/45/b2871a9dd98a8c943d93e345cba9f284f39aec307f3d9c9530561f3ce2382f`, a -concrete pinned object proving the seed reached disk over the authored source. It also -proves the generated tree is module-topped rather than `.napl`-topped: it reads the -migrated `HEAD` snapshot, follows its `root` `greeting` entry's `moduleState` to that -object under `dest/objects`, parses the module-state, and asserts the `rust` target's -`generated_tree` equals the `root` of `store_tree::build_tree` over the one -`store_tree::TreeFile` with `path` the tree-relative `greeting.rs`, `hash` -`e642c6c1db8a3afbd4f38967c99aa12fb18839e27fb65db9a3adfaec1d703698`, and `mode` -`store_tree::FILE_MODE`, so the `.napl/src/rust/` prefix was stripped from the tree path -while the file bytes were read from the full `.napl/src/rust/greeting.rs`; it never -accepts a tree built from the root-relative map path. It also proves the seeded -attribution blob is canonical JSON rather than the on-disk YAML, making this defect class -caught forever: from the same module-state it follows the `rust` target's `attribution` -to that blob object under `dest/objects`, reads the blob's payload, and asserts it parses -through `serde_json` into a value that `store_diff::validate_attribution` accepts as the -`store_diff::Attribution` with module `greeting`, target `rust`, and empty `entries`, -byte-identical to `store_hash::canonical_json` of that document, exactly the round-trip -the reducer's attribution loader performs; it never accepts a blob whose payload is the -raw YAML text, which would fail that parse as a corrupt object. - -The named-substitution prohibitions for this module's confusable tokens are strict. -Never write `promptBlob` where a case pins `blockIndex`, nor `generatedTree` where a -case pins `attribution`, nor `mapl` where a case pins `ir`, nor the reverse of any of -these; each module-state field is transcribed exactly as its case names it. Never -confuse a bare content hash (the map's `files` `hash` and the journal's `hashBefore` -and `hashAfter`, compared by the audit) with an envelope object name (the store's -object names, computed through `store_hash`); the two are different values for the -same file and are never substituted for one another. The three `MigrateOutcome` -variants are exactly `Migrated`, `RefusedDirty`, and `Aborted`, and none is written -where a case pins another. The head file is exactly the head name and one trailing -newline, never without the newline and never with two. The `object_path` fan-out is -exactly the first two characters, a slash, then the remaining characters, never a -three-character or one-character split. The `GENESIS_PARENT` sentinel is the digit `0` -sixty-four times and is never confused with the `None` of a creation step's absent -hash. The audit report's fixed header token is exactly `napl migrate audit v1` and its -verdict tokens are exactly `clean` and `anomalies`, never reworded, and the trailing -newline that ends the report is never dropped. - -Every case above is realized by exactly one dedicated verbatim test; a narrower, -neighboring, or recomputed test does not discharge a case; any supplemental -table-driven or integration test is separately named and never substitutes for a -dedicated test; and the count of dedicated tests is at least the count of frontmatter -cases. - -Comment rules: no line or block comments anywhere in the generated code; no doc -comment on a private item; a module `//!` summary and `///` on public items only; no -doctest code blocks in any doc comment; no em dashes in any string, comment, or doc -this module emits. Every struct that deserializes external JSON reads only the fields -this module needs and never fabricates a value the input did not carry; `map.json` is -a foreign document and its unread fields are ignored rather than rejected. -napl-store receipt v1 4839 -{"baseStateKey":"0ef99195476c7251619345064e2dc74fa005197438592370d03b0b5e5a688b55","baseStoreSnapshot":"8c8a4a7fddaf37822cf48c2dca7f8f4f11279f85c144fc105e996bc7dfd0cd48","baseWorkspaceHash":"e0333e9ca0f11dc00a94f410e47c0285fab72fe5776962d435a09022cf797e2d","candidateTreeHash":"c6e984f3367bbcd224e16de533fa8c357d320625ec698fb566526ceb887a50b8","contractVersion":4,"inputKey":"1b54c1209237735b1e5ee88a233557bd84e054b9e48e3a8582bf2db41d4142ee","module":"napl","outputManifest":[{"hash":"f0987b243dce1a039a0c78f8100965a895c03501bc378b4217765c5c84982d34","path":"crates/napl/src/attribution.rs"},{"hash":"e539c65a4600ed1784b9cc176cb28f24c140f514f9783360f1f5a27142f89f68","path":"crates/napl/src/authoring.rs"},{"hash":"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2","path":"crates/napl/src/blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","path":"crates/napl/src/candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","path":"crates/napl/src/canon.rs"},{"hash":"0b8190e7989129e7baf4eed7046e19b7608067eb5d13bed95ad416118bf34a25","path":"crates/napl/src/capture.rs"},{"hash":"2e5b5b54aebea3db240acc2f2dc944df07b908bf97ba2067517a9757ed8c7dcb","path":"crates/napl/src/cli.rs"},{"hash":"7f8fe1b370188a60f00e524cc5c8059ca17a347b97915097286fff2c0ec7e290","path":"crates/napl/src/commandset.rs"},{"hash":"f762bd207827748390f0f6b80d2ee0cce44aed0b609affc3ca7cf39e2c11e958","path":"crates/napl/src/commit.rs"},{"hash":"3c0db9b5d179e2f3b7123cd8e08844ea9ad28a0a6ffbc7e5794738709f5af1f4","path":"crates/napl/src/compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","path":"crates/napl/src/cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","path":"crates/napl/src/decision.rs"},{"hash":"bc274cef6c4e5aca63e7c46d81e1f86e118c6ba079e7a4ccd4e9f14a8d3eb21d","path":"crates/napl/src/directive.rs"},{"hash":"b86a4431e286225b32bae5cebc03298584fce063e8d4ee2df4633e18143f0aa7","path":"crates/napl/src/disk.rs"},{"hash":"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812","path":"crates/napl/src/error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","path":"crates/napl/src/evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","path":"crates/napl/src/graph.rs"},{"hash":"824c95b7229ac2ad5b1cc518b296443883fb56a110c86c2a2e67923c0a19ea07","path":"crates/napl/src/lint.rs"},{"hash":"2ea965827e5c5073cbcacacd22bf2318a9067874c901b4f05f67268e99baf92c","path":"crates/napl/src/lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","path":"crates/napl/src/main.rs"},{"hash":"6fc1b58dad295d780c917c10ac0f793667e46c455349cff3d42e6855501a91bd","path":"crates/napl/src/maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","path":"crates/napl/src/mv.rs"},{"hash":"aff9bd5b095cfbcd34114cb0c34ac87b1716fef02f0b452888c502e5301476d2","path":"crates/napl/src/objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","path":"crates/napl/src/paths.rs"},{"hash":"93f11780827b58057b742550c206f180d5fa1659b216e90b8cfded514400be21","path":"crates/napl/src/porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","path":"crates/napl/src/receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","path":"crates/napl/src/slice.rs"},{"hash":"48d165c0c10ed38a4c594954631c4aa3b9a3c5fa562cc0891b4343fc3d32dd14","path":"crates/napl/src/source.rs"},{"hash":"e3374a27d7d0f882064cf00e4f95f29623db5d5ff23a06dfb5050d5f6639463a","path":"crates/napl/src/staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","path":"crates/napl/src/store_view.rs"},{"hash":"1820f647bb5dbd74c8db7f5519d404ef0ce1520c833a579aaca893b5cad7ba08","path":"crates/napl/src/surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","path":"crates/napl/src/target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","path":"crates/napl/src/textdiff.rs"},{"hash":"167749ecbb655a214510c849c92be88cb536cc7f57a330d21b0314780457b0a1","path":"crates/napl/src/transaction.rs"},{"hash":"d4ef5888a7ecc406d8eb5173dc215c7b1a1ef1e1ddd3ab8b3a8aa6639d125017","path":"crates/napl/src/transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","path":"crates/napl/src/trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","path":"crates/napl/src/version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","path":"crates/napl/src/workspace.rs"}],"receiptVersion":1,"sliceHash":"fd7989abdc979752df5b92ec979c5781e9c383da7760eb3d1e2537f6e3f9e105","target":"rust"} -napl-store blob v1 20466 -use std::collections::BTreeMap; -use std::io::{BufRead, Write}; -use std::path::{Path, PathBuf}; - -use serde_json::{json, Value}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Entry { - pub prompt_start: u32, - pub prompt_end: u32, - pub file: String, - pub line_start: u32, - pub line_end: u32, - pub note: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Attribution { - pub module: String, - pub target: String, - pub entries: Vec, -} - -pub fn serve(reader: &mut R, writer: &mut W) { - let mut docs: BTreeMap = BTreeMap::new(); - while let Some(message) = read_message(reader) { - let method = message - .get("method") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - let id = message.get("id").cloned(); - match method.as_str() { - "initialize" => respond(writer, id, initialize_result()), - "initialized" => {} - "shutdown" => respond(writer, id, Value::Null), - "exit" => break, - "textDocument/didOpen" => track_open(&mut docs, &message), - "textDocument/didChange" => track_change(&mut docs, &message), - "textDocument/didClose" => track_close(&mut docs, &message), - "textDocument/hover" => respond(writer, id, hover(&docs, &message)), - "textDocument/definition" => respond(writer, id, definition(&docs, &message)), - "textDocument/references" => respond(writer, id, references(&docs, &message)), - "textDocument/codeLens" => respond(writer, id, code_lens(&docs, &message)), - _ => { - if id.is_some() { - respond(writer, id, Value::Null); - } - } - } - } -} - -fn initialize_result() -> Value { - json!({ - "capabilities": { - "codeLensProvider": { "resolveProvider": false }, - "definitionProvider": true, - "hoverProvider": true, - "referencesProvider": true, - "textDocumentSync": 1 - }, - "serverInfo": { "name": "napl-lsp" } - }) -} - -fn respond(writer: &mut W, id: Option, result: Value) { - let Some(id) = id else { - return; - }; - write_message( - writer, - &json!({ "id": id, "jsonrpc": "2.0", "result": result }), - ); -} - -fn read_message(reader: &mut R) -> Option { - let mut content_length: Option = None; - loop { - let mut line = String::new(); - let read = reader.read_line(&mut line).ok()?; - if read == 0 { - return None; - } - let trimmed = line.trim_end_matches(['\r', '\n']); - if trimmed.is_empty() { - break; - } - if let Some(value) = trimmed.strip_prefix("Content-Length:") { - content_length = value.trim().parse::().ok(); - } - } - let length = content_length?; - let mut buffer = vec![0u8; length]; - reader.read_exact(&mut buffer).ok()?; - serde_json::from_slice(&buffer).ok() -} - -fn write_message(writer: &mut W, value: &Value) { - let body = serde_json::to_vec(value).unwrap_or_default(); - let _ = write!(writer, "Content-Length: {}\r\n\r\n", body.len()); - let _ = writer.write_all(&body); - let _ = writer.flush(); -} - -fn track_open(docs: &mut BTreeMap, message: &Value) { - let item = &message["params"]["textDocument"]; - if let (Some(uri), Some(text)) = (item["uri"].as_str(), item["text"].as_str()) { - docs.insert(uri.to_string(), text.to_string()); - } -} - -fn track_change(docs: &mut BTreeMap, message: &Value) { - let params = &message["params"]; - let Some(uri) = params["textDocument"]["uri"].as_str() else { - return; - }; - let changes = params["contentChanges"].as_array(); - if let Some(text) = changes - .and_then(|list| list.last()) - .and_then(|change| change["text"].as_str()) - { - docs.insert(uri.to_string(), text.to_string()); - } -} - -fn track_close(docs: &mut BTreeMap, message: &Value) { - if let Some(uri) = message["params"]["textDocument"]["uri"].as_str() { - docs.remove(uri); - } -} - -pub fn parse_attribution(text: &str) -> Option { - let unified = text.replace("\r\n", "\n").replace('\r', "\n"); - let mut module: Option = None; - let mut target: Option = None; - let mut entries: Vec = Vec::new(); - let mut in_entries = false; - let mut entry_indent: Option = None; - let mut current: Vec<(String, Vec)> = Vec::new(); - let mut pending_key: Option = None; - - let flush = |current: &mut Vec<(String, Vec)>, entries: &mut Vec| { - if let Some(entry) = build_entry(current) { - entries.push(entry); - } - current.clear(); - }; - - for raw in unified.split('\n') { - if raw.trim().is_empty() { - continue; - } - let indent = raw.len() - raw.trim_start().len(); - if indent == 0 { - if in_entries { - flush(&mut current, &mut entries); - in_entries = false; - } - let (key, value) = raw.split_once(':')?; - match key.trim() { - "module" => module = Some(unquote(value.trim())), - "target" => target = Some(unquote(value.trim())), - "entries" => { - in_entries = true; - entry_indent = None; - } - _ => {} - } - pending_key = None; - continue; - } - if !in_entries { - continue; - } - let trimmed = raw.trim_start(); - let is_item = trimmed == "-" || trimmed.starts_with("- "); - let starts_entry = is_item && entry_indent.map(|width| indent <= width).unwrap_or(true); - if starts_entry { - flush(&mut current, &mut entries); - entry_indent = Some(indent); - pending_key = None; - let rest = trimmed.strip_prefix("- ").unwrap_or("").trim(); - if !rest.is_empty() { - append_field(&mut current, &mut pending_key, rest); - } - continue; - } - if is_item { - let value = trimmed.strip_prefix("- ").unwrap_or("").trim(); - if let Some(key) = &pending_key { - if let Some(slot) = current.iter_mut().find(|(field, _)| field == key) { - slot.1.push(unquote(value)); - } - } - continue; - } - append_field(&mut current, &mut pending_key, trimmed); - } - if in_entries { - flush(&mut current, &mut entries); - } - - Some(Attribution { - module: module?, - target: target.unwrap_or_default(), - entries, - }) -} - -fn append_field( - current: &mut Vec<(String, Vec)>, - pending_key: &mut Option, - text: &str, -) { - let Some((key, value)) = text.split_once(':') else { - return; - }; - let key = key.trim().to_string(); - let value = value.trim(); - if value.is_empty() { - current.push((key.clone(), Vec::new())); - *pending_key = Some(key); - } else { - current.push((key, vec![unquote(value)])); - *pending_key = None; - } -} - -fn build_entry(fields: &[(String, Vec)]) -> Option { - let mut prompt: Option<(u32, u32)> = None; - let mut lines: Option<(u32, u32)> = None; - let mut file: Option = None; - let mut note = String::new(); - for (key, values) in fields { - match key.as_str() { - "promptLines" => prompt = pair(values), - "lines" => lines = pair(values), - "file" => file = values.first().cloned(), - "note" => note = values.first().cloned().unwrap_or_default(), - _ => {} - } - } - let (prompt_start, prompt_end) = prompt?; - let (line_start, line_end) = lines?; - Some(Entry { - prompt_start, - prompt_end, - file: file?, - line_start, - line_end, - note, - }) -} - -fn pair(values: &[String]) -> Option<(u32, u32)> { - let first = values.first()?.parse::().ok()?; - let second = values.get(1)?.parse::().ok()?; - Some((first.min(second), first.max(second))) -} - -fn unquote(text: &str) -> String { - let quoted = text.len() >= 2 - && ((text.starts_with('"') && text.ends_with('"')) - || (text.starts_with('\'') && text.ends_with('\''))); - if quoted { - text[1..text.len() - 1].to_string() - } else { - text.to_string() - } -} - -fn load_records(workspace: &Path) -> Vec { - let dir = workspace.join(".napl").join("attribution"); - let mut names: Vec = match std::fs::read_dir(&dir) { - Ok(entries) => entries - .filter_map(|entry| entry.ok()) - .filter_map(|entry| entry.file_name().into_string().ok()) - .filter(|name| name.ends_with(".yaml")) - .collect(), - Err(_) => return Vec::new(), - }; - names.sort(); - let mut records: Vec = Vec::new(); - for name in names { - if let Ok(text) = std::fs::read_to_string(dir.join(&name)) { - if let Some(record) = parse_attribution(&text) { - records.push(record); - } - } - } - records -} - -enum Located { - Prompt { module: String, line: u32 }, - Generated { file: String, line: u32 }, - None, -} - -fn uri_to_path(uri: &str) -> String { - let stripped = uri.strip_prefix("file://").unwrap_or(uri); - percent_decode(stripped) -} - -fn resolve_workspace(uri: &str) -> Option { - let path = uri_to_path(uri); - crate::workspace::find_workspace_root(Path::new(&path)) -} - -fn percent_decode(text: &str) -> String { - let bytes = text.as_bytes(); - let mut out: Vec = Vec::with_capacity(bytes.len()); - let mut index = 0; - while index < bytes.len() { - if bytes[index] == b'%' && index + 2 < bytes.len() { - let high = (bytes[index + 1] as char).to_digit(16); - let low = (bytes[index + 2] as char).to_digit(16); - if let (Some(high), Some(low)) = (high, low) { - out.push((high * 16 + low) as u8); - index += 3; - continue; - } - } - out.push(bytes[index]); - index += 1; - } - String::from_utf8_lossy(&out).into_owned() -} - -fn relativize(workspace: &Path, path: &str) -> String { - match Path::new(path).strip_prefix(workspace) { - Ok(rest) => rest.to_string_lossy().replace('\\', "/"), - Err(_) => path.to_string(), - } -} - -fn stem(rel: &str) -> Option { - let name = rel.rsplit('/').next()?; - name.strip_suffix(".napl").map(str::to_string) -} - -fn generated_match(rel: &str, file: &str) -> bool { - rel == file || rel.ends_with(&format!("/{file}")) -} - -fn document_text( - workspace: &Path, - docs: &BTreeMap, - uri: &str, - rel: &str, -) -> Option { - if let Some(text) = docs.get(uri) { - return Some(text.clone()); - } - std::fs::read_to_string(workspace.join(rel)).ok() -} - -fn frontmatter_lines(text: &str) -> u32 { - let mut fences = 0; - let mut count = 0u32; - for line in text.split('\n') { - count += 1; - if line.trim_end_matches('\r') == "---" { - fences += 1; - if fences == 2 { - return count; - } - } - } - 0 -} - -fn locate( - workspace: &Path, - docs: &BTreeMap, - records: &[Attribution], - uri: &str, - line0: u32, -) -> Located { - let path = uri_to_path(uri); - let rel = relativize(workspace, &path); - let raw_line = line0 + 1; - if let Some(module) = stem(&rel) { - if records.iter().any(|record| record.module == module) { - let offset = document_text(workspace, docs, uri, &rel) - .map(|text| frontmatter_lines(&text)) - .unwrap_or(0); - if raw_line > offset { - return Located::Prompt { - module, - line: raw_line - offset, - }; - } - return Located::None; - } - } - Located::Generated { - file: rel, - line: raw_line, - } -} +The attribution document is authored line-based, keyed to prompt line ranges, and +this module consumes it as already-parsed values. Expose a public struct +`OwnershipEntry` (same derives, same `#[serde(deny_unknown_fields)]`), one +attribution entry, matching the on-disk shape exactly. Public fields: -fn prompt_entries<'a>(records: &'a [Attribution], module: &str, line: u32) -> Vec<&'a Entry> { - records - .iter() - .filter(|record| record.module == module) - .flat_map(|record| record.entries.iter()) - .filter(|entry| entry.prompt_start <= line && line <= entry.prompt_end) - .collect() -} +- `prompt_lines: [u32; 2]`, serde field name `promptLines`. The inclusive one-based + prompt line range this entry attributes, a two-element array `[start, end]`. +- `file: String`, serde field name `file`. The generated file. +- `lines: [u32; 2]`, serde field name `lines`. The inclusive one-based generated + file line range, a two-element array `[start, end]`. +- `note: String`, serde field name `note`. The human note. -fn generated_entries<'a>( - records: &'a [Attribution], - file: &str, - line: u32, -) -> Vec<(&'a Attribution, &'a Entry)> { - let mut out: Vec<(&Attribution, &Entry)> = Vec::new(); - for record in records { - for entry in &record.entries { - if generated_match(file, &entry.file) - && entry.line_start <= line - && line <= entry.line_end - { - out.push((record, entry)); - } - } - } - out -} +Expose a public struct `Attribution` (same derives, same +`#[serde(deny_unknown_fields)]`), one target's attribution document. Public fields: -fn markdown(lines: Vec) -> Value { - json!({ "contents": { "kind": "markdown", "value": lines.join("\n") } }) -} +- `module: String`, serde field name `module`. +- `target: String`, serde field name `target`. +- `entries: Vec`, serde field name `entries`. -fn hover(docs: &BTreeMap, message: &Value) -> Value { - let params = &message["params"]; - let uri = params["textDocument"]["uri"].as_str().unwrap_or(""); - let line0 = params["position"]["line"].as_u64().unwrap_or(0) as u32; - let Some(workspace) = resolve_workspace(uri) else { - return Value::Null; - }; - let workspace = workspace.as_path(); - let records = load_records(workspace); - match locate(workspace, docs, &records, uri, line0) { - Located::Prompt { module, line } => { - let matched = prompt_entries(&records, &module, line); - if matched.is_empty() { - return Value::Null; - } - let mut lines: Vec = Vec::new(); - for entry in matched { - lines.push(format!( - "{}:{}-{} - {}", - entry.file, entry.line_start, entry.line_end, entry.note - )); - } - markdown(lines) - } - Located::Generated { file, line } => { - let matched = generated_entries(&records, &file, line); - if matched.is_empty() { - return Value::Null; - } - let mut lines: Vec = Vec::new(); - for (record, entry) in matched { - lines.push(format!( - "{}.napl:{}-{} - {}", - record.module, entry.prompt_start, entry.prompt_end, entry.note - )); - } - markdown(lines) - } - Located::None => Value::Null, - } -} +Expose `validate_attribution(value: serde_json::Value) -> Result`: deserialize the JSON value into an `Attribution`, returning +`StoreDiffError::Malformed` on any deserialization failure, then check that every +entry's `promptLines` and `lines` are ascending, that is element 0 is less than or +equal to element 1, else `StoreDiffError::InvalidLineRange`. -fn location(workspace: &Path, file: &str, start: u32, end: u32) -> Value { - let uri = format!("file://{}", workspace.join(file).to_string_lossy()); - let start_line = start.saturating_sub(1); - let end_line = end.saturating_sub(1); - json!({ - "range": { - "end": { "character": 0, "line": end_line }, - "start": { "character": 0, "line": start_line } - }, - "uri": uri - }) -} +::: test +name: an attribution entry whose promptLines are reversed is rejected +given: { json: "an attribution whose one entry promptLines is set to [4, 1]" } +expect: { error: InvalidLineRange } +::: -fn prompt_offset(workspace: &Path, docs: &BTreeMap, module: &str) -> u32 { - let rel = format!("{module}.napl"); - let uri = format!("file://{}", workspace.join(&rel).to_string_lossy()); - document_text(workspace, docs, &uri, &rel) - .map(|text| frontmatter_lines(&text)) - .unwrap_or(0) -} +::: test +name: an attribution with an unknown top-level field is rejected +given: { json: "an attribution object with an extra top-level key \"bogus\": 1" } +expect: { error: Malformed } +::: -fn definition(docs: &BTreeMap, message: &Value) -> Value { - let params = &message["params"]; - let uri = params["textDocument"]["uri"].as_str().unwrap_or(""); - let line0 = params["position"]["line"].as_u64().unwrap_or(0) as u32; - let Some(workspace) = resolve_workspace(uri) else { - return Value::Array(Vec::new()); - }; - let workspace = workspace.as_path(); - let records = load_records(workspace); - let mut out: Vec = Vec::new(); - match locate(workspace, docs, &records, uri, line0) { - Located::Prompt { module, line } => { - for entry in prompt_entries(&records, &module, line) { - out.push(location(workspace, &entry.file, entry.line_start, entry.line_end)); - } - } - Located::Generated { file, line } => { - for (record, entry) in generated_entries(&records, &file, line) { - let offset = prompt_offset(workspace, docs, &record.module); - out.push(location( - workspace, - &format!("{}.napl", record.module), - entry.prompt_start + offset, - entry.prompt_end + offset, - )); - } - } - Located::None => {} - } - Value::Array(out) -} +::: test +name: an ownership entry with an unknown field is rejected +given: { json: "an attribution whose one entry carries an extra key \"bogus\": 1" } +expect: { error: Malformed } +::: -fn references(docs: &BTreeMap, message: &Value) -> Value { - let params = &message["params"]; - let uri = params["textDocument"]["uri"].as_str().unwrap_or(""); - let line0 = params["position"]["line"].as_u64().unwrap_or(0) as u32; - let Some(workspace) = resolve_workspace(uri) else { - return Value::Array(Vec::new()); - }; - let workspace = workspace.as_path(); - let records = load_records(workspace); - let matched: Vec<(&Attribution, &Entry)> = - match locate(workspace, docs, &records, uri, line0) { - Located::Prompt { module, line } => prompt_entries(&records, &module, line) - .into_iter() - .filter_map(|entry| { - records - .iter() - .find(|record| record.module == module) - .map(|record| (record, entry)) - }) - .collect(), - Located::Generated { file, line } => generated_entries(&records, &file, line), - Located::None => Vec::new(), - }; - let mut out: Vec = Vec::new(); - for (record, entry) in matched { - let offset = prompt_offset(workspace, docs, &record.module); - out.push(location( - workspace, - &format!("{}.napl", record.module), - entry.prompt_start + offset, - entry.prompt_end + offset, - )); - out.push(location(workspace, &entry.file, entry.line_start, entry.line_end)); - } - Value::Array(out) -} +## The bundle -fn code_lens(docs: &BTreeMap, message: &Value) -> Value { - let uri = message["params"]["textDocument"]["uri"].as_str().unwrap_or(""); - let Some(workspace) = resolve_workspace(uri) else { - return Value::Array(Vec::new()); - }; - let workspace = workspace.as_path(); - let records = load_records(workspace); - let path = uri_to_path(uri); - let rel = relativize(workspace, &path); - let Some(module) = stem(&rel) else { - return Value::Array(Vec::new()); - }; - let Some(record) = records.iter().find(|record| record.module == module) else { - return Value::Array(Vec::new()); - }; - let Some(text) = document_text(workspace, docs, uri, &rel) else { - return Value::Array(Vec::new()); - }; - let offset = frontmatter_lines(&text); - let mut headings: Vec = Vec::new(); - for (index, line) in text.split('\n').enumerate() { - let raw_line = index as u32 + 1; - if raw_line <= offset { - continue; - } - if line.trim_start().starts_with('#') { - headings.push(raw_line - offset); - } - } - let mut lenses: Vec = Vec::new(); - for (position, &body_line) in headings.iter().enumerate() { - let next = headings.get(position + 1).copied().unwrap_or(u32::MAX); - let mut files: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); - for entry in &record.entries { - if entry.prompt_start >= body_line && entry.prompt_start < next { - files.insert(entry.file.as_str()); - } - } - let raw_line = body_line + offset; - lenses.push(json!({ - "command": { - "command": "", - "title": format!("{} attributed files", files.len()) - }, - "range": { - "end": { "character": 0, "line": raw_line - 1 }, - "start": { "character": 0, "line": raw_line - 1 } - } - })); - } - Value::Array(lenses) -} -napl-store receipt v1 1042 -{"baseStateKey":"79b5813169ed5adeb10cc35fc52c22e390bdef3e22fb2a3dd0b3a300ec844dc4","baseStoreSnapshot":"8df3ac8e0604ab839c829a8f25bbe65b20d19aa7ec8b60a7d143d8429738c2a4","baseWorkspaceHash":"f741de8fc098f0c0452e95aebb018f72540fab2027c6f2a19eee83a54b8b3bf8","candidateTreeHash":"9853f46abd1fcdca3b3eaad6a5970de328a6dd30d982591cdfe46507193679c8","contractVersion":4,"inputKey":"66757b679873952442ef2168c17e58616924374434f76a86e04eede6b872ab41","module":"query","outputManifest":[{"hash":"3b3b46d4f8f832a2b67e59d481fce3269061f0c79cb335d3ddc5a51d8a1ce915","path":"crates/napl/src/check.rs"},{"hash":"b5e4e2782fa3e7b35714d43c6a5dd578e6adc40a53e872bce582dd3d76dc27c2","path":"crates/napl/src/document.rs"},{"hash":"2e84007b600de3257549b12e35b45e779d85751c59dd2ec7db7abde7ecd7e4c2","path":"crates/napl/src/promptbuild.rs"},{"hash":"c38df1128a297e15c512cf45a5c041588f37f1fea97b2ab011b6edc9b5bb945f","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"sliceHash":"1696723be87d0e2bc09005e0bb3eab8b50bef71452161241d132f1e9bfa8da87","target":"rust"} -napl-store blob v1 9954 ---- -module: commands -deps: [napl] -targets: [rust] ---- -:::docs -# Starting a workspace +Expose a public struct `Bundle` (same derives, same +`#[serde(deny_unknown_fields)]`), the assembled diff bundle for one module. Public +fields, in this shape: -napl init bootstraps a workspace and napl watch keeps it fresh. +- `module: String`, serde field name `module`. +- `prompt_changes: Vec`, serde field name `promptChanges`. The + prompt-side changes in emission order. +- `owned_old: Vec`, serde field name `ownedOld`. Generated regions + a changed block owned on the old side and whose file actually changed. +- `owned_new: Vec`, serde field name `ownedNew`. The same on the + new side. +- `unowned: Vec`, serde field name `unowned`. Code changes owned by no + changed prompt block, the audit signal that code moved with no prompt cause. -Two commands carry the daily loop. `napl init` turns a directory into -a workspace once; `napl watch` regenerates code whenever a prompt -changes. Everything between those two moments is the add and commit -cycle described in the tutorial. -::: -# commands: init and watch +## The error type -Two porcelain additions to the `napl` binary, each a thin composition -over the same napl-core derivations and napl-store reads the other -commands use. They live in this module and compile into the same crate as -the core toolchain. The query surface that used to live here belongs to -the `query` module. +Expose a public enum `StoreDiffError` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`) with exactly these variants: -## napl init +- `Malformed(String)`, carrying serde's message, for any deserialization failure + (an unknown field at any nesting level, a missing required field, a wrong JSON + type, an integer outside `u32`). +- `MalformedHash`, an occurrence-side `contentHash` that is not sixty-four + lowercase hex. +- `InconsistentChange`, a changed block whose `change` label disagrees with the + presence of its `old` and `new` sides. +- `InconsistentOwner`, an owner whose `level` disagrees with the presence of its + `sub_index`. +- `InvalidLineRange`, an occurrence side, attribution `promptLines`, or attribution + `lines` whose start exceeds its end. +- `MissingObject`, a tree root or subtree the bundle's tree diff needed but the + supplied object map did not contain (surfaced from `store_tree`). -:::docs -## napl init +## Validating a bundle -Run `napl init` once, in the directory that should become the -workspace. It creates the `.napl/` records directory with an empty -store and touches nothing else. Running it again is safe: an existing -workspace reports `already_initialized` and no file changes. +Expose `validate_bundle(value: serde_json::Value) -> Result`: deserialize the JSON value into a `Bundle`, returning +`StoreDiffError::Malformed` on any deserialization failure, then apply these +semantic checks, returning the first failure: -The payload teaches the first mile. `nextSteps` carries one complete -minimal prompt and the exact command sequence from writing it through -`add` and `commit`, so the first module never starts from a blank -page. Inside a git repository, init also writes the `.gitattributes` -lines that keep store files quiet in diffs, and writes the agent -entry files (`napl-agents.md`, `CLAUDE.md`, `AGENTS.md`) only where -they do not already exist. -::: +- For each changed block: every present occurrence side's `contentHash` is a + well-formed hash, else `StoreDiffError::MalformedHash`; every present occurrence + side has `lineStart` less than or equal to `lineEnd`, else + `StoreDiffError::InvalidLineRange`; the `change` label matches the two sides + (`Added` needs `old` absent and `new` present, `Removed` needs `old` present and + `new` absent, `Modified` needs both present), else + `StoreDiffError::InconsistentChange`. +- For each ownership record in `ownedOld` and `ownedNew`, and for every `owner` on + a bundle: an `Atom` owner has no `subIndex` and a `SubSpan` owner has a + `subIndex`, else `StoreDiffError::InconsistentOwner`. -:::example{lang="cli" name="bootstrap a workspace"} -napl init +::: test +name: a bundle with an unknown top-level field is rejected +given: { json: "the BD_EMPTY object with an extra top-level key \"bogus\": 1" } +expect: { error: Malformed } ::: -:::note -Init never nests workspaces and never rewrites a file it finds. If an -entry file already exists, the payload prints the one reference line -you may add by hand. +::: test +name: a changed block with an unknown field is rejected +given: { json: "the BD_RICH object whose one promptChanges block carries an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: -:::docs -### Your store travels with your code - -The store is ordinary files under `.napl/`, so it travels the way -your code does: commit, push, clone, nothing extra. Inside a git -repository init writes the `.gitattributes` lines that mark store -objects and packs as generated, so pull requests stay readable while -the store rides along. Outside git, none of this appears. +::: test +name: an occurrence side with an unknown field is rejected +given: { json: "the BD_RICH object whose one promptChanges block old side carries an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: -`napl init` bootstraps a workspace: it creates the records directory -`.napl/` with an empty store (`store/` skeleton, no HEAD, no objects) in -the current directory when no ancestor workspace exists. Running it inside -an existing workspace changes nothing and reports `already_initialized`; -it never creates a nested workspace under another workspace and never -touches an existing store's bytes. The result of init is exactly the -state a fresh `napl compile` expects: head absent, no modules recorded. -Like `git init`, the directory it runs in is the whole story: the -success payload names that directory as the workspace root, there is -nothing to configure, and a prompt written beside `.napl` is already -inside placement. Init accepts no flags beyond the shared help flag; -anything else is a usage error under the command line contract. The -payload also teaches the first mile: a `nextSteps` field carries one -complete minimal prompt, frontmatter fences with `module`, `deps`, and -`targets` plus one body line, the fact that discovery finds prompts by -the `.napl` extension, and the exact command sequence from writing that -prompt through `add` and `commit`, so a cold user never reverse -engineers the grammar one validation error at a time. The minimal -prompt's frontmatter includes a `roots` declaration naming its own -module, so the very first workspace is lint-ready and the sequence's -forward pointer about `roots` is a reminder, never a first encounter. - -Init also scaffolds the agent-teaching glue, wrangler-style, writing -only what is absent and never touching an existing file: a -`napl-agents.md` at the workspace root carrying the working doctrine -for LLM sessions (edit with your native tools, drift is legal and the -diagnostics mark it, the status-add-commit loop, the query surface, -capture verbs are deliberate acts), plus, when absent, a `CLAUDE.md` -containing an import reference to it and an `AGENTS.md` containing a -pointer line, so whichever harness opens the repo finds its native -entry file already pointing at the one doctrine. Every command the -scaffolded teaching spells is a valid invocation under the live -command line contract: a query example always carries its expression, -`napl q '.' --working-tree` and never a bare `napl q`, because the -expression is required and a bare invocation is a usage error. No -environment is -sniffed and no harness detected: every entry file is written, tiny -and inert, and determinism holds. When an entry file already exists, -init leaves it alone and the payload prints the one reference line -the user may add by hand. All scaffolded files are user-owned from -the moment they exist: ordinary files, never records, never -rewritten by any later command. - -When init runs inside a git repository it also scaffolds the store's -git manners under the same never-clobber law: a `.gitattributes` at -the workspace root carrying `.napl/store/objects/** -text -diff -linguist-generated` covering objects and packs alike, -`.napl/store/HEAD binary`, and `-text` on the store's notes and the -attribution records, which keep their readable diffs because reading -them is those files' purpose; written only when no `.gitattributes` -exists, and when one exists, init leaves it alone and the payload -prints the exact lines to add by hand. `-text` is the load-bearing word: an -envelope's name is the hash of its exact bytes, so an eol-converting -checkout on any platform would corrupt every object against its own -name, and marking the store never-text is what makes a clone safe -everywhere. `binary` on HEAD keeps a merge from ever writing conflict -markers into the pointer. Nothing else about travel needs teaching, -because the store is ordinary tracked files and git already carries -them. Outside a git repository none of this appears, in the payload -or on disk. - ::: test -name: init in an empty directory creates the records skeleton with no head -given: { directory: empty } -expect: { created: [".napl/store"], head: absent, exit_zero: true, workspace_root_reported: true } +name: an owner reference with an unknown field is rejected +given: { json: "the BD_RICH object whose first ownedNew owner carries an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: ::: test -name: init teaches the first mile with a complete minimal prompt and the command sequence -given: { directory: empty } -expect: { next_steps_prompt_passes_check: true, extension_named: ".napl", sequence_named: [write_prompt, add, commit], prompt_declares_roots: true } +name: an ownership record with an unknown field is rejected +given: { json: "the BD_RICH object whose first ownedNew record carries an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: ::: test -name: init scaffolds the agent glue only where absent and clobbers nothing -given: { directory: with_existing_claude_md } -expect: { napl_agents_md_written: true, agents_md_written: true, claude_md_untouched: true, payload_prints_reference_line: true } +name: a code change with an unknown field is rejected +given: { json: "the BD_RICH object whose one unowned code change carries an extra key \"bogus\": 1" } +expect: { error: Malformed } ::: ::: test -name: scaffolded teaching never spells a bare napl q and its query example carries an expression -given: { directory: empty } -expect: { bare_napl_q_absent: true, query_example_has_expression: true } +name: an occurrence side whose contentHash is not sixty-four lowercase hex is rejected +given: { json: "the BD_RICH object whose one promptChanges block old contentHash is set to \"xyz\"" } +expect: { error: MalformedHash } ::: ::: test -name: scaffolded files are inert and later commands never rewrite them -given: { directory: empty, then: [init, write_prompt, add_module, commit] } -expect: { scaffold_bytes_unchanged_after: true } +name: an added block that carries an old side is rejected +given: { json: "a bundle with one added-level block carrying both an old and a new occurrence side" } +expect: { error: InconsistentChange } ::: ::: test -name: init where a workspace already exists changes nothing and says so -given: { directory: existing_workspace } -expect: { changed_files: [], outcome: already_initialized, exit_zero: true } +name: a removed block that carries a new side is rejected +given: { json: "a bundle with one removed-level block carrying both an old and a new occurrence side" } +expect: { error: InconsistentChange } ::: ::: test -name: init never overwrites an existing store -given: { directory: existing_workspace_with_commits } -expect: { store_bytes_unchanged: true } +name: a modified block missing its new side is rejected +given: { json: "a bundle with one modified-level block carrying only an old occurrence side" } +expect: { error: InconsistentChange } ::: ::: test -name: init inside a git repository writes the gitattributes store lines only where no gitattributes exists -given: { directory: git_repo_no_gitattributes } -expect: { gitattributes_written: true, store_objects_marked_never_text_undiffed_generated: true, head_marked_binary: true, notes_and_attribution_never_text_diff_kept: true } +name: an atom-level owner carrying a subIndex is rejected +given: { json: "the BD_RICH object whose first ownedNew owner level is set to \"atom\" while it keeps its subIndex 1" } +expect: { error: InconsistentOwner } ::: ::: test -name: init leaves an existing gitattributes alone and prints the lines to add -given: { directory: git_repo_with_gitattributes } -expect: { gitattributes_bytes_unchanged: true, payload_prints_store_lines: true } +name: a subSpan-level owner missing its subIndex is rejected +given: { json: "the BD_RICH object whose first ownedNew owner has its subIndex key removed while its level stays subSpan" } +expect: { error: InconsistentOwner } ::: ::: test -name: init outside a git repository scaffolds no hook and teaches no git rules -given: { directory: empty } -expect: { pre_push_hook_written: false, payload_mentions_napl_store_branch: false } +name: an occurrence side whose lineStart exceeds its lineEnd is rejected +given: { json: "the BD_RICH object whose one promptChanges block old side lineStart is set to 9 and lineEnd left at 2" } +expect: { error: InvalidLineRange } ::: -## napl watch +## The two-stage alignment -:::docs -## napl watch +Both the atom level and the sub-span level align two sequences of content-hash +tokens by the same deterministic two-stage rule. Stage one finds the identical +anchors by a longest-common-subsequence over the token sequences. Stage two pairs +the runs of non-anchor tokens between consecutive anchors positionally, so a +changed token keeps its correspondence and duplicate ties break toward the nearest +index. -Run `napl watch` in the workspace and it watches every prompt file for -changes. Each time you save a prompt, it recompiles and prints the -result to your terminal as JSON. It never writes anything: no files -change, nothing is added to the store. +The longest-common-subsequence is computed by the standard dynamic-programming +length table over token equality, and the anchors are recovered by a forward +traceback that is fully deterministic: walking from the start of both sequences, +at each step take a diagonal match when the two current tokens are equal, else +advance the old cursor when the table value below is greater than or equal to the +table value to the right, else advance the new cursor. This traceback rule is the +pinned tie-break; do not substitute a different one. -Watch notices real changes, not just a save: if the file's content is -the same as before, nothing recompiles. If you save a prompt with a -mistake in it, watch reports the problem in its output and keeps -running rather than stopping. -::: - -:::example{lang="cli" name="watch a workspace for changes"} -napl watch -::: - -:::note -The only way to stop `napl watch` is to stop the process. Nothing is -lost when you do. -::: - -`napl watch` is an I/O shell around the pure compile derivation: it -observes the workspace's `*.napl` files and re-runs the derivation when -their content changes, emitting each result to stdout as canonical JSON. It publishes nothing: no semantic writes, no store -mutation, no locks held between runs. Change detection is by content -hash, never by timestamp, so an editor save that leaves bytes identical -produces no recompile. A malformed prompt is reported as a diagnostic in -the output stream and the watcher keeps running. Stopping the process is -the only way to end it, and stopping it loses nothing. - -::: test -name: watch re-derives compile output when a prompt changes and writes nothing semantic -given: { workspace: fixture_two_modules, edit: touch_leaf_prompt } -expect: { recompiled: true, semantic_state_published: false } -::: - -::: test -name: watch survives a malformed prompt edit and reports it instead of dying -given: { workspace: fixture_two_modules, edit: break_leaf_frontmatter } -expect: { process_alive: true, diagnostic_reported: true } -::: - -napl-store snapshot v1 445 -{"contractVersion":1,"event":"d8cbb549ed651915f8d0e273a5967e6243279f3a9bf79eed5e099135c535828c","states":[{"module":"commands","state":"7c7e8183b9fbf594f3d61a4ca43f5154c404f912f32b8789d32c697ae883859b","target":"rust"},{"module":"napl","state":"81b727d0fdbdcbf9412b7798b650076b811080051e9b4598ce3677811b30443a","target":"rust"},{"module":"napl_store","state":"e0c61506bbf3fe83ae5425120174331a4abfe75f3e794691484414af3a0acd53","target":"rust"}]} -napl-store surface v1 73442 -{"contractVersion":4,"items":[{"kind":"struct","path":"attribution::Attribution","signature":"pub struct Attribution"},{"kind":"field","path":"attribution::Attribution.entries","signature":"pub entries : Vec < Entry >"},{"kind":"field","path":"attribution::Attribution.module","signature":"pub module : String"},{"kind":"field","path":"attribution::Attribution.stale","signature":"pub stale : bool"},{"kind":"field","path":"attribution::Attribution.target","signature":"pub target : String"},{"kind":"enum","path":"attribution::Direction","signature":"pub enum Direction { # [ default ] PromptToCode , CodeToPrompt }"},{"kind":"method","path":"attribution::Direction::as_str","signature":"pub fn as_str ( & self ) -> & ' static str"},{"kind":"method","path":"attribution::Direction::from_token","signature":"pub fn from_token ( text : & str ) -> Direction"},{"kind":"const","path":"attribution::ENDPOINTS","signature":"pub const ENDPOINTS : [ & str ; 2 ] = [ \" code \" , \" prompt \" ] ;"},{"kind":"struct","path":"attribution::Entry","signature":"pub struct Entry"},{"kind":"field","path":"attribution::Entry.code_hash","signature":"pub code_hash : Option < String >"},{"kind":"field","path":"attribution::Entry.direction","signature":"pub direction : Direction"},{"kind":"field","path":"attribution::Entry.file","signature":"pub file : Option < String >"},{"kind":"field","path":"attribution::Entry.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"attribution::Entry.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"attribution::Entry.note","signature":"pub note : String"},{"kind":"field","path":"attribution::Entry.prompt_end","signature":"pub prompt_end : u32"},{"kind":"field","path":"attribution::Entry.prompt_hash","signature":"pub prompt_hash : Option < String >"},{"kind":"field","path":"attribution::Entry.prompt_start","signature":"pub prompt_start : u32"},{"kind":"field","path":"attribution::Entry.provenance","signature":"pub provenance : Provenance"},{"kind":"field","path":"attribution::Entry.whole_file","signature":"pub whole_file : bool"},{"kind":"field","path":"attribution::Entry.whole_prompt","signature":"pub whole_prompt : bool"},{"kind":"method","path":"attribution::Entry::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"attribution::Entry::hashes_match","signature":"pub fn hashes_match ( & self , code_bytes : Option < & [ u8 ] > , prompt_body : & str ) -> bool"},{"kind":"method","path":"attribution::Entry::is_law","signature":"pub fn is_law ( & self ) -> bool"},{"kind":"method","path":"attribution::Entry::self_affirming","signature":"pub fn self_affirming ( & self ) -> bool"},{"kind":"struct","path":"attribution::MapParseError","signature":"pub struct MapParseError"},{"kind":"field","path":"attribution::MapParseError.construct","signature":"pub construct : String"},{"kind":"field","path":"attribution::MapParseError.detail","signature":"pub detail : String"},{"kind":"field","path":"attribution::MapParseError.line","signature":"pub line : u32"},{"kind":"struct","path":"attribution::Provenance","signature":"pub struct Provenance"},{"kind":"field","path":"attribution::Provenance.model","signature":"pub model : Option < String >"},{"kind":"field","path":"attribution::Provenance.timestamp","signature":"pub timestamp : Option < String >"},{"kind":"field","path":"attribution::Provenance.tool_version","signature":"pub tool_version : Option < String >"},{"kind":"field","path":"attribution::Provenance.usage","signature":"pub usage : Option < UsageRef >"},{"kind":"method","path":"attribution::Provenance::is_empty","signature":"pub fn is_empty ( & self ) -> bool"},{"kind":"method","path":"attribution::Provenance::served","signature":"pub fn served ( & self ) -> Vec < ( & ' static str , String ) >"},{"kind":"method","path":"attribution::Provenance::served_json","signature":"pub fn served_json ( & self ) -> Value"},{"kind":"struct","path":"attribution::ReferenceStatus","signature":"pub struct ReferenceStatus"},{"kind":"field","path":"attribution::ReferenceStatus.endpoints","signature":"pub endpoints : Vec < & ' static str >"},{"kind":"field","path":"attribution::ReferenceStatus.stale","signature":"pub stale : bool"},{"kind":"method","path":"attribution::ReferenceStatus::as_str","signature":"pub fn as_str ( & self ) -> & ' static str"},{"kind":"const","path":"attribution::UNAVAILABLE","signature":"pub const UNAVAILABLE : & str = \" unavailable \" ;"},{"kind":"enum","path":"attribution::UsageRef","signature":"pub enum UsageRef { Envelope ( String ) , Window ( UsageWindow ) }"},{"kind":"method","path":"attribution::UsageRef::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"attribution::UsageRef::rendered","signature":"pub fn rendered ( & self ) -> String"},{"kind":"struct","path":"attribution::UsageWindow","signature":"pub struct UsageWindow"},{"kind":"field","path":"attribution::UsageWindow.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"attribution::UsageWindow.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"attribution::UsageWindow.log_path","signature":"pub log_path : String"},{"kind":"field","path":"attribution::UsageWindow.media_type","signature":"pub media_type : String"},{"kind":"field","path":"attribution::UsageWindow.session","signature":"pub session : String"},{"kind":"const","path":"attribution::WHOLE","signature":"pub const WHOLE : & str = \" whole \" ;"},{"kind":"fn","path":"attribution::code_drifted","signature":"pub fn code_drifted ( committed_blob : Option < & str > , working_bytes : Option < & [ u8 ] > ) -> bool"},{"kind":"fn","path":"attribution::map_contract","signature":"pub fn map_contract ( ) -> Value"},{"kind":"fn","path":"attribution::parse","signature":"pub fn parse ( text : & str ) -> Option < Attribution >"},{"kind":"fn","path":"attribution::parse_detailed","signature":"pub fn parse_detailed ( text : & str ) -> std :: result :: Result < Attribution , MapParseError >"},{"kind":"fn","path":"attribution::record_hash","signature":"pub fn record_hash ( attribution : & Attribution ) -> String"},{"kind":"fn","path":"attribution::reference_status","signature":"pub fn reference_status ( record_stale : bool , prompt_drifted : bool , code_drifted : bool ) -> ReferenceStatus"},{"kind":"fn","path":"attribution::remap_prefix","signature":"pub fn remap_prefix ( attribution : & mut Attribution , old_root : & str , dest : & str )"},{"kind":"fn","path":"attribution::resolve_whole","signature":"pub fn resolve_whole ( attribution : & mut Attribution , prompt_body_lines : u32 , file_lines : & BTreeMap < String , u32 > )"},{"kind":"fn","path":"attribution::to_yaml","signature":"pub fn to_yaml ( attribution : & Attribution ) -> Vec < u8 >"},{"kind":"fn","path":"attribution::validate","signature":"pub fn validate ( target : & TargetDef , root : & str , module : & str , candidate_files : & BTreeMap < String , Vec < u8 > > , prompt_body_lines : u32 , attribution : & Attribution ) -> Result < ( ) >"},{"kind":"fn","path":"attribution::view_path","signature":"pub fn view_path ( module : & str ) -> String"},{"kind":"fn","path":"attribution::whole_map","signature":"pub fn whole_map ( module : & str , target : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Attribution"},{"kind":"fn","path":"authoring::apply_fix","signature":"pub fn apply_fix ( workspace : & Path , target : & str , finding : & Finding ) -> Result < CommitReport >"},{"kind":"fn","path":"authoring::draft_receipt","signature":"pub fn draft_receipt ( workspace : & Path , target_name : & str , module : & str , candidate_dir : & Path , usage_path : Option < & Path > ) -> Result < Value >"},{"kind":"fn","path":"authoring::draft_receipt_for","signature":"pub fn draft_receipt_for ( workspace : & Path , target_name : & str , module : & str , candidate : & crate :: candidate :: Candidate , usage_path : Option < & Path > ) -> Result < Value >"},{"kind":"struct","path":"blame::BlameLine","signature":"pub struct BlameLine"},{"kind":"field","path":"blame::BlameLine.line","signature":"pub line : usize"},{"kind":"field","path":"blame::BlameLine.text","signature":"pub text : String"},{"kind":"field","path":"blame::BlameLine.transition","signature":"pub transition : String"},{"kind":"struct","path":"blame::Revision","signature":"pub struct Revision"},{"kind":"field","path":"blame::Revision.content","signature":"pub content : String"},{"kind":"field","path":"blame::Revision.transition","signature":"pub transition : String"},{"kind":"fn","path":"blame::blame","signature":"pub fn blame ( revisions : & [ Revision ] ) -> Vec < BlameLine >"},{"kind":"fn","path":"blame::blame_json","signature":"pub fn blame_json ( path : & str , lines : & [ BlameLine ] ) -> Value"},{"kind":"fn","path":"blame::untracked_json","signature":"pub fn untracked_json ( path : & str , reason : & str ) -> Value"},{"kind":"struct","path":"candidate::Candidate","signature":"pub struct Candidate"},{"kind":"field","path":"candidate::Candidate.files","signature":"pub files : BTreeMap < String , Vec < u8 > >"},{"kind":"field","path":"candidate::Candidate.manifest","signature":"pub manifest : BTreeMap < String , String >"},{"kind":"field","path":"candidate::Candidate.tree_hash","signature":"pub tree_hash : String"},{"kind":"field","path":"candidate::Candidate.tree_objects","signature":"pub tree_objects : BTreeMap < String , Tree >"},{"kind":"enum","path":"candidate::EntryKind","signature":"pub enum EntryKind { File , Link , Device }"},{"kind":"struct","path":"candidate::Limits","signature":"pub struct Limits"},{"kind":"field","path":"candidate::Limits.max_entries","signature":"pub max_entries : usize"},{"kind":"field","path":"candidate::Limits.max_entry_bytes","signature":"pub max_entry_bytes : usize"},{"kind":"field","path":"candidate::Limits.max_total_bytes","signature":"pub max_total_bytes : usize"},{"kind":"struct","path":"candidate::ScannedEntry","signature":"pub struct ScannedEntry"},{"kind":"field","path":"candidate::ScannedEntry.bytes","signature":"pub bytes : Vec < u8 >"},{"kind":"field","path":"candidate::ScannedEntry.kind","signature":"pub kind : EntryKind"},{"kind":"field","path":"candidate::ScannedEntry.path","signature":"pub path : String"},{"kind":"fn","path":"candidate::default_limits","signature":"pub fn default_limits ( ) -> Limits"},{"kind":"fn","path":"candidate::ingest","signature":"pub fn ingest ( entries : & [ ScannedEntry ] , limits : Limits ) -> Result < Candidate >"},{"kind":"fn","path":"canon::canonical_bytes","signature":"pub fn canonical_bytes ( value : & Value ) -> Vec < u8 >"},{"kind":"fn","path":"canon::hash_json","signature":"pub fn hash_json ( kind : ObjectKind , value : & Value ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"canon::is_object_name","signature":"pub fn is_object_name ( text : & str ) -> bool"},{"kind":"fn","path":"canon::json_hash","signature":"pub fn json_hash ( kind : ObjectKind , value : & Value ) -> String"},{"kind":"fn","path":"canon::parse_json","signature":"pub fn parse_json ( bytes : & [ u8 ] , context : & str , code : OutcomeCode ) -> Result < Value >"},{"kind":"const","path":"capture::SESSION_VARIABLE","signature":"pub const SESSION_VARIABLE : & str = \" NAPL_SESSION \" ;"},{"kind":"const","path":"capture::USAGE_LOG_MEDIA_TYPE","signature":"pub const USAGE_LOG_MEDIA_TYPE : & str = \" application/x-napl-usage-proxy-jsonl-v1 \" ;"},{"kind":"const","path":"capture::USAGE_LOG_PATH","signature":"pub const USAGE_LOG_PATH : & str = \" .napl/usage-proxy.jsonl \" ;"},{"kind":"fn","path":"capture::edit_bytes","signature":"pub fn edit_bytes ( workspace : & Path , path : & str , old : & str , new : & str , replace_all : bool , reference : Option < & str > , session : Option < & str > ) -> Result < CliResult >"},{"kind":"fn","path":"capture::ref_contract","signature":"pub fn ref_contract ( ) -> Value"},{"kind":"fn","path":"capture::write_bytes","signature":"pub fn write_bytes ( workspace : & Path , path : & str , bytes : & [ u8 ] , reference : Option < & str > , session : Option < & str > ) -> Result < CliResult >"},{"kind":"const","path":"cli::BINARY_USAGE","signature":"pub const BINARY_USAGE : & str = \" napl [arguments] \" ;"},{"kind":"struct","path":"cli::CliResult","signature":"pub struct CliResult"},{"kind":"field","path":"cli::CliResult.exit_code","signature":"pub exit_code : i32"},{"kind":"field","path":"cli::CliResult.stdout","signature":"pub stdout : Vec < u8 >"},{"kind":"struct","path":"cli::CommandSpec","signature":"pub struct CommandSpec"},{"kind":"field","path":"cli::CommandSpec.example","signature":"pub example : & ' static str"},{"kind":"field","path":"cli::CommandSpec.flags","signature":"pub flags : & ' static [ FlagSpec ]"},{"kind":"field","path":"cli::CommandSpec.name","signature":"pub name : & ' static str"},{"kind":"field","path":"cli::CommandSpec.passthrough","signature":"pub passthrough : bool"},{"kind":"field","path":"cli::CommandSpec.run","signature":"pub run : fn ( & Path , & [ String ] ) -> Result < CliResult >"},{"kind":"field","path":"cli::CommandSpec.summary","signature":"pub summary : & ' static str"},{"kind":"field","path":"cli::CommandSpec.usage","signature":"pub usage : & ' static str"},{"kind":"enum","path":"cli::FlagArity","signature":"pub enum FlagArity { Switch , Value }"},{"kind":"struct","path":"cli::FlagSpec","signature":"pub struct FlagSpec"},{"kind":"field","path":"cli::FlagSpec.arity","signature":"pub arity : FlagArity"},{"kind":"field","path":"cli::FlagSpec.meaning","signature":"pub meaning : & ' static str"},{"kind":"field","path":"cli::FlagSpec.name","signature":"pub name : & ' static str"},{"kind":"field","path":"cli::FlagSpec.short","signature":"pub short : Option < & ' static str >"},{"kind":"const","path":"cli::HELP_FLAGS","signature":"pub const HELP_FLAGS : [ & str ; 2 ] = [ \" --help \" , \" -h \" ] ;"},{"kind":"const","path":"cli::HUMAN_FLAG","signature":"pub const HUMAN_FLAG : & str = \" --human \" ;"},{"kind":"const","path":"cli::JSON_FLAG","signature":"pub const JSON_FLAG : & str = \" --json \" ;"},{"kind":"const","path":"cli::USAGE_EXIT_CODE","signature":"pub const USAGE_EXIT_CODE : i32 = 2 ;"},{"kind":"const","path":"cli::WORKSPACE_FLAG","signature":"pub const WORKSPACE_FLAG : FlagSpec = value ( \" --workspace \" , \" the workspace root to operate on, instead of discovering it from the current directory \" ) ;"},{"kind":"fn","path":"cli::clap_command_for","signature":"pub fn clap_command_for ( spec : & CommandSpec ) -> clap :: Command"},{"kind":"fn","path":"cli::clap_root","signature":"pub fn clap_root ( registry : & [ CommandSpec ] ) -> clap :: Command"},{"kind":"fn","path":"cli::emit_prerequisite","signature":"pub fn emit_prerequisite ( command : & str ) -> CliResult"},{"kind":"fn","path":"cli::help_all_json","signature":"pub fn help_all_json ( registry : & [ CommandSpec ] ) -> Value"},{"kind":"fn","path":"cli::help_command_json","signature":"pub fn help_command_json ( spec : & CommandSpec ) -> Value"},{"kind":"fn","path":"cli::prerequisite_json","signature":"pub fn prerequisite_json ( command : & str ) -> Value"},{"kind":"fn","path":"cli::render_help_all","signature":"pub fn render_help_all ( registry : & [ CommandSpec ] ) -> String"},{"kind":"fn","path":"cli::render_help_command","signature":"pub fn render_help_command ( spec : & CommandSpec ) -> String"},{"kind":"fn","path":"cli::run_cli","signature":"pub fn run_cli ( cwd : & Path , args : & [ String ] ) -> CliResult"},{"kind":"fn","path":"cli::switch","signature":"pub const fn switch ( name : & ' static str , meaning : & ' static str ) -> FlagSpec"},{"kind":"fn","path":"cli::switch_short","signature":"pub const fn switch_short ( name : & ' static str , short : & ' static str , meaning : & ' static str ) -> FlagSpec"},{"kind":"fn","path":"cli::usage_json","signature":"pub fn usage_json ( spec : Option < & CommandSpec > , detail : & str , unknown : Option < & str > , suggestion : Option < & str > ) -> Value"},{"kind":"fn","path":"cli::value","signature":"pub const fn value ( name : & ' static str , meaning : & ' static str ) -> FlagSpec"},{"kind":"const","path":"commandset::COMPLETION_SHELLS","signature":"pub const COMPLETION_SHELLS : [ & str ; 3 ] = [ \" bash \" , \" zsh \" , \" fish \" ] ;"},{"kind":"fn","path":"commandset::commands","signature":"pub fn commands ( ) -> & ' static [ CommandSpec ]"},{"kind":"enum","path":"commit::AttributionMode","signature":"pub enum AttributionMode { Prove , PreProven , Escape }"},{"kind":"struct","path":"commit::CommitReport","signature":"pub struct CommitReport"},{"kind":"field","path":"commit::CommitReport.outcome","signature":"pub outcome : OutcomeCode"},{"kind":"field","path":"commit::CommitReport.payload","signature":"pub payload : Value"},{"kind":"fn","path":"commit::commit","signature":"pub fn commit ( workspace : & Path , receipt_path : & Path , candidate_dir : & Path , attribution_path : Option < & Path > , no_verify : bool ) -> CommitReport"},{"kind":"fn","path":"commit::commit_with_mode","signature":"pub fn commit_with_mode ( workspace : & Path , receipt_path : & Path , candidate_dir : & Path , attribution_path : Option < & Path > , mode : AttributionMode ) -> CommitReport"},{"kind":"fn","path":"commit::lock_key","signature":"pub fn lock_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"commit::recover","signature":"pub fn recover ( store : & Store ) -> Result < Vec < String > >"},{"kind":"struct","path":"compile::CompileArtifact","signature":"pub struct CompileArtifact"},{"kind":"field","path":"compile::CompileArtifact.artifact_hash","signature":"pub artifact_hash : String"},{"kind":"field","path":"compile::CompileArtifact.slices","signature":"pub slices : Vec < CompileSlice >"},{"kind":"field","path":"compile::CompileArtifact.value","signature":"pub value : Value"},{"kind":"fn","path":"compile::base_output_files","signature":"pub fn base_output_files ( source : & dyn ObjectSource , state : & ModuleState ) -> Result < Vec < ( String , String ) > >"},{"kind":"fn","path":"compile::compile","signature":"pub fn compile ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , only_module : Option < & str > ) -> Result < CompileArtifact >"},{"kind":"fn","path":"compile::graph_for","signature":"pub fn graph_for ( sources : & [ SourceModule ] , target_name : & str ) -> Result < Graph >"},{"kind":"fn","path":"compile::head_states","signature":"pub fn head_states ( source : & dyn ObjectSource , snapshot : Option < & str > ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"compile::read_surface_items","signature":"pub fn read_surface_items ( source : & dyn ObjectSource , hash : & str ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"compile::slice_at","signature":"pub fn slice_at ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , module : & str , snapshot : Option < & str > ) -> Result < CompileSlice >"},{"kind":"fn","path":"compile::state_of","signature":"pub fn state_of ( source : & dyn ObjectSource , states : & BTreeMap < ( String , String ) , String > , target : & TargetDef , module : & str ) -> Result < ( ModuleState , String ) >"},{"kind":"fn","path":"compile::workspace_source_hash","signature":"pub fn workspace_source_hash ( sources : & [ SourceModule ] ) -> Result < String >"},{"kind":"struct","path":"cost::CostLine","signature":"pub struct CostLine"},{"kind":"field","path":"cost::CostLine.cost_usd","signature":"pub cost_usd : Option < String >"},{"kind":"field","path":"cost::CostLine.model","signature":"pub model : Option < String >"},{"kind":"field","path":"cost::CostLine.module","signature":"pub module : String"},{"kind":"field","path":"cost::CostLine.reason","signature":"pub reason : Option < String >"},{"kind":"field","path":"cost::CostLine.receipt","signature":"pub receipt : String"},{"kind":"field","path":"cost::CostLine.tokens","signature":"pub tokens : Option < TokenUsage >"},{"kind":"const","path":"cost::UNKNOWN","signature":"pub const UNKNOWN : & str = \" unknown \" ;"},{"kind":"struct","path":"cost::WindowRef","signature":"pub struct WindowRef"},{"kind":"field","path":"cost::WindowRef.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"cost::WindowRef.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"cost::WindowRef.log_path","signature":"pub log_path : String"},{"kind":"field","path":"cost::WindowRef.media_type","signature":"pub media_type : String"},{"kind":"field","path":"cost::WindowRef.session","signature":"pub session : String"},{"kind":"fn","path":"cost::cost_report","signature":"pub fn cost_report ( lines : & [ CostLine ] ) -> Value"},{"kind":"fn","path":"cost::line_from_reported","signature":"pub fn line_from_reported ( receipt : & str , module : & str , reported : & Value ) -> Result < CostLine >"},{"kind":"fn","path":"cost::line_from_window","signature":"pub fn line_from_window ( receipt : & str , module : & str , window : & WindowRef , log : Option < & str > ) -> CostLine"},{"kind":"fn","path":"cost::lines_from_session","signature":"pub fn lines_from_session ( log : & str , session : & str ) -> Vec < CostLine >"},{"kind":"fn","path":"cost::pricing_provenance","signature":"pub fn pricing_provenance ( ) -> Value"},{"kind":"fn","path":"cost::window_from_reported","signature":"pub fn window_from_reported ( reported : & Value ) -> Option < WindowRef >"},{"kind":"struct","path":"decision::Arriving","signature":"pub struct Arriving"},{"kind":"field","path":"decision::Arriving.base_snapshot","signature":"pub base_snapshot : Option < String >"},{"kind":"field","path":"decision::Arriving.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Arriving.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Arriving.write_set","signature":"pub write_set : BTreeSet < String >"},{"kind":"enum","path":"decision::Decision","signature":"pub enum Decision { Install , Rebase , StaleInput { expected : String , found : String } , BaseOutputChanged { expected : String , found : String } , WriteConflict { overlap : Vec < String > } }"},{"kind":"struct","path":"decision::Head","signature":"pub struct Head"},{"kind":"field","path":"decision::Head.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Head.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Head.intervening_write_set","signature":"pub intervening_write_set : BTreeSet < String >"},{"kind":"field","path":"decision::Head.snapshot","signature":"pub snapshot : Option < String >"},{"kind":"fn","path":"decision::conflicts","signature":"pub fn conflicts ( write_set_a : & BTreeSet < String > , read_set_a : & BTreeSet < String > , write_set_b : & BTreeSet < String > , changed_identities_b : & BTreeSet < String > ) -> bool"},{"kind":"fn","path":"decision::decide","signature":"pub fn decide ( arriving : & Arriving , head : & Head ) -> Decision"},{"kind":"fn","path":"decision::is_accepted","signature":"pub fn is_accepted ( decision : & Decision ) -> bool"},{"kind":"fn","path":"decision::rejection","signature":"pub fn rejection ( decision : & Decision ) -> Option < CompilerError >"},{"kind":"struct","path":"directive::DirectiveCase","signature":"pub struct DirectiveCase"},{"kind":"field","path":"directive::DirectiveCase.expect","signature":"pub expect : Value"},{"kind":"field","path":"directive::DirectiveCase.given","signature":"pub given : Value"},{"kind":"field","path":"directive::DirectiveCase.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"directive::DirectiveCase.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"directive::DirectiveCase.name","signature":"pub name : String"},{"kind":"fn","path":"directive::extract_cases","signature":"pub fn extract_cases ( normalized_body : & str ) -> Result < Vec < DirectiveCase > >"},{"kind":"fn","path":"directive::extract_validated","signature":"pub fn extract_validated ( normalized_body : & str , frontmatter_names : & [ String ] ) -> Result < Vec < DirectiveCase > >"},{"kind":"fn","path":"directive::normalize_case_name","signature":"pub fn normalize_case_name ( name : & str ) -> String"},{"kind":"fn","path":"directive::parse_inline_value","signature":"pub fn parse_inline_value ( text : & str ) -> Option < Value >"},{"kind":"const","path":"disk::POINTER_LOCK","signature":"pub const POINTER_LOCK : & str = \" pointer \" ;"},{"kind":"const","path":"disk::STORE_DIR","signature":"pub const STORE_DIR : & str = \" .napl/store \" ;"},{"kind":"struct","path":"disk::Store","signature":"pub struct Store"},{"kind":"field","path":"disk::Store.root","signature":"pub root : PathBuf"},{"kind":"field","path":"disk::Store.workspace","signature":"pub workspace : PathBuf"},{"kind":"fn","path":"disk::compare_and_set_head","signature":"pub fn compare_and_set_head ( store : & Store , expected : Option < & str > , next : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::delete_marker","signature":"pub fn delete_marker ( store : & Store , snapshot : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::init","signature":"pub fn init ( workspace : & Path ) -> Result < Store >"},{"kind":"fn","path":"disk::io","signature":"pub fn io ( path : & Path , err : & std :: io :: Error ) -> CompilerError"},{"kind":"fn","path":"disk::marker_snapshots","signature":"pub fn marker_snapshots ( store : & Store ) -> Result < Vec < String > >"},{"kind":"fn","path":"disk::open","signature":"pub fn open ( workspace : & Path ) -> Store"},{"kind":"fn","path":"disk::publish","signature":"pub fn publish ( store : & Store , kind : ObjectKind , payload : & [ u8 ] , owner : & str ) -> Result < String >"},{"kind":"fn","path":"disk::read_marker","signature":"pub fn read_marker ( store : & Store , snapshot : & str ) -> Result < Option < CommitMarker > >"},{"kind":"fn","path":"disk::release_lock","signature":"pub fn release_lock ( guard : LockGuard ) -> Result < ( ) >"},{"kind":"fn","path":"disk::remove_object","signature":"pub fn remove_object ( store : & Store , name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::take_lock","signature":"pub fn take_lock ( store : & Store , name : & str ) -> Result < LockGuard >"},{"kind":"fn","path":"disk::write_marker","signature":"pub fn write_marker ( store : & Store , marker : & CommitMarker ) -> Result < ( ) >"},{"kind":"struct","path":"error::CompilerError","signature":"pub struct CompilerError"},{"kind":"field","path":"error::CompilerError.code","signature":"pub code : OutcomeCode"},{"kind":"field","path":"error::CompilerError.data","signature":"pub data : Option < Value >"},{"kind":"field","path":"error::CompilerError.detail","signature":"pub detail : String"},{"kind":"enum","path":"error::OutcomeCode","signature":"pub enum OutcomeCode { Success , Usage , StaleInput , BaseOutputChanged , WriteConflict , LockContended , InvalidCandidate , InvalidReceipt , MappingIntegrity , ResourceLimit , UnsupportedVersion , StoreIntegrity , IoError , EditMismatch , ReferenceUnresolvable , PromptInvalid }"},{"kind":"type","path":"error::Result","signature":"pub type Result < T > = std :: result :: Result < T , CompilerError > ;"},{"kind":"fn","path":"error::error","signature":"pub fn error ( code : OutcomeCode , detail : impl Into < String > ) -> CompilerError"},{"kind":"fn","path":"error::error_payload","signature":"pub fn error_payload ( err : & CompilerError , blobs_published : bool , provenance_published : bool ) -> Value"},{"kind":"fn","path":"error::error_with","signature":"pub fn error_with ( code : OutcomeCode , detail : impl Into < String > , data : Value ) -> CompilerError"},{"kind":"fn","path":"error::outcome_exit_code","signature":"pub fn outcome_exit_code ( code : OutcomeCode ) -> i32"},{"kind":"fn","path":"error::outcome_is_retryable","signature":"pub fn outcome_is_retryable ( code : OutcomeCode ) -> bool"},{"kind":"fn","path":"error::outcome_slug","signature":"pub fn outcome_slug ( code : OutcomeCode ) -> & ' static str"},{"kind":"const","path":"evidence::CLAUDE_CLI_JSON","signature":"pub const CLAUDE_CLI_JSON : & str = \" application/vnd.claude-cli+json \" ;"},{"kind":"struct","path":"evidence::EvidenceItem","signature":"pub struct EvidenceItem"},{"kind":"field","path":"evidence::EvidenceItem.body","signature":"pub body : String"},{"kind":"field","path":"evidence::EvidenceItem.media_type","signature":"pub media_type : String"},{"kind":"enum","path":"evidence::EvidenceOutcome","signature":"pub enum EvidenceOutcome { Validated , Unsupported , Invalid , ValidatorError }"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEMS","signature":"pub const MAX_EVIDENCE_ITEMS : usize = 8 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEM_BYTES","signature":"pub const MAX_EVIDENCE_ITEM_BYTES : usize = 64 * 1024 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_TOTAL_BYTES","signature":"pub const MAX_EVIDENCE_TOTAL_BYTES : usize = 256 * 1024 ;"},{"kind":"const","path":"evidence::USAGE_PROXY_JSONL","signature":"pub const USAGE_PROXY_JSONL : & str = \" application/x-napl-usage-proxy-jsonl-v1 \" ;"},{"kind":"fn","path":"evidence::check_evidence_bounds","signature":"pub fn check_evidence_bounds ( items : & [ EvidenceItem ] ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::check_reported_bounds","signature":"pub fn check_reported_bounds ( reported : & Value , limit : usize ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::evidence_report","signature":"pub fn evidence_report ( items : & [ ( EvidenceItem , EvidenceOutcome ) ] ) -> Value"},{"kind":"fn","path":"evidence::evidence_slug","signature":"pub fn evidence_slug ( outcome : EvidenceOutcome ) -> & ' static str"},{"kind":"fn","path":"evidence::parse_evidence","signature":"pub fn parse_evidence ( reported : & Value ) -> Result < Vec < EvidenceItem > >"},{"kind":"fn","path":"evidence::registered_media_types","signature":"pub fn registered_media_types ( ) -> Vec < String >"},{"kind":"fn","path":"evidence::validate_all","signature":"pub fn validate_all ( items : & [ EvidenceItem ] ) -> Vec < ( EvidenceItem , EvidenceOutcome ) >"},{"kind":"fn","path":"evidence::validate_evidence","signature":"pub fn validate_evidence ( item : & EvidenceItem ) -> EvidenceOutcome"},{"kind":"type","path":"graph::Graph","signature":"pub type Graph = BTreeMap < String , SourceModule > ;"},{"kind":"fn","path":"graph::build_graph","signature":"pub fn build_graph ( sources : & [ SourceModule ] , target : & TargetDef ) -> Result < Graph >"},{"kind":"fn","path":"graph::changed_frontier","signature":"pub fn changed_frontier ( graph : & Graph , committed_input_keys : & BTreeMap < String , Option < String > > , input_keys : & BTreeMap < String , String > , only_module : Option < & str > ) -> Result < Vec < String > >"},{"kind":"fn","path":"graph::dependents_of","signature":"pub fn dependents_of ( graph : & Graph , module : & str ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::index_sources","signature":"pub fn index_sources ( sources : & [ SourceModule ] ) -> Result < BTreeMap < String , SourceModule > >"},{"kind":"fn","path":"graph::reachable_from","signature":"pub fn reachable_from ( graph : & Graph , roots : & [ String ] ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::topological_levels","signature":"pub fn topological_levels ( graph : & Graph ) -> Result < BTreeMap < String , usize > >"},{"kind":"enum","path":"lint::Analysis","signature":"pub enum Analysis { Performed , Refused }"},{"kind":"enum","path":"lint::Finding","signature":"pub enum Finding { UnreachableModule { module : String , cascade : Vec < String > } , UnconsumedSurfaceEntry { module : String , path : String } , StaleModule { module : String } , UnusedDependency { module : String , dep : String } }"},{"kind":"const","path":"lint::GRAPH_SCOPE","signature":"pub const GRAPH_SCOPE : & str = \" graph \" ;"},{"kind":"struct","path":"lint::LintInput","signature":"pub struct LintInput"},{"kind":"field","path":"lint::LintInput.committed_input_keys","signature":"pub committed_input_keys : BTreeMap < String , Option < String > >"},{"kind":"field","path":"lint::LintInput.current_input_keys","signature":"pub current_input_keys : BTreeMap < String , String >"},{"kind":"field","path":"lint::LintInput.referenced_names","signature":"pub referenced_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"field","path":"lint::LintInput.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"lint::LintInput.surface_names","signature":"pub surface_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"struct","path":"lint::LintReport","signature":"pub struct LintReport"},{"kind":"field","path":"lint::LintReport.findings","signature":"pub findings : Vec < Finding >"},{"kind":"field","path":"lint::LintReport.unreachable_analysis","signature":"pub unreachable_analysis : Analysis"},{"kind":"const","path":"lint::ROOTS_DECLARATION","signature":"pub const ROOTS_DECLARATION : & str = \" roots: [, ...] in the frontmatter of a prompt in this workspace \" ;"},{"kind":"fn","path":"lint::finding_json","signature":"pub fn finding_json ( finding : & Finding ) -> Value"},{"kind":"fn","path":"lint::is_auto_fixable","signature":"pub fn is_auto_fixable ( finding : & Finding ) -> bool"},{"kind":"fn","path":"lint::lint","signature":"pub fn lint ( graph : & Graph , input : & LintInput ) -> LintReport"},{"kind":"fn","path":"lint::lint_json","signature":"pub fn lint_json ( report : & LintReport ) -> Value"},{"kind":"fn","path":"lint::refusal_json","signature":"pub fn refusal_json ( source_scan : Value ) -> Value"},{"kind":"fn","path":"lsp::serve","signature":"pub fn serve < R : BufRead , W : Write > ( reader : & mut R , writer : & mut W )"},{"kind":"struct","path":"maintenance::FileFact","signature":"pub struct FileFact"},{"kind":"field","path":"maintenance::FileFact.hash","signature":"pub hash : String"},{"kind":"field","path":"maintenance::FileFact.path","signature":"pub path : String"},{"kind":"enum","path":"maintenance::FsckFinding","signature":"pub enum FsckFinding { HashMismatch { object : String , detail : String } , MissingParent { event : String , parent : String } , MissingObject { from : String , missing : String } }"},{"kind":"enum","path":"maintenance::RestorePlan","signature":"pub enum RestorePlan { Apply ( Vec < RestoreStep > ) , Blocked { path : String , candidates : Vec < String > } }"},{"kind":"enum","path":"maintenance::RestoreStep","signature":"pub enum RestoreStep { Move { from : String , to : String } , Write { path : String , hash : String } , Remove { path : String } }"},{"kind":"fn","path":"maintenance::fsck","signature":"pub fn fsck ( source : & dyn ObjectSource ) -> Result < Vec < FsckFinding > >"},{"kind":"fn","path":"maintenance::fsck_json","signature":"pub fn fsck_json ( findings : & [ FsckFinding ] ) -> Value"},{"kind":"fn","path":"maintenance::gc_json","signature":"pub fn gc_json ( swept : & [ String ] , kept : usize ) -> Value"},{"kind":"fn","path":"maintenance::plan_restore","signature":"pub fn plan_restore ( expected : & [ FileFact ] , observed : & [ FileFact ] ) -> RestorePlan"},{"kind":"fn","path":"maintenance::plan_sweep","signature":"pub fn plan_sweep ( all : & BTreeSet < String > , reachable : & BTreeSet < String > ) -> Vec < String >"},{"kind":"fn","path":"maintenance::reachable_from_head","signature":"pub fn reachable_from_head ( source : & dyn ObjectSource , head : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"maintenance::restore_json","signature":"pub fn restore_json ( plan : & RestorePlan ) -> Value"},{"kind":"fn","path":"mv::relative_dir","signature":"pub fn relative_dir ( from : & str , to : & str ) -> String"},{"kind":"fn","path":"mv::relocate_files","signature":"pub fn relocate_files ( files : & BTreeMap < String , Vec < u8 > > , old_root : & str , dest : & str ) -> Result < BTreeMap < String , Vec < u8 > > >"},{"kind":"fn","path":"mv::relocate_module","signature":"pub fn relocate_module ( workspace : & Path , target_name : & str , module : & str , dest : & str ) -> CommitReport"},{"kind":"struct","path":"objects::AttributionState","signature":"pub struct AttributionState"},{"kind":"field","path":"objects::AttributionState.prompt_hash","signature":"pub prompt_hash : String"},{"kind":"field","path":"objects::AttributionState.record","signature":"pub record : String"},{"kind":"field","path":"objects::AttributionState.stale","signature":"pub stale : bool"},{"kind":"struct","path":"objects::Footprint","signature":"pub struct Footprint"},{"kind":"field","path":"objects::Footprint.derived_projections","signature":"pub derived_projections : Vec < String >"},{"kind":"field","path":"objects::Footprint.install_paths","signature":"pub install_paths : Vec < String >"},{"kind":"field","path":"objects::Footprint.read_set","signature":"pub read_set : Vec < String >"},{"kind":"field","path":"objects::Footprint.write_set","signature":"pub write_set : Vec < String >"},{"kind":"struct","path":"objects::ModuleState","signature":"pub struct ModuleState"},{"kind":"field","path":"objects::ModuleState.attribution","signature":"pub attribution : Option < AttributionState >"},{"kind":"field","path":"objects::ModuleState.input_key","signature":"pub input_key : Option < String >"},{"kind":"field","path":"objects::ModuleState.module","signature":"pub module : String"},{"kind":"field","path":"objects::ModuleState.output_tree","signature":"pub output_tree : String"},{"kind":"field","path":"objects::ModuleState.slice_hash","signature":"pub slice_hash : Option < String >"},{"kind":"field","path":"objects::ModuleState.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"objects::ModuleState.target","signature":"pub target : String"},{"kind":"struct","path":"objects::Transition","signature":"pub struct Transition"},{"kind":"field","path":"objects::Transition.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"objects::Transition.footprint","signature":"pub footprint : Footprint"},{"kind":"field","path":"objects::Transition.input_key","signature":"pub input_key : String"},{"kind":"field","path":"objects::Transition.module","signature":"pub module : String"},{"kind":"field","path":"objects::Transition.new_state","signature":"pub new_state : String"},{"kind":"field","path":"objects::Transition.receipt","signature":"pub receipt : String"},{"kind":"field","path":"objects::Transition.surface","signature":"pub surface : String"},{"kind":"field","path":"objects::Transition.target","signature":"pub target : String"},{"kind":"fn","path":"objects::empty_module_state","signature":"pub fn empty_module_state ( module : & str , target : & str , empty_tree : & str ) -> ModuleState"},{"kind":"fn","path":"objects::event_hash","signature":"pub fn event_hash ( content : & str , parents : & [ String ] ) -> String"},{"kind":"fn","path":"objects::event_json","signature":"pub fn event_json ( content : & str , parents : & [ String ] ) -> Value"},{"kind":"fn","path":"objects::event_transition","signature":"pub fn event_transition ( source : & dyn ObjectSource , event : & str ) -> Result < String >"},{"kind":"fn","path":"objects::module_state_hash","signature":"pub fn module_state_hash ( state : & ModuleState ) -> String"},{"kind":"fn","path":"objects::module_state_json","signature":"pub fn module_state_json ( state : & ModuleState ) -> Value"},{"kind":"fn","path":"objects::ownership_write_key","signature":"pub fn ownership_write_key ( root : & str ) -> String"},{"kind":"fn","path":"objects::read_module_state","signature":"pub fn read_module_state ( source : & dyn ObjectSource , hash : & str ) -> Result < ModuleState >"},{"kind":"fn","path":"objects::snapshot_event","signature":"pub fn snapshot_event ( source : & dyn ObjectSource , snapshot : & str ) -> Result < String >"},{"kind":"fn","path":"objects::snapshot_hash","signature":"pub fn snapshot_hash ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> String"},{"kind":"fn","path":"objects::snapshot_json","signature":"pub fn snapshot_json ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> Value"},{"kind":"fn","path":"objects::snapshot_members","signature":"pub fn snapshot_members ( states : & BTreeMap < ( String , String ) , String > , target : & str ) -> Vec < String >"},{"kind":"fn","path":"objects::snapshot_states","signature":"pub fn snapshot_states ( source : & dyn ObjectSource , snapshot : & str ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"objects::state_write_key","signature":"pub fn state_write_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"objects::transition_hash","signature":"pub fn transition_hash ( transition : & Transition ) -> String"},{"kind":"fn","path":"objects::transition_json","signature":"pub fn transition_json ( transition : & Transition ) -> Value"},{"kind":"fn","path":"objects::transition_write_set","signature":"pub fn transition_write_set ( source : & dyn ObjectSource , transition : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"objects::walk_events","signature":"pub fn walk_events ( source : & dyn ObjectSource , event : & str , until : Option < & str > ) -> Result < Vec < String > >"},{"kind":"const","path":"paths::MAX_PATH_LEN","signature":"pub const MAX_PATH_LEN : usize = 1024 ;"},{"kind":"const","path":"paths::MAX_SEGMENT_LEN","signature":"pub const MAX_SEGMENT_LEN : usize = 128 ;"},{"kind":"fn","path":"paths::check_canonical_path","signature":"pub fn check_canonical_path ( path : & str , code : OutcomeCode ) -> Result < ( ) >"},{"kind":"fn","path":"paths::decode_name","signature":"pub fn decode_name ( segment : & str ) -> Option < String >"},{"kind":"fn","path":"paths::encode_name","signature":"pub fn encode_name ( name : & str ) -> String"},{"kind":"fn","path":"paths::is_under","signature":"pub fn is_under ( path : & str , prefix : & str ) -> bool"},{"kind":"fn","path":"paths::join_root","signature":"pub fn join_root ( root : & str , rest : & str ) -> String"},{"kind":"fn","path":"paths::normalize_path_key","signature":"pub fn normalize_path_key ( path : & str ) -> String"},{"kind":"const","path":"porcelain::PROJECTION_OWNER","signature":"pub const PROJECTION_OWNER : & str = \" no module: this path is recomputed by commit from the crate's committed module file sets and is never user-supplied; a candidate that carries it is rejected as invalid-candidate \" ;"},{"kind":"const","path":"porcelain::PROJECTION_OWNERSHIP","signature":"pub const PROJECTION_OWNERSHIP : & str = \" tool-derived \" ;"},{"kind":"fn","path":"porcelain::add_note","signature":"pub fn add_note ( workspace : & Path , transition : & str , note : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::blame_path","signature":"pub fn blame_path ( workspace : & Path , path : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::cat_file","signature":"pub fn cat_file ( workspace : & Path , hash : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"porcelain::causal_order","signature":"pub fn causal_order ( store : & Store , head : & str ) -> Result < Vec < String > >"},{"kind":"fn","path":"porcelain::committed_attribution_yaml","signature":"pub fn committed_attribution_yaml ( store : & Store , target : & str , module : & str ) -> Result < Option < Vec < u8 > > >"},{"kind":"fn","path":"porcelain::committed_manifest","signature":"pub fn committed_manifest ( store : & Store , target : & str ) -> Result < BTreeMap < String , String > >"},{"kind":"fn","path":"porcelain::committed_state","signature":"pub fn committed_state ( store : & Store , target : & str , module : & str ) -> Result < Option < ModuleState > >"},{"kind":"fn","path":"porcelain::cost","signature":"pub fn cost ( workspace : & Path , names : & [ String ] , files : & [ String ] , sessions : & [ String ] ) -> Result < Value >"},{"kind":"fn","path":"porcelain::diff","signature":"pub fn diff ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::first_output","signature":"pub fn first_output ( target : & TargetDef , root : & str , module : & str ) -> Value"},{"kind":"fn","path":"porcelain::fsck_store","signature":"pub fn fsck_store ( workspace : & Path ) -> Result < ( bool , Value ) >"},{"kind":"fn","path":"porcelain::gc","signature":"pub fn gc ( workspace : & Path , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::has_committed_head","signature":"pub fn has_committed_head ( workspace : & Path ) -> Result < bool >"},{"kind":"fn","path":"porcelain::lint_workspace","signature":"pub fn lint_workspace ( workspace : & Path , target : & str ) -> Result < ( Graph , LintReport , Value ) >"},{"kind":"fn","path":"porcelain::log","signature":"pub fn log ( workspace : & Path , module : Option < & str > ) -> Result < Value >"},{"kind":"fn","path":"porcelain::module_plan","signature":"pub fn module_plan ( workspace : & Path , target_name : & str , module : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::projection_labels","signature":"pub fn projection_labels ( target : & TargetDef , root : & str ) -> Vec < Value >"},{"kind":"fn","path":"porcelain::restore","signature":"pub fn restore ( workspace : & Path , target : & str , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::show","signature":"pub fn show ( workspace : & Path , hash : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::source_syntax_scan","signature":"pub fn source_syntax_scan ( workspace : & Path , target_name : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::status","signature":"pub fn status ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::surface_of","signature":"pub fn surface_of ( workspace : & Path , target : & str , module : & str ) -> Result < Value >"},{"kind":"const","path":"receipt::MAX_REPORTED_BYTES","signature":"pub const MAX_REPORTED_BYTES : usize = 512 * 1024 ;"},{"kind":"const","path":"receipt::RECEIPT_VERSION","signature":"pub const RECEIPT_VERSION : u32 = 1 ;"},{"kind":"struct","path":"receipt::Receipt","signature":"pub struct Receipt"},{"kind":"field","path":"receipt::Receipt.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"receipt::Receipt.base_store_snapshot","signature":"pub base_store_snapshot : Option < String >"},{"kind":"field","path":"receipt::Receipt.base_workspace_hash","signature":"pub base_workspace_hash : String"},{"kind":"field","path":"receipt::Receipt.candidate_tree_hash","signature":"pub candidate_tree_hash : String"},{"kind":"field","path":"receipt::Receipt.evidence","signature":"pub evidence : Vec < EvidenceItem >"},{"kind":"field","path":"receipt::Receipt.input_key","signature":"pub input_key : String"},{"kind":"field","path":"receipt::Receipt.module","signature":"pub module : String"},{"kind":"field","path":"receipt::Receipt.output_manifest","signature":"pub output_manifest : BTreeMap < String , String >"},{"kind":"field","path":"receipt::Receipt.receipt_version","signature":"pub receipt_version : u32"},{"kind":"field","path":"receipt::Receipt.reported","signature":"pub reported : Value"},{"kind":"field","path":"receipt::Receipt.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"receipt::Receipt.target","signature":"pub target : String"},{"kind":"const","path":"receipt::UNAVAILABLE","signature":"pub const UNAVAILABLE : & str = \" unavailable \" ;"},{"kind":"fn","path":"receipt::normalize_reported","signature":"pub fn normalize_reported ( value : & Value ) -> Value"},{"kind":"fn","path":"receipt::parse_receipt","signature":"pub fn parse_receipt ( bytes : & [ u8 ] ) -> Result < Receipt >"},{"kind":"fn","path":"receipt::receipt_hash","signature":"pub fn receipt_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_json","signature":"pub fn receipt_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::receipt_semantic_hash","signature":"pub fn receipt_semantic_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_semantic_json","signature":"pub fn receipt_semantic_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::reported_usage","signature":"pub fn reported_usage ( receipt : & Receipt ) -> Value"},{"kind":"struct","path":"slice::CompileSlice","signature":"pub struct CompileSlice"},{"kind":"field","path":"slice::CompileSlice.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"slice::CompileSlice.body","signature":"pub body : Value"},{"kind":"field","path":"slice::CompileSlice.input_key","signature":"pub input_key : String"},{"kind":"field","path":"slice::CompileSlice.module","signature":"pub module : String"},{"kind":"field","path":"slice::CompileSlice.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"slice::CompileSlice.target","signature":"pub target : String"},{"kind":"field","path":"slice::CompileSlice.wave","signature":"pub wave : usize"},{"kind":"struct","path":"slice::DepSurface","signature":"pub struct DepSurface"},{"kind":"field","path":"slice::DepSurface.items","signature":"pub items : Vec < SurfaceItem >"},{"kind":"field","path":"slice::DepSurface.module","signature":"pub module : String"},{"kind":"field","path":"slice::DepSurface.surface_hash","signature":"pub surface_hash : String"},{"kind":"fn","path":"slice::build_slice","signature":"pub fn build_slice ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] , base : & ModuleState , base_state_key : & str , base_files : & [ ( String , String ) ] , wave : usize ) -> CompileSlice"},{"kind":"fn","path":"slice::closure_manifest","signature":"pub fn closure_manifest ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> Value"},{"kind":"fn","path":"slice::input_key","signature":"pub fn input_key ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> String"},{"kind":"fn","path":"slice::receipt_requirements","signature":"pub fn receipt_requirements ( ) -> Value"},{"kind":"fn","path":"slice::recompute_slice_hash","signature":"pub fn recompute_slice_hash ( body : & Value ) -> String"},{"kind":"const","path":"source::MAX_SOURCE_BYTES","signature":"pub const MAX_SOURCE_BYTES : usize = 4 * 1024 * 1024 ;"},{"kind":"const","path":"source::MINIMAL_PROMPT","signature":"pub const MINIMAL_PROMPT : & str = \" --- \\n module: example \\n deps: [] \\n roots: [example] \\n targets: [rust] \\n --- \\n One sentence of specification prose. \\n \" ;"},{"kind":"const","path":"source::SOURCE_EXTENSION","signature":"pub const SOURCE_EXTENSION : & str = \" napl \" ;"},{"kind":"struct","path":"source::SourceModule","signature":"pub struct SourceModule"},{"kind":"field","path":"source::SourceModule.deps","signature":"pub deps : Vec < String >"},{"kind":"field","path":"source::SourceModule.directive_cases","signature":"pub directive_cases : Vec < crate :: directive :: DirectiveCase >"},{"kind":"field","path":"source::SourceModule.frontmatter_cases","signature":"pub frontmatter_cases : Vec < serde_json :: Value >"},{"kind":"field","path":"source::SourceModule.module","signature":"pub module : String"},{"kind":"field","path":"source::SourceModule.normalized_body","signature":"pub normalized_body : String"},{"kind":"field","path":"source::SourceModule.normalized_source_hash","signature":"pub normalized_source_hash : String"},{"kind":"field","path":"source::SourceModule.path","signature":"pub path : String"},{"kind":"field","path":"source::SourceModule.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"source::SourceModule.targets","signature":"pub targets : Vec < String >"},{"kind":"field","path":"source::SourceModule.tests","signature":"pub tests : Vec < String >"},{"kind":"fn","path":"source::check_module_name","signature":"pub fn check_module_name ( name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"source::normalize_body","signature":"pub fn normalize_body ( raw : & str ) -> String"},{"kind":"fn","path":"source::parse_source","signature":"pub fn parse_source ( path : & str , text : & str ) -> Result < SourceModule >"},{"kind":"fn","path":"source::rewrite_deps","signature":"pub fn rewrite_deps ( text : & str , deps : & [ String ] ) -> Result < String >"},{"kind":"enum","path":"staging::AddSource","signature":"pub enum AddSource { CandidateDir ( PathBuf ) , WorkingTree , Overlay ( Vec < String > ) }"},{"kind":"const","path":"staging::STAGING_DIR","signature":"pub const STAGING_DIR : & str = \" .napl/staging \" ;"},{"kind":"struct","path":"staging::StagedRecord","signature":"pub struct StagedRecord"},{"kind":"field","path":"staging::StagedRecord.module","signature":"pub module : String"},{"kind":"field","path":"staging::StagedRecord.path","signature":"pub path : PathBuf"},{"kind":"field","path":"staging::StagedRecord.relative","signature":"pub relative : String"},{"kind":"field","path":"staging::StagedRecord.target","signature":"pub target : String"},{"kind":"fn","path":"staging::add","signature":"pub fn add ( workspace : & Path , target_name : & str , module : & str , source : & AddSource , attribution_path : Option < & Path > , usage_path : Option < & Path > , no_verify : bool , check : bool ) -> Result < Value >"},{"kind":"fn","path":"staging::add_working_tree_paths","signature":"pub fn add_working_tree_paths ( workspace : & Path , cwd : & Path , target_name : & str , given : & [ String ] , explicit_module : Option < & str > , attribution_path : Option < & Path > , usage_path : Option < & Path > , no_verify : bool , check : bool ) -> Result < Value >"},{"kind":"fn","path":"staging::commit_all","signature":"pub fn commit_all ( workspace : & Path ) -> Result < ( Value , i32 ) >"},{"kind":"fn","path":"staging::commit_staged","signature":"pub fn commit_staged ( workspace : & Path , record : & Path ) -> CommitReport"},{"kind":"fn","path":"staging::mode_slug","signature":"pub fn mode_slug ( mode : AttributionMode ) -> & ' static str"},{"kind":"fn","path":"staging::record_dir","signature":"pub fn record_dir ( workspace : & Path , module : & str , tree_hash : & str ) -> PathBuf"},{"kind":"fn","path":"staging::relative_record","signature":"pub fn relative_record ( workspace : & Path , path : & Path ) -> String"},{"kind":"fn","path":"staging::staged_payload","signature":"pub fn staged_payload ( target_name : & str , records : Vec < Value > ) -> Value"},{"kind":"fn","path":"staging::staged_records","signature":"pub fn staged_records ( workspace : & Path ) -> Result < Vec < StagedRecord > >"},{"kind":"fn","path":"staging::unclaimed_working_paths","signature":"pub fn unclaimed_working_paths ( workspace : & Path , store : & Store , target : & TargetDef , module : & str , sources : & [ SourceModule ] ) -> Result < Vec < String > >"},{"kind":"fn","path":"staging::write_record","signature":"pub fn write_record ( record_dir : & Path , module : & str , target_name : & str , tree_hash : & str , attribution_present : bool , mode : AttributionMode ) -> Result < ( ) >"},{"kind":"trait","path":"store_view::ObjectSource","signature":"pub trait ObjectSource { fn envelope ( & self , name : & str ) -> Result < Option < Vec < u8 > > > ; fn head ( & self ) -> Result < Option < String > > ; fn names ( & self ) -> Result < Vec < String > > ; }"},{"kind":"fn","path":"store_view::flatten_tree","signature":"pub fn flatten_tree ( source : & dyn ObjectSource , root : & str ) -> Result < BTreeMap < String , ( String , u32 ) > >"},{"kind":"fn","path":"store_view::get_blob","signature":"pub fn get_blob ( source : & dyn ObjectSource , name : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"store_view::get_json","signature":"pub fn get_json ( source : & dyn ObjectSource , name : & str , expected : ObjectKind ) -> Result < Value >"},{"kind":"fn","path":"store_view::get_object","signature":"pub fn get_object ( source : & dyn ObjectSource , name : & str ) -> Result < ( ObjectKind , Vec < u8 > ) >"},{"kind":"fn","path":"store_view::get_tree","signature":"pub fn get_tree ( source : & dyn ObjectSource , name : & str ) -> Result < Tree >"},{"kind":"fn","path":"store_view::has_object","signature":"pub fn has_object ( source : & dyn ObjectSource , name : & str ) -> bool"},{"kind":"fn","path":"store_view::load_tree_objects","signature":"pub fn load_tree_objects ( source : & dyn ObjectSource , root : & str , out : & mut BTreeMap < String , Tree > ) -> Result < ( ) >"},{"kind":"struct","path":"surface::ParseFailure","signature":"pub struct ParseFailure"},{"kind":"field","path":"surface::ParseFailure.column","signature":"pub column : u32"},{"kind":"field","path":"surface::ParseFailure.line","signature":"pub line : u32"},{"kind":"field","path":"surface::ParseFailure.path","signature":"pub path : String"},{"kind":"field","path":"surface::ParseFailure.region","signature":"pub region : String"},{"kind":"field","path":"surface::ParseFailure.region_end","signature":"pub region_end : u32"},{"kind":"field","path":"surface::ParseFailure.region_start","signature":"pub region_start : u32"},{"kind":"method","path":"surface::ParseFailure::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"surface::ParseFailure::detail","signature":"pub fn detail ( & self ) -> String"},{"kind":"const","path":"surface::SOURCE_SYNTAX_SCOPE","signature":"pub const SOURCE_SYNTAX_SCOPE : & str = \" source syntax \" ;"},{"kind":"struct","path":"surface::SurfaceItem","signature":"pub struct SurfaceItem"},{"kind":"field","path":"surface::SurfaceItem.kind","signature":"pub kind : String"},{"kind":"field","path":"surface::SurfaceItem.path","signature":"pub path : String"},{"kind":"field","path":"surface::SurfaceItem.signature","signature":"pub signature : String"},{"kind":"fn","path":"surface::empty_surface_hash","signature":"pub fn empty_surface_hash ( module : & str , target : & str ) -> String"},{"kind":"fn","path":"surface::extract_surface","signature":"pub fn extract_surface ( target : & TargetDef , root : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"surface::is_target_source","signature":"pub fn is_target_source ( target : & TargetDef , root : & str , path : & str ) -> bool"},{"kind":"fn","path":"surface::parse_check","signature":"pub fn parse_check ( target : & TargetDef , path : & str , bytes : & [ u8 ] ) -> Result < Option < ParseFailure > >"},{"kind":"fn","path":"surface::parse_failure_error","signature":"pub fn parse_failure_error ( code : OutcomeCode , failure : & ParseFailure ) -> crate :: error :: CompilerError"},{"kind":"fn","path":"surface::parse_gate","signature":"pub fn parse_gate ( target : & TargetDef , root : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Result < ( ) >"},{"kind":"fn","path":"surface::surface_hash","signature":"pub fn surface_hash ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> String"},{"kind":"fn","path":"surface::surface_items_from_json","signature":"pub fn surface_items_from_json ( value : & Value ) -> Vec < SurfaceItem >"},{"kind":"fn","path":"surface::surface_json","signature":"pub fn surface_json ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> Value"},{"kind":"struct","path":"target::TargetDef","signature":"pub struct TargetDef"},{"kind":"field","path":"target::TargetDef.entry_point_version","signature":"pub entry_point_version : u32"},{"kind":"field","path":"target::TargetDef.manifest_file","signature":"pub manifest_file : String"},{"kind":"field","path":"target::TargetDef.name","signature":"pub name : String"},{"kind":"field","path":"target::TargetDef.source_dir","signature":"pub source_dir : String"},{"kind":"field","path":"target::TargetDef.source_extension","signature":"pub source_extension : String"},{"kind":"fn","path":"target::builtin_target","signature":"pub fn builtin_target ( name : & str ) -> Result < TargetDef >"},{"kind":"fn","path":"target::builtin_target_names","signature":"pub fn builtin_target_names ( ) -> Vec < String >"},{"kind":"fn","path":"target::derive_lib_projection","signature":"pub fn derive_lib_projection ( target : & TargetDef , root : & str , file_paths : & [ String ] , command_providers : & [ String ] ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"target::is_manifest_path","signature":"pub fn is_manifest_path ( target : & TargetDef , path : & str ) -> bool"},{"kind":"fn","path":"target::is_owned_path","signature":"pub fn is_owned_path ( target : & TargetDef , root : & str , path : & str ) -> bool"},{"kind":"fn","path":"target::owned_globs","signature":"pub fn owned_globs ( _target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::placement_facts","signature":"pub fn placement_facts ( target : & TargetDef , root : & str ) -> Value"},{"kind":"fn","path":"target::placement_root","signature":"pub fn placement_root ( source : & SourceModule ) -> String"},{"kind":"fn","path":"target::projection_owned_paths","signature":"pub fn projection_owned_paths ( target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::surface_grammar","signature":"pub fn surface_grammar ( target : & TargetDef ) -> Value"},{"kind":"fn","path":"target::target_def_hash","signature":"pub fn target_def_hash ( target : & TargetDef ) -> String"},{"kind":"fn","path":"target::target_def_json","signature":"pub fn target_def_json ( target : & TargetDef ) -> Value"},{"kind":"enum","path":"textdiff::Edit","signature":"pub enum Edit { Keep ( usize , usize ) , Delete ( usize ) , Insert ( usize ) }"},{"kind":"fn","path":"textdiff::diff_lines","signature":"pub fn diff_lines ( old : & [ String ] , new : & [ String ] ) -> Vec < Edit >"},{"kind":"fn","path":"textdiff::lines","signature":"pub fn lines ( text : & str ) -> Vec < String >"},{"kind":"fn","path":"textdiff::render_drift","signature":"pub fn render_drift ( committed : & BTreeMap < String , String > , working : & BTreeMap < String , String > ) -> String"},{"kind":"fn","path":"textdiff::render_patch","signature":"pub fn render_patch ( path : & str , old : & str , new : & str ) -> String"},{"kind":"struct","path":"transaction::Accepted","signature":"pub struct Accepted"},{"kind":"field","path":"transaction::Accepted.event","signature":"pub event : String"},{"kind":"field","path":"transaction::Accepted.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::Accepted.projection_bytes","signature":"pub projection_bytes : Vec < u8 >"},{"kind":"field","path":"transaction::Accepted.projection_path","signature":"pub projection_path : String"},{"kind":"field","path":"transaction::Accepted.rebased","signature":"pub rebased : bool"},{"kind":"field","path":"transaction::Accepted.snapshot","signature":"pub snapshot : String"},{"kind":"field","path":"transaction::Accepted.steps","signature":"pub steps : Vec < InstallStep >"},{"kind":"struct","path":"transaction::BuiltAttribution","signature":"pub struct BuiltAttribution"},{"kind":"field","path":"transaction::BuiltAttribution.state","signature":"pub state : AttributionState"},{"kind":"field","path":"transaction::BuiltAttribution.view_path","signature":"pub view_path : String"},{"kind":"field","path":"transaction::BuiltAttribution.yaml","signature":"pub yaml : Vec < u8 >"},{"kind":"struct","path":"transaction::DerivedCommit","signature":"pub struct DerivedCommit"},{"kind":"field","path":"transaction::DerivedCommit.attribution_install","signature":"pub attribution_install : Option < ( String , String ) >"},{"kind":"field","path":"transaction::DerivedCommit.attribution_status","signature":"pub attribution_status : String"},{"kind":"field","path":"transaction::DerivedCommit.evidence","signature":"pub evidence : Value"},{"kind":"field","path":"transaction::DerivedCommit.new_state_key","signature":"pub new_state_key : String"},{"kind":"field","path":"transaction::DerivedCommit.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::DerivedCommit.receipt_object","signature":"pub receipt_object : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_items","signature":"pub surface_items : Vec < SurfaceItem >"},{"kind":"field","path":"transaction::DerivedCommit.transition","signature":"pub transition : Transition"},{"kind":"field","path":"transaction::DerivedCommit.transition_id","signature":"pub transition_id : String"},{"kind":"struct","path":"transaction::PendingObject","signature":"pub struct PendingObject"},{"kind":"field","path":"transaction::PendingObject.kind","signature":"pub kind : ObjectKind"},{"kind":"field","path":"transaction::PendingObject.name","signature":"pub name : String"},{"kind":"field","path":"transaction::PendingObject.payload","signature":"pub payload : Vec < u8 >"},{"kind":"fn","path":"transaction::accept","signature":"pub fn accept ( source : & dyn ObjectSource , target : & TargetDef , root : & str , candidate : & Candidate , derived : & DerivedCommit , head : Option < & str > , base_snapshot : Option < & str > ) -> Result < Accepted >"},{"kind":"fn","path":"transaction::check_candidate_shape","signature":"pub fn check_candidate_shape ( target : & TargetDef , root : & str , candidate : & Candidate ) -> Result < ( ) >"},{"kind":"fn","path":"transaction::derive_commit","signature":"pub fn derive_commit ( target : & TargetDef , root : & str , receipt : & Receipt , candidate : & Candidate , built : Option < & BuiltAttribution > ) -> Result < DerivedCommit >"},{"kind":"fn","path":"transaction::intervening_write_set","signature":"pub fn intervening_write_set ( source : & dyn ObjectSource , head : Option < & str > , base : Option < & str > ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"transaction::success_payload","signature":"pub fn success_payload ( derived : & DerivedCommit , accepted : & Accepted ) -> Value"},{"kind":"fn","path":"transaction::verify_receipt","signature":"pub fn verify_receipt ( source : & dyn ObjectSource , sources : & [ SourceModule ] , receipt : & Receipt , candidate : & Candidate ) -> Result < CompileSlice >"},{"kind":"struct","path":"transport::ClaimRefusal","signature":"pub struct ClaimRefusal"},{"kind":"field","path":"transport::ClaimRefusal.code","signature":"pub code : Option < EndpointVerdict >"},{"kind":"field","path":"transport::ClaimRefusal.file","signature":"pub file : Option < String >"},{"kind":"field","path":"transport::ClaimRefusal.index","signature":"pub index : usize"},{"kind":"field","path":"transport::ClaimRefusal.note","signature":"pub note : String"},{"kind":"field","path":"transport::ClaimRefusal.prompt","signature":"pub prompt : EndpointVerdict"},{"kind":"method","path":"transport::ClaimRefusal::affirmation_needed","signature":"pub fn affirmation_needed ( & self ) -> Vec < & ' static str >"},{"kind":"method","path":"transport::ClaimRefusal::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"transport::ClaimRefusal::prompt_lines","signature":"pub fn prompt_lines ( & self ) -> Range"},{"kind":"method","path":"transport::ClaimRefusal::side","signature":"pub fn side ( & self ) -> & ' static str"},{"kind":"struct","path":"transport::EndpointVerdict","signature":"pub struct EndpointVerdict"},{"kind":"field","path":"transport::EndpointVerdict.detail","signature":"pub detail : Option < String >"},{"kind":"field","path":"transport::EndpointVerdict.observed_line_count","signature":"pub observed_line_count : u32"},{"kind":"field","path":"transport::EndpointVerdict.observed_surviving","signature":"pub observed_surviving : Option < Range >"},{"kind":"field","path":"transport::EndpointVerdict.path","signature":"pub path : Option < String >"},{"kind":"field","path":"transport::EndpointVerdict.reason","signature":"pub reason : Option < & ' static str >"},{"kind":"field","path":"transport::EndpointVerdict.recorded","signature":"pub recorded : Option < Range >"},{"kind":"field","path":"transport::EndpointVerdict.transported","signature":"pub transported : Option < Range >"},{"kind":"field","path":"transport::EndpointVerdict.whole","signature":"pub whole : bool"},{"kind":"method","path":"transport::EndpointVerdict::refused","signature":"pub fn refused ( & self ) -> bool"},{"kind":"struct","path":"transport::LineMap","signature":"pub struct LineMap"},{"kind":"method","path":"transport::LineMap::anchor_before","signature":"pub fn anchor_before ( & self , old_line : u32 ) -> Option < u32 >"},{"kind":"method","path":"transport::LineMap::moved_line","signature":"pub fn moved_line ( & self , old_line : u32 ) -> Option < u32 >"},{"kind":"method","path":"transport::LineMap::new_line_count","signature":"pub fn new_line_count ( & self ) -> u32"},{"kind":"method","path":"transport::LineMap::old_line_count","signature":"pub fn old_line_count ( & self ) -> u32"},{"kind":"struct","path":"transport::Range","signature":"pub struct Range"},{"kind":"field","path":"transport::Range.end","signature":"pub end : u32"},{"kind":"field","path":"transport::Range.start","signature":"pub start : u32"},{"kind":"method","path":"transport::Range::new","signature":"pub fn new ( start : u32 , end : u32 ) -> Range"},{"kind":"enum","path":"transport::Refusal","signature":"pub enum Refusal { OutsideOldContent , RegionRewritten , RangeSplit }"},{"kind":"method","path":"transport::Refusal::as_str","signature":"pub fn as_str ( & self ) -> & ' static str"},{"kind":"method","path":"transport::Refusal::detail","signature":"pub fn detail ( & self ) -> & ' static str"},{"kind":"struct","path":"transport::TransportInputs","signature":"pub struct TransportInputs < ' a >"},{"kind":"field","path":"transport::TransportInputs.committed_body","signature":"pub committed_body : & ' a str"},{"kind":"field","path":"transport::TransportInputs.committed_files","signature":"pub committed_files : & ' a BTreeMap < String , String >"},{"kind":"field","path":"transport::TransportInputs.pending_body","signature":"pub pending_body : & ' a str"},{"kind":"field","path":"transport::TransportInputs.staged_files","signature":"pub staged_files : & ' a BTreeMap < String , String >"},{"kind":"struct","path":"transport::TransportOutcome","signature":"pub struct TransportOutcome"},{"kind":"field","path":"transport::TransportOutcome.record","signature":"pub record : Attribution"},{"kind":"field","path":"transport::TransportOutcome.refusals","signature":"pub refusals : Vec < ClaimRefusal >"},{"kind":"method","path":"transport::TransportOutcome::complete","signature":"pub fn complete ( & self ) -> bool"},{"kind":"enum","path":"transport::Transported","signature":"pub enum Transported { Proven ( Range ) , Unproven { refusal : Refusal , surviving : Option < Range > , anchor : Option < u32 > } }"},{"kind":"method","path":"transport::Transported::proven","signature":"pub fn proven ( & self ) -> Option < Range >"},{"kind":"fn","path":"transport::line_map","signature":"pub fn line_map ( old : & str , new : & str ) -> LineMap"},{"kind":"fn","path":"transport::refusal_detail","signature":"pub fn refusal_detail ( module : & str , refusals : & [ ClaimRefusal ] ) -> String"},{"kind":"fn","path":"transport::refusal_payload","signature":"pub fn refusal_payload ( module : & str , refusals : & [ ClaimRefusal ] ) -> Value"},{"kind":"fn","path":"transport::transport_attribution","signature":"pub fn transport_attribution ( record : & Attribution , inputs : & TransportInputs ) -> TransportOutcome"},{"kind":"fn","path":"transport::transport_range","signature":"pub fn transport_range ( map : & LineMap , range : Range ) -> Transported"},{"kind":"fn","path":"trees::empty_tree_hash","signature":"pub fn empty_tree_hash ( ) -> String"},{"kind":"fn","path":"trees::tree_of","signature":"pub fn tree_of ( manifest : & BTreeMap < String , String > ) -> Result < BuiltTree >"},{"kind":"const","path":"version::CONTRACT_VERSION","signature":"pub const CONTRACT_VERSION : u32 = 4 ;"},{"kind":"fn","path":"workspace::apply_step","signature":"pub fn apply_step ( root : & Path , step : & InstallStep , payload : & dyn Fn ( & str ) -> Result < Vec < u8 > > ) -> Result < ( ) >"},{"kind":"fn","path":"workspace::discover_sources","signature":"pub fn discover_sources ( root : & Path ) -> Result < Vec < SourceModule > >"},{"kind":"fn","path":"workspace::find_workspace_root","signature":"pub fn find_workspace_root ( start : & Path ) -> Option < PathBuf >"},{"kind":"fn","path":"workspace::read_working_text","signature":"pub fn read_working_text ( root : & Path , paths : & [ String ] ) -> BTreeMap < String , String >"},{"kind":"fn","path":"workspace::require_workspace_root","signature":"pub fn require_workspace_root ( start : & Path ) -> Result < PathBuf >"},{"kind":"fn","path":"workspace::scan_candidate","signature":"pub fn scan_candidate ( dir : & Path ) -> Result < Vec < ScannedEntry > >"},{"kind":"fn","path":"workspace::step_path","signature":"pub fn step_path ( step : & InstallStep ) -> & str"},{"kind":"fn","path":"workspace::write_file","signature":"pub fn write_file ( path : & Path , bytes : & [ u8 ] ) -> Result < ( ) >"}],"module":"napl","target":"rust"} -napl-store module-state v1 559 -{"attribution":{"promptHash":"6b98457c4a34f4460528fd9867a16bd87069eb66875a351fb23205533e511bbe","record":"0f3756f4e4507027e1ed76f9e0ef890738b3fe60b53eae65468551943cb63929","stale":false},"contractVersion":4,"inputKey":"454c2da32be1feb04e7544b8831278a54e5fca8dc4056a85d9c44e27e785c7c4","module":"agent","outputTree":"c8cf73ffb7c118e5ad378bc58783365f8b56c59299ef2359aebad52205080d68","sliceHash":"b0c395c8c32a6c85d1a858896b3fcdb8ef465c263c88acb4c2b301ba4d22dae3","surfaceHash":"ad1581ae209ba3feb19322408bfff544be7a29bc6a7db44c672b705f1afcd120","target":"rust"} -napl-store snapshot v1 895 -{"contractVersion":4,"event":"b3d7ae339e60525bbc912602c4df1aa26fda7fba4d5e9b0f6e92446287ded032","states":[{"module":"agent","state":"a16fdf3bc7ab489d5610d2f9cf9917b2501ce06cc5b8dd236cc5464f6c93d7f1","target":"rust"},{"module":"commands","state":"10df13f5214ccfa57f086032061ff35bf445a9b2da972f3d7d99016eb2d83199","target":"rust"},{"module":"git-store","state":"33a141441be88c6825e85a1495467d599a016bae61a678358f9d680a52017c99","target":"rust"},{"module":"napl","state":"0ef99195476c7251619345064e2dc74fa005197438592370d03b0b5e5a688b55","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"32812b7dc48cdba647dd60846a7204e6e6a76eee25b75a16aab0e8779497d214","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} -napl-store receipt v1 1830 -{"baseStateKey":"83e7eb0b234bbffd0573d91bcf92e594e91a0a15b536e6d5c915a965fe0d4309","baseStoreSnapshot":"5fea07446ceb73282789afdacd67fe7fb272122a07ddb8003709d80eec220398","baseWorkspaceHash":"a514163a925391219b20e5461159baee848a08a26d17c8b5bb1c62fffb1e560e","candidateTreeHash":"8a214291d385d0008c8c11387def1d4c226bfb970b090415dc921466ff3e21ca","contractVersion":4,"inputKey":"7ab1a333a614964d17ba55a718d21237178f9425a77e2170cd858656ce39896b","module":"usage_proxy","outputManifest":[{"hash":"6e5fc7c645dc91d9cf4cc415d91749bf9eb96cf1a13b17cbffbf7d549cfb3c76","path":"crates/napl-usage-proxy/src/agent.rs"},{"hash":"360a819b87eecfefab1a3831b4340ac7fada4ccb2f8ccef52208b6042f556d1e","path":"crates/napl-usage-proxy/src/args.rs"},{"hash":"8b827a70205c5aadce37f553b10f080fe5c618cf23c3f7954859011d329dbe7c","path":"crates/napl-usage-proxy/src/canonical.rs"},{"hash":"7b54deca43ff224cc112166abd958048b53b2762431c41a9fdc1044144d9c96a","path":"crates/napl-usage-proxy/src/capture.rs"},{"hash":"6844a11e7ff59e2b07df44e86039349fdba2b82241a712904027c6f01fb5a215","path":"crates/napl-usage-proxy/src/logline.rs"},{"hash":"a2da8502c17775fd09cf0f6c314674688fab51a644b5a5c3ce058c4286de69d9","path":"crates/napl-usage-proxy/src/main.rs"},{"hash":"eb4ca92f81de67ad4082efbb23d7619bf0f206dd607122b4e1872a24ee1787ee","path":"crates/napl-usage-proxy/src/proxy.rs"},{"hash":"0479a5be6a51a7191b47168b7f5bbb14ac2168809a25f33b3135dd11d0882a37","path":"crates/napl-usage-proxy/src/session.rs"},{"hash":"5d710e71c5606166c6ed3f471ba86a411990b186acea3ca8aa8ca388253539dc","path":"crates/napl-usage-proxy/src/state.rs"},{"hash":"cfbdfdce716fc1b0828ea03ee5d4b1cde37529c7011f1216862eccd1cc6acc4e","path":"crates/napl-usage-proxy/src/upstream.rs"}],"receiptVersion":1,"sliceHash":"8402d0e8e78aa124e849084dda2ef1b50d120d5c6fe8621946f69ff68da3157a","target":"rust"} -napl-store snapshot v1 895 -{"contractVersion":4,"event":"798cdb510ad310c837372681f20606da6374c6ac6b7295ebb3750da7a29adddf","states":[{"module":"agent","state":"112df64307c695d6e80359ccbbcf9100579692216d90fd08be281e7c82c07574","target":"rust"},{"module":"commands","state":"9e7b90a24d35bb3d42c0476c6d37e380c905ed227e86d8cf44e8342e96265a33","target":"rust"},{"module":"git-store","state":"33a141441be88c6825e85a1495467d599a016bae61a678358f9d680a52017c99","target":"rust"},{"module":"napl","state":"0ef99195476c7251619345064e2dc74fa005197438592370d03b0b5e5a688b55","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"32812b7dc48cdba647dd60846a7204e6e6a76eee25b75a16aab0e8779497d214","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} -napl-store event v1 158 -{"content":"0683a2f5623022e219ad446de610a80a5b77956fa5d2bf9ea9df38037f9bdae4","parents":["0a757fac5d572e4fc554b6ff0d3fe77b65b01622f0b7cc503ea93f84880a5633"]} -napl-store transition v1 1428 -{"baseStateKey":"d5141dcf22807eb4d7314871e99dd9b1f165a355d2ba0c088f6d2f4e2b9cf7ac","contractVersion":2,"derivedProjections":["crates/napl-store/src/lib.rs"],"inputKey":"6b0592b0c82aa68f8a0c06832ac1db151ab4ddc3d5b7e603162e461743237d58","installPaths":["crates/napl-store/src/hash.rs","crates/napl-store/src/store_blocks/mod.rs","crates/napl-store/src/store_blocks/tests.rs","crates/napl-store/src/store_diff/mod.rs","crates/napl-store/src/store_diff/tests.rs","crates/napl-store/src/store_hash.rs","crates/napl-store/src/store_io/mod.rs","crates/napl-store/src/store_io/tests.rs","crates/napl-store/src/store_objects/mod.rs","crates/napl-store/src/store_objects/tests.rs","crates/napl-store/src/store_reduce/mod.rs","crates/napl-store/src/store_reduce/tests.rs","crates/napl-store/src/store_tree.rs","crates/napl-store/src/store_txn_core.rs"],"module":"napl_store","newState":"aa1c6feb509424d6daaf1184f30c91f91fa1840466d1b59b2b379fbed233080a","readSet":["base-state:d5141dcf22807eb4d7314871e99dd9b1f165a355d2ba0c088f6d2f4e2b9cf7ac","input-key:6b0592b0c82aa68f8a0c06832ac1db151ab4ddc3d5b7e603162e461743237d58","target-def:eff9678e5301c88ac1e42eec62c56eb3860fca247f6278b6501639f14112a8d4"],"receipt":"05b37a6e9c078e2fec2fe0c0ae3d24e9117c25e7557f1317b4b133ca7d1f90d5","surface":"fb4aa1182902dca4e0ae0f15304e23b6fa162fdfcfd9b20c54d16696eea10017","target":"rust","writeSet":["owned:crates/napl-store","module-state:rust/napl_store"]} -napl-store receipt v1 1042 -{"baseStateKey":"782340da0a7d9facec2d0d42bc2fc55bab1d1cbddc3d888a91eba6f9ef9bc7a1","baseStoreSnapshot":"5267d01ec740e8bd16aee21fcc3b1793b34625a15aad070c4f99a9c1fee469de","baseWorkspaceHash":"7f682f874d2946896575efe98318ff918d12056d184eb7f60c66128bb4742ff2","candidateTreeHash":"fe440cd1ff613b544411bcbb210d7fbeac16036b945a163437a9462cc7f2896d","contractVersion":4,"inputKey":"c5d75a16ebd9b47dee925970788b6d6877b9516c34c1733a6a2b849fe75b981f","module":"query","outputManifest":[{"hash":"3b3b46d4f8f832a2b67e59d481fce3269061f0c79cb335d3ddc5a51d8a1ce915","path":"crates/napl/src/check.rs"},{"hash":"342bd33e8d08dec713feb5979249463f25ad382dd960e0c156b019c1e6de0e60","path":"crates/napl/src/document.rs"},{"hash":"2e84007b600de3257549b12e35b45e779d85751c59dd2ec7db7abde7ecd7e4c2","path":"crates/napl/src/promptbuild.rs"},{"hash":"ed4e1767d9f257439d2fe1a157104d2bbe511b8296bacd6027ba490122386f3f","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"sliceHash":"0b9bce6ead1a013e111512490b4e2666081fe219c177221fef06f8ef05292dfe","target":"rust"} -napl-store blob v1 12471 -//! `gc`, `fsck`, and `restore`: the three maintenance derivations. -//! -//! All three are decisions over content. None of them consults a clock, a -//! process table, or a wall-time budget: a sweep keeps what a named head -//! reaches, an integrity pass reports what fails to verify, and a repair plan -//! either names an unambiguous move or blocks. - -use std::collections::{BTreeMap, BTreeSet}; - -use napl_store::store_hash::{verify_object, ObjectKind}; -use serde_json::{json, Value}; - -use crate::error::Result; -use crate::objects::{event_transition, snapshot_event, snapshot_states, walk_events}; -use crate::store_view::{get_json, load_tree_objects, ObjectSource}; - -// ------------------------------------------------------------------- gc - -/// Every object reachable from a named head. -/// -/// Reachability is the whole safety argument for `gc`: the sweep is the -/// complement of this set, so an object this walk reaches is never a candidate -/// for collection, whatever else is happening concurrently. -pub fn reachable_from_head(source: &dyn ObjectSource, head: &str) -> Result> { - let mut out: BTreeSet = BTreeSet::new(); - out.insert(head.to_string()); - - let head_event = snapshot_event(source, head)?; - for event in walk_events(source, &head_event, None)? { - out.insert(event.clone()); - let transition = event_transition(source, &event)?; - out.insert(transition.clone()); - let body = get_json(source, &transition, ObjectKind::Transition)?; - for key in ["newState", "receipt", "surface"] { - if let Some(name) = body[key].as_str() { - out.insert(name.to_string()); - } - } - } - - for state_hash in snapshot_states(source, head)?.values() { - out.insert(state_hash.clone()); - let state = get_json(source, state_hash, ObjectKind::ModuleState)?; - if let Some(surface) = state["surfaceHash"].as_str() { - out.insert(surface.to_string()); - } - // The attribution record blob and the committed prompt blob it names by - // hash both survive gc: the language server reads the record, and - // `napl prompt` reads the prompt bytes at their snapshot. - if let Some(record) = state["attribution"]["record"].as_str() { - out.insert(record.to_string()); - } - if let Some(prompt) = state["attribution"]["promptHash"].as_str() { - out.insert(prompt.to_string()); - } - let Some(tree) = state["outputTree"].as_str() else { - continue; - }; - let mut trees = BTreeMap::new(); - load_tree_objects(source, tree, &mut trees)?; - for (name, node) in &trees { - out.insert(name.clone()); - for entry in &node.entries { - out.insert(entry.hash.clone()); - } - } - } - Ok(out) -} - -/// The sweep: every object no named head reaches, in canonical order. -pub fn plan_sweep(all: &BTreeSet, reachable: &BTreeSet) -> Vec { - all.difference(reachable).cloned().collect() -} - -/// The canonical JSON body of a gc report. -pub fn gc_json(swept: &[String], kept: usize) -> Value { - json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "kept": kept as u64, - "swept": swept, - }) -} - -// ----------------------------------------------------------------- fsck - -/// One integrity failure. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub enum FsckFinding { - /// The object's bytes do not hash to its name, or its envelope is malformed. - HashMismatch { object: String, detail: String }, - /// An event names a parent the store does not hold. - MissingParent { event: String, parent: String }, - /// A recorded object names an object the store does not hold. - MissingObject { from: String, missing: String }, -} - -/// Verify every object's envelope and every causal parent link. -pub fn fsck(source: &dyn ObjectSource) -> Result> { - let mut findings: Vec = Vec::new(); - let names = source.names()?; - let present: BTreeSet<&String> = names.iter().collect(); - - for name in &names { - let Some(bytes) = source.envelope(name)? else { - continue; - }; - let verified = match verify_object(name, &bytes) { - Ok(verified) => verified, - Err(e) => { - findings.push(FsckFinding::HashMismatch { - object: name.clone(), - detail: e.to_string(), - }); - continue; - } - }; - if verified.kind == ObjectKind::Blob { - continue; - } - let Ok(value) = serde_json::from_slice::(&verified.payload) else { - continue; - }; - if verified.kind == ObjectKind::Event { - for parent in value["parents"].as_array().cloned().unwrap_or_default() { - if let Some(parent) = parent.as_str() { - if !present.contains(&parent.to_string()) { - findings.push(FsckFinding::MissingParent { - event: name.clone(), - parent: parent.to_string(), - }); - } - } - } - } - for referenced in structural_refs(verified.kind, &value) { - if !present.contains(&referenced) { - findings.push(FsckFinding::MissingObject { - from: name.clone(), - missing: referenced, - }); - } - } - } - findings.sort(); - findings.dedup(); - Ok(findings) -} - -/// The objects a record structurally points at. -/// -/// Only structural links count. A record also carries *keys* — a `baseStateKey`, -/// a `baseWorkspaceHash`, an `inputKey` — which are identities of states and -/// derivations rather than names of stored objects, and demanding that those -/// resolve would report a healthy store as broken. -fn structural_refs(kind: ObjectKind, value: &Value) -> BTreeSet { - let mut out: BTreeSet = BTreeSet::new(); - let mut take = |name: Option<&str>| { - if let Some(name) = name.filter(|n| crate::canon::is_object_name(n)) { - out.insert(name.to_string()); - } - }; - match kind { - ObjectKind::Tree => { - for entry in value["entries"].as_array().cloned().unwrap_or_default() { - take(entry["hash"].as_str()); - } - } - ObjectKind::Snapshot => { - for entry in value["states"].as_array().cloned().unwrap_or_default() { - take(entry["state"].as_str()); - } - } - ObjectKind::ModuleState => { - take(value["outputTree"].as_str()); - take(value["surfaceHash"].as_str()); - } - ObjectKind::Transition => { - for key in ["newState", "receipt", "surface"] { - take(value[key].as_str()); - } - } - ObjectKind::Event => take(value["content"].as_str()), - _ => {} - } - out -} +Given the anchors, emit the alignment operations in this canonical order. Treat the +end of both sequences as a final sentinel anchor. For each anchor in turn, first +resolve the gap of non-anchor tokens that precedes it: pair the old-run and new-run +positionally, emitting a `Modified` operation `(oldIndex, newIndex)` for each of +the first `min(oldRunLen, newRunLen)` pairs in order, then a `Removed` operation +for each leftover old-run index in order, then an `Added` operation for each +leftover new-run index in order; then emit the anchor itself as an `Unchanged` +operation. The final sentinel contributes its preceding gap but no anchor +operation. This order is observable and pinned. -/// The canonical JSON body of an fsck report. -pub fn fsck_json(findings: &[FsckFinding]) -> Value { - let entries: Vec = findings - .iter() - .map(|finding| match finding { - FsckFinding::HashMismatch { object, detail } => json!({ - "detail": detail, - "kind": "hash-mismatch", - "object": object, - }), - FsckFinding::MissingParent { event, parent } => json!({ - "event": event, - "kind": "missing-parent", - "parent": parent, - }), - FsckFinding::MissingObject { from, missing } => json!({ - "from": from, - "kind": "missing-object", - "missing": missing, - }), - }) - .collect(); - json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "findings": entries, - "healthy": findings.is_empty(), - }) -} +## Hierarchical prompt diff -// -------------------------------------------------------------- restore +Expose `diff_prompt(old_index: &store_blocks::BlockIndex, old_prompt: &str, +new_index: &store_blocks::BlockIndex, new_prompt: &str) -> Vec`. It +is total: each `content_hash` is treated as an opaque token, each `text` is the +prompt slice of the record's byte range, and the caller supplies prompt bytes +consistent with the index (the same precondition `store_blocks::reconstruct` +carries). -/// One `path -> blob` fact, from the committed state or from the working tree. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct FileFact { - pub path: String, - pub hash: String, -} +Run the two-stage alignment over the atoms' `content_hash` sequences and walk the +operations in emission order: -/// One step of a repair plan. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub enum RestoreStep { - /// The content is present at the wrong path and exactly one candidate - /// explains it. - Move { from: String, to: String }, - /// The content is not in the working tree; reinstall it from the store. - Write { path: String, hash: String }, - /// The working tree holds a file the committed state does not own. - Remove { path: String }, -} +- An `Unchanged` atom operation emits nothing: the atoms are byte-identical. +- An `Added` atom operation emits one `ChangedBlock` with `change` `Added`, `level` + `Atom`, `old` `None`, and `new` an `OccurrenceSide` for the new atom (its + `atomIndex`, no `subIndex`, its `contentHash`, its prompt slice as `text`, and + its line range). An added atom's own sub-spans are never cross-matched into any + other atom; the whole atom is one added block. +- A `Removed` atom operation emits one `ChangedBlock` with `change` `Removed`, + `level` `Atom`, `old` the removed atom's `OccurrenceSide`, and `new` `None`. +- A `Modified` atom operation names a corresponding atom pair. An atom is + **sub-span-reliable** when it carries at least two sub-spans in the index; an + atom with zero or one sub-span (a frontmatter, heading, fenced-block, whitespace, + protected, or otherwise unsplittable atom) is not reliable. When **both** atoms + of the pair are sub-span-reliable, diff at the sub-span level (below). Otherwise + the pair degrades to an atom-level block: emit one `ChangedBlock` with `change` + `Modified`, `level` `Atom`, `old` the old atom's `OccurrenceSide`, and `new` the + new atom's `OccurrenceSide`. -/// A repair plan, or the reason there is no unambiguous one. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RestorePlan { - Apply(Vec), - Blocked { - path: String, - candidates: Vec, - }, -} +The sub-span level runs the same two-stage alignment over the two atoms' sub-span +`content_hash` sequences, taken in `subIndex` order, and walks the operations in +emission order. Sub-span matching stays strictly within this atom pair; a sub-span +is never matched into another atom's sub-spans. Each operation emits a +`ChangedBlock` with `level` `SubSpan`: -/// Derive a repair plan from persisted evidence alone. -/// -/// A missing file whose exact content sits at exactly one unexpected path is an -/// unambiguous move and installs. Two candidates is an ambiguity, and ambiguity -/// blocks: liveness and wall time never get a vote. -pub fn plan_restore(expected: &[FileFact], observed: &[FileFact]) -> RestorePlan { - let expected_paths: BTreeSet<&String> = expected.iter().map(|f| &f.path).collect(); - let observed_by_path: BTreeMap<&String, &String> = - observed.iter().map(|f| (&f.path, &f.hash)).collect(); +- `Unchanged` emits nothing. +- `Added` emits `change` `Added`, `old` `None`, `new` the new sub-span's + `OccurrenceSide` (its parent atom's index as `atomIndex`, its `subIndex`, its + `contentHash`, its prompt slice as `text`, its line range). +- `Removed` emits `change` `Removed`, `old` the old sub-span's `OccurrenceSide`, + `new` `None`. +- `Modified` emits `change` `Modified`, `old` and `new` the two sub-spans' + occurrence sides. - let mut steps: Vec = Vec::new(); - for want in expected { - if observed_by_path.get(&want.path) == Some(&&want.hash) { - continue; - } - if observed_by_path.contains_key(&want.path) { - steps.push(RestoreStep::Write { - path: want.path.clone(), - hash: want.hash.clone(), - }); - continue; - } - let mut candidates: Vec = observed - .iter() - .filter(|found| found.hash == want.hash && !expected_paths.contains(&found.path)) - .map(|found| found.path.clone()) - .collect(); - candidates.sort(); - candidates.dedup(); - match candidates.len() { - 0 => steps.push(RestoreStep::Write { - path: want.path.clone(), - hash: want.hash.clone(), - }), - 1 => steps.push(RestoreStep::Move { - from: candidates[0].clone(), - to: want.path.clone(), - }), - _ => { - return RestorePlan::Blocked { - path: want.path.clone(), - candidates, - } - } - } - } +Because an insertion adds an atom while the following identical atoms remain +anchors, an insertion near the top adds exactly one atom block and never churns the +suffix. Because a paragraph that splits differently between versions leaves one +side without reliable sub-spans, that atom pair degrades to a single atom-level +block rather than a churn of cross-matched sentences. - let moved: BTreeSet = steps - .iter() - .filter_map(|step| match step { - RestoreStep::Move { from, .. } => Some(from.clone()), - _ => None, - }) - .collect(); - for found in observed { - if !expected_paths.contains(&found.path) && !moved.contains(&found.path) { - steps.push(RestoreStep::Remove { - path: found.path.clone(), - }); - } - } +## Attribution ownership resolution - steps.sort(); - RestorePlan::Apply(steps) -} +Expose `resolve_ownership(index: &store_blocks::BlockIndex, attribution: +&Attribution) -> Vec`. For each attribution entry in order, +intersect its `promptLines` range against the index by inclusive line overlap (two +inclusive ranges `[a0, a1]` and `[b0, b1]` overlap when `a0 <= b1` and `b0 <= a1`) +and resolve one owner, coarsening deliberately when a line cannot uniquely select a +sentence: -/// The canonical JSON body of a restore plan. -pub fn restore_json(plan: &RestorePlan) -> Value { - match plan { - RestorePlan::Blocked { path, candidates } => json!({ - "candidates": candidates, - "contractVersion": crate::version::CONTRACT_VERSION, - "outcome": "blocked", - "path": path, - }), - RestorePlan::Apply(steps) => { - let entries: Vec = steps - .iter() - .map(|step| match step { - RestoreStep::Move { from, to } => { - json!({ "from": from, "op": "move", "to": to }) - } - RestoreStep::Write { path, hash } => { - json!({ "hash": hash, "op": "write", "path": path }) - } - RestoreStep::Remove { path } => json!({ "op": "remove", "path": path }), - }) - .collect(); - json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "outcome": "plan", - "steps": entries, - }) - } - } -} -napl-store blob v1 23378 -# query: the canonical document, napl q, and napl prompt +- Collect the sub-spans whose line range overlaps the entry's `promptLines`. +- If exactly one sub-span overlaps, the owner is that sub-span: a `SubSpan` + `OwnerRef` with its parent atom's index and its `subIndex`. +- If two or more sub-spans overlap and they all share one parent atom, ownership + **coarsens to the enclosing atom**: an `Atom` `OwnerRef` with that atom's index. + A line range that intersects several sibling sentences deliberately owns at the + atom level rather than guessing a sentence split; this is the two-sentences-on- + one-line rule. +- If the overlapping sub-spans span more than one atom, emit one `Atom`-owner + record per overlapped atom in ascending atom-index order, all carrying the + entry's file, lines, and note. +- If no sub-span overlaps, fall back to atoms: collect the atoms whose line range + overlaps `promptLines`; a single overlapped atom yields one `Atom`-owner record, + several yield one `Atom`-owner record per atom in ascending index order, and no + overlap at all yields no record for this entry (the entry references lines + outside this index version and is dropped). -The language answers every question through one query surface and builds -agent context through one prompt builder. This module owns both commands -and the document they share. Everything here is a pure read: no store -writes, no locks held, no clock, no randomness, no network (P1). Reads -serve committed records, with two stated exceptions: reference staleness -is computed at read time from the working tree as well as the committed -bytes, so a hand edit to either end of a reference reads stale without -anything being stored; and the working-tree read mode below serves the -pending prompt when asked to explicitly. The same committed state plus -the same working tree produces the same bytes. This module owns `src/query.rs` (taken over -from the commands module), plus the document builder and prompt builder -sources it introduces. +Each emitted `OwnershipRecord` carries the resolved `owner`, the entry's `file`, +its `lines[0]` as `lineStart` and `lines[1]` as `lineEnd`, and its `note`. -## The canonical document +## Bundle assembly -One JSON value, `docVersion 1`, built at query time from committed state: -the store head, module states, attribution records, staged-evidence usage -inside receipts, recorded surfaces, and the committed prompt bytes. Top -level: `docVersion`, `head` (the snapshot hash), `modules`, `history`, -`state`. +Expose `build_bundle(module: &str, prompt_changes: &[ChangedBlock], old_index: +&store_blocks::BlockIndex, old_attribution: &Attribution, new_index: +&store_blocks::BlockIndex, new_attribution: &Attribution, old_tree_root: +Option<&str>, new_tree_root: Option<&str>, tree_objects: +&std::collections::BTreeMap) -> Result`. It assembles the bundle: -Each module carries `name`, `promptPath`, `deps`, `targets`, -`surfaceHash`, `pending`, `attribution` (an object whose `status` is -`fresh`, `stale`, or `absent`), `sections`, `frontmatterCases` (cases -declared in frontmatter `tests:`, which have no body position), and -`files` (owned output paths), and `outline` — the prompt body's -headings in order, each `{text, level, line}` with the line -body-normalized and 1-based, so "which body line is this heading on" -is a query, never a raw file read. Sections are the module's attribution -entries, and every section is a SELF-CONTAINED REF: it carries `head`, -`module`, `promptPath`, `lines` (body-normalized, 1-based, inclusive), -`note`, `law` (true exactly when the entry owns no file), `files` (each -`{path, lines, head}`), `direction` (`prompt_to_code` or -`code_to_prompt`, per the reference contract in the napl module; entries -recorded before directions read `prompt_to_code`), `provenance` (an -object with `model`, `timestamp`, `toolVersion`, and `usage`, each -absent field served as `unavailable`, never invented), `referenceStatus` -(`fresh` or `stale`), `staleEndpoints` (empty when fresh, `code` and -`prompt` when stale), and `cases` — the `::: test` directive cases -whose block range intersects the section's range, each -`{name, given, expect, lines}` with values in canonical JSON. A directive -case intersecting no entry lands in a synthetic section with empty -`files`. When a module's attribution is stale, sections still carry -their cases (cases derive from prompt bytes, not from attribution) but -each section carries `associationStale: true` and the module's status -says `stale`: the section-to-file pairing cannot be trusted, the case -data can. +- Compute the code-side changes by calling `store_tree::diff_trees(old_tree_root, + new_tree_root, tree_objects)`; on `store_tree::StoreTreeError::MissingObject` + return `StoreDiffError::MissingObject`, and on any other `store_tree` error + return `StoreDiffError::MissingObject` as well since a well-formed caller only + reaches the missing-object path here. Project each returned `TreeChange` into a + `CodeChange` through `code_change_from_tree`. +- Let the changed-file set be the `path` of every code change. An `OwnerRef` + **matches** a changed-block occurrence side when they name the same `atomIndex` + and, when both carry a `subIndex`, the same `subIndex` (an atom owner matches any + occurrence in its atom, and a sub-span owner matches an atom-level occurrence in + its atom). +- `owned_old` is every record from `resolve_ownership(old_index, old_attribution)`, + in that function's order, whose `file` is in the changed-file set and whose + `owner` matches the `old` side of some changed block. +- `owned_new` is the same over `resolve_ownership(new_index, new_attribution)` + against the `new` sides. +- Let the owned-file set be the `file` of every record in `owned_old` and + `owned_new`. `unowned` is every code change, in tree-diff order, whose `path` is + not in the owned-file set. +- Return the `Bundle` with the given `module`, the given `prompt_changes`, and the + three assembled lists. -Each file ref inside a section carries `claim`, `whole` when the entry -owns the file with no line range and `range` when it names one, so a -consumer never guesses whether `lines` bounds the claim or merely -samples it. +For a no-op changeset (no prompt changes and identical trees) every list is empty: +the bundle's code side is empty, which is exactly what the audit consumer asserts +for an expected-no-op. -Docs fences from the napl module's directive law surface here per -section: `sections[].docs` is the ordered list of docs payloads whose -fence sits inside the section's line range, each -`{directive, attrs, lines, body}` with inner fences verbatim in the -body. The module preamble fence, when present, is served at -`modules[].docs` beside the sections. Docs `lines` are physical prompt -coordinates, the one place physical coordinates appear in the -document, because docs fences are excluded from the semantic body that -every other coordinate speaks in; the document states both coordinate -systems explicitly. A module with no docs fences serves empty docs -lists, never null. +## Rendering the bundle blob -The document's freshness verdicts and the language server's are one -analysis: a section the LSP would squiggle as reference-stale reads -`referenceStatus: stale` here with the same endpoints, never a -hardcoded fresh. +Expose `bundle_bytes(bundle: &Bundle) -> Vec`, the bundle's blob payload: the +`store_hash::canonical_json` of the serialized bundle value. Expose +`bundle_blob_name(bundle: &Bundle) -> String`, the bundle's blob object name: +`store_hash::object_name(store_hash::ObjectKind::Blob, &bundle_bytes(bundle))`. +These do not reimplement canonical JSON or hashing; they call `store_hash`. A +bundle recovered from its canonical bytes through `validate_bundle` equals the +original. -History entries carry the transition hash, module, target, outcome, and -`usage` — `{model, input, output, cacheRead}` from the staged record's -validated usage evidence, or null when none was supplied; absent -telemetry is null, never zero. +## Fixtures the tests pin -A history entry carries the transition's kind: an ordinary install -says `installed`, and a docs-only transition says `docs-only` as both -its `kind` and its `outcome`, so any reader of the document tells -prose history from semantic history without dereferencing the -transition object. +These fixtures and hash constants are referenced by name in the test suite. The +hash constants are each a sixty-four-character string of a single repeated +character: `A` is sixty-four `a`, `B` sixty-four `b`, `C` sixty-four `c`, `D` +sixty-four `d`, and `S2`, `S3`, `S4` are sixty-four repetitions of the digits `2`, +`3`, `4` respectively. All are well-formed lowercase-hex tokens. -Canonical serialization throughout: sorted keys, total ordering for every -array (modules by name; sections by start then end line; files and cases -by path and name; history by transition sequence, transition hash as -tiebreaker), no absolute paths anywhere (`promptPath` and file paths are -workspace-relative), one trailing newline. The same committed state -produces the same document bytes on any machine. +The block-index fixtures are built directly as `store_blocks::BlockIndex` values +(`seg_version` 1) with the atoms and sub-spans named; their `content_hash` tokens +are chosen to drive the alignment and are compared only for equality. -::: test -name: q with no arguments emits the canonical document byte-stably -given: { workspace: fixture_two_modules, runs: 2 } -expect: { bytes_equal: true, top_level_keys: [docVersion, head, history, modules, state] } -::: +- **DIFF_INS**: old prompt `Alpha line.\nBeta line.\nGamma line.\n`, three atoms + with content hashes `A`, `B`, `C` at byte ranges `[0,12)`, `[12,23)`, `[23,35)` + and line ranges `(1,1)`, `(2,2)`, `(3,3)`, no sub-spans; new prompt + `Zeta line.\nAlpha line.\nBeta line.\nGamma line.\n`, four atoms with content + hashes `D`, `A`, `B`, `C` at byte ranges `[0,11)`, `[11,23)`, `[23,34)`, + `[34,46)` and line ranges `(1,1)`, `(2,2)`, `(3,3)`, `(4,4)`, no sub-spans. ::: test -name: the document contains no absolute paths -given: { workspace: fixture_two_modules } -expect: { absolute_paths: 0 } +name: an insertion near the top adds one atom and never churns the suffix +given: { fixture: DIFF_INS } +expect: + prompt_changes: + - { change: added, level: atom, newAtomIndex: 0, newContentHash: D, newText: "Zeta line.\n", newLineStart: 1, newLineEnd: 1 } ::: -::: test -name: directive cases appear inside their owning section -given: { workspace: fixture_with_directive_case, attribution: fresh } -expect: { section_cases: 1, case_fields: [expect, given, lines, name] } -::: +- **DIFF_SUBDIFF**: old prompt `A cat sat. A dog ran.\n`, one atom content hash + `A` at `[0,22)` line `(1,1)` with two sub-spans, `subIndex` 0 content hash + sixty-four `1`, bytes `[0,11)`, line `(1,1)`, and `subIndex` 1 content hash `S2`, + bytes `[11,22)`, line `(1,1)`; new prompt `A cat sat. A fox ran.\n`, one atom + content hash `B` at + `[0,22)` line `(1,1)` with two sub-spans, `subIndex` 0 content hash sixty-four + `1`, bytes `[0,11)`, line `(1,1)`, and `subIndex` 1 content hash `S3`, bytes + `[11,22)`, line `(1,1)`. The shared first sub-span anchors; the second sub-span + is a modified pair whose old text is `A dog ran.\n` and new text is + `A fox ran.\n`. ::: test -name: stale attribution keeps cases and marks the association stale -given: { workspace: fixture_with_directive_case, attribution: stale } -expect: { section_cases: 1, association_stale: true, module_status: stale } +name: a modified prose atom pair with reliable sub-spans diffs at the sub-span level +given: { fixture: DIFF_SUBDIFF } +expect: { prompt_changes: [ { change: modified, level: subSpan, oldAtomIndex: 0, oldSubIndex: 1, oldContentHash: S2, oldText: "A dog ran.\n", newAtomIndex: 0, newSubIndex: 1, newContentHash: S3, newText: "A fox ran.\n" } ] } ::: -::: test -name: section refs are self-contained -given: { workspace: fixture_two_modules } -expect: { section_fields_present: [direction, head, lines, module, promptPath, provenance, referenceStatus, staleEndpoints] } -::: +- **DIFF_DEGRADE**: old prompt `Single line only.\n`, one atom content hash `A` at + `[0,18)` line `(1,1)` with a single sub-span `subIndex` 0 content hash `S4`, + bytes `[0,18)`, line `(1,1)` (one sub-span, so not reliable); new prompt + `Single line only. And more text.\n`, one atom content hash `B` at `[0,33)` line + `(1,1)` with two sub-spans, `subIndex` 0 content hash sixty-four `1`, bytes + `[0,18)`, line `(1,1)`, and `subIndex` 1 content hash `S2`, bytes `[18,33)`, + line `(1,1)`. The old side is not reliable, so the pair degrades to an atom-level + modified block whose old text is `Single line only.\n` and new text is + `Single line only. And more text.\n`. ::: test -name: usage from staged evidence appears in history entries -given: { workspace: fixture_two_modules, commit_with_usage: true } -expect: { history_usage_fields: [cacheRead, input, model, output], absent_usage: null } +name: a modified atom pair with an unreliable side degrades to an atom-level block +given: { fixture: DIFF_DEGRADE } +expect: { prompt_changes: [ { change: modified, level: atom, oldAtomIndex: 0, oldText: "Single line only.\n", newAtomIndex: 0, newText: "Single line only. And more text.\n" } ] } ::: -::: test -name: a docs-only transition is told apart in history without dereferencing the transition -given: { workspace: fixture_two_modules, then: [docs_edit, add_prompt_path, commit] } -expect: { last_history_kind: docs-only, last_history_outcome: docs-only, ordinary_entries_say: installed } -::: +- **DIFF_REMOVE**: old prompt `Alpha line.\nBeta line.\n`, two atoms content hashes + `A`, `B` at `[0,12)`, `[12,23)`, lines `(1,1)`, `(2,2)`, no sub-spans; new prompt + `Alpha line.\n`, one atom content hash `A` at `[0,12)` line `(1,1)`. The second + atom is removed. ::: test -name: a section's docs payloads arrive in order with their bodies verbatim -given: { workspace: fixture_with_docs_fences } -expect: { docs_fields: [attrs, body, directive, lines], order_preserved: true } +name: a removed trailing atom is reported at the atom level +given: { fixture: DIFF_REMOVE } +expect: + prompt_changes: + - { change: removed, level: atom, oldAtomIndex: 1, oldContentHash: B, oldText: "Beta line.\n", oldLineStart: 2, oldLineEnd: 2 } ::: -::: test -name: a module preamble fence serves title and description in module docs -given: { workspace: fixture_with_module_preamble } -expect: { module_docs_attrs: [description, title] } -::: +- **OWN_COARSEN**: an index with one atom (content hash `A`, bytes `[0,22)`, line + `(1,1)`) and two sub-spans both on line 1 (`subIndex` 0 bytes `[0,11)` line + `(1,1)`, `subIndex` 1 bytes `[11,22)` line `(1,1)`), + and an `Attribution` (`module` `greeting`, `target` `rust`) with one entry + `promptLines` `[1,1]`, `file` `greeting.rs`, `lines` `[1,1]`, `note` + `whole line`. The entry overlaps both sub-spans of one atom, so ownership + coarsens to `Atom` owner index 0. ::: test -name: a module with no docs fences serves empty docs lists -given: { workspace: fixture_two_modules } -expect: { module_docs: [], section_docs_empty: true, nulls: 0 } +name: two sentences on one line coarsen attribution ownership to the enclosing atom +given: { fixture: OWN_COARSEN } +expect: + ownership: + - { owner_level: atom, owner_atom_index: 0, file: "greeting.rs", line_start: 1, line_end: 1, note: "whole line" } ::: -::: test -name: file refs carry the claim discriminator -given: { workspace: fixture_two_modules } -expect: { file_claims_subset_of: [range, whole] } -::: +- **OWN_UNIQUE**: an index with one atom (bytes `[0,22)`, line `(1,2)`) and two + sub-spans on distinct lines (`subIndex` 0 bytes `[0,11)` line `(1,1)`, `subIndex` + 1 bytes `[11,22)` line `(2,2)`), and an + `Attribution` with one entry `promptLines` `[2,2]`, `file` `greeting.rs`, `lines` + `[5,7]`, `note` `second`. The entry overlaps only the second sub-span, so the + owner is `SubSpan` atom 0 sub-index 1. ::: test -name: reference staleness in the document matches the language server verdict -given: { workspace: fixture_with_stale_reference } -expect: { reference_status: stale, lsp_parity: true, hardcoded_fresh: false } +name: an attribution line that selects one sub-span owns at the sub-span level +given: { fixture: OWN_UNIQUE } +expect: + ownership: + - { owner_level: subSpan, owner_atom_index: 0, owner_sub_index: 1, file: "greeting.rs", line_start: 5, line_end: 7, note: "second" } ::: -## napl q - -`napl q` is shaped exactly like jq and IS jq: expressions are evaluated -by an embedded jq engine (the jaq crate, version pinned by the lockfile, -upgraded only through a normal committed change). Impure builtins are -rejected at expression compile time (`env`, `$ENV`, `now`, `input`, -`inputs`, `debug`, `stderr`, `input_line_number`, and anything else doing -I/O or randomness), so an expression is a pure function of its input. - -Argv grammar, exactly three forms; more than one input is a usage -error; `--` is accepted before a path starting with a dash; the -expression is always required and there is deliberately no shorthand -for identity anywhere, because the primary caller is a language model -session and a bare invocation that dumps the whole document is a -context bomb, not a convenience; the usage error for a missing -expression teaches `.` as the explicit spelling and jq demands its -filter the same way: - - napl q expression over the canonical document - napl q - expression over stdin - napl q expression over a JSON file - -Input may be a single JSON value or a stream of values (JSONL); the -expression applies per value exactly as jq does. Each result value is -canonically serialized, one per line; `-r`/`--raw-output` emits string -results as their raw contents exactly as jq does, one result per -output line with exactly one newline between results and one at the -end, never doubled when the string itself ends in one, so shell -pipelines never need a dequoting round trip and a raw result diffs -clean against the file it came from. An empty result stream is exit 0 -with no output. Because canonical serialization is already one compact -value per line, jq's `-c`/`--compact-output` is accepted as a -harmless synonym of the default: it changes nothing and never errors, -so the jq reflex costs no usage round trip; `-c` composes with `-r` -exactly as it does in jq, where raw string output ignores it. Errors are canonical JSON on stderr with pinned exit -codes: 2 usage, 3 expression parse or refused builtin, 4 input parse, 5 -runtime or type error. Evaluation carries a compiled-in output ceiling of -64 MiB, enforced functionally and surfaced as `resource-limit` with its -existing exit code; recursion depth is bounded by the embedded engine's -own evaluation stack rather than a compiled-in constant, and an -expression exceeding it aborts rather than returning wrong data. Piping any napl command's -canonical JSON back through an expression works by construction: -`napl status | napl q '.pending' -`. +- **OWN_NOSUB**: an index with one atom (bytes `[0,10)`, line `(1,1)`) and no + sub-spans, and an + `Attribution` with one entry `promptLines` `[1,1]`, `file` `greeting.rs`, `lines` + `[1,1]`, `note` `heading`. No sub-span overlaps, so ownership falls back to the + atom, an `Atom` owner index 0. ::: test -name: q evaluates a jq expression over the document -given: { workspace: fixture_two_modules, expr: ".modules[].name" } -expect: { output_lines: [consumer, leaf] } +name: an attribution over an atom with no sub-spans falls back to atom ownership +given: { fixture: OWN_NOSUB } +expect: + ownership: + - { owner_level: atom, owner_atom_index: 0, file: "greeting.rs", line_start: 1, line_end: 1, note: "heading" } ::: -::: test -name: q accepts the compact flag as a synonym of the default output -given: { workspace: fixture_two_modules, invocations: ["q .pending", "q -c .pending", "q --compact-output .pending"] } -expect: { outputs_byte_identical: true, exit_codes: [0, 0, 0] } -::: +- **BUNDLE_NOOP**: `module` `greeting`, empty prompt changes, an index and + attribution on each side, and the same tree root on both sides (or `None` on + both) so the tree diff is empty. Every bundle list is empty. ::: test -name: q without an expression is a usage error teaching the explicit dot -given: { workspace: fixture_two_modules, invocations: ["q", "q -c", "q -r"] } -expect: { exit_codes: [2, 2, 2], error_teaches_dot: true, document_dumped: false } +name: a no-op changeset builds an empty bundle with an empty code side +given: { fixture: BUNDLE_NOOP } +expect: { prompt_changes: [], owned_old: [], owned_new: [], unowned: [] } ::: -::: test -name: q compact composes with raw output changing nothing -given: { stdin: "{\"a\":\"x\"}", expr: ".a", input: "-", flags: ["-r", "-c -r"] } -expect: { outputs_byte_identical: true, raw_string_unquoted: true } -::: +- **BUNDLE_RICH**: `module` `greeting`. Old index one atom (content hash `A`, bytes + `[0,22)`, line `(1,2)`) with two sub-spans, `subIndex` 0 content hash sixty-four + `1` bytes `[0,11)` line `(1,1)`, `subIndex` 1 content hash `S2` bytes `[11,22)` + line `(2,2)`; new index one atom (content hash sixty-four `0`, differing from the + old atom, bytes `[0,22)`, line `(1,2)`) with two sub-spans, `subIndex` 0 content + hash sixty-four `1` bytes `[0,11)` line `(1,1)`, `subIndex` 1 content hash `S3` + bytes `[11,22)` line `(2,2)`. Old prompt + `A cat sat.\nA dog ran.\n`, new prompt `A cat sat.\nA fox ran.\n`. The prompt + changes come from `diff_prompt`: one modified sub-span block, old text + `A dog ran.\n` new text `A fox ran.\n`, both at line `(2,2)`. Old and new + attribution each carry one entry `promptLines` `[2,2]`, `file` `greeting.rs`, + `lines` `[2,2]`, `note` `greet`. The old tree has files `greeting.rs` (hash + sixty-four `a`, mode 33188) and `extra.rs` (hash sixty-four `e`, mode 33188); the + new tree has `greeting.rs` (hash sixty-four `b`, mode 33188) and `extra.rs` (hash + sixty-four `f`, mode 33188); both files change. The build attributes + `greeting.rs` to the changed sub-span on both sides (so it appears in `ownedOld` + and `ownedNew`) and leaves `extra.rs` unowned. Its bundle blob bytes are exactly + the pinned canonical bytes and its blob name is the pinned digest. ::: test -name: q evaluates an expression over stdin when the input is a dash -given: { stdin: "{\"pending\":[\"m\"]}", expr: ".pending", input: "-" } -expect: { output: "[\"m\"]" } +name: "a bundle labels owned old, owned new, and unowned code changes distinctly" +given: { fixture: BUNDLE_RICH } +expect: + owned_old: + - { owner_level: subSpan, owner_atom_index: 0, owner_sub_index: 1, file: "greeting.rs", line_start: 2, line_end: 2, note: "greet" } + owned_new: + - { owner_level: subSpan, owner_atom_index: 0, owner_sub_index: 1, file: "greeting.rs", line_start: 2, line_end: 2, note: "greet" } + unowned: + - { change: modified, path: "extra.rs" } ::: -::: test -name: q rejects an invalid expression without touching the store -given: { workspace: fixture_two_modules, expr: ".[unclosed" } -expect: { exit_code: 3, store_bytes_unchanged: true } -::: +- **BD_EMPTY**: the `Bundle` value `module` `greeting` with all four lists empty. + Its canonical bytes are exactly + `{"module":"greeting","ownedNew":[],"ownedOld":[],"promptChanges":[],"unowned":[]}` + followed by one newline; its blob name is + `6dc9ca1a0fe393bbed56cce83d42aadded789fac58c3f5ef22d2c13bf25d1bdc`. ::: test -name: q refuses impure builtins -given: { workspace: fixture_two_modules, expr: "now" } -expect: { exit_code: 3, error_names_builtin: true } +name: an empty bundle canonicalizes to its pinned bytes +given: { fixture: BD_EMPTY } +expect: { bundle_bytes: "{\"module\":\"greeting\",\"ownedNew\":[],\"ownedOld\":[],\"promptChanges\":[],\"unowned\":[]}\n" } ::: ::: test -name: q raw output emits string contents without quotes exactly as jq dash r -given: { workspace: fixture_two_modules, expr: ".modules[0].name", flags: [-r] } -expect: { output: leaf_unquoted, trailing_newline: true } +name: an empty bundle names its blob by its pinned digest +given: { fixture: BD_EMPTY } +expect: { bundle_blob_name: "6dc9ca1a0fe393bbed56cce83d42aadded789fac58c3f5ef22d2c13bf25d1bdc" } ::: ::: test -name: the document serves a heading outline with body line numbers -given: { workspace: fixture_two_modules } -expect: { outline_present: true, outline_entry_fields: [level, line, text], lines_body_normalized: true } +name: an empty bundle round-trips back to an equal value +given: { fixture: BD_EMPTY } +expect: { round_trip_equal: true } ::: -## napl prompt - -`napl prompt` (alias `napl p`) is the prompt builder: it reads refs from -stdin — the objects a `napl q` stage selected — and emits markdown built -from committed state at the refs' snapshot, never current head, and -never the working tree except under the explicit `--working-tree` mode -defined in the working-tree reads section. Stdin is typed-ref JSONL: one JSON object per line -carrying `head`, `lines`, and exactly one of `path` or `promptPath`; -unknown extra fields are ignored. A line that is not an object, carries -both path kinds or neither, or has a non-integer, zero, reversed, or -out-of-range line range is an error naming the input line number, never a -silent skip. Empty stdin is an error saying nothing was selected. Refs -naming more than one head in a single invocation error. Duplicate refs -(same head, path, range) emit once; input order is otherwise preserved. - -Materialization reads the snapshot's content-addressed blobs; committed -prompts and committed module outputs are the only eligible paths. A -`{path, lines}` ref resolves to its owning module through the snapshot's -output manifests — owned globs are disjoint by commit invariant, so -ownership is unique, and a discovered overlap is a store-integrity -failure, not a guess. A prompt ref yields specification prose; a file ref -yields code: one resolver, both directions. Every chunk gets a provenance -header naming `path:start-end` plus the module and note when known. -Cases render after their section's prose as canonical JSON code blocks -inside dynamically sized fences, immune to backticks in values. - -Law closure by default: the `law: true` sections of every module a ref -touched are appended once, deduplicated, ordered by module then range, -under one trailing header; `--no-laws` opts out. Refs into a module whose -attribution is stale refuse by default with that module's fix directive; -`--allow-stale` proceeds and stamps `stale` into the affected provenance -headers. Unresolvable refs (absent path, unavailable snapshot) error -naming the ref's input line. +- **BD_RICH**: the `Bundle` value that `BUNDLE_RICH` builds. Its canonical bytes + are the pinned rich-bundle literal and its blob name is + `8e42bc109b1229962ccfcecb1aced8316d00145d46129e88e4c0fc71c7f2eee7`. ::: test -name: prompt materializes a prompt ref with a provenance header -given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf } -expect: { output_contains_prose: true, header_format: "path:start-end" } +name: the rich bundle canonicalizes to its pinned bytes +given: { fixture: BD_RICH } +expect: { bundle_bytes: "{\"module\":\"greeting\",\"ownedNew\":[{\"file\":\"greeting.rs\",\"lineEnd\":2,\"lineStart\":2,\"note\":\"greet\",\"owner\":{\"atomIndex\":0,\"level\":\"subSpan\",\"subIndex\":1}}],\"ownedOld\":[{\"file\":\"greeting.rs\",\"lineEnd\":2,\"lineStart\":2,\"note\":\"greet\",\"owner\":{\"atomIndex\":0,\"level\":\"subSpan\",\"subIndex\":1}}],\"promptChanges\":[{\"change\":\"modified\",\"level\":\"subSpan\",\"new\":{\"atomIndex\":0,\"contentHash\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"lineEnd\":2,\"lineStart\":2,\"subIndex\":1,\"text\":\"A fox ran.\\n\"},\"old\":{\"atomIndex\":0,\"contentHash\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"lineEnd\":2,\"lineStart\":2,\"subIndex\":1,\"text\":\"A dog ran.\\n\"}}],\"unowned\":[{\"change\":\"modified\",\"newHash\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"newMode\":33188,\"oldHash\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"oldMode\":33188,\"path\":\"extra.rs\"}]}\n" } ::: ::: test -name: prompt materializes a generated-file ref symmetrically -given: { workspace: fixture_two_modules, stdin: file_ref_for_leaf_output } -expect: { output_contains_code: true } +name: the rich bundle names its blob by its pinned digest +given: { fixture: BD_RICH } +expect: { bundle_blob_name: "8e42bc109b1229962ccfcecb1aced8316d00145d46129e88e4c0fc71c7f2eee7" } ::: ::: test -name: prompt appends law sections by default and omits them under no-laws -given: { workspace: fixture_with_law_section, stdin: section_ref_non_law } -expect: { laws_appended_default: true, laws_with_flag: false } +name: the rich bundle round-trips back to an equal value +given: { fixture: BD_RICH } +expect: { round_trip_equal: true } ::: -::: test -name: prompt refuses refs into a stale module by default -given: { workspace: fixture_with_directive_case, attribution: stale, stdin: section_ref } -expect: { exit_nonzero: true, directive_present: true, allow_stale_overrides: true } -::: +- **BUNDLE_MISSING_ROOT**: a `build_bundle` call whose `old_tree_root` is a + sixty-four-hex name (use `D`) deliberately absent from `tree_objects`, so the + tree diff fails and the build returns `MissingObject`. ::: test -name: prompt errors on a malformed stdin line naming its line number -given: { stdin_lines: [valid_ref, "not json"] } -expect: { exit_nonzero: true, error_names_line: 2 } +name: a bundle build whose tree root is absent from the object map is rejected +given: { fixture: BUNDLE_MISSING_ROOT } +expect: { error: MissingObject } ::: -::: test -name: prompt errors on empty stdin -given: { stdin: "" } -expect: { exit_nonzero: true, error: nothing_selected } -::: +## Test and code discipline -::: test -name: prompt deduplicates identical refs and preserves input order -given: { stdin_lines: [ref_a, ref_b, ref_a] } -expect: { chunks: 2, order: [ref_a, ref_b] } -::: +The frontmatter test suite is a contract, not a sketch. Each case in it is realized +by its own dedicated test function that exercises only that case, so the number of +dedicated tests is at least the number of frontmatter cases. Never fold two or more +cases into one shared test, and a single test that loops over a table of inputs +never counts as the dedicated test any of those inputs requires: such a combined +test may exist only as an extra. Cases that differ only in their input while +sharing an expected shape are still distinct cases, each owed its own dedicated +test. -::: test -name: "prompt resolves refs against their snapshot, not current head" -given: { workspace: fixture_two_modules, ref_head: previous_snapshot, edits_after: leaf_recommitted } -expect: { materialized_bytes: previous_snapshot_bytes } -::: +Enumerate the case families by name, each owed its own dedicated verbatim test. The +wire-string cases: the three `ChangeKind` serialize strings `added`, `removed`, +`modified`; the two `BlockLevel` serialize strings `atom`, `subSpan`; and the two +deserialize cases (`added` to `ChangeKind::Added`, `subSpan` to +`BlockLevel::SubSpan`), each its own test even where a token is shared. The +byte-pinned bundle cases: the BD_EMPTY canonical-bytes case, the BD_EMPTY blob-name +case, the BD_EMPTY round-trip case, the BD_RICH canonical-bytes case, the BD_RICH +blob-name case, and the BD_RICH round-trip case, each its own test. Every case that +expects `Malformed` (the unknown top-level bundle field, the unknown changed-block +field, the unknown occurrence-side field, the unknown owner field, the unknown +ownership-record field, the unknown code-change field, the unknown attribution +top-level field, and the unknown ownership-entry field), the `MalformedHash` case, +every `InconsistentChange` case (the added block with an old side, the removed +block with a new side, the modified block missing its new side), every +`InconsistentOwner` case (the atom owner carrying a subIndex, the subSpan owner +missing its subIndex), every `InvalidLineRange` case (the occurrence side, the +attribution promptLines), and the `MissingObject` case, gets its own dedicated +test. The algorithmic cases: the insertion case DIFF_INS, the sub-span-diff case +DIFF_SUBDIFF, the atom-degradation case DIFF_DEGRADE, the removed-atom case +DIFF_REMOVE, the coarsening case OWN_COARSEN, the unique-sub-span case OWN_UNIQUE, +the no-sub-span fallback case OWN_NOSUB, the no-op bundle case BUNDLE_NOOP, and the +labelled-bundle case BUNDLE_RICH, each its own dedicated test. -## Working-tree reads, byte-exact retrieval, and pre-add validation +Inside a case's dedicated test, hand the code exactly the fixture that case gives, +byte for byte, and assert its pinned expectation verbatim: no shorter or more +convenient stand-in, no re-derivation of a value the case pins as a literal, and no +weakening of a pinned equality into an inequality-only or recomputed check. The +byte-pinned bundle cases assert their exact literals: BD_EMPTY renders +`bundle_bytes` equal to +`{"module":"greeting","ownedNew":[],"ownedOld":[],"promptChanges":[],"unowned":[]}` +plus one newline and names its blob +`6dc9ca1a0fe393bbed56cce83d42aadded789fac58c3f5ef22d2c13bf25d1bdc`; BD_RICH renders +`bundle_bytes` equal to its pinned rich literal and names its blob +`8e42bc109b1229962ccfcecb1aced8316d00145d46129e88e4c0fc71c7f2eee7`. Where a case +pins bundle bytes, obtain them from `bundle_bytes` and compare to the literal; +where a case pins a blob name, obtain it from `bundle_blob_name` and compare to the +literal. The BD_EMPTY blob-name test asserts only that pinned digest, leaving the +canonical-bytes shape to the case that owns it. Each round-trip case builds its own +named bundle, renders its canonical bytes, recovers a `Bundle` through +`validate_bundle` over the parsed bytes, and asserts the recovered value equals the +original verbatim, never weakening that equality into a mere `is_ok` acceptance and +never substituting a lighter or neighboring fixture. -Three reads exist so that a tool amending a prompt can read exactly what -it is amending, retrieve the exact bytes it is about to splice, and -validate the result before anything stages, without a single raw file -read. All three are pure reads: no store writes, no locks, no clock, no -staging. Each is a pure function of the committed state plus the on-disk -prompt bytes it explicitly names, and byte-stable across runs over the -same inputs. +The rejection cases construct exactly the malformed JSON the case describes, built +by taking the named fixture's JSON and applying only the one described mutation +using that mutation's exact literal tokens, and assert the pinned error variant. +The mutation literal is transcribed verbatim, never paraphrased. Every extra-key +case inserts the key named `bogus` with the integer value `1`, exactly +`"bogus": 1`, never a renamed key such as `extra` and never a restyled value such +as the string `"nope"`, and no extra-key case discharges another. Each extra-key +case starts from its own named base fixture and inserts `bogus` at exactly its own +locus: the bundle top-level case starts from BD_EMPTY and adds the key at the object +top level; the changed-block case starts from BD_RICH and adds it to the single +`promptChanges` block; the occurrence-side case starts from BD_RICH and adds it to +that block's `old` side; the owner case starts from BD_RICH and adds it to the first +`ownedNew` record's `owner`; the ownership-record case starts from BD_RICH and adds +it to the first `ownedNew` record; the code-change case starts from BD_RICH and adds +it to the single `unowned` code change; the attribution top-level case starts from +the attribution object and adds it at the top level; and the ownership-entry case +starts from the attribution object and adds it to the single entry. These are eight +separate dedicated tests. The bundle top-level case never substitutes BD_RICH for +BD_EMPTY, and the ownership-record case never mutates `ownedOld` in place of the +first `ownedNew` record. The malformed-hash case sets the changed +block's old occurrence `contentHash` to exactly `"xyz"`. The named-substitution +prohibitions for this module's confusable tokens are strict: never write `added` +where a case pins `removed` or `modified`, nor `atom` where a case pins `subSpan`, +nor `ownedOld` where a case pins `ownedNew` (or the reverse), nor `oldHash` where a +case pins `newHash`, nor `promptLines` where a case pins `lines`, nor `lineStart` +where a case pins `lineEnd`, nor `atomIndex` where a case pins `subIndex`; each +confusable token is transcribed exactly as its case names it. The +`InconsistentChange` cases each build a bundle with one block: the added-with-old +block sets `change` `added` with both `old` and `new` present; the removed-with-new +block sets `change` `removed` with both present; the modified-missing-new block +sets `change` `modified` with only `old` present; each asserts `InconsistentChange` +and none discharges another. The `InconsistentOwner` cases mutate BD_RICH's first +`ownedNew` owner: the atom-with-subIndex case sets its `level` to `atom` while +keeping its `subIndex` `1`, and the subSpan-without-subIndex case removes its +`subIndex` while keeping `level` `subSpan`; each asserts `InconsistentOwner`. The +occurrence-side `InvalidLineRange` case sets the old side's `lineStart` to `9` with +`lineEnd` left at `2`; the attribution `InvalidLineRange` case sets an entry's +`promptLines` to `[4, 1]`; each asserts `InvalidLineRange` through the matching +validator (`validate_bundle` for the occurrence side, `validate_attribution` for +the attribution). -`napl q --working-tree` builds the canonical document with every -prompt-derived fact enumerated from the on-disk prompt files instead of -the committed blobs: frontmatter, sections, `::: test` directive cases, -and body line ranges all reflect the pending working-tree prompt, -including sections and cases that exist only in the working tree. -Store-derived facts are unchanged and still committed: `head`, module -states, history, receipts. The top level carries `workingTree: true`, -and each section carries `workingTree: true` in place of a committed -`head` value, so a working-tree ref can never be mistaken for a -committed one and no hash is ever invented for uncommitted bytes. -Section file associations are the committed map's claims transported -through the prompt's committed-to-pending diff, the same deterministic -transport `add` uses: an association whose prompt range transports -cleanly is served intact, and a section whose range lands in a -rewritten or ambiguous region serves `files: []` with -`associationUnproven: true`. The view never re-associates files by -range arithmetic over the re-anchored body; an overlap that happens to -exist in the pending text is not a claim, and serving it as one would -be inventing a mapping nobody made. -Section-to-file association under a stale attribution behaves exactly as -today: sections carry their cases, `associationStale: true` marks the -pairing untrustworthy, and nothing pretends freshness. Reference -staleness fields serve exactly as in the committed document. - -`napl prompt --working-tree` resolves `promptPath` refs against the -on-disk prompt file instead of a committed blob. A working-tree ref -carries `workingTree: true` and no `head`; a ref carrying both, or a mix -of working-tree and committed refs in one invocation, is a usage error -naming the input line. Everything else about materialization holds: -provenance headers, law closure from committed state, case rendering, -error shapes. - -`napl prompt --bytes` is byte-exact retrieval, the inverse of the edit a -tool is about to make: the ref's line range materializes as the exact -source bytes of that range, no reflow, no case rendering, no law -closure, no provenance header, one trailing newline. It accepts exactly -one ref; more than one is a usage error. It composes with -`--working-tree` so a splice can be computed against the pending file. -The bytes returned are byte-identical to the named lines of the source, -so retrieve-then-splice is deterministic by construction. - -`napl check ` is dry validation of an on-disk prompt file -before `add`: frontmatter shape and module-name rules per the napl -contract, body normalization, directive grammar for every `::: test` -block including canonical JSON in `given` and `expect`, and case-name -uniqueness. The module-name rules are enforced as name grammar, not -merely as YAML: a frontmatter value that parses as a string but can -never be a legal module name (path-metacharacters, a bare `[`, empty -after trimming) is a finding at check time, never a surprise later. -Findings are canonical JSON on stdout, each naming the file, -the line, and what failed; every reported line is the physical -1-based line in the file as an editor counts it, frontmatter -included, never a body-normalized offset. Exit codes are pinned: 0 for a valid prompt, -1 when findings exist, 2 for usage including an unreadable path. The -store is never touched and nothing stages. `check` passing means the -prompt parses under the same reader `add` uses; it does not predict -`add` outcomes that depend on candidates, attribution, or placement, and -its wording never implies otherwise. `check` teaches while it refuses: -the finding for a missing opening frontmatter fence carries one -complete minimal valid prompt inline, so the first error a cold user -sees contains the whole grammar they were missing; and a checked file -whose name lacks the `.napl` extension gets a finding saying discovery -will never find it, naming the extension, whatever else is valid about -its content. - -::: test -name: q working-tree enumerates a section that exists only in the working tree -given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } -expect: { new_section_enumerated: true, top_level_working_tree: true, committed_document_unchanged: true } -::: - -::: test -name: working-tree sections carry no committed head and mark themselves -given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } -expect: { section_working_tree: true, section_head_absent: true } -::: - -::: test -name: the working-tree document is byte-stable across runs -given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, runs: 2 } -expect: { bytes_equal: true } -::: - -::: test -name: a transported section keeps its files and a rewritten one serves none unproven -given: { workspace: fixture_two_modules, working_tree_edit: shift_one_section_rewrite_another, flags: [--working-tree] } -expect: { shifted_section_files_intact: true, rewritten_section_files: [], rewritten_section_association_unproven: true, overlap_reassociation: 0 } -::: - -::: test -name: prompt bytes round-trips the exact source bytes of a range -given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf, flags: [--bytes] } -expect: { bytes_equal_raw_slice: true, reflow: false, headers: false } -::: - -::: test -name: prompt bytes refuses more than one ref as a usage error -given: { stdin_lines: [ref_a, ref_b], flags: [--bytes] } -expect: { exit_code: 2, error_names_line: 2 } -::: - -::: test -name: a ref carrying both a head and working-tree is a usage error naming its line -given: { stdin_lines: [ref_with_head_and_working_tree], flags: [--working-tree] } -expect: { exit_nonzero: true, error_names_line: 1 } -::: +Each algorithmic case builds the exact fixture its case names and asserts the +returned value equals the pinned list exactly, element for element in the pinned +order, never weakening the equality into a length-only or membership check and +never reordering. The DIFF_INS case asserts a single added atom block at new atom +index 0 with new text `Zeta line.\n`, and asserts explicitly that no block mentions +the anchored atoms `A`, `B`, or `C`, so the no-churn property is proven by the +absence of any suffix change and not merely by a happy total. The DIFF_SUBDIFF case +asserts a single modified sub-span block at atom 0 sub-index 1 whose old text is +`A dog ran.\n` and new text is `A fox ran.\n`, and asserts the shared first +sub-span produced no block. The DIFF_DEGRADE case asserts a single modified +atom-level block at atom 0 whose old text is `Single line only.\n` and new text is +`Single line only. And more text.\n`, and asserts the block's `level` is `atom`, +never `subSpan`. The DIFF_REMOVE case asserts a single removed atom block at old +atom index 1 with old text `Beta line.\n`. The OWN_COARSEN case asserts a single +ownership record whose owner is an `Atom` owner at index 0, never a `SubSpan` owner, +proving the two-sentences-on-one-line coarsening. The OWN_UNIQUE case asserts a +single ownership record whose owner is a `SubSpan` owner at atom 0 sub-index 1. The +OWN_NOSUB case asserts a single ownership record whose owner is an `Atom` owner at +index 0. The BUNDLE_NOOP case asserts every bundle list, `promptChanges`, +`ownedOld`, `ownedNew`, and `unowned`, is empty, so the code side is empty. The +BUNDLE_RICH case asserts `ownedOld` and `ownedNew` each carry the one `greeting.rs` +record owned by the changed sub-span, and asserts `unowned` carries exactly the one +`extra.rs` code change, labelling the three sides distinctly. The +BUNDLE_MISSING_ROOT case passes an old tree root absent from the object map and +asserts `MissingObject`. -::: test -name: check passes a valid prompt with exit zero and no findings -given: { workspace: fixture_two_modules, prompt: leaf_prompt_valid } -expect: { exit_code: 0, findings: 0, store_bytes_unchanged: true } -::: +Closing mandate on one-to-one case fidelity. Every case in the frontmatter test +suite above, without exception, is realized by exactly one dedicated test that +hands the code that case's own fixture with its literal values copied verbatim from +the case, and asserts that case's own pinned expectation, neither narrower nor +recomputed. A test that inspects a different or narrower property than its case +declares, that swaps in a lighter or neighboring fixture, that weakens a pinned +equality into an `is_ok` or inequality-only check, or that reuses a sibling case's +values, does not discharge the case and leaves it untested. Two cases that share a +shape but differ in a fixture value or in the property asserted are two separate +cases, each owed its own verbatim test: the three `InconsistentChange` cases never +discharge one another, the two `InconsistentOwner` cases never discharge one +another, the two `InvalidLineRange` cases never discharge one another, the eight +extra-key `Malformed` cases never discharge one another, the DIFF_SUBDIFF sub-span +case never collapses into the DIFF_DEGRADE atom case, and the OWN_COARSEN atom-owner +case never reuses the OWN_UNIQUE sub-span-owner values. Supplemental tests beyond +the suite are named for what they add and are never counted as, nor substituted +for, any suite case's dedicated test. The number of dedicated tests is therefore at +least the number of frontmatter cases, and each case above is discharged by its own +verbatim test. -::: test -name: check reports a malformed directive with a finding naming its line -given: { workspace: fixture_two_modules, prompt: leaf_prompt_broken_directive } -expect: { exit_code: 1, finding_names_line: true, store_bytes_unchanged: true } -::: +Write no explanatory line or block comments in the generated code, and no doc +comment on any private item. The crate keeps its module-level `//!` summary. Every +public type, every public enum variant, and every public function carries a `///` +doc comment restating its contract; a public struct field carries a `///` doc +comment only where its name alone does not convey the contract, and simply named +fields may carry none. The private helper functions and private constants of this +module carry no doc comment. Beyond the module summary and these public-item doc +comments the code carries no commentary of any kind. Use no em dashes anywhere in +the generated code, including inside string literals and doc comments. -::: test -name: the missing-fence finding carries a complete minimal prompt that itself passes check -given: { workspace: fixture_two_modules, prompt: file_without_frontmatter } -expect: { exit_code: 1, finding_example_passes_check: true } -::: +## migrate_core -::: test -name: a checked file without the napl extension is told discovery will never find it -given: { workspace: fixture_two_modules, prompt: valid_content_named_greeting_md } -expect: { exit_code: 1, finding_names_extension: ".napl", store_bytes_unchanged: true } -::: +# Migrate core: the audit classifier, the sanction report, and the migration plan -::: test -name: a module name that yaml accepts but the grammar forbids is a finding -given: { workspace: fixture_two_modules, prompt: frontmatter_module_bare_bracket } -expect: { exit_code: 1, finding_names_module_grammar: true } -::: +This module is the pure brain of the one-shot ledger migration. It reads the legacy +`journal.jsonl` as data, classifies how faithfully each file's history can be +reconstructed, renders the human-facing audit report the maintainer sanctions +before any live migration, and assembles the content-addressed objects that the +migrated store is built from. It does all of this with no filesystem, no clock, and +no randomness: every input is handed in, every output is a value, and the same +inputs always produce the same bytes. The command shell that reads and writes files +lives in the sibling `migrate_io` module; nothing here touches disk. -::: test -name: findings report physical file lines as an editor counts them -given: { workspace: fixture_two_modules, prompt: empty_module_value_on_file_line_2 } -expect: { finding_line: 2, line_kind: physical_one_based } -::: -napl-store blob v1 23619 -:::docs{title="Querying the store" description="Read everything napl knows with one verb."} -Everything napl knows lives in one canonical JSON document: modules, -sections, owned files, attribution, test cases, history. `napl q` -evaluates a jq expression against that document, and `napl prompt` -builds agent context from the same tree. There is no other read -surface to learn, ever. -::: -# query: the canonical document, napl q, and napl prompt +Bring in `serde` and `serde_json`. -The language answers every question through one query surface and builds -agent context through one prompt builder. This module owns both commands -and the document they share. Everything here is a pure read: no store -writes, no locks held, no clock, no randomness, no network (P1). Reads -serve committed records, with two stated exceptions: reference staleness -is computed at read time from the working tree as well as the committed -bytes, so a hand edit to either end of a reference reads stale without -anything being stored; and the working-tree read mode below serves the -pending prompt when asked to explicitly. The same committed state plus -the same working tree produces the same bytes. This module owns `src/query.rs` (taken over -from the commands module), plus the document builder and prompt builder -sources it introduces. +## Where this code lives -## The canonical document +The working directory is a Cargo workspace whose root manifest is written and owned +by the toolchain, leave it alone. Create this module as its own member crate in a +subdirectory named `migrate_core/`: `migrate_core/Cargo.toml` (package name +`migrate_core`) and `migrate_core/src/lib.rs`. Touch nothing outside +`migrate_core/`. Ensure `cargo test` passes from the workspace root before +finishing. -One JSON value, `docVersion 1`, built at query time from committed state: -the store head, module states, attribution records, staged-evidence usage -inside receipts, recorded surfaces, and the committed prompt bytes. Top -level: `docVersion`, `head` (the snapshot hash), `modules`, `history`, -`state`. +## Builds on two modules of this workspace -Each module carries `name`, `promptPath`, `deps`, `targets`, -`surfaceHash`, `pending`, `attribution` (an object whose `status` is -`fresh`, `stale`, or `absent`), `sections`, `frontmatterCases` (cases -declared in frontmatter `tests:`, which have no body position), and -`files` (owned output paths), and `outline` — the prompt body's -headings in order, each `{text, level, line}` with the line -body-normalized and 1-based, so "which body line is this heading on" -is a query, never a raw file read. Sections are the module's attribution -entries, and every section is a SELF-CONTAINED REF: it carries `head`, -`module`, `promptPath`, `lines` (body-normalized, 1-based, inclusive), -`note`, `law` (true exactly when the entry owns no file), `files` (each -`{path, lines, head}`), `direction` (`prompt_to_code` or -`code_to_prompt`, per the reference contract in the napl module; entries -recorded before directions read `prompt_to_code`), `provenance` (an -object with `model`, `timestamp`, `toolVersion`, and `usage`, each -absent field served as `unavailable`, never invented), `referenceStatus` -(`fresh` or `stale`), `staleEndpoints` (empty when fresh, `code` and -`prompt` when stale), and `cases` — the `::: test` directive cases -whose block range intersects the section's range, each -`{name, given, expect, lines}` with values in canonical JSON. A directive -case intersecting no entry lands in a synthetic section with empty -`files`. When a module's attribution is stale, sections still carry -their cases (cases derive from prompt bytes, not from attribution) but -each section carries `associationStale: true` and the module's status -says `stale`: the section-to-file pairing cannot be trusted, the case -data can. +This crate composes on two generated sibling crates. Use each one's public API, do +not reimplement its types or logic, and do not depend on any hand-written crate. Do +not bring in the `sha2` crate directly; all hashing goes through `store_hash`. -Each file ref inside a section carries `claim`, `whole` when the entry -owns the file with no line range and `range` when it names one, so a -consumer never guesses whether `lines` bounds the claim or merely -samples it. +- **`store_hash`** (`../store_hash`) supplies the typed envelope framing, the + canonical JSON encoding, and the SHA-256 object naming. It uses + `store_hash::ObjectKind` (the variants `ObjectKind::Blob`, `ObjectKind::Event`, + `ObjectKind::ModuleState`, and `ObjectKind::Snapshot`), + `store_hash::canonical_json(value: &serde_json::Value) -> Vec`, + `store_hash::envelope_bytes(kind: store_hash::ObjectKind, payload: &[u8]) -> + Vec`, and `store_hash::object_name(kind: store_hash::ObjectKind, payload: + &[u8]) -> String`. This module never reimplements canonical JSON, envelope + framing, or hashing; it calls `store_hash` for all four. +- **`store_objects`** (`../store_objects`) supplies the provenance object types and + the naming functions over them. It uses `store_objects::ModuleState`, + `store_objects::TargetArtifacts`, `store_objects::StateRef` (the variants + `StateRef::State(String)` and `StateRef::Unknown`), `store_objects::Event`, + `store_objects::EventPayload` (the variant `EventPayload::Legacy`), + `store_objects::LegacyFields` (public fields `original_line: String` serde + `originalLine`, and `classification: store_objects::LegacyClassification`), + `store_objects::LegacyClassification` (the variants `Exact`, `MissingPrefix`, + `InconsistentBefore`, `InconsistentAfter`, `Unreconstructable`, serializing to the + wire tokens `exact`, `missing-prefix`, `inconsistent-before`, + `inconsistent-after`, and `unreconstructable`), `store_objects::Snapshot`, and + `store_objects::RootEntry` (public fields `module_state: String` serde + `moduleState`, and `event_head: String` serde `eventHead`). This module reuses + `store_objects::LegacyClassification` as its own classification result type, and + it does not define a second classification enum. A built object's name is obtained + through `store_objects::module_state_object_name`, + `store_objects::event_object_name`, and `store_objects::snapshot_object_name`, and + those names must equal the names this module frames through `store_hash` over the + same canonical payload. -Docs fences from the napl module's directive law surface here per -section: `sections[].docs` is the ordered list of docs payloads whose -fence sits inside the section's line range, each -`{directive, attrs, lines, body}` with inner fences verbatim in the -body. The module preamble fence, when present, is served at -`modules[].docs` beside the sections. Docs `lines` are physical prompt -coordinates, the one place physical coordinates appear in the -document, because docs fences are excluded from the semantic body that -every other coordinate speaks in; the document states both coordinate -systems explicitly. A module with no docs fences serves empty docs -lists, never null. +The legacy classification wire tokens are frozen by `store_objects`; this module +never spells them itself except where the audit report renders them as text, and +those report spellings are exactly those five tokens. -The document's freshness verdicts and the language server's are one -analysis: a section the LSP would squiggle as reference-stale reads -`referenceStatus: stale` here with the same endpoints, never a -hardcoded fresh. +## Content hashes are plain strings -History entries carry the transition hash, module, target, outcome, and -`usage` — `{model, input, output, cacheRead}` from the staged record's -validated usage evidence, or null when none was supplied; absent -telemetry is null, never zero. +Every content hash, whether a legacy file hash out of the journal or a +content-addressed object name, is carried as a `String`, never a newtype. A legacy +file hash and an object name are both sixty-four lowercase hexadecimal characters +(`0` through `9`, `a` through `f`), but this module compares them only for equality +and never recomputes a legacy file hash: the journal's `hashBefore` and `hashAfter` +are authoritative recorded values, and reconstructing historical file content is +impossible (the working tree holds only the latest content), so classification is a +pure comparison of recorded hashes and the current file hash, never a patch +application. -Canonical serialization throughout: sorted keys, total ordering for every -array (modules by name; sections by start then end line; files and cases -by path and name; history by transition sequence, transition hash as -tiebreaker), no absolute paths anywhere (`promptPath` and file paths are -workspace-relative), one trailing newline. The same committed state -produces the same document bytes on any machine. +## The parsed journal -::: test -name: q with no arguments emits the canonical document byte-stably -given: { workspace: fixture_two_modules, runs: 2 } -expect: { bytes_equal: true, top_level_keys: [docVersion, head, history, modules, state] } -::: +A legacy journal is a sequence of newline-delimited JSON lines. Only the fields that +classification needs are read; every other field is ignored, and the untouched line +text is preserved verbatim for its blob. Expose a public struct `FileStep` +(deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with public fields `path: String`, +`hash_before: Option`, and `hash_after: Option`: one file's recorded +before and after hashes in one entry, each `None` when the journal recorded JSON +`null` (a creation has a `None` before; a deletion has a `None` after). -::: test -name: the document contains no absolute paths -given: { workspace: fixture_two_modules } -expect: { absolute_paths: 0 } -::: +Expose a public struct `JournalEntry` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`) with public fields `line: u64` (the one-based line number in the file), `gen: +u64`, `module: String`, `target: Option`, `files: Vec`, and `raw: +String` (the line's exact original text, without its terminating newline). Expose a +public struct `CorruptLine` (same derives) with public fields `line: u64` and `raw: +String`. -::: test -name: directive cases appear inside their owning section -given: { workspace: fixture_with_directive_case, attribution: fresh } -expect: { section_cases: 1, case_fields: [expect, given, lines, name] } -::: +Expose a public struct `ParsedJournal` (same derives) with public fields `entries: +Vec` and `corrupt: Vec`, both in ascending line order. +Expose `parse_journal(text: &str) -> ParsedJournal`. Split `text` on the newline +character into lines numbered from one; a trailing empty line produced by a final +newline is not a line and is dropped, and an entirely empty input yields an empty +journal. For each line, attempt to read it as a JSON object carrying an integer +`gen`, a non-empty string `module`, and an array `files` whose every element is an +object with a string `path` and both a `hashBefore` field and a `hashAfter` field, +each present and carrying either JSON `null` or a string. Both hash fields must be +present: presence is required and an absent field is never coerced to `null`. A JSON +`null` is a legitimate recorded value that a present field carries, a `None` before +marking a creation and a `None` after marking a deletion, but a file element that +omits either `hashBefore` or `hashAfter` entirely fails the shape check, because a +step whose before or after was never recorded cannot be verified and must not pass as +a creation or a deletion. A line meeting that shape becomes a `JournalEntry` (its +`target` taken from a string `target` field when present, otherwise `None`; its +`raw` the verbatim line text); any line that is not valid JSON, is not a JSON +object, or fails any of those shape checks, including a file element that omits +either hash field, becomes a `CorruptLine`, which the audit counts and reports as +`unreconstructable` under its `line:` key like every other corrupt line. Unknown extra +fields on the object or on a file element are ignored, never a parse failure: the +real ledger carries `timestamp`, `promptHash`, `promptDiff`, `mode`, and per-file +`patch` fields this module does not read. ::: test -name: stale attribution keeps cases and marks the association stale -given: { workspace: fixture_with_directive_case, attribution: stale } -expect: { section_cases: 1, association_stale: true, module_status: stale } +name: a well-formed journal line parses into an entry with its path chain steps +given: { fixture: LINE_VALID } +expect: { parsed_ok: true, gen: 1, module: "greeting", target: "rust", paths: ["g.rs"] } ::: ::: test -name: section refs are self-contained -given: { workspace: fixture_two_modules } -expect: { section_fields_present: [direction, head, lines, module, promptPath, provenance, referenceStatus, staleEndpoints] } +name: a journal line that is not valid json is a corrupt line +given: { fixture: LINE_CORRUPT_JSON } +expect: { parsed_ok: false, corrupt: true } ::: ::: test -name: usage from staged evidence appears in history entries -given: { workspace: fixture_two_modules, commit_with_usage: true } -expect: { history_usage_fields: [cacheRead, input, model, output], absent_usage: null } +name: a json line missing the required gen module or files shape is a corrupt line +given: { fixture: LINE_CORRUPT_SHAPE } +expect: { parsed_ok: false, corrupt: true } ::: ::: test -name: a section's docs payloads arrive in order with their bodies verbatim -given: { workspace: fixture_with_docs_fences } -expect: { docs_fields: [attrs, body, directive, lines], order_preserved: true } +name: a journal line carrying extra fields beside the classification fields still parses +given: { fixture: LINE_EXTRA_FIELDS } +expect: { parsed_ok: true, gen: 3, module: "greeting", paths: ["g.rs"] } ::: ::: test -name: a module preamble fence serves title and description in module docs -given: { workspace: fixture_with_module_preamble } -expect: { module_docs_attrs: [description, title] } +name: a journal line whose file element omits the hashBefore field entirely is a corrupt line +given: { fixture: LINE_MISSING_BEFORE } +expect: { parsed_ok: false, corrupt: true } ::: ::: test -name: a module with no docs fences serves empty docs lists -given: { workspace: fixture_two_modules } -expect: { module_docs: [], section_docs_empty: true, nulls: 0 } +name: a journal line whose file element omits the hashAfter field entirely is a corrupt line +given: { fixture: LINE_MISSING_AFTER } +expect: { parsed_ok: false, corrupt: true } ::: ::: test -name: file refs carry the claim discriminator -given: { workspace: fixture_two_modules } -expect: { file_claims_subset_of: [range, whole] } +name: an explicit null hashBefore parses as a valid creation step keeping its recorded hashAfter +given: { fixture: LINE_NULL_BEFORE } +expect: { parsed_ok: true, gen: 1, module: "greeting", paths: ["c.rs"], hash_before: null, hash_after: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } ::: ::: test -name: reference staleness in the document matches the language server verdict -given: { workspace: fixture_with_stale_reference } -expect: { reference_status: stale, lsp_parity: true, hardcoded_fresh: false } +name: an explicit null hashAfter parses as a valid deletion step keeping its recorded hashBefore +given: { fixture: LINE_NULL_AFTER } +expect: { parsed_ok: true, gen: 1, module: "greeting", paths: ["d.rs"], hash_before: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", hash_after: null } ::: -## napl q - -`napl q` is shaped exactly like jq and IS jq: expressions are evaluated -by an embedded jq engine (the jaq crate, version pinned by the lockfile, -upgraded only through a normal committed change). Impure builtins are -rejected at expression compile time (`env`, `$ENV`, `now`, `input`, -`inputs`, `debug`, `stderr`, `input_line_number`, and anything else doing -I/O or randomness), so an expression is a pure function of its input. - -Argv grammar, exactly three forms; more than one input is a usage -error; `--` is accepted before a path starting with a dash; the -expression is always required and there is deliberately no shorthand -for identity anywhere, because the primary caller is a language model -session and a bare invocation that dumps the whole document is a -context bomb, not a convenience; the usage error for a missing -expression teaches `.` as the explicit spelling and jq demands its -filter the same way: - - napl q expression over the canonical document - napl q - expression over stdin - napl q expression over a JSON file +## The five classifications -Input may be a single JSON value or a stream of values (JSONL); the -expression applies per value exactly as jq does. Each result value is -canonically serialized, one per line; `-r`/`--raw-output` emits string -results as their raw contents exactly as jq does, one result per -output line with exactly one newline between results and one at the -end, never doubled when the string itself ends in one, so shell -pipelines never need a dequoting round trip and a raw result diffs -clean against the file it came from. An empty result stream is exit 0 -with no output. Because canonical serialization is already one compact -value per line, jq's `-c`/`--compact-output` is accepted as a -harmless synonym of the default: it changes nothing and never errors, -so the jq reflex costs no usage round trip; `-c` composes with `-r` -exactly as it does in jq, where raw string output ignores it. Errors are canonical JSON on stderr with pinned exit -codes: 2 usage, 3 expression parse or refused builtin, 4 input parse, 5 -runtime or type error. Evaluation carries a compiled-in output ceiling of -64 MiB, enforced functionally and surfaced as `resource-limit` with its -existing exit code; recursion depth is bounded by the embedded engine's -own evaluation stack rather than a compiled-in constant, and an -expression exceeding it aborts rather than returning wrong data. Piping any napl command's -canonical JSON back through an expression works by construction: -`napl status | napl q '.pending' -`. +A **path chain** is every recorded step for one file path across the whole journal, +in ascending `gen` order. Expose a public struct `ChainStep` (deriving `Debug`, +`Clone`, `PartialEq`, `Eq`) with public fields `gen: u64`, `hash_before: +Option`, and `hash_after: Option`. -:::docs -`napl q` is jq over the store: same expression language, same flags, -same reflexes. The expression is required; `.` spells the whole -document explicitly. -::: +Expose `classify_chain(steps: &[ChainStep], current_hash: Option<&str>) -> +store_objects::LegacyClassification`. The `steps` are already in ascending `gen` +order and non-empty. The result is the worst condition that holds, under the +severity order `Exact` (least severe), then `MissingPrefix`, then +`InconsistentBefore`, then `InconsistentAfter`, then `Unreconstructable` (most +severe). A path chain never returns `Unreconstructable`; that classification is +reserved for corrupt lines, which carry no parseable path. Compute the result by +starting at `Exact` and taking the worse of the running result and each condition +that applies: -:::example{name="read the head"} -napl q '.head' -::: +- **MissingPrefix** applies when the first step's `hash_before` is not `None`: the + chain begins mid-history, its creating prefix removed from the ledger (this is the + surgical-removal scar's expected shape, accepted and documented). +- **InconsistentBefore** applies when some step after the first has a `hash_before` + that is not equal to the immediately preceding step's `hash_after`: an internal + linkage break. +- **InconsistentAfter** applies when the last step's `hash_after` is not equal to + `current_hash` (comparing an `Option`, so a recorded `None` terminal matches a + `None` current, and any mismatch, including present-versus-absent, applies): the + ledger's final recorded state disagrees with the file on disk now. -:::example{name="list every module"} -napl q '.modules[].name' -::: +When none of these applies the chain is `Exact`: it begins at a creation, links +step to step, and ends at the current file. Taking the worse of the applicable +conditions means a chain that both begins mid-history and ends disagreeing with the +current file classifies as `InconsistentAfter`, the more severe of the two. -:::note -Piping works by construction: any napl command's JSON output feeds -back through an expression, as in `napl status | napl q '.pending' -`. -::: +Expose `classification_severity(classification: store_objects::LegacyClassification) +-> u8`, returning `0` for `Exact`, `1` for `MissingPrefix`, `2` for +`InconsistentBefore`, `3` for `InconsistentAfter`, and `4` for `Unreconstructable`, +the total severity order the classifier and the abort comparison both use. ::: test -name: q evaluates a jq expression over the document -given: { workspace: fixture_two_modules, expr: ".modules[].name" } -expect: { output_lines: [consumer, leaf] } +name: an exact creation chain classifies as exact +given: { fixture: CH_EXACT_CREATE } +expect: { classification: "exact" } ::: ::: test -name: q accepts the compact flag as a synonym of the default output -given: { workspace: fixture_two_modules, invocations: ["q .pending", "q -c .pending", "q --compact-output .pending"] } -expect: { outputs_byte_identical: true, exit_codes: [0, 0, 0] } +name: an exact multi-step chain classifies as exact +given: { fixture: CH_EXACT_MULTI } +expect: { classification: "exact" } ::: ::: test -name: q without an expression is a usage error teaching the explicit dot -given: { workspace: fixture_two_modules, invocations: ["q", "q -c", "q -r"] } -expect: { exit_codes: [2, 2, 2], error_teaches_dot: true, document_dumped: false } +name: a chain whose first step has a non-null before classifies as missing-prefix +given: { fixture: CH_MISSING_PREFIX } +expect: { classification: "missing-prefix" } ::: ::: test -name: q compact composes with raw output changing nothing -given: { stdin: "{\"a\":\"x\"}", expr: ".a", input: "-", flags: ["-r", "-c -r"] } -expect: { outputs_byte_identical: true, raw_string_unquoted: true } +name: a chain with an internal linkage break classifies as inconsistent-before +given: { fixture: CH_INCONSIST_BEFORE } +expect: { classification: "inconsistent-before" } ::: ::: test -name: q evaluates an expression over stdin when the input is a dash -given: { stdin: "{\"pending\":[\"m\"]}", expr: ".pending", input: "-" } -expect: { output: "[\"m\"]" } +name: a chain whose terminal after disagrees with the current hash classifies as inconsistent-after +given: { fixture: CH_INCONSIST_AFTER } +expect: { classification: "inconsistent-after" } ::: ::: test -name: q rejects an invalid expression without touching the store -given: { workspace: fixture_two_modules, expr: ".[unclosed" } -expect: { exit_code: 3, store_bytes_unchanged: true } +name: a missing-prefix start whose terminal also disagrees takes the worse inconsistent-after +given: { fixture: CH_MISSING_PREFIX_AND_AFTER } +expect: { classification: "inconsistent-after" } ::: ::: test -name: q refuses impure builtins -given: { workspace: fixture_two_modules, expr: "now" } -expect: { exit_code: 3, error_names_builtin: true } +name: the classification severity order runs exact below missing-prefix below inconsistent-before below inconsistent-after below unreconstructable +given: { probe: severity_order } +expect: { ordered: true } ::: -::: test -name: q raw output emits string contents without quotes exactly as jq dash r -given: { workspace: fixture_two_modules, expr: ".modules[0].name", flags: [-r] } -expect: { output: leaf_unquoted, trailing_newline: true } -::: +## The audit result and its report -::: test -name: the document serves a heading outline with body line numbers -given: { workspace: fixture_two_modules } -expect: { outline_present: true, outline_entry_fields: [level, line, text], lines_body_normalized: true } -::: +Expose a public struct `AuditResult` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`) with public fields `entries: u64` (the count of parseable entries), +`corrupt_lines: Vec` (the one-based line numbers of corrupt lines, ascending), +`chains: std::collections::BTreeMap` +(each distinct path mapped to its chain classification; a `BTreeMap` so paths are in +sorted order), and `current_state_only: Vec` (the module names that the caller +declared in the module inventory but that appear in no parseable journal entry, in +ascending byte order and deduplicated). A current-state-only module is the emptied-mapl +scar seen from the audit: a module the repository still carries on disk yet whose +recorded history is entirely gone, so the ledger holds no event for it. This is a +distinct fact from any path chain, and it is never folded into `chains`, the corrupt +lines, or the per-classification counts. -## napl prompt +Expose `audit(journal: &ParsedJournal, current_hashes: +&std::collections::BTreeMap, inventory: +&std::collections::BTreeSet) -> AuditResult`. Group every entry's +`files` by `path` into path chains, each chain's steps built from the entries in +ascending `gen` order, and classify each chain with `classify_chain` against the +path's current hash (looked up in `current_hashes`, absent meaning `None`). The +`corrupt_lines` are the corrupt lines' numbers, and `entries` is the count of +parseable entries. The `inventory` is the caller's declared module set (the modules the +map declares); `current_state_only` is exactly the inventory modules that are the +`module` of no parseable journal entry, in ascending order, so a module the inventory +declares but the journal never mentions is surfaced as current-state-only. An empty +inventory yields an empty `current_state_only`, and a module present in even one +parseable entry is never current-state-only. Corrupt lines, whose module cannot be +read, never remove a module from the current-state-only set. -`napl prompt` (alias `napl p`) is the prompt builder: it reads refs from -stdin — the objects a `napl q` stage selected — and emits markdown built -from committed state at the refs' snapshot, never current head, and -never the working tree except under the explicit `--working-tree` mode -defined in the working-tree reads section. Stdin is typed-ref JSONL: one JSON object per line -carrying `head`, `lines`, and exactly one of `path` or `promptPath`; -unknown extra fields are ignored. A line that is not an object, carries -both path kinds or neither, or has a non-integer, zero, reversed, or -out-of-range line range is an error naming the input line number, never a -silent skip. Empty stdin is an error saying nothing was selected. Refs -naming more than one head in a single invocation error. Duplicate refs -(same head, path, range) emit once; input order is otherwise preserved. +Expose `render_audit_report(result: &AuditResult) -> String`, the maintainer's +sanction artifact, deterministic and fully sorted. The exact grammar, every literal +byte-for-byte: -Materialization reads the snapshot's content-addressed blobs; committed -prompts and committed module outputs are the only eligible paths. A -`{path, lines}` ref resolves to its owning module through the snapshot's -output manifests — owned globs are disjoint by commit invariant, so -ownership is unique, and a discovered overlap is a store-integrity -failure, not a guess. A prompt ref yields specification prose; a file ref -yields code: one resolver, both directions. Every chunk gets a provenance -header naming `path:start-end` plus the module and note when known. -Cases render after their section's prose as canonical JSON code blocks -inside dynamically sized fences, immune to backticks in values. +- The first line is exactly `napl migrate audit v1`. +- Then `entries ` followed by the decimal `entries` count. +- Then `corrupt-lines ` followed by the decimal count of `corrupt_lines`. +- Then `chains ` followed by the decimal total chain count, which is the number of + distinct path chains plus the number of corrupt lines. +- Then the five per-classification count lines, in the fixed order `exact`, + `missing-prefix`, `inconsistent-before`, `inconsistent-after`, + `unreconstructable`, each the wire token, a space, and the decimal count of chains + in that class. The `unreconstructable` count is the number of corrupt lines, since + only corrupt lines are unreconstructable; the other four count path chains. These + five counts sum to the total chain count. +- Then one blank line. +- Then the anomaly lines, one per non-`exact` chain, each a classification wire + token, a space, and the chain's key: a path chain's key is its path, and a corrupt + line's key is `line:` followed by its one-based line number. All anomaly lines are + sorted together by ascending byte order of the full rendered line. +- Then, after the chain and corrupt anomaly lines and before the verdict, the + current-state-only lines, one per module in `current_state_only`, each exactly the + fixed token `current-state-only`, a space, and the module name, sorted by ascending + byte order of the module name. These are a distinct group from the chain anomaly + lines above: the scar is a module-level fact, so it renders as its own + `current-state-only ` line and is never merged into a path chain's line. +- Then the final line, exactly `verdict clean` when there are no anomaly lines and no + current-state-only lines, and exactly `verdict anomalies` when there is at least one + anomaly line or at least one current-state-only line. A current-state-only module is + a scar the maintainer must see, so it alone flips the verdict to `anomalies`. -Law closure by default: the `law: true` sections of every module a ref -touched are appended once, deduplicated, ordered by module then range, -under one trailing header; `--no-laws` opts out. Refs into a module whose -attribution is stale refuse by default with that module's fix directive; -`--allow-stale` proceeds and stamps `stale` into the affected provenance -headers. Unresolvable refs (absent path, unavailable snapshot) error -naming the ref's input line. +Every line, including the last, is terminated by a single newline, so the report +ends in a newline. Lines are joined by single newlines and there is exactly one +blank line, the one before the anomalies. An all-`exact` journal with no corrupt +lines and no current-state-only modules renders the blank line immediately followed by +`verdict clean`; when `current_state_only` is empty the report is byte-for-byte what it +was before the inventory existed, so a healthy ledger's report is unchanged. ::: test -name: prompt materializes a prompt ref with a provenance header -given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf } -expect: { output_contains_prose: true, header_format: "path:start-end" } +name: the audit report of an all-exact journal renders its pinned clean bytes +given: { fixture: JRN_CLEAN } +expect: { report_bytes: "napl migrate audit v1\nentries 2\ncorrupt-lines 0\nchains 2\nexact 2\nmissing-prefix 0\ninconsistent-before 0\ninconsistent-after 0\nunreconstructable 0\n\nverdict clean\n" } ::: ::: test -name: prompt materializes a generated-file ref symmetrically -given: { workspace: fixture_two_modules, stdin: file_ref_for_leaf_output } -expect: { output_contains_code: true } +name: the audit report of a mixed journal renders its pinned anomaly bytes +given: { fixture: JRN_MIXED } +expect: { report_bytes: "napl migrate audit v1\nentries 5\ncorrupt-lines 1\nchains 5\nexact 1\nmissing-prefix 1\ninconsistent-before 1\ninconsistent-after 1\nunreconstructable 1\n\ninconsistent-after d.rs\ninconsistent-before c.rs\nmissing-prefix b.rs\nunreconstructable line:6\nverdict anomalies\n" } ::: ::: test -name: prompt appends law sections by default and omits them under no-laws -given: { workspace: fixture_with_law_section, stdin: section_ref_non_law } -expect: { laws_appended_default: true, laws_with_flag: false } +name: an inventory module absent from the journal audits as a distinct current-state-only scar +given: { fixture: AUDIT_CURRENT_ONLY } +expect: { current_state_only: ["greeting"], report_bytes: "napl migrate audit v1\nentries 0\ncorrupt-lines 0\nchains 0\nexact 0\nmissing-prefix 0\ninconsistent-before 0\ninconsistent-after 0\nunreconstructable 0\n\ncurrent-state-only greeting\nverdict anomalies\n" } ::: +## Building the migration objects + +Migration turns the ledger into content-addressed objects. Expose a public struct +`StagedObject` (deriving `Debug`, `Clone`, `PartialEq`, `Eq`) with public fields +`name: String` (the object's SHA-256 name) and `envelope: Vec` (its full +envelope bytes, ready to write). Every builder frames its payload through +`store_hash` and returns a `StagedObject` whose `name` equals the matching +`store_objects` naming function over the same value. + +- Expose `line_blob(raw_line: &str) -> StagedObject`: a `blob` whose payload is the + exact UTF-8 bytes of `raw_line` (the verbatim original journal line, no trailing + newline). Its `envelope` is `store_hash::envelope_bytes(ObjectKind::Blob, bytes)` + and its `name` is `store_hash::object_name(ObjectKind::Blob, bytes)`. A blob's + payload is raw bytes, never canonical JSON. +- Expose `legacy_event(module: &str, target: Option<&str>, ordinal: u64, + classification: store_objects::LegacyClassification, original_line: &str, + timestamp: &str, parents: Vec) -> store_objects::Event`. It builds the + legacy event for one journal line: `parents` as given (the module's prior legacy + event names, empty at the module's first legacy event); `module` as given; `target` + as given; `ordinal` as given (the entry's `gen`); `timestamp` as given (the entry's + own recorded timestamp, so no clock is read); `engine` the empty string (a legacy + entry named no engine); `state_before` and `state_after` each + `Some(store_objects::StateRef::Unknown)`, the unknown sentinel, because no + historical module-state can be reconstructed; and `payload` + `store_objects::EventPayload::Legacy(store_objects::LegacyFields { original_line: + original_line.to_string(), classification })`, where `original_line` is the name + of the line's verbatim blob. +- Expose `event_object(event: &store_objects::Event) -> StagedObject`: serialize the + event to a `serde_json::Value`, frame its canonical payload through `store_hash` + under `ObjectKind::Event`, and return the `StagedObject`. Its `name` equals + `store_objects::event_object_name(event)`. +- Expose `module_state_object(state: &store_objects::ModuleState) -> StagedObject`: + the same framing under `ObjectKind::ModuleState`; its `name` equals + `store_objects::module_state_object_name(state)`. +- Expose `snapshot_object(snapshot: &store_objects::Snapshot) -> StagedObject`: the + same framing under `ObjectKind::Snapshot`; its `name` equals + `store_objects::snapshot_object_name(snapshot)`. + ::: test -name: prompt refuses refs into a stale module by default -given: { workspace: fixture_with_directive_case, attribution: stale, stdin: section_ref } -expect: { exit_nonzero: true, directive_present: true, allow_stale_overrides: true } +name: a verbatim journal line frames its blob envelope and names it +given: { fixture: LINE_A } +expect: { envelope_bytes: "napl-store blob v1 168\n{\"gen\":1,\"module\":\"greeting\",\"target\":\"rust\",\"files\":[{\"path\":\"g.rs\",\"hashBefore\":null,\"hashAfter\":\"2222222222222222222222222222222222222222222222222222222222222222\"}]}", object_name: "ed2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360" } ::: ::: test -name: prompt errors on a malformed stdin line naming its line number -given: { stdin_lines: [valid_ref, "not json"] } -expect: { exit_nonzero: true, error_names_line: 2 } +name: a missing-prefix legacy event canonicalizes to its pinned bytes and name +given: { fixture: LEG_MISSING } +expect: { canonical_bytes: "{\"engine\":\"\",\"module\":\"greeting\",\"ordinal\":1,\"parents\":[],\"payload\":{\"fields\":{\"classification\":\"missing-prefix\",\"originalLine\":\"5555555555555555555555555555555555555555555555555555555555555555\"},\"kind\":\"legacy\"},\"stateAfter\":\"unknown\",\"stateBefore\":\"unknown\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n", object_name: "fba5e4f9532e4dd01d1f9571b46afafced354533db1730806acb703643fd05f4" } ::: ::: test -name: prompt errors on empty stdin -given: { stdin: "" } -expect: { exit_nonzero: true, error: nothing_selected } +name: an exact target-scoped chained legacy event canonicalizes to its pinned bytes and name +given: { fixture: LEG_EXACT } +expect: { canonical_bytes: "{\"engine\":\"\",\"module\":\"greeting\",\"ordinal\":2,\"parents\":[\"fba5e4f9532e4dd01d1f9571b46afafced354533db1730806acb703643fd05f4\"],\"payload\":{\"fields\":{\"classification\":\"exact\",\"originalLine\":\"ed2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360\"},\"kind\":\"legacy\"},\"stateAfter\":\"unknown\",\"stateBefore\":\"unknown\",\"target\":\"rust\",\"timestamp\":\"2026-07-25T00:00:00Z\"}\n", object_name: "65407002b376af4c707dbab5e9a668c743cffb1bd14ef427ca64cf49be8c4fff" } ::: ::: test -name: prompt deduplicates identical refs and preserves input order -given: { stdin_lines: [ref_a, ref_b, ref_a] } -expect: { chunks: 2, order: [ref_a, ref_b] } +name: a working-file-seeded module-state canonicalizes to its pinned bytes and name +given: { fixture: MS_SEED } +expect: { canonical_bytes: "{\"blockIndex\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"mapl\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"promptBlob\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"targets\":{\"rust\":{\"attribution\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"generatedTree\":\"3333333333333333333333333333333333333333333333333333333333333333\"}}}\n", object_name: "e4351cea324d75b2362a7321008ddc4d9b98b0ea13f57650445dab61bb0aa272" } ::: ::: test -name: "prompt resolves refs against their snapshot, not current head" -given: { workspace: fixture_two_modules, ref_head: previous_snapshot, edits_after: leaf_recommitted } -expect: { materialized_bytes: previous_snapshot_bytes } +name: the final migration snapshot over a genesis parent canonicalizes to its pinned bytes and name +given: { fixture: SNAP } +expect: { canonical_bytes: "{\"parents\":[\"0000000000000000000000000000000000000000000000000000000000000000\"],\"root\":{\"greeting\":{\"eventHead\":\"65407002b376af4c707dbab5e9a668c743cffb1bd14ef427ca64cf49be8c4fff\",\"moduleState\":\"e4351cea324d75b2362a7321008ddc4d9b98b0ea13f57650445dab61bb0aa272\"}},\"seq\":2}\n", object_name: "8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845" } ::: -## Working-tree reads, byte-exact retrieval, and pre-add validation - -Three reads exist so that a tool amending a prompt can read exactly what -it is amending, retrieve the exact bytes it is about to splice, and -validate the result before anything stages, without a single raw file -read. All three are pure reads: no store writes, no locks, no clock, no -staging. Each is a pure function of the committed state plus the on-disk -prompt bytes it explicitly names, and byte-stable across runs over the -same inputs. +## The staged store -`napl q --working-tree` builds the canonical document with every -prompt-derived fact enumerated from the on-disk prompt files instead of -the committed blobs: frontmatter, sections, `::: test` directive cases, -and body line ranges all reflect the pending working-tree prompt, -including sections and cases that exist only in the working tree. -Store-derived facts are unchanged and still committed: `head`, module -states, history, receipts. The top level carries `workingTree: true`, -and each section carries `workingTree: true` in place of a committed -`head` value, so a working-tree ref can never be mistaken for a -committed one and no hash is ever invented for uncommitted bytes. -Section file associations are the committed map's claims transported -through the prompt's committed-to-pending diff, the same deterministic -transport `add` uses: an association whose prompt range transports -cleanly is served intact, and a section whose range lands in a -rewritten or ambiguous region serves `files: []` with -`associationUnproven: true`. The view never re-associates files by -range arithmetic over the re-anchored body; an overlap that happens to -exist in the pending text is not a claim, and serving it as one would -be inventing a mapping nobody made. -Section-to-file association under a stale attribution behaves exactly as -today: sections carry their cases, `associationStale: true` marks the -pairing untrustworthy, and nothing pretends freshness. Reference -staleness fields serve exactly as in the committed document. +Expose a public struct `StagedStore` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`) with public fields `objects: std::collections::BTreeMap>` +(every object to write, keyed by name to its envelope bytes, so duplicate names +collapse to one entry and iteration is sorted) and `head: String` (the name of the +final snapshot, the value the store's `HEAD` is set to). -`napl prompt --working-tree` resolves `promptPath` refs against the -on-disk prompt file instead of a committed blob. A working-tree ref -carries `workingTree: true` and no `head`; a ref carrying both, or a mix -of working-tree and committed refs in one invocation, is a usage error -naming the input line. Everything else about materialization holds: -provenance headers, law closure from committed state, case rendering, -error shapes. +Expose `build_staged_store(journal: &ParsedJournal, audit: &AuditResult, seeds: +&std::collections::BTreeMap, genesis_parent: +&str) -> StagedStore`. It assembles, in this order: -`napl prompt --bytes` is byte-exact retrieval, the inverse of the edit a -tool is about to make: the ref's line range materializes as the exact -source bytes of that range, no reflow, no case rendering, no law -closure, no provenance header, one trailing newline. It accepts exactly -one ref; more than one is a usage error. It composes with -`--working-tree` so a splice can be computed against the pending file. -The bytes returned are byte-identical to the named lines of the source, -so retrieve-then-splice is deterministic by construction. +- For every parseable entry, in file order, a verbatim line blob (`line_blob` over + the entry's `raw`) and one legacy event. The event's classification is the worst + classification among the entry's file paths, read from `audit.chains` under the + severity order (an entry touching no files, or only paths absent from + `audit.chains`, takes `Exact`). Its `original_line` is that entry's line blob name. + Its `parents` chain per module: an entry's legacy event lists the immediately + preceding legacy event name for the same `module`, so each module forms one linear + event chain in `gen` order; the first entry of a module has empty `parents`. Each + event is framed through `event_object` and inserted into `objects`; the last + event name for each module is that module's event head. +- For every corrupt line, a verbatim line blob and one legacy event whose module and + ordinal cannot be read from the line: such an event is not emitted into any + module's chain, and the corrupt line's verbatim blob is still inserted into + `objects` so nothing is lost. Corrupt lines contribute no root-map entry. +- For every module present in `seeds`, without exception, the seeded module-state + (built by the caller from the current working files) framed through + `module_state_object` and inserted into `objects`, and a `store_objects::RootEntry` + whose `module_state` is that object's name and whose `event_head` is the module's + last legacy event name when the module has at least one parseable legacy event, or + the `genesis_parent` sentinel when the module has no legacy event at all. A module + present in `seeds` but absent from every journal entry is a current-state-only + module: its history was lost yet its current state survives on disk (the + emptied-mapl scar), and it must still reach the snapshot's root. Every such seed + contributes a root entry pointing its `event_head` at `genesis_parent`, the + all-zeros genesis sentinel, which the store's reducer reads as the empty-history + boundary for that module: a current-state-only root entry has no recorded event, so + the reducer answers its event-chain as empty, its classification as current-state-only, + and its ordinal lookup as absent, all by recognizing that sentinel value rather than + by fetching a nonexistent event. The snapshot's `root` therefore names every seeded + module and drops none. This assembly fabricates no event for a current-state-only + module: it reuses the genesis sentinel already handed in, and it never invents a + legacy event, a timestamp, or a chain the journal did not record. +- One final `store_objects::Snapshot` whose `parents` is the single-element vector + `[genesis_parent.to_string()]`, whose `seq` is the greatest entry `gen` in the + journal (or one when the journal has no entries), and whose `root` is the sorted + map of module name to root entry. It is framed through `snapshot_object`, inserted + into `objects`, and its name is the `StagedStore`'s `head`. -`napl check ` is dry validation of an on-disk prompt file -before `add`: frontmatter shape and module-name rules per the napl -contract, body normalization, directive grammar for every `::: test` -block including canonical JSON in `given` and `expect`, and case-name -uniqueness. The module-name rules are enforced as name grammar, not -merely as YAML: a frontmatter value that parses as a string but can -never be a legal module name (path-metacharacters, a bare `[`, empty -after trimming) is a finding at check time, never a surprise later. -Findings are canonical JSON on stdout, each naming the file, -the line, and what failed; every reported line is the physical -1-based line in the file as an editor counts it, frontmatter -included, never a body-normalized offset. Exit codes are pinned: 0 for a valid prompt, -1 when findings exist, 2 for usage including an unreadable path. The -store is never touched and nothing stages. `check` passing means the -prompt parses under the same reader `add` uses; it does not predict -`add` outcomes that depend on candidates, attribution, or placement, and -its wording never implies otherwise. `check` teaches while it refuses: -the finding for a missing opening frontmatter fence carries one -complete minimal valid prompt inline, so the first error a cold user -sees contains the whole grammar they were missing; and a checked file -whose name lacks the `.napl` extension gets a finding saying discovery -will never find it, naming the extension, whatever else is valid about -its content. +The `genesis_parent` is a parameter, never invented here: it is the name of the +absent parent that marks the genesis boundary, and its production value is supplied +by the caller. The whole assembly is deterministic given its inputs. ::: test -name: q working-tree enumerates a section that exists only in the working tree -given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } -expect: { new_section_enumerated: true, top_level_working_tree: true, committed_document_unchanged: true } +name: the staged store of a two-entry greeting journal names its head snapshot +given: { fixture: STAGED_GREETING } +expect: { head: "8e640fcf87640f7432fa44c8e44b81686ad4b6c67dccebe573c463d7fb8172c0" } ::: ::: test -name: working-tree sections carry no committed head and mark themselves -given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } -expect: { section_working_tree: true, section_head_absent: true } +name: a current-state-only seed with no legacy event reaches the snapshot root with a genesis-parent event head +given: { fixture: STAGED_CURRENT_ONLY } +expect: { head: "8ea748a37b92e975faeef61ca5ff0a4fb740c64f48f10f8fb1e706f7ea095a8d", head_object_bytes: "napl-store snapshot v1 271\n{\"parents\":[\"0000000000000000000000000000000000000000000000000000000000000000\"],\"root\":{\"greeting\":{\"eventHead\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"moduleState\":\"e4351cea324d75b2362a7321008ddc4d9b98b0ea13f57650445dab61bb0aa272\"}},\"seq\":1}\n" } ::: -::: test -name: the working-tree document is byte-stable across runs -given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, runs: 2 } -expect: { bytes_equal: true } -::: +## The abort comparison -::: test -name: a transported section keeps its files and a rewritten one serves none unproven -given: { workspace: fixture_two_modules, working_tree_edit: shift_one_section_rewrite_another, flags: [--working-tree] } -expect: { shifted_section_files_intact: true, rewritten_section_files: [], rewritten_section_association_unproven: true, overlap_reassociation: 0 } -::: +`napl migrate` refuses to proceed when the live ledger has drifted worse than the +report the maintainer sanctioned. Expose a public enum `MigrateDecision` (deriving +`Debug`, `Clone`, `PartialEq`, `Eq`) with exactly `MigrateDecision::Proceed` and +`MigrateDecision::Abort(Vec)`, the abort carrying the offending keys in +ascending byte order. -::: test -name: prompt bytes round-trips the exact source bytes of a range -given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf, flags: [--bytes] } -expect: { bytes_equal_raw_slice: true, reflow: false, headers: false } -::: +Expose `compare_to_sanctioned(current: &AuditResult, sanctioned: &AuditResult) -> +MigrateDecision`. A key is offending when the current audit is worse there than the +sanctioned audit allows: -::: test -name: prompt bytes refuses more than one ref as a usage error -given: { stdin_lines: [ref_a, ref_b], flags: [--bytes] } -expect: { exit_code: 2, error_names_line: 2 } -::: +- For each path in `current.chains`, its classification's severity is compared to + the same path's severity in `sanctioned.chains`; a path absent from + `sanctioned.chains` is treated as sanctioned `Exact` (severity zero). The path is + offending when its current severity is strictly greater than its sanctioned + severity. Its key is the path. +- For each corrupt line number in `current.corrupt_lines` that is not in + `sanctioned.corrupt_lines`, that line is offending. Its key is `line:` followed by + the number. +- For each module in `current.current_state_only` that is not in + `sanctioned.current_state_only`, that module is offending. Its key is + `current-state-only:` followed by the module name. A module in + `sanctioned.current_state_only` is already sanctioned and never offends when it is + also in `current.current_state_only`, and a module in `sanctioned.current_state_only` + that is absent from `current.current_state_only` has disappeared and never offends, + exactly as a chain path or corrupt line present in the sanctioned baseline yet absent + from the current audit is treated as an improvement and never offends: like both other + kinds, this comparison iterates the current audit and measures only where current is + worse than what the sanctioned baseline already accepted. + +When there are no offending keys the decision is `Proceed`; otherwise it is +`Abort` carrying the offending keys sorted ascending by byte order across all three key +kinds. A current audit no worse than the sanctioned one at every key proceeds even when +both carry the same anomalies, because the sanctioned report already accepted them. A +current-state-only scar is not merely surfaced for the maintainer: it is a drift the +abort gate measures exactly as a new corrupt line is, so a module that appears +current-state-only in the current audit without the sanctioned baseline having accepted +it aborts, and the new scar is never laundered into a silent proceed. ::: test -name: a ref carrying both a head and working-tree is a usage error naming its line -given: { stdin_lines: [ref_with_head_and_working_tree], flags: [--working-tree] } -expect: { exit_nonzero: true, error_names_line: 1 } +name: a current audit equal to the sanctioned baseline proceeds +given: { fixture: CMP_SAME } +expect: { decision: "proceed" } ::: ::: test -name: check passes a valid prompt with exit zero and no findings -given: { workspace: fixture_two_modules, prompt: leaf_prompt_valid } -expect: { exit_code: 0, findings: 0, store_bytes_unchanged: true } +name: a current chain worse than its sanctioned classification aborts and names the offending chain +given: { fixture: CMP_WORSE } +expect: { decision: "abort", offending: ["c.rs"] } ::: ::: test -name: check reports a malformed directive with a finding naming its line -given: { workspace: fixture_two_modules, prompt: leaf_prompt_broken_directive } -expect: { exit_code: 1, finding_names_line: true, store_bytes_unchanged: true } +name: a corrupt line absent from the sanctioned baseline aborts and names the offending line +given: { fixture: CMP_NEW_CORRUPT } +expect: { decision: "abort", offending: ["line:4"] } ::: ::: test -name: the missing-fence finding carries a complete minimal prompt that itself passes check -given: { workspace: fixture_two_modules, prompt: file_without_frontmatter } -expect: { exit_code: 1, finding_example_passes_check: true } +name: an anomalous chain that was exact in the sanctioned baseline aborts and names it +given: { fixture: CMP_NEW_ANOMALY } +expect: { decision: "abort", offending: ["b.rs"] } ::: ::: test -name: a checked file without the napl extension is told discovery will never find it -given: { workspace: fixture_two_modules, prompt: valid_content_named_greeting_md } -expect: { exit_code: 1, finding_names_extension: ".napl", store_bytes_unchanged: true } +name: a current-state-only scar present in current but absent from the sanctioned baseline aborts and names the module +given: { fixture: CMP_NEW_SCAR } +expect: { decision: "abort", offending: ["current-state-only:greeting"] } ::: ::: test -name: a module name that yaml accepts but the grammar forbids is a finding -given: { workspace: fixture_two_modules, prompt: frontmatter_module_bare_bracket } -expect: { exit_code: 1, finding_names_module_grammar: true } +name: a current-state-only scar present in both current and sanctioned proceeds +given: { fixture: CMP_SAME_SCAR } +expect: { decision: "proceed" } ::: ::: test -name: findings report physical file lines as an editor counts them -given: { workspace: fixture_two_modules, prompt: empty_module_value_on_file_line_2 } -expect: { finding_line: 2, line_kind: physical_one_based } +name: a current-state-only scar present in the sanctioned baseline but gone from current proceeds +given: { fixture: CMP_GONE_SCAR } +expect: { decision: "proceed" } ::: -napl-store module-state v1 562 -{"attribution":{"promptHash":"a1ffa0765647bba4a5800b7b586b2fabbc059f4ef87d92634909d2b563fe4a59","record":"9c187268acfc021be505e952ab135fccccd38d1168593caf63899afe84a8238e","stale":false},"contractVersion":4,"inputKey":"8920df377fc0fe22186e48fe6a235b4cbd6750c7856b0082a54f7e08afd17520","module":"commands","outputTree":"bb697c13a7df86325725a0bc05269c8197416b2b34f5e7b496ea3a3eb9a07976","sliceHash":"93be886fefa6c04a317ff38171d357d3c3ffb88c1c31b4dc53c4dfba79780604","surfaceHash":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust"} -napl-store tree v1 129 -{"entries":[{"hash":"5720bfa6d7c0b790f6e43277d6bfb2d58aa4204ba3706d76e37e90e49a7e2a66","kind":"dir","mode":16384,"name":"src"}]} -napl-store transition v1 875 -{"baseStateKey":"efeb55ee16b2b444a3502f2e63424bcaaec34c9f676dd2c74f00944dceec5a8d","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"0c24795983a2a1227f49aecc11439fa8374eeb139047ae85b2d55ce1cd27ae06","installPaths":["crates/napl/src/init.rs","crates/napl/src/watch.rs"],"module":"commands","newState":"7f362809b6d0f959bc8479adebdde987a718107d3e70636e879e44d0d4c59bb5","readSet":["base-state:efeb55ee16b2b444a3502f2e63424bcaaec34c9f676dd2c74f00944dceec5a8d","input-key:0c24795983a2a1227f49aecc11439fa8374eeb139047ae85b2d55ce1cd27ae06","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"1a90570994829d35b84b3d0afb4c1add8c874b698abe4ebb9acdb218ecf30c77","surface":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust","writeSet":["owned:crates/napl","module-state:rust/commands"]} -napl-store snapshot v1 781 -{"contractVersion":4,"event":"f0b8a3c47a244f6054199d9672f8fb7eb24f451e467af370d2026e72b30601d2","states":[{"module":"agent","state":"651686af1f5c9f3aad425d1ca7f0de325e4dc362cb59d9a71dc7d75f3216972c","target":"rust"},{"module":"commands","state":"7f362809b6d0f959bc8479adebdde987a718107d3e70636e879e44d0d4c59bb5","target":"rust"},{"module":"napl","state":"338c9b575a24883622bf3cb1e3276c24a26c31ef34a5097927420523727b6d26","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"26e26696fa18accc362da9494d522e4ab3f3e70babc7ad776335ce8125582ef8","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} -napl-store blob v1 6388 -//! Outcome codes, exit-code mapping, and the crate error type. -//! -//! Every failure the compiler can report is one of the outcomes named by the -//! deterministic-compiler contract. Four are concurrency outcomes a caller may -//! retry after regenerating; ten are terminal and must never be retried -//! unchanged. The last three joined the closed table with the capture verbs -//! under contract 4 and continue the pinned exit-code order; none of them reuses -//! `mapping-integrity`, because a refused edit is not a broken mapping. -use serde_json::{json, Value}; +## Fixtures the tests pin -/// The complete outcome vocabulary of the compiler. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum OutcomeCode { - Success, - /// The invocation did not match the command's declared grammar: an unknown - /// command, an unknown flag, a missing required flag or positional, a - /// malformed flag value. It is a grammar failure rather than a compilation - /// outcome, so it records nothing and carries the pinned code 2. - Usage, - // Concurrency outcomes, in decision-table order. - StaleInput, - BaseOutputChanged, - WriteConflict, - LockContended, - // Terminal outcomes, in contract order. - InvalidCandidate, - InvalidReceipt, - MappingIntegrity, - ResourceLimit, - UnsupportedVersion, - StoreIntegrity, - IoError, - /// A capture verb's `--old-string` matched nowhere, matched more than once - /// without `--replace-all`, or equalled `--new-string`. - EditMismatch, - /// A capture verb's `--ref` named a line or a path the post-edit bytes and - /// the owning module's placement cannot carry. - ReferenceUnresolvable, - /// A capture verb's result would not parse as the prompt it replaces. - PromptInvalid, -} - -/// The stable machine-readable slug for an outcome. -pub fn outcome_slug(code: OutcomeCode) -> &'static str { - match code { - OutcomeCode::Success => "success", - OutcomeCode::Usage => "usage", - OutcomeCode::StaleInput => "stale-input", - OutcomeCode::BaseOutputChanged => "base-output-changed", - OutcomeCode::WriteConflict => "write-conflict", - OutcomeCode::LockContended => "lock-contended", - OutcomeCode::InvalidCandidate => "invalid-candidate", - OutcomeCode::InvalidReceipt => "invalid-receipt", - OutcomeCode::MappingIntegrity => "mapping-integrity", - OutcomeCode::ResourceLimit => "resource-limit", - OutcomeCode::UnsupportedVersion => "unsupported-version", - OutcomeCode::StoreIntegrity => "store-integrity", - OutcomeCode::IoError => "io-error", - OutcomeCode::EditMismatch => "edit-mismatch", - OutcomeCode::ReferenceUnresolvable => "reference-unresolvable", - OutcomeCode::PromptInvalid => "prompt-invalid", - } -} - -/// Process exit code: 0 success, 2 usage, 10..=13 concurrency, 20..=29 terminal. -/// -/// Exit 2 is pinned binary-wide for every usage error, so a caller can tell -/// "you spelled the invocation wrong" from "the compiler refused the work" -/// without reading a payload. -pub fn outcome_exit_code(code: OutcomeCode) -> i32 { - match code { - OutcomeCode::Success => 0, - OutcomeCode::Usage => 2, - OutcomeCode::StaleInput => 10, - OutcomeCode::BaseOutputChanged => 11, - OutcomeCode::WriteConflict => 12, - OutcomeCode::LockContended => 13, - OutcomeCode::InvalidCandidate => 20, - OutcomeCode::InvalidReceipt => 21, - OutcomeCode::MappingIntegrity => 22, - OutcomeCode::ResourceLimit => 23, - OutcomeCode::UnsupportedVersion => 24, - OutcomeCode::StoreIntegrity => 25, - OutcomeCode::IoError => 26, - OutcomeCode::EditMismatch => 27, - OutcomeCode::ReferenceUnresolvable => 28, - OutcomeCode::PromptInvalid => 29, - } -} - -/// True when the outcome may be retried after the caller regenerates its inputs. -pub fn outcome_is_retryable(code: OutcomeCode) -> bool { - matches!( - code, - OutcomeCode::StaleInput - | OutcomeCode::BaseOutputChanged - | OutcomeCode::WriteConflict - | OutcomeCode::LockContended - ) -} - -/// A failure carrying the outcome it maps to, a human-readable detail, and the -/// structured facts a caller needs to act on it. -/// -/// `data` exists because some refusals are lists rather than sentences: an -/// attribution transport that cannot carry three claims has to name those three -/// and the bound each was checked against, and folding that into prose would make -/// a machine reader parse English. Its keys merge into the outcome payload -/// alongside the shared ones. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CompilerError { - pub code: OutcomeCode, - pub detail: String, - pub data: Option, -} - -pub type Result = std::result::Result; - -/// Build a [`CompilerError`]. -pub fn error(code: OutcomeCode, detail: impl Into) -> CompilerError { - CompilerError { - code, - detail: detail.into(), - data: None, - } -} - -/// Build a [`CompilerError`] that also carries structured facts. -pub fn error_with(code: OutcomeCode, detail: impl Into, data: Value) -> CompilerError { - CompilerError { - code, - detail: detail.into(), - data: Some(data), - } -} +The classification fixtures are chains with their current hash. Write the +sixty-four-character hashes as the digit or letter repeated, so `A64` is the letter +`a` sixty-four times, and likewise `B64`, `D64`, `E64`, `F64`, `H1_64` the digit +`1`, `H2_64` the digit `2`, `H9_64` the digit `9`, and `ZERO64` the digit `0`. -/// The canonical JSON body shared by every failing outcome payload. -/// -/// A structured detail merges in, and never over: a shared key always wins, so no -/// refusal can restate its own outcome or publication facts. -pub fn error_payload( - err: &CompilerError, - blobs_published: bool, - provenance_published: bool, -) -> Value { - let mut payload = serde_json::Map::new(); - if let Some(Value::Object(extra)) = &err.data { - for (key, value) in extra { - payload.insert(key.clone(), value.clone()); - } - } - for (key, value) in [ - ("blobsPublished", json!(blobs_published)), - ( - "contractVersion", - json!(crate::version::CONTRACT_VERSION), - ), - ("detail", json!(err.detail)), - ("outcome", json!(outcome_slug(err.code))), - ("provenancePublished", json!(provenance_published)), - ("retryable", json!(outcome_is_retryable(err.code))), - ("semanticStatePublished", json!(false)), - ] { - payload.insert(key.to_string(), value); - } - Value::Object(payload) -} -napl-store blob v1 3027 -//! NAPL: the deterministic compiler. -//! -//! One crate with two halves, kept apart by module boundary rather than by crate -//! boundary. The pure compiler — parsing, the dependency graph, slices, surface -//! extraction, receipts, the decision table, blame, lint, cost — decides -//! everything. The shell — [`cli`], [`commit`], [`disk`], [`porcelain`], -//! [`workspace`], [`authoring`] — owns the syscalls and decides nothing. The -//! byte-level contract (the envelope, the SHA-256 name, canonical JSON, trees, -//! the transaction core) belongs to `napl-store` and is used from there rather -//! than restated. -//! -//! # The two principles -//! -//! **P1, ownership.** The toolchain owns `.napl` source, the derived layer, the -//! store, and the mappings between them; every owned fact is provable from -//! content alone. There is no model invocation, no provider identifier, no -//! completion parsing, no process execution, no clock read, and no randomness -//! anywhere in this crate. Generation happens outside it: an agent session reads -//! `napl compile` output, writes candidate code, and calls `napl commit`. -//! -//! **P2, footprint scoping.** Every operation declares a read set of content -//! identities, a semantic write set of owned records, and its derived -//! projections plus physical install paths. Two operations conflict when their -//! semantic write sets overlap, or when one's accepted write changes a content -//! identity in the other's read set. Derived projections — the shared workspace -//! manifest and its member list — are never conflict-bearing: they are -//! recomputed exactly once from the winning merged head inside commit and -//! rebase. -//! -//! # What "pure" means here -//! -//! No function in the compiler half opens a file, takes a lock, reads a clock, -//! reads an environment variable, or draws a random number. Store reads arrive -//! through [`store_view::ObjectSource`]; candidate bytes arrive through -//! [`candidate::ScannedEntry`]; source text arrives as `&str`. That is the whole -//! reason two compiles of the same workspace, from different absolute paths and -//! under different locales, produce identical bytes: the ambient environment is -//! not merely ignored, it is unreachable. +- `CH_EXACT_CREATE`: one step `{ gen 1, before None, after A64 }`, current + `Some(A64)`. +- `CH_EXACT_MULTI`: steps `{ gen 1, before None, after H1_64 }` then `{ gen 2, + before H1_64, after H2_64 }`, current `Some(H2_64)`. +- `CH_MISSING_PREFIX`: one step `{ gen 5, before E64, after B64 }`, current + `Some(B64)`. +- `CH_INCONSIST_BEFORE`: steps `{ gen 1, before None, after H1_64 }` then `{ gen 2, + before H9_64, after H2_64 }`, current `Some(H2_64)`. +- `CH_INCONSIST_AFTER`: one step `{ gen 1, before None, after H1_64 }`, current + `Some(H2_64)`. +- `CH_MISSING_PREFIX_AND_AFTER`: one step `{ gen 5, before E64, after B64 }`, + current `Some(F64)`. Both the missing-prefix start and the terminal mismatch + apply, and the worse `InconsistentAfter` wins. -// The pure compiler. -pub mod blame; -pub mod candidate; -pub mod canon; -pub mod compile; -pub mod cost; -pub mod decision; -pub mod error; -pub mod evidence; -pub mod graph; -pub mod lint; -pub mod maintenance; -pub mod objects; -pub mod paths; -pub mod receipt; -pub mod slice; -pub mod source; -pub mod store_view; -pub mod surface; -pub mod target; -pub mod textdiff; -pub mod transaction; -pub mod trees; +The parse fixtures are single lines: -// The shell: the syscalls and the argument grammar. -pub mod authoring; -pub mod cli; -pub mod commit; -pub mod disk; -pub mod lsp; -pub mod porcelain; -pub mod workspace; +- `LINE_VALID`: + `{"gen":1,"module":"greeting","target":"rust","files":[{"path":"g.rs","hashBefore":null,"hashAfter":"H2_64"}]}` + with `H2_64` written out, parsing to gen `1`, module `greeting`, target `rust`, + one path `g.rs`. +- `LINE_CORRUPT_JSON`: the text `{ this is not valid json`, not valid JSON. +- `LINE_CORRUPT_SHAPE`: `{"gen":"one","module":"greeting"}`, valid JSON but with a + non-integer `gen` and no `files`. +- `LINE_EXTRA_FIELDS`: + `{"gen":3,"timestamp":"2026-07-24T00:00:00.000Z","module":"greeting","target":"rust","promptHash":"H1_64","promptDiff":"","mode":"full","files":[{"path":"g.rs","patch":"@@ -0,0 +1 @@\n+x","hashBefore":null,"hashAfter":"H2_64"}]}` + with the hashes written out, parsing to gen `3`, module `greeting`, one path + `g.rs`, its extra `timestamp`, `promptHash`, `promptDiff`, `mode`, and per-file + `patch` fields ignored. +- `LINE_MISSING_BEFORE`: + `{"gen":1,"module":"greeting","target":"rust","files":[{"path":"g.rs","hashAfter":"H2_64"}]}` + with `H2_64` written out: valid JSON of otherwise the right shape, except its one + file element omits the `hashBefore` field entirely rather than recording an explicit + `null`, so the line fails the shape check and is a corrupt line, never a creation. +- `LINE_MISSING_AFTER`: + `{"gen":1,"module":"greeting","target":"rust","files":[{"path":"g.rs","hashBefore":"H1_64"}]}` + with `H1_64` written out: the same, its one file element omitting the `hashAfter` + field entirely, so the line is a corrupt line, never a deletion. +- `LINE_NULL_BEFORE`: + `{"gen":1,"module":"greeting","files":[{"path":"c.rs","hashBefore":null,"hashAfter":"A64"}]}` + with `A64` written out: an explicit `null` `hashBefore` present alongside a string + `hashAfter`, parsing to gen `1`, module `greeting`, one path `c.rs`, whose one + `FileStep` has `hash_before` `None` (a valid creation) and `hash_after` `Some` of + `A64`. +- `LINE_NULL_AFTER`: + `{"gen":1,"module":"greeting","files":[{"path":"d.rs","hashBefore":"A64","hashAfter":null}]}` + with `A64` written out: a string `hashBefore` present alongside an explicit `null` + `hashAfter`, parsing to gen `1`, module `greeting`, one path `d.rs`, whose one + `FileStep` has `hash_before` `Some` of `A64` and `hash_after` `None` (a valid + deletion). -/// The version of the compiler contract these bytes implement. -/// -/// It enters every `inputKey`, so raising it invalidates every outstanding -/// receipt rather than silently reinterpreting one. -pub const CONTRACT_VERSION: u32 = 1; -napl-store blob v1 6733 -module: napl -target: rust -stale: false -entries: - - promptLines: - - 1 - - 8 - file: crates/napl/src/cli.rs - lines: - - 1 - - 133 - note: "the binary reads args runs one command and exits with the contract code" - - promptLines: - - 9 - - 88 - file: crates/napl/src/workspace.rs - lines: - - 1 - - 251 - note: "layout and discovery walk up to the nearest napl directory" - - promptLines: - - 9 - - 88 - file: crates/napl/src/target.rs - lines: - - 1 - - 228 - note: "placement roots owned source globs and the user-owned manifest seam" - - promptLines: - - 9 - - 88 - file: crates/napl/src/transaction.rs - lines: - - 1 - - 537 - note: "the crate lib.rs projection derived from the committed module file sets" - - promptLines: - - 89 - - 110 - file: crates/napl/src/objects.rs - lines: - - 1 - - 332 - note: "P2 footprints read write and derived-projection sets over content identities" - - promptLines: - - 89 - - 110 - file: crates/napl/src/decision.rs - lines: - - 1 - - 113 - note: "the conflict rule two operations conflict when semantic write sets overlap" - - promptLines: - - 111 - - 128 - file: crates/napl/src/version.rs - lines: - - 1 - - 1 - note: "the contract version bumped to two by the body-directive grammar" - - promptLines: - - 111 - - 128 - file: crates/napl/src/slice.rs - lines: - - 1 - - 170 - note: "identity vocabulary inputKey closure manifest baseStateKey and sliceHash" - - promptLines: - - 129 - - 145 - file: crates/napl/src/compile.rs - lines: - - 1 - - 257 - note: "the compile derivation a pure read that never persists an artifact" - - promptLines: - - 146 - - 179 - file: crates/napl/src/directive.rs - lines: - - 1 - - 584 - note: "the test body-directive value grammar diagnostics and deterministic extraction" - - promptLines: - - 180 - - 193 - file: crates/napl/src/surface.rs - lines: - - 1 - - 694 - note: "tree-sitter surface extraction sound by construction and loud on malformed input" - - promptLines: - - 194 - - 263 - file: crates/napl/src/staging.rs - lines: - - 1 - - 216 - note: "napl add stages a candidate ingesting validating and writing the internal receipt" - - promptLines: - - 194 - - 263 - file: crates/napl/src/commit.rs - lines: - - 1 - - 367 - note: "napl commit the one guarded transaction over what add staged" - - promptLines: - - 194 - - 263 - file: crates/napl/src/receipt.rs - lines: - - 1 - - 194 - note: "the internal receipt everything the compiler recomputes and refuses to believe" - - promptLines: - - 194 - - 263 - file: crates/napl/src/attribution.rs - lines: - - 1 - - 323 - note: "attribution rides the staged record validated before install" - - promptLines: - - 194 - - 263 - file: crates/napl/src/candidate.rs - lines: - - 1 - - 150 - note: "candidate ingestion untrusted bytes become immutable content-addressed blobs" - - promptLines: - - 264 - - 323 - file: crates/napl/src/porcelain.rs - lines: - - 1 - - 679 - note: "the observational porcelain status log show diff blame lint cost gc fsck restore" - - promptLines: - - 264 - - 323 - file: crates/napl/src/lsp.rs - lines: - - 1 - - 492 - note: "the language server hover definition references and code lens over the mapping" - - promptLines: - - 264 - - 323 - file: crates/napl/src/mv.rs - lines: - - 1 - - 215 - note: "relocation as a semantic operation re-rooting a module through the guarded path" - - promptLines: - - 264 - - 323 - file: crates/napl/src/blame.rs - lines: - - 1 - - 79 - note: "derived line ancestry surviving lines trace through accepted patches" - - promptLines: - - 264 - - 323 - file: crates/napl/src/lint.rs - lines: - - 1 - - 211 - note: "unreachable modules under declared roots and unconsumed surface entries" - - promptLines: - - 264 - - 323 - file: crates/napl/src/cost.rs - lines: - - 1 - - 163 - note: "cost as a pure calculation over an explicit receipt set unknown stays unknown" - - promptLines: - - 264 - - 323 - file: crates/napl/src/maintenance.rs - lines: - - 1 - - 335 - note: "gc fsck and restore deterministic repair plans from persisted evidence" - - promptLines: - - 264 - - 323 - file: crates/napl/src/evidence.rs - lines: - - 1 - - 208 - note: "evidence validators keyed by media type with compiled-in bounds" - - promptLines: - - 324 - - 334 - file: crates/napl/src/canon.rs - lines: - - 1 - - 44 - note: "the determinism contract canonical bytes and byte-pinned serialization" - - promptLines: - - 129 - - 145 - file: crates/napl/src/graph.rs - lines: - - 1 - - 162 - note: "the dependency graph and dependency-ordered wave planning" - - promptLines: - - 194 - - 263 - file: crates/napl/src/disk.rs - lines: - - 1 - - 203 - note: "the store shell publication head compare-and-set and module-keyed locks" - - promptLines: - - 194 - - 263 - file: crates/napl/src/store_view.rs - lines: - - 1 - - 152 - note: "the read seam over content-addressed objects a pure function of the bytes" - - promptLines: - - 129 - - 145 - file: crates/napl/src/paths.rs - lines: - - 1 - - 69 - note: "the portable path grammar and its collision-detecting normalization" - - promptLines: - - 194 - - 263 - file: crates/napl/src/trees.rs - lines: - - 1 - - 34 - note: "building the candidate tree over the ingested manifest" - - promptLines: - - 264 - - 323 - file: crates/napl/src/textdiff.rs - lines: - - 1 - - 152 - note: "diff renders drift as a canonical byte-stable patch" - - promptLines: - - 194 - - 263 - file: crates/napl/src/authoring.rs - lines: - - 1 - - 220 - note: "the internal receipt derivation and the one guarded auto-fix" - - promptLines: - - 89 - - 110 - file: crates/napl/src/error.rs - lines: - - 1 - - 108 - note: "the outcome vocabulary and its exit-code mapping" - - promptLines: - - 1 - - 8 - file: crates/napl/src/commandset.rs - lines: - - 1 - - 226 - note: "the command table the core toolchain contributes to the derived registry" - - promptLines: - - 1 - - 8 - file: crates/napl/src/main.rs - lines: - - 1 - - 16 - note: "the binary entry point that spawns no process" -napl-store tree v1 132 -{"entries":[{"hash":"42ec5951151046f227be896e37e6906718443065a0a99c05bc7cf6bb85dbce45","kind":"dir","mode":16384,"name":"crates"}]} -napl-store receipt v1 815 -{"baseStateKey":"02051dd93f1be5de0dcfc96ef2d9c28c8bd05c00d65c9d9b3ffca1a3fa2cf893","baseStoreSnapshot":"dd5b21ef4dfb44f68ed82f9b84e73330a2a7341bf5a5dc350bdeb1980104fcea","baseWorkspaceHash":"5b6e85f70de3ba0107ccc1fc60b9e72b7f2e4581c52cf4519a8c1360f630b1a1","candidateTreeHash":"d83d2467beb91b948fff71e8ad6912f421053f3c456f4ef0d652253c7a1b8ce7","contractVersion":4,"inputKey":"65cf34ec0a6587883b89d13fdd2204c716e21cffd66d8d82956ffd61b3c07c63","module":"commands","outputManifest":[{"hash":"cb4a371433bd6721613afa223fb5e3fd962eb9d58272937e6b632c21a0a351be","path":"crates/napl/src/init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","path":"crates/napl/src/watch.rs"}],"receiptVersion":1,"sliceHash":"a70d8ad5a39c87958af5a0e3a572bc713effb0045ce26f3c91dc021aee9a1a22","target":"rust"} -napl-store blob v1 97983 -# NAPL: the deterministic compiler +The report fixtures are whole journals with their current-hash map. `JRN_CLEAN` is +these two lines, module `m`, target `rust`: -This prompt describes the entire NAPL toolchain, rebuilt as one small Cargo -workspace of exactly three crates. It replaces the legacy 76-crate -implementation completely; git history holds the original. This prompt is -the governing contract: the workspace's `.napl` prompts and the committed -code are the two truths, and no external document outranks them. +- line 1 + `{"gen":1,"module":"m","target":"rust","files":[{"path":"a.rs","hashBefore":null,"hashAfter":"A64"}]}` +- line 2 + `{"gen":2,"module":"m","target":"rust","files":[{"path":"b.rs","hashBefore":null,"hashAfter":"B64"}]}` -## Layout: this module generates two crates on top of the ported store +with current hashes `{ a.rs: A64, b.rs: B64 }`, rendering the pinned clean report. +`JRN_MIXED` is these six lines with current hashes `{ a.rs: A64, b.rs: B64, c.rs: +H2_64, d.rs: F64 }`: -The workspace layout is fixed: the records directory `.napl/` lives at the -repository root; generated code installs under `crates/`; a module's -prompt file lives inside the crate directory that receives its code, and -the crate is the folder: every module whose `.napl` file sits in -`crates//` compiles into the `` crate as its own source -module, so one crate may be described by several prompt files -(`crates/napl/napl.napl` and `crates/napl/commands.napl` both build -`crates/napl/`). Module discovery finds prompts by walking the workspace -for `*.napl` files, and the workspace root is the nearest ancestor -directory containing `.napl`. A subtree that contains its own `.napl` -directory is a separate workspace: discovery never descends into it, the -way git never descends into a nested repository. Hidden directories and -`node_modules` are never descended into. +- line 1 + `{"gen":1,"module":"m","target":"rust","files":[{"path":"a.rs","hashBefore":null,"hashAfter":"A64"}]}` + (a.rs exact) +- line 2 + `{"gen":2,"module":"m","target":"rust","files":[{"path":"b.rs","hashBefore":"E64","hashAfter":"B64"}]}` + (b.rs missing-prefix) +- line 3 + `{"gen":3,"module":"m","target":"rust","files":[{"path":"c.rs","hashBefore":null,"hashAfter":"H1_64"}]}` +- line 4 + `{"gen":4,"module":"m","target":"rust","files":[{"path":"c.rs","hashBefore":"H9_64","hashAfter":"H2_64"}]}` + (c.rs inconsistent-before, because `H9_64` is not the prior `H1_64`) +- line 5 + `{"gen":5,"module":"m","target":"rust","files":[{"path":"d.rs","hashBefore":null,"hashAfter":"D64"}]}` + (d.rs inconsistent-after, because the terminal `D64` is not the current `F64`) +- line 6 the text `{ this is not valid json` (corrupt, `line:6`) -The proven store already exists as a hand-ported crate at -`crates/napl-store/` (same envelope format `napl-store v1 -\n`, SHA-256 names, canonical JSON payloads, same object -kinds, same `.napl/store/` on-disk layout with HEAD compare-and-swap, -module-keyed locks, and dead-holder steal, ported code and tests from the -legacy store crates). Use its public API through a path dependency; do not -reimplement, wrap thinly, or fork any of its types or logic. The four new -object kinds the compiler needs (`transition`, `receipt`, `surface`, -`target-def`) already exist in it additively. +with all sixty-four-character hashes written out in full. -::: test -name: the ported store hashes objects exactly as the original store crates did -given: { vectors: ported_store_hash_test_corpus } -expect: { all_pass_unmodified: true } -::: +Both `JRN_CLEAN` and `JRN_MIXED` call `audit` with an inventory equal to the set of +modules their journal already carries, exactly the one module `m`, so no module is +inventory-present but journal-absent, `current_state_only` is empty, and the rendered +report is byte-for-byte the pinned literal, with no `current-state-only` line. The +report fixture `AUDIT_CURRENT_ONLY` is the emptied-mapl scar seen by the audit: an empty +journal (no parseable entries and no corrupt lines), an empty current-hash map, and an +inventory of the one module `greeting`. Because `greeting` is declared in the inventory +yet named by no journal entry, `audit` returns an `AuditResult` whose `entries` is `0`, +whose `chains` is empty, whose `corrupt_lines` is empty, and whose `current_state_only` +is exactly `["greeting"]`. Its rendered report is exactly `napl migrate audit v1` then a +newline, `entries 0`, `corrupt-lines 0`, `chains 0`, `exact 0`, `missing-prefix 0`, +`inconsistent-before 0`, `inconsistent-after 0`, `unreconstructable 0`, a blank line, +`current-state-only greeting`, and `verdict anomalies`, each line followed by one +newline, so the whole report string is +`napl migrate audit v1\nentries 0\ncorrupt-lines 0\nchains 0\nexact 0\nmissing-prefix 0\ninconsistent-before 0\ninconsistent-after 0\nunreconstructable 0\n\ncurrent-state-only greeting\nverdict anomalies\n`. +This proves the scar is visibly distinct in the report and never laundered into a clean +verdict. -::: test -name: identical payload under two object kinds gets two different names -given: { payload: "X", kind_a: blob, kind_b: snapshot } -expect: { equal: false } -::: +The object fixtures pin bytes and names. `LINE_A` is the verbatim line +`{"gen":1,"module":"greeting","target":"rust","files":[{"path":"g.rs","hashBefore":null,"hashAfter":"H2_64"}]}` +with `H2_64` written out, whose blob envelope is `napl-store blob v1 168\n` followed +by that line and whose name is +`ed2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360`. `LEG_MISSING` +is the legacy event `legacy_event("greeting", None, 1, +LegacyClassification::MissingPrefix, H5_64, "2026-07-25T00:00:00Z", [])` where +`H5_64` is the digit `5` sixty-four times, canonicalizing to its pinned bytes and +naming itself `fba5e4f9532e4dd01d1f9571b46afafced354533db1730806acb703643fd05f4`. +`LEG_EXACT` is `legacy_event("greeting", Some("rust"), 2, +LegacyClassification::Exact, ed2f...5360, "2026-07-25T00:00:00Z", [fba5...05f4])` +whose `original_line` is the `LINE_A` blob name and whose one parent is the +`LEG_MISSING` name, canonicalizing to its pinned bytes and naming itself +`65407002b376af4c707dbab5e9a668c743cffb1bd14ef427ca64cf49be8c4fff`. -This module's generated output is as many crates as the design needs, side -by side in the module's owned directory. Keep it a small, clearly separated -workspace (this replaces a 76-crate explosion; do not recreate one), with -`napl-store` staying its own untouched crate. The natural split, which you -may adjust if the design genuinely wants another boundary: +`MS_SEED` is the module-state with `prompt_blob` the digit `1` repeated, +`block_index` the digit `2` repeated, `mapl` `Some` of the digit `5` repeated, `ir` +`None`, and one target `rust` whose `generated_tree` is the digit `3` repeated and +`attribution` is `Some` of the digit `4` repeated, canonicalizing to its pinned +bytes and naming itself +`e4351cea324d75b2362a7321008ddc4d9b98b0ea13f57650445dab61bb0aa272`. `SNAP` is the +snapshot with `parents` the single element `ZERO64` (the genesis-parent placeholder, +the digit `0` sixty-four times), `seq` `2`, and one root entry for `greeting` whose +`module_state` is the `MS_SEED` name and whose `event_head` is the `LEG_EXACT` name, +canonicalizing to its pinned bytes and naming itself +`8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845`. -1. **`napl-core/`** (lib): the pure compiler. Parsing `.napl` files (YAML - frontmatter: module, deps, targets, tests; markdown body), discovery and - dependency graph, canonical path grammar, the identity vocabulary, - per-module artifact slices, wave planning, tree-sitter surface extraction - for Rust and TypeScript targets, the surface grammar, placement rules - and entry-file projection derivation, receipt verification logic, the commit - decision table as pure functions, blame line-ancestry replay, lint - diagnosis, cost calculation, evidence validation. No filesystem writes, - no locks, no clocks, no environment reads: everything enters as - arguments and leaves as values. -2. **`napl-cli/`** (bin `napl`): every command, each a thin composition of - napl-core derivations and napl-store reads plus the one guarded write - path. No private logic in the binary beyond argument parsing and - formatting. +`STAGED_GREETING` is `build_staged_store` over a two-entry journal for module +`greeting`. Its first entry is the line +`{"gen":1,"module":"greeting","files":[{"path":"f.rs","hashBefore":"E64","hashAfter":"B64"}]}` +(gen `1`, no `target`, a missing-prefix `f.rs` step) and its second entry is the +`LINE_A`-shaped line +`{"gen":2,"module":"greeting","target":"rust","files":[{"path":"g.rs","hashBefore":null,"hashAfter":"H2_64"}]}` +(gen `2`, `target` `rust`), which differs from `LINE_A` only in its `gen`; both lines +have `E64`, `B64`, and `H2_64` written out in full and neither records a `timestamp`. +The `seeds` map holds `greeting` mapped to `MS_SEED`, the `audit` marks `f.rs` +`MissingPrefix` and `g.rs` `Exact` with an empty `current_state_only`, and +`genesis_parent` is `ZERO64`. `build_staged_store` reads only the audit's `chains`, so +the audit's `current_state_only` never affects the staged store. The first +entry's legacy event takes `ordinal` `1`, empty `parents`, no `target`, +`MissingPrefix`, `original_line` the first line's blob name, and, the entry having +recorded no timestamp, the empty-string `timestamp`; the second entry's legacy event +takes `ordinal` `2`, that first event's name as its one parent, `target` `rust`, +`Exact`, `original_line` the second line's blob name, and the empty-string +`timestamp`; the second event is the module's event head. The `greeting` root entry +names `MS_SEED` and that event head, `seq` is `2`, and the staged store's `head` is +`8e640fcf87640f7432fa44c8e44b81686ad4b6c67dccebe573c463d7fb8172c0`. -The crates depend on `napl-store` by path, `serde_json`, `sha2`, -`tree-sitter`, `tree-sitter-rust`, `tree-sitter-typescript`, `clap` -(builder API only, driving the command line contract below), and nothing -that talks to a network, spawns a process, or reads clocks; those -declarations live in the user-owned manifests, not in any module's -output. Ensure `cargo test` passes for both crates and `napl-store` from -their workspace before finishing. Generated code is authored against its -final installed location, never a staging tree: acceptance runs at the -final location, so green certifies the bytes where they live. The -working-tree add forms are the operational spelling of this law: author -and verify in place, then stage from where the bytes live; assembling a -separate candidate directory is the exception, for staging bytes that -are not the working tree, never the routine. +`STAGED_CURRENT_ONLY` is `build_staged_store` over an empty journal (a `ParsedJournal` +with no entries and no corrupt lines), an `audit` over that empty journal computed with +an empty inventory (so its `chains`, `corrupt_lines`, and `current_state_only` are all +empty, and `build_staged_store` reads only `chains`), a +`seeds` map holding `greeting` mapped to `MS_SEED`, and `genesis_parent` `ZERO64` (the +digit `0` sixty-four times). Because the journal records nothing, `greeting` has no +legacy event, so it is the current-state-only scar: the builder still emits its +`MS_SEED` module-state object and a `greeting` root entry whose `module_state` is the +`MS_SEED` name and whose `event_head` is `ZERO64`, the genesis sentinel, not any event +name. With no journal entry the `seq` is `1`. The resulting snapshot canonicalizes to +`{"parents":["ZERO64"],"root":{"greeting":{"eventHead":"ZERO64","moduleState":"e4351cea324d75b2362a7321008ddc4d9b98b0ea13f57650445dab61bb0aa272"}},"seq":1}` +(with `ZERO64` written out as sixty-four `0` characters in both places), framed as the +snapshot envelope `napl-store snapshot v1 271` then a newline then those canonical +bytes then a trailing newline, and the staged store's `head` is +`8ea748a37b92e975faeef61ca5ff0a4fb740c64f48f10f8fb1e706f7ea095a8d`. The seeded module +is therefore reachable from the head: a current-state-only module is never dropped +from the snapshot's root. -When several modules share one crate, the crate's shared entry files are -derived projections owned by no module: the crate root `lib.rs` module -list is recomputed at commit from the union of the crate's committed -module file sets, and the binary's command registry is recomputed from -the command tables the crate's modules expose under one fixed convention -(each module may expose `pub fn commands() -> &'static [CommandSpec]` -from its own files; the dispatch loop iterates the derived registry and -contains no per-command match arms of its own). A module's owned output -therefore never includes the shared entry files, two modules can never -claim the same path, and a commit that would write a projection-owned -path is rejected as invalid-candidate. +The abort fixtures are pairs of audit results. The four chain-and-line fixtures each +construct both their `AuditResult`s with an empty `current_state_only`, so the +current-state-only comparison contributes no key and only their chains and corrupt lines +decide them. `CMP_SAME` has current and sanctioned +both mapping `c.rs` to `InconsistentBefore` with no corrupt lines, so it proceeds. +`CMP_WORSE` has sanctioned mapping `c.rs` to `InconsistentBefore` and current +mapping `c.rs` to `InconsistentAfter`, so it aborts naming `c.rs`. `CMP_NEW_CORRUPT` +has current carrying corrupt line `4` that the sanctioned baseline does not, so it +aborts naming `line:4`. `CMP_NEW_ANOMALY` has sanctioned with no entry for `b.rs` +(treated as `Exact`) and current mapping `b.rs` to `MissingPrefix`, so it aborts +naming `b.rs`. The three current-state-only fixtures hold their `chains` and +`corrupt_lines` empty so only the current-state-only comparison decides them. +`CMP_NEW_SCAR` has current `current_state_only` exactly `["greeting"]` and sanctioned +`current_state_only` empty, so `greeting` appears in the current audit but not the +sanctioned baseline and it aborts naming `current-state-only:greeting`. `CMP_SAME_SCAR` +has both current and sanctioned `current_state_only` exactly `["greeting"]`, so the scar +is already sanctioned and it proceeds. `CMP_GONE_SCAR` has current `current_state_only` +empty and sanctioned `current_state_only` exactly `["greeting"]`, so the sanctioned scar +has disappeared from the current audit and it proceeds. -A prompt at the workspace root is legal and means the workspace root is -the crate directory, exactly as git treats the directory it was -initialized in: the placement root is the empty workspace-relative path, -every workspace-relative path is inside it, and every join against it -produces clean relative paths, never a fabricated leading separator. A -cold workspace whose first prompt sits beside `.napl` therefore commits -its first module with no configuration; placement never needs declaring -because the prompt's location is the declaration. +## Test and code discipline -::: test -name: a root-level prompt places at the workspace root and its module commits -given: { workspace: fresh_init_root_prompt, candidate: valid_root_level } -expect: { placement_root: "", owned_globs_relative: true, outcome: installed } -::: +The frontmatter test suite is a contract, not a sketch. Each case in it is realized +by its own dedicated test function that exercises only that case, so the number of +dedicated tests is at least the number of frontmatter cases. Never fold two or more +cases into one shared test, and a single test that loops over a table of inputs never +counts as the dedicated test any of those inputs requires: such a combined test may +exist only as an extra. Cases that differ only in their input while sharing an +expected shape are still distinct cases, each owed its own dedicated test. -::: test -name: status for a root-level module reports a workspace-root plan with clean globs -given: { workspace: fresh_init_root_prompt, flag: module_root } -expect: { plan_placement_root: "", owned_globs_leading_separator: false } -::: +Enumerate the case families by name, each owed its own dedicated verbatim test. The +classification cases: `CH_EXACT_CREATE`, `CH_EXACT_MULTI`, `CH_MISSING_PREFIX`, +`CH_INCONSIST_BEFORE`, `CH_INCONSIST_AFTER`, and the worse-of case +`CH_MISSING_PREFIX_AND_AFTER`, plus the severity-order case, each its own test. The +parse cases: `LINE_VALID`, `LINE_CORRUPT_JSON`, `LINE_CORRUPT_SHAPE`, +`LINE_EXTRA_FIELDS`, `LINE_MISSING_BEFORE`, `LINE_MISSING_AFTER`, `LINE_NULL_BEFORE`, +and `LINE_NULL_AFTER`, each its own test, where the corrupt cases are distinct (one +not JSON at all, one JSON of the wrong shape, one omitting `hashBefore`, one omitting +`hashAfter`) and none discharges another, and the two null cases are distinct from +the two missing cases because an explicit `null` is a valid recorded value while an +absent field is a shape failure. The +report cases: the `JRN_CLEAN` byte-pinned report and the `JRN_MIXED` byte-pinned +report, each its own test, each calling `audit` with an inventory of its journal's own +modules so `current_state_only` is empty and the pinned bytes are unchanged; and the +`AUDIT_CURRENT_ONLY` case, its own test, calling `audit` with an empty journal and an +inventory of the one absent module `greeting`, asserting both that the returned +`AuditResult.current_state_only` equals exactly `["greeting"]` and that +`render_audit_report` returns the pinned bytes carrying the distinct +`current-state-only greeting` line and `verdict anomalies`; it never asserts a clean +verdict and never folds the module into `chains`. The object cases: the `LINE_A` blob envelope-and-name +case, the `LEG_MISSING` canonical-and-name case, the `LEG_EXACT` canonical-and-name +case, the `MS_SEED` canonical-and-name case, and the `SNAP` canonical-and-name case, +each its own test. The staged-store head case `STAGED_GREETING`, its own test, and the +current-state-only staged-store case `STAGED_CURRENT_ONLY`, its own test, asserting +both the pinned `head` name and that the store's object under that head equals the +pinned snapshot envelope bytes, proving the `greeting` root entry carries the +genesis-parent `event_head` and is reachable rather than dropped; the two staged-store +cases are distinct and neither discharges the other. The +abort cases: `CMP_SAME`, `CMP_WORSE`, `CMP_NEW_CORRUPT`, `CMP_NEW_ANOMALY`, +`CMP_NEW_SCAR`, `CMP_SAME_SCAR`, and `CMP_GONE_SCAR`, each its own test, where the three +current-state-only cases are distinct from one another and from the four chain-and-line +cases and none discharges another. -## The command line contract +Inside a case's dedicated test, hand the code exactly the fixture that case gives, +byte for byte, and assert its pinned expectation verbatim: no shorter or more +convenient stand-in, no re-derivation of a value the case pins as a literal, and no +weakening of a pinned equality into an inequality-only or recomputed check. The +byte-pinned cases assert their exact literals. The `JRN_CLEAN` report equals +`napl migrate audit v1` then a newline, `entries 2`, `corrupt-lines 0`, `chains 2`, +`exact 2`, `missing-prefix 0`, `inconsistent-before 0`, `inconsistent-after 0`, +`unreconstructable 0`, a blank line, and `verdict clean`, each followed by one +newline. The `JRN_MIXED` report equals `napl migrate audit v1`, `entries 5`, +`corrupt-lines 1`, `chains 5`, `exact 1`, `missing-prefix 1`, `inconsistent-before +1`, `inconsistent-after 1`, `unreconstructable 1`, a blank line, then the sorted +anomaly lines `inconsistent-after d.rs`, `inconsistent-before c.rs`, `missing-prefix +b.rs`, `unreconstructable line:6`, then `verdict anomalies`, each followed by one +newline. Obtain report bytes from `render_audit_report` and compare to the literal; +obtain a blob envelope from `line_blob(...).envelope` and its name from +`line_blob(...).name`; obtain a canonical payload by framing the built value through +`store_hash` and compare to the literal; obtain an object name from the builder's +`StagedObject` `name` and, separately in the same test, assert it equals the matching +`store_objects` naming function over the same value, so the two naming paths are +pinned to agree. -Every command the registry serves declares its full grammar in its -`CommandSpec`: name, a one-line summary, the exact usage line, and the -closed table of flags it accepts, each with arity and meaning. The -dispatch loop owns two behaviors ahead of every command, so no command -can get them wrong. First, help: `napl help` lists every registered -command with its summary, `napl help ` and `--help` or `-h` -anywhere in a command's arguments print that command's usage line, flag -table, and summary, exit 0, and never invoke the command. Help speaks -human by default and machine on request: when stdout is a terminal or -`--human` is passed, help renders readable text from the registry, -usage line, summary, flag table with meanings, and one worked example -per command; when stdout is piped or `--json` is passed, help emits -the canonical JSON registry exactly as today, byte-stable. Help is -the only command with a human rendering; every other command speaks -canonical JSON everywhere, and the two help renderings derive from -the one registry so they can never disagree. Help is a pure -read: no store access, no filesystem write, no state change of any kind, -and this holds for every command including `init` and `watch`. Second, -strict parsing: an argument beginning with `--` that the command's flag -table does not name is a usage error, never ignored; the error goes to -stderr as canonical JSON naming the unknown flag, listing the command's -valid flags, and suggesting the nearest declared flag by edit distance -when one is close, and the command does not execute. Exit 2 is the -pinned usage-error code for the whole binary: unknown command, unknown -flag, missing required flag or positional, malformed flag value. A -usage error always restates the command's exact usage line, so the -error itself teaches the grammar. +The classification tests assert the exact `store_objects::LegacyClassification` +variant, never a mere boolean: `CH_EXACT_CREATE` and `CH_EXACT_MULTI` assert +`Exact`; `CH_MISSING_PREFIX` asserts `MissingPrefix`; `CH_INCONSIST_BEFORE` asserts +`InconsistentBefore`; `CH_INCONSIST_AFTER` and `CH_MISSING_PREFIX_AND_AFTER` assert +`InconsistentAfter`. The `CH_MISSING_PREFIX_AND_AFTER` test never asserts +`MissingPrefix`, proving the worse condition wins. The severity-order test asserts +`classification_severity` is strictly increasing across `Exact`, `MissingPrefix`, +`InconsistentBefore`, `InconsistentAfter`, `Unreconstructable`, the exact values `0`, +`1`, `2`, `3`, `4`. The parse tests assert the exact parsed fields for a valid line +and that a corrupt line lands in `corrupt` with its verbatim `raw`, never a weakened +`is_ok`. The `LINE_MISSING_BEFORE` and `LINE_MISSING_AFTER` tests each assert their +line lands in `corrupt` with its verbatim `raw`, proving an absent hash field is a +corrupt line rather than a creation or a deletion. The `LINE_NULL_BEFORE` and +`LINE_NULL_AFTER` tests each assert the parsed `FileStep`'s exact `hash_before` and +`hash_after` `Option` variants, `None` for the field recorded as `null` and `Some` of +the recorded hash for the other, proving an explicit `null` remains a valid creation +or deletion step and is never confused with an absent field. The abort tests assert the exact `MigrateDecision` including the offending +key vector verbatim and in sorted order: `CMP_SAME` asserts `Proceed`; `CMP_WORSE` +asserts `Abort` carrying exactly `["c.rs"]`; `CMP_NEW_CORRUPT` asserts `Abort` +carrying exactly `["line:4"]`; `CMP_NEW_ANOMALY` asserts `Abort` carrying exactly +`["b.rs"]`; `CMP_NEW_SCAR` asserts `Abort` carrying exactly +`["current-state-only:greeting"]`; `CMP_SAME_SCAR` asserts `Proceed`; and +`CMP_GONE_SCAR` asserts `Proceed`. Never assert only that the decision is an abort while ignoring its keys. -The parser behind this contract is clap's builder API, constructed at -dispatch from the CommandSpec registry, never derive macros and never -per-command clap declarations: the registry stays the single source of -grammar and clap is its execution engine. clap's own renderings are -never emitted; every parse failure is caught and served as the -canonical JSON usage error above, byte-shaped by this contract alone. -`--flag value` and `--flag=value` parse identically; no command -declares short flags today, so clustering is outside the contract -until one does. The registry pre-pass, not clap, is what refuses an -undeclared flag, because the canonical JSON payload with its -nearest-name suggestion is pinned by this contract; clap normalizes -spellings and values behind it. `napl completions ` emits a completion script for -`bash`, `zsh`, or `fish`, generated from the same registry, a pure -read on stdout; an unknown shell name is a usage error. +The named-substitution prohibitions for this module's confusable tokens are strict. +Never write `missing-prefix` where a case pins `inconsistent-before`, nor +`inconsistent-before` where a case pins `inconsistent-after`, nor `exact` where a +case pins any anomaly token, nor the reverse of any of these; each classification +token is transcribed exactly as its case names it, and the five are never +interchanged. Never write `hashBefore` where a case pins `hashAfter` or the reverse, +nor `stateBefore` where a case pins `stateAfter` or the reverse, nor `moduleState` +where a case pins `eventHead` or the reverse, nor `promptBlob` where a case pins +`blockIndex`, nor `generatedTree` where a case pins `attribution`. The +genesis-parent placeholder `ZERO64` is the digit `0` sixty-four times and is never +confused with the absent-hash `None` of a creation step. The verdict tokens are +exactly `clean` and `anomalies`; never write `ok` or `dirty` in their place. The +report's fixed header token is exactly `napl migrate audit v1`; never abbreviate or +reword it, and never drop the trailing newline that ends the report. -The commands whose meaning requires a committed head are exactly -`diff`, `blame`, and `notes add`; in a workspace that has none they -answer with a prerequisite error, never an empty success shape: the -payload names what is missing and the exact sequence that creates it, -from `napl init` through writing a prompt beside `.napl`, `add`, and -`commit`. `log` and `show` keep their committed contracts and answer -empty honestly. `napl blame` on a path no committed module owns, and -`napl diff` for a target under which no committed module owns output, -say so: the payload carries `tracked: false` and a reason naming the -absent baseline, never an empty patch or empty ancestry that reads as -success. Diff's operand is a target name and its usage line spells it -``; an operand that is not one (a module name, a file -path) is a usage error naming the target names that exist, so the -argument teaches its own vocabulary. The `napl notes add` refusal for a working path -that is not an object name states how an object name is obtained, -naming `napl log` and `napl show` with a one-line example. +Every case above is realized by exactly one dedicated verbatim test; a narrower, +neighboring, or recomputed test does not discharge a case; any supplemental +table-driven or integration test is separately named and never substitutes for a +dedicated test; and the count of dedicated tests is at least the count of frontmatter +cases. -::: test -name: flag equals value and separated value parse identically -given: { workspace: fixture_two_modules, invocations: ["status --module leaf", "status --module=leaf"] } -expect: { outputs_byte_identical: true } -::: +Comment rules: no line or block comments anywhere in the generated code; no doc +comment on a private item; a module `//!` summary and `///` on public items only; no +doctest code blocks in any doc comment; no em dashes in any string, comment, or doc +this module emits. Every struct that deserializes external JSON does so only through +the fields this module reads and never fabricates a value the input did not carry. -::: test -name: completions emits a script for a known shell and refuses an unknown one -given: { workspace: fixture_two_modules, invocations: ["completions zsh", "completions tcsh"] } -expect: { zsh_exit_code: 0, zsh_output_nonempty: true, tcsh_exit_code: 2, store_bytes_unchanged: true } -::: +## migrate_io -::: test -name: blame on an unowned path and diff on an outputless target say untracked -given: { workspace: fixture_two_modules, blame_path: file_no_module_owns, diff_target: target_without_outputs } -expect: { tracked: false, reason_names_baseline: true, exit_code: 0 } -::: +# Migrate io: the command shell that reads the legacy ledger and writes the staged store -::: test -name: status reports code-side drift and stale references beside prompt freshness -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } -expect: { module_drift: true, stale_reference_count: 1, prompt_side_fresh: true } -::: +This module is the filesystem shell over the pure `migrate_core` brain. It is the +whole disk-facing surface of the one-shot ledger migration: it reads the legacy +`journal.jsonl` and `map.json`, runs the audit that the maintainer sanctions, and, +on a clean tree that is no worse than the sanctioned report, seeds the migrated +store's module-states from the current working files and materializes the staged +store into a sibling directory in the design-frozen on-disk layout. Every operation +is deterministic and tempdir-testable: the root and the destination are handed in as +paths, the dirty-tree signal and the sanctioned baseline are handed in as values, no +wall clock is read, and no randomness is used. All classification, report rendering, +object framing, and store assembly are delegated to `migrate_core`; this module owns +only the reading of inputs, the walking of working files, and the writing of the +staged store. -::: test -name: a headless workspace answers baseline commands with the prerequisite sequence -given: { workspace: fresh_init_no_commits, invocation: "blame some/file.rs" } -expect: { error_names_missing_head: true, sequence_named: [init, prompt, add, commit] } -::: +Bring in `serde`, `serde_json`, and `serde_yaml`. -::: test -name: init with a help flag prints usage and mutates nothing -given: { directory: empty, invocation: "init --help" } -expect: { exit_code: 0, created: [], usage_printed: true } -::: +## Where this code lives -::: test -name: an unknown flag is a usage error naming the flag and suggesting the nearest -given: { workspace: fixture_two_modules, invocation: "add cand --modul m" } -expect: { exit_code: 2, error_names_unknown: "--modul", suggested: "--module", executed: false } -::: +The working directory is a Cargo workspace whose root manifest is written and owned +by the toolchain, leave it alone. Create this module as its own member crate in a +subdirectory named `migrate_io/`: `migrate_io/Cargo.toml` (package name +`migrate_io`) and `migrate_io/src/lib.rs`. Touch nothing outside `migrate_io/`. +Ensure `cargo test` passes from the workspace root before finishing. -::: test -name: help lists every registered command and per-command help states exact usage -given: { workspace: fixture_two_modules, invocations: ["help", "help add"] } -expect: { all_commands_listed: true, add_usage_line_present: true, exit_code: 0, store_bytes_unchanged: true } -::: +## Builds on nine modules of this workspace -::: test -name: help renders human text under the human flag and identical json when piped -given: { workspace: fixture_two_modules, invocations: ["help --human", "help --json"] } -expect: { human_not_json: true, human_carries_example: true, json_byte_stable: true } -::: +This crate composes on nine generated sibling crates. Use each one's public API, do +not reimplement its types or logic, and do not depend on any hand-written crate. Do +not bring in the `sha2` crate directly; all hashing goes through `store_hash`. Do +not reimplement journal parsing, classification, report rendering, object framing, or +store assembly; all of those come from `migrate_core`. Do not reimplement attribution +parsing, validation, or the ownership document; the attribution YAML goes through +`schemas_attribution` and the canonical attribution document is `store_diff`'s. Seven of +these are production dependencies used by the module body; the last two, `store_reduce` +and `store_io`, are consumed only by the downstream-consumability integration test, which +reads the store this module materializes back through the real reducer. -Manifests are user-owned glue seams, not records: `Cargo.toml` at the -workspace root and in every crate belongs to the user, edited by hand -like any Rust project, tracked by git, consumed by cargo, and NEVER -owned, installed, validated, or rewritten by the toolchain. A candidate -containing a manifest path is rejected as invalid-candidate; no module's -owned output includes one; nothing in the store records manifest bytes. -The toolchain's only relationship to manifests is diagnosis: `status` -reports, as observations never as writes, a module placed in a crate the -workspace manifest does not list, or a crate directory missing a -manifest. Every manifest diagnostic names the exact workspace-relative -manifest path it means and says the file is the user's to edit, so the -manifest is never a thing status alludes to without locating. A dependency needed by generated code is added by editing the -manifest, and the build battery is what proves it correct. +- **`migrate_core`** (`../migrate_core`) is the pure brain. It uses + `migrate_core::parse_journal(text: &str) -> migrate_core::ParsedJournal`, + `migrate_core::ParsedJournal`, `migrate_core::AuditResult` (whose public fields now + include `current_state_only: Vec`, the inventory modules absent from the + journal), `migrate_core::audit(journal: &migrate_core::ParsedJournal, current_hashes: + &std::collections::BTreeMap, inventory: + &std::collections::BTreeSet) -> migrate_core::AuditResult`, + `migrate_core::render_audit_report(result: + &migrate_core::AuditResult) -> String`, `migrate_core::compare_to_sanctioned(current: + &migrate_core::AuditResult, sanctioned: &migrate_core::AuditResult) -> + migrate_core::MigrateDecision` with its variants `migrate_core::MigrateDecision::Proceed` + and `migrate_core::MigrateDecision::Abort(Vec)`, + `migrate_core::build_staged_store(journal: &migrate_core::ParsedJournal, audit: + &migrate_core::AuditResult, seeds: &std::collections::BTreeMap, genesis_parent: &str) -> migrate_core::StagedStore`, and + `migrate_core::StagedStore` with its public fields `objects: + std::collections::BTreeMap>` and `head: String`. This module never + reimplements any of these; it calls them. +- **`store_objects`** (`../store_objects`) supplies the module-state types this + module seeds. It uses `store_objects::ModuleState` with public fields `prompt_blob: + String` (serde `promptBlob`), `block_index: String` (serde `blockIndex`), `mapl: + Option`, `ir: Option`, and `targets: + std::collections::BTreeMap`, and + `store_objects::TargetArtifacts` with public fields `generated_tree: String` (serde + `generatedTree`) and `attribution: Option`. This module constructs + `ModuleState` values for the seeds map; it never renames or reshapes them. +- **`store_hash`** (`../store_hash`) supplies the envelope framing, canonical JSON, + and object naming. It uses `store_hash::ObjectKind` (the variants + `ObjectKind::Blob`, `ObjectKind::Tree`, and `ObjectKind::BlockIndex`), + `store_hash::canonical_json(value: &serde_json::Value) -> Vec`, + `store_hash::envelope_bytes(kind: store_hash::ObjectKind, payload: &[u8]) -> + Vec`, and `store_hash::object_name(kind: store_hash::ObjectKind, payload: + &[u8]) -> String`. This module frames every component object it publishes through + `store_hash` and never reimplements canonical JSON, envelope framing, or hashing. +- **`store_blocks`** (`../store_blocks`) supplies the prompt segmentation index. It + uses `store_blocks::segment(prompt: &str, seg_version: u32) -> + store_blocks::BlockIndex`, `store_blocks::block_index_object_name(index: + &store_blocks::BlockIndex) -> String`, and the constant `store_blocks::SEG_VERSION`. + This module segments a prompt through `store_blocks::segment` under + `store_blocks::SEG_VERSION` and never reimplements segmentation. +- **`store_tree`** (`../store_tree`) supplies the generated-tree builder. It uses + `store_tree::TreeFile` with public fields `path: String`, `hash: String`, and + `mode: u32`; `store_tree::build_tree(files: &[store_tree::TreeFile]) -> + Result`; `store_tree::BuiltTree` + with public fields `root: String` and `objects: std::collections::BTreeMap`; `store_tree::Tree`; `store_tree::tree_object_name(tree: + &store_tree::Tree) -> String`; and the constant `store_tree::FILE_MODE` (the decimal + value `33188`). This module builds a generated tree through `store_tree::build_tree` + and never reimplements tree building. +- **`schemas_attribution`** (`../schemas_attribution`) parses and validates the on-disk + attribution YAML into the attribution document. It uses + `schemas_attribution::validate_attribution(value: serde_json::Value) -> + Result` and + `schemas_attribution::Attribution` with its public fields `module: String`, `target: + String`, and `entries: Vec`, and + `schemas_attribution::AttributionEntry` with its public fields `prompt_lines` and + `lines` (each an inclusive line range exposing `start: u32` and `end: u32`), `file: + String`, and `note: String`. This module parses a module's attribution YAML through + `schemas_attribution::validate_attribution`, never reimplementing the parse or its + validation. +- **`store_diff`** (`../store_diff`) supplies the store's canonical attribution document, + the exact form a gen commits. It uses `store_diff::Attribution` with public fields + `module: String`, `target: String`, and `entries: Vec`; + `store_diff::OwnershipEntry` with public fields `prompt_lines: [u32; 2]`, `file: + String`, `lines: [u32; 2]`, and `note: String`; and `store_diff::validate_attribution(value: + serde_json::Value) -> Result`. This module maps a parsed + `schemas_attribution::Attribution` into a `store_diff::Attribution` exactly as a gen's + ownership document does and never reimplements the canonical attribution type. +- **`store_reduce`** (`../store_reduce`) is the typed query layer over the + content-addressed store, used only by the downstream integration test. It uses + `store_reduce::ObjectProvider`, `store_reduce::classify(provider: &P, module: &str, + target: &str, observed: &store_reduce::Observed) -> Result` with the variant `store_reduce::Classification::CurrentStateOnly`, + `store_reduce::Observed`, and `store_reduce::event_graph(provider: &P, module: &str) -> + Result, store_reduce::ReduceError>`. The test calls these to + prove the migrated store is consumable; this module never reimplements the reducer. +- **`store_io`** (`../store_io`) supplies the disk-backed `ObjectProvider`, used only by + the downstream integration test. It uses `store_io::DiskProvider` and + `store_io::DiskProvider::open(root: &std::path::Path) -> store_io::DiskProvider`, which + reads objects and `HEAD` from a materialized store root in exactly the layout + `materialize_staged_store` writes. The test opens a `DiskProvider` over the migration's + `dest` and hands it to the reducer. -::: test -name: a candidate containing a manifest path is rejected -given: { workspace: fixture_two_modules, candidate: contains_cargo_toml } -expect: { outcome: invalid_candidate, manifest_bytes_unchanged: true } -::: +## Bare content hashes versus envelope object names -## The two principles +Two different hash schemes meet in this module, and confusing them corrupts the +store. The legacy `journal.jsonl` records a file's **bare content hash** in +`hashBefore` and `hashAfter`, and `map.json` records the same bare content hash in +each `files` entry's `hash`. Those bare hashes are the values the audit classifier +compares: `migrate_core::audit` looks up a path's current hash in the map exactly as +recorded and matches it against the journal's recorded terminal hash, and this module +never recomputes a bare content hash (recomputing one would require the `sha2` crate, +which is forbidden here). The migrated store's objects, by contrast, are named by +their full **envelope object name**: the SHA-256 of the typed, versioned, +length-prefixed envelope, obtained through `store_hash::object_name`. A generated +file therefore has two unrelated names in this migration: the bare content hash the +map records (used only by the audit) and the envelope blob object name this module +computes when it seeds that file into a tree. This module reads bare hashes from the +map for the audit and computes envelope object names through `store_hash` for the +store, and it never substitutes one for the other. Consistency between the working +tree and the map is guaranteed upstream: the dirty-tree refusal guarantees the files +on disk are the committed files the map describes, and the audit's inconsistent-after +classification is what compares the recorded terminal hash to the map's current hash, +so this module performs no additional per-file rehashing. -P1 ownership: the toolchain owns `.napl` source, the derived layer, the -store, and the mappings between them; every owned fact is provable from -content alone. It contains no LLM invocation, no provider identifier, no -model selection, no completion parsing, no process execution (the only -process is `napl` itself), no clock reads in any semantic path, no -randomness in any semantic path. Generation happens outside: the user's -agent session reads the plan from `napl status`, the specification through -the query surface, writes candidate code, and calls `napl add` then -`napl commit`. +## Reading the legacy inputs -::: test -name: the binaries spawn no process and name no provider -given: { crate_source: self } -expect: { spawns_processes: false, provider_identifiers: [] } -::: +Expose a public enum `MigrateIoError` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`) with exactly these variants: -P2 footprint scoping: every operation declares a read set (content -identities consumed), a semantic write set (owned records changed), and its -derived projections plus physical install paths. Two operations conflict -when semantic write sets overlap or one's accepted write changes a content -identity in the other's read set. Derived projections (the crate-root -lib.rs module list, the command registry) are never conflict-bearing: -they are recomputed exactly once from the winning merged head inside -commit and rebase. Manifests are outside footprints entirely: user-owned -seams no operation reads as identity or writes at all. +- `Io(String)`, carrying a human-readable message, for any filesystem read or write + failure (a missing file, an unreadable path, a failed directory creation or file + write) and for a relative path this module refuses to resolve because it is not + contained by the injected root (an absolute path, or one that climbs above root + through parent traversal). An `std::io::Error` is mapped into this variant by + carrying its display string, so the error type stays `Clone` and `Eq`; a + containment refusal carries a short message naming the offending path, and its + message bytes are not pinned. +- `MalformedMap(String)`, carrying serde's message, for a `map.json` text that is not + a JSON object carrying the shape this module reads. -## Identity +Expose a public struct `ModuleView` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`) with public fields `module: String`, `prompt_path: String` (the map's +`prompts` entry key, retained verbatim as the module's prompt identifier for +provenance; in the live ledger this key is the bare module name, not a filesystem +path, so this module treats it as a label and never resolves a prompt through it, +see the prompt-source rule below), and `targets: +std::collections::BTreeMap>` (each declared target mapped to its +list of generated file paths, both taken verbatim from the map). Expose a public +struct `MapView` (same derives) with public fields `current_hashes: +std::collections::BTreeMap` (every `files` path mapped to its bare +content `hash`) and `modules: Vec` (one per `prompts` entry, in ascending +prompt-path order). -Expose `pub const CONTRACT_VERSION: u32 = 4` in napl-core (bumped from 1 -by the body-directive grammar, which changes how the same source bytes -are interpreted, from 2 by the relaxed module-name rule below, which -accepts sources contract 2 rejected, and from 3 by the capture verbs, -whose three refusal outcomes join the closed terminal-outcome table). Per module and -target: `surfaceHash` (hash of the recorded public surface), `inputKey` -(hash of the canonical closure manifest over normalized source hash, direct -dependencies' surfaceHashes, placement facts, entry-point version, target -name plus target-definition hash, CONTRACT_VERSION), `baseStateKey` (the -module's committed owned-output state hash), `sliceHash` (hash of the -module's canonical compile slice). Whole-compile `artifactHash` includes -scope and serves determinism tests only, never a commit precondition. Event -identity is `H(content hash + sorted parent hashes)`; ordinals and -timestamps are display metadata, never identity. Semantic identifiers are -content hashes, never a timestamp, pid, hostname, random value, model, or -provider. Telemetry lives in provenance records linking to semantic objects -by hash. +Expose `parse_map(map_text: &str) -> Result`. It reads only +the fields this module needs and ignores every other field of the real ledger's +`map.json`, because `map.json` is a large foreign document this module does not own +and must not reject for carrying fields it does not read. Deserialize the text into a +lenient view that requires a top-level JSON object with a `files` object (each entry +an object carrying a string `hash`) and a `prompts` object (each entry an object +carrying a string `module` and a `targets` object whose every value is an object +carrying a `files` array of strings); on any failure of that shape return +`MigrateIoError::MalformedMap` carrying serde's message. Build `current_hashes` from +the `files` object (path to its `hash`), and build `modules` from the `prompts` +object: one `ModuleView` per prompt entry, its `prompt_path` the entry's key, its +`module` the entry's `module`, and its `targets` the map of each target name to that +target's `files` list, all verbatim. Fields such as `version`, `promptHash`, +`declaredTargets`, `promptHashAtGen`, and a `files` entry's `target` and `prompts` +are present in the real map and are ignored, never a parse failure. The `prompts` +key is retained in `prompt_path` exactly as written and is never interpreted as a +path: the live ledger keys each `prompts` entry by the bare module name (so the key +equals the entry's `module`), and this module accepts that verbatim, resolving the +prompt source through the module's injected path rather than by this key, see the +prompt-source rule below. -::: test -name: "a module's inputKey covers source, direct dep surfaces, placement, target identity, and contract version" -given: { workspace: fixture_two_modules, module: consumer } -expect: { input_key_components: [source_hash, direct_dep_surface_hashes, placement_facts, target_name, target_definition_hash, entry_point_version, contract_version] } -::: +Every relative path this module reads is map-controlled or map-derived: the map's +`prompts` keys, its `files` paths, and the `.napl` artifact paths built from module +names all flow into these readers, so a hostile or corrupt map must never be able to +steer a read outside the injected root. Expose a shared containment guard that every +reader runs before it touches the filesystem: `contained_path(root: +&std::path::Path, rel: &str) -> Result`. It +refuses, returning `MigrateIoError::Io` and touching no file, any `rel` that is an +absolute path (a `rel` whose `std::path::Path` `is_absolute`, or on a rooted or +prefix component) and any `rel` that, once its `.` and `..` components are collapsed +lexically against `root`, does not stay strictly within `root`; a `rel` carrying a +`..` component that would climb to or above `root` is refused. Containment is purely +lexical: the guard normalizes the component sequence itself and never calls +`canonicalize`, never resolves symlinks, and never reads the filesystem to make its +decision, so it is deterministic and cannot be raced. This module does not defend +against a symlink that already lives inside the trusted, freshly committed tree the +migration runs over; its sole job is to stop a map-recorded relative path from +escaping root through an absolute path or a `..` climb. On success it returns the +joined, contained `PathBuf`. Ordinary nested relative paths such as +`.napl/mapl/greeting.mapl` or `.napl/src/rust/greeting.rs` are contained and pass. + +Expose `read_to_string_at(root: &std::path::Path, rel: &str) -> Result`: resolve `rel` through `contained_path` first, then read that file as +text, mapping any `std::io::Error` to `MigrateIoError::Io`. Expose `read_bytes_at(root: +&std::path::Path, rel: &str) -> Result, MigrateIoError>` for the same +contained read as raw bytes. Neither hardcodes a `.napl` path; the root is always +injected, and neither ever reads a path the guard refuses. + +The migration's optional artifacts (a module's mapl, ir, and attribution) may be +absent, and absence is a legitimate `None`, but an absent file and an unreadable file +are different facts and this module never conflates them. Expose `read_optional_at(root: +&std::path::Path, rel: &str) -> Result>, MigrateIoError>`. It resolves +`rel` through the same containment guard, then probes existence with an error-surfacing +try-exists: a definite non-existence returns `Ok(None)`, a definite existence reads the +file and returns `Ok(Some(bytes))`, and any error while probing (a permission failure, +a symlink loop, or any other metadata error) is returned as `MigrateIoError::Io`, never +swallowed into `Ok(None)`. It never uses a bare existence check that maps a metadata +error to false, because doing so would silently treat an unreadable optional artifact +as absent and drop it from the seed. An io error is an error, not an absence. ::: test -name: changing a leaf's surface changes the direct dependent's inputKey only -given: { workspace: fixture_chain_three, edit: rename_public_item_in_leaf } -expect: { changed_input_keys: [middle], unchanged_input_keys: [top] } +name: a map parses its current file hashes and its module views +given: { fixture: MAP_VIEW } +expect: + current_hashes: + a.rs: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .napl/src/rust/greeting.rs: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + modules: + - { module: "greeting", prompt_path: "prompts/greeting.md", target: "rust", files: [".napl/src/rust/greeting.rs"] } ::: ::: test -name: a leaf edit absorbed by an unchanged intermediate surface leaves higher dependents' inputKeys unchanged -given: { workspace: fixture_chain_three, edit: private_body_change_in_leaf } -expect: { changed_input_keys: [], unchanged_input_keys: [middle, top] } +name: a map that is not a json object carrying a files map is rejected as malformed +given: { fixture: MAP_BAD } +expect: { error: MalformedMap } ::: ::: test -name: telemetry differences never change transition identity -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipts: [with_usage, without_usage] } -expect: { transition_identities_equal: true } +name: a read whose relative path is absolute is refused before touching the filesystem +given: { fixture: PATH_REJECT_ABSOLUTE } +expect: { error: Io } ::: ::: test -name: history nodes are causal hashes over content plus sorted parents -given: { workspace: fixture_two_modules, commits: 2 } -expect: { event_id_components: [content_hash, sorted_parent_ids], ordinal_in_identity: false, timestamp_in_identity: false } +name: a read whose relative path escapes the root through parent traversal is refused +given: { fixture: PATH_REJECT_ESCAPE } +expect: { error: Io } ::: -## Module names +## The prompt-source rule -A module name is any non-empty single-line scalar. The empty string is the only -value rejected, surfaced as `mapping-integrity`. No character class, case, or -identifier shape is required: `todo-app`, `todo_app`, and non-ascii names are all -legal. The same rule governs every name position the parser reads, the `module` -frontmatter value, each `deps` entry, and each declared root, so all three accept -exactly the set the module rule accepts. This relaxation replaces the contract 2 -identifier constraint and rides the CONTRACT_VERSION bump to 3, the sanctioned -identity transition. As with the 1-to-2 bump, every module's inputKey recomputes -because CONTRACT_VERSION is one of its components; no source or surface changes, -only the version does. +The migrated store's prompt blob for a module is content-addressed over that module's +authored full `.napl` source file bytes, frontmatter included, the exact bytes the +status classifier hashes when it reads `root` joined with that module's prompt path. +This identity is the whole point: a module's head-recorded prompt blob and the blob the +status classifier later observes are the same hash of the same full-file bytes, so a +frontmatter edit registers as a prompt change and a migrated head never reads as +spuriously prompt-stale against its own source. A body-only hash would carry a different +value than the classifier observes and break that identity. -Because a name is unrestricted, it is never used raw as a filesystem path segment -or a store key. napl-core exposes one injective, reversible encoding used at every -such site: each byte outside `[A-Za-z0-9._-]` is percent-encoded as `%XX` with -uppercase hex, and a name that is exactly `.` or `..` has its dots percent-encoded -too, so an encoded segment is never `/`, `\`, empty, `.`, or `..` and can never -traverse or alias a directory. A name already matching `[A-Za-z0-9._-]` and not -equal to `.` or `..` encodes to itself, so existing records keep their on-disk -names: `todo-app` stays `todo-app`. Every writer that keys a record or scratch -path by module name, the mapl, ir, attribution, and prompt-at-gen documents, the -staging and relocation scratch directories, and the store root map, uses the -encoded segment; decoding recovers the exact name. On a case-insensitive -filesystem two names differing only in ascii case can still share one segment; -that collision is the author's concern, distinct from the traversal safety above. +The authored full `.napl` is the single prompt source that feeds both the prompt blob +and the block index, exactly the one source a generation reads: the generator +content-addresses the prompt blob over the authored full bytes and segments the block +index over those same bytes, so a migrated module-state is byte-identical to a freshly +generated one, with one segmentation semantic everywhere and no migrate-only variant. -Wherever a module name is serialized into a record it is written as a quoted, -escaped scalar, never interpolated raw, so a name containing YAML or JSON -metacharacters reparses to the identical name. +The authored source is not addressable from the map alone. The live ledger keys each +`prompts` entry by the bare module name, and the map's `files` object records only +generated files, so neither the map's `prompts` key nor its `files` map names the +authored `.napl` path. That path is therefore an injected value, handed to `run_migrate` +as a module-name-to-path mapping exactly as the dirty-tree signal and the sanctioned +baseline are injected, never read from a clock, a live `git` invocation, a directory +walk, or a discovery pass. For each seeded module the migration reads that module's +authored full `.napl` bytes from `root` joined with its injected path through the +contained reader, and derives both the prompt blob and the block index from those bytes. -::: test -name: a non-identifier module name is accepted -given: { workspace: fixture_single_module, module_name: "todo-app" } -expect: { outcome: ok } -::: +This rule is uniform: every seeded module, including a current-state-only module, +content-addresses its prompt blob and segments its block index over its own authored +full `.napl`, and a module whose injected authored source is absent or unreadable +surfaces the same `MigrateIoError::Io` any required read does, never a silent skip. The +body snapshot at `.napl/prompts-at-gen/.md` plays no part in seeding: the +migration derives both the prompt blob and the block index from the authored full +`.napl` and reads no body snapshot for a seed. -::: test -name: an empty module name is rejected as mapping-integrity -given: { workspace: fixture_single_module, module_name: "" } -expect: { outcome: mapping_integrity } -::: +## The audit command -::: test -name: deps entries and declared roots accept the same names as the module rule -given: { module_name: "todo-app", dep_name: "a-b", root_name: "x.y" } -expect: { all_accepted: true } -::: +Expose `audit_report_from(journal_text: &str, map_text: &str) -> Result`, the pure core of `napl migrate --audit`: parse the map through +`parse_map`, parse the journal through `migrate_core::parse_journal`, and audit it +through `migrate_core::audit` against the map's `current_hashes` and the map's module +inventory. The inventory is the set of module names the map declares, built from +`parse_map`'s `modules` (each `ModuleView`'s `module` name); it is exactly this set that +lets the audit surface a map-declared module with no journal history as a +current-state-only scar rather than laundering it into a clean report. Render the +report through `migrate_core::render_audit_report`, returning the exact report string. +It reads no files and touches no disk. + +Expose `run_audit(root: &std::path::Path) -> Result`: read +`journal.jsonl` and `map.json` from `root` (through `read_to_string_at`), then return +`audit_report_from` over those two texts. This is the whole body of the read-only +`napl migrate --audit` command; the caller prints the returned string to standard +output byte for byte. The report is the maintainer's sanction artifact and its bytes +are exactly those `migrate_core::render_audit_report` pins. ::: test -name: the name encoding is injective reversible and never traverses -given: { names: ["todo-app", "a/b", "..", "café", "a b"] } -expect: { decode_roundtrip_equal: true, no_segment_in: ["/", "\\", "", ".", ".."], safe_subset_unchanged: ["todo-app"] } +name: the audit command over an all-exact root prints the pinned clean report bytes +given: { fixture: ROOT_CLEAN } +expect: { report_bytes: "napl migrate audit v1\nentries 2\ncorrupt-lines 0\nchains 2\nexact 2\nmissing-prefix 0\ninconsistent-before 0\ninconsistent-after 0\nunreconstructable 0\n\nverdict clean\n" } ::: ::: test -name: a name with yaml metacharacters is written quoted and reparses identically -given: { module_name: "a: {b} #c" } -expect: { record_reparse_equal: true, written_as_quoted_scalar: true } +name: the audit command over a mixed root prints the pinned anomaly report bytes +given: { fixture: ROOT_MIXED } +expect: { report_bytes: "napl migrate audit v1\nentries 5\ncorrupt-lines 1\nchains 5\nexact 1\nmissing-prefix 1\ninconsistent-before 1\ninconsistent-after 1\nunreconstructable 1\n\ninconsistent-after d.rs\ninconsistent-before c.rs\nmissing-prefix b.rs\nunreconstructable line:6\nverdict anomalies\n" } ::: -## The compile derivation - -Compilation is a pure derivation and NOT a command: parse the graph, -compute per-module slices for the changed frontier, derive -dependency-ordered waves. Never persisted, and never spelled on the CLI — -it is the internal engine of `status`, `add`, `commit`, `watch`, and the -lsp. Per module the slice carries: normalized source body and hash, direct -dependency surfaces and surfaceHashes, placement and allowed-output -constraints including the surface grammar, the owned-output base and -baseStateKey, inputKey, sliceHash, wave position, receipt requirements. It -contains no provider, model, prompt assembly, credentials, timestamps, -retry policy, pricing, or test results. `napl status --module ` -reports the module's PLAN from this derivation — owned globs, placement -root, projection ownership, receipt requirements, wave position — the -invariant requirements only, a pure function of the working tree's -prompts; candidate-dependent validation belongs to `add`, never the plan. - -Pending is never a bare boolean. A pending module names its reason -from a closed vocabulary: `never-committed`, `source-changed`, -`dependency-surface-moved`, `placement-changed`, `target-changed`, -`contract-version-changed`; the reason is derived by comparing the -current derivation's inputKey components against the identities the -committed state records, the same facts the store already holds, -never a guess. And because an undrifted working tree stages exactly -the committed bytes, a pending module with no drift additionally -says the landing would re-stamp identical bytes, so whether a -pending module needs a real regeneration or a no-op landing is read -off status, never discovered by staging and comparing tree hashes. +## The object layout and materialization -::: test -name: a pending module names dependency-surface-moved as its reason -given: { workspace: fixture_chain_three, intervening_commit: leaf_surface_change, flag: module_middle } -expect: { pending: true, pending_reason: dependency_surface_moved } -::: +The migrated store's on-disk layout is design-frozen. Every object is a plain file +whose bytes are its full envelope, stored at a two-character fan-out path under an +`objects` directory, and the store's single mutable ref is a `HEAD` file holding the +head snapshot's name. -::: test -name: a pending undrifted module says the landing re-stamps identical bytes -given: { workspace: fixture_chain_three, intervening_commit: leaf_surface_change, flag: module_middle } -expect: { pending: true, drift: false, reland_identical_bytes: true } -::: +Expose `object_path(name: &str) -> String`: the object's store-relative path, +exactly `objects/`, then the first two characters of `name`, then `/`, then the +remaining characters of `name`. For a sixty-four-character object name this yields +`objects//`. -::: test -name: a never committed module reads pending with its reason -given: { workspace: fixture_two_modules, module: pending_never_committed, flag: module_pending } -expect: { pending: true, pending_reason: never_committed } -::: +Expose `materialize_staged_store(staged: &migrate_core::StagedStore, dest: +&std::path::Path) -> Result<(), MigrateIoError>`. The `dest` is the store root, a +fresh single-writer directory this migration owns (a sibling temporary directory); +this is a bulk write of a fresh store, not the concurrent publication primitive, so +no locks and no compare-and-swap are involved. Even though this is a bulk write, every file this step publishes appears atomically, +so an interrupted run never leaves a half-written object or, worst of all, a `HEAD` +that names a snapshot whose objects are not all on disk. For every `(name, envelope)` +pair in `staged.objects`, first create the two-character fan-out directory under +`dest` when it does not exist, then write `envelope` to a temporary file in that same +fan-out directory (a sidecar beside the final object path, so the rename that follows +is a same-directory rename and never crosses a filesystem boundary), then rename that +temporary file onto the final path at `dest` joined with `object_path(name)`. The +published object file therefore only ever exists with its complete envelope bytes; a +partial write lives only in the temporary sidecar and is never observable at the +object's real path. After every object has been renamed into place, and only then, +publish `HEAD` the same way: write `staged.head` followed by a single newline to a +temporary file in `dest`, then rename that temporary onto `dest` joined with `HEAD`. +`HEAD` is renamed last, after every object it could name is durably at its path, so a +reader that observes `HEAD` always finds the snapshot it names and every object that +snapshot reaches already present, and a partial `HEAD` temporary never appears as a +published ref. Every filesystem failure (a directory creation, a temporary write, or a +rename) maps to `MigrateIoError::Io`. Object files carry the exact envelope bytes +handed in, never re-encoded, and `HEAD` carries the head name and one trailing newline +and nothing else. After a successful materialize the only files under `dest` are the +object files at their fan-out paths and the single `HEAD`; no temporary sidecar +survives, because each was renamed away. ::: test -name: a prompt edit reads pending as source-changed -given: { workspace: fixture_two_modules, working_tree_edit: prompt_body_touched, flag: module_edited } -expect: { pending: true, pending_reason: source_changed } +name: an object name shards into its two-character fan-out path +given: { fixture: NAME_SNAP } +expect: { object_path: "objects/81/57965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845" } ::: ::: test -name: status reports a module plan derived from the working tree -given: { workspace: fixture_two_modules, flag: module_leaf } -expect: { plan_fields: [owned_globs, placement_root, receipt_requirements, wave_position], candidate_dependent_fields: absent } +name: materializing a staged store writes each object at its sharded path and writes head with a trailing newline +given: { fixture: STORE_MIN } +expect: { head_bytes: "8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845\n", objects: [ { path: "objects/81/57965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845", envelope: "napl-store snapshot v1 271\n{\"parents\":[\"0000000000000000000000000000000000000000000000000000000000000000\"],\"root\":{\"greeting\":{\"eventHead\":\"65407002b376af4c707dbab5e9a668c743cffb1bd14ef427ca64cf49be8c4fff\",\"moduleState\":\"e4351cea324d75b2362a7321008ddc4d9b98b0ea13f57650445dab61bb0aa272\"}},\"seq\":2}\n" }, { path: "objects/ed/2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360", envelope: "napl-store blob v1 168\n{\"gen\":1,\"module\":\"greeting\",\"target\":\"rust\",\"files\":[{\"path\":\"g.rs\",\"hashBefore\":null,\"hashAfter\":\"2222222222222222222222222222222222222222222222222222222222222222\"}]}" } ] } ::: ::: test -name: status module answers live state and the derived projection beside the plan -given: { workspace: fixture_two_modules, flag: module_leaf, working_tree_edit: leaf_output_touched } -expect: { state_fields: [attribution, drift, pending, stale_references, tracked], drift: true, projection_path_present: true, projection_content_present: true } +name: materializing a staged store leaves only its objects and head with no partial or temporary file +given: { fixture: MAT_NO_PARTIAL } +expect: { head_bytes: "8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845\n", stray_files: 0 } ::: -::: test -name: show lists attached notes inline and log counts them -given: { workspace: fixture_two_modules, history: [commit, note_attached] } -expect: { show_notes_listed: 1, show_note_text_present: true, log_note_count: 1 } -::: +## Seeding a module-state from working files -## Body directives +The final migration snapshot's module-states are seeded from the current working +files, so the migrated HEAD is complete and exact even where history is not. Seeding +is pure over the bytes of those files: the caller reads each file and hands its bytes +in, so the function has no filesystem dependency and is fully deterministic. -The prompt body grammar reserves directive blocks, `directiveVersion 1`, -and recognizing them is part of the identity contract: text that older -binaries read as inert prose can be a compile error under this grammar, so -this recognition rides a CONTRACT_VERSION bump, the sanctioned identity -transition. The directives are `test` and the docs family stated below. -For `test`, an opener is a line that is exactly `::: test`; the closer -is a line that is exactly `:::`; nothing nests inside a test block. An -unknown `::: ` opener is a compile error, never prose, so future -directive names are non-breaking. Test blocks are body text: they count -in normalized body lines and attribution ranges may cover them. Docs -fences are the one exception, display content excluded from the -semantic body, and their own section states that law in full. +Expose a public struct `GeneratedFile` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`) with public fields `path: String` (the generated file's tree-relative path), +`bytes: Vec` (its exact content), and `mode: u32` (its file mode). Expose a +public struct `SeededModule` (same derives) with public fields `state: +store_objects::ModuleState` (the seeded module-state value) and `objects: +std::collections::BTreeMap>` (every component object the state +references, keyed by object name to its envelope bytes, ready to merge into the staged +store). -Block content is one case in an exact value grammar mapping 1:1 onto -canonical JSON, so two binaries cannot disagree on a value: one mapping -with keys `name` (required, non-empty, single line), `given` (optional, -mapping), `expect` (required, mapping). Block mappings and sequences -indent by exactly two spaces; flow `{}` and `[]` forms are allowed. -Strings are double-quoted with JSON escapes, or plain scalars ended by a -structural character (`:`, `,`, `{`, `}`, `[`, `]`, `#`) or end of line -with no leading or trailing space. Numbers are JSON number syntax; -`true`, `false`, `null` are keywords; every other plain scalar is a -string. Nothing else from YAML exists here: no comments, no duplicate -mapping keys, no anchors, aliases, tags, block scalars, multiline -scalars, multi-document markers, octal or hex, timestamps, or merge keys. -A case needing prose belongs in prose. Diagnostics are compile errors -naming the exact body line, judged in this precedence: unclosed block, -unknown directive name, nested opener, content outside the value grammar, -missing or empty name, missing expect, duplicate case name within the -module across both positions after NFC normalization and whitespace trim. -Frontmatter `tests:` remains legal; a module's case set is the union of -both positions; extraction of cases from body bytes is deterministic — -same bytes, same cases. +Expose `seed_module_state(prompt_bytes: &[u8], mapl_bytes: Option<&[u8]>, ir_bytes: +Option<&[u8]>, target: &str, generated: &[GeneratedFile], attribution_bytes: +Option<&[u8]>) -> SeededModule`. The `prompt_bytes` are the module's authored full +`.napl` source bytes; both the prompt blob and the block index are derived from them, a +single prompt source feeding both. It composes the frozen builders, never reimplementing +any of them, and assembles both the module-state and its component objects: -::: test -name: an unclosed test directive block is a compile error naming its opener line -given: { body: directive_without_closer } -expect: { outcome: compile_error, line_named: opener } -::: +- The prompt blob: its envelope is `store_hash::envelope_bytes(ObjectKind::Blob, + prompt_bytes)` and its name is `store_hash::object_name(ObjectKind::Blob, + prompt_bytes)`. The name becomes the state's `prompt_blob`, and the object is + inserted into `objects`. A blob's payload is raw bytes, never canonical JSON. +- The block index: segment the prompt through `store_blocks::segment(prompt, + store_blocks::SEG_VERSION)` where `prompt` is the UTF-8 string of `prompt_bytes`, + name it through `store_blocks::block_index_object_name`, and set that name as the + state's `block_index`. Frame the index's own object by serializing the `BlockIndex` + to a `serde_json::Value` and inserting `store_hash::envelope_bytes(ObjectKind::BlockIndex, + &store_hash::canonical_json(&value))` into `objects` under that same name, which + equals `store_blocks::block_index_object_name` over the index. +- The mapl and ir: when `mapl_bytes` is `Some`, its blob name becomes the state's + `mapl` and its blob object is inserted; when `None`, the state's `mapl` is `None` + and no object is added. The ir follows the identical rule with `ir_bytes` and the + state's `ir`. Both sit at the module level, not inside a target. +- The generated tree for `target`: build one `store_tree::TreeFile` per + `GeneratedFile`, its `path` the file's `path` verbatim, its `hash` the blob object + name `store_hash::object_name(ObjectKind::Blob, &file.bytes)`, and its `mode` the + file's `mode`; call `store_tree::build_tree` over that slice. The build's `root` + becomes the target's `generated_tree`. Insert into `objects` every generated file's + blob object (envelope over its bytes) and, for every `(name, tree)` pair in the + build's `objects`, the tree's envelope + `store_hash::envelope_bytes(ObjectKind::Tree, &store_hash::canonical_json(&value))` + under `name`, where `value` is the serialized `Tree` and `name` equals + `store_tree::tree_object_name` over that tree. +- The attribution: when `attribution_bytes` is `Some`, its blob name becomes the + target's `attribution` and its blob object is inserted verbatim; when `None`, the + target's `attribution` is `None`. The `attribution_bytes` are the store's canonical + attribution blob, the caller having already parsed the on-disk YAML and mapped it to + the canonical form (the attribution-canonical-form rule below), so this function + publishes them exactly as it publishes `mapl_bytes` and `ir_bytes`, byte for byte with + no re-encoding. +- Assemble the `store_objects::ModuleState` with the `prompt_blob`, `block_index`, + `mapl`, and `ir` set above and a `targets` map holding one entry, `target` mapped to + a `store_objects::TargetArtifacts` with the `generated_tree` and `attribution` set + above. The module-state object itself is not inserted into `objects`; it is framed + by `migrate_core::build_staged_store` from the seed, so `objects` carries only the + components the state references. ::: test -name: "an unknown directive name is a compile error, never prose" -given: { body: unknown_directive_opener } -expect: { outcome: compile_error, treated_as_prose: false } +name: seeding a module names its component blobs and composes its block index and generated tree +given: { fixture: SEED_GREETING } +expect: + prompt_blob: "45b2871a9dd98a8c943d93e345cba9f284f39aec307f3d9c9530561f3ce2382f" + mapl: "4b693e63b8c3b60e9c15db024bf8591e2ebb2af1da3dbe480c1c1a94ec7288ca" + ir: null + attribution_matches_canonical_json: true + block_index_matches_segment: true + generated_tree_matches_build_tree: true + objects_present: true ::: -::: test -name: a duplicate case name across frontmatter and body errors -given: { frontmatter_case: alpha, body_directive_case: alpha } -expect: { outcome: compile_error, diagnostic: duplicate_case_name } -::: +## The attribution canonical form -::: test -name: directive case extraction is deterministic -given: { body: fixture_with_two_directives, runs: 2 } -expect: { cases: 2, bytes_equal: true } -::: +The store's canonical attribution blob is canonical JSON, not the on-disk YAML. A gen +derives a `store_diff::Attribution` ownership document and publishes +`store_hash::canonical_json` of its `serde_json::Value` as the blob, and the sole +store-blob reader, the reducer's attribution loader, JSON-parses that payload and +validates it through `store_diff::validate_attribution`. The object framing does not +police a blob payload's shape, so a YAML blob would pass framing yet fail that reader as +a corrupt object and stall every incremental gen that reads prior attribution. The +migration therefore never seeds the raw YAML bytes; it reconstructs the exact blob a gen +would publish. -### The docs directive +Canonicalizing a module's attribution proceeds in this order, byte-identical to the gen +path, and its result is the attribution blob bytes handed to `seed_module_state`: -A prompt section may carry `::: docs` fences. The body of a docs fence -is display prose for human documentation: plain markdown plus the -language's own directive vocabulary, never a renderer's component -syntax, so any consumer that can render markdown and the named -directives can render a prompt, and no prompt ever names a website's -component. +- Parse the on-disk attribution YAML text into a `serde_json::Value` the same way a gen + reads a prior attribution back, deserializing the YAML into a `serde_yaml::Value` and + converting that into a `serde_json::Value`. +- Validate and read that value through `schemas_attribution::validate_attribution` into a + `schemas_attribution::Attribution`, the document carrying `module`, `target`, and + `entries`, and tolerating the optional `generatedBy` telemetry block a gen also writes. +- Map that document into a `store_diff::Attribution` exactly as a gen's ownership document + does: `module` and `target` verbatim, and one `store_diff::OwnershipEntry` per + `schemas_attribution::AttributionEntry` with `prompt_lines` the two-element array of the + entry's prompt line range `start` and `end`, `file` the entry's `file`, `lines` the + two-element array of the entry's line range `start` and `end`, and `note` the entry's + `note`. The mapping keeps only these fields and drops the telemetry block, so the + document is the ownership document a gen commits and nothing more. +- Serialize the `store_diff::Attribution` to a `serde_json::Value`, validate it through + `store_diff::validate_attribution` whose `deny_unknown_fields` guarantees the mapping is + lossless, and take `store_hash::canonical_json` of that value as the attribution blob + bytes. These bytes are byte-identical to the blob a fresh gen publishes for the same + attribution. -The docs vocabulary is language-owned and deliberately tiny: `docs` -for prose, `example` for runnable content, `note` and `warning` as -admonitions. An example body is validated: napl source in it must pass -check and CLI invocations in it must parse under the live command -grammar, so a published example cannot rot. An example fence may -carry a `lang` attribute naming what its body is, `lang="napl"` for -prompt source and `lang="cli"` for command invocations; when `lang` -is present it selects the validator, and when it is absent the -validator is chosen by inspection as before, so every existing fence -keeps its meaning. A `lang` the language does not know is refused -naming the line, because a validated vocabulary with an unvalidated -discriminator would let examples rot behind a typo. Directives named `x-` -followed by anything are the extension namespace: carried verbatim, -never validated, a renderer's private experiment and a candidate for -promotion. A renderer meeting an unknown directive renders its body as -a plain block, never an error, so old renderers survive new -vocabulary; check keeps refusing unknown names, so tolerance is a -renderer rule, never a check rule. +Any failure along this path, an attribution that is not valid UTF-8, not valid YAML, +does not validate as an attribution document, or does not map and revalidate as a +canonical ownership document, is a `MigrateIoError::Io` naming the module, carrying a +short message whose bytes are not pinned; the migration never falls back to seeding the +raw YAML bytes. -Directive names follow the micromark grammar: alphanumeric plus `-` -and `_`, never ending in `-` or `_`, so `x-tabs` is a name and a bare -`x-` is not. Attributes are HTML-like braces per the same grammar, -the `:::example{name="..."}` spelling: `{a}`, `{a=b}`, `{a="b"}`, and -`{a='b'}` are equivalent, `.class` shorthands accumulate, and a later -`#id` overrides an earlier one. +## The genesis parent -Fence lines are unambiguous by shape: an opening line names its -directive, a closing line is bare colons. The parser keeps a fence -stack. Nesting spells the remark-directive way, more colons outside: +Expose `pub const GENESIS_PARENT: &str = +"0000000000000000000000000000000000000000000000000000000000000000"`, the store-wide +genesis sentinel: the all-zeros object name, sixty-four `0` characters. It is +shape-valid, never published, and absent from every store by construction, so the +snapshot spine's absent-ancestor walk terminates at it with no special genesis case. +The same sentinel also stands as the `event_head` of a current-state-only module's root +entry, where it is not an absent ancestor to walk over but the module's empty-history +boundary: the store's reducer recognizes that sentinel value and answers a +current-state-only module's event chain as empty, its classification as +current-state-only, and its ordinal lookup as absent, never fetching a nonexistent event. +This is the exact value `napl migrate` passes as the final snapshot chain root's +parent; `migrate_core::build_staged_store` takes the genesis parent as a parameter and +this module supplies `GENESIS_PARENT` for it in production. It is never confused with +a creation step's absent `hashBefore`, which is a JSON `null`, not this hash string. - ::::docs - prose - :::example{name="read the head"} - napl q '.head' - ::: - :::: +::: test +name: the genesis parent constant is the all-zeros sentinel +given: { probe: genesis_parent } +expect: { genesis_parent: "0000000000000000000000000000000000000000000000000000000000000000" } +::: -A closing line of N colons closes the innermost open fence whose -opening count is at most N, the same discipline code fences use, so -every document legal under remark-directive parses here with the same -shape. Same-count nesting is additionally legal, because an opening -line always carries a name and a closing line never does. In this -contract only top-level fences carry docs payloads; an inner fence -rides verbatim inside the outer body, unparsed, so a later contract -may parse children without breaking any prompt written today. Code -fences suspend the stack, so a code block may show `:::` lines and -directive syntax freely; directive syntax shown as prose must sit in -a code fence for the same reason. Where micromark lets an unclosed -container run silently to the end of its parent, check refuses it -naming the opening line: deliberate strictness, not incompatibility. +## The migrate command -Fences are ordered siblings within their section, and order is -meaning: prose, then a warning, then more prose renders as that -interleaved flow, which is how a caveat lands mid-explanation without -nesting. Adjacent example fences sharing a name and differing by a -variant attribute are one example in several spellings; how a -renderer groups them is the renderer's business. +Expose a public enum `MigrateOutcome` (deriving `Debug`, `Clone`, `PartialEq`, +`Eq`) with exactly these variants: -The first docs fence of a module, when it sits before the module's -first section heading, is the module preamble: its body introduces the -module and its attributes may carry `title` and `description`, -document metadata any renderer may use for a page head, an index card, -or a search snippet. A module without a preamble fence simply has no -display title beyond its name. +- `Migrated { head: String, object_count: usize }`, the head snapshot name the store + was written to and the number of objects written. +- `RefusedDirty`, the migration refused because the working tree is dirty. +- `Aborted(Vec)`, the migration aborted because the current audit is worse + than the sanctioned baseline, carrying the offending keys in ascending byte order. -Docs fences are display content, not contract. The semantic body of a -prompt, the bytes that inputKey, attribution prompt hashes, and every -freshness derivation consume, is the body with all docs fences -removed, and attribution endpoints name lines of that semantic body, -so inserting or growing a docs fence shifts no endpoint. Editing, -adding, or deleting a docs fence therefore never re-keys a module, -never marks code pending, and never moves an attribution endpoint. A -prompt whose only change is inside docs fences produces the same -inputKey and the same attribution verdicts, and the store records the -new prompt bytes as a new object whose module state is unchanged. +Expose `run_migrate(root: &std::path::Path, dest: &std::path::Path, dirty: bool, +sanctioned: &migrate_core::AuditResult, prompt_paths: &std::collections::BTreeMap) -> Result`. The `dirty` flag, the `sanctioned` +baseline, and the `prompt_paths` mapping are injected values, never read from a clock, a +live `git` invocation, or a directory walk, so the whole command is deterministic and +tempdir-testable. The `prompt_paths` mapping carries each module's authored full `.napl` +source path relative to `root`, the module name mapped to its authored prompt path; it +is the sole source of the authored bytes both the prompt blob and the block index are +derived from, since that path is not recoverable from the map. It proceeds in this +order, writing nothing to `dest` unless it reaches the materialize step: -::: test -name: "a docs-only edit changes prompt bytes but no key, pending, or endpoint" -given: { edit: inside_docs_fence_only } -expect: { input_key_changed: false, pending: [], endpoints_moved: 0, prompt_bytes_changed: true } -::: +- Read `journal.jsonl` and `map.json` from `root`, parse the map through `parse_map`, + parse the journal through `migrate_core::parse_journal`, and audit it through + `migrate_core::audit` against the map's `current_hashes` and the map's module + inventory (the set of the map's `ModuleView` module names). The same inventory the + audit command uses, so a current-state-only module surfaces identically whether the + maintainer runs `--audit` first or the migration audits internally. +- If `dirty` is `true`, return `Ok(MigrateOutcome::RefusedDirty)` without writing any + file: a dirty tree is refused before anything is staged. +- Compute `migrate_core::compare_to_sanctioned(¤t_audit, sanctioned)`. If it is + `MigrateDecision::Abort(keys)`, return `Ok(MigrateOutcome::Aborted(keys))` without + writing any file: a live ledger worse than the sanctioned report aborts. +- Otherwise seed each module. For every `ModuleView` in the map, read its authored full + `.napl` source bytes from `root` joined with that module's path in `prompt_paths` + through `read_bytes_at`, the single prompt source both the prompt blob and the block + index are derived from (the prompt-source rule above), refusing with + `MigrateIoError::Io` a module the mapping does not carry rather than silently skipping + it; read its mapl bytes from `root` joined with + `.napl/mapl/.mapl` through + `read_optional_at` (an absent file is `None`, an unreadable file is an `Io` error); + read its ir bytes from `root` joined with `.napl/ir/.yaml` through + `read_optional_at`; read its attribution YAML from `root` joined with + `.napl/attribution/.yaml` through `read_optional_at`, and when it is `Some` + canonicalize those YAML bytes into the store's attribution blob bytes before seeding + (the attribution-canonical-form rule below), surfacing a present attribution that + fails to parse, validate, or map as a `MigrateIoError::Io` naming the module and never + seeding it verbatim, while an absent attribution stays `None`. For the + module's one target and its file list, read each generated file's bytes from `root` + joined with that file's full map-recorded path (the byte read is always + root-relative), and build a `GeneratedFile` whose `bytes` are that content, whose + `mode` is `store_tree::FILE_MODE`, and whose `path` is the map-recorded path with its + leading `.napl/src//` prefix stripped, where `` is the module's target + name. The strip makes `GeneratedFile.path` tree-relative as that field's contract + requires and byte-identical to the module-topped, target-root-relative path a + generation commits, so the seeded generated tree equals the store's generated tree and + the reducer's exact-tree-equality classification never reads the migrated module as + drifted. A map path that does not begin with the expected `.napl/src//` prefix + for its target is a migration error surfaced as `MigrateIoError::Io` carrying a short + message naming the offending path, never a silent verbatim pass-through; the message + bytes are not pinned. Only the tree path is stripped, the byte read keeps the full + root-relative path. Call `seed_module_state` with the authored source bytes and the + canonicalized attribution blob bytes and collect its `SeededModule`. +- Assemble the seeds map (module name to the seeded `store_objects::ModuleState`), + call `migrate_core::build_staged_store(&journal, ¤t_audit, &seeds, + GENESIS_PARENT)`, and merge every seeded module's component objects into the staged + store's `objects` (a shared object name maps to identical bytes, so a merge is + idempotent). Materialize the merged staged store into `dest` through + `materialize_staged_store`, and return `Ok(MigrateOutcome::Migrated { head: + staged.head.clone(), object_count: staged.objects.len() })`. -::: test -name: a docs fence containing a code fence containing three colons parses as one fence -given: { body: docs_fence_with_code_fence_colons } -expect: { docs_payloads: 1 } -::: +Every module the map declares is seeded, including a module whose `prompt_path` and +generated files exist on disk but whose legacy events have vanished from the journal: +the emptied-mapl current-state-only scar. This module seeds such a module exactly like +any other, from its current working files, and hands it into the seeds map. The +reachability of that seed is the builder's contract, not this shell's: `migrate_core:: +build_staged_store` gives every seeded module a root entry, so a current-state-only +module reaches the migrated `HEAD` with its `event_head` pointing at the genesis-parent +sentinel `GENESIS_PARENT`, never dropped from the snapshot. This shell fabricates no +event to make that happen; it only seeds the current state and lets the builder place +it in the root. -::: test -name: a same-count example fence inside a docs fence rides verbatim in one payload -given: { body: docs_fence_with_inner_example_same_count } -expect: { docs_payloads: 1, inner_fence_verbatim: true } -::: +Only the `Migrated` path writes to `dest`; the `RefusedDirty` and `Aborted` paths +leave `dest` untouched. ::: test -name: a four-colon docs fence closes around a three-colon example per remark -given: { body: four_colon_docs_wrapping_three_colon_example } -expect: { docs_payloads: 1, inner_fence_verbatim: true } +name: migrate refuses on a dirty tree and writes no store +given: { fixture: MIG_DIRTY } +expect: { outcome: RefusedDirty, store_written: false } ::: ::: test -name: a fence left open at section end is refused naming the opening line -given: { body: docs_fence_without_closer } -expect: { outcome: compile_error, line_named: opener } +name: migrate aborts when a chain is worse than the sanctioned baseline and writes no store +given: { fixture: MIG_ABORT } +expect: { outcome: Aborted, offending: ["c.rs"], store_written: false } ::: ::: test -name: an invalid directive name ending in a hyphen is refused naming the line -given: { body: docs_fence_named_x_dash } -expect: { outcome: compile_error } +name: migrate proceeds on a clean tree and writes a consistent store carrying its head and the seeded prompt blob +given: { fixture: MIG_PROCEED } +expect: { outcome: Migrated, head_file_matches_outcome: true, prompt_blob_object_present: true } ::: ::: test -name: an example lang attribute selects the validator -given: { body: example_lang_napl_with_cli_looking_body } -expect: { validated_as: napl_source } +name: an unreadable optional artifact surfaces an io error rather than a silent absence +given: { fixture: MIG_OPT_UNREADABLE } +expect: { error: Io } ::: ::: test -name: an unknown example lang is refused naming the line -given: { body: example_lang_unknown } -expect: { outcome: compile_error } +name: a current-state-only module is reachable from the migrated head and its audit surfaces the distinct scar +given: { fixture: MIG_CURRENT_ONLY } +expect: { outcome: Migrated, event_head: "0000000000000000000000000000000000000000000000000000000000000000", module_state_object_present: true, audit_report: "napl migrate audit v1\nentries 0\ncorrupt-lines 0\nchains 0\nexact 0\nmissing-prefix 0\ninconsistent-before 0\ninconsistent-after 0\nunreconstructable 0\n\ncurrent-state-only greeting\nverdict anomalies\n" } ::: ::: test -name: a napl code block inside a docs fence that fails check is refused naming the block line -given: { body: docs_fence_with_broken_napl_example } -expect: { outcome: compile_error, line_named: example_block } +name: a current-state-only migrated store is consumable by the real reducer without a missing-object error +given: { fixture: MIG_CURRENT_ONLY_DOWNSTREAM } +expect: { classification: current-state-only, event_graph_nodes: 0 } ::: -::: test -name: a CLI line inside an example fence must parse under the live command grammar -given: { body: docs_example_with_ghost_cli_flag } -expect: { outcome: compile_error } -::: +## Fixtures the tests pin -## Recorded surfaces +The sixty-four-character hash constants are each a single character repeated +sixty-four times: `A64` is `a`, `B64` is `b`, `C64` is `c`, `D64` is `d`, `F64` is +`f`, `H2_64` is the digit `2`, and `ZERO64` is the digit `0`. -At commit, extract the candidate's public API with tree-sitter (pinned -grammars) and record it as a `surface` object; dependents' inputKeys pin -recorded surfaceHashes, store facts, never demand scans. The surface -grammar makes extraction sound by construction and violations reject as -`mapping-integrity`: for Rust, public items must be named `pub` -declarations at statically visible paths, no glob re-exports, no -macro-generated `pub` items; a named `pub use` is allowed and is recorded -as the unresolved written binding, never a resolved signature. For -TypeScript: explicit named exports only, no `export *`. Extraction is -syntactic, deterministic, and loud on malformed input. Loud means a -payload, never silence: extraction over bytes that do not parse -answers a parse failure naming the file and the first error's line, -distinct from a genuinely empty surface, in every seat that extracts, -the commit recorder, the `surface` read, and the query layer, so an -empty item list always means parsed and nothing public. Re-extraction from -committed output must reproduce the recorded surface byte-identically. +The map fixture `MAP_VIEW` is a `map.json` text whose `version` is `2`, whose +`prompts` object carries one entry keyed `prompts/greeting.md` with `module` +`greeting`, a `promptHash` string, a `declaredTargets` array, and a `targets` object +with one target `rust` carrying a `promptHashAtGen` string and a `files` array +holding the one path `.napl/src/rust/greeting.rs`, and whose `files` object carries +two entries: `a.rs` with `target` `rust`, `hash` `A64`, and a `prompts` array, and +`.napl/src/rust/greeting.rs` with `target` `rust`, `hash` `B64`, and a `prompts` +array. Parsing it yields `current_hashes` mapping `a.rs` to `A64` and +`.napl/src/rust/greeting.rs` to `B64`, and one `ModuleView` for `greeting` with +`prompt_path` `prompts/greeting.md` and target `rust` mapped to the one file path. +The extra `version`, `promptHash`, `declaredTargets`, `promptHashAtGen`, and per-file +`target` and `prompts` fields are ignored, never a parse failure. -The `surface` read is honest about absence the same way: a module with -no committed state under the target answers `tracked: false` naming -the absent baseline, never an empty item list that reads as a real -surface with nothing public. +The map fixture `MAP_BAD` is the text `{"version":2}`, a JSON object with no `files` +map, which `parse_map` rejects as `MigrateIoError::MalformedMap`. -::: test -name: extraction over unparseable bytes answers parse-failed never an empty surface -given: { source_bytes: syntactically_invalid_rust, extraction: rust_surface } -expect: { outcome: parse_failed, error_names_line: true, served_as_empty_surface: false } -::: +The root fixture `ROOT_CLEAN` is a temporary directory holding a `journal.jsonl` of +these two lines and a `map.json` whose `files` object gives the current hashes: -::: test -name: surface for an uncommitted module says untracked never an empty list -given: { workspace: fixture_two_modules, module: pending_never_committed, read: surface_for_target } -expect: { tracked: false, reason_names_baseline: true, empty_item_list_served: false } -::: +- line 1 + `{"gen":1,"module":"m","target":"rust","files":[{"path":"a.rs","hashBefore":null,"hashAfter":"A64"}]}` +- line 2 + `{"gen":2,"module":"m","target":"rust","files":[{"path":"b.rs","hashBefore":null,"hashAfter":"B64"}]}` -::: test -name: a candidate with a glob re-export is rejected as mapping-integrity -given: { workspace: fixture_two_modules, candidate: contains_glob_reexport } -expect: { outcome: mapping_integrity, published_semantic_state: false } -::: +with the map's `files` object mapping `a.rs` to `A64` and `b.rs` to `B64` (its +`prompts` object may be empty), so `run_audit` returns exactly the pinned clean +report. The root fixture `ROOT_MIXED` is a temporary directory holding a +`journal.jsonl` of these six lines and a `map.json` whose `files` object maps `a.rs` +to `A64`, `b.rs` to `B64`, `c.rs` to `H2_64`, and `d.rs` to `F64`: -::: test -name: a named pub use is recorded in the surface as the written binding -given: { workspace: fixture_two_modules, candidate: valid_with_named_reexport } -expect: { surface_contains_written_binding: true, surface_contains_resolved_signature: false } -::: +- line 1 + `{"gen":1,"module":"m","target":"rust","files":[{"path":"a.rs","hashBefore":null,"hashAfter":"A64"}]}` +- line 2 + `{"gen":2,"module":"m","target":"rust","files":[{"path":"b.rs","hashBefore":"E64","hashAfter":"B64"}]}` +- line 3 + `{"gen":3,"module":"m","target":"rust","files":[{"path":"c.rs","hashBefore":null,"hashAfter":"H1_64"}]}` +- line 4 + `{"gen":4,"module":"m","target":"rust","files":[{"path":"c.rs","hashBefore":"H9_64","hashAfter":"H2_64"}]}` +- line 5 + `{"gen":5,"module":"m","target":"rust","files":[{"path":"d.rs","hashBefore":null,"hashAfter":"D64"}]}` +- line 6 the text `{ this is not valid json` -::: test -name: recorded surface re-extraction from committed output is byte-identical -given: { workspace: fixture_two_modules, candidate: valid_for_consumer } -expect: { reextraction_equal: true } -::: +with `E64` the letter `e`, `H1_64` the digit `1`, and `H9_64` the digit `9`, all +written out in full, so `run_audit` returns exactly the pinned anomaly report. These +two report fixtures reuse the identical journals and current-hash maps that +`migrate_core` pins for its `JRN_CLEAN` and `JRN_MIXED` cases, so their reports are +the same pinned bytes, now produced end to end from files on disk. -## add, then commit +The name fixture `NAME_SNAP` is the object name +`8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845`, whose +`object_path` is +`objects/81/57965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845`. -Git parity is the mental model: `add` stages, `commit` installs. The -receipt is an internal record between them, never a user-facing noun. -`napl commit` with no arguments installs every staged record in -dependency-wave order, rebasing where the decision table allows, one -outcome reported per record; `--staged ` installs one. A -multi-module add followed by a bare commit is therefore one command -each, with the sequencing and retry the table already defines owned by -the tool, never hand-looped; and when an earlier record in the same -invocation moves the head so that a later one returns write-conflict -or stale-input against it, bare commit re-derives that record against -the new head, exactly what re-running add would produce, and retries -it once, so modules sharing one crate land in one invocation instead -of hand-ordered pairs. A retry that fails again returns its outcome -honestly. A bare commit that finds nothing staged says so instead of -dressing emptiness as success: the payload names that zero records -were staged, and when any module carries working-tree drift it names -each drifted module beside the exact `napl add` invocation that -would stage it, so the empty commit is the teacher, never the trap. -Commit sweeps the staged record it installed, so a later -bare commit never re-attempts history; `napl gc` reports staged -records among the unreachable, and `napl gc --apply` sweeps them: -they are disposable litter by the git-state law, re-creatable by -re-running add, and no one should ever hand-edit `.napl` to be rid of -them. +The store fixture `STORE_MIN` is a `migrate_core::StagedStore` value built directly in +the test, not derived from `build_staged_store`, so its bytes are fully pinned. Its +`head` is `8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845`. Its +`objects` map holds exactly two entries at distinct fan-out shards: the snapshot +object named +`8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845` whose envelope +bytes are `napl-store snapshot v1 271\n` followed by the canonical snapshot payload +`{"parents":["ZERO64"],"root":{"greeting":{"eventHead":"65407002b376af4c707dbab5e9a668c743cffb1bd14ef427ca64cf49be8c4fff","moduleState":"e4351cea324d75b2362a7321008ddc4d9b98b0ea13f57650445dab61bb0aa272"}},"seq":2}` +and a trailing newline (with `ZERO64` written out as sixty-four `0` characters), and +the blob object named +`ed2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360` whose envelope +bytes are `napl-store blob v1 168\n` followed by the verbatim line +`{"gen":1,"module":"greeting","target":"rust","files":[{"path":"g.rs","hashBefore":null,"hashAfter":"H2_64"}]}` +with `H2_64` written out and no trailing newline. Materializing it into a temporary +`dest` writes the snapshot envelope at +`objects/81/57965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845`, the blob +envelope at `objects/ed/2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360`, +and `HEAD` holding +`8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845` and one newline. -`napl add` stages a module state from any of three sources, and the -working tree is the default one, exactly as in git: +The seed fixture `SEED_GREETING` calls `seed_module_state` with `prompt_bytes` the +bytes `# Greeting`, newline, newline, `Say hello to the world.`, newline (the +thirty-five-byte prompt, frontmatter-free, so both the prompt blob and the block index +are derived from these exact bytes and every hash the case pins below is unchanged); +`mapl_bytes` the `Some` of the bytes `module: greeting`, +newline; `ir_bytes` `None`; `target` `rust`; `generated` one `GeneratedFile` with +`path` `greeting.rs`, `bytes` the bytes +`pub fn greet() -> &'static str { "hi" }`, newline, and `mode` `store_tree::FILE_MODE`; +and `attribution_bytes` the `Some` of the store's canonical attribution blob bytes for +this module, the bytes `store_hash::canonical_json` returns for the `serde_json::Value` +of the `store_diff::Attribution` with `module` `greeting`, `target` `rust`, and an empty +`entries` list, the canonical JSON blob a fresh gen publishes and never the on-disk YAML. +The seeded state's `prompt_blob` is +`45b2871a9dd98a8c943d93e345cba9f284f39aec307f3d9c9530561f3ce2382f`, its `mapl` is +`Some("4b693e63b8c3b60e9c15db024bf8591e2ebb2af1da3dbe480c1c1a94ec7288ca")`, its `ir` +is `None`, and its one target `rust` carries `attribution` +`Some(store_hash::object_name(ObjectKind::Blob, &canonical))` where `canonical` is those +same canonical JSON bytes, the canonical blob name this seeder publishes and never a +YAML blob name. The seeded state's `block_index` equals +`store_blocks::block_index_object_name` over `store_blocks::segment(prompt, +store_blocks::SEG_VERSION)`, and the target's `generated_tree` equals the `root` of +`store_tree::build_tree` over the one `store_tree::TreeFile` with `path` `greeting.rs`, +`hash` the blob name +`e642c6c1db8a3afbd4f38967c99aa12fb18839e27fb65db9a3adfaec1d703698`, and `mode` +`store_tree::FILE_MODE`. The `SeededModule.objects` map contains the four blob names (the +prompt blob `45b2871a...`, the mapl blob `4b693e63...`, the canonical attribution blob +just named, and the generated file blob `e642c6c1...`), the block-index object name, and +every tree object name in the build's `objects`, and it does not contain the +module-state object itself. -- `napl add [...]` names working-tree files. Each path - resolves to its owning module through the owned globs (unique by - commit invariant, so no `--module` is needed), and the staged state - is that module's committed output with exactly the named files - overlaid from the working tree. Paths owned by different modules - stage one record per module in one invocation. A path no module owns - is a usage error naming the owned globs that do exist. -- `napl add --module ` stages the module's whole owned tree as - it sits in the working tree. -- `napl add --module ` stages explicit candidate - bytes, for the rare case where the bytes to stage are not the - working tree. +The migrate fixtures are temporary roots with a `dest` directory. Each is run through +`run_migrate` with an injected `prompt_paths` mapping alongside the `dirty` flag and the +`sanctioned` baseline; the `MIG_DIRTY` and `MIG_ABORT` paths short-circuit before seeding +and never read the mapping, so their mapping may be empty. `MIG_DIRTY` is any +valid root (a minimal `journal.jsonl` and `map.json`) run with `dirty` `true` and any +`sanctioned` baseline, returning `MigrateOutcome::RefusedDirty` and leaving `dest` +with no `HEAD` file and no `objects` directory. `MIG_ABORT` is a root whose one +journal line +`{"gen":1,"module":"m","target":"rust","files":[{"path":"c.rs","hashBefore":null,"hashAfter":"A64"}]}` +paired with a map giving `c.rs` the current hash `B64` audits `c.rs` as +inconsistent-after, run with `dirty` `false` and a `sanctioned` baseline whose +`chains` map holds `c.rs` at `store_objects::LegacyClassification::InconsistentBefore` +and no corrupt lines, so the current inconsistent-after is worse and the outcome is +`MigrateOutcome::Aborted(["c.rs"])`, again leaving `dest` untouched. `MIG_PROCEED` is +a clean root for one module `greeting` target `rust`: a `journal.jsonl` of the one +line +`{"gen":1,"module":"greeting","target":"rust","files":[{"path":".napl/src/rust/greeting.rs","hashBefore":null,"hashAfter":"A64"}]}`, +a `map.json` in the live shape whose `prompts` object keys the bare module name +`greeting` (the key equal to the entry's `module`) to module `greeting` with target +`rust` carrying a `promptHashAtGen` string and listing the one file +`.napl/src/rust/greeting.rs`, and whose `files` object gives that path the current +hash `A64` alongside its own `target` and `prompts` fields, an authored prompt source +file at `greeting.napl` holding the same thirty-five-byte prompt as `SEED_GREETING` +(frontmatter-free, so the seeded prompt blob is unchanged), the generated file at +`.napl/src/rust/greeting.rs` holding the same bytes as `SEED_GREETING`'s generated file +(`pub fn greet() -> &'static str { "hi" }`, newline, so its blob name is +`e642c6c1db8a3afbd4f38967c99aa12fb18839e27fb65db9a3adfaec1d703698`), a mapl at +`.napl/mapl/greeting.mapl`, and an attribution YAML at `.napl/attribution/greeting.yaml` +holding `module: greeting`, newline, `target: rust`, newline, `entries: []`, newline (no +ir file). The `promptHashAtGen` and per-file `prompts` fields are ignored by +`parse_map`; the module's prompt blob and its block index are both derived from the +authored `greeting.napl` bytes read through the injected `prompt_paths`, never from the +`prompts` key, and the seeding reads no `.napl/prompts-at-gen` body. The one generated +file path the map records is `.napl/src/rust/greeting.rs`, root-relative under the +`.napl/src/rust/` prefix; the migration reads its bytes from that full path but strips +the prefix when it builds the tree, so the seeded generated tree is built from the +tree-relative path `greeting.rs`, module-topped, never the `.napl/src/rust/`-topped map +path. Run with `dirty` `false`, a `sanctioned` baseline equal to the current audit (so +the comparison proceeds), and a `prompt_paths` mapping of `greeting` to `greeting.napl`, +the outcome is `MigrateOutcome::Migrated`, the written `HEAD` file holds the outcome's +`head` followed by one newline, the migrated `greeting` module-state's `rust` target +`generated_tree` equals the `root` of `store_tree::build_tree` over the one +`store_tree::TreeFile` with `path` the tree-relative `greeting.rs`, `hash` +`e642c6c1db8a3afbd4f38967c99aa12fb18839e27fb65db9a3adfaec1d703698`, and `mode` +`store_tree::FILE_MODE`, proving the `.napl/src/rust/` prefix was stripped from the tree +path, the store contains the seeded prompt blob object +`45b2871a9dd98a8c943d93e345cba9f284f39aec307f3d9c9530561f3ce2382f` at its sharded path +`objects/45/b2871a9dd98a8c943d93e345cba9f284f39aec307f3d9c9530561f3ce2382f`, and the +seeded attribution blob is the store's canonical JSON form rather than the on-disk YAML: +the migration seeds the module-state target's `attribution` as +`store_hash::object_name(ObjectKind::Blob, &canonical)` where `canonical` is +`store_hash::canonical_json` of the `serde_json::Value` of the `store_diff::Attribution` +with module `greeting`, target `rust`, and empty `entries`, and the blob object at that +name holds exactly `canonical`, so reading that blob's payload back and parsing it the +way the reducer's attribution loader does, `serde_json` into a value validated through +`store_diff::validate_attribution`, yields that `store_diff::Attribution` and never a +corrupt object. -A module's own prompt path is the one path add accepts that no owned -glob names, and it is accepted for exactly one purpose: recording a -docs edit. When the working-tree prompt differs from the committed -prompt only inside docs fences, so the semantic body is byte-identical, -`napl add ` stages a docs record: the new prompt bytes -and nothing else, no candidate tree, no attribution work, no usage. -Commit installs it by writing the new prompt object and re-pointing -the module's prompt at it while every derived fact holds: same -inputKey, same attribution map, same endpoints, same pending state, -and the transition says `docs-only` so history never confuses prose -with semantics. When the prompt's drift reaches outside docs fences, -the same invocation refuses and names the first semantic line that -moved, teaching `napl add --module` as the path for real changes, -because a docs record that smuggled a semantic edit would be an -unreviewed module change. The empty-commit teacher includes docs -drift: a bare commit finding only docs-drifted prompts names each one -beside the `napl add ` that would record it. +The containment fixtures probe the reader guard against a temporary root holding a +readable file. `PATH_REJECT_ABSOLUTE` calls `read_bytes_at` (and, in the same test, +`read_to_string_at`) with a `rel` that is an absolute path (a real absolute path on +this platform, for instance the temporary root's own absolute path joined with a +filename, so the string genuinely begins at the filesystem root), and asserts the +result is `Err(MigrateIoError::Io(_))`, proving an absolute `rel` is refused before any +read and the message is not pinned. `PATH_REJECT_ESCAPE` calls the same readers with a +`rel` of `../escape.txt` (a parent-traversal that climbs above root) against a root +that has a sibling file one level up, and asserts the result is +`Err(MigrateIoError::Io(_))`, proving a `..` climb is refused and the sibling above +root is never read. Both assert only the `Io` variant, never the message bytes. -::: test -name: a docs-only prompt edit stages and commits as a docs record leaving module state untouched -given: { edit: inside_docs_fence_only, then: [add_prompt_path, commit] } -expect: { transition: docs_only, input_key_changed: false, endpoints_moved: 0, pending: [], prompt_object_updated: true } -::: +`MAT_NO_PARTIAL` is the same two-object `STORE_MIN` staged store materialized into a +fresh temporary `dest`. After `materialize_staged_store` returns, the test asserts the +`HEAD` file holds exactly `8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845` +and one trailing newline, and, recursively walking `dest`, asserts the only regular +files present are the two object files at their fan-out paths +`objects/81/57965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845` and +`objects/ed/2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360` and the +single `HEAD`, so the count of files that are neither of these two objects nor `HEAD` +is zero: no temporary sidecar and no partial ref survives the atomic materialize. -::: test -name: add of a prompt path with semantic drift refuses naming the first semantic line -given: { edit: outside_docs_fence, then: [add_prompt_path] } -expect: { outcome: refused, line_named: first_semantic_change, teaches: add_module } -::: +`MIG_OPT_UNREADABLE` is the clean single-module `MIG_PROCEED` root with one alteration: +the optional mapl artifact at `.napl/mapl/greeting.mapl` is not a readable file but a +symlink loop (a symlink whose target resolves back to itself), so probing its existence +raises a metadata io error rather than a clean present-or-absent answer. Its authored +`greeting.napl` source and its `prompt_paths` mapping of `greeting` to `greeting.napl` +are the same as `MIG_PROCEED`, so the authored source read succeeds and the run reaches +the unreadable mapl. Run with `dirty` `false`, the equal sanctioned baseline, and that +mapping, `run_migrate` returns `Err(MigrateIoError::Io(_))`, proving +the unreadable optional artifact surfaces as an io error and is never silently seeded as +an absent `None`. The test asserts only the `Io` variant, not the message bytes. -::: test -name: a bare commit names docs-drifted prompts beside the add that records them -given: { edit: inside_docs_fence_only, then: [commit] } -expect: { installed: 0, docs_drifted_named: true, teaches: add_prompt_path } -::: +`MIG_CURRENT_ONLY` is a clean root whose one module `greeting` is current-state-only: +its `journal.jsonl` is empty (the module's legacy events are gone, the emptied-mapl +scar), yet its current working files survive. Its `map.json` is in the live shape: its +`prompts` object keys the bare module name `greeting` to module `greeting` with target +`rust` listing the one file `.napl/src/rust/greeting.rs`, and its `files` object gives +that path the current hash `A64`; the working tree carries the same thirty-five-byte +prompt as `SEED_GREETING` as its authored source at `greeting.napl` (frontmatter-free, +so the seeded prompt blob is unchanged), the generated file at +`.napl/src/rust/greeting.rs`, a mapl at `.napl/mapl/greeting.mapl`, and an attribution +YAML at `.napl/attribution/greeting.yaml` holding `module: greeting`, newline, `target: +rust`, newline, `entries: []`, newline (no ir file), and it is migrated with a +`prompt_paths` mapping of `greeting` to `greeting.napl`; the migration canonicalizes that +attribution YAML into the store's canonical JSON attribution blob just as `MIG_PROCEED` +does. The journal names no path chain and no corrupt line, but `greeting` is +declared in the map inventory and named by no journal entry, so the audit is NOT clean: +it surfaces `greeting` as a distinct current-state-only scar. `run_audit` over this root +returns exactly +`napl migrate audit v1\nentries 0\ncorrupt-lines 0\nchains 0\nexact 0\nmissing-prefix 0\ninconsistent-before 0\ninconsistent-after 0\nunreconstructable 0\n\ncurrent-state-only greeting\nverdict anomalies\n`, +the report carrying the distinct `current-state-only greeting` line and `verdict +anomalies`, never a clean verdict. The migration still proceeds because the scar is not a +drift the abort gate measures: run with `dirty` `false`, a `sanctioned` baseline equal +to the current audit (so the abort comparison, which ignores `current_state_only`, finds +nothing worse), and the `greeting` to `greeting.napl` `prompt_paths` mapping, the outcome +is `MigrateOutcome::Migrated`. The test asserts three things, +each in the same test: first, that `run_audit` returns the pinned scar report above, +byte for byte, proving the audit surfaces the scar rather than laundering it; second, +that the outcome is `Migrated`; and third, that the module is reachable from the written +`HEAD` rather than silently dropped, by reading the `HEAD` file (the head snapshot name +and one newline), reading the snapshot object at its fan-out path under `dest/objects`, +parsing that snapshot object's envelope payload as json, and asserting its `root` object +carries a `greeting` entry whose `eventHead` equals +`0000000000000000000000000000000000000000000000000000000000000000`, the genesis-parent +sentinel, and whose `moduleState` names an object file that is present under +`dest/objects` at its own fan-out path. -All three forms feed one pipeline with `[--attribution ] -[--usage ]`: ingest the staged bytes into immutable content -addressed blobs (reject links, devices, duplicate normalized paths, -absolute or parent-escaping paths, oversized entries or totals; all -later steps read ingested blobs, never the source files); parse every -staged file the target claims as source with the target's pinned -tree-sitter grammar, and refuse as `invalid-candidate` when any parse -tree carries an error node, naming the file, the first error's line, -and the malformed region, because a surface extracted from a broken -tree is not a fact worth recording; the gate is syntax only, judged by -the same grammar the surface extractor uses, and everything past -syntax, types, borrows, semantics, stays the acceptance battery's job; -commit re-runs the same gate when it recomputes the staged record, so -bytes that do not parse can never install through any door; compute -the slice and validate against it; resolve attribution as below; validate -and attach usage evidence when supplied; then write the staged record — -the internal receipt: identities generated against, base snapshot -hashes, candidate tree hash, output manifest, attribution, optional -reported fields. Validation failures surface at add time with the same -outcome classes commit uses. Staged records are transient coordination -state, disposable litter by the git-state law: never promoted to -history, safely deletable, re-creatable by re-running add. +`MIG_CURRENT_ONLY_DOWNSTREAM` is the identical current-state-only root as +`MIG_CURRENT_ONLY`, migrated the same way into a temporary `dest` with the `Migrated` +outcome, but its test then proves the migrated store is consumable by the real reducer +rather than only structurally present. It opens a `store_io::DiskProvider` over `dest` +and drives the downstream reducer APIs that read a root entry's event head: it calls +`store_reduce::classify(&provider, "greeting", "rust", &observed)` (where `observed` is any +`store_reduce::Observed`, since the result is decided before the working comparison) and +asserts the returned `store_reduce::Classification` is exactly +`store_reduce::Classification::CurrentStateOnly`, and it calls +`store_reduce::event_graph(&provider, "greeting")` and asserts the returned node vector is +empty. Both must return their values as `Ok`, never a +`store_reduce::ReduceError::MissingObject` over the all-zeros genesis sentinel: this is +the end-to-end proof that the genesis-sentinel event head migration writes is +consumable downstream, closing the loop the reachability-only assertion left open. This +case never substitutes for `MIG_CURRENT_ONLY`, which pins the audit and the on-disk +reachability; the two are distinct and neither discharges the other. -Attribution transports instead of being demanded. Add computes the -byte diff between the module's committed output and the staged bytes, -and the prompt's committed body and its pending body, and transports -every committed attribution claim through those diffs -deterministically: a range the diff proves untouched survives -verbatim, a range below an insertion or deletion shifts by its size, -and a claim landing in a rewritten or ambiguous region does not -transport, because the transport never guesses; a range it cannot -prove through the diff is asked for, never invented. When every claim -transports, add stages with the transported map and no `--attribution` -flag is needed: the paperwork updates itself as a consequence of the -diff already being content-addressed on both sides. When any claim -fails to transport, add refuses as `invalid-candidate` listing exactly -the claims that need affirmation, each beside the observed bound or -region it was checked against, and `--attribution` supplies -affirmations for the listed claims only, layered over the transported -remainder. Any attribution refusal, transport or explicit, names the -observed bound (the actual line count or surviving range) next to the -rejected claim, so a wrong range never costs a manual count. Each -listed claim also carries its index in the recorded map and its note -verbatim, so an affirmation is written against named prose, never -positional trust or a separate query join. +## Test and code discipline -::: test -name: a transport refusal lists each claim with its map index and note -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } -expect: { listed_claims: 1, claim_index_present: true, claim_note_verbatim: true } -::: +The frontmatter test suite is a contract, not a sketch. Each case in it is realized +by its own dedicated test function that exercises only that case, so the number of +dedicated tests is at least the number of frontmatter cases. Never fold two or more +cases into one shared test, and a single test that loops over a table of inputs never +counts as the dedicated test any of those inputs requires: such a combined test may +exist only as an extra. Cases that differ only in their input while sharing an +expected shape are still distinct cases, each owed its own dedicated test. -A module's first add has no committed map to transport, and it demands -no hand-authored one either: absent `--attribution`, add stages the -trivial whole map, every staged file claimed in full by the whole -prompt body, recorded as whole-file claims and honestly coarse. The -cold path to a verified first commit is therefore write the prompt, -write the code, `add --module`, `commit`, with attribution present and -fresh, never `absent`, and `--no-verify` never required to get started. -A claim, in any map, may be spelled whole: naming a file with no -numbers claims all of it, and naming a prompt range as whole claims -the whole body; bounds are derived at validation time from the staged -blob and the pending body, so no line count is ever transcribed by -hand and a whole-file claim survives any growth of its file. +Enumerate the case families by name, each owed its own dedicated verbatim test. The +map cases: the `MAP_VIEW` parse case, asserting the exact `current_hashes` entries and +the one `ModuleView`, and the `MAP_BAD` case, asserting `MigrateIoError::MalformedMap`, +each its own test, and the malformed case never discharges the parse case. The audit +cases: the `ROOT_CLEAN` end-to-end report case and the `ROOT_MIXED` end-to-end report +case, each writing its journal and map into a temporary root, calling `run_audit`, and +asserting the returned string equals its pinned literal byte for byte, each its own +test. The layout cases: the `NAME_SNAP` `object_path` case and the `STORE_MIN` +`materialize_staged_store` case, each its own test. The seed case `SEED_GREETING`, its +own test. The genesis-constant case, its own test. The migrate cases: `MIG_DIRTY`, +`MIG_ABORT`, and `MIG_PROCEED`, each its own test, and none discharges another. The +containment cases: `PATH_REJECT_ABSOLUTE` and `PATH_REJECT_ESCAPE`, each its own test +asserting the reader returns `MigrateIoError::Io`, and neither discharges the other nor +any successful-read case. The atomic-materialize case `MAT_NO_PARTIAL`, its own test +asserting the exact `HEAD` bytes and that no file beyond the two objects and `HEAD` +survives; it never weakens the leftover-file assertion into a mere existence check and +never discharges the `STORE_MIN` materialize case. The unreadable-optional case +`MIG_OPT_UNREADABLE`, its own test asserting `run_migrate` returns `MigrateIoError::Io` +rather than seeding a silent `None`. The current-state-only integration case +`MIG_CURRENT_ONLY`, its own test asserting all three of: `run_audit` over the root +returns the pinned scar report byte for byte, carrying the distinct +`current-state-only greeting` line and `verdict anomalies` and never a clean verdict; the +outcome is `MigrateOutcome::Migrated`; and the written head snapshot's `root` carries the +`greeting` entry with `eventHead` the genesis-parent sentinel and a `moduleState` object +present on disk. It never asserts only that the migration succeeded while ignoring the +audit or reachability, never asserts a clean audit, and never discharges the +`MIG_PROCEED` case. The downstream-consumability case `MIG_CURRENT_ONLY_DOWNSTREAM`, its +own test, migrates the same root, opens a `store_io::DiskProvider` over `dest`, and +asserts `store_reduce::classify` returns `Classification::CurrentStateOnly` and +`store_reduce::event_graph` returns an empty node vector, both `Ok` and never a +`MissingObject` over the genesis sentinel; it drives the real reducer over the real +materialized store and never substitutes for `MIG_CURRENT_ONLY`. -Two more spellings close the remaining gaps: `napl add ---module ` stages a path under a module explicitly, for the file -that is new in a crate several modules share, where neither the -committed manifest nor the globs can disambiguate it alone; and -`napl add --check` with any form runs the entire pipeline, transport -verdict and refusal listing included, and writes nothing, not even a -staged record, the dry run that answers before anything lands. The -check verdict says exactly what it judged and what it did not: the -payload enumerates the validations that ran (placement, ingest rules, -the parse gate, attribution transport, evidence) and names what stays -unjudged, everything past syntax, the acceptance battery's job, so -`would-stage` means the record is sound and the syntax parses, never -that the code is correct. A transport refusal -reports each endpoint's verdict separately: a claim whose prompt side -failed but whose code side transported serves the transported code -range beside the failed prompt range, and the affirmation supplies -only the side that actually needs a human, never both because one -refused. An unparseable or wrong-shaped `--attribution` file is -answered like a bad ref payload: the refusal carries the complete map -contract, every field and one complete valid example, never a bare -"not parseable". The map reader speaks the same value grammar as body -directives, so block and flow spellings of one map are one map: -`lines: [4, 9]` and its two-line block form parse identically. A map -that parses but yields no entries, or that fails at any construct, is -refused naming the exact line and the construct that stopped the -reader, beside the full contract, never a bare count of zero. The -contract is also taught before any failure: `help add` and -`add --help` serve the same complete map contract and worked example -the refusal carries, so the map is learnable from help instead of -from trial and error. +Inside a case's dedicated test, hand the code exactly the fixture that case gives, +byte for byte, and assert its pinned expectation verbatim: no shorter or more +convenient stand-in, no re-derivation of a value the case pins as a literal, and no +weakening of a pinned equality into an inequality-only or recomputed check. The +`ROOT_CLEAN` test asserts the report equals `napl migrate audit v1` then a newline, +`entries 2`, `corrupt-lines 0`, `chains 2`, `exact 2`, `missing-prefix 0`, +`inconsistent-before 0`, `inconsistent-after 0`, `unreconstructable 0`, a blank line, +and `verdict clean`, each followed by one newline. The `ROOT_MIXED` test asserts the +report equals `napl migrate audit v1`, `entries 5`, `corrupt-lines 1`, `chains 5`, +`exact 1`, `missing-prefix 1`, `inconsistent-before 1`, `inconsistent-after 1`, +`unreconstructable 1`, a blank line, then the sorted anomaly lines +`inconsistent-after d.rs`, `inconsistent-before c.rs`, `missing-prefix b.rs`, +`unreconstructable line:6`, then `verdict anomalies`, each followed by one newline. +Obtain each report from `run_audit` over a temporary root and compare to the literal; +never assert a prefix or a contains, always the full bytes. -::: test -name: flow and block spellings of one attribution map parse identically -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, maps: [flow_sequences, block_sequences] } -expect: { parsed_entries_equal: true, both_stage: true } -::: +The `object_path` test asserts the exact string +`objects/81/57965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845` for the +name `8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845`. The +`materialize_staged_store` test builds the `STORE_MIN` value with its two exact +envelope byte vectors and its head, materializes into a temporary `dest`, then reads +back and asserts three things: the file at +`objects/81/57965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845` holds the +snapshot envelope `napl-store snapshot v1 271\n` followed by the canonical snapshot +payload and its trailing newline byte for byte; the file at +`objects/ed/2f545b2702bcc01d9d013e8c0719e330c176c7729b6fe6b73c0f4310a35360` holds the +blob envelope `napl-store blob v1 168\n` followed by the verbatim journal line with no +trailing newline byte for byte; and the `HEAD` file holds +`8157965a4ea89aff71b226fffc08298376f1cbe6e2b113b7d3b34c5ff7a0e845` and exactly one +trailing newline. It never asserts only that the files exist, and never weakens an +envelope equality into a length or prefix check. -::: test -name: a map failing mid-parse names the line and construct beside the contract -given: { workspace: fixture_two_modules, attribution_file: bad_construct_on_line_3 } -expect: { error_names_line: 3, error_names_construct: true, error_carries_valid_example: true } -::: +The `SEED_GREETING` test calls `seed_module_state` with the exact fixture bytes and +asserts each pinned value verbatim: `state.prompt_blob` equals +`45b2871a9dd98a8c943d93e345cba9f284f39aec307f3d9c9530561f3ce2382f`, `state.mapl` +equals `Some("4b693e63b8c3b60e9c15db024bf8591e2ebb2af1da3dbe480c1c1a94ec7288ca")`, +`state.ir` equals `None`, and the target `rust`'s `attribution` equals +`Some(store_hash::object_name(ObjectKind::Blob, &canonical))` where `canonical` is the +same canonical JSON attribution bytes handed in, proving the seeded attribution blob is +the canonical form and never a YAML blob hash. It then +asserts the composition agreement, proving the frozen crates are called rather than +reimplemented: `state.block_index` equals `store_blocks::block_index_object_name` +over `store_blocks::segment` of the prompt under `store_blocks::SEG_VERSION`, and the +target's `generated_tree` equals the `root` of `store_tree::build_tree` over the one +`store_tree::TreeFile` naming the generated file's blob. Finally it asserts +`SeededModule.objects` contains the four pinned blob names, the block-index object +name, and the build's tree root name, and does not contain the module-state object. +The `ir` `None` assertion is verbatim and is never weakened into an is-some check, and +the `mapl` and `attribution` assertions carry their exact pinned digests. -::: test -name: add help carries the complete attribution map contract and example -given: { workspace: fixture_two_modules, invocation: "help add" } -expect: { map_fields_listed: true, map_example_present: true, exit_code: 0, store_bytes_unchanged: true } -::: +The genesis-constant test asserts `GENESIS_PARENT` equals the sixty-four-character +string of the digit `0`, never a shorter or differently cased value, and never the +`None` of an absent hash. -::: test -name: a first add with no flag stages the whole map and commits verified -given: { workspace: fresh_init_root_prompt, candidate: valid_root_level, attribution_flag: absent } -expect: { add_outcome: staged, map_kind: whole_file_claims, commit_outcome: success, attribution: fresh, no_verify_used: false } -::: +The `MIG_DIRTY` test runs `run_migrate` with `dirty` `true` and asserts the outcome is +`MigrateOutcome::RefusedDirty` and, separately in the same test, that the `dest` +directory holds no `HEAD` file and no `objects` directory, proving nothing was +written. The `MIG_ABORT` test runs `run_migrate` with `dirty` `false` and the +`InconsistentBefore` sanctioned baseline and asserts the outcome is exactly +`MigrateOutcome::Aborted(vec!["c.rs".to_string()])`, its offending vector verbatim and +in sorted order, and that `dest` was left untouched; it never asserts only that the +outcome is an abort while ignoring its keys. The `MIG_PROCEED` test runs `run_migrate` +on the clean single-module root with `dirty` `false`, the equal sanctioned baseline, and +the `greeting` to `greeting.napl` `prompt_paths` mapping, asserts the outcome is +`MigrateOutcome::Migrated`, reads the written `HEAD` file and asserts its bytes equal the +outcome's `head` followed by one newline (a self-consistency the materialize step +guarantees), and asserts the store holds the seeded prompt blob object +`45b2871a9dd98a8c943d93e345cba9f284f39aec307f3d9c9530561f3ce2382f` at +`objects/45/b2871a9dd98a8c943d93e345cba9f284f39aec307f3d9c9530561f3ce2382f`, a +concrete pinned object proving the seed reached disk over the authored source. It also +proves the generated tree is module-topped rather than `.napl`-topped: it reads the +migrated `HEAD` snapshot, follows its `root` `greeting` entry's `moduleState` to that +object under `dest/objects`, parses the module-state, and asserts the `rust` target's +`generated_tree` equals the `root` of `store_tree::build_tree` over the one +`store_tree::TreeFile` with `path` the tree-relative `greeting.rs`, `hash` +`e642c6c1db8a3afbd4f38967c99aa12fb18839e27fb65db9a3adfaec1d703698`, and `mode` +`store_tree::FILE_MODE`, so the `.napl/src/rust/` prefix was stripped from the tree path +while the file bytes were read from the full `.napl/src/rust/greeting.rs`; it never +accepts a tree built from the root-relative map path. It also proves the seeded +attribution blob is canonical JSON rather than the on-disk YAML, making this defect class +caught forever: from the same module-state it follows the `rust` target's `attribution` +to that blob object under `dest/objects`, reads the blob's payload, and asserts it parses +through `serde_json` into a value that `store_diff::validate_attribution` accepts as the +`store_diff::Attribution` with module `greeting`, target `rust`, and empty `entries`, +byte-identical to `store_hash::canonical_json` of that document, exactly the round-trip +the reducer's attribution loader performs; it never accepts a blob whose payload is the +raw YAML text, which would fail that parse as a corrupt object. -::: test -name: a whole spelling claims the entire file and survives file growth -given: { workspace: fixture_two_modules, map_entry: whole_file_no_numbers, then: file_grows } -expect: { claim_valid: true, bounds_derived: true, retranscription_needed: false } -::: +The named-substitution prohibitions for this module's confusable tokens are strict. +Never write `promptBlob` where a case pins `blockIndex`, nor `generatedTree` where a +case pins `attribution`, nor `mapl` where a case pins `ir`, nor the reverse of any of +these; each module-state field is transcribed exactly as its case names it. Never +confuse a bare content hash (the map's `files` `hash` and the journal's `hashBefore` +and `hashAfter`, compared by the audit) with an envelope object name (the store's +object names, computed through `store_hash`); the two are different values for the +same file and are never substituted for one another. The three `MigrateOutcome` +variants are exactly `Migrated`, `RefusedDirty`, and `Aborted`, and none is written +where a case pins another. The head file is exactly the head name and one trailing +newline, never without the newline and never with two. The `object_path` fan-out is +exactly the first two characters, a slash, then the remaining characters, never a +three-character or one-character split. The `GENESIS_PARENT` sentinel is the digit `0` +sixty-four times and is never confused with the `None` of a creation step's absent +hash. The audit report's fixed header token is exactly `napl migrate audit v1` and its +verdict tokens are exactly `clean` and `anomalies`, never reworded, and the trailing +newline that ends the report is never dropped. -::: test -name: add check reports the full verdict and writes nothing -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_check } -expect: { verdict_listed_claims: 1, staged_records: 0, staging_dir_untouched: true, store_bytes_unchanged: true } -::: +Every case above is realized by exactly one dedicated verbatim test; a narrower, +neighboring, or recomputed test does not discharge a case; any supplemental +table-driven or integration test is separately named and never substitutes for a +dedicated test; and the count of dedicated tests is at least the count of frontmatter +cases. -::: test -name: an explicit module stages a new path in a shared crate -given: { workspace: fixture_shared_crate_two_modules, working_tree: new_uncommitted_file_in_shared_glob, add_path_with_module: true } -expect: { add_outcome: staged, module_as_named: true } -::: +Comment rules: no line or block comments anywhere in the generated code; no doc +comment on a private item; a module `//!` summary and `///` on public items only; no +doctest code blocks in any doc comment; no em dashes in any string, comment, or doc +this module emits. Every struct that deserializes external JSON reads only the fields +this module needs and never fabricates a value the input did not carry; `map.json` is +a foreign document and its unread fields are ignored rather than rejected. +napl-store receipt v1 4839 +{"baseStateKey":"0ef99195476c7251619345064e2dc74fa005197438592370d03b0b5e5a688b55","baseStoreSnapshot":"8c8a4a7fddaf37822cf48c2dca7f8f4f11279f85c144fc105e996bc7dfd0cd48","baseWorkspaceHash":"e0333e9ca0f11dc00a94f410e47c0285fab72fe5776962d435a09022cf797e2d","candidateTreeHash":"c6e984f3367bbcd224e16de533fa8c357d320625ec698fb566526ceb887a50b8","contractVersion":4,"inputKey":"1b54c1209237735b1e5ee88a233557bd84e054b9e48e3a8582bf2db41d4142ee","module":"napl","outputManifest":[{"hash":"f0987b243dce1a039a0c78f8100965a895c03501bc378b4217765c5c84982d34","path":"crates/napl/src/attribution.rs"},{"hash":"e539c65a4600ed1784b9cc176cb28f24c140f514f9783360f1f5a27142f89f68","path":"crates/napl/src/authoring.rs"},{"hash":"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2","path":"crates/napl/src/blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","path":"crates/napl/src/candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","path":"crates/napl/src/canon.rs"},{"hash":"0b8190e7989129e7baf4eed7046e19b7608067eb5d13bed95ad416118bf34a25","path":"crates/napl/src/capture.rs"},{"hash":"2e5b5b54aebea3db240acc2f2dc944df07b908bf97ba2067517a9757ed8c7dcb","path":"crates/napl/src/cli.rs"},{"hash":"7f8fe1b370188a60f00e524cc5c8059ca17a347b97915097286fff2c0ec7e290","path":"crates/napl/src/commandset.rs"},{"hash":"f762bd207827748390f0f6b80d2ee0cce44aed0b609affc3ca7cf39e2c11e958","path":"crates/napl/src/commit.rs"},{"hash":"3c0db9b5d179e2f3b7123cd8e08844ea9ad28a0a6ffbc7e5794738709f5af1f4","path":"crates/napl/src/compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","path":"crates/napl/src/cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","path":"crates/napl/src/decision.rs"},{"hash":"bc274cef6c4e5aca63e7c46d81e1f86e118c6ba079e7a4ccd4e9f14a8d3eb21d","path":"crates/napl/src/directive.rs"},{"hash":"b86a4431e286225b32bae5cebc03298584fce063e8d4ee2df4633e18143f0aa7","path":"crates/napl/src/disk.rs"},{"hash":"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812","path":"crates/napl/src/error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","path":"crates/napl/src/evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","path":"crates/napl/src/graph.rs"},{"hash":"824c95b7229ac2ad5b1cc518b296443883fb56a110c86c2a2e67923c0a19ea07","path":"crates/napl/src/lint.rs"},{"hash":"2ea965827e5c5073cbcacacd22bf2318a9067874c901b4f05f67268e99baf92c","path":"crates/napl/src/lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","path":"crates/napl/src/main.rs"},{"hash":"6fc1b58dad295d780c917c10ac0f793667e46c455349cff3d42e6855501a91bd","path":"crates/napl/src/maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","path":"crates/napl/src/mv.rs"},{"hash":"aff9bd5b095cfbcd34114cb0c34ac87b1716fef02f0b452888c502e5301476d2","path":"crates/napl/src/objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","path":"crates/napl/src/paths.rs"},{"hash":"93f11780827b58057b742550c206f180d5fa1659b216e90b8cfded514400be21","path":"crates/napl/src/porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","path":"crates/napl/src/receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","path":"crates/napl/src/slice.rs"},{"hash":"48d165c0c10ed38a4c594954631c4aa3b9a3c5fa562cc0891b4343fc3d32dd14","path":"crates/napl/src/source.rs"},{"hash":"e3374a27d7d0f882064cf00e4f95f29623db5d5ff23a06dfb5050d5f6639463a","path":"crates/napl/src/staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","path":"crates/napl/src/store_view.rs"},{"hash":"1820f647bb5dbd74c8db7f5519d404ef0ce1520c833a579aaca893b5cad7ba08","path":"crates/napl/src/surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","path":"crates/napl/src/target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","path":"crates/napl/src/textdiff.rs"},{"hash":"167749ecbb655a214510c849c92be88cb536cc7f57a330d21b0314780457b0a1","path":"crates/napl/src/transaction.rs"},{"hash":"d4ef5888a7ecc406d8eb5173dc215c7b1a1ef1e1ddd3ab8b3a8aa6639d125017","path":"crates/napl/src/transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","path":"crates/napl/src/trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","path":"crates/napl/src/version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","path":"crates/napl/src/workspace.rs"}],"receiptVersion":1,"sliceHash":"fd7989abdc979752df5b92ec979c5781e9c383da7760eb3d1e2537f6e3f9e105","target":"rust"} +napl-store blob v1 20466 +use std::collections::BTreeMap; +use std::io::{BufRead, Write}; +use std::path::{Path, PathBuf}; -::: test -name: add of one working-tree path stages the committed state with that file overlaid -given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, add_paths: [edited_file] } -expect: { add_outcome: staged, staged_state: committed_plus_overlay, unnamed_files_at_committed_bytes: true } -::: +use serde_json::{json, Value}; -::: test -name: paths owned by two modules stage one record per module -given: { workspace: fixture_two_modules, working_tree_edit: both_modules_touched, add_paths: [file_of_leaf, file_of_consumer] } -expect: { staged_records: 2, modules: [consumer, leaf] } -::: +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + pub prompt_start: u32, + pub prompt_end: u32, + pub file: String, + pub line_start: u32, + pub line_end: u32, + pub note: String, +} -::: test -name: a path no module owns is a usage error naming the owned globs -given: { workspace: fixture_two_modules, add_paths: [unowned_file] } -expect: { exit_code: 2, error_names_owned_globs: true, staged_records: 0 } -::: +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Attribution { + pub module: String, + pub target: String, + pub entries: Vec, +} -::: test -name: a pure line-shift edit transports the whole map and needs no attribution flag -given: { workspace: fixture_two_modules, working_tree_edit: insert_lines_above_attributed_range, add: module_form_without_attribution } -expect: { add_outcome: staged, map_transported: true, ranges_shifted: true, attribution_flag_needed: false } -::: +pub fn serve(reader: &mut R, writer: &mut W) { + let mut docs: BTreeMap = BTreeMap::new(); + while let Some(message) = read_message(reader) { + let method = message + .get("method") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let id = message.get("id").cloned(); + match method.as_str() { + "initialize" => respond(writer, id, initialize_result()), + "initialized" => {} + "shutdown" => respond(writer, id, Value::Null), + "exit" => break, + "textDocument/didOpen" => track_open(&mut docs, &message), + "textDocument/didChange" => track_change(&mut docs, &message), + "textDocument/didClose" => track_close(&mut docs, &message), + "textDocument/hover" => respond(writer, id, hover(&docs, &message)), + "textDocument/definition" => respond(writer, id, definition(&docs, &message)), + "textDocument/references" => respond(writer, id, references(&docs, &message)), + "textDocument/codeLens" => respond(writer, id, code_lens(&docs, &message)), + _ => { + if id.is_some() { + respond(writer, id, Value::Null); + } + } + } + } +} -::: test -name: a claim in a rewritten region refuses listing that claim with its observed bound -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } -expect: { outcome: invalid_candidate, listed_claims: 1, observed_bound_present: true, untouched_claims_not_listed: true } -::: +fn initialize_result() -> Value { + json!({ + "capabilities": { + "codeLensProvider": { "resolveProvider": false }, + "definitionProvider": true, + "hoverProvider": true, + "referencesProvider": true, + "textDocumentSync": 1 + }, + "serverInfo": { "name": "napl-lsp" } + }) +} -::: test -name: affirmations layer over the transported remainder and stage -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_affirmation_for_listed_claim } -expect: { add_outcome: staged, transported_claims_kept: true, affirmed_claim_recorded: true } -::: +fn respond(writer: &mut W, id: Option, result: Value) { + let Some(id) = id else { + return; + }; + write_message( + writer, + &json!({ "id": id, "jsonrpc": "2.0", "result": result }), + ); +} -::: test -name: add stages a valid candidate and commit installs it -given: { workspace: fixture_two_modules, candidate: valid_with_attribution } -expect: { add_outcome: staged, commit_outcome: success } -::: +fn read_message(reader: &mut R) -> Option { + let mut content_length: Option = None; + loop { + let mut line = String::new(); + let read = reader.read_line(&mut line).ok()?; + if read == 0 { + return None; + } + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + break; + } + if let Some(value) = trimmed.strip_prefix("Content-Length:") { + content_length = value.trim().parse::().ok(); + } + } + let length = content_length?; + let mut buffer = vec![0u8; length]; + reader.read_exact(&mut buffer).ok()?; + serde_json::from_slice(&buffer).ok() +} -::: test -name: an explicit candidate whose transport is incomplete and unaffirmed fails loudly -given: { workspace: fixture_two_modules, candidate: rewritten_region_without_affirmation } -expect: { outcome: invalid_candidate, listed_claims_present: true } -::: +fn write_message(writer: &mut W, value: &Value) { + let body = serde_json::to_vec(value).unwrap_or_default(); + let _ = write!(writer, "Content-Length: {}\r\n\r\n", body.len()); + let _ = writer.write_all(&body); + let _ = writer.flush(); +} -::: test -name: a candidate with a parent-escaping path is rejected as invalid-candidate -given: { workspace: fixture_two_modules, candidate: contains_dotdot_path } -expect: { outcome: invalid_candidate, published_semantic_state: false } -::: +fn track_open(docs: &mut BTreeMap, message: &Value) { + let item = &message["params"]["textDocument"]; + if let (Some(uri), Some(text)) = (item["uri"].as_str(), item["text"].as_str()) { + docs.insert(uri.to_string(), text.to_string()); + } +} -::: test -name: tampering a staged blob after ingestion never mixes into the install -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, tamper: candidate_file_after_ingest } -expect: { outcome_in: [installed_from_immutable_blobs, store_integrity], mixed_content: false } -::: +fn track_change(docs: &mut BTreeMap, message: &Value) { + let params = &message["params"]; + let Some(uri) = params["textDocument"]["uri"].as_str() else { + return; + }; + let changes = params["contentChanges"].as_array(); + if let Some(text) = changes + .and_then(|list| list.last()) + .and_then(|change| change["text"].as_str()) + { + docs.insert(uri.to_string(), text.to_string()); + } +} -`napl commit --staged ` is the one guarded -transaction over what add staged. Pipeline: verify the staged record -(recompute sliceHash, inputKey, baseStateKey at the recorded bases, -reject mismatches, enforce evidence bounds); extract and record the -surface; derive the complete transition (three-part footprint, member-set -delta, shared projections recomputed from the merged head); stage all -materialization outside any lock; then one bounded critical section under -the pointer lock performing only the footprint recheck and the pointer -and directory swaps, bounded by the transition's file count, never -hashing content inside the lock. +fn track_close(docs: &mut BTreeMap, message: &Value) { + if let Some(uri) = message["params"]["textDocument"]["uri"].as_str() { + docs.remove(uri); + } +} -::: test -name: a crash between staged materialization and pointer swap leaves a deterministic recovery plan -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, crash_after: staging } -expect: { store_readable: true, recovery_plan_deterministic: true, semantic_state_unchanged: true } -::: +pub fn parse_attribution(text: &str) -> Option { + let unified = text.replace("\r\n", "\n").replace('\r', "\n"); + let mut module: Option = None; + let mut target: Option = None; + let mut entries: Vec = Vec::new(); + let mut in_entries = false; + let mut entry_indent: Option = None; + let mut current: Vec<(String, Vec)> = Vec::new(); + let mut pending_key: Option = None; -::: test -name: a receipt whose recomputed slice hash disagrees is rejected as invalid-receipt -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: tampered_slice_hash } -expect: { outcome: invalid_receipt, published_semantic_state: false } -::: + let flush = |current: &mut Vec<(String, Vec)>, entries: &mut Vec| { + if let Some(entry) = build_entry(current) { + entries.push(entry); + } + current.clear(); + }; -The decision table, judged per arriving transition against the current -head: identities reproduce with head unmoved installs; head moved with -identities reproducing and footprints disjoint rebases then installs -(projections re-derived, never reused); inputKey failure returns -`stale-input`; baseStateKey failure returns `base-output-changed`; semantic -write overlap returns `write-conflict`; lock contention returns -`lock-contended`. Terminal outcomes, never retried unchanged: -`invalid-candidate`, `invalid-receipt`, `mapping-integrity`, -`resource-limit`, `unsupported-version`, `store-integrity`, `io-error`. -Every outcome is a canonical machine-readable JSON payload naming whether -blobs or provenance were published; no outcome publishes semantic state -partially. Exit codes: 0 success, 10 through 13 for the four returned -concurrency outcomes in table order, 20 through 26 for the seven terminal -outcomes in the order above. Staged records carry `receiptVersion`, the -per-module identities generated against, base snapshot hashes, the -candidate tree hash, the output manifest, then optional reported fields; -the compiler recomputes everything derivable and rejects on mismatch; -absent telemetry is `unavailable`, never zero. `napl notes add --to -` attaches later provenance without touching semantic state. + for raw in unified.split('\n') { + if raw.trim().is_empty() { + continue; + } + let indent = raw.len() - raw.trim_start().len(); + if indent == 0 { + if in_entries { + flush(&mut current, &mut entries); + in_entries = false; + } + let (key, value) = raw.split_once(':')?; + match key.trim() { + "module" => module = Some(unquote(value.trim())), + "target" => target = Some(unquote(value.trim())), + "entries" => { + in_entries = true; + entry_indent = None; + } + _ => {} + } + pending_key = None; + continue; + } + if !in_entries { + continue; + } + let trimmed = raw.trim_start(); + let is_item = trimmed == "-" || trimmed.starts_with("- "); + let starts_entry = is_item && entry_indent.map(|width| indent <= width).unwrap_or(true); + if starts_entry { + flush(&mut current, &mut entries); + entry_indent = Some(indent); + pending_key = None; + let rest = trimmed.strip_prefix("- ").unwrap_or("").trim(); + if !rest.is_empty() { + append_field(&mut current, &mut pending_key, rest); + } + continue; + } + if is_item { + let value = trimmed.strip_prefix("- ").unwrap_or("").trim(); + if let Some(key) = &pending_key { + if let Some(slot) = current.iter_mut().find(|(field, _)| field == key) { + slot.1.push(unquote(value)); + } + } + continue; + } + append_field(&mut current, &mut pending_key, trimmed); + } + if in_entries { + flush(&mut current, &mut entries); + } -::: test -name: commit installs a valid candidate against an unmoved head -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, head: unmoved } -expect: { outcome: installed, head_advanced: true, surface_recorded: true } -::: + Some(Attribution { + module: module?, + target: target.unwrap_or_default(), + entries, + }) +} -::: test -name: a bare commit installs every staged record in wave order -given: { workspace: fixture_two_modules, staged: [record_for_leaf, record_for_consumer], commit_args: none } -expect: { outcomes: [success, success], order: wave_order, hand_sequencing_needed: false } -::: +fn append_field( + current: &mut Vec<(String, Vec)>, + pending_key: &mut Option, + text: &str, +) { + let Some((key, value)) = text.split_once(':') else { + return; + }; + let key = key.trim().to_string(); + let value = value.trim(); + if value.is_empty() { + current.push((key.clone(), Vec::new())); + *pending_key = Some(key); + } else { + current.push((key, vec![unquote(value)])); + *pending_key = None; + } +} -::: test -name: bare commit re-derives and lands records for modules sharing one crate -given: { workspace: fixture_shared_crate_two_modules, staged: [record_for_first, record_for_second], commit_args: none } -expect: { outcomes: [success, success], retried_after_head_move: 1, hand_sequencing_needed: false } -::: +fn build_entry(fields: &[(String, Vec)]) -> Option { + let mut prompt: Option<(u32, u32)> = None; + let mut lines: Option<(u32, u32)> = None; + let mut file: Option = None; + let mut note = String::new(); + for (key, values) in fields { + match key.as_str() { + "promptLines" => prompt = pair(values), + "lines" => lines = pair(values), + "file" => file = values.first().cloned(), + "note" => note = values.first().cloned().unwrap_or_default(), + _ => {} + } + } + let (prompt_start, prompt_end) = prompt?; + let (line_start, line_end) = lines?; + Some(Entry { + prompt_start, + prompt_end, + file: file?, + line_start, + line_end, + note, + }) +} -::: test -name: commit sweeps the record it installed -given: { workspace: fixture_two_modules, staged: [record_for_leaf], commit: bare_then_bare_again } -expect: { first_outcomes: [success], second_outcomes: [], reattempted_history: 0 } -::: +fn pair(values: &[String]) -> Option<(u32, u32)> { + let first = values.first()?.parse::().ok()?; + let second = values.get(1)?.parse::().ok()?; + Some((first.min(second), first.max(second))) +} -::: test -name: a bare commit with nothing staged names the drifted modules and their add commands -given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, staged: none, commit_args: none } -expect: { installed: 0, nothing_staged_named: true, drifted_modules_named: [consumer], add_command_named: true } -::: +fn unquote(text: &str) -> String { + let quoted = text.len() >= 2 + && ((text.starts_with('"') && text.ends_with('"')) + || (text.starts_with('\'') && text.ends_with('\''))); + if quoted { + text[1..text.len() - 1].to_string() + } else { + text.to_string() + } +} -::: test -name: a per-side transport refusal serves the surviving side's transported range -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_prompt_section_only, add: module_form_with_check } -expect: { prompt_side: refused, code_side: transported, transported_code_range_served: true, affirmation_needed_sides: [prompt] } -::: +fn load_records(workspace: &Path) -> Vec { + let dir = workspace.join(".napl").join("attribution"); + let mut names: Vec = match std::fs::read_dir(&dir) { + Ok(entries) => entries + .filter_map(|entry| entry.ok()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| name.ends_with(".yaml")) + .collect(), + Err(_) => return Vec::new(), + }; + names.sort(); + let mut records: Vec = Vec::new(); + for name in names { + if let Ok(text) = std::fs::read_to_string(dir.join(&name)) { + if let Some(record) = parse_attribution(&text) { + records.push(record); + } + } + } + records +} -::: test -name: check enumerates the parse gate and refuses a file that does not parse -given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, add: module_form_with_check } -expect: { validations_enumerated: true, parse_gate_listed: true, outcome: invalid_candidate, error_names_file_and_line: true, staged_records: 0 } -::: +enum Located { + Prompt { module: String, line: u32 }, + Generated { file: String, line: u32 }, + None, +} -::: test -name: add refuses a candidate whose source does not parse naming file and line -given: { workspace: fixture_two_modules, candidate: syntactically_invalid_rust } -expect: { outcome: invalid_candidate, error_names_file_and_line: true, published_semantic_state: false } -::: +fn uri_to_path(uri: &str) -> String { + let stripped = uri.strip_prefix("file://").unwrap_or(uri); + percent_decode(stripped) +} -::: test -name: commit recomputation refuses staged bytes that do not parse -given: { workspace: fixture_two_modules, staged: record_with_tampered_unparseable_blob } -expect: { outcome_in: [invalid_candidate, store_integrity], installed: false } -::: +fn resolve_workspace(uri: &str) -> Option { + let path = uri_to_path(uri); + crate::workspace::find_workspace_root(Path::new(&path)) +} -::: test -name: a bad attribution file answers with the complete map contract and example -given: { workspace: fixture_two_modules, attribution_file: empty_object, add: module_form_with_attribution } -expect: { error_lists_all_fields: true, error_carries_valid_example: true } -::: +fn percent_decode(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let high = (bytes[index + 1] as char).to_digit(16); + let low = (bytes[index + 2] as char).to_digit(16); + if let (Some(high), Some(low)) = (high, low) { + out.push((high * 16 + low) as u8); + index += 3; + continue; + } + } + out.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&out).into_owned() +} -::: test -name: the plan labels projections and names a legal first output -given: { workspace: fresh_init_root_prompt, flag: module_root } -expect: { projection_labeled_tool_derived: true, legal_first_output_named: true } -::: +fn relativize(workspace: &Path, path: &str) -> String { + match Path::new(path).strip_prefix(workspace) { + Ok(rest) => rest.to_string_lossy().replace('\\', "/"), + Err(_) => path.to_string(), + } +} -::: test -name: gc apply sweeps staged records and touches nothing reachable -given: { workspace: fixture_two_modules, staged: [stale_record], gc: apply } -expect: { staging_empty: true, reachable_objects_untouched: true, store_healthy: true } -::: +fn stem(rel: &str) -> Option { + let name = rel.rsplit('/').next()?; + name.strip_suffix(".napl").map(str::to_string) +} -::: test -name: commit against a moved head with disjoint footprints rebases and installs -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: to_unrelated_module } -expect: { outcome: installed, rebased: true } -::: +fn generated_match(rel: &str, file: &str) -> bool { + rel == file || rel.ends_with(&format!("/{file}")) +} -::: test -name: commit whose direct dep surface moved underneath returns stale-input -given: { workspace: fixture_chain_three, candidate: valid_for_middle, intervening_commit: leaf_surface_change } -expect: { outcome: stale_input, head_advanced: false } -::: +fn document_text( + workspace: &Path, + docs: &BTreeMap, + uri: &str, + rel: &str, +) -> Option { + if let Some(text) = docs.get(uri) { + return Some(text.clone()); + } + std::fs::read_to_string(workspace.join(rel)).ok() +} -::: test -name: commit whose own outputs moved underneath returns base-output-changed -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: same_module_other_candidate } -expect: { outcome: base_output_changed } -::: +fn frontmatter_lines(text: &str) -> u32 { + let mut fences = 0; + let mut count = 0u32; + for line in text.split('\n') { + count += 1; + if line.trim_end_matches('\r') == "---" { + fences += 1; + if fences == 2 { + return count; + } + } + } + 0 +} -::: test -name: two concurrent disjoint commits both land and neither touches a manifest -given: { workspace: fixture_two_modules, concurrent_candidates: [module_a, module_b], orders: [ab, ba] } -expect: { both_installed: true, manifest_bytes_unchanged: true } -::: +fn locate( + workspace: &Path, + docs: &BTreeMap, + records: &[Attribution], + uri: &str, + line0: u32, +) -> Located { + let path = uri_to_path(uri); + let rel = relativize(workspace, &path); + let raw_line = line0 + 1; + if let Some(module) = stem(&rel) { + if records.iter().any(|record| record.module == module) { + let offset = document_text(workspace, docs, uri, &rel) + .map(|text| frontmatter_lines(&text)) + .unwrap_or(0); + if raw_line > offset { + return Located::Prompt { + module, + line: raw_line - offset, + }; + } + return Located::None; + } + } + Located::Generated { + file: rel, + line: raw_line, + } +} -::: test -name: "absent telemetry reads as unavailable, never zero" -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: without_usage } -expect: { usage: unavailable } -::: +fn prompt_entries<'a>(records: &'a [Attribution], module: &str, line: u32) -> Vec<&'a Entry> { + records + .iter() + .filter(|record| record.module == module) + .flat_map(|record| record.entries.iter()) + .filter(|entry| entry.prompt_start <= line && line <= entry.prompt_end) + .collect() +} -Attribution rides the staged record; there is no other write path for it. -`napl add --attribution ` carries the module's mapping of prompt body -line ranges to generated file line ranges with notes; add validates every -claim before staging: each file path inside the module's owned globs, -each code line range present in the candidate tree, each prompt line -range present in the current normalized prompt body; a mapping that lies -is `invalid-candidate`. An entry may own no file at all: contract-level -prose — a law — that governs the module rather than any single path; -validation accepts a file-less entry (its prompt range must still exist) -and the query layer serves it flagged as law. An add whose transport is -incomplete and whose affirmations do not cover the listed claims fails -loudly as `invalid-candidate`, naming exactly which claims are missing; -`napl add --no-verify` is the explicit escape hatch that stages anyway -and records the module's attribution as stale on install. Any prompt -edit, code transition, or move that changes what a recorded mapping -points at marks it stale the same way; nothing ever repairs a mapping -automatically. `napl status` reports attribution freshness per -module (`fresh`, `stale`, `absent`) and, for stale or absent, emits a -machine-readable directive naming the exact fix: commit with a fresh -attribution for that module. Relocation recomputes only the path prefix -of a mapping, never its line content. The lsp serves a stale mapping -marked stale rather than hiding it; absent answers empty. +fn generated_entries<'a>( + records: &'a [Attribution], + file: &str, + line: u32, +) -> Vec<(&'a Attribution, &'a Entry)> { + let mut out: Vec<(&Attribution, &Entry)> = Vec::new(); + for record in records { + for entry in &record.entries { + if generated_match(file, &entry.file) + && entry.line_start <= line + && line <= entry.line_end + { + out.push((record, entry)); + } + } + } + out +} -An attribution entry is a reference, and a reference has a direction: -prompt-to-code, a prompt body line range naming generated file line -ranges, or code-to-prompt, a generated file line range naming a prompt -body line range. Every entry recorded before directions existed reads as -prompt-to-code; migration is a reading, never a rewrite of stored -records. Validation is identical for both directions and referential -only, the same claims checked the same way, and nothing about a -direction implies the sentence describes the code: a reference is a -claim with provenance and freshness, and the record vocabulary never -says more. +fn markdown(lines: Vec) -> Value { + json!({ "contents": { "kind": "markdown", "value": lines.join("\n") } }) +} -A reference carries a provenance block, recorded by the compiler at -staging time from what the caller supplied: recording model identity, -timestamp, tool version, and an optional usage-envelope reference -through the existing evidence seam. Every absent field is `unavailable`, -never zero, never invented, and provenance is display and pricing -metadata that never enters any identity, exactly as telemetry already -behaves. The query layer and the lsp serve the provenance block with the -entry. +fn hover(docs: &BTreeMap, message: &Value) -> Value { + let params = &message["params"]; + let uri = params["textDocument"]["uri"].as_str().unwrap_or(""); + let line0 = params["position"]["line"].as_u64().unwrap_or(0) as u32; + let Some(workspace) = resolve_workspace(uri) else { + return Value::Null; + }; + let workspace = workspace.as_path(); + let records = load_records(workspace); + match locate(workspace, docs, &records, uri, line0) { + Located::Prompt { module, line } => { + let matched = prompt_entries(&records, &module, line); + if matched.is_empty() { + return Value::Null; + } + let mut lines: Vec = Vec::new(); + for entry in matched { + lines.push(format!( + "{}:{}-{} - {}", + entry.file, entry.line_start, entry.line_end, entry.note + )); + } + markdown(lines) + } + Located::Generated { file, line } => { + let matched = generated_entries(&records, &file, line); + if matched.is_empty() { + return Value::Null; + } + let mut lines: Vec = Vec::new(); + for (record, entry) in matched { + lines.push(format!( + "{}.napl:{}-{} - {}", + record.module, entry.prompt_start, entry.prompt_end, entry.note + )); + } + markdown(lines) + } + Located::None => Value::Null, + } +} -Staleness covers both ends of a reference, and working-tree drift is one -of the ways an end changes: a reference whose code side no longer -matches the committed bytes, or whose prompt side changed in the working -tree, reads stale on both endpoints, computed at read time from the -records and the working tree, never stored as a transition. A hand edit -to generated source is drift like any other: legal, visible through the -same status and diff machinery, healable by restore or by re-recording -the reference; nothing repairs a reference automatically and nothing -polices the edit that staled it. +fn location(workspace: &Path, file: &str, start: u32, end: u32) -> Value { + let uri = format!("file://{}", workspace.join(file).to_string_lossy()); + let start_line = start.saturating_sub(1); + let end_line = end.saturating_sub(1); + json!({ + "range": { + "end": { "character": 0, "line": end_line }, + "start": { "character": 0, "line": start_line } + }, + "uri": uri + }) +} -::: test -name: a code-to-prompt entry stages and is served with its direction -given: { workspace: fixture_two_modules, candidate: valid_with_code_to_prompt_reference } -expect: { add_outcome: staged, served_direction: code_to_prompt } -::: +fn prompt_offset(workspace: &Path, docs: &BTreeMap, module: &str) -> u32 { + let rel = format!("{module}.napl"); + let uri = format!("file://{}", workspace.join(&rel).to_string_lossy()); + document_text(workspace, docs, &uri, &rel) + .map(|text| frontmatter_lines(&text)) + .unwrap_or(0) +} -::: test -name: entries recorded before directions read as prompt-to-code unchanged -given: { workspace: fixture_two_modules, history: [pre_direction_attribution] } -expect: { served_direction: prompt_to_code, stored_records_rewritten: false } -::: +fn definition(docs: &BTreeMap, message: &Value) -> Value { + let params = &message["params"]; + let uri = params["textDocument"]["uri"].as_str().unwrap_or(""); + let line0 = params["position"]["line"].as_u64().unwrap_or(0) as u32; + let Some(workspace) = resolve_workspace(uri) else { + return Value::Array(Vec::new()); + }; + let workspace = workspace.as_path(); + let records = load_records(workspace); + let mut out: Vec = Vec::new(); + match locate(workspace, docs, &records, uri, line0) { + Located::Prompt { module, line } => { + for entry in prompt_entries(&records, &module, line) { + out.push(location(workspace, &entry.file, entry.line_start, entry.line_end)); + } + } + Located::Generated { file, line } => { + for (record, entry) in generated_entries(&records, &file, line) { + let offset = prompt_offset(workspace, docs, &record.module); + out.push(location( + workspace, + &format!("{}.napl", record.module), + entry.prompt_start + offset, + entry.prompt_end + offset, + )); + } + } + Located::None => {} + } + Value::Array(out) +} -::: test -name: provenance is served with the entry and absent fields read unavailable -given: { workspace: fixture_two_modules, candidate: reference_without_usage_envelope } -expect: { provenance_served: true, usage: unavailable, cost_component: unavailable } -::: +fn references(docs: &BTreeMap, message: &Value) -> Value { + let params = &message["params"]; + let uri = params["textDocument"]["uri"].as_str().unwrap_or(""); + let line0 = params["position"]["line"].as_u64().unwrap_or(0) as u32; + let Some(workspace) = resolve_workspace(uri) else { + return Value::Array(Vec::new()); + }; + let workspace = workspace.as_path(); + let records = load_records(workspace); + let matched: Vec<(&Attribution, &Entry)> = + match locate(workspace, docs, &records, uri, line0) { + Located::Prompt { module, line } => prompt_entries(&records, &module, line) + .into_iter() + .filter_map(|entry| { + records + .iter() + .find(|record| record.module == module) + .map(|record| (record, entry)) + }) + .collect(), + Located::Generated { file, line } => generated_entries(&records, &file, line), + Located::None => Vec::new(), + }; + let mut out: Vec = Vec::new(); + for (record, entry) in matched { + let offset = prompt_offset(workspace, docs, &record.module); + out.push(location( + workspace, + &format!("{}.napl", record.module), + entry.prompt_start + offset, + entry.prompt_end + offset, + )); + out.push(location(workspace, &entry.file, entry.line_start, entry.line_end)); + } + Value::Array(out) +} -::: test -name: a hand edit to the code side reads stale on both endpoints -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } -expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } -::: +fn code_lens(docs: &BTreeMap, message: &Value) -> Value { + let uri = message["params"]["textDocument"]["uri"].as_str().unwrap_or(""); + let Some(workspace) = resolve_workspace(uri) else { + return Value::Array(Vec::new()); + }; + let workspace = workspace.as_path(); + let records = load_records(workspace); + let path = uri_to_path(uri); + let rel = relativize(workspace, &path); + let Some(module) = stem(&rel) else { + return Value::Array(Vec::new()); + }; + let Some(record) = records.iter().find(|record| record.module == module) else { + return Value::Array(Vec::new()); + }; + let Some(text) = document_text(workspace, docs, uri, &rel) else { + return Value::Array(Vec::new()); + }; + let offset = frontmatter_lines(&text); + let mut headings: Vec = Vec::new(); + for (index, line) in text.split('\n').enumerate() { + let raw_line = index as u32 + 1; + if raw_line <= offset { + continue; + } + if line.trim_start().starts_with('#') { + headings.push(raw_line - offset); + } + } + let mut lenses: Vec = Vec::new(); + for (position, &body_line) in headings.iter().enumerate() { + let next = headings.get(position + 1).copied().unwrap_or(u32::MAX); + let mut files: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); + for entry in &record.entries { + if entry.prompt_start >= body_line && entry.prompt_start < next { + files.insert(entry.file.as_str()); + } + } + let raw_line = body_line + offset; + lenses.push(json!({ + "command": { + "command": "", + "title": format!("{} attributed files", files.len()) + }, + "range": { + "end": { "character": 0, "line": raw_line - 1 }, + "start": { "character": 0, "line": raw_line - 1 } + } + })); + } + Value::Array(lenses) +} +napl-store receipt v1 1042 +{"baseStateKey":"79b5813169ed5adeb10cc35fc52c22e390bdef3e22fb2a3dd0b3a300ec844dc4","baseStoreSnapshot":"8df3ac8e0604ab839c829a8f25bbe65b20d19aa7ec8b60a7d143d8429738c2a4","baseWorkspaceHash":"f741de8fc098f0c0452e95aebb018f72540fab2027c6f2a19eee83a54b8b3bf8","candidateTreeHash":"9853f46abd1fcdca3b3eaad6a5970de328a6dd30d982591cdfe46507193679c8","contractVersion":4,"inputKey":"66757b679873952442ef2168c17e58616924374434f76a86e04eede6b872ab41","module":"query","outputManifest":[{"hash":"3b3b46d4f8f832a2b67e59d481fce3269061f0c79cb335d3ddc5a51d8a1ce915","path":"crates/napl/src/check.rs"},{"hash":"b5e4e2782fa3e7b35714d43c6a5dd578e6adc40a53e872bce582dd3d76dc27c2","path":"crates/napl/src/document.rs"},{"hash":"2e84007b600de3257549b12e35b45e779d85751c59dd2ec7db7abde7ecd7e4c2","path":"crates/napl/src/promptbuild.rs"},{"hash":"c38df1128a297e15c512cf45a5c041588f37f1fea97b2ab011b6edc9b5bb945f","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"sliceHash":"1696723be87d0e2bc09005e0bb3eab8b50bef71452161241d132f1e9bfa8da87","target":"rust"} +napl-store blob v1 9954 +--- +module: commands +deps: [napl] +targets: [rust] +--- +:::docs +# Starting a workspace -::: test -name: a working-tree prompt edit reads stale on both endpoints -given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched } -expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } -::: +napl init bootstraps a workspace and napl watch keeps it fresh. -::: test -name: a code-to-prompt reference naming a missing prompt range is invalid-candidate -given: { workspace: fixture_two_modules, candidate: code_to_prompt_reference_to_missing_range } -expect: { outcome: invalid_candidate, published_semantic_state: false } +Two commands carry the daily loop. `napl init` turns a directory into +a workspace once; `napl watch` regenerates code whenever a prompt +changes. Everything between those two moments is the add and commit +cycle described in the tutorial. ::: +# commands: init and watch -## edit and write: the capture verbs - -`napl edit` and `napl write` are the deterministic capture layer in -front of the same guarded door. They carry the familiar tool -signatures: `napl edit --old-string --new-string -[--replace-all]` replaces an exact occurrence in a file, and -`napl write ` writes stdin as the file's full content; its usage -line says the content comes from stdin, and an empty stdin refuses as -a usage error naming `--empty`, the explicit flag that authorizes -writing a zero-byte file, so a forgotten pipe can never silently -truncate. Either -side is editable, a prompt or a generated file, by a human or an LLM; -a hand edit through an ordinary editor stays equally legal, and the -verbs add capture, never exclusivity. The verbs never synthesize or -rewrite prose beyond the bytes given, never invoke a model, never -decide placement, never repair a reference, and never bypass the -guard: `add` and `commit` remain exactly what they are. +Two porcelain additions to the `napl` binary, each a thin composition +over the same napl-core derivations and napl-store reads the other +commands use. They live in this module and compile into the same crate as +the core toolchain. The query surface that used to live here belongs to +the `query` module. -Each call, deterministically and in this order: validate, apply, -capture. When the target is a prompt file, the result of the edit is -validated first by the same reader `check` and `add` use; a result -that fails validation refuses as `prompt-invalid` and applies nothing. -An `--old-string` that matches nowhere, matches more than once -without `--replace-all`, or equals `--new-string` refuses as -`edit-mismatch` and applies nothing, so a no-op edit can never mint a -record. Only a call that will succeed touches the working tree, so -every refusal leaves every file byte-identical. +## napl init -A call may carry one reference: `--ref `, a single object naming -a direction and both endpoints, prompt body line range and generated -file line range, in either direction per the reference contract above. -The contract is taught, never reverse engineered: the verbs' usage -lines and flag meanings name the object's fields, and the FIRST -validation failure of a ref payload answers with the complete -contract in one refusal, every field, the closed list of valid -`direction` values, and one complete valid example, never one missing -field per attempt. -The reference is validated referentially only, exactly as strong as -attribution validation and no stronger: the file path inside the -owning module's placement, both line ranges present in the -post-edit bytes of their files. A reference naming a missing line or -a path outside placement refuses as `reference-unresolvable` and -applies nothing. A valid reference-bearing call stages a -reference-only record and commits it through the guarded transaction -in the same invocation: the transition carries the reference entry and -its provenance, no output-tree change and no surface change, and every -rule of the decision table, journaled atomicity, and recovery applies -to it unchanged. The success payload carries the recorded reference -object verbatim and its transition hash, and says plainly that the -edited file remains working-tree drift until its module is committed: -recording a reference and advancing the committed baseline are -different acts, and the payload spells the sequence that performs the -second, `napl add` for the module then `napl commit`, so nobody reads -`recorded: true` as committed and nobody feeds a bare commit nothing. -A plain apply teaches the same way: a written or edited file is -working-tree drift until staged and committed, and the success -payload of both verbs names both verbs in order. `napl show ` lists the -transition's reference entries inline exactly as it lists notes. A -call without `--ref` is a plain edit: apply, no -record, no store touch. Any existing reference whose endpoint the edit -moved needs no marking, because both-ends staleness is already -computed at read time from the working tree; the verbs store nothing -about it. +:::docs +## napl init -A reference the verb records is evidence, and evidence serves itself. -The record carries the content hash of each endpoint's post-edit -bytes, the file as the verb wrote it and the normalized prompt body it -validated against. Freshness reads those hashes first: a verb-recorded -reference whose endpoint bytes still match the working tree reads -fresh, and it goes stale only when the tree moves off the bytes the -record names, never merely because the committed baseline lags the -edit, so `recorded: true` is never followed by a stale count the verb -itself caused. At the next add, transport seeds from the same -evidence: a committed claim refusing in a rewritten region is affirmed -automatically when a reference transition recorded since the module's -committed output covers that region and its endpoint hashes match the -staged bytes, because the tool that applied the edit owns its diff; -the affirmation is the record, and no hand-authored map is ever -demanded for a range a capture verb already recorded. A recorded -reference whose hashes no longer match affirms nothing and is listed -in the refusal like any other claim, so the shortcut never guesses. +Run `napl init` once, in the directory that should become the +workspace. It creates the `.napl/` records directory with an empty +store and touches nothing else. Running it again is safe: an existing +workspace reports `already_initialized` and no file changes. -::: test -name: a verb-recorded reference reads fresh until the tree moves off its bytes -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: status_module } -expect: { stale_reference_count: 0, drift: true } +The payload teaches the first mile. `nextSteps` carries one complete +minimal prompt and the exact command sequence from writing it through +`add` and `commit`, so the first module never starts from a blank +page. Inside a git repository, init also writes the `.gitattributes` +lines that keep store files quiet in diffs, and writes the agent +entry files (`napl-agents.md`, `CLAUDE.md`, `AGENTS.md`) only where +they do not already exist. ::: -::: test -name: add after a referenced edit self-affirms the rewritten region and stages without a map -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, add: module_form_without_attribution } -expect: { add_outcome: staged, affirmed_from_reference_record: true, attribution_flag_needed: false } +:::example{lang="cli" name="bootstrap a workspace"} +napl init ::: -::: test -name: a recorded reference whose bytes moved again affirms nothing -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: hand_edit_same_region, add: module_form_without_attribution } -expect: { outcome: invalid_candidate, listed_claims_present: true, affirmed_from_reference_record: false } +:::note +Init never nests workspaces and never rewrites a file it finds. If an +entry file already exists, the payload prints the one reference line +you may add by hand. ::: -Provenance rides the session window: `--session ` names the -usage-proxy session whose log lines are this call's evidence, and when -the flag is absent the verb reads the `NAPL_SESSION` environment -variable, so a call made under `napl agent` carries its window with no -extra argument. The recorded usage reference is the window itself, -media type, workspace-relative log path, session id, and the line span -observed at staging, priced lazily by `napl cost` at read time, never -aggregated at record time. Model identity and timestamp come from the -window's log lines, or read `unavailable` when there is no window; -the verbs read no clock, so the same call over the same working tree, -store state, and log bytes produces byte-identical records. Absent is -`unavailable`, never zero, never invented, and provenance never -enters identity. - -The three refusals join the closed terminal-outcome table under the -contract bump to 4, pinned exit codes continuing the table order: -`edit-mismatch` 27, `reference-unresolvable` 28, `prompt-invalid` 29. -None reuses `mapping-integrity`, and each is a canonical -machine-readable JSON payload naming what failed and that nothing was -applied or published. +:::docs +### Your store travels with your code -::: test -name: an edit with a valid code-to-prompt reference stages and commits in one call -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } -expect: { outcome: installed, served_direction: code_to_prompt, output_tree_changed: false } +The store is ordinary files under `.napl/`, so it travels the way +your code does: commit, push, clone, nothing extra. Inside a git +repository init writes the `.gitattributes` lines that mark store +objects and packs as generated, so pull requests stay readable while +the store rides along. Outside git, none of this appears. ::: -::: test -name: the same edit call twice produces byte-identical records -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, runs_from_same_base: 2 } -expect: { records_byte_identical: true } -::: +`napl init` bootstraps a workspace: it creates the records directory +`.napl/` with an empty store (`store/` skeleton, no HEAD, no objects) in +the current directory when no ancestor workspace exists. Running it inside +an existing workspace changes nothing and reports `already_initialized`; +it never creates a nested workspace under another workspace and never +touches an existing store's bytes. The result of init is exactly the +state a fresh `napl compile` expects: head absent, no modules recorded. +Like `git init`, the directory it runs in is the whole story: the +success payload names that directory as the workspace root, there is +nothing to configure, and a prompt written beside `.napl` is already +inside placement. Init accepts no flags beyond the shared help flag; +anything else is a usage error under the command line contract. The +payload also teaches the first mile: a `nextSteps` field carries one +complete minimal prompt, frontmatter fences with `module`, `deps`, and +`targets` plus one body line, the fact that discovery finds prompts by +the `.napl` extension, and the exact command sequence from writing that +prompt through `add` and `commit`, so a cold user never reverse +engineers the grammar one validation error at a time. The minimal +prompt's frontmatter includes a `roots` declaration naming its own +module, so the very first workspace is lint-ready and the sequence's +forward pointer about `roots` is a reminder, never a first encounter. -::: test -name: a reference to a missing line refuses as reference-unresolvable and applies nothing -given: { workspace: fixture_two_modules, edit: ref_names_missing_line } -expect: { outcome: reference_unresolvable, exit_code: 28, files_byte_identical: true } -::: +Init also scaffolds the agent-teaching glue, wrangler-style, writing +only what is absent and never touching an existing file: a +`napl-agents.md` at the workspace root carrying the working doctrine +for LLM sessions (edit with your native tools, drift is legal and the +diagnostics mark it, the status-add-commit loop, the query surface, +capture verbs are deliberate acts), plus, when absent, a `CLAUDE.md` +containing an import reference to it and an `AGENTS.md` containing a +pointer line, so whichever harness opens the repo finds its native +entry file already pointing at the one doctrine. Every command the +scaffolded teaching spells is a valid invocation under the live +command line contract: a query example always carries its expression, +`napl q '.' --working-tree` and never a bare `napl q`, because the +expression is required and a bare invocation is a usage error. No +environment is +sniffed and no harness detected: every entry file is written, tiny +and inert, and determinism holds. When an entry file already exists, +init leaves it alone and the payload prints the one reference line +the user may add by hand. All scaffolded files are user-owned from +the moment they exist: ordinary files, never records, never +rewritten by any later command. -::: test -name: a prompt edit that breaks the prompt refuses as prompt-invalid and applies nothing -given: { workspace: fixture_two_modules, edit: prompt_edit_breaking_directive } -expect: { outcome: prompt_invalid, exit_code: 29, files_byte_identical: true } -::: +When init runs inside a git repository it also scaffolds the store's +git manners under the same never-clobber law: a `.gitattributes` at +the workspace root carrying `.napl/store/objects/** -text -diff +linguist-generated` covering objects and packs alike, +`.napl/store/HEAD binary`, and `-text` on the store's notes and the +attribution records, which keep their readable diffs because reading +them is those files' purpose; written only when no `.gitattributes` +exists, and when one exists, init leaves it alone and the payload +prints the exact lines to add by hand. `-text` is the load-bearing word: an +envelope's name is the hash of its exact bytes, so an eol-converting +checkout on any platform would corrupt every object against its own +name, and marking the store never-text is what makes a clone safe +everywhere. `binary` on HEAD keeps a merge from ever writing conflict +markers into the pointer. Nothing else about travel needs teaching, +because the store is ordinary tracked files and git already carries +them. Outside a git repository none of this appears, in the payload +or on disk. ::: test -name: an ambiguous old-string without replace-all refuses as edit-mismatch -given: { workspace: fixture_two_modules, edit: old_string_matches_twice } -expect: { outcome: edit_mismatch, exit_code: 27, files_byte_identical: true } +name: init in an empty directory creates the records skeleton with no head +given: { directory: empty } +expect: { created: [".napl/store"], head: absent, exit_zero: true, workspace_root_reported: true } ::: ::: test -name: the first bad ref payload answers with the whole contract at once -given: { workspace: fixture_two_modules, edit: ref_payload_bare_string } -expect: { error_lists_all_fields: true, error_lists_direction_values: true, error_carries_valid_example: true, files_byte_identical: true } +name: init teaches the first mile with a complete minimal prompt and the command sequence +given: { directory: empty } +expect: { next_steps_prompt_passes_check: true, extension_named: ".napl", sequence_named: [write_prompt, add, commit], prompt_declares_roots: true } ::: ::: test -name: a recorded edit serves its reference in the payload and in show -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } -expect: { payload_reference_verbatim: true, payload_names_drift_until_commit: true, show_lists_reference: true } +name: init scaffolds the agent glue only where absent and clobbers nothing +given: { directory: with_existing_claude_md } +expect: { napl_agents_md_written: true, agents_md_written: true, claude_md_untouched: true, payload_prints_reference_line: true } ::: ::: test -name: a prose-only edit needs no reference and touches no store state -given: { workspace: fixture_two_modules, edit: plain_prompt_edit_valid } -expect: { applied: true, store_bytes_unchanged: true } +name: scaffolded teaching never spells a bare napl q and its query example carries an expression +given: { directory: empty } +expect: { bare_napl_q_absent: true, query_example_has_expression: true } ::: ::: test -name: verb success payloads spell the add then commit sequence -given: { workspace: fixture_two_modules, invocations: [plain_write_owned_file, referenced_code_edit_with_ref] } -expect: { write_payload_names: [add, commit], edit_payload_names: [add, commit] } +name: scaffolded files are inert and later commands never rewrite them +given: { directory: empty, then: [init, write_prompt, add_module, commit] } +expect: { scaffold_bytes_unchanged_after: true } ::: ::: test -name: write with empty stdin refuses naming the empty flag and writes nothing -given: { workspace: fixture_two_modules, write: target_with_empty_stdin } -expect: { exit_code: 2, error_names_flag: "--empty", files_byte_identical: true } +name: init where a workspace already exists changes nothing and says so +given: { directory: existing_workspace } +expect: { changed_files: [], outcome: already_initialized, exit_zero: true } ::: ::: test -name: an edit touching a referenced line reads stale on both endpoints afterward -given: { workspace: fixture_two_modules, edit: plain_edit_on_referenced_code_line } -expect: { reference_status: stale, stale_endpoints: [prompt, code] } +name: init never overwrites an existing store +given: { directory: existing_workspace_with_commits } +expect: { store_bytes_unchanged: true } ::: ::: test -name: a session window from the environment is recorded as the usage reference -given: { workspace: fixture_two_modules, env: { NAPL_SESSION: s1 }, log: proxy_log_with_s1_lines, edit: referenced_code_edit_with_ref } -expect: { provenance_usage_session: s1, usage_span_recorded: true, aggregated_at_record: false } +name: init inside a git repository writes the gitattributes store lines only where no gitattributes exists +given: { directory: git_repo_no_gitattributes } +expect: { gitattributes_written: true, store_objects_marked_never_text_undiffed_generated: true, head_marked_binary: true, notes_and_attribution_never_text_diff_kept: true } ::: ::: test -name: an edit with no session window records provenance unavailable -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, env: {} } -expect: { provenance_model: unavailable, provenance_usage: unavailable } +name: init leaves an existing gitattributes alone and prints the lines to add +given: { directory: git_repo_with_gitattributes } +expect: { gitattributes_bytes_unchanged: true, payload_prints_store_lines: true } ::: -## The rest of the porcelain - -`napl status` observational classification, zero mutation, and it -judges both ends: per module it reports `drift: true` when the -committed output differs from the working tree, and the module's stale -reference count, the same derivations diff and the lsp already serve, -so status can never read clean while the query surface reads stale. -A module fresh on the prompt side and drifted on the code side says -so in one payload. `napl status --module ` answers with the -module's live state beside its plan, tracked or pending, drift, stale -reference count, attribution status, in the same payload as the owned -globs and wave position, so the command named status never answers -with a plan alone; and the payload carries the derived entry-file -projection, path and exact content, so the line a new file needs in a -shared entry file is read from the tool, never composed by hand. -Every projection path the plan mentions is labeled what it is, -tool-derived and never user-supplied, and the plan names one concrete -legal first output path (the module name under the target's source -dir), so the obvious wrong first move, hand-writing the entry file, -is warned against before it is made. The add refusal for a module -whose owned tree holds nothing stageable says the same thing: which -paths present are projections, and what the first legal file would -be. -`napl log` entries carry each transition's note count and reference -count, and the log payload names `napl show ` as where -the full note and reference text is served, so provenance is one -named hop away, never a secret; `napl show ` lists its -attached notes inline, hash and text, so a note attached is a note -findable without plumbing. - ::: test -name: log counts references and names show as the inspection verb -given: { workspace: fixture_two_modules, history: [commit, referenced_edit] } -expect: { log_reference_count: 1, show_named_in_payload: true } +name: init outside a git repository scaffolds no hook and teaches no git rules +given: { directory: empty } +expect: { pre_push_hook_written: false, payload_mentions_napl_store_branch: false } ::: -`napl log` -per-module or store-wide history. `napl show ` inspect one object. -`napl diff` drift between committed outputs and working tree as a canonical -byte-stable patch. `napl blame ` derived ancestry: surviving lines -trace through accepted patches by canonical replay; rewritten regions blame -the rewriting transition, honestly coarse. Blame reads committed -bytes only, and says so when it matters: a blamed path whose working -tree drifts from the committed output carries a drift notice naming -`napl diff` as where the uncommitted change lives, so ancestry over -the committed bytes is never mistaken for the present file. +## napl watch -::: test -name: blame over a drifted path names the drift and points at diff -given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, blame_path: drifted_file } -expect: { drift_noticed: true, diff_named: true, ancestry_served: true } -::: +:::docs +## napl watch -`napl lint` unreachable modules -under an explicit `roots` declaration; when roots are absent the refusal -is loud, exit 2 with canonical JSON naming the missing prerequisite and -the exact declaration that would satisfy it, never an empty findings -list under exit 0; unconsumed surface entries (diagnosis only), module staleness; -findings are proposals with cascade previews; `napl lint --fix` applies -only complete deterministic mapping-safe edits through the guarded commit -path and never removes lines. `napl lint ` is the -source-language scan, a different question needing no roots: it parses -every committed module's owned working-tree file under that target -with the pinned grammar and reports one finding per file that fails, -naming the file and the first error's line. Every lint payload names -the scope it judged, graph or source syntax, so an empty findings -list is never read wider than the scan that produced it. Source -syntax findings are facts, not proposals: a target scan carrying any -finding exits 1, so automation can never read broken source as -success, and a clean scan exits 0; the graph scope keeps its -advisory exit. +Run `napl watch` in the workspace and it watches every prompt file for +changes. Each time you save a prompt, it recompiles and prints the +result to your terminal as JSON. It never writes anything: no files +change, nothing is added to the store. -::: test -name: lint of a target exits nonzero on source findings and zero when clean -given: { workspace: fixture_two_modules, runs: [clean_tree, syntactically_invalid_owned_file], lint: target_form } -expect: { clean_exit_code: 0, broken_exit_code: 1, findings_when_broken: 1 } +Watch notices real changes, not just a save: if the file's content is +the same as before, nothing recompiles. If you save a prompt with a +mistake in it, watch reports the problem in its output and keeps +running rather than stopping. ::: -::: test -name: lint of a target reports a parse finding per malformed working-tree file -given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, lint: target_form } -expect: { findings: 1, error_names_file_and_line: true, scope_named: source_syntax, roots_required: false } +:::example{lang="cli" name="watch a workspace for changes"} +napl watch ::: -::: test -name: lint of a target over a clean tree answers empty and names its scope -given: { workspace: fixture_two_modules, lint: target_form } -expect: { findings: 0, scope_named: source_syntax, exit_code: 0 } +:::note +The only way to stop `napl watch` is to stop the process. Nothing is +lost when you do. ::: -`napl cost` pure calculation over an explicit -receipt set and a pinned pricing-table provenance object; unknown is -unknown, never zero. Edits price the way generations price: records the -capture verbs staged enter the same receipt set, their session-window -usage references resolve at read time against the named log's bytes, -summing the window's lines under the same pinned pricing table, and -`napl cost --session ` reports one session's calls directly from -the log. A record without a window prices `unavailable`; a window whose -log file or session lines are absent prices `unavailable` and says -which; a model the pricing table does not name prices unknown; nothing -is ever silently zero and nothing is aggregated at record time. The -same receipt set, log bytes, and pricing table produce byte-identical -reports. `napl gc` stages sweeps outside locks, swaps pointers -inside the bounded section, never blocks writers, never deletes anything -reachable from a named head. `napl gc --apply` also repacks: the -reachable closure lands in one pack beside its index under -`objects/pack/` through the store's repack operation, loose files and -older packs are removed after the pack is durable, and the payload -names the pack written, the objects packed, and the files removed. -The dry `napl gc` reports what a repack would write. Every read -answers identically before and after a repack, fsck is healthy after -exactly when it was healthy before, and publication keeps writing -loose objects, so a store is always the sum of its packs plus its -loose files. Nobody is required to know any of this: exactly as git -packs itself, a successful commit whose store holds more loose -objects than a compiled-in threshold runs the same repack -automatically, after the critical section and outside every lock, -and reports what it packed under a `repack` key in the commit -payload; the threshold is a count, never a clock, so when the repack -fires is a function of store content alone, and the pack it writes -is byte-identical to the one gc would have written. A user who never -types gc keeps a small store; gc remains the explicit verb for doing -it now. Reachable means the transitive closure: -from every named head, through every event in its chain, through every -object any reachable object references by hash, output trees, -surfaces, attribution records, prompt blobs, receipts, at every -contract version history carries. `fsck`'s definition of a present -object IS gc's definition of a kept one; a store that was healthy -before a sweep is healthy after it, and blame replays the same -ancestry after a sweep as before. An edge gc does not understand is a -reason to keep, never to sweep. - -::: test -name: gc apply on a workspace with history leaves fsck healthy and blame replaying -given: { workspace: fixture_with_multi_commit_history, gc: apply } -expect: { fsck_findings: 0, blame_replays_history: true, head_intact: true } -::: +`napl watch` is an I/O shell around the pure compile derivation: it +observes the workspace's `*.napl` files and re-runs the derivation when +their content changes, emitting each result to stdout as canonical JSON. It publishes nothing: no semantic writes, no store +mutation, no locks held between runs. Change detection is by content +hash, never by timestamp, so an editor save that leaves bytes identical +produces no recompile. A malformed prompt is reported as a diagnostic in +the output stream and the watcher keeps running. Stopping the process is +the only way to end it, and stopping it loses nothing. ::: test -name: gc apply repacks the reachable closure into one pack and the store still answers -given: { workspace: fixture_with_multi_commit_history, gc: apply } -expect: { packs: 1, loose_reachable_remaining: 0, all_commands_answer_identically: true, fsck_findings: 0 } +name: watch re-derives compile output when a prompt changes and writes nothing semantic +given: { workspace: fixture_two_modules, edit: touch_leaf_prompt } +expect: { recompiled: true, semantic_state_published: false } ::: ::: test -name: a commit after a repack writes loose objects beside the pack -given: { workspace: fixture_with_multi_commit_history, gc: apply, then: valid_commit } -expect: { commit_outcome: installed, new_objects_loose: true, pack_untouched: true } +name: watch survives a malformed prompt edit and reports it instead of dying +given: { workspace: fixture_two_modules, edit: break_leaf_frontmatter } +expect: { process_alive: true, diagnostic_reported: true } ::: -::: test -name: a commit crossing the loose threshold repacks automatically and says so -given: { workspace: fixture_with_multi_commit_history, loose_objects: above_threshold, then: valid_commit } -expect: { commit_outcome: installed, repack_reported: true, packs: 1, loose_reachable_remaining: 0 } -::: +napl-store snapshot v1 445 +{"contractVersion":1,"event":"d8cbb549ed651915f8d0e273a5967e6243279f3a9bf79eed5e099135c535828c","states":[{"module":"commands","state":"7c7e8183b9fbf594f3d61a4ca43f5154c404f912f32b8789d32c697ae883859b","target":"rust"},{"module":"napl","state":"81b727d0fdbdcbf9412b7798b650076b811080051e9b4598ce3677811b30443a","target":"rust"},{"module":"napl_store","state":"e0c61506bbf3fe83ae5425120174331a4abfe75f3e794691484414af3a0acd53","target":"rust"}]} +napl-store surface v1 73442 +{"contractVersion":4,"items":[{"kind":"struct","path":"attribution::Attribution","signature":"pub struct Attribution"},{"kind":"field","path":"attribution::Attribution.entries","signature":"pub entries : Vec < Entry >"},{"kind":"field","path":"attribution::Attribution.module","signature":"pub module : String"},{"kind":"field","path":"attribution::Attribution.stale","signature":"pub stale : bool"},{"kind":"field","path":"attribution::Attribution.target","signature":"pub target : String"},{"kind":"enum","path":"attribution::Direction","signature":"pub enum Direction { # [ default ] PromptToCode , CodeToPrompt }"},{"kind":"method","path":"attribution::Direction::as_str","signature":"pub fn as_str ( & self ) -> & ' static str"},{"kind":"method","path":"attribution::Direction::from_token","signature":"pub fn from_token ( text : & str ) -> Direction"},{"kind":"const","path":"attribution::ENDPOINTS","signature":"pub const ENDPOINTS : [ & str ; 2 ] = [ \" code \" , \" prompt \" ] ;"},{"kind":"struct","path":"attribution::Entry","signature":"pub struct Entry"},{"kind":"field","path":"attribution::Entry.code_hash","signature":"pub code_hash : Option < String >"},{"kind":"field","path":"attribution::Entry.direction","signature":"pub direction : Direction"},{"kind":"field","path":"attribution::Entry.file","signature":"pub file : Option < String >"},{"kind":"field","path":"attribution::Entry.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"attribution::Entry.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"attribution::Entry.note","signature":"pub note : String"},{"kind":"field","path":"attribution::Entry.prompt_end","signature":"pub prompt_end : u32"},{"kind":"field","path":"attribution::Entry.prompt_hash","signature":"pub prompt_hash : Option < String >"},{"kind":"field","path":"attribution::Entry.prompt_start","signature":"pub prompt_start : u32"},{"kind":"field","path":"attribution::Entry.provenance","signature":"pub provenance : Provenance"},{"kind":"field","path":"attribution::Entry.whole_file","signature":"pub whole_file : bool"},{"kind":"field","path":"attribution::Entry.whole_prompt","signature":"pub whole_prompt : bool"},{"kind":"method","path":"attribution::Entry::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"attribution::Entry::hashes_match","signature":"pub fn hashes_match ( & self , code_bytes : Option < & [ u8 ] > , prompt_body : & str ) -> bool"},{"kind":"method","path":"attribution::Entry::is_law","signature":"pub fn is_law ( & self ) -> bool"},{"kind":"method","path":"attribution::Entry::self_affirming","signature":"pub fn self_affirming ( & self ) -> bool"},{"kind":"struct","path":"attribution::MapParseError","signature":"pub struct MapParseError"},{"kind":"field","path":"attribution::MapParseError.construct","signature":"pub construct : String"},{"kind":"field","path":"attribution::MapParseError.detail","signature":"pub detail : String"},{"kind":"field","path":"attribution::MapParseError.line","signature":"pub line : u32"},{"kind":"struct","path":"attribution::Provenance","signature":"pub struct Provenance"},{"kind":"field","path":"attribution::Provenance.model","signature":"pub model : Option < String >"},{"kind":"field","path":"attribution::Provenance.timestamp","signature":"pub timestamp : Option < String >"},{"kind":"field","path":"attribution::Provenance.tool_version","signature":"pub tool_version : Option < String >"},{"kind":"field","path":"attribution::Provenance.usage","signature":"pub usage : Option < UsageRef >"},{"kind":"method","path":"attribution::Provenance::is_empty","signature":"pub fn is_empty ( & self ) -> bool"},{"kind":"method","path":"attribution::Provenance::served","signature":"pub fn served ( & self ) -> Vec < ( & ' static str , String ) >"},{"kind":"method","path":"attribution::Provenance::served_json","signature":"pub fn served_json ( & self ) -> Value"},{"kind":"struct","path":"attribution::ReferenceStatus","signature":"pub struct ReferenceStatus"},{"kind":"field","path":"attribution::ReferenceStatus.endpoints","signature":"pub endpoints : Vec < & ' static str >"},{"kind":"field","path":"attribution::ReferenceStatus.stale","signature":"pub stale : bool"},{"kind":"method","path":"attribution::ReferenceStatus::as_str","signature":"pub fn as_str ( & self ) -> & ' static str"},{"kind":"const","path":"attribution::UNAVAILABLE","signature":"pub const UNAVAILABLE : & str = \" unavailable \" ;"},{"kind":"enum","path":"attribution::UsageRef","signature":"pub enum UsageRef { Envelope ( String ) , Window ( UsageWindow ) }"},{"kind":"method","path":"attribution::UsageRef::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"attribution::UsageRef::rendered","signature":"pub fn rendered ( & self ) -> String"},{"kind":"struct","path":"attribution::UsageWindow","signature":"pub struct UsageWindow"},{"kind":"field","path":"attribution::UsageWindow.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"attribution::UsageWindow.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"attribution::UsageWindow.log_path","signature":"pub log_path : String"},{"kind":"field","path":"attribution::UsageWindow.media_type","signature":"pub media_type : String"},{"kind":"field","path":"attribution::UsageWindow.session","signature":"pub session : String"},{"kind":"const","path":"attribution::WHOLE","signature":"pub const WHOLE : & str = \" whole \" ;"},{"kind":"fn","path":"attribution::code_drifted","signature":"pub fn code_drifted ( committed_blob : Option < & str > , working_bytes : Option < & [ u8 ] > ) -> bool"},{"kind":"fn","path":"attribution::map_contract","signature":"pub fn map_contract ( ) -> Value"},{"kind":"fn","path":"attribution::parse","signature":"pub fn parse ( text : & str ) -> Option < Attribution >"},{"kind":"fn","path":"attribution::parse_detailed","signature":"pub fn parse_detailed ( text : & str ) -> std :: result :: Result < Attribution , MapParseError >"},{"kind":"fn","path":"attribution::record_hash","signature":"pub fn record_hash ( attribution : & Attribution ) -> String"},{"kind":"fn","path":"attribution::reference_status","signature":"pub fn reference_status ( record_stale : bool , prompt_drifted : bool , code_drifted : bool ) -> ReferenceStatus"},{"kind":"fn","path":"attribution::remap_prefix","signature":"pub fn remap_prefix ( attribution : & mut Attribution , old_root : & str , dest : & str )"},{"kind":"fn","path":"attribution::resolve_whole","signature":"pub fn resolve_whole ( attribution : & mut Attribution , prompt_body_lines : u32 , file_lines : & BTreeMap < String , u32 > )"},{"kind":"fn","path":"attribution::to_yaml","signature":"pub fn to_yaml ( attribution : & Attribution ) -> Vec < u8 >"},{"kind":"fn","path":"attribution::validate","signature":"pub fn validate ( target : & TargetDef , root : & str , module : & str , candidate_files : & BTreeMap < String , Vec < u8 > > , prompt_body_lines : u32 , attribution : & Attribution ) -> Result < ( ) >"},{"kind":"fn","path":"attribution::view_path","signature":"pub fn view_path ( module : & str ) -> String"},{"kind":"fn","path":"attribution::whole_map","signature":"pub fn whole_map ( module : & str , target : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Attribution"},{"kind":"fn","path":"authoring::apply_fix","signature":"pub fn apply_fix ( workspace : & Path , target : & str , finding : & Finding ) -> Result < CommitReport >"},{"kind":"fn","path":"authoring::draft_receipt","signature":"pub fn draft_receipt ( workspace : & Path , target_name : & str , module : & str , candidate_dir : & Path , usage_path : Option < & Path > ) -> Result < Value >"},{"kind":"fn","path":"authoring::draft_receipt_for","signature":"pub fn draft_receipt_for ( workspace : & Path , target_name : & str , module : & str , candidate : & crate :: candidate :: Candidate , usage_path : Option < & Path > ) -> Result < Value >"},{"kind":"struct","path":"blame::BlameLine","signature":"pub struct BlameLine"},{"kind":"field","path":"blame::BlameLine.line","signature":"pub line : usize"},{"kind":"field","path":"blame::BlameLine.text","signature":"pub text : String"},{"kind":"field","path":"blame::BlameLine.transition","signature":"pub transition : String"},{"kind":"struct","path":"blame::Revision","signature":"pub struct Revision"},{"kind":"field","path":"blame::Revision.content","signature":"pub content : String"},{"kind":"field","path":"blame::Revision.transition","signature":"pub transition : String"},{"kind":"fn","path":"blame::blame","signature":"pub fn blame ( revisions : & [ Revision ] ) -> Vec < BlameLine >"},{"kind":"fn","path":"blame::blame_json","signature":"pub fn blame_json ( path : & str , lines : & [ BlameLine ] ) -> Value"},{"kind":"fn","path":"blame::untracked_json","signature":"pub fn untracked_json ( path : & str , reason : & str ) -> Value"},{"kind":"struct","path":"candidate::Candidate","signature":"pub struct Candidate"},{"kind":"field","path":"candidate::Candidate.files","signature":"pub files : BTreeMap < String , Vec < u8 > >"},{"kind":"field","path":"candidate::Candidate.manifest","signature":"pub manifest : BTreeMap < String , String >"},{"kind":"field","path":"candidate::Candidate.tree_hash","signature":"pub tree_hash : String"},{"kind":"field","path":"candidate::Candidate.tree_objects","signature":"pub tree_objects : BTreeMap < String , Tree >"},{"kind":"enum","path":"candidate::EntryKind","signature":"pub enum EntryKind { File , Link , Device }"},{"kind":"struct","path":"candidate::Limits","signature":"pub struct Limits"},{"kind":"field","path":"candidate::Limits.max_entries","signature":"pub max_entries : usize"},{"kind":"field","path":"candidate::Limits.max_entry_bytes","signature":"pub max_entry_bytes : usize"},{"kind":"field","path":"candidate::Limits.max_total_bytes","signature":"pub max_total_bytes : usize"},{"kind":"struct","path":"candidate::ScannedEntry","signature":"pub struct ScannedEntry"},{"kind":"field","path":"candidate::ScannedEntry.bytes","signature":"pub bytes : Vec < u8 >"},{"kind":"field","path":"candidate::ScannedEntry.kind","signature":"pub kind : EntryKind"},{"kind":"field","path":"candidate::ScannedEntry.path","signature":"pub path : String"},{"kind":"fn","path":"candidate::default_limits","signature":"pub fn default_limits ( ) -> Limits"},{"kind":"fn","path":"candidate::ingest","signature":"pub fn ingest ( entries : & [ ScannedEntry ] , limits : Limits ) -> Result < Candidate >"},{"kind":"fn","path":"canon::canonical_bytes","signature":"pub fn canonical_bytes ( value : & Value ) -> Vec < u8 >"},{"kind":"fn","path":"canon::hash_json","signature":"pub fn hash_json ( kind : ObjectKind , value : & Value ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"canon::is_object_name","signature":"pub fn is_object_name ( text : & str ) -> bool"},{"kind":"fn","path":"canon::json_hash","signature":"pub fn json_hash ( kind : ObjectKind , value : & Value ) -> String"},{"kind":"fn","path":"canon::parse_json","signature":"pub fn parse_json ( bytes : & [ u8 ] , context : & str , code : OutcomeCode ) -> Result < Value >"},{"kind":"const","path":"capture::SESSION_VARIABLE","signature":"pub const SESSION_VARIABLE : & str = \" NAPL_SESSION \" ;"},{"kind":"const","path":"capture::USAGE_LOG_MEDIA_TYPE","signature":"pub const USAGE_LOG_MEDIA_TYPE : & str = \" application/x-napl-usage-proxy-jsonl-v1 \" ;"},{"kind":"const","path":"capture::USAGE_LOG_PATH","signature":"pub const USAGE_LOG_PATH : & str = \" .napl/usage-proxy.jsonl \" ;"},{"kind":"fn","path":"capture::edit_bytes","signature":"pub fn edit_bytes ( workspace : & Path , path : & str , old : & str , new : & str , replace_all : bool , reference : Option < & str > , session : Option < & str > ) -> Result < CliResult >"},{"kind":"fn","path":"capture::ref_contract","signature":"pub fn ref_contract ( ) -> Value"},{"kind":"fn","path":"capture::write_bytes","signature":"pub fn write_bytes ( workspace : & Path , path : & str , bytes : & [ u8 ] , reference : Option < & str > , session : Option < & str > ) -> Result < CliResult >"},{"kind":"const","path":"cli::BINARY_USAGE","signature":"pub const BINARY_USAGE : & str = \" napl [arguments] \" ;"},{"kind":"struct","path":"cli::CliResult","signature":"pub struct CliResult"},{"kind":"field","path":"cli::CliResult.exit_code","signature":"pub exit_code : i32"},{"kind":"field","path":"cli::CliResult.stdout","signature":"pub stdout : Vec < u8 >"},{"kind":"struct","path":"cli::CommandSpec","signature":"pub struct CommandSpec"},{"kind":"field","path":"cli::CommandSpec.example","signature":"pub example : & ' static str"},{"kind":"field","path":"cli::CommandSpec.flags","signature":"pub flags : & ' static [ FlagSpec ]"},{"kind":"field","path":"cli::CommandSpec.name","signature":"pub name : & ' static str"},{"kind":"field","path":"cli::CommandSpec.passthrough","signature":"pub passthrough : bool"},{"kind":"field","path":"cli::CommandSpec.run","signature":"pub run : fn ( & Path , & [ String ] ) -> Result < CliResult >"},{"kind":"field","path":"cli::CommandSpec.summary","signature":"pub summary : & ' static str"},{"kind":"field","path":"cli::CommandSpec.usage","signature":"pub usage : & ' static str"},{"kind":"enum","path":"cli::FlagArity","signature":"pub enum FlagArity { Switch , Value }"},{"kind":"struct","path":"cli::FlagSpec","signature":"pub struct FlagSpec"},{"kind":"field","path":"cli::FlagSpec.arity","signature":"pub arity : FlagArity"},{"kind":"field","path":"cli::FlagSpec.meaning","signature":"pub meaning : & ' static str"},{"kind":"field","path":"cli::FlagSpec.name","signature":"pub name : & ' static str"},{"kind":"field","path":"cli::FlagSpec.short","signature":"pub short : Option < & ' static str >"},{"kind":"const","path":"cli::HELP_FLAGS","signature":"pub const HELP_FLAGS : [ & str ; 2 ] = [ \" --help \" , \" -h \" ] ;"},{"kind":"const","path":"cli::HUMAN_FLAG","signature":"pub const HUMAN_FLAG : & str = \" --human \" ;"},{"kind":"const","path":"cli::JSON_FLAG","signature":"pub const JSON_FLAG : & str = \" --json \" ;"},{"kind":"const","path":"cli::USAGE_EXIT_CODE","signature":"pub const USAGE_EXIT_CODE : i32 = 2 ;"},{"kind":"const","path":"cli::WORKSPACE_FLAG","signature":"pub const WORKSPACE_FLAG : FlagSpec = value ( \" --workspace \" , \" the workspace root to operate on, instead of discovering it from the current directory \" ) ;"},{"kind":"fn","path":"cli::clap_command_for","signature":"pub fn clap_command_for ( spec : & CommandSpec ) -> clap :: Command"},{"kind":"fn","path":"cli::clap_root","signature":"pub fn clap_root ( registry : & [ CommandSpec ] ) -> clap :: Command"},{"kind":"fn","path":"cli::emit_prerequisite","signature":"pub fn emit_prerequisite ( command : & str ) -> CliResult"},{"kind":"fn","path":"cli::help_all_json","signature":"pub fn help_all_json ( registry : & [ CommandSpec ] ) -> Value"},{"kind":"fn","path":"cli::help_command_json","signature":"pub fn help_command_json ( spec : & CommandSpec ) -> Value"},{"kind":"fn","path":"cli::prerequisite_json","signature":"pub fn prerequisite_json ( command : & str ) -> Value"},{"kind":"fn","path":"cli::render_help_all","signature":"pub fn render_help_all ( registry : & [ CommandSpec ] ) -> String"},{"kind":"fn","path":"cli::render_help_command","signature":"pub fn render_help_command ( spec : & CommandSpec ) -> String"},{"kind":"fn","path":"cli::run_cli","signature":"pub fn run_cli ( cwd : & Path , args : & [ String ] ) -> CliResult"},{"kind":"fn","path":"cli::switch","signature":"pub const fn switch ( name : & ' static str , meaning : & ' static str ) -> FlagSpec"},{"kind":"fn","path":"cli::switch_short","signature":"pub const fn switch_short ( name : & ' static str , short : & ' static str , meaning : & ' static str ) -> FlagSpec"},{"kind":"fn","path":"cli::usage_json","signature":"pub fn usage_json ( spec : Option < & CommandSpec > , detail : & str , unknown : Option < & str > , suggestion : Option < & str > ) -> Value"},{"kind":"fn","path":"cli::value","signature":"pub const fn value ( name : & ' static str , meaning : & ' static str ) -> FlagSpec"},{"kind":"const","path":"commandset::COMPLETION_SHELLS","signature":"pub const COMPLETION_SHELLS : [ & str ; 3 ] = [ \" bash \" , \" zsh \" , \" fish \" ] ;"},{"kind":"fn","path":"commandset::commands","signature":"pub fn commands ( ) -> & ' static [ CommandSpec ]"},{"kind":"enum","path":"commit::AttributionMode","signature":"pub enum AttributionMode { Prove , PreProven , Escape }"},{"kind":"struct","path":"commit::CommitReport","signature":"pub struct CommitReport"},{"kind":"field","path":"commit::CommitReport.outcome","signature":"pub outcome : OutcomeCode"},{"kind":"field","path":"commit::CommitReport.payload","signature":"pub payload : Value"},{"kind":"fn","path":"commit::commit","signature":"pub fn commit ( workspace : & Path , receipt_path : & Path , candidate_dir : & Path , attribution_path : Option < & Path > , no_verify : bool ) -> CommitReport"},{"kind":"fn","path":"commit::commit_with_mode","signature":"pub fn commit_with_mode ( workspace : & Path , receipt_path : & Path , candidate_dir : & Path , attribution_path : Option < & Path > , mode : AttributionMode ) -> CommitReport"},{"kind":"fn","path":"commit::lock_key","signature":"pub fn lock_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"commit::recover","signature":"pub fn recover ( store : & Store ) -> Result < Vec < String > >"},{"kind":"struct","path":"compile::CompileArtifact","signature":"pub struct CompileArtifact"},{"kind":"field","path":"compile::CompileArtifact.artifact_hash","signature":"pub artifact_hash : String"},{"kind":"field","path":"compile::CompileArtifact.slices","signature":"pub slices : Vec < CompileSlice >"},{"kind":"field","path":"compile::CompileArtifact.value","signature":"pub value : Value"},{"kind":"fn","path":"compile::base_output_files","signature":"pub fn base_output_files ( source : & dyn ObjectSource , state : & ModuleState ) -> Result < Vec < ( String , String ) > >"},{"kind":"fn","path":"compile::compile","signature":"pub fn compile ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , only_module : Option < & str > ) -> Result < CompileArtifact >"},{"kind":"fn","path":"compile::graph_for","signature":"pub fn graph_for ( sources : & [ SourceModule ] , target_name : & str ) -> Result < Graph >"},{"kind":"fn","path":"compile::head_states","signature":"pub fn head_states ( source : & dyn ObjectSource , snapshot : Option < & str > ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"compile::read_surface_items","signature":"pub fn read_surface_items ( source : & dyn ObjectSource , hash : & str ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"compile::slice_at","signature":"pub fn slice_at ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , module : & str , snapshot : Option < & str > ) -> Result < CompileSlice >"},{"kind":"fn","path":"compile::state_of","signature":"pub fn state_of ( source : & dyn ObjectSource , states : & BTreeMap < ( String , String ) , String > , target : & TargetDef , module : & str ) -> Result < ( ModuleState , String ) >"},{"kind":"fn","path":"compile::workspace_source_hash","signature":"pub fn workspace_source_hash ( sources : & [ SourceModule ] ) -> Result < String >"},{"kind":"struct","path":"cost::CostLine","signature":"pub struct CostLine"},{"kind":"field","path":"cost::CostLine.cost_usd","signature":"pub cost_usd : Option < String >"},{"kind":"field","path":"cost::CostLine.model","signature":"pub model : Option < String >"},{"kind":"field","path":"cost::CostLine.module","signature":"pub module : String"},{"kind":"field","path":"cost::CostLine.reason","signature":"pub reason : Option < String >"},{"kind":"field","path":"cost::CostLine.receipt","signature":"pub receipt : String"},{"kind":"field","path":"cost::CostLine.tokens","signature":"pub tokens : Option < TokenUsage >"},{"kind":"const","path":"cost::UNKNOWN","signature":"pub const UNKNOWN : & str = \" unknown \" ;"},{"kind":"struct","path":"cost::WindowRef","signature":"pub struct WindowRef"},{"kind":"field","path":"cost::WindowRef.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"cost::WindowRef.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"cost::WindowRef.log_path","signature":"pub log_path : String"},{"kind":"field","path":"cost::WindowRef.media_type","signature":"pub media_type : String"},{"kind":"field","path":"cost::WindowRef.session","signature":"pub session : String"},{"kind":"fn","path":"cost::cost_report","signature":"pub fn cost_report ( lines : & [ CostLine ] ) -> Value"},{"kind":"fn","path":"cost::line_from_reported","signature":"pub fn line_from_reported ( receipt : & str , module : & str , reported : & Value ) -> Result < CostLine >"},{"kind":"fn","path":"cost::line_from_window","signature":"pub fn line_from_window ( receipt : & str , module : & str , window : & WindowRef , log : Option < & str > ) -> CostLine"},{"kind":"fn","path":"cost::lines_from_session","signature":"pub fn lines_from_session ( log : & str , session : & str ) -> Vec < CostLine >"},{"kind":"fn","path":"cost::pricing_provenance","signature":"pub fn pricing_provenance ( ) -> Value"},{"kind":"fn","path":"cost::window_from_reported","signature":"pub fn window_from_reported ( reported : & Value ) -> Option < WindowRef >"},{"kind":"struct","path":"decision::Arriving","signature":"pub struct Arriving"},{"kind":"field","path":"decision::Arriving.base_snapshot","signature":"pub base_snapshot : Option < String >"},{"kind":"field","path":"decision::Arriving.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Arriving.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Arriving.write_set","signature":"pub write_set : BTreeSet < String >"},{"kind":"enum","path":"decision::Decision","signature":"pub enum Decision { Install , Rebase , StaleInput { expected : String , found : String } , BaseOutputChanged { expected : String , found : String } , WriteConflict { overlap : Vec < String > } }"},{"kind":"struct","path":"decision::Head","signature":"pub struct Head"},{"kind":"field","path":"decision::Head.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Head.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Head.intervening_write_set","signature":"pub intervening_write_set : BTreeSet < String >"},{"kind":"field","path":"decision::Head.snapshot","signature":"pub snapshot : Option < String >"},{"kind":"fn","path":"decision::conflicts","signature":"pub fn conflicts ( write_set_a : & BTreeSet < String > , read_set_a : & BTreeSet < String > , write_set_b : & BTreeSet < String > , changed_identities_b : & BTreeSet < String > ) -> bool"},{"kind":"fn","path":"decision::decide","signature":"pub fn decide ( arriving : & Arriving , head : & Head ) -> Decision"},{"kind":"fn","path":"decision::is_accepted","signature":"pub fn is_accepted ( decision : & Decision ) -> bool"},{"kind":"fn","path":"decision::rejection","signature":"pub fn rejection ( decision : & Decision ) -> Option < CompilerError >"},{"kind":"struct","path":"directive::DirectiveCase","signature":"pub struct DirectiveCase"},{"kind":"field","path":"directive::DirectiveCase.expect","signature":"pub expect : Value"},{"kind":"field","path":"directive::DirectiveCase.given","signature":"pub given : Value"},{"kind":"field","path":"directive::DirectiveCase.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"directive::DirectiveCase.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"directive::DirectiveCase.name","signature":"pub name : String"},{"kind":"fn","path":"directive::extract_cases","signature":"pub fn extract_cases ( normalized_body : & str ) -> Result < Vec < DirectiveCase > >"},{"kind":"fn","path":"directive::extract_validated","signature":"pub fn extract_validated ( normalized_body : & str , frontmatter_names : & [ String ] ) -> Result < Vec < DirectiveCase > >"},{"kind":"fn","path":"directive::normalize_case_name","signature":"pub fn normalize_case_name ( name : & str ) -> String"},{"kind":"fn","path":"directive::parse_inline_value","signature":"pub fn parse_inline_value ( text : & str ) -> Option < Value >"},{"kind":"const","path":"disk::POINTER_LOCK","signature":"pub const POINTER_LOCK : & str = \" pointer \" ;"},{"kind":"const","path":"disk::STORE_DIR","signature":"pub const STORE_DIR : & str = \" .napl/store \" ;"},{"kind":"struct","path":"disk::Store","signature":"pub struct Store"},{"kind":"field","path":"disk::Store.root","signature":"pub root : PathBuf"},{"kind":"field","path":"disk::Store.workspace","signature":"pub workspace : PathBuf"},{"kind":"fn","path":"disk::compare_and_set_head","signature":"pub fn compare_and_set_head ( store : & Store , expected : Option < & str > , next : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::delete_marker","signature":"pub fn delete_marker ( store : & Store , snapshot : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::init","signature":"pub fn init ( workspace : & Path ) -> Result < Store >"},{"kind":"fn","path":"disk::io","signature":"pub fn io ( path : & Path , err : & std :: io :: Error ) -> CompilerError"},{"kind":"fn","path":"disk::marker_snapshots","signature":"pub fn marker_snapshots ( store : & Store ) -> Result < Vec < String > >"},{"kind":"fn","path":"disk::open","signature":"pub fn open ( workspace : & Path ) -> Store"},{"kind":"fn","path":"disk::publish","signature":"pub fn publish ( store : & Store , kind : ObjectKind , payload : & [ u8 ] , owner : & str ) -> Result < String >"},{"kind":"fn","path":"disk::read_marker","signature":"pub fn read_marker ( store : & Store , snapshot : & str ) -> Result < Option < CommitMarker > >"},{"kind":"fn","path":"disk::release_lock","signature":"pub fn release_lock ( guard : LockGuard ) -> Result < ( ) >"},{"kind":"fn","path":"disk::remove_object","signature":"pub fn remove_object ( store : & Store , name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::take_lock","signature":"pub fn take_lock ( store : & Store , name : & str ) -> Result < LockGuard >"},{"kind":"fn","path":"disk::write_marker","signature":"pub fn write_marker ( store : & Store , marker : & CommitMarker ) -> Result < ( ) >"},{"kind":"struct","path":"error::CompilerError","signature":"pub struct CompilerError"},{"kind":"field","path":"error::CompilerError.code","signature":"pub code : OutcomeCode"},{"kind":"field","path":"error::CompilerError.data","signature":"pub data : Option < Value >"},{"kind":"field","path":"error::CompilerError.detail","signature":"pub detail : String"},{"kind":"enum","path":"error::OutcomeCode","signature":"pub enum OutcomeCode { Success , Usage , StaleInput , BaseOutputChanged , WriteConflict , LockContended , InvalidCandidate , InvalidReceipt , MappingIntegrity , ResourceLimit , UnsupportedVersion , StoreIntegrity , IoError , EditMismatch , ReferenceUnresolvable , PromptInvalid }"},{"kind":"type","path":"error::Result","signature":"pub type Result < T > = std :: result :: Result < T , CompilerError > ;"},{"kind":"fn","path":"error::error","signature":"pub fn error ( code : OutcomeCode , detail : impl Into < String > ) -> CompilerError"},{"kind":"fn","path":"error::error_payload","signature":"pub fn error_payload ( err : & CompilerError , blobs_published : bool , provenance_published : bool ) -> Value"},{"kind":"fn","path":"error::error_with","signature":"pub fn error_with ( code : OutcomeCode , detail : impl Into < String > , data : Value ) -> CompilerError"},{"kind":"fn","path":"error::outcome_exit_code","signature":"pub fn outcome_exit_code ( code : OutcomeCode ) -> i32"},{"kind":"fn","path":"error::outcome_is_retryable","signature":"pub fn outcome_is_retryable ( code : OutcomeCode ) -> bool"},{"kind":"fn","path":"error::outcome_slug","signature":"pub fn outcome_slug ( code : OutcomeCode ) -> & ' static str"},{"kind":"const","path":"evidence::CLAUDE_CLI_JSON","signature":"pub const CLAUDE_CLI_JSON : & str = \" application/vnd.claude-cli+json \" ;"},{"kind":"struct","path":"evidence::EvidenceItem","signature":"pub struct EvidenceItem"},{"kind":"field","path":"evidence::EvidenceItem.body","signature":"pub body : String"},{"kind":"field","path":"evidence::EvidenceItem.media_type","signature":"pub media_type : String"},{"kind":"enum","path":"evidence::EvidenceOutcome","signature":"pub enum EvidenceOutcome { Validated , Unsupported , Invalid , ValidatorError }"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEMS","signature":"pub const MAX_EVIDENCE_ITEMS : usize = 8 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEM_BYTES","signature":"pub const MAX_EVIDENCE_ITEM_BYTES : usize = 64 * 1024 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_TOTAL_BYTES","signature":"pub const MAX_EVIDENCE_TOTAL_BYTES : usize = 256 * 1024 ;"},{"kind":"const","path":"evidence::USAGE_PROXY_JSONL","signature":"pub const USAGE_PROXY_JSONL : & str = \" application/x-napl-usage-proxy-jsonl-v1 \" ;"},{"kind":"fn","path":"evidence::check_evidence_bounds","signature":"pub fn check_evidence_bounds ( items : & [ EvidenceItem ] ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::check_reported_bounds","signature":"pub fn check_reported_bounds ( reported : & Value , limit : usize ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::evidence_report","signature":"pub fn evidence_report ( items : & [ ( EvidenceItem , EvidenceOutcome ) ] ) -> Value"},{"kind":"fn","path":"evidence::evidence_slug","signature":"pub fn evidence_slug ( outcome : EvidenceOutcome ) -> & ' static str"},{"kind":"fn","path":"evidence::parse_evidence","signature":"pub fn parse_evidence ( reported : & Value ) -> Result < Vec < EvidenceItem > >"},{"kind":"fn","path":"evidence::registered_media_types","signature":"pub fn registered_media_types ( ) -> Vec < String >"},{"kind":"fn","path":"evidence::validate_all","signature":"pub fn validate_all ( items : & [ EvidenceItem ] ) -> Vec < ( EvidenceItem , EvidenceOutcome ) >"},{"kind":"fn","path":"evidence::validate_evidence","signature":"pub fn validate_evidence ( item : & EvidenceItem ) -> EvidenceOutcome"},{"kind":"type","path":"graph::Graph","signature":"pub type Graph = BTreeMap < String , SourceModule > ;"},{"kind":"fn","path":"graph::build_graph","signature":"pub fn build_graph ( sources : & [ SourceModule ] , target : & TargetDef ) -> Result < Graph >"},{"kind":"fn","path":"graph::changed_frontier","signature":"pub fn changed_frontier ( graph : & Graph , committed_input_keys : & BTreeMap < String , Option < String > > , input_keys : & BTreeMap < String , String > , only_module : Option < & str > ) -> Result < Vec < String > >"},{"kind":"fn","path":"graph::dependents_of","signature":"pub fn dependents_of ( graph : & Graph , module : & str ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::index_sources","signature":"pub fn index_sources ( sources : & [ SourceModule ] ) -> Result < BTreeMap < String , SourceModule > >"},{"kind":"fn","path":"graph::reachable_from","signature":"pub fn reachable_from ( graph : & Graph , roots : & [ String ] ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::topological_levels","signature":"pub fn topological_levels ( graph : & Graph ) -> Result < BTreeMap < String , usize > >"},{"kind":"enum","path":"lint::Analysis","signature":"pub enum Analysis { Performed , Refused }"},{"kind":"enum","path":"lint::Finding","signature":"pub enum Finding { UnreachableModule { module : String , cascade : Vec < String > } , UnconsumedSurfaceEntry { module : String , path : String } , StaleModule { module : String } , UnusedDependency { module : String , dep : String } }"},{"kind":"const","path":"lint::GRAPH_SCOPE","signature":"pub const GRAPH_SCOPE : & str = \" graph \" ;"},{"kind":"struct","path":"lint::LintInput","signature":"pub struct LintInput"},{"kind":"field","path":"lint::LintInput.committed_input_keys","signature":"pub committed_input_keys : BTreeMap < String , Option < String > >"},{"kind":"field","path":"lint::LintInput.current_input_keys","signature":"pub current_input_keys : BTreeMap < String , String >"},{"kind":"field","path":"lint::LintInput.referenced_names","signature":"pub referenced_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"field","path":"lint::LintInput.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"lint::LintInput.surface_names","signature":"pub surface_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"struct","path":"lint::LintReport","signature":"pub struct LintReport"},{"kind":"field","path":"lint::LintReport.findings","signature":"pub findings : Vec < Finding >"},{"kind":"field","path":"lint::LintReport.unreachable_analysis","signature":"pub unreachable_analysis : Analysis"},{"kind":"const","path":"lint::ROOTS_DECLARATION","signature":"pub const ROOTS_DECLARATION : & str = \" roots: [, ...] in the frontmatter of a prompt in this workspace \" ;"},{"kind":"fn","path":"lint::finding_json","signature":"pub fn finding_json ( finding : & Finding ) -> Value"},{"kind":"fn","path":"lint::is_auto_fixable","signature":"pub fn is_auto_fixable ( finding : & Finding ) -> bool"},{"kind":"fn","path":"lint::lint","signature":"pub fn lint ( graph : & Graph , input : & LintInput ) -> LintReport"},{"kind":"fn","path":"lint::lint_json","signature":"pub fn lint_json ( report : & LintReport ) -> Value"},{"kind":"fn","path":"lint::refusal_json","signature":"pub fn refusal_json ( source_scan : Value ) -> Value"},{"kind":"fn","path":"lsp::serve","signature":"pub fn serve < R : BufRead , W : Write > ( reader : & mut R , writer : & mut W )"},{"kind":"struct","path":"maintenance::FileFact","signature":"pub struct FileFact"},{"kind":"field","path":"maintenance::FileFact.hash","signature":"pub hash : String"},{"kind":"field","path":"maintenance::FileFact.path","signature":"pub path : String"},{"kind":"enum","path":"maintenance::FsckFinding","signature":"pub enum FsckFinding { HashMismatch { object : String , detail : String } , MissingParent { event : String , parent : String } , MissingObject { from : String , missing : String } }"},{"kind":"enum","path":"maintenance::RestorePlan","signature":"pub enum RestorePlan { Apply ( Vec < RestoreStep > ) , Blocked { path : String , candidates : Vec < String > } }"},{"kind":"enum","path":"maintenance::RestoreStep","signature":"pub enum RestoreStep { Move { from : String , to : String } , Write { path : String , hash : String } , Remove { path : String } }"},{"kind":"fn","path":"maintenance::fsck","signature":"pub fn fsck ( source : & dyn ObjectSource ) -> Result < Vec < FsckFinding > >"},{"kind":"fn","path":"maintenance::fsck_json","signature":"pub fn fsck_json ( findings : & [ FsckFinding ] ) -> Value"},{"kind":"fn","path":"maintenance::gc_json","signature":"pub fn gc_json ( swept : & [ String ] , kept : usize ) -> Value"},{"kind":"fn","path":"maintenance::plan_restore","signature":"pub fn plan_restore ( expected : & [ FileFact ] , observed : & [ FileFact ] ) -> RestorePlan"},{"kind":"fn","path":"maintenance::plan_sweep","signature":"pub fn plan_sweep ( all : & BTreeSet < String > , reachable : & BTreeSet < String > ) -> Vec < String >"},{"kind":"fn","path":"maintenance::reachable_from_head","signature":"pub fn reachable_from_head ( source : & dyn ObjectSource , head : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"maintenance::restore_json","signature":"pub fn restore_json ( plan : & RestorePlan ) -> Value"},{"kind":"fn","path":"mv::relative_dir","signature":"pub fn relative_dir ( from : & str , to : & str ) -> String"},{"kind":"fn","path":"mv::relocate_files","signature":"pub fn relocate_files ( files : & BTreeMap < String , Vec < u8 > > , old_root : & str , dest : & str ) -> Result < BTreeMap < String , Vec < u8 > > >"},{"kind":"fn","path":"mv::relocate_module","signature":"pub fn relocate_module ( workspace : & Path , target_name : & str , module : & str , dest : & str ) -> CommitReport"},{"kind":"struct","path":"objects::AttributionState","signature":"pub struct AttributionState"},{"kind":"field","path":"objects::AttributionState.prompt_hash","signature":"pub prompt_hash : String"},{"kind":"field","path":"objects::AttributionState.record","signature":"pub record : String"},{"kind":"field","path":"objects::AttributionState.stale","signature":"pub stale : bool"},{"kind":"struct","path":"objects::Footprint","signature":"pub struct Footprint"},{"kind":"field","path":"objects::Footprint.derived_projections","signature":"pub derived_projections : Vec < String >"},{"kind":"field","path":"objects::Footprint.install_paths","signature":"pub install_paths : Vec < String >"},{"kind":"field","path":"objects::Footprint.read_set","signature":"pub read_set : Vec < String >"},{"kind":"field","path":"objects::Footprint.write_set","signature":"pub write_set : Vec < String >"},{"kind":"struct","path":"objects::ModuleState","signature":"pub struct ModuleState"},{"kind":"field","path":"objects::ModuleState.attribution","signature":"pub attribution : Option < AttributionState >"},{"kind":"field","path":"objects::ModuleState.input_key","signature":"pub input_key : Option < String >"},{"kind":"field","path":"objects::ModuleState.module","signature":"pub module : String"},{"kind":"field","path":"objects::ModuleState.output_tree","signature":"pub output_tree : String"},{"kind":"field","path":"objects::ModuleState.slice_hash","signature":"pub slice_hash : Option < String >"},{"kind":"field","path":"objects::ModuleState.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"objects::ModuleState.target","signature":"pub target : String"},{"kind":"struct","path":"objects::Transition","signature":"pub struct Transition"},{"kind":"field","path":"objects::Transition.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"objects::Transition.footprint","signature":"pub footprint : Footprint"},{"kind":"field","path":"objects::Transition.input_key","signature":"pub input_key : String"},{"kind":"field","path":"objects::Transition.module","signature":"pub module : String"},{"kind":"field","path":"objects::Transition.new_state","signature":"pub new_state : String"},{"kind":"field","path":"objects::Transition.receipt","signature":"pub receipt : String"},{"kind":"field","path":"objects::Transition.surface","signature":"pub surface : String"},{"kind":"field","path":"objects::Transition.target","signature":"pub target : String"},{"kind":"fn","path":"objects::empty_module_state","signature":"pub fn empty_module_state ( module : & str , target : & str , empty_tree : & str ) -> ModuleState"},{"kind":"fn","path":"objects::event_hash","signature":"pub fn event_hash ( content : & str , parents : & [ String ] ) -> String"},{"kind":"fn","path":"objects::event_json","signature":"pub fn event_json ( content : & str , parents : & [ String ] ) -> Value"},{"kind":"fn","path":"objects::event_transition","signature":"pub fn event_transition ( source : & dyn ObjectSource , event : & str ) -> Result < String >"},{"kind":"fn","path":"objects::module_state_hash","signature":"pub fn module_state_hash ( state : & ModuleState ) -> String"},{"kind":"fn","path":"objects::module_state_json","signature":"pub fn module_state_json ( state : & ModuleState ) -> Value"},{"kind":"fn","path":"objects::ownership_write_key","signature":"pub fn ownership_write_key ( root : & str ) -> String"},{"kind":"fn","path":"objects::read_module_state","signature":"pub fn read_module_state ( source : & dyn ObjectSource , hash : & str ) -> Result < ModuleState >"},{"kind":"fn","path":"objects::snapshot_event","signature":"pub fn snapshot_event ( source : & dyn ObjectSource , snapshot : & str ) -> Result < String >"},{"kind":"fn","path":"objects::snapshot_hash","signature":"pub fn snapshot_hash ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> String"},{"kind":"fn","path":"objects::snapshot_json","signature":"pub fn snapshot_json ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> Value"},{"kind":"fn","path":"objects::snapshot_members","signature":"pub fn snapshot_members ( states : & BTreeMap < ( String , String ) , String > , target : & str ) -> Vec < String >"},{"kind":"fn","path":"objects::snapshot_states","signature":"pub fn snapshot_states ( source : & dyn ObjectSource , snapshot : & str ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"objects::state_write_key","signature":"pub fn state_write_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"objects::transition_hash","signature":"pub fn transition_hash ( transition : & Transition ) -> String"},{"kind":"fn","path":"objects::transition_json","signature":"pub fn transition_json ( transition : & Transition ) -> Value"},{"kind":"fn","path":"objects::transition_write_set","signature":"pub fn transition_write_set ( source : & dyn ObjectSource , transition : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"objects::walk_events","signature":"pub fn walk_events ( source : & dyn ObjectSource , event : & str , until : Option < & str > ) -> Result < Vec < String > >"},{"kind":"const","path":"paths::MAX_PATH_LEN","signature":"pub const MAX_PATH_LEN : usize = 1024 ;"},{"kind":"const","path":"paths::MAX_SEGMENT_LEN","signature":"pub const MAX_SEGMENT_LEN : usize = 128 ;"},{"kind":"fn","path":"paths::check_canonical_path","signature":"pub fn check_canonical_path ( path : & str , code : OutcomeCode ) -> Result < ( ) >"},{"kind":"fn","path":"paths::decode_name","signature":"pub fn decode_name ( segment : & str ) -> Option < String >"},{"kind":"fn","path":"paths::encode_name","signature":"pub fn encode_name ( name : & str ) -> String"},{"kind":"fn","path":"paths::is_under","signature":"pub fn is_under ( path : & str , prefix : & str ) -> bool"},{"kind":"fn","path":"paths::join_root","signature":"pub fn join_root ( root : & str , rest : & str ) -> String"},{"kind":"fn","path":"paths::normalize_path_key","signature":"pub fn normalize_path_key ( path : & str ) -> String"},{"kind":"const","path":"porcelain::PROJECTION_OWNER","signature":"pub const PROJECTION_OWNER : & str = \" no module: this path is recomputed by commit from the crate's committed module file sets and is never user-supplied; a candidate that carries it is rejected as invalid-candidate \" ;"},{"kind":"const","path":"porcelain::PROJECTION_OWNERSHIP","signature":"pub const PROJECTION_OWNERSHIP : & str = \" tool-derived \" ;"},{"kind":"fn","path":"porcelain::add_note","signature":"pub fn add_note ( workspace : & Path , transition : & str , note : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::blame_path","signature":"pub fn blame_path ( workspace : & Path , path : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::cat_file","signature":"pub fn cat_file ( workspace : & Path , hash : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"porcelain::causal_order","signature":"pub fn causal_order ( store : & Store , head : & str ) -> Result < Vec < String > >"},{"kind":"fn","path":"porcelain::committed_attribution_yaml","signature":"pub fn committed_attribution_yaml ( store : & Store , target : & str , module : & str ) -> Result < Option < Vec < u8 > > >"},{"kind":"fn","path":"porcelain::committed_manifest","signature":"pub fn committed_manifest ( store : & Store , target : & str ) -> Result < BTreeMap < String , String > >"},{"kind":"fn","path":"porcelain::committed_state","signature":"pub fn committed_state ( store : & Store , target : & str , module : & str ) -> Result < Option < ModuleState > >"},{"kind":"fn","path":"porcelain::cost","signature":"pub fn cost ( workspace : & Path , names : & [ String ] , files : & [ String ] , sessions : & [ String ] ) -> Result < Value >"},{"kind":"fn","path":"porcelain::diff","signature":"pub fn diff ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::first_output","signature":"pub fn first_output ( target : & TargetDef , root : & str , module : & str ) -> Value"},{"kind":"fn","path":"porcelain::fsck_store","signature":"pub fn fsck_store ( workspace : & Path ) -> Result < ( bool , Value ) >"},{"kind":"fn","path":"porcelain::gc","signature":"pub fn gc ( workspace : & Path , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::has_committed_head","signature":"pub fn has_committed_head ( workspace : & Path ) -> Result < bool >"},{"kind":"fn","path":"porcelain::lint_workspace","signature":"pub fn lint_workspace ( workspace : & Path , target : & str ) -> Result < ( Graph , LintReport , Value ) >"},{"kind":"fn","path":"porcelain::log","signature":"pub fn log ( workspace : & Path , module : Option < & str > ) -> Result < Value >"},{"kind":"fn","path":"porcelain::module_plan","signature":"pub fn module_plan ( workspace : & Path , target_name : & str , module : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::projection_labels","signature":"pub fn projection_labels ( target : & TargetDef , root : & str ) -> Vec < Value >"},{"kind":"fn","path":"porcelain::restore","signature":"pub fn restore ( workspace : & Path , target : & str , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::show","signature":"pub fn show ( workspace : & Path , hash : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::source_syntax_scan","signature":"pub fn source_syntax_scan ( workspace : & Path , target_name : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::status","signature":"pub fn status ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::surface_of","signature":"pub fn surface_of ( workspace : & Path , target : & str , module : & str ) -> Result < Value >"},{"kind":"const","path":"receipt::MAX_REPORTED_BYTES","signature":"pub const MAX_REPORTED_BYTES : usize = 512 * 1024 ;"},{"kind":"const","path":"receipt::RECEIPT_VERSION","signature":"pub const RECEIPT_VERSION : u32 = 1 ;"},{"kind":"struct","path":"receipt::Receipt","signature":"pub struct Receipt"},{"kind":"field","path":"receipt::Receipt.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"receipt::Receipt.base_store_snapshot","signature":"pub base_store_snapshot : Option < String >"},{"kind":"field","path":"receipt::Receipt.base_workspace_hash","signature":"pub base_workspace_hash : String"},{"kind":"field","path":"receipt::Receipt.candidate_tree_hash","signature":"pub candidate_tree_hash : String"},{"kind":"field","path":"receipt::Receipt.evidence","signature":"pub evidence : Vec < EvidenceItem >"},{"kind":"field","path":"receipt::Receipt.input_key","signature":"pub input_key : String"},{"kind":"field","path":"receipt::Receipt.module","signature":"pub module : String"},{"kind":"field","path":"receipt::Receipt.output_manifest","signature":"pub output_manifest : BTreeMap < String , String >"},{"kind":"field","path":"receipt::Receipt.receipt_version","signature":"pub receipt_version : u32"},{"kind":"field","path":"receipt::Receipt.reported","signature":"pub reported : Value"},{"kind":"field","path":"receipt::Receipt.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"receipt::Receipt.target","signature":"pub target : String"},{"kind":"const","path":"receipt::UNAVAILABLE","signature":"pub const UNAVAILABLE : & str = \" unavailable \" ;"},{"kind":"fn","path":"receipt::normalize_reported","signature":"pub fn normalize_reported ( value : & Value ) -> Value"},{"kind":"fn","path":"receipt::parse_receipt","signature":"pub fn parse_receipt ( bytes : & [ u8 ] ) -> Result < Receipt >"},{"kind":"fn","path":"receipt::receipt_hash","signature":"pub fn receipt_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_json","signature":"pub fn receipt_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::receipt_semantic_hash","signature":"pub fn receipt_semantic_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_semantic_json","signature":"pub fn receipt_semantic_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::reported_usage","signature":"pub fn reported_usage ( receipt : & Receipt ) -> Value"},{"kind":"struct","path":"slice::CompileSlice","signature":"pub struct CompileSlice"},{"kind":"field","path":"slice::CompileSlice.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"slice::CompileSlice.body","signature":"pub body : Value"},{"kind":"field","path":"slice::CompileSlice.input_key","signature":"pub input_key : String"},{"kind":"field","path":"slice::CompileSlice.module","signature":"pub module : String"},{"kind":"field","path":"slice::CompileSlice.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"slice::CompileSlice.target","signature":"pub target : String"},{"kind":"field","path":"slice::CompileSlice.wave","signature":"pub wave : usize"},{"kind":"struct","path":"slice::DepSurface","signature":"pub struct DepSurface"},{"kind":"field","path":"slice::DepSurface.items","signature":"pub items : Vec < SurfaceItem >"},{"kind":"field","path":"slice::DepSurface.module","signature":"pub module : String"},{"kind":"field","path":"slice::DepSurface.surface_hash","signature":"pub surface_hash : String"},{"kind":"fn","path":"slice::build_slice","signature":"pub fn build_slice ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] , base : & ModuleState , base_state_key : & str , base_files : & [ ( String , String ) ] , wave : usize ) -> CompileSlice"},{"kind":"fn","path":"slice::closure_manifest","signature":"pub fn closure_manifest ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> Value"},{"kind":"fn","path":"slice::input_key","signature":"pub fn input_key ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> String"},{"kind":"fn","path":"slice::receipt_requirements","signature":"pub fn receipt_requirements ( ) -> Value"},{"kind":"fn","path":"slice::recompute_slice_hash","signature":"pub fn recompute_slice_hash ( body : & Value ) -> String"},{"kind":"const","path":"source::MAX_SOURCE_BYTES","signature":"pub const MAX_SOURCE_BYTES : usize = 4 * 1024 * 1024 ;"},{"kind":"const","path":"source::MINIMAL_PROMPT","signature":"pub const MINIMAL_PROMPT : & str = \" --- \\n module: example \\n deps: [] \\n roots: [example] \\n targets: [rust] \\n --- \\n One sentence of specification prose. \\n \" ;"},{"kind":"const","path":"source::SOURCE_EXTENSION","signature":"pub const SOURCE_EXTENSION : & str = \" napl \" ;"},{"kind":"struct","path":"source::SourceModule","signature":"pub struct SourceModule"},{"kind":"field","path":"source::SourceModule.deps","signature":"pub deps : Vec < String >"},{"kind":"field","path":"source::SourceModule.directive_cases","signature":"pub directive_cases : Vec < crate :: directive :: DirectiveCase >"},{"kind":"field","path":"source::SourceModule.frontmatter_cases","signature":"pub frontmatter_cases : Vec < serde_json :: Value >"},{"kind":"field","path":"source::SourceModule.module","signature":"pub module : String"},{"kind":"field","path":"source::SourceModule.normalized_body","signature":"pub normalized_body : String"},{"kind":"field","path":"source::SourceModule.normalized_source_hash","signature":"pub normalized_source_hash : String"},{"kind":"field","path":"source::SourceModule.path","signature":"pub path : String"},{"kind":"field","path":"source::SourceModule.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"source::SourceModule.targets","signature":"pub targets : Vec < String >"},{"kind":"field","path":"source::SourceModule.tests","signature":"pub tests : Vec < String >"},{"kind":"fn","path":"source::check_module_name","signature":"pub fn check_module_name ( name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"source::normalize_body","signature":"pub fn normalize_body ( raw : & str ) -> String"},{"kind":"fn","path":"source::parse_source","signature":"pub fn parse_source ( path : & str , text : & str ) -> Result < SourceModule >"},{"kind":"fn","path":"source::rewrite_deps","signature":"pub fn rewrite_deps ( text : & str , deps : & [ String ] ) -> Result < String >"},{"kind":"enum","path":"staging::AddSource","signature":"pub enum AddSource { CandidateDir ( PathBuf ) , WorkingTree , Overlay ( Vec < String > ) }"},{"kind":"const","path":"staging::STAGING_DIR","signature":"pub const STAGING_DIR : & str = \" .napl/staging \" ;"},{"kind":"struct","path":"staging::StagedRecord","signature":"pub struct StagedRecord"},{"kind":"field","path":"staging::StagedRecord.module","signature":"pub module : String"},{"kind":"field","path":"staging::StagedRecord.path","signature":"pub path : PathBuf"},{"kind":"field","path":"staging::StagedRecord.relative","signature":"pub relative : String"},{"kind":"field","path":"staging::StagedRecord.target","signature":"pub target : String"},{"kind":"fn","path":"staging::add","signature":"pub fn add ( workspace : & Path , target_name : & str , module : & str , source : & AddSource , attribution_path : Option < & Path > , usage_path : Option < & Path > , no_verify : bool , check : bool ) -> Result < Value >"},{"kind":"fn","path":"staging::add_working_tree_paths","signature":"pub fn add_working_tree_paths ( workspace : & Path , cwd : & Path , target_name : & str , given : & [ String ] , explicit_module : Option < & str > , attribution_path : Option < & Path > , usage_path : Option < & Path > , no_verify : bool , check : bool ) -> Result < Value >"},{"kind":"fn","path":"staging::commit_all","signature":"pub fn commit_all ( workspace : & Path ) -> Result < ( Value , i32 ) >"},{"kind":"fn","path":"staging::commit_staged","signature":"pub fn commit_staged ( workspace : & Path , record : & Path ) -> CommitReport"},{"kind":"fn","path":"staging::mode_slug","signature":"pub fn mode_slug ( mode : AttributionMode ) -> & ' static str"},{"kind":"fn","path":"staging::record_dir","signature":"pub fn record_dir ( workspace : & Path , module : & str , tree_hash : & str ) -> PathBuf"},{"kind":"fn","path":"staging::relative_record","signature":"pub fn relative_record ( workspace : & Path , path : & Path ) -> String"},{"kind":"fn","path":"staging::staged_payload","signature":"pub fn staged_payload ( target_name : & str , records : Vec < Value > ) -> Value"},{"kind":"fn","path":"staging::staged_records","signature":"pub fn staged_records ( workspace : & Path ) -> Result < Vec < StagedRecord > >"},{"kind":"fn","path":"staging::unclaimed_working_paths","signature":"pub fn unclaimed_working_paths ( workspace : & Path , store : & Store , target : & TargetDef , module : & str , sources : & [ SourceModule ] ) -> Result < Vec < String > >"},{"kind":"fn","path":"staging::write_record","signature":"pub fn write_record ( record_dir : & Path , module : & str , target_name : & str , tree_hash : & str , attribution_present : bool , mode : AttributionMode ) -> Result < ( ) >"},{"kind":"trait","path":"store_view::ObjectSource","signature":"pub trait ObjectSource { fn envelope ( & self , name : & str ) -> Result < Option < Vec < u8 > > > ; fn head ( & self ) -> Result < Option < String > > ; fn names ( & self ) -> Result < Vec < String > > ; }"},{"kind":"fn","path":"store_view::flatten_tree","signature":"pub fn flatten_tree ( source : & dyn ObjectSource , root : & str ) -> Result < BTreeMap < String , ( String , u32 ) > >"},{"kind":"fn","path":"store_view::get_blob","signature":"pub fn get_blob ( source : & dyn ObjectSource , name : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"store_view::get_json","signature":"pub fn get_json ( source : & dyn ObjectSource , name : & str , expected : ObjectKind ) -> Result < Value >"},{"kind":"fn","path":"store_view::get_object","signature":"pub fn get_object ( source : & dyn ObjectSource , name : & str ) -> Result < ( ObjectKind , Vec < u8 > ) >"},{"kind":"fn","path":"store_view::get_tree","signature":"pub fn get_tree ( source : & dyn ObjectSource , name : & str ) -> Result < Tree >"},{"kind":"fn","path":"store_view::has_object","signature":"pub fn has_object ( source : & dyn ObjectSource , name : & str ) -> bool"},{"kind":"fn","path":"store_view::load_tree_objects","signature":"pub fn load_tree_objects ( source : & dyn ObjectSource , root : & str , out : & mut BTreeMap < String , Tree > ) -> Result < ( ) >"},{"kind":"struct","path":"surface::ParseFailure","signature":"pub struct ParseFailure"},{"kind":"field","path":"surface::ParseFailure.column","signature":"pub column : u32"},{"kind":"field","path":"surface::ParseFailure.line","signature":"pub line : u32"},{"kind":"field","path":"surface::ParseFailure.path","signature":"pub path : String"},{"kind":"field","path":"surface::ParseFailure.region","signature":"pub region : String"},{"kind":"field","path":"surface::ParseFailure.region_end","signature":"pub region_end : u32"},{"kind":"field","path":"surface::ParseFailure.region_start","signature":"pub region_start : u32"},{"kind":"method","path":"surface::ParseFailure::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"surface::ParseFailure::detail","signature":"pub fn detail ( & self ) -> String"},{"kind":"const","path":"surface::SOURCE_SYNTAX_SCOPE","signature":"pub const SOURCE_SYNTAX_SCOPE : & str = \" source syntax \" ;"},{"kind":"struct","path":"surface::SurfaceItem","signature":"pub struct SurfaceItem"},{"kind":"field","path":"surface::SurfaceItem.kind","signature":"pub kind : String"},{"kind":"field","path":"surface::SurfaceItem.path","signature":"pub path : String"},{"kind":"field","path":"surface::SurfaceItem.signature","signature":"pub signature : String"},{"kind":"fn","path":"surface::empty_surface_hash","signature":"pub fn empty_surface_hash ( module : & str , target : & str ) -> String"},{"kind":"fn","path":"surface::extract_surface","signature":"pub fn extract_surface ( target : & TargetDef , root : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"surface::is_target_source","signature":"pub fn is_target_source ( target : & TargetDef , root : & str , path : & str ) -> bool"},{"kind":"fn","path":"surface::parse_check","signature":"pub fn parse_check ( target : & TargetDef , path : & str , bytes : & [ u8 ] ) -> Result < Option < ParseFailure > >"},{"kind":"fn","path":"surface::parse_failure_error","signature":"pub fn parse_failure_error ( code : OutcomeCode , failure : & ParseFailure ) -> crate :: error :: CompilerError"},{"kind":"fn","path":"surface::parse_gate","signature":"pub fn parse_gate ( target : & TargetDef , root : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Result < ( ) >"},{"kind":"fn","path":"surface::surface_hash","signature":"pub fn surface_hash ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> String"},{"kind":"fn","path":"surface::surface_items_from_json","signature":"pub fn surface_items_from_json ( value : & Value ) -> Vec < SurfaceItem >"},{"kind":"fn","path":"surface::surface_json","signature":"pub fn surface_json ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> Value"},{"kind":"struct","path":"target::TargetDef","signature":"pub struct TargetDef"},{"kind":"field","path":"target::TargetDef.entry_point_version","signature":"pub entry_point_version : u32"},{"kind":"field","path":"target::TargetDef.manifest_file","signature":"pub manifest_file : String"},{"kind":"field","path":"target::TargetDef.name","signature":"pub name : String"},{"kind":"field","path":"target::TargetDef.source_dir","signature":"pub source_dir : String"},{"kind":"field","path":"target::TargetDef.source_extension","signature":"pub source_extension : String"},{"kind":"fn","path":"target::builtin_target","signature":"pub fn builtin_target ( name : & str ) -> Result < TargetDef >"},{"kind":"fn","path":"target::builtin_target_names","signature":"pub fn builtin_target_names ( ) -> Vec < String >"},{"kind":"fn","path":"target::derive_lib_projection","signature":"pub fn derive_lib_projection ( target : & TargetDef , root : & str , file_paths : & [ String ] , command_providers : & [ String ] ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"target::is_manifest_path","signature":"pub fn is_manifest_path ( target : & TargetDef , path : & str ) -> bool"},{"kind":"fn","path":"target::is_owned_path","signature":"pub fn is_owned_path ( target : & TargetDef , root : & str , path : & str ) -> bool"},{"kind":"fn","path":"target::owned_globs","signature":"pub fn owned_globs ( _target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::placement_facts","signature":"pub fn placement_facts ( target : & TargetDef , root : & str ) -> Value"},{"kind":"fn","path":"target::placement_root","signature":"pub fn placement_root ( source : & SourceModule ) -> String"},{"kind":"fn","path":"target::projection_owned_paths","signature":"pub fn projection_owned_paths ( target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::surface_grammar","signature":"pub fn surface_grammar ( target : & TargetDef ) -> Value"},{"kind":"fn","path":"target::target_def_hash","signature":"pub fn target_def_hash ( target : & TargetDef ) -> String"},{"kind":"fn","path":"target::target_def_json","signature":"pub fn target_def_json ( target : & TargetDef ) -> Value"},{"kind":"enum","path":"textdiff::Edit","signature":"pub enum Edit { Keep ( usize , usize ) , Delete ( usize ) , Insert ( usize ) }"},{"kind":"fn","path":"textdiff::diff_lines","signature":"pub fn diff_lines ( old : & [ String ] , new : & [ String ] ) -> Vec < Edit >"},{"kind":"fn","path":"textdiff::lines","signature":"pub fn lines ( text : & str ) -> Vec < String >"},{"kind":"fn","path":"textdiff::render_drift","signature":"pub fn render_drift ( committed : & BTreeMap < String , String > , working : & BTreeMap < String , String > ) -> String"},{"kind":"fn","path":"textdiff::render_patch","signature":"pub fn render_patch ( path : & str , old : & str , new : & str ) -> String"},{"kind":"struct","path":"transaction::Accepted","signature":"pub struct Accepted"},{"kind":"field","path":"transaction::Accepted.event","signature":"pub event : String"},{"kind":"field","path":"transaction::Accepted.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::Accepted.projection_bytes","signature":"pub projection_bytes : Vec < u8 >"},{"kind":"field","path":"transaction::Accepted.projection_path","signature":"pub projection_path : String"},{"kind":"field","path":"transaction::Accepted.rebased","signature":"pub rebased : bool"},{"kind":"field","path":"transaction::Accepted.snapshot","signature":"pub snapshot : String"},{"kind":"field","path":"transaction::Accepted.steps","signature":"pub steps : Vec < InstallStep >"},{"kind":"struct","path":"transaction::BuiltAttribution","signature":"pub struct BuiltAttribution"},{"kind":"field","path":"transaction::BuiltAttribution.state","signature":"pub state : AttributionState"},{"kind":"field","path":"transaction::BuiltAttribution.view_path","signature":"pub view_path : String"},{"kind":"field","path":"transaction::BuiltAttribution.yaml","signature":"pub yaml : Vec < u8 >"},{"kind":"struct","path":"transaction::DerivedCommit","signature":"pub struct DerivedCommit"},{"kind":"field","path":"transaction::DerivedCommit.attribution_install","signature":"pub attribution_install : Option < ( String , String ) >"},{"kind":"field","path":"transaction::DerivedCommit.attribution_status","signature":"pub attribution_status : String"},{"kind":"field","path":"transaction::DerivedCommit.evidence","signature":"pub evidence : Value"},{"kind":"field","path":"transaction::DerivedCommit.new_state_key","signature":"pub new_state_key : String"},{"kind":"field","path":"transaction::DerivedCommit.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::DerivedCommit.receipt_object","signature":"pub receipt_object : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_items","signature":"pub surface_items : Vec < SurfaceItem >"},{"kind":"field","path":"transaction::DerivedCommit.transition","signature":"pub transition : Transition"},{"kind":"field","path":"transaction::DerivedCommit.transition_id","signature":"pub transition_id : String"},{"kind":"struct","path":"transaction::PendingObject","signature":"pub struct PendingObject"},{"kind":"field","path":"transaction::PendingObject.kind","signature":"pub kind : ObjectKind"},{"kind":"field","path":"transaction::PendingObject.name","signature":"pub name : String"},{"kind":"field","path":"transaction::PendingObject.payload","signature":"pub payload : Vec < u8 >"},{"kind":"fn","path":"transaction::accept","signature":"pub fn accept ( source : & dyn ObjectSource , target : & TargetDef , root : & str , candidate : & Candidate , derived : & DerivedCommit , head : Option < & str > , base_snapshot : Option < & str > ) -> Result < Accepted >"},{"kind":"fn","path":"transaction::check_candidate_shape","signature":"pub fn check_candidate_shape ( target : & TargetDef , root : & str , candidate : & Candidate ) -> Result < ( ) >"},{"kind":"fn","path":"transaction::derive_commit","signature":"pub fn derive_commit ( target : & TargetDef , root : & str , receipt : & Receipt , candidate : & Candidate , built : Option < & BuiltAttribution > ) -> Result < DerivedCommit >"},{"kind":"fn","path":"transaction::intervening_write_set","signature":"pub fn intervening_write_set ( source : & dyn ObjectSource , head : Option < & str > , base : Option < & str > ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"transaction::success_payload","signature":"pub fn success_payload ( derived : & DerivedCommit , accepted : & Accepted ) -> Value"},{"kind":"fn","path":"transaction::verify_receipt","signature":"pub fn verify_receipt ( source : & dyn ObjectSource , sources : & [ SourceModule ] , receipt : & Receipt , candidate : & Candidate ) -> Result < CompileSlice >"},{"kind":"struct","path":"transport::ClaimRefusal","signature":"pub struct ClaimRefusal"},{"kind":"field","path":"transport::ClaimRefusal.code","signature":"pub code : Option < EndpointVerdict >"},{"kind":"field","path":"transport::ClaimRefusal.file","signature":"pub file : Option < String >"},{"kind":"field","path":"transport::ClaimRefusal.index","signature":"pub index : usize"},{"kind":"field","path":"transport::ClaimRefusal.note","signature":"pub note : String"},{"kind":"field","path":"transport::ClaimRefusal.prompt","signature":"pub prompt : EndpointVerdict"},{"kind":"method","path":"transport::ClaimRefusal::affirmation_needed","signature":"pub fn affirmation_needed ( & self ) -> Vec < & ' static str >"},{"kind":"method","path":"transport::ClaimRefusal::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"transport::ClaimRefusal::prompt_lines","signature":"pub fn prompt_lines ( & self ) -> Range"},{"kind":"method","path":"transport::ClaimRefusal::side","signature":"pub fn side ( & self ) -> & ' static str"},{"kind":"struct","path":"transport::EndpointVerdict","signature":"pub struct EndpointVerdict"},{"kind":"field","path":"transport::EndpointVerdict.detail","signature":"pub detail : Option < String >"},{"kind":"field","path":"transport::EndpointVerdict.observed_line_count","signature":"pub observed_line_count : u32"},{"kind":"field","path":"transport::EndpointVerdict.observed_surviving","signature":"pub observed_surviving : Option < Range >"},{"kind":"field","path":"transport::EndpointVerdict.path","signature":"pub path : Option < String >"},{"kind":"field","path":"transport::EndpointVerdict.reason","signature":"pub reason : Option < & ' static str >"},{"kind":"field","path":"transport::EndpointVerdict.recorded","signature":"pub recorded : Option < Range >"},{"kind":"field","path":"transport::EndpointVerdict.transported","signature":"pub transported : Option < Range >"},{"kind":"field","path":"transport::EndpointVerdict.whole","signature":"pub whole : bool"},{"kind":"method","path":"transport::EndpointVerdict::refused","signature":"pub fn refused ( & self ) -> bool"},{"kind":"struct","path":"transport::LineMap","signature":"pub struct LineMap"},{"kind":"method","path":"transport::LineMap::anchor_before","signature":"pub fn anchor_before ( & self , old_line : u32 ) -> Option < u32 >"},{"kind":"method","path":"transport::LineMap::moved_line","signature":"pub fn moved_line ( & self , old_line : u32 ) -> Option < u32 >"},{"kind":"method","path":"transport::LineMap::new_line_count","signature":"pub fn new_line_count ( & self ) -> u32"},{"kind":"method","path":"transport::LineMap::old_line_count","signature":"pub fn old_line_count ( & self ) -> u32"},{"kind":"struct","path":"transport::Range","signature":"pub struct Range"},{"kind":"field","path":"transport::Range.end","signature":"pub end : u32"},{"kind":"field","path":"transport::Range.start","signature":"pub start : u32"},{"kind":"method","path":"transport::Range::new","signature":"pub fn new ( start : u32 , end : u32 ) -> Range"},{"kind":"enum","path":"transport::Refusal","signature":"pub enum Refusal { OutsideOldContent , RegionRewritten , RangeSplit }"},{"kind":"method","path":"transport::Refusal::as_str","signature":"pub fn as_str ( & self ) -> & ' static str"},{"kind":"method","path":"transport::Refusal::detail","signature":"pub fn detail ( & self ) -> & ' static str"},{"kind":"struct","path":"transport::TransportInputs","signature":"pub struct TransportInputs < ' a >"},{"kind":"field","path":"transport::TransportInputs.committed_body","signature":"pub committed_body : & ' a str"},{"kind":"field","path":"transport::TransportInputs.committed_files","signature":"pub committed_files : & ' a BTreeMap < String , String >"},{"kind":"field","path":"transport::TransportInputs.pending_body","signature":"pub pending_body : & ' a str"},{"kind":"field","path":"transport::TransportInputs.staged_files","signature":"pub staged_files : & ' a BTreeMap < String , String >"},{"kind":"struct","path":"transport::TransportOutcome","signature":"pub struct TransportOutcome"},{"kind":"field","path":"transport::TransportOutcome.record","signature":"pub record : Attribution"},{"kind":"field","path":"transport::TransportOutcome.refusals","signature":"pub refusals : Vec < ClaimRefusal >"},{"kind":"method","path":"transport::TransportOutcome::complete","signature":"pub fn complete ( & self ) -> bool"},{"kind":"enum","path":"transport::Transported","signature":"pub enum Transported { Proven ( Range ) , Unproven { refusal : Refusal , surviving : Option < Range > , anchor : Option < u32 > } }"},{"kind":"method","path":"transport::Transported::proven","signature":"pub fn proven ( & self ) -> Option < Range >"},{"kind":"fn","path":"transport::line_map","signature":"pub fn line_map ( old : & str , new : & str ) -> LineMap"},{"kind":"fn","path":"transport::refusal_detail","signature":"pub fn refusal_detail ( module : & str , refusals : & [ ClaimRefusal ] ) -> String"},{"kind":"fn","path":"transport::refusal_payload","signature":"pub fn refusal_payload ( module : & str , refusals : & [ ClaimRefusal ] ) -> Value"},{"kind":"fn","path":"transport::transport_attribution","signature":"pub fn transport_attribution ( record : & Attribution , inputs : & TransportInputs ) -> TransportOutcome"},{"kind":"fn","path":"transport::transport_range","signature":"pub fn transport_range ( map : & LineMap , range : Range ) -> Transported"},{"kind":"fn","path":"trees::empty_tree_hash","signature":"pub fn empty_tree_hash ( ) -> String"},{"kind":"fn","path":"trees::tree_of","signature":"pub fn tree_of ( manifest : & BTreeMap < String , String > ) -> Result < BuiltTree >"},{"kind":"const","path":"version::CONTRACT_VERSION","signature":"pub const CONTRACT_VERSION : u32 = 4 ;"},{"kind":"fn","path":"workspace::apply_step","signature":"pub fn apply_step ( root : & Path , step : & InstallStep , payload : & dyn Fn ( & str ) -> Result < Vec < u8 > > ) -> Result < ( ) >"},{"kind":"fn","path":"workspace::discover_sources","signature":"pub fn discover_sources ( root : & Path ) -> Result < Vec < SourceModule > >"},{"kind":"fn","path":"workspace::find_workspace_root","signature":"pub fn find_workspace_root ( start : & Path ) -> Option < PathBuf >"},{"kind":"fn","path":"workspace::read_working_text","signature":"pub fn read_working_text ( root : & Path , paths : & [ String ] ) -> BTreeMap < String , String >"},{"kind":"fn","path":"workspace::require_workspace_root","signature":"pub fn require_workspace_root ( start : & Path ) -> Result < PathBuf >"},{"kind":"fn","path":"workspace::scan_candidate","signature":"pub fn scan_candidate ( dir : & Path ) -> Result < Vec < ScannedEntry > >"},{"kind":"fn","path":"workspace::step_path","signature":"pub fn step_path ( step : & InstallStep ) -> & str"},{"kind":"fn","path":"workspace::write_file","signature":"pub fn write_file ( path : & Path , bytes : & [ u8 ] ) -> Result < ( ) >"}],"module":"napl","target":"rust"} +napl-store module-state v1 559 +{"attribution":{"promptHash":"6b98457c4a34f4460528fd9867a16bd87069eb66875a351fb23205533e511bbe","record":"0f3756f4e4507027e1ed76f9e0ef890738b3fe60b53eae65468551943cb63929","stale":false},"contractVersion":4,"inputKey":"454c2da32be1feb04e7544b8831278a54e5fca8dc4056a85d9c44e27e785c7c4","module":"agent","outputTree":"c8cf73ffb7c118e5ad378bc58783365f8b56c59299ef2359aebad52205080d68","sliceHash":"b0c395c8c32a6c85d1a858896b3fcdb8ef465c263c88acb4c2b301ba4d22dae3","surfaceHash":"ad1581ae209ba3feb19322408bfff544be7a29bc6a7db44c672b705f1afcd120","target":"rust"} +napl-store snapshot v1 895 +{"contractVersion":4,"event":"b3d7ae339e60525bbc912602c4df1aa26fda7fba4d5e9b0f6e92446287ded032","states":[{"module":"agent","state":"a16fdf3bc7ab489d5610d2f9cf9917b2501ce06cc5b8dd236cc5464f6c93d7f1","target":"rust"},{"module":"commands","state":"10df13f5214ccfa57f086032061ff35bf445a9b2da972f3d7d99016eb2d83199","target":"rust"},{"module":"git-store","state":"33a141441be88c6825e85a1495467d599a016bae61a678358f9d680a52017c99","target":"rust"},{"module":"napl","state":"0ef99195476c7251619345064e2dc74fa005197438592370d03b0b5e5a688b55","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"32812b7dc48cdba647dd60846a7204e6e6a76eee25b75a16aab0e8779497d214","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} +napl-store receipt v1 1830 +{"baseStateKey":"83e7eb0b234bbffd0573d91bcf92e594e91a0a15b536e6d5c915a965fe0d4309","baseStoreSnapshot":"5fea07446ceb73282789afdacd67fe7fb272122a07ddb8003709d80eec220398","baseWorkspaceHash":"a514163a925391219b20e5461159baee848a08a26d17c8b5bb1c62fffb1e560e","candidateTreeHash":"8a214291d385d0008c8c11387def1d4c226bfb970b090415dc921466ff3e21ca","contractVersion":4,"inputKey":"7ab1a333a614964d17ba55a718d21237178f9425a77e2170cd858656ce39896b","module":"usage_proxy","outputManifest":[{"hash":"6e5fc7c645dc91d9cf4cc415d91749bf9eb96cf1a13b17cbffbf7d549cfb3c76","path":"crates/napl-usage-proxy/src/agent.rs"},{"hash":"360a819b87eecfefab1a3831b4340ac7fada4ccb2f8ccef52208b6042f556d1e","path":"crates/napl-usage-proxy/src/args.rs"},{"hash":"8b827a70205c5aadce37f553b10f080fe5c618cf23c3f7954859011d329dbe7c","path":"crates/napl-usage-proxy/src/canonical.rs"},{"hash":"7b54deca43ff224cc112166abd958048b53b2762431c41a9fdc1044144d9c96a","path":"crates/napl-usage-proxy/src/capture.rs"},{"hash":"6844a11e7ff59e2b07df44e86039349fdba2b82241a712904027c6f01fb5a215","path":"crates/napl-usage-proxy/src/logline.rs"},{"hash":"a2da8502c17775fd09cf0f6c314674688fab51a644b5a5c3ce058c4286de69d9","path":"crates/napl-usage-proxy/src/main.rs"},{"hash":"eb4ca92f81de67ad4082efbb23d7619bf0f206dd607122b4e1872a24ee1787ee","path":"crates/napl-usage-proxy/src/proxy.rs"},{"hash":"0479a5be6a51a7191b47168b7f5bbb14ac2168809a25f33b3135dd11d0882a37","path":"crates/napl-usage-proxy/src/session.rs"},{"hash":"5d710e71c5606166c6ed3f471ba86a411990b186acea3ca8aa8ca388253539dc","path":"crates/napl-usage-proxy/src/state.rs"},{"hash":"cfbdfdce716fc1b0828ea03ee5d4b1cde37529c7011f1216862eccd1cc6acc4e","path":"crates/napl-usage-proxy/src/upstream.rs"}],"receiptVersion":1,"sliceHash":"8402d0e8e78aa124e849084dda2ef1b50d120d5c6fe8621946f69ff68da3157a","target":"rust"} +napl-store snapshot v1 895 +{"contractVersion":4,"event":"798cdb510ad310c837372681f20606da6374c6ac6b7295ebb3750da7a29adddf","states":[{"module":"agent","state":"112df64307c695d6e80359ccbbcf9100579692216d90fd08be281e7c82c07574","target":"rust"},{"module":"commands","state":"9e7b90a24d35bb3d42c0476c6d37e380c905ed227e86d8cf44e8342e96265a33","target":"rust"},{"module":"git-store","state":"33a141441be88c6825e85a1495467d599a016bae61a678358f9d680a52017c99","target":"rust"},{"module":"napl","state":"0ef99195476c7251619345064e2dc74fa005197438592370d03b0b5e5a688b55","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"32812b7dc48cdba647dd60846a7204e6e6a76eee25b75a16aab0e8779497d214","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} +napl-store event v1 158 +{"content":"0683a2f5623022e219ad446de610a80a5b77956fa5d2bf9ea9df38037f9bdae4","parents":["0a757fac5d572e4fc554b6ff0d3fe77b65b01622f0b7cc503ea93f84880a5633"]} +napl-store transition v1 1428 +{"baseStateKey":"d5141dcf22807eb4d7314871e99dd9b1f165a355d2ba0c088f6d2f4e2b9cf7ac","contractVersion":2,"derivedProjections":["crates/napl-store/src/lib.rs"],"inputKey":"6b0592b0c82aa68f8a0c06832ac1db151ab4ddc3d5b7e603162e461743237d58","installPaths":["crates/napl-store/src/hash.rs","crates/napl-store/src/store_blocks/mod.rs","crates/napl-store/src/store_blocks/tests.rs","crates/napl-store/src/store_diff/mod.rs","crates/napl-store/src/store_diff/tests.rs","crates/napl-store/src/store_hash.rs","crates/napl-store/src/store_io/mod.rs","crates/napl-store/src/store_io/tests.rs","crates/napl-store/src/store_objects/mod.rs","crates/napl-store/src/store_objects/tests.rs","crates/napl-store/src/store_reduce/mod.rs","crates/napl-store/src/store_reduce/tests.rs","crates/napl-store/src/store_tree.rs","crates/napl-store/src/store_txn_core.rs"],"module":"napl_store","newState":"aa1c6feb509424d6daaf1184f30c91f91fa1840466d1b59b2b379fbed233080a","readSet":["base-state:d5141dcf22807eb4d7314871e99dd9b1f165a355d2ba0c088f6d2f4e2b9cf7ac","input-key:6b0592b0c82aa68f8a0c06832ac1db151ab4ddc3d5b7e603162e461743237d58","target-def:eff9678e5301c88ac1e42eec62c56eb3860fca247f6278b6501639f14112a8d4"],"receipt":"05b37a6e9c078e2fec2fe0c0ae3d24e9117c25e7557f1317b4b133ca7d1f90d5","surface":"fb4aa1182902dca4e0ae0f15304e23b6fa162fdfcfd9b20c54d16696eea10017","target":"rust","writeSet":["owned:crates/napl-store","module-state:rust/napl_store"]} +napl-store receipt v1 1042 +{"baseStateKey":"782340da0a7d9facec2d0d42bc2fc55bab1d1cbddc3d888a91eba6f9ef9bc7a1","baseStoreSnapshot":"5267d01ec740e8bd16aee21fcc3b1793b34625a15aad070c4f99a9c1fee469de","baseWorkspaceHash":"7f682f874d2946896575efe98318ff918d12056d184eb7f60c66128bb4742ff2","candidateTreeHash":"fe440cd1ff613b544411bcbb210d7fbeac16036b945a163437a9462cc7f2896d","contractVersion":4,"inputKey":"c5d75a16ebd9b47dee925970788b6d6877b9516c34c1733a6a2b849fe75b981f","module":"query","outputManifest":[{"hash":"3b3b46d4f8f832a2b67e59d481fce3269061f0c79cb335d3ddc5a51d8a1ce915","path":"crates/napl/src/check.rs"},{"hash":"342bd33e8d08dec713feb5979249463f25ad382dd960e0c156b019c1e6de0e60","path":"crates/napl/src/document.rs"},{"hash":"2e84007b600de3257549b12e35b45e779d85751c59dd2ec7db7abde7ecd7e4c2","path":"crates/napl/src/promptbuild.rs"},{"hash":"ed4e1767d9f257439d2fe1a157104d2bbe511b8296bacd6027ba490122386f3f","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"sliceHash":"0b9bce6ead1a013e111512490b4e2666081fe219c177221fef06f8ef05292dfe","target":"rust"} +napl-store blob v1 12471 +//! `gc`, `fsck`, and `restore`: the three maintenance derivations. +//! +//! All three are decisions over content. None of them consults a clock, a +//! process table, or a wall-time budget: a sweep keeps what a named head +//! reaches, an integrity pass reports what fails to verify, and a repair plan +//! either names an unambiguous move or blocks. -::: test -name: a commit below the loose threshold never repacks -given: { workspace: fixture_with_multi_commit_history, loose_objects: below_threshold, then: valid_commit } -expect: { commit_outcome: installed, repack_reported: false, packs_unchanged: true } -::: +use std::collections::{BTreeMap, BTreeSet}; -::: test -name: the automatic repack writes the byte-identical pack gc would have written -given: { workspace: fixture_with_multi_commit_history, compare: [auto_repack_at_commit, explicit_gc_apply] } -expect: { pack_bytes_equal: true, pack_names_equal: true } -::: +use napl_store::store_hash::{verify_object, ObjectKind}; +use serde_json::{json, Value}; -`napl store migrate` is the one-shot verb that moves a workspace's -objects out of the working tree and into the repository's own object -database. It refuses outside a git repository and refuses when the -store branch already exists, and neither refusal writes anything; -otherwise it walks every tracked object and every pack into blobs -under one deterministic commit, creates the branch there, and reports -the object files as ready to leave the working tree together with the -exact command that removes them, because deleting them from the index -is the owner's ordinary git commit and never a store mutation. The -verb touches no tracked file itself. After it, the object backend is -chosen by observation and never by configuration: a workspace root -inside a git repository whose store branch exists reads and writes -its objects through the git provider, every other workspace uses the -tracked layout, and no flag, key, or environment variable is ever -consulted. Only object storage moves. HEAD, staging, locks, markers, -installed stamps, and attribution keep the disk paths they always -had, because each is branch-local state one shared ref could never -speak for, and the HEAD compare-and-swap with its decision table -arbitrates under either backend exactly as it does today. A -git-backed store is append-only by construction, so its loose count -is zero, the automatic repack never fires, and a sweep reports -without deleting; the packfile machinery stays as the tracked -layout's own mitigation. +use crate::error::Result; +use crate::objects::{event_transition, snapshot_event, snapshot_states, walk_events}; +use crate::store_view::{get_json, load_tree_objects, ObjectSource}; -::: test -name: store migrate refuses outside a git repository and refuses a second time inside one -given: { workspace: fixture_two_modules, runs: [gitless, migrated_then_again] } -expect: { gitless_outcome: refused, second_outcome: refused, branch_created_once: true } -::: +// ------------------------------------------------------------------- gc -::: test -name: a migrated workspace answers identically through the git provider and leaves the tracked files alone -given: { workspace: fixture_two_modules, history: [commit], then: store_migrate } -expect: { status_identical: true, fsck_findings: 0, tracked_object_files_untouched: true, objects_readable: true } -::: +/// Every object reachable from a named head. +/// +/// Reachability is the whole safety argument for `gc`: the sweep is the +/// complement of this set, so an object this walk reaches is never a candidate +/// for collection, whatever else is happening concurrently. +pub fn reachable_from_head(source: &dyn ObjectSource, head: &str) -> Result> { + let mut out: BTreeSet = BTreeSet::new(); + out.insert(head.to_string()); -::: test -name: a commit into a migrated workspace lands its objects on the store branch and moves the tracked head -given: { workspace: fixture_two_modules, then: [store_migrate, valid_commit] } -expect: { commit_outcome: installed, new_objects_on_branch: true, head_is_the_tracked_file: true, loose_objects_added: 0 } -::: + let head_event = snapshot_event(source, head)?; + for event in walk_events(source, &head_event, None)? { + out.insert(event.clone()); + let transition = event_transition(source, &event)?; + out.insert(transition.clone()); + let body = get_json(source, &transition, ObjectKind::Transition)?; + for key in ["newState", "receipt", "surface"] { + if let Some(name) = body[key].as_str() { + out.insert(name.to_string()); + } + } + } -`napl fsck` envelope hashes, kind validity, -causal parent links. `napl restore` deterministic repair plans from -persisted evidence; unambiguous moves install, ambiguity blocks; liveness -and wall time never decide recovery. `napl mv ` relocation -as a semantic operation: moves a module's installed files to a new -placement, recomputes every placement-derived fact (derived entry-file -projections, attribution file paths) from the destination, passes -through the guarded commit path, and re-verifies there; failed -verification at the destination installs nothing; manifests are the -user's to update, and status diagnoses a placement the workspace -manifest no longer lists. A move made outside `napl mv` is drift like any other: -the store still holds the committed bytes, `napl restore` re-materializes -the module at its recorded placement, and when the drifted content matches -a committed state exactly the plan may instead adopt the new placement -through the commit path; anything ambiguous blocks. `napl cat-file ` -raw plumbing. `napl lsp` a language server over stdio for editor -extensions: hover, definition, references, and code lens over the -prompt-to-code mapping. Everything it serves derives from committed -records (attribution entries, module surfaces, the ir) resolved against -the workspace root; hover on a prompt line presents the attributed -generated spans with their notes, hover on a generated line presents the -owning prompt lines and note, definition jumps across the mapping in both -directions, references enumerates every attributed span, code lens counts -attributed files per prompt section. The server publishes diagnostics -derived from reference staleness: for every stale reference it emits one -diagnostic on each endpoint, the prompt span whose code moved and the -code span whose prompt changed, so sync debt is visible where the edit -happens, like a type error for documentation. Publication is a pure -derivation from committed records plus the working tree at the moment -the protocol asks, no background mutation, no clocks, no scanning loops -beyond what the editor protocol requires; opening or changing a -document refreshes that document's diagnostics and its counterparts'. -Severity is warning, never error: drift is a legal state, marked, not -policed. The diagnostic message states exactly what is known and -nothing more: which end drifted, the reference's direction, recorded -model or `unavailable`, referentially valid, and the healing verbs -(re-record through `napl edit`, or restore); no wording implies the -sentence describes the code. A fresh reference publishes nothing. -Resolving is always a human or LLM re-affirming or re-pointing the -reference through the capture verbs; the server proposes nothing and -repairs nothing. Pure reads only: no mutation, no -clocks, no background scanning. A request whose records are missing or -unparseable answers empty rather than erroring or blocking, and missing -or unparseable records degrade to no diagnostics, never an error; a stale -mapping is served marked stale, consistent with the attribution contract; -the server never hangs on a malformed or absent record. Record resolution is -anchored per document: walk up from the document's filesystem path to the -nearest ancestor directory containing `.napl` and read records there; the -server's working directory and the client's rootUri carry no meaning. -Every location the server emits that points at a module's prompt resolves -to the prompt file's actual placement found by workspace prompt discovery, -where the prompt sits inside the crate directory its code lands in; a -module name is never joined against the workspace root to fabricate a -prompt path, and a module whose prompt cannot be discovered answers empty. -Crash safety: every persisted application step leaves markers such that -recovery derives a deterministic plan from content alone. Evidence -validators are a registry keyed by media type (Claude CLI JSON envelope -ships first, and `application/x-napl-usage-proxy-jsonl-v1` validates -usage-proxy logs: one canonical JSON object per line, required fields -present, absent values the string `unavailable`, token counts -non-negative integers when present, a line failing the shape is -`invalid`); outcomes `validated`, `unsupported`, `invalid`, -`validator-error`; invalid stays visible as failed. Evidence bounds are -compiled-in constants; exceeding any is `resource-limit` before anything -publishes. + for state_hash in snapshot_states(source, head)?.values() { + out.insert(state_hash.clone()); + let state = get_json(source, state_hash, ObjectKind::ModuleState)?; + if let Some(surface) = state["surfaceHash"].as_str() { + out.insert(surface.to_string()); + } + // The attribution record blob and the committed prompt blob it names by + // hash both survive gc: the language server reads the record, and + // `napl prompt` reads the prompt bytes at their snapshot. + if let Some(record) = state["attribution"]["record"].as_str() { + out.insert(record.to_string()); + } + if let Some(prompt) = state["attribution"]["promptHash"].as_str() { + out.insert(prompt.to_string()); + } + let Some(tree) = state["outputTree"].as_str() else { + continue; + }; + let mut trees = BTreeMap::new(); + load_tree_objects(source, tree, &mut trees)?; + for (name, node) in &trees { + out.insert(name.clone()); + for entry in &node.entries { + out.insert(entry.hash.clone()); + } + } + } + Ok(out) +} -::: test -name: a stale reference publishes one diagnostic on each endpoint -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, lsp: [open_prompt, open_code] } -expect: { prompt_diagnostics: 1, code_diagnostics: 1, severity: warning } -::: +/// The sweep: every object no named head reaches, in canonical order. +pub fn plan_sweep(all: &BTreeSet, reachable: &BTreeSet) -> Vec { + all.difference(reachable).cloned().collect() +} -::: test -name: a fresh reference publishes nothing -given: { workspace: fixture_two_modules, lsp: [open_prompt, open_code] } -expect: { prompt_diagnostics: 0, code_diagnostics: 0 } -::: +/// The canonical JSON body of a gc report. +pub fn gc_json(swept: &[String], kept: usize) -> Value { + json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "kept": kept as u64, + "swept": swept, + }) +} -::: test -name: "the diagnostic message carries drifted end, direction, and provenance and nothing semantic" -given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched, lsp: [open_code] } -expect: { message_names_drifted_end: true, message_names_direction: true, message_model_or_unavailable: true, message_semantic_claims: 0 } -::: +// ----------------------------------------------------------------- fsck -::: test -name: absent or unparseable records publish no diagnostics and answer empty -given: { workspace: fixture_two_modules, records: attribution_file_corrupted, lsp: [open_prompt] } -expect: { diagnostics: 0, error_responses: 0, server_alive: true } -::: +/// One integrity failure. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum FsckFinding { + /// The object's bytes do not hash to its name, or its envelope is malformed. + HashMismatch { object: String, detail: String }, + /// An event names a parent the store does not hold. + MissingParent { event: String, parent: String }, + /// A recorded object names an object the store does not hold. + MissingObject { from: String, missing: String }, +} -::: test -name: committing the drifted code clears both endpoints' diagnostics on refresh -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, then: commit_module, lsp: [change_code_document] } -expect: { prompt_diagnostics: 0, code_diagnostics: 0 } -::: +/// Verify every object's envelope and every causal parent link. +pub fn fsck(source: &dyn ObjectSource) -> Result> { + let mut findings: Vec = Vec::new(); + let names = source.names()?; + let present: BTreeSet<&String> = names.iter().collect(); -::: test -name: diff renders drift between committed output and working tree as a canonical patch -given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched } -expect: { diff_nonempty: true, canonical_patch: true, byte_stable: true } -::: + for name in &names { + let Some(bytes) = source.envelope(name)? else { + continue; + }; + let verified = match verify_object(name, &bytes) { + Ok(verified) => verified, + Err(e) => { + findings.push(FsckFinding::HashMismatch { + object: name.clone(), + detail: e.to_string(), + }); + continue; + } + }; + if verified.kind == ObjectKind::Blob { + continue; + } + let Ok(value) = serde_json::from_slice::(&verified.payload) else { + continue; + }; + if verified.kind == ObjectKind::Event { + for parent in value["parents"].as_array().cloned().unwrap_or_default() { + if let Some(parent) = parent.as_str() { + if !present.contains(&parent.to_string()) { + findings.push(FsckFinding::MissingParent { + event: name.clone(), + parent: parent.to_string(), + }); + } + } + } + } + for referenced in structural_refs(verified.kind, &value) { + if !present.contains(&referenced) { + findings.push(FsckFinding::MissingObject { + from: name.clone(), + missing: referenced, + }); + } + } + } + findings.sort(); + findings.dedup(); + Ok(findings) +} -::: test -name: "blame over a whole-file regeneration coarsens to the transition, line ancestry survives accepted patches" -given: { workspace: fixture_two_modules, history: [patch_commit, full_rewrite_commit] } -expect: { patched_lines_trace_to_patch: true, rewritten_region_blames_transition: true } -::: - -::: test -name: lint reports an unreachable module only under declared roots and refuses without them -given: { workspace: fixture_orphan_module, roots: absent } -expect: { unreachable_analysis: refused, exit_code: 2, reason_names_roots: true, findings_field_absent: true } -::: - -::: test -name: lint with declared roots proposes removal of the orphan with a cascade preview and never auto-applies -given: { workspace: fixture_orphan_module, roots: [main_module] } -expect: { findings: [unreachable_napl], auto_applied: false, cascade_preview: true } -::: - -::: test -name: lint --fix drops an unreferenced declared dependency through the guarded commit path -given: { workspace: fixture_unused_dep, fix: drop_unused_dep } -expect: { outcome: installed, transition_recorded: true, dep_removed: true } -::: - -::: test -name: a cost report over a receipt set containing verb-staged records includes the edits -given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines } -expect: { report_rows: 2, edit_row_priced: true, same_pricing_table: true } -::: - -::: test -name: an edit without a window and a window with missing log lines both price unavailable naming why -given: { workspace: fixture_two_modules, receipts: [edit_without_window, edit_with_window_to_missing_lines] } -expect: { priced_unavailable: 2, reason_named_per_row: true, zeros: 0 } -::: - -::: test -name: a model absent from the pricing table prices unknown never zero -given: { workspace: fixture_two_modules, receipts: [edit_with_unknown_model_window], log: proxy_log_unknown_model } -expect: { priced_unknown: 1, zeros: 0 } -::: - -::: test -name: cost reports are byte-identical across runs over the same receipts log and table -given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines, runs: 2 } -expect: { bytes_equal: true } -::: - -::: test -name: cost session reports one session's calls directly from the log -given: { workspace: fixture_two_modules, log: proxy_log_two_sessions, session: s1 } -expect: { rows_for_session: 2, rows_for_other_sessions: 0, priced_from_log: true } -::: - -::: test -name: a proxy log line failing the shape validates invalid and stays visible as failed -given: { evidence: proxy_log_with_malformed_line, media_type: application_x_napl_usage_proxy_jsonl_v1 } -expect: { outcome: invalid, visible_as_failed: true } -::: - -::: test -name: cost over one receipt set and pricing version is byte-identical across runs and unknown for unknown models -given: { receipts: fixture_receipts, pricing_version: pinned, runs: 2, unknown_model_receipt: included } -expect: { reports_equal: true, unknown_model_cost: unknown } -::: - -::: test -name: gc never deletes an object reachable from the current head and never blocks a concurrent commit -given: { workspace: fixture_two_modules, concurrent: [gc, valid_commit] } -expect: { reachable_deleted: false, commit_blocked: false, unreachable_swept: true } -::: - -::: test -name: fsck detects a corrupted object and a broken parent link -given: { workspace: fixture_two_modules, corrupt: [flip_byte_in_object, remove_parent_event] } -expect: { findings: [hash_mismatch, missing_parent], exit_nonzero: true } -::: - -::: test -name: restore applies an unambiguous move-repair plan and blocks on ambiguity -given: { workspace: fixture_moved_output, ambiguity: [none, two_candidates] } -expect: { unambiguous: installed, ambiguous: blocked } -::: - -::: test -name: hover on an attributed prompt line resolves its generated spans -given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: within_entry } } -expect: { spans_resolved: true, notes_included: true } -::: - -::: test -name: "hover on an unattributed line answers empty, not error" -given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: outside_entries } } -expect: { result: empty, error: false } -::: - -::: test -name: missing attribution records degrade to empty responses -given: { attribution: absent, request: hover } -expect: { result: empty, error: false, blocked: false } -::: - -::: test -name: definition from a generated line targets the prompt at its discovered placement -given: { attribution: fixture_attribution_small, prompt_placement: inside_crate_dir, request: definition, position: { file: generated_within_entry } } -expect: { location_file: discovered_prompt_path, root_joined_module_name: false } -::: - -::: test -name: evidence failing its validator is invalid and distinguishable from unsupported -given: { receipt_evidence: [valid_claude_json, malformed_claude_json, unknown_media_type] } -expect: { outcomes: [validated, invalid, unsupported] } -::: - -::: test -name: a receipt exceeding evidence bounds is rejected as resource-limit with nothing published -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: oversized_evidence } -expect: { outcome: resource_limit, published_semantic_state: false, published_provenance: false } -::: - -## Determinism contract - -Every collection canonically ordered; every input explicit and content -identified; serialization byte-pinned; ambient environment excluded -(locale, timezone, machine identity, CPU count, enumeration order); reads -never write; a state transition names its base and fails on mismatch per -the decision table rather than recalculating. Same workspace compiled -twice, from different absolute paths, under different discovery orders, -locales, and timezones produces byte-identical artifacts. The test suite -proves the vectors in the frontmatter with deterministic fixtures -constructed in each crate's `tests/`. - -::: test -name: compiling the same workspace twice yields byte-identical artifacts -given: { workspace: fixture_two_modules, runs: 2 } -expect: { artifacts_equal: true } -::: - -::: test -name: artifact bytes are independent of discovery order -given: { workspace: fixture_two_modules, discovery_orders: [sorted, reversed, shuffled_seed_7] } -expect: { artifacts_equal: true } -::: - -::: test -name: artifact bytes are independent of the absolute workspace path -given: { workspace: fixture_two_modules, mounted_at: [rootA, rootB] } -expect: { artifacts_equal: true } -::: - -::: test -name: canonical json sorts keys and is byte-stable -given: { value: "{\"b\":1,\"a\":2}", encodings: 2 } -expect: { bytes_equal: true, sorted_keys: true } -::: -napl-store tree v1 4679 -{"entries":[{"hash":"1eab4fcec1746de996240b145f35707eeea8526ddffcfbe0afa5c7ab0f1da3cc","kind":"file","mode":33188,"name":"attribution.rs"},{"hash":"e9997799e9a16e02275f9ccc7d7f893af10b00537cadfc93f0a782b116610f0f","kind":"file","mode":33188,"name":"authoring.rs"},{"hash":"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2","kind":"file","mode":33188,"name":"blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","kind":"file","mode":33188,"name":"candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","kind":"file","mode":33188,"name":"canon.rs"},{"hash":"44704a3a2c842e344fcd7218c1b9da9621dc645340891cafe396a92a52386991","kind":"file","mode":33188,"name":"capture.rs"},{"hash":"8fe3ea8a3dd647910505e230d5c261594b65926d6521a5c7ad5ddaf6bc752fbc","kind":"file","mode":33188,"name":"cli.rs"},{"hash":"b164c6110c4b163fd7d28b03f1f81984452da3282aeb087000e4117f47614ca2","kind":"file","mode":33188,"name":"commandset.rs"},{"hash":"b151c3a18fb9dcae20fbaaddabd221bf11d62aa67578d2a100e67aa8b41408d4","kind":"file","mode":33188,"name":"commit.rs"},{"hash":"51a5efd09235e6a66de7381faaa217a51136ca9c7d2b10285f99af8ebe4ffc6f","kind":"file","mode":33188,"name":"compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","kind":"file","mode":33188,"name":"cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","kind":"file","mode":33188,"name":"decision.rs"},{"hash":"04a648a127be983c89a99181ee26e28401ee00f84ea0b10b6c050ecd263a1b2c","kind":"file","mode":33188,"name":"directive.rs"},{"hash":"28059a89256aad1de78ecd31d7b77cae21772bbc6e96fe8ba1cdb9044048f410","kind":"file","mode":33188,"name":"disk.rs"},{"hash":"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812","kind":"file","mode":33188,"name":"error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","kind":"file","mode":33188,"name":"evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","kind":"file","mode":33188,"name":"graph.rs"},{"hash":"e92c8773dc9d09a3289e877e97f76a056b8dd9d4822b6c224b327cf747972b35","kind":"file","mode":33188,"name":"lint.rs"},{"hash":"659bee27ac76f1462f4e7e5275edac75a6975daa7d2c9e349d92c9a9351e6cbd","kind":"file","mode":33188,"name":"lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","kind":"file","mode":33188,"name":"main.rs"},{"hash":"86fabd45da78a46bbfa7e92465788159360c0abaa4402f10ca433779b419ee0d","kind":"file","mode":33188,"name":"maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","kind":"file","mode":33188,"name":"mv.rs"},{"hash":"3ae089b923327032845c386f2332ff653a08f25ccec28e0786094fa05fd77a00","kind":"file","mode":33188,"name":"objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","kind":"file","mode":33188,"name":"paths.rs"},{"hash":"a14dd6dfce9155411a941b4a6d70650ab7b5ad430be0ed7193a1fcef2a2b2aa5","kind":"file","mode":33188,"name":"porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","kind":"file","mode":33188,"name":"receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","kind":"file","mode":33188,"name":"slice.rs"},{"hash":"0deb3e918644f69c5dd0dfb1434002e896b38efddd4aabc76a778fe02a06b704","kind":"file","mode":33188,"name":"source.rs"},{"hash":"0b443cd4703dc57218f7c2858b1a66913833e5d99d02310bc30f26373a77c434","kind":"file","mode":33188,"name":"staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","kind":"file","mode":33188,"name":"store_view.rs"},{"hash":"9ae420e34160e7c2a6c2ba092aaa9c9ff1df622ece595849d95bf5c5eb7cbe81","kind":"file","mode":33188,"name":"surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","kind":"file","mode":33188,"name":"target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","kind":"file","mode":33188,"name":"textdiff.rs"},{"hash":"cd15f0d80168975552d87c075819d485d3bb65daa0da7f4b3958aa9abd2ecd05","kind":"file","mode":33188,"name":"transaction.rs"},{"hash":"65ee4e7c4fb2eb1af467a3198df19fe8b5ff5479cec1993721e874f60cfeb146","kind":"file","mode":33188,"name":"transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","kind":"file","mode":33188,"name":"trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","kind":"file","mode":33188,"name":"version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","kind":"file","mode":33188,"name":"workspace.rs"}]} -napl-store snapshot v1 1005 -{"contractVersion":4,"event":"220528417ab84fb68a3b21eef82fb259a47e8c9c0b5552e216260bfde8da0cc5","states":[{"module":"agent","state":"d0d406dd4f30ea6840a808bcbc3ddeea8ec8cc10c84de12ab5d0cfeef7bb6696","target":"rust"},{"module":"commands","state":"ac7d92c561ec9fa19f0e28e1392bd90c1d95ca663b038424bd8ce760c6f4cd9e","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"42cee1b00043646e7e0ebed264397212c4ae136d44233aee64af6bebcab11d53","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"accaf93691b84c6794df99c7643c710529c765f12cc9faedede4b6b598a8c6b0","target":"rust"},{"module":"serve","state":"40ddb00b3be320909acf0ae1440e2bc5662e3002dcf77dc3367bcb215b1710fd","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} -napl-store blob v1 26049 -//! Attribution transport: carrying recorded claims across a byte diff, or -//! refusing to. -//! -//! A recorded attribution claim names a line range. When the content under that -//! range changes, the claim has to move with it or stop being a claim. Transport -//! is that move, and it is the same computation blame already performs: replay -//! the line ancestry the diff proves, and say nothing where the diff proves -//! nothing. Blame answers "which transition wrote this line"; transport answers -//! "where did this range go", and both are reads of the identical `Keep`, -//! `Insert`, `Delete` script, so the two share this one line-ancestry map. -//! -//! The bias is refusal over inference. A range every line of which survives as -//! one contiguous run transports to that run: an insertion or deletion above it -//! shifts it by the delta, and an untouched file leaves it exactly where it was. -//! Anything else — a line that did not survive, a run broken apart by content -//! inserted inside it, a region rewritten, a move that reads as a delete plus an -//! add — does not transport. The refusal carries the observed bound next to the -//! claim, so a reader never has to count lines by hand to find out what the -//! content actually says now. -//! -//! Two consumers share this: `add`, which transports a committed map onto staged -//! bytes and a pending prompt body, and the working-tree document, which -//! transports the same map onto the pending prompt body so a view can serve a -//! section's files without re-associating them by range arithmetic. - -use std::collections::BTreeMap; - -use serde_json::{json, Value}; - -use crate::attribution::{Attribution, Entry}; -use crate::textdiff::{diff_lines, lines, Edit}; - -/// The line ancestry one diff proves, as a lookup from old line to new line. +/// The objects a record structurally points at. /// -/// `moved[i]` is where old line `i` (0-based) landed, or `None` when the diff -/// deleted it. `new_len` is the line count of the new content, which is the -/// observed bound a refusal reports. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LineMap { - moved: Vec>, - new_len: usize, -} - -impl LineMap { - /// The number of lines the new content has: the bound every claim against it - /// is checked in. - pub fn new_line_count(&self) -> u32 { - self.new_len as u32 - } - - /// The number of lines the old content had. - pub fn old_line_count(&self) -> u32 { - self.moved.len() as u32 - } - - /// Where a 1-based old line landed, 1-based, or `None` when it did not - /// survive. - pub fn moved_line(&self, old_line: u32) -> Option { - let index = (old_line as usize).checked_sub(1)?; - self.moved.get(index).copied().flatten().map(|new| new as u32 + 1) - } - - /// The last surviving line at or before `old_line`, expressed in the new - /// content: the proven anchor a refusal can be located at without inventing - /// an extent for the claim. `None` when nothing at or before it survived. - pub fn anchor_before(&self, old_line: u32) -> Option { - let mut cursor = old_line.min(self.old_line_count()); - while cursor >= 1 { - if let Some(new) = self.moved_line(cursor) { - return Some(new); - } - cursor -= 1; - } - None - } -} - -/// Build the line ancestry between two texts. -pub fn line_map(old: &str, new: &str) -> LineMap { - let old_lines = lines(old); - let new_lines = lines(new); - let mut moved: Vec> = vec![None; old_lines.len()]; - for edit in diff_lines(&old_lines, &new_lines) { - if let Edit::Keep(old_index, new_index) = edit { - moved[old_index] = Some(new_index); - } - } - LineMap { - moved, - new_len: new_lines.len(), - } -} - -/// A 1-based inclusive line range. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Range { - pub start: u32, - pub end: u32, -} - -impl Range { - pub fn new(start: u32, end: u32) -> Range { - Range { start, end } - } -} - -/// Why a range refused to transport, in the words the refusal is served in. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Refusal { - /// The range names lines the old content never had, so there was nothing to - /// carry: a claim already wrong before the diff. - OutsideOldContent, - /// At least one line of the range did not survive the diff. A rewritten - /// region and a move that reads as a delete plus an add both land here, and - /// the transport declines to tell them apart rather than guessing which. - RegionRewritten, - /// Every line survived, but not as one run: content entered the middle of - /// the range, so the range's extent in the new content is an inference and - /// not a fact the diff proves. - RangeSplit, -} - -impl Refusal { - /// The wire word a payload carries. - pub fn as_str(&self) -> &'static str { - match self { - Refusal::OutsideOldContent => "outside-recorded-content", - Refusal::RegionRewritten => "region-rewritten", - Refusal::RangeSplit => "range-split", +/// Only structural links count. A record also carries *keys* — a `baseStateKey`, +/// a `baseWorkspaceHash`, an `inputKey` — which are identities of states and +/// derivations rather than names of stored objects, and demanding that those +/// resolve would report a healthy store as broken. +fn structural_refs(kind: ObjectKind, value: &Value) -> BTreeSet { + let mut out: BTreeSet = BTreeSet::new(); + let mut take = |name: Option<&str>| { + if let Some(name) = name.filter(|n| crate::canon::is_object_name(n)) { + out.insert(name.to_string()); } - } - - /// One sentence saying what the transport could not prove. - pub fn detail(&self) -> &'static str { - match self { - Refusal::OutsideOldContent => { - "the recorded range names lines the recorded content does not have" - } - Refusal::RegionRewritten => { - "the recorded range covers lines the diff shows were rewritten or removed, so where it moved to is not proven" - } - Refusal::RangeSplit => { - "every recorded line survived but content entered the middle of the range, so its new extent would be a guess" + }; + match kind { + ObjectKind::Tree => { + for entry in value["entries"].as_array().cloned().unwrap_or_default() { + take(entry["hash"].as_str()); } } - } -} - -/// What transport made of one range. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Transported { - /// The diff proves the range is now exactly this range. - Proven(Range), - /// The diff proves nothing about where the range went. `surviving` is the - /// largest proven run inside the range, when there is one, and `anchor` is - /// the last surviving line at or before the range's start: both are - /// observations about the new content, never a repositioned claim. - Unproven { - refusal: Refusal, - surviving: Option, - anchor: Option, - }, -} - -impl Transported { - /// The proven range, or `None`. - pub fn proven(&self) -> Option { - match self { - Transported::Proven(range) => Some(*range), - Transported::Unproven { .. } => None, - } - } -} - -/// Carry one range across a diff, or refuse. -pub fn transport_range(map: &LineMap, range: Range) -> Transported { - if range.start == 0 || range.start > range.end || range.end > map.old_line_count() { - return Transported::Unproven { - refusal: Refusal::OutsideOldContent, - surviving: None, - anchor: map.anchor_before(range.start), - }; - } - let mut moved: Vec> = Vec::new(); - for line in range.start..=range.end { - moved.push(map.moved_line(line)); - } - if moved.iter().any(Option::is_none) { - return Transported::Unproven { - refusal: Refusal::RegionRewritten, - surviving: longest_run(&moved), - anchor: map.anchor_before(range.start), - }; - } - let first = moved[0].expect("every line survived"); - let contiguous = moved - .iter() - .enumerate() - .all(|(offset, line)| *line == Some(first + offset as u32)); - if !contiguous { - return Transported::Unproven { - refusal: Refusal::RangeSplit, - surviving: longest_run(&moved), - anchor: map.anchor_before(range.start), - }; - } - Transported::Proven(Range::new( - first, - first + (range.end - range.start), - )) -} - -/// The longest contiguous surviving run inside a partially surviving range, -/// expressed in the new content. This is an observation offered beside a -/// refusal, not a transported claim. -fn longest_run(moved: &[Option]) -> Option { - let mut best: Option = None; - let mut current: Option = None; - for entry in moved { - match (entry, current) { - (Some(line), Some(run)) if *line == run.end + 1 => { - current = Some(Range::new(run.start, *line)); + ObjectKind::Snapshot => { + for entry in value["states"].as_array().cloned().unwrap_or_default() { + take(entry["state"].as_str()); } - (Some(line), _) => current = Some(Range::new(*line, *line)), - (None, _) => current = None, } - if let Some(run) = current { - let longer = best - .map(|seen| run.end - run.start > seen.end - seen.start) - .unwrap_or(true); - if longer { - best = Some(run); + ObjectKind::ModuleState => { + take(value["outputTree"].as_str()); + take(value["surfaceHash"].as_str()); + } + ObjectKind::Transition => { + for key in ["newState", "receipt", "surface"] { + take(value[key].as_str()); } } + ObjectKind::Event => take(value["content"].as_str()), + _ => {} } - best -} - -// ------------------------------------------------ transporting a whole record - -/// The four texts a record is transported between: the prompt body as recorded -/// and as it stands pending, and the module's output as committed and as staged. -/// -/// Both sides are content the caller already has in hand, so the transport is a -/// pure function of them and takes no filesystem or store of its own. -#[derive(Debug, Clone)] -pub struct TransportInputs<'a> { - pub committed_body: &'a str, - pub pending_body: &'a str, - pub committed_files: &'a BTreeMap, - pub staged_files: &'a BTreeMap, -} - -/// What the transport made of ONE endpoint of one reference. -/// -/// A reference has two ends and they are diffed against different content, so -/// they get different answers and both answers are facts. Reporting only the -/// failing one throws away work the transport already did and sends the author -/// to re-derive a range the tool has in hand. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EndpointVerdict { - /// The range as recorded, or `None` when the endpoint is spelled whole. - pub recorded: Option, - /// True when the endpoint claims the whole body or the whole file, which - /// transports by construction because it names no lines. - pub whole: bool, - /// Where the diff proves the range went, when it proves anything. - pub transported: Option, - /// The refusal word, or `None` when this endpoint transported. - pub reason: Option<&'static str>, - pub detail: Option, - /// The line count of the content this endpoint was checked against. - pub observed_line_count: u32, - /// The largest run of the claim that provably survived, when any did. - pub observed_surviving: Option, - /// The path the bound was observed in; the code side only. - pub path: Option, + out } -impl EndpointVerdict { - /// True when this end of the reference needs a human. - pub fn refused(&self) -> bool { - self.reason.is_some() - } - - fn as_json(&self) -> Value { - json!({ - "detail": self.detail.clone().map_or(Value::Null, Value::String), - "observedBound": { - "lineCount": self.observed_line_count, - "path": self.path.clone().map_or(Value::Null, Value::String), - "survivingRange": self - .observed_surviving - .map_or(Value::Null, |range| json!([range.start, range.end])), - }, - "reason": self.reason.map_or(Value::Null, |r| json!(r)), - "recorded": self - .recorded - .map_or(Value::Null, |range| json!([range.start, range.end])), - "transported": self - .transported - .map_or(Value::Null, |range| json!([range.start, range.end])), - "verdict": if self.refused() { "refused" } else { "transported" }, - "whole": self.whole, +/// The canonical JSON body of an fsck report. +pub fn fsck_json(findings: &[FsckFinding]) -> Value { + let entries: Vec = findings + .iter() + .map(|finding| match finding { + FsckFinding::HashMismatch { object, detail } => json!({ + "detail": detail, + "kind": "hash-mismatch", + "object": object, + }), + FsckFinding::MissingParent { event, parent } => json!({ + "event": event, + "kind": "missing-parent", + "parent": parent, + }), + FsckFinding::MissingObject { from, missing } => json!({ + "from": from, + "kind": "missing-object", + "missing": missing, + }), }) - } -} - -/// One claim the transport declined, beside the bound it was checked against. -/// -/// The bound is the point: a claim rejected without one costs the reader a -/// manual line count, and the whole reason attribution is machine-checked is so -/// nobody counts lines by hand. -/// -/// Both endpoints are always reported, each with its own verdict. A claim whose -/// prompt side was rewritten but whose code side never moved serves the -/// transported code range beside the failed prompt range, and -/// `affirmationNeeded` names only the side that actually needs a human: the -/// other one is already proven, and asking for it again would be asking the -/// author to re-type a fact. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ClaimRefusal { - /// The claim's position in the recorded entry list, which is the order the - /// refusal listing and any affirmation both use. - pub index: usize, - pub file: Option, - pub prompt: EndpointVerdict, - /// The code endpoint, absent on a law, which owns no file at all. - pub code: Option, + .collect(); + json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "findings": entries, + "healthy": findings.is_empty(), + }) } -impl ClaimRefusal { - /// The recorded prompt range, or the whole-body range placeholder. - pub fn prompt_lines(&self) -> Range { - self.prompt.recorded.unwrap_or(Range::new(1, 1)) - } - - /// The sides that need a human, in canonical order. - pub fn affirmation_needed(&self) -> Vec<&'static str> { - let mut out: Vec<&'static str> = Vec::new(); - if self.code.as_ref().is_some_and(EndpointVerdict::refused) { - out.push("code"); - } - if self.prompt.refused() { - out.push("prompt"); - } - out - } - - /// The side named at the top level: the failing one, and the prompt when - /// both failed, because a prompt rewrite is the cause a reader looks at - /// first. - pub fn side(&self) -> &'static str { - if self.prompt.refused() { - "prompt" - } else { - "code" - } - } - - fn failing(&self) -> &EndpointVerdict { - if self.prompt.refused() { - &self.prompt - } else { - self.code.as_ref().unwrap_or(&self.prompt) - } - } +// -------------------------------------------------------------- restore - /// The canonical JSON one listed claim is served as. - pub fn as_json(&self) -> Value { - let failing = self.failing(); - let prompt_lines = self.prompt_lines(); - json!({ - "affirmationNeeded": self.affirmation_needed(), - "detail": failing.detail.clone().unwrap_or_default(), - "endpoints": { - "code": self.code.as_ref().map_or(Value::Null, EndpointVerdict::as_json), - "prompt": self.prompt.as_json(), - }, - "file": self.file.clone().map_or(Value::Null, Value::String), - "lines": self - .code - .as_ref() - .and_then(|code| code.recorded) - .map_or(Value::Null, |range| json!([range.start, range.end])), - "observedBound": { - "lineCount": failing.observed_line_count, - "path": failing.path.clone().map_or(Value::Null, Value::String), - "survivingRange": failing - .observed_surviving - .map_or(Value::Null, |range| json!([range.start, range.end])), - }, - "promptLines": [prompt_lines.start, prompt_lines.end], - "reason": failing.reason.unwrap_or("region-rewritten"), - "side": self.side(), - }) - } +/// One `path -> blob` fact, from the committed state or from the working tree. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct FileFact { + pub path: String, + pub hash: String, } -/// What transporting a whole record produced: the record carried forward as far -/// as the diffs prove, and the claims that refused. -/// -/// The carried record is complete exactly when `refusals` is empty. When it is -/// not, the refused entries are still present in `record` at their recorded -/// ranges, so an affirmation can be layered onto them positionally. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TransportOutcome { - pub record: Attribution, - pub refusals: Vec, +/// One step of a repair plan. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum RestoreStep { + /// The content is present at the wrong path and exactly one candidate + /// explains it. + Move { from: String, to: String }, + /// The content is not in the working tree; reinstall it from the store. + Write { path: String, hash: String }, + /// The working tree holds a file the committed state does not own. + Remove { path: String }, } -impl TransportOutcome { - /// True when every claim was carried and no affirmation is needed. - pub fn complete(&self) -> bool { - self.refusals.is_empty() - } +/// A repair plan, or the reason there is no unambiguous one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RestorePlan { + Apply(Vec), + Blocked { + path: String, + candidates: Vec, + }, } -/// Transport every claim a recorded attribution makes onto the staged bytes and -/// the pending prompt body. +/// Derive a repair plan from persisted evidence alone. /// -/// Each claim is carried through two diffs, the prompt's and its file's, and both -/// have to prove their side. A claim whose file is absent from either tree cannot -/// be proven at all and refuses with the same honesty as a rewritten region. -pub fn transport_attribution(record: &Attribution, inputs: &TransportInputs) -> TransportOutcome { - let prompt_map = line_map(inputs.committed_body, inputs.pending_body); - let mut carried = record.clone(); - let mut refusals: Vec = Vec::new(); - - for (index, entry) in record.entries.iter().enumerate() { - // Both ends are judged, always and independently. Stopping at the first - // refusal would throw away a verdict the transport is about to compute - // anyway and would send the author to re-derive a range already proven. - let prompt = prompt_endpoint(entry, &prompt_map); - let code = code_endpoint(entry, inputs); +/// A missing file whose exact content sits at exactly one unexpected path is an +/// unambiguous move and installs. Two candidates is an ambiguity, and ambiguity +/// blocks: liveness and wall time never get a vote. +pub fn plan_restore(expected: &[FileFact], observed: &[FileFact]) -> RestorePlan { + let expected_paths: BTreeSet<&String> = expected.iter().map(|f| &f.path).collect(); + let observed_by_path: BTreeMap<&String, &String> = + observed.iter().map(|f| (&f.path, &f.hash)).collect(); - let refused = prompt.refused() || code.as_ref().is_some_and(EndpointVerdict::refused); - if refused { - refusals.push(ClaimRefusal { - index, - file: entry.file.clone(), - prompt, - code, - }); + let mut steps: Vec = Vec::new(); + for want in expected { + if observed_by_path.get(&want.path) == Some(&&want.hash) { continue; } - - if let Some(proven) = prompt.transported { - carried.entries[index].prompt_start = proven.start; - carried.entries[index].prompt_end = proven.end; - } - if let Some(proven) = code.as_ref().and_then(|verdict| verdict.transported) { - carried.entries[index].line_start = proven.start; - carried.entries[index].line_end = proven.end; + if observed_by_path.contains_key(&want.path) { + steps.push(RestoreStep::Write { + path: want.path.clone(), + hash: want.hash.clone(), + }); + continue; } - } - - TransportOutcome { - record: carried, - refusals, - } -} - -/// Judge the prompt end of one entry against the prompt's own diff. -/// -/// A whole endpoint transports by construction: it names no lines, so no diff -/// can move it. The whole body is still the whole body whatever the bytes -/// between its ends did. -fn prompt_endpoint(entry: &Entry, map: &LineMap) -> EndpointVerdict { - let recorded = Range::new(entry.prompt_start, entry.prompt_end); - if entry.whole_prompt { - return EndpointVerdict { - recorded: None, - whole: true, - transported: None, - reason: None, - detail: None, - observed_line_count: map.new_line_count(), - observed_surviving: None, - path: None, - }; - } - match transport_range(map, recorded) { - Transported::Proven(proven) => EndpointVerdict { - recorded: Some(recorded), - whole: false, - transported: Some(proven), - reason: None, - detail: None, - observed_line_count: map.new_line_count(), - observed_surviving: None, - path: None, - }, - Transported::Unproven { - refusal, surviving, .. - } => EndpointVerdict { - recorded: Some(recorded), - whole: false, - transported: None, - reason: Some(refusal.as_str()), - detail: Some(refusal.detail().to_string()), - observed_line_count: map.new_line_count(), - observed_surviving: surviving, - path: None, - }, - } -} - -/// Judge the code end of one entry against its file's diff, or `None` for a law, -/// which owns no file at all. -fn code_endpoint(entry: &Entry, inputs: &TransportInputs) -> Option { - let file = entry.file.clone()?; - let staged_len = inputs - .staged_files - .get(&file) - .map(|text| lines(text).len() as u32) - .unwrap_or(0); - - if entry.whole_file { - let present = inputs.staged_files.contains_key(&file); - return Some(EndpointVerdict { - recorded: None, - whole: true, - transported: None, - reason: (!present).then_some("file-absent"), - detail: (!present).then(|| { - format!("the claim names the whole of {file:?}, which the staged bytes do not contain") + let mut candidates: Vec = observed + .iter() + .filter(|found| found.hash == want.hash && !expected_paths.contains(&found.path)) + .map(|found| found.path.clone()) + .collect(); + candidates.sort(); + candidates.dedup(); + match candidates.len() { + 0 => steps.push(RestoreStep::Write { + path: want.path.clone(), + hash: want.hash.clone(), }), - observed_line_count: staged_len, - observed_surviving: None, - path: Some(file), - }); - } - - let recorded = Range::new(entry.line_start, entry.line_end); - let (Some(before), Some(after)) = ( - inputs.committed_files.get(&file), - inputs.staged_files.get(&file), - ) else { - return Some(EndpointVerdict { - recorded: Some(recorded), - whole: false, - transported: None, - reason: Some("file-absent"), - detail: Some(format!( - "the claim names {file:?}, which is not present in both the committed output and the staged bytes, so nothing proves where its lines went" - )), - observed_line_count: staged_len, - observed_surviving: None, - path: Some(file), - }); - }; - let map = line_map(before, after); - Some(match transport_range(&map, recorded) { - Transported::Proven(proven) => EndpointVerdict { - recorded: Some(recorded), - whole: false, - transported: Some(proven), - reason: None, - detail: None, - observed_line_count: map.new_line_count(), - observed_surviving: None, - path: Some(file), - }, - Transported::Unproven { - refusal, surviving, .. - } => EndpointVerdict { - recorded: Some(recorded), - whole: false, - transported: None, - reason: Some(refusal.as_str()), - detail: Some(refusal.detail().to_string()), - observed_line_count: map.new_line_count(), - observed_surviving: surviving, - path: Some(file), - }, - }) -} - -/// The structured facts an incomplete transport refuses with: every listed claim -/// beside its observed bound, and the flag that affirms them. -pub fn refusal_payload(module: &str, refusals: &[ClaimRefusal]) -> Value { - json!({ - "affirmationFlag": "--attribution", - "listedClaims": refusals.iter().map(ClaimRefusal::as_json).collect::>(), - "module": module, - }) -} - -/// The one sentence an incomplete transport says beside its listing. -pub fn refusal_detail(module: &str, refusals: &[ClaimRefusal]) -> String { - format!( - "{} recorded attribution claim(s) of module {module:?} could not be transported through the diff, so each is listed with both endpoints' verdicts and the bound each was checked against; affirm only the sides named in affirmationNeeded, with --attribution", - refusals.len() - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn an_untouched_text_transports_every_range_verbatim() { - let text = "a\nb\nc\nd\n"; - let map = line_map(text, text); - assert_eq!( - transport_range(&map, Range::new(2, 3)), - Transported::Proven(Range::new(2, 3)) - ); - } - - #[test] - fn an_insertion_above_a_range_shifts_it_by_the_delta() { - let map = line_map("a\nb\nc\n", "x\ny\na\nb\nc\n"); - assert_eq!( - transport_range(&map, Range::new(2, 3)), - Transported::Proven(Range::new(4, 5)) - ); - } - - #[test] - fn a_deletion_above_a_range_shifts_it_back() { - let map = line_map("a\nb\nc\nd\n", "c\nd\n"); - assert_eq!( - transport_range(&map, Range::new(3, 4)), - Transported::Proven(Range::new(1, 2)) - ); - } - - #[test] - fn a_rewritten_range_refuses_and_reports_what_survived() { - let map = line_map("a\nb\nc\nd\n", "a\nB\nC\nd\n"); - match transport_range(&map, Range::new(2, 3)) { - Transported::Unproven { - refusal, - surviving, - anchor, - } => { - assert_eq!(refusal, Refusal::RegionRewritten); - assert_eq!(surviving, None); - assert_eq!(anchor, Some(1)); + 1 => steps.push(RestoreStep::Move { + from: candidates[0].clone(), + to: want.path.clone(), + }), + _ => { + return RestorePlan::Blocked { + path: want.path.clone(), + candidates, + } } - other => panic!("a rewritten range must not transport: {other:?}"), } } - #[test] - fn content_inserted_inside_a_range_refuses_rather_than_widening_it() { - let map = line_map("a\nb\nc\n", "a\nb\nNEW\nc\n"); - match transport_range(&map, Range::new(1, 3)) { - Transported::Unproven { refusal, .. } => assert_eq!(refusal, Refusal::RangeSplit), - other => panic!("a split range must not transport: {other:?}"), + let moved: BTreeSet = steps + .iter() + .filter_map(|step| match step { + RestoreStep::Move { from, .. } => Some(from.clone()), + _ => None, + }) + .collect(); + for found in observed { + if !expected_paths.contains(&found.path) && !moved.contains(&found.path) { + steps.push(RestoreStep::Remove { + path: found.path.clone(), + }); } } - #[test] - fn a_range_past_the_end_of_the_recorded_content_refuses() { - let map = line_map("a\nb\n", "a\nb\n"); - match transport_range(&map, Range::new(2, 9)) { - Transported::Unproven { refusal, .. } => { - assert_eq!(refusal, Refusal::OutsideOldContent) - } - other => panic!("an impossible range must not transport: {other:?}"), - } - } + steps.sort(); + RestorePlan::Apply(steps) +} - /// A region the diff resolves as a deletion plus an addition is exactly the - /// ambiguity the transport declines: the old lines are gone, and whether the - /// new lines are the same claim reworded is not something a byte diff knows. - #[test] - fn a_region_read_as_a_delete_plus_an_add_refuses() { - let map = line_map("keep1\nblock\nkeep2\n", "keep1\nkeep2\nreworded\n"); - match transport_range(&map, Range::new(2, 2)) { - Transported::Unproven { refusal, .. } => assert_eq!(refusal, Refusal::RegionRewritten), - other => panic!("a delete plus add must not transport: {other:?}"), +/// The canonical JSON body of a restore plan. +pub fn restore_json(plan: &RestorePlan) -> Value { + match plan { + RestorePlan::Blocked { path, candidates } => json!({ + "candidates": candidates, + "contractVersion": crate::version::CONTRACT_VERSION, + "outcome": "blocked", + "path": path, + }), + RestorePlan::Apply(steps) => { + let entries: Vec = steps + .iter() + .map(|step| match step { + RestoreStep::Move { from, to } => { + json!({ "from": from, "op": "move", "to": to }) + } + RestoreStep::Write { path, hash } => { + json!({ "hash": hash, "op": "write", "path": path }) + } + RestoreStep::Remove { path } => json!({ "op": "remove", "path": path }), + }) + .collect(); + json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "outcome": "plan", + "steps": entries, + }) } } - - /// A block the diff still proves surviving as one run transports, even though - /// its neighbours moved: that is a fact the ancestry carries, not a guess. - #[test] - fn a_block_the_diff_proves_surviving_transports_to_where_it_survived() { - let map = line_map("head\nx\ny\ntail\n", "head\nx\ny\n"); - assert_eq!( - transport_range(&map, Range::new(2, 3)), - Transported::Proven(Range::new(2, 3)) - ); - } } -napl-store transition v1 906 -{"baseStateKey":"cda23242a5fb8aa243b577c8d7fb4e666dd23757c9c1f8919f20af2224dadec3","contractVersion":3,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"0ac0f86a77570f04d575a508bdef86c69ee4fd71c2c2e4763f098ff7f8743ec9","installPaths":["crates/napl/src/document.rs","crates/napl/src/promptbuild.rs","crates/napl/src/query.rs"],"module":"query","newState":"986a298b6293326971aff7e849b8b51340ebe3941d1b79008a16b883883ecf40","readSet":["base-state:cda23242a5fb8aa243b577c8d7fb4e666dd23757c9c1f8919f20af2224dadec3","input-key:0ac0f86a77570f04d575a508bdef86c69ee4fd71c2c2e4763f098ff7f8743ec9","target-def:2ed3fe80bffabcc2e24f4abae5f3289d37d725496f0e890b12f14a15217c9dd6"],"receipt":"66205a79dee145b87125b71a266ceb068c582c34ff7f6a7b4a75197983ce28b1","surface":"d9032b03ee7b166d66159ff59d7e98ba35a3a66b35cbae983d43f0630747ad65","target":"rust","writeSet":["owned:crates/napl","module-state:rust/query"]} -napl-store event v1 158 -{"content":"abd9678be77834afc30adb367555a39cee0d590b67d041507a4655ae4d9cd55e","parents":["ab4ebac89ef6f5459c34b98a99f6e62f834e8b01d447bfe3a2349e0f3ae3ec37"]} -napl-store tree v1 252 -{"entries":[{"hash":"f581419a200dbec0190a3d8e3451537c8071adf85cb0c8d7b0d947cfcca529ca","kind":"file","mode":33188,"name":"Cargo.toml"},{"hash":"0bae24f716e10910ab80aa7b128b67c7c57a6df7cd2c92daf447a09bd7d2dc41","kind":"dir","mode":16384,"name":"src"}]} -napl-store transition v1 843 -{"baseStateKey":"f66e5812f0b1d528c46f9377b7f6ff2595f21ee248ff0c9e799672f01b1edabd","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"4cd98e72a32ea8f99b2d6795f9c3eb10e4d987a907db9e391040710ae7d93536","installPaths":["crates/napl/src/agent.rs"],"module":"agent","newState":"3960d70a07476e0ca5ad272606f66b22890a9d9169b4b411397290260c51e4a9","readSet":["base-state:f66e5812f0b1d528c46f9377b7f6ff2595f21ee248ff0c9e799672f01b1edabd","input-key:4cd98e72a32ea8f99b2d6795f9c3eb10e4d987a907db9e391040710ae7d93536","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"2c5e5d555ec16edcc0cdbe133b51c09629a8154a714ce75baba59f0f595cca38","surface":"ad1581ae209ba3feb19322408bfff544be7a29bc6a7db44c672b705f1afcd120","target":"rust","writeSet":["owned:crates/napl","module-state:rust/agent"]} -napl-store snapshot v1 895 -{"contractVersion":4,"event":"98b42ae47d4f2e1a604af5e2dd574838431f481106ddf2ffa3ca141314664325","states":[{"module":"agent","state":"651686af1f5c9f3aad425d1ca7f0de325e4dc362cb59d9a71dc7d75f3216972c","target":"rust"},{"module":"commands","state":"02051dd93f1be5de0dcfc96ef2d9c28c8bd05c00d65c9d9b3ffca1a3fa2cf893","target":"rust"},{"module":"git-store","state":"d261cf32efb85d15c5256b019fd404387167830c5495b8ce6350d77f7cb0dcdb","target":"rust"},{"module":"napl","state":"338c9b575a24883622bf3cb1e3276c24a26c31ef34a5097927420523727b6d26","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"26e26696fa18accc362da9494d522e4ab3f3e70babc7ad776335ce8125582ef8","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} -napl-store event v1 158 -{"content":"4849348275e3c2a55836d85f5b720c855b8acd6db6a2aad4f418cf407419621d","parents":["6c76641a8b37f5f214bb69a79f10e7e2001600f0e6b2a7c6b10341322b076738"]} -napl-store blob v1 15770 +napl-store blob v1 23378 # query: the canonical document, napl q, and napl prompt The language answers every question through one query surface and builds @@ -221577,7 +220223,10 @@ Each module carries `name`, `promptPath`, `deps`, `targets`, `surfaceHash`, `pending`, `attribution` (an object whose `status` is `fresh`, `stale`, or `absent`), `sections`, `frontmatterCases` (cases declared in frontmatter `tests:`, which have no body position), and -`files` (owned output paths). Sections are the module's attribution +`files` (owned output paths), and `outline` — the prompt body's +headings in order, each `{text, level, line}` with the line +body-normalized and 1-based, so "which body line is this heading on" +is a query, never a raw file read. Sections are the module's attribution entries, and every section is a SELF-CONTAINED REF: it carries `head`, `module`, `promptPath`, `lines` (body-normalized, 1-based, inclusive), `note`, `law` (true exactly when the entry owns no file), `files` (each @@ -221597,11 +220246,39 @@ each section carries `associationStale: true` and the module's status says `stale`: the section-to-file pairing cannot be trusted, the case data can. +Each file ref inside a section carries `claim`, `whole` when the entry +owns the file with no line range and `range` when it names one, so a +consumer never guesses whether `lines` bounds the claim or merely +samples it. + +Docs fences from the napl module's directive law surface here per +section: `sections[].docs` is the ordered list of docs payloads whose +fence sits inside the section's line range, each +`{directive, attrs, lines, body}` with inner fences verbatim in the +body. The module preamble fence, when present, is served at +`modules[].docs` beside the sections. Docs `lines` are physical prompt +coordinates, the one place physical coordinates appear in the +document, because docs fences are excluded from the semantic body that +every other coordinate speaks in; the document states both coordinate +systems explicitly. A module with no docs fences serves empty docs +lists, never null. + +The document's freshness verdicts and the language server's are one +analysis: a section the LSP would squiggle as reference-stale reads +`referenceStatus: stale` here with the same endpoints, never a +hardcoded fresh. + History entries carry the transition hash, module, target, outcome, and `usage` — `{model, input, output, cacheRead}` from the staged record's validated usage evidence, or null when none was supplied; absent telemetry is null, never zero. +A history entry carries the transition's kind: an ordinary install +says `installed`, and a docs-only transition says `docs-only` as both +its `kind` and its `outcome`, so any reader of the document tells +prose history from semantic history without dereferencing the +transition object. + Canonical serialization throughout: sorted keys, total ordering for every array (modules by name; sections by start then end line; files and cases by path and name; history by transition sequence, transition hash as @@ -221645,28 +220322,77 @@ given: { workspace: fixture_two_modules, commit_with_usage: true } expect: { history_usage_fields: [cacheRead, input, model, output], absent_usage: null } ::: -## napl q +::: test +name: a docs-only transition is told apart in history without dereferencing the transition +given: { workspace: fixture_two_modules, then: [docs_edit, add_prompt_path, commit] } +expect: { last_history_kind: docs-only, last_history_outcome: docs-only, ordinary_entries_say: installed } +::: -`napl q` is shaped exactly like jq and IS jq: expressions are evaluated -by an embedded jq engine (the jaq crate, version pinned by the lockfile, -upgraded only through a normal committed change). Impure builtins are -rejected at expression compile time (`env`, `$ENV`, `now`, `input`, -`inputs`, `debug`, `stderr`, `input_line_number`, and anything else doing -I/O or randomness), so an expression is a pure function of its input. +::: test +name: a section's docs payloads arrive in order with their bodies verbatim +given: { workspace: fixture_with_docs_fences } +expect: { docs_fields: [attrs, body, directive, lines], order_preserved: true } +::: -Argv grammar, exactly four forms; more than one input is a usage error; -`--` is accepted before a path starting with a dash; there is -deliberately no shorthand for identity over a file or stdin (write `.`): +::: test +name: a module preamble fence serves title and description in module docs +given: { workspace: fixture_with_module_preamble } +expect: { module_docs_attrs: [description, title] } +::: + +::: test +name: a module with no docs fences serves empty docs lists +given: { workspace: fixture_two_modules } +expect: { module_docs: [], section_docs_empty: true, nulls: 0 } +::: + +::: test +name: file refs carry the claim discriminator +given: { workspace: fixture_two_modules } +expect: { file_claims_subset_of: [range, whole] } +::: + +::: test +name: reference staleness in the document matches the language server verdict +given: { workspace: fixture_with_stale_reference } +expect: { reference_status: stale, lsp_parity: true, hardcoded_fresh: false } +::: + +## napl q + +`napl q` is shaped exactly like jq and IS jq: expressions are evaluated +by an embedded jq engine (the jaq crate, version pinned by the lockfile, +upgraded only through a normal committed change). Impure builtins are +rejected at expression compile time (`env`, `$ENV`, `now`, `input`, +`inputs`, `debug`, `stderr`, `input_line_number`, and anything else doing +I/O or randomness), so an expression is a pure function of its input. + +Argv grammar, exactly three forms; more than one input is a usage +error; `--` is accepted before a path starting with a dash; the +expression is always required and there is deliberately no shorthand +for identity anywhere, because the primary caller is a language model +session and a bare invocation that dumps the whole document is a +context bomb, not a convenience; the usage error for a missing +expression teaches `.` as the explicit spelling and jq demands its +filter the same way: - napl q identity over the canonical document napl q expression over the canonical document napl q - expression over stdin napl q expression over a JSON file Input may be a single JSON value or a stream of values (JSONL); the expression applies per value exactly as jq does. Each result value is -canonically serialized, one per line. An empty result stream is exit 0 -with no output. Errors are canonical JSON on stderr with pinned exit +canonically serialized, one per line; `-r`/`--raw-output` emits string +results as their raw contents exactly as jq does, one result per +output line with exactly one newline between results and one at the +end, never doubled when the string itself ends in one, so shell +pipelines never need a dequoting round trip and a raw result diffs +clean against the file it came from. An empty result stream is exit 0 +with no output. Because canonical serialization is already one compact +value per line, jq's `-c`/`--compact-output` is accepted as a +harmless synonym of the default: it changes nothing and never errors, +so the jq reflex costs no usage round trip; `-c` composes with `-r` +exactly as it does in jq, where raw string output ignores it. Errors are canonical JSON on stderr with pinned exit codes: 2 usage, 3 expression parse or refused builtin, 4 input parse, 5 runtime or type error. Evaluation carries a compiled-in output ceiling of 64 MiB, enforced functionally and surfaced as `resource-limit` with its @@ -221682,6 +220408,24 @@ given: { workspace: fixture_two_modules, expr: ".modules[].name" } expect: { output_lines: [consumer, leaf] } ::: +::: test +name: q accepts the compact flag as a synonym of the default output +given: { workspace: fixture_two_modules, invocations: ["q .pending", "q -c .pending", "q --compact-output .pending"] } +expect: { outputs_byte_identical: true, exit_codes: [0, 0, 0] } +::: + +::: test +name: q without an expression is a usage error teaching the explicit dot +given: { workspace: fixture_two_modules, invocations: ["q", "q -c", "q -r"] } +expect: { exit_codes: [2, 2, 2], error_teaches_dot: true, document_dumped: false } +::: + +::: test +name: q compact composes with raw output changing nothing +given: { stdin: "{\"a\":\"x\"}", expr: ".a", input: "-", flags: ["-r", "-c -r"] } +expect: { outputs_byte_identical: true, raw_string_unquoted: true } +::: + ::: test name: q evaluates an expression over stdin when the input is a dash given: { stdin: "{\"pending\":[\"m\"]}", expr: ".pending", input: "-" } @@ -221700,6 +220444,18 @@ given: { workspace: fixture_two_modules, expr: "now" } expect: { exit_code: 3, error_names_builtin: true } ::: +::: test +name: q raw output emits string contents without quotes exactly as jq dash r +given: { workspace: fixture_two_modules, expr: ".modules[0].name", flags: [-r] } +expect: { output: leaf_unquoted, trailing_newline: true } +::: + +::: test +name: the document serves a heading outline with body line numbers +given: { workspace: fixture_two_modules } +expect: { outline_present: true, outline_entry_fields: [level, line, text], lines_body_normalized: true } +::: + ## napl prompt `napl prompt` (alias `napl p`) is the prompt builder: it reads refs from @@ -221802,6 +220558,15 @@ states, history, receipts. The top level carries `workingTree: true`, and each section carries `workingTree: true` in place of a committed `head` value, so a working-tree ref can never be mistaken for a committed one and no hash is ever invented for uncommitted bytes. +Section file associations are the committed map's claims transported +through the prompt's committed-to-pending diff, the same deterministic +transport `add` uses: an association whose prompt range transports +cleanly is served intact, and a section whose range lands in a +rewritten or ambiguous region serves `files: []` with +`associationUnproven: true`. The view never re-associates files by +range arithmetic over the re-anchored body; an overlap that happens to +exist in the pending text is not a claim, and serving it as one would +be inventing a mapping nobody made. Section-to-file association under a stale attribution behaves exactly as today: sections carry their cases, `associationStale: true` marks the pairing untrustworthy, and nothing pretends freshness. Reference @@ -221828,13 +220593,25 @@ so retrieve-then-splice is deterministic by construction. before `add`: frontmatter shape and module-name rules per the napl contract, body normalization, directive grammar for every `::: test` block including canonical JSON in `given` and `expect`, and case-name -uniqueness. Findings are canonical JSON on stdout, each naming the file, -the line, and what failed. Exit codes are pinned: 0 for a valid prompt, +uniqueness. The module-name rules are enforced as name grammar, not +merely as YAML: a frontmatter value that parses as a string but can +never be a legal module name (path-metacharacters, a bare `[`, empty +after trimming) is a finding at check time, never a surprise later. +Findings are canonical JSON on stdout, each naming the file, +the line, and what failed; every reported line is the physical +1-based line in the file as an editor counts it, frontmatter +included, never a body-normalized offset. Exit codes are pinned: 0 for a valid prompt, 1 when findings exist, 2 for usage including an unreadable path. The store is never touched and nothing stages. `check` passing means the prompt parses under the same reader `add` uses; it does not predict `add` outcomes that depend on candidates, attribution, or placement, and -its wording never implies otherwise. +its wording never implies otherwise. `check` teaches while it refuses: +the finding for a missing opening frontmatter fence carries one +complete minimal valid prompt inline, so the first error a cold user +sees contains the whole grammar they were missing; and a checked file +whose name lacks the `.napl` extension gets a finding saying discovery +will never find it, naming the extension, whatever else is valid about +its content. ::: test name: q working-tree enumerates a section that exists only in the working tree @@ -221854,6 +220631,12 @@ given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_se expect: { bytes_equal: true } ::: +::: test +name: a transported section keeps its files and a rewritten one serves none unproven +given: { workspace: fixture_two_modules, working_tree_edit: shift_one_section_rewrite_another, flags: [--working-tree] } +expect: { shifted_section_files_intact: true, rewritten_section_files: [], rewritten_section_association_unproven: true, overlap_reassociation: 0 } +::: + ::: test name: prompt bytes round-trips the exact source bytes of a range given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf, flags: [--bytes] } @@ -221883,5511 +220666,5528 @@ name: check reports a malformed directive with a finding naming its line given: { workspace: fixture_two_modules, prompt: leaf_prompt_broken_directive } expect: { exit_code: 1, finding_names_line: true, store_bytes_unchanged: true } ::: -napl-store blob v1 7885 -//! Lint diagnosis: proposals with cascade previews, never edits. -//! -//! Two of the three analyses are diagnosis only. The third — an unreferenced -//! declared dependency — is the one class of finding whose repair is complete, -//! deterministic, and mapping-safe, so it is the only one `--fix` may apply, and -//! even then only through the guarded commit path. -//! -//! The unreachable-module analysis refuses to run without an explicit `roots` -//! declaration. Guessing the roots would make "this module is dead" a statement -//! about the guess rather than about the workspace. - -use std::collections::{BTreeMap, BTreeSet}; - -use serde_json::{json, Value}; - -use crate::graph::{dependents_of, reachable_from, Graph}; - -/// Whether the unreachable-module analysis ran. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Analysis { - Performed, - /// No `roots:` declaration exists, so the analysis is refused. - Refused, -} - -/// One lint finding. Every finding is a proposal. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Finding { - /// A module no declared root reaches. - UnreachableModule { - module: String, - /// What else would fall out of the graph if this module were removed. - cascade: Vec, - }, - /// A recorded surface entry no dependent names. - UnconsumedSurfaceEntry { module: String, path: String }, - /// A module whose current `inputKey` differs from its committed one. - StaleModule { module: String }, - /// A declared dependency whose surface the module never names. - UnusedDependency { module: String, dep: String }, -} - -/// The result of a lint pass. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LintReport { - pub unreachable_analysis: Analysis, - pub findings: Vec, -} - -/// Everything the lint pass reads, all of it supplied by the caller. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LintInput { - /// The declared reachability roots, `None` when no source declares any. - pub roots: Option>, - /// Each module's committed `inputKey`, absent when it has never committed. - pub committed_input_keys: BTreeMap>, - /// Each module's `inputKey` as recomputed now. - pub current_input_keys: BTreeMap, - /// The identifiers each module's committed output names. - pub referenced_names: BTreeMap>, - /// The leaf names each module's recorded surface exposes. - pub surface_names: BTreeMap>, -} - -/// Run every analysis over a graph. -pub fn lint(graph: &Graph, input: &LintInput) -> LintReport { - let mut findings: Vec = Vec::new(); - - let analysis = match &input.roots { - None => Analysis::Refused, - Some(roots) => { - let live = reachable_from(graph, roots); - for module in graph.keys() { - if !live.contains(module) { - findings.push(Finding::UnreachableModule { - module: module.clone(), - cascade: cascade_preview(graph, module, &live), - }); - } - } - Analysis::Performed - } - }; - for (module, current) in &input.current_input_keys { - let committed = input - .committed_input_keys - .get(module) - .and_then(Option::as_deref); - if committed != Some(current.as_str()) { - findings.push(Finding::StaleModule { - module: module.clone(), - }); - } - } +::: test +name: the missing-fence finding carries a complete minimal prompt that itself passes check +given: { workspace: fixture_two_modules, prompt: file_without_frontmatter } +expect: { exit_code: 1, finding_example_passes_check: true } +::: - for (module, declared) in graph { - let names = input.referenced_names.get(module).cloned().unwrap_or_default(); - for dep in &declared.deps { - let exposed = input.surface_names.get(dep).cloned().unwrap_or_default(); - // A dependency with an empty surface is not evidence of disuse: there - // is nothing it could have exposed to be named. - if !exposed.is_empty() && exposed.is_disjoint(&names) { - findings.push(Finding::UnusedDependency { - module: module.clone(), - dep: dep.clone(), - }); - } - } - } +::: test +name: a checked file without the napl extension is told discovery will never find it +given: { workspace: fixture_two_modules, prompt: valid_content_named_greeting_md } +expect: { exit_code: 1, finding_names_extension: ".napl", store_bytes_unchanged: true } +::: - for (module, exposed) in &input.surface_names { - let consumers = dependents_of(graph, module); - for name in exposed { - let consumed = consumers.iter().any(|consumer| { - input - .referenced_names - .get(consumer) - .is_some_and(|names| names.contains(name)) - }); - if !consumed { - findings.push(Finding::UnconsumedSurfaceEntry { - module: module.clone(), - path: name.clone(), - }); - } - } - } +::: test +name: a module name that yaml accepts but the grammar forbids is a finding +given: { workspace: fixture_two_modules, prompt: frontmatter_module_bare_bracket } +expect: { exit_code: 1, finding_names_module_grammar: true } +::: - findings.sort_by_key(finding_sort_key); - LintReport { - unreachable_analysis: analysis, - findings, - } -} +::: test +name: findings report physical file lines as an editor counts them +given: { workspace: fixture_two_modules, prompt: empty_module_value_on_file_line_2 } +expect: { finding_line: 2, line_kind: physical_one_based } +::: +napl-store blob v1 23619 +:::docs{title="Querying the store" description="Read everything napl knows with one verb."} +Everything napl knows lives in one canonical JSON document: modules, +sections, owned files, attribution, test cases, history. `napl q` +evaluates a jq expression against that document, and `napl prompt` +builds agent context from the same tree. There is no other read +surface to learn, ever. +::: +# query: the canonical document, napl q, and napl prompt -/// The modules that would also leave the graph if `module` were removed. -fn cascade_preview(graph: &Graph, module: &str, live: &BTreeSet) -> Vec { - let mut out: Vec = dependents_of(graph, module) - .into_iter() - .filter(|name| !live.contains(name)) - .collect(); - out.sort(); - out -} +The language answers every question through one query surface and builds +agent context through one prompt builder. This module owns both commands +and the document they share. Everything here is a pure read: no store +writes, no locks held, no clock, no randomness, no network (P1). Reads +serve committed records, with two stated exceptions: reference staleness +is computed at read time from the working tree as well as the committed +bytes, so a hand edit to either end of a reference reads stale without +anything being stored; and the working-tree read mode below serves the +pending prompt when asked to explicitly. The same committed state plus +the same working tree produces the same bytes. This module owns `src/query.rs` (taken over +from the commands module), plus the document builder and prompt builder +sources it introduces. -fn finding_sort_key(finding: &Finding) -> (u8, String, String) { - match finding { - Finding::UnreachableModule { module, .. } => (0, module.clone(), String::new()), - Finding::StaleModule { module } => (1, module.clone(), String::new()), - Finding::UnusedDependency { module, dep } => (2, module.clone(), dep.clone()), - Finding::UnconsumedSurfaceEntry { module, path } => (3, module.clone(), path.clone()), - } -} +## The canonical document -/// True when a finding's repair is complete, deterministic, and mapping-safe. -/// -/// Only these reach `napl lint --fix`, and the fix rewrites the `deps:` value in -/// place: it edits one line and removes none. -pub fn is_auto_fixable(finding: &Finding) -> bool { - matches!(finding, Finding::UnusedDependency { .. }) -} +One JSON value, `docVersion 1`, built at query time from committed state: +the store head, module states, attribution records, staged-evidence usage +inside receipts, recorded surfaces, and the committed prompt bytes. Top +level: `docVersion`, `head` (the snapshot hash), `modules`, `history`, +`state`. -/// The canonical JSON body of a lint report. -pub fn lint_json(report: &LintReport) -> Value { - let findings: Vec = report.findings.iter().map(finding_json).collect(); - json!({ - "contractVersion": crate::CONTRACT_VERSION, - "findings": findings, - "unreachableAnalysis": match report.unreachable_analysis { - Analysis::Performed => "performed", - Analysis::Refused => "refused", - }, - }) -} +Each module carries `name`, `promptPath`, `deps`, `targets`, +`surfaceHash`, `pending`, `attribution` (an object whose `status` is +`fresh`, `stale`, or `absent`), `sections`, `frontmatterCases` (cases +declared in frontmatter `tests:`, which have no body position), and +`files` (owned output paths), and `outline` — the prompt body's +headings in order, each `{text, level, line}` with the line +body-normalized and 1-based, so "which body line is this heading on" +is a query, never a raw file read. Sections are the module's attribution +entries, and every section is a SELF-CONTAINED REF: it carries `head`, +`module`, `promptPath`, `lines` (body-normalized, 1-based, inclusive), +`note`, `law` (true exactly when the entry owns no file), `files` (each +`{path, lines, head}`), `direction` (`prompt_to_code` or +`code_to_prompt`, per the reference contract in the napl module; entries +recorded before directions read `prompt_to_code`), `provenance` (an +object with `model`, `timestamp`, `toolVersion`, and `usage`, each +absent field served as `unavailable`, never invented), `referenceStatus` +(`fresh` or `stale`), `staleEndpoints` (empty when fresh, `code` and +`prompt` when stale), and `cases` — the `::: test` directive cases +whose block range intersects the section's range, each +`{name, given, expect, lines}` with values in canonical JSON. A directive +case intersecting no entry lands in a synthetic section with empty +`files`. When a module's attribution is stale, sections still carry +their cases (cases derive from prompt bytes, not from attribution) but +each section carries `associationStale: true` and the module's status +says `stale`: the section-to-file pairing cannot be trusted, the case +data can. -/// The canonical JSON body of one finding, including its cascade preview. -pub fn finding_json(finding: &Finding) -> Value { - match finding { - Finding::UnreachableModule { module, cascade } => json!({ - "autoFixable": false, - "cascade": cascade, - "kind": "unreachable-module", - "module": module, - "proposal": format!("remove the unreachable module {module:?}"), - }), - Finding::UnconsumedSurfaceEntry { module, path } => json!({ - "autoFixable": false, - "cascade": Vec::::new(), - "kind": "unconsumed-surface-entry", - "module": module, - "path": path, - "proposal": format!("no dependent names {path:?}; diagnosis only"), - }), - Finding::StaleModule { module } => json!({ - "autoFixable": false, - "cascade": Vec::::new(), - "kind": "stale-module", - "module": module, - "proposal": format!("regenerate {module:?}: its inputKey has moved"), - }), - Finding::UnusedDependency { module, dep } => json!({ - "autoFixable": true, - "cascade": Vec::::new(), - "dep": dep, - "kind": "unused-dependency", - "module": module, - "proposal": format!("drop the unreferenced dependency {dep:?} from {module:?}"), - }), - } -} -napl-store blob v1 61409 -# NAPL: the deterministic compiler +Each file ref inside a section carries `claim`, `whole` when the entry +owns the file with no line range and `range` when it names one, so a +consumer never guesses whether `lines` bounds the claim or merely +samples it. -This prompt describes the entire NAPL toolchain, rebuilt as one small Cargo -workspace of exactly three crates. It replaces the legacy 76-crate -implementation completely; git history holds the original. The governing -contract is docs/specs/2026-07-27-deterministic-compiler-plan.md rev 4; where -this prompt compresses it, the spec's definition wins. +Docs fences from the napl module's directive law surface here per +section: `sections[].docs` is the ordered list of docs payloads whose +fence sits inside the section's line range, each +`{directive, attrs, lines, body}` with inner fences verbatim in the +body. The module preamble fence, when present, is served at +`modules[].docs` beside the sections. Docs `lines` are physical prompt +coordinates, the one place physical coordinates appear in the +document, because docs fences are excluded from the semantic body that +every other coordinate speaks in; the document states both coordinate +systems explicitly. A module with no docs fences serves empty docs +lists, never null. -## Layout: this module generates two crates on top of the ported store +The document's freshness verdicts and the language server's are one +analysis: a section the LSP would squiggle as reference-stale reads +`referenceStatus: stale` here with the same endpoints, never a +hardcoded fresh. -The workspace layout is fixed: the records directory `.napl/` lives at the -repository root; generated code installs under `crates/`; a module's -prompt file lives inside the crate directory that receives its code, and -the crate is the folder: every module whose `.napl` file sits in -`crates//` compiles into the `` crate as its own source -module, so one crate may be described by several prompt files -(`crates/napl/napl.napl` and `crates/napl/commands.napl` both build -`crates/napl/`). Module discovery finds prompts by walking the workspace -for `*.napl` files, and the workspace root is the nearest ancestor -directory containing `.napl`. A subtree that contains its own `.napl` -directory is a separate workspace: discovery never descends into it, the -way git never descends into a nested repository. Hidden directories and -`node_modules` are never descended into. +History entries carry the transition hash, module, target, outcome, and +`usage` — `{model, input, output, cacheRead}` from the staged record's +validated usage evidence, or null when none was supplied; absent +telemetry is null, never zero. -The proven store already exists as a hand-ported crate at -`crates/napl-store/` (same envelope format `napl-store v1 -\n`, SHA-256 names, canonical JSON payloads, same object -kinds, same `.napl/store/` on-disk layout with HEAD compare-and-swap, -module-keyed locks, and dead-holder steal, ported code and tests from the -legacy store crates). Use its public API through a path dependency; do not -reimplement, wrap thinly, or fork any of its types or logic. The four new -object kinds the compiler needs (`transition`, `receipt`, `surface`, -`target-def`) already exist in it additively. +Canonical serialization throughout: sorted keys, total ordering for every +array (modules by name; sections by start then end line; files and cases +by path and name; history by transition sequence, transition hash as +tiebreaker), no absolute paths anywhere (`promptPath` and file paths are +workspace-relative), one trailing newline. The same committed state +produces the same document bytes on any machine. ::: test -name: the ported store hashes objects exactly as the original store crates did -given: { vectors: ported_store_hash_test_corpus } -expect: { all_pass_unmodified: true } +name: q with no arguments emits the canonical document byte-stably +given: { workspace: fixture_two_modules, runs: 2 } +expect: { bytes_equal: true, top_level_keys: [docVersion, head, history, modules, state] } ::: ::: test -name: identical payload under two object kinds gets two different names -given: { payload: "X", kind_a: blob, kind_b: snapshot } -expect: { equal: false } +name: the document contains no absolute paths +given: { workspace: fixture_two_modules } +expect: { absolute_paths: 0 } ::: -This module's generated output is as many crates as the design needs, side -by side in the module's owned directory. Keep it a small, clearly separated -workspace (this replaces a 76-crate explosion; do not recreate one), with -`napl-store` staying its own untouched crate. The natural split, which you -may adjust if the design genuinely wants another boundary: - -1. **`napl-core/`** (lib): the pure compiler. Parsing `.napl` files (YAML - frontmatter: module, deps, targets, tests; markdown body), discovery and - dependency graph, canonical path grammar, the identity vocabulary, - per-module artifact slices, wave planning, tree-sitter surface extraction - for Rust and TypeScript targets, the surface grammar, placement rules - and entry-file projection derivation, receipt verification logic, the commit - decision table as pure functions, blame line-ancestry replay, lint - diagnosis, cost calculation, evidence validation. No filesystem writes, - no locks, no clocks, no environment reads: everything enters as - arguments and leaves as values. -2. **`napl-cli/`** (bin `napl`): every command, each a thin composition of - napl-core derivations and napl-store reads plus the one guarded write - path. No private logic in the binary beyond argument parsing and - formatting. - -The crates depend on `napl-store` by path, `serde_json`, `sha2`, -`tree-sitter`, `tree-sitter-rust`, `tree-sitter-typescript`, `clap` -(builder API only, driving the command line contract below), and nothing -that talks to a network, spawns a process, or reads clocks; those -declarations live in the user-owned manifests, not in any module's -output. Ensure `cargo test` passes for both crates and `napl-store` from -their workspace before finishing. Generated code is authored against its -final installed location, never a staging tree: acceptance runs at the -final location, so green certifies the bytes where they live. The -working-tree add forms are the operational spelling of this law: author -and verify in place, then stage from where the bytes live; assembling a -separate candidate directory is the exception, for staging bytes that -are not the working tree, never the routine. - -When several modules share one crate, the crate's shared entry files are -derived projections owned by no module: the crate root `lib.rs` module -list is recomputed at commit from the union of the crate's committed -module file sets, and the binary's command registry is recomputed from -the command tables the crate's modules expose under one fixed convention -(each module may expose `pub fn commands() -> &'static [CommandSpec]` -from its own files; the dispatch loop iterates the derived registry and -contains no per-command match arms of its own). A module's owned output -therefore never includes the shared entry files, two modules can never -claim the same path, and a commit that would write a projection-owned -path is rejected as invalid-candidate. - -A prompt at the workspace root is legal and means the workspace root is -the crate directory, exactly as git treats the directory it was -initialized in: the placement root is the empty workspace-relative path, -every workspace-relative path is inside it, and every join against it -produces clean relative paths, never a fabricated leading separator. A -cold workspace whose first prompt sits beside `.napl` therefore commits -its first module with no configuration; placement never needs declaring -because the prompt's location is the declaration. - ::: test -name: a root-level prompt places at the workspace root and its module commits -given: { workspace: fresh_init_root_prompt, candidate: valid_root_level } -expect: { placement_root: "", owned_globs_relative: true, outcome: installed } +name: directive cases appear inside their owning section +given: { workspace: fixture_with_directive_case, attribution: fresh } +expect: { section_cases: 1, case_fields: [expect, given, lines, name] } ::: ::: test -name: status for a root-level module reports a workspace-root plan with clean globs -given: { workspace: fresh_init_root_prompt, flag: module_root } -expect: { plan_placement_root: "", owned_globs_leading_separator: false } +name: stale attribution keeps cases and marks the association stale +given: { workspace: fixture_with_directive_case, attribution: stale } +expect: { section_cases: 1, association_stale: true, module_status: stale } ::: -## The command line contract - -Every command the registry serves declares its full grammar in its -`CommandSpec`: name, a one-line summary, the exact usage line, and the -closed table of flags it accepts, each with arity and meaning. The -dispatch loop owns two behaviors ahead of every command, so no command -can get them wrong. First, help: `napl help` lists every registered -command with its summary, `napl help ` and `--help` or `-h` -anywhere in a command's arguments print that command's usage line, flag -table, and summary, exit 0, and never invoke the command. Help is a pure -read: no store access, no filesystem write, no state change of any kind, -and this holds for every command including `init` and `watch`. Second, -strict parsing: an argument beginning with `--` that the command's flag -table does not name is a usage error, never ignored; the error goes to -stderr as canonical JSON naming the unknown flag, listing the command's -valid flags, and suggesting the nearest declared flag by edit distance -when one is close, and the command does not execute. Exit 2 is the -pinned usage-error code for the whole binary: unknown command, unknown -flag, missing required flag or positional, malformed flag value. A -usage error always restates the command's exact usage line, so the -error itself teaches the grammar. - -The parser behind this contract is clap's builder API, constructed at -dispatch from the CommandSpec registry, never derive macros and never -per-command clap declarations: the registry stays the single source of -grammar and clap is its execution engine. clap's own renderings are -never emitted; every parse failure is caught and served as the -canonical JSON usage error above, byte-shaped by this contract alone. -`--flag value` and `--flag=value` parse identically; no command -declares short flags today, so clustering is outside the contract -until one does. The registry pre-pass, not clap, is what refuses an -undeclared flag, because the canonical JSON payload with its -nearest-name suggestion is pinned by this contract; clap normalizes -spellings and values behind it. `napl completions ` emits a completion script for -`bash`, `zsh`, or `fish`, generated from the same registry, a pure -read on stdout; an unknown shell name is a usage error. - -The commands whose meaning requires a committed head are exactly -`diff`, `blame`, and `notes add`; in a workspace that has none they -answer with a prerequisite error, never an empty success shape: the -payload names what is missing and the exact sequence that creates it, -from `napl init` through writing a prompt beside `.napl`, `add`, and -`commit`. `log` and `show` keep their committed contracts and answer -empty honestly. `napl blame` on a path no committed module owns, and -`napl diff` for a target under which no committed module owns output, -say so: the payload carries `tracked: false` and a reason naming the -absent baseline, never an empty patch or empty ancestry that reads as -success. The `napl notes add` refusal for a working path -that is not an object name states how an object name is obtained, -naming `napl log` and `napl show` with a one-line example. - ::: test -name: flag equals value and separated value parse identically -given: { workspace: fixture_two_modules, invocations: ["status --module leaf", "status --module=leaf"] } -expect: { outputs_byte_identical: true } +name: section refs are self-contained +given: { workspace: fixture_two_modules } +expect: { section_fields_present: [direction, head, lines, module, promptPath, provenance, referenceStatus, staleEndpoints] } ::: ::: test -name: completions emits a script for a known shell and refuses an unknown one -given: { workspace: fixture_two_modules, invocations: ["completions zsh", "completions tcsh"] } -expect: { zsh_exit_code: 0, zsh_output_nonempty: true, tcsh_exit_code: 2, store_bytes_unchanged: true } +name: usage from staged evidence appears in history entries +given: { workspace: fixture_two_modules, commit_with_usage: true } +expect: { history_usage_fields: [cacheRead, input, model, output], absent_usage: null } ::: ::: test -name: blame on an unowned path and diff on an outputless target say untracked -given: { workspace: fixture_two_modules, blame_path: file_no_module_owns, diff_target: target_without_outputs } -expect: { tracked: false, reason_names_baseline: true, exit_code: 0 } +name: a section's docs payloads arrive in order with their bodies verbatim +given: { workspace: fixture_with_docs_fences } +expect: { docs_fields: [attrs, body, directive, lines], order_preserved: true } ::: ::: test -name: status reports code-side drift and stale references beside prompt freshness -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } -expect: { module_drift: true, stale_reference_count: 1, prompt_side_fresh: true } +name: a module preamble fence serves title and description in module docs +given: { workspace: fixture_with_module_preamble } +expect: { module_docs_attrs: [description, title] } ::: ::: test -name: a headless workspace answers baseline commands with the prerequisite sequence -given: { workspace: fresh_init_no_commits, invocation: "blame some/file.rs" } -expect: { error_names_missing_head: true, sequence_named: [init, prompt, add, commit] } +name: a module with no docs fences serves empty docs lists +given: { workspace: fixture_two_modules } +expect: { module_docs: [], section_docs_empty: true, nulls: 0 } ::: ::: test -name: init with a help flag prints usage and mutates nothing -given: { directory: empty, invocation: "init --help" } -expect: { exit_code: 0, created: [], usage_printed: true } +name: file refs carry the claim discriminator +given: { workspace: fixture_two_modules } +expect: { file_claims_subset_of: [range, whole] } ::: ::: test -name: an unknown flag is a usage error naming the flag and suggesting the nearest -given: { workspace: fixture_two_modules, invocation: "add cand --modul m" } -expect: { exit_code: 2, error_names_unknown: "--modul", suggested: "--module", executed: false } +name: reference staleness in the document matches the language server verdict +given: { workspace: fixture_with_stale_reference } +expect: { reference_status: stale, lsp_parity: true, hardcoded_fresh: false } ::: -::: test -name: help lists every registered command and per-command help states exact usage -given: { workspace: fixture_two_modules, invocations: ["help", "help add"] } -expect: { all_commands_listed: true, add_usage_line_present: true, exit_code: 0, store_bytes_unchanged: true } -::: +## napl q -Manifests are user-owned glue seams, not records: `Cargo.toml` at the -workspace root and in every crate belongs to the user, edited by hand -like any Rust project, tracked by git, consumed by cargo, and NEVER -owned, installed, validated, or rewritten by the toolchain. A candidate -containing a manifest path is rejected as invalid-candidate; no module's -owned output includes one; nothing in the store records manifest bytes. -The toolchain's only relationship to manifests is diagnosis: `status` -reports, as observations never as writes, a module placed in a crate the -workspace manifest does not list, or a crate directory missing a -manifest. Every manifest diagnostic names the exact workspace-relative -manifest path it means and says the file is the user's to edit, so the -manifest is never a thing status alludes to without locating. A dependency needed by generated code is added by editing the -manifest, and the build battery is what proves it correct. +`napl q` is shaped exactly like jq and IS jq: expressions are evaluated +by an embedded jq engine (the jaq crate, version pinned by the lockfile, +upgraded only through a normal committed change). Impure builtins are +rejected at expression compile time (`env`, `$ENV`, `now`, `input`, +`inputs`, `debug`, `stderr`, `input_line_number`, and anything else doing +I/O or randomness), so an expression is a pure function of its input. -::: test -name: a candidate containing a manifest path is rejected -given: { workspace: fixture_two_modules, candidate: contains_cargo_toml } -expect: { outcome: invalid_candidate, manifest_bytes_unchanged: true } -::: +Argv grammar, exactly three forms; more than one input is a usage +error; `--` is accepted before a path starting with a dash; the +expression is always required and there is deliberately no shorthand +for identity anywhere, because the primary caller is a language model +session and a bare invocation that dumps the whole document is a +context bomb, not a convenience; the usage error for a missing +expression teaches `.` as the explicit spelling and jq demands its +filter the same way: -## The two principles + napl q expression over the canonical document + napl q - expression over stdin + napl q expression over a JSON file -P1 ownership: the toolchain owns `.napl` source, the derived layer, the -store, and the mappings between them; every owned fact is provable from -content alone. It contains no LLM invocation, no provider identifier, no -model selection, no completion parsing, no process execution (the only -process is `napl` itself), no clock reads in any semantic path, no -randomness in any semantic path. Generation happens outside: the user's -agent session reads the plan from `napl status`, the specification through -the query surface, writes candidate code, and calls `napl add` then -`napl commit`. +Input may be a single JSON value or a stream of values (JSONL); the +expression applies per value exactly as jq does. Each result value is +canonically serialized, one per line; `-r`/`--raw-output` emits string +results as their raw contents exactly as jq does, one result per +output line with exactly one newline between results and one at the +end, never doubled when the string itself ends in one, so shell +pipelines never need a dequoting round trip and a raw result diffs +clean against the file it came from. An empty result stream is exit 0 +with no output. Because canonical serialization is already one compact +value per line, jq's `-c`/`--compact-output` is accepted as a +harmless synonym of the default: it changes nothing and never errors, +so the jq reflex costs no usage round trip; `-c` composes with `-r` +exactly as it does in jq, where raw string output ignores it. Errors are canonical JSON on stderr with pinned exit +codes: 2 usage, 3 expression parse or refused builtin, 4 input parse, 5 +runtime or type error. Evaluation carries a compiled-in output ceiling of +64 MiB, enforced functionally and surfaced as `resource-limit` with its +existing exit code; recursion depth is bounded by the embedded engine's +own evaluation stack rather than a compiled-in constant, and an +expression exceeding it aborts rather than returning wrong data. Piping any napl command's +canonical JSON back through an expression works by construction: +`napl status | napl q '.pending' -`. -::: test -name: the binaries spawn no process and name no provider -given: { crate_source: self } -expect: { spawns_processes: false, provider_identifiers: [] } +:::docs +`napl q` is jq over the store: same expression language, same flags, +same reflexes. The expression is required; `.` spells the whole +document explicitly. ::: -P2 footprint scoping: every operation declares a read set (content -identities consumed), a semantic write set (owned records changed), and its -derived projections plus physical install paths. Two operations conflict -when semantic write sets overlap or one's accepted write changes a content -identity in the other's read set. Derived projections (the crate-root -lib.rs module list, the command registry) are never conflict-bearing: -they are recomputed exactly once from the winning merged head inside -commit and rebase. Manifests are outside footprints entirely: user-owned -seams no operation reads as identity or writes at all. - -## Identity - -Expose `pub const CONTRACT_VERSION: u32 = 4` in napl-core (bumped from 1 -by the body-directive grammar, which changes how the same source bytes -are interpreted, from 2 by the relaxed module-name rule below, which -accepts sources contract 2 rejected, and from 3 by the capture verbs, -whose three refusal outcomes join the closed terminal-outcome table). Per module and -target: `surfaceHash` (hash of the recorded public surface), `inputKey` -(hash of the canonical closure manifest over normalized source hash, direct -dependencies' surfaceHashes, placement facts, entry-point version, target -name plus target-definition hash, CONTRACT_VERSION), `baseStateKey` (the -module's committed owned-output state hash), `sliceHash` (hash of the -module's canonical compile slice). Whole-compile `artifactHash` includes -scope and serves determinism tests only, never a commit precondition. Event -identity is `H(content hash + sorted parent hashes)`; ordinals and -timestamps are display metadata, never identity. Semantic identifiers are -content hashes, never a timestamp, pid, hostname, random value, model, or -provider. Telemetry lives in provenance records linking to semantic objects -by hash. +:::example{name="read the head"} +napl q '.head' +::: -::: test -name: "a module's inputKey covers source, direct dep surfaces, placement, target identity, and contract version" -given: { workspace: fixture_two_modules, module: consumer } -expect: { input_key_components: [source_hash, direct_dep_surface_hashes, placement_facts, target_name, target_definition_hash, entry_point_version, contract_version] } +:::example{name="list every module"} +napl q '.modules[].name' ::: -::: test -name: changing a leaf's surface changes the direct dependent's inputKey only -given: { workspace: fixture_chain_three, edit: rename_public_item_in_leaf } -expect: { changed_input_keys: [middle], unchanged_input_keys: [top] } +:::note +Piping works by construction: any napl command's JSON output feeds +back through an expression, as in `napl status | napl q '.pending' -`. ::: ::: test -name: a leaf edit absorbed by an unchanged intermediate surface leaves higher dependents' inputKeys unchanged -given: { workspace: fixture_chain_three, edit: private_body_change_in_leaf } -expect: { changed_input_keys: [], unchanged_input_keys: [middle, top] } +name: q evaluates a jq expression over the document +given: { workspace: fixture_two_modules, expr: ".modules[].name" } +expect: { output_lines: [consumer, leaf] } ::: ::: test -name: telemetry differences never change transition identity -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipts: [with_usage, without_usage] } -expect: { transition_identities_equal: true } +name: q accepts the compact flag as a synonym of the default output +given: { workspace: fixture_two_modules, invocations: ["q .pending", "q -c .pending", "q --compact-output .pending"] } +expect: { outputs_byte_identical: true, exit_codes: [0, 0, 0] } ::: ::: test -name: history nodes are causal hashes over content plus sorted parents -given: { workspace: fixture_two_modules, commits: 2 } -expect: { event_id_components: [content_hash, sorted_parent_ids], ordinal_in_identity: false, timestamp_in_identity: false } +name: q without an expression is a usage error teaching the explicit dot +given: { workspace: fixture_two_modules, invocations: ["q", "q -c", "q -r"] } +expect: { exit_codes: [2, 2, 2], error_teaches_dot: true, document_dumped: false } ::: -## Module names - -A module name is any non-empty single-line scalar. The empty string is the only -value rejected, surfaced as `mapping-integrity`. No character class, case, or -identifier shape is required: `todo-app`, `todo_app`, and non-ascii names are all -legal. The same rule governs every name position the parser reads, the `module` -frontmatter value, each `deps` entry, and each declared root, so all three accept -exactly the set the module rule accepts. This relaxation replaces the contract 2 -identifier constraint and rides the CONTRACT_VERSION bump to 3, the sanctioned -identity transition. As with the 1-to-2 bump, every module's inputKey recomputes -because CONTRACT_VERSION is one of its components; no source or surface changes, -only the version does. - -Because a name is unrestricted, it is never used raw as a filesystem path segment -or a store key. napl-core exposes one injective, reversible encoding used at every -such site: each byte outside `[A-Za-z0-9._-]` is percent-encoded as `%XX` with -uppercase hex, and a name that is exactly `.` or `..` has its dots percent-encoded -too, so an encoded segment is never `/`, `\`, empty, `.`, or `..` and can never -traverse or alias a directory. A name already matching `[A-Za-z0-9._-]` and not -equal to `.` or `..` encodes to itself, so existing records keep their on-disk -names: `todo-app` stays `todo-app`. Every writer that keys a record or scratch -path by module name, the mapl, ir, attribution, and prompt-at-gen documents, the -staging and relocation scratch directories, and the store root map, uses the -encoded segment; decoding recovers the exact name. On a case-insensitive -filesystem two names differing only in ascii case can still share one segment; -that collision is the author's concern, distinct from the traversal safety above. - -Wherever a module name is serialized into a record it is written as a quoted, -escaped scalar, never interpolated raw, so a name containing YAML or JSON -metacharacters reparses to the identical name. - ::: test -name: a non-identifier module name is accepted -given: { workspace: fixture_single_module, module_name: "todo-app" } -expect: { outcome: ok } +name: q compact composes with raw output changing nothing +given: { stdin: "{\"a\":\"x\"}", expr: ".a", input: "-", flags: ["-r", "-c -r"] } +expect: { outputs_byte_identical: true, raw_string_unquoted: true } ::: ::: test -name: an empty module name is rejected as mapping-integrity -given: { workspace: fixture_single_module, module_name: "" } -expect: { outcome: mapping_integrity } +name: q evaluates an expression over stdin when the input is a dash +given: { stdin: "{\"pending\":[\"m\"]}", expr: ".pending", input: "-" } +expect: { output: "[\"m\"]" } ::: ::: test -name: deps entries and declared roots accept the same names as the module rule -given: { module_name: "todo-app", dep_name: "a-b", root_name: "x.y" } -expect: { all_accepted: true } +name: q rejects an invalid expression without touching the store +given: { workspace: fixture_two_modules, expr: ".[unclosed" } +expect: { exit_code: 3, store_bytes_unchanged: true } ::: ::: test -name: the name encoding is injective reversible and never traverses -given: { names: ["todo-app", "a/b", "..", "café", "a b"] } -expect: { decode_roundtrip_equal: true, no_segment_in: ["/", "\\", "", ".", ".."], safe_subset_unchanged: ["todo-app"] } +name: q refuses impure builtins +given: { workspace: fixture_two_modules, expr: "now" } +expect: { exit_code: 3, error_names_builtin: true } ::: ::: test -name: a name with yaml metacharacters is written quoted and reparses identically -given: { module_name: "a: {b} #c" } -expect: { record_reparse_equal: true, written_as_quoted_scalar: true } +name: q raw output emits string contents without quotes exactly as jq dash r +given: { workspace: fixture_two_modules, expr: ".modules[0].name", flags: [-r] } +expect: { output: leaf_unquoted, trailing_newline: true } ::: -## The compile derivation - -Compilation is a pure derivation and NOT a command: parse the graph, -compute per-module slices for the changed frontier, derive -dependency-ordered waves. Never persisted, and never spelled on the CLI — -it is the internal engine of `status`, `add`, `commit`, `watch`, and the -lsp. Per module the slice carries: normalized source body and hash, direct -dependency surfaces and surfaceHashes, placement and allowed-output -constraints including the surface grammar, the owned-output base and -baseStateKey, inputKey, sliceHash, wave position, receipt requirements. It -contains no provider, model, prompt assembly, credentials, timestamps, -retry policy, pricing, or test results. `napl status --module ` -reports the module's PLAN from this derivation — owned globs, placement -root, projection ownership, receipt requirements, wave position — the -invariant requirements only, a pure function of the working tree's -prompts; candidate-dependent validation belongs to `add`, never the plan. - ::: test -name: status reports a module plan derived from the working tree -given: { workspace: fixture_two_modules, flag: module_leaf } -expect: { plan_fields: [owned_globs, placement_root, receipt_requirements, wave_position], candidate_dependent_fields: absent } +name: the document serves a heading outline with body line numbers +given: { workspace: fixture_two_modules } +expect: { outline_present: true, outline_entry_fields: [level, line, text], lines_body_normalized: true } ::: -## Body directives +## napl prompt -The prompt body grammar reserves directive blocks, `directiveVersion 1`, -and recognizing them is part of the identity contract: text that older -binaries read as inert prose can be a compile error under this grammar, so -this recognition rides a CONTRACT_VERSION bump, the sanctioned identity -transition. The first directive is `test`. An opener is a line that is -exactly `::: test`; the closer is a line that is exactly `:::`; no -nesting. An unknown `::: ` opener is a compile error, never prose, -so future directive names are non-breaking. Directive blocks are body -text: they count in normalized body lines and attribution ranges may -cover them. +`napl prompt` (alias `napl p`) is the prompt builder: it reads refs from +stdin — the objects a `napl q` stage selected — and emits markdown built +from committed state at the refs' snapshot, never current head, and +never the working tree except under the explicit `--working-tree` mode +defined in the working-tree reads section. Stdin is typed-ref JSONL: one JSON object per line +carrying `head`, `lines`, and exactly one of `path` or `promptPath`; +unknown extra fields are ignored. A line that is not an object, carries +both path kinds or neither, or has a non-integer, zero, reversed, or +out-of-range line range is an error naming the input line number, never a +silent skip. Empty stdin is an error saying nothing was selected. Refs +naming more than one head in a single invocation error. Duplicate refs +(same head, path, range) emit once; input order is otherwise preserved. -Block content is one case in an exact value grammar mapping 1:1 onto -canonical JSON, so two binaries cannot disagree on a value: one mapping -with keys `name` (required, non-empty, single line), `given` (optional, -mapping), `expect` (required, mapping). Block mappings and sequences -indent by exactly two spaces; flow `{}` and `[]` forms are allowed. -Strings are double-quoted with JSON escapes, or plain scalars ended by a -structural character (`:`, `,`, `{`, `}`, `[`, `]`, `#`) or end of line -with no leading or trailing space. Numbers are JSON number syntax; -`true`, `false`, `null` are keywords; every other plain scalar is a -string. Nothing else from YAML exists here: no comments, no duplicate -mapping keys, no anchors, aliases, tags, block scalars, multiline -scalars, multi-document markers, octal or hex, timestamps, or merge keys. -A case needing prose belongs in prose. Diagnostics are compile errors -naming the exact body line, judged in this precedence: unclosed block, -unknown directive name, nested opener, content outside the value grammar, -missing or empty name, missing expect, duplicate case name within the -module across both positions after NFC normalization and whitespace trim. -Frontmatter `tests:` remains legal; a module's case set is the union of -both positions; extraction of cases from body bytes is deterministic — -same bytes, same cases. +Materialization reads the snapshot's content-addressed blobs; committed +prompts and committed module outputs are the only eligible paths. A +`{path, lines}` ref resolves to its owning module through the snapshot's +output manifests — owned globs are disjoint by commit invariant, so +ownership is unique, and a discovered overlap is a store-integrity +failure, not a guess. A prompt ref yields specification prose; a file ref +yields code: one resolver, both directions. Every chunk gets a provenance +header naming `path:start-end` plus the module and note when known. +Cases render after their section's prose as canonical JSON code blocks +inside dynamically sized fences, immune to backticks in values. + +Law closure by default: the `law: true` sections of every module a ref +touched are appended once, deduplicated, ordered by module then range, +under one trailing header; `--no-laws` opts out. Refs into a module whose +attribution is stale refuse by default with that module's fix directive; +`--allow-stale` proceeds and stamps `stale` into the affected provenance +headers. Unresolvable refs (absent path, unavailable snapshot) error +naming the ref's input line. ::: test -name: an unclosed test directive block is a compile error naming its opener line -given: { body: directive_without_closer } -expect: { outcome: compile_error, line_named: opener } +name: prompt materializes a prompt ref with a provenance header +given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf } +expect: { output_contains_prose: true, header_format: "path:start-end" } ::: ::: test -name: "an unknown directive name is a compile error, never prose" -given: { body: unknown_directive_opener } -expect: { outcome: compile_error, treated_as_prose: false } +name: prompt materializes a generated-file ref symmetrically +given: { workspace: fixture_two_modules, stdin: file_ref_for_leaf_output } +expect: { output_contains_code: true } ::: ::: test -name: a duplicate case name across frontmatter and body errors -given: { frontmatter_case: alpha, body_directive_case: alpha } -expect: { outcome: compile_error, diagnostic: duplicate_case_name } +name: prompt appends law sections by default and omits them under no-laws +given: { workspace: fixture_with_law_section, stdin: section_ref_non_law } +expect: { laws_appended_default: true, laws_with_flag: false } ::: ::: test -name: directive case extraction is deterministic -given: { body: fixture_with_two_directives, runs: 2 } -expect: { cases: 2, bytes_equal: true } +name: prompt refuses refs into a stale module by default +given: { workspace: fixture_with_directive_case, attribution: stale, stdin: section_ref } +expect: { exit_nonzero: true, directive_present: true, allow_stale_overrides: true } ::: -## Recorded surfaces - -At commit, extract the candidate's public API with tree-sitter (pinned -grammars) and record it as a `surface` object; dependents' inputKeys pin -recorded surfaceHashes, store facts, never demand scans. The surface -grammar makes extraction sound by construction and violations reject as -`mapping-integrity`: for Rust, public items must be named `pub` -declarations at statically visible paths, no glob re-exports, no -macro-generated `pub` items; a named `pub use` is allowed and is recorded -as the unresolved written binding, never a resolved signature. For -TypeScript: explicit named exports only, no `export *`. Extraction is -syntactic, deterministic, and loud on malformed input. Re-extraction from -committed output must reproduce the recorded surface byte-identically. +::: test +name: prompt errors on a malformed stdin line naming its line number +given: { stdin_lines: [valid_ref, "not json"] } +expect: { exit_nonzero: true, error_names_line: 2 } +::: ::: test -name: a candidate with a glob re-export is rejected as mapping-integrity -given: { workspace: fixture_two_modules, candidate: contains_glob_reexport } -expect: { outcome: mapping_integrity, published_semantic_state: false } +name: prompt errors on empty stdin +given: { stdin: "" } +expect: { exit_nonzero: true, error: nothing_selected } ::: ::: test -name: a named pub use is recorded in the surface as the written binding -given: { workspace: fixture_two_modules, candidate: valid_with_named_reexport } -expect: { surface_contains_written_binding: true, surface_contains_resolved_signature: false } +name: prompt deduplicates identical refs and preserves input order +given: { stdin_lines: [ref_a, ref_b, ref_a] } +expect: { chunks: 2, order: [ref_a, ref_b] } ::: ::: test -name: recorded surface re-extraction from committed output is byte-identical -given: { workspace: fixture_two_modules, candidate: valid_for_consumer } -expect: { reextraction_equal: true } +name: "prompt resolves refs against their snapshot, not current head" +given: { workspace: fixture_two_modules, ref_head: previous_snapshot, edits_after: leaf_recommitted } +expect: { materialized_bytes: previous_snapshot_bytes } ::: -## add, then commit +## Working-tree reads, byte-exact retrieval, and pre-add validation -Git parity is the mental model: `add` stages, `commit` installs. The -receipt is an internal record between them, never a user-facing noun. +Three reads exist so that a tool amending a prompt can read exactly what +it is amending, retrieve the exact bytes it is about to splice, and +validate the result before anything stages, without a single raw file +read. All three are pure reads: no store writes, no locks, no clock, no +staging. Each is a pure function of the committed state plus the on-disk +prompt bytes it explicitly names, and byte-stable across runs over the +same inputs. -`napl add` stages a module state from any of three sources, and the -working tree is the default one, exactly as in git: +`napl q --working-tree` builds the canonical document with every +prompt-derived fact enumerated from the on-disk prompt files instead of +the committed blobs: frontmatter, sections, `::: test` directive cases, +and body line ranges all reflect the pending working-tree prompt, +including sections and cases that exist only in the working tree. +Store-derived facts are unchanged and still committed: `head`, module +states, history, receipts. The top level carries `workingTree: true`, +and each section carries `workingTree: true` in place of a committed +`head` value, so a working-tree ref can never be mistaken for a +committed one and no hash is ever invented for uncommitted bytes. +Section file associations are the committed map's claims transported +through the prompt's committed-to-pending diff, the same deterministic +transport `add` uses: an association whose prompt range transports +cleanly is served intact, and a section whose range lands in a +rewritten or ambiguous region serves `files: []` with +`associationUnproven: true`. The view never re-associates files by +range arithmetic over the re-anchored body; an overlap that happens to +exist in the pending text is not a claim, and serving it as one would +be inventing a mapping nobody made. +Section-to-file association under a stale attribution behaves exactly as +today: sections carry their cases, `associationStale: true` marks the +pairing untrustworthy, and nothing pretends freshness. Reference +staleness fields serve exactly as in the committed document. -- `napl add [...]` names working-tree files. Each path - resolves to its owning module through the owned globs (unique by - commit invariant, so no `--module` is needed), and the staged state - is that module's committed output with exactly the named files - overlaid from the working tree. Paths owned by different modules - stage one record per module in one invocation. A path no module owns - is a usage error naming the owned globs that do exist. -- `napl add --module ` stages the module's whole owned tree as - it sits in the working tree. -- `napl add --module ` stages explicit candidate - bytes, for the rare case where the bytes to stage are not the - working tree. +`napl prompt --working-tree` resolves `promptPath` refs against the +on-disk prompt file instead of a committed blob. A working-tree ref +carries `workingTree: true` and no `head`; a ref carrying both, or a mix +of working-tree and committed refs in one invocation, is a usage error +naming the input line. Everything else about materialization holds: +provenance headers, law closure from committed state, case rendering, +error shapes. -All three forms feed one pipeline with `[--attribution ] -[--usage ]`: ingest the staged bytes into immutable content -addressed blobs (reject links, devices, duplicate normalized paths, -absolute or parent-escaping paths, oversized entries or totals; all -later steps read ingested blobs, never the source files); compute the -slice and validate against it; resolve attribution as below; validate -and attach usage evidence when supplied; then write the staged record — -the internal receipt: identities generated against, base snapshot -hashes, candidate tree hash, output manifest, attribution, optional -reported fields. Validation failures surface at add time with the same -outcome classes commit uses. Staged records are transient coordination -state, disposable litter by the git-state law: never promoted to -history, safely deletable, re-creatable by re-running add. +`napl prompt --bytes` is byte-exact retrieval, the inverse of the edit a +tool is about to make: the ref's line range materializes as the exact +source bytes of that range, no reflow, no case rendering, no law +closure, no provenance header, one trailing newline. It accepts exactly +one ref; more than one is a usage error. It composes with +`--working-tree` so a splice can be computed against the pending file. +The bytes returned are byte-identical to the named lines of the source, +so retrieve-then-splice is deterministic by construction. -Attribution transports instead of being demanded. Add computes the -byte diff between the module's committed output and the staged bytes, -and the prompt's committed body and its pending body, and transports -every committed attribution claim through those diffs -deterministically: a range the diff proves untouched survives -verbatim, a range below an insertion or deletion shifts by its size, -and a claim landing in a rewritten or ambiguous region does not -transport, because the transport never guesses; a range it cannot -prove through the diff is asked for, never invented. When every claim -transports, add stages with the transported map and no `--attribution` -flag is needed: the paperwork updates itself as a consequence of the -diff already being content-addressed on both sides. When any claim -fails to transport, add refuses as `invalid-candidate` listing exactly -the claims that need affirmation, each beside the observed bound or -region it was checked against, and `--attribution` supplies -affirmations for the listed claims only, layered over the transported -remainder. Any attribution refusal, transport or explicit, names the -observed bound (the actual line count or surviving range) next to the -rejected claim, so a wrong range never costs a manual count. +`napl check ` is dry validation of an on-disk prompt file +before `add`: frontmatter shape and module-name rules per the napl +contract, body normalization, directive grammar for every `::: test` +block including canonical JSON in `given` and `expect`, and case-name +uniqueness. The module-name rules are enforced as name grammar, not +merely as YAML: a frontmatter value that parses as a string but can +never be a legal module name (path-metacharacters, a bare `[`, empty +after trimming) is a finding at check time, never a surprise later. +Findings are canonical JSON on stdout, each naming the file, +the line, and what failed; every reported line is the physical +1-based line in the file as an editor counts it, frontmatter +included, never a body-normalized offset. Exit codes are pinned: 0 for a valid prompt, +1 when findings exist, 2 for usage including an unreadable path. The +store is never touched and nothing stages. `check` passing means the +prompt parses under the same reader `add` uses; it does not predict +`add` outcomes that depend on candidates, attribution, or placement, and +its wording never implies otherwise. `check` teaches while it refuses: +the finding for a missing opening frontmatter fence carries one +complete minimal valid prompt inline, so the first error a cold user +sees contains the whole grammar they were missing; and a checked file +whose name lacks the `.napl` extension gets a finding saying discovery +will never find it, naming the extension, whatever else is valid about +its content. ::: test -name: add of one working-tree path stages the committed state with that file overlaid -given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, add_paths: [edited_file] } -expect: { add_outcome: staged, staged_state: committed_plus_overlay, unnamed_files_at_committed_bytes: true } +name: q working-tree enumerates a section that exists only in the working tree +given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } +expect: { new_section_enumerated: true, top_level_working_tree: true, committed_document_unchanged: true } ::: ::: test -name: paths owned by two modules stage one record per module -given: { workspace: fixture_two_modules, working_tree_edit: both_modules_touched, add_paths: [file_of_leaf, file_of_consumer] } -expect: { staged_records: 2, modules: [consumer, leaf] } +name: working-tree sections carry no committed head and mark themselves +given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } +expect: { section_working_tree: true, section_head_absent: true } ::: ::: test -name: a path no module owns is a usage error naming the owned globs -given: { workspace: fixture_two_modules, add_paths: [unowned_file] } -expect: { exit_code: 2, error_names_owned_globs: true, staged_records: 0 } +name: the working-tree document is byte-stable across runs +given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, runs: 2 } +expect: { bytes_equal: true } ::: ::: test -name: a pure line-shift edit transports the whole map and needs no attribution flag -given: { workspace: fixture_two_modules, working_tree_edit: insert_lines_above_attributed_range, add: module_form_without_attribution } -expect: { add_outcome: staged, map_transported: true, ranges_shifted: true, attribution_flag_needed: false } +name: a transported section keeps its files and a rewritten one serves none unproven +given: { workspace: fixture_two_modules, working_tree_edit: shift_one_section_rewrite_another, flags: [--working-tree] } +expect: { shifted_section_files_intact: true, rewritten_section_files: [], rewritten_section_association_unproven: true, overlap_reassociation: 0 } ::: ::: test -name: a claim in a rewritten region refuses listing that claim with its observed bound -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } -expect: { outcome: invalid_candidate, listed_claims: 1, observed_bound_present: true, untouched_claims_not_listed: true } +name: prompt bytes round-trips the exact source bytes of a range +given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf, flags: [--bytes] } +expect: { bytes_equal_raw_slice: true, reflow: false, headers: false } ::: ::: test -name: affirmations layer over the transported remainder and stage -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_affirmation_for_listed_claim } -expect: { add_outcome: staged, transported_claims_kept: true, affirmed_claim_recorded: true } +name: prompt bytes refuses more than one ref as a usage error +given: { stdin_lines: [ref_a, ref_b], flags: [--bytes] } +expect: { exit_code: 2, error_names_line: 2 } ::: ::: test -name: add stages a valid candidate and commit installs it -given: { workspace: fixture_two_modules, candidate: valid_with_attribution } -expect: { add_outcome: staged, commit_outcome: success } +name: a ref carrying both a head and working-tree is a usage error naming its line +given: { stdin_lines: [ref_with_head_and_working_tree], flags: [--working-tree] } +expect: { exit_nonzero: true, error_names_line: 1 } ::: ::: test -name: an explicit candidate whose transport is incomplete and unaffirmed fails loudly -given: { workspace: fixture_two_modules, candidate: rewritten_region_without_affirmation } -expect: { outcome: invalid_candidate, listed_claims_present: true } +name: check passes a valid prompt with exit zero and no findings +given: { workspace: fixture_two_modules, prompt: leaf_prompt_valid } +expect: { exit_code: 0, findings: 0, store_bytes_unchanged: true } ::: ::: test -name: a candidate with a parent-escaping path is rejected as invalid-candidate -given: { workspace: fixture_two_modules, candidate: contains_dotdot_path } -expect: { outcome: invalid_candidate, published_semantic_state: false } +name: check reports a malformed directive with a finding naming its line +given: { workspace: fixture_two_modules, prompt: leaf_prompt_broken_directive } +expect: { exit_code: 1, finding_names_line: true, store_bytes_unchanged: true } ::: ::: test -name: tampering a staged blob after ingestion never mixes into the install -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, tamper: candidate_file_after_ingest } -expect: { outcome_in: [installed_from_immutable_blobs, store_integrity], mixed_content: false } +name: the missing-fence finding carries a complete minimal prompt that itself passes check +given: { workspace: fixture_two_modules, prompt: file_without_frontmatter } +expect: { exit_code: 1, finding_example_passes_check: true } ::: -`napl commit --staged ` is the one guarded -transaction over what add staged. Pipeline: verify the staged record -(recompute sliceHash, inputKey, baseStateKey at the recorded bases, -reject mismatches, enforce evidence bounds); extract and record the -surface; derive the complete transition (three-part footprint, member-set -delta, shared projections recomputed from the merged head); stage all -materialization outside any lock; then one bounded critical section under -the pointer lock performing only the footprint recheck and the pointer -and directory swaps, bounded by the transition's file count, never -hashing content inside the lock. - ::: test -name: a crash between staged materialization and pointer swap leaves a deterministic recovery plan -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, crash_after: staging } -expect: { store_readable: true, recovery_plan_deterministic: true, semantic_state_unchanged: true } +name: a checked file without the napl extension is told discovery will never find it +given: { workspace: fixture_two_modules, prompt: valid_content_named_greeting_md } +expect: { exit_code: 1, finding_names_extension: ".napl", store_bytes_unchanged: true } ::: ::: test -name: a receipt whose recomputed slice hash disagrees is rejected as invalid-receipt -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: tampered_slice_hash } -expect: { outcome: invalid_receipt, published_semantic_state: false } +name: a module name that yaml accepts but the grammar forbids is a finding +given: { workspace: fixture_two_modules, prompt: frontmatter_module_bare_bracket } +expect: { exit_code: 1, finding_names_module_grammar: true } ::: -The decision table, judged per arriving transition against the current -head: identities reproduce with head unmoved installs; head moved with -identities reproducing and footprints disjoint rebases then installs -(projections re-derived, never reused); inputKey failure returns -`stale-input`; baseStateKey failure returns `base-output-changed`; semantic -write overlap returns `write-conflict`; lock contention returns -`lock-contended`. Terminal outcomes, never retried unchanged: -`invalid-candidate`, `invalid-receipt`, `mapping-integrity`, -`resource-limit`, `unsupported-version`, `store-integrity`, `io-error`. -Every outcome is a canonical machine-readable JSON payload naming whether -blobs or provenance were published; no outcome publishes semantic state -partially. Exit codes: 0 success, 10 through 13 for the four returned -concurrency outcomes in table order, 20 through 26 for the seven terminal -outcomes in the order above. Staged records carry `receiptVersion`, the -per-module identities generated against, base snapshot hashes, the -candidate tree hash, the output manifest, then optional reported fields; -the compiler recomputes everything derivable and rejects on mismatch; -absent telemetry is `unavailable`, never zero. `napl notes add --to -` attaches later provenance without touching semantic state. - ::: test -name: commit installs a valid candidate against an unmoved head -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, head: unmoved } -expect: { outcome: installed, head_advanced: true, surface_recorded: true } +name: findings report physical file lines as an editor counts them +given: { workspace: fixture_two_modules, prompt: empty_module_value_on_file_line_2 } +expect: { finding_line: 2, line_kind: physical_one_based } ::: +napl-store module-state v1 562 +{"attribution":{"promptHash":"a1ffa0765647bba4a5800b7b586b2fabbc059f4ef87d92634909d2b563fe4a59","record":"9c187268acfc021be505e952ab135fccccd38d1168593caf63899afe84a8238e","stale":false},"contractVersion":4,"inputKey":"8920df377fc0fe22186e48fe6a235b4cbd6750c7856b0082a54f7e08afd17520","module":"commands","outputTree":"bb697c13a7df86325725a0bc05269c8197416b2b34f5e7b496ea3a3eb9a07976","sliceHash":"93be886fefa6c04a317ff38171d357d3c3ffb88c1c31b4dc53c4dfba79780604","surfaceHash":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust"} +napl-store tree v1 129 +{"entries":[{"hash":"5720bfa6d7c0b790f6e43277d6bfb2d58aa4204ba3706d76e37e90e49a7e2a66","kind":"dir","mode":16384,"name":"src"}]} +napl-store transition v1 875 +{"baseStateKey":"efeb55ee16b2b444a3502f2e63424bcaaec34c9f676dd2c74f00944dceec5a8d","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"0c24795983a2a1227f49aecc11439fa8374eeb139047ae85b2d55ce1cd27ae06","installPaths":["crates/napl/src/init.rs","crates/napl/src/watch.rs"],"module":"commands","newState":"7f362809b6d0f959bc8479adebdde987a718107d3e70636e879e44d0d4c59bb5","readSet":["base-state:efeb55ee16b2b444a3502f2e63424bcaaec34c9f676dd2c74f00944dceec5a8d","input-key:0c24795983a2a1227f49aecc11439fa8374eeb139047ae85b2d55ce1cd27ae06","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"1a90570994829d35b84b3d0afb4c1add8c874b698abe4ebb9acdb218ecf30c77","surface":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust","writeSet":["owned:crates/napl","module-state:rust/commands"]} +napl-store snapshot v1 781 +{"contractVersion":4,"event":"f0b8a3c47a244f6054199d9672f8fb7eb24f451e467af370d2026e72b30601d2","states":[{"module":"agent","state":"651686af1f5c9f3aad425d1ca7f0de325e4dc362cb59d9a71dc7d75f3216972c","target":"rust"},{"module":"commands","state":"7f362809b6d0f959bc8479adebdde987a718107d3e70636e879e44d0d4c59bb5","target":"rust"},{"module":"napl","state":"338c9b575a24883622bf3cb1e3276c24a26c31ef34a5097927420523727b6d26","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"26e26696fa18accc362da9494d522e4ab3f3e70babc7ad776335ce8125582ef8","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} +napl-store blob v1 6388 +//! Outcome codes, exit-code mapping, and the crate error type. +//! +//! Every failure the compiler can report is one of the outcomes named by the +//! deterministic-compiler contract. Four are concurrency outcomes a caller may +//! retry after regenerating; ten are terminal and must never be retried +//! unchanged. The last three joined the closed table with the capture verbs +//! under contract 4 and continue the pinned exit-code order; none of them reuses +//! `mapping-integrity`, because a refused edit is not a broken mapping. -::: test -name: commit against a moved head with disjoint footprints rebases and installs -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: to_unrelated_module } -expect: { outcome: installed, rebased: true } -::: +use serde_json::{json, Value}; + +/// The complete outcome vocabulary of the compiler. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum OutcomeCode { + Success, + /// The invocation did not match the command's declared grammar: an unknown + /// command, an unknown flag, a missing required flag or positional, a + /// malformed flag value. It is a grammar failure rather than a compilation + /// outcome, so it records nothing and carries the pinned code 2. + Usage, + // Concurrency outcomes, in decision-table order. + StaleInput, + BaseOutputChanged, + WriteConflict, + LockContended, + // Terminal outcomes, in contract order. + InvalidCandidate, + InvalidReceipt, + MappingIntegrity, + ResourceLimit, + UnsupportedVersion, + StoreIntegrity, + IoError, + /// A capture verb's `--old-string` matched nowhere, matched more than once + /// without `--replace-all`, or equalled `--new-string`. + EditMismatch, + /// A capture verb's `--ref` named a line or a path the post-edit bytes and + /// the owning module's placement cannot carry. + ReferenceUnresolvable, + /// A capture verb's result would not parse as the prompt it replaces. + PromptInvalid, +} + +/// The stable machine-readable slug for an outcome. +pub fn outcome_slug(code: OutcomeCode) -> &'static str { + match code { + OutcomeCode::Success => "success", + OutcomeCode::Usage => "usage", + OutcomeCode::StaleInput => "stale-input", + OutcomeCode::BaseOutputChanged => "base-output-changed", + OutcomeCode::WriteConflict => "write-conflict", + OutcomeCode::LockContended => "lock-contended", + OutcomeCode::InvalidCandidate => "invalid-candidate", + OutcomeCode::InvalidReceipt => "invalid-receipt", + OutcomeCode::MappingIntegrity => "mapping-integrity", + OutcomeCode::ResourceLimit => "resource-limit", + OutcomeCode::UnsupportedVersion => "unsupported-version", + OutcomeCode::StoreIntegrity => "store-integrity", + OutcomeCode::IoError => "io-error", + OutcomeCode::EditMismatch => "edit-mismatch", + OutcomeCode::ReferenceUnresolvable => "reference-unresolvable", + OutcomeCode::PromptInvalid => "prompt-invalid", + } +} + +/// Process exit code: 0 success, 2 usage, 10..=13 concurrency, 20..=29 terminal. +/// +/// Exit 2 is pinned binary-wide for every usage error, so a caller can tell +/// "you spelled the invocation wrong" from "the compiler refused the work" +/// without reading a payload. +pub fn outcome_exit_code(code: OutcomeCode) -> i32 { + match code { + OutcomeCode::Success => 0, + OutcomeCode::Usage => 2, + OutcomeCode::StaleInput => 10, + OutcomeCode::BaseOutputChanged => 11, + OutcomeCode::WriteConflict => 12, + OutcomeCode::LockContended => 13, + OutcomeCode::InvalidCandidate => 20, + OutcomeCode::InvalidReceipt => 21, + OutcomeCode::MappingIntegrity => 22, + OutcomeCode::ResourceLimit => 23, + OutcomeCode::UnsupportedVersion => 24, + OutcomeCode::StoreIntegrity => 25, + OutcomeCode::IoError => 26, + OutcomeCode::EditMismatch => 27, + OutcomeCode::ReferenceUnresolvable => 28, + OutcomeCode::PromptInvalid => 29, + } +} + +/// True when the outcome may be retried after the caller regenerates its inputs. +pub fn outcome_is_retryable(code: OutcomeCode) -> bool { + matches!( + code, + OutcomeCode::StaleInput + | OutcomeCode::BaseOutputChanged + | OutcomeCode::WriteConflict + | OutcomeCode::LockContended + ) +} + +/// A failure carrying the outcome it maps to, a human-readable detail, and the +/// structured facts a caller needs to act on it. +/// +/// `data` exists because some refusals are lists rather than sentences: an +/// attribution transport that cannot carry three claims has to name those three +/// and the bound each was checked against, and folding that into prose would make +/// a machine reader parse English. Its keys merge into the outcome payload +/// alongside the shared ones. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompilerError { + pub code: OutcomeCode, + pub detail: String, + pub data: Option, +} + +pub type Result = std::result::Result; + +/// Build a [`CompilerError`]. +pub fn error(code: OutcomeCode, detail: impl Into) -> CompilerError { + CompilerError { + code, + detail: detail.into(), + data: None, + } +} + +/// Build a [`CompilerError`] that also carries structured facts. +pub fn error_with(code: OutcomeCode, detail: impl Into, data: Value) -> CompilerError { + CompilerError { + code, + detail: detail.into(), + data: Some(data), + } +} + +/// The canonical JSON body shared by every failing outcome payload. +/// +/// A structured detail merges in, and never over: a shared key always wins, so no +/// refusal can restate its own outcome or publication facts. +pub fn error_payload( + err: &CompilerError, + blobs_published: bool, + provenance_published: bool, +) -> Value { + let mut payload = serde_json::Map::new(); + if let Some(Value::Object(extra)) = &err.data { + for (key, value) in extra { + payload.insert(key.clone(), value.clone()); + } + } + for (key, value) in [ + ("blobsPublished", json!(blobs_published)), + ( + "contractVersion", + json!(crate::version::CONTRACT_VERSION), + ), + ("detail", json!(err.detail)), + ("outcome", json!(outcome_slug(err.code))), + ("provenancePublished", json!(provenance_published)), + ("retryable", json!(outcome_is_retryable(err.code))), + ("semanticStatePublished", json!(false)), + ] { + payload.insert(key.to_string(), value); + } + Value::Object(payload) +} +napl-store blob v1 3027 +//! NAPL: the deterministic compiler. +//! +//! One crate with two halves, kept apart by module boundary rather than by crate +//! boundary. The pure compiler — parsing, the dependency graph, slices, surface +//! extraction, receipts, the decision table, blame, lint, cost — decides +//! everything. The shell — [`cli`], [`commit`], [`disk`], [`porcelain`], +//! [`workspace`], [`authoring`] — owns the syscalls and decides nothing. The +//! byte-level contract (the envelope, the SHA-256 name, canonical JSON, trees, +//! the transaction core) belongs to `napl-store` and is used from there rather +//! than restated. +//! +//! # The two principles +//! +//! **P1, ownership.** The toolchain owns `.napl` source, the derived layer, the +//! store, and the mappings between them; every owned fact is provable from +//! content alone. There is no model invocation, no provider identifier, no +//! completion parsing, no process execution, no clock read, and no randomness +//! anywhere in this crate. Generation happens outside it: an agent session reads +//! `napl compile` output, writes candidate code, and calls `napl commit`. +//! +//! **P2, footprint scoping.** Every operation declares a read set of content +//! identities, a semantic write set of owned records, and its derived +//! projections plus physical install paths. Two operations conflict when their +//! semantic write sets overlap, or when one's accepted write changes a content +//! identity in the other's read set. Derived projections — the shared workspace +//! manifest and its member list — are never conflict-bearing: they are +//! recomputed exactly once from the winning merged head inside commit and +//! rebase. +//! +//! # What "pure" means here +//! +//! No function in the compiler half opens a file, takes a lock, reads a clock, +//! reads an environment variable, or draws a random number. Store reads arrive +//! through [`store_view::ObjectSource`]; candidate bytes arrive through +//! [`candidate::ScannedEntry`]; source text arrives as `&str`. That is the whole +//! reason two compiles of the same workspace, from different absolute paths and +//! under different locales, produce identical bytes: the ambient environment is +//! not merely ignored, it is unreachable. + +// The pure compiler. +pub mod blame; +pub mod candidate; +pub mod canon; +pub mod compile; +pub mod cost; +pub mod decision; +pub mod error; +pub mod evidence; +pub mod graph; +pub mod lint; +pub mod maintenance; +pub mod objects; +pub mod paths; +pub mod receipt; +pub mod slice; +pub mod source; +pub mod store_view; +pub mod surface; +pub mod target; +pub mod textdiff; +pub mod transaction; +pub mod trees; + +// The shell: the syscalls and the argument grammar. +pub mod authoring; +pub mod cli; +pub mod commit; +pub mod disk; +pub mod lsp; +pub mod porcelain; +pub mod workspace; + +/// The version of the compiler contract these bytes implement. +/// +/// It enters every `inputKey`, so raising it invalidates every outstanding +/// receipt rather than silently reinterpreting one. +pub const CONTRACT_VERSION: u32 = 1; +napl-store event v1 158 +{"content":"6adffc623010c612d8a7069a6b3f35b881e02b01086e81d10774882039ec4cf2","parents":["38eaa9d6b15a1bb30970cfd4c3c2f98fab73f4a40ce774a7d622cbadc210ac55"]} +napl-store blob v1 6733 +module: napl +target: rust +stale: false +entries: + - promptLines: + - 1 + - 8 + file: crates/napl/src/cli.rs + lines: + - 1 + - 133 + note: "the binary reads args runs one command and exits with the contract code" + - promptLines: + - 9 + - 88 + file: crates/napl/src/workspace.rs + lines: + - 1 + - 251 + note: "layout and discovery walk up to the nearest napl directory" + - promptLines: + - 9 + - 88 + file: crates/napl/src/target.rs + lines: + - 1 + - 228 + note: "placement roots owned source globs and the user-owned manifest seam" + - promptLines: + - 9 + - 88 + file: crates/napl/src/transaction.rs + lines: + - 1 + - 537 + note: "the crate lib.rs projection derived from the committed module file sets" + - promptLines: + - 89 + - 110 + file: crates/napl/src/objects.rs + lines: + - 1 + - 332 + note: "P2 footprints read write and derived-projection sets over content identities" + - promptLines: + - 89 + - 110 + file: crates/napl/src/decision.rs + lines: + - 1 + - 113 + note: "the conflict rule two operations conflict when semantic write sets overlap" + - promptLines: + - 111 + - 128 + file: crates/napl/src/version.rs + lines: + - 1 + - 1 + note: "the contract version bumped to two by the body-directive grammar" + - promptLines: + - 111 + - 128 + file: crates/napl/src/slice.rs + lines: + - 1 + - 170 + note: "identity vocabulary inputKey closure manifest baseStateKey and sliceHash" + - promptLines: + - 129 + - 145 + file: crates/napl/src/compile.rs + lines: + - 1 + - 257 + note: "the compile derivation a pure read that never persists an artifact" + - promptLines: + - 146 + - 179 + file: crates/napl/src/directive.rs + lines: + - 1 + - 584 + note: "the test body-directive value grammar diagnostics and deterministic extraction" + - promptLines: + - 180 + - 193 + file: crates/napl/src/surface.rs + lines: + - 1 + - 694 + note: "tree-sitter surface extraction sound by construction and loud on malformed input" + - promptLines: + - 194 + - 263 + file: crates/napl/src/staging.rs + lines: + - 1 + - 216 + note: "napl add stages a candidate ingesting validating and writing the internal receipt" + - promptLines: + - 194 + - 263 + file: crates/napl/src/commit.rs + lines: + - 1 + - 367 + note: "napl commit the one guarded transaction over what add staged" + - promptLines: + - 194 + - 263 + file: crates/napl/src/receipt.rs + lines: + - 1 + - 194 + note: "the internal receipt everything the compiler recomputes and refuses to believe" + - promptLines: + - 194 + - 263 + file: crates/napl/src/attribution.rs + lines: + - 1 + - 323 + note: "attribution rides the staged record validated before install" + - promptLines: + - 194 + - 263 + file: crates/napl/src/candidate.rs + lines: + - 1 + - 150 + note: "candidate ingestion untrusted bytes become immutable content-addressed blobs" + - promptLines: + - 264 + - 323 + file: crates/napl/src/porcelain.rs + lines: + - 1 + - 679 + note: "the observational porcelain status log show diff blame lint cost gc fsck restore" + - promptLines: + - 264 + - 323 + file: crates/napl/src/lsp.rs + lines: + - 1 + - 492 + note: "the language server hover definition references and code lens over the mapping" + - promptLines: + - 264 + - 323 + file: crates/napl/src/mv.rs + lines: + - 1 + - 215 + note: "relocation as a semantic operation re-rooting a module through the guarded path" + - promptLines: + - 264 + - 323 + file: crates/napl/src/blame.rs + lines: + - 1 + - 79 + note: "derived line ancestry surviving lines trace through accepted patches" + - promptLines: + - 264 + - 323 + file: crates/napl/src/lint.rs + lines: + - 1 + - 211 + note: "unreachable modules under declared roots and unconsumed surface entries" + - promptLines: + - 264 + - 323 + file: crates/napl/src/cost.rs + lines: + - 1 + - 163 + note: "cost as a pure calculation over an explicit receipt set unknown stays unknown" + - promptLines: + - 264 + - 323 + file: crates/napl/src/maintenance.rs + lines: + - 1 + - 335 + note: "gc fsck and restore deterministic repair plans from persisted evidence" + - promptLines: + - 264 + - 323 + file: crates/napl/src/evidence.rs + lines: + - 1 + - 208 + note: "evidence validators keyed by media type with compiled-in bounds" + - promptLines: + - 324 + - 334 + file: crates/napl/src/canon.rs + lines: + - 1 + - 44 + note: "the determinism contract canonical bytes and byte-pinned serialization" + - promptLines: + - 129 + - 145 + file: crates/napl/src/graph.rs + lines: + - 1 + - 162 + note: "the dependency graph and dependency-ordered wave planning" + - promptLines: + - 194 + - 263 + file: crates/napl/src/disk.rs + lines: + - 1 + - 203 + note: "the store shell publication head compare-and-set and module-keyed locks" + - promptLines: + - 194 + - 263 + file: crates/napl/src/store_view.rs + lines: + - 1 + - 152 + note: "the read seam over content-addressed objects a pure function of the bytes" + - promptLines: + - 129 + - 145 + file: crates/napl/src/paths.rs + lines: + - 1 + - 69 + note: "the portable path grammar and its collision-detecting normalization" + - promptLines: + - 194 + - 263 + file: crates/napl/src/trees.rs + lines: + - 1 + - 34 + note: "building the candidate tree over the ingested manifest" + - promptLines: + - 264 + - 323 + file: crates/napl/src/textdiff.rs + lines: + - 1 + - 152 + note: "diff renders drift as a canonical byte-stable patch" + - promptLines: + - 194 + - 263 + file: crates/napl/src/authoring.rs + lines: + - 1 + - 220 + note: "the internal receipt derivation and the one guarded auto-fix" + - promptLines: + - 89 + - 110 + file: crates/napl/src/error.rs + lines: + - 1 + - 108 + note: "the outcome vocabulary and its exit-code mapping" + - promptLines: + - 1 + - 8 + file: crates/napl/src/commandset.rs + lines: + - 1 + - 226 + note: "the command table the core toolchain contributes to the derived registry" + - promptLines: + - 1 + - 8 + file: crates/napl/src/main.rs + lines: + - 1 + - 16 + note: "the binary entry point that spawns no process" +napl-store tree v1 132 +{"entries":[{"hash":"42ec5951151046f227be896e37e6906718443065a0a99c05bc7cf6bb85dbce45","kind":"dir","mode":16384,"name":"crates"}]} +napl-store receipt v1 815 +{"baseStateKey":"02051dd93f1be5de0dcfc96ef2d9c28c8bd05c00d65c9d9b3ffca1a3fa2cf893","baseStoreSnapshot":"dd5b21ef4dfb44f68ed82f9b84e73330a2a7341bf5a5dc350bdeb1980104fcea","baseWorkspaceHash":"5b6e85f70de3ba0107ccc1fc60b9e72b7f2e4581c52cf4519a8c1360f630b1a1","candidateTreeHash":"d83d2467beb91b948fff71e8ad6912f421053f3c456f4ef0d652253c7a1b8ce7","contractVersion":4,"inputKey":"65cf34ec0a6587883b89d13fdd2204c716e21cffd66d8d82956ffd61b3c07c63","module":"commands","outputManifest":[{"hash":"cb4a371433bd6721613afa223fb5e3fd962eb9d58272937e6b632c21a0a351be","path":"crates/napl/src/init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","path":"crates/napl/src/watch.rs"}],"receiptVersion":1,"sliceHash":"a70d8ad5a39c87958af5a0e3a572bc713effb0045ce26f3c91dc021aee9a1a22","target":"rust"} +napl-store blob v1 97983 +# NAPL: the deterministic compiler + +This prompt describes the entire NAPL toolchain, rebuilt as one small Cargo +workspace of exactly three crates. It replaces the legacy 76-crate +implementation completely; git history holds the original. This prompt is +the governing contract: the workspace's `.napl` prompts and the committed +code are the two truths, and no external document outranks them. + +## Layout: this module generates two crates on top of the ported store + +The workspace layout is fixed: the records directory `.napl/` lives at the +repository root; generated code installs under `crates/`; a module's +prompt file lives inside the crate directory that receives its code, and +the crate is the folder: every module whose `.napl` file sits in +`crates//` compiles into the `` crate as its own source +module, so one crate may be described by several prompt files +(`crates/napl/napl.napl` and `crates/napl/commands.napl` both build +`crates/napl/`). Module discovery finds prompts by walking the workspace +for `*.napl` files, and the workspace root is the nearest ancestor +directory containing `.napl`. A subtree that contains its own `.napl` +directory is a separate workspace: discovery never descends into it, the +way git never descends into a nested repository. Hidden directories and +`node_modules` are never descended into. + +The proven store already exists as a hand-ported crate at +`crates/napl-store/` (same envelope format `napl-store v1 +\n`, SHA-256 names, canonical JSON payloads, same object +kinds, same `.napl/store/` on-disk layout with HEAD compare-and-swap, +module-keyed locks, and dead-holder steal, ported code and tests from the +legacy store crates). Use its public API through a path dependency; do not +reimplement, wrap thinly, or fork any of its types or logic. The four new +object kinds the compiler needs (`transition`, `receipt`, `surface`, +`target-def`) already exist in it additively. ::: test -name: commit whose direct dep surface moved underneath returns stale-input -given: { workspace: fixture_chain_three, candidate: valid_for_middle, intervening_commit: leaf_surface_change } -expect: { outcome: stale_input, head_advanced: false } +name: the ported store hashes objects exactly as the original store crates did +given: { vectors: ported_store_hash_test_corpus } +expect: { all_pass_unmodified: true } ::: ::: test -name: commit whose own outputs moved underneath returns base-output-changed -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: same_module_other_candidate } -expect: { outcome: base_output_changed } +name: identical payload under two object kinds gets two different names +given: { payload: "X", kind_a: blob, kind_b: snapshot } +expect: { equal: false } ::: +This module's generated output is as many crates as the design needs, side +by side in the module's owned directory. Keep it a small, clearly separated +workspace (this replaces a 76-crate explosion; do not recreate one), with +`napl-store` staying its own untouched crate. The natural split, which you +may adjust if the design genuinely wants another boundary: + +1. **`napl-core/`** (lib): the pure compiler. Parsing `.napl` files (YAML + frontmatter: module, deps, targets, tests; markdown body), discovery and + dependency graph, canonical path grammar, the identity vocabulary, + per-module artifact slices, wave planning, tree-sitter surface extraction + for Rust and TypeScript targets, the surface grammar, placement rules + and entry-file projection derivation, receipt verification logic, the commit + decision table as pure functions, blame line-ancestry replay, lint + diagnosis, cost calculation, evidence validation. No filesystem writes, + no locks, no clocks, no environment reads: everything enters as + arguments and leaves as values. +2. **`napl-cli/`** (bin `napl`): every command, each a thin composition of + napl-core derivations and napl-store reads plus the one guarded write + path. No private logic in the binary beyond argument parsing and + formatting. + +The crates depend on `napl-store` by path, `serde_json`, `sha2`, +`tree-sitter`, `tree-sitter-rust`, `tree-sitter-typescript`, `clap` +(builder API only, driving the command line contract below), and nothing +that talks to a network, spawns a process, or reads clocks; those +declarations live in the user-owned manifests, not in any module's +output. Ensure `cargo test` passes for both crates and `napl-store` from +their workspace before finishing. Generated code is authored against its +final installed location, never a staging tree: acceptance runs at the +final location, so green certifies the bytes where they live. The +working-tree add forms are the operational spelling of this law: author +and verify in place, then stage from where the bytes live; assembling a +separate candidate directory is the exception, for staging bytes that +are not the working tree, never the routine. + +When several modules share one crate, the crate's shared entry files are +derived projections owned by no module: the crate root `lib.rs` module +list is recomputed at commit from the union of the crate's committed +module file sets, and the binary's command registry is recomputed from +the command tables the crate's modules expose under one fixed convention +(each module may expose `pub fn commands() -> &'static [CommandSpec]` +from its own files; the dispatch loop iterates the derived registry and +contains no per-command match arms of its own). A module's owned output +therefore never includes the shared entry files, two modules can never +claim the same path, and a commit that would write a projection-owned +path is rejected as invalid-candidate. + +A prompt at the workspace root is legal and means the workspace root is +the crate directory, exactly as git treats the directory it was +initialized in: the placement root is the empty workspace-relative path, +every workspace-relative path is inside it, and every join against it +produces clean relative paths, never a fabricated leading separator. A +cold workspace whose first prompt sits beside `.napl` therefore commits +its first module with no configuration; placement never needs declaring +because the prompt's location is the declaration. + ::: test -name: two concurrent disjoint commits both land and neither touches a manifest -given: { workspace: fixture_two_modules, concurrent_candidates: [module_a, module_b], orders: [ab, ba] } -expect: { both_installed: true, manifest_bytes_unchanged: true } +name: a root-level prompt places at the workspace root and its module commits +given: { workspace: fresh_init_root_prompt, candidate: valid_root_level } +expect: { placement_root: "", owned_globs_relative: true, outcome: installed } ::: ::: test -name: "absent telemetry reads as unavailable, never zero" -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: without_usage } -expect: { usage: unavailable } +name: status for a root-level module reports a workspace-root plan with clean globs +given: { workspace: fresh_init_root_prompt, flag: module_root } +expect: { plan_placement_root: "", owned_globs_leading_separator: false } ::: -Attribution rides the staged record; there is no other write path for it. -`napl add --attribution ` carries the module's mapping of prompt body -line ranges to generated file line ranges with notes; add validates every -claim before staging: each file path inside the module's owned globs, -each code line range present in the candidate tree, each prompt line -range present in the current normalized prompt body; a mapping that lies -is `invalid-candidate`. An entry may own no file at all: contract-level -prose — a law — that governs the module rather than any single path; -validation accepts a file-less entry (its prompt range must still exist) -and the query layer serves it flagged as law. An add whose transport is -incomplete and whose affirmations do not cover the listed claims fails -loudly as `invalid-candidate`, naming exactly which claims are missing; -`napl add --no-verify` is the explicit escape hatch that stages anyway -and records the module's attribution as stale on install. Any prompt -edit, code transition, or move that changes what a recorded mapping -points at marks it stale the same way; nothing ever repairs a mapping -automatically. `napl status` reports attribution freshness per -module (`fresh`, `stale`, `absent`) and, for stale or absent, emits a -machine-readable directive naming the exact fix: commit with a fresh -attribution for that module. Relocation recomputes only the path prefix -of a mapping, never its line content. The lsp serves a stale mapping -marked stale rather than hiding it; absent answers empty. +## The command line contract -An attribution entry is a reference, and a reference has a direction: -prompt-to-code, a prompt body line range naming generated file line -ranges, or code-to-prompt, a generated file line range naming a prompt -body line range. Every entry recorded before directions existed reads as -prompt-to-code; migration is a reading, never a rewrite of stored -records. Validation is identical for both directions and referential -only, the same claims checked the same way, and nothing about a -direction implies the sentence describes the code: a reference is a -claim with provenance and freshness, and the record vocabulary never -says more. +Every command the registry serves declares its full grammar in its +`CommandSpec`: name, a one-line summary, the exact usage line, and the +closed table of flags it accepts, each with arity and meaning. The +dispatch loop owns two behaviors ahead of every command, so no command +can get them wrong. First, help: `napl help` lists every registered +command with its summary, `napl help ` and `--help` or `-h` +anywhere in a command's arguments print that command's usage line, flag +table, and summary, exit 0, and never invoke the command. Help speaks +human by default and machine on request: when stdout is a terminal or +`--human` is passed, help renders readable text from the registry, +usage line, summary, flag table with meanings, and one worked example +per command; when stdout is piped or `--json` is passed, help emits +the canonical JSON registry exactly as today, byte-stable. Help is +the only command with a human rendering; every other command speaks +canonical JSON everywhere, and the two help renderings derive from +the one registry so they can never disagree. Help is a pure +read: no store access, no filesystem write, no state change of any kind, +and this holds for every command including `init` and `watch`. Second, +strict parsing: an argument beginning with `--` that the command's flag +table does not name is a usage error, never ignored; the error goes to +stderr as canonical JSON naming the unknown flag, listing the command's +valid flags, and suggesting the nearest declared flag by edit distance +when one is close, and the command does not execute. Exit 2 is the +pinned usage-error code for the whole binary: unknown command, unknown +flag, missing required flag or positional, malformed flag value. A +usage error always restates the command's exact usage line, so the +error itself teaches the grammar. -A reference carries a provenance block, recorded by the compiler at -staging time from what the caller supplied: recording model identity, -timestamp, tool version, and an optional usage-envelope reference -through the existing evidence seam. Every absent field is `unavailable`, -never zero, never invented, and provenance is display and pricing -metadata that never enters any identity, exactly as telemetry already -behaves. The query layer and the lsp serve the provenance block with the -entry. +The parser behind this contract is clap's builder API, constructed at +dispatch from the CommandSpec registry, never derive macros and never +per-command clap declarations: the registry stays the single source of +grammar and clap is its execution engine. clap's own renderings are +never emitted; every parse failure is caught and served as the +canonical JSON usage error above, byte-shaped by this contract alone. +`--flag value` and `--flag=value` parse identically; no command +declares short flags today, so clustering is outside the contract +until one does. The registry pre-pass, not clap, is what refuses an +undeclared flag, because the canonical JSON payload with its +nearest-name suggestion is pinned by this contract; clap normalizes +spellings and values behind it. `napl completions ` emits a completion script for +`bash`, `zsh`, or `fish`, generated from the same registry, a pure +read on stdout; an unknown shell name is a usage error. -Staleness covers both ends of a reference, and working-tree drift is one -of the ways an end changes: a reference whose code side no longer -matches the committed bytes, or whose prompt side changed in the working -tree, reads stale on both endpoints, computed at read time from the -records and the working tree, never stored as a transition. A hand edit -to generated source is drift like any other: legal, visible through the -same status and diff machinery, healable by restore or by re-recording -the reference; nothing repairs a reference automatically and nothing -polices the edit that staled it. +The commands whose meaning requires a committed head are exactly +`diff`, `blame`, and `notes add`; in a workspace that has none they +answer with a prerequisite error, never an empty success shape: the +payload names what is missing and the exact sequence that creates it, +from `napl init` through writing a prompt beside `.napl`, `add`, and +`commit`. `log` and `show` keep their committed contracts and answer +empty honestly. `napl blame` on a path no committed module owns, and +`napl diff` for a target under which no committed module owns output, +say so: the payload carries `tracked: false` and a reason naming the +absent baseline, never an empty patch or empty ancestry that reads as +success. Diff's operand is a target name and its usage line spells it +``; an operand that is not one (a module name, a file +path) is a usage error naming the target names that exist, so the +argument teaches its own vocabulary. The `napl notes add` refusal for a working path +that is not an object name states how an object name is obtained, +naming `napl log` and `napl show` with a one-line example. ::: test -name: a code-to-prompt entry stages and is served with its direction -given: { workspace: fixture_two_modules, candidate: valid_with_code_to_prompt_reference } -expect: { add_outcome: staged, served_direction: code_to_prompt } +name: flag equals value and separated value parse identically +given: { workspace: fixture_two_modules, invocations: ["status --module leaf", "status --module=leaf"] } +expect: { outputs_byte_identical: true } ::: ::: test -name: entries recorded before directions read as prompt-to-code unchanged -given: { workspace: fixture_two_modules, history: [pre_direction_attribution] } -expect: { served_direction: prompt_to_code, stored_records_rewritten: false } +name: completions emits a script for a known shell and refuses an unknown one +given: { workspace: fixture_two_modules, invocations: ["completions zsh", "completions tcsh"] } +expect: { zsh_exit_code: 0, zsh_output_nonempty: true, tcsh_exit_code: 2, store_bytes_unchanged: true } ::: ::: test -name: provenance is served with the entry and absent fields read unavailable -given: { workspace: fixture_two_modules, candidate: reference_without_usage_envelope } -expect: { provenance_served: true, usage: unavailable, cost_component: unavailable } +name: blame on an unowned path and diff on an outputless target say untracked +given: { workspace: fixture_two_modules, blame_path: file_no_module_owns, diff_target: target_without_outputs } +expect: { tracked: false, reason_names_baseline: true, exit_code: 0 } ::: ::: test -name: a hand edit to the code side reads stale on both endpoints +name: status reports code-side drift and stale references beside prompt freshness given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } -expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } +expect: { module_drift: true, stale_reference_count: 1, prompt_side_fresh: true } ::: ::: test -name: a working-tree prompt edit reads stale on both endpoints -given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched } -expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } +name: a headless workspace answers baseline commands with the prerequisite sequence +given: { workspace: fresh_init_no_commits, invocation: "blame some/file.rs" } +expect: { error_names_missing_head: true, sequence_named: [init, prompt, add, commit] } ::: ::: test -name: a code-to-prompt reference naming a missing prompt range is invalid-candidate -given: { workspace: fixture_two_modules, candidate: code_to_prompt_reference_to_missing_range } -expect: { outcome: invalid_candidate, published_semantic_state: false } +name: init with a help flag prints usage and mutates nothing +given: { directory: empty, invocation: "init --help" } +expect: { exit_code: 0, created: [], usage_printed: true } ::: -## edit and write: the capture verbs +::: test +name: an unknown flag is a usage error naming the flag and suggesting the nearest +given: { workspace: fixture_two_modules, invocation: "add cand --modul m" } +expect: { exit_code: 2, error_names_unknown: "--modul", suggested: "--module", executed: false } +::: -`napl edit` and `napl write` are the deterministic capture layer in -front of the same guarded door. They carry the familiar tool -signatures: `napl edit --old-string --new-string -[--replace-all]` replaces an exact occurrence in a file, and -`napl write ` writes stdin as the file's full content; its usage -line says the content comes from stdin, and an empty stdin refuses as -a usage error naming `--empty`, the explicit flag that authorizes -writing a zero-byte file, so a forgotten pipe can never silently -truncate. Either -side is editable, a prompt or a generated file, by a human or an LLM; -a hand edit through an ordinary editor stays equally legal, and the -verbs add capture, never exclusivity. The verbs never synthesize or -rewrite prose beyond the bytes given, never invoke a model, never -decide placement, never repair a reference, and never bypass the -guard: `add` and `commit` remain exactly what they are. +::: test +name: help lists every registered command and per-command help states exact usage +given: { workspace: fixture_two_modules, invocations: ["help", "help add"] } +expect: { all_commands_listed: true, add_usage_line_present: true, exit_code: 0, store_bytes_unchanged: true } +::: -Each call, deterministically and in this order: validate, apply, -capture. When the target is a prompt file, the result of the edit is -validated first by the same reader `check` and `add` use; a result -that fails validation refuses as `prompt-invalid` and applies nothing. -An `--old-string` that matches nowhere, matches more than once -without `--replace-all`, or equals `--new-string` refuses as -`edit-mismatch` and applies nothing, so a no-op edit can never mint a -record. Only a call that will succeed touches the working tree, so -every refusal leaves every file byte-identical. +::: test +name: help renders human text under the human flag and identical json when piped +given: { workspace: fixture_two_modules, invocations: ["help --human", "help --json"] } +expect: { human_not_json: true, human_carries_example: true, json_byte_stable: true } +::: -A call may carry one reference: `--ref `, a single object naming -a direction and both endpoints, prompt body line range and generated -file line range, in either direction per the reference contract above. -The reference is validated referentially only, exactly as strong as -attribution validation and no stronger: the file path inside the -owning module's placement, both line ranges present in the -post-edit bytes of their files. A reference naming a missing line or -a path outside placement refuses as `reference-unresolvable` and -applies nothing. A valid reference-bearing call stages a -reference-only record and commits it through the guarded transaction -in the same invocation: the transition carries the reference entry and -its provenance, no output-tree change and no surface change, and every -rule of the decision table, journaled atomicity, and recovery applies -to it unchanged. A call without `--ref` is a plain edit: apply, no -record, no store touch. Any existing reference whose endpoint the edit -moved needs no marking, because both-ends staleness is already -computed at read time from the working tree; the verbs store nothing -about it. +Manifests are user-owned glue seams, not records: `Cargo.toml` at the +workspace root and in every crate belongs to the user, edited by hand +like any Rust project, tracked by git, consumed by cargo, and NEVER +owned, installed, validated, or rewritten by the toolchain. A candidate +containing a manifest path is rejected as invalid-candidate; no module's +owned output includes one; nothing in the store records manifest bytes. +The toolchain's only relationship to manifests is diagnosis: `status` +reports, as observations never as writes, a module placed in a crate the +workspace manifest does not list, or a crate directory missing a +manifest. Every manifest diagnostic names the exact workspace-relative +manifest path it means and says the file is the user's to edit, so the +manifest is never a thing status alludes to without locating. A dependency needed by generated code is added by editing the +manifest, and the build battery is what proves it correct. -Provenance rides the session window: `--session ` names the -usage-proxy session whose log lines are this call's evidence, and when -the flag is absent the verb reads the `NAPL_SESSION` environment -variable, so a call made under `napl agent` carries its window with no -extra argument. The recorded usage reference is the window itself, -media type, workspace-relative log path, session id, and the line span -observed at staging, priced lazily by `napl cost` at read time, never -aggregated at record time. Model identity and timestamp come from the -window's log lines, or read `unavailable` when there is no window; -the verbs read no clock, so the same call over the same working tree, -store state, and log bytes produces byte-identical records. Absent is -`unavailable`, never zero, never invented, and provenance never -enters identity. +::: test +name: a candidate containing a manifest path is rejected +given: { workspace: fixture_two_modules, candidate: contains_cargo_toml } +expect: { outcome: invalid_candidate, manifest_bytes_unchanged: true } +::: -The three refusals join the closed terminal-outcome table under the -contract bump to 4, pinned exit codes continuing the table order: -`edit-mismatch` 27, `reference-unresolvable` 28, `prompt-invalid` 29. -None reuses `mapping-integrity`, and each is a canonical -machine-readable JSON payload naming what failed and that nothing was -applied or published. +## The two principles + +P1 ownership: the toolchain owns `.napl` source, the derived layer, the +store, and the mappings between them; every owned fact is provable from +content alone. It contains no LLM invocation, no provider identifier, no +model selection, no completion parsing, no process execution (the only +process is `napl` itself), no clock reads in any semantic path, no +randomness in any semantic path. Generation happens outside: the user's +agent session reads the plan from `napl status`, the specification through +the query surface, writes candidate code, and calls `napl add` then +`napl commit`. ::: test -name: an edit with a valid code-to-prompt reference stages and commits in one call -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } -expect: { outcome: installed, served_direction: code_to_prompt, output_tree_changed: false } +name: the binaries spawn no process and name no provider +given: { crate_source: self } +expect: { spawns_processes: false, provider_identifiers: [] } ::: +P2 footprint scoping: every operation declares a read set (content +identities consumed), a semantic write set (owned records changed), and its +derived projections plus physical install paths. Two operations conflict +when semantic write sets overlap or one's accepted write changes a content +identity in the other's read set. Derived projections (the crate-root +lib.rs module list, the command registry) are never conflict-bearing: +they are recomputed exactly once from the winning merged head inside +commit and rebase. Manifests are outside footprints entirely: user-owned +seams no operation reads as identity or writes at all. + +## Identity + +Expose `pub const CONTRACT_VERSION: u32 = 4` in napl-core (bumped from 1 +by the body-directive grammar, which changes how the same source bytes +are interpreted, from 2 by the relaxed module-name rule below, which +accepts sources contract 2 rejected, and from 3 by the capture verbs, +whose three refusal outcomes join the closed terminal-outcome table). Per module and +target: `surfaceHash` (hash of the recorded public surface), `inputKey` +(hash of the canonical closure manifest over normalized source hash, direct +dependencies' surfaceHashes, placement facts, entry-point version, target +name plus target-definition hash, CONTRACT_VERSION), `baseStateKey` (the +module's committed owned-output state hash), `sliceHash` (hash of the +module's canonical compile slice). Whole-compile `artifactHash` includes +scope and serves determinism tests only, never a commit precondition. Event +identity is `H(content hash + sorted parent hashes)`; ordinals and +timestamps are display metadata, never identity. Semantic identifiers are +content hashes, never a timestamp, pid, hostname, random value, model, or +provider. Telemetry lives in provenance records linking to semantic objects +by hash. + ::: test -name: the same edit call twice produces byte-identical records -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, runs_from_same_base: 2 } -expect: { records_byte_identical: true } +name: "a module's inputKey covers source, direct dep surfaces, placement, target identity, and contract version" +given: { workspace: fixture_two_modules, module: consumer } +expect: { input_key_components: [source_hash, direct_dep_surface_hashes, placement_facts, target_name, target_definition_hash, entry_point_version, contract_version] } ::: ::: test -name: a reference to a missing line refuses as reference-unresolvable and applies nothing -given: { workspace: fixture_two_modules, edit: ref_names_missing_line } -expect: { outcome: reference_unresolvable, exit_code: 28, files_byte_identical: true } +name: changing a leaf's surface changes the direct dependent's inputKey only +given: { workspace: fixture_chain_three, edit: rename_public_item_in_leaf } +expect: { changed_input_keys: [middle], unchanged_input_keys: [top] } ::: ::: test -name: a prompt edit that breaks the prompt refuses as prompt-invalid and applies nothing -given: { workspace: fixture_two_modules, edit: prompt_edit_breaking_directive } -expect: { outcome: prompt_invalid, exit_code: 29, files_byte_identical: true } +name: a leaf edit absorbed by an unchanged intermediate surface leaves higher dependents' inputKeys unchanged +given: { workspace: fixture_chain_three, edit: private_body_change_in_leaf } +expect: { changed_input_keys: [], unchanged_input_keys: [middle, top] } ::: ::: test -name: an ambiguous old-string without replace-all refuses as edit-mismatch -given: { workspace: fixture_two_modules, edit: old_string_matches_twice } -expect: { outcome: edit_mismatch, exit_code: 27, files_byte_identical: true } +name: telemetry differences never change transition identity +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipts: [with_usage, without_usage] } +expect: { transition_identities_equal: true } ::: ::: test -name: a prose-only edit needs no reference and touches no store state -given: { workspace: fixture_two_modules, edit: plain_prompt_edit_valid } -expect: { applied: true, store_bytes_unchanged: true } +name: history nodes are causal hashes over content plus sorted parents +given: { workspace: fixture_two_modules, commits: 2 } +expect: { event_id_components: [content_hash, sorted_parent_ids], ordinal_in_identity: false, timestamp_in_identity: false } ::: +## Module names + +A module name is any non-empty single-line scalar. The empty string is the only +value rejected, surfaced as `mapping-integrity`. No character class, case, or +identifier shape is required: `todo-app`, `todo_app`, and non-ascii names are all +legal. The same rule governs every name position the parser reads, the `module` +frontmatter value, each `deps` entry, and each declared root, so all three accept +exactly the set the module rule accepts. This relaxation replaces the contract 2 +identifier constraint and rides the CONTRACT_VERSION bump to 3, the sanctioned +identity transition. As with the 1-to-2 bump, every module's inputKey recomputes +because CONTRACT_VERSION is one of its components; no source or surface changes, +only the version does. + +Because a name is unrestricted, it is never used raw as a filesystem path segment +or a store key. napl-core exposes one injective, reversible encoding used at every +such site: each byte outside `[A-Za-z0-9._-]` is percent-encoded as `%XX` with +uppercase hex, and a name that is exactly `.` or `..` has its dots percent-encoded +too, so an encoded segment is never `/`, `\`, empty, `.`, or `..` and can never +traverse or alias a directory. A name already matching `[A-Za-z0-9._-]` and not +equal to `.` or `..` encodes to itself, so existing records keep their on-disk +names: `todo-app` stays `todo-app`. Every writer that keys a record or scratch +path by module name, the mapl, ir, attribution, and prompt-at-gen documents, the +staging and relocation scratch directories, and the store root map, uses the +encoded segment; decoding recovers the exact name. On a case-insensitive +filesystem two names differing only in ascii case can still share one segment; +that collision is the author's concern, distinct from the traversal safety above. + +Wherever a module name is serialized into a record it is written as a quoted, +escaped scalar, never interpolated raw, so a name containing YAML or JSON +metacharacters reparses to the identical name. + ::: test -name: write with empty stdin refuses naming the empty flag and writes nothing -given: { workspace: fixture_two_modules, write: target_with_empty_stdin } -expect: { exit_code: 2, error_names_flag: "--empty", files_byte_identical: true } +name: a non-identifier module name is accepted +given: { workspace: fixture_single_module, module_name: "todo-app" } +expect: { outcome: ok } ::: ::: test -name: an edit touching a referenced line reads stale on both endpoints afterward -given: { workspace: fixture_two_modules, edit: plain_edit_on_referenced_code_line } -expect: { reference_status: stale, stale_endpoints: [prompt, code] } +name: an empty module name is rejected as mapping-integrity +given: { workspace: fixture_single_module, module_name: "" } +expect: { outcome: mapping_integrity } ::: ::: test -name: a session window from the environment is recorded as the usage reference -given: { workspace: fixture_two_modules, env: { NAPL_SESSION: s1 }, log: proxy_log_with_s1_lines, edit: referenced_code_edit_with_ref } -expect: { provenance_usage_session: s1, usage_span_recorded: true, aggregated_at_record: false } +name: deps entries and declared roots accept the same names as the module rule +given: { module_name: "todo-app", dep_name: "a-b", root_name: "x.y" } +expect: { all_accepted: true } ::: ::: test -name: an edit with no session window records provenance unavailable -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, env: {} } -expect: { provenance_model: unavailable, provenance_usage: unavailable } +name: the name encoding is injective reversible and never traverses +given: { names: ["todo-app", "a/b", "..", "café", "a b"] } +expect: { decode_roundtrip_equal: true, no_segment_in: ["/", "\\", "", ".", ".."], safe_subset_unchanged: ["todo-app"] } ::: -## The rest of the porcelain +::: test +name: a name with yaml metacharacters is written quoted and reparses identically +given: { module_name: "a: {b} #c" } +expect: { record_reparse_equal: true, written_as_quoted_scalar: true } +::: -`napl status` observational classification, zero mutation, and it -judges both ends: per module it reports `drift: true` when the -committed output differs from the working tree, and the module's stale -reference count, the same derivations diff and the lsp already serve, -so status can never read clean while the query surface reads stale. -A module fresh on the prompt side and drifted on the code side says -so in one payload. `napl log` -per-module or store-wide history. `napl show ` inspect one object. -`napl diff` drift between committed outputs and working tree as a canonical -byte-stable patch. `napl blame ` derived ancestry: surviving lines -trace through accepted patches by canonical replay; rewritten regions blame -the rewriting transition, honestly coarse. `napl lint` unreachable modules -under an explicit `roots` declaration; when roots are absent the refusal -is loud, exit 2 with canonical JSON naming the missing prerequisite and -the exact declaration that would satisfy it, never an empty findings -list under exit 0; unconsumed surface entries (diagnosis only), module staleness; -findings are proposals with cascade previews; `napl lint --fix` applies -only complete deterministic mapping-safe edits through the guarded commit -path and never removes lines. `napl cost` pure calculation over an explicit -receipt set and a pinned pricing-table provenance object; unknown is -unknown, never zero. Edits price the way generations price: records the -capture verbs staged enter the same receipt set, their session-window -usage references resolve at read time against the named log's bytes, -summing the window's lines under the same pinned pricing table, and -`napl cost --session ` reports one session's calls directly from -the log. A record without a window prices `unavailable`; a window whose -log file or session lines are absent prices `unavailable` and says -which; a model the pricing table does not name prices unknown; nothing -is ever silently zero and nothing is aggregated at record time. The -same receipt set, log bytes, and pricing table produce byte-identical -reports. `napl gc` stages sweeps outside locks, swaps pointers -inside the bounded section, never blocks writers, never deletes anything -reachable from a named head. `napl fsck` envelope hashes, kind validity, -causal parent links. `napl restore` deterministic repair plans from -persisted evidence; unambiguous moves install, ambiguity blocks; liveness -and wall time never decide recovery. `napl mv ` relocation -as a semantic operation: moves a module's installed files to a new -placement, recomputes every placement-derived fact (derived entry-file -projections, attribution file paths) from the destination, passes -through the guarded commit path, and re-verifies there; failed -verification at the destination installs nothing; manifests are the -user's to update, and status diagnoses a placement the workspace -manifest no longer lists. A move made outside `napl mv` is drift like any other: -the store still holds the committed bytes, `napl restore` re-materializes -the module at its recorded placement, and when the drifted content matches -a committed state exactly the plan may instead adopt the new placement -through the commit path; anything ambiguous blocks. `napl cat-file ` -raw plumbing. `napl lsp` a language server over stdio for editor -extensions: hover, definition, references, and code lens over the -prompt-to-code mapping. Everything it serves derives from committed -records (attribution entries, module surfaces, the ir) resolved against -the workspace root; hover on a prompt line presents the attributed -generated spans with their notes, hover on a generated line presents the -owning prompt lines and note, definition jumps across the mapping in both -directions, references enumerates every attributed span, code lens counts -attributed files per prompt section. The server publishes diagnostics -derived from reference staleness: for every stale reference it emits one -diagnostic on each endpoint, the prompt span whose code moved and the -code span whose prompt changed, so sync debt is visible where the edit -happens, like a type error for documentation. Publication is a pure -derivation from committed records plus the working tree at the moment -the protocol asks, no background mutation, no clocks, no scanning loops -beyond what the editor protocol requires; opening or changing a -document refreshes that document's diagnostics and its counterparts'. -Severity is warning, never error: drift is a legal state, marked, not -policed. The diagnostic message states exactly what is known and -nothing more: which end drifted, the reference's direction, recorded -model or `unavailable`, referentially valid, and the healing verbs -(re-record through `napl edit`, or restore); no wording implies the -sentence describes the code. A fresh reference publishes nothing. -Resolving is always a human or LLM re-affirming or re-pointing the -reference through the capture verbs; the server proposes nothing and -repairs nothing. Pure reads only: no mutation, no -clocks, no background scanning. A request whose records are missing or -unparseable answers empty rather than erroring or blocking, and missing -or unparseable records degrade to no diagnostics, never an error; a stale -mapping is served marked stale, consistent with the attribution contract; -the server never hangs on a malformed or absent record. Record resolution is -anchored per document: walk up from the document's filesystem path to the -nearest ancestor directory containing `.napl` and read records there; the -server's working directory and the client's rootUri carry no meaning. -Every location the server emits that points at a module's prompt resolves -to the prompt file's actual placement found by workspace prompt discovery, -where the prompt sits inside the crate directory its code lands in; a -module name is never joined against the workspace root to fabricate a -prompt path, and a module whose prompt cannot be discovered answers empty. -Crash safety: every persisted application step leaves markers such that -recovery derives a deterministic plan from content alone. Evidence -validators are a registry keyed by media type (Claude CLI JSON envelope -ships first, and `application/x-napl-usage-proxy-jsonl-v1` validates -usage-proxy logs: one canonical JSON object per line, required fields -present, absent values the string `unavailable`, token counts -non-negative integers when present, a line failing the shape is -`invalid`); outcomes `validated`, `unsupported`, `invalid`, -`validator-error`; invalid stays visible as failed. Evidence bounds are -compiled-in constants; exceeding any is `resource-limit` before anything -publishes. +## The compile derivation + +Compilation is a pure derivation and NOT a command: parse the graph, +compute per-module slices for the changed frontier, derive +dependency-ordered waves. Never persisted, and never spelled on the CLI — +it is the internal engine of `status`, `add`, `commit`, `watch`, and the +lsp. Per module the slice carries: normalized source body and hash, direct +dependency surfaces and surfaceHashes, placement and allowed-output +constraints including the surface grammar, the owned-output base and +baseStateKey, inputKey, sliceHash, wave position, receipt requirements. It +contains no provider, model, prompt assembly, credentials, timestamps, +retry policy, pricing, or test results. `napl status --module ` +reports the module's PLAN from this derivation — owned globs, placement +root, projection ownership, receipt requirements, wave position — the +invariant requirements only, a pure function of the working tree's +prompts; candidate-dependent validation belongs to `add`, never the plan. + +Pending is never a bare boolean. A pending module names its reason +from a closed vocabulary: `never-committed`, `source-changed`, +`dependency-surface-moved`, `placement-changed`, `target-changed`, +`contract-version-changed`; the reason is derived by comparing the +current derivation's inputKey components against the identities the +committed state records, the same facts the store already holds, +never a guess. And because an undrifted working tree stages exactly +the committed bytes, a pending module with no drift additionally +says the landing would re-stamp identical bytes, so whether a +pending module needs a real regeneration or a no-op landing is read +off status, never discovered by staging and comparing tree hashes. ::: test -name: a stale reference publishes one diagnostic on each endpoint -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, lsp: [open_prompt, open_code] } -expect: { prompt_diagnostics: 1, code_diagnostics: 1, severity: warning } +name: a pending module names dependency-surface-moved as its reason +given: { workspace: fixture_chain_three, intervening_commit: leaf_surface_change, flag: module_middle } +expect: { pending: true, pending_reason: dependency_surface_moved } ::: ::: test -name: a fresh reference publishes nothing -given: { workspace: fixture_two_modules, lsp: [open_prompt, open_code] } -expect: { prompt_diagnostics: 0, code_diagnostics: 0 } +name: a pending undrifted module says the landing re-stamps identical bytes +given: { workspace: fixture_chain_three, intervening_commit: leaf_surface_change, flag: module_middle } +expect: { pending: true, drift: false, reland_identical_bytes: true } ::: ::: test -name: "the diagnostic message carries drifted end, direction, and provenance and nothing semantic" -given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched, lsp: [open_code] } -expect: { message_names_drifted_end: true, message_names_direction: true, message_model_or_unavailable: true, message_semantic_claims: 0 } +name: a never committed module reads pending with its reason +given: { workspace: fixture_two_modules, module: pending_never_committed, flag: module_pending } +expect: { pending: true, pending_reason: never_committed } ::: ::: test -name: absent or unparseable records publish no diagnostics and answer empty -given: { workspace: fixture_two_modules, records: attribution_file_corrupted, lsp: [open_prompt] } -expect: { diagnostics: 0, error_responses: 0, server_alive: true } +name: a prompt edit reads pending as source-changed +given: { workspace: fixture_two_modules, working_tree_edit: prompt_body_touched, flag: module_edited } +expect: { pending: true, pending_reason: source_changed } ::: ::: test -name: committing the drifted code clears both endpoints' diagnostics on refresh -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, then: commit_module, lsp: [change_code_document] } -expect: { prompt_diagnostics: 0, code_diagnostics: 0 } +name: status reports a module plan derived from the working tree +given: { workspace: fixture_two_modules, flag: module_leaf } +expect: { plan_fields: [owned_globs, placement_root, receipt_requirements, wave_position], candidate_dependent_fields: absent } ::: ::: test -name: diff renders drift between committed output and working tree as a canonical patch -given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched } -expect: { diff_nonempty: true, canonical_patch: true, byte_stable: true } +name: status module answers live state and the derived projection beside the plan +given: { workspace: fixture_two_modules, flag: module_leaf, working_tree_edit: leaf_output_touched } +expect: { state_fields: [attribution, drift, pending, stale_references, tracked], drift: true, projection_path_present: true, projection_content_present: true } ::: ::: test -name: "blame over a whole-file regeneration coarsens to the transition, line ancestry survives accepted patches" -given: { workspace: fixture_two_modules, history: [patch_commit, full_rewrite_commit] } -expect: { patched_lines_trace_to_patch: true, rewritten_region_blames_transition: true } +name: show lists attached notes inline and log counts them +given: { workspace: fixture_two_modules, history: [commit, note_attached] } +expect: { show_notes_listed: 1, show_note_text_present: true, log_note_count: 1 } ::: +## Body directives + +The prompt body grammar reserves directive blocks, `directiveVersion 1`, +and recognizing them is part of the identity contract: text that older +binaries read as inert prose can be a compile error under this grammar, so +this recognition rides a CONTRACT_VERSION bump, the sanctioned identity +transition. The directives are `test` and the docs family stated below. +For `test`, an opener is a line that is exactly `::: test`; the closer +is a line that is exactly `:::`; nothing nests inside a test block. An +unknown `::: ` opener is a compile error, never prose, so future +directive names are non-breaking. Test blocks are body text: they count +in normalized body lines and attribution ranges may cover them. Docs +fences are the one exception, display content excluded from the +semantic body, and their own section states that law in full. + +Block content is one case in an exact value grammar mapping 1:1 onto +canonical JSON, so two binaries cannot disagree on a value: one mapping +with keys `name` (required, non-empty, single line), `given` (optional, +mapping), `expect` (required, mapping). Block mappings and sequences +indent by exactly two spaces; flow `{}` and `[]` forms are allowed. +Strings are double-quoted with JSON escapes, or plain scalars ended by a +structural character (`:`, `,`, `{`, `}`, `[`, `]`, `#`) or end of line +with no leading or trailing space. Numbers are JSON number syntax; +`true`, `false`, `null` are keywords; every other plain scalar is a +string. Nothing else from YAML exists here: no comments, no duplicate +mapping keys, no anchors, aliases, tags, block scalars, multiline +scalars, multi-document markers, octal or hex, timestamps, or merge keys. +A case needing prose belongs in prose. Diagnostics are compile errors +naming the exact body line, judged in this precedence: unclosed block, +unknown directive name, nested opener, content outside the value grammar, +missing or empty name, missing expect, duplicate case name within the +module across both positions after NFC normalization and whitespace trim. +Frontmatter `tests:` remains legal; a module's case set is the union of +both positions; extraction of cases from body bytes is deterministic — +same bytes, same cases. + ::: test -name: lint reports an unreachable module only under declared roots and refuses without them -given: { workspace: fixture_orphan_module, roots: absent } -expect: { unreachable_analysis: refused, exit_code: 2, reason_names_roots: true, findings_field_absent: true } +name: an unclosed test directive block is a compile error naming its opener line +given: { body: directive_without_closer } +expect: { outcome: compile_error, line_named: opener } ::: ::: test -name: lint with declared roots proposes removal of the orphan with a cascade preview and never auto-applies -given: { workspace: fixture_orphan_module, roots: [main_module] } -expect: { findings: [unreachable_napl], auto_applied: false, cascade_preview: true } +name: "an unknown directive name is a compile error, never prose" +given: { body: unknown_directive_opener } +expect: { outcome: compile_error, treated_as_prose: false } ::: ::: test -name: lint --fix drops an unreferenced declared dependency through the guarded commit path -given: { workspace: fixture_unused_dep, fix: drop_unused_dep } -expect: { outcome: installed, transition_recorded: true, dep_removed: true } +name: a duplicate case name across frontmatter and body errors +given: { frontmatter_case: alpha, body_directive_case: alpha } +expect: { outcome: compile_error, diagnostic: duplicate_case_name } ::: ::: test -name: a cost report over a receipt set containing verb-staged records includes the edits -given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines } -expect: { report_rows: 2, edit_row_priced: true, same_pricing_table: true } +name: directive case extraction is deterministic +given: { body: fixture_with_two_directives, runs: 2 } +expect: { cases: 2, bytes_equal: true } ::: +### The docs directive + +A prompt section may carry `::: docs` fences. The body of a docs fence +is display prose for human documentation: plain markdown plus the +language's own directive vocabulary, never a renderer's component +syntax, so any consumer that can render markdown and the named +directives can render a prompt, and no prompt ever names a website's +component. + +The docs vocabulary is language-owned and deliberately tiny: `docs` +for prose, `example` for runnable content, `note` and `warning` as +admonitions. An example body is validated: napl source in it must pass +check and CLI invocations in it must parse under the live command +grammar, so a published example cannot rot. An example fence may +carry a `lang` attribute naming what its body is, `lang="napl"` for +prompt source and `lang="cli"` for command invocations; when `lang` +is present it selects the validator, and when it is absent the +validator is chosen by inspection as before, so every existing fence +keeps its meaning. A `lang` the language does not know is refused +naming the line, because a validated vocabulary with an unvalidated +discriminator would let examples rot behind a typo. Directives named `x-` +followed by anything are the extension namespace: carried verbatim, +never validated, a renderer's private experiment and a candidate for +promotion. A renderer meeting an unknown directive renders its body as +a plain block, never an error, so old renderers survive new +vocabulary; check keeps refusing unknown names, so tolerance is a +renderer rule, never a check rule. + +Directive names follow the micromark grammar: alphanumeric plus `-` +and `_`, never ending in `-` or `_`, so `x-tabs` is a name and a bare +`x-` is not. Attributes are HTML-like braces per the same grammar, +the `:::example{name="..."}` spelling: `{a}`, `{a=b}`, `{a="b"}`, and +`{a='b'}` are equivalent, `.class` shorthands accumulate, and a later +`#id` overrides an earlier one. + +Fence lines are unambiguous by shape: an opening line names its +directive, a closing line is bare colons. The parser keeps a fence +stack. Nesting spells the remark-directive way, more colons outside: + + ::::docs + prose + :::example{name="read the head"} + napl q '.head' + ::: + :::: + +A closing line of N colons closes the innermost open fence whose +opening count is at most N, the same discipline code fences use, so +every document legal under remark-directive parses here with the same +shape. Same-count nesting is additionally legal, because an opening +line always carries a name and a closing line never does. In this +contract only top-level fences carry docs payloads; an inner fence +rides verbatim inside the outer body, unparsed, so a later contract +may parse children without breaking any prompt written today. Code +fences suspend the stack, so a code block may show `:::` lines and +directive syntax freely; directive syntax shown as prose must sit in +a code fence for the same reason. Where micromark lets an unclosed +container run silently to the end of its parent, check refuses it +naming the opening line: deliberate strictness, not incompatibility. + +Fences are ordered siblings within their section, and order is +meaning: prose, then a warning, then more prose renders as that +interleaved flow, which is how a caveat lands mid-explanation without +nesting. Adjacent example fences sharing a name and differing by a +variant attribute are one example in several spellings; how a +renderer groups them is the renderer's business. + +The first docs fence of a module, when it sits before the module's +first section heading, is the module preamble: its body introduces the +module and its attributes may carry `title` and `description`, +document metadata any renderer may use for a page head, an index card, +or a search snippet. A module without a preamble fence simply has no +display title beyond its name. + +Docs fences are display content, not contract. The semantic body of a +prompt, the bytes that inputKey, attribution prompt hashes, and every +freshness derivation consume, is the body with all docs fences +removed, and attribution endpoints name lines of that semantic body, +so inserting or growing a docs fence shifts no endpoint. Editing, +adding, or deleting a docs fence therefore never re-keys a module, +never marks code pending, and never moves an attribution endpoint. A +prompt whose only change is inside docs fences produces the same +inputKey and the same attribution verdicts, and the store records the +new prompt bytes as a new object whose module state is unchanged. + ::: test -name: an edit without a window and a window with missing log lines both price unavailable naming why -given: { workspace: fixture_two_modules, receipts: [edit_without_window, edit_with_window_to_missing_lines] } -expect: { priced_unavailable: 2, reason_named_per_row: true, zeros: 0 } +name: "a docs-only edit changes prompt bytes but no key, pending, or endpoint" +given: { edit: inside_docs_fence_only } +expect: { input_key_changed: false, pending: [], endpoints_moved: 0, prompt_bytes_changed: true } ::: ::: test -name: a model absent from the pricing table prices unknown never zero -given: { workspace: fixture_two_modules, receipts: [edit_with_unknown_model_window], log: proxy_log_unknown_model } -expect: { priced_unknown: 1, zeros: 0 } +name: a docs fence containing a code fence containing three colons parses as one fence +given: { body: docs_fence_with_code_fence_colons } +expect: { docs_payloads: 1 } ::: ::: test -name: cost reports are byte-identical across runs over the same receipts log and table -given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines, runs: 2 } -expect: { bytes_equal: true } +name: a same-count example fence inside a docs fence rides verbatim in one payload +given: { body: docs_fence_with_inner_example_same_count } +expect: { docs_payloads: 1, inner_fence_verbatim: true } ::: ::: test -name: cost session reports one session's calls directly from the log -given: { workspace: fixture_two_modules, log: proxy_log_two_sessions, session: s1 } -expect: { rows_for_session: 2, rows_for_other_sessions: 0, priced_from_log: true } +name: a four-colon docs fence closes around a three-colon example per remark +given: { body: four_colon_docs_wrapping_three_colon_example } +expect: { docs_payloads: 1, inner_fence_verbatim: true } ::: ::: test -name: a proxy log line failing the shape validates invalid and stays visible as failed -given: { evidence: proxy_log_with_malformed_line, media_type: application_x_napl_usage_proxy_jsonl_v1 } -expect: { outcome: invalid, visible_as_failed: true } +name: a fence left open at section end is refused naming the opening line +given: { body: docs_fence_without_closer } +expect: { outcome: compile_error, line_named: opener } ::: ::: test -name: cost over one receipt set and pricing version is byte-identical across runs and unknown for unknown models -given: { receipts: fixture_receipts, pricing_version: pinned, runs: 2, unknown_model_receipt: included } -expect: { reports_equal: true, unknown_model_cost: unknown } +name: an invalid directive name ending in a hyphen is refused naming the line +given: { body: docs_fence_named_x_dash } +expect: { outcome: compile_error } ::: ::: test -name: gc never deletes an object reachable from the current head and never blocks a concurrent commit -given: { workspace: fixture_two_modules, concurrent: [gc, valid_commit] } -expect: { reachable_deleted: false, commit_blocked: false, unreachable_swept: true } +name: an example lang attribute selects the validator +given: { body: example_lang_napl_with_cli_looking_body } +expect: { validated_as: napl_source } ::: ::: test -name: fsck detects a corrupted object and a broken parent link -given: { workspace: fixture_two_modules, corrupt: [flip_byte_in_object, remove_parent_event] } -expect: { findings: [hash_mismatch, missing_parent], exit_nonzero: true } +name: an unknown example lang is refused naming the line +given: { body: example_lang_unknown } +expect: { outcome: compile_error } ::: ::: test -name: restore applies an unambiguous move-repair plan and blocks on ambiguity -given: { workspace: fixture_moved_output, ambiguity: [none, two_candidates] } -expect: { unambiguous: installed, ambiguous: blocked } +name: a napl code block inside a docs fence that fails check is refused naming the block line +given: { body: docs_fence_with_broken_napl_example } +expect: { outcome: compile_error, line_named: example_block } ::: ::: test -name: hover on an attributed prompt line resolves its generated spans -given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: within_entry } } -expect: { spans_resolved: true, notes_included: true } +name: a CLI line inside an example fence must parse under the live command grammar +given: { body: docs_example_with_ghost_cli_flag } +expect: { outcome: compile_error } ::: +## Recorded surfaces + +At commit, extract the candidate's public API with tree-sitter (pinned +grammars) and record it as a `surface` object; dependents' inputKeys pin +recorded surfaceHashes, store facts, never demand scans. The surface +grammar makes extraction sound by construction and violations reject as +`mapping-integrity`: for Rust, public items must be named `pub` +declarations at statically visible paths, no glob re-exports, no +macro-generated `pub` items; a named `pub use` is allowed and is recorded +as the unresolved written binding, never a resolved signature. For +TypeScript: explicit named exports only, no `export *`. Extraction is +syntactic, deterministic, and loud on malformed input. Loud means a +payload, never silence: extraction over bytes that do not parse +answers a parse failure naming the file and the first error's line, +distinct from a genuinely empty surface, in every seat that extracts, +the commit recorder, the `surface` read, and the query layer, so an +empty item list always means parsed and nothing public. Re-extraction from +committed output must reproduce the recorded surface byte-identically. + +The `surface` read is honest about absence the same way: a module with +no committed state under the target answers `tracked: false` naming +the absent baseline, never an empty item list that reads as a real +surface with nothing public. + ::: test -name: "hover on an unattributed line answers empty, not error" -given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: outside_entries } } -expect: { result: empty, error: false } +name: extraction over unparseable bytes answers parse-failed never an empty surface +given: { source_bytes: syntactically_invalid_rust, extraction: rust_surface } +expect: { outcome: parse_failed, error_names_line: true, served_as_empty_surface: false } ::: ::: test -name: missing attribution records degrade to empty responses -given: { attribution: absent, request: hover } -expect: { result: empty, error: false, blocked: false } +name: surface for an uncommitted module says untracked never an empty list +given: { workspace: fixture_two_modules, module: pending_never_committed, read: surface_for_target } +expect: { tracked: false, reason_names_baseline: true, empty_item_list_served: false } ::: ::: test -name: definition from a generated line targets the prompt at its discovered placement -given: { attribution: fixture_attribution_small, prompt_placement: inside_crate_dir, request: definition, position: { file: generated_within_entry } } -expect: { location_file: discovered_prompt_path, root_joined_module_name: false } +name: a candidate with a glob re-export is rejected as mapping-integrity +given: { workspace: fixture_two_modules, candidate: contains_glob_reexport } +expect: { outcome: mapping_integrity, published_semantic_state: false } ::: ::: test -name: evidence failing its validator is invalid and distinguishable from unsupported -given: { receipt_evidence: [valid_claude_json, malformed_claude_json, unknown_media_type] } -expect: { outcomes: [validated, invalid, unsupported] } +name: a named pub use is recorded in the surface as the written binding +given: { workspace: fixture_two_modules, candidate: valid_with_named_reexport } +expect: { surface_contains_written_binding: true, surface_contains_resolved_signature: false } ::: ::: test -name: a receipt exceeding evidence bounds is rejected as resource-limit with nothing published -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: oversized_evidence } -expect: { outcome: resource_limit, published_semantic_state: false, published_provenance: false } +name: recorded surface re-extraction from committed output is byte-identical +given: { workspace: fixture_two_modules, candidate: valid_for_consumer } +expect: { reextraction_equal: true } ::: -## Determinism contract +## add, then commit -Every collection canonically ordered; every input explicit and content -identified; serialization byte-pinned; ambient environment excluded -(locale, timezone, machine identity, CPU count, enumeration order); reads -never write; a state transition names its base and fails on mismatch per -the decision table rather than recalculating. Same workspace compiled -twice, from different absolute paths, under different discovery orders, -locales, and timezones produces byte-identical artifacts. The test suite -proves the vectors in the frontmatter with deterministic fixtures -constructed in each crate's `tests/`. +Git parity is the mental model: `add` stages, `commit` installs. The +receipt is an internal record between them, never a user-facing noun. +`napl commit` with no arguments installs every staged record in +dependency-wave order, rebasing where the decision table allows, one +outcome reported per record; `--staged ` installs one. A +multi-module add followed by a bare commit is therefore one command +each, with the sequencing and retry the table already defines owned by +the tool, never hand-looped; and when an earlier record in the same +invocation moves the head so that a later one returns write-conflict +or stale-input against it, bare commit re-derives that record against +the new head, exactly what re-running add would produce, and retries +it once, so modules sharing one crate land in one invocation instead +of hand-ordered pairs. A retry that fails again returns its outcome +honestly. A bare commit that finds nothing staged says so instead of +dressing emptiness as success: the payload names that zero records +were staged, and when any module carries working-tree drift it names +each drifted module beside the exact `napl add` invocation that +would stage it, so the empty commit is the teacher, never the trap. +Commit sweeps the staged record it installed, so a later +bare commit never re-attempts history; `napl gc` reports staged +records among the unreachable, and `napl gc --apply` sweeps them: +they are disposable litter by the git-state law, re-creatable by +re-running add, and no one should ever hand-edit `.napl` to be rid of +them. + +`napl add` stages a module state from any of three sources, and the +working tree is the default one, exactly as in git: + +- `napl add [...]` names working-tree files. Each path + resolves to its owning module through the owned globs (unique by + commit invariant, so no `--module` is needed), and the staged state + is that module's committed output with exactly the named files + overlaid from the working tree. Paths owned by different modules + stage one record per module in one invocation. A path no module owns + is a usage error naming the owned globs that do exist. +- `napl add --module ` stages the module's whole owned tree as + it sits in the working tree. +- `napl add --module ` stages explicit candidate + bytes, for the rare case where the bytes to stage are not the + working tree. + +A module's own prompt path is the one path add accepts that no owned +glob names, and it is accepted for exactly one purpose: recording a +docs edit. When the working-tree prompt differs from the committed +prompt only inside docs fences, so the semantic body is byte-identical, +`napl add ` stages a docs record: the new prompt bytes +and nothing else, no candidate tree, no attribution work, no usage. +Commit installs it by writing the new prompt object and re-pointing +the module's prompt at it while every derived fact holds: same +inputKey, same attribution map, same endpoints, same pending state, +and the transition says `docs-only` so history never confuses prose +with semantics. When the prompt's drift reaches outside docs fences, +the same invocation refuses and names the first semantic line that +moved, teaching `napl add --module` as the path for real changes, +because a docs record that smuggled a semantic edit would be an +unreviewed module change. The empty-commit teacher includes docs +drift: a bare commit finding only docs-drifted prompts names each one +beside the `napl add ` that would record it. ::: test -name: compiling the same workspace twice yields byte-identical artifacts -given: { workspace: fixture_two_modules, runs: 2 } -expect: { artifacts_equal: true } +name: a docs-only prompt edit stages and commits as a docs record leaving module state untouched +given: { edit: inside_docs_fence_only, then: [add_prompt_path, commit] } +expect: { transition: docs_only, input_key_changed: false, endpoints_moved: 0, pending: [], prompt_object_updated: true } ::: ::: test -name: artifact bytes are independent of discovery order -given: { workspace: fixture_two_modules, discovery_orders: [sorted, reversed, shuffled_seed_7] } -expect: { artifacts_equal: true } +name: add of a prompt path with semantic drift refuses naming the first semantic line +given: { edit: outside_docs_fence, then: [add_prompt_path] } +expect: { outcome: refused, line_named: first_semantic_change, teaches: add_module } ::: ::: test -name: artifact bytes are independent of the absolute workspace path -given: { workspace: fixture_two_modules, mounted_at: [rootA, rootB] } -expect: { artifacts_equal: true } +name: a bare commit names docs-drifted prompts beside the add that records them +given: { edit: inside_docs_fence_only, then: [commit] } +expect: { installed: 0, docs_drifted_named: true, teaches: add_prompt_path } ::: +All three forms feed one pipeline with `[--attribution ] +[--usage ]`: ingest the staged bytes into immutable content +addressed blobs (reject links, devices, duplicate normalized paths, +absolute or parent-escaping paths, oversized entries or totals; all +later steps read ingested blobs, never the source files); parse every +staged file the target claims as source with the target's pinned +tree-sitter grammar, and refuse as `invalid-candidate` when any parse +tree carries an error node, naming the file, the first error's line, +and the malformed region, because a surface extracted from a broken +tree is not a fact worth recording; the gate is syntax only, judged by +the same grammar the surface extractor uses, and everything past +syntax, types, borrows, semantics, stays the acceptance battery's job; +commit re-runs the same gate when it recomputes the staged record, so +bytes that do not parse can never install through any door; compute +the slice and validate against it; resolve attribution as below; validate +and attach usage evidence when supplied; then write the staged record — +the internal receipt: identities generated against, base snapshot +hashes, candidate tree hash, output manifest, attribution, optional +reported fields. Validation failures surface at add time with the same +outcome classes commit uses. Staged records are transient coordination +state, disposable litter by the git-state law: never promoted to +history, safely deletable, re-creatable by re-running add. + +Attribution transports instead of being demanded. Add computes the +byte diff between the module's committed output and the staged bytes, +and the prompt's committed body and its pending body, and transports +every committed attribution claim through those diffs +deterministically: a range the diff proves untouched survives +verbatim, a range below an insertion or deletion shifts by its size, +and a claim landing in a rewritten or ambiguous region does not +transport, because the transport never guesses; a range it cannot +prove through the diff is asked for, never invented. When every claim +transports, add stages with the transported map and no `--attribution` +flag is needed: the paperwork updates itself as a consequence of the +diff already being content-addressed on both sides. When any claim +fails to transport, add refuses as `invalid-candidate` listing exactly +the claims that need affirmation, each beside the observed bound or +region it was checked against, and `--attribution` supplies +affirmations for the listed claims only, layered over the transported +remainder. Any attribution refusal, transport or explicit, names the +observed bound (the actual line count or surviving range) next to the +rejected claim, so a wrong range never costs a manual count. Each +listed claim also carries its index in the recorded map and its note +verbatim, so an affirmation is written against named prose, never +positional trust or a separate query join. + ::: test -name: canonical json sorts keys and is byte-stable -given: { value: "{\"b\":1,\"a\":2}", encodings: 2 } -expect: { bytes_equal: true, sorted_keys: true } +name: a transport refusal lists each claim with its map index and note +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } +expect: { listed_claims: 1, claim_index_present: true, claim_note_verbatim: true } ::: -napl-store tree v1 255 -{"entries":[{"hash":"e9e8397be3fefaa27b6009ddfcd9f9949a06d531dc07c64c1f6f13e8d43a33d0","kind":"file","mode":33188,"name":"init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","kind":"file","mode":33188,"name":"watch.rs"}]} -napl-store tree v1 132 -{"entries":[{"hash":"d6d79f2130efcc425bb0177dc83a0093cb305233932544e161162ba9474b938d","kind":"dir","mode":16384,"name":"crates"}]} -napl-store transition v1 843 -{"baseStateKey":"93bbadd9800acdd4b01ec77020982562dce3e81fc0250ce794d78654c94a1238","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"dddfe696dea70b0c812204774c03469b7239f61e0388ee0b314f8302290acff0","installPaths":["crates/napl/src/serve.rs"],"module":"serve","newState":"70c656393697d4192e4f4cadf2217370c3556a0ec85655605fb63deb7a400973","readSet":["base-state:93bbadd9800acdd4b01ec77020982562dce3e81fc0250ce794d78654c94a1238","input-key:dddfe696dea70b0c812204774c03469b7239f61e0388ee0b314f8302290acff0","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"d14bdf8caefad65ff735cff07223c8d3a740cb2fba79eccd5daba36cfaaa1d09","surface":"0a742d0232e4b7000bd79ff29b39af501675254174d2c98163f8b7337cf7f0b7","target":"rust","writeSet":["owned:crates/napl","module-state:rust/serve"]} -napl-store blob v1 24802 -use std::io::Write as _; -use std::path::Path; -use serde_json::{json, Value}; +A module's first add has no committed map to transport, and it demands +no hand-authored one either: absent `--attribution`, add stages the +trivial whole map, every staged file claimed in full by the whole +prompt body, recorded as whole-file claims and honestly coarse. The +cold path to a verified first commit is therefore write the prompt, +write the code, `add --module`, `commit`, with attribution present and +fresh, never `absent`, and `--no-verify` never required to get started. +A claim, in any map, may be spelled whole: naming a file with no +numbers claims all of it, and naming a prompt range as whole claims +the whole body; bounds are derived at validation time from the staged +blob and the pending body, so no line count is ever transcribed by +hand and a whole-file claim survives any growth of its file. -use crate::authoring::apply_fix; -use crate::canon::canonical_bytes; -use crate::cli::{ - clap_root, emit_prerequisite, flag, has_switch, ok, positional, positionals_from, repeated_flag, - require_flag, resolve_workspace, switch, usage, value, CliResult, CommandSpec, FlagSpec, - USAGE_EXIT_CODE, WORKSPACE_FLAG, -}; -use crate::commit::recover; -use crate::disk; -use crate::error::{outcome_exit_code, OutcomeCode, Result}; -use crate::lint::{is_auto_fixable, lint_json, refusal_json, Analysis}; -use crate::mv::relocate_module; -use crate::porcelain::{ - add_note, blame_path, cat_file, cost, diff, fsck_store, gc, lint_workspace, log, module_plan, - restore, show, status, surface_of, -}; +Two more spellings close the remaining gaps: `napl add +--module ` stages a path under a module explicitly, for the file +that is new in a crate several modules share, where neither the +committed manifest nor the globs can disambiguate it alone; and +`napl add --check` with any form runs the entire pipeline, transport +verdict and refusal listing included, and writes nothing, not even a +staged record, the dry run that answers before anything lands. The +check verdict says exactly what it judged and what it did not: the +payload enumerates the validations that ran (placement, ingest rules, +the parse gate, attribution transport, evidence) and names what stays +unjudged, everything past syntax, the acceptance battery's job, so +`would-stage` means the record is sound and the syntax parses, never +that the code is correct. A transport refusal +reports each endpoint's verdict separately: a claim whose prompt side +failed but whose code side transported serves the transported code +range beside the failed prompt range, and the affirmation supplies +only the side that actually needs a human, never both because one +refused. An unparseable or wrong-shaped `--attribution` file is +answered like a bad ref payload: the refusal carries the complete map +contract, every field and one complete valid example, never a bare +"not parseable". The map reader speaks the same value grammar as body +directives, so block and flow spellings of one map are one map: +`lines: [4, 9]` and its two-line block form parse identically. A map +that parses but yields no entries, or that fails at any construct, is +refused naming the exact line and the construct that stopped the +reader, beside the full contract, never a bare count of zero. The +contract is also taught before any failure: `help add` and +`add --help` serve the same complete map contract and worked example +the refusal carries, so the map is learnable from help instead of +from trial and error. -/// Only `--workspace`: the command reads the workspace and nothing else. -const WORKSPACE_ONLY: &[FlagSpec] = &[WORKSPACE_FLAG]; +::: test +name: flow and block spellings of one attribution map parse identically +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, maps: [flow_sequences, block_sequences] } +expect: { parsed_entries_equal: true, both_stage: true } +::: -const ADD_FLAGS: &[FlagSpec] = &[ - value( - "--attribution", - "path to a map affirming the claims the transport listed; entries may spell an endpoint `whole`", - ), - switch( - "--check", - "run the whole pipeline including the transport verdict and write nothing at all", - ), - value("--module", "the module the candidate is output for"), - switch("--no-verify", "stage without the acceptance verification pass"), - value("--target", "the target to stage for, default rust"), - value("--usage", "path to the usage envelope recorded as provenance"), - WORKSPACE_FLAG, -]; +::: test +name: a map failing mid-parse names the line and construct beside the contract +given: { workspace: fixture_two_modules, attribution_file: bad_construct_on_line_3 } +expect: { error_names_line: 3, error_names_construct: true, error_carries_valid_example: true } +::: -const COMMIT_FLAGS: &[FlagSpec] = &[ - value( - "--staged", - "path to one staged record `napl add` reported; without it every staged record installs in wave order", - ), - WORKSPACE_FLAG, -]; +::: test +name: add help carries the complete attribution map contract and example +given: { workspace: fixture_two_modules, invocation: "help add" } +expect: { map_fields_listed: true, map_example_present: true, exit_code: 0, store_bytes_unchanged: true } +::: -const STATUS_FLAGS: &[FlagSpec] = &[ - value( - "--module", - "report one module's plan, live state, and derived entry-file projection instead of the workspace", - ), - WORKSPACE_FLAG, -]; +::: test +name: a first add with no flag stages the whole map and commits verified +given: { workspace: fresh_init_root_prompt, candidate: valid_root_level, attribution_flag: absent } +expect: { add_outcome: staged, map_kind: whole_file_claims, commit_outcome: success, attribution: fresh, no_verify_used: false } +::: -const LOG_FLAGS: &[FlagSpec] = &[ - value("--module", "limit the history to one module"), - WORKSPACE_FLAG, -]; +::: test +name: a whole spelling claims the entire file and survives file growth +given: { workspace: fixture_two_modules, map_entry: whole_file_no_numbers, then: file_grows } +expect: { claim_valid: true, bounds_derived: true, retranscription_needed: false } +::: -const MODULE_FLAGS: &[FlagSpec] = &[ - value("--module", "the module to read"), - WORKSPACE_FLAG, -]; +::: test +name: add check reports the full verdict and writes nothing +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_check } +expect: { verdict_listed_claims: 1, staged_records: 0, staging_dir_untouched: true, store_bytes_unchanged: true } +::: -const LINT_FLAGS: &[FlagSpec] = &[ - switch("--fix", "apply the auto-fixable findings through the guarded commit path"), - WORKSPACE_FLAG, -]; +::: test +name: an explicit module stages a new path in a shared crate +given: { workspace: fixture_shared_crate_two_modules, working_tree: new_uncommitted_file_in_shared_glob, add_path_with_module: true } +expect: { add_outcome: staged, module_as_named: true } +::: -const COST_FLAGS: &[FlagSpec] = &[ - value("--file", "an extra receipt file to price, repeatable"), - value("--session", "price one session's calls from the log, repeatable"), - WORKSPACE_FLAG, -]; +::: test +name: add of one working-tree path stages the committed state with that file overlaid +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, add_paths: [edited_file] } +expect: { add_outcome: staged, staged_state: committed_plus_overlay, unnamed_files_at_committed_bytes: true } +::: -const APPLY_FLAGS: &[FlagSpec] = &[ - switch("--apply", "apply the plan instead of only reporting it"), - WORKSPACE_FLAG, -]; +::: test +name: paths owned by two modules stage one record per module +given: { workspace: fixture_two_modules, working_tree_edit: both_modules_touched, add_paths: [file_of_leaf, file_of_consumer] } +expect: { staged_records: 2, modules: [consumer, leaf] } +::: -const NOTES_FLAGS: &[FlagSpec] = &[ - value("--note", "the note text to attach"), - value("--to", "the transition the note attaches to"), - WORKSPACE_FLAG, -]; +::: test +name: a path no module owns is a usage error naming the owned globs +given: { workspace: fixture_two_modules, add_paths: [unowned_file] } +expect: { exit_code: 2, error_names_owned_globs: true, staged_records: 0 } +::: -const MV_FLAGS: &[FlagSpec] = &[ - value("--target", "the target the module is placed for, default rust"), - WORKSPACE_FLAG, -]; +::: test +name: a pure line-shift edit transports the whole map and needs no attribution flag +given: { workspace: fixture_two_modules, working_tree_edit: insert_lines_above_attributed_range, add: module_form_without_attribution } +expect: { add_outcome: staged, map_transported: true, ranges_shifted: true, attribution_flag_needed: false } +::: -const EDIT_FLAGS: &[FlagSpec] = &[ - value("--new-string", "the replacement text"), - value("--old-string", "the exact text to replace"), - value( - "--ref", - "a single JSON object recording one reference: direction (code_to_prompt or prompt_to_code), module, file, lines, promptLines, and an optional note", - ), - switch("--replace-all", "replace every occurrence instead of requiring exactly one"), - value("--session", "the session window recorded as provenance"), - WORKSPACE_FLAG, -]; +::: test +name: a claim in a rewritten region refuses listing that claim with its observed bound +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } +expect: { outcome: invalid_candidate, listed_claims: 1, observed_bound_present: true, untouched_claims_not_listed: true } +::: -const WRITE_FLAGS: &[FlagSpec] = &[ - switch("--empty", "authorize writing a zero-byte file, which empty stdin alone never does"), - value( - "--ref", - "a single JSON object recording one reference: direction (code_to_prompt or prompt_to_code), module, file, lines, promptLines, and an optional note", - ), - value("--session", "the session window recorded as provenance"), - WORKSPACE_FLAG, -]; +::: test +name: affirmations layer over the transported remainder and stage +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_affirmation_for_listed_claim } +expect: { add_outcome: staged, transported_claims_kept: true, affirmed_claim_recorded: true } +::: -/// The shells `napl completions` generates for. -pub const COMPLETION_SHELLS: [&str; 3] = ["bash", "zsh", "fish"]; +::: test +name: add stages a valid candidate and commit installs it +given: { workspace: fixture_two_modules, candidate: valid_with_attribution } +expect: { add_outcome: staged, commit_outcome: success } +::: -/// The command table the core toolchain exposes to the derived registry. -/// -/// Every entry declares its full grammar: a one-line summary, the exact usage -/// line, and the closed table of flags it accepts with each flag's arity and -/// meaning. The dispatch loop answers help and refuses undeclared flags from -/// these tables, so no command parses its own arguments ad hoc. -pub fn commands() -> &'static [CommandSpec] { - &[ - CommandSpec { - name: "add", - example: "napl add --module leaf", - summary: "stage working-tree files, a module's whole owned tree, or an explicit candidate directory", - usage: "napl add ... | napl add ... --module | napl add --module | napl add --module [--check] [--target ] [--attribution ] [--usage ] [--no-verify]", - flags: ADD_FLAGS, - passthrough: false, - run: run_add, - }, - CommandSpec { - name: "blame", - example: "napl blame crates/leaf/src/greet.rs", - summary: "trace a generated file's lines back through the transitions that wrote them", - usage: "napl blame ", - flags: WORKSPACE_ONLY, - passthrough: false, - run: run_blame, - }, - CommandSpec { - name: "cat-file", - example: "napl cat-file 3f1c0a7e5b2d49c8a1e6f0b3d7c25948e0a1b2c3d4e5f60718293a4b5c6d7e8f", - summary: "print one stored object's raw bytes", - usage: "napl cat-file ", - flags: WORKSPACE_ONLY, - passthrough: false, - run: run_cat_file, - }, - CommandSpec { - name: "commit", - example: "napl commit", - summary: "install every staged record in wave order, or one named record, through the one guarded transaction", - usage: "napl commit [--staged ]", - flags: COMMIT_FLAGS, - passthrough: false, - run: run_commit, - }, - CommandSpec { - name: "completions", - example: "napl completions zsh", - summary: "emit a shell completion script generated from the command registry", - usage: "napl completions ", - flags: &[], - passthrough: false, - run: run_completions, - }, - CommandSpec { - name: "cost", - example: "napl cost --session s1", - summary: "price an explicit receipt set under a pinned pricing table", - usage: "napl cost [...] [--file ]... [--session ]...", - flags: COST_FLAGS, - passthrough: false, - run: run_cost, - }, - CommandSpec { - name: "diff", - example: "napl diff rust", - summary: "render drift between committed outputs and the working tree", - usage: "napl diff ", - flags: WORKSPACE_ONLY, - passthrough: false, - run: run_diff, - }, - CommandSpec { - name: "edit", - example: "napl edit crates/leaf/src/greet.rs --old-string 'let name' --new-string 'let who'", - summary: "replace exact text in a file and capture the change as a record", - usage: "napl edit --old-string --new-string [--replace-all] [--ref {direction, module, file, lines, promptLines, note}] [--session ]", - flags: EDIT_FLAGS, - passthrough: false, - run: crate::capture::run_edit, - }, - CommandSpec { - name: "fsck", - example: "napl fsck", - summary: "verify envelope hashes, kind validity, and causal parent links", - usage: "napl fsck", - flags: WORKSPACE_ONLY, - passthrough: false, - run: run_fsck, - }, - CommandSpec { - name: "gc", - example: "napl gc --apply", - summary: "report, or with --apply sweep, objects no named head reaches and the staged records beside them", - usage: "napl gc [--apply]", - flags: APPLY_FLAGS, - passthrough: false, - run: run_gc, - }, - CommandSpec { - name: "lint", - example: "napl lint rust", - summary: "diagnose unreachable modules, unconsumed surface entries, and staleness", - usage: "napl lint [--fix]", - flags: LINT_FLAGS, - passthrough: false, - run: run_lint, - }, - CommandSpec { - name: "log", - example: "napl log --module leaf", - summary: "read the store's history, per module or workspace wide", - usage: "napl log [--module ]", - flags: LOG_FLAGS, - passthrough: false, - run: run_log, - }, - CommandSpec { - name: "lsp", - example: "napl lsp", - summary: "serve the language server over stdio for editor extensions", - usage: "napl lsp", - flags: &[], - passthrough: false, - run: run_lsp, - }, - CommandSpec { - name: "mv", - example: "napl mv leaf crates/greeting", - summary: "relocate a module's installed files through the guarded commit path", - usage: "napl mv [--target ]", - flags: MV_FLAGS, - passthrough: false, - run: run_mv, - }, - CommandSpec { - name: "notes", - example: "napl notes add --to --note 'reviewed by hand'", - summary: "attach a note to a recorded transition", - usage: "napl notes add --to --note ", - flags: NOTES_FLAGS, - passthrough: false, - run: run_notes, - }, - CommandSpec { - name: "recover", - example: "napl recover", - summary: "replay the persisted markers a crash left, deterministically", - usage: "napl recover", - flags: WORKSPACE_ONLY, - passthrough: false, - run: run_recover, - }, - CommandSpec { - name: "restore", - example: "napl restore rust --apply", - summary: "derive, or with --apply install, a repair plan from persisted evidence", - usage: "napl restore [--apply]", - flags: APPLY_FLAGS, - passthrough: false, - run: run_restore, - }, - CommandSpec { - name: "show", - example: "napl show ", - summary: "inspect one stored object as canonical JSON", - usage: "napl show ", - flags: WORKSPACE_ONLY, - passthrough: false, - run: run_show, - }, - CommandSpec { - name: "status", - example: "napl status --module leaf", - summary: "classify what needs doing, observationally and with zero mutation", - usage: "napl status [] [--module ]", - flags: STATUS_FLAGS, - passthrough: false, - run: run_status, - }, - CommandSpec { - name: "surface", - example: "napl surface rust --module leaf", - summary: "print a module's recorded public surface", - usage: "napl surface --module ", - flags: MODULE_FLAGS, - passthrough: false, - run: run_surface, - }, - CommandSpec { - name: "write", - example: "napl write crates/leaf/src/greet.rs < greet.rs", - summary: "write a file's full content from stdin and capture it as a record", - usage: "napl write [--empty] [--ref {direction, module, file, lines, promptLines, note}] [--session ] < content-on-stdin", - flags: WRITE_FLAGS, - passthrough: false, - run: crate::capture::run_write, - }, - ] -} - -/// `napl add`, whose default source is the working tree exactly as in git. -/// -/// The three forms are told apart by what they name, not by a mode flag. Operands -/// with no `--module` are working-tree paths, each resolving to its owning module. -/// A `--module` with no operand is that module's whole owned tree. A `--module` -/// with one operand that is a directory is the explicit candidate form, which -/// stays legal unchanged for the case where the bytes to stage are not the working -/// tree; a `--module` with operands that are files is the explicit-module path -/// form, the spelling for a file that is new in a crate several modules share and -/// that neither the committed manifest nor the identical globs can attribute. -/// -/// `--check` composes with every form: the pipeline runs whole, the transport -/// verdict and refusal listing included, and nothing is written. -fn run_add(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - let operands = positionals_from(args, 1); - let module = flag(args, "--module")?; - let target = flag(args, "--target")?.unwrap_or_else(|| "rust".to_string()); - let attribution = flag(args, "--attribution")?; - let usage_path = flag(args, "--usage")?; - let attribution = attribution.as_deref().map(Path::new); - let usage_path = usage_path.as_deref().map(Path::new); - let no_verify = has_switch(args, "--no-verify"); - let check = has_switch(args, "--check"); +::: test +name: an explicit candidate whose transport is incomplete and unaffirmed fails loudly +given: { workspace: fixture_two_modules, candidate: rewritten_region_without_affirmation } +expect: { outcome: invalid_candidate, listed_claims_present: true } +::: - let Some(module) = module else { - if operands.is_empty() { - return Err(usage( - "napl add needs working-tree paths, or --module to stage a module's whole owned tree", - )); - } - return ok(&crate::staging::add_working_tree_paths( - &workspace, - cwd, - &target, - &operands, - None, - attribution, - usage_path, - no_verify, - check, - )?); - }; - // One operand that is a directory is candidate bytes; anything else named - // beside `--module` is a working-tree path staged under the named module. - let candidate_dir = match operands.as_slice() { - [only] => resolve_operand(cwd, &workspace, only).is_dir(), - _ => false, - }; - if !operands.is_empty() && !candidate_dir { - return ok(&crate::staging::add_working_tree_paths( - &workspace, - cwd, - &target, - &operands, - Some(&module), - attribution, - usage_path, - no_verify, - check, - )?); - } - let source = match operands.as_slice() { - [] => crate::staging::AddSource::WorkingTree, - [only] => crate::staging::AddSource::CandidateDir(std::path::PathBuf::from(only)), - _ => unreachable!("more than one operand never resolves to a candidate directory"), - }; - let record = crate::staging::add( - &workspace, - &target, - &module, - &source, - attribution, - usage_path, - no_verify, - check, - )?; - ok(&crate::staging::staged_payload(&target, vec![record])) -} +::: test +name: a candidate with a parent-escaping path is rejected as invalid-candidate +given: { workspace: fixture_two_modules, candidate: contains_dotdot_path } +expect: { outcome: invalid_candidate, published_semantic_state: false } +::: -/// An operand as a path on disk, resolved the way `add` resolves working-tree -/// paths: workspace-relative first, then relative to the working directory. -fn resolve_operand(cwd: &Path, workspace: &Path, given: &str) -> std::path::PathBuf { - let direct = workspace.join(given); - if direct.exists() { - return direct; - } - cwd.join(given) -} +::: test +name: tampering a staged blob after ingestion never mixes into the install +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, tamper: candidate_file_after_ingest } +expect: { outcome_in: [installed_from_immutable_blobs, store_integrity], mixed_content: false } +::: -/// `napl commit`: every staged record in wave order, or one named by `--staged`. -fn run_commit(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - let Some(record) = flag(args, "--staged")? else { - let (payload, exit_code) = crate::staging::commit_all(&workspace)?; - return Ok(CliResult { - stdout: canonical_bytes(&payload), - exit_code, - }); - }; - // The record path `add` reports is workspace-relative; an absolute path is - // honored as given. - let record_path = if Path::new(&record).is_absolute() { - std::path::PathBuf::from(&record) - } else { - workspace.join(&record) - }; - let report = crate::staging::commit_staged(&workspace, &record_path); - Ok(CliResult { - stdout: canonical_bytes(&report.payload), - exit_code: outcome_exit_code(report.outcome), - }) -} +`napl commit --staged ` is the one guarded +transaction over what add staged. Pipeline: verify the staged record +(recompute sliceHash, inputKey, baseStateKey at the recorded bases, +reject mismatches, enforce evidence bounds); extract and record the +surface; derive the complete transition (three-part footprint, member-set +delta, shared projections recomputed from the merged head); stage all +materialization outside any lock; then one bounded critical section under +the pointer lock performing only the footprint recheck and the pointer +and directory swaps, bounded by the transition's file count, never +hashing content inside the lock. -fn run_status(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - let target = positional(args, 1).unwrap_or_else(|_| "rust".to_string()); - if let Some(module) = flag(args, "--module")? { - return ok(&module_plan(&workspace, &target, &module)?); - } - ok(&status(&workspace, &target)?) -} +::: test +name: a crash between staged materialization and pointer swap leaves a deterministic recovery plan +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, crash_after: staging } +expect: { store_readable: true, recovery_plan_deterministic: true, semantic_state_unchanged: true } +::: -fn run_log(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - ok(&log(&workspace, flag(args, "--module")?.as_deref())?) -} +::: test +name: a receipt whose recomputed slice hash disagrees is rejected as invalid-receipt +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: tampered_slice_hash } +expect: { outcome: invalid_receipt, published_semantic_state: false } +::: -fn run_show(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - ok(&show(&workspace, &positional(args, 1)?)?) -} +The decision table, judged per arriving transition against the current +head: identities reproduce with head unmoved installs; head moved with +identities reproducing and footprints disjoint rebases then installs +(projections re-derived, never reused); inputKey failure returns +`stale-input`; baseStateKey failure returns `base-output-changed`; semantic +write overlap returns `write-conflict`; lock contention returns +`lock-contended`. Terminal outcomes, never retried unchanged: +`invalid-candidate`, `invalid-receipt`, `mapping-integrity`, +`resource-limit`, `unsupported-version`, `store-integrity`, `io-error`. +Every outcome is a canonical machine-readable JSON payload naming whether +blobs or provenance were published; no outcome publishes semantic state +partially. Exit codes: 0 success, 10 through 13 for the four returned +concurrency outcomes in table order, 20 through 26 for the seven terminal +outcomes in the order above. Staged records carry `receiptVersion`, the +per-module identities generated against, base snapshot hashes, the +candidate tree hash, the output manifest, then optional reported fields; +the compiler recomputes everything derivable and rejects on mismatch; +absent telemetry is `unavailable`, never zero. `napl notes add --to +` attaches later provenance without touching semantic state. -fn run_cat_file(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - Ok(CliResult { - stdout: cat_file(&workspace, &positional(args, 1)?)?, - exit_code: 0, - }) -} +::: test +name: commit installs a valid candidate against an unmoved head +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, head: unmoved } +expect: { outcome: installed, head_advanced: true, surface_recorded: true } +::: -fn run_surface(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - let target = positional(args, 1)?; - let module = require_flag(args, "--module")?; - ok(&surface_of(&workspace, &target, &module)?) -} +::: test +name: a bare commit installs every staged record in wave order +given: { workspace: fixture_two_modules, staged: [record_for_leaf, record_for_consumer], commit_args: none } +expect: { outcomes: [success, success], order: wave_order, hand_sequencing_needed: false } +::: -/// `napl completions `: a completion script generated from the registry. -/// -/// The script is derived from the same `clap::Command` tree parsing and help are -/// built from, so a completion can never offer a flag the grammar does not -/// declare. It is a pure read on stdout: no workspace is resolved, no store is -/// opened, nothing is written. An unknown shell name is a usage error under the -/// command line contract, naming the shells that exist. -fn run_completions(_cwd: &Path, args: &[String]) -> Result { - let shell = positional(args, 1).map_err(|_| { - usage(&format!( - "napl completions needs a shell name, one of {}", - COMPLETION_SHELLS.join(", ") - )) - })?; - let generator = match shell.as_str() { - "bash" => clap_complete::Shell::Bash, - "zsh" => clap_complete::Shell::Zsh, - "fish" => clap_complete::Shell::Fish, - other => { - return Err(usage(&format!( - "unknown shell {other:?}: napl completions generates {}", - COMPLETION_SHELLS.join(", ") - ))) - } - }; - let mut root = clap_root(&crate::registry()); - let mut script: Vec = Vec::new(); - clap_complete::generate(generator, &mut root, "napl", &mut script); - Ok(CliResult { - stdout: script, - exit_code: 0, - }) -} +::: test +name: bare commit re-derives and lands records for modules sharing one crate +given: { workspace: fixture_shared_crate_two_modules, staged: [record_for_first, record_for_second], commit_args: none } +expect: { outcomes: [success, success], retried_after_head_move: 1, hand_sequencing_needed: false } +::: -/// `napl diff`, which means nothing without a committed baseline to compare -/// against. A headless workspace gets the prerequisite sequence, and a target no -/// committed module owns anything under answers `tracked: false` with its reason -/// rather than the empty patch that would read as "no drift". -fn run_diff(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - if !crate::porcelain::has_committed_head(&workspace)? { - return Ok(emit_prerequisite("napl diff")); - } - ok(&diff(&workspace, &positional(args, 1)?)?) -} +::: test +name: commit sweeps the record it installed +given: { workspace: fixture_two_modules, staged: [record_for_leaf], commit: bare_then_bare_again } +expect: { first_outcomes: [success], second_outcomes: [], reattempted_history: 0 } +::: -/// `napl blame`, under the same rule: no head means the prerequisite sequence, and -/// a path no committed module owns is untracked rather than a file with no -/// ancestry. -fn run_blame(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - if !crate::porcelain::has_committed_head(&workspace)? { - return Ok(emit_prerequisite("napl blame")); - } - ok(&blame_path(&workspace, &positional(args, 1)?)?) -} +::: test +name: a bare commit with nothing staged names the drifted modules and their add commands +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, staged: none, commit_args: none } +expect: { installed: 0, nothing_staged_named: true, drifted_modules_named: [consumer], add_command_named: true } +::: -/// `napl lint`, whose reachability analysis has one prerequisite it will not -/// guess. With no declared roots the refusal is loud: canonical JSON naming the -/// missing prerequisite and the exact declaration that satisfies it, exit 2, and -/// no `findings` field at all, because an empty findings list under exit 0 would -/// read as "nothing is wrong" when the truth is "nothing was checked". -fn run_lint(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - let target = positional(args, 1)?; - let (_, report, value) = lint_workspace(&workspace, &target)?; - if report.unreachable_analysis == Analysis::Refused { - let bytes = canonical_bytes(&refusal_json()); - let stderr = std::io::stderr(); - let mut lock = stderr.lock(); - let _ = lock.write_all(&bytes); - let _ = lock.flush(); - return Ok(CliResult { - stdout: Vec::new(), - exit_code: USAGE_EXIT_CODE, - }); - } - if !has_switch(args, "--fix") { - return ok(&value); - } - let mut applied: Vec = Vec::new(); - let mut exit_code = 0; - for finding in report.findings.iter().filter(|f| is_auto_fixable(f)) { - let outcome = apply_fix(&workspace, &target, finding)?; - if outcome.outcome != OutcomeCode::Success { - exit_code = outcome_exit_code(outcome.outcome); - } - applied.push(outcome.payload); - } - Ok(CliResult { - stdout: canonical_bytes(&json!({ - "applied": applied, - "contractVersion": crate::version::CONTRACT_VERSION, - "report": lint_json(&report), - })), - exit_code, - }) -} +::: test +name: a per-side transport refusal serves the surviving side's transported range +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_prompt_section_only, add: module_form_with_check } +expect: { prompt_side: refused, code_side: transported, transported_code_range_served: true, affirmation_needed_sides: [prompt] } +::: -fn run_cost(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - let names = positionals_from(args, 1); - let files = repeated_flag(args, "--file"); - let sessions = repeated_flag(args, "--session"); - ok(&cost(&workspace, &names, &files, &sessions)?) -} +::: test +name: check enumerates the parse gate and refuses a file that does not parse +given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, add: module_form_with_check } +expect: { validations_enumerated: true, parse_gate_listed: true, outcome: invalid_candidate, error_names_file_and_line: true, staged_records: 0 } +::: -fn run_gc(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - ok(&gc(&workspace, has_switch(args, "--apply"))?) -} +::: test +name: add refuses a candidate whose source does not parse naming file and line +given: { workspace: fixture_two_modules, candidate: syntactically_invalid_rust } +expect: { outcome: invalid_candidate, error_names_file_and_line: true, published_semantic_state: false } +::: -fn run_fsck(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - let (healthy, value) = fsck_store(&workspace)?; - Ok(CliResult { - stdout: canonical_bytes(&value), - exit_code: if healthy { - 0 - } else { - outcome_exit_code(OutcomeCode::StoreIntegrity) - }, - }) -} +::: test +name: commit recomputation refuses staged bytes that do not parse +given: { workspace: fixture_two_modules, staged: record_with_tampered_unparseable_blob } +expect: { outcome_in: [invalid_candidate, store_integrity], installed: false } +::: -fn run_restore(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - let target = positional(args, 1)?; - ok(&restore(&workspace, &target, has_switch(args, "--apply"))?) -} +::: test +name: a bad attribution file answers with the complete map contract and example +given: { workspace: fixture_two_modules, attribution_file: empty_object, add: module_form_with_attribution } +expect: { error_lists_all_fields: true, error_carries_valid_example: true } +::: -fn run_recover(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - let store = disk::init(&workspace)?; - ok(&json!({ - "applied": recover(&store)?, - "contractVersion": crate::version::CONTRACT_VERSION, - "outcome": "success", - })) -} +::: test +name: the plan labels projections and names a legal first output +given: { workspace: fresh_init_root_prompt, flag: module_root } +expect: { projection_labeled_tool_derived: true, legal_first_output_named: true } +::: -/// `napl notes add`, the third command whose meaning requires a committed head: -/// a note attaches to a recorded transition, and a workspace with no head has -/// none to attach to. The headless-prerequisite set is exactly `diff`, `blame`, -/// and `notes add`; `log` and `show` keep their committed contracts and answer -/// empty honestly. -fn run_notes(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - if positional(args, 1)? != "add" { - return Err(usage("notes takes the subcommand `add`")); - } - if !crate::porcelain::has_committed_head(&workspace)? { - return Ok(emit_prerequisite("napl notes add")); - } - let transition = require_flag(args, "--to")?; - let note = require_flag(args, "--note")?; - ok(&add_note(&workspace, &transition, ¬e)?) -} +::: test +name: gc apply sweeps staged records and touches nothing reachable +given: { workspace: fixture_two_modules, staged: [stale_record], gc: apply } +expect: { staging_empty: true, reachable_objects_untouched: true, store_healthy: true } +::: -fn run_mv(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - let module = positional(args, 1)?; - let dest = positional(args, 2)?; - let target = flag(args, "--target")?.unwrap_or_else(|| "rust".to_string()); - let report = relocate_module(&workspace, &target, &module, &dest); - Ok(CliResult { - stdout: canonical_bytes(&report.payload), - exit_code: outcome_exit_code(report.outcome), - }) -} +::: test +name: commit against a moved head with disjoint footprints rebases and installs +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: to_unrelated_module } +expect: { outcome: installed, rebased: true } +::: -fn run_lsp(_cwd: &Path, _args: &[String]) -> Result { - let stdin = std::io::stdin(); - let stdout = std::io::stdout(); - crate::lsp::serve(&mut stdin.lock(), &mut stdout.lock()); - Ok(CliResult { - stdout: Vec::new(), - exit_code: 0, - }) -} -napl-store receipt v1 4839 -{"baseStateKey":"2e6328cde4c885e073cae7722b8da9557cedfb471f20f3b766215224d7d99edd","baseStoreSnapshot":"e7b38c4393ac64edbc646dfcc237b3551d3841c13880bce234cae11ff9e7801d","baseWorkspaceHash":"618ee501a30b5e02230cfe131cf13245c135d35f796e6f8300aa1fccb2dc8223","candidateTreeHash":"69bf9de1d96653068bee489d514b4ce0c80219a74bb74f8c0385f79d1196fe42","contractVersion":4,"inputKey":"421c2de594ddecf81bcbbcfaad2d0566fe99881b28c2ecccac9479da5dbfcd01","module":"napl","outputManifest":[{"hash":"f0987b243dce1a039a0c78f8100965a895c03501bc378b4217765c5c84982d34","path":"crates/napl/src/attribution.rs"},{"hash":"e539c65a4600ed1784b9cc176cb28f24c140f514f9783360f1f5a27142f89f68","path":"crates/napl/src/authoring.rs"},{"hash":"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2","path":"crates/napl/src/blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","path":"crates/napl/src/candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","path":"crates/napl/src/canon.rs"},{"hash":"0b8190e7989129e7baf4eed7046e19b7608067eb5d13bed95ad416118bf34a25","path":"crates/napl/src/capture.rs"},{"hash":"2e5b5b54aebea3db240acc2f2dc944df07b908bf97ba2067517a9757ed8c7dcb","path":"crates/napl/src/cli.rs"},{"hash":"7f8fe1b370188a60f00e524cc5c8059ca17a347b97915097286fff2c0ec7e290","path":"crates/napl/src/commandset.rs"},{"hash":"ed1c68648258a7ac19741b966c321db9c100222da858a64cc0604b108b7998fa","path":"crates/napl/src/commit.rs"},{"hash":"3c0db9b5d179e2f3b7123cd8e08844ea9ad28a0a6ffbc7e5794738709f5af1f4","path":"crates/napl/src/compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","path":"crates/napl/src/cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","path":"crates/napl/src/decision.rs"},{"hash":"bc274cef6c4e5aca63e7c46d81e1f86e118c6ba079e7a4ccd4e9f14a8d3eb21d","path":"crates/napl/src/directive.rs"},{"hash":"b86a4431e286225b32bae5cebc03298584fce063e8d4ee2df4633e18143f0aa7","path":"crates/napl/src/disk.rs"},{"hash":"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812","path":"crates/napl/src/error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","path":"crates/napl/src/evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","path":"crates/napl/src/graph.rs"},{"hash":"824c95b7229ac2ad5b1cc518b296443883fb56a110c86c2a2e67923c0a19ea07","path":"crates/napl/src/lint.rs"},{"hash":"2ea965827e5c5073cbcacacd22bf2318a9067874c901b4f05f67268e99baf92c","path":"crates/napl/src/lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","path":"crates/napl/src/main.rs"},{"hash":"6fc1b58dad295d780c917c10ac0f793667e46c455349cff3d42e6855501a91bd","path":"crates/napl/src/maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","path":"crates/napl/src/mv.rs"},{"hash":"3ae089b923327032845c386f2332ff653a08f25ccec28e0786094fa05fd77a00","path":"crates/napl/src/objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","path":"crates/napl/src/paths.rs"},{"hash":"93f11780827b58057b742550c206f180d5fa1659b216e90b8cfded514400be21","path":"crates/napl/src/porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","path":"crates/napl/src/receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","path":"crates/napl/src/slice.rs"},{"hash":"582dc66d4cbe8957f2b566d2aa4052cee721d416cd4fdb122ec24a5982c4516d","path":"crates/napl/src/source.rs"},{"hash":"7d1bba27fd84f0edae130f1902b00abab524e3a67d0428b1a2fe7a2c01af5273","path":"crates/napl/src/staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","path":"crates/napl/src/store_view.rs"},{"hash":"1820f647bb5dbd74c8db7f5519d404ef0ce1520c833a579aaca893b5cad7ba08","path":"crates/napl/src/surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","path":"crates/napl/src/target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","path":"crates/napl/src/textdiff.rs"},{"hash":"ca46ca78bcb2940f23d3aede83fdee2d10b6910dfb0eaa9de6a8ca83a2209427","path":"crates/napl/src/transaction.rs"},{"hash":"d4ef5888a7ecc406d8eb5173dc215c7b1a1ef1e1ddd3ab8b3a8aa6639d125017","path":"crates/napl/src/transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","path":"crates/napl/src/trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","path":"crates/napl/src/version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","path":"crates/napl/src/workspace.rs"}],"receiptVersion":1,"sliceHash":"d40e15cead8598ca2bd14fd6aedb544c457ca8ea38694a4e63e3451cb8845cb6","target":"rust"} -napl-store transition v1 915 -{"baseStateKey":"fc84533dbe7fc3dac061433bc0c1f8fc5bde8245ab04b55fa0c742bcbe63d0a2","contractVersion":1,"derivedProjections":["Cargo.toml"],"inputKey":"d6f3c4d7d96039eee461324079f3fce8d621a98c3eb209dd2d79beb23a124527","installPaths":["crates/napl/Cargo.toml","crates/napl/src/init.rs","crates/napl/src/query.rs","crates/napl/src/watch.rs"],"module":"commands","newState":"7c7e8183b9fbf594f3d61a4ca43f5154c404f912f32b8789d32c697ae883859b","readSet":["base-state:fc84533dbe7fc3dac061433bc0c1f8fc5bde8245ab04b55fa0c742bcbe63d0a2","input-key:d6f3c4d7d96039eee461324079f3fce8d621a98c3eb209dd2d79beb23a124527","target-def:24836906360841beb373080103c8b6c1fe791d495a88247b70267efffbbf9f10"],"receipt":"4184266c3322e534d08af35a422433e9ad39cbf1c471643a855a0d48be278fac","surface":"d1d65a35524fa9e7716908f525fe47c5726d2d3b9bad4943566d96f07ef4f20f","target":"rust","writeSet":["owned:crates/napl","module-state:rust/commands"]} -napl-store blob v1 9922 -# usage-proxy: napl agent, and the capture sidecar behind it +::: test +name: commit whose direct dep surface moved underneath returns stale-input +given: { workspace: fixture_chain_three, candidate: valid_for_middle, intervening_commit: leaf_surface_change } +expect: { outcome: stale_input, head_advanced: false } +::: -One user-facing verb and one small binary behind it. `napl agent -- - [args]` runs any LLM harness, interactive or not, with usage -capture just there: it starts the workspace's capture proxy if none is -running, injects the base-URL override into the child environment, and -hands the terminal to the command unchanged. The user story is one line: -run your agent through `napl agent` and provenance is captured, whatever -the harness. No daemon to remember, no environment to edit, no config -file. +::: test +name: commit whose own outputs moved underneath returns base-output-changed +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: same_module_other_candidate } +expect: { outcome: base_output_changed } +::: -The sidecar, `napl-usage-proxy`, sits between the harness and the -Anthropic API and does one thing: pass requests through untouched and -append one usage record per completed call to a local JSONL log. It is -not a gateway: no routing, no budgets, no multi-provider translation, no -retries, no caching, no database. +::: test +name: two concurrent disjoint commits both land and neither touches a manifest +given: { workspace: fixture_two_modules, concurrent_candidates: [module_a, module_b], orders: [ab, ba] } +expect: { both_installed: true, manifest_bytes_unchanged: true } +::: -This crate is runtime I/O by nature: it listens on a socket, speaks TLS -to one upstream, spawns the wrapped command, and stamps wall-clock -timestamps into its log. That is legal here and stated plainly: the -determinism contract governs compilation and the store, and this -module's generation is as deterministic as any other; the running -proxy produces telemetry, and telemetry never enters identity. The log -is evidence in the sense of the usage-envelope seam: observed facts -about calls, display and pricing metadata, never a semantic claim. +::: test +name: "absent telemetry reads as unavailable, never zero" +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: without_usage } +expect: { usage: unavailable } +::: -## napl agent (the wrapper) +Attribution rides the staged record; there is no other write path for it. +`napl add --attribution ` carries the module's mapping of prompt body +line ranges to generated file line ranges with notes; add validates every +claim before staging: each file path inside the module's owned globs, +each code line range present in the candidate tree, each prompt line +range present in the current normalized prompt body; a mapping that lies +is `invalid-candidate`. An entry may own no file at all: contract-level +prose — a law — that governs the module rather than any single path; +validation accepts a file-less entry (its prompt range must still exist) +and the query layer serves it flagged as law. An add whose transport is +incomplete and whose affirmations do not cover the listed claims fails +loudly as `invalid-candidate`, naming exactly which claims are missing; +`napl add --no-verify` is the explicit escape hatch that stages anyway +and records the module's attribution as stale on install. Any prompt +edit, code transition, or move that changes what a recorded mapping +points at marks it stale the same way; nothing ever repairs a mapping +automatically. `napl status` reports attribution freshness per +module (`fresh`, `stale`, `absent`) and, for stale or absent, emits a +machine-readable directive naming the exact fix: commit with a fresh +attribution for that module. Relocation recomputes only the path prefix +of a mapping, never its line content. The lsp serves a stale mapping +marked stale rather than hiding it; absent answers empty. -`napl agent -- [args]` resolves the workspace root (nearest -ancestor with `.napl`, same rule as everything else), then: +An attribution entry is a reference, and a reference has a direction: +prompt-to-code, a prompt body line range naming generated file line +ranges, or code-to-prompt, a generated file line range naming a prompt +body line range. Every entry recorded before directions existed reads as +prompt-to-code; migration is a reading, never a rewrite of stored +records. Validation is identical for both directions and referential +only, the same claims checked the same way, and nothing about a +direction implies the sentence describes the code: a reference is a +claim with provenance and freshness, and the record vocabulary never +says more. -- reuses the workspace's running sidecar when one is alive, else starts - one on an ephemeral loopback port. Liveness and identity live in - `.napl/usage-proxy.state`, a single canonical JSON file holding the - port, the sidecar pid, and the log path; a stale state file (dead - pid) is replaced, never trusted. One sidecar per workspace: claiming - the state file is atomic, written whole and renamed into place, so - when several wrappers race from cold exactly one claim survives; a - loser detects the surviving claim, shuts down its own spawn, and uses - the winner's port. Concurrent runs in any number of terminals share - the one sidecar, each under its own session id. -- injects `ANTHROPIC_BASE_URL=http://127.0.0.1:` into the child - environment, leaving every other variable untouched. An - `ANTHROPIC_BASE_URL` already present in the parent environment wins - and is left alone, with a warning on stderr, so an outer proxy is - never silently displaced. -- generates a fresh session id, injects it as `NAPL_SESSION` in the - child environment, and passes it to the sidecar so every log line - written while this child runs carries it. The session id is how an - edit or generation is later joined to its calls: a session window, - not a guess against a global log. -- executes the command with the terminal fully inherited: stdin, - stdout, stderr, tty. Interactive TUIs run exactly as if invoked - directly. The wrapper adds nothing to the terminal. -- exits with the child's exit code. The sidecar outlives the child and - keeps serving later `napl agent` runs; `napl agent --stop` shuts the - workspace's sidecar down and clears the state file. +A reference carries a provenance block, recorded by the compiler at +staging time from what the caller supplied: recording model identity, +timestamp, tool version, and an optional usage-envelope reference +through the existing evidence seam. Every absent field is `unavailable`, +never zero, never invented, and provenance is display and pricing +metadata that never enters any identity, exactly as telemetry already +behaves. The query layer and the lsp serve the provenance block with the +entry. -Absent `--`, or an empty command, is a usage error on stderr with exit -code 2. The wrapper never modifies the workspace, the store, or any -file except the state file and the log. +Staleness covers both ends of a reference, and working-tree drift is one +of the ways an end changes: a reference whose code side no longer +matches the committed bytes, or whose prompt side changed in the working +tree, reads stale on both endpoints, computed at read time from the +records and the working tree, never stored as a transition. A hand edit +to generated source is drift like any other: legal, visible through the +same status and diff machinery, healable by restore or by re-recording +the reference; nothing repairs a reference automatically and nothing +polices the edit that staled it. -## Passthrough +::: test +name: a code-to-prompt entry stages and is served with its direction +given: { workspace: fixture_two_modules, candidate: valid_with_code_to_prompt_reference } +expect: { add_outcome: staged, served_direction: code_to_prompt } +::: -The sidecar listens on its loopback port and forwards every request to -one upstream origin, `https://api.anthropic.com` by default, -overridable per workspace through `--upstream` on `napl agent` (stored -in the state file so reuse keeps the same upstream). Method, path, -query, headers, and body forward verbatim; the response streams back -byte-for-byte as received, including SSE streams, status codes, and -error bodies. The proxy never rewrites, buffers whole streams -needlessly, or injects headers beyond what proxying itself requires. -Authorization material (`x-api-key`, `authorization`, cookies) passes -through and is never written to the log, never printed, and never held -longer than the request. Request and response bodies are never logged. +::: test +name: entries recorded before directions read as prompt-to-code unchanged +given: { workspace: fixture_two_modules, history: [pre_direction_attribution] } +expect: { served_direction: prompt_to_code, stored_records_rewritten: false } +::: -Capture must never break the call: any failure to parse or record usage -is swallowed, the client still receives the upstream response -unchanged, and the log line for that call says what could not be read. -Fail open is the rule; observation never gates. +::: test +name: provenance is served with the entry and absent fields read unavailable +given: { workspace: fixture_two_modules, candidate: reference_without_usage_envelope } +expect: { provenance_served: true, usage: unavailable, cost_component: unavailable } +::: -## Capture +::: test +name: a hand edit to the code side reads stale on both endpoints +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } +expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } +::: -The sidecar tees only what it needs to extract usage. A non-streaming -JSON response carries `model` and `usage` at the top level. A streaming -SSE response carries them in the `message_start` event and the final -`message_delta` event; the proxy reads those two event types and -ignores every other event. Responses with no usage shape (non message -endpoints, upstream errors) log `usage` as `unavailable`. +::: test +name: a working-tree prompt edit reads stale on both endpoints +given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched } +expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } +::: -Each completed call appends exactly one line to -`.napl/usage-proxy.jsonl` in the workspace, canonical JSON, sorted -keys, one object per line: +::: test +name: a code-to-prompt reference naming a missing prompt range is invalid-candidate +given: { workspace: fixture_two_modules, candidate: code_to_prompt_reference_to_missing_range } +expect: { outcome: invalid_candidate, published_semantic_state: false } +::: -- `timestamp`: RFC 3339 UTC, recorded when the response completes. -- `session`: the `NAPL_SESSION` id of the calling child, or - `unavailable` when the call carried none. -- `model`: the model string the response named, or `unavailable`. -- `requestId`: the upstream `request-id` header value, or - `unavailable`. -- `endpoint`: the request path, query stripped. -- `status`: the upstream HTTP status code. -- `usage`: the usage object as the response reported it, with - `input_tokens`, `output_tokens`, and any cache token fields the - upstream included, or the string `unavailable` when no usage could - be read. Absent is `unavailable`, never zero, never invented. +## edit and write: the capture verbs -Session attribution rides a header, because one port serves every -session. The wrapper arranges it: it sets -`ANTHROPIC_CUSTOM_HEADERS` in the child environment to carry -`x-napl-session: ` when the variable is unset, and the sidecar -strips exactly that header before forwarding upstream and stamps its -value into the line. A call arriving without the header logs `session` -as `unavailable`; capture still happens. A pre-existing -`ANTHROPIC_CUSTOM_HEADERS` in the parent environment is left alone -with a stderr warning, and those sessions read `unavailable`. +`napl edit` and `napl write` are the deterministic capture layer in +front of the same guarded door. They carry the familiar tool +signatures: `napl edit --old-string --new-string +[--replace-all]` replaces an exact occurrence in a file, and +`napl write ` writes stdin as the file's full content; its usage +line says the content comes from stdin, and an empty stdin refuses as +a usage error naming `--empty`, the explicit flag that authorizes +writing a zero-byte file, so a forgotten pipe can never silently +truncate. Either +side is editable, a prompt or a generated file, by a human or an LLM; +a hand edit through an ordinary editor stays equally legal, and the +verbs add capture, never exclusivity. The verbs never synthesize or +rewrite prose beyond the bytes given, never invoke a model, never +decide placement, never repair a reference, and never bypass the +guard: `add` and `commit` remain exactly what they are. -Appends are atomic per line: a line is written whole with a trailing -newline or not at all, and concurrent calls never interleave bytes -within a line. The log is append-only; the proxy never rewrites or -truncates it. The media type of this log is -`application/x-napl-usage-proxy-jsonl-v1`, named here so the evidence -validator registry can adopt it as a validated envelope kind in a later -amendment to the napl module; nothing in this crate registers anything. +Each call, deterministically and in this order: validate, apply, +capture. When the target is a prompt file, the result of the edit is +validated first by the same reader `check` and `add` use; a result +that fails validation refuses as `prompt-invalid` and applies nothing. +An `--old-string` that matches nowhere, matches more than once +without `--replace-all`, or equals `--new-string` refuses as +`edit-mismatch` and applies nothing, so a no-op edit can never mint a +record. Only a call that will succeed touches the working tree, so +every refusal leaves every file byte-identical. -## Boundaries +A call may carry one reference: `--ref `, a single object naming +a direction and both endpoints, prompt body line range and generated +file line range, in either direction per the reference contract above. +The contract is taught, never reverse engineered: the verbs' usage +lines and flag meanings name the object's fields, and the FIRST +validation failure of a ref payload answers with the complete +contract in one refusal, every field, the closed list of valid +`direction` values, and one complete valid example, never one missing +field per attempt. +The reference is validated referentially only, exactly as strong as +attribution validation and no stronger: the file path inside the +owning module's placement, both line ranges present in the +post-edit bytes of their files. A reference naming a missing line or +a path outside placement refuses as `reference-unresolvable` and +applies nothing. A valid reference-bearing call stages a +reference-only record and commits it through the guarded transaction +in the same invocation: the transition carries the reference entry and +its provenance, no output-tree change and no surface change, and every +rule of the decision table, journaled atomicity, and recovery applies +to it unchanged. The success payload carries the recorded reference +object verbatim and its transition hash, and says plainly that the +edited file remains working-tree drift until its module is committed: +recording a reference and advancing the committed baseline are +different acts, and the payload spells the sequence that performs the +second, `napl add` for the module then `napl commit`, so nobody reads +`recorded: true` as committed and nobody feeds a bare commit nothing. +A plain apply teaches the same way: a written or edited file is +working-tree drift until staged and committed, and the success +payload of both verbs names both verbs in order. `napl show ` lists the +transition's reference entries inline exactly as it lists notes. A +call without `--ref` is a plain edit: apply, no +record, no store touch. Any existing reference whose endpoint the edit +moved needs no marking, because both-ends staleness is already +computed at read time from the working tree; the verbs store nothing +about it. -The crate depends on a minimal async HTTP stack and rustls, declared in -the user-owned manifest like every other dependency. It never reads the -store, never writes anything but the log and the state file, never -spawns anything but the wrapped command and the sidecar, and never -phones anywhere except the one configured upstream. The running -sidecar exits nonzero only on startup failure (bind failure, unusable -log path), never because of a bad request. +A reference the verb records is evidence, and evidence serves itself. +The record carries the content hash of each endpoint's post-edit +bytes, the file as the verb wrote it and the normalized prompt body it +validated against. Freshness reads those hashes first: a verb-recorded +reference whose endpoint bytes still match the working tree reads +fresh, and it goes stale only when the tree moves off the bytes the +record names, never merely because the committed baseline lags the +edit, so `recorded: true` is never followed by a stale count the verb +itself caused. At the next add, transport seeds from the same +evidence: a committed claim refusing in a rewritten region is affirmed +automatically when a reference transition recorded since the module's +committed output covers that region and its endpoint hashes match the +staged bytes, because the tool that applied the edit owns its diff; +the affirmation is the record, and no hand-authored map is ever +demanded for a range a capture verb already recorded. A recorded +reference whose hashes no longer match affirms nothing and is listed +in the refusal like any other claim, so the shortcut never guesses. ::: test -name: agent runs an interactive-style child with the terminal inherited and returns its exit code -given: { child: exits_7_after_reading_stdin } -expect: { exit_code: 7, stdio_inherited: true } +name: a verb-recorded reference reads fresh until the tree moves off its bytes +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: status_module } +expect: { stale_reference_count: 0, drift: true } ::: ::: test -name: agent injects the base url and session id and leaves the rest of the environment alone -given: { child: prints_env } -expect: { anthropic_base_url_loopback: true, napl_session_present: true, other_env_untouched: true } +name: add after a referenced edit self-affirms the rewritten region and stages without a map +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, add: module_form_without_attribution } +expect: { add_outcome: staged, affirmed_from_reference_record: true, attribution_flag_needed: false } ::: ::: test -name: a pre-existing base url in the parent environment wins with a warning -given: { parent_env: { ANTHROPIC_BASE_URL: preset }, child: prints_env } -expect: { child_base_url: preset, stderr_warns: true } +name: a recorded reference whose bytes moved again affirms nothing +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: hand_edit_same_region, add: module_form_without_attribution } +expect: { outcome: invalid_candidate, listed_claims_present: true, affirmed_from_reference_record: false } ::: +Provenance rides the session window: `--session ` names the +usage-proxy session whose log lines are this call's evidence, and when +the flag is absent the verb reads the `NAPL_SESSION` environment +variable, so a call made under `napl agent` carries its window with no +extra argument. The recorded usage reference is the window itself, +media type, workspace-relative log path, session id, and the line span +observed at staging, priced lazily by `napl cost` at read time, never +aggregated at record time. Model identity and timestamp come from the +window's log lines, or read `unavailable` when there is no window; +the verbs read no clock, so the same call over the same working tree, +store state, and log bytes produces byte-identical records. Absent is +`unavailable`, never zero, never invented, and provenance never +enters identity. + +The three refusals join the closed terminal-outcome table under the +contract bump to 4, pinned exit codes continuing the table order: +`edit-mismatch` 27, `reference-unresolvable` 28, `prompt-invalid` 29. +None reuses `mapping-integrity`, and each is a canonical +machine-readable JSON payload naming what failed and that nothing was +applied or published. + ::: test -name: two agent runs in one workspace reuse one sidecar and a stale state file is replaced -given: { runs: 2, then: state_pid_killed, runs_after: 1 } -expect: { sidecars_started: 2, state_file_replaced: true } +name: an edit with a valid code-to-prompt reference stages and commits in one call +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } +expect: { outcome: installed, served_direction: code_to_prompt, output_tree_changed: false } ::: ::: test -name: wrappers racing from cold converge on one surviving sidecar -given: { concurrent_cold_starts: 4 } -expect: { surviving_sidecars: 1, every_wrapper_usable_port: true, log_files: 1 } +name: the same edit call twice produces byte-identical records +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, runs_from_same_base: 2 } +expect: { records_byte_identical: true } ::: ::: test -name: passthrough returns upstream body and status byte for byte -given: { upstream: stub_json_response, request: post_messages } -expect: { body_identical: true, status_identical: true } +name: a reference to a missing line refuses as reference-unresolvable and applies nothing +given: { workspace: fixture_two_modules, edit: ref_names_missing_line } +expect: { outcome: reference_unresolvable, exit_code: 28, files_byte_identical: true } ::: ::: test -name: a non-streaming response appends one usage line carrying the session id and tokens -given: { upstream: stub_json_with_usage, request: post_messages_with_session_header } -expect: { log_lines: 1, log_session_matches: true, log_has_input_tokens: true, header_stripped_upstream: true } +name: a prompt edit that breaks the prompt refuses as prompt-invalid and applies nothing +given: { workspace: fixture_two_modules, edit: prompt_edit_breaking_directive } +expect: { outcome: prompt_invalid, exit_code: 29, files_byte_identical: true } ::: ::: test -name: an sse response yields usage from message_start and the final message_delta -given: { upstream: stub_sse_stream_with_usage, request: post_messages_stream } -expect: { log_lines: 1, usage_merged_from_start_and_delta: true, client_stream_unmodified: true } +name: an ambiguous old-string without replace-all refuses as edit-mismatch +given: { workspace: fixture_two_modules, edit: old_string_matches_twice } +expect: { outcome: edit_mismatch, exit_code: 27, files_byte_identical: true } ::: ::: test -name: an unparseable response still passes through and logs usage unavailable -given: { upstream: stub_malformed_body, request: post_messages } -expect: { body_identical: true, log_lines: 1, usage: unavailable } +name: the first bad ref payload answers with the whole contract at once +given: { workspace: fixture_two_modules, edit: ref_payload_bare_string } +expect: { error_lists_all_fields: true, error_lists_direction_values: true, error_carries_valid_example: true, files_byte_identical: true } ::: ::: test -name: authorization material never appears in the log -given: { upstream: stub_json_with_usage, request: post_messages_with_api_key } -expect: { log_contains_api_key: false, log_contains_authorization: false } +name: a recorded edit serves its reference in the payload and in show +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } +expect: { payload_reference_verbatim: true, payload_names_drift_until_commit: true, show_lists_reference: true } ::: ::: test -name: concurrent calls append whole lines that never interleave -given: { upstream: stub_json_with_usage, concurrent_requests: 8 } -expect: { log_lines: 8, every_line_parses: true } +name: a prose-only edit needs no reference and touches no store state +given: { workspace: fixture_two_modules, edit: plain_prompt_edit_valid } +expect: { applied: true, store_bytes_unchanged: true } ::: ::: test -name: an upstream error status passes through and logs its status with usage unavailable -given: { upstream: stub_overloaded_529, request: post_messages } -expect: { status_identical: true, log_lines: 1, logged_status: 529, usage: unavailable } +name: verb success payloads spell the add then commit sequence +given: { workspace: fixture_two_modules, invocations: [plain_write_owned_file, referenced_code_edit_with_ref] } +expect: { write_payload_names: [add, commit], edit_payload_names: [add, commit] } ::: -napl-store blob v1 63108 -//! The read commands and the maintenance commands. -//! -//! Every read resolves HEAD exactly once and then answers entirely from objects -//! reachable from that snapshot. No read creates a directory, takes a lock, or -//! writes a byte, so the whole read surface works on a read-only filesystem. -use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; +::: test +name: write with empty stdin refuses naming the empty flag and writes nothing +given: { workspace: fixture_two_modules, write: target_with_empty_stdin } +expect: { exit_code: 2, error_names_flag: "--empty", files_byte_identical: true } +::: -use crate::blame::{blame, blame_json, untracked_json, Revision}; -use crate::compile::{ - compile, graph_for, install_index, read_surface_items, state_of, verdict as pending_verdict, -}; -use crate::cost::{cost_report, line_from_reported, CostLine}; -use crate::error::{error, OutcomeCode, Result}; -use crate::graph::{topological_levels, Graph}; -use crate::slice::receipt_requirements; -use crate::lint::{lint, lint_json, LintInput, LintReport}; -use crate::maintenance::{ - fsck, fsck_json, gc_json, plan_restore, plan_sweep, reachable_from_head, restore_json, - FileFact, RestorePlan, -}; -use crate::objects::{ - event_transition, read_module_state, snapshot_event, snapshot_states, walk_events, ModuleState, -}; -use crate::receipt::normalize_reported; -use crate::store_view::{ - flatten_tree, get_blob, get_json, get_object, has_object, ObjectSource, -}; -use crate::surface::surface_json; -use crate::target::{ - builtin_target, is_manifest_path, owned_globs, placement_root, projection_owned_paths, TargetDef, -}; -use crate::textdiff::render_drift; -use napl_store::store_hash::ObjectKind; -use serde_json::{json, Value}; +::: test +name: an edit touching a referenced line reads stale on both endpoints afterward +given: { workspace: fixture_two_modules, edit: plain_edit_on_referenced_code_line } +expect: { reference_status: stale, stale_endpoints: [prompt, code] } +::: -use crate::disk::{self, Store}; -use crate::workspace::{discover_sources, read_working_text}; +::: test +name: a session window from the environment is recorded as the usage reference +given: { workspace: fixture_two_modules, env: { NAPL_SESSION: s1 }, log: proxy_log_with_s1_lines, edit: referenced_code_edit_with_ref } +expect: { provenance_usage_session: s1, usage_span_recorded: true, aggregated_at_record: false } +::: -/// A snapshot-consistent status report for one target: observation, zero -/// mutation. -pub fn status(workspace: &Path, target: &str) -> Result { - let store = disk::open(workspace); - let head = store.head()?; - let states = match &head { - Some(snapshot) => snapshot_states(&store, snapshot)?, - None => BTreeMap::new(), - }; - let sources = discover_sources(workspace)?; - let artifact = compile(&store, &sources, target, None)?; - let pending: Vec = artifact - .slices - .iter() - .map(|slice| slice.module.clone()) - .collect(); - let frontier: BTreeSet = pending.iter().cloned().collect(); - let target_def = builtin_target(target)?; - let installs = install_index(&store, head.as_deref())?; - // Docs drift is drift a module state is not keyed on, so it never makes a - // module pending; it is still work waiting, and status is where a reader - // looks for waiting work. It comes from the one reader the empty-commit - // teacher uses, so a clean status and a silent teacher say the same thing. - let docs_drifted = crate::staging::docs_drift(workspace)?; +::: test +name: an edit with no session window records provenance unavailable +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, env: {} } +expect: { provenance_model: unavailable, provenance_usage: unavailable } +::: - let mut modules: Vec = Vec::new(); - for ((state_target, module), state_hash) in &states { - if state_target != target { - continue; - } - let state = read_module_state(&store, state_hash)?; - let freshness = attribution_freshness(Some(&state), module, target, &sources); - let prompt_stale = freshness["status"].as_str() != Some("fresh"); - let ends = both_ends( - workspace, - &store, - &state, - prompt_stale, - &pending_body(&sources, module), - )?; - let is_pending = pending.contains(module); - let verdict = pending_verdict( - &store, - &sources, - &target_def, - module, - &states, - &installs, - &frontier, - ends.drift, - )?; - let (reason, reasons, reland) = verdict.json_fields(is_pending); - modules.push(json!({ - "attribution": freshness, - "baseStateKey": state_hash, - "docsDrift": docs_drifted.contains(&(target.to_string(), module.clone())), - "drift": ends.drift, - "inputKey": state.input_key, - "module": module, - "outputTree": state.output_tree, - "pending": is_pending, - "pendingReason": reason, - "pendingReasons": reasons, - "relandIdenticalBytes": reland, - "sliceHash": state.slice_hash, - "staleReferences": ends.stale_references as u64, - "surfaceHash": state.surface_hash, - })); - } - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "head": head, - "manifestDiagnostics": manifest_diagnostics(workspace, target, &sources), - "modules": modules, - "pending": pending, - "target": target, - })) -} +## The rest of the porcelain -/// What status observes about a module's code side, so status judges both ends. -/// -/// `drift` is true when the committed output differs from the working tree, and -/// `stale_references` counts the module's references reading stale right now. Both -/// come from the derivations `diff` and the language server already serve, which -/// is the whole point: status can never read clean while the query surface reads -/// stale, because there is one derivation and not two. -struct BothEnds { - drift: bool, - stale_references: usize, -} +`napl status` observational classification, zero mutation, and it +judges both ends: per module it reports `drift: true` when the +committed output differs from the working tree, and the module's stale +reference count, the same derivations diff and the lsp already serve, +so status can never read clean while the query surface reads stale. +A module fresh on the prompt side and drifted on the code side says +so in one payload. `napl status --module ` answers with the +module's live state beside its plan, tracked or pending, drift, stale +reference count, attribution status, in the same payload as the owned +globs and wave position, so the command named status never answers +with a plan alone; and the payload carries the derived entry-file +projection, path and exact content, so the line a new file needs in a +shared entry file is read from the tool, never composed by hand. +Every projection path the plan mentions is labeled what it is, +tool-derived and never user-supplied, and the plan names one concrete +legal first output path (the module name under the target's source +dir), so the obvious wrong first move, hand-writing the entry file, +is warned against before it is made. The add refusal for a module +whose owned tree holds nothing stageable says the same thing: which +paths present are projections, and what the first legal file would +be. +`napl log` entries carry each transition's note count and reference +count, and the log payload names `napl show ` as where +the full note and reference text is served, so provenance is one +named hop away, never a secret; `napl show ` lists its +attached notes inline, hash and text, so a note attached is a note +findable without plumbing. -fn both_ends( - workspace: &Path, - store: &Store, - state: &ModuleState, - prompt_stale: bool, - pending_body: &str, -) -> Result { - let mut drift = false; - let mut drifted_paths: BTreeSet = BTreeSet::new(); - if has_object(store, &state.output_tree) { - for (path, (blob, _mode)) in flatten_tree(store, &state.output_tree)? { - let bytes = std::fs::read(workspace.join(&path)).ok(); - // An unreadable working file is not drift: the reader knows nothing - // about it, so it claims nothing, exactly as the query surface does. - if crate::attribution::code_drifted(Some(blob.as_str()), bytes.as_deref()) { - drift = true; - drifted_paths.insert(path); - } - } - } - let entries = match &state.attribution { - Some(recorded) if has_object(store, &recorded.record) => { - let bytes = get_blob(store, &recorded.record)?; - crate::attribution::parse(&String::from_utf8_lossy(&bytes)) - .map(|record| record.entries) - .unwrap_or_default() - } - _ => Vec::new(), - }; - let mut stale_references = 0usize; - for entry in &entries { - // A reference a capture verb recorded carries the content hash of each - // endpoint's post-edit bytes, and freshness reads those first. While the - // working tree still holds exactly those bytes the reference is fresh, - // whatever the committed baseline says: recording a reference and - // advancing the baseline are different acts, and the verb's own edit must - // never be counted as debt the verb created. - let working = entry - .file - .as_ref() - .and_then(|file| std::fs::read(workspace.join(file)).ok()); - let code_drift = entry - .file - .as_ref() - .map(|file| drifted_paths.contains(file)) - .unwrap_or(false); - if crate::attribution::entry_reference_status( - entry, - prompt_stale, - false, - code_drift, - working.as_deref(), - pending_body, - ) - .stale - { - stale_references += 1; - } - } - Ok(BothEnds { - drift, - stale_references, - }) -} +::: test +name: log counts references and names show as the inspection verb +given: { workspace: fixture_two_modules, history: [commit, referenced_edit] } +expect: { log_reference_count: 1, show_named_in_payload: true } +::: -/// The module's pending normalized prompt body, or the empty body when the -/// workspace no longer has a prompt for it. -fn pending_body(sources: &[crate::source::SourceModule], module: &str) -> String { - sources - .iter() - .find(|source| source.module == module) - .map(|source| source.normalized_body.clone()) - .unwrap_or_default() -} +`napl log` +per-module or store-wide history. `napl show ` inspect one object. +`napl diff` drift between committed outputs and working tree as a canonical +byte-stable patch. `napl blame ` derived ancestry: surviving lines +trace through accepted patches by canonical replay; rewritten regions blame +the rewriting transition, honestly coarse. Blame reads committed +bytes only, and says so when it matters: a blamed path whose working +tree drifts from the committed output carries a drift notice naming +`napl diff` as where the uncommitted change lives, so ancestry over +the committed bytes is never mistaken for the present file. -/// The module PLAN beside the module's live STATE. -/// -/// The plan is the invariant requirements a conforming implementation derives -/// from the working tree's prompts alone: owned globs, placement root, projection -/// ownership, receipt requirements, wave position, never a candidate-dependent -/// fact, which is `add`'s job. The state is what is true of the module right now: -/// tracked or pending, drift, stale reference count, attribution status. The -/// command named status never answers with a plan alone. -/// -/// The payload also carries the derived entry-file projection, path and exact -/// content, computed the way commit computes it but over the working tree, so the -/// line a new file needs in a shared entry file is read from the tool rather than -/// composed by hand. -pub fn module_plan(workspace: &Path, target_name: &str, module: &str) -> Result { - let sources = discover_sources(workspace)?; - let target = builtin_target(target_name)?; - let source = sources.iter().find(|s| s.module == module).ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} has no prompt file in the workspace"), - ) - })?; - let graph = graph_for(&sources, target_name)?; - let levels = topological_levels(&graph)?; - let wave = *levels.get(module).ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} is not in the graph for target {target_name:?}"), - ) - })?; - let root = placement_root(source); +::: test +name: blame over a drifted path names the drift and points at diff +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, blame_path: drifted_file } +expect: { drift_noticed: true, diff_named: true, ancestry_served: true } +::: - let store = disk::open(workspace); - let artifact = compile(&store, &sources, target_name, None)?; - let pending = artifact.slices.iter().any(|slice| slice.module == module); - let frontier: BTreeSet = artifact - .slices - .iter() - .map(|slice| slice.module.clone()) - .collect(); - let head = store.head()?; - let head_states = crate::compile::head_states(&store, head.as_deref())?; - let installs = install_index(&store, head.as_deref())?; - let committed = committed_state(&store, target_name, module)?; - let state = match &committed { - Some(state) => { - let freshness = attribution_freshness(Some(state), module, target_name, &sources); - let prompt_stale = freshness["status"].as_str() != Some("fresh"); - let ends = both_ends( - workspace, - &store, - state, - prompt_stale, - &pending_body(&sources, module), - )?; - let verdict = pending_verdict( - &store, - &sources, - &target, - module, - &head_states, - &installs, - &frontier, - ends.drift, - )?; - let (reason, reasons, reland) = verdict.json_fields(pending); - json!({ - "attribution": freshness, - "docsDrift": crate::staging::docs_drift(workspace)? - .contains(&(target_name.to_string(), module.to_string())), - "drift": ends.drift, - "pending": pending, - "pendingReason": reason, - "pendingReasons": reasons, - "relandIdenticalBytes": reland, - "staleReferences": ends.stale_references as u64, - "tracked": true, - }) - } - // A module with no committed state is not tracked, and saying so is the - // honest answer: it has no baseline to drift from and no references to - // stale, so those read false and zero because they are, not because - // nothing was looked at. - None => { - let verdict = pending_verdict( - &store, - &sources, - &target, - module, - &head_states, - &installs, - &frontier, - false, - )?; - let (reason, reasons, reland) = verdict.json_fields(pending); - json!({ - "attribution": attribution_freshness(None, module, target_name, &sources), - "docsDrift": false, - "drift": false, - "pending": pending, - "pendingReason": reason, - "pendingReasons": reasons, - "relandIdenticalBytes": reland, - "staleReferences": 0, - "tracked": false, - }) - } - }; +`napl lint` unreachable modules +under an explicit `roots` declaration; when roots are absent the refusal +is loud, exit 2 with canonical JSON naming the missing prerequisite and +the exact declaration that would satisfy it, never an empty findings +list under exit 0; unconsumed surface entries (diagnosis only), module staleness; +findings are proposals with cascade previews; `napl lint --fix` applies +only complete deterministic mapping-safe edits through the guarded commit +path and never removes lines. `napl lint ` is the +source-language scan, a different question needing no roots: it parses +every committed module's owned working-tree file under that target +with the pinned grammar and reports one finding per file that fails, +naming the file and the first error's line. Every lint payload names +the scope it judged, graph or source syntax, so an empty findings +list is never read wider than the scan that produced it. Source +syntax findings are facts, not proposals: a target scan carrying any +finding exits 1, so automation can never read broken source as +success, and a clean scan exits 0; the graph scope keeps its +advisory exit. - let (projection_path, projection_bytes) = - working_tree_projection(workspace, &store, &target, &sources, module, &root)?; - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "module": module, - "plan": { - "firstOutput": first_output(&target, &root, module), - "ownedGlobs": owned_globs(&target, &root), - "placementRoot": root, - "projection": { - "content": String::from_utf8_lossy(&projection_bytes), - "ownership": PROJECTION_OWNERSHIP, - "owner": PROJECTION_OWNER, - "path": projection_path, - "userSupplied": false, - }, - "projectionOwned": projection_owned_paths(&target, &root), - "projections": projection_labels(&target, &root), - "receiptRequirements": receipt_requirements(), - "wave": wave as u64, - }, - "state": state, - "target": target_name, - })) -} +::: test +name: lint of a target exits nonzero on source findings and zero when clean +given: { workspace: fixture_two_modules, runs: [clean_tree, syntactically_invalid_owned_file], lint: target_form } +expect: { clean_exit_code: 0, broken_exit_code: 1, findings_when_broken: 1 } +::: -/// What a projection path is, said in the plan every time one is mentioned. -/// -/// The obvious wrong first move in a cold module is to hand-write the crate's -/// entry file, because it is the file that visibly has to change for a new -/// source file to be reachable. It is derived, it is owned by no module, and a -/// candidate carrying it is rejected. Saying so beside the path is cheaper than -/// the rejection that would otherwise teach it. -pub const PROJECTION_OWNERSHIP: &str = "tool-derived"; -pub const PROJECTION_OWNER: &str = - "no module: this path is recomputed by commit from the crate's committed module file sets and is never user-supplied; a candidate that carries it is rejected as invalid-candidate"; +::: test +name: lint of a target reports a parse finding per malformed working-tree file +given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, lint: target_form } +expect: { findings: 1, error_names_file_and_line: true, scope_named: source_syntax, roots_required: false } +::: -/// Every projection path this module's crate has, each labeled for what it is. -pub fn projection_labels(target: &TargetDef, root: &str) -> Vec { - projection_owned_paths(target, root) - .into_iter() - .map(|path| { - json!({ - "ownership": PROJECTION_OWNERSHIP, - "owner": PROJECTION_OWNER, - "path": path, - "userSupplied": false, - }) - }) - .collect() -} +::: test +name: lint of a target over a clean tree answers empty and names its scope +given: { workspace: fixture_two_modules, lint: target_form } +expect: { findings: 0, scope_named: source_syntax, exit_code: 0 } +::: -/// One concrete legal first output path for a module: the module's own name -/// under the target's source directory, inside the placement root. +`napl cost` pure calculation over an explicit +receipt set and a pinned pricing-table provenance object; unknown is +unknown, never zero. Edits price the way generations price: records the +capture verbs staged enter the same receipt set, their session-window +usage references resolve at read time against the named log's bytes, +summing the window's lines under the same pinned pricing table, and +`napl cost --session ` reports one session's calls directly from +the log. A record without a window prices `unavailable`; a window whose +log file or session lines are absent prices `unavailable` and says +which; a model the pricing table does not name prices unknown; nothing +is ever silently zero and nothing is aggregated at record time. The +same receipt set, log bytes, and pricing table produce byte-identical +reports. `napl gc` stages sweeps outside locks, swaps pointers +inside the bounded section, never blocks writers, never deletes anything +reachable from a named head. `napl gc --apply` also repacks: the +reachable closure lands in one pack beside its index under +`objects/pack/` through the store's repack operation, loose files and +older packs are removed after the pack is durable, and the payload +names the pack written, the objects packed, and the files removed. +The dry `napl gc` reports what a repack would write. Every read +answers identically before and after a repack, fsck is healthy after +exactly when it was healthy before, and publication keeps writing +loose objects, so a store is always the sum of its packs plus its +loose files. Nobody is required to know any of this: exactly as git +packs itself, a successful commit whose store holds more loose +objects than a compiled-in threshold runs the same repack +automatically, after the critical section and outside every lock, +and reports what it packed under a `repack` key in the commit +payload; the threshold is a count, never a clock, so when the repack +fires is a function of store content alone, and the pack it writes +is byte-identical to the one gc would have written. A user who never +types gc keeps a small store; gc remains the explicit verb for doing +it now. Reachable means the transitive closure: +from every named head, through every event in its chain, through every +object any reachable object references by hash, output trees, +surfaces, attribution records, prompt blobs, receipts, at every +contract version history carries. `fsck`'s definition of a present +object IS gc's definition of a kept one; a store that was healthy +before a sweep is healthy after it, and blame replays the same +ancestry after a sweep as before. An edge gc does not understand is a +reason to keep, never to sweep. + +::: test +name: gc apply on a workspace with history leaves fsck healthy and blame replaying +given: { workspace: fixture_with_multi_commit_history, gc: apply } +expect: { fsck_findings: 0, blame_replays_history: true, head_intact: true } +::: + +::: test +name: gc apply repacks the reachable closure into one pack and the store still answers +given: { workspace: fixture_with_multi_commit_history, gc: apply } +expect: { packs: 1, loose_reachable_remaining: 0, all_commands_answer_identically: true, fsck_findings: 0 } +::: + +::: test +name: a commit after a repack writes loose objects beside the pack +given: { workspace: fixture_with_multi_commit_history, gc: apply, then: valid_commit } +expect: { commit_outcome: installed, new_objects_loose: true, pack_untouched: true } +::: + +::: test +name: a commit crossing the loose threshold repacks automatically and says so +given: { workspace: fixture_with_multi_commit_history, loose_objects: above_threshold, then: valid_commit } +expect: { commit_outcome: installed, repack_reported: true, packs: 1, loose_reachable_remaining: 0 } +::: + +::: test +name: a commit below the loose threshold never repacks +given: { workspace: fixture_with_multi_commit_history, loose_objects: below_threshold, then: valid_commit } +expect: { commit_outcome: installed, repack_reported: false, packs_unchanged: true } +::: + +::: test +name: the automatic repack writes the byte-identical pack gc would have written +given: { workspace: fixture_with_multi_commit_history, compare: [auto_repack_at_commit, explicit_gc_apply] } +expect: { pack_bytes_equal: true, pack_names_equal: true } +::: + +`napl store migrate` is the one-shot verb that moves a workspace's +objects out of the working tree and into the repository's own object +database. It refuses outside a git repository and refuses when the +store branch already exists, and neither refusal writes anything; +otherwise it walks every tracked object and every pack into blobs +under one deterministic commit, creates the branch there, and reports +the object files as ready to leave the working tree together with the +exact command that removes them, because deleting them from the index +is the owner's ordinary git commit and never a store mutation. The +verb touches no tracked file itself. After it, the object backend is +chosen by observation and never by configuration: a workspace root +inside a git repository whose store branch exists reads and writes +its objects through the git provider, every other workspace uses the +tracked layout, and no flag, key, or environment variable is ever +consulted. Only object storage moves. HEAD, staging, locks, markers, +installed stamps, and attribution keep the disk paths they always +had, because each is branch-local state one shared ref could never +speak for, and the HEAD compare-and-swap with its decision table +arbitrates under either backend exactly as it does today. A +git-backed store is append-only by construction, so its loose count +is zero, the automatic repack never fires, and a sweep reports +without deleting; the packfile machinery stays as the tracked +layout's own mitigation. + +::: test +name: store migrate refuses outside a git repository and refuses a second time inside one +given: { workspace: fixture_two_modules, runs: [gitless, migrated_then_again] } +expect: { gitless_outcome: refused, second_outcome: refused, branch_created_once: true } +::: + +::: test +name: a migrated workspace answers identically through the git provider and leaves the tracked files alone +given: { workspace: fixture_two_modules, history: [commit], then: store_migrate } +expect: { status_identical: true, fsck_findings: 0, tracked_object_files_untouched: true, objects_readable: true } +::: + +::: test +name: a commit into a migrated workspace lands its objects on the store branch and moves the tracked head +given: { workspace: fixture_two_modules, then: [store_migrate, valid_commit] } +expect: { commit_outcome: installed, new_objects_on_branch: true, head_is_the_tracked_file: true, loose_objects_added: 0 } +::: + +`napl fsck` envelope hashes, kind validity, +causal parent links. `napl restore` deterministic repair plans from +persisted evidence; unambiguous moves install, ambiguity blocks; liveness +and wall time never decide recovery. `napl mv ` relocation +as a semantic operation: moves a module's installed files to a new +placement, recomputes every placement-derived fact (derived entry-file +projections, attribution file paths) from the destination, passes +through the guarded commit path, and re-verifies there; failed +verification at the destination installs nothing; manifests are the +user's to update, and status diagnoses a placement the workspace +manifest no longer lists. A move made outside `napl mv` is drift like any other: +the store still holds the committed bytes, `napl restore` re-materializes +the module at its recorded placement, and when the drifted content matches +a committed state exactly the plan may instead adopt the new placement +through the commit path; anything ambiguous blocks. `napl cat-file ` +raw plumbing. `napl lsp` a language server over stdio for editor +extensions: hover, definition, references, and code lens over the +prompt-to-code mapping. Everything it serves derives from committed +records (attribution entries, module surfaces, the ir) resolved against +the workspace root; hover on a prompt line presents the attributed +generated spans with their notes, hover on a generated line presents the +owning prompt lines and note, definition jumps across the mapping in both +directions, references enumerates every attributed span, code lens counts +attributed files per prompt section. The server publishes diagnostics +derived from reference staleness: for every stale reference it emits one +diagnostic on each endpoint, the prompt span whose code moved and the +code span whose prompt changed, so sync debt is visible where the edit +happens, like a type error for documentation. Publication is a pure +derivation from committed records plus the working tree at the moment +the protocol asks, no background mutation, no clocks, no scanning loops +beyond what the editor protocol requires; opening or changing a +document refreshes that document's diagnostics and its counterparts'. +Severity is warning, never error: drift is a legal state, marked, not +policed. The diagnostic message states exactly what is known and +nothing more: which end drifted, the reference's direction, recorded +model or `unavailable`, referentially valid, and the healing verbs +(re-record through `napl edit`, or restore); no wording implies the +sentence describes the code. A fresh reference publishes nothing. +Resolving is always a human or LLM re-affirming or re-pointing the +reference through the capture verbs; the server proposes nothing and +repairs nothing. Pure reads only: no mutation, no +clocks, no background scanning. A request whose records are missing or +unparseable answers empty rather than erroring or blocking, and missing +or unparseable records degrade to no diagnostics, never an error; a stale +mapping is served marked stale, consistent with the attribution contract; +the server never hangs on a malformed or absent record. Record resolution is +anchored per document: walk up from the document's filesystem path to the +nearest ancestor directory containing `.napl` and read records there; the +server's working directory and the client's rootUri carry no meaning. +Every location the server emits that points at a module's prompt resolves +to the prompt file's actual placement found by workspace prompt discovery, +where the prompt sits inside the crate directory its code lands in; a +module name is never joined against the workspace root to fabricate a +prompt path, and a module whose prompt cannot be discovered answers empty. +Crash safety: every persisted application step leaves markers such that +recovery derives a deterministic plan from content alone. Evidence +validators are a registry keyed by media type (Claude CLI JSON envelope +ships first, and `application/x-napl-usage-proxy-jsonl-v1` validates +usage-proxy logs: one canonical JSON object per line, required fields +present, absent values the string `unavailable`, token counts +non-negative integers when present, a line failing the shape is +`invalid`); outcomes `validated`, `unsupported`, `invalid`, +`validator-error`; invalid stays visible as failed. Evidence bounds are +compiled-in constants; exceeding any is `resource-limit` before anything +publishes. + +::: test +name: a stale reference publishes one diagnostic on each endpoint +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, lsp: [open_prompt, open_code] } +expect: { prompt_diagnostics: 1, code_diagnostics: 1, severity: warning } +::: + +::: test +name: a fresh reference publishes nothing +given: { workspace: fixture_two_modules, lsp: [open_prompt, open_code] } +expect: { prompt_diagnostics: 0, code_diagnostics: 0 } +::: + +::: test +name: "the diagnostic message carries drifted end, direction, and provenance and nothing semantic" +given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched, lsp: [open_code] } +expect: { message_names_drifted_end: true, message_names_direction: true, message_model_or_unavailable: true, message_semantic_claims: 0 } +::: + +::: test +name: absent or unparseable records publish no diagnostics and answer empty +given: { workspace: fixture_two_modules, records: attribution_file_corrupted, lsp: [open_prompt] } +expect: { diagnostics: 0, error_responses: 0, server_alive: true } +::: + +::: test +name: committing the drifted code clears both endpoints' diagnostics on refresh +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, then: commit_module, lsp: [change_code_document] } +expect: { prompt_diagnostics: 0, code_diagnostics: 0 } +::: + +::: test +name: diff renders drift between committed output and working tree as a canonical patch +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched } +expect: { diff_nonempty: true, canonical_patch: true, byte_stable: true } +::: + +::: test +name: "blame over a whole-file regeneration coarsens to the transition, line ancestry survives accepted patches" +given: { workspace: fixture_two_modules, history: [patch_commit, full_rewrite_commit] } +expect: { patched_lines_trace_to_patch: true, rewritten_region_blames_transition: true } +::: + +::: test +name: lint reports an unreachable module only under declared roots and refuses without them +given: { workspace: fixture_orphan_module, roots: absent } +expect: { unreachable_analysis: refused, exit_code: 2, reason_names_roots: true, findings_field_absent: true } +::: + +::: test +name: lint with declared roots proposes removal of the orphan with a cascade preview and never auto-applies +given: { workspace: fixture_orphan_module, roots: [main_module] } +expect: { findings: [unreachable_napl], auto_applied: false, cascade_preview: true } +::: + +::: test +name: lint --fix drops an unreferenced declared dependency through the guarded commit path +given: { workspace: fixture_unused_dep, fix: drop_unused_dep } +expect: { outcome: installed, transition_recorded: true, dep_removed: true } +::: + +::: test +name: a cost report over a receipt set containing verb-staged records includes the edits +given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines } +expect: { report_rows: 2, edit_row_priced: true, same_pricing_table: true } +::: + +::: test +name: an edit without a window and a window with missing log lines both price unavailable naming why +given: { workspace: fixture_two_modules, receipts: [edit_without_window, edit_with_window_to_missing_lines] } +expect: { priced_unavailable: 2, reason_named_per_row: true, zeros: 0 } +::: + +::: test +name: a model absent from the pricing table prices unknown never zero +given: { workspace: fixture_two_modules, receipts: [edit_with_unknown_model_window], log: proxy_log_unknown_model } +expect: { priced_unknown: 1, zeros: 0 } +::: + +::: test +name: cost reports are byte-identical across runs over the same receipts log and table +given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines, runs: 2 } +expect: { bytes_equal: true } +::: + +::: test +name: cost session reports one session's calls directly from the log +given: { workspace: fixture_two_modules, log: proxy_log_two_sessions, session: s1 } +expect: { rows_for_session: 2, rows_for_other_sessions: 0, priced_from_log: true } +::: + +::: test +name: a proxy log line failing the shape validates invalid and stays visible as failed +given: { evidence: proxy_log_with_malformed_line, media_type: application_x_napl_usage_proxy_jsonl_v1 } +expect: { outcome: invalid, visible_as_failed: true } +::: + +::: test +name: cost over one receipt set and pricing version is byte-identical across runs and unknown for unknown models +given: { receipts: fixture_receipts, pricing_version: pinned, runs: 2, unknown_model_receipt: included } +expect: { reports_equal: true, unknown_model_cost: unknown } +::: + +::: test +name: gc never deletes an object reachable from the current head and never blocks a concurrent commit +given: { workspace: fixture_two_modules, concurrent: [gc, valid_commit] } +expect: { reachable_deleted: false, commit_blocked: false, unreachable_swept: true } +::: + +::: test +name: fsck detects a corrupted object and a broken parent link +given: { workspace: fixture_two_modules, corrupt: [flip_byte_in_object, remove_parent_event] } +expect: { findings: [hash_mismatch, missing_parent], exit_nonzero: true } +::: + +::: test +name: restore applies an unambiguous move-repair plan and blocks on ambiguity +given: { workspace: fixture_moved_output, ambiguity: [none, two_candidates] } +expect: { unambiguous: installed, ambiguous: blocked } +::: + +::: test +name: hover on an attributed prompt line resolves its generated spans +given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: within_entry } } +expect: { spans_resolved: true, notes_included: true } +::: + +::: test +name: "hover on an unattributed line answers empty, not error" +given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: outside_entries } } +expect: { result: empty, error: false } +::: + +::: test +name: missing attribution records degrade to empty responses +given: { attribution: absent, request: hover } +expect: { result: empty, error: false, blocked: false } +::: + +::: test +name: definition from a generated line targets the prompt at its discovered placement +given: { attribution: fixture_attribution_small, prompt_placement: inside_crate_dir, request: definition, position: { file: generated_within_entry } } +expect: { location_file: discovered_prompt_path, root_joined_module_name: false } +::: + +::: test +name: evidence failing its validator is invalid and distinguishable from unsupported +given: { receipt_evidence: [valid_claude_json, malformed_claude_json, unknown_media_type] } +expect: { outcomes: [validated, invalid, unsupported] } +::: + +::: test +name: a receipt exceeding evidence bounds is rejected as resource-limit with nothing published +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: oversized_evidence } +expect: { outcome: resource_limit, published_semantic_state: false, published_provenance: false } +::: + +## Determinism contract + +Every collection canonically ordered; every input explicit and content +identified; serialization byte-pinned; ambient environment excluded +(locale, timezone, machine identity, CPU count, enumeration order); reads +never write; a state transition names its base and fails on mismatch per +the decision table rather than recalculating. Same workspace compiled +twice, from different absolute paths, under different discovery orders, +locales, and timezones produces byte-identical artifacts. The test suite +proves the vectors in the frontmatter with deterministic fixtures +constructed in each crate's `tests/`. + +::: test +name: compiling the same workspace twice yields byte-identical artifacts +given: { workspace: fixture_two_modules, runs: 2 } +expect: { artifacts_equal: true } +::: + +::: test +name: artifact bytes are independent of discovery order +given: { workspace: fixture_two_modules, discovery_orders: [sorted, reversed, shuffled_seed_7] } +expect: { artifacts_equal: true } +::: + +::: test +name: artifact bytes are independent of the absolute workspace path +given: { workspace: fixture_two_modules, mounted_at: [rootA, rootB] } +expect: { artifacts_equal: true } +::: + +::: test +name: canonical json sorts keys and is byte-stable +given: { value: "{\"b\":1,\"a\":2}", encodings: 2 } +expect: { bytes_equal: true, sorted_keys: true } +::: +napl-store tree v1 4679 +{"entries":[{"hash":"1eab4fcec1746de996240b145f35707eeea8526ddffcfbe0afa5c7ab0f1da3cc","kind":"file","mode":33188,"name":"attribution.rs"},{"hash":"e9997799e9a16e02275f9ccc7d7f893af10b00537cadfc93f0a782b116610f0f","kind":"file","mode":33188,"name":"authoring.rs"},{"hash":"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2","kind":"file","mode":33188,"name":"blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","kind":"file","mode":33188,"name":"candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","kind":"file","mode":33188,"name":"canon.rs"},{"hash":"44704a3a2c842e344fcd7218c1b9da9621dc645340891cafe396a92a52386991","kind":"file","mode":33188,"name":"capture.rs"},{"hash":"8fe3ea8a3dd647910505e230d5c261594b65926d6521a5c7ad5ddaf6bc752fbc","kind":"file","mode":33188,"name":"cli.rs"},{"hash":"b164c6110c4b163fd7d28b03f1f81984452da3282aeb087000e4117f47614ca2","kind":"file","mode":33188,"name":"commandset.rs"},{"hash":"b151c3a18fb9dcae20fbaaddabd221bf11d62aa67578d2a100e67aa8b41408d4","kind":"file","mode":33188,"name":"commit.rs"},{"hash":"51a5efd09235e6a66de7381faaa217a51136ca9c7d2b10285f99af8ebe4ffc6f","kind":"file","mode":33188,"name":"compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","kind":"file","mode":33188,"name":"cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","kind":"file","mode":33188,"name":"decision.rs"},{"hash":"04a648a127be983c89a99181ee26e28401ee00f84ea0b10b6c050ecd263a1b2c","kind":"file","mode":33188,"name":"directive.rs"},{"hash":"28059a89256aad1de78ecd31d7b77cae21772bbc6e96fe8ba1cdb9044048f410","kind":"file","mode":33188,"name":"disk.rs"},{"hash":"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812","kind":"file","mode":33188,"name":"error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","kind":"file","mode":33188,"name":"evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","kind":"file","mode":33188,"name":"graph.rs"},{"hash":"e92c8773dc9d09a3289e877e97f76a056b8dd9d4822b6c224b327cf747972b35","kind":"file","mode":33188,"name":"lint.rs"},{"hash":"659bee27ac76f1462f4e7e5275edac75a6975daa7d2c9e349d92c9a9351e6cbd","kind":"file","mode":33188,"name":"lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","kind":"file","mode":33188,"name":"main.rs"},{"hash":"86fabd45da78a46bbfa7e92465788159360c0abaa4402f10ca433779b419ee0d","kind":"file","mode":33188,"name":"maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","kind":"file","mode":33188,"name":"mv.rs"},{"hash":"3ae089b923327032845c386f2332ff653a08f25ccec28e0786094fa05fd77a00","kind":"file","mode":33188,"name":"objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","kind":"file","mode":33188,"name":"paths.rs"},{"hash":"a14dd6dfce9155411a941b4a6d70650ab7b5ad430be0ed7193a1fcef2a2b2aa5","kind":"file","mode":33188,"name":"porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","kind":"file","mode":33188,"name":"receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","kind":"file","mode":33188,"name":"slice.rs"},{"hash":"0deb3e918644f69c5dd0dfb1434002e896b38efddd4aabc76a778fe02a06b704","kind":"file","mode":33188,"name":"source.rs"},{"hash":"0b443cd4703dc57218f7c2858b1a66913833e5d99d02310bc30f26373a77c434","kind":"file","mode":33188,"name":"staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","kind":"file","mode":33188,"name":"store_view.rs"},{"hash":"9ae420e34160e7c2a6c2ba092aaa9c9ff1df622ece595849d95bf5c5eb7cbe81","kind":"file","mode":33188,"name":"surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","kind":"file","mode":33188,"name":"target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","kind":"file","mode":33188,"name":"textdiff.rs"},{"hash":"cd15f0d80168975552d87c075819d485d3bb65daa0da7f4b3958aa9abd2ecd05","kind":"file","mode":33188,"name":"transaction.rs"},{"hash":"65ee4e7c4fb2eb1af467a3198df19fe8b5ff5479cec1993721e874f60cfeb146","kind":"file","mode":33188,"name":"transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","kind":"file","mode":33188,"name":"trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","kind":"file","mode":33188,"name":"version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","kind":"file","mode":33188,"name":"workspace.rs"}]} +napl-store snapshot v1 1005 +{"contractVersion":4,"event":"220528417ab84fb68a3b21eef82fb259a47e8c9c0b5552e216260bfde8da0cc5","states":[{"module":"agent","state":"d0d406dd4f30ea6840a808bcbc3ddeea8ec8cc10c84de12ab5d0cfeef7bb6696","target":"rust"},{"module":"commands","state":"ac7d92c561ec9fa19f0e28e1392bd90c1d95ca663b038424bd8ce760c6f4cd9e","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"42cee1b00043646e7e0ebed264397212c4ae136d44233aee64af6bebcab11d53","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"accaf93691b84c6794df99c7643c710529c765f12cc9faedede4b6b598a8c6b0","target":"rust"},{"module":"serve","state":"40ddb00b3be320909acf0ae1440e2bc5662e3002dcf77dc3367bcb215b1710fd","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} +napl-store blob v1 26049 +//! Attribution transport: carrying recorded claims across a byte diff, or +//! refusing to. +//! +//! A recorded attribution claim names a line range. When the content under that +//! range changes, the claim has to move with it or stop being a claim. Transport +//! is that move, and it is the same computation blame already performs: replay +//! the line ancestry the diff proves, and say nothing where the diff proves +//! nothing. Blame answers "which transition wrote this line"; transport answers +//! "where did this range go", and both are reads of the identical `Keep`, +//! `Insert`, `Delete` script, so the two share this one line-ancestry map. +//! +//! The bias is refusal over inference. A range every line of which survives as +//! one contiguous run transports to that run: an insertion or deletion above it +//! shifts it by the delta, and an untouched file leaves it exactly where it was. +//! Anything else — a line that did not survive, a run broken apart by content +//! inserted inside it, a region rewritten, a move that reads as a delete plus an +//! add — does not transport. The refusal carries the observed bound next to the +//! claim, so a reader never has to count lines by hand to find out what the +//! content actually says now. +//! +//! Two consumers share this: `add`, which transports a committed map onto staged +//! bytes and a pending prompt body, and the working-tree document, which +//! transports the same map onto the pending prompt body so a view can serve a +//! section's files without re-associating them by range arithmetic. + +use std::collections::BTreeMap; + +use serde_json::{json, Value}; + +use crate::attribution::{Attribution, Entry}; +use crate::textdiff::{diff_lines, lines, Edit}; + +/// The line ancestry one diff proves, as a lookup from old line to new line. /// -/// A plan that lists globs and a projection but no example leaves the author to -/// pick a path and find out afterwards whether it was ownable. Naming one that -/// certainly is turns the first move into a copy. -pub fn first_output(target: &TargetDef, root: &str, module: &str) -> Value { - let path = crate::paths::join_root( - root, - &format!( - "{}/{}.{}", - target.source_dir, - crate::paths::encode_name(module), - target.source_extension - ), - ); - json!({ - "detail": "one concrete path this module may own: its own name under the target's source directory, inside the placement root; any path matching the owned globs that is not a projection or a manifest is equally legal", - "path": path, - }) +/// `moved[i]` is where old line `i` (0-based) landed, or `None` when the diff +/// deleted it. `new_len` is the line count of the new content, which is the +/// observed bound a refusal reports. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LineMap { + moved: Vec>, + new_len: usize, } -/// The crate's entry-file projection as commit would derive it if this module -/// were committed from the working tree right now. -/// -/// The inputs are the other modules' committed file sets plus this module's -/// working-tree files, and the command providers are read the same way: from the -/// other modules' recorded surfaces plus this module's working-tree surface. That -/// is what makes the answer useful for a file that is new: a projection derived -/// from committed state alone could never name it, and naming it is the whole -/// question a reader asks here. -fn working_tree_projection( - workspace: &Path, - store: &Store, - target: &TargetDef, - sources: &[crate::source::SourceModule], - module: &str, - root: &str, -) -> Result<(String, Vec)> { - let src_root = crate::paths::join_root(root, &target.source_dir); - let mut files: Vec = Vec::new(); - let mut providers: Vec = Vec::new(); +impl LineMap { + /// The number of lines the new content has: the bound every claim against it + /// is checked in. + pub fn new_line_count(&self) -> u32 { + self.new_len as u32 + } - let states = match store.head()? { - Some(head) => snapshot_states(store, &head)?, - None => BTreeMap::new(), - }; - for ((state_target, other), hash) in &states { - if state_target != &target.name || other == module { - continue; - } - let state = read_module_state(store, hash)?; - if !has_object(store, &state.output_tree) { - continue; - } - let owned: Vec = flatten_tree(store, &state.output_tree)?.into_keys().collect(); - if !owned - .iter() - .any(|path| crate::paths::is_under(path, &src_root)) - { - continue; - } - files.extend(owned); - for item in read_surface_items(store, &state.surface_hash)? { - if let Some(provider) = command_provider(&item.kind, &item.path) { - providers.push(provider); + /// The number of lines the old content had. + pub fn old_line_count(&self) -> u32 { + self.moved.len() as u32 + } + + /// Where a 1-based old line landed, 1-based, or `None` when it did not + /// survive. + pub fn moved_line(&self, old_line: u32) -> Option { + let index = (old_line as usize).checked_sub(1)?; + self.moved.get(index).copied().flatten().map(|new| new as u32 + 1) + } + + /// The last surviving line at or before `old_line`, expressed in the new + /// content: the proven anchor a refusal can be located at without inventing + /// an extent for the claim. `None` when nothing at or before it survived. + pub fn anchor_before(&self, old_line: u32) -> Option { + let mut cursor = old_line.min(self.old_line_count()); + while cursor >= 1 { + if let Some(new) = self.moved_line(cursor) { + return Some(new); } + cursor -= 1; } + None } +} - let working = working_owned_files(workspace, store, target, sources, module)?; - for item in crate::surface::extract_surface(target, root, &working)? { - if let Some(provider) = command_provider(&item.kind, &item.path) { - providers.push(provider); +/// Build the line ancestry between two texts. +pub fn line_map(old: &str, new: &str) -> LineMap { + let old_lines = lines(old); + let new_lines = lines(new); + let mut moved: Vec> = vec![None; old_lines.len()]; + for edit in diff_lines(&old_lines, &new_lines) { + if let Edit::Keep(old_index, new_index) = edit { + moved[old_index] = Some(new_index); } } - files.extend(working.into_keys()); - Ok(crate::target::derive_lib_projection( - target, root, &files, &providers, - )) + LineMap { + moved, + new_len: new_lines.len(), + } } -/// The module stem a surface item declares a command table for, when it declares -/// one: the convention is one `commands` function per module file. -fn command_provider(kind: &str, path: &str) -> Option { - if kind != "fn" { - return None; +/// A 1-based inclusive line range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Range { + pub start: u32, + pub end: u32, +} + +impl Range { + pub fn new(start: u32, end: u32) -> Range { + Range { start, end } } - let prefix = path.strip_suffix("::commands")?; - (!prefix.is_empty() && !prefix.contains(' ')).then(|| prefix.to_string()) } -/// A module's owned files as they sit in the working tree: the committed paths -/// read from disk, plus the paths under its globs no module has committed. -fn working_owned_files( - workspace: &Path, - store: &Store, - target: &TargetDef, - sources: &[crate::source::SourceModule], - module: &str, -) -> Result>> { - let mut out: BTreeMap> = BTreeMap::new(); - if let Some(state) = committed_state(store, &target.name, module)? { - if has_object(store, &state.output_tree) { - for path in flatten_tree(store, &state.output_tree)?.into_keys() { - if let Ok(bytes) = std::fs::read(workspace.join(&path)) { - out.insert(path, bytes); - } - } +/// Why a range refused to transport, in the words the refusal is served in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refusal { + /// The range names lines the old content never had, so there was nothing to + /// carry: a claim already wrong before the diff. + OutsideOldContent, + /// At least one line of the range did not survive the diff. A rewritten + /// region and a move that reads as a delete plus an add both land here, and + /// the transport declines to tell them apart rather than guessing which. + RegionRewritten, + /// Every line survived, but not as one run: content entered the middle of + /// the range, so the range's extent in the new content is an inference and + /// not a fact the diff proves. + RangeSplit, +} + +impl Refusal { + /// The wire word a payload carries. + pub fn as_str(&self) -> &'static str { + match self { + Refusal::OutsideOldContent => "outside-recorded-content", + Refusal::RegionRewritten => "region-rewritten", + Refusal::RangeSplit => "range-split", } } - for path in crate::staging::unclaimed_working_paths(workspace, store, target, module, sources)? { - if let Ok(bytes) = std::fs::read(workspace.join(&path)) { - out.insert(path, bytes); + + /// One sentence saying what the transport could not prove. + pub fn detail(&self) -> &'static str { + match self { + Refusal::OutsideOldContent => { + "the recorded range names lines the recorded content does not have" + } + Refusal::RegionRewritten => { + "the recorded range covers lines the diff shows were rewritten or removed, so where it moved to is not proven" + } + Refusal::RangeSplit => { + "every recorded line survived but content entered the middle of the range, so its new extent would be a guess" + } } } - Ok(out) } -/// Observe, never mutate, the manifest/placement mismatches the toolchain now -/// only diagnoses: a module placed in a crate the workspace manifest does not -/// list, or a crate directory missing a manifest. Manifests are user-owned -/// seams, so these are reports, not writes. -fn manifest_diagnostics( - workspace: &Path, - target_name: &str, - sources: &[crate::source::SourceModule], -) -> Vec { - let Ok(target) = builtin_target(target_name) else { - return Vec::new(); - }; - let members = workspace_members(workspace, &target); - let mut roots: Vec = sources.iter().map(placement_root).collect(); - roots.sort(); - roots.dedup(); - let mut out: Vec = Vec::new(); - for root in roots { - if root.is_empty() { - continue; - } - if let Some(members) = &members { - if !members.contains(&root) { - out.push(manifest_diagnostic( - &root, - // The manifest this observation means is the workspace-root - // one, which holds the member list, not the crate's own. - &target.manifest_file, - "crate-not-listed-in-workspace-manifest", - &format!("the crate {root:?} holds a placed module but the workspace manifest does not list it as a member"), - )); - } - } - let manifest = workspace.join(&root).join(&target.manifest_file); - if !manifest.is_file() { - out.push(manifest_diagnostic( - &root, - &crate::paths::join_root(&root, &target.manifest_file), - "crate-directory-missing-manifest", - &format!("the crate directory {root:?} receives placed modules but has no manifest"), - )); +/// What transport made of one range. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Transported { + /// The diff proves the range is now exactly this range. + Proven(Range), + /// The diff proves nothing about where the range went. `surviving` is the + /// largest proven run inside the range, when there is one, and `anchor` is + /// the last surviving line at or before the range's start: both are + /// observations about the new content, never a repositioned claim. + Unproven { + refusal: Refusal, + surviving: Option, + anchor: Option, + }, +} + +impl Transported { + /// The proven range, or `None`. + pub fn proven(&self) -> Option { + match self { + Transported::Proven(range) => Some(*range), + Transported::Unproven { .. } => None, } } - out } -/// One manifest observation, which always locates the file it means. -/// -/// A manifest is a user-owned seam: the toolchain never writes one, so a -/// diagnostic that only alluded to "the manifest" would leave the reader hunting -/// for the file they are the only one allowed to edit. Every diagnostic therefore -/// carries the exact workspace-relative path and says plainly whose file it is. -fn manifest_diagnostic( - crate_root: &str, - manifest: &str, - observation: &str, - detail: &str, -) -> Value { - json!({ - "crate": crate_root, - "detail": detail, - "manifest": manifest, - "observation": observation, - "owner": "user", - "remedy": format!("{manifest} is yours to edit; the toolchain diagnoses manifests and never writes them"), - }) +/// Carry one range across a diff, or refuse. +pub fn transport_range(map: &LineMap, range: Range) -> Transported { + if range.start == 0 || range.start > range.end || range.end > map.old_line_count() { + return Transported::Unproven { + refusal: Refusal::OutsideOldContent, + surviving: None, + anchor: map.anchor_before(range.start), + }; + } + let mut moved: Vec> = Vec::new(); + for line in range.start..=range.end { + moved.push(map.moved_line(line)); + } + if moved.iter().any(Option::is_none) { + return Transported::Unproven { + refusal: Refusal::RegionRewritten, + surviving: longest_run(&moved), + anchor: map.anchor_before(range.start), + }; + } + let first = moved[0].expect("every line survived"); + let contiguous = moved + .iter() + .enumerate() + .all(|(offset, line)| *line == Some(first + offset as u32)); + if !contiguous { + return Transported::Unproven { + refusal: Refusal::RangeSplit, + surviving: longest_run(&moved), + anchor: map.anchor_before(range.start), + }; + } + Transported::Proven(Range::new( + first, + first + (range.end - range.start), + )) } -/// The crate directories the workspace-root manifest lists as members, or -/// `None` when there is no readable workspace manifest to compare against. -fn workspace_members(workspace: &Path, target: &TargetDef) -> Option> { - let text = std::fs::read_to_string(workspace.join(&target.manifest_file)).ok()?; - let mut members: Vec = Vec::new(); - for line in text.lines() { - let trimmed = line.trim().trim_end_matches(','); - if let Some(inner) = trimmed - .strip_prefix('"') - .and_then(|rest| rest.strip_suffix('"')) - { - if !is_manifest_path(target, inner) && inner.contains('/') { - members.push(inner.to_string()); +/// The longest contiguous surviving run inside a partially surviving range, +/// expressed in the new content. This is an observation offered beside a +/// refusal, not a transported claim. +fn longest_run(moved: &[Option]) -> Option { + let mut best: Option = None; + let mut current: Option = None; + for entry in moved { + match (entry, current) { + (Some(line), Some(run)) if *line == run.end + 1 => { + current = Some(Range::new(run.start, *line)); } + (Some(line), _) => current = Some(Range::new(*line, *line)), + (None, _) => current = None, } - } - Some(members) -} - -/// Classify a module's attribution freshness and, when it is not fresh, name the -/// exact fix. -/// -/// It is a pure observation over content already resolved: an absent record, a -/// record installed unproven, or a record whose recorded prompt hash no longer -/// matches the current normalized body all read as needing a fresh commit. A -/// record proven against the body still on disk is fresh. -fn attribution_freshness( - state: Option<&ModuleState>, - module: &str, - target: &str, - sources: &[crate::source::SourceModule], -) -> Value { - let directive = json!({ - "command": "commit", - "directive": format!( - "commit module {module:?} for target {target:?} with a fresh attribution update" - ), - "module": module, - "target": target, - }); - match state.and_then(|state| state.attribution.as_ref()) { - None => json!({ "fix": directive, "status": "absent" }), - Some(attribution) => { - let current = sources - .iter() - .find(|source| source.module == module) - .map(|source| source.normalized_source_hash.as_str()); - if attribution.stale { - json!({ - "fix": directive, - "reason": "installed with --no-verify", - "status": "stale", - }) - } else if current != Some(attribution.prompt_hash.as_str()) { - json!({ - "fix": directive, - "reason": "the prompt changed since the attribution was recorded", - "status": "stale", - }) - } else { - json!({ "status": "fresh" }) + if let Some(run) = current { + let longer = best + .map(|seen| run.end - run.start > seen.end - seen.start) + .unwrap_or(true); + if longer { + best = Some(run); } } } + best } -/// The causal history reachable from HEAD, optionally narrowed to one module. +// ------------------------------------------------ transporting a whole record + +/// The four texts a record is transported between: the prompt body as recorded +/// and as it stands pending, and the module's output as committed and as staged. /// -/// The `ordinal` field is a presentation device computed from the causal graph -/// at read time. It is never stored and never enters an identity. -pub fn log(workspace: &Path, module: Option<&str>) -> Result { - let store = disk::open(workspace); - let Some(head) = store.head()? else { - return Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "entries": [], - "head": Value::Null, - })); - }; - let mut entries: Vec = Vec::new(); - for (ordinal, event) in causal_order(&store, &head)?.iter().enumerate() { - let transition_id = event_transition(&store, event)?; - let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; - if let Some(wanted) = module { - if transition["module"].as_str() != Some(wanted) { - continue; - } - } - entries.push(json!({ - "event": event, - "module": transition["module"], - "newState": transition["newState"], - "noteCount": attached_notes(&store, &transition_id)?.len() as u64, - "ordinal": ordinal as u64, - // A reference riding a transition is provenance exactly as a note is, - // so the log counts both and names the one hop that serves their full - // text. Provenance a reader cannot find is provenance that may as well - // not be recorded. - "referenceCount": transition_references(&store, &transition)?.len() as u64, - "target": transition["target"], - "transition": transition_id, - "writeSet": transition["writeSet"], - })); - } - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "entries": entries, - "head": head, - "inspect": { - "command": "napl show ", - "detail": "the full text of a transition's notes and reference entries is served by `napl show `, one named hop from any entry's transition hash", - }, - })) +/// Both sides are content the caller already has in hand, so the transport is a +/// pure function of them and takes no filesystem or store of its own. +#[derive(Debug, Clone)] +pub struct TransportInputs<'a> { + pub committed_body: &'a str, + pub pending_body: &'a str, + pub committed_files: &'a BTreeMap, + pub staged_files: &'a BTreeMap, } -/// Describe any stored object: its kind and, for JSON kinds, its payload. +/// What the transport made of ONE endpoint of one reference. /// -/// A transition also lists the notes attached to it inline, hash and text, so a -/// note attached is a note findable: nobody should have to know that notes live -/// behind an index and are stored as blobs in order to read one back. -pub fn show(workspace: &Path, hash: &str) -> Result { - let store = disk::open(workspace); - let (kind, payload) = get_object(&store, hash)?; - let body = if kind == ObjectKind::Blob { - json!({ "bytes": payload.len() as u64 }) - } else { - serde_json::from_slice(&payload).map_err(|e| { - error( - OutcomeCode::StoreIntegrity, - format!("object {hash} is not JSON: {e}"), - ) - })? - }; - let mut out = json!({ - "body": body, - "contractVersion": crate::version::CONTRACT_VERSION, - "hash": hash, - "kind": kind.as_wire(), - }); - if kind == ObjectKind::Transition { - out["notes"] = Value::Array(attached_notes(&store, hash)?); - out["references"] = Value::Array(transition_references(&store, &out["body"])?); +/// A reference has two ends and they are diffed against different content, so +/// they get different answers and both answers are facts. Reporting only the +/// failing one throws away work the transport already did and sends the author +/// to re-derive a range the tool has in hand. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EndpointVerdict { + /// The range as recorded, or `None` when the endpoint is spelled whole. + pub recorded: Option, + /// True when the endpoint claims the whole body or the whole file, which + /// transports by construction because it names no lines. + pub whole: bool, + /// Where the diff proves the range went, when it proves anything. + pub transported: Option, + /// The refusal word, or `None` when this endpoint transported. + pub reason: Option<&'static str>, + pub detail: Option, + /// The line count of the content this endpoint was checked against. + pub observed_line_count: u32, + /// The largest run of the claim that provably survived, when any did. + pub observed_surviving: Option, + /// The path the bound was observed in; the code side only. + pub path: Option, +} + +impl EndpointVerdict { + /// True when this end of the reference needs a human. + pub fn refused(&self) -> bool { + self.reason.is_some() + } + + fn as_json(&self) -> Value { + json!({ + "detail": self.detail.clone().map_or(Value::Null, Value::String), + "observedBound": { + "lineCount": self.observed_line_count, + "path": self.path.clone().map_or(Value::Null, Value::String), + "survivingRange": self + .observed_surviving + .map_or(Value::Null, |range| json!([range.start, range.end])), + }, + "reason": self.reason.map_or(Value::Null, |r| json!(r)), + "recorded": self + .recorded + .map_or(Value::Null, |range| json!([range.start, range.end])), + "transported": self + .transported + .map_or(Value::Null, |range| json!([range.start, range.end])), + "verdict": if self.refused() { "refused" } else { "transported" }, + "whole": self.whole, + }) } - Ok(out) } -/// The reference entries one transition introduced, served inline. +/// One claim the transport declined, beside the bound it was checked against. /// -/// A reference attached to a transition is exactly as findable as a note -/// attached to one: both are provenance riding a transition, and both are read -/// here rather than through plumbing. What this transition introduced is the -/// difference between its new state's attribution record and the record its base -/// state carried, so a commit that merely carried the module's existing -/// references forward lists none and a capture verb's transition lists the one it -/// recorded. A base that cannot be resolved degrades to the whole new record -/// rather than erroring, the way every other read here degrades. -fn transition_references(store: &Store, body: &Value) -> Result> { - let Some(new_state) = body["newState"].as_str() else { - return Ok(Vec::new()); - }; - let Some(record) = state_attribution_entries(store, new_state)? else { - return Ok(Vec::new()); - }; - let base = match body["baseStateKey"].as_str() { - Some(base) => state_attribution_entries(store, base)?.unwrap_or_default(), - None => Vec::new(), - }; - Ok(record - .into_iter() - .filter(|entry| !base.contains(entry)) - .map(|entry| entry.as_json()) - .collect()) +/// The bound is the point: a claim rejected without one costs the reader a +/// manual line count, and the whole reason attribution is machine-checked is so +/// nobody counts lines by hand. +/// +/// Both endpoints are always reported, each with its own verdict. A claim whose +/// prompt side was rewritten but whose code side never moved serves the +/// transported code range beside the failed prompt range, and +/// `affirmationNeeded` names only the side that actually needs a human: the +/// other one is already proven, and asking for it again would be asking the +/// author to re-type a fact. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimRefusal { + /// The claim's position in the recorded entry list, which is the order the + /// refusal listing and any affirmation both use. + pub index: usize, + pub file: Option, + pub prompt: EndpointVerdict, + /// The code endpoint, absent on a law, which owns no file at all. + pub code: Option, } -/// The attribution entries a module state's record carries, or `None` when the -/// state names no record or the record is not readable. -fn state_attribution_entries( - store: &Store, - state_hash: &str, -) -> Result>> { - if !has_object(store, state_hash) { - return Ok(None); +impl ClaimRefusal { + /// The recorded prompt range, or the whole-body range placeholder. + pub fn prompt_lines(&self) -> Range { + self.prompt.recorded.unwrap_or(Range::new(1, 1)) } - let state = read_module_state(store, state_hash)?; - let Some(attribution) = state.attribution else { - return Ok(None); - }; - if !has_object(store, &attribution.record) { - return Ok(None); + + /// The sides that need a human, in canonical order. + pub fn affirmation_needed(&self) -> Vec<&'static str> { + let mut out: Vec<&'static str> = Vec::new(); + if self.code.as_ref().is_some_and(EndpointVerdict::refused) { + out.push("code"); + } + if self.prompt.refused() { + out.push("prompt"); + } + out } - let bytes = get_blob(store, &attribution.record)?; - Ok(crate::attribution::parse(&String::from_utf8_lossy(&bytes)).map(|record| record.entries)) -} -/// The notes attached to one transition, each as its blob name and its text. -/// -/// The index is a plain list of blob names beside the store, so a missing or -/// unreadable index is simply no notes: a reader degrades to empty rather than -/// erroring, exactly as every other read here does. -fn attached_notes(store: &Store, transition: &str) -> Result> { - let index = store.root.join("notes").join(format!("{transition}.json")); - let bytes = match std::fs::read(&index) { - Ok(bytes) => bytes, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(disk::io(&index, &e)), - }; - let names: Vec = serde_json::from_slice(&bytes).unwrap_or_default(); - let mut out: Vec = Vec::new(); - for name in names { - if !has_object(store, &name) { - continue; + /// The side named at the top level: the failing one, and the prompt when + /// both failed, because a prompt rewrite is the cause a reader looks at + /// first. + pub fn side(&self) -> &'static str { + if self.prompt.refused() { + "prompt" + } else { + "code" } - let body = get_blob(store, &name)?; - let parsed: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); - out.push(json!({ - "hash": name, - "note": parsed["note"].as_str().unwrap_or_default(), - })); } - Ok(out) -} -/// The raw payload bytes of an object, with the envelope stripped. -pub fn cat_file(workspace: &Path, hash: &str) -> Result> { - Ok(get_object(&disk::open(workspace), hash)?.1) -} + fn failing(&self) -> &EndpointVerdict { + if self.prompt.refused() { + &self.prompt + } else { + self.code.as_ref().unwrap_or(&self.prompt) + } + } -/// The recorded surface of a module as of HEAD. -/// -/// A module with no committed state under this target has no recorded surface at -/// all, and an empty item list would read as a real surface with nothing public. -/// The honest answer names the absent baseline and carries no items, exactly as -/// `diff` and `blame` answer for a missing baseline. -pub fn surface_of(workspace: &Path, target: &str, module: &str) -> Result { - let store = disk::open(workspace); - let Some(state) = committed_state(&store, target, module)? else { - return Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "module": module, - "reason": format!( - "module {module:?} has no committed state under target {target:?}, so there is no recorded surface to serve and no baseline to read one from" - ), - "target": target, - "tracked": false, - })); - }; - let items = read_surface_items(&store, &state.surface_hash)?; - let mut out = surface_json(module, target, &items); - if let Some(object) = out.as_object_mut() { - object.insert("tracked".to_string(), Value::Bool(true)); + /// The canonical JSON one listed claim is served as. + pub fn as_json(&self) -> Value { + let failing = self.failing(); + let prompt_lines = self.prompt_lines(); + json!({ + "affirmationNeeded": self.affirmation_needed(), + "detail": failing.detail.clone().unwrap_or_default(), + "endpoints": { + "code": self.code.as_ref().map_or(Value::Null, EndpointVerdict::as_json), + "prompt": self.prompt.as_json(), + }, + "file": self.file.clone().map_or(Value::Null, Value::String), + "lines": self + .code + .as_ref() + .and_then(|code| code.recorded) + .map_or(Value::Null, |range| json!([range.start, range.end])), + "observedBound": { + "lineCount": failing.observed_line_count, + "path": failing.path.clone().map_or(Value::Null, Value::String), + "survivingRange": failing + .observed_surviving + .map_or(Value::Null, |range| json!([range.start, range.end])), + }, + "promptLines": [prompt_lines.start, prompt_lines.end], + "reason": failing.reason.unwrap_or("region-rewritten"), + "side": self.side(), + }) } - Ok(out) } -/// Whether this workspace has a committed head at all. +/// What transporting a whole record produced: the record carried forward as far +/// as the diffs prove, and the claims that refused. /// -/// The one observation a command whose meaning requires a baseline makes before -/// it answers, so a headless workspace gets the prerequisite sequence instead of -/// an empty shape that would read as success. -pub fn has_committed_head(workspace: &Path) -> Result { - Ok(disk::open(workspace).head()?.is_some()) +/// The carried record is complete exactly when `refusals` is empty. When it is +/// not, the refused entries are still present in `record` at their recorded +/// ranges, so an affirmation can be layered onto them positionally. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransportOutcome { + pub record: Attribution, + pub refusals: Vec, } -/// Drift between committed outputs and the working tree, as a canonical patch. -/// -/// A target no committed module owns anything under has no baseline, and the -/// honest answer is `tracked: false` with the reason: an empty patch would say -/// "nothing drifted" when the truth is "nothing was compared". -pub fn diff(workspace: &Path, target: &str) -> Result { - let store = disk::open(workspace); - let committed = committed_text(&store, target)?; - if committed.is_empty() { - return Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "reason": format!( - "no committed module owns any output under target {target:?}, so there is no baseline to diff against" - ), - "target": target, - "tracked": false, - })); +impl TransportOutcome { + /// True when every claim was carried and no affirmation is needed. + pub fn complete(&self) -> bool { + self.refusals.is_empty() } - let paths: Vec = committed.keys().cloned().collect(); - let working = read_working_text(workspace, &paths); - let patch = render_drift(&committed, &working); - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "patch": patch, - "target": target, - "tracked": true, - })) } -/// Derived line ancestry for one committed path. -/// -/// A path no committed module owns has no ancestry to replay, and empty ancestry -/// would read as a file that exists and was never written. It answers -/// `tracked: false` naming the absent baseline instead, an honest success shape. +/// Transport every claim a recorded attribution makes onto the staged bytes and +/// the pending prompt body. /// -/// Blame reads committed bytes only. When the working tree has moved away from -/// them the report carries a drift notice naming `napl diff` as where the -/// uncommitted change lives, so ancestry is never mistaken for the present file. -pub fn blame_path(workspace: &Path, path: &str) -> Result { - let store = disk::open(workspace); - let Some(head) = store.head()? else { - return Ok(untracked_json( - path, - "this workspace has no committed head, so no module owns this path yet", - )); - }; - if !committed_manifest_all_targets(&store)?.contains(path) { - return Ok(untracked_json( - path, - "no committed module owns this path, so there is no committed baseline to trace it through", - )); - } - let mut revisions: Vec = Vec::new(); - for event in causal_order(&store, &head)? { - let transition_id = event_transition(&store, &event)?; - let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; - let touched = transition["installPaths"] - .as_array() - .map(|paths| paths.iter().any(|p| p.as_str() == Some(path))) - .unwrap_or(false); - if !touched { +/// Each claim is carried through two diffs, the prompt's and its file's, and both +/// have to prove their side. A claim whose file is absent from either tree cannot +/// be proven at all and refuses with the same honesty as a rewritten region. +pub fn transport_attribution(record: &Attribution, inputs: &TransportInputs) -> TransportOutcome { + let prompt_map = line_map(inputs.committed_body, inputs.pending_body); + let mut carried = record.clone(); + let mut refusals: Vec = Vec::new(); + + for (index, entry) in record.entries.iter().enumerate() { + // Both ends are judged, always and independently. Stopping at the first + // refusal would throw away a verdict the transport is about to compute + // anyway and would send the author to re-derive a range already proven. + let prompt = prompt_endpoint(entry, &prompt_map); + let code = code_endpoint(entry, inputs); + + let refused = prompt.refused() || code.as_ref().is_some_and(EndpointVerdict::refused); + if refused { + refusals.push(ClaimRefusal { + index, + file: entry.file.clone(), + prompt, + code, + }); continue; } - let Some(state_hash) = transition["newState"].as_str() else { - continue; - }; - let state = read_module_state(&store, state_hash)?; - let files = flatten_tree(&store, &state.output_tree)?; - let Some((blob, _)) = files.get(path) else { - continue; - }; - let bytes = get_blob(&store, blob)?; - revisions.push(Revision { - transition: transition_id, - content: String::from_utf8_lossy(&bytes).into_owned(), - }); - } - let mut value = blame_json(path, &blame(&revisions)); - // Blame reads committed bytes only, and says so when it matters. A path - // whose working tree has moved away from the committed output carries a - // notice naming `napl diff` as where the uncommitted change lives, so - // ancestry over committed bytes is never mistaken for the present file. - let committed = revisions.last().map(|revision| revision.content.clone()); - let working = std::fs::read(workspace.join(path)).ok(); - let working_text = working - .as_deref() - .map(|bytes| String::from_utf8_lossy(bytes).into_owned()); - let drifted = match (&committed, &working_text) { - (Some(committed), Some(working)) => committed != working, - _ => false, - }; - if let Some(object) = value.as_object_mut() { - object.insert("drift".to_string(), Value::Bool(drifted)); - if drifted { - object.insert( - "driftNotice".to_string(), - Value::String(format!( - "the working tree of {path:?} differs from the committed output; this ancestry is over the committed bytes only, and the uncommitted change lives in `napl diff`" - )), - ); - object.insert("driftInspect".to_string(), Value::String("napl diff".to_string())); + + if let Some(proven) = prompt.transported { + carried.entries[index].prompt_start = proven.start; + carried.entries[index].prompt_end = proven.end; + } + if let Some(proven) = code.as_ref().and_then(|verdict| verdict.transported) { + carried.entries[index].line_start = proven.start; + carried.entries[index].line_end = proven.end; } } - Ok(value) + + TransportOutcome { + record: carried, + refusals, + } } -/// The source-language scan `napl lint ` performs: parse every -/// committed module's owned working-tree file under that target with the pinned -/// grammar and report one finding per file that fails. +/// Judge the prompt end of one entry against the prompt's own diff. /// -/// This is a different question from the graph analyses beside it and it needs no -/// roots declaration to answer, because nothing about reachability is involved. -/// The payload names its scope, so an empty findings list is never read wider -/// than the scan that produced it. -pub fn source_syntax_scan(workspace: &Path, target_name: &str) -> Result { - let store = disk::open(workspace); - let target = builtin_target(target_name)?; - let sources = discover_sources(workspace)?; - let roots: BTreeSet = sources - .iter() - .filter(|source| source.targets.iter().any(|t| t == target_name)) - .map(placement_root) - .collect(); - let mut findings: Vec = Vec::new(); - let mut scanned = 0u64; - for path in committed_manifest(&store, target_name)?.into_keys() { - if !roots - .iter() - .any(|root| crate::surface::is_target_source(&target, root, &path)) - { - continue; - } - let Ok(bytes) = std::fs::read(workspace.join(&path)) else { - // A committed path the working tree no longer holds is drift, which - // `diff` and `status` already report; the syntax scan judges bytes it - // can read and claims nothing about bytes it cannot. - continue; +/// A whole endpoint transports by construction: it names no lines, so no diff +/// can move it. The whole body is still the whole body whatever the bytes +/// between its ends did. +fn prompt_endpoint(entry: &Entry, map: &LineMap) -> EndpointVerdict { + let recorded = Range::new(entry.prompt_start, entry.prompt_end); + if entry.whole_prompt { + return EndpointVerdict { + recorded: None, + whole: true, + transported: None, + reason: None, + detail: None, + observed_line_count: map.new_line_count(), + observed_surviving: None, + path: None, }; - scanned += 1; - if let Some(failure) = crate::surface::parse_check(&target, &path, &bytes)? { - findings.push(json!({ - "autoFixable": false, - "file": failure.path, - "kind": "source-parse-error", - "line": failure.line, - "parseFailure": failure.as_json(), - "proposal": format!( - "fix the syntax at line {} of {:?}; the pinned grammar cannot parse it, so no surface can be extracted from it", - failure.line, failure.path - ), - "scope": crate::surface::SOURCE_SYNTAX_SCOPE, - })); - } } - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "detail": "every committed module's owned working-tree file under this target was parsed with the target's pinned grammar; this scan judges syntax and nothing else, and it needs no roots declaration", - "filesScanned": scanned, - "findings": findings, - "rootsRequired": false, - "scope": crate::surface::SOURCE_SYNTAX_SCOPE, - "target": target_name, - })) + match transport_range(map, recorded) { + Transported::Proven(proven) => EndpointVerdict { + recorded: Some(recorded), + whole: false, + transported: Some(proven), + reason: None, + detail: None, + observed_line_count: map.new_line_count(), + observed_surviving: None, + path: None, + }, + Transported::Unproven { + refusal, surviving, .. + } => EndpointVerdict { + recorded: Some(recorded), + whole: false, + transported: None, + reason: Some(refusal.as_str()), + detail: Some(refusal.detail().to_string()), + observed_line_count: map.new_line_count(), + observed_surviving: surviving, + path: None, + }, + } } -/// Diagnose a workspace. -pub fn lint_workspace(workspace: &Path, target: &str) -> Result<(Graph, LintReport, Value)> { - let store = disk::open(workspace); - let sources = discover_sources(workspace)?; - let artifact = compile(&store, &sources, target, None)?; - let graph = crate::compile::graph_for(&sources, target)?; - let definition = builtin_target(target)?; - let states = match store.head()? { - Some(snapshot) => snapshot_states(&store, &snapshot)?, - None => BTreeMap::new(), - }; +/// Judge the code end of one entry against its file's diff, or `None` for a law, +/// which owns no file at all. +fn code_endpoint(entry: &Entry, inputs: &TransportInputs) -> Option { + let file = entry.file.clone()?; + let staged_len = inputs + .staged_files + .get(&file) + .map(|text| lines(text).len() as u32) + .unwrap_or(0); - let mut committed_input_keys: BTreeMap> = BTreeMap::new(); - let mut referenced_names: BTreeMap> = BTreeMap::new(); - let mut surface_names: BTreeMap> = BTreeMap::new(); - for module in graph.keys() { - let (state, _) = state_of(&store, &states, &definition, module)?; - committed_input_keys.insert(module.clone(), state.input_key.clone()); - surface_names.insert( - module.clone(), - read_surface_items(&store, &state.surface_hash)? - .iter() - .filter_map(|item| item.path.rsplit("::").next().map(str::to_string)) - .collect(), - ); - referenced_names.insert(module.clone(), identifiers(&store, &state)?); + if entry.whole_file { + let present = inputs.staged_files.contains_key(&file); + return Some(EndpointVerdict { + recorded: None, + whole: true, + transported: None, + reason: (!present).then_some("file-absent"), + detail: (!present).then(|| { + format!("the claim names the whole of {file:?}, which the staged bytes do not contain") + }), + observed_line_count: staged_len, + observed_surviving: None, + path: Some(file), + }); } - let current_input_keys: BTreeMap = artifact - .slices - .iter() - .map(|slice| (slice.module.clone(), slice.input_key.clone())) - .collect(); - let roots = declared_roots(&sources); - let report = lint( - &graph, - &LintInput { - roots, - committed_input_keys, - current_input_keys, - referenced_names, - surface_names, + let recorded = Range::new(entry.line_start, entry.line_end); + let (Some(before), Some(after)) = ( + inputs.committed_files.get(&file), + inputs.staged_files.get(&file), + ) else { + return Some(EndpointVerdict { + recorded: Some(recorded), + whole: false, + transported: None, + reason: Some("file-absent"), + detail: Some(format!( + "the claim names {file:?}, which is not present in both the committed output and the staged bytes, so nothing proves where its lines went" + )), + observed_line_count: staged_len, + observed_surviving: None, + path: Some(file), + }); + }; + let map = line_map(before, after); + Some(match transport_range(&map, recorded) { + Transported::Proven(proven) => EndpointVerdict { + recorded: Some(recorded), + whole: false, + transported: Some(proven), + reason: None, + detail: None, + observed_line_count: map.new_line_count(), + observed_surviving: None, + path: Some(file), }, - ); - let value = lint_json(&report); - Ok((graph, report, value)) + Transported::Unproven { + refusal, surviving, .. + } => EndpointVerdict { + recorded: Some(recorded), + whole: false, + transported: None, + reason: Some(refusal.as_str()), + detail: Some(refusal.detail().to_string()), + observed_line_count: map.new_line_count(), + observed_surviving: surviving, + path: Some(file), + }, + }) } -/// Cost over an explicit set: receipt object names in the store, receipt file -/// paths, usage-proxy session windows, or any mixture. -/// -/// A session window is priced here, lazily, at read time: a capture verb records -/// the window as a reference and never aggregates it, so the log lines are summed -/// only when a reader asks. A model the pinned table does not know stays -/// `unknown`, never zero. -pub fn cost( - workspace: &Path, - names: &[String], - files: &[String], - sessions: &[String], -) -> Result { - let store = disk::open(workspace); - let mut lines: Vec = Vec::new(); - for session in sessions { - lines.extend(crate::cost::lines_from_session( - &std::fs::read_to_string(workspace.join(crate::capture::USAGE_LOG_PATH)) - .unwrap_or_default(), - session, - )); - } - for name in names { - let body = get_json(&store, name, ObjectKind::Receipt)?; - lines.push(receipt_line(workspace, name, &body)?); - } - for file in files { - let path = Path::new(file); - let bytes = std::fs::read(path).map_err(|e| disk::io(path, &e))?; - let body: Value = serde_json::from_slice(&bytes).map_err(|e| { - error( - OutcomeCode::InvalidReceipt, - format!("{file}: invalid JSON: {e}"), - ) - })?; - let name = crate::canon::json_hash(ObjectKind::Receipt, &body); - lines.push(receipt_line(workspace, &name, &body)?); - } - Ok(cost_report(&lines)) +/// The structured facts an incomplete transport refuses with: every listed claim +/// beside its observed bound, and the flag that affirms them. +pub fn refusal_payload(module: &str, refusals: &[ClaimRefusal]) -> Value { + json!({ + "affirmationFlag": "--attribution", + "listedClaims": refusals.iter().map(ClaimRefusal::as_json).collect::>(), + "module": module, + }) } -/// One receipt's cost line: a reported total priced directly, a recorded session -/// window priced against the log the record names. -fn receipt_line(workspace: &Path, name: &str, body: &Value) -> Result { - let module = body["module"].as_str().unwrap_or_default(); - let reported = normalize_reported(&body["reported"]); - match crate::cost::window_from_reported(&reported) { - Some(window) => { - let log = window_log(workspace, &window.log_path); - Ok(crate::cost::line_from_window( - name, - module, - &window, - log.as_deref(), - )) - } - None => line_from_reported(name, module, &reported), - } +/// The one sentence an incomplete transport says beside its listing. +pub fn refusal_detail(module: &str, refusals: &[ClaimRefusal]) -> String { + format!( + "{} recorded attribution claim(s) of module {module:?} could not be transported through the diff, so each is listed with both endpoints' verdicts and the bound each was checked against; affirm only the sides named in affirmationNeeded, with --attribution", + refusals.len() + ) } -/// The bytes of a log a record names, read at the moment the reader asks. -/// -/// The path is a record's own workspace-relative path, so a path that escapes -/// the workspace or is absolute is not a log this reader will open: it reads as -/// absent, which the report already knows how to say. -fn window_log(workspace: &Path, log_path: &str) -> Option { - let candidate = Path::new(log_path); - if candidate.is_absolute() || log_path.split('/').any(|part| part == "..") { - return None; +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_untouched_text_transports_every_range_verbatim() { + let text = "a\nb\nc\nd\n"; + let map = line_map(text, text); + assert_eq!( + transport_range(&map, Range::new(2, 3)), + Transported::Proven(Range::new(2, 3)) + ); } - std::fs::read_to_string(workspace.join(candidate)).ok() -} -/// Sweep every object no named head reaches, and the staged records beside them. -/// -/// The sweep set is computed outside the lock; only the deletions run under it, -/// and a writer publishing concurrently is never blocked because publication -/// takes no lock at all. -/// -/// Staged records belong in this report because they are exactly what the -/// git-state law calls disposable litter: never promoted to history, re-creatable -/// by re-running `add`, and reachable from no head. `--apply` sweeps them, so -/// nobody ever hand-edits `.napl` to be rid of one. -pub fn gc(workspace: &Path, apply: bool) -> Result { - let store = disk::init(workspace)?; - let all: BTreeSet = store.names()?.into_iter().collect(); - let reachable = match store.head()? { - Some(head) => reachable_from_head(&store, &head)?, - None => BTreeSet::new(), - }; - let swept = plan_sweep(&all, &reachable); - let staged: Vec = crate::staging::staged_records(workspace)? - .into_iter() - .map(|record| record.relative) - .collect(); - // The repack is the apply path's whole sweep: it packs the reachable closure - // and removes every loose file it enumerated, unreachable ones included, so - // packing the reachable and removing the rest is one operation. - let repacked = if apply { - disk::repack(&store, &reachable, "gc")? - } else { - disk::plan_repack(&store, &reachable)? - }; - if apply { - for name in &swept { - disk::remove_object(&store, name)?; - } - for record in &staged { - let dir = workspace.join(record); - match std::fs::remove_dir_all(&dir) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(disk::io(&dir, &e)), - } - } + #[test] + fn an_insertion_above_a_range_shifts_it_by_the_delta() { + let map = line_map("a\nb\nc\n", "x\ny\na\nb\nc\n"); + assert_eq!( + transport_range(&map, Range::new(2, 3)), + Transported::Proven(Range::new(4, 5)) + ); } - // Kept counts objects the store actually holds. The reachable set may name a - // hash no object backs — a `readSet` identity, a derivation key — and keeping - // a name that resolves to nothing is not keeping an object. - let mut payload = gc_json(&swept, all.len() - swept.len()); - payload["stagedRecords"] = json!(staged); - let mut repack = crate::maintenance::repack_json(&repacked); - repack["applied"] = json!(apply); - payload["repack"] = repack; - Ok(payload) -} -/// The exact command the owner runs to take the migrated object files out of -/// the working tree. It is theirs to run, not the toolchain's: removing them -/// from the index is an ordinary git commit and never a store mutation. -pub const STORE_MIGRATE_REMOVAL: &str = "git rm -r .napl/store/objects"; + #[test] + fn a_deletion_above_a_range_shifts_it_back() { + let map = line_map("a\nb\nc\nd\n", "c\nd\n"); + assert_eq!( + transport_range(&map, Range::new(3, 4)), + Transported::Proven(Range::new(1, 2)) + ); + } -/// `napl store migrate`: the one way `refs/heads/napl-store` comes to exist. -/// -/// It walks the tracked store's loose objects and every pack into git blobs, -/// re-hashing each envelope to the napl name it must keep, builds one fanout -/// tree and one deterministic commit over them, and creates the branch there. It -/// refuses outside a git repository and refuses when the branch already exists, -/// and it touches no tracked file: the payload names the object files as ready -/// to leave the working tree and the exact command that does it, because that -/// removal is the owner's ordinary git commit. -/// -/// After it, selection observes the branch and every later read and write goes -/// through the git provider with no flag set anywhere. -pub fn store_migrate(workspace: &Path) -> Result<(bool, Value)> { - let store = disk::open(workspace); - let Some(repository) = napl_store::git_store::enclosing_repository(workspace) else { - return Ok(( - false, - migrate_refusal( - "not-a-git-repository", - "this workspace is not inside a git repository, so there is no object database to migrate into; the tracked layout with its packfile mitigation stays the floor", - ), - )); - }; - let provider = napl_store::git_store::GitProvider::open(&repository, &store.root) - .map_err(disk::git_error)?; - if provider.tip().map_err(disk::git_error)?.is_some() { - return Ok(( - false, - migrate_refusal( - "already-migrated", - "refs/heads/napl-store already exists, and migration is a one-shot verb that never rewrites a store branch", - ), - )); + #[test] + fn a_rewritten_range_refuses_and_reports_what_survived() { + let map = line_map("a\nb\nc\nd\n", "a\nB\nC\nd\n"); + match transport_range(&map, Range::new(2, 3)) { + Transported::Unproven { + refusal, + surviving, + anchor, + } => { + assert_eq!(refusal, Refusal::RegionRewritten); + assert_eq!(surviving, None); + assert_eq!(anchor, Some(1)); + } + other => panic!("a rewritten range must not transport: {other:?}"), + } } - let tracked = tracked_object_files(&store.root)?; - let outcome = provider - .convert_tracked_store(&store.root, crate::version::CONTRACT_VERSION) - .map_err(disk::git_error)?; - - Ok(( - true, - json!({ - "branch": napl_store::git_store::STORE_BRANCH, - "commit": outcome.commit, - "contractVersion": crate::version::CONTRACT_VERSION, - "objects": outcome.objects, - "outcome": "migrated", - "readyToLeaveWorkingTree": { - "detail": "migration touched none of these: removing them from the index is your ordinary git commit, not a store mutation", - "files": tracked, - "removalCommand": STORE_MIGRATE_REMOVAL, - "root": format!("{}/objects", disk::STORE_DIR), - }, - "snapshot": outcome.snapshot, - }), - )) -} + #[test] + fn content_inserted_inside_a_range_refuses_rather_than_widening_it() { + let map = line_map("a\nb\nc\n", "a\nb\nNEW\nc\n"); + match transport_range(&map, Range::new(1, 3)) { + Transported::Unproven { refusal, .. } => assert_eq!(refusal, Refusal::RangeSplit), + other => panic!("a split range must not transport: {other:?}"), + } + } -/// How many files the tracked object layout holds under the store root: every -/// loose object file, every pack, and every pack index. -fn tracked_object_files(root: &Path) -> Result { - let mut count = 0; - let mut stack = vec![root.join("objects")]; - while let Some(directory) = stack.pop() { - let entries = match std::fs::read_dir(&directory) { - Ok(entries) => entries, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, - Err(e) => return Err(disk::io(&directory, &e)), - }; - for entry in entries { - let entry = entry.map_err(|e| disk::io(&directory, &e))?; - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else { - count += 1; + #[test] + fn a_range_past_the_end_of_the_recorded_content_refuses() { + let map = line_map("a\nb\n", "a\nb\n"); + match transport_range(&map, Range::new(2, 9)) { + Transported::Unproven { refusal, .. } => { + assert_eq!(refusal, Refusal::OutsideOldContent) } + other => panic!("an impossible range must not transport: {other:?}"), } } - Ok(count) -} - -/// A migration refusal: the reason named, nothing done, nothing written. -fn migrate_refusal(reason: &str, detail: &str) -> Value { - json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "detail": detail, - "outcome": "refused", - "reason": reason, - }) -} - -/// Verify envelope hashes, kind validity, causal parent links, and every pack's -/// name, index, and members. -pub fn fsck_store(workspace: &Path) -> Result<(bool, Value)> { - let store = disk::open(workspace); - let mut findings = fsck(&store)?; - findings.extend(disk::verify_packs(&store)?.into_iter().map(pack_finding)); - findings.sort(); - findings.dedup(); - Ok((findings.is_empty(), fsck_json(&findings))) -} -/// Translate one store-level pack finding into the compiler's fsck vocabulary. -fn pack_finding(finding: napl_store::store_io::PackFinding) -> crate::maintenance::FsckFinding { - use napl_store::store_io::PackFinding as Pack; - let (pack, detail) = match finding { - Pack::NameMismatch { pack } => { - let detail = "the pack's bytes do not hash to its name".to_string(); - (pack, detail) - } - Pack::IndexDisagrees { pack, detail } => (pack, detail), - Pack::MemberCorrupt { pack, object } => { - let detail = format!("packed object {object} does not verify against its name"); - (pack, detail) + /// A region the diff resolves as a deletion plus an addition is exactly the + /// ambiguity the transport declines: the old lines are gone, and whether the + /// new lines are the same claim reworded is not something a byte diff knows. + #[test] + fn a_region_read_as_a_delete_plus_an_add_refuses() { + let map = line_map("keep1\nblock\nkeep2\n", "keep1\nkeep2\nreworded\n"); + match transport_range(&map, Range::new(2, 2)) { + Transported::Unproven { refusal, .. } => assert_eq!(refusal, Refusal::RegionRewritten), + other => panic!("a delete plus add must not transport: {other:?}"), } - }; - crate::maintenance::FsckFinding::PackDamaged { pack, detail } -} - -/// Repack the store when its loose-object count crosses the compiled-in bound. -/// -/// Called after a successful commit's critical section and outside every lock: -/// the pointer has already moved, so a repack here never lengthens the bounded -/// section and never blocks a writer. It packs the same reachable closure `gc -/// --apply` packs, so the pack it writes is byte-identical to gc's. -pub fn repack_if_loose(store: &Store) -> Result> { - if disk::loose_object_count(store)? <= crate::maintenance::LOOSE_OBJECT_REPACK_THRESHOLD { - return Ok(None); - } - let reachable = match store.head()? { - Some(head) => reachable_from_head(store, &head)?, - None => return Ok(None), - }; - let outcome = disk::repack(store, &reachable, "gc")?; - if outcome.pack.is_none() { - return Ok(None); } - Ok(Some(crate::maintenance::repack_json(&outcome))) -} -/// Derive a repair plan for a target's committed outputs, and optionally apply -/// it. -pub fn restore(workspace: &Path, target: &str, apply: bool) -> Result { - let store = disk::open(workspace); - let expected: Vec = committed_manifest(&store, target)? - .into_iter() - .map(|(path, hash)| FileFact { path, hash }) - .collect(); - let roots: BTreeSet = expected - .iter() - .filter_map(|fact| fact.path.split('/').next().map(str::to_string)) - .collect(); - let observed = observed_facts(workspace, &roots)?; - let plan = plan_restore(&expected, &observed); - if apply { - if let RestorePlan::Apply(steps) = &plan { - apply_restore(&store, workspace, steps)?; - } + /// A block the diff still proves surviving as one run transports, even though + /// its neighbours moved: that is a fact the ancestry carries, not a guess. + #[test] + fn a_block_the_diff_proves_surviving_transports_to_where_it_survived() { + let map = line_map("head\nx\ny\ntail\n", "head\nx\ny\n"); + assert_eq!( + transport_range(&map, Range::new(2, 3)), + Transported::Proven(Range::new(2, 3)) + ); } - Ok(restore_json(&plan)) } +napl-store transition v1 906 +{"baseStateKey":"cda23242a5fb8aa243b577c8d7fb4e666dd23757c9c1f8919f20af2224dadec3","contractVersion":3,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"0ac0f86a77570f04d575a508bdef86c69ee4fd71c2c2e4763f098ff7f8743ec9","installPaths":["crates/napl/src/document.rs","crates/napl/src/promptbuild.rs","crates/napl/src/query.rs"],"module":"query","newState":"986a298b6293326971aff7e849b8b51340ebe3941d1b79008a16b883883ecf40","readSet":["base-state:cda23242a5fb8aa243b577c8d7fb4e666dd23757c9c1f8919f20af2224dadec3","input-key:0ac0f86a77570f04d575a508bdef86c69ee4fd71c2c2e4763f098ff7f8743ec9","target-def:2ed3fe80bffabcc2e24f4abae5f3289d37d725496f0e890b12f14a15217c9dd6"],"receipt":"66205a79dee145b87125b71a266ceb068c582c34ff7f6a7b4a75197983ce28b1","surface":"d9032b03ee7b166d66159ff59d7e98ba35a3a66b35cbae983d43f0630747ad65","target":"rust","writeSet":["owned:crates/napl","module-state:rust/query"]} +napl-store event v1 158 +{"content":"abd9678be77834afc30adb367555a39cee0d590b67d041507a4655ae4d9cd55e","parents":["ab4ebac89ef6f5459c34b98a99f6e62f834e8b01d447bfe3a2349e0f3ae3ec37"]} +napl-store tree v1 252 +{"entries":[{"hash":"f581419a200dbec0190a3d8e3451537c8071adf85cb0c8d7b0d947cfcca529ca","kind":"file","mode":33188,"name":"Cargo.toml"},{"hash":"0bae24f716e10910ab80aa7b128b67c7c57a6df7cd2c92daf447a09bd7d2dc41","kind":"dir","mode":16384,"name":"src"}]} +napl-store transition v1 843 +{"baseStateKey":"f66e5812f0b1d528c46f9377b7f6ff2595f21ee248ff0c9e799672f01b1edabd","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"4cd98e72a32ea8f99b2d6795f9c3eb10e4d987a907db9e391040710ae7d93536","installPaths":["crates/napl/src/agent.rs"],"module":"agent","newState":"3960d70a07476e0ca5ad272606f66b22890a9d9169b4b411397290260c51e4a9","readSet":["base-state:f66e5812f0b1d528c46f9377b7f6ff2595f21ee248ff0c9e799672f01b1edabd","input-key:4cd98e72a32ea8f99b2d6795f9c3eb10e4d987a907db9e391040710ae7d93536","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"2c5e5d555ec16edcc0cdbe133b51c09629a8154a714ce75baba59f0f595cca38","surface":"ad1581ae209ba3feb19322408bfff544be7a29bc6a7db44c672b705f1afcd120","target":"rust","writeSet":["owned:crates/napl","module-state:rust/agent"]} +napl-store snapshot v1 895 +{"contractVersion":4,"event":"98b42ae47d4f2e1a604af5e2dd574838431f481106ddf2ffa3ca141314664325","states":[{"module":"agent","state":"651686af1f5c9f3aad425d1ca7f0de325e4dc362cb59d9a71dc7d75f3216972c","target":"rust"},{"module":"commands","state":"02051dd93f1be5de0dcfc96ef2d9c28c8bd05c00d65c9d9b3ffca1a3fa2cf893","target":"rust"},{"module":"git-store","state":"d261cf32efb85d15c5256b019fd404387167830c5495b8ce6350d77f7cb0dcdb","target":"rust"},{"module":"napl","state":"338c9b575a24883622bf3cb1e3276c24a26c31ef34a5097927420523727b6d26","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"26e26696fa18accc362da9494d522e4ab3f3e70babc7ad776335ce8125582ef8","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} +napl-store event v1 158 +{"content":"4849348275e3c2a55836d85f5b720c855b8acd6db6a2aad4f418cf407419621d","parents":["6c76641a8b37f5f214bb69a79f10e7e2001600f0e6b2a7c6b10341322b076738"]} +napl-store blob v1 15770 +# query: the canonical document, napl q, and napl prompt -/// Attach later provenance to a recorded transition without touching semantic -/// state. -/// The refusal a `--to` that is not an object name earns. -/// -/// A working path is the mistake a reader makes here, because every other verb -/// takes one, so the refusal does not merely say no: it names the two commands -/// that produce and inspect an object name and shows the one line that gets one. -fn not_an_object_name(given: &str) -> crate::error::CompilerError { - error( - OutcomeCode::Usage, - format!( - "--to expects the object name of a recorded transition, and {given:?} is not one. \ - Object names come from `napl log`, and `napl show ` inspects one, \ - for example: napl log | napl q '.entries[-1].transition' -" - ), - ) -} +The language answers every question through one query surface and builds +agent context through one prompt builder. This module owns both commands +and the document they share. Everything here is a pure read: no store +writes, no locks held, no clock, no randomness, no network (P1). Reads +serve committed records, with two stated exceptions: reference staleness +is computed at read time from the working tree as well as the committed +bytes, so a hand edit to either end of a reference reads stale without +anything being stored; and the working-tree read mode below serves the +pending prompt when asked to explicitly. The same committed state plus +the same working tree produces the same bytes. This module owns `src/query.rs` (taken over +from the commands module), plus the document builder and prompt builder +sources it introduces. -pub fn add_note(workspace: &Path, transition: &str, note: &str) -> Result { - let store = disk::init(workspace)?; - // A path is never an object name, and neither is anything else outside the - // store's name alphabet: refuse before opening anything, so the reader is - // told how to get a name rather than handed a missing-object error. - if !is_object_name(transition) || !has_object(&store, transition) { - return Err(not_an_object_name(transition)); - } - get_json(&store, transition, ObjectKind::Transition)?; - let body = json!({ "note": note, "transition": transition }); - let payload = crate::canon::canonical_bytes(&body); - let name = disk::publish(&store, ObjectKind::Blob, &payload, transition)?; +## The canonical document - let index = store.root.join("notes").join(format!("{transition}.json")); - let mut names: BTreeSet = match std::fs::read(&index) { - Ok(bytes) => serde_json::from_slice::>(&bytes) - .unwrap_or_default() - .into_iter() - .collect(), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(), - Err(e) => return Err(disk::io(&index, &e)), - }; - names.insert(name.clone()); - let listed: Vec = names.iter().map(|n| json!(n)).collect(); - crate::workspace::write_file(&index, &crate::canon::canonical_bytes(&Value::Array(listed)))?; - Ok(json!({ - "note": name, - "outcome": "success", - "semanticStatePublished": false, - "transition": transition, - })) -} +One JSON value, `docVersion 1`, built at query time from committed state: +the store head, module states, attribution records, staged-evidence usage +inside receipts, recorded surfaces, and the committed prompt bytes. Top +level: `docVersion`, `head` (the snapshot hash), `modules`, `history`, +`state`. -/// The events reachable from a head, ordered oldest first by causal depth. -pub fn causal_order(store: &Store, head: &str) -> Result> { - let head_event = snapshot_event(store, head)?; - let events = walk_events(store, &head_event, None)?; - let mut depths: BTreeMap = BTreeMap::new(); - for event in &events { - depth_of(store, event, &mut depths)?; - } - let mut ordered = events; - ordered.sort_by(|a, b| (depths[a], a).cmp(&(depths[b], b))); - Ok(ordered) -} +Each module carries `name`, `promptPath`, `deps`, `targets`, +`surfaceHash`, `pending`, `attribution` (an object whose `status` is +`fresh`, `stale`, or `absent`), `sections`, `frontmatterCases` (cases +declared in frontmatter `tests:`, which have no body position), and +`files` (owned output paths). Sections are the module's attribution +entries, and every section is a SELF-CONTAINED REF: it carries `head`, +`module`, `promptPath`, `lines` (body-normalized, 1-based, inclusive), +`note`, `law` (true exactly when the entry owns no file), `files` (each +`{path, lines, head}`), `direction` (`prompt_to_code` or +`code_to_prompt`, per the reference contract in the napl module; entries +recorded before directions read `prompt_to_code`), `provenance` (an +object with `model`, `timestamp`, `toolVersion`, and `usage`, each +absent field served as `unavailable`, never invented), `referenceStatus` +(`fresh` or `stale`), `staleEndpoints` (empty when fresh, `code` and +`prompt` when stale), and `cases` — the `::: test` directive cases +whose block range intersects the section's range, each +`{name, given, expect, lines}` with values in canonical JSON. A directive +case intersecting no entry lands in a synthetic section with empty +`files`. When a module's attribution is stale, sections still carry +their cases (cases derive from prompt bytes, not from attribution) but +each section carries `associationStale: true` and the module's status +says `stale`: the section-to-file pairing cannot be trusted, the case +data can. -fn depth_of(store: &Store, event: &str, depths: &mut BTreeMap) -> Result { - if let Some(known) = depths.get(event) { - return Ok(*known); - } - let value = get_json(store, event, ObjectKind::Event)?; - let mut depth = 0usize; - for parent in value["parents"].as_array().cloned().unwrap_or_default() { - if let Some(parent) = parent.as_str() { - depth = depth.max(depth_of(store, parent, depths)? + 1); - } - } - depths.insert(event.to_string(), depth); - Ok(depth) -} +History entries carry the transition hash, module, target, outcome, and +`usage` — `{model, input, output, cacheRead}` from the staged record's +validated usage evidence, or null when none was supplied; absent +telemetry is null, never zero. -pub fn committed_state(store: &Store, target: &str, module: &str) -> Result> { - let Some(head) = store.head()? else { - return Ok(None); - }; - let states = snapshot_states(store, &head)?; - match states.get(&(target.to_string(), module.to_string())) { - Some(hash) => Ok(Some(read_module_state(store, hash)?)), - None => Ok(None), - } -} +Canonical serialization throughout: sorted keys, total ordering for every +array (modules by name; sections by start then end line; files and cases +by path and name; history by transition sequence, transition hash as +tiebreaker), no absolute paths anywhere (`promptPath` and file paths are +workspace-relative), one trailing newline. The same committed state +produces the same document bytes on any machine. -/// The materialized attribution view a module committed last, if any. -/// -/// The bytes are the record blob, which is the view itself: an internal -/// re-commit reads them to carry the mapping forward rather than fabricating a -/// new one. -pub fn committed_attribution_yaml( - store: &Store, - target: &str, - module: &str, -) -> Result>> { - let Some(state) = committed_state(store, target, module)? else { - return Ok(None); - }; - match state.attribution { - Some(attribution) => Ok(Some(get_blob(store, &attribution.record)?)), - None => Ok(None), - } -} +::: test +name: q with no arguments emits the canonical document byte-stably +given: { workspace: fixture_two_modules, runs: 2 } +expect: { bytes_equal: true, top_level_keys: [docVersion, head, history, modules, state] } +::: -/// The committed `path -> blob` manifest of every module under a target. -pub fn committed_manifest(store: &Store, target: &str) -> Result> { - let Some(head) = store.head()? else { - return Ok(BTreeMap::new()); - }; - let mut out = BTreeMap::new(); - for ((state_target, _), hash) in snapshot_states(store, &head)? { - if state_target != target { - continue; - } - let state = read_module_state(store, &hash)?; - for (path, (blob, _)) in flatten_tree(store, &state.output_tree)? { - out.insert(path, blob); - } - } - Ok(out) +::: test +name: the document contains no absolute paths +given: { workspace: fixture_two_modules } +expect: { absolute_paths: 0 } +::: + +::: test +name: directive cases appear inside their owning section +given: { workspace: fixture_with_directive_case, attribution: fresh } +expect: { section_cases: 1, case_fields: [expect, given, lines, name] } +::: + +::: test +name: stale attribution keeps cases and marks the association stale +given: { workspace: fixture_with_directive_case, attribution: stale } +expect: { section_cases: 1, association_stale: true, module_status: stale } +::: + +::: test +name: section refs are self-contained +given: { workspace: fixture_two_modules } +expect: { section_fields_present: [direction, head, lines, module, promptPath, provenance, referenceStatus, staleEndpoints] } +::: + +::: test +name: usage from staged evidence appears in history entries +given: { workspace: fixture_two_modules, commit_with_usage: true } +expect: { history_usage_fields: [cacheRead, input, model, output], absent_usage: null } +::: + +## napl q + +`napl q` is shaped exactly like jq and IS jq: expressions are evaluated +by an embedded jq engine (the jaq crate, version pinned by the lockfile, +upgraded only through a normal committed change). Impure builtins are +rejected at expression compile time (`env`, `$ENV`, `now`, `input`, +`inputs`, `debug`, `stderr`, `input_line_number`, and anything else doing +I/O or randomness), so an expression is a pure function of its input. + +Argv grammar, exactly four forms; more than one input is a usage error; +`--` is accepted before a path starting with a dash; there is +deliberately no shorthand for identity over a file or stdin (write `.`): + + napl q identity over the canonical document + napl q expression over the canonical document + napl q - expression over stdin + napl q expression over a JSON file + +Input may be a single JSON value or a stream of values (JSONL); the +expression applies per value exactly as jq does. Each result value is +canonically serialized, one per line. An empty result stream is exit 0 +with no output. Errors are canonical JSON on stderr with pinned exit +codes: 2 usage, 3 expression parse or refused builtin, 4 input parse, 5 +runtime or type error. Evaluation carries a compiled-in output ceiling of +64 MiB, enforced functionally and surfaced as `resource-limit` with its +existing exit code; recursion depth is bounded by the embedded engine's +own evaluation stack rather than a compiled-in constant, and an +expression exceeding it aborts rather than returning wrong data. Piping any napl command's +canonical JSON back through an expression works by construction: +`napl status | napl q '.pending' -`. + +::: test +name: q evaluates a jq expression over the document +given: { workspace: fixture_two_modules, expr: ".modules[].name" } +expect: { output_lines: [consumer, leaf] } +::: + +::: test +name: q evaluates an expression over stdin when the input is a dash +given: { stdin: "{\"pending\":[\"m\"]}", expr: ".pending", input: "-" } +expect: { output: "[\"m\"]" } +::: + +::: test +name: q rejects an invalid expression without touching the store +given: { workspace: fixture_two_modules, expr: ".[unclosed" } +expect: { exit_code: 3, store_bytes_unchanged: true } +::: + +::: test +name: q refuses impure builtins +given: { workspace: fixture_two_modules, expr: "now" } +expect: { exit_code: 3, error_names_builtin: true } +::: + +## napl prompt + +`napl prompt` (alias `napl p`) is the prompt builder: it reads refs from +stdin — the objects a `napl q` stage selected — and emits markdown built +from committed state at the refs' snapshot, never current head, and +never the working tree except under the explicit `--working-tree` mode +defined in the working-tree reads section. Stdin is typed-ref JSONL: one JSON object per line +carrying `head`, `lines`, and exactly one of `path` or `promptPath`; +unknown extra fields are ignored. A line that is not an object, carries +both path kinds or neither, or has a non-integer, zero, reversed, or +out-of-range line range is an error naming the input line number, never a +silent skip. Empty stdin is an error saying nothing was selected. Refs +naming more than one head in a single invocation error. Duplicate refs +(same head, path, range) emit once; input order is otherwise preserved. + +Materialization reads the snapshot's content-addressed blobs; committed +prompts and committed module outputs are the only eligible paths. A +`{path, lines}` ref resolves to its owning module through the snapshot's +output manifests — owned globs are disjoint by commit invariant, so +ownership is unique, and a discovered overlap is a store-integrity +failure, not a guess. A prompt ref yields specification prose; a file ref +yields code: one resolver, both directions. Every chunk gets a provenance +header naming `path:start-end` plus the module and note when known. +Cases render after their section's prose as canonical JSON code blocks +inside dynamically sized fences, immune to backticks in values. + +Law closure by default: the `law: true` sections of every module a ref +touched are appended once, deduplicated, ordered by module then range, +under one trailing header; `--no-laws` opts out. Refs into a module whose +attribution is stale refuse by default with that module's fix directive; +`--allow-stale` proceeds and stamps `stale` into the affected provenance +headers. Unresolvable refs (absent path, unavailable snapshot) error +naming the ref's input line. + +::: test +name: prompt materializes a prompt ref with a provenance header +given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf } +expect: { output_contains_prose: true, header_format: "path:start-end" } +::: + +::: test +name: prompt materializes a generated-file ref symmetrically +given: { workspace: fixture_two_modules, stdin: file_ref_for_leaf_output } +expect: { output_contains_code: true } +::: + +::: test +name: prompt appends law sections by default and omits them under no-laws +given: { workspace: fixture_with_law_section, stdin: section_ref_non_law } +expect: { laws_appended_default: true, laws_with_flag: false } +::: + +::: test +name: prompt refuses refs into a stale module by default +given: { workspace: fixture_with_directive_case, attribution: stale, stdin: section_ref } +expect: { exit_nonzero: true, directive_present: true, allow_stale_overrides: true } +::: + +::: test +name: prompt errors on a malformed stdin line naming its line number +given: { stdin_lines: [valid_ref, "not json"] } +expect: { exit_nonzero: true, error_names_line: 2 } +::: + +::: test +name: prompt errors on empty stdin +given: { stdin: "" } +expect: { exit_nonzero: true, error: nothing_selected } +::: + +::: test +name: prompt deduplicates identical refs and preserves input order +given: { stdin_lines: [ref_a, ref_b, ref_a] } +expect: { chunks: 2, order: [ref_a, ref_b] } +::: + +::: test +name: "prompt resolves refs against their snapshot, not current head" +given: { workspace: fixture_two_modules, ref_head: previous_snapshot, edits_after: leaf_recommitted } +expect: { materialized_bytes: previous_snapshot_bytes } +::: + +## Working-tree reads, byte-exact retrieval, and pre-add validation + +Three reads exist so that a tool amending a prompt can read exactly what +it is amending, retrieve the exact bytes it is about to splice, and +validate the result before anything stages, without a single raw file +read. All three are pure reads: no store writes, no locks, no clock, no +staging. Each is a pure function of the committed state plus the on-disk +prompt bytes it explicitly names, and byte-stable across runs over the +same inputs. + +`napl q --working-tree` builds the canonical document with every +prompt-derived fact enumerated from the on-disk prompt files instead of +the committed blobs: frontmatter, sections, `::: test` directive cases, +and body line ranges all reflect the pending working-tree prompt, +including sections and cases that exist only in the working tree. +Store-derived facts are unchanged and still committed: `head`, module +states, history, receipts. The top level carries `workingTree: true`, +and each section carries `workingTree: true` in place of a committed +`head` value, so a working-tree ref can never be mistaken for a +committed one and no hash is ever invented for uncommitted bytes. +Section-to-file association under a stale attribution behaves exactly as +today: sections carry their cases, `associationStale: true` marks the +pairing untrustworthy, and nothing pretends freshness. Reference +staleness fields serve exactly as in the committed document. + +`napl prompt --working-tree` resolves `promptPath` refs against the +on-disk prompt file instead of a committed blob. A working-tree ref +carries `workingTree: true` and no `head`; a ref carrying both, or a mix +of working-tree and committed refs in one invocation, is a usage error +naming the input line. Everything else about materialization holds: +provenance headers, law closure from committed state, case rendering, +error shapes. + +`napl prompt --bytes` is byte-exact retrieval, the inverse of the edit a +tool is about to make: the ref's line range materializes as the exact +source bytes of that range, no reflow, no case rendering, no law +closure, no provenance header, one trailing newline. It accepts exactly +one ref; more than one is a usage error. It composes with +`--working-tree` so a splice can be computed against the pending file. +The bytes returned are byte-identical to the named lines of the source, +so retrieve-then-splice is deterministic by construction. + +`napl check ` is dry validation of an on-disk prompt file +before `add`: frontmatter shape and module-name rules per the napl +contract, body normalization, directive grammar for every `::: test` +block including canonical JSON in `given` and `expect`, and case-name +uniqueness. Findings are canonical JSON on stdout, each naming the file, +the line, and what failed. Exit codes are pinned: 0 for a valid prompt, +1 when findings exist, 2 for usage including an unreadable path. The +store is never touched and nothing stages. `check` passing means the +prompt parses under the same reader `add` uses; it does not predict +`add` outcomes that depend on candidates, attribution, or placement, and +its wording never implies otherwise. + +::: test +name: q working-tree enumerates a section that exists only in the working tree +given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } +expect: { new_section_enumerated: true, top_level_working_tree: true, committed_document_unchanged: true } +::: + +::: test +name: working-tree sections carry no committed head and mark themselves +given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } +expect: { section_working_tree: true, section_head_absent: true } +::: + +::: test +name: the working-tree document is byte-stable across runs +given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, runs: 2 } +expect: { bytes_equal: true } +::: + +::: test +name: prompt bytes round-trips the exact source bytes of a range +given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf, flags: [--bytes] } +expect: { bytes_equal_raw_slice: true, reflow: false, headers: false } +::: + +::: test +name: prompt bytes refuses more than one ref as a usage error +given: { stdin_lines: [ref_a, ref_b], flags: [--bytes] } +expect: { exit_code: 2, error_names_line: 2 } +::: + +::: test +name: a ref carrying both a head and working-tree is a usage error naming its line +given: { stdin_lines: [ref_with_head_and_working_tree], flags: [--working-tree] } +expect: { exit_nonzero: true, error_names_line: 1 } +::: + +::: test +name: check passes a valid prompt with exit zero and no findings +given: { workspace: fixture_two_modules, prompt: leaf_prompt_valid } +expect: { exit_code: 0, findings: 0, store_bytes_unchanged: true } +::: + +::: test +name: check reports a malformed directive with a finding naming its line +given: { workspace: fixture_two_modules, prompt: leaf_prompt_broken_directive } +expect: { exit_code: 1, finding_names_line: true, store_bytes_unchanged: true } +::: +napl-store blob v1 7885 +//! Lint diagnosis: proposals with cascade previews, never edits. +//! +//! Two of the three analyses are diagnosis only. The third — an unreferenced +//! declared dependency — is the one class of finding whose repair is complete, +//! deterministic, and mapping-safe, so it is the only one `--fix` may apply, and +//! even then only through the guarded commit path. +//! +//! The unreachable-module analysis refuses to run without an explicit `roots` +//! declaration. Guessing the roots would make "this module is dead" a statement +//! about the guess rather than about the workspace. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{json, Value}; + +use crate::graph::{dependents_of, reachable_from, Graph}; + +/// Whether the unreachable-module analysis ran. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Analysis { + Performed, + /// No `roots:` declaration exists, so the analysis is refused. + Refused, } -/// Whether a token is shaped like a store object name at all: the lowercase hex -/// of a SHA-256, and nothing else. A workspace path fails on the first separator. -fn is_object_name(given: &str) -> bool { - given.len() == 64 && given.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +/// One lint finding. Every finding is a proposal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Finding { + /// A module no declared root reaches. + UnreachableModule { + module: String, + /// What else would fall out of the graph if this module were removed. + cascade: Vec, + }, + /// A recorded surface entry no dependent names. + UnconsumedSurfaceEntry { module: String, path: String }, + /// A module whose current `inputKey` differs from its committed one. + StaleModule { module: String }, + /// A declared dependency whose surface the module never names. + UnusedDependency { module: String, dep: String }, } -/// Every path any committed module owns, across every target: the set that -/// decides whether a path is tracked at all. -fn committed_manifest_all_targets(store: &Store) -> Result> { - let mut out: BTreeSet = BTreeSet::new(); - let Some(head) = store.head()? else { - return Ok(out); - }; - for (_, hash) in snapshot_states(store, &head)? { - let state = read_module_state(store, &hash)?; - for (path, _) in flatten_tree(store, &state.output_tree)? { - out.insert(path); - } - } - Ok(out) +/// The result of a lint pass. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LintReport { + pub unreachable_analysis: Analysis, + pub findings: Vec, } -fn committed_text(store: &Store, target: &str) -> Result> { - let mut out = BTreeMap::new(); - for (path, blob) in committed_manifest(store, target)? { - let bytes = get_blob(store, &blob)?; - out.insert(path, String::from_utf8_lossy(&bytes).into_owned()); - } - Ok(out) +/// Everything the lint pass reads, all of it supplied by the caller. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LintInput { + /// The declared reachability roots, `None` when no source declares any. + pub roots: Option>, + /// Each module's committed `inputKey`, absent when it has never committed. + pub committed_input_keys: BTreeMap>, + /// Each module's `inputKey` as recomputed now. + pub current_input_keys: BTreeMap, + /// The identifiers each module's committed output names. + pub referenced_names: BTreeMap>, + /// The leaf names each module's recorded surface exposes. + pub surface_names: BTreeMap>, } -/// Every identifier a module's committed output names. -/// -/// This is a token scan, not name resolution: lint says a surface entry looks -/// unconsumed, and says so as a proposal, precisely because it cannot prove it. -fn identifiers(store: &Store, state: &ModuleState) -> Result> { - let mut out: BTreeSet = BTreeSet::new(); - if !has_object(store, &state.output_tree) { - // A module that has never committed names nothing yet. - return Ok(out); - } - for (_, (blob, _)) in flatten_tree(store, &state.output_tree)? { - let bytes = get_blob(store, &blob)?; - let text = String::from_utf8_lossy(&bytes).into_owned(); - for token in text.split(|c: char| !(c.is_alphanumeric() || c == '_')) { - if !token.is_empty() { - out.insert(token.to_string()); +/// Run every analysis over a graph. +pub fn lint(graph: &Graph, input: &LintInput) -> LintReport { + let mut findings: Vec = Vec::new(); + + let analysis = match &input.roots { + None => Analysis::Refused, + Some(roots) => { + let live = reachable_from(graph, roots); + for module in graph.keys() { + if !live.contains(module) { + findings.push(Finding::UnreachableModule { + module: module.clone(), + cascade: cascade_preview(graph, module, &live), + }); + } } + Analysis::Performed } - } - Ok(out) -} + }; -fn declared_roots(sources: &[crate::source::SourceModule]) -> Option> { - let mut declared: Option> = None; - for source in sources { - if let Some(roots) = &source.roots { - declared - .get_or_insert_with(BTreeSet::new) - .extend(roots.iter().cloned()); + for (module, current) in &input.current_input_keys { + let committed = input + .committed_input_keys + .get(module) + .and_then(Option::as_deref); + if committed != Some(current.as_str()) { + findings.push(Finding::StaleModule { + module: module.clone(), + }); } } - declared.map(|set| set.into_iter().collect()) -} -fn observed_facts(workspace: &Path, roots: &BTreeSet) -> Result> { - let mut out: Vec = Vec::new(); - for root in roots { - let dir = workspace.join(root); - if !dir.is_dir() { - continue; - } - for entry in crate::workspace::scan_candidate(&dir)? { - if entry.kind != crate::candidate::EntryKind::File { - continue; + for (module, declared) in graph { + let names = input.referenced_names.get(module).cloned().unwrap_or_default(); + for dep in &declared.deps { + let exposed = input.surface_names.get(dep).cloned().unwrap_or_default(); + // A dependency with an empty surface is not evidence of disuse: there + // is nothing it could have exposed to be named. + if !exposed.is_empty() && exposed.is_disjoint(&names) { + findings.push(Finding::UnusedDependency { + module: module.clone(), + dep: dep.clone(), + }); } - out.push(FileFact { - path: crate::paths::join_root(root, &entry.path), - hash: napl_store::store_hash::object_name(ObjectKind::Blob, &entry.bytes), - }); } } - out.sort(); - Ok(out) -} -fn apply_restore( - store: &Store, - workspace: &Path, - steps: &[crate::maintenance::RestoreStep], -) -> Result<()> { - use crate::maintenance::RestoreStep; - for step in steps { - match step { - RestoreStep::Move { from, to } => { - let target = workspace.join(to); - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent).map_err(|e| disk::io(parent, &e))?; - } - std::fs::rename(workspace.join(from), &target) - .map_err(|e| disk::io(&target, &e))?; - } - RestoreStep::Write { path, hash } => { - let bytes = get_blob(store, hash)?; - crate::workspace::write_file(&workspace.join(path), &bytes)?; - } - RestoreStep::Remove { path } => { - let target = workspace.join(path); - match std::fs::remove_file(&target) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(disk::io(&target, &e)), - } + for (module, exposed) in &input.surface_names { + let consumers = dependents_of(graph, module); + for name in exposed { + let consumed = consumers.iter().any(|consumer| { + input + .referenced_names + .get(consumer) + .is_some_and(|names| names.contains(name)) + }); + if !consumed { + findings.push(Finding::UnconsumedSurfaceEntry { + module: module.clone(), + path: name.clone(), + }); } } } - Ok(()) -} -napl-store transition v1 1847 -{"baseStateKey":"81b727d0fdbdcbf9412b7798b650076b811080051e9b4598ce3677811b30443a","contractVersion":2,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"d43c4332d50ffbb217ef58e9c78888d884c1391f0d623f70bf921f2618329422","installPaths":["crates/napl/src/attribution.rs","crates/napl/src/authoring.rs","crates/napl/src/blame.rs","crates/napl/src/candidate.rs","crates/napl/src/canon.rs","crates/napl/src/cli.rs","crates/napl/src/commandset.rs","crates/napl/src/commit.rs","crates/napl/src/compile.rs","crates/napl/src/cost.rs","crates/napl/src/decision.rs","crates/napl/src/directive.rs","crates/napl/src/disk.rs","crates/napl/src/error.rs","crates/napl/src/evidence.rs","crates/napl/src/graph.rs","crates/napl/src/lint.rs","crates/napl/src/lsp.rs","crates/napl/src/main.rs","crates/napl/src/maintenance.rs","crates/napl/src/mv.rs","crates/napl/src/objects.rs","crates/napl/src/paths.rs","crates/napl/src/porcelain.rs","crates/napl/src/receipt.rs","crates/napl/src/slice.rs","crates/napl/src/source.rs","crates/napl/src/staging.rs","crates/napl/src/store_view.rs","crates/napl/src/surface.rs","crates/napl/src/target.rs","crates/napl/src/textdiff.rs","crates/napl/src/transaction.rs","crates/napl/src/trees.rs","crates/napl/src/version.rs","crates/napl/src/workspace.rs"],"module":"napl","newState":"ede5d6f7c642bc1ae88d4737fc899bdcc20ef363ef468792804cd36f02350c41","readSet":["base-state:81b727d0fdbdcbf9412b7798b650076b811080051e9b4598ce3677811b30443a","input-key:d43c4332d50ffbb217ef58e9c78888d884c1391f0d623f70bf921f2618329422","target-def:eff9678e5301c88ac1e42eec62c56eb3860fca247f6278b6501639f14112a8d4"],"receipt":"eb982c4abd90f069ed07ba89b7fd809d2eff0d277a3d7240b2e781af90d33399","surface":"7ceb7e024b769e255bc6f1a9ada45327087cab66a296c78db60c95593a13fc95","target":"rust","writeSet":["owned:crates/napl","module-state:rust/napl"]} -napl-store module-state v1 559 -{"attribution":{"promptHash":"b0a8d8b52ccb7ad3e5b791e0791df5baefde058ae25183f7350aa0f575959abf","record":"ca4d0b8217a1164f7884e0195489d4139c836c6b32035adb683b27c3552548dc","stale":false},"contractVersion":4,"inputKey":"9206162a79eefbb9bbbad00daffa25e4dcbca3fb7db9b908b65fa2829a272f7e","module":"query","outputTree":"562e142c3a950b916ff1d8cebfdd0ab3a63fb61ebb42d766e6cd8215190b007f","sliceHash":"476bdcd17f363b2f8386f1f6076495802c94de78870b20ef3dc5dc26ae7e69d1","surfaceHash":"326826be3fd8aaa84d4869493b7e41f886a07bea8960df79b61c87f557f4cbd0","target":"rust"} -napl-store module-state v1 635 -{"attribution":{"promptHash":"d3b4f7308b35a3105c66bbbbec005cb91288ecd8476e45e4e46019e1dda2d3f6","record":"edd2ba44a2a0c8e6b1f6a328a617821ef6949cf4455658fe9c7b9f80935a0b97","stale":false},"contractVersion":4,"inputKey":"e5f189e86fd1da6b1aa3320cccaefd4a29cfd6bfdf4955a6a5b6a7ebdd5ef3fd","module":"serve","outputTree":"505d5f544144525d73def85bf0b3b9e16230ea634599f4d343080af78d359024","prompt":"cc1b8713650f5fa9aabd1f43b96cc999725f0552b614e90c3c25a8e9ada70d25","sliceHash":"19d34221d206f9ac8994717910bef591aa8bb1c07b88ae7478d4554716a5b018","surfaceHash":"0a742d0232e4b7000bd79ff29b39af501675254174d2c98163f8b7337cf7f0b7","target":"rust"} -napl-store tree v1 129 -{"entries":[{"hash":"4ce91446078aa8686a56c4c665b6d8fda58b19d6ec02721577e7fb70d95d284d","kind":"dir","mode":16384,"name":"src"}]} -napl-store module-state v1 639 -{"attribution":{"promptHash":"d819535b170965a2083000f6caa6b12f90884261a18117d948aafd98bdbe53db","record":"003ff2e611d823369c217a8516567db73b36275c47eb1567db203fc8271dab8c","stale":false},"contractVersion":4,"inputKey":"a81dbd9694703c7aa43996c1fd0ba96dc10741827cd8283deefbfa80995cb3ac","module":"git-store","outputTree":"10e6ecbb2cf94bd488d9b800284ed2e991d1f6ee5ab3156d8b8d6a711e784c87","prompt":"0ccfd4213b325ead9e7de194b86e32322a281d222b27a5af1f2cd57db8f49bde","sliceHash":"8206a86db94e415102f4e807e628b12ca3e0ba90b3d6525f47f7d7f4d054482a","surfaceHash":"de0377465c067b511f8b1f2727ceee7caf1ec3d1f896a6c31766ce05a72b5f1c","target":"rust"} -napl-store snapshot v1 781 -{"contractVersion":4,"event":"0488bd1ad406ad2da40f6d1b0c87ff1b7a1410275cca5d9a346ba242b76eb74d","states":[{"module":"agent","state":"5c85f9c974c1e41c9d2702163845b4cc5076dd694300a3122f98daac8e283aa2","target":"rust"},{"module":"commands","state":"87384fb9465ce6341cb179768f2b35a68b7a4f7ab1ff58a7e5cc220b82a22793","target":"rust"},{"module":"napl","state":"a376d26e5f8de69d8cb90f959edc75dcf7631ac57bf981ba4b0d699d1f5e2b90","target":"rust"},{"module":"napl_store","state":"7011890f658fb28bd29684802e5e53bb5d54d54d3c5a64cc926a56fae7ef944d","target":"rust"},{"module":"query","state":"a9968cca3d5e718781aa07dfc91a9e68495df5b964c5d5b50622038329c145cc","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} -napl-store blob v1 1816 -use serde_json::Value; -pub fn quote_string(text: &str) -> String { - let mut out = String::with_capacity(text.len() + 2); - out.push('"'); - for c in text.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), - c => out.push(c), - } + findings.sort_by_key(finding_sort_key); + LintReport { + unreachable_analysis: analysis, + findings, } - out.push('"'); - out } -pub fn to_canonical_line(value: &Value) -> String { - let mut out = String::new(); - write_value(&mut out, value); +/// The modules that would also leave the graph if `module` were removed. +fn cascade_preview(graph: &Graph, module: &str, live: &BTreeSet) -> Vec { + let mut out: Vec = dependents_of(graph, module) + .into_iter() + .filter(|name| !live.contains(name)) + .collect(); + out.sort(); out } -fn write_value(out: &mut String, value: &Value) { - match value { - Value::Null => out.push_str("null"), - Value::Bool(true) => out.push_str("true"), - Value::Bool(false) => out.push_str("false"), - Value::Number(number) => out.push_str(&number.to_string()), - Value::String(text) => out.push_str("e_string(text)), - Value::Array(items) => { - out.push('['); - for (index, item) in items.iter().enumerate() { - if index > 0 { - out.push(','); - } - write_value(out, item); - } - out.push(']'); - } - Value::Object(map) => { - let mut keys: Vec<&String> = map.keys().collect(); - keys.sort(); - out.push('{'); - for (index, key) in keys.iter().enumerate() { - if index > 0 { - out.push(','); - } - out.push_str("e_string(key)); - out.push(':'); - write_value(out, &map[key.as_str()]); - } - out.push('}'); - } +fn finding_sort_key(finding: &Finding) -> (u8, String, String) { + match finding { + Finding::UnreachableModule { module, .. } => (0, module.clone(), String::new()), + Finding::StaleModule { module } => (1, module.clone(), String::new()), + Finding::UnusedDependency { module, dep } => (2, module.clone(), dep.clone()), + Finding::UnconsumedSurfaceEntry { module, path } => (3, module.clone(), path.clone()), } } -napl-store blob v1 9053 -//! Target definitions: placement, owned outputs, surface grammar, projections. -//! -//! A target definition is itself a content-addressed object, and its hash enters -//! every `inputKey`. Changing where a module is placed, or what it is allowed to -//! own, therefore invalidates every outstanding receipt rather than silently -//! reinterpreting one. - -use napl_store::store_hash::ObjectKind; -use serde_json::{json, Value}; - -use crate::canon::json_hash; -use crate::error::{error, OutcomeCode, Result}; -use crate::paths::{is_under, join_root}; -use crate::source::SourceModule; -/// A built-in target definition. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TargetDef { - pub name: String, - pub entry_point_version: u32, - /// The user-owned manifest file name. The toolchain never owns, installs, - /// validates, or records it; it names the file only so a candidate carrying - /// one can be refused and so `status` can diagnose a missing one. - pub manifest_file: String, - pub source_dir: String, - pub source_extension: String, +/// True when a finding's repair is complete, deterministic, and mapping-safe. +/// +/// Only these reach `napl lint --fix`, and the fix rewrites the `deps:` value in +/// place: it edits one line and removes none. +pub fn is_auto_fixable(finding: &Finding) -> bool { + matches!(finding, Finding::UnusedDependency { .. }) } -/// The names of every built-in target, sorted. -pub fn builtin_target_names() -> Vec { - vec!["rust".to_string(), "typescript".to_string()] +/// The canonical JSON body of a lint report. +pub fn lint_json(report: &LintReport) -> Value { + let findings: Vec = report.findings.iter().map(finding_json).collect(); + json!({ + "contractVersion": crate::CONTRACT_VERSION, + "findings": findings, + "unreachableAnalysis": match report.unreachable_analysis { + Analysis::Performed => "performed", + Analysis::Refused => "refused", + }, + }) } -/// Look up a built-in target definition by name. -pub fn builtin_target(name: &str) -> Result { - match name { - "rust" => Ok(TargetDef { - name: "rust".into(), - entry_point_version: 1, - manifest_file: "Cargo.toml".into(), - source_dir: "src".into(), - source_extension: "rs".into(), +/// The canonical JSON body of one finding, including its cascade preview. +pub fn finding_json(finding: &Finding) -> Value { + match finding { + Finding::UnreachableModule { module, cascade } => json!({ + "autoFixable": false, + "cascade": cascade, + "kind": "unreachable-module", + "module": module, + "proposal": format!("remove the unreachable module {module:?}"), }), - "typescript" => Ok(TargetDef { - name: "typescript".into(), - entry_point_version: 1, - manifest_file: "package.json".into(), - source_dir: "src".into(), - source_extension: "ts".into(), + Finding::UnconsumedSurfaceEntry { module, path } => json!({ + "autoFixable": false, + "cascade": Vec::::new(), + "kind": "unconsumed-surface-entry", + "module": module, + "path": path, + "proposal": format!("no dependent names {path:?}; diagnosis only"), + }), + Finding::StaleModule { module } => json!({ + "autoFixable": false, + "cascade": Vec::::new(), + "kind": "stale-module", + "module": module, + "proposal": format!("regenerate {module:?}: its inputKey has moved"), + }), + Finding::UnusedDependency { module, dep } => json!({ + "autoFixable": true, + "cascade": Vec::::new(), + "dep": dep, + "kind": "unused-dependency", + "module": module, + "proposal": format!("drop the unreferenced dependency {dep:?} from {module:?}"), }), - other => Err(error( - OutcomeCode::UnsupportedVersion, - format!( - "unknown target {other:?}; known targets: {:?}", - builtin_target_names() - ), - )), - } -} - -/// The install root a module occupies: the crate directory its prompt sits in. -/// -/// The crate is the folder. A module's `.napl` prompt lives inside the crate -/// directory that receives its code, so the placement root is that prompt's -/// parent directory, workspace-relative. Several prompts sharing one directory -/// therefore share one crate. -/// -/// A prompt at the workspace root is legal and means the workspace root is the -/// crate directory, exactly as git treats the directory it was initialized in. -/// Its placement root is the empty workspace-relative path: every -/// workspace-relative path is inside it, and every join against it produces a -/// clean relative path rather than a fabricated leading separator. -pub fn placement_root(source: &SourceModule) -> String { - match source.path.rsplit_once('/') { - Some((dir, _)) => dir.to_string(), - None => String::new(), } } +napl-store blob v1 61409 +# NAPL: the deterministic compiler -/// The owned-output constraint for a crate: the source tree alone. -/// -/// The manifest is a user-owned glue seam, not a module output, so it is not in -/// the owned globs; a candidate carrying one is refused rather than installed. -pub fn owned_globs(_target: &TargetDef, root: &str) -> Vec { - vec![join_root(root, &format!("{}/**", _target.source_dir))] -} +This prompt describes the entire NAPL toolchain, rebuilt as one small Cargo +workspace of exactly three crates. It replaces the legacy 76-crate +implementation completely; git history holds the original. The governing +contract is docs/specs/2026-07-27-deterministic-compiler-plan.md rev 4; where +this prompt compresses it, the spec's definition wins. -/// True when `path` is an output a crate rooted at `root` may own. -/// -/// The crate's shared entry files are derived projections owned by no module, so -/// they are never ownable even though they sit under the source tree; the -/// manifest is a user-owned seam and is never ownable either. -pub fn is_owned_path(target: &TargetDef, root: &str, path: &str) -> bool { - if projection_owned_paths(target, root).iter().any(|p| p == path) { - return false; - } - let src = join_root(root, &target.source_dir); - is_under(path, &src) && path != src -} +## Layout: this module generates two crates on top of the ported store -/// True when `path` is a manifest the toolchain must never own, install, or -/// record: the workspace-root manifest or any crate's manifest. -pub fn is_manifest_path(target: &TargetDef, path: &str) -> bool { - path == target.manifest_file || path.ends_with(&format!("/{}", target.manifest_file)) -} +The workspace layout is fixed: the records directory `.napl/` lives at the +repository root; generated code installs under `crates/`; a module's +prompt file lives inside the crate directory that receives its code, and +the crate is the folder: every module whose `.napl` file sits in +`crates//` compiles into the `` crate as its own source +module, so one crate may be described by several prompt files +(`crates/napl/napl.napl` and `crates/napl/commands.napl` both build +`crates/napl/`). Module discovery finds prompts by walking the workspace +for `*.napl` files, and the workspace root is the nearest ancestor +directory containing `.napl`. A subtree that contains its own `.napl` +directory is a separate workspace: discovery never descends into it, the +way git never descends into a nested repository. Hidden directories and +`node_modules` are never descended into. -/// The crate-local paths that are derived projections rather than owned outputs. -/// -/// The crate root `lib.rs` is recomputed at commit from the union of the crate's -/// committed module file sets, so no module ever owns it and a candidate that -/// writes it is rejected. The crate manifest is deliberately not here: it stays -/// owned and shared, guarded by a byte-identity check rather than derivation. -pub fn projection_owned_paths(target: &TargetDef, root: &str) -> Vec { - match target.name.as_str() { - "rust" => vec![join_root(root, &format!("{}/lib.rs", target.source_dir))], - _ => Vec::new(), - } -} +The proven store already exists as a hand-ported crate at +`crates/napl-store/` (same envelope format `napl-store v1 +\n`, SHA-256 names, canonical JSON payloads, same object +kinds, same `.napl/store/` on-disk layout with HEAD compare-and-swap, +module-keyed locks, and dead-holder steal, ported code and tests from the +legacy store crates). Use its public API through a path dependency; do not +reimplement, wrap thinly, or fork any of its types or logic. The four new +object kinds the compiler needs (`transition`, `receipt`, `surface`, +`target-def`) already exist in it additively. -/// The stem a source file contributes to the crate root module list: its first -/// path segment under `src/`, with a `.rs` file suffix removed. A nested file -/// such as `store_blocks/mod.rs` contributes its top segment `store_blocks`. -fn module_list_stem(target: &TargetDef, root: &str, path: &str) -> Option { - let src_prefix = format!("{}/", join_root(root, &target.source_dir)); - let rest = path.strip_prefix(&src_prefix)?; - let ext = format!(".{}", target.source_extension); - if !rest.ends_with(&ext) { - return None; - } - let segment = rest.split('/').next().unwrap_or(rest); - let stem = segment.strip_suffix(&ext).unwrap_or(segment); - if stem.is_empty() || stem == "lib" || stem == "main" { - return None; - } - Some(stem.to_string()) -} +::: test +name: the ported store hashes objects exactly as the original store crates did +given: { vectors: ported_store_hash_test_corpus } +expect: { all_pass_unmodified: true } +::: -/// Recompute a crate root `lib.rs` from its committed module file sets. -/// -/// The body is a sorted `pub mod` list over every distinct source stem the -/// crate's modules install, followed, only when at least one module exposes a -/// command table, by a registry aggregator the dispatch loop iterates. It is a -/// pure function of the sorted inputs, so it derives byte-identically regardless -/// of the order the modules landed. -pub fn derive_lib_projection( - target: &TargetDef, - root: &str, - file_paths: &[String], - command_providers: &[String], -) -> (String, Vec) { - let mut stems: Vec = file_paths - .iter() - .filter_map(|path| module_list_stem(target, root, path)) - .collect(); - stems.sort(); - stems.dedup(); - let mut providers: Vec = command_providers.to_vec(); - providers.sort(); - providers.dedup(); +::: test +name: identical payload under two object kinds gets two different names +given: { payload: "X", kind_a: blob, kind_b: snapshot } +expect: { equal: false } +::: - let mut out = String::new(); - for stem in &stems { - out.push_str("pub mod "); - out.push_str(stem); - out.push_str(";\n"); - } - if !providers.is_empty() { - out.push('\n'); - out.push_str("pub fn registry() -> Vec {\n"); - out.push_str(" let mut specs: Vec = Vec::new();\n"); - for provider in &providers { - out.push_str(" specs.extend_from_slice(crate::"); - out.push_str(provider); - out.push_str("::commands());\n"); - } - out.push_str(" specs\n"); - out.push_str("}\n"); - } - ( - join_root(root, &format!("{}/lib.rs", target.source_dir)), - out.into_bytes(), - ) -} +This module's generated output is as many crates as the design needs, side +by side in the module's owned directory. Keep it a small, clearly separated +workspace (this replaces a 76-crate explosion; do not recreate one), with +`napl-store` staying its own untouched crate. The natural split, which you +may adjust if the design genuinely wants another boundary: -/// The surface grammar that makes extraction sound by construction. -pub fn surface_grammar(target: &TargetDef) -> Value { - match target.name.as_str() { - "rust" => json!({ - "globReexports": "rejected", - "language": "rust", - "macroGeneratedPublicItems": "rejected", - "namedReexports": "recorded as the unresolved written binding", - "parseErrors": "reject the candidate", - "publicItems": "named pub declarations at statically visible paths", - }), - _ => json!({ - "defaultExports": "rejected", - "inferredPublicTypes": "rejected", - "language": "typescript", - "parseErrors": "reject the candidate", - "publicItems": "explicit named exports only", - "starExports": "rejected", - }), - } -} +1. **`napl-core/`** (lib): the pure compiler. Parsing `.napl` files (YAML + frontmatter: module, deps, targets, tests; markdown body), discovery and + dependency graph, canonical path grammar, the identity vocabulary, + per-module artifact slices, wave planning, tree-sitter surface extraction + for Rust and TypeScript targets, the surface grammar, placement rules + and entry-file projection derivation, receipt verification logic, the commit + decision table as pure functions, blame line-ancestry replay, lint + diagnosis, cost calculation, evidence validation. No filesystem writes, + no locks, no clocks, no environment reads: everything enters as + arguments and leaves as values. +2. **`napl-cli/`** (bin `napl`): every command, each a thin composition of + napl-core derivations and napl-store reads plus the one guarded write + path. No private logic in the binary beyond argument parsing and + formatting. -/// The canonical JSON body of a target definition object. -pub fn target_def_json(target: &TargetDef) -> Value { - json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "entryPointVersion": target.entry_point_version, - "manifestFile": target.manifest_file, - "name": target.name, - "ownedGlobs": ["/{sourceDir}/**"], - "placement": "/", - "sourceDir": target.source_dir, - "sourceExtension": target.source_extension, - "surfaceGrammar": surface_grammar(target), - }) -} +The crates depend on `napl-store` by path, `serde_json`, `sha2`, +`tree-sitter`, `tree-sitter-rust`, `tree-sitter-typescript`, `clap` +(builder API only, driving the command line contract below), and nothing +that talks to a network, spawns a process, or reads clocks; those +declarations live in the user-owned manifests, not in any module's +output. Ensure `cargo test` passes for both crates and `napl-store` from +their workspace before finishing. Generated code is authored against its +final installed location, never a staging tree: acceptance runs at the +final location, so green certifies the bytes where they live. The +working-tree add forms are the operational spelling of this law: author +and verify in place, then stage from where the bytes live; assembling a +separate candidate directory is the exception, for staging bytes that +are not the working tree, never the routine. -/// The content identity of a target definition. -pub fn target_def_hash(target: &TargetDef) -> String { - json_hash(ObjectKind::TargetDef, &target_def_json(target)) -} +When several modules share one crate, the crate's shared entry files are +derived projections owned by no module: the crate root `lib.rs` module +list is recomputed at commit from the union of the crate's committed +module file sets, and the binary's command registry is recomputed from +the command tables the crate's modules expose under one fixed convention +(each module may expose `pub fn commands() -> &'static [CommandSpec]` +from its own files; the dispatch loop iterates the derived registry and +contains no per-command match arms of its own). A module's owned output +therefore never includes the shared entry files, two modules can never +claim the same path, and a commit that would write a projection-owned +path is rejected as invalid-candidate. -/// The placement facts that enter a module's `inputKey`. -pub fn placement_facts(target: &TargetDef, root: &str) -> Value { - json!({ - "ownedGlobs": owned_globs(target, root), - "root": root, - }) -} +A prompt at the workspace root is legal and means the workspace root is +the crate directory, exactly as git treats the directory it was +initialized in: the placement root is the empty workspace-relative path, +every workspace-relative path is inside it, and every join against it +produces clean relative paths, never a fabricated leading separator. A +cold workspace whose first prompt sits beside `.napl` therefore commits +its first module with no configuration; placement never needs declaring +because the prompt's location is the declaration. -napl-store snapshot v1 1005 -{"contractVersion":4,"event":"5577592b64ed66ed8bd6911d322d138c119734e148c0ff8106afa475413c5eed","states":[{"module":"agent","state":"d0d406dd4f30ea6840a808bcbc3ddeea8ec8cc10c84de12ab5d0cfeef7bb6696","target":"rust"},{"module":"commands","state":"ac7d92c561ec9fa19f0e28e1392bd90c1d95ca663b038424bd8ce760c6f4cd9e","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"42cee1b00043646e7e0ebed264397212c4ae136d44233aee64af6bebcab11d53","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"2f4def880281f987518186a75836278fd0c2286ca7cd8a63094c9467bc92f5d6","target":"rust"},{"module":"serve","state":"40ddb00b3be320909acf0ae1440e2bc5662e3002dcf77dc3367bcb215b1710fd","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} -napl-store tree v1 130 -{"entries":[{"hash":"8e0041629b58cb5f197dfc8bda886af7ba35a32f3cd7dd91396b139607cbf22b","kind":"dir","mode":16384,"name":"napl"}]} -napl-store snapshot v1 895 -{"contractVersion":4,"event":"f22e593a636a23cc73d78d2e0e687cd1c4f566ab623b6a7f49e279fe88286fe5","states":[{"module":"agent","state":"112df64307c695d6e80359ccbbcf9100579692216d90fd08be281e7c82c07574","target":"rust"},{"module":"commands","state":"9e7b90a24d35bb3d42c0476c6d37e380c905ed227e86d8cf44e8342e96265a33","target":"rust"},{"module":"git-store","state":"33a141441be88c6825e85a1495467d599a016bae61a678358f9d680a52017c99","target":"rust"},{"module":"napl","state":"34af262bdb9e210661534e5954107df8da9354c9e616f791c2dd3346242be742","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"f38b0d5a644c8edfdf8d88f5387838f0df2f638ef556620264f3a6900afd8c6b","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} -napl-store blob v1 12986 -//! `napl commit`: the one guarded write path. -//! -//! The shape of this file is the contract's pipeline, in order: ingest, verify, -//! extract, derive, stage — all outside any lock — and then one bounded critical -//! section that rechecks the footprint and swaps the pointer and the directory. -//! Nothing inside the lock hashes content, and the number of steps it applies is -//! fixed before it is taken. -//! -//! Every decision it makes is the pure compiler's. This file supplies bytes and -//! performs writes. +::: test +name: a root-level prompt places at the workspace root and its module commits +given: { workspace: fresh_init_root_prompt, candidate: valid_root_level } +expect: { placement_root: "", owned_globs_relative: true, outcome: installed } +::: -use std::collections::BTreeMap; -use std::path::Path; +::: test +name: status for a root-level module reports a workspace-root plan with clean globs +given: { workspace: fresh_init_root_prompt, flag: module_root } +expect: { plan_placement_root: "", owned_globs_leading_separator: false } +::: -use crate::candidate::{default_limits, ingest, Candidate}; -use crate::compile::{head_states, slice_at, state_of}; -use crate::decision::{decide, rejection, Arriving, Head}; -use crate::error::{error, error_payload, OutcomeCode, Result}; -use crate::receipt::{parse_receipt, Receipt}; -use crate::source::SourceModule; -use crate::store_view::{get_blob, ObjectSource}; -use crate::target::{builtin_target, placement_root, TargetDef}; -use crate::transaction::{ - accept, check_candidate_shape, derive_commit, intervening_write_set, success_payload, - BuiltAttribution, DerivedCommit, PendingObject, -}; -use crate::attribution; -use crate::objects::AttributionState; -use napl_store::store_txn_core::CommitMarker; -use serde_json::Value; +## The command line contract -use crate::disk::{self, Store, POINTER_LOCK}; -use crate::workspace::{apply_step, discover_sources, scan_candidate}; +Every command the registry serves declares its full grammar in its +`CommandSpec`: name, a one-line summary, the exact usage line, and the +closed table of flags it accepts, each with arity and meaning. The +dispatch loop owns two behaviors ahead of every command, so no command +can get them wrong. First, help: `napl help` lists every registered +command with its summary, `napl help ` and `--help` or `-h` +anywhere in a command's arguments print that command's usage line, flag +table, and summary, exit 0, and never invoke the command. Help is a pure +read: no store access, no filesystem write, no state change of any kind, +and this holds for every command including `init` and `watch`. Second, +strict parsing: an argument beginning with `--` that the command's flag +table does not name is a usage error, never ignored; the error goes to +stderr as canonical JSON naming the unknown flag, listing the command's +valid flags, and suggesting the nearest declared flag by edit distance +when one is close, and the command does not execute. Exit 2 is the +pinned usage-error code for the whole binary: unknown command, unknown +flag, missing required flag or positional, malformed flag value. A +usage error always restates the command's exact usage line, so the +error itself teaches the grammar. -/// The machine-readable result of a commit attempt. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommitReport { - pub outcome: OutcomeCode, - pub payload: Value, -} +The parser behind this contract is clap's builder API, constructed at +dispatch from the CommandSpec registry, never derive macros and never +per-command clap declarations: the registry stays the single source of +grammar and clap is its execution engine. clap's own renderings are +never emitted; every parse failure is caught and served as the +canonical JSON usage error above, byte-shaped by this contract alone. +`--flag value` and `--flag=value` parse identically; no command +declares short flags today, so clustering is outside the contract +until one does. The registry pre-pass, not clap, is what refuses an +undeclared flag, because the canonical JSON payload with its +nearest-name suggestion is pinned by this contract; clap normalizes +spellings and values behind it. `napl completions ` emits a completion script for +`bash`, `zsh`, or `fish`, generated from the same registry, a pure +read on stdout; an unknown shell name is a usage error. -/// What had already reached the store when a failure occurred. -#[derive(Default, Debug, Clone, Copy)] -struct Published { - blobs: bool, - provenance: bool, -} +The commands whose meaning requires a committed head are exactly +`diff`, `blame`, and `notes add`; in a workspace that has none they +answer with a prerequisite error, never an empty success shape: the +payload names what is missing and the exact sequence that creates it, +from `napl init` through writing a prompt beside `.napl`, `add`, and +`commit`. `log` and `show` keep their committed contracts and answer +empty honestly. `napl blame` on a path no committed module owns, and +`napl diff` for a target under which no committed module owns output, +say so: the payload carries `tracked: false` and a reason naming the +absent baseline, never an empty patch or empty ancestry that reads as +success. The `napl notes add` refusal for a working path +that is not an object name states how an object name is obtained, +naming `napl log` and `napl show` with a one-line example. -/// Run a commit. It never panics and never returns an error: every path -/// produces a canonical payload naming whether blobs or provenance were -/// published, and no path publishes semantic state partially. -pub fn commit( - workspace: &Path, - receipt_path: &Path, - candidate_dir: &Path, - attribution_path: Option<&Path>, - no_verify: bool, -) -> CommitReport { - let mut published = Published::default(); - match run( - workspace, - receipt_path, - candidate_dir, - attribution_path, - no_verify, - &mut published, - ) { - Ok(payload) => CommitReport { - outcome: OutcomeCode::Success, - payload, - }, - Err(err) => CommitReport { - outcome: err.code, - payload: error_payload(&err, published.blobs, published.provenance), - }, - } -} +::: test +name: flag equals value and separated value parse identically +given: { workspace: fixture_two_modules, invocations: ["status --module leaf", "status --module=leaf"] } +expect: { outputs_byte_identical: true } +::: -fn run( - workspace: &Path, - receipt_path: &Path, - candidate_dir: &Path, - attribution_path: Option<&Path>, - no_verify: bool, - published: &mut Published, -) -> Result { - let store = disk::init(workspace)?; - // A crashed predecessor's marker is rolled forward before this commit reads - // anything, so it never observes a half-applied head. - recover(&store)?; +::: test +name: completions emits a script for a known shell and refuses an unknown one +given: { workspace: fixture_two_modules, invocations: ["completions zsh", "completions tcsh"] } +expect: { zsh_exit_code: 0, zsh_output_nonempty: true, tcsh_exit_code: 2, store_bytes_unchanged: true } +::: - let receipt_bytes = - std::fs::read(receipt_path).map_err(|e| disk::io(receipt_path, &e))?; - let receipt = parse_receipt(&receipt_bytes)?; - let target = builtin_target(&receipt.target)?; - let sources = discover_sources(workspace)?; +::: test +name: blame on an unowned path and diff on an outputless target say untracked +given: { workspace: fixture_two_modules, blame_path: file_no_module_owns, diff_target: target_without_outputs } +expect: { tracked: false, reason_names_baseline: true, exit_code: 0 } +::: - // 1. Ingest. Every later step reads these bytes, never the candidate - // directory, so tampering with the directory afterwards changes nothing. - let candidate = ingest(&scan_candidate(candidate_dir)?, default_limits())?; - let root = placement_of(&sources, &receipt.module)?; - check_candidate_shape(&target, &root, &candidate)?; +::: test +name: status reports code-side drift and stale references beside prompt freshness +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } +expect: { module_drift: true, stale_reference_count: 1, prompt_side_fresh: true } +::: - // 2. Verify against independently recomputed identities, before publishing. - crate::transaction::verify_receipt(&store, &sources, &receipt, &candidate)?; +::: test +name: a headless workspace answers baseline commands with the prerequisite sequence +given: { workspace: fresh_init_no_commits, invocation: "blame some/file.rs" } +expect: { error_names_missing_head: true, sequence_named: [init, prompt, add, commit] } +::: - // 2b. Prove the attribution update the candidate carries, or refuse to - // install without one. The escape hatch installs it unproven and stale. - let built = build_attribution( - &sources, - &receipt, - &target, - &root, - &candidate, - attribution_path, - no_verify, - )?; +::: test +name: init with a help flag prints usage and mutates nothing +given: { directory: empty, invocation: "init --help" } +expect: { exit_code: 0, created: [], usage_printed: true } +::: - // 3. Extract the surface and derive the whole transition. - let derived = derive_commit(&target, &root, &receipt, &candidate, built.as_ref())?; +::: test +name: an unknown flag is a usage error naming the flag and suggesting the nearest +given: { workspace: fixture_two_modules, invocation: "add cand --modul m" } +expect: { exit_code: 2, error_names_unknown: "--modul", suggested: "--module", executed: false } +::: - // 4. Publish blobs and provenance outside any lock. - publish_all(&store, &derived.objects, &derived.transition_id)?; - // The module's committed prompt bytes ride along as a content-addressed - // blob named by the prompt hash the attribution already records, so - // `napl prompt` materializes spec prose from the snapshot, never the tree. - if let Some(src) = sources.iter().find(|s| s.module == receipt.module) { - disk::publish( - &store, - napl_store::store_hash::ObjectKind::Blob, - src.normalized_body.as_bytes(), - &derived.transition_id, - )?; - } - published.blobs = true; - published.provenance = true; +::: test +name: help lists every registered command and per-command help states exact usage +given: { workspace: fixture_two_modules, invocations: ["help", "help add"] } +expect: { all_commands_listed: true, add_usage_line_present: true, exit_code: 0, store_bytes_unchanged: true } +::: - // 5. The bounded critical section. - install(&store, workspace, &target, &root, &receipt, &sources, &candidate, &derived) -} +Manifests are user-owned glue seams, not records: `Cargo.toml` at the +workspace root and in every crate belongs to the user, edited by hand +like any Rust project, tracked by git, consumed by cargo, and NEVER +owned, installed, validated, or rewritten by the toolchain. A candidate +containing a manifest path is rejected as invalid-candidate; no module's +owned output includes one; nothing in the store records manifest bytes. +The toolchain's only relationship to manifests is diagnosis: `status` +reports, as observations never as writes, a module placed in a crate the +workspace manifest does not list, or a crate directory missing a +manifest. Every manifest diagnostic names the exact workspace-relative +manifest path it means and says the file is the user's to edit, so the +manifest is never a thing status alludes to without locating. A dependency needed by generated code is added by editing the +manifest, and the build battery is what proves it correct. -#[allow(clippy::too_many_arguments)] -fn install( - store: &Store, - workspace: &Path, - target: &TargetDef, - root: &str, - receipt: &Receipt, - sources: &[SourceModule], - candidate: &Candidate, - derived: &DerivedCommit, -) -> Result { - let module_lock = disk::take_lock(store, &lock_key(&target.name, &receipt.module))?; - let pointer_lock = match disk::take_lock(store, POINTER_LOCK) { - Ok(guard) => guard, - Err(err) => { - disk::release_lock(module_lock)?; - return Err(err); - } - }; - let outcome = - locked_install(store, workspace, target, root, receipt, sources, candidate, derived); - disk::release_lock(pointer_lock)?; - disk::release_lock(module_lock)?; - outcome -} +::: test +name: a candidate containing a manifest path is rejected +given: { workspace: fixture_two_modules, candidate: contains_cargo_toml } +expect: { outcome: invalid_candidate, manifest_bytes_unchanged: true } +::: -#[allow(clippy::too_many_arguments)] -fn locked_install( - store: &Store, - workspace: &Path, - target: &TargetDef, - root: &str, - receipt: &Receipt, - sources: &[SourceModule], - candidate: &Candidate, - derived: &DerivedCommit, -) -> Result { - let head = store.head()?; +## The two principles - // The footprint recheck: identities recomputed at the head that actually - // won, never at the head the receipt assumed. - let head_slice = slice_at( - store, - sources, - &target.name, - &receipt.module, - head.as_deref(), - )?; - let states = head_states(store, head.as_deref())?; - let head_state_key = state_of(store, &states, target, &receipt.module)?.1; - let arriving = Arriving { - input_key: receipt.input_key.clone(), - base_state_key: receipt.base_state_key.clone(), - base_snapshot: receipt.base_store_snapshot.clone(), - write_set: derived.transition.footprint.write_set.iter().cloned().collect(), - }; - let head_facts = Head { - snapshot: head.clone(), - input_key: head_slice.input_key, - base_state_key: head_state_key, - intervening_write_set: intervening_write_set( - store, - head.as_deref(), - receipt.base_store_snapshot.as_deref(), - )?, - }; - if let Some(rejected) = rejection(&decide(&arriving, &head_facts)) { - return Err(rejected); - } +P1 ownership: the toolchain owns `.napl` source, the derived layer, the +store, and the mappings between them; every owned fact is provable from +content alone. It contains no LLM invocation, no provider identifier, no +model selection, no completion parsing, no process execution (the only +process is `napl` itself), no clock reads in any semantic path, no +randomness in any semantic path. Generation happens outside: the user's +agent session reads the plan from `napl status`, the specification through +the query surface, writes candidate code, and calls `napl add` then +`napl commit`. - let accepted = accept( - store, - target, - root, - candidate, - derived, - head.as_deref(), - receipt.base_store_snapshot.as_deref(), - )?; - publish_all(store, &accepted.objects, &derived.transition_id)?; +::: test +name: the binaries spawn no process and name no provider +given: { crate_source: self } +expect: { spawns_processes: false, provider_identifiers: [] } +::: - // The marker is durable before the pointer moves, so a crash anywhere after - // this point leaves a plan recovery derives from content alone. - let marker = CommitMarker { - domain: receipt.module.clone(), - owner: derived.transition_id.clone(), - snapshot: accepted.snapshot.clone(), - steps: accepted.steps.clone(), - }; - disk::write_marker(store, &marker)?; - disk::compare_and_set_head(store, head.as_deref(), &accepted.snapshot)?; - apply_steps(store, workspace, &marker)?; - disk::delete_marker(store, &accepted.snapshot)?; +P2 footprint scoping: every operation declares a read set (content +identities consumed), a semantic write set (owned records changed), and its +derived projections plus physical install paths. Two operations conflict +when semantic write sets overlap or one's accepted write changes a content +identity in the other's read set. Derived projections (the crate-root +lib.rs module list, the command registry) are never conflict-bearing: +they are recomputed exactly once from the winning merged head inside +commit and rebase. Manifests are outside footprints entirely: user-owned +seams no operation reads as identity or writes at all. - Ok(success_payload(derived, &accepted)) -} +## Identity -/// Roll every recoverable commit marker forward, then drop it. -/// -/// The disposition is `napl-store`'s: a marker whose snapshot is HEAD or an -/// ancestor of HEAD rolls forward; a marker ahead of HEAD is a commit that never -/// landed and is collected. Neither branch consults liveness or wall time. -pub fn recover(store: &Store) -> Result> { - let head = store.head()?; - let mut applied: Vec = Vec::new(); - for snapshot in disk::marker_snapshots(store)? { - let Some(marker) = disk::read_marker(store, &snapshot)? else { - continue; - }; - if head.as_deref() == Some(snapshot.as_str()) { - apply_steps(store, &store.workspace, &marker)?; - applied.push(snapshot.clone()); - } - disk::delete_marker(store, &snapshot)?; - } - Ok(applied) -} +Expose `pub const CONTRACT_VERSION: u32 = 4` in napl-core (bumped from 1 +by the body-directive grammar, which changes how the same source bytes +are interpreted, from 2 by the relaxed module-name rule below, which +accepts sources contract 2 rejected, and from 3 by the capture verbs, +whose three refusal outcomes join the closed terminal-outcome table). Per module and +target: `surfaceHash` (hash of the recorded public surface), `inputKey` +(hash of the canonical closure manifest over normalized source hash, direct +dependencies' surfaceHashes, placement facts, entry-point version, target +name plus target-definition hash, CONTRACT_VERSION), `baseStateKey` (the +module's committed owned-output state hash), `sliceHash` (hash of the +module's canonical compile slice). Whole-compile `artifactHash` includes +scope and serves determinism tests only, never a commit precondition. Event +identity is `H(content hash + sorted parent hashes)`; ordinals and +timestamps are display metadata, never identity. Semantic identifiers are +content hashes, never a timestamp, pid, hostname, random value, model, or +provider. Telemetry lives in provenance records linking to semantic objects +by hash. -fn apply_steps(store: &Store, workspace: &Path, marker: &CommitMarker) -> Result<()> { - let payload = |hash: &str| get_blob(store, hash); - for step in &marker.steps { - apply_step(workspace, step, &payload)?; - } - Ok(()) -} +::: test +name: "a module's inputKey covers source, direct dep surfaces, placement, target identity, and contract version" +given: { workspace: fixture_two_modules, module: consumer } +expect: { input_key_components: [source_hash, direct_dep_surface_hashes, placement_facts, target_name, target_definition_hash, entry_point_version, contract_version] } +::: -fn publish_all(store: &Store, objects: &[PendingObject], owner: &str) -> Result<()> { - let mut seen: BTreeMap<&str, ()> = BTreeMap::new(); - for object in objects { - if seen.insert(object.name.as_str(), ()).is_some() { - continue; - } - disk::publish(store, object.kind, &object.payload, owner)?; - } - Ok(()) -} +::: test +name: changing a leaf's surface changes the direct dependent's inputKey only +given: { workspace: fixture_chain_three, edit: rename_public_item_in_leaf } +expect: { changed_input_keys: [middle], unchanged_input_keys: [top] } +::: -/// Prove the attribution update a commit carries, or refuse the commit. -/// -/// A commit without an attribution update fails loudly, naming what is missing, -/// unless the escape hatch is set. With the escape hatch, an update installs -/// unproven and is recorded stale, and a commit with no update at all leaves the -/// module's attribution absent. A proven update is validated against the ingested -/// candidate and the current normalized prompt body before anything installs. -#[allow(clippy::too_many_arguments)] -fn build_attribution( - sources: &[SourceModule], - receipt: &Receipt, - target: &TargetDef, - root: &str, - candidate: &Candidate, - attribution_path: Option<&Path>, - no_verify: bool, -) -> Result> { - let bytes = match attribution_path { - Some(path) => std::fs::read(path).map_err(|e| disk::io(path, &e))?, - None => { - if no_verify { - return Ok(None); - } - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "commit of module {:?} for target {:?} carries no attribution update; supply --attribution or --no-verify", - receipt.module, target.name - ), - )); - } - }; - let text = String::from_utf8(bytes).map_err(|_| { - error( - OutcomeCode::InvalidCandidate, - "attribution update is not valid UTF-8", - ) - })?; - let mut map = attribution::parse(&text).ok_or_else(|| { - error( - OutcomeCode::InvalidCandidate, - "attribution update is not a parseable record", - ) - })?; - map.stale = no_verify; +::: test +name: a leaf edit absorbed by an unchanged intermediate surface leaves higher dependents' inputKeys unchanged +given: { workspace: fixture_chain_three, edit: private_body_change_in_leaf } +expect: { changed_input_keys: [], unchanged_input_keys: [middle, top] } +::: - let source = sources - .iter() - .find(|source| source.module == receipt.module) - .ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("module {:?} has no prompt file in the workspace", receipt.module), - ) - })?; - if !no_verify { - let body_lines = source.normalized_body.lines().count() as u32; - attribution::validate( - target, - root, - &receipt.module, - &candidate.files, - body_lines, - &map, - )?; - } +::: test +name: telemetry differences never change transition identity +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipts: [with_usage, without_usage] } +expect: { transition_identities_equal: true } +::: - let yaml = attribution::to_yaml(&map); - let state = AttributionState { - record: attribution::record_hash(&map), - prompt_hash: source.normalized_source_hash.clone(), - stale: no_verify, - }; - Ok(Some(BuiltAttribution { - state, - yaml, - view_path: attribution::view_path(&receipt.module), - })) -} +::: test +name: history nodes are causal hashes over content plus sorted parents +given: { workspace: fixture_two_modules, commits: 2 } +expect: { event_id_components: [content_hash, sorted_parent_ids], ordinal_in_identity: false, timestamp_in_identity: false } +::: -/// The lock name a module's domain is keyed by. There is no global write lock: -/// two modules commit concurrently without ever contending. -pub fn lock_key(target: &str, module: &str) -> String { - format!("module-{target}-{module}") -} +## Module names -/// The crate directory a module's prompt sits in, read off the discovered -/// sources so the placement follows the prompt's location and nothing else. -fn placement_of(sources: &[SourceModule], module: &str) -> Result { - sources - .iter() - .find(|source| source.module == module) - .map(placement_root) - .ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} has no prompt file in the workspace"), - ) - }) -} -napl-store snapshot v1 895 -{"contractVersion":4,"event":"61c81af035bed2e1255db7546ce0842eda9e60cf632f0e7eb870277caa837c53","states":[{"module":"agent","state":"112df64307c695d6e80359ccbbcf9100579692216d90fd08be281e7c82c07574","target":"rust"},{"module":"commands","state":"9e7b90a24d35bb3d42c0476c6d37e380c905ed227e86d8cf44e8342e96265a33","target":"rust"},{"module":"git-store","state":"33a141441be88c6825e85a1495467d599a016bae61a678358f9d680a52017c99","target":"rust"},{"module":"napl","state":"0ef99195476c7251619345064e2dc74fa005197438592370d03b0b5e5a688b55","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"f38b0d5a644c8edfdf8d88f5387838f0df2f638ef556620264f3a6900afd8c6b","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} -napl-store snapshot v1 781 -{"contractVersion":4,"event":"f3b88a7a02788d3c15aa142f2b1ec6a89ac03423a4f19e3f9333a7b636eef3a4","states":[{"module":"agent","state":"f66e5812f0b1d528c46f9377b7f6ff2595f21ee248ff0c9e799672f01b1edabd","target":"rust"},{"module":"commands","state":"9b23b62a07e1249fc078c3851c727ecf191bfe6c99ff2f5644f5eedbd2ff94e2","target":"rust"},{"module":"napl","state":"53a0ccab9dfa85e4f9715bc963de833a064081e2a7c96dc788bee20f3d9b650d","target":"rust"},{"module":"napl_store","state":"7ad4dba399677a4a801444f26c938bc4594731235bcccd9a979e1728b21f1822","target":"rust"},{"module":"query","state":"fafd87076d293a026ae6a6414d9a59f31d90c8a201c16a6167e90d03fe60aebc","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} -napl-store receipt v1 1042 -{"baseStateKey":"bc9f18a34748d245185298746e0db6fcac33afe57ddff8836927bb668f4172b6","baseStoreSnapshot":"937ba4b41de7564b24b82f0a73c4682155a931e651b629000414a8eb912c84cd","baseWorkspaceHash":"cd1ddce56e031ff2cb7ac24a109b3142d438c1a17491d2e1f8cb478b64231cc1","candidateTreeHash":"87dd9ca6b265f5f60f9a6a6a1f6e24134423830ba25de102f8ea3847df5a61a8","contractVersion":4,"inputKey":"1482b94e36b0b1f07a843d2b7144c900fb83228da90a6f9a367087cdc2b505fe","module":"query","outputManifest":[{"hash":"d6f8e36947df60a7eb714da2a8bd90ca48d88fd415c533ca24381e05a6ca9575","path":"crates/napl/src/check.rs"},{"hash":"8143fb1d2c2022405f5c7e77e08ea12da13b2379521f98fd50131e71c184219e","path":"crates/napl/src/document.rs"},{"hash":"6cb21bfc68622856056fac18b252e6ee0ffe650bda0923fb0745b26e10c18a06","path":"crates/napl/src/promptbuild.rs"},{"hash":"add27b49dd4b55c54e03c42b54962c7e9172e51a9b83b7e721f1fca8588b768a","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"sliceHash":"444a5b66d304eb5ed934ed56a58b5577bea31610466fff08a914c8679ccacfd4","target":"rust"} -napl-store blob v1 14266 -module: "napl" -target: "rust" -stale: false -entries: - - promptLines: - - 1 - - 8 - file: "crates/napl/src/cli.rs" - lines: whole - note: "the binary reads args runs one command and exits with the contract code" - - promptLines: - - 1 - - 8 - file: "crates/napl/src/commandset.rs" - lines: whole - note: "the command table the core toolchain contributes to the derived registry, store migrate among its entries" - - promptLines: - - 1 - - 8 - file: "crates/napl/src/main.rs" - lines: whole - note: "the binary entry point that spawns no process" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/source.rs" - lines: whole - note: "the smallest prompt the reader accepts, stated once beside the reader that judges it so the two commands that teach with it cannot drift from the grammar discovery finds by the .napl extension" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/transaction.rs" - lines: whole - note: "the crate lib.rs projection derived from the committed module file sets" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/workspace.rs" - lines: whole - note: "layout and discovery walk up to the nearest napl directory" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/paths.rs" - lines: whole - note: "the empty placement root contains every workspace-relative path and joins against it never fabricate a leading separator" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/target.rs" - lines: whole - note: "a prompt at the workspace root places at the empty root and every join against it stays relative" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/transaction.rs" - lines: whole - note: "candidate containment against the empty root admits every workspace-relative path instead of rejecting all of them" - - promptLines: - - 115 - - 250 - file: "crates/napl/src/cli.rs" - lines: whole - note: "the declared grammar of every command plus the dispatch loop that answers help ahead of it and refuses an undeclared flag before it runs, with clap's builder API constructed at dispatch from the registry as the parser behind the contract, every parse failure caught and served as the canonical JSON usage error, and the conventional spellings reduced to one normalized argument vector so --flag=value and --flag value cannot differ" - - promptLines: - - 115 - - 250 - file: "crates/napl/src/commandset.rs" - lines: whole - note: "each core command declares its summary exact usage line and closed flag table, diff's operand is a target name whose usage line spells it and whose refusal names the target names that exist, and store declares its one operation so an unknown one is a grammar failure like any other undeclared token" - - promptLines: - - 115 - - 250 - file: "crates/napl/src/error.rs" - lines: whole - note: "exit 2 is the pinned usage-error code for the whole binary" - - promptLines: - - 115 - - 250 - file: "crates/napl/src/target.rs" - lines: whole - note: "placement roots owned source globs and the user-owned manifest seam a candidate may never contain" - - promptLines: - - 251 - - 278 - file: "crates/napl/src/decision.rs" - lines: whole - note: "the conflict rule two operations conflict when semantic write sets overlap" - - promptLines: - - 251 - - 278 - file: "crates/napl/src/error.rs" - lines: whole - note: "the outcome vocabulary and its exit-code mapping" - - promptLines: - - 251 - - 278 - file: "crates/napl/src/objects.rs" - lines: whole - note: "P2 footprints read write and derived-projection sets over content identities" - - promptLines: - - 279 - - 328 - file: "crates/napl/src/slice.rs" - lines: whole - note: "identity vocabulary inputKey closure manifest baseStateKey and sliceHash" - - promptLines: - - 279 - - 328 - file: "crates/napl/src/version.rs" - lines: whole - note: "the contract version bumped to four by the capture verbs whose three refusals join the closed table" - - promptLines: - - 329 - - 389 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "names are serialized as quoted escaped scalars that reparse identically" - - promptLines: - - 329 - - 389 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "the attribution record path keys on the encoded name never the raw one" - - promptLines: - - 329 - - 389 - file: "crates/napl/src/paths.rs" - lines: whole - note: "the injective reversible name encoding used wherever a name becomes a segment" - - promptLines: - - 329 - - 389 - file: "crates/napl/src/source.rs" - lines: whole - note: "the module-name rule any non-empty single-line scalar and nothing narrower" - - promptLines: - - 390 - - 460 - file: "crates/napl/src/compile.rs" - lines: whole - note: "the compile derivation a pure read that never persists an artifact, and the pending reason it derives from the closed vocabulary by comparing the current inputKey components against the identities the committed record holds, with the re-stamp promise an undrifted pending module carries" - - promptLines: - - 390 - - 460 - file: "crates/napl/src/graph.rs" - lines: whole - note: "the dependency graph and dependency-ordered wave planning" - - promptLines: - - 390 - - 460 - file: "crates/napl/src/paths.rs" - lines: whole - note: "the portable path grammar and its collision-detecting normalization" - - promptLines: - - 461 - - 675 - file: "crates/napl/src/directive.rs" - lines: whole - note: "the test body-directive value grammar diagnostics and deterministic extraction" - - promptLines: - - 521 - - 673 - file: "crates/napl/src/directive.rs" - lines: whole - note: "the docs fence family with its micromark name and attribute grammar, the fence stack where a closing line of N colons closes the innermost fence opened with at most N and where code blocks suspend the stack, and the split that lifts every top-level docs fence out of the semantic body" - - promptLines: - - 521 - - 673 - file: "crates/napl/src/source.rs" - lines: whole - note: "the semantic body every identity derivation consumes is the normalized body with its docs fences removed, the module preamble is the first fence standing before the first heading, and an example fence is validated so a published example cannot rot" - - promptLines: - - 521 - - 673 - file: "crates/napl/src/cli.rs" - lines: whole - note: "a napl invocation written inside an example fence is judged against the live command grammar without running anything" - - promptLines: - - 676 - - 729 - file: "crates/napl/src/surface.rs" - lines: whole - note: "tree-sitter surface extraction sound by construction and loud on malformed input" - - promptLines: - - 730 - - 1286 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "attribution rides the staged record validated before install" - - promptLines: - - 730 - - 1286 - file: "crates/napl/src/authoring.rs" - lines: whole - note: "the internal receipt derivation and the one guarded auto-fix" - - promptLines: - - 730 - - 1286 - file: "crates/napl/src/candidate.rs" - lines: whole - note: "candidate ingestion untrusted bytes become immutable content-addressed blobs" - - promptLines: - - 730 - - 1286 - file: "crates/napl/src/commit.rs" - lines: whole - note: "napl commit the one guarded transaction over what add staged, and bare commit re-derives a record against the head an earlier record in the same invocation moved and retries it once" - - promptLines: - - 730 - - 1286 - file: "crates/napl/src/disk.rs" - lines: whole - note: "the store shell publication head compare-and-set and module-keyed locks, and the seam where the object backend is chosen by observing whether the workspace sits in a git repository whose store branch exists, everything but object storage keeping its disk paths" - - promptLines: - - 730 - - 1286 - file: "crates/napl/src/receipt.rs" - lines: whole - note: "the internal receipt everything the compiler recomputes and refuses to believe" - - promptLines: - - 730 - - 1286 - file: "crates/napl/src/staging.rs" - lines: whole - note: "napl add stages from any of the three sources through one pipeline, the dry run enumerates the validations it ran and names target-language validity as the one it did not judge, and a bad attribution map is answered with the whole map contract" - - promptLines: - - 730 - - 1286 - file: "crates/napl/src/store_view.rs" - lines: whole - note: "the read seam over content-addressed objects a pure function of the bytes" - - promptLines: - - 730 - - 1286 - file: "crates/napl/src/transport.rs" - lines: whole - note: "attribution transports through the byte diff on both ends independently, and a refusal reports each endpoint's verdict separately so the side that transported serves its transported range and only the side needing a human is asked for" - - promptLines: - - 730 - - 1286 - file: "crates/napl/src/trees.rs" - lines: whole - note: "building the candidate tree over the ingested manifest" - - promptLines: - - 730 - - 1286 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "attribution rides the staged record validated before install" - - promptLines: - - 730 - - 1286 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "attribution rides the staged record validated before install" - - promptLines: - - 730 - - 1286 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "attribution rides the staged record validated before install" - - promptLines: - - 730 - - 1286 - file: "crates/napl/src/lsp.rs" - lines: whole - note: "hover states the direction the recorded provenance that the reference is referentially valid and which endpoints read stale" - - promptLines: - - 1287 - - 1485 - file: "crates/napl/src/capture.rs" - lines: whole - note: "napl edit and napl write validate apply capture in that order, and a recorded call's payload carries the reference object verbatim with its transition hash and says the edited file remains working-tree drift until its module commits" - - promptLines: - - 1287 - - 1485 - file: "crates/napl/src/commit.rs" - lines: whole - note: "a pre-proven attribution installs fresh because the verb already proved the reference against the exact bytes it names and the guard runs unchanged" - - promptLines: - - 1287 - - 1485 - file: "crates/napl/src/cost.rs" - lines: whole - note: "a recorded session window is priced lazily at read time never aggregated at record time" - - promptLines: - - 1287 - - 1485 - file: "crates/napl/src/error.rs" - lines: whole - note: "the three refusals join the closed terminal-outcome table with pinned exit codes 27 28 and 29 and none reuses mapping-integrity" - - promptLines: - - 1287 - - 1485 - file: "crates/napl/src/staging.rs" - lines: whole - note: "the staged record carries its attribution mode so a reference-only record commits through the same door add and commit already use" - - promptLines: - - 1486 - - 1917 - file: "crates/napl/src/blame.rs" - lines: whole - note: "derived line ancestry where surviving lines trace through accepted patches, and a path no committed module owns answers tracked false naming the absent baseline rather than an empty ancestry that would read as success" - - promptLines: - - 1486 - - 1917 - file: "crates/napl/src/cost.rs" - lines: whole - note: "cost as a pure calculation over an explicit receipt set where verb-staged session windows resolve against the log at read time and unknown stays unknown" - - promptLines: - - 1486 - - 1917 - file: "crates/napl/src/evidence.rs" - lines: whole - note: "evidence validators keyed by media type including the usage-proxy jsonl log with compiled-in bounds" - - promptLines: - - 1486 - - 1917 - file: "crates/napl/src/lint.rs" - lines: whole - note: "unreachable modules under declared roots where absent roots refuse loudly with the exact declaration rather than an empty findings list, and unconsumed surface entries" - - promptLines: - - 1486 - - 1917 - file: "crates/napl/src/lsp.rs" - lines: whole - note: "the language server hover definition references code lens and the diagnostics it publishes from reference staleness over the mapping" - - promptLines: - - 1486 - - 1917 - file: "crates/napl/src/maintenance.rs" - lines: whole - note: "gc fsck and restore, where reachable means the transitive closure from every named head through every event and every hash edge any reachable object carries, so an edge gc does not understand is a reason to keep and a store healthy before a sweep is healthy after it" - - promptLines: - - 1486 - - 1917 - file: "crates/napl/src/mv.rs" - lines: whole - note: "relocation as a semantic operation re-rooting a module through the guarded path" - - promptLines: - - 1486 - - 1917 - file: "crates/napl/src/porcelain.rs" - lines: whole - note: "the observational porcelain status log show diff blame plus the maintenance verbs, where the plan labels every projection path tool-derived and names one concrete legal first output, show lists a transition's reference entries inline exactly as it lists notes, and store migrate walks the tracked objects into one deterministic commit, refusing outside a repository and refusing a branch that already exists, and reports the object files ready to leave the working tree with the exact removal command" - - promptLines: - - 1486 - - 1917 - file: "crates/napl/src/textdiff.rs" - lines: whole - note: "diff renders drift as a canonical byte-stable patch" - - promptLines: - - 1918 - - 1952 - file: "crates/napl/src/canon.rs" - lines: whole - note: "the determinism contract canonical bytes and byte-pinned serialization" -napl-store event v1 158 -{"content":"d627ee05b2f4f062faaea7f1ae5e4be35cd3d53d720842eed3d6fcdb90c11fd1","parents":["6496be0db3e57121c30fcf9b7e6ef0a0a8b359b0d400321f6f4f56d4517e4cd0"]} -napl-store blob v1 13275 -use std::path::Path; +A module name is any non-empty single-line scalar. The empty string is the only +value rejected, surfaced as `mapping-integrity`. No character class, case, or +identifier shape is required: `todo-app`, `todo_app`, and non-ascii names are all +legal. The same rule governs every name position the parser reads, the `module` +frontmatter value, each `deps` entry, and each declared root, so all three accept +exactly the set the module rule accepts. This relaxation replaces the contract 2 +identifier constraint and rides the CONTRACT_VERSION bump to 3, the sanctioned +identity transition. As with the 1-to-2 bump, every module's inputKey recomputes +because CONTRACT_VERSION is one of its components; no source or surface changes, +only the version does. -use serde_json::{json, Value}; +Because a name is unrestricted, it is never used raw as a filesystem path segment +or a store key. napl-core exposes one injective, reversible encoding used at every +such site: each byte outside `[A-Za-z0-9._-]` is percent-encoded as `%XX` with +uppercase hex, and a name that is exactly `.` or `..` has its dots percent-encoded +too, so an encoded segment is never `/`, `\`, empty, `.`, or `..` and can never +traverse or alias a directory. A name already matching `[A-Za-z0-9._-]` and not +equal to `.` or `..` encodes to itself, so existing records keep their on-disk +names: `todo-app` stays `todo-app`. Every writer that keys a record or scratch +path by module name, the mapl, ir, attribution, and prompt-at-gen documents, the +staging and relocation scratch directories, and the store root map, uses the +encoded segment; decoding recovers the exact name. On a case-insensitive +filesystem two names differing only in ascii case can still share one segment; +that collision is the author's concern, distinct from the traversal safety above. -use crate::canon::canonical_bytes; -use crate::cli::{CliResult, CommandSpec, FlagSpec, WORKSPACE_FLAG}; -use crate::disk; -use crate::error::Result; -use crate::source::{MINIMAL_PROMPT, SOURCE_EXTENSION}; -use crate::workspace::find_workspace_root; +Wherever a module name is serialized into a record it is written as a quoted, +escaped scalar, never interpolated raw, so a name containing YAML or JSON +metacharacters reparses to the identical name. -const WATCH_FLAGS: &[FlagSpec] = &[WORKSPACE_FLAG]; +::: test +name: a non-identifier module name is accepted +given: { workspace: fixture_single_module, module_name: "todo-app" } +expect: { outcome: ok } +::: -/// The command table the `commands` module contributes to the derived registry: -/// `init` and `watch`. The query surface that used to live here belongs to the -/// `query` module now. -/// -/// `init` declares an empty flag table. Like `git init`, the directory it runs -/// in is the whole story: there is nothing to configure, so anything beyond the -/// shared help flag is a usage error the dispatch loop refuses before `init` -/// runs. -pub fn commands() -> &'static [CommandSpec] { - &[ - CommandSpec { - name: "init", - example: "napl init", - summary: "bootstrap a workspace in this directory, creating the records skeleton", - usage: "napl init", - flags: &[], - passthrough: false, - run: run_init, - }, - CommandSpec { - name: "watch", - example: "napl watch", - summary: "re-derive the compile output whenever a prompt's content changes", - usage: "napl watch [--workspace ]", - flags: WATCH_FLAGS, - passthrough: false, - run: crate::watch::run_watch, - }, - ] -} +::: test +name: an empty module name is rejected as mapping-integrity +given: { workspace: fixture_single_module, module_name: "" } +expect: { outcome: mapping_integrity } +::: -/// `napl init`: bootstrap a workspace by creating the records directory with an -/// empty store skeleton, unless an ancestor workspace already exists. -/// -/// Inside an existing workspace it changes nothing and reports -/// `already_initialized`; it never nests a workspace under another and never -/// touches an existing store's bytes. A fresh store has no head and no objects, -/// exactly the state a first `napl compile` expects. -/// -/// Like `git init`, the directory it runs in is the whole story: the success -/// payload names that directory as the workspace root, there is nothing to -/// configure, and a prompt written beside `.napl` is already inside placement. The -/// payload also teaches the first mile through its `nextSteps` field, so the -/// grammar arrives with the workspace instead of being discovered one validation -/// error at a time. -pub(crate) fn run_init(cwd: &Path, _args: &[String]) -> Result { - let dir = cwd.to_path_buf(); - if let Some(existing) = find_workspace_root(&dir) { - return Ok(CliResult { - stdout: canonical_bytes(&json!({ - "changed": Vec::::new(), - "contractVersion": crate::version::CONTRACT_VERSION, - "outcome": "already_initialized", - "workspaceRoot": display_path(&existing), - })), - exit_code: 0, - }); - } - disk::init(&dir)?; - let scaffold = scaffold_agent_glue(&dir)?; - let mut payload = json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "created": [".napl/store"], - "head": Value::Null, - "nextSteps": next_steps(), - "outcome": "initialized", - "scaffold": scaffold, - "workspaceRoot": display_path(&dir), - }); - if let Some(quiet) = scaffold_store_quiet(&dir)? { - payload["gitattributes"] = quiet; - } - Ok(CliResult { - stdout: canonical_bytes(&payload), - exit_code: 0, - }) -} +::: test +name: deps entries and declared roots accept the same names as the module rule +given: { module_name: "todo-app", dep_name: "a-b", root_name: "x.y" } +expect: { all_accepted: true } +::: -/// The store's diff glue, scaffolded under the same never-clobber law the agent -/// glue obeys. -/// -/// The store travels the way the code does now, so there is nothing to install -/// and nothing to teach about pushing: what is left is readability. A -/// `.gitattributes` carrying the two store lines is written only when the -/// workspace has none; an existing one is left exactly as it is and the payload -/// prints the exact lines its owner may add by hand. -fn scaffold_store_quiet(dir: &Path) -> Result> { - if crate::git::enclosing_repository(dir).is_none() { - return Ok(None); - } - let path = dir.join(GITATTRIBUTES_PATH); - let exists = path.exists(); - if !exists { - std::fs::write(&path, GITATTRIBUTES_STORE_BODY).map_err(|e| disk::io(&path, &e))?; - } - Ok(Some(json!({ - "detail": if exists { - "left exactly as it was; add the lines below to it by hand so store files stay quiet in diffs" - } else { - "written because it was absent; it is yours from now on and no later command rewrites it" - }, - "lines": GITATTRIBUTES_STORE_LINES, - "path": GITATTRIBUTES_PATH, - "userOwned": true, - "why": "store objects and packs are generated content: marking them keeps a pull request readable while the store rides along in it", - "written": !exists, - }))) -} +::: test +name: the name encoding is injective reversible and never traverses +given: { names: ["todo-app", "a/b", "..", "café", "a b"] } +expect: { decode_roundtrip_equal: true, no_segment_in: ["/", "\\", "", ".", ".."], safe_subset_unchanged: ["todo-app"] } +::: -/// The file the store lines live in, and the two lines themselves: one for the -/// loose objects, one for the packs beside them. -const GITATTRIBUTES_PATH: &str = ".gitattributes"; -const GITATTRIBUTES_STORE_LINES: &[&str] = &[ - ".napl/store/objects/** linguist-generated -diff", - ".napl/store/objects/pack/** linguist-generated -diff", -]; +::: test +name: a name with yaml metacharacters is written quoted and reparses identically +given: { module_name: "a: {b} #c" } +expect: { record_reparse_equal: true, written_as_quoted_scalar: true } +::: -/// The file init writes when the workspace has none. -const GITATTRIBUTES_STORE_BODY: &str = "\ -.napl/store/objects/** linguist-generated -diff -.napl/store/objects/pack/** linguist-generated -diff -"; +## The compile derivation -/// The agent-teaching glue, written wrangler-style: only what is absent, never -/// over an existing file. -/// -/// Whichever harness opens the repository finds its own native entry file -/// already pointing at the one doctrine, so nothing sniffs an environment and -/// nothing detects a harness: every entry file is written, each tiny and inert, -/// and the result is the same bytes on every machine. A file that already exists -/// is left exactly as it is and the payload prints the one reference line its -/// owner may add by hand, because the alternative — merging into a file the user -/// owns — is a rewrite nobody asked for. -/// -/// Everything written here is user-owned from the moment it exists: ordinary -/// files, never records, never rewritten by any later command. -fn scaffold_agent_glue(dir: &Path) -> Result> { - let mut out: Vec = Vec::new(); - for (path, content, reference) in [ - (AGENT_DOCTRINE_PATH, AGENT_DOCTRINE, AGENT_DOCTRINE_PATH), - (CLAUDE_ENTRY_PATH, CLAUDE_ENTRY, CLAUDE_ENTRY_REFERENCE), - (AGENTS_ENTRY_PATH, AGENTS_ENTRY, AGENTS_ENTRY_REFERENCE), - ] { - let target = dir.join(path); - let exists = target.exists(); - if !exists { - std::fs::write(&target, content).map_err(|e| disk::io(&target, &e))?; - } - out.push(json!({ - "detail": if exists { - "left exactly as it was; add the reference line by hand if you want it" - } else { - "written because it was absent; it is yours from now on and no later command rewrites it" - }, - "path": path, - "referenceLine": reference, - "userOwned": true, - "written": !exists, - })); - } - Ok(out) -} +Compilation is a pure derivation and NOT a command: parse the graph, +compute per-module slices for the changed frontier, derive +dependency-ordered waves. Never persisted, and never spelled on the CLI — +it is the internal engine of `status`, `add`, `commit`, `watch`, and the +lsp. Per module the slice carries: normalized source body and hash, direct +dependency surfaces and surfaceHashes, placement and allowed-output +constraints including the surface grammar, the owned-output base and +baseStateKey, inputKey, sliceHash, wave position, receipt requirements. It +contains no provider, model, prompt assembly, credentials, timestamps, +retry policy, pricing, or test results. `napl status --module ` +reports the module's PLAN from this derivation — owned globs, placement +root, projection ownership, receipt requirements, wave position — the +invariant requirements only, a pure function of the working tree's +prompts; candidate-dependent validation belongs to `add`, never the plan. -/// The one doctrine file, and the two entry files that point at it. -const AGENT_DOCTRINE_PATH: &str = "napl-agents.md"; -const CLAUDE_ENTRY_PATH: &str = "CLAUDE.md"; -const AGENTS_ENTRY_PATH: &str = "AGENTS.md"; +::: test +name: status reports a module plan derived from the working tree +given: { workspace: fixture_two_modules, flag: module_leaf } +expect: { plan_fields: [owned_globs, placement_root, receipt_requirements, wave_position], candidate_dependent_fields: absent } +::: -/// The line a user adds by hand when their entry file already exists. -const CLAUDE_ENTRY_REFERENCE: &str = "@napl-agents.md"; -const AGENTS_ENTRY_REFERENCE: &str = "Read napl-agents.md before working in this repository."; +## Body directives -const CLAUDE_ENTRY: &str = "@napl-agents.md\n"; +The prompt body grammar reserves directive blocks, `directiveVersion 1`, +and recognizing them is part of the identity contract: text that older +binaries read as inert prose can be a compile error under this grammar, so +this recognition rides a CONTRACT_VERSION bump, the sanctioned identity +transition. The first directive is `test`. An opener is a line that is +exactly `::: test`; the closer is a line that is exactly `:::`; no +nesting. An unknown `::: ` opener is a compile error, never prose, +so future directive names are non-breaking. Directive blocks are body +text: they count in normalized body lines and attribution ranges may +cover them. -const AGENTS_ENTRY: &str = - "Read napl-agents.md before working in this repository.\n"; +Block content is one case in an exact value grammar mapping 1:1 onto +canonical JSON, so two binaries cannot disagree on a value: one mapping +with keys `name` (required, non-empty, single line), `given` (optional, +mapping), `expect` (required, mapping). Block mappings and sequences +indent by exactly two spaces; flow `{}` and `[]` forms are allowed. +Strings are double-quoted with JSON escapes, or plain scalars ended by a +structural character (`:`, `,`, `{`, `}`, `[`, `]`, `#`) or end of line +with no leading or trailing space. Numbers are JSON number syntax; +`true`, `false`, `null` are keywords; every other plain scalar is a +string. Nothing else from YAML exists here: no comments, no duplicate +mapping keys, no anchors, aliases, tags, block scalars, multiline +scalars, multi-document markers, octal or hex, timestamps, or merge keys. +A case needing prose belongs in prose. Diagnostics are compile errors +naming the exact body line, judged in this precedence: unclosed block, +unknown directive name, nested opener, content outside the value grammar, +missing or empty name, missing expect, duplicate case name within the +module across both positions after NFC normalization and whitespace trim. +Frontmatter `tests:` remains legal; a module's case set is the union of +both positions; extraction of cases from body bytes is deterministic — +same bytes, same cases. -/// The working doctrine for an LLM session in a napl workspace. -/// -/// Compiled in as a constant, so the bytes are the same on every machine and -/// nothing about the environment can change them. -const AGENT_DOCTRINE: &str = "\ -# Working in this napl workspace +::: test +name: an unclosed test directive block is a compile error naming its opener line +given: { body: directive_without_closer } +expect: { outcome: compile_error, line_named: opener } +::: -This repository keeps its specifications in `.napl` prompt files and its records -in `.napl/`. The napl toolchain is the only writer of `.napl/`. Everything below -is how a session works here. +::: test +name: "an unknown directive name is a compile error, never prose" +given: { body: unknown_directive_opener } +expect: { outcome: compile_error, treated_as_prose: false } +::: -## Edit with your native tools +::: test +name: a duplicate case name across frontmatter and body errors +given: { frontmatter_case: alpha, body_directive_case: alpha } +expect: { outcome: compile_error, diagnostic: duplicate_case_name } +::: -Open files and edit them the way you normally would. A hand edit to a generated -file is legal. So is a hand edit to a prompt. Nothing polices either one. +::: test +name: directive case extraction is deterministic +given: { body: fixture_with_two_directives, runs: 2 } +expect: { cases: 2, bytes_equal: true } +::: -## Drift is legal, and the diagnostics mark it +## Recorded surfaces -When a generated file no longer matches what was committed, that is drift. Drift -is a state, not a failure: `napl status` reports it per module, `napl diff` -renders it as a patch, and the language server publishes a warning on each end of -every stale reference. Nothing repairs a reference automatically and nothing -blocks you for having one. +At commit, extract the candidate's public API with tree-sitter (pinned +grammars) and record it as a `surface` object; dependents' inputKeys pin +recorded surfaceHashes, store facts, never demand scans. The surface +grammar makes extraction sound by construction and violations reject as +`mapping-integrity`: for Rust, public items must be named `pub` +declarations at statically visible paths, no glob re-exports, no +macro-generated `pub` items; a named `pub use` is allowed and is recorded +as the unresolved written binding, never a resolved signature. For +TypeScript: explicit named exports only, no `export *`. Extraction is +syntactic, deterministic, and loud on malformed input. Re-extraction from +committed output must reproduce the recorded surface byte-identically. -## The loop +::: test +name: a candidate with a glob re-export is rejected as mapping-integrity +given: { workspace: fixture_two_modules, candidate: contains_glob_reexport } +expect: { outcome: mapping_integrity, published_semantic_state: false } +::: - napl status what needs doing, per module - napl status --module that module's plan: owned globs, placement root, - projections, receipt requirements, wave position - napl add --module stage the module's owned tree from the working tree - napl add [...] stage named working-tree files under their owners - napl add --check ... run the whole pipeline and write nothing - napl commit install every staged record, in wave order +::: test +name: a named pub use is recorded in the surface as the written binding +given: { workspace: fixture_two_modules, candidate: valid_with_named_reexport } +expect: { surface_contains_written_binding: true, surface_contains_resolved_signature: false } +::: -`add` stages and `commit` installs, exactly as in git. Attribution transports -itself across a diff; you supply a map only for the claims the diff cannot prove, -and a claim may be spelled `whole` so no line count is ever transcribed by hand. +::: test +name: recorded surface re-extraction from committed output is byte-identical +given: { workspace: fixture_two_modules, candidate: valid_for_consumer } +expect: { reextraction_equal: true } +::: -## The query surface +## add, then commit - napl q '' the whole canonical document as JSON - napl q '.' --working-tree the same, with prompt facts read from disk - napl q '' | napl prompt - materialize the specification prose for a selection - napl check validate a prompt before anything stages +Git parity is the mental model: `add` stages, `commit` installs. The +receipt is an internal record between them, never a user-facing noun. -Ask the toolchain rather than reading records by hand. `napl q` is jq, and any -command's JSON pipes back into it: `napl status | napl q '.pending' -`. +`napl add` stages a module state from any of three sources, and the +working tree is the default one, exactly as in git: -## The capture verbs are deliberate acts +- `napl add [...]` names working-tree files. Each path + resolves to its owning module through the owned globs (unique by + commit invariant, so no `--module` is needed), and the staged state + is that module's committed output with exactly the named files + overlaid from the working tree. Paths owned by different modules + stage one record per module in one invocation. A path no module owns + is a usage error naming the owned globs that do exist. +- `napl add --module ` stages the module's whole owned tree as + it sits in the working tree. +- `napl add --module ` stages explicit candidate + bytes, for the rare case where the bytes to stage are not the + working tree. -`napl edit` and `napl write` apply a change and, when given `--ref`, record a -reference through the same guarded door `commit` uses. They never invoke a model, -never decide placement, and never repair anything. Use them when you mean to -record a reference; use your ordinary editor otherwise. +All three forms feed one pipeline with `[--attribution ] +[--usage ]`: ingest the staged bytes into immutable content +addressed blobs (reject links, devices, duplicate normalized paths, +absolute or parent-escaping paths, oversized entries or totals; all +later steps read ingested blobs, never the source files); compute the +slice and validate against it; resolve attribution as below; validate +and attach usage evidence when supplied; then write the staged record — +the internal receipt: identities generated against, base snapshot +hashes, candidate tree hash, output manifest, attribution, optional +reported fields. Validation failures surface at add time with the same +outcome classes commit uses. Staged records are transient coordination +state, disposable litter by the git-state law: never promoted to +history, safely deletable, re-creatable by re-running add. -## What is not yours +Attribution transports instead of being demanded. Add computes the +byte diff between the module's committed output and the staged bytes, +and the prompt's committed body and its pending body, and transports +every committed attribution claim through those diffs +deterministically: a range the diff proves untouched survives +verbatim, a range below an insertion or deletion shifts by its size, +and a claim landing in a rewritten or ambiguous region does not +transport, because the transport never guesses; a range it cannot +prove through the diff is asked for, never invented. When every claim +transports, add stages with the transported map and no `--attribution` +flag is needed: the paperwork updates itself as a consequence of the +diff already being content-addressed on both sides. When any claim +fails to transport, add refuses as `invalid-candidate` listing exactly +the claims that need affirmation, each beside the observed bound or +region it was checked against, and `--attribution` supplies +affirmations for the listed claims only, layered over the transported +remainder. Any attribution refusal, transport or explicit, names the +observed bound (the actual line count or surviving range) next to the +rejected claim, so a wrong range never costs a manual count. -`.napl/` is the toolchain's. Every `Cargo.toml` and package manifest is the -user's, edited by hand, never owned by a module. A crate's shared entry file is a -derived projection owned by no module: the tool computes it, and a candidate that -carries it is rejected. -"; +::: test +name: add of one working-tree path stages the committed state with that file overlaid +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, add_paths: [edited_file] } +expect: { add_outcome: staged, staged_state: committed_plus_overlay, unnamed_files_at_committed_bytes: true } +::: -/// The first mile, carried in the success payload so a cold user never reverse -/// engineers the grammar one validation error at a time. -/// -/// Three facts, and nothing a reader has to infer: a complete minimal prompt, -/// which is the reader's own [`MINIMAL_PROMPT`] and therefore passes `napl check` -/// by construction rather than by resemblance; how discovery finds a prompt, -/// which is the `.napl` extension and not a registry or a configuration file; and -/// the exact commands from writing that prompt through `add` and `commit`. -/// -/// The sequence ends with one forward pointer rather than a command: reachability -/// analysis needs a `roots` declaration before it can judge anything, and naming -/// that here makes lint's later refusal a reminder instead of a first encounter. -fn next_steps() -> Value { - json!({ - "discovery": format!( - "discovery walks the workspace for files whose extension is {:?}, so a prompt is found by its name and nothing registers it", - SOURCE_EXTENSION - ), - "minimalPrompt": MINIMAL_PROMPT, - "promptExtension": format!(".{SOURCE_EXTENSION}"), - "sequence": [ - { - "command": "napl write example.napl < the-prompt-above", - "step": "write the prompt beside .napl, named with the .napl extension", - }, - { - "command": "napl check example.napl", - "step": "confirm the prompt parses under the same reader add uses", - }, - { - "command": "napl status --module example", - "step": "read the module's plan: owned globs, placement root, receipt requirements", - }, - { - "command": "napl add --module example", - "step": "stage the generated code as the module's output; a first add needs no attribution map", - }, - { - "command": "napl commit", - "step": "install what add staged, through the one guarded transaction", - }, - { - "command": "napl lint rust", - "forward": true, - "step": "later: lint needs a `roots` declaration in a prompt's frontmatter before it can judge reachability, and refuses without one", - }, - ], - }) -} +::: test +name: paths owned by two modules stage one record per module +given: { workspace: fixture_two_modules, working_tree_edit: both_modules_touched, add_paths: [file_of_leaf, file_of_consumer] } +expect: { staged_records: 2, modules: [consumer, leaf] } +::: -/// The workspace root as the payload names it: the directory itself, with -/// separators normalized so the bytes are stable across platforms. -fn display_path(dir: &Path) -> String { - dir.to_string_lossy().replace('\\', "/") -} -napl-store receipt v1 815 -{"baseStateKey":"87384fb9465ce6341cb179768f2b35a68b7a4f7ab1ff58a7e5cc220b82a22793","baseStoreSnapshot":"b8cc0db620cad03ea09682909ece1143c84d2837bda22513e02dd149b3b202ed","baseWorkspaceHash":"bfbe44f31259f19b8e6718707fd63473e44124a4abc7fafbfda89dd7b368e596","candidateTreeHash":"1bef0976e67d9c88366a636f94d1f838e7eed135831902d3b0f82cd98ac99f4b","contractVersion":4,"inputKey":"b7a2ef2587dc9f5032474eebcb5b828574d7bcdddd1dca697c21d4a06af68121","module":"commands","outputManifest":[{"hash":"c1a6ba3fce9918dc77114ee7fa8a2a5ed211ad39e3ffffbb76d924ca7f89e808","path":"crates/napl/src/init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","path":"crates/napl/src/watch.rs"}],"receiptVersion":1,"sliceHash":"0d1cb6037feb23824d1506966006ccfafbeb8e35d21a710db01d71604cdb6f9f","target":"rust"} -napl-store blob v1 640 -[package] -name = "napl" -version = "0.1.0" -edition = "2021" +::: test +name: a path no module owns is a usage error naming the owned globs +given: { workspace: fixture_two_modules, add_paths: [unowned_file] } +expect: { exit_code: 2, error_names_owned_globs: true, staged_records: 0 } +::: -[lib] -name = "napl" -path = "src/lib.rs" +::: test +name: a pure line-shift edit transports the whole map and needs no attribution flag +given: { workspace: fixture_two_modules, working_tree_edit: insert_lines_above_attributed_range, add: module_form_without_attribution } +expect: { add_outcome: staged, map_transported: true, ranges_shifted: true, attribution_flag_needed: false } +::: -[[bin]] -name = "napl" -path = "src/main.rs" +::: test +name: a claim in a rewritten region refuses listing that claim with its observed bound +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } +expect: { outcome: invalid_candidate, listed_claims: 1, observed_bound_present: true, untouched_claims_not_listed: true } +::: -[dependencies] -serde_json = "1" -sha2 = "0.10" -tree-sitter = "0.25" -tree-sitter-rust = "0.24" -tree-sitter-typescript = "0.23" +::: test +name: affirmations layer over the transported remainder and stage +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_affirmation_for_listed_claim } +expect: { add_outcome: staged, transported_claims_kept: true, affirmed_claim_recorded: true } +::: -# The proven store, hand-ported and living at the repository root — outside this -# derived tree, and not a NAPL module, which is why it does not appear in the -# prompt's `deps:` frontmatter. Declared as its own table so the dependency form -# says plainly that this is an out-of-tree crate, not a sibling module. -[dependencies.napl-store] -path = "../../../napl-store" -napl-store snapshot v1 781 -{"contractVersion":4,"event":"57e89f67d8f577c3d02c04f0e83ae79cad645b88ec9c2be490c8515a29c1a778","states":[{"module":"agent","state":"651686af1f5c9f3aad425d1ca7f0de325e4dc362cb59d9a71dc7d75f3216972c","target":"rust"},{"module":"commands","state":"7f362809b6d0f959bc8479adebdde987a718107d3e70636e879e44d0d4c59bb5","target":"rust"},{"module":"napl","state":"338c9b575a24883622bf3cb1e3276c24a26c31ef34a5097927420523727b6d26","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"79b5813169ed5adeb10cc35fc52c22e390bdef3e22fb2a3dd0b3a300ec844dc4","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} -napl-store transition v1 632 -{"baseStateKey":"0d9de5138a0e51434c732db16fde21ad6ecfb1f4b0f8fb2789c8b538784b1bda","contractVersion":4,"derivedProjections":[],"inputKey":"9206162a79eefbb9bbbad00daffa25e4dcbca3fb7db9b908b65fa2829a272f7e","installPaths":[],"kind":"docs-only","module":"query","newState":"37fc6230a31e6efbc940e618a1ccc6af767b5660a9961c987cc04018ac20076a","prompt":"5b749ec1e5f1229f41a4a69cc0c4529c13418d4cac1d331ef009d70086d55089","readSet":["prompt:68be37dcd4b57fdfbfcd0ac160bd5cf63e4066585d837d20a6b4fe3f4d380de9"],"surface":"326826be3fd8aaa84d4869493b7e41f886a07bea8960df79b61c87f557f4cbd0","target":"rust","writeSet":["module-state:rust/query"]} -napl-store tree v1 129 -{"entries":[{"hash":"6a5d9b61ffdaed3a3114f65cbf05beae1f376860b585901c664ada68ecbf392f","kind":"dir","mode":16384,"name":"src"}]} -napl-store blob v1 3871 -module: "usage_proxy" -target: "rust" -stale: false -entries: - - promptLines: - - 1 - - 26 - law: true - note: "one user-facing verb and one capture-only sidecar behind it; the crate is runtime I/O by nature and its telemetry is evidence, never identity" - - promptLines: - - 27 - - 62 - file: "crates/napl-usage-proxy/src/agent.rs" - lines: - - 1 - - 208 - note: "napl agent resolves the workspace root, reuses or starts the one sidecar, injects the base url the session id and the session header, inherits the terminal whole, returns the child exit code, and stops the sidecar on --stop" - - promptLines: - - 27 - - 62 - file: "crates/napl-usage-proxy/src/state.rs" - lines: - - 1 - - 158 - note: "liveness and identity live in one canonical state file written whole and claimed atomically, so racing wrappers converge on one survivor and a dead pid is replaced rather than trusted" - - promptLines: - - 27 - - 62 - file: "crates/napl-usage-proxy/src/session.rs" - lines: - - 1 - - 23 - note: "a fresh session id per run, the window an edit is later joined to its calls by" - - promptLines: - - 27 - - 62 - file: "crates/napl-usage-proxy/src/args.rs" - lines: - - 1 - - 28 - note: "the argv grammar of the verb: flags, the -- separator, and the empty command that is a usage error" - - promptLines: - - 27 - - 62 - file: "crates/napl-usage-proxy/src/main.rs" - lines: - - 1 - - 36 - note: "the binary entry: the agent verb and the sidecar it starts are one executable, so napl agent reaches this crate through its own command name" - - promptLines: - - 63 - - 81 - file: "crates/napl-usage-proxy/src/proxy.rs" - lines: - - 215 - - 292 - note: "verbatim passthrough to the one upstream origin, session header stripped and hop-by-hop headers dropped, response streamed back as received, capture never gating the call" - - promptLines: - - 63 - - 81 - file: "crates/napl-usage-proxy/src/upstream.rs" - lines: - - 1 - - 33 - note: "one upstream origin over rustls, the request path and query carried across unchanged" - - promptLines: - - 82 - - 125 - file: "crates/napl-usage-proxy/src/capture.rs" - lines: - - 1 - - 132 - note: "usage read from a top-level JSON body or merged from the message_start and message_delta events of an SSE stream, every other event ignored, failure to parse leaving usage unread" - - promptLines: - - 82 - - 125 - file: "crates/napl-usage-proxy/src/logline.rs" - lines: - - 1 - - 112 - note: "one canonical line per completed call appended whole under a gate, absent fields reported unavailable and never zero, the log append-only" - - promptLines: - - 82 - - 125 - file: "crates/napl-usage-proxy/src/canonical.rs" - lines: - - 1 - - 59 - note: "canonical JSON with recursively sorted keys, so a log line and a state file are the same bytes for the same facts" - - promptLines: - - 82 - - 125 - file: "crates/napl-usage-proxy/src/proxy.rs" - lines: - - 23 - - 122 - note: "the response body tees into the capture as it streams and settles exactly one log line when the call completes, carrying the stripped session header and never the auth material or the bodies" - - promptLines: - - 126 - - 135 - file: "crates/napl-usage-proxy/src/proxy.rs" - lines: - - 123 - - 214 - note: "the sidecar exits nonzero only on startup failure, a bind failure or an unusable log path, and never because of a bad request" - - promptLines: - - 136 - - 206 - law: true - note: "the twelve pinned cases are live tests in the hand-maintained crates/napl-usage-proxy/tests seam, covering the wrapper, the cold-start race, passthrough, capture, secrecy, and concurrent appends" -napl-store surface v1 50276 -{"contractVersion":3,"items":[{"kind":"struct","path":"attribution::Attribution","signature":"pub struct Attribution"},{"kind":"field","path":"attribution::Attribution.entries","signature":"pub entries : Vec < Entry >"},{"kind":"field","path":"attribution::Attribution.module","signature":"pub module : String"},{"kind":"field","path":"attribution::Attribution.stale","signature":"pub stale : bool"},{"kind":"field","path":"attribution::Attribution.target","signature":"pub target : String"},{"kind":"struct","path":"attribution::Entry","signature":"pub struct Entry"},{"kind":"field","path":"attribution::Entry.file","signature":"pub file : Option < String >"},{"kind":"field","path":"attribution::Entry.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"attribution::Entry.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"attribution::Entry.note","signature":"pub note : String"},{"kind":"field","path":"attribution::Entry.prompt_end","signature":"pub prompt_end : u32"},{"kind":"field","path":"attribution::Entry.prompt_start","signature":"pub prompt_start : u32"},{"kind":"method","path":"attribution::Entry::is_law","signature":"pub fn is_law ( & self ) -> bool"},{"kind":"fn","path":"attribution::parse","signature":"pub fn parse ( text : & str ) -> Option < Attribution >"},{"kind":"fn","path":"attribution::record_hash","signature":"pub fn record_hash ( attribution : & Attribution ) -> String"},{"kind":"fn","path":"attribution::remap_prefix","signature":"pub fn remap_prefix ( attribution : & mut Attribution , old_root : & str , dest : & str )"},{"kind":"fn","path":"attribution::to_yaml","signature":"pub fn to_yaml ( attribution : & Attribution ) -> Vec < u8 >"},{"kind":"fn","path":"attribution::validate","signature":"pub fn validate ( target : & TargetDef , root : & str , module : & str , candidate_files : & BTreeMap < String , Vec < u8 > > , prompt_body_lines : u32 , attribution : & Attribution ) -> Result < ( ) >"},{"kind":"fn","path":"attribution::view_path","signature":"pub fn view_path ( module : & str ) -> String"},{"kind":"fn","path":"authoring::apply_fix","signature":"pub fn apply_fix ( workspace : & Path , target : & str , finding : & Finding ) -> Result < CommitReport >"},{"kind":"fn","path":"authoring::draft_receipt","signature":"pub fn draft_receipt ( workspace : & Path , target_name : & str , module : & str , candidate_dir : & Path , usage_path : Option < & Path > ) -> Result < Value >"},{"kind":"struct","path":"blame::BlameLine","signature":"pub struct BlameLine"},{"kind":"field","path":"blame::BlameLine.line","signature":"pub line : usize"},{"kind":"field","path":"blame::BlameLine.text","signature":"pub text : String"},{"kind":"field","path":"blame::BlameLine.transition","signature":"pub transition : String"},{"kind":"struct","path":"blame::Revision","signature":"pub struct Revision"},{"kind":"field","path":"blame::Revision.content","signature":"pub content : String"},{"kind":"field","path":"blame::Revision.transition","signature":"pub transition : String"},{"kind":"fn","path":"blame::blame","signature":"pub fn blame ( revisions : & [ Revision ] ) -> Vec < BlameLine >"},{"kind":"fn","path":"blame::blame_json","signature":"pub fn blame_json ( path : & str , lines : & [ BlameLine ] ) -> Value"},{"kind":"struct","path":"candidate::Candidate","signature":"pub struct Candidate"},{"kind":"field","path":"candidate::Candidate.files","signature":"pub files : BTreeMap < String , Vec < u8 > >"},{"kind":"field","path":"candidate::Candidate.manifest","signature":"pub manifest : BTreeMap < String , String >"},{"kind":"field","path":"candidate::Candidate.tree_hash","signature":"pub tree_hash : String"},{"kind":"field","path":"candidate::Candidate.tree_objects","signature":"pub tree_objects : BTreeMap < String , Tree >"},{"kind":"enum","path":"candidate::EntryKind","signature":"pub enum EntryKind { File , Link , Device }"},{"kind":"struct","path":"candidate::Limits","signature":"pub struct Limits"},{"kind":"field","path":"candidate::Limits.max_entries","signature":"pub max_entries : usize"},{"kind":"field","path":"candidate::Limits.max_entry_bytes","signature":"pub max_entry_bytes : usize"},{"kind":"field","path":"candidate::Limits.max_total_bytes","signature":"pub max_total_bytes : usize"},{"kind":"struct","path":"candidate::ScannedEntry","signature":"pub struct ScannedEntry"},{"kind":"field","path":"candidate::ScannedEntry.bytes","signature":"pub bytes : Vec < u8 >"},{"kind":"field","path":"candidate::ScannedEntry.kind","signature":"pub kind : EntryKind"},{"kind":"field","path":"candidate::ScannedEntry.path","signature":"pub path : String"},{"kind":"fn","path":"candidate::default_limits","signature":"pub fn default_limits ( ) -> Limits"},{"kind":"fn","path":"candidate::ingest","signature":"pub fn ingest ( entries : & [ ScannedEntry ] , limits : Limits ) -> Result < Candidate >"},{"kind":"fn","path":"canon::canonical_bytes","signature":"pub fn canonical_bytes ( value : & Value ) -> Vec < u8 >"},{"kind":"fn","path":"canon::hash_json","signature":"pub fn hash_json ( kind : ObjectKind , value : & Value ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"canon::is_object_name","signature":"pub fn is_object_name ( text : & str ) -> bool"},{"kind":"fn","path":"canon::json_hash","signature":"pub fn json_hash ( kind : ObjectKind , value : & Value ) -> String"},{"kind":"fn","path":"canon::parse_json","signature":"pub fn parse_json ( bytes : & [ u8 ] , context : & str , code : OutcomeCode ) -> Result < Value >"},{"kind":"struct","path":"cli::CliResult","signature":"pub struct CliResult"},{"kind":"field","path":"cli::CliResult.exit_code","signature":"pub exit_code : i32"},{"kind":"field","path":"cli::CliResult.stdout","signature":"pub stdout : Vec < u8 >"},{"kind":"struct","path":"cli::CommandSpec","signature":"pub struct CommandSpec"},{"kind":"field","path":"cli::CommandSpec.name","signature":"pub name : & ' static str"},{"kind":"field","path":"cli::CommandSpec.run","signature":"pub run : fn ( & Path , & [ String ] ) -> Result < CliResult >"},{"kind":"fn","path":"cli::run_cli","signature":"pub fn run_cli ( cwd : & Path , args : & [ String ] ) -> CliResult"},{"kind":"fn","path":"commandset::commands","signature":"pub fn commands ( ) -> & ' static [ CommandSpec ]"},{"kind":"struct","path":"commit::CommitReport","signature":"pub struct CommitReport"},{"kind":"field","path":"commit::CommitReport.outcome","signature":"pub outcome : OutcomeCode"},{"kind":"field","path":"commit::CommitReport.payload","signature":"pub payload : Value"},{"kind":"fn","path":"commit::commit","signature":"pub fn commit ( workspace : & Path , receipt_path : & Path , candidate_dir : & Path , attribution_path : Option < & Path > , no_verify : bool ) -> CommitReport"},{"kind":"fn","path":"commit::lock_key","signature":"pub fn lock_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"commit::recover","signature":"pub fn recover ( store : & Store ) -> Result < Vec < String > >"},{"kind":"struct","path":"compile::CompileArtifact","signature":"pub struct CompileArtifact"},{"kind":"field","path":"compile::CompileArtifact.artifact_hash","signature":"pub artifact_hash : String"},{"kind":"field","path":"compile::CompileArtifact.slices","signature":"pub slices : Vec < CompileSlice >"},{"kind":"field","path":"compile::CompileArtifact.value","signature":"pub value : Value"},{"kind":"fn","path":"compile::base_output_files","signature":"pub fn base_output_files ( source : & dyn ObjectSource , state : & ModuleState ) -> Result < Vec < ( String , String ) > >"},{"kind":"fn","path":"compile::compile","signature":"pub fn compile ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , only_module : Option < & str > ) -> Result < CompileArtifact >"},{"kind":"fn","path":"compile::graph_for","signature":"pub fn graph_for ( sources : & [ SourceModule ] , target_name : & str ) -> Result < Graph >"},{"kind":"fn","path":"compile::head_states","signature":"pub fn head_states ( source : & dyn ObjectSource , snapshot : Option < & str > ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"compile::read_surface_items","signature":"pub fn read_surface_items ( source : & dyn ObjectSource , hash : & str ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"compile::slice_at","signature":"pub fn slice_at ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , module : & str , snapshot : Option < & str > ) -> Result < CompileSlice >"},{"kind":"fn","path":"compile::state_of","signature":"pub fn state_of ( source : & dyn ObjectSource , states : & BTreeMap < ( String , String ) , String > , target : & TargetDef , module : & str ) -> Result < ( ModuleState , String ) >"},{"kind":"fn","path":"compile::workspace_source_hash","signature":"pub fn workspace_source_hash ( sources : & [ SourceModule ] ) -> Result < String >"},{"kind":"struct","path":"cost::CostLine","signature":"pub struct CostLine"},{"kind":"field","path":"cost::CostLine.cost_usd","signature":"pub cost_usd : Option < String >"},{"kind":"field","path":"cost::CostLine.model","signature":"pub model : Option < String >"},{"kind":"field","path":"cost::CostLine.module","signature":"pub module : String"},{"kind":"field","path":"cost::CostLine.receipt","signature":"pub receipt : String"},{"kind":"field","path":"cost::CostLine.tokens","signature":"pub tokens : Option < TokenUsage >"},{"kind":"const","path":"cost::UNKNOWN","signature":"pub const UNKNOWN : & str = \" unknown \" ;"},{"kind":"fn","path":"cost::cost_report","signature":"pub fn cost_report ( lines : & [ CostLine ] ) -> Value"},{"kind":"fn","path":"cost::line_from_reported","signature":"pub fn line_from_reported ( receipt : & str , module : & str , reported : & Value ) -> Result < CostLine >"},{"kind":"fn","path":"cost::pricing_provenance","signature":"pub fn pricing_provenance ( ) -> Value"},{"kind":"struct","path":"decision::Arriving","signature":"pub struct Arriving"},{"kind":"field","path":"decision::Arriving.base_snapshot","signature":"pub base_snapshot : Option < String >"},{"kind":"field","path":"decision::Arriving.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Arriving.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Arriving.write_set","signature":"pub write_set : BTreeSet < String >"},{"kind":"enum","path":"decision::Decision","signature":"pub enum Decision { Install , Rebase , StaleInput { expected : String , found : String } , BaseOutputChanged { expected : String , found : String } , WriteConflict { overlap : Vec < String > } }"},{"kind":"struct","path":"decision::Head","signature":"pub struct Head"},{"kind":"field","path":"decision::Head.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Head.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Head.intervening_write_set","signature":"pub intervening_write_set : BTreeSet < String >"},{"kind":"field","path":"decision::Head.snapshot","signature":"pub snapshot : Option < String >"},{"kind":"fn","path":"decision::conflicts","signature":"pub fn conflicts ( write_set_a : & BTreeSet < String > , read_set_a : & BTreeSet < String > , write_set_b : & BTreeSet < String > , changed_identities_b : & BTreeSet < String > ) -> bool"},{"kind":"fn","path":"decision::decide","signature":"pub fn decide ( arriving : & Arriving , head : & Head ) -> Decision"},{"kind":"fn","path":"decision::is_accepted","signature":"pub fn is_accepted ( decision : & Decision ) -> bool"},{"kind":"fn","path":"decision::rejection","signature":"pub fn rejection ( decision : & Decision ) -> Option < CompilerError >"},{"kind":"struct","path":"directive::DirectiveCase","signature":"pub struct DirectiveCase"},{"kind":"field","path":"directive::DirectiveCase.expect","signature":"pub expect : Value"},{"kind":"field","path":"directive::DirectiveCase.given","signature":"pub given : Value"},{"kind":"field","path":"directive::DirectiveCase.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"directive::DirectiveCase.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"directive::DirectiveCase.name","signature":"pub name : String"},{"kind":"fn","path":"directive::extract_cases","signature":"pub fn extract_cases ( normalized_body : & str ) -> Result < Vec < DirectiveCase > >"},{"kind":"fn","path":"directive::extract_validated","signature":"pub fn extract_validated ( normalized_body : & str , frontmatter_names : & [ String ] ) -> Result < Vec < DirectiveCase > >"},{"kind":"fn","path":"directive::normalize_case_name","signature":"pub fn normalize_case_name ( name : & str ) -> String"},{"kind":"fn","path":"directive::parse_inline_value","signature":"pub fn parse_inline_value ( text : & str ) -> Option < Value >"},{"kind":"const","path":"disk::POINTER_LOCK","signature":"pub const POINTER_LOCK : & str = \" pointer \" ;"},{"kind":"const","path":"disk::STORE_DIR","signature":"pub const STORE_DIR : & str = \" .napl/store \" ;"},{"kind":"struct","path":"disk::Store","signature":"pub struct Store"},{"kind":"field","path":"disk::Store.root","signature":"pub root : PathBuf"},{"kind":"field","path":"disk::Store.workspace","signature":"pub workspace : PathBuf"},{"kind":"fn","path":"disk::compare_and_set_head","signature":"pub fn compare_and_set_head ( store : & Store , expected : Option < & str > , next : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::delete_marker","signature":"pub fn delete_marker ( store : & Store , snapshot : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::init","signature":"pub fn init ( workspace : & Path ) -> Result < Store >"},{"kind":"fn","path":"disk::io","signature":"pub fn io ( path : & Path , err : & std :: io :: Error ) -> CompilerError"},{"kind":"fn","path":"disk::marker_snapshots","signature":"pub fn marker_snapshots ( store : & Store ) -> Result < Vec < String > >"},{"kind":"fn","path":"disk::open","signature":"pub fn open ( workspace : & Path ) -> Store"},{"kind":"fn","path":"disk::publish","signature":"pub fn publish ( store : & Store , kind : ObjectKind , payload : & [ u8 ] , owner : & str ) -> Result < String >"},{"kind":"fn","path":"disk::read_marker","signature":"pub fn read_marker ( store : & Store , snapshot : & str ) -> Result < Option < CommitMarker > >"},{"kind":"fn","path":"disk::release_lock","signature":"pub fn release_lock ( guard : LockGuard ) -> Result < ( ) >"},{"kind":"fn","path":"disk::remove_object","signature":"pub fn remove_object ( store : & Store , name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::take_lock","signature":"pub fn take_lock ( store : & Store , name : & str ) -> Result < LockGuard >"},{"kind":"fn","path":"disk::write_marker","signature":"pub fn write_marker ( store : & Store , marker : & CommitMarker ) -> Result < ( ) >"},{"kind":"struct","path":"error::CompilerError","signature":"pub struct CompilerError"},{"kind":"field","path":"error::CompilerError.code","signature":"pub code : OutcomeCode"},{"kind":"field","path":"error::CompilerError.detail","signature":"pub detail : String"},{"kind":"enum","path":"error::OutcomeCode","signature":"pub enum OutcomeCode { Success , StaleInput , BaseOutputChanged , WriteConflict , LockContended , InvalidCandidate , InvalidReceipt , MappingIntegrity , ResourceLimit , UnsupportedVersion , StoreIntegrity , IoError }"},{"kind":"type","path":"error::Result","signature":"pub type Result < T > = std :: result :: Result < T , CompilerError > ;"},{"kind":"fn","path":"error::error","signature":"pub fn error ( code : OutcomeCode , detail : impl Into < String > ) -> CompilerError"},{"kind":"fn","path":"error::error_payload","signature":"pub fn error_payload ( err : & CompilerError , blobs_published : bool , provenance_published : bool ) -> Value"},{"kind":"fn","path":"error::outcome_exit_code","signature":"pub fn outcome_exit_code ( code : OutcomeCode ) -> i32"},{"kind":"fn","path":"error::outcome_is_retryable","signature":"pub fn outcome_is_retryable ( code : OutcomeCode ) -> bool"},{"kind":"fn","path":"error::outcome_slug","signature":"pub fn outcome_slug ( code : OutcomeCode ) -> & ' static str"},{"kind":"const","path":"evidence::CLAUDE_CLI_JSON","signature":"pub const CLAUDE_CLI_JSON : & str = \" application/vnd.claude-cli+json \" ;"},{"kind":"struct","path":"evidence::EvidenceItem","signature":"pub struct EvidenceItem"},{"kind":"field","path":"evidence::EvidenceItem.body","signature":"pub body : String"},{"kind":"field","path":"evidence::EvidenceItem.media_type","signature":"pub media_type : String"},{"kind":"enum","path":"evidence::EvidenceOutcome","signature":"pub enum EvidenceOutcome { Validated , Unsupported , Invalid , ValidatorError }"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEMS","signature":"pub const MAX_EVIDENCE_ITEMS : usize = 8 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEM_BYTES","signature":"pub const MAX_EVIDENCE_ITEM_BYTES : usize = 64 * 1024 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_TOTAL_BYTES","signature":"pub const MAX_EVIDENCE_TOTAL_BYTES : usize = 256 * 1024 ;"},{"kind":"fn","path":"evidence::check_evidence_bounds","signature":"pub fn check_evidence_bounds ( items : & [ EvidenceItem ] ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::check_reported_bounds","signature":"pub fn check_reported_bounds ( reported : & Value , limit : usize ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::evidence_report","signature":"pub fn evidence_report ( items : & [ ( EvidenceItem , EvidenceOutcome ) ] ) -> Value"},{"kind":"fn","path":"evidence::evidence_slug","signature":"pub fn evidence_slug ( outcome : EvidenceOutcome ) -> & ' static str"},{"kind":"fn","path":"evidence::parse_evidence","signature":"pub fn parse_evidence ( reported : & Value ) -> Result < Vec < EvidenceItem > >"},{"kind":"fn","path":"evidence::registered_media_types","signature":"pub fn registered_media_types ( ) -> Vec < String >"},{"kind":"fn","path":"evidence::validate_all","signature":"pub fn validate_all ( items : & [ EvidenceItem ] ) -> Vec < ( EvidenceItem , EvidenceOutcome ) >"},{"kind":"fn","path":"evidence::validate_evidence","signature":"pub fn validate_evidence ( item : & EvidenceItem ) -> EvidenceOutcome"},{"kind":"type","path":"graph::Graph","signature":"pub type Graph = BTreeMap < String , SourceModule > ;"},{"kind":"fn","path":"graph::build_graph","signature":"pub fn build_graph ( sources : & [ SourceModule ] , target : & TargetDef ) -> Result < Graph >"},{"kind":"fn","path":"graph::changed_frontier","signature":"pub fn changed_frontier ( graph : & Graph , committed_input_keys : & BTreeMap < String , Option < String > > , input_keys : & BTreeMap < String , String > , only_module : Option < & str > ) -> Result < Vec < String > >"},{"kind":"fn","path":"graph::dependents_of","signature":"pub fn dependents_of ( graph : & Graph , module : & str ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::index_sources","signature":"pub fn index_sources ( sources : & [ SourceModule ] ) -> Result < BTreeMap < String , SourceModule > >"},{"kind":"fn","path":"graph::reachable_from","signature":"pub fn reachable_from ( graph : & Graph , roots : & [ String ] ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::topological_levels","signature":"pub fn topological_levels ( graph : & Graph ) -> Result < BTreeMap < String , usize > >"},{"kind":"enum","path":"lint::Analysis","signature":"pub enum Analysis { Performed , Refused }"},{"kind":"enum","path":"lint::Finding","signature":"pub enum Finding { UnreachableModule { module : String , cascade : Vec < String > } , UnconsumedSurfaceEntry { module : String , path : String } , StaleModule { module : String } , UnusedDependency { module : String , dep : String } }"},{"kind":"struct","path":"lint::LintInput","signature":"pub struct LintInput"},{"kind":"field","path":"lint::LintInput.committed_input_keys","signature":"pub committed_input_keys : BTreeMap < String , Option < String > >"},{"kind":"field","path":"lint::LintInput.current_input_keys","signature":"pub current_input_keys : BTreeMap < String , String >"},{"kind":"field","path":"lint::LintInput.referenced_names","signature":"pub referenced_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"field","path":"lint::LintInput.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"lint::LintInput.surface_names","signature":"pub surface_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"struct","path":"lint::LintReport","signature":"pub struct LintReport"},{"kind":"field","path":"lint::LintReport.findings","signature":"pub findings : Vec < Finding >"},{"kind":"field","path":"lint::LintReport.unreachable_analysis","signature":"pub unreachable_analysis : Analysis"},{"kind":"fn","path":"lint::finding_json","signature":"pub fn finding_json ( finding : & Finding ) -> Value"},{"kind":"fn","path":"lint::is_auto_fixable","signature":"pub fn is_auto_fixable ( finding : & Finding ) -> bool"},{"kind":"fn","path":"lint::lint","signature":"pub fn lint ( graph : & Graph , input : & LintInput ) -> LintReport"},{"kind":"fn","path":"lint::lint_json","signature":"pub fn lint_json ( report : & LintReport ) -> Value"},{"kind":"fn","path":"lsp::serve","signature":"pub fn serve < R : BufRead , W : Write > ( reader : & mut R , writer : & mut W )"},{"kind":"struct","path":"maintenance::FileFact","signature":"pub struct FileFact"},{"kind":"field","path":"maintenance::FileFact.hash","signature":"pub hash : String"},{"kind":"field","path":"maintenance::FileFact.path","signature":"pub path : String"},{"kind":"enum","path":"maintenance::FsckFinding","signature":"pub enum FsckFinding { HashMismatch { object : String , detail : String } , MissingParent { event : String , parent : String } , MissingObject { from : String , missing : String } }"},{"kind":"enum","path":"maintenance::RestorePlan","signature":"pub enum RestorePlan { Apply ( Vec < RestoreStep > ) , Blocked { path : String , candidates : Vec < String > } }"},{"kind":"enum","path":"maintenance::RestoreStep","signature":"pub enum RestoreStep { Move { from : String , to : String } , Write { path : String , hash : String } , Remove { path : String } }"},{"kind":"fn","path":"maintenance::fsck","signature":"pub fn fsck ( source : & dyn ObjectSource ) -> Result < Vec < FsckFinding > >"},{"kind":"fn","path":"maintenance::fsck_json","signature":"pub fn fsck_json ( findings : & [ FsckFinding ] ) -> Value"},{"kind":"fn","path":"maintenance::gc_json","signature":"pub fn gc_json ( swept : & [ String ] , kept : usize ) -> Value"},{"kind":"fn","path":"maintenance::plan_restore","signature":"pub fn plan_restore ( expected : & [ FileFact ] , observed : & [ FileFact ] ) -> RestorePlan"},{"kind":"fn","path":"maintenance::plan_sweep","signature":"pub fn plan_sweep ( all : & BTreeSet < String > , reachable : & BTreeSet < String > ) -> Vec < String >"},{"kind":"fn","path":"maintenance::reachable_from_head","signature":"pub fn reachable_from_head ( source : & dyn ObjectSource , head : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"maintenance::restore_json","signature":"pub fn restore_json ( plan : & RestorePlan ) -> Value"},{"kind":"fn","path":"mv::relative_dir","signature":"pub fn relative_dir ( from : & str , to : & str ) -> String"},{"kind":"fn","path":"mv::relocate_files","signature":"pub fn relocate_files ( files : & BTreeMap < String , Vec < u8 > > , old_root : & str , dest : & str ) -> Result < BTreeMap < String , Vec < u8 > > >"},{"kind":"fn","path":"mv::relocate_module","signature":"pub fn relocate_module ( workspace : & Path , target_name : & str , module : & str , dest : & str ) -> CommitReport"},{"kind":"struct","path":"objects::AttributionState","signature":"pub struct AttributionState"},{"kind":"field","path":"objects::AttributionState.prompt_hash","signature":"pub prompt_hash : String"},{"kind":"field","path":"objects::AttributionState.record","signature":"pub record : String"},{"kind":"field","path":"objects::AttributionState.stale","signature":"pub stale : bool"},{"kind":"struct","path":"objects::Footprint","signature":"pub struct Footprint"},{"kind":"field","path":"objects::Footprint.derived_projections","signature":"pub derived_projections : Vec < String >"},{"kind":"field","path":"objects::Footprint.install_paths","signature":"pub install_paths : Vec < String >"},{"kind":"field","path":"objects::Footprint.read_set","signature":"pub read_set : Vec < String >"},{"kind":"field","path":"objects::Footprint.write_set","signature":"pub write_set : Vec < String >"},{"kind":"struct","path":"objects::ModuleState","signature":"pub struct ModuleState"},{"kind":"field","path":"objects::ModuleState.attribution","signature":"pub attribution : Option < AttributionState >"},{"kind":"field","path":"objects::ModuleState.input_key","signature":"pub input_key : Option < String >"},{"kind":"field","path":"objects::ModuleState.module","signature":"pub module : String"},{"kind":"field","path":"objects::ModuleState.output_tree","signature":"pub output_tree : String"},{"kind":"field","path":"objects::ModuleState.slice_hash","signature":"pub slice_hash : Option < String >"},{"kind":"field","path":"objects::ModuleState.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"objects::ModuleState.target","signature":"pub target : String"},{"kind":"struct","path":"objects::Transition","signature":"pub struct Transition"},{"kind":"field","path":"objects::Transition.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"objects::Transition.footprint","signature":"pub footprint : Footprint"},{"kind":"field","path":"objects::Transition.input_key","signature":"pub input_key : String"},{"kind":"field","path":"objects::Transition.module","signature":"pub module : String"},{"kind":"field","path":"objects::Transition.new_state","signature":"pub new_state : String"},{"kind":"field","path":"objects::Transition.receipt","signature":"pub receipt : String"},{"kind":"field","path":"objects::Transition.surface","signature":"pub surface : String"},{"kind":"field","path":"objects::Transition.target","signature":"pub target : String"},{"kind":"fn","path":"objects::empty_module_state","signature":"pub fn empty_module_state ( module : & str , target : & str , empty_tree : & str ) -> ModuleState"},{"kind":"fn","path":"objects::event_hash","signature":"pub fn event_hash ( content : & str , parents : & [ String ] ) -> String"},{"kind":"fn","path":"objects::event_json","signature":"pub fn event_json ( content : & str , parents : & [ String ] ) -> Value"},{"kind":"fn","path":"objects::event_transition","signature":"pub fn event_transition ( source : & dyn ObjectSource , event : & str ) -> Result < String >"},{"kind":"fn","path":"objects::module_state_hash","signature":"pub fn module_state_hash ( state : & ModuleState ) -> String"},{"kind":"fn","path":"objects::module_state_json","signature":"pub fn module_state_json ( state : & ModuleState ) -> Value"},{"kind":"fn","path":"objects::ownership_write_key","signature":"pub fn ownership_write_key ( root : & str ) -> String"},{"kind":"fn","path":"objects::read_module_state","signature":"pub fn read_module_state ( source : & dyn ObjectSource , hash : & str ) -> Result < ModuleState >"},{"kind":"fn","path":"objects::snapshot_event","signature":"pub fn snapshot_event ( source : & dyn ObjectSource , snapshot : & str ) -> Result < String >"},{"kind":"fn","path":"objects::snapshot_hash","signature":"pub fn snapshot_hash ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> String"},{"kind":"fn","path":"objects::snapshot_json","signature":"pub fn snapshot_json ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> Value"},{"kind":"fn","path":"objects::snapshot_members","signature":"pub fn snapshot_members ( states : & BTreeMap < ( String , String ) , String > , target : & str ) -> Vec < String >"},{"kind":"fn","path":"objects::snapshot_states","signature":"pub fn snapshot_states ( source : & dyn ObjectSource , snapshot : & str ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"objects::state_write_key","signature":"pub fn state_write_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"objects::transition_hash","signature":"pub fn transition_hash ( transition : & Transition ) -> String"},{"kind":"fn","path":"objects::transition_json","signature":"pub fn transition_json ( transition : & Transition ) -> Value"},{"kind":"fn","path":"objects::transition_write_set","signature":"pub fn transition_write_set ( source : & dyn ObjectSource , transition : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"objects::walk_events","signature":"pub fn walk_events ( source : & dyn ObjectSource , event : & str , until : Option < & str > ) -> Result < Vec < String > >"},{"kind":"const","path":"paths::MAX_PATH_LEN","signature":"pub const MAX_PATH_LEN : usize = 1024 ;"},{"kind":"const","path":"paths::MAX_SEGMENT_LEN","signature":"pub const MAX_SEGMENT_LEN : usize = 128 ;"},{"kind":"fn","path":"paths::check_canonical_path","signature":"pub fn check_canonical_path ( path : & str , code : OutcomeCode ) -> Result < ( ) >"},{"kind":"fn","path":"paths::decode_name","signature":"pub fn decode_name ( segment : & str ) -> Option < String >"},{"kind":"fn","path":"paths::encode_name","signature":"pub fn encode_name ( name : & str ) -> String"},{"kind":"fn","path":"paths::is_under","signature":"pub fn is_under ( path : & str , prefix : & str ) -> bool"},{"kind":"fn","path":"paths::normalize_path_key","signature":"pub fn normalize_path_key ( path : & str ) -> String"},{"kind":"fn","path":"porcelain::add_note","signature":"pub fn add_note ( workspace : & Path , transition : & str , note : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::blame_path","signature":"pub fn blame_path ( workspace : & Path , path : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::cat_file","signature":"pub fn cat_file ( workspace : & Path , hash : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"porcelain::causal_order","signature":"pub fn causal_order ( store : & Store , head : & str ) -> Result < Vec < String > >"},{"kind":"fn","path":"porcelain::committed_attribution_yaml","signature":"pub fn committed_attribution_yaml ( store : & Store , target : & str , module : & str ) -> Result < Option < Vec < u8 > > >"},{"kind":"fn","path":"porcelain::committed_manifest","signature":"pub fn committed_manifest ( store : & Store , target : & str ) -> Result < BTreeMap < String , String > >"},{"kind":"fn","path":"porcelain::committed_state","signature":"pub fn committed_state ( store : & Store , target : & str , module : & str ) -> Result < Option < ModuleState > >"},{"kind":"fn","path":"porcelain::cost","signature":"pub fn cost ( workspace : & Path , names : & [ String ] , files : & [ String ] ) -> Result < Value >"},{"kind":"fn","path":"porcelain::diff","signature":"pub fn diff ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::fsck_store","signature":"pub fn fsck_store ( workspace : & Path ) -> Result < ( bool , Value ) >"},{"kind":"fn","path":"porcelain::gc","signature":"pub fn gc ( workspace : & Path , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::lint_workspace","signature":"pub fn lint_workspace ( workspace : & Path , target : & str ) -> Result < ( Graph , LintReport , Value ) >"},{"kind":"fn","path":"porcelain::log","signature":"pub fn log ( workspace : & Path , module : Option < & str > ) -> Result < Value >"},{"kind":"fn","path":"porcelain::module_plan","signature":"pub fn module_plan ( workspace : & Path , target_name : & str , module : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::restore","signature":"pub fn restore ( workspace : & Path , target : & str , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::show","signature":"pub fn show ( workspace : & Path , hash : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::status","signature":"pub fn status ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::surface_of","signature":"pub fn surface_of ( workspace : & Path , target : & str , module : & str ) -> Result < Value >"},{"kind":"const","path":"receipt::MAX_REPORTED_BYTES","signature":"pub const MAX_REPORTED_BYTES : usize = 512 * 1024 ;"},{"kind":"const","path":"receipt::RECEIPT_VERSION","signature":"pub const RECEIPT_VERSION : u32 = 1 ;"},{"kind":"struct","path":"receipt::Receipt","signature":"pub struct Receipt"},{"kind":"field","path":"receipt::Receipt.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"receipt::Receipt.base_store_snapshot","signature":"pub base_store_snapshot : Option < String >"},{"kind":"field","path":"receipt::Receipt.base_workspace_hash","signature":"pub base_workspace_hash : String"},{"kind":"field","path":"receipt::Receipt.candidate_tree_hash","signature":"pub candidate_tree_hash : String"},{"kind":"field","path":"receipt::Receipt.evidence","signature":"pub evidence : Vec < EvidenceItem >"},{"kind":"field","path":"receipt::Receipt.input_key","signature":"pub input_key : String"},{"kind":"field","path":"receipt::Receipt.module","signature":"pub module : String"},{"kind":"field","path":"receipt::Receipt.output_manifest","signature":"pub output_manifest : BTreeMap < String , String >"},{"kind":"field","path":"receipt::Receipt.receipt_version","signature":"pub receipt_version : u32"},{"kind":"field","path":"receipt::Receipt.reported","signature":"pub reported : Value"},{"kind":"field","path":"receipt::Receipt.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"receipt::Receipt.target","signature":"pub target : String"},{"kind":"const","path":"receipt::UNAVAILABLE","signature":"pub const UNAVAILABLE : & str = \" unavailable \" ;"},{"kind":"fn","path":"receipt::normalize_reported","signature":"pub fn normalize_reported ( value : & Value ) -> Value"},{"kind":"fn","path":"receipt::parse_receipt","signature":"pub fn parse_receipt ( bytes : & [ u8 ] ) -> Result < Receipt >"},{"kind":"fn","path":"receipt::receipt_hash","signature":"pub fn receipt_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_json","signature":"pub fn receipt_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::receipt_semantic_hash","signature":"pub fn receipt_semantic_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_semantic_json","signature":"pub fn receipt_semantic_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::reported_usage","signature":"pub fn reported_usage ( receipt : & Receipt ) -> Value"},{"kind":"struct","path":"slice::CompileSlice","signature":"pub struct CompileSlice"},{"kind":"field","path":"slice::CompileSlice.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"slice::CompileSlice.body","signature":"pub body : Value"},{"kind":"field","path":"slice::CompileSlice.input_key","signature":"pub input_key : String"},{"kind":"field","path":"slice::CompileSlice.module","signature":"pub module : String"},{"kind":"field","path":"slice::CompileSlice.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"slice::CompileSlice.target","signature":"pub target : String"},{"kind":"field","path":"slice::CompileSlice.wave","signature":"pub wave : usize"},{"kind":"struct","path":"slice::DepSurface","signature":"pub struct DepSurface"},{"kind":"field","path":"slice::DepSurface.items","signature":"pub items : Vec < SurfaceItem >"},{"kind":"field","path":"slice::DepSurface.module","signature":"pub module : String"},{"kind":"field","path":"slice::DepSurface.surface_hash","signature":"pub surface_hash : String"},{"kind":"fn","path":"slice::build_slice","signature":"pub fn build_slice ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] , base : & ModuleState , base_state_key : & str , base_files : & [ ( String , String ) ] , wave : usize ) -> CompileSlice"},{"kind":"fn","path":"slice::closure_manifest","signature":"pub fn closure_manifest ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> Value"},{"kind":"fn","path":"slice::input_key","signature":"pub fn input_key ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> String"},{"kind":"fn","path":"slice::receipt_requirements","signature":"pub fn receipt_requirements ( ) -> Value"},{"kind":"fn","path":"slice::recompute_slice_hash","signature":"pub fn recompute_slice_hash ( body : & Value ) -> String"},{"kind":"const","path":"source::MAX_SOURCE_BYTES","signature":"pub const MAX_SOURCE_BYTES : usize = 4 * 1024 * 1024 ;"},{"kind":"const","path":"source::SOURCE_EXTENSION","signature":"pub const SOURCE_EXTENSION : & str = \" napl \" ;"},{"kind":"struct","path":"source::SourceModule","signature":"pub struct SourceModule"},{"kind":"field","path":"source::SourceModule.deps","signature":"pub deps : Vec < String >"},{"kind":"field","path":"source::SourceModule.directive_cases","signature":"pub directive_cases : Vec < crate :: directive :: DirectiveCase >"},{"kind":"field","path":"source::SourceModule.frontmatter_cases","signature":"pub frontmatter_cases : Vec < serde_json :: Value >"},{"kind":"field","path":"source::SourceModule.module","signature":"pub module : String"},{"kind":"field","path":"source::SourceModule.normalized_body","signature":"pub normalized_body : String"},{"kind":"field","path":"source::SourceModule.normalized_source_hash","signature":"pub normalized_source_hash : String"},{"kind":"field","path":"source::SourceModule.path","signature":"pub path : String"},{"kind":"field","path":"source::SourceModule.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"source::SourceModule.targets","signature":"pub targets : Vec < String >"},{"kind":"field","path":"source::SourceModule.tests","signature":"pub tests : Vec < String >"},{"kind":"fn","path":"source::check_module_name","signature":"pub fn check_module_name ( name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"source::normalize_body","signature":"pub fn normalize_body ( raw : & str ) -> String"},{"kind":"fn","path":"source::parse_source","signature":"pub fn parse_source ( path : & str , text : & str ) -> Result < SourceModule >"},{"kind":"fn","path":"source::rewrite_deps","signature":"pub fn rewrite_deps ( text : & str , deps : & [ String ] ) -> Result < String >"},{"kind":"const","path":"staging::STAGING_DIR","signature":"pub const STAGING_DIR : & str = \" .napl/staging \" ;"},{"kind":"fn","path":"staging::add","signature":"pub fn add ( workspace : & Path , target_name : & str , module : & str , candidate_dir : & Path , attribution_path : Option < & Path > , usage_path : Option < & Path > , no_verify : bool ) -> Result < Value >"},{"kind":"fn","path":"staging::commit_staged","signature":"pub fn commit_staged ( workspace : & Path , record : & Path ) -> CommitReport"},{"kind":"trait","path":"store_view::ObjectSource","signature":"pub trait ObjectSource { fn envelope ( & self , name : & str ) -> Result < Option < Vec < u8 > > > ; fn head ( & self ) -> Result < Option < String > > ; fn names ( & self ) -> Result < Vec < String > > ; }"},{"kind":"fn","path":"store_view::flatten_tree","signature":"pub fn flatten_tree ( source : & dyn ObjectSource , root : & str ) -> Result < BTreeMap < String , ( String , u32 ) > >"},{"kind":"fn","path":"store_view::get_blob","signature":"pub fn get_blob ( source : & dyn ObjectSource , name : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"store_view::get_json","signature":"pub fn get_json ( source : & dyn ObjectSource , name : & str , expected : ObjectKind ) -> Result < Value >"},{"kind":"fn","path":"store_view::get_object","signature":"pub fn get_object ( source : & dyn ObjectSource , name : & str ) -> Result < ( ObjectKind , Vec < u8 > ) >"},{"kind":"fn","path":"store_view::get_tree","signature":"pub fn get_tree ( source : & dyn ObjectSource , name : & str ) -> Result < Tree >"},{"kind":"fn","path":"store_view::has_object","signature":"pub fn has_object ( source : & dyn ObjectSource , name : & str ) -> bool"},{"kind":"fn","path":"store_view::load_tree_objects","signature":"pub fn load_tree_objects ( source : & dyn ObjectSource , root : & str , out : & mut BTreeMap < String , Tree > ) -> Result < ( ) >"},{"kind":"struct","path":"surface::SurfaceItem","signature":"pub struct SurfaceItem"},{"kind":"field","path":"surface::SurfaceItem.kind","signature":"pub kind : String"},{"kind":"field","path":"surface::SurfaceItem.path","signature":"pub path : String"},{"kind":"field","path":"surface::SurfaceItem.signature","signature":"pub signature : String"},{"kind":"fn","path":"surface::empty_surface_hash","signature":"pub fn empty_surface_hash ( module : & str , target : & str ) -> String"},{"kind":"fn","path":"surface::extract_surface","signature":"pub fn extract_surface ( target : & TargetDef , root : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"surface::surface_hash","signature":"pub fn surface_hash ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> String"},{"kind":"fn","path":"surface::surface_items_from_json","signature":"pub fn surface_items_from_json ( value : & Value ) -> Vec < SurfaceItem >"},{"kind":"fn","path":"surface::surface_json","signature":"pub fn surface_json ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> Value"},{"kind":"struct","path":"target::TargetDef","signature":"pub struct TargetDef"},{"kind":"field","path":"target::TargetDef.entry_point_version","signature":"pub entry_point_version : u32"},{"kind":"field","path":"target::TargetDef.manifest_file","signature":"pub manifest_file : String"},{"kind":"field","path":"target::TargetDef.name","signature":"pub name : String"},{"kind":"field","path":"target::TargetDef.source_dir","signature":"pub source_dir : String"},{"kind":"field","path":"target::TargetDef.source_extension","signature":"pub source_extension : String"},{"kind":"fn","path":"target::builtin_target","signature":"pub fn builtin_target ( name : & str ) -> Result < TargetDef >"},{"kind":"fn","path":"target::builtin_target_names","signature":"pub fn builtin_target_names ( ) -> Vec < String >"},{"kind":"fn","path":"target::derive_lib_projection","signature":"pub fn derive_lib_projection ( target : & TargetDef , root : & str , file_paths : & [ String ] , command_providers : & [ String ] ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"target::is_manifest_path","signature":"pub fn is_manifest_path ( target : & TargetDef , path : & str ) -> bool"},{"kind":"fn","path":"target::is_owned_path","signature":"pub fn is_owned_path ( target : & TargetDef , root : & str , path : & str ) -> bool"},{"kind":"fn","path":"target::owned_globs","signature":"pub fn owned_globs ( _target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::placement_facts","signature":"pub fn placement_facts ( target : & TargetDef , root : & str ) -> Value"},{"kind":"fn","path":"target::placement_root","signature":"pub fn placement_root ( source : & SourceModule ) -> String"},{"kind":"fn","path":"target::projection_owned_paths","signature":"pub fn projection_owned_paths ( target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::surface_grammar","signature":"pub fn surface_grammar ( target : & TargetDef ) -> Value"},{"kind":"fn","path":"target::target_def_hash","signature":"pub fn target_def_hash ( target : & TargetDef ) -> String"},{"kind":"fn","path":"target::target_def_json","signature":"pub fn target_def_json ( target : & TargetDef ) -> Value"},{"kind":"enum","path":"textdiff::Edit","signature":"pub enum Edit { Keep ( usize , usize ) , Delete ( usize ) , Insert ( usize ) }"},{"kind":"fn","path":"textdiff::diff_lines","signature":"pub fn diff_lines ( old : & [ String ] , new : & [ String ] ) -> Vec < Edit >"},{"kind":"fn","path":"textdiff::lines","signature":"pub fn lines ( text : & str ) -> Vec < String >"},{"kind":"fn","path":"textdiff::render_drift","signature":"pub fn render_drift ( committed : & BTreeMap < String , String > , working : & BTreeMap < String , String > ) -> String"},{"kind":"fn","path":"textdiff::render_patch","signature":"pub fn render_patch ( path : & str , old : & str , new : & str ) -> String"},{"kind":"struct","path":"transaction::Accepted","signature":"pub struct Accepted"},{"kind":"field","path":"transaction::Accepted.event","signature":"pub event : String"},{"kind":"field","path":"transaction::Accepted.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::Accepted.projection_bytes","signature":"pub projection_bytes : Vec < u8 >"},{"kind":"field","path":"transaction::Accepted.projection_path","signature":"pub projection_path : String"},{"kind":"field","path":"transaction::Accepted.rebased","signature":"pub rebased : bool"},{"kind":"field","path":"transaction::Accepted.snapshot","signature":"pub snapshot : String"},{"kind":"field","path":"transaction::Accepted.steps","signature":"pub steps : Vec < InstallStep >"},{"kind":"struct","path":"transaction::BuiltAttribution","signature":"pub struct BuiltAttribution"},{"kind":"field","path":"transaction::BuiltAttribution.state","signature":"pub state : AttributionState"},{"kind":"field","path":"transaction::BuiltAttribution.view_path","signature":"pub view_path : String"},{"kind":"field","path":"transaction::BuiltAttribution.yaml","signature":"pub yaml : Vec < u8 >"},{"kind":"struct","path":"transaction::DerivedCommit","signature":"pub struct DerivedCommit"},{"kind":"field","path":"transaction::DerivedCommit.attribution_install","signature":"pub attribution_install : Option < ( String , String ) >"},{"kind":"field","path":"transaction::DerivedCommit.attribution_status","signature":"pub attribution_status : String"},{"kind":"field","path":"transaction::DerivedCommit.evidence","signature":"pub evidence : Value"},{"kind":"field","path":"transaction::DerivedCommit.new_state_key","signature":"pub new_state_key : String"},{"kind":"field","path":"transaction::DerivedCommit.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::DerivedCommit.receipt_object","signature":"pub receipt_object : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_items","signature":"pub surface_items : Vec < SurfaceItem >"},{"kind":"field","path":"transaction::DerivedCommit.transition","signature":"pub transition : Transition"},{"kind":"field","path":"transaction::DerivedCommit.transition_id","signature":"pub transition_id : String"},{"kind":"struct","path":"transaction::PendingObject","signature":"pub struct PendingObject"},{"kind":"field","path":"transaction::PendingObject.kind","signature":"pub kind : ObjectKind"},{"kind":"field","path":"transaction::PendingObject.name","signature":"pub name : String"},{"kind":"field","path":"transaction::PendingObject.payload","signature":"pub payload : Vec < u8 >"},{"kind":"fn","path":"transaction::accept","signature":"pub fn accept ( source : & dyn ObjectSource , target : & TargetDef , root : & str , candidate : & Candidate , derived : & DerivedCommit , head : Option < & str > , base_snapshot : Option < & str > ) -> Result < Accepted >"},{"kind":"fn","path":"transaction::check_candidate_shape","signature":"pub fn check_candidate_shape ( target : & TargetDef , root : & str , candidate : & Candidate ) -> Result < ( ) >"},{"kind":"fn","path":"transaction::derive_commit","signature":"pub fn derive_commit ( target : & TargetDef , root : & str , receipt : & Receipt , candidate : & Candidate , built : Option < & BuiltAttribution > ) -> Result < DerivedCommit >"},{"kind":"fn","path":"transaction::intervening_write_set","signature":"pub fn intervening_write_set ( source : & dyn ObjectSource , head : Option < & str > , base : Option < & str > ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"transaction::success_payload","signature":"pub fn success_payload ( derived : & DerivedCommit , accepted : & Accepted ) -> Value"},{"kind":"fn","path":"transaction::verify_receipt","signature":"pub fn verify_receipt ( source : & dyn ObjectSource , sources : & [ SourceModule ] , receipt : & Receipt , candidate : & Candidate ) -> Result < CompileSlice >"},{"kind":"fn","path":"trees::empty_tree_hash","signature":"pub fn empty_tree_hash ( ) -> String"},{"kind":"fn","path":"trees::tree_of","signature":"pub fn tree_of ( manifest : & BTreeMap < String , String > ) -> Result < BuiltTree >"},{"kind":"const","path":"version::CONTRACT_VERSION","signature":"pub const CONTRACT_VERSION : u32 = 3 ;"},{"kind":"fn","path":"workspace::apply_step","signature":"pub fn apply_step ( root : & Path , step : & InstallStep , payload : & dyn Fn ( & str ) -> Result < Vec < u8 > > ) -> Result < ( ) >"},{"kind":"fn","path":"workspace::discover_sources","signature":"pub fn discover_sources ( root : & Path ) -> Result < Vec < SourceModule > >"},{"kind":"fn","path":"workspace::find_workspace_root","signature":"pub fn find_workspace_root ( start : & Path ) -> Option < PathBuf >"},{"kind":"fn","path":"workspace::read_working_text","signature":"pub fn read_working_text ( root : & Path , paths : & [ String ] ) -> BTreeMap < String , String >"},{"kind":"fn","path":"workspace::require_workspace_root","signature":"pub fn require_workspace_root ( start : & Path ) -> Result < PathBuf >"},{"kind":"fn","path":"workspace::scan_candidate","signature":"pub fn scan_candidate ( dir : & Path ) -> Result < Vec < ScannedEntry > >"},{"kind":"fn","path":"workspace::step_path","signature":"pub fn step_path ( step : & InstallStep ) -> & str"},{"kind":"fn","path":"workspace::write_file","signature":"pub fn write_file ( path : & Path , bytes : & [ u8 ] ) -> Result < ( ) >"}],"module":"napl","target":"rust"} -napl-store tree v1 507 -{"entries":[{"hash":"3b3b46d4f8f832a2b67e59d481fce3269061f0c79cb335d3ddc5a51d8a1ce915","kind":"file","mode":33188,"name":"check.rs"},{"hash":"b5e4e2782fa3e7b35714d43c6a5dd578e6adc40a53e872bce582dd3d76dc27c2","kind":"file","mode":33188,"name":"document.rs"},{"hash":"2e84007b600de3257549b12e35b45e779d85751c59dd2ec7db7abde7ecd7e4c2","kind":"file","mode":33188,"name":"promptbuild.rs"},{"hash":"ed4e1767d9f257439d2fe1a157104d2bbe511b8296bacd6027ba490122386f3f","kind":"file","mode":33188,"name":"query.rs"}]} -napl-store snapshot v1 891 -{"contractVersion":4,"event":"38eaa9d6b15a1bb30970cfd4c3c2f98fab73f4a40ce774a7d622cbadc210ac55","states":[{"module":"agent","state":"b5cde0e371a87ce3810de7c7b7af9e48bf453094540fce927bde21dde6b576b9","target":"rust"},{"module":"commands","state":"74f67c7538797091e6d19bd6f87099d0b414743cc4cc151db667619ff4a48d03","target":"rust"},{"module":"napl","state":"76f91323327cf2f0a66946473c1176b3988c304d57f2c6c67626991dd449cdd1","target":"rust"},{"module":"napl_store","state":"ede6f68b1d885a44d4fc2effd563a93d50c5b5ad8c31cdf2f07bb0f849109d1e","target":"rust"},{"module":"query","state":"2ceea09f82f0700bbe48909b0c56aa0e69cc477dd51287bd88cd2e87f6f7a1e1","target":"rust"},{"module":"serve","state":"8b31e47aad33789775fb32ae56868e37db7a9dd174e6df3a0262781a7ed2f6ae","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} -napl-store event v1 158 -{"content":"c21927fa38d31d70135585bace74db198943f9507fc0bd009aaadf340ab1a815","parents":["513fdbb16b9f7a1e1793f2c320111b2dc34e4559013c25d2c4ec92f7b2bb6e66"]} -napl-store blob v1 987 -module: "query" -target: "rust" -stale: false -entries: - - promptLines: - - 1 - - 14 - file: "crates/napl/src/query.rs" - lines: - - 1 - - 373 - note: "the query module owns napl q and napl prompt and the canonical document they share" - - promptLines: - - 15 - - 94 - file: "crates/napl/src/document.rs" - lines: - - 1 - - 381 - note: "the canonical document docVersion 1 built purely from committed records and the working tree, totally ordered, no absolute paths" - - promptLines: - - 95 - - 149 - file: "crates/napl/src/query.rs" - lines: - - 1 - - 373 - note: "napl q shaped exactly like jq via the embedded jaq engine, impure builtins refused, 64 MiB output ceiling" - - promptLines: - - 150 - - 229 - file: "crates/napl/src/promptbuild.rs" - lines: - - 1 - - 439 - note: "napl prompt reads typed refs and materializes prose and code from committed blobs at the refs snapshot, with law closure" -napl-store module-state v1 372 -{"contractVersion":1,"inputKey":"ca5b1967770c28629e18ac06fa023b37ce9888f8c1304234652ea5f6dfed7217","module":"napl","outputTree":"edca14b88b8b1c49625f4a6c1445716b16ec09c67fc22a01136492500d7e9f46","sliceHash":"1d43d63f59c9d3409fe15244f8fca6a6948084aafa206f9e21e8bcc8865b16a8","surfaceHash":"854dc3f816957d3a40ee1d16be7b44fb2a161a33e4919cccb846046979cdcca5","target":"rust"} -napl-store module-state v1 640 -{"attribution":{"promptHash":"85eab36f412936d247f59dc688116dd31246d27c0b86a7b3df52829d27e2d90a","record":"bcd92cb1f2335798a1c44ba0949c437b8273a798a03e253f20e17a93567d000b","stale":false},"contractVersion":4,"inputKey":"865433045eb8c750a39bd7d29f2e8631c96a03d6aedad8f19ea9907551bdc927","module":"napl_store","outputTree":"85a337713f60111fece6cdda3168eed43966c95adb1458d324acba9336110b24","prompt":"51bb437ce9ebb45c4381ed2f33805b71f99b6952618f1f1e3332108f5daf519c","sliceHash":"771bf0c5336c4db5e9304a3bbf42b4632bccbe537b40611140822a1eab5c5302","surfaceHash":"50b745f71f63d7fd5b8a05fdf84474524004af8f75d47f8346b7404f99981fab","target":"rust"} -napl-store module-state v1 558 -{"attribution":{"promptHash":"2b806ccda147575f59a8fc847b2656b26af400aa3cc7cf8733ae2c5f34ea402c","record":"72fa7c252c24654c4b70b21f44048c170b79b60a2c535fda6b57d6aeb0b6ce0f","stale":false},"contractVersion":4,"inputKey":"f0d2db36f12c77e49df8b1ef4d3ebed5546c7a4b89f1d5e1173d8064aab73494","module":"napl","outputTree":"52cf17c04dccef9f3e7106db729cf1555210c1726fdeb087e94651e7e60674df","sliceHash":"c4a38efe120fb6088316ad1073e6862e7f91170e6a33c7aaa00817f2a06620d2","surfaceHash":"0508c952df26ea2ffe5ab27a5dafc05d843d76948f77370745ec28cdba6d5260","target":"rust"} -napl-store tree v1 4679 -{"entries":[{"hash":"1a5de49301e4c6d47619ec0e44eab914b1846735830b07483c031e6a8c3dc67b","kind":"file","mode":33188,"name":"attribution.rs"},{"hash":"e539c65a4600ed1784b9cc176cb28f24c140f514f9783360f1f5a27142f89f68","kind":"file","mode":33188,"name":"authoring.rs"},{"hash":"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2","kind":"file","mode":33188,"name":"blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","kind":"file","mode":33188,"name":"candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","kind":"file","mode":33188,"name":"canon.rs"},{"hash":"0b8190e7989129e7baf4eed7046e19b7608067eb5d13bed95ad416118bf34a25","kind":"file","mode":33188,"name":"capture.rs"},{"hash":"085f7e2d366c1756ff8f4e7bfdccbb8139c49779fd8ab00f91e3d6c787569c23","kind":"file","mode":33188,"name":"cli.rs"},{"hash":"abad4283136e7fd0e880f49148d5422d673d5d1e4267976fd2a3dec47d7f34bd","kind":"file","mode":33188,"name":"commandset.rs"},{"hash":"ed1c68648258a7ac19741b966c321db9c100222da858a64cc0604b108b7998fa","kind":"file","mode":33188,"name":"commit.rs"},{"hash":"3c0db9b5d179e2f3b7123cd8e08844ea9ad28a0a6ffbc7e5794738709f5af1f4","kind":"file","mode":33188,"name":"compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","kind":"file","mode":33188,"name":"cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","kind":"file","mode":33188,"name":"decision.rs"},{"hash":"04a648a127be983c89a99181ee26e28401ee00f84ea0b10b6c050ecd263a1b2c","kind":"file","mode":33188,"name":"directive.rs"},{"hash":"ada150c613fba5947ada47cc6e694d518c13a89626ed80bf665ed3a9b2f373b6","kind":"file","mode":33188,"name":"disk.rs"},{"hash":"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812","kind":"file","mode":33188,"name":"error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","kind":"file","mode":33188,"name":"evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","kind":"file","mode":33188,"name":"graph.rs"},{"hash":"824c95b7229ac2ad5b1cc518b296443883fb56a110c86c2a2e67923c0a19ea07","kind":"file","mode":33188,"name":"lint.rs"},{"hash":"40689b2560d6c445afd552c2f470ba3ce0d427622d96ed2cf2ad8cfdb4286321","kind":"file","mode":33188,"name":"lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","kind":"file","mode":33188,"name":"main.rs"},{"hash":"6fc1b58dad295d780c917c10ac0f793667e46c455349cff3d42e6855501a91bd","kind":"file","mode":33188,"name":"maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","kind":"file","mode":33188,"name":"mv.rs"},{"hash":"3ae089b923327032845c386f2332ff653a08f25ccec28e0786094fa05fd77a00","kind":"file","mode":33188,"name":"objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","kind":"file","mode":33188,"name":"paths.rs"},{"hash":"23047facf0e4f522af446d5b1947d683c2248f6d1075f8ff6034c407fe597095","kind":"file","mode":33188,"name":"porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","kind":"file","mode":33188,"name":"receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","kind":"file","mode":33188,"name":"slice.rs"},{"hash":"a24eb1b3fc59a87a34e7f343c3396da60352ece50b6688c36b04da55351f1312","kind":"file","mode":33188,"name":"source.rs"},{"hash":"7d1bba27fd84f0edae130f1902b00abab524e3a67d0428b1a2fe7a2c01af5273","kind":"file","mode":33188,"name":"staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","kind":"file","mode":33188,"name":"store_view.rs"},{"hash":"1820f647bb5dbd74c8db7f5519d404ef0ce1520c833a579aaca893b5cad7ba08","kind":"file","mode":33188,"name":"surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","kind":"file","mode":33188,"name":"target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","kind":"file","mode":33188,"name":"textdiff.rs"},{"hash":"ca46ca78bcb2940f23d3aede83fdee2d10b6910dfb0eaa9de6a8ca83a2209427","kind":"file","mode":33188,"name":"transaction.rs"},{"hash":"d4ef5888a7ecc406d8eb5173dc215c7b1a1ef1e1ddd3ab8b3a8aa6639d125017","kind":"file","mode":33188,"name":"transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","kind":"file","mode":33188,"name":"trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","kind":"file","mode":33188,"name":"version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","kind":"file","mode":33188,"name":"workspace.rs"}]} -napl-store blob v1 27243 -use std::io::Write as _; -use std::path::Path; +::: test +name: add stages a valid candidate and commit installs it +given: { workspace: fixture_two_modules, candidate: valid_with_attribution } +expect: { add_outcome: staged, commit_outcome: success } +::: -use serde_json::{json, Value}; +::: test +name: an explicit candidate whose transport is incomplete and unaffirmed fails loudly +given: { workspace: fixture_two_modules, candidate: rewritten_region_without_affirmation } +expect: { outcome: invalid_candidate, listed_claims_present: true } +::: -use crate::authoring::apply_fix; -use crate::canon::canonical_bytes; -use crate::cli::{ - clap_root, emit_prerequisite, flag, has_switch, ok, positional, positionals_from, repeated_flag, - require_flag, resolve_workspace, switch, usage, value, CliResult, CommandSpec, FlagSpec, - USAGE_EXIT_CODE, WORKSPACE_FLAG, -}; -use crate::commit::recover; -use crate::disk; -use crate::error::{outcome_exit_code, OutcomeCode, Result}; -use crate::lint::{is_auto_fixable, lint_json, refusal_json, Analysis}; -use crate::mv::relocate_module; -use crate::porcelain::{ - add_note, blame_path, cat_file, cost, diff, fsck_store, gc, lint_workspace, log, module_plan, - restore, show, status, surface_of, -}; +::: test +name: a candidate with a parent-escaping path is rejected as invalid-candidate +given: { workspace: fixture_two_modules, candidate: contains_dotdot_path } +expect: { outcome: invalid_candidate, published_semantic_state: false } +::: -/// Only `--workspace`: the command reads the workspace and nothing else. -const WORKSPACE_ONLY: &[FlagSpec] = &[WORKSPACE_FLAG]; +::: test +name: tampering a staged blob after ingestion never mixes into the install +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, tamper: candidate_file_after_ingest } +expect: { outcome_in: [installed_from_immutable_blobs, store_integrity], mixed_content: false } +::: -const ADD_FLAGS: &[FlagSpec] = &[ - value( - "--attribution", - "path to a map affirming the claims the transport listed; entries may spell an endpoint `whole`", - ), - switch( - "--check", - "run the whole pipeline including the transport verdict and write nothing at all", - ), - value("--module", "the module the candidate is output for"), - switch("--no-verify", "stage without the acceptance verification pass"), - value("--target", "the target to stage for, default rust"), - value("--usage", "path to the usage envelope recorded as provenance"), - WORKSPACE_FLAG, -]; +`napl commit --staged ` is the one guarded +transaction over what add staged. Pipeline: verify the staged record +(recompute sliceHash, inputKey, baseStateKey at the recorded bases, +reject mismatches, enforce evidence bounds); extract and record the +surface; derive the complete transition (three-part footprint, member-set +delta, shared projections recomputed from the merged head); stage all +materialization outside any lock; then one bounded critical section under +the pointer lock performing only the footprint recheck and the pointer +and directory swaps, bounded by the transition's file count, never +hashing content inside the lock. -const COMMIT_FLAGS: &[FlagSpec] = &[ - value( - "--staged", - "path to one staged record `napl add` reported; without it every staged record installs in wave order", - ), - WORKSPACE_FLAG, -]; +::: test +name: a crash between staged materialization and pointer swap leaves a deterministic recovery plan +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, crash_after: staging } +expect: { store_readable: true, recovery_plan_deterministic: true, semantic_state_unchanged: true } +::: -const STATUS_FLAGS: &[FlagSpec] = &[ - value( - "--module", - "report one module's plan, live state, and derived entry-file projection instead of the workspace", - ), - WORKSPACE_FLAG, -]; +::: test +name: a receipt whose recomputed slice hash disagrees is rejected as invalid-receipt +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: tampered_slice_hash } +expect: { outcome: invalid_receipt, published_semantic_state: false } +::: -const LOG_FLAGS: &[FlagSpec] = &[ - value("--module", "limit the history to one module"), - WORKSPACE_FLAG, -]; +The decision table, judged per arriving transition against the current +head: identities reproduce with head unmoved installs; head moved with +identities reproducing and footprints disjoint rebases then installs +(projections re-derived, never reused); inputKey failure returns +`stale-input`; baseStateKey failure returns `base-output-changed`; semantic +write overlap returns `write-conflict`; lock contention returns +`lock-contended`. Terminal outcomes, never retried unchanged: +`invalid-candidate`, `invalid-receipt`, `mapping-integrity`, +`resource-limit`, `unsupported-version`, `store-integrity`, `io-error`. +Every outcome is a canonical machine-readable JSON payload naming whether +blobs or provenance were published; no outcome publishes semantic state +partially. Exit codes: 0 success, 10 through 13 for the four returned +concurrency outcomes in table order, 20 through 26 for the seven terminal +outcomes in the order above. Staged records carry `receiptVersion`, the +per-module identities generated against, base snapshot hashes, the +candidate tree hash, the output manifest, then optional reported fields; +the compiler recomputes everything derivable and rejects on mismatch; +absent telemetry is `unavailable`, never zero. `napl notes add --to +` attaches later provenance without touching semantic state. -const MODULE_FLAGS: &[FlagSpec] = &[ - value("--module", "the module to read"), - WORKSPACE_FLAG, -]; +::: test +name: commit installs a valid candidate against an unmoved head +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, head: unmoved } +expect: { outcome: installed, head_advanced: true, surface_recorded: true } +::: -const LINT_FLAGS: &[FlagSpec] = &[ - switch("--fix", "apply the auto-fixable findings through the guarded commit path"), - WORKSPACE_FLAG, -]; +::: test +name: commit against a moved head with disjoint footprints rebases and installs +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: to_unrelated_module } +expect: { outcome: installed, rebased: true } +::: -const COST_FLAGS: &[FlagSpec] = &[ - value("--file", "an extra receipt file to price, repeatable"), - value("--session", "price one session's calls from the log, repeatable"), - WORKSPACE_FLAG, -]; +::: test +name: commit whose direct dep surface moved underneath returns stale-input +given: { workspace: fixture_chain_three, candidate: valid_for_middle, intervening_commit: leaf_surface_change } +expect: { outcome: stale_input, head_advanced: false } +::: -const APPLY_FLAGS: &[FlagSpec] = &[ - switch("--apply", "apply the plan instead of only reporting it"), - WORKSPACE_FLAG, -]; +::: test +name: commit whose own outputs moved underneath returns base-output-changed +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: same_module_other_candidate } +expect: { outcome: base_output_changed } +::: -const NOTES_FLAGS: &[FlagSpec] = &[ - value("--note", "the note text to attach"), - value("--to", "the transition the note attaches to"), - WORKSPACE_FLAG, -]; +::: test +name: two concurrent disjoint commits both land and neither touches a manifest +given: { workspace: fixture_two_modules, concurrent_candidates: [module_a, module_b], orders: [ab, ba] } +expect: { both_installed: true, manifest_bytes_unchanged: true } +::: -const MV_FLAGS: &[FlagSpec] = &[ - value("--target", "the target the module is placed for, default rust"), - WORKSPACE_FLAG, -]; +::: test +name: "absent telemetry reads as unavailable, never zero" +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: without_usage } +expect: { usage: unavailable } +::: -const EDIT_FLAGS: &[FlagSpec] = &[ - value("--new-string", "the replacement text"), - value("--old-string", "the exact text to replace"), - value( - "--ref", +Attribution rides the staged record; there is no other write path for it. +`napl add --attribution ` carries the module's mapping of prompt body +line ranges to generated file line ranges with notes; add validates every +claim before staging: each file path inside the module's owned globs, +each code line range present in the candidate tree, each prompt line +range present in the current normalized prompt body; a mapping that lies +is `invalid-candidate`. An entry may own no file at all: contract-level +prose — a law — that governs the module rather than any single path; +validation accepts a file-less entry (its prompt range must still exist) +and the query layer serves it flagged as law. An add whose transport is +incomplete and whose affirmations do not cover the listed claims fails +loudly as `invalid-candidate`, naming exactly which claims are missing; +`napl add --no-verify` is the explicit escape hatch that stages anyway +and records the module's attribution as stale on install. Any prompt +edit, code transition, or move that changes what a recorded mapping +points at marks it stale the same way; nothing ever repairs a mapping +automatically. `napl status` reports attribution freshness per +module (`fresh`, `stale`, `absent`) and, for stale or absent, emits a +machine-readable directive naming the exact fix: commit with a fresh +attribution for that module. Relocation recomputes only the path prefix +of a mapping, never its line content. The lsp serves a stale mapping +marked stale rather than hiding it; absent answers empty. + +An attribution entry is a reference, and a reference has a direction: +prompt-to-code, a prompt body line range naming generated file line +ranges, or code-to-prompt, a generated file line range naming a prompt +body line range. Every entry recorded before directions existed reads as +prompt-to-code; migration is a reading, never a rewrite of stored +records. Validation is identical for both directions and referential +only, the same claims checked the same way, and nothing about a +direction implies the sentence describes the code: a reference is a +claim with provenance and freshness, and the record vocabulary never +says more. + +A reference carries a provenance block, recorded by the compiler at +staging time from what the caller supplied: recording model identity, +timestamp, tool version, and an optional usage-envelope reference +through the existing evidence seam. Every absent field is `unavailable`, +never zero, never invented, and provenance is display and pricing +metadata that never enters any identity, exactly as telemetry already +behaves. The query layer and the lsp serve the provenance block with the +entry. + +Staleness covers both ends of a reference, and working-tree drift is one +of the ways an end changes: a reference whose code side no longer +matches the committed bytes, or whose prompt side changed in the working +tree, reads stale on both endpoints, computed at read time from the +records and the working tree, never stored as a transition. A hand edit +to generated source is drift like any other: legal, visible through the +same status and diff machinery, healable by restore or by re-recording +the reference; nothing repairs a reference automatically and nothing +polices the edit that staled it. + +::: test +name: a code-to-prompt entry stages and is served with its direction +given: { workspace: fixture_two_modules, candidate: valid_with_code_to_prompt_reference } +expect: { add_outcome: staged, served_direction: code_to_prompt } +::: + +::: test +name: entries recorded before directions read as prompt-to-code unchanged +given: { workspace: fixture_two_modules, history: [pre_direction_attribution] } +expect: { served_direction: prompt_to_code, stored_records_rewritten: false } +::: + +::: test +name: provenance is served with the entry and absent fields read unavailable +given: { workspace: fixture_two_modules, candidate: reference_without_usage_envelope } +expect: { provenance_served: true, usage: unavailable, cost_component: unavailable } +::: + +::: test +name: a hand edit to the code side reads stale on both endpoints +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } +expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } +::: + +::: test +name: a working-tree prompt edit reads stale on both endpoints +given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched } +expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } +::: + +::: test +name: a code-to-prompt reference naming a missing prompt range is invalid-candidate +given: { workspace: fixture_two_modules, candidate: code_to_prompt_reference_to_missing_range } +expect: { outcome: invalid_candidate, published_semantic_state: false } +::: + +## edit and write: the capture verbs + +`napl edit` and `napl write` are the deterministic capture layer in +front of the same guarded door. They carry the familiar tool +signatures: `napl edit --old-string --new-string +[--replace-all]` replaces an exact occurrence in a file, and +`napl write ` writes stdin as the file's full content; its usage +line says the content comes from stdin, and an empty stdin refuses as +a usage error naming `--empty`, the explicit flag that authorizes +writing a zero-byte file, so a forgotten pipe can never silently +truncate. Either +side is editable, a prompt or a generated file, by a human or an LLM; +a hand edit through an ordinary editor stays equally legal, and the +verbs add capture, never exclusivity. The verbs never synthesize or +rewrite prose beyond the bytes given, never invoke a model, never +decide placement, never repair a reference, and never bypass the +guard: `add` and `commit` remain exactly what they are. + +Each call, deterministically and in this order: validate, apply, +capture. When the target is a prompt file, the result of the edit is +validated first by the same reader `check` and `add` use; a result +that fails validation refuses as `prompt-invalid` and applies nothing. +An `--old-string` that matches nowhere, matches more than once +without `--replace-all`, or equals `--new-string` refuses as +`edit-mismatch` and applies nothing, so a no-op edit can never mint a +record. Only a call that will succeed touches the working tree, so +every refusal leaves every file byte-identical. + +A call may carry one reference: `--ref `, a single object naming +a direction and both endpoints, prompt body line range and generated +file line range, in either direction per the reference contract above. +The reference is validated referentially only, exactly as strong as +attribution validation and no stronger: the file path inside the +owning module's placement, both line ranges present in the +post-edit bytes of their files. A reference naming a missing line or +a path outside placement refuses as `reference-unresolvable` and +applies nothing. A valid reference-bearing call stages a +reference-only record and commits it through the guarded transaction +in the same invocation: the transition carries the reference entry and +its provenance, no output-tree change and no surface change, and every +rule of the decision table, journaled atomicity, and recovery applies +to it unchanged. A call without `--ref` is a plain edit: apply, no +record, no store touch. Any existing reference whose endpoint the edit +moved needs no marking, because both-ends staleness is already +computed at read time from the working tree; the verbs store nothing +about it. + +Provenance rides the session window: `--session ` names the +usage-proxy session whose log lines are this call's evidence, and when +the flag is absent the verb reads the `NAPL_SESSION` environment +variable, so a call made under `napl agent` carries its window with no +extra argument. The recorded usage reference is the window itself, +media type, workspace-relative log path, session id, and the line span +observed at staging, priced lazily by `napl cost` at read time, never +aggregated at record time. Model identity and timestamp come from the +window's log lines, or read `unavailable` when there is no window; +the verbs read no clock, so the same call over the same working tree, +store state, and log bytes produces byte-identical records. Absent is +`unavailable`, never zero, never invented, and provenance never +enters identity. + +The three refusals join the closed terminal-outcome table under the +contract bump to 4, pinned exit codes continuing the table order: +`edit-mismatch` 27, `reference-unresolvable` 28, `prompt-invalid` 29. +None reuses `mapping-integrity`, and each is a canonical +machine-readable JSON payload naming what failed and that nothing was +applied or published. + +::: test +name: an edit with a valid code-to-prompt reference stages and commits in one call +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } +expect: { outcome: installed, served_direction: code_to_prompt, output_tree_changed: false } +::: + +::: test +name: the same edit call twice produces byte-identical records +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, runs_from_same_base: 2 } +expect: { records_byte_identical: true } +::: + +::: test +name: a reference to a missing line refuses as reference-unresolvable and applies nothing +given: { workspace: fixture_two_modules, edit: ref_names_missing_line } +expect: { outcome: reference_unresolvable, exit_code: 28, files_byte_identical: true } +::: + +::: test +name: a prompt edit that breaks the prompt refuses as prompt-invalid and applies nothing +given: { workspace: fixture_two_modules, edit: prompt_edit_breaking_directive } +expect: { outcome: prompt_invalid, exit_code: 29, files_byte_identical: true } +::: + +::: test +name: an ambiguous old-string without replace-all refuses as edit-mismatch +given: { workspace: fixture_two_modules, edit: old_string_matches_twice } +expect: { outcome: edit_mismatch, exit_code: 27, files_byte_identical: true } +::: + +::: test +name: a prose-only edit needs no reference and touches no store state +given: { workspace: fixture_two_modules, edit: plain_prompt_edit_valid } +expect: { applied: true, store_bytes_unchanged: true } +::: + +::: test +name: write with empty stdin refuses naming the empty flag and writes nothing +given: { workspace: fixture_two_modules, write: target_with_empty_stdin } +expect: { exit_code: 2, error_names_flag: "--empty", files_byte_identical: true } +::: + +::: test +name: an edit touching a referenced line reads stale on both endpoints afterward +given: { workspace: fixture_two_modules, edit: plain_edit_on_referenced_code_line } +expect: { reference_status: stale, stale_endpoints: [prompt, code] } +::: + +::: test +name: a session window from the environment is recorded as the usage reference +given: { workspace: fixture_two_modules, env: { NAPL_SESSION: s1 }, log: proxy_log_with_s1_lines, edit: referenced_code_edit_with_ref } +expect: { provenance_usage_session: s1, usage_span_recorded: true, aggregated_at_record: false } +::: + +::: test +name: an edit with no session window records provenance unavailable +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, env: {} } +expect: { provenance_model: unavailable, provenance_usage: unavailable } +::: + +## The rest of the porcelain + +`napl status` observational classification, zero mutation, and it +judges both ends: per module it reports `drift: true` when the +committed output differs from the working tree, and the module's stale +reference count, the same derivations diff and the lsp already serve, +so status can never read clean while the query surface reads stale. +A module fresh on the prompt side and drifted on the code side says +so in one payload. `napl log` +per-module or store-wide history. `napl show ` inspect one object. +`napl diff` drift between committed outputs and working tree as a canonical +byte-stable patch. `napl blame ` derived ancestry: surviving lines +trace through accepted patches by canonical replay; rewritten regions blame +the rewriting transition, honestly coarse. `napl lint` unreachable modules +under an explicit `roots` declaration; when roots are absent the refusal +is loud, exit 2 with canonical JSON naming the missing prerequisite and +the exact declaration that would satisfy it, never an empty findings +list under exit 0; unconsumed surface entries (diagnosis only), module staleness; +findings are proposals with cascade previews; `napl lint --fix` applies +only complete deterministic mapping-safe edits through the guarded commit +path and never removes lines. `napl cost` pure calculation over an explicit +receipt set and a pinned pricing-table provenance object; unknown is +unknown, never zero. Edits price the way generations price: records the +capture verbs staged enter the same receipt set, their session-window +usage references resolve at read time against the named log's bytes, +summing the window's lines under the same pinned pricing table, and +`napl cost --session ` reports one session's calls directly from +the log. A record without a window prices `unavailable`; a window whose +log file or session lines are absent prices `unavailable` and says +which; a model the pricing table does not name prices unknown; nothing +is ever silently zero and nothing is aggregated at record time. The +same receipt set, log bytes, and pricing table produce byte-identical +reports. `napl gc` stages sweeps outside locks, swaps pointers +inside the bounded section, never blocks writers, never deletes anything +reachable from a named head. `napl fsck` envelope hashes, kind validity, +causal parent links. `napl restore` deterministic repair plans from +persisted evidence; unambiguous moves install, ambiguity blocks; liveness +and wall time never decide recovery. `napl mv ` relocation +as a semantic operation: moves a module's installed files to a new +placement, recomputes every placement-derived fact (derived entry-file +projections, attribution file paths) from the destination, passes +through the guarded commit path, and re-verifies there; failed +verification at the destination installs nothing; manifests are the +user's to update, and status diagnoses a placement the workspace +manifest no longer lists. A move made outside `napl mv` is drift like any other: +the store still holds the committed bytes, `napl restore` re-materializes +the module at its recorded placement, and when the drifted content matches +a committed state exactly the plan may instead adopt the new placement +through the commit path; anything ambiguous blocks. `napl cat-file ` +raw plumbing. `napl lsp` a language server over stdio for editor +extensions: hover, definition, references, and code lens over the +prompt-to-code mapping. Everything it serves derives from committed +records (attribution entries, module surfaces, the ir) resolved against +the workspace root; hover on a prompt line presents the attributed +generated spans with their notes, hover on a generated line presents the +owning prompt lines and note, definition jumps across the mapping in both +directions, references enumerates every attributed span, code lens counts +attributed files per prompt section. The server publishes diagnostics +derived from reference staleness: for every stale reference it emits one +diagnostic on each endpoint, the prompt span whose code moved and the +code span whose prompt changed, so sync debt is visible where the edit +happens, like a type error for documentation. Publication is a pure +derivation from committed records plus the working tree at the moment +the protocol asks, no background mutation, no clocks, no scanning loops +beyond what the editor protocol requires; opening or changing a +document refreshes that document's diagnostics and its counterparts'. +Severity is warning, never error: drift is a legal state, marked, not +policed. The diagnostic message states exactly what is known and +nothing more: which end drifted, the reference's direction, recorded +model or `unavailable`, referentially valid, and the healing verbs +(re-record through `napl edit`, or restore); no wording implies the +sentence describes the code. A fresh reference publishes nothing. +Resolving is always a human or LLM re-affirming or re-pointing the +reference through the capture verbs; the server proposes nothing and +repairs nothing. Pure reads only: no mutation, no +clocks, no background scanning. A request whose records are missing or +unparseable answers empty rather than erroring or blocking, and missing +or unparseable records degrade to no diagnostics, never an error; a stale +mapping is served marked stale, consistent with the attribution contract; +the server never hangs on a malformed or absent record. Record resolution is +anchored per document: walk up from the document's filesystem path to the +nearest ancestor directory containing `.napl` and read records there; the +server's working directory and the client's rootUri carry no meaning. +Every location the server emits that points at a module's prompt resolves +to the prompt file's actual placement found by workspace prompt discovery, +where the prompt sits inside the crate directory its code lands in; a +module name is never joined against the workspace root to fabricate a +prompt path, and a module whose prompt cannot be discovered answers empty. +Crash safety: every persisted application step leaves markers such that +recovery derives a deterministic plan from content alone. Evidence +validators are a registry keyed by media type (Claude CLI JSON envelope +ships first, and `application/x-napl-usage-proxy-jsonl-v1` validates +usage-proxy logs: one canonical JSON object per line, required fields +present, absent values the string `unavailable`, token counts +non-negative integers when present, a line failing the shape is +`invalid`); outcomes `validated`, `unsupported`, `invalid`, +`validator-error`; invalid stays visible as failed. Evidence bounds are +compiled-in constants; exceeding any is `resource-limit` before anything +publishes. + +::: test +name: a stale reference publishes one diagnostic on each endpoint +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, lsp: [open_prompt, open_code] } +expect: { prompt_diagnostics: 1, code_diagnostics: 1, severity: warning } +::: + +::: test +name: a fresh reference publishes nothing +given: { workspace: fixture_two_modules, lsp: [open_prompt, open_code] } +expect: { prompt_diagnostics: 0, code_diagnostics: 0 } +::: + +::: test +name: "the diagnostic message carries drifted end, direction, and provenance and nothing semantic" +given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched, lsp: [open_code] } +expect: { message_names_drifted_end: true, message_names_direction: true, message_model_or_unavailable: true, message_semantic_claims: 0 } +::: + +::: test +name: absent or unparseable records publish no diagnostics and answer empty +given: { workspace: fixture_two_modules, records: attribution_file_corrupted, lsp: [open_prompt] } +expect: { diagnostics: 0, error_responses: 0, server_alive: true } +::: + +::: test +name: committing the drifted code clears both endpoints' diagnostics on refresh +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, then: commit_module, lsp: [change_code_document] } +expect: { prompt_diagnostics: 0, code_diagnostics: 0 } +::: + +::: test +name: diff renders drift between committed output and working tree as a canonical patch +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched } +expect: { diff_nonempty: true, canonical_patch: true, byte_stable: true } +::: + +::: test +name: "blame over a whole-file regeneration coarsens to the transition, line ancestry survives accepted patches" +given: { workspace: fixture_two_modules, history: [patch_commit, full_rewrite_commit] } +expect: { patched_lines_trace_to_patch: true, rewritten_region_blames_transition: true } +::: + +::: test +name: lint reports an unreachable module only under declared roots and refuses without them +given: { workspace: fixture_orphan_module, roots: absent } +expect: { unreachable_analysis: refused, exit_code: 2, reason_names_roots: true, findings_field_absent: true } +::: + +::: test +name: lint with declared roots proposes removal of the orphan with a cascade preview and never auto-applies +given: { workspace: fixture_orphan_module, roots: [main_module] } +expect: { findings: [unreachable_napl], auto_applied: false, cascade_preview: true } +::: + +::: test +name: lint --fix drops an unreferenced declared dependency through the guarded commit path +given: { workspace: fixture_unused_dep, fix: drop_unused_dep } +expect: { outcome: installed, transition_recorded: true, dep_removed: true } +::: + +::: test +name: a cost report over a receipt set containing verb-staged records includes the edits +given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines } +expect: { report_rows: 2, edit_row_priced: true, same_pricing_table: true } +::: + +::: test +name: an edit without a window and a window with missing log lines both price unavailable naming why +given: { workspace: fixture_two_modules, receipts: [edit_without_window, edit_with_window_to_missing_lines] } +expect: { priced_unavailable: 2, reason_named_per_row: true, zeros: 0 } +::: + +::: test +name: a model absent from the pricing table prices unknown never zero +given: { workspace: fixture_two_modules, receipts: [edit_with_unknown_model_window], log: proxy_log_unknown_model } +expect: { priced_unknown: 1, zeros: 0 } +::: + +::: test +name: cost reports are byte-identical across runs over the same receipts log and table +given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines, runs: 2 } +expect: { bytes_equal: true } +::: + +::: test +name: cost session reports one session's calls directly from the log +given: { workspace: fixture_two_modules, log: proxy_log_two_sessions, session: s1 } +expect: { rows_for_session: 2, rows_for_other_sessions: 0, priced_from_log: true } +::: + +::: test +name: a proxy log line failing the shape validates invalid and stays visible as failed +given: { evidence: proxy_log_with_malformed_line, media_type: application_x_napl_usage_proxy_jsonl_v1 } +expect: { outcome: invalid, visible_as_failed: true } +::: + +::: test +name: cost over one receipt set and pricing version is byte-identical across runs and unknown for unknown models +given: { receipts: fixture_receipts, pricing_version: pinned, runs: 2, unknown_model_receipt: included } +expect: { reports_equal: true, unknown_model_cost: unknown } +::: + +::: test +name: gc never deletes an object reachable from the current head and never blocks a concurrent commit +given: { workspace: fixture_two_modules, concurrent: [gc, valid_commit] } +expect: { reachable_deleted: false, commit_blocked: false, unreachable_swept: true } +::: + +::: test +name: fsck detects a corrupted object and a broken parent link +given: { workspace: fixture_two_modules, corrupt: [flip_byte_in_object, remove_parent_event] } +expect: { findings: [hash_mismatch, missing_parent], exit_nonzero: true } +::: + +::: test +name: restore applies an unambiguous move-repair plan and blocks on ambiguity +given: { workspace: fixture_moved_output, ambiguity: [none, two_candidates] } +expect: { unambiguous: installed, ambiguous: blocked } +::: + +::: test +name: hover on an attributed prompt line resolves its generated spans +given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: within_entry } } +expect: { spans_resolved: true, notes_included: true } +::: + +::: test +name: "hover on an unattributed line answers empty, not error" +given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: outside_entries } } +expect: { result: empty, error: false } +::: + +::: test +name: missing attribution records degrade to empty responses +given: { attribution: absent, request: hover } +expect: { result: empty, error: false, blocked: false } +::: + +::: test +name: definition from a generated line targets the prompt at its discovered placement +given: { attribution: fixture_attribution_small, prompt_placement: inside_crate_dir, request: definition, position: { file: generated_within_entry } } +expect: { location_file: discovered_prompt_path, root_joined_module_name: false } +::: + +::: test +name: evidence failing its validator is invalid and distinguishable from unsupported +given: { receipt_evidence: [valid_claude_json, malformed_claude_json, unknown_media_type] } +expect: { outcomes: [validated, invalid, unsupported] } +::: + +::: test +name: a receipt exceeding evidence bounds is rejected as resource-limit with nothing published +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: oversized_evidence } +expect: { outcome: resource_limit, published_semantic_state: false, published_provenance: false } +::: + +## Determinism contract + +Every collection canonically ordered; every input explicit and content +identified; serialization byte-pinned; ambient environment excluded +(locale, timezone, machine identity, CPU count, enumeration order); reads +never write; a state transition names its base and fails on mismatch per +the decision table rather than recalculating. Same workspace compiled +twice, from different absolute paths, under different discovery orders, +locales, and timezones produces byte-identical artifacts. The test suite +proves the vectors in the frontmatter with deterministic fixtures +constructed in each crate's `tests/`. + +::: test +name: compiling the same workspace twice yields byte-identical artifacts +given: { workspace: fixture_two_modules, runs: 2 } +expect: { artifacts_equal: true } +::: + +::: test +name: artifact bytes are independent of discovery order +given: { workspace: fixture_two_modules, discovery_orders: [sorted, reversed, shuffled_seed_7] } +expect: { artifacts_equal: true } +::: + +::: test +name: artifact bytes are independent of the absolute workspace path +given: { workspace: fixture_two_modules, mounted_at: [rootA, rootB] } +expect: { artifacts_equal: true } +::: + +::: test +name: canonical json sorts keys and is byte-stable +given: { value: "{\"b\":1,\"a\":2}", encodings: 2 } +expect: { bytes_equal: true, sorted_keys: true } +::: +napl-store tree v1 255 +{"entries":[{"hash":"e9e8397be3fefaa27b6009ddfcd9f9949a06d531dc07c64c1f6f13e8d43a33d0","kind":"file","mode":33188,"name":"init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","kind":"file","mode":33188,"name":"watch.rs"}]} +napl-store tree v1 132 +{"entries":[{"hash":"d6d79f2130efcc425bb0177dc83a0093cb305233932544e161162ba9474b938d","kind":"dir","mode":16384,"name":"crates"}]} +napl-store transition v1 843 +{"baseStateKey":"93bbadd9800acdd4b01ec77020982562dce3e81fc0250ce794d78654c94a1238","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"dddfe696dea70b0c812204774c03469b7239f61e0388ee0b314f8302290acff0","installPaths":["crates/napl/src/serve.rs"],"module":"serve","newState":"70c656393697d4192e4f4cadf2217370c3556a0ec85655605fb63deb7a400973","readSet":["base-state:93bbadd9800acdd4b01ec77020982562dce3e81fc0250ce794d78654c94a1238","input-key:dddfe696dea70b0c812204774c03469b7239f61e0388ee0b314f8302290acff0","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"d14bdf8caefad65ff735cff07223c8d3a740cb2fba79eccd5daba36cfaaa1d09","surface":"0a742d0232e4b7000bd79ff29b39af501675254174d2c98163f8b7337cf7f0b7","target":"rust","writeSet":["owned:crates/napl","module-state:rust/serve"]} +napl-store blob v1 24802 +use std::io::Write as _; +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::authoring::apply_fix; +use crate::canon::canonical_bytes; +use crate::cli::{ + clap_root, emit_prerequisite, flag, has_switch, ok, positional, positionals_from, repeated_flag, + require_flag, resolve_workspace, switch, usage, value, CliResult, CommandSpec, FlagSpec, + USAGE_EXIT_CODE, WORKSPACE_FLAG, +}; +use crate::commit::recover; +use crate::disk; +use crate::error::{outcome_exit_code, OutcomeCode, Result}; +use crate::lint::{is_auto_fixable, lint_json, refusal_json, Analysis}; +use crate::mv::relocate_module; +use crate::porcelain::{ + add_note, blame_path, cat_file, cost, diff, fsck_store, gc, lint_workspace, log, module_plan, + restore, show, status, surface_of, +}; + +/// Only `--workspace`: the command reads the workspace and nothing else. +const WORKSPACE_ONLY: &[FlagSpec] = &[WORKSPACE_FLAG]; + +const ADD_FLAGS: &[FlagSpec] = &[ + value( + "--attribution", + "path to a map affirming the claims the transport listed; entries may spell an endpoint `whole`", + ), + switch( + "--check", + "run the whole pipeline including the transport verdict and write nothing at all", + ), + value("--module", "the module the candidate is output for"), + switch("--no-verify", "stage without the acceptance verification pass"), + value("--target", "the target to stage for, default rust"), + value("--usage", "path to the usage envelope recorded as provenance"), + WORKSPACE_FLAG, +]; + +const COMMIT_FLAGS: &[FlagSpec] = &[ + value( + "--staged", + "path to one staged record `napl add` reported; without it every staged record installs in wave order", + ), + WORKSPACE_FLAG, +]; + +const STATUS_FLAGS: &[FlagSpec] = &[ + value( + "--module", + "report one module's plan, live state, and derived entry-file projection instead of the workspace", + ), + WORKSPACE_FLAG, +]; + +const LOG_FLAGS: &[FlagSpec] = &[ + value("--module", "limit the history to one module"), + WORKSPACE_FLAG, +]; + +const MODULE_FLAGS: &[FlagSpec] = &[ + value("--module", "the module to read"), + WORKSPACE_FLAG, +]; + +const LINT_FLAGS: &[FlagSpec] = &[ + switch("--fix", "apply the auto-fixable findings through the guarded commit path"), + WORKSPACE_FLAG, +]; + +const COST_FLAGS: &[FlagSpec] = &[ + value("--file", "an extra receipt file to price, repeatable"), + value("--session", "price one session's calls from the log, repeatable"), + WORKSPACE_FLAG, +]; + +const APPLY_FLAGS: &[FlagSpec] = &[ + switch("--apply", "apply the plan instead of only reporting it"), + WORKSPACE_FLAG, +]; + +const NOTES_FLAGS: &[FlagSpec] = &[ + value("--note", "the note text to attach"), + value("--to", "the transition the note attaches to"), + WORKSPACE_FLAG, +]; + +const MV_FLAGS: &[FlagSpec] = &[ + value("--target", "the target the module is placed for, default rust"), + WORKSPACE_FLAG, +]; + +const EDIT_FLAGS: &[FlagSpec] = &[ + value("--new-string", "the replacement text"), + value("--old-string", "the exact text to replace"), + value( + "--ref", "a single JSON object recording one reference: direction (code_to_prompt or prompt_to_code), module, file, lines, promptLines, and an optional note", ), switch("--replace-all", "replace every occurrence instead of requiring exactly one"), @@ -227474,7 +226274,7 @@ pub fn commands() -> &'static [CommandSpec] { name: "diff", example: "napl diff rust", summary: "render drift between committed outputs and the working tree", - usage: "napl diff ", + usage: "napl diff ", flags: WORKSPACE_ONLY, passthrough: false, run: run_diff, @@ -227509,8 +226309,8 @@ pub fn commands() -> &'static [CommandSpec] { CommandSpec { name: "lint", example: "napl lint rust", - summary: "diagnose the module graph and scan the target's source files for syntax the pinned grammar cannot parse", - usage: "napl lint [--fix]", + summary: "diagnose unreachable modules, unconsumed surface entries, and staleness", + usage: "napl lint [--fix]", flags: LINT_FLAGS, passthrough: false, run: run_lint, @@ -227587,15 +226387,6 @@ pub fn commands() -> &'static [CommandSpec] { passthrough: false, run: run_status, }, - CommandSpec { - name: "store", - example: "napl store migrate", - summary: "one-shot store operations; `migrate` moves the tracked object files into the git-backed store", - usage: "napl store migrate", - flags: WORKSPACE_ONLY, - passthrough: false, - run: run_store, - }, CommandSpec { name: "surface", example: "napl surface rust --module leaf", @@ -227660,6 +226451,8 @@ fn run_add(cwd: &Path, args: &[String]) -> Result { check, )?); }; + // One operand that is a directory is candidate bytes; anything else named + // beside `--module` is a working-tree path staged under the named module. let candidate_dir = match operands.as_slice() { [only] => resolve_operand(cwd, &workspace, only).is_dir(), _ => false, @@ -227715,6 +226508,8 @@ fn run_commit(cwd: &Path, args: &[String]) -> Result { exit_code, }); }; + // The record path `add` reports is workspace-relative; an absolute path is + // honored as given. let record_path = if Path::new(&record).is_absolute() { std::path::PathBuf::from(&record) } else { @@ -227799,25 +226594,12 @@ fn run_completions(_cwd: &Path, args: &[String]) -> Result { /// against. A headless workspace gets the prerequisite sequence, and a target no /// committed module owns anything under answers `tracked: false` with its reason /// rather than the empty patch that would read as "no drift". -/// -/// The operand is a target name and the usage line spells it ``. An -/// operand that is not one — a module name, a file path — is a usage error that -/// names the target names which exist, so the argument teaches its own -/// vocabulary instead of being reverse engineered from a store-integrity code. fn run_diff(cwd: &Path, args: &[String]) -> Result { let workspace = resolve_workspace(cwd, args)?; - let given = positional(args, 1)?; - let known = crate::target::builtin_target_names(); - if !known.contains(&given) { - return Err(usage(&format!( - "{given:?} is not a target name; the target names that exist are {}", - known.join(", ") - ))); - } if !crate::porcelain::has_committed_head(&workspace)? { return Ok(emit_prerequisite("napl diff")); } - ok(&diff(&workspace, &given)?) + ok(&diff(&workspace, &positional(args, 1)?)?) } /// `napl blame`, under the same rule: no head means the prerequisite sequence, and @@ -227836,30 +226618,12 @@ fn run_blame(cwd: &Path, args: &[String]) -> Result { /// missing prerequisite and the exact declaration that satisfies it, exit 2, and /// no `findings` field at all, because an empty findings list under exit 0 would /// read as "nothing is wrong" when the truth is "nothing was checked". -/// -/// The source-syntax scope sets the exit code of a target lint: any finding is a -/// fact about bytes that do not parse, so it exits 1 and a clean scan exits 0. -/// The graph scope stays advisory, its findings being proposals rather than -/// facts. fn run_lint(cwd: &Path, args: &[String]) -> Result { let workspace = resolve_workspace(cwd, args)?; let target = positional(args, 1)?; - let source_scan = crate::porcelain::source_syntax_scan(&workspace, &target)?; - let source_findings = source_scan["findings"] - .as_array() - .map(|findings| findings.len()) - .unwrap_or(0); - let source_exit_code = if source_findings > 0 { 1 } else { 0 }; - let (_, report, mut value) = lint_workspace(&workspace, &target)?; - if let Some(object) = value.as_object_mut() { - object.insert("sourceSyntax".to_string(), source_scan.clone()); - object.insert( - "scopes".to_string(), - json!([crate::lint::GRAPH_SCOPE, crate::surface::SOURCE_SYNTAX_SCOPE]), - ); - } + let (_, report, value) = lint_workspace(&workspace, &target)?; if report.unreachable_analysis == Analysis::Refused { - let bytes = canonical_bytes(&refusal_json(source_scan)); + let bytes = canonical_bytes(&refusal_json()); let stderr = std::io::stderr(); let mut lock = stderr.lock(); let _ = lock.write_all(&bytes); @@ -227870,13 +226634,10 @@ fn run_lint(cwd: &Path, args: &[String]) -> Result { }); } if !has_switch(args, "--fix") { - return Ok(CliResult { - stdout: canonical_bytes(&value), - exit_code: source_exit_code, - }); + return ok(&value); } let mut applied: Vec = Vec::new(); - let mut exit_code = source_exit_code; + let mut exit_code = 0; for finding in report.findings.iter().filter(|f| is_auto_fixable(f)) { let outcome = apply_fix(&workspace, &target, finding)?; if outcome.outcome != OutcomeCode::Success { @@ -227907,24 +226668,6 @@ fn run_gc(cwd: &Path, args: &[String]) -> Result { ok(&gc(&workspace, has_switch(args, "--apply"))?) } -/// `napl store `: today exactly one operation, and an unknown one is -/// a grammar failure like any other undeclared token. -fn run_store(cwd: &Path, args: &[String]) -> Result { - let workspace = resolve_workspace(cwd, args)?; - let operation = positional(args, 1) - .map_err(|_| usage("napl store needs an operation, and the only one is migrate"))?; - if operation != "migrate" { - return Err(usage(&format!( - "unknown store operation {operation:?}: napl store performs migrate" - ))); - } - let (migrated, value) = crate::porcelain::store_migrate(&workspace)?; - Ok(CliResult { - stdout: canonical_bytes(&value), - exit_code: if migrated { 0 } else { USAGE_EXIT_CODE }, - }) -} - fn run_fsck(cwd: &Path, args: &[String]) -> Result { let workspace = resolve_workspace(cwd, args)?; let (healthy, value) = fsck_store(&workspace)?; @@ -227993,3090 +226736,2120 @@ fn run_lsp(_cwd: &Path, _args: &[String]) -> Result { exit_code: 0, }) } -napl-store snapshot v1 781 -{"contractVersion":4,"event":"6f9cb496dfb5bdf61d6135b05a0bb296a7e89b97e87b7b47a9cff2cd527eae4c","states":[{"module":"agent","state":"7c568214148bdacf0c078e2420e1c8355c24100c703f3490f65ca9bcf1f64535","target":"rust"},{"module":"commands","state":"cca37ce0ab52aabdb236250fe7211d03ae415524090fe1ea8d26ed32f63a0714","target":"rust"},{"module":"napl","state":"a3ebf3249a0d13b8b6d8fbfae95a896bb8c148b98b23e11aef070b51b624058d","target":"rust"},{"module":"napl_store","state":"7011890f658fb28bd29684802e5e53bb5d54d54d3c5a64cc926a56fae7ef944d","target":"rust"},{"module":"query","state":"d8753add6fb4489381d764e2d8abd5964e1eb8a5435669bfc1ac296d9739ffcf","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} -napl-store blob v1 5838 -//! Per-module compile slices and the derived keys that pin them. -//! -//! A slice is everything a producer needs and nothing it does not. There is no -//! provider, model, prompt assembly, credential, timestamp, retry policy, price, -//! or test result anywhere in this file — those live outside the compiler by -//! construction, not by convention. +napl-store receipt v1 4839 +{"baseStateKey":"2e6328cde4c885e073cae7722b8da9557cedfb471f20f3b766215224d7d99edd","baseStoreSnapshot":"e7b38c4393ac64edbc646dfcc237b3551d3841c13880bce234cae11ff9e7801d","baseWorkspaceHash":"618ee501a30b5e02230cfe131cf13245c135d35f796e6f8300aa1fccb2dc8223","candidateTreeHash":"69bf9de1d96653068bee489d514b4ce0c80219a74bb74f8c0385f79d1196fe42","contractVersion":4,"inputKey":"421c2de594ddecf81bcbbcfaad2d0566fe99881b28c2ecccac9479da5dbfcd01","module":"napl","outputManifest":[{"hash":"f0987b243dce1a039a0c78f8100965a895c03501bc378b4217765c5c84982d34","path":"crates/napl/src/attribution.rs"},{"hash":"e539c65a4600ed1784b9cc176cb28f24c140f514f9783360f1f5a27142f89f68","path":"crates/napl/src/authoring.rs"},{"hash":"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2","path":"crates/napl/src/blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","path":"crates/napl/src/candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","path":"crates/napl/src/canon.rs"},{"hash":"0b8190e7989129e7baf4eed7046e19b7608067eb5d13bed95ad416118bf34a25","path":"crates/napl/src/capture.rs"},{"hash":"2e5b5b54aebea3db240acc2f2dc944df07b908bf97ba2067517a9757ed8c7dcb","path":"crates/napl/src/cli.rs"},{"hash":"7f8fe1b370188a60f00e524cc5c8059ca17a347b97915097286fff2c0ec7e290","path":"crates/napl/src/commandset.rs"},{"hash":"ed1c68648258a7ac19741b966c321db9c100222da858a64cc0604b108b7998fa","path":"crates/napl/src/commit.rs"},{"hash":"3c0db9b5d179e2f3b7123cd8e08844ea9ad28a0a6ffbc7e5794738709f5af1f4","path":"crates/napl/src/compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","path":"crates/napl/src/cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","path":"crates/napl/src/decision.rs"},{"hash":"bc274cef6c4e5aca63e7c46d81e1f86e118c6ba079e7a4ccd4e9f14a8d3eb21d","path":"crates/napl/src/directive.rs"},{"hash":"b86a4431e286225b32bae5cebc03298584fce063e8d4ee2df4633e18143f0aa7","path":"crates/napl/src/disk.rs"},{"hash":"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812","path":"crates/napl/src/error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","path":"crates/napl/src/evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","path":"crates/napl/src/graph.rs"},{"hash":"824c95b7229ac2ad5b1cc518b296443883fb56a110c86c2a2e67923c0a19ea07","path":"crates/napl/src/lint.rs"},{"hash":"2ea965827e5c5073cbcacacd22bf2318a9067874c901b4f05f67268e99baf92c","path":"crates/napl/src/lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","path":"crates/napl/src/main.rs"},{"hash":"6fc1b58dad295d780c917c10ac0f793667e46c455349cff3d42e6855501a91bd","path":"crates/napl/src/maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","path":"crates/napl/src/mv.rs"},{"hash":"3ae089b923327032845c386f2332ff653a08f25ccec28e0786094fa05fd77a00","path":"crates/napl/src/objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","path":"crates/napl/src/paths.rs"},{"hash":"93f11780827b58057b742550c206f180d5fa1659b216e90b8cfded514400be21","path":"crates/napl/src/porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","path":"crates/napl/src/receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","path":"crates/napl/src/slice.rs"},{"hash":"582dc66d4cbe8957f2b566d2aa4052cee721d416cd4fdb122ec24a5982c4516d","path":"crates/napl/src/source.rs"},{"hash":"7d1bba27fd84f0edae130f1902b00abab524e3a67d0428b1a2fe7a2c01af5273","path":"crates/napl/src/staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","path":"crates/napl/src/store_view.rs"},{"hash":"1820f647bb5dbd74c8db7f5519d404ef0ce1520c833a579aaca893b5cad7ba08","path":"crates/napl/src/surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","path":"crates/napl/src/target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","path":"crates/napl/src/textdiff.rs"},{"hash":"ca46ca78bcb2940f23d3aede83fdee2d10b6910dfb0eaa9de6a8ca83a2209427","path":"crates/napl/src/transaction.rs"},{"hash":"d4ef5888a7ecc406d8eb5173dc215c7b1a1ef1e1ddd3ab8b3a8aa6639d125017","path":"crates/napl/src/transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","path":"crates/napl/src/trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","path":"crates/napl/src/version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","path":"crates/napl/src/workspace.rs"}],"receiptVersion":1,"sliceHash":"d40e15cead8598ca2bd14fd6aedb544c457ca8ea38694a4e63e3451cb8845cb6","target":"rust"} +napl-store transition v1 915 +{"baseStateKey":"fc84533dbe7fc3dac061433bc0c1f8fc5bde8245ab04b55fa0c742bcbe63d0a2","contractVersion":1,"derivedProjections":["Cargo.toml"],"inputKey":"d6f3c4d7d96039eee461324079f3fce8d621a98c3eb209dd2d79beb23a124527","installPaths":["crates/napl/Cargo.toml","crates/napl/src/init.rs","crates/napl/src/query.rs","crates/napl/src/watch.rs"],"module":"commands","newState":"7c7e8183b9fbf594f3d61a4ca43f5154c404f912f32b8789d32c697ae883859b","readSet":["base-state:fc84533dbe7fc3dac061433bc0c1f8fc5bde8245ab04b55fa0c742bcbe63d0a2","input-key:d6f3c4d7d96039eee461324079f3fce8d621a98c3eb209dd2d79beb23a124527","target-def:24836906360841beb373080103c8b6c1fe791d495a88247b70267efffbbf9f10"],"receipt":"4184266c3322e534d08af35a422433e9ad39cbf1c471643a855a0d48be278fac","surface":"d1d65a35524fa9e7716908f525fe47c5726d2d3b9bad4943566d96f07ef4f20f","target":"rust","writeSet":["owned:crates/napl","module-state:rust/commands"]} +napl-store tree v1 4920 +{"entries":[{"hash":"12166b5fd9554e4781dd2efb828ac24b17228fab1a08ad7df51069e20f8efb0f","kind":"file","mode":33188,"name":"attribution.rs"},{"hash":"c777943d5bb9e51d363d75a4b45ef5b79311f0ccc4c4de6b471d93664ac0540c","kind":"file","mode":33188,"name":"authoring.rs"},{"hash":"4b44f76ec9cece4314358f54861f937a5c4e875e93646399a3abbb09c2a51bea","kind":"file","mode":33188,"name":"blame.rs"},{"hash":"02d2ea0745e17a3fdc17f2657ee532dfb748423bced9c1302e3178f45dfab10a","kind":"file","mode":33188,"name":"candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","kind":"file","mode":33188,"name":"canon.rs"},{"hash":"d85bfa83452d95a4eeb9b36dce2774cf0d64189608cf962441406db9b7fe5a3b","kind":"file","mode":33188,"name":"capture.rs"},{"hash":"f5e2132da4e24805a9c5d93e78db873618fea004116575dac3a9e60446b01c20","kind":"file","mode":33188,"name":"cli.rs"},{"hash":"b234a71b43518e1fac23f8b600230deca8ca847391e80393f26eb556fca4c8c2","kind":"file","mode":33188,"name":"commandset.rs"},{"hash":"6dab721be0eba11dbf2919eb60718ca9689e4dbbf5e9b6abd233a795144a3cb8","kind":"file","mode":33188,"name":"commit.rs"},{"hash":"5287b1e356febbfb1bb42750c5d0e218597176a3fee19f387b975d84aa0cc11b","kind":"file","mode":33188,"name":"compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","kind":"file","mode":33188,"name":"cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","kind":"file","mode":33188,"name":"decision.rs"},{"hash":"9321a742b82fb57726c27849b15e2d1865e3521dd95838968055836eca5ac06f","kind":"file","mode":33188,"name":"directive.rs"},{"hash":"94a48069d0b3d0b8deea53f54a9826ba0c934c231d8092f57a51e2618f0b1991","kind":"file","mode":33188,"name":"disk.rs"},{"hash":"bba24de15a64304a2c47ff84ea2d957dd65f77a450ab1f813ef2d51fb69e7cc0","kind":"file","mode":33188,"name":"error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","kind":"file","mode":33188,"name":"evidence.rs"},{"hash":"b58c9905a5161853627760881b34a4296428afb5e07610dee4a3a908a5a63559","kind":"file","mode":33188,"name":"git.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","kind":"file","mode":33188,"name":"graph.rs"},{"hash":"bcacc1842bc9440afe08e7d396622023b8ce1db9d04b796d2842fb704c7b2b6d","kind":"file","mode":33188,"name":"lint.rs"},{"hash":"0f2e50ef0121924926bdadaa29157d5abb2159c2308a44edb3e3e8fde77fbc0c","kind":"file","mode":33188,"name":"lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","kind":"file","mode":33188,"name":"main.rs"},{"hash":"1a3eb8a85e1d9905dd35f074dace13f08ddcd15c4b345c449bd73143ea71fb03","kind":"file","mode":33188,"name":"maintenance.rs"},{"hash":"697b0b828643d41b8ce208d441a0e11fdfe044cc743514830d3c36287b46a778","kind":"file","mode":33188,"name":"mv.rs"},{"hash":"7cf9c419b2c4f7c7b17e2c1e4362861de714f4ffe54727c1a0fcb749b2e6975b","kind":"file","mode":33188,"name":"objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","kind":"file","mode":33188,"name":"paths.rs"},{"hash":"ec7f6eba08072fca5e00c2518a34a059f3768a18473cf7a19452b476c4855776","kind":"file","mode":33188,"name":"porcelain.rs"},{"hash":"156ab704b4d1d385105fd45dfe9ffa5af5cdbf7344eac0c70c3c4d26b6583167","kind":"file","mode":33188,"name":"receipt.rs"},{"hash":"d3b2ea92ec386819cf5437dfdc1c7edae62cc79bb1ca259139462b71af4fea13","kind":"file","mode":33188,"name":"remove.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","kind":"file","mode":33188,"name":"slice.rs"},{"hash":"2c24abb95918f8bd106460536d1b502075a3e7cd6dcd9bf6e94a7e5d703d1140","kind":"file","mode":33188,"name":"source.rs"},{"hash":"2e88434ca61348aa7f7a71203f65f08fd9421ce60ace0ff5d2cea854bf3f60c3","kind":"file","mode":33188,"name":"staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","kind":"file","mode":33188,"name":"store_view.rs"},{"hash":"5e9e418d1a1e8dccd7207bdf85f39d21e510760135c6cc2be2f149e4b82a6f78","kind":"file","mode":33188,"name":"surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","kind":"file","mode":33188,"name":"target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","kind":"file","mode":33188,"name":"textdiff.rs"},{"hash":"bfd8ff52f2306a963616bda4a97971a9daebe88e75f34bb2f06960ea92feac39","kind":"file","mode":33188,"name":"transaction.rs"},{"hash":"b73e4b4242b073030bdaf119b375999eec7f383111a0b02f8e73505eb0ecc76d","kind":"file","mode":33188,"name":"transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","kind":"file","mode":33188,"name":"trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","kind":"file","mode":33188,"name":"version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","kind":"file","mode":33188,"name":"workspace.rs"}]} +napl-store blob v1 9922 +# usage-proxy: napl agent, and the capture sidecar behind it -use napl_store::store_hash::ObjectKind; -use serde_json::{json, Map, Value}; +One user-facing verb and one small binary behind it. `napl agent -- + [args]` runs any LLM harness, interactive or not, with usage +capture just there: it starts the workspace's capture proxy if none is +running, injects the base-URL override into the child environment, and +hands the terminal to the command unchanged. The user story is one line: +run your agent through `napl agent` and provenance is captured, whatever +the harness. No daemon to remember, no environment to edit, no config +file. -use crate::canon::json_hash; -use crate::objects::ModuleState; -use crate::receipt::RECEIPT_VERSION; -use crate::source::SourceModule; -use crate::surface::{surface_json, SurfaceItem}; -use crate::target::{ - owned_globs, placement_facts, placement_root, projection_owned_paths, surface_grammar, - target_def_hash, TargetDef, -}; +The sidecar, `napl-usage-proxy`, sits between the harness and the +Anthropic API and does one thing: pass requests through untouched and +append one usage record per completed call to a local JSONL log. It is +not a gateway: no routing, no budgets, no multi-provider translation, no +retries, no caching, no database. -/// A dependency's recorded surface as it enters a slice. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DepSurface { - pub module: String, - pub surface_hash: String, - pub items: Vec, -} +This crate is runtime I/O by nature: it listens on a socket, speaks TLS +to one upstream, spawns the wrapped command, and stamps wall-clock +timestamps into its log. That is legal here and stated plainly: the +determinism contract governs compilation and the store, and this +module's generation is as deterministic as any other; the running +proxy produces telemetry, and telemetry never enters identity. The log +is evidence in the sense of the usage-envelope seam: observed facts +about calls, display and pricing metadata, never a semantic claim. -/// One module's compile slice. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CompileSlice { - pub module: String, - pub target: String, - pub input_key: String, - pub base_state_key: String, - pub slice_hash: String, - pub wave: usize, - pub body: Value, -} +## napl agent (the wrapper) -/// The closure manifest whose hash is the module's `inputKey`. -/// -/// It names the normalized source, every direct dependency's *recorded* surface -/// hash, the placement facts, the entry-point version, the target and its -/// definition hash, and the contract version. Nothing else can move it. -pub fn closure_manifest(source: &SourceModule, target: &TargetDef, deps: &[DepSurface]) -> Value { - let encoded: Vec = deps - .iter() - .map(|dep| json!({ "module": dep.module, "surfaceHash": dep.surface_hash })) - .collect(); - json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "deps": encoded, - "entryPointVersion": target.entry_point_version, - "normalizedSourceHash": source.normalized_source_hash, - "placement": placement_facts(target, &placement_root(source)), - "target": target.name, - "targetDefHash": target_def_hash(target), - }) -} +`napl agent -- [args]` resolves the workspace root (nearest +ancestor with `.napl`, same rule as everything else), then: -/// Compute a module's `inputKey`. -pub fn input_key(source: &SourceModule, target: &TargetDef, deps: &[DepSurface]) -> String { - json_hash(ObjectKind::Blob, &closure_manifest(source, target, deps)) -} +- reuses the workspace's running sidecar when one is alive, else starts + one on an ephemeral loopback port. Liveness and identity live in + `.napl/usage-proxy.state`, a single canonical JSON file holding the + port, the sidecar pid, and the log path; a stale state file (dead + pid) is replaced, never trusted. One sidecar per workspace: claiming + the state file is atomic, written whole and renamed into place, so + when several wrappers race from cold exactly one claim survives; a + loser detects the surviving claim, shuts down its own spawn, and uses + the winner's port. Concurrent runs in any number of terminals share + the one sidecar, each under its own session id. +- injects `ANTHROPIC_BASE_URL=http://127.0.0.1:` into the child + environment, leaving every other variable untouched. An + `ANTHROPIC_BASE_URL` already present in the parent environment wins + and is left alone, with a warning on stderr, so an outer proxy is + never silently displaced. +- generates a fresh session id, injects it as `NAPL_SESSION` in the + child environment, and passes it to the sidecar so every log line + written while this child runs carries it. The session id is how an + edit or generation is later joined to its calls: a session window, + not a guess against a global log. +- executes the command with the terminal fully inherited: stdin, + stdout, stderr, tty. Interactive TUIs run exactly as if invoked + directly. The wrapper adds nothing to the terminal. +- exits with the child's exit code. The sidecar outlives the child and + keeps serving later `napl agent` runs; `napl agent --stop` shuts the + workspace's sidecar down and clears the state file. -/// What a receipt must carry, and what it may optionally report. -pub fn receipt_requirements() -> Value { - json!({ - "optionalReported": ["evidence", "gates", "notes", "producer", "usage"], - "receiptVersion": RECEIPT_VERSION, - "required": [ - "baseStateKey", - "baseStoreSnapshot", - "baseWorkspaceHash", - "candidateTreeHash", - "inputKey", - "module", - "outputManifest", - "receiptVersion", - "sliceHash", - "target", - ], - }) -} +Absent `--`, or an empty command, is a usage error on stderr with exit +code 2. The wrapper never modifies the workspace, the store, or any +file except the state file and the log. -/// Build a module's compile slice. -pub fn build_slice( - source: &SourceModule, - target: &TargetDef, - deps: &[DepSurface], - base: &ModuleState, - base_state_key: &str, - base_files: &[(String, String)], - wave: usize, -) -> CompileSlice { - let input_key = input_key(source, target, deps); - let dependencies: Vec = deps - .iter() - .map(|dep| { - json!({ - "module": dep.module, - "surface": surface_json(&dep.module, &target.name, &dep.items), - "surfaceHash": dep.surface_hash, - }) - }) - .collect(); - let base_manifest: Vec = base_files - .iter() - .map(|(path, hash)| json!({ "hash": hash, "path": path })) - .collect(); +## Passthrough - let root = placement_root(source); - let mut body = Map::new(); - body.insert( - "allowedOutputs".into(), - json!({ - "ownedGlobs": owned_globs(target, &root), - "projectionOwned": projection_owned_paths(target, &root), - "root": root, - "surfaceGrammar": surface_grammar(target), - }), - ); - body.insert( - "baseOutput".into(), - json!({ - "baseStateKey": base_state_key, - "files": base_manifest, - "outputTree": base.output_tree, - "surfaceHash": base.surface_hash, - }), - ); - body.insert("contractVersion".into(), json!(crate::version::CONTRACT_VERSION)); - body.insert("dependencies".into(), Value::Array(dependencies)); - body.insert("inputKey".into(), json!(input_key)); - body.insert("module".into(), json!(source.module)); - body.insert("normalizedSource".into(), json!(source.normalized_body)); - body.insert( - "normalizedSourceHash".into(), - json!(source.normalized_source_hash), - ); - body.insert("receiptRequirements".into(), receipt_requirements()); - body.insert("target".into(), json!(target.name)); - body.insert("tests".into(), json!(source.tests)); +The sidecar listens on its loopback port and forwards every request to +one upstream origin, `https://api.anthropic.com` by default, +overridable per workspace through `--upstream` on `napl agent` (stored +in the state file so reuse keeps the same upstream). Method, path, +query, headers, and body forward verbatim; the response streams back +byte-for-byte as received, including SSE streams, status codes, and +error bodies. The proxy never rewrites, buffers whole streams +needlessly, or injects headers beyond what proxying itself requires. +Authorization material (`x-api-key`, `authorization`, cookies) passes +through and is never written to the log, never printed, and never held +longer than the request. Request and response bodies are never logged. - let slice_hash = json_hash(ObjectKind::Blob, &Value::Object(body.clone())); - body.insert("sliceHash".into(), json!(slice_hash)); - body.insert("wave".into(), json!(wave as u64)); +Capture must never break the call: any failure to parse or record usage +is swallowed, the client still receives the upstream response +unchanged, and the log line for that call says what could not be read. +Fail open is the rule; observation never gates. - CompileSlice { - module: source.module.clone(), - target: target.name.clone(), - input_key, - base_state_key: base_state_key.to_string(), - slice_hash, - wave, - body: Value::Object(body), - } -} +## Capture -/// Recompute a slice's hash from its own body. -/// -/// `sliceHash` and `wave` are excluded: the hash identifies the compile work, -/// not the position that work happens to occupy in one particular sweep. -pub fn recompute_slice_hash(body: &Value) -> String { - let mut stripped = body.as_object().cloned().unwrap_or_default(); - stripped.remove("sliceHash"); - stripped.remove("wave"); - json_hash(ObjectKind::Blob, &Value::Object(stripped)) -} -napl-store blob v1 66932 -//! `napl add`: stage a module state; `napl commit --staged`: install what add staged. -//! -//! Git parity is the mental model: add stages, commit installs. The staged -//! record is the internal receipt between them, never a user-facing noun. It is -//! transient coordination state, disposable litter by the git-state law: never -//! promoted to history, safely deletable, and re-creatable by re-running add. -//! Add ingests the staged bytes into an immutable copy and every later step -//! reads that copy, so tampering with the source files after add cannot change -//! what commit installs. That holds for all three sources equally, because all -//! three become a list of scanned entries before ingestion sees them. -//! -//! The working tree is the default source, exactly as in git. `napl add ` -//! names files and stages the owning module's committed output with those files -//! overlaid; `napl add --module ` stages the module's whole owned tree as -//! it sits on disk; `napl add --module ` stages explicit -//! bytes, for the rare case where what must be staged is not the working tree. -//! -//! Attribution transports rather than being demanded. Add diffs the committed -//! output against the staged bytes and the committed prompt body against the -//! pending one, and carries every recorded claim through those diffs. A claim the -//! diff proves survives; a claim it does not prove is asked for, never invented. +The sidecar tees only what it needs to extract usage. A non-streaming +JSON response carries `model` and `usage` at the top level. A streaming +SSE response carries them in the `message_start` event and the final +`message_delta` event; the proxy reads those two event types and +ignores every other event. Responses with no usage shape (non message +endpoints, upstream errors) log `usage` as `unavailable`. -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; +Each completed call appends exactly one line to +`.napl/usage-proxy.jsonl` in the workspace, canonical JSON, sorted +keys, one object per line: -use serde_json::{json, Value}; +- `timestamp`: RFC 3339 UTC, recorded when the response completes. +- `session`: the `NAPL_SESSION` id of the calling child, or + `unavailable` when the call carried none. +- `model`: the model string the response named, or `unavailable`. +- `requestId`: the upstream `request-id` header value, or + `unavailable`. +- `endpoint`: the request path, query stripped. +- `status`: the upstream HTTP status code. +- `usage`: the usage object as the response reported it, with + `input_tokens`, `output_tokens`, and any cache token fields the + upstream included, or the string `unavailable` when no usage could + be read. Absent is `unavailable`, never zero, never invented. -use crate::attribution::{self, Attribution}; -use crate::authoring::draft_receipt_for; -use crate::candidate::{default_limits, ingest, Candidate, EntryKind, ScannedEntry}; -use crate::commit::{commit_with_mode, AttributionMode, CommitReport}; -use crate::disk::{self, Store}; -use crate::error::{error, error_payload, OutcomeCode, Result}; -use crate::objects::{read_module_state, snapshot_states}; -use crate::source::SourceModule; -use crate::store_view::{flatten_tree, get_blob, ObjectSource}; -use crate::target::{builtin_target, is_owned_path, owned_globs, placement_root, TargetDef}; -use crate::transaction::check_candidate_shape; -use crate::transport::{ - refusal_detail, refusal_payload, transport_attribution, TransportInputs, TransportOutcome, -}; -use crate::workspace::{discover_sources, scan_candidate, write_file}; +Session attribution rides a header, because one port serves every +session. The wrapper arranges it: it sets +`ANTHROPIC_CUSTOM_HEADERS` in the child environment to carry +`x-napl-session: ` when the variable is unset, and the sidecar +strips exactly that header before forwarding upstream and stamps its +value into the line. A call arriving without the header logs `session` +as `unavailable`; capture still happens. A pre-existing +`ANTHROPIC_CUSTOM_HEADERS` in the parent environment is left alone +with a stderr warning, and those sessions read `unavailable`. -/// The disposable staging area for `add`, workspace-relative. -pub const STAGING_DIR: &str = ".napl/staging"; +Appends are atomic per line: a line is written whole with a trailing +newline or not at all, and concurrent calls never interleave bytes +within a line. The log is append-only; the proxy never rewrites or +truncates it. The media type of this log is +`application/x-napl-usage-proxy-jsonl-v1`, named here so the evidence +validator registry can adopt it as a validated envelope kind in a later +amendment to the napl module; nothing in this crate registers anything. -/// Where the bytes `add` stages come from. -/// -/// Three sources, one pipeline: each resolves to a list of scanned entries, and -/// from ingestion onward nothing downstream can tell them apart. -#[derive(Debug, Clone)] -pub enum AddSource { - /// Explicit candidate bytes in a directory: the exception, not the routine. - CandidateDir(PathBuf), - /// The module's whole owned tree, as it sits in the working tree. - WorkingTree, - /// The module's committed output with exactly these workspace-relative paths - /// overlaid from the working tree. - Overlay(Vec), -} +## Boundaries -/// Stage one module state from any of the three sources. -/// -/// Ingest the staged bytes into immutable blobs, validate against the slice, -/// resolve attribution by transport or affirmation, attach usage evidence, then -/// write the staged record. Validation failures surface here with the same -/// outcome classes commit uses. -#[allow(clippy::too_many_arguments)] -pub fn add( - workspace: &Path, - target_name: &str, - module: &str, - source: &AddSource, - attribution_path: Option<&Path>, - usage_path: Option<&Path>, - no_verify: bool, - check: bool, -) -> Result { - let target = builtin_target(target_name)?; - let sources = discover_sources(workspace)?; - let prompt = sources.iter().find(|s| s.module == module).ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} has no prompt file in the workspace"), - ) - })?; - let root = placement_root(prompt); - let store = disk::open(workspace); +The crate depends on a minimal async HTTP stack and rustls, declared in +the user-owned manifest like every other dependency. It never reads the +store, never writes anything but the log and the state file, never +spawns anything but the wrapped command and the sidecar, and never +phones anywhere except the one configured upstream. The running +sidecar exits nonzero only on startup failure (bind failure, unusable +log path), never because of a bad request. - // Resolve the source to entries, then ingest once into immutable bytes; - // every later step reads these, never the files they came from. - let entries = scan_source(workspace, &store, &target, module, &sources, source)?; - let candidate = ingest(&entries, default_limits())?; - check_candidate_shape(&target, &root, &candidate)?; - // The parse gate, before anything reads the bytes as a surface: every staged - // file the target claims as source has to parse under the target's pinned - // grammar, or the record refuses naming the file, the first error's line, and - // the malformed region. Syntax only; everything past it is the battery's job. - // - // The refusal enumerates the same validations a passing `--check` verdict - // enumerates, so the reader learns which gate stopped them in the same - // vocabulary that would have told them everything passed. - if let Err(err) = crate::surface::parse_gate(&target, &root, &candidate.files) { - let mut data = match err.data { - Some(Value::Object(map)) => map, - _ => serde_json::Map::new(), - }; - data.insert("module".into(), json!(module)); - data.insert("notJudged".into(), not_judged()); - data.insert( - "validations".into(), - validations_run(attribution_path.is_some()), - ); - return Err(crate::error::error_with( - err.code, - err.detail, - Value::Object(data), - )); - } +::: test +name: agent runs an interactive-style child with the terminal inherited and returns its exit code +given: { child: exits_7_after_reading_stdin } +expect: { exit_code: 7, stdio_inherited: true } +::: - // The receipt is drafted against the ingested copy, not against files on - // disk, so the whole verdict is computable before anything is written. That - // is what makes `--check` a real dry run rather than a staged record deleted - // afterwards. - let receipt = draft_receipt_for(workspace, target_name, module, &candidate, usage_path)?; +::: test +name: agent injects the base url and session id and leaves the rest of the environment alone +given: { child: prints_env } +expect: { anthropic_base_url_loopback: true, napl_session_present: true, other_env_untouched: true } +::: - // Resolve the attribution update: transported where the diffs prove it, - // affirmed where they do not, refused when neither. - let attribution_bytes = resolve_attribution( - workspace, - &store, - &target, - &root, - module, - prompt, - &candidate, - attribution_path, - no_verify, - )?; +::: test +name: a pre-existing base url in the parent environment wins with a warning +given: { parent_env: { ANTHROPIC_BASE_URL: preset }, child: prints_env } +expect: { child_base_url: preset, stderr_warns: true } +::: - if check { - // The dry run has now run the entire pipeline, transport verdict and - // refusal listing included, and it writes nothing: no staged record, no - // entry in the staging directory, no store byte touched. - // - // And it says exactly what it judged. `would-stage` is a verdict about a - // record, not about code: the staged bytes could be a file that does not - // parse in the target language and this command would still, correctly, - // answer would-stage. Enumerating the validations that ran beside the one - // that deliberately did not is what keeps the two from being confused. - return Ok(json!({ - "checked": true, - "contractVersion": crate::version::CONTRACT_VERSION, - "module": module, - "notJudged": not_judged(), - "outcome": "would-stage", - "staged": false, - "target": target_name, - "validations": validations_run(attribution_path.is_some()), - })); - } +::: test +name: two agent runs in one workspace reuse one sidecar and a stale state file is replaced +given: { runs: 2, then: state_pid_killed, runs_after: 1 } +expect: { sidecars_started: 2, state_file_replaced: true } +::: - let record_dir = record_dir(workspace, module, &candidate.tree_hash); - if record_dir.exists() { - std::fs::remove_dir_all(&record_dir).map_err(|e| disk::io(&record_dir, &e))?; - } - let staged_dir = record_dir.join("candidate"); - for (path, bytes) in &candidate.files { - write_file(&staged_dir.join(path), bytes)?; - } +::: test +name: wrappers racing from cold converge on one surviving sidecar +given: { concurrent_cold_starts: 4 } +expect: { surviving_sidecars: 1, every_wrapper_usable_port: true, log_files: 1 } +::: - write_file( - &record_dir.join("receipt.json"), - &crate::canon::canonical_bytes(&receipt), - )?; - let attribution_present = match &attribution_bytes { - Some(bytes) => { - write_file(&record_dir.join("attribution.yaml"), bytes)?; - true - } - None => false, +::: test +name: passthrough returns upstream body and status byte for byte +given: { upstream: stub_json_response, request: post_messages } +expect: { body_identical: true, status_identical: true } +::: + +::: test +name: a non-streaming response appends one usage line carrying the session id and tokens +given: { upstream: stub_json_with_usage, request: post_messages_with_session_header } +expect: { log_lines: 1, log_session_matches: true, log_has_input_tokens: true, header_stripped_upstream: true } +::: + +::: test +name: an sse response yields usage from message_start and the final message_delta +given: { upstream: stub_sse_stream_with_usage, request: post_messages_stream } +expect: { log_lines: 1, usage_merged_from_start_and_delta: true, client_stream_unmodified: true } +::: + +::: test +name: an unparseable response still passes through and logs usage unavailable +given: { upstream: stub_malformed_body, request: post_messages } +expect: { body_identical: true, log_lines: 1, usage: unavailable } +::: + +::: test +name: authorization material never appears in the log +given: { upstream: stub_json_with_usage, request: post_messages_with_api_key } +expect: { log_contains_api_key: false, log_contains_authorization: false } +::: + +::: test +name: concurrent calls append whole lines that never interleave +given: { upstream: stub_json_with_usage, concurrent_requests: 8 } +expect: { log_lines: 8, every_line_parses: true } +::: + +::: test +name: an upstream error status passes through and logs its status with usage unavailable +given: { upstream: stub_overloaded_529, request: post_messages } +expect: { status_identical: true, log_lines: 1, logged_status: 529, usage: unavailable } +::: +napl-store blob v1 63108 +//! The read commands and the maintenance commands. +//! +//! Every read resolves HEAD exactly once and then answers entirely from objects +//! reachable from that snapshot. No read creates a directory, takes a lock, or +//! writes a byte, so the whole read surface works on a read-only filesystem. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use crate::blame::{blame, blame_json, untracked_json, Revision}; +use crate::compile::{ + compile, graph_for, install_index, read_surface_items, state_of, verdict as pending_verdict, +}; +use crate::cost::{cost_report, line_from_reported, CostLine}; +use crate::error::{error, OutcomeCode, Result}; +use crate::graph::{topological_levels, Graph}; +use crate::slice::receipt_requirements; +use crate::lint::{lint, lint_json, LintInput, LintReport}; +use crate::maintenance::{ + fsck, fsck_json, gc_json, plan_restore, plan_sweep, reachable_from_head, restore_json, + FileFact, RestorePlan, +}; +use crate::objects::{ + event_transition, read_module_state, snapshot_event, snapshot_states, walk_events, ModuleState, +}; +use crate::receipt::normalize_reported; +use crate::store_view::{ + flatten_tree, get_blob, get_json, get_object, has_object, ObjectSource, +}; +use crate::surface::surface_json; +use crate::target::{ + builtin_target, is_manifest_path, owned_globs, placement_root, projection_owned_paths, TargetDef, +}; +use crate::textdiff::render_drift; +use napl_store::store_hash::ObjectKind; +use serde_json::{json, Value}; + +use crate::disk::{self, Store}; +use crate::workspace::{discover_sources, read_working_text}; + +/// A snapshot-consistent status report for one target: observation, zero +/// mutation. +pub fn status(workspace: &Path, target: &str) -> Result { + let store = disk::open(workspace); + let head = store.head()?; + let states = match &head { + Some(snapshot) => snapshot_states(&store, snapshot)?, + None => BTreeMap::new(), }; - write_record( - &record_dir, - module, - target_name, - &candidate.tree_hash, - attribution_present, - if no_verify { - AttributionMode::Escape - } else { - AttributionMode::Prove - }, - )?; + let sources = discover_sources(workspace)?; + let artifact = compile(&store, &sources, target, None)?; + let pending: Vec = artifact + .slices + .iter() + .map(|slice| slice.module.clone()) + .collect(); + let frontier: BTreeSet = pending.iter().cloned().collect(); + let target_def = builtin_target(target)?; + let installs = install_index(&store, head.as_deref())?; + // Docs drift is drift a module state is not keyed on, so it never makes a + // module pending; it is still work waiting, and status is where a reader + // looks for waiting work. It comes from the one reader the empty-commit + // teacher uses, so a clean status and a silent teacher say the same thing. + let docs_drifted = crate::staging::docs_drift(workspace)?; + let mut modules: Vec = Vec::new(); + for ((state_target, module), state_hash) in &states { + if state_target != target { + continue; + } + let state = read_module_state(&store, state_hash)?; + let freshness = attribution_freshness(Some(&state), module, target, &sources); + let prompt_stale = freshness["status"].as_str() != Some("fresh"); + let ends = both_ends( + workspace, + &store, + &state, + prompt_stale, + &pending_body(&sources, module), + )?; + let is_pending = pending.contains(module); + let verdict = pending_verdict( + &store, + &sources, + &target_def, + module, + &states, + &installs, + &frontier, + ends.drift, + )?; + let (reason, reasons, reland) = verdict.json_fields(is_pending); + modules.push(json!({ + "attribution": freshness, + "baseStateKey": state_hash, + "docsDrift": docs_drifted.contains(&(target.to_string(), module.clone())), + "drift": ends.drift, + "inputKey": state.input_key, + "module": module, + "outputTree": state.output_tree, + "pending": is_pending, + "pendingReason": reason, + "pendingReasons": reasons, + "relandIdenticalBytes": reland, + "sliceHash": state.slice_hash, + "staleReferences": ends.stale_references as u64, + "surfaceHash": state.surface_hash, + })); + } Ok(json!({ "contractVersion": crate::version::CONTRACT_VERSION, - "module": module, - "outcome": "staged", - "record": display_path(workspace, &record_dir), - "target": target_name, + "head": head, + "manifestDiagnostics": manifest_diagnostics(workspace, target, &sources), + "modules": modules, + "pending": pending, + "target": target, })) } -/// The validations `--check` actually ran, in the order the pipeline runs them. -/// -/// Every one of these is a fact about the record: where the bytes may live, what -/// shape the ingest accepts, whether the recorded claims still point at -/// something, and whether the supplied evidence validates. None of them is a -/// fact about whether the code works. -fn validations_run(attribution_supplied: bool) -> Value { - json!([ - { - "detail": "every staged path lies inside the module's placement root and its owned globs, no path is a user-owned manifest, and no path is a derived entry-file projection", - "judged": true, - "name": "placement", - }, - { - "detail": "the staged bytes ingest into content-addressed blobs: no links or devices, no duplicate normalized paths, no absolute or parent-escaping path, nothing over the compiled-in size bounds", - "judged": true, - "name": "ingest", - }, - { - "detail": "every staged file the target claims as source parsed under the target's pinned tree-sitter grammar; a parse tree carrying an error node refuses as invalid-candidate naming the file, the first error's line, and the malformed region", - "judged": true, - "name": "parse gate", - "scope": crate::surface::SOURCE_SYNTAX_SCOPE, - }, - { - "detail": if attribution_supplied { - "every recorded claim was carried through the committed-to-staged and committed-to-pending diffs, and the supplied --attribution map was layered over the transported remainder and revalidated" - } else { - "every recorded claim was carried through the committed-to-staged and committed-to-pending diffs, and any claim the diffs do not prove is listed with both endpoints' verdicts" - }, - "judged": true, - "name": "attribution transport", - }, - { - "detail": "any supplied usage envelope was validated by its media type's registered validator and checked against the compiled-in evidence bounds", - "judged": true, - "name": "evidence", - }, - ]) -} - -/// What `--check` deliberately did not judge. +/// What status observes about a module's code side, so status judges both ends. /// -/// The dry run answers a question about staging and about syntax. Whether the -/// code type checks, borrow checks, or passes its tests is the acceptance -/// battery's question, and this command never ran it, so `would-stage` means the -/// record is sound and the syntax parses, never that the code is correct. -fn not_judged() -> Value { - json!([ - { - "detail": "whether the staged bytes compile, type check, borrow check, or pass their tests is the acceptance battery's job and nothing here ran it; the parse gate judged syntax and only syntax, so would-stage means the record is sound and the syntax parses, never that the code is correct", - "judged": false, - "name": "everything past syntax", - }, - ]) +/// `drift` is true when the committed output differs from the working tree, and +/// `stale_references` counts the module's references reading stale right now. Both +/// come from the derivations `diff` and the language server already serve, which +/// is the whole point: status can never read clean while the query surface reads +/// stale, because there is one derivation and not two. +struct BothEnds { + drift: bool, + stale_references: usize, } -/// `napl add [...]`: stage one record per owning module. -/// -/// Each path resolves to its owning module through the committed output manifest, -/// which is unique by the commit invariant that two modules never claim one path, -/// so no `--module` is needed. A path no module owns is a usage error that names -/// the owned globs which do exist, because the useful answer to "this is not -/// owned" is "here is what is". -/// -/// `napl add --module ` is the same form with the resolution -/// declared instead of derived: a file that is new in a crate several modules -/// share matches every one of their identical globs and is committed by none of -/// them, so neither the manifest nor the globs can name an owner. The author -/// names it, and the form exists precisely so that answer is sayable. -#[allow(clippy::too_many_arguments)] -pub fn add_working_tree_paths( +fn both_ends( workspace: &Path, - cwd: &Path, - target_name: &str, - given: &[String], - explicit_module: Option<&str>, - attribution_path: Option<&Path>, - usage_path: Option<&Path>, - no_verify: bool, - check: bool, -) -> Result { - let target = builtin_target(target_name)?; - let sources = discover_sources(workspace)?; - let store = disk::open(workspace); - let ownership = committed_ownership(&store, target_name)?; - - let mut grouped: BTreeMap> = BTreeMap::new(); - let mut prompts: Vec = Vec::new(); - for raw in given { - let path = workspace_relative(workspace, cwd, raw); - // A module's own prompt path is the one path add accepts that no owned - // glob names, and it is accepted for exactly one purpose: recording a - // docs edit. Naming it under `--module` is a different sentence and stays - // an unowned path, because the docs door is the path alone. - if explicit_module.is_none() { - if let Some(prompt) = sources.iter().find(|source| source.path == path) { - prompts.push(prompt.clone()); - continue; + store: &Store, + state: &ModuleState, + prompt_stale: bool, + pending_body: &str, +) -> Result { + let mut drift = false; + let mut drifted_paths: BTreeSet = BTreeSet::new(); + if has_object(store, &state.output_tree) { + for (path, (blob, _mode)) in flatten_tree(store, &state.output_tree)? { + let bytes = std::fs::read(workspace.join(&path)).ok(); + // An unreadable working file is not drift: the reader knows nothing + // about it, so it claims nothing, exactly as the query surface does. + if crate::attribution::code_drifted(Some(blob.as_str()), bytes.as_deref()) { + drift = true; + drifted_paths.insert(path); } } - let owner = match explicit_module { - Some(named) => Some(named.to_string()), - None => ownership - .get(&path) - .cloned() - .or_else(|| sole_glob_owner(&target, &sources, &path, &ownership)), - }; - let Some(owner) = owner else { - return Err(unowned_path(&target, &sources, &path)); - }; - grouped.entry(owner).or_default().push(path); } - - let mut records: Vec = Vec::new(); - for prompt in &prompts { - if attribution_path.is_some() || usage_path.is_some() { - return Err(crate::cli::usage(&format!( - "{:?} is module {:?}'s prompt, and a prompt path records a docs edit: the new prompt bytes and nothing else, no attribution work and no usage. Drop --attribution and --usage, or stage code with `napl add --module {}`", - prompt.path, prompt.module, prompt.module - ))); + let entries = match &state.attribution { + Some(recorded) if has_object(store, &recorded.record) => { + let bytes = get_blob(store, &recorded.record)?; + crate::attribution::parse(&String::from_utf8_lossy(&bytes)) + .map(|record| record.entries) + .unwrap_or_default() + } + _ => Vec::new(), + }; + let mut stale_references = 0usize; + for entry in &entries { + // A reference a capture verb recorded carries the content hash of each + // endpoint's post-edit bytes, and freshness reads those first. While the + // working tree still holds exactly those bytes the reference is fresh, + // whatever the committed baseline says: recording a reference and + // advancing the baseline are different acts, and the verb's own edit must + // never be counted as debt the verb created. + let working = entry + .file + .as_ref() + .and_then(|file| std::fs::read(workspace.join(file)).ok()); + let code_drift = entry + .file + .as_ref() + .map(|file| drifted_paths.contains(file)) + .unwrap_or(false); + if crate::attribution::entry_reference_status( + entry, + prompt_stale, + false, + code_drift, + working.as_deref(), + pending_body, + ) + .stale + { + stale_references += 1; } - records.push(add_docs(workspace, target_name, prompt, check)?); - } - for (module, paths) in grouped { - records.push(add( - workspace, - target_name, - &module, - &AddSource::Overlay(paths), - attribution_path, - usage_path, - no_verify, - check, - )?); } - Ok(staged_payload(target_name, records)) + Ok(BothEnds { + drift, + stale_references, + }) } -/// What the working-tree prompt has done to the prompt the store committed. -#[derive(Debug, Clone, PartialEq, Eq)] -enum PromptDrift { - /// The working tree matches the committed prompt byte for byte, prose - /// included. There is no docs edit to record. - Unchanged, - /// Prose moved and the semantic body did not: the one drift a docs record - /// carries. - DocsOnly, - /// The drift reached outside the docs fences. The line is the first semantic - /// body line that moved, in the coordinate system attribution endpoints name. - Semantic { - line: u32, - committed: String, - working: String, - }, +/// The module's pending normalized prompt body, or the empty body when the +/// workspace no longer has a prompt for it. +fn pending_body(sources: &[crate::source::SourceModule], module: &str) -> String { + sources + .iter() + .find(|source| source.module == module) + .map(|source| source.normalized_body.clone()) + .unwrap_or_default() } -/// Judge the working-tree prompt against the committed one. +/// The module PLAN beside the module's live STATE. /// -/// The semantic body is the whole question: it is the byte string every identity -/// derivation consumes, so a prompt whose semantic body is byte-identical has -/// moved nothing a module state is keyed on, whatever its prose did. The -/// committed semantic body is the blob the module's attribution was proven -/// against, which is the only committed copy of that byte string, so a module -/// with no recorded attribution has no baseline and no docs door. -fn prompt_drift(store: &Store, target_name: &str, prompt: &SourceModule) -> Result { - let Some(state) = crate::porcelain::committed_state(store, target_name, &prompt.module)? else { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "module {:?} has no committed state for target {target_name:?}, so there is no committed prompt for a docs edit to differ from; commit the module first with `napl add --module {}`", - prompt.module, prompt.module - ), - )); - }; - let Some(attribution) = &state.attribution else { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "module {:?} records no attribution, so the store holds no committed copy of its semantic body to prove a docs edit against; commit the module with `napl add --module {}`", - prompt.module, prompt.module - ), - )); - }; - let committed_semantic = String::from_utf8(get_blob(store, &attribution.prompt_hash)?) - .map_err(|_| { - error( - OutcomeCode::StoreIntegrity, - format!( - "the committed semantic body of module {:?} is not valid UTF-8", - prompt.module - ), - ) - })?; - if committed_semantic != prompt.normalized_body { - let (line, committed, working) = - first_difference(&committed_semantic, &prompt.normalized_body); - return Ok(PromptDrift::Semantic { - line, - committed, - working, - }); - } - // A module committed before prompt objects existed has no recorded physical - // body, so there is nothing to call identical: recording the docs edit is - // exactly what installs that baseline. - let recorded = match &state.prompt { - Some(object) => object.clone(), - None => return Ok(PromptDrift::DocsOnly), - }; - let written = napl_store::store_hash::object_name( - napl_store::store_hash::ObjectKind::Blob, - prompt.physical_body.as_bytes(), - ); - if recorded == written { - Ok(PromptDrift::Unchanged) - } else { - Ok(PromptDrift::DocsOnly) - } -} - -/// The first line two bodies disagree on, one-based, beside both spellings. +/// The plan is the invariant requirements a conforming implementation derives +/// from the working tree's prompts alone: owned globs, placement root, projection +/// ownership, receipt requirements, wave position, never a candidate-dependent +/// fact, which is `add`'s job. The state is what is true of the module right now: +/// tracked or pending, drift, stale reference count, attribution status. The +/// command named status never answers with a plan alone. /// -/// A body that is a prefix of the other disagrees at the first line past the -/// shorter one, so a pure insertion or deletion names a line rather than nothing. -fn first_difference(committed: &str, working: &str) -> (u32, String, String) { - let left: Vec<&str> = committed.lines().collect(); - let right: Vec<&str> = working.lines().collect(); - for index in 0..left.len().max(right.len()) { - let a = left.get(index).copied().unwrap_or_default(); - let b = right.get(index).copied().unwrap_or_default(); - if a != b { - return (index as u32 + 1, a.to_string(), b.to_string()); - } - } - (left.len().max(right.len()) as u32, String::new(), String::new()) -} +/// The payload also carries the derived entry-file projection, path and exact +/// content, computed the way commit computes it but over the working tree, so the +/// line a new file needs in a shared entry file is read from the tool rather than +/// composed by hand. +pub fn module_plan(workspace: &Path, target_name: &str, module: &str) -> Result { + let sources = discover_sources(workspace)?; + let target = builtin_target(target_name)?; + let source = sources.iter().find(|s| s.module == module).ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} has no prompt file in the workspace"), + ) + })?; + let graph = graph_for(&sources, target_name)?; + let levels = topological_levels(&graph)?; + let wave = *levels.get(module).ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} is not in the graph for target {target_name:?}"), + ) + })?; + let root = placement_root(source); -/// `napl add `: stage a docs record. -/// -/// The record is the new prompt bytes and nothing else: no candidate tree, no -/// attribution work, no usage. Commit installs it by writing the new prompt -/// object and re-pointing the module's prompt at it, and every derived fact -/// holds. Drift that reaches outside the docs fences refuses here rather than at -/// commit, naming the first semantic line that moved, because a docs record that -/// smuggled a semantic edit would be an unreviewed module change. -fn add_docs( - workspace: &Path, - target_name: &str, - prompt: &SourceModule, - check: bool, -) -> Result { let store = disk::open(workspace); - match prompt_drift(&store, target_name, prompt)? { - PromptDrift::Unchanged => { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "{:?} matches the committed prompt of module {:?} byte for byte, so there is no docs edit to record", - prompt.path, prompt.module - ), - )); + let artifact = compile(&store, &sources, target_name, None)?; + let pending = artifact.slices.iter().any(|slice| slice.module == module); + let frontier: BTreeSet = artifact + .slices + .iter() + .map(|slice| slice.module.clone()) + .collect(); + let head = store.head()?; + let head_states = crate::compile::head_states(&store, head.as_deref())?; + let installs = install_index(&store, head.as_deref())?; + let committed = committed_state(&store, target_name, module)?; + let state = match &committed { + Some(state) => { + let freshness = attribution_freshness(Some(state), module, target_name, &sources); + let prompt_stale = freshness["status"].as_str() != Some("fresh"); + let ends = both_ends( + workspace, + &store, + state, + prompt_stale, + &pending_body(&sources, module), + )?; + let verdict = pending_verdict( + &store, + &sources, + &target, + module, + &head_states, + &installs, + &frontier, + ends.drift, + )?; + let (reason, reasons, reland) = verdict.json_fields(pending); + json!({ + "attribution": freshness, + "docsDrift": crate::staging::docs_drift(workspace)? + .contains(&(target_name.to_string(), module.to_string())), + "drift": ends.drift, + "pending": pending, + "pendingReason": reason, + "pendingReasons": reasons, + "relandIdenticalBytes": reland, + "staleReferences": ends.stale_references as u64, + "tracked": true, + }) } - PromptDrift::Semantic { - line, - committed, - working, - } => { - return Err(crate::error::error_with( - OutcomeCode::InvalidCandidate, - format!( - "{:?} changed module {:?} outside its docs fences: semantic body line {line} moved from {committed:?} to {working:?}. A prompt path records a docs edit and nothing else; stage a real change with `napl add --module {}`", - prompt.path, prompt.module, prompt.module - ), - json!({ - "committedLine": committed, - "module": prompt.module, - "promptPath": prompt.path, - "semanticLine": line, - "teaches": format!("napl add --module {}", prompt.module), - "workingLine": working, - }), - )); + // A module with no committed state is not tracked, and saying so is the + // honest answer: it has no baseline to drift from and no references to + // stale, so those read false and zero because they are, not because + // nothing was looked at. + None => { + let verdict = pending_verdict( + &store, + &sources, + &target, + module, + &head_states, + &installs, + &frontier, + false, + )?; + let (reason, reasons, reland) = verdict.json_fields(pending); + json!({ + "attribution": attribution_freshness(None, module, target_name, &sources), + "docsDrift": false, + "drift": false, + "pending": pending, + "pendingReason": reason, + "pendingReasons": reasons, + "relandIdenticalBytes": reland, + "staleReferences": 0, + "tracked": false, + }) } - PromptDrift::DocsOnly => {} - } - - let bytes = std::fs::read(workspace.join(&prompt.path)) - .map_err(|e| disk::io(&workspace.join(&prompt.path), &e))?; - if check { - return Ok(json!({ - "checked": true, - "contractVersion": crate::version::CONTRACT_VERSION, - "kind": DOCS_RECORD, - "module": prompt.module, - "notJudged": not_judged(), - "outcome": "would-stage", - "promptPath": prompt.path, - "staged": false, - "target": target_name, - "validations": docs_validations(), - })); - } + }; - let object = napl_store::store_hash::object_name( - napl_store::store_hash::ObjectKind::Blob, - prompt.physical_body.as_bytes(), - ); - let record_dir = record_dir(workspace, &prompt.module, &object); - if record_dir.exists() { - std::fs::remove_dir_all(&record_dir).map_err(|e| disk::io(&record_dir, &e))?; - } - // The staged bytes are the whole prompt file, so commit re-parses what add - // read rather than trusting a body add derived: the ingest law holds for the - // docs door exactly as it holds for the other three. - write_file(&record_dir.join(DOCS_PROMPT_FILE), &bytes)?; - let record = json!({ - "kind": DOCS_RECORD, - "module": prompt.module, - "prompt": object, - "promptPath": prompt.path, - "receiptVersion": crate::receipt::RECEIPT_VERSION, - "target": target_name, - }); - write_file( - &record_dir.join("record.json"), - &crate::canon::canonical_bytes(&record), - )?; + let (projection_path, projection_bytes) = + working_tree_projection(workspace, &store, &target, &sources, module, &root)?; Ok(json!({ "contractVersion": crate::version::CONTRACT_VERSION, - "kind": DOCS_RECORD, - "module": prompt.module, - "outcome": "staged", - "promptPath": prompt.path, - "record": display_path(workspace, &record_dir), + "module": module, + "plan": { + "firstOutput": first_output(&target, &root, module), + "ownedGlobs": owned_globs(&target, &root), + "placementRoot": root, + "projection": { + "content": String::from_utf8_lossy(&projection_bytes), + "ownership": PROJECTION_OWNERSHIP, + "owner": PROJECTION_OWNER, + "path": projection_path, + "userSupplied": false, + }, + "projectionOwned": projection_owned_paths(&target, &root), + "projections": projection_labels(&target, &root), + "receiptRequirements": receipt_requirements(), + "wave": wave as u64, + }, + "state": state, "target": target_name, })) } -/// The `kind` a docs record carries, the one spelling commit branches on. -pub const DOCS_RECORD: &str = "docs"; - -/// The staged prompt file inside a docs record directory. -pub const DOCS_PROMPT_FILE: &str = "prompt.napl"; +/// What a projection path is, said in the plan every time one is mentioned. +/// +/// The obvious wrong first move in a cold module is to hand-write the crate's +/// entry file, because it is the file that visibly has to change for a new +/// source file to be reachable. It is derived, it is owned by no module, and a +/// candidate carrying it is rejected. Saying so beside the path is cheaper than +/// the rejection that would otherwise teach it. +pub const PROJECTION_OWNERSHIP: &str = "tool-derived"; +pub const PROJECTION_OWNER: &str = + "no module: this path is recomputed by commit from the crate's committed module file sets and is never user-supplied; a candidate that carries it is rejected as invalid-candidate"; -/// The validations a docs record's dry run actually ran. -fn docs_validations() -> Value { - json!([ - { - "detail": "the working-tree prompt parses, and its docs fences validate under the docs vocabulary", - "judged": true, - "name": "prompt grammar", - }, - { - "detail": "the semantic body is byte-identical to the committed one, so no identity the module is keyed on moved", - "judged": true, - "name": "docs-only drift", - }, - ]) +/// Every projection path this module's crate has, each labeled for what it is. +pub fn projection_labels(target: &TargetDef, root: &str) -> Vec { + projection_owned_paths(target, root) + .into_iter() + .map(|path| { + json!({ + "ownership": PROJECTION_OWNERSHIP, + "owner": PROJECTION_OWNER, + "path": path, + "userSupplied": false, + }) + }) + .collect() } -/// The payload every `add` form reports. +/// One concrete legal first output path for a module: the module's own name +/// under the target's source directory, inside the placement root. /// -/// `records` is the whole truth, one entry per staged module. A single-record -/// invocation also states its module and record at the top level, because that is -/// what a caller pipes straight into `commit --staged`. -pub fn staged_payload(target_name: &str, records: Vec) -> Value { - let mut payload = serde_json::Map::new(); - payload.insert( - "contractVersion".into(), - json!(crate::version::CONTRACT_VERSION), - ); - // A dry run reports the verdict it would have staged under, never `staged`, - // because nothing was. - let checked = records - .iter() - .all(|record| record["checked"].as_bool().unwrap_or(false)); - payload.insert( - "outcome".into(), - json!(if checked && !records.is_empty() { - "would-stage" - } else { - "staged" - }), +/// A plan that lists globs and a projection but no example leaves the author to +/// pick a path and find out afterwards whether it was ownable. Naming one that +/// certainly is turns the first move into a copy. +pub fn first_output(target: &TargetDef, root: &str, module: &str) -> Value { + let path = crate::paths::join_root( + root, + &format!( + "{}/{}.{}", + target.source_dir, + crate::paths::encode_name(module), + target.source_extension + ), ); - payload.insert("staged".into(), json!(!checked || records.is_empty())); - payload.insert("target".into(), json!(target_name)); - if records.len() == 1 { - payload.insert("module".into(), records[0]["module"].clone()); - if let Some(record) = records[0].get("record") { - payload.insert("record".into(), record.clone()); - } - // A dry run's verdict is the whole point of the invocation, so it is - // read at the top level and never dug out of the array. - for key in ["validations", "notJudged"] { - if let Some(value) = records[0].get(key) { - payload.insert(key.into(), value.clone()); - } - } - } - payload.insert("records".into(), Value::Array(records)); - Value::Object(payload) + json!({ + "detail": "one concrete path this module may own: its own name under the target's source directory, inside the placement root; any path matching the owned globs that is not a projection or a manifest is equally legal", + "path": path, + }) } -/// Resolve a path as the caller spelled it to a workspace-relative path. +/// The crate's entry-file projection as commit would derive it if this module +/// were committed from the working tree right now. /// -/// A path already spelled relative to the workspace wins, so the spelling every -/// napl payload uses round-trips. Otherwise it is resolved against the working -/// directory, which is what a shell completion produces. -fn workspace_relative(workspace: &Path, cwd: &Path, given: &str) -> String { - let normalized = given.replace('\\', "/"); - let direct = normalized.trim_start_matches("./").to_string(); - if workspace.join(&direct).exists() { - return direct; - } - let absolute = if Path::new(given).is_absolute() { - PathBuf::from(given) - } else { - cwd.join(given) - }; - match absolute.strip_prefix(workspace) { - Ok(rest) => rest.to_string_lossy().replace('\\', "/"), - Err(_) => direct, - } -} +/// The inputs are the other modules' committed file sets plus this module's +/// working-tree files, and the command providers are read the same way: from the +/// other modules' recorded surfaces plus this module's working-tree surface. That +/// is what makes the answer useful for a file that is new: a projection derived +/// from committed state alone could never name it, and naming it is the whole +/// question a reader asks here. +fn working_tree_projection( + workspace: &Path, + store: &Store, + target: &TargetDef, + sources: &[crate::source::SourceModule], + module: &str, + root: &str, +) -> Result<(String, Vec)> { + let src_root = crate::paths::join_root(root, &target.source_dir); + let mut files: Vec = Vec::new(); + let mut providers: Vec = Vec::new(); -/// The committed `path -> module` ownership map for one target. -fn committed_ownership(store: &Store, target_name: &str) -> Result> { - let mut out: BTreeMap = BTreeMap::new(); - let Some(head) = store.head()? else { - return Ok(out); + let states = match store.head()? { + Some(head) => snapshot_states(store, &head)?, + None => BTreeMap::new(), }; - for ((state_target, module), hash) in snapshot_states(store, &head)? { - if state_target != target_name { + for ((state_target, other), hash) in &states { + if state_target != &target.name || other == module { continue; } - let state = read_module_state(store, &hash)?; - if !crate::store_view::has_object(store, &state.output_tree) { + let state = read_module_state(store, hash)?; + if !has_object(store, &state.output_tree) { continue; } - for path in flatten_tree(store, &state.output_tree)?.into_keys() { - out.insert(path, module.clone()); + let owned: Vec = flatten_tree(store, &state.output_tree)?.into_keys().collect(); + if !owned + .iter() + .any(|path| crate::paths::is_under(path, &src_root)) + { + continue; + } + files.extend(owned); + for item in read_surface_items(store, &state.surface_hash)? { + if let Some(provider) = command_provider(&item.kind, &item.path) { + providers.push(provider); + } } } - Ok(out) -} -/// The module a not-yet-committed path belongs to, when exactly one module's -/// owned globs match it and no module has committed it. -/// -/// Where several prompts share one crate their owned globs are identical, so this -/// answers `None` rather than picking one: a new file in a shared crate is a -/// declaration the author has to make, through `--module` or an explicit -/// candidate directory. -fn sole_glob_owner( - target: &TargetDef, - sources: &[SourceModule], - path: &str, - ownership: &BTreeMap, -) -> Option { - if ownership.contains_key(path) { - return None; - } - let mut matched: Vec<&SourceModule> = sources - .iter() - .filter(|source| source.targets.iter().any(|t| t == &target.name)) - .filter(|source| is_owned_path(target, &placement_root(source), path)) - .collect(); - matched.dedup_by(|a, b| a.module == b.module); - match matched.as_slice() { - [only] => Some(only.module.clone()), - _ => None, + let working = working_owned_files(workspace, store, target, sources, module)?; + for item in crate::surface::extract_surface(target, root, &working)? { + if let Some(provider) = command_provider(&item.kind, &item.path) { + providers.push(provider); + } } + files.extend(working.into_keys()); + Ok(crate::target::derive_lib_projection( + target, root, &files, &providers, + )) } -/// The refusal a path no module owns earns: a usage error that lists the owned -/// globs which do exist, so the reader learns the shape of what is ownable -/// instead of only that this was not. -fn unowned_path( - target: &TargetDef, - sources: &[SourceModule], - path: &str, -) -> crate::error::CompilerError { - let mut globs: BTreeSet = BTreeSet::new(); - for source in sources { - if !source.targets.iter().any(|t| t == &target.name) { - continue; - } - for glob in owned_globs(target, &placement_root(source)) { - globs.insert(glob); - } +/// The module stem a surface item declares a command table for, when it declares +/// one: the convention is one `commands` function per module file. +fn command_provider(kind: &str, path: &str) -> Option { + if kind != "fn" { + return None; } - let listed: Vec = globs.into_iter().collect(); - crate::cli::usage(&format!( - "no module owns {path:?}, so there is nothing to stage it as. The owned globs that exist are: {}", - listed.join(", ") - )) + let prefix = path.strip_suffix("::commands")?; + (!prefix.is_empty() && !prefix.contains(' ')).then(|| prefix.to_string()) } -/// Turn one add source into the entry list ingestion judges. -fn scan_source( +/// A module's owned files as they sit in the working tree: the committed paths +/// read from disk, plus the paths under its globs no module has committed. +fn working_owned_files( workspace: &Path, store: &Store, target: &TargetDef, + sources: &[crate::source::SourceModule], module: &str, - sources: &[SourceModule], - source: &AddSource, -) -> Result> { - match source { - AddSource::CandidateDir(dir) => scan_candidate(dir), - AddSource::Overlay(paths) => { - let mut staged = committed_output(store, &target.name, module)?; - if staged.is_empty() { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "module {module:?} has no committed output to overlay onto; stage the whole tree with --module {module} or an explicit candidate directory" - ), - )); - } - for path in paths { - match std::fs::read(workspace.join(path)) { - Ok(bytes) => { - staged.insert(path.clone(), bytes); - } - // A named path that is gone is a deletion, which is a real - // staged state and not an error. - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - staged.remove(path); - } - Err(e) => return Err(disk::io(&workspace.join(path), &e)), +) -> Result>> { + let mut out: BTreeMap> = BTreeMap::new(); + if let Some(state) = committed_state(store, &target.name, module)? { + if has_object(store, &state.output_tree) { + for path in flatten_tree(store, &state.output_tree)?.into_keys() { + if let Ok(bytes) = std::fs::read(workspace.join(&path)) { + out.insert(path, bytes); } } - Ok(entries_from(&staged)) } - AddSource::WorkingTree => { - let committed = committed_output(store, &target.name, module)?; - let mut staged: BTreeMap> = BTreeMap::new(); - for path in committed.keys() { - match std::fs::read(workspace.join(path)) { - Ok(bytes) => { - staged.insert(path.clone(), bytes); - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(disk::io(&workspace.join(path), &e)), - } - } - for path in unclaimed_working_paths(workspace, store, target, module, sources)? { - let bytes = std::fs::read(workspace.join(&path)) - .map_err(|e| disk::io(&workspace.join(&path), &e))?; - staged.insert(path, bytes); - } - if staged.is_empty() { - return Err(nothing_stageable(workspace, target, module, sources)); - } - Ok(entries_from(&staged)) + } + for path in crate::staging::unclaimed_working_paths(workspace, store, target, module, sources)? { + if let Ok(bytes) = std::fs::read(workspace.join(&path)) { + out.insert(path, bytes); } } + Ok(out) } -/// The refusal for a module whose owned tree holds nothing stageable. -/// -/// The usual cause is that the only file under the crate's source directory is -/// the entry file, which is a derived projection this module may not own. A -/// refusal that says "owns nothing" while a file plainly sits there reads as a -/// bug, so it names which present paths are projections and what the first legal -/// file would be. The obvious wrong next move, hand-writing the entry file, is -/// warned against here rather than one rejection later. -fn nothing_stageable( - workspace: &Path, - target: &TargetDef, - module: &str, - sources: &[SourceModule], -) -> crate::error::CompilerError { - let root = sources - .iter() - .find(|s| s.module == module) - .map(placement_root) - .unwrap_or_default(); - let present: Vec = crate::porcelain::projection_labels(target, &root) - .into_iter() - .filter(|label| { - label["path"] - .as_str() - .is_some_and(|path| workspace.join(path).is_file()) - }) - .collect(); - let first = crate::porcelain::first_output(target, &root, module); - let hint = match first["path"].as_str() { - Some(path) => format!( - "; the first legal file would be {path:?}, and the crate's entry file is not one of this module's to write" - ), - None => String::new(), - }; - crate::error::error_with( - OutcomeCode::InvalidCandidate, - format!( - "module {module:?} owns nothing stageable in the working tree yet{hint}. Write an owned file, or stage an explicit candidate directory" - ), - json!({ - "firstOutput": first, - "ownedGlobs": owned_globs(target, &root), - "placementRoot": root, - "projectionsPresent": present, - }), - ) -} - -/// Working-tree files under a module's owned globs that no module has committed -/// and no other module's globs also match: files this module owns by elimination. -pub fn unclaimed_working_paths( +/// Observe, never mutate, the manifest/placement mismatches the toolchain now +/// only diagnoses: a module placed in a crate the workspace manifest does not +/// list, or a crate directory missing a manifest. Manifests are user-owned +/// seams, so these are reports, not writes. +fn manifest_diagnostics( workspace: &Path, - store: &Store, - target: &TargetDef, - module: &str, - sources: &[SourceModule], -) -> Result> { - let ownership = committed_ownership(store, &target.name)?; - let Some(source) = sources.iter().find(|s| s.module == module) else { - return Ok(Vec::new()); + target_name: &str, + sources: &[crate::source::SourceModule], +) -> Vec { + let Ok(target) = builtin_target(target_name) else { + return Vec::new(); }; - let root = placement_root(source); - let src_dir = crate::paths::join_root(&root, &target.source_dir); - let dir = workspace.join(&src_dir); - if !dir.is_dir() { - return Ok(Vec::new()); - } - let mut out: Vec = Vec::new(); - for entry in scan_candidate(&dir)? { - if entry.kind != EntryKind::File { + let members = workspace_members(workspace, &target); + let mut roots: Vec = sources.iter().map(placement_root).collect(); + roots.sort(); + roots.dedup(); + let mut out: Vec = Vec::new(); + for root in roots { + if root.is_empty() { continue; } - let path = crate::paths::join_root(&src_dir, &entry.path); - if !is_owned_path(target, &root, &path) { - continue; + if let Some(members) = &members { + if !members.contains(&root) { + out.push(manifest_diagnostic( + &root, + // The manifest this observation means is the workspace-root + // one, which holds the member list, not the crate's own. + &target.manifest_file, + "crate-not-listed-in-workspace-manifest", + &format!("the crate {root:?} holds a placed module but the workspace manifest does not list it as a member"), + )); + } } - if sole_glob_owner(target, sources, &path, &ownership).as_deref() == Some(module) { - out.push(path); + let manifest = workspace.join(&root).join(&target.manifest_file); + if !manifest.is_file() { + out.push(manifest_diagnostic( + &root, + &crate::paths::join_root(&root, &target.manifest_file), + "crate-directory-missing-manifest", + &format!("the crate directory {root:?} receives placed modules but has no manifest"), + )); } } - Ok(out) -} - -fn entries_from(files: &BTreeMap>) -> Vec { - files - .iter() - .map(|(path, bytes)| ScannedEntry { - path: path.clone(), - kind: EntryKind::File, - bytes: bytes.clone(), - }) - .collect() + out } -/// A module's committed output as `path -> bytes`. -fn committed_output( - store: &Store, - target_name: &str, - module: &str, -) -> Result>> { - let mut out: BTreeMap> = BTreeMap::new(); - let Some(state) = crate::porcelain::committed_state(store, target_name, module)? else { - return Ok(out); - }; - if !crate::store_view::has_object(store, &state.output_tree) { - return Ok(out); - } - for (path, (blob, _mode)) in flatten_tree(store, &state.output_tree)? { - out.insert(path, get_blob(store, &blob)?); - } - Ok(out) +/// One manifest observation, which always locates the file it means. +/// +/// A manifest is a user-owned seam: the toolchain never writes one, so a +/// diagnostic that only alluded to "the manifest" would leave the reader hunting +/// for the file they are the only one allowed to edit. Every diagnostic therefore +/// carries the exact workspace-relative path and says plainly whose file it is. +fn manifest_diagnostic( + crate_root: &str, + manifest: &str, + observation: &str, + detail: &str, +) -> Value { + json!({ + "crate": crate_root, + "detail": detail, + "manifest": manifest, + "observation": observation, + "owner": "user", + "remedy": format!("{manifest} is yours to edit; the toolchain diagnoses manifests and never writes them"), + }) } -/// The wire spelling of an attribution mode on a staged record. -pub fn mode_slug(mode: AttributionMode) -> &'static str { - match mode { - AttributionMode::Prove => "prove", - AttributionMode::PreProven => "pre-proven", - AttributionMode::Escape => "escape", +/// The crate directories the workspace-root manifest lists as members, or +/// `None` when there is no readable workspace manifest to compare against. +fn workspace_members(workspace: &Path, target: &TargetDef) -> Option> { + let text = std::fs::read_to_string(workspace.join(&target.manifest_file)).ok()?; + let mut members: Vec = Vec::new(); + for line in text.lines() { + let trimmed = line.trim().trim_end_matches(','); + if let Some(inner) = trimmed + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + { + if !is_manifest_path(target, inner) && inner.contains('/') { + members.push(inner.to_string()); + } + } } + Some(members) } -/// Write the staged record's metadata: what commit needs to find the pieces and -/// to know how the attribution update was meant to be judged. +/// Classify a module's attribution freshness and, when it is not fresh, name the +/// exact fix. /// -/// The mode rides the record rather than the command line so that a staged -/// record commits the same way whoever picks it up. A record written before -/// modes existed carries only `noVerify` and reads as `escape` or `prove`. -pub fn write_record( - record_dir: &Path, +/// It is a pure observation over content already resolved: an absent record, a +/// record installed unproven, or a record whose recorded prompt hash no longer +/// matches the current normalized body all read as needing a fresh commit. A +/// record proven against the body still on disk is fresh. +fn attribution_freshness( + state: Option<&ModuleState>, module: &str, - target_name: &str, - tree_hash: &str, - attribution_present: bool, - mode: AttributionMode, -) -> Result<()> { - let record = json!({ - "attribution": attribution_present, - "candidateTreeHash": tree_hash, - "mode": mode_slug(mode), + target: &str, + sources: &[crate::source::SourceModule], +) -> Value { + let directive = json!({ + "command": "commit", + "directive": format!( + "commit module {module:?} for target {target:?} with a fresh attribution update" + ), "module": module, - "noVerify": mode == AttributionMode::Escape, - "receiptVersion": crate::receipt::RECEIPT_VERSION, - "target": target_name, + "target": target, }); - write_file( - &record_dir.join("record.json"), - &crate::canon::canonical_bytes(&record), - ) -} - -/// The workspace-relative directory a staged record for one candidate lives in. -pub fn record_dir(workspace: &Path, module: &str, tree_hash: &str) -> PathBuf { - workspace.join(STAGING_DIR).join(format!( - "{}-{}", - crate::paths::encode_name(module), - &tree_hash[..16] - )) + match state.and_then(|state| state.attribution.as_ref()) { + None => json!({ "fix": directive, "status": "absent" }), + Some(attribution) => { + let current = sources + .iter() + .find(|source| source.module == module) + .map(|source| source.normalized_source_hash.as_str()); + if attribution.stale { + json!({ + "fix": directive, + "reason": "installed with --no-verify", + "status": "stale", + }) + } else if current != Some(attribution.prompt_hash.as_str()) { + json!({ + "fix": directive, + "reason": "the prompt changed since the attribution was recorded", + "status": "stale", + }) + } else { + json!({ "status": "fresh" }) + } + } + } } -/// The workspace-relative spelling of a path, the form `add` reports and -/// `commit --staged` accepts. -pub fn relative_record(workspace: &Path, path: &Path) -> String { - display_path(workspace, path) +/// The causal history reachable from HEAD, optionally narrowed to one module. +/// +/// The `ordinal` field is a presentation device computed from the causal graph +/// at read time. It is never stored and never enters an identity. +pub fn log(workspace: &Path, module: Option<&str>) -> Result { + let store = disk::open(workspace); + let Some(head) = store.head()? else { + return Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "entries": [], + "head": Value::Null, + })); + }; + let mut entries: Vec = Vec::new(); + for (ordinal, event) in causal_order(&store, &head)?.iter().enumerate() { + let transition_id = event_transition(&store, event)?; + let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; + if let Some(wanted) = module { + if transition["module"].as_str() != Some(wanted) { + continue; + } + } + entries.push(json!({ + "event": event, + "module": transition["module"], + "newState": transition["newState"], + "noteCount": attached_notes(&store, &transition_id)?.len() as u64, + "ordinal": ordinal as u64, + // A reference riding a transition is provenance exactly as a note is, + // so the log counts both and names the one hop that serves their full + // text. Provenance a reader cannot find is provenance that may as well + // not be recorded. + "referenceCount": transition_references(&store, &transition)?.len() as u64, + "target": transition["target"], + "transition": transition_id, + "writeSet": transition["writeSet"], + })); + } + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "entries": entries, + "head": head, + "inspect": { + "command": "napl show ", + "detail": "the full text of a transition's notes and reference entries is served by `napl show `, one named hop from any entry's transition hash", + }, + })) } -/// Resolve the attribution update that rides this stage. +/// Describe any stored object: its kind and, for JSON kinds, its payload. /// -/// The order is transport first, affirmation second, refusal last. When the -/// diffs carry every recorded claim, nothing is asked for: the paperwork updates -/// itself as a consequence of the diff already being content-addressed on both -/// sides. When they do not, the listed claims are what `--attribution` affirms, -/// and an explicit map that covers the whole record still wins whole, because an -/// author reshaping a mapping is allowed to say so outright. -#[allow(clippy::too_many_arguments)] -fn resolve_attribution( - workspace: &Path, - store: &Store, - target: &TargetDef, - root: &str, - module: &str, - prompt: &SourceModule, - candidate: &Candidate, - attribution_path: Option<&Path>, - no_verify: bool, -) -> Result>> { - let body_lines = prompt.normalized_body.lines().count() as u32; - let supplied = match attribution_path { - Some(path) => Some(std::fs::read(path).map_err(|e| disk::io(path, &e))?), - None => None, +/// A transition also lists the notes attached to it inline, hash and text, so a +/// note attached is a note findable: nobody should have to know that notes live +/// behind an index and are stored as blobs in order to read one back. +pub fn show(workspace: &Path, hash: &str) -> Result { + let store = disk::open(workspace); + let (kind, payload) = get_object(&store, hash)?; + let body = if kind == ObjectKind::Blob { + json!({ "bytes": payload.len() as u64 }) + } else { + serde_json::from_slice(&payload).map_err(|e| { + error( + OutcomeCode::StoreIntegrity, + format!("object {hash} is not JSON: {e}"), + ) + })? }; - if no_verify { - // The escape hatch proves nothing and says so: the record installs - // marked stale, and an absent map stays absent. - return Ok(supplied); + let mut out = json!({ + "body": body, + "contractVersion": crate::version::CONTRACT_VERSION, + "hash": hash, + "kind": kind.as_wire(), + }); + if kind == ObjectKind::Transition { + out["notes"] = Value::Array(attached_notes(&store, hash)?); + out["references"] = Value::Array(transition_references(&store, &out["body"])?); } + Ok(out) +} - let transported = transport_for(workspace, store, target, module, prompt, candidate)?; - - match (transported, supplied) { - // Nothing recorded to carry: the map is the author's to supply, exactly - // as it was before transport existed. - (None, Some(bytes)) => { - let map = parse_map(&bytes)?; - attribution::validate(target, root, module, &candidate.files, body_lines, &map)?; - Ok(Some(bytes)) - } - // A module's first add has no committed map to transport, and it demands - // no hand-authored one either: the trivial whole map claims every staged - // file in full by the whole prompt body. It is honestly coarse, it is - // present and fresh rather than absent, and it is why the cold path to a - // verified first commit never needs `--no-verify`. - (None, None) => { - let whole = attribution::whole_map(module, &target.name, &candidate.files); - attribution::validate(target, root, module, &candidate.files, body_lines, &whole)?; - Ok(Some(attribution::to_yaml(&whole))) - } - (Some(outcome), Some(bytes)) => { - let map = parse_map(&bytes)?; - let layered = layer_affirmations(module, &outcome, &map)?; - attribution::validate(target, root, module, &candidate.files, body_lines, &layered)?; - Ok(Some(attribution::to_yaml(&layered))) - } - (Some(outcome), None) => { - // Transport seeds from the evidence a capture verb already recorded: - // a claim refusing in a rewritten region is affirmed automatically - // when a reference covering that region carries endpoint hashes that - // still match the staged bytes, because the tool that applied the - // edit owns its diff. A recorded reference whose hashes no longer - // match affirms nothing and stays listed. - let (outcome, affirmed) = - self_affirm(outcome, &candidate.files, &prompt.normalized_body); - if !outcome.complete() { - let mut payload = refusal_payload(module, &outcome.refusals); - if let Some(object) = payload.as_object_mut() { - object.insert("affirmedFromReferenceRecord".into(), json!(affirmed)); - } - return Err(crate::error::error_with( - OutcomeCode::InvalidCandidate, - refusal_detail(module, &outcome.refusals), - payload, - )); - } - let carried = outcome.record; - attribution::validate(target, root, module, &candidate.files, body_lines, &carried)?; - Ok(Some(attribution::to_yaml(&carried))) - } - } +/// The reference entries one transition introduced, served inline. +/// +/// A reference attached to a transition is exactly as findable as a note +/// attached to one: both are provenance riding a transition, and both are read +/// here rather than through plumbing. What this transition introduced is the +/// difference between its new state's attribution record and the record its base +/// state carried, so a commit that merely carried the module's existing +/// references forward lists none and a capture verb's transition lists the one it +/// recorded. A base that cannot be resolved degrades to the whole new record +/// rather than erroring, the way every other read here degrades. +fn transition_references(store: &Store, body: &Value) -> Result> { + let Some(new_state) = body["newState"].as_str() else { + return Ok(Vec::new()); + }; + let Some(record) = state_attribution_entries(store, new_state)? else { + return Ok(Vec::new()); + }; + let base = match body["baseStateKey"].as_str() { + Some(base) => state_attribution_entries(store, base)?.unwrap_or_default(), + None => Vec::new(), + }; + Ok(record + .into_iter() + .filter(|entry| !base.contains(entry)) + .map(|entry| entry.as_json()) + .collect()) } -/// Transport the module's recorded attribution onto the staged bytes and the -/// pending prompt body, or `None` when nothing is recorded yet. -fn transport_for( - workspace: &Path, +/// The attribution entries a module state's record carries, or `None` when the +/// state names no record or the record is not readable. +fn state_attribution_entries( store: &Store, - target: &TargetDef, - module: &str, - prompt: &SourceModule, - candidate: &Candidate, -) -> Result> { - let Some(state) = crate::porcelain::committed_state(store, &target.name, module)? else { + state_hash: &str, +) -> Result>> { + if !has_object(store, state_hash) { return Ok(None); - }; - let Some(recorded) = state.attribution.as_ref() else { + } + let state = read_module_state(store, state_hash)?; + let Some(attribution) = state.attribution else { return Ok(None); }; - if !crate::store_view::has_object(store, &recorded.record) - || !crate::store_view::has_object(store, &recorded.prompt_hash) - { + if !has_object(store, &attribution.record) { return Ok(None); } - let record_bytes = get_blob(store, &recorded.record)?; - let Some(record) = attribution::parse(&String::from_utf8_lossy(&record_bytes)) else { - return Ok(None); - }; - let committed_body = String::from_utf8_lossy(&get_blob(store, &recorded.prompt_hash)?) - .into_owned(); - let committed_files: BTreeMap = - committed_output(store, &target.name, module)? - .into_iter() - .map(|(path, bytes)| (path, String::from_utf8_lossy(&bytes).into_owned())) - .collect(); - let staged_files: BTreeMap = candidate - .files - .iter() - .map(|(path, bytes)| (path.clone(), String::from_utf8_lossy(bytes).into_owned())) - .collect(); - let _ = workspace; - Ok(Some(transport_attribution( - &record, - &TransportInputs { - committed_body: &committed_body, - pending_body: &prompt.normalized_body, - committed_files: &committed_files, - staged_files: &staged_files, - }, - ))) + let bytes = get_blob(store, &attribution.record)?; + Ok(crate::attribution::parse(&String::from_utf8_lossy(&bytes)).map(|record| record.entries)) } -/// Affirm refused claims from the evidence a capture verb recorded. -/// -/// A reference the verbs record carries the content hash of each endpoint's -/// post-edit bytes. When those hashes still match the staged bytes and the -/// pending body, the reference proves the current content is exactly what its -/// recorder wrote, so a claim landing in that region needs no hand-authored -/// affirmation: the record IS the affirmation. The claim keeps its recorded -/// coordinates, which the hashes prove are coordinates in these bytes. +/// The notes attached to one transition, each as its blob name and its text. /// -/// Nothing here guesses. A reference whose hashes no longer match proves -/// nothing, affirms nothing, and its claim stays in the refusal list beside -/// every other. -fn self_affirm( - outcome: TransportOutcome, - staged_files: &BTreeMap>, - pending_body: &str, -) -> (TransportOutcome, usize) { - let body_lines = pending_body.lines().count() as u32; - // The references whose evidence still holds, by the file they name. - let mut evidence: BTreeMap> = BTreeMap::new(); - for entry in &outcome.record.entries { - let Some(file) = &entry.file else { continue }; - if !entry.self_affirming() { - continue; - } - if !entry.hashes_match(staged_files.get(file).map(Vec::as_slice), pending_body) { - continue; - } - evidence - .entry(file.clone()) - .or_default() - .push((entry.line_start, entry.line_end)); - } - if evidence.is_empty() { - return (outcome, 0); - } - - let TransportOutcome { record, refusals } = outcome; - let mut kept: Vec = Vec::new(); - let mut affirmed = 0usize; - for refusal in refusals { - let entry = &record.entries[refusal.index]; - let covered = entry - .file - .as_ref() - .and_then(|file| evidence.get(file).map(|spans| (file.clone(), spans))) - .map(|(file, spans)| { - let lines = staged_files - .get(&file) - .map(|bytes| String::from_utf8_lossy(bytes).lines().count() as u32) - .unwrap_or(0); - let in_bounds = entry.whole_file - || (entry.line_start >= 1 && entry.line_end <= lines); - let prompt_in_bounds = - entry.whole_prompt || (entry.prompt_start >= 1 && entry.prompt_end <= body_lines); - let overlaps = entry.whole_file - || spans.iter().any(|(start, end)| { - entry.line_start <= *end && *start <= entry.line_end - }); - in_bounds && prompt_in_bounds && overlaps - }) - .unwrap_or(false); - if covered { - affirmed += 1; +/// The index is a plain list of blob names beside the store, so a missing or +/// unreadable index is simply no notes: a reader degrades to empty rather than +/// erroring, exactly as every other read here does. +fn attached_notes(store: &Store, transition: &str) -> Result> { + let index = store.root.join("notes").join(format!("{transition}.json")); + let bytes = match std::fs::read(&index) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(disk::io(&index, &e)), + }; + let names: Vec = serde_json::from_slice(&bytes).unwrap_or_default(); + let mut out: Vec = Vec::new(); + for name in names { + if !has_object(store, &name) { continue; } - kept.push(refusal); + let body = get_blob(store, &name)?; + let parsed: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); + out.push(json!({ + "hash": name, + "note": parsed["note"].as_str().unwrap_or_default(), + })); } - ( - TransportOutcome { - record, - refusals: kept, - }, - affirmed, - ) + Ok(out) } -/// Layer affirmations over the transported remainder. -/// -/// A map with one entry per listed claim affirms exactly those, in the order the -/// refusal listed them, and every carried claim is kept. A map covering the whole -/// record replaces it outright. Anything between is refused rather than guessed -/// at, because a partial map whose entries cannot be matched to claims is not an -/// affirmation of anything in particular. -fn layer_affirmations( - module: &str, - outcome: &TransportOutcome, - supplied: &Attribution, -) -> Result { - if outcome.complete() || supplied.entries.len() == outcome.record.entries.len() { - return Ok(supplied.clone()); - } - if supplied.entries.len() != outcome.refusals.len() { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "the attribution update for module {module:?} carries {} entries, but the transport listed {} claims needing affirmation and the record holds {} in all; supply one entry per listed claim, in the order they were listed, or a complete map", - supplied.entries.len(), - outcome.refusals.len(), - outcome.record.entries.len() - ), - )); - } - let mut layered = outcome.record.clone(); - for (affirmation, refusal) in supplied.entries.iter().zip(outcome.refusals.iter()) { - layered.entries[refusal.index] = affirmation.clone(); - } - Ok(layered) +/// The raw payload bytes of an object, with the envelope stripped. +pub fn cat_file(workspace: &Path, hash: &str) -> Result> { + Ok(get_object(&disk::open(workspace), hash)?.1) } -/// Read a supplied `--attribution` map, answering a bad one with the whole -/// contract rather than a bare "not parseable". +/// The recorded surface of a module as of HEAD. /// -/// The first refusal is the only one the author gets before they go rewrite the -/// file, so it carries every field, the closed vocabularies, and one complete -/// valid example. This is the same law the `--ref` payload already follows. -/// A map that fails mid-parse, or parses into no entries at all, is refused -/// naming the exact line and the construct that stopped the reader, beside the -/// full contract. A bare count of zero would describe the reader's outcome -/// rather than the author's document. -fn parse_map(bytes: &[u8]) -> Result { - let bad = |why: String, located: Option<&attribution::MapParseError>| { - crate::error::error_with( - OutcomeCode::InvalidCandidate, - why, - json!({ - "mapContract": attribution::map_contract(), - "stoppedAt": match located { - Some(err) => json!({ - "construct": err.construct, - "detail": err.detail, - "line": err.line, - }), - None => Value::Null, - }, - }), - ) - }; - let text = std::str::from_utf8(bytes) - .map_err(|_| bad("the --attribution file is not valid UTF-8".to_string(), None))?; - let parsed = attribution::parse_detailed(text).map_err(|err| { - bad( - format!( - "the --attribution file stopped being readable at line {}, reading a {}: {}", - err.line, err.construct, err.detail +/// A module with no committed state under this target has no recorded surface at +/// all, and an empty item list would read as a real surface with nothing public. +/// The honest answer names the absent baseline and carries no items, exactly as +/// `diff` and `blame` answer for a missing baseline. +pub fn surface_of(workspace: &Path, target: &str, module: &str) -> Result { + let store = disk::open(workspace); + let Some(state) = committed_state(&store, target, module)? else { + return Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "module": module, + "reason": format!( + "module {module:?} has no committed state under target {target:?}, so there is no recorded surface to serve and no baseline to read one from" ), - Some(&err), - ) - })?; - if parsed.module.is_empty() || parsed.target.is_empty() { - return Err(bad( - "the --attribution file names no module or no target".to_string(), - None, - )); - } - Ok(parsed) -} - -/// Every staged record currently sitting in the staging directory, workspace -/// relative, with the module and target each one names. -/// -/// This is a plain directory read: staged records are disposable litter by the -/// git-state law, so what is there is the whole truth and nothing indexes them. -pub fn staged_records(workspace: &Path) -> Result> { - let dir = workspace.join(STAGING_DIR); - let mut out: Vec = Vec::new(); - let listing = match std::fs::read_dir(&dir) { - Ok(listing) => listing, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), - Err(e) => return Err(disk::io(&dir, &e)), + "target": target, + "tracked": false, + })); }; - let mut paths: Vec = Vec::new(); - for entry in listing { - let entry = entry.map_err(|e| disk::io(&dir, &e))?; - if entry.path().join("record.json").is_file() { - paths.push(entry.path()); - } - } - // The directory hands entries back in whatever order the filesystem holds - // them, which is exactly the ambient enumeration order the determinism - // contract excludes: sort before anything reads them. - paths.sort(); - for path in paths { - let bytes = std::fs::read(path.join("record.json")) - .map_err(|e| disk::io(&path.join("record.json"), &e))?; - let meta: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); - out.push(StagedRecord { - module: meta["module"].as_str().unwrap_or_default().to_string(), - target: meta["target"].as_str().unwrap_or("rust").to_string(), - relative: display_path(workspace, &path), - path, - }); + let items = read_surface_items(&store, &state.surface_hash)?; + let mut out = surface_json(module, target, &items); + if let Some(object) = out.as_object_mut() { + object.insert("tracked".to_string(), Value::Bool(true)); } Ok(out) } -/// One staged record as the staging directory holds it. -#[derive(Debug, Clone)] -pub struct StagedRecord { - pub module: String, - pub target: String, - /// The workspace-relative spelling, which is what `--staged` accepts. - pub relative: String, - pub path: PathBuf, +/// Whether this workspace has a committed head at all. +/// +/// The one observation a command whose meaning requires a baseline makes before +/// it answers, so a headless workspace gets the prerequisite sequence instead of +/// an empty shape that would read as success. +pub fn has_committed_head(workspace: &Path) -> Result { + Ok(disk::open(workspace).head()?.is_some()) } -/// `napl commit` with no arguments: install every staged record in dependency -/// wave order, one outcome reported per record. +/// Drift between committed outputs and the working tree, as a canonical patch. /// -/// A multi-module add followed by a bare commit is one command each. The -/// sequencing and the retry the decision table already defines belong to the -/// tool, so nobody hand-loops over records in wave order and gets it subtly -/// wrong. Each record still goes through exactly the same guarded transaction -/// `--staged` runs, and each reports its own outcome whatever the others did. -pub fn commit_all(workspace: &Path) -> Result<(Value, i32)> { - let records = staged_records(workspace)?; - let ordered = in_wave_order(workspace, records)?; - let mut outcomes: Vec = Vec::new(); - let mut exit_code = 0; - // Set once an earlier record in this invocation has moved the head. Only - // then is a later record's write-conflict or stale-input this invocation's - // own doing, and only then is re-deriving it the tool's job rather than a - // report the caller has to act on. - let mut head_moved = false; - for record in &ordered { - let mut report = commit_staged(workspace, &record.path); - let mut retried = false; - if head_moved && retryable_against_a_moved_head(report.outcome) { - // Re-derive exactly what re-running `add` would produce now: the - // record's own ingested bytes, re-drafted against the head this - // invocation just advanced, with its resolved attribution carried - // back in as affirmations. Modules sharing one crate overlap on the - // crate's ownership write key, so the second one always arrives - // against a head the first one moved; the retry is what makes that - // pair one invocation instead of a hand-ordered sequence. - // - // A docs record has nothing to re-derive: its staged bytes are a - // prompt, and the commit reads the head it arrives at, so retrying it - // as it stands is exactly re-running add. - let docs = record_kind(&record.path).ok().flatten().as_deref() == Some(DOCS_RECORD); - if docs || rederive_against_head(workspace, record).is_ok() { - retried = true; - report = commit_staged(workspace, &record.path); - } - } - if report.outcome != OutcomeCode::Success && exit_code == 0 { - exit_code = crate::error::outcome_exit_code(report.outcome); - } - if report.outcome == OutcomeCode::Success { - head_moved = true; - } - outcomes.push(json!({ - "module": record.module, - "outcome": report.payload["outcome"].clone(), - "record": record.relative, - "report": report.payload, - "retried": retried, - "target": record.target, +/// A target no committed module owns anything under has no baseline, and the +/// honest answer is `tracked: false` with the reason: an empty patch would say +/// "nothing drifted" when the truth is "nothing was compared". +pub fn diff(workspace: &Path, target: &str) -> Result { + let store = disk::open(workspace); + let committed = committed_text(&store, target)?; + if committed.is_empty() { + return Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "reason": format!( + "no committed module owns any output under target {target:?}, so there is no baseline to diff against" + ), + "target": target, + "tracked": false, })); } - let mut payload = json!({ + let paths: Vec = committed.keys().cloned().collect(); + let working = read_working_text(workspace, &paths); + let patch = render_drift(&committed, &working); + Ok(json!({ "contractVersion": crate::version::CONTRACT_VERSION, - "installed": outcomes.len() as u64, - "order": ordered - .iter() - .map(|record| json!(record.module)) - .collect::>(), - "outcomes": outcomes, - }); - // A bare commit that found nothing staged says so instead of dressing - // emptiness as success, and it names the work that is actually waiting: every - // module carrying working-tree drift, beside the exact `napl add` invocation - // that would stage it. The empty commit is the teacher, never the trap. - if ordered.is_empty() { - let drifted = drifted_modules(workspace)?; - // The teacher includes docs drift: a prompt whose prose moved is work - // waiting exactly as drifted code is, and the invocation that records it - // is the prompt path itself. - let docs_drifted = docs_drifted_prompts(workspace)?; - if let Some(object) = payload.as_object_mut() { - object.insert("stagedRecords".to_string(), json!(0)); - object.insert( - "detail".to_string(), - json!(match (drifted.is_empty(), docs_drifted.is_empty()) { - (true, true) => "zero records were staged and no module carries working-tree drift, so there was nothing to install".to_string(), - (true, false) => format!( - "zero records were staged; {} prompt(s) carry docs-only drift and are listed with the exact `napl add ` that would record each", - docs_drifted.len() - ), - (false, true) => format!( - "zero records were staged; {} module(s) carry working-tree drift and are listed with the exact `napl add` invocation that would stage each", - drifted.len() - ), - (false, false) => format!( - "zero records were staged; {} module(s) carry working-tree drift and {} prompt(s) carry docs-only drift, each listed with the exact `napl add` invocation that would record it", - drifted.len(), - docs_drifted.len() - ), - }), - ); - object.insert( - "docsDriftedPrompts".to_string(), - Value::Array(docs_drifted), - ); - object.insert( - "driftedModules".to_string(), - Value::Array(drifted), - ); - } - } - Ok((payload, exit_code)) + "patch": patch, + "target": target, + "tracked": true, + })) } -/// Every module whose committed output the working tree has moved away from, -/// beside the exact `napl add` invocation that stages it. +/// Derived line ancestry for one committed path. /// -/// This is the same drift `status` and `diff` report, read straight off the -/// committed output trees, so the empty bare commit can never disagree with the -/// query surface about what is waiting. -fn drifted_modules(workspace: &Path) -> Result> { +/// A path no committed module owns has no ancestry to replay, and empty ancestry +/// would read as a file that exists and was never written. It answers +/// `tracked: false` naming the absent baseline instead, an honest success shape. +/// +/// Blame reads committed bytes only. When the working tree has moved away from +/// them the report carries a drift notice naming `napl diff` as where the +/// uncommitted change lives, so ancestry is never mistaken for the present file. +pub fn blame_path(workspace: &Path, path: &str) -> Result { let store = disk::open(workspace); let Some(head) = store.head()? else { - return Ok(Vec::new()); + return Ok(untracked_json( + path, + "this workspace has no committed head, so no module owns this path yet", + )); }; - let mut listed: Vec = Vec::new(); - for ((target, module), state_hash) in snapshot_states(&store, &head)? { - let state = read_module_state(&store, &state_hash)?; - if !crate::store_view::has_object(&store, &state.output_tree) { + if !committed_manifest_all_targets(&store)?.contains(path) { + return Ok(untracked_json( + path, + "no committed module owns this path, so there is no committed baseline to trace it through", + )); + } + let mut revisions: Vec = Vec::new(); + for event in causal_order(&store, &head)? { + let transition_id = event_transition(&store, &event)?; + let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; + let touched = transition["installPaths"] + .as_array() + .map(|paths| paths.iter().any(|p| p.as_str() == Some(path))) + .unwrap_or(false); + if !touched { continue; } - let mut paths: Vec = Vec::new(); - for (path, (blob, _mode)) in flatten_tree(&store, &state.output_tree)? { - let bytes = std::fs::read(workspace.join(&path)).ok(); - // An unreadable working file is not drift: the reader knows nothing - // about it, so it claims nothing, exactly as status does. - if attribution::code_drifted(Some(blob.as_str()), bytes.as_deref()) { - paths.push(path); - } - } - if paths.is_empty() { + let Some(state_hash) = transition["newState"].as_str() else { continue; - } - listed.push(json!({ - "addCommand": format!("napl add --module {module}"), - "driftedPaths": paths, - "module": module, - "target": target, - })); + }; + let state = read_module_state(&store, state_hash)?; + let files = flatten_tree(&store, &state.output_tree)?; + let Some((blob, _)) = files.get(path) else { + continue; + }; + let bytes = get_blob(&store, blob)?; + revisions.push(Revision { + transition: transition_id, + content: String::from_utf8_lossy(&bytes).into_owned(), + }); } - Ok(listed) + let mut value = blame_json(path, &blame(&revisions)); + // Blame reads committed bytes only, and says so when it matters. A path + // whose working tree has moved away from the committed output carries a + // notice naming `napl diff` as where the uncommitted change lives, so + // ancestry over committed bytes is never mistaken for the present file. + let committed = revisions.last().map(|revision| revision.content.clone()); + let working = std::fs::read(workspace.join(path)).ok(); + let working_text = working + .as_deref() + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()); + let drifted = match (&committed, &working_text) { + (Some(committed), Some(working)) => committed != working, + _ => false, + }; + if let Some(object) = value.as_object_mut() { + object.insert("drift".to_string(), Value::Bool(drifted)); + if drifted { + object.insert( + "driftNotice".to_string(), + Value::String(format!( + "the working tree of {path:?} differs from the committed output; this ancestry is over the committed bytes only, and the uncommitted change lives in `napl diff`" + )), + ); + object.insert("driftInspect".to_string(), Value::String("napl diff".to_string())); + } + } + Ok(value) } -/// Every prompt whose prose moved inside its docs fences since it was committed, -/// beside the `napl add ` that records it. +/// The source-language scan `napl lint ` performs: parse every +/// committed module's owned working-tree file under that target with the pinned +/// grammar and report one finding per file that fails. /// -/// A prompt whose drift reaches the semantic body is not listed here: that module -/// reads as source-changed everywhere else, and naming the docs door beside it -/// would teach the invocation that refuses. -fn docs_drifted_prompts(workspace: &Path) -> Result> { +/// This is a different question from the graph analyses beside it and it needs no +/// roots declaration to answer, because nothing about reachability is involved. +/// The payload names its scope, so an empty findings list is never read wider +/// than the scan that produced it. +pub fn source_syntax_scan(workspace: &Path, target_name: &str) -> Result { let store = disk::open(workspace); - if store.head()?.is_none() { - return Ok(Vec::new()); - } - let mut listed: Vec = Vec::new(); - for source in discover_sources(workspace)? { - for target in &source.targets { - if matches!(prompt_drift(&store, target, &source), Ok(PromptDrift::DocsOnly)) { - listed.push(json!({ - "addCommand": format!("napl add {}", source.path), - "module": source.module, - "promptPath": source.path, - "target": target, - })); - } + let target = builtin_target(target_name)?; + let sources = discover_sources(workspace)?; + let roots: BTreeSet = sources + .iter() + .filter(|source| source.targets.iter().any(|t| t == target_name)) + .map(placement_root) + .collect(); + let mut findings: Vec = Vec::new(); + let mut scanned = 0u64; + for path in committed_manifest(&store, target_name)?.into_keys() { + if !roots + .iter() + .any(|root| crate::surface::is_target_source(&target, root, &path)) + { + continue; + } + let Ok(bytes) = std::fs::read(workspace.join(&path)) else { + // A committed path the working tree no longer holds is drift, which + // `diff` and `status` already report; the syntax scan judges bytes it + // can read and claims nothing about bytes it cannot. + continue; + }; + scanned += 1; + if let Some(failure) = crate::surface::parse_check(&target, &path, &bytes)? { + findings.push(json!({ + "autoFixable": false, + "file": failure.path, + "kind": "source-parse-error", + "line": failure.line, + "parseFailure": failure.as_json(), + "proposal": format!( + "fix the syntax at line {} of {:?}; the pinned grammar cannot parse it, so no surface can be extracted from it", + failure.line, failure.path + ), + "scope": crate::surface::SOURCE_SYNTAX_SCOPE, + })); } } - Ok(listed) + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "detail": "every committed module's owned working-tree file under this target was parsed with the target's pinned grammar; this scan judges syntax and nothing else, and it needs no roots declaration", + "filesScanned": scanned, + "findings": findings, + "rootsRequired": false, + "scope": crate::surface::SOURCE_SYNTAX_SCOPE, + "target": target_name, + })) } -/// The two outcomes an earlier record in the same invocation can cause: the -/// head moved under this record, and moving it is what this invocation did. -/// -/// `base-output-changed` is deliberately not here. It says the module's *own* -/// committed output moved, which no other module's commit can do, so it is a -/// genuine report to the caller rather than self-inflicted sequencing. -fn retryable_against_a_moved_head(outcome: OutcomeCode) -> bool { - matches!(outcome, OutcomeCode::WriteConflict | OutcomeCode::StaleInput) +/// Diagnose a workspace. +pub fn lint_workspace(workspace: &Path, target: &str) -> Result<(Graph, LintReport, Value)> { + let store = disk::open(workspace); + let sources = discover_sources(workspace)?; + let artifact = compile(&store, &sources, target, None)?; + let graph = crate::compile::graph_for(&sources, target)?; + let definition = builtin_target(target)?; + let states = match store.head()? { + Some(snapshot) => snapshot_states(&store, &snapshot)?, + None => BTreeMap::new(), + }; + + let mut committed_input_keys: BTreeMap> = BTreeMap::new(); + let mut referenced_names: BTreeMap> = BTreeMap::new(); + let mut surface_names: BTreeMap> = BTreeMap::new(); + for module in graph.keys() { + let (state, _) = state_of(&store, &states, &definition, module)?; + committed_input_keys.insert(module.clone(), state.input_key.clone()); + surface_names.insert( + module.clone(), + read_surface_items(&store, &state.surface_hash)? + .iter() + .filter_map(|item| item.path.rsplit("::").next().map(str::to_string)) + .collect(), + ); + referenced_names.insert(module.clone(), identifiers(&store, &state)?); + } + let current_input_keys: BTreeMap = artifact + .slices + .iter() + .map(|slice| (slice.module.clone(), slice.input_key.clone())) + .collect(); + + let roots = declared_roots(&sources); + let report = lint( + &graph, + &LintInput { + roots, + committed_input_keys, + current_input_keys, + referenced_names, + surface_names, + }, + ); + let value = lint_json(&report); + Ok((graph, report, value)) } -/// Re-derive a staged record against the current head, in place. +/// Cost over an explicit set: receipt object names in the store, receipt file +/// paths, usage-proxy session windows, or any mixture. /// -/// The record's ingested candidate bytes are the immutable truth of what was -/// staged, whichever of the three sources produced them, so re-running the add -/// pipeline over them is exactly re-running add: the receipt is re-drafted -/// against the head that just moved, the attribution transport is recomputed, -/// and the resolved map already in the record rides back in as affirmations. -/// The record directory is keyed by the candidate tree hash, which the bytes did -/// not change, so the record rewrites itself at the same path. -fn rederive_against_head(workspace: &Path, record: &StagedRecord) -> Result<()> { - let (_, candidate_dir, attribution_path, mode) = resolve(&record.path)?; - add( - workspace, - &record.target, - &record.module, - &AddSource::CandidateDir(candidate_dir), - attribution_path.as_deref(), - None, - mode == AttributionMode::Escape, - false, - )?; - Ok(()) +/// A session window is priced here, lazily, at read time: a capture verb records +/// the window as a reference and never aggregates it, so the log lines are summed +/// only when a reader asks. A model the pinned table does not know stays +/// `unknown`, never zero. +pub fn cost( + workspace: &Path, + names: &[String], + files: &[String], + sessions: &[String], +) -> Result { + let store = disk::open(workspace); + let mut lines: Vec = Vec::new(); + for session in sessions { + lines.extend(crate::cost::lines_from_session( + &std::fs::read_to_string(workspace.join(crate::capture::USAGE_LOG_PATH)) + .unwrap_or_default(), + session, + )); + } + for name in names { + let body = get_json(&store, name, ObjectKind::Receipt)?; + lines.push(receipt_line(workspace, name, &body)?); + } + for file in files { + let path = Path::new(file); + let bytes = std::fs::read(path).map_err(|e| disk::io(path, &e))?; + let body: Value = serde_json::from_slice(&bytes).map_err(|e| { + error( + OutcomeCode::InvalidReceipt, + format!("{file}: invalid JSON: {e}"), + ) + })?; + let name = crate::canon::json_hash(ObjectKind::Receipt, &body); + lines.push(receipt_line(workspace, &name, &body)?); + } + Ok(cost_report(&lines)) } -/// Remove one staged record directory. -fn sweep_record(workspace: &Path, record: &Path) -> Result<()> { - let dir = if record.join("record.json").is_file() { - record.to_path_buf() - } else if record.file_name().and_then(|n| n.to_str()) == Some("record.json") { - match record.parent() { - Some(parent) => parent.to_path_buf(), - None => return Ok(()), +/// One receipt's cost line: a reported total priced directly, a recorded session +/// window priced against the log the record names. +fn receipt_line(workspace: &Path, name: &str, body: &Value) -> Result { + let module = body["module"].as_str().unwrap_or_default(); + let reported = normalize_reported(&body["reported"]); + match crate::cost::window_from_reported(&reported) { + Some(window) => { + let log = window_log(workspace, &window.log_path); + Ok(crate::cost::line_from_window( + name, + module, + &window, + log.as_deref(), + )) } - } else { - return Ok(()); - }; - if !dir.starts_with(workspace.join(STAGING_DIR)) { - return Ok(()); - } - match std::fs::remove_dir_all(&dir) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(disk::io(&dir, &e)), + None => line_from_reported(name, module, &reported), } } -/// Sort staged records into dependency wave order, the order commit installs in. +/// The bytes of a log a record names, read at the moment the reader asks. /// -/// A module the graph does not place sorts after every placed one rather than -/// blocking the whole batch: its own commit will say what is wrong with it, and -/// refusing to sequence is not the same as refusing to install. -fn in_wave_order(workspace: &Path, records: Vec) -> Result> { - let sources = discover_sources(workspace)?; - let mut keyed: Vec<(usize, String, StagedRecord)> = Vec::new(); - for record in records { - let wave = crate::compile::graph_for(&sources, &record.target) - .ok() - .and_then(|graph| crate::graph::topological_levels(&graph).ok()) - .and_then(|levels| levels.get(&record.module).copied()) - .unwrap_or(usize::MAX); - keyed.push((wave, record.relative.clone(), record)); +/// The path is a record's own workspace-relative path, so a path that escapes +/// the workspace or is absolute is not a log this reader will open: it reads as +/// absent, which the report already knows how to say. +fn window_log(workspace: &Path, log_path: &str) -> Option { + let candidate = Path::new(log_path); + if candidate.is_absolute() || log_path.split('/').any(|part| part == "..") { + return None; } - keyed.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); - Ok(keyed.into_iter().map(|(_, _, record)| record).collect()) + std::fs::read_to_string(workspace.join(candidate)).ok() } -/// Install what add staged: read the staged record and run the one guarded -/// commit over the immutable staged copy. +/// Sweep every object no named head reaches, and the staged records beside them. /// -/// A record that installs is swept. It has become history, and leaving it in the -/// staging directory would make the next bare commit re-attempt an installed -/// transition against a head that has already moved past it. Nobody should ever -/// hand-edit `.napl` to be rid of one, and nobody should have to. -pub fn commit_staged(workspace: &Path, record: &Path) -> CommitReport { - // A docs record installs through its own door: it carries no candidate and - // drafts no receipt, so the candidate pipeline has nothing to read. - let report = match record_kind(record) { - Ok(Some(kind)) if kind == DOCS_RECORD => crate::commit::commit_docs(workspace, record), - Ok(_) => match resolve(record) { - Ok((receipt_path, candidate_dir, attribution_path, mode)) => commit_with_mode( - workspace, - &receipt_path, - &candidate_dir, - attribution_path.as_deref(), - mode, - ), - Err(err) => CommitReport { - outcome: err.code, - payload: error_payload(&err, false, false), - }, - }, - Err(err) => CommitReport { - outcome: err.code, - payload: error_payload(&err, false, false), - }, +/// The sweep set is computed outside the lock; only the deletions run under it, +/// and a writer publishing concurrently is never blocked because publication +/// takes no lock at all. +/// +/// Staged records belong in this report because they are exactly what the +/// git-state law calls disposable litter: never promoted to history, re-creatable +/// by re-running `add`, and reachable from no head. `--apply` sweeps them, so +/// nobody ever hand-edits `.napl` to be rid of one. +pub fn gc(workspace: &Path, apply: bool) -> Result { + let store = disk::init(workspace)?; + let all: BTreeSet = store.names()?.into_iter().collect(); + let reachable = match store.head()? { + Some(head) => reachable_from_head(&store, &head)?, + None => BTreeSet::new(), }; - if report.outcome == OutcomeCode::Success { - if let Err(err) = sweep_record(workspace, record) { - return CommitReport { - outcome: err.code, - payload: error_payload(&err, true, true), - }; + let swept = plan_sweep(&all, &reachable); + let staged: Vec = crate::staging::staged_records(workspace)? + .into_iter() + .map(|record| record.relative) + .collect(); + // The repack is the apply path's whole sweep: it packs the reachable closure + // and removes every loose file it enumerated, unreachable ones included, so + // packing the reachable and removing the rest is one operation. + let repacked = if apply { + disk::repack(&store, &reachable, "gc")? + } else { + disk::plan_repack(&store, &reachable)? + }; + if apply { + for name in &swept { + disk::remove_object(&store, name)?; + } + for record in &staged { + let dir = workspace.join(record); + match std::fs::remove_dir_all(&dir) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(disk::io(&dir, &e)), + } } } - report + // Kept counts objects the store actually holds. The reachable set may name a + // hash no object backs — a `readSet` identity, a derivation key — and keeping + // a name that resolves to nothing is not keeping an object. + let mut payload = gc_json(&swept, all.len() - swept.len()); + payload["stagedRecords"] = json!(staged); + let mut repack = crate::maintenance::repack_json(&repacked); + repack["applied"] = json!(apply); + payload["repack"] = repack; + Ok(payload) } -/// The directory a `--staged` argument names, whether the caller spelled the -/// directory or the `record.json` inside it. -pub fn record_directory(record: &Path) -> Result { - if record.join("record.json").is_file() { - Ok(record.to_path_buf()) - } else if record.file_name().and_then(|n| n.to_str()) == Some("record.json") { - Ok(record - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from("."))) - } else { - Err(error( - OutcomeCode::InvalidReceipt, - format!("no staged record at {}", record.display()), - )) - } -} +/// The exact command the owner runs to take the migrated object files out of +/// the working tree. It is theirs to run, not the toolchain's: removing them +/// from the index is an ordinary git commit and never a store mutation. +pub const STORE_MIGRATE_REMOVAL: &str = "git rm -r .napl/store/objects"; -/// The metadata a staged record declares. -pub fn record_meta(record_dir: &Path) -> Result { - let bytes = std::fs::read(record_dir.join("record.json")) - .map_err(|e| disk::io(&record_dir.join("record.json"), &e))?; - serde_json::from_slice(&bytes).map_err(|e| { - error( - OutcomeCode::InvalidReceipt, - format!("staged record is not JSON: {e}"), - ) - }) -} +/// `napl store migrate`: the one way `refs/heads/napl-store` comes to exist. +/// +/// It walks the tracked store's loose objects and every pack into git blobs, +/// re-hashing each envelope to the napl name it must keep, builds one fanout +/// tree and one deterministic commit over them, and creates the branch there. It +/// refuses outside a git repository and refuses when the branch already exists, +/// and it touches no tracked file: the payload names the object files as ready +/// to leave the working tree and the exact command that does it, because that +/// removal is the owner's ordinary git commit. +/// +/// After it, selection observes the branch and every later read and write goes +/// through the git provider with no flag set anywhere. +pub fn store_migrate(workspace: &Path) -> Result<(bool, Value)> { + let store = disk::open(workspace); + let Some(repository) = napl_store::git_store::enclosing_repository(workspace) else { + return Ok(( + false, + migrate_refusal( + "not-a-git-repository", + "this workspace is not inside a git repository, so there is no object database to migrate into; the tracked layout with its packfile mitigation stays the floor", + ), + )); + }; + let provider = napl_store::git_store::GitProvider::open(&repository, &store.root) + .map_err(disk::git_error)?; + if provider.tip().map_err(disk::git_error)?.is_some() { + return Ok(( + false, + migrate_refusal( + "already-migrated", + "refs/heads/napl-store already exists, and migration is a one-shot verb that never rewrites a store branch", + ), + )); + } -/// The `kind` a staged record declares. An ordinary candidate record declares -/// none, so a record written before docs records existed reads as `None`. -fn record_kind(record: &Path) -> Result> { - let dir = record_directory(record)?; - Ok(record_meta(&dir)?["kind"].as_str().map(str::to_string)) -} + let tracked = tracked_object_files(&store.root)?; + let outcome = provider + .convert_tracked_store(&store.root, crate::version::CONTRACT_VERSION) + .map_err(disk::git_error)?; -fn resolve(record: &Path) -> Result<(PathBuf, PathBuf, Option, AttributionMode)> { - let record_dir = record_directory(record)?; - let meta = record_meta(&record_dir)?; - let no_verify = meta["noVerify"].as_bool().unwrap_or(false); - let mode = match meta["mode"].as_str() { - Some("pre-proven") => AttributionMode::PreProven, - Some("escape") => AttributionMode::Escape, - Some("prove") => AttributionMode::Prove, - // A record written before modes existed says only whether it escaped. - _ if no_verify => AttributionMode::Escape, - _ => AttributionMode::Prove, - }; - let attribution_path = if meta["attribution"].as_bool().unwrap_or(false) { - Some(record_dir.join("attribution.yaml")) - } else { - None - }; Ok(( - record_dir.join("receipt.json"), - record_dir.join("candidate"), - attribution_path, - mode, + true, + json!({ + "branch": napl_store::git_store::STORE_BRANCH, + "commit": outcome.commit, + "contractVersion": crate::version::CONTRACT_VERSION, + "objects": outcome.objects, + "outcome": "migrated", + "readyToLeaveWorkingTree": { + "detail": "migration touched none of these: removing them from the index is your ordinary git commit, not a store mutation", + "files": tracked, + "removalCommand": STORE_MIGRATE_REMOVAL, + "root": format!("{}/objects", disk::STORE_DIR), + }, + "snapshot": outcome.snapshot, + }), )) } -fn display_path(workspace: &Path, path: &Path) -> String { - path.strip_prefix(workspace) - .map(|rest| rest.to_string_lossy().replace('\\', "/")) - .unwrap_or_else(|_| path.to_string_lossy().into_owned()) -} -napl-store blob v1 15043 -//! The commit transaction, up to but not including the syscalls. -//! -//! Everything the pipeline decides lives here: what the candidate is allowed to -//! contain, whether the receipt reproduces, what surface the candidate records, -//! what transition that derives, and which install steps materialize it. The -//! binary supplies the bytes and performs the writes; it makes no decision of -//! its own. -//! -//! The ordering is the contract's ordering, and it is what makes the critical -//! section bounded: ingest, verify, extract, derive, and plan all happen before -//! any lock is taken, so the locked region only rechecks the footprint and swaps -//! pointers, bounded by the transition's file count and hashing nothing. - -use std::collections::{BTreeMap, BTreeSet}; - -use napl_store::store_hash::ObjectKind; -use napl_store::store_tree::{diff_trees, Tree, TreeChange, FILE_MODE}; -use napl_store::store_txn_core::{plan_install_steps, InstallStep}; -use serde_json::{json, Value}; - -use crate::candidate::Candidate; -use crate::canon::{hash_json, is_object_name}; -use crate::compile::{head_states, slice_at, state_of}; -use crate::error::{error, OutcomeCode, Result}; -use crate::evidence::{evidence_report, validate_all}; -use crate::objects::{ - event_hash, event_json, module_state_hash, module_state_json, ownership_write_key, - snapshot_hash, snapshot_json, snapshot_members, state_write_key, transition_hash, - transition_json, Footprint, ModuleState, Transition, -}; -use crate::paths::is_under; -use crate::receipt::{receipt_json, receipt_semantic_hash, Receipt, RECEIPT_VERSION}; -use crate::slice::CompileSlice; -use crate::source::SourceModule; -use crate::store_view::{load_tree_objects, ObjectSource}; -use crate::surface::{extract_surface, surface_hash, surface_json, SurfaceItem}; -use crate::target::{derive_projection, is_owned_path, placement_root, target_def_hash, TargetDef}; - -/// One object the transaction wants published, kind and payload. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PendingObject { - pub name: String, - pub kind: ObjectKind, - pub payload: Vec, +/// How many files the tracked object layout holds under the store root: every +/// loose object file, every pack, and every pack index. +fn tracked_object_files(root: &Path) -> Result { + let mut count = 0; + let mut stack = vec![root.join("objects")]; + while let Some(directory) = stack.pop() { + let entries = match std::fs::read_dir(&directory) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(disk::io(&directory, &e)), + }; + for entry in entries { + let entry = entry.map_err(|e| disk::io(&directory, &e))?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + count += 1; + } + } + } + Ok(count) } -/// Everything a commit derived, ready to be applied. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DerivedCommit { - pub transition: Transition, - pub transition_id: String, - pub new_state_key: String, - pub surface_hash: String, - pub surface_items: Vec, - /// The recorded receipt object, provenance included. Distinct from the - /// transition's `receipt`, which names the semantic body alone. - pub receipt_object: String, - /// The verdict on every evidence item the receipt carried. Provenance: it is - /// reported, and it changes nothing about what installs. - pub evidence: Value, - /// Blobs and provenance, published before the critical section. - pub objects: Vec, +/// A migration refusal: the reason named, nothing done, nothing written. +fn migrate_refusal(reason: &str, detail: &str) -> Value { + json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "detail": detail, + "outcome": "refused", + "reason": reason, + }) } -/// The state the accepted transition writes, plus how to materialize it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Accepted { - pub snapshot: String, - pub event: String, - pub steps: Vec, - /// The derived projection: recomputed from the merged head, never reused. - pub projection_path: String, - pub projection_bytes: Vec, - pub objects: Vec, - pub rebased: bool, +/// Verify envelope hashes, kind validity, causal parent links, and every pack's +/// name, index, and members. +pub fn fsck_store(workspace: &Path) -> Result<(bool, Value)> { + let store = disk::open(workspace); + let mut findings = fsck(&store)?; + findings.extend(disk::verify_packs(&store)?.into_iter().map(pack_finding)); + findings.sort(); + findings.dedup(); + Ok((findings.is_empty(), fsck_json(&findings))) } -/// Check a candidate against its module's placement and allowed outputs. -pub fn check_candidate_shape( - target: &TargetDef, - module: &str, - candidate: &Candidate, -) -> Result<()> { - if candidate.manifest.is_empty() { - return Err(error(OutcomeCode::InvalidCandidate, "candidate is empty")); - } - let root = placement_root(target, module); - for path in candidate.manifest.keys() { - if !is_under(path, &root) { - return Err(error( - OutcomeCode::InvalidCandidate, - format!("candidate path {path:?} is outside the placement root {root:?}"), - )); +/// Translate one store-level pack finding into the compiler's fsck vocabulary. +fn pack_finding(finding: napl_store::store_io::PackFinding) -> crate::maintenance::FsckFinding { + use napl_store::store_io::PackFinding as Pack; + let (pack, detail) = match finding { + Pack::NameMismatch { pack } => { + let detail = "the pack's bytes do not hash to its name".to_string(); + (pack, detail) } - if !is_owned_path(target, module, path) { - return Err(error( - OutcomeCode::InvalidCandidate, - format!("candidate path {path:?} is not an allowed output for {module:?}"), - )); + Pack::IndexDisagrees { pack, detail } => (pack, detail), + Pack::MemberCorrupt { pack, object } => { + let detail = format!("packed object {object} does not verify against its name"); + (pack, detail) } - } - let manifest = format!("{root}/{}", target.manifest_file); - if !candidate.manifest.contains_key(&manifest) { - return Err(error( - OutcomeCode::InvalidCandidate, - format!("candidate is missing its manifest {manifest:?}"), - )); - } - Ok(()) + }; + crate::maintenance::FsckFinding::PackDamaged { pack, detail } } -/// Verify a receipt against independently recomputed identities. -pub fn verify_receipt( - source: &dyn ObjectSource, - sources: &[SourceModule], - receipt: &Receipt, - candidate: &Candidate, -) -> Result { - if receipt.receipt_version != RECEIPT_VERSION { - return Err(error( - OutcomeCode::UnsupportedVersion, - format!("receiptVersion {} is not supported", receipt.receipt_version), - )); - } - if receipt.candidate_tree_hash != candidate.tree_hash { - return Err(error( - OutcomeCode::InvalidReceipt, - format!( - "candidateTreeHash {} does not match the ingested candidate {}", - receipt.candidate_tree_hash, candidate.tree_hash - ), - )); - } - if receipt.output_manifest != candidate.manifest { - return Err(error( - OutcomeCode::InvalidReceipt, - "outputManifest does not match the ingested candidate", - )); - } - if !is_object_name(&receipt.base_workspace_hash) { - return Err(error( - OutcomeCode::InvalidReceipt, - "baseWorkspaceHash is not an object name", - )); +/// Repack the store when its loose-object count crosses the compiled-in bound. +/// +/// Called after a successful commit's critical section and outside every lock: +/// the pointer has already moved, so a repack here never lengthens the bounded +/// section and never blocks a writer. It packs the same reachable closure `gc +/// --apply` packs, so the pack it writes is byte-identical to gc's. +pub fn repack_if_loose(store: &Store) -> Result> { + if disk::loose_object_count(store)? <= crate::maintenance::LOOSE_OBJECT_REPACK_THRESHOLD { + return Ok(None); } - let slice = slice_at( - source, - sources, - &receipt.target, - &receipt.module, - receipt.base_store_snapshot.as_deref(), - )?; - let mismatch = |field: &str, claimed: &str, recomputed: &str| { - error( - OutcomeCode::InvalidReceipt, - format!("{field} {claimed} does not reproduce at the receipt's bases (recomputed {recomputed})"), - ) + let reachable = match store.head()? { + Some(head) => reachable_from_head(store, &head)?, + None => return Ok(None), }; - if slice.slice_hash != receipt.slice_hash { - return Err(mismatch("sliceHash", &receipt.slice_hash, &slice.slice_hash)); - } - if slice.input_key != receipt.input_key { - return Err(mismatch("inputKey", &receipt.input_key, &slice.input_key)); + let outcome = disk::repack(store, &reachable, "gc")?; + if outcome.pack.is_none() { + return Ok(None); } - if slice.base_state_key != receipt.base_state_key { - return Err(mismatch( - "baseStateKey", - &receipt.base_state_key, - &slice.base_state_key, - )); + Ok(Some(crate::maintenance::repack_json(&outcome))) +} + +/// Derive a repair plan for a target's committed outputs, and optionally apply +/// it. +pub fn restore(workspace: &Path, target: &str, apply: bool) -> Result { + let store = disk::open(workspace); + let expected: Vec = committed_manifest(&store, target)? + .into_iter() + .map(|(path, hash)| FileFact { path, hash }) + .collect(); + let roots: BTreeSet = expected + .iter() + .filter_map(|fact| fact.path.split('/').next().map(str::to_string)) + .collect(); + let observed = observed_facts(workspace, &roots)?; + let plan = plan_restore(&expected, &observed); + if apply { + if let RestorePlan::Apply(steps) = &plan { + apply_restore(&store, workspace, steps)?; + } } - Ok(slice) + Ok(restore_json(&plan)) } -/// Extract the candidate's surface and derive the whole transition. -pub fn derive_commit( - target: &TargetDef, - receipt: &Receipt, - candidate: &Candidate, -) -> Result { - let items = extract_surface(target, &receipt.module, &candidate.files)?; - // Extraction is claimed to be a pure function of the bytes; a second pass - // over the same ingested bytes is the cheapest way to keep that claim honest. - if items != extract_surface(target, &receipt.module, &candidate.files)? { - return Err(error( - OutcomeCode::MappingIntegrity, - "surface extraction is not reproducible for this candidate", - )); +/// Attach later provenance to a recorded transition without touching semantic +/// state. +/// The refusal a `--to` that is not an object name earns. +/// +/// A working path is the mistake a reader makes here, because every other verb +/// takes one, so the refusal does not merely say no: it names the two commands +/// that produce and inspect an object name and shows the one line that gets one. +fn not_an_object_name(given: &str) -> crate::error::CompilerError { + error( + OutcomeCode::Usage, + format!( + "--to expects the object name of a recorded transition, and {given:?} is not one. \ + Object names come from `napl log`, and `napl show ` inspects one, \ + for example: napl log | napl q '.entries[-1].transition' -" + ), + ) +} + +pub fn add_note(workspace: &Path, transition: &str, note: &str) -> Result { + let store = disk::init(workspace)?; + // A path is never an object name, and neither is anything else outside the + // store's name alphabet: refuse before opening anything, so the reader is + // told how to get a name rather than handed a missing-object error. + if !is_object_name(transition) || !has_object(&store, transition) { + return Err(not_an_object_name(transition)); } - let surface_value = surface_json(&receipt.module, &target.name, &items); - let recorded_surface = surface_hash(&receipt.module, &target.name, &items); + get_json(&store, transition, ObjectKind::Transition)?; + let body = json!({ "note": note, "transition": transition }); + let payload = crate::canon::canonical_bytes(&body); + let name = disk::publish(&store, ObjectKind::Blob, &payload, transition)?; - let new_state = ModuleState { - module: receipt.module.clone(), - target: target.name.clone(), - input_key: Some(receipt.input_key.clone()), - slice_hash: Some(receipt.slice_hash.clone()), - surface_hash: recorded_surface.clone(), - output_tree: candidate.tree_hash.clone(), + let index = store.root.join("notes").join(format!("{transition}.json")); + let mut names: BTreeSet = match std::fs::read(&index) { + Ok(bytes) => serde_json::from_slice::>(&bytes) + .unwrap_or_default() + .into_iter() + .collect(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(), + Err(e) => return Err(disk::io(&index, &e)), }; - let new_state_key = module_state_hash(&new_state); + names.insert(name.clone()); + let listed: Vec = names.iter().map(|n| json!(n)).collect(); + crate::workspace::write_file(&index, &crate::canon::canonical_bytes(&Value::Array(listed)))?; + Ok(json!({ + "note": name, + "outcome": "success", + "semanticStatePublished": false, + "transition": transition, + })) +} - let root = placement_root(target, &receipt.module); - let mut read_set = vec![ - format!("input-key:{}", receipt.input_key), - format!("base-state:{}", receipt.base_state_key), - format!("target-def:{}", target_def_hash(target)), - ]; - read_set.sort(); - let footprint = Footprint { - read_set, - write_set: vec![ - ownership_write_key(&root), - state_write_key(&target.name, &receipt.module), - ], - derived_projections: vec![target.projection_path.clone()], - install_paths: candidate.manifest.keys().cloned().collect(), - }; - let transition = Transition { - module: receipt.module.clone(), - target: target.name.clone(), - input_key: receipt.input_key.clone(), - base_state_key: receipt.base_state_key.clone(), - new_state: new_state_key.clone(), - receipt: receipt_semantic_hash(receipt), - surface: recorded_surface.clone(), - footprint, - }; - let transition_id = transition_hash(&transition); +/// The events reachable from a head, ordered oldest first by causal depth. +pub fn causal_order(store: &Store, head: &str) -> Result> { + let head_event = snapshot_event(store, head)?; + let events = walk_events(store, &head_event, None)?; + let mut depths: BTreeMap = BTreeMap::new(); + for event in &events { + depth_of(store, event, &mut depths)?; + } + let mut ordered = events; + ordered.sort_by(|a, b| (depths[a], a).cmp(&(depths[b], b))); + Ok(ordered) +} - let mut objects: Vec = Vec::new(); - for (_, bytes) in &candidate.files { - objects.push(blob_object(bytes)); +fn depth_of(store: &Store, event: &str, depths: &mut BTreeMap) -> Result { + if let Some(known) = depths.get(event) { + return Ok(*known); } - for tree in candidate.tree_objects.values() { - objects.push(json_object(ObjectKind::Tree, &tree_value(tree))); + let value = get_json(store, event, ObjectKind::Event)?; + let mut depth = 0usize; + for parent in value["parents"].as_array().cloned().unwrap_or_default() { + if let Some(parent) = parent.as_str() { + depth = depth.max(depth_of(store, parent, depths)? + 1); + } } - objects.push(json_object(ObjectKind::Surface, &surface_value)); - objects.push(json_object( - ObjectKind::ModuleState, - &module_state_json(&new_state), - )); - objects.push(json_object(ObjectKind::Receipt, &receipt_json(receipt))); - objects.push(json_object( - ObjectKind::Receipt, - &crate::receipt::receipt_semantic_json(receipt), - )); - objects.push(json_object( - ObjectKind::TargetDef, - &crate::target::target_def_json(target), - )); - objects.push(json_object( - ObjectKind::Transition, - &transition_json(&transition), - )); - - Ok(DerivedCommit { - transition, - transition_id, - new_state_key, - surface_hash: recorded_surface, - surface_items: items, - receipt_object: crate::receipt::receipt_hash(receipt), - evidence: evidence_report(&validate_all(&receipt.evidence)), - objects, - }) + depths.insert(event.to_string(), depth); + Ok(depth) } -/// Derive the new snapshot, the install steps, and the re-derived projection. -/// -/// Called with the winning head already resolved, so a rebase and a -/// straight-line install go through exactly the same derivation and the -/// projection can never be carried across. -pub fn accept( - source: &dyn ObjectSource, - target: &TargetDef, - candidate: &Candidate, - derived: &DerivedCommit, - head: Option<&str>, - base_snapshot: Option<&str>, -) -> Result { - let mut states = head_states(source, head)?; - let key = (target.name.clone(), derived.transition.module.clone()); - let previous = state_of(source, &states, target, &derived.transition.module)?.0; - states.insert(key, derived.new_state_key.clone()); - - let parents: Vec = match head { - Some(snapshot) => vec![crate::objects::snapshot_event(source, snapshot)?], - None => Vec::new(), +pub fn committed_state(store: &Store, target: &str, module: &str) -> Result> { + let Some(head) = store.head()? else { + return Ok(None); }; - let event = event_hash(&derived.transition_id, &parents); - let snapshot = snapshot_hash(&event, &states); - - let mut objects = vec![ - json_object(ObjectKind::Event, &event_json(&derived.transition_id, &parents)), - json_object(ObjectKind::Snapshot, &snapshot_json(&event, &states)), - ]; - - // The projection is recomputed from the merged member set, never reused. - let members = snapshot_members(&states, &target.name); - let (projection_path, projection_bytes) = derive_projection(target, &members); - objects.push(blob_object(&projection_bytes)); - - // Diffing the committed output tree against the candidate tree is what - // bounds the critical section: the steps are counted before the lock, and - // applying one never hashes content. - let mut trees: BTreeMap = BTreeMap::new(); - // A module with no committed output has an output tree that was never - // published; there is nothing to diff against, which is exactly "no old - // side" rather than a missing object. - let previous_root = load_tree_objects(source, &previous.output_tree, &mut trees) - .ok() - .map(|()| previous.output_tree.clone()); - for (name, tree) in &candidate.tree_objects { - trees.insert(name.clone(), tree.clone()); + let states = snapshot_states(store, &head)?; + match states.get(&(target.to_string(), module.to_string())) { + Some(hash) => Ok(Some(read_module_state(store, hash)?)), + None => Ok(None), } - let mut changes = diff_trees( - previous_root.as_deref(), - Some(&candidate.tree_hash), - &trees, - ) - .map_err(|e| { - error( - OutcomeCode::StoreIntegrity, - format!("cannot diff the module's owned output: {e}"), - ) - })?; - // The projection rides the same plan, where `classify_path` files it as the - // snapshot-wide derived view it is. - changes.push(TreeChange::Added { - path: projection_path.clone(), - hash: napl_store::store_hash::object_name(ObjectKind::Blob, &projection_bytes), - mode: FILE_MODE, - }); - let steps = plan_install_steps(&changes); - - Ok(Accepted { - snapshot, - event, - steps, - projection_path, - projection_bytes, - objects, - rebased: head != base_snapshot, - }) } -/// The canonical success payload of a commit. -pub fn success_payload(derived: &DerivedCommit, accepted: &Accepted) -> Value { - json!({ - "blobsPublished": true, - "contractVersion": crate::CONTRACT_VERSION, - "derivedProjections": [accepted.projection_path.clone()], - "evidence": derived.evidence, - "head": accepted.snapshot, - "installed": derived.transition.footprint.install_paths, - "outcome": "success", - "provenancePublished": true, - "rebased": accepted.rebased, - "receipt": derived.receipt_object, - "semanticStatePublished": true, - "surfaceHash": derived.surface_hash, - "transition": derived.transition_id, - }) +/// The materialized attribution view a module committed last, if any. +/// +/// The bytes are the record blob, which is the view itself: an internal +/// re-commit reads them to carry the mapping forward rather than fabricating a +/// new one. +pub fn committed_attribution_yaml( + store: &Store, + target: &str, + module: &str, +) -> Result>> { + let Some(state) = committed_state(store, target, module)? else { + return Ok(None); + }; + match state.attribution { + Some(attribution) => Ok(Some(get_blob(store, &attribution.record)?)), + None => Ok(None), + } } -/// The union of write sets committed between `base` and `head`. -pub fn intervening_write_set( - source: &dyn ObjectSource, - head: Option<&str>, - base: Option<&str>, -) -> Result> { - let Some(head) = head else { - return Ok(BTreeSet::new()); - }; - let head_event = crate::objects::snapshot_event(source, head)?; - let base_event = match base { - Some(snapshot) => Some(crate::objects::snapshot_event(source, snapshot)?), - None => None, +/// The committed `path -> blob` manifest of every module under a target. +pub fn committed_manifest(store: &Store, target: &str) -> Result> { + let Some(head) = store.head()? else { + return Ok(BTreeMap::new()); }; - let mut out: BTreeSet = BTreeSet::new(); - for event in crate::objects::walk_events(source, &head_event, base_event.as_deref())? { - let transition = crate::objects::event_transition(source, &event)?; - out.extend(crate::objects::transition_write_set(source, &transition)?); + let mut out = BTreeMap::new(); + for ((state_target, _), hash) in snapshot_states(store, &head)? { + if state_target != target { + continue; + } + let state = read_module_state(store, &hash)?; + for (path, (blob, _)) in flatten_tree(store, &state.output_tree)? { + out.insert(path, blob); + } } Ok(out) } -fn blob_object(bytes: &[u8]) -> PendingObject { - PendingObject { - name: napl_store::store_hash::object_name(ObjectKind::Blob, bytes), - kind: ObjectKind::Blob, - payload: bytes.to_vec(), - } -} - -fn json_object(kind: ObjectKind, value: &Value) -> PendingObject { - let (name, payload) = hash_json(kind, value); - PendingObject { - name, - kind, - payload, - } -} - -fn tree_value(tree: &Tree) -> Value { - serde_json::to_value(tree).expect("a tree always serializes") -} -napl-store module-state v1 638 -{"attribution":{"promptHash":"4ede33df8b04ca4243178cfa1dc5d46036133d8f5d248e7ea32c990e74e881e3","record":"9093a24f5cccf37f4f2c54a38808cece50e46b033d5c47ec6bb673aa7bb667f6","stale":false},"contractVersion":4,"inputKey":"58efbc3e44ab68ee5736b83c275e90d33671b13233d9404897977d74139ee4f6","module":"commands","outputTree":"327df4533c69e9df80fe2e89dc208ef1194ce9529a11c8a6c21086bdbc1e918e","prompt":"864391598ee91f825cfe21eb337df2a32d03ad7b9146288d6d14bc13547a2e0e","sliceHash":"f2e007ad5d93fbf19718c059eb8ad30918abd26223e8ecd45f9538e2bf8f85ad","surfaceHash":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust"} -napl-store blob v1 29905 -//! The command line contract: declared grammars, help, and strict parsing. -//! -//! Every command the registry serves declares its full grammar in its -//! [`CommandSpec`]: name, a one-line summary, the exact usage line, and the -//! closed table of flags it accepts, each with arity and meaning. The dispatch -//! loop owns two behaviors ahead of every command, so no command can get them -//! wrong: help is answered before the command runs and is a pure read, and any -//! flag the command's table does not name is a usage error before the command -//! executes. Exit 2 is the pinned usage-error code for the whole binary. -//! -//! The parser behind the contract is clap's builder API, constructed at dispatch -//! from the registry: [`clap_command_for`] turns one [`CommandSpec`] into one -//! `clap::Command`, so the registry stays the single source of grammar and clap -//! is only its execution engine. There are no derive macros and no per-command -//! clap declarations anywhere. clap's own renderings are never emitted: every -//! parse failure is caught and served as the canonical JSON usage error below, -//! byte-shaped by this contract alone. `--flag value` and `--flag=value` reduce -//! to the same normalized argument vector before any command body reads an -//! argument, so the two spellings cannot produce different output. A flag may -//! also declare one short spelling, which normalizes to the same long name; -//! clustering several short flags into one token is outside the contract, so a -//! clustered token is simply an undeclared flag. -//! -//! The registry pre-pass, not clap, is what refuses an undeclared flag: the -//! canonical JSON payload with its nearest-name suggestion is pinned by this -//! contract, and clap normalizes spellings and values behind it. - -use std::io::Write as _; -use std::path::{Path, PathBuf}; - -use clap::{Arg, ArgAction}; -use serde_json::{json, Value}; - -use crate::canon::canonical_bytes; -use crate::error::{error, error_payload, outcome_exit_code, CompilerError, OutcomeCode, Result}; -use crate::workspace::require_workspace_root; - -/// How many arguments a flag consumes beyond its own token. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FlagArity { - /// A bare switch: present or absent, never followed by a value. - Switch, - /// A flag that takes exactly one value, as `--name value` or `--name=value`. - Value, -} - -/// One entry in a command's closed flag table. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FlagSpec { - pub name: &'static str, - pub arity: FlagArity, - pub meaning: &'static str, - /// The one short spelling this flag also answers to, written with its single - /// dash, or `None`. A flag has at most one, and clustering several short - /// flags into a single token is outside the contract. - pub short: Option<&'static str>, +/// Whether a token is shaped like a store object name at all: the lowercase hex +/// of a SHA-256, and nothing else. A workspace path fails on the first separator. +fn is_object_name(given: &str) -> bool { + given.len() == 64 && given.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } -/// A switch flag: present or absent. -pub const fn switch(name: &'static str, meaning: &'static str) -> FlagSpec { - FlagSpec { - name, - arity: FlagArity::Switch, - meaning, - short: None, +/// Every path any committed module owns, across every target: the set that +/// decides whether a path is tracked at all. +fn committed_manifest_all_targets(store: &Store) -> Result> { + let mut out: BTreeSet = BTreeSet::new(); + let Some(head) = store.head()? else { + return Ok(out); + }; + for (_, hash) in snapshot_states(store, &head)? { + let state = read_module_state(store, &hash)?; + for (path, _) in flatten_tree(store, &state.output_tree)? { + out.insert(path); + } } + Ok(out) } -/// A switch flag that also answers to one short spelling, as `-r` does for -/// `--raw-output`. The short form normalizes to the long name before any command -/// body reads an argument, so the two spellings cannot behave differently. -pub const fn switch_short( - name: &'static str, - short: &'static str, - meaning: &'static str, -) -> FlagSpec { - FlagSpec { - name, - arity: FlagArity::Switch, - meaning, - short: Some(short), +fn committed_text(store: &Store, target: &str) -> Result> { + let mut out = BTreeMap::new(); + for (path, blob) in committed_manifest(store, target)? { + let bytes = get_blob(store, &blob)?; + out.insert(path, String::from_utf8_lossy(&bytes).into_owned()); } + Ok(out) } -/// A flag that takes one value. -pub const fn value(name: &'static str, meaning: &'static str) -> FlagSpec { - FlagSpec { - name, - arity: FlagArity::Value, - meaning, - short: None, +/// Every identifier a module's committed output names. +/// +/// This is a token scan, not name resolution: lint says a surface entry looks +/// unconsumed, and says so as a proposal, precisely because it cannot prove it. +fn identifiers(store: &Store, state: &ModuleState) -> Result> { + let mut out: BTreeSet = BTreeSet::new(); + if !has_object(store, &state.output_tree) { + // A module that has never committed names nothing yet. + return Ok(out); } -} - -/// The `--workspace` flag, declared by every command that resolves a workspace. -pub const WORKSPACE_FLAG: FlagSpec = value( - "--workspace", - "the workspace root to operate on, instead of discovering it from the current directory", -); - -/// The help flags the dispatch loop answers ahead of every command. -pub const HELP_FLAGS: [&str; 2] = ["--help", "-h"]; - -/// The exit code every usage error in the binary uses. -pub const USAGE_EXIT_CODE: i32 = 2; - -/// The top-level usage line, used when no command was named. -pub const BINARY_USAGE: &str = "napl [arguments]"; - -/// A command's full declared grammar. -#[derive(Clone, Copy)] -pub struct CommandSpec { - pub name: &'static str, - /// One line saying what the command does. - pub summary: &'static str, - /// The exact usage line a usage error restates. - pub usage: &'static str, - /// The closed table of flags this command accepts. - pub flags: &'static [FlagSpec], - /// True when the command forwards its arguments verbatim to another - /// program, so its grammar is that program's and strict parsing does not - /// apply. Help is still answered ahead of it. - pub passthrough: bool, - pub run: fn(&Path, &[String]) -> Result, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CliResult { - pub stdout: Vec, - pub exit_code: i32, -} - -pub fn run_cli(cwd: &Path, args: &[String]) -> CliResult { - match dispatch(cwd, args) { - Ok(result) => result, - Err(err) if err.code == OutcomeCode::Usage => { - emit_usage(&usage_json(None, &err.detail, None, None)) + for (_, (blob, _)) in flatten_tree(store, &state.output_tree)? { + let bytes = get_blob(store, &blob)?; + let text = String::from_utf8_lossy(&bytes).into_owned(); + for token in text.split(|c: char| !(c.is_alphanumeric() || c == '_')) { + if !token.is_empty() { + out.insert(token.to_string()); + } } - Err(err) => CliResult { - stdout: canonical_bytes(&error_payload(&err, false, false)), - exit_code: outcome_exit_code(err.code), - }, } + Ok(out) } -fn dispatch(cwd: &Path, args: &[String]) -> Result { - let registry = crate::registry(); - let Some(command) = command_word(args) else { - if args.iter().any(|arg| is_help_token(arg)) { - return Ok(ok_bytes(&help_all_json(®istry))); - } - return Ok(emit_usage(&usage_json(None, "no command given", None, None))); - }; - if command == "help" { - return Ok(help_command_or_all(®istry, args)); - } - let Some(spec) = registry.iter().find(|spec| spec.name == command).copied() else { - let names = command_names(®istry); - return Ok(emit_usage(&usage_json( - None, - &format!("unknown command {command:?}"), - Some(&command), - nearest(&command, &names).as_deref(), - ))); - }; - // Help is answered ahead of the command and is a pure read: no store - // access, no filesystem write, no state change of any kind. - if wants_help(&spec, args) { - return Ok(ok_bytes(&help_command_json(&spec))); - } - // A passthrough command's grammar is the program it forwards to, so its - // arguments reach that program exactly as typed and neither strict parsing - // nor clap normalization touches them. - let arguments: Vec = if spec.passthrough { - args.to_vec() - } else { - if let Some(problem) = strict_parse(&spec, args) { - return Ok(emit_usage(&problem)); - } - match normalize_arguments(&spec, args) { - Ok(normalized) => normalized, - Err(problem) => return Ok(emit_usage(&problem)), - } - }; - match (spec.run)(cwd, &arguments) { - Err(err) if err.code == OutcomeCode::Usage => { - Ok(emit_usage(&usage_json(Some(&spec), &err.detail, None, None))) +fn declared_roots(sources: &[crate::source::SourceModule]) -> Option> { + let mut declared: Option> = None; + for source in sources { + if let Some(roots) = &source.roots { + declared + .get_or_insert_with(BTreeSet::new) + .extend(roots.iter().cloned()); } - other => other, } + declared.map(|set| set.into_iter().collect()) } -/// The first non-flag token, which names the command. Flag values are skipped -/// so `napl --workspace /w status` still names `status`. -fn command_word(args: &[String]) -> Option { - positionals_from(args, 0).into_iter().next() -} - -fn is_help_token(arg: &str) -> bool { - HELP_FLAGS.contains(&arg) -} - -/// True when a help flag appears among the command's arguments. Flag values are -/// skipped, so a `-h` that is the value of a declared flag is not a help request. -fn wants_help(spec: &CommandSpec, args: &[String]) -> bool { - let mut index = 0; - while index < args.len() { - let arg = args[index].as_str(); - if arg == "--" { - return false; - } - if is_help_token(arg) { - return true; +fn observed_facts(workspace: &Path, roots: &BTreeSet) -> Result> { + let mut out: Vec = Vec::new(); + for root in roots { + let dir = workspace.join(root); + if !dir.is_dir() { + continue; } - if let Some(flag) = declared_flag(spec, arg) { - if flag.arity == FlagArity::Value && !arg.contains('=') { - index += 2; + for entry in crate::workspace::scan_candidate(&dir)? { + if entry.kind != crate::candidate::EntryKind::File { continue; } + out.push(FileFact { + path: crate::paths::join_root(root, &entry.path), + hash: napl_store::store_hash::object_name(ObjectKind::Blob, &entry.bytes), + }); } - index += 1; } - false -} - -/// The flag table entry an argument names, if the command declares it, under -/// either its long spelling or its one short spelling. -fn declared_flag(spec: &CommandSpec, arg: &str) -> Option<&'static FlagSpec> { - let name = arg.split('=').next().unwrap_or(arg); - spec.flags - .iter() - .find(|flag| flag.name == name || flag.short == Some(name)) + out.sort(); + Ok(out) } -/// Refuse any `--flag` the command's table does not name, before it executes. -/// -/// The refusal names the unknown flag, lists the valid ones, suggests the -/// nearest declared flag when one is close, and restates the exact usage line, -/// so the error itself teaches the grammar. -fn strict_parse(spec: &CommandSpec, args: &[String]) -> Option { - let mut index = 0; - while index < args.len() { - let arg = args[index].as_str(); - if arg == "--" { - return None; - } - // A lone `-` is the stdin operand, never a flag. Anything else that - // leads with a dash is a flag spelling, long or short, and the pre-pass - // owns the refusal either way: clustering is outside the contract, so a - // clustered short token is simply undeclared. - if !arg.starts_with('-') || arg == "-" { - index += 1; - continue; - } - if is_help_token(arg.split('=').next().unwrap_or(arg)) { - index += 1; - continue; - } - match declared_flag(spec, arg) { - Some(flag) => { - if flag.arity == FlagArity::Value && !arg.contains('=') { - if index + 1 >= args.len() { - return Some(usage_json( - Some(spec), - &format!("{} needs a value", flag.name), - None, - None, - )); - } - index += 2; - continue; - } - if flag.arity == FlagArity::Switch && arg.contains('=') { - return Some(usage_json( - Some(spec), - &format!("{} takes no value", flag.name), - None, - None, - )); +fn apply_restore( + store: &Store, + workspace: &Path, + steps: &[crate::maintenance::RestoreStep], +) -> Result<()> { + use crate::maintenance::RestoreStep; + for step in steps { + match step { + RestoreStep::Move { from, to } => { + let target = workspace.join(to); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|e| disk::io(parent, &e))?; } - index += 1; + std::fs::rename(workspace.join(from), &target) + .map_err(|e| disk::io(&target, &e))?; } - None => { - let unknown = arg.split('=').next().unwrap_or(arg).to_string(); - let names: Vec = - spec.flags.iter().map(|flag| flag.name.to_string()).collect(); - return Some(usage_json( - Some(spec), - &format!( - "unknown flag {unknown:?} for command {:?}", - spec.name - ), - Some(&unknown), - nearest(&unknown, &names).as_deref(), - )); + RestoreStep::Write { path, hash } => { + let bytes = get_blob(store, hash)?; + crate::workspace::write_file(&workspace.join(path), &bytes)?; + } + RestoreStep::Remove { path } => { + let target = workspace.join(path); + match std::fs::remove_file(&target) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(disk::io(&target, &e)), + } } } } - None + Ok(()) } +napl-store transition v1 1847 +{"baseStateKey":"81b727d0fdbdcbf9412b7798b650076b811080051e9b4598ce3677811b30443a","contractVersion":2,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"d43c4332d50ffbb217ef58e9c78888d884c1391f0d623f70bf921f2618329422","installPaths":["crates/napl/src/attribution.rs","crates/napl/src/authoring.rs","crates/napl/src/blame.rs","crates/napl/src/candidate.rs","crates/napl/src/canon.rs","crates/napl/src/cli.rs","crates/napl/src/commandset.rs","crates/napl/src/commit.rs","crates/napl/src/compile.rs","crates/napl/src/cost.rs","crates/napl/src/decision.rs","crates/napl/src/directive.rs","crates/napl/src/disk.rs","crates/napl/src/error.rs","crates/napl/src/evidence.rs","crates/napl/src/graph.rs","crates/napl/src/lint.rs","crates/napl/src/lsp.rs","crates/napl/src/main.rs","crates/napl/src/maintenance.rs","crates/napl/src/mv.rs","crates/napl/src/objects.rs","crates/napl/src/paths.rs","crates/napl/src/porcelain.rs","crates/napl/src/receipt.rs","crates/napl/src/slice.rs","crates/napl/src/source.rs","crates/napl/src/staging.rs","crates/napl/src/store_view.rs","crates/napl/src/surface.rs","crates/napl/src/target.rs","crates/napl/src/textdiff.rs","crates/napl/src/transaction.rs","crates/napl/src/trees.rs","crates/napl/src/version.rs","crates/napl/src/workspace.rs"],"module":"napl","newState":"ede5d6f7c642bc1ae88d4737fc899bdcc20ef363ef468792804cd36f02350c41","readSet":["base-state:81b727d0fdbdcbf9412b7798b650076b811080051e9b4598ce3677811b30443a","input-key:d43c4332d50ffbb217ef58e9c78888d884c1391f0d623f70bf921f2618329422","target-def:eff9678e5301c88ac1e42eec62c56eb3860fca247f6278b6501639f14112a8d4"],"receipt":"eb982c4abd90f069ed07ba89b7fd809d2eff0d277a3d7240b2e781af90d33399","surface":"7ceb7e024b769e255bc6f1a9ada45327087cab66a296c78db60c95593a13fc95","target":"rust","writeSet":["owned:crates/napl","module-state:rust/napl"]} +napl-store module-state v1 559 +{"attribution":{"promptHash":"b0a8d8b52ccb7ad3e5b791e0791df5baefde058ae25183f7350aa0f575959abf","record":"ca4d0b8217a1164f7884e0195489d4139c836c6b32035adb683b27c3552548dc","stale":false},"contractVersion":4,"inputKey":"9206162a79eefbb9bbbad00daffa25e4dcbca3fb7db9b908b65fa2829a272f7e","module":"query","outputTree":"562e142c3a950b916ff1d8cebfdd0ab3a63fb61ebb42d766e6cd8215190b007f","sliceHash":"476bdcd17f363b2f8386f1f6076495802c94de78870b20ef3dc5dc26ae7e69d1","surfaceHash":"326826be3fd8aaa84d4869493b7e41f886a07bea8960df79b61c87f557f4cbd0","target":"rust"} +napl-store module-state v1 635 +{"attribution":{"promptHash":"d3b4f7308b35a3105c66bbbbec005cb91288ecd8476e45e4e46019e1dda2d3f6","record":"edd2ba44a2a0c8e6b1f6a328a617821ef6949cf4455658fe9c7b9f80935a0b97","stale":false},"contractVersion":4,"inputKey":"e5f189e86fd1da6b1aa3320cccaefd4a29cfd6bfdf4955a6a5b6a7ebdd5ef3fd","module":"serve","outputTree":"505d5f544144525d73def85bf0b3b9e16230ea634599f4d343080af78d359024","prompt":"cc1b8713650f5fa9aabd1f43b96cc999725f0552b614e90c3c25a8e9ada70d25","sliceHash":"19d34221d206f9ac8994717910bef591aa8bb1c07b88ae7478d4554716a5b018","surfaceHash":"0a742d0232e4b7000bd79ff29b39af501675254174d2c98163f8b7337cf7f0b7","target":"rust"} +napl-store tree v1 129 +{"entries":[{"hash":"4ce91446078aa8686a56c4c665b6d8fda58b19d6ec02721577e7fb70d95d284d","kind":"dir","mode":16384,"name":"src"}]} +napl-store module-state v1 639 +{"attribution":{"promptHash":"d819535b170965a2083000f6caa6b12f90884261a18117d948aafd98bdbe53db","record":"003ff2e611d823369c217a8516567db73b36275c47eb1567db203fc8271dab8c","stale":false},"contractVersion":4,"inputKey":"a81dbd9694703c7aa43996c1fd0ba96dc10741827cd8283deefbfa80995cb3ac","module":"git-store","outputTree":"10e6ecbb2cf94bd488d9b800284ed2e991d1f6ee5ab3156d8b8d6a711e784c87","prompt":"0ccfd4213b325ead9e7de194b86e32322a281d222b27a5af1f2cd57db8f49bde","sliceHash":"8206a86db94e415102f4e807e628b12ca3e0ba90b3d6525f47f7d7f4d054482a","surfaceHash":"de0377465c067b511f8b1f2727ceee7caf1ec3d1f896a6c31766ce05a72b5f1c","target":"rust"} +napl-store snapshot v1 781 +{"contractVersion":4,"event":"0488bd1ad406ad2da40f6d1b0c87ff1b7a1410275cca5d9a346ba242b76eb74d","states":[{"module":"agent","state":"5c85f9c974c1e41c9d2702163845b4cc5076dd694300a3122f98daac8e283aa2","target":"rust"},{"module":"commands","state":"87384fb9465ce6341cb179768f2b35a68b7a4f7ab1ff58a7e5cc220b82a22793","target":"rust"},{"module":"napl","state":"a376d26e5f8de69d8cb90f959edc75dcf7631ac57bf981ba4b0d699d1f5e2b90","target":"rust"},{"module":"napl_store","state":"7011890f658fb28bd29684802e5e53bb5d54d54d3c5a64cc926a56fae7ef944d","target":"rust"},{"module":"query","state":"a9968cca3d5e718781aa07dfc91a9e68495df5b964c5d5b50622038329c145cc","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} +napl-store blob v1 1816 +use serde_json::Value; -/// The clap argument id the operands of a command collect under. It is not a -/// spelling any user types; the leading underscores keep it out of the flag -/// namespace the registry declares. -const OPERANDS: &str = "__operands"; - -/// The `clap::Command` one registry entry declares, constructed here and nowhere -/// else. Every fact comes from the [`CommandSpec`]: the name, the summary, the -/// exact usage line, and the closed flag table with each flag's arity. clap's own -/// help and error output is disabled, because the contract renders both itself. -/// -/// Value flags accept hyphen-leading values, because the value of a declared flag -/// is a value and never a flag to validate. Operands deliberately do not: a -/// permissive operand would swallow `--flag=value` whole and hand a command a -/// spelling that must parse as a flag, so `-` for stdin stays an operand by being -/// a value clap already accepts, and anything genuinely hyphen-leading is written -/// after `--`, which the grammar takes as the operand escape. -pub fn clap_command_for(spec: &CommandSpec) -> clap::Command { - let mut command = clap::Command::new(spec.name) - .no_binary_name(true) - .disable_help_flag(true) - .disable_help_subcommand(true) - .disable_version_flag(true) - .about(spec.summary) - .override_usage(spec.usage); - for flag in spec.flags { - let id = flag_id(flag.name); - let mut arg = Arg::new(id).long(id).help(flag.meaning); - if let Some(letter) = flag.short.and_then(|short| short.chars().nth(1)) { - arg = arg.short(letter); +pub fn quote_string(text: &str) -> String { + let mut out = String::with_capacity(text.len() + 2); + out.push('"'); + for c in text.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), } - command = command.arg(match flag.arity { - FlagArity::Switch => arg.action(ArgAction::SetTrue), - FlagArity::Value => arg - .action(ArgAction::Append) - .num_args(1) - .allow_hyphen_values(true) - .value_name("value"), - }); - } - command.arg( - Arg::new(OPERANDS) - .num_args(0..) - .value_name("arguments"), - ) -} - -/// The whole registry as one clap command tree: the binary with one subcommand -/// per registered command. This is what `napl completions` generates from, so a -/// completion script is derived from the same declarations help and parsing use -/// and can never drift from them. -pub fn clap_root(registry: &[CommandSpec]) -> clap::Command { - let mut listed: Vec<&CommandSpec> = registry.iter().collect(); - listed.sort_by_key(|spec| spec.name); - let mut root = clap::Command::new("napl") - .disable_help_flag(true) - .disable_help_subcommand(true) - .disable_version_flag(true) - .override_usage(BINARY_USAGE); - for spec in listed { - root = root.subcommand(clap_command_for(spec).no_binary_name(false)); } - root + out.push('"'); + out } -/// The clap argument id for a declared flag: its spelling without the dashes, -/// which is also the long spelling clap registers, so `--old-string` is the only -/// way to write it and the id never invents a second name. -fn flag_id(name: &'static str) -> &'static str { - name.trim_start_matches('-') +pub fn to_canonical_line(value: &Value) -> String { + let mut out = String::new(); + write_value(&mut out, value); + out } -/// Parse a command's arguments with clap and reduce them to one normalized -/// vector: the command word, then every operand in order, then every flag in -/// declared order as a separated `--name value` pair. -/// -/// Reduction is where the conventional spellings converge. `--module=leaf` and -/// `--module leaf` both arrive here as different tokens and both leave as the -/// same two, so a command body reads one shape and byte-identical output for the -/// two spellings is a property of the parser rather than a promise each command -/// keeps. Failure is the canonical JSON usage error, never clap's rendering. -fn normalize_arguments(spec: &CommandSpec, args: &[String]) -> std::result::Result, Value> { - let (word, rest) = split_command_word(args); - let matches = clap_command_for(spec) - .try_get_matches_from(rest) - .map_err(|err| clap_usage(spec, args, &err))?; - let mut out: Vec = vec![word]; - if let Some(values) = matches.get_many::(OPERANDS) { - out.extend(values.cloned()); - } - for flag in spec.flags { - let id = flag_id(flag.name); - match flag.arity { - FlagArity::Switch => { - if matches.get_flag(id) { - out.push(flag.name.to_string()); +fn write_value(out: &mut String, value: &Value) { + match value { + Value::Null => out.push_str("null"), + Value::Bool(true) => out.push_str("true"), + Value::Bool(false) => out.push_str("false"), + Value::Number(number) => out.push_str(&number.to_string()), + Value::String(text) => out.push_str("e_string(text)), + Value::Array(items) => { + out.push('['); + for (index, item) in items.iter().enumerate() { + if index > 0 { + out.push(','); } + write_value(out, item); } - FlagArity::Value => { - if let Some(values) = matches.get_many::(id) { - for given in values { - out.push(flag.name.to_string()); - out.push(given.clone()); - } + out.push(']'); + } + Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + out.push('{'); + for (index, key) in keys.iter().enumerate() { + if index > 0 { + out.push(','); } + out.push_str("e_string(key)); + out.push(':'); + write_value(out, &map[key.as_str()]); } + out.push('}'); } } - Ok(out) } +napl-store blob v1 9053 +//! Target definitions: placement, owned outputs, surface grammar, projections. +//! +//! A target definition is itself a content-addressed object, and its hash enters +//! every `inputKey`. Changing where a module is placed, or what it is allowed to +//! own, therefore invalidates every outstanding receipt rather than silently +//! reinterpreting one. -/// Split the command word out of the arguments, leaving what clap parses. -/// -/// The command word is the first operand, which is not always the first token: a -/// flag the command declares may precede it, as in `napl --workspace /w status`. -fn split_command_word(args: &[String]) -> (String, Vec) { - let mut rest: Vec = Vec::new(); - let mut word: Option = None; - let mut iter = args.iter().peekable(); - while let Some(arg) = iter.next() { - if word.is_none() && !is_flag(arg) && arg != "--" { - word = Some(arg.clone()); - continue; - } - rest.push(arg.clone()); - if word.is_none() && is_flag(arg) && !is_switch(arg) && !arg.contains('=') { - if let Some(value) = iter.next() { - rest.push(value.clone()); - } - } - } - (word.unwrap_or_default(), rest) -} +use napl_store::store_hash::ObjectKind; +use serde_json::{json, Value}; -/// Translate one clap parse failure into the canonical JSON usage error. -/// -/// Nothing clap wrote is read: the outcome is classified by kind and the detail -/// is this contract's own sentence, so the bytes a caller sees are the bytes the -/// contract pins whatever clap's messages say this release. -fn clap_usage(spec: &CommandSpec, args: &[String], err: &clap::Error) -> Value { - use clap::error::ErrorKind; - if matches!( - err.kind(), - ErrorKind::UnknownArgument | ErrorKind::InvalidSubcommand | ErrorKind::NoEquals - ) { - let unknown = undeclared_token(spec, args); - let names: Vec = spec.flags.iter().map(|flag| flag.name.to_string()).collect(); - return usage_json( - Some(spec), - &format!("unknown flag {unknown:?} for command {:?}", spec.name), - Some(&unknown), - nearest(&unknown, &names).as_deref(), - ); - } - let detail = match err.kind() { - ErrorKind::InvalidValue | ErrorKind::TooFewValues | ErrorKind::WrongNumberOfValues => { - "a declared flag was given no value, or a value its grammar does not accept" - } - ErrorKind::MissingRequiredArgument => "a required argument is missing", - ErrorKind::TooManyValues => "a declared flag was given more values than it accepts", - ErrorKind::ArgumentConflict => "two of the given arguments cannot be used together", - ErrorKind::InvalidUtf8 => "an argument is not valid UTF-8", - _ => "the arguments do not parse under this command's declared grammar", - }; - usage_json(Some(spec), detail, None, None) +use crate::canon::json_hash; +use crate::error::{error, OutcomeCode, Result}; +use crate::paths::{is_under, join_root}; +use crate::source::SourceModule; + +/// A built-in target definition. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TargetDef { + pub name: String, + pub entry_point_version: u32, + /// The user-owned manifest file name. The toolchain never owns, installs, + /// validates, or records it; it names the file only so a candidate carrying + /// one can be refused and so `status` can diagnose a missing one. + pub manifest_file: String, + pub source_dir: String, + pub source_extension: String, } -/// The first `--` token the command's table does not declare, which is the token -/// a caller means when clap reports an unknown argument. Falling back to the -/// whole failing invocation would name nothing, so an absent token reads as the -/// empty spelling and the suggestion machinery simply finds nothing near it. -fn undeclared_token(spec: &CommandSpec, args: &[String]) -> String { - for arg in args { - if arg == "--" { - break; - } - if !arg.starts_with('-') || arg == "-" { - continue; - } - let name = arg.split('=').next().unwrap_or(arg); - if is_help_token(name) { - continue; - } - if declared_flag(spec, name).is_none() { - return name.to_string(); - } - } - String::new() +/// The names of every built-in target, sorted. +pub fn builtin_target_names() -> Vec { + vec!["rust".to_string(), "typescript".to_string()] } -/// The nearest candidate by edit distance, when one is close enough to mean it. -fn nearest(given: &str, candidates: &[String]) -> Option { - let mut best: Option<(usize, String)> = None; - for candidate in candidates { - let distance = edit_distance(given, candidate); - if best.as_ref().is_none_or(|(seen, _)| distance < *seen) { - best = Some((distance, candidate.clone())); - } +/// Look up a built-in target definition by name. +pub fn builtin_target(name: &str) -> Result { + match name { + "rust" => Ok(TargetDef { + name: "rust".into(), + entry_point_version: 1, + manifest_file: "Cargo.toml".into(), + source_dir: "src".into(), + source_extension: "rs".into(), + }), + "typescript" => Ok(TargetDef { + name: "typescript".into(), + entry_point_version: 1, + manifest_file: "package.json".into(), + source_dir: "src".into(), + source_extension: "ts".into(), + }), + other => Err(error( + OutcomeCode::UnsupportedVersion, + format!( + "unknown target {other:?}; known targets: {:?}", + builtin_target_names() + ), + )), } - let (distance, candidate) = best?; - let ceiling = std::cmp::max(2, given.chars().count() / 3); - (distance <= ceiling).then_some(candidate) } -/// Levenshtein distance over characters. -fn edit_distance(left: &str, right: &str) -> usize { - let left: Vec = left.chars().collect(); - let right: Vec = right.chars().collect(); - let mut previous: Vec = (0..=right.len()).collect(); - let mut current: Vec = vec![0; right.len() + 1]; - for (i, l) in left.iter().enumerate() { - current[0] = i + 1; - for (j, r) in right.iter().enumerate() { - let cost = usize::from(l != r); - current[j + 1] = std::cmp::min( - std::cmp::min(current[j] + 1, previous[j + 1] + 1), - previous[j] + cost, - ); - } - std::mem::swap(&mut previous, &mut current); +/// The install root a module occupies: the crate directory its prompt sits in. +/// +/// The crate is the folder. A module's `.napl` prompt lives inside the crate +/// directory that receives its code, so the placement root is that prompt's +/// parent directory, workspace-relative. Several prompts sharing one directory +/// therefore share one crate. +/// +/// A prompt at the workspace root is legal and means the workspace root is the +/// crate directory, exactly as git treats the directory it was initialized in. +/// Its placement root is the empty workspace-relative path: every +/// workspace-relative path is inside it, and every join against it produces a +/// clean relative path rather than a fabricated leading separator. +pub fn placement_root(source: &SourceModule) -> String { + match source.path.rsplit_once('/') { + Some((dir, _)) => dir.to_string(), + None => String::new(), } - previous[right.len()] } -fn command_names(registry: &[CommandSpec]) -> Vec { - let mut names: Vec = registry.iter().map(|spec| spec.name.to_string()).collect(); - names.sort(); - names.dedup(); - names +/// The owned-output constraint for a crate: the source tree alone. +/// +/// The manifest is a user-owned glue seam, not a module output, so it is not in +/// the owned globs; a candidate carrying one is refused rather than installed. +pub fn owned_globs(_target: &TargetDef, root: &str) -> Vec { + vec![join_root(root, &format!("{}/**", _target.source_dir))] } -/// `napl help` and `napl help `. -fn help_command_or_all(registry: &[CommandSpec], args: &[String]) -> CliResult { - let topic = positionals_from(args, 1).into_iter().next(); - let Some(topic) = topic else { - return ok_bytes(&help_all_json(registry)); - }; - match registry.iter().find(|spec| spec.name == topic) { - Some(spec) => ok_bytes(&help_command_json(spec)), - None => { - let names = command_names(registry); - emit_usage(&usage_json( - None, - &format!("unknown command {topic:?}"), - Some(&topic), - nearest(&topic, &names).as_deref(), - )) - } +/// True when `path` is an output a crate rooted at `root` may own. +/// +/// The crate's shared entry files are derived projections owned by no module, so +/// they are never ownable even though they sit under the source tree; the +/// manifest is a user-owned seam and is never ownable either. +pub fn is_owned_path(target: &TargetDef, root: &str, path: &str) -> bool { + if projection_owned_paths(target, root).iter().any(|p| p == path) { + return false; } + let src = join_root(root, &target.source_dir); + is_under(path, &src) && path != src } -/// The canonical body `napl help` prints: every registered command with its -/// one-line summary. -pub fn help_all_json(registry: &[CommandSpec]) -> Value { - let mut listed: Vec<&CommandSpec> = registry.iter().collect(); - listed.sort_by_key(|spec| spec.name); - let commands: Vec = listed - .iter() - .map(|spec| { - json!({ - "name": spec.name, - "summary": spec.summary, - "usage": spec.usage, - }) - }) - .collect(); - json!({ - "commands": commands, - "contractVersion": crate::version::CONTRACT_VERSION, - "usage": BINARY_USAGE, - }) +/// True when `path` is a manifest the toolchain must never own, install, or +/// record: the workspace-root manifest or any crate's manifest. +pub fn is_manifest_path(target: &TargetDef, path: &str) -> bool { + path == target.manifest_file || path.ends_with(&format!("/{}", target.manifest_file)) } -/// The canonical body per-command help prints: the summary, the exact usage -/// line, and the closed flag table with arity and meaning. -pub fn help_command_json(spec: &CommandSpec) -> Value { - json!({ - "command": spec.name, - "contractVersion": crate::version::CONTRACT_VERSION, - "flags": flag_table_json(spec), - "summary": spec.summary, - "usage": spec.usage, - }) +/// The crate-local paths that are derived projections rather than owned outputs. +/// +/// The crate root `lib.rs` is recomputed at commit from the union of the crate's +/// committed module file sets, so no module ever owns it and a candidate that +/// writes it is rejected. The crate manifest is deliberately not here: it stays +/// owned and shared, guarded by a byte-identity check rather than derivation. +pub fn projection_owned_paths(target: &TargetDef, root: &str) -> Vec { + match target.name.as_str() { + "rust" => vec![join_root(root, &format!("{}/lib.rs", target.source_dir))], + _ => Vec::new(), + } } -fn flag_table_json(spec: &CommandSpec) -> Vec { - let mut flags: Vec<&FlagSpec> = spec.flags.iter().collect(); - flags.sort_by_key(|flag| flag.name); - flags - .iter() - .map(|flag| { - json!({ - "arity": match flag.arity { - FlagArity::Switch => 0, - FlagArity::Value => 1, - }, - "meaning": flag.meaning, - "name": flag.name, - "short": flag.short.map_or(Value::Null, |short| json!(short)), - }) - }) - .collect() +/// The stem a source file contributes to the crate root module list: its first +/// path segment under `src/`, with a `.rs` file suffix removed. A nested file +/// such as `store_blocks/mod.rs` contributes its top segment `store_blocks`. +fn module_list_stem(target: &TargetDef, root: &str, path: &str) -> Option { + let src_prefix = format!("{}/", join_root(root, &target.source_dir)); + let rest = path.strip_prefix(&src_prefix)?; + let ext = format!(".{}", target.source_extension); + if !rest.ends_with(&ext) { + return None; + } + let segment = rest.split('/').next().unwrap_or(rest); + let stem = segment.strip_suffix(&ext).unwrap_or(segment); + if stem.is_empty() || stem == "lib" || stem == "main" { + return None; + } + Some(stem.to_string()) } -/// The canonical body of a usage error: what was wrong, the exact usage line, -/// the valid flags, and the nearest declared flag when one is close. -pub fn usage_json( - spec: Option<&CommandSpec>, - detail: &str, - unknown: Option<&str>, - suggestion: Option<&str>, -) -> Value { - let mut payload = serde_json::Map::new(); - payload.insert("contractVersion".into(), json!(crate::version::CONTRACT_VERSION)); - payload.insert("detail".into(), json!(detail)); - payload.insert("exitCode".into(), json!(USAGE_EXIT_CODE)); - payload.insert("outcome".into(), json!("usage")); - match spec { - Some(spec) => { - payload.insert("command".into(), json!(spec.name)); - payload.insert("usage".into(), json!(spec.usage)); - payload.insert("validFlags".into(), json!(flag_table_json(spec))); - } - None => { - payload.insert("command".into(), Value::Null); - payload.insert("usage".into(), json!(BINARY_USAGE)); - payload.insert("validCommands".into(), json!(command_names(&crate::registry()))); +/// Recompute a crate root `lib.rs` from its committed module file sets. +/// +/// The body is a sorted `pub mod` list over every distinct source stem the +/// crate's modules install, followed, only when at least one module exposes a +/// command table, by a registry aggregator the dispatch loop iterates. It is a +/// pure function of the sorted inputs, so it derives byte-identically regardless +/// of the order the modules landed. +pub fn derive_lib_projection( + target: &TargetDef, + root: &str, + file_paths: &[String], + command_providers: &[String], +) -> (String, Vec) { + let mut stems: Vec = file_paths + .iter() + .filter_map(|path| module_list_stem(target, root, path)) + .collect(); + stems.sort(); + stems.dedup(); + let mut providers: Vec = command_providers.to_vec(); + providers.sort(); + providers.dedup(); + + let mut out = String::new(); + for stem in &stems { + out.push_str("pub mod "); + out.push_str(stem); + out.push_str(";\n"); + } + if !providers.is_empty() { + out.push('\n'); + out.push_str("pub fn registry() -> Vec {\n"); + out.push_str(" let mut specs: Vec = Vec::new();\n"); + for provider in &providers { + out.push_str(" specs.extend_from_slice(crate::"); + out.push_str(provider); + out.push_str("::commands());\n"); } + out.push_str(" specs\n"); + out.push_str("}\n"); } - payload.insert( - "unknown".into(), - unknown.map_or(Value::Null, |name| json!(name)), - ); - payload.insert( - "suggestion".into(), - suggestion.map_or(Value::Null, |name| json!(name)), - ); - Value::Object(payload) + ( + join_root(root, &format!("{}/lib.rs", target.source_dir)), + out.into_bytes(), + ) } -/// Write a usage error to stderr as canonical JSON and exit with the pinned -/// usage-error code. Nothing goes to stdout, so a caller piping output never -/// mistakes a refusal for a result. -fn emit_usage(payload: &Value) -> CliResult { - let bytes = canonical_bytes(payload); - let stderr = std::io::stderr(); - let mut lock = stderr.lock(); - let _ = lock.write_all(&bytes); - let _ = lock.flush(); - CliResult { - stdout: Vec::new(), - exit_code: USAGE_EXIT_CODE, +/// The surface grammar that makes extraction sound by construction. +pub fn surface_grammar(target: &TargetDef) -> Value { + match target.name.as_str() { + "rust" => json!({ + "globReexports": "rejected", + "language": "rust", + "macroGeneratedPublicItems": "rejected", + "namedReexports": "recorded as the unresolved written binding", + "parseErrors": "reject the candidate", + "publicItems": "named pub declarations at statically visible paths", + }), + _ => json!({ + "defaultExports": "rejected", + "inferredPublicTypes": "rejected", + "language": "typescript", + "parseErrors": "reject the candidate", + "publicItems": "explicit named exports only", + "starExports": "rejected", + }), } } -/// The canonical body of a prerequisite refusal: a command whose meaning requires -/// a committed head, run in a workspace that has none. -/// -/// An empty success shape would be a lie here, because nothing was compared: the -/// payload names what is missing and the exact sequence that creates it, from -/// `napl init` through writing a prompt beside `.napl`, `add`, and `commit`. -pub fn prerequisite_json(command: &str) -> Value { +/// The canonical JSON body of a target definition object. +pub fn target_def_json(target: &TargetDef) -> Value { json!({ - "command": command, "contractVersion": crate::version::CONTRACT_VERSION, - "detail": format!( - "{command} needs a committed head to compare against, and this workspace has none" - ), - "exitCode": USAGE_EXIT_CODE, - "missing": "head", - "outcome": "prerequisite", - "sequence": [ - { - "command": "napl init", - "step": "init", - }, - { - "command": "napl write .napl < prompt", - "step": "prompt", - }, - { - "command": "napl add --module ", - "step": "add", - }, - { - "command": "napl commit --staged ", - "step": "commit", - }, - ], + "entryPointVersion": target.entry_point_version, + "manifestFile": target.manifest_file, + "name": target.name, + "ownedGlobs": ["/{sourceDir}/**"], + "placement": "/", + "sourceDir": target.source_dir, + "sourceExtension": target.source_extension, + "surfaceGrammar": surface_grammar(target), }) } -/// Write a prerequisite refusal to stderr as canonical JSON under the pinned -/// usage-error code, so a caller piping stdout never reads a refusal as a result. -pub fn emit_prerequisite(command: &str) -> CliResult { - emit_usage(&prerequisite_json(command)) -} - -fn ok_bytes(value: &Value) -> CliResult { - CliResult { - stdout: canonical_bytes(value), - exit_code: 0, - } +/// The content identity of a target definition. +pub fn target_def_hash(target: &TargetDef) -> String { + json_hash(ObjectKind::TargetDef, &target_def_json(target)) } -pub(crate) fn ok(value: &Value) -> Result { - Ok(ok_bytes(value)) +/// The placement facts that enter a module's `inputKey`. +pub fn placement_facts(target: &TargetDef, root: &str) -> Value { + json!({ + "ownedGlobs": owned_globs(target, root), + "root": root, + }) } -/// A usage failure raised from inside a command body: a missing required flag, -/// a missing positional, a malformed value. It carries the pinned usage code and -/// the dispatch loop restates the command's exact usage line around it. -pub(crate) fn usage(detail: &str) -> CompilerError { - error(OutcomeCode::Usage, detail.to_string()) -} +napl-store snapshot v1 1005 +{"contractVersion":4,"event":"5577592b64ed66ed8bd6911d322d138c119734e148c0ff8106afa475413c5eed","states":[{"module":"agent","state":"d0d406dd4f30ea6840a808bcbc3ddeea8ec8cc10c84de12ab5d0cfeef7bb6696","target":"rust"},{"module":"commands","state":"ac7d92c561ec9fa19f0e28e1392bd90c1d95ca663b038424bd8ce760c6f4cd9e","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"42cee1b00043646e7e0ebed264397212c4ae136d44233aee64af6bebcab11d53","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"2f4def880281f987518186a75836278fd0c2286ca7cd8a63094c9467bc92f5d6","target":"rust"},{"module":"serve","state":"40ddb00b3be320909acf0ae1440e2bc5662e3002dcf77dc3367bcb215b1710fd","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} +napl-store tree v1 130 +{"entries":[{"hash":"8e0041629b58cb5f197dfc8bda886af7ba35a32f3cd7dd91396b139607cbf22b","kind":"dir","mode":16384,"name":"napl"}]} +napl-store snapshot v1 895 +{"contractVersion":4,"event":"f22e593a636a23cc73d78d2e0e687cd1c4f566ab623b6a7f49e279fe88286fe5","states":[{"module":"agent","state":"112df64307c695d6e80359ccbbcf9100579692216d90fd08be281e7c82c07574","target":"rust"},{"module":"commands","state":"9e7b90a24d35bb3d42c0476c6d37e380c905ed227e86d8cf44e8342e96265a33","target":"rust"},{"module":"git-store","state":"33a141441be88c6825e85a1495467d599a016bae61a678358f9d680a52017c99","target":"rust"},{"module":"napl","state":"34af262bdb9e210661534e5954107df8da9354c9e616f791c2dd3346242be742","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"f38b0d5a644c8edfdf8d88f5387838f0df2f638ef556620264f3a6900afd8c6b","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} +napl-store snapshot v1 891 +{"contractVersion":4,"event":"87a81273601f87562d1d76e3cabb77aae24f69d0ce565b9026b1b57802971049","states":[{"module":"agent","state":"b5cde0e371a87ce3810de7c7b7af9e48bf453094540fce927bde21dde6b576b9","target":"rust"},{"module":"commands","state":"74f67c7538797091e6d19bd6f87099d0b414743cc4cc151db667619ff4a48d03","target":"rust"},{"module":"napl","state":"a9231bb7eaaf6d32d3a8179fc0925d9602207968fd5d49a0548b9d2817f9820e","target":"rust"},{"module":"napl_store","state":"ede6f68b1d885a44d4fc2effd563a93d50c5b5ad8c31cdf2f07bb0f849109d1e","target":"rust"},{"module":"query","state":"2ceea09f82f0700bbe48909b0c56aa0e69cc477dd51287bd88cd2e87f6f7a1e1","target":"rust"},{"module":"serve","state":"8b31e47aad33789775fb32ae56868e37db7a9dd174e6df3a0262781a7ed2f6ae","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} +napl-store blob v1 12986 +//! `napl commit`: the one guarded write path. +//! +//! The shape of this file is the contract's pipeline, in order: ingest, verify, +//! extract, derive, stage — all outside any lock — and then one bounded critical +//! section that rechecks the footprint and swaps the pointer and the directory. +//! Nothing inside the lock hashes content, and the number of steps it applies is +//! fixed before it is taken. +//! +//! Every decision it makes is the pure compiler's. This file supplies bytes and +//! performs writes. -pub(crate) fn resolve_workspace(cwd: &Path, args: &[String]) -> Result { - match flag(args, "--workspace")? { - Some(explicit) => Ok(PathBuf::from(explicit)), - None => require_workspace_root(cwd), - } -} - -fn is_flag(arg: &str) -> bool { - arg.starts_with("--") -} - -/// True when a flag name is declared as a switch by some command in the -/// registry, so the argument walk knows it consumes no value. Switch names are -/// unique across the registry by construction; a name declared as a switch -/// anywhere is a switch everywhere. -fn is_switch(arg: &str) -> bool { - let name = arg.split('=').next().unwrap_or(arg); - if HELP_FLAGS.contains(&name) { - return true; - } - crate::registry().iter().any(|spec| { - spec.flags - .iter() - .any(|flag| flag.name == name && flag.arity == FlagArity::Switch) - }) -} - -pub(crate) fn positional(args: &[String], index: usize) -> Result { - positionals_from(args, 0) - .get(index) - .cloned() - .ok_or_else(|| usage(&format!("missing positional argument {index}"))) -} - -pub(crate) fn positionals_from(args: &[String], skip: usize) -> Vec { - let mut out: Vec = Vec::new(); - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - if is_flag(arg) { - if !is_switch(arg) && !arg.contains('=') { - iter.next(); - } - continue; - } - out.push(arg.clone()); - } - out.into_iter().skip(skip).collect() -} - -pub(crate) fn has_switch(args: &[String], name: &str) -> bool { - args.iter().any(|arg| arg == name) -} - -pub(crate) fn flag(args: &[String], name: &str) -> Result> { - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - if arg == name { - return iter - .next() - .cloned() - .map(Some) - .ok_or_else(|| usage(&format!("{name} needs a value"))); - } - if let Some(value) = arg.strip_prefix(&format!("{name}=")) { - return Ok(Some(value.to_string())); - } - } - Ok(None) -} - -pub(crate) fn repeated_flag(args: &[String], name: &str) -> Vec { - let mut out: Vec = Vec::new(); - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - if arg == name { - if let Some(value) = iter.next() { - out.push(value.clone()); - } - } else if let Some(value) = arg.strip_prefix(&format!("{name}=")) { - out.push(value.to_string()); - } - } - out -} - -pub(crate) fn require_flag(args: &[String], name: &str) -> Result { - flag(args, name)?.ok_or_else(|| usage(&format!("{name} is required"))) -} -napl-store tree v1 129 -{"entries":[{"hash":"279b09a95275fcdffed7d04f7abeac151929523eeb3f1773ca01a4b042570dc9","kind":"dir","mode":16384,"name":"src"}]} -napl-store snapshot v1 445 -{"contractVersion":2,"event":"b260a2fdfd546f30e7123695754da1bc177f26f76048a2c7b8bcce9285fb8ae2","states":[{"module":"commands","state":"7c7e8183b9fbf594f3d61a4ca43f5154c404f912f32b8789d32c697ae883859b","target":"rust"},{"module":"napl","state":"ede5d6f7c642bc1ae88d4737fc899bdcc20ef363ef468792804cd36f02350c41","target":"rust"},{"module":"napl_store","state":"d5141dcf22807eb4d7314871e99dd9b1f165a355d2ba0c088f6d2f4e2b9cf7ac","target":"rust"}]} -napl-store blob v1 1439 -module: "commands" -target: "rust" -stale: false -entries: - - promptLines: - - 1 - - 8 - file: "crates/napl/src/init.rs" - lines: whole - note: "two porcelain additions each a thin composition over the shared derivations, the command table contributing init and watch" - - promptLines: - - 9 - - 124 - file: "crates/napl/src/init.rs" - lines: whole - note: "napl init bootstraps the store skeleton or reports already_initialized, names the directory it ran in as the workspace root, declares no flags beyond the shared help flag, teaches the first mile through nextSteps whose minimal prompt declares roots naming its own module so the first workspace is lint-ready, and scaffolds the agent glue by writing only the entry files that are absent, never touching one that exists, and spelling every command as a valid invocation under the live command line contract so the query example carries its expression, and scaffolds the store's travel glue under the same never-clobber law, a pre-push hook that carries the store branch written only where no pre-push hook exists and the one line to add printed where one does, with the two protection rules taught in the payload and none of it appearing at all outside a git repository" - - promptLines: - - 125 - - 146 - file: "crates/napl/src/watch.rs" - lines: whole - note: "napl watch an io shell re-running compile on content change publishing nothing" -napl-store tree v1 136 -{"entries":[{"hash":"afd89e2c931d08eca6a2030a2c098322e09c32eb9e4b70405f2fcc67e728dba0","kind":"dir","mode":16384,"name":"napl-store"}]} -napl-store module-state v1 564 -{"attribution":{"promptHash":"85eab36f412936d247f59dc688116dd31246d27c0b86a7b3df52829d27e2d90a","record":"503ce69816a25637fa759ebc9ee0e90d64903dde221729eb06e6f8edb26bf571","stale":false},"contractVersion":4,"inputKey":"865433045eb8c750a39bd7d29f2e8631c96a03d6aedad8f19ea9907551bdc927","module":"napl_store","outputTree":"ff83494adc1c5e3514e322b085e3a4d0de257ed1eed408a0d4ae9d6e37dbbc4e","sliceHash":"951ebb40565bde5002786d6a78770f17133d18e44aa250622eec8fef2d0596d9","surfaceHash":"50b745f71f63d7fd5b8a05fdf84474524004af8f75d47f8346b7404f99981fab","target":"rust"} -napl-store blob v1 1759 -module: "commands" -target: "rust" -stale: false -entries: - - promptLines: - - 1 - - 8 - file: "crates/napl/src/init.rs" - lines: whole - note: "two porcelain additions each a thin composition over the shared derivations, the command table contributing init and watch" - - promptLines: - - 9 - - 131 - file: "crates/napl/src/init.rs" - lines: whole - note: "napl init bootstraps the store skeleton or reports already_initialized, names the directory it ran in as the workspace root, declares no flags beyond the shared help flag, teaches the first mile through nextSteps whose minimal prompt declares roots naming its own module so the first workspace is lint-ready, and scaffolds the agent glue by writing only the entry files that are absent, never touching one that exists, and spelling every command as a valid invocation under the live command line contract so the query example carries its expression, and scaffolds the store's git manners under the same never-clobber law, a gitattributes whose one glob marks the objects and the packs beneath them never text undiffed and generated because an eol-converting checkout would corrupt every envelope against its own name, whose HEAD line marks the pointer binary so a merge never writes conflict markers into it, and whose notes and attribution lines mark those records never text and nothing else because reading their diffs is what they are for, written only where no gitattributes exists and the exact lines printed where one does, and none of it appearing at all outside a git repository" - - promptLines: - - 132 - - 153 - file: "crates/napl/src/watch.rs" - lines: whole - note: "napl watch an io shell re-running compile on content change publishing nothing" -napl-store blob v1 25465 -//! `napl commit`: the one guarded write path. -//! -//! The shape of this file is the contract's pipeline, in order: ingest, verify, -//! extract, derive, stage — all outside any lock — and then one bounded critical -//! section that rechecks the footprint and swaps the pointer and the directory. -//! Nothing inside the lock hashes content, and the number of steps it applies is -//! fixed before it is taken. -//! -//! Every decision it makes is the pure compiler's. This file supplies bytes and -//! performs writes. - -use std::collections::BTreeMap; -use std::path::Path; +use std::collections::BTreeMap; +use std::path::Path; use crate::candidate::{default_limits, ingest, Candidate}; use crate::compile::{head_states, slice_at, state_of}; @@ -231112,26 +228885,6 @@ struct Published { provenance: bool, } -/// How a commit treats the attribution update it carries. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AttributionMode { - /// Prove every claim against the ingested candidate and the current prompt - /// body before anything installs. The default, and what `add` requires. - Prove, - /// Install an update whose claims were already proven, referentially and in - /// full, against the exact bytes they name. This is the capture verbs' mode: - /// a reference-bearing edit proves its ranges against the post-edit working - /// tree, which is the content the reference is about, and the candidate it - /// re-commits is the module's unchanged committed output. Re-proving the - /// reference against those older bytes would judge a claim nobody made. The - /// guard is untouched: the whole transaction, decision table, journal, and - /// recovery run exactly as they do for `commit`. - PreProven, - /// The explicit escape hatch: install unproven and record the module's - /// attribution as stale. - Escape, -} - /// Run a commit. It never panics and never returns an error: every path /// produces a canonical payload naming whether blobs or provenance were /// published, and no path publishes semantic state partially. @@ -231141,36 +228894,6 @@ pub fn commit( candidate_dir: &Path, attribution_path: Option<&Path>, no_verify: bool, -) -> CommitReport { - let mode = if no_verify { - AttributionMode::Escape - } else { - AttributionMode::Prove - }; - commit_with_mode( - workspace, - receipt_path, - candidate_dir, - attribution_path, - mode, - None, - ) -} - -/// Run a commit under an explicit attribution mode. -/// -/// `staged_prompt` is the module's prompt file as `add` captured it. A record -/// staged through `add` always carries one, and commit installs those bytes, so -/// a prompt edited between add and commit is not absorbed unjudged. A caller -/// with no staged copy, which is every capture verb, passes `None` and the -/// working tree answers. -pub fn commit_with_mode( - workspace: &Path, - receipt_path: &Path, - candidate_dir: &Path, - attribution_path: Option<&Path>, - mode: AttributionMode, - staged_prompt: Option<&Path>, ) -> CommitReport { let mut published = Published::default(); match run( @@ -231178,8 +228901,7 @@ pub fn commit_with_mode( receipt_path, candidate_dir, attribution_path, - mode, - staged_prompt, + no_verify, &mut published, ) { Ok(payload) => CommitReport { @@ -231193,17 +228915,17 @@ pub fn commit_with_mode( } } -#[allow(clippy::too_many_arguments)] fn run( workspace: &Path, receipt_path: &Path, candidate_dir: &Path, attribution_path: Option<&Path>, - mode: AttributionMode, - staged_prompt: Option<&Path>, + no_verify: bool, published: &mut Published, ) -> Result { let store = disk::init(workspace)?; + // A crashed predecessor's marker is rolled forward before this commit reads + // anything, so it never observes a half-applied head. recover(&store)?; let receipt_bytes = @@ -231212,12 +228934,17 @@ fn run( let target = builtin_target(&receipt.target)?; let sources = discover_sources(workspace)?; + // 1. Ingest. Every later step reads these bytes, never the candidate + // directory, so tampering with the directory afterwards changes nothing. let candidate = ingest(&scan_candidate(candidate_dir)?, default_limits())?; let root = placement_of(&sources, &receipt.module)?; check_candidate_shape(&target, &root, &candidate)?; + // 2. Verify against independently recomputed identities, before publishing. crate::transaction::verify_receipt(&store, &sources, &receipt, &candidate)?; + // 2b. Prove the attribution update the candidate carries, or refuse to + // install without one. The escape hatch installs it unproven and stale. let built = build_attribution( &sources, &receipt, @@ -231225,53 +228952,17 @@ fn run( &root, &candidate, attribution_path, - mode, + no_verify, )?; - let source = sources.iter().find(|s| s.module == receipt.module); - let prompt_file = match (staged_prompt, source) { - (Some(staged), _) => Some(std::fs::read(staged).map_err(|e| disk::io(staged, &e))?), - (None, Some(src)) => { - let path = workspace.join(&src.path); - Some(std::fs::read(&path).map_err(|e| disk::io(&path, &e))?) - } - (None, None) => None, - }; - if let Some(bytes) = &prompt_file { - let spelled = source.map(|src| src.path.clone()).unwrap_or_default(); - let text = std::str::from_utf8(bytes).map_err(|_| { - error( - OutcomeCode::InvalidCandidate, - format!( - "the staged prompt for module {:?} is not valid UTF-8", - receipt.module - ), - ) - })?; - let parsed = crate::source::parse_source(&spelled, text)?; - if parsed.module != receipt.module { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "the staged prompt declares module {:?} but this record commits {:?}", - parsed.module, receipt.module - ), - )); - } - } - let prompt_object = prompt_file.as_ref().map(|bytes| { - napl_store::store_hash::object_name(napl_store::store_hash::ObjectKind::Blob, bytes) - }); - let derived = derive_commit( - &target, - &root, - &receipt, - &candidate, - built.as_ref(), - prompt_object, - )?; + // 3. Extract the surface and derive the whole transition. + let derived = derive_commit(&target, &root, &receipt, &candidate, built.as_ref())?; + // 4. Publish blobs and provenance outside any lock. publish_all(&store, &derived.objects, &derived.transition_id)?; + // The module's committed prompt bytes ride along as a content-addressed + // blob named by the prompt hash the attribution already records, so + // `napl prompt` materializes spec prose from the snapshot, never the tree. if let Some(src) = sources.iter().find(|s| s.module == receipt.module) { disk::publish( &store, @@ -231280,22 +228971,11 @@ fn run( &derived.transition_id, )?; } - if let Some(bytes) = &prompt_file { - disk::publish( - &store, - napl_store::store_hash::ObjectKind::Blob, - bytes, - &derived.transition_id, - )?; - } published.blobs = true; published.provenance = true; - let mut payload = - install(&store, workspace, &target, &root, &receipt, &sources, &candidate, &derived)?; - - crate::porcelain::repack_if_loose(&store)?.record(&mut payload); - Ok(payload) + // 5. The bounded critical section. + install(&store, workspace, &target, &root, &receipt, &sources, &candidate, &derived) } #[allow(clippy::too_many_arguments)] @@ -231337,6 +229017,8 @@ fn locked_install( ) -> Result { let head = store.head()?; + // The footprint recheck: identities recomputed at the head that actually + // won, never at the head the receipt assumed. let head_slice = slice_at( store, sources, @@ -231377,6 +229059,8 @@ fn locked_install( )?; publish_all(store, &accepted.objects, &derived.transition_id)?; + // The marker is durable before the pointer moves, so a crash anywhere after + // this point leaves a plan recovery derives from content alone. let marker = CommitMarker { domain: receipt.module.clone(), owner: derived.transition_id.clone(), @@ -231391,276 +229075,6 @@ fn locked_install( Ok(success_payload(derived, &accepted)) } -/// Install a docs record: write the new prompt object and re-point the module's -/// prompt at it, while every derived fact holds. -/// -/// The module keeps its inputKey, its attribution map, its endpoints, its surface -/// and its output tree, because none of them is a function of prose. What moves -/// is the state's prompt object and the head that names the state. The transition -/// says `docs-only`, so history never confuses prose with semantics. -pub fn commit_docs(workspace: &Path, record: &Path) -> CommitReport { - let mut published = Published::default(); - match run_docs(workspace, record, &mut published) { - Ok(payload) => CommitReport { - outcome: OutcomeCode::Success, - payload, - }, - Err(err) => CommitReport { - outcome: err.code, - payload: error_payload(&err, published.blobs, published.provenance), - }, - } -} - -fn run_docs(workspace: &Path, record: &Path, published: &mut Published) -> Result { - let store = disk::init(workspace)?; - recover(&store)?; - - let record_dir = crate::staging::record_directory(record)?; - let meta = crate::staging::record_meta(&record_dir)?; - let module = meta["module"].as_str().unwrap_or_default().to_string(); - let target_name = meta["target"].as_str().unwrap_or("rust").to_string(); - builtin_target(&target_name)?; - let prompt_path = meta["promptPath"].as_str().unwrap_or_default().to_string(); - let base_state_key = meta["baseStateKey"].as_str().unwrap_or_default().to_string(); - if base_state_key.is_empty() { - return Err(error( - OutcomeCode::InvalidReceipt, - format!("the staged docs record for module {module:?} names no base state; re-run `napl add {prompt_path}`"), - )); - } - - let staged = record_dir.join(crate::staging::DOCS_PROMPT_FILE); - let bytes = std::fs::read(&staged).map_err(|e| disk::io(&staged, &e))?; - let text = String::from_utf8(bytes).map_err(|_| { - error( - OutcomeCode::InvalidCandidate, - format!("the staged prompt for module {module:?} is not valid UTF-8"), - ) - })?; - let parsed = crate::source::parse_source(&prompt_path, &text)?; - if parsed.module != module { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "the staged prompt declares module {:?} but the record names {module:?}", - parsed.module - ), - )); - } - - let object = - napl_store::store_hash::object_name(napl_store::store_hash::ObjectKind::Blob, text.as_bytes()); - - let head = store.head()?; - let committed_now = crate::porcelain::committed_state(&store, &target_name, &module)?; - if committed_now.as_ref().and_then(|s| s.prompt.as_deref()) == Some(object.as_str()) { - return Ok(serde_json::json!({ - "alreadyRecorded": true, - "blobsPublished": false, - "contractVersion": crate::version::CONTRACT_VERSION, - "detail": "the committed prompt already is these bytes, so this record had nothing left to install and was swept", - "head": head, - "installed": [], - "kind": Value::Null, - "module": module, - "outcome": "success", - "prompt": object, - "promptPath": prompt_path, - "provenancePublished": false, - "semanticStatePublished": false, - "target": target_name, - })); - } - - let state = crate::objects::read_module_state(&store, &base_state_key)?; - let Some(baseline) = &state.prompt else { - return Err(error( - OutcomeCode::InvalidCandidate, - format!("the base state of module {module:?} names no prompt object, so there is no committed prompt to prove this docs edit against"), - )); - }; - let committed = crate::staging::committed_prompt(&store, baseline, &module)?; - match crate::staging::judge_prompt(&prompt_path, &committed, &text)? { - crate::staging::PromptDrift::DocsOnly => {} - other => { - return Err(error( - OutcomeCode::InvalidCandidate, - format!("the staged prompt for module {module:?} is not a docs-only edit against the base state it names ({other:?}), and no docs record may install one"), - )); - } - } - - let new_state = crate::objects::ModuleState { - prompt: Some(object.clone()), - ..state - }; - let new_state_key = crate::objects::module_state_hash(&new_state); - let transition = crate::objects::Transition { - module: module.clone(), - target: target_name.clone(), - input_key: new_state.input_key.clone().unwrap_or_default(), - base_state_key: base_state_key.clone(), - new_state: new_state_key.clone(), - receipt: String::new(), - surface: new_state.surface_hash.clone(), - footprint: crate::objects::Footprint { - read_set: vec![format!("prompt:{baseline}")], - write_set: vec![crate::objects::state_write_key(&target_name, &module)], - derived_projections: Vec::new(), - install_paths: Vec::new(), - }, - kind: Some(crate::objects::DOCS_ONLY.to_string()), - prompt: Some(object.clone()), - }; - let transition_id = crate::objects::transition_hash(&transition); - - let objects = vec![ - PendingObject { - name: object.clone(), - kind: napl_store::store_hash::ObjectKind::Blob, - payload: text.as_bytes().to_vec(), - }, - json_pending( - napl_store::store_hash::ObjectKind::ModuleState, - &crate::objects::module_state_json(&new_state), - ), - json_pending( - napl_store::store_hash::ObjectKind::Transition, - &crate::objects::transition_json(&transition), - ), - ]; - publish_all(&store, &objects, &transition_id)?; - published.blobs = true; - published.provenance = true; - - let payload = install_docs( - &store, - &target_name, - &module, - &base_state_key, - &new_state_key, - &transition_id, - &object, - &prompt_path, - )?; - Ok(payload) -} - -#[allow(clippy::too_many_arguments)] -fn install_docs( - store: &Store, - target_name: &str, - module: &str, - base_state_key: &str, - new_state_key: &str, - transition_id: &str, - prompt_object: &str, - prompt_path: &str, -) -> Result { - let module_lock = disk::take_lock(store, &lock_key(target_name, module))?; - let pointer_lock = match disk::take_lock(store, POINTER_LOCK) { - Ok(guard) => guard, - Err(err) => { - disk::release_lock(module_lock)?; - return Err(err); - } - }; - let outcome = locked_docs_install( - store, - target_name, - module, - base_state_key, - new_state_key, - transition_id, - prompt_object, - prompt_path, - ); - disk::release_lock(pointer_lock)?; - disk::release_lock(module_lock)?; - outcome -} - -#[allow(clippy::too_many_arguments)] -fn locked_docs_install( - store: &Store, - target_name: &str, - module: &str, - base_state_key: &str, - new_state_key: &str, - transition_id: &str, - prompt_object: &str, - prompt_path: &str, -) -> Result { - let head = store.head()?; - let mut states = head_states(store, head.as_deref())?; - let key = (target_name.to_string(), module.to_string()); - if states.get(&key).map(String::as_str) != Some(base_state_key) { - return Err(error( - OutcomeCode::WriteConflict, - format!("module {module:?} moved under this docs record; re-run `napl add {prompt_path}`"), - )); - } - states.insert(key, new_state_key.to_string()); - - let parents: Vec = match head.as_deref() { - Some(snapshot) => vec![crate::objects::snapshot_event(store, snapshot)?], - None => Vec::new(), - }; - let event = crate::objects::event_hash(transition_id, &parents); - let snapshot = crate::objects::snapshot_hash(&event, &states); - publish_all( - store, - &[ - json_pending( - napl_store::store_hash::ObjectKind::Event, - &crate::objects::event_json(transition_id, &parents), - ), - json_pending( - napl_store::store_hash::ObjectKind::Snapshot, - &crate::objects::snapshot_json(&event, &states), - ), - ], - transition_id, - )?; - - let marker = CommitMarker { - domain: module.to_string(), - owner: transition_id.to_string(), - snapshot: snapshot.clone(), - steps: Vec::new(), - }; - disk::write_marker(store, &marker)?; - disk::compare_and_set_head(store, head.as_deref(), &snapshot)?; - disk::delete_marker(store, &snapshot)?; - - Ok(serde_json::json!({ - "attribution": "fresh", - "blobsPublished": true, - "contractVersion": crate::version::CONTRACT_VERSION, - "head": snapshot, - "installed": [], - "kind": crate::objects::DOCS_ONLY, - "module": module, - "outcome": "success", - "prompt": prompt_object, - "promptPath": prompt_path, - "provenancePublished": true, - "semanticStatePublished": true, - "target": target_name, - "transition": transition_id, - })) -} - -fn json_pending(kind: napl_store::store_hash::ObjectKind, value: &Value) -> PendingObject { - let (name, payload) = crate::canon::hash_json(kind, value); - PendingObject { - name, - kind, - payload, - } -} - /// Roll every recoverable commit marker forward, then drop it. /// /// The disposition is `napl-store`'s: a marker whose snapshot is HEAD or an @@ -231707,9 +229121,7 @@ fn publish_all(store: &Store, objects: &[PendingObject], owner: &str) -> Result< /// unless the escape hatch is set. With the escape hatch, an update installs /// unproven and is recorded stale, and a commit with no update at all leaves the /// module's attribution absent. A proven update is validated against the ingested -/// candidate and the current normalized prompt body before anything installs. A -/// pre-proven update installs fresh without a second validation, because its -/// claims were already proven against the exact bytes they name. +/// candidate and the current normalized prompt body before anything installs. #[allow(clippy::too_many_arguments)] fn build_attribution( sources: &[SourceModule], @@ -231718,9 +229130,8 @@ fn build_attribution( root: &str, candidate: &Candidate, attribution_path: Option<&Path>, - mode: AttributionMode, + no_verify: bool, ) -> Result> { - let no_verify = mode == AttributionMode::Escape; let bytes = match attribution_path { Some(path) => std::fs::read(path).map_err(|e| disk::io(path, &e))?, None => { @@ -231759,7 +229170,7 @@ fn build_attribution( format!("module {:?} has no prompt file in the workspace", receipt.module), ) })?; - if mode == AttributionMode::Prove { + if !no_verify { let body_lines = source.normalized_body.lines().count() as u32; attribution::validate( target, @@ -231787,7 +229198,7 @@ fn build_attribution( /// The lock name a module's domain is keyed by. There is no global write lock: /// two modules commit concurrently without ever contending. pub fn lock_key(target: &str, module: &str) -> String { - format!("module-{target}-{}", crate::paths::encode_name(module)) + format!("module-{target}-{module}") } /// The crate directory a module's prompt sits in, read off the discovered @@ -231804,5725 +229215,13825 @@ fn placement_of(sources: &[SourceModule], module: &str) -> Result { ) }) } -napl-store tree v1 130 -{"entries":[{"hash":"621295411a54c711bb2002b25e35ee2504da96049cb9f1a4e375d21d96bbde31","kind":"dir","mode":16384,"name":"napl"}]} -napl-store receipt v1 815 -{"baseStateKey":"cca37ce0ab52aabdb236250fe7211d03ae415524090fe1ea8d26ed32f63a0714","baseStoreSnapshot":"2b3feb80cd701b183f001a4b6848669e0e3999d8f4111519dacb885fe8b5dfea","baseWorkspaceHash":"deae17e7d8faae52c958b035adffbad6a288c5ad47b99d6867eb22cd0c828cc1","candidateTreeHash":"9f4012e8a99b2f4b02d181e9b143d433c28e42d021ae54a95e4f0532441bc34f","contractVersion":4,"inputKey":"7706591c3b4333a5a63ee74e62afcf995e52fba9ec546755861dbe9afad51b2c","module":"commands","outputManifest":[{"hash":"5e6d95977066d4654c90b04b5924524324c7572a9c6ec7d315046f7503d8c413","path":"crates/napl/src/init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","path":"crates/napl/src/watch.rs"}],"receiptVersion":1,"sliceHash":"68a148095cb3ff456ce3fc67c2b82b7d160324ef4747870628c765de84f48462","target":"rust"} -napl-store snapshot v1 1005 -{"contractVersion":4,"event":"069c2728f8a4fd65fe20f47e6a3d13c0ed56b7f45f47f325935ee81b346fed45","states":[{"module":"agent","state":"f26d6fde72e9fc4f86b6909dc51455aaf37c8a17ed31d6db11e94d9a0408b324","target":"rust"},{"module":"commands","state":"5c9687b0b683b34bcb823c08473e0bb5b941fc81ccf943666acc7640b948d3da","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"e5181ff4a21adb9dbd040fac3f0a5c958ff8273c4c141fe9f4a438eb2b02d800","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"37fc6230a31e6efbc940e618a1ccc6af767b5660a9961c987cc04018ac20076a","target":"rust"},{"module":"serve","state":"dc62dc04b6d519a8cccdd4d4a3f1b1fa1a0fd830ca0ae5b01058e9d7b5347b98","target":"rust"},{"module":"usage_proxy","state":"e06293a226b1c96b12b2ce6037f5ffdbca05c34b57e0493823456f2c146a4ca1","target":"rust"}]} -napl-store tree v1 130 -{"entries":[{"hash":"9cff2b0892f0b162d1154c2c40241862f71545009a168e05b9928c40fd016df5","kind":"dir","mode":16384,"name":"napl"}]} -napl-store receipt v1 815 -{"baseStateKey":"cb82ac8a35d902954b841fa6b0c14cd03d99e15d69b8005c3986d5ad66e22b29","baseStoreSnapshot":"004169b2891eccf80efc107d5fdca1836dedf116b1bed2892ef7df8609cf2fad","baseWorkspaceHash":"d233c435c9c3f542ebe93d3316cfd02e5b3117ce60b4a048deb988277b9005ba","candidateTreeHash":"b92d10f23649a6ab076e0a23eeda2a922fbb3e779486a81b37c96c11f63d7873","contractVersion":4,"inputKey":"58fca4cee049991c052e54dc443f06f4d3b78ec74aad2fb6971c3138171783c9","module":"commands","outputManifest":[{"hash":"4b14b273d01558abe3560da549f380b808673d119fa0b3f23343a210e6098aee","path":"crates/napl/src/init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","path":"crates/napl/src/watch.rs"}],"receiptVersion":1,"sliceHash":"e4357376b35865972053121b598b255836d940b6c9c6398b07e24e2721ba0fdc","target":"rust"} -napl-store snapshot v1 891 -{"contractVersion":4,"event":"41831e2909f88aaf88451b2435b6bcd41be1ffb1066197bf1ed0fe5e2107876f","states":[{"module":"agent","state":"b5cde0e371a87ce3810de7c7b7af9e48bf453094540fce927bde21dde6b576b9","target":"rust"},{"module":"commands","state":"74f67c7538797091e6d19bd6f87099d0b414743cc4cc151db667619ff4a48d03","target":"rust"},{"module":"napl","state":"56e953f37cb5363c713a80779f2d9c53ed61639c3b0ec2591272f695fb39dfed","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"2ceea09f82f0700bbe48909b0c56aa0e69cc477dd51287bd88cd2e87f6f7a1e1","target":"rust"},{"module":"serve","state":"dd477d0f6a8458d7f08017856ea567c847968a47135ac24ed6afafb6b20b2b8f","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} -napl-store tree v1 136 -{"entries":[{"hash":"e2e63fc725000f036533110366b2bc711836e51e37ed6645ab598f43235a0939","kind":"dir","mode":16384,"name":"napl-store"}]} -napl-store transition v1 843 -{"baseStateKey":"3960d70a07476e0ca5ad272606f66b22890a9d9169b4b411397290260c51e4a9","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"8e31863d6e42fcf4c3d9eb0a7c6546b77e5b0119069b83f197b0bed1c6291cdd","installPaths":["crates/napl/src/agent.rs"],"module":"agent","newState":"7c568214148bdacf0c078e2420e1c8355c24100c703f3490f65ca9bcf1f64535","readSet":["base-state:3960d70a07476e0ca5ad272606f66b22890a9d9169b4b411397290260c51e4a9","input-key:8e31863d6e42fcf4c3d9eb0a7c6546b77e5b0119069b83f197b0bed1c6291cdd","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"7230e35b63a6e73c43fbe209beb2ce01f229aed222f844a91d5986a90c6da014","surface":"ad1581ae209ba3feb19322408bfff544be7a29bc6a7db44c672b705f1afcd120","target":"rust","writeSet":["owned:crates/napl","module-state:rust/agent"]} -napl-store event v1 158 -{"content":"ae84275fd397e90e7bd5908e314c5e069eeb8133820cb7ceb15bfb8be2e61f3b","parents":["57e89f67d8f577c3d02c04f0e83ae79cad645b88ec9c2be490c8515a29c1a778"]} -napl-store tree v1 130 -{"entries":[{"hash":"c34719963c6609475a3a32e33030cfa0b5c3cc087a1e73fce9dc4c801acebc5b","kind":"dir","mode":16384,"name":"napl"}]} napl-store snapshot v1 895 -{"contractVersion":4,"event":"ddbbd2e77f026e42ea002ce1b43457362f9cb8b4f444487ed662ad8d7c22d9b5","states":[{"module":"agent","state":"67df6d3a1c20bac424e4a0104bf02ea3dd17d728c930e641c212ce0bfd3260d1","target":"rust"},{"module":"commands","state":"723676083558c83d83926d043c8edd16bb2d48cfe3722fde330cf0351870b1a5","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"145d84afd1892b41d64f472a44ed100f908d8255144a8c7ba0335159325e56d5","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"7aa7e0b4e8cc02142a8aa14ca243a5f2058d72de2f65d7ddb7c59e3d201b692a","target":"rust"},{"module":"usage_proxy","state":"acd8f8cd1e71585d8b25804d42455806f39659af2949e7ed369deedcb63abbb2","target":"rust"}]} -napl-store transition v1 1907 -{"baseStateKey":"145d84afd1892b41d64f472a44ed100f908d8255144a8c7ba0335159325e56d5","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"8779274f09f294258d8a2963719ea67a4fdf5a4734d37939c620a596a427aa6b","installPaths":["crates/napl/src/attribution.rs","crates/napl/src/authoring.rs","crates/napl/src/blame.rs","crates/napl/src/candidate.rs","crates/napl/src/canon.rs","crates/napl/src/capture.rs","crates/napl/src/cli.rs","crates/napl/src/commandset.rs","crates/napl/src/commit.rs","crates/napl/src/compile.rs","crates/napl/src/cost.rs","crates/napl/src/decision.rs","crates/napl/src/directive.rs","crates/napl/src/disk.rs","crates/napl/src/error.rs","crates/napl/src/evidence.rs","crates/napl/src/graph.rs","crates/napl/src/lint.rs","crates/napl/src/lsp.rs","crates/napl/src/main.rs","crates/napl/src/maintenance.rs","crates/napl/src/mv.rs","crates/napl/src/objects.rs","crates/napl/src/paths.rs","crates/napl/src/porcelain.rs","crates/napl/src/receipt.rs","crates/napl/src/slice.rs","crates/napl/src/source.rs","crates/napl/src/staging.rs","crates/napl/src/store_view.rs","crates/napl/src/surface.rs","crates/napl/src/target.rs","crates/napl/src/textdiff.rs","crates/napl/src/transaction.rs","crates/napl/src/transport.rs","crates/napl/src/trees.rs","crates/napl/src/version.rs","crates/napl/src/workspace.rs"],"module":"napl","newState":"e5181ff4a21adb9dbd040fac3f0a5c958ff8273c4c141fe9f4a438eb2b02d800","readSet":["base-state:145d84afd1892b41d64f472a44ed100f908d8255144a8c7ba0335159325e56d5","input-key:8779274f09f294258d8a2963719ea67a4fdf5a4734d37939c620a596a427aa6b","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"f23a56b7192bb76b2bca2e1e30587339024960bc963f00249e35eda44620ee52","surface":"6771d238ea5e5fc5c8f741b560515bc9cdc65eebcb55f10dacac50273f555262","target":"rust","writeSet":["owned:crates/napl","module-state:rust/napl"]} -napl-store tree v1 130 -{"entries":[{"hash":"6e23642d8357ffec6e50f5c11c2788c67c0f8e64527c77601207ac9bfd43cd8a","kind":"dir","mode":16384,"name":"napl"}]} -napl-store blob v1 34932 -//! Body directive blocks: `directiveVersion 1`. -//! -//! A prompt body may pin exact corners next to the prose that sharpens them with -//! `::: test` blocks. Recognizing them is part of the identity contract: text an -//! older binary read as inert prose can be a compile error here, which is why -//! this recognition rides a CONTRACT_VERSION bump. The block content is an exact -//! value grammar that maps 1:1 onto canonical JSON, so two binaries cannot -//! disagree on a value. Extraction is a pure function of the normalized body -//! bytes and the directive version: the same bytes always yield the same cases. - -use serde_json::{Map, Value}; -use unicode_normalization::UnicodeNormalization; +{"contractVersion":4,"event":"61c81af035bed2e1255db7546ce0842eda9e60cf632f0e7eb870277caa837c53","states":[{"module":"agent","state":"112df64307c695d6e80359ccbbcf9100579692216d90fd08be281e7c82c07574","target":"rust"},{"module":"commands","state":"9e7b90a24d35bb3d42c0476c6d37e380c905ed227e86d8cf44e8342e96265a33","target":"rust"},{"module":"git-store","state":"33a141441be88c6825e85a1495467d599a016bae61a678358f9d680a52017c99","target":"rust"},{"module":"napl","state":"0ef99195476c7251619345064e2dc74fa005197438592370d03b0b5e5a688b55","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"f38b0d5a644c8edfdf8d88f5387838f0df2f638ef556620264f3a6900afd8c6b","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} +napl-store snapshot v1 781 +{"contractVersion":4,"event":"f3b88a7a02788d3c15aa142f2b1ec6a89ac03423a4f19e3f9333a7b636eef3a4","states":[{"module":"agent","state":"f66e5812f0b1d528c46f9377b7f6ff2595f21ee248ff0c9e799672f01b1edabd","target":"rust"},{"module":"commands","state":"9b23b62a07e1249fc078c3851c727ecf191bfe6c99ff2f5644f5eedbd2ff94e2","target":"rust"},{"module":"napl","state":"53a0ccab9dfa85e4f9715bc963de833a064081e2a7c96dc788bee20f3d9b650d","target":"rust"},{"module":"napl_store","state":"7ad4dba399677a4a801444f26c938bc4594731235bcccd9a979e1728b21f1822","target":"rust"},{"module":"query","state":"fafd87076d293a026ae6a6414d9a59f31d90c8a201c16a6167e90d03fe60aebc","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} +napl-store receipt v1 1042 +{"baseStateKey":"bc9f18a34748d245185298746e0db6fcac33afe57ddff8836927bb668f4172b6","baseStoreSnapshot":"937ba4b41de7564b24b82f0a73c4682155a931e651b629000414a8eb912c84cd","baseWorkspaceHash":"cd1ddce56e031ff2cb7ac24a109b3142d438c1a17491d2e1f8cb478b64231cc1","candidateTreeHash":"87dd9ca6b265f5f60f9a6a6a1f6e24134423830ba25de102f8ea3847df5a61a8","contractVersion":4,"inputKey":"1482b94e36b0b1f07a843d2b7144c900fb83228da90a6f9a367087cdc2b505fe","module":"query","outputManifest":[{"hash":"d6f8e36947df60a7eb714da2a8bd90ca48d88fd415c533ca24381e05a6ca9575","path":"crates/napl/src/check.rs"},{"hash":"8143fb1d2c2022405f5c7e77e08ea12da13b2379521f98fd50131e71c184219e","path":"crates/napl/src/document.rs"},{"hash":"6cb21bfc68622856056fac18b252e6ee0ffe650bda0923fb0745b26e10c18a06","path":"crates/napl/src/promptbuild.rs"},{"hash":"add27b49dd4b55c54e03c42b54962c7e9172e51a9b83b7e721f1fca8588b768a","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"sliceHash":"444a5b66d304eb5ed934ed56a58b5577bea31610466fff08a914c8679ccacfd4","target":"rust"} +napl-store blob v1 14266 +module: "napl" +target: "rust" +stale: false +entries: + - promptLines: + - 1 + - 8 + file: "crates/napl/src/cli.rs" + lines: whole + note: "the binary reads args runs one command and exits with the contract code" + - promptLines: + - 1 + - 8 + file: "crates/napl/src/commandset.rs" + lines: whole + note: "the command table the core toolchain contributes to the derived registry, store migrate among its entries" + - promptLines: + - 1 + - 8 + file: "crates/napl/src/main.rs" + lines: whole + note: "the binary entry point that spawns no process" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/source.rs" + lines: whole + note: "the smallest prompt the reader accepts, stated once beside the reader that judges it so the two commands that teach with it cannot drift from the grammar discovery finds by the .napl extension" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/transaction.rs" + lines: whole + note: "the crate lib.rs projection derived from the committed module file sets" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/workspace.rs" + lines: whole + note: "layout and discovery walk up to the nearest napl directory" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/paths.rs" + lines: whole + note: "the empty placement root contains every workspace-relative path and joins against it never fabricate a leading separator" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/target.rs" + lines: whole + note: "a prompt at the workspace root places at the empty root and every join against it stays relative" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/transaction.rs" + lines: whole + note: "candidate containment against the empty root admits every workspace-relative path instead of rejecting all of them" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/cli.rs" + lines: whole + note: "the declared grammar of every command plus the dispatch loop that answers help ahead of it and refuses an undeclared flag before it runs, with clap's builder API constructed at dispatch from the registry as the parser behind the contract, every parse failure caught and served as the canonical JSON usage error, and the conventional spellings reduced to one normalized argument vector so --flag=value and --flag value cannot differ" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/commandset.rs" + lines: whole + note: "each core command declares its summary exact usage line and closed flag table, diff's operand is a target name whose usage line spells it and whose refusal names the target names that exist, and store declares its one operation so an unknown one is a grammar failure like any other undeclared token" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/error.rs" + lines: whole + note: "exit 2 is the pinned usage-error code for the whole binary" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/target.rs" + lines: whole + note: "placement roots owned source globs and the user-owned manifest seam a candidate may never contain" + - promptLines: + - 251 + - 278 + file: "crates/napl/src/decision.rs" + lines: whole + note: "the conflict rule two operations conflict when semantic write sets overlap" + - promptLines: + - 251 + - 278 + file: "crates/napl/src/error.rs" + lines: whole + note: "the outcome vocabulary and its exit-code mapping" + - promptLines: + - 251 + - 278 + file: "crates/napl/src/objects.rs" + lines: whole + note: "P2 footprints read write and derived-projection sets over content identities" + - promptLines: + - 279 + - 328 + file: "crates/napl/src/slice.rs" + lines: whole + note: "identity vocabulary inputKey closure manifest baseStateKey and sliceHash" + - promptLines: + - 279 + - 328 + file: "crates/napl/src/version.rs" + lines: whole + note: "the contract version bumped to four by the capture verbs whose three refusals join the closed table" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "names are serialized as quoted escaped scalars that reparse identically" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "the attribution record path keys on the encoded name never the raw one" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/paths.rs" + lines: whole + note: "the injective reversible name encoding used wherever a name becomes a segment" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/source.rs" + lines: whole + note: "the module-name rule any non-empty single-line scalar and nothing narrower" + - promptLines: + - 390 + - 460 + file: "crates/napl/src/compile.rs" + lines: whole + note: "the compile derivation a pure read that never persists an artifact, and the pending reason it derives from the closed vocabulary by comparing the current inputKey components against the identities the committed record holds, with the re-stamp promise an undrifted pending module carries" + - promptLines: + - 390 + - 460 + file: "crates/napl/src/graph.rs" + lines: whole + note: "the dependency graph and dependency-ordered wave planning" + - promptLines: + - 390 + - 460 + file: "crates/napl/src/paths.rs" + lines: whole + note: "the portable path grammar and its collision-detecting normalization" + - promptLines: + - 461 + - 675 + file: "crates/napl/src/directive.rs" + lines: whole + note: "the test body-directive value grammar diagnostics and deterministic extraction" + - promptLines: + - 521 + - 673 + file: "crates/napl/src/directive.rs" + lines: whole + note: "the docs fence family with its micromark name and attribute grammar, the fence stack where a closing line of N colons closes the innermost fence opened with at most N and where code blocks suspend the stack, and the split that lifts every top-level docs fence out of the semantic body" + - promptLines: + - 521 + - 673 + file: "crates/napl/src/source.rs" + lines: whole + note: "the semantic body every identity derivation consumes is the normalized body with its docs fences removed, the module preamble is the first fence standing before the first heading, and an example fence is validated so a published example cannot rot" + - promptLines: + - 521 + - 673 + file: "crates/napl/src/cli.rs" + lines: whole + note: "a napl invocation written inside an example fence is judged against the live command grammar without running anything" + - promptLines: + - 676 + - 729 + file: "crates/napl/src/surface.rs" + lines: whole + note: "tree-sitter surface extraction sound by construction and loud on malformed input" + - promptLines: + - 730 + - 1286 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "attribution rides the staged record validated before install" + - promptLines: + - 730 + - 1286 + file: "crates/napl/src/authoring.rs" + lines: whole + note: "the internal receipt derivation and the one guarded auto-fix" + - promptLines: + - 730 + - 1286 + file: "crates/napl/src/candidate.rs" + lines: whole + note: "candidate ingestion untrusted bytes become immutable content-addressed blobs" + - promptLines: + - 730 + - 1286 + file: "crates/napl/src/commit.rs" + lines: whole + note: "napl commit the one guarded transaction over what add staged, and bare commit re-derives a record against the head an earlier record in the same invocation moved and retries it once" + - promptLines: + - 730 + - 1286 + file: "crates/napl/src/disk.rs" + lines: whole + note: "the store shell publication head compare-and-set and module-keyed locks, and the seam where the object backend is chosen by observing whether the workspace sits in a git repository whose store branch exists, everything but object storage keeping its disk paths" + - promptLines: + - 730 + - 1286 + file: "crates/napl/src/receipt.rs" + lines: whole + note: "the internal receipt everything the compiler recomputes and refuses to believe" + - promptLines: + - 730 + - 1286 + file: "crates/napl/src/staging.rs" + lines: whole + note: "napl add stages from any of the three sources through one pipeline, the dry run enumerates the validations it ran and names target-language validity as the one it did not judge, and a bad attribution map is answered with the whole map contract" + - promptLines: + - 730 + - 1286 + file: "crates/napl/src/store_view.rs" + lines: whole + note: "the read seam over content-addressed objects a pure function of the bytes" + - promptLines: + - 730 + - 1286 + file: "crates/napl/src/transport.rs" + lines: whole + note: "attribution transports through the byte diff on both ends independently, and a refusal reports each endpoint's verdict separately so the side that transported serves its transported range and only the side needing a human is asked for" + - promptLines: + - 730 + - 1286 + file: "crates/napl/src/trees.rs" + lines: whole + note: "building the candidate tree over the ingested manifest" + - promptLines: + - 730 + - 1286 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "attribution rides the staged record validated before install" + - promptLines: + - 730 + - 1286 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "attribution rides the staged record validated before install" + - promptLines: + - 730 + - 1286 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "attribution rides the staged record validated before install" + - promptLines: + - 730 + - 1286 + file: "crates/napl/src/lsp.rs" + lines: whole + note: "hover states the direction the recorded provenance that the reference is referentially valid and which endpoints read stale" + - promptLines: + - 1287 + - 1485 + file: "crates/napl/src/capture.rs" + lines: whole + note: "napl edit and napl write validate apply capture in that order, and a recorded call's payload carries the reference object verbatim with its transition hash and says the edited file remains working-tree drift until its module commits" + - promptLines: + - 1287 + - 1485 + file: "crates/napl/src/commit.rs" + lines: whole + note: "a pre-proven attribution installs fresh because the verb already proved the reference against the exact bytes it names and the guard runs unchanged" + - promptLines: + - 1287 + - 1485 + file: "crates/napl/src/cost.rs" + lines: whole + note: "a recorded session window is priced lazily at read time never aggregated at record time" + - promptLines: + - 1287 + - 1485 + file: "crates/napl/src/error.rs" + lines: whole + note: "the three refusals join the closed terminal-outcome table with pinned exit codes 27 28 and 29 and none reuses mapping-integrity" + - promptLines: + - 1287 + - 1485 + file: "crates/napl/src/staging.rs" + lines: whole + note: "the staged record carries its attribution mode so a reference-only record commits through the same door add and commit already use" + - promptLines: + - 1486 + - 1917 + file: "crates/napl/src/blame.rs" + lines: whole + note: "derived line ancestry where surviving lines trace through accepted patches, and a path no committed module owns answers tracked false naming the absent baseline rather than an empty ancestry that would read as success" + - promptLines: + - 1486 + - 1917 + file: "crates/napl/src/cost.rs" + lines: whole + note: "cost as a pure calculation over an explicit receipt set where verb-staged session windows resolve against the log at read time and unknown stays unknown" + - promptLines: + - 1486 + - 1917 + file: "crates/napl/src/evidence.rs" + lines: whole + note: "evidence validators keyed by media type including the usage-proxy jsonl log with compiled-in bounds" + - promptLines: + - 1486 + - 1917 + file: "crates/napl/src/lint.rs" + lines: whole + note: "unreachable modules under declared roots where absent roots refuse loudly with the exact declaration rather than an empty findings list, and unconsumed surface entries" + - promptLines: + - 1486 + - 1917 + file: "crates/napl/src/lsp.rs" + lines: whole + note: "the language server hover definition references code lens and the diagnostics it publishes from reference staleness over the mapping" + - promptLines: + - 1486 + - 1917 + file: "crates/napl/src/maintenance.rs" + lines: whole + note: "gc fsck and restore, where reachable means the transitive closure from every named head through every event and every hash edge any reachable object carries, so an edge gc does not understand is a reason to keep and a store healthy before a sweep is healthy after it" + - promptLines: + - 1486 + - 1917 + file: "crates/napl/src/mv.rs" + lines: whole + note: "relocation as a semantic operation re-rooting a module through the guarded path" + - promptLines: + - 1486 + - 1917 + file: "crates/napl/src/porcelain.rs" + lines: whole + note: "the observational porcelain status log show diff blame plus the maintenance verbs, where the plan labels every projection path tool-derived and names one concrete legal first output, show lists a transition's reference entries inline exactly as it lists notes, and store migrate walks the tracked objects into one deterministic commit, refusing outside a repository and refusing a branch that already exists, and reports the object files ready to leave the working tree with the exact removal command" + - promptLines: + - 1486 + - 1917 + file: "crates/napl/src/textdiff.rs" + lines: whole + note: "diff renders drift as a canonical byte-stable patch" + - promptLines: + - 1918 + - 1952 + file: "crates/napl/src/canon.rs" + lines: whole + note: "the determinism contract canonical bytes and byte-pinned serialization" +napl-store event v1 158 +{"content":"d627ee05b2f4f062faaea7f1ae5e4be35cd3d53d720842eed3d6fcdb90c11fd1","parents":["6496be0db3e57121c30fcf9b7e6ef0a0a8b359b0d400321f6f4f56d4517e4cd0"]} +napl-store blob v1 13275 +use std::path::Path; -use crate::error::{error, CompilerError, OutcomeCode, Result}; +use serde_json::{json, Value}; -/// The docs-family directive names the language owns outright. -pub const DOCS_DIRECTIVES: [&str; 4] = ["docs", "example", "note", "warning"]; +use crate::canon::canonical_bytes; +use crate::cli::{CliResult, CommandSpec, FlagSpec, WORKSPACE_FLAG}; +use crate::disk; +use crate::error::Result; +use crate::source::{MINIMAL_PROMPT, SOURCE_EXTENSION}; +use crate::workspace::find_workspace_root; -/// The prefix that opens the extension namespace: carried verbatim, never -/// validated, a renderer's private experiment. -pub const EXTENSION_PREFIX: &str = "x-"; +const WATCH_FLAGS: &[FlagSpec] = &[WORKSPACE_FLAG]; -/// True for a directive name the docs family owns, including the extension -/// namespace. -pub fn is_docs_directive(name: &str) -> bool { - DOCS_DIRECTIVES.contains(&name) || name.starts_with(EXTENSION_PREFIX) +/// The command table the `commands` module contributes to the derived registry: +/// `init` and `watch`. The query surface that used to live here belongs to the +/// `query` module now. +/// +/// `init` declares an empty flag table. Like `git init`, the directory it runs +/// in is the whole story: there is nothing to configure, so anything beyond the +/// shared help flag is a usage error the dispatch loop refuses before `init` +/// runs. +pub fn commands() -> &'static [CommandSpec] { + &[ + CommandSpec { + name: "init", + example: "napl init", + summary: "bootstrap a workspace in this directory, creating the records skeleton", + usage: "napl init", + flags: &[], + passthrough: false, + run: run_init, + }, + CommandSpec { + name: "watch", + example: "napl watch", + summary: "re-derive the compile output whenever a prompt's content changes", + usage: "napl watch [--workspace ]", + flags: WATCH_FLAGS, + passthrough: false, + run: crate::watch::run_watch, + }, + ] } -/// One top-level docs fence, the display payload a renderer is served. +/// `napl init`: bootstrap a workspace by creating the records directory with an +/// empty store skeleton, unless an ancestor workspace already exists. /// -/// `line_start` and `line_end` are PHYSICAL prompt-body lines, 1-based and -/// inclusive: docs fences are cut out of the semantic body, so they have no -/// semantic coordinate of their own. `anchor` is the semantic line the fence -/// follows, which is how a payload finds the section it sits in, and is zero for -/// a fence standing before any semantic line. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DocsFence { - pub directive: String, - pub attrs: Value, - pub line_start: u32, - pub line_end: u32, - pub body: String, - pub anchor: u32, -} - -/// A prompt body split into the semantic bytes every identity derivation -/// consumes and the display payloads that were cut out of them. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BodySplit { - pub semantic: String, - pub docs: Vec, -} - -/// One directive case, with its block range in body-normalized 1-based inclusive -/// lines. `given` is `null` when the block omits it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DirectiveCase { - pub name: String, - pub given: Value, - pub expect: Value, - pub line_start: u32, - pub line_end: u32, -} - -/// A candidate diagnostic, ranked by the precedence the grammar pins. -struct Diag { - rank: u8, - line: u32, - msg: String, +/// Inside an existing workspace it changes nothing and reports +/// `already_initialized`; it never nests a workspace under another and never +/// touches an existing store's bytes. A fresh store has no head and no objects, +/// exactly the state a first `napl compile` expects. +/// +/// Like `git init`, the directory it runs in is the whole story: the success +/// payload names that directory as the workspace root, there is nothing to +/// configure, and a prompt written beside `.napl` is already inside placement. The +/// payload also teaches the first mile through its `nextSteps` field, so the +/// grammar arrives with the workspace instead of being discovered one validation +/// error at a time. +pub(crate) fn run_init(cwd: &Path, _args: &[String]) -> Result { + let dir = cwd.to_path_buf(); + if let Some(existing) = find_workspace_root(&dir) { + return Ok(CliResult { + stdout: canonical_bytes(&json!({ + "changed": Vec::::new(), + "contractVersion": crate::version::CONTRACT_VERSION, + "outcome": "already_initialized", + "workspaceRoot": display_path(&existing), + })), + exit_code: 0, + }); + } + disk::init(&dir)?; + let scaffold = scaffold_agent_glue(&dir)?; + let mut payload = json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "created": [".napl/store"], + "head": Value::Null, + "nextSteps": next_steps(), + "outcome": "initialized", + "scaffold": scaffold, + "workspaceRoot": display_path(&dir), + }); + if let Some(quiet) = scaffold_store_quiet(&dir)? { + payload["gitattributes"] = quiet; + } + Ok(CliResult { + stdout: canonical_bytes(&payload), + exit_code: 0, + }) } -/// The precedence ranks, lowest number wins: -/// 1 unclosed, 2 unknown name, 3 nested, 4 grammar, 5 name, 6 expect, 7 duplicate. -fn worst(diags: Vec) -> Option { - diags - .into_iter() - .min_by(|a, b| (a.rank, a.line).cmp(&(b.rank, b.line))) +/// The store's diff glue, scaffolded under the same never-clobber law the agent +/// glue obeys. +/// +/// The store travels the way the code does now, so there is nothing to install +/// and nothing to teach about pushing: what is left is readability. A +/// `.gitattributes` carrying the two store lines is written only when the +/// workspace has none; an existing one is left exactly as it is and the payload +/// prints the exact lines its owner may add by hand. +fn scaffold_store_quiet(dir: &Path) -> Result> { + if crate::git::enclosing_repository(dir).is_none() { + return Ok(None); + } + let path = dir.join(GITATTRIBUTES_PATH); + let exists = path.exists(); + if !exists { + std::fs::write(&path, GITATTRIBUTES_STORE_BODY).map_err(|e| disk::io(&path, &e))?; + } + Ok(Some(json!({ + "detail": if exists { + "left exactly as it was; add the lines below to it by hand so store files stay quiet in diffs" + } else { + "written because it was absent; it is yours from now on and no later command rewrites it" + }, + "lines": GITATTRIBUTES_STORE_LINES, + "path": GITATTRIBUTES_PATH, + "userOwned": true, + "why": "store objects and packs are generated content: marking them keeps a pull request readable while the store rides along in it", + "written": !exists, + }))) } -fn compile_error(diag: &Diag) -> CompilerError { - error( - OutcomeCode::MappingIntegrity, - format!("body line {}: {}", diag.line, diag.msg), - ) -} +/// The file the store lines live in, and the two lines themselves: one for the +/// loose objects, one for the packs beside them. +const GITATTRIBUTES_PATH: &str = ".gitattributes"; +const GITATTRIBUTES_STORE_LINES: &[&str] = &[ + ".napl/store/objects/** linguist-generated -diff", + ".napl/store/objects/pack/** linguist-generated -diff", +]; -/// A normalized line of the body with its 1-based number, its leading-space -/// indent, and the text with that indent stripped. -struct Line { - no: u32, - indent: usize, - text: String, -} +/// The file init writes when the workspace has none. +const GITATTRIBUTES_STORE_BODY: &str = "\ +.napl/store/objects/** linguist-generated -diff +.napl/store/objects/pack/** linguist-generated -diff +"; -/// Extract every `::: test` case from a normalized body, or the highest -/// precedence compile error naming the exact body line. -pub fn extract_cases(normalized_body: &str) -> Result> { - let lines: Vec<&str> = normalized_body.lines().collect(); - let mut diags: Vec = Vec::new(); - let mut cases: Vec = Vec::new(); - let mut i = 0usize; - while i < lines.len() { - let lineno = (i + 1) as u32; - let line = lines[i]; - let Some(rest) = line.strip_prefix(":::") else { - i += 1; - continue; - }; - if rest.is_empty() { - diags.push(Diag { - rank: 2, - line: lineno, - msg: "closer `:::` has no matching `::: test` opener".into(), - }); - i += 1; - continue; - } - if rest != " test" { - diags.push(Diag { - rank: 2, - line: lineno, - msg: format!("unknown directive opener {line:?}; only `::: test` is defined"), - }); - i += 1; - continue; +/// The agent-teaching glue, written wrangler-style: only what is absent, never +/// over an existing file. +/// +/// Whichever harness opens the repository finds its own native entry file +/// already pointing at the one doctrine, so nothing sniffs an environment and +/// nothing detects a harness: every entry file is written, each tiny and inert, +/// and the result is the same bytes on every machine. A file that already exists +/// is left exactly as it is and the payload prints the one reference line its +/// owner may add by hand, because the alternative — merging into a file the user +/// owns — is a rewrite nobody asked for. +/// +/// Everything written here is user-owned from the moment it exists: ordinary +/// files, never records, never rewritten by any later command. +fn scaffold_agent_glue(dir: &Path) -> Result> { + let mut out: Vec = Vec::new(); + for (path, content, reference) in [ + (AGENT_DOCTRINE_PATH, AGENT_DOCTRINE, AGENT_DOCTRINE_PATH), + (CLAUDE_ENTRY_PATH, CLAUDE_ENTRY, CLAUDE_ENTRY_REFERENCE), + (AGENTS_ENTRY_PATH, AGENTS_ENTRY, AGENTS_ENTRY_REFERENCE), + ] { + let target = dir.join(path); + let exists = target.exists(); + if !exists { + std::fs::write(&target, content).map_err(|e| disk::io(&target, &e))?; } - let opener = lineno; - let mut j = i + 1; - let mut nested: Option = None; - let mut content: Vec<(u32, &str)> = Vec::new(); - let mut closed_at: Option = None; - while j < lines.len() { - let l = lines[j]; - if l == ":::" { - closed_at = Some((j + 1) as u32); - break; - } - if l.starts_with(":::") { - if nested.is_none() { - nested = Some((j + 1) as u32); - } + out.push(json!({ + "detail": if exists { + "left exactly as it was; add the reference line by hand if you want it" } else { - content.push(((j + 1) as u32, l)); - } - j += 1; - } - let Some(closer) = closed_at else { - diags.push(Diag { - rank: 1, - line: opener, - msg: "`::: test` block is never closed by a `:::` line".into(), - }); - break; - }; - if let Some(nested_line) = nested { - diags.push(Diag { - rank: 3, - line: nested_line, - msg: "a `:::` opener is nested inside a `::: test` block".into(), - }); - i = closer as usize; - continue; - } - match parse_case(&content, opener, closer) { - Ok(case) => cases.push(case), - Err(diag) => diags.push(diag), - } - i = closer as usize; - } - if let Some(diag) = worst(diags) { - return Err(compile_error(&diag)); + "written because it was absent; it is yours from now on and no later command rewrites it" + }, + "path": path, + "referenceLine": reference, + "userOwned": true, + "written": !exists, + })); } - Ok(cases) + Ok(out) } -/// Extract the cases and prove no case name collides, across the frontmatter -/// `tests:` names and the body cases, after NFC normalization and whitespace -/// trim. A duplicate is the lowest precedence compile error. -pub fn extract_validated( - normalized_body: &str, - frontmatter_names: &[String], -) -> Result> { - let cases = extract_cases(normalized_body)?; - let mut seen: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for name in frontmatter_names { - seen.entry(normalize_case_name(name)).or_insert(0); - } - for case in &cases { - let key = normalize_case_name(&case.name); - if seen.contains_key(&key) { - return Err(compile_error(&Diag { - rank: 7, - line: case.line_start, - msg: format!("duplicate case name {:?} within the module", case.name), - })); - } - seen.insert(key, case.line_start); - } - Ok(cases) -} +/// The one doctrine file, and the two entry files that point at it. +const AGENT_DOCTRINE_PATH: &str = "napl-agents.md"; +const CLAUDE_ENTRY_PATH: &str = "CLAUDE.md"; +const AGENTS_ENTRY_PATH: &str = "AGENTS.md"; -/// The comparison key for a case name: NFC normalized and whitespace trimmed. -pub fn normalize_case_name(name: &str) -> String { - name.trim().nfc().collect::() -} +/// The line a user adds by hand when their entry file already exists. +const CLAUDE_ENTRY_REFERENCE: &str = "@napl-agents.md"; +const AGENTS_ENTRY_REFERENCE: &str = "Read napl-agents.md before working in this repository."; -/// Parse a single inline value in the directive value grammar (a scalar or a -/// flow mapping or sequence), returning `None` when it is not well-formed. Used -/// to read the `given`/`expect` flow values of frontmatter `tests:` cases with -/// the same grammar the body directives use. -pub fn parse_inline_value(text: &str) -> Option { - let trimmed = text.trim(); - if trimmed.is_empty() { - return None; - } - parse_flow_full(trimmed, 0).ok() -} +const CLAUDE_ENTRY: &str = "@napl-agents.md\n"; -fn parse_case(content: &[(u32, &str)], opener: u32, closer: u32) -> std::result::Result { - let mut ls: Vec = Vec::new(); - for (no, text) in content { - if text.trim().is_empty() { - continue; - } - let indent = leading_spaces(text, *no)?; - ls.push(Line { - no: *no, - indent, - text: text[indent..].to_string(), - }); - } - if ls.is_empty() { - return Err(Diag { - rank: 4, - line: opener, - msg: "`::: test` block has no case mapping".into(), - }); - } - let value = parse_block(&ls, 0)?; - let obj = value.as_object().ok_or(Diag { - rank: 4, - line: opener, - msg: "case content is not a mapping".into(), - })?; - let mut diags: Vec = Vec::new(); - for key in obj.keys() { - if key != "name" && key != "given" && key != "expect" { - diags.push(Diag { - rank: 4, - line: opener, - msg: format!("case mapping has an unknown key {key:?}"), - }); - } - } - let name = match obj.get("name") { - Some(Value::String(s)) if !s.trim().is_empty() => Some(s.clone()), - Some(_) => { - diags.push(Diag { - rank: 5, - line: opener, - msg: "case `name` must be a non-empty single-line string".into(), - }); - None - } - None => { - diags.push(Diag { - rank: 5, - line: opener, - msg: "case is missing its `name`".into(), - }); - None - } - }; - let expect = match obj.get("expect") { - Some(v @ Value::Object(_)) => Some(v.clone()), - Some(_) => { - diags.push(Diag { - rank: 4, - line: opener, - msg: "case `expect` must be a mapping".into(), - }); - None - } - None => { - diags.push(Diag { - rank: 6, - line: opener, - msg: "case is missing its `expect`".into(), - }); - None - } - }; - let given = match obj.get("given") { - Some(v @ Value::Object(_)) => v.clone(), - None => Value::Null, - Some(_) => { - diags.push(Diag { - rank: 4, - line: opener, - msg: "case `given` must be a mapping".into(), - }); - Value::Null - } - }; - if let Some(diag) = worst(diags) { - return Err(diag); - } - Ok(DirectiveCase { - name: name.expect("name is present when no diagnostic outranks it"), - given, - expect: expect.expect("expect is present when no diagnostic outranks it"), - line_start: opener, - line_end: closer, - }) -} +const AGENTS_ENTRY: &str = + "Read napl-agents.md before working in this repository.\n"; -fn leading_spaces(text: &str, no: u32) -> std::result::Result { - let mut count = 0usize; - for ch in text.chars() { - match ch { - ' ' => count += 1, - '\t' => { - return Err(Diag { - rank: 4, - line: no, - msg: "indentation uses a tab; the grammar indents by two spaces".into(), - }) - } - _ => break, - } - } - Ok(count) -} +/// The working doctrine for an LLM session in a napl workspace. +/// +/// Compiled in as a constant, so the bytes are the same on every machine and +/// nothing about the environment can change them. +const AGENT_DOCTRINE: &str = "\ +# Working in this napl workspace -fn parse_block(lines: &[Line], base: usize) -> std::result::Result { - let first = &lines[0]; - if first.indent != base { - return Err(Diag { - rank: 4, - line: first.no, - msg: "unexpected indentation".into(), - }); - } - if first.text == "-" || first.text.starts_with("- ") { - parse_seq(lines, base) - } else { - parse_map(lines, base) - } -} +This repository keeps its specifications in `.napl` prompt files and its records +in `.napl/`. The napl toolchain is the only writer of `.napl/`. Everything below +is how a session works here. -fn parse_map(lines: &[Line], base: usize) -> std::result::Result { - let mut map = Map::new(); - let mut i = 0usize; - while i < lines.len() { - let line = &lines[i]; - if line.indent != base { - return Err(Diag { - rank: 4, - line: line.no, - msg: "unexpected indentation in mapping".into(), - }); - } - let colon = line.text.find(':').ok_or(Diag { - rank: 4, - line: line.no, - msg: "mapping entry has no `:`".into(), - })?; - let key = line.text[..colon].trim().to_string(); - if key.is_empty() { - return Err(Diag { - rank: 4, - line: line.no, - msg: "mapping key is empty".into(), - }); - } - if map.contains_key(&key) { - return Err(Diag { - rank: 4, - line: line.no, - msg: format!("duplicate mapping key {key:?}"), - }); - } - let rest = line.text[colon + 1..].trim(); - if rest.is_empty() { - let mut k = i + 1; - while k < lines.len() && lines[k].indent > base { - k += 1; - } - let sub = &lines[i + 1..k]; - if sub.is_empty() { - return Err(Diag { - rank: 4, - line: line.no, - msg: format!("key {key:?} has no value"), - }); - } - if sub[0].indent != base + 2 { - return Err(Diag { - rank: 4, - line: sub[0].no, - msg: "a block value must indent by exactly two spaces".into(), - }); - } - map.insert(key, parse_block(sub, base + 2)?); - i = k; - } else { - map.insert(key, parse_flow_full(rest, line.no)?); - i += 1; - } - } - Ok(Value::Object(map)) -} +## Edit with your native tools -fn parse_seq(lines: &[Line], base: usize) -> std::result::Result { - let mut arr: Vec = Vec::new(); - let mut i = 0usize; - while i < lines.len() { - let line = &lines[i]; - if line.indent != base { - return Err(Diag { - rank: 4, - line: line.no, - msg: "unexpected indentation in sequence".into(), - }); - } - if line.text != "-" && !line.text.starts_with("- ") { - return Err(Diag { - rank: 4, - line: line.no, - msg: "sequence entry does not begin with `- `".into(), - }); - } - let rest = if line.text == "-" { - "" - } else { - line.text[2..].trim() - }; - if rest.is_empty() { - let mut k = i + 1; - while k < lines.len() && lines[k].indent > base { - k += 1; - } - let sub = &lines[i + 1..k]; - if sub.is_empty() { - return Err(Diag { - rank: 4, - line: line.no, - msg: "sequence entry has no value".into(), - }); - } - if sub[0].indent != base + 2 { - return Err(Diag { - rank: 4, - line: sub[0].no, - msg: "a block value must indent by exactly two spaces".into(), - }); - } - arr.push(parse_block(sub, base + 2)?); - i = k; - } else { - arr.push(parse_flow_full(rest, line.no)?); - i += 1; - } - } - Ok(Value::Array(arr)) -} +Open files and edit them the way you normally would. A hand edit to a generated +file is legal. So is a hand edit to a prompt. Nothing polices either one. -fn parse_flow_full(text: &str, no: u32) -> std::result::Result { - let chars: Vec = text.chars().collect(); - let (value, pos) = parse_flow(&chars, 0, no)?; - let mut end = pos; - while end < chars.len() && chars[end] == ' ' { - end += 1; - } - if end != chars.len() { - return Err(Diag { - rank: 4, - line: no, - msg: "trailing content after a value".into(), - }); - } - Ok(value) -} +## Drift is legal, and the diagnostics mark it -fn parse_flow(chars: &[char], mut pos: usize, no: u32) -> std::result::Result<(Value, usize), Diag> { - while pos < chars.len() && chars[pos] == ' ' { - pos += 1; - } - if pos >= chars.len() { - return Err(Diag { - rank: 4, - line: no, - msg: "expected a value".into(), - }); - } - match chars[pos] { - '{' => parse_flow_map(chars, pos + 1, no), - '[' => parse_flow_seq(chars, pos + 1, no), - '"' => parse_quoted(chars, pos, no), - _ => parse_plain(chars, pos, no), - } -} +When a generated file no longer matches what was committed, that is drift. Drift +is a state, not a failure: `napl status` reports it per module, `napl diff` +renders it as a patch, and the language server publishes a warning on each end of +every stale reference. Nothing repairs a reference automatically and nothing +blocks you for having one. -fn parse_flow_map(chars: &[char], mut pos: usize, no: u32) -> std::result::Result<(Value, usize), Diag> { - let mut map = Map::new(); - loop { - while pos < chars.len() && chars[pos] == ' ' { - pos += 1; - } - if pos >= chars.len() { - return Err(Diag { rank: 4, line: no, msg: "unterminated flow mapping".into() }); - } - if chars[pos] == '}' { - return Ok((Value::Object(map), pos + 1)); - } - let (key, next) = parse_flow_key(chars, pos, no)?; - pos = next; - while pos < chars.len() && chars[pos] == ' ' { - pos += 1; - } - if pos >= chars.len() || chars[pos] != ':' { - return Err(Diag { rank: 4, line: no, msg: "flow mapping entry has no `:`".into() }); - } - pos += 1; - let (value, next) = parse_flow(chars, pos, no)?; - pos = next; - if map.contains_key(&key) { - return Err(Diag { rank: 4, line: no, msg: format!("duplicate mapping key {key:?}") }); - } - map.insert(key, value); - while pos < chars.len() && chars[pos] == ' ' { - pos += 1; - } - if pos >= chars.len() { - return Err(Diag { rank: 4, line: no, msg: "unterminated flow mapping".into() }); - } - match chars[pos] { - ',' => pos += 1, - '}' => return Ok((Value::Object(map), pos + 1)), - _ => return Err(Diag { rank: 4, line: no, msg: "expected `,` or `}` in flow mapping".into() }), - } - } -} +## The loop -fn parse_flow_seq(chars: &[char], mut pos: usize, no: u32) -> std::result::Result<(Value, usize), Diag> { - let mut arr: Vec = Vec::new(); - loop { - while pos < chars.len() && chars[pos] == ' ' { - pos += 1; - } - if pos >= chars.len() { - return Err(Diag { rank: 4, line: no, msg: "unterminated flow sequence".into() }); - } - if chars[pos] == ']' { - return Ok((Value::Array(arr), pos + 1)); - } - let (value, next) = parse_flow(chars, pos, no)?; - arr.push(value); - pos = next; - while pos < chars.len() && chars[pos] == ' ' { - pos += 1; - } - if pos >= chars.len() { - return Err(Diag { rank: 4, line: no, msg: "unterminated flow sequence".into() }); - } - match chars[pos] { - ',' => pos += 1, - ']' => return Ok((Value::Array(arr), pos + 1)), - _ => return Err(Diag { rank: 4, line: no, msg: "expected `,` or `]` in flow sequence".into() }), - } - } -} + napl status what needs doing, per module + napl status --module that module's plan: owned globs, placement root, + projections, receipt requirements, wave position + napl add --module stage the module's owned tree from the working tree + napl add [...] stage named working-tree files under their owners + napl add --check ... run the whole pipeline and write nothing + napl commit install every staged record, in wave order -fn parse_flow_key(chars: &[char], pos: usize, no: u32) -> std::result::Result<(String, usize), Diag> { - if pos < chars.len() && chars[pos] == '"' { - let (value, next) = parse_quoted(chars, pos, no)?; - match value { - Value::String(s) => Ok((s, next)), - _ => Err(Diag { rank: 4, line: no, msg: "flow mapping key is not a string".into() }), - } - } else { - let mut end = pos; - while end < chars.len() && !matches!(chars[end], ':' | ',' | '{' | '}' | '[' | ']' | '#') { - end += 1; - } - let key: String = chars[pos..end].iter().collect(); - let key = key.trim().to_string(); - if key.is_empty() { - return Err(Diag { rank: 4, line: no, msg: "flow mapping key is empty".into() }); - } - Ok((key, end)) - } -} +`add` stages and `commit` installs, exactly as in git. Attribution transports +itself across a diff; you supply a map only for the claims the diff cannot prove, +and a claim may be spelled `whole` so no line count is ever transcribed by hand. -fn parse_quoted(chars: &[char], pos: usize, no: u32) -> std::result::Result<(Value, usize), Diag> { - let mut end = pos + 1; - while end < chars.len() { - match chars[end] { - '\\' => end += 2, - '"' => { - end += 1; - let raw: String = chars[pos..end].iter().collect(); - let decoded: String = serde_json::from_str(&raw).map_err(|_| Diag { - rank: 4, - line: no, - msg: "string is not valid JSON with JSON escapes".into(), - })?; - return Ok((Value::String(decoded), end)); - } - _ => end += 1, - } - } - Err(Diag { rank: 4, line: no, msg: "unterminated string".into() }) -} +## The query surface -fn parse_plain(chars: &[char], pos: usize, no: u32) -> std::result::Result<(Value, usize), Diag> { - let mut end = pos; - while end < chars.len() && !matches!(chars[end], ',' | '{' | '}' | '[' | ']' | ':' | '#') { - end += 1; - } - let token: String = chars[pos..end].iter().collect(); - let token = token.trim().to_string(); - if token.is_empty() { - return Err(Diag { rank: 4, line: no, msg: "empty scalar".into() }); - } - let value = classify_scalar(&token); - Ok((value, end)) -} + napl q '' the whole canonical document as JSON + napl q '.' --working-tree the same, with prompt facts read from disk + napl q '' | napl prompt + materialize the specification prose for a selection + napl check validate a prompt before anything stages -fn classify_scalar(token: &str) -> Value { - match token { - "true" => Value::Bool(true), - "false" => Value::Bool(false), - "null" => Value::Null, - _ => match serde_json::from_str::(token) { - Ok(v) if v.is_number() => v, - _ => Value::String(token.to_string()), - }, - } -} +Ask the toolchain rather than reading records by hand. `napl q` is jq, and any +command's JSON pipes back into it: `napl status | napl q '.pending' -`. -/// One open fence on the parser's stack. -struct Frame { - name: String, - attrs: Value, - count: usize, - line: u32, -} +## The capture verbs are deliberate acts -/// Split a normalized prompt body into its semantic bytes and its docs payloads. -/// -/// Docs fences are display content, not contract: the semantic body is the body -/// with every top-level docs-family fence removed, and that is the byte string -/// inputKey, attribution prompt hashes, and every freshness derivation consume. A -/// prompt whose only change is inside a docs fence therefore produces the same -/// semantic body, the same key, and the same endpoints. +`napl edit` and `napl write` apply a change and, when given `--ref`, record a +reference through the same guarded door `commit` uses. They never invoke a model, +never decide placement, and never repair anything. Use them when you mean to +record a reference; use your ordinary editor otherwise. + +## What is not yours + +`.napl/` is the toolchain's. Every `Cargo.toml` and package manifest is the +user's, edited by hand, never owned by a module. A crate's shared entry file is a +derived projection owned by no module: the tool computes it, and a candidate that +carries it is rejected. +"; + +/// The first mile, carried in the success payload so a cold user never reverse +/// engineers the grammar one validation error at a time. /// -/// Fence lines are unambiguous by shape: an opening line names its directive, a -/// closing line is bare colons. A closing line of N colons closes the innermost -/// open fence whose opening count is at most N, and fences opened above it close -/// with it. Code blocks suspend the stack, both fenced and indented, so a block -/// may show `:::` lines freely. Only top-level fences carry payloads; an inner -/// fence rides verbatim inside the outer body, unparsed. +/// Three facts, and nothing a reader has to infer: a complete minimal prompt, +/// which is the reader's own [`MINIMAL_PROMPT`] and therefore passes `napl check` +/// by construction rather than by resemblance; how discovery finds a prompt, +/// which is the `.napl` extension and not a registry or a configuration file; and +/// the exact commands from writing that prompt through `add` and `commit`. /// -/// An unclosed fence is refused naming its opening line. Where that strictness -/// trips differs by fence kind: a heading inside an open example fence refuses on -/// sight, while inside an open prose fence, docs, note, or warning, it is prose -/// and the refusal waits for the end of the body, which is where a prose fence -/// provably cannot close. -pub fn split_docs(normalized_body: &str) -> Result { - split_body(normalized_body).map_err(|diag| compile_error(&diag)) +/// The sequence ends with one forward pointer rather than a command: reachability +/// analysis needs a `roots` declaration before it can judge anything, and naming +/// that here makes lint's later refusal a reminder instead of a first encounter. +fn next_steps() -> Value { + json!({ + "discovery": format!( + "discovery walks the workspace for files whose extension is {:?}, so a prompt is found by its name and nothing registers it", + SOURCE_EXTENSION + ), + "minimalPrompt": MINIMAL_PROMPT, + "promptExtension": format!(".{SOURCE_EXTENSION}"), + "sequence": [ + { + "command": "napl write example.napl < the-prompt-above", + "step": "write the prompt beside .napl, named with the .napl extension", + }, + { + "command": "napl check example.napl", + "step": "confirm the prompt parses under the same reader add uses", + }, + { + "command": "napl status --module example", + "step": "read the module's plan: owned globs, placement root, receipt requirements", + }, + { + "command": "napl add --module example", + "step": "stage the generated code as the module's output; a first add needs no attribution map", + }, + { + "command": "napl commit", + "step": "install what add staged, through the one guarded transaction", + }, + { + "command": "napl lint rust", + "forward": true, + "step": "later: lint needs a `roots` declaration in a prompt's frontmatter before it can judge reachability, and refuses without one", + }, + ], + }) } -fn split_body(body: &str) -> std::result::Result { - let lines: Vec<&str> = body.lines().collect(); - let mut removed: Vec = vec![false; lines.len()]; - let mut docs: Vec = Vec::new(); - let mut stack: Vec = Vec::new(); - let mut code: Option<(char, usize)> = None; - let mut i = 0usize; - while i < lines.len() { - let raw = lines[i]; - let lineno = (i + 1) as u32; - let indent = raw.chars().take_while(|c| *c == ' ').count(); - let trimmed = &raw[indent.min(raw.len())..]; - if let Some((open_char, open_len)) = code { - if let Some((fence_char, fence_len, info)) = code_fence(trimmed) { - if fence_char == open_char && fence_len >= open_len && info.is_empty() { - code = None; - } - } - i += 1; - continue; - } - if indent >= 4 { - i += 1; - continue; - } - if let Some((fence_char, fence_len, _)) = code_fence(trimmed) { - code = Some((fence_char, fence_len)); - i += 1; - continue; - } - if let Some(frame) = stack.last() { - if section_heading(trimmed) && !holds_prose(&frame.name) { - return Err(Diag { - rank: 1, - line: frame.line, - msg: format!( - "the `{}` fence opened here is never closed before the section ends", - frame.name - ), - }); - } - } - let Some((count, rest)) = colon_run(trimmed) else { - i += 1; - continue; - }; - if rest.trim().is_empty() { - let Some(matched) = stack.iter().rposition(|frame| frame.count <= count) else { - return Err(Diag { - rank: 2, - line: lineno, - msg: "closer `:::` has no matching directive opener".into(), - }); - }; - let closing: Vec = stack.split_off(matched); - let frame = closing - .into_iter() - .next() - .expect("the split always yields the matched frame"); - if stack.is_empty() && is_docs_directive(&frame.name) { - for line in removed.iter_mut().take(i + 1).skip(frame.line as usize - 1) { - *line = true; - } - let opener = frame.line as usize - 1; - let standalone = opener == 0 - || removed[opener - 1] - || lines[opener - 1].trim().is_empty(); - if standalone { - let mut after = i + 1; - while after < lines.len() && lines[after].trim().is_empty() { - removed[after] = true; - after += 1; - } - } - docs.push(DocsFence { - directive: frame.name, - attrs: frame.attrs, - line_start: frame.line, - line_end: lineno, - body: fence_body(&lines[frame.line as usize..i]), - anchor: 0, - }); - } - i += 1; - continue; - } - let top = stack.is_empty(); - if top && raw == "::: test" { - let mut j = i + 1; - while j < lines.len() && lines[j] != ":::" { - j += 1; - } - i = if j < lines.len() { j + 1 } else { lines.len() }; - continue; - } - let parsed = parse_opener(rest, lineno); - if !top { - let Ok((name, _)) = parsed else { - i += 1; - continue; - }; - let open = stack.last().expect("a line below the top always has an open frame"); - return Err(Diag { - rank: 3, - line: lineno, - msg: format!( - "the `{name}` fence opened here nests inside the `{}` fence opened at body line {}, and directive fences do not nest", - open.name, open.line - ), - }); - } - let (name, attrs) = parsed?; - if !is_docs_directive(&name) { - return Err(Diag { - rank: 2, - line: lineno, - msg: format!( - "unknown directive opener {raw:?}; only `::: test` and the docs family are defined" - ), - }); - } - stack.push(Frame { name, attrs, count, line: lineno }); - i += 1; - } - if let Some(frame) = stack.last() { - return Err(Diag { - rank: 1, - line: frame.line, - msg: format!( - "the `{}` fence opened here is never closed before the section ends", - frame.name - ), - }); - } - let kept: Vec<&str> = lines - .iter() - .enumerate() - .filter(|(index, _)| !removed[*index]) - .map(|(_, line)| *line) - .collect(); - let semantic = crate::source::normalize_body(&kept.join("\n")); - let mut seen = 0u32; - let mut anchors: Vec = Vec::with_capacity(lines.len() + 1); - anchors.push(0); - for (index, _) in lines.iter().enumerate() { - if !removed[index] { - seen += 1; - } - anchors.push(seen); - } - let semantic_lines = semantic.lines().count() as u32; - for fence in &mut docs { - fence.anchor = anchors[fence.line_start as usize - 1].min(semantic_lines); - } - Ok(BodySplit { semantic, docs }) +/// The workspace root as the payload names it: the directory itself, with +/// separators normalized so the bytes are stable across platforms. +fn display_path(dir: &Path) -> String { + dir.to_string_lossy().replace('\\', "/") } +napl-store receipt v1 815 +{"baseStateKey":"87384fb9465ce6341cb179768f2b35a68b7a4f7ab1ff58a7e5cc220b82a22793","baseStoreSnapshot":"b8cc0db620cad03ea09682909ece1143c84d2837bda22513e02dd149b3b202ed","baseWorkspaceHash":"bfbe44f31259f19b8e6718707fd63473e44124a4abc7fafbfda89dd7b368e596","candidateTreeHash":"1bef0976e67d9c88366a636f94d1f838e7eed135831902d3b0f82cd98ac99f4b","contractVersion":4,"inputKey":"b7a2ef2587dc9f5032474eebcb5b828574d7bcdddd1dca697c21d4a06af68121","module":"commands","outputManifest":[{"hash":"c1a6ba3fce9918dc77114ee7fa8a2a5ed211ad39e3ffffbb76d924ca7f89e808","path":"crates/napl/src/init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","path":"crates/napl/src/watch.rs"}],"receiptVersion":1,"sliceHash":"0d1cb6037feb23824d1506966006ccfafbeb8e35d21a710db01d71604cdb6f9f","target":"rust"} +napl-store blob v1 640 +[package] +name = "napl" +version = "0.1.0" +edition = "2021" -/// The verbatim body of one fence: every line between the opener and the closer, -/// inner fences included exactly as written. -fn fence_body(lines: &[&str]) -> String { - if lines.is_empty() { - return String::new(); - } - let mut out = lines.join("\n"); - out.push('\n'); - out -} +[lib] +name = "napl" +path = "src/lib.rs" -/// A markdown code fence: its character, its length, and its info string. -fn code_fence(text: &str) -> Option<(char, usize, &str)> { - let fence_char = text.chars().next().filter(|c| *c == '`' || *c == '~')?; - let len = text.chars().take_while(|c| *c == fence_char).count(); - if len < 3 { - return None; - } - let info = text[len..].trim(); - if fence_char == '`' && info.contains('`') { - return None; - } - Some((fence_char, len, info)) -} +[[bin]] +name = "napl" +path = "src/main.rs" -/// True for the heading that ends a section, which is where an open fence that -/// holds no prose has run out of room. -fn section_heading(text: &str) -> bool { - text.starts_with("# ") || text.starts_with("## ") -} +[dependencies] +serde_json = "1" +sha2 = "0.10" +tree-sitter = "0.25" +tree-sitter-rust = "0.24" +tree-sitter-typescript = "0.23" -/// True for the prose fences, docs, note, and warning, the family whose whole -/// purpose is prose, and where a heading is therefore a heading rather than the -/// proof that a fence was left open. +# The proven store, hand-ported and living at the repository root — outside this +# derived tree, and not a NAPL module, which is why it does not appear in the +# prompt's `deps:` frontmatter. Declared as its own table so the dependency form +# says plainly that this is an out-of-tree crate, not a sibling module. +[dependencies.napl-store] +path = "../../../napl-store" +napl-store snapshot v1 781 +{"contractVersion":4,"event":"57e89f67d8f577c3d02c04f0e83ae79cad645b88ec9c2be490c8515a29c1a778","states":[{"module":"agent","state":"651686af1f5c9f3aad425d1ca7f0de325e4dc362cb59d9a71dc7d75f3216972c","target":"rust"},{"module":"commands","state":"7f362809b6d0f959bc8479adebdde987a718107d3e70636e879e44d0d4c59bb5","target":"rust"},{"module":"napl","state":"338c9b575a24883622bf3cb1e3276c24a26c31ef34a5097927420523727b6d26","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"79b5813169ed5adeb10cc35fc52c22e390bdef3e22fb2a3dd0b3a300ec844dc4","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} +napl-store transition v1 632 +{"baseStateKey":"0d9de5138a0e51434c732db16fde21ad6ecfb1f4b0f8fb2789c8b538784b1bda","contractVersion":4,"derivedProjections":[],"inputKey":"9206162a79eefbb9bbbad00daffa25e4dcbca3fb7db9b908b65fa2829a272f7e","installPaths":[],"kind":"docs-only","module":"query","newState":"37fc6230a31e6efbc940e618a1ccc6af767b5660a9961c987cc04018ac20076a","prompt":"5b749ec1e5f1229f41a4a69cc0c4529c13418d4cac1d331ef009d70086d55089","readSet":["prompt:68be37dcd4b57fdfbfcd0ac160bd5cf63e4066585d837d20a6b4fe3f4d380de9"],"surface":"326826be3fd8aaa84d4869493b7e41f886a07bea8960df79b61c87f557f4cbd0","target":"rust","writeSet":["module-state:rust/query"]} +napl-store tree v1 129 +{"entries":[{"hash":"6a5d9b61ffdaed3a3114f65cbf05beae1f376860b585901c664ada68ecbf392f","kind":"dir","mode":16384,"name":"src"}]} +napl-store blob v1 3871 +module: "usage_proxy" +target: "rust" +stale: false +entries: + - promptLines: + - 1 + - 26 + law: true + note: "one user-facing verb and one capture-only sidecar behind it; the crate is runtime I/O by nature and its telemetry is evidence, never identity" + - promptLines: + - 27 + - 62 + file: "crates/napl-usage-proxy/src/agent.rs" + lines: + - 1 + - 208 + note: "napl agent resolves the workspace root, reuses or starts the one sidecar, injects the base url the session id and the session header, inherits the terminal whole, returns the child exit code, and stops the sidecar on --stop" + - promptLines: + - 27 + - 62 + file: "crates/napl-usage-proxy/src/state.rs" + lines: + - 1 + - 158 + note: "liveness and identity live in one canonical state file written whole and claimed atomically, so racing wrappers converge on one survivor and a dead pid is replaced rather than trusted" + - promptLines: + - 27 + - 62 + file: "crates/napl-usage-proxy/src/session.rs" + lines: + - 1 + - 23 + note: "a fresh session id per run, the window an edit is later joined to its calls by" + - promptLines: + - 27 + - 62 + file: "crates/napl-usage-proxy/src/args.rs" + lines: + - 1 + - 28 + note: "the argv grammar of the verb: flags, the -- separator, and the empty command that is a usage error" + - promptLines: + - 27 + - 62 + file: "crates/napl-usage-proxy/src/main.rs" + lines: + - 1 + - 36 + note: "the binary entry: the agent verb and the sidecar it starts are one executable, so napl agent reaches this crate through its own command name" + - promptLines: + - 63 + - 81 + file: "crates/napl-usage-proxy/src/proxy.rs" + lines: + - 215 + - 292 + note: "verbatim passthrough to the one upstream origin, session header stripped and hop-by-hop headers dropped, response streamed back as received, capture never gating the call" + - promptLines: + - 63 + - 81 + file: "crates/napl-usage-proxy/src/upstream.rs" + lines: + - 1 + - 33 + note: "one upstream origin over rustls, the request path and query carried across unchanged" + - promptLines: + - 82 + - 125 + file: "crates/napl-usage-proxy/src/capture.rs" + lines: + - 1 + - 132 + note: "usage read from a top-level JSON body or merged from the message_start and message_delta events of an SSE stream, every other event ignored, failure to parse leaving usage unread" + - promptLines: + - 82 + - 125 + file: "crates/napl-usage-proxy/src/logline.rs" + lines: + - 1 + - 112 + note: "one canonical line per completed call appended whole under a gate, absent fields reported unavailable and never zero, the log append-only" + - promptLines: + - 82 + - 125 + file: "crates/napl-usage-proxy/src/canonical.rs" + lines: + - 1 + - 59 + note: "canonical JSON with recursively sorted keys, so a log line and a state file are the same bytes for the same facts" + - promptLines: + - 82 + - 125 + file: "crates/napl-usage-proxy/src/proxy.rs" + lines: + - 23 + - 122 + note: "the response body tees into the capture as it streams and settles exactly one log line when the call completes, carrying the stripped session header and never the auth material or the bodies" + - promptLines: + - 126 + - 135 + file: "crates/napl-usage-proxy/src/proxy.rs" + lines: + - 123 + - 214 + note: "the sidecar exits nonzero only on startup failure, a bind failure or an unusable log path, and never because of a bad request" + - promptLines: + - 136 + - 206 + law: true + note: "the twelve pinned cases are live tests in the hand-maintained crates/napl-usage-proxy/tests seam, covering the wrapper, the cold-start race, passthrough, capture, secrecy, and concurrent appends" +napl-store surface v1 50276 +{"contractVersion":3,"items":[{"kind":"struct","path":"attribution::Attribution","signature":"pub struct Attribution"},{"kind":"field","path":"attribution::Attribution.entries","signature":"pub entries : Vec < Entry >"},{"kind":"field","path":"attribution::Attribution.module","signature":"pub module : String"},{"kind":"field","path":"attribution::Attribution.stale","signature":"pub stale : bool"},{"kind":"field","path":"attribution::Attribution.target","signature":"pub target : String"},{"kind":"struct","path":"attribution::Entry","signature":"pub struct Entry"},{"kind":"field","path":"attribution::Entry.file","signature":"pub file : Option < String >"},{"kind":"field","path":"attribution::Entry.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"attribution::Entry.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"attribution::Entry.note","signature":"pub note : String"},{"kind":"field","path":"attribution::Entry.prompt_end","signature":"pub prompt_end : u32"},{"kind":"field","path":"attribution::Entry.prompt_start","signature":"pub prompt_start : u32"},{"kind":"method","path":"attribution::Entry::is_law","signature":"pub fn is_law ( & self ) -> bool"},{"kind":"fn","path":"attribution::parse","signature":"pub fn parse ( text : & str ) -> Option < Attribution >"},{"kind":"fn","path":"attribution::record_hash","signature":"pub fn record_hash ( attribution : & Attribution ) -> String"},{"kind":"fn","path":"attribution::remap_prefix","signature":"pub fn remap_prefix ( attribution : & mut Attribution , old_root : & str , dest : & str )"},{"kind":"fn","path":"attribution::to_yaml","signature":"pub fn to_yaml ( attribution : & Attribution ) -> Vec < u8 >"},{"kind":"fn","path":"attribution::validate","signature":"pub fn validate ( target : & TargetDef , root : & str , module : & str , candidate_files : & BTreeMap < String , Vec < u8 > > , prompt_body_lines : u32 , attribution : & Attribution ) -> Result < ( ) >"},{"kind":"fn","path":"attribution::view_path","signature":"pub fn view_path ( module : & str ) -> String"},{"kind":"fn","path":"authoring::apply_fix","signature":"pub fn apply_fix ( workspace : & Path , target : & str , finding : & Finding ) -> Result < CommitReport >"},{"kind":"fn","path":"authoring::draft_receipt","signature":"pub fn draft_receipt ( workspace : & Path , target_name : & str , module : & str , candidate_dir : & Path , usage_path : Option < & Path > ) -> Result < Value >"},{"kind":"struct","path":"blame::BlameLine","signature":"pub struct BlameLine"},{"kind":"field","path":"blame::BlameLine.line","signature":"pub line : usize"},{"kind":"field","path":"blame::BlameLine.text","signature":"pub text : String"},{"kind":"field","path":"blame::BlameLine.transition","signature":"pub transition : String"},{"kind":"struct","path":"blame::Revision","signature":"pub struct Revision"},{"kind":"field","path":"blame::Revision.content","signature":"pub content : String"},{"kind":"field","path":"blame::Revision.transition","signature":"pub transition : String"},{"kind":"fn","path":"blame::blame","signature":"pub fn blame ( revisions : & [ Revision ] ) -> Vec < BlameLine >"},{"kind":"fn","path":"blame::blame_json","signature":"pub fn blame_json ( path : & str , lines : & [ BlameLine ] ) -> Value"},{"kind":"struct","path":"candidate::Candidate","signature":"pub struct Candidate"},{"kind":"field","path":"candidate::Candidate.files","signature":"pub files : BTreeMap < String , Vec < u8 > >"},{"kind":"field","path":"candidate::Candidate.manifest","signature":"pub manifest : BTreeMap < String , String >"},{"kind":"field","path":"candidate::Candidate.tree_hash","signature":"pub tree_hash : String"},{"kind":"field","path":"candidate::Candidate.tree_objects","signature":"pub tree_objects : BTreeMap < String , Tree >"},{"kind":"enum","path":"candidate::EntryKind","signature":"pub enum EntryKind { File , Link , Device }"},{"kind":"struct","path":"candidate::Limits","signature":"pub struct Limits"},{"kind":"field","path":"candidate::Limits.max_entries","signature":"pub max_entries : usize"},{"kind":"field","path":"candidate::Limits.max_entry_bytes","signature":"pub max_entry_bytes : usize"},{"kind":"field","path":"candidate::Limits.max_total_bytes","signature":"pub max_total_bytes : usize"},{"kind":"struct","path":"candidate::ScannedEntry","signature":"pub struct ScannedEntry"},{"kind":"field","path":"candidate::ScannedEntry.bytes","signature":"pub bytes : Vec < u8 >"},{"kind":"field","path":"candidate::ScannedEntry.kind","signature":"pub kind : EntryKind"},{"kind":"field","path":"candidate::ScannedEntry.path","signature":"pub path : String"},{"kind":"fn","path":"candidate::default_limits","signature":"pub fn default_limits ( ) -> Limits"},{"kind":"fn","path":"candidate::ingest","signature":"pub fn ingest ( entries : & [ ScannedEntry ] , limits : Limits ) -> Result < Candidate >"},{"kind":"fn","path":"canon::canonical_bytes","signature":"pub fn canonical_bytes ( value : & Value ) -> Vec < u8 >"},{"kind":"fn","path":"canon::hash_json","signature":"pub fn hash_json ( kind : ObjectKind , value : & Value ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"canon::is_object_name","signature":"pub fn is_object_name ( text : & str ) -> bool"},{"kind":"fn","path":"canon::json_hash","signature":"pub fn json_hash ( kind : ObjectKind , value : & Value ) -> String"},{"kind":"fn","path":"canon::parse_json","signature":"pub fn parse_json ( bytes : & [ u8 ] , context : & str , code : OutcomeCode ) -> Result < Value >"},{"kind":"struct","path":"cli::CliResult","signature":"pub struct CliResult"},{"kind":"field","path":"cli::CliResult.exit_code","signature":"pub exit_code : i32"},{"kind":"field","path":"cli::CliResult.stdout","signature":"pub stdout : Vec < u8 >"},{"kind":"struct","path":"cli::CommandSpec","signature":"pub struct CommandSpec"},{"kind":"field","path":"cli::CommandSpec.name","signature":"pub name : & ' static str"},{"kind":"field","path":"cli::CommandSpec.run","signature":"pub run : fn ( & Path , & [ String ] ) -> Result < CliResult >"},{"kind":"fn","path":"cli::run_cli","signature":"pub fn run_cli ( cwd : & Path , args : & [ String ] ) -> CliResult"},{"kind":"fn","path":"commandset::commands","signature":"pub fn commands ( ) -> & ' static [ CommandSpec ]"},{"kind":"struct","path":"commit::CommitReport","signature":"pub struct CommitReport"},{"kind":"field","path":"commit::CommitReport.outcome","signature":"pub outcome : OutcomeCode"},{"kind":"field","path":"commit::CommitReport.payload","signature":"pub payload : Value"},{"kind":"fn","path":"commit::commit","signature":"pub fn commit ( workspace : & Path , receipt_path : & Path , candidate_dir : & Path , attribution_path : Option < & Path > , no_verify : bool ) -> CommitReport"},{"kind":"fn","path":"commit::lock_key","signature":"pub fn lock_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"commit::recover","signature":"pub fn recover ( store : & Store ) -> Result < Vec < String > >"},{"kind":"struct","path":"compile::CompileArtifact","signature":"pub struct CompileArtifact"},{"kind":"field","path":"compile::CompileArtifact.artifact_hash","signature":"pub artifact_hash : String"},{"kind":"field","path":"compile::CompileArtifact.slices","signature":"pub slices : Vec < CompileSlice >"},{"kind":"field","path":"compile::CompileArtifact.value","signature":"pub value : Value"},{"kind":"fn","path":"compile::base_output_files","signature":"pub fn base_output_files ( source : & dyn ObjectSource , state : & ModuleState ) -> Result < Vec < ( String , String ) > >"},{"kind":"fn","path":"compile::compile","signature":"pub fn compile ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , only_module : Option < & str > ) -> Result < CompileArtifact >"},{"kind":"fn","path":"compile::graph_for","signature":"pub fn graph_for ( sources : & [ SourceModule ] , target_name : & str ) -> Result < Graph >"},{"kind":"fn","path":"compile::head_states","signature":"pub fn head_states ( source : & dyn ObjectSource , snapshot : Option < & str > ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"compile::read_surface_items","signature":"pub fn read_surface_items ( source : & dyn ObjectSource , hash : & str ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"compile::slice_at","signature":"pub fn slice_at ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , module : & str , snapshot : Option < & str > ) -> Result < CompileSlice >"},{"kind":"fn","path":"compile::state_of","signature":"pub fn state_of ( source : & dyn ObjectSource , states : & BTreeMap < ( String , String ) , String > , target : & TargetDef , module : & str ) -> Result < ( ModuleState , String ) >"},{"kind":"fn","path":"compile::workspace_source_hash","signature":"pub fn workspace_source_hash ( sources : & [ SourceModule ] ) -> Result < String >"},{"kind":"struct","path":"cost::CostLine","signature":"pub struct CostLine"},{"kind":"field","path":"cost::CostLine.cost_usd","signature":"pub cost_usd : Option < String >"},{"kind":"field","path":"cost::CostLine.model","signature":"pub model : Option < String >"},{"kind":"field","path":"cost::CostLine.module","signature":"pub module : String"},{"kind":"field","path":"cost::CostLine.receipt","signature":"pub receipt : String"},{"kind":"field","path":"cost::CostLine.tokens","signature":"pub tokens : Option < TokenUsage >"},{"kind":"const","path":"cost::UNKNOWN","signature":"pub const UNKNOWN : & str = \" unknown \" ;"},{"kind":"fn","path":"cost::cost_report","signature":"pub fn cost_report ( lines : & [ CostLine ] ) -> Value"},{"kind":"fn","path":"cost::line_from_reported","signature":"pub fn line_from_reported ( receipt : & str , module : & str , reported : & Value ) -> Result < CostLine >"},{"kind":"fn","path":"cost::pricing_provenance","signature":"pub fn pricing_provenance ( ) -> Value"},{"kind":"struct","path":"decision::Arriving","signature":"pub struct Arriving"},{"kind":"field","path":"decision::Arriving.base_snapshot","signature":"pub base_snapshot : Option < String >"},{"kind":"field","path":"decision::Arriving.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Arriving.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Arriving.write_set","signature":"pub write_set : BTreeSet < String >"},{"kind":"enum","path":"decision::Decision","signature":"pub enum Decision { Install , Rebase , StaleInput { expected : String , found : String } , BaseOutputChanged { expected : String , found : String } , WriteConflict { overlap : Vec < String > } }"},{"kind":"struct","path":"decision::Head","signature":"pub struct Head"},{"kind":"field","path":"decision::Head.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Head.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Head.intervening_write_set","signature":"pub intervening_write_set : BTreeSet < String >"},{"kind":"field","path":"decision::Head.snapshot","signature":"pub snapshot : Option < String >"},{"kind":"fn","path":"decision::conflicts","signature":"pub fn conflicts ( write_set_a : & BTreeSet < String > , read_set_a : & BTreeSet < String > , write_set_b : & BTreeSet < String > , changed_identities_b : & BTreeSet < String > ) -> bool"},{"kind":"fn","path":"decision::decide","signature":"pub fn decide ( arriving : & Arriving , head : & Head ) -> Decision"},{"kind":"fn","path":"decision::is_accepted","signature":"pub fn is_accepted ( decision : & Decision ) -> bool"},{"kind":"fn","path":"decision::rejection","signature":"pub fn rejection ( decision : & Decision ) -> Option < CompilerError >"},{"kind":"struct","path":"directive::DirectiveCase","signature":"pub struct DirectiveCase"},{"kind":"field","path":"directive::DirectiveCase.expect","signature":"pub expect : Value"},{"kind":"field","path":"directive::DirectiveCase.given","signature":"pub given : Value"},{"kind":"field","path":"directive::DirectiveCase.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"directive::DirectiveCase.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"directive::DirectiveCase.name","signature":"pub name : String"},{"kind":"fn","path":"directive::extract_cases","signature":"pub fn extract_cases ( normalized_body : & str ) -> Result < Vec < DirectiveCase > >"},{"kind":"fn","path":"directive::extract_validated","signature":"pub fn extract_validated ( normalized_body : & str , frontmatter_names : & [ String ] ) -> Result < Vec < DirectiveCase > >"},{"kind":"fn","path":"directive::normalize_case_name","signature":"pub fn normalize_case_name ( name : & str ) -> String"},{"kind":"fn","path":"directive::parse_inline_value","signature":"pub fn parse_inline_value ( text : & str ) -> Option < Value >"},{"kind":"const","path":"disk::POINTER_LOCK","signature":"pub const POINTER_LOCK : & str = \" pointer \" ;"},{"kind":"const","path":"disk::STORE_DIR","signature":"pub const STORE_DIR : & str = \" .napl/store \" ;"},{"kind":"struct","path":"disk::Store","signature":"pub struct Store"},{"kind":"field","path":"disk::Store.root","signature":"pub root : PathBuf"},{"kind":"field","path":"disk::Store.workspace","signature":"pub workspace : PathBuf"},{"kind":"fn","path":"disk::compare_and_set_head","signature":"pub fn compare_and_set_head ( store : & Store , expected : Option < & str > , next : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::delete_marker","signature":"pub fn delete_marker ( store : & Store , snapshot : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::init","signature":"pub fn init ( workspace : & Path ) -> Result < Store >"},{"kind":"fn","path":"disk::io","signature":"pub fn io ( path : & Path , err : & std :: io :: Error ) -> CompilerError"},{"kind":"fn","path":"disk::marker_snapshots","signature":"pub fn marker_snapshots ( store : & Store ) -> Result < Vec < String > >"},{"kind":"fn","path":"disk::open","signature":"pub fn open ( workspace : & Path ) -> Store"},{"kind":"fn","path":"disk::publish","signature":"pub fn publish ( store : & Store , kind : ObjectKind , payload : & [ u8 ] , owner : & str ) -> Result < String >"},{"kind":"fn","path":"disk::read_marker","signature":"pub fn read_marker ( store : & Store , snapshot : & str ) -> Result < Option < CommitMarker > >"},{"kind":"fn","path":"disk::release_lock","signature":"pub fn release_lock ( guard : LockGuard ) -> Result < ( ) >"},{"kind":"fn","path":"disk::remove_object","signature":"pub fn remove_object ( store : & Store , name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::take_lock","signature":"pub fn take_lock ( store : & Store , name : & str ) -> Result < LockGuard >"},{"kind":"fn","path":"disk::write_marker","signature":"pub fn write_marker ( store : & Store , marker : & CommitMarker ) -> Result < ( ) >"},{"kind":"struct","path":"error::CompilerError","signature":"pub struct CompilerError"},{"kind":"field","path":"error::CompilerError.code","signature":"pub code : OutcomeCode"},{"kind":"field","path":"error::CompilerError.detail","signature":"pub detail : String"},{"kind":"enum","path":"error::OutcomeCode","signature":"pub enum OutcomeCode { Success , StaleInput , BaseOutputChanged , WriteConflict , LockContended , InvalidCandidate , InvalidReceipt , MappingIntegrity , ResourceLimit , UnsupportedVersion , StoreIntegrity , IoError }"},{"kind":"type","path":"error::Result","signature":"pub type Result < T > = std :: result :: Result < T , CompilerError > ;"},{"kind":"fn","path":"error::error","signature":"pub fn error ( code : OutcomeCode , detail : impl Into < String > ) -> CompilerError"},{"kind":"fn","path":"error::error_payload","signature":"pub fn error_payload ( err : & CompilerError , blobs_published : bool , provenance_published : bool ) -> Value"},{"kind":"fn","path":"error::outcome_exit_code","signature":"pub fn outcome_exit_code ( code : OutcomeCode ) -> i32"},{"kind":"fn","path":"error::outcome_is_retryable","signature":"pub fn outcome_is_retryable ( code : OutcomeCode ) -> bool"},{"kind":"fn","path":"error::outcome_slug","signature":"pub fn outcome_slug ( code : OutcomeCode ) -> & ' static str"},{"kind":"const","path":"evidence::CLAUDE_CLI_JSON","signature":"pub const CLAUDE_CLI_JSON : & str = \" application/vnd.claude-cli+json \" ;"},{"kind":"struct","path":"evidence::EvidenceItem","signature":"pub struct EvidenceItem"},{"kind":"field","path":"evidence::EvidenceItem.body","signature":"pub body : String"},{"kind":"field","path":"evidence::EvidenceItem.media_type","signature":"pub media_type : String"},{"kind":"enum","path":"evidence::EvidenceOutcome","signature":"pub enum EvidenceOutcome { Validated , Unsupported , Invalid , ValidatorError }"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEMS","signature":"pub const MAX_EVIDENCE_ITEMS : usize = 8 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEM_BYTES","signature":"pub const MAX_EVIDENCE_ITEM_BYTES : usize = 64 * 1024 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_TOTAL_BYTES","signature":"pub const MAX_EVIDENCE_TOTAL_BYTES : usize = 256 * 1024 ;"},{"kind":"fn","path":"evidence::check_evidence_bounds","signature":"pub fn check_evidence_bounds ( items : & [ EvidenceItem ] ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::check_reported_bounds","signature":"pub fn check_reported_bounds ( reported : & Value , limit : usize ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::evidence_report","signature":"pub fn evidence_report ( items : & [ ( EvidenceItem , EvidenceOutcome ) ] ) -> Value"},{"kind":"fn","path":"evidence::evidence_slug","signature":"pub fn evidence_slug ( outcome : EvidenceOutcome ) -> & ' static str"},{"kind":"fn","path":"evidence::parse_evidence","signature":"pub fn parse_evidence ( reported : & Value ) -> Result < Vec < EvidenceItem > >"},{"kind":"fn","path":"evidence::registered_media_types","signature":"pub fn registered_media_types ( ) -> Vec < String >"},{"kind":"fn","path":"evidence::validate_all","signature":"pub fn validate_all ( items : & [ EvidenceItem ] ) -> Vec < ( EvidenceItem , EvidenceOutcome ) >"},{"kind":"fn","path":"evidence::validate_evidence","signature":"pub fn validate_evidence ( item : & EvidenceItem ) -> EvidenceOutcome"},{"kind":"type","path":"graph::Graph","signature":"pub type Graph = BTreeMap < String , SourceModule > ;"},{"kind":"fn","path":"graph::build_graph","signature":"pub fn build_graph ( sources : & [ SourceModule ] , target : & TargetDef ) -> Result < Graph >"},{"kind":"fn","path":"graph::changed_frontier","signature":"pub fn changed_frontier ( graph : & Graph , committed_input_keys : & BTreeMap < String , Option < String > > , input_keys : & BTreeMap < String , String > , only_module : Option < & str > ) -> Result < Vec < String > >"},{"kind":"fn","path":"graph::dependents_of","signature":"pub fn dependents_of ( graph : & Graph , module : & str ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::index_sources","signature":"pub fn index_sources ( sources : & [ SourceModule ] ) -> Result < BTreeMap < String , SourceModule > >"},{"kind":"fn","path":"graph::reachable_from","signature":"pub fn reachable_from ( graph : & Graph , roots : & [ String ] ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::topological_levels","signature":"pub fn topological_levels ( graph : & Graph ) -> Result < BTreeMap < String , usize > >"},{"kind":"enum","path":"lint::Analysis","signature":"pub enum Analysis { Performed , Refused }"},{"kind":"enum","path":"lint::Finding","signature":"pub enum Finding { UnreachableModule { module : String , cascade : Vec < String > } , UnconsumedSurfaceEntry { module : String , path : String } , StaleModule { module : String } , UnusedDependency { module : String , dep : String } }"},{"kind":"struct","path":"lint::LintInput","signature":"pub struct LintInput"},{"kind":"field","path":"lint::LintInput.committed_input_keys","signature":"pub committed_input_keys : BTreeMap < String , Option < String > >"},{"kind":"field","path":"lint::LintInput.current_input_keys","signature":"pub current_input_keys : BTreeMap < String , String >"},{"kind":"field","path":"lint::LintInput.referenced_names","signature":"pub referenced_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"field","path":"lint::LintInput.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"lint::LintInput.surface_names","signature":"pub surface_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"struct","path":"lint::LintReport","signature":"pub struct LintReport"},{"kind":"field","path":"lint::LintReport.findings","signature":"pub findings : Vec < Finding >"},{"kind":"field","path":"lint::LintReport.unreachable_analysis","signature":"pub unreachable_analysis : Analysis"},{"kind":"fn","path":"lint::finding_json","signature":"pub fn finding_json ( finding : & Finding ) -> Value"},{"kind":"fn","path":"lint::is_auto_fixable","signature":"pub fn is_auto_fixable ( finding : & Finding ) -> bool"},{"kind":"fn","path":"lint::lint","signature":"pub fn lint ( graph : & Graph , input : & LintInput ) -> LintReport"},{"kind":"fn","path":"lint::lint_json","signature":"pub fn lint_json ( report : & LintReport ) -> Value"},{"kind":"fn","path":"lsp::serve","signature":"pub fn serve < R : BufRead , W : Write > ( reader : & mut R , writer : & mut W )"},{"kind":"struct","path":"maintenance::FileFact","signature":"pub struct FileFact"},{"kind":"field","path":"maintenance::FileFact.hash","signature":"pub hash : String"},{"kind":"field","path":"maintenance::FileFact.path","signature":"pub path : String"},{"kind":"enum","path":"maintenance::FsckFinding","signature":"pub enum FsckFinding { HashMismatch { object : String , detail : String } , MissingParent { event : String , parent : String } , MissingObject { from : String , missing : String } }"},{"kind":"enum","path":"maintenance::RestorePlan","signature":"pub enum RestorePlan { Apply ( Vec < RestoreStep > ) , Blocked { path : String , candidates : Vec < String > } }"},{"kind":"enum","path":"maintenance::RestoreStep","signature":"pub enum RestoreStep { Move { from : String , to : String } , Write { path : String , hash : String } , Remove { path : String } }"},{"kind":"fn","path":"maintenance::fsck","signature":"pub fn fsck ( source : & dyn ObjectSource ) -> Result < Vec < FsckFinding > >"},{"kind":"fn","path":"maintenance::fsck_json","signature":"pub fn fsck_json ( findings : & [ FsckFinding ] ) -> Value"},{"kind":"fn","path":"maintenance::gc_json","signature":"pub fn gc_json ( swept : & [ String ] , kept : usize ) -> Value"},{"kind":"fn","path":"maintenance::plan_restore","signature":"pub fn plan_restore ( expected : & [ FileFact ] , observed : & [ FileFact ] ) -> RestorePlan"},{"kind":"fn","path":"maintenance::plan_sweep","signature":"pub fn plan_sweep ( all : & BTreeSet < String > , reachable : & BTreeSet < String > ) -> Vec < String >"},{"kind":"fn","path":"maintenance::reachable_from_head","signature":"pub fn reachable_from_head ( source : & dyn ObjectSource , head : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"maintenance::restore_json","signature":"pub fn restore_json ( plan : & RestorePlan ) -> Value"},{"kind":"fn","path":"mv::relative_dir","signature":"pub fn relative_dir ( from : & str , to : & str ) -> String"},{"kind":"fn","path":"mv::relocate_files","signature":"pub fn relocate_files ( files : & BTreeMap < String , Vec < u8 > > , old_root : & str , dest : & str ) -> Result < BTreeMap < String , Vec < u8 > > >"},{"kind":"fn","path":"mv::relocate_module","signature":"pub fn relocate_module ( workspace : & Path , target_name : & str , module : & str , dest : & str ) -> CommitReport"},{"kind":"struct","path":"objects::AttributionState","signature":"pub struct AttributionState"},{"kind":"field","path":"objects::AttributionState.prompt_hash","signature":"pub prompt_hash : String"},{"kind":"field","path":"objects::AttributionState.record","signature":"pub record : String"},{"kind":"field","path":"objects::AttributionState.stale","signature":"pub stale : bool"},{"kind":"struct","path":"objects::Footprint","signature":"pub struct Footprint"},{"kind":"field","path":"objects::Footprint.derived_projections","signature":"pub derived_projections : Vec < String >"},{"kind":"field","path":"objects::Footprint.install_paths","signature":"pub install_paths : Vec < String >"},{"kind":"field","path":"objects::Footprint.read_set","signature":"pub read_set : Vec < String >"},{"kind":"field","path":"objects::Footprint.write_set","signature":"pub write_set : Vec < String >"},{"kind":"struct","path":"objects::ModuleState","signature":"pub struct ModuleState"},{"kind":"field","path":"objects::ModuleState.attribution","signature":"pub attribution : Option < AttributionState >"},{"kind":"field","path":"objects::ModuleState.input_key","signature":"pub input_key : Option < String >"},{"kind":"field","path":"objects::ModuleState.module","signature":"pub module : String"},{"kind":"field","path":"objects::ModuleState.output_tree","signature":"pub output_tree : String"},{"kind":"field","path":"objects::ModuleState.slice_hash","signature":"pub slice_hash : Option < String >"},{"kind":"field","path":"objects::ModuleState.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"objects::ModuleState.target","signature":"pub target : String"},{"kind":"struct","path":"objects::Transition","signature":"pub struct Transition"},{"kind":"field","path":"objects::Transition.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"objects::Transition.footprint","signature":"pub footprint : Footprint"},{"kind":"field","path":"objects::Transition.input_key","signature":"pub input_key : String"},{"kind":"field","path":"objects::Transition.module","signature":"pub module : String"},{"kind":"field","path":"objects::Transition.new_state","signature":"pub new_state : String"},{"kind":"field","path":"objects::Transition.receipt","signature":"pub receipt : String"},{"kind":"field","path":"objects::Transition.surface","signature":"pub surface : String"},{"kind":"field","path":"objects::Transition.target","signature":"pub target : String"},{"kind":"fn","path":"objects::empty_module_state","signature":"pub fn empty_module_state ( module : & str , target : & str , empty_tree : & str ) -> ModuleState"},{"kind":"fn","path":"objects::event_hash","signature":"pub fn event_hash ( content : & str , parents : & [ String ] ) -> String"},{"kind":"fn","path":"objects::event_json","signature":"pub fn event_json ( content : & str , parents : & [ String ] ) -> Value"},{"kind":"fn","path":"objects::event_transition","signature":"pub fn event_transition ( source : & dyn ObjectSource , event : & str ) -> Result < String >"},{"kind":"fn","path":"objects::module_state_hash","signature":"pub fn module_state_hash ( state : & ModuleState ) -> String"},{"kind":"fn","path":"objects::module_state_json","signature":"pub fn module_state_json ( state : & ModuleState ) -> Value"},{"kind":"fn","path":"objects::ownership_write_key","signature":"pub fn ownership_write_key ( root : & str ) -> String"},{"kind":"fn","path":"objects::read_module_state","signature":"pub fn read_module_state ( source : & dyn ObjectSource , hash : & str ) -> Result < ModuleState >"},{"kind":"fn","path":"objects::snapshot_event","signature":"pub fn snapshot_event ( source : & dyn ObjectSource , snapshot : & str ) -> Result < String >"},{"kind":"fn","path":"objects::snapshot_hash","signature":"pub fn snapshot_hash ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> String"},{"kind":"fn","path":"objects::snapshot_json","signature":"pub fn snapshot_json ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> Value"},{"kind":"fn","path":"objects::snapshot_members","signature":"pub fn snapshot_members ( states : & BTreeMap < ( String , String ) , String > , target : & str ) -> Vec < String >"},{"kind":"fn","path":"objects::snapshot_states","signature":"pub fn snapshot_states ( source : & dyn ObjectSource , snapshot : & str ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"objects::state_write_key","signature":"pub fn state_write_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"objects::transition_hash","signature":"pub fn transition_hash ( transition : & Transition ) -> String"},{"kind":"fn","path":"objects::transition_json","signature":"pub fn transition_json ( transition : & Transition ) -> Value"},{"kind":"fn","path":"objects::transition_write_set","signature":"pub fn transition_write_set ( source : & dyn ObjectSource , transition : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"objects::walk_events","signature":"pub fn walk_events ( source : & dyn ObjectSource , event : & str , until : Option < & str > ) -> Result < Vec < String > >"},{"kind":"const","path":"paths::MAX_PATH_LEN","signature":"pub const MAX_PATH_LEN : usize = 1024 ;"},{"kind":"const","path":"paths::MAX_SEGMENT_LEN","signature":"pub const MAX_SEGMENT_LEN : usize = 128 ;"},{"kind":"fn","path":"paths::check_canonical_path","signature":"pub fn check_canonical_path ( path : & str , code : OutcomeCode ) -> Result < ( ) >"},{"kind":"fn","path":"paths::decode_name","signature":"pub fn decode_name ( segment : & str ) -> Option < String >"},{"kind":"fn","path":"paths::encode_name","signature":"pub fn encode_name ( name : & str ) -> String"},{"kind":"fn","path":"paths::is_under","signature":"pub fn is_under ( path : & str , prefix : & str ) -> bool"},{"kind":"fn","path":"paths::normalize_path_key","signature":"pub fn normalize_path_key ( path : & str ) -> String"},{"kind":"fn","path":"porcelain::add_note","signature":"pub fn add_note ( workspace : & Path , transition : & str , note : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::blame_path","signature":"pub fn blame_path ( workspace : & Path , path : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::cat_file","signature":"pub fn cat_file ( workspace : & Path , hash : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"porcelain::causal_order","signature":"pub fn causal_order ( store : & Store , head : & str ) -> Result < Vec < String > >"},{"kind":"fn","path":"porcelain::committed_attribution_yaml","signature":"pub fn committed_attribution_yaml ( store : & Store , target : & str , module : & str ) -> Result < Option < Vec < u8 > > >"},{"kind":"fn","path":"porcelain::committed_manifest","signature":"pub fn committed_manifest ( store : & Store , target : & str ) -> Result < BTreeMap < String , String > >"},{"kind":"fn","path":"porcelain::committed_state","signature":"pub fn committed_state ( store : & Store , target : & str , module : & str ) -> Result < Option < ModuleState > >"},{"kind":"fn","path":"porcelain::cost","signature":"pub fn cost ( workspace : & Path , names : & [ String ] , files : & [ String ] ) -> Result < Value >"},{"kind":"fn","path":"porcelain::diff","signature":"pub fn diff ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::fsck_store","signature":"pub fn fsck_store ( workspace : & Path ) -> Result < ( bool , Value ) >"},{"kind":"fn","path":"porcelain::gc","signature":"pub fn gc ( workspace : & Path , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::lint_workspace","signature":"pub fn lint_workspace ( workspace : & Path , target : & str ) -> Result < ( Graph , LintReport , Value ) >"},{"kind":"fn","path":"porcelain::log","signature":"pub fn log ( workspace : & Path , module : Option < & str > ) -> Result < Value >"},{"kind":"fn","path":"porcelain::module_plan","signature":"pub fn module_plan ( workspace : & Path , target_name : & str , module : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::restore","signature":"pub fn restore ( workspace : & Path , target : & str , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::show","signature":"pub fn show ( workspace : & Path , hash : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::status","signature":"pub fn status ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::surface_of","signature":"pub fn surface_of ( workspace : & Path , target : & str , module : & str ) -> Result < Value >"},{"kind":"const","path":"receipt::MAX_REPORTED_BYTES","signature":"pub const MAX_REPORTED_BYTES : usize = 512 * 1024 ;"},{"kind":"const","path":"receipt::RECEIPT_VERSION","signature":"pub const RECEIPT_VERSION : u32 = 1 ;"},{"kind":"struct","path":"receipt::Receipt","signature":"pub struct Receipt"},{"kind":"field","path":"receipt::Receipt.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"receipt::Receipt.base_store_snapshot","signature":"pub base_store_snapshot : Option < String >"},{"kind":"field","path":"receipt::Receipt.base_workspace_hash","signature":"pub base_workspace_hash : String"},{"kind":"field","path":"receipt::Receipt.candidate_tree_hash","signature":"pub candidate_tree_hash : String"},{"kind":"field","path":"receipt::Receipt.evidence","signature":"pub evidence : Vec < EvidenceItem >"},{"kind":"field","path":"receipt::Receipt.input_key","signature":"pub input_key : String"},{"kind":"field","path":"receipt::Receipt.module","signature":"pub module : String"},{"kind":"field","path":"receipt::Receipt.output_manifest","signature":"pub output_manifest : BTreeMap < String , String >"},{"kind":"field","path":"receipt::Receipt.receipt_version","signature":"pub receipt_version : u32"},{"kind":"field","path":"receipt::Receipt.reported","signature":"pub reported : Value"},{"kind":"field","path":"receipt::Receipt.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"receipt::Receipt.target","signature":"pub target : String"},{"kind":"const","path":"receipt::UNAVAILABLE","signature":"pub const UNAVAILABLE : & str = \" unavailable \" ;"},{"kind":"fn","path":"receipt::normalize_reported","signature":"pub fn normalize_reported ( value : & Value ) -> Value"},{"kind":"fn","path":"receipt::parse_receipt","signature":"pub fn parse_receipt ( bytes : & [ u8 ] ) -> Result < Receipt >"},{"kind":"fn","path":"receipt::receipt_hash","signature":"pub fn receipt_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_json","signature":"pub fn receipt_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::receipt_semantic_hash","signature":"pub fn receipt_semantic_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_semantic_json","signature":"pub fn receipt_semantic_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::reported_usage","signature":"pub fn reported_usage ( receipt : & Receipt ) -> Value"},{"kind":"struct","path":"slice::CompileSlice","signature":"pub struct CompileSlice"},{"kind":"field","path":"slice::CompileSlice.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"slice::CompileSlice.body","signature":"pub body : Value"},{"kind":"field","path":"slice::CompileSlice.input_key","signature":"pub input_key : String"},{"kind":"field","path":"slice::CompileSlice.module","signature":"pub module : String"},{"kind":"field","path":"slice::CompileSlice.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"slice::CompileSlice.target","signature":"pub target : String"},{"kind":"field","path":"slice::CompileSlice.wave","signature":"pub wave : usize"},{"kind":"struct","path":"slice::DepSurface","signature":"pub struct DepSurface"},{"kind":"field","path":"slice::DepSurface.items","signature":"pub items : Vec < SurfaceItem >"},{"kind":"field","path":"slice::DepSurface.module","signature":"pub module : String"},{"kind":"field","path":"slice::DepSurface.surface_hash","signature":"pub surface_hash : String"},{"kind":"fn","path":"slice::build_slice","signature":"pub fn build_slice ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] , base : & ModuleState , base_state_key : & str , base_files : & [ ( String , String ) ] , wave : usize ) -> CompileSlice"},{"kind":"fn","path":"slice::closure_manifest","signature":"pub fn closure_manifest ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> Value"},{"kind":"fn","path":"slice::input_key","signature":"pub fn input_key ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> String"},{"kind":"fn","path":"slice::receipt_requirements","signature":"pub fn receipt_requirements ( ) -> Value"},{"kind":"fn","path":"slice::recompute_slice_hash","signature":"pub fn recompute_slice_hash ( body : & Value ) -> String"},{"kind":"const","path":"source::MAX_SOURCE_BYTES","signature":"pub const MAX_SOURCE_BYTES : usize = 4 * 1024 * 1024 ;"},{"kind":"const","path":"source::SOURCE_EXTENSION","signature":"pub const SOURCE_EXTENSION : & str = \" napl \" ;"},{"kind":"struct","path":"source::SourceModule","signature":"pub struct SourceModule"},{"kind":"field","path":"source::SourceModule.deps","signature":"pub deps : Vec < String >"},{"kind":"field","path":"source::SourceModule.directive_cases","signature":"pub directive_cases : Vec < crate :: directive :: DirectiveCase >"},{"kind":"field","path":"source::SourceModule.frontmatter_cases","signature":"pub frontmatter_cases : Vec < serde_json :: Value >"},{"kind":"field","path":"source::SourceModule.module","signature":"pub module : String"},{"kind":"field","path":"source::SourceModule.normalized_body","signature":"pub normalized_body : String"},{"kind":"field","path":"source::SourceModule.normalized_source_hash","signature":"pub normalized_source_hash : String"},{"kind":"field","path":"source::SourceModule.path","signature":"pub path : String"},{"kind":"field","path":"source::SourceModule.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"source::SourceModule.targets","signature":"pub targets : Vec < String >"},{"kind":"field","path":"source::SourceModule.tests","signature":"pub tests : Vec < String >"},{"kind":"fn","path":"source::check_module_name","signature":"pub fn check_module_name ( name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"source::normalize_body","signature":"pub fn normalize_body ( raw : & str ) -> String"},{"kind":"fn","path":"source::parse_source","signature":"pub fn parse_source ( path : & str , text : & str ) -> Result < SourceModule >"},{"kind":"fn","path":"source::rewrite_deps","signature":"pub fn rewrite_deps ( text : & str , deps : & [ String ] ) -> Result < String >"},{"kind":"const","path":"staging::STAGING_DIR","signature":"pub const STAGING_DIR : & str = \" .napl/staging \" ;"},{"kind":"fn","path":"staging::add","signature":"pub fn add ( workspace : & Path , target_name : & str , module : & str , candidate_dir : & Path , attribution_path : Option < & Path > , usage_path : Option < & Path > , no_verify : bool ) -> Result < Value >"},{"kind":"fn","path":"staging::commit_staged","signature":"pub fn commit_staged ( workspace : & Path , record : & Path ) -> CommitReport"},{"kind":"trait","path":"store_view::ObjectSource","signature":"pub trait ObjectSource { fn envelope ( & self , name : & str ) -> Result < Option < Vec < u8 > > > ; fn head ( & self ) -> Result < Option < String > > ; fn names ( & self ) -> Result < Vec < String > > ; }"},{"kind":"fn","path":"store_view::flatten_tree","signature":"pub fn flatten_tree ( source : & dyn ObjectSource , root : & str ) -> Result < BTreeMap < String , ( String , u32 ) > >"},{"kind":"fn","path":"store_view::get_blob","signature":"pub fn get_blob ( source : & dyn ObjectSource , name : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"store_view::get_json","signature":"pub fn get_json ( source : & dyn ObjectSource , name : & str , expected : ObjectKind ) -> Result < Value >"},{"kind":"fn","path":"store_view::get_object","signature":"pub fn get_object ( source : & dyn ObjectSource , name : & str ) -> Result < ( ObjectKind , Vec < u8 > ) >"},{"kind":"fn","path":"store_view::get_tree","signature":"pub fn get_tree ( source : & dyn ObjectSource , name : & str ) -> Result < Tree >"},{"kind":"fn","path":"store_view::has_object","signature":"pub fn has_object ( source : & dyn ObjectSource , name : & str ) -> bool"},{"kind":"fn","path":"store_view::load_tree_objects","signature":"pub fn load_tree_objects ( source : & dyn ObjectSource , root : & str , out : & mut BTreeMap < String , Tree > ) -> Result < ( ) >"},{"kind":"struct","path":"surface::SurfaceItem","signature":"pub struct SurfaceItem"},{"kind":"field","path":"surface::SurfaceItem.kind","signature":"pub kind : String"},{"kind":"field","path":"surface::SurfaceItem.path","signature":"pub path : String"},{"kind":"field","path":"surface::SurfaceItem.signature","signature":"pub signature : String"},{"kind":"fn","path":"surface::empty_surface_hash","signature":"pub fn empty_surface_hash ( module : & str , target : & str ) -> String"},{"kind":"fn","path":"surface::extract_surface","signature":"pub fn extract_surface ( target : & TargetDef , root : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"surface::surface_hash","signature":"pub fn surface_hash ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> String"},{"kind":"fn","path":"surface::surface_items_from_json","signature":"pub fn surface_items_from_json ( value : & Value ) -> Vec < SurfaceItem >"},{"kind":"fn","path":"surface::surface_json","signature":"pub fn surface_json ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> Value"},{"kind":"struct","path":"target::TargetDef","signature":"pub struct TargetDef"},{"kind":"field","path":"target::TargetDef.entry_point_version","signature":"pub entry_point_version : u32"},{"kind":"field","path":"target::TargetDef.manifest_file","signature":"pub manifest_file : String"},{"kind":"field","path":"target::TargetDef.name","signature":"pub name : String"},{"kind":"field","path":"target::TargetDef.source_dir","signature":"pub source_dir : String"},{"kind":"field","path":"target::TargetDef.source_extension","signature":"pub source_extension : String"},{"kind":"fn","path":"target::builtin_target","signature":"pub fn builtin_target ( name : & str ) -> Result < TargetDef >"},{"kind":"fn","path":"target::builtin_target_names","signature":"pub fn builtin_target_names ( ) -> Vec < String >"},{"kind":"fn","path":"target::derive_lib_projection","signature":"pub fn derive_lib_projection ( target : & TargetDef , root : & str , file_paths : & [ String ] , command_providers : & [ String ] ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"target::is_manifest_path","signature":"pub fn is_manifest_path ( target : & TargetDef , path : & str ) -> bool"},{"kind":"fn","path":"target::is_owned_path","signature":"pub fn is_owned_path ( target : & TargetDef , root : & str , path : & str ) -> bool"},{"kind":"fn","path":"target::owned_globs","signature":"pub fn owned_globs ( _target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::placement_facts","signature":"pub fn placement_facts ( target : & TargetDef , root : & str ) -> Value"},{"kind":"fn","path":"target::placement_root","signature":"pub fn placement_root ( source : & SourceModule ) -> String"},{"kind":"fn","path":"target::projection_owned_paths","signature":"pub fn projection_owned_paths ( target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::surface_grammar","signature":"pub fn surface_grammar ( target : & TargetDef ) -> Value"},{"kind":"fn","path":"target::target_def_hash","signature":"pub fn target_def_hash ( target : & TargetDef ) -> String"},{"kind":"fn","path":"target::target_def_json","signature":"pub fn target_def_json ( target : & TargetDef ) -> Value"},{"kind":"enum","path":"textdiff::Edit","signature":"pub enum Edit { Keep ( usize , usize ) , Delete ( usize ) , Insert ( usize ) }"},{"kind":"fn","path":"textdiff::diff_lines","signature":"pub fn diff_lines ( old : & [ String ] , new : & [ String ] ) -> Vec < Edit >"},{"kind":"fn","path":"textdiff::lines","signature":"pub fn lines ( text : & str ) -> Vec < String >"},{"kind":"fn","path":"textdiff::render_drift","signature":"pub fn render_drift ( committed : & BTreeMap < String , String > , working : & BTreeMap < String , String > ) -> String"},{"kind":"fn","path":"textdiff::render_patch","signature":"pub fn render_patch ( path : & str , old : & str , new : & str ) -> String"},{"kind":"struct","path":"transaction::Accepted","signature":"pub struct Accepted"},{"kind":"field","path":"transaction::Accepted.event","signature":"pub event : String"},{"kind":"field","path":"transaction::Accepted.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::Accepted.projection_bytes","signature":"pub projection_bytes : Vec < u8 >"},{"kind":"field","path":"transaction::Accepted.projection_path","signature":"pub projection_path : String"},{"kind":"field","path":"transaction::Accepted.rebased","signature":"pub rebased : bool"},{"kind":"field","path":"transaction::Accepted.snapshot","signature":"pub snapshot : String"},{"kind":"field","path":"transaction::Accepted.steps","signature":"pub steps : Vec < InstallStep >"},{"kind":"struct","path":"transaction::BuiltAttribution","signature":"pub struct BuiltAttribution"},{"kind":"field","path":"transaction::BuiltAttribution.state","signature":"pub state : AttributionState"},{"kind":"field","path":"transaction::BuiltAttribution.view_path","signature":"pub view_path : String"},{"kind":"field","path":"transaction::BuiltAttribution.yaml","signature":"pub yaml : Vec < u8 >"},{"kind":"struct","path":"transaction::DerivedCommit","signature":"pub struct DerivedCommit"},{"kind":"field","path":"transaction::DerivedCommit.attribution_install","signature":"pub attribution_install : Option < ( String , String ) >"},{"kind":"field","path":"transaction::DerivedCommit.attribution_status","signature":"pub attribution_status : String"},{"kind":"field","path":"transaction::DerivedCommit.evidence","signature":"pub evidence : Value"},{"kind":"field","path":"transaction::DerivedCommit.new_state_key","signature":"pub new_state_key : String"},{"kind":"field","path":"transaction::DerivedCommit.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::DerivedCommit.receipt_object","signature":"pub receipt_object : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_items","signature":"pub surface_items : Vec < SurfaceItem >"},{"kind":"field","path":"transaction::DerivedCommit.transition","signature":"pub transition : Transition"},{"kind":"field","path":"transaction::DerivedCommit.transition_id","signature":"pub transition_id : String"},{"kind":"struct","path":"transaction::PendingObject","signature":"pub struct PendingObject"},{"kind":"field","path":"transaction::PendingObject.kind","signature":"pub kind : ObjectKind"},{"kind":"field","path":"transaction::PendingObject.name","signature":"pub name : String"},{"kind":"field","path":"transaction::PendingObject.payload","signature":"pub payload : Vec < u8 >"},{"kind":"fn","path":"transaction::accept","signature":"pub fn accept ( source : & dyn ObjectSource , target : & TargetDef , root : & str , candidate : & Candidate , derived : & DerivedCommit , head : Option < & str > , base_snapshot : Option < & str > ) -> Result < Accepted >"},{"kind":"fn","path":"transaction::check_candidate_shape","signature":"pub fn check_candidate_shape ( target : & TargetDef , root : & str , candidate : & Candidate ) -> Result < ( ) >"},{"kind":"fn","path":"transaction::derive_commit","signature":"pub fn derive_commit ( target : & TargetDef , root : & str , receipt : & Receipt , candidate : & Candidate , built : Option < & BuiltAttribution > ) -> Result < DerivedCommit >"},{"kind":"fn","path":"transaction::intervening_write_set","signature":"pub fn intervening_write_set ( source : & dyn ObjectSource , head : Option < & str > , base : Option < & str > ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"transaction::success_payload","signature":"pub fn success_payload ( derived : & DerivedCommit , accepted : & Accepted ) -> Value"},{"kind":"fn","path":"transaction::verify_receipt","signature":"pub fn verify_receipt ( source : & dyn ObjectSource , sources : & [ SourceModule ] , receipt : & Receipt , candidate : & Candidate ) -> Result < CompileSlice >"},{"kind":"fn","path":"trees::empty_tree_hash","signature":"pub fn empty_tree_hash ( ) -> String"},{"kind":"fn","path":"trees::tree_of","signature":"pub fn tree_of ( manifest : & BTreeMap < String , String > ) -> Result < BuiltTree >"},{"kind":"const","path":"version::CONTRACT_VERSION","signature":"pub const CONTRACT_VERSION : u32 = 3 ;"},{"kind":"fn","path":"workspace::apply_step","signature":"pub fn apply_step ( root : & Path , step : & InstallStep , payload : & dyn Fn ( & str ) -> Result < Vec < u8 > > ) -> Result < ( ) >"},{"kind":"fn","path":"workspace::discover_sources","signature":"pub fn discover_sources ( root : & Path ) -> Result < Vec < SourceModule > >"},{"kind":"fn","path":"workspace::find_workspace_root","signature":"pub fn find_workspace_root ( start : & Path ) -> Option < PathBuf >"},{"kind":"fn","path":"workspace::read_working_text","signature":"pub fn read_working_text ( root : & Path , paths : & [ String ] ) -> BTreeMap < String , String >"},{"kind":"fn","path":"workspace::require_workspace_root","signature":"pub fn require_workspace_root ( start : & Path ) -> Result < PathBuf >"},{"kind":"fn","path":"workspace::scan_candidate","signature":"pub fn scan_candidate ( dir : & Path ) -> Result < Vec < ScannedEntry > >"},{"kind":"fn","path":"workspace::step_path","signature":"pub fn step_path ( step : & InstallStep ) -> & str"},{"kind":"fn","path":"workspace::write_file","signature":"pub fn write_file ( path : & Path , bytes : & [ u8 ] ) -> Result < ( ) >"}],"module":"napl","target":"rust"} +napl-store tree v1 507 +{"entries":[{"hash":"3b3b46d4f8f832a2b67e59d481fce3269061f0c79cb335d3ddc5a51d8a1ce915","kind":"file","mode":33188,"name":"check.rs"},{"hash":"b5e4e2782fa3e7b35714d43c6a5dd578e6adc40a53e872bce582dd3d76dc27c2","kind":"file","mode":33188,"name":"document.rs"},{"hash":"2e84007b600de3257549b12e35b45e779d85751c59dd2ec7db7abde7ecd7e4c2","kind":"file","mode":33188,"name":"promptbuild.rs"},{"hash":"ed4e1767d9f257439d2fe1a157104d2bbe511b8296bacd6027ba490122386f3f","kind":"file","mode":33188,"name":"query.rs"}]} +napl-store snapshot v1 891 +{"contractVersion":4,"event":"38eaa9d6b15a1bb30970cfd4c3c2f98fab73f4a40ce774a7d622cbadc210ac55","states":[{"module":"agent","state":"b5cde0e371a87ce3810de7c7b7af9e48bf453094540fce927bde21dde6b576b9","target":"rust"},{"module":"commands","state":"74f67c7538797091e6d19bd6f87099d0b414743cc4cc151db667619ff4a48d03","target":"rust"},{"module":"napl","state":"76f91323327cf2f0a66946473c1176b3988c304d57f2c6c67626991dd449cdd1","target":"rust"},{"module":"napl_store","state":"ede6f68b1d885a44d4fc2effd563a93d50c5b5ad8c31cdf2f07bb0f849109d1e","target":"rust"},{"module":"query","state":"2ceea09f82f0700bbe48909b0c56aa0e69cc477dd51287bd88cd2e87f6f7a1e1","target":"rust"},{"module":"serve","state":"8b31e47aad33789775fb32ae56868e37db7a9dd174e6df3a0262781a7ed2f6ae","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} +napl-store event v1 158 +{"content":"c21927fa38d31d70135585bace74db198943f9507fc0bd009aaadf340ab1a815","parents":["513fdbb16b9f7a1e1793f2c320111b2dc34e4559013c25d2c4ec92f7b2bb6e66"]} +napl-store blob v1 987 +module: "query" +target: "rust" +stale: false +entries: + - promptLines: + - 1 + - 14 + file: "crates/napl/src/query.rs" + lines: + - 1 + - 373 + note: "the query module owns napl q and napl prompt and the canonical document they share" + - promptLines: + - 15 + - 94 + file: "crates/napl/src/document.rs" + lines: + - 1 + - 381 + note: "the canonical document docVersion 1 built purely from committed records and the working tree, totally ordered, no absolute paths" + - promptLines: + - 95 + - 149 + file: "crates/napl/src/query.rs" + lines: + - 1 + - 373 + note: "napl q shaped exactly like jq via the embedded jaq engine, impure builtins refused, 64 MiB output ceiling" + - promptLines: + - 150 + - 229 + file: "crates/napl/src/promptbuild.rs" + lines: + - 1 + - 439 + note: "napl prompt reads typed refs and materializes prose and code from committed blobs at the refs snapshot, with law closure" +napl-store module-state v1 372 +{"contractVersion":1,"inputKey":"ca5b1967770c28629e18ac06fa023b37ce9888f8c1304234652ea5f6dfed7217","module":"napl","outputTree":"edca14b88b8b1c49625f4a6c1445716b16ec09c67fc22a01136492500d7e9f46","sliceHash":"1d43d63f59c9d3409fe15244f8fca6a6948084aafa206f9e21e8bcc8865b16a8","surfaceHash":"854dc3f816957d3a40ee1d16be7b44fb2a161a33e4919cccb846046979cdcca5","target":"rust"} +napl-store module-state v1 640 +{"attribution":{"promptHash":"85eab36f412936d247f59dc688116dd31246d27c0b86a7b3df52829d27e2d90a","record":"bcd92cb1f2335798a1c44ba0949c437b8273a798a03e253f20e17a93567d000b","stale":false},"contractVersion":4,"inputKey":"865433045eb8c750a39bd7d29f2e8631c96a03d6aedad8f19ea9907551bdc927","module":"napl_store","outputTree":"85a337713f60111fece6cdda3168eed43966c95adb1458d324acba9336110b24","prompt":"51bb437ce9ebb45c4381ed2f33805b71f99b6952618f1f1e3332108f5daf519c","sliceHash":"771bf0c5336c4db5e9304a3bbf42b4632bccbe537b40611140822a1eab5c5302","surfaceHash":"50b745f71f63d7fd5b8a05fdf84474524004af8f75d47f8346b7404f99981fab","target":"rust"} +napl-store module-state v1 558 +{"attribution":{"promptHash":"2b806ccda147575f59a8fc847b2656b26af400aa3cc7cf8733ae2c5f34ea402c","record":"72fa7c252c24654c4b70b21f44048c170b79b60a2c535fda6b57d6aeb0b6ce0f","stale":false},"contractVersion":4,"inputKey":"f0d2db36f12c77e49df8b1ef4d3ebed5546c7a4b89f1d5e1173d8064aab73494","module":"napl","outputTree":"52cf17c04dccef9f3e7106db729cf1555210c1726fdeb087e94651e7e60674df","sliceHash":"c4a38efe120fb6088316ad1073e6862e7f91170e6a33c7aaa00817f2a06620d2","surfaceHash":"0508c952df26ea2ffe5ab27a5dafc05d843d76948f77370745ec28cdba6d5260","target":"rust"} +napl-store tree v1 4679 +{"entries":[{"hash":"1a5de49301e4c6d47619ec0e44eab914b1846735830b07483c031e6a8c3dc67b","kind":"file","mode":33188,"name":"attribution.rs"},{"hash":"e539c65a4600ed1784b9cc176cb28f24c140f514f9783360f1f5a27142f89f68","kind":"file","mode":33188,"name":"authoring.rs"},{"hash":"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2","kind":"file","mode":33188,"name":"blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","kind":"file","mode":33188,"name":"candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","kind":"file","mode":33188,"name":"canon.rs"},{"hash":"0b8190e7989129e7baf4eed7046e19b7608067eb5d13bed95ad416118bf34a25","kind":"file","mode":33188,"name":"capture.rs"},{"hash":"085f7e2d366c1756ff8f4e7bfdccbb8139c49779fd8ab00f91e3d6c787569c23","kind":"file","mode":33188,"name":"cli.rs"},{"hash":"abad4283136e7fd0e880f49148d5422d673d5d1e4267976fd2a3dec47d7f34bd","kind":"file","mode":33188,"name":"commandset.rs"},{"hash":"ed1c68648258a7ac19741b966c321db9c100222da858a64cc0604b108b7998fa","kind":"file","mode":33188,"name":"commit.rs"},{"hash":"3c0db9b5d179e2f3b7123cd8e08844ea9ad28a0a6ffbc7e5794738709f5af1f4","kind":"file","mode":33188,"name":"compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","kind":"file","mode":33188,"name":"cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","kind":"file","mode":33188,"name":"decision.rs"},{"hash":"04a648a127be983c89a99181ee26e28401ee00f84ea0b10b6c050ecd263a1b2c","kind":"file","mode":33188,"name":"directive.rs"},{"hash":"ada150c613fba5947ada47cc6e694d518c13a89626ed80bf665ed3a9b2f373b6","kind":"file","mode":33188,"name":"disk.rs"},{"hash":"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812","kind":"file","mode":33188,"name":"error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","kind":"file","mode":33188,"name":"evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","kind":"file","mode":33188,"name":"graph.rs"},{"hash":"824c95b7229ac2ad5b1cc518b296443883fb56a110c86c2a2e67923c0a19ea07","kind":"file","mode":33188,"name":"lint.rs"},{"hash":"40689b2560d6c445afd552c2f470ba3ce0d427622d96ed2cf2ad8cfdb4286321","kind":"file","mode":33188,"name":"lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","kind":"file","mode":33188,"name":"main.rs"},{"hash":"6fc1b58dad295d780c917c10ac0f793667e46c455349cff3d42e6855501a91bd","kind":"file","mode":33188,"name":"maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","kind":"file","mode":33188,"name":"mv.rs"},{"hash":"3ae089b923327032845c386f2332ff653a08f25ccec28e0786094fa05fd77a00","kind":"file","mode":33188,"name":"objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","kind":"file","mode":33188,"name":"paths.rs"},{"hash":"23047facf0e4f522af446d5b1947d683c2248f6d1075f8ff6034c407fe597095","kind":"file","mode":33188,"name":"porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","kind":"file","mode":33188,"name":"receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","kind":"file","mode":33188,"name":"slice.rs"},{"hash":"a24eb1b3fc59a87a34e7f343c3396da60352ece50b6688c36b04da55351f1312","kind":"file","mode":33188,"name":"source.rs"},{"hash":"7d1bba27fd84f0edae130f1902b00abab524e3a67d0428b1a2fe7a2c01af5273","kind":"file","mode":33188,"name":"staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","kind":"file","mode":33188,"name":"store_view.rs"},{"hash":"1820f647bb5dbd74c8db7f5519d404ef0ce1520c833a579aaca893b5cad7ba08","kind":"file","mode":33188,"name":"surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","kind":"file","mode":33188,"name":"target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","kind":"file","mode":33188,"name":"textdiff.rs"},{"hash":"ca46ca78bcb2940f23d3aede83fdee2d10b6910dfb0eaa9de6a8ca83a2209427","kind":"file","mode":33188,"name":"transaction.rs"},{"hash":"d4ef5888a7ecc406d8eb5173dc215c7b1a1ef1e1ddd3ab8b3a8aa6639d125017","kind":"file","mode":33188,"name":"transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","kind":"file","mode":33188,"name":"trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","kind":"file","mode":33188,"name":"version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","kind":"file","mode":33188,"name":"workspace.rs"}]} +napl-store blob v1 27243 +use std::io::Write as _; +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::authoring::apply_fix; +use crate::canon::canonical_bytes; +use crate::cli::{ + clap_root, emit_prerequisite, flag, has_switch, ok, positional, positionals_from, repeated_flag, + require_flag, resolve_workspace, switch, usage, value, CliResult, CommandSpec, FlagSpec, + USAGE_EXIT_CODE, WORKSPACE_FLAG, +}; +use crate::commit::recover; +use crate::disk; +use crate::error::{outcome_exit_code, OutcomeCode, Result}; +use crate::lint::{is_auto_fixable, lint_json, refusal_json, Analysis}; +use crate::mv::relocate_module; +use crate::porcelain::{ + add_note, blame_path, cat_file, cost, diff, fsck_store, gc, lint_workspace, log, module_plan, + restore, show, status, surface_of, +}; + +/// Only `--workspace`: the command reads the workspace and nothing else. +const WORKSPACE_ONLY: &[FlagSpec] = &[WORKSPACE_FLAG]; + +const ADD_FLAGS: &[FlagSpec] = &[ + value( + "--attribution", + "path to a map affirming the claims the transport listed; entries may spell an endpoint `whole`", + ), + switch( + "--check", + "run the whole pipeline including the transport verdict and write nothing at all", + ), + value("--module", "the module the candidate is output for"), + switch("--no-verify", "stage without the acceptance verification pass"), + value("--target", "the target to stage for, default rust"), + value("--usage", "path to the usage envelope recorded as provenance"), + WORKSPACE_FLAG, +]; + +const COMMIT_FLAGS: &[FlagSpec] = &[ + value( + "--staged", + "path to one staged record `napl add` reported; without it every staged record installs in wave order", + ), + WORKSPACE_FLAG, +]; + +const STATUS_FLAGS: &[FlagSpec] = &[ + value( + "--module", + "report one module's plan, live state, and derived entry-file projection instead of the workspace", + ), + WORKSPACE_FLAG, +]; + +const LOG_FLAGS: &[FlagSpec] = &[ + value("--module", "limit the history to one module"), + WORKSPACE_FLAG, +]; + +const MODULE_FLAGS: &[FlagSpec] = &[ + value("--module", "the module to read"), + WORKSPACE_FLAG, +]; + +const LINT_FLAGS: &[FlagSpec] = &[ + switch("--fix", "apply the auto-fixable findings through the guarded commit path"), + WORKSPACE_FLAG, +]; + +const COST_FLAGS: &[FlagSpec] = &[ + value("--file", "an extra receipt file to price, repeatable"), + value("--session", "price one session's calls from the log, repeatable"), + WORKSPACE_FLAG, +]; + +const APPLY_FLAGS: &[FlagSpec] = &[ + switch("--apply", "apply the plan instead of only reporting it"), + WORKSPACE_FLAG, +]; + +const NOTES_FLAGS: &[FlagSpec] = &[ + value("--note", "the note text to attach"), + value("--to", "the transition the note attaches to"), + WORKSPACE_FLAG, +]; + +const MV_FLAGS: &[FlagSpec] = &[ + value("--target", "the target the module is placed for, default rust"), + WORKSPACE_FLAG, +]; + +const EDIT_FLAGS: &[FlagSpec] = &[ + value("--new-string", "the replacement text"), + value("--old-string", "the exact text to replace"), + value( + "--ref", + "a single JSON object recording one reference: direction (code_to_prompt or prompt_to_code), module, file, lines, promptLines, and an optional note", + ), + switch("--replace-all", "replace every occurrence instead of requiring exactly one"), + value("--session", "the session window recorded as provenance"), + WORKSPACE_FLAG, +]; + +const WRITE_FLAGS: &[FlagSpec] = &[ + switch("--empty", "authorize writing a zero-byte file, which empty stdin alone never does"), + value( + "--ref", + "a single JSON object recording one reference: direction (code_to_prompt or prompt_to_code), module, file, lines, promptLines, and an optional note", + ), + value("--session", "the session window recorded as provenance"), + WORKSPACE_FLAG, +]; + +/// The shells `napl completions` generates for. +pub const COMPLETION_SHELLS: [&str; 3] = ["bash", "zsh", "fish"]; + +/// The command table the core toolchain exposes to the derived registry. /// -/// Where strictness trips differs by fence kind. A `# ` or `## ` line inside an -/// open example or test fence still refuses immediately, because nothing but -/// directive content belongs there. Inside an open prose fence the same line is -/// markdown the document renders, so the unclosed refusal fires where the fence -/// provably cannot close: at the end of the body, naming the opening line. -fn holds_prose(name: &str) -> bool { - matches!(name, "docs" | "note" | "warning") +/// Every entry declares its full grammar: a one-line summary, the exact usage +/// line, and the closed table of flags it accepts with each flag's arity and +/// meaning. The dispatch loop answers help and refuses undeclared flags from +/// these tables, so no command parses its own arguments ad hoc. +pub fn commands() -> &'static [CommandSpec] { + &[ + CommandSpec { + name: "add", + example: "napl add --module leaf", + summary: "stage working-tree files, a module's whole owned tree, or an explicit candidate directory", + usage: "napl add ... | napl add ... --module | napl add --module | napl add --module [--check] [--target ] [--attribution ] [--usage ] [--no-verify]", + flags: ADD_FLAGS, + passthrough: false, + run: run_add, + }, + CommandSpec { + name: "blame", + example: "napl blame crates/leaf/src/greet.rs", + summary: "trace a generated file's lines back through the transitions that wrote them", + usage: "napl blame ", + flags: WORKSPACE_ONLY, + passthrough: false, + run: run_blame, + }, + CommandSpec { + name: "cat-file", + example: "napl cat-file 3f1c0a7e5b2d49c8a1e6f0b3d7c25948e0a1b2c3d4e5f60718293a4b5c6d7e8f", + summary: "print one stored object's raw bytes", + usage: "napl cat-file ", + flags: WORKSPACE_ONLY, + passthrough: false, + run: run_cat_file, + }, + CommandSpec { + name: "commit", + example: "napl commit", + summary: "install every staged record in wave order, or one named record, through the one guarded transaction", + usage: "napl commit [--staged ]", + flags: COMMIT_FLAGS, + passthrough: false, + run: run_commit, + }, + CommandSpec { + name: "completions", + example: "napl completions zsh", + summary: "emit a shell completion script generated from the command registry", + usage: "napl completions ", + flags: &[], + passthrough: false, + run: run_completions, + }, + CommandSpec { + name: "cost", + example: "napl cost --session s1", + summary: "price an explicit receipt set under a pinned pricing table", + usage: "napl cost [...] [--file ]... [--session ]...", + flags: COST_FLAGS, + passthrough: false, + run: run_cost, + }, + CommandSpec { + name: "diff", + example: "napl diff rust", + summary: "render drift between committed outputs and the working tree", + usage: "napl diff ", + flags: WORKSPACE_ONLY, + passthrough: false, + run: run_diff, + }, + CommandSpec { + name: "edit", + example: "napl edit crates/leaf/src/greet.rs --old-string 'let name' --new-string 'let who'", + summary: "replace exact text in a file and capture the change as a record", + usage: "napl edit --old-string --new-string [--replace-all] [--ref {direction, module, file, lines, promptLines, note}] [--session ]", + flags: EDIT_FLAGS, + passthrough: false, + run: crate::capture::run_edit, + }, + CommandSpec { + name: "fsck", + example: "napl fsck", + summary: "verify envelope hashes, kind validity, and causal parent links", + usage: "napl fsck", + flags: WORKSPACE_ONLY, + passthrough: false, + run: run_fsck, + }, + CommandSpec { + name: "gc", + example: "napl gc --apply", + summary: "report, or with --apply sweep, objects no named head reaches and the staged records beside them", + usage: "napl gc [--apply]", + flags: APPLY_FLAGS, + passthrough: false, + run: run_gc, + }, + CommandSpec { + name: "lint", + example: "napl lint rust", + summary: "diagnose the module graph and scan the target's source files for syntax the pinned grammar cannot parse", + usage: "napl lint [--fix]", + flags: LINT_FLAGS, + passthrough: false, + run: run_lint, + }, + CommandSpec { + name: "log", + example: "napl log --module leaf", + summary: "read the store's history, per module or workspace wide", + usage: "napl log [--module ]", + flags: LOG_FLAGS, + passthrough: false, + run: run_log, + }, + CommandSpec { + name: "lsp", + example: "napl lsp", + summary: "serve the language server over stdio for editor extensions", + usage: "napl lsp", + flags: &[], + passthrough: false, + run: run_lsp, + }, + CommandSpec { + name: "mv", + example: "napl mv leaf crates/greeting", + summary: "relocate a module's installed files through the guarded commit path", + usage: "napl mv [--target ]", + flags: MV_FLAGS, + passthrough: false, + run: run_mv, + }, + CommandSpec { + name: "notes", + example: "napl notes add --to --note 'reviewed by hand'", + summary: "attach a note to a recorded transition", + usage: "napl notes add --to --note ", + flags: NOTES_FLAGS, + passthrough: false, + run: run_notes, + }, + CommandSpec { + name: "recover", + example: "napl recover", + summary: "replay the persisted markers a crash left, deterministically", + usage: "napl recover", + flags: WORKSPACE_ONLY, + passthrough: false, + run: run_recover, + }, + CommandSpec { + name: "restore", + example: "napl restore rust --apply", + summary: "derive, or with --apply install, a repair plan from persisted evidence", + usage: "napl restore [--apply]", + flags: APPLY_FLAGS, + passthrough: false, + run: run_restore, + }, + CommandSpec { + name: "show", + example: "napl show ", + summary: "inspect one stored object as canonical JSON", + usage: "napl show ", + flags: WORKSPACE_ONLY, + passthrough: false, + run: run_show, + }, + CommandSpec { + name: "status", + example: "napl status --module leaf", + summary: "classify what needs doing, observationally and with zero mutation", + usage: "napl status [] [--module ]", + flags: STATUS_FLAGS, + passthrough: false, + run: run_status, + }, + CommandSpec { + name: "store", + example: "napl store migrate", + summary: "one-shot store operations; `migrate` moves the tracked object files into the git-backed store", + usage: "napl store migrate", + flags: WORKSPACE_ONLY, + passthrough: false, + run: run_store, + }, + CommandSpec { + name: "surface", + example: "napl surface rust --module leaf", + summary: "print a module's recorded public surface", + usage: "napl surface --module ", + flags: MODULE_FLAGS, + passthrough: false, + run: run_surface, + }, + CommandSpec { + name: "write", + example: "napl write crates/leaf/src/greet.rs < greet.rs", + summary: "write a file's full content from stdin and capture it as a record", + usage: "napl write [--empty] [--ref {direction, module, file, lines, promptLines, note}] [--session ] < content-on-stdin", + flags: WRITE_FLAGS, + passthrough: false, + run: crate::capture::run_write, + }, + ] } -/// The leading colon run of a fence line and the text after it. -fn colon_run(text: &str) -> Option<(usize, &str)> { - let count = text.chars().take_while(|c| *c == ':').count(); - if count < 3 { - return None; - } - Some((count, &text[count..])) -} +/// `napl add`, whose default source is the working tree exactly as in git. +/// +/// The three forms are told apart by what they name, not by a mode flag. Operands +/// with no `--module` are working-tree paths, each resolving to its owning module. +/// A `--module` with no operand is that module's whole owned tree. A `--module` +/// with one operand that is a directory is the explicit candidate form, which +/// stays legal unchanged for the case where the bytes to stage are not the working +/// tree; a `--module` with operands that are files is the explicit-module path +/// form, the spelling for a file that is new in a crate several modules share and +/// that neither the committed manifest nor the identical globs can attribute. +/// +/// `--check` composes with every form: the pipeline runs whole, the transport +/// verdict and refusal listing included, and nothing is written. +fn run_add(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + let operands = positionals_from(args, 1); + let module = flag(args, "--module")?; + let target = flag(args, "--target")?.unwrap_or_else(|| "rust".to_string()); + let attribution = flag(args, "--attribution")?; + let usage_path = flag(args, "--usage")?; + let attribution = attribution.as_deref().map(Path::new); + let usage_path = usage_path.as_deref().map(Path::new); + let no_verify = has_switch(args, "--no-verify"); + let check = has_switch(args, "--check"); -/// Parse an opening fence line's directive name and attributes. -fn parse_opener(rest: &str, line: u32) -> std::result::Result<(String, Value), Diag> { - let text = rest.trim_start_matches(' '); - let name_len = text - .chars() - .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') - .count(); - let name = &text[..name_len]; - let invalid = |msg: String| Diag { rank: 2, line, msg }; - if name.is_empty() - || !name.starts_with(|c: char| c.is_ascii_alphanumeric()) - || name.ends_with('-') - || name.ends_with('_') - { - return Err(invalid(format!( - "directive name {name:?} is not a name: alphanumeric plus `-` and `_`, never ending in `-` or `_`" - ))); + let Some(module) = module else { + if operands.is_empty() { + return Err(usage( + "napl add needs working-tree paths, or --module to stage a module's whole owned tree", + )); + } + return ok(&crate::staging::add_working_tree_paths( + &workspace, + cwd, + &target, + &operands, + None, + attribution, + usage_path, + no_verify, + check, + )?); + }; + let candidate_dir = match operands.as_slice() { + [only] => resolve_operand(cwd, &workspace, only).is_dir(), + _ => false, + }; + if !operands.is_empty() && !candidate_dir { + return ok(&crate::staging::add_working_tree_paths( + &workspace, + cwd, + &target, + &operands, + Some(&module), + attribution, + usage_path, + no_verify, + check, + )?); } - let after = text[name_len..].trim(); - let attrs = if after.is_empty() { - Value::Object(Map::new()) - } else if after.starts_with('{') && after.ends_with('}') { - parse_attributes(&after[1..after.len() - 1], line)? - } else { - return Err(invalid(format!( - "directive opener carries {after:?} where only an HTML-like `{{...}}` attribute block is allowed" - ))); + let source = match operands.as_slice() { + [] => crate::staging::AddSource::WorkingTree, + [only] => crate::staging::AddSource::CandidateDir(std::path::PathBuf::from(only)), + _ => unreachable!("more than one operand never resolves to a candidate directory"), }; - Ok((name.to_string(), attrs)) + let record = crate::staging::add( + &workspace, + &target, + &module, + &source, + attribution, + usage_path, + no_verify, + check, + )?; + ok(&crate::staging::staged_payload(&target, vec![record])) } -/// Parse an HTML-like attribute block: `{a}`, `{a=b}`, `{a="b"}`, and `{a='b'}` -/// are equivalent, `.class` shorthands accumulate, and a later `#id` overrides an -/// earlier one. -fn parse_attributes(text: &str, line: u32) -> std::result::Result { - let chars: Vec = text.chars().collect(); - let mut map = Map::new(); - let mut classes: Vec = Vec::new(); - let mut pos = 0usize; - let malformed = |msg: &str| Diag { rank: 2, line, msg: msg.to_string() }; - while pos < chars.len() { - if chars[pos].is_whitespace() { - pos += 1; - continue; - } - if chars[pos] == '.' || chars[pos] == '#' { - let kind = chars[pos]; - pos += 1; - let start = pos; - while pos < chars.len() && !chars[pos].is_whitespace() { - pos += 1; - } - let value: String = chars[start..pos].iter().collect(); - if value.is_empty() { - return Err(malformed("attribute shorthand carries no value")); - } - if kind == '.' { - classes.push(value); - } else { - map.insert("id".to_string(), Value::String(value)); - } - continue; - } - let start = pos; - while pos < chars.len() && !chars[pos].is_whitespace() && chars[pos] != '=' { - pos += 1; - } - let key: String = chars[start..pos].iter().collect(); - if key.is_empty() { - return Err(malformed("attribute name is empty")); - } - if pos >= chars.len() || chars[pos] != '=' { - map.insert(key, Value::String(String::new())); - continue; - } - pos += 1; - let value = if pos < chars.len() && (chars[pos] == '"' || chars[pos] == '\'') { - let quote = chars[pos]; - pos += 1; - let start = pos; - while pos < chars.len() && chars[pos] != quote { - pos += 1; - } - if pos >= chars.len() { - return Err(malformed("attribute value is not closed by its quote")); - } - let value: String = chars[start..pos].iter().collect(); - pos += 1; - value - } else { - let start = pos; - while pos < chars.len() && !chars[pos].is_whitespace() { - pos += 1; - } - chars[start..pos].iter().collect() - }; - map.insert(key, Value::String(value)); - } - if !classes.is_empty() { - map.insert("class".to_string(), Value::String(classes.join(" "))); +/// An operand as a path on disk, resolved the way `add` resolves working-tree +/// paths: workspace-relative first, then relative to the working directory. +fn resolve_operand(cwd: &Path, workspace: &Path, given: &str) -> std::path::PathBuf { + let direct = workspace.join(given); + if direct.exists() { + return direct; } - Ok(Value::Object(map)) + cwd.join(given) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_plain_scalar_preserves_an_embedded_double_quote() { - let body = "::: test\nname: quote case\nexpect: { note: ab\"cd }\n:::"; - let cases = extract_cases(body).expect("the block parses"); - assert_eq!(cases.len(), 1); - assert_eq!(cases[0].expect["note"], serde_json::json!("ab\"cd")); - } +/// `napl commit`: every staged record in wave order, or one named by `--staged`. +fn run_commit(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + let Some(record) = flag(args, "--staged")? else { + let (payload, exit_code) = crate::staging::commit_all(&workspace)?; + return Ok(CliResult { + stdout: canonical_bytes(&payload), + exit_code, + }); + }; + let record_path = if Path::new(&record).is_absolute() { + std::path::PathBuf::from(&record) + } else { + workspace.join(&record) + }; + let report = crate::staging::commit_staged(&workspace, &record_path); + Ok(CliResult { + stdout: canonical_bytes(&report.payload), + exit_code: outcome_exit_code(report.outcome), + }) +} - #[test] - fn a_block_reports_the_rank_four_grammar_error_over_a_missing_expect() { - let body = "::: test\nname: both wrong\ngiven: 5\n:::"; - let err = extract_cases(body).expect_err("the block is invalid"); - assert!( - err.detail.contains("given"), - "reports the rank-4 given grammar error: {}", - err.detail - ); - assert!( - !err.detail.contains("expect"), - "not the rank-6 missing-expect error: {}", - err.detail - ); +fn run_status(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + let target = positional(args, 1).unwrap_or_else(|_| "rust".to_string()); + if let Some(module) = flag(args, "--module")? { + return ok(&module_plan(&workspace, &target, &module)?); } + ok(&status(&workspace, &target)?) } -napl-store transition v1 875 -{"baseStateKey":"9b23b62a07e1249fc078c3851c727ecf191bfe6c99ff2f5644f5eedbd2ff94e2","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"ec475967a95606aace0108806431c47b4ab59d5215771372147250b520ea35ac","installPaths":["crates/napl/src/init.rs","crates/napl/src/watch.rs"],"module":"commands","newState":"cca37ce0ab52aabdb236250fe7211d03ae415524090fe1ea8d26ed32f63a0714","readSet":["base-state:9b23b62a07e1249fc078c3851c727ecf191bfe6c99ff2f5644f5eedbd2ff94e2","input-key:ec475967a95606aace0108806431c47b4ab59d5215771372147250b520ea35ac","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"6082f8faf8471d056527c0b18f3396e9662b83a27b926af904f846768ceaf845","surface":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust","writeSet":["owned:crates/napl","module-state:rust/commands"]} -napl-store snapshot v1 671 -{"contractVersion":4,"event":"155528aea66782fbd67555be27e392c8cee0d0ce7022ec10eecb2f39cb50ecf4","states":[{"module":"commands","state":"cb82ac8a35d902954b841fa6b0c14cd03d99e15d69b8005c3986d5ad66e22b29","target":"rust"},{"module":"napl","state":"520ca02fb2a0fb05deb02a3386c9aa14433e7218e6e6abf11fbaa13c273bee21","target":"rust"},{"module":"napl_store","state":"7ad4dba399677a4a801444f26c938bc4594731235bcccd9a979e1728b21f1822","target":"rust"},{"module":"query","state":"bc9f18a34748d245185298746e0db6fcac33afe57ddff8836927bb668f4172b6","target":"rust"},{"module":"usage_proxy","state":"83e7eb0b234bbffd0573d91bcf92e594e91a0a15b536e6d5c915a965fe0d4309","target":"rust"}]} -napl-store module-state v1 635 -{"attribution":{"promptHash":"d3b4f7308b35a3105c66bbbbec005cb91288ecd8476e45e4e46019e1dda2d3f6","record":"edd2ba44a2a0c8e6b1f6a328a617821ef6949cf4455658fe9c7b9f80935a0b97","stale":false},"contractVersion":4,"inputKey":"4d1fb17243be4b78e491396c6d7f09490975c5851b5c2f191f4e10798848ccc4","module":"serve","outputTree":"505d5f544144525d73def85bf0b3b9e16230ea634599f4d343080af78d359024","prompt":"552d4c4d5476b26a91d581f0c430dfaf8b2d1f3d7eed2bc6c808e4148a666cc7","sliceHash":"a798c8c609ac68ef2ad8eaa774d5dae53efe4a6ab0a13582db815e77e1cf6d2c","surfaceHash":"0a742d0232e4b7000bd79ff29b39af501675254174d2c98163f8b7337cf7f0b7","target":"rust"} -napl-store snapshot v1 891 -{"contractVersion":4,"event":"f64e1f03a1390adb272fdc6562a83d949641740535efa1281cdbddd85f3506e4","states":[{"module":"agent","state":"d0d406dd4f30ea6840a808bcbc3ddeea8ec8cc10c84de12ab5d0cfeef7bb6696","target":"rust"},{"module":"commands","state":"ab9aba5c093920082ceb592429a727d642cf559ac435b93db5e525ee4ba1b2a4","target":"rust"},{"module":"napl","state":"a2c25e6d7aa8dab2c129f662991afa8d79ff647831687a5759a8ddae398d7041","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"c47a40c3211c7c349ffae5b69c3698f9a7818190e661d9391194acc19568d38f","target":"rust"},{"module":"serve","state":"70c656393697d4192e4f4cadf2217370c3556a0ec85655605fb63deb7a400973","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} -napl-store blob v1 62461 -//! The read commands and the maintenance commands. -//! -//! Every read resolves HEAD exactly once and then answers entirely from objects -//! reachable from that snapshot. No read creates a directory, takes a lock, or -//! writes a byte, so the whole read surface works on a read-only filesystem. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; - -use crate::blame::{blame, blame_json, untracked_json, Revision}; -use crate::compile::{ - compile, graph_for, install_index, read_surface_items, state_of, verdict as pending_verdict, -}; -use crate::cost::{cost_report, line_from_reported, CostLine}; -use crate::error::{error, OutcomeCode, Result}; -use crate::graph::{topological_levels, Graph}; -use crate::slice::receipt_requirements; -use crate::lint::{lint, lint_json, LintInput, LintReport}; -use crate::maintenance::{ - fsck, fsck_json, gc_json, plan_restore, plan_sweep, reachable_from_head, restore_json, - FileFact, RestorePlan, -}; -use crate::objects::{ - event_transition, read_module_state, snapshot_event, snapshot_states, walk_events, ModuleState, -}; -use crate::receipt::normalize_reported; -use crate::store_view::{ - flatten_tree, get_blob, get_json, get_object, has_object, ObjectSource, -}; -use crate::surface::surface_json; -use crate::target::{ - builtin_target, is_manifest_path, owned_globs, placement_root, projection_owned_paths, TargetDef, -}; -use crate::textdiff::render_drift; -use napl_store::store_hash::ObjectKind; -use serde_json::{json, Value}; -use crate::disk::{self, Store}; -use crate::workspace::{discover_sources, read_working_text}; +fn run_log(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + ok(&log(&workspace, flag(args, "--module")?.as_deref())?) +} -/// A snapshot-consistent status report for one target: observation, zero -/// mutation. -pub fn status(workspace: &Path, target: &str) -> Result { - let store = disk::open(workspace); - let head = store.head()?; - let states = match &head { - Some(snapshot) => snapshot_states(&store, snapshot)?, - None => BTreeMap::new(), - }; - let sources = discover_sources(workspace)?; - let artifact = compile(&store, &sources, target, None)?; - let pending: Vec = artifact - .slices - .iter() - .map(|slice| slice.module.clone()) - .collect(); - let frontier: BTreeSet = pending.iter().cloned().collect(); - let target_def = builtin_target(target)?; - let installs = install_index(&store, head.as_deref())?; +fn run_show(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + ok(&show(&workspace, &positional(args, 1)?)?) +} - let mut modules: Vec = Vec::new(); - for ((state_target, module), state_hash) in &states { - if state_target != target { - continue; - } - let state = read_module_state(&store, state_hash)?; - let freshness = attribution_freshness(Some(&state), module, target, &sources); - let prompt_stale = freshness["status"].as_str() != Some("fresh"); - let ends = both_ends( - workspace, - &store, - &state, - prompt_stale, - &pending_body(&sources, module), - )?; - let is_pending = pending.contains(module); - let verdict = pending_verdict( - &store, - &sources, - &target_def, - module, - &states, - &installs, - &frontier, - ends.drift, - )?; - let (reason, reasons, reland) = verdict.json_fields(is_pending); - modules.push(json!({ - "attribution": freshness, - "baseStateKey": state_hash, - "drift": ends.drift, - "inputKey": state.input_key, - "module": module, - "outputTree": state.output_tree, - "pending": is_pending, - "pendingReason": reason, - "pendingReasons": reasons, - "relandIdenticalBytes": reland, - "sliceHash": state.slice_hash, - "staleReferences": ends.stale_references as u64, - "surfaceHash": state.surface_hash, - })); - } - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "head": head, - "manifestDiagnostics": manifest_diagnostics(workspace, target, &sources), - "modules": modules, - "pending": pending, - "target": target, - })) +fn run_cat_file(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + Ok(CliResult { + stdout: cat_file(&workspace, &positional(args, 1)?)?, + exit_code: 0, + }) } -/// What status observes about a module's code side, so status judges both ends. -/// -/// `drift` is true when the committed output differs from the working tree, and -/// `stale_references` counts the module's references reading stale right now. Both -/// come from the derivations `diff` and the language server already serve, which -/// is the whole point: status can never read clean while the query surface reads -/// stale, because there is one derivation and not two. -struct BothEnds { - drift: bool, - stale_references: usize, +fn run_surface(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + let target = positional(args, 1)?; + let module = require_flag(args, "--module")?; + ok(&surface_of(&workspace, &target, &module)?) } -fn both_ends( - workspace: &Path, - store: &Store, - state: &ModuleState, - prompt_stale: bool, - pending_body: &str, -) -> Result { - let mut drift = false; - let mut drifted_paths: BTreeSet = BTreeSet::new(); - if has_object(store, &state.output_tree) { - for (path, (blob, _mode)) in flatten_tree(store, &state.output_tree)? { - let bytes = std::fs::read(workspace.join(&path)).ok(); - // An unreadable working file is not drift: the reader knows nothing - // about it, so it claims nothing, exactly as the query surface does. - if crate::attribution::code_drifted(Some(blob.as_str()), bytes.as_deref()) { - drift = true; - drifted_paths.insert(path); - } - } - } - let entries = match &state.attribution { - Some(recorded) if has_object(store, &recorded.record) => { - let bytes = get_blob(store, &recorded.record)?; - crate::attribution::parse(&String::from_utf8_lossy(&bytes)) - .map(|record| record.entries) - .unwrap_or_default() +/// `napl completions `: a completion script generated from the registry. +/// +/// The script is derived from the same `clap::Command` tree parsing and help are +/// built from, so a completion can never offer a flag the grammar does not +/// declare. It is a pure read on stdout: no workspace is resolved, no store is +/// opened, nothing is written. An unknown shell name is a usage error under the +/// command line contract, naming the shells that exist. +fn run_completions(_cwd: &Path, args: &[String]) -> Result { + let shell = positional(args, 1).map_err(|_| { + usage(&format!( + "napl completions needs a shell name, one of {}", + COMPLETION_SHELLS.join(", ") + )) + })?; + let generator = match shell.as_str() { + "bash" => clap_complete::Shell::Bash, + "zsh" => clap_complete::Shell::Zsh, + "fish" => clap_complete::Shell::Fish, + other => { + return Err(usage(&format!( + "unknown shell {other:?}: napl completions generates {}", + COMPLETION_SHELLS.join(", ") + ))) } - _ => Vec::new(), }; - let mut stale_references = 0usize; - for entry in &entries { - // A reference a capture verb recorded carries the content hash of each - // endpoint's post-edit bytes, and freshness reads those first. While the - // working tree still holds exactly those bytes the reference is fresh, - // whatever the committed baseline says: recording a reference and - // advancing the baseline are different acts, and the verb's own edit must - // never be counted as debt the verb created. - let working = entry - .file - .as_ref() - .and_then(|file| std::fs::read(workspace.join(file)).ok()); - let code_drift = entry - .file - .as_ref() - .map(|file| drifted_paths.contains(file)) - .unwrap_or(false); - if crate::attribution::entry_reference_status( - entry, - prompt_stale, - false, - code_drift, - working.as_deref(), - pending_body, - ) - .stale - { - stale_references += 1; - } - } - Ok(BothEnds { - drift, - stale_references, + let mut root = clap_root(&crate::registry()); + let mut script: Vec = Vec::new(); + clap_complete::generate(generator, &mut root, "napl", &mut script); + Ok(CliResult { + stdout: script, + exit_code: 0, }) } -/// The module's pending normalized prompt body, or the empty body when the -/// workspace no longer has a prompt for it. -fn pending_body(sources: &[crate::source::SourceModule], module: &str) -> String { - sources - .iter() - .find(|source| source.module == module) - .map(|source| source.normalized_body.clone()) - .unwrap_or_default() -} - -/// The module PLAN beside the module's live STATE. -/// -/// The plan is the invariant requirements a conforming implementation derives -/// from the working tree's prompts alone: owned globs, placement root, projection -/// ownership, receipt requirements, wave position, never a candidate-dependent -/// fact, which is `add`'s job. The state is what is true of the module right now: -/// tracked or pending, drift, stale reference count, attribution status. The -/// command named status never answers with a plan alone. +/// `napl diff`, which means nothing without a committed baseline to compare +/// against. A headless workspace gets the prerequisite sequence, and a target no +/// committed module owns anything under answers `tracked: false` with its reason +/// rather than the empty patch that would read as "no drift". /// -/// The payload also carries the derived entry-file projection, path and exact -/// content, computed the way commit computes it but over the working tree, so the -/// line a new file needs in a shared entry file is read from the tool rather than -/// composed by hand. -pub fn module_plan(workspace: &Path, target_name: &str, module: &str) -> Result { - let sources = discover_sources(workspace)?; - let target = builtin_target(target_name)?; - let source = sources.iter().find(|s| s.module == module).ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} has no prompt file in the workspace"), - ) - })?; - let graph = graph_for(&sources, target_name)?; - let levels = topological_levels(&graph)?; - let wave = *levels.get(module).ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} is not in the graph for target {target_name:?}"), - ) - })?; - let root = placement_root(source); - - let store = disk::open(workspace); - let artifact = compile(&store, &sources, target_name, None)?; - let pending = artifact.slices.iter().any(|slice| slice.module == module); - let frontier: BTreeSet = artifact - .slices - .iter() - .map(|slice| slice.module.clone()) - .collect(); - let head = store.head()?; - let head_states = crate::compile::head_states(&store, head.as_deref())?; - let installs = install_index(&store, head.as_deref())?; - let committed = committed_state(&store, target_name, module)?; - let state = match &committed { - Some(state) => { - let freshness = attribution_freshness(Some(state), module, target_name, &sources); - let prompt_stale = freshness["status"].as_str() != Some("fresh"); - let ends = both_ends( - workspace, - &store, - state, - prompt_stale, - &pending_body(&sources, module), - )?; - let verdict = pending_verdict( - &store, - &sources, - &target, - module, - &head_states, - &installs, - &frontier, - ends.drift, - )?; - let (reason, reasons, reland) = verdict.json_fields(pending); - json!({ - "attribution": freshness, - "drift": ends.drift, - "pending": pending, - "pendingReason": reason, - "pendingReasons": reasons, - "relandIdenticalBytes": reland, - "staleReferences": ends.stale_references as u64, - "tracked": true, - }) - } - // A module with no committed state is not tracked, and saying so is the - // honest answer: it has no baseline to drift from and no references to - // stale, so those read false and zero because they are, not because - // nothing was looked at. - None => { - let verdict = pending_verdict( - &store, - &sources, - &target, - module, - &head_states, - &installs, - &frontier, - false, - )?; - let (reason, reasons, reland) = verdict.json_fields(pending); - json!({ - "attribution": attribution_freshness(None, module, target_name, &sources), - "drift": false, - "pending": pending, - "pendingReason": reason, - "pendingReasons": reasons, - "relandIdenticalBytes": reland, - "staleReferences": 0, - "tracked": false, - }) - } - }; - - let (projection_path, projection_bytes) = - working_tree_projection(workspace, &store, &target, &sources, module, &root)?; - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "module": module, - "plan": { - "firstOutput": first_output(&target, &root, module), - "ownedGlobs": owned_globs(&target, &root), - "placementRoot": root, - "projection": { - "content": String::from_utf8_lossy(&projection_bytes), - "ownership": PROJECTION_OWNERSHIP, - "owner": PROJECTION_OWNER, - "path": projection_path, - "userSupplied": false, - }, - "projectionOwned": projection_owned_paths(&target, &root), - "projections": projection_labels(&target, &root), - "receiptRequirements": receipt_requirements(), - "wave": wave as u64, - }, - "state": state, - "target": target_name, - })) +/// The operand is a target name and the usage line spells it ``. An +/// operand that is not one — a module name, a file path — is a usage error that +/// names the target names which exist, so the argument teaches its own +/// vocabulary instead of being reverse engineered from a store-integrity code. +fn run_diff(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + let given = positional(args, 1)?; + let known = crate::target::builtin_target_names(); + if !known.contains(&given) { + return Err(usage(&format!( + "{given:?} is not a target name; the target names that exist are {}", + known.join(", ") + ))); + } + if !crate::porcelain::has_committed_head(&workspace)? { + return Ok(emit_prerequisite("napl diff")); + } + ok(&diff(&workspace, &given)?) } -/// What a projection path is, said in the plan every time one is mentioned. -/// -/// The obvious wrong first move in a cold module is to hand-write the crate's -/// entry file, because it is the file that visibly has to change for a new -/// source file to be reachable. It is derived, it is owned by no module, and a -/// candidate carrying it is rejected. Saying so beside the path is cheaper than -/// the rejection that would otherwise teach it. -pub const PROJECTION_OWNERSHIP: &str = "tool-derived"; -pub const PROJECTION_OWNER: &str = - "no module: this path is recomputed by commit from the crate's committed module file sets and is never user-supplied; a candidate that carries it is rejected as invalid-candidate"; - -/// Every projection path this module's crate has, each labeled for what it is. -pub fn projection_labels(target: &TargetDef, root: &str) -> Vec { - projection_owned_paths(target, root) - .into_iter() - .map(|path| { - json!({ - "ownership": PROJECTION_OWNERSHIP, - "owner": PROJECTION_OWNER, - "path": path, - "userSupplied": false, - }) - }) - .collect() +/// `napl blame`, under the same rule: no head means the prerequisite sequence, and +/// a path no committed module owns is untracked rather than a file with no +/// ancestry. +fn run_blame(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + if !crate::porcelain::has_committed_head(&workspace)? { + return Ok(emit_prerequisite("napl blame")); + } + ok(&blame_path(&workspace, &positional(args, 1)?)?) } -/// One concrete legal first output path for a module: the module's own name -/// under the target's source directory, inside the placement root. +/// `napl lint`, whose reachability analysis has one prerequisite it will not +/// guess. With no declared roots the refusal is loud: canonical JSON naming the +/// missing prerequisite and the exact declaration that satisfies it, exit 2, and +/// no `findings` field at all, because an empty findings list under exit 0 would +/// read as "nothing is wrong" when the truth is "nothing was checked". /// -/// A plan that lists globs and a projection but no example leaves the author to -/// pick a path and find out afterwards whether it was ownable. Naming one that -/// certainly is turns the first move into a copy. -pub fn first_output(target: &TargetDef, root: &str, module: &str) -> Value { - let path = crate::paths::join_root( - root, - &format!( - "{}/{}.{}", - target.source_dir, - crate::paths::encode_name(module), - target.source_extension - ), - ); - json!({ - "detail": "one concrete path this module may own: its own name under the target's source directory, inside the placement root; any path matching the owned globs that is not a projection or a manifest is equally legal", - "path": path, +/// The source-syntax scope sets the exit code of a target lint: any finding is a +/// fact about bytes that do not parse, so it exits 1 and a clean scan exits 0. +/// The graph scope stays advisory, its findings being proposals rather than +/// facts. +fn run_lint(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + let target = positional(args, 1)?; + let source_scan = crate::porcelain::source_syntax_scan(&workspace, &target)?; + let source_findings = source_scan["findings"] + .as_array() + .map(|findings| findings.len()) + .unwrap_or(0); + let source_exit_code = if source_findings > 0 { 1 } else { 0 }; + let (_, report, mut value) = lint_workspace(&workspace, &target)?; + if let Some(object) = value.as_object_mut() { + object.insert("sourceSyntax".to_string(), source_scan.clone()); + object.insert( + "scopes".to_string(), + json!([crate::lint::GRAPH_SCOPE, crate::surface::SOURCE_SYNTAX_SCOPE]), + ); + } + if report.unreachable_analysis == Analysis::Refused { + let bytes = canonical_bytes(&refusal_json(source_scan)); + let stderr = std::io::stderr(); + let mut lock = stderr.lock(); + let _ = lock.write_all(&bytes); + let _ = lock.flush(); + return Ok(CliResult { + stdout: Vec::new(), + exit_code: USAGE_EXIT_CODE, + }); + } + if !has_switch(args, "--fix") { + return Ok(CliResult { + stdout: canonical_bytes(&value), + exit_code: source_exit_code, + }); + } + let mut applied: Vec = Vec::new(); + let mut exit_code = source_exit_code; + for finding in report.findings.iter().filter(|f| is_auto_fixable(f)) { + let outcome = apply_fix(&workspace, &target, finding)?; + if outcome.outcome != OutcomeCode::Success { + exit_code = outcome_exit_code(outcome.outcome); + } + applied.push(outcome.payload); + } + Ok(CliResult { + stdout: canonical_bytes(&json!({ + "applied": applied, + "contractVersion": crate::version::CONTRACT_VERSION, + "report": lint_json(&report), + })), + exit_code, }) } -/// The crate's entry-file projection as commit would derive it if this module -/// were committed from the working tree right now. -/// -/// The inputs are the other modules' committed file sets plus this module's -/// working-tree files, and the command providers are read the same way: from the -/// other modules' recorded surfaces plus this module's working-tree surface. That -/// is what makes the answer useful for a file that is new: a projection derived -/// from committed state alone could never name it, and naming it is the whole -/// question a reader asks here. -fn working_tree_projection( - workspace: &Path, - store: &Store, - target: &TargetDef, - sources: &[crate::source::SourceModule], - module: &str, - root: &str, -) -> Result<(String, Vec)> { - let src_root = crate::paths::join_root(root, &target.source_dir); - let mut files: Vec = Vec::new(); - let mut providers: Vec = Vec::new(); +fn run_cost(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + let names = positionals_from(args, 1); + let files = repeated_flag(args, "--file"); + let sessions = repeated_flag(args, "--session"); + ok(&cost(&workspace, &names, &files, &sessions)?) +} - let states = match store.head()? { - Some(head) => snapshot_states(store, &head)?, - None => BTreeMap::new(), - }; - for ((state_target, other), hash) in &states { - if state_target != &target.name || other == module { - continue; - } - let state = read_module_state(store, hash)?; - if !has_object(store, &state.output_tree) { - continue; - } - let owned: Vec = flatten_tree(store, &state.output_tree)?.into_keys().collect(); - if !owned - .iter() - .any(|path| crate::paths::is_under(path, &src_root)) - { - continue; - } - files.extend(owned); - for item in read_surface_items(store, &state.surface_hash)? { - if let Some(provider) = command_provider(&item.kind, &item.path) { - providers.push(provider); - } - } - } +fn run_gc(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + ok(&gc(&workspace, has_switch(args, "--apply"))?) +} - let working = working_owned_files(workspace, store, target, sources, module)?; - for item in crate::surface::extract_surface(target, root, &working)? { - if let Some(provider) = command_provider(&item.kind, &item.path) { - providers.push(provider); - } +/// `napl store `: today exactly one operation, and an unknown one is +/// a grammar failure like any other undeclared token. +fn run_store(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + let operation = positional(args, 1) + .map_err(|_| usage("napl store needs an operation, and the only one is migrate"))?; + if operation != "migrate" { + return Err(usage(&format!( + "unknown store operation {operation:?}: napl store performs migrate" + ))); } - files.extend(working.into_keys()); - Ok(crate::target::derive_lib_projection( - target, root, &files, &providers, - )) + let (migrated, value) = crate::porcelain::store_migrate(&workspace)?; + Ok(CliResult { + stdout: canonical_bytes(&value), + exit_code: if migrated { 0 } else { USAGE_EXIT_CODE }, + }) } -/// The module stem a surface item declares a command table for, when it declares -/// one: the convention is one `commands` function per module file. -fn command_provider(kind: &str, path: &str) -> Option { - if kind != "fn" { - return None; - } - let prefix = path.strip_suffix("::commands")?; - (!prefix.is_empty() && !prefix.contains(' ')).then(|| prefix.to_string()) +fn run_fsck(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + let (healthy, value) = fsck_store(&workspace)?; + Ok(CliResult { + stdout: canonical_bytes(&value), + exit_code: if healthy { + 0 + } else { + outcome_exit_code(OutcomeCode::StoreIntegrity) + }, + }) } -/// A module's owned files as they sit in the working tree: the committed paths -/// read from disk, plus the paths under its globs no module has committed. -fn working_owned_files( - workspace: &Path, - store: &Store, - target: &TargetDef, - sources: &[crate::source::SourceModule], - module: &str, -) -> Result>> { - let mut out: BTreeMap> = BTreeMap::new(); - if let Some(state) = committed_state(store, &target.name, module)? { - if has_object(store, &state.output_tree) { - for path in flatten_tree(store, &state.output_tree)?.into_keys() { - if let Ok(bytes) = std::fs::read(workspace.join(&path)) { - out.insert(path, bytes); - } - } - } +fn run_restore(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + let target = positional(args, 1)?; + ok(&restore(&workspace, &target, has_switch(args, "--apply"))?) +} + +fn run_recover(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + let store = disk::init(&workspace)?; + ok(&json!({ + "applied": recover(&store)?, + "contractVersion": crate::version::CONTRACT_VERSION, + "outcome": "success", + })) +} + +/// `napl notes add`, the third command whose meaning requires a committed head: +/// a note attaches to a recorded transition, and a workspace with no head has +/// none to attach to. The headless-prerequisite set is exactly `diff`, `blame`, +/// and `notes add`; `log` and `show` keep their committed contracts and answer +/// empty honestly. +fn run_notes(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + if positional(args, 1)? != "add" { + return Err(usage("notes takes the subcommand `add`")); } - for path in crate::staging::unclaimed_working_paths(workspace, store, target, module, sources)? { - if let Ok(bytes) = std::fs::read(workspace.join(&path)) { - out.insert(path, bytes); - } + if !crate::porcelain::has_committed_head(&workspace)? { + return Ok(emit_prerequisite("napl notes add")); } - Ok(out) + let transition = require_flag(args, "--to")?; + let note = require_flag(args, "--note")?; + ok(&add_note(&workspace, &transition, ¬e)?) } -/// Observe, never mutate, the manifest/placement mismatches the toolchain now -/// only diagnoses: a module placed in a crate the workspace manifest does not -/// list, or a crate directory missing a manifest. Manifests are user-owned -/// seams, so these are reports, not writes. -fn manifest_diagnostics( - workspace: &Path, - target_name: &str, - sources: &[crate::source::SourceModule], -) -> Vec { - let Ok(target) = builtin_target(target_name) else { - return Vec::new(); - }; - let members = workspace_members(workspace, &target); - let mut roots: Vec = sources.iter().map(placement_root).collect(); - roots.sort(); - roots.dedup(); - let mut out: Vec = Vec::new(); - for root in roots { - if root.is_empty() { - continue; - } - if let Some(members) = &members { - if !members.contains(&root) { - out.push(manifest_diagnostic( - &root, - // The manifest this observation means is the workspace-root - // one, which holds the member list, not the crate's own. - &target.manifest_file, - "crate-not-listed-in-workspace-manifest", - &format!("the crate {root:?} holds a placed module but the workspace manifest does not list it as a member"), - )); - } - } - let manifest = workspace.join(&root).join(&target.manifest_file); - if !manifest.is_file() { - out.push(manifest_diagnostic( - &root, - &crate::paths::join_root(&root, &target.manifest_file), - "crate-directory-missing-manifest", - &format!("the crate directory {root:?} receives placed modules but has no manifest"), - )); - } - } - out +fn run_mv(cwd: &Path, args: &[String]) -> Result { + let workspace = resolve_workspace(cwd, args)?; + let module = positional(args, 1)?; + let dest = positional(args, 2)?; + let target = flag(args, "--target")?.unwrap_or_else(|| "rust".to_string()); + let report = relocate_module(&workspace, &target, &module, &dest); + Ok(CliResult { + stdout: canonical_bytes(&report.payload), + exit_code: outcome_exit_code(report.outcome), + }) } -/// One manifest observation, which always locates the file it means. -/// -/// A manifest is a user-owned seam: the toolchain never writes one, so a -/// diagnostic that only alluded to "the manifest" would leave the reader hunting -/// for the file they are the only one allowed to edit. Every diagnostic therefore -/// carries the exact workspace-relative path and says plainly whose file it is. -fn manifest_diagnostic( - crate_root: &str, - manifest: &str, - observation: &str, - detail: &str, -) -> Value { - json!({ - "crate": crate_root, - "detail": detail, - "manifest": manifest, - "observation": observation, - "owner": "user", - "remedy": format!("{manifest} is yours to edit; the toolchain diagnoses manifests and never writes them"), +fn run_lsp(_cwd: &Path, _args: &[String]) -> Result { + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + crate::lsp::serve(&mut stdin.lock(), &mut stdout.lock()); + Ok(CliResult { + stdout: Vec::new(), + exit_code: 0, }) } +napl-store snapshot v1 781 +{"contractVersion":4,"event":"6f9cb496dfb5bdf61d6135b05a0bb296a7e89b97e87b7b47a9cff2cd527eae4c","states":[{"module":"agent","state":"7c568214148bdacf0c078e2420e1c8355c24100c703f3490f65ca9bcf1f64535","target":"rust"},{"module":"commands","state":"cca37ce0ab52aabdb236250fe7211d03ae415524090fe1ea8d26ed32f63a0714","target":"rust"},{"module":"napl","state":"a3ebf3249a0d13b8b6d8fbfae95a896bb8c148b98b23e11aef070b51b624058d","target":"rust"},{"module":"napl_store","state":"7011890f658fb28bd29684802e5e53bb5d54d54d3c5a64cc926a56fae7ef944d","target":"rust"},{"module":"query","state":"d8753add6fb4489381d764e2d8abd5964e1eb8a5435669bfc1ac296d9739ffcf","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} +napl-store blob v1 5838 +//! Per-module compile slices and the derived keys that pin them. +//! +//! A slice is everything a producer needs and nothing it does not. There is no +//! provider, model, prompt assembly, credential, timestamp, retry policy, price, +//! or test result anywhere in this file — those live outside the compiler by +//! construction, not by convention. -/// The crate directories the workspace-root manifest lists as members, or -/// `None` when there is no readable workspace manifest to compare against. -fn workspace_members(workspace: &Path, target: &TargetDef) -> Option> { - let text = std::fs::read_to_string(workspace.join(&target.manifest_file)).ok()?; - let mut members: Vec = Vec::new(); - for line in text.lines() { - let trimmed = line.trim().trim_end_matches(','); - if let Some(inner) = trimmed - .strip_prefix('"') - .and_then(|rest| rest.strip_suffix('"')) - { - if !is_manifest_path(target, inner) && inner.contains('/') { - members.push(inner.to_string()); - } - } - } - Some(members) +use napl_store::store_hash::ObjectKind; +use serde_json::{json, Map, Value}; + +use crate::canon::json_hash; +use crate::objects::ModuleState; +use crate::receipt::RECEIPT_VERSION; +use crate::source::SourceModule; +use crate::surface::{surface_json, SurfaceItem}; +use crate::target::{ + owned_globs, placement_facts, placement_root, projection_owned_paths, surface_grammar, + target_def_hash, TargetDef, +}; + +/// A dependency's recorded surface as it enters a slice. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DepSurface { + pub module: String, + pub surface_hash: String, + pub items: Vec, } -/// Classify a module's attribution freshness and, when it is not fresh, name the -/// exact fix. -/// -/// It is a pure observation over content already resolved: an absent record, a -/// record installed unproven, or a record whose recorded prompt hash no longer -/// matches the current normalized body all read as needing a fresh commit. A -/// record proven against the body still on disk is fresh. -fn attribution_freshness( - state: Option<&ModuleState>, - module: &str, - target: &str, - sources: &[crate::source::SourceModule], -) -> Value { - let directive = json!({ - "command": "commit", - "directive": format!( - "commit module {module:?} for target {target:?} with a fresh attribution update" - ), - "module": module, - "target": target, - }); - match state.and_then(|state| state.attribution.as_ref()) { - None => json!({ "fix": directive, "status": "absent" }), - Some(attribution) => { - let current = sources - .iter() - .find(|source| source.module == module) - .map(|source| source.normalized_source_hash.as_str()); - if attribution.stale { - json!({ - "fix": directive, - "reason": "installed with --no-verify", - "status": "stale", - }) - } else if current != Some(attribution.prompt_hash.as_str()) { - json!({ - "fix": directive, - "reason": "the prompt changed since the attribution was recorded", - "status": "stale", - }) - } else { - json!({ "status": "fresh" }) - } - } - } +/// One module's compile slice. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompileSlice { + pub module: String, + pub target: String, + pub input_key: String, + pub base_state_key: String, + pub slice_hash: String, + pub wave: usize, + pub body: Value, } -/// The causal history reachable from HEAD, optionally narrowed to one module. +/// The closure manifest whose hash is the module's `inputKey`. /// -/// The `ordinal` field is a presentation device computed from the causal graph -/// at read time. It is never stored and never enters an identity. -pub fn log(workspace: &Path, module: Option<&str>) -> Result { - let store = disk::open(workspace); - let Some(head) = store.head()? else { - return Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "entries": [], - "head": Value::Null, - })); - }; - let mut entries: Vec = Vec::new(); - for (ordinal, event) in causal_order(&store, &head)?.iter().enumerate() { - let transition_id = event_transition(&store, event)?; - let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; - if let Some(wanted) = module { - if transition["module"].as_str() != Some(wanted) { - continue; - } - } - entries.push(json!({ - "event": event, - "module": transition["module"], - "newState": transition["newState"], - "noteCount": attached_notes(&store, &transition_id)?.len() as u64, - "ordinal": ordinal as u64, - // A reference riding a transition is provenance exactly as a note is, - // so the log counts both and names the one hop that serves their full - // text. Provenance a reader cannot find is provenance that may as well - // not be recorded. - "referenceCount": transition_references(&store, &transition)?.len() as u64, - "target": transition["target"], - "transition": transition_id, - "writeSet": transition["writeSet"], - })); - } - Ok(json!({ +/// It names the normalized source, every direct dependency's *recorded* surface +/// hash, the placement facts, the entry-point version, the target and its +/// definition hash, and the contract version. Nothing else can move it. +pub fn closure_manifest(source: &SourceModule, target: &TargetDef, deps: &[DepSurface]) -> Value { + let encoded: Vec = deps + .iter() + .map(|dep| json!({ "module": dep.module, "surfaceHash": dep.surface_hash })) + .collect(); + json!({ "contractVersion": crate::version::CONTRACT_VERSION, - "entries": entries, - "head": head, - "inspect": { - "command": "napl show ", - "detail": "the full text of a transition's notes and reference entries is served by `napl show `, one named hop from any entry's transition hash", - }, - })) + "deps": encoded, + "entryPointVersion": target.entry_point_version, + "normalizedSourceHash": source.normalized_source_hash, + "placement": placement_facts(target, &placement_root(source)), + "target": target.name, + "targetDefHash": target_def_hash(target), + }) } -/// Describe any stored object: its kind and, for JSON kinds, its payload. -/// -/// A transition also lists the notes attached to it inline, hash and text, so a -/// note attached is a note findable: nobody should have to know that notes live -/// behind an index and are stored as blobs in order to read one back. -pub fn show(workspace: &Path, hash: &str) -> Result { - let store = disk::open(workspace); - let (kind, payload) = get_object(&store, hash)?; - let body = if kind == ObjectKind::Blob { - json!({ "bytes": payload.len() as u64 }) - } else { - serde_json::from_slice(&payload).map_err(|e| { - error( - OutcomeCode::StoreIntegrity, - format!("object {hash} is not JSON: {e}"), - ) - })? - }; - let mut out = json!({ - "body": body, - "contractVersion": crate::version::CONTRACT_VERSION, - "hash": hash, - "kind": kind.as_wire(), - }); - if kind == ObjectKind::Transition { - out["notes"] = Value::Array(attached_notes(&store, hash)?); - out["references"] = Value::Array(transition_references(&store, &out["body"])?); - } - Ok(out) +/// Compute a module's `inputKey`. +pub fn input_key(source: &SourceModule, target: &TargetDef, deps: &[DepSurface]) -> String { + json_hash(ObjectKind::Blob, &closure_manifest(source, target, deps)) } -/// The reference entries one transition introduced, served inline. -/// -/// A reference attached to a transition is exactly as findable as a note -/// attached to one: both are provenance riding a transition, and both are read -/// here rather than through plumbing. What this transition introduced is the -/// difference between its new state's attribution record and the record its base -/// state carried, so a commit that merely carried the module's existing -/// references forward lists none and a capture verb's transition lists the one it -/// recorded. A base that cannot be resolved degrades to the whole new record -/// rather than erroring, the way every other read here degrades. -fn transition_references(store: &Store, body: &Value) -> Result> { - let Some(new_state) = body["newState"].as_str() else { - return Ok(Vec::new()); - }; - let Some(record) = state_attribution_entries(store, new_state)? else { - return Ok(Vec::new()); - }; - let base = match body["baseStateKey"].as_str() { - Some(base) => state_attribution_entries(store, base)?.unwrap_or_default(), - None => Vec::new(), - }; - Ok(record - .into_iter() - .filter(|entry| !base.contains(entry)) - .map(|entry| entry.as_json()) - .collect()) +/// What a receipt must carry, and what it may optionally report. +pub fn receipt_requirements() -> Value { + json!({ + "optionalReported": ["evidence", "gates", "notes", "producer", "usage"], + "receiptVersion": RECEIPT_VERSION, + "required": [ + "baseStateKey", + "baseStoreSnapshot", + "baseWorkspaceHash", + "candidateTreeHash", + "inputKey", + "module", + "outputManifest", + "receiptVersion", + "sliceHash", + "target", + ], + }) } -/// The attribution entries a module state's record carries, or `None` when the -/// state names no record or the record is not readable. -fn state_attribution_entries( - store: &Store, - state_hash: &str, -) -> Result>> { - if !has_object(store, state_hash) { - return Ok(None); - } - let state = read_module_state(store, state_hash)?; - let Some(attribution) = state.attribution else { - return Ok(None); - }; - if !has_object(store, &attribution.record) { - return Ok(None); +/// Build a module's compile slice. +pub fn build_slice( + source: &SourceModule, + target: &TargetDef, + deps: &[DepSurface], + base: &ModuleState, + base_state_key: &str, + base_files: &[(String, String)], + wave: usize, +) -> CompileSlice { + let input_key = input_key(source, target, deps); + let dependencies: Vec = deps + .iter() + .map(|dep| { + json!({ + "module": dep.module, + "surface": surface_json(&dep.module, &target.name, &dep.items), + "surfaceHash": dep.surface_hash, + }) + }) + .collect(); + let base_manifest: Vec = base_files + .iter() + .map(|(path, hash)| json!({ "hash": hash, "path": path })) + .collect(); + + let root = placement_root(source); + let mut body = Map::new(); + body.insert( + "allowedOutputs".into(), + json!({ + "ownedGlobs": owned_globs(target, &root), + "projectionOwned": projection_owned_paths(target, &root), + "root": root, + "surfaceGrammar": surface_grammar(target), + }), + ); + body.insert( + "baseOutput".into(), + json!({ + "baseStateKey": base_state_key, + "files": base_manifest, + "outputTree": base.output_tree, + "surfaceHash": base.surface_hash, + }), + ); + body.insert("contractVersion".into(), json!(crate::version::CONTRACT_VERSION)); + body.insert("dependencies".into(), Value::Array(dependencies)); + body.insert("inputKey".into(), json!(input_key)); + body.insert("module".into(), json!(source.module)); + body.insert("normalizedSource".into(), json!(source.normalized_body)); + body.insert( + "normalizedSourceHash".into(), + json!(source.normalized_source_hash), + ); + body.insert("receiptRequirements".into(), receipt_requirements()); + body.insert("target".into(), json!(target.name)); + body.insert("tests".into(), json!(source.tests)); + + let slice_hash = json_hash(ObjectKind::Blob, &Value::Object(body.clone())); + body.insert("sliceHash".into(), json!(slice_hash)); + body.insert("wave".into(), json!(wave as u64)); + + CompileSlice { + module: source.module.clone(), + target: target.name.clone(), + input_key, + base_state_key: base_state_key.to_string(), + slice_hash, + wave, + body: Value::Object(body), } - let bytes = get_blob(store, &attribution.record)?; - Ok(crate::attribution::parse(&String::from_utf8_lossy(&bytes)).map(|record| record.entries)) } -/// The notes attached to one transition, each as its blob name and its text. +/// Recompute a slice's hash from its own body. /// -/// The index is a plain list of blob names beside the store, so a missing or -/// unreadable index is simply no notes: a reader degrades to empty rather than -/// erroring, exactly as every other read here does. -fn attached_notes(store: &Store, transition: &str) -> Result> { - let index = store.root.join("notes").join(format!("{transition}.json")); - let bytes = match std::fs::read(&index) { - Ok(bytes) => bytes, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(disk::io(&index, &e)), - }; - let names: Vec = serde_json::from_slice(&bytes).unwrap_or_default(); - let mut out: Vec = Vec::new(); - for name in names { - if !has_object(store, &name) { - continue; - } - let body = get_blob(store, &name)?; - let parsed: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); - out.push(json!({ - "hash": name, - "note": parsed["note"].as_str().unwrap_or_default(), - })); - } - Ok(out) +/// `sliceHash` and `wave` are excluded: the hash identifies the compile work, +/// not the position that work happens to occupy in one particular sweep. +pub fn recompute_slice_hash(body: &Value) -> String { + let mut stripped = body.as_object().cloned().unwrap_or_default(); + stripped.remove("sliceHash"); + stripped.remove("wave"); + json_hash(ObjectKind::Blob, &Value::Object(stripped)) } +napl-store blob v1 66932 +//! `napl add`: stage a module state; `napl commit --staged`: install what add staged. +//! +//! Git parity is the mental model: add stages, commit installs. The staged +//! record is the internal receipt between them, never a user-facing noun. It is +//! transient coordination state, disposable litter by the git-state law: never +//! promoted to history, safely deletable, and re-creatable by re-running add. +//! Add ingests the staged bytes into an immutable copy and every later step +//! reads that copy, so tampering with the source files after add cannot change +//! what commit installs. That holds for all three sources equally, because all +//! three become a list of scanned entries before ingestion sees them. +//! +//! The working tree is the default source, exactly as in git. `napl add ` +//! names files and stages the owning module's committed output with those files +//! overlaid; `napl add --module ` stages the module's whole owned tree as +//! it sits on disk; `napl add --module ` stages explicit +//! bytes, for the rare case where what must be staged is not the working tree. +//! +//! Attribution transports rather than being demanded. Add diffs the committed +//! output against the staged bytes and the committed prompt body against the +//! pending one, and carries every recorded claim through those diffs. A claim the +//! diff proves survives; a claim it does not prove is asked for, never invented. -/// The raw payload bytes of an object, with the envelope stripped. -pub fn cat_file(workspace: &Path, hash: &str) -> Result> { - Ok(get_object(&disk::open(workspace), hash)?.1) +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +use crate::attribution::{self, Attribution}; +use crate::authoring::draft_receipt_for; +use crate::candidate::{default_limits, ingest, Candidate, EntryKind, ScannedEntry}; +use crate::commit::{commit_with_mode, AttributionMode, CommitReport}; +use crate::disk::{self, Store}; +use crate::error::{error, error_payload, OutcomeCode, Result}; +use crate::objects::{read_module_state, snapshot_states}; +use crate::source::SourceModule; +use crate::store_view::{flatten_tree, get_blob, ObjectSource}; +use crate::target::{builtin_target, is_owned_path, owned_globs, placement_root, TargetDef}; +use crate::transaction::check_candidate_shape; +use crate::transport::{ + refusal_detail, refusal_payload, transport_attribution, TransportInputs, TransportOutcome, +}; +use crate::workspace::{discover_sources, scan_candidate, write_file}; + +/// The disposable staging area for `add`, workspace-relative. +pub const STAGING_DIR: &str = ".napl/staging"; + +/// Where the bytes `add` stages come from. +/// +/// Three sources, one pipeline: each resolves to a list of scanned entries, and +/// from ingestion onward nothing downstream can tell them apart. +#[derive(Debug, Clone)] +pub enum AddSource { + /// Explicit candidate bytes in a directory: the exception, not the routine. + CandidateDir(PathBuf), + /// The module's whole owned tree, as it sits in the working tree. + WorkingTree, + /// The module's committed output with exactly these workspace-relative paths + /// overlaid from the working tree. + Overlay(Vec), } -/// The recorded surface of a module as of HEAD. +/// Stage one module state from any of the three sources. /// -/// A module with no committed state under this target has no recorded surface at -/// all, and an empty item list would read as a real surface with nothing public. -/// The honest answer names the absent baseline and carries no items, exactly as -/// `diff` and `blame` answer for a missing baseline. -pub fn surface_of(workspace: &Path, target: &str, module: &str) -> Result { +/// Ingest the staged bytes into immutable blobs, validate against the slice, +/// resolve attribution by transport or affirmation, attach usage evidence, then +/// write the staged record. Validation failures surface here with the same +/// outcome classes commit uses. +#[allow(clippy::too_many_arguments)] +pub fn add( + workspace: &Path, + target_name: &str, + module: &str, + source: &AddSource, + attribution_path: Option<&Path>, + usage_path: Option<&Path>, + no_verify: bool, + check: bool, +) -> Result { + let target = builtin_target(target_name)?; + let sources = discover_sources(workspace)?; + let prompt = sources.iter().find(|s| s.module == module).ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} has no prompt file in the workspace"), + ) + })?; + let root = placement_root(prompt); let store = disk::open(workspace); - let Some(state) = committed_state(&store, target, module)? else { + + // Resolve the source to entries, then ingest once into immutable bytes; + // every later step reads these, never the files they came from. + let entries = scan_source(workspace, &store, &target, module, &sources, source)?; + let candidate = ingest(&entries, default_limits())?; + check_candidate_shape(&target, &root, &candidate)?; + // The parse gate, before anything reads the bytes as a surface: every staged + // file the target claims as source has to parse under the target's pinned + // grammar, or the record refuses naming the file, the first error's line, and + // the malformed region. Syntax only; everything past it is the battery's job. + // + // The refusal enumerates the same validations a passing `--check` verdict + // enumerates, so the reader learns which gate stopped them in the same + // vocabulary that would have told them everything passed. + if let Err(err) = crate::surface::parse_gate(&target, &root, &candidate.files) { + let mut data = match err.data { + Some(Value::Object(map)) => map, + _ => serde_json::Map::new(), + }; + data.insert("module".into(), json!(module)); + data.insert("notJudged".into(), not_judged()); + data.insert( + "validations".into(), + validations_run(attribution_path.is_some()), + ); + return Err(crate::error::error_with( + err.code, + err.detail, + Value::Object(data), + )); + } + + // The receipt is drafted against the ingested copy, not against files on + // disk, so the whole verdict is computable before anything is written. That + // is what makes `--check` a real dry run rather than a staged record deleted + // afterwards. + let receipt = draft_receipt_for(workspace, target_name, module, &candidate, usage_path)?; + + // Resolve the attribution update: transported where the diffs prove it, + // affirmed where they do not, refused when neither. + let attribution_bytes = resolve_attribution( + workspace, + &store, + &target, + &root, + module, + prompt, + &candidate, + attribution_path, + no_verify, + )?; + + if check { + // The dry run has now run the entire pipeline, transport verdict and + // refusal listing included, and it writes nothing: no staged record, no + // entry in the staging directory, no store byte touched. + // + // And it says exactly what it judged. `would-stage` is a verdict about a + // record, not about code: the staged bytes could be a file that does not + // parse in the target language and this command would still, correctly, + // answer would-stage. Enumerating the validations that ran beside the one + // that deliberately did not is what keeps the two from being confused. return Ok(json!({ + "checked": true, "contractVersion": crate::version::CONTRACT_VERSION, "module": module, - "reason": format!( - "module {module:?} has no committed state under target {target:?}, so there is no recorded surface to serve and no baseline to read one from" - ), - "target": target, - "tracked": false, + "notJudged": not_judged(), + "outcome": "would-stage", + "staged": false, + "target": target_name, + "validations": validations_run(attribution_path.is_some()), })); - }; - let items = read_surface_items(&store, &state.surface_hash)?; - let mut out = surface_json(module, target, &items); - if let Some(object) = out.as_object_mut() { - object.insert("tracked".to_string(), Value::Bool(true)); } - Ok(out) -} -/// Whether this workspace has a committed head at all. -/// -/// The one observation a command whose meaning requires a baseline makes before -/// it answers, so a headless workspace gets the prerequisite sequence instead of -/// an empty shape that would read as success. -pub fn has_committed_head(workspace: &Path) -> Result { - Ok(disk::open(workspace).head()?.is_some()) -} + let record_dir = record_dir(workspace, module, &candidate.tree_hash); + if record_dir.exists() { + std::fs::remove_dir_all(&record_dir).map_err(|e| disk::io(&record_dir, &e))?; + } + let staged_dir = record_dir.join("candidate"); + for (path, bytes) in &candidate.files { + write_file(&staged_dir.join(path), bytes)?; + } -/// Drift between committed outputs and the working tree, as a canonical patch. -/// -/// A target no committed module owns anything under has no baseline, and the -/// honest answer is `tracked: false` with the reason: an empty patch would say -/// "nothing drifted" when the truth is "nothing was compared". -pub fn diff(workspace: &Path, target: &str) -> Result { - let store = disk::open(workspace); - let committed = committed_text(&store, target)?; - if committed.is_empty() { - return Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "reason": format!( - "no committed module owns any output under target {target:?}, so there is no baseline to diff against" - ), - "target": target, - "tracked": false, - })); - } - let paths: Vec = committed.keys().cloned().collect(); - let working = read_working_text(workspace, &paths); - let patch = render_drift(&committed, &working); - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "patch": patch, - "target": target, - "tracked": true, - })) -} - -/// Derived line ancestry for one committed path. -/// -/// A path no committed module owns has no ancestry to replay, and empty ancestry -/// would read as a file that exists and was never written. It answers -/// `tracked: false` naming the absent baseline instead, an honest success shape. -/// -/// Blame reads committed bytes only. When the working tree has moved away from -/// them the report carries a drift notice naming `napl diff` as where the -/// uncommitted change lives, so ancestry is never mistaken for the present file. -pub fn blame_path(workspace: &Path, path: &str) -> Result { - let store = disk::open(workspace); - let Some(head) = store.head()? else { - return Ok(untracked_json( - path, - "this workspace has no committed head, so no module owns this path yet", - )); - }; - if !committed_manifest_all_targets(&store)?.contains(path) { - return Ok(untracked_json( - path, - "no committed module owns this path, so there is no committed baseline to trace it through", - )); - } - let mut revisions: Vec = Vec::new(); - for event in causal_order(&store, &head)? { - let transition_id = event_transition(&store, &event)?; - let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; - let touched = transition["installPaths"] - .as_array() - .map(|paths| paths.iter().any(|p| p.as_str() == Some(path))) - .unwrap_or(false); - if !touched { - continue; + write_file( + &record_dir.join("receipt.json"), + &crate::canon::canonical_bytes(&receipt), + )?; + let attribution_present = match &attribution_bytes { + Some(bytes) => { + write_file(&record_dir.join("attribution.yaml"), bytes)?; + true } - let Some(state_hash) = transition["newState"].as_str() else { - continue; - }; - let state = read_module_state(&store, state_hash)?; - let files = flatten_tree(&store, &state.output_tree)?; - let Some((blob, _)) = files.get(path) else { - continue; - }; - let bytes = get_blob(&store, blob)?; - revisions.push(Revision { - transition: transition_id, - content: String::from_utf8_lossy(&bytes).into_owned(), - }); - } - let mut value = blame_json(path, &blame(&revisions)); - // Blame reads committed bytes only, and says so when it matters. A path - // whose working tree has moved away from the committed output carries a - // notice naming `napl diff` as where the uncommitted change lives, so - // ancestry over committed bytes is never mistaken for the present file. - let committed = revisions.last().map(|revision| revision.content.clone()); - let working = std::fs::read(workspace.join(path)).ok(); - let working_text = working - .as_deref() - .map(|bytes| String::from_utf8_lossy(bytes).into_owned()); - let drifted = match (&committed, &working_text) { - (Some(committed), Some(working)) => committed != working, - _ => false, + None => false, }; - if let Some(object) = value.as_object_mut() { - object.insert("drift".to_string(), Value::Bool(drifted)); - if drifted { - object.insert( - "driftNotice".to_string(), - Value::String(format!( - "the working tree of {path:?} differs from the committed output; this ancestry is over the committed bytes only, and the uncommitted change lives in `napl diff`" - )), - ); - object.insert("driftInspect".to_string(), Value::String("napl diff".to_string())); - } - } - Ok(value) -} + write_record( + &record_dir, + module, + target_name, + &candidate.tree_hash, + attribution_present, + if no_verify { + AttributionMode::Escape + } else { + AttributionMode::Prove + }, + )?; -/// The source-language scan `napl lint ` performs: parse every -/// committed module's owned working-tree file under that target with the pinned -/// grammar and report one finding per file that fails. -/// -/// This is a different question from the graph analyses beside it and it needs no -/// roots declaration to answer, because nothing about reachability is involved. -/// The payload names its scope, so an empty findings list is never read wider -/// than the scan that produced it. -pub fn source_syntax_scan(workspace: &Path, target_name: &str) -> Result { - let store = disk::open(workspace); - let target = builtin_target(target_name)?; - let sources = discover_sources(workspace)?; - let roots: BTreeSet = sources - .iter() - .filter(|source| source.targets.iter().any(|t| t == target_name)) - .map(placement_root) - .collect(); - let mut findings: Vec = Vec::new(); - let mut scanned = 0u64; - for path in committed_manifest(&store, target_name)?.into_keys() { - if !roots - .iter() - .any(|root| crate::surface::is_target_source(&target, root, &path)) - { - continue; - } - let Ok(bytes) = std::fs::read(workspace.join(&path)) else { - // A committed path the working tree no longer holds is drift, which - // `diff` and `status` already report; the syntax scan judges bytes it - // can read and claims nothing about bytes it cannot. - continue; - }; - scanned += 1; - if let Some(failure) = crate::surface::parse_check(&target, &path, &bytes)? { - findings.push(json!({ - "autoFixable": false, - "file": failure.path, - "kind": "source-parse-error", - "line": failure.line, - "parseFailure": failure.as_json(), - "proposal": format!( - "fix the syntax at line {} of {:?}; the pinned grammar cannot parse it, so no surface can be extracted from it", - failure.line, failure.path - ), - "scope": crate::surface::SOURCE_SYNTAX_SCOPE, - })); - } - } Ok(json!({ "contractVersion": crate::version::CONTRACT_VERSION, - "detail": "every committed module's owned working-tree file under this target was parsed with the target's pinned grammar; this scan judges syntax and nothing else, and it needs no roots declaration", - "filesScanned": scanned, - "findings": findings, - "rootsRequired": false, - "scope": crate::surface::SOURCE_SYNTAX_SCOPE, + "module": module, + "outcome": "staged", + "record": display_path(workspace, &record_dir), "target": target_name, })) } -/// Diagnose a workspace. -pub fn lint_workspace(workspace: &Path, target: &str) -> Result<(Graph, LintReport, Value)> { - let store = disk::open(workspace); - let sources = discover_sources(workspace)?; - let artifact = compile(&store, &sources, target, None)?; - let graph = crate::compile::graph_for(&sources, target)?; - let definition = builtin_target(target)?; - let states = match store.head()? { - Some(snapshot) => snapshot_states(&store, &snapshot)?, - None => BTreeMap::new(), - }; - - let mut committed_input_keys: BTreeMap> = BTreeMap::new(); - let mut referenced_names: BTreeMap> = BTreeMap::new(); - let mut surface_names: BTreeMap> = BTreeMap::new(); - for module in graph.keys() { - let (state, _) = state_of(&store, &states, &definition, module)?; - committed_input_keys.insert(module.clone(), state.input_key.clone()); - surface_names.insert( - module.clone(), - read_surface_items(&store, &state.surface_hash)? - .iter() - .filter_map(|item| item.path.rsplit("::").next().map(str::to_string)) - .collect(), - ); - referenced_names.insert(module.clone(), identifiers(&store, &state)?); - } - let current_input_keys: BTreeMap = artifact - .slices - .iter() - .map(|slice| (slice.module.clone(), slice.input_key.clone())) - .collect(); +/// The validations `--check` actually ran, in the order the pipeline runs them. +/// +/// Every one of these is a fact about the record: where the bytes may live, what +/// shape the ingest accepts, whether the recorded claims still point at +/// something, and whether the supplied evidence validates. None of them is a +/// fact about whether the code works. +fn validations_run(attribution_supplied: bool) -> Value { + json!([ + { + "detail": "every staged path lies inside the module's placement root and its owned globs, no path is a user-owned manifest, and no path is a derived entry-file projection", + "judged": true, + "name": "placement", + }, + { + "detail": "the staged bytes ingest into content-addressed blobs: no links or devices, no duplicate normalized paths, no absolute or parent-escaping path, nothing over the compiled-in size bounds", + "judged": true, + "name": "ingest", + }, + { + "detail": "every staged file the target claims as source parsed under the target's pinned tree-sitter grammar; a parse tree carrying an error node refuses as invalid-candidate naming the file, the first error's line, and the malformed region", + "judged": true, + "name": "parse gate", + "scope": crate::surface::SOURCE_SYNTAX_SCOPE, + }, + { + "detail": if attribution_supplied { + "every recorded claim was carried through the committed-to-staged and committed-to-pending diffs, and the supplied --attribution map was layered over the transported remainder and revalidated" + } else { + "every recorded claim was carried through the committed-to-staged and committed-to-pending diffs, and any claim the diffs do not prove is listed with both endpoints' verdicts" + }, + "judged": true, + "name": "attribution transport", + }, + { + "detail": "any supplied usage envelope was validated by its media type's registered validator and checked against the compiled-in evidence bounds", + "judged": true, + "name": "evidence", + }, + ]) +} - let roots = declared_roots(&sources); - let report = lint( - &graph, - &LintInput { - roots, - committed_input_keys, - current_input_keys, - referenced_names, - surface_names, +/// What `--check` deliberately did not judge. +/// +/// The dry run answers a question about staging and about syntax. Whether the +/// code type checks, borrow checks, or passes its tests is the acceptance +/// battery's question, and this command never ran it, so `would-stage` means the +/// record is sound and the syntax parses, never that the code is correct. +fn not_judged() -> Value { + json!([ + { + "detail": "whether the staged bytes compile, type check, borrow check, or pass their tests is the acceptance battery's job and nothing here ran it; the parse gate judged syntax and only syntax, so would-stage means the record is sound and the syntax parses, never that the code is correct", + "judged": false, + "name": "everything past syntax", }, - ); - let value = lint_json(&report); - Ok((graph, report, value)) + ]) } -/// Cost over an explicit set: receipt object names in the store, receipt file -/// paths, usage-proxy session windows, or any mixture. +/// `napl add [...]`: stage one record per owning module. /// -/// A session window is priced here, lazily, at read time: a capture verb records -/// the window as a reference and never aggregates it, so the log lines are summed -/// only when a reader asks. A model the pinned table does not know stays -/// `unknown`, never zero. -pub fn cost( +/// Each path resolves to its owning module through the committed output manifest, +/// which is unique by the commit invariant that two modules never claim one path, +/// so no `--module` is needed. A path no module owns is a usage error that names +/// the owned globs which do exist, because the useful answer to "this is not +/// owned" is "here is what is". +/// +/// `napl add --module ` is the same form with the resolution +/// declared instead of derived: a file that is new in a crate several modules +/// share matches every one of their identical globs and is committed by none of +/// them, so neither the manifest nor the globs can name an owner. The author +/// names it, and the form exists precisely so that answer is sayable. +#[allow(clippy::too_many_arguments)] +pub fn add_working_tree_paths( workspace: &Path, - names: &[String], - files: &[String], - sessions: &[String], + cwd: &Path, + target_name: &str, + given: &[String], + explicit_module: Option<&str>, + attribution_path: Option<&Path>, + usage_path: Option<&Path>, + no_verify: bool, + check: bool, ) -> Result { + let target = builtin_target(target_name)?; + let sources = discover_sources(workspace)?; let store = disk::open(workspace); - let mut lines: Vec = Vec::new(); - for session in sessions { - lines.extend(crate::cost::lines_from_session( - &std::fs::read_to_string(workspace.join(crate::capture::USAGE_LOG_PATH)) - .unwrap_or_default(), - session, - )); - } - for name in names { - let body = get_json(&store, name, ObjectKind::Receipt)?; - lines.push(receipt_line(workspace, name, &body)?); - } - for file in files { - let path = Path::new(file); - let bytes = std::fs::read(path).map_err(|e| disk::io(path, &e))?; - let body: Value = serde_json::from_slice(&bytes).map_err(|e| { - error( - OutcomeCode::InvalidReceipt, - format!("{file}: invalid JSON: {e}"), - ) - })?; - let name = crate::canon::json_hash(ObjectKind::Receipt, &body); - lines.push(receipt_line(workspace, &name, &body)?); - } - Ok(cost_report(&lines)) -} + let ownership = committed_ownership(&store, target_name)?; -/// One receipt's cost line: a reported total priced directly, a recorded session -/// window priced against the log the record names. -fn receipt_line(workspace: &Path, name: &str, body: &Value) -> Result { - let module = body["module"].as_str().unwrap_or_default(); - let reported = normalize_reported(&body["reported"]); - match crate::cost::window_from_reported(&reported) { - Some(window) => { - let log = window_log(workspace, &window.log_path); - Ok(crate::cost::line_from_window( - name, - module, - &window, - log.as_deref(), - )) + let mut grouped: BTreeMap> = BTreeMap::new(); + let mut prompts: Vec = Vec::new(); + for raw in given { + let path = workspace_relative(workspace, cwd, raw); + // A module's own prompt path is the one path add accepts that no owned + // glob names, and it is accepted for exactly one purpose: recording a + // docs edit. Naming it under `--module` is a different sentence and stays + // an unowned path, because the docs door is the path alone. + if explicit_module.is_none() { + if let Some(prompt) = sources.iter().find(|source| source.path == path) { + prompts.push(prompt.clone()); + continue; + } } - None => line_from_reported(name, module, &reported), - } -} - -/// The bytes of a log a record names, read at the moment the reader asks. -/// -/// The path is a record's own workspace-relative path, so a path that escapes -/// the workspace or is absolute is not a log this reader will open: it reads as -/// absent, which the report already knows how to say. -fn window_log(workspace: &Path, log_path: &str) -> Option { - let candidate = Path::new(log_path); - if candidate.is_absolute() || log_path.split('/').any(|part| part == "..") { - return None; + let owner = match explicit_module { + Some(named) => Some(named.to_string()), + None => ownership + .get(&path) + .cloned() + .or_else(|| sole_glob_owner(&target, &sources, &path, &ownership)), + }; + let Some(owner) = owner else { + return Err(unowned_path(&target, &sources, &path)); + }; + grouped.entry(owner).or_default().push(path); } - std::fs::read_to_string(workspace.join(candidate)).ok() -} -/// Sweep every object no named head reaches, and the staged records beside them. -/// -/// The sweep set is computed outside the lock; only the deletions run under it, -/// and a writer publishing concurrently is never blocked because publication -/// takes no lock at all. -/// -/// Staged records belong in this report because they are exactly what the -/// git-state law calls disposable litter: never promoted to history, re-creatable -/// by re-running `add`, and reachable from no head. `--apply` sweeps them, so -/// nobody ever hand-edits `.napl` to be rid of one. -pub fn gc(workspace: &Path, apply: bool) -> Result { - let store = disk::init(workspace)?; - let all: BTreeSet = store.names()?.into_iter().collect(); - let reachable = match store.head()? { - Some(head) => reachable_from_head(&store, &head)?, - None => BTreeSet::new(), - }; - let swept = plan_sweep(&all, &reachable); - let staged: Vec = crate::staging::staged_records(workspace)? - .into_iter() - .map(|record| record.relative) - .collect(); - // The repack is the apply path's whole sweep: it packs the reachable closure - // and removes every loose file it enumerated, unreachable ones included, so - // packing the reachable and removing the rest is one operation. - let repacked = if apply { - disk::repack(&store, &reachable, "gc")? - } else { - disk::plan_repack(&store, &reachable)? - }; - if apply { - for name in &swept { - disk::remove_object(&store, name)?; - } - for record in &staged { - let dir = workspace.join(record); - match std::fs::remove_dir_all(&dir) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(disk::io(&dir, &e)), - } + let mut records: Vec = Vec::new(); + for prompt in &prompts { + if attribution_path.is_some() || usage_path.is_some() { + return Err(crate::cli::usage(&format!( + "{:?} is module {:?}'s prompt, and a prompt path records a docs edit: the new prompt bytes and nothing else, no attribution work and no usage. Drop --attribution and --usage, or stage code with `napl add --module {}`", + prompt.path, prompt.module, prompt.module + ))); } + records.push(add_docs(workspace, target_name, prompt, check)?); } - // Kept counts objects the store actually holds. The reachable set may name a - // hash no object backs — a `readSet` identity, a derivation key — and keeping - // a name that resolves to nothing is not keeping an object. - let mut payload = gc_json(&swept, all.len() - swept.len()); - payload["stagedRecords"] = json!(staged); - let mut repack = crate::maintenance::repack_json(&repacked); - repack["applied"] = json!(apply); - payload["repack"] = repack; - Ok(payload) + for (module, paths) in grouped { + records.push(add( + workspace, + target_name, + &module, + &AddSource::Overlay(paths), + attribution_path, + usage_path, + no_verify, + check, + )?); + } + Ok(staged_payload(target_name, records)) } -/// The exact command the owner runs to take the migrated object files out of -/// the working tree. It is theirs to run, not the toolchain's: removing them -/// from the index is an ordinary git commit and never a store mutation. -pub const STORE_MIGRATE_REMOVAL: &str = "git rm -r .napl/store/objects"; +/// What the working-tree prompt has done to the prompt the store committed. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PromptDrift { + /// The working tree matches the committed prompt byte for byte, prose + /// included. There is no docs edit to record. + Unchanged, + /// Prose moved and the semantic body did not: the one drift a docs record + /// carries. + DocsOnly, + /// The drift reached outside the docs fences. The line is the first semantic + /// body line that moved, in the coordinate system attribution endpoints name. + Semantic { + line: u32, + committed: String, + working: String, + }, +} -/// `napl store migrate`: the one way `refs/heads/napl-store` comes to exist. -/// -/// It walks the tracked store's loose objects and every pack into git blobs, -/// re-hashing each envelope to the napl name it must keep, builds one fanout -/// tree and one deterministic commit over them, and creates the branch there. It -/// refuses outside a git repository and refuses when the branch already exists, -/// and it touches no tracked file: the payload names the object files as ready -/// to leave the working tree and the exact command that does it, because that -/// removal is the owner's ordinary git commit. +/// Judge the working-tree prompt against the committed one. /// -/// After it, selection observes the branch and every later read and write goes -/// through the git provider with no flag set anywhere. -pub fn store_migrate(workspace: &Path) -> Result<(bool, Value)> { - let store = disk::open(workspace); - let Some(repository) = napl_store::git_store::enclosing_repository(workspace) else { - return Ok(( - false, - migrate_refusal( - "not-a-git-repository", - "this workspace is not inside a git repository, so there is no object database to migrate into; the tracked layout with its packfile mitigation stays the floor", +/// The semantic body is the whole question: it is the byte string every identity +/// derivation consumes, so a prompt whose semantic body is byte-identical has +/// moved nothing a module state is keyed on, whatever its prose did. The +/// committed semantic body is the blob the module's attribution was proven +/// against, which is the only committed copy of that byte string, so a module +/// with no recorded attribution has no baseline and no docs door. +fn prompt_drift(store: &Store, target_name: &str, prompt: &SourceModule) -> Result { + let Some(state) = crate::porcelain::committed_state(store, target_name, &prompt.module)? else { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "module {:?} has no committed state for target {target_name:?}, so there is no committed prompt for a docs edit to differ from; commit the module first with `napl add --module {}`", + prompt.module, prompt.module ), )); }; - let provider = napl_store::git_store::GitProvider::open(&repository, &store.root) - .map_err(disk::git_error)?; - if provider.tip().map_err(disk::git_error)?.is_some() { - return Ok(( - false, - migrate_refusal( - "already-migrated", - "refs/heads/napl-store already exists, and migration is a one-shot verb that never rewrites a store branch", + let Some(attribution) = &state.attribution else { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "module {:?} records no attribution, so the store holds no committed copy of its semantic body to prove a docs edit against; commit the module with `napl add --module {}`", + prompt.module, prompt.module ), )); + }; + let committed_semantic = String::from_utf8(get_blob(store, &attribution.prompt_hash)?) + .map_err(|_| { + error( + OutcomeCode::StoreIntegrity, + format!( + "the committed semantic body of module {:?} is not valid UTF-8", + prompt.module + ), + ) + })?; + if committed_semantic != prompt.normalized_body { + let (line, committed, working) = + first_difference(&committed_semantic, &prompt.normalized_body); + return Ok(PromptDrift::Semantic { + line, + committed, + working, + }); + } + // A module committed before prompt objects existed has no recorded physical + // body, so there is nothing to call identical: recording the docs edit is + // exactly what installs that baseline. + let recorded = match &state.prompt { + Some(object) => object.clone(), + None => return Ok(PromptDrift::DocsOnly), + }; + let written = napl_store::store_hash::object_name( + napl_store::store_hash::ObjectKind::Blob, + prompt.physical_body.as_bytes(), + ); + if recorded == written { + Ok(PromptDrift::Unchanged) + } else { + Ok(PromptDrift::DocsOnly) } - - let tracked = tracked_object_files(&store.root)?; - let outcome = provider - .convert_tracked_store(&store.root, crate::version::CONTRACT_VERSION) - .map_err(disk::git_error)?; - - Ok(( - true, - json!({ - "branch": napl_store::git_store::STORE_BRANCH, - "commit": outcome.commit, - "contractVersion": crate::version::CONTRACT_VERSION, - "objects": outcome.objects, - "outcome": "migrated", - "readyToLeaveWorkingTree": { - "detail": "migration touched none of these: removing them from the index is your ordinary git commit, not a store mutation", - "files": tracked, - "removalCommand": STORE_MIGRATE_REMOVAL, - "root": format!("{}/objects", disk::STORE_DIR), - }, - "snapshot": outcome.snapshot, - }), - )) } -/// How many files the tracked object layout holds under the store root: every -/// loose object file, every pack, and every pack index. -fn tracked_object_files(root: &Path) -> Result { - let mut count = 0; - let mut stack = vec![root.join("objects")]; - while let Some(directory) = stack.pop() { - let entries = match std::fs::read_dir(&directory) { - Ok(entries) => entries, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, - Err(e) => return Err(disk::io(&directory, &e)), - }; - for entry in entries { - let entry = entry.map_err(|e| disk::io(&directory, &e))?; - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else { - count += 1; - } +/// The first line two bodies disagree on, one-based, beside both spellings. +/// +/// A body that is a prefix of the other disagrees at the first line past the +/// shorter one, so a pure insertion or deletion names a line rather than nothing. +fn first_difference(committed: &str, working: &str) -> (u32, String, String) { + let left: Vec<&str> = committed.lines().collect(); + let right: Vec<&str> = working.lines().collect(); + for index in 0..left.len().max(right.len()) { + let a = left.get(index).copied().unwrap_or_default(); + let b = right.get(index).copied().unwrap_or_default(); + if a != b { + return (index as u32 + 1, a.to_string(), b.to_string()); } } - Ok(count) -} - -/// A migration refusal: the reason named, nothing done, nothing written. -fn migrate_refusal(reason: &str, detail: &str) -> Value { - json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "detail": detail, - "outcome": "refused", - "reason": reason, - }) + (left.len().max(right.len()) as u32, String::new(), String::new()) } -/// Verify envelope hashes, kind validity, causal parent links, and every pack's -/// name, index, and members. -pub fn fsck_store(workspace: &Path) -> Result<(bool, Value)> { +/// `napl add `: stage a docs record. +/// +/// The record is the new prompt bytes and nothing else: no candidate tree, no +/// attribution work, no usage. Commit installs it by writing the new prompt +/// object and re-pointing the module's prompt at it, and every derived fact +/// holds. Drift that reaches outside the docs fences refuses here rather than at +/// commit, naming the first semantic line that moved, because a docs record that +/// smuggled a semantic edit would be an unreviewed module change. +fn add_docs( + workspace: &Path, + target_name: &str, + prompt: &SourceModule, + check: bool, +) -> Result { let store = disk::open(workspace); - let mut findings = fsck(&store)?; - findings.extend(disk::verify_packs(&store)?.into_iter().map(pack_finding)); - findings.sort(); - findings.dedup(); - Ok((findings.is_empty(), fsck_json(&findings))) -} - -/// Translate one store-level pack finding into the compiler's fsck vocabulary. -fn pack_finding(finding: napl_store::store_io::PackFinding) -> crate::maintenance::FsckFinding { - use napl_store::store_io::PackFinding as Pack; - let (pack, detail) = match finding { - Pack::NameMismatch { pack } => { - let detail = "the pack's bytes do not hash to its name".to_string(); - (pack, detail) + match prompt_drift(&store, target_name, prompt)? { + PromptDrift::Unchanged => { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "{:?} matches the committed prompt of module {:?} byte for byte, so there is no docs edit to record", + prompt.path, prompt.module + ), + )); } - Pack::IndexDisagrees { pack, detail } => (pack, detail), - Pack::MemberCorrupt { pack, object } => { - let detail = format!("packed object {object} does not verify against its name"); - (pack, detail) + PromptDrift::Semantic { + line, + committed, + working, + } => { + return Err(crate::error::error_with( + OutcomeCode::InvalidCandidate, + format!( + "{:?} changed module {:?} outside its docs fences: semantic body line {line} moved from {committed:?} to {working:?}. A prompt path records a docs edit and nothing else; stage a real change with `napl add --module {}`", + prompt.path, prompt.module, prompt.module + ), + json!({ + "committedLine": committed, + "module": prompt.module, + "promptPath": prompt.path, + "semanticLine": line, + "teaches": format!("napl add --module {}", prompt.module), + "workingLine": working, + }), + )); } - }; - crate::maintenance::FsckFinding::PackDamaged { pack, detail } -} - -/// Repack the store when its loose-object count crosses the compiled-in bound. -/// -/// Called after a successful commit's critical section and outside every lock: -/// the pointer has already moved, so a repack here never lengthens the bounded -/// section and never blocks a writer. It packs the same reachable closure `gc -/// --apply` packs, so the pack it writes is byte-identical to gc's. -pub fn repack_if_loose(store: &Store) -> Result> { - if disk::loose_object_count(store)? <= crate::maintenance::LOOSE_OBJECT_REPACK_THRESHOLD { - return Ok(None); - } - let reachable = match store.head()? { - Some(head) => reachable_from_head(store, &head)?, - None => return Ok(None), - }; - let outcome = disk::repack(store, &reachable, "gc")?; - if outcome.pack.is_none() { - return Ok(None); + PromptDrift::DocsOnly => {} } - Ok(Some(crate::maintenance::repack_json(&outcome))) -} -/// Derive a repair plan for a target's committed outputs, and optionally apply -/// it. -pub fn restore(workspace: &Path, target: &str, apply: bool) -> Result { - let store = disk::open(workspace); - let expected: Vec = committed_manifest(&store, target)? - .into_iter() - .map(|(path, hash)| FileFact { path, hash }) - .collect(); - let roots: BTreeSet = expected - .iter() - .filter_map(|fact| fact.path.split('/').next().map(str::to_string)) - .collect(); - let observed = observed_facts(workspace, &roots)?; - let plan = plan_restore(&expected, &observed); - if apply { - if let RestorePlan::Apply(steps) = &plan { - apply_restore(&store, workspace, steps)?; - } + let bytes = std::fs::read(workspace.join(&prompt.path)) + .map_err(|e| disk::io(&workspace.join(&prompt.path), &e))?; + if check { + return Ok(json!({ + "checked": true, + "contractVersion": crate::version::CONTRACT_VERSION, + "kind": DOCS_RECORD, + "module": prompt.module, + "notJudged": not_judged(), + "outcome": "would-stage", + "promptPath": prompt.path, + "staged": false, + "target": target_name, + "validations": docs_validations(), + })); } - Ok(restore_json(&plan)) -} - -/// Attach later provenance to a recorded transition without touching semantic -/// state. -/// The refusal a `--to` that is not an object name earns. -/// -/// A working path is the mistake a reader makes here, because every other verb -/// takes one, so the refusal does not merely say no: it names the two commands -/// that produce and inspect an object name and shows the one line that gets one. -fn not_an_object_name(given: &str) -> crate::error::CompilerError { - error( - OutcomeCode::Usage, - format!( - "--to expects the object name of a recorded transition, and {given:?} is not one. \ - Object names come from `napl log`, and `napl show ` inspects one, \ - for example: napl log | napl q '.entries[-1].transition' -" - ), - ) -} -pub fn add_note(workspace: &Path, transition: &str, note: &str) -> Result { - let store = disk::init(workspace)?; - // A path is never an object name, and neither is anything else outside the - // store's name alphabet: refuse before opening anything, so the reader is - // told how to get a name rather than handed a missing-object error. - if !is_object_name(transition) || !has_object(&store, transition) { - return Err(not_an_object_name(transition)); + let object = napl_store::store_hash::object_name( + napl_store::store_hash::ObjectKind::Blob, + prompt.physical_body.as_bytes(), + ); + let record_dir = record_dir(workspace, &prompt.module, &object); + if record_dir.exists() { + std::fs::remove_dir_all(&record_dir).map_err(|e| disk::io(&record_dir, &e))?; } - get_json(&store, transition, ObjectKind::Transition)?; - let body = json!({ "note": note, "transition": transition }); - let payload = crate::canon::canonical_bytes(&body); - let name = disk::publish(&store, ObjectKind::Blob, &payload, transition)?; - - let index = store.root.join("notes").join(format!("{transition}.json")); - let mut names: BTreeSet = match std::fs::read(&index) { - Ok(bytes) => serde_json::from_slice::>(&bytes) - .unwrap_or_default() - .into_iter() - .collect(), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(), - Err(e) => return Err(disk::io(&index, &e)), - }; - names.insert(name.clone()); - let listed: Vec = names.iter().map(|n| json!(n)).collect(); - crate::workspace::write_file(&index, &crate::canon::canonical_bytes(&Value::Array(listed)))?; + // The staged bytes are the whole prompt file, so commit re-parses what add + // read rather than trusting a body add derived: the ingest law holds for the + // docs door exactly as it holds for the other three. + write_file(&record_dir.join(DOCS_PROMPT_FILE), &bytes)?; + let record = json!({ + "kind": DOCS_RECORD, + "module": prompt.module, + "prompt": object, + "promptPath": prompt.path, + "receiptVersion": crate::receipt::RECEIPT_VERSION, + "target": target_name, + }); + write_file( + &record_dir.join("record.json"), + &crate::canon::canonical_bytes(&record), + )?; Ok(json!({ - "note": name, - "outcome": "success", - "semanticStatePublished": false, - "transition": transition, + "contractVersion": crate::version::CONTRACT_VERSION, + "kind": DOCS_RECORD, + "module": prompt.module, + "outcome": "staged", + "promptPath": prompt.path, + "record": display_path(workspace, &record_dir), + "target": target_name, })) } -/// The events reachable from a head, ordered oldest first by causal depth. -pub fn causal_order(store: &Store, head: &str) -> Result> { - let head_event = snapshot_event(store, head)?; - let events = walk_events(store, &head_event, None)?; - let mut depths: BTreeMap = BTreeMap::new(); - for event in &events { - depth_of(store, event, &mut depths)?; - } - let mut ordered = events; - ordered.sort_by(|a, b| (depths[a], a).cmp(&(depths[b], b))); - Ok(ordered) -} +/// The `kind` a docs record carries, the one spelling commit branches on. +pub const DOCS_RECORD: &str = "docs"; -fn depth_of(store: &Store, event: &str, depths: &mut BTreeMap) -> Result { - if let Some(known) = depths.get(event) { - return Ok(*known); - } - let value = get_json(store, event, ObjectKind::Event)?; - let mut depth = 0usize; - for parent in value["parents"].as_array().cloned().unwrap_or_default() { - if let Some(parent) = parent.as_str() { - depth = depth.max(depth_of(store, parent, depths)? + 1); - } - } - depths.insert(event.to_string(), depth); - Ok(depth) +/// The staged prompt file inside a docs record directory. +pub const DOCS_PROMPT_FILE: &str = "prompt.napl"; + +/// The validations a docs record's dry run actually ran. +fn docs_validations() -> Value { + json!([ + { + "detail": "the working-tree prompt parses, and its docs fences validate under the docs vocabulary", + "judged": true, + "name": "prompt grammar", + }, + { + "detail": "the semantic body is byte-identical to the committed one, so no identity the module is keyed on moved", + "judged": true, + "name": "docs-only drift", + }, + ]) } -pub fn committed_state(store: &Store, target: &str, module: &str) -> Result> { - let Some(head) = store.head()? else { - return Ok(None); - }; - let states = snapshot_states(store, &head)?; - match states.get(&(target.to_string(), module.to_string())) { - Some(hash) => Ok(Some(read_module_state(store, hash)?)), - None => Ok(None), +/// The payload every `add` form reports. +/// +/// `records` is the whole truth, one entry per staged module. A single-record +/// invocation also states its module and record at the top level, because that is +/// what a caller pipes straight into `commit --staged`. +pub fn staged_payload(target_name: &str, records: Vec) -> Value { + let mut payload = serde_json::Map::new(); + payload.insert( + "contractVersion".into(), + json!(crate::version::CONTRACT_VERSION), + ); + // A dry run reports the verdict it would have staged under, never `staged`, + // because nothing was. + let checked = records + .iter() + .all(|record| record["checked"].as_bool().unwrap_or(false)); + payload.insert( + "outcome".into(), + json!(if checked && !records.is_empty() { + "would-stage" + } else { + "staged" + }), + ); + payload.insert("staged".into(), json!(!checked || records.is_empty())); + payload.insert("target".into(), json!(target_name)); + if records.len() == 1 { + payload.insert("module".into(), records[0]["module"].clone()); + if let Some(record) = records[0].get("record") { + payload.insert("record".into(), record.clone()); + } + // A dry run's verdict is the whole point of the invocation, so it is + // read at the top level and never dug out of the array. + for key in ["validations", "notJudged"] { + if let Some(value) = records[0].get(key) { + payload.insert(key.into(), value.clone()); + } + } } + payload.insert("records".into(), Value::Array(records)); + Value::Object(payload) } -/// The materialized attribution view a module committed last, if any. +/// Resolve a path as the caller spelled it to a workspace-relative path. /// -/// The bytes are the record blob, which is the view itself: an internal -/// re-commit reads them to carry the mapping forward rather than fabricating a -/// new one. -pub fn committed_attribution_yaml( - store: &Store, - target: &str, - module: &str, -) -> Result>> { - let Some(state) = committed_state(store, target, module)? else { - return Ok(None); +/// A path already spelled relative to the workspace wins, so the spelling every +/// napl payload uses round-trips. Otherwise it is resolved against the working +/// directory, which is what a shell completion produces. +fn workspace_relative(workspace: &Path, cwd: &Path, given: &str) -> String { + let normalized = given.replace('\\', "/"); + let direct = normalized.trim_start_matches("./").to_string(); + if workspace.join(&direct).exists() { + return direct; + } + let absolute = if Path::new(given).is_absolute() { + PathBuf::from(given) + } else { + cwd.join(given) }; - match state.attribution { - Some(attribution) => Ok(Some(get_blob(store, &attribution.record)?)), - None => Ok(None), + match absolute.strip_prefix(workspace) { + Ok(rest) => rest.to_string_lossy().replace('\\', "/"), + Err(_) => direct, } } -/// The committed `path -> blob` manifest of every module under a target. -pub fn committed_manifest(store: &Store, target: &str) -> Result> { +/// The committed `path -> module` ownership map for one target. +fn committed_ownership(store: &Store, target_name: &str) -> Result> { + let mut out: BTreeMap = BTreeMap::new(); let Some(head) = store.head()? else { - return Ok(BTreeMap::new()); + return Ok(out); }; - let mut out = BTreeMap::new(); - for ((state_target, _), hash) in snapshot_states(store, &head)? { - if state_target != target { + for ((state_target, module), hash) in snapshot_states(store, &head)? { + if state_target != target_name { continue; } let state = read_module_state(store, &hash)?; - for (path, (blob, _)) in flatten_tree(store, &state.output_tree)? { - out.insert(path, blob); + if !crate::store_view::has_object(store, &state.output_tree) { + continue; } - } - Ok(out) -} - -/// Whether a token is shaped like a store object name at all: the lowercase hex -/// of a SHA-256, and nothing else. A workspace path fails on the first separator. -fn is_object_name(given: &str) -> bool { - given.len() == 64 && given.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) -} - -/// Every path any committed module owns, across every target: the set that -/// decides whether a path is tracked at all. -fn committed_manifest_all_targets(store: &Store) -> Result> { - let mut out: BTreeSet = BTreeSet::new(); - let Some(head) = store.head()? else { - return Ok(out); - }; - for (_, hash) in snapshot_states(store, &head)? { - let state = read_module_state(store, &hash)?; - for (path, _) in flatten_tree(store, &state.output_tree)? { - out.insert(path); + for path in flatten_tree(store, &state.output_tree)?.into_keys() { + out.insert(path, module.clone()); } } Ok(out) } -fn committed_text(store: &Store, target: &str) -> Result> { - let mut out = BTreeMap::new(); - for (path, blob) in committed_manifest(store, target)? { - let bytes = get_blob(store, &blob)?; - out.insert(path, String::from_utf8_lossy(&bytes).into_owned()); +/// The module a not-yet-committed path belongs to, when exactly one module's +/// owned globs match it and no module has committed it. +/// +/// Where several prompts share one crate their owned globs are identical, so this +/// answers `None` rather than picking one: a new file in a shared crate is a +/// declaration the author has to make, through `--module` or an explicit +/// candidate directory. +fn sole_glob_owner( + target: &TargetDef, + sources: &[SourceModule], + path: &str, + ownership: &BTreeMap, +) -> Option { + if ownership.contains_key(path) { + return None; + } + let mut matched: Vec<&SourceModule> = sources + .iter() + .filter(|source| source.targets.iter().any(|t| t == &target.name)) + .filter(|source| is_owned_path(target, &placement_root(source), path)) + .collect(); + matched.dedup_by(|a, b| a.module == b.module); + match matched.as_slice() { + [only] => Some(only.module.clone()), + _ => None, } - Ok(out) } -/// Every identifier a module's committed output names. -/// -/// This is a token scan, not name resolution: lint says a surface entry looks -/// unconsumed, and says so as a proposal, precisely because it cannot prove it. -fn identifiers(store: &Store, state: &ModuleState) -> Result> { - let mut out: BTreeSet = BTreeSet::new(); - if !has_object(store, &state.output_tree) { - // A module that has never committed names nothing yet. - return Ok(out); - } - for (_, (blob, _)) in flatten_tree(store, &state.output_tree)? { - let bytes = get_blob(store, &blob)?; - let text = String::from_utf8_lossy(&bytes).into_owned(); - for token in text.split(|c: char| !(c.is_alphanumeric() || c == '_')) { - if !token.is_empty() { - out.insert(token.to_string()); - } - } - } - Ok(out) -} - -fn declared_roots(sources: &[crate::source::SourceModule]) -> Option> { - let mut declared: Option> = None; +/// The refusal a path no module owns earns: a usage error that lists the owned +/// globs which do exist, so the reader learns the shape of what is ownable +/// instead of only that this was not. +fn unowned_path( + target: &TargetDef, + sources: &[SourceModule], + path: &str, +) -> crate::error::CompilerError { + let mut globs: BTreeSet = BTreeSet::new(); for source in sources { - if let Some(roots) = &source.roots { - declared - .get_or_insert_with(BTreeSet::new) - .extend(roots.iter().cloned()); - } - } - declared.map(|set| set.into_iter().collect()) -} - -fn observed_facts(workspace: &Path, roots: &BTreeSet) -> Result> { - let mut out: Vec = Vec::new(); - for root in roots { - let dir = workspace.join(root); - if !dir.is_dir() { + if !source.targets.iter().any(|t| t == &target.name) { continue; } - for entry in crate::workspace::scan_candidate(&dir)? { - if entry.kind != crate::candidate::EntryKind::File { - continue; - } - out.push(FileFact { - path: crate::paths::join_root(root, &entry.path), - hash: napl_store::store_hash::object_name(ObjectKind::Blob, &entry.bytes), - }); + for glob in owned_globs(target, &placement_root(source)) { + globs.insert(glob); } } - out.sort(); - Ok(out) + let listed: Vec = globs.into_iter().collect(); + crate::cli::usage(&format!( + "no module owns {path:?}, so there is nothing to stage it as. The owned globs that exist are: {}", + listed.join(", ") + )) } -fn apply_restore( - store: &Store, +/// Turn one add source into the entry list ingestion judges. +fn scan_source( workspace: &Path, - steps: &[crate::maintenance::RestoreStep], -) -> Result<()> { - use crate::maintenance::RestoreStep; - for step in steps { - match step { - RestoreStep::Move { from, to } => { - let target = workspace.join(to); - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent).map_err(|e| disk::io(parent, &e))?; - } - std::fs::rename(workspace.join(from), &target) - .map_err(|e| disk::io(&target, &e))?; + store: &Store, + target: &TargetDef, + module: &str, + sources: &[SourceModule], + source: &AddSource, +) -> Result> { + match source { + AddSource::CandidateDir(dir) => scan_candidate(dir), + AddSource::Overlay(paths) => { + let mut staged = committed_output(store, &target.name, module)?; + if staged.is_empty() { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "module {module:?} has no committed output to overlay onto; stage the whole tree with --module {module} or an explicit candidate directory" + ), + )); } - RestoreStep::Write { path, hash } => { - let bytes = get_blob(store, hash)?; - crate::workspace::write_file(&workspace.join(path), &bytes)?; + for path in paths { + match std::fs::read(workspace.join(path)) { + Ok(bytes) => { + staged.insert(path.clone(), bytes); + } + // A named path that is gone is a deletion, which is a real + // staged state and not an error. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + staged.remove(path); + } + Err(e) => return Err(disk::io(&workspace.join(path), &e)), + } } - RestoreStep::Remove { path } => { - let target = workspace.join(path); - match std::fs::remove_file(&target) { - Ok(()) => {} + Ok(entries_from(&staged)) + } + AddSource::WorkingTree => { + let committed = committed_output(store, &target.name, module)?; + let mut staged: BTreeMap> = BTreeMap::new(); + for path in committed.keys() { + match std::fs::read(workspace.join(path)) { + Ok(bytes) => { + staged.insert(path.clone(), bytes); + } Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(disk::io(&target, &e)), + Err(e) => return Err(disk::io(&workspace.join(path), &e)), } } + for path in unclaimed_working_paths(workspace, store, target, module, sources)? { + let bytes = std::fs::read(workspace.join(&path)) + .map_err(|e| disk::io(&workspace.join(&path), &e))?; + staged.insert(path, bytes); + } + if staged.is_empty() { + return Err(nothing_stageable(workspace, target, module, sources)); + } + Ok(entries_from(&staged)) } } - Ok(()) } -napl-store module-state v1 562 -{"attribution":{"promptHash":"0a53f13c5e29172205697a166ba275f3517875ed8eef390067a8bd98659f5991","record":"a38d322afe4fa30f9d73cf7f101d26cb366e2dd382463b60fc5a76b5c3d62d02","stale":false},"contractVersion":3,"inputKey":"7b226b9e4f0e6ccadf5acb407ffd20a7ff8c22e7312c6487fcc61d8e62926a8e","module":"commands","outputTree":"b92d10f23649a6ab076e0a23eeda2a922fbb3e779486a81b37c96c11f63d7873","sliceHash":"18a663dd644a423c3581cca0d701b6e4a8eaea40161258aac8ff64b86126fd3e","surfaceHash":"6260dd0f3f8757a6fcbd1c6049df47ff714828822079322176853e9dbc120f0e","target":"rust"} -napl-store tree v1 4545 -{"entries":[{"hash":"f9b963cff641b6476b8a60a34e87be76cb9a071cfe5db598fc71260799fab532","kind":"file","mode":33188,"name":"attribution.rs"},{"hash":"1dec6b9fe622b28f871dae12c000036fd0604aef104c3fdcf95f38e1edcb3733","kind":"file","mode":33188,"name":"authoring.rs"},{"hash":"bcb41a480457775ca3f239c29b7f6ec05cb48e121a6b04172b3b2fe63afaca68","kind":"file","mode":33188,"name":"blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","kind":"file","mode":33188,"name":"candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","kind":"file","mode":33188,"name":"canon.rs"},{"hash":"a258b748a1c40209cc4b9d748a9257c8b314224856c232ed8982aa62002e9c31","kind":"file","mode":33188,"name":"cli.rs"},{"hash":"2c7ac3d8f01c5c4fd114bb758f824ef4e47029fb88fab1390abb6aa550d40a25","kind":"file","mode":33188,"name":"commandset.rs"},{"hash":"271b52c2ec09469469e510d0c21be9948e7132d5be7f7776356dccf6a4647ef3","kind":"file","mode":33188,"name":"commit.rs"},{"hash":"51a5efd09235e6a66de7381faaa217a51136ca9c7d2b10285f99af8ebe4ffc6f","kind":"file","mode":33188,"name":"compile.rs"},{"hash":"14768a9b96b8dd25e1efa36ddad54dd48c5feab01b57a4be8883f7ce8b524eb8","kind":"file","mode":33188,"name":"cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","kind":"file","mode":33188,"name":"decision.rs"},{"hash":"28059a89256aad1de78ecd31d7b77cae21772bbc6e96fe8ba1cdb9044048f410","kind":"file","mode":33188,"name":"disk.rs"},{"hash":"0b71431883adea0e4f3dc3a77b968b69ac4f18348387362cd3c48fbc023d8bb4","kind":"file","mode":33188,"name":"error.rs"},{"hash":"5b7c0553530cac82c017fc61327a6c120e284d0a6d0966e24f0d63fe07d134f5","kind":"file","mode":33188,"name":"evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","kind":"file","mode":33188,"name":"graph.rs"},{"hash":"3448e19e121d0f55b9a0f58f38d0ad2a5849ea8e6676688fb18401987dccdf2a","kind":"file","mode":33188,"name":"init.rs"},{"hash":"f0641fc8a646a08a653d7a2ba1b8fe68da672f027919a1fccb53254e7bcd456a","kind":"file","mode":33188,"name":"lint.rs"},{"hash":"4b445320f81bd7a35f96dd118169b2f02dd112966610fb0d60ccd79e9ddc6e46","kind":"file","mode":33188,"name":"lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","kind":"file","mode":33188,"name":"main.rs"},{"hash":"315f6fa79c7c675b848f1308087ef5e1c328e63cdbb2ad88929b0c89023a27df","kind":"file","mode":33188,"name":"maintenance.rs"},{"hash":"564ecc0f6a253c3229aa3f95806ca4d9b0e894a9a71740153859f2e62b4d02ee","kind":"file","mode":33188,"name":"mv.rs"},{"hash":"3050b7652a1a7070197d7fd954cf55c585dc328306ae68a91b27d24b6b84dcfd","kind":"file","mode":33188,"name":"objects.rs"},{"hash":"003dfa7b8045ce14ca57c31215434fe8034f78efa7c2f6660e635ae4dec26152","kind":"file","mode":33188,"name":"paths.rs"},{"hash":"0bfaac92fd735f0afe003a99021211a8154b66123c3dc7d07ba882e48ba64bdd","kind":"file","mode":33188,"name":"porcelain.rs"},{"hash":"7a087b2d7673515a1187be0a27048a44753403ad0eaae3c729b2eb033803ae7d","kind":"file","mode":33188,"name":"query.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","kind":"file","mode":33188,"name":"receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","kind":"file","mode":33188,"name":"slice.rs"},{"hash":"73c7b0d30b3283cf87c1dcebfca770a61d530918bfc1327077b62fd25e25a2c7","kind":"file","mode":33188,"name":"source.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","kind":"file","mode":33188,"name":"store_view.rs"},{"hash":"9ae420e34160e7c2a6c2ba092aaa9c9ff1df622ece595849d95bf5c5eb7cbe81","kind":"file","mode":33188,"name":"surface.rs"},{"hash":"0cc9f6deb3819268377d5cd3b50b42adc297a013a9c8c9dcf5c77eebc0cd0f8e","kind":"file","mode":33188,"name":"target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","kind":"file","mode":33188,"name":"textdiff.rs"},{"hash":"73a6d090ea83ac2eafc5df7c7dbbb3b0c04de2f11e5e1b54c8a478a6d12b0a52","kind":"file","mode":33188,"name":"transaction.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","kind":"file","mode":33188,"name":"trees.rs"},{"hash":"2cfa7807b9e55b5e943987474c43959a77f4da2ca21c6e4454f42ce9fb2988c7","kind":"file","mode":33188,"name":"version.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","kind":"file","mode":33188,"name":"watch.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","kind":"file","mode":33188,"name":"workspace.rs"}]} -napl-store blob v1 60845 -//! The read commands and the maintenance commands. -//! -//! Every read resolves HEAD exactly once and then answers entirely from objects -//! reachable from that snapshot. No read creates a directory, takes a lock, or -//! writes a byte, so the whole read surface works on a read-only filesystem. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; - -use crate::blame::{blame, blame_json, untracked_json, Revision}; -use crate::compile::{ - compile, graph_for, install_index, read_surface_items, state_of, verdict as pending_verdict, -}; -use crate::cost::{cost_report, line_from_reported, CostLine}; -use crate::error::{error, OutcomeCode, Result}; -use crate::graph::{topological_levels, Graph}; -use crate::slice::receipt_requirements; -use crate::lint::{lint, lint_json, LintInput, LintReport}; -use crate::maintenance::{ - fsck, fsck_json, gc_json, plan_restore, plan_sweep, reachable_from_head, restore_json, - FileFact, RestorePlan, -}; -use crate::objects::{ - event_transition, read_module_state, snapshot_event, snapshot_states, walk_events, ModuleState, -}; -use crate::receipt::normalize_reported; -use crate::store_view::{ - flatten_tree, get_blob, get_json, get_object, has_object, ObjectSource, -}; -use crate::surface::surface_json; -use crate::target::{ - builtin_target, is_manifest_path, owned_globs, placement_root, projection_owned_paths, TargetDef, -}; -use crate::textdiff::render_drift; -use napl_store::store_hash::ObjectKind; -use serde_json::{json, Value}; - -use crate::disk::{self, Store}; -use crate::workspace::{discover_sources, read_working_text}; -/// A snapshot-consistent status report for one target: observation, zero -/// mutation. -pub fn status(workspace: &Path, target: &str) -> Result { - let store = disk::open(workspace); - let head = store.head()?; - let states = match &head { - Some(snapshot) => snapshot_states(&store, snapshot)?, - None => BTreeMap::new(), - }; - let sources = discover_sources(workspace)?; - let artifact = compile(&store, &sources, target, None)?; - let pending: Vec = artifact - .slices +/// The refusal for a module whose owned tree holds nothing stageable. +/// +/// The usual cause is that the only file under the crate's source directory is +/// the entry file, which is a derived projection this module may not own. A +/// refusal that says "owns nothing" while a file plainly sits there reads as a +/// bug, so it names which present paths are projections and what the first legal +/// file would be. The obvious wrong next move, hand-writing the entry file, is +/// warned against here rather than one rejection later. +fn nothing_stageable( + workspace: &Path, + target: &TargetDef, + module: &str, + sources: &[SourceModule], +) -> crate::error::CompilerError { + let root = sources .iter() - .map(|slice| slice.module.clone()) + .find(|s| s.module == module) + .map(placement_root) + .unwrap_or_default(); + let present: Vec = crate::porcelain::projection_labels(target, &root) + .into_iter() + .filter(|label| { + label["path"] + .as_str() + .is_some_and(|path| workspace.join(path).is_file()) + }) .collect(); - let frontier: BTreeSet = pending.iter().cloned().collect(); - let target_def = builtin_target(target)?; - let installs = install_index(&store, head.as_deref())?; - let docs_drifted = crate::staging::docs_drift(workspace)?; + let first = crate::porcelain::first_output(target, &root, module); + let hint = match first["path"].as_str() { + Some(path) => format!( + "; the first legal file would be {path:?}, and the crate's entry file is not one of this module's to write" + ), + None => String::new(), + }; + crate::error::error_with( + OutcomeCode::InvalidCandidate, + format!( + "module {module:?} owns nothing stageable in the working tree yet{hint}. Write an owned file, or stage an explicit candidate directory" + ), + json!({ + "firstOutput": first, + "ownedGlobs": owned_globs(target, &root), + "placementRoot": root, + "projectionsPresent": present, + }), + ) +} - let mut modules: Vec = Vec::new(); - for ((state_target, module), state_hash) in &states { - if state_target != target { +/// Working-tree files under a module's owned globs that no module has committed +/// and no other module's globs also match: files this module owns by elimination. +pub fn unclaimed_working_paths( + workspace: &Path, + store: &Store, + target: &TargetDef, + module: &str, + sources: &[SourceModule], +) -> Result> { + let ownership = committed_ownership(store, &target.name)?; + let Some(source) = sources.iter().find(|s| s.module == module) else { + return Ok(Vec::new()); + }; + let root = placement_root(source); + let src_dir = crate::paths::join_root(&root, &target.source_dir); + let dir = workspace.join(&src_dir); + if !dir.is_dir() { + return Ok(Vec::new()); + } + let mut out: Vec = Vec::new(); + for entry in scan_candidate(&dir)? { + if entry.kind != EntryKind::File { continue; } - let state = read_module_state(&store, state_hash)?; - let freshness = attribution_freshness(Some(&state), module, target, &sources); - let prompt_stale = freshness["status"].as_str() != Some("fresh"); - let ends = both_ends( - workspace, - &store, - &state, - prompt_stale, - &pending_body(&sources, module), - )?; - let is_pending = pending.contains(module); - let verdict = pending_verdict( - &store, - &sources, - &target_def, - module, - &states, - &installs, - &frontier, - ends.drift, - )?; - let (reason, reasons, reland) = verdict.json_fields(is_pending); - modules.push(json!({ - "attribution": freshness, - "baseStateKey": state_hash, - "docsDrift": docs_drifted.contains(&(target.to_string(), module.clone())), - "drift": ends.drift, - "inputKey": state.input_key, - "module": module, - "outputTree": state.output_tree, - "pending": is_pending, - "pendingReason": reason, - "pendingReasons": reasons, - "relandIdenticalBytes": reland, - "sliceHash": state.slice_hash, - "staleReferences": ends.stale_references as u64, - "surfaceHash": state.surface_hash, - })); + let path = crate::paths::join_root(&src_dir, &entry.path); + if !is_owned_path(target, &root, &path) { + continue; + } + if sole_glob_owner(target, sources, &path, &ownership).as_deref() == Some(module) { + out.push(path); + } } - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "head": head, - "manifestDiagnostics": manifest_diagnostics(workspace, target, &sources), - "modules": modules, - "pending": pending, - "target": target, - })) + Ok(out) } -/// What status observes about a module's code side, so status judges both ends. -/// -/// `drift` is true when the committed output differs from the working tree, and -/// `stale_references` counts the module's references reading stale right now. Both -/// come from the derivations `diff` and the language server already serve, which -/// is the whole point: status can never read clean while the query surface reads -/// stale, because there is one derivation and not two. -struct BothEnds { - drift: bool, - stale_references: usize, +fn entries_from(files: &BTreeMap>) -> Vec { + files + .iter() + .map(|(path, bytes)| ScannedEntry { + path: path.clone(), + kind: EntryKind::File, + bytes: bytes.clone(), + }) + .collect() } -fn both_ends( - workspace: &Path, +/// A module's committed output as `path -> bytes`. +fn committed_output( store: &Store, - state: &ModuleState, - prompt_stale: bool, - pending_body: &str, -) -> Result { - let mut drift = false; - let mut drifted_paths: BTreeSet = BTreeSet::new(); - if has_object(store, &state.output_tree) { - for (path, (blob, _mode)) in flatten_tree(store, &state.output_tree)? { - let bytes = std::fs::read(workspace.join(&path)).ok(); - if crate::attribution::code_drifted(Some(blob.as_str()), bytes.as_deref()) { - drift = true; - drifted_paths.insert(path); - } - } - } - let entries = match &state.attribution { - Some(recorded) if has_object(store, &recorded.record) => { - let bytes = get_blob(store, &recorded.record)?; - crate::attribution::parse(&String::from_utf8_lossy(&bytes)) - .map(|record| record.entries) - .unwrap_or_default() - } - _ => Vec::new(), + target_name: &str, + module: &str, +) -> Result>> { + let mut out: BTreeMap> = BTreeMap::new(); + let Some(state) = crate::porcelain::committed_state(store, target_name, module)? else { + return Ok(out); }; - let mut stale_references = 0usize; - for entry in &entries { - let working = entry - .file - .as_ref() - .and_then(|file| std::fs::read(workspace.join(file)).ok()); - let code_drift = entry - .file - .as_ref() - .map(|file| drifted_paths.contains(file)) - .unwrap_or(false); - if crate::attribution::entry_reference_status( - entry, - prompt_stale, - false, - code_drift, - working.as_deref(), - pending_body, - ) - .stale - { - stale_references += 1; - } + if !crate::store_view::has_object(store, &state.output_tree) { + return Ok(out); } - Ok(BothEnds { - drift, - stale_references, - }) + for (path, (blob, _mode)) in flatten_tree(store, &state.output_tree)? { + out.insert(path, get_blob(store, &blob)?); + } + Ok(out) } -/// The module's pending normalized prompt body, or the empty body when the -/// workspace no longer has a prompt for it. -fn pending_body(sources: &[crate::source::SourceModule], module: &str) -> String { - sources - .iter() - .find(|source| source.module == module) - .map(|source| source.normalized_body.clone()) - .unwrap_or_default() +/// The wire spelling of an attribution mode on a staged record. +pub fn mode_slug(mode: AttributionMode) -> &'static str { + match mode { + AttributionMode::Prove => "prove", + AttributionMode::PreProven => "pre-proven", + AttributionMode::Escape => "escape", + } } -/// The module PLAN beside the module's live STATE. -/// -/// The plan is the invariant requirements a conforming implementation derives -/// from the working tree's prompts alone: owned globs, placement root, projection -/// ownership, receipt requirements, wave position, never a candidate-dependent -/// fact, which is `add`'s job. The state is what is true of the module right now: -/// tracked or pending, drift, stale reference count, attribution status. The -/// command named status never answers with a plan alone. +/// Write the staged record's metadata: what commit needs to find the pieces and +/// to know how the attribution update was meant to be judged. /// -/// The payload also carries the derived entry-file projection, path and exact -/// content, computed the way commit computes it but over the working tree, so the -/// line a new file needs in a shared entry file is read from the tool rather than -/// composed by hand. -pub fn module_plan(workspace: &Path, target_name: &str, module: &str) -> Result { - let sources = discover_sources(workspace)?; - let target = builtin_target(target_name)?; - let source = sources.iter().find(|s| s.module == module).ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} has no prompt file in the workspace"), - ) - })?; - let graph = graph_for(&sources, target_name)?; - let levels = topological_levels(&graph)?; - let wave = *levels.get(module).ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} is not in the graph for target {target_name:?}"), - ) - })?; - let root = placement_root(source); - - let store = disk::open(workspace); - let artifact = compile(&store, &sources, target_name, None)?; - let pending = artifact.slices.iter().any(|slice| slice.module == module); - let frontier: BTreeSet = artifact - .slices - .iter() - .map(|slice| slice.module.clone()) - .collect(); - let head = store.head()?; - let head_states = crate::compile::head_states(&store, head.as_deref())?; - let installs = install_index(&store, head.as_deref())?; - let committed = committed_state(&store, target_name, module)?; - let state = match &committed { - Some(state) => { - let freshness = attribution_freshness(Some(state), module, target_name, &sources); - let prompt_stale = freshness["status"].as_str() != Some("fresh"); - let ends = both_ends( - workspace, - &store, - state, - prompt_stale, - &pending_body(&sources, module), - )?; - let verdict = pending_verdict( - &store, - &sources, - &target, - module, - &head_states, - &installs, - &frontier, - ends.drift, - )?; - let (reason, reasons, reland) = verdict.json_fields(pending); - json!({ - "attribution": freshness, - "docsDrift": crate::staging::docs_drift(workspace)? - .contains(&(target_name.to_string(), module.to_string())), - "drift": ends.drift, - "pending": pending, - "pendingReason": reason, - "pendingReasons": reasons, - "relandIdenticalBytes": reland, - "staleReferences": ends.stale_references as u64, - "tracked": true, - }) - } - None => { - let verdict = pending_verdict( - &store, - &sources, - &target, - module, - &head_states, - &installs, - &frontier, - false, - )?; - let (reason, reasons, reland) = verdict.json_fields(pending); - json!({ - "attribution": attribution_freshness(None, module, target_name, &sources), - "docsDrift": false, - "drift": false, - "pending": pending, - "pendingReason": reason, - "pendingReasons": reasons, - "relandIdenticalBytes": reland, - "staleReferences": 0, - "tracked": false, - }) - } - }; - - let (projection_path, projection_bytes) = - working_tree_projection(workspace, &store, &target, &sources, module, &root)?; - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, +/// The mode rides the record rather than the command line so that a staged +/// record commits the same way whoever picks it up. A record written before +/// modes existed carries only `noVerify` and reads as `escape` or `prove`. +pub fn write_record( + record_dir: &Path, + module: &str, + target_name: &str, + tree_hash: &str, + attribution_present: bool, + mode: AttributionMode, +) -> Result<()> { + let record = json!({ + "attribution": attribution_present, + "candidateTreeHash": tree_hash, + "mode": mode_slug(mode), "module": module, - "plan": { - "firstOutput": first_output(&target, &root, module), - "ownedGlobs": owned_globs(&target, &root), - "placementRoot": root, - "projection": { - "content": String::from_utf8_lossy(&projection_bytes), - "ownership": PROJECTION_OWNERSHIP, - "owner": PROJECTION_OWNER, - "path": projection_path, - "userSupplied": false, - }, - "projectionOwned": projection_owned_paths(&target, &root), - "projections": projection_labels(&target, &root), - "receiptRequirements": receipt_requirements(), - "wave": wave as u64, - }, - "state": state, + "noVerify": mode == AttributionMode::Escape, + "receiptVersion": crate::receipt::RECEIPT_VERSION, "target": target_name, - })) + }); + write_file( + &record_dir.join("record.json"), + &crate::canon::canonical_bytes(&record), + ) } -/// What a projection path is, said in the plan every time one is mentioned. -/// -/// The obvious wrong first move in a cold module is to hand-write the crate's -/// entry file, because it is the file that visibly has to change for a new -/// source file to be reachable. It is derived, it is owned by no module, and a -/// candidate carrying it is rejected. Saying so beside the path is cheaper than -/// the rejection that would otherwise teach it. -pub const PROJECTION_OWNERSHIP: &str = "tool-derived"; -pub const PROJECTION_OWNER: &str = - "no module: this path is recomputed by commit from the crate's committed module file sets and is never user-supplied; a candidate that carries it is rejected as invalid-candidate"; - -/// Every projection path this module's crate has, each labeled for what it is. -pub fn projection_labels(target: &TargetDef, root: &str) -> Vec { - projection_owned_paths(target, root) - .into_iter() - .map(|path| { - json!({ - "ownership": PROJECTION_OWNERSHIP, - "owner": PROJECTION_OWNER, - "path": path, - "userSupplied": false, - }) - }) - .collect() +/// The workspace-relative directory a staged record for one candidate lives in. +pub fn record_dir(workspace: &Path, module: &str, tree_hash: &str) -> PathBuf { + workspace.join(STAGING_DIR).join(format!( + "{}-{}", + crate::paths::encode_name(module), + &tree_hash[..16] + )) } -/// One concrete legal first output path for a module: the module's own name -/// under the target's source directory, inside the placement root. -/// -/// A plan that lists globs and a projection but no example leaves the author to -/// pick a path and find out afterwards whether it was ownable. Naming one that -/// certainly is turns the first move into a copy. -pub fn first_output(target: &TargetDef, root: &str, module: &str) -> Value { - let path = crate::paths::join_root( - root, - &format!( - "{}/{}.{}", - target.source_dir, - crate::paths::encode_name(module), - target.source_extension - ), - ); - json!({ - "detail": "one concrete path this module may own: its own name under the target's source directory, inside the placement root; any path matching the owned globs that is not a projection or a manifest is equally legal", - "path": path, - }) +/// The workspace-relative spelling of a path, the form `add` reports and +/// `commit --staged` accepts. +pub fn relative_record(workspace: &Path, path: &Path) -> String { + display_path(workspace, path) } -/// The crate's entry-file projection as commit would derive it if this module -/// were committed from the working tree right now. +/// Resolve the attribution update that rides this stage. /// -/// The inputs are the other modules' committed file sets plus this module's -/// working-tree files, and the command providers are read the same way: from the -/// other modules' recorded surfaces plus this module's working-tree surface. That -/// is what makes the answer useful for a file that is new: a projection derived -/// from committed state alone could never name it, and naming it is the whole -/// question a reader asks here. -fn working_tree_projection( +/// The order is transport first, affirmation second, refusal last. When the +/// diffs carry every recorded claim, nothing is asked for: the paperwork updates +/// itself as a consequence of the diff already being content-addressed on both +/// sides. When they do not, the listed claims are what `--attribution` affirms, +/// and an explicit map that covers the whole record still wins whole, because an +/// author reshaping a mapping is allowed to say so outright. +#[allow(clippy::too_many_arguments)] +fn resolve_attribution( workspace: &Path, store: &Store, target: &TargetDef, - sources: &[crate::source::SourceModule], - module: &str, root: &str, -) -> Result<(String, Vec)> { - let src_root = crate::paths::join_root(root, &target.source_dir); - let mut files: Vec = Vec::new(); - let mut providers: Vec = Vec::new(); - - let states = match store.head()? { - Some(head) => snapshot_states(store, &head)?, - None => BTreeMap::new(), + module: &str, + prompt: &SourceModule, + candidate: &Candidate, + attribution_path: Option<&Path>, + no_verify: bool, +) -> Result>> { + let body_lines = prompt.normalized_body.lines().count() as u32; + let supplied = match attribution_path { + Some(path) => Some(std::fs::read(path).map_err(|e| disk::io(path, &e))?), + None => None, }; - for ((state_target, other), hash) in &states { - if state_target != &target.name || other == module { - continue; + if no_verify { + // The escape hatch proves nothing and says so: the record installs + // marked stale, and an absent map stays absent. + return Ok(supplied); + } + + let transported = transport_for(workspace, store, target, module, prompt, candidate)?; + + match (transported, supplied) { + // Nothing recorded to carry: the map is the author's to supply, exactly + // as it was before transport existed. + (None, Some(bytes)) => { + let map = parse_map(&bytes)?; + attribution::validate(target, root, module, &candidate.files, body_lines, &map)?; + Ok(Some(bytes)) } - let state = read_module_state(store, hash)?; - if !has_object(store, &state.output_tree) { - continue; + // A module's first add has no committed map to transport, and it demands + // no hand-authored one either: the trivial whole map claims every staged + // file in full by the whole prompt body. It is honestly coarse, it is + // present and fresh rather than absent, and it is why the cold path to a + // verified first commit never needs `--no-verify`. + (None, None) => { + let whole = attribution::whole_map(module, &target.name, &candidate.files); + attribution::validate(target, root, module, &candidate.files, body_lines, &whole)?; + Ok(Some(attribution::to_yaml(&whole))) } - let owned: Vec = flatten_tree(store, &state.output_tree)?.into_keys().collect(); - if !owned - .iter() - .any(|path| crate::paths::is_under(path, &src_root)) - { - continue; + (Some(outcome), Some(bytes)) => { + let map = parse_map(&bytes)?; + let layered = layer_affirmations(module, &outcome, &map)?; + attribution::validate(target, root, module, &candidate.files, body_lines, &layered)?; + Ok(Some(attribution::to_yaml(&layered))) } - files.extend(owned); - for item in read_surface_items(store, &state.surface_hash)? { - if let Some(provider) = command_provider(&item.kind, &item.path) { - providers.push(provider); + (Some(outcome), None) => { + // Transport seeds from the evidence a capture verb already recorded: + // a claim refusing in a rewritten region is affirmed automatically + // when a reference covering that region carries endpoint hashes that + // still match the staged bytes, because the tool that applied the + // edit owns its diff. A recorded reference whose hashes no longer + // match affirms nothing and stays listed. + let (outcome, affirmed) = + self_affirm(outcome, &candidate.files, &prompt.normalized_body); + if !outcome.complete() { + let mut payload = refusal_payload(module, &outcome.refusals); + if let Some(object) = payload.as_object_mut() { + object.insert("affirmedFromReferenceRecord".into(), json!(affirmed)); + } + return Err(crate::error::error_with( + OutcomeCode::InvalidCandidate, + refusal_detail(module, &outcome.refusals), + payload, + )); } + let carried = outcome.record; + attribution::validate(target, root, module, &candidate.files, body_lines, &carried)?; + Ok(Some(attribution::to_yaml(&carried))) } } - - let working = working_owned_files(workspace, store, target, sources, module)?; - for item in crate::surface::extract_surface(target, root, &working)? { - if let Some(provider) = command_provider(&item.kind, &item.path) { - providers.push(provider); - } - } - files.extend(working.into_keys()); - Ok(crate::target::derive_lib_projection( - target, root, &files, &providers, - )) -} - -/// The module stem a surface item declares a command table for, when it declares -/// one: the convention is one `commands` function per module file. -fn command_provider(kind: &str, path: &str) -> Option { - if kind != "fn" { - return None; - } - let prefix = path.strip_suffix("::commands")?; - (!prefix.is_empty() && !prefix.contains(' ')).then(|| prefix.to_string()) } -/// A module's owned files as they sit in the working tree: the committed paths -/// read from disk, plus the paths under its globs no module has committed. -fn working_owned_files( +/// Transport the module's recorded attribution onto the staged bytes and the +/// pending prompt body, or `None` when nothing is recorded yet. +fn transport_for( workspace: &Path, store: &Store, target: &TargetDef, - sources: &[crate::source::SourceModule], module: &str, -) -> Result>> { - let mut out: BTreeMap> = BTreeMap::new(); - if let Some(state) = committed_state(store, &target.name, module)? { - if has_object(store, &state.output_tree) { - for path in flatten_tree(store, &state.output_tree)?.into_keys() { - if let Ok(bytes) = std::fs::read(workspace.join(&path)) { - out.insert(path, bytes); - } - } - } - } - for path in crate::staging::unclaimed_working_paths(workspace, store, target, module, sources)? { - if let Ok(bytes) = std::fs::read(workspace.join(&path)) { - out.insert(path, bytes); - } + prompt: &SourceModule, + candidate: &Candidate, +) -> Result> { + let Some(state) = crate::porcelain::committed_state(store, &target.name, module)? else { + return Ok(None); + }; + let Some(recorded) = state.attribution.as_ref() else { + return Ok(None); + }; + if !crate::store_view::has_object(store, &recorded.record) + || !crate::store_view::has_object(store, &recorded.prompt_hash) + { + return Ok(None); } - Ok(out) + let record_bytes = get_blob(store, &recorded.record)?; + let Some(record) = attribution::parse(&String::from_utf8_lossy(&record_bytes)) else { + return Ok(None); + }; + let committed_body = String::from_utf8_lossy(&get_blob(store, &recorded.prompt_hash)?) + .into_owned(); + let committed_files: BTreeMap = + committed_output(store, &target.name, module)? + .into_iter() + .map(|(path, bytes)| (path, String::from_utf8_lossy(&bytes).into_owned())) + .collect(); + let staged_files: BTreeMap = candidate + .files + .iter() + .map(|(path, bytes)| (path.clone(), String::from_utf8_lossy(bytes).into_owned())) + .collect(); + let _ = workspace; + Ok(Some(transport_attribution( + &record, + &TransportInputs { + committed_body: &committed_body, + pending_body: &prompt.normalized_body, + committed_files: &committed_files, + staged_files: &staged_files, + }, + ))) } -/// Observe, never mutate, the manifest/placement mismatches the toolchain now -/// only diagnoses: a module placed in a crate the workspace manifest does not -/// list, or a crate directory missing a manifest. Manifests are user-owned -/// seams, so these are reports, not writes. -fn manifest_diagnostics( - workspace: &Path, - target_name: &str, - sources: &[crate::source::SourceModule], -) -> Vec { - let Ok(target) = builtin_target(target_name) else { - return Vec::new(); - }; - let members = workspace_members(workspace, &target); - let mut roots: Vec = sources.iter().map(placement_root).collect(); - roots.sort(); - roots.dedup(); - let mut out: Vec = Vec::new(); - for root in roots { - if root.is_empty() { +/// Affirm refused claims from the evidence a capture verb recorded. +/// +/// A reference the verbs record carries the content hash of each endpoint's +/// post-edit bytes. When those hashes still match the staged bytes and the +/// pending body, the reference proves the current content is exactly what its +/// recorder wrote, so a claim landing in that region needs no hand-authored +/// affirmation: the record IS the affirmation. The claim keeps its recorded +/// coordinates, which the hashes prove are coordinates in these bytes. +/// +/// Nothing here guesses. A reference whose hashes no longer match proves +/// nothing, affirms nothing, and its claim stays in the refusal list beside +/// every other. +fn self_affirm( + outcome: TransportOutcome, + staged_files: &BTreeMap>, + pending_body: &str, +) -> (TransportOutcome, usize) { + let body_lines = pending_body.lines().count() as u32; + // The references whose evidence still holds, by the file they name. + let mut evidence: BTreeMap> = BTreeMap::new(); + for entry in &outcome.record.entries { + let Some(file) = &entry.file else { continue }; + if !entry.self_affirming() { continue; } - if let Some(members) = &members { - if !members.contains(&root) { - out.push(manifest_diagnostic( - &root, - &target.manifest_file, - "crate-not-listed-in-workspace-manifest", - &format!("the crate {root:?} holds a placed module but the workspace manifest does not list it as a member"), - )); - } - } - let manifest = workspace.join(&root).join(&target.manifest_file); - if !manifest.is_file() { - out.push(manifest_diagnostic( - &root, - &crate::paths::join_root(&root, &target.manifest_file), - "crate-directory-missing-manifest", - &format!("the crate directory {root:?} receives placed modules but has no manifest"), - )); + if !entry.hashes_match(staged_files.get(file).map(Vec::as_slice), pending_body) { + continue; } + evidence + .entry(file.clone()) + .or_default() + .push((entry.line_start, entry.line_end)); + } + if evidence.is_empty() { + return (outcome, 0); } - out -} - -/// One manifest observation, which always locates the file it means. -/// -/// A manifest is a user-owned seam: the toolchain never writes one, so a -/// diagnostic that only alluded to "the manifest" would leave the reader hunting -/// for the file they are the only one allowed to edit. Every diagnostic therefore -/// carries the exact workspace-relative path and says plainly whose file it is. -fn manifest_diagnostic( - crate_root: &str, - manifest: &str, - observation: &str, - detail: &str, -) -> Value { - json!({ - "crate": crate_root, - "detail": detail, - "manifest": manifest, - "observation": observation, - "owner": "user", - "remedy": format!("{manifest} is yours to edit; the toolchain diagnoses manifests and never writes them"), - }) -} -/// The crate directories the workspace-root manifest lists as members, or -/// `None` when there is no readable workspace manifest to compare against. -fn workspace_members(workspace: &Path, target: &TargetDef) -> Option> { - let text = std::fs::read_to_string(workspace.join(&target.manifest_file)).ok()?; - let mut members: Vec = Vec::new(); - for line in text.lines() { - let trimmed = line.trim().trim_end_matches(','); - if let Some(inner) = trimmed - .strip_prefix('"') - .and_then(|rest| rest.strip_suffix('"')) - { - if !is_manifest_path(target, inner) && inner.contains('/') { - members.push(inner.to_string()); - } + let TransportOutcome { record, refusals } = outcome; + let mut kept: Vec = Vec::new(); + let mut affirmed = 0usize; + for refusal in refusals { + let entry = &record.entries[refusal.index]; + let covered = entry + .file + .as_ref() + .and_then(|file| evidence.get(file).map(|spans| (file.clone(), spans))) + .map(|(file, spans)| { + let lines = staged_files + .get(&file) + .map(|bytes| String::from_utf8_lossy(bytes).lines().count() as u32) + .unwrap_or(0); + let in_bounds = entry.whole_file + || (entry.line_start >= 1 && entry.line_end <= lines); + let prompt_in_bounds = + entry.whole_prompt || (entry.prompt_start >= 1 && entry.prompt_end <= body_lines); + let overlaps = entry.whole_file + || spans.iter().any(|(start, end)| { + entry.line_start <= *end && *start <= entry.line_end + }); + in_bounds && prompt_in_bounds && overlaps + }) + .unwrap_or(false); + if covered { + affirmed += 1; + continue; } + kept.push(refusal); } - Some(members) + ( + TransportOutcome { + record, + refusals: kept, + }, + affirmed, + ) } -/// Classify a module's attribution freshness and, when it is not fresh, name the -/// exact fix. +/// Layer affirmations over the transported remainder. /// -/// It is a pure observation over content already resolved: an absent record, a -/// record installed unproven, or a record whose recorded prompt hash no longer -/// matches the current normalized body all read as needing a fresh commit. A -/// record proven against the body still on disk is fresh. -fn attribution_freshness( - state: Option<&ModuleState>, +/// A map with one entry per listed claim affirms exactly those, in the order the +/// refusal listed them, and every carried claim is kept. A map covering the whole +/// record replaces it outright. Anything between is refused rather than guessed +/// at, because a partial map whose entries cannot be matched to claims is not an +/// affirmation of anything in particular. +fn layer_affirmations( module: &str, - target: &str, - sources: &[crate::source::SourceModule], -) -> Value { - let directive = json!({ - "command": "commit", - "directive": format!( - "commit module {module:?} for target {target:?} with a fresh attribution update" - ), - "module": module, - "target": target, - }); - match state.and_then(|state| state.attribution.as_ref()) { - None => json!({ "fix": directive, "status": "absent" }), - Some(attribution) => { - let current = sources - .iter() - .find(|source| source.module == module) - .map(|source| source.normalized_source_hash.as_str()); - if attribution.stale { - json!({ - "fix": directive, - "reason": "installed with --no-verify", - "status": "stale", - }) - } else if current != Some(attribution.prompt_hash.as_str()) { - json!({ - "fix": directive, - "reason": "the prompt changed since the attribution was recorded", - "status": "stale", - }) - } else { - json!({ "status": "fresh" }) - } - } + outcome: &TransportOutcome, + supplied: &Attribution, +) -> Result { + if outcome.complete() || supplied.entries.len() == outcome.record.entries.len() { + return Ok(supplied.clone()); } -} - -/// The causal history reachable from HEAD, optionally narrowed to one module. -/// -/// The `ordinal` field is a presentation device computed from the causal graph -/// at read time. It is never stored and never enters an identity. -pub fn log(workspace: &Path, module: Option<&str>) -> Result { - let store = disk::open(workspace); - let Some(head) = store.head()? else { - return Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "entries": [], - "head": Value::Null, - })); - }; - let mut entries: Vec = Vec::new(); - for (ordinal, event) in causal_order(&store, &head)?.iter().enumerate() { - let transition_id = event_transition(&store, event)?; - let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; - if let Some(wanted) = module { - if transition["module"].as_str() != Some(wanted) { - continue; - } - } - entries.push(json!({ - "event": event, - "module": transition["module"], - "newState": transition["newState"], - "noteCount": attached_notes(&store, &transition_id)?.len() as u64, - "ordinal": ordinal as u64, - "referenceCount": transition_references(&store, &transition)?.len() as u64, - "target": transition["target"], - "transition": transition_id, - "writeSet": transition["writeSet"], - })); + if supplied.entries.len() != outcome.refusals.len() { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "the attribution update for module {module:?} carries {} entries, but the transport listed {} claims needing affirmation and the record holds {} in all; supply one entry per listed claim, in the order they were listed, or a complete map", + supplied.entries.len(), + outcome.refusals.len(), + outcome.record.entries.len() + ), + )); } - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "entries": entries, - "head": head, - "inspect": { - "command": "napl show ", - "detail": "the full text of a transition's notes and reference entries is served by `napl show `, one named hop from any entry's transition hash", - }, - })) -} - -/// Describe any stored object: its kind and, for JSON kinds, its payload. -/// -/// A transition also lists the notes attached to it inline, hash and text, so a -/// note attached is a note findable: nobody should have to know that notes live -/// behind an index and are stored as blobs in order to read one back. -pub fn show(workspace: &Path, hash: &str) -> Result { - let store = disk::open(workspace); - let (kind, payload) = get_object(&store, hash)?; - let body = if kind == ObjectKind::Blob { - json!({ "bytes": payload.len() as u64 }) - } else { - serde_json::from_slice(&payload).map_err(|e| { - error( - OutcomeCode::StoreIntegrity, - format!("object {hash} is not JSON: {e}"), - ) - })? - }; - let mut out = json!({ - "body": body, - "contractVersion": crate::version::CONTRACT_VERSION, - "hash": hash, - "kind": kind.as_wire(), - }); - if kind == ObjectKind::Transition { - out["notes"] = Value::Array(attached_notes(&store, hash)?); - out["references"] = Value::Array(transition_references(&store, &out["body"])?); + let mut layered = outcome.record.clone(); + for (affirmation, refusal) in supplied.entries.iter().zip(outcome.refusals.iter()) { + layered.entries[refusal.index] = affirmation.clone(); } - Ok(out) + Ok(layered) } -/// The reference entries one transition introduced, served inline. +/// Read a supplied `--attribution` map, answering a bad one with the whole +/// contract rather than a bare "not parseable". /// -/// A reference attached to a transition is exactly as findable as a note -/// attached to one: both are provenance riding a transition, and both are read -/// here rather than through plumbing. What this transition introduced is the -/// difference between its new state's attribution record and the record its base -/// state carried, so a commit that merely carried the module's existing -/// references forward lists none and a capture verb's transition lists the one it -/// recorded. A base that cannot be resolved degrades to the whole new record -/// rather than erroring, the way every other read here degrades. -fn transition_references(store: &Store, body: &Value) -> Result> { - let Some(new_state) = body["newState"].as_str() else { - return Ok(Vec::new()); - }; - let Some(record) = state_attribution_entries(store, new_state)? else { - return Ok(Vec::new()); - }; - let base = match body["baseStateKey"].as_str() { - Some(base) => state_attribution_entries(store, base)?.unwrap_or_default(), - None => Vec::new(), - }; - Ok(record - .into_iter() - .filter(|entry| !base.contains(entry)) - .map(|entry| entry.as_json()) - .collect()) -} - -/// The attribution entries a module state's record carries, or `None` when the -/// state names no record or the record is not readable. -fn state_attribution_entries( - store: &Store, - state_hash: &str, -) -> Result>> { - if !has_object(store, state_hash) { - return Ok(None); - } - let state = read_module_state(store, state_hash)?; - let Some(attribution) = state.attribution else { - return Ok(None); +/// The first refusal is the only one the author gets before they go rewrite the +/// file, so it carries every field, the closed vocabularies, and one complete +/// valid example. This is the same law the `--ref` payload already follows. +/// A map that fails mid-parse, or parses into no entries at all, is refused +/// naming the exact line and the construct that stopped the reader, beside the +/// full contract. A bare count of zero would describe the reader's outcome +/// rather than the author's document. +fn parse_map(bytes: &[u8]) -> Result { + let bad = |why: String, located: Option<&attribution::MapParseError>| { + crate::error::error_with( + OutcomeCode::InvalidCandidate, + why, + json!({ + "mapContract": attribution::map_contract(), + "stoppedAt": match located { + Some(err) => json!({ + "construct": err.construct, + "detail": err.detail, + "line": err.line, + }), + None => Value::Null, + }, + }), + ) }; - if !has_object(store, &attribution.record) { - return Ok(None); + let text = std::str::from_utf8(bytes) + .map_err(|_| bad("the --attribution file is not valid UTF-8".to_string(), None))?; + let parsed = attribution::parse_detailed(text).map_err(|err| { + bad( + format!( + "the --attribution file stopped being readable at line {}, reading a {}: {}", + err.line, err.construct, err.detail + ), + Some(&err), + ) + })?; + if parsed.module.is_empty() || parsed.target.is_empty() { + return Err(bad( + "the --attribution file names no module or no target".to_string(), + None, + )); } - let bytes = get_blob(store, &attribution.record)?; - Ok(crate::attribution::parse(&String::from_utf8_lossy(&bytes)).map(|record| record.entries)) + Ok(parsed) } -/// The notes attached to one transition, each as its blob name and its text. +/// Every staged record currently sitting in the staging directory, workspace +/// relative, with the module and target each one names. /// -/// The index is a plain list of blob names beside the store, so a missing or -/// unreadable index is simply no notes: a reader degrades to empty rather than -/// erroring, exactly as every other read here does. -fn attached_notes(store: &Store, transition: &str) -> Result> { - let index = store.root.join("notes").join(format!("{transition}.json")); - let bytes = match std::fs::read(&index) { - Ok(bytes) => bytes, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(disk::io(&index, &e)), +/// This is a plain directory read: staged records are disposable litter by the +/// git-state law, so what is there is the whole truth and nothing indexes them. +pub fn staged_records(workspace: &Path) -> Result> { + let dir = workspace.join(STAGING_DIR); + let mut out: Vec = Vec::new(); + let listing = match std::fs::read_dir(&dir) { + Ok(listing) => listing, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(disk::io(&dir, &e)), }; - let names: Vec = serde_json::from_slice(&bytes).unwrap_or_default(); - let mut out: Vec = Vec::new(); - for name in names { - if !has_object(store, &name) { - continue; + let mut paths: Vec = Vec::new(); + for entry in listing { + let entry = entry.map_err(|e| disk::io(&dir, &e))?; + if entry.path().join("record.json").is_file() { + paths.push(entry.path()); } - let body = get_blob(store, &name)?; - let parsed: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); - out.push(json!({ - "hash": name, - "note": parsed["note"].as_str().unwrap_or_default(), - })); } - Ok(out) -} - -/// The raw payload bytes of an object, with the envelope stripped. -pub fn cat_file(workspace: &Path, hash: &str) -> Result> { - Ok(get_object(&disk::open(workspace), hash)?.1) -} - -/// The recorded surface of a module as of HEAD. -/// -/// A module with no committed state under this target has no recorded surface at -/// all, and an empty item list would read as a real surface with nothing public. -/// The honest answer names the absent baseline and carries no items, exactly as -/// `diff` and `blame` answer for a missing baseline. -pub fn surface_of(workspace: &Path, target: &str, module: &str) -> Result { - let store = disk::open(workspace); - let Some(state) = committed_state(&store, target, module)? else { - return Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "module": module, - "reason": format!( - "module {module:?} has no committed state under target {target:?}, so there is no recorded surface to serve and no baseline to read one from" - ), - "target": target, - "tracked": false, - })); - }; - let items = read_surface_items(&store, &state.surface_hash)?; - let mut out = surface_json(module, target, &items); - if let Some(object) = out.as_object_mut() { - object.insert("tracked".to_string(), Value::Bool(true)); + // The directory hands entries back in whatever order the filesystem holds + // them, which is exactly the ambient enumeration order the determinism + // contract excludes: sort before anything reads them. + paths.sort(); + for path in paths { + let bytes = std::fs::read(path.join("record.json")) + .map_err(|e| disk::io(&path.join("record.json"), &e))?; + let meta: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + out.push(StagedRecord { + module: meta["module"].as_str().unwrap_or_default().to_string(), + target: meta["target"].as_str().unwrap_or("rust").to_string(), + relative: display_path(workspace, &path), + path, + }); } Ok(out) } -/// Whether this workspace has a committed head at all. -/// -/// The one observation a command whose meaning requires a baseline makes before -/// it answers, so a headless workspace gets the prerequisite sequence instead of -/// an empty shape that would read as success. -pub fn has_committed_head(workspace: &Path) -> Result { - Ok(disk::open(workspace).head()?.is_some()) +/// One staged record as the staging directory holds it. +#[derive(Debug, Clone)] +pub struct StagedRecord { + pub module: String, + pub target: String, + /// The workspace-relative spelling, which is what `--staged` accepts. + pub relative: String, + pub path: PathBuf, } -/// Drift between committed outputs and the working tree, as a canonical patch. +/// `napl commit` with no arguments: install every staged record in dependency +/// wave order, one outcome reported per record. /// -/// A target no committed module owns anything under has no baseline, and the -/// honest answer is `tracked: false` with the reason: an empty patch would say -/// "nothing drifted" when the truth is "nothing was compared". -pub fn diff(workspace: &Path, target: &str) -> Result { - let store = disk::open(workspace); - let committed = committed_text(&store, target)?; - if committed.is_empty() { - return Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "reason": format!( - "no committed module owns any output under target {target:?}, so there is no baseline to diff against" - ), - "target": target, - "tracked": false, +/// A multi-module add followed by a bare commit is one command each. The +/// sequencing and the retry the decision table already defines belong to the +/// tool, so nobody hand-loops over records in wave order and gets it subtly +/// wrong. Each record still goes through exactly the same guarded transaction +/// `--staged` runs, and each reports its own outcome whatever the others did. +pub fn commit_all(workspace: &Path) -> Result<(Value, i32)> { + let records = staged_records(workspace)?; + let ordered = in_wave_order(workspace, records)?; + let mut outcomes: Vec = Vec::new(); + let mut exit_code = 0; + // Set once an earlier record in this invocation has moved the head. Only + // then is a later record's write-conflict or stale-input this invocation's + // own doing, and only then is re-deriving it the tool's job rather than a + // report the caller has to act on. + let mut head_moved = false; + for record in &ordered { + let mut report = commit_staged(workspace, &record.path); + let mut retried = false; + if head_moved && retryable_against_a_moved_head(report.outcome) { + // Re-derive exactly what re-running `add` would produce now: the + // record's own ingested bytes, re-drafted against the head this + // invocation just advanced, with its resolved attribution carried + // back in as affirmations. Modules sharing one crate overlap on the + // crate's ownership write key, so the second one always arrives + // against a head the first one moved; the retry is what makes that + // pair one invocation instead of a hand-ordered sequence. + // + // A docs record has nothing to re-derive: its staged bytes are a + // prompt, and the commit reads the head it arrives at, so retrying it + // as it stands is exactly re-running add. + let docs = record_kind(&record.path).ok().flatten().as_deref() == Some(DOCS_RECORD); + if docs || rederive_against_head(workspace, record).is_ok() { + retried = true; + report = commit_staged(workspace, &record.path); + } + } + if report.outcome != OutcomeCode::Success && exit_code == 0 { + exit_code = crate::error::outcome_exit_code(report.outcome); + } + if report.outcome == OutcomeCode::Success { + head_moved = true; + } + outcomes.push(json!({ + "module": record.module, + "outcome": report.payload["outcome"].clone(), + "record": record.relative, + "report": report.payload, + "retried": retried, + "target": record.target, })); } - let paths: Vec = committed.keys().cloned().collect(); - let working = read_working_text(workspace, &paths); - let patch = render_drift(&committed, &working); - Ok(json!({ + let mut payload = json!({ "contractVersion": crate::version::CONTRACT_VERSION, - "patch": patch, - "target": target, - "tracked": true, - })) + "installed": outcomes.len() as u64, + "order": ordered + .iter() + .map(|record| json!(record.module)) + .collect::>(), + "outcomes": outcomes, + }); + // A bare commit that found nothing staged says so instead of dressing + // emptiness as success, and it names the work that is actually waiting: every + // module carrying working-tree drift, beside the exact `napl add` invocation + // that would stage it. The empty commit is the teacher, never the trap. + if ordered.is_empty() { + let drifted = drifted_modules(workspace)?; + // The teacher includes docs drift: a prompt whose prose moved is work + // waiting exactly as drifted code is, and the invocation that records it + // is the prompt path itself. + let docs_drifted = docs_drifted_prompts(workspace)?; + if let Some(object) = payload.as_object_mut() { + object.insert("stagedRecords".to_string(), json!(0)); + object.insert( + "detail".to_string(), + json!(match (drifted.is_empty(), docs_drifted.is_empty()) { + (true, true) => "zero records were staged and no module carries working-tree drift, so there was nothing to install".to_string(), + (true, false) => format!( + "zero records were staged; {} prompt(s) carry docs-only drift and are listed with the exact `napl add ` that would record each", + docs_drifted.len() + ), + (false, true) => format!( + "zero records were staged; {} module(s) carry working-tree drift and are listed with the exact `napl add` invocation that would stage each", + drifted.len() + ), + (false, false) => format!( + "zero records were staged; {} module(s) carry working-tree drift and {} prompt(s) carry docs-only drift, each listed with the exact `napl add` invocation that would record it", + drifted.len(), + docs_drifted.len() + ), + }), + ); + object.insert( + "docsDriftedPrompts".to_string(), + Value::Array(docs_drifted), + ); + object.insert( + "driftedModules".to_string(), + Value::Array(drifted), + ); + } + } + Ok((payload, exit_code)) } -/// Derived line ancestry for one committed path. -/// -/// A path no committed module owns has no ancestry to replay, and empty ancestry -/// would read as a file that exists and was never written. It answers -/// `tracked: false` naming the absent baseline instead, an honest success shape. +/// Every module whose committed output the working tree has moved away from, +/// beside the exact `napl add` invocation that stages it. /// -/// Blame reads committed bytes only. When the working tree has moved away from -/// them the report carries a drift notice naming `napl diff` as where the -/// uncommitted change lives, so ancestry is never mistaken for the present file. -pub fn blame_path(workspace: &Path, path: &str) -> Result { +/// This is the same drift `status` and `diff` report, read straight off the +/// committed output trees, so the empty bare commit can never disagree with the +/// query surface about what is waiting. +fn drifted_modules(workspace: &Path) -> Result> { let store = disk::open(workspace); let Some(head) = store.head()? else { - return Ok(untracked_json( - path, - "this workspace has no committed head, so no module owns this path yet", - )); + return Ok(Vec::new()); }; - if !committed_manifest_all_targets(&store)?.contains(path) { - return Ok(untracked_json( - path, - "no committed module owns this path, so there is no committed baseline to trace it through", - )); - } - let mut revisions: Vec = Vec::new(); - for event in causal_order(&store, &head)? { - let transition_id = event_transition(&store, &event)?; - let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; - let touched = transition["installPaths"] - .as_array() - .map(|paths| paths.iter().any(|p| p.as_str() == Some(path))) - .unwrap_or(false); - if !touched { + let mut listed: Vec = Vec::new(); + for ((target, module), state_hash) in snapshot_states(&store, &head)? { + let state = read_module_state(&store, &state_hash)?; + if !crate::store_view::has_object(&store, &state.output_tree) { continue; } - let Some(state_hash) = transition["newState"].as_str() else { - continue; - }; - let state = read_module_state(&store, state_hash)?; - let files = flatten_tree(&store, &state.output_tree)?; - let Some((blob, _)) = files.get(path) else { + let mut paths: Vec = Vec::new(); + for (path, (blob, _mode)) in flatten_tree(&store, &state.output_tree)? { + let bytes = std::fs::read(workspace.join(&path)).ok(); + // An unreadable working file is not drift: the reader knows nothing + // about it, so it claims nothing, exactly as status does. + if attribution::code_drifted(Some(blob.as_str()), bytes.as_deref()) { + paths.push(path); + } + } + if paths.is_empty() { continue; - }; - let bytes = get_blob(&store, blob)?; - revisions.push(Revision { - transition: transition_id, - content: String::from_utf8_lossy(&bytes).into_owned(), - }); - } - let mut value = blame_json(path, &blame(&revisions)); - let committed = revisions.last().map(|revision| revision.content.clone()); - let working = std::fs::read(workspace.join(path)).ok(); - let working_text = working - .as_deref() - .map(|bytes| String::from_utf8_lossy(bytes).into_owned()); - let drifted = match (&committed, &working_text) { - (Some(committed), Some(working)) => committed != working, - _ => false, - }; - if let Some(object) = value.as_object_mut() { - object.insert("drift".to_string(), Value::Bool(drifted)); - if drifted { - object.insert( - "driftNotice".to_string(), - Value::String(format!( - "the working tree of {path:?} differs from the committed output; this ancestry is over the committed bytes only, and the uncommitted change lives in `napl diff`" - )), - ); - object.insert("driftInspect".to_string(), Value::String("napl diff".to_string())); } + listed.push(json!({ + "addCommand": format!("napl add --module {module}"), + "driftedPaths": paths, + "module": module, + "target": target, + })); } - Ok(value) + Ok(listed) } -/// The source-language scan `napl lint ` performs: parse every -/// committed module's owned working-tree file under that target with the pinned -/// grammar and report one finding per file that fails. +/// Every prompt whose prose moved inside its docs fences since it was committed, +/// beside the `napl add ` that records it. /// -/// This is a different question from the graph analyses beside it and it needs no -/// roots declaration to answer, because nothing about reachability is involved. -/// The payload names its scope, so an empty findings list is never read wider -/// than the scan that produced it. -pub fn source_syntax_scan(workspace: &Path, target_name: &str) -> Result { +/// A prompt whose drift reaches the semantic body is not listed here: that module +/// reads as source-changed everywhere else, and naming the docs door beside it +/// would teach the invocation that refuses. +fn docs_drifted_prompts(workspace: &Path) -> Result> { let store = disk::open(workspace); - let target = builtin_target(target_name)?; - let sources = discover_sources(workspace)?; - let roots: BTreeSet = sources - .iter() - .filter(|source| source.targets.iter().any(|t| t == target_name)) - .map(placement_root) - .collect(); - let mut findings: Vec = Vec::new(); - let mut scanned = 0u64; - for path in committed_manifest(&store, target_name)?.into_keys() { - if !roots - .iter() - .any(|root| crate::surface::is_target_source(&target, root, &path)) - { - continue; - } - let Ok(bytes) = std::fs::read(workspace.join(&path)) else { - continue; - }; - scanned += 1; - if let Some(failure) = crate::surface::parse_check(&target, &path, &bytes)? { - findings.push(json!({ - "autoFixable": false, - "file": failure.path, - "kind": "source-parse-error", - "line": failure.line, - "parseFailure": failure.as_json(), - "proposal": format!( - "fix the syntax at line {} of {:?}; the pinned grammar cannot parse it, so no surface can be extracted from it", - failure.line, failure.path - ), - "scope": crate::surface::SOURCE_SYNTAX_SCOPE, - })); + if store.head()?.is_none() { + return Ok(Vec::new()); + } + let mut listed: Vec = Vec::new(); + for source in discover_sources(workspace)? { + for target in &source.targets { + if matches!(prompt_drift(&store, target, &source), Ok(PromptDrift::DocsOnly)) { + listed.push(json!({ + "addCommand": format!("napl add {}", source.path), + "module": source.module, + "promptPath": source.path, + "target": target, + })); + } } } - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "detail": "every committed module's owned working-tree file under this target was parsed with the target's pinned grammar; this scan judges syntax and nothing else, and it needs no roots declaration", - "filesScanned": scanned, - "findings": findings, - "rootsRequired": false, - "scope": crate::surface::SOURCE_SYNTAX_SCOPE, - "target": target_name, - })) + Ok(listed) } -/// Diagnose a workspace. -pub fn lint_workspace(workspace: &Path, target: &str) -> Result<(Graph, LintReport, Value)> { - let store = disk::open(workspace); - let sources = discover_sources(workspace)?; - let artifact = compile(&store, &sources, target, None)?; - let graph = crate::compile::graph_for(&sources, target)?; - let definition = builtin_target(target)?; - let states = match store.head()? { - Some(snapshot) => snapshot_states(&store, &snapshot)?, - None => BTreeMap::new(), - }; - - let mut committed_input_keys: BTreeMap> = BTreeMap::new(); - let mut referenced_names: BTreeMap> = BTreeMap::new(); - let mut surface_names: BTreeMap> = BTreeMap::new(); - for module in graph.keys() { - let (state, _) = state_of(&store, &states, &definition, module)?; - committed_input_keys.insert(module.clone(), state.input_key.clone()); - surface_names.insert( - module.clone(), - read_surface_items(&store, &state.surface_hash)? - .iter() - .filter_map(|item| item.path.rsplit("::").next().map(str::to_string)) - .collect(), - ); - referenced_names.insert(module.clone(), identifiers(&store, &state)?); - } - let current_input_keys: BTreeMap = artifact - .slices - .iter() - .map(|slice| (slice.module.clone(), slice.input_key.clone())) - .collect(); - - let roots = declared_roots(&sources); - let report = lint( - &graph, - &LintInput { - roots, - committed_input_keys, - current_input_keys, - referenced_names, - surface_names, - }, - ); - let value = lint_json(&report); - Ok((graph, report, value)) +/// The two outcomes an earlier record in the same invocation can cause: the +/// head moved under this record, and moving it is what this invocation did. +/// +/// `base-output-changed` is deliberately not here. It says the module's *own* +/// committed output moved, which no other module's commit can do, so it is a +/// genuine report to the caller rather than self-inflicted sequencing. +fn retryable_against_a_moved_head(outcome: OutcomeCode) -> bool { + matches!(outcome, OutcomeCode::WriteConflict | OutcomeCode::StaleInput) } -/// Cost over an explicit set: receipt object names in the store, receipt file -/// paths, usage-proxy session windows, or any mixture. +/// Re-derive a staged record against the current head, in place. /// -/// A session window is priced here, lazily, at read time: a capture verb records -/// the window as a reference and never aggregates it, so the log lines are summed -/// only when a reader asks. A model the pinned table does not know stays -/// `unknown`, never zero. -pub fn cost( - workspace: &Path, - names: &[String], - files: &[String], - sessions: &[String], -) -> Result { - let store = disk::open(workspace); - let mut lines: Vec = Vec::new(); - for session in sessions { - lines.extend(crate::cost::lines_from_session( - &std::fs::read_to_string(workspace.join(crate::capture::USAGE_LOG_PATH)) - .unwrap_or_default(), - session, - )); - } - for name in names { - let body = get_json(&store, name, ObjectKind::Receipt)?; - lines.push(receipt_line(workspace, name, &body)?); - } - for file in files { - let path = Path::new(file); - let bytes = std::fs::read(path).map_err(|e| disk::io(path, &e))?; - let body: Value = serde_json::from_slice(&bytes).map_err(|e| { - error( - OutcomeCode::InvalidReceipt, - format!("{file}: invalid JSON: {e}"), - ) - })?; - let name = crate::canon::json_hash(ObjectKind::Receipt, &body); - lines.push(receipt_line(workspace, &name, &body)?); - } - Ok(cost_report(&lines)) +/// The record's ingested candidate bytes are the immutable truth of what was +/// staged, whichever of the three sources produced them, so re-running the add +/// pipeline over them is exactly re-running add: the receipt is re-drafted +/// against the head that just moved, the attribution transport is recomputed, +/// and the resolved map already in the record rides back in as affirmations. +/// The record directory is keyed by the candidate tree hash, which the bytes did +/// not change, so the record rewrites itself at the same path. +fn rederive_against_head(workspace: &Path, record: &StagedRecord) -> Result<()> { + let (_, candidate_dir, attribution_path, mode) = resolve(&record.path)?; + add( + workspace, + &record.target, + &record.module, + &AddSource::CandidateDir(candidate_dir), + attribution_path.as_deref(), + None, + mode == AttributionMode::Escape, + false, + )?; + Ok(()) } -/// One receipt's cost line: a reported total priced directly, a recorded session -/// window priced against the log the record names. -fn receipt_line(workspace: &Path, name: &str, body: &Value) -> Result { - let module = body["module"].as_str().unwrap_or_default(); - let reported = normalize_reported(&body["reported"]); - match crate::cost::window_from_reported(&reported) { - Some(window) => { - let log = window_log(workspace, &window.log_path); - Ok(crate::cost::line_from_window( - name, - module, - &window, - log.as_deref(), - )) +/// Remove one staged record directory. +fn sweep_record(workspace: &Path, record: &Path) -> Result<()> { + let dir = if record.join("record.json").is_file() { + record.to_path_buf() + } else if record.file_name().and_then(|n| n.to_str()) == Some("record.json") { + match record.parent() { + Some(parent) => parent.to_path_buf(), + None => return Ok(()), } - None => line_from_reported(name, module, &reported), + } else { + return Ok(()); + }; + if !dir.starts_with(workspace.join(STAGING_DIR)) { + return Ok(()); } -} - -/// The bytes of a log a record names, read at the moment the reader asks. -/// -/// The path is a record's own workspace-relative path, so a path that escapes -/// the workspace or is absolute is not a log this reader will open: it reads as -/// absent, which the report already knows how to say. -fn window_log(workspace: &Path, log_path: &str) -> Option { - let candidate = Path::new(log_path); - if candidate.is_absolute() || log_path.split('/').any(|part| part == "..") { - return None; + match std::fs::remove_dir_all(&dir) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(disk::io(&dir, &e)), } - std::fs::read_to_string(workspace.join(candidate)).ok() } -/// Sweep every object no named head reaches, and the staged records beside them. -/// -/// The sweep set is computed outside the lock; only the deletions run under it, -/// and a writer publishing concurrently is never blocked because publication -/// takes no lock at all. +/// Sort staged records into dependency wave order, the order commit installs in. /// -/// Staged records belong in this report because they are exactly what the -/// git-state law calls disposable litter: never promoted to history, re-creatable -/// by re-running `add`, and reachable from no head. `--apply` sweeps them, so -/// nobody ever hand-edits `.napl` to be rid of one. -pub fn gc(workspace: &Path, apply: bool) -> Result { - let store = disk::init(workspace)?; - let all: BTreeSet = store.names()?.into_iter().collect(); - let reachable = match store.head()? { - Some(head) => reachable_from_head(&store, &head)?, - None => BTreeSet::new(), - }; - let swept = plan_sweep(&all, &reachable); - let staged: Vec = crate::staging::staged_records(workspace)? - .into_iter() - .map(|record| record.relative) - .collect(); - let repacked = if apply { - disk::repack(&store, &reachable, "gc")? - } else { - disk::plan_repack(&store, &reachable)? - }; - if apply { - for name in &swept { - disk::remove_object(&store, name)?; - } - for record in &staged { - let dir = workspace.join(record); - match std::fs::remove_dir_all(&dir) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(disk::io(&dir, &e)), - } - } +/// A module the graph does not place sorts after every placed one rather than +/// blocking the whole batch: its own commit will say what is wrong with it, and +/// refusing to sequence is not the same as refusing to install. +fn in_wave_order(workspace: &Path, records: Vec) -> Result> { + let sources = discover_sources(workspace)?; + let mut keyed: Vec<(usize, String, StagedRecord)> = Vec::new(); + for record in records { + let wave = crate::compile::graph_for(&sources, &record.target) + .ok() + .and_then(|graph| crate::graph::topological_levels(&graph).ok()) + .and_then(|levels| levels.get(&record.module).copied()) + .unwrap_or(usize::MAX); + keyed.push((wave, record.relative.clone(), record)); } - let mut payload = gc_json(&swept, all.len() - swept.len()); - payload["stagedRecords"] = json!(staged); - let mut repack = crate::maintenance::repack_json(&repacked); - repack["applied"] = json!(apply); - payload["repack"] = repack; - Ok(payload) + keyed.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); + Ok(keyed.into_iter().map(|(_, _, record)| record).collect()) } -/// The exact command the owner runs to take the migrated object files out of -/// the working tree. It is theirs to run, not the toolchain's: removing them -/// from the index is an ordinary git commit and never a store mutation. -pub const STORE_MIGRATE_REMOVAL: &str = "git rm -r .napl/store/objects"; - -/// `napl store migrate`: the one way `refs/heads/napl-store` comes to exist. -/// -/// It walks the tracked store's loose objects and every pack into git blobs, -/// re-hashing each envelope to the napl name it must keep, builds one fanout -/// tree and one deterministic commit over them, and creates the branch there. It -/// refuses outside a git repository and refuses when the branch already exists, -/// and it touches no tracked file: the payload names the object files as ready -/// to leave the working tree and the exact command that does it, because that -/// removal is the owner's ordinary git commit. +/// Install what add staged: read the staged record and run the one guarded +/// commit over the immutable staged copy. /// -/// After it, selection observes the branch and every later read and write goes -/// through the git provider with no flag set anywhere. -pub fn store_migrate(workspace: &Path) -> Result<(bool, Value)> { - let store = disk::open(workspace); - let Some(repository) = napl_store::git_store::enclosing_repository(workspace) else { - return Ok(( - false, - migrate_refusal( - "not-a-git-repository", - "this workspace is not inside a git repository, so there is no object database to migrate into; the tracked layout with its packfile mitigation stays the floor", +/// A record that installs is swept. It has become history, and leaving it in the +/// staging directory would make the next bare commit re-attempt an installed +/// transition against a head that has already moved past it. Nobody should ever +/// hand-edit `.napl` to be rid of one, and nobody should have to. +pub fn commit_staged(workspace: &Path, record: &Path) -> CommitReport { + // A docs record installs through its own door: it carries no candidate and + // drafts no receipt, so the candidate pipeline has nothing to read. + let report = match record_kind(record) { + Ok(Some(kind)) if kind == DOCS_RECORD => crate::commit::commit_docs(workspace, record), + Ok(_) => match resolve(record) { + Ok((receipt_path, candidate_dir, attribution_path, mode)) => commit_with_mode( + workspace, + &receipt_path, + &candidate_dir, + attribution_path.as_deref(), + mode, ), - )); + Err(err) => CommitReport { + outcome: err.code, + payload: error_payload(&err, false, false), + }, + }, + Err(err) => CommitReport { + outcome: err.code, + payload: error_payload(&err, false, false), + }, }; - let provider = napl_store::git_store::GitProvider::open(&repository, &store.root) - .map_err(disk::git_error)?; - if provider.tip().map_err(disk::git_error)?.is_some() { - return Ok(( - false, - migrate_refusal( - "already-migrated", - "refs/heads/napl-store already exists, and migration is a one-shot verb that never rewrites a store branch", - ), - )); + if report.outcome == OutcomeCode::Success { + if let Err(err) = sweep_record(workspace, record) { + return CommitReport { + outcome: err.code, + payload: error_payload(&err, true, true), + }; + } } - - let tracked = tracked_object_files(&store.root)?; - let outcome = provider - .convert_tracked_store(&store.root, crate::version::CONTRACT_VERSION) - .map_err(disk::git_error)?; - - Ok(( - true, - json!({ - "branch": napl_store::git_store::STORE_BRANCH, - "commit": outcome.commit, - "contractVersion": crate::version::CONTRACT_VERSION, - "objects": outcome.objects, - "outcome": "migrated", - "readyToLeaveWorkingTree": { - "detail": "migration touched none of these: removing them from the index is your ordinary git commit, not a store mutation", - "files": tracked, - "removalCommand": STORE_MIGRATE_REMOVAL, - "root": format!("{}/objects", disk::STORE_DIR), - }, - "snapshot": outcome.snapshot, - }), - )) + report } -/// How many files the tracked object layout holds under the store root: every -/// loose object file, every pack, and every pack index. -fn tracked_object_files(root: &Path) -> Result { - let mut count = 0; - let mut stack = vec![root.join("objects")]; - while let Some(directory) = stack.pop() { - let entries = match std::fs::read_dir(&directory) { - Ok(entries) => entries, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, - Err(e) => return Err(disk::io(&directory, &e)), - }; - for entry in entries { - let entry = entry.map_err(|e| disk::io(&directory, &e))?; - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else { - count += 1; - } - } +/// The directory a `--staged` argument names, whether the caller spelled the +/// directory or the `record.json` inside it. +pub fn record_directory(record: &Path) -> Result { + if record.join("record.json").is_file() { + Ok(record.to_path_buf()) + } else if record.file_name().and_then(|n| n.to_str()) == Some("record.json") { + Ok(record + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from("."))) + } else { + Err(error( + OutcomeCode::InvalidReceipt, + format!("no staged record at {}", record.display()), + )) } - Ok(count) } -/// A migration refusal: the reason named, nothing done, nothing written. -fn migrate_refusal(reason: &str, detail: &str) -> Value { - json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "detail": detail, - "outcome": "refused", - "reason": reason, +/// The metadata a staged record declares. +pub fn record_meta(record_dir: &Path) -> Result { + let bytes = std::fs::read(record_dir.join("record.json")) + .map_err(|e| disk::io(&record_dir.join("record.json"), &e))?; + serde_json::from_slice(&bytes).map_err(|e| { + error( + OutcomeCode::InvalidReceipt, + format!("staged record is not JSON: {e}"), + ) }) } -/// Verify envelope hashes, kind validity, causal parent links, and every pack's -/// name, index, and members. -pub fn fsck_store(workspace: &Path) -> Result<(bool, Value)> { - let store = disk::open(workspace); - let mut findings = fsck(&store)?; - findings.extend(disk::verify_packs(&store)?.into_iter().map(pack_finding)); - findings.sort(); - findings.dedup(); - Ok((findings.is_empty(), fsck_json(&findings))) +/// The `kind` a staged record declares. An ordinary candidate record declares +/// none, so a record written before docs records existed reads as `None`. +fn record_kind(record: &Path) -> Result> { + let dir = record_directory(record)?; + Ok(record_meta(&dir)?["kind"].as_str().map(str::to_string)) } -/// Translate one store-level pack finding into the compiler's fsck vocabulary. -fn pack_finding(finding: napl_store::store_io::PackFinding) -> crate::maintenance::FsckFinding { - use napl_store::store_io::PackFinding as Pack; - let (pack, detail) = match finding { - Pack::NameMismatch { pack } => { - let detail = "the pack's bytes do not hash to its name".to_string(); - (pack, detail) - } - Pack::IndexDisagrees { pack, detail } => (pack, detail), - Pack::MemberCorrupt { pack, object } => { - let detail = format!("packed object {object} does not verify against its name"); - (pack, detail) - } +fn resolve(record: &Path) -> Result<(PathBuf, PathBuf, Option, AttributionMode)> { + let record_dir = record_directory(record)?; + let meta = record_meta(&record_dir)?; + let no_verify = meta["noVerify"].as_bool().unwrap_or(false); + let mode = match meta["mode"].as_str() { + Some("pre-proven") => AttributionMode::PreProven, + Some("escape") => AttributionMode::Escape, + Some("prove") => AttributionMode::Prove, + // A record written before modes existed says only whether it escaped. + _ if no_verify => AttributionMode::Escape, + _ => AttributionMode::Prove, }; - crate::maintenance::FsckFinding::PackDamaged { pack, detail } -} - -/// Repack the store when its loose-object count crosses the compiled-in bound. -/// -/// Called after a successful commit's critical section and outside every lock: -/// the pointer has already moved, so a repack here never lengthens the bounded -/// section and never blocks a writer. It packs the same reachable closure `gc -/// --apply` packs, so the pack it writes is byte-identical to gc's. -pub fn repack_if_loose(store: &Store) -> Result> { - if disk::loose_object_count(store)? <= crate::maintenance::LOOSE_OBJECT_REPACK_THRESHOLD { - return Ok(None); - } - let reachable = match store.head()? { - Some(head) => reachable_from_head(store, &head)?, - None => return Ok(None), + let attribution_path = if meta["attribution"].as_bool().unwrap_or(false) { + Some(record_dir.join("attribution.yaml")) + } else { + None }; - let outcome = disk::repack(store, &reachable, "gc")?; - if outcome.pack.is_none() { - return Ok(None); - } - Ok(Some(crate::maintenance::repack_json(&outcome))) + Ok(( + record_dir.join("receipt.json"), + record_dir.join("candidate"), + attribution_path, + mode, + )) } -/// Derive a repair plan for a target's committed outputs, and optionally apply -/// it. -pub fn restore(workspace: &Path, target: &str, apply: bool) -> Result { - let store = disk::open(workspace); - let expected: Vec = committed_manifest(&store, target)? - .into_iter() - .map(|(path, hash)| FileFact { path, hash }) - .collect(); - let roots: BTreeSet = expected - .iter() - .filter_map(|fact| fact.path.split('/').next().map(str::to_string)) - .collect(); - let observed = observed_facts(workspace, &roots)?; - let plan = plan_restore(&expected, &observed); - if apply { - if let RestorePlan::Apply(steps) = &plan { - apply_restore(&store, workspace, steps)?; - } - } - Ok(restore_json(&plan)) +fn display_path(workspace: &Path, path: &Path) -> String { + path.strip_prefix(workspace) + .map(|rest| rest.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| path.to_string_lossy().into_owned()) } +napl-store blob v1 15043 +//! The commit transaction, up to but not including the syscalls. +//! +//! Everything the pipeline decides lives here: what the candidate is allowed to +//! contain, whether the receipt reproduces, what surface the candidate records, +//! what transition that derives, and which install steps materialize it. The +//! binary supplies the bytes and performs the writes; it makes no decision of +//! its own. +//! +//! The ordering is the contract's ordering, and it is what makes the critical +//! section bounded: ingest, verify, extract, derive, and plan all happen before +//! any lock is taken, so the locked region only rechecks the footprint and swaps +//! pointers, bounded by the transition's file count and hashing nothing. -/// Attach later provenance to a recorded transition without touching semantic -/// state. -/// The refusal a `--to` that is not an object name earns. -/// -/// A working path is the mistake a reader makes here, because every other verb -/// takes one, so the refusal does not merely say no: it names the two commands -/// that produce and inspect an object name and shows the one line that gets one. -fn not_an_object_name(given: &str) -> crate::error::CompilerError { - error( - OutcomeCode::Usage, - format!( - "--to expects the object name of a recorded transition, and {given:?} is not one. \ - Object names come from `napl log`, and `napl show ` inspects one, \ - for example: napl log | napl q '.entries[-1].transition' -" - ), - ) -} +use std::collections::{BTreeMap, BTreeSet}; -pub fn add_note(workspace: &Path, transition: &str, note: &str) -> Result { - let store = disk::init(workspace)?; - if !is_object_name(transition) || !has_object(&store, transition) { - return Err(not_an_object_name(transition)); - } - get_json(&store, transition, ObjectKind::Transition)?; - let body = json!({ "note": note, "transition": transition }); - let payload = crate::canon::canonical_bytes(&body); - let name = disk::publish(&store, ObjectKind::Blob, &payload, transition)?; +use napl_store::store_hash::ObjectKind; +use napl_store::store_tree::{diff_trees, Tree, TreeChange, FILE_MODE}; +use napl_store::store_txn_core::{plan_install_steps, InstallStep}; +use serde_json::{json, Value}; - let index = store.root.join("notes").join(format!("{transition}.json")); - let mut names: BTreeSet = match std::fs::read(&index) { - Ok(bytes) => serde_json::from_slice::>(&bytes) - .unwrap_or_default() - .into_iter() - .collect(), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(), - Err(e) => return Err(disk::io(&index, &e)), - }; - names.insert(name.clone()); - let listed: Vec = names.iter().map(|n| json!(n)).collect(); - crate::workspace::write_file(&index, &crate::canon::canonical_bytes(&Value::Array(listed)))?; - Ok(json!({ - "note": name, - "outcome": "success", - "semanticStatePublished": false, - "transition": transition, - })) -} +use crate::candidate::Candidate; +use crate::canon::{hash_json, is_object_name}; +use crate::compile::{head_states, slice_at, state_of}; +use crate::error::{error, OutcomeCode, Result}; +use crate::evidence::{evidence_report, validate_all}; +use crate::objects::{ + event_hash, event_json, module_state_hash, module_state_json, ownership_write_key, + snapshot_hash, snapshot_json, snapshot_members, state_write_key, transition_hash, + transition_json, Footprint, ModuleState, Transition, +}; +use crate::paths::is_under; +use crate::receipt::{receipt_json, receipt_semantic_hash, Receipt, RECEIPT_VERSION}; +use crate::slice::CompileSlice; +use crate::source::SourceModule; +use crate::store_view::{load_tree_objects, ObjectSource}; +use crate::surface::{extract_surface, surface_hash, surface_json, SurfaceItem}; +use crate::target::{derive_projection, is_owned_path, placement_root, target_def_hash, TargetDef}; -/// The events reachable from a head, ordered oldest first by causal depth. -pub fn causal_order(store: &Store, head: &str) -> Result> { - let head_event = snapshot_event(store, head)?; - let events = walk_events(store, &head_event, None)?; - let mut depths: BTreeMap = BTreeMap::new(); - for event in &events { - depth_of(store, event, &mut depths)?; - } - let mut ordered = events; - ordered.sort_by(|a, b| (depths[a], a).cmp(&(depths[b], b))); - Ok(ordered) +/// One object the transaction wants published, kind and payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingObject { + pub name: String, + pub kind: ObjectKind, + pub payload: Vec, } -fn depth_of(store: &Store, event: &str, depths: &mut BTreeMap) -> Result { - if let Some(known) = depths.get(event) { - return Ok(*known); - } - let value = get_json(store, event, ObjectKind::Event)?; - let mut depth = 0usize; - for parent in value["parents"].as_array().cloned().unwrap_or_default() { - if let Some(parent) = parent.as_str() { - depth = depth.max(depth_of(store, parent, depths)? + 1); - } - } - depths.insert(event.to_string(), depth); - Ok(depth) +/// Everything a commit derived, ready to be applied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DerivedCommit { + pub transition: Transition, + pub transition_id: String, + pub new_state_key: String, + pub surface_hash: String, + pub surface_items: Vec, + /// The recorded receipt object, provenance included. Distinct from the + /// transition's `receipt`, which names the semantic body alone. + pub receipt_object: String, + /// The verdict on every evidence item the receipt carried. Provenance: it is + /// reported, and it changes nothing about what installs. + pub evidence: Value, + /// Blobs and provenance, published before the critical section. + pub objects: Vec, } -pub fn committed_state(store: &Store, target: &str, module: &str) -> Result> { - Ok(committed_state_at(store, target, module)?.map(|(_, state)| state)) +/// The state the accepted transition writes, plus how to materialize it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Accepted { + pub snapshot: String, + pub event: String, + pub steps: Vec, + /// The derived projection: recomputed from the merged head, never reused. + pub projection_path: String, + pub projection_bytes: Vec, + pub objects: Vec, + pub rebased: bool, } -/// A module's committed state beside the name the snapshot knows it by. -/// -/// The name comes off the snapshot rather than being recomputed from the -/// deserialized value, so a caller naming this state as the base of a transition -/// names the object the store actually holds. Re-hashing a value that has been -/// through a round trip would answer a different name the moment a field's -/// serialization ever moved, and a base nobody can find is a wedge. -pub fn committed_state_at( - store: &Store, - target: &str, +/// Check a candidate against its module's placement and allowed outputs. +pub fn check_candidate_shape( + target: &TargetDef, module: &str, -) -> Result> { - let Some(head) = store.head()? else { - return Ok(None); - }; - let states = snapshot_states(store, &head)?; - match states.get(&(target.to_string(), module.to_string())) { - Some(hash) => Ok(Some((hash.clone(), read_module_state(store, hash)?))), - None => Ok(None), + candidate: &Candidate, +) -> Result<()> { + if candidate.manifest.is_empty() { + return Err(error(OutcomeCode::InvalidCandidate, "candidate is empty")); + } + let root = placement_root(target, module); + for path in candidate.manifest.keys() { + if !is_under(path, &root) { + return Err(error( + OutcomeCode::InvalidCandidate, + format!("candidate path {path:?} is outside the placement root {root:?}"), + )); + } + if !is_owned_path(target, module, path) { + return Err(error( + OutcomeCode::InvalidCandidate, + format!("candidate path {path:?} is not an allowed output for {module:?}"), + )); + } + } + let manifest = format!("{root}/{}", target.manifest_file); + if !candidate.manifest.contains_key(&manifest) { + return Err(error( + OutcomeCode::InvalidCandidate, + format!("candidate is missing its manifest {manifest:?}"), + )); } + Ok(()) } -/// The materialized attribution view a module committed last, if any. -/// -/// The bytes are the record blob, which is the view itself: an internal -/// re-commit reads them to carry the mapping forward rather than fabricating a -/// new one. -pub fn committed_attribution_yaml( - store: &Store, - target: &str, - module: &str, -) -> Result>> { - let Some(state) = committed_state(store, target, module)? else { - return Ok(None); +/// Verify a receipt against independently recomputed identities. +pub fn verify_receipt( + source: &dyn ObjectSource, + sources: &[SourceModule], + receipt: &Receipt, + candidate: &Candidate, +) -> Result { + if receipt.receipt_version != RECEIPT_VERSION { + return Err(error( + OutcomeCode::UnsupportedVersion, + format!("receiptVersion {} is not supported", receipt.receipt_version), + )); + } + if receipt.candidate_tree_hash != candidate.tree_hash { + return Err(error( + OutcomeCode::InvalidReceipt, + format!( + "candidateTreeHash {} does not match the ingested candidate {}", + receipt.candidate_tree_hash, candidate.tree_hash + ), + )); + } + if receipt.output_manifest != candidate.manifest { + return Err(error( + OutcomeCode::InvalidReceipt, + "outputManifest does not match the ingested candidate", + )); + } + if !is_object_name(&receipt.base_workspace_hash) { + return Err(error( + OutcomeCode::InvalidReceipt, + "baseWorkspaceHash is not an object name", + )); + } + let slice = slice_at( + source, + sources, + &receipt.target, + &receipt.module, + receipt.base_store_snapshot.as_deref(), + )?; + let mismatch = |field: &str, claimed: &str, recomputed: &str| { + error( + OutcomeCode::InvalidReceipt, + format!("{field} {claimed} does not reproduce at the receipt's bases (recomputed {recomputed})"), + ) }; - match state.attribution { - Some(attribution) => Ok(Some(get_blob(store, &attribution.record)?)), - None => Ok(None), + if slice.slice_hash != receipt.slice_hash { + return Err(mismatch("sliceHash", &receipt.slice_hash, &slice.slice_hash)); + } + if slice.input_key != receipt.input_key { + return Err(mismatch("inputKey", &receipt.input_key, &slice.input_key)); + } + if slice.base_state_key != receipt.base_state_key { + return Err(mismatch( + "baseStateKey", + &receipt.base_state_key, + &slice.base_state_key, + )); } + Ok(slice) } -/// The committed `path -> blob` manifest of every module under a target. -pub fn committed_manifest(store: &Store, target: &str) -> Result> { - let Some(head) = store.head()? else { - return Ok(BTreeMap::new()); +/// Extract the candidate's surface and derive the whole transition. +pub fn derive_commit( + target: &TargetDef, + receipt: &Receipt, + candidate: &Candidate, +) -> Result { + let items = extract_surface(target, &receipt.module, &candidate.files)?; + // Extraction is claimed to be a pure function of the bytes; a second pass + // over the same ingested bytes is the cheapest way to keep that claim honest. + if items != extract_surface(target, &receipt.module, &candidate.files)? { + return Err(error( + OutcomeCode::MappingIntegrity, + "surface extraction is not reproducible for this candidate", + )); + } + let surface_value = surface_json(&receipt.module, &target.name, &items); + let recorded_surface = surface_hash(&receipt.module, &target.name, &items); + + let new_state = ModuleState { + module: receipt.module.clone(), + target: target.name.clone(), + input_key: Some(receipt.input_key.clone()), + slice_hash: Some(receipt.slice_hash.clone()), + surface_hash: recorded_surface.clone(), + output_tree: candidate.tree_hash.clone(), }; - let mut out = BTreeMap::new(); - for ((state_target, _), hash) in snapshot_states(store, &head)? { - if state_target != target { - continue; - } - let state = read_module_state(store, &hash)?; - for (path, (blob, _)) in flatten_tree(store, &state.output_tree)? { - out.insert(path, blob); - } + let new_state_key = module_state_hash(&new_state); + + let root = placement_root(target, &receipt.module); + let mut read_set = vec![ + format!("input-key:{}", receipt.input_key), + format!("base-state:{}", receipt.base_state_key), + format!("target-def:{}", target_def_hash(target)), + ]; + read_set.sort(); + let footprint = Footprint { + read_set, + write_set: vec![ + ownership_write_key(&root), + state_write_key(&target.name, &receipt.module), + ], + derived_projections: vec![target.projection_path.clone()], + install_paths: candidate.manifest.keys().cloned().collect(), + }; + let transition = Transition { + module: receipt.module.clone(), + target: target.name.clone(), + input_key: receipt.input_key.clone(), + base_state_key: receipt.base_state_key.clone(), + new_state: new_state_key.clone(), + receipt: receipt_semantic_hash(receipt), + surface: recorded_surface.clone(), + footprint, + }; + let transition_id = transition_hash(&transition); + + let mut objects: Vec = Vec::new(); + for (_, bytes) in &candidate.files { + objects.push(blob_object(bytes)); } - Ok(out) -} + for tree in candidate.tree_objects.values() { + objects.push(json_object(ObjectKind::Tree, &tree_value(tree))); + } + objects.push(json_object(ObjectKind::Surface, &surface_value)); + objects.push(json_object( + ObjectKind::ModuleState, + &module_state_json(&new_state), + )); + objects.push(json_object(ObjectKind::Receipt, &receipt_json(receipt))); + objects.push(json_object( + ObjectKind::Receipt, + &crate::receipt::receipt_semantic_json(receipt), + )); + objects.push(json_object( + ObjectKind::TargetDef, + &crate::target::target_def_json(target), + )); + objects.push(json_object( + ObjectKind::Transition, + &transition_json(&transition), + )); -/// Whether a token is shaped like a store object name at all: the lowercase hex -/// of a SHA-256, and nothing else. A workspace path fails on the first separator. -fn is_object_name(given: &str) -> bool { - given.len() == 64 && given.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + Ok(DerivedCommit { + transition, + transition_id, + new_state_key, + surface_hash: recorded_surface, + surface_items: items, + receipt_object: crate::receipt::receipt_hash(receipt), + evidence: evidence_report(&validate_all(&receipt.evidence)), + objects, + }) } -/// Every path any committed module owns, across every target: the set that -/// decides whether a path is tracked at all. -fn committed_manifest_all_targets(store: &Store) -> Result> { - let mut out: BTreeSet = BTreeSet::new(); - let Some(head) = store.head()? else { - return Ok(out); +/// Derive the new snapshot, the install steps, and the re-derived projection. +/// +/// Called with the winning head already resolved, so a rebase and a +/// straight-line install go through exactly the same derivation and the +/// projection can never be carried across. +pub fn accept( + source: &dyn ObjectSource, + target: &TargetDef, + candidate: &Candidate, + derived: &DerivedCommit, + head: Option<&str>, + base_snapshot: Option<&str>, +) -> Result { + let mut states = head_states(source, head)?; + let key = (target.name.clone(), derived.transition.module.clone()); + let previous = state_of(source, &states, target, &derived.transition.module)?.0; + states.insert(key, derived.new_state_key.clone()); + + let parents: Vec = match head { + Some(snapshot) => vec![crate::objects::snapshot_event(source, snapshot)?], + None => Vec::new(), }; - for (_, hash) in snapshot_states(store, &head)? { - let state = read_module_state(store, &hash)?; - for (path, _) in flatten_tree(store, &state.output_tree)? { - out.insert(path); - } + let event = event_hash(&derived.transition_id, &parents); + let snapshot = snapshot_hash(&event, &states); + + let mut objects = vec![ + json_object(ObjectKind::Event, &event_json(&derived.transition_id, &parents)), + json_object(ObjectKind::Snapshot, &snapshot_json(&event, &states)), + ]; + + // The projection is recomputed from the merged member set, never reused. + let members = snapshot_members(&states, &target.name); + let (projection_path, projection_bytes) = derive_projection(target, &members); + objects.push(blob_object(&projection_bytes)); + + // Diffing the committed output tree against the candidate tree is what + // bounds the critical section: the steps are counted before the lock, and + // applying one never hashes content. + let mut trees: BTreeMap = BTreeMap::new(); + // A module with no committed output has an output tree that was never + // published; there is nothing to diff against, which is exactly "no old + // side" rather than a missing object. + let previous_root = load_tree_objects(source, &previous.output_tree, &mut trees) + .ok() + .map(|()| previous.output_tree.clone()); + for (name, tree) in &candidate.tree_objects { + trees.insert(name.clone(), tree.clone()); } - Ok(out) + let mut changes = diff_trees( + previous_root.as_deref(), + Some(&candidate.tree_hash), + &trees, + ) + .map_err(|e| { + error( + OutcomeCode::StoreIntegrity, + format!("cannot diff the module's owned output: {e}"), + ) + })?; + // The projection rides the same plan, where `classify_path` files it as the + // snapshot-wide derived view it is. + changes.push(TreeChange::Added { + path: projection_path.clone(), + hash: napl_store::store_hash::object_name(ObjectKind::Blob, &projection_bytes), + mode: FILE_MODE, + }); + let steps = plan_install_steps(&changes); + + Ok(Accepted { + snapshot, + event, + steps, + projection_path, + projection_bytes, + objects, + rebased: head != base_snapshot, + }) } -fn committed_text(store: &Store, target: &str) -> Result> { - let mut out = BTreeMap::new(); - for (path, blob) in committed_manifest(store, target)? { - let bytes = get_blob(store, &blob)?; - out.insert(path, String::from_utf8_lossy(&bytes).into_owned()); - } - Ok(out) +/// The canonical success payload of a commit. +pub fn success_payload(derived: &DerivedCommit, accepted: &Accepted) -> Value { + json!({ + "blobsPublished": true, + "contractVersion": crate::CONTRACT_VERSION, + "derivedProjections": [accepted.projection_path.clone()], + "evidence": derived.evidence, + "head": accepted.snapshot, + "installed": derived.transition.footprint.install_paths, + "outcome": "success", + "provenancePublished": true, + "rebased": accepted.rebased, + "receipt": derived.receipt_object, + "semanticStatePublished": true, + "surfaceHash": derived.surface_hash, + "transition": derived.transition_id, + }) } -/// Every identifier a module's committed output names. -/// -/// This is a token scan, not name resolution: lint says a surface entry looks -/// unconsumed, and says so as a proposal, precisely because it cannot prove it. -fn identifiers(store: &Store, state: &ModuleState) -> Result> { +/// The union of write sets committed between `base` and `head`. +pub fn intervening_write_set( + source: &dyn ObjectSource, + head: Option<&str>, + base: Option<&str>, +) -> Result> { + let Some(head) = head else { + return Ok(BTreeSet::new()); + }; + let head_event = crate::objects::snapshot_event(source, head)?; + let base_event = match base { + Some(snapshot) => Some(crate::objects::snapshot_event(source, snapshot)?), + None => None, + }; let mut out: BTreeSet = BTreeSet::new(); - if !has_object(store, &state.output_tree) { - return Ok(out); - } - for (_, (blob, _)) in flatten_tree(store, &state.output_tree)? { - let bytes = get_blob(store, &blob)?; - let text = String::from_utf8_lossy(&bytes).into_owned(); - for token in text.split(|c: char| !(c.is_alphanumeric() || c == '_')) { - if !token.is_empty() { - out.insert(token.to_string()); - } - } + for event in crate::objects::walk_events(source, &head_event, base_event.as_deref())? { + let transition = crate::objects::event_transition(source, &event)?; + out.extend(crate::objects::transition_write_set(source, &transition)?); } Ok(out) } -fn declared_roots(sources: &[crate::source::SourceModule]) -> Option> { - let mut declared: Option> = None; - for source in sources { - if let Some(roots) = &source.roots { - declared - .get_or_insert_with(BTreeSet::new) - .extend(roots.iter().cloned()); - } +fn blob_object(bytes: &[u8]) -> PendingObject { + PendingObject { + name: napl_store::store_hash::object_name(ObjectKind::Blob, bytes), + kind: ObjectKind::Blob, + payload: bytes.to_vec(), } - declared.map(|set| set.into_iter().collect()) } -fn observed_facts(workspace: &Path, roots: &BTreeSet) -> Result> { - let mut out: Vec = Vec::new(); - for root in roots { - let dir = workspace.join(root); - if !dir.is_dir() { - continue; - } - for entry in crate::workspace::scan_candidate(&dir)? { - if entry.kind != crate::candidate::EntryKind::File { - continue; - } - out.push(FileFact { - path: crate::paths::join_root(root, &entry.path), - hash: napl_store::store_hash::object_name(ObjectKind::Blob, &entry.bytes), - }); - } +fn json_object(kind: ObjectKind, value: &Value) -> PendingObject { + let (name, payload) = hash_json(kind, value); + PendingObject { + name, + kind, + payload, } - out.sort(); - Ok(out) } -fn apply_restore( - store: &Store, - workspace: &Path, - steps: &[crate::maintenance::RestoreStep], -) -> Result<()> { - use crate::maintenance::RestoreStep; - for step in steps { - match step { - RestoreStep::Move { from, to } => { - let target = workspace.join(to); - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent).map_err(|e| disk::io(parent, &e))?; - } - std::fs::rename(workspace.join(from), &target) - .map_err(|e| disk::io(&target, &e))?; - } - RestoreStep::Write { path, hash } => { - let bytes = get_blob(store, hash)?; - crate::workspace::write_file(&workspace.join(path), &bytes)?; - } - RestoreStep::Remove { path } => { - let target = workspace.join(path); - match std::fs::remove_file(&target) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(disk::io(&target, &e)), - } - } - } - } - Ok(()) +fn tree_value(tree: &Tree) -> Value { + serde_json::to_value(tree).expect("a tree always serializes") } -napl-store receipt v1 815 -{"baseStateKey":"9d5b22e0c8befdc44bcf7eedf973b53e8b03468a904eed0306369785f87283b1","baseStoreSnapshot":"d512c3e78967e6a3e2750ea7311592d5210d24fc0aa8ca6dd7e360c985778cea","baseWorkspaceHash":"d1e85c3abcbc49cf3afa118c54102e3f052a84fc24b01cab46c246ea1644693c","candidateTreeHash":"d83d2467beb91b948fff71e8ad6912f421053f3c456f4ef0d652253c7a1b8ce7","contractVersion":4,"inputKey":"80d6c286c5a0ed57bf1e618663be6a809568faa305d8c6fcba677a9f1ff461d5","module":"commands","outputManifest":[{"hash":"cb4a371433bd6721613afa223fb5e3fd962eb9d58272937e6b632c21a0a351be","path":"crates/napl/src/init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","path":"crates/napl/src/watch.rs"}],"receiptVersion":1,"sliceHash":"e5e5114ea27c93228aef1fcbd0331a002c8f0c3b074c922431556fcc4a573af8","target":"rust"} -napl-store transition v1 959 -{"baseStateKey":"2f4def880281f987518186a75836278fd0c2286ca7cd8a63094c9467bc92f5d6","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"85d737876ae3fc250cbc36211bc65a89f07d702ebf8116091bf855a7f0e48e33","installPaths":["crates/napl/src/check.rs","crates/napl/src/docs.rs","crates/napl/src/document.rs","crates/napl/src/promptbuild.rs","crates/napl/src/query.rs"],"module":"query","newState":"accaf93691b84c6794df99c7643c710529c765f12cc9faedede4b6b598a8c6b0","readSet":["base-state:2f4def880281f987518186a75836278fd0c2286ca7cd8a63094c9467bc92f5d6","input-key:85d737876ae3fc250cbc36211bc65a89f07d702ebf8116091bf855a7f0e48e33","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"b1884c0dda826ee579c0cedf56ec34289dd945e1f551f31a6563c11fbcd71d8c","surface":"37985e1e1289f96f00be156b4fff0e75a2b00894f5ef2e9816ddca84e594c486","target":"rust","writeSet":["owned:crates/napl","module-state:rust/query"]} -napl-store blob v1 12659 -//! The store shell: the compiler's read seam and write path, over real bytes. +napl-store module-state v1 638 +{"attribution":{"promptHash":"4ede33df8b04ca4243178cfa1dc5d46036133d8f5d248e7ea32c990e74e881e3","record":"9093a24f5cccf37f4f2c54a38808cece50e46b033d5c47ec6bb673aa7bb667f6","stale":false},"contractVersion":4,"inputKey":"58efbc3e44ab68ee5736b83c275e90d33671b13233d9404897977d74139ee4f6","module":"commands","outputTree":"327df4533c69e9df80fe2e89dc208ef1194ce9529a11c8a6c21086bdbc1e918e","prompt":"864391598ee91f825cfe21eb337df2a32d03ad7b9146288d6d14bc13547a2e0e","sliceHash":"f2e007ad5d93fbf19718c059eb8ad30918abd26223e8ecd45f9538e2bf8f85ad","surfaceHash":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust"} +napl-store blob v1 29905 +//! The command line contract: declared grammars, help, and strict parsing. //! -//! Every syscall in this file is one `napl-store` already knows how to make. -//! Publication, the HEAD compare-and-set, module-keyed locks with dead-holder -//! steal, and commit markers are the store's; this module only points them at a -//! store root and translates their failures into the compiler's outcome -//! vocabulary. +//! Every command the registry serves declares its full grammar in its +//! [`CommandSpec`]: name, a one-line summary, the exact usage line, and the +//! closed table of flags it accepts, each with arity and meaning. The dispatch +//! loop owns two behaviors ahead of every command, so no command can get them +//! wrong: help is answered before the command runs and is a pure read, and any +//! flag the command's table does not name is a usage error before the command +//! executes. Exit 2 is the pinned usage-error code for the whole binary. //! -//! There is one object layout and no backend to choose: the tracked files under -//! the store root, loose until a repack folds them into a pack. A workspace -//! whose repository still carries the retired `refs/heads/napl-store` is a -//! legacy clone, and the skeleton this file creates is where that refusal is -//! raised, before any verb has written a byte. +//! The parser behind the contract is clap's builder API, constructed at dispatch +//! from the registry: [`clap_command_for`] turns one [`CommandSpec`] into one +//! `clap::Command`, so the registry stays the single source of grammar and clap +//! is only its execution engine. There are no derive macros and no per-command +//! clap declarations anywhere. clap's own renderings are never emitted: every +//! parse failure is caught and served as the canonical JSON usage error below, +//! byte-shaped by this contract alone. `--flag value` and `--flag=value` reduce +//! to the same normalized argument vector before any command body reads an +//! argument, so the two spellings cannot produce different output. A flag may +//! also declare one short spelling, which normalizes to the same long name; +//! clustering several short flags into one token is outside the contract, so a +//! clustered token is simply an undeclared flag. +//! +//! The registry pre-pass, not clap, is what refuses an undeclared flag: the +//! canonical JSON payload with its nearest-name suggestion is pinned by this +//! contract, and clap normalizes spellings and values behind it. -use std::collections::BTreeSet; +use std::io::Write as _; use std::path::{Path, PathBuf}; -use crate::error::{error, CompilerError, OutcomeCode, Result}; -use crate::store_view::ObjectSource; -use napl_store::store_hash::ObjectKind; -use napl_store::store_io::{self, LockGuard, PackFinding, RepackOutcome, StoreIoError}; -use napl_store::store_txn_core::CommitMarker; - -/// The store directory, relative to the workspace root. -pub const STORE_DIR: &str = ".napl/store"; - -/// The lock guarding the pointer swap and the install. -/// -/// Deliberately not `HEAD`: the store's own compare-and-set takes `HEAD` for the -/// duration of its read-compare-write, and holding it here would deadlock -/// against that. -pub const POINTER_LOCK: &str = "pointer"; +use clap::{Arg, ArgAction}; +use serde_json::{json, Value}; -/// The lock guarding the object layout while a repack rewrites it. -/// -/// Deliberately not the pointer lock: a repack moves no pointer and must not -/// contend with the bounded critical section, so this names the one thing it -/// does own, which files the object layout holds. -pub const OBJECTS_LOCK: &str = "objects"; +use crate::canon::canonical_bytes; +use crate::error::{error, error_payload, outcome_exit_code, CompilerError, OutcomeCode, Result}; +use crate::workspace::require_workspace_root; -/// A store rooted under one workspace. -#[derive(Default, Debug, Clone, PartialEq, Eq)] -pub struct Store { - pub workspace: PathBuf, - pub root: PathBuf, +/// How many arguments a flag consumes beyond its own token. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FlagArity { + /// A bare switch: present or absent, never followed by a value. + Switch, + /// A flag that takes exactly one value, as `--name value` or `--name=value`. + Value, } -/// Point at a store without touching the filesystem. -pub fn open(workspace: &Path) -> Store { - Store { - workspace: workspace.to_path_buf(), - root: workspace.join(STORE_DIR), - } +/// One entry in a command's closed flag table. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FlagSpec { + pub name: &'static str, + pub arity: FlagArity, + pub meaning: &'static str, + /// The one short spelling this flag also answers to, written with its single + /// dash, or `None`. A flag has at most one, and clustering several short + /// flags into a single token is outside the contract. + pub short: Option<&'static str>, } -/// Point at a store, creating its directory skeleton. -/// -/// This is the door every store-writing verb passes through, so it is where a -/// legacy clone is turned away: a repository still carrying the retired store -/// ref is refused here, before a lock is taken or an object is published. -pub fn init(workspace: &Path) -> Result { - crate::git::guard_legacy_store_ref(workspace)?; - let store = open(workspace); - for dir in ["objects", "locks", "markers", "attempts", "tmp", "notes"] { - let path = store.root.join(dir); - std::fs::create_dir_all(&path).map_err(|e| io(&path, &e))?; +/// A switch flag: present or absent. +pub const fn switch(name: &'static str, meaning: &'static str) -> FlagSpec { + FlagSpec { + name, + arity: FlagArity::Switch, + meaning, + short: None, } - Ok(store) } -impl ObjectSource for Store { - fn envelope(&self, name: &str) -> Result>> { - store_io::read_object(&self.root, name).map_err(store_error) - } - - /// napl HEAD, the tracked file. - /// - /// Two code branches legitimately hold two different snapshots, so HEAD is - /// branch-local state like every other file in the working tree and travels - /// with the checkout that wrote it. That is also the one file a merge can - /// conflict on, so a value that is not an object name is refused as store - /// integrity naming the likely cause: the `.gitattributes` line init writes - /// marks HEAD binary precisely so a merge holds it unmerged rather than - /// writing conflict markers into it, and resolving is choosing a side. - fn head(&self) -> Result> { - let head = store_io::read_head(&self.root).map_err(store_error)?; - match &head { - Some(value) if !crate::canon::is_object_name(value) => Err(error( - OutcomeCode::StoreIntegrity, - format!( - "{}/HEAD holds {value:?}, which is not a snapshot name; if a merge wrote it, choose the snapshot this branch is on and write that hash alone, because a merge's store holds both sides' objects and either side resolves", - STORE_DIR - ), - )), - _ => Ok(head), - } - } - - /// Every name the store holds: the loose files plus the packs. A packed - /// object is present exactly as a loose one is, and an object held both ways - /// is counted once. - fn names(&self) -> Result> { - let mut names = store_io::loose_object_names(&self.root).map_err(store_error)?; - names.extend(store_io::packed_object_names(&self.root).map_err(store_error)?); - Ok(names.into_iter().collect()) +/// A switch flag that also answers to one short spelling, as `-r` does for +/// `--raw-output`. The short form normalizes to the long name before any command +/// body reads an argument, so the two spellings cannot behave differently. +pub const fn switch_short( + name: &'static str, + short: &'static str, + meaning: &'static str, +) -> FlagSpec { + FlagSpec { + name, + arity: FlagArity::Switch, + meaning, + short: Some(short), } } -/// How many loose object files the store holds, the count that decides whether -/// the automatic repack has anything to do. -pub fn loose_object_count(store: &Store) -> Result { - store_io::loose_object_count(&store.root).map_err(store_error) -} - -/// Pack the reachable closure into one pack, deleting the loose files it packed, -/// the loose files no head reaches, and every older pack once the new one is -/// durable. -/// -/// It runs under the objects lock, which buys exactly one thing: two repacks, or -/// a repack and a sweeping gc, never rewrite the layout at once. Publication -/// takes no lock, so a publisher racing a repack can still lose a just-written -/// loose object before any snapshot names it; that window is open by design and -/// closes only when the store learns a waiting lock. -/// -/// The count of what it swept is read inside the lock, so the number reported is -/// the number of files this repack actually removed rather than one another -/// writer could have moved under it. -pub fn repack( - store: &Store, - reachable: &BTreeSet, - owner: &str, -) -> Result<(RepackOutcome, usize)> { - let guard = take_stealable_lock(store, OBJECTS_LOCK)?; - let counted = store_io::loose_object_names(&store.root) - .map_err(store_error) - .map(|loose| loose.iter().filter(|name| !reachable.contains(*name)).count()); - let outcome = store_io::repack(&store.root, reachable, owner).map_err(store_error); - release_lock(guard)?; - Ok((outcome?, counted?)) +/// A flag that takes one value. +pub const fn value(name: &'static str, meaning: &'static str) -> FlagSpec { + FlagSpec { + name, + arity: FlagArity::Value, + meaning, + short: None, + } } -/// The pack a repack over `reachable` would write, without touching the store. -pub fn plan_repack(store: &Store, reachable: &BTreeSet) -> Result { - store_io::plan_repack(&store.root, reachable).map_err(store_error) -} +/// The `--workspace` flag, declared by every command that resolves a workspace. +pub const WORKSPACE_FLAG: FlagSpec = value( + "--workspace", + "the workspace root to operate on, instead of discovering it from the current directory", +); -/// Every pack finding the store's packs carry. -pub fn verify_packs(store: &Store) -> Result> { - store_io::verify_packs(&store.root).map_err(store_error) -} +/// The help flags the dispatch loop answers ahead of every command. +pub const HELP_FLAGS: [&str; 2] = ["--help", "-h"]; -/// Publish one object; publishing an existing object is an idempotent no-op and -/// a byte mismatch under an existing name is a loud store-integrity failure. -pub fn publish(store: &Store, kind: ObjectKind, payload: &[u8], owner: &str) -> Result { - store_io::publish_object(&store.root, kind, payload, owner).map_err(store_error) -} +/// The exit code every usage error in the binary uses. +pub const USAGE_EXIT_CODE: i32 = 2; -/// Move HEAD forward only from the value the caller observed. -/// -/// The compare-and-swap is the tracked file's, with the decision table behind -/// it, and it is the whole of the pointer move. -pub fn compare_and_set_head(store: &Store, expected: Option<&str>, next: &str) -> Result<()> { - store_io::compare_and_set_head(&store.root, expected, next).map_err(store_error) -} +/// The top-level usage line, used when no command was named. +pub const BINARY_USAGE: &str = "napl [arguments]"; -/// Take a lock, mapping a live holder to `lock-contended`. -/// -/// The liveness probe always answers "alive" and the host is the empty string: -/// neither a pid table nor a hostname may decide a semantic outcome, so a held -/// lock is simply contended and the caller retries. -pub fn take_lock(store: &Store, name: &str) -> Result { - acquire(store, name, &|_| true) +/// A command's full declared grammar. +#[derive(Clone, Copy)] +pub struct CommandSpec { + pub name: &'static str, + /// One line saying what the command does. + pub summary: &'static str, + /// The exact usage line a usage error restates. + pub usage: &'static str, + /// The closed table of flags this command accepts. + pub flags: &'static [FlagSpec], + /// True when the command forwards its arguments verbatim to another + /// program, so its grammar is that program's and strict parsing does not + /// apply. Help is still answered ahead of it. + pub passthrough: bool, + pub run: fn(&Path, &[String]) -> Result, } -/// Take a lock whose abandoned copy is stolen rather than waited on. -/// -/// Reserved for the objects lock, and lawful only there: it holds no invariant a -/// crashed holder could corrupt, because the pack it guards is staged under a -/// temporary name and linked into place, so a holder that died mid-repack left -/// nothing but a file nobody references. Asking whether the writer is still -/// running therefore decides no semantic outcome, only whether a dead lock is a -/// wedge, and it must not be one. -pub fn take_stealable_lock(store: &Store, name: &str) -> Result { - acquire(store, name, &process_is_alive) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliResult { + pub stdout: Vec, + pub exit_code: i32, } -fn acquire(store: &Store, name: &str, is_alive: &dyn Fn(u64) -> bool) -> Result { - store_io::acquire_lock(&store.root, name, "", is_alive).map_err(|e| match &e { - StoreIoError::Io(detail) if detail.starts_with("lock held:") => error( - OutcomeCode::LockContended, - format!("lock {name:?} is held by another operation"), - ), - _ => store_error(e), - }) +pub fn run_cli(cwd: &Path, args: &[String]) -> CliResult { + match dispatch(cwd, args) { + Ok(result) => result, + Err(err) if err.code == OutcomeCode::Usage => { + emit_usage(&usage_json(None, &err.detail, None, None)) + } + Err(err) => CliResult { + stdout: canonical_bytes(&error_payload(&err, false, false)), + exit_code: outcome_exit_code(err.code), + }, + } } -/// Whether the process that wrote a lock file is still running. -/// -/// The null signal is the whole probe: it asks the kernel about the process and -/// delivers nothing to it. A process this one may not signal is still a process, -/// so a permission refusal reads as alive. -#[cfg(unix)] -fn process_is_alive(pid: u64) -> bool { - if pid == 0 { - return true; +fn dispatch(cwd: &Path, args: &[String]) -> Result { + let registry = crate::registry(); + let Some(command) = command_word(args) else { + if args.iter().any(|arg| is_help_token(arg)) { + return Ok(ok_bytes(&help_all_json(®istry))); + } + return Ok(emit_usage(&usage_json(None, "no command given", None, None))); + }; + if command == "help" { + return Ok(help_command_or_all(®istry, args)); } - let Ok(pid) = i32::try_from(pid) else { - return true; + let Some(spec) = registry.iter().find(|spec| spec.name == command).copied() else { + let names = command_names(®istry); + return Ok(emit_usage(&usage_json( + None, + &format!("unknown command {command:?}"), + Some(&command), + nearest(&command, &names).as_deref(), + ))); }; - if unsafe { libc::kill(pid, 0) } == 0 { - return true; + // Help is answered ahead of the command and is a pure read: no store + // access, no filesystem write, no state change of any kind. + if wants_help(&spec, args) { + return Ok(ok_bytes(&help_command_json(&spec))); } - std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) -} - -/// Where the null signal does not exist, nothing is ever stolen. A wedge is the -/// worse outcome, but guessing a holder dead is the unrecoverable one. -#[cfg(not(unix))] -fn process_is_alive(_pid: u64) -> bool { - true -} - -/// Release a held lock. -pub fn release_lock(guard: LockGuard) -> Result<()> { - store_io::release_lock(guard).map_err(store_error) -} - -/// Write the commit marker that makes the pointer swap recoverable. -pub fn write_marker(store: &Store, marker: &CommitMarker) -> Result<()> { - store_io::write_commit_marker(&store.root, marker).map_err(store_error) -} - -/// Read a commit marker back. -pub fn read_marker(store: &Store, snapshot: &str) -> Result> { - store_io::read_commit_marker(&store.root, snapshot).map_err(store_error) -} - -/// Drop a commit marker whose work is durably applied. -pub fn delete_marker(store: &Store, snapshot: &str) -> Result<()> { - store_io::delete_commit_marker(&store.root, snapshot).map_err(store_error) -} - -/// Every commit marker the store holds, in ascending snapshot order. -pub fn marker_snapshots(store: &Store) -> Result> { - let dir = store.root.join("markers"); - let entries = match std::fs::read_dir(&dir) { - Ok(entries) => entries, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(io(&dir, &e)), + // A passthrough command's grammar is the program it forwards to, so its + // arguments reach that program exactly as typed and neither strict parsing + // nor clap normalization touches them. + let arguments: Vec = if spec.passthrough { + args.to_vec() + } else { + if let Some(problem) = strict_parse(&spec, args) { + return Ok(emit_usage(&problem)); + } + match normalize_arguments(&spec, args) { + Ok(normalized) => normalized, + Err(problem) => return Ok(emit_usage(&problem)), + } }; - let mut out: Vec = Vec::new(); - for entry in entries { - let entry = entry.map_err(|e| io(&dir, &e))?; - let name = entry.file_name().to_string_lossy().into_owned(); - if let Some(stem) = name.strip_suffix(".json") { - out.push(stem.to_string()); + match (spec.run)(cwd, &arguments) { + Err(err) if err.code == OutcomeCode::Usage => { + Ok(emit_usage(&usage_json(Some(&spec), &err.detail, None, None))) } + other => other, } - out.sort(); - Ok(out) } -/// Delete one object, used only by a sweep that already proved it unreachable. -pub fn remove_object(store: &Store, name: &str) -> Result<()> { - let path = object_path(&store.root, name); - match std::fs::remove_file(&path) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(io(&path, &e)), - } +/// The first non-flag token, which names the command. Flag values are skipped +/// so `napl --workspace /w status` still names `status`. +fn command_word(args: &[String]) -> Option { + positionals_from(args, 0).into_iter().next() } -fn object_path(root: &Path, name: &str) -> PathBuf { - if name.len() < 3 { - return root.join("objects").join(name); - } - root.join("objects").join(&name[..2]).join(&name[2..]) +fn is_help_token(arg: &str) -> bool { + HELP_FLAGS.contains(&arg) } -/// Wrap a filesystem failure as an `io-error` outcome, naming the path. -pub fn io(path: &Path, err: &std::io::Error) -> CompilerError { - error( - OutcomeCode::IoError, - format!("{}: {err}", path.display()), - ) +/// True when a help flag appears among the command's arguments. Flag values are +/// skipped, so a `-h` that is the value of a declared flag is not a help request. +fn wants_help(spec: &CommandSpec, args: &[String]) -> bool { + let mut index = 0; + while index < args.len() { + let arg = args[index].as_str(); + if arg == "--" { + return false; + } + if is_help_token(arg) { + return true; + } + if let Some(flag) = declared_flag(spec, arg) { + if flag.arity == FlagArity::Value && !arg.contains('=') { + index += 2; + continue; + } + } + index += 1; + } + false } -fn store_error(err: StoreIoError) -> CompilerError { - match err { - StoreIoError::Io(detail) => error(OutcomeCode::IoError, detail), - StoreIoError::Integrity(name) => error( - OutcomeCode::StoreIntegrity, - format!("object {name} failed its integrity check"), - ), - StoreIoError::HeadConflict { expected, found } => error( - OutcomeCode::LockContended, - format!( - "head moved under the pointer lock: expected {}, found {}", - expected.as_deref().unwrap_or("none"), - found.as_deref().unwrap_or("none") - ), - ), - StoreIoError::Corrupt(key) => error( - OutcomeCode::StoreIntegrity, - format!("corrupt store marker {key}"), - ), - } +/// The flag table entry an argument names, if the command declares it, under +/// either its long spelling or its one short spelling. +fn declared_flag(spec: &CommandSpec, arg: &str) -> Option<&'static FlagSpec> { + let name = arg.split('=').next().unwrap_or(arg); + spec.flags + .iter() + .find(|flag| flag.name == name || flag.short == Some(name)) } -napl-store receipt v1 1280 -{"baseStateKey":"accaf93691b84c6794df99c7643c710529c765f12cc9faedede4b6b598a8c6b0","baseStoreSnapshot":"653c03e84a35c5b5456fac1a327f8a29ebcc4c19d96ee0f5e166aa3c73d5b7d4","baseWorkspaceHash":"1cbd0f8a55437d59751b61ddc91d2290912cf5b52204af9c179365134de2e5f3","candidateTreeHash":"05f44ac11e9808b57f775c0ffedd617fc59c8a13cef9839ed551a535e36b7148","contractVersion":4,"inputKey":"a3af18b0602f5c451e31dde831d3640aaf42c3d650c51f4b679b6dd8aa24ff6f","module":"query","outputManifest":[{"hash":"deff13703dd0b3c630077edaffafc1fa9f2fdfde297b9a84b45841a428715965","path":"crates/napl/src/check.rs"},{"hash":"7c26384c4adc5b5c857d0811275db450218c8f05177ae35a898ec59a5a5a5e03","path":"crates/napl/src/docs.rs"},{"hash":"726a20b8361501f4c26633cd92835ffb2e5f1acd584c0af76895d29bf620a7b1","path":"crates/napl/src/document.rs"},{"hash":"5ec2c2137f45d02c8e8bf78ecf0652966850772097b41f3835a99116f3b78f57","path":"crates/napl/src/promptbuild.rs"},{"hash":"be629fe4418f4226c3119cf486d936741c11b644241c9c358df2172131c722c1","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"reported":{"evidence":"unavailable","gates":"unavailable","notes":"unavailable","producer":"unavailable","usage":"unavailable"},"sliceHash":"21a0afb6d5668b4db3969637babc0ca89e99d9330e65f1b1e746facc1ba809e4","target":"rust"} -napl-store tree v1 130 -{"entries":[{"hash":"dfc2adefb687a9f0243769321f66f0b4d203dd2aee2ef525da1ec9a86dedaa05","kind":"dir","mode":16384,"name":"napl"}]} -napl-store module-state v1 559 -{"attribution":{"promptHash":"e1d3d8958ac3bb2bdcd6896cd27d2a494c86d5b58d7f066d4d61cc33cb85099b","record":"0733317c8f30d785be80fae7a227e8fbc09360a50e084afc86d2c6771f9c135c","stale":false},"contractVersion":4,"inputKey":"9c5c5537326102883d9375da90c05e6a6b0374fed0463417ef20c1e3e0ba83ab","module":"query","outputTree":"9853f46abd1fcdca3b3eaad6a5970de328a6dd30d982591cdfe46507193679c8","sliceHash":"8e7140d83f09629e29b45aaad774c33611e24927431dc7ff6695a5b7c0e14750","surfaceHash":"cb2b0ee10d67af3091437305664b3ea3b0419b83dee9436cb4ebea480fef83d3","target":"rust"} -napl-store snapshot v1 555 -{"contractVersion":2,"event":"4139939d5ce1e1118cbf72e76f8e3122007ebcdb2fb4d533fe418cd45833ed3c","states":[{"module":"commands","state":"e79a58ac8ad0c50f897901451c0b0b0f44757ab69818fe49282b3d41efd1b1bf","target":"rust"},{"module":"napl","state":"1c9027a76e242965501c9d639d69099add69b059b264ef6b815bf03630443f30","target":"rust"},{"module":"napl_store","state":"6132b58875d0b959de5162b96ac1af7c2fd15990fec1ba8dd10a507fadab0b62","target":"rust"},{"module":"query","state":"4d311c2a892a05d64e1842768363dd60597693c828bd017e82627692d36d2183","target":"rust"}]} -napl-store blob v1 25978 ---- -module: query -deps: [napl] -targets: [rust] ---- -:::docs{title="Querying the store" description="Read everything napl knows with one verb."} -Everything napl knows lives in one canonical JSON document: modules, -sections, owned files, attribution, test cases, history. `napl q` -evaluates a jq expression against that document, and `napl prompt` -builds agent context from the same tree. There is no other read -surface to learn, and this sentence is the live demo talking. -::: -# query: the canonical document, napl q, and napl prompt - -The language answers every question through one query surface and builds -agent context through one prompt builder. This module owns both commands -and the document they share. Everything here is a pure read: no store -writes, no locks held, no clock, no randomness, no network (P1). Reads -serve committed records, with two stated exceptions: reference staleness -is computed at read time from the working tree as well as the committed -bytes, so a hand edit to either end of a reference reads stale without -anything being stored; and the working-tree read mode below serves the -pending prompt when asked to explicitly. The same committed state plus -the same working tree produces the same bytes. This module owns `src/query.rs` (taken over -from the commands module), plus the document builder and prompt builder -sources it introduces. -## The canonical document +/// Refuse any `--flag` the command's table does not name, before it executes. +/// +/// The refusal names the unknown flag, lists the valid ones, suggests the +/// nearest declared flag when one is close, and restates the exact usage line, +/// so the error itself teaches the grammar. +fn strict_parse(spec: &CommandSpec, args: &[String]) -> Option { + let mut index = 0; + while index < args.len() { + let arg = args[index].as_str(); + if arg == "--" { + return None; + } + // A lone `-` is the stdin operand, never a flag. Anything else that + // leads with a dash is a flag spelling, long or short, and the pre-pass + // owns the refusal either way: clustering is outside the contract, so a + // clustered short token is simply undeclared. + if !arg.starts_with('-') || arg == "-" { + index += 1; + continue; + } + if is_help_token(arg.split('=').next().unwrap_or(arg)) { + index += 1; + continue; + } + match declared_flag(spec, arg) { + Some(flag) => { + if flag.arity == FlagArity::Value && !arg.contains('=') { + if index + 1 >= args.len() { + return Some(usage_json( + Some(spec), + &format!("{} needs a value", flag.name), + None, + None, + )); + } + index += 2; + continue; + } + if flag.arity == FlagArity::Switch && arg.contains('=') { + return Some(usage_json( + Some(spec), + &format!("{} takes no value", flag.name), + None, + None, + )); + } + index += 1; + } + None => { + let unknown = arg.split('=').next().unwrap_or(arg).to_string(); + let names: Vec = + spec.flags.iter().map(|flag| flag.name.to_string()).collect(); + return Some(usage_json( + Some(spec), + &format!( + "unknown flag {unknown:?} for command {:?}", + spec.name + ), + Some(&unknown), + nearest(&unknown, &names).as_deref(), + )); + } + } + } + None +} -One JSON value, `docVersion 1`, built at query time from committed state: -the store head, module states, attribution records, staged-evidence usage -inside receipts, recorded surfaces, and the committed prompt bytes. Top -level: `docVersion`, `head` (the snapshot hash), `modules`, `history`, -`state`. +/// The clap argument id the operands of a command collect under. It is not a +/// spelling any user types; the leading underscores keep it out of the flag +/// namespace the registry declares. +const OPERANDS: &str = "__operands"; -Each module carries `name`, `promptPath`, `deps`, `targets`, -`surfaceHash`, `pending`, `attribution` (an object whose `status` is -`fresh`, `stale`, or `absent`), `sections`, `frontmatterCases` (cases -declared in frontmatter `tests:`, which have no body position), and -`files` (owned output paths), and `outline` — the prompt body's -headings in order, each `{text, level, line}` with the line -body-normalized and 1-based, so "which body line is this heading on" -is a query, never a raw file read. Sections are the module's attribution -entries, and every section is a SELF-CONTAINED REF: it carries `head`, -`module`, `promptPath`, `lines` (body-normalized, 1-based, inclusive), -`note`, `law` (true exactly when the entry owns no file), `files` (each -`{path, lines, head}`), `direction` (`prompt_to_code` or -`code_to_prompt`, per the reference contract in the napl module; entries -recorded before directions read `prompt_to_code`), `provenance` (an -object with `model`, `timestamp`, `toolVersion`, and `usage`, each -absent field served as `unavailable`, never invented), `referenceStatus` -(`fresh` or `stale`), `staleEndpoints` (empty when fresh, `code` and -`prompt` when stale), and `cases` — the `::: test` directive cases -whose block range intersects the section's range, each -`{name, given, expect, lines}` with values in canonical JSON. A directive -case intersecting no entry lands in a synthetic section with empty -`files`. When a module's attribution is stale, sections still carry -their cases (cases derive from prompt bytes, not from attribution) but -each section carries `associationStale: true` and the module's status -says `stale`: the section-to-file pairing cannot be trusted, the case -data can. +/// The `clap::Command` one registry entry declares, constructed here and nowhere +/// else. Every fact comes from the [`CommandSpec`]: the name, the summary, the +/// exact usage line, and the closed flag table with each flag's arity. clap's own +/// help and error output is disabled, because the contract renders both itself. +/// +/// Value flags accept hyphen-leading values, because the value of a declared flag +/// is a value and never a flag to validate. Operands deliberately do not: a +/// permissive operand would swallow `--flag=value` whole and hand a command a +/// spelling that must parse as a flag, so `-` for stdin stays an operand by being +/// a value clap already accepts, and anything genuinely hyphen-leading is written +/// after `--`, which the grammar takes as the operand escape. +pub fn clap_command_for(spec: &CommandSpec) -> clap::Command { + let mut command = clap::Command::new(spec.name) + .no_binary_name(true) + .disable_help_flag(true) + .disable_help_subcommand(true) + .disable_version_flag(true) + .about(spec.summary) + .override_usage(spec.usage); + for flag in spec.flags { + let id = flag_id(flag.name); + let mut arg = Arg::new(id).long(id).help(flag.meaning); + if let Some(letter) = flag.short.and_then(|short| short.chars().nth(1)) { + arg = arg.short(letter); + } + command = command.arg(match flag.arity { + FlagArity::Switch => arg.action(ArgAction::SetTrue), + FlagArity::Value => arg + .action(ArgAction::Append) + .num_args(1) + .allow_hyphen_values(true) + .value_name("value"), + }); + } + command.arg( + Arg::new(OPERANDS) + .num_args(0..) + .value_name("arguments"), + ) +} -Each file ref inside a section carries `claim`, `whole` when the entry -owns the file with no line range and `range` when it names one, so a -consumer never guesses whether `lines` bounds the claim or merely -samples it. +/// The whole registry as one clap command tree: the binary with one subcommand +/// per registered command. This is what `napl completions` generates from, so a +/// completion script is derived from the same declarations help and parsing use +/// and can never drift from them. +pub fn clap_root(registry: &[CommandSpec]) -> clap::Command { + let mut listed: Vec<&CommandSpec> = registry.iter().collect(); + listed.sort_by_key(|spec| spec.name); + let mut root = clap::Command::new("napl") + .disable_help_flag(true) + .disable_help_subcommand(true) + .disable_version_flag(true) + .override_usage(BINARY_USAGE); + for spec in listed { + root = root.subcommand(clap_command_for(spec).no_binary_name(false)); + } + root +} -Docs fences from the napl module's directive law surface here per -section: `sections[].docs` is the ordered list of docs payloads whose -fence sits inside the section's line range, each -`{directive, attrs, lines, body}` with inner fences verbatim in the -body. The module preamble fence, when present, is served at -`modules[].docs` beside the sections. Docs `lines` are physical prompt -coordinates, the one place physical coordinates appear in the -document, because docs fences are excluded from the semantic body that -every other coordinate speaks in; the document states both coordinate -systems explicitly. A module with no docs fences serves empty docs -lists, never null. +/// The clap argument id for a declared flag: its spelling without the dashes, +/// which is also the long spelling clap registers, so `--old-string` is the only +/// way to write it and the id never invents a second name. +fn flag_id(name: &'static str) -> &'static str { + name.trim_start_matches('-') +} -The committed document's docs are read from the committed prompt -object, the whole prompt file the module's state names, never from -the working tree, for the same reason every other field of the -committed document ignores the working tree: the document is what the -store recorded, and a docs edit that has not passed through the docs -record does not exist to it. A renderer serving the committed -document therefore shows exactly the prose a `docs-only` or module -transition installed, byte for byte, and hovering that prose can only -ever reveal code the store attributes. A state recorded before prompt -objects existed serves its docs lists empty rather than guessing from -the working tree, and the module's next commit records the baseline. -Under `--working-tree` docs come from the working-tree prompt files -as before, which is the read the LSP and the editing loop want. +/// Parse a command's arguments with clap and reduce them to one normalized +/// vector: the command word, then every operand in order, then every flag in +/// declared order as a separated `--name value` pair. +/// +/// Reduction is where the conventional spellings converge. `--module=leaf` and +/// `--module leaf` both arrive here as different tokens and both leave as the +/// same two, so a command body reads one shape and byte-identical output for the +/// two spellings is a property of the parser rather than a promise each command +/// keeps. Failure is the canonical JSON usage error, never clap's rendering. +fn normalize_arguments(spec: &CommandSpec, args: &[String]) -> std::result::Result, Value> { + let (word, rest) = split_command_word(args); + let matches = clap_command_for(spec) + .try_get_matches_from(rest) + .map_err(|err| clap_usage(spec, args, &err))?; + let mut out: Vec = vec![word]; + if let Some(values) = matches.get_many::(OPERANDS) { + out.extend(values.cloned()); + } + for flag in spec.flags { + let id = flag_id(flag.name); + match flag.arity { + FlagArity::Switch => { + if matches.get_flag(id) { + out.push(flag.name.to_string()); + } + } + FlagArity::Value => { + if let Some(values) = matches.get_many::(id) { + for given in values { + out.push(flag.name.to_string()); + out.push(given.clone()); + } + } + } + } + } + Ok(out) +} -The document's freshness verdicts and the language server's are one -analysis: a section the LSP would squiggle as reference-stale reads -`referenceStatus: stale` here with the same endpoints, never a -hardcoded fresh. +/// Split the command word out of the arguments, leaving what clap parses. +/// +/// The command word is the first operand, which is not always the first token: a +/// flag the command declares may precede it, as in `napl --workspace /w status`. +fn split_command_word(args: &[String]) -> (String, Vec) { + let mut rest: Vec = Vec::new(); + let mut word: Option = None; + let mut iter = args.iter().peekable(); + while let Some(arg) = iter.next() { + if word.is_none() && !is_flag(arg) && arg != "--" { + word = Some(arg.clone()); + continue; + } + rest.push(arg.clone()); + if word.is_none() && is_flag(arg) && !is_switch(arg) && !arg.contains('=') { + if let Some(value) = iter.next() { + rest.push(value.clone()); + } + } + } + (word.unwrap_or_default(), rest) +} -History entries carry the transition hash, module, target, outcome, and -`usage` — `{model, input, output, cacheRead}` from the staged record's -validated usage evidence, or null when none was supplied; absent -telemetry is null, never zero. +/// Translate one clap parse failure into the canonical JSON usage error. +/// +/// Nothing clap wrote is read: the outcome is classified by kind and the detail +/// is this contract's own sentence, so the bytes a caller sees are the bytes the +/// contract pins whatever clap's messages say this release. +fn clap_usage(spec: &CommandSpec, args: &[String], err: &clap::Error) -> Value { + use clap::error::ErrorKind; + if matches!( + err.kind(), + ErrorKind::UnknownArgument | ErrorKind::InvalidSubcommand | ErrorKind::NoEquals + ) { + let unknown = undeclared_token(spec, args); + let names: Vec = spec.flags.iter().map(|flag| flag.name.to_string()).collect(); + return usage_json( + Some(spec), + &format!("unknown flag {unknown:?} for command {:?}", spec.name), + Some(&unknown), + nearest(&unknown, &names).as_deref(), + ); + } + let detail = match err.kind() { + ErrorKind::InvalidValue | ErrorKind::TooFewValues | ErrorKind::WrongNumberOfValues => { + "a declared flag was given no value, or a value its grammar does not accept" + } + ErrorKind::MissingRequiredArgument => "a required argument is missing", + ErrorKind::TooManyValues => "a declared flag was given more values than it accepts", + ErrorKind::ArgumentConflict => "two of the given arguments cannot be used together", + ErrorKind::InvalidUtf8 => "an argument is not valid UTF-8", + _ => "the arguments do not parse under this command's declared grammar", + }; + usage_json(Some(spec), detail, None, None) +} -A history entry carries the transition's kind: an ordinary install -says `installed`, and a docs-only transition says `docs-only` as both -its `kind` and its `outcome`, so any reader of the document tells -prose history from semantic history without dereferencing the -transition object. +/// The first `--` token the command's table does not declare, which is the token +/// a caller means when clap reports an unknown argument. Falling back to the +/// whole failing invocation would name nothing, so an absent token reads as the +/// empty spelling and the suggestion machinery simply finds nothing near it. +fn undeclared_token(spec: &CommandSpec, args: &[String]) -> String { + for arg in args { + if arg == "--" { + break; + } + if !arg.starts_with('-') || arg == "-" { + continue; + } + let name = arg.split('=').next().unwrap_or(arg); + if is_help_token(name) { + continue; + } + if declared_flag(spec, name).is_none() { + return name.to_string(); + } + } + String::new() +} -Canonical serialization throughout: sorted keys, total ordering for every -array (modules by name; sections by start then end line; files and cases -by path and name; history by transition sequence, transition hash as -tiebreaker), no absolute paths anywhere (`promptPath` and file paths are -workspace-relative), one trailing newline. The same committed state -produces the same document bytes on any machine. +/// The nearest candidate by edit distance, when one is close enough to mean it. +fn nearest(given: &str, candidates: &[String]) -> Option { + let mut best: Option<(usize, String)> = None; + for candidate in candidates { + let distance = edit_distance(given, candidate); + if best.as_ref().is_none_or(|(seen, _)| distance < *seen) { + best = Some((distance, candidate.clone())); + } + } + let (distance, candidate) = best?; + let ceiling = std::cmp::max(2, given.chars().count() / 3); + (distance <= ceiling).then_some(candidate) +} -::: test -name: q with no arguments emits the canonical document byte-stably -given: { workspace: fixture_two_modules, runs: 2 } -expect: { bytes_equal: true, top_level_keys: [docVersion, head, history, modules, state] } -::: +/// Levenshtein distance over characters. +fn edit_distance(left: &str, right: &str) -> usize { + let left: Vec = left.chars().collect(); + let right: Vec = right.chars().collect(); + let mut previous: Vec = (0..=right.len()).collect(); + let mut current: Vec = vec![0; right.len() + 1]; + for (i, l) in left.iter().enumerate() { + current[0] = i + 1; + for (j, r) in right.iter().enumerate() { + let cost = usize::from(l != r); + current[j + 1] = std::cmp::min( + std::cmp::min(current[j] + 1, previous[j + 1] + 1), + previous[j] + cost, + ); + } + std::mem::swap(&mut previous, &mut current); + } + previous[right.len()] +} -::: test -name: the document contains no absolute paths -given: { workspace: fixture_two_modules } -expect: { absolute_paths: 0 } -::: +fn command_names(registry: &[CommandSpec]) -> Vec { + let mut names: Vec = registry.iter().map(|spec| spec.name.to_string()).collect(); + names.sort(); + names.dedup(); + names +} -::: test -name: directive cases appear inside their owning section -given: { workspace: fixture_with_directive_case, attribution: fresh } -expect: { section_cases: 1, case_fields: [expect, given, lines, name] } -::: +/// `napl help` and `napl help `. +fn help_command_or_all(registry: &[CommandSpec], args: &[String]) -> CliResult { + let topic = positionals_from(args, 1).into_iter().next(); + let Some(topic) = topic else { + return ok_bytes(&help_all_json(registry)); + }; + match registry.iter().find(|spec| spec.name == topic) { + Some(spec) => ok_bytes(&help_command_json(spec)), + None => { + let names = command_names(registry); + emit_usage(&usage_json( + None, + &format!("unknown command {topic:?}"), + Some(&topic), + nearest(&topic, &names).as_deref(), + )) + } + } +} -::: test -name: stale attribution keeps cases and marks the association stale -given: { workspace: fixture_with_directive_case, attribution: stale } -expect: { section_cases: 1, association_stale: true, module_status: stale } -::: +/// The canonical body `napl help` prints: every registered command with its +/// one-line summary. +pub fn help_all_json(registry: &[CommandSpec]) -> Value { + let mut listed: Vec<&CommandSpec> = registry.iter().collect(); + listed.sort_by_key(|spec| spec.name); + let commands: Vec = listed + .iter() + .map(|spec| { + json!({ + "name": spec.name, + "summary": spec.summary, + "usage": spec.usage, + }) + }) + .collect(); + json!({ + "commands": commands, + "contractVersion": crate::version::CONTRACT_VERSION, + "usage": BINARY_USAGE, + }) +} -::: test -name: section refs are self-contained -given: { workspace: fixture_two_modules } -expect: { section_fields_present: [direction, head, lines, module, promptPath, provenance, referenceStatus, staleEndpoints] } -::: +/// The canonical body per-command help prints: the summary, the exact usage +/// line, and the closed flag table with arity and meaning. +pub fn help_command_json(spec: &CommandSpec) -> Value { + json!({ + "command": spec.name, + "contractVersion": crate::version::CONTRACT_VERSION, + "flags": flag_table_json(spec), + "summary": spec.summary, + "usage": spec.usage, + }) +} -::: test -name: usage from staged evidence appears in history entries -given: { workspace: fixture_two_modules, commit_with_usage: true } -expect: { history_usage_fields: [cacheRead, input, model, output], absent_usage: null } -::: +fn flag_table_json(spec: &CommandSpec) -> Vec { + let mut flags: Vec<&FlagSpec> = spec.flags.iter().collect(); + flags.sort_by_key(|flag| flag.name); + flags + .iter() + .map(|flag| { + json!({ + "arity": match flag.arity { + FlagArity::Switch => 0, + FlagArity::Value => 1, + }, + "meaning": flag.meaning, + "name": flag.name, + "short": flag.short.map_or(Value::Null, |short| json!(short)), + }) + }) + .collect() +} -::: test -name: a docs-only transition is told apart in history without dereferencing the transition -given: { workspace: fixture_two_modules, then: [docs_edit, add_prompt_path, commit] } -expect: { last_history_kind: docs-only, last_history_outcome: docs-only, ordinary_entries_say: installed } -::: +/// The canonical body of a usage error: what was wrong, the exact usage line, +/// the valid flags, and the nearest declared flag when one is close. +pub fn usage_json( + spec: Option<&CommandSpec>, + detail: &str, + unknown: Option<&str>, + suggestion: Option<&str>, +) -> Value { + let mut payload = serde_json::Map::new(); + payload.insert("contractVersion".into(), json!(crate::version::CONTRACT_VERSION)); + payload.insert("detail".into(), json!(detail)); + payload.insert("exitCode".into(), json!(USAGE_EXIT_CODE)); + payload.insert("outcome".into(), json!("usage")); + match spec { + Some(spec) => { + payload.insert("command".into(), json!(spec.name)); + payload.insert("usage".into(), json!(spec.usage)); + payload.insert("validFlags".into(), json!(flag_table_json(spec))); + } + None => { + payload.insert("command".into(), Value::Null); + payload.insert("usage".into(), json!(BINARY_USAGE)); + payload.insert("validCommands".into(), json!(command_names(&crate::registry()))); + } + } + payload.insert( + "unknown".into(), + unknown.map_or(Value::Null, |name| json!(name)), + ); + payload.insert( + "suggestion".into(), + suggestion.map_or(Value::Null, |name| json!(name)), + ); + Value::Object(payload) +} -::: test -name: a section's docs payloads arrive in order with their bodies verbatim -given: { workspace: fixture_with_docs_fences } -expect: { docs_fields: [attrs, body, directive, lines], order_preserved: true } -::: +/// Write a usage error to stderr as canonical JSON and exit with the pinned +/// usage-error code. Nothing goes to stdout, so a caller piping output never +/// mistakes a refusal for a result. +fn emit_usage(payload: &Value) -> CliResult { + let bytes = canonical_bytes(payload); + let stderr = std::io::stderr(); + let mut lock = stderr.lock(); + let _ = lock.write_all(&bytes); + let _ = lock.flush(); + CliResult { + stdout: Vec::new(), + exit_code: USAGE_EXIT_CODE, + } +} -::: test -name: "the committed document serves the docs the store recorded, not the working tree" -given: { workspace: fixture_with_docs_fences, then: [edit_docs_fence_without_recording] } -expect: { committed_docs_unchanged: true, working_tree_docs_show_edit: true } -::: +/// The canonical body of a prerequisite refusal: a command whose meaning requires +/// a committed head, run in a workspace that has none. +/// +/// An empty success shape would be a lie here, because nothing was compared: the +/// payload names what is missing and the exact sequence that creates it, from +/// `napl init` through writing a prompt beside `.napl`, `add`, and `commit`. +pub fn prerequisite_json(command: &str) -> Value { + json!({ + "command": command, + "contractVersion": crate::version::CONTRACT_VERSION, + "detail": format!( + "{command} needs a committed head to compare against, and this workspace has none" + ), + "exitCode": USAGE_EXIT_CODE, + "missing": "head", + "outcome": "prerequisite", + "sequence": [ + { + "command": "napl init", + "step": "init", + }, + { + "command": "napl write .napl < prompt", + "step": "prompt", + }, + { + "command": "napl add --module ", + "step": "add", + }, + { + "command": "napl commit --staged ", + "step": "commit", + }, + ], + }) +} -::: test -name: a docs-only commit moves the committed document's docs and nothing else in the module -given: { workspace: fixture_with_docs_fences, then: [docs_edit, add_prompt_path, commit] } -expect: { committed_docs_show_edit: true, sections_unmoved: true, input_key_changed: false } -::: +/// Write a prerequisite refusal to stderr as canonical JSON under the pinned +/// usage-error code, so a caller piping stdout never reads a refusal as a result. +pub fn emit_prerequisite(command: &str) -> CliResult { + emit_usage(&prerequisite_json(command)) +} -::: test -name: a state without a prompt baseline serves empty docs rather than guessing -given: { workspace: fixture_committed_before_prompt_objects } -expect: { module_docs: [], section_docs_empty: true, working_tree_mode_still_serves_docs: true } -::: +fn ok_bytes(value: &Value) -> CliResult { + CliResult { + stdout: canonical_bytes(value), + exit_code: 0, + } +} -::: test -name: a module preamble fence serves title and description in module docs -given: { workspace: fixture_with_module_preamble } -expect: { module_docs_attrs: [description, title] } -::: +pub(crate) fn ok(value: &Value) -> Result { + Ok(ok_bytes(value)) +} -::: test -name: a module with no docs fences serves empty docs lists -given: { workspace: fixture_two_modules } -expect: { module_docs: [], section_docs_empty: true, nulls: 0 } -::: +/// A usage failure raised from inside a command body: a missing required flag, +/// a missing positional, a malformed value. It carries the pinned usage code and +/// the dispatch loop restates the command's exact usage line around it. +pub(crate) fn usage(detail: &str) -> CompilerError { + error(OutcomeCode::Usage, detail.to_string()) +} -::: test -name: file refs carry the claim discriminator -given: { workspace: fixture_two_modules } -expect: { file_claims_subset_of: [range, whole] } -::: +pub(crate) fn resolve_workspace(cwd: &Path, args: &[String]) -> Result { + match flag(args, "--workspace")? { + Some(explicit) => Ok(PathBuf::from(explicit)), + None => require_workspace_root(cwd), + } +} -::: test -name: reference staleness in the document matches the language server verdict -given: { workspace: fixture_with_stale_reference } -expect: { reference_status: stale, lsp_parity: true, hardcoded_fresh: false } -::: +fn is_flag(arg: &str) -> bool { + arg.starts_with("--") +} -## napl q +/// True when a flag name is declared as a switch by some command in the +/// registry, so the argument walk knows it consumes no value. Switch names are +/// unique across the registry by construction; a name declared as a switch +/// anywhere is a switch everywhere. +fn is_switch(arg: &str) -> bool { + let name = arg.split('=').next().unwrap_or(arg); + if HELP_FLAGS.contains(&name) { + return true; + } + crate::registry().iter().any(|spec| { + spec.flags + .iter() + .any(|flag| flag.name == name && flag.arity == FlagArity::Switch) + }) +} -`napl q` is shaped exactly like jq and IS jq: expressions are evaluated -by an embedded jq engine (the jaq crate, version pinned by the lockfile, -upgraded only through a normal committed change). Impure builtins are -rejected at expression compile time (`env`, `$ENV`, `now`, `input`, -`inputs`, `debug`, `stderr`, `input_line_number`, and anything else doing -I/O or randomness), so an expression is a pure function of its input. +pub(crate) fn positional(args: &[String], index: usize) -> Result { + positionals_from(args, 0) + .get(index) + .cloned() + .ok_or_else(|| usage(&format!("missing positional argument {index}"))) +} -Argv grammar, exactly three forms; more than one input is a usage -error; `--` is accepted before a path starting with a dash; the -expression is always required and there is deliberately no shorthand -for identity anywhere, because the primary caller is a language model -session and a bare invocation that dumps the whole document is a -context bomb, not a convenience; the usage error for a missing -expression teaches `.` as the explicit spelling and jq demands its -filter the same way: +pub(crate) fn positionals_from(args: &[String], skip: usize) -> Vec { + let mut out: Vec = Vec::new(); + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if is_flag(arg) { + if !is_switch(arg) && !arg.contains('=') { + iter.next(); + } + continue; + } + out.push(arg.clone()); + } + out.into_iter().skip(skip).collect() +} - napl q expression over the canonical document - napl q - expression over stdin - napl q expression over a JSON file +pub(crate) fn has_switch(args: &[String], name: &str) -> bool { + args.iter().any(|arg| arg == name) +} -Input may be a single JSON value or a stream of values (JSONL); the -expression applies per value exactly as jq does. Each result value is -canonically serialized, one per line; `-r`/`--raw-output` emits string -results as their raw contents exactly as jq does, one result per -output line with exactly one newline between results and one at the -end, never doubled when the string itself ends in one, so shell -pipelines never need a dequoting round trip and a raw result diffs -clean against the file it came from. An empty result stream is exit 0 -with no output. Because canonical serialization is already one compact -value per line, jq's `-c`/`--compact-output` is accepted as a -harmless synonym of the default: it changes nothing and never errors, -so the jq reflex costs no usage round trip; `-c` composes with `-r` -exactly as it does in jq, where raw string output ignores it. Errors are canonical JSON on stderr with pinned exit -codes: 2 usage, 3 expression parse or refused builtin, 4 input parse, 5 -runtime or type error. Evaluation carries a compiled-in output ceiling of -64 MiB, enforced functionally and surfaced as `resource-limit` with its -existing exit code; recursion depth is bounded by the embedded engine's -own evaluation stack rather than a compiled-in constant, and an -expression exceeding it aborts rather than returning wrong data. Piping any napl command's -canonical JSON back through an expression works by construction: -`napl status | napl q '.pending' -`. +pub(crate) fn flag(args: &[String], name: &str) -> Result> { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == name { + return iter + .next() + .cloned() + .map(Some) + .ok_or_else(|| usage(&format!("{name} needs a value"))); + } + if let Some(value) = arg.strip_prefix(&format!("{name}=")) { + return Ok(Some(value.to_string())); + } + } + Ok(None) +} -:::docs -`napl q` is jq over the store: same expression language, same flags, -same reflexes. The expression is required; `.` spells the whole -document explicitly. -::: +pub(crate) fn repeated_flag(args: &[String], name: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == name { + if let Some(value) = iter.next() { + out.push(value.clone()); + } + } else if let Some(value) = arg.strip_prefix(&format!("{name}=")) { + out.push(value.to_string()); + } + } + out +} -:::example{name="read the head"} -napl q '.head' -::: +pub(crate) fn require_flag(args: &[String], name: &str) -> Result { + flag(args, name)?.ok_or_else(|| usage(&format!("{name} is required"))) +} +napl-store tree v1 129 +{"entries":[{"hash":"279b09a95275fcdffed7d04f7abeac151929523eeb3f1773ca01a4b042570dc9","kind":"dir","mode":16384,"name":"src"}]} +napl-store snapshot v1 445 +{"contractVersion":2,"event":"b260a2fdfd546f30e7123695754da1bc177f26f76048a2c7b8bcce9285fb8ae2","states":[{"module":"commands","state":"7c7e8183b9fbf594f3d61a4ca43f5154c404f912f32b8789d32c697ae883859b","target":"rust"},{"module":"napl","state":"ede5d6f7c642bc1ae88d4737fc899bdcc20ef363ef468792804cd36f02350c41","target":"rust"},{"module":"napl_store","state":"d5141dcf22807eb4d7314871e99dd9b1f165a355d2ba0c088f6d2f4e2b9cf7ac","target":"rust"}]} +napl-store blob v1 1439 +module: "commands" +target: "rust" +stale: false +entries: + - promptLines: + - 1 + - 8 + file: "crates/napl/src/init.rs" + lines: whole + note: "two porcelain additions each a thin composition over the shared derivations, the command table contributing init and watch" + - promptLines: + - 9 + - 124 + file: "crates/napl/src/init.rs" + lines: whole + note: "napl init bootstraps the store skeleton or reports already_initialized, names the directory it ran in as the workspace root, declares no flags beyond the shared help flag, teaches the first mile through nextSteps whose minimal prompt declares roots naming its own module so the first workspace is lint-ready, and scaffolds the agent glue by writing only the entry files that are absent, never touching one that exists, and spelling every command as a valid invocation under the live command line contract so the query example carries its expression, and scaffolds the store's travel glue under the same never-clobber law, a pre-push hook that carries the store branch written only where no pre-push hook exists and the one line to add printed where one does, with the two protection rules taught in the payload and none of it appearing at all outside a git repository" + - promptLines: + - 125 + - 146 + file: "crates/napl/src/watch.rs" + lines: whole + note: "napl watch an io shell re-running compile on content change publishing nothing" +napl-store tree v1 136 +{"entries":[{"hash":"afd89e2c931d08eca6a2030a2c098322e09c32eb9e4b70405f2fcc67e728dba0","kind":"dir","mode":16384,"name":"napl-store"}]} +napl-store module-state v1 564 +{"attribution":{"promptHash":"85eab36f412936d247f59dc688116dd31246d27c0b86a7b3df52829d27e2d90a","record":"503ce69816a25637fa759ebc9ee0e90d64903dde221729eb06e6f8edb26bf571","stale":false},"contractVersion":4,"inputKey":"865433045eb8c750a39bd7d29f2e8631c96a03d6aedad8f19ea9907551bdc927","module":"napl_store","outputTree":"ff83494adc1c5e3514e322b085e3a4d0de257ed1eed408a0d4ae9d6e37dbbc4e","sliceHash":"951ebb40565bde5002786d6a78770f17133d18e44aa250622eec8fef2d0596d9","surfaceHash":"50b745f71f63d7fd5b8a05fdf84474524004af8f75d47f8346b7404f99981fab","target":"rust"} +napl-store blob v1 1759 +module: "commands" +target: "rust" +stale: false +entries: + - promptLines: + - 1 + - 8 + file: "crates/napl/src/init.rs" + lines: whole + note: "two porcelain additions each a thin composition over the shared derivations, the command table contributing init and watch" + - promptLines: + - 9 + - 131 + file: "crates/napl/src/init.rs" + lines: whole + note: "napl init bootstraps the store skeleton or reports already_initialized, names the directory it ran in as the workspace root, declares no flags beyond the shared help flag, teaches the first mile through nextSteps whose minimal prompt declares roots naming its own module so the first workspace is lint-ready, and scaffolds the agent glue by writing only the entry files that are absent, never touching one that exists, and spelling every command as a valid invocation under the live command line contract so the query example carries its expression, and scaffolds the store's git manners under the same never-clobber law, a gitattributes whose one glob marks the objects and the packs beneath them never text undiffed and generated because an eol-converting checkout would corrupt every envelope against its own name, whose HEAD line marks the pointer binary so a merge never writes conflict markers into it, and whose notes and attribution lines mark those records never text and nothing else because reading their diffs is what they are for, written only where no gitattributes exists and the exact lines printed where one does, and none of it appearing at all outside a git repository" + - promptLines: + - 132 + - 153 + file: "crates/napl/src/watch.rs" + lines: whole + note: "napl watch an io shell re-running compile on content change publishing nothing" +napl-store blob v1 25465 +//! `napl commit`: the one guarded write path. +//! +//! The shape of this file is the contract's pipeline, in order: ingest, verify, +//! extract, derive, stage — all outside any lock — and then one bounded critical +//! section that rechecks the footprint and swaps the pointer and the directory. +//! Nothing inside the lock hashes content, and the number of steps it applies is +//! fixed before it is taken. +//! +//! Every decision it makes is the pure compiler's. This file supplies bytes and +//! performs writes. -:::example{name="list every module"} -napl q '.modules[].name' -::: +use std::collections::BTreeMap; +use std::path::Path; -:::note -Piping works by construction: any napl command's JSON output feeds -back through an expression, as in `napl status | napl q '.pending' -`. -::: +use crate::candidate::{default_limits, ingest, Candidate}; +use crate::compile::{head_states, slice_at, state_of}; +use crate::decision::{decide, rejection, Arriving, Head}; +use crate::error::{error, error_payload, OutcomeCode, Result}; +use crate::receipt::{parse_receipt, Receipt}; +use crate::source::SourceModule; +use crate::store_view::{get_blob, ObjectSource}; +use crate::target::{builtin_target, placement_root, TargetDef}; +use crate::transaction::{ + accept, check_candidate_shape, derive_commit, intervening_write_set, success_payload, + BuiltAttribution, DerivedCommit, PendingObject, +}; +use crate::attribution; +use crate::objects::AttributionState; +use napl_store::store_txn_core::CommitMarker; +use serde_json::Value; -::: test -name: q evaluates a jq expression over the document -given: { workspace: fixture_two_modules, expr: ".modules[].name" } -expect: { output_lines: [consumer, leaf] } -::: +use crate::disk::{self, Store, POINTER_LOCK}; +use crate::workspace::{apply_step, discover_sources, scan_candidate}; -::: test -name: q accepts the compact flag as a synonym of the default output -given: { workspace: fixture_two_modules, invocations: ["q .pending", "q -c .pending", "q --compact-output .pending"] } -expect: { outputs_byte_identical: true, exit_codes: [0, 0, 0] } -::: +/// The machine-readable result of a commit attempt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitReport { + pub outcome: OutcomeCode, + pub payload: Value, +} -::: test -name: q without an expression is a usage error teaching the explicit dot -given: { workspace: fixture_two_modules, invocations: ["q", "q -c", "q -r"] } -expect: { exit_codes: [2, 2, 2], error_teaches_dot: true, document_dumped: false } -::: +/// What had already reached the store when a failure occurred. +#[derive(Default, Debug, Clone, Copy)] +struct Published { + blobs: bool, + provenance: bool, +} -::: test -name: q compact composes with raw output changing nothing -given: { stdin: "{\"a\":\"x\"}", expr: ".a", input: "-", flags: ["-r", "-c -r"] } -expect: { outputs_byte_identical: true, raw_string_unquoted: true } -::: +/// How a commit treats the attribution update it carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttributionMode { + /// Prove every claim against the ingested candidate and the current prompt + /// body before anything installs. The default, and what `add` requires. + Prove, + /// Install an update whose claims were already proven, referentially and in + /// full, against the exact bytes they name. This is the capture verbs' mode: + /// a reference-bearing edit proves its ranges against the post-edit working + /// tree, which is the content the reference is about, and the candidate it + /// re-commits is the module's unchanged committed output. Re-proving the + /// reference against those older bytes would judge a claim nobody made. The + /// guard is untouched: the whole transaction, decision table, journal, and + /// recovery run exactly as they do for `commit`. + PreProven, + /// The explicit escape hatch: install unproven and record the module's + /// attribution as stale. + Escape, +} -::: test -name: q evaluates an expression over stdin when the input is a dash -given: { stdin: "{\"pending\":[\"m\"]}", expr: ".pending", input: "-" } -expect: { output: "[\"m\"]" } -::: +/// Run a commit. It never panics and never returns an error: every path +/// produces a canonical payload naming whether blobs or provenance were +/// published, and no path publishes semantic state partially. +pub fn commit( + workspace: &Path, + receipt_path: &Path, + candidate_dir: &Path, + attribution_path: Option<&Path>, + no_verify: bool, +) -> CommitReport { + let mode = if no_verify { + AttributionMode::Escape + } else { + AttributionMode::Prove + }; + commit_with_mode( + workspace, + receipt_path, + candidate_dir, + attribution_path, + mode, + None, + ) +} -::: test -name: q rejects an invalid expression without touching the store -given: { workspace: fixture_two_modules, expr: ".[unclosed" } -expect: { exit_code: 3, store_bytes_unchanged: true } -::: +/// Run a commit under an explicit attribution mode. +/// +/// `staged_prompt` is the module's prompt file as `add` captured it. A record +/// staged through `add` always carries one, and commit installs those bytes, so +/// a prompt edited between add and commit is not absorbed unjudged. A caller +/// with no staged copy, which is every capture verb, passes `None` and the +/// working tree answers. +pub fn commit_with_mode( + workspace: &Path, + receipt_path: &Path, + candidate_dir: &Path, + attribution_path: Option<&Path>, + mode: AttributionMode, + staged_prompt: Option<&Path>, +) -> CommitReport { + let mut published = Published::default(); + match run( + workspace, + receipt_path, + candidate_dir, + attribution_path, + mode, + staged_prompt, + &mut published, + ) { + Ok(payload) => CommitReport { + outcome: OutcomeCode::Success, + payload, + }, + Err(err) => CommitReport { + outcome: err.code, + payload: error_payload(&err, published.blobs, published.provenance), + }, + } +} -::: test -name: q refuses impure builtins -given: { workspace: fixture_two_modules, expr: "now" } -expect: { exit_code: 3, error_names_builtin: true } -::: +#[allow(clippy::too_many_arguments)] +fn run( + workspace: &Path, + receipt_path: &Path, + candidate_dir: &Path, + attribution_path: Option<&Path>, + mode: AttributionMode, + staged_prompt: Option<&Path>, + published: &mut Published, +) -> Result { + let store = disk::init(workspace)?; + recover(&store)?; -::: test -name: q raw output emits string contents without quotes exactly as jq dash r -given: { workspace: fixture_two_modules, expr: ".modules[0].name", flags: [-r] } -expect: { output: leaf_unquoted, trailing_newline: true } -::: + let receipt_bytes = + std::fs::read(receipt_path).map_err(|e| disk::io(receipt_path, &e))?; + let receipt = parse_receipt(&receipt_bytes)?; + let target = builtin_target(&receipt.target)?; + let sources = discover_sources(workspace)?; -::: test -name: the document serves a heading outline with body line numbers -given: { workspace: fixture_two_modules } -expect: { outline_present: true, outline_entry_fields: [level, line, text], lines_body_normalized: true } -::: + let candidate = ingest(&scan_candidate(candidate_dir)?, default_limits())?; + let root = placement_of(&sources, &receipt.module)?; + check_candidate_shape(&target, &root, &candidate)?; -## napl prompt + crate::transaction::verify_receipt(&store, &sources, &receipt, &candidate)?; -`napl prompt` (alias `napl p`) is the prompt builder: it reads refs from -stdin — the objects a `napl q` stage selected — and emits markdown built -from committed state at the refs' snapshot, never current head, and -never the working tree except under the explicit `--working-tree` mode -defined in the working-tree reads section. Stdin is typed-ref JSONL: one JSON object per line -carrying `head`, `lines`, and exactly one of `path` or `promptPath`; -unknown extra fields are ignored. A line that is not an object, carries -both path kinds or neither, or has a non-integer, zero, reversed, or -out-of-range line range is an error naming the input line number, never a -silent skip. Empty stdin is an error saying nothing was selected. Refs -naming more than one head in a single invocation error. Duplicate refs -(same head, path, range) emit once; input order is otherwise preserved. - -Materialization reads the snapshot's content-addressed blobs; committed -prompts and committed module outputs are the only eligible paths. A -`{path, lines}` ref resolves to its owning module through the snapshot's -output manifests — owned globs are disjoint by commit invariant, so -ownership is unique, and a discovered overlap is a store-integrity -failure, not a guess. A prompt ref yields specification prose; a file ref -yields code: one resolver, both directions. Every chunk gets a provenance -header naming `path:start-end` plus the module and note when known. -Cases render after their section's prose as canonical JSON code blocks -inside dynamically sized fences, immune to backticks in values. - -Law closure by default: the `law: true` sections of every module a ref -touched are appended once, deduplicated, ordered by module then range, -under one trailing header; `--no-laws` opts out. Refs into a module whose -attribution is stale refuse by default with that module's fix directive; -`--allow-stale` proceeds and stamps `stale` into the affected provenance -headers. Unresolvable refs (absent path, unavailable snapshot) error -naming the ref's input line. - -::: test -name: prompt materializes a prompt ref with a provenance header -given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf } -expect: { output_contains_prose: true, header_format: "path:start-end" } -::: - -::: test -name: prompt materializes a generated-file ref symmetrically -given: { workspace: fixture_two_modules, stdin: file_ref_for_leaf_output } -expect: { output_contains_code: true } -::: - -::: test -name: prompt appends law sections by default and omits them under no-laws -given: { workspace: fixture_with_law_section, stdin: section_ref_non_law } -expect: { laws_appended_default: true, laws_with_flag: false } -::: - -::: test -name: prompt refuses refs into a stale module by default -given: { workspace: fixture_with_directive_case, attribution: stale, stdin: section_ref } -expect: { exit_nonzero: true, directive_present: true, allow_stale_overrides: true } -::: - -::: test -name: prompt errors on a malformed stdin line naming its line number -given: { stdin_lines: [valid_ref, "not json"] } -expect: { exit_nonzero: true, error_names_line: 2 } -::: - -::: test -name: prompt errors on empty stdin -given: { stdin: "" } -expect: { exit_nonzero: true, error: nothing_selected } -::: + let built = build_attribution( + &sources, + &receipt, + &target, + &root, + &candidate, + attribution_path, + mode, + )?; -::: test -name: prompt deduplicates identical refs and preserves input order -given: { stdin_lines: [ref_a, ref_b, ref_a] } -expect: { chunks: 2, order: [ref_a, ref_b] } -::: + let source = sources.iter().find(|s| s.module == receipt.module); + let prompt_file = match (staged_prompt, source) { + (Some(staged), _) => Some(std::fs::read(staged).map_err(|e| disk::io(staged, &e))?), + (None, Some(src)) => { + let path = workspace.join(&src.path); + Some(std::fs::read(&path).map_err(|e| disk::io(&path, &e))?) + } + (None, None) => None, + }; + if let Some(bytes) = &prompt_file { + let spelled = source.map(|src| src.path.clone()).unwrap_or_default(); + let text = std::str::from_utf8(bytes).map_err(|_| { + error( + OutcomeCode::InvalidCandidate, + format!( + "the staged prompt for module {:?} is not valid UTF-8", + receipt.module + ), + ) + })?; + let parsed = crate::source::parse_source(&spelled, text)?; + if parsed.module != receipt.module { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "the staged prompt declares module {:?} but this record commits {:?}", + parsed.module, receipt.module + ), + )); + } + } + let prompt_object = prompt_file.as_ref().map(|bytes| { + napl_store::store_hash::object_name(napl_store::store_hash::ObjectKind::Blob, bytes) + }); + let derived = derive_commit( + &target, + &root, + &receipt, + &candidate, + built.as_ref(), + prompt_object, + )?; -::: test -name: "prompt resolves refs against their snapshot, not current head" -given: { workspace: fixture_two_modules, ref_head: previous_snapshot, edits_after: leaf_recommitted } -expect: { materialized_bytes: previous_snapshot_bytes } -::: + publish_all(&store, &derived.objects, &derived.transition_id)?; + if let Some(src) = sources.iter().find(|s| s.module == receipt.module) { + disk::publish( + &store, + napl_store::store_hash::ObjectKind::Blob, + src.normalized_body.as_bytes(), + &derived.transition_id, + )?; + } + if let Some(bytes) = &prompt_file { + disk::publish( + &store, + napl_store::store_hash::ObjectKind::Blob, + bytes, + &derived.transition_id, + )?; + } + published.blobs = true; + published.provenance = true; -## Working-tree reads, byte-exact retrieval, and pre-add validation + let mut payload = + install(&store, workspace, &target, &root, &receipt, &sources, &candidate, &derived)?; -Three reads exist so that a tool amending a prompt can read exactly what -it is amending, retrieve the exact bytes it is about to splice, and -validate the result before anything stages, without a single raw file -read. All three are pure reads: no store writes, no locks, no clock, no -staging. Each is a pure function of the committed state plus the on-disk -prompt bytes it explicitly names, and byte-stable across runs over the -same inputs. + crate::porcelain::repack_if_loose(&store)?.record(&mut payload); + Ok(payload) +} -`napl q --working-tree` builds the canonical document with every -prompt-derived fact enumerated from the on-disk prompt files instead of -the committed blobs: frontmatter, sections, `::: test` directive cases, -and body line ranges all reflect the pending working-tree prompt, -including sections and cases that exist only in the working tree. -Store-derived facts are unchanged and still committed: `head`, module -states, history, receipts. The top level carries `workingTree: true`, -and each section carries `workingTree: true` in place of a committed -`head` value, so a working-tree ref can never be mistaken for a -committed one and no hash is ever invented for uncommitted bytes. -Section file associations are the committed map's claims transported -through the prompt's committed-to-pending diff, the same deterministic -transport `add` uses: an association whose prompt range transports -cleanly is served intact, and a section whose range lands in a -rewritten or ambiguous region serves `files: []` with -`associationUnproven: true`. The view never re-associates files by -range arithmetic over the re-anchored body; an overlap that happens to -exist in the pending text is not a claim, and serving it as one would -be inventing a mapping nobody made. -Section-to-file association under a stale attribution behaves exactly as -today: sections carry their cases, `associationStale: true` marks the -pairing untrustworthy, and nothing pretends freshness. Reference -staleness fields serve exactly as in the committed document. +#[allow(clippy::too_many_arguments)] +fn install( + store: &Store, + workspace: &Path, + target: &TargetDef, + root: &str, + receipt: &Receipt, + sources: &[SourceModule], + candidate: &Candidate, + derived: &DerivedCommit, +) -> Result { + let module_lock = disk::take_lock(store, &lock_key(&target.name, &receipt.module))?; + let pointer_lock = match disk::take_lock(store, POINTER_LOCK) { + Ok(guard) => guard, + Err(err) => { + disk::release_lock(module_lock)?; + return Err(err); + } + }; + let outcome = + locked_install(store, workspace, target, root, receipt, sources, candidate, derived); + disk::release_lock(pointer_lock)?; + disk::release_lock(module_lock)?; + outcome +} -`napl prompt --working-tree` resolves `promptPath` refs against the -on-disk prompt file instead of a committed blob. A working-tree ref -carries `workingTree: true` and no `head`; a ref carrying both, or a mix -of working-tree and committed refs in one invocation, is a usage error -naming the input line. Everything else about materialization holds: -provenance headers, law closure from committed state, case rendering, -error shapes. +#[allow(clippy::too_many_arguments)] +fn locked_install( + store: &Store, + workspace: &Path, + target: &TargetDef, + root: &str, + receipt: &Receipt, + sources: &[SourceModule], + candidate: &Candidate, + derived: &DerivedCommit, +) -> Result { + let head = store.head()?; -`napl prompt --bytes` is byte-exact retrieval, the inverse of the edit a -tool is about to make: the ref's line range materializes as the exact -source bytes of that range, no reflow, no case rendering, no law -closure, no provenance header, one trailing newline. It accepts exactly -one ref; more than one is a usage error. It composes with -`--working-tree` so a splice can be computed against the pending file. -The bytes returned are byte-identical to the named lines of the source, -so retrieve-then-splice is deterministic by construction. + let head_slice = slice_at( + store, + sources, + &target.name, + &receipt.module, + head.as_deref(), + )?; + let states = head_states(store, head.as_deref())?; + let head_state_key = state_of(store, &states, target, &receipt.module)?.1; + let arriving = Arriving { + input_key: receipt.input_key.clone(), + base_state_key: receipt.base_state_key.clone(), + base_snapshot: receipt.base_store_snapshot.clone(), + write_set: derived.transition.footprint.write_set.iter().cloned().collect(), + }; + let head_facts = Head { + snapshot: head.clone(), + input_key: head_slice.input_key, + base_state_key: head_state_key, + intervening_write_set: intervening_write_set( + store, + head.as_deref(), + receipt.base_store_snapshot.as_deref(), + )?, + }; + if let Some(rejected) = rejection(&decide(&arriving, &head_facts)) { + return Err(rejected); + } -`napl check ` is dry validation of an on-disk prompt file -before `add`: frontmatter shape and module-name rules per the napl -contract, body normalization, directive grammar for every `::: test` -block including canonical JSON in `given` and `expect`, and case-name -uniqueness. The module-name rules are enforced as name grammar, not -merely as YAML: a frontmatter value that parses as a string but can -never be a legal module name (path-metacharacters, a bare `[`, empty -after trimming) is a finding at check time, never a surprise later. -Findings are canonical JSON on stdout, each naming the file, -the line, and what failed; every reported line is the physical -1-based line in the file as an editor counts it, frontmatter -included, never a body-normalized offset. Exit codes are pinned: 0 for a valid prompt, -1 when findings exist, 2 for usage including an unreadable path. The -store is never touched and nothing stages. `check` passing means the -prompt parses under the same reader `add` uses; it does not predict -`add` outcomes that depend on candidates, attribution, or placement, and -its wording never implies otherwise. `check` teaches while it refuses: -the finding for a missing opening frontmatter fence carries one -complete minimal valid prompt inline, so the first error a cold user -sees contains the whole grammar they were missing; and a checked file -whose name lacks the `.napl` extension gets a finding saying discovery -will never find it, naming the extension, whatever else is valid about -its content. + let accepted = accept( + store, + target, + root, + candidate, + derived, + head.as_deref(), + receipt.base_store_snapshot.as_deref(), + )?; + publish_all(store, &accepted.objects, &derived.transition_id)?; -::: test -name: q working-tree enumerates a section that exists only in the working tree -given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } -expect: { new_section_enumerated: true, top_level_working_tree: true, committed_document_unchanged: true } -::: + let marker = CommitMarker { + domain: receipt.module.clone(), + owner: derived.transition_id.clone(), + snapshot: accepted.snapshot.clone(), + steps: accepted.steps.clone(), + }; + disk::write_marker(store, &marker)?; + disk::compare_and_set_head(store, head.as_deref(), &accepted.snapshot)?; + apply_steps(store, workspace, &marker)?; + disk::delete_marker(store, &accepted.snapshot)?; -::: test -name: working-tree sections carry no committed head and mark themselves -given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } -expect: { section_working_tree: true, section_head_absent: true } -::: + Ok(success_payload(derived, &accepted)) +} -::: test -name: the working-tree document is byte-stable across runs -given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, runs: 2 } -expect: { bytes_equal: true } -::: +/// Install a docs record: write the new prompt object and re-point the module's +/// prompt at it, while every derived fact holds. +/// +/// The module keeps its inputKey, its attribution map, its endpoints, its surface +/// and its output tree, because none of them is a function of prose. What moves +/// is the state's prompt object and the head that names the state. The transition +/// says `docs-only`, so history never confuses prose with semantics. +pub fn commit_docs(workspace: &Path, record: &Path) -> CommitReport { + let mut published = Published::default(); + match run_docs(workspace, record, &mut published) { + Ok(payload) => CommitReport { + outcome: OutcomeCode::Success, + payload, + }, + Err(err) => CommitReport { + outcome: err.code, + payload: error_payload(&err, published.blobs, published.provenance), + }, + } +} -::: test -name: a transported section keeps its files and a rewritten one serves none unproven -given: { workspace: fixture_two_modules, working_tree_edit: shift_one_section_rewrite_another, flags: [--working-tree] } -expect: { shifted_section_files_intact: true, rewritten_section_files: [], rewritten_section_association_unproven: true, overlap_reassociation: 0 } -::: +fn run_docs(workspace: &Path, record: &Path, published: &mut Published) -> Result { + let store = disk::init(workspace)?; + recover(&store)?; -::: test -name: prompt bytes round-trips the exact source bytes of a range -given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf, flags: [--bytes] } -expect: { bytes_equal_raw_slice: true, reflow: false, headers: false } -::: + let record_dir = crate::staging::record_directory(record)?; + let meta = crate::staging::record_meta(&record_dir)?; + let module = meta["module"].as_str().unwrap_or_default().to_string(); + let target_name = meta["target"].as_str().unwrap_or("rust").to_string(); + builtin_target(&target_name)?; + let prompt_path = meta["promptPath"].as_str().unwrap_or_default().to_string(); + let base_state_key = meta["baseStateKey"].as_str().unwrap_or_default().to_string(); + if base_state_key.is_empty() { + return Err(error( + OutcomeCode::InvalidReceipt, + format!("the staged docs record for module {module:?} names no base state; re-run `napl add {prompt_path}`"), + )); + } -::: test -name: prompt bytes refuses more than one ref as a usage error -given: { stdin_lines: [ref_a, ref_b], flags: [--bytes] } -expect: { exit_code: 2, error_names_line: 2 } -::: + let staged = record_dir.join(crate::staging::DOCS_PROMPT_FILE); + let bytes = std::fs::read(&staged).map_err(|e| disk::io(&staged, &e))?; + let text = String::from_utf8(bytes).map_err(|_| { + error( + OutcomeCode::InvalidCandidate, + format!("the staged prompt for module {module:?} is not valid UTF-8"), + ) + })?; + let parsed = crate::source::parse_source(&prompt_path, &text)?; + if parsed.module != module { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "the staged prompt declares module {:?} but the record names {module:?}", + parsed.module + ), + )); + } -::: test -name: a ref carrying both a head and working-tree is a usage error naming its line -given: { stdin_lines: [ref_with_head_and_working_tree], flags: [--working-tree] } -expect: { exit_nonzero: true, error_names_line: 1 } -::: + let object = + napl_store::store_hash::object_name(napl_store::store_hash::ObjectKind::Blob, text.as_bytes()); -::: test -name: check passes a valid prompt with exit zero and no findings -given: { workspace: fixture_two_modules, prompt: leaf_prompt_valid } -expect: { exit_code: 0, findings: 0, store_bytes_unchanged: true } -::: + let head = store.head()?; + let committed_now = crate::porcelain::committed_state(&store, &target_name, &module)?; + if committed_now.as_ref().and_then(|s| s.prompt.as_deref()) == Some(object.as_str()) { + return Ok(serde_json::json!({ + "alreadyRecorded": true, + "blobsPublished": false, + "contractVersion": crate::version::CONTRACT_VERSION, + "detail": "the committed prompt already is these bytes, so this record had nothing left to install and was swept", + "head": head, + "installed": [], + "kind": Value::Null, + "module": module, + "outcome": "success", + "prompt": object, + "promptPath": prompt_path, + "provenancePublished": false, + "semanticStatePublished": false, + "target": target_name, + })); + } -::: test -name: check reports a malformed directive with a finding naming its line -given: { workspace: fixture_two_modules, prompt: leaf_prompt_broken_directive } -expect: { exit_code: 1, finding_names_line: true, store_bytes_unchanged: true } -::: + let state = crate::objects::read_module_state(&store, &base_state_key)?; + let Some(baseline) = &state.prompt else { + return Err(error( + OutcomeCode::InvalidCandidate, + format!("the base state of module {module:?} names no prompt object, so there is no committed prompt to prove this docs edit against"), + )); + }; + let committed = crate::staging::committed_prompt(&store, baseline, &module)?; + match crate::staging::judge_prompt(&prompt_path, &committed, &text)? { + crate::staging::PromptDrift::DocsOnly => {} + other => { + return Err(error( + OutcomeCode::InvalidCandidate, + format!("the staged prompt for module {module:?} is not a docs-only edit against the base state it names ({other:?}), and no docs record may install one"), + )); + } + } -::: test -name: the missing-fence finding carries a complete minimal prompt that itself passes check -given: { workspace: fixture_two_modules, prompt: file_without_frontmatter } -expect: { exit_code: 1, finding_example_passes_check: true } -::: + let new_state = crate::objects::ModuleState { + prompt: Some(object.clone()), + ..state + }; + let new_state_key = crate::objects::module_state_hash(&new_state); + let transition = crate::objects::Transition { + module: module.clone(), + target: target_name.clone(), + input_key: new_state.input_key.clone().unwrap_or_default(), + base_state_key: base_state_key.clone(), + new_state: new_state_key.clone(), + receipt: String::new(), + surface: new_state.surface_hash.clone(), + footprint: crate::objects::Footprint { + read_set: vec![format!("prompt:{baseline}")], + write_set: vec![crate::objects::state_write_key(&target_name, &module)], + derived_projections: Vec::new(), + install_paths: Vec::new(), + }, + kind: Some(crate::objects::DOCS_ONLY.to_string()), + prompt: Some(object.clone()), + }; + let transition_id = crate::objects::transition_hash(&transition); -::: test -name: a checked file without the napl extension is told discovery will never find it -given: { workspace: fixture_two_modules, prompt: valid_content_named_greeting_md } -expect: { exit_code: 1, finding_names_extension: ".napl", store_bytes_unchanged: true } -::: + let objects = vec![ + PendingObject { + name: object.clone(), + kind: napl_store::store_hash::ObjectKind::Blob, + payload: text.as_bytes().to_vec(), + }, + json_pending( + napl_store::store_hash::ObjectKind::ModuleState, + &crate::objects::module_state_json(&new_state), + ), + json_pending( + napl_store::store_hash::ObjectKind::Transition, + &crate::objects::transition_json(&transition), + ), + ]; + publish_all(&store, &objects, &transition_id)?; + published.blobs = true; + published.provenance = true; -::: test -name: a module name that yaml accepts but the grammar forbids is a finding -given: { workspace: fixture_two_modules, prompt: frontmatter_module_bare_bracket } -expect: { exit_code: 1, finding_names_module_grammar: true } -::: + let payload = install_docs( + &store, + &target_name, + &module, + &base_state_key, + &new_state_key, + &transition_id, + &object, + &prompt_path, + )?; + Ok(payload) +} -::: test -name: findings report physical file lines as an editor counts them -given: { workspace: fixture_two_modules, prompt: empty_module_value_on_file_line_2 } -expect: { finding_line: 2, line_kind: physical_one_based } -::: -napl-store transition v1 632 -{"baseStateKey":"f38b0d5a644c8edfdf8d88f5387838f0df2f638ef556620264f3a6900afd8c6b","contractVersion":4,"derivedProjections":[],"inputKey":"75f1ab1522b202af14be01e6b4ddbdecda7cb2c0de4d48fd4019bfefc5baa2bd","installPaths":[],"kind":"docs-only","module":"query","newState":"c9b19428a067036863248c563011734528e64170ca525ded3f9a7d984af54a0d","prompt":"873378d3138b0baad075c5f7dbd2199724a15cbefb539ea81b8c2985a4348ff8","readSet":["prompt:ee120f47ae972eb522d7434f8b88090ac6330b202121d45c9c500357a03b8815"],"surface":"cb2b0ee10d67af3091437305664b3ea3b0419b83dee9436cb4ebea480fef83d3","target":"rust","writeSet":["module-state:rust/query"]} -napl-store blob v1 12460 -module: "napl" -target: "rust" -stale: false -entries: - - promptLines: - - 1 - - 8 - file: "crates/napl/src/cli.rs" - lines: whole - note: "the binary reads args runs one command and exits with the contract code" - - promptLines: - - 1 - - 8 - file: "crates/napl/src/commandset.rs" - lines: whole - note: "the command table the core toolchain contributes to the derived registry" - - promptLines: - - 1 - - 8 - file: "crates/napl/src/main.rs" - lines: whole - note: "the binary entry point that spawns no process" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/source.rs" - lines: whole - note: "the smallest prompt the reader accepts, stated once beside the reader that judges it so the two commands that teach with it cannot drift from the grammar discovery finds by the .napl extension" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/transaction.rs" - lines: whole - note: "the crate lib.rs projection derived from the committed module file sets" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/workspace.rs" - lines: whole - note: "layout and discovery walk up to the nearest napl directory" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/paths.rs" - lines: whole - note: "the empty placement root contains every workspace-relative path and joins against it never fabricate a leading separator" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/target.rs" - lines: whole - note: "a prompt at the workspace root places at the empty root and every join against it stays relative" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/transaction.rs" - lines: whole - note: "candidate containment against the empty root admits every workspace-relative path instead of rejecting all of them" - - promptLines: - - 115 - - 250 - file: "crates/napl/src/cli.rs" - lines: whole - note: "the declared grammar of every command plus the dispatch loop that answers help ahead of it and refuses an undeclared flag before it runs, with clap's builder API constructed at dispatch from the registry as the parser behind the contract, every parse failure caught and served as the canonical JSON usage error, and the conventional spellings reduced to one normalized argument vector so --flag=value and --flag value cannot differ" - - promptLines: - - 115 - - 250 - file: "crates/napl/src/commandset.rs" - lines: whole - note: "each core command declares its summary exact usage line and closed flag table, and diff's operand is a target name whose usage line spells it and whose refusal names the target names that exist" - - promptLines: - - 115 - - 250 - file: "crates/napl/src/error.rs" - lines: whole - note: "exit 2 is the pinned usage-error code for the whole binary" - - promptLines: - - 115 - - 250 - file: "crates/napl/src/target.rs" - lines: whole - note: "placement roots owned source globs and the user-owned manifest seam a candidate may never contain" - - promptLines: - - 251 - - 278 - file: "crates/napl/src/decision.rs" - lines: whole - note: "the conflict rule two operations conflict when semantic write sets overlap" - - promptLines: - - 251 - - 278 - file: "crates/napl/src/error.rs" - lines: whole - note: "the outcome vocabulary and its exit-code mapping" - - promptLines: - - 251 - - 278 - file: "crates/napl/src/objects.rs" - lines: whole - note: "P2 footprints read write and derived-projection sets over content identities" - - promptLines: - - 279 - - 328 - file: "crates/napl/src/slice.rs" - lines: whole - note: "identity vocabulary inputKey closure manifest baseStateKey and sliceHash" - - promptLines: - - 279 - - 328 - file: "crates/napl/src/version.rs" - lines: whole - note: "the contract version bumped to four by the capture verbs whose three refusals join the closed table" - - promptLines: - - 329 - - 389 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "names are serialized as quoted escaped scalars that reparse identically" - - promptLines: - - 329 - - 389 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "the attribution record path keys on the encoded name never the raw one" - - promptLines: - - 329 - - 389 - file: "crates/napl/src/paths.rs" - lines: whole - note: "the injective reversible name encoding used wherever a name becomes a segment" - - promptLines: - - 329 - - 389 - file: "crates/napl/src/source.rs" - lines: whole - note: "the module-name rule any non-empty single-line scalar and nothing narrower" - - promptLines: - - 390 - - 424 - file: "crates/napl/src/compile.rs" - lines: whole - note: "the compile derivation a pure read that never persists an artifact" - - promptLines: - - 390 - - 424 - file: "crates/napl/src/graph.rs" - lines: whole - note: "the dependency graph and dependency-ordered wave planning" - - promptLines: - - 390 - - 424 - file: "crates/napl/src/paths.rs" - lines: whole - note: "the portable path grammar and its collision-detecting normalization" - - promptLines: - - 425 - - 482 - file: "crates/napl/src/directive.rs" - lines: whole - note: "the test body-directive value grammar diagnostics and deterministic extraction" - - promptLines: - - 483 - - 536 - file: "crates/napl/src/surface.rs" - lines: whole - note: "tree-sitter surface extraction sound by construction and loud on malformed input" - - promptLines: - - 537 - - 991 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "attribution rides the staged record validated before install" - - promptLines: - - 537 - - 991 - file: "crates/napl/src/authoring.rs" - lines: whole - note: "the internal receipt derivation and the one guarded auto-fix" - - promptLines: - - 537 - - 991 - file: "crates/napl/src/candidate.rs" - lines: whole - note: "candidate ingestion untrusted bytes become immutable content-addressed blobs" - - promptLines: - - 537 - - 991 - file: "crates/napl/src/commit.rs" - lines: whole - note: "napl commit the one guarded transaction over what add staged, and bare commit re-derives a record against the head an earlier record in the same invocation moved and retries it once" - - promptLines: - - 537 - - 991 - file: "crates/napl/src/disk.rs" - lines: whole - note: "the store shell publication head compare-and-set and module-keyed locks" - - promptLines: - - 537 - - 991 - file: "crates/napl/src/receipt.rs" - lines: whole - note: "the internal receipt everything the compiler recomputes and refuses to believe" - - promptLines: - - 537 - - 991 - file: "crates/napl/src/staging.rs" - lines: whole - note: "napl add stages from any of the three sources through one pipeline, the dry run enumerates the validations it ran and names target-language validity as the one it did not judge, and a bad attribution map is answered with the whole map contract" - - promptLines: - - 537 - - 991 - file: "crates/napl/src/store_view.rs" - lines: whole - note: "the read seam over content-addressed objects a pure function of the bytes" - - promptLines: - - 537 - - 991 - file: "crates/napl/src/transport.rs" - lines: whole - note: "attribution transports through the byte diff on both ends independently, and a refusal reports each endpoint's verdict separately so the side that transported serves its transported range and only the side needing a human is asked for" - - promptLines: - - 537 - - 991 - file: "crates/napl/src/trees.rs" - lines: whole - note: "building the candidate tree over the ingested manifest" - - promptLines: - - 537 - - 991 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "attribution rides the staged record validated before install" - - promptLines: - - 537 - - 991 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "attribution rides the staged record validated before install" - - promptLines: - - 537 - - 991 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "attribution rides the staged record validated before install" - - promptLines: - - 537 - - 991 - file: "crates/napl/src/lsp.rs" - lines: whole - note: "hover states the direction the recorded provenance that the reference is referentially valid and which endpoints read stale" - - promptLines: - - 992 - - 1180 - file: "crates/napl/src/capture.rs" - lines: whole - note: "napl edit and napl write validate apply capture in that order, and a recorded call's payload carries the reference object verbatim with its transition hash and says the edited file remains working-tree drift until its module commits" - - promptLines: - - 992 - - 1180 - file: "crates/napl/src/commit.rs" - lines: whole - note: "a pre-proven attribution installs fresh because the verb already proved the reference against the exact bytes it names and the guard runs unchanged" - - promptLines: - - 992 - - 1180 - file: "crates/napl/src/cost.rs" - lines: whole - note: "a recorded session window is priced lazily at read time never aggregated at record time" - - promptLines: - - 992 - - 1180 - file: "crates/napl/src/error.rs" - lines: whole - note: "the three refusals join the closed terminal-outcome table with pinned exit codes 27 28 and 29 and none reuses mapping-integrity" - - promptLines: - - 992 - - 1180 - file: "crates/napl/src/staging.rs" - lines: whole - note: "the staged record carries its attribution mode so a reference-only record commits through the same door add and commit already use" - - promptLines: - - 1181 - - 1500 - file: "crates/napl/src/blame.rs" - lines: whole - note: "derived line ancestry where surviving lines trace through accepted patches, and a path no committed module owns answers tracked false naming the absent baseline rather than an empty ancestry that would read as success" - - promptLines: - - 1181 - - 1500 - file: "crates/napl/src/cost.rs" - lines: whole - note: "cost as a pure calculation over an explicit receipt set where verb-staged session windows resolve against the log at read time and unknown stays unknown" - - promptLines: - - 1181 - - 1500 - file: "crates/napl/src/evidence.rs" - lines: whole - note: "evidence validators keyed by media type including the usage-proxy jsonl log with compiled-in bounds" - - promptLines: - - 1181 - - 1500 - file: "crates/napl/src/lint.rs" - lines: whole - note: "unreachable modules under declared roots where absent roots refuse loudly with the exact declaration rather than an empty findings list, and unconsumed surface entries" - - promptLines: - - 1181 - - 1500 - file: "crates/napl/src/lsp.rs" - lines: whole - note: "the language server hover definition references code lens and the diagnostics it publishes from reference staleness over the mapping" - - promptLines: - - 1181 - - 1500 - file: "crates/napl/src/maintenance.rs" - lines: whole - note: "gc fsck and restore, where reachable means the transitive closure from every named head through every event and every hash edge any reachable object carries, so an edge gc does not understand is a reason to keep and a store healthy before a sweep is healthy after it" - - promptLines: - - 1181 - - 1500 - file: "crates/napl/src/mv.rs" - lines: whole - note: "relocation as a semantic operation re-rooting a module through the guarded path" - - promptLines: - - 1181 - - 1500 - file: "crates/napl/src/porcelain.rs" - lines: whole - note: "the observational porcelain status log show diff blame, where the plan labels every projection path tool-derived and names one concrete legal first output and show lists a transition's reference entries inline exactly as it lists notes" - - promptLines: - - 1181 - - 1500 - file: "crates/napl/src/textdiff.rs" - lines: whole - note: "diff renders drift as a canonical byte-stable patch" - - promptLines: - - 1501 - - 1535 - file: "crates/napl/src/canon.rs" - lines: whole - note: "the determinism contract canonical bytes and byte-pinned serialization" -napl-store blob v1 5087 -use std::collections::BTreeMap; -use std::path::Path; +#[allow(clippy::too_many_arguments)] +fn install_docs( + store: &Store, + target_name: &str, + module: &str, + base_state_key: &str, + new_state_key: &str, + transition_id: &str, + prompt_object: &str, + prompt_path: &str, +) -> Result { + let module_lock = disk::take_lock(store, &lock_key(target_name, module))?; + let pointer_lock = match disk::take_lock(store, POINTER_LOCK) { + Ok(guard) => guard, + Err(err) => { + disk::release_lock(module_lock)?; + return Err(err); + } + }; + let outcome = locked_docs_install( + store, + target_name, + module, + base_state_key, + new_state_key, + transition_id, + prompt_object, + prompt_path, + ); + disk::release_lock(pointer_lock)?; + disk::release_lock(module_lock)?; + outcome +} -use serde_json::{json, Map, Value}; +#[allow(clippy::too_many_arguments)] +fn locked_docs_install( + store: &Store, + target_name: &str, + module: &str, + base_state_key: &str, + new_state_key: &str, + transition_id: &str, + prompt_object: &str, + prompt_path: &str, +) -> Result { + let head = store.head()?; + let mut states = head_states(store, head.as_deref())?; + let key = (target_name.to_string(), module.to_string()); + if states.get(&key).map(String::as_str) != Some(base_state_key) { + return Err(error( + OutcomeCode::WriteConflict, + format!("module {module:?} moved under this docs record; re-run `napl add {prompt_path}`"), + )); + } + states.insert(key, new_state_key.to_string()); -use crate::canon::canonical_bytes; -use crate::cli::{ok, positional, resolve_workspace, CliResult, CommandSpec}; -use crate::error::Result; -use crate::lsp::parse_attribution; -use crate::porcelain::{log, status, surface_of}; -use crate::workspace::discover_sources; + let parents: Vec = match head.as_deref() { + Some(snapshot) => vec![crate::objects::snapshot_event(store, snapshot)?], + None => Vec::new(), + }; + let event = crate::objects::event_hash(transition_id, &parents); + let snapshot = crate::objects::snapshot_hash(&event, &states); + publish_all( + store, + &[ + json_pending( + napl_store::store_hash::ObjectKind::Event, + &crate::objects::event_json(transition_id, &parents), + ), + json_pending( + napl_store::store_hash::ObjectKind::Snapshot, + &crate::objects::snapshot_json(&event, &states), + ), + ], + transition_id, + )?; -/// The command table this module contributes to the derived registry. -pub fn commands() -> &'static [CommandSpec] { - &[ - CommandSpec { - name: "init", - run: crate::init::run_init, - }, - CommandSpec { - name: "q", - run: run_q, - }, - CommandSpec { - name: "watch", - run: crate::watch::run_watch, - }, - ] -} + let marker = CommitMarker { + domain: module.to_string(), + owner: transition_id.to_string(), + snapshot: snapshot.clone(), + steps: Vec::new(), + }; + disk::write_marker(store, &marker)?; + disk::compare_and_set_head(store, head.as_deref(), &snapshot)?; + disk::delete_marker(store, &snapshot)?; -/// `napl q [args]`: the stable, scriptable query surface over the -/// typed read operations the porcelain formats for humans. -/// -/// Every operation emits canonical JSON and reads only; the store is never -/// written. The registry is fixed: `modules`, `history`, `surface`, -/// `attribution`, and `state`. An unknown operation is `unknown_operation`, -/// exits nonzero, and never touches the store. -pub(crate) fn run_q(cwd: &Path, args: &[String]) -> Result { - let operation = positional(args, 1)?; - const KNOWN: [&str; 5] = ["attribution", "history", "modules", "state", "surface"]; - if !KNOWN.contains(&operation.as_str()) { - return Ok(unknown_operation(&operation)); - } - let workspace = resolve_workspace(cwd, args)?; - match operation.as_str() { - "modules" => ok(&modules(&workspace)?), - "history" => ok(&log(&workspace, Some(&positional(args, 2)?))?), - "surface" => ok(&surface_of(&workspace, "rust", &positional(args, 2)?)?), - "attribution" => ok(&attribution(&workspace, &positional(args, 2)?)?), - "state" => ok(&status(&workspace, "rust")?), - _ => Ok(unknown_operation(&operation)), - } + Ok(serde_json::json!({ + "attribution": "fresh", + "blobsPublished": true, + "contractVersion": crate::version::CONTRACT_VERSION, + "head": snapshot, + "installed": [], + "kind": crate::objects::DOCS_ONLY, + "module": module, + "outcome": "success", + "prompt": prompt_object, + "promptPath": prompt_path, + "provenancePublished": true, + "semanticStatePublished": true, + "target": target_name, + "transition": transition_id, + })) } -fn unknown_operation(operation: &str) -> CliResult { - CliResult { - stdout: canonical_bytes(&json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "operation": operation, - "outcome": "unknown_operation", - })), - exit_code: 2, +fn json_pending(kind: napl_store::store_hash::ObjectKind, value: &Value) -> PendingObject { + let (name, payload) = crate::canon::hash_json(kind, value); + PendingObject { + name, + kind, + payload, } } -/// The module list with identities, in canonical module order. -fn modules(workspace: &Path) -> Result { - let sources = discover_sources(workspace)?; - let state = status(workspace, "rust")?; - let mut committed: BTreeMap = BTreeMap::new(); - if let Some(entries) = state["modules"].as_array() { - for entry in entries { - if let Some(name) = entry["module"].as_str() { - committed.insert(name.to_string(), entry.clone()); +/// Roll every recoverable commit marker forward, then drop it. +/// +/// The disposition is `napl-store`'s: a marker whose snapshot is HEAD or an +/// ancestor of HEAD rolls forward; a marker ahead of HEAD is a commit that never +/// landed and is collected. Neither branch consults liveness or wall time. +pub fn recover(store: &Store) -> Result> { + let head = store.head()?; + let mut applied: Vec = Vec::new(); + for snapshot in disk::marker_snapshots(store)? { + let Some(marker) = disk::read_marker(store, &snapshot)? else { + continue; + }; + if head.as_deref() == Some(snapshot.as_str()) { + apply_steps(store, &store.workspace, &marker)?; + applied.push(snapshot.clone()); + } + disk::delete_marker(store, &snapshot)?; + } + Ok(applied) +} + +fn apply_steps(store: &Store, workspace: &Path, marker: &CommitMarker) -> Result<()> { + let payload = |hash: &str| get_blob(store, hash); + for step in &marker.steps { + apply_step(workspace, step, &payload)?; + } + Ok(()) +} + +fn publish_all(store: &Store, objects: &[PendingObject], owner: &str) -> Result<()> { + let mut seen: BTreeMap<&str, ()> = BTreeMap::new(); + for object in objects { + if seen.insert(object.name.as_str(), ()).is_some() { + continue; + } + disk::publish(store, object.kind, &object.payload, owner)?; + } + Ok(()) +} + +/// Prove the attribution update a commit carries, or refuse the commit. +/// +/// A commit without an attribution update fails loudly, naming what is missing, +/// unless the escape hatch is set. With the escape hatch, an update installs +/// unproven and is recorded stale, and a commit with no update at all leaves the +/// module's attribution absent. A proven update is validated against the ingested +/// candidate and the current normalized prompt body before anything installs. A +/// pre-proven update installs fresh without a second validation, because its +/// claims were already proven against the exact bytes they name. +#[allow(clippy::too_many_arguments)] +fn build_attribution( + sources: &[SourceModule], + receipt: &Receipt, + target: &TargetDef, + root: &str, + candidate: &Candidate, + attribution_path: Option<&Path>, + mode: AttributionMode, +) -> Result> { + let no_verify = mode == AttributionMode::Escape; + let bytes = match attribution_path { + Some(path) => std::fs::read(path).map_err(|e| disk::io(path, &e))?, + None => { + if no_verify { + return Ok(None); } + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "commit of module {:?} for target {:?} carries no attribution update; supply --attribution or --no-verify", + receipt.module, target.name + ), + )); } + }; + let text = String::from_utf8(bytes).map_err(|_| { + error( + OutcomeCode::InvalidCandidate, + "attribution update is not valid UTF-8", + ) + })?; + let mut map = attribution::parse(&text).ok_or_else(|| { + error( + OutcomeCode::InvalidCandidate, + "attribution update is not a parseable record", + ) + })?; + map.stale = no_verify; + + let source = sources + .iter() + .find(|source| source.module == receipt.module) + .ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {:?} has no prompt file in the workspace", receipt.module), + ) + })?; + if mode == AttributionMode::Prove { + let body_lines = source.normalized_body.lines().count() as u32; + attribution::validate( + target, + root, + &receipt.module, + &candidate.files, + body_lines, + &map, + )?; } - let mut names: Vec = sources.iter().map(|s| s.module.clone()).collect(); - names.sort(); - names.dedup(); - let mut list: Vec = Vec::new(); - for name in &names { - let deps: Vec = sources - .iter() - .find(|s| &s.module == name) - .map(|s| s.deps.clone()) - .unwrap_or_default(); - let mut record = Map::new(); - record.insert("module".into(), json!(name)); - record.insert("deps".into(), json!(deps)); - match committed.get(name) { - Some(entry) => { - record.insert("committed".into(), json!(true)); - record.insert("inputKey".into(), entry["inputKey"].clone()); - record.insert("surfaceHash".into(), entry["surfaceHash"].clone()); + + let yaml = attribution::to_yaml(&map); + let state = AttributionState { + record: attribution::record_hash(&map), + prompt_hash: source.normalized_source_hash.clone(), + stale: no_verify, + }; + Ok(Some(BuiltAttribution { + state, + yaml, + view_path: attribution::view_path(&receipt.module), + })) +} + +/// The lock name a module's domain is keyed by. There is no global write lock: +/// two modules commit concurrently without ever contending. +pub fn lock_key(target: &str, module: &str) -> String { + format!("module-{target}-{}", crate::paths::encode_name(module)) +} + +/// The crate directory a module's prompt sits in, read off the discovered +/// sources so the placement follows the prompt's location and nothing else. +fn placement_of(sources: &[SourceModule], module: &str) -> Result { + sources + .iter() + .find(|source| source.module == module) + .map(placement_root) + .ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} has no prompt file in the workspace"), + ) + }) +} +napl-store tree v1 130 +{"entries":[{"hash":"621295411a54c711bb2002b25e35ee2504da96049cb9f1a4e375d21d96bbde31","kind":"dir","mode":16384,"name":"napl"}]} +napl-store receipt v1 815 +{"baseStateKey":"cca37ce0ab52aabdb236250fe7211d03ae415524090fe1ea8d26ed32f63a0714","baseStoreSnapshot":"2b3feb80cd701b183f001a4b6848669e0e3999d8f4111519dacb885fe8b5dfea","baseWorkspaceHash":"deae17e7d8faae52c958b035adffbad6a288c5ad47b99d6867eb22cd0c828cc1","candidateTreeHash":"9f4012e8a99b2f4b02d181e9b143d433c28e42d021ae54a95e4f0532441bc34f","contractVersion":4,"inputKey":"7706591c3b4333a5a63ee74e62afcf995e52fba9ec546755861dbe9afad51b2c","module":"commands","outputManifest":[{"hash":"5e6d95977066d4654c90b04b5924524324c7572a9c6ec7d315046f7503d8c413","path":"crates/napl/src/init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","path":"crates/napl/src/watch.rs"}],"receiptVersion":1,"sliceHash":"68a148095cb3ff456ce3fc67c2b82b7d160324ef4747870628c765de84f48462","target":"rust"} +napl-store snapshot v1 1005 +{"contractVersion":4,"event":"069c2728f8a4fd65fe20f47e6a3d13c0ed56b7f45f47f325935ee81b346fed45","states":[{"module":"agent","state":"f26d6fde72e9fc4f86b6909dc51455aaf37c8a17ed31d6db11e94d9a0408b324","target":"rust"},{"module":"commands","state":"5c9687b0b683b34bcb823c08473e0bb5b941fc81ccf943666acc7640b948d3da","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"e5181ff4a21adb9dbd040fac3f0a5c958ff8273c4c141fe9f4a438eb2b02d800","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"37fc6230a31e6efbc940e618a1ccc6af767b5660a9961c987cc04018ac20076a","target":"rust"},{"module":"serve","state":"dc62dc04b6d519a8cccdd4d4a3f1b1fa1a0fd830ca0ae5b01058e9d7b5347b98","target":"rust"},{"module":"usage_proxy","state":"e06293a226b1c96b12b2ce6037f5ffdbca05c34b57e0493823456f2c146a4ca1","target":"rust"}]} +napl-store tree v1 130 +{"entries":[{"hash":"9cff2b0892f0b162d1154c2c40241862f71545009a168e05b9928c40fd016df5","kind":"dir","mode":16384,"name":"napl"}]} +napl-store receipt v1 815 +{"baseStateKey":"cb82ac8a35d902954b841fa6b0c14cd03d99e15d69b8005c3986d5ad66e22b29","baseStoreSnapshot":"004169b2891eccf80efc107d5fdca1836dedf116b1bed2892ef7df8609cf2fad","baseWorkspaceHash":"d233c435c9c3f542ebe93d3316cfd02e5b3117ce60b4a048deb988277b9005ba","candidateTreeHash":"b92d10f23649a6ab076e0a23eeda2a922fbb3e779486a81b37c96c11f63d7873","contractVersion":4,"inputKey":"58fca4cee049991c052e54dc443f06f4d3b78ec74aad2fb6971c3138171783c9","module":"commands","outputManifest":[{"hash":"4b14b273d01558abe3560da549f380b808673d119fa0b3f23343a210e6098aee","path":"crates/napl/src/init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","path":"crates/napl/src/watch.rs"}],"receiptVersion":1,"sliceHash":"e4357376b35865972053121b598b255836d940b6c9c6398b07e24e2721ba0fdc","target":"rust"} +napl-store snapshot v1 891 +{"contractVersion":4,"event":"41831e2909f88aaf88451b2435b6bcd41be1ffb1066197bf1ed0fe5e2107876f","states":[{"module":"agent","state":"b5cde0e371a87ce3810de7c7b7af9e48bf453094540fce927bde21dde6b576b9","target":"rust"},{"module":"commands","state":"74f67c7538797091e6d19bd6f87099d0b414743cc4cc151db667619ff4a48d03","target":"rust"},{"module":"napl","state":"56e953f37cb5363c713a80779f2d9c53ed61639c3b0ec2591272f695fb39dfed","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"2ceea09f82f0700bbe48909b0c56aa0e69cc477dd51287bd88cd2e87f6f7a1e1","target":"rust"},{"module":"serve","state":"dd477d0f6a8458d7f08017856ea567c847968a47135ac24ed6afafb6b20b2b8f","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} +napl-store tree v1 136 +{"entries":[{"hash":"e2e63fc725000f036533110366b2bc711836e51e37ed6645ab598f43235a0939","kind":"dir","mode":16384,"name":"napl-store"}]} +napl-store transition v1 843 +{"baseStateKey":"3960d70a07476e0ca5ad272606f66b22890a9d9169b4b411397290260c51e4a9","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"8e31863d6e42fcf4c3d9eb0a7c6546b77e5b0119069b83f197b0bed1c6291cdd","installPaths":["crates/napl/src/agent.rs"],"module":"agent","newState":"7c568214148bdacf0c078e2420e1c8355c24100c703f3490f65ca9bcf1f64535","readSet":["base-state:3960d70a07476e0ca5ad272606f66b22890a9d9169b4b411397290260c51e4a9","input-key:8e31863d6e42fcf4c3d9eb0a7c6546b77e5b0119069b83f197b0bed1c6291cdd","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"7230e35b63a6e73c43fbe209beb2ce01f229aed222f844a91d5986a90c6da014","surface":"ad1581ae209ba3feb19322408bfff544be7a29bc6a7db44c672b705f1afcd120","target":"rust","writeSet":["owned:crates/napl","module-state:rust/agent"]} +napl-store event v1 158 +{"content":"ae84275fd397e90e7bd5908e314c5e069eeb8133820cb7ceb15bfb8be2e61f3b","parents":["57e89f67d8f577c3d02c04f0e83ae79cad645b88ec9c2be490c8515a29c1a778"]} +napl-store tree v1 130 +{"entries":[{"hash":"c34719963c6609475a3a32e33030cfa0b5c3cc087a1e73fce9dc4c801acebc5b","kind":"dir","mode":16384,"name":"napl"}]} +napl-store snapshot v1 895 +{"contractVersion":4,"event":"ddbbd2e77f026e42ea002ce1b43457362f9cb8b4f444487ed662ad8d7c22d9b5","states":[{"module":"agent","state":"67df6d3a1c20bac424e4a0104bf02ea3dd17d728c930e641c212ce0bfd3260d1","target":"rust"},{"module":"commands","state":"723676083558c83d83926d043c8edd16bb2d48cfe3722fde330cf0351870b1a5","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"145d84afd1892b41d64f472a44ed100f908d8255144a8c7ba0335159325e56d5","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"7aa7e0b4e8cc02142a8aa14ca243a5f2058d72de2f65d7ddb7c59e3d201b692a","target":"rust"},{"module":"usage_proxy","state":"acd8f8cd1e71585d8b25804d42455806f39659af2949e7ed369deedcb63abbb2","target":"rust"}]} +napl-store transition v1 1907 +{"baseStateKey":"145d84afd1892b41d64f472a44ed100f908d8255144a8c7ba0335159325e56d5","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"8779274f09f294258d8a2963719ea67a4fdf5a4734d37939c620a596a427aa6b","installPaths":["crates/napl/src/attribution.rs","crates/napl/src/authoring.rs","crates/napl/src/blame.rs","crates/napl/src/candidate.rs","crates/napl/src/canon.rs","crates/napl/src/capture.rs","crates/napl/src/cli.rs","crates/napl/src/commandset.rs","crates/napl/src/commit.rs","crates/napl/src/compile.rs","crates/napl/src/cost.rs","crates/napl/src/decision.rs","crates/napl/src/directive.rs","crates/napl/src/disk.rs","crates/napl/src/error.rs","crates/napl/src/evidence.rs","crates/napl/src/graph.rs","crates/napl/src/lint.rs","crates/napl/src/lsp.rs","crates/napl/src/main.rs","crates/napl/src/maintenance.rs","crates/napl/src/mv.rs","crates/napl/src/objects.rs","crates/napl/src/paths.rs","crates/napl/src/porcelain.rs","crates/napl/src/receipt.rs","crates/napl/src/slice.rs","crates/napl/src/source.rs","crates/napl/src/staging.rs","crates/napl/src/store_view.rs","crates/napl/src/surface.rs","crates/napl/src/target.rs","crates/napl/src/textdiff.rs","crates/napl/src/transaction.rs","crates/napl/src/transport.rs","crates/napl/src/trees.rs","crates/napl/src/version.rs","crates/napl/src/workspace.rs"],"module":"napl","newState":"e5181ff4a21adb9dbd040fac3f0a5c958ff8273c4c141fe9f4a438eb2b02d800","readSet":["base-state:145d84afd1892b41d64f472a44ed100f908d8255144a8c7ba0335159325e56d5","input-key:8779274f09f294258d8a2963719ea67a4fdf5a4734d37939c620a596a427aa6b","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"f23a56b7192bb76b2bca2e1e30587339024960bc963f00249e35eda44620ee52","surface":"6771d238ea5e5fc5c8f741b560515bc9cdc65eebcb55f10dacac50273f555262","target":"rust","writeSet":["owned:crates/napl","module-state:rust/napl"]} +napl-store tree v1 130 +{"entries":[{"hash":"6e23642d8357ffec6e50f5c11c2788c67c0f8e64527c77601207ac9bfd43cd8a","kind":"dir","mode":16384,"name":"napl"}]} +napl-store blob v1 34932 +//! Body directive blocks: `directiveVersion 1`. +//! +//! A prompt body may pin exact corners next to the prose that sharpens them with +//! `::: test` blocks. Recognizing them is part of the identity contract: text an +//! older binary read as inert prose can be a compile error here, which is why +//! this recognition rides a CONTRACT_VERSION bump. The block content is an exact +//! value grammar that maps 1:1 onto canonical JSON, so two binaries cannot +//! disagree on a value. Extraction is a pure function of the normalized body +//! bytes and the directive version: the same bytes always yield the same cases. + +use serde_json::{Map, Value}; +use unicode_normalization::UnicodeNormalization; + +use crate::error::{error, CompilerError, OutcomeCode, Result}; + +/// The docs-family directive names the language owns outright. +pub const DOCS_DIRECTIVES: [&str; 4] = ["docs", "example", "note", "warning"]; + +/// The prefix that opens the extension namespace: carried verbatim, never +/// validated, a renderer's private experiment. +pub const EXTENSION_PREFIX: &str = "x-"; + +/// True for a directive name the docs family owns, including the extension +/// namespace. +pub fn is_docs_directive(name: &str) -> bool { + DOCS_DIRECTIVES.contains(&name) || name.starts_with(EXTENSION_PREFIX) +} + +/// One top-level docs fence, the display payload a renderer is served. +/// +/// `line_start` and `line_end` are PHYSICAL prompt-body lines, 1-based and +/// inclusive: docs fences are cut out of the semantic body, so they have no +/// semantic coordinate of their own. `anchor` is the semantic line the fence +/// follows, which is how a payload finds the section it sits in, and is zero for +/// a fence standing before any semantic line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocsFence { + pub directive: String, + pub attrs: Value, + pub line_start: u32, + pub line_end: u32, + pub body: String, + pub anchor: u32, +} + +/// A prompt body split into the semantic bytes every identity derivation +/// consumes and the display payloads that were cut out of them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BodySplit { + pub semantic: String, + pub docs: Vec, +} + +/// One directive case, with its block range in body-normalized 1-based inclusive +/// lines. `given` is `null` when the block omits it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectiveCase { + pub name: String, + pub given: Value, + pub expect: Value, + pub line_start: u32, + pub line_end: u32, +} + +/// A candidate diagnostic, ranked by the precedence the grammar pins. +struct Diag { + rank: u8, + line: u32, + msg: String, +} + +/// The precedence ranks, lowest number wins: +/// 1 unclosed, 2 unknown name, 3 nested, 4 grammar, 5 name, 6 expect, 7 duplicate. +fn worst(diags: Vec) -> Option { + diags + .into_iter() + .min_by(|a, b| (a.rank, a.line).cmp(&(b.rank, b.line))) +} + +fn compile_error(diag: &Diag) -> CompilerError { + error( + OutcomeCode::MappingIntegrity, + format!("body line {}: {}", diag.line, diag.msg), + ) +} + +/// A normalized line of the body with its 1-based number, its leading-space +/// indent, and the text with that indent stripped. +struct Line { + no: u32, + indent: usize, + text: String, +} + +/// Extract every `::: test` case from a normalized body, or the highest +/// precedence compile error naming the exact body line. +pub fn extract_cases(normalized_body: &str) -> Result> { + let lines: Vec<&str> = normalized_body.lines().collect(); + let mut diags: Vec = Vec::new(); + let mut cases: Vec = Vec::new(); + let mut i = 0usize; + while i < lines.len() { + let lineno = (i + 1) as u32; + let line = lines[i]; + let Some(rest) = line.strip_prefix(":::") else { + i += 1; + continue; + }; + if rest.is_empty() { + diags.push(Diag { + rank: 2, + line: lineno, + msg: "closer `:::` has no matching `::: test` opener".into(), + }); + i += 1; + continue; + } + if rest != " test" { + diags.push(Diag { + rank: 2, + line: lineno, + msg: format!("unknown directive opener {line:?}; only `::: test` is defined"), + }); + i += 1; + continue; + } + let opener = lineno; + let mut j = i + 1; + let mut nested: Option = None; + let mut content: Vec<(u32, &str)> = Vec::new(); + let mut closed_at: Option = None; + while j < lines.len() { + let l = lines[j]; + if l == ":::" { + closed_at = Some((j + 1) as u32); + break; } - None => { - record.insert("committed".into(), json!(false)); + if l.starts_with(":::") { + if nested.is_none() { + nested = Some((j + 1) as u32); + } + } else { + content.push(((j + 1) as u32, l)); } + j += 1; } - list.push(Value::Object(record)); + let Some(closer) = closed_at else { + diags.push(Diag { + rank: 1, + line: opener, + msg: "`::: test` block is never closed by a `:::` line".into(), + }); + break; + }; + if let Some(nested_line) = nested { + diags.push(Diag { + rank: 3, + line: nested_line, + msg: "a `:::` opener is nested inside a `::: test` block".into(), + }); + i = closer as usize; + continue; + } + match parse_case(&content, opener, closer) { + Ok(case) => cases.push(case), + Err(diag) => diags.push(diag), + } + i = closer as usize; } - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "modules": list, - "operation": "modules", - })) + if let Some(diag) = worst(diags) { + return Err(compile_error(&diag)); + } + Ok(cases) } -/// The recorded prompt-to-code attribution for a module. -fn attribution(workspace: &Path, module: &str) -> Result { - let path = workspace - .join(".napl") - .join("attribution") - .join(format!("{module}.yaml")); - let entries: Vec = match std::fs::read_to_string(&path) { - Ok(text) => parse_attribution(&text) - .map(|record| { - record - .entries - .iter() - .map(|entry| { - json!({ - "file": entry.file, - "lineEnd": entry.line_end, - "lineStart": entry.line_start, - "note": entry.note, - "promptEnd": entry.prompt_end, - "promptStart": entry.prompt_start, - }) - }) - .collect() - }) - .unwrap_or_default(), - Err(_) => Vec::new(), +/// Extract the cases and prove no case name collides, across the frontmatter +/// `tests:` names and the body cases, after NFC normalization and whitespace +/// trim. A duplicate is the lowest precedence compile error. +pub fn extract_validated( + normalized_body: &str, + frontmatter_names: &[String], +) -> Result> { + let cases = extract_cases(normalized_body)?; + let mut seen: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for name in frontmatter_names { + seen.entry(normalize_case_name(name)).or_insert(0); + } + for case in &cases { + let key = normalize_case_name(&case.name); + if seen.contains_key(&key) { + return Err(compile_error(&Diag { + rank: 7, + line: case.line_start, + msg: format!("duplicate case name {:?} within the module", case.name), + })); + } + seen.insert(key, case.line_start); + } + Ok(cases) +} + +/// The comparison key for a case name: NFC normalized and whitespace trimmed. +pub fn normalize_case_name(name: &str) -> String { + name.trim().nfc().collect::() +} + +/// Parse a single inline value in the directive value grammar (a scalar or a +/// flow mapping or sequence), returning `None` when it is not well-formed. Used +/// to read the `given`/`expect` flow values of frontmatter `tests:` cases with +/// the same grammar the body directives use. +pub fn parse_inline_value(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + parse_flow_full(trimmed, 0).ok() +} + +fn parse_case(content: &[(u32, &str)], opener: u32, closer: u32) -> std::result::Result { + let mut ls: Vec = Vec::new(); + for (no, text) in content { + if text.trim().is_empty() { + continue; + } + let indent = leading_spaces(text, *no)?; + ls.push(Line { + no: *no, + indent, + text: text[indent..].to_string(), + }); + } + if ls.is_empty() { + return Err(Diag { + rank: 4, + line: opener, + msg: "`::: test` block has no case mapping".into(), + }); + } + let value = parse_block(&ls, 0)?; + let obj = value.as_object().ok_or(Diag { + rank: 4, + line: opener, + msg: "case content is not a mapping".into(), + })?; + let mut diags: Vec = Vec::new(); + for key in obj.keys() { + if key != "name" && key != "given" && key != "expect" { + diags.push(Diag { + rank: 4, + line: opener, + msg: format!("case mapping has an unknown key {key:?}"), + }); + } + } + let name = match obj.get("name") { + Some(Value::String(s)) if !s.trim().is_empty() => Some(s.clone()), + Some(_) => { + diags.push(Diag { + rank: 5, + line: opener, + msg: "case `name` must be a non-empty single-line string".into(), + }); + None + } + None => { + diags.push(Diag { + rank: 5, + line: opener, + msg: "case is missing its `name`".into(), + }); + None + } }; - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "entries": entries, - "module": module, - "operation": "attribution", - })) + let expect = match obj.get("expect") { + Some(v @ Value::Object(_)) => Some(v.clone()), + Some(_) => { + diags.push(Diag { + rank: 4, + line: opener, + msg: "case `expect` must be a mapping".into(), + }); + None + } + None => { + diags.push(Diag { + rank: 6, + line: opener, + msg: "case is missing its `expect`".into(), + }); + None + } + }; + let given = match obj.get("given") { + Some(v @ Value::Object(_)) => v.clone(), + None => Value::Null, + Some(_) => { + diags.push(Diag { + rank: 4, + line: opener, + msg: "case `given` must be a mapping".into(), + }); + Value::Null + } + }; + if let Some(diag) = worst(diags) { + return Err(diag); + } + Ok(DirectiveCase { + name: name.expect("name is present when no diagnostic outranks it"), + given, + expect: expect.expect("expect is present when no diagnostic outranks it"), + line_start: opener, + line_end: closer, + }) +} + +fn leading_spaces(text: &str, no: u32) -> std::result::Result { + let mut count = 0usize; + for ch in text.chars() { + match ch { + ' ' => count += 1, + '\t' => { + return Err(Diag { + rank: 4, + line: no, + msg: "indentation uses a tab; the grammar indents by two spaces".into(), + }) + } + _ => break, + } + } + Ok(count) +} + +fn parse_block(lines: &[Line], base: usize) -> std::result::Result { + let first = &lines[0]; + if first.indent != base { + return Err(Diag { + rank: 4, + line: first.no, + msg: "unexpected indentation".into(), + }); + } + if first.text == "-" || first.text.starts_with("- ") { + parse_seq(lines, base) + } else { + parse_map(lines, base) + } +} + +fn parse_map(lines: &[Line], base: usize) -> std::result::Result { + let mut map = Map::new(); + let mut i = 0usize; + while i < lines.len() { + let line = &lines[i]; + if line.indent != base { + return Err(Diag { + rank: 4, + line: line.no, + msg: "unexpected indentation in mapping".into(), + }); + } + let colon = line.text.find(':').ok_or(Diag { + rank: 4, + line: line.no, + msg: "mapping entry has no `:`".into(), + })?; + let key = line.text[..colon].trim().to_string(); + if key.is_empty() { + return Err(Diag { + rank: 4, + line: line.no, + msg: "mapping key is empty".into(), + }); + } + if map.contains_key(&key) { + return Err(Diag { + rank: 4, + line: line.no, + msg: format!("duplicate mapping key {key:?}"), + }); + } + let rest = line.text[colon + 1..].trim(); + if rest.is_empty() { + let mut k = i + 1; + while k < lines.len() && lines[k].indent > base { + k += 1; + } + let sub = &lines[i + 1..k]; + if sub.is_empty() { + return Err(Diag { + rank: 4, + line: line.no, + msg: format!("key {key:?} has no value"), + }); + } + if sub[0].indent != base + 2 { + return Err(Diag { + rank: 4, + line: sub[0].no, + msg: "a block value must indent by exactly two spaces".into(), + }); + } + map.insert(key, parse_block(sub, base + 2)?); + i = k; + } else { + map.insert(key, parse_flow_full(rest, line.no)?); + i += 1; + } + } + Ok(Value::Object(map)) +} + +fn parse_seq(lines: &[Line], base: usize) -> std::result::Result { + let mut arr: Vec = Vec::new(); + let mut i = 0usize; + while i < lines.len() { + let line = &lines[i]; + if line.indent != base { + return Err(Diag { + rank: 4, + line: line.no, + msg: "unexpected indentation in sequence".into(), + }); + } + if line.text != "-" && !line.text.starts_with("- ") { + return Err(Diag { + rank: 4, + line: line.no, + msg: "sequence entry does not begin with `- `".into(), + }); + } + let rest = if line.text == "-" { + "" + } else { + line.text[2..].trim() + }; + if rest.is_empty() { + let mut k = i + 1; + while k < lines.len() && lines[k].indent > base { + k += 1; + } + let sub = &lines[i + 1..k]; + if sub.is_empty() { + return Err(Diag { + rank: 4, + line: line.no, + msg: "sequence entry has no value".into(), + }); + } + if sub[0].indent != base + 2 { + return Err(Diag { + rank: 4, + line: sub[0].no, + msg: "a block value must indent by exactly two spaces".into(), + }); + } + arr.push(parse_block(sub, base + 2)?); + i = k; + } else { + arr.push(parse_flow_full(rest, line.no)?); + i += 1; + } + } + Ok(Value::Array(arr)) +} + +fn parse_flow_full(text: &str, no: u32) -> std::result::Result { + let chars: Vec = text.chars().collect(); + let (value, pos) = parse_flow(&chars, 0, no)?; + let mut end = pos; + while end < chars.len() && chars[end] == ' ' { + end += 1; + } + if end != chars.len() { + return Err(Diag { + rank: 4, + line: no, + msg: "trailing content after a value".into(), + }); + } + Ok(value) +} + +fn parse_flow(chars: &[char], mut pos: usize, no: u32) -> std::result::Result<(Value, usize), Diag> { + while pos < chars.len() && chars[pos] == ' ' { + pos += 1; + } + if pos >= chars.len() { + return Err(Diag { + rank: 4, + line: no, + msg: "expected a value".into(), + }); + } + match chars[pos] { + '{' => parse_flow_map(chars, pos + 1, no), + '[' => parse_flow_seq(chars, pos + 1, no), + '"' => parse_quoted(chars, pos, no), + _ => parse_plain(chars, pos, no), + } +} + +fn parse_flow_map(chars: &[char], mut pos: usize, no: u32) -> std::result::Result<(Value, usize), Diag> { + let mut map = Map::new(); + loop { + while pos < chars.len() && chars[pos] == ' ' { + pos += 1; + } + if pos >= chars.len() { + return Err(Diag { rank: 4, line: no, msg: "unterminated flow mapping".into() }); + } + if chars[pos] == '}' { + return Ok((Value::Object(map), pos + 1)); + } + let (key, next) = parse_flow_key(chars, pos, no)?; + pos = next; + while pos < chars.len() && chars[pos] == ' ' { + pos += 1; + } + if pos >= chars.len() || chars[pos] != ':' { + return Err(Diag { rank: 4, line: no, msg: "flow mapping entry has no `:`".into() }); + } + pos += 1; + let (value, next) = parse_flow(chars, pos, no)?; + pos = next; + if map.contains_key(&key) { + return Err(Diag { rank: 4, line: no, msg: format!("duplicate mapping key {key:?}") }); + } + map.insert(key, value); + while pos < chars.len() && chars[pos] == ' ' { + pos += 1; + } + if pos >= chars.len() { + return Err(Diag { rank: 4, line: no, msg: "unterminated flow mapping".into() }); + } + match chars[pos] { + ',' => pos += 1, + '}' => return Ok((Value::Object(map), pos + 1)), + _ => return Err(Diag { rank: 4, line: no, msg: "expected `,` or `}` in flow mapping".into() }), + } + } +} + +fn parse_flow_seq(chars: &[char], mut pos: usize, no: u32) -> std::result::Result<(Value, usize), Diag> { + let mut arr: Vec = Vec::new(); + loop { + while pos < chars.len() && chars[pos] == ' ' { + pos += 1; + } + if pos >= chars.len() { + return Err(Diag { rank: 4, line: no, msg: "unterminated flow sequence".into() }); + } + if chars[pos] == ']' { + return Ok((Value::Array(arr), pos + 1)); + } + let (value, next) = parse_flow(chars, pos, no)?; + arr.push(value); + pos = next; + while pos < chars.len() && chars[pos] == ' ' { + pos += 1; + } + if pos >= chars.len() { + return Err(Diag { rank: 4, line: no, msg: "unterminated flow sequence".into() }); + } + match chars[pos] { + ',' => pos += 1, + ']' => return Ok((Value::Array(arr), pos + 1)), + _ => return Err(Diag { rank: 4, line: no, msg: "expected `,` or `]` in flow sequence".into() }), + } + } +} + +fn parse_flow_key(chars: &[char], pos: usize, no: u32) -> std::result::Result<(String, usize), Diag> { + if pos < chars.len() && chars[pos] == '"' { + let (value, next) = parse_quoted(chars, pos, no)?; + match value { + Value::String(s) => Ok((s, next)), + _ => Err(Diag { rank: 4, line: no, msg: "flow mapping key is not a string".into() }), + } + } else { + let mut end = pos; + while end < chars.len() && !matches!(chars[end], ':' | ',' | '{' | '}' | '[' | ']' | '#') { + end += 1; + } + let key: String = chars[pos..end].iter().collect(); + let key = key.trim().to_string(); + if key.is_empty() { + return Err(Diag { rank: 4, line: no, msg: "flow mapping key is empty".into() }); + } + Ok((key, end)) + } +} + +fn parse_quoted(chars: &[char], pos: usize, no: u32) -> std::result::Result<(Value, usize), Diag> { + let mut end = pos + 1; + while end < chars.len() { + match chars[end] { + '\\' => end += 2, + '"' => { + end += 1; + let raw: String = chars[pos..end].iter().collect(); + let decoded: String = serde_json::from_str(&raw).map_err(|_| Diag { + rank: 4, + line: no, + msg: "string is not valid JSON with JSON escapes".into(), + })?; + return Ok((Value::String(decoded), end)); + } + _ => end += 1, + } + } + Err(Diag { rank: 4, line: no, msg: "unterminated string".into() }) +} + +fn parse_plain(chars: &[char], pos: usize, no: u32) -> std::result::Result<(Value, usize), Diag> { + let mut end = pos; + while end < chars.len() && !matches!(chars[end], ',' | '{' | '}' | '[' | ']' | ':' | '#') { + end += 1; + } + let token: String = chars[pos..end].iter().collect(); + let token = token.trim().to_string(); + if token.is_empty() { + return Err(Diag { rank: 4, line: no, msg: "empty scalar".into() }); + } + let value = classify_scalar(&token); + Ok((value, end)) +} + +fn classify_scalar(token: &str) -> Value { + match token { + "true" => Value::Bool(true), + "false" => Value::Bool(false), + "null" => Value::Null, + _ => match serde_json::from_str::(token) { + Ok(v) if v.is_number() => v, + _ => Value::String(token.to_string()), + }, + } +} + +/// One open fence on the parser's stack. +struct Frame { + name: String, + attrs: Value, + count: usize, + line: u32, +} + +/// Split a normalized prompt body into its semantic bytes and its docs payloads. +/// +/// Docs fences are display content, not contract: the semantic body is the body +/// with every top-level docs-family fence removed, and that is the byte string +/// inputKey, attribution prompt hashes, and every freshness derivation consume. A +/// prompt whose only change is inside a docs fence therefore produces the same +/// semantic body, the same key, and the same endpoints. +/// +/// Fence lines are unambiguous by shape: an opening line names its directive, a +/// closing line is bare colons. A closing line of N colons closes the innermost +/// open fence whose opening count is at most N, and fences opened above it close +/// with it. Code blocks suspend the stack, both fenced and indented, so a block +/// may show `:::` lines freely. Only top-level fences carry payloads; an inner +/// fence rides verbatim inside the outer body, unparsed. +/// +/// An unclosed fence is refused naming its opening line. Where that strictness +/// trips differs by fence kind: a heading inside an open example fence refuses on +/// sight, while inside an open prose fence, docs, note, or warning, it is prose +/// and the refusal waits for the end of the body, which is where a prose fence +/// provably cannot close. +pub fn split_docs(normalized_body: &str) -> Result { + split_body(normalized_body).map_err(|diag| compile_error(&diag)) +} + +fn split_body(body: &str) -> std::result::Result { + let lines: Vec<&str> = body.lines().collect(); + let mut removed: Vec = vec![false; lines.len()]; + let mut docs: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); + let mut code: Option<(char, usize)> = None; + let mut i = 0usize; + while i < lines.len() { + let raw = lines[i]; + let lineno = (i + 1) as u32; + let indent = raw.chars().take_while(|c| *c == ' ').count(); + let trimmed = &raw[indent.min(raw.len())..]; + if let Some((open_char, open_len)) = code { + if let Some((fence_char, fence_len, info)) = code_fence(trimmed) { + if fence_char == open_char && fence_len >= open_len && info.is_empty() { + code = None; + } + } + i += 1; + continue; + } + if indent >= 4 { + i += 1; + continue; + } + if let Some((fence_char, fence_len, _)) = code_fence(trimmed) { + code = Some((fence_char, fence_len)); + i += 1; + continue; + } + if let Some(frame) = stack.last() { + if section_heading(trimmed) && !holds_prose(&frame.name) { + return Err(Diag { + rank: 1, + line: frame.line, + msg: format!( + "the `{}` fence opened here is never closed before the section ends", + frame.name + ), + }); + } + } + let Some((count, rest)) = colon_run(trimmed) else { + i += 1; + continue; + }; + if rest.trim().is_empty() { + let Some(matched) = stack.iter().rposition(|frame| frame.count <= count) else { + return Err(Diag { + rank: 2, + line: lineno, + msg: "closer `:::` has no matching directive opener".into(), + }); + }; + let closing: Vec = stack.split_off(matched); + let frame = closing + .into_iter() + .next() + .expect("the split always yields the matched frame"); + if stack.is_empty() && is_docs_directive(&frame.name) { + for line in removed.iter_mut().take(i + 1).skip(frame.line as usize - 1) { + *line = true; + } + let opener = frame.line as usize - 1; + let standalone = opener == 0 + || removed[opener - 1] + || lines[opener - 1].trim().is_empty(); + if standalone { + let mut after = i + 1; + while after < lines.len() && lines[after].trim().is_empty() { + removed[after] = true; + after += 1; + } + } + docs.push(DocsFence { + directive: frame.name, + attrs: frame.attrs, + line_start: frame.line, + line_end: lineno, + body: fence_body(&lines[frame.line as usize..i]), + anchor: 0, + }); + } + i += 1; + continue; + } + let top = stack.is_empty(); + if top && raw == "::: test" { + let mut j = i + 1; + while j < lines.len() && lines[j] != ":::" { + j += 1; + } + i = if j < lines.len() { j + 1 } else { lines.len() }; + continue; + } + let parsed = parse_opener(rest, lineno); + if !top { + let Ok((name, _)) = parsed else { + i += 1; + continue; + }; + let open = stack.last().expect("a line below the top always has an open frame"); + return Err(Diag { + rank: 3, + line: lineno, + msg: format!( + "the `{name}` fence opened here nests inside the `{}` fence opened at body line {}, and directive fences do not nest", + open.name, open.line + ), + }); + } + let (name, attrs) = parsed?; + if !is_docs_directive(&name) { + return Err(Diag { + rank: 2, + line: lineno, + msg: format!( + "unknown directive opener {raw:?}; only `::: test` and the docs family are defined" + ), + }); + } + stack.push(Frame { name, attrs, count, line: lineno }); + i += 1; + } + if let Some(frame) = stack.last() { + return Err(Diag { + rank: 1, + line: frame.line, + msg: format!( + "the `{}` fence opened here is never closed before the section ends", + frame.name + ), + }); + } + let kept: Vec<&str> = lines + .iter() + .enumerate() + .filter(|(index, _)| !removed[*index]) + .map(|(_, line)| *line) + .collect(); + let semantic = crate::source::normalize_body(&kept.join("\n")); + let mut seen = 0u32; + let mut anchors: Vec = Vec::with_capacity(lines.len() + 1); + anchors.push(0); + for (index, _) in lines.iter().enumerate() { + if !removed[index] { + seen += 1; + } + anchors.push(seen); + } + let semantic_lines = semantic.lines().count() as u32; + for fence in &mut docs { + fence.anchor = anchors[fence.line_start as usize - 1].min(semantic_lines); + } + Ok(BodySplit { semantic, docs }) +} + +/// The verbatim body of one fence: every line between the opener and the closer, +/// inner fences included exactly as written. +fn fence_body(lines: &[&str]) -> String { + if lines.is_empty() { + return String::new(); + } + let mut out = lines.join("\n"); + out.push('\n'); + out +} + +/// A markdown code fence: its character, its length, and its info string. +fn code_fence(text: &str) -> Option<(char, usize, &str)> { + let fence_char = text.chars().next().filter(|c| *c == '`' || *c == '~')?; + let len = text.chars().take_while(|c| *c == fence_char).count(); + if len < 3 { + return None; + } + let info = text[len..].trim(); + if fence_char == '`' && info.contains('`') { + return None; + } + Some((fence_char, len, info)) +} + +/// True for the heading that ends a section, which is where an open fence that +/// holds no prose has run out of room. +fn section_heading(text: &str) -> bool { + text.starts_with("# ") || text.starts_with("## ") +} + +/// True for the prose fences, docs, note, and warning, the family whose whole +/// purpose is prose, and where a heading is therefore a heading rather than the +/// proof that a fence was left open. +/// +/// Where strictness trips differs by fence kind. A `# ` or `## ` line inside an +/// open example or test fence still refuses immediately, because nothing but +/// directive content belongs there. Inside an open prose fence the same line is +/// markdown the document renders, so the unclosed refusal fires where the fence +/// provably cannot close: at the end of the body, naming the opening line. +fn holds_prose(name: &str) -> bool { + matches!(name, "docs" | "note" | "warning") +} + +/// The leading colon run of a fence line and the text after it. +fn colon_run(text: &str) -> Option<(usize, &str)> { + let count = text.chars().take_while(|c| *c == ':').count(); + if count < 3 { + return None; + } + Some((count, &text[count..])) +} + +/// Parse an opening fence line's directive name and attributes. +fn parse_opener(rest: &str, line: u32) -> std::result::Result<(String, Value), Diag> { + let text = rest.trim_start_matches(' '); + let name_len = text + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .count(); + let name = &text[..name_len]; + let invalid = |msg: String| Diag { rank: 2, line, msg }; + if name.is_empty() + || !name.starts_with(|c: char| c.is_ascii_alphanumeric()) + || name.ends_with('-') + || name.ends_with('_') + { + return Err(invalid(format!( + "directive name {name:?} is not a name: alphanumeric plus `-` and `_`, never ending in `-` or `_`" + ))); + } + let after = text[name_len..].trim(); + let attrs = if after.is_empty() { + Value::Object(Map::new()) + } else if after.starts_with('{') && after.ends_with('}') { + parse_attributes(&after[1..after.len() - 1], line)? + } else { + return Err(invalid(format!( + "directive opener carries {after:?} where only an HTML-like `{{...}}` attribute block is allowed" + ))); + }; + Ok((name.to_string(), attrs)) +} + +/// Parse an HTML-like attribute block: `{a}`, `{a=b}`, `{a="b"}`, and `{a='b'}` +/// are equivalent, `.class` shorthands accumulate, and a later `#id` overrides an +/// earlier one. +fn parse_attributes(text: &str, line: u32) -> std::result::Result { + let chars: Vec = text.chars().collect(); + let mut map = Map::new(); + let mut classes: Vec = Vec::new(); + let mut pos = 0usize; + let malformed = |msg: &str| Diag { rank: 2, line, msg: msg.to_string() }; + while pos < chars.len() { + if chars[pos].is_whitespace() { + pos += 1; + continue; + } + if chars[pos] == '.' || chars[pos] == '#' { + let kind = chars[pos]; + pos += 1; + let start = pos; + while pos < chars.len() && !chars[pos].is_whitespace() { + pos += 1; + } + let value: String = chars[start..pos].iter().collect(); + if value.is_empty() { + return Err(malformed("attribute shorthand carries no value")); + } + if kind == '.' { + classes.push(value); + } else { + map.insert("id".to_string(), Value::String(value)); + } + continue; + } + let start = pos; + while pos < chars.len() && !chars[pos].is_whitespace() && chars[pos] != '=' { + pos += 1; + } + let key: String = chars[start..pos].iter().collect(); + if key.is_empty() { + return Err(malformed("attribute name is empty")); + } + if pos >= chars.len() || chars[pos] != '=' { + map.insert(key, Value::String(String::new())); + continue; + } + pos += 1; + let value = if pos < chars.len() && (chars[pos] == '"' || chars[pos] == '\'') { + let quote = chars[pos]; + pos += 1; + let start = pos; + while pos < chars.len() && chars[pos] != quote { + pos += 1; + } + if pos >= chars.len() { + return Err(malformed("attribute value is not closed by its quote")); + } + let value: String = chars[start..pos].iter().collect(); + pos += 1; + value + } else { + let start = pos; + while pos < chars.len() && !chars[pos].is_whitespace() { + pos += 1; + } + chars[start..pos].iter().collect() + }; + map.insert(key, Value::String(value)); + } + if !classes.is_empty() { + map.insert("class".to_string(), Value::String(classes.join(" "))); + } + Ok(Value::Object(map)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_plain_scalar_preserves_an_embedded_double_quote() { + let body = "::: test\nname: quote case\nexpect: { note: ab\"cd }\n:::"; + let cases = extract_cases(body).expect("the block parses"); + assert_eq!(cases.len(), 1); + assert_eq!(cases[0].expect["note"], serde_json::json!("ab\"cd")); + } + + #[test] + fn a_block_reports_the_rank_four_grammar_error_over_a_missing_expect() { + let body = "::: test\nname: both wrong\ngiven: 5\n:::"; + let err = extract_cases(body).expect_err("the block is invalid"); + assert!( + err.detail.contains("given"), + "reports the rank-4 given grammar error: {}", + err.detail + ); + assert!( + !err.detail.contains("expect"), + "not the rank-6 missing-expect error: {}", + err.detail + ); + } +} +napl-store transition v1 875 +{"baseStateKey":"9b23b62a07e1249fc078c3851c727ecf191bfe6c99ff2f5644f5eedbd2ff94e2","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"ec475967a95606aace0108806431c47b4ab59d5215771372147250b520ea35ac","installPaths":["crates/napl/src/init.rs","crates/napl/src/watch.rs"],"module":"commands","newState":"cca37ce0ab52aabdb236250fe7211d03ae415524090fe1ea8d26ed32f63a0714","readSet":["base-state:9b23b62a07e1249fc078c3851c727ecf191bfe6c99ff2f5644f5eedbd2ff94e2","input-key:ec475967a95606aace0108806431c47b4ab59d5215771372147250b520ea35ac","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"6082f8faf8471d056527c0b18f3396e9662b83a27b926af904f846768ceaf845","surface":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust","writeSet":["owned:crates/napl","module-state:rust/commands"]} +napl-store snapshot v1 671 +{"contractVersion":4,"event":"155528aea66782fbd67555be27e392c8cee0d0ce7022ec10eecb2f39cb50ecf4","states":[{"module":"commands","state":"cb82ac8a35d902954b841fa6b0c14cd03d99e15d69b8005c3986d5ad66e22b29","target":"rust"},{"module":"napl","state":"520ca02fb2a0fb05deb02a3386c9aa14433e7218e6e6abf11fbaa13c273bee21","target":"rust"},{"module":"napl_store","state":"7ad4dba399677a4a801444f26c938bc4594731235bcccd9a979e1728b21f1822","target":"rust"},{"module":"query","state":"bc9f18a34748d245185298746e0db6fcac33afe57ddff8836927bb668f4172b6","target":"rust"},{"module":"usage_proxy","state":"83e7eb0b234bbffd0573d91bcf92e594e91a0a15b536e6d5c915a965fe0d4309","target":"rust"}]} +napl-store module-state v1 635 +{"attribution":{"promptHash":"d3b4f7308b35a3105c66bbbbec005cb91288ecd8476e45e4e46019e1dda2d3f6","record":"edd2ba44a2a0c8e6b1f6a328a617821ef6949cf4455658fe9c7b9f80935a0b97","stale":false},"contractVersion":4,"inputKey":"4d1fb17243be4b78e491396c6d7f09490975c5851b5c2f191f4e10798848ccc4","module":"serve","outputTree":"505d5f544144525d73def85bf0b3b9e16230ea634599f4d343080af78d359024","prompt":"552d4c4d5476b26a91d581f0c430dfaf8b2d1f3d7eed2bc6c808e4148a666cc7","sliceHash":"a798c8c609ac68ef2ad8eaa774d5dae53efe4a6ab0a13582db815e77e1cf6d2c","surfaceHash":"0a742d0232e4b7000bd79ff29b39af501675254174d2c98163f8b7337cf7f0b7","target":"rust"} +napl-store snapshot v1 891 +{"contractVersion":4,"event":"f64e1f03a1390adb272fdc6562a83d949641740535efa1281cdbddd85f3506e4","states":[{"module":"agent","state":"d0d406dd4f30ea6840a808bcbc3ddeea8ec8cc10c84de12ab5d0cfeef7bb6696","target":"rust"},{"module":"commands","state":"ab9aba5c093920082ceb592429a727d642cf559ac435b93db5e525ee4ba1b2a4","target":"rust"},{"module":"napl","state":"a2c25e6d7aa8dab2c129f662991afa8d79ff647831687a5759a8ddae398d7041","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"c47a40c3211c7c349ffae5b69c3698f9a7818190e661d9391194acc19568d38f","target":"rust"},{"module":"serve","state":"70c656393697d4192e4f4cadf2217370c3556a0ec85655605fb63deb7a400973","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} +napl-store blob v1 62461 +//! The read commands and the maintenance commands. +//! +//! Every read resolves HEAD exactly once and then answers entirely from objects +//! reachable from that snapshot. No read creates a directory, takes a lock, or +//! writes a byte, so the whole read surface works on a read-only filesystem. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use crate::blame::{blame, blame_json, untracked_json, Revision}; +use crate::compile::{ + compile, graph_for, install_index, read_surface_items, state_of, verdict as pending_verdict, +}; +use crate::cost::{cost_report, line_from_reported, CostLine}; +use crate::error::{error, OutcomeCode, Result}; +use crate::graph::{topological_levels, Graph}; +use crate::slice::receipt_requirements; +use crate::lint::{lint, lint_json, LintInput, LintReport}; +use crate::maintenance::{ + fsck, fsck_json, gc_json, plan_restore, plan_sweep, reachable_from_head, restore_json, + FileFact, RestorePlan, +}; +use crate::objects::{ + event_transition, read_module_state, snapshot_event, snapshot_states, walk_events, ModuleState, +}; +use crate::receipt::normalize_reported; +use crate::store_view::{ + flatten_tree, get_blob, get_json, get_object, has_object, ObjectSource, +}; +use crate::surface::surface_json; +use crate::target::{ + builtin_target, is_manifest_path, owned_globs, placement_root, projection_owned_paths, TargetDef, +}; +use crate::textdiff::render_drift; +use napl_store::store_hash::ObjectKind; +use serde_json::{json, Value}; + +use crate::disk::{self, Store}; +use crate::workspace::{discover_sources, read_working_text}; + +/// A snapshot-consistent status report for one target: observation, zero +/// mutation. +pub fn status(workspace: &Path, target: &str) -> Result { + let store = disk::open(workspace); + let head = store.head()?; + let states = match &head { + Some(snapshot) => snapshot_states(&store, snapshot)?, + None => BTreeMap::new(), + }; + let sources = discover_sources(workspace)?; + let artifact = compile(&store, &sources, target, None)?; + let pending: Vec = artifact + .slices + .iter() + .map(|slice| slice.module.clone()) + .collect(); + let frontier: BTreeSet = pending.iter().cloned().collect(); + let target_def = builtin_target(target)?; + let installs = install_index(&store, head.as_deref())?; + + let mut modules: Vec = Vec::new(); + for ((state_target, module), state_hash) in &states { + if state_target != target { + continue; + } + let state = read_module_state(&store, state_hash)?; + let freshness = attribution_freshness(Some(&state), module, target, &sources); + let prompt_stale = freshness["status"].as_str() != Some("fresh"); + let ends = both_ends( + workspace, + &store, + &state, + prompt_stale, + &pending_body(&sources, module), + )?; + let is_pending = pending.contains(module); + let verdict = pending_verdict( + &store, + &sources, + &target_def, + module, + &states, + &installs, + &frontier, + ends.drift, + )?; + let (reason, reasons, reland) = verdict.json_fields(is_pending); + modules.push(json!({ + "attribution": freshness, + "baseStateKey": state_hash, + "drift": ends.drift, + "inputKey": state.input_key, + "module": module, + "outputTree": state.output_tree, + "pending": is_pending, + "pendingReason": reason, + "pendingReasons": reasons, + "relandIdenticalBytes": reland, + "sliceHash": state.slice_hash, + "staleReferences": ends.stale_references as u64, + "surfaceHash": state.surface_hash, + })); + } + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "head": head, + "manifestDiagnostics": manifest_diagnostics(workspace, target, &sources), + "modules": modules, + "pending": pending, + "target": target, + })) +} + +/// What status observes about a module's code side, so status judges both ends. +/// +/// `drift` is true when the committed output differs from the working tree, and +/// `stale_references` counts the module's references reading stale right now. Both +/// come from the derivations `diff` and the language server already serve, which +/// is the whole point: status can never read clean while the query surface reads +/// stale, because there is one derivation and not two. +struct BothEnds { + drift: bool, + stale_references: usize, +} + +fn both_ends( + workspace: &Path, + store: &Store, + state: &ModuleState, + prompt_stale: bool, + pending_body: &str, +) -> Result { + let mut drift = false; + let mut drifted_paths: BTreeSet = BTreeSet::new(); + if has_object(store, &state.output_tree) { + for (path, (blob, _mode)) in flatten_tree(store, &state.output_tree)? { + let bytes = std::fs::read(workspace.join(&path)).ok(); + // An unreadable working file is not drift: the reader knows nothing + // about it, so it claims nothing, exactly as the query surface does. + if crate::attribution::code_drifted(Some(blob.as_str()), bytes.as_deref()) { + drift = true; + drifted_paths.insert(path); + } + } + } + let entries = match &state.attribution { + Some(recorded) if has_object(store, &recorded.record) => { + let bytes = get_blob(store, &recorded.record)?; + crate::attribution::parse(&String::from_utf8_lossy(&bytes)) + .map(|record| record.entries) + .unwrap_or_default() + } + _ => Vec::new(), + }; + let mut stale_references = 0usize; + for entry in &entries { + // A reference a capture verb recorded carries the content hash of each + // endpoint's post-edit bytes, and freshness reads those first. While the + // working tree still holds exactly those bytes the reference is fresh, + // whatever the committed baseline says: recording a reference and + // advancing the baseline are different acts, and the verb's own edit must + // never be counted as debt the verb created. + let working = entry + .file + .as_ref() + .and_then(|file| std::fs::read(workspace.join(file)).ok()); + let code_drift = entry + .file + .as_ref() + .map(|file| drifted_paths.contains(file)) + .unwrap_or(false); + if crate::attribution::entry_reference_status( + entry, + prompt_stale, + false, + code_drift, + working.as_deref(), + pending_body, + ) + .stale + { + stale_references += 1; + } + } + Ok(BothEnds { + drift, + stale_references, + }) +} + +/// The module's pending normalized prompt body, or the empty body when the +/// workspace no longer has a prompt for it. +fn pending_body(sources: &[crate::source::SourceModule], module: &str) -> String { + sources + .iter() + .find(|source| source.module == module) + .map(|source| source.normalized_body.clone()) + .unwrap_or_default() +} + +/// The module PLAN beside the module's live STATE. +/// +/// The plan is the invariant requirements a conforming implementation derives +/// from the working tree's prompts alone: owned globs, placement root, projection +/// ownership, receipt requirements, wave position, never a candidate-dependent +/// fact, which is `add`'s job. The state is what is true of the module right now: +/// tracked or pending, drift, stale reference count, attribution status. The +/// command named status never answers with a plan alone. +/// +/// The payload also carries the derived entry-file projection, path and exact +/// content, computed the way commit computes it but over the working tree, so the +/// line a new file needs in a shared entry file is read from the tool rather than +/// composed by hand. +pub fn module_plan(workspace: &Path, target_name: &str, module: &str) -> Result { + let sources = discover_sources(workspace)?; + let target = builtin_target(target_name)?; + let source = sources.iter().find(|s| s.module == module).ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} has no prompt file in the workspace"), + ) + })?; + let graph = graph_for(&sources, target_name)?; + let levels = topological_levels(&graph)?; + let wave = *levels.get(module).ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} is not in the graph for target {target_name:?}"), + ) + })?; + let root = placement_root(source); + + let store = disk::open(workspace); + let artifact = compile(&store, &sources, target_name, None)?; + let pending = artifact.slices.iter().any(|slice| slice.module == module); + let frontier: BTreeSet = artifact + .slices + .iter() + .map(|slice| slice.module.clone()) + .collect(); + let head = store.head()?; + let head_states = crate::compile::head_states(&store, head.as_deref())?; + let installs = install_index(&store, head.as_deref())?; + let committed = committed_state(&store, target_name, module)?; + let state = match &committed { + Some(state) => { + let freshness = attribution_freshness(Some(state), module, target_name, &sources); + let prompt_stale = freshness["status"].as_str() != Some("fresh"); + let ends = both_ends( + workspace, + &store, + state, + prompt_stale, + &pending_body(&sources, module), + )?; + let verdict = pending_verdict( + &store, + &sources, + &target, + module, + &head_states, + &installs, + &frontier, + ends.drift, + )?; + let (reason, reasons, reland) = verdict.json_fields(pending); + json!({ + "attribution": freshness, + "drift": ends.drift, + "pending": pending, + "pendingReason": reason, + "pendingReasons": reasons, + "relandIdenticalBytes": reland, + "staleReferences": ends.stale_references as u64, + "tracked": true, + }) + } + // A module with no committed state is not tracked, and saying so is the + // honest answer: it has no baseline to drift from and no references to + // stale, so those read false and zero because they are, not because + // nothing was looked at. + None => { + let verdict = pending_verdict( + &store, + &sources, + &target, + module, + &head_states, + &installs, + &frontier, + false, + )?; + let (reason, reasons, reland) = verdict.json_fields(pending); + json!({ + "attribution": attribution_freshness(None, module, target_name, &sources), + "drift": false, + "pending": pending, + "pendingReason": reason, + "pendingReasons": reasons, + "relandIdenticalBytes": reland, + "staleReferences": 0, + "tracked": false, + }) + } + }; + + let (projection_path, projection_bytes) = + working_tree_projection(workspace, &store, &target, &sources, module, &root)?; + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "module": module, + "plan": { + "firstOutput": first_output(&target, &root, module), + "ownedGlobs": owned_globs(&target, &root), + "placementRoot": root, + "projection": { + "content": String::from_utf8_lossy(&projection_bytes), + "ownership": PROJECTION_OWNERSHIP, + "owner": PROJECTION_OWNER, + "path": projection_path, + "userSupplied": false, + }, + "projectionOwned": projection_owned_paths(&target, &root), + "projections": projection_labels(&target, &root), + "receiptRequirements": receipt_requirements(), + "wave": wave as u64, + }, + "state": state, + "target": target_name, + })) +} + +/// What a projection path is, said in the plan every time one is mentioned. +/// +/// The obvious wrong first move in a cold module is to hand-write the crate's +/// entry file, because it is the file that visibly has to change for a new +/// source file to be reachable. It is derived, it is owned by no module, and a +/// candidate carrying it is rejected. Saying so beside the path is cheaper than +/// the rejection that would otherwise teach it. +pub const PROJECTION_OWNERSHIP: &str = "tool-derived"; +pub const PROJECTION_OWNER: &str = + "no module: this path is recomputed by commit from the crate's committed module file sets and is never user-supplied; a candidate that carries it is rejected as invalid-candidate"; + +/// Every projection path this module's crate has, each labeled for what it is. +pub fn projection_labels(target: &TargetDef, root: &str) -> Vec { + projection_owned_paths(target, root) + .into_iter() + .map(|path| { + json!({ + "ownership": PROJECTION_OWNERSHIP, + "owner": PROJECTION_OWNER, + "path": path, + "userSupplied": false, + }) + }) + .collect() +} + +/// One concrete legal first output path for a module: the module's own name +/// under the target's source directory, inside the placement root. +/// +/// A plan that lists globs and a projection but no example leaves the author to +/// pick a path and find out afterwards whether it was ownable. Naming one that +/// certainly is turns the first move into a copy. +pub fn first_output(target: &TargetDef, root: &str, module: &str) -> Value { + let path = crate::paths::join_root( + root, + &format!( + "{}/{}.{}", + target.source_dir, + crate::paths::encode_name(module), + target.source_extension + ), + ); + json!({ + "detail": "one concrete path this module may own: its own name under the target's source directory, inside the placement root; any path matching the owned globs that is not a projection or a manifest is equally legal", + "path": path, + }) +} + +/// The crate's entry-file projection as commit would derive it if this module +/// were committed from the working tree right now. +/// +/// The inputs are the other modules' committed file sets plus this module's +/// working-tree files, and the command providers are read the same way: from the +/// other modules' recorded surfaces plus this module's working-tree surface. That +/// is what makes the answer useful for a file that is new: a projection derived +/// from committed state alone could never name it, and naming it is the whole +/// question a reader asks here. +fn working_tree_projection( + workspace: &Path, + store: &Store, + target: &TargetDef, + sources: &[crate::source::SourceModule], + module: &str, + root: &str, +) -> Result<(String, Vec)> { + let src_root = crate::paths::join_root(root, &target.source_dir); + let mut files: Vec = Vec::new(); + let mut providers: Vec = Vec::new(); + + let states = match store.head()? { + Some(head) => snapshot_states(store, &head)?, + None => BTreeMap::new(), + }; + for ((state_target, other), hash) in &states { + if state_target != &target.name || other == module { + continue; + } + let state = read_module_state(store, hash)?; + if !has_object(store, &state.output_tree) { + continue; + } + let owned: Vec = flatten_tree(store, &state.output_tree)?.into_keys().collect(); + if !owned + .iter() + .any(|path| crate::paths::is_under(path, &src_root)) + { + continue; + } + files.extend(owned); + for item in read_surface_items(store, &state.surface_hash)? { + if let Some(provider) = command_provider(&item.kind, &item.path) { + providers.push(provider); + } + } + } + + let working = working_owned_files(workspace, store, target, sources, module)?; + for item in crate::surface::extract_surface(target, root, &working)? { + if let Some(provider) = command_provider(&item.kind, &item.path) { + providers.push(provider); + } + } + files.extend(working.into_keys()); + Ok(crate::target::derive_lib_projection( + target, root, &files, &providers, + )) +} + +/// The module stem a surface item declares a command table for, when it declares +/// one: the convention is one `commands` function per module file. +fn command_provider(kind: &str, path: &str) -> Option { + if kind != "fn" { + return None; + } + let prefix = path.strip_suffix("::commands")?; + (!prefix.is_empty() && !prefix.contains(' ')).then(|| prefix.to_string()) +} + +/// A module's owned files as they sit in the working tree: the committed paths +/// read from disk, plus the paths under its globs no module has committed. +fn working_owned_files( + workspace: &Path, + store: &Store, + target: &TargetDef, + sources: &[crate::source::SourceModule], + module: &str, +) -> Result>> { + let mut out: BTreeMap> = BTreeMap::new(); + if let Some(state) = committed_state(store, &target.name, module)? { + if has_object(store, &state.output_tree) { + for path in flatten_tree(store, &state.output_tree)?.into_keys() { + if let Ok(bytes) = std::fs::read(workspace.join(&path)) { + out.insert(path, bytes); + } + } + } + } + for path in crate::staging::unclaimed_working_paths(workspace, store, target, module, sources)? { + if let Ok(bytes) = std::fs::read(workspace.join(&path)) { + out.insert(path, bytes); + } + } + Ok(out) +} + +/// Observe, never mutate, the manifest/placement mismatches the toolchain now +/// only diagnoses: a module placed in a crate the workspace manifest does not +/// list, or a crate directory missing a manifest. Manifests are user-owned +/// seams, so these are reports, not writes. +fn manifest_diagnostics( + workspace: &Path, + target_name: &str, + sources: &[crate::source::SourceModule], +) -> Vec { + let Ok(target) = builtin_target(target_name) else { + return Vec::new(); + }; + let members = workspace_members(workspace, &target); + let mut roots: Vec = sources.iter().map(placement_root).collect(); + roots.sort(); + roots.dedup(); + let mut out: Vec = Vec::new(); + for root in roots { + if root.is_empty() { + continue; + } + if let Some(members) = &members { + if !members.contains(&root) { + out.push(manifest_diagnostic( + &root, + // The manifest this observation means is the workspace-root + // one, which holds the member list, not the crate's own. + &target.manifest_file, + "crate-not-listed-in-workspace-manifest", + &format!("the crate {root:?} holds a placed module but the workspace manifest does not list it as a member"), + )); + } + } + let manifest = workspace.join(&root).join(&target.manifest_file); + if !manifest.is_file() { + out.push(manifest_diagnostic( + &root, + &crate::paths::join_root(&root, &target.manifest_file), + "crate-directory-missing-manifest", + &format!("the crate directory {root:?} receives placed modules but has no manifest"), + )); + } + } + out +} + +/// One manifest observation, which always locates the file it means. +/// +/// A manifest is a user-owned seam: the toolchain never writes one, so a +/// diagnostic that only alluded to "the manifest" would leave the reader hunting +/// for the file they are the only one allowed to edit. Every diagnostic therefore +/// carries the exact workspace-relative path and says plainly whose file it is. +fn manifest_diagnostic( + crate_root: &str, + manifest: &str, + observation: &str, + detail: &str, +) -> Value { + json!({ + "crate": crate_root, + "detail": detail, + "manifest": manifest, + "observation": observation, + "owner": "user", + "remedy": format!("{manifest} is yours to edit; the toolchain diagnoses manifests and never writes them"), + }) +} + +/// The crate directories the workspace-root manifest lists as members, or +/// `None` when there is no readable workspace manifest to compare against. +fn workspace_members(workspace: &Path, target: &TargetDef) -> Option> { + let text = std::fs::read_to_string(workspace.join(&target.manifest_file)).ok()?; + let mut members: Vec = Vec::new(); + for line in text.lines() { + let trimmed = line.trim().trim_end_matches(','); + if let Some(inner) = trimmed + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + { + if !is_manifest_path(target, inner) && inner.contains('/') { + members.push(inner.to_string()); + } + } + } + Some(members) +} + +/// Classify a module's attribution freshness and, when it is not fresh, name the +/// exact fix. +/// +/// It is a pure observation over content already resolved: an absent record, a +/// record installed unproven, or a record whose recorded prompt hash no longer +/// matches the current normalized body all read as needing a fresh commit. A +/// record proven against the body still on disk is fresh. +fn attribution_freshness( + state: Option<&ModuleState>, + module: &str, + target: &str, + sources: &[crate::source::SourceModule], +) -> Value { + let directive = json!({ + "command": "commit", + "directive": format!( + "commit module {module:?} for target {target:?} with a fresh attribution update" + ), + "module": module, + "target": target, + }); + match state.and_then(|state| state.attribution.as_ref()) { + None => json!({ "fix": directive, "status": "absent" }), + Some(attribution) => { + let current = sources + .iter() + .find(|source| source.module == module) + .map(|source| source.normalized_source_hash.as_str()); + if attribution.stale { + json!({ + "fix": directive, + "reason": "installed with --no-verify", + "status": "stale", + }) + } else if current != Some(attribution.prompt_hash.as_str()) { + json!({ + "fix": directive, + "reason": "the prompt changed since the attribution was recorded", + "status": "stale", + }) + } else { + json!({ "status": "fresh" }) + } + } + } +} + +/// The causal history reachable from HEAD, optionally narrowed to one module. +/// +/// The `ordinal` field is a presentation device computed from the causal graph +/// at read time. It is never stored and never enters an identity. +pub fn log(workspace: &Path, module: Option<&str>) -> Result { + let store = disk::open(workspace); + let Some(head) = store.head()? else { + return Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "entries": [], + "head": Value::Null, + })); + }; + let mut entries: Vec = Vec::new(); + for (ordinal, event) in causal_order(&store, &head)?.iter().enumerate() { + let transition_id = event_transition(&store, event)?; + let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; + if let Some(wanted) = module { + if transition["module"].as_str() != Some(wanted) { + continue; + } + } + entries.push(json!({ + "event": event, + "module": transition["module"], + "newState": transition["newState"], + "noteCount": attached_notes(&store, &transition_id)?.len() as u64, + "ordinal": ordinal as u64, + // A reference riding a transition is provenance exactly as a note is, + // so the log counts both and names the one hop that serves their full + // text. Provenance a reader cannot find is provenance that may as well + // not be recorded. + "referenceCount": transition_references(&store, &transition)?.len() as u64, + "target": transition["target"], + "transition": transition_id, + "writeSet": transition["writeSet"], + })); + } + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "entries": entries, + "head": head, + "inspect": { + "command": "napl show ", + "detail": "the full text of a transition's notes and reference entries is served by `napl show `, one named hop from any entry's transition hash", + }, + })) +} + +/// Describe any stored object: its kind and, for JSON kinds, its payload. +/// +/// A transition also lists the notes attached to it inline, hash and text, so a +/// note attached is a note findable: nobody should have to know that notes live +/// behind an index and are stored as blobs in order to read one back. +pub fn show(workspace: &Path, hash: &str) -> Result { + let store = disk::open(workspace); + let (kind, payload) = get_object(&store, hash)?; + let body = if kind == ObjectKind::Blob { + json!({ "bytes": payload.len() as u64 }) + } else { + serde_json::from_slice(&payload).map_err(|e| { + error( + OutcomeCode::StoreIntegrity, + format!("object {hash} is not JSON: {e}"), + ) + })? + }; + let mut out = json!({ + "body": body, + "contractVersion": crate::version::CONTRACT_VERSION, + "hash": hash, + "kind": kind.as_wire(), + }); + if kind == ObjectKind::Transition { + out["notes"] = Value::Array(attached_notes(&store, hash)?); + out["references"] = Value::Array(transition_references(&store, &out["body"])?); + } + Ok(out) +} + +/// The reference entries one transition introduced, served inline. +/// +/// A reference attached to a transition is exactly as findable as a note +/// attached to one: both are provenance riding a transition, and both are read +/// here rather than through plumbing. What this transition introduced is the +/// difference between its new state's attribution record and the record its base +/// state carried, so a commit that merely carried the module's existing +/// references forward lists none and a capture verb's transition lists the one it +/// recorded. A base that cannot be resolved degrades to the whole new record +/// rather than erroring, the way every other read here degrades. +fn transition_references(store: &Store, body: &Value) -> Result> { + let Some(new_state) = body["newState"].as_str() else { + return Ok(Vec::new()); + }; + let Some(record) = state_attribution_entries(store, new_state)? else { + return Ok(Vec::new()); + }; + let base = match body["baseStateKey"].as_str() { + Some(base) => state_attribution_entries(store, base)?.unwrap_or_default(), + None => Vec::new(), + }; + Ok(record + .into_iter() + .filter(|entry| !base.contains(entry)) + .map(|entry| entry.as_json()) + .collect()) +} + +/// The attribution entries a module state's record carries, or `None` when the +/// state names no record or the record is not readable. +fn state_attribution_entries( + store: &Store, + state_hash: &str, +) -> Result>> { + if !has_object(store, state_hash) { + return Ok(None); + } + let state = read_module_state(store, state_hash)?; + let Some(attribution) = state.attribution else { + return Ok(None); + }; + if !has_object(store, &attribution.record) { + return Ok(None); + } + let bytes = get_blob(store, &attribution.record)?; + Ok(crate::attribution::parse(&String::from_utf8_lossy(&bytes)).map(|record| record.entries)) +} + +/// The notes attached to one transition, each as its blob name and its text. +/// +/// The index is a plain list of blob names beside the store, so a missing or +/// unreadable index is simply no notes: a reader degrades to empty rather than +/// erroring, exactly as every other read here does. +fn attached_notes(store: &Store, transition: &str) -> Result> { + let index = store.root.join("notes").join(format!("{transition}.json")); + let bytes = match std::fs::read(&index) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(disk::io(&index, &e)), + }; + let names: Vec = serde_json::from_slice(&bytes).unwrap_or_default(); + let mut out: Vec = Vec::new(); + for name in names { + if !has_object(store, &name) { + continue; + } + let body = get_blob(store, &name)?; + let parsed: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); + out.push(json!({ + "hash": name, + "note": parsed["note"].as_str().unwrap_or_default(), + })); + } + Ok(out) +} + +/// The raw payload bytes of an object, with the envelope stripped. +pub fn cat_file(workspace: &Path, hash: &str) -> Result> { + Ok(get_object(&disk::open(workspace), hash)?.1) +} + +/// The recorded surface of a module as of HEAD. +/// +/// A module with no committed state under this target has no recorded surface at +/// all, and an empty item list would read as a real surface with nothing public. +/// The honest answer names the absent baseline and carries no items, exactly as +/// `diff` and `blame` answer for a missing baseline. +pub fn surface_of(workspace: &Path, target: &str, module: &str) -> Result { + let store = disk::open(workspace); + let Some(state) = committed_state(&store, target, module)? else { + return Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "module": module, + "reason": format!( + "module {module:?} has no committed state under target {target:?}, so there is no recorded surface to serve and no baseline to read one from" + ), + "target": target, + "tracked": false, + })); + }; + let items = read_surface_items(&store, &state.surface_hash)?; + let mut out = surface_json(module, target, &items); + if let Some(object) = out.as_object_mut() { + object.insert("tracked".to_string(), Value::Bool(true)); + } + Ok(out) +} + +/// Whether this workspace has a committed head at all. +/// +/// The one observation a command whose meaning requires a baseline makes before +/// it answers, so a headless workspace gets the prerequisite sequence instead of +/// an empty shape that would read as success. +pub fn has_committed_head(workspace: &Path) -> Result { + Ok(disk::open(workspace).head()?.is_some()) +} + +/// Drift between committed outputs and the working tree, as a canonical patch. +/// +/// A target no committed module owns anything under has no baseline, and the +/// honest answer is `tracked: false` with the reason: an empty patch would say +/// "nothing drifted" when the truth is "nothing was compared". +pub fn diff(workspace: &Path, target: &str) -> Result { + let store = disk::open(workspace); + let committed = committed_text(&store, target)?; + if committed.is_empty() { + return Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "reason": format!( + "no committed module owns any output under target {target:?}, so there is no baseline to diff against" + ), + "target": target, + "tracked": false, + })); + } + let paths: Vec = committed.keys().cloned().collect(); + let working = read_working_text(workspace, &paths); + let patch = render_drift(&committed, &working); + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "patch": patch, + "target": target, + "tracked": true, + })) +} + +/// Derived line ancestry for one committed path. +/// +/// A path no committed module owns has no ancestry to replay, and empty ancestry +/// would read as a file that exists and was never written. It answers +/// `tracked: false` naming the absent baseline instead, an honest success shape. +/// +/// Blame reads committed bytes only. When the working tree has moved away from +/// them the report carries a drift notice naming `napl diff` as where the +/// uncommitted change lives, so ancestry is never mistaken for the present file. +pub fn blame_path(workspace: &Path, path: &str) -> Result { + let store = disk::open(workspace); + let Some(head) = store.head()? else { + return Ok(untracked_json( + path, + "this workspace has no committed head, so no module owns this path yet", + )); + }; + if !committed_manifest_all_targets(&store)?.contains(path) { + return Ok(untracked_json( + path, + "no committed module owns this path, so there is no committed baseline to trace it through", + )); + } + let mut revisions: Vec = Vec::new(); + for event in causal_order(&store, &head)? { + let transition_id = event_transition(&store, &event)?; + let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; + let touched = transition["installPaths"] + .as_array() + .map(|paths| paths.iter().any(|p| p.as_str() == Some(path))) + .unwrap_or(false); + if !touched { + continue; + } + let Some(state_hash) = transition["newState"].as_str() else { + continue; + }; + let state = read_module_state(&store, state_hash)?; + let files = flatten_tree(&store, &state.output_tree)?; + let Some((blob, _)) = files.get(path) else { + continue; + }; + let bytes = get_blob(&store, blob)?; + revisions.push(Revision { + transition: transition_id, + content: String::from_utf8_lossy(&bytes).into_owned(), + }); + } + let mut value = blame_json(path, &blame(&revisions)); + // Blame reads committed bytes only, and says so when it matters. A path + // whose working tree has moved away from the committed output carries a + // notice naming `napl diff` as where the uncommitted change lives, so + // ancestry over committed bytes is never mistaken for the present file. + let committed = revisions.last().map(|revision| revision.content.clone()); + let working = std::fs::read(workspace.join(path)).ok(); + let working_text = working + .as_deref() + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()); + let drifted = match (&committed, &working_text) { + (Some(committed), Some(working)) => committed != working, + _ => false, + }; + if let Some(object) = value.as_object_mut() { + object.insert("drift".to_string(), Value::Bool(drifted)); + if drifted { + object.insert( + "driftNotice".to_string(), + Value::String(format!( + "the working tree of {path:?} differs from the committed output; this ancestry is over the committed bytes only, and the uncommitted change lives in `napl diff`" + )), + ); + object.insert("driftInspect".to_string(), Value::String("napl diff".to_string())); + } + } + Ok(value) +} + +/// The source-language scan `napl lint ` performs: parse every +/// committed module's owned working-tree file under that target with the pinned +/// grammar and report one finding per file that fails. +/// +/// This is a different question from the graph analyses beside it and it needs no +/// roots declaration to answer, because nothing about reachability is involved. +/// The payload names its scope, so an empty findings list is never read wider +/// than the scan that produced it. +pub fn source_syntax_scan(workspace: &Path, target_name: &str) -> Result { + let store = disk::open(workspace); + let target = builtin_target(target_name)?; + let sources = discover_sources(workspace)?; + let roots: BTreeSet = sources + .iter() + .filter(|source| source.targets.iter().any(|t| t == target_name)) + .map(placement_root) + .collect(); + let mut findings: Vec = Vec::new(); + let mut scanned = 0u64; + for path in committed_manifest(&store, target_name)?.into_keys() { + if !roots + .iter() + .any(|root| crate::surface::is_target_source(&target, root, &path)) + { + continue; + } + let Ok(bytes) = std::fs::read(workspace.join(&path)) else { + // A committed path the working tree no longer holds is drift, which + // `diff` and `status` already report; the syntax scan judges bytes it + // can read and claims nothing about bytes it cannot. + continue; + }; + scanned += 1; + if let Some(failure) = crate::surface::parse_check(&target, &path, &bytes)? { + findings.push(json!({ + "autoFixable": false, + "file": failure.path, + "kind": "source-parse-error", + "line": failure.line, + "parseFailure": failure.as_json(), + "proposal": format!( + "fix the syntax at line {} of {:?}; the pinned grammar cannot parse it, so no surface can be extracted from it", + failure.line, failure.path + ), + "scope": crate::surface::SOURCE_SYNTAX_SCOPE, + })); + } + } + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "detail": "every committed module's owned working-tree file under this target was parsed with the target's pinned grammar; this scan judges syntax and nothing else, and it needs no roots declaration", + "filesScanned": scanned, + "findings": findings, + "rootsRequired": false, + "scope": crate::surface::SOURCE_SYNTAX_SCOPE, + "target": target_name, + })) +} + +/// Diagnose a workspace. +pub fn lint_workspace(workspace: &Path, target: &str) -> Result<(Graph, LintReport, Value)> { + let store = disk::open(workspace); + let sources = discover_sources(workspace)?; + let artifact = compile(&store, &sources, target, None)?; + let graph = crate::compile::graph_for(&sources, target)?; + let definition = builtin_target(target)?; + let states = match store.head()? { + Some(snapshot) => snapshot_states(&store, &snapshot)?, + None => BTreeMap::new(), + }; + + let mut committed_input_keys: BTreeMap> = BTreeMap::new(); + let mut referenced_names: BTreeMap> = BTreeMap::new(); + let mut surface_names: BTreeMap> = BTreeMap::new(); + for module in graph.keys() { + let (state, _) = state_of(&store, &states, &definition, module)?; + committed_input_keys.insert(module.clone(), state.input_key.clone()); + surface_names.insert( + module.clone(), + read_surface_items(&store, &state.surface_hash)? + .iter() + .filter_map(|item| item.path.rsplit("::").next().map(str::to_string)) + .collect(), + ); + referenced_names.insert(module.clone(), identifiers(&store, &state)?); + } + let current_input_keys: BTreeMap = artifact + .slices + .iter() + .map(|slice| (slice.module.clone(), slice.input_key.clone())) + .collect(); + + let roots = declared_roots(&sources); + let report = lint( + &graph, + &LintInput { + roots, + committed_input_keys, + current_input_keys, + referenced_names, + surface_names, + }, + ); + let value = lint_json(&report); + Ok((graph, report, value)) +} + +/// Cost over an explicit set: receipt object names in the store, receipt file +/// paths, usage-proxy session windows, or any mixture. +/// +/// A session window is priced here, lazily, at read time: a capture verb records +/// the window as a reference and never aggregates it, so the log lines are summed +/// only when a reader asks. A model the pinned table does not know stays +/// `unknown`, never zero. +pub fn cost( + workspace: &Path, + names: &[String], + files: &[String], + sessions: &[String], +) -> Result { + let store = disk::open(workspace); + let mut lines: Vec = Vec::new(); + for session in sessions { + lines.extend(crate::cost::lines_from_session( + &std::fs::read_to_string(workspace.join(crate::capture::USAGE_LOG_PATH)) + .unwrap_or_default(), + session, + )); + } + for name in names { + let body = get_json(&store, name, ObjectKind::Receipt)?; + lines.push(receipt_line(workspace, name, &body)?); + } + for file in files { + let path = Path::new(file); + let bytes = std::fs::read(path).map_err(|e| disk::io(path, &e))?; + let body: Value = serde_json::from_slice(&bytes).map_err(|e| { + error( + OutcomeCode::InvalidReceipt, + format!("{file}: invalid JSON: {e}"), + ) + })?; + let name = crate::canon::json_hash(ObjectKind::Receipt, &body); + lines.push(receipt_line(workspace, &name, &body)?); + } + Ok(cost_report(&lines)) +} + +/// One receipt's cost line: a reported total priced directly, a recorded session +/// window priced against the log the record names. +fn receipt_line(workspace: &Path, name: &str, body: &Value) -> Result { + let module = body["module"].as_str().unwrap_or_default(); + let reported = normalize_reported(&body["reported"]); + match crate::cost::window_from_reported(&reported) { + Some(window) => { + let log = window_log(workspace, &window.log_path); + Ok(crate::cost::line_from_window( + name, + module, + &window, + log.as_deref(), + )) + } + None => line_from_reported(name, module, &reported), + } +} + +/// The bytes of a log a record names, read at the moment the reader asks. +/// +/// The path is a record's own workspace-relative path, so a path that escapes +/// the workspace or is absolute is not a log this reader will open: it reads as +/// absent, which the report already knows how to say. +fn window_log(workspace: &Path, log_path: &str) -> Option { + let candidate = Path::new(log_path); + if candidate.is_absolute() || log_path.split('/').any(|part| part == "..") { + return None; + } + std::fs::read_to_string(workspace.join(candidate)).ok() +} + +/// Sweep every object no named head reaches, and the staged records beside them. +/// +/// The sweep set is computed outside the lock; only the deletions run under it, +/// and a writer publishing concurrently is never blocked because publication +/// takes no lock at all. +/// +/// Staged records belong in this report because they are exactly what the +/// git-state law calls disposable litter: never promoted to history, re-creatable +/// by re-running `add`, and reachable from no head. `--apply` sweeps them, so +/// nobody ever hand-edits `.napl` to be rid of one. +pub fn gc(workspace: &Path, apply: bool) -> Result { + let store = disk::init(workspace)?; + let all: BTreeSet = store.names()?.into_iter().collect(); + let reachable = match store.head()? { + Some(head) => reachable_from_head(&store, &head)?, + None => BTreeSet::new(), + }; + let swept = plan_sweep(&all, &reachable); + let staged: Vec = crate::staging::staged_records(workspace)? + .into_iter() + .map(|record| record.relative) + .collect(); + // The repack is the apply path's whole sweep: it packs the reachable closure + // and removes every loose file it enumerated, unreachable ones included, so + // packing the reachable and removing the rest is one operation. + let repacked = if apply { + disk::repack(&store, &reachable, "gc")? + } else { + disk::plan_repack(&store, &reachable)? + }; + if apply { + for name in &swept { + disk::remove_object(&store, name)?; + } + for record in &staged { + let dir = workspace.join(record); + match std::fs::remove_dir_all(&dir) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(disk::io(&dir, &e)), + } + } + } + // Kept counts objects the store actually holds. The reachable set may name a + // hash no object backs — a `readSet` identity, a derivation key — and keeping + // a name that resolves to nothing is not keeping an object. + let mut payload = gc_json(&swept, all.len() - swept.len()); + payload["stagedRecords"] = json!(staged); + let mut repack = crate::maintenance::repack_json(&repacked); + repack["applied"] = json!(apply); + payload["repack"] = repack; + Ok(payload) +} + +/// The exact command the owner runs to take the migrated object files out of +/// the working tree. It is theirs to run, not the toolchain's: removing them +/// from the index is an ordinary git commit and never a store mutation. +pub const STORE_MIGRATE_REMOVAL: &str = "git rm -r .napl/store/objects"; + +/// `napl store migrate`: the one way `refs/heads/napl-store` comes to exist. +/// +/// It walks the tracked store's loose objects and every pack into git blobs, +/// re-hashing each envelope to the napl name it must keep, builds one fanout +/// tree and one deterministic commit over them, and creates the branch there. It +/// refuses outside a git repository and refuses when the branch already exists, +/// and it touches no tracked file: the payload names the object files as ready +/// to leave the working tree and the exact command that does it, because that +/// removal is the owner's ordinary git commit. +/// +/// After it, selection observes the branch and every later read and write goes +/// through the git provider with no flag set anywhere. +pub fn store_migrate(workspace: &Path) -> Result<(bool, Value)> { + let store = disk::open(workspace); + let Some(repository) = napl_store::git_store::enclosing_repository(workspace) else { + return Ok(( + false, + migrate_refusal( + "not-a-git-repository", + "this workspace is not inside a git repository, so there is no object database to migrate into; the tracked layout with its packfile mitigation stays the floor", + ), + )); + }; + let provider = napl_store::git_store::GitProvider::open(&repository, &store.root) + .map_err(disk::git_error)?; + if provider.tip().map_err(disk::git_error)?.is_some() { + return Ok(( + false, + migrate_refusal( + "already-migrated", + "refs/heads/napl-store already exists, and migration is a one-shot verb that never rewrites a store branch", + ), + )); + } + + let tracked = tracked_object_files(&store.root)?; + let outcome = provider + .convert_tracked_store(&store.root, crate::version::CONTRACT_VERSION) + .map_err(disk::git_error)?; + + Ok(( + true, + json!({ + "branch": napl_store::git_store::STORE_BRANCH, + "commit": outcome.commit, + "contractVersion": crate::version::CONTRACT_VERSION, + "objects": outcome.objects, + "outcome": "migrated", + "readyToLeaveWorkingTree": { + "detail": "migration touched none of these: removing them from the index is your ordinary git commit, not a store mutation", + "files": tracked, + "removalCommand": STORE_MIGRATE_REMOVAL, + "root": format!("{}/objects", disk::STORE_DIR), + }, + "snapshot": outcome.snapshot, + }), + )) +} + +/// How many files the tracked object layout holds under the store root: every +/// loose object file, every pack, and every pack index. +fn tracked_object_files(root: &Path) -> Result { + let mut count = 0; + let mut stack = vec![root.join("objects")]; + while let Some(directory) = stack.pop() { + let entries = match std::fs::read_dir(&directory) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(disk::io(&directory, &e)), + }; + for entry in entries { + let entry = entry.map_err(|e| disk::io(&directory, &e))?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + count += 1; + } + } + } + Ok(count) +} + +/// A migration refusal: the reason named, nothing done, nothing written. +fn migrate_refusal(reason: &str, detail: &str) -> Value { + json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "detail": detail, + "outcome": "refused", + "reason": reason, + }) +} + +/// Verify envelope hashes, kind validity, causal parent links, and every pack's +/// name, index, and members. +pub fn fsck_store(workspace: &Path) -> Result<(bool, Value)> { + let store = disk::open(workspace); + let mut findings = fsck(&store)?; + findings.extend(disk::verify_packs(&store)?.into_iter().map(pack_finding)); + findings.sort(); + findings.dedup(); + Ok((findings.is_empty(), fsck_json(&findings))) +} + +/// Translate one store-level pack finding into the compiler's fsck vocabulary. +fn pack_finding(finding: napl_store::store_io::PackFinding) -> crate::maintenance::FsckFinding { + use napl_store::store_io::PackFinding as Pack; + let (pack, detail) = match finding { + Pack::NameMismatch { pack } => { + let detail = "the pack's bytes do not hash to its name".to_string(); + (pack, detail) + } + Pack::IndexDisagrees { pack, detail } => (pack, detail), + Pack::MemberCorrupt { pack, object } => { + let detail = format!("packed object {object} does not verify against its name"); + (pack, detail) + } + }; + crate::maintenance::FsckFinding::PackDamaged { pack, detail } +} + +/// Repack the store when its loose-object count crosses the compiled-in bound. +/// +/// Called after a successful commit's critical section and outside every lock: +/// the pointer has already moved, so a repack here never lengthens the bounded +/// section and never blocks a writer. It packs the same reachable closure `gc +/// --apply` packs, so the pack it writes is byte-identical to gc's. +pub fn repack_if_loose(store: &Store) -> Result> { + if disk::loose_object_count(store)? <= crate::maintenance::LOOSE_OBJECT_REPACK_THRESHOLD { + return Ok(None); + } + let reachable = match store.head()? { + Some(head) => reachable_from_head(store, &head)?, + None => return Ok(None), + }; + let outcome = disk::repack(store, &reachable, "gc")?; + if outcome.pack.is_none() { + return Ok(None); + } + Ok(Some(crate::maintenance::repack_json(&outcome))) +} + +/// Derive a repair plan for a target's committed outputs, and optionally apply +/// it. +pub fn restore(workspace: &Path, target: &str, apply: bool) -> Result { + let store = disk::open(workspace); + let expected: Vec = committed_manifest(&store, target)? + .into_iter() + .map(|(path, hash)| FileFact { path, hash }) + .collect(); + let roots: BTreeSet = expected + .iter() + .filter_map(|fact| fact.path.split('/').next().map(str::to_string)) + .collect(); + let observed = observed_facts(workspace, &roots)?; + let plan = plan_restore(&expected, &observed); + if apply { + if let RestorePlan::Apply(steps) = &plan { + apply_restore(&store, workspace, steps)?; + } + } + Ok(restore_json(&plan)) +} + +/// Attach later provenance to a recorded transition without touching semantic +/// state. +/// The refusal a `--to` that is not an object name earns. +/// +/// A working path is the mistake a reader makes here, because every other verb +/// takes one, so the refusal does not merely say no: it names the two commands +/// that produce and inspect an object name and shows the one line that gets one. +fn not_an_object_name(given: &str) -> crate::error::CompilerError { + error( + OutcomeCode::Usage, + format!( + "--to expects the object name of a recorded transition, and {given:?} is not one. \ + Object names come from `napl log`, and `napl show ` inspects one, \ + for example: napl log | napl q '.entries[-1].transition' -" + ), + ) +} + +pub fn add_note(workspace: &Path, transition: &str, note: &str) -> Result { + let store = disk::init(workspace)?; + // A path is never an object name, and neither is anything else outside the + // store's name alphabet: refuse before opening anything, so the reader is + // told how to get a name rather than handed a missing-object error. + if !is_object_name(transition) || !has_object(&store, transition) { + return Err(not_an_object_name(transition)); + } + get_json(&store, transition, ObjectKind::Transition)?; + let body = json!({ "note": note, "transition": transition }); + let payload = crate::canon::canonical_bytes(&body); + let name = disk::publish(&store, ObjectKind::Blob, &payload, transition)?; + + let index = store.root.join("notes").join(format!("{transition}.json")); + let mut names: BTreeSet = match std::fs::read(&index) { + Ok(bytes) => serde_json::from_slice::>(&bytes) + .unwrap_or_default() + .into_iter() + .collect(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(), + Err(e) => return Err(disk::io(&index, &e)), + }; + names.insert(name.clone()); + let listed: Vec = names.iter().map(|n| json!(n)).collect(); + crate::workspace::write_file(&index, &crate::canon::canonical_bytes(&Value::Array(listed)))?; + Ok(json!({ + "note": name, + "outcome": "success", + "semanticStatePublished": false, + "transition": transition, + })) +} + +/// The events reachable from a head, ordered oldest first by causal depth. +pub fn causal_order(store: &Store, head: &str) -> Result> { + let head_event = snapshot_event(store, head)?; + let events = walk_events(store, &head_event, None)?; + let mut depths: BTreeMap = BTreeMap::new(); + for event in &events { + depth_of(store, event, &mut depths)?; + } + let mut ordered = events; + ordered.sort_by(|a, b| (depths[a], a).cmp(&(depths[b], b))); + Ok(ordered) +} + +fn depth_of(store: &Store, event: &str, depths: &mut BTreeMap) -> Result { + if let Some(known) = depths.get(event) { + return Ok(*known); + } + let value = get_json(store, event, ObjectKind::Event)?; + let mut depth = 0usize; + for parent in value["parents"].as_array().cloned().unwrap_or_default() { + if let Some(parent) = parent.as_str() { + depth = depth.max(depth_of(store, parent, depths)? + 1); + } + } + depths.insert(event.to_string(), depth); + Ok(depth) +} + +pub fn committed_state(store: &Store, target: &str, module: &str) -> Result> { + let Some(head) = store.head()? else { + return Ok(None); + }; + let states = snapshot_states(store, &head)?; + match states.get(&(target.to_string(), module.to_string())) { + Some(hash) => Ok(Some(read_module_state(store, hash)?)), + None => Ok(None), + } +} + +/// The materialized attribution view a module committed last, if any. +/// +/// The bytes are the record blob, which is the view itself: an internal +/// re-commit reads them to carry the mapping forward rather than fabricating a +/// new one. +pub fn committed_attribution_yaml( + store: &Store, + target: &str, + module: &str, +) -> Result>> { + let Some(state) = committed_state(store, target, module)? else { + return Ok(None); + }; + match state.attribution { + Some(attribution) => Ok(Some(get_blob(store, &attribution.record)?)), + None => Ok(None), + } +} + +/// The committed `path -> blob` manifest of every module under a target. +pub fn committed_manifest(store: &Store, target: &str) -> Result> { + let Some(head) = store.head()? else { + return Ok(BTreeMap::new()); + }; + let mut out = BTreeMap::new(); + for ((state_target, _), hash) in snapshot_states(store, &head)? { + if state_target != target { + continue; + } + let state = read_module_state(store, &hash)?; + for (path, (blob, _)) in flatten_tree(store, &state.output_tree)? { + out.insert(path, blob); + } + } + Ok(out) +} + +/// Whether a token is shaped like a store object name at all: the lowercase hex +/// of a SHA-256, and nothing else. A workspace path fails on the first separator. +fn is_object_name(given: &str) -> bool { + given.len() == 64 && given.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Every path any committed module owns, across every target: the set that +/// decides whether a path is tracked at all. +fn committed_manifest_all_targets(store: &Store) -> Result> { + let mut out: BTreeSet = BTreeSet::new(); + let Some(head) = store.head()? else { + return Ok(out); + }; + for (_, hash) in snapshot_states(store, &head)? { + let state = read_module_state(store, &hash)?; + for (path, _) in flatten_tree(store, &state.output_tree)? { + out.insert(path); + } + } + Ok(out) +} + +fn committed_text(store: &Store, target: &str) -> Result> { + let mut out = BTreeMap::new(); + for (path, blob) in committed_manifest(store, target)? { + let bytes = get_blob(store, &blob)?; + out.insert(path, String::from_utf8_lossy(&bytes).into_owned()); + } + Ok(out) +} + +/// Every identifier a module's committed output names. +/// +/// This is a token scan, not name resolution: lint says a surface entry looks +/// unconsumed, and says so as a proposal, precisely because it cannot prove it. +fn identifiers(store: &Store, state: &ModuleState) -> Result> { + let mut out: BTreeSet = BTreeSet::new(); + if !has_object(store, &state.output_tree) { + // A module that has never committed names nothing yet. + return Ok(out); + } + for (_, (blob, _)) in flatten_tree(store, &state.output_tree)? { + let bytes = get_blob(store, &blob)?; + let text = String::from_utf8_lossy(&bytes).into_owned(); + for token in text.split(|c: char| !(c.is_alphanumeric() || c == '_')) { + if !token.is_empty() { + out.insert(token.to_string()); + } + } + } + Ok(out) +} + +fn declared_roots(sources: &[crate::source::SourceModule]) -> Option> { + let mut declared: Option> = None; + for source in sources { + if let Some(roots) = &source.roots { + declared + .get_or_insert_with(BTreeSet::new) + .extend(roots.iter().cloned()); + } + } + declared.map(|set| set.into_iter().collect()) +} + +fn observed_facts(workspace: &Path, roots: &BTreeSet) -> Result> { + let mut out: Vec = Vec::new(); + for root in roots { + let dir = workspace.join(root); + if !dir.is_dir() { + continue; + } + for entry in crate::workspace::scan_candidate(&dir)? { + if entry.kind != crate::candidate::EntryKind::File { + continue; + } + out.push(FileFact { + path: crate::paths::join_root(root, &entry.path), + hash: napl_store::store_hash::object_name(ObjectKind::Blob, &entry.bytes), + }); + } + } + out.sort(); + Ok(out) +} + +fn apply_restore( + store: &Store, + workspace: &Path, + steps: &[crate::maintenance::RestoreStep], +) -> Result<()> { + use crate::maintenance::RestoreStep; + for step in steps { + match step { + RestoreStep::Move { from, to } => { + let target = workspace.join(to); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|e| disk::io(parent, &e))?; + } + std::fs::rename(workspace.join(from), &target) + .map_err(|e| disk::io(&target, &e))?; + } + RestoreStep::Write { path, hash } => { + let bytes = get_blob(store, hash)?; + crate::workspace::write_file(&workspace.join(path), &bytes)?; + } + RestoreStep::Remove { path } => { + let target = workspace.join(path); + match std::fs::remove_file(&target) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(disk::io(&target, &e)), + } + } + } + } + Ok(()) +} +napl-store module-state v1 562 +{"attribution":{"promptHash":"0a53f13c5e29172205697a166ba275f3517875ed8eef390067a8bd98659f5991","record":"a38d322afe4fa30f9d73cf7f101d26cb366e2dd382463b60fc5a76b5c3d62d02","stale":false},"contractVersion":3,"inputKey":"7b226b9e4f0e6ccadf5acb407ffd20a7ff8c22e7312c6487fcc61d8e62926a8e","module":"commands","outputTree":"b92d10f23649a6ab076e0a23eeda2a922fbb3e779486a81b37c96c11f63d7873","sliceHash":"18a663dd644a423c3581cca0d701b6e4a8eaea40161258aac8ff64b86126fd3e","surfaceHash":"6260dd0f3f8757a6fcbd1c6049df47ff714828822079322176853e9dbc120f0e","target":"rust"} +napl-store tree v1 4545 +{"entries":[{"hash":"f9b963cff641b6476b8a60a34e87be76cb9a071cfe5db598fc71260799fab532","kind":"file","mode":33188,"name":"attribution.rs"},{"hash":"1dec6b9fe622b28f871dae12c000036fd0604aef104c3fdcf95f38e1edcb3733","kind":"file","mode":33188,"name":"authoring.rs"},{"hash":"bcb41a480457775ca3f239c29b7f6ec05cb48e121a6b04172b3b2fe63afaca68","kind":"file","mode":33188,"name":"blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","kind":"file","mode":33188,"name":"candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","kind":"file","mode":33188,"name":"canon.rs"},{"hash":"a258b748a1c40209cc4b9d748a9257c8b314224856c232ed8982aa62002e9c31","kind":"file","mode":33188,"name":"cli.rs"},{"hash":"2c7ac3d8f01c5c4fd114bb758f824ef4e47029fb88fab1390abb6aa550d40a25","kind":"file","mode":33188,"name":"commandset.rs"},{"hash":"271b52c2ec09469469e510d0c21be9948e7132d5be7f7776356dccf6a4647ef3","kind":"file","mode":33188,"name":"commit.rs"},{"hash":"51a5efd09235e6a66de7381faaa217a51136ca9c7d2b10285f99af8ebe4ffc6f","kind":"file","mode":33188,"name":"compile.rs"},{"hash":"14768a9b96b8dd25e1efa36ddad54dd48c5feab01b57a4be8883f7ce8b524eb8","kind":"file","mode":33188,"name":"cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","kind":"file","mode":33188,"name":"decision.rs"},{"hash":"28059a89256aad1de78ecd31d7b77cae21772bbc6e96fe8ba1cdb9044048f410","kind":"file","mode":33188,"name":"disk.rs"},{"hash":"0b71431883adea0e4f3dc3a77b968b69ac4f18348387362cd3c48fbc023d8bb4","kind":"file","mode":33188,"name":"error.rs"},{"hash":"5b7c0553530cac82c017fc61327a6c120e284d0a6d0966e24f0d63fe07d134f5","kind":"file","mode":33188,"name":"evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","kind":"file","mode":33188,"name":"graph.rs"},{"hash":"3448e19e121d0f55b9a0f58f38d0ad2a5849ea8e6676688fb18401987dccdf2a","kind":"file","mode":33188,"name":"init.rs"},{"hash":"f0641fc8a646a08a653d7a2ba1b8fe68da672f027919a1fccb53254e7bcd456a","kind":"file","mode":33188,"name":"lint.rs"},{"hash":"4b445320f81bd7a35f96dd118169b2f02dd112966610fb0d60ccd79e9ddc6e46","kind":"file","mode":33188,"name":"lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","kind":"file","mode":33188,"name":"main.rs"},{"hash":"315f6fa79c7c675b848f1308087ef5e1c328e63cdbb2ad88929b0c89023a27df","kind":"file","mode":33188,"name":"maintenance.rs"},{"hash":"564ecc0f6a253c3229aa3f95806ca4d9b0e894a9a71740153859f2e62b4d02ee","kind":"file","mode":33188,"name":"mv.rs"},{"hash":"3050b7652a1a7070197d7fd954cf55c585dc328306ae68a91b27d24b6b84dcfd","kind":"file","mode":33188,"name":"objects.rs"},{"hash":"003dfa7b8045ce14ca57c31215434fe8034f78efa7c2f6660e635ae4dec26152","kind":"file","mode":33188,"name":"paths.rs"},{"hash":"0bfaac92fd735f0afe003a99021211a8154b66123c3dc7d07ba882e48ba64bdd","kind":"file","mode":33188,"name":"porcelain.rs"},{"hash":"7a087b2d7673515a1187be0a27048a44753403ad0eaae3c729b2eb033803ae7d","kind":"file","mode":33188,"name":"query.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","kind":"file","mode":33188,"name":"receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","kind":"file","mode":33188,"name":"slice.rs"},{"hash":"73c7b0d30b3283cf87c1dcebfca770a61d530918bfc1327077b62fd25e25a2c7","kind":"file","mode":33188,"name":"source.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","kind":"file","mode":33188,"name":"store_view.rs"},{"hash":"9ae420e34160e7c2a6c2ba092aaa9c9ff1df622ece595849d95bf5c5eb7cbe81","kind":"file","mode":33188,"name":"surface.rs"},{"hash":"0cc9f6deb3819268377d5cd3b50b42adc297a013a9c8c9dcf5c77eebc0cd0f8e","kind":"file","mode":33188,"name":"target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","kind":"file","mode":33188,"name":"textdiff.rs"},{"hash":"73a6d090ea83ac2eafc5df7c7dbbb3b0c04de2f11e5e1b54c8a478a6d12b0a52","kind":"file","mode":33188,"name":"transaction.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","kind":"file","mode":33188,"name":"trees.rs"},{"hash":"2cfa7807b9e55b5e943987474c43959a77f4da2ca21c6e4454f42ce9fb2988c7","kind":"file","mode":33188,"name":"version.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","kind":"file","mode":33188,"name":"watch.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","kind":"file","mode":33188,"name":"workspace.rs"}]} +napl-store blob v1 60845 +//! The read commands and the maintenance commands. +//! +//! Every read resolves HEAD exactly once and then answers entirely from objects +//! reachable from that snapshot. No read creates a directory, takes a lock, or +//! writes a byte, so the whole read surface works on a read-only filesystem. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use crate::blame::{blame, blame_json, untracked_json, Revision}; +use crate::compile::{ + compile, graph_for, install_index, read_surface_items, state_of, verdict as pending_verdict, +}; +use crate::cost::{cost_report, line_from_reported, CostLine}; +use crate::error::{error, OutcomeCode, Result}; +use crate::graph::{topological_levels, Graph}; +use crate::slice::receipt_requirements; +use crate::lint::{lint, lint_json, LintInput, LintReport}; +use crate::maintenance::{ + fsck, fsck_json, gc_json, plan_restore, plan_sweep, reachable_from_head, restore_json, + FileFact, RestorePlan, +}; +use crate::objects::{ + event_transition, read_module_state, snapshot_event, snapshot_states, walk_events, ModuleState, +}; +use crate::receipt::normalize_reported; +use crate::store_view::{ + flatten_tree, get_blob, get_json, get_object, has_object, ObjectSource, +}; +use crate::surface::surface_json; +use crate::target::{ + builtin_target, is_manifest_path, owned_globs, placement_root, projection_owned_paths, TargetDef, +}; +use crate::textdiff::render_drift; +use napl_store::store_hash::ObjectKind; +use serde_json::{json, Value}; + +use crate::disk::{self, Store}; +use crate::workspace::{discover_sources, read_working_text}; + +/// A snapshot-consistent status report for one target: observation, zero +/// mutation. +pub fn status(workspace: &Path, target: &str) -> Result { + let store = disk::open(workspace); + let head = store.head()?; + let states = match &head { + Some(snapshot) => snapshot_states(&store, snapshot)?, + None => BTreeMap::new(), + }; + let sources = discover_sources(workspace)?; + let artifact = compile(&store, &sources, target, None)?; + let pending: Vec = artifact + .slices + .iter() + .map(|slice| slice.module.clone()) + .collect(); + let frontier: BTreeSet = pending.iter().cloned().collect(); + let target_def = builtin_target(target)?; + let installs = install_index(&store, head.as_deref())?; + let docs_drifted = crate::staging::docs_drift(workspace)?; + + let mut modules: Vec = Vec::new(); + for ((state_target, module), state_hash) in &states { + if state_target != target { + continue; + } + let state = read_module_state(&store, state_hash)?; + let freshness = attribution_freshness(Some(&state), module, target, &sources); + let prompt_stale = freshness["status"].as_str() != Some("fresh"); + let ends = both_ends( + workspace, + &store, + &state, + prompt_stale, + &pending_body(&sources, module), + )?; + let is_pending = pending.contains(module); + let verdict = pending_verdict( + &store, + &sources, + &target_def, + module, + &states, + &installs, + &frontier, + ends.drift, + )?; + let (reason, reasons, reland) = verdict.json_fields(is_pending); + modules.push(json!({ + "attribution": freshness, + "baseStateKey": state_hash, + "docsDrift": docs_drifted.contains(&(target.to_string(), module.clone())), + "drift": ends.drift, + "inputKey": state.input_key, + "module": module, + "outputTree": state.output_tree, + "pending": is_pending, + "pendingReason": reason, + "pendingReasons": reasons, + "relandIdenticalBytes": reland, + "sliceHash": state.slice_hash, + "staleReferences": ends.stale_references as u64, + "surfaceHash": state.surface_hash, + })); + } + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "head": head, + "manifestDiagnostics": manifest_diagnostics(workspace, target, &sources), + "modules": modules, + "pending": pending, + "target": target, + })) +} + +/// What status observes about a module's code side, so status judges both ends. +/// +/// `drift` is true when the committed output differs from the working tree, and +/// `stale_references` counts the module's references reading stale right now. Both +/// come from the derivations `diff` and the language server already serve, which +/// is the whole point: status can never read clean while the query surface reads +/// stale, because there is one derivation and not two. +struct BothEnds { + drift: bool, + stale_references: usize, +} + +fn both_ends( + workspace: &Path, + store: &Store, + state: &ModuleState, + prompt_stale: bool, + pending_body: &str, +) -> Result { + let mut drift = false; + let mut drifted_paths: BTreeSet = BTreeSet::new(); + if has_object(store, &state.output_tree) { + for (path, (blob, _mode)) in flatten_tree(store, &state.output_tree)? { + let bytes = std::fs::read(workspace.join(&path)).ok(); + if crate::attribution::code_drifted(Some(blob.as_str()), bytes.as_deref()) { + drift = true; + drifted_paths.insert(path); + } + } + } + let entries = match &state.attribution { + Some(recorded) if has_object(store, &recorded.record) => { + let bytes = get_blob(store, &recorded.record)?; + crate::attribution::parse(&String::from_utf8_lossy(&bytes)) + .map(|record| record.entries) + .unwrap_or_default() + } + _ => Vec::new(), + }; + let mut stale_references = 0usize; + for entry in &entries { + let working = entry + .file + .as_ref() + .and_then(|file| std::fs::read(workspace.join(file)).ok()); + let code_drift = entry + .file + .as_ref() + .map(|file| drifted_paths.contains(file)) + .unwrap_or(false); + if crate::attribution::entry_reference_status( + entry, + prompt_stale, + false, + code_drift, + working.as_deref(), + pending_body, + ) + .stale + { + stale_references += 1; + } + } + Ok(BothEnds { + drift, + stale_references, + }) +} + +/// The module's pending normalized prompt body, or the empty body when the +/// workspace no longer has a prompt for it. +fn pending_body(sources: &[crate::source::SourceModule], module: &str) -> String { + sources + .iter() + .find(|source| source.module == module) + .map(|source| source.normalized_body.clone()) + .unwrap_or_default() +} + +/// The module PLAN beside the module's live STATE. +/// +/// The plan is the invariant requirements a conforming implementation derives +/// from the working tree's prompts alone: owned globs, placement root, projection +/// ownership, receipt requirements, wave position, never a candidate-dependent +/// fact, which is `add`'s job. The state is what is true of the module right now: +/// tracked or pending, drift, stale reference count, attribution status. The +/// command named status never answers with a plan alone. +/// +/// The payload also carries the derived entry-file projection, path and exact +/// content, computed the way commit computes it but over the working tree, so the +/// line a new file needs in a shared entry file is read from the tool rather than +/// composed by hand. +pub fn module_plan(workspace: &Path, target_name: &str, module: &str) -> Result { + let sources = discover_sources(workspace)?; + let target = builtin_target(target_name)?; + let source = sources.iter().find(|s| s.module == module).ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} has no prompt file in the workspace"), + ) + })?; + let graph = graph_for(&sources, target_name)?; + let levels = topological_levels(&graph)?; + let wave = *levels.get(module).ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} is not in the graph for target {target_name:?}"), + ) + })?; + let root = placement_root(source); + + let store = disk::open(workspace); + let artifact = compile(&store, &sources, target_name, None)?; + let pending = artifact.slices.iter().any(|slice| slice.module == module); + let frontier: BTreeSet = artifact + .slices + .iter() + .map(|slice| slice.module.clone()) + .collect(); + let head = store.head()?; + let head_states = crate::compile::head_states(&store, head.as_deref())?; + let installs = install_index(&store, head.as_deref())?; + let committed = committed_state(&store, target_name, module)?; + let state = match &committed { + Some(state) => { + let freshness = attribution_freshness(Some(state), module, target_name, &sources); + let prompt_stale = freshness["status"].as_str() != Some("fresh"); + let ends = both_ends( + workspace, + &store, + state, + prompt_stale, + &pending_body(&sources, module), + )?; + let verdict = pending_verdict( + &store, + &sources, + &target, + module, + &head_states, + &installs, + &frontier, + ends.drift, + )?; + let (reason, reasons, reland) = verdict.json_fields(pending); + json!({ + "attribution": freshness, + "docsDrift": crate::staging::docs_drift(workspace)? + .contains(&(target_name.to_string(), module.to_string())), + "drift": ends.drift, + "pending": pending, + "pendingReason": reason, + "pendingReasons": reasons, + "relandIdenticalBytes": reland, + "staleReferences": ends.stale_references as u64, + "tracked": true, + }) + } + None => { + let verdict = pending_verdict( + &store, + &sources, + &target, + module, + &head_states, + &installs, + &frontier, + false, + )?; + let (reason, reasons, reland) = verdict.json_fields(pending); + json!({ + "attribution": attribution_freshness(None, module, target_name, &sources), + "docsDrift": false, + "drift": false, + "pending": pending, + "pendingReason": reason, + "pendingReasons": reasons, + "relandIdenticalBytes": reland, + "staleReferences": 0, + "tracked": false, + }) + } + }; + + let (projection_path, projection_bytes) = + working_tree_projection(workspace, &store, &target, &sources, module, &root)?; + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "module": module, + "plan": { + "firstOutput": first_output(&target, &root, module), + "ownedGlobs": owned_globs(&target, &root), + "placementRoot": root, + "projection": { + "content": String::from_utf8_lossy(&projection_bytes), + "ownership": PROJECTION_OWNERSHIP, + "owner": PROJECTION_OWNER, + "path": projection_path, + "userSupplied": false, + }, + "projectionOwned": projection_owned_paths(&target, &root), + "projections": projection_labels(&target, &root), + "receiptRequirements": receipt_requirements(), + "wave": wave as u64, + }, + "state": state, + "target": target_name, + })) +} + +/// What a projection path is, said in the plan every time one is mentioned. +/// +/// The obvious wrong first move in a cold module is to hand-write the crate's +/// entry file, because it is the file that visibly has to change for a new +/// source file to be reachable. It is derived, it is owned by no module, and a +/// candidate carrying it is rejected. Saying so beside the path is cheaper than +/// the rejection that would otherwise teach it. +pub const PROJECTION_OWNERSHIP: &str = "tool-derived"; +pub const PROJECTION_OWNER: &str = + "no module: this path is recomputed by commit from the crate's committed module file sets and is never user-supplied; a candidate that carries it is rejected as invalid-candidate"; + +/// Every projection path this module's crate has, each labeled for what it is. +pub fn projection_labels(target: &TargetDef, root: &str) -> Vec { + projection_owned_paths(target, root) + .into_iter() + .map(|path| { + json!({ + "ownership": PROJECTION_OWNERSHIP, + "owner": PROJECTION_OWNER, + "path": path, + "userSupplied": false, + }) + }) + .collect() +} + +/// One concrete legal first output path for a module: the module's own name +/// under the target's source directory, inside the placement root. +/// +/// A plan that lists globs and a projection but no example leaves the author to +/// pick a path and find out afterwards whether it was ownable. Naming one that +/// certainly is turns the first move into a copy. +pub fn first_output(target: &TargetDef, root: &str, module: &str) -> Value { + let path = crate::paths::join_root( + root, + &format!( + "{}/{}.{}", + target.source_dir, + crate::paths::encode_name(module), + target.source_extension + ), + ); + json!({ + "detail": "one concrete path this module may own: its own name under the target's source directory, inside the placement root; any path matching the owned globs that is not a projection or a manifest is equally legal", + "path": path, + }) +} + +/// The crate's entry-file projection as commit would derive it if this module +/// were committed from the working tree right now. +/// +/// The inputs are the other modules' committed file sets plus this module's +/// working-tree files, and the command providers are read the same way: from the +/// other modules' recorded surfaces plus this module's working-tree surface. That +/// is what makes the answer useful for a file that is new: a projection derived +/// from committed state alone could never name it, and naming it is the whole +/// question a reader asks here. +fn working_tree_projection( + workspace: &Path, + store: &Store, + target: &TargetDef, + sources: &[crate::source::SourceModule], + module: &str, + root: &str, +) -> Result<(String, Vec)> { + let src_root = crate::paths::join_root(root, &target.source_dir); + let mut files: Vec = Vec::new(); + let mut providers: Vec = Vec::new(); + + let states = match store.head()? { + Some(head) => snapshot_states(store, &head)?, + None => BTreeMap::new(), + }; + for ((state_target, other), hash) in &states { + if state_target != &target.name || other == module { + continue; + } + let state = read_module_state(store, hash)?; + if !has_object(store, &state.output_tree) { + continue; + } + let owned: Vec = flatten_tree(store, &state.output_tree)?.into_keys().collect(); + if !owned + .iter() + .any(|path| crate::paths::is_under(path, &src_root)) + { + continue; + } + files.extend(owned); + for item in read_surface_items(store, &state.surface_hash)? { + if let Some(provider) = command_provider(&item.kind, &item.path) { + providers.push(provider); + } + } + } + + let working = working_owned_files(workspace, store, target, sources, module)?; + for item in crate::surface::extract_surface(target, root, &working)? { + if let Some(provider) = command_provider(&item.kind, &item.path) { + providers.push(provider); + } + } + files.extend(working.into_keys()); + Ok(crate::target::derive_lib_projection( + target, root, &files, &providers, + )) +} + +/// The module stem a surface item declares a command table for, when it declares +/// one: the convention is one `commands` function per module file. +fn command_provider(kind: &str, path: &str) -> Option { + if kind != "fn" { + return None; + } + let prefix = path.strip_suffix("::commands")?; + (!prefix.is_empty() && !prefix.contains(' ')).then(|| prefix.to_string()) +} + +/// A module's owned files as they sit in the working tree: the committed paths +/// read from disk, plus the paths under its globs no module has committed. +fn working_owned_files( + workspace: &Path, + store: &Store, + target: &TargetDef, + sources: &[crate::source::SourceModule], + module: &str, +) -> Result>> { + let mut out: BTreeMap> = BTreeMap::new(); + if let Some(state) = committed_state(store, &target.name, module)? { + if has_object(store, &state.output_tree) { + for path in flatten_tree(store, &state.output_tree)?.into_keys() { + if let Ok(bytes) = std::fs::read(workspace.join(&path)) { + out.insert(path, bytes); + } + } + } + } + for path in crate::staging::unclaimed_working_paths(workspace, store, target, module, sources)? { + if let Ok(bytes) = std::fs::read(workspace.join(&path)) { + out.insert(path, bytes); + } + } + Ok(out) +} + +/// Observe, never mutate, the manifest/placement mismatches the toolchain now +/// only diagnoses: a module placed in a crate the workspace manifest does not +/// list, or a crate directory missing a manifest. Manifests are user-owned +/// seams, so these are reports, not writes. +fn manifest_diagnostics( + workspace: &Path, + target_name: &str, + sources: &[crate::source::SourceModule], +) -> Vec { + let Ok(target) = builtin_target(target_name) else { + return Vec::new(); + }; + let members = workspace_members(workspace, &target); + let mut roots: Vec = sources.iter().map(placement_root).collect(); + roots.sort(); + roots.dedup(); + let mut out: Vec = Vec::new(); + for root in roots { + if root.is_empty() { + continue; + } + if let Some(members) = &members { + if !members.contains(&root) { + out.push(manifest_diagnostic( + &root, + &target.manifest_file, + "crate-not-listed-in-workspace-manifest", + &format!("the crate {root:?} holds a placed module but the workspace manifest does not list it as a member"), + )); + } + } + let manifest = workspace.join(&root).join(&target.manifest_file); + if !manifest.is_file() { + out.push(manifest_diagnostic( + &root, + &crate::paths::join_root(&root, &target.manifest_file), + "crate-directory-missing-manifest", + &format!("the crate directory {root:?} receives placed modules but has no manifest"), + )); + } + } + out +} + +/// One manifest observation, which always locates the file it means. +/// +/// A manifest is a user-owned seam: the toolchain never writes one, so a +/// diagnostic that only alluded to "the manifest" would leave the reader hunting +/// for the file they are the only one allowed to edit. Every diagnostic therefore +/// carries the exact workspace-relative path and says plainly whose file it is. +fn manifest_diagnostic( + crate_root: &str, + manifest: &str, + observation: &str, + detail: &str, +) -> Value { + json!({ + "crate": crate_root, + "detail": detail, + "manifest": manifest, + "observation": observation, + "owner": "user", + "remedy": format!("{manifest} is yours to edit; the toolchain diagnoses manifests and never writes them"), + }) +} + +/// The crate directories the workspace-root manifest lists as members, or +/// `None` when there is no readable workspace manifest to compare against. +fn workspace_members(workspace: &Path, target: &TargetDef) -> Option> { + let text = std::fs::read_to_string(workspace.join(&target.manifest_file)).ok()?; + let mut members: Vec = Vec::new(); + for line in text.lines() { + let trimmed = line.trim().trim_end_matches(','); + if let Some(inner) = trimmed + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + { + if !is_manifest_path(target, inner) && inner.contains('/') { + members.push(inner.to_string()); + } + } + } + Some(members) +} + +/// Classify a module's attribution freshness and, when it is not fresh, name the +/// exact fix. +/// +/// It is a pure observation over content already resolved: an absent record, a +/// record installed unproven, or a record whose recorded prompt hash no longer +/// matches the current normalized body all read as needing a fresh commit. A +/// record proven against the body still on disk is fresh. +fn attribution_freshness( + state: Option<&ModuleState>, + module: &str, + target: &str, + sources: &[crate::source::SourceModule], +) -> Value { + let directive = json!({ + "command": "commit", + "directive": format!( + "commit module {module:?} for target {target:?} with a fresh attribution update" + ), + "module": module, + "target": target, + }); + match state.and_then(|state| state.attribution.as_ref()) { + None => json!({ "fix": directive, "status": "absent" }), + Some(attribution) => { + let current = sources + .iter() + .find(|source| source.module == module) + .map(|source| source.normalized_source_hash.as_str()); + if attribution.stale { + json!({ + "fix": directive, + "reason": "installed with --no-verify", + "status": "stale", + }) + } else if current != Some(attribution.prompt_hash.as_str()) { + json!({ + "fix": directive, + "reason": "the prompt changed since the attribution was recorded", + "status": "stale", + }) + } else { + json!({ "status": "fresh" }) + } + } + } +} + +/// The causal history reachable from HEAD, optionally narrowed to one module. +/// +/// The `ordinal` field is a presentation device computed from the causal graph +/// at read time. It is never stored and never enters an identity. +pub fn log(workspace: &Path, module: Option<&str>) -> Result { + let store = disk::open(workspace); + let Some(head) = store.head()? else { + return Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "entries": [], + "head": Value::Null, + })); + }; + let mut entries: Vec = Vec::new(); + for (ordinal, event) in causal_order(&store, &head)?.iter().enumerate() { + let transition_id = event_transition(&store, event)?; + let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; + if let Some(wanted) = module { + if transition["module"].as_str() != Some(wanted) { + continue; + } + } + entries.push(json!({ + "event": event, + "module": transition["module"], + "newState": transition["newState"], + "noteCount": attached_notes(&store, &transition_id)?.len() as u64, + "ordinal": ordinal as u64, + "referenceCount": transition_references(&store, &transition)?.len() as u64, + "target": transition["target"], + "transition": transition_id, + "writeSet": transition["writeSet"], + })); + } + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "entries": entries, + "head": head, + "inspect": { + "command": "napl show ", + "detail": "the full text of a transition's notes and reference entries is served by `napl show `, one named hop from any entry's transition hash", + }, + })) +} + +/// Describe any stored object: its kind and, for JSON kinds, its payload. +/// +/// A transition also lists the notes attached to it inline, hash and text, so a +/// note attached is a note findable: nobody should have to know that notes live +/// behind an index and are stored as blobs in order to read one back. +pub fn show(workspace: &Path, hash: &str) -> Result { + let store = disk::open(workspace); + let (kind, payload) = get_object(&store, hash)?; + let body = if kind == ObjectKind::Blob { + json!({ "bytes": payload.len() as u64 }) + } else { + serde_json::from_slice(&payload).map_err(|e| { + error( + OutcomeCode::StoreIntegrity, + format!("object {hash} is not JSON: {e}"), + ) + })? + }; + let mut out = json!({ + "body": body, + "contractVersion": crate::version::CONTRACT_VERSION, + "hash": hash, + "kind": kind.as_wire(), + }); + if kind == ObjectKind::Transition { + out["notes"] = Value::Array(attached_notes(&store, hash)?); + out["references"] = Value::Array(transition_references(&store, &out["body"])?); + } + Ok(out) +} + +/// The reference entries one transition introduced, served inline. +/// +/// A reference attached to a transition is exactly as findable as a note +/// attached to one: both are provenance riding a transition, and both are read +/// here rather than through plumbing. What this transition introduced is the +/// difference between its new state's attribution record and the record its base +/// state carried, so a commit that merely carried the module's existing +/// references forward lists none and a capture verb's transition lists the one it +/// recorded. A base that cannot be resolved degrades to the whole new record +/// rather than erroring, the way every other read here degrades. +fn transition_references(store: &Store, body: &Value) -> Result> { + let Some(new_state) = body["newState"].as_str() else { + return Ok(Vec::new()); + }; + let Some(record) = state_attribution_entries(store, new_state)? else { + return Ok(Vec::new()); + }; + let base = match body["baseStateKey"].as_str() { + Some(base) => state_attribution_entries(store, base)?.unwrap_or_default(), + None => Vec::new(), + }; + Ok(record + .into_iter() + .filter(|entry| !base.contains(entry)) + .map(|entry| entry.as_json()) + .collect()) +} + +/// The attribution entries a module state's record carries, or `None` when the +/// state names no record or the record is not readable. +fn state_attribution_entries( + store: &Store, + state_hash: &str, +) -> Result>> { + if !has_object(store, state_hash) { + return Ok(None); + } + let state = read_module_state(store, state_hash)?; + let Some(attribution) = state.attribution else { + return Ok(None); + }; + if !has_object(store, &attribution.record) { + return Ok(None); + } + let bytes = get_blob(store, &attribution.record)?; + Ok(crate::attribution::parse(&String::from_utf8_lossy(&bytes)).map(|record| record.entries)) +} + +/// The notes attached to one transition, each as its blob name and its text. +/// +/// The index is a plain list of blob names beside the store, so a missing or +/// unreadable index is simply no notes: a reader degrades to empty rather than +/// erroring, exactly as every other read here does. +fn attached_notes(store: &Store, transition: &str) -> Result> { + let index = store.root.join("notes").join(format!("{transition}.json")); + let bytes = match std::fs::read(&index) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(disk::io(&index, &e)), + }; + let names: Vec = serde_json::from_slice(&bytes).unwrap_or_default(); + let mut out: Vec = Vec::new(); + for name in names { + if !has_object(store, &name) { + continue; + } + let body = get_blob(store, &name)?; + let parsed: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); + out.push(json!({ + "hash": name, + "note": parsed["note"].as_str().unwrap_or_default(), + })); + } + Ok(out) +} + +/// The raw payload bytes of an object, with the envelope stripped. +pub fn cat_file(workspace: &Path, hash: &str) -> Result> { + Ok(get_object(&disk::open(workspace), hash)?.1) +} + +/// The recorded surface of a module as of HEAD. +/// +/// A module with no committed state under this target has no recorded surface at +/// all, and an empty item list would read as a real surface with nothing public. +/// The honest answer names the absent baseline and carries no items, exactly as +/// `diff` and `blame` answer for a missing baseline. +pub fn surface_of(workspace: &Path, target: &str, module: &str) -> Result { + let store = disk::open(workspace); + let Some(state) = committed_state(&store, target, module)? else { + return Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "module": module, + "reason": format!( + "module {module:?} has no committed state under target {target:?}, so there is no recorded surface to serve and no baseline to read one from" + ), + "target": target, + "tracked": false, + })); + }; + let items = read_surface_items(&store, &state.surface_hash)?; + let mut out = surface_json(module, target, &items); + if let Some(object) = out.as_object_mut() { + object.insert("tracked".to_string(), Value::Bool(true)); + } + Ok(out) +} + +/// Whether this workspace has a committed head at all. +/// +/// The one observation a command whose meaning requires a baseline makes before +/// it answers, so a headless workspace gets the prerequisite sequence instead of +/// an empty shape that would read as success. +pub fn has_committed_head(workspace: &Path) -> Result { + Ok(disk::open(workspace).head()?.is_some()) +} + +/// Drift between committed outputs and the working tree, as a canonical patch. +/// +/// A target no committed module owns anything under has no baseline, and the +/// honest answer is `tracked: false` with the reason: an empty patch would say +/// "nothing drifted" when the truth is "nothing was compared". +pub fn diff(workspace: &Path, target: &str) -> Result { + let store = disk::open(workspace); + let committed = committed_text(&store, target)?; + if committed.is_empty() { + return Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "reason": format!( + "no committed module owns any output under target {target:?}, so there is no baseline to diff against" + ), + "target": target, + "tracked": false, + })); + } + let paths: Vec = committed.keys().cloned().collect(); + let working = read_working_text(workspace, &paths); + let patch = render_drift(&committed, &working); + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "patch": patch, + "target": target, + "tracked": true, + })) +} + +/// Derived line ancestry for one committed path. +/// +/// A path no committed module owns has no ancestry to replay, and empty ancestry +/// would read as a file that exists and was never written. It answers +/// `tracked: false` naming the absent baseline instead, an honest success shape. +/// +/// Blame reads committed bytes only. When the working tree has moved away from +/// them the report carries a drift notice naming `napl diff` as where the +/// uncommitted change lives, so ancestry is never mistaken for the present file. +pub fn blame_path(workspace: &Path, path: &str) -> Result { + let store = disk::open(workspace); + let Some(head) = store.head()? else { + return Ok(untracked_json( + path, + "this workspace has no committed head, so no module owns this path yet", + )); + }; + if !committed_manifest_all_targets(&store)?.contains(path) { + return Ok(untracked_json( + path, + "no committed module owns this path, so there is no committed baseline to trace it through", + )); + } + let mut revisions: Vec = Vec::new(); + for event in causal_order(&store, &head)? { + let transition_id = event_transition(&store, &event)?; + let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; + let touched = transition["installPaths"] + .as_array() + .map(|paths| paths.iter().any(|p| p.as_str() == Some(path))) + .unwrap_or(false); + if !touched { + continue; + } + let Some(state_hash) = transition["newState"].as_str() else { + continue; + }; + let state = read_module_state(&store, state_hash)?; + let files = flatten_tree(&store, &state.output_tree)?; + let Some((blob, _)) = files.get(path) else { + continue; + }; + let bytes = get_blob(&store, blob)?; + revisions.push(Revision { + transition: transition_id, + content: String::from_utf8_lossy(&bytes).into_owned(), + }); + } + let mut value = blame_json(path, &blame(&revisions)); + let committed = revisions.last().map(|revision| revision.content.clone()); + let working = std::fs::read(workspace.join(path)).ok(); + let working_text = working + .as_deref() + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()); + let drifted = match (&committed, &working_text) { + (Some(committed), Some(working)) => committed != working, + _ => false, + }; + if let Some(object) = value.as_object_mut() { + object.insert("drift".to_string(), Value::Bool(drifted)); + if drifted { + object.insert( + "driftNotice".to_string(), + Value::String(format!( + "the working tree of {path:?} differs from the committed output; this ancestry is over the committed bytes only, and the uncommitted change lives in `napl diff`" + )), + ); + object.insert("driftInspect".to_string(), Value::String("napl diff".to_string())); + } + } + Ok(value) +} + +/// The source-language scan `napl lint ` performs: parse every +/// committed module's owned working-tree file under that target with the pinned +/// grammar and report one finding per file that fails. +/// +/// This is a different question from the graph analyses beside it and it needs no +/// roots declaration to answer, because nothing about reachability is involved. +/// The payload names its scope, so an empty findings list is never read wider +/// than the scan that produced it. +pub fn source_syntax_scan(workspace: &Path, target_name: &str) -> Result { + let store = disk::open(workspace); + let target = builtin_target(target_name)?; + let sources = discover_sources(workspace)?; + let roots: BTreeSet = sources + .iter() + .filter(|source| source.targets.iter().any(|t| t == target_name)) + .map(placement_root) + .collect(); + let mut findings: Vec = Vec::new(); + let mut scanned = 0u64; + for path in committed_manifest(&store, target_name)?.into_keys() { + if !roots + .iter() + .any(|root| crate::surface::is_target_source(&target, root, &path)) + { + continue; + } + let Ok(bytes) = std::fs::read(workspace.join(&path)) else { + continue; + }; + scanned += 1; + if let Some(failure) = crate::surface::parse_check(&target, &path, &bytes)? { + findings.push(json!({ + "autoFixable": false, + "file": failure.path, + "kind": "source-parse-error", + "line": failure.line, + "parseFailure": failure.as_json(), + "proposal": format!( + "fix the syntax at line {} of {:?}; the pinned grammar cannot parse it, so no surface can be extracted from it", + failure.line, failure.path + ), + "scope": crate::surface::SOURCE_SYNTAX_SCOPE, + })); + } + } + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "detail": "every committed module's owned working-tree file under this target was parsed with the target's pinned grammar; this scan judges syntax and nothing else, and it needs no roots declaration", + "filesScanned": scanned, + "findings": findings, + "rootsRequired": false, + "scope": crate::surface::SOURCE_SYNTAX_SCOPE, + "target": target_name, + })) +} + +/// Diagnose a workspace. +pub fn lint_workspace(workspace: &Path, target: &str) -> Result<(Graph, LintReport, Value)> { + let store = disk::open(workspace); + let sources = discover_sources(workspace)?; + let artifact = compile(&store, &sources, target, None)?; + let graph = crate::compile::graph_for(&sources, target)?; + let definition = builtin_target(target)?; + let states = match store.head()? { + Some(snapshot) => snapshot_states(&store, &snapshot)?, + None => BTreeMap::new(), + }; + + let mut committed_input_keys: BTreeMap> = BTreeMap::new(); + let mut referenced_names: BTreeMap> = BTreeMap::new(); + let mut surface_names: BTreeMap> = BTreeMap::new(); + for module in graph.keys() { + let (state, _) = state_of(&store, &states, &definition, module)?; + committed_input_keys.insert(module.clone(), state.input_key.clone()); + surface_names.insert( + module.clone(), + read_surface_items(&store, &state.surface_hash)? + .iter() + .filter_map(|item| item.path.rsplit("::").next().map(str::to_string)) + .collect(), + ); + referenced_names.insert(module.clone(), identifiers(&store, &state)?); + } + let current_input_keys: BTreeMap = artifact + .slices + .iter() + .map(|slice| (slice.module.clone(), slice.input_key.clone())) + .collect(); + + let roots = declared_roots(&sources); + let report = lint( + &graph, + &LintInput { + roots, + committed_input_keys, + current_input_keys, + referenced_names, + surface_names, + }, + ); + let value = lint_json(&report); + Ok((graph, report, value)) +} + +/// Cost over an explicit set: receipt object names in the store, receipt file +/// paths, usage-proxy session windows, or any mixture. +/// +/// A session window is priced here, lazily, at read time: a capture verb records +/// the window as a reference and never aggregates it, so the log lines are summed +/// only when a reader asks. A model the pinned table does not know stays +/// `unknown`, never zero. +pub fn cost( + workspace: &Path, + names: &[String], + files: &[String], + sessions: &[String], +) -> Result { + let store = disk::open(workspace); + let mut lines: Vec = Vec::new(); + for session in sessions { + lines.extend(crate::cost::lines_from_session( + &std::fs::read_to_string(workspace.join(crate::capture::USAGE_LOG_PATH)) + .unwrap_or_default(), + session, + )); + } + for name in names { + let body = get_json(&store, name, ObjectKind::Receipt)?; + lines.push(receipt_line(workspace, name, &body)?); + } + for file in files { + let path = Path::new(file); + let bytes = std::fs::read(path).map_err(|e| disk::io(path, &e))?; + let body: Value = serde_json::from_slice(&bytes).map_err(|e| { + error( + OutcomeCode::InvalidReceipt, + format!("{file}: invalid JSON: {e}"), + ) + })?; + let name = crate::canon::json_hash(ObjectKind::Receipt, &body); + lines.push(receipt_line(workspace, &name, &body)?); + } + Ok(cost_report(&lines)) +} + +/// One receipt's cost line: a reported total priced directly, a recorded session +/// window priced against the log the record names. +fn receipt_line(workspace: &Path, name: &str, body: &Value) -> Result { + let module = body["module"].as_str().unwrap_or_default(); + let reported = normalize_reported(&body["reported"]); + match crate::cost::window_from_reported(&reported) { + Some(window) => { + let log = window_log(workspace, &window.log_path); + Ok(crate::cost::line_from_window( + name, + module, + &window, + log.as_deref(), + )) + } + None => line_from_reported(name, module, &reported), + } +} + +/// The bytes of a log a record names, read at the moment the reader asks. +/// +/// The path is a record's own workspace-relative path, so a path that escapes +/// the workspace or is absolute is not a log this reader will open: it reads as +/// absent, which the report already knows how to say. +fn window_log(workspace: &Path, log_path: &str) -> Option { + let candidate = Path::new(log_path); + if candidate.is_absolute() || log_path.split('/').any(|part| part == "..") { + return None; + } + std::fs::read_to_string(workspace.join(candidate)).ok() +} + +/// Sweep every object no named head reaches, and the staged records beside them. +/// +/// The sweep set is computed outside the lock; only the deletions run under it, +/// and a writer publishing concurrently is never blocked because publication +/// takes no lock at all. +/// +/// Staged records belong in this report because they are exactly what the +/// git-state law calls disposable litter: never promoted to history, re-creatable +/// by re-running `add`, and reachable from no head. `--apply` sweeps them, so +/// nobody ever hand-edits `.napl` to be rid of one. +pub fn gc(workspace: &Path, apply: bool) -> Result { + let store = disk::init(workspace)?; + let all: BTreeSet = store.names()?.into_iter().collect(); + let reachable = match store.head()? { + Some(head) => reachable_from_head(&store, &head)?, + None => BTreeSet::new(), + }; + let swept = plan_sweep(&all, &reachable); + let staged: Vec = crate::staging::staged_records(workspace)? + .into_iter() + .map(|record| record.relative) + .collect(); + let repacked = if apply { + disk::repack(&store, &reachable, "gc")? + } else { + disk::plan_repack(&store, &reachable)? + }; + if apply { + for name in &swept { + disk::remove_object(&store, name)?; + } + for record in &staged { + let dir = workspace.join(record); + match std::fs::remove_dir_all(&dir) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(disk::io(&dir, &e)), + } + } + } + let mut payload = gc_json(&swept, all.len() - swept.len()); + payload["stagedRecords"] = json!(staged); + let mut repack = crate::maintenance::repack_json(&repacked); + repack["applied"] = json!(apply); + payload["repack"] = repack; + Ok(payload) +} + +/// The exact command the owner runs to take the migrated object files out of +/// the working tree. It is theirs to run, not the toolchain's: removing them +/// from the index is an ordinary git commit and never a store mutation. +pub const STORE_MIGRATE_REMOVAL: &str = "git rm -r .napl/store/objects"; + +/// `napl store migrate`: the one way `refs/heads/napl-store` comes to exist. +/// +/// It walks the tracked store's loose objects and every pack into git blobs, +/// re-hashing each envelope to the napl name it must keep, builds one fanout +/// tree and one deterministic commit over them, and creates the branch there. It +/// refuses outside a git repository and refuses when the branch already exists, +/// and it touches no tracked file: the payload names the object files as ready +/// to leave the working tree and the exact command that does it, because that +/// removal is the owner's ordinary git commit. +/// +/// After it, selection observes the branch and every later read and write goes +/// through the git provider with no flag set anywhere. +pub fn store_migrate(workspace: &Path) -> Result<(bool, Value)> { + let store = disk::open(workspace); + let Some(repository) = napl_store::git_store::enclosing_repository(workspace) else { + return Ok(( + false, + migrate_refusal( + "not-a-git-repository", + "this workspace is not inside a git repository, so there is no object database to migrate into; the tracked layout with its packfile mitigation stays the floor", + ), + )); + }; + let provider = napl_store::git_store::GitProvider::open(&repository, &store.root) + .map_err(disk::git_error)?; + if provider.tip().map_err(disk::git_error)?.is_some() { + return Ok(( + false, + migrate_refusal( + "already-migrated", + "refs/heads/napl-store already exists, and migration is a one-shot verb that never rewrites a store branch", + ), + )); + } + + let tracked = tracked_object_files(&store.root)?; + let outcome = provider + .convert_tracked_store(&store.root, crate::version::CONTRACT_VERSION) + .map_err(disk::git_error)?; + + Ok(( + true, + json!({ + "branch": napl_store::git_store::STORE_BRANCH, + "commit": outcome.commit, + "contractVersion": crate::version::CONTRACT_VERSION, + "objects": outcome.objects, + "outcome": "migrated", + "readyToLeaveWorkingTree": { + "detail": "migration touched none of these: removing them from the index is your ordinary git commit, not a store mutation", + "files": tracked, + "removalCommand": STORE_MIGRATE_REMOVAL, + "root": format!("{}/objects", disk::STORE_DIR), + }, + "snapshot": outcome.snapshot, + }), + )) +} + +/// How many files the tracked object layout holds under the store root: every +/// loose object file, every pack, and every pack index. +fn tracked_object_files(root: &Path) -> Result { + let mut count = 0; + let mut stack = vec![root.join("objects")]; + while let Some(directory) = stack.pop() { + let entries = match std::fs::read_dir(&directory) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(disk::io(&directory, &e)), + }; + for entry in entries { + let entry = entry.map_err(|e| disk::io(&directory, &e))?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + count += 1; + } + } + } + Ok(count) +} + +/// A migration refusal: the reason named, nothing done, nothing written. +fn migrate_refusal(reason: &str, detail: &str) -> Value { + json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "detail": detail, + "outcome": "refused", + "reason": reason, + }) +} + +/// Verify envelope hashes, kind validity, causal parent links, and every pack's +/// name, index, and members. +pub fn fsck_store(workspace: &Path) -> Result<(bool, Value)> { + let store = disk::open(workspace); + let mut findings = fsck(&store)?; + findings.extend(disk::verify_packs(&store)?.into_iter().map(pack_finding)); + findings.sort(); + findings.dedup(); + Ok((findings.is_empty(), fsck_json(&findings))) +} + +/// Translate one store-level pack finding into the compiler's fsck vocabulary. +fn pack_finding(finding: napl_store::store_io::PackFinding) -> crate::maintenance::FsckFinding { + use napl_store::store_io::PackFinding as Pack; + let (pack, detail) = match finding { + Pack::NameMismatch { pack } => { + let detail = "the pack's bytes do not hash to its name".to_string(); + (pack, detail) + } + Pack::IndexDisagrees { pack, detail } => (pack, detail), + Pack::MemberCorrupt { pack, object } => { + let detail = format!("packed object {object} does not verify against its name"); + (pack, detail) + } + }; + crate::maintenance::FsckFinding::PackDamaged { pack, detail } +} + +/// Repack the store when its loose-object count crosses the compiled-in bound. +/// +/// Called after a successful commit's critical section and outside every lock: +/// the pointer has already moved, so a repack here never lengthens the bounded +/// section and never blocks a writer. It packs the same reachable closure `gc +/// --apply` packs, so the pack it writes is byte-identical to gc's. +pub fn repack_if_loose(store: &Store) -> Result> { + if disk::loose_object_count(store)? <= crate::maintenance::LOOSE_OBJECT_REPACK_THRESHOLD { + return Ok(None); + } + let reachable = match store.head()? { + Some(head) => reachable_from_head(store, &head)?, + None => return Ok(None), + }; + let outcome = disk::repack(store, &reachable, "gc")?; + if outcome.pack.is_none() { + return Ok(None); + } + Ok(Some(crate::maintenance::repack_json(&outcome))) +} + +/// Derive a repair plan for a target's committed outputs, and optionally apply +/// it. +pub fn restore(workspace: &Path, target: &str, apply: bool) -> Result { + let store = disk::open(workspace); + let expected: Vec = committed_manifest(&store, target)? + .into_iter() + .map(|(path, hash)| FileFact { path, hash }) + .collect(); + let roots: BTreeSet = expected + .iter() + .filter_map(|fact| fact.path.split('/').next().map(str::to_string)) + .collect(); + let observed = observed_facts(workspace, &roots)?; + let plan = plan_restore(&expected, &observed); + if apply { + if let RestorePlan::Apply(steps) = &plan { + apply_restore(&store, workspace, steps)?; + } + } + Ok(restore_json(&plan)) +} + +/// Attach later provenance to a recorded transition without touching semantic +/// state. +/// The refusal a `--to` that is not an object name earns. +/// +/// A working path is the mistake a reader makes here, because every other verb +/// takes one, so the refusal does not merely say no: it names the two commands +/// that produce and inspect an object name and shows the one line that gets one. +fn not_an_object_name(given: &str) -> crate::error::CompilerError { + error( + OutcomeCode::Usage, + format!( + "--to expects the object name of a recorded transition, and {given:?} is not one. \ + Object names come from `napl log`, and `napl show ` inspects one, \ + for example: napl log | napl q '.entries[-1].transition' -" + ), + ) +} + +pub fn add_note(workspace: &Path, transition: &str, note: &str) -> Result { + let store = disk::init(workspace)?; + if !is_object_name(transition) || !has_object(&store, transition) { + return Err(not_an_object_name(transition)); + } + get_json(&store, transition, ObjectKind::Transition)?; + let body = json!({ "note": note, "transition": transition }); + let payload = crate::canon::canonical_bytes(&body); + let name = disk::publish(&store, ObjectKind::Blob, &payload, transition)?; + + let index = store.root.join("notes").join(format!("{transition}.json")); + let mut names: BTreeSet = match std::fs::read(&index) { + Ok(bytes) => serde_json::from_slice::>(&bytes) + .unwrap_or_default() + .into_iter() + .collect(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(), + Err(e) => return Err(disk::io(&index, &e)), + }; + names.insert(name.clone()); + let listed: Vec = names.iter().map(|n| json!(n)).collect(); + crate::workspace::write_file(&index, &crate::canon::canonical_bytes(&Value::Array(listed)))?; + Ok(json!({ + "note": name, + "outcome": "success", + "semanticStatePublished": false, + "transition": transition, + })) +} + +/// The events reachable from a head, ordered oldest first by causal depth. +pub fn causal_order(store: &Store, head: &str) -> Result> { + let head_event = snapshot_event(store, head)?; + let events = walk_events(store, &head_event, None)?; + let mut depths: BTreeMap = BTreeMap::new(); + for event in &events { + depth_of(store, event, &mut depths)?; + } + let mut ordered = events; + ordered.sort_by(|a, b| (depths[a], a).cmp(&(depths[b], b))); + Ok(ordered) +} + +fn depth_of(store: &Store, event: &str, depths: &mut BTreeMap) -> Result { + if let Some(known) = depths.get(event) { + return Ok(*known); + } + let value = get_json(store, event, ObjectKind::Event)?; + let mut depth = 0usize; + for parent in value["parents"].as_array().cloned().unwrap_or_default() { + if let Some(parent) = parent.as_str() { + depth = depth.max(depth_of(store, parent, depths)? + 1); + } + } + depths.insert(event.to_string(), depth); + Ok(depth) +} + +pub fn committed_state(store: &Store, target: &str, module: &str) -> Result> { + Ok(committed_state_at(store, target, module)?.map(|(_, state)| state)) +} + +/// A module's committed state beside the name the snapshot knows it by. +/// +/// The name comes off the snapshot rather than being recomputed from the +/// deserialized value, so a caller naming this state as the base of a transition +/// names the object the store actually holds. Re-hashing a value that has been +/// through a round trip would answer a different name the moment a field's +/// serialization ever moved, and a base nobody can find is a wedge. +pub fn committed_state_at( + store: &Store, + target: &str, + module: &str, +) -> Result> { + let Some(head) = store.head()? else { + return Ok(None); + }; + let states = snapshot_states(store, &head)?; + match states.get(&(target.to_string(), module.to_string())) { + Some(hash) => Ok(Some((hash.clone(), read_module_state(store, hash)?))), + None => Ok(None), + } +} + +/// The materialized attribution view a module committed last, if any. +/// +/// The bytes are the record blob, which is the view itself: an internal +/// re-commit reads them to carry the mapping forward rather than fabricating a +/// new one. +pub fn committed_attribution_yaml( + store: &Store, + target: &str, + module: &str, +) -> Result>> { + let Some(state) = committed_state(store, target, module)? else { + return Ok(None); + }; + match state.attribution { + Some(attribution) => Ok(Some(get_blob(store, &attribution.record)?)), + None => Ok(None), + } +} + +/// The committed `path -> blob` manifest of every module under a target. +pub fn committed_manifest(store: &Store, target: &str) -> Result> { + let Some(head) = store.head()? else { + return Ok(BTreeMap::new()); + }; + let mut out = BTreeMap::new(); + for ((state_target, _), hash) in snapshot_states(store, &head)? { + if state_target != target { + continue; + } + let state = read_module_state(store, &hash)?; + for (path, (blob, _)) in flatten_tree(store, &state.output_tree)? { + out.insert(path, blob); + } + } + Ok(out) +} + +/// Whether a token is shaped like a store object name at all: the lowercase hex +/// of a SHA-256, and nothing else. A workspace path fails on the first separator. +fn is_object_name(given: &str) -> bool { + given.len() == 64 && given.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Every path any committed module owns, across every target: the set that +/// decides whether a path is tracked at all. +fn committed_manifest_all_targets(store: &Store) -> Result> { + let mut out: BTreeSet = BTreeSet::new(); + let Some(head) = store.head()? else { + return Ok(out); + }; + for (_, hash) in snapshot_states(store, &head)? { + let state = read_module_state(store, &hash)?; + for (path, _) in flatten_tree(store, &state.output_tree)? { + out.insert(path); + } + } + Ok(out) +} + +fn committed_text(store: &Store, target: &str) -> Result> { + let mut out = BTreeMap::new(); + for (path, blob) in committed_manifest(store, target)? { + let bytes = get_blob(store, &blob)?; + out.insert(path, String::from_utf8_lossy(&bytes).into_owned()); + } + Ok(out) +} + +/// Every identifier a module's committed output names. +/// +/// This is a token scan, not name resolution: lint says a surface entry looks +/// unconsumed, and says so as a proposal, precisely because it cannot prove it. +fn identifiers(store: &Store, state: &ModuleState) -> Result> { + let mut out: BTreeSet = BTreeSet::new(); + if !has_object(store, &state.output_tree) { + return Ok(out); + } + for (_, (blob, _)) in flatten_tree(store, &state.output_tree)? { + let bytes = get_blob(store, &blob)?; + let text = String::from_utf8_lossy(&bytes).into_owned(); + for token in text.split(|c: char| !(c.is_alphanumeric() || c == '_')) { + if !token.is_empty() { + out.insert(token.to_string()); + } + } + } + Ok(out) +} + +fn declared_roots(sources: &[crate::source::SourceModule]) -> Option> { + let mut declared: Option> = None; + for source in sources { + if let Some(roots) = &source.roots { + declared + .get_or_insert_with(BTreeSet::new) + .extend(roots.iter().cloned()); + } + } + declared.map(|set| set.into_iter().collect()) +} + +fn observed_facts(workspace: &Path, roots: &BTreeSet) -> Result> { + let mut out: Vec = Vec::new(); + for root in roots { + let dir = workspace.join(root); + if !dir.is_dir() { + continue; + } + for entry in crate::workspace::scan_candidate(&dir)? { + if entry.kind != crate::candidate::EntryKind::File { + continue; + } + out.push(FileFact { + path: crate::paths::join_root(root, &entry.path), + hash: napl_store::store_hash::object_name(ObjectKind::Blob, &entry.bytes), + }); + } + } + out.sort(); + Ok(out) +} + +fn apply_restore( + store: &Store, + workspace: &Path, + steps: &[crate::maintenance::RestoreStep], +) -> Result<()> { + use crate::maintenance::RestoreStep; + for step in steps { + match step { + RestoreStep::Move { from, to } => { + let target = workspace.join(to); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|e| disk::io(parent, &e))?; + } + std::fs::rename(workspace.join(from), &target) + .map_err(|e| disk::io(&target, &e))?; + } + RestoreStep::Write { path, hash } => { + let bytes = get_blob(store, hash)?; + crate::workspace::write_file(&workspace.join(path), &bytes)?; + } + RestoreStep::Remove { path } => { + let target = workspace.join(path); + match std::fs::remove_file(&target) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(disk::io(&target, &e)), + } + } + } + } + Ok(()) +} +napl-store receipt v1 815 +{"baseStateKey":"9d5b22e0c8befdc44bcf7eedf973b53e8b03468a904eed0306369785f87283b1","baseStoreSnapshot":"d512c3e78967e6a3e2750ea7311592d5210d24fc0aa8ca6dd7e360c985778cea","baseWorkspaceHash":"d1e85c3abcbc49cf3afa118c54102e3f052a84fc24b01cab46c246ea1644693c","candidateTreeHash":"d83d2467beb91b948fff71e8ad6912f421053f3c456f4ef0d652253c7a1b8ce7","contractVersion":4,"inputKey":"80d6c286c5a0ed57bf1e618663be6a809568faa305d8c6fcba677a9f1ff461d5","module":"commands","outputManifest":[{"hash":"cb4a371433bd6721613afa223fb5e3fd962eb9d58272937e6b632c21a0a351be","path":"crates/napl/src/init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","path":"crates/napl/src/watch.rs"}],"receiptVersion":1,"sliceHash":"e5e5114ea27c93228aef1fcbd0331a002c8f0c3b074c922431556fcc4a573af8","target":"rust"} +napl-store transition v1 959 +{"baseStateKey":"2f4def880281f987518186a75836278fd0c2286ca7cd8a63094c9467bc92f5d6","contractVersion":4,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"85d737876ae3fc250cbc36211bc65a89f07d702ebf8116091bf855a7f0e48e33","installPaths":["crates/napl/src/check.rs","crates/napl/src/docs.rs","crates/napl/src/document.rs","crates/napl/src/promptbuild.rs","crates/napl/src/query.rs"],"module":"query","newState":"accaf93691b84c6794df99c7643c710529c765f12cc9faedede4b6b598a8c6b0","readSet":["base-state:2f4def880281f987518186a75836278fd0c2286ca7cd8a63094c9467bc92f5d6","input-key:85d737876ae3fc250cbc36211bc65a89f07d702ebf8116091bf855a7f0e48e33","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"b1884c0dda826ee579c0cedf56ec34289dd945e1f551f31a6563c11fbcd71d8c","surface":"37985e1e1289f96f00be156b4fff0e75a2b00894f5ef2e9816ddca84e594c486","target":"rust","writeSet":["owned:crates/napl","module-state:rust/query"]} +napl-store blob v1 12659 +//! The store shell: the compiler's read seam and write path, over real bytes. +//! +//! Every syscall in this file is one `napl-store` already knows how to make. +//! Publication, the HEAD compare-and-set, module-keyed locks with dead-holder +//! steal, and commit markers are the store's; this module only points them at a +//! store root and translates their failures into the compiler's outcome +//! vocabulary. +//! +//! There is one object layout and no backend to choose: the tracked files under +//! the store root, loose until a repack folds them into a pack. A workspace +//! whose repository still carries the retired `refs/heads/napl-store` is a +//! legacy clone, and the skeleton this file creates is where that refusal is +//! raised, before any verb has written a byte. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use crate::error::{error, CompilerError, OutcomeCode, Result}; +use crate::store_view::ObjectSource; +use napl_store::store_hash::ObjectKind; +use napl_store::store_io::{self, LockGuard, PackFinding, RepackOutcome, StoreIoError}; +use napl_store::store_txn_core::CommitMarker; + +/// The store directory, relative to the workspace root. +pub const STORE_DIR: &str = ".napl/store"; + +/// The lock guarding the pointer swap and the install. +/// +/// Deliberately not `HEAD`: the store's own compare-and-set takes `HEAD` for the +/// duration of its read-compare-write, and holding it here would deadlock +/// against that. +pub const POINTER_LOCK: &str = "pointer"; + +/// The lock guarding the object layout while a repack rewrites it. +/// +/// Deliberately not the pointer lock: a repack moves no pointer and must not +/// contend with the bounded critical section, so this names the one thing it +/// does own, which files the object layout holds. +pub const OBJECTS_LOCK: &str = "objects"; + +/// A store rooted under one workspace. +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub struct Store { + pub workspace: PathBuf, + pub root: PathBuf, +} + +/// Point at a store without touching the filesystem. +pub fn open(workspace: &Path) -> Store { + Store { + workspace: workspace.to_path_buf(), + root: workspace.join(STORE_DIR), + } +} + +/// Point at a store, creating its directory skeleton. +/// +/// This is the door every store-writing verb passes through, so it is where a +/// legacy clone is turned away: a repository still carrying the retired store +/// ref is refused here, before a lock is taken or an object is published. +pub fn init(workspace: &Path) -> Result { + crate::git::guard_legacy_store_ref(workspace)?; + let store = open(workspace); + for dir in ["objects", "locks", "markers", "attempts", "tmp", "notes"] { + let path = store.root.join(dir); + std::fs::create_dir_all(&path).map_err(|e| io(&path, &e))?; + } + Ok(store) +} + +impl ObjectSource for Store { + fn envelope(&self, name: &str) -> Result>> { + store_io::read_object(&self.root, name).map_err(store_error) + } + + /// napl HEAD, the tracked file. + /// + /// Two code branches legitimately hold two different snapshots, so HEAD is + /// branch-local state like every other file in the working tree and travels + /// with the checkout that wrote it. That is also the one file a merge can + /// conflict on, so a value that is not an object name is refused as store + /// integrity naming the likely cause: the `.gitattributes` line init writes + /// marks HEAD binary precisely so a merge holds it unmerged rather than + /// writing conflict markers into it, and resolving is choosing a side. + fn head(&self) -> Result> { + let head = store_io::read_head(&self.root).map_err(store_error)?; + match &head { + Some(value) if !crate::canon::is_object_name(value) => Err(error( + OutcomeCode::StoreIntegrity, + format!( + "{}/HEAD holds {value:?}, which is not a snapshot name; if a merge wrote it, choose the snapshot this branch is on and write that hash alone, because a merge's store holds both sides' objects and either side resolves", + STORE_DIR + ), + )), + _ => Ok(head), + } + } + + /// Every name the store holds: the loose files plus the packs. A packed + /// object is present exactly as a loose one is, and an object held both ways + /// is counted once. + fn names(&self) -> Result> { + let mut names = store_io::loose_object_names(&self.root).map_err(store_error)?; + names.extend(store_io::packed_object_names(&self.root).map_err(store_error)?); + Ok(names.into_iter().collect()) + } +} + +/// How many loose object files the store holds, the count that decides whether +/// the automatic repack has anything to do. +pub fn loose_object_count(store: &Store) -> Result { + store_io::loose_object_count(&store.root).map_err(store_error) +} + +/// Pack the reachable closure into one pack, deleting the loose files it packed, +/// the loose files no head reaches, and every older pack once the new one is +/// durable. +/// +/// It runs under the objects lock, which buys exactly one thing: two repacks, or +/// a repack and a sweeping gc, never rewrite the layout at once. Publication +/// takes no lock, so a publisher racing a repack can still lose a just-written +/// loose object before any snapshot names it; that window is open by design and +/// closes only when the store learns a waiting lock. +/// +/// The count of what it swept is read inside the lock, so the number reported is +/// the number of files this repack actually removed rather than one another +/// writer could have moved under it. +pub fn repack( + store: &Store, + reachable: &BTreeSet, + owner: &str, +) -> Result<(RepackOutcome, usize)> { + let guard = take_stealable_lock(store, OBJECTS_LOCK)?; + let counted = store_io::loose_object_names(&store.root) + .map_err(store_error) + .map(|loose| loose.iter().filter(|name| !reachable.contains(*name)).count()); + let outcome = store_io::repack(&store.root, reachable, owner).map_err(store_error); + release_lock(guard)?; + Ok((outcome?, counted?)) +} + +/// The pack a repack over `reachable` would write, without touching the store. +pub fn plan_repack(store: &Store, reachable: &BTreeSet) -> Result { + store_io::plan_repack(&store.root, reachable).map_err(store_error) +} + +/// Every pack finding the store's packs carry. +pub fn verify_packs(store: &Store) -> Result> { + store_io::verify_packs(&store.root).map_err(store_error) +} + +/// Publish one object; publishing an existing object is an idempotent no-op and +/// a byte mismatch under an existing name is a loud store-integrity failure. +pub fn publish(store: &Store, kind: ObjectKind, payload: &[u8], owner: &str) -> Result { + store_io::publish_object(&store.root, kind, payload, owner).map_err(store_error) +} + +/// Move HEAD forward only from the value the caller observed. +/// +/// The compare-and-swap is the tracked file's, with the decision table behind +/// it, and it is the whole of the pointer move. +pub fn compare_and_set_head(store: &Store, expected: Option<&str>, next: &str) -> Result<()> { + store_io::compare_and_set_head(&store.root, expected, next).map_err(store_error) +} + +/// Take a lock, mapping a live holder to `lock-contended`. +/// +/// The liveness probe always answers "alive" and the host is the empty string: +/// neither a pid table nor a hostname may decide a semantic outcome, so a held +/// lock is simply contended and the caller retries. +pub fn take_lock(store: &Store, name: &str) -> Result { + acquire(store, name, &|_| true) +} + +/// Take a lock whose abandoned copy is stolen rather than waited on. +/// +/// Reserved for the objects lock, and lawful only there: it holds no invariant a +/// crashed holder could corrupt, because the pack it guards is staged under a +/// temporary name and linked into place, so a holder that died mid-repack left +/// nothing but a file nobody references. Asking whether the writer is still +/// running therefore decides no semantic outcome, only whether a dead lock is a +/// wedge, and it must not be one. +pub fn take_stealable_lock(store: &Store, name: &str) -> Result { + acquire(store, name, &process_is_alive) +} + +fn acquire(store: &Store, name: &str, is_alive: &dyn Fn(u64) -> bool) -> Result { + store_io::acquire_lock(&store.root, name, "", is_alive).map_err(|e| match &e { + StoreIoError::Io(detail) if detail.starts_with("lock held:") => error( + OutcomeCode::LockContended, + format!("lock {name:?} is held by another operation"), + ), + _ => store_error(e), + }) +} + +/// Whether the process that wrote a lock file is still running. +/// +/// The null signal is the whole probe: it asks the kernel about the process and +/// delivers nothing to it. A process this one may not signal is still a process, +/// so a permission refusal reads as alive. +#[cfg(unix)] +fn process_is_alive(pid: u64) -> bool { + if pid == 0 { + return true; + } + let Ok(pid) = i32::try_from(pid) else { + return true; + }; + if unsafe { libc::kill(pid, 0) } == 0 { + return true; + } + std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +/// Where the null signal does not exist, nothing is ever stolen. A wedge is the +/// worse outcome, but guessing a holder dead is the unrecoverable one. +#[cfg(not(unix))] +fn process_is_alive(_pid: u64) -> bool { + true +} + +/// Release a held lock. +pub fn release_lock(guard: LockGuard) -> Result<()> { + store_io::release_lock(guard).map_err(store_error) +} + +/// Write the commit marker that makes the pointer swap recoverable. +pub fn write_marker(store: &Store, marker: &CommitMarker) -> Result<()> { + store_io::write_commit_marker(&store.root, marker).map_err(store_error) +} + +/// Read a commit marker back. +pub fn read_marker(store: &Store, snapshot: &str) -> Result> { + store_io::read_commit_marker(&store.root, snapshot).map_err(store_error) +} + +/// Drop a commit marker whose work is durably applied. +pub fn delete_marker(store: &Store, snapshot: &str) -> Result<()> { + store_io::delete_commit_marker(&store.root, snapshot).map_err(store_error) +} + +/// Every commit marker the store holds, in ascending snapshot order. +pub fn marker_snapshots(store: &Store) -> Result> { + let dir = store.root.join("markers"); + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(io(&dir, &e)), + }; + let mut out: Vec = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| io(&dir, &e))?; + let name = entry.file_name().to_string_lossy().into_owned(); + if let Some(stem) = name.strip_suffix(".json") { + out.push(stem.to_string()); + } + } + out.sort(); + Ok(out) +} + +/// Delete one object, used only by a sweep that already proved it unreachable. +pub fn remove_object(store: &Store, name: &str) -> Result<()> { + let path = object_path(&store.root, name); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(io(&path, &e)), + } +} + +fn object_path(root: &Path, name: &str) -> PathBuf { + if name.len() < 3 { + return root.join("objects").join(name); + } + root.join("objects").join(&name[..2]).join(&name[2..]) +} + +/// Wrap a filesystem failure as an `io-error` outcome, naming the path. +pub fn io(path: &Path, err: &std::io::Error) -> CompilerError { + error( + OutcomeCode::IoError, + format!("{}: {err}", path.display()), + ) +} + +fn store_error(err: StoreIoError) -> CompilerError { + match err { + StoreIoError::Io(detail) => error(OutcomeCode::IoError, detail), + StoreIoError::Integrity(name) => error( + OutcomeCode::StoreIntegrity, + format!("object {name} failed its integrity check"), + ), + StoreIoError::HeadConflict { expected, found } => error( + OutcomeCode::LockContended, + format!( + "head moved under the pointer lock: expected {}, found {}", + expected.as_deref().unwrap_or("none"), + found.as_deref().unwrap_or("none") + ), + ), + StoreIoError::Corrupt(key) => error( + OutcomeCode::StoreIntegrity, + format!("corrupt store marker {key}"), + ), + } +} +napl-store receipt v1 1280 +{"baseStateKey":"accaf93691b84c6794df99c7643c710529c765f12cc9faedede4b6b598a8c6b0","baseStoreSnapshot":"653c03e84a35c5b5456fac1a327f8a29ebcc4c19d96ee0f5e166aa3c73d5b7d4","baseWorkspaceHash":"1cbd0f8a55437d59751b61ddc91d2290912cf5b52204af9c179365134de2e5f3","candidateTreeHash":"05f44ac11e9808b57f775c0ffedd617fc59c8a13cef9839ed551a535e36b7148","contractVersion":4,"inputKey":"a3af18b0602f5c451e31dde831d3640aaf42c3d650c51f4b679b6dd8aa24ff6f","module":"query","outputManifest":[{"hash":"deff13703dd0b3c630077edaffafc1fa9f2fdfde297b9a84b45841a428715965","path":"crates/napl/src/check.rs"},{"hash":"7c26384c4adc5b5c857d0811275db450218c8f05177ae35a898ec59a5a5a5e03","path":"crates/napl/src/docs.rs"},{"hash":"726a20b8361501f4c26633cd92835ffb2e5f1acd584c0af76895d29bf620a7b1","path":"crates/napl/src/document.rs"},{"hash":"5ec2c2137f45d02c8e8bf78ecf0652966850772097b41f3835a99116f3b78f57","path":"crates/napl/src/promptbuild.rs"},{"hash":"be629fe4418f4226c3119cf486d936741c11b644241c9c358df2172131c722c1","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"reported":{"evidence":"unavailable","gates":"unavailable","notes":"unavailable","producer":"unavailable","usage":"unavailable"},"sliceHash":"21a0afb6d5668b4db3969637babc0ca89e99d9330e65f1b1e746facc1ba809e4","target":"rust"} +napl-store tree v1 130 +{"entries":[{"hash":"dfc2adefb687a9f0243769321f66f0b4d203dd2aee2ef525da1ec9a86dedaa05","kind":"dir","mode":16384,"name":"napl"}]} +napl-store module-state v1 559 +{"attribution":{"promptHash":"e1d3d8958ac3bb2bdcd6896cd27d2a494c86d5b58d7f066d4d61cc33cb85099b","record":"0733317c8f30d785be80fae7a227e8fbc09360a50e084afc86d2c6771f9c135c","stale":false},"contractVersion":4,"inputKey":"9c5c5537326102883d9375da90c05e6a6b0374fed0463417ef20c1e3e0ba83ab","module":"query","outputTree":"9853f46abd1fcdca3b3eaad6a5970de328a6dd30d982591cdfe46507193679c8","sliceHash":"8e7140d83f09629e29b45aaad774c33611e24927431dc7ff6695a5b7c0e14750","surfaceHash":"cb2b0ee10d67af3091437305664b3ea3b0419b83dee9436cb4ebea480fef83d3","target":"rust"} +napl-store snapshot v1 555 +{"contractVersion":2,"event":"4139939d5ce1e1118cbf72e76f8e3122007ebcdb2fb4d533fe418cd45833ed3c","states":[{"module":"commands","state":"e79a58ac8ad0c50f897901451c0b0b0f44757ab69818fe49282b3d41efd1b1bf","target":"rust"},{"module":"napl","state":"1c9027a76e242965501c9d639d69099add69b059b264ef6b815bf03630443f30","target":"rust"},{"module":"napl_store","state":"6132b58875d0b959de5162b96ac1af7c2fd15990fec1ba8dd10a507fadab0b62","target":"rust"},{"module":"query","state":"4d311c2a892a05d64e1842768363dd60597693c828bd017e82627692d36d2183","target":"rust"}]} +napl-store blob v1 25978 +--- +module: query +deps: [napl] +targets: [rust] +--- +:::docs{title="Querying the store" description="Read everything napl knows with one verb."} +Everything napl knows lives in one canonical JSON document: modules, +sections, owned files, attribution, test cases, history. `napl q` +evaluates a jq expression against that document, and `napl prompt` +builds agent context from the same tree. There is no other read +surface to learn, and this sentence is the live demo talking. +::: +# query: the canonical document, napl q, and napl prompt + +The language answers every question through one query surface and builds +agent context through one prompt builder. This module owns both commands +and the document they share. Everything here is a pure read: no store +writes, no locks held, no clock, no randomness, no network (P1). Reads +serve committed records, with two stated exceptions: reference staleness +is computed at read time from the working tree as well as the committed +bytes, so a hand edit to either end of a reference reads stale without +anything being stored; and the working-tree read mode below serves the +pending prompt when asked to explicitly. The same committed state plus +the same working tree produces the same bytes. This module owns `src/query.rs` (taken over +from the commands module), plus the document builder and prompt builder +sources it introduces. + +## The canonical document + +One JSON value, `docVersion 1`, built at query time from committed state: +the store head, module states, attribution records, staged-evidence usage +inside receipts, recorded surfaces, and the committed prompt bytes. Top +level: `docVersion`, `head` (the snapshot hash), `modules`, `history`, +`state`. + +Each module carries `name`, `promptPath`, `deps`, `targets`, +`surfaceHash`, `pending`, `attribution` (an object whose `status` is +`fresh`, `stale`, or `absent`), `sections`, `frontmatterCases` (cases +declared in frontmatter `tests:`, which have no body position), and +`files` (owned output paths), and `outline` — the prompt body's +headings in order, each `{text, level, line}` with the line +body-normalized and 1-based, so "which body line is this heading on" +is a query, never a raw file read. Sections are the module's attribution +entries, and every section is a SELF-CONTAINED REF: it carries `head`, +`module`, `promptPath`, `lines` (body-normalized, 1-based, inclusive), +`note`, `law` (true exactly when the entry owns no file), `files` (each +`{path, lines, head}`), `direction` (`prompt_to_code` or +`code_to_prompt`, per the reference contract in the napl module; entries +recorded before directions read `prompt_to_code`), `provenance` (an +object with `model`, `timestamp`, `toolVersion`, and `usage`, each +absent field served as `unavailable`, never invented), `referenceStatus` +(`fresh` or `stale`), `staleEndpoints` (empty when fresh, `code` and +`prompt` when stale), and `cases` — the `::: test` directive cases +whose block range intersects the section's range, each +`{name, given, expect, lines}` with values in canonical JSON. A directive +case intersecting no entry lands in a synthetic section with empty +`files`. When a module's attribution is stale, sections still carry +their cases (cases derive from prompt bytes, not from attribution) but +each section carries `associationStale: true` and the module's status +says `stale`: the section-to-file pairing cannot be trusted, the case +data can. + +Each file ref inside a section carries `claim`, `whole` when the entry +owns the file with no line range and `range` when it names one, so a +consumer never guesses whether `lines` bounds the claim or merely +samples it. + +Docs fences from the napl module's directive law surface here per +section: `sections[].docs` is the ordered list of docs payloads whose +fence sits inside the section's line range, each +`{directive, attrs, lines, body}` with inner fences verbatim in the +body. The module preamble fence, when present, is served at +`modules[].docs` beside the sections. Docs `lines` are physical prompt +coordinates, the one place physical coordinates appear in the +document, because docs fences are excluded from the semantic body that +every other coordinate speaks in; the document states both coordinate +systems explicitly. A module with no docs fences serves empty docs +lists, never null. + +The committed document's docs are read from the committed prompt +object, the whole prompt file the module's state names, never from +the working tree, for the same reason every other field of the +committed document ignores the working tree: the document is what the +store recorded, and a docs edit that has not passed through the docs +record does not exist to it. A renderer serving the committed +document therefore shows exactly the prose a `docs-only` or module +transition installed, byte for byte, and hovering that prose can only +ever reveal code the store attributes. A state recorded before prompt +objects existed serves its docs lists empty rather than guessing from +the working tree, and the module's next commit records the baseline. +Under `--working-tree` docs come from the working-tree prompt files +as before, which is the read the LSP and the editing loop want. + +The document's freshness verdicts and the language server's are one +analysis: a section the LSP would squiggle as reference-stale reads +`referenceStatus: stale` here with the same endpoints, never a +hardcoded fresh. + +History entries carry the transition hash, module, target, outcome, and +`usage` — `{model, input, output, cacheRead}` from the staged record's +validated usage evidence, or null when none was supplied; absent +telemetry is null, never zero. + +A history entry carries the transition's kind: an ordinary install +says `installed`, and a docs-only transition says `docs-only` as both +its `kind` and its `outcome`, so any reader of the document tells +prose history from semantic history without dereferencing the +transition object. + +Canonical serialization throughout: sorted keys, total ordering for every +array (modules by name; sections by start then end line; files and cases +by path and name; history by transition sequence, transition hash as +tiebreaker), no absolute paths anywhere (`promptPath` and file paths are +workspace-relative), one trailing newline. The same committed state +produces the same document bytes on any machine. + +::: test +name: q with no arguments emits the canonical document byte-stably +given: { workspace: fixture_two_modules, runs: 2 } +expect: { bytes_equal: true, top_level_keys: [docVersion, head, history, modules, state] } +::: + +::: test +name: the document contains no absolute paths +given: { workspace: fixture_two_modules } +expect: { absolute_paths: 0 } +::: + +::: test +name: directive cases appear inside their owning section +given: { workspace: fixture_with_directive_case, attribution: fresh } +expect: { section_cases: 1, case_fields: [expect, given, lines, name] } +::: + +::: test +name: stale attribution keeps cases and marks the association stale +given: { workspace: fixture_with_directive_case, attribution: stale } +expect: { section_cases: 1, association_stale: true, module_status: stale } +::: + +::: test +name: section refs are self-contained +given: { workspace: fixture_two_modules } +expect: { section_fields_present: [direction, head, lines, module, promptPath, provenance, referenceStatus, staleEndpoints] } +::: + +::: test +name: usage from staged evidence appears in history entries +given: { workspace: fixture_two_modules, commit_with_usage: true } +expect: { history_usage_fields: [cacheRead, input, model, output], absent_usage: null } +::: + +::: test +name: a docs-only transition is told apart in history without dereferencing the transition +given: { workspace: fixture_two_modules, then: [docs_edit, add_prompt_path, commit] } +expect: { last_history_kind: docs-only, last_history_outcome: docs-only, ordinary_entries_say: installed } +::: + +::: test +name: a section's docs payloads arrive in order with their bodies verbatim +given: { workspace: fixture_with_docs_fences } +expect: { docs_fields: [attrs, body, directive, lines], order_preserved: true } +::: + +::: test +name: "the committed document serves the docs the store recorded, not the working tree" +given: { workspace: fixture_with_docs_fences, then: [edit_docs_fence_without_recording] } +expect: { committed_docs_unchanged: true, working_tree_docs_show_edit: true } +::: + +::: test +name: a docs-only commit moves the committed document's docs and nothing else in the module +given: { workspace: fixture_with_docs_fences, then: [docs_edit, add_prompt_path, commit] } +expect: { committed_docs_show_edit: true, sections_unmoved: true, input_key_changed: false } +::: + +::: test +name: a state without a prompt baseline serves empty docs rather than guessing +given: { workspace: fixture_committed_before_prompt_objects } +expect: { module_docs: [], section_docs_empty: true, working_tree_mode_still_serves_docs: true } +::: + +::: test +name: a module preamble fence serves title and description in module docs +given: { workspace: fixture_with_module_preamble } +expect: { module_docs_attrs: [description, title] } +::: + +::: test +name: a module with no docs fences serves empty docs lists +given: { workspace: fixture_two_modules } +expect: { module_docs: [], section_docs_empty: true, nulls: 0 } +::: + +::: test +name: file refs carry the claim discriminator +given: { workspace: fixture_two_modules } +expect: { file_claims_subset_of: [range, whole] } +::: + +::: test +name: reference staleness in the document matches the language server verdict +given: { workspace: fixture_with_stale_reference } +expect: { reference_status: stale, lsp_parity: true, hardcoded_fresh: false } +::: + +## napl q + +`napl q` is shaped exactly like jq and IS jq: expressions are evaluated +by an embedded jq engine (the jaq crate, version pinned by the lockfile, +upgraded only through a normal committed change). Impure builtins are +rejected at expression compile time (`env`, `$ENV`, `now`, `input`, +`inputs`, `debug`, `stderr`, `input_line_number`, and anything else doing +I/O or randomness), so an expression is a pure function of its input. + +Argv grammar, exactly three forms; more than one input is a usage +error; `--` is accepted before a path starting with a dash; the +expression is always required and there is deliberately no shorthand +for identity anywhere, because the primary caller is a language model +session and a bare invocation that dumps the whole document is a +context bomb, not a convenience; the usage error for a missing +expression teaches `.` as the explicit spelling and jq demands its +filter the same way: + + napl q expression over the canonical document + napl q - expression over stdin + napl q expression over a JSON file + +Input may be a single JSON value or a stream of values (JSONL); the +expression applies per value exactly as jq does. Each result value is +canonically serialized, one per line; `-r`/`--raw-output` emits string +results as their raw contents exactly as jq does, one result per +output line with exactly one newline between results and one at the +end, never doubled when the string itself ends in one, so shell +pipelines never need a dequoting round trip and a raw result diffs +clean against the file it came from. An empty result stream is exit 0 +with no output. Because canonical serialization is already one compact +value per line, jq's `-c`/`--compact-output` is accepted as a +harmless synonym of the default: it changes nothing and never errors, +so the jq reflex costs no usage round trip; `-c` composes with `-r` +exactly as it does in jq, where raw string output ignores it. Errors are canonical JSON on stderr with pinned exit +codes: 2 usage, 3 expression parse or refused builtin, 4 input parse, 5 +runtime or type error. Evaluation carries a compiled-in output ceiling of +64 MiB, enforced functionally and surfaced as `resource-limit` with its +existing exit code; recursion depth is bounded by the embedded engine's +own evaluation stack rather than a compiled-in constant, and an +expression exceeding it aborts rather than returning wrong data. Piping any napl command's +canonical JSON back through an expression works by construction: +`napl status | napl q '.pending' -`. + +:::docs +`napl q` is jq over the store: same expression language, same flags, +same reflexes. The expression is required; `.` spells the whole +document explicitly. +::: + +:::example{name="read the head"} +napl q '.head' +::: + +:::example{name="list every module"} +napl q '.modules[].name' +::: + +:::note +Piping works by construction: any napl command's JSON output feeds +back through an expression, as in `napl status | napl q '.pending' -`. +::: + +::: test +name: q evaluates a jq expression over the document +given: { workspace: fixture_two_modules, expr: ".modules[].name" } +expect: { output_lines: [consumer, leaf] } +::: + +::: test +name: q accepts the compact flag as a synonym of the default output +given: { workspace: fixture_two_modules, invocations: ["q .pending", "q -c .pending", "q --compact-output .pending"] } +expect: { outputs_byte_identical: true, exit_codes: [0, 0, 0] } +::: + +::: test +name: q without an expression is a usage error teaching the explicit dot +given: { workspace: fixture_two_modules, invocations: ["q", "q -c", "q -r"] } +expect: { exit_codes: [2, 2, 2], error_teaches_dot: true, document_dumped: false } +::: + +::: test +name: q compact composes with raw output changing nothing +given: { stdin: "{\"a\":\"x\"}", expr: ".a", input: "-", flags: ["-r", "-c -r"] } +expect: { outputs_byte_identical: true, raw_string_unquoted: true } +::: + +::: test +name: q evaluates an expression over stdin when the input is a dash +given: { stdin: "{\"pending\":[\"m\"]}", expr: ".pending", input: "-" } +expect: { output: "[\"m\"]" } +::: + +::: test +name: q rejects an invalid expression without touching the store +given: { workspace: fixture_two_modules, expr: ".[unclosed" } +expect: { exit_code: 3, store_bytes_unchanged: true } +::: + +::: test +name: q refuses impure builtins +given: { workspace: fixture_two_modules, expr: "now" } +expect: { exit_code: 3, error_names_builtin: true } +::: + +::: test +name: q raw output emits string contents without quotes exactly as jq dash r +given: { workspace: fixture_two_modules, expr: ".modules[0].name", flags: [-r] } +expect: { output: leaf_unquoted, trailing_newline: true } +::: + +::: test +name: the document serves a heading outline with body line numbers +given: { workspace: fixture_two_modules } +expect: { outline_present: true, outline_entry_fields: [level, line, text], lines_body_normalized: true } +::: + +## napl prompt + +`napl prompt` (alias `napl p`) is the prompt builder: it reads refs from +stdin — the objects a `napl q` stage selected — and emits markdown built +from committed state at the refs' snapshot, never current head, and +never the working tree except under the explicit `--working-tree` mode +defined in the working-tree reads section. Stdin is typed-ref JSONL: one JSON object per line +carrying `head`, `lines`, and exactly one of `path` or `promptPath`; +unknown extra fields are ignored. A line that is not an object, carries +both path kinds or neither, or has a non-integer, zero, reversed, or +out-of-range line range is an error naming the input line number, never a +silent skip. Empty stdin is an error saying nothing was selected. Refs +naming more than one head in a single invocation error. Duplicate refs +(same head, path, range) emit once; input order is otherwise preserved. + +Materialization reads the snapshot's content-addressed blobs; committed +prompts and committed module outputs are the only eligible paths. A +`{path, lines}` ref resolves to its owning module through the snapshot's +output manifests — owned globs are disjoint by commit invariant, so +ownership is unique, and a discovered overlap is a store-integrity +failure, not a guess. A prompt ref yields specification prose; a file ref +yields code: one resolver, both directions. Every chunk gets a provenance +header naming `path:start-end` plus the module and note when known. +Cases render after their section's prose as canonical JSON code blocks +inside dynamically sized fences, immune to backticks in values. + +Law closure by default: the `law: true` sections of every module a ref +touched are appended once, deduplicated, ordered by module then range, +under one trailing header; `--no-laws` opts out. Refs into a module whose +attribution is stale refuse by default with that module's fix directive; +`--allow-stale` proceeds and stamps `stale` into the affected provenance +headers. Unresolvable refs (absent path, unavailable snapshot) error +naming the ref's input line. + +::: test +name: prompt materializes a prompt ref with a provenance header +given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf } +expect: { output_contains_prose: true, header_format: "path:start-end" } +::: + +::: test +name: prompt materializes a generated-file ref symmetrically +given: { workspace: fixture_two_modules, stdin: file_ref_for_leaf_output } +expect: { output_contains_code: true } +::: + +::: test +name: prompt appends law sections by default and omits them under no-laws +given: { workspace: fixture_with_law_section, stdin: section_ref_non_law } +expect: { laws_appended_default: true, laws_with_flag: false } +::: + +::: test +name: prompt refuses refs into a stale module by default +given: { workspace: fixture_with_directive_case, attribution: stale, stdin: section_ref } +expect: { exit_nonzero: true, directive_present: true, allow_stale_overrides: true } +::: + +::: test +name: prompt errors on a malformed stdin line naming its line number +given: { stdin_lines: [valid_ref, "not json"] } +expect: { exit_nonzero: true, error_names_line: 2 } +::: + +::: test +name: prompt errors on empty stdin +given: { stdin: "" } +expect: { exit_nonzero: true, error: nothing_selected } +::: + +::: test +name: prompt deduplicates identical refs and preserves input order +given: { stdin_lines: [ref_a, ref_b, ref_a] } +expect: { chunks: 2, order: [ref_a, ref_b] } +::: + +::: test +name: "prompt resolves refs against their snapshot, not current head" +given: { workspace: fixture_two_modules, ref_head: previous_snapshot, edits_after: leaf_recommitted } +expect: { materialized_bytes: previous_snapshot_bytes } +::: + +## Working-tree reads, byte-exact retrieval, and pre-add validation + +Three reads exist so that a tool amending a prompt can read exactly what +it is amending, retrieve the exact bytes it is about to splice, and +validate the result before anything stages, without a single raw file +read. All three are pure reads: no store writes, no locks, no clock, no +staging. Each is a pure function of the committed state plus the on-disk +prompt bytes it explicitly names, and byte-stable across runs over the +same inputs. + +`napl q --working-tree` builds the canonical document with every +prompt-derived fact enumerated from the on-disk prompt files instead of +the committed blobs: frontmatter, sections, `::: test` directive cases, +and body line ranges all reflect the pending working-tree prompt, +including sections and cases that exist only in the working tree. +Store-derived facts are unchanged and still committed: `head`, module +states, history, receipts. The top level carries `workingTree: true`, +and each section carries `workingTree: true` in place of a committed +`head` value, so a working-tree ref can never be mistaken for a +committed one and no hash is ever invented for uncommitted bytes. +Section file associations are the committed map's claims transported +through the prompt's committed-to-pending diff, the same deterministic +transport `add` uses: an association whose prompt range transports +cleanly is served intact, and a section whose range lands in a +rewritten or ambiguous region serves `files: []` with +`associationUnproven: true`. The view never re-associates files by +range arithmetic over the re-anchored body; an overlap that happens to +exist in the pending text is not a claim, and serving it as one would +be inventing a mapping nobody made. +Section-to-file association under a stale attribution behaves exactly as +today: sections carry their cases, `associationStale: true` marks the +pairing untrustworthy, and nothing pretends freshness. Reference +staleness fields serve exactly as in the committed document. + +`napl prompt --working-tree` resolves `promptPath` refs against the +on-disk prompt file instead of a committed blob. A working-tree ref +carries `workingTree: true` and no `head`; a ref carrying both, or a mix +of working-tree and committed refs in one invocation, is a usage error +naming the input line. Everything else about materialization holds: +provenance headers, law closure from committed state, case rendering, +error shapes. + +`napl prompt --bytes` is byte-exact retrieval, the inverse of the edit a +tool is about to make: the ref's line range materializes as the exact +source bytes of that range, no reflow, no case rendering, no law +closure, no provenance header, one trailing newline. It accepts exactly +one ref; more than one is a usage error. It composes with +`--working-tree` so a splice can be computed against the pending file. +The bytes returned are byte-identical to the named lines of the source, +so retrieve-then-splice is deterministic by construction. + +`napl check ` is dry validation of an on-disk prompt file +before `add`: frontmatter shape and module-name rules per the napl +contract, body normalization, directive grammar for every `::: test` +block including canonical JSON in `given` and `expect`, and case-name +uniqueness. The module-name rules are enforced as name grammar, not +merely as YAML: a frontmatter value that parses as a string but can +never be a legal module name (path-metacharacters, a bare `[`, empty +after trimming) is a finding at check time, never a surprise later. +Findings are canonical JSON on stdout, each naming the file, +the line, and what failed; every reported line is the physical +1-based line in the file as an editor counts it, frontmatter +included, never a body-normalized offset. Exit codes are pinned: 0 for a valid prompt, +1 when findings exist, 2 for usage including an unreadable path. The +store is never touched and nothing stages. `check` passing means the +prompt parses under the same reader `add` uses; it does not predict +`add` outcomes that depend on candidates, attribution, or placement, and +its wording never implies otherwise. `check` teaches while it refuses: +the finding for a missing opening frontmatter fence carries one +complete minimal valid prompt inline, so the first error a cold user +sees contains the whole grammar they were missing; and a checked file +whose name lacks the `.napl` extension gets a finding saying discovery +will never find it, naming the extension, whatever else is valid about +its content. + +::: test +name: q working-tree enumerates a section that exists only in the working tree +given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } +expect: { new_section_enumerated: true, top_level_working_tree: true, committed_document_unchanged: true } +::: + +::: test +name: working-tree sections carry no committed head and mark themselves +given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } +expect: { section_working_tree: true, section_head_absent: true } +::: + +::: test +name: the working-tree document is byte-stable across runs +given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, runs: 2 } +expect: { bytes_equal: true } +::: + +::: test +name: a transported section keeps its files and a rewritten one serves none unproven +given: { workspace: fixture_two_modules, working_tree_edit: shift_one_section_rewrite_another, flags: [--working-tree] } +expect: { shifted_section_files_intact: true, rewritten_section_files: [], rewritten_section_association_unproven: true, overlap_reassociation: 0 } +::: + +::: test +name: prompt bytes round-trips the exact source bytes of a range +given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf, flags: [--bytes] } +expect: { bytes_equal_raw_slice: true, reflow: false, headers: false } +::: + +::: test +name: prompt bytes refuses more than one ref as a usage error +given: { stdin_lines: [ref_a, ref_b], flags: [--bytes] } +expect: { exit_code: 2, error_names_line: 2 } +::: + +::: test +name: a ref carrying both a head and working-tree is a usage error naming its line +given: { stdin_lines: [ref_with_head_and_working_tree], flags: [--working-tree] } +expect: { exit_nonzero: true, error_names_line: 1 } +::: + +::: test +name: check passes a valid prompt with exit zero and no findings +given: { workspace: fixture_two_modules, prompt: leaf_prompt_valid } +expect: { exit_code: 0, findings: 0, store_bytes_unchanged: true } +::: + +::: test +name: check reports a malformed directive with a finding naming its line +given: { workspace: fixture_two_modules, prompt: leaf_prompt_broken_directive } +expect: { exit_code: 1, finding_names_line: true, store_bytes_unchanged: true } +::: + +::: test +name: the missing-fence finding carries a complete minimal prompt that itself passes check +given: { workspace: fixture_two_modules, prompt: file_without_frontmatter } +expect: { exit_code: 1, finding_example_passes_check: true } +::: + +::: test +name: a checked file without the napl extension is told discovery will never find it +given: { workspace: fixture_two_modules, prompt: valid_content_named_greeting_md } +expect: { exit_code: 1, finding_names_extension: ".napl", store_bytes_unchanged: true } +::: + +::: test +name: a module name that yaml accepts but the grammar forbids is a finding +given: { workspace: fixture_two_modules, prompt: frontmatter_module_bare_bracket } +expect: { exit_code: 1, finding_names_module_grammar: true } +::: + +::: test +name: findings report physical file lines as an editor counts them +given: { workspace: fixture_two_modules, prompt: empty_module_value_on_file_line_2 } +expect: { finding_line: 2, line_kind: physical_one_based } +::: +napl-store transition v1 632 +{"baseStateKey":"f38b0d5a644c8edfdf8d88f5387838f0df2f638ef556620264f3a6900afd8c6b","contractVersion":4,"derivedProjections":[],"inputKey":"75f1ab1522b202af14be01e6b4ddbdecda7cb2c0de4d48fd4019bfefc5baa2bd","installPaths":[],"kind":"docs-only","module":"query","newState":"c9b19428a067036863248c563011734528e64170ca525ded3f9a7d984af54a0d","prompt":"873378d3138b0baad075c5f7dbd2199724a15cbefb539ea81b8c2985a4348ff8","readSet":["prompt:ee120f47ae972eb522d7434f8b88090ac6330b202121d45c9c500357a03b8815"],"surface":"cb2b0ee10d67af3091437305664b3ea3b0419b83dee9436cb4ebea480fef83d3","target":"rust","writeSet":["module-state:rust/query"]} +napl-store blob v1 12460 +module: "napl" +target: "rust" +stale: false +entries: + - promptLines: + - 1 + - 8 + file: "crates/napl/src/cli.rs" + lines: whole + note: "the binary reads args runs one command and exits with the contract code" + - promptLines: + - 1 + - 8 + file: "crates/napl/src/commandset.rs" + lines: whole + note: "the command table the core toolchain contributes to the derived registry" + - promptLines: + - 1 + - 8 + file: "crates/napl/src/main.rs" + lines: whole + note: "the binary entry point that spawns no process" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/source.rs" + lines: whole + note: "the smallest prompt the reader accepts, stated once beside the reader that judges it so the two commands that teach with it cannot drift from the grammar discovery finds by the .napl extension" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/transaction.rs" + lines: whole + note: "the crate lib.rs projection derived from the committed module file sets" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/workspace.rs" + lines: whole + note: "layout and discovery walk up to the nearest napl directory" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/paths.rs" + lines: whole + note: "the empty placement root contains every workspace-relative path and joins against it never fabricate a leading separator" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/target.rs" + lines: whole + note: "a prompt at the workspace root places at the empty root and every join against it stays relative" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/transaction.rs" + lines: whole + note: "candidate containment against the empty root admits every workspace-relative path instead of rejecting all of them" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/cli.rs" + lines: whole + note: "the declared grammar of every command plus the dispatch loop that answers help ahead of it and refuses an undeclared flag before it runs, with clap's builder API constructed at dispatch from the registry as the parser behind the contract, every parse failure caught and served as the canonical JSON usage error, and the conventional spellings reduced to one normalized argument vector so --flag=value and --flag value cannot differ" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/commandset.rs" + lines: whole + note: "each core command declares its summary exact usage line and closed flag table, and diff's operand is a target name whose usage line spells it and whose refusal names the target names that exist" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/error.rs" + lines: whole + note: "exit 2 is the pinned usage-error code for the whole binary" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/target.rs" + lines: whole + note: "placement roots owned source globs and the user-owned manifest seam a candidate may never contain" + - promptLines: + - 251 + - 278 + file: "crates/napl/src/decision.rs" + lines: whole + note: "the conflict rule two operations conflict when semantic write sets overlap" + - promptLines: + - 251 + - 278 + file: "crates/napl/src/error.rs" + lines: whole + note: "the outcome vocabulary and its exit-code mapping" + - promptLines: + - 251 + - 278 + file: "crates/napl/src/objects.rs" + lines: whole + note: "P2 footprints read write and derived-projection sets over content identities" + - promptLines: + - 279 + - 328 + file: "crates/napl/src/slice.rs" + lines: whole + note: "identity vocabulary inputKey closure manifest baseStateKey and sliceHash" + - promptLines: + - 279 + - 328 + file: "crates/napl/src/version.rs" + lines: whole + note: "the contract version bumped to four by the capture verbs whose three refusals join the closed table" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "names are serialized as quoted escaped scalars that reparse identically" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "the attribution record path keys on the encoded name never the raw one" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/paths.rs" + lines: whole + note: "the injective reversible name encoding used wherever a name becomes a segment" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/source.rs" + lines: whole + note: "the module-name rule any non-empty single-line scalar and nothing narrower" + - promptLines: + - 390 + - 424 + file: "crates/napl/src/compile.rs" + lines: whole + note: "the compile derivation a pure read that never persists an artifact" + - promptLines: + - 390 + - 424 + file: "crates/napl/src/graph.rs" + lines: whole + note: "the dependency graph and dependency-ordered wave planning" + - promptLines: + - 390 + - 424 + file: "crates/napl/src/paths.rs" + lines: whole + note: "the portable path grammar and its collision-detecting normalization" + - promptLines: + - 425 + - 482 + file: "crates/napl/src/directive.rs" + lines: whole + note: "the test body-directive value grammar diagnostics and deterministic extraction" + - promptLines: + - 483 + - 536 + file: "crates/napl/src/surface.rs" + lines: whole + note: "tree-sitter surface extraction sound by construction and loud on malformed input" + - promptLines: + - 537 + - 991 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "attribution rides the staged record validated before install" + - promptLines: + - 537 + - 991 + file: "crates/napl/src/authoring.rs" + lines: whole + note: "the internal receipt derivation and the one guarded auto-fix" + - promptLines: + - 537 + - 991 + file: "crates/napl/src/candidate.rs" + lines: whole + note: "candidate ingestion untrusted bytes become immutable content-addressed blobs" + - promptLines: + - 537 + - 991 + file: "crates/napl/src/commit.rs" + lines: whole + note: "napl commit the one guarded transaction over what add staged, and bare commit re-derives a record against the head an earlier record in the same invocation moved and retries it once" + - promptLines: + - 537 + - 991 + file: "crates/napl/src/disk.rs" + lines: whole + note: "the store shell publication head compare-and-set and module-keyed locks" + - promptLines: + - 537 + - 991 + file: "crates/napl/src/receipt.rs" + lines: whole + note: "the internal receipt everything the compiler recomputes and refuses to believe" + - promptLines: + - 537 + - 991 + file: "crates/napl/src/staging.rs" + lines: whole + note: "napl add stages from any of the three sources through one pipeline, the dry run enumerates the validations it ran and names target-language validity as the one it did not judge, and a bad attribution map is answered with the whole map contract" + - promptLines: + - 537 + - 991 + file: "crates/napl/src/store_view.rs" + lines: whole + note: "the read seam over content-addressed objects a pure function of the bytes" + - promptLines: + - 537 + - 991 + file: "crates/napl/src/transport.rs" + lines: whole + note: "attribution transports through the byte diff on both ends independently, and a refusal reports each endpoint's verdict separately so the side that transported serves its transported range and only the side needing a human is asked for" + - promptLines: + - 537 + - 991 + file: "crates/napl/src/trees.rs" + lines: whole + note: "building the candidate tree over the ingested manifest" + - promptLines: + - 537 + - 991 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "attribution rides the staged record validated before install" + - promptLines: + - 537 + - 991 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "attribution rides the staged record validated before install" + - promptLines: + - 537 + - 991 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "attribution rides the staged record validated before install" + - promptLines: + - 537 + - 991 + file: "crates/napl/src/lsp.rs" + lines: whole + note: "hover states the direction the recorded provenance that the reference is referentially valid and which endpoints read stale" + - promptLines: + - 992 + - 1180 + file: "crates/napl/src/capture.rs" + lines: whole + note: "napl edit and napl write validate apply capture in that order, and a recorded call's payload carries the reference object verbatim with its transition hash and says the edited file remains working-tree drift until its module commits" + - promptLines: + - 992 + - 1180 + file: "crates/napl/src/commit.rs" + lines: whole + note: "a pre-proven attribution installs fresh because the verb already proved the reference against the exact bytes it names and the guard runs unchanged" + - promptLines: + - 992 + - 1180 + file: "crates/napl/src/cost.rs" + lines: whole + note: "a recorded session window is priced lazily at read time never aggregated at record time" + - promptLines: + - 992 + - 1180 + file: "crates/napl/src/error.rs" + lines: whole + note: "the three refusals join the closed terminal-outcome table with pinned exit codes 27 28 and 29 and none reuses mapping-integrity" + - promptLines: + - 992 + - 1180 + file: "crates/napl/src/staging.rs" + lines: whole + note: "the staged record carries its attribution mode so a reference-only record commits through the same door add and commit already use" + - promptLines: + - 1181 + - 1500 + file: "crates/napl/src/blame.rs" + lines: whole + note: "derived line ancestry where surviving lines trace through accepted patches, and a path no committed module owns answers tracked false naming the absent baseline rather than an empty ancestry that would read as success" + - promptLines: + - 1181 + - 1500 + file: "crates/napl/src/cost.rs" + lines: whole + note: "cost as a pure calculation over an explicit receipt set where verb-staged session windows resolve against the log at read time and unknown stays unknown" + - promptLines: + - 1181 + - 1500 + file: "crates/napl/src/evidence.rs" + lines: whole + note: "evidence validators keyed by media type including the usage-proxy jsonl log with compiled-in bounds" + - promptLines: + - 1181 + - 1500 + file: "crates/napl/src/lint.rs" + lines: whole + note: "unreachable modules under declared roots where absent roots refuse loudly with the exact declaration rather than an empty findings list, and unconsumed surface entries" + - promptLines: + - 1181 + - 1500 + file: "crates/napl/src/lsp.rs" + lines: whole + note: "the language server hover definition references code lens and the diagnostics it publishes from reference staleness over the mapping" + - promptLines: + - 1181 + - 1500 + file: "crates/napl/src/maintenance.rs" + lines: whole + note: "gc fsck and restore, where reachable means the transitive closure from every named head through every event and every hash edge any reachable object carries, so an edge gc does not understand is a reason to keep and a store healthy before a sweep is healthy after it" + - promptLines: + - 1181 + - 1500 + file: "crates/napl/src/mv.rs" + lines: whole + note: "relocation as a semantic operation re-rooting a module through the guarded path" + - promptLines: + - 1181 + - 1500 + file: "crates/napl/src/porcelain.rs" + lines: whole + note: "the observational porcelain status log show diff blame, where the plan labels every projection path tool-derived and names one concrete legal first output and show lists a transition's reference entries inline exactly as it lists notes" + - promptLines: + - 1181 + - 1500 + file: "crates/napl/src/textdiff.rs" + lines: whole + note: "diff renders drift as a canonical byte-stable patch" + - promptLines: + - 1501 + - 1535 + file: "crates/napl/src/canon.rs" + lines: whole + note: "the determinism contract canonical bytes and byte-pinned serialization" +napl-store blob v1 5087 +use std::collections::BTreeMap; +use std::path::Path; + +use serde_json::{json, Map, Value}; + +use crate::canon::canonical_bytes; +use crate::cli::{ok, positional, resolve_workspace, CliResult, CommandSpec}; +use crate::error::Result; +use crate::lsp::parse_attribution; +use crate::porcelain::{log, status, surface_of}; +use crate::workspace::discover_sources; + +/// The command table this module contributes to the derived registry. +pub fn commands() -> &'static [CommandSpec] { + &[ + CommandSpec { + name: "init", + run: crate::init::run_init, + }, + CommandSpec { + name: "q", + run: run_q, + }, + CommandSpec { + name: "watch", + run: crate::watch::run_watch, + }, + ] +} + +/// `napl q [args]`: the stable, scriptable query surface over the +/// typed read operations the porcelain formats for humans. +/// +/// Every operation emits canonical JSON and reads only; the store is never +/// written. The registry is fixed: `modules`, `history`, `surface`, +/// `attribution`, and `state`. An unknown operation is `unknown_operation`, +/// exits nonzero, and never touches the store. +pub(crate) fn run_q(cwd: &Path, args: &[String]) -> Result { + let operation = positional(args, 1)?; + const KNOWN: [&str; 5] = ["attribution", "history", "modules", "state", "surface"]; + if !KNOWN.contains(&operation.as_str()) { + return Ok(unknown_operation(&operation)); + } + let workspace = resolve_workspace(cwd, args)?; + match operation.as_str() { + "modules" => ok(&modules(&workspace)?), + "history" => ok(&log(&workspace, Some(&positional(args, 2)?))?), + "surface" => ok(&surface_of(&workspace, "rust", &positional(args, 2)?)?), + "attribution" => ok(&attribution(&workspace, &positional(args, 2)?)?), + "state" => ok(&status(&workspace, "rust")?), + _ => Ok(unknown_operation(&operation)), + } +} + +fn unknown_operation(operation: &str) -> CliResult { + CliResult { + stdout: canonical_bytes(&json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "operation": operation, + "outcome": "unknown_operation", + })), + exit_code: 2, + } +} + +/// The module list with identities, in canonical module order. +fn modules(workspace: &Path) -> Result { + let sources = discover_sources(workspace)?; + let state = status(workspace, "rust")?; + let mut committed: BTreeMap = BTreeMap::new(); + if let Some(entries) = state["modules"].as_array() { + for entry in entries { + if let Some(name) = entry["module"].as_str() { + committed.insert(name.to_string(), entry.clone()); + } + } + } + let mut names: Vec = sources.iter().map(|s| s.module.clone()).collect(); + names.sort(); + names.dedup(); + let mut list: Vec = Vec::new(); + for name in &names { + let deps: Vec = sources + .iter() + .find(|s| &s.module == name) + .map(|s| s.deps.clone()) + .unwrap_or_default(); + let mut record = Map::new(); + record.insert("module".into(), json!(name)); + record.insert("deps".into(), json!(deps)); + match committed.get(name) { + Some(entry) => { + record.insert("committed".into(), json!(true)); + record.insert("inputKey".into(), entry["inputKey"].clone()); + record.insert("surfaceHash".into(), entry["surfaceHash"].clone()); + } + None => { + record.insert("committed".into(), json!(false)); + } + } + list.push(Value::Object(record)); + } + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "modules": list, + "operation": "modules", + })) +} + +/// The recorded prompt-to-code attribution for a module. +fn attribution(workspace: &Path, module: &str) -> Result { + let path = workspace + .join(".napl") + .join("attribution") + .join(format!("{module}.yaml")); + let entries: Vec = match std::fs::read_to_string(&path) { + Ok(text) => parse_attribution(&text) + .map(|record| { + record + .entries + .iter() + .map(|entry| { + json!({ + "file": entry.file, + "lineEnd": entry.line_end, + "lineStart": entry.line_start, + "note": entry.note, + "promptEnd": entry.prompt_end, + "promptStart": entry.prompt_start, + }) + }) + .collect() + }) + .unwrap_or_default(), + Err(_) => Vec::new(), + }; + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "entries": entries, + "module": module, + "operation": "attribution", + })) +} +napl-store tree v1 252 +{"entries":[{"hash":"8de82553f5e9c70c2794f09aff4bc7718200713bbb71121e3b5780bdf22b762d","kind":"file","mode":33188,"name":"Cargo.toml"},{"hash":"3c251b1b1947263918fe20db4e1c83fae7baee926b3953aa244925e187430015","kind":"dir","mode":16384,"name":"src"}]} +napl-store snapshot v1 1005 +{"contractVersion":4,"event":"2e1b675f4a30854a24e637cf09b4e00615240ce52a71872287ce67f9f4d3e9e8","states":[{"module":"agent","state":"d0d406dd4f30ea6840a808bcbc3ddeea8ec8cc10c84de12ab5d0cfeef7bb6696","target":"rust"},{"module":"commands","state":"ac7d92c561ec9fa19f0e28e1392bd90c1d95ca663b038424bd8ce760c6f4cd9e","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"42cee1b00043646e7e0ebed264397212c4ae136d44233aee64af6bebcab11d53","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"4935806b1fd8ae8a2d0d93597213ab55ebfe7965cbdfaef8663b6e24c80f9bf4","target":"rust"},{"module":"serve","state":"e3fd98fd28ebc7ff92befcd40a0f2d7b202619645398362d7b789bcadb724ebd","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} +napl-store blob v1 7395 +module: "query" +target: "rust" +stale: false +entries: + - promptLines: + - 1 + - 15 + file: "crates/napl/src/query.rs" + lines: whole + note: "the query module owns napl q and napl prompt and the canonical document they share" + - promptLines: + - 16 + - 200 + file: "crates/napl/src/document.rs" + lines: + - 1 + - 816 + note: "the canonical document docVersion 1 built purely from committed records and the working tree, totally ordered, no absolute paths" + - promptLines: + - 16 + - 200 + file: "crates/napl/src/document.rs" + lines: + - 209 + - 233 + note: "the prompt body heading outline served with body-normalized one-based lines so a heading position is a query" + - promptLines: + - 52 + - 54 + file: "crates/napl/src/document.rs" + lines: + - 448 + - 467 + note: "every file ref carries the claim discriminator so a consumer never guesses whether lines bounds the claim or samples it" + - promptLines: + - 56 + - 66 + file: "crates/napl/src/document.rs" + lines: + - 426 + - 446 + note: "docs payloads are served per section in physical prompt coordinates, the one place the document speaks them, because docs fences are excluded from the semantic body every other coordinate speaks in" + - promptLines: + - 68 + - 86 + file: "crates/napl/src/document.rs" + lines: + - 275 + - 325 + note: "the committed document reads its docs from the module state's prompt object by splitting fences alone, never re-running the example validation the commit door applies, so a grammar that has moved since those bytes were recorded can never erase served prose; a state with no readable prompt baseline serves empty lists rather than guessing from the working tree" + - promptLines: + - 88 + - 91 + file: "crates/napl/src/document.rs" + lines: + - 358 + - 424 + note: "the document's freshness verdicts and the language server's are one analysis, never a hardcoded fresh" + - promptLines: + - 201 + - 298 + file: "crates/napl/src/query.rs" + lines: whole + note: "napl q shaped exactly like jq via the embedded jaq engine, impure builtins refused, 64 MiB output ceiling, and the expression always required so a bare invocation is a usage error teaching the explicit dot rather than a document dump" + - promptLines: + - 201 + - 298 + file: "crates/napl/src/query.rs" + lines: + - 321 + - 348 + note: "raw output emits string results as their contents exactly as jq does, one newline between results and one at the end, never doubled when the string itself ends in one, so a raw result diffs clean against the file it came from" + - promptLines: + - 299 + - 447 + file: "crates/napl/src/docs.rs" + lines: whole + note: "the docs envelope section governs the envelope module as a whole, its pinned cases included" + - promptLines: + - 299 + - 310 + file: "crates/napl/src/docs.rs" + lines: + - 1 + - 30 + note: "the docs envelope exists because every docs consumer wants prose, stable section identities, and the attributed code at once, and because an emitter that walks recorded structure cannot produce the escaping defects regexing prose outside the toolchain produced" + - promptLines: + - 312 + - 319 + file: "crates/napl/src/query.rs" + lines: + - 71 + - 89 + note: "the rendering is declared as one flag value on napl q, so the closed flag table names it and an unknown rendering is refused before anything is read" + - promptLines: + - 312 + - 319 + file: "crates/napl/src/query.rs" + lines: + - 156 + - 245 + note: "the flag composes with the document form alone, so an input operand is a usage refusal teaching that markdown rendering is a reading of the store rather than a jq transformation, and the required expression is evaluated against the document before anything is rendered" + - promptLines: + - 312 + - 319 + file: "crates/napl/src/docs.rs" + lines: + - 27 + - 55 + note: "the three shapes an expression result may take, the document itself, one module value, or an array of module values, with every other result refused by a message that names all three" + - promptLines: + - 321 + - 328 + file: "crates/napl/src/docs.rs" + lines: + - 57 + - 223 + note: "the envelope under the document's own canonical serialization law, each module carrying its prompt path, the title and description read out of its preamble docs fence, and that preamble rendered" + - promptLines: + - 330 + - 362 + file: "crates/napl/src/docs.rs" + lines: + - 225 + - 490 + note: "the regroup from one section per file ref into prose shape: identical ranges merge with files deduplicated by path and lines and every constituent provenance record kept, a file-less group strictly inside exactly one surviving section joins it carrying its cases, files, and staleness, each served file ref gains the committed span it claims, and the section is served fence by fence with every fence resolving its own semantic position against every attribution entry of the module that contains it, narrowest span first then path then start, each source carrying that entry's files with their excerpts" + - promptLines: + - 364 + - 374 + file: "crates/napl/src/docs.rs" + lines: + - 492 + - 581 + note: "rendering walks the recorded fence split into CommonMark plus GFM and nothing else: a docs body passes through, a note or warning becomes an alert blockquote, and an example becomes a fenced block whose run outgrows the longest run inside its body" + - promptLines: + - 448 + - 529 + file: "crates/napl/src/promptbuild.rs" + lines: + - 1 + - 556 + note: "napl prompt reads typed refs and materializes prose and code from committed blobs at the refs snapshot, with law closure" + - promptLines: + - 530 + - 681 + file: "crates/napl/src/check.rs" + lines: + - 1 + - 310 + note: "napl check dry validation of an on-disk prompt through the same reader add uses, teaching while it refuses: the missing-fence finding embeds a complete minimal valid prompt that itself passes check, and a file whose name lacks the .napl extension is told discovery will never find it whatever else is valid about its content" + - promptLines: + - 530 + - 681 + file: "crates/napl/src/document.rs" + lines: + - 583 + - 696 + note: "the working-tree document enumerates sections from the on-disk prompt body so a heading and its cases are served the moment they are typed, marked workingTree and carrying no invented head" + - promptLines: + - 530 + - 681 + file: "crates/napl/src/promptbuild.rs" + lines: + - 247 + - 257 + note: "a working-tree ref resolves against the on-disk prompt body rather than a committed blob" + - promptLines: + - 530 + - 681 + file: "crates/napl/src/promptbuild.rs" + lines: + - 426 + - 492 + note: "ref parsing separates a committed head from a working-tree ref and refuses a ref carrying both, naming the input line" + - promptLines: + - 530 + - 681 + file: "crates/napl/src/query.rs" + lines: + - 30 + - 34 + note: "the working-tree switch is argv grammar rather than an operand so the three q forms and the one-input rule hold" +napl-store blob v1 15218 +module: "napl" +target: "rust" +stale: false +entries: + - promptLines: + - 1 + - 8 + file: "crates/napl/src/cli.rs" + lines: + - 1 + - 1037 + note: "the binary reads args runs one command and exits with the contract code" + - promptLines: + - 1 + - 8 + file: "crates/napl/src/commandset.rs" + lines: + - 1 + - 704 + note: "the command table the core toolchain contributes to the derived registry, store migrate among its entries" + - promptLines: + - 1 + - 8 + file: "crates/napl/src/main.rs" + lines: + - 1 + - 16 + note: "the binary entry point that spawns no process" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/source.rs" + lines: + - 1 + - 615 + note: "the smallest prompt the reader accepts, stated once beside the reader that judges it so the two commands that teach with it cannot drift from the grammar discovery finds by the .napl extension" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/transaction.rs" + lines: + - 1 + - 515 + note: "the crate lib.rs projection derived from the committed module file sets" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/workspace.rs" + lines: + - 1 + - 251 + note: "layout and discovery walk up to the nearest napl directory" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/paths.rs" + lines: + - 1 + - 141 + note: "the empty placement root contains every workspace-relative path and joins against it never fabricate a leading separator" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/target.rs" + lines: + - 1 + - 234 + note: "a prompt at the workspace root places at the empty root and every join against it stays relative" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/transaction.rs" + lines: + - 1 + - 515 + note: "candidate containment against the empty root admits every workspace-relative path instead of rejecting all of them" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/cli.rs" + lines: + - 1 + - 1037 + note: "the declared grammar of every command plus the dispatch loop that answers help ahead of it and refuses an undeclared flag before it runs, with clap's builder API constructed at dispatch from the registry as the parser behind the contract, every parse failure caught and served as the canonical JSON usage error, and the conventional spellings reduced to one normalized argument vector so --flag=value and --flag value cannot differ" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/commandset.rs" + lines: + - 1 + - 704 + note: "each core command declares its summary exact usage line and closed flag table, diff's operand is a target name whose usage line spells it and whose refusal names the target names that exist, and store declares its one operation so an unknown one is a grammar failure like any other undeclared token" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/error.rs" + lines: + - 1 + - 166 + note: "exit 2 is the pinned usage-error code for the whole binary" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/target.rs" + lines: + - 1 + - 234 + note: "placement roots owned source globs and the user-owned manifest seam a candidate may never contain" + - promptLines: + - 251 + - 278 + file: "crates/napl/src/decision.rs" + lines: + - 1 + - 113 + note: "the conflict rule two operations conflict when semantic write sets overlap" + - promptLines: + - 251 + - 278 + file: "crates/napl/src/error.rs" + lines: + - 1 + - 166 + note: "the outcome vocabulary and its exit-code mapping" + - promptLines: + - 251 + - 278 + file: "crates/napl/src/objects.rs" + lines: + - 1 + - 380 + note: "P2 footprints read write and derived-projection sets over content identities" + - promptLines: + - 279 + - 328 + file: "crates/napl/src/slice.rs" + lines: + - 1 + - 170 + note: "identity vocabulary inputKey closure manifest baseStateKey and sliceHash" + - promptLines: + - 279 + - 328 + file: "crates/napl/src/version.rs" + lines: + - 1 + - 1 + note: "the contract version bumped to four by the capture verbs whose three refusals join the closed table" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/attribution.rs" + lines: + - 1 + - 1269 + note: "names are serialized as quoted escaped scalars that reparse identically" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/attribution.rs" + lines: + - 1 + - 1269 + note: "the attribution record path keys on the encoded name never the raw one" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/paths.rs" + lines: + - 1 + - 141 + note: "the injective reversible name encoding used wherever a name becomes a segment" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/source.rs" + lines: + - 1 + - 615 + note: "the module-name rule any non-empty single-line scalar and nothing narrower" + - promptLines: + - 390 + - 460 + file: "crates/napl/src/compile.rs" + lines: + - 1 + - 553 + note: "the compile derivation a pure read that never persists an artifact, and the pending reason it derives from the closed vocabulary by comparing the current inputKey components against the identities the committed record holds, with the re-stamp promise an undrifted pending module carries" + - promptLines: + - 390 + - 460 + file: "crates/napl/src/graph.rs" + lines: + - 1 + - 162 + note: "the dependency graph and dependency-ordered wave planning" + - promptLines: + - 390 + - 460 + file: "crates/napl/src/paths.rs" + lines: + - 1 + - 141 + note: "the portable path grammar and its collision-detecting normalization" + - promptLines: + - 461 + - 722 + file: "crates/napl/src/directive.rs" + lines: + - 1 + - 1009 + note: "the test body-directive value grammar diagnostics and deterministic extraction" + - promptLines: + - 521 + - 720 + file: "crates/napl/src/directive.rs" + lines: + - 1 + - 1009 + note: "the docs fence family with its micromark name and attribute grammar, the fence stack where a closing line of N colons closes the innermost fence opened with at most N and where code blocks suspend the stack, and the split that lifts every top-level docs fence out of the semantic body" + - promptLines: + - 521 + - 720 + file: "crates/napl/src/source.rs" + lines: + - 1 + - 615 + note: "the semantic body every identity derivation consumes is the normalized body with its docs fences removed, the module preamble is the first fence standing before the first heading, and an example fence is validated so a published example cannot rot" + - promptLines: + - 521 + - 720 + file: "crates/napl/src/cli.rs" + lines: + - 1 + - 1037 + note: "a napl invocation written inside an example fence is judged against the live command grammar without running anything" + - promptLines: + - 723 + - 776 + file: "crates/napl/src/surface.rs" + lines: + - 1 + - 875 + note: "tree-sitter surface extraction sound by construction and loud on malformed input" + - promptLines: + - 777 + - 1333 + file: "crates/napl/src/attribution.rs" + lines: + - 1 + - 1269 + note: "attribution rides the staged record validated before install" + - promptLines: + - 777 + - 1333 + file: "crates/napl/src/authoring.rs" + lines: + - 1 + - 229 + note: "the internal receipt derivation and the one guarded auto-fix" + - promptLines: + - 777 + - 1333 + file: "crates/napl/src/candidate.rs" + lines: + - 1 + - 148 + note: "candidate ingestion untrusted bytes become immutable content-addressed blobs" + - promptLines: + - 777 + - 1333 + file: "crates/napl/src/commit.rs" + lines: + - 1 + - 742 + note: "napl commit the one guarded transaction over what add staged, and bare commit re-derives a record against the head an earlier record in the same invocation moved and retries it once" + - promptLines: + - 777 + - 1333 + file: "crates/napl/src/disk.rs" + lines: + - 1 + - 348 + note: "the store shell publication head compare-and-set and module-keyed locks, and the seam where the object backend is chosen by observing whether the workspace sits in a git repository whose store branch exists, everything but object storage keeping its disk paths" + - promptLines: + - 777 + - 1333 + file: "crates/napl/src/receipt.rs" + lines: + - 1 + - 192 + note: "the internal receipt everything the compiler recomputes and refuses to believe" + - promptLines: + - 777 + - 1333 + file: "crates/napl/src/staging.rs" + lines: + - 1 + - 1732 + note: "napl add stages from any of the three sources through one pipeline, the dry run enumerates the validations it ran and names target-language validity as the one it did not judge, and a bad attribution map is answered with the whole map contract" + - promptLines: + - 777 + - 1333 + file: "crates/napl/src/store_view.rs" + lines: + - 1 + - 152 + note: "the read seam over content-addressed objects a pure function of the bytes" + - promptLines: + - 777 + - 1333 + file: "crates/napl/src/transport.rs" + lines: + - 1 + - 691 + note: "attribution transports through the byte diff on both ends independently, and a refusal reports each endpoint's verdict separately so the side that transported serves its transported range and only the side needing a human is asked for" + - promptLines: + - 777 + - 1333 + file: "crates/napl/src/trees.rs" + lines: + - 1 + - 34 + note: "building the candidate tree over the ingested manifest" + - promptLines: + - 777 + - 1333 + file: "crates/napl/src/attribution.rs" + lines: + - 1 + - 1269 + note: "attribution rides the staged record validated before install" + - promptLines: + - 777 + - 1333 + file: "crates/napl/src/attribution.rs" + lines: + - 1 + - 1269 + note: "attribution rides the staged record validated before install" + - promptLines: + - 777 + - 1333 + file: "crates/napl/src/attribution.rs" + lines: + - 1 + - 1269 + note: "attribution rides the staged record validated before install" + - promptLines: + - 777 + - 1333 + file: "crates/napl/src/lsp.rs" + lines: + - 1 + - 883 + note: "hover states the direction the recorded provenance that the reference is referentially valid and which endpoints read stale" + - promptLines: + - 1334 + - 1532 + file: "crates/napl/src/capture.rs" + lines: + - 1 + - 876 + note: "napl edit and napl write validate apply capture in that order, and a recorded call's payload carries the reference object verbatim with its transition hash and says the edited file remains working-tree drift until its module commits" + - promptLines: + - 1334 + - 1532 + file: "crates/napl/src/commit.rs" + lines: + - 1 + - 742 + note: "a pre-proven attribution installs fresh because the verb already proved the reference against the exact bytes it names and the guard runs unchanged" + - promptLines: + - 1334 + - 1532 + file: "crates/napl/src/cost.rs" + lines: + - 1 + - 407 + note: "a recorded session window is priced lazily at read time never aggregated at record time" + - promptLines: + - 1334 + - 1532 + file: "crates/napl/src/error.rs" + lines: + - 1 + - 166 + note: "the three refusals join the closed terminal-outcome table with pinned exit codes 27 28 and 29 and none reuses mapping-integrity" + - promptLines: + - 1334 + - 1532 + file: "crates/napl/src/staging.rs" + lines: + - 1 + - 1732 + note: "the staged record carries its attribution mode so a reference-only record commits through the same door add and commit already use" + - promptLines: + - 1533 + - 1964 + file: "crates/napl/src/blame.rs" + lines: + - 1 + - 90 + note: "derived line ancestry where surviving lines trace through accepted patches, and a path no committed module owns answers tracked false naming the absent baseline rather than an empty ancestry that would read as success" + - promptLines: + - 1533 + - 1964 + file: "crates/napl/src/cost.rs" + lines: + - 1 + - 407 + note: "cost as a pure calculation over an explicit receipt set where verb-staged session windows resolve against the log at read time and unknown stays unknown" + - promptLines: + - 1533 + - 1964 + file: "crates/napl/src/evidence.rs" + lines: + - 1 + - 297 + note: "evidence validators keyed by media type including the usage-proxy jsonl log with compiled-in bounds" + - promptLines: + - 1533 + - 1964 + file: "crates/napl/src/lint.rs" + lines: + - 1 + - 246 + note: "unreachable modules under declared roots where absent roots refuse loudly with the exact declaration rather than an empty findings list, and unconsumed surface entries" + - promptLines: + - 1533 + - 1964 + file: "crates/napl/src/lsp.rs" + lines: + - 1 + - 883 + note: "the language server hover definition references code lens and the diagnostics it publishes from reference staleness over the mapping" + - promptLines: + - 1533 + - 1964 + file: "crates/napl/src/maintenance.rs" + lines: + - 1 + - 428 + note: "gc fsck and restore, where reachable means the transitive closure from every named head through every event and every hash edge any reachable object carries, so an edge gc does not understand is a reason to keep and a store healthy before a sweep is healthy after it" + - promptLines: + - 1533 + - 1964 + file: "crates/napl/src/mv.rs" + lines: + - 1 + - 212 + note: "relocation as a semantic operation re-rooting a module through the guarded path" + - promptLines: + - 1533 + - 1964 + file: "crates/napl/src/porcelain.rs" + lines: + - 1 + - 1547 + note: "the observational porcelain status log show diff blame plus the maintenance verbs, where the plan labels every projection path tool-derived and names one concrete legal first output, show lists a transition's reference entries inline exactly as it lists notes, and store migrate walks the tracked objects into one deterministic commit, refusing outside a repository and refusing a branch that already exists, and reports the object files ready to leave the working tree with the exact removal command" + - promptLines: + - 1533 + - 1964 + file: "crates/napl/src/textdiff.rs" + lines: + - 1 + - 152 + note: "diff renders drift as a canonical byte-stable patch" + - promptLines: + - 1965 + - 1999 + file: "crates/napl/src/canon.rs" + lines: + - 1 + - 44 + note: "the determinism contract canonical bytes and byte-pinned serialization" +napl-store blob v1 17640 +//! `napl q` and the query module's command table. +//! +//! `napl q` is shaped exactly like jq and IS jq: expressions are evaluated by an +//! embedded jq engine (the jaq crate) against the canonical document, stdin, or +//! a JSON file. Everything here is a pure read over committed records: no store +//! writes, no locks, no clock, no randomness, no network. Impure builtins are +//! refused at expression compile time so an expression is a pure function of its +//! input; a compiled-in 64 MiB output ceiling surfaces as `resource-limit`. + +use std::io::Read as _; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use jaq_core::load::{Arena, File, Loader}; +use jaq_core::{data, unwrap_valr, Compiler, Ctx, Vars}; +use jaq_json::Val; +use serde_json::{json, Value}; + +use crate::canon::canonical_bytes; +use crate::cli::{ + has_switch, resolve_workspace, switch, switch_short, CliResult, CommandSpec, FlagSpec, + WORKSPACE_FLAG, +}; +use crate::document::build_document_view; +use crate::error::Result; + +/// The compiled-in output ceiling: exceeding it is `resource-limit`. +pub const OUTPUT_CEILING_BYTES: usize = 64 * 1024 * 1024; + +/// Switches `napl q` accepts after the command word. They are not operands, so +/// the argv grammar keeps its exactly-four forms and one-input rule. The short +/// spelling `-r` normalizes to `--raw-output` before this body sees it, so only +/// the long names appear here. +const Q_SWITCHES: [&str; 2] = ["--raw-output", "--working-tree"]; + +/// Impure builtins the argv pre-scan names explicitly, so a well-known name is +/// refused with a message that names it. These mirror the prompt's enumerated +/// list; the real guarantee is the reachability filter below, which removes +/// every impure builtin from the compiled set regardless of this list. +const IMPURE_BUILTINS: [&str; 7] = [ + "env", + "now", + "input", + "inputs", + "debug", + "stderr", + "input_line_number", +]; + +/// Native jaq-std filters that read the clock, timezone, or environment, or that +/// perform I/O. They are removed from the compiled filter set by construction so +/// an expression is a pure function of its input (P1): a jaq-std upgrade cannot +/// silently reintroduce a leak because a removed name is simply undefined at +/// compile time. `input`/`inputs` are never chained in the first place (they live +/// in `jaq_std::input::funs`, which needs an inputs iterator this engine does not +/// provide), and `input_line_number`/`$ENV` are likewise never registered. +pub(crate) const IMPURE_FUNS: [&str; 6] = [ + "env", + "now", + "localtime", + "strflocaltime", + "debug_empty", + "stderr_empty", +]; + +/// Standard-library definitions built on the impure native funs above (they do +/// stderr I/O), removed from the compiled definition set for the same reason so +/// the surviving defs never reference a fun that is no longer registered. +pub(crate) const IMPURE_DEFS: [&str; 3] = ["debug", "stderr", "halt_error"]; + +const Q_FLAGS: &[FlagSpec] = &[ + switch_short( + "--raw-output", + "-r", + "emit string results as their raw contents, exactly as jq does", + ), + switch("--working-tree", "read the working tree's prompts instead of the committed records"), + WORKSPACE_FLAG, +]; + +const PROMPT_FLAGS: &[FlagSpec] = &[ + switch("--allow-stale", "materialize even where the mapping reads stale"), + switch("--bytes", "emit the selected bytes alone, without the surrounding prose"), + switch("--no-laws", "omit the module's standing laws from the materialized prompt"), + switch("--working-tree", "read the working tree's prompts instead of the committed records"), + WORKSPACE_FLAG, +]; + +const CHECK_FLAGS: &[FlagSpec] = &[WORKSPACE_FLAG]; + +/// The command table the `query` module contributes to the derived registry. +/// +/// Each entry declares its summary, exact usage line, and closed flag table, so +/// the dispatch loop can answer help and refuse undeclared flags without the +/// query commands parsing arguments of their own. +pub fn commands() -> &'static [CommandSpec] { + &[ + CommandSpec { + name: "q", + example: "napl q '.modules[].name'", + summary: "evaluate a jq expression over the canonical document", + usage: "napl q [] [|-] [-r|--raw-output] [--working-tree]", + flags: Q_FLAGS, + passthrough: false, + run: run_q, + }, + CommandSpec { + name: "prompt", + example: "napl q '.modules[] | select(.name==\"leaf\")' | napl prompt", + summary: "materialize the specification prose and pinned cases a selection names", + usage: "napl prompt [--no-laws] [--allow-stale] [--working-tree] [--bytes] < selection", + flags: PROMPT_FLAGS, + passthrough: false, + run: crate::promptbuild::run_prompt, + }, + CommandSpec { + name: "p", + example: "napl q '.modules[] | select(.name==\"leaf\")' | napl p", + summary: "the short spelling of napl prompt", + usage: "napl p [--no-laws] [--allow-stale] [--working-tree] [--bytes] < selection", + flags: PROMPT_FLAGS, + passthrough: false, + run: crate::promptbuild::run_prompt, + }, + CommandSpec { + name: "check", + example: "napl check crates/leaf/leaf.napl", + summary: "report what would stop a prompt file from being added", + usage: "napl check ", + flags: CHECK_FLAGS, + passthrough: false, + run: crate::check::run_check, + }, + ] +} + +/// Where `napl q` reads its input. +enum Input { + Stdin, + File(PathBuf), +} + +fn run_q(cwd: &Path, args: &[String]) -> Result { + let (expr, input) = match parse_argv(args) { + Ok(parsed) => parsed, + Err(msg) => return Ok(fail(2, "usage", &msg)), + }; + if let Some(builtin) = refused_builtin(&expr) { + return Ok(fail( + 3, + "expression", + &format!("impure builtin {builtin:?} is not permitted"), + )); + } + let input_bytes = match &input { + None => { + let workspace = resolve_workspace(cwd, args)?; + let working_tree = has_switch(args, "--working-tree"); + canonical_bytes(&build_document_view(&workspace, working_tree)?) + } + Some(Input::Stdin) => { + let mut buf = Vec::new(); + if std::io::stdin().read_to_end(&mut buf).is_err() { + return Ok(fail(4, "input", "could not read stdin")); + } + buf + } + Some(Input::File(path)) => match std::fs::read(path) { + Ok(bytes) => bytes, + Err(e) => return Ok(fail(4, "input", &format!("{}: {e}", path.display()))), + }, + }; + let raw = has_switch(args, "--raw-output"); + match evaluate(&expr, &input_bytes, raw) { + Ok(out) => Ok(CliResult { + stdout: out, + exit_code: 0, + }), + Err((code, kind, msg)) => Ok(fail(code, kind, &msg)), + } +} + +/// Evaluate the expression over one JSON value or a stream of values, emitting +/// each result canonically, one per line, under the output ceiling. +fn evaluate( + expr: &str, + input_bytes: &[u8], + raw: bool, +) -> std::result::Result, (i32, &'static str, String)> { + let defs = jaq_core::defs() + .chain(jaq_std::defs().filter(|d| !IMPURE_DEFS.contains(&d.name))) + .chain(jaq_json::defs()); + let funs = jaq_core::funs() + .chain(jaq_std::funs().filter(|f| !IMPURE_FUNS.contains(&f.0))) + .chain(jaq_json::funs()); + let loader = Loader::new(defs); + let arena = Arena::default(); + let modules = loader + .load(&arena, File { code: expr, path: () }) + .map_err(|errs| (3, "expression", format!("invalid expression: {errs:?}")))?; + let filter = Compiler::default() + .with_funs(funs) + .compile(modules) + .map_err(|errs| (3, "expression", format!("invalid expression: {errs:?}")))?; + + let mut out: Vec = Vec::new(); + for input in jaq_json::read::parse_many(input_bytes) { + let input = input.map_err(|e| (4, "input", format!("invalid input: {e:?}")))?; + let ctx = Ctx::>::new(&filter.lut, Vars::new([])); + for result in filter.id.run((ctx, input)).map(unwrap_valr) { + let value = + result.map_err(|e| (5, "runtime", format!("runtime error: {e:?}")))?; + let line = serialize(&value, raw); + if out.len() + line.len() > OUTPUT_CEILING_BYTES { + return Err((23, "resource-limit", "output exceeds the 64 MiB ceiling".into())); + } + out.extend_from_slice(&line); + } + } + Ok(out) +} + +/// Canonically serialize one jaq value: sorted keys, one trailing newline. jaq's +/// own writer emits valid JSON; re-parsing it and re-emitting canonically pins +/// the bytes. +/// +/// Under `--raw-output` a string result emits its contents instead of its JSON +/// spelling, exactly as jq does, so a shell pipeline never needs a dequoting +/// round trip. Every non-string result is unaffected, because there is no raw +/// form of an object for the flag to invent. +fn serialize(value: &Val, raw: bool) -> Vec { + let mut buf: Vec = Vec::new(); + let _ = jaq_json::write::write(&mut buf, &jaq_json::write::Pp::default(), 0, value); + let parsed: Value = serde_json::from_slice(&buf).unwrap_or(Value::Null); + if raw { + if let Value::String(text) = &parsed { + let mut out = text.clone().into_bytes(); + out.push(b'\n'); + return out; + } + } + canonical_bytes(&parsed) +} + +/// The first impure builtin the expression references, or `None`. A field +/// access such as `.input` is not a builtin call; a string literal never is. +fn refused_builtin(expr: &str) -> Option { + let bytes = expr.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + if c == b'"' { + i += 1; + while i < bytes.len() && bytes[i] != b'"' { + if bytes[i] == b'\\' { + i += 1; + } + i += 1; + } + i += 1; + continue; + } + if c == b'$' { + let start = i + 1; + let mut j = start; + while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { + j += 1; + } + if &expr[start..j] == "ENV" { + return Some("$ENV".to_string()); + } + i = j.max(i + 1); + continue; + } + if c.is_ascii_alphabetic() || c == b'_' { + let prev = if i > 0 { Some(bytes[i - 1]) } else { None }; + let mut j = i; + while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { + j += 1; + } + let name = &expr[i..j]; + if prev != Some(b'.') && IMPURE_BUILTINS.contains(&name) { + return Some(name.to_string()); + } + i = j; + continue; + } + i += 1; + } + None +} + +/// Parse the jq-style argv: `q [expr] [--] [input]`. More than one input is a +/// usage error; `--` is accepted before a path starting with a dash; there is +/// no shorthand for identity over a file or stdin, so a missing expression means +/// identity over the canonical document. +fn parse_argv(args: &[String]) -> std::result::Result<(String, Option), String> { + let mut operands: Vec = Vec::new(); + let mut seen_command = false; + let mut past_dashdash = false; + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + if !seen_command { + if arg == "--workspace" { + i += 2; + continue; + } + if arg.starts_with("--") { + i += 1; + continue; + } + seen_command = true; + i += 1; + continue; + } + if arg == "--" && !past_dashdash { + past_dashdash = true; + i += 1; + continue; + } + if !past_dashdash && Q_SWITCHES.contains(&arg.as_str()) { + i += 1; + continue; + } + // A declared value flag and its value are never operands. The dispatch + // loop hands this body a normalized vector where every flag trails the + // operands in separated form, so skipping the pair here is exact. + if !past_dashdash && arg == "--workspace" { + i += 2; + continue; + } + operands.push(arg.clone()); + i += 1; + } + let expr = operands.first().cloned().unwrap_or_else(|| ".".to_string()); + let inputs = if operands.is_empty() { + &operands[..] + } else { + &operands[1..] + }; + if inputs.len() > 1 { + return Err("more than one input given".to_string()); + } + let input = inputs.first().map(|token| { + if token == "-" { + Input::Stdin + } else { + Input::File(PathBuf::from(token)) + } + }); + Ok((expr, input)) +} + +/// Write a canonical error payload to stderr and return the pinned exit code. +fn fail(code: i32, kind: &str, detail: &str) -> CliResult { + let payload = json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "detail": detail, + "kind": kind, + "outcome": "query-error", + }); + let bytes = canonical_bytes(&payload); + let stderr = std::io::stderr(); + let mut lock = stderr.lock(); + let _ = lock.write_all(&bytes); + let _ = lock.flush(); + CliResult { + stdout: Vec::new(), + exit_code: code, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + type D = data::JustLut; + + fn std_fun_names() -> Vec<&'static str> { + let mut names: Vec<&'static str> = jaq_std::funs::().map(|f| f.0).collect(); + names.sort_unstable(); + names + } + + fn std_def_names() -> Vec<&'static str> { + let mut names: Vec<&'static str> = jaq_std::defs().map(|d| d.name).collect(); + names.sort_unstable(); + names + } + + #[test] + fn every_filtered_impure_fun_is_a_real_jaq_std_name() { + let names = std_fun_names(); + for impure in IMPURE_FUNS { + assert!( + names.contains(&impure), + "jaq-std no longer registers native fun {impure:?}; the reachability filter is stale and must be re-derived" + ); + } + } + + #[test] + fn every_filtered_impure_def_is_a_real_jaq_std_name() { + let names = std_def_names(); + for impure in IMPURE_DEFS { + assert!( + names.contains(&impure), + "jaq-std no longer defines {impure:?}; the reachability filter is stale and must be re-derived" + ); + } + } + + #[test] + fn the_compiled_fun_set_excludes_every_impure_builtin() { + let filtered: Vec<&'static str> = jaq_std::funs::() + .filter(|f| !IMPURE_FUNS.contains(&f.0)) + .map(|f| f.0) + .collect(); + for impure in IMPURE_FUNS { + assert!( + !filtered.contains(&impure), + "impure fun {impure:?} is still reachable in the compiled set" + ); + } + } + + #[test] + fn the_compiled_def_set_excludes_every_impure_definition() { + let filtered: Vec<&'static str> = jaq_std::defs() + .filter(|d| !IMPURE_DEFS.contains(&d.name)) + .map(|d| d.name) + .collect(); + for impure in IMPURE_DEFS { + assert!( + !filtered.contains(&impure), + "impure def {impure:?} is still reachable in the compiled set" + ); + } + } + + #[test] + fn the_jaq_std_fun_roster_is_frozen_so_a_new_builtin_trips_this_test() { + let expected: Vec<&'static str> = FROZEN_STD_FUNS.to_vec(); + assert_eq!( + std_fun_names(), + expected, + "jaq-std's native filter roster changed: classify each added or renamed name (pure -> add to FROZEN_STD_FUNS, clock/timezone/env/randomness/io -> add to IMPURE_FUNS) before shipping the upgrade" + ); + } + + /// The complete native-filter roster jaq-std registers, frozen at the pinned + /// version so any upgrade that adds or renames a filter fails the test above. + const FROZEN_STD_FUNS: [&str; 96] = [ + "acos", "acosh", "ascii_downcase", "ascii_upcase", "asin", "asinh", "atan", "atan2", + "atanh", "cbrt", "ceil", "copysign", "cos", "cosh", "debug_empty", "decode_base64", + "decode_uri", "encode_base64", "encode_uri", "endswith", "env", "erf", "erfc", + "escape_html", "escape_sh", "exp", "exp10", "exp2", "explode", "expm1", "fabs", "fdim", + "floor", "fma", "fmax", "fmin", "fmod", "frexp", "fromdateiso8601", "gmtime", "group_by", + "halt", "hypot", "ilogb", "implode", "j0", "j1", "jn", "ldexp", "lgamma", "localtime", + "log", "log10", "log1p", "log2", "ltrim", "ltrimstr", "matches", "max_by_or_empty", + "min_by_or_empty", "mktime", "modf", "nearbyint", "nextafter", "now", "pow", "remainder", + "reverse", "rint", "round", "rtrim", "rtrimstr", "scalbln", "sin", "sinh", "sort", + "sort_by", "split_", "split_matches", "sqrt", "startswith", "stderr_empty", + "strflocaltime", "strftime", "strptime", "tan", "tanh", "tgamma", "todateiso8601", "trim", + "trunc", "unescape_html", "utf8bytelength", "y0", "y1", "yn", + ]; +} +napl-store tree v1 132 +{"entries":[{"hash":"657ac3e3f3363a76e675e5847f2cedab5b5415cad46388fc8e3508173ac2351e","kind":"dir","mode":16384,"name":"crates"}]} +napl-store blob v1 82805 +# NAPL: the deterministic compiler + +This prompt describes the entire NAPL toolchain, rebuilt as one small Cargo +workspace of exactly three crates. It replaces the legacy 76-crate +implementation completely; git history holds the original. The governing +contract is docs/specs/2026-07-27-deterministic-compiler-plan.md rev 4; where +this prompt compresses it, the spec's definition wins. + +## Layout: this module generates two crates on top of the ported store + +The workspace layout is fixed: the records directory `.napl/` lives at the +repository root; generated code installs under `crates/`; a module's +prompt file lives inside the crate directory that receives its code, and +the crate is the folder: every module whose `.napl` file sits in +`crates//` compiles into the `` crate as its own source +module, so one crate may be described by several prompt files +(`crates/napl/napl.napl` and `crates/napl/commands.napl` both build +`crates/napl/`). Module discovery finds prompts by walking the workspace +for `*.napl` files, and the workspace root is the nearest ancestor +directory containing `.napl`. A subtree that contains its own `.napl` +directory is a separate workspace: discovery never descends into it, the +way git never descends into a nested repository. Hidden directories and +`node_modules` are never descended into. + +The proven store already exists as a hand-ported crate at +`crates/napl-store/` (same envelope format `napl-store v1 +\n`, SHA-256 names, canonical JSON payloads, same object +kinds, same `.napl/store/` on-disk layout with HEAD compare-and-swap, +module-keyed locks, and dead-holder steal, ported code and tests from the +legacy store crates). Use its public API through a path dependency; do not +reimplement, wrap thinly, or fork any of its types or logic. The four new +object kinds the compiler needs (`transition`, `receipt`, `surface`, +`target-def`) already exist in it additively. + +::: test +name: the ported store hashes objects exactly as the original store crates did +given: { vectors: ported_store_hash_test_corpus } +expect: { all_pass_unmodified: true } +::: + +::: test +name: identical payload under two object kinds gets two different names +given: { payload: "X", kind_a: blob, kind_b: snapshot } +expect: { equal: false } +::: + +This module's generated output is as many crates as the design needs, side +by side in the module's owned directory. Keep it a small, clearly separated +workspace (this replaces a 76-crate explosion; do not recreate one), with +`napl-store` staying its own untouched crate. The natural split, which you +may adjust if the design genuinely wants another boundary: + +1. **`napl-core/`** (lib): the pure compiler. Parsing `.napl` files (YAML + frontmatter: module, deps, targets, tests; markdown body), discovery and + dependency graph, canonical path grammar, the identity vocabulary, + per-module artifact slices, wave planning, tree-sitter surface extraction + for Rust and TypeScript targets, the surface grammar, placement rules + and entry-file projection derivation, receipt verification logic, the commit + decision table as pure functions, blame line-ancestry replay, lint + diagnosis, cost calculation, evidence validation. No filesystem writes, + no locks, no clocks, no environment reads: everything enters as + arguments and leaves as values. +2. **`napl-cli/`** (bin `napl`): every command, each a thin composition of + napl-core derivations and napl-store reads plus the one guarded write + path. No private logic in the binary beyond argument parsing and + formatting. + +The crates depend on `napl-store` by path, `serde_json`, `sha2`, +`tree-sitter`, `tree-sitter-rust`, `tree-sitter-typescript`, `clap` +(builder API only, driving the command line contract below), and nothing +that talks to a network, spawns a process, or reads clocks; those +declarations live in the user-owned manifests, not in any module's +output. Ensure `cargo test` passes for both crates and `napl-store` from +their workspace before finishing. Generated code is authored against its +final installed location, never a staging tree: acceptance runs at the +final location, so green certifies the bytes where they live. The +working-tree add forms are the operational spelling of this law: author +and verify in place, then stage from where the bytes live; assembling a +separate candidate directory is the exception, for staging bytes that +are not the working tree, never the routine. + +When several modules share one crate, the crate's shared entry files are +derived projections owned by no module: the crate root `lib.rs` module +list is recomputed at commit from the union of the crate's committed +module file sets, and the binary's command registry is recomputed from +the command tables the crate's modules expose under one fixed convention +(each module may expose `pub fn commands() -> &'static [CommandSpec]` +from its own files; the dispatch loop iterates the derived registry and +contains no per-command match arms of its own). A module's owned output +therefore never includes the shared entry files, two modules can never +claim the same path, and a commit that would write a projection-owned +path is rejected as invalid-candidate. + +A prompt at the workspace root is legal and means the workspace root is +the crate directory, exactly as git treats the directory it was +initialized in: the placement root is the empty workspace-relative path, +every workspace-relative path is inside it, and every join against it +produces clean relative paths, never a fabricated leading separator. A +cold workspace whose first prompt sits beside `.napl` therefore commits +its first module with no configuration; placement never needs declaring +because the prompt's location is the declaration. + +::: test +name: a root-level prompt places at the workspace root and its module commits +given: { workspace: fresh_init_root_prompt, candidate: valid_root_level } +expect: { placement_root: "", owned_globs_relative: true, outcome: installed } +::: + +::: test +name: status for a root-level module reports a workspace-root plan with clean globs +given: { workspace: fresh_init_root_prompt, flag: module_root } +expect: { plan_placement_root: "", owned_globs_leading_separator: false } +::: + +## The command line contract + +Every command the registry serves declares its full grammar in its +`CommandSpec`: name, a one-line summary, the exact usage line, and the +closed table of flags it accepts, each with arity and meaning. The +dispatch loop owns two behaviors ahead of every command, so no command +can get them wrong. First, help: `napl help` lists every registered +command with its summary, `napl help ` and `--help` or `-h` +anywhere in a command's arguments print that command's usage line, flag +table, and summary, exit 0, and never invoke the command. Help speaks +human by default and machine on request: when stdout is a terminal or +`--human` is passed, help renders readable text from the registry, +usage line, summary, flag table with meanings, and one worked example +per command; when stdout is piped or `--json` is passed, help emits +the canonical JSON registry exactly as today, byte-stable. Help is +the only command with a human rendering; every other command speaks +canonical JSON everywhere, and the two help renderings derive from +the one registry so they can never disagree. Help is a pure +read: no store access, no filesystem write, no state change of any kind, +and this holds for every command including `init` and `watch`. Second, +strict parsing: an argument beginning with `--` that the command's flag +table does not name is a usage error, never ignored; the error goes to +stderr as canonical JSON naming the unknown flag, listing the command's +valid flags, and suggesting the nearest declared flag by edit distance +when one is close, and the command does not execute. Exit 2 is the +pinned usage-error code for the whole binary: unknown command, unknown +flag, missing required flag or positional, malformed flag value. A +usage error always restates the command's exact usage line, so the +error itself teaches the grammar. + +The parser behind this contract is clap's builder API, constructed at +dispatch from the CommandSpec registry, never derive macros and never +per-command clap declarations: the registry stays the single source of +grammar and clap is its execution engine. clap's own renderings are +never emitted; every parse failure is caught and served as the +canonical JSON usage error above, byte-shaped by this contract alone. +`--flag value` and `--flag=value` parse identically; no command +declares short flags today, so clustering is outside the contract +until one does. The registry pre-pass, not clap, is what refuses an +undeclared flag, because the canonical JSON payload with its +nearest-name suggestion is pinned by this contract; clap normalizes +spellings and values behind it. `napl completions ` emits a completion script for +`bash`, `zsh`, or `fish`, generated from the same registry, a pure +read on stdout; an unknown shell name is a usage error. + +The commands whose meaning requires a committed head are exactly +`diff`, `blame`, and `notes add`; in a workspace that has none they +answer with a prerequisite error, never an empty success shape: the +payload names what is missing and the exact sequence that creates it, +from `napl init` through writing a prompt beside `.napl`, `add`, and +`commit`. `log` and `show` keep their committed contracts and answer +empty honestly. `napl blame` on a path no committed module owns, and +`napl diff` for a target under which no committed module owns output, +say so: the payload carries `tracked: false` and a reason naming the +absent baseline, never an empty patch or empty ancestry that reads as +success. Diff's operand is a target name and its usage line spells it +``; an operand that is not one (a module name, a file +path) is a usage error naming the target names that exist, so the +argument teaches its own vocabulary. The `napl notes add` refusal for a working path +that is not an object name states how an object name is obtained, +naming `napl log` and `napl show` with a one-line example. + +::: test +name: flag equals value and separated value parse identically +given: { workspace: fixture_two_modules, invocations: ["status --module leaf", "status --module=leaf"] } +expect: { outputs_byte_identical: true } +::: + +::: test +name: completions emits a script for a known shell and refuses an unknown one +given: { workspace: fixture_two_modules, invocations: ["completions zsh", "completions tcsh"] } +expect: { zsh_exit_code: 0, zsh_output_nonempty: true, tcsh_exit_code: 2, store_bytes_unchanged: true } +::: + +::: test +name: blame on an unowned path and diff on an outputless target say untracked +given: { workspace: fixture_two_modules, blame_path: file_no_module_owns, diff_target: target_without_outputs } +expect: { tracked: false, reason_names_baseline: true, exit_code: 0 } +::: + +::: test +name: status reports code-side drift and stale references beside prompt freshness +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } +expect: { module_drift: true, stale_reference_count: 1, prompt_side_fresh: true } +::: + +::: test +name: a headless workspace answers baseline commands with the prerequisite sequence +given: { workspace: fresh_init_no_commits, invocation: "blame some/file.rs" } +expect: { error_names_missing_head: true, sequence_named: [init, prompt, add, commit] } +::: + +::: test +name: init with a help flag prints usage and mutates nothing +given: { directory: empty, invocation: "init --help" } +expect: { exit_code: 0, created: [], usage_printed: true } +::: + +::: test +name: an unknown flag is a usage error naming the flag and suggesting the nearest +given: { workspace: fixture_two_modules, invocation: "add cand --modul m" } +expect: { exit_code: 2, error_names_unknown: "--modul", suggested: "--module", executed: false } +::: + +::: test +name: help lists every registered command and per-command help states exact usage +given: { workspace: fixture_two_modules, invocations: ["help", "help add"] } +expect: { all_commands_listed: true, add_usage_line_present: true, exit_code: 0, store_bytes_unchanged: true } +::: + +::: test +name: help renders human text under the human flag and identical json when piped +given: { workspace: fixture_two_modules, invocations: ["help --human", "help --json"] } +expect: { human_not_json: true, human_carries_example: true, json_byte_stable: true } +::: + +Manifests are user-owned glue seams, not records: `Cargo.toml` at the +workspace root and in every crate belongs to the user, edited by hand +like any Rust project, tracked by git, consumed by cargo, and NEVER +owned, installed, validated, or rewritten by the toolchain. A candidate +containing a manifest path is rejected as invalid-candidate; no module's +owned output includes one; nothing in the store records manifest bytes. +The toolchain's only relationship to manifests is diagnosis: `status` +reports, as observations never as writes, a module placed in a crate the +workspace manifest does not list, or a crate directory missing a +manifest. Every manifest diagnostic names the exact workspace-relative +manifest path it means and says the file is the user's to edit, so the +manifest is never a thing status alludes to without locating. A dependency needed by generated code is added by editing the +manifest, and the build battery is what proves it correct. + +::: test +name: a candidate containing a manifest path is rejected +given: { workspace: fixture_two_modules, candidate: contains_cargo_toml } +expect: { outcome: invalid_candidate, manifest_bytes_unchanged: true } +::: + +## The two principles + +P1 ownership: the toolchain owns `.napl` source, the derived layer, the +store, and the mappings between them; every owned fact is provable from +content alone. It contains no LLM invocation, no provider identifier, no +model selection, no completion parsing, no process execution (the only +process is `napl` itself), no clock reads in any semantic path, no +randomness in any semantic path. Generation happens outside: the user's +agent session reads the plan from `napl status`, the specification through +the query surface, writes candidate code, and calls `napl add` then +`napl commit`. + +::: test +name: the binaries spawn no process and name no provider +given: { crate_source: self } +expect: { spawns_processes: false, provider_identifiers: [] } +::: + +P2 footprint scoping: every operation declares a read set (content +identities consumed), a semantic write set (owned records changed), and its +derived projections plus physical install paths. Two operations conflict +when semantic write sets overlap or one's accepted write changes a content +identity in the other's read set. Derived projections (the crate-root +lib.rs module list, the command registry) are never conflict-bearing: +they are recomputed exactly once from the winning merged head inside +commit and rebase. Manifests are outside footprints entirely: user-owned +seams no operation reads as identity or writes at all. + +## Identity + +Expose `pub const CONTRACT_VERSION: u32 = 4` in napl-core (bumped from 1 +by the body-directive grammar, which changes how the same source bytes +are interpreted, from 2 by the relaxed module-name rule below, which +accepts sources contract 2 rejected, and from 3 by the capture verbs, +whose three refusal outcomes join the closed terminal-outcome table). Per module and +target: `surfaceHash` (hash of the recorded public surface), `inputKey` +(hash of the canonical closure manifest over normalized source hash, direct +dependencies' surfaceHashes, placement facts, entry-point version, target +name plus target-definition hash, CONTRACT_VERSION), `baseStateKey` (the +module's committed owned-output state hash), `sliceHash` (hash of the +module's canonical compile slice). Whole-compile `artifactHash` includes +scope and serves determinism tests only, never a commit precondition. Event +identity is `H(content hash + sorted parent hashes)`; ordinals and +timestamps are display metadata, never identity. Semantic identifiers are +content hashes, never a timestamp, pid, hostname, random value, model, or +provider. Telemetry lives in provenance records linking to semantic objects +by hash. + +::: test +name: "a module's inputKey covers source, direct dep surfaces, placement, target identity, and contract version" +given: { workspace: fixture_two_modules, module: consumer } +expect: { input_key_components: [source_hash, direct_dep_surface_hashes, placement_facts, target_name, target_definition_hash, entry_point_version, contract_version] } +::: + +::: test +name: changing a leaf's surface changes the direct dependent's inputKey only +given: { workspace: fixture_chain_three, edit: rename_public_item_in_leaf } +expect: { changed_input_keys: [middle], unchanged_input_keys: [top] } +::: + +::: test +name: a leaf edit absorbed by an unchanged intermediate surface leaves higher dependents' inputKeys unchanged +given: { workspace: fixture_chain_three, edit: private_body_change_in_leaf } +expect: { changed_input_keys: [], unchanged_input_keys: [middle, top] } +::: + +::: test +name: telemetry differences never change transition identity +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipts: [with_usage, without_usage] } +expect: { transition_identities_equal: true } +::: + +::: test +name: history nodes are causal hashes over content plus sorted parents +given: { workspace: fixture_two_modules, commits: 2 } +expect: { event_id_components: [content_hash, sorted_parent_ids], ordinal_in_identity: false, timestamp_in_identity: false } +::: + +## Module names + +A module name is any non-empty single-line scalar. The empty string is the only +value rejected, surfaced as `mapping-integrity`. No character class, case, or +identifier shape is required: `todo-app`, `todo_app`, and non-ascii names are all +legal. The same rule governs every name position the parser reads, the `module` +frontmatter value, each `deps` entry, and each declared root, so all three accept +exactly the set the module rule accepts. This relaxation replaces the contract 2 +identifier constraint and rides the CONTRACT_VERSION bump to 3, the sanctioned +identity transition. As with the 1-to-2 bump, every module's inputKey recomputes +because CONTRACT_VERSION is one of its components; no source or surface changes, +only the version does. + +Because a name is unrestricted, it is never used raw as a filesystem path segment +or a store key. napl-core exposes one injective, reversible encoding used at every +such site: each byte outside `[A-Za-z0-9._-]` is percent-encoded as `%XX` with +uppercase hex, and a name that is exactly `.` or `..` has its dots percent-encoded +too, so an encoded segment is never `/`, `\`, empty, `.`, or `..` and can never +traverse or alias a directory. A name already matching `[A-Za-z0-9._-]` and not +equal to `.` or `..` encodes to itself, so existing records keep their on-disk +names: `todo-app` stays `todo-app`. Every writer that keys a record or scratch +path by module name, the mapl, ir, attribution, and prompt-at-gen documents, the +staging and relocation scratch directories, and the store root map, uses the +encoded segment; decoding recovers the exact name. On a case-insensitive +filesystem two names differing only in ascii case can still share one segment; +that collision is the author's concern, distinct from the traversal safety above. + +Wherever a module name is serialized into a record it is written as a quoted, +escaped scalar, never interpolated raw, so a name containing YAML or JSON +metacharacters reparses to the identical name. + +::: test +name: a non-identifier module name is accepted +given: { workspace: fixture_single_module, module_name: "todo-app" } +expect: { outcome: ok } +::: + +::: test +name: an empty module name is rejected as mapping-integrity +given: { workspace: fixture_single_module, module_name: "" } +expect: { outcome: mapping_integrity } +::: + +::: test +name: deps entries and declared roots accept the same names as the module rule +given: { module_name: "todo-app", dep_name: "a-b", root_name: "x.y" } +expect: { all_accepted: true } +::: + +::: test +name: the name encoding is injective reversible and never traverses +given: { names: ["todo-app", "a/b", "..", "café", "a b"] } +expect: { decode_roundtrip_equal: true, no_segment_in: ["/", "\\", "", ".", ".."], safe_subset_unchanged: ["todo-app"] } +::: + +::: test +name: a name with yaml metacharacters is written quoted and reparses identically +given: { module_name: "a: {b} #c" } +expect: { record_reparse_equal: true, written_as_quoted_scalar: true } +::: + +## The compile derivation + +Compilation is a pure derivation and NOT a command: parse the graph, +compute per-module slices for the changed frontier, derive +dependency-ordered waves. Never persisted, and never spelled on the CLI — +it is the internal engine of `status`, `add`, `commit`, `watch`, and the +lsp. Per module the slice carries: normalized source body and hash, direct +dependency surfaces and surfaceHashes, placement and allowed-output +constraints including the surface grammar, the owned-output base and +baseStateKey, inputKey, sliceHash, wave position, receipt requirements. It +contains no provider, model, prompt assembly, credentials, timestamps, +retry policy, pricing, or test results. `napl status --module ` +reports the module's PLAN from this derivation — owned globs, placement +root, projection ownership, receipt requirements, wave position — the +invariant requirements only, a pure function of the working tree's +prompts; candidate-dependent validation belongs to `add`, never the plan. + +::: test +name: status reports a module plan derived from the working tree +given: { workspace: fixture_two_modules, flag: module_leaf } +expect: { plan_fields: [owned_globs, placement_root, receipt_requirements, wave_position], candidate_dependent_fields: absent } +::: + +::: test +name: status module answers live state and the derived projection beside the plan +given: { workspace: fixture_two_modules, flag: module_leaf, working_tree_edit: leaf_output_touched } +expect: { state_fields: [attribution, drift, pending, stale_references, tracked], drift: true, projection_path_present: true, projection_content_present: true } +::: + +::: test +name: show lists attached notes inline and log counts them +given: { workspace: fixture_two_modules, history: [commit, note_attached] } +expect: { show_notes_listed: 1, show_note_text_present: true, log_note_count: 1 } +::: + +## Body directives + +The prompt body grammar reserves directive blocks, `directiveVersion 1`, +and recognizing them is part of the identity contract: text that older +binaries read as inert prose can be a compile error under this grammar, so +this recognition rides a CONTRACT_VERSION bump, the sanctioned identity +transition. The first directive is `test`. An opener is a line that is +exactly `::: test`; the closer is a line that is exactly `:::`; no +nesting. An unknown `::: ` opener is a compile error, never prose, +so future directive names are non-breaking. Directive blocks are body +text: they count in normalized body lines and attribution ranges may +cover them. + +Block content is one case in an exact value grammar mapping 1:1 onto +canonical JSON, so two binaries cannot disagree on a value: one mapping +with keys `name` (required, non-empty, single line), `given` (optional, +mapping), `expect` (required, mapping). Block mappings and sequences +indent by exactly two spaces; flow `{}` and `[]` forms are allowed. +Strings are double-quoted with JSON escapes, or plain scalars ended by a +structural character (`:`, `,`, `{`, `}`, `[`, `]`, `#`) or end of line +with no leading or trailing space. Numbers are JSON number syntax; +`true`, `false`, `null` are keywords; every other plain scalar is a +string. Nothing else from YAML exists here: no comments, no duplicate +mapping keys, no anchors, aliases, tags, block scalars, multiline +scalars, multi-document markers, octal or hex, timestamps, or merge keys. +A case needing prose belongs in prose. Diagnostics are compile errors +naming the exact body line, judged in this precedence: unclosed block, +unknown directive name, nested opener, content outside the value grammar, +missing or empty name, missing expect, duplicate case name within the +module across both positions after NFC normalization and whitespace trim. +Frontmatter `tests:` remains legal; a module's case set is the union of +both positions; extraction of cases from body bytes is deterministic — +same bytes, same cases. + +::: test +name: an unclosed test directive block is a compile error naming its opener line +given: { body: directive_without_closer } +expect: { outcome: compile_error, line_named: opener } +::: + +::: test +name: "an unknown directive name is a compile error, never prose" +given: { body: unknown_directive_opener } +expect: { outcome: compile_error, treated_as_prose: false } +::: + +::: test +name: a duplicate case name across frontmatter and body errors +given: { frontmatter_case: alpha, body_directive_case: alpha } +expect: { outcome: compile_error, diagnostic: duplicate_case_name } +::: + +::: test +name: directive case extraction is deterministic +given: { body: fixture_with_two_directives, runs: 2 } +expect: { cases: 2, bytes_equal: true } +::: + +## Recorded surfaces + +At commit, extract the candidate's public API with tree-sitter (pinned +grammars) and record it as a `surface` object; dependents' inputKeys pin +recorded surfaceHashes, store facts, never demand scans. The surface +grammar makes extraction sound by construction and violations reject as +`mapping-integrity`: for Rust, public items must be named `pub` +declarations at statically visible paths, no glob re-exports, no +macro-generated `pub` items; a named `pub use` is allowed and is recorded +as the unresolved written binding, never a resolved signature. For +TypeScript: explicit named exports only, no `export *`. Extraction is +syntactic, deterministic, and loud on malformed input. Loud means a +payload, never silence: extraction over bytes that do not parse +answers a parse failure naming the file and the first error's line, +distinct from a genuinely empty surface, in every seat that extracts, +the commit recorder, the `surface` read, and the query layer, so an +empty item list always means parsed and nothing public. Re-extraction from +committed output must reproduce the recorded surface byte-identically. + +The `surface` read is honest about absence the same way: a module with +no committed state under the target answers `tracked: false` naming +the absent baseline, never an empty item list that reads as a real +surface with nothing public. + +::: test +name: extraction over unparseable bytes answers parse-failed never an empty surface +given: { source_bytes: syntactically_invalid_rust, extraction: rust_surface } +expect: { outcome: parse_failed, error_names_line: true, served_as_empty_surface: false } +::: + +::: test +name: surface for an uncommitted module says untracked never an empty list +given: { workspace: fixture_two_modules, module: pending_never_committed, read: surface_for_target } +expect: { tracked: false, reason_names_baseline: true, empty_item_list_served: false } +::: + +::: test +name: a candidate with a glob re-export is rejected as mapping-integrity +given: { workspace: fixture_two_modules, candidate: contains_glob_reexport } +expect: { outcome: mapping_integrity, published_semantic_state: false } +::: + +::: test +name: a named pub use is recorded in the surface as the written binding +given: { workspace: fixture_two_modules, candidate: valid_with_named_reexport } +expect: { surface_contains_written_binding: true, surface_contains_resolved_signature: false } +::: + +::: test +name: recorded surface re-extraction from committed output is byte-identical +given: { workspace: fixture_two_modules, candidate: valid_for_consumer } +expect: { reextraction_equal: true } +::: + +## add, then commit + +Git parity is the mental model: `add` stages, `commit` installs. The +receipt is an internal record between them, never a user-facing noun. +`napl commit` with no arguments installs every staged record in +dependency-wave order, rebasing where the decision table allows, one +outcome reported per record; `--staged ` installs one. A +multi-module add followed by a bare commit is therefore one command +each, with the sequencing and retry the table already defines owned by +the tool, never hand-looped; and when an earlier record in the same +invocation moves the head so that a later one returns write-conflict +or stale-input against it, bare commit re-derives that record against +the new head, exactly what re-running add would produce, and retries +it once, so modules sharing one crate land in one invocation instead +of hand-ordered pairs. A retry that fails again returns its outcome +honestly. A bare commit that finds nothing staged says so instead of +dressing emptiness as success: the payload names that zero records +were staged, and when any module carries working-tree drift it names +each drifted module beside the exact `napl add` invocation that +would stage it, so the empty commit is the teacher, never the trap. +Commit sweeps the staged record it installed, so a later +bare commit never re-attempts history; `napl gc` reports staged +records among the unreachable, and `napl gc --apply` sweeps them: +they are disposable litter by the git-state law, re-creatable by +re-running add, and no one should ever hand-edit `.napl` to be rid of +them. + +`napl add` stages a module state from any of three sources, and the +working tree is the default one, exactly as in git: + +- `napl add [...]` names working-tree files. Each path + resolves to its owning module through the owned globs (unique by + commit invariant, so no `--module` is needed), and the staged state + is that module's committed output with exactly the named files + overlaid from the working tree. Paths owned by different modules + stage one record per module in one invocation. A path no module owns + is a usage error naming the owned globs that do exist. +- `napl add --module ` stages the module's whole owned tree as + it sits in the working tree. +- `napl add --module ` stages explicit candidate + bytes, for the rare case where the bytes to stage are not the + working tree. + +All three forms feed one pipeline with `[--attribution ] +[--usage ]`: ingest the staged bytes into immutable content +addressed blobs (reject links, devices, duplicate normalized paths, +absolute or parent-escaping paths, oversized entries or totals; all +later steps read ingested blobs, never the source files); parse every +staged file the target claims as source with the target's pinned +tree-sitter grammar, and refuse as `invalid-candidate` when any parse +tree carries an error node, naming the file, the first error's line, +and the malformed region, because a surface extracted from a broken +tree is not a fact worth recording; the gate is syntax only, judged by +the same grammar the surface extractor uses, and everything past +syntax, types, borrows, semantics, stays the acceptance battery's job; +commit re-runs the same gate when it recomputes the staged record, so +bytes that do not parse can never install through any door; compute +the slice and validate against it; resolve attribution as below; validate +and attach usage evidence when supplied; then write the staged record — +the internal receipt: identities generated against, base snapshot +hashes, candidate tree hash, output manifest, attribution, optional +reported fields. Validation failures surface at add time with the same +outcome classes commit uses. Staged records are transient coordination +state, disposable litter by the git-state law: never promoted to +history, safely deletable, re-creatable by re-running add. + +Attribution transports instead of being demanded. Add computes the +byte diff between the module's committed output and the staged bytes, +and the prompt's committed body and its pending body, and transports +every committed attribution claim through those diffs +deterministically: a range the diff proves untouched survives +verbatim, a range below an insertion or deletion shifts by its size, +and a claim landing in a rewritten or ambiguous region does not +transport, because the transport never guesses; a range it cannot +prove through the diff is asked for, never invented. When every claim +transports, add stages with the transported map and no `--attribution` +flag is needed: the paperwork updates itself as a consequence of the +diff already being content-addressed on both sides. When any claim +fails to transport, add refuses as `invalid-candidate` listing exactly +the claims that need affirmation, each beside the observed bound or +region it was checked against, and `--attribution` supplies +affirmations for the listed claims only, layered over the transported +remainder. Any attribution refusal, transport or explicit, names the +observed bound (the actual line count or surviving range) next to the +rejected claim, so a wrong range never costs a manual count. Each +listed claim also carries its index in the recorded map and its note +verbatim, so an affirmation is written against named prose, never +positional trust or a separate query join. + +::: test +name: a transport refusal lists each claim with its map index and note +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } +expect: { listed_claims: 1, claim_index_present: true, claim_note_verbatim: true } +::: + +A module's first add has no committed map to transport, and it demands +no hand-authored one either: absent `--attribution`, add stages the +trivial whole map, every staged file claimed in full by the whole +prompt body, recorded as whole-file claims and honestly coarse. The +cold path to a verified first commit is therefore write the prompt, +write the code, `add --module`, `commit`, with attribution present and +fresh, never `absent`, and `--no-verify` never required to get started. +A claim, in any map, may be spelled whole: naming a file with no +numbers claims all of it, and naming a prompt range as whole claims +the whole body; bounds are derived at validation time from the staged +blob and the pending body, so no line count is ever transcribed by +hand and a whole-file claim survives any growth of its file. + +Two more spellings close the remaining gaps: `napl add +--module ` stages a path under a module explicitly, for the file +that is new in a crate several modules share, where neither the +committed manifest nor the globs can disambiguate it alone; and +`napl add --check` with any form runs the entire pipeline, transport +verdict and refusal listing included, and writes nothing, not even a +staged record, the dry run that answers before anything lands. The +check verdict says exactly what it judged and what it did not: the +payload enumerates the validations that ran (placement, ingest rules, +the parse gate, attribution transport, evidence) and names what stays +unjudged, everything past syntax, the acceptance battery's job, so +`would-stage` means the record is sound and the syntax parses, never +that the code is correct. A transport refusal +reports each endpoint's verdict separately: a claim whose prompt side +failed but whose code side transported serves the transported code +range beside the failed prompt range, and the affirmation supplies +only the side that actually needs a human, never both because one +refused. An unparseable or wrong-shaped `--attribution` file is +answered like a bad ref payload: the refusal carries the complete map +contract, every field and one complete valid example, never a bare +"not parseable". The map reader speaks the same value grammar as body +directives, so block and flow spellings of one map are one map: +`lines: [4, 9]` and its two-line block form parse identically. A map +that parses but yields no entries, or that fails at any construct, is +refused naming the exact line and the construct that stopped the +reader, beside the full contract, never a bare count of zero. The +contract is also taught before any failure: `help add` and +`add --help` serve the same complete map contract and worked example +the refusal carries, so the map is learnable from help instead of +from trial and error. + +::: test +name: flow and block spellings of one attribution map parse identically +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, maps: [flow_sequences, block_sequences] } +expect: { parsed_entries_equal: true, both_stage: true } +::: + +::: test +name: a map failing mid-parse names the line and construct beside the contract +given: { workspace: fixture_two_modules, attribution_file: bad_construct_on_line_3 } +expect: { error_names_line: 3, error_names_construct: true, error_carries_valid_example: true } +::: + +::: test +name: add help carries the complete attribution map contract and example +given: { workspace: fixture_two_modules, invocation: "help add" } +expect: { map_fields_listed: true, map_example_present: true, exit_code: 0, store_bytes_unchanged: true } +::: + +::: test +name: a first add with no flag stages the whole map and commits verified +given: { workspace: fresh_init_root_prompt, candidate: valid_root_level, attribution_flag: absent } +expect: { add_outcome: staged, map_kind: whole_file_claims, commit_outcome: success, attribution: fresh, no_verify_used: false } +::: + +::: test +name: a whole spelling claims the entire file and survives file growth +given: { workspace: fixture_two_modules, map_entry: whole_file_no_numbers, then: file_grows } +expect: { claim_valid: true, bounds_derived: true, retranscription_needed: false } +::: + +::: test +name: add check reports the full verdict and writes nothing +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_check } +expect: { verdict_listed_claims: 1, staged_records: 0, staging_dir_untouched: true, store_bytes_unchanged: true } +::: + +::: test +name: an explicit module stages a new path in a shared crate +given: { workspace: fixture_shared_crate_two_modules, working_tree: new_uncommitted_file_in_shared_glob, add_path_with_module: true } +expect: { add_outcome: staged, module_as_named: true } +::: + +::: test +name: add of one working-tree path stages the committed state with that file overlaid +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, add_paths: [edited_file] } +expect: { add_outcome: staged, staged_state: committed_plus_overlay, unnamed_files_at_committed_bytes: true } +::: + +::: test +name: paths owned by two modules stage one record per module +given: { workspace: fixture_two_modules, working_tree_edit: both_modules_touched, add_paths: [file_of_leaf, file_of_consumer] } +expect: { staged_records: 2, modules: [consumer, leaf] } +::: + +::: test +name: a path no module owns is a usage error naming the owned globs +given: { workspace: fixture_two_modules, add_paths: [unowned_file] } +expect: { exit_code: 2, error_names_owned_globs: true, staged_records: 0 } +::: + +::: test +name: a pure line-shift edit transports the whole map and needs no attribution flag +given: { workspace: fixture_two_modules, working_tree_edit: insert_lines_above_attributed_range, add: module_form_without_attribution } +expect: { add_outcome: staged, map_transported: true, ranges_shifted: true, attribution_flag_needed: false } +::: + +::: test +name: a claim in a rewritten region refuses listing that claim with its observed bound +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } +expect: { outcome: invalid_candidate, listed_claims: 1, observed_bound_present: true, untouched_claims_not_listed: true } +::: + +::: test +name: affirmations layer over the transported remainder and stage +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_affirmation_for_listed_claim } +expect: { add_outcome: staged, transported_claims_kept: true, affirmed_claim_recorded: true } +::: + +::: test +name: add stages a valid candidate and commit installs it +given: { workspace: fixture_two_modules, candidate: valid_with_attribution } +expect: { add_outcome: staged, commit_outcome: success } +::: + +::: test +name: an explicit candidate whose transport is incomplete and unaffirmed fails loudly +given: { workspace: fixture_two_modules, candidate: rewritten_region_without_affirmation } +expect: { outcome: invalid_candidate, listed_claims_present: true } +::: + +::: test +name: a candidate with a parent-escaping path is rejected as invalid-candidate +given: { workspace: fixture_two_modules, candidate: contains_dotdot_path } +expect: { outcome: invalid_candidate, published_semantic_state: false } +::: + +::: test +name: tampering a staged blob after ingestion never mixes into the install +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, tamper: candidate_file_after_ingest } +expect: { outcome_in: [installed_from_immutable_blobs, store_integrity], mixed_content: false } +::: + +`napl commit --staged ` is the one guarded +transaction over what add staged. Pipeline: verify the staged record +(recompute sliceHash, inputKey, baseStateKey at the recorded bases, +reject mismatches, enforce evidence bounds); extract and record the +surface; derive the complete transition (three-part footprint, member-set +delta, shared projections recomputed from the merged head); stage all +materialization outside any lock; then one bounded critical section under +the pointer lock performing only the footprint recheck and the pointer +and directory swaps, bounded by the transition's file count, never +hashing content inside the lock. + +::: test +name: a crash between staged materialization and pointer swap leaves a deterministic recovery plan +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, crash_after: staging } +expect: { store_readable: true, recovery_plan_deterministic: true, semantic_state_unchanged: true } +::: + +::: test +name: a receipt whose recomputed slice hash disagrees is rejected as invalid-receipt +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: tampered_slice_hash } +expect: { outcome: invalid_receipt, published_semantic_state: false } +::: + +The decision table, judged per arriving transition against the current +head: identities reproduce with head unmoved installs; head moved with +identities reproducing and footprints disjoint rebases then installs +(projections re-derived, never reused); inputKey failure returns +`stale-input`; baseStateKey failure returns `base-output-changed`; semantic +write overlap returns `write-conflict`; lock contention returns +`lock-contended`. Terminal outcomes, never retried unchanged: +`invalid-candidate`, `invalid-receipt`, `mapping-integrity`, +`resource-limit`, `unsupported-version`, `store-integrity`, `io-error`. +Every outcome is a canonical machine-readable JSON payload naming whether +blobs or provenance were published; no outcome publishes semantic state +partially. Exit codes: 0 success, 10 through 13 for the four returned +concurrency outcomes in table order, 20 through 26 for the seven terminal +outcomes in the order above. Staged records carry `receiptVersion`, the +per-module identities generated against, base snapshot hashes, the +candidate tree hash, the output manifest, then optional reported fields; +the compiler recomputes everything derivable and rejects on mismatch; +absent telemetry is `unavailable`, never zero. `napl notes add --to +` attaches later provenance without touching semantic state. + +::: test +name: commit installs a valid candidate against an unmoved head +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, head: unmoved } +expect: { outcome: installed, head_advanced: true, surface_recorded: true } +::: + +::: test +name: a bare commit installs every staged record in wave order +given: { workspace: fixture_two_modules, staged: [record_for_leaf, record_for_consumer], commit_args: none } +expect: { outcomes: [success, success], order: wave_order, hand_sequencing_needed: false } +::: + +::: test +name: bare commit re-derives and lands records for modules sharing one crate +given: { workspace: fixture_shared_crate_two_modules, staged: [record_for_first, record_for_second], commit_args: none } +expect: { outcomes: [success, success], retried_after_head_move: 1, hand_sequencing_needed: false } +::: + +::: test +name: commit sweeps the record it installed +given: { workspace: fixture_two_modules, staged: [record_for_leaf], commit: bare_then_bare_again } +expect: { first_outcomes: [success], second_outcomes: [], reattempted_history: 0 } +::: + +::: test +name: a bare commit with nothing staged names the drifted modules and their add commands +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, staged: none, commit_args: none } +expect: { installed: 0, nothing_staged_named: true, drifted_modules_named: [consumer], add_command_named: true } +::: + +::: test +name: a per-side transport refusal serves the surviving side's transported range +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_prompt_section_only, add: module_form_with_check } +expect: { prompt_side: refused, code_side: transported, transported_code_range_served: true, affirmation_needed_sides: [prompt] } +::: + +::: test +name: check enumerates the parse gate and refuses a file that does not parse +given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, add: module_form_with_check } +expect: { validations_enumerated: true, parse_gate_listed: true, outcome: invalid_candidate, error_names_file_and_line: true, staged_records: 0 } +::: + +::: test +name: add refuses a candidate whose source does not parse naming file and line +given: { workspace: fixture_two_modules, candidate: syntactically_invalid_rust } +expect: { outcome: invalid_candidate, error_names_file_and_line: true, published_semantic_state: false } +::: + +::: test +name: commit recomputation refuses staged bytes that do not parse +given: { workspace: fixture_two_modules, staged: record_with_tampered_unparseable_blob } +expect: { outcome_in: [invalid_candidate, store_integrity], installed: false } +::: + +::: test +name: a bad attribution file answers with the complete map contract and example +given: { workspace: fixture_two_modules, attribution_file: empty_object, add: module_form_with_attribution } +expect: { error_lists_all_fields: true, error_carries_valid_example: true } +::: + +::: test +name: the plan labels projections and names a legal first output +given: { workspace: fresh_init_root_prompt, flag: module_root } +expect: { projection_labeled_tool_derived: true, legal_first_output_named: true } +::: + +::: test +name: gc apply sweeps staged records and touches nothing reachable +given: { workspace: fixture_two_modules, staged: [stale_record], gc: apply } +expect: { staging_empty: true, reachable_objects_untouched: true, store_healthy: true } +::: + +::: test +name: commit against a moved head with disjoint footprints rebases and installs +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: to_unrelated_module } +expect: { outcome: installed, rebased: true } +::: + +::: test +name: commit whose direct dep surface moved underneath returns stale-input +given: { workspace: fixture_chain_three, candidate: valid_for_middle, intervening_commit: leaf_surface_change } +expect: { outcome: stale_input, head_advanced: false } +::: + +::: test +name: commit whose own outputs moved underneath returns base-output-changed +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: same_module_other_candidate } +expect: { outcome: base_output_changed } +::: + +::: test +name: two concurrent disjoint commits both land and neither touches a manifest +given: { workspace: fixture_two_modules, concurrent_candidates: [module_a, module_b], orders: [ab, ba] } +expect: { both_installed: true, manifest_bytes_unchanged: true } +::: + +::: test +name: "absent telemetry reads as unavailable, never zero" +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: without_usage } +expect: { usage: unavailable } +::: + +Attribution rides the staged record; there is no other write path for it. +`napl add --attribution ` carries the module's mapping of prompt body +line ranges to generated file line ranges with notes; add validates every +claim before staging: each file path inside the module's owned globs, +each code line range present in the candidate tree, each prompt line +range present in the current normalized prompt body; a mapping that lies +is `invalid-candidate`. An entry may own no file at all: contract-level +prose — a law — that governs the module rather than any single path; +validation accepts a file-less entry (its prompt range must still exist) +and the query layer serves it flagged as law. An add whose transport is +incomplete and whose affirmations do not cover the listed claims fails +loudly as `invalid-candidate`, naming exactly which claims are missing; +`napl add --no-verify` is the explicit escape hatch that stages anyway +and records the module's attribution as stale on install. Any prompt +edit, code transition, or move that changes what a recorded mapping +points at marks it stale the same way; nothing ever repairs a mapping +automatically. `napl status` reports attribution freshness per +module (`fresh`, `stale`, `absent`) and, for stale or absent, emits a +machine-readable directive naming the exact fix: commit with a fresh +attribution for that module. Relocation recomputes only the path prefix +of a mapping, never its line content. The lsp serves a stale mapping +marked stale rather than hiding it; absent answers empty. + +An attribution entry is a reference, and a reference has a direction: +prompt-to-code, a prompt body line range naming generated file line +ranges, or code-to-prompt, a generated file line range naming a prompt +body line range. Every entry recorded before directions existed reads as +prompt-to-code; migration is a reading, never a rewrite of stored +records. Validation is identical for both directions and referential +only, the same claims checked the same way, and nothing about a +direction implies the sentence describes the code: a reference is a +claim with provenance and freshness, and the record vocabulary never +says more. + +A reference carries a provenance block, recorded by the compiler at +staging time from what the caller supplied: recording model identity, +timestamp, tool version, and an optional usage-envelope reference +through the existing evidence seam. Every absent field is `unavailable`, +never zero, never invented, and provenance is display and pricing +metadata that never enters any identity, exactly as telemetry already +behaves. The query layer and the lsp serve the provenance block with the +entry. + +Staleness covers both ends of a reference, and working-tree drift is one +of the ways an end changes: a reference whose code side no longer +matches the committed bytes, or whose prompt side changed in the working +tree, reads stale on both endpoints, computed at read time from the +records and the working tree, never stored as a transition. A hand edit +to generated source is drift like any other: legal, visible through the +same status and diff machinery, healable by restore or by re-recording +the reference; nothing repairs a reference automatically and nothing +polices the edit that staled it. + +::: test +name: a code-to-prompt entry stages and is served with its direction +given: { workspace: fixture_two_modules, candidate: valid_with_code_to_prompt_reference } +expect: { add_outcome: staged, served_direction: code_to_prompt } +::: + +::: test +name: entries recorded before directions read as prompt-to-code unchanged +given: { workspace: fixture_two_modules, history: [pre_direction_attribution] } +expect: { served_direction: prompt_to_code, stored_records_rewritten: false } +::: + +::: test +name: provenance is served with the entry and absent fields read unavailable +given: { workspace: fixture_two_modules, candidate: reference_without_usage_envelope } +expect: { provenance_served: true, usage: unavailable, cost_component: unavailable } +::: + +::: test +name: a hand edit to the code side reads stale on both endpoints +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } +expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } +::: + +::: test +name: a working-tree prompt edit reads stale on both endpoints +given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched } +expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } +::: + +::: test +name: a code-to-prompt reference naming a missing prompt range is invalid-candidate +given: { workspace: fixture_two_modules, candidate: code_to_prompt_reference_to_missing_range } +expect: { outcome: invalid_candidate, published_semantic_state: false } +::: + +## edit and write: the capture verbs + +`napl edit` and `napl write` are the deterministic capture layer in +front of the same guarded door. They carry the familiar tool +signatures: `napl edit --old-string --new-string +[--replace-all]` replaces an exact occurrence in a file, and +`napl write ` writes stdin as the file's full content; its usage +line says the content comes from stdin, and an empty stdin refuses as +a usage error naming `--empty`, the explicit flag that authorizes +writing a zero-byte file, so a forgotten pipe can never silently +truncate. Either +side is editable, a prompt or a generated file, by a human or an LLM; +a hand edit through an ordinary editor stays equally legal, and the +verbs add capture, never exclusivity. The verbs never synthesize or +rewrite prose beyond the bytes given, never invoke a model, never +decide placement, never repair a reference, and never bypass the +guard: `add` and `commit` remain exactly what they are. + +Each call, deterministically and in this order: validate, apply, +capture. When the target is a prompt file, the result of the edit is +validated first by the same reader `check` and `add` use; a result +that fails validation refuses as `prompt-invalid` and applies nothing. +An `--old-string` that matches nowhere, matches more than once +without `--replace-all`, or equals `--new-string` refuses as +`edit-mismatch` and applies nothing, so a no-op edit can never mint a +record. Only a call that will succeed touches the working tree, so +every refusal leaves every file byte-identical. + +A call may carry one reference: `--ref `, a single object naming +a direction and both endpoints, prompt body line range and generated +file line range, in either direction per the reference contract above. +The contract is taught, never reverse engineered: the verbs' usage +lines and flag meanings name the object's fields, and the FIRST +validation failure of a ref payload answers with the complete +contract in one refusal, every field, the closed list of valid +`direction` values, and one complete valid example, never one missing +field per attempt. +The reference is validated referentially only, exactly as strong as +attribution validation and no stronger: the file path inside the +owning module's placement, both line ranges present in the +post-edit bytes of their files. A reference naming a missing line or +a path outside placement refuses as `reference-unresolvable` and +applies nothing. A valid reference-bearing call stages a +reference-only record and commits it through the guarded transaction +in the same invocation: the transition carries the reference entry and +its provenance, no output-tree change and no surface change, and every +rule of the decision table, journaled atomicity, and recovery applies +to it unchanged. The success payload carries the recorded reference +object verbatim and its transition hash, and says plainly that the +edited file remains working-tree drift until its module is committed: +recording a reference and advancing the committed baseline are +different acts, and the payload spells the sequence that performs the +second, `napl add` for the module then `napl commit`, so nobody reads +`recorded: true` as committed and nobody feeds a bare commit nothing. +A plain apply teaches the same way: a written or edited file is +working-tree drift until staged and committed, and the success +payload of both verbs names both verbs in order. `napl show ` lists the +transition's reference entries inline exactly as it lists notes. A +call without `--ref` is a plain edit: apply, no +record, no store touch. Any existing reference whose endpoint the edit +moved needs no marking, because both-ends staleness is already +computed at read time from the working tree; the verbs store nothing +about it. + +A reference the verb records is evidence, and evidence serves itself. +The record carries the content hash of each endpoint's post-edit +bytes, the file as the verb wrote it and the normalized prompt body it +validated against. Freshness reads those hashes first: a verb-recorded +reference whose endpoint bytes still match the working tree reads +fresh, and it goes stale only when the tree moves off the bytes the +record names, never merely because the committed baseline lags the +edit, so `recorded: true` is never followed by a stale count the verb +itself caused. At the next add, transport seeds from the same +evidence: a committed claim refusing in a rewritten region is affirmed +automatically when a reference transition recorded since the module's +committed output covers that region and its endpoint hashes match the +staged bytes, because the tool that applied the edit owns its diff; +the affirmation is the record, and no hand-authored map is ever +demanded for a range a capture verb already recorded. A recorded +reference whose hashes no longer match affirms nothing and is listed +in the refusal like any other claim, so the shortcut never guesses. + +::: test +name: a verb-recorded reference reads fresh until the tree moves off its bytes +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: status_module } +expect: { stale_reference_count: 0, drift: true } +::: + +::: test +name: add after a referenced edit self-affirms the rewritten region and stages without a map +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, add: module_form_without_attribution } +expect: { add_outcome: staged, affirmed_from_reference_record: true, attribution_flag_needed: false } +::: + +::: test +name: a recorded reference whose bytes moved again affirms nothing +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: hand_edit_same_region, add: module_form_without_attribution } +expect: { outcome: invalid_candidate, listed_claims_present: true, affirmed_from_reference_record: false } +::: + +Provenance rides the session window: `--session ` names the +usage-proxy session whose log lines are this call's evidence, and when +the flag is absent the verb reads the `NAPL_SESSION` environment +variable, so a call made under `napl agent` carries its window with no +extra argument. The recorded usage reference is the window itself, +media type, workspace-relative log path, session id, and the line span +observed at staging, priced lazily by `napl cost` at read time, never +aggregated at record time. Model identity and timestamp come from the +window's log lines, or read `unavailable` when there is no window; +the verbs read no clock, so the same call over the same working tree, +store state, and log bytes produces byte-identical records. Absent is +`unavailable`, never zero, never invented, and provenance never +enters identity. + +The three refusals join the closed terminal-outcome table under the +contract bump to 4, pinned exit codes continuing the table order: +`edit-mismatch` 27, `reference-unresolvable` 28, `prompt-invalid` 29. +None reuses `mapping-integrity`, and each is a canonical +machine-readable JSON payload naming what failed and that nothing was +applied or published. + +::: test +name: an edit with a valid code-to-prompt reference stages and commits in one call +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } +expect: { outcome: installed, served_direction: code_to_prompt, output_tree_changed: false } +::: + +::: test +name: the same edit call twice produces byte-identical records +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, runs_from_same_base: 2 } +expect: { records_byte_identical: true } +::: + +::: test +name: a reference to a missing line refuses as reference-unresolvable and applies nothing +given: { workspace: fixture_two_modules, edit: ref_names_missing_line } +expect: { outcome: reference_unresolvable, exit_code: 28, files_byte_identical: true } +::: + +::: test +name: a prompt edit that breaks the prompt refuses as prompt-invalid and applies nothing +given: { workspace: fixture_two_modules, edit: prompt_edit_breaking_directive } +expect: { outcome: prompt_invalid, exit_code: 29, files_byte_identical: true } +::: + +::: test +name: an ambiguous old-string without replace-all refuses as edit-mismatch +given: { workspace: fixture_two_modules, edit: old_string_matches_twice } +expect: { outcome: edit_mismatch, exit_code: 27, files_byte_identical: true } +::: + +::: test +name: the first bad ref payload answers with the whole contract at once +given: { workspace: fixture_two_modules, edit: ref_payload_bare_string } +expect: { error_lists_all_fields: true, error_lists_direction_values: true, error_carries_valid_example: true, files_byte_identical: true } +::: + +::: test +name: a recorded edit serves its reference in the payload and in show +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } +expect: { payload_reference_verbatim: true, payload_names_drift_until_commit: true, show_lists_reference: true } +::: + +::: test +name: a prose-only edit needs no reference and touches no store state +given: { workspace: fixture_two_modules, edit: plain_prompt_edit_valid } +expect: { applied: true, store_bytes_unchanged: true } +::: + +::: test +name: verb success payloads spell the add then commit sequence +given: { workspace: fixture_two_modules, invocations: [plain_write_owned_file, referenced_code_edit_with_ref] } +expect: { write_payload_names: [add, commit], edit_payload_names: [add, commit] } +::: + +::: test +name: write with empty stdin refuses naming the empty flag and writes nothing +given: { workspace: fixture_two_modules, write: target_with_empty_stdin } +expect: { exit_code: 2, error_names_flag: "--empty", files_byte_identical: true } +::: + +::: test +name: an edit touching a referenced line reads stale on both endpoints afterward +given: { workspace: fixture_two_modules, edit: plain_edit_on_referenced_code_line } +expect: { reference_status: stale, stale_endpoints: [prompt, code] } +::: + +::: test +name: a session window from the environment is recorded as the usage reference +given: { workspace: fixture_two_modules, env: { NAPL_SESSION: s1 }, log: proxy_log_with_s1_lines, edit: referenced_code_edit_with_ref } +expect: { provenance_usage_session: s1, usage_span_recorded: true, aggregated_at_record: false } +::: + +::: test +name: an edit with no session window records provenance unavailable +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, env: {} } +expect: { provenance_model: unavailable, provenance_usage: unavailable } +::: + +## The rest of the porcelain + +`napl status` observational classification, zero mutation, and it +judges both ends: per module it reports `drift: true` when the +committed output differs from the working tree, and the module's stale +reference count, the same derivations diff and the lsp already serve, +so status can never read clean while the query surface reads stale. +A module fresh on the prompt side and drifted on the code side says +so in one payload. `napl status --module ` answers with the +module's live state beside its plan, tracked or pending, drift, stale +reference count, attribution status, in the same payload as the owned +globs and wave position, so the command named status never answers +with a plan alone; and the payload carries the derived entry-file +projection, path and exact content, so the line a new file needs in a +shared entry file is read from the tool, never composed by hand. +Every projection path the plan mentions is labeled what it is, +tool-derived and never user-supplied, and the plan names one concrete +legal first output path (the module name under the target's source +dir), so the obvious wrong first move, hand-writing the entry file, +is warned against before it is made. The add refusal for a module +whose owned tree holds nothing stageable says the same thing: which +paths present are projections, and what the first legal file would +be. +`napl log` entries carry each transition's note count and reference +count, and the log payload names `napl show ` as where +the full note and reference text is served, so provenance is one +named hop away, never a secret; `napl show ` lists its +attached notes inline, hash and text, so a note attached is a note +findable without plumbing. + +::: test +name: log counts references and names show as the inspection verb +given: { workspace: fixture_two_modules, history: [commit, referenced_edit] } +expect: { log_reference_count: 1, show_named_in_payload: true } +::: + +`napl log` +per-module or store-wide history. `napl show ` inspect one object. +`napl diff` drift between committed outputs and working tree as a canonical +byte-stable patch. `napl blame ` derived ancestry: surviving lines +trace through accepted patches by canonical replay; rewritten regions blame +the rewriting transition, honestly coarse. Blame reads committed +bytes only, and says so when it matters: a blamed path whose working +tree drifts from the committed output carries a drift notice naming +`napl diff` as where the uncommitted change lives, so ancestry over +the committed bytes is never mistaken for the present file. + +::: test +name: blame over a drifted path names the drift and points at diff +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, blame_path: drifted_file } +expect: { drift_noticed: true, diff_named: true, ancestry_served: true } +::: + +`napl lint` unreachable modules +under an explicit `roots` declaration; when roots are absent the refusal +is loud, exit 2 with canonical JSON naming the missing prerequisite and +the exact declaration that would satisfy it, never an empty findings +list under exit 0; unconsumed surface entries (diagnosis only), module staleness; +findings are proposals with cascade previews; `napl lint --fix` applies +only complete deterministic mapping-safe edits through the guarded commit +path and never removes lines. `napl lint ` is the +source-language scan, a different question needing no roots: it parses +every committed module's owned working-tree file under that target +with the pinned grammar and reports one finding per file that fails, +naming the file and the first error's line. Every lint payload names +the scope it judged, graph or source syntax, so an empty findings +list is never read wider than the scan that produced it. Source +syntax findings are facts, not proposals: a target scan carrying any +finding exits 1, so automation can never read broken source as +success, and a clean scan exits 0; the graph scope keeps its +advisory exit. + +::: test +name: lint of a target exits nonzero on source findings and zero when clean +given: { workspace: fixture_two_modules, runs: [clean_tree, syntactically_invalid_owned_file], lint: target_form } +expect: { clean_exit_code: 0, broken_exit_code: 1, findings_when_broken: 1 } +::: + +::: test +name: lint of a target reports a parse finding per malformed working-tree file +given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, lint: target_form } +expect: { findings: 1, error_names_file_and_line: true, scope_named: source_syntax, roots_required: false } +::: + +::: test +name: lint of a target over a clean tree answers empty and names its scope +given: { workspace: fixture_two_modules, lint: target_form } +expect: { findings: 0, scope_named: source_syntax, exit_code: 0 } +::: + +`napl cost` pure calculation over an explicit +receipt set and a pinned pricing-table provenance object; unknown is +unknown, never zero. Edits price the way generations price: records the +capture verbs staged enter the same receipt set, their session-window +usage references resolve at read time against the named log's bytes, +summing the window's lines under the same pinned pricing table, and +`napl cost --session ` reports one session's calls directly from +the log. A record without a window prices `unavailable`; a window whose +log file or session lines are absent prices `unavailable` and says +which; a model the pricing table does not name prices unknown; nothing +is ever silently zero and nothing is aggregated at record time. The +same receipt set, log bytes, and pricing table produce byte-identical +reports. `napl gc` stages sweeps outside locks, swaps pointers +inside the bounded section, never blocks writers, never deletes anything +reachable from a named head. Reachable means the transitive closure: +from every named head, through every event in its chain, through every +object any reachable object references by hash, output trees, +surfaces, attribution records, prompt blobs, receipts, at every +contract version history carries. `fsck`'s definition of a present +object IS gc's definition of a kept one; a store that was healthy +before a sweep is healthy after it, and blame replays the same +ancestry after a sweep as before. An edge gc does not understand is a +reason to keep, never to sweep. + +::: test +name: gc apply on a workspace with history leaves fsck healthy and blame replaying +given: { workspace: fixture_with_multi_commit_history, gc: apply } +expect: { fsck_findings: 0, blame_replays_history: true, head_intact: true } +::: + +`napl fsck` envelope hashes, kind validity, +causal parent links. `napl restore` deterministic repair plans from +persisted evidence; unambiguous moves install, ambiguity blocks; liveness +and wall time never decide recovery. `napl mv ` relocation +as a semantic operation: moves a module's installed files to a new +placement, recomputes every placement-derived fact (derived entry-file +projections, attribution file paths) from the destination, passes +through the guarded commit path, and re-verifies there; failed +verification at the destination installs nothing; manifests are the +user's to update, and status diagnoses a placement the workspace +manifest no longer lists. A move made outside `napl mv` is drift like any other: +the store still holds the committed bytes, `napl restore` re-materializes +the module at its recorded placement, and when the drifted content matches +a committed state exactly the plan may instead adopt the new placement +through the commit path; anything ambiguous blocks. `napl cat-file ` +raw plumbing. `napl lsp` a language server over stdio for editor +extensions: hover, definition, references, and code lens over the +prompt-to-code mapping. Everything it serves derives from committed +records (attribution entries, module surfaces, the ir) resolved against +the workspace root; hover on a prompt line presents the attributed +generated spans with their notes, hover on a generated line presents the +owning prompt lines and note, definition jumps across the mapping in both +directions, references enumerates every attributed span, code lens counts +attributed files per prompt section. The server publishes diagnostics +derived from reference staleness: for every stale reference it emits one +diagnostic on each endpoint, the prompt span whose code moved and the +code span whose prompt changed, so sync debt is visible where the edit +happens, like a type error for documentation. Publication is a pure +derivation from committed records plus the working tree at the moment +the protocol asks, no background mutation, no clocks, no scanning loops +beyond what the editor protocol requires; opening or changing a +document refreshes that document's diagnostics and its counterparts'. +Severity is warning, never error: drift is a legal state, marked, not +policed. The diagnostic message states exactly what is known and +nothing more: which end drifted, the reference's direction, recorded +model or `unavailable`, referentially valid, and the healing verbs +(re-record through `napl edit`, or restore); no wording implies the +sentence describes the code. A fresh reference publishes nothing. +Resolving is always a human or LLM re-affirming or re-pointing the +reference through the capture verbs; the server proposes nothing and +repairs nothing. Pure reads only: no mutation, no +clocks, no background scanning. A request whose records are missing or +unparseable answers empty rather than erroring or blocking, and missing +or unparseable records degrade to no diagnostics, never an error; a stale +mapping is served marked stale, consistent with the attribution contract; +the server never hangs on a malformed or absent record. Record resolution is +anchored per document: walk up from the document's filesystem path to the +nearest ancestor directory containing `.napl` and read records there; the +server's working directory and the client's rootUri carry no meaning. +Every location the server emits that points at a module's prompt resolves +to the prompt file's actual placement found by workspace prompt discovery, +where the prompt sits inside the crate directory its code lands in; a +module name is never joined against the workspace root to fabricate a +prompt path, and a module whose prompt cannot be discovered answers empty. +Crash safety: every persisted application step leaves markers such that +recovery derives a deterministic plan from content alone. Evidence +validators are a registry keyed by media type (Claude CLI JSON envelope +ships first, and `application/x-napl-usage-proxy-jsonl-v1` validates +usage-proxy logs: one canonical JSON object per line, required fields +present, absent values the string `unavailable`, token counts +non-negative integers when present, a line failing the shape is +`invalid`); outcomes `validated`, `unsupported`, `invalid`, +`validator-error`; invalid stays visible as failed. Evidence bounds are +compiled-in constants; exceeding any is `resource-limit` before anything +publishes. + +::: test +name: a stale reference publishes one diagnostic on each endpoint +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, lsp: [open_prompt, open_code] } +expect: { prompt_diagnostics: 1, code_diagnostics: 1, severity: warning } +::: + +::: test +name: a fresh reference publishes nothing +given: { workspace: fixture_two_modules, lsp: [open_prompt, open_code] } +expect: { prompt_diagnostics: 0, code_diagnostics: 0 } +::: + +::: test +name: "the diagnostic message carries drifted end, direction, and provenance and nothing semantic" +given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched, lsp: [open_code] } +expect: { message_names_drifted_end: true, message_names_direction: true, message_model_or_unavailable: true, message_semantic_claims: 0 } +::: + +::: test +name: absent or unparseable records publish no diagnostics and answer empty +given: { workspace: fixture_two_modules, records: attribution_file_corrupted, lsp: [open_prompt] } +expect: { diagnostics: 0, error_responses: 0, server_alive: true } +::: + +::: test +name: committing the drifted code clears both endpoints' diagnostics on refresh +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, then: commit_module, lsp: [change_code_document] } +expect: { prompt_diagnostics: 0, code_diagnostics: 0 } +::: + +::: test +name: diff renders drift between committed output and working tree as a canonical patch +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched } +expect: { diff_nonempty: true, canonical_patch: true, byte_stable: true } +::: + +::: test +name: "blame over a whole-file regeneration coarsens to the transition, line ancestry survives accepted patches" +given: { workspace: fixture_two_modules, history: [patch_commit, full_rewrite_commit] } +expect: { patched_lines_trace_to_patch: true, rewritten_region_blames_transition: true } +::: + +::: test +name: lint reports an unreachable module only under declared roots and refuses without them +given: { workspace: fixture_orphan_module, roots: absent } +expect: { unreachable_analysis: refused, exit_code: 2, reason_names_roots: true, findings_field_absent: true } +::: + +::: test +name: lint with declared roots proposes removal of the orphan with a cascade preview and never auto-applies +given: { workspace: fixture_orphan_module, roots: [main_module] } +expect: { findings: [unreachable_napl], auto_applied: false, cascade_preview: true } +::: + +::: test +name: lint --fix drops an unreferenced declared dependency through the guarded commit path +given: { workspace: fixture_unused_dep, fix: drop_unused_dep } +expect: { outcome: installed, transition_recorded: true, dep_removed: true } +::: + +::: test +name: a cost report over a receipt set containing verb-staged records includes the edits +given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines } +expect: { report_rows: 2, edit_row_priced: true, same_pricing_table: true } +::: + +::: test +name: an edit without a window and a window with missing log lines both price unavailable naming why +given: { workspace: fixture_two_modules, receipts: [edit_without_window, edit_with_window_to_missing_lines] } +expect: { priced_unavailable: 2, reason_named_per_row: true, zeros: 0 } +::: + +::: test +name: a model absent from the pricing table prices unknown never zero +given: { workspace: fixture_two_modules, receipts: [edit_with_unknown_model_window], log: proxy_log_unknown_model } +expect: { priced_unknown: 1, zeros: 0 } +::: + +::: test +name: cost reports are byte-identical across runs over the same receipts log and table +given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines, runs: 2 } +expect: { bytes_equal: true } +::: + +::: test +name: cost session reports one session's calls directly from the log +given: { workspace: fixture_two_modules, log: proxy_log_two_sessions, session: s1 } +expect: { rows_for_session: 2, rows_for_other_sessions: 0, priced_from_log: true } +::: + +::: test +name: a proxy log line failing the shape validates invalid and stays visible as failed +given: { evidence: proxy_log_with_malformed_line, media_type: application_x_napl_usage_proxy_jsonl_v1 } +expect: { outcome: invalid, visible_as_failed: true } +::: + +::: test +name: cost over one receipt set and pricing version is byte-identical across runs and unknown for unknown models +given: { receipts: fixture_receipts, pricing_version: pinned, runs: 2, unknown_model_receipt: included } +expect: { reports_equal: true, unknown_model_cost: unknown } +::: + +::: test +name: gc never deletes an object reachable from the current head and never blocks a concurrent commit +given: { workspace: fixture_two_modules, concurrent: [gc, valid_commit] } +expect: { reachable_deleted: false, commit_blocked: false, unreachable_swept: true } +::: + +::: test +name: fsck detects a corrupted object and a broken parent link +given: { workspace: fixture_two_modules, corrupt: [flip_byte_in_object, remove_parent_event] } +expect: { findings: [hash_mismatch, missing_parent], exit_nonzero: true } +::: + +::: test +name: restore applies an unambiguous move-repair plan and blocks on ambiguity +given: { workspace: fixture_moved_output, ambiguity: [none, two_candidates] } +expect: { unambiguous: installed, ambiguous: blocked } +::: + +::: test +name: hover on an attributed prompt line resolves its generated spans +given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: within_entry } } +expect: { spans_resolved: true, notes_included: true } +::: + +::: test +name: "hover on an unattributed line answers empty, not error" +given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: outside_entries } } +expect: { result: empty, error: false } +::: + +::: test +name: missing attribution records degrade to empty responses +given: { attribution: absent, request: hover } +expect: { result: empty, error: false, blocked: false } +::: + +::: test +name: definition from a generated line targets the prompt at its discovered placement +given: { attribution: fixture_attribution_small, prompt_placement: inside_crate_dir, request: definition, position: { file: generated_within_entry } } +expect: { location_file: discovered_prompt_path, root_joined_module_name: false } +::: + +::: test +name: evidence failing its validator is invalid and distinguishable from unsupported +given: { receipt_evidence: [valid_claude_json, malformed_claude_json, unknown_media_type] } +expect: { outcomes: [validated, invalid, unsupported] } +::: + +::: test +name: a receipt exceeding evidence bounds is rejected as resource-limit with nothing published +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: oversized_evidence } +expect: { outcome: resource_limit, published_semantic_state: false, published_provenance: false } +::: + +## Determinism contract + +Every collection canonically ordered; every input explicit and content +identified; serialization byte-pinned; ambient environment excluded +(locale, timezone, machine identity, CPU count, enumeration order); reads +never write; a state transition names its base and fails on mismatch per +the decision table rather than recalculating. Same workspace compiled +twice, from different absolute paths, under different discovery orders, +locales, and timezones produces byte-identical artifacts. The test suite +proves the vectors in the frontmatter with deterministic fixtures +constructed in each crate's `tests/`. + +::: test +name: compiling the same workspace twice yields byte-identical artifacts +given: { workspace: fixture_two_modules, runs: 2 } +expect: { artifacts_equal: true } +::: + +::: test +name: artifact bytes are independent of discovery order +given: { workspace: fixture_two_modules, discovery_orders: [sorted, reversed, shuffled_seed_7] } +expect: { artifacts_equal: true } +::: + +::: test +name: artifact bytes are independent of the absolute workspace path +given: { workspace: fixture_two_modules, mounted_at: [rootA, rootB] } +expect: { artifacts_equal: true } +::: + +::: test +name: canonical json sorts keys and is byte-stable +given: { value: "{\"b\":1,\"a\":2}", encodings: 2 } +expect: { bytes_equal: true, sorted_keys: true } +::: +napl-store blob v1 5100 +//! Producer-side helpers: drafting a receipt, and the one automatic fix. +//! +//! Both are compositions of derivations that already exist. Drafting a receipt +//! invokes nothing and reports no telemetry of its own; the fix reaches the +//! store only through the guarded commit path, never around it. + +use std::collections::BTreeMap; +use std::path::Path; + +use crate::candidate::{default_limits, ingest}; +use crate::compile::{slice_at, workspace_source_hash}; +use crate::error::{error, OutcomeCode, Result}; +use crate::lint::{is_auto_fixable, Finding}; +use crate::receipt::RECEIPT_VERSION; +use crate::source::rewrite_deps; +use crate::store_view::{get_blob, ObjectSource}; +use crate::target::{builtin_target, placement_root}; +use serde_json::{json, Value}; + +use crate::commit::{commit, CommitReport}; +use crate::disk; +use crate::porcelain::committed_manifest; +use crate::workspace::{discover_sources, scan_candidate, write_file}; + +/// Build the receipt a producer would submit for a candidate directory. +pub fn draft_receipt( + workspace: &Path, + target_name: &str, + module: &str, + candidate_dir: &Path, +) -> Result { + builtin_target(target_name)?; + let store = disk::open(workspace); + let head = store.head()?; + let sources = discover_sources(workspace)?; + let slice = slice_at(&store, &sources, target_name, module, head.as_deref())?; + let candidate = ingest(&scan_candidate(candidate_dir)?, default_limits())?; + let entries: Vec = candidate + .manifest + .iter() + .map(|(path, hash)| json!({ "hash": hash, "path": path })) + .collect(); + + Ok(json!({ + "baseStateKey": slice.base_state_key, + "baseStoreSnapshot": head, + "baseWorkspaceHash": workspace_source_hash(&sources)?, + "candidateTreeHash": candidate.tree_hash, + "inputKey": slice.input_key, + "module": module, + "outputManifest": entries, + "receiptVersion": RECEIPT_VERSION, + "sliceHash": slice.slice_hash, + "target": target_name, + })) +} + +/// Apply one auto-fixable finding through the guarded commit path. +/// +/// Dropping an unreferenced declared dependency changes the module's `inputKey` +/// and nothing else, so the candidate is the module's own committed output, +/// byte for byte. That is what makes the fix complete and mapping-safe: it can +/// only ever move an identity, never a generated line. +pub fn apply_fix(workspace: &Path, target: &str, finding: &Finding) -> Result { + if !is_auto_fixable(finding) { + return Err(error( + OutcomeCode::MappingIntegrity, + "finding has no complete deterministic mapping-safe repair", + )); + } + let Finding::UnusedDependency { module, dep } = finding else { + unreachable!("only an unused dependency is auto-fixable"); + }; + + let store = disk::open(workspace); + let sources = discover_sources(workspace)?; + let declared = sources + .iter() + .find(|source| &source.module == module) + .ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} has no source file"), + ) + })?; + let remaining: Vec = declared + .deps + .iter() + .filter(|name| *name != dep) + .cloned() + .collect(); + + // Materialize the committed output as the candidate, then rewrite the + // source. Order matters: the candidate must be the output that was committed + // against the *old* source, and the commit that follows re-derives every + // identity against the new one. + let staging = store.root.join("tmp").join(format!("fix-{module}")); + if staging.exists() { + std::fs::remove_dir_all(&staging).map_err(|e| disk::io(&staging, &e))?; + } + let committed = committed_manifest(&store, target)?; + let root = placement_root(declared); + let owned: BTreeMap<&String, &String> = committed + .iter() + .filter(|(path, _)| path.starts_with(&format!("{root}/"))) + .collect(); + if owned.is_empty() { + return Err(error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} has no committed output to re-commit"), + )); + } + for (path, blob) in &owned { + write_file(&staging.join(path), &get_blob(&store, blob)?)?; + } + + let source_path = workspace.join(&declared.path); + let original = std::fs::read_to_string(&source_path).map_err(|e| disk::io(&source_path, &e))?; + write_file(&source_path, rewrite_deps(&original, &remaining)?.as_bytes())?; + + let receipt = draft_receipt(workspace, target, module, &staging)?; + let receipt_path = store.root.join("tmp").join(format!("fix-{module}.json")); + write_file( + &receipt_path, + &crate::canon::canonical_bytes(&receipt), + )?; + + let report = commit(workspace, &receipt_path, &staging); + if report.outcome != OutcomeCode::Success { + // A rejected fix leaves the source exactly as it was found. + write_file(&source_path, original.as_bytes())?; + } + Ok(report) } -napl-store tree v1 252 -{"entries":[{"hash":"8de82553f5e9c70c2794f09aff4bc7718200713bbb71121e3b5780bdf22b762d","kind":"file","mode":33188,"name":"Cargo.toml"},{"hash":"3c251b1b1947263918fe20db4e1c83fae7baee926b3953aa244925e187430015","kind":"dir","mode":16384,"name":"src"}]} -napl-store snapshot v1 1005 -{"contractVersion":4,"event":"2e1b675f4a30854a24e637cf09b4e00615240ce52a71872287ce67f9f4d3e9e8","states":[{"module":"agent","state":"d0d406dd4f30ea6840a808bcbc3ddeea8ec8cc10c84de12ab5d0cfeef7bb6696","target":"rust"},{"module":"commands","state":"ac7d92c561ec9fa19f0e28e1392bd90c1d95ca663b038424bd8ce760c6f4cd9e","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"42cee1b00043646e7e0ebed264397212c4ae136d44233aee64af6bebcab11d53","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"4935806b1fd8ae8a2d0d93597213ab55ebfe7965cbdfaef8663b6e24c80f9bf4","target":"rust"},{"module":"serve","state":"e3fd98fd28ebc7ff92befcd40a0f2d7b202619645398362d7b789bcadb724ebd","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} -napl-store blob v1 7395 -module: "query" +napl-store receipt v1 703 +{"baseStateKey":"40ddb00b3be320909acf0ae1440e2bc5662e3002dcf77dc3367bcb215b1710fd","baseStoreSnapshot":"54466d71840faf6fafba87099ed5a51a284f481fe896c6bc355fc6ed92f884d8","baseWorkspaceHash":"1cbd0f8a55437d59751b61ddc91d2290912cf5b52204af9c179365134de2e5f3","candidateTreeHash":"505d5f544144525d73def85bf0b3b9e16230ea634599f4d343080af78d359024","contractVersion":4,"inputKey":"4d1fb17243be4b78e491396c6d7f09490975c5851b5c2f191f4e10798848ccc4","module":"serve","outputManifest":[{"hash":"c6b8ce166bc273993feec781ce54da25499ac925715435ffb9d9dc32a2fda9e3","path":"crates/napl/src/serve.rs"}],"receiptVersion":1,"sliceHash":"a798c8c609ac68ef2ad8eaa774d5dae53efe4a6ab0a13582db815e77e1cf6d2c","target":"rust"} +napl-store tree v1 4679 +{"entries":[{"hash":"12166b5fd9554e4781dd2efb828ac24b17228fab1a08ad7df51069e20f8efb0f","kind":"file","mode":33188,"name":"attribution.rs"},{"hash":"c777943d5bb9e51d363d75a4b45ef5b79311f0ccc4c4de6b471d93664ac0540c","kind":"file","mode":33188,"name":"authoring.rs"},{"hash":"4b44f76ec9cece4314358f54861f937a5c4e875e93646399a3abbb09c2a51bea","kind":"file","mode":33188,"name":"blame.rs"},{"hash":"02d2ea0745e17a3fdc17f2657ee532dfb748423bced9c1302e3178f45dfab10a","kind":"file","mode":33188,"name":"candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","kind":"file","mode":33188,"name":"canon.rs"},{"hash":"d85bfa83452d95a4eeb9b36dce2774cf0d64189608cf962441406db9b7fe5a3b","kind":"file","mode":33188,"name":"capture.rs"},{"hash":"f5e2132da4e24805a9c5d93e78db873618fea004116575dac3a9e60446b01c20","kind":"file","mode":33188,"name":"cli.rs"},{"hash":"8f9c70df379b3ab58f979c191dc1ae5fb0e5b2199c84757b61c254bb05241ebd","kind":"file","mode":33188,"name":"commandset.rs"},{"hash":"a939de9095ad62f52d77a4e472a0cf040966d14c98b860308bec79e6f8cf5bc1","kind":"file","mode":33188,"name":"commit.rs"},{"hash":"5287b1e356febbfb1bb42750c5d0e218597176a3fee19f387b975d84aa0cc11b","kind":"file","mode":33188,"name":"compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","kind":"file","mode":33188,"name":"cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","kind":"file","mode":33188,"name":"decision.rs"},{"hash":"43b1245f514e01498961d0fb53b8543fff9303363c309346031fdb23984f9a18","kind":"file","mode":33188,"name":"directive.rs"},{"hash":"b86a4431e286225b32bae5cebc03298584fce063e8d4ee2df4633e18143f0aa7","kind":"file","mode":33188,"name":"disk.rs"},{"hash":"bba24de15a64304a2c47ff84ea2d957dd65f77a450ab1f813ef2d51fb69e7cc0","kind":"file","mode":33188,"name":"error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","kind":"file","mode":33188,"name":"evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","kind":"file","mode":33188,"name":"graph.rs"},{"hash":"bcacc1842bc9440afe08e7d396622023b8ce1db9d04b796d2842fb704c7b2b6d","kind":"file","mode":33188,"name":"lint.rs"},{"hash":"0f2e50ef0121924926bdadaa29157d5abb2159c2308a44edb3e3e8fde77fbc0c","kind":"file","mode":33188,"name":"lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","kind":"file","mode":33188,"name":"main.rs"},{"hash":"830cb1dea8bac30fabe6ccd94440f6801106adc77b473dd96a0c10c464024b7c","kind":"file","mode":33188,"name":"maintenance.rs"},{"hash":"697b0b828643d41b8ce208d441a0e11fdfe044cc743514830d3c36287b46a778","kind":"file","mode":33188,"name":"mv.rs"},{"hash":"7cf9c419b2c4f7c7b17e2c1e4362861de714f4ffe54727c1a0fcb749b2e6975b","kind":"file","mode":33188,"name":"objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","kind":"file","mode":33188,"name":"paths.rs"},{"hash":"948ef7cd994335320ebef7a5d3ba1380ecdbdcf64b79ac90a8d451bc3aeab382","kind":"file","mode":33188,"name":"porcelain.rs"},{"hash":"156ab704b4d1d385105fd45dfe9ffa5af5cdbf7344eac0c70c3c4d26b6583167","kind":"file","mode":33188,"name":"receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","kind":"file","mode":33188,"name":"slice.rs"},{"hash":"1983d2bf91bbcc59706fd11a0609f56551072e728b90e5db9dc8e7b19bcb0a8f","kind":"file","mode":33188,"name":"source.rs"},{"hash":"35e58a71895a9981f01ee4dec4bb6bd1a8464d507c1d4bd91a2fd07759f85f6f","kind":"file","mode":33188,"name":"staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","kind":"file","mode":33188,"name":"store_view.rs"},{"hash":"5e9e418d1a1e8dccd7207bdf85f39d21e510760135c6cc2be2f149e4b82a6f78","kind":"file","mode":33188,"name":"surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","kind":"file","mode":33188,"name":"target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","kind":"file","mode":33188,"name":"textdiff.rs"},{"hash":"bfd8ff52f2306a963616bda4a97971a9daebe88e75f34bb2f06960ea92feac39","kind":"file","mode":33188,"name":"transaction.rs"},{"hash":"b73e4b4242b073030bdaf119b375999eec7f383111a0b02f8e73505eb0ecc76d","kind":"file","mode":33188,"name":"transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","kind":"file","mode":33188,"name":"trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","kind":"file","mode":33188,"name":"version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","kind":"file","mode":33188,"name":"workspace.rs"}]} +napl-store tree v1 132 +{"entries":[{"hash":"539a182ea541e60a1f754a9c3205d681a91304592e430f56ee35f04c9b32cbbb","kind":"dir","mode":16384,"name":"crates"}]} +napl-store blob v1 789 +module: "commands" target: "rust" stale: false entries: - promptLines: - 1 - - 15 - file: "crates/napl/src/query.rs" - lines: whole - note: "the query module owns napl q and napl prompt and the canonical document they share" - - promptLines: - - 16 - - 200 - file: "crates/napl/src/document.rs" - lines: - - 1 - - 816 - note: "the canonical document docVersion 1 built purely from committed records and the working tree, totally ordered, no absolute paths" - - promptLines: - - 16 - - 200 - file: "crates/napl/src/document.rs" - lines: - - 209 - - 233 - note: "the prompt body heading outline served with body-normalized one-based lines so a heading position is a query" - - promptLines: - - 52 - - 54 - file: "crates/napl/src/document.rs" - lines: - - 448 - - 467 - note: "every file ref carries the claim discriminator so a consumer never guesses whether lines bounds the claim or samples it" - - promptLines: - - 56 - - 66 - file: "crates/napl/src/document.rs" - lines: - - 426 - - 446 - note: "docs payloads are served per section in physical prompt coordinates, the one place the document speaks them, because docs fences are excluded from the semantic body every other coordinate speaks in" - - promptLines: - - 68 - - 86 - file: "crates/napl/src/document.rs" - lines: - - 275 - - 325 - note: "the committed document reads its docs from the module state's prompt object by splitting fences alone, never re-running the example validation the commit door applies, so a grammar that has moved since those bytes were recorded can never erase served prose; a state with no readable prompt baseline serves empty lists rather than guessing from the working tree" - - promptLines: - - 88 - - 91 - file: "crates/napl/src/document.rs" - lines: - - 358 - - 424 - note: "the document's freshness verdicts and the language server's are one analysis, never a hardcoded fresh" - - promptLines: - - 201 - - 298 - file: "crates/napl/src/query.rs" - lines: whole - note: "napl q shaped exactly like jq via the embedded jaq engine, impure builtins refused, 64 MiB output ceiling, and the expression always required so a bare invocation is a usage error teaching the explicit dot rather than a document dump" - - promptLines: - - 201 - - 298 - file: "crates/napl/src/query.rs" - lines: - - 321 - - 348 - note: "raw output emits string results as their contents exactly as jq does, one newline between results and one at the end, never doubled when the string itself ends in one, so a raw result diffs clean against the file it came from" - - promptLines: - - 299 - - 447 - file: "crates/napl/src/docs.rs" - lines: whole - note: "the docs envelope section governs the envelope module as a whole, its pinned cases included" - - promptLines: - - 299 - - 310 - file: "crates/napl/src/docs.rs" + - 8 + file: "crates/napl/src/init.rs" lines: - 1 - - 30 - note: "the docs envelope exists because every docs consumer wants prose, stable section identities, and the attributed code at once, and because an emitter that walks recorded structure cannot produce the escaping defects regexing prose outside the toolchain produced" - - promptLines: - - 312 - - 319 - file: "crates/napl/src/query.rs" - lines: - - 71 - - 89 - note: "the rendering is declared as one flag value on napl q, so the closed flag table names it and an unknown rendering is refused before anything is read" - - promptLines: - - 312 - - 319 - file: "crates/napl/src/query.rs" - lines: - - 156 - - 245 - note: "the flag composes with the document form alone, so an input operand is a usage refusal teaching that markdown rendering is a reading of the store rather than a jq transformation, and the required expression is evaluated against the document before anything is rendered" - - promptLines: - - 312 - - 319 - file: "crates/napl/src/docs.rs" - lines: - - 27 - - 55 - note: "the three shapes an expression result may take, the document itself, one module value, or an array of module values, with every other result refused by a message that names all three" - - promptLines: - - 321 - - 328 - file: "crates/napl/src/docs.rs" - lines: - - 57 - - 223 - note: "the envelope under the document's own canonical serialization law, each module carrying its prompt path, the title and description read out of its preamble docs fence, and that preamble rendered" - - promptLines: - - 330 - - 362 - file: "crates/napl/src/docs.rs" - lines: - - 225 - - 490 - note: "the regroup from one section per file ref into prose shape: identical ranges merge with files deduplicated by path and lines and every constituent provenance record kept, a file-less group strictly inside exactly one surviving section joins it carrying its cases, files, and staleness, each served file ref gains the committed span it claims, and the section is served fence by fence with every fence resolving its own semantic position against every attribution entry of the module that contains it, narrowest span first then path then start, each source carrying that entry's files with their excerpts" - - promptLines: - - 364 - - 374 - file: "crates/napl/src/docs.rs" - lines: - - 492 - - 581 - note: "rendering walks the recorded fence split into CommonMark plus GFM and nothing else: a docs body passes through, a note or warning becomes an alert blockquote, and an example becomes a fenced block whose run outgrows the longest run inside its body" + - 83 + note: "two porcelain additions each a thin composition over the shared derivations, the command table contributing init and watch" - promptLines: - - 448 - - 529 - file: "crates/napl/src/promptbuild.rs" + - 9 + - 41 + file: "crates/napl/src/init.rs" lines: - 1 - - 556 - note: "napl prompt reads typed refs and materializes prose and code from committed blobs at the refs snapshot, with law closure" + - 83 + note: "napl init bootstraps the store skeleton or reports already_initialized, names the directory it ran in as the workspace root, and declares no flags beyond the shared help flag" - promptLines: - - 530 - - 681 - file: "crates/napl/src/check.rs" + - 42 + - 63 + file: "crates/napl/src/watch.rs" lines: - 1 - - 310 - note: "napl check dry validation of an on-disk prompt through the same reader add uses, teaching while it refuses: the missing-fence finding embeds a complete minimal valid prompt that itself passes check, and a file whose name lacks the .napl extension is told discovery will never find it whatever else is valid about its content" - - promptLines: - - 530 - - 681 - file: "crates/napl/src/document.rs" - lines: - - 583 - - 696 - note: "the working-tree document enumerates sections from the on-disk prompt body so a heading and its cases are served the moment they are typed, marked workingTree and carrying no invented head" - - promptLines: - - 530 - - 681 - file: "crates/napl/src/promptbuild.rs" - lines: - - 247 - - 257 - note: "a working-tree ref resolves against the on-disk prompt body rather than a committed blob" - - promptLines: - - 530 - - 681 - file: "crates/napl/src/promptbuild.rs" - lines: - - 426 - - 492 - note: "ref parsing separates a committed head from a working-tree ref and refuses a ref carrying both, naming the input line" - - promptLines: - - 530 - - 681 - file: "crates/napl/src/query.rs" - lines: - - 30 - - 34 - note: "the working-tree switch is argv grammar rather than an operand so the three q forms and the one-input rule hold" -napl-store blob v1 15218 + - 127 + note: "napl watch an io shell re-running compile on content change publishing nothing" +napl-store module-state v1 559 +{"attribution":{"promptHash":"6ffc6466a8595d2c095631bb4e83ee15014b405072b0d39e9b6514a0e58b15fa","record":"48e5624ef415a6044dcc36f8f24c51662620a5ff31cdedaefb76aefecad4fc63","stale":false},"contractVersion":3,"inputKey":"0ac0f86a77570f04d575a508bdef86c69ee4fd71c2c2e4763f098ff7f8743ec9","module":"query","outputTree":"dfe384bca0e1e6245624b6a79f81a99c5fa0bf60f5bc0e04856a53d888e92a48","sliceHash":"75334a4f5a419e1326f760e837d22ecfbe79c64d8f67602b0d1e135a8ca4c2e6","surfaceHash":"d9032b03ee7b166d66159ff59d7e98ba35a3a66b35cbae983d43f0630747ad65","target":"rust"} +napl-store blob v1 14432 module: "napl" target: "rust" stale: false @@ -237531,946 +243042,2141 @@ entries: - 1 - 8 file: "crates/napl/src/cli.rs" - lines: - - 1 - - 1037 + lines: whole note: "the binary reads args runs one command and exits with the contract code" - promptLines: - 1 - 8 file: "crates/napl/src/commandset.rs" - lines: - - 1 - - 704 - note: "the command table the core toolchain contributes to the derived registry, store migrate among its entries" + lines: whole + note: "the command table the core toolchain contributes to the derived registry, remove among its entries" - promptLines: - 1 - 8 file: "crates/napl/src/main.rs" - lines: - - 1 - - 16 + lines: whole note: "the binary entry point that spawns no process" - promptLines: - 9 - 114 file: "crates/napl/src/source.rs" - lines: - - 1 - - 615 + lines: whole note: "the smallest prompt the reader accepts, stated once beside the reader that judges it so the two commands that teach with it cannot drift from the grammar discovery finds by the .napl extension" - promptLines: - 9 - 114 file: "crates/napl/src/transaction.rs" - lines: - - 1 - - 515 + lines: whole note: "the crate lib.rs projection derived from the committed module file sets" - promptLines: - 9 - 114 file: "crates/napl/src/workspace.rs" - lines: - - 1 - - 251 + lines: whole note: "layout and discovery walk up to the nearest napl directory" - promptLines: - 9 - 114 file: "crates/napl/src/paths.rs" - lines: - - 1 - - 141 + lines: whole note: "the empty placement root contains every workspace-relative path and joins against it never fabricate a leading separator" - promptLines: - 9 - 114 file: "crates/napl/src/target.rs" - lines: - - 1 - - 234 + lines: whole note: "a prompt at the workspace root places at the empty root and every join against it stays relative" - promptLines: - 9 - 114 file: "crates/napl/src/transaction.rs" - lines: - - 1 - - 515 + lines: whole note: "candidate containment against the empty root admits every workspace-relative path instead of rejecting all of them" - promptLines: - 115 - 250 file: "crates/napl/src/cli.rs" - lines: - - 1 - - 1037 + lines: whole note: "the declared grammar of every command plus the dispatch loop that answers help ahead of it and refuses an undeclared flag before it runs, with clap's builder API constructed at dispatch from the registry as the parser behind the contract, every parse failure caught and served as the canonical JSON usage error, and the conventional spellings reduced to one normalized argument vector so --flag=value and --flag value cannot differ" - promptLines: - 115 - 250 file: "crates/napl/src/commandset.rs" - lines: - - 1 - - 704 + lines: whole note: "each core command declares its summary exact usage line and closed flag table, diff's operand is a target name whose usage line spells it and whose refusal names the target names that exist, and store declares its one operation so an unknown one is a grammar failure like any other undeclared token" - promptLines: - 115 - 250 file: "crates/napl/src/error.rs" - lines: - - 1 - - 166 + lines: whole note: "exit 2 is the pinned usage-error code for the whole binary" - promptLines: - 115 - 250 file: "crates/napl/src/target.rs" - lines: - - 1 - - 234 + lines: whole note: "placement roots owned source globs and the user-owned manifest seam a candidate may never contain" - promptLines: - 251 - 278 file: "crates/napl/src/decision.rs" - lines: - - 1 - - 113 + lines: whole note: "the conflict rule two operations conflict when semantic write sets overlap" - promptLines: - 251 - 278 file: "crates/napl/src/error.rs" - lines: - - 1 - - 166 + lines: whole note: "the outcome vocabulary and its exit-code mapping" - promptLines: - 251 - 278 file: "crates/napl/src/objects.rs" - lines: - - 1 - - 380 + lines: whole note: "P2 footprints read write and derived-projection sets over content identities" - promptLines: - 279 - 328 file: "crates/napl/src/slice.rs" - lines: - - 1 - - 170 + lines: whole note: "identity vocabulary inputKey closure manifest baseStateKey and sliceHash" - promptLines: - 279 - 328 file: "crates/napl/src/version.rs" - lines: - - 1 - - 1 + lines: whole note: "the contract version bumped to four by the capture verbs whose three refusals join the closed table" - promptLines: - 329 - 389 file: "crates/napl/src/attribution.rs" - lines: - - 1 - - 1269 + lines: whole note: "names are serialized as quoted escaped scalars that reparse identically" - promptLines: - 329 - 389 file: "crates/napl/src/attribution.rs" - lines: - - 1 - - 1269 + lines: whole note: "the attribution record path keys on the encoded name never the raw one" - promptLines: - 329 - 389 file: "crates/napl/src/paths.rs" - lines: - - 1 - - 141 + lines: whole note: "the injective reversible name encoding used wherever a name becomes a segment" - promptLines: - 329 - 389 file: "crates/napl/src/source.rs" - lines: - - 1 - - 615 + lines: whole note: "the module-name rule any non-empty single-line scalar and nothing narrower" - promptLines: - 390 - 460 file: "crates/napl/src/compile.rs" - lines: - - 1 - - 553 + lines: whole note: "the compile derivation a pure read that never persists an artifact, and the pending reason it derives from the closed vocabulary by comparing the current inputKey components against the identities the committed record holds, with the re-stamp promise an undrifted pending module carries" - promptLines: - 390 - 460 file: "crates/napl/src/graph.rs" - lines: - - 1 - - 162 + lines: whole note: "the dependency graph and dependency-ordered wave planning" - promptLines: - 390 - 460 file: "crates/napl/src/paths.rs" - lines: - - 1 - - 141 + lines: whole note: "the portable path grammar and its collision-detecting normalization" - promptLines: - 461 - - 722 + - 716 file: "crates/napl/src/directive.rs" - lines: - - 1 - - 1009 + lines: whole note: "the test body-directive value grammar diagnostics and deterministic extraction" - promptLines: - 521 - - 720 + - 714 file: "crates/napl/src/directive.rs" - lines: - - 1 - - 1009 - note: "the docs fence family with its micromark name and attribute grammar, the fence stack where a closing line of N colons closes the innermost fence opened with at most N and where code blocks suspend the stack, and the split that lifts every top-level docs fence out of the semantic body" + lines: whole + note: "the docs fence family with its micromark name and attribute grammar, the fence stack where a closing line of N colons closes the innermost fence opened with at most N and where code blocks suspend the stack, the refusal that names the line of a directive fence opened inside another open directive fence because fences are ordered siblings rather than a tree, and the split that lifts every top-level docs fence out of the semantic body, plus the heading tolerance that reads a `# ` or `## ` line inside an open prose fence, docs note or warning, as the prose it is while the same line inside an open example or test fence still refuses on sight" - promptLines: - 521 - - 720 + - 714 file: "crates/napl/src/source.rs" - lines: - - 1 - - 615 + lines: whole note: "the semantic body every identity derivation consumes is the normalized body with its docs fences removed, the module preamble is the first fence standing before the first heading, and an example fence is validated so a published example cannot rot" - promptLines: - 521 - - 720 + - 714 file: "crates/napl/src/cli.rs" - lines: - - 1 - - 1037 + lines: whole note: "a napl invocation written inside an example fence is judged against the live command grammar without running anything" - promptLines: - - 723 - - 776 + - 717 + - 770 file: "crates/napl/src/surface.rs" - lines: - - 1 - - 875 + lines: whole note: "tree-sitter surface extraction sound by construction and loud on malformed input" - promptLines: - - 777 - - 1333 + - 771 + - 1327 file: "crates/napl/src/attribution.rs" - lines: - - 1 - - 1269 + lines: whole note: "attribution rides the staged record validated before install" - promptLines: - - 777 - - 1333 + - 771 + - 1327 file: "crates/napl/src/authoring.rs" - lines: - - 1 - - 229 + lines: whole note: "the internal receipt derivation and the one guarded auto-fix" - promptLines: - - 777 - - 1333 + - 771 + - 1327 file: "crates/napl/src/candidate.rs" - lines: - - 1 - - 148 + lines: whole note: "candidate ingestion untrusted bytes become immutable content-addressed blobs" - promptLines: - - 777 - - 1333 + - 771 + - 1327 file: "crates/napl/src/commit.rs" - lines: - - 1 - - 742 + lines: whole note: "napl commit the one guarded transaction over what add staged, and bare commit re-derives a record against the head an earlier record in the same invocation moved and retries it once" - promptLines: - - 777 - - 1333 + - 771 + - 1327 file: "crates/napl/src/disk.rs" - lines: - - 1 - - 348 - note: "the store shell publication head compare-and-set and module-keyed locks, and the seam where the object backend is chosen by observing whether the workspace sits in a git repository whose store branch exists, everything but object storage keeping its disk paths" + lines: whole + note: "the store shell publication head compare-and-set and module-keyed locks over the one tracked object layout, and the door where a repository still carrying the retired store ref is refused before any verb has written a byte" - promptLines: - - 777 - - 1333 + - 771 + - 1327 file: "crates/napl/src/receipt.rs" - lines: - - 1 - - 192 + lines: whole note: "the internal receipt everything the compiler recomputes and refuses to believe" - promptLines: - - 777 - - 1333 + - 771 + - 1327 file: "crates/napl/src/staging.rs" - lines: - - 1 - - 1732 + lines: whole note: "napl add stages from any of the three sources through one pipeline, the dry run enumerates the validations it ran and names target-language validity as the one it did not judge, and a bad attribution map is answered with the whole map contract" - promptLines: - - 777 - - 1333 + - 771 + - 1327 file: "crates/napl/src/store_view.rs" - lines: - - 1 - - 152 + lines: whole note: "the read seam over content-addressed objects a pure function of the bytes" - promptLines: - - 777 - - 1333 + - 771 + - 1327 file: "crates/napl/src/transport.rs" - lines: - - 1 - - 691 + lines: whole note: "attribution transports through the byte diff on both ends independently, and a refusal reports each endpoint's verdict separately so the side that transported serves its transported range and only the side needing a human is asked for" - promptLines: - - 777 - - 1333 + - 771 + - 1327 file: "crates/napl/src/trees.rs" - lines: - - 1 - - 34 + lines: whole note: "building the candidate tree over the ingested manifest" - promptLines: - - 777 - - 1333 + - 771 + - 1327 file: "crates/napl/src/attribution.rs" - lines: - - 1 - - 1269 + lines: whole note: "attribution rides the staged record validated before install" - promptLines: - - 777 - - 1333 + - 771 + - 1327 file: "crates/napl/src/attribution.rs" - lines: - - 1 - - 1269 + lines: whole note: "attribution rides the staged record validated before install" - promptLines: - - 777 - - 1333 + - 771 + - 1327 file: "crates/napl/src/attribution.rs" - lines: - - 1 - - 1269 + lines: whole note: "attribution rides the staged record validated before install" - promptLines: - - 777 - - 1333 + - 771 + - 1327 file: "crates/napl/src/lsp.rs" - lines: - - 1 - - 883 + lines: whole note: "hover states the direction the recorded provenance that the reference is referentially valid and which endpoints read stale" - promptLines: - - 1334 - - 1532 + - 1328 + - 1526 file: "crates/napl/src/capture.rs" - lines: - - 1 - - 876 + lines: whole note: "napl edit and napl write validate apply capture in that order, and a recorded call's payload carries the reference object verbatim with its transition hash and says the edited file remains working-tree drift until its module commits" - promptLines: - - 1334 - - 1532 + - 1328 + - 1526 file: "crates/napl/src/commit.rs" - lines: - - 1 - - 742 + lines: whole note: "a pre-proven attribution installs fresh because the verb already proved the reference against the exact bytes it names and the guard runs unchanged" - promptLines: - - 1334 - - 1532 + - 1328 + - 1526 file: "crates/napl/src/cost.rs" - lines: - - 1 - - 407 + lines: whole note: "a recorded session window is priced lazily at read time never aggregated at record time" - promptLines: - - 1334 - - 1532 + - 1328 + - 1526 file: "crates/napl/src/error.rs" - lines: - - 1 - - 166 + lines: whole note: "the three refusals join the closed terminal-outcome table with pinned exit codes 27 28 and 29 and none reuses mapping-integrity" - promptLines: - - 1334 - - 1532 + - 1328 + - 1526 file: "crates/napl/src/staging.rs" - lines: - - 1 - - 1732 + lines: whole note: "the staged record carries its attribution mode so a reference-only record commits through the same door add and commit already use" - promptLines: - - 1533 - - 1964 + - 1527 + - 1986 file: "crates/napl/src/blame.rs" - lines: - - 1 - - 90 + lines: whole note: "derived line ancestry where surviving lines trace through accepted patches, and a path no committed module owns answers tracked false naming the absent baseline rather than an empty ancestry that would read as success" - promptLines: - - 1533 - - 1964 + - 1527 + - 1986 file: "crates/napl/src/cost.rs" - lines: - - 1 - - 407 + lines: whole note: "cost as a pure calculation over an explicit receipt set where verb-staged session windows resolve against the log at read time and unknown stays unknown" - promptLines: - - 1533 - - 1964 + - 1527 + - 1986 file: "crates/napl/src/evidence.rs" - lines: - - 1 - - 297 + lines: whole note: "evidence validators keyed by media type including the usage-proxy jsonl log with compiled-in bounds" - promptLines: - - 1533 - - 1964 + - 1527 + - 1986 file: "crates/napl/src/lint.rs" - lines: - - 1 - - 246 + lines: whole note: "unreachable modules under declared roots where absent roots refuse loudly with the exact declaration rather than an empty findings list, and unconsumed surface entries" - promptLines: - - 1533 - - 1964 + - 1527 + - 1986 file: "crates/napl/src/lsp.rs" - lines: - - 1 - - 883 + lines: whole note: "the language server hover definition references code lens and the diagnostics it publishes from reference staleness over the mapping" - promptLines: - - 1533 - - 1964 + - 1527 + - 1986 file: "crates/napl/src/maintenance.rs" - lines: - - 1 - - 428 + lines: whole note: "gc fsck and restore, where reachable means the transitive closure from every named head through every event and every hash edge any reachable object carries, so an edge gc does not understand is a reason to keep and a store healthy before a sweep is healthy after it" - promptLines: - - 1533 - - 1964 + - 1527 + - 1986 file: "crates/napl/src/mv.rs" - lines: - - 1 - - 212 + lines: whole note: "relocation as a semantic operation re-rooting a module through the guarded path" - promptLines: - - 1533 - - 1964 + - 1527 + - 1986 file: "crates/napl/src/porcelain.rs" - lines: - - 1 - - 1547 - note: "the observational porcelain status log show diff blame plus the maintenance verbs, where the plan labels every projection path tool-derived and names one concrete legal first output, show lists a transition's reference entries inline exactly as it lists notes, and store migrate walks the tracked objects into one deterministic commit, refusing outside a repository and refusing a branch that already exists, and reports the object files ready to leave the working tree with the exact removal command" + lines: whole + note: "the observational porcelain status log show diff blame plus the maintenance verbs, where the plan labels every projection path tool-derived and names one concrete legal first output, show lists a transition's reference entries inline exactly as it lists notes, and blame skips a transition that names no new state because a removal records an absence" - promptLines: - - 1533 - - 1964 + - 1527 + - 1986 file: "crates/napl/src/textdiff.rs" - lines: - - 1 - - 152 + lines: whole note: "diff renders drift as a canonical byte-stable patch" - promptLines: - - 1965 - - 1999 + - 1987 + - 2021 file: "crates/napl/src/canon.rs" - lines: - - 1 - - 44 + lines: whole note: "the determinism contract canonical bytes and byte-pinned serialization" -napl-store blob v1 17640 -//! `napl q` and the query module's command table. +napl-store snapshot v1 781 +{"contractVersion":4,"event":"d40ce1419539932ad2e15bbb2827c9f0aa6e64986030b54a69656ba64b443d31","states":[{"module":"agent","state":"e6448ff36c8767678aa7e2cc0c3a1300850d42e5b5e42107b85f93168c0ff856","target":"rust"},{"module":"commands","state":"606861d8a74ab37a851e6dfcf4a3bab1f824dcf8bef9e34bf9c6b27bc74a827e","target":"rust"},{"module":"napl","state":"33b9b868629947a37ba69a16cdd88b5f91fe1c54853c6910d0b22aeab7b22800","target":"rust"},{"module":"napl_store","state":"7011890f658fb28bd29684802e5e53bb5d54d54d3c5a64cc926a56fae7ef944d","target":"rust"},{"module":"query","state":"bc53b1a015afda4574efb81103b417c0bf8de879a57bfb3bcc3bcdfb3f2b639f","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} +napl-store receipt v1 717 +{"baseStateKey":"33a141441be88c6825e85a1495467d599a016bae61a678358f9d680a52017c99","baseStoreSnapshot":"ad325c51ef6afc696eb5e656129787c12b7e5048f387269a0e40419a73a9879e","baseWorkspaceHash":"bfa4ca1555c45ef6c957fcfd62e6fff07ef87365e518dfe0aa8d0adb989181c6","candidateTreeHash":"10e6ecbb2cf94bd488d9b800284ed2e991d1f6ee5ab3156d8b8d6a711e784c87","contractVersion":4,"inputKey":"a81dbd9694703c7aa43996c1fd0ba96dc10741827cd8283deefbfa80995cb3ac","module":"git-store","outputManifest":[{"hash":"1f70489bb644bef7e033b0d1c58f888dde57e9874e3045580feee00633ec748b","path":"crates/napl-store/src/git_store.rs"}],"receiptVersion":1,"sliceHash":"20862bd94681f9290944a9a8ea0ab62a8dad62db6b327c9e87c7c972de9a55ef","target":"rust"} +napl-store event v1 158 +{"content":"ccef8c05e2e325c5627320612073e20444cc5639b5bf1cc99349c915e0799eb5","parents":["4316a0ee1e4f6061a6ab25eed8e494799c62d033a790fe1ab629ce4989318039"]} +napl-store blob v1 5676 +# commands: init and watch + +Two porcelain additions to the `napl` binary, each a thin composition +over the same napl-core derivations and napl-store reads the other +commands use. They live in this module and compile into the same crate as +the core toolchain. The query surface that used to live here belongs to +the `query` module. + +## napl init + +`napl init` bootstraps a workspace: it creates the records directory +`.napl/` with an empty store (`store/` skeleton, no HEAD, no objects) in +the current directory when no ancestor workspace exists. Running it inside +an existing workspace changes nothing and reports `already_initialized`; +it never creates a nested workspace under another workspace and never +touches an existing store's bytes. The result of init is exactly the +state a fresh `napl compile` expects: head absent, no modules recorded. +Like `git init`, the directory it runs in is the whole story: the +success payload names that directory as the workspace root, there is +nothing to configure, and a prompt written beside `.napl` is already +inside placement. Init accepts no flags beyond the shared help flag; +anything else is a usage error under the command line contract. The +payload also teaches the first mile: a `nextSteps` field carries one +complete minimal prompt, frontmatter fences with `module`, `deps`, and +`targets` plus one body line, the fact that discovery finds prompts by +the `.napl` extension, and the exact command sequence from writing that +prompt through `add` and `commit`, so a cold user never reverse +engineers the grammar one validation error at a time. The minimal +prompt's frontmatter includes a `roots` declaration naming its own +module, so the very first workspace is lint-ready and the sequence's +forward pointer about `roots` is a reminder, never a first encounter. + +Init also scaffolds the agent-teaching glue, wrangler-style, writing +only what is absent and never touching an existing file: a +`napl-agents.md` at the workspace root carrying the working doctrine +for LLM sessions (edit with your native tools, drift is legal and the +diagnostics mark it, the status-add-commit loop, the query surface, +capture verbs are deliberate acts), plus, when absent, a `CLAUDE.md` +containing an import reference to it and an `AGENTS.md` containing a +pointer line, so whichever harness opens the repo finds its native +entry file already pointing at the one doctrine. Every command the +scaffolded teaching spells is a valid invocation under the live +command line contract: a query example always carries its expression, +`napl q '.' --working-tree` and never a bare `napl q`, because the +expression is required and a bare invocation is a usage error. No +environment is +sniffed and no harness detected: every entry file is written, tiny +and inert, and determinism holds. When an entry file already exists, +init leaves it alone and the payload prints the one reference line +the user may add by hand. All scaffolded files are user-owned from +the moment they exist: ordinary files, never records, never +rewritten by any later command. + +::: test +name: init in an empty directory creates the records skeleton with no head +given: { directory: empty } +expect: { created: [".napl/store"], head: absent, exit_zero: true, workspace_root_reported: true } +::: + +::: test +name: init teaches the first mile with a complete minimal prompt and the command sequence +given: { directory: empty } +expect: { next_steps_prompt_passes_check: true, extension_named: ".napl", sequence_named: [write_prompt, add, commit], prompt_declares_roots: true } +::: + +::: test +name: init scaffolds the agent glue only where absent and clobbers nothing +given: { directory: with_existing_claude_md } +expect: { napl_agents_md_written: true, agents_md_written: true, claude_md_untouched: true, payload_prints_reference_line: true } +::: + +::: test +name: scaffolded teaching never spells a bare napl q and its query example carries an expression +given: { directory: empty } +expect: { bare_napl_q_absent: true, query_example_has_expression: true } +::: + +::: test +name: scaffolded files are inert and later commands never rewrite them +given: { directory: empty, then: [init, write_prompt, add_module, commit] } +expect: { scaffold_bytes_unchanged_after: true } +::: + +::: test +name: init where a workspace already exists changes nothing and says so +given: { directory: existing_workspace } +expect: { changed_files: [], outcome: already_initialized, exit_zero: true } +::: + +::: test +name: init never overwrites an existing store +given: { directory: existing_workspace_with_commits } +expect: { store_bytes_unchanged: true } +::: + +## napl watch + +`napl watch` is an I/O shell around the pure compile derivation: it +observes the workspace's `*.napl` files and re-runs the derivation when +their content changes, emitting each result to stdout as canonical JSON. It publishes nothing: no semantic writes, no store +mutation, no locks held between runs. Change detection is by content +hash, never by timestamp, so an editor save that leaves bytes identical +produces no recompile. A malformed prompt is reported as a diagnostic in +the output stream and the watcher keeps running. Stopping the process is +the only way to end it, and stopping it loses nothing. + +::: test +name: watch re-derives compile output when a prompt changes and writes nothing semantic +given: { workspace: fixture_two_modules, edit: touch_leaf_prompt } +expect: { recompiled: true, semantic_state_published: false } +::: + +::: test +name: watch survives a malformed prompt edit and reports it instead of dying +given: { workspace: fixture_two_modules, edit: break_leaf_frontmatter } +expect: { process_alive: true, diagnostic_reported: true } +::: +napl-store receipt v1 815 +{"baseStateKey":"d6fd7c0f72ece55d52229d300030de9f49a3a9908961e50c8af30bb819d0a768","baseStoreSnapshot":"fe951b7bd4a25490821f5174777888dd2c87066733b393bf3009954694f9db0e","baseWorkspaceHash":"3baf3bc6860508a4786c8485e3b68a27f5c83e221a9eb0e175541a425d4a0bae","candidateTreeHash":"ec3719633a6e910e550f100d8393823afd477b930ca45ff7da752a1a944df986","contractVersion":4,"inputKey":"80d6c286c5a0ed57bf1e618663be6a809568faa305d8c6fcba677a9f1ff461d5","module":"commands","outputManifest":[{"hash":"82acfbd2af961a35f71f855f259486c4b37d79987b0fcf83ed5470ec43fcfc46","path":"crates/napl/src/init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","path":"crates/napl/src/watch.rs"}],"receiptVersion":1,"sliceHash":"02e18c381f552fc0fa874c5dcab19c3f41ef8eba1df4a2cd2bb6b594ff6c0670","target":"rust"} +napl-store event v1 158 +{"content":"a8fba66cc1793229e7c9f92be45bb40f49138a4390a4795f4d478e3b33493584","parents":["17ebcc121b23401d4580f3a6a36b49c11ae698c382eaeae3221d4bb5b7a05def"]} +napl-store receipt v1 1280 +{"baseStateKey":"01ca35a882d63c2d2509a7c0890b0c588a743757cc873b5814e57d275dee997e","baseStoreSnapshot":"619895ec62acae41dd1253bea6d7cf73f56ad99bcf9163a2ba130c2806133944","baseWorkspaceHash":"da64f5d7f36eff8fe5c88375df07238b5dd37e691fc9a3f9e364ba5e92db203e","candidateTreeHash":"05f44ac11e9808b57f775c0ffedd617fc59c8a13cef9839ed551a535e36b7148","contractVersion":4,"inputKey":"4ebe5ebb9187aa6b6491ced2de8b175ff38ef22cf378fb2659be1b6d95de170b","module":"query","outputManifest":[{"hash":"deff13703dd0b3c630077edaffafc1fa9f2fdfde297b9a84b45841a428715965","path":"crates/napl/src/check.rs"},{"hash":"7c26384c4adc5b5c857d0811275db450218c8f05177ae35a898ec59a5a5a5e03","path":"crates/napl/src/docs.rs"},{"hash":"726a20b8361501f4c26633cd92835ffb2e5f1acd584c0af76895d29bf620a7b1","path":"crates/napl/src/document.rs"},{"hash":"5ec2c2137f45d02c8e8bf78ecf0652966850772097b41f3835a99116f3b78f57","path":"crates/napl/src/promptbuild.rs"},{"hash":"be629fe4418f4226c3119cf486d936741c11b644241c9c358df2172131c722c1","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"reported":{"evidence":"unavailable","gates":"unavailable","notes":"unavailable","producer":"unavailable","usage":"unavailable"},"sliceHash":"005b93d52f82f204510ec1ede96a5a691e10e210f1b721ddfc24ae04d782903f","target":"rust"} +napl-store snapshot v1 555 +{"contractVersion":3,"event":"80a5ca3cb250b5aefac03a5ef11becc14107d0f2d40459b0bc408394a44b6df0","states":[{"module":"commands","state":"e79a58ac8ad0c50f897901451c0b0b0f44757ab69818fe49282b3d41efd1b1bf","target":"rust"},{"module":"napl","state":"21631447bbac552e78b823a151bc6b9b6132a82db42e636c7befdfc081f3ed2d","target":"rust"},{"module":"napl_store","state":"1243a30383b122b303ec2bae058167b2541d8b9805b3eab569011d2cf15ad607","target":"rust"},{"module":"query","state":"7bfc8437757380e5783a1381f2b78ff6a753c5db50b8afdb02095deca4527799","target":"rust"}]} +napl-store surface v1 82577 +{"contractVersion":4,"items":[{"kind":"struct","path":"attribution::Attribution","signature":"pub struct Attribution"},{"kind":"field","path":"attribution::Attribution.entries","signature":"pub entries : Vec < Entry >"},{"kind":"field","path":"attribution::Attribution.module","signature":"pub module : String"},{"kind":"field","path":"attribution::Attribution.stale","signature":"pub stale : bool"},{"kind":"field","path":"attribution::Attribution.target","signature":"pub target : String"},{"kind":"enum","path":"attribution::Direction","signature":"pub enum Direction { # [ default ] PromptToCode , CodeToPrompt }"},{"kind":"method","path":"attribution::Direction::as_str","signature":"pub fn as_str ( & self ) -> & ' static str"},{"kind":"method","path":"attribution::Direction::from_token","signature":"pub fn from_token ( text : & str ) -> Direction"},{"kind":"const","path":"attribution::ENDPOINTS","signature":"pub const ENDPOINTS : [ & str ; 2 ] = [ \" code \" , \" prompt \" ] ;"},{"kind":"struct","path":"attribution::Entry","signature":"pub struct Entry"},{"kind":"field","path":"attribution::Entry.code_hash","signature":"pub code_hash : Option < String >"},{"kind":"field","path":"attribution::Entry.direction","signature":"pub direction : Direction"},{"kind":"field","path":"attribution::Entry.file","signature":"pub file : Option < String >"},{"kind":"field","path":"attribution::Entry.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"attribution::Entry.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"attribution::Entry.note","signature":"pub note : String"},{"kind":"field","path":"attribution::Entry.prompt_end","signature":"pub prompt_end : u32"},{"kind":"field","path":"attribution::Entry.prompt_hash","signature":"pub prompt_hash : Option < String >"},{"kind":"field","path":"attribution::Entry.prompt_start","signature":"pub prompt_start : u32"},{"kind":"field","path":"attribution::Entry.provenance","signature":"pub provenance : Provenance"},{"kind":"field","path":"attribution::Entry.whole_file","signature":"pub whole_file : bool"},{"kind":"field","path":"attribution::Entry.whole_prompt","signature":"pub whole_prompt : bool"},{"kind":"method","path":"attribution::Entry::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"attribution::Entry::hashes_match","signature":"pub fn hashes_match ( & self , code_bytes : Option < & [ u8 ] > , prompt_body : & str ) -> bool"},{"kind":"method","path":"attribution::Entry::is_law","signature":"pub fn is_law ( & self ) -> bool"},{"kind":"method","path":"attribution::Entry::self_affirming","signature":"pub fn self_affirming ( & self ) -> bool"},{"kind":"struct","path":"attribution::MapParseError","signature":"pub struct MapParseError"},{"kind":"field","path":"attribution::MapParseError.construct","signature":"pub construct : String"},{"kind":"field","path":"attribution::MapParseError.detail","signature":"pub detail : String"},{"kind":"field","path":"attribution::MapParseError.line","signature":"pub line : u32"},{"kind":"struct","path":"attribution::Provenance","signature":"pub struct Provenance"},{"kind":"field","path":"attribution::Provenance.model","signature":"pub model : Option < String >"},{"kind":"field","path":"attribution::Provenance.timestamp","signature":"pub timestamp : Option < String >"},{"kind":"field","path":"attribution::Provenance.tool_version","signature":"pub tool_version : Option < String >"},{"kind":"field","path":"attribution::Provenance.usage","signature":"pub usage : Option < UsageRef >"},{"kind":"method","path":"attribution::Provenance::is_empty","signature":"pub fn is_empty ( & self ) -> bool"},{"kind":"method","path":"attribution::Provenance::served","signature":"pub fn served ( & self ) -> Vec < ( & ' static str , String ) >"},{"kind":"method","path":"attribution::Provenance::served_json","signature":"pub fn served_json ( & self ) -> Value"},{"kind":"struct","path":"attribution::ReferenceStatus","signature":"pub struct ReferenceStatus"},{"kind":"field","path":"attribution::ReferenceStatus.endpoints","signature":"pub endpoints : Vec < & ' static str >"},{"kind":"field","path":"attribution::ReferenceStatus.stale","signature":"pub stale : bool"},{"kind":"method","path":"attribution::ReferenceStatus::as_str","signature":"pub fn as_str ( & self ) -> & ' static str"},{"kind":"const","path":"attribution::UNAVAILABLE","signature":"pub const UNAVAILABLE : & str = \" unavailable \" ;"},{"kind":"enum","path":"attribution::UsageRef","signature":"pub enum UsageRef { Envelope ( String ) , Window ( UsageWindow ) }"},{"kind":"method","path":"attribution::UsageRef::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"attribution::UsageRef::rendered","signature":"pub fn rendered ( & self ) -> String"},{"kind":"struct","path":"attribution::UsageWindow","signature":"pub struct UsageWindow"},{"kind":"field","path":"attribution::UsageWindow.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"attribution::UsageWindow.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"attribution::UsageWindow.log_path","signature":"pub log_path : String"},{"kind":"field","path":"attribution::UsageWindow.media_type","signature":"pub media_type : String"},{"kind":"field","path":"attribution::UsageWindow.session","signature":"pub session : String"},{"kind":"const","path":"attribution::WHOLE","signature":"pub const WHOLE : & str = \" whole \" ;"},{"kind":"fn","path":"attribution::code_drifted","signature":"pub fn code_drifted ( committed_blob : Option < & str > , working_bytes : Option < & [ u8 ] > ) -> bool"},{"kind":"fn","path":"attribution::entry_reference_status","signature":"pub fn entry_reference_status ( entry : & Entry , record_stale : bool , prompt_drifted : bool , code_drifted : bool , code_bytes : Option < & [ u8 ] > , pending_body : & str ) -> ReferenceStatus"},{"kind":"fn","path":"attribution::map_contract","signature":"pub fn map_contract ( ) -> Value"},{"kind":"fn","path":"attribution::parse","signature":"pub fn parse ( text : & str ) -> Option < Attribution >"},{"kind":"fn","path":"attribution::parse_detailed","signature":"pub fn parse_detailed ( text : & str ) -> std :: result :: Result < Attribution , MapParseError >"},{"kind":"fn","path":"attribution::record_hash","signature":"pub fn record_hash ( attribution : & Attribution ) -> String"},{"kind":"fn","path":"attribution::reference_status","signature":"pub fn reference_status ( record_stale : bool , prompt_drifted : bool , code_drifted : bool ) -> ReferenceStatus"},{"kind":"fn","path":"attribution::remap_prefix","signature":"pub fn remap_prefix ( attribution : & mut Attribution , old_root : & str , dest : & str )"},{"kind":"fn","path":"attribution::resolve_whole","signature":"pub fn resolve_whole ( attribution : & mut Attribution , prompt_body_lines : u32 , file_lines : & BTreeMap < String , u32 > )"},{"kind":"fn","path":"attribution::to_yaml","signature":"pub fn to_yaml ( attribution : & Attribution ) -> Vec < u8 >"},{"kind":"fn","path":"attribution::validate","signature":"pub fn validate ( target : & TargetDef , root : & str , module : & str , candidate_files : & BTreeMap < String , Vec < u8 > > , prompt_body_lines : u32 , attribution : & Attribution ) -> Result < ( ) >"},{"kind":"fn","path":"attribution::view_path","signature":"pub fn view_path ( module : & str ) -> String"},{"kind":"fn","path":"attribution::whole_map","signature":"pub fn whole_map ( module : & str , target : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Attribution"},{"kind":"fn","path":"authoring::apply_fix","signature":"pub fn apply_fix ( workspace : & Path , target : & str , finding : & Finding ) -> Result < CommitReport >"},{"kind":"fn","path":"authoring::draft_receipt","signature":"pub fn draft_receipt ( workspace : & Path , target_name : & str , module : & str , candidate_dir : & Path , usage_path : Option < & Path > ) -> Result < Value >"},{"kind":"fn","path":"authoring::draft_receipt_for","signature":"pub fn draft_receipt_for ( workspace : & Path , target_name : & str , module : & str , candidate : & crate :: candidate :: Candidate , usage_path : Option < & Path > ) -> Result < Value >"},{"kind":"struct","path":"blame::BlameLine","signature":"pub struct BlameLine"},{"kind":"field","path":"blame::BlameLine.line","signature":"pub line : usize"},{"kind":"field","path":"blame::BlameLine.text","signature":"pub text : String"},{"kind":"field","path":"blame::BlameLine.transition","signature":"pub transition : String"},{"kind":"struct","path":"blame::Revision","signature":"pub struct Revision"},{"kind":"field","path":"blame::Revision.content","signature":"pub content : String"},{"kind":"field","path":"blame::Revision.transition","signature":"pub transition : String"},{"kind":"fn","path":"blame::blame","signature":"pub fn blame ( revisions : & [ Revision ] ) -> Vec < BlameLine >"},{"kind":"fn","path":"blame::blame_json","signature":"pub fn blame_json ( path : & str , lines : & [ BlameLine ] ) -> Value"},{"kind":"fn","path":"blame::untracked_json","signature":"pub fn untracked_json ( path : & str , reason : & str ) -> Value"},{"kind":"struct","path":"candidate::Candidate","signature":"pub struct Candidate"},{"kind":"field","path":"candidate::Candidate.files","signature":"pub files : BTreeMap < String , Vec < u8 > >"},{"kind":"field","path":"candidate::Candidate.manifest","signature":"pub manifest : BTreeMap < String , String >"},{"kind":"field","path":"candidate::Candidate.tree_hash","signature":"pub tree_hash : String"},{"kind":"field","path":"candidate::Candidate.tree_objects","signature":"pub tree_objects : BTreeMap < String , Tree >"},{"kind":"enum","path":"candidate::EntryKind","signature":"pub enum EntryKind { File , Link , Device }"},{"kind":"struct","path":"candidate::Limits","signature":"pub struct Limits"},{"kind":"field","path":"candidate::Limits.max_entries","signature":"pub max_entries : usize"},{"kind":"field","path":"candidate::Limits.max_entry_bytes","signature":"pub max_entry_bytes : usize"},{"kind":"field","path":"candidate::Limits.max_total_bytes","signature":"pub max_total_bytes : usize"},{"kind":"struct","path":"candidate::ScannedEntry","signature":"pub struct ScannedEntry"},{"kind":"field","path":"candidate::ScannedEntry.bytes","signature":"pub bytes : Vec < u8 >"},{"kind":"field","path":"candidate::ScannedEntry.kind","signature":"pub kind : EntryKind"},{"kind":"field","path":"candidate::ScannedEntry.path","signature":"pub path : String"},{"kind":"fn","path":"candidate::default_limits","signature":"pub fn default_limits ( ) -> Limits"},{"kind":"fn","path":"candidate::ingest","signature":"pub fn ingest ( entries : & [ ScannedEntry ] , limits : Limits ) -> Result < Candidate >"},{"kind":"fn","path":"canon::canonical_bytes","signature":"pub fn canonical_bytes ( value : & Value ) -> Vec < u8 >"},{"kind":"fn","path":"canon::hash_json","signature":"pub fn hash_json ( kind : ObjectKind , value : & Value ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"canon::is_object_name","signature":"pub fn is_object_name ( text : & str ) -> bool"},{"kind":"fn","path":"canon::json_hash","signature":"pub fn json_hash ( kind : ObjectKind , value : & Value ) -> String"},{"kind":"fn","path":"canon::parse_json","signature":"pub fn parse_json ( bytes : & [ u8 ] , context : & str , code : OutcomeCode ) -> Result < Value >"},{"kind":"const","path":"capture::SESSION_VARIABLE","signature":"pub const SESSION_VARIABLE : & str = \" NAPL_SESSION \" ;"},{"kind":"const","path":"capture::USAGE_LOG_MEDIA_TYPE","signature":"pub const USAGE_LOG_MEDIA_TYPE : & str = \" application/x-napl-usage-proxy-jsonl-v1 \" ;"},{"kind":"const","path":"capture::USAGE_LOG_PATH","signature":"pub const USAGE_LOG_PATH : & str = \" .napl/usage-proxy.jsonl \" ;"},{"kind":"fn","path":"capture::edit_bytes","signature":"pub fn edit_bytes ( workspace : & Path , path : & str , old : & str , new : & str , replace_all : bool , reference : Option < & str > , session : Option < & str > ) -> Result < CliResult >"},{"kind":"fn","path":"capture::ref_contract","signature":"pub fn ref_contract ( ) -> Value"},{"kind":"fn","path":"capture::write_bytes","signature":"pub fn write_bytes ( workspace : & Path , path : & str , bytes : & [ u8 ] , reference : Option < & str > , session : Option < & str > ) -> Result < CliResult >"},{"kind":"const","path":"cli::BINARY_USAGE","signature":"pub const BINARY_USAGE : & str = \" napl [arguments] \" ;"},{"kind":"struct","path":"cli::CliResult","signature":"pub struct CliResult"},{"kind":"field","path":"cli::CliResult.exit_code","signature":"pub exit_code : i32"},{"kind":"field","path":"cli::CliResult.stdout","signature":"pub stdout : Vec < u8 >"},{"kind":"struct","path":"cli::CommandSpec","signature":"pub struct CommandSpec"},{"kind":"field","path":"cli::CommandSpec.example","signature":"pub example : & ' static str"},{"kind":"field","path":"cli::CommandSpec.flags","signature":"pub flags : & ' static [ FlagSpec ]"},{"kind":"field","path":"cli::CommandSpec.name","signature":"pub name : & ' static str"},{"kind":"field","path":"cli::CommandSpec.passthrough","signature":"pub passthrough : bool"},{"kind":"field","path":"cli::CommandSpec.run","signature":"pub run : fn ( & Path , & [ String ] ) -> Result < CliResult >"},{"kind":"field","path":"cli::CommandSpec.summary","signature":"pub summary : & ' static str"},{"kind":"field","path":"cli::CommandSpec.usage","signature":"pub usage : & ' static str"},{"kind":"enum","path":"cli::FlagArity","signature":"pub enum FlagArity { Switch , Value }"},{"kind":"struct","path":"cli::FlagSpec","signature":"pub struct FlagSpec"},{"kind":"field","path":"cli::FlagSpec.arity","signature":"pub arity : FlagArity"},{"kind":"field","path":"cli::FlagSpec.meaning","signature":"pub meaning : & ' static str"},{"kind":"field","path":"cli::FlagSpec.name","signature":"pub name : & ' static str"},{"kind":"field","path":"cli::FlagSpec.short","signature":"pub short : Option < & ' static str >"},{"kind":"const","path":"cli::HELP_FLAGS","signature":"pub const HELP_FLAGS : [ & str ; 2 ] = [ \" --help \" , \" -h \" ] ;"},{"kind":"const","path":"cli::HUMAN_FLAG","signature":"pub const HUMAN_FLAG : & str = \" --human \" ;"},{"kind":"const","path":"cli::JSON_FLAG","signature":"pub const JSON_FLAG : & str = \" --json \" ;"},{"kind":"const","path":"cli::USAGE_EXIT_CODE","signature":"pub const USAGE_EXIT_CODE : i32 = 2 ;"},{"kind":"const","path":"cli::WORKSPACE_FLAG","signature":"pub const WORKSPACE_FLAG : FlagSpec = value ( \" --workspace \" , \" the workspace root to operate on, instead of discovering it from the current directory \" ) ;"},{"kind":"fn","path":"cli::check_invocation","signature":"pub fn check_invocation ( line : & str ) -> std :: result :: Result < ( ) , String >"},{"kind":"fn","path":"cli::clap_command_for","signature":"pub fn clap_command_for ( spec : & CommandSpec ) -> clap :: Command"},{"kind":"fn","path":"cli::clap_root","signature":"pub fn clap_root ( registry : & [ CommandSpec ] ) -> clap :: Command"},{"kind":"fn","path":"cli::emit_prerequisite","signature":"pub fn emit_prerequisite ( command : & str ) -> CliResult"},{"kind":"fn","path":"cli::help_all_json","signature":"pub fn help_all_json ( registry : & [ CommandSpec ] ) -> Value"},{"kind":"fn","path":"cli::help_command_json","signature":"pub fn help_command_json ( spec : & CommandSpec ) -> Value"},{"kind":"fn","path":"cli::prerequisite_json","signature":"pub fn prerequisite_json ( command : & str ) -> Value"},{"kind":"fn","path":"cli::render_help_all","signature":"pub fn render_help_all ( registry : & [ CommandSpec ] ) -> String"},{"kind":"fn","path":"cli::render_help_command","signature":"pub fn render_help_command ( spec : & CommandSpec ) -> String"},{"kind":"fn","path":"cli::run_cli","signature":"pub fn run_cli ( cwd : & Path , args : & [ String ] ) -> CliResult"},{"kind":"fn","path":"cli::switch","signature":"pub const fn switch ( name : & ' static str , meaning : & ' static str ) -> FlagSpec"},{"kind":"fn","path":"cli::switch_short","signature":"pub const fn switch_short ( name : & ' static str , short : & ' static str , meaning : & ' static str ) -> FlagSpec"},{"kind":"fn","path":"cli::usage_json","signature":"pub fn usage_json ( spec : Option < & CommandSpec > , detail : & str , unknown : Option < & str > , suggestion : Option < & str > ) -> Value"},{"kind":"fn","path":"cli::value","signature":"pub const fn value ( name : & ' static str , meaning : & ' static str ) -> FlagSpec"},{"kind":"const","path":"commandset::COMPLETION_SHELLS","signature":"pub const COMPLETION_SHELLS : [ & str ; 3 ] = [ \" bash \" , \" zsh \" , \" fish \" ] ;"},{"kind":"fn","path":"commandset::commands","signature":"pub fn commands ( ) -> & ' static [ CommandSpec ]"},{"kind":"enum","path":"commit::AttributionMode","signature":"pub enum AttributionMode { Prove , PreProven , Escape }"},{"kind":"struct","path":"commit::CommitReport","signature":"pub struct CommitReport"},{"kind":"field","path":"commit::CommitReport.outcome","signature":"pub outcome : OutcomeCode"},{"kind":"field","path":"commit::CommitReport.payload","signature":"pub payload : Value"},{"kind":"fn","path":"commit::commit","signature":"pub fn commit ( workspace : & Path , receipt_path : & Path , candidate_dir : & Path , attribution_path : Option < & Path > , no_verify : bool ) -> CommitReport"},{"kind":"fn","path":"commit::commit_docs","signature":"pub fn commit_docs ( workspace : & Path , record : & Path ) -> CommitReport"},{"kind":"fn","path":"commit::commit_with_mode","signature":"pub fn commit_with_mode ( workspace : & Path , receipt_path : & Path , candidate_dir : & Path , attribution_path : Option < & Path > , mode : AttributionMode , staged_prompt : Option < & Path > ) -> CommitReport"},{"kind":"fn","path":"commit::lock_key","signature":"pub fn lock_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"commit::recover","signature":"pub fn recover ( store : & Store ) -> Result < Vec < String > >"},{"kind":"const","path":"compile::CONTRACT_VERSION_CHANGED","signature":"pub const CONTRACT_VERSION_CHANGED : & str = \" contract-version-changed \" ;"},{"kind":"struct","path":"compile::CompileArtifact","signature":"pub struct CompileArtifact"},{"kind":"field","path":"compile::CompileArtifact.artifact_hash","signature":"pub artifact_hash : String"},{"kind":"field","path":"compile::CompileArtifact.slices","signature":"pub slices : Vec < CompileSlice >"},{"kind":"field","path":"compile::CompileArtifact.value","signature":"pub value : Value"},{"kind":"const","path":"compile::DEPENDENCY_SURFACE_MOVED","signature":"pub const DEPENDENCY_SURFACE_MOVED : & str = \" dependency-surface-moved \" ;"},{"kind":"const","path":"compile::NEVER_COMMITTED","signature":"pub const NEVER_COMMITTED : & str = \" never-committed \" ;"},{"kind":"const","path":"compile::PLACEMENT_CHANGED","signature":"pub const PLACEMENT_CHANGED : & str = \" placement-changed \" ;"},{"kind":"const","path":"compile::SOURCE_CHANGED","signature":"pub const SOURCE_CHANGED : & str = \" source-changed \" ;"},{"kind":"const","path":"compile::TARGET_CHANGED","signature":"pub const TARGET_CHANGED : & str = \" target-changed \" ;"},{"kind":"struct","path":"compile::Verdict","signature":"pub struct Verdict"},{"kind":"field","path":"compile::Verdict.reasons","signature":"pub reasons : Vec < & ' static str >"},{"kind":"field","path":"compile::Verdict.reland_identical_bytes","signature":"pub reland_identical_bytes : bool"},{"kind":"method","path":"compile::Verdict::json_fields","signature":"pub fn json_fields ( & self , pending : bool ) -> ( Value , Value , Value )"},{"kind":"method","path":"compile::Verdict::reason","signature":"pub fn reason ( & self ) -> Option < & ' static str >"},{"kind":"fn","path":"compile::base_output_files","signature":"pub fn base_output_files ( source : & dyn ObjectSource , state : & ModuleState ) -> Result < Vec < ( String , String ) > >"},{"kind":"fn","path":"compile::compile","signature":"pub fn compile ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , only_module : Option < & str > ) -> Result < CompileArtifact >"},{"kind":"fn","path":"compile::graph_for","signature":"pub fn graph_for ( sources : & [ SourceModule ] , target_name : & str ) -> Result < Graph >"},{"kind":"fn","path":"compile::head_states","signature":"pub fn head_states ( source : & dyn ObjectSource , snapshot : Option < & str > ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"compile::install_index","signature":"pub fn install_index ( source : & dyn ObjectSource , head : Option < & str > ) -> Result < BTreeMap < String , Value > >"},{"kind":"fn","path":"compile::read_surface_items","signature":"pub fn read_surface_items ( source : & dyn ObjectSource , hash : & str ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"compile::slice_at","signature":"pub fn slice_at ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , module : & str , snapshot : Option < & str > ) -> Result < CompileSlice >"},{"kind":"fn","path":"compile::state_of","signature":"pub fn state_of ( source : & dyn ObjectSource , states : & BTreeMap < ( String , String ) , String > , target : & TargetDef , module : & str ) -> Result < ( ModuleState , String ) >"},{"kind":"fn","path":"compile::verdict","signature":"pub fn verdict ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target : & TargetDef , module : & str , states : & BTreeMap < ( String , String ) , String > , installs : & BTreeMap < String , Value > , frontier : & BTreeSet < String > , drift : bool ) -> Result < Verdict >"},{"kind":"fn","path":"compile::vocabulary","signature":"pub fn vocabulary ( ) -> Vec < & ' static str >"},{"kind":"fn","path":"compile::workspace_source_hash","signature":"pub fn workspace_source_hash ( sources : & [ SourceModule ] ) -> Result < String >"},{"kind":"struct","path":"cost::CostLine","signature":"pub struct CostLine"},{"kind":"field","path":"cost::CostLine.cost_usd","signature":"pub cost_usd : Option < String >"},{"kind":"field","path":"cost::CostLine.model","signature":"pub model : Option < String >"},{"kind":"field","path":"cost::CostLine.module","signature":"pub module : String"},{"kind":"field","path":"cost::CostLine.reason","signature":"pub reason : Option < String >"},{"kind":"field","path":"cost::CostLine.receipt","signature":"pub receipt : String"},{"kind":"field","path":"cost::CostLine.tokens","signature":"pub tokens : Option < TokenUsage >"},{"kind":"const","path":"cost::UNKNOWN","signature":"pub const UNKNOWN : & str = \" unknown \" ;"},{"kind":"struct","path":"cost::WindowRef","signature":"pub struct WindowRef"},{"kind":"field","path":"cost::WindowRef.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"cost::WindowRef.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"cost::WindowRef.log_path","signature":"pub log_path : String"},{"kind":"field","path":"cost::WindowRef.media_type","signature":"pub media_type : String"},{"kind":"field","path":"cost::WindowRef.session","signature":"pub session : String"},{"kind":"fn","path":"cost::cost_report","signature":"pub fn cost_report ( lines : & [ CostLine ] ) -> Value"},{"kind":"fn","path":"cost::line_from_reported","signature":"pub fn line_from_reported ( receipt : & str , module : & str , reported : & Value ) -> Result < CostLine >"},{"kind":"fn","path":"cost::line_from_window","signature":"pub fn line_from_window ( receipt : & str , module : & str , window : & WindowRef , log : Option < & str > ) -> CostLine"},{"kind":"fn","path":"cost::lines_from_session","signature":"pub fn lines_from_session ( log : & str , session : & str ) -> Vec < CostLine >"},{"kind":"fn","path":"cost::pricing_provenance","signature":"pub fn pricing_provenance ( ) -> Value"},{"kind":"fn","path":"cost::window_from_reported","signature":"pub fn window_from_reported ( reported : & Value ) -> Option < WindowRef >"},{"kind":"struct","path":"decision::Arriving","signature":"pub struct Arriving"},{"kind":"field","path":"decision::Arriving.base_snapshot","signature":"pub base_snapshot : Option < String >"},{"kind":"field","path":"decision::Arriving.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Arriving.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Arriving.write_set","signature":"pub write_set : BTreeSet < String >"},{"kind":"enum","path":"decision::Decision","signature":"pub enum Decision { Install , Rebase , StaleInput { expected : String , found : String } , BaseOutputChanged { expected : String , found : String } , WriteConflict { overlap : Vec < String > } }"},{"kind":"struct","path":"decision::Head","signature":"pub struct Head"},{"kind":"field","path":"decision::Head.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Head.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Head.intervening_write_set","signature":"pub intervening_write_set : BTreeSet < String >"},{"kind":"field","path":"decision::Head.snapshot","signature":"pub snapshot : Option < String >"},{"kind":"fn","path":"decision::conflicts","signature":"pub fn conflicts ( write_set_a : & BTreeSet < String > , read_set_a : & BTreeSet < String > , write_set_b : & BTreeSet < String > , changed_identities_b : & BTreeSet < String > ) -> bool"},{"kind":"fn","path":"decision::decide","signature":"pub fn decide ( arriving : & Arriving , head : & Head ) -> Decision"},{"kind":"fn","path":"decision::is_accepted","signature":"pub fn is_accepted ( decision : & Decision ) -> bool"},{"kind":"fn","path":"decision::rejection","signature":"pub fn rejection ( decision : & Decision ) -> Option < CompilerError >"},{"kind":"struct","path":"directive::BodySplit","signature":"pub struct BodySplit"},{"kind":"field","path":"directive::BodySplit.docs","signature":"pub docs : Vec < DocsFence >"},{"kind":"field","path":"directive::BodySplit.semantic","signature":"pub semantic : String"},{"kind":"const","path":"directive::DOCS_DIRECTIVES","signature":"pub const DOCS_DIRECTIVES : [ & str ; 4 ] = [ \" docs \" , \" example \" , \" note \" , \" warning \" ] ;"},{"kind":"struct","path":"directive::DirectiveCase","signature":"pub struct DirectiveCase"},{"kind":"field","path":"directive::DirectiveCase.expect","signature":"pub expect : Value"},{"kind":"field","path":"directive::DirectiveCase.given","signature":"pub given : Value"},{"kind":"field","path":"directive::DirectiveCase.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"directive::DirectiveCase.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"directive::DirectiveCase.name","signature":"pub name : String"},{"kind":"struct","path":"directive::DocsFence","signature":"pub struct DocsFence"},{"kind":"field","path":"directive::DocsFence.anchor","signature":"pub anchor : u32"},{"kind":"field","path":"directive::DocsFence.attrs","signature":"pub attrs : Value"},{"kind":"field","path":"directive::DocsFence.body","signature":"pub body : String"},{"kind":"field","path":"directive::DocsFence.directive","signature":"pub directive : String"},{"kind":"field","path":"directive::DocsFence.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"directive::DocsFence.line_start","signature":"pub line_start : u32"},{"kind":"const","path":"directive::EXTENSION_PREFIX","signature":"pub const EXTENSION_PREFIX : & str = \" x- \" ;"},{"kind":"fn","path":"directive::extract_cases","signature":"pub fn extract_cases ( normalized_body : & str ) -> Result < Vec < DirectiveCase > >"},{"kind":"fn","path":"directive::extract_validated","signature":"pub fn extract_validated ( normalized_body : & str , frontmatter_names : & [ String ] ) -> Result < Vec < DirectiveCase > >"},{"kind":"fn","path":"directive::is_docs_directive","signature":"pub fn is_docs_directive ( name : & str ) -> bool"},{"kind":"fn","path":"directive::normalize_case_name","signature":"pub fn normalize_case_name ( name : & str ) -> String"},{"kind":"fn","path":"directive::parse_inline_value","signature":"pub fn parse_inline_value ( text : & str ) -> Option < Value >"},{"kind":"fn","path":"directive::split_docs","signature":"pub fn split_docs ( normalized_body : & str ) -> Result < BodySplit >"},{"kind":"const","path":"disk::POINTER_LOCK","signature":"pub const POINTER_LOCK : & str = \" pointer \" ;"},{"kind":"const","path":"disk::STORE_DIR","signature":"pub const STORE_DIR : & str = \" .napl/store \" ;"},{"kind":"struct","path":"disk::Store","signature":"pub struct Store"},{"kind":"field","path":"disk::Store.root","signature":"pub root : PathBuf"},{"kind":"field","path":"disk::Store.workspace","signature":"pub workspace : PathBuf"},{"kind":"fn","path":"disk::compare_and_set_head","signature":"pub fn compare_and_set_head ( store : & Store , expected : Option < & str > , next : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::delete_marker","signature":"pub fn delete_marker ( store : & Store , snapshot : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::init","signature":"pub fn init ( workspace : & Path ) -> Result < Store >"},{"kind":"fn","path":"disk::io","signature":"pub fn io ( path : & Path , err : & std :: io :: Error ) -> CompilerError"},{"kind":"fn","path":"disk::loose_object_count","signature":"pub fn loose_object_count ( store : & Store ) -> Result < usize >"},{"kind":"fn","path":"disk::marker_snapshots","signature":"pub fn marker_snapshots ( store : & Store ) -> Result < Vec < String > >"},{"kind":"fn","path":"disk::open","signature":"pub fn open ( workspace : & Path ) -> Store"},{"kind":"fn","path":"disk::plan_repack","signature":"pub fn plan_repack ( store : & Store , reachable : & BTreeSet < String > ) -> Result < RepackOutcome >"},{"kind":"fn","path":"disk::publish","signature":"pub fn publish ( store : & Store , kind : ObjectKind , payload : & [ u8 ] , owner : & str ) -> Result < String >"},{"kind":"fn","path":"disk::read_marker","signature":"pub fn read_marker ( store : & Store , snapshot : & str ) -> Result < Option < CommitMarker > >"},{"kind":"fn","path":"disk::release_lock","signature":"pub fn release_lock ( guard : LockGuard ) -> Result < ( ) >"},{"kind":"fn","path":"disk::remove_object","signature":"pub fn remove_object ( store : & Store , name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::repack","signature":"pub fn repack ( store : & Store , reachable : & BTreeSet < String > , owner : & str ) -> Result < RepackOutcome >"},{"kind":"fn","path":"disk::take_lock","signature":"pub fn take_lock ( store : & Store , name : & str ) -> Result < LockGuard >"},{"kind":"fn","path":"disk::verify_packs","signature":"pub fn verify_packs ( store : & Store ) -> Result < Vec < PackFinding > >"},{"kind":"fn","path":"disk::write_marker","signature":"pub fn write_marker ( store : & Store , marker : & CommitMarker ) -> Result < ( ) >"},{"kind":"struct","path":"error::CompilerError","signature":"pub struct CompilerError"},{"kind":"field","path":"error::CompilerError.code","signature":"pub code : OutcomeCode"},{"kind":"field","path":"error::CompilerError.data","signature":"pub data : Option < Value >"},{"kind":"field","path":"error::CompilerError.detail","signature":"pub detail : String"},{"kind":"enum","path":"error::OutcomeCode","signature":"pub enum OutcomeCode { Success , Usage , StaleInput , BaseOutputChanged , WriteConflict , LockContended , InvalidCandidate , InvalidReceipt , MappingIntegrity , ResourceLimit , UnsupportedVersion , StoreIntegrity , IoError , EditMismatch , ReferenceUnresolvable , PromptInvalid }"},{"kind":"type","path":"error::Result","signature":"pub type Result < T > = std :: result :: Result < T , CompilerError > ;"},{"kind":"fn","path":"error::error","signature":"pub fn error ( code : OutcomeCode , detail : impl Into < String > ) -> CompilerError"},{"kind":"fn","path":"error::error_payload","signature":"pub fn error_payload ( err : & CompilerError , blobs_published : bool , provenance_published : bool ) -> Value"},{"kind":"fn","path":"error::error_with","signature":"pub fn error_with ( code : OutcomeCode , detail : impl Into < String > , data : Value ) -> CompilerError"},{"kind":"fn","path":"error::outcome_exit_code","signature":"pub fn outcome_exit_code ( code : OutcomeCode ) -> i32"},{"kind":"fn","path":"error::outcome_is_retryable","signature":"pub fn outcome_is_retryable ( code : OutcomeCode ) -> bool"},{"kind":"fn","path":"error::outcome_slug","signature":"pub fn outcome_slug ( code : OutcomeCode ) -> & ' static str"},{"kind":"const","path":"evidence::CLAUDE_CLI_JSON","signature":"pub const CLAUDE_CLI_JSON : & str = \" application/vnd.claude-cli+json \" ;"},{"kind":"struct","path":"evidence::EvidenceItem","signature":"pub struct EvidenceItem"},{"kind":"field","path":"evidence::EvidenceItem.body","signature":"pub body : String"},{"kind":"field","path":"evidence::EvidenceItem.media_type","signature":"pub media_type : String"},{"kind":"enum","path":"evidence::EvidenceOutcome","signature":"pub enum EvidenceOutcome { Validated , Unsupported , Invalid , ValidatorError }"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEMS","signature":"pub const MAX_EVIDENCE_ITEMS : usize = 8 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEM_BYTES","signature":"pub const MAX_EVIDENCE_ITEM_BYTES : usize = 64 * 1024 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_TOTAL_BYTES","signature":"pub const MAX_EVIDENCE_TOTAL_BYTES : usize = 256 * 1024 ;"},{"kind":"const","path":"evidence::USAGE_PROXY_JSONL","signature":"pub const USAGE_PROXY_JSONL : & str = \" application/x-napl-usage-proxy-jsonl-v1 \" ;"},{"kind":"fn","path":"evidence::check_evidence_bounds","signature":"pub fn check_evidence_bounds ( items : & [ EvidenceItem ] ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::check_reported_bounds","signature":"pub fn check_reported_bounds ( reported : & Value , limit : usize ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::evidence_report","signature":"pub fn evidence_report ( items : & [ ( EvidenceItem , EvidenceOutcome ) ] ) -> Value"},{"kind":"fn","path":"evidence::evidence_slug","signature":"pub fn evidence_slug ( outcome : EvidenceOutcome ) -> & ' static str"},{"kind":"fn","path":"evidence::parse_evidence","signature":"pub fn parse_evidence ( reported : & Value ) -> Result < Vec < EvidenceItem > >"},{"kind":"fn","path":"evidence::registered_media_types","signature":"pub fn registered_media_types ( ) -> Vec < String >"},{"kind":"fn","path":"evidence::validate_all","signature":"pub fn validate_all ( items : & [ EvidenceItem ] ) -> Vec < ( EvidenceItem , EvidenceOutcome ) >"},{"kind":"fn","path":"evidence::validate_evidence","signature":"pub fn validate_evidence ( item : & EvidenceItem ) -> EvidenceOutcome"},{"kind":"const","path":"git::LEGACY_STORE_REF","signature":"pub const LEGACY_STORE_REF : & str = \" refs/heads/napl-store \" ;"},{"kind":"const","path":"git::LEGACY_STORE_REF_COMMANDS","signature":"pub const LEGACY_STORE_REF_COMMANDS : & [ & str ] = & [ \" git archive refs/heads/napl-store | tar -x -C .napl/store/objects \" , \" git add .napl/store/objects && git commit -m 'the store comes home' \" , \" git branch -D napl-store && git push origin --delete napl-store \" ] ;"},{"kind":"const","path":"git::LEGACY_STORE_REF_RECIPE","signature":"pub const LEGACY_STORE_REF_RECIPE : & str = \" check out the store branch's tree into .napl/store/objects/, commit it on the code branch, and delete the ref locally and on every remote \" ;"},{"kind":"fn","path":"git::common_git_dir","signature":"pub fn common_git_dir ( start : & Path ) -> Option < PathBuf >"},{"kind":"fn","path":"git::enclosing_repository","signature":"pub fn enclosing_repository ( start : & Path ) -> Option < PathBuf >"},{"kind":"fn","path":"git::guard_legacy_store_ref","signature":"pub fn guard_legacy_store_ref ( workspace : & Path ) -> Result < ( ) >"},{"kind":"fn","path":"git::has_legacy_store_ref","signature":"pub fn has_legacy_store_ref ( workspace : & Path ) -> bool"},{"kind":"fn","path":"git::legacy_store_ref_refusal","signature":"pub fn legacy_store_ref_refusal ( ) -> CompilerError"},{"kind":"type","path":"graph::Graph","signature":"pub type Graph = BTreeMap < String , SourceModule > ;"},{"kind":"fn","path":"graph::build_graph","signature":"pub fn build_graph ( sources : & [ SourceModule ] , target : & TargetDef ) -> Result < Graph >"},{"kind":"fn","path":"graph::changed_frontier","signature":"pub fn changed_frontier ( graph : & Graph , committed_input_keys : & BTreeMap < String , Option < String > > , input_keys : & BTreeMap < String , String > , only_module : Option < & str > ) -> Result < Vec < String > >"},{"kind":"fn","path":"graph::dependents_of","signature":"pub fn dependents_of ( graph : & Graph , module : & str ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::index_sources","signature":"pub fn index_sources ( sources : & [ SourceModule ] ) -> Result < BTreeMap < String , SourceModule > >"},{"kind":"fn","path":"graph::reachable_from","signature":"pub fn reachable_from ( graph : & Graph , roots : & [ String ] ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::topological_levels","signature":"pub fn topological_levels ( graph : & Graph ) -> Result < BTreeMap < String , usize > >"},{"kind":"enum","path":"lint::Analysis","signature":"pub enum Analysis { Performed , Refused }"},{"kind":"enum","path":"lint::Finding","signature":"pub enum Finding { UnreachableModule { module : String , cascade : Vec < String > } , UnconsumedSurfaceEntry { module : String , path : String } , StaleModule { module : String } , UnusedDependency { module : String , dep : String } }"},{"kind":"const","path":"lint::GRAPH_SCOPE","signature":"pub const GRAPH_SCOPE : & str = \" graph \" ;"},{"kind":"struct","path":"lint::LintInput","signature":"pub struct LintInput"},{"kind":"field","path":"lint::LintInput.committed_input_keys","signature":"pub committed_input_keys : BTreeMap < String , Option < String > >"},{"kind":"field","path":"lint::LintInput.current_input_keys","signature":"pub current_input_keys : BTreeMap < String , String >"},{"kind":"field","path":"lint::LintInput.referenced_names","signature":"pub referenced_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"field","path":"lint::LintInput.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"lint::LintInput.surface_names","signature":"pub surface_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"struct","path":"lint::LintReport","signature":"pub struct LintReport"},{"kind":"field","path":"lint::LintReport.findings","signature":"pub findings : Vec < Finding >"},{"kind":"field","path":"lint::LintReport.unreachable_analysis","signature":"pub unreachable_analysis : Analysis"},{"kind":"const","path":"lint::ROOTS_DECLARATION","signature":"pub const ROOTS_DECLARATION : & str = \" roots: [, ...] in the frontmatter of a prompt in this workspace \" ;"},{"kind":"fn","path":"lint::finding_json","signature":"pub fn finding_json ( finding : & Finding ) -> Value"},{"kind":"fn","path":"lint::is_auto_fixable","signature":"pub fn is_auto_fixable ( finding : & Finding ) -> bool"},{"kind":"fn","path":"lint::lint","signature":"pub fn lint ( graph : & Graph , input : & LintInput ) -> LintReport"},{"kind":"fn","path":"lint::lint_json","signature":"pub fn lint_json ( report : & LintReport ) -> Value"},{"kind":"fn","path":"lint::refusal_json","signature":"pub fn refusal_json ( source_scan : Value ) -> Value"},{"kind":"fn","path":"lsp::serve","signature":"pub fn serve < R : BufRead , W : Write > ( reader : & mut R , writer : & mut W )"},{"kind":"struct","path":"maintenance::FileFact","signature":"pub struct FileFact"},{"kind":"field","path":"maintenance::FileFact.hash","signature":"pub hash : String"},{"kind":"field","path":"maintenance::FileFact.path","signature":"pub path : String"},{"kind":"enum","path":"maintenance::FsckFinding","signature":"pub enum FsckFinding { HashMismatch { object : String , detail : String } , MissingParent { event : String , parent : String } , MissingObject { from : String , missing : String } , PackDamaged { pack : String , detail : String } }"},{"kind":"const","path":"maintenance::LOOSE_OBJECT_REPACK_THRESHOLD","signature":"pub const LOOSE_OBJECT_REPACK_THRESHOLD : usize = 1024 ;"},{"kind":"enum","path":"maintenance::RestorePlan","signature":"pub enum RestorePlan { Apply ( Vec < RestoreStep > ) , Blocked { path : String , candidates : Vec < String > } }"},{"kind":"enum","path":"maintenance::RestoreStep","signature":"pub enum RestoreStep { Move { from : String , to : String } , Write { path : String , hash : String } , Remove { path : String } }"},{"kind":"fn","path":"maintenance::fsck","signature":"pub fn fsck ( source : & dyn ObjectSource ) -> Result < Vec < FsckFinding > >"},{"kind":"fn","path":"maintenance::fsck_json","signature":"pub fn fsck_json ( findings : & [ FsckFinding ] ) -> Value"},{"kind":"fn","path":"maintenance::gc_json","signature":"pub fn gc_json ( swept : & [ String ] , kept : usize ) -> Value"},{"kind":"fn","path":"maintenance::plan_restore","signature":"pub fn plan_restore ( expected : & [ FileFact ] , observed : & [ FileFact ] ) -> RestorePlan"},{"kind":"fn","path":"maintenance::plan_sweep","signature":"pub fn plan_sweep ( all : & BTreeSet < String > , reachable : & BTreeSet < String > ) -> Vec < String >"},{"kind":"fn","path":"maintenance::reachable_from_head","signature":"pub fn reachable_from_head ( source : & dyn ObjectSource , head : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"maintenance::repack_json","signature":"pub fn repack_json ( outcome : & napl_store :: store_io :: RepackOutcome ) -> Value"},{"kind":"fn","path":"maintenance::restore_json","signature":"pub fn restore_json ( plan : & RestorePlan ) -> Value"},{"kind":"fn","path":"mv::relative_dir","signature":"pub fn relative_dir ( from : & str , to : & str ) -> String"},{"kind":"fn","path":"mv::relocate_files","signature":"pub fn relocate_files ( files : & BTreeMap < String , Vec < u8 > > , old_root : & str , dest : & str ) -> Result < BTreeMap < String , Vec < u8 > > >"},{"kind":"fn","path":"mv::relocate_module","signature":"pub fn relocate_module ( workspace : & Path , target_name : & str , module : & str , dest : & str ) -> CommitReport"},{"kind":"struct","path":"objects::AttributionState","signature":"pub struct AttributionState"},{"kind":"field","path":"objects::AttributionState.prompt_hash","signature":"pub prompt_hash : String"},{"kind":"field","path":"objects::AttributionState.record","signature":"pub record : String"},{"kind":"field","path":"objects::AttributionState.stale","signature":"pub stale : bool"},{"kind":"const","path":"objects::DOCS_ONLY","signature":"pub const DOCS_ONLY : & str = \" docs-only \" ;"},{"kind":"struct","path":"objects::Footprint","signature":"pub struct Footprint"},{"kind":"field","path":"objects::Footprint.derived_projections","signature":"pub derived_projections : Vec < String >"},{"kind":"field","path":"objects::Footprint.install_paths","signature":"pub install_paths : Vec < String >"},{"kind":"field","path":"objects::Footprint.read_set","signature":"pub read_set : Vec < String >"},{"kind":"field","path":"objects::Footprint.write_set","signature":"pub write_set : Vec < String >"},{"kind":"struct","path":"objects::ModuleState","signature":"pub struct ModuleState"},{"kind":"field","path":"objects::ModuleState.attribution","signature":"pub attribution : Option < AttributionState >"},{"kind":"field","path":"objects::ModuleState.input_key","signature":"pub input_key : Option < String >"},{"kind":"field","path":"objects::ModuleState.module","signature":"pub module : String"},{"kind":"field","path":"objects::ModuleState.output_tree","signature":"pub output_tree : String"},{"kind":"field","path":"objects::ModuleState.prompt","signature":"pub prompt : Option < String >"},{"kind":"field","path":"objects::ModuleState.slice_hash","signature":"pub slice_hash : Option < String >"},{"kind":"field","path":"objects::ModuleState.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"objects::ModuleState.target","signature":"pub target : String"},{"kind":"struct","path":"objects::Transition","signature":"pub struct Transition"},{"kind":"field","path":"objects::Transition.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"objects::Transition.footprint","signature":"pub footprint : Footprint"},{"kind":"field","path":"objects::Transition.input_key","signature":"pub input_key : String"},{"kind":"field","path":"objects::Transition.kind","signature":"pub kind : Option < String >"},{"kind":"field","path":"objects::Transition.module","signature":"pub module : String"},{"kind":"field","path":"objects::Transition.new_state","signature":"pub new_state : String"},{"kind":"field","path":"objects::Transition.prompt","signature":"pub prompt : Option < String >"},{"kind":"field","path":"objects::Transition.receipt","signature":"pub receipt : String"},{"kind":"field","path":"objects::Transition.surface","signature":"pub surface : String"},{"kind":"field","path":"objects::Transition.target","signature":"pub target : String"},{"kind":"fn","path":"objects::empty_module_state","signature":"pub fn empty_module_state ( module : & str , target : & str , empty_tree : & str ) -> ModuleState"},{"kind":"fn","path":"objects::event_hash","signature":"pub fn event_hash ( content : & str , parents : & [ String ] ) -> String"},{"kind":"fn","path":"objects::event_json","signature":"pub fn event_json ( content : & str , parents : & [ String ] ) -> Value"},{"kind":"fn","path":"objects::event_transition","signature":"pub fn event_transition ( source : & dyn ObjectSource , event : & str ) -> Result < String >"},{"kind":"fn","path":"objects::module_state_hash","signature":"pub fn module_state_hash ( state : & ModuleState ) -> String"},{"kind":"fn","path":"objects::module_state_json","signature":"pub fn module_state_json ( state : & ModuleState ) -> Value"},{"kind":"fn","path":"objects::ownership_write_key","signature":"pub fn ownership_write_key ( root : & str ) -> String"},{"kind":"fn","path":"objects::read_module_state","signature":"pub fn read_module_state ( source : & dyn ObjectSource , hash : & str ) -> Result < ModuleState >"},{"kind":"fn","path":"objects::snapshot_event","signature":"pub fn snapshot_event ( source : & dyn ObjectSource , snapshot : & str ) -> Result < String >"},{"kind":"fn","path":"objects::snapshot_hash","signature":"pub fn snapshot_hash ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> String"},{"kind":"fn","path":"objects::snapshot_json","signature":"pub fn snapshot_json ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> Value"},{"kind":"fn","path":"objects::snapshot_members","signature":"pub fn snapshot_members ( states : & BTreeMap < ( String , String ) , String > , target : & str ) -> Vec < String >"},{"kind":"fn","path":"objects::snapshot_states","signature":"pub fn snapshot_states ( source : & dyn ObjectSource , snapshot : & str ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"objects::state_write_key","signature":"pub fn state_write_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"objects::transition_hash","signature":"pub fn transition_hash ( transition : & Transition ) -> String"},{"kind":"fn","path":"objects::transition_json","signature":"pub fn transition_json ( transition : & Transition ) -> Value"},{"kind":"fn","path":"objects::transition_write_set","signature":"pub fn transition_write_set ( source : & dyn ObjectSource , transition : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"objects::walk_events","signature":"pub fn walk_events ( source : & dyn ObjectSource , event : & str , until : Option < & str > ) -> Result < Vec < String > >"},{"kind":"const","path":"paths::MAX_PATH_LEN","signature":"pub const MAX_PATH_LEN : usize = 1024 ;"},{"kind":"const","path":"paths::MAX_SEGMENT_LEN","signature":"pub const MAX_SEGMENT_LEN : usize = 128 ;"},{"kind":"fn","path":"paths::check_canonical_path","signature":"pub fn check_canonical_path ( path : & str , code : OutcomeCode ) -> Result < ( ) >"},{"kind":"fn","path":"paths::decode_name","signature":"pub fn decode_name ( segment : & str ) -> Option < String >"},{"kind":"fn","path":"paths::encode_name","signature":"pub fn encode_name ( name : & str ) -> String"},{"kind":"fn","path":"paths::is_under","signature":"pub fn is_under ( path : & str , prefix : & str ) -> bool"},{"kind":"fn","path":"paths::join_root","signature":"pub fn join_root ( root : & str , rest : & str ) -> String"},{"kind":"fn","path":"paths::normalize_path_key","signature":"pub fn normalize_path_key ( path : & str ) -> String"},{"kind":"const","path":"porcelain::PROJECTION_OWNER","signature":"pub const PROJECTION_OWNER : & str = \" no module: this path is recomputed by commit from the crate's committed module file sets and is never user-supplied; a candidate that carries it is rejected as invalid-candidate \" ;"},{"kind":"const","path":"porcelain::PROJECTION_OWNERSHIP","signature":"pub const PROJECTION_OWNERSHIP : & str = \" tool-derived \" ;"},{"kind":"fn","path":"porcelain::add_note","signature":"pub fn add_note ( workspace : & Path , transition : & str , note : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::blame_path","signature":"pub fn blame_path ( workspace : & Path , path : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::cat_file","signature":"pub fn cat_file ( workspace : & Path , hash : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"porcelain::causal_order","signature":"pub fn causal_order ( store : & Store , head : & str ) -> Result < Vec < String > >"},{"kind":"fn","path":"porcelain::committed_attribution_yaml","signature":"pub fn committed_attribution_yaml ( store : & Store , target : & str , module : & str ) -> Result < Option < Vec < u8 > > >"},{"kind":"fn","path":"porcelain::committed_manifest","signature":"pub fn committed_manifest ( store : & Store , target : & str ) -> Result < BTreeMap < String , String > >"},{"kind":"fn","path":"porcelain::committed_state","signature":"pub fn committed_state ( store : & Store , target : & str , module : & str ) -> Result < Option < ModuleState > >"},{"kind":"fn","path":"porcelain::committed_state_at","signature":"pub fn committed_state_at ( store : & Store , target : & str , module : & str ) -> Result < Option < ( String , ModuleState ) > >"},{"kind":"fn","path":"porcelain::cost","signature":"pub fn cost ( workspace : & Path , names : & [ String ] , files : & [ String ] , sessions : & [ String ] ) -> Result < Value >"},{"kind":"fn","path":"porcelain::diff","signature":"pub fn diff ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::first_output","signature":"pub fn first_output ( target : & TargetDef , root : & str , module : & str ) -> Value"},{"kind":"fn","path":"porcelain::fsck_store","signature":"pub fn fsck_store ( workspace : & Path ) -> Result < ( bool , Value ) >"},{"kind":"fn","path":"porcelain::gc","signature":"pub fn gc ( workspace : & Path , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::has_committed_head","signature":"pub fn has_committed_head ( workspace : & Path ) -> Result < bool >"},{"kind":"fn","path":"porcelain::lint_workspace","signature":"pub fn lint_workspace ( workspace : & Path , target : & str ) -> Result < ( Graph , LintReport , Value ) >"},{"kind":"fn","path":"porcelain::log","signature":"pub fn log ( workspace : & Path , module : Option < & str > ) -> Result < Value >"},{"kind":"fn","path":"porcelain::module_plan","signature":"pub fn module_plan ( workspace : & Path , target_name : & str , module : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::projection_labels","signature":"pub fn projection_labels ( target : & TargetDef , root : & str ) -> Vec < Value >"},{"kind":"fn","path":"porcelain::repack_if_loose","signature":"pub fn repack_if_loose ( store : & Store ) -> Result < Option < Value > >"},{"kind":"fn","path":"porcelain::restore","signature":"pub fn restore ( workspace : & Path , target : & str , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::show","signature":"pub fn show ( workspace : & Path , hash : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::source_syntax_scan","signature":"pub fn source_syntax_scan ( workspace : & Path , target_name : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::status","signature":"pub fn status ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::surface_of","signature":"pub fn surface_of ( workspace : & Path , target : & str , module : & str ) -> Result < Value >"},{"kind":"const","path":"receipt::MAX_REPORTED_BYTES","signature":"pub const MAX_REPORTED_BYTES : usize = 512 * 1024 ;"},{"kind":"const","path":"receipt::RECEIPT_VERSION","signature":"pub const RECEIPT_VERSION : u32 = 1 ;"},{"kind":"struct","path":"receipt::Receipt","signature":"pub struct Receipt"},{"kind":"field","path":"receipt::Receipt.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"receipt::Receipt.base_store_snapshot","signature":"pub base_store_snapshot : Option < String >"},{"kind":"field","path":"receipt::Receipt.base_workspace_hash","signature":"pub base_workspace_hash : String"},{"kind":"field","path":"receipt::Receipt.candidate_tree_hash","signature":"pub candidate_tree_hash : String"},{"kind":"field","path":"receipt::Receipt.evidence","signature":"pub evidence : Vec < EvidenceItem >"},{"kind":"field","path":"receipt::Receipt.input_key","signature":"pub input_key : String"},{"kind":"field","path":"receipt::Receipt.module","signature":"pub module : String"},{"kind":"field","path":"receipt::Receipt.output_manifest","signature":"pub output_manifest : BTreeMap < String , String >"},{"kind":"field","path":"receipt::Receipt.receipt_version","signature":"pub receipt_version : u32"},{"kind":"field","path":"receipt::Receipt.reported","signature":"pub reported : Value"},{"kind":"field","path":"receipt::Receipt.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"receipt::Receipt.target","signature":"pub target : String"},{"kind":"const","path":"receipt::UNAVAILABLE","signature":"pub const UNAVAILABLE : & str = \" unavailable \" ;"},{"kind":"fn","path":"receipt::normalize_reported","signature":"pub fn normalize_reported ( value : & Value ) -> Value"},{"kind":"fn","path":"receipt::parse_receipt","signature":"pub fn parse_receipt ( bytes : & [ u8 ] ) -> Result < Receipt >"},{"kind":"fn","path":"receipt::receipt_hash","signature":"pub fn receipt_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_json","signature":"pub fn receipt_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::receipt_semantic_hash","signature":"pub fn receipt_semantic_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_semantic_json","signature":"pub fn receipt_semantic_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::reported_usage","signature":"pub fn reported_usage ( receipt : & Receipt ) -> Value"},{"kind":"const","path":"remove::MODULE_REMOVED","signature":"pub const MODULE_REMOVED : & str = \" module-removed \" ;"},{"kind":"fn","path":"remove::remove_module","signature":"pub fn remove_module ( workspace : & Path , target_name : & str , module : & str ) -> CommitReport"},{"kind":"struct","path":"slice::CompileSlice","signature":"pub struct CompileSlice"},{"kind":"field","path":"slice::CompileSlice.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"slice::CompileSlice.body","signature":"pub body : Value"},{"kind":"field","path":"slice::CompileSlice.input_key","signature":"pub input_key : String"},{"kind":"field","path":"slice::CompileSlice.module","signature":"pub module : String"},{"kind":"field","path":"slice::CompileSlice.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"slice::CompileSlice.target","signature":"pub target : String"},{"kind":"field","path":"slice::CompileSlice.wave","signature":"pub wave : usize"},{"kind":"struct","path":"slice::DepSurface","signature":"pub struct DepSurface"},{"kind":"field","path":"slice::DepSurface.items","signature":"pub items : Vec < SurfaceItem >"},{"kind":"field","path":"slice::DepSurface.module","signature":"pub module : String"},{"kind":"field","path":"slice::DepSurface.surface_hash","signature":"pub surface_hash : String"},{"kind":"fn","path":"slice::build_slice","signature":"pub fn build_slice ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] , base : & ModuleState , base_state_key : & str , base_files : & [ ( String , String ) ] , wave : usize ) -> CompileSlice"},{"kind":"fn","path":"slice::closure_manifest","signature":"pub fn closure_manifest ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> Value"},{"kind":"fn","path":"slice::input_key","signature":"pub fn input_key ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> String"},{"kind":"fn","path":"slice::receipt_requirements","signature":"pub fn receipt_requirements ( ) -> Value"},{"kind":"fn","path":"slice::recompute_slice_hash","signature":"pub fn recompute_slice_hash ( body : & Value ) -> String"},{"kind":"const","path":"source::MAX_SOURCE_BYTES","signature":"pub const MAX_SOURCE_BYTES : usize = 4 * 1024 * 1024 ;"},{"kind":"const","path":"source::MINIMAL_PROMPT","signature":"pub const MINIMAL_PROMPT : & str = \" --- \\n module: example \\n deps: [] \\n roots: [example] \\n targets: [rust] \\n --- \\n One sentence of specification prose. \\n \" ;"},{"kind":"const","path":"source::SOURCE_EXTENSION","signature":"pub const SOURCE_EXTENSION : & str = \" napl \" ;"},{"kind":"struct","path":"source::SourceModule","signature":"pub struct SourceModule"},{"kind":"field","path":"source::SourceModule.deps","signature":"pub deps : Vec < String >"},{"kind":"field","path":"source::SourceModule.directive_cases","signature":"pub directive_cases : Vec < crate :: directive :: DirectiveCase >"},{"kind":"field","path":"source::SourceModule.docs","signature":"pub docs : Vec < crate :: directive :: DocsFence >"},{"kind":"field","path":"source::SourceModule.frontmatter_cases","signature":"pub frontmatter_cases : Vec < serde_json :: Value >"},{"kind":"field","path":"source::SourceModule.module","signature":"pub module : String"},{"kind":"field","path":"source::SourceModule.normalized_body","signature":"pub normalized_body : String"},{"kind":"field","path":"source::SourceModule.normalized_source_hash","signature":"pub normalized_source_hash : String"},{"kind":"field","path":"source::SourceModule.path","signature":"pub path : String"},{"kind":"field","path":"source::SourceModule.physical_body","signature":"pub physical_body : String"},{"kind":"field","path":"source::SourceModule.preamble","signature":"pub preamble : Option < crate :: directive :: DocsFence >"},{"kind":"field","path":"source::SourceModule.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"source::SourceModule.targets","signature":"pub targets : Vec < String >"},{"kind":"field","path":"source::SourceModule.tests","signature":"pub tests : Vec < String >"},{"kind":"fn","path":"source::check_module_name","signature":"pub fn check_module_name ( name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"source::frontmatter_text","signature":"pub fn frontmatter_text ( path : & str , text : & str ) -> Result < String >"},{"kind":"fn","path":"source::normalize_body","signature":"pub fn normalize_body ( raw : & str ) -> String"},{"kind":"fn","path":"source::parse_source","signature":"pub fn parse_source ( path : & str , text : & str ) -> Result < SourceModule >"},{"kind":"fn","path":"source::rewrite_deps","signature":"pub fn rewrite_deps ( text : & str , deps : & [ String ] ) -> Result < String >"},{"kind":"fn","path":"source::semantic_body","signature":"pub fn semantic_body ( path : & str , text : & str ) -> Result < String >"},{"kind":"enum","path":"staging::AddSource","signature":"pub enum AddSource { CandidateDir ( PathBuf ) , WorkingTree , Overlay ( Vec < String > ) }"},{"kind":"const","path":"staging::DOCS_PROMPT_FILE","signature":"pub const DOCS_PROMPT_FILE : & str = \" prompt.napl \" ;"},{"kind":"const","path":"staging::DOCS_RECORD","signature":"pub const DOCS_RECORD : & str = \" docs \" ;"},{"kind":"enum","path":"staging::PromptDrift","signature":"pub enum PromptDrift { Unchanged , DocsOnly , NoBaseline , Frontmatter { line : u32 , committed : String , working : String } , Semantic { line : u32 , committed : String , working : String } }"},{"kind":"const","path":"staging::STAGING_DIR","signature":"pub const STAGING_DIR : & str = \" .napl/staging \" ;"},{"kind":"struct","path":"staging::StagedRecord","signature":"pub struct StagedRecord"},{"kind":"field","path":"staging::StagedRecord.module","signature":"pub module : String"},{"kind":"field","path":"staging::StagedRecord.path","signature":"pub path : PathBuf"},{"kind":"field","path":"staging::StagedRecord.relative","signature":"pub relative : String"},{"kind":"field","path":"staging::StagedRecord.target","signature":"pub target : String"},{"kind":"fn","path":"staging::add","signature":"pub fn add ( workspace : & Path , target_name : & str , module : & str , source : & AddSource , attribution_path : Option < & Path > , usage_path : Option < & Path > , no_verify : bool , check : bool ) -> Result < Value >"},{"kind":"fn","path":"staging::add_working_tree_paths","signature":"pub fn add_working_tree_paths ( workspace : & Path , cwd : & Path , target_name : & str , given : & [ String ] , explicit_module : Option < & str > , attribution_path : Option < & Path > , usage_path : Option < & Path > , no_verify : bool , check : bool ) -> Result < Value >"},{"kind":"fn","path":"staging::commit_all","signature":"pub fn commit_all ( workspace : & Path ) -> Result < ( Value , i32 ) >"},{"kind":"fn","path":"staging::commit_staged","signature":"pub fn commit_staged ( workspace : & Path , record : & Path ) -> CommitReport"},{"kind":"fn","path":"staging::committed_prompt","signature":"pub fn committed_prompt ( store : & Store , object : & str , module : & str ) -> Result < String >"},{"kind":"fn","path":"staging::docs_drift","signature":"pub fn docs_drift ( workspace : & Path ) -> Result < BTreeSet < ( String , String ) > >"},{"kind":"fn","path":"staging::judge_prompt","signature":"pub fn judge_prompt ( path : & str , committed : & str , working : & str ) -> Result < PromptDrift >"},{"kind":"fn","path":"staging::mode_slug","signature":"pub fn mode_slug ( mode : AttributionMode ) -> & ' static str"},{"kind":"fn","path":"staging::record_dir","signature":"pub fn record_dir ( workspace : & Path , module : & str , tree_hash : & str ) -> PathBuf"},{"kind":"fn","path":"staging::record_directory","signature":"pub fn record_directory ( record : & Path ) -> Result < PathBuf >"},{"kind":"fn","path":"staging::record_meta","signature":"pub fn record_meta ( record_dir : & Path ) -> Result < Value >"},{"kind":"fn","path":"staging::relative_record","signature":"pub fn relative_record ( workspace : & Path , path : & Path ) -> String"},{"kind":"fn","path":"staging::staged_payload","signature":"pub fn staged_payload ( target_name : & str , records : Vec < Value > ) -> Value"},{"kind":"fn","path":"staging::staged_records","signature":"pub fn staged_records ( workspace : & Path ) -> Result < Vec < StagedRecord > >"},{"kind":"fn","path":"staging::unclaimed_working_paths","signature":"pub fn unclaimed_working_paths ( workspace : & Path , store : & Store , target : & TargetDef , module : & str , sources : & [ SourceModule ] ) -> Result < Vec < String > >"},{"kind":"fn","path":"staging::write_record","signature":"pub fn write_record ( record_dir : & Path , module : & str , target_name : & str , tree_hash : & str , attribution_present : bool , mode : AttributionMode ) -> Result < ( ) >"},{"kind":"trait","path":"store_view::ObjectSource","signature":"pub trait ObjectSource { fn envelope ( & self , name : & str ) -> Result < Option < Vec < u8 > > > ; fn head ( & self ) -> Result < Option < String > > ; fn names ( & self ) -> Result < Vec < String > > ; }"},{"kind":"fn","path":"store_view::flatten_tree","signature":"pub fn flatten_tree ( source : & dyn ObjectSource , root : & str ) -> Result < BTreeMap < String , ( String , u32 ) > >"},{"kind":"fn","path":"store_view::get_blob","signature":"pub fn get_blob ( source : & dyn ObjectSource , name : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"store_view::get_json","signature":"pub fn get_json ( source : & dyn ObjectSource , name : & str , expected : ObjectKind ) -> Result < Value >"},{"kind":"fn","path":"store_view::get_object","signature":"pub fn get_object ( source : & dyn ObjectSource , name : & str ) -> Result < ( ObjectKind , Vec < u8 > ) >"},{"kind":"fn","path":"store_view::get_tree","signature":"pub fn get_tree ( source : & dyn ObjectSource , name : & str ) -> Result < Tree >"},{"kind":"fn","path":"store_view::has_object","signature":"pub fn has_object ( source : & dyn ObjectSource , name : & str ) -> bool"},{"kind":"fn","path":"store_view::load_tree_objects","signature":"pub fn load_tree_objects ( source : & dyn ObjectSource , root : & str , out : & mut BTreeMap < String , Tree > ) -> Result < ( ) >"},{"kind":"struct","path":"surface::ParseFailure","signature":"pub struct ParseFailure"},{"kind":"field","path":"surface::ParseFailure.column","signature":"pub column : u32"},{"kind":"field","path":"surface::ParseFailure.line","signature":"pub line : u32"},{"kind":"field","path":"surface::ParseFailure.path","signature":"pub path : String"},{"kind":"field","path":"surface::ParseFailure.region","signature":"pub region : String"},{"kind":"field","path":"surface::ParseFailure.region_end","signature":"pub region_end : u32"},{"kind":"field","path":"surface::ParseFailure.region_start","signature":"pub region_start : u32"},{"kind":"method","path":"surface::ParseFailure::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"surface::ParseFailure::detail","signature":"pub fn detail ( & self ) -> String"},{"kind":"const","path":"surface::SOURCE_SYNTAX_SCOPE","signature":"pub const SOURCE_SYNTAX_SCOPE : & str = \" source syntax \" ;"},{"kind":"struct","path":"surface::SurfaceItem","signature":"pub struct SurfaceItem"},{"kind":"field","path":"surface::SurfaceItem.kind","signature":"pub kind : String"},{"kind":"field","path":"surface::SurfaceItem.path","signature":"pub path : String"},{"kind":"field","path":"surface::SurfaceItem.signature","signature":"pub signature : String"},{"kind":"fn","path":"surface::empty_surface_hash","signature":"pub fn empty_surface_hash ( module : & str , target : & str ) -> String"},{"kind":"fn","path":"surface::extract_surface","signature":"pub fn extract_surface ( target : & TargetDef , root : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"surface::is_target_source","signature":"pub fn is_target_source ( target : & TargetDef , root : & str , path : & str ) -> bool"},{"kind":"fn","path":"surface::parse_check","signature":"pub fn parse_check ( target : & TargetDef , path : & str , bytes : & [ u8 ] ) -> Result < Option < ParseFailure > >"},{"kind":"fn","path":"surface::parse_failure_error","signature":"pub fn parse_failure_error ( code : OutcomeCode , failure : & ParseFailure ) -> crate :: error :: CompilerError"},{"kind":"fn","path":"surface::parse_gate","signature":"pub fn parse_gate ( target : & TargetDef , root : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Result < ( ) >"},{"kind":"fn","path":"surface::surface_hash","signature":"pub fn surface_hash ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> String"},{"kind":"fn","path":"surface::surface_items_from_json","signature":"pub fn surface_items_from_json ( value : & Value ) -> Vec < SurfaceItem >"},{"kind":"fn","path":"surface::surface_json","signature":"pub fn surface_json ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> Value"},{"kind":"struct","path":"target::TargetDef","signature":"pub struct TargetDef"},{"kind":"field","path":"target::TargetDef.entry_point_version","signature":"pub entry_point_version : u32"},{"kind":"field","path":"target::TargetDef.manifest_file","signature":"pub manifest_file : String"},{"kind":"field","path":"target::TargetDef.name","signature":"pub name : String"},{"kind":"field","path":"target::TargetDef.source_dir","signature":"pub source_dir : String"},{"kind":"field","path":"target::TargetDef.source_extension","signature":"pub source_extension : String"},{"kind":"fn","path":"target::builtin_target","signature":"pub fn builtin_target ( name : & str ) -> Result < TargetDef >"},{"kind":"fn","path":"target::builtin_target_names","signature":"pub fn builtin_target_names ( ) -> Vec < String >"},{"kind":"fn","path":"target::derive_lib_projection","signature":"pub fn derive_lib_projection ( target : & TargetDef , root : & str , file_paths : & [ String ] , command_providers : & [ String ] ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"target::is_manifest_path","signature":"pub fn is_manifest_path ( target : & TargetDef , path : & str ) -> bool"},{"kind":"fn","path":"target::is_owned_path","signature":"pub fn is_owned_path ( target : & TargetDef , root : & str , path : & str ) -> bool"},{"kind":"fn","path":"target::owned_globs","signature":"pub fn owned_globs ( _target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::placement_facts","signature":"pub fn placement_facts ( target : & TargetDef , root : & str ) -> Value"},{"kind":"fn","path":"target::placement_root","signature":"pub fn placement_root ( source : & SourceModule ) -> String"},{"kind":"fn","path":"target::projection_owned_paths","signature":"pub fn projection_owned_paths ( target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::surface_grammar","signature":"pub fn surface_grammar ( target : & TargetDef ) -> Value"},{"kind":"fn","path":"target::target_def_hash","signature":"pub fn target_def_hash ( target : & TargetDef ) -> String"},{"kind":"fn","path":"target::target_def_json","signature":"pub fn target_def_json ( target : & TargetDef ) -> Value"},{"kind":"enum","path":"textdiff::Edit","signature":"pub enum Edit { Keep ( usize , usize ) , Delete ( usize ) , Insert ( usize ) }"},{"kind":"fn","path":"textdiff::diff_lines","signature":"pub fn diff_lines ( old : & [ String ] , new : & [ String ] ) -> Vec < Edit >"},{"kind":"fn","path":"textdiff::lines","signature":"pub fn lines ( text : & str ) -> Vec < String >"},{"kind":"fn","path":"textdiff::render_drift","signature":"pub fn render_drift ( committed : & BTreeMap < String , String > , working : & BTreeMap < String , String > ) -> String"},{"kind":"fn","path":"textdiff::render_patch","signature":"pub fn render_patch ( path : & str , old : & str , new : & str ) -> String"},{"kind":"struct","path":"transaction::Accepted","signature":"pub struct Accepted"},{"kind":"field","path":"transaction::Accepted.event","signature":"pub event : String"},{"kind":"field","path":"transaction::Accepted.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::Accepted.projection_bytes","signature":"pub projection_bytes : Vec < u8 >"},{"kind":"field","path":"transaction::Accepted.projection_path","signature":"pub projection_path : String"},{"kind":"field","path":"transaction::Accepted.rebased","signature":"pub rebased : bool"},{"kind":"field","path":"transaction::Accepted.snapshot","signature":"pub snapshot : String"},{"kind":"field","path":"transaction::Accepted.steps","signature":"pub steps : Vec < InstallStep >"},{"kind":"struct","path":"transaction::BuiltAttribution","signature":"pub struct BuiltAttribution"},{"kind":"field","path":"transaction::BuiltAttribution.state","signature":"pub state : AttributionState"},{"kind":"field","path":"transaction::BuiltAttribution.view_path","signature":"pub view_path : String"},{"kind":"field","path":"transaction::BuiltAttribution.yaml","signature":"pub yaml : Vec < u8 >"},{"kind":"struct","path":"transaction::DerivedCommit","signature":"pub struct DerivedCommit"},{"kind":"field","path":"transaction::DerivedCommit.attribution_install","signature":"pub attribution_install : Option < ( String , String ) >"},{"kind":"field","path":"transaction::DerivedCommit.attribution_status","signature":"pub attribution_status : String"},{"kind":"field","path":"transaction::DerivedCommit.evidence","signature":"pub evidence : Value"},{"kind":"field","path":"transaction::DerivedCommit.new_state_key","signature":"pub new_state_key : String"},{"kind":"field","path":"transaction::DerivedCommit.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::DerivedCommit.receipt_object","signature":"pub receipt_object : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_items","signature":"pub surface_items : Vec < SurfaceItem >"},{"kind":"field","path":"transaction::DerivedCommit.transition","signature":"pub transition : Transition"},{"kind":"field","path":"transaction::DerivedCommit.transition_id","signature":"pub transition_id : String"},{"kind":"struct","path":"transaction::PendingObject","signature":"pub struct PendingObject"},{"kind":"field","path":"transaction::PendingObject.kind","signature":"pub kind : ObjectKind"},{"kind":"field","path":"transaction::PendingObject.name","signature":"pub name : String"},{"kind":"field","path":"transaction::PendingObject.payload","signature":"pub payload : Vec < u8 >"},{"kind":"fn","path":"transaction::accept","signature":"pub fn accept ( source : & dyn ObjectSource , target : & TargetDef , root : & str , candidate : & Candidate , derived : & DerivedCommit , head : Option < & str > , base_snapshot : Option < & str > ) -> Result < Accepted >"},{"kind":"fn","path":"transaction::check_candidate_shape","signature":"pub fn check_candidate_shape ( target : & TargetDef , root : & str , candidate : & Candidate ) -> Result < ( ) >"},{"kind":"fn","path":"transaction::derive_commit","signature":"pub fn derive_commit ( target : & TargetDef , root : & str , receipt : & Receipt , candidate : & Candidate , built : Option < & BuiltAttribution > , prompt : Option < String > ) -> Result < DerivedCommit >"},{"kind":"fn","path":"transaction::intervening_write_set","signature":"pub fn intervening_write_set ( source : & dyn ObjectSource , head : Option < & str > , base : Option < & str > ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"transaction::success_payload","signature":"pub fn success_payload ( derived : & DerivedCommit , accepted : & Accepted ) -> Value"},{"kind":"fn","path":"transaction::verify_receipt","signature":"pub fn verify_receipt ( source : & dyn ObjectSource , sources : & [ SourceModule ] , receipt : & Receipt , candidate : & Candidate ) -> Result < CompileSlice >"},{"kind":"struct","path":"transport::ClaimRefusal","signature":"pub struct ClaimRefusal"},{"kind":"field","path":"transport::ClaimRefusal.code","signature":"pub code : Option < EndpointVerdict >"},{"kind":"field","path":"transport::ClaimRefusal.file","signature":"pub file : Option < String >"},{"kind":"field","path":"transport::ClaimRefusal.index","signature":"pub index : usize"},{"kind":"field","path":"transport::ClaimRefusal.note","signature":"pub note : String"},{"kind":"field","path":"transport::ClaimRefusal.prompt","signature":"pub prompt : EndpointVerdict"},{"kind":"method","path":"transport::ClaimRefusal::affirmation_needed","signature":"pub fn affirmation_needed ( & self ) -> Vec < & ' static str >"},{"kind":"method","path":"transport::ClaimRefusal::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"transport::ClaimRefusal::prompt_lines","signature":"pub fn prompt_lines ( & self ) -> Range"},{"kind":"method","path":"transport::ClaimRefusal::side","signature":"pub fn side ( & self ) -> & ' static str"},{"kind":"struct","path":"transport::EndpointVerdict","signature":"pub struct EndpointVerdict"},{"kind":"field","path":"transport::EndpointVerdict.detail","signature":"pub detail : Option < String >"},{"kind":"field","path":"transport::EndpointVerdict.observed_line_count","signature":"pub observed_line_count : u32"},{"kind":"field","path":"transport::EndpointVerdict.observed_surviving","signature":"pub observed_surviving : Option < Range >"},{"kind":"field","path":"transport::EndpointVerdict.path","signature":"pub path : Option < String >"},{"kind":"field","path":"transport::EndpointVerdict.reason","signature":"pub reason : Option < & ' static str >"},{"kind":"field","path":"transport::EndpointVerdict.recorded","signature":"pub recorded : Option < Range >"},{"kind":"field","path":"transport::EndpointVerdict.transported","signature":"pub transported : Option < Range >"},{"kind":"field","path":"transport::EndpointVerdict.whole","signature":"pub whole : bool"},{"kind":"method","path":"transport::EndpointVerdict::refused","signature":"pub fn refused ( & self ) -> bool"},{"kind":"struct","path":"transport::LineMap","signature":"pub struct LineMap"},{"kind":"method","path":"transport::LineMap::anchor_before","signature":"pub fn anchor_before ( & self , old_line : u32 ) -> Option < u32 >"},{"kind":"method","path":"transport::LineMap::moved_line","signature":"pub fn moved_line ( & self , old_line : u32 ) -> Option < u32 >"},{"kind":"method","path":"transport::LineMap::new_line_count","signature":"pub fn new_line_count ( & self ) -> u32"},{"kind":"method","path":"transport::LineMap::old_line_count","signature":"pub fn old_line_count ( & self ) -> u32"},{"kind":"struct","path":"transport::Range","signature":"pub struct Range"},{"kind":"field","path":"transport::Range.end","signature":"pub end : u32"},{"kind":"field","path":"transport::Range.start","signature":"pub start : u32"},{"kind":"method","path":"transport::Range::new","signature":"pub fn new ( start : u32 , end : u32 ) -> Range"},{"kind":"enum","path":"transport::Refusal","signature":"pub enum Refusal { OutsideOldContent , RegionRewritten , RangeSplit }"},{"kind":"method","path":"transport::Refusal::as_str","signature":"pub fn as_str ( & self ) -> & ' static str"},{"kind":"method","path":"transport::Refusal::detail","signature":"pub fn detail ( & self ) -> & ' static str"},{"kind":"struct","path":"transport::TransportInputs","signature":"pub struct TransportInputs < ' a >"},{"kind":"field","path":"transport::TransportInputs.committed_body","signature":"pub committed_body : & ' a str"},{"kind":"field","path":"transport::TransportInputs.committed_files","signature":"pub committed_files : & ' a BTreeMap < String , String >"},{"kind":"field","path":"transport::TransportInputs.pending_body","signature":"pub pending_body : & ' a str"},{"kind":"field","path":"transport::TransportInputs.staged_files","signature":"pub staged_files : & ' a BTreeMap < String , String >"},{"kind":"struct","path":"transport::TransportOutcome","signature":"pub struct TransportOutcome"},{"kind":"field","path":"transport::TransportOutcome.record","signature":"pub record : Attribution"},{"kind":"field","path":"transport::TransportOutcome.refusals","signature":"pub refusals : Vec < ClaimRefusal >"},{"kind":"method","path":"transport::TransportOutcome::complete","signature":"pub fn complete ( & self ) -> bool"},{"kind":"enum","path":"transport::Transported","signature":"pub enum Transported { Proven ( Range ) , Unproven { refusal : Refusal , surviving : Option < Range > , anchor : Option < u32 > } }"},{"kind":"method","path":"transport::Transported::proven","signature":"pub fn proven ( & self ) -> Option < Range >"},{"kind":"fn","path":"transport::line_map","signature":"pub fn line_map ( old : & str , new : & str ) -> LineMap"},{"kind":"fn","path":"transport::refusal_detail","signature":"pub fn refusal_detail ( module : & str , refusals : & [ ClaimRefusal ] ) -> String"},{"kind":"fn","path":"transport::refusal_payload","signature":"pub fn refusal_payload ( module : & str , refusals : & [ ClaimRefusal ] ) -> Value"},{"kind":"fn","path":"transport::transport_attribution","signature":"pub fn transport_attribution ( record : & Attribution , inputs : & TransportInputs ) -> TransportOutcome"},{"kind":"fn","path":"transport::transport_range","signature":"pub fn transport_range ( map : & LineMap , range : Range ) -> Transported"},{"kind":"fn","path":"trees::empty_tree_hash","signature":"pub fn empty_tree_hash ( ) -> String"},{"kind":"fn","path":"trees::tree_of","signature":"pub fn tree_of ( manifest : & BTreeMap < String , String > ) -> Result < BuiltTree >"},{"kind":"const","path":"version::CONTRACT_VERSION","signature":"pub const CONTRACT_VERSION : u32 = 4 ;"},{"kind":"fn","path":"workspace::apply_step","signature":"pub fn apply_step ( root : & Path , step : & InstallStep , payload : & dyn Fn ( & str ) -> Result < Vec < u8 > > ) -> Result < ( ) >"},{"kind":"fn","path":"workspace::discover_sources","signature":"pub fn discover_sources ( root : & Path ) -> Result < Vec < SourceModule > >"},{"kind":"fn","path":"workspace::find_workspace_root","signature":"pub fn find_workspace_root ( start : & Path ) -> Option < PathBuf >"},{"kind":"fn","path":"workspace::read_working_text","signature":"pub fn read_working_text ( root : & Path , paths : & [ String ] ) -> BTreeMap < String , String >"},{"kind":"fn","path":"workspace::require_workspace_root","signature":"pub fn require_workspace_root ( start : & Path ) -> Result < PathBuf >"},{"kind":"fn","path":"workspace::scan_candidate","signature":"pub fn scan_candidate ( dir : & Path ) -> Result < Vec < ScannedEntry > >"},{"kind":"fn","path":"workspace::step_path","signature":"pub fn step_path ( step : & InstallStep ) -> & str"},{"kind":"fn","path":"workspace::write_file","signature":"pub fn write_file ( path : & Path , bytes : & [ u8 ] ) -> Result < ( ) >"}],"module":"napl","target":"rust"} +napl-store blob v1 18843 +//! The read commands and the maintenance commands. +//! +//! Every read resolves HEAD exactly once and then answers entirely from objects +//! reachable from that snapshot. No read creates a directory, takes a lock, or +//! writes a byte, so the whole read surface works on a read-only filesystem. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use crate::blame::{blame, blame_json, Revision}; +use crate::compile::{compile, read_surface_items, state_of}; +use crate::cost::{cost_report, line_from_reported, CostLine}; +use crate::error::{error, OutcomeCode, Result}; +use crate::graph::Graph; +use crate::lint::{lint, lint_json, LintInput, LintReport}; +use crate::maintenance::{ + fsck, fsck_json, gc_json, plan_restore, plan_sweep, reachable_from_head, restore_json, + FileFact, RestorePlan, +}; +use crate::objects::{ + event_transition, read_module_state, snapshot_event, snapshot_states, walk_events, ModuleState, +}; +use crate::receipt::normalize_reported; +use crate::store_view::{ + flatten_tree, get_blob, get_json, get_object, has_object, ObjectSource, +}; +use crate::surface::surface_json; +use crate::target::builtin_target; +use crate::textdiff::render_drift; +use napl_store::store_hash::ObjectKind; +use serde_json::{json, Value}; + +use crate::disk::{self, Store}; +use crate::workspace::{discover_sources, read_working_text}; + +/// A snapshot-consistent status report for one target: observation, zero +/// mutation. +pub fn status(workspace: &Path, target: &str) -> Result { + let store = disk::open(workspace); + let head = store.head()?; + let states = match &head { + Some(snapshot) => snapshot_states(&store, snapshot)?, + None => BTreeMap::new(), + }; + let sources = discover_sources(workspace)?; + let artifact = compile(&store, &sources, target, None)?; + let pending: Vec = artifact + .slices + .iter() + .map(|slice| slice.module.clone()) + .collect(); + + let mut modules: Vec = Vec::new(); + for ((state_target, module), state_hash) in &states { + if state_target != target { + continue; + } + let state = read_module_state(&store, state_hash)?; + modules.push(json!({ + "baseStateKey": state_hash, + "inputKey": state.input_key, + "module": module, + "outputTree": state.output_tree, + "pending": pending.contains(module), + "sliceHash": state.slice_hash, + "surfaceHash": state.surface_hash, + })); + } + Ok(json!({ + "contractVersion": crate::CONTRACT_VERSION, + "head": head, + "modules": modules, + "pending": pending, + "target": target, + })) +} + +/// The causal history reachable from HEAD, optionally narrowed to one module. +/// +/// The `ordinal` field is a presentation device computed from the causal graph +/// at read time. It is never stored and never enters an identity. +pub fn log(workspace: &Path, module: Option<&str>) -> Result { + let store = disk::open(workspace); + let Some(head) = store.head()? else { + return Ok(json!({ + "contractVersion": crate::CONTRACT_VERSION, + "entries": [], + "head": Value::Null, + })); + }; + let mut entries: Vec = Vec::new(); + for (ordinal, event) in causal_order(&store, &head)?.iter().enumerate() { + let transition_id = event_transition(&store, event)?; + let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; + if let Some(wanted) = module { + if transition["module"].as_str() != Some(wanted) { + continue; + } + } + entries.push(json!({ + "event": event, + "module": transition["module"], + "newState": transition["newState"], + "ordinal": ordinal as u64, + "target": transition["target"], + "transition": transition_id, + "writeSet": transition["writeSet"], + })); + } + Ok(json!({ + "contractVersion": crate::CONTRACT_VERSION, + "entries": entries, + "head": head, + })) +} + +/// Describe any stored object: its kind and, for JSON kinds, its payload. +pub fn show(workspace: &Path, hash: &str) -> Result { + let store = disk::open(workspace); + let (kind, payload) = get_object(&store, hash)?; + let body = if kind == ObjectKind::Blob { + json!({ "bytes": payload.len() as u64 }) + } else { + serde_json::from_slice(&payload).map_err(|e| { + error( + OutcomeCode::StoreIntegrity, + format!("object {hash} is not JSON: {e}"), + ) + })? + }; + Ok(json!({ + "body": body, + "contractVersion": crate::CONTRACT_VERSION, + "hash": hash, + "kind": kind.as_wire(), + })) +} + +/// The raw payload bytes of an object, with the envelope stripped. +pub fn cat_file(workspace: &Path, hash: &str) -> Result> { + Ok(get_object(&disk::open(workspace), hash)?.1) +} + +/// The recorded surface of a module as of HEAD. +pub fn surface_of(workspace: &Path, target: &str, module: &str) -> Result { + let store = disk::open(workspace); + let Some(state) = committed_state(&store, target, module)? else { + return Ok(surface_json(module, target, &[])); + }; + let items = read_surface_items(&store, &state.surface_hash)?; + Ok(surface_json(module, target, &items)) +} + +/// Drift between committed outputs and the working tree, as a canonical patch. +pub fn diff(workspace: &Path, target: &str) -> Result { + let store = disk::open(workspace); + let committed = committed_text(&store, target)?; + let paths: Vec = committed.keys().cloned().collect(); + let working = read_working_text(workspace, &paths); + let patch = render_drift(&committed, &working); + Ok(json!({ + "contractVersion": crate::CONTRACT_VERSION, + "patch": patch, + "target": target, + })) +} + +/// Derived line ancestry for one committed path. +pub fn blame_path(workspace: &Path, path: &str) -> Result { + let store = disk::open(workspace); + let Some(head) = store.head()? else { + return Ok(blame_json(path, &[])); + }; + let mut revisions: Vec = Vec::new(); + for event in causal_order(&store, &head)? { + let transition_id = event_transition(&store, &event)?; + let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; + let touched = transition["installPaths"] + .as_array() + .map(|paths| paths.iter().any(|p| p.as_str() == Some(path))) + .unwrap_or(false); + if !touched { + continue; + } + let Some(state_hash) = transition["newState"].as_str() else { + continue; + }; + let state = read_module_state(&store, state_hash)?; + let files = flatten_tree(&store, &state.output_tree)?; + let Some((blob, _)) = files.get(path) else { + continue; + }; + let bytes = get_blob(&store, blob)?; + revisions.push(Revision { + transition: transition_id, + content: String::from_utf8_lossy(&bytes).into_owned(), + }); + } + Ok(blame_json(path, &blame(&revisions))) +} + +/// Diagnose a workspace. +pub fn lint_workspace(workspace: &Path, target: &str) -> Result<(Graph, LintReport, Value)> { + let store = disk::open(workspace); + let sources = discover_sources(workspace)?; + let artifact = compile(&store, &sources, target, None)?; + let graph = crate::compile::graph_for(&sources, target)?; + let definition = builtin_target(target)?; + let states = match store.head()? { + Some(snapshot) => snapshot_states(&store, &snapshot)?, + None => BTreeMap::new(), + }; + + let mut committed_input_keys: BTreeMap> = BTreeMap::new(); + let mut referenced_names: BTreeMap> = BTreeMap::new(); + let mut surface_names: BTreeMap> = BTreeMap::new(); + for module in graph.keys() { + let (state, _) = state_of(&store, &states, &definition, module)?; + committed_input_keys.insert(module.clone(), state.input_key.clone()); + surface_names.insert( + module.clone(), + read_surface_items(&store, &state.surface_hash)? + .iter() + .filter_map(|item| item.path.rsplit("::").next().map(str::to_string)) + .collect(), + ); + referenced_names.insert(module.clone(), identifiers(&store, &state)?); + } + let current_input_keys: BTreeMap = artifact + .slices + .iter() + .map(|slice| (slice.module.clone(), slice.input_key.clone())) + .collect(); + + let roots = declared_roots(&sources); + let report = lint( + &graph, + &LintInput { + roots, + committed_input_keys, + current_input_keys, + referenced_names, + surface_names, + }, + ); + let value = lint_json(&report); + Ok((graph, report, value)) +} + +/// Cost over an explicit receipt set: object names in the store, file paths, or +/// both. +pub fn cost(workspace: &Path, names: &[String], files: &[String]) -> Result { + let store = disk::open(workspace); + let mut lines: Vec = Vec::new(); + for name in names { + let body = get_json(&store, name, ObjectKind::Receipt)?; + lines.push(line_from_reported( + name, + body["module"].as_str().unwrap_or_default(), + &normalize_reported(&body["reported"]), + )?); + } + for file in files { + let path = Path::new(file); + let bytes = std::fs::read(path).map_err(|e| disk::io(path, &e))?; + let body: Value = serde_json::from_slice(&bytes).map_err(|e| { + error( + OutcomeCode::InvalidReceipt, + format!("{file}: invalid JSON: {e}"), + ) + })?; + let name = crate::canon::json_hash(ObjectKind::Receipt, &body); + lines.push(line_from_reported( + &name, + body["module"].as_str().unwrap_or_default(), + &normalize_reported(&body["reported"]), + )?); + } + Ok(cost_report(&lines)) +} + +/// Sweep every object no named head reaches. +/// +/// The sweep set is computed outside the lock; only the deletions run under it, +/// and a writer publishing concurrently is never blocked because publication +/// takes no lock at all. +pub fn gc(workspace: &Path, apply: bool) -> Result { + let store = disk::init(workspace)?; + let all: BTreeSet = store.names()?.into_iter().collect(); + let reachable = match store.head()? { + Some(head) => reachable_from_head(&store, &head)?, + None => BTreeSet::new(), + }; + let swept = plan_sweep(&all, &reachable); + if apply { + for name in &swept { + disk::remove_object(&store, name)?; + } + } + Ok(gc_json(&swept, reachable.len())) +} + +/// Verify envelope hashes, kind validity, and causal parent links. +pub fn fsck_store(workspace: &Path) -> Result<(bool, Value)> { + let store = disk::open(workspace); + let findings = fsck(&store)?; + Ok((findings.is_empty(), fsck_json(&findings))) +} + +/// Derive a repair plan for a target's committed outputs, and optionally apply +/// it. +pub fn restore(workspace: &Path, target: &str, apply: bool) -> Result { + let store = disk::open(workspace); + let expected: Vec = committed_manifest(&store, target)? + .into_iter() + .map(|(path, hash)| FileFact { path, hash }) + .collect(); + let roots: BTreeSet = expected + .iter() + .filter_map(|fact| fact.path.split('/').next().map(str::to_string)) + .collect(); + let observed = observed_facts(workspace, &roots)?; + let plan = plan_restore(&expected, &observed); + if apply { + if let RestorePlan::Apply(steps) = &plan { + apply_restore(&store, workspace, steps)?; + } + } + Ok(restore_json(&plan)) +} + +/// Attach later provenance to a recorded transition without touching semantic +/// state. +pub fn add_note(workspace: &Path, transition: &str, note: &str) -> Result { + let store = disk::init(workspace)?; + get_json(&store, transition, ObjectKind::Transition)?; + let body = json!({ "note": note, "transition": transition }); + let payload = crate::canon::canonical_bytes(&body); + let name = disk::publish(&store, ObjectKind::Blob, &payload, transition)?; + + let index = store.root.join("notes").join(format!("{transition}.json")); + let mut names: BTreeSet = match std::fs::read(&index) { + Ok(bytes) => serde_json::from_slice::>(&bytes) + .unwrap_or_default() + .into_iter() + .collect(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(), + Err(e) => return Err(disk::io(&index, &e)), + }; + names.insert(name.clone()); + let listed: Vec = names.iter().map(|n| json!(n)).collect(); + crate::workspace::write_file(&index, &crate::canon::canonical_bytes(&Value::Array(listed)))?; + Ok(json!({ + "note": name, + "outcome": "success", + "semanticStatePublished": false, + "transition": transition, + })) +} + +/// The events reachable from a head, ordered oldest first by causal depth. +pub fn causal_order(store: &Store, head: &str) -> Result> { + let head_event = snapshot_event(store, head)?; + let events = walk_events(store, &head_event, None)?; + let mut depths: BTreeMap = BTreeMap::new(); + for event in &events { + depth_of(store, event, &mut depths)?; + } + let mut ordered = events; + ordered.sort_by(|a, b| (depths[a], a).cmp(&(depths[b], b))); + Ok(ordered) +} + +fn depth_of(store: &Store, event: &str, depths: &mut BTreeMap) -> Result { + if let Some(known) = depths.get(event) { + return Ok(*known); + } + let value = get_json(store, event, ObjectKind::Event)?; + let mut depth = 0usize; + for parent in value["parents"].as_array().cloned().unwrap_or_default() { + if let Some(parent) = parent.as_str() { + depth = depth.max(depth_of(store, parent, depths)? + 1); + } + } + depths.insert(event.to_string(), depth); + Ok(depth) +} + +fn committed_state(store: &Store, target: &str, module: &str) -> Result> { + let Some(head) = store.head()? else { + return Ok(None); + }; + let states = snapshot_states(store, &head)?; + match states.get(&(target.to_string(), module.to_string())) { + Some(hash) => Ok(Some(read_module_state(store, hash)?)), + None => Ok(None), + } +} + +/// The committed `path -> blob` manifest of every module under a target. +pub fn committed_manifest(store: &Store, target: &str) -> Result> { + let Some(head) = store.head()? else { + return Ok(BTreeMap::new()); + }; + let mut out = BTreeMap::new(); + for ((state_target, _), hash) in snapshot_states(store, &head)? { + if state_target != target { + continue; + } + let state = read_module_state(store, &hash)?; + for (path, (blob, _)) in flatten_tree(store, &state.output_tree)? { + out.insert(path, blob); + } + } + Ok(out) +} + +fn committed_text(store: &Store, target: &str) -> Result> { + let mut out = BTreeMap::new(); + for (path, blob) in committed_manifest(store, target)? { + let bytes = get_blob(store, &blob)?; + out.insert(path, String::from_utf8_lossy(&bytes).into_owned()); + } + Ok(out) +} + +/// Every identifier a module's committed output names. +/// +/// This is a token scan, not name resolution: lint says a surface entry looks +/// unconsumed, and says so as a proposal, precisely because it cannot prove it. +fn identifiers(store: &Store, state: &ModuleState) -> Result> { + let mut out: BTreeSet = BTreeSet::new(); + if !has_object(store, &state.output_tree) { + // A module that has never committed names nothing yet. + return Ok(out); + } + for (_, (blob, _)) in flatten_tree(store, &state.output_tree)? { + let bytes = get_blob(store, &blob)?; + let text = String::from_utf8_lossy(&bytes).into_owned(); + for token in text.split(|c: char| !(c.is_alphanumeric() || c == '_')) { + if !token.is_empty() { + out.insert(token.to_string()); + } + } + } + Ok(out) +} + +fn declared_roots(sources: &[crate::source::SourceModule]) -> Option> { + let mut declared: Option> = None; + for source in sources { + if let Some(roots) = &source.roots { + declared + .get_or_insert_with(BTreeSet::new) + .extend(roots.iter().cloned()); + } + } + declared.map(|set| set.into_iter().collect()) +} + +fn observed_facts(workspace: &Path, roots: &BTreeSet) -> Result> { + let mut out: Vec = Vec::new(); + for root in roots { + let dir = workspace.join(root); + if !dir.is_dir() { + continue; + } + for entry in crate::workspace::scan_candidate(&dir)? { + if entry.kind != crate::candidate::EntryKind::File { + continue; + } + out.push(FileFact { + path: format!("{root}/{}", entry.path), + hash: napl_store::store_hash::object_name(ObjectKind::Blob, &entry.bytes), + }); + } + } + out.sort(); + Ok(out) +} + +fn apply_restore( + store: &Store, + workspace: &Path, + steps: &[crate::maintenance::RestoreStep], +) -> Result<()> { + use crate::maintenance::RestoreStep; + for step in steps { + match step { + RestoreStep::Move { from, to } => { + let target = workspace.join(to); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|e| disk::io(parent, &e))?; + } + std::fs::rename(workspace.join(from), &target) + .map_err(|e| disk::io(&target, &e))?; + } + RestoreStep::Write { path, hash } => { + let bytes = get_blob(store, hash)?; + crate::workspace::write_file(&workspace.join(path), &bytes)?; + } + RestoreStep::Remove { path } => { + let target = workspace.join(path); + match std::fs::remove_file(&target) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(disk::io(&target, &e)), + } + } + } + } + Ok(()) +} +napl-store module-state v1 559 +{"attribution":{"promptHash":"b0a8d8b52ccb7ad3e5b791e0791df5baefde058ae25183f7350aa0f575959abf","record":"cdd487c8ec08a7bc6a1e6877fbfa49e52675df90d5c1aa50d0f332f5277bd3c9","stale":false},"contractVersion":4,"inputKey":"9206162a79eefbb9bbbad00daffa25e4dcbca3fb7db9b908b65fa2829a272f7e","module":"query","outputTree":"562e142c3a950b916ff1d8cebfdd0ab3a63fb61ebb42d766e6cd8215190b007f","sliceHash":"82f3d21f63858c133b9fae84aa3886b5558cc8534947dc100ebc0861346c688e","surfaceHash":"326826be3fd8aaa84d4869493b7e41f886a07bea8960df79b61c87f557f4cbd0","target":"rust"} +napl-store event v1 158 +{"content":"9da3bd272976984693f0eca3ccff22b31a8bc6db9e7c17f8e9eaba3fe1e3fee9","parents":["59e51f9ceeccb7464b32c50db2f23dafe489a63af8a69ccd67bf00b780b8b1a9"]} +napl-store snapshot v1 555 +{"contractVersion":3,"event":"a60e0c3e9cf0995bdba6dd0bc8308e4ff71133350cb9245cbb8849e43cae5480","states":[{"module":"commands","state":"94697ec1fb16dd5f899f438ef2b257f8b6c5cb611e93acf940a8f2fe94a3713f","target":"rust"},{"module":"napl","state":"21631447bbac552e78b823a151bc6b9b6132a82db42e636c7befdfc081f3ed2d","target":"rust"},{"module":"napl_store","state":"1506a85bc6ef2b6f1cc8426700959b114ea5532cd02cf43e57d2a82ab7b4fb8a","target":"rust"},{"module":"query","state":"cda23242a5fb8aa243b577c8d7fb4e666dd23757c9c1f8919f20af2224dadec3","target":"rust"}]} +napl-store event v1 158 +{"content":"e09c02c9e6a72aa35c4ac60ad8622970105b21646d146930ef108bb9b6f4607e","parents":["571414daa0db8ed1603a5cee9dfdf393a2b95a16315317ae52481be25bec96db"]} +napl-store module-state v1 558 +{"attribution":{"promptHash":"d0c13ac14ee9addbd79c32d232c2c2af8564fd64f8bce57f7aa1f3d506b28127","record":"cd67c700a387c4fd20907440b1d1525ad7f86e632a76469f488364d5820383b7","stale":false},"contractVersion":4,"inputKey":"421c2de594ddecf81bcbbcfaad2d0566fe99881b28c2ecccac9479da5dbfcd01","module":"napl","outputTree":"69bf9de1d96653068bee489d514b4ce0c80219a74bb74f8c0385f79d1196fe42","sliceHash":"492627424b74e8a4da6485813c1230ca006efeb65e89a9f35e2561bb62361251","surfaceHash":"e4e11f0f7cc8f5e702a90f8860a56f3220e0e7deb04a9383b462857040616a2c","target":"rust"} +napl-store blob v1 24865 +//! Recorded public surfaces, extracted syntactically with pinned tree-sitter +//! grammars. //! -//! `napl q` is shaped exactly like jq and IS jq: expressions are evaluated by an -//! embedded jq engine (the jaq crate) against the canonical document, stdin, or -//! a JSON file. Everything here is a pure read over committed records: no store -//! writes, no locks, no clock, no randomness, no network. Impure builtins are -//! refused at expression compile time so an expression is a pure function of its -//! input; a compiled-in 64 MiB output ceiling surfaces as `resource-limit`. +//! Extraction is sound by construction because the surface grammar is narrow: it +//! admits only constructs whose public API can be read off the syntax tree +//! without name resolution. Anything that would need resolution — a glob +//! re-export, a macro that emits `pub`, an inferred TypeScript type — is +//! rejected as `mapping-integrity` rather than approximated. +//! +//! Extraction is a pure function of the file bytes, so re-extracting from +//! committed output reproduces the recorded surface byte for byte. -use std::io::Read as _; -use std::io::Write as _; -use std::path::{Path, PathBuf}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; -use jaq_core::load::{Arena, File, Loader}; -use jaq_core::{data, unwrap_valr, Compiler, Ctx, Vars}; -use jaq_json::Val; +use napl_store::store_hash::ObjectKind; use serde_json::{json, Value}; +use tree_sitter::{Node, Parser}; -use crate::canon::canonical_bytes; -use crate::cli::{ - has_switch, resolve_workspace, switch, switch_short, CliResult, CommandSpec, FlagSpec, - WORKSPACE_FLAG, -}; -use crate::document::build_document_view; -use crate::error::Result; +use crate::canon::json_hash; +use crate::error::{error, OutcomeCode, Result}; +use crate::target::TargetDef; -/// The compiled-in output ceiling: exceeding it is `resource-limit`. -pub const OUTPUT_CEILING_BYTES: usize = 64 * 1024 * 1024; +/// One recorded element of a module's public API. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct SurfaceItem { + /// Sort key first: the statically visible path of the item. + pub path: String, + /// The syntactic category, e.g. `fn`, `struct`, `use`, `export-binding`. + pub kind: String, + /// The recorded declaration text, normalized to its token sequence. + pub signature: String, +} -/// Switches `napl q` accepts after the command word. They are not operands, so -/// the argv grammar keeps its exactly-four forms and one-input rule. The short -/// spelling `-r` normalizes to `--raw-output` before this body sees it, so only -/// the long names appear here. -const Q_SWITCHES: [&str; 2] = ["--raw-output", "--working-tree"]; +/// Extract the public surface of a crate rooted at `root` from its owned files. +pub fn extract_surface( + target: &TargetDef, + root: &str, + files: &BTreeMap>, +) -> Result> { + let sources = collect_sources(target, root, files)?; + let mut items = match target.name.as_str() { + "rust" => extract_rust(&sources)?, + _ => extract_typescript(&sources)?, + }; + items.sort(); + items.dedup(); + Ok(items) +} -/// Impure builtins the argv pre-scan names explicitly, so a well-known name is -/// refused with a message that names it. These mirror the prompt's enumerated -/// list; the real guarantee is the reachability filter below, which removes -/// every impure builtin from the compiled set regardless of this list. -const IMPURE_BUILTINS: [&str; 7] = [ - "env", - "now", - "input", - "inputs", - "debug", - "stderr", - "input_line_number", -]; +/// The canonical JSON body of a surface object. +pub fn surface_json(module: &str, target: &str, items: &[SurfaceItem]) -> Value { + let encoded: Vec = items + .iter() + .map(|item| { + json!({ + "kind": item.kind, + "path": item.path, + "signature": item.signature, + }) + }) + .collect(); + json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "items": encoded, + "module": module, + "target": target, + }) +} -/// Native jaq-std filters that read the clock, timezone, or environment, or that -/// perform I/O. They are removed from the compiled filter set by construction so -/// an expression is a pure function of its input (P1): a jaq-std upgrade cannot -/// silently reintroduce a leak because a removed name is simply undefined at -/// compile time. `input`/`inputs` are never chained in the first place (they live -/// in `jaq_std::input::funs`, which needs an inputs iterator this engine does not -/// provide), and `input_line_number`/`$ENV` are likewise never registered. -pub(crate) const IMPURE_FUNS: [&str; 6] = [ - "env", - "now", - "localtime", - "strflocaltime", - "debug_empty", - "stderr_empty", -]; +/// Read a recorded surface object's items back out of its JSON body. +pub fn surface_items_from_json(value: &Value) -> Vec { + let mut items: Vec = value["items"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .map(|item| SurfaceItem { + path: item["path"].as_str().unwrap_or_default().to_string(), + kind: item["kind"].as_str().unwrap_or_default().to_string(), + signature: item["signature"].as_str().unwrap_or_default().to_string(), + }) + .collect(); + items.sort(); + items +} -/// Standard-library definitions built on the impure native funs above (they do -/// stderr I/O), removed from the compiled definition set for the same reason so -/// the surviving defs never reference a fun that is no longer registered. -pub(crate) const IMPURE_DEFS: [&str; 3] = ["debug", "stderr", "halt_error"]; +/// The content identity of a recorded surface. +pub fn surface_hash(module: &str, target: &str, items: &[SurfaceItem]) -> String { + json_hash(ObjectKind::Surface, &surface_json(module, target, items)) +} -const Q_FLAGS: &[FlagSpec] = &[ - switch_short( - "--raw-output", - "-r", - "emit string results as their raw contents, exactly as jq does", - ), - switch("--working-tree", "read the working tree's prompts instead of the committed records"), - WORKSPACE_FLAG, -]; +/// The identity of the empty surface, used for modules with no committed state. +pub fn empty_surface_hash(module: &str, target: &str) -> String { + surface_hash(module, target, &[]) +} -const PROMPT_FLAGS: &[FlagSpec] = &[ - switch("--allow-stale", "materialize even where the mapping reads stale"), - switch("--bytes", "emit the selected bytes alone, without the surrounding prose"), - switch("--no-laws", "omit the module's standing laws from the materialized prompt"), - switch("--working-tree", "read the working tree's prompts instead of the committed records"), - WORKSPACE_FLAG, -]; +/// A source file paired with the module path it occupies. +struct SourceFile { + /// Workspace-relative path, used only in diagnostics. + path: String, + /// The item-path prefix this file contributes, e.g. `a::b` or `util`. + prefix: String, + text: String, +} -const CHECK_FLAGS: &[FlagSpec] = &[WORKSPACE_FLAG]; +fn collect_sources( + target: &TargetDef, + root: &str, + files: &BTreeMap>, +) -> Result> { + let src_root = format!("{}/{}/", root, target.source_dir); + let ext = format!(".{}", target.source_extension); + let mut out: Vec = Vec::new(); + for (path, bytes) in files { + let Some(rest) = path.strip_prefix(&src_root) else { + continue; + }; + let Some(stem) = rest.strip_suffix(&ext) else { + continue; + }; + if target.name == "rust" && (stem == "main" || stem.ends_with("/main")) { + continue; + } + let text = String::from_utf8(bytes.clone()).map_err(|_| { + error( + OutcomeCode::MappingIntegrity, + format!("{path}: source file is not valid UTF-8"), + ) + })?; + let prefix = if target.name == "rust" { + rust_prefix(stem) + } else { + stem.to_string() + }; + out.push(SourceFile { + path: path.clone(), + prefix, + text, + }); + } + out.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(out) +} -/// The command table the `query` module contributes to the derived registry. +fn rust_prefix(stem: &str) -> String { + let trimmed = stem.strip_suffix("/mod").unwrap_or(stem); + if trimmed == "lib" { + String::new() + } else { + trimmed.replace('/', "::") + } +} + +fn parse(language: &tree_sitter::Language, file: &SourceFile) -> Result { + let mut parser = Parser::new(); + parser.set_language(language).map_err(|e| { + error( + OutcomeCode::StoreIntegrity, + format!("pinned grammar rejected: {e}"), + ) + })?; + let tree = parser.parse(file.text.as_bytes(), None).ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("{}: parser produced no tree", file.path), + ) + })?; + if tree.root_node().has_error() { + return Err(error( + OutcomeCode::MappingIntegrity, + format!("{}: file-global parse error", file.path), + )); + } + Ok(tree) +} + +/// The declaration text of a node, normalized to its token sequence. /// -/// Each entry declares its summary, exact usage line, and closed flag table, so -/// the dispatch loop can answer help and refuse undeclared flags without the -/// query commands parsing arguments of their own. -pub fn commands() -> &'static [CommandSpec] { - &[ - CommandSpec { - name: "q", - example: "napl q '.modules[].name'", - summary: "evaluate a jq expression over the canonical document", - usage: "napl q [] [|-] [-r|--raw-output] [--working-tree]", - flags: Q_FLAGS, - passthrough: false, - run: run_q, - }, - CommandSpec { - name: "prompt", - example: "napl q '.modules[] | select(.name==\"leaf\")' | napl prompt", - summary: "materialize the specification prose and pinned cases a selection names", - usage: "napl prompt [--no-laws] [--allow-stale] [--working-tree] [--bytes] < selection", - flags: PROMPT_FLAGS, - passthrough: false, - run: crate::promptbuild::run_prompt, - }, - CommandSpec { - name: "p", - example: "napl q '.modules[] | select(.name==\"leaf\")' | napl p", - summary: "the short spelling of napl prompt", - usage: "napl p [--no-laws] [--allow-stale] [--working-tree] [--bytes] < selection", - flags: PROMPT_FLAGS, - passthrough: false, - run: crate::promptbuild::run_prompt, - }, - CommandSpec { - name: "check", - example: "napl check crates/leaf/leaf.napl", - summary: "report what would stop a prompt file from being added", - usage: "napl check ", - flags: CHECK_FLAGS, - passthrough: false, - run: crate::check::run_check, - }, - ] +/// Recording tokens rather than raw bytes makes the surface independent of +/// formatting and of comments: reformatting a signature must not move a +/// dependent's `inputKey`, because nothing a dependent can observe has changed. +fn text_of(node: Node, src: &str) -> String { + token_text(node, src, node.end_byte()) } -/// Where `napl q` reads its input. -enum Input { - Stdin, - File(PathBuf), +fn token_text(node: Node, src: &str, end: usize) -> String { + let mut tokens: Vec = Vec::new(); + collect_tokens(node, src, end, &mut tokens); + drop_trailing_separators(&mut tokens); + tokens.join(" ") } -fn run_q(cwd: &Path, args: &[String]) -> Result { - let (expr, input) = match parse_argv(args) { - Ok(parsed) => parsed, - Err(msg) => return Ok(fail(2, "usage", &msg)), +/// Drop a separator that only a formatter put there. +/// +/// A trailing comma before a closing delimiter is a line-wrapping artifact, and +/// nothing a dependent can observe changes when one appears. Leaving it in the +/// token stream would let `cargo fmt` move a dependent's `inputKey`. +fn drop_trailing_separators(tokens: &mut Vec) { + let mut index = 0; + while index + 1 < tokens.len() { + if tokens[index] == "," && matches!(tokens[index + 1].as_str(), ")" | "]" | ">" | "}") { + tokens.remove(index); + } else { + index += 1; + } + } +} + +fn collect_tokens(node: Node, src: &str, end: usize, out: &mut Vec) { + if node.start_byte() >= end { + return; + } + if node.kind().contains("comment") { + return; + } + if node.child_count() == 0 { + let text = src[node.byte_range()].trim(); + if !text.is_empty() { + out.push(text.to_string()); + } + return; + } + for index in 0..node.child_count() { + if let Some(child) = node.child(index) { + collect_tokens(child, src, end, out); + } + } +} + +fn normalize_ws(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +fn join_path(prefix: &str, name: &str) -> String { + if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}::{name}") + } +} + +// ---------------------------------------------------------------- Rust + +fn extract_rust(sources: &[SourceFile]) -> Result> { + let language: tree_sitter::Language = tree_sitter_rust::LANGUAGE.into(); + let mut trees: Vec<(usize, tree_sitter::Tree)> = Vec::new(); + for (index, file) in sources.iter().enumerate() { + trees.push((index, parse(&language, file)?)); + } + + // A file's items are public only when every `mod` on the way to it was + // declared `pub`. Reachability runs over the declared chain, never over the + // directory layout. + let mut declared: BTreeMap> = BTreeMap::new(); + for (index, tree) in &trees { + let file = &sources[*index]; + let mut names: BTreeSet = BTreeSet::new(); + let mut cursor = tree.root_node().walk(); + for child in tree.root_node().named_children(&mut cursor) { + if child.kind() == "mod_item" + && child.child_by_field_name("body").is_none() + && is_pub(child, &file.text) + { + if let Some(name) = child.child_by_field_name("name") { + names.insert(file.text[name.byte_range()].to_string()); + } + } + } + declared.insert(file.prefix.clone(), names); + } + + let mut reachable: BTreeSet = BTreeSet::new(); + let mut queue: VecDeque = VecDeque::new(); + if declared.contains_key("") { + reachable.insert(String::new()); + queue.push_back(String::new()); + } else { + // The crate root `lib.rs` is a derived projection excluded from a + // module's owned files, and it declares every top-level source file + // `pub mod`. With no crate-root file present, reachability is therefore + // rooted at each top-level module file; nested modules still flow only + // through the `pub mod` chains their parents declare. + for file in sources { + if !file.prefix.is_empty() && !file.prefix.contains("::") && reachable.insert(file.prefix.clone()) { + queue.push_back(file.prefix.clone()); + } + } + } + while let Some(prefix) = queue.pop_front() { + let Some(children) = declared.get(&prefix) else { + continue; + }; + for name in children.clone() { + let child = join_path(&prefix, &name); + if declared.contains_key(&child) && reachable.insert(child.clone()) { + queue.push_back(child); + } + } + } + + let mut items: Vec = Vec::new(); + for (index, tree) in &trees { + let file = &sources[*index]; + reject_public_macros(tree.root_node(), file)?; + if !reachable.contains(&file.prefix) { + // The grammar still binds an unreachable file; it records nothing. + continue; + } + walk_rust_items(tree.root_node(), file, &file.prefix, &mut items)?; + } + Ok(items) +} + +fn reject_public_macros(node: Node, file: &SourceFile) -> Result<()> { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "macro_definition" { + let body = &file.text[child.byte_range()]; + if body + .split(|c: char| !(c.is_alphanumeric() || c == '_')) + .any(|token| token == "pub") + { + return Err(error( + OutcomeCode::MappingIntegrity, + format!("{}: macro_rules! emits `pub` items", file.path), + )); + } + } + reject_public_macros(child, file)?; + } + Ok(()) +} + +fn walk_rust_items( + parent: Node, + file: &SourceFile, + prefix: &str, + items: &mut Vec, +) -> Result<()> { + let mut cursor = parent.walk(); + for node in parent.named_children(&mut cursor) { + match node.kind() { + "mod_item" => { + if !is_pub(node, &file.text) { + continue; + } + let name = required_name(node, file)?; + items.push(SurfaceItem { + path: join_path(prefix, &name), + kind: "mod".into(), + signature: format!("pub mod {name}"), + }); + // An inline module extends the visible path; an external + // declaration is answered by that module's own file. + if let Some(body) = node.child_by_field_name("body") { + walk_rust_items(body, file, &join_path(prefix, &name), items)?; + } + } + "use_declaration" => { + if !is_pub(node, &file.text) { + continue; + } + collect_use_bindings(node, file, prefix, items)?; + } + "function_item" => { + if !is_pub(node, &file.text) { + continue; + } + let name = required_name(node, file)?; + items.push(SurfaceItem { + path: join_path(prefix, &name), + kind: "fn".into(), + signature: signature_without_body(node, file), + }); + } + "struct_item" | "union_item" => { + if !is_pub(node, &file.text) { + continue; + } + let name = required_name(node, file)?; + let path = join_path(prefix, &name); + let kind = if node.kind() == "struct_item" { + "struct" + } else { + "union" + }; + items.push(SurfaceItem { + path: path.clone(), + kind: kind.into(), + signature: signature_without_body(node, file), + }); + collect_public_fields(node, file, &path, items); + } + "enum_item" | "trait_item" | "const_item" | "static_item" | "type_item" => { + if !is_pub(node, &file.text) { + continue; + } + let name = required_name(node, file)?; + let kind = match node.kind() { + "enum_item" => "enum", + "trait_item" => "trait", + "const_item" => "const", + "static_item" => "static", + _ => "type", + }; + items.push(SurfaceItem { + path: join_path(prefix, &name), + kind: kind.into(), + signature: text_of(node, &file.text), + }); + } + "impl_item" => { + // A trait impl adds no independently addressable public item. + if node.child_by_field_name("trait").is_some() { + continue; + } + let Some(type_node) = node.child_by_field_name("type") else { + continue; + }; + let type_path = join_path(prefix, &text_of(type_node, &file.text)); + let Some(body) = node.child_by_field_name("body") else { + continue; + }; + let mut body_cursor = body.walk(); + for member in body.named_children(&mut body_cursor) { + if member.kind() != "function_item" || !is_pub(member, &file.text) { + continue; + } + let name = required_name(member, file)?; + items.push(SurfaceItem { + path: format!("{type_path}::{name}"), + kind: "method".into(), + signature: signature_without_body(member, file), + }); + } + } + _ => {} + } + } + Ok(()) +} + +fn collect_public_fields(node: Node, file: &SourceFile, path: &str, items: &mut Vec) { + let Some(body) = node.child_by_field_name("body") else { + return; }; - if let Some(builtin) = refused_builtin(&expr) { - return Ok(fail( - 3, - "expression", - &format!("impure builtin {builtin:?} is not permitted"), + let mut cursor = body.walk(); + for field in body.named_children(&mut cursor) { + if field.kind() != "field_declaration" || !is_pub(field, &file.text) { + continue; + } + let Some(name) = field.child_by_field_name("name") else { + continue; + }; + items.push(SurfaceItem { + path: format!("{path}.{}", &file.text[name.byte_range()]), + kind: "field".into(), + signature: text_of(field, &file.text), + }); + } +} + +fn collect_use_bindings( + node: Node, + file: &SourceFile, + prefix: &str, + items: &mut Vec, +) -> Result<()> { + if contains_kind(node, "use_wildcard") { + return Err(error( + OutcomeCode::MappingIntegrity, + format!("{}: glob re-export is outside the surface grammar", file.path), )); } - let input_bytes = match &input { - None => { - let workspace = resolve_workspace(cwd, args)?; - let working_tree = has_switch(args, "--working-tree"); - canonical_bytes(&build_document_view(&workspace, working_tree)?) + let Some(argument) = node.child_by_field_name("argument") else { + return Ok(()); + }; + // A named `pub use` records the written binding, never a resolved signature: + // resolving it would need name resolution the grammar deliberately excludes. + let written = text_of(node, &file.text); + let mut names: Vec = Vec::new(); + collect_use_names(argument, &file.text, &mut names); + for name in names { + items.push(SurfaceItem { + path: join_path(prefix, &name), + kind: "use".into(), + signature: written.clone(), + }); + } + Ok(()) +} + +fn collect_use_names(node: Node, src: &str, out: &mut Vec) { + match node.kind() { + "use_as_clause" => { + if let Some(alias) = node.child_by_field_name("alias") { + out.push(src[alias.byte_range()].to_string()); + } } - Some(Input::Stdin) => { - let mut buf = Vec::new(); - if std::io::stdin().read_to_end(&mut buf).is_err() { - return Ok(fail(4, "input", "could not read stdin")); + "use_list" => { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + collect_use_names(child, src, out); + } + } + "scoped_use_list" => { + if let Some(list) = node.child_by_field_name("list") { + collect_use_names(list, src, out); + } + } + "scoped_identifier" => { + if let Some(name) = node.child_by_field_name("name") { + out.push(src[name.byte_range()].to_string()); + } + } + "identifier" | "type_identifier" => out.push(src[node.byte_range()].to_string()), + _ => {} + } +} + +fn contains_kind(node: Node, kind: &str) -> bool { + if node.kind() == kind { + return true; + } + (0..node.child_count()).any(|i| node.child(i).is_some_and(|child| contains_kind(child, kind))) +} + +fn is_pub(node: Node, src: &str) -> bool { + (0..node.child_count()).any(|i| { + node.child(i).is_some_and(|child| { + child.kind() == "visibility_modifier" && &src[child.byte_range()] == "pub" + }) + }) +} + +fn required_name(node: Node, file: &SourceFile) -> Result { + let name = node.child_by_field_name("name").ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!( + "{}: unnamed public {} is outside the surface grammar", + file.path, + node.kind() + ), + ) + })?; + Ok(file.text[name.byte_range()].to_string()) +} + +fn signature_without_body(node: Node, file: &SourceFile) -> String { + let end = node + .child_by_field_name("body") + .map(|body| body.start_byte()) + .unwrap_or_else(|| node.end_byte()); + token_text(node, &file.text, end) + .trim_end_matches([' ', ';']) + .to_string() +} + +// ---------------------------------------------------------- TypeScript + +fn extract_typescript(sources: &[SourceFile]) -> Result> { + let language: tree_sitter::Language = tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(); + let mut items: Vec = Vec::new(); + for file in sources { + let tree = parse(&language, file)?; + let root = tree.root_node(); + let mut cursor = root.walk(); + for node in root.named_children(&mut cursor) { + if node.kind() != "export_statement" { + continue; + } + extract_ts_export(node, file, &mut items)?; + } + } + Ok(items) +} + +fn extract_ts_export(node: Node, file: &SourceFile, items: &mut Vec) -> Result<()> { + let raw = &file.text[node.byte_range()]; + if contains_kind(node, "export_clause") { + // `export { a, b }` and `export { a } from "x"`: explicit named bindings. + let written = normalize_ws(raw); + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if child.kind() != "export_clause" { + continue; + } + let mut clause_cursor = child.walk(); + for spec in child.named_children(&mut clause_cursor) { + if spec.kind() != "export_specifier" { + continue; + } + let bound = spec + .child_by_field_name("alias") + .or_else(|| spec.child_by_field_name("name")); + if let Some(bound) = bound { + items.push(SurfaceItem { + path: join_path(&file.prefix, &file.text[bound.byte_range()]), + kind: "export-binding".into(), + signature: written.clone(), + }); + } + } + } + return Ok(()); + } + if node.child_by_field_name("declaration").is_none() + && node.child_by_field_name("value").is_none() + { + return Err(error( + OutcomeCode::MappingIntegrity, + format!("{}: `export *` is outside the surface grammar", file.path), + )); + } + if node.child_by_field_name("value").is_some() { + return Err(error( + OutcomeCode::MappingIntegrity, + format!("{}: default export is outside the surface grammar", file.path), + )); + } + let declaration = node + .child_by_field_name("declaration") + .expect("checked above"); + match declaration.kind() { + "function_declaration" | "generator_function_declaration" => { + if declaration.child_by_field_name("return_type").is_none() { + return Err(error( + OutcomeCode::MappingIntegrity, + format!("{}: exported function has an inferred return type", file.path), + )); + } + let name = required_name(declaration, file)?; + items.push(SurfaceItem { + path: join_path(&file.prefix, &name), + kind: "function".into(), + signature: signature_without_body(declaration, file), + }); + } + "lexical_declaration" | "variable_declaration" => { + let mut cursor = declaration.walk(); + for declarator in declaration.named_children(&mut cursor) { + if declarator.kind() != "variable_declarator" { + continue; + } + let Some(type_node) = declarator.child_by_field_name("type") else { + return Err(error( + OutcomeCode::MappingIntegrity, + format!("{}: exported binding has an inferred type", file.path), + )); + }; + let Some(name) = declarator.child_by_field_name("name") else { + continue; + }; + items.push(SurfaceItem { + path: join_path(&file.prefix, &file.text[name.byte_range()]), + kind: "const".into(), + signature: normalize_ws(&format!( + "export {}", + &file.text[name.start_byte()..type_node.end_byte()] + )), + }); } - buf } - Some(Input::File(path)) => match std::fs::read(path) { - Ok(bytes) => bytes, - Err(e) => return Ok(fail(4, "input", &format!("{}: {e}", path.display()))), - }, - }; - let raw = has_switch(args, "--raw-output"); - match evaluate(&expr, &input_bytes, raw) { - Ok(out) => Ok(CliResult { - stdout: out, - exit_code: 0, - }), - Err((code, kind, msg)) => Ok(fail(code, kind, &msg)), + "class_declaration" | "abstract_class_declaration" => { + let name = required_name(declaration, file)?; + items.push(SurfaceItem { + path: join_path(&file.prefix, &name), + kind: "class".into(), + signature: signature_without_body(declaration, file), + }); + } + "interface_declaration" | "type_alias_declaration" | "enum_declaration" => { + let name = required_name(declaration, file)?; + let kind = match declaration.kind() { + "interface_declaration" => "interface", + "type_alias_declaration" => "type", + _ => "enum", + }; + items.push(SurfaceItem { + path: join_path(&file.prefix, &name), + kind: kind.into(), + signature: text_of(declaration, &file.text), + }); + } + other => { + return Err(error( + OutcomeCode::MappingIntegrity, + format!("{}: exported {other} is outside the surface grammar", file.path), + )); + } } + Ok(()) } +napl-store tree v1 139 +{"entries":[{"hash":"ce845a9e5d14f391f955b836d94119c54041efcf8661908b6df2c356fe9ab5ab","kind":"file","mode":33188,"name":"git_store.rs"}]} +napl-store event v1 158 +{"content":"5934e49fb4e7ff5d932984b2ae35bf02405608a3343f1eccf9308e7c52ccec03","parents":["f0b8a3c47a244f6054199d9672f8fb7eb24f451e467af370d2026e72b30601d2"]} +napl-store snapshot v1 671 +{"contractVersion":3,"event":"86cf27e1f5896a81f28f1c71ec9658129a767c388c0a01f8a30bbd5196c83ad8","states":[{"module":"commands","state":"84428602c5cbe86749e6c4629d39f0b5aba863df62c3995134411ae5e016d853","target":"rust"},{"module":"napl","state":"55b8614a73ee118bd8731c37ce598fe2b9fcc799e89ead46639d0e57c4307472","target":"rust"},{"module":"napl_store","state":"1506a85bc6ef2b6f1cc8426700959b114ea5532cd02cf43e57d2a82ab7b4fb8a","target":"rust"},{"module":"query","state":"bc9f18a34748d245185298746e0db6fcac33afe57ddff8836927bb668f4172b6","target":"rust"},{"module":"usage_proxy","state":"ba1c5035ed5d02088e38bd72fb9b3545bdc4e67aaf793a9ba0690cbb18c06e9f","target":"rust"}]} +napl-store module-state v1 562 +{"attribution":{"promptHash":"0e979f14174777d495141f1638be430cd20960c15eeebc27a1889a562c771dab","record":"985ad0289abe71d78be6986f66e458dcbb06a8c9d12b2f2e7034223351b7a862","stale":false},"contractVersion":4,"inputKey":"30b2e4781c44a24c697a4a0d9f331ef76e62b87bdcb7422a4f43048d19547510","module":"commands","outputTree":"40602bb2e8e06bd384895319bc98b8ed659e32d428b4b7c9688380bc7aca41d3","sliceHash":"7c2429007fb859cf2c5b5a8745a211b4dc24690e797bc3c8e39caa3571a424c1","surfaceHash":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust"} +napl-store tree v1 132 +{"entries":[{"hash":"51817c7e1e3e6e351e85b6f8f5123fa042f2381f73e104ea2ff9a6acad29baf9","kind":"dir","mode":16384,"name":"crates"}]} +napl-store snapshot v1 895 +{"contractVersion":4,"event":"fbe075025e33525f74179f1505d87984295e5b10d3c021a45a938e2a27af97ba","states":[{"module":"agent","state":"67df6d3a1c20bac424e4a0104bf02ea3dd17d728c930e641c212ce0bfd3260d1","target":"rust"},{"module":"commands","state":"723676083558c83d83926d043c8edd16bb2d48cfe3722fde330cf0351870b1a5","target":"rust"},{"module":"git-store","state":"830001785de125922d57970caf0af5bcf679e6bdc24f008fd39f80867db57535","target":"rust"},{"module":"napl","state":"145d84afd1892b41d64f472a44ed100f908d8255144a8c7ba0335159325e56d5","target":"rust"},{"module":"napl_store","state":"8ef0d60befb8a6c444e8a5e0e22a1691167422a114f4e5bbcb088c6f59f73860","target":"rust"},{"module":"query","state":"7aa7e0b4e8cc02142a8aa14ca243a5f2058d72de2f65d7ddb7c59e3d201b692a","target":"rust"},{"module":"usage_proxy","state":"acd8f8cd1e71585d8b25804d42455806f39659af2949e7ed369deedcb63abbb2","target":"rust"}]} +napl-store snapshot v1 891 +{"contractVersion":4,"event":"3808efee99d8af834c52c0f732c9a199b90a427dd0e299c875eb9661ddff642d","states":[{"module":"agent","state":"b5cde0e371a87ce3810de7c7b7af9e48bf453094540fce927bde21dde6b576b9","target":"rust"},{"module":"commands","state":"74f67c7538797091e6d19bd6f87099d0b414743cc4cc151db667619ff4a48d03","target":"rust"},{"module":"napl","state":"76f91323327cf2f0a66946473c1176b3988c304d57f2c6c67626991dd449cdd1","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"2ceea09f82f0700bbe48909b0c56aa0e69cc477dd51287bd88cd2e87f6f7a1e1","target":"rust"},{"module":"serve","state":"8b31e47aad33789775fb32ae56868e37db7a9dd174e6df3a0262781a7ed2f6ae","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} +napl-store event v1 158 +{"content":"a3707d0c37b7be86384daa2cedd8d96389e59bb0ab5a50bf7d50d1001ad7f623","parents":["5ffc124faaf33a047ed806d69782e58ec50f5bac472139fec60eee8e76034898"]} +napl-store blob v1 14110 +//! Attribution: the mapping from prompt body line ranges to generated line +//! ranges, the one owned fact whose write path is the commit. +//! +//! An attribution update rides a commit for its module. Commit validates every +//! claim it makes against content it can recompute: the file paths against the +//! module's owned globs and the ingested candidate, the code line ranges against +//! the candidate bytes, the prompt line ranges against the current normalized +//! body. A claim that cannot be proven is rejected, never guessed past. The +//! record is content addressed and materialized as a view the language server +//! reads; freshness is a separate fact the module state carries. -/// Evaluate the expression over one JSON value or a stream of values, emitting -/// each result canonically, one per line, under the output ceiling. -fn evaluate( - expr: &str, - input_bytes: &[u8], - raw: bool, -) -> std::result::Result, (i32, &'static str, String)> { - let defs = jaq_core::defs() - .chain(jaq_std::defs().filter(|d| !IMPURE_DEFS.contains(&d.name))) - .chain(jaq_json::defs()); - let funs = jaq_core::funs() - .chain(jaq_std::funs().filter(|f| !IMPURE_FUNS.contains(&f.0))) - .chain(jaq_json::funs()); - let loader = Loader::new(defs); - let arena = Arena::default(); - let modules = loader - .load(&arena, File { code: expr, path: () }) - .map_err(|errs| (3, "expression", format!("invalid expression: {errs:?}")))?; - let filter = Compiler::default() - .with_funs(funs) - .compile(modules) - .map_err(|errs| (3, "expression", format!("invalid expression: {errs:?}")))?; +use std::collections::BTreeMap; - let mut out: Vec = Vec::new(); - for input in jaq_json::read::parse_many(input_bytes) { - let input = input.map_err(|e| (4, "input", format!("invalid input: {e:?}")))?; - let ctx = Ctx::>::new(&filter.lut, Vars::new([])); - for result in filter.id.run((ctx, input)).map(unwrap_valr) { - let value = - result.map_err(|e| (5, "runtime", format!("runtime error: {e:?}")))?; - let line = serialize(&value, raw); - if out.len() + line.len() > OUTPUT_CEILING_BYTES { - return Err((23, "resource-limit", "output exceeds the 64 MiB ceiling".into())); - } - out.extend_from_slice(&line); - } +use napl_store::store_hash::{object_name, ObjectKind}; + +use crate::error::{error, OutcomeCode, Result}; +use crate::paths::is_under; +use crate::target::{is_owned_path, TargetDef}; + +/// One attributed span: a prompt body line range, the generated file and its +/// line range, and a human note. A law entry owns no file at all: it is +/// contract-level prose that governs the module rather than any single path, so +/// its `file` is `None` and its code line range is unused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + pub prompt_start: u32, + pub prompt_end: u32, + pub file: Option, + pub line_start: u32, + pub line_end: u32, + pub note: String, +} + +impl Entry { + /// True when the entry owns no file: a law governing the whole module. + pub fn is_law(&self) -> bool { + self.file.is_none() } - Ok(out) } -/// Canonically serialize one jaq value: sorted keys, one trailing newline. jaq's -/// own writer emits valid JSON; re-parsing it and re-emitting canonically pins -/// the bytes. +/// A parsed attribution record for one module and target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Attribution { + pub module: String, + pub target: String, + /// True when the record was installed through the `--no-verify` escape hatch + /// and therefore was never proven against the content it points at. + pub stale: bool, + pub entries: Vec, +} + +/// Parse an attribution record from its YAML view. /// -/// Under `--raw-output` a string result emits its contents instead of its JSON -/// spelling, exactly as jq does, so a shell pipeline never needs a dequoting -/// round trip. Every non-string result is unaffected, because there is no raw -/// form of an object for the flag to invent. -fn serialize(value: &Val, raw: bool) -> Vec { - let mut buf: Vec = Vec::new(); - let _ = jaq_json::write::write(&mut buf, &jaq_json::write::Pp::default(), 0, value); - let parsed: Value = serde_json::from_slice(&buf).unwrap_or(Value::Null); - if raw { - if let Value::String(text) = &parsed { - let mut out = text.clone().into_bytes(); - out.push(b'\n'); - return out; +/// A record the parser cannot make sense of yields `None` rather than an error, +/// so a reader degrades to empty instead of blocking on a malformed byte. +pub fn parse(text: &str) -> Option { + let unified = text.replace("\r\n", "\n").replace('\r', "\n"); + let mut module: Option = None; + let mut target: Option = None; + let mut stale = false; + let mut entries: Vec = Vec::new(); + let mut in_entries = false; + let mut entry_indent: Option = None; + let mut current: Vec<(String, Vec)> = Vec::new(); + let mut pending_key: Option = None; + + let flush = |current: &mut Vec<(String, Vec)>, entries: &mut Vec| { + if let Some(entry) = build_entry(current) { + entries.push(entry); } - } - canonical_bytes(&parsed) -} + current.clear(); + }; -/// The first impure builtin the expression references, or `None`. A field -/// access such as `.input` is not a builtin call; a string literal never is. -fn refused_builtin(expr: &str) -> Option { - let bytes = expr.as_bytes(); - let mut i = 0; - while i < bytes.len() { - let c = bytes[i]; - if c == b'"' { - i += 1; - while i < bytes.len() && bytes[i] != b'"' { - if bytes[i] == b'\\' { - i += 1; - } - i += 1; - } - i += 1; + for raw in unified.split('\n') { + if raw.trim().is_empty() { continue; } - if c == b'$' { - let start = i + 1; - let mut j = start; - while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { - j += 1; + let indent = raw.len() - raw.trim_start().len(); + if indent == 0 { + if in_entries { + flush(&mut current, &mut entries); + in_entries = false; } - if &expr[start..j] == "ENV" { - return Some("$ENV".to_string()); + let (key, value) = raw.split_once(':')?; + match key.trim() { + "module" => module = Some(unquote(value.trim())), + "target" => target = Some(unquote(value.trim())), + "stale" => stale = value.trim() == "true", + "entries" => { + in_entries = true; + entry_indent = None; + } + _ => {} } - i = j.max(i + 1); + pending_key = None; continue; } - if c.is_ascii_alphabetic() || c == b'_' { - let prev = if i > 0 { Some(bytes[i - 1]) } else { None }; - let mut j = i; - while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { - j += 1; - } - let name = &expr[i..j]; - if prev != Some(b'.') && IMPURE_BUILTINS.contains(&name) { - return Some(name.to_string()); - } - i = j; + if !in_entries { continue; } - i += 1; - } - None -} - -/// Parse the jq-style argv: `q [expr] [--] [input]`. More than one input is a -/// usage error; `--` is accepted before a path starting with a dash; there is -/// no shorthand for identity over a file or stdin, so a missing expression means -/// identity over the canonical document. -fn parse_argv(args: &[String]) -> std::result::Result<(String, Option), String> { - let mut operands: Vec = Vec::new(); - let mut seen_command = false; - let mut past_dashdash = false; - let mut i = 0; - while i < args.len() { - let arg = &args[i]; - if !seen_command { - if arg == "--workspace" { - i += 2; - continue; - } - if arg.starts_with("--") { - i += 1; - continue; + let trimmed = raw.trim_start(); + let is_item = trimmed == "-" || trimmed.starts_with("- "); + let starts_entry = is_item && entry_indent.map(|width| indent <= width).unwrap_or(true); + if starts_entry { + flush(&mut current, &mut entries); + entry_indent = Some(indent); + pending_key = None; + let rest = trimmed.strip_prefix("- ").unwrap_or("").trim(); + if !rest.is_empty() { + append_field(&mut current, &mut pending_key, rest); } - seen_command = true; - i += 1; - continue; - } - if arg == "--" && !past_dashdash { - past_dashdash = true; - i += 1; - continue; - } - if !past_dashdash && Q_SWITCHES.contains(&arg.as_str()) { - i += 1; continue; } - // A declared value flag and its value are never operands. The dispatch - // loop hands this body a normalized vector where every flag trails the - // operands in separated form, so skipping the pair here is exact. - if !past_dashdash && arg == "--workspace" { - i += 2; + if is_item { + let value = trimmed.strip_prefix("- ").unwrap_or("").trim(); + if let Some(key) = &pending_key { + if let Some(slot) = current.iter_mut().find(|(field, _)| field == key) { + slot.1.push(unquote(value)); + } + } continue; } - operands.push(arg.clone()); - i += 1; + append_field(&mut current, &mut pending_key, trimmed); } - let expr = operands.first().cloned().unwrap_or_else(|| ".".to_string()); - let inputs = if operands.is_empty() { - &operands[..] - } else { - &operands[1..] - }; - if inputs.len() > 1 { - return Err("more than one input given".to_string()); + if in_entries { + flush(&mut current, &mut entries); } - let input = inputs.first().map(|token| { - if token == "-" { - Input::Stdin - } else { - Input::File(PathBuf::from(token)) - } - }); - Ok((expr, input)) + + Some(Attribution { + module: module?, + target: target.unwrap_or_default(), + stale, + entries, + }) } -/// Write a canonical error payload to stderr and return the pinned exit code. -fn fail(code: i32, kind: &str, detail: &str) -> CliResult { - let payload = json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "detail": detail, - "kind": kind, - "outcome": "query-error", - }); - let bytes = canonical_bytes(&payload); - let stderr = std::io::stderr(); - let mut lock = stderr.lock(); - let _ = lock.write_all(&bytes); - let _ = lock.flush(); - CliResult { - stdout: Vec::new(), - exit_code: code, +fn append_field( + current: &mut Vec<(String, Vec)>, + pending_key: &mut Option, + text: &str, +) { + let Some((key, value)) = text.split_once(':') else { + return; + }; + let key = key.trim().to_string(); + let value = value.trim(); + if value.is_empty() { + current.push((key.clone(), Vec::new())); + *pending_key = Some(key); + } else { + current.push((key, vec![unquote(value)])); + *pending_key = None; } } -#[cfg(test)] -mod tests { - use super::*; - - type D = data::JustLut; - - fn std_fun_names() -> Vec<&'static str> { - let mut names: Vec<&'static str> = jaq_std::funs::().map(|f| f.0).collect(); - names.sort_unstable(); - names +fn build_entry(fields: &[(String, Vec)]) -> Option { + let mut prompt: Option<(u32, u32)> = None; + let mut lines: Option<(u32, u32)> = None; + let mut file: Option = None; + let mut note = String::new(); + let mut law = false; + for (key, values) in fields { + match key.as_str() { + "promptLines" => prompt = pair(values), + "lines" => lines = pair(values), + "file" => file = values.first().cloned(), + "law" => law = values.first().map(|v| v == "true").unwrap_or(false), + "note" => note = values.first().cloned().unwrap_or_default(), + _ => {} + } } - - fn std_def_names() -> Vec<&'static str> { - let mut names: Vec<&'static str> = jaq_std::defs().map(|d| d.name).collect(); - names.sort_unstable(); - names + let (prompt_start, prompt_end) = prompt?; + if law { + // A law entry owns no file and carries no code line range. + return Some(Entry { + prompt_start, + prompt_end, + file: None, + line_start: 0, + line_end: 0, + note, + }); } + let (line_start, line_end) = lines?; + Some(Entry { + prompt_start, + prompt_end, + file: Some(file?), + line_start, + line_end, + note, + }) +} - #[test] - fn every_filtered_impure_fun_is_a_real_jaq_std_name() { - let names = std_fun_names(); - for impure in IMPURE_FUNS { - assert!( - names.contains(&impure), - "jaq-std no longer registers native fun {impure:?}; the reachability filter is stale and must be re-derived" - ); - } - } +fn pair(values: &[String]) -> Option<(u32, u32)> { + let first = values.first()?.parse::().ok()?; + let second = values.get(1)?.parse::().ok()?; + Some((first.min(second), first.max(second))) +} - #[test] - fn every_filtered_impure_def_is_a_real_jaq_std_name() { - let names = std_def_names(); - for impure in IMPURE_DEFS { - assert!( - names.contains(&impure), - "jaq-std no longer defines {impure:?}; the reachability filter is stale and must be re-derived" - ); +fn unquote(text: &str) -> String { + let quoted = text.len() >= 2 + && ((text.starts_with('"') && text.ends_with('"')) + || (text.starts_with('\'') && text.ends_with('\''))); + if !quoted { + return text.to_string(); + } + let inner = &text[1..text.len() - 1]; + let mut out = String::with_capacity(inner.len()); + let mut chars = inner.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(escaped) = chars.next() { + out.push(escaped); + } + continue; } + out.push(c); } + out +} - #[test] - fn the_compiled_fun_set_excludes_every_impure_builtin() { - let filtered: Vec<&'static str> = jaq_std::funs::() - .filter(|f| !IMPURE_FUNS.contains(&f.0)) - .map(|f| f.0) - .collect(); - for impure in IMPURE_FUNS { - assert!( - !filtered.contains(&impure), - "impure fun {impure:?} is still reachable in the compiled set" - ); +/// Render a scalar as a quoted, escaped YAML value. +/// +/// A module name is any non-empty single-line scalar, so every name the record +/// carries is written quoted rather than interpolated raw: a name holding YAML +/// metacharacters reparses to the identical name. `unquote` is the exact +/// inverse. +fn quoted(value: &str) -> String { + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + +/// A note reduced to the small character set the view grammar round trips: a +/// double quote or a newline in a note would break the flat YAML the parser +/// reads back, so both collapse deterministically. +fn sanitize_note(note: &str) -> String { + note.replace(['\n', '\r'], " ").replace('"', "'") +} + +/// Serialize an attribution to its canonical YAML view. +/// +/// This is a pure function of the record: the same attribution serializes to the +/// same bytes, which is what lets the view double as the content-addressed +/// record. Entries are emitted in the order they arrive, so a producer controls +/// the record's shape and the compiler only proves it. +pub fn to_yaml(attribution: &Attribution) -> Vec { + let mut out = String::new(); + out.push_str(&format!("module: {}\n", quoted(&attribution.module))); + out.push_str(&format!("target: {}\n", quoted(&attribution.target))); + out.push_str(&format!("stale: {}\n", attribution.stale)); + out.push_str("entries:\n"); + for entry in &attribution.entries { + out.push_str(" - promptLines:\n"); + out.push_str(&format!(" - {}\n", entry.prompt_start)); + out.push_str(&format!(" - {}\n", entry.prompt_end)); + match &entry.file { + Some(file) => { + out.push_str(&format!(" file: {}\n", quoted(file))); + out.push_str(" lines:\n"); + out.push_str(&format!(" - {}\n", entry.line_start)); + out.push_str(&format!(" - {}\n", entry.line_end)); + } + None => out.push_str(" law: true\n"), } + out.push_str(&format!(" note: {}\n", quoted(&sanitize_note(&entry.note)))); } + out.into_bytes() +} - #[test] - fn the_compiled_def_set_excludes_every_impure_definition() { - let filtered: Vec<&'static str> = jaq_std::defs() - .filter(|d| !IMPURE_DEFS.contains(&d.name)) - .map(|d| d.name) - .collect(); - for impure in IMPURE_DEFS { - assert!( - !filtered.contains(&impure), - "impure def {impure:?} is still reachable in the compiled set" - ); +/// The content identity of an attribution record: the blob name of its canonical +/// view. The view the language server reads is these same bytes. +pub fn record_hash(attribution: &Attribution) -> String { + object_name(ObjectKind::Blob, &to_yaml(attribution)) +} + +/// The workspace-relative path an attribution view materializes at. +/// +/// The module name is a record key here, so it goes through the reversible +/// segment encoding rather than into the path raw. A name already drawn from the +/// safe subset keeps its on-disk spelling. +pub fn view_path(module: &str) -> String { + format!(".napl/attribution/{}.yaml", crate::paths::encode_name(module)) +} + +/// Re-root every entry's file prefix from `old_root` to `dest`, leaving line +/// content untouched. This is the only recomputation a relocation performs on an +/// attribution: the mapping's lines are the same lines, at a new placement. +pub fn remap_prefix(attribution: &mut Attribution, old_root: &str, dest: &str) { + let prefix = format!("{old_root}/"); + for entry in &mut attribution.entries { + if let Some(file) = &entry.file { + if let Some(rest) = file.strip_prefix(&prefix) { + entry.file = Some(format!("{dest}/{rest}")); + } } } +} - #[test] - fn the_jaq_std_fun_roster_is_frozen_so_a_new_builtin_trips_this_test() { - let expected: Vec<&'static str> = FROZEN_STD_FUNS.to_vec(); - assert_eq!( - std_fun_names(), - expected, - "jaq-std's native filter roster changed: classify each added or renamed name (pure -> add to FROZEN_STD_FUNS, clock/timezone/env/randomness/io -> add to IMPURE_FUNS) before shipping the upgrade" - ); - } +/// The number of lines a body of text addresses. +fn line_count(text: &str) -> u32 { + text.lines().count() as u32 +} - /// The complete native-filter roster jaq-std registers, frozen at the pinned - /// version so any upgrade that adds or renames a filter fails the test above. - const FROZEN_STD_FUNS: [&str; 96] = [ - "acos", "acosh", "ascii_downcase", "ascii_upcase", "asin", "asinh", "atan", "atan2", - "atanh", "cbrt", "ceil", "copysign", "cos", "cosh", "debug_empty", "decode_base64", - "decode_uri", "encode_base64", "encode_uri", "endswith", "env", "erf", "erfc", - "escape_html", "escape_sh", "exp", "exp10", "exp2", "explode", "expm1", "fabs", "fdim", - "floor", "fma", "fmax", "fmin", "fmod", "frexp", "fromdateiso8601", "gmtime", "group_by", - "halt", "hypot", "ilogb", "implode", "j0", "j1", "jn", "ldexp", "lgamma", "localtime", - "log", "log10", "log1p", "log2", "ltrim", "ltrimstr", "matches", "max_by_or_empty", - "min_by_or_empty", "mktime", "modf", "nearbyint", "nextafter", "now", "pow", "remainder", - "reverse", "rint", "round", "rtrim", "rtrimstr", "scalbln", "sin", "sinh", "sort", - "sort_by", "split_", "split_matches", "sqrt", "startswith", "stderr_empty", - "strflocaltime", "strftime", "strptime", "tan", "tanh", "tgamma", "todateiso8601", "trim", - "trunc", "unescape_html", "utf8bytelength", "y0", "y1", "yn", - ]; +/// Validate every claim an attribution update makes, before anything installs. +/// +/// A claim the compiler cannot prove from content is `invalid-candidate`, named +/// exactly: a file outside the module's owned outputs or absent from the +/// candidate, a code line range past the end of its file, a prompt line range +/// past the end of the normalized body. An update that maps nothing is rejected +/// too, so an empty update can never stand in for a real one. +pub fn validate( + target: &TargetDef, + root: &str, + module: &str, + candidate_files: &BTreeMap>, + prompt_body_lines: u32, + attribution: &Attribution, +) -> Result<()> { + let bad = |why: String| error(OutcomeCode::InvalidCandidate, why); + if attribution.module != module { + return Err(bad(format!( + "attribution names module {:?} but the commit is for {module:?}", + attribution.module + ))); + } + if attribution.target != target.name { + return Err(bad(format!( + "attribution names target {:?} but the commit is for {:?}", + attribution.target, target.name + ))); + } + if attribution.entries.is_empty() { + return Err(bad(format!( + "attribution update for module {module:?} maps nothing" + ))); + } + for entry in &attribution.entries { + // Every entry, law or not, pins a prompt body range that must exist. + if entry.prompt_start == 0 || entry.prompt_start > entry.prompt_end { + return Err(bad(format!( + "attribution entry for {:?} has an empty prompt range {}-{}", + entry.file, entry.prompt_start, entry.prompt_end + ))); + } + if entry.prompt_end > prompt_body_lines { + return Err(bad(format!( + "attribution entry for {:?} names prompt lines {}-{} but the body has {} lines", + entry.file, entry.prompt_start, entry.prompt_end, prompt_body_lines + ))); + } + // A law entry owns no file: its prompt range is the whole claim. + let Some(file) = &entry.file else { + continue; + }; + if entry.line_start == 0 || entry.line_start > entry.line_end { + return Err(bad(format!( + "attribution entry for {file:?} has an empty code range {}-{}", + entry.line_start, entry.line_end + ))); + } + if !is_under(file, root) || !is_owned_path(target, root, file) { + return Err(bad(format!( + "attribution entry names {file:?}, which is not an owned output of the crate {root:?}" + ))); + } + let Some(bytes) = candidate_files.get(file) else { + return Err(bad(format!( + "attribution entry names {file:?}, which the candidate does not contain" + ))); + }; + let lines = line_count(&String::from_utf8_lossy(bytes)); + if entry.line_end > lines { + return Err(bad(format!( + "attribution entry names {file:?} lines {}-{} but the file has {} lines", + entry.line_start, entry.line_end, lines + ))); + } + } + Ok(()) } -napl-store tree v1 132 -{"entries":[{"hash":"657ac3e3f3363a76e675e5847f2cedab5b5415cad46388fc8e3508173ac2351e","kind":"dir","mode":16384,"name":"crates"}]} -napl-store blob v1 82805 +napl-store receipt v1 4613 +{"baseStateKey":"21631447bbac552e78b823a151bc6b9b6132a82db42e636c7befdfc081f3ed2d","baseStoreSnapshot":"9a0caff48b5cf4bc11b1b489b1e9ee8b2c1e98bb3ee5a3bc45fb240d9bf6d7f4","baseWorkspaceHash":"0193dd82442305411885e027f4e227b6accf0abd4afa4ca1c0922aa9c1a0b818","candidateTreeHash":"5f7088714d13f49b2b5e1fa022fd18a6456eb6dcadf4b6cb9151230f8255bbba","contractVersion":3,"inputKey":"15cca5b55715b06f2dce1abb9e5aed739865b58dadbe321d4b68837f253d0b97","module":"napl","outputManifest":[{"hash":"5521a1f18a7e90cd2b61be3ae7557245fd71cd03251ffb1f2dd105f79bb3128e","path":"crates/napl/src/attribution.rs"},{"hash":"da2f011cafa328905636f1c49fe397e38ac42269963da6915171fde6742bbf28","path":"crates/napl/src/authoring.rs"},{"hash":"bcb41a480457775ca3f239c29b7f6ec05cb48e121a6b04172b3b2fe63afaca68","path":"crates/napl/src/blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","path":"crates/napl/src/candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","path":"crates/napl/src/canon.rs"},{"hash":"a258b748a1c40209cc4b9d748a9257c8b314224856c232ed8982aa62002e9c31","path":"crates/napl/src/cli.rs"},{"hash":"4df745d53cdf794e90eb98b84242a5575da64075ca21716afe7efca51caa0a30","path":"crates/napl/src/commandset.rs"},{"hash":"ec558800ae0e5d2cb0cc76d27acf7a03b5e98f69b8ad7b2076a0c7d19a29538a","path":"crates/napl/src/commit.rs"},{"hash":"51a5efd09235e6a66de7381faaa217a51136ca9c7d2b10285f99af8ebe4ffc6f","path":"crates/napl/src/compile.rs"},{"hash":"14768a9b96b8dd25e1efa36ddad54dd48c5feab01b57a4be8883f7ce8b524eb8","path":"crates/napl/src/cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","path":"crates/napl/src/decision.rs"},{"hash":"04a648a127be983c89a99181ee26e28401ee00f84ea0b10b6c050ecd263a1b2c","path":"crates/napl/src/directive.rs"},{"hash":"28059a89256aad1de78ecd31d7b77cae21772bbc6e96fe8ba1cdb9044048f410","path":"crates/napl/src/disk.rs"},{"hash":"0b71431883adea0e4f3dc3a77b968b69ac4f18348387362cd3c48fbc023d8bb4","path":"crates/napl/src/error.rs"},{"hash":"5b7c0553530cac82c017fc61327a6c120e284d0a6d0966e24f0d63fe07d134f5","path":"crates/napl/src/evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","path":"crates/napl/src/graph.rs"},{"hash":"f0641fc8a646a08a653d7a2ba1b8fe68da672f027919a1fccb53254e7bcd456a","path":"crates/napl/src/lint.rs"},{"hash":"226d2a965911245d7b23dc6e878c6c444e53ba52e5b84ec4a69efb81bcd1c181","path":"crates/napl/src/lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","path":"crates/napl/src/main.rs"},{"hash":"86fabd45da78a46bbfa7e92465788159360c0abaa4402f10ca433779b419ee0d","path":"crates/napl/src/maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","path":"crates/napl/src/mv.rs"},{"hash":"3ae089b923327032845c386f2332ff653a08f25ccec28e0786094fa05fd77a00","path":"crates/napl/src/objects.rs"},{"hash":"3c512923df554d1876eed8803ad3d4f0f34029f937b19529b6245a687d9b6a59","path":"crates/napl/src/paths.rs"},{"hash":"5247e34ebd33f612010e0c38d0bf1b4f2b001de64888ab0443c9d38810546cec","path":"crates/napl/src/porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","path":"crates/napl/src/receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","path":"crates/napl/src/slice.rs"},{"hash":"593720d310b290ed164410f0dc33277e10006a41f90e438d46fae8579387f73c","path":"crates/napl/src/source.rs"},{"hash":"c1d23f637ba514ce61a14500549419fdb6798ec084a8434001b665f7232dc8a4","path":"crates/napl/src/staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","path":"crates/napl/src/store_view.rs"},{"hash":"9ae420e34160e7c2a6c2ba092aaa9c9ff1df622ece595849d95bf5c5eb7cbe81","path":"crates/napl/src/surface.rs"},{"hash":"2aeba094700b60cf2ab6e0c88dd79228028a72f6b9b0e2da489dce572f2d2641","path":"crates/napl/src/target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","path":"crates/napl/src/textdiff.rs"},{"hash":"c2690840af51faafd65929bdca676747ab87806e1e05ec50f2d3f676156373e5","path":"crates/napl/src/transaction.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","path":"crates/napl/src/trees.rs"},{"hash":"b3abc9f9c902897188124ed990c6f51a18932f26a69c3ac3884da4d43444c4e9","path":"crates/napl/src/version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","path":"crates/napl/src/workspace.rs"}],"receiptVersion":1,"sliceHash":"3f199669f8e6cacafca611b3fa287d5af760bde4a8e152e56ed8f190ecf2b761","target":"rust"} +napl-store tree v1 130 +{"entries":[{"hash":"1de719dc1404ec3b290ad06d820a1617ed3f78aae2c1baa54d1a2ca1c22858ad","kind":"dir","mode":16384,"name":"napl"}]} +napl-store event v1 158 +{"content":"e88c3509dbd1a91038d5daf1fcc667c45517350ed43900a7bc0727c62246f878","parents":["6102c0100b6cf296ea7bc233d05e4ebf9efdc88d1f5d126a7f38675739d6aadc"]} +napl-store blob v1 975 +module: "commands" +target: "rust" +stale: false +entries: + - promptLines: + - 1 + - 8 + file: "crates/napl/src/init.rs" + lines: whole + note: "two porcelain additions each a thin composition over the shared derivations, the command table contributing init and watch" + - promptLines: + - 9 + - 56 + file: "crates/napl/src/init.rs" + lines: whole + note: "napl init bootstraps the store skeleton or reports already_initialized, names the directory it ran in as the workspace root, declares no flags beyond the shared help flag, and teaches the first mile through nextSteps: one complete minimal prompt that itself passes check, the .napl extension discovery finds prompts by, and the exact sequence from writing that prompt through add and commit" + - promptLines: + - 57 + - 78 + file: "crates/napl/src/watch.rs" + lines: + - 1 + - 127 + note: "napl watch an io shell re-running compile on content change publishing nothing" +napl-store event v1 158 +{"content":"ba257e4e665b2cf38b618422e44e185b97222ea869c70e9f556669bce0205965","parents":["9d62cdfe887811181783f1c4a2b2876691ab6a3564c5d06fb7f2bfc0d25751f9"]} +napl-store blob v1 52936 # NAPL: the deterministic compiler This prompt describes the entire NAPL toolchain, rebuilt as one small Cargo @@ -238539,18 +245245,13 @@ may adjust if the design genuinely wants another boundary: formatting. The crates depend on `napl-store` by path, `serde_json`, `sha2`, -`tree-sitter`, `tree-sitter-rust`, `tree-sitter-typescript`, `clap` -(builder API only, driving the command line contract below), and nothing +`tree-sitter`, `tree-sitter-rust`, `tree-sitter-typescript`, and nothing that talks to a network, spawns a process, or reads clocks; those declarations live in the user-owned manifests, not in any module's output. Ensure `cargo test` passes for both crates and `napl-store` from their workspace before finishing. Generated code is authored against its final installed location, never a staging tree: acceptance runs at the -final location, so green certifies the bytes where they live. The -working-tree add forms are the operational spelling of this law: author -and verify in place, then stage from where the bytes live; assembling a -separate candidate directory is the exception, for staging bytes that -are not the working tree, never the routine. +final location, so green certifies the bytes where they live. When several modules share one crate, the crate's shared entry files are derived projections owned by no module: the crate root `lib.rs` module @@ -238594,15 +245295,7 @@ dispatch loop owns two behaviors ahead of every command, so no command can get them wrong. First, help: `napl help` lists every registered command with its summary, `napl help ` and `--help` or `-h` anywhere in a command's arguments print that command's usage line, flag -table, and summary, exit 0, and never invoke the command. Help speaks -human by default and machine on request: when stdout is a terminal or -`--human` is passed, help renders readable text from the registry, -usage line, summary, flag table with meanings, and one worked example -per command; when stdout is piped or `--json` is passed, help emits -the canonical JSON registry exactly as today, byte-stable. Help is -the only command with a human rendering; every other command speaks -canonical JSON everywhere, and the two help renderings derive from -the one registry so they can never disagree. Help is a pure +table, and summary, exit 0, and never invoke the command. Help is a pure read: no store access, no filesystem write, no state change of any kind, and this holds for every command including `init` and `watch`. Second, strict parsing: an argument beginning with `--` that the command's flag @@ -238615,68 +245308,6 @@ flag, missing required flag or positional, malformed flag value. A usage error always restates the command's exact usage line, so the error itself teaches the grammar. -The parser behind this contract is clap's builder API, constructed at -dispatch from the CommandSpec registry, never derive macros and never -per-command clap declarations: the registry stays the single source of -grammar and clap is its execution engine. clap's own renderings are -never emitted; every parse failure is caught and served as the -canonical JSON usage error above, byte-shaped by this contract alone. -`--flag value` and `--flag=value` parse identically; no command -declares short flags today, so clustering is outside the contract -until one does. The registry pre-pass, not clap, is what refuses an -undeclared flag, because the canonical JSON payload with its -nearest-name suggestion is pinned by this contract; clap normalizes -spellings and values behind it. `napl completions ` emits a completion script for -`bash`, `zsh`, or `fish`, generated from the same registry, a pure -read on stdout; an unknown shell name is a usage error. - -The commands whose meaning requires a committed head are exactly -`diff`, `blame`, and `notes add`; in a workspace that has none they -answer with a prerequisite error, never an empty success shape: the -payload names what is missing and the exact sequence that creates it, -from `napl init` through writing a prompt beside `.napl`, `add`, and -`commit`. `log` and `show` keep their committed contracts and answer -empty honestly. `napl blame` on a path no committed module owns, and -`napl diff` for a target under which no committed module owns output, -say so: the payload carries `tracked: false` and a reason naming the -absent baseline, never an empty patch or empty ancestry that reads as -success. Diff's operand is a target name and its usage line spells it -``; an operand that is not one (a module name, a file -path) is a usage error naming the target names that exist, so the -argument teaches its own vocabulary. The `napl notes add` refusal for a working path -that is not an object name states how an object name is obtained, -naming `napl log` and `napl show` with a one-line example. - -::: test -name: flag equals value and separated value parse identically -given: { workspace: fixture_two_modules, invocations: ["status --module leaf", "status --module=leaf"] } -expect: { outputs_byte_identical: true } -::: - -::: test -name: completions emits a script for a known shell and refuses an unknown one -given: { workspace: fixture_two_modules, invocations: ["completions zsh", "completions tcsh"] } -expect: { zsh_exit_code: 0, zsh_output_nonempty: true, tcsh_exit_code: 2, store_bytes_unchanged: true } -::: - -::: test -name: blame on an unowned path and diff on an outputless target say untracked -given: { workspace: fixture_two_modules, blame_path: file_no_module_owns, diff_target: target_without_outputs } -expect: { tracked: false, reason_names_baseline: true, exit_code: 0 } -::: - -::: test -name: status reports code-side drift and stale references beside prompt freshness -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } -expect: { module_drift: true, stale_reference_count: 1, prompt_side_fresh: true } -::: - -::: test -name: a headless workspace answers baseline commands with the prerequisite sequence -given: { workspace: fresh_init_no_commits, invocation: "blame some/file.rs" } -expect: { error_names_missing_head: true, sequence_named: [init, prompt, add, commit] } -::: - ::: test name: init with a help flag prints usage and mutates nothing given: { directory: empty, invocation: "init --help" } @@ -238695,12 +245326,6 @@ given: { workspace: fixture_two_modules, invocations: ["help", "help add"] } expect: { all_commands_listed: true, add_usage_line_present: true, exit_code: 0, store_bytes_unchanged: true } ::: -::: test -name: help renders human text under the human flag and identical json when piped -given: { workspace: fixture_two_modules, invocations: ["help --human", "help --json"] } -expect: { human_not_json: true, human_carries_example: true, json_byte_stable: true } -::: - Manifests are user-owned glue seams, not records: `Cargo.toml` at the workspace root and in every crate belongs to the user, edited by hand like any Rust project, tracked by git, consumed by cargo, and NEVER @@ -238710,9 +245335,7 @@ owned output includes one; nothing in the store records manifest bytes. The toolchain's only relationship to manifests is diagnosis: `status` reports, as observations never as writes, a module placed in a crate the workspace manifest does not list, or a crate directory missing a -manifest. Every manifest diagnostic names the exact workspace-relative -manifest path it means and says the file is the user's to edit, so the -manifest is never a thing status alludes to without locating. A dependency needed by generated code is added by editing the +manifest. A dependency needed by generated code is added by editing the manifest, and the build battery is what proves it correct. ::: test @@ -238883,18 +245506,6 @@ given: { workspace: fixture_two_modules, flag: module_leaf } expect: { plan_fields: [owned_globs, placement_root, receipt_requirements, wave_position], candidate_dependent_fields: absent } ::: -::: test -name: status module answers live state and the derived projection beside the plan -given: { workspace: fixture_two_modules, flag: module_leaf, working_tree_edit: leaf_output_touched } -expect: { state_fields: [attribution, drift, pending, stale_references, tracked], drift: true, projection_path_present: true, projection_content_present: true } -::: - -::: test -name: show lists attached notes inline and log counts them -given: { workspace: fixture_two_modules, history: [commit, note_attached] } -expect: { show_notes_listed: 1, show_note_text_present: true, log_note_count: 1 } -::: - ## Body directives The prompt body grammar reserves directive blocks, `directiveVersion 1`, @@ -238964,266 +245575,47 @@ declarations at statically visible paths, no glob re-exports, no macro-generated `pub` items; a named `pub use` is allowed and is recorded as the unresolved written binding, never a resolved signature. For TypeScript: explicit named exports only, no `export *`. Extraction is -syntactic, deterministic, and loud on malformed input. Loud means a -payload, never silence: extraction over bytes that do not parse -answers a parse failure naming the file and the first error's line, -distinct from a genuinely empty surface, in every seat that extracts, -the commit recorder, the `surface` read, and the query layer, so an -empty item list always means parsed and nothing public. Re-extraction from +syntactic, deterministic, and loud on malformed input. Re-extraction from committed output must reproduce the recorded surface byte-identically. -The `surface` read is honest about absence the same way: a module with -no committed state under the target answers `tracked: false` naming -the absent baseline, never an empty item list that reads as a real -surface with nothing public. - -::: test -name: extraction over unparseable bytes answers parse-failed never an empty surface -given: { source_bytes: syntactically_invalid_rust, extraction: rust_surface } -expect: { outcome: parse_failed, error_names_line: true, served_as_empty_surface: false } -::: - -::: test -name: surface for an uncommitted module says untracked never an empty list -given: { workspace: fixture_two_modules, module: pending_never_committed, read: surface_for_target } -expect: { tracked: false, reason_names_baseline: true, empty_item_list_served: false } -::: - ::: test name: a candidate with a glob re-export is rejected as mapping-integrity -given: { workspace: fixture_two_modules, candidate: contains_glob_reexport } -expect: { outcome: mapping_integrity, published_semantic_state: false } -::: - -::: test -name: a named pub use is recorded in the surface as the written binding -given: { workspace: fixture_two_modules, candidate: valid_with_named_reexport } -expect: { surface_contains_written_binding: true, surface_contains_resolved_signature: false } -::: - -::: test -name: recorded surface re-extraction from committed output is byte-identical -given: { workspace: fixture_two_modules, candidate: valid_for_consumer } -expect: { reextraction_equal: true } -::: - -## add, then commit - -Git parity is the mental model: `add` stages, `commit` installs. The -receipt is an internal record between them, never a user-facing noun. -`napl commit` with no arguments installs every staged record in -dependency-wave order, rebasing where the decision table allows, one -outcome reported per record; `--staged ` installs one. A -multi-module add followed by a bare commit is therefore one command -each, with the sequencing and retry the table already defines owned by -the tool, never hand-looped; and when an earlier record in the same -invocation moves the head so that a later one returns write-conflict -or stale-input against it, bare commit re-derives that record against -the new head, exactly what re-running add would produce, and retries -it once, so modules sharing one crate land in one invocation instead -of hand-ordered pairs. A retry that fails again returns its outcome -honestly. A bare commit that finds nothing staged says so instead of -dressing emptiness as success: the payload names that zero records -were staged, and when any module carries working-tree drift it names -each drifted module beside the exact `napl add` invocation that -would stage it, so the empty commit is the teacher, never the trap. -Commit sweeps the staged record it installed, so a later -bare commit never re-attempts history; `napl gc` reports staged -records among the unreachable, and `napl gc --apply` sweeps them: -they are disposable litter by the git-state law, re-creatable by -re-running add, and no one should ever hand-edit `.napl` to be rid of -them. - -`napl add` stages a module state from any of three sources, and the -working tree is the default one, exactly as in git: - -- `napl add [...]` names working-tree files. Each path - resolves to its owning module through the owned globs (unique by - commit invariant, so no `--module` is needed), and the staged state - is that module's committed output with exactly the named files - overlaid from the working tree. Paths owned by different modules - stage one record per module in one invocation. A path no module owns - is a usage error naming the owned globs that do exist. -- `napl add --module ` stages the module's whole owned tree as - it sits in the working tree. -- `napl add --module ` stages explicit candidate - bytes, for the rare case where the bytes to stage are not the - working tree. - -All three forms feed one pipeline with `[--attribution ] -[--usage ]`: ingest the staged bytes into immutable content -addressed blobs (reject links, devices, duplicate normalized paths, -absolute or parent-escaping paths, oversized entries or totals; all -later steps read ingested blobs, never the source files); parse every -staged file the target claims as source with the target's pinned -tree-sitter grammar, and refuse as `invalid-candidate` when any parse -tree carries an error node, naming the file, the first error's line, -and the malformed region, because a surface extracted from a broken -tree is not a fact worth recording; the gate is syntax only, judged by -the same grammar the surface extractor uses, and everything past -syntax, types, borrows, semantics, stays the acceptance battery's job; -commit re-runs the same gate when it recomputes the staged record, so -bytes that do not parse can never install through any door; compute -the slice and validate against it; resolve attribution as below; validate -and attach usage evidence when supplied; then write the staged record — -the internal receipt: identities generated against, base snapshot -hashes, candidate tree hash, output manifest, attribution, optional -reported fields. Validation failures surface at add time with the same -outcome classes commit uses. Staged records are transient coordination -state, disposable litter by the git-state law: never promoted to -history, safely deletable, re-creatable by re-running add. - -Attribution transports instead of being demanded. Add computes the -byte diff between the module's committed output and the staged bytes, -and the prompt's committed body and its pending body, and transports -every committed attribution claim through those diffs -deterministically: a range the diff proves untouched survives -verbatim, a range below an insertion or deletion shifts by its size, -and a claim landing in a rewritten or ambiguous region does not -transport, because the transport never guesses; a range it cannot -prove through the diff is asked for, never invented. When every claim -transports, add stages with the transported map and no `--attribution` -flag is needed: the paperwork updates itself as a consequence of the -diff already being content-addressed on both sides. When any claim -fails to transport, add refuses as `invalid-candidate` listing exactly -the claims that need affirmation, each beside the observed bound or -region it was checked against, and `--attribution` supplies -affirmations for the listed claims only, layered over the transported -remainder. Any attribution refusal, transport or explicit, names the -observed bound (the actual line count or surviving range) next to the -rejected claim, so a wrong range never costs a manual count. Each -listed claim also carries its index in the recorded map and its note -verbatim, so an affirmation is written against named prose, never -positional trust or a separate query join. - -::: test -name: a transport refusal lists each claim with its map index and note -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } -expect: { listed_claims: 1, claim_index_present: true, claim_note_verbatim: true } -::: - -A module's first add has no committed map to transport, and it demands -no hand-authored one either: absent `--attribution`, add stages the -trivial whole map, every staged file claimed in full by the whole -prompt body, recorded as whole-file claims and honestly coarse. The -cold path to a verified first commit is therefore write the prompt, -write the code, `add --module`, `commit`, with attribution present and -fresh, never `absent`, and `--no-verify` never required to get started. -A claim, in any map, may be spelled whole: naming a file with no -numbers claims all of it, and naming a prompt range as whole claims -the whole body; bounds are derived at validation time from the staged -blob and the pending body, so no line count is ever transcribed by -hand and a whole-file claim survives any growth of its file. - -Two more spellings close the remaining gaps: `napl add ---module ` stages a path under a module explicitly, for the file -that is new in a crate several modules share, where neither the -committed manifest nor the globs can disambiguate it alone; and -`napl add --check` with any form runs the entire pipeline, transport -verdict and refusal listing included, and writes nothing, not even a -staged record, the dry run that answers before anything lands. The -check verdict says exactly what it judged and what it did not: the -payload enumerates the validations that ran (placement, ingest rules, -the parse gate, attribution transport, evidence) and names what stays -unjudged, everything past syntax, the acceptance battery's job, so -`would-stage` means the record is sound and the syntax parses, never -that the code is correct. A transport refusal -reports each endpoint's verdict separately: a claim whose prompt side -failed but whose code side transported serves the transported code -range beside the failed prompt range, and the affirmation supplies -only the side that actually needs a human, never both because one -refused. An unparseable or wrong-shaped `--attribution` file is -answered like a bad ref payload: the refusal carries the complete map -contract, every field and one complete valid example, never a bare -"not parseable". The map reader speaks the same value grammar as body -directives, so block and flow spellings of one map are one map: -`lines: [4, 9]` and its two-line block form parse identically. A map -that parses but yields no entries, or that fails at any construct, is -refused naming the exact line and the construct that stopped the -reader, beside the full contract, never a bare count of zero. The -contract is also taught before any failure: `help add` and -`add --help` serve the same complete map contract and worked example -the refusal carries, so the map is learnable from help instead of -from trial and error. - -::: test -name: flow and block spellings of one attribution map parse identically -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, maps: [flow_sequences, block_sequences] } -expect: { parsed_entries_equal: true, both_stage: true } -::: - -::: test -name: a map failing mid-parse names the line and construct beside the contract -given: { workspace: fixture_two_modules, attribution_file: bad_construct_on_line_3 } -expect: { error_names_line: 3, error_names_construct: true, error_carries_valid_example: true } -::: - -::: test -name: add help carries the complete attribution map contract and example -given: { workspace: fixture_two_modules, invocation: "help add" } -expect: { map_fields_listed: true, map_example_present: true, exit_code: 0, store_bytes_unchanged: true } -::: - -::: test -name: a first add with no flag stages the whole map and commits verified -given: { workspace: fresh_init_root_prompt, candidate: valid_root_level, attribution_flag: absent } -expect: { add_outcome: staged, map_kind: whole_file_claims, commit_outcome: success, attribution: fresh, no_verify_used: false } -::: - -::: test -name: a whole spelling claims the entire file and survives file growth -given: { workspace: fixture_two_modules, map_entry: whole_file_no_numbers, then: file_grows } -expect: { claim_valid: true, bounds_derived: true, retranscription_needed: false } -::: - -::: test -name: add check reports the full verdict and writes nothing -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_check } -expect: { verdict_listed_claims: 1, staged_records: 0, staging_dir_untouched: true, store_bytes_unchanged: true } -::: - -::: test -name: an explicit module stages a new path in a shared crate -given: { workspace: fixture_shared_crate_two_modules, working_tree: new_uncommitted_file_in_shared_glob, add_path_with_module: true } -expect: { add_outcome: staged, module_as_named: true } -::: - -::: test -name: add of one working-tree path stages the committed state with that file overlaid -given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, add_paths: [edited_file] } -expect: { add_outcome: staged, staged_state: committed_plus_overlay, unnamed_files_at_committed_bytes: true } -::: - -::: test -name: paths owned by two modules stage one record per module -given: { workspace: fixture_two_modules, working_tree_edit: both_modules_touched, add_paths: [file_of_leaf, file_of_consumer] } -expect: { staged_records: 2, modules: [consumer, leaf] } -::: - -::: test -name: a path no module owns is a usage error naming the owned globs -given: { workspace: fixture_two_modules, add_paths: [unowned_file] } -expect: { exit_code: 2, error_names_owned_globs: true, staged_records: 0 } -::: - -::: test -name: a pure line-shift edit transports the whole map and needs no attribution flag -given: { workspace: fixture_two_modules, working_tree_edit: insert_lines_above_attributed_range, add: module_form_without_attribution } -expect: { add_outcome: staged, map_transported: true, ranges_shifted: true, attribution_flag_needed: false } +given: { workspace: fixture_two_modules, candidate: contains_glob_reexport } +expect: { outcome: mapping_integrity, published_semantic_state: false } ::: ::: test -name: a claim in a rewritten region refuses listing that claim with its observed bound -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } -expect: { outcome: invalid_candidate, listed_claims: 1, observed_bound_present: true, untouched_claims_not_listed: true } +name: a named pub use is recorded in the surface as the written binding +given: { workspace: fixture_two_modules, candidate: valid_with_named_reexport } +expect: { surface_contains_written_binding: true, surface_contains_resolved_signature: false } ::: ::: test -name: affirmations layer over the transported remainder and stage -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_affirmation_for_listed_claim } -expect: { add_outcome: staged, transported_claims_kept: true, affirmed_claim_recorded: true } +name: recorded surface re-extraction from committed output is byte-identical +given: { workspace: fixture_two_modules, candidate: valid_for_consumer } +expect: { reextraction_equal: true } ::: +## add, then commit + +Git parity is the mental model: `add` stages, `commit` installs. The +receipt is an internal record between them, never a user-facing noun. + +`napl add --module [--attribution ] +[--usage ]` stages a candidate: ingest candidate bytes into +immutable content addressed blobs (reject links, devices, duplicate +normalized paths, absolute or parent-escaping paths, oversized entries or +totals; all later steps read ingested blobs, never the candidate +directory); compute the slice and validate the candidate against it; +validate the attribution update's every claim; validate and attach usage +evidence when supplied; then write the staged record — the internal +receipt: identities generated against, base snapshot hashes, candidate +tree hash, output manifest, attribution, optional reported fields. +Validation failures surface at add time with the same outcome classes +commit uses. Staged records are transient coordination state, disposable +litter by the git-state law: never promoted to history, safely deletable, +re-creatable by re-running add. + ::: test name: add stages a valid candidate and commit installs it given: { workspace: fixture_two_modules, candidate: valid_with_attribution } @@ -239231,9 +245623,9 @@ expect: { add_outcome: staged, commit_outcome: success } ::: ::: test -name: an explicit candidate whose transport is incomplete and unaffirmed fails loudly -given: { workspace: fixture_two_modules, candidate: rewritten_region_without_affirmation } -expect: { outcome: invalid_candidate, listed_claims_present: true } +name: add without an attribution update fails loudly +given: { workspace: fixture_two_modules, candidate: valid_without_attribution } +expect: { outcome: invalid_candidate, missing_named: attribution } ::: ::: test @@ -239297,72 +245689,6 @@ given: { workspace: fixture_two_modules, candidate: valid_for_consumer, head: un expect: { outcome: installed, head_advanced: true, surface_recorded: true } ::: -::: test -name: a bare commit installs every staged record in wave order -given: { workspace: fixture_two_modules, staged: [record_for_leaf, record_for_consumer], commit_args: none } -expect: { outcomes: [success, success], order: wave_order, hand_sequencing_needed: false } -::: - -::: test -name: bare commit re-derives and lands records for modules sharing one crate -given: { workspace: fixture_shared_crate_two_modules, staged: [record_for_first, record_for_second], commit_args: none } -expect: { outcomes: [success, success], retried_after_head_move: 1, hand_sequencing_needed: false } -::: - -::: test -name: commit sweeps the record it installed -given: { workspace: fixture_two_modules, staged: [record_for_leaf], commit: bare_then_bare_again } -expect: { first_outcomes: [success], second_outcomes: [], reattempted_history: 0 } -::: - -::: test -name: a bare commit with nothing staged names the drifted modules and their add commands -given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, staged: none, commit_args: none } -expect: { installed: 0, nothing_staged_named: true, drifted_modules_named: [consumer], add_command_named: true } -::: - -::: test -name: a per-side transport refusal serves the surviving side's transported range -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_prompt_section_only, add: module_form_with_check } -expect: { prompt_side: refused, code_side: transported, transported_code_range_served: true, affirmation_needed_sides: [prompt] } -::: - -::: test -name: check enumerates the parse gate and refuses a file that does not parse -given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, add: module_form_with_check } -expect: { validations_enumerated: true, parse_gate_listed: true, outcome: invalid_candidate, error_names_file_and_line: true, staged_records: 0 } -::: - -::: test -name: add refuses a candidate whose source does not parse naming file and line -given: { workspace: fixture_two_modules, candidate: syntactically_invalid_rust } -expect: { outcome: invalid_candidate, error_names_file_and_line: true, published_semantic_state: false } -::: - -::: test -name: commit recomputation refuses staged bytes that do not parse -given: { workspace: fixture_two_modules, staged: record_with_tampered_unparseable_blob } -expect: { outcome_in: [invalid_candidate, store_integrity], installed: false } -::: - -::: test -name: a bad attribution file answers with the complete map contract and example -given: { workspace: fixture_two_modules, attribution_file: empty_object, add: module_form_with_attribution } -expect: { error_lists_all_fields: true, error_carries_valid_example: true } -::: - -::: test -name: the plan labels projections and names a legal first output -given: { workspace: fresh_init_root_prompt, flag: module_root } -expect: { projection_labeled_tool_derived: true, legal_first_output_named: true } -::: - -::: test -name: gc apply sweeps staged records and touches nothing reachable -given: { workspace: fixture_two_modules, staged: [stale_record], gc: apply } -expect: { staging_empty: true, reachable_objects_untouched: true, store_healthy: true } -::: - ::: test name: commit against a moved head with disjoint footprints rebases and installs given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: to_unrelated_module } @@ -239402,9 +245728,8 @@ range present in the current normalized prompt body; a mapping that lies is `invalid-candidate`. An entry may own no file at all: contract-level prose — a law — that governs the module rather than any single path; validation accepts a file-less entry (its prompt range must still exist) -and the query layer serves it flagged as law. An add whose transport is -incomplete and whose affirmations do not cover the listed claims fails -loudly as `invalid-candidate`, naming exactly which claims are missing; +and the query layer serves it flagged as law. An add without an attribution update fails +loudly as `invalid-candidate`, naming exactly what is missing; `napl add --no-verify` is the explicit escape hatch that stages anyway and records the module's attribution as stale on install. Any prompt edit, code transition, or move that changes what a recorded mapping @@ -239488,11 +245813,7 @@ expect: { outcome: invalid_candidate, published_semantic_state: false } front of the same guarded door. They carry the familiar tool signatures: `napl edit --old-string --new-string [--replace-all]` replaces an exact occurrence in a file, and -`napl write ` writes stdin as the file's full content; its usage -line says the content comes from stdin, and an empty stdin refuses as -a usage error naming `--empty`, the explicit flag that authorizes -writing a zero-byte file, so a forgotten pipe can never silently -truncate. Either +`napl write ` writes stdin as the file's full content. Either side is editable, a prompt or a generated file, by a human or an LLM; a hand edit through an ordinary editor stays equally legal, and the verbs add capture, never exclusivity. The verbs never synthesize or @@ -239513,12 +245834,6 @@ every refusal leaves every file byte-identical. A call may carry one reference: `--ref `, a single object naming a direction and both endpoints, prompt body line range and generated file line range, in either direction per the reference contract above. -The contract is taught, never reverse engineered: the verbs' usage -lines and flag meanings name the object's fields, and the FIRST -validation failure of a ref payload answers with the complete -contract in one refusal, every field, the closed list of valid -`direction` values, and one complete valid example, never one missing -field per attempt. The reference is validated referentially only, exactly as strong as attribution validation and no stronger: the file path inside the owning module's placement, both line ranges present in the @@ -239529,59 +245844,12 @@ reference-only record and commits it through the guarded transaction in the same invocation: the transition carries the reference entry and its provenance, no output-tree change and no surface change, and every rule of the decision table, journaled atomicity, and recovery applies -to it unchanged. The success payload carries the recorded reference -object verbatim and its transition hash, and says plainly that the -edited file remains working-tree drift until its module is committed: -recording a reference and advancing the committed baseline are -different acts, and the payload spells the sequence that performs the -second, `napl add` for the module then `napl commit`, so nobody reads -`recorded: true` as committed and nobody feeds a bare commit nothing. -A plain apply teaches the same way: a written or edited file is -working-tree drift until staged and committed, and the success -payload of both verbs names both verbs in order. `napl show ` lists the -transition's reference entries inline exactly as it lists notes. A -call without `--ref` is a plain edit: apply, no +to it unchanged. A call without `--ref` is a plain edit: apply, no record, no store touch. Any existing reference whose endpoint the edit moved needs no marking, because both-ends staleness is already computed at read time from the working tree; the verbs store nothing about it. -A reference the verb records is evidence, and evidence serves itself. -The record carries the content hash of each endpoint's post-edit -bytes, the file as the verb wrote it and the normalized prompt body it -validated against. Freshness reads those hashes first: a verb-recorded -reference whose endpoint bytes still match the working tree reads -fresh, and it goes stale only when the tree moves off the bytes the -record names, never merely because the committed baseline lags the -edit, so `recorded: true` is never followed by a stale count the verb -itself caused. At the next add, transport seeds from the same -evidence: a committed claim refusing in a rewritten region is affirmed -automatically when a reference transition recorded since the module's -committed output covers that region and its endpoint hashes match the -staged bytes, because the tool that applied the edit owns its diff; -the affirmation is the record, and no hand-authored map is ever -demanded for a range a capture verb already recorded. A recorded -reference whose hashes no longer match affirms nothing and is listed -in the refusal like any other claim, so the shortcut never guesses. - -::: test -name: a verb-recorded reference reads fresh until the tree moves off its bytes -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: status_module } -expect: { stale_reference_count: 0, drift: true } -::: - -::: test -name: add after a referenced edit self-affirms the rewritten region and stages without a map -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, add: module_form_without_attribution } -expect: { add_outcome: staged, affirmed_from_reference_record: true, attribution_flag_needed: false } -::: - -::: test -name: a recorded reference whose bytes moved again affirms nothing -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: hand_edit_same_region, add: module_form_without_attribution } -expect: { outcome: invalid_candidate, listed_claims_present: true, affirmed_from_reference_record: false } -::: - Provenance rides the session window: `--session ` names the usage-proxy session whose log lines are this call's evidence, and when the flag is absent the verb reads the `NAPL_SESSION` environment @@ -239633,36 +245901,12 @@ given: { workspace: fixture_two_modules, edit: old_string_matches_twice } expect: { outcome: edit_mismatch, exit_code: 27, files_byte_identical: true } ::: -::: test -name: the first bad ref payload answers with the whole contract at once -given: { workspace: fixture_two_modules, edit: ref_payload_bare_string } -expect: { error_lists_all_fields: true, error_lists_direction_values: true, error_carries_valid_example: true, files_byte_identical: true } -::: - -::: test -name: a recorded edit serves its reference in the payload and in show -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } -expect: { payload_reference_verbatim: true, payload_names_drift_until_commit: true, show_lists_reference: true } -::: - ::: test name: a prose-only edit needs no reference and touches no store state given: { workspace: fixture_two_modules, edit: plain_prompt_edit_valid } expect: { applied: true, store_bytes_unchanged: true } ::: -::: test -name: verb success payloads spell the add then commit sequence -given: { workspace: fixture_two_modules, invocations: [plain_write_owned_file, referenced_code_edit_with_ref] } -expect: { write_payload_names: [add, commit], edit_payload_names: [add, commit] } -::: - -::: test -name: write with empty stdin refuses naming the empty flag and writes nothing -given: { workspace: fixture_two_modules, write: target_with_empty_stdin } -expect: { exit_code: 2, error_names_flag: "--empty", files_byte_identical: true } -::: - ::: test name: an edit touching a referenced line reads stale on both endpoints afterward given: { workspace: fixture_two_modules, edit: plain_edit_on_referenced_code_line } @@ -239683,95 +245927,19 @@ expect: { provenance_model: unavailable, provenance_usage: unavailable } ## The rest of the porcelain -`napl status` observational classification, zero mutation, and it -judges both ends: per module it reports `drift: true` when the -committed output differs from the working tree, and the module's stale -reference count, the same derivations diff and the lsp already serve, -so status can never read clean while the query surface reads stale. -A module fresh on the prompt side and drifted on the code side says -so in one payload. `napl status --module ` answers with the -module's live state beside its plan, tracked or pending, drift, stale -reference count, attribution status, in the same payload as the owned -globs and wave position, so the command named status never answers -with a plan alone; and the payload carries the derived entry-file -projection, path and exact content, so the line a new file needs in a -shared entry file is read from the tool, never composed by hand. -Every projection path the plan mentions is labeled what it is, -tool-derived and never user-supplied, and the plan names one concrete -legal first output path (the module name under the target's source -dir), so the obvious wrong first move, hand-writing the entry file, -is warned against before it is made. The add refusal for a module -whose owned tree holds nothing stageable says the same thing: which -paths present are projections, and what the first legal file would -be. -`napl log` entries carry each transition's note count and reference -count, and the log payload names `napl show ` as where -the full note and reference text is served, so provenance is one -named hop away, never a secret; `napl show ` lists its -attached notes inline, hash and text, so a note attached is a note -findable without plumbing. - -::: test -name: log counts references and names show as the inspection verb -given: { workspace: fixture_two_modules, history: [commit, referenced_edit] } -expect: { log_reference_count: 1, show_named_in_payload: true } -::: - -`napl log` +`napl status` observational classification, zero mutation. `napl log` per-module or store-wide history. `napl show ` inspect one object. `napl diff` drift between committed outputs and working tree as a canonical byte-stable patch. `napl blame ` derived ancestry: surviving lines trace through accepted patches by canonical replay; rewritten regions blame -the rewriting transition, honestly coarse. Blame reads committed -bytes only, and says so when it matters: a blamed path whose working -tree drifts from the committed output carries a drift notice naming -`napl diff` as where the uncommitted change lives, so ancestry over -the committed bytes is never mistaken for the present file. - -::: test -name: blame over a drifted path names the drift and points at diff -given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, blame_path: drifted_file } -expect: { drift_noticed: true, diff_named: true, ancestry_served: true } -::: - -`napl lint` unreachable modules +the rewriting transition, honestly coarse. `napl lint` unreachable modules under an explicit `roots` declaration; when roots are absent the refusal is loud, exit 2 with canonical JSON naming the missing prerequisite and the exact declaration that would satisfy it, never an empty findings list under exit 0; unconsumed surface entries (diagnosis only), module staleness; findings are proposals with cascade previews; `napl lint --fix` applies only complete deterministic mapping-safe edits through the guarded commit -path and never removes lines. `napl lint ` is the -source-language scan, a different question needing no roots: it parses -every committed module's owned working-tree file under that target -with the pinned grammar and reports one finding per file that fails, -naming the file and the first error's line. Every lint payload names -the scope it judged, graph or source syntax, so an empty findings -list is never read wider than the scan that produced it. Source -syntax findings are facts, not proposals: a target scan carrying any -finding exits 1, so automation can never read broken source as -success, and a clean scan exits 0; the graph scope keeps its -advisory exit. - -::: test -name: lint of a target exits nonzero on source findings and zero when clean -given: { workspace: fixture_two_modules, runs: [clean_tree, syntactically_invalid_owned_file], lint: target_form } -expect: { clean_exit_code: 0, broken_exit_code: 1, findings_when_broken: 1 } -::: - -::: test -name: lint of a target reports a parse finding per malformed working-tree file -given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, lint: target_form } -expect: { findings: 1, error_names_file_and_line: true, scope_named: source_syntax, roots_required: false } -::: - -::: test -name: lint of a target over a clean tree answers empty and names its scope -given: { workspace: fixture_two_modules, lint: target_form } -expect: { findings: 0, scope_named: source_syntax, exit_code: 0 } -::: - -`napl cost` pure calculation over an explicit +path and never removes lines. `napl cost` pure calculation over an explicit receipt set and a pinned pricing-table provenance object; unknown is unknown, never zero. Edits price the way generations price: records the capture verbs staged enter the same receipt set, their session-window @@ -239785,23 +245953,7 @@ is ever silently zero and nothing is aggregated at record time. The same receipt set, log bytes, and pricing table produce byte-identical reports. `napl gc` stages sweeps outside locks, swaps pointers inside the bounded section, never blocks writers, never deletes anything -reachable from a named head. Reachable means the transitive closure: -from every named head, through every event in its chain, through every -object any reachable object references by hash, output trees, -surfaces, attribution records, prompt blobs, receipts, at every -contract version history carries. `fsck`'s definition of a present -object IS gc's definition of a kept one; a store that was healthy -before a sweep is healthy after it, and blame replays the same -ancestry after a sweep as before. An edge gc does not understand is a -reason to keep, never to sweep. - -::: test -name: gc apply on a workspace with history leaves fsck healthy and blame replaying -given: { workspace: fixture_with_multi_commit_history, gc: apply } -expect: { fsck_findings: 0, blame_replays_history: true, head_intact: true } -::: - -`napl fsck` envelope hashes, kind validity, +reachable from a named head. `napl fsck` envelope hashes, kind validity, causal parent links. `napl restore` deterministic repair plans from persisted evidence; unambiguous moves install, ambiguity blocks; liveness and wall time never decide recovery. `napl mv ` relocation @@ -239928,13105 +246080,11911 @@ expect: { outcome: installed, transition_recorded: true, dep_removed: true } ::: ::: test -name: a cost report over a receipt set containing verb-staged records includes the edits -given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines } -expect: { report_rows: 2, edit_row_priced: true, same_pricing_table: true } -::: - -::: test -name: an edit without a window and a window with missing log lines both price unavailable naming why -given: { workspace: fixture_two_modules, receipts: [edit_without_window, edit_with_window_to_missing_lines] } -expect: { priced_unavailable: 2, reason_named_per_row: true, zeros: 0 } -::: - -::: test -name: a model absent from the pricing table prices unknown never zero -given: { workspace: fixture_two_modules, receipts: [edit_with_unknown_model_window], log: proxy_log_unknown_model } -expect: { priced_unknown: 1, zeros: 0 } -::: - -::: test -name: cost reports are byte-identical across runs over the same receipts log and table -given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines, runs: 2 } -expect: { bytes_equal: true } -::: - -::: test -name: cost session reports one session's calls directly from the log -given: { workspace: fixture_two_modules, log: proxy_log_two_sessions, session: s1 } -expect: { rows_for_session: 2, rows_for_other_sessions: 0, priced_from_log: true } -::: - -::: test -name: a proxy log line failing the shape validates invalid and stays visible as failed -given: { evidence: proxy_log_with_malformed_line, media_type: application_x_napl_usage_proxy_jsonl_v1 } -expect: { outcome: invalid, visible_as_failed: true } -::: - -::: test -name: cost over one receipt set and pricing version is byte-identical across runs and unknown for unknown models -given: { receipts: fixture_receipts, pricing_version: pinned, runs: 2, unknown_model_receipt: included } -expect: { reports_equal: true, unknown_model_cost: unknown } -::: - -::: test -name: gc never deletes an object reachable from the current head and never blocks a concurrent commit -given: { workspace: fixture_two_modules, concurrent: [gc, valid_commit] } -expect: { reachable_deleted: false, commit_blocked: false, unreachable_swept: true } -::: - -::: test -name: fsck detects a corrupted object and a broken parent link -given: { workspace: fixture_two_modules, corrupt: [flip_byte_in_object, remove_parent_event] } -expect: { findings: [hash_mismatch, missing_parent], exit_nonzero: true } -::: - -::: test -name: restore applies an unambiguous move-repair plan and blocks on ambiguity -given: { workspace: fixture_moved_output, ambiguity: [none, two_candidates] } -expect: { unambiguous: installed, ambiguous: blocked } -::: - -::: test -name: hover on an attributed prompt line resolves its generated spans -given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: within_entry } } -expect: { spans_resolved: true, notes_included: true } -::: - -::: test -name: "hover on an unattributed line answers empty, not error" -given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: outside_entries } } -expect: { result: empty, error: false } -::: - -::: test -name: missing attribution records degrade to empty responses -given: { attribution: absent, request: hover } -expect: { result: empty, error: false, blocked: false } -::: - -::: test -name: definition from a generated line targets the prompt at its discovered placement -given: { attribution: fixture_attribution_small, prompt_placement: inside_crate_dir, request: definition, position: { file: generated_within_entry } } -expect: { location_file: discovered_prompt_path, root_joined_module_name: false } -::: - -::: test -name: evidence failing its validator is invalid and distinguishable from unsupported -given: { receipt_evidence: [valid_claude_json, malformed_claude_json, unknown_media_type] } -expect: { outcomes: [validated, invalid, unsupported] } -::: - -::: test -name: a receipt exceeding evidence bounds is rejected as resource-limit with nothing published -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: oversized_evidence } -expect: { outcome: resource_limit, published_semantic_state: false, published_provenance: false } -::: - -## Determinism contract - -Every collection canonically ordered; every input explicit and content -identified; serialization byte-pinned; ambient environment excluded -(locale, timezone, machine identity, CPU count, enumeration order); reads -never write; a state transition names its base and fails on mismatch per -the decision table rather than recalculating. Same workspace compiled -twice, from different absolute paths, under different discovery orders, -locales, and timezones produces byte-identical artifacts. The test suite -proves the vectors in the frontmatter with deterministic fixtures -constructed in each crate's `tests/`. - -::: test -name: compiling the same workspace twice yields byte-identical artifacts -given: { workspace: fixture_two_modules, runs: 2 } -expect: { artifacts_equal: true } -::: - -::: test -name: artifact bytes are independent of discovery order -given: { workspace: fixture_two_modules, discovery_orders: [sorted, reversed, shuffled_seed_7] } -expect: { artifacts_equal: true } -::: - -::: test -name: artifact bytes are independent of the absolute workspace path -given: { workspace: fixture_two_modules, mounted_at: [rootA, rootB] } -expect: { artifacts_equal: true } -::: - -::: test -name: canonical json sorts keys and is byte-stable -given: { value: "{\"b\":1,\"a\":2}", encodings: 2 } -expect: { bytes_equal: true, sorted_keys: true } -::: -napl-store blob v1 5100 -//! Producer-side helpers: drafting a receipt, and the one automatic fix. -//! -//! Both are compositions of derivations that already exist. Drafting a receipt -//! invokes nothing and reports no telemetry of its own; the fix reaches the -//! store only through the guarded commit path, never around it. - -use std::collections::BTreeMap; -use std::path::Path; - -use crate::candidate::{default_limits, ingest}; -use crate::compile::{slice_at, workspace_source_hash}; -use crate::error::{error, OutcomeCode, Result}; -use crate::lint::{is_auto_fixable, Finding}; -use crate::receipt::RECEIPT_VERSION; -use crate::source::rewrite_deps; -use crate::store_view::{get_blob, ObjectSource}; -use crate::target::{builtin_target, placement_root}; -use serde_json::{json, Value}; - -use crate::commit::{commit, CommitReport}; -use crate::disk; -use crate::porcelain::committed_manifest; -use crate::workspace::{discover_sources, scan_candidate, write_file}; - -/// Build the receipt a producer would submit for a candidate directory. -pub fn draft_receipt( - workspace: &Path, - target_name: &str, - module: &str, - candidate_dir: &Path, -) -> Result { - builtin_target(target_name)?; - let store = disk::open(workspace); - let head = store.head()?; - let sources = discover_sources(workspace)?; - let slice = slice_at(&store, &sources, target_name, module, head.as_deref())?; - let candidate = ingest(&scan_candidate(candidate_dir)?, default_limits())?; - let entries: Vec = candidate - .manifest - .iter() - .map(|(path, hash)| json!({ "hash": hash, "path": path })) - .collect(); - - Ok(json!({ - "baseStateKey": slice.base_state_key, - "baseStoreSnapshot": head, - "baseWorkspaceHash": workspace_source_hash(&sources)?, - "candidateTreeHash": candidate.tree_hash, - "inputKey": slice.input_key, - "module": module, - "outputManifest": entries, - "receiptVersion": RECEIPT_VERSION, - "sliceHash": slice.slice_hash, - "target": target_name, - })) -} - -/// Apply one auto-fixable finding through the guarded commit path. -/// -/// Dropping an unreferenced declared dependency changes the module's `inputKey` -/// and nothing else, so the candidate is the module's own committed output, -/// byte for byte. That is what makes the fix complete and mapping-safe: it can -/// only ever move an identity, never a generated line. -pub fn apply_fix(workspace: &Path, target: &str, finding: &Finding) -> Result { - if !is_auto_fixable(finding) { - return Err(error( - OutcomeCode::MappingIntegrity, - "finding has no complete deterministic mapping-safe repair", - )); - } - let Finding::UnusedDependency { module, dep } = finding else { - unreachable!("only an unused dependency is auto-fixable"); - }; - - let store = disk::open(workspace); - let sources = discover_sources(workspace)?; - let declared = sources - .iter() - .find(|source| &source.module == module) - .ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} has no source file"), - ) - })?; - let remaining: Vec = declared - .deps - .iter() - .filter(|name| *name != dep) - .cloned() - .collect(); - - // Materialize the committed output as the candidate, then rewrite the - // source. Order matters: the candidate must be the output that was committed - // against the *old* source, and the commit that follows re-derives every - // identity against the new one. - let staging = store.root.join("tmp").join(format!("fix-{module}")); - if staging.exists() { - std::fs::remove_dir_all(&staging).map_err(|e| disk::io(&staging, &e))?; - } - let committed = committed_manifest(&store, target)?; - let root = placement_root(declared); - let owned: BTreeMap<&String, &String> = committed - .iter() - .filter(|(path, _)| path.starts_with(&format!("{root}/"))) - .collect(); - if owned.is_empty() { - return Err(error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} has no committed output to re-commit"), - )); - } - for (path, blob) in &owned { - write_file(&staging.join(path), &get_blob(&store, blob)?)?; - } - - let source_path = workspace.join(&declared.path); - let original = std::fs::read_to_string(&source_path).map_err(|e| disk::io(&source_path, &e))?; - write_file(&source_path, rewrite_deps(&original, &remaining)?.as_bytes())?; - - let receipt = draft_receipt(workspace, target, module, &staging)?; - let receipt_path = store.root.join("tmp").join(format!("fix-{module}.json")); - write_file( - &receipt_path, - &crate::canon::canonical_bytes(&receipt), - )?; - - let report = commit(workspace, &receipt_path, &staging); - if report.outcome != OutcomeCode::Success { - // A rejected fix leaves the source exactly as it was found. - write_file(&source_path, original.as_bytes())?; - } - Ok(report) -} -napl-store receipt v1 703 -{"baseStateKey":"40ddb00b3be320909acf0ae1440e2bc5662e3002dcf77dc3367bcb215b1710fd","baseStoreSnapshot":"54466d71840faf6fafba87099ed5a51a284f481fe896c6bc355fc6ed92f884d8","baseWorkspaceHash":"1cbd0f8a55437d59751b61ddc91d2290912cf5b52204af9c179365134de2e5f3","candidateTreeHash":"505d5f544144525d73def85bf0b3b9e16230ea634599f4d343080af78d359024","contractVersion":4,"inputKey":"4d1fb17243be4b78e491396c6d7f09490975c5851b5c2f191f4e10798848ccc4","module":"serve","outputManifest":[{"hash":"c6b8ce166bc273993feec781ce54da25499ac925715435ffb9d9dc32a2fda9e3","path":"crates/napl/src/serve.rs"}],"receiptVersion":1,"sliceHash":"a798c8c609ac68ef2ad8eaa774d5dae53efe4a6ab0a13582db815e77e1cf6d2c","target":"rust"} -napl-store tree v1 4679 -{"entries":[{"hash":"12166b5fd9554e4781dd2efb828ac24b17228fab1a08ad7df51069e20f8efb0f","kind":"file","mode":33188,"name":"attribution.rs"},{"hash":"c777943d5bb9e51d363d75a4b45ef5b79311f0ccc4c4de6b471d93664ac0540c","kind":"file","mode":33188,"name":"authoring.rs"},{"hash":"4b44f76ec9cece4314358f54861f937a5c4e875e93646399a3abbb09c2a51bea","kind":"file","mode":33188,"name":"blame.rs"},{"hash":"02d2ea0745e17a3fdc17f2657ee532dfb748423bced9c1302e3178f45dfab10a","kind":"file","mode":33188,"name":"candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","kind":"file","mode":33188,"name":"canon.rs"},{"hash":"d85bfa83452d95a4eeb9b36dce2774cf0d64189608cf962441406db9b7fe5a3b","kind":"file","mode":33188,"name":"capture.rs"},{"hash":"f5e2132da4e24805a9c5d93e78db873618fea004116575dac3a9e60446b01c20","kind":"file","mode":33188,"name":"cli.rs"},{"hash":"8f9c70df379b3ab58f979c191dc1ae5fb0e5b2199c84757b61c254bb05241ebd","kind":"file","mode":33188,"name":"commandset.rs"},{"hash":"a939de9095ad62f52d77a4e472a0cf040966d14c98b860308bec79e6f8cf5bc1","kind":"file","mode":33188,"name":"commit.rs"},{"hash":"5287b1e356febbfb1bb42750c5d0e218597176a3fee19f387b975d84aa0cc11b","kind":"file","mode":33188,"name":"compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","kind":"file","mode":33188,"name":"cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","kind":"file","mode":33188,"name":"decision.rs"},{"hash":"43b1245f514e01498961d0fb53b8543fff9303363c309346031fdb23984f9a18","kind":"file","mode":33188,"name":"directive.rs"},{"hash":"b86a4431e286225b32bae5cebc03298584fce063e8d4ee2df4633e18143f0aa7","kind":"file","mode":33188,"name":"disk.rs"},{"hash":"bba24de15a64304a2c47ff84ea2d957dd65f77a450ab1f813ef2d51fb69e7cc0","kind":"file","mode":33188,"name":"error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","kind":"file","mode":33188,"name":"evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","kind":"file","mode":33188,"name":"graph.rs"},{"hash":"bcacc1842bc9440afe08e7d396622023b8ce1db9d04b796d2842fb704c7b2b6d","kind":"file","mode":33188,"name":"lint.rs"},{"hash":"0f2e50ef0121924926bdadaa29157d5abb2159c2308a44edb3e3e8fde77fbc0c","kind":"file","mode":33188,"name":"lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","kind":"file","mode":33188,"name":"main.rs"},{"hash":"830cb1dea8bac30fabe6ccd94440f6801106adc77b473dd96a0c10c464024b7c","kind":"file","mode":33188,"name":"maintenance.rs"},{"hash":"697b0b828643d41b8ce208d441a0e11fdfe044cc743514830d3c36287b46a778","kind":"file","mode":33188,"name":"mv.rs"},{"hash":"7cf9c419b2c4f7c7b17e2c1e4362861de714f4ffe54727c1a0fcb749b2e6975b","kind":"file","mode":33188,"name":"objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","kind":"file","mode":33188,"name":"paths.rs"},{"hash":"948ef7cd994335320ebef7a5d3ba1380ecdbdcf64b79ac90a8d451bc3aeab382","kind":"file","mode":33188,"name":"porcelain.rs"},{"hash":"156ab704b4d1d385105fd45dfe9ffa5af5cdbf7344eac0c70c3c4d26b6583167","kind":"file","mode":33188,"name":"receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","kind":"file","mode":33188,"name":"slice.rs"},{"hash":"1983d2bf91bbcc59706fd11a0609f56551072e728b90e5db9dc8e7b19bcb0a8f","kind":"file","mode":33188,"name":"source.rs"},{"hash":"35e58a71895a9981f01ee4dec4bb6bd1a8464d507c1d4bd91a2fd07759f85f6f","kind":"file","mode":33188,"name":"staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","kind":"file","mode":33188,"name":"store_view.rs"},{"hash":"5e9e418d1a1e8dccd7207bdf85f39d21e510760135c6cc2be2f149e4b82a6f78","kind":"file","mode":33188,"name":"surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","kind":"file","mode":33188,"name":"target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","kind":"file","mode":33188,"name":"textdiff.rs"},{"hash":"bfd8ff52f2306a963616bda4a97971a9daebe88e75f34bb2f06960ea92feac39","kind":"file","mode":33188,"name":"transaction.rs"},{"hash":"b73e4b4242b073030bdaf119b375999eec7f383111a0b02f8e73505eb0ecc76d","kind":"file","mode":33188,"name":"transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","kind":"file","mode":33188,"name":"trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","kind":"file","mode":33188,"name":"version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","kind":"file","mode":33188,"name":"workspace.rs"}]} -napl-store tree v1 132 -{"entries":[{"hash":"539a182ea541e60a1f754a9c3205d681a91304592e430f56ee35f04c9b32cbbb","kind":"dir","mode":16384,"name":"crates"}]} -napl-store blob v1 789 -module: "commands" -target: "rust" -stale: false -entries: - - promptLines: - - 1 - - 8 - file: "crates/napl/src/init.rs" - lines: - - 1 - - 83 - note: "two porcelain additions each a thin composition over the shared derivations, the command table contributing init and watch" - - promptLines: - - 9 - - 41 - file: "crates/napl/src/init.rs" - lines: - - 1 - - 83 - note: "napl init bootstraps the store skeleton or reports already_initialized, names the directory it ran in as the workspace root, and declares no flags beyond the shared help flag" - - promptLines: - - 42 - - 63 - file: "crates/napl/src/watch.rs" - lines: - - 1 - - 127 - note: "napl watch an io shell re-running compile on content change publishing nothing" -napl-store module-state v1 559 -{"attribution":{"promptHash":"6ffc6466a8595d2c095631bb4e83ee15014b405072b0d39e9b6514a0e58b15fa","record":"48e5624ef415a6044dcc36f8f24c51662620a5ff31cdedaefb76aefecad4fc63","stale":false},"contractVersion":3,"inputKey":"0ac0f86a77570f04d575a508bdef86c69ee4fd71c2c2e4763f098ff7f8743ec9","module":"query","outputTree":"dfe384bca0e1e6245624b6a79f81a99c5fa0bf60f5bc0e04856a53d888e92a48","sliceHash":"75334a4f5a419e1326f760e837d22ecfbe79c64d8f67602b0d1e135a8ca4c2e6","surfaceHash":"d9032b03ee7b166d66159ff59d7e98ba35a3a66b35cbae983d43f0630747ad65","target":"rust"} -napl-store blob v1 14432 -module: "napl" -target: "rust" -stale: false -entries: - - promptLines: - - 1 - - 8 - file: "crates/napl/src/cli.rs" - lines: whole - note: "the binary reads args runs one command and exits with the contract code" - - promptLines: - - 1 - - 8 - file: "crates/napl/src/commandset.rs" - lines: whole - note: "the command table the core toolchain contributes to the derived registry, remove among its entries" - - promptLines: - - 1 - - 8 - file: "crates/napl/src/main.rs" - lines: whole - note: "the binary entry point that spawns no process" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/source.rs" - lines: whole - note: "the smallest prompt the reader accepts, stated once beside the reader that judges it so the two commands that teach with it cannot drift from the grammar discovery finds by the .napl extension" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/transaction.rs" - lines: whole - note: "the crate lib.rs projection derived from the committed module file sets" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/workspace.rs" - lines: whole - note: "layout and discovery walk up to the nearest napl directory" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/paths.rs" - lines: whole - note: "the empty placement root contains every workspace-relative path and joins against it never fabricate a leading separator" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/target.rs" - lines: whole - note: "a prompt at the workspace root places at the empty root and every join against it stays relative" - - promptLines: - - 9 - - 114 - file: "crates/napl/src/transaction.rs" - lines: whole - note: "candidate containment against the empty root admits every workspace-relative path instead of rejecting all of them" - - promptLines: - - 115 - - 250 - file: "crates/napl/src/cli.rs" - lines: whole - note: "the declared grammar of every command plus the dispatch loop that answers help ahead of it and refuses an undeclared flag before it runs, with clap's builder API constructed at dispatch from the registry as the parser behind the contract, every parse failure caught and served as the canonical JSON usage error, and the conventional spellings reduced to one normalized argument vector so --flag=value and --flag value cannot differ" - - promptLines: - - 115 - - 250 - file: "crates/napl/src/commandset.rs" - lines: whole - note: "each core command declares its summary exact usage line and closed flag table, diff's operand is a target name whose usage line spells it and whose refusal names the target names that exist, and store declares its one operation so an unknown one is a grammar failure like any other undeclared token" - - promptLines: - - 115 - - 250 - file: "crates/napl/src/error.rs" - lines: whole - note: "exit 2 is the pinned usage-error code for the whole binary" - - promptLines: - - 115 - - 250 - file: "crates/napl/src/target.rs" - lines: whole - note: "placement roots owned source globs and the user-owned manifest seam a candidate may never contain" - - promptLines: - - 251 - - 278 - file: "crates/napl/src/decision.rs" - lines: whole - note: "the conflict rule two operations conflict when semantic write sets overlap" - - promptLines: - - 251 - - 278 - file: "crates/napl/src/error.rs" - lines: whole - note: "the outcome vocabulary and its exit-code mapping" - - promptLines: - - 251 - - 278 - file: "crates/napl/src/objects.rs" - lines: whole - note: "P2 footprints read write and derived-projection sets over content identities" - - promptLines: - - 279 - - 328 - file: "crates/napl/src/slice.rs" - lines: whole - note: "identity vocabulary inputKey closure manifest baseStateKey and sliceHash" - - promptLines: - - 279 - - 328 - file: "crates/napl/src/version.rs" - lines: whole - note: "the contract version bumped to four by the capture verbs whose three refusals join the closed table" - - promptLines: - - 329 - - 389 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "names are serialized as quoted escaped scalars that reparse identically" - - promptLines: - - 329 - - 389 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "the attribution record path keys on the encoded name never the raw one" - - promptLines: - - 329 - - 389 - file: "crates/napl/src/paths.rs" - lines: whole - note: "the injective reversible name encoding used wherever a name becomes a segment" - - promptLines: - - 329 - - 389 - file: "crates/napl/src/source.rs" - lines: whole - note: "the module-name rule any non-empty single-line scalar and nothing narrower" - - promptLines: - - 390 - - 460 - file: "crates/napl/src/compile.rs" - lines: whole - note: "the compile derivation a pure read that never persists an artifact, and the pending reason it derives from the closed vocabulary by comparing the current inputKey components against the identities the committed record holds, with the re-stamp promise an undrifted pending module carries" - - promptLines: - - 390 - - 460 - file: "crates/napl/src/graph.rs" - lines: whole - note: "the dependency graph and dependency-ordered wave planning" - - promptLines: - - 390 - - 460 - file: "crates/napl/src/paths.rs" - lines: whole - note: "the portable path grammar and its collision-detecting normalization" - - promptLines: - - 461 - - 716 - file: "crates/napl/src/directive.rs" - lines: whole - note: "the test body-directive value grammar diagnostics and deterministic extraction" - - promptLines: - - 521 - - 714 - file: "crates/napl/src/directive.rs" - lines: whole - note: "the docs fence family with its micromark name and attribute grammar, the fence stack where a closing line of N colons closes the innermost fence opened with at most N and where code blocks suspend the stack, the refusal that names the line of a directive fence opened inside another open directive fence because fences are ordered siblings rather than a tree, and the split that lifts every top-level docs fence out of the semantic body, plus the heading tolerance that reads a `# ` or `## ` line inside an open prose fence, docs note or warning, as the prose it is while the same line inside an open example or test fence still refuses on sight" - - promptLines: - - 521 - - 714 - file: "crates/napl/src/source.rs" - lines: whole - note: "the semantic body every identity derivation consumes is the normalized body with its docs fences removed, the module preamble is the first fence standing before the first heading, and an example fence is validated so a published example cannot rot" - - promptLines: - - 521 - - 714 - file: "crates/napl/src/cli.rs" - lines: whole - note: "a napl invocation written inside an example fence is judged against the live command grammar without running anything" - - promptLines: - - 717 - - 770 - file: "crates/napl/src/surface.rs" - lines: whole - note: "tree-sitter surface extraction sound by construction and loud on malformed input" - - promptLines: - - 771 - - 1327 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "attribution rides the staged record validated before install" - - promptLines: - - 771 - - 1327 - file: "crates/napl/src/authoring.rs" - lines: whole - note: "the internal receipt derivation and the one guarded auto-fix" - - promptLines: - - 771 - - 1327 - file: "crates/napl/src/candidate.rs" - lines: whole - note: "candidate ingestion untrusted bytes become immutable content-addressed blobs" - - promptLines: - - 771 - - 1327 - file: "crates/napl/src/commit.rs" - lines: whole - note: "napl commit the one guarded transaction over what add staged, and bare commit re-derives a record against the head an earlier record in the same invocation moved and retries it once" - - promptLines: - - 771 - - 1327 - file: "crates/napl/src/disk.rs" - lines: whole - note: "the store shell publication head compare-and-set and module-keyed locks over the one tracked object layout, and the door where a repository still carrying the retired store ref is refused before any verb has written a byte" - - promptLines: - - 771 - - 1327 - file: "crates/napl/src/receipt.rs" - lines: whole - note: "the internal receipt everything the compiler recomputes and refuses to believe" - - promptLines: - - 771 - - 1327 - file: "crates/napl/src/staging.rs" - lines: whole - note: "napl add stages from any of the three sources through one pipeline, the dry run enumerates the validations it ran and names target-language validity as the one it did not judge, and a bad attribution map is answered with the whole map contract" - - promptLines: - - 771 - - 1327 - file: "crates/napl/src/store_view.rs" - lines: whole - note: "the read seam over content-addressed objects a pure function of the bytes" - - promptLines: - - 771 - - 1327 - file: "crates/napl/src/transport.rs" - lines: whole - note: "attribution transports through the byte diff on both ends independently, and a refusal reports each endpoint's verdict separately so the side that transported serves its transported range and only the side needing a human is asked for" - - promptLines: - - 771 - - 1327 - file: "crates/napl/src/trees.rs" - lines: whole - note: "building the candidate tree over the ingested manifest" - - promptLines: - - 771 - - 1327 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "attribution rides the staged record validated before install" - - promptLines: - - 771 - - 1327 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "attribution rides the staged record validated before install" - - promptLines: - - 771 - - 1327 - file: "crates/napl/src/attribution.rs" - lines: whole - note: "attribution rides the staged record validated before install" - - promptLines: - - 771 - - 1327 - file: "crates/napl/src/lsp.rs" - lines: whole - note: "hover states the direction the recorded provenance that the reference is referentially valid and which endpoints read stale" - - promptLines: - - 1328 - - 1526 - file: "crates/napl/src/capture.rs" - lines: whole - note: "napl edit and napl write validate apply capture in that order, and a recorded call's payload carries the reference object verbatim with its transition hash and says the edited file remains working-tree drift until its module commits" - - promptLines: - - 1328 - - 1526 - file: "crates/napl/src/commit.rs" - lines: whole - note: "a pre-proven attribution installs fresh because the verb already proved the reference against the exact bytes it names and the guard runs unchanged" - - promptLines: - - 1328 - - 1526 - file: "crates/napl/src/cost.rs" - lines: whole - note: "a recorded session window is priced lazily at read time never aggregated at record time" - - promptLines: - - 1328 - - 1526 - file: "crates/napl/src/error.rs" - lines: whole - note: "the three refusals join the closed terminal-outcome table with pinned exit codes 27 28 and 29 and none reuses mapping-integrity" - - promptLines: - - 1328 - - 1526 - file: "crates/napl/src/staging.rs" - lines: whole - note: "the staged record carries its attribution mode so a reference-only record commits through the same door add and commit already use" - - promptLines: - - 1527 - - 1986 - file: "crates/napl/src/blame.rs" - lines: whole - note: "derived line ancestry where surviving lines trace through accepted patches, and a path no committed module owns answers tracked false naming the absent baseline rather than an empty ancestry that would read as success" - - promptLines: - - 1527 - - 1986 - file: "crates/napl/src/cost.rs" - lines: whole - note: "cost as a pure calculation over an explicit receipt set where verb-staged session windows resolve against the log at read time and unknown stays unknown" - - promptLines: - - 1527 - - 1986 - file: "crates/napl/src/evidence.rs" - lines: whole - note: "evidence validators keyed by media type including the usage-proxy jsonl log with compiled-in bounds" - - promptLines: - - 1527 - - 1986 - file: "crates/napl/src/lint.rs" - lines: whole - note: "unreachable modules under declared roots where absent roots refuse loudly with the exact declaration rather than an empty findings list, and unconsumed surface entries" - - promptLines: - - 1527 - - 1986 - file: "crates/napl/src/lsp.rs" - lines: whole - note: "the language server hover definition references code lens and the diagnostics it publishes from reference staleness over the mapping" - - promptLines: - - 1527 - - 1986 - file: "crates/napl/src/maintenance.rs" - lines: whole - note: "gc fsck and restore, where reachable means the transitive closure from every named head through every event and every hash edge any reachable object carries, so an edge gc does not understand is a reason to keep and a store healthy before a sweep is healthy after it" - - promptLines: - - 1527 - - 1986 - file: "crates/napl/src/mv.rs" - lines: whole - note: "relocation as a semantic operation re-rooting a module through the guarded path" - - promptLines: - - 1527 - - 1986 - file: "crates/napl/src/porcelain.rs" - lines: whole - note: "the observational porcelain status log show diff blame plus the maintenance verbs, where the plan labels every projection path tool-derived and names one concrete legal first output, show lists a transition's reference entries inline exactly as it lists notes, and blame skips a transition that names no new state because a removal records an absence" - - promptLines: - - 1527 - - 1986 - file: "crates/napl/src/textdiff.rs" - lines: whole - note: "diff renders drift as a canonical byte-stable patch" - - promptLines: - - 1987 - - 2021 - file: "crates/napl/src/canon.rs" - lines: whole - note: "the determinism contract canonical bytes and byte-pinned serialization" -napl-store snapshot v1 781 -{"contractVersion":4,"event":"d40ce1419539932ad2e15bbb2827c9f0aa6e64986030b54a69656ba64b443d31","states":[{"module":"agent","state":"e6448ff36c8767678aa7e2cc0c3a1300850d42e5b5e42107b85f93168c0ff856","target":"rust"},{"module":"commands","state":"606861d8a74ab37a851e6dfcf4a3bab1f824dcf8bef9e34bf9c6b27bc74a827e","target":"rust"},{"module":"napl","state":"33b9b868629947a37ba69a16cdd88b5f91fe1c54853c6910d0b22aeab7b22800","target":"rust"},{"module":"napl_store","state":"7011890f658fb28bd29684802e5e53bb5d54d54d3c5a64cc926a56fae7ef944d","target":"rust"},{"module":"query","state":"bc53b1a015afda4574efb81103b417c0bf8de879a57bfb3bcc3bcdfb3f2b639f","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} -napl-store receipt v1 717 -{"baseStateKey":"33a141441be88c6825e85a1495467d599a016bae61a678358f9d680a52017c99","baseStoreSnapshot":"ad325c51ef6afc696eb5e656129787c12b7e5048f387269a0e40419a73a9879e","baseWorkspaceHash":"bfa4ca1555c45ef6c957fcfd62e6fff07ef87365e518dfe0aa8d0adb989181c6","candidateTreeHash":"10e6ecbb2cf94bd488d9b800284ed2e991d1f6ee5ab3156d8b8d6a711e784c87","contractVersion":4,"inputKey":"a81dbd9694703c7aa43996c1fd0ba96dc10741827cd8283deefbfa80995cb3ac","module":"git-store","outputManifest":[{"hash":"1f70489bb644bef7e033b0d1c58f888dde57e9874e3045580feee00633ec748b","path":"crates/napl-store/src/git_store.rs"}],"receiptVersion":1,"sliceHash":"20862bd94681f9290944a9a8ea0ab62a8dad62db6b327c9e87c7c972de9a55ef","target":"rust"} -napl-store event v1 158 -{"content":"ccef8c05e2e325c5627320612073e20444cc5639b5bf1cc99349c915e0799eb5","parents":["4316a0ee1e4f6061a6ab25eed8e494799c62d033a790fe1ab629ce4989318039"]} -napl-store blob v1 5676 -# commands: init and watch - -Two porcelain additions to the `napl` binary, each a thin composition -over the same napl-core derivations and napl-store reads the other -commands use. They live in this module and compile into the same crate as -the core toolchain. The query surface that used to live here belongs to -the `query` module. - -## napl init - -`napl init` bootstraps a workspace: it creates the records directory -`.napl/` with an empty store (`store/` skeleton, no HEAD, no objects) in -the current directory when no ancestor workspace exists. Running it inside -an existing workspace changes nothing and reports `already_initialized`; -it never creates a nested workspace under another workspace and never -touches an existing store's bytes. The result of init is exactly the -state a fresh `napl compile` expects: head absent, no modules recorded. -Like `git init`, the directory it runs in is the whole story: the -success payload names that directory as the workspace root, there is -nothing to configure, and a prompt written beside `.napl` is already -inside placement. Init accepts no flags beyond the shared help flag; -anything else is a usage error under the command line contract. The -payload also teaches the first mile: a `nextSteps` field carries one -complete minimal prompt, frontmatter fences with `module`, `deps`, and -`targets` plus one body line, the fact that discovery finds prompts by -the `.napl` extension, and the exact command sequence from writing that -prompt through `add` and `commit`, so a cold user never reverse -engineers the grammar one validation error at a time. The minimal -prompt's frontmatter includes a `roots` declaration naming its own -module, so the very first workspace is lint-ready and the sequence's -forward pointer about `roots` is a reminder, never a first encounter. - -Init also scaffolds the agent-teaching glue, wrangler-style, writing -only what is absent and never touching an existing file: a -`napl-agents.md` at the workspace root carrying the working doctrine -for LLM sessions (edit with your native tools, drift is legal and the -diagnostics mark it, the status-add-commit loop, the query surface, -capture verbs are deliberate acts), plus, when absent, a `CLAUDE.md` -containing an import reference to it and an `AGENTS.md` containing a -pointer line, so whichever harness opens the repo finds its native -entry file already pointing at the one doctrine. Every command the -scaffolded teaching spells is a valid invocation under the live -command line contract: a query example always carries its expression, -`napl q '.' --working-tree` and never a bare `napl q`, because the -expression is required and a bare invocation is a usage error. No -environment is -sniffed and no harness detected: every entry file is written, tiny -and inert, and determinism holds. When an entry file already exists, -init leaves it alone and the payload prints the one reference line -the user may add by hand. All scaffolded files are user-owned from -the moment they exist: ordinary files, never records, never -rewritten by any later command. - -::: test -name: init in an empty directory creates the records skeleton with no head -given: { directory: empty } -expect: { created: [".napl/store"], head: absent, exit_zero: true, workspace_root_reported: true } -::: - -::: test -name: init teaches the first mile with a complete minimal prompt and the command sequence -given: { directory: empty } -expect: { next_steps_prompt_passes_check: true, extension_named: ".napl", sequence_named: [write_prompt, add, commit], prompt_declares_roots: true } +name: a cost report over a receipt set containing verb-staged records includes the edits +given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines } +expect: { report_rows: 2, edit_row_priced: true, same_pricing_table: true } ::: ::: test -name: init scaffolds the agent glue only where absent and clobbers nothing -given: { directory: with_existing_claude_md } -expect: { napl_agents_md_written: true, agents_md_written: true, claude_md_untouched: true, payload_prints_reference_line: true } +name: an edit without a window and a window with missing log lines both price unavailable naming why +given: { workspace: fixture_two_modules, receipts: [edit_without_window, edit_with_window_to_missing_lines] } +expect: { priced_unavailable: 2, reason_named_per_row: true, zeros: 0 } ::: ::: test -name: scaffolded teaching never spells a bare napl q and its query example carries an expression -given: { directory: empty } -expect: { bare_napl_q_absent: true, query_example_has_expression: true } +name: a model absent from the pricing table prices unknown never zero +given: { workspace: fixture_two_modules, receipts: [edit_with_unknown_model_window], log: proxy_log_unknown_model } +expect: { priced_unknown: 1, zeros: 0 } ::: ::: test -name: scaffolded files are inert and later commands never rewrite them -given: { directory: empty, then: [init, write_prompt, add_module, commit] } -expect: { scaffold_bytes_unchanged_after: true } +name: cost reports are byte-identical across runs over the same receipts log and table +given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines, runs: 2 } +expect: { bytes_equal: true } ::: ::: test -name: init where a workspace already exists changes nothing and says so -given: { directory: existing_workspace } -expect: { changed_files: [], outcome: already_initialized, exit_zero: true } +name: cost session reports one session's calls directly from the log +given: { workspace: fixture_two_modules, log: proxy_log_two_sessions, session: s1 } +expect: { rows_for_session: 2, rows_for_other_sessions: 0, priced_from_log: true } ::: ::: test -name: init never overwrites an existing store -given: { directory: existing_workspace_with_commits } -expect: { store_bytes_unchanged: true } +name: a proxy log line failing the shape validates invalid and stays visible as failed +given: { evidence: proxy_log_with_malformed_line, media_type: application_x_napl_usage_proxy_jsonl_v1 } +expect: { outcome: invalid, visible_as_failed: true } ::: -## napl watch - -`napl watch` is an I/O shell around the pure compile derivation: it -observes the workspace's `*.napl` files and re-runs the derivation when -their content changes, emitting each result to stdout as canonical JSON. It publishes nothing: no semantic writes, no store -mutation, no locks held between runs. Change detection is by content -hash, never by timestamp, so an editor save that leaves bytes identical -produces no recompile. A malformed prompt is reported as a diagnostic in -the output stream and the watcher keeps running. Stopping the process is -the only way to end it, and stopping it loses nothing. - ::: test -name: watch re-derives compile output when a prompt changes and writes nothing semantic -given: { workspace: fixture_two_modules, edit: touch_leaf_prompt } -expect: { recompiled: true, semantic_state_published: false } +name: cost over one receipt set and pricing version is byte-identical across runs and unknown for unknown models +given: { receipts: fixture_receipts, pricing_version: pinned, runs: 2, unknown_model_receipt: included } +expect: { reports_equal: true, unknown_model_cost: unknown } ::: ::: test -name: watch survives a malformed prompt edit and reports it instead of dying -given: { workspace: fixture_two_modules, edit: break_leaf_frontmatter } -expect: { process_alive: true, diagnostic_reported: true } +name: gc never deletes an object reachable from the current head and never blocks a concurrent commit +given: { workspace: fixture_two_modules, concurrent: [gc, valid_commit] } +expect: { reachable_deleted: false, commit_blocked: false, unreachable_swept: true } ::: -napl-store receipt v1 815 -{"baseStateKey":"d6fd7c0f72ece55d52229d300030de9f49a3a9908961e50c8af30bb819d0a768","baseStoreSnapshot":"fe951b7bd4a25490821f5174777888dd2c87066733b393bf3009954694f9db0e","baseWorkspaceHash":"3baf3bc6860508a4786c8485e3b68a27f5c83e221a9eb0e175541a425d4a0bae","candidateTreeHash":"ec3719633a6e910e550f100d8393823afd477b930ca45ff7da752a1a944df986","contractVersion":4,"inputKey":"80d6c286c5a0ed57bf1e618663be6a809568faa305d8c6fcba677a9f1ff461d5","module":"commands","outputManifest":[{"hash":"82acfbd2af961a35f71f855f259486c4b37d79987b0fcf83ed5470ec43fcfc46","path":"crates/napl/src/init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","path":"crates/napl/src/watch.rs"}],"receiptVersion":1,"sliceHash":"02e18c381f552fc0fa874c5dcab19c3f41ef8eba1df4a2cd2bb6b594ff6c0670","target":"rust"} -napl-store event v1 158 -{"content":"a8fba66cc1793229e7c9f92be45bb40f49138a4390a4795f4d478e3b33493584","parents":["17ebcc121b23401d4580f3a6a36b49c11ae698c382eaeae3221d4bb5b7a05def"]} -napl-store receipt v1 1280 -{"baseStateKey":"01ca35a882d63c2d2509a7c0890b0c588a743757cc873b5814e57d275dee997e","baseStoreSnapshot":"619895ec62acae41dd1253bea6d7cf73f56ad99bcf9163a2ba130c2806133944","baseWorkspaceHash":"da64f5d7f36eff8fe5c88375df07238b5dd37e691fc9a3f9e364ba5e92db203e","candidateTreeHash":"05f44ac11e9808b57f775c0ffedd617fc59c8a13cef9839ed551a535e36b7148","contractVersion":4,"inputKey":"4ebe5ebb9187aa6b6491ced2de8b175ff38ef22cf378fb2659be1b6d95de170b","module":"query","outputManifest":[{"hash":"deff13703dd0b3c630077edaffafc1fa9f2fdfde297b9a84b45841a428715965","path":"crates/napl/src/check.rs"},{"hash":"7c26384c4adc5b5c857d0811275db450218c8f05177ae35a898ec59a5a5a5e03","path":"crates/napl/src/docs.rs"},{"hash":"726a20b8361501f4c26633cd92835ffb2e5f1acd584c0af76895d29bf620a7b1","path":"crates/napl/src/document.rs"},{"hash":"5ec2c2137f45d02c8e8bf78ecf0652966850772097b41f3835a99116f3b78f57","path":"crates/napl/src/promptbuild.rs"},{"hash":"be629fe4418f4226c3119cf486d936741c11b644241c9c358df2172131c722c1","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"reported":{"evidence":"unavailable","gates":"unavailable","notes":"unavailable","producer":"unavailable","usage":"unavailable"},"sliceHash":"005b93d52f82f204510ec1ede96a5a691e10e210f1b721ddfc24ae04d782903f","target":"rust"} -napl-store snapshot v1 555 -{"contractVersion":3,"event":"80a5ca3cb250b5aefac03a5ef11becc14107d0f2d40459b0bc408394a44b6df0","states":[{"module":"commands","state":"e79a58ac8ad0c50f897901451c0b0b0f44757ab69818fe49282b3d41efd1b1bf","target":"rust"},{"module":"napl","state":"21631447bbac552e78b823a151bc6b9b6132a82db42e636c7befdfc081f3ed2d","target":"rust"},{"module":"napl_store","state":"1243a30383b122b303ec2bae058167b2541d8b9805b3eab569011d2cf15ad607","target":"rust"},{"module":"query","state":"7bfc8437757380e5783a1381f2b78ff6a753c5db50b8afdb02095deca4527799","target":"rust"}]} -napl-store surface v1 82577 -{"contractVersion":4,"items":[{"kind":"struct","path":"attribution::Attribution","signature":"pub struct Attribution"},{"kind":"field","path":"attribution::Attribution.entries","signature":"pub entries : Vec < Entry >"},{"kind":"field","path":"attribution::Attribution.module","signature":"pub module : String"},{"kind":"field","path":"attribution::Attribution.stale","signature":"pub stale : bool"},{"kind":"field","path":"attribution::Attribution.target","signature":"pub target : String"},{"kind":"enum","path":"attribution::Direction","signature":"pub enum Direction { # [ default ] PromptToCode , CodeToPrompt }"},{"kind":"method","path":"attribution::Direction::as_str","signature":"pub fn as_str ( & self ) -> & ' static str"},{"kind":"method","path":"attribution::Direction::from_token","signature":"pub fn from_token ( text : & str ) -> Direction"},{"kind":"const","path":"attribution::ENDPOINTS","signature":"pub const ENDPOINTS : [ & str ; 2 ] = [ \" code \" , \" prompt \" ] ;"},{"kind":"struct","path":"attribution::Entry","signature":"pub struct Entry"},{"kind":"field","path":"attribution::Entry.code_hash","signature":"pub code_hash : Option < String >"},{"kind":"field","path":"attribution::Entry.direction","signature":"pub direction : Direction"},{"kind":"field","path":"attribution::Entry.file","signature":"pub file : Option < String >"},{"kind":"field","path":"attribution::Entry.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"attribution::Entry.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"attribution::Entry.note","signature":"pub note : String"},{"kind":"field","path":"attribution::Entry.prompt_end","signature":"pub prompt_end : u32"},{"kind":"field","path":"attribution::Entry.prompt_hash","signature":"pub prompt_hash : Option < String >"},{"kind":"field","path":"attribution::Entry.prompt_start","signature":"pub prompt_start : u32"},{"kind":"field","path":"attribution::Entry.provenance","signature":"pub provenance : Provenance"},{"kind":"field","path":"attribution::Entry.whole_file","signature":"pub whole_file : bool"},{"kind":"field","path":"attribution::Entry.whole_prompt","signature":"pub whole_prompt : bool"},{"kind":"method","path":"attribution::Entry::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"attribution::Entry::hashes_match","signature":"pub fn hashes_match ( & self , code_bytes : Option < & [ u8 ] > , prompt_body : & str ) -> bool"},{"kind":"method","path":"attribution::Entry::is_law","signature":"pub fn is_law ( & self ) -> bool"},{"kind":"method","path":"attribution::Entry::self_affirming","signature":"pub fn self_affirming ( & self ) -> bool"},{"kind":"struct","path":"attribution::MapParseError","signature":"pub struct MapParseError"},{"kind":"field","path":"attribution::MapParseError.construct","signature":"pub construct : String"},{"kind":"field","path":"attribution::MapParseError.detail","signature":"pub detail : String"},{"kind":"field","path":"attribution::MapParseError.line","signature":"pub line : u32"},{"kind":"struct","path":"attribution::Provenance","signature":"pub struct Provenance"},{"kind":"field","path":"attribution::Provenance.model","signature":"pub model : Option < String >"},{"kind":"field","path":"attribution::Provenance.timestamp","signature":"pub timestamp : Option < String >"},{"kind":"field","path":"attribution::Provenance.tool_version","signature":"pub tool_version : Option < String >"},{"kind":"field","path":"attribution::Provenance.usage","signature":"pub usage : Option < UsageRef >"},{"kind":"method","path":"attribution::Provenance::is_empty","signature":"pub fn is_empty ( & self ) -> bool"},{"kind":"method","path":"attribution::Provenance::served","signature":"pub fn served ( & self ) -> Vec < ( & ' static str , String ) >"},{"kind":"method","path":"attribution::Provenance::served_json","signature":"pub fn served_json ( & self ) -> Value"},{"kind":"struct","path":"attribution::ReferenceStatus","signature":"pub struct ReferenceStatus"},{"kind":"field","path":"attribution::ReferenceStatus.endpoints","signature":"pub endpoints : Vec < & ' static str >"},{"kind":"field","path":"attribution::ReferenceStatus.stale","signature":"pub stale : bool"},{"kind":"method","path":"attribution::ReferenceStatus::as_str","signature":"pub fn as_str ( & self ) -> & ' static str"},{"kind":"const","path":"attribution::UNAVAILABLE","signature":"pub const UNAVAILABLE : & str = \" unavailable \" ;"},{"kind":"enum","path":"attribution::UsageRef","signature":"pub enum UsageRef { Envelope ( String ) , Window ( UsageWindow ) }"},{"kind":"method","path":"attribution::UsageRef::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"attribution::UsageRef::rendered","signature":"pub fn rendered ( & self ) -> String"},{"kind":"struct","path":"attribution::UsageWindow","signature":"pub struct UsageWindow"},{"kind":"field","path":"attribution::UsageWindow.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"attribution::UsageWindow.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"attribution::UsageWindow.log_path","signature":"pub log_path : String"},{"kind":"field","path":"attribution::UsageWindow.media_type","signature":"pub media_type : String"},{"kind":"field","path":"attribution::UsageWindow.session","signature":"pub session : String"},{"kind":"const","path":"attribution::WHOLE","signature":"pub const WHOLE : & str = \" whole \" ;"},{"kind":"fn","path":"attribution::code_drifted","signature":"pub fn code_drifted ( committed_blob : Option < & str > , working_bytes : Option < & [ u8 ] > ) -> bool"},{"kind":"fn","path":"attribution::entry_reference_status","signature":"pub fn entry_reference_status ( entry : & Entry , record_stale : bool , prompt_drifted : bool , code_drifted : bool , code_bytes : Option < & [ u8 ] > , pending_body : & str ) -> ReferenceStatus"},{"kind":"fn","path":"attribution::map_contract","signature":"pub fn map_contract ( ) -> Value"},{"kind":"fn","path":"attribution::parse","signature":"pub fn parse ( text : & str ) -> Option < Attribution >"},{"kind":"fn","path":"attribution::parse_detailed","signature":"pub fn parse_detailed ( text : & str ) -> std :: result :: Result < Attribution , MapParseError >"},{"kind":"fn","path":"attribution::record_hash","signature":"pub fn record_hash ( attribution : & Attribution ) -> String"},{"kind":"fn","path":"attribution::reference_status","signature":"pub fn reference_status ( record_stale : bool , prompt_drifted : bool , code_drifted : bool ) -> ReferenceStatus"},{"kind":"fn","path":"attribution::remap_prefix","signature":"pub fn remap_prefix ( attribution : & mut Attribution , old_root : & str , dest : & str )"},{"kind":"fn","path":"attribution::resolve_whole","signature":"pub fn resolve_whole ( attribution : & mut Attribution , prompt_body_lines : u32 , file_lines : & BTreeMap < String , u32 > )"},{"kind":"fn","path":"attribution::to_yaml","signature":"pub fn to_yaml ( attribution : & Attribution ) -> Vec < u8 >"},{"kind":"fn","path":"attribution::validate","signature":"pub fn validate ( target : & TargetDef , root : & str , module : & str , candidate_files : & BTreeMap < String , Vec < u8 > > , prompt_body_lines : u32 , attribution : & Attribution ) -> Result < ( ) >"},{"kind":"fn","path":"attribution::view_path","signature":"pub fn view_path ( module : & str ) -> String"},{"kind":"fn","path":"attribution::whole_map","signature":"pub fn whole_map ( module : & str , target : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Attribution"},{"kind":"fn","path":"authoring::apply_fix","signature":"pub fn apply_fix ( workspace : & Path , target : & str , finding : & Finding ) -> Result < CommitReport >"},{"kind":"fn","path":"authoring::draft_receipt","signature":"pub fn draft_receipt ( workspace : & Path , target_name : & str , module : & str , candidate_dir : & Path , usage_path : Option < & Path > ) -> Result < Value >"},{"kind":"fn","path":"authoring::draft_receipt_for","signature":"pub fn draft_receipt_for ( workspace : & Path , target_name : & str , module : & str , candidate : & crate :: candidate :: Candidate , usage_path : Option < & Path > ) -> Result < Value >"},{"kind":"struct","path":"blame::BlameLine","signature":"pub struct BlameLine"},{"kind":"field","path":"blame::BlameLine.line","signature":"pub line : usize"},{"kind":"field","path":"blame::BlameLine.text","signature":"pub text : String"},{"kind":"field","path":"blame::BlameLine.transition","signature":"pub transition : String"},{"kind":"struct","path":"blame::Revision","signature":"pub struct Revision"},{"kind":"field","path":"blame::Revision.content","signature":"pub content : String"},{"kind":"field","path":"blame::Revision.transition","signature":"pub transition : String"},{"kind":"fn","path":"blame::blame","signature":"pub fn blame ( revisions : & [ Revision ] ) -> Vec < BlameLine >"},{"kind":"fn","path":"blame::blame_json","signature":"pub fn blame_json ( path : & str , lines : & [ BlameLine ] ) -> Value"},{"kind":"fn","path":"blame::untracked_json","signature":"pub fn untracked_json ( path : & str , reason : & str ) -> Value"},{"kind":"struct","path":"candidate::Candidate","signature":"pub struct Candidate"},{"kind":"field","path":"candidate::Candidate.files","signature":"pub files : BTreeMap < String , Vec < u8 > >"},{"kind":"field","path":"candidate::Candidate.manifest","signature":"pub manifest : BTreeMap < String , String >"},{"kind":"field","path":"candidate::Candidate.tree_hash","signature":"pub tree_hash : String"},{"kind":"field","path":"candidate::Candidate.tree_objects","signature":"pub tree_objects : BTreeMap < String , Tree >"},{"kind":"enum","path":"candidate::EntryKind","signature":"pub enum EntryKind { File , Link , Device }"},{"kind":"struct","path":"candidate::Limits","signature":"pub struct Limits"},{"kind":"field","path":"candidate::Limits.max_entries","signature":"pub max_entries : usize"},{"kind":"field","path":"candidate::Limits.max_entry_bytes","signature":"pub max_entry_bytes : usize"},{"kind":"field","path":"candidate::Limits.max_total_bytes","signature":"pub max_total_bytes : usize"},{"kind":"struct","path":"candidate::ScannedEntry","signature":"pub struct ScannedEntry"},{"kind":"field","path":"candidate::ScannedEntry.bytes","signature":"pub bytes : Vec < u8 >"},{"kind":"field","path":"candidate::ScannedEntry.kind","signature":"pub kind : EntryKind"},{"kind":"field","path":"candidate::ScannedEntry.path","signature":"pub path : String"},{"kind":"fn","path":"candidate::default_limits","signature":"pub fn default_limits ( ) -> Limits"},{"kind":"fn","path":"candidate::ingest","signature":"pub fn ingest ( entries : & [ ScannedEntry ] , limits : Limits ) -> Result < Candidate >"},{"kind":"fn","path":"canon::canonical_bytes","signature":"pub fn canonical_bytes ( value : & Value ) -> Vec < u8 >"},{"kind":"fn","path":"canon::hash_json","signature":"pub fn hash_json ( kind : ObjectKind , value : & Value ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"canon::is_object_name","signature":"pub fn is_object_name ( text : & str ) -> bool"},{"kind":"fn","path":"canon::json_hash","signature":"pub fn json_hash ( kind : ObjectKind , value : & Value ) -> String"},{"kind":"fn","path":"canon::parse_json","signature":"pub fn parse_json ( bytes : & [ u8 ] , context : & str , code : OutcomeCode ) -> Result < Value >"},{"kind":"const","path":"capture::SESSION_VARIABLE","signature":"pub const SESSION_VARIABLE : & str = \" NAPL_SESSION \" ;"},{"kind":"const","path":"capture::USAGE_LOG_MEDIA_TYPE","signature":"pub const USAGE_LOG_MEDIA_TYPE : & str = \" application/x-napl-usage-proxy-jsonl-v1 \" ;"},{"kind":"const","path":"capture::USAGE_LOG_PATH","signature":"pub const USAGE_LOG_PATH : & str = \" .napl/usage-proxy.jsonl \" ;"},{"kind":"fn","path":"capture::edit_bytes","signature":"pub fn edit_bytes ( workspace : & Path , path : & str , old : & str , new : & str , replace_all : bool , reference : Option < & str > , session : Option < & str > ) -> Result < CliResult >"},{"kind":"fn","path":"capture::ref_contract","signature":"pub fn ref_contract ( ) -> Value"},{"kind":"fn","path":"capture::write_bytes","signature":"pub fn write_bytes ( workspace : & Path , path : & str , bytes : & [ u8 ] , reference : Option < & str > , session : Option < & str > ) -> Result < CliResult >"},{"kind":"const","path":"cli::BINARY_USAGE","signature":"pub const BINARY_USAGE : & str = \" napl [arguments] \" ;"},{"kind":"struct","path":"cli::CliResult","signature":"pub struct CliResult"},{"kind":"field","path":"cli::CliResult.exit_code","signature":"pub exit_code : i32"},{"kind":"field","path":"cli::CliResult.stdout","signature":"pub stdout : Vec < u8 >"},{"kind":"struct","path":"cli::CommandSpec","signature":"pub struct CommandSpec"},{"kind":"field","path":"cli::CommandSpec.example","signature":"pub example : & ' static str"},{"kind":"field","path":"cli::CommandSpec.flags","signature":"pub flags : & ' static [ FlagSpec ]"},{"kind":"field","path":"cli::CommandSpec.name","signature":"pub name : & ' static str"},{"kind":"field","path":"cli::CommandSpec.passthrough","signature":"pub passthrough : bool"},{"kind":"field","path":"cli::CommandSpec.run","signature":"pub run : fn ( & Path , & [ String ] ) -> Result < CliResult >"},{"kind":"field","path":"cli::CommandSpec.summary","signature":"pub summary : & ' static str"},{"kind":"field","path":"cli::CommandSpec.usage","signature":"pub usage : & ' static str"},{"kind":"enum","path":"cli::FlagArity","signature":"pub enum FlagArity { Switch , Value }"},{"kind":"struct","path":"cli::FlagSpec","signature":"pub struct FlagSpec"},{"kind":"field","path":"cli::FlagSpec.arity","signature":"pub arity : FlagArity"},{"kind":"field","path":"cli::FlagSpec.meaning","signature":"pub meaning : & ' static str"},{"kind":"field","path":"cli::FlagSpec.name","signature":"pub name : & ' static str"},{"kind":"field","path":"cli::FlagSpec.short","signature":"pub short : Option < & ' static str >"},{"kind":"const","path":"cli::HELP_FLAGS","signature":"pub const HELP_FLAGS : [ & str ; 2 ] = [ \" --help \" , \" -h \" ] ;"},{"kind":"const","path":"cli::HUMAN_FLAG","signature":"pub const HUMAN_FLAG : & str = \" --human \" ;"},{"kind":"const","path":"cli::JSON_FLAG","signature":"pub const JSON_FLAG : & str = \" --json \" ;"},{"kind":"const","path":"cli::USAGE_EXIT_CODE","signature":"pub const USAGE_EXIT_CODE : i32 = 2 ;"},{"kind":"const","path":"cli::WORKSPACE_FLAG","signature":"pub const WORKSPACE_FLAG : FlagSpec = value ( \" --workspace \" , \" the workspace root to operate on, instead of discovering it from the current directory \" ) ;"},{"kind":"fn","path":"cli::check_invocation","signature":"pub fn check_invocation ( line : & str ) -> std :: result :: Result < ( ) , String >"},{"kind":"fn","path":"cli::clap_command_for","signature":"pub fn clap_command_for ( spec : & CommandSpec ) -> clap :: Command"},{"kind":"fn","path":"cli::clap_root","signature":"pub fn clap_root ( registry : & [ CommandSpec ] ) -> clap :: Command"},{"kind":"fn","path":"cli::emit_prerequisite","signature":"pub fn emit_prerequisite ( command : & str ) -> CliResult"},{"kind":"fn","path":"cli::help_all_json","signature":"pub fn help_all_json ( registry : & [ CommandSpec ] ) -> Value"},{"kind":"fn","path":"cli::help_command_json","signature":"pub fn help_command_json ( spec : & CommandSpec ) -> Value"},{"kind":"fn","path":"cli::prerequisite_json","signature":"pub fn prerequisite_json ( command : & str ) -> Value"},{"kind":"fn","path":"cli::render_help_all","signature":"pub fn render_help_all ( registry : & [ CommandSpec ] ) -> String"},{"kind":"fn","path":"cli::render_help_command","signature":"pub fn render_help_command ( spec : & CommandSpec ) -> String"},{"kind":"fn","path":"cli::run_cli","signature":"pub fn run_cli ( cwd : & Path , args : & [ String ] ) -> CliResult"},{"kind":"fn","path":"cli::switch","signature":"pub const fn switch ( name : & ' static str , meaning : & ' static str ) -> FlagSpec"},{"kind":"fn","path":"cli::switch_short","signature":"pub const fn switch_short ( name : & ' static str , short : & ' static str , meaning : & ' static str ) -> FlagSpec"},{"kind":"fn","path":"cli::usage_json","signature":"pub fn usage_json ( spec : Option < & CommandSpec > , detail : & str , unknown : Option < & str > , suggestion : Option < & str > ) -> Value"},{"kind":"fn","path":"cli::value","signature":"pub const fn value ( name : & ' static str , meaning : & ' static str ) -> FlagSpec"},{"kind":"const","path":"commandset::COMPLETION_SHELLS","signature":"pub const COMPLETION_SHELLS : [ & str ; 3 ] = [ \" bash \" , \" zsh \" , \" fish \" ] ;"},{"kind":"fn","path":"commandset::commands","signature":"pub fn commands ( ) -> & ' static [ CommandSpec ]"},{"kind":"enum","path":"commit::AttributionMode","signature":"pub enum AttributionMode { Prove , PreProven , Escape }"},{"kind":"struct","path":"commit::CommitReport","signature":"pub struct CommitReport"},{"kind":"field","path":"commit::CommitReport.outcome","signature":"pub outcome : OutcomeCode"},{"kind":"field","path":"commit::CommitReport.payload","signature":"pub payload : Value"},{"kind":"fn","path":"commit::commit","signature":"pub fn commit ( workspace : & Path , receipt_path : & Path , candidate_dir : & Path , attribution_path : Option < & Path > , no_verify : bool ) -> CommitReport"},{"kind":"fn","path":"commit::commit_docs","signature":"pub fn commit_docs ( workspace : & Path , record : & Path ) -> CommitReport"},{"kind":"fn","path":"commit::commit_with_mode","signature":"pub fn commit_with_mode ( workspace : & Path , receipt_path : & Path , candidate_dir : & Path , attribution_path : Option < & Path > , mode : AttributionMode , staged_prompt : Option < & Path > ) -> CommitReport"},{"kind":"fn","path":"commit::lock_key","signature":"pub fn lock_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"commit::recover","signature":"pub fn recover ( store : & Store ) -> Result < Vec < String > >"},{"kind":"const","path":"compile::CONTRACT_VERSION_CHANGED","signature":"pub const CONTRACT_VERSION_CHANGED : & str = \" contract-version-changed \" ;"},{"kind":"struct","path":"compile::CompileArtifact","signature":"pub struct CompileArtifact"},{"kind":"field","path":"compile::CompileArtifact.artifact_hash","signature":"pub artifact_hash : String"},{"kind":"field","path":"compile::CompileArtifact.slices","signature":"pub slices : Vec < CompileSlice >"},{"kind":"field","path":"compile::CompileArtifact.value","signature":"pub value : Value"},{"kind":"const","path":"compile::DEPENDENCY_SURFACE_MOVED","signature":"pub const DEPENDENCY_SURFACE_MOVED : & str = \" dependency-surface-moved \" ;"},{"kind":"const","path":"compile::NEVER_COMMITTED","signature":"pub const NEVER_COMMITTED : & str = \" never-committed \" ;"},{"kind":"const","path":"compile::PLACEMENT_CHANGED","signature":"pub const PLACEMENT_CHANGED : & str = \" placement-changed \" ;"},{"kind":"const","path":"compile::SOURCE_CHANGED","signature":"pub const SOURCE_CHANGED : & str = \" source-changed \" ;"},{"kind":"const","path":"compile::TARGET_CHANGED","signature":"pub const TARGET_CHANGED : & str = \" target-changed \" ;"},{"kind":"struct","path":"compile::Verdict","signature":"pub struct Verdict"},{"kind":"field","path":"compile::Verdict.reasons","signature":"pub reasons : Vec < & ' static str >"},{"kind":"field","path":"compile::Verdict.reland_identical_bytes","signature":"pub reland_identical_bytes : bool"},{"kind":"method","path":"compile::Verdict::json_fields","signature":"pub fn json_fields ( & self , pending : bool ) -> ( Value , Value , Value )"},{"kind":"method","path":"compile::Verdict::reason","signature":"pub fn reason ( & self ) -> Option < & ' static str >"},{"kind":"fn","path":"compile::base_output_files","signature":"pub fn base_output_files ( source : & dyn ObjectSource , state : & ModuleState ) -> Result < Vec < ( String , String ) > >"},{"kind":"fn","path":"compile::compile","signature":"pub fn compile ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , only_module : Option < & str > ) -> Result < CompileArtifact >"},{"kind":"fn","path":"compile::graph_for","signature":"pub fn graph_for ( sources : & [ SourceModule ] , target_name : & str ) -> Result < Graph >"},{"kind":"fn","path":"compile::head_states","signature":"pub fn head_states ( source : & dyn ObjectSource , snapshot : Option < & str > ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"compile::install_index","signature":"pub fn install_index ( source : & dyn ObjectSource , head : Option < & str > ) -> Result < BTreeMap < String , Value > >"},{"kind":"fn","path":"compile::read_surface_items","signature":"pub fn read_surface_items ( source : & dyn ObjectSource , hash : & str ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"compile::slice_at","signature":"pub fn slice_at ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target_name : & str , module : & str , snapshot : Option < & str > ) -> Result < CompileSlice >"},{"kind":"fn","path":"compile::state_of","signature":"pub fn state_of ( source : & dyn ObjectSource , states : & BTreeMap < ( String , String ) , String > , target : & TargetDef , module : & str ) -> Result < ( ModuleState , String ) >"},{"kind":"fn","path":"compile::verdict","signature":"pub fn verdict ( source : & dyn ObjectSource , sources : & [ SourceModule ] , target : & TargetDef , module : & str , states : & BTreeMap < ( String , String ) , String > , installs : & BTreeMap < String , Value > , frontier : & BTreeSet < String > , drift : bool ) -> Result < Verdict >"},{"kind":"fn","path":"compile::vocabulary","signature":"pub fn vocabulary ( ) -> Vec < & ' static str >"},{"kind":"fn","path":"compile::workspace_source_hash","signature":"pub fn workspace_source_hash ( sources : & [ SourceModule ] ) -> Result < String >"},{"kind":"struct","path":"cost::CostLine","signature":"pub struct CostLine"},{"kind":"field","path":"cost::CostLine.cost_usd","signature":"pub cost_usd : Option < String >"},{"kind":"field","path":"cost::CostLine.model","signature":"pub model : Option < String >"},{"kind":"field","path":"cost::CostLine.module","signature":"pub module : String"},{"kind":"field","path":"cost::CostLine.reason","signature":"pub reason : Option < String >"},{"kind":"field","path":"cost::CostLine.receipt","signature":"pub receipt : String"},{"kind":"field","path":"cost::CostLine.tokens","signature":"pub tokens : Option < TokenUsage >"},{"kind":"const","path":"cost::UNKNOWN","signature":"pub const UNKNOWN : & str = \" unknown \" ;"},{"kind":"struct","path":"cost::WindowRef","signature":"pub struct WindowRef"},{"kind":"field","path":"cost::WindowRef.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"cost::WindowRef.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"cost::WindowRef.log_path","signature":"pub log_path : String"},{"kind":"field","path":"cost::WindowRef.media_type","signature":"pub media_type : String"},{"kind":"field","path":"cost::WindowRef.session","signature":"pub session : String"},{"kind":"fn","path":"cost::cost_report","signature":"pub fn cost_report ( lines : & [ CostLine ] ) -> Value"},{"kind":"fn","path":"cost::line_from_reported","signature":"pub fn line_from_reported ( receipt : & str , module : & str , reported : & Value ) -> Result < CostLine >"},{"kind":"fn","path":"cost::line_from_window","signature":"pub fn line_from_window ( receipt : & str , module : & str , window : & WindowRef , log : Option < & str > ) -> CostLine"},{"kind":"fn","path":"cost::lines_from_session","signature":"pub fn lines_from_session ( log : & str , session : & str ) -> Vec < CostLine >"},{"kind":"fn","path":"cost::pricing_provenance","signature":"pub fn pricing_provenance ( ) -> Value"},{"kind":"fn","path":"cost::window_from_reported","signature":"pub fn window_from_reported ( reported : & Value ) -> Option < WindowRef >"},{"kind":"struct","path":"decision::Arriving","signature":"pub struct Arriving"},{"kind":"field","path":"decision::Arriving.base_snapshot","signature":"pub base_snapshot : Option < String >"},{"kind":"field","path":"decision::Arriving.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Arriving.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Arriving.write_set","signature":"pub write_set : BTreeSet < String >"},{"kind":"enum","path":"decision::Decision","signature":"pub enum Decision { Install , Rebase , StaleInput { expected : String , found : String } , BaseOutputChanged { expected : String , found : String } , WriteConflict { overlap : Vec < String > } }"},{"kind":"struct","path":"decision::Head","signature":"pub struct Head"},{"kind":"field","path":"decision::Head.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"decision::Head.input_key","signature":"pub input_key : String"},{"kind":"field","path":"decision::Head.intervening_write_set","signature":"pub intervening_write_set : BTreeSet < String >"},{"kind":"field","path":"decision::Head.snapshot","signature":"pub snapshot : Option < String >"},{"kind":"fn","path":"decision::conflicts","signature":"pub fn conflicts ( write_set_a : & BTreeSet < String > , read_set_a : & BTreeSet < String > , write_set_b : & BTreeSet < String > , changed_identities_b : & BTreeSet < String > ) -> bool"},{"kind":"fn","path":"decision::decide","signature":"pub fn decide ( arriving : & Arriving , head : & Head ) -> Decision"},{"kind":"fn","path":"decision::is_accepted","signature":"pub fn is_accepted ( decision : & Decision ) -> bool"},{"kind":"fn","path":"decision::rejection","signature":"pub fn rejection ( decision : & Decision ) -> Option < CompilerError >"},{"kind":"struct","path":"directive::BodySplit","signature":"pub struct BodySplit"},{"kind":"field","path":"directive::BodySplit.docs","signature":"pub docs : Vec < DocsFence >"},{"kind":"field","path":"directive::BodySplit.semantic","signature":"pub semantic : String"},{"kind":"const","path":"directive::DOCS_DIRECTIVES","signature":"pub const DOCS_DIRECTIVES : [ & str ; 4 ] = [ \" docs \" , \" example \" , \" note \" , \" warning \" ] ;"},{"kind":"struct","path":"directive::DirectiveCase","signature":"pub struct DirectiveCase"},{"kind":"field","path":"directive::DirectiveCase.expect","signature":"pub expect : Value"},{"kind":"field","path":"directive::DirectiveCase.given","signature":"pub given : Value"},{"kind":"field","path":"directive::DirectiveCase.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"directive::DirectiveCase.line_start","signature":"pub line_start : u32"},{"kind":"field","path":"directive::DirectiveCase.name","signature":"pub name : String"},{"kind":"struct","path":"directive::DocsFence","signature":"pub struct DocsFence"},{"kind":"field","path":"directive::DocsFence.anchor","signature":"pub anchor : u32"},{"kind":"field","path":"directive::DocsFence.attrs","signature":"pub attrs : Value"},{"kind":"field","path":"directive::DocsFence.body","signature":"pub body : String"},{"kind":"field","path":"directive::DocsFence.directive","signature":"pub directive : String"},{"kind":"field","path":"directive::DocsFence.line_end","signature":"pub line_end : u32"},{"kind":"field","path":"directive::DocsFence.line_start","signature":"pub line_start : u32"},{"kind":"const","path":"directive::EXTENSION_PREFIX","signature":"pub const EXTENSION_PREFIX : & str = \" x- \" ;"},{"kind":"fn","path":"directive::extract_cases","signature":"pub fn extract_cases ( normalized_body : & str ) -> Result < Vec < DirectiveCase > >"},{"kind":"fn","path":"directive::extract_validated","signature":"pub fn extract_validated ( normalized_body : & str , frontmatter_names : & [ String ] ) -> Result < Vec < DirectiveCase > >"},{"kind":"fn","path":"directive::is_docs_directive","signature":"pub fn is_docs_directive ( name : & str ) -> bool"},{"kind":"fn","path":"directive::normalize_case_name","signature":"pub fn normalize_case_name ( name : & str ) -> String"},{"kind":"fn","path":"directive::parse_inline_value","signature":"pub fn parse_inline_value ( text : & str ) -> Option < Value >"},{"kind":"fn","path":"directive::split_docs","signature":"pub fn split_docs ( normalized_body : & str ) -> Result < BodySplit >"},{"kind":"const","path":"disk::POINTER_LOCK","signature":"pub const POINTER_LOCK : & str = \" pointer \" ;"},{"kind":"const","path":"disk::STORE_DIR","signature":"pub const STORE_DIR : & str = \" .napl/store \" ;"},{"kind":"struct","path":"disk::Store","signature":"pub struct Store"},{"kind":"field","path":"disk::Store.root","signature":"pub root : PathBuf"},{"kind":"field","path":"disk::Store.workspace","signature":"pub workspace : PathBuf"},{"kind":"fn","path":"disk::compare_and_set_head","signature":"pub fn compare_and_set_head ( store : & Store , expected : Option < & str > , next : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::delete_marker","signature":"pub fn delete_marker ( store : & Store , snapshot : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::init","signature":"pub fn init ( workspace : & Path ) -> Result < Store >"},{"kind":"fn","path":"disk::io","signature":"pub fn io ( path : & Path , err : & std :: io :: Error ) -> CompilerError"},{"kind":"fn","path":"disk::loose_object_count","signature":"pub fn loose_object_count ( store : & Store ) -> Result < usize >"},{"kind":"fn","path":"disk::marker_snapshots","signature":"pub fn marker_snapshots ( store : & Store ) -> Result < Vec < String > >"},{"kind":"fn","path":"disk::open","signature":"pub fn open ( workspace : & Path ) -> Store"},{"kind":"fn","path":"disk::plan_repack","signature":"pub fn plan_repack ( store : & Store , reachable : & BTreeSet < String > ) -> Result < RepackOutcome >"},{"kind":"fn","path":"disk::publish","signature":"pub fn publish ( store : & Store , kind : ObjectKind , payload : & [ u8 ] , owner : & str ) -> Result < String >"},{"kind":"fn","path":"disk::read_marker","signature":"pub fn read_marker ( store : & Store , snapshot : & str ) -> Result < Option < CommitMarker > >"},{"kind":"fn","path":"disk::release_lock","signature":"pub fn release_lock ( guard : LockGuard ) -> Result < ( ) >"},{"kind":"fn","path":"disk::remove_object","signature":"pub fn remove_object ( store : & Store , name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"disk::repack","signature":"pub fn repack ( store : & Store , reachable : & BTreeSet < String > , owner : & str ) -> Result < RepackOutcome >"},{"kind":"fn","path":"disk::take_lock","signature":"pub fn take_lock ( store : & Store , name : & str ) -> Result < LockGuard >"},{"kind":"fn","path":"disk::verify_packs","signature":"pub fn verify_packs ( store : & Store ) -> Result < Vec < PackFinding > >"},{"kind":"fn","path":"disk::write_marker","signature":"pub fn write_marker ( store : & Store , marker : & CommitMarker ) -> Result < ( ) >"},{"kind":"struct","path":"error::CompilerError","signature":"pub struct CompilerError"},{"kind":"field","path":"error::CompilerError.code","signature":"pub code : OutcomeCode"},{"kind":"field","path":"error::CompilerError.data","signature":"pub data : Option < Value >"},{"kind":"field","path":"error::CompilerError.detail","signature":"pub detail : String"},{"kind":"enum","path":"error::OutcomeCode","signature":"pub enum OutcomeCode { Success , Usage , StaleInput , BaseOutputChanged , WriteConflict , LockContended , InvalidCandidate , InvalidReceipt , MappingIntegrity , ResourceLimit , UnsupportedVersion , StoreIntegrity , IoError , EditMismatch , ReferenceUnresolvable , PromptInvalid }"},{"kind":"type","path":"error::Result","signature":"pub type Result < T > = std :: result :: Result < T , CompilerError > ;"},{"kind":"fn","path":"error::error","signature":"pub fn error ( code : OutcomeCode , detail : impl Into < String > ) -> CompilerError"},{"kind":"fn","path":"error::error_payload","signature":"pub fn error_payload ( err : & CompilerError , blobs_published : bool , provenance_published : bool ) -> Value"},{"kind":"fn","path":"error::error_with","signature":"pub fn error_with ( code : OutcomeCode , detail : impl Into < String > , data : Value ) -> CompilerError"},{"kind":"fn","path":"error::outcome_exit_code","signature":"pub fn outcome_exit_code ( code : OutcomeCode ) -> i32"},{"kind":"fn","path":"error::outcome_is_retryable","signature":"pub fn outcome_is_retryable ( code : OutcomeCode ) -> bool"},{"kind":"fn","path":"error::outcome_slug","signature":"pub fn outcome_slug ( code : OutcomeCode ) -> & ' static str"},{"kind":"const","path":"evidence::CLAUDE_CLI_JSON","signature":"pub const CLAUDE_CLI_JSON : & str = \" application/vnd.claude-cli+json \" ;"},{"kind":"struct","path":"evidence::EvidenceItem","signature":"pub struct EvidenceItem"},{"kind":"field","path":"evidence::EvidenceItem.body","signature":"pub body : String"},{"kind":"field","path":"evidence::EvidenceItem.media_type","signature":"pub media_type : String"},{"kind":"enum","path":"evidence::EvidenceOutcome","signature":"pub enum EvidenceOutcome { Validated , Unsupported , Invalid , ValidatorError }"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEMS","signature":"pub const MAX_EVIDENCE_ITEMS : usize = 8 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_ITEM_BYTES","signature":"pub const MAX_EVIDENCE_ITEM_BYTES : usize = 64 * 1024 ;"},{"kind":"const","path":"evidence::MAX_EVIDENCE_TOTAL_BYTES","signature":"pub const MAX_EVIDENCE_TOTAL_BYTES : usize = 256 * 1024 ;"},{"kind":"const","path":"evidence::USAGE_PROXY_JSONL","signature":"pub const USAGE_PROXY_JSONL : & str = \" application/x-napl-usage-proxy-jsonl-v1 \" ;"},{"kind":"fn","path":"evidence::check_evidence_bounds","signature":"pub fn check_evidence_bounds ( items : & [ EvidenceItem ] ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::check_reported_bounds","signature":"pub fn check_reported_bounds ( reported : & Value , limit : usize ) -> Result < ( ) >"},{"kind":"fn","path":"evidence::evidence_report","signature":"pub fn evidence_report ( items : & [ ( EvidenceItem , EvidenceOutcome ) ] ) -> Value"},{"kind":"fn","path":"evidence::evidence_slug","signature":"pub fn evidence_slug ( outcome : EvidenceOutcome ) -> & ' static str"},{"kind":"fn","path":"evidence::parse_evidence","signature":"pub fn parse_evidence ( reported : & Value ) -> Result < Vec < EvidenceItem > >"},{"kind":"fn","path":"evidence::registered_media_types","signature":"pub fn registered_media_types ( ) -> Vec < String >"},{"kind":"fn","path":"evidence::validate_all","signature":"pub fn validate_all ( items : & [ EvidenceItem ] ) -> Vec < ( EvidenceItem , EvidenceOutcome ) >"},{"kind":"fn","path":"evidence::validate_evidence","signature":"pub fn validate_evidence ( item : & EvidenceItem ) -> EvidenceOutcome"},{"kind":"const","path":"git::LEGACY_STORE_REF","signature":"pub const LEGACY_STORE_REF : & str = \" refs/heads/napl-store \" ;"},{"kind":"const","path":"git::LEGACY_STORE_REF_COMMANDS","signature":"pub const LEGACY_STORE_REF_COMMANDS : & [ & str ] = & [ \" git archive refs/heads/napl-store | tar -x -C .napl/store/objects \" , \" git add .napl/store/objects && git commit -m 'the store comes home' \" , \" git branch -D napl-store && git push origin --delete napl-store \" ] ;"},{"kind":"const","path":"git::LEGACY_STORE_REF_RECIPE","signature":"pub const LEGACY_STORE_REF_RECIPE : & str = \" check out the store branch's tree into .napl/store/objects/, commit it on the code branch, and delete the ref locally and on every remote \" ;"},{"kind":"fn","path":"git::common_git_dir","signature":"pub fn common_git_dir ( start : & Path ) -> Option < PathBuf >"},{"kind":"fn","path":"git::enclosing_repository","signature":"pub fn enclosing_repository ( start : & Path ) -> Option < PathBuf >"},{"kind":"fn","path":"git::guard_legacy_store_ref","signature":"pub fn guard_legacy_store_ref ( workspace : & Path ) -> Result < ( ) >"},{"kind":"fn","path":"git::has_legacy_store_ref","signature":"pub fn has_legacy_store_ref ( workspace : & Path ) -> bool"},{"kind":"fn","path":"git::legacy_store_ref_refusal","signature":"pub fn legacy_store_ref_refusal ( ) -> CompilerError"},{"kind":"type","path":"graph::Graph","signature":"pub type Graph = BTreeMap < String , SourceModule > ;"},{"kind":"fn","path":"graph::build_graph","signature":"pub fn build_graph ( sources : & [ SourceModule ] , target : & TargetDef ) -> Result < Graph >"},{"kind":"fn","path":"graph::changed_frontier","signature":"pub fn changed_frontier ( graph : & Graph , committed_input_keys : & BTreeMap < String , Option < String > > , input_keys : & BTreeMap < String , String > , only_module : Option < & str > ) -> Result < Vec < String > >"},{"kind":"fn","path":"graph::dependents_of","signature":"pub fn dependents_of ( graph : & Graph , module : & str ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::index_sources","signature":"pub fn index_sources ( sources : & [ SourceModule ] ) -> Result < BTreeMap < String , SourceModule > >"},{"kind":"fn","path":"graph::reachable_from","signature":"pub fn reachable_from ( graph : & Graph , roots : & [ String ] ) -> BTreeSet < String >"},{"kind":"fn","path":"graph::topological_levels","signature":"pub fn topological_levels ( graph : & Graph ) -> Result < BTreeMap < String , usize > >"},{"kind":"enum","path":"lint::Analysis","signature":"pub enum Analysis { Performed , Refused }"},{"kind":"enum","path":"lint::Finding","signature":"pub enum Finding { UnreachableModule { module : String , cascade : Vec < String > } , UnconsumedSurfaceEntry { module : String , path : String } , StaleModule { module : String } , UnusedDependency { module : String , dep : String } }"},{"kind":"const","path":"lint::GRAPH_SCOPE","signature":"pub const GRAPH_SCOPE : & str = \" graph \" ;"},{"kind":"struct","path":"lint::LintInput","signature":"pub struct LintInput"},{"kind":"field","path":"lint::LintInput.committed_input_keys","signature":"pub committed_input_keys : BTreeMap < String , Option < String > >"},{"kind":"field","path":"lint::LintInput.current_input_keys","signature":"pub current_input_keys : BTreeMap < String , String >"},{"kind":"field","path":"lint::LintInput.referenced_names","signature":"pub referenced_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"field","path":"lint::LintInput.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"lint::LintInput.surface_names","signature":"pub surface_names : BTreeMap < String , BTreeSet < String > >"},{"kind":"struct","path":"lint::LintReport","signature":"pub struct LintReport"},{"kind":"field","path":"lint::LintReport.findings","signature":"pub findings : Vec < Finding >"},{"kind":"field","path":"lint::LintReport.unreachable_analysis","signature":"pub unreachable_analysis : Analysis"},{"kind":"const","path":"lint::ROOTS_DECLARATION","signature":"pub const ROOTS_DECLARATION : & str = \" roots: [, ...] in the frontmatter of a prompt in this workspace \" ;"},{"kind":"fn","path":"lint::finding_json","signature":"pub fn finding_json ( finding : & Finding ) -> Value"},{"kind":"fn","path":"lint::is_auto_fixable","signature":"pub fn is_auto_fixable ( finding : & Finding ) -> bool"},{"kind":"fn","path":"lint::lint","signature":"pub fn lint ( graph : & Graph , input : & LintInput ) -> LintReport"},{"kind":"fn","path":"lint::lint_json","signature":"pub fn lint_json ( report : & LintReport ) -> Value"},{"kind":"fn","path":"lint::refusal_json","signature":"pub fn refusal_json ( source_scan : Value ) -> Value"},{"kind":"fn","path":"lsp::serve","signature":"pub fn serve < R : BufRead , W : Write > ( reader : & mut R , writer : & mut W )"},{"kind":"struct","path":"maintenance::FileFact","signature":"pub struct FileFact"},{"kind":"field","path":"maintenance::FileFact.hash","signature":"pub hash : String"},{"kind":"field","path":"maintenance::FileFact.path","signature":"pub path : String"},{"kind":"enum","path":"maintenance::FsckFinding","signature":"pub enum FsckFinding { HashMismatch { object : String , detail : String } , MissingParent { event : String , parent : String } , MissingObject { from : String , missing : String } , PackDamaged { pack : String , detail : String } }"},{"kind":"const","path":"maintenance::LOOSE_OBJECT_REPACK_THRESHOLD","signature":"pub const LOOSE_OBJECT_REPACK_THRESHOLD : usize = 1024 ;"},{"kind":"enum","path":"maintenance::RestorePlan","signature":"pub enum RestorePlan { Apply ( Vec < RestoreStep > ) , Blocked { path : String , candidates : Vec < String > } }"},{"kind":"enum","path":"maintenance::RestoreStep","signature":"pub enum RestoreStep { Move { from : String , to : String } , Write { path : String , hash : String } , Remove { path : String } }"},{"kind":"fn","path":"maintenance::fsck","signature":"pub fn fsck ( source : & dyn ObjectSource ) -> Result < Vec < FsckFinding > >"},{"kind":"fn","path":"maintenance::fsck_json","signature":"pub fn fsck_json ( findings : & [ FsckFinding ] ) -> Value"},{"kind":"fn","path":"maintenance::gc_json","signature":"pub fn gc_json ( swept : & [ String ] , kept : usize ) -> Value"},{"kind":"fn","path":"maintenance::plan_restore","signature":"pub fn plan_restore ( expected : & [ FileFact ] , observed : & [ FileFact ] ) -> RestorePlan"},{"kind":"fn","path":"maintenance::plan_sweep","signature":"pub fn plan_sweep ( all : & BTreeSet < String > , reachable : & BTreeSet < String > ) -> Vec < String >"},{"kind":"fn","path":"maintenance::reachable_from_head","signature":"pub fn reachable_from_head ( source : & dyn ObjectSource , head : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"maintenance::repack_json","signature":"pub fn repack_json ( outcome : & napl_store :: store_io :: RepackOutcome ) -> Value"},{"kind":"fn","path":"maintenance::restore_json","signature":"pub fn restore_json ( plan : & RestorePlan ) -> Value"},{"kind":"fn","path":"mv::relative_dir","signature":"pub fn relative_dir ( from : & str , to : & str ) -> String"},{"kind":"fn","path":"mv::relocate_files","signature":"pub fn relocate_files ( files : & BTreeMap < String , Vec < u8 > > , old_root : & str , dest : & str ) -> Result < BTreeMap < String , Vec < u8 > > >"},{"kind":"fn","path":"mv::relocate_module","signature":"pub fn relocate_module ( workspace : & Path , target_name : & str , module : & str , dest : & str ) -> CommitReport"},{"kind":"struct","path":"objects::AttributionState","signature":"pub struct AttributionState"},{"kind":"field","path":"objects::AttributionState.prompt_hash","signature":"pub prompt_hash : String"},{"kind":"field","path":"objects::AttributionState.record","signature":"pub record : String"},{"kind":"field","path":"objects::AttributionState.stale","signature":"pub stale : bool"},{"kind":"const","path":"objects::DOCS_ONLY","signature":"pub const DOCS_ONLY : & str = \" docs-only \" ;"},{"kind":"struct","path":"objects::Footprint","signature":"pub struct Footprint"},{"kind":"field","path":"objects::Footprint.derived_projections","signature":"pub derived_projections : Vec < String >"},{"kind":"field","path":"objects::Footprint.install_paths","signature":"pub install_paths : Vec < String >"},{"kind":"field","path":"objects::Footprint.read_set","signature":"pub read_set : Vec < String >"},{"kind":"field","path":"objects::Footprint.write_set","signature":"pub write_set : Vec < String >"},{"kind":"struct","path":"objects::ModuleState","signature":"pub struct ModuleState"},{"kind":"field","path":"objects::ModuleState.attribution","signature":"pub attribution : Option < AttributionState >"},{"kind":"field","path":"objects::ModuleState.input_key","signature":"pub input_key : Option < String >"},{"kind":"field","path":"objects::ModuleState.module","signature":"pub module : String"},{"kind":"field","path":"objects::ModuleState.output_tree","signature":"pub output_tree : String"},{"kind":"field","path":"objects::ModuleState.prompt","signature":"pub prompt : Option < String >"},{"kind":"field","path":"objects::ModuleState.slice_hash","signature":"pub slice_hash : Option < String >"},{"kind":"field","path":"objects::ModuleState.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"objects::ModuleState.target","signature":"pub target : String"},{"kind":"struct","path":"objects::Transition","signature":"pub struct Transition"},{"kind":"field","path":"objects::Transition.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"objects::Transition.footprint","signature":"pub footprint : Footprint"},{"kind":"field","path":"objects::Transition.input_key","signature":"pub input_key : String"},{"kind":"field","path":"objects::Transition.kind","signature":"pub kind : Option < String >"},{"kind":"field","path":"objects::Transition.module","signature":"pub module : String"},{"kind":"field","path":"objects::Transition.new_state","signature":"pub new_state : String"},{"kind":"field","path":"objects::Transition.prompt","signature":"pub prompt : Option < String >"},{"kind":"field","path":"objects::Transition.receipt","signature":"pub receipt : String"},{"kind":"field","path":"objects::Transition.surface","signature":"pub surface : String"},{"kind":"field","path":"objects::Transition.target","signature":"pub target : String"},{"kind":"fn","path":"objects::empty_module_state","signature":"pub fn empty_module_state ( module : & str , target : & str , empty_tree : & str ) -> ModuleState"},{"kind":"fn","path":"objects::event_hash","signature":"pub fn event_hash ( content : & str , parents : & [ String ] ) -> String"},{"kind":"fn","path":"objects::event_json","signature":"pub fn event_json ( content : & str , parents : & [ String ] ) -> Value"},{"kind":"fn","path":"objects::event_transition","signature":"pub fn event_transition ( source : & dyn ObjectSource , event : & str ) -> Result < String >"},{"kind":"fn","path":"objects::module_state_hash","signature":"pub fn module_state_hash ( state : & ModuleState ) -> String"},{"kind":"fn","path":"objects::module_state_json","signature":"pub fn module_state_json ( state : & ModuleState ) -> Value"},{"kind":"fn","path":"objects::ownership_write_key","signature":"pub fn ownership_write_key ( root : & str ) -> String"},{"kind":"fn","path":"objects::read_module_state","signature":"pub fn read_module_state ( source : & dyn ObjectSource , hash : & str ) -> Result < ModuleState >"},{"kind":"fn","path":"objects::snapshot_event","signature":"pub fn snapshot_event ( source : & dyn ObjectSource , snapshot : & str ) -> Result < String >"},{"kind":"fn","path":"objects::snapshot_hash","signature":"pub fn snapshot_hash ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> String"},{"kind":"fn","path":"objects::snapshot_json","signature":"pub fn snapshot_json ( event : & str , states : & BTreeMap < ( String , String ) , String > ) -> Value"},{"kind":"fn","path":"objects::snapshot_members","signature":"pub fn snapshot_members ( states : & BTreeMap < ( String , String ) , String > , target : & str ) -> Vec < String >"},{"kind":"fn","path":"objects::snapshot_states","signature":"pub fn snapshot_states ( source : & dyn ObjectSource , snapshot : & str ) -> Result < BTreeMap < ( String , String ) , String > >"},{"kind":"fn","path":"objects::state_write_key","signature":"pub fn state_write_key ( target : & str , module : & str ) -> String"},{"kind":"fn","path":"objects::transition_hash","signature":"pub fn transition_hash ( transition : & Transition ) -> String"},{"kind":"fn","path":"objects::transition_json","signature":"pub fn transition_json ( transition : & Transition ) -> Value"},{"kind":"fn","path":"objects::transition_write_set","signature":"pub fn transition_write_set ( source : & dyn ObjectSource , transition : & str ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"objects::walk_events","signature":"pub fn walk_events ( source : & dyn ObjectSource , event : & str , until : Option < & str > ) -> Result < Vec < String > >"},{"kind":"const","path":"paths::MAX_PATH_LEN","signature":"pub const MAX_PATH_LEN : usize = 1024 ;"},{"kind":"const","path":"paths::MAX_SEGMENT_LEN","signature":"pub const MAX_SEGMENT_LEN : usize = 128 ;"},{"kind":"fn","path":"paths::check_canonical_path","signature":"pub fn check_canonical_path ( path : & str , code : OutcomeCode ) -> Result < ( ) >"},{"kind":"fn","path":"paths::decode_name","signature":"pub fn decode_name ( segment : & str ) -> Option < String >"},{"kind":"fn","path":"paths::encode_name","signature":"pub fn encode_name ( name : & str ) -> String"},{"kind":"fn","path":"paths::is_under","signature":"pub fn is_under ( path : & str , prefix : & str ) -> bool"},{"kind":"fn","path":"paths::join_root","signature":"pub fn join_root ( root : & str , rest : & str ) -> String"},{"kind":"fn","path":"paths::normalize_path_key","signature":"pub fn normalize_path_key ( path : & str ) -> String"},{"kind":"const","path":"porcelain::PROJECTION_OWNER","signature":"pub const PROJECTION_OWNER : & str = \" no module: this path is recomputed by commit from the crate's committed module file sets and is never user-supplied; a candidate that carries it is rejected as invalid-candidate \" ;"},{"kind":"const","path":"porcelain::PROJECTION_OWNERSHIP","signature":"pub const PROJECTION_OWNERSHIP : & str = \" tool-derived \" ;"},{"kind":"fn","path":"porcelain::add_note","signature":"pub fn add_note ( workspace : & Path , transition : & str , note : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::blame_path","signature":"pub fn blame_path ( workspace : & Path , path : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::cat_file","signature":"pub fn cat_file ( workspace : & Path , hash : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"porcelain::causal_order","signature":"pub fn causal_order ( store : & Store , head : & str ) -> Result < Vec < String > >"},{"kind":"fn","path":"porcelain::committed_attribution_yaml","signature":"pub fn committed_attribution_yaml ( store : & Store , target : & str , module : & str ) -> Result < Option < Vec < u8 > > >"},{"kind":"fn","path":"porcelain::committed_manifest","signature":"pub fn committed_manifest ( store : & Store , target : & str ) -> Result < BTreeMap < String , String > >"},{"kind":"fn","path":"porcelain::committed_state","signature":"pub fn committed_state ( store : & Store , target : & str , module : & str ) -> Result < Option < ModuleState > >"},{"kind":"fn","path":"porcelain::committed_state_at","signature":"pub fn committed_state_at ( store : & Store , target : & str , module : & str ) -> Result < Option < ( String , ModuleState ) > >"},{"kind":"fn","path":"porcelain::cost","signature":"pub fn cost ( workspace : & Path , names : & [ String ] , files : & [ String ] , sessions : & [ String ] ) -> Result < Value >"},{"kind":"fn","path":"porcelain::diff","signature":"pub fn diff ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::first_output","signature":"pub fn first_output ( target : & TargetDef , root : & str , module : & str ) -> Value"},{"kind":"fn","path":"porcelain::fsck_store","signature":"pub fn fsck_store ( workspace : & Path ) -> Result < ( bool , Value ) >"},{"kind":"fn","path":"porcelain::gc","signature":"pub fn gc ( workspace : & Path , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::has_committed_head","signature":"pub fn has_committed_head ( workspace : & Path ) -> Result < bool >"},{"kind":"fn","path":"porcelain::lint_workspace","signature":"pub fn lint_workspace ( workspace : & Path , target : & str ) -> Result < ( Graph , LintReport , Value ) >"},{"kind":"fn","path":"porcelain::log","signature":"pub fn log ( workspace : & Path , module : Option < & str > ) -> Result < Value >"},{"kind":"fn","path":"porcelain::module_plan","signature":"pub fn module_plan ( workspace : & Path , target_name : & str , module : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::projection_labels","signature":"pub fn projection_labels ( target : & TargetDef , root : & str ) -> Vec < Value >"},{"kind":"fn","path":"porcelain::repack_if_loose","signature":"pub fn repack_if_loose ( store : & Store ) -> Result < Option < Value > >"},{"kind":"fn","path":"porcelain::restore","signature":"pub fn restore ( workspace : & Path , target : & str , apply : bool ) -> Result < Value >"},{"kind":"fn","path":"porcelain::show","signature":"pub fn show ( workspace : & Path , hash : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::source_syntax_scan","signature":"pub fn source_syntax_scan ( workspace : & Path , target_name : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::status","signature":"pub fn status ( workspace : & Path , target : & str ) -> Result < Value >"},{"kind":"fn","path":"porcelain::surface_of","signature":"pub fn surface_of ( workspace : & Path , target : & str , module : & str ) -> Result < Value >"},{"kind":"const","path":"receipt::MAX_REPORTED_BYTES","signature":"pub const MAX_REPORTED_BYTES : usize = 512 * 1024 ;"},{"kind":"const","path":"receipt::RECEIPT_VERSION","signature":"pub const RECEIPT_VERSION : u32 = 1 ;"},{"kind":"struct","path":"receipt::Receipt","signature":"pub struct Receipt"},{"kind":"field","path":"receipt::Receipt.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"receipt::Receipt.base_store_snapshot","signature":"pub base_store_snapshot : Option < String >"},{"kind":"field","path":"receipt::Receipt.base_workspace_hash","signature":"pub base_workspace_hash : String"},{"kind":"field","path":"receipt::Receipt.candidate_tree_hash","signature":"pub candidate_tree_hash : String"},{"kind":"field","path":"receipt::Receipt.evidence","signature":"pub evidence : Vec < EvidenceItem >"},{"kind":"field","path":"receipt::Receipt.input_key","signature":"pub input_key : String"},{"kind":"field","path":"receipt::Receipt.module","signature":"pub module : String"},{"kind":"field","path":"receipt::Receipt.output_manifest","signature":"pub output_manifest : BTreeMap < String , String >"},{"kind":"field","path":"receipt::Receipt.receipt_version","signature":"pub receipt_version : u32"},{"kind":"field","path":"receipt::Receipt.reported","signature":"pub reported : Value"},{"kind":"field","path":"receipt::Receipt.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"receipt::Receipt.target","signature":"pub target : String"},{"kind":"const","path":"receipt::UNAVAILABLE","signature":"pub const UNAVAILABLE : & str = \" unavailable \" ;"},{"kind":"fn","path":"receipt::normalize_reported","signature":"pub fn normalize_reported ( value : & Value ) -> Value"},{"kind":"fn","path":"receipt::parse_receipt","signature":"pub fn parse_receipt ( bytes : & [ u8 ] ) -> Result < Receipt >"},{"kind":"fn","path":"receipt::receipt_hash","signature":"pub fn receipt_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_json","signature":"pub fn receipt_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::receipt_semantic_hash","signature":"pub fn receipt_semantic_hash ( receipt : & Receipt ) -> String"},{"kind":"fn","path":"receipt::receipt_semantic_json","signature":"pub fn receipt_semantic_json ( receipt : & Receipt ) -> Value"},{"kind":"fn","path":"receipt::reported_usage","signature":"pub fn reported_usage ( receipt : & Receipt ) -> Value"},{"kind":"const","path":"remove::MODULE_REMOVED","signature":"pub const MODULE_REMOVED : & str = \" module-removed \" ;"},{"kind":"fn","path":"remove::remove_module","signature":"pub fn remove_module ( workspace : & Path , target_name : & str , module : & str ) -> CommitReport"},{"kind":"struct","path":"slice::CompileSlice","signature":"pub struct CompileSlice"},{"kind":"field","path":"slice::CompileSlice.base_state_key","signature":"pub base_state_key : String"},{"kind":"field","path":"slice::CompileSlice.body","signature":"pub body : Value"},{"kind":"field","path":"slice::CompileSlice.input_key","signature":"pub input_key : String"},{"kind":"field","path":"slice::CompileSlice.module","signature":"pub module : String"},{"kind":"field","path":"slice::CompileSlice.slice_hash","signature":"pub slice_hash : String"},{"kind":"field","path":"slice::CompileSlice.target","signature":"pub target : String"},{"kind":"field","path":"slice::CompileSlice.wave","signature":"pub wave : usize"},{"kind":"struct","path":"slice::DepSurface","signature":"pub struct DepSurface"},{"kind":"field","path":"slice::DepSurface.items","signature":"pub items : Vec < SurfaceItem >"},{"kind":"field","path":"slice::DepSurface.module","signature":"pub module : String"},{"kind":"field","path":"slice::DepSurface.surface_hash","signature":"pub surface_hash : String"},{"kind":"fn","path":"slice::build_slice","signature":"pub fn build_slice ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] , base : & ModuleState , base_state_key : & str , base_files : & [ ( String , String ) ] , wave : usize ) -> CompileSlice"},{"kind":"fn","path":"slice::closure_manifest","signature":"pub fn closure_manifest ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> Value"},{"kind":"fn","path":"slice::input_key","signature":"pub fn input_key ( source : & SourceModule , target : & TargetDef , deps : & [ DepSurface ] ) -> String"},{"kind":"fn","path":"slice::receipt_requirements","signature":"pub fn receipt_requirements ( ) -> Value"},{"kind":"fn","path":"slice::recompute_slice_hash","signature":"pub fn recompute_slice_hash ( body : & Value ) -> String"},{"kind":"const","path":"source::MAX_SOURCE_BYTES","signature":"pub const MAX_SOURCE_BYTES : usize = 4 * 1024 * 1024 ;"},{"kind":"const","path":"source::MINIMAL_PROMPT","signature":"pub const MINIMAL_PROMPT : & str = \" --- \\n module: example \\n deps: [] \\n roots: [example] \\n targets: [rust] \\n --- \\n One sentence of specification prose. \\n \" ;"},{"kind":"const","path":"source::SOURCE_EXTENSION","signature":"pub const SOURCE_EXTENSION : & str = \" napl \" ;"},{"kind":"struct","path":"source::SourceModule","signature":"pub struct SourceModule"},{"kind":"field","path":"source::SourceModule.deps","signature":"pub deps : Vec < String >"},{"kind":"field","path":"source::SourceModule.directive_cases","signature":"pub directive_cases : Vec < crate :: directive :: DirectiveCase >"},{"kind":"field","path":"source::SourceModule.docs","signature":"pub docs : Vec < crate :: directive :: DocsFence >"},{"kind":"field","path":"source::SourceModule.frontmatter_cases","signature":"pub frontmatter_cases : Vec < serde_json :: Value >"},{"kind":"field","path":"source::SourceModule.module","signature":"pub module : String"},{"kind":"field","path":"source::SourceModule.normalized_body","signature":"pub normalized_body : String"},{"kind":"field","path":"source::SourceModule.normalized_source_hash","signature":"pub normalized_source_hash : String"},{"kind":"field","path":"source::SourceModule.path","signature":"pub path : String"},{"kind":"field","path":"source::SourceModule.physical_body","signature":"pub physical_body : String"},{"kind":"field","path":"source::SourceModule.preamble","signature":"pub preamble : Option < crate :: directive :: DocsFence >"},{"kind":"field","path":"source::SourceModule.roots","signature":"pub roots : Option < Vec < String > >"},{"kind":"field","path":"source::SourceModule.targets","signature":"pub targets : Vec < String >"},{"kind":"field","path":"source::SourceModule.tests","signature":"pub tests : Vec < String >"},{"kind":"fn","path":"source::check_module_name","signature":"pub fn check_module_name ( name : & str ) -> Result < ( ) >"},{"kind":"fn","path":"source::frontmatter_text","signature":"pub fn frontmatter_text ( path : & str , text : & str ) -> Result < String >"},{"kind":"fn","path":"source::normalize_body","signature":"pub fn normalize_body ( raw : & str ) -> String"},{"kind":"fn","path":"source::parse_source","signature":"pub fn parse_source ( path : & str , text : & str ) -> Result < SourceModule >"},{"kind":"fn","path":"source::rewrite_deps","signature":"pub fn rewrite_deps ( text : & str , deps : & [ String ] ) -> Result < String >"},{"kind":"fn","path":"source::semantic_body","signature":"pub fn semantic_body ( path : & str , text : & str ) -> Result < String >"},{"kind":"enum","path":"staging::AddSource","signature":"pub enum AddSource { CandidateDir ( PathBuf ) , WorkingTree , Overlay ( Vec < String > ) }"},{"kind":"const","path":"staging::DOCS_PROMPT_FILE","signature":"pub const DOCS_PROMPT_FILE : & str = \" prompt.napl \" ;"},{"kind":"const","path":"staging::DOCS_RECORD","signature":"pub const DOCS_RECORD : & str = \" docs \" ;"},{"kind":"enum","path":"staging::PromptDrift","signature":"pub enum PromptDrift { Unchanged , DocsOnly , NoBaseline , Frontmatter { line : u32 , committed : String , working : String } , Semantic { line : u32 , committed : String , working : String } }"},{"kind":"const","path":"staging::STAGING_DIR","signature":"pub const STAGING_DIR : & str = \" .napl/staging \" ;"},{"kind":"struct","path":"staging::StagedRecord","signature":"pub struct StagedRecord"},{"kind":"field","path":"staging::StagedRecord.module","signature":"pub module : String"},{"kind":"field","path":"staging::StagedRecord.path","signature":"pub path : PathBuf"},{"kind":"field","path":"staging::StagedRecord.relative","signature":"pub relative : String"},{"kind":"field","path":"staging::StagedRecord.target","signature":"pub target : String"},{"kind":"fn","path":"staging::add","signature":"pub fn add ( workspace : & Path , target_name : & str , module : & str , source : & AddSource , attribution_path : Option < & Path > , usage_path : Option < & Path > , no_verify : bool , check : bool ) -> Result < Value >"},{"kind":"fn","path":"staging::add_working_tree_paths","signature":"pub fn add_working_tree_paths ( workspace : & Path , cwd : & Path , target_name : & str , given : & [ String ] , explicit_module : Option < & str > , attribution_path : Option < & Path > , usage_path : Option < & Path > , no_verify : bool , check : bool ) -> Result < Value >"},{"kind":"fn","path":"staging::commit_all","signature":"pub fn commit_all ( workspace : & Path ) -> Result < ( Value , i32 ) >"},{"kind":"fn","path":"staging::commit_staged","signature":"pub fn commit_staged ( workspace : & Path , record : & Path ) -> CommitReport"},{"kind":"fn","path":"staging::committed_prompt","signature":"pub fn committed_prompt ( store : & Store , object : & str , module : & str ) -> Result < String >"},{"kind":"fn","path":"staging::docs_drift","signature":"pub fn docs_drift ( workspace : & Path ) -> Result < BTreeSet < ( String , String ) > >"},{"kind":"fn","path":"staging::judge_prompt","signature":"pub fn judge_prompt ( path : & str , committed : & str , working : & str ) -> Result < PromptDrift >"},{"kind":"fn","path":"staging::mode_slug","signature":"pub fn mode_slug ( mode : AttributionMode ) -> & ' static str"},{"kind":"fn","path":"staging::record_dir","signature":"pub fn record_dir ( workspace : & Path , module : & str , tree_hash : & str ) -> PathBuf"},{"kind":"fn","path":"staging::record_directory","signature":"pub fn record_directory ( record : & Path ) -> Result < PathBuf >"},{"kind":"fn","path":"staging::record_meta","signature":"pub fn record_meta ( record_dir : & Path ) -> Result < Value >"},{"kind":"fn","path":"staging::relative_record","signature":"pub fn relative_record ( workspace : & Path , path : & Path ) -> String"},{"kind":"fn","path":"staging::staged_payload","signature":"pub fn staged_payload ( target_name : & str , records : Vec < Value > ) -> Value"},{"kind":"fn","path":"staging::staged_records","signature":"pub fn staged_records ( workspace : & Path ) -> Result < Vec < StagedRecord > >"},{"kind":"fn","path":"staging::unclaimed_working_paths","signature":"pub fn unclaimed_working_paths ( workspace : & Path , store : & Store , target : & TargetDef , module : & str , sources : & [ SourceModule ] ) -> Result < Vec < String > >"},{"kind":"fn","path":"staging::write_record","signature":"pub fn write_record ( record_dir : & Path , module : & str , target_name : & str , tree_hash : & str , attribution_present : bool , mode : AttributionMode ) -> Result < ( ) >"},{"kind":"trait","path":"store_view::ObjectSource","signature":"pub trait ObjectSource { fn envelope ( & self , name : & str ) -> Result < Option < Vec < u8 > > > ; fn head ( & self ) -> Result < Option < String > > ; fn names ( & self ) -> Result < Vec < String > > ; }"},{"kind":"fn","path":"store_view::flatten_tree","signature":"pub fn flatten_tree ( source : & dyn ObjectSource , root : & str ) -> Result < BTreeMap < String , ( String , u32 ) > >"},{"kind":"fn","path":"store_view::get_blob","signature":"pub fn get_blob ( source : & dyn ObjectSource , name : & str ) -> Result < Vec < u8 > >"},{"kind":"fn","path":"store_view::get_json","signature":"pub fn get_json ( source : & dyn ObjectSource , name : & str , expected : ObjectKind ) -> Result < Value >"},{"kind":"fn","path":"store_view::get_object","signature":"pub fn get_object ( source : & dyn ObjectSource , name : & str ) -> Result < ( ObjectKind , Vec < u8 > ) >"},{"kind":"fn","path":"store_view::get_tree","signature":"pub fn get_tree ( source : & dyn ObjectSource , name : & str ) -> Result < Tree >"},{"kind":"fn","path":"store_view::has_object","signature":"pub fn has_object ( source : & dyn ObjectSource , name : & str ) -> bool"},{"kind":"fn","path":"store_view::load_tree_objects","signature":"pub fn load_tree_objects ( source : & dyn ObjectSource , root : & str , out : & mut BTreeMap < String , Tree > ) -> Result < ( ) >"},{"kind":"struct","path":"surface::ParseFailure","signature":"pub struct ParseFailure"},{"kind":"field","path":"surface::ParseFailure.column","signature":"pub column : u32"},{"kind":"field","path":"surface::ParseFailure.line","signature":"pub line : u32"},{"kind":"field","path":"surface::ParseFailure.path","signature":"pub path : String"},{"kind":"field","path":"surface::ParseFailure.region","signature":"pub region : String"},{"kind":"field","path":"surface::ParseFailure.region_end","signature":"pub region_end : u32"},{"kind":"field","path":"surface::ParseFailure.region_start","signature":"pub region_start : u32"},{"kind":"method","path":"surface::ParseFailure::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"surface::ParseFailure::detail","signature":"pub fn detail ( & self ) -> String"},{"kind":"const","path":"surface::SOURCE_SYNTAX_SCOPE","signature":"pub const SOURCE_SYNTAX_SCOPE : & str = \" source syntax \" ;"},{"kind":"struct","path":"surface::SurfaceItem","signature":"pub struct SurfaceItem"},{"kind":"field","path":"surface::SurfaceItem.kind","signature":"pub kind : String"},{"kind":"field","path":"surface::SurfaceItem.path","signature":"pub path : String"},{"kind":"field","path":"surface::SurfaceItem.signature","signature":"pub signature : String"},{"kind":"fn","path":"surface::empty_surface_hash","signature":"pub fn empty_surface_hash ( module : & str , target : & str ) -> String"},{"kind":"fn","path":"surface::extract_surface","signature":"pub fn extract_surface ( target : & TargetDef , root : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Result < Vec < SurfaceItem > >"},{"kind":"fn","path":"surface::is_target_source","signature":"pub fn is_target_source ( target : & TargetDef , root : & str , path : & str ) -> bool"},{"kind":"fn","path":"surface::parse_check","signature":"pub fn parse_check ( target : & TargetDef , path : & str , bytes : & [ u8 ] ) -> Result < Option < ParseFailure > >"},{"kind":"fn","path":"surface::parse_failure_error","signature":"pub fn parse_failure_error ( code : OutcomeCode , failure : & ParseFailure ) -> crate :: error :: CompilerError"},{"kind":"fn","path":"surface::parse_gate","signature":"pub fn parse_gate ( target : & TargetDef , root : & str , files : & BTreeMap < String , Vec < u8 > > ) -> Result < ( ) >"},{"kind":"fn","path":"surface::surface_hash","signature":"pub fn surface_hash ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> String"},{"kind":"fn","path":"surface::surface_items_from_json","signature":"pub fn surface_items_from_json ( value : & Value ) -> Vec < SurfaceItem >"},{"kind":"fn","path":"surface::surface_json","signature":"pub fn surface_json ( module : & str , target : & str , items : & [ SurfaceItem ] ) -> Value"},{"kind":"struct","path":"target::TargetDef","signature":"pub struct TargetDef"},{"kind":"field","path":"target::TargetDef.entry_point_version","signature":"pub entry_point_version : u32"},{"kind":"field","path":"target::TargetDef.manifest_file","signature":"pub manifest_file : String"},{"kind":"field","path":"target::TargetDef.name","signature":"pub name : String"},{"kind":"field","path":"target::TargetDef.source_dir","signature":"pub source_dir : String"},{"kind":"field","path":"target::TargetDef.source_extension","signature":"pub source_extension : String"},{"kind":"fn","path":"target::builtin_target","signature":"pub fn builtin_target ( name : & str ) -> Result < TargetDef >"},{"kind":"fn","path":"target::builtin_target_names","signature":"pub fn builtin_target_names ( ) -> Vec < String >"},{"kind":"fn","path":"target::derive_lib_projection","signature":"pub fn derive_lib_projection ( target : & TargetDef , root : & str , file_paths : & [ String ] , command_providers : & [ String ] ) -> ( String , Vec < u8 > )"},{"kind":"fn","path":"target::is_manifest_path","signature":"pub fn is_manifest_path ( target : & TargetDef , path : & str ) -> bool"},{"kind":"fn","path":"target::is_owned_path","signature":"pub fn is_owned_path ( target : & TargetDef , root : & str , path : & str ) -> bool"},{"kind":"fn","path":"target::owned_globs","signature":"pub fn owned_globs ( _target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::placement_facts","signature":"pub fn placement_facts ( target : & TargetDef , root : & str ) -> Value"},{"kind":"fn","path":"target::placement_root","signature":"pub fn placement_root ( source : & SourceModule ) -> String"},{"kind":"fn","path":"target::projection_owned_paths","signature":"pub fn projection_owned_paths ( target : & TargetDef , root : & str ) -> Vec < String >"},{"kind":"fn","path":"target::surface_grammar","signature":"pub fn surface_grammar ( target : & TargetDef ) -> Value"},{"kind":"fn","path":"target::target_def_hash","signature":"pub fn target_def_hash ( target : & TargetDef ) -> String"},{"kind":"fn","path":"target::target_def_json","signature":"pub fn target_def_json ( target : & TargetDef ) -> Value"},{"kind":"enum","path":"textdiff::Edit","signature":"pub enum Edit { Keep ( usize , usize ) , Delete ( usize ) , Insert ( usize ) }"},{"kind":"fn","path":"textdiff::diff_lines","signature":"pub fn diff_lines ( old : & [ String ] , new : & [ String ] ) -> Vec < Edit >"},{"kind":"fn","path":"textdiff::lines","signature":"pub fn lines ( text : & str ) -> Vec < String >"},{"kind":"fn","path":"textdiff::render_drift","signature":"pub fn render_drift ( committed : & BTreeMap < String , String > , working : & BTreeMap < String , String > ) -> String"},{"kind":"fn","path":"textdiff::render_patch","signature":"pub fn render_patch ( path : & str , old : & str , new : & str ) -> String"},{"kind":"struct","path":"transaction::Accepted","signature":"pub struct Accepted"},{"kind":"field","path":"transaction::Accepted.event","signature":"pub event : String"},{"kind":"field","path":"transaction::Accepted.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::Accepted.projection_bytes","signature":"pub projection_bytes : Vec < u8 >"},{"kind":"field","path":"transaction::Accepted.projection_path","signature":"pub projection_path : String"},{"kind":"field","path":"transaction::Accepted.rebased","signature":"pub rebased : bool"},{"kind":"field","path":"transaction::Accepted.snapshot","signature":"pub snapshot : String"},{"kind":"field","path":"transaction::Accepted.steps","signature":"pub steps : Vec < InstallStep >"},{"kind":"struct","path":"transaction::BuiltAttribution","signature":"pub struct BuiltAttribution"},{"kind":"field","path":"transaction::BuiltAttribution.state","signature":"pub state : AttributionState"},{"kind":"field","path":"transaction::BuiltAttribution.view_path","signature":"pub view_path : String"},{"kind":"field","path":"transaction::BuiltAttribution.yaml","signature":"pub yaml : Vec < u8 >"},{"kind":"struct","path":"transaction::DerivedCommit","signature":"pub struct DerivedCommit"},{"kind":"field","path":"transaction::DerivedCommit.attribution_install","signature":"pub attribution_install : Option < ( String , String ) >"},{"kind":"field","path":"transaction::DerivedCommit.attribution_status","signature":"pub attribution_status : String"},{"kind":"field","path":"transaction::DerivedCommit.evidence","signature":"pub evidence : Value"},{"kind":"field","path":"transaction::DerivedCommit.new_state_key","signature":"pub new_state_key : String"},{"kind":"field","path":"transaction::DerivedCommit.objects","signature":"pub objects : Vec < PendingObject >"},{"kind":"field","path":"transaction::DerivedCommit.receipt_object","signature":"pub receipt_object : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_hash","signature":"pub surface_hash : String"},{"kind":"field","path":"transaction::DerivedCommit.surface_items","signature":"pub surface_items : Vec < SurfaceItem >"},{"kind":"field","path":"transaction::DerivedCommit.transition","signature":"pub transition : Transition"},{"kind":"field","path":"transaction::DerivedCommit.transition_id","signature":"pub transition_id : String"},{"kind":"struct","path":"transaction::PendingObject","signature":"pub struct PendingObject"},{"kind":"field","path":"transaction::PendingObject.kind","signature":"pub kind : ObjectKind"},{"kind":"field","path":"transaction::PendingObject.name","signature":"pub name : String"},{"kind":"field","path":"transaction::PendingObject.payload","signature":"pub payload : Vec < u8 >"},{"kind":"fn","path":"transaction::accept","signature":"pub fn accept ( source : & dyn ObjectSource , target : & TargetDef , root : & str , candidate : & Candidate , derived : & DerivedCommit , head : Option < & str > , base_snapshot : Option < & str > ) -> Result < Accepted >"},{"kind":"fn","path":"transaction::check_candidate_shape","signature":"pub fn check_candidate_shape ( target : & TargetDef , root : & str , candidate : & Candidate ) -> Result < ( ) >"},{"kind":"fn","path":"transaction::derive_commit","signature":"pub fn derive_commit ( target : & TargetDef , root : & str , receipt : & Receipt , candidate : & Candidate , built : Option < & BuiltAttribution > , prompt : Option < String > ) -> Result < DerivedCommit >"},{"kind":"fn","path":"transaction::intervening_write_set","signature":"pub fn intervening_write_set ( source : & dyn ObjectSource , head : Option < & str > , base : Option < & str > ) -> Result < BTreeSet < String > >"},{"kind":"fn","path":"transaction::success_payload","signature":"pub fn success_payload ( derived : & DerivedCommit , accepted : & Accepted ) -> Value"},{"kind":"fn","path":"transaction::verify_receipt","signature":"pub fn verify_receipt ( source : & dyn ObjectSource , sources : & [ SourceModule ] , receipt : & Receipt , candidate : & Candidate ) -> Result < CompileSlice >"},{"kind":"struct","path":"transport::ClaimRefusal","signature":"pub struct ClaimRefusal"},{"kind":"field","path":"transport::ClaimRefusal.code","signature":"pub code : Option < EndpointVerdict >"},{"kind":"field","path":"transport::ClaimRefusal.file","signature":"pub file : Option < String >"},{"kind":"field","path":"transport::ClaimRefusal.index","signature":"pub index : usize"},{"kind":"field","path":"transport::ClaimRefusal.note","signature":"pub note : String"},{"kind":"field","path":"transport::ClaimRefusal.prompt","signature":"pub prompt : EndpointVerdict"},{"kind":"method","path":"transport::ClaimRefusal::affirmation_needed","signature":"pub fn affirmation_needed ( & self ) -> Vec < & ' static str >"},{"kind":"method","path":"transport::ClaimRefusal::as_json","signature":"pub fn as_json ( & self ) -> Value"},{"kind":"method","path":"transport::ClaimRefusal::prompt_lines","signature":"pub fn prompt_lines ( & self ) -> Range"},{"kind":"method","path":"transport::ClaimRefusal::side","signature":"pub fn side ( & self ) -> & ' static str"},{"kind":"struct","path":"transport::EndpointVerdict","signature":"pub struct EndpointVerdict"},{"kind":"field","path":"transport::EndpointVerdict.detail","signature":"pub detail : Option < String >"},{"kind":"field","path":"transport::EndpointVerdict.observed_line_count","signature":"pub observed_line_count : u32"},{"kind":"field","path":"transport::EndpointVerdict.observed_surviving","signature":"pub observed_surviving : Option < Range >"},{"kind":"field","path":"transport::EndpointVerdict.path","signature":"pub path : Option < String >"},{"kind":"field","path":"transport::EndpointVerdict.reason","signature":"pub reason : Option < & ' static str >"},{"kind":"field","path":"transport::EndpointVerdict.recorded","signature":"pub recorded : Option < Range >"},{"kind":"field","path":"transport::EndpointVerdict.transported","signature":"pub transported : Option < Range >"},{"kind":"field","path":"transport::EndpointVerdict.whole","signature":"pub whole : bool"},{"kind":"method","path":"transport::EndpointVerdict::refused","signature":"pub fn refused ( & self ) -> bool"},{"kind":"struct","path":"transport::LineMap","signature":"pub struct LineMap"},{"kind":"method","path":"transport::LineMap::anchor_before","signature":"pub fn anchor_before ( & self , old_line : u32 ) -> Option < u32 >"},{"kind":"method","path":"transport::LineMap::moved_line","signature":"pub fn moved_line ( & self , old_line : u32 ) -> Option < u32 >"},{"kind":"method","path":"transport::LineMap::new_line_count","signature":"pub fn new_line_count ( & self ) -> u32"},{"kind":"method","path":"transport::LineMap::old_line_count","signature":"pub fn old_line_count ( & self ) -> u32"},{"kind":"struct","path":"transport::Range","signature":"pub struct Range"},{"kind":"field","path":"transport::Range.end","signature":"pub end : u32"},{"kind":"field","path":"transport::Range.start","signature":"pub start : u32"},{"kind":"method","path":"transport::Range::new","signature":"pub fn new ( start : u32 , end : u32 ) -> Range"},{"kind":"enum","path":"transport::Refusal","signature":"pub enum Refusal { OutsideOldContent , RegionRewritten , RangeSplit }"},{"kind":"method","path":"transport::Refusal::as_str","signature":"pub fn as_str ( & self ) -> & ' static str"},{"kind":"method","path":"transport::Refusal::detail","signature":"pub fn detail ( & self ) -> & ' static str"},{"kind":"struct","path":"transport::TransportInputs","signature":"pub struct TransportInputs < ' a >"},{"kind":"field","path":"transport::TransportInputs.committed_body","signature":"pub committed_body : & ' a str"},{"kind":"field","path":"transport::TransportInputs.committed_files","signature":"pub committed_files : & ' a BTreeMap < String , String >"},{"kind":"field","path":"transport::TransportInputs.pending_body","signature":"pub pending_body : & ' a str"},{"kind":"field","path":"transport::TransportInputs.staged_files","signature":"pub staged_files : & ' a BTreeMap < String , String >"},{"kind":"struct","path":"transport::TransportOutcome","signature":"pub struct TransportOutcome"},{"kind":"field","path":"transport::TransportOutcome.record","signature":"pub record : Attribution"},{"kind":"field","path":"transport::TransportOutcome.refusals","signature":"pub refusals : Vec < ClaimRefusal >"},{"kind":"method","path":"transport::TransportOutcome::complete","signature":"pub fn complete ( & self ) -> bool"},{"kind":"enum","path":"transport::Transported","signature":"pub enum Transported { Proven ( Range ) , Unproven { refusal : Refusal , surviving : Option < Range > , anchor : Option < u32 > } }"},{"kind":"method","path":"transport::Transported::proven","signature":"pub fn proven ( & self ) -> Option < Range >"},{"kind":"fn","path":"transport::line_map","signature":"pub fn line_map ( old : & str , new : & str ) -> LineMap"},{"kind":"fn","path":"transport::refusal_detail","signature":"pub fn refusal_detail ( module : & str , refusals : & [ ClaimRefusal ] ) -> String"},{"kind":"fn","path":"transport::refusal_payload","signature":"pub fn refusal_payload ( module : & str , refusals : & [ ClaimRefusal ] ) -> Value"},{"kind":"fn","path":"transport::transport_attribution","signature":"pub fn transport_attribution ( record : & Attribution , inputs : & TransportInputs ) -> TransportOutcome"},{"kind":"fn","path":"transport::transport_range","signature":"pub fn transport_range ( map : & LineMap , range : Range ) -> Transported"},{"kind":"fn","path":"trees::empty_tree_hash","signature":"pub fn empty_tree_hash ( ) -> String"},{"kind":"fn","path":"trees::tree_of","signature":"pub fn tree_of ( manifest : & BTreeMap < String , String > ) -> Result < BuiltTree >"},{"kind":"const","path":"version::CONTRACT_VERSION","signature":"pub const CONTRACT_VERSION : u32 = 4 ;"},{"kind":"fn","path":"workspace::apply_step","signature":"pub fn apply_step ( root : & Path , step : & InstallStep , payload : & dyn Fn ( & str ) -> Result < Vec < u8 > > ) -> Result < ( ) >"},{"kind":"fn","path":"workspace::discover_sources","signature":"pub fn discover_sources ( root : & Path ) -> Result < Vec < SourceModule > >"},{"kind":"fn","path":"workspace::find_workspace_root","signature":"pub fn find_workspace_root ( start : & Path ) -> Option < PathBuf >"},{"kind":"fn","path":"workspace::read_working_text","signature":"pub fn read_working_text ( root : & Path , paths : & [ String ] ) -> BTreeMap < String , String >"},{"kind":"fn","path":"workspace::require_workspace_root","signature":"pub fn require_workspace_root ( start : & Path ) -> Result < PathBuf >"},{"kind":"fn","path":"workspace::scan_candidate","signature":"pub fn scan_candidate ( dir : & Path ) -> Result < Vec < ScannedEntry > >"},{"kind":"fn","path":"workspace::step_path","signature":"pub fn step_path ( step : & InstallStep ) -> & str"},{"kind":"fn","path":"workspace::write_file","signature":"pub fn write_file ( path : & Path , bytes : & [ u8 ] ) -> Result < ( ) >"}],"module":"napl","target":"rust"} -napl-store blob v1 18843 -//! The read commands and the maintenance commands. -//! -//! Every read resolves HEAD exactly once and then answers entirely from objects -//! reachable from that snapshot. No read creates a directory, takes a lock, or -//! writes a byte, so the whole read surface works on a read-only filesystem. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; - -use crate::blame::{blame, blame_json, Revision}; -use crate::compile::{compile, read_surface_items, state_of}; -use crate::cost::{cost_report, line_from_reported, CostLine}; -use crate::error::{error, OutcomeCode, Result}; -use crate::graph::Graph; -use crate::lint::{lint, lint_json, LintInput, LintReport}; -use crate::maintenance::{ - fsck, fsck_json, gc_json, plan_restore, plan_sweep, reachable_from_head, restore_json, - FileFact, RestorePlan, -}; -use crate::objects::{ - event_transition, read_module_state, snapshot_event, snapshot_states, walk_events, ModuleState, -}; -use crate::receipt::normalize_reported; -use crate::store_view::{ - flatten_tree, get_blob, get_json, get_object, has_object, ObjectSource, -}; -use crate::surface::surface_json; -use crate::target::builtin_target; -use crate::textdiff::render_drift; -use napl_store::store_hash::ObjectKind; -use serde_json::{json, Value}; - -use crate::disk::{self, Store}; -use crate::workspace::{discover_sources, read_working_text}; - -/// A snapshot-consistent status report for one target: observation, zero -/// mutation. -pub fn status(workspace: &Path, target: &str) -> Result { - let store = disk::open(workspace); - let head = store.head()?; - let states = match &head { - Some(snapshot) => snapshot_states(&store, snapshot)?, - None => BTreeMap::new(), - }; - let sources = discover_sources(workspace)?; - let artifact = compile(&store, &sources, target, None)?; - let pending: Vec = artifact - .slices - .iter() - .map(|slice| slice.module.clone()) - .collect(); - - let mut modules: Vec = Vec::new(); - for ((state_target, module), state_hash) in &states { - if state_target != target { - continue; - } - let state = read_module_state(&store, state_hash)?; - modules.push(json!({ - "baseStateKey": state_hash, - "inputKey": state.input_key, - "module": module, - "outputTree": state.output_tree, - "pending": pending.contains(module), - "sliceHash": state.slice_hash, - "surfaceHash": state.surface_hash, - })); - } - Ok(json!({ - "contractVersion": crate::CONTRACT_VERSION, - "head": head, - "modules": modules, - "pending": pending, - "target": target, - })) -} - -/// The causal history reachable from HEAD, optionally narrowed to one module. -/// -/// The `ordinal` field is a presentation device computed from the causal graph -/// at read time. It is never stored and never enters an identity. -pub fn log(workspace: &Path, module: Option<&str>) -> Result { - let store = disk::open(workspace); - let Some(head) = store.head()? else { - return Ok(json!({ - "contractVersion": crate::CONTRACT_VERSION, - "entries": [], - "head": Value::Null, - })); - }; - let mut entries: Vec = Vec::new(); - for (ordinal, event) in causal_order(&store, &head)?.iter().enumerate() { - let transition_id = event_transition(&store, event)?; - let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; - if let Some(wanted) = module { - if transition["module"].as_str() != Some(wanted) { - continue; - } - } - entries.push(json!({ - "event": event, - "module": transition["module"], - "newState": transition["newState"], - "ordinal": ordinal as u64, - "target": transition["target"], - "transition": transition_id, - "writeSet": transition["writeSet"], - })); - } - Ok(json!({ - "contractVersion": crate::CONTRACT_VERSION, - "entries": entries, - "head": head, - })) -} - -/// Describe any stored object: its kind and, for JSON kinds, its payload. -pub fn show(workspace: &Path, hash: &str) -> Result { - let store = disk::open(workspace); - let (kind, payload) = get_object(&store, hash)?; - let body = if kind == ObjectKind::Blob { - json!({ "bytes": payload.len() as u64 }) - } else { - serde_json::from_slice(&payload).map_err(|e| { - error( - OutcomeCode::StoreIntegrity, - format!("object {hash} is not JSON: {e}"), - ) - })? - }; - Ok(json!({ - "body": body, - "contractVersion": crate::CONTRACT_VERSION, - "hash": hash, - "kind": kind.as_wire(), - })) -} - -/// The raw payload bytes of an object, with the envelope stripped. -pub fn cat_file(workspace: &Path, hash: &str) -> Result> { - Ok(get_object(&disk::open(workspace), hash)?.1) -} - -/// The recorded surface of a module as of HEAD. -pub fn surface_of(workspace: &Path, target: &str, module: &str) -> Result { - let store = disk::open(workspace); - let Some(state) = committed_state(&store, target, module)? else { - return Ok(surface_json(module, target, &[])); - }; - let items = read_surface_items(&store, &state.surface_hash)?; - Ok(surface_json(module, target, &items)) -} - -/// Drift between committed outputs and the working tree, as a canonical patch. -pub fn diff(workspace: &Path, target: &str) -> Result { - let store = disk::open(workspace); - let committed = committed_text(&store, target)?; - let paths: Vec = committed.keys().cloned().collect(); - let working = read_working_text(workspace, &paths); - let patch = render_drift(&committed, &working); - Ok(json!({ - "contractVersion": crate::CONTRACT_VERSION, - "patch": patch, - "target": target, - })) -} - -/// Derived line ancestry for one committed path. -pub fn blame_path(workspace: &Path, path: &str) -> Result { - let store = disk::open(workspace); - let Some(head) = store.head()? else { - return Ok(blame_json(path, &[])); - }; - let mut revisions: Vec = Vec::new(); - for event in causal_order(&store, &head)? { - let transition_id = event_transition(&store, &event)?; - let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; - let touched = transition["installPaths"] - .as_array() - .map(|paths| paths.iter().any(|p| p.as_str() == Some(path))) - .unwrap_or(false); - if !touched { - continue; - } - let Some(state_hash) = transition["newState"].as_str() else { - continue; - }; - let state = read_module_state(&store, state_hash)?; - let files = flatten_tree(&store, &state.output_tree)?; - let Some((blob, _)) = files.get(path) else { - continue; - }; - let bytes = get_blob(&store, blob)?; - revisions.push(Revision { - transition: transition_id, - content: String::from_utf8_lossy(&bytes).into_owned(), - }); - } - Ok(blame_json(path, &blame(&revisions))) -} - -/// Diagnose a workspace. -pub fn lint_workspace(workspace: &Path, target: &str) -> Result<(Graph, LintReport, Value)> { - let store = disk::open(workspace); - let sources = discover_sources(workspace)?; - let artifact = compile(&store, &sources, target, None)?; - let graph = crate::compile::graph_for(&sources, target)?; - let definition = builtin_target(target)?; - let states = match store.head()? { - Some(snapshot) => snapshot_states(&store, &snapshot)?, - None => BTreeMap::new(), - }; - - let mut committed_input_keys: BTreeMap> = BTreeMap::new(); - let mut referenced_names: BTreeMap> = BTreeMap::new(); - let mut surface_names: BTreeMap> = BTreeMap::new(); - for module in graph.keys() { - let (state, _) = state_of(&store, &states, &definition, module)?; - committed_input_keys.insert(module.clone(), state.input_key.clone()); - surface_names.insert( - module.clone(), - read_surface_items(&store, &state.surface_hash)? - .iter() - .filter_map(|item| item.path.rsplit("::").next().map(str::to_string)) - .collect(), - ); - referenced_names.insert(module.clone(), identifiers(&store, &state)?); - } - let current_input_keys: BTreeMap = artifact - .slices - .iter() - .map(|slice| (slice.module.clone(), slice.input_key.clone())) - .collect(); - - let roots = declared_roots(&sources); - let report = lint( - &graph, - &LintInput { - roots, - committed_input_keys, - current_input_keys, - referenced_names, - surface_names, - }, - ); - let value = lint_json(&report); - Ok((graph, report, value)) -} - -/// Cost over an explicit receipt set: object names in the store, file paths, or -/// both. -pub fn cost(workspace: &Path, names: &[String], files: &[String]) -> Result { - let store = disk::open(workspace); - let mut lines: Vec = Vec::new(); - for name in names { - let body = get_json(&store, name, ObjectKind::Receipt)?; - lines.push(line_from_reported( - name, - body["module"].as_str().unwrap_or_default(), - &normalize_reported(&body["reported"]), - )?); - } - for file in files { - let path = Path::new(file); - let bytes = std::fs::read(path).map_err(|e| disk::io(path, &e))?; - let body: Value = serde_json::from_slice(&bytes).map_err(|e| { - error( - OutcomeCode::InvalidReceipt, - format!("{file}: invalid JSON: {e}"), - ) - })?; - let name = crate::canon::json_hash(ObjectKind::Receipt, &body); - lines.push(line_from_reported( - &name, - body["module"].as_str().unwrap_or_default(), - &normalize_reported(&body["reported"]), - )?); - } - Ok(cost_report(&lines)) -} -/// Sweep every object no named head reaches. -/// -/// The sweep set is computed outside the lock; only the deletions run under it, -/// and a writer publishing concurrently is never blocked because publication -/// takes no lock at all. -pub fn gc(workspace: &Path, apply: bool) -> Result { - let store = disk::init(workspace)?; - let all: BTreeSet = store.names()?.into_iter().collect(); - let reachable = match store.head()? { - Some(head) => reachable_from_head(&store, &head)?, - None => BTreeSet::new(), - }; - let swept = plan_sweep(&all, &reachable); - if apply { - for name in &swept { - disk::remove_object(&store, name)?; - } - } - Ok(gc_json(&swept, reachable.len())) -} +::: test +name: fsck detects a corrupted object and a broken parent link +given: { workspace: fixture_two_modules, corrupt: [flip_byte_in_object, remove_parent_event] } +expect: { findings: [hash_mismatch, missing_parent], exit_nonzero: true } +::: -/// Verify envelope hashes, kind validity, and causal parent links. -pub fn fsck_store(workspace: &Path) -> Result<(bool, Value)> { - let store = disk::open(workspace); - let findings = fsck(&store)?; - Ok((findings.is_empty(), fsck_json(&findings))) -} +::: test +name: restore applies an unambiguous move-repair plan and blocks on ambiguity +given: { workspace: fixture_moved_output, ambiguity: [none, two_candidates] } +expect: { unambiguous: installed, ambiguous: blocked } +::: -/// Derive a repair plan for a target's committed outputs, and optionally apply -/// it. -pub fn restore(workspace: &Path, target: &str, apply: bool) -> Result { - let store = disk::open(workspace); - let expected: Vec = committed_manifest(&store, target)? - .into_iter() - .map(|(path, hash)| FileFact { path, hash }) - .collect(); - let roots: BTreeSet = expected - .iter() - .filter_map(|fact| fact.path.split('/').next().map(str::to_string)) - .collect(); - let observed = observed_facts(workspace, &roots)?; - let plan = plan_restore(&expected, &observed); - if apply { - if let RestorePlan::Apply(steps) = &plan { - apply_restore(&store, workspace, steps)?; - } - } - Ok(restore_json(&plan)) -} +::: test +name: hover on an attributed prompt line resolves its generated spans +given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: within_entry } } +expect: { spans_resolved: true, notes_included: true } +::: -/// Attach later provenance to a recorded transition without touching semantic -/// state. -pub fn add_note(workspace: &Path, transition: &str, note: &str) -> Result { - let store = disk::init(workspace)?; - get_json(&store, transition, ObjectKind::Transition)?; - let body = json!({ "note": note, "transition": transition }); - let payload = crate::canon::canonical_bytes(&body); - let name = disk::publish(&store, ObjectKind::Blob, &payload, transition)?; +::: test +name: "hover on an unattributed line answers empty, not error" +given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: outside_entries } } +expect: { result: empty, error: false } +::: - let index = store.root.join("notes").join(format!("{transition}.json")); - let mut names: BTreeSet = match std::fs::read(&index) { - Ok(bytes) => serde_json::from_slice::>(&bytes) - .unwrap_or_default() - .into_iter() - .collect(), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(), - Err(e) => return Err(disk::io(&index, &e)), - }; - names.insert(name.clone()); - let listed: Vec = names.iter().map(|n| json!(n)).collect(); - crate::workspace::write_file(&index, &crate::canon::canonical_bytes(&Value::Array(listed)))?; - Ok(json!({ - "note": name, - "outcome": "success", - "semanticStatePublished": false, - "transition": transition, - })) -} +::: test +name: missing attribution records degrade to empty responses +given: { attribution: absent, request: hover } +expect: { result: empty, error: false, blocked: false } +::: -/// The events reachable from a head, ordered oldest first by causal depth. -pub fn causal_order(store: &Store, head: &str) -> Result> { - let head_event = snapshot_event(store, head)?; - let events = walk_events(store, &head_event, None)?; - let mut depths: BTreeMap = BTreeMap::new(); - for event in &events { - depth_of(store, event, &mut depths)?; - } - let mut ordered = events; - ordered.sort_by(|a, b| (depths[a], a).cmp(&(depths[b], b))); - Ok(ordered) -} +::: test +name: definition from a generated line targets the prompt at its discovered placement +given: { attribution: fixture_attribution_small, prompt_placement: inside_crate_dir, request: definition, position: { file: generated_within_entry } } +expect: { location_file: discovered_prompt_path, root_joined_module_name: false } +::: -fn depth_of(store: &Store, event: &str, depths: &mut BTreeMap) -> Result { - if let Some(known) = depths.get(event) { - return Ok(*known); - } - let value = get_json(store, event, ObjectKind::Event)?; - let mut depth = 0usize; - for parent in value["parents"].as_array().cloned().unwrap_or_default() { - if let Some(parent) = parent.as_str() { - depth = depth.max(depth_of(store, parent, depths)? + 1); - } - } - depths.insert(event.to_string(), depth); - Ok(depth) -} +::: test +name: evidence failing its validator is invalid and distinguishable from unsupported +given: { receipt_evidence: [valid_claude_json, malformed_claude_json, unknown_media_type] } +expect: { outcomes: [validated, invalid, unsupported] } +::: -fn committed_state(store: &Store, target: &str, module: &str) -> Result> { - let Some(head) = store.head()? else { - return Ok(None); - }; - let states = snapshot_states(store, &head)?; - match states.get(&(target.to_string(), module.to_string())) { - Some(hash) => Ok(Some(read_module_state(store, hash)?)), - None => Ok(None), - } -} +::: test +name: a receipt exceeding evidence bounds is rejected as resource-limit with nothing published +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: oversized_evidence } +expect: { outcome: resource_limit, published_semantic_state: false, published_provenance: false } +::: -/// The committed `path -> blob` manifest of every module under a target. -pub fn committed_manifest(store: &Store, target: &str) -> Result> { - let Some(head) = store.head()? else { - return Ok(BTreeMap::new()); - }; - let mut out = BTreeMap::new(); - for ((state_target, _), hash) in snapshot_states(store, &head)? { - if state_target != target { - continue; - } - let state = read_module_state(store, &hash)?; - for (path, (blob, _)) in flatten_tree(store, &state.output_tree)? { - out.insert(path, blob); - } - } - Ok(out) -} +## Determinism contract -fn committed_text(store: &Store, target: &str) -> Result> { - let mut out = BTreeMap::new(); - for (path, blob) in committed_manifest(store, target)? { - let bytes = get_blob(store, &blob)?; - out.insert(path, String::from_utf8_lossy(&bytes).into_owned()); - } - Ok(out) -} +Every collection canonically ordered; every input explicit and content +identified; serialization byte-pinned; ambient environment excluded +(locale, timezone, machine identity, CPU count, enumeration order); reads +never write; a state transition names its base and fails on mismatch per +the decision table rather than recalculating. Same workspace compiled +twice, from different absolute paths, under different discovery orders, +locales, and timezones produces byte-identical artifacts. The test suite +proves the vectors in the frontmatter with deterministic fixtures +constructed in each crate's `tests/`. -/// Every identifier a module's committed output names. -/// -/// This is a token scan, not name resolution: lint says a surface entry looks -/// unconsumed, and says so as a proposal, precisely because it cannot prove it. -fn identifiers(store: &Store, state: &ModuleState) -> Result> { - let mut out: BTreeSet = BTreeSet::new(); - if !has_object(store, &state.output_tree) { - // A module that has never committed names nothing yet. - return Ok(out); - } - for (_, (blob, _)) in flatten_tree(store, &state.output_tree)? { - let bytes = get_blob(store, &blob)?; - let text = String::from_utf8_lossy(&bytes).into_owned(); - for token in text.split(|c: char| !(c.is_alphanumeric() || c == '_')) { - if !token.is_empty() { - out.insert(token.to_string()); - } - } - } - Ok(out) -} +::: test +name: compiling the same workspace twice yields byte-identical artifacts +given: { workspace: fixture_two_modules, runs: 2 } +expect: { artifacts_equal: true } +::: -fn declared_roots(sources: &[crate::source::SourceModule]) -> Option> { - let mut declared: Option> = None; - for source in sources { - if let Some(roots) = &source.roots { - declared - .get_or_insert_with(BTreeSet::new) - .extend(roots.iter().cloned()); - } - } - declared.map(|set| set.into_iter().collect()) -} +::: test +name: artifact bytes are independent of discovery order +given: { workspace: fixture_two_modules, discovery_orders: [sorted, reversed, shuffled_seed_7] } +expect: { artifacts_equal: true } +::: -fn observed_facts(workspace: &Path, roots: &BTreeSet) -> Result> { - let mut out: Vec = Vec::new(); - for root in roots { - let dir = workspace.join(root); - if !dir.is_dir() { - continue; - } - for entry in crate::workspace::scan_candidate(&dir)? { - if entry.kind != crate::candidate::EntryKind::File { - continue; - } - out.push(FileFact { - path: format!("{root}/{}", entry.path), - hash: napl_store::store_hash::object_name(ObjectKind::Blob, &entry.bytes), - }); - } - } - out.sort(); - Ok(out) -} +::: test +name: artifact bytes are independent of the absolute workspace path +given: { workspace: fixture_two_modules, mounted_at: [rootA, rootB] } +expect: { artifacts_equal: true } +::: -fn apply_restore( - store: &Store, - workspace: &Path, - steps: &[crate::maintenance::RestoreStep], -) -> Result<()> { - use crate::maintenance::RestoreStep; - for step in steps { - match step { - RestoreStep::Move { from, to } => { - let target = workspace.join(to); - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent).map_err(|e| disk::io(parent, &e))?; - } - std::fs::rename(workspace.join(from), &target) - .map_err(|e| disk::io(&target, &e))?; - } - RestoreStep::Write { path, hash } => { - let bytes = get_blob(store, hash)?; - crate::workspace::write_file(&workspace.join(path), &bytes)?; - } - RestoreStep::Remove { path } => { - let target = workspace.join(path); - match std::fs::remove_file(&target) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(disk::io(&target, &e)), - } - } - } - } - Ok(()) -} -napl-store module-state v1 559 -{"attribution":{"promptHash":"b0a8d8b52ccb7ad3e5b791e0791df5baefde058ae25183f7350aa0f575959abf","record":"cdd487c8ec08a7bc6a1e6877fbfa49e52675df90d5c1aa50d0f332f5277bd3c9","stale":false},"contractVersion":4,"inputKey":"9206162a79eefbb9bbbad00daffa25e4dcbca3fb7db9b908b65fa2829a272f7e","module":"query","outputTree":"562e142c3a950b916ff1d8cebfdd0ab3a63fb61ebb42d766e6cd8215190b007f","sliceHash":"82f3d21f63858c133b9fae84aa3886b5558cc8534947dc100ebc0861346c688e","surfaceHash":"326826be3fd8aaa84d4869493b7e41f886a07bea8960df79b61c87f557f4cbd0","target":"rust"} -napl-store event v1 158 -{"content":"9da3bd272976984693f0eca3ccff22b31a8bc6db9e7c17f8e9eaba3fe1e3fee9","parents":["59e51f9ceeccb7464b32c50db2f23dafe489a63af8a69ccd67bf00b780b8b1a9"]} -napl-store snapshot v1 555 -{"contractVersion":3,"event":"a60e0c3e9cf0995bdba6dd0bc8308e4ff71133350cb9245cbb8849e43cae5480","states":[{"module":"commands","state":"94697ec1fb16dd5f899f438ef2b257f8b6c5cb611e93acf940a8f2fe94a3713f","target":"rust"},{"module":"napl","state":"21631447bbac552e78b823a151bc6b9b6132a82db42e636c7befdfc081f3ed2d","target":"rust"},{"module":"napl_store","state":"1506a85bc6ef2b6f1cc8426700959b114ea5532cd02cf43e57d2a82ab7b4fb8a","target":"rust"},{"module":"query","state":"cda23242a5fb8aa243b577c8d7fb4e666dd23757c9c1f8919f20af2224dadec3","target":"rust"}]} +::: test +name: canonical json sorts keys and is byte-stable +given: { value: "{\"b\":1,\"a\":2}", encodings: 2 } +expect: { bytes_equal: true, sorted_keys: true } +::: +napl-store snapshot v1 781 +{"contractVersion":4,"event":"558721ec5962d23ef0cbc9eb4771329d7f2cde12546f6d5167e49c1b85907063","states":[{"module":"agent","state":"335cdc8da2bee5a1ea22ebd01ce3aeb652efa002f052617fb3123eb318193597","target":"rust"},{"module":"commands","state":"2354c58048f64831cc4e160fab474657cd729c89a21d81320e7f221d3b1bc127","target":"rust"},{"module":"napl","state":"3691606e4e8c5b310d3148cff5211f28da63b0d2dff14c4696b4b53c1d018e77","target":"rust"},{"module":"napl_store","state":"7011890f658fb28bd29684802e5e53bb5d54d54d3c5a64cc926a56fae7ef944d","target":"rust"},{"module":"query","state":"06e076725f8e997478f279e24eaa7ed1c09d8fb7765ec4f5aa4f783e35fdbd3b","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} +napl-store tree v1 132 +{"entries":[{"hash":"2297837c4e3941e251dee4cd7ad9c53759fcc8c961ceb362926b8318cc485e03","kind":"dir","mode":16384,"name":"crates"}]} +napl-store snapshot v1 1005 +{"contractVersion":4,"event":"9d62cdfe887811181783f1c4a2b2876691ab6a3564c5d06fb7f2bfc0d25751f9","states":[{"module":"agent","state":"d0d406dd4f30ea6840a808bcbc3ddeea8ec8cc10c84de12ab5d0cfeef7bb6696","target":"rust"},{"module":"commands","state":"ac7d92c561ec9fa19f0e28e1392bd90c1d95ca663b038424bd8ce760c6f4cd9e","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"1c7c754a9870be661e3ada83f5192a6c7843c671e55c36d53baa5ec9279643f9","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"3bf9a4b0e8a429450fc7d3fe321ef68f7a7382e827cd76136be68a2c2fed4c31","target":"rust"},{"module":"serve","state":"dc62dc04b6d519a8cccdd4d4a3f1b1fa1a0fd830ca0ae5b01058e9d7b5347b98","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} +napl-store blob v1 7690 +module: napl +target: rust +stale: false +entries: + - promptLines: + - 1 + - 8 + file: crates/napl/src/cli.rs + lines: + - 1 + - 133 + note: "the binary reads args runs one command and exits with the contract code" + - promptLines: + - 1 + - 8 + file: crates/napl/src/commandset.rs + lines: + - 1 + - 226 + note: "the command table the core toolchain contributes to the derived registry" + - promptLines: + - 1 + - 8 + file: crates/napl/src/main.rs + lines: + - 1 + - 16 + note: "the binary entry point that spawns no process" + - promptLines: + - 9 + - 106 + file: crates/napl/src/workspace.rs + lines: + - 1 + - 251 + note: "layout and discovery walk up to the nearest napl directory" + - promptLines: + - 9 + - 106 + file: crates/napl/src/target.rs + lines: + - 1 + - 228 + note: "placement roots owned source globs and the user-owned manifest seam" + - promptLines: + - 9 + - 106 + file: crates/napl/src/transaction.rs + lines: + - 1 + - 537 + note: "the crate lib.rs projection derived from the committed module file sets" + - promptLines: + - 107 + - 134 + file: crates/napl/src/objects.rs + lines: + - 1 + - 332 + note: "P2 footprints read write and derived-projection sets over content identities" + - promptLines: + - 107 + - 134 + file: crates/napl/src/decision.rs + lines: + - 1 + - 113 + note: "the conflict rule two operations conflict when semantic write sets overlap" + - promptLines: + - 107 + - 134 + file: crates/napl/src/error.rs + lines: + - 1 + - 108 + note: "the outcome vocabulary and its exit-code mapping" + - promptLines: + - 135 + - 183 + file: crates/napl/src/version.rs + lines: + - 1 + - 1 + note: "the contract version bumped to two by the body-directive grammar" + - promptLines: + - 135 + - 183 + file: crates/napl/src/slice.rs + lines: + - 1 + - 170 + note: "identity vocabulary inputKey closure manifest baseStateKey and sliceHash" + - promptLines: + - 184 + - 244 + file: crates/napl/src/attribution.rs + lines: + - 201 + - 232 + note: "names are serialized as quoted escaped scalars that reparse identically" + - promptLines: + - 184 + - 244 + file: crates/napl/src/attribution.rs + lines: + - 277 + - 284 + note: "the attribution record path keys on the encoded name never the raw one" + - promptLines: + - 184 + - 244 + file: crates/napl/src/paths.rs + lines: + - 71 + - 119 + note: "the injective reversible name encoding used wherever a name becomes a segment" + - promptLines: + - 184 + - 244 + file: crates/napl/src/source.rs + lines: + - 73 + - 95 + note: "the module-name rule any non-empty single-line scalar and nothing narrower" + - promptLines: + - 245 + - 267 + file: crates/napl/src/compile.rs + lines: + - 1 + - 257 + note: "the compile derivation a pure read that never persists an artifact" + - promptLines: + - 245 + - 267 + file: crates/napl/src/graph.rs + lines: + - 1 + - 162 + note: "the dependency graph and dependency-ordered wave planning" + - promptLines: + - 245 + - 267 + file: crates/napl/src/paths.rs + lines: + - 1 + - 119 + note: "the portable path grammar and its collision-detecting normalization" + - promptLines: + - 268 + - 325 + file: crates/napl/src/directive.rs + lines: + - 1 + - 634 + note: "the test body-directive value grammar diagnostics and deterministic extraction" + - promptLines: + - 326 + - 357 + file: crates/napl/src/surface.rs + lines: + - 1 + - 694 + note: "tree-sitter surface extraction sound by construction and loud on malformed input" + - promptLines: + - 358 + - 502 + file: crates/napl/src/staging.rs + lines: + - 1 + - 220 + note: "napl add stages a candidate ingesting validating and writing the internal receipt" + - promptLines: + - 358 + - 502 + file: crates/napl/src/commit.rs + lines: + - 1 + - 378 + note: "napl commit the one guarded transaction over what add staged" + - promptLines: + - 358 + - 502 + file: crates/napl/src/receipt.rs + lines: + - 1 + - 194 + note: "the internal receipt everything the compiler recomputes and refuses to believe" + - promptLines: + - 358 + - 502 + file: crates/napl/src/attribution.rs + lines: + - 1 + - 381 + note: "attribution rides the staged record validated before install" + - promptLines: + - 358 + - 502 + file: crates/napl/src/candidate.rs + lines: + - 1 + - 150 + note: "candidate ingestion untrusted bytes become immutable content-addressed blobs" + - promptLines: + - 358 + - 502 + file: crates/napl/src/disk.rs + lines: + - 1 + - 203 + note: "the store shell publication head compare-and-set and module-keyed locks" + - promptLines: + - 358 + - 502 + file: crates/napl/src/store_view.rs + lines: + - 1 + - 152 + note: "the read seam over content-addressed objects a pure function of the bytes" + - promptLines: + - 358 + - 502 + file: crates/napl/src/trees.rs + lines: + - 1 + - 34 + note: "building the candidate tree over the ingested manifest" + - promptLines: + - 358 + - 502 + file: crates/napl/src/authoring.rs + lines: + - 1 + - 221 + note: "the internal receipt derivation and the one guarded auto-fix" + - promptLines: + - 503 + - 652 + file: crates/napl/src/porcelain.rs + lines: + - 1 + - 679 + note: "the observational porcelain status log show diff blame lint cost gc fsck restore" + - promptLines: + - 503 + - 652 + file: crates/napl/src/lsp.rs + lines: + - 1 + - 505 + note: "the language server hover definition references and code lens over the mapping" + - promptLines: + - 503 + - 652 + file: crates/napl/src/mv.rs + lines: + - 1 + - 216 + note: "relocation as a semantic operation re-rooting a module through the guarded path" + - promptLines: + - 503 + - 652 + file: crates/napl/src/blame.rs + lines: + - 1 + - 79 + note: "derived line ancestry surviving lines trace through accepted patches" + - promptLines: + - 503 + - 652 + file: crates/napl/src/lint.rs + lines: + - 1 + - 211 + note: "unreachable modules under declared roots and unconsumed surface entries" + - promptLines: + - 503 + - 652 + file: crates/napl/src/cost.rs + lines: + - 1 + - 163 + note: "cost as a pure calculation over an explicit receipt set unknown stays unknown" + - promptLines: + - 503 + - 652 + file: crates/napl/src/maintenance.rs + lines: + - 1 + - 344 + note: "gc fsck and restore deterministic repair plans from persisted evidence" + - promptLines: + - 503 + - 652 + file: crates/napl/src/evidence.rs + lines: + - 1 + - 208 + note: "evidence validators keyed by media type with compiled-in bounds" + - promptLines: + - 503 + - 652 + file: crates/napl/src/textdiff.rs + lines: + - 1 + - 152 + note: "diff renders drift as a canonical byte-stable patch" + - promptLines: + - 653 + - 687 + file: crates/napl/src/canon.rs + lines: + - 1 + - 44 + note: "the determinism contract canonical bytes and byte-pinned serialization" + - promptLines: + - 653 + - 687 + law: true + note: "the determinism contract as a standing law governing every derivation in this module" +napl-store tree v1 129 +{"entries":[{"hash":"28bccc83435bfa1404f7092fc174d909c1b332197b757fed91fbe3f53260fb18","kind":"dir","mode":16384,"name":"src"}]} +napl-store module-state v1 638 +{"attribution":{"promptHash":"1d93e039abeb6a0ff6dfcbc1a0b7cf5f034a0d040115ea5d09ae1a89051eb978","record":"8febd0b5b5494fd230183698d8b54851cad9cd4b608607440d6f3c63a8ef9c85","stale":false},"contractVersion":4,"inputKey":"2e906dd519879ed63e677c9f3d4a71912a01d61c4acc4992ff7c95d2fc42d72d","module":"commands","outputTree":"d83d2467beb91b948fff71e8ad6912f421053f3c456f4ef0d652253c7a1b8ce7","prompt":"2307ccec6af2c2087ef769a86cf51a8f2784d06ed3a14229f8d7228d7909a8af","sliceHash":"b4ab36d3b275f3f1d604f90824dda2d7d7caa4ac7e106c51737a565c3374a948","surfaceHash":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust"} napl-store event v1 158 -{"content":"e09c02c9e6a72aa35c4ac60ad8622970105b21646d146930ef108bb9b6f4607e","parents":["571414daa0db8ed1603a5cee9dfdf393a2b95a16315317ae52481be25bec96db"]} -napl-store module-state v1 558 -{"attribution":{"promptHash":"d0c13ac14ee9addbd79c32d232c2c2af8564fd64f8bce57f7aa1f3d506b28127","record":"cd67c700a387c4fd20907440b1d1525ad7f86e632a76469f488364d5820383b7","stale":false},"contractVersion":4,"inputKey":"421c2de594ddecf81bcbbcfaad2d0566fe99881b28c2ecccac9479da5dbfcd01","module":"napl","outputTree":"69bf9de1d96653068bee489d514b4ce0c80219a74bb74f8c0385f79d1196fe42","sliceHash":"492627424b74e8a4da6485813c1230ca006efeb65e89a9f35e2561bb62361251","surfaceHash":"e4e11f0f7cc8f5e702a90f8860a56f3220e0e7deb04a9383b462857040616a2c","target":"rust"} -napl-store blob v1 24865 -//! Recorded public surfaces, extracted syntactically with pinned tree-sitter -//! grammars. -//! -//! Extraction is sound by construction because the surface grammar is narrow: it -//! admits only constructs whose public API can be read off the syntax tree -//! without name resolution. Anything that would need resolution — a glob -//! re-export, a macro that emits `pub`, an inferred TypeScript type — is -//! rejected as `mapping-integrity` rather than approximated. +{"content":"1c67b12e1b8b26fe646dee9d76fc02a892715d97a14fc819107a1177defc5f84","parents":["6d3d5f796701f72b83a9e496cd95ab717b808eab8e67a8e0ce71ba5f1c871855"]} +napl-store blob v1 22107 +//! `napl q` and the query module's command table. //! -//! Extraction is a pure function of the file bytes, so re-extracting from -//! committed output reproduces the recorded surface byte for byte. +//! `napl q` is shaped exactly like jq and IS jq: expressions are evaluated by an +//! embedded jq engine (the jaq crate) against the canonical document, stdin, or +//! a JSON file. Everything here is a pure read over committed records: no store +//! writes, no locks, no clock, no randomness, no network. Impure builtins are +//! refused at expression compile time so an expression is a pure function of its +//! input; a compiled-in 64 MiB output ceiling surfaces as `resource-limit`. -use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::io::Read as _; +use std::io::Write as _; +use std::path::{Path, PathBuf}; -use napl_store::store_hash::ObjectKind; +use jaq_core::load::{Arena, File, Loader}; +use jaq_core::{data, unwrap_valr, Compiler, Ctx, Vars}; +use jaq_json::Val; use serde_json::{json, Value}; -use tree_sitter::{Node, Parser}; - -use crate::canon::json_hash; -use crate::error::{error, OutcomeCode, Result}; -use crate::target::TargetDef; -/// One recorded element of a module's public API. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct SurfaceItem { - /// Sort key first: the statically visible path of the item. - pub path: String, - /// The syntactic category, e.g. `fn`, `struct`, `use`, `export-binding`. - pub kind: String, - /// The recorded declaration text, normalized to its token sequence. - pub signature: String, -} +use crate::canon::canonical_bytes; +use crate::cli::{ + flag, has_switch, resolve_workspace, switch, switch_short, value, CliResult, CommandSpec, + FlagSpec, WORKSPACE_FLAG, +}; +use crate::document::build_document_view; +use crate::error::Result; -/// Extract the public surface of a crate rooted at `root` from its owned files. -pub fn extract_surface( - target: &TargetDef, - root: &str, - files: &BTreeMap>, -) -> Result> { - let sources = collect_sources(target, root, files)?; - let mut items = match target.name.as_str() { - "rust" => extract_rust(&sources)?, - _ => extract_typescript(&sources)?, - }; - items.sort(); - items.dedup(); - Ok(items) -} +/// The compiled-in output ceiling: exceeding it is `resource-limit`. +pub const OUTPUT_CEILING_BYTES: usize = 64 * 1024 * 1024; -/// The canonical JSON body of a surface object. -pub fn surface_json(module: &str, target: &str, items: &[SurfaceItem]) -> Value { - let encoded: Vec = items - .iter() - .map(|item| { - json!({ - "kind": item.kind, - "path": item.path, - "signature": item.signature, - }) - }) - .collect(); - json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "items": encoded, - "module": module, - "target": target, - }) -} +/// Switches `napl q` accepts after the command word. They are not operands, so +/// the argv grammar keeps its exactly-three forms and one-input rule. The short +/// spellings `-r` and `-c` normalize to `--raw-output` and `--compact-output` +/// before this body sees it, so only the long names appear here. +const Q_SWITCHES: [&str; 3] = ["--compact-output", "--raw-output", "--working-tree"]; -/// Read a recorded surface object's items back out of its JSON body. -pub fn surface_items_from_json(value: &Value) -> Vec { - let mut items: Vec = value["items"] - .as_array() - .cloned() - .unwrap_or_default() - .iter() - .map(|item| SurfaceItem { - path: item["path"].as_str().unwrap_or_default().to_string(), - kind: item["kind"].as_str().unwrap_or_default().to_string(), - signature: item["signature"].as_str().unwrap_or_default().to_string(), - }) - .collect(); - items.sort(); - items -} +/// Impure builtins the argv pre-scan names explicitly, so a well-known name is +/// refused with a message that names it. These mirror the prompt's enumerated +/// list; the real guarantee is the reachability filter below, which removes +/// every impure builtin from the compiled set regardless of this list. +const IMPURE_BUILTINS: [&str; 7] = [ + "env", + "now", + "input", + "inputs", + "debug", + "stderr", + "input_line_number", +]; -/// The content identity of a recorded surface. -pub fn surface_hash(module: &str, target: &str, items: &[SurfaceItem]) -> String { - json_hash(ObjectKind::Surface, &surface_json(module, target, items)) -} +/// Native jaq-std filters that read the clock, timezone, or environment, or that +/// perform I/O. They are removed from the compiled filter set by construction so +/// an expression is a pure function of its input (P1): a jaq-std upgrade cannot +/// silently reintroduce a leak because a removed name is simply undefined at +/// compile time. `input`/`inputs` are never chained in the first place (they live +/// in `jaq_std::input::funs`, which needs an inputs iterator this engine does not +/// provide), and `input_line_number`/`$ENV` are likewise never registered. +pub(crate) const IMPURE_FUNS: [&str; 6] = [ + "env", + "now", + "localtime", + "strflocaltime", + "debug_empty", + "stderr_empty", +]; -/// The identity of the empty surface, used for modules with no committed state. -pub fn empty_surface_hash(module: &str, target: &str) -> String { - surface_hash(module, target, &[]) -} +/// Standard-library definitions built on the impure native funs above (they do +/// stderr I/O), removed from the compiled definition set for the same reason so +/// the surviving defs never reference a fun that is no longer registered. +pub(crate) const IMPURE_DEFS: [&str; 3] = ["debug", "stderr", "halt_error"]; -/// A source file paired with the module path it occupies. -struct SourceFile { - /// Workspace-relative path, used only in diagnostics. - path: String, - /// The item-path prefix this file contributes, e.g. `a::b` or `util`. - prefix: String, - text: String, -} +/// The one rendering `--output` names. Anything else is a usage error, because +/// the flag is a reading of the store rather than an open format negotiation. +const MARKDOWN_OUTPUT: &str = "markdown"; -fn collect_sources( - target: &TargetDef, - root: &str, - files: &BTreeMap>, -) -> Result> { - let src_root = format!("{}/{}/", root, target.source_dir); - let ext = format!(".{}", target.source_extension); - let mut out: Vec = Vec::new(); - for (path, bytes) in files { - let Some(rest) = path.strip_prefix(&src_root) else { - continue; - }; - let Some(stem) = rest.strip_suffix(&ext) else { - continue; - }; - if target.name == "rust" && (stem == "main" || stem.ends_with("/main")) { - continue; - } - let text = String::from_utf8(bytes.clone()).map_err(|_| { - error( - OutcomeCode::MappingIntegrity, - format!("{path}: source file is not valid UTF-8"), - ) - })?; - let prefix = if target.name == "rust" { - rust_prefix(stem) - } else { - stem.to_string() - }; - out.push(SourceFile { - path: path.clone(), - prefix, - text, - }); - } - out.sort_by(|a, b| a.path.cmp(&b.path)); - Ok(out) -} +const Q_FLAGS: &[FlagSpec] = &[ + switch_short( + "--compact-output", + "-c", + "accepted as a synonym of the default output, which is already one compact value per line", + ), + switch_short( + "--raw-output", + "-r", + "emit string results as their raw contents, exactly as jq does", + ), + value( + "--output", + "render the selected modules' docs as one markdown envelope instead of JSON results", + ), + switch("--working-tree", "read the working tree's prompts instead of the committed records"), + WORKSPACE_FLAG, +]; -fn rust_prefix(stem: &str) -> String { - let trimmed = stem.strip_suffix("/mod").unwrap_or(stem); - if trimmed == "lib" { - String::new() - } else { - trimmed.replace('/', "::") - } -} +const PROMPT_FLAGS: &[FlagSpec] = &[ + switch("--allow-stale", "materialize even where the mapping reads stale"), + switch("--bytes", "emit the selected bytes alone, without the surrounding prose"), + switch("--no-laws", "omit the module's standing laws from the materialized prompt"), + switch("--working-tree", "read the working tree's prompts instead of the committed records"), + WORKSPACE_FLAG, +]; -fn parse(language: &tree_sitter::Language, file: &SourceFile) -> Result { - let mut parser = Parser::new(); - parser.set_language(language).map_err(|e| { - error( - OutcomeCode::StoreIntegrity, - format!("pinned grammar rejected: {e}"), - ) - })?; - let tree = parser.parse(file.text.as_bytes(), None).ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("{}: parser produced no tree", file.path), - ) - })?; - if tree.root_node().has_error() { - return Err(error( - OutcomeCode::MappingIntegrity, - format!("{}: file-global parse error", file.path), - )); - } - Ok(tree) -} +const CHECK_FLAGS: &[FlagSpec] = &[WORKSPACE_FLAG]; -/// The declaration text of a node, normalized to its token sequence. +/// The command table the `query` module contributes to the derived registry. /// -/// Recording tokens rather than raw bytes makes the surface independent of -/// formatting and of comments: reformatting a signature must not move a -/// dependent's `inputKey`, because nothing a dependent can observe has changed. -fn text_of(node: Node, src: &str) -> String { - token_text(node, src, node.end_byte()) -} - -fn token_text(node: Node, src: &str, end: usize) -> String { - let mut tokens: Vec = Vec::new(); - collect_tokens(node, src, end, &mut tokens); - drop_trailing_separators(&mut tokens); - tokens.join(" ") +/// Each entry declares its summary, exact usage line, and closed flag table, so +/// the dispatch loop can answer help and refuse undeclared flags without the +/// query commands parsing arguments of their own. +pub fn commands() -> &'static [CommandSpec] { + &[ + CommandSpec { + name: "q", + example: "napl q '.modules[].name'", + summary: "evaluate a jq expression over the canonical document", + usage: "napl q [|-] [-c|--compact-output] [-r|--raw-output] [--output=markdown] [--working-tree]", + flags: Q_FLAGS, + passthrough: false, + run: run_q, + }, + CommandSpec { + name: "prompt", + example: "napl q '.modules[] | select(.name==\"leaf\")' | napl prompt", + summary: "materialize the specification prose and pinned cases a selection names", + usage: "napl prompt [--no-laws] [--allow-stale] [--working-tree] [--bytes] < selection", + flags: PROMPT_FLAGS, + passthrough: false, + run: crate::promptbuild::run_prompt, + }, + CommandSpec { + name: "p", + example: "napl q '.modules[] | select(.name==\"leaf\")' | napl p", + summary: "the short spelling of napl prompt", + usage: "napl p [--no-laws] [--allow-stale] [--working-tree] [--bytes] < selection", + flags: PROMPT_FLAGS, + passthrough: false, + run: crate::promptbuild::run_prompt, + }, + CommandSpec { + name: "check", + example: "napl check crates/leaf/leaf.napl", + summary: "report what would stop a prompt file from being added", + usage: "napl check ", + flags: CHECK_FLAGS, + passthrough: false, + run: crate::check::run_check, + }, + ] } -/// Drop a separator that only a formatter put there. -/// -/// A trailing comma before a closing delimiter is a line-wrapping artifact, and -/// nothing a dependent can observe changes when one appears. Leaving it in the -/// token stream would let `cargo fmt` move a dependent's `inputKey`. -fn drop_trailing_separators(tokens: &mut Vec) { - let mut index = 0; - while index + 1 < tokens.len() { - if tokens[index] == "," && matches!(tokens[index + 1].as_str(), ")" | "]" | ">" | "}") { - tokens.remove(index); - } else { - index += 1; - } - } +/// Where `napl q` reads its input. +enum Input { + Stdin, + File(PathBuf), } -fn collect_tokens(node: Node, src: &str, end: usize, out: &mut Vec) { - if node.start_byte() >= end { - return; - } - if node.kind().contains("comment") { - return; +fn run_q(cwd: &Path, args: &[String]) -> Result { + let (expr, input) = match parse_argv(args) { + Ok(parsed) => parsed, + Err(msg) => return Ok(fail(2, "usage", &msg)), + }; + if let Some(builtin) = refused_builtin(&expr) { + return Ok(fail( + 3, + "expression", + &format!("impure builtin {builtin:?} is not permitted"), + )); } - if node.child_count() == 0 { - let text = src[node.byte_range()].trim(); - if !text.is_empty() { - out.push(text.to_string()); + let markdown = match flag(args, "--output")? { + Some(rendering) if rendering == MARKDOWN_OUTPUT => true, + Some(rendering) => { + return Ok(fail( + 2, + "usage", + &format!("unknown output rendering {rendering:?}: --output names {MARKDOWN_OUTPUT}"), + )) } - return; + None => false, + }; + if markdown { + if input.is_some() { + return Ok(fail(2, "usage", MARKDOWN_READS_THE_STORE)); + } + let workspace = resolve_workspace(cwd, args)?; + let document = build_document_view(&workspace, has_switch(args, "--working-tree"))?; + return run_markdown(&workspace, &expr, &document); } - for index in 0..node.child_count() { - if let Some(child) = node.child(index) { - collect_tokens(child, src, end, out); + let input_bytes = match &input { + None => { + let workspace = resolve_workspace(cwd, args)?; + let working_tree = has_switch(args, "--working-tree"); + canonical_bytes(&build_document_view(&workspace, working_tree)?) + } + Some(Input::Stdin) => { + let mut buf = Vec::new(); + if std::io::stdin().read_to_end(&mut buf).is_err() { + return Ok(fail(4, "input", "could not read stdin")); + } + buf } + Some(Input::File(path)) => match std::fs::read(path) { + Ok(bytes) => bytes, + Err(e) => return Ok(fail(4, "input", &format!("{}: {e}", path.display()))), + }, + }; + let raw = has_switch(args, "--raw-output"); + match evaluate(&expr, &input_bytes, raw) { + Ok(out) => Ok(CliResult { + stdout: out, + exit_code: 0, + }), + Err((code, kind, msg)) => Ok(fail(code, kind, &msg)), } } -fn normalize_ws(text: &str) -> String { - text.split_whitespace().collect::>().join(" ") -} +/// The detail a markdown invocation carrying an input operand is refused with: +/// rendering docs is a reading of the store, never a jq transformation of bytes +/// somebody piped in. +const MARKDOWN_READS_THE_STORE: &str = + "--output=markdown renders the canonical document and takes no input operand: it is a reading of the store, not a transformation of stdin or a file"; -fn join_path(prefix: &str, name: &str) -> String { - if prefix.is_empty() { - name.to_string() - } else { - format!("{prefix}::{name}") - } +/// Render the docs envelope for whatever the expression selects. +fn run_markdown(workspace: &Path, expr: &str, document: &Value) -> Result { + let bytes = canonical_bytes(document); + let values = match evaluate_values(expr, &bytes) { + Ok(values) => values, + Err((code, kind, msg)) => return Ok(fail(code, kind, &msg)), + }; + let selected = match crate::docs::select_modules(&values) { + Ok(selected) => selected, + Err(msg) => return Ok(fail(2, "usage", &msg)), + }; + let envelope = crate::docs::build_envelope(workspace, &document["head"], &selected)?; + Ok(CliResult { + stdout: canonical_bytes(&envelope), + exit_code: 0, + }) } -// ---------------------------------------------------------------- Rust - -fn extract_rust(sources: &[SourceFile]) -> Result> { - let language: tree_sitter::Language = tree_sitter_rust::LANGUAGE.into(); - let mut trees: Vec<(usize, tree_sitter::Tree)> = Vec::new(); - for (index, file) in sources.iter().enumerate() { - trees.push((index, parse(&language, file)?)); - } +/// The compiled filter for one expression, with every impure builtin removed +/// from the definition and function sets by construction. +fn compile_filter<'a>( + expr: &'a str, + arena: &'a Arena, +) -> std::result::Result< + jaq_core::Filter>, + (i32, &'static str, String), +> { + let defs = jaq_core::defs() + .chain(jaq_std::defs().filter(|d| !IMPURE_DEFS.contains(&d.name))) + .chain(jaq_json::defs()); + let funs = jaq_core::funs() + .chain(jaq_std::funs().filter(|f| !IMPURE_FUNS.contains(&f.0))) + .chain(jaq_json::funs()); + let loader = Loader::new(defs); + let modules = loader + .load(arena, File { code: expr, path: () }) + .map_err(|errs| (3, "expression", format!("invalid expression: {errs:?}")))?; + Compiler::default() + .with_funs(funs) + .compile(modules) + .map_err(|errs| (3, "expression", format!("invalid expression: {errs:?}"))) +} - // A file's items are public only when every `mod` on the way to it was - // declared `pub`. Reachability runs over the declared chain, never over the - // directory layout. - let mut declared: BTreeMap> = BTreeMap::new(); - for (index, tree) in &trees { - let file = &sources[*index]; - let mut names: BTreeSet = BTreeSet::new(); - let mut cursor = tree.root_node().walk(); - for child in tree.root_node().named_children(&mut cursor) { - if child.kind() == "mod_item" - && child.child_by_field_name("body").is_none() - && is_pub(child, &file.text) - { - if let Some(name) = child.child_by_field_name("name") { - names.insert(file.text[name.byte_range()].to_string()); - } - } +/// Evaluate the expression and collect its results as JSON values, the shape the +/// envelope classifies rather than re-parses out of emitted bytes. +fn evaluate_values( + expr: &str, + input_bytes: &[u8], +) -> std::result::Result, (i32, &'static str, String)> { + let arena = Arena::default(); + let filter = compile_filter(expr, &arena)?; + let mut out: Vec = Vec::new(); + for input in jaq_json::read::parse_many(input_bytes) { + let input = input.map_err(|e| (4, "input", format!("invalid input: {e:?}")))?; + let ctx = Ctx::>::new(&filter.lut, Vars::new([])); + for result in filter.id.run((ctx, input)).map(unwrap_valr) { + let value = result.map_err(|e| (5, "runtime", format!("runtime error: {e:?}")))?; + let mut buf: Vec = Vec::new(); + let _ = jaq_json::write::write(&mut buf, &jaq_json::write::Pp::default(), 0, &value); + out.push(serde_json::from_slice(&buf).unwrap_or(Value::Null)); } - declared.insert(file.prefix.clone(), names); } + Ok(out) +} - let mut reachable: BTreeSet = BTreeSet::new(); - let mut queue: VecDeque = VecDeque::new(); - if declared.contains_key("") { - reachable.insert(String::new()); - queue.push_back(String::new()); - } else { - // The crate root `lib.rs` is a derived projection excluded from a - // module's owned files, and it declares every top-level source file - // `pub mod`. With no crate-root file present, reachability is therefore - // rooted at each top-level module file; nested modules still flow only - // through the `pub mod` chains their parents declare. - for file in sources { - if !file.prefix.is_empty() && !file.prefix.contains("::") && reachable.insert(file.prefix.clone()) { - queue.push_back(file.prefix.clone()); - } - } - } - while let Some(prefix) = queue.pop_front() { - let Some(children) = declared.get(&prefix) else { - continue; - }; - for name in children.clone() { - let child = join_path(&prefix, &name); - if declared.contains_key(&child) && reachable.insert(child.clone()) { - queue.push_back(child); - } - } - } +/// Evaluate the expression over one JSON value or a stream of values, emitting +/// each result canonically, one per line, under the output ceiling. +fn evaluate( + expr: &str, + input_bytes: &[u8], + raw: bool, +) -> std::result::Result, (i32, &'static str, String)> { + let arena = Arena::default(); + let filter = compile_filter(expr, &arena)?; - let mut items: Vec = Vec::new(); - for (index, tree) in &trees { - let file = &sources[*index]; - reject_public_macros(tree.root_node(), file)?; - if !reachable.contains(&file.prefix) { - // The grammar still binds an unreachable file; it records nothing. - continue; + let mut out: Vec = Vec::new(); + for input in jaq_json::read::parse_many(input_bytes) { + let input = input.map_err(|e| (4, "input", format!("invalid input: {e:?}")))?; + let ctx = Ctx::>::new(&filter.lut, Vars::new([])); + for result in filter.id.run((ctx, input)).map(unwrap_valr) { + let value = + result.map_err(|e| (5, "runtime", format!("runtime error: {e:?}")))?; + let line = serialize(&value, raw); + if out.len() + line.len() > OUTPUT_CEILING_BYTES { + return Err((23, "resource-limit", "output exceeds the 64 MiB ceiling".into())); + } + out.extend_from_slice(&line); } - walk_rust_items(tree.root_node(), file, &file.prefix, &mut items)?; } - Ok(items) + Ok(out) } -fn reject_public_macros(node: Node, file: &SourceFile) -> Result<()> { - let mut cursor = node.walk(); - for child in node.children(&mut cursor) { - if child.kind() == "macro_definition" { - let body = &file.text[child.byte_range()]; - if body - .split(|c: char| !(c.is_alphanumeric() || c == '_')) - .any(|token| token == "pub") - { - return Err(error( - OutcomeCode::MappingIntegrity, - format!("{}: macro_rules! emits `pub` items", file.path), - )); +/// Canonically serialize one jaq value: sorted keys, one trailing newline. jaq's +/// own writer emits valid JSON; re-parsing it and re-emitting canonically pins +/// the bytes. +/// +/// Under `--raw-output` a string result emits its contents instead of its JSON +/// spelling, exactly as jq does, so a shell pipeline never needs a dequoting +/// round trip. Every non-string result is unaffected, because there is no raw +/// form of an object for the flag to invent. +/// +/// The newline is a separator, not an appendix: exactly one between results and +/// one at the end. A raw string that already ends in a newline supplies its own, +/// so nothing is added, and a raw result diffs clean against the file it came +/// from instead of gaining a blank line the source never had. +fn serialize(value: &Val, raw: bool) -> Vec { + let mut buf: Vec = Vec::new(); + let _ = jaq_json::write::write(&mut buf, &jaq_json::write::Pp::default(), 0, value); + let parsed: Value = serde_json::from_slice(&buf).unwrap_or(Value::Null); + if raw { + if let Value::String(text) = &parsed { + let mut out = text.clone().into_bytes(); + if !out.ends_with(b"\n") { + out.push(b'\n'); } + return out; } - reject_public_macros(child, file)?; } - Ok(()) + canonical_bytes(&parsed) } -fn walk_rust_items( - parent: Node, - file: &SourceFile, - prefix: &str, - items: &mut Vec, -) -> Result<()> { - let mut cursor = parent.walk(); - for node in parent.named_children(&mut cursor) { - match node.kind() { - "mod_item" => { - if !is_pub(node, &file.text) { - continue; - } - let name = required_name(node, file)?; - items.push(SurfaceItem { - path: join_path(prefix, &name), - kind: "mod".into(), - signature: format!("pub mod {name}"), - }); - // An inline module extends the visible path; an external - // declaration is answered by that module's own file. - if let Some(body) = node.child_by_field_name("body") { - walk_rust_items(body, file, &join_path(prefix, &name), items)?; - } - } - "use_declaration" => { - if !is_pub(node, &file.text) { - continue; +/// The first impure builtin the expression references, or `None`. A field +/// access such as `.input` is not a builtin call; a string literal never is. +fn refused_builtin(expr: &str) -> Option { + let bytes = expr.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + if c == b'"' { + i += 1; + while i < bytes.len() && bytes[i] != b'"' { + if bytes[i] == b'\\' { + i += 1; } - collect_use_bindings(node, file, prefix, items)?; + i += 1; } - "function_item" => { - if !is_pub(node, &file.text) { - continue; - } - let name = required_name(node, file)?; - items.push(SurfaceItem { - path: join_path(prefix, &name), - kind: "fn".into(), - signature: signature_without_body(node, file), - }); + i += 1; + continue; + } + if c == b'$' { + let start = i + 1; + let mut j = start; + while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { + j += 1; } - "struct_item" | "union_item" => { - if !is_pub(node, &file.text) { - continue; - } - let name = required_name(node, file)?; - let path = join_path(prefix, &name); - let kind = if node.kind() == "struct_item" { - "struct" - } else { - "union" - }; - items.push(SurfaceItem { - path: path.clone(), - kind: kind.into(), - signature: signature_without_body(node, file), - }); - collect_public_fields(node, file, &path, items); + if &expr[start..j] == "ENV" { + return Some("$ENV".to_string()); } - "enum_item" | "trait_item" | "const_item" | "static_item" | "type_item" => { - if !is_pub(node, &file.text) { - continue; - } - let name = required_name(node, file)?; - let kind = match node.kind() { - "enum_item" => "enum", - "trait_item" => "trait", - "const_item" => "const", - "static_item" => "static", - _ => "type", - }; - items.push(SurfaceItem { - path: join_path(prefix, &name), - kind: kind.into(), - signature: text_of(node, &file.text), - }); + i = j.max(i + 1); + continue; + } + if c.is_ascii_alphabetic() || c == b'_' { + let prev = if i > 0 { Some(bytes[i - 1]) } else { None }; + let mut j = i; + while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { + j += 1; } - "impl_item" => { - // A trait impl adds no independently addressable public item. - if node.child_by_field_name("trait").is_some() { - continue; - } - let Some(type_node) = node.child_by_field_name("type") else { - continue; - }; - let type_path = join_path(prefix, &text_of(type_node, &file.text)); - let Some(body) = node.child_by_field_name("body") else { - continue; - }; - let mut body_cursor = body.walk(); - for member in body.named_children(&mut body_cursor) { - if member.kind() != "function_item" || !is_pub(member, &file.text) { - continue; - } - let name = required_name(member, file)?; - items.push(SurfaceItem { - path: format!("{type_path}::{name}"), - kind: "method".into(), - signature: signature_without_body(member, file), - }); - } + let name = &expr[i..j]; + if prev != Some(b'.') && IMPURE_BUILTINS.contains(&name) { + return Some(name.to_string()); } - _ => {} - } - } - Ok(()) -} - -fn collect_public_fields(node: Node, file: &SourceFile, path: &str, items: &mut Vec) { - let Some(body) = node.child_by_field_name("body") else { - return; - }; - let mut cursor = body.walk(); - for field in body.named_children(&mut cursor) { - if field.kind() != "field_declaration" || !is_pub(field, &file.text) { + i = j; continue; } - let Some(name) = field.child_by_field_name("name") else { - continue; - }; - items.push(SurfaceItem { - path: format!("{path}.{}", &file.text[name.byte_range()]), - kind: "field".into(), - signature: text_of(field, &file.text), - }); + i += 1; } + None } -fn collect_use_bindings( - node: Node, - file: &SourceFile, - prefix: &str, - items: &mut Vec, -) -> Result<()> { - if contains_kind(node, "use_wildcard") { - return Err(error( - OutcomeCode::MappingIntegrity, - format!("{}: glob re-export is outside the surface grammar", file.path), - )); - } - let Some(argument) = node.child_by_field_name("argument") else { - return Ok(()); - }; - // A named `pub use` records the written binding, never a resolved signature: - // resolving it would need name resolution the grammar deliberately excludes. - let written = text_of(node, &file.text); - let mut names: Vec = Vec::new(); - collect_use_names(argument, &file.text, &mut names); - for name in names { - items.push(SurfaceItem { - path: join_path(prefix, &name), - kind: "use".into(), - signature: written.clone(), - }); - } - Ok(()) -} +/// The usage detail a missing expression reports. It teaches `.` as the explicit +/// spelling of identity rather than accepting a bare invocation, because the +/// primary caller is a language-model session and a whole-document dump is a +/// context bomb; jq demands its filter the same way. +const MISSING_EXPRESSION: &str = + "no expression given: napl q takes a filter exactly as jq does, so identity is spelled explicitly, as in napl q '.' or napl q '.modules[].name'"; -fn collect_use_names(node: Node, src: &str, out: &mut Vec) { - match node.kind() { - "use_as_clause" => { - if let Some(alias) = node.child_by_field_name("alias") { - out.push(src[alias.byte_range()].to_string()); +/// Parse the jq-style argv: `q [--] [input]`. More than one input is a +/// usage error; `--` is accepted before a path starting with a dash; the +/// expression is always required and there is deliberately no shorthand for +/// identity anywhere, so a bare `napl q`, with or without switches, is a usage +/// error rather than a whole-document dump into a model's context. +fn parse_argv(args: &[String]) -> std::result::Result<(String, Option), String> { + let mut operands: Vec = Vec::new(); + let mut seen_command = false; + let mut past_dashdash = false; + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + if !seen_command { + if arg == "--workspace" { + i += 2; + continue; } - } - "use_list" => { - let mut cursor = node.walk(); - for child in node.named_children(&mut cursor) { - collect_use_names(child, src, out); + if arg.starts_with("--") { + i += 1; + continue; } + seen_command = true; + i += 1; + continue; } - "scoped_use_list" => { - if let Some(list) = node.child_by_field_name("list") { - collect_use_names(list, src, out); - } + if arg == "--" && !past_dashdash { + past_dashdash = true; + i += 1; + continue; } - "scoped_identifier" => { - if let Some(name) = node.child_by_field_name("name") { - out.push(src[name.byte_range()].to_string()); - } + if !past_dashdash && Q_SWITCHES.contains(&arg.as_str()) { + i += 1; + continue; } - "identifier" | "type_identifier" => out.push(src[node.byte_range()].to_string()), - _ => {} + if !past_dashdash && (arg == "--workspace" || arg == "--output") { + i += 2; + continue; + } + operands.push(arg.clone()); + i += 1; + } + let Some(expr) = operands.first().cloned() else { + return Err(MISSING_EXPRESSION.to_string()); + }; + let inputs = &operands[1..]; + if inputs.len() > 1 { + return Err("more than one input given".to_string()); } + let input = inputs.first().map(|token| { + if token == "-" { + Input::Stdin + } else { + Input::File(PathBuf::from(token)) + } + }); + Ok((expr, input)) } -fn contains_kind(node: Node, kind: &str) -> bool { - if node.kind() == kind { - return true; +/// Write a canonical error payload to stderr and return the pinned exit code. +fn fail(code: i32, kind: &str, detail: &str) -> CliResult { + let payload = json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "detail": detail, + "kind": kind, + "outcome": "query-error", + }); + let bytes = canonical_bytes(&payload); + let stderr = std::io::stderr(); + let mut lock = stderr.lock(); + let _ = lock.write_all(&bytes); + let _ = lock.flush(); + CliResult { + stdout: Vec::new(), + exit_code: code, } - (0..node.child_count()).any(|i| node.child(i).is_some_and(|child| contains_kind(child, kind))) } -fn is_pub(node: Node, src: &str) -> bool { - (0..node.child_count()).any(|i| { - node.child(i).is_some_and(|child| { - child.kind() == "visibility_modifier" && &src[child.byte_range()] == "pub" - }) - }) -} +#[cfg(test)] +mod tests { + use super::*; -fn required_name(node: Node, file: &SourceFile) -> Result { - let name = node.child_by_field_name("name").ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!( - "{}: unnamed public {} is outside the surface grammar", - file.path, - node.kind() - ), - ) - })?; - Ok(file.text[name.byte_range()].to_string()) -} + type D = data::JustLut; -fn signature_without_body(node: Node, file: &SourceFile) -> String { - let end = node - .child_by_field_name("body") - .map(|body| body.start_byte()) - .unwrap_or_else(|| node.end_byte()); - token_text(node, &file.text, end) - .trim_end_matches([' ', ';']) - .to_string() -} + fn std_fun_names() -> Vec<&'static str> { + let mut names: Vec<&'static str> = jaq_std::funs::().map(|f| f.0).collect(); + names.sort_unstable(); + names + } -// ---------------------------------------------------------- TypeScript + fn std_def_names() -> Vec<&'static str> { + let mut names: Vec<&'static str> = jaq_std::defs().map(|d| d.name).collect(); + names.sort_unstable(); + names + } -fn extract_typescript(sources: &[SourceFile]) -> Result> { - let language: tree_sitter::Language = tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(); - let mut items: Vec = Vec::new(); - for file in sources { - let tree = parse(&language, file)?; - let root = tree.root_node(); - let mut cursor = root.walk(); - for node in root.named_children(&mut cursor) { - if node.kind() != "export_statement" { - continue; - } - extract_ts_export(node, file, &mut items)?; + #[test] + fn every_filtered_impure_fun_is_a_real_jaq_std_name() { + let names = std_fun_names(); + for impure in IMPURE_FUNS { + assert!( + names.contains(&impure), + "jaq-std no longer registers native fun {impure:?}; the reachability filter is stale and must be re-derived" + ); } } - Ok(items) -} -fn extract_ts_export(node: Node, file: &SourceFile, items: &mut Vec) -> Result<()> { - let raw = &file.text[node.byte_range()]; - if contains_kind(node, "export_clause") { - // `export { a, b }` and `export { a } from "x"`: explicit named bindings. - let written = normalize_ws(raw); - let mut cursor = node.walk(); - for child in node.named_children(&mut cursor) { - if child.kind() != "export_clause" { - continue; - } - let mut clause_cursor = child.walk(); - for spec in child.named_children(&mut clause_cursor) { - if spec.kind() != "export_specifier" { - continue; - } - let bound = spec - .child_by_field_name("alias") - .or_else(|| spec.child_by_field_name("name")); - if let Some(bound) = bound { - items.push(SurfaceItem { - path: join_path(&file.prefix, &file.text[bound.byte_range()]), - kind: "export-binding".into(), - signature: written.clone(), - }); - } - } + #[test] + fn every_filtered_impure_def_is_a_real_jaq_std_name() { + let names = std_def_names(); + for impure in IMPURE_DEFS { + assert!( + names.contains(&impure), + "jaq-std no longer defines {impure:?}; the reachability filter is stale and must be re-derived" + ); } - return Ok(()); - } - if node.child_by_field_name("declaration").is_none() - && node.child_by_field_name("value").is_none() - { - return Err(error( - OutcomeCode::MappingIntegrity, - format!("{}: `export *` is outside the surface grammar", file.path), - )); - } - if node.child_by_field_name("value").is_some() { - return Err(error( - OutcomeCode::MappingIntegrity, - format!("{}: default export is outside the surface grammar", file.path), - )); } - let declaration = node - .child_by_field_name("declaration") - .expect("checked above"); - match declaration.kind() { - "function_declaration" | "generator_function_declaration" => { - if declaration.child_by_field_name("return_type").is_none() { - return Err(error( - OutcomeCode::MappingIntegrity, - format!("{}: exported function has an inferred return type", file.path), - )); - } - let name = required_name(declaration, file)?; - items.push(SurfaceItem { - path: join_path(&file.prefix, &name), - kind: "function".into(), - signature: signature_without_body(declaration, file), - }); - } - "lexical_declaration" | "variable_declaration" => { - let mut cursor = declaration.walk(); - for declarator in declaration.named_children(&mut cursor) { - if declarator.kind() != "variable_declarator" { - continue; - } - let Some(type_node) = declarator.child_by_field_name("type") else { - return Err(error( - OutcomeCode::MappingIntegrity, - format!("{}: exported binding has an inferred type", file.path), - )); - }; - let Some(name) = declarator.child_by_field_name("name") else { - continue; - }; - items.push(SurfaceItem { - path: join_path(&file.prefix, &file.text[name.byte_range()]), - kind: "const".into(), - signature: normalize_ws(&format!( - "export {}", - &file.text[name.start_byte()..type_node.end_byte()] - )), - }); - } - } - "class_declaration" | "abstract_class_declaration" => { - let name = required_name(declaration, file)?; - items.push(SurfaceItem { - path: join_path(&file.prefix, &name), - kind: "class".into(), - signature: signature_without_body(declaration, file), - }); - } - "interface_declaration" | "type_alias_declaration" | "enum_declaration" => { - let name = required_name(declaration, file)?; - let kind = match declaration.kind() { - "interface_declaration" => "interface", - "type_alias_declaration" => "type", - _ => "enum", - }; - items.push(SurfaceItem { - path: join_path(&file.prefix, &name), - kind: kind.into(), - signature: text_of(declaration, &file.text), - }); + + #[test] + fn the_compiled_fun_set_excludes_every_impure_builtin() { + let filtered: Vec<&'static str> = jaq_std::funs::() + .filter(|f| !IMPURE_FUNS.contains(&f.0)) + .map(|f| f.0) + .collect(); + for impure in IMPURE_FUNS { + assert!( + !filtered.contains(&impure), + "impure fun {impure:?} is still reachable in the compiled set" + ); } - other => { - return Err(error( - OutcomeCode::MappingIntegrity, - format!("{}: exported {other} is outside the surface grammar", file.path), - )); + } + + #[test] + fn the_compiled_def_set_excludes_every_impure_definition() { + let filtered: Vec<&'static str> = jaq_std::defs() + .filter(|d| !IMPURE_DEFS.contains(&d.name)) + .map(|d| d.name) + .collect(); + for impure in IMPURE_DEFS { + assert!( + !filtered.contains(&impure), + "impure def {impure:?} is still reachable in the compiled set" + ); } } - Ok(()) + + #[test] + fn the_jaq_std_fun_roster_is_frozen_so_a_new_builtin_trips_this_test() { + let expected: Vec<&'static str> = FROZEN_STD_FUNS.to_vec(); + assert_eq!( + std_fun_names(), + expected, + "jaq-std's native filter roster changed: classify each added or renamed name (pure -> add to FROZEN_STD_FUNS, clock/timezone/env/randomness/io -> add to IMPURE_FUNS) before shipping the upgrade" + ); + } + + /// The complete native-filter roster jaq-std registers, frozen at the pinned + /// version so any upgrade that adds or renames a filter fails the test above. + const FROZEN_STD_FUNS: [&str; 96] = [ + "acos", "acosh", "ascii_downcase", "ascii_upcase", "asin", "asinh", "atan", "atan2", + "atanh", "cbrt", "ceil", "copysign", "cos", "cosh", "debug_empty", "decode_base64", + "decode_uri", "encode_base64", "encode_uri", "endswith", "env", "erf", "erfc", + "escape_html", "escape_sh", "exp", "exp10", "exp2", "explode", "expm1", "fabs", "fdim", + "floor", "fma", "fmax", "fmin", "fmod", "frexp", "fromdateiso8601", "gmtime", "group_by", + "halt", "hypot", "ilogb", "implode", "j0", "j1", "jn", "ldexp", "lgamma", "localtime", + "log", "log10", "log1p", "log2", "ltrim", "ltrimstr", "matches", "max_by_or_empty", + "min_by_or_empty", "mktime", "modf", "nearbyint", "nextafter", "now", "pow", "remainder", + "reverse", "rint", "round", "rtrim", "rtrimstr", "scalbln", "sin", "sinh", "sort", + "sort_by", "split_", "split_matches", "sqrt", "startswith", "stderr_empty", + "strflocaltime", "strftime", "strptime", "tan", "tanh", "tgamma", "todateiso8601", "trim", + "trunc", "unescape_html", "utf8bytelength", "y0", "y1", "yn", + ]; } -napl-store tree v1 139 -{"entries":[{"hash":"ce845a9e5d14f391f955b836d94119c54041efcf8661908b6df2c356fe9ab5ab","kind":"file","mode":33188,"name":"git_store.rs"}]} -napl-store event v1 158 -{"content":"5934e49fb4e7ff5d932984b2ae35bf02405608a3343f1eccf9308e7c52ccec03","parents":["f0b8a3c47a244f6054199d9672f8fb7eb24f451e467af370d2026e72b30601d2"]} -napl-store snapshot v1 671 -{"contractVersion":3,"event":"86cf27e1f5896a81f28f1c71ec9658129a767c388c0a01f8a30bbd5196c83ad8","states":[{"module":"commands","state":"84428602c5cbe86749e6c4629d39f0b5aba863df62c3995134411ae5e016d853","target":"rust"},{"module":"napl","state":"55b8614a73ee118bd8731c37ce598fe2b9fcc799e89ead46639d0e57c4307472","target":"rust"},{"module":"napl_store","state":"1506a85bc6ef2b6f1cc8426700959b114ea5532cd02cf43e57d2a82ab7b4fb8a","target":"rust"},{"module":"query","state":"bc9f18a34748d245185298746e0db6fcac33afe57ddff8836927bb668f4172b6","target":"rust"},{"module":"usage_proxy","state":"ba1c5035ed5d02088e38bd72fb9b3545bdc4e67aaf793a9ba0690cbb18c06e9f","target":"rust"}]} -napl-store module-state v1 562 -{"attribution":{"promptHash":"0e979f14174777d495141f1638be430cd20960c15eeebc27a1889a562c771dab","record":"985ad0289abe71d78be6986f66e458dcbb06a8c9d12b2f2e7034223351b7a862","stale":false},"contractVersion":4,"inputKey":"30b2e4781c44a24c697a4a0d9f331ef76e62b87bdcb7422a4f43048d19547510","module":"commands","outputTree":"40602bb2e8e06bd384895319bc98b8ed659e32d428b4b7c9688380bc7aca41d3","sliceHash":"7c2429007fb859cf2c5b5a8745a211b4dc24690e797bc3c8e39caa3571a424c1","surfaceHash":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust"} -napl-store tree v1 132 -{"entries":[{"hash":"51817c7e1e3e6e351e85b6f8f5123fa042f2381f73e104ea2ff9a6acad29baf9","kind":"dir","mode":16384,"name":"crates"}]} +napl-store transition v1 632 +{"baseStateKey":"6d4798b1c65398c11513e3f38c5ce2f841b6d0e7f4a7c7274728fb106d666b4b","contractVersion":4,"derivedProjections":[],"inputKey":"c5d75a16ebd9b47dee925970788b6d6877b9516c34c1733a6a2b849fe75b981f","installPaths":[],"kind":"docs-only","module":"query","newState":"e69889078f43de5c958a75fc18b5cd16b9ada2088fe9fe249fc0d9444291706d","prompt":"664271626f562daa87989461e8d10f3eff40a284b4810755bbbd7aca221ff81d","readSet":["prompt:953a5312661e1d886229af4e8b9c4d1ba4f999c0f68dab2e97a4ad590c394c5f"],"surface":"cb2b0ee10d67af3091437305664b3ea3b0419b83dee9436cb4ebea480fef83d3","target":"rust","writeSet":["module-state:rust/query"]} napl-store snapshot v1 895 -{"contractVersion":4,"event":"fbe075025e33525f74179f1505d87984295e5b10d3c021a45a938e2a27af97ba","states":[{"module":"agent","state":"67df6d3a1c20bac424e4a0104bf02ea3dd17d728c930e641c212ce0bfd3260d1","target":"rust"},{"module":"commands","state":"723676083558c83d83926d043c8edd16bb2d48cfe3722fde330cf0351870b1a5","target":"rust"},{"module":"git-store","state":"830001785de125922d57970caf0af5bcf679e6bdc24f008fd39f80867db57535","target":"rust"},{"module":"napl","state":"145d84afd1892b41d64f472a44ed100f908d8255144a8c7ba0335159325e56d5","target":"rust"},{"module":"napl_store","state":"8ef0d60befb8a6c444e8a5e0e22a1691167422a114f4e5bbcb088c6f59f73860","target":"rust"},{"module":"query","state":"7aa7e0b4e8cc02142a8aa14ca243a5f2058d72de2f65d7ddb7c59e3d201b692a","target":"rust"},{"module":"usage_proxy","state":"acd8f8cd1e71585d8b25804d42455806f39659af2949e7ed369deedcb63abbb2","target":"rust"}]} -napl-store snapshot v1 891 -{"contractVersion":4,"event":"3808efee99d8af834c52c0f732c9a199b90a427dd0e299c875eb9661ddff642d","states":[{"module":"agent","state":"b5cde0e371a87ce3810de7c7b7af9e48bf453094540fce927bde21dde6b576b9","target":"rust"},{"module":"commands","state":"74f67c7538797091e6d19bd6f87099d0b414743cc4cc151db667619ff4a48d03","target":"rust"},{"module":"napl","state":"76f91323327cf2f0a66946473c1176b3988c304d57f2c6c67626991dd449cdd1","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"2ceea09f82f0700bbe48909b0c56aa0e69cc477dd51287bd88cd2e87f6f7a1e1","target":"rust"},{"module":"serve","state":"8b31e47aad33789775fb32ae56868e37db7a9dd174e6df3a0262781a7ed2f6ae","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} -napl-store event v1 158 -{"content":"a3707d0c37b7be86384daa2cedd8d96389e59bb0ab5a50bf7d50d1001ad7f623","parents":["5ffc124faaf33a047ed806d69782e58ec50f5bac472139fec60eee8e76034898"]} -napl-store blob v1 14110 -//! Attribution: the mapping from prompt body line ranges to generated line -//! ranges, the one owned fact whose write path is the commit. -//! -//! An attribution update rides a commit for its module. Commit validates every -//! claim it makes against content it can recompute: the file paths against the -//! module's owned globs and the ingested candidate, the code line ranges against -//! the candidate bytes, the prompt line ranges against the current normalized -//! body. A claim that cannot be proven is rejected, never guessed past. The -//! record is content addressed and materialized as a view the language server -//! reads; freshness is a separate fact the module state carries. - +{"contractVersion":4,"event":"6bcd56affb9c34552c2a27389cb81b291c1d9e4849b43d64ba9c36481483ba08","states":[{"module":"agent","state":"f26d6fde72e9fc4f86b6909dc51455aaf37c8a17ed31d6db11e94d9a0408b324","target":"rust"},{"module":"commands","state":"723676083558c83d83926d043c8edd16bb2d48cfe3722fde330cf0351870b1a5","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"e5181ff4a21adb9dbd040fac3f0a5c958ff8273c4c141fe9f4a438eb2b02d800","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"0d9de5138a0e51434c732db16fde21ad6ecfb1f4b0f8fb2789c8b538784b1bda","target":"rust"},{"module":"usage_proxy","state":"acd8f8cd1e71585d8b25804d42455806f39659af2949e7ed369deedcb63abbb2","target":"rust"}]} +napl-store tree v1 129 +{"entries":[{"hash":"eba0c9432525af646bd212cf7103c0cb9f7726adc11db5352779581741a8f8db","kind":"dir","mode":16384,"name":"src"}]} +napl-store snapshot v1 781 +{"contractVersion":4,"event":"2507f26208d4c6efc71f3506a5ee94e70e8a69e9941137abad82037fdd5fb8f2","states":[{"module":"agent","state":"651686af1f5c9f3aad425d1ca7f0de325e4dc362cb59d9a71dc7d75f3216972c","target":"rust"},{"module":"commands","state":"efeb55ee16b2b444a3502f2e63424bcaaec34c9f676dd2c74f00944dceec5a8d","target":"rust"},{"module":"napl","state":"338c9b575a24883622bf3cb1e3276c24a26c31ef34a5097927420523727b6d26","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"79b5813169ed5adeb10cc35fc52c22e390bdef3e22fb2a3dd0b3a300ec844dc4","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} +napl-store tree v1 132 +{"entries":[{"hash":"366a22294f2c4a9eccda8fd05f4b6cbf265bb94cc47827f9bfa114df64e5695a","kind":"dir","mode":16384,"name":"crates"}]} +napl-store blob v1 17388 use std::collections::BTreeMap; +use std::io::{BufRead, Write}; +use std::path::{Path, PathBuf}; -use napl_store::store_hash::{object_name, ObjectKind}; +use serde_json::{json, Value}; -use crate::error::{error, OutcomeCode, Result}; -use crate::paths::is_under; -use crate::target::{is_owned_path, TargetDef}; +use crate::attribution::{parse as parse_attribution, Attribution, Entry}; -/// One attributed span: a prompt body line range, the generated file and its -/// line range, and a human note. A law entry owns no file at all: it is -/// contract-level prose that governs the module rather than any single path, so -/// its `file` is `None` and its code line range is unused. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Entry { - pub prompt_start: u32, - pub prompt_end: u32, - pub file: Option, - pub line_start: u32, - pub line_end: u32, - pub note: String, -} +/// The banner a hover carries when the record it resolved is marked stale, so a +/// reader is told the mapping was never proven rather than being shown nothing. +const STALE_MARKER: &str = "stale attribution: regenerate with a fresh commit"; -impl Entry { - /// True when the entry owns no file: a law governing the whole module. - pub fn is_law(&self) -> bool { - self.file.is_none() +pub fn serve(reader: &mut R, writer: &mut W) { + let mut docs: BTreeMap = BTreeMap::new(); + while let Some(message) = read_message(reader) { + let method = message + .get("method") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let id = message.get("id").cloned(); + match method.as_str() { + "initialize" => respond(writer, id, initialize_result()), + "initialized" => {} + "shutdown" => respond(writer, id, Value::Null), + "exit" => break, + "textDocument/didOpen" => track_open(&mut docs, &message), + "textDocument/didChange" => track_change(&mut docs, &message), + "textDocument/didClose" => track_close(&mut docs, &message), + "textDocument/hover" => respond(writer, id, hover(&docs, &message)), + "textDocument/definition" => respond(writer, id, definition(&docs, &message)), + "textDocument/references" => respond(writer, id, references(&docs, &message)), + "textDocument/codeLens" => respond(writer, id, code_lens(&docs, &message)), + _ => { + if id.is_some() { + respond(writer, id, Value::Null); + } + } + } } } -/// A parsed attribution record for one module and target. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Attribution { - pub module: String, - pub target: String, - /// True when the record was installed through the `--no-verify` escape hatch - /// and therefore was never proven against the content it points at. - pub stale: bool, - pub entries: Vec, +fn initialize_result() -> Value { + json!({ + "capabilities": { + "codeLensProvider": { "resolveProvider": false }, + "definitionProvider": true, + "hoverProvider": true, + "referencesProvider": true, + "textDocumentSync": 1 + }, + "serverInfo": { "name": "napl-lsp" } + }) } -/// Parse an attribution record from its YAML view. -/// -/// A record the parser cannot make sense of yields `None` rather than an error, -/// so a reader degrades to empty instead of blocking on a malformed byte. -pub fn parse(text: &str) -> Option { - let unified = text.replace("\r\n", "\n").replace('\r', "\n"); - let mut module: Option = None; - let mut target: Option = None; - let mut stale = false; - let mut entries: Vec = Vec::new(); - let mut in_entries = false; - let mut entry_indent: Option = None; - let mut current: Vec<(String, Vec)> = Vec::new(); - let mut pending_key: Option = None; - - let flush = |current: &mut Vec<(String, Vec)>, entries: &mut Vec| { - if let Some(entry) = build_entry(current) { - entries.push(entry); - } - current.clear(); +fn respond(writer: &mut W, id: Option, result: Value) { + let Some(id) = id else { + return; }; + write_message( + writer, + &json!({ "id": id, "jsonrpc": "2.0", "result": result }), + ); +} - for raw in unified.split('\n') { - if raw.trim().is_empty() { - continue; - } - let indent = raw.len() - raw.trim_start().len(); - if indent == 0 { - if in_entries { - flush(&mut current, &mut entries); - in_entries = false; - } - let (key, value) = raw.split_once(':')?; - match key.trim() { - "module" => module = Some(unquote(value.trim())), - "target" => target = Some(unquote(value.trim())), - "stale" => stale = value.trim() == "true", - "entries" => { - in_entries = true; - entry_indent = None; - } - _ => {} - } - pending_key = None; - continue; - } - if !in_entries { - continue; +fn read_message(reader: &mut R) -> Option { + let mut content_length: Option = None; + loop { + let mut line = String::new(); + let read = reader.read_line(&mut line).ok()?; + if read == 0 { + return None; } - let trimmed = raw.trim_start(); - let is_item = trimmed == "-" || trimmed.starts_with("- "); - let starts_entry = is_item && entry_indent.map(|width| indent <= width).unwrap_or(true); - if starts_entry { - flush(&mut current, &mut entries); - entry_indent = Some(indent); - pending_key = None; - let rest = trimmed.strip_prefix("- ").unwrap_or("").trim(); - if !rest.is_empty() { - append_field(&mut current, &mut pending_key, rest); - } - continue; + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + break; } - if is_item { - let value = trimmed.strip_prefix("- ").unwrap_or("").trim(); - if let Some(key) = &pending_key { - if let Some(slot) = current.iter_mut().find(|(field, _)| field == key) { - slot.1.push(unquote(value)); - } - } - continue; + if let Some(value) = trimmed.strip_prefix("Content-Length:") { + content_length = value.trim().parse::().ok(); } - append_field(&mut current, &mut pending_key, trimmed); - } - if in_entries { - flush(&mut current, &mut entries); } + let length = content_length?; + let mut buffer = vec![0u8; length]; + reader.read_exact(&mut buffer).ok()?; + serde_json::from_slice(&buffer).ok() +} - Some(Attribution { - module: module?, - target: target.unwrap_or_default(), - stale, - entries, - }) +fn write_message(writer: &mut W, value: &Value) { + let body = serde_json::to_vec(value).unwrap_or_default(); + let _ = write!(writer, "Content-Length: {}\r\n\r\n", body.len()); + let _ = writer.write_all(&body); + let _ = writer.flush(); } -fn append_field( - current: &mut Vec<(String, Vec)>, - pending_key: &mut Option, - text: &str, -) { - let Some((key, value)) = text.split_once(':') else { +fn track_open(docs: &mut BTreeMap, message: &Value) { + let item = &message["params"]["textDocument"]; + if let (Some(uri), Some(text)) = (item["uri"].as_str(), item["text"].as_str()) { + docs.insert(uri.to_string(), text.to_string()); + } +} + +fn track_change(docs: &mut BTreeMap, message: &Value) { + let params = &message["params"]; + let Some(uri) = params["textDocument"]["uri"].as_str() else { return; }; - let key = key.trim().to_string(); - let value = value.trim(); - if value.is_empty() { - current.push((key.clone(), Vec::new())); - *pending_key = Some(key); - } else { - current.push((key, vec![unquote(value)])); - *pending_key = None; + let changes = params["contentChanges"].as_array(); + if let Some(text) = changes + .and_then(|list| list.last()) + .and_then(|change| change["text"].as_str()) + { + docs.insert(uri.to_string(), text.to_string()); } } -fn build_entry(fields: &[(String, Vec)]) -> Option { - let mut prompt: Option<(u32, u32)> = None; - let mut lines: Option<(u32, u32)> = None; - let mut file: Option = None; - let mut note = String::new(); - let mut law = false; - for (key, values) in fields { - match key.as_str() { - "promptLines" => prompt = pair(values), - "lines" => lines = pair(values), - "file" => file = values.first().cloned(), - "law" => law = values.first().map(|v| v == "true").unwrap_or(false), - "note" => note = values.first().cloned().unwrap_or_default(), - _ => {} - } +fn track_close(docs: &mut BTreeMap, message: &Value) { + if let Some(uri) = message["params"]["textDocument"]["uri"].as_str() { + docs.remove(uri); } - let (prompt_start, prompt_end) = prompt?; - if law { - // A law entry owns no file and carries no code line range. - return Some(Entry { - prompt_start, - prompt_end, - file: None, - line_start: 0, - line_end: 0, - note, - }); +} + +fn load_records(workspace: &Path) -> Vec { + let dir = workspace.join(".napl").join("attribution"); + let mut names: Vec = match std::fs::read_dir(&dir) { + Ok(entries) => entries + .filter_map(|entry| entry.ok()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| name.ends_with(".yaml")) + .collect(), + Err(_) => return Vec::new(), + }; + names.sort(); + let mut records: Vec = Vec::new(); + for name in names { + if let Ok(text) = std::fs::read_to_string(dir.join(&name)) { + if let Some(record) = parse_attribution(&text) { + records.push(record); + } + } } - let (line_start, line_end) = lines?; - Some(Entry { - prompt_start, - prompt_end, - file: Some(file?), - line_start, - line_end, - note, - }) + records } -fn pair(values: &[String]) -> Option<(u32, u32)> { - let first = values.first()?.parse::().ok()?; - let second = values.get(1)?.parse::().ok()?; - Some((first.min(second), first.max(second))) +enum Located { + Prompt { module: String, line: u32 }, + Generated { file: String, line: u32 }, + None, } -fn unquote(text: &str) -> String { - let quoted = text.len() >= 2 - && ((text.starts_with('"') && text.ends_with('"')) - || (text.starts_with('\'') && text.ends_with('\''))); - if !quoted { - return text.to_string(); - } - let inner = &text[1..text.len() - 1]; - let mut out = String::with_capacity(inner.len()); - let mut chars = inner.chars(); - while let Some(c) = chars.next() { - if c == '\\' { - if let Some(escaped) = chars.next() { - out.push(escaped); +fn uri_to_path(uri: &str) -> String { + let stripped = uri.strip_prefix("file://").unwrap_or(uri); + percent_decode(stripped) +} + +fn resolve_workspace(uri: &str) -> Option { + let path = uri_to_path(uri); + crate::workspace::find_workspace_root(Path::new(&path)) +} + +fn percent_decode(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let high = (bytes[index + 1] as char).to_digit(16); + let low = (bytes[index + 2] as char).to_digit(16); + if let (Some(high), Some(low)) = (high, low) { + out.push((high * 16 + low) as u8); + index += 3; + continue; } - continue; } - out.push(c); + out.push(bytes[index]); + index += 1; } - out + String::from_utf8_lossy(&out).into_owned() } -/// Render a scalar as a quoted, escaped YAML value. -/// -/// A module name is any non-empty single-line scalar, so every name the record -/// carries is written quoted rather than interpolated raw: a name holding YAML -/// metacharacters reparses to the identical name. `unquote` is the exact -/// inverse. -fn quoted(value: &str) -> String { - let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); - format!("\"{escaped}\"") +fn relativize(workspace: &Path, path: &str) -> String { + match Path::new(path).strip_prefix(workspace) { + Ok(rest) => rest.to_string_lossy().replace('\\', "/"), + Err(_) => path.to_string(), + } } -/// A note reduced to the small character set the view grammar round trips: a -/// double quote or a newline in a note would break the flat YAML the parser -/// reads back, so both collapse deterministically. -fn sanitize_note(note: &str) -> String { - note.replace(['\n', '\r'], " ").replace('"', "'") +fn stem(rel: &str) -> Option { + let name = rel.rsplit('/').next()?; + name.strip_suffix(".napl").map(str::to_string) } -/// Serialize an attribution to its canonical YAML view. -/// -/// This is a pure function of the record: the same attribution serializes to the -/// same bytes, which is what lets the view double as the content-addressed -/// record. Entries are emitted in the order they arrive, so a producer controls -/// the record's shape and the compiler only proves it. -pub fn to_yaml(attribution: &Attribution) -> Vec { - let mut out = String::new(); - out.push_str(&format!("module: {}\n", quoted(&attribution.module))); - out.push_str(&format!("target: {}\n", quoted(&attribution.target))); - out.push_str(&format!("stale: {}\n", attribution.stale)); - out.push_str("entries:\n"); - for entry in &attribution.entries { - out.push_str(" - promptLines:\n"); - out.push_str(&format!(" - {}\n", entry.prompt_start)); - out.push_str(&format!(" - {}\n", entry.prompt_end)); - match &entry.file { - Some(file) => { - out.push_str(&format!(" file: {}\n", quoted(file))); - out.push_str(" lines:\n"); - out.push_str(&format!(" - {}\n", entry.line_start)); - out.push_str(&format!(" - {}\n", entry.line_end)); +fn generated_match(rel: &str, file: &str) -> bool { + rel == file || rel.ends_with(&format!("/{file}")) +} + +fn document_text( + workspace: &Path, + docs: &BTreeMap, + uri: &str, + rel: &str, +) -> Option { + if let Some(text) = docs.get(uri) { + return Some(text.clone()); + } + std::fs::read_to_string(workspace.join(rel)).ok() +} + +fn frontmatter_lines(text: &str) -> u32 { + let mut fences = 0; + let mut count = 0u32; + for line in text.split('\n') { + count += 1; + if line.trim_end_matches('\r') == "---" { + fences += 1; + if fences == 2 { + return count; } - None => out.push_str(" law: true\n"), } - out.push_str(&format!(" note: {}\n", quoted(&sanitize_note(&entry.note)))); } - out.into_bytes() + 0 } -/// The content identity of an attribution record: the blob name of its canonical -/// view. The view the language server reads is these same bytes. -pub fn record_hash(attribution: &Attribution) -> String { - object_name(ObjectKind::Blob, &to_yaml(attribution)) +fn locate( + workspace: &Path, + docs: &BTreeMap, + records: &[Attribution], + uri: &str, + line0: u32, +) -> Located { + let path = uri_to_path(uri); + let rel = relativize(workspace, &path); + let raw_line = line0 + 1; + if let Some(module) = stem(&rel) { + if records.iter().any(|record| record.module == module) { + let offset = document_text(workspace, docs, uri, &rel) + .map(|text| frontmatter_lines(&text)) + .unwrap_or(0); + if raw_line > offset { + return Located::Prompt { + module, + line: raw_line - offset, + }; + } + return Located::None; + } + } + Located::Generated { + file: rel, + line: raw_line, + } } -/// The workspace-relative path an attribution view materializes at. -/// -/// The module name is a record key here, so it goes through the reversible -/// segment encoding rather than into the path raw. A name already drawn from the -/// safe subset keeps its on-disk spelling. -pub fn view_path(module: &str) -> String { - format!(".napl/attribution/{}.yaml", crate::paths::encode_name(module)) +fn prompt_entries<'a>(records: &'a [Attribution], module: &str, line: u32) -> Vec<&'a Entry> { + records + .iter() + .filter(|record| record.module == module) + .flat_map(|record| record.entries.iter()) + .filter(|entry| entry.prompt_start <= line && line <= entry.prompt_end) + .collect() } -/// Re-root every entry's file prefix from `old_root` to `dest`, leaving line -/// content untouched. This is the only recomputation a relocation performs on an -/// attribution: the mapping's lines are the same lines, at a new placement. -pub fn remap_prefix(attribution: &mut Attribution, old_root: &str, dest: &str) { - let prefix = format!("{old_root}/"); - for entry in &mut attribution.entries { - if let Some(file) = &entry.file { - if let Some(rest) = file.strip_prefix(&prefix) { - entry.file = Some(format!("{dest}/{rest}")); +fn generated_entries<'a>( + records: &'a [Attribution], + file: &str, + line: u32, +) -> Vec<(&'a Attribution, &'a Entry)> { + let mut out: Vec<(&Attribution, &Entry)> = Vec::new(); + for record in records { + for entry in &record.entries { + // A law entry owns no file, so it never resolves from a generated line. + let Some(entry_file) = &entry.file else { + continue; + }; + if generated_match(file, entry_file) + && entry.line_start <= line + && line <= entry.line_end + { + out.push((record, entry)); } } } + out } -/// The number of lines a body of text addresses. -fn line_count(text: &str) -> u32 { - text.lines().count() as u32 +fn markdown(lines: Vec) -> Value { + json!({ "contents": { "kind": "markdown", "value": lines.join("\n") } }) } -/// Validate every claim an attribution update makes, before anything installs. -/// -/// A claim the compiler cannot prove from content is `invalid-candidate`, named -/// exactly: a file outside the module's owned outputs or absent from the -/// candidate, a code line range past the end of its file, a prompt line range -/// past the end of the normalized body. An update that maps nothing is rejected -/// too, so an empty update can never stand in for a real one. -pub fn validate( - target: &TargetDef, - root: &str, - module: &str, - candidate_files: &BTreeMap>, - prompt_body_lines: u32, - attribution: &Attribution, -) -> Result<()> { - let bad = |why: String| error(OutcomeCode::InvalidCandidate, why); - if attribution.module != module { - return Err(bad(format!( - "attribution names module {:?} but the commit is for {module:?}", - attribution.module - ))); - } - if attribution.target != target.name { - return Err(bad(format!( - "attribution names target {:?} but the commit is for {:?}", - attribution.target, target.name - ))); - } - if attribution.entries.is_empty() { - return Err(bad(format!( - "attribution update for module {module:?} maps nothing" - ))); - } - for entry in &attribution.entries { - // Every entry, law or not, pins a prompt body range that must exist. - if entry.prompt_start == 0 || entry.prompt_start > entry.prompt_end { - return Err(bad(format!( - "attribution entry for {:?} has an empty prompt range {}-{}", - entry.file, entry.prompt_start, entry.prompt_end - ))); - } - if entry.prompt_end > prompt_body_lines { - return Err(bad(format!( - "attribution entry for {:?} names prompt lines {}-{} but the body has {} lines", - entry.file, entry.prompt_start, entry.prompt_end, prompt_body_lines - ))); - } - // A law entry owns no file: its prompt range is the whole claim. - let Some(file) = &entry.file else { - continue; - }; - if entry.line_start == 0 || entry.line_start > entry.line_end { - return Err(bad(format!( - "attribution entry for {file:?} has an empty code range {}-{}", - entry.line_start, entry.line_end - ))); - } - if !is_under(file, root) || !is_owned_path(target, root, file) { - return Err(bad(format!( - "attribution entry names {file:?}, which is not an owned output of the crate {root:?}" - ))); +fn hover(docs: &BTreeMap, message: &Value) -> Value { + let params = &message["params"]; + let uri = params["textDocument"]["uri"].as_str().unwrap_or(""); + let line0 = params["position"]["line"].as_u64().unwrap_or(0) as u32; + let Some(workspace) = resolve_workspace(uri) else { + return Value::Null; + }; + let workspace = workspace.as_path(); + let records = load_records(workspace); + match locate(workspace, docs, &records, uri, line0) { + Located::Prompt { module, line } => { + let matched = prompt_entries(&records, &module, line); + if matched.is_empty() { + return Value::Null; + } + let stale = records + .iter() + .any(|record| record.module == module && record.stale); + let mut lines: Vec = Vec::new(); + if stale { + lines.push(STALE_MARKER.to_string()); + } + for entry in matched { + match &entry.file { + Some(file) => lines.push(format!( + "{}:{}-{} - {}", + file, entry.line_start, entry.line_end, entry.note + )), + None => lines.push(format!("law - {}", entry.note)), + } + } + markdown(lines) } - let Some(bytes) = candidate_files.get(file) else { - return Err(bad(format!( - "attribution entry names {file:?}, which the candidate does not contain" - ))); - }; - let lines = line_count(&String::from_utf8_lossy(bytes)); - if entry.line_end > lines { - return Err(bad(format!( - "attribution entry names {file:?} lines {}-{} but the file has {} lines", - entry.line_start, entry.line_end, lines - ))); + Located::Generated { file, line } => { + let matched = generated_entries(&records, &file, line); + if matched.is_empty() { + return Value::Null; + } + let stale = matched.iter().any(|(record, _)| record.stale); + let mut lines: Vec = Vec::new(); + if stale { + lines.push(STALE_MARKER.to_string()); + } + for (record, entry) in matched { + let rel = discovered_prompt(workspace, &record.module) + .unwrap_or_else(|| format!("{}.napl", record.module)); + lines.push(format!( + "{}:{}-{} - {}", + rel, entry.prompt_start, entry.prompt_end, entry.note + )); + } + markdown(lines) } + Located::None => Value::Null, } - Ok(()) } -napl-store receipt v1 4613 -{"baseStateKey":"21631447bbac552e78b823a151bc6b9b6132a82db42e636c7befdfc081f3ed2d","baseStoreSnapshot":"9a0caff48b5cf4bc11b1b489b1e9ee8b2c1e98bb3ee5a3bc45fb240d9bf6d7f4","baseWorkspaceHash":"0193dd82442305411885e027f4e227b6accf0abd4afa4ca1c0922aa9c1a0b818","candidateTreeHash":"5f7088714d13f49b2b5e1fa022fd18a6456eb6dcadf4b6cb9151230f8255bbba","contractVersion":3,"inputKey":"15cca5b55715b06f2dce1abb9e5aed739865b58dadbe321d4b68837f253d0b97","module":"napl","outputManifest":[{"hash":"5521a1f18a7e90cd2b61be3ae7557245fd71cd03251ffb1f2dd105f79bb3128e","path":"crates/napl/src/attribution.rs"},{"hash":"da2f011cafa328905636f1c49fe397e38ac42269963da6915171fde6742bbf28","path":"crates/napl/src/authoring.rs"},{"hash":"bcb41a480457775ca3f239c29b7f6ec05cb48e121a6b04172b3b2fe63afaca68","path":"crates/napl/src/blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","path":"crates/napl/src/candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","path":"crates/napl/src/canon.rs"},{"hash":"a258b748a1c40209cc4b9d748a9257c8b314224856c232ed8982aa62002e9c31","path":"crates/napl/src/cli.rs"},{"hash":"4df745d53cdf794e90eb98b84242a5575da64075ca21716afe7efca51caa0a30","path":"crates/napl/src/commandset.rs"},{"hash":"ec558800ae0e5d2cb0cc76d27acf7a03b5e98f69b8ad7b2076a0c7d19a29538a","path":"crates/napl/src/commit.rs"},{"hash":"51a5efd09235e6a66de7381faaa217a51136ca9c7d2b10285f99af8ebe4ffc6f","path":"crates/napl/src/compile.rs"},{"hash":"14768a9b96b8dd25e1efa36ddad54dd48c5feab01b57a4be8883f7ce8b524eb8","path":"crates/napl/src/cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","path":"crates/napl/src/decision.rs"},{"hash":"04a648a127be983c89a99181ee26e28401ee00f84ea0b10b6c050ecd263a1b2c","path":"crates/napl/src/directive.rs"},{"hash":"28059a89256aad1de78ecd31d7b77cae21772bbc6e96fe8ba1cdb9044048f410","path":"crates/napl/src/disk.rs"},{"hash":"0b71431883adea0e4f3dc3a77b968b69ac4f18348387362cd3c48fbc023d8bb4","path":"crates/napl/src/error.rs"},{"hash":"5b7c0553530cac82c017fc61327a6c120e284d0a6d0966e24f0d63fe07d134f5","path":"crates/napl/src/evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","path":"crates/napl/src/graph.rs"},{"hash":"f0641fc8a646a08a653d7a2ba1b8fe68da672f027919a1fccb53254e7bcd456a","path":"crates/napl/src/lint.rs"},{"hash":"226d2a965911245d7b23dc6e878c6c444e53ba52e5b84ec4a69efb81bcd1c181","path":"crates/napl/src/lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","path":"crates/napl/src/main.rs"},{"hash":"86fabd45da78a46bbfa7e92465788159360c0abaa4402f10ca433779b419ee0d","path":"crates/napl/src/maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","path":"crates/napl/src/mv.rs"},{"hash":"3ae089b923327032845c386f2332ff653a08f25ccec28e0786094fa05fd77a00","path":"crates/napl/src/objects.rs"},{"hash":"3c512923df554d1876eed8803ad3d4f0f34029f937b19529b6245a687d9b6a59","path":"crates/napl/src/paths.rs"},{"hash":"5247e34ebd33f612010e0c38d0bf1b4f2b001de64888ab0443c9d38810546cec","path":"crates/napl/src/porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","path":"crates/napl/src/receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","path":"crates/napl/src/slice.rs"},{"hash":"593720d310b290ed164410f0dc33277e10006a41f90e438d46fae8579387f73c","path":"crates/napl/src/source.rs"},{"hash":"c1d23f637ba514ce61a14500549419fdb6798ec084a8434001b665f7232dc8a4","path":"crates/napl/src/staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","path":"crates/napl/src/store_view.rs"},{"hash":"9ae420e34160e7c2a6c2ba092aaa9c9ff1df622ece595849d95bf5c5eb7cbe81","path":"crates/napl/src/surface.rs"},{"hash":"2aeba094700b60cf2ab6e0c88dd79228028a72f6b9b0e2da489dce572f2d2641","path":"crates/napl/src/target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","path":"crates/napl/src/textdiff.rs"},{"hash":"c2690840af51faafd65929bdca676747ab87806e1e05ec50f2d3f676156373e5","path":"crates/napl/src/transaction.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","path":"crates/napl/src/trees.rs"},{"hash":"b3abc9f9c902897188124ed990c6f51a18932f26a69c3ac3884da4d43444c4e9","path":"crates/napl/src/version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","path":"crates/napl/src/workspace.rs"}],"receiptVersion":1,"sliceHash":"3f199669f8e6cacafca611b3fa287d5af760bde4a8e152e56ed8f190ecf2b761","target":"rust"} -napl-store tree v1 130 -{"entries":[{"hash":"1de719dc1404ec3b290ad06d820a1617ed3f78aae2c1baa54d1a2ca1c22858ad","kind":"dir","mode":16384,"name":"napl"}]} -napl-store event v1 158 -{"content":"e88c3509dbd1a91038d5daf1fcc667c45517350ed43900a7bc0727c62246f878","parents":["6102c0100b6cf296ea7bc233d05e4ebf9efdc88d1f5d126a7f38675739d6aadc"]} -napl-store blob v1 975 -module: "commands" -target: "rust" -stale: false -entries: - - promptLines: - - 1 - - 8 - file: "crates/napl/src/init.rs" - lines: whole - note: "two porcelain additions each a thin composition over the shared derivations, the command table contributing init and watch" - - promptLines: - - 9 - - 56 - file: "crates/napl/src/init.rs" - lines: whole - note: "napl init bootstraps the store skeleton or reports already_initialized, names the directory it ran in as the workspace root, declares no flags beyond the shared help flag, and teaches the first mile through nextSteps: one complete minimal prompt that itself passes check, the .napl extension discovery finds prompts by, and the exact sequence from writing that prompt through add and commit" - - promptLines: - - 57 - - 78 - file: "crates/napl/src/watch.rs" - lines: - - 1 - - 127 - note: "napl watch an io shell re-running compile on content change publishing nothing" -napl-store event v1 158 -{"content":"ba257e4e665b2cf38b618422e44e185b97222ea869c70e9f556669bce0205965","parents":["9d62cdfe887811181783f1c4a2b2876691ab6a3564c5d06fb7f2bfc0d25751f9"]} -napl-store blob v1 52936 -# NAPL: the deterministic compiler - -This prompt describes the entire NAPL toolchain, rebuilt as one small Cargo -workspace of exactly three crates. It replaces the legacy 76-crate -implementation completely; git history holds the original. The governing -contract is docs/specs/2026-07-27-deterministic-compiler-plan.md rev 4; where -this prompt compresses it, the spec's definition wins. - -## Layout: this module generates two crates on top of the ported store - -The workspace layout is fixed: the records directory `.napl/` lives at the -repository root; generated code installs under `crates/`; a module's -prompt file lives inside the crate directory that receives its code, and -the crate is the folder: every module whose `.napl` file sits in -`crates//` compiles into the `` crate as its own source -module, so one crate may be described by several prompt files -(`crates/napl/napl.napl` and `crates/napl/commands.napl` both build -`crates/napl/`). Module discovery finds prompts by walking the workspace -for `*.napl` files, and the workspace root is the nearest ancestor -directory containing `.napl`. A subtree that contains its own `.napl` -directory is a separate workspace: discovery never descends into it, the -way git never descends into a nested repository. Hidden directories and -`node_modules` are never descended into. - -The proven store already exists as a hand-ported crate at -`crates/napl-store/` (same envelope format `napl-store v1 -\n`, SHA-256 names, canonical JSON payloads, same object -kinds, same `.napl/store/` on-disk layout with HEAD compare-and-swap, -module-keyed locks, and dead-holder steal, ported code and tests from the -legacy store crates). Use its public API through a path dependency; do not -reimplement, wrap thinly, or fork any of its types or logic. The four new -object kinds the compiler needs (`transition`, `receipt`, `surface`, -`target-def`) already exist in it additively. - -::: test -name: the ported store hashes objects exactly as the original store crates did -given: { vectors: ported_store_hash_test_corpus } -expect: { all_pass_unmodified: true } -::: - -::: test -name: identical payload under two object kinds gets two different names -given: { payload: "X", kind_a: blob, kind_b: snapshot } -expect: { equal: false } -::: - -This module's generated output is as many crates as the design needs, side -by side in the module's owned directory. Keep it a small, clearly separated -workspace (this replaces a 76-crate explosion; do not recreate one), with -`napl-store` staying its own untouched crate. The natural split, which you -may adjust if the design genuinely wants another boundary: - -1. **`napl-core/`** (lib): the pure compiler. Parsing `.napl` files (YAML - frontmatter: module, deps, targets, tests; markdown body), discovery and - dependency graph, canonical path grammar, the identity vocabulary, - per-module artifact slices, wave planning, tree-sitter surface extraction - for Rust and TypeScript targets, the surface grammar, placement rules - and entry-file projection derivation, receipt verification logic, the commit - decision table as pure functions, blame line-ancestry replay, lint - diagnosis, cost calculation, evidence validation. No filesystem writes, - no locks, no clocks, no environment reads: everything enters as - arguments and leaves as values. -2. **`napl-cli/`** (bin `napl`): every command, each a thin composition of - napl-core derivations and napl-store reads plus the one guarded write - path. No private logic in the binary beyond argument parsing and - formatting. - -The crates depend on `napl-store` by path, `serde_json`, `sha2`, -`tree-sitter`, `tree-sitter-rust`, `tree-sitter-typescript`, and nothing -that talks to a network, spawns a process, or reads clocks; those -declarations live in the user-owned manifests, not in any module's -output. Ensure `cargo test` passes for both crates and `napl-store` from -their workspace before finishing. Generated code is authored against its -final installed location, never a staging tree: acceptance runs at the -final location, so green certifies the bytes where they live. - -When several modules share one crate, the crate's shared entry files are -derived projections owned by no module: the crate root `lib.rs` module -list is recomputed at commit from the union of the crate's committed -module file sets, and the binary's command registry is recomputed from -the command tables the crate's modules expose under one fixed convention -(each module may expose `pub fn commands() -> &'static [CommandSpec]` -from its own files; the dispatch loop iterates the derived registry and -contains no per-command match arms of its own). A module's owned output -therefore never includes the shared entry files, two modules can never -claim the same path, and a commit that would write a projection-owned -path is rejected as invalid-candidate. - -A prompt at the workspace root is legal and means the workspace root is -the crate directory, exactly as git treats the directory it was -initialized in: the placement root is the empty workspace-relative path, -every workspace-relative path is inside it, and every join against it -produces clean relative paths, never a fabricated leading separator. A -cold workspace whose first prompt sits beside `.napl` therefore commits -its first module with no configuration; placement never needs declaring -because the prompt's location is the declaration. -::: test -name: a root-level prompt places at the workspace root and its module commits -given: { workspace: fresh_init_root_prompt, candidate: valid_root_level } -expect: { placement_root: "", owned_globs_relative: true, outcome: installed } -::: - -::: test -name: status for a root-level module reports a workspace-root plan with clean globs -given: { workspace: fresh_init_root_prompt, flag: module_root } -expect: { plan_placement_root: "", owned_globs_leading_separator: false } -::: +fn location(workspace: &Path, file: &str, start: u32, end: u32) -> Value { + let uri = format!("file://{}", workspace.join(file).to_string_lossy()); + let start_line = start.saturating_sub(1); + let end_line = end.saturating_sub(1); + json!({ + "range": { + "end": { "character": 0, "line": end_line }, + "start": { "character": 0, "line": start_line } + }, + "uri": uri + }) +} -## The command line contract +fn discovered_prompt(workspace: &Path, module: &str) -> Option { + crate::workspace::discover_sources(workspace) + .ok()? + .into_iter() + .find(|source| source.module == module) + .map(|source| source.path) +} -Every command the registry serves declares its full grammar in its -`CommandSpec`: name, a one-line summary, the exact usage line, and the -closed table of flags it accepts, each with arity and meaning. The -dispatch loop owns two behaviors ahead of every command, so no command -can get them wrong. First, help: `napl help` lists every registered -command with its summary, `napl help ` and `--help` or `-h` -anywhere in a command's arguments print that command's usage line, flag -table, and summary, exit 0, and never invoke the command. Help is a pure -read: no store access, no filesystem write, no state change of any kind, -and this holds for every command including `init` and `watch`. Second, -strict parsing: an argument beginning with `--` that the command's flag -table does not name is a usage error, never ignored; the error goes to -stderr as canonical JSON naming the unknown flag, listing the command's -valid flags, and suggesting the nearest declared flag by edit distance -when one is close, and the command does not execute. Exit 2 is the -pinned usage-error code for the whole binary: unknown command, unknown -flag, missing required flag or positional, malformed flag value. A -usage error always restates the command's exact usage line, so the -error itself teaches the grammar. +fn prompt_offset(workspace: &Path, docs: &BTreeMap, rel: &str) -> u32 { + let uri = format!("file://{}", workspace.join(rel).to_string_lossy()); + document_text(workspace, docs, &uri, rel) + .map(|text| frontmatter_lines(&text)) + .unwrap_or(0) +} -::: test -name: init with a help flag prints usage and mutates nothing -given: { directory: empty, invocation: "init --help" } -expect: { exit_code: 0, created: [], usage_printed: true } -::: +fn definition(docs: &BTreeMap, message: &Value) -> Value { + let params = &message["params"]; + let uri = params["textDocument"]["uri"].as_str().unwrap_or(""); + let line0 = params["position"]["line"].as_u64().unwrap_or(0) as u32; + let Some(workspace) = resolve_workspace(uri) else { + return Value::Array(Vec::new()); + }; + let workspace = workspace.as_path(); + let records = load_records(workspace); + let mut out: Vec = Vec::new(); + match locate(workspace, docs, &records, uri, line0) { + Located::Prompt { module, line } => { + for entry in prompt_entries(&records, &module, line) { + if let Some(file) = &entry.file { + out.push(location(workspace, file, entry.line_start, entry.line_end)); + } + } + } + Located::Generated { file, line } => { + for (record, entry) in generated_entries(&records, &file, line) { + let Some(rel) = discovered_prompt(workspace, &record.module) else { + continue; + }; + let offset = prompt_offset(workspace, docs, &rel); + out.push(location( + workspace, + &rel, + entry.prompt_start + offset, + entry.prompt_end + offset, + )); + } + } + Located::None => {} + } + Value::Array(out) +} -::: test -name: an unknown flag is a usage error naming the flag and suggesting the nearest -given: { workspace: fixture_two_modules, invocation: "add cand --modul m" } -expect: { exit_code: 2, error_names_unknown: "--modul", suggested: "--module", executed: false } -::: +fn references(docs: &BTreeMap, message: &Value) -> Value { + let params = &message["params"]; + let uri = params["textDocument"]["uri"].as_str().unwrap_or(""); + let line0 = params["position"]["line"].as_u64().unwrap_or(0) as u32; + let Some(workspace) = resolve_workspace(uri) else { + return Value::Array(Vec::new()); + }; + let workspace = workspace.as_path(); + let records = load_records(workspace); + let matched: Vec<(&Attribution, &Entry)> = + match locate(workspace, docs, &records, uri, line0) { + Located::Prompt { module, line } => prompt_entries(&records, &module, line) + .into_iter() + .filter_map(|entry| { + records + .iter() + .find(|record| record.module == module) + .map(|record| (record, entry)) + }) + .collect(), + Located::Generated { file, line } => generated_entries(&records, &file, line), + Located::None => Vec::new(), + }; + let mut out: Vec = Vec::new(); + for (record, entry) in matched { + if let Some(rel) = discovered_prompt(workspace, &record.module) { + let offset = prompt_offset(workspace, docs, &rel); + out.push(location( + workspace, + &rel, + entry.prompt_start + offset, + entry.prompt_end + offset, + )); + } + if let Some(file) = &entry.file { + out.push(location(workspace, file, entry.line_start, entry.line_end)); + } + } + Value::Array(out) +} -::: test -name: help lists every registered command and per-command help states exact usage -given: { workspace: fixture_two_modules, invocations: ["help", "help add"] } -expect: { all_commands_listed: true, add_usage_line_present: true, exit_code: 0, store_bytes_unchanged: true } -::: +fn code_lens(docs: &BTreeMap, message: &Value) -> Value { + let uri = message["params"]["textDocument"]["uri"].as_str().unwrap_or(""); + let Some(workspace) = resolve_workspace(uri) else { + return Value::Array(Vec::new()); + }; + let workspace = workspace.as_path(); + let records = load_records(workspace); + let path = uri_to_path(uri); + let rel = relativize(workspace, &path); + let Some(module) = stem(&rel) else { + return Value::Array(Vec::new()); + }; + let Some(record) = records.iter().find(|record| record.module == module) else { + return Value::Array(Vec::new()); + }; + let Some(text) = document_text(workspace, docs, uri, &rel) else { + return Value::Array(Vec::new()); + }; + let offset = frontmatter_lines(&text); + let mut headings: Vec = Vec::new(); + for (index, line) in text.split('\n').enumerate() { + let raw_line = index as u32 + 1; + if raw_line <= offset { + continue; + } + if line.trim_start().starts_with('#') { + headings.push(raw_line - offset); + } + } + let mut lenses: Vec = Vec::new(); + for (position, &body_line) in headings.iter().enumerate() { + let next = headings.get(position + 1).copied().unwrap_or(u32::MAX); + let mut files: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); + for entry in &record.entries { + if entry.prompt_start >= body_line && entry.prompt_start < next { + if let Some(file) = &entry.file { + files.insert(file.as_str()); + } + } + } + let raw_line = body_line + offset; + lenses.push(json!({ + "command": { + "command": "", + "title": format!("{} attributed files", files.len()) + }, + "range": { + "end": { "character": 0, "line": raw_line - 1 }, + "start": { "character": 0, "line": raw_line - 1 } + } + })); + } + Value::Array(lenses) +} +napl-store blob v1 32826 +# query: the canonical document, napl q, and napl prompt -Manifests are user-owned glue seams, not records: `Cargo.toml` at the -workspace root and in every crate belongs to the user, edited by hand -like any Rust project, tracked by git, consumed by cargo, and NEVER -owned, installed, validated, or rewritten by the toolchain. A candidate -containing a manifest path is rejected as invalid-candidate; no module's -owned output includes one; nothing in the store records manifest bytes. -The toolchain's only relationship to manifests is diagnosis: `status` -reports, as observations never as writes, a module placed in a crate the -workspace manifest does not list, or a crate directory missing a -manifest. A dependency needed by generated code is added by editing the -manifest, and the build battery is what proves it correct. +The language answers every question through one query surface and builds +agent context through one prompt builder. This module owns both commands +and the document they share. Everything here is a pure read: no store +writes, no locks held, no clock, no randomness, no network (P1). Reads +serve committed records, with two stated exceptions: reference staleness +is computed at read time from the working tree as well as the committed +bytes, so a hand edit to either end of a reference reads stale without +anything being stored; and the working-tree read mode below serves the +pending prompt when asked to explicitly. The same committed state plus +the same working tree produces the same bytes. This module owns `src/query.rs` (taken over +from the commands module), plus the document builder and prompt builder +sources it introduces. -::: test -name: a candidate containing a manifest path is rejected -given: { workspace: fixture_two_modules, candidate: contains_cargo_toml } -expect: { outcome: invalid_candidate, manifest_bytes_unchanged: true } -::: +## The canonical document -## The two principles +One JSON value, `docVersion 1`, built at query time from committed state: +the store head, module states, attribution records, staged-evidence usage +inside receipts, recorded surfaces, and the committed prompt bytes. Top +level: `docVersion`, `head` (the snapshot hash), `modules`, `history`, +`state`. -P1 ownership: the toolchain owns `.napl` source, the derived layer, the -store, and the mappings between them; every owned fact is provable from -content alone. It contains no LLM invocation, no provider identifier, no -model selection, no completion parsing, no process execution (the only -process is `napl` itself), no clock reads in any semantic path, no -randomness in any semantic path. Generation happens outside: the user's -agent session reads the plan from `napl status`, the specification through -the query surface, writes candidate code, and calls `napl add` then -`napl commit`. +Each module carries `name`, `promptPath`, `deps`, `targets`, +`surfaceHash`, `pending`, `attribution` (an object whose `status` is +`fresh`, `stale`, or `absent`), `sections`, `frontmatterCases` (cases +declared in frontmatter `tests:`, which have no body position), and +`files` (owned output paths), and `outline` — the prompt body's +headings in order, each `{text, level, line}` with the line +body-normalized and 1-based, so "which body line is this heading on" +is a query, never a raw file read. Sections are the module's attribution +entries, and every section is a SELF-CONTAINED REF: it carries `head`, +`module`, `promptPath`, `lines` (body-normalized, 1-based, inclusive), +`note`, `law` (true exactly when the entry owns no file), `files` (each +`{path, lines, head}`), `direction` (`prompt_to_code` or +`code_to_prompt`, per the reference contract in the napl module; entries +recorded before directions read `prompt_to_code`), `provenance` (an +object with `model`, `timestamp`, `toolVersion`, and `usage`, each +absent field served as `unavailable`, never invented), `referenceStatus` +(`fresh` or `stale`), `staleEndpoints` (empty when fresh, `code` and +`prompt` when stale), and `cases` — the `::: test` directive cases +whose block range intersects the section's range, each +`{name, given, expect, lines}` with values in canonical JSON. A directive +case intersecting no entry lands in a synthetic section with empty +`files`. When a module's attribution is stale, sections still carry +their cases (cases derive from prompt bytes, not from attribution) but +each section carries `associationStale: true` and the module's status +says `stale`: the section-to-file pairing cannot be trusted, the case +data can. -::: test -name: the binaries spawn no process and name no provider -given: { crate_source: self } -expect: { spawns_processes: false, provider_identifiers: [] } -::: +Each file ref inside a section carries `claim`, `whole` when the entry +owns the file with no line range and `range` when it names one, so a +consumer never guesses whether `lines` bounds the claim or merely +samples it. -P2 footprint scoping: every operation declares a read set (content -identities consumed), a semantic write set (owned records changed), and its -derived projections plus physical install paths. Two operations conflict -when semantic write sets overlap or one's accepted write changes a content -identity in the other's read set. Derived projections (the crate-root -lib.rs module list, the command registry) are never conflict-bearing: -they are recomputed exactly once from the winning merged head inside -commit and rebase. Manifests are outside footprints entirely: user-owned -seams no operation reads as identity or writes at all. +Docs fences from the napl module's directive law surface here per +section: `sections[].docs` is the ordered list of docs payloads whose +fence sits inside the section's line range, each +`{directive, attrs, lines, body}` with inner fences verbatim in the +body. The module preamble fence, when present, is served at +`modules[].docs` beside the sections. Docs `lines` are physical prompt +coordinates, the one place physical coordinates appear in the +document, because docs fences are excluded from the semantic body that +every other coordinate speaks in; the document states both coordinate +systems explicitly. A module with no docs fences serves empty docs +lists, never null. -## Identity +The committed document's docs are read from the committed prompt +object, the whole prompt file the module's state names, never from +the working tree, for the same reason the committed document's +sections, files, and attribution ignore the working tree: the +document is what the store recorded, and a docs edit that has not +passed through the docs record does not exist to it. A renderer +serving the committed document therefore shows exactly the prose a +`docs-only` or module transition installed, byte for byte, and +hovering that prose can only ever reveal code the store attributes. +Reading docs out of committed bytes is the fence split alone, never +the example validation that guards the door at commit time: those +bytes already passed the gate when they were recorded, and re-judging +them against a grammar that has since moved would let a flag rename +silently erase served prose. A state recorded before prompt objects +existed, or one whose prompt object cannot be read, serves its docs +lists empty rather than guessing from the working tree, and the +module's next commit records the baseline. Under `--working-tree` +docs come from the working-tree prompt files as before, which is the +read the LSP and the editing loop want. -Expose `pub const CONTRACT_VERSION: u32 = 4` in napl-core (bumped from 1 -by the body-directive grammar, which changes how the same source bytes -are interpreted, from 2 by the relaxed module-name rule below, which -accepts sources contract 2 rejected, and from 3 by the capture verbs, -whose three refusal outcomes join the closed terminal-outcome table). Per module and -target: `surfaceHash` (hash of the recorded public surface), `inputKey` -(hash of the canonical closure manifest over normalized source hash, direct -dependencies' surfaceHashes, placement facts, entry-point version, target -name plus target-definition hash, CONTRACT_VERSION), `baseStateKey` (the -module's committed owned-output state hash), `sliceHash` (hash of the -module's canonical compile slice). Whole-compile `artifactHash` includes -scope and serves determinism tests only, never a commit precondition. Event -identity is `H(content hash + sorted parent hashes)`; ordinals and -timestamps are display metadata, never identity. Semantic identifiers are -content hashes, never a timestamp, pid, hostname, random value, model, or -provider. Telemetry lives in provenance records linking to semantic objects -by hash. +The document's freshness verdicts and the language server's are one +analysis: a section the LSP would squiggle as reference-stale reads +`referenceStatus: stale` here with the same endpoints, never a +hardcoded fresh. -::: test -name: "a module's inputKey covers source, direct dep surfaces, placement, target identity, and contract version" -given: { workspace: fixture_two_modules, module: consumer } -expect: { input_key_components: [source_hash, direct_dep_surface_hashes, placement_facts, target_name, target_definition_hash, entry_point_version, contract_version] } -::: +History entries carry the transition hash, module, target, outcome, and +`usage` — `{model, input, output, cacheRead}` from the staged record's +validated usage evidence, or null when none was supplied; absent +telemetry is null, never zero. -::: test -name: changing a leaf's surface changes the direct dependent's inputKey only -given: { workspace: fixture_chain_three, edit: rename_public_item_in_leaf } -expect: { changed_input_keys: [middle], unchanged_input_keys: [top] } -::: +A history entry carries the transition's kind: an ordinary install +says `installed`, and a docs-only transition says `docs-only` as both +its `kind` and its `outcome`, so any reader of the document tells +prose history from semantic history without dereferencing the +transition object. -::: test -name: a leaf edit absorbed by an unchanged intermediate surface leaves higher dependents' inputKeys unchanged -given: { workspace: fixture_chain_three, edit: private_body_change_in_leaf } -expect: { changed_input_keys: [], unchanged_input_keys: [middle, top] } -::: +Canonical serialization throughout: sorted keys, total ordering for every +array (modules by name; sections by start then end line; files and cases +by path and name; history by transition sequence, transition hash as +tiebreaker), no absolute paths anywhere (`promptPath` and file paths are +workspace-relative), one trailing newline. The same committed state +produces the same document bytes on any machine. ::: test -name: telemetry differences never change transition identity -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipts: [with_usage, without_usage] } -expect: { transition_identities_equal: true } +name: q with no arguments emits the canonical document byte-stably +given: { workspace: fixture_two_modules, runs: 2 } +expect: { bytes_equal: true, top_level_keys: [docVersion, head, history, modules, state] } ::: ::: test -name: history nodes are causal hashes over content plus sorted parents -given: { workspace: fixture_two_modules, commits: 2 } -expect: { event_id_components: [content_hash, sorted_parent_ids], ordinal_in_identity: false, timestamp_in_identity: false } +name: the document contains no absolute paths +given: { workspace: fixture_two_modules } +expect: { absolute_paths: 0 } ::: -## Module names - -A module name is any non-empty single-line scalar. The empty string is the only -value rejected, surfaced as `mapping-integrity`. No character class, case, or -identifier shape is required: `todo-app`, `todo_app`, and non-ascii names are all -legal. The same rule governs every name position the parser reads, the `module` -frontmatter value, each `deps` entry, and each declared root, so all three accept -exactly the set the module rule accepts. This relaxation replaces the contract 2 -identifier constraint and rides the CONTRACT_VERSION bump to 3, the sanctioned -identity transition. As with the 1-to-2 bump, every module's inputKey recomputes -because CONTRACT_VERSION is one of its components; no source or surface changes, -only the version does. - -Because a name is unrestricted, it is never used raw as a filesystem path segment -or a store key. napl-core exposes one injective, reversible encoding used at every -such site: each byte outside `[A-Za-z0-9._-]` is percent-encoded as `%XX` with -uppercase hex, and a name that is exactly `.` or `..` has its dots percent-encoded -too, so an encoded segment is never `/`, `\`, empty, `.`, or `..` and can never -traverse or alias a directory. A name already matching `[A-Za-z0-9._-]` and not -equal to `.` or `..` encodes to itself, so existing records keep their on-disk -names: `todo-app` stays `todo-app`. Every writer that keys a record or scratch -path by module name, the mapl, ir, attribution, and prompt-at-gen documents, the -staging and relocation scratch directories, and the store root map, uses the -encoded segment; decoding recovers the exact name. On a case-insensitive -filesystem two names differing only in ascii case can still share one segment; -that collision is the author's concern, distinct from the traversal safety above. - -Wherever a module name is serialized into a record it is written as a quoted, -escaped scalar, never interpolated raw, so a name containing YAML or JSON -metacharacters reparses to the identical name. - ::: test -name: a non-identifier module name is accepted -given: { workspace: fixture_single_module, module_name: "todo-app" } -expect: { outcome: ok } +name: directive cases appear inside their owning section +given: { workspace: fixture_with_directive_case, attribution: fresh } +expect: { section_cases: 1, case_fields: [expect, given, lines, name] } ::: ::: test -name: an empty module name is rejected as mapping-integrity -given: { workspace: fixture_single_module, module_name: "" } -expect: { outcome: mapping_integrity } +name: stale attribution keeps cases and marks the association stale +given: { workspace: fixture_with_directive_case, attribution: stale } +expect: { section_cases: 1, association_stale: true, module_status: stale } ::: ::: test -name: deps entries and declared roots accept the same names as the module rule -given: { module_name: "todo-app", dep_name: "a-b", root_name: "x.y" } -expect: { all_accepted: true } +name: section refs are self-contained +given: { workspace: fixture_two_modules } +expect: { section_fields_present: [direction, head, lines, module, promptPath, provenance, referenceStatus, staleEndpoints] } ::: ::: test -name: the name encoding is injective reversible and never traverses -given: { names: ["todo-app", "a/b", "..", "café", "a b"] } -expect: { decode_roundtrip_equal: true, no_segment_in: ["/", "\\", "", ".", ".."], safe_subset_unchanged: ["todo-app"] } +name: usage from staged evidence appears in history entries +given: { workspace: fixture_two_modules, commit_with_usage: true } +expect: { history_usage_fields: [cacheRead, input, model, output], absent_usage: null } ::: ::: test -name: a name with yaml metacharacters is written quoted and reparses identically -given: { module_name: "a: {b} #c" } -expect: { record_reparse_equal: true, written_as_quoted_scalar: true } +name: a docs-only transition is told apart in history without dereferencing the transition +given: { workspace: fixture_two_modules, then: [docs_edit, add_prompt_path, commit] } +expect: { last_history_kind: docs-only, last_history_outcome: docs-only, ordinary_entries_say: installed } ::: -## The compile derivation - -Compilation is a pure derivation and NOT a command: parse the graph, -compute per-module slices for the changed frontier, derive -dependency-ordered waves. Never persisted, and never spelled on the CLI — -it is the internal engine of `status`, `add`, `commit`, `watch`, and the -lsp. Per module the slice carries: normalized source body and hash, direct -dependency surfaces and surfaceHashes, placement and allowed-output -constraints including the surface grammar, the owned-output base and -baseStateKey, inputKey, sliceHash, wave position, receipt requirements. It -contains no provider, model, prompt assembly, credentials, timestamps, -retry policy, pricing, or test results. `napl status --module ` -reports the module's PLAN from this derivation — owned globs, placement -root, projection ownership, receipt requirements, wave position — the -invariant requirements only, a pure function of the working tree's -prompts; candidate-dependent validation belongs to `add`, never the plan. - ::: test -name: status reports a module plan derived from the working tree -given: { workspace: fixture_two_modules, flag: module_leaf } -expect: { plan_fields: [owned_globs, placement_root, receipt_requirements, wave_position], candidate_dependent_fields: absent } +name: a section's docs payloads arrive in order with their bodies verbatim +given: { workspace: fixture_with_docs_fences } +expect: { docs_fields: [attrs, body, directive, lines], order_preserved: true } ::: -## Body directives - -The prompt body grammar reserves directive blocks, `directiveVersion 1`, -and recognizing them is part of the identity contract: text that older -binaries read as inert prose can be a compile error under this grammar, so -this recognition rides a CONTRACT_VERSION bump, the sanctioned identity -transition. The first directive is `test`. An opener is a line that is -exactly `::: test`; the closer is a line that is exactly `:::`; no -nesting. An unknown `::: ` opener is a compile error, never prose, -so future directive names are non-breaking. Directive blocks are body -text: they count in normalized body lines and attribution ranges may -cover them. - -Block content is one case in an exact value grammar mapping 1:1 onto -canonical JSON, so two binaries cannot disagree on a value: one mapping -with keys `name` (required, non-empty, single line), `given` (optional, -mapping), `expect` (required, mapping). Block mappings and sequences -indent by exactly two spaces; flow `{}` and `[]` forms are allowed. -Strings are double-quoted with JSON escapes, or plain scalars ended by a -structural character (`:`, `,`, `{`, `}`, `[`, `]`, `#`) or end of line -with no leading or trailing space. Numbers are JSON number syntax; -`true`, `false`, `null` are keywords; every other plain scalar is a -string. Nothing else from YAML exists here: no comments, no duplicate -mapping keys, no anchors, aliases, tags, block scalars, multiline -scalars, multi-document markers, octal or hex, timestamps, or merge keys. -A case needing prose belongs in prose. Diagnostics are compile errors -naming the exact body line, judged in this precedence: unclosed block, -unknown directive name, nested opener, content outside the value grammar, -missing or empty name, missing expect, duplicate case name within the -module across both positions after NFC normalization and whitespace trim. -Frontmatter `tests:` remains legal; a module's case set is the union of -both positions; extraction of cases from body bytes is deterministic — -same bytes, same cases. - ::: test -name: an unclosed test directive block is a compile error naming its opener line -given: { body: directive_without_closer } -expect: { outcome: compile_error, line_named: opener } +name: "the committed document serves the docs the store recorded, not the working tree" +given: { workspace: fixture_with_docs_fences, then: [edit_docs_fence_without_recording] } +expect: { committed_docs_unchanged: true, working_tree_docs_show_edit: true } ::: ::: test -name: "an unknown directive name is a compile error, never prose" -given: { body: unknown_directive_opener } -expect: { outcome: compile_error, treated_as_prose: false } +name: a docs-only commit moves the committed document's docs and nothing else in the module +given: { workspace: fixture_with_docs_fences, then: [docs_edit, add_prompt_path, commit] } +expect: { committed_docs_show_edit: true, sections_unmoved: true, input_key_changed: false } ::: ::: test -name: a duplicate case name across frontmatter and body errors -given: { frontmatter_case: alpha, body_directive_case: alpha } -expect: { outcome: compile_error, diagnostic: duplicate_case_name } +name: a state without a prompt baseline serves empty docs rather than guessing +given: { workspace: fixture_committed_before_prompt_objects } +expect: { module_docs: [], section_docs_empty: true, working_tree_mode_still_serves_docs: true } ::: ::: test -name: directive case extraction is deterministic -given: { body: fixture_with_two_directives, runs: 2 } -expect: { cases: 2, bytes_equal: true } +name: a module preamble fence serves title and description in module docs +given: { workspace: fixture_with_module_preamble } +expect: { module_docs_attrs: [description, title] } ::: -## Recorded surfaces - -At commit, extract the candidate's public API with tree-sitter (pinned -grammars) and record it as a `surface` object; dependents' inputKeys pin -recorded surfaceHashes, store facts, never demand scans. The surface -grammar makes extraction sound by construction and violations reject as -`mapping-integrity`: for Rust, public items must be named `pub` -declarations at statically visible paths, no glob re-exports, no -macro-generated `pub` items; a named `pub use` is allowed and is recorded -as the unresolved written binding, never a resolved signature. For -TypeScript: explicit named exports only, no `export *`. Extraction is -syntactic, deterministic, and loud on malformed input. Re-extraction from -committed output must reproduce the recorded surface byte-identically. - ::: test -name: a candidate with a glob re-export is rejected as mapping-integrity -given: { workspace: fixture_two_modules, candidate: contains_glob_reexport } -expect: { outcome: mapping_integrity, published_semantic_state: false } +name: a module with no docs fences serves empty docs lists +given: { workspace: fixture_two_modules } +expect: { module_docs: [], section_docs_empty: true, nulls: 0 } ::: ::: test -name: a named pub use is recorded in the surface as the written binding -given: { workspace: fixture_two_modules, candidate: valid_with_named_reexport } -expect: { surface_contains_written_binding: true, surface_contains_resolved_signature: false } +name: file refs carry the claim discriminator +given: { workspace: fixture_two_modules } +expect: { file_claims_subset_of: [range, whole] } ::: ::: test -name: recorded surface re-extraction from committed output is byte-identical -given: { workspace: fixture_two_modules, candidate: valid_for_consumer } -expect: { reextraction_equal: true } +name: reference staleness in the document matches the language server verdict +given: { workspace: fixture_with_stale_reference } +expect: { reference_status: stale, lsp_parity: true, hardcoded_fresh: false } ::: -## add, then commit +## napl q -Git parity is the mental model: `add` stages, `commit` installs. The -receipt is an internal record between them, never a user-facing noun. +`napl q` is shaped exactly like jq and IS jq: expressions are evaluated +by an embedded jq engine (the jaq crate, version pinned by the lockfile, +upgraded only through a normal committed change). Impure builtins are +rejected at expression compile time (`env`, `$ENV`, `now`, `input`, +`inputs`, `debug`, `stderr`, `input_line_number`, and anything else doing +I/O or randomness), so an expression is a pure function of its input. -`napl add --module [--attribution ] -[--usage ]` stages a candidate: ingest candidate bytes into -immutable content addressed blobs (reject links, devices, duplicate -normalized paths, absolute or parent-escaping paths, oversized entries or -totals; all later steps read ingested blobs, never the candidate -directory); compute the slice and validate the candidate against it; -validate the attribution update's every claim; validate and attach usage -evidence when supplied; then write the staged record — the internal -receipt: identities generated against, base snapshot hashes, candidate -tree hash, output manifest, attribution, optional reported fields. -Validation failures surface at add time with the same outcome classes -commit uses. Staged records are transient coordination state, disposable -litter by the git-state law: never promoted to history, safely deletable, -re-creatable by re-running add. +Argv grammar, exactly three forms; more than one input is a usage +error; `--` is accepted before a path starting with a dash; the +expression is always required and there is deliberately no shorthand +for identity anywhere, because the primary caller is a language model +session and a bare invocation that dumps the whole document is a +context bomb, not a convenience; the usage error for a missing +expression teaches `.` as the explicit spelling and jq demands its +filter the same way: -::: test -name: add stages a valid candidate and commit installs it -given: { workspace: fixture_two_modules, candidate: valid_with_attribution } -expect: { add_outcome: staged, commit_outcome: success } -::: + napl q expression over the canonical document + napl q - expression over stdin + napl q expression over a JSON file -::: test -name: add without an attribution update fails loudly -given: { workspace: fixture_two_modules, candidate: valid_without_attribution } -expect: { outcome: invalid_candidate, missing_named: attribution } -::: +Input may be a single JSON value or a stream of values (JSONL); the +expression applies per value exactly as jq does. Each result value is +canonically serialized, one per line; `-r`/`--raw-output` emits string +results as their raw contents exactly as jq does, one result per +output line with exactly one newline between results and one at the +end, never doubled when the string itself ends in one, so shell +pipelines never need a dequoting round trip and a raw result diffs +clean against the file it came from. An empty result stream is exit 0 +with no output. Because canonical serialization is already one compact +value per line, jq's `-c`/`--compact-output` is accepted as a +harmless synonym of the default: it changes nothing and never errors, +so the jq reflex costs no usage round trip; `-c` composes with `-r` +exactly as it does in jq, where raw string output ignores it. Errors are canonical JSON on stderr with pinned exit +codes: 2 usage, 3 expression parse or refused builtin, 4 input parse, 5 +runtime or type error. Evaluation carries a compiled-in output ceiling of +64 MiB, enforced functionally and surfaced as `resource-limit` with its +existing exit code; recursion depth is bounded by the embedded engine's +own evaluation stack rather than a compiled-in constant, and an +expression exceeding it aborts rather than returning wrong data. Piping any napl command's +canonical JSON back through an expression works by construction: +`napl status | napl q '.pending' -`. ::: test -name: a candidate with a parent-escaping path is rejected as invalid-candidate -given: { workspace: fixture_two_modules, candidate: contains_dotdot_path } -expect: { outcome: invalid_candidate, published_semantic_state: false } +name: q evaluates a jq expression over the document +given: { workspace: fixture_two_modules, expr: ".modules[].name" } +expect: { output_lines: [consumer, leaf] } ::: ::: test -name: tampering a staged blob after ingestion never mixes into the install -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, tamper: candidate_file_after_ingest } -expect: { outcome_in: [installed_from_immutable_blobs, store_integrity], mixed_content: false } +name: q accepts the compact flag as a synonym of the default output +given: { workspace: fixture_two_modules, invocations: ["q .pending", "q -c .pending", "q --compact-output .pending"] } +expect: { outputs_byte_identical: true, exit_codes: [0, 0, 0] } ::: -`napl commit --staged ` is the one guarded -transaction over what add staged. Pipeline: verify the staged record -(recompute sliceHash, inputKey, baseStateKey at the recorded bases, -reject mismatches, enforce evidence bounds); extract and record the -surface; derive the complete transition (three-part footprint, member-set -delta, shared projections recomputed from the merged head); stage all -materialization outside any lock; then one bounded critical section under -the pointer lock performing only the footprint recheck and the pointer -and directory swaps, bounded by the transition's file count, never -hashing content inside the lock. - ::: test -name: a crash between staged materialization and pointer swap leaves a deterministic recovery plan -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, crash_after: staging } -expect: { store_readable: true, recovery_plan_deterministic: true, semantic_state_unchanged: true } +name: q without an expression is a usage error teaching the explicit dot +given: { workspace: fixture_two_modules, invocations: ["q", "q -c", "q -r"] } +expect: { exit_codes: [2, 2, 2], error_teaches_dot: true, document_dumped: false } ::: ::: test -name: a receipt whose recomputed slice hash disagrees is rejected as invalid-receipt -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: tampered_slice_hash } -expect: { outcome: invalid_receipt, published_semantic_state: false } +name: q compact composes with raw output changing nothing +given: { stdin: "{\"a\":\"x\"}", expr: ".a", input: "-", flags: ["-r", "-c -r"] } +expect: { outputs_byte_identical: true, raw_string_unquoted: true } ::: -The decision table, judged per arriving transition against the current -head: identities reproduce with head unmoved installs; head moved with -identities reproducing and footprints disjoint rebases then installs -(projections re-derived, never reused); inputKey failure returns -`stale-input`; baseStateKey failure returns `base-output-changed`; semantic -write overlap returns `write-conflict`; lock contention returns -`lock-contended`. Terminal outcomes, never retried unchanged: -`invalid-candidate`, `invalid-receipt`, `mapping-integrity`, -`resource-limit`, `unsupported-version`, `store-integrity`, `io-error`. -Every outcome is a canonical machine-readable JSON payload naming whether -blobs or provenance were published; no outcome publishes semantic state -partially. Exit codes: 0 success, 10 through 13 for the four returned -concurrency outcomes in table order, 20 through 26 for the seven terminal -outcomes in the order above. Staged records carry `receiptVersion`, the -per-module identities generated against, base snapshot hashes, the -candidate tree hash, the output manifest, then optional reported fields; -the compiler recomputes everything derivable and rejects on mismatch; -absent telemetry is `unavailable`, never zero. `napl notes add --to -` attaches later provenance without touching semantic state. - ::: test -name: commit installs a valid candidate against an unmoved head -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, head: unmoved } -expect: { outcome: installed, head_advanced: true, surface_recorded: true } +name: q evaluates an expression over stdin when the input is a dash +given: { stdin: "{\"pending\":[\"m\"]}", expr: ".pending", input: "-" } +expect: { output: "[\"m\"]" } ::: ::: test -name: commit against a moved head with disjoint footprints rebases and installs -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: to_unrelated_module } -expect: { outcome: installed, rebased: true } +name: q rejects an invalid expression without touching the store +given: { workspace: fixture_two_modules, expr: ".[unclosed" } +expect: { exit_code: 3, store_bytes_unchanged: true } ::: ::: test -name: commit whose direct dep surface moved underneath returns stale-input -given: { workspace: fixture_chain_three, candidate: valid_for_middle, intervening_commit: leaf_surface_change } -expect: { outcome: stale_input, head_advanced: false } +name: q refuses impure builtins +given: { workspace: fixture_two_modules, expr: "now" } +expect: { exit_code: 3, error_names_builtin: true } ::: ::: test -name: commit whose own outputs moved underneath returns base-output-changed -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: same_module_other_candidate } -expect: { outcome: base_output_changed } +name: q raw output emits string contents without quotes exactly as jq dash r +given: { workspace: fixture_two_modules, expr: ".modules[0].name", flags: [-r] } +expect: { output: leaf_unquoted, trailing_newline: true } ::: ::: test -name: two concurrent disjoint commits both land and neither touches a manifest -given: { workspace: fixture_two_modules, concurrent_candidates: [module_a, module_b], orders: [ab, ba] } -expect: { both_installed: true, manifest_bytes_unchanged: true } +name: the document serves a heading outline with body line numbers +given: { workspace: fixture_two_modules } +expect: { outline_present: true, outline_entry_fields: [level, line, text], lines_body_normalized: true } ::: -::: test -name: "absent telemetry reads as unavailable, never zero" -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: without_usage } -expect: { usage: unavailable } -::: +## The docs envelope: napl q --output=markdown -Attribution rides the staged record; there is no other write path for it. -`napl add --attribution ` carries the module's mapping of prompt body -line ranges to generated file line ranges with notes; add validates every -claim before staging: each file path inside the module's owned globs, -each code line range present in the candidate tree, each prompt line -range present in the current normalized prompt body; a mapping that lies -is `invalid-candidate`. An entry may own no file at all: contract-level -prose — a law — that governs the module rather than any single path; -validation accepts a file-less entry (its prompt range must still exist) -and the query layer serves it flagged as law. An add without an attribution update fails -loudly as `invalid-candidate`, naming exactly what is missing; -`napl add --no-verify` is the explicit escape hatch that stages anyway -and records the module's attribution as stale on install. Any prompt -edit, code transition, or move that changes what a recorded mapping -points at marks it stale the same way; nothing ever repairs a mapping -automatically. `napl status` reports attribution freshness per -module (`fresh`, `stale`, `absent`) and, for stale or absent, emits a -machine-readable directive naming the exact fix: commit with a fresh -attribution for that module. Relocation recomputes only the path prefix -of a mapping, never its line content. The lsp serves a stale mapping -marked stale rather than hiding it; absent answers empty. +`napl q --output=markdown` renders the canonical document's docs as +markdown, packaged in one JSON envelope, because every docs consumer +(a site, a README generator, an editor) wants the same three things at +once: prose as markdown, sections with stable identities, and the code +each section attributes. Assembling those client-side from the raw +document means re-deriving serialization the language already owns, +and every past attempt to regex prose into markdown outside the +toolchain produced escaping defects the emitter cannot, because the +emitter walks recorded structure, the fence split and the outline, +never text. -An attribution entry is a reference, and a reference has a direction: -prompt-to-code, a prompt body line range naming generated file line -ranges, or code-to-prompt, a generated file line range naming a prompt -body line range. Every entry recorded before directions existed reads as -prompt-to-code; migration is a reading, never a rewrite of stored -records. Validation is identical for both directions and referential -only, the same claims checked the same way, and nothing about a -direction implies the sentence describes the code: a reference is a -claim with provenance and freshness, and the record vocabulary never -says more. +The flag composes with the document form only; with `-` or a file +input it is exit 2, usage, teaching that markdown rendering is a +reading of the store, not a jq transformation. The expression stays +required and selects modules: it is evaluated against the canonical +document first, and its result must be the document itself, one module +value, or an array of module values; any other result is exit 2 naming +the three accepted shapes. `napl q . --output=markdown` renders every +module. -A reference carries a provenance block, recorded by the compiler at -staging time from what the caller supplied: recording model identity, -timestamp, tool version, and an optional usage-envelope reference -through the existing evidence seam. Every absent field is `unavailable`, -never zero, never invented, and provenance is display and pricing -metadata that never enters any identity, exactly as telemetry already -behaves. The query layer and the lsp serve the provenance block with the -entry. +The envelope is `{docsVersion: 1, head, modules}` under the same +canonical serialization law as the document: sorted keys, total array +orderings, no absolute paths, one trailing newline, byte-stable across +machines. Each envelope module carries `name`, `promptPath`, `title` +(the text of the first heading inside the module's preamble docs +fence), `description` (the first paragraph after that heading), both +served empty when the preamble or its parts are absent, `markdown` +(the preamble fence rendered), and `sections`. -Staleness covers both ends of a reference, and working-tree drift is one -of the ways an end changes: a reference whose code side no longer -matches the committed bytes, or whose prompt side changed in the working -tree, reads stale on both endpoints, computed at read time from the -records and the working tree, never stored as a transition. A hand edit -to generated source is drift like any other: legal, visible through the -same status and diff machinery, healable by restore or by re-recording -the reference; nothing repairs a reference automatically and nothing -polices the edit that staled it. +Envelope sections regroup the document's one-section-per-file-ref +shape into prose shape: entries sharing an identical line range merge +into one envelope section whose `files` concatenate deduplicated by +path and lines, whose `referenceStatus` is stale when any constituent +is stale, and whose provenance keeps every constituent record. A +case-only group, one with empty files, whose range sits strictly +inside exactly one surviving section joins that section, and the join +carries everything the group owned: cases append, files and staleness +merge, nothing is dropped on the floor because a guarantee that +arrives without its provenance reads fresher than the store knows it +to be. Each envelope section carries `id`, the string +`--` from its body-normalized line range, stable +across heading renames because no heading text participates; `lines`; +`markdown`, the section's docs fences rendered in order; `cases` +verbatim from the document; and `files`, where each file ref carries +the document's fields plus `excerpt`, the claimed span's bytes read +from the committed blob at the ref's recorded head, the whole file +when the claim is `whole`, verbatim to the byte. -::: test -name: a code-to-prompt entry stages and is served with its direction -given: { workspace: fixture_two_modules, candidate: valid_with_code_to_prompt_reference } -expect: { add_outcome: staged, served_direction: code_to_prompt } -::: +Each envelope section also serves its docs fence by fence, because a +hover that answers every paragraph with the whole section's claims is +answering a question nobody asked. `fences` is the ordered list of +the section's rendered docs-family fences, each `{lines, markdown, +sources}`, where `sources` resolves the fence's own position against +EVERY attribution entry whose range contains it, not just the +section it sits in, ordered by span length ascending, then path, +then start line, so the narrowest claim about the prose under the +cursor always speaks first. Each source carries the entry's files +with their excerpts, exactly as section files do. The section's +`markdown` remains the fences rendered in order, so a consumer that +wants the page renders `markdown` and a consumer that wants +granularity walks `fences`; the two are the same bytes in the same +order by construction. -::: test -name: entries recorded before directions read as prompt-to-code unchanged -given: { workspace: fixture_two_modules, history: [pre_direction_attribution] } -expect: { served_direction: prompt_to_code, stored_records_rewritten: false } -::: +Rendered markdown is CommonMark plus GFM, nothing else: no JSX, no +raw HTML, no directive syntax survives into it. A docs fence body +passes through as the prose it already is. A note or warning directive +renders as a GFM alert blockquote, `> [!NOTE]` or `> [!WARNING]`. An +example directive renders as a fenced code block whose info string is +the example's lang plus `title=""`, and the fence run is one +backtick longer than the longest backtick run inside the body, so an +example that itself contains fences nests without truncation. +Guarantees stay data: the envelope serves cases, never a rendered +table, because tables want anchors and interaction and those belong to +whatever renders the envelope, not to the language. ::: test -name: provenance is served with the entry and absent fields read unavailable -given: { workspace: fixture_two_modules, candidate: reference_without_usage_envelope } -expect: { provenance_served: true, usage: unavailable, cost_component: unavailable } +name: the docs envelope is byte-stable and canonically shaped +given: { workspace: fixture_two_modules, invocation: "q . --output=markdown", runs: 2 } +expect: { bytes_equal: true, top_level_keys: [docsVersion, head, modules], docs_version: 1 } ::: ::: test -name: a hand edit to the code side reads stale on both endpoints -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } -expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } +name: the expression selects which modules the envelope renders +given: { workspace: fixture_two_modules, invocations: ["q '.modules[0]' --output=markdown", "q '.modules' --output=markdown"] } +expect: { module_counts: [1, 2], exit_codes: [0, 0] } ::: ::: test -name: a working-tree prompt edit reads stale on both endpoints -given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched } -expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } +name: a scalar expression result refuses markdown output naming the accepted shapes +given: { workspace: fixture_two_modules, expr: ".head", flags: ["--output=markdown"] } +expect: { exit_code: 2, error_names_shapes: [document, module, module_array] } ::: ::: test -name: a code-to-prompt reference naming a missing prompt range is invalid-candidate -given: { workspace: fixture_two_modules, candidate: code_to_prompt_reference_to_missing_range } -expect: { outcome: invalid_candidate, published_semantic_state: false } +name: markdown output refuses stdin and file inputs +given: { stdin: "{}", invocations: ["q . --output=markdown -", "q . --output=markdown some.json"] } +expect: { exit_codes: [2, 2] } ::: -## edit and write: the capture verbs - -`napl edit` and `napl write` are the deterministic capture layer in -front of the same guarded door. They carry the familiar tool -signatures: `napl edit --old-string --new-string -[--replace-all]` replaces an exact occurrence in a file, and -`napl write ` writes stdin as the file's full content. Either -side is editable, a prompt or a generated file, by a human or an LLM; -a hand edit through an ordinary editor stays equally legal, and the -verbs add capture, never exclusivity. The verbs never synthesize or -rewrite prose beyond the bytes given, never invoke a model, never -decide placement, never repair a reference, and never bypass the -guard: `add` and `commit` remain exactly what they are. - -Each call, deterministically and in this order: validate, apply, -capture. When the target is a prompt file, the result of the edit is -validated first by the same reader `check` and `add` use; a result -that fails validation refuses as `prompt-invalid` and applies nothing. -An `--old-string` that matches nowhere, matches more than once -without `--replace-all`, or equals `--new-string` refuses as -`edit-mismatch` and applies nothing, so a no-op edit can never mint a -record. Only a call that will succeed touches the working tree, so -every refusal leaves every file byte-identical. - -A call may carry one reference: `--ref `, a single object naming -a direction and both endpoints, prompt body line range and generated -file line range, in either direction per the reference contract above. -The reference is validated referentially only, exactly as strong as -attribution validation and no stronger: the file path inside the -owning module's placement, both line ranges present in the -post-edit bytes of their files. A reference naming a missing line or -a path outside placement refuses as `reference-unresolvable` and -applies nothing. A valid reference-bearing call stages a -reference-only record and commits it through the guarded transaction -in the same invocation: the transition carries the reference entry and -its provenance, no output-tree change and no surface change, and every -rule of the decision table, journaled atomicity, and recovery applies -to it unchanged. A call without `--ref` is a plain edit: apply, no -record, no store touch. Any existing reference whose endpoint the edit -moved needs no marking, because both-ends staleness is already -computed at read time from the working tree; the verbs store nothing -about it. - -Provenance rides the session window: `--session ` names the -usage-proxy session whose log lines are this call's evidence, and when -the flag is absent the verb reads the `NAPL_SESSION` environment -variable, so a call made under `napl agent` carries its window with no -extra argument. The recorded usage reference is the window itself, -media type, workspace-relative log path, session id, and the line span -observed at staging, priced lazily by `napl cost` at read time, never -aggregated at record time. Model identity and timestamp come from the -window's log lines, or read `unavailable` when there is no window; -the verbs read no clock, so the same call over the same working tree, -store state, and log bytes produces byte-identical records. Absent is -`unavailable`, never zero, never invented, and provenance never -enters identity. - -The three refusals join the closed terminal-outcome table under the -contract bump to 4, pinned exit codes continuing the table order: -`edit-mismatch` 27, `reference-unresolvable` 28, `prompt-invalid` 29. -None reuses `mapping-integrity`, and each is a canonical -machine-readable JSON payload naming what failed and that nothing was -applied or published. - ::: test -name: an edit with a valid code-to-prompt reference stages and commits in one call -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } -expect: { outcome: installed, served_direction: code_to_prompt, output_tree_changed: false } +name: envelope sections merge same-range entries and keep every provenance record +given: { workspace: fixture_overlapping_attribution } +expect: { same_range_entries_merged: true, files_deduped_by_path_and_lines: true, stale_when_any_constituent_stale: true, provenance_records_kept: all } ::: ::: test -name: the same edit call twice produces byte-identical records -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, runs_from_same_base: 2 } -expect: { records_byte_identical: true } +name: a contained case-only group joins its section carrying files and staleness +given: { workspace: fixture_with_directive_case } +expect: { case_only_group_joined: true, joined_cases_appended: true, joined_files_merged: true, joined_staleness_merged: true } ::: ::: test -name: a reference to a missing line refuses as reference-unresolvable and applies nothing -given: { workspace: fixture_two_modules, edit: ref_names_missing_line } -expect: { outcome: reference_unresolvable, exit_code: 28, files_byte_identical: true } +name: rendered markdown carries no jsx html or directive syntax +given: { workspace: fixture_two_modules, invocation: "q . --output=markdown" } +expect: { markdown_contains_jsx: false, markdown_contains_raw_html: false, markdown_contains_directive_fences: false } ::: ::: test -name: a prompt edit that breaks the prompt refuses as prompt-invalid and applies nothing -given: { workspace: fixture_two_modules, edit: prompt_edit_breaking_directive } -expect: { outcome: prompt_invalid, exit_code: 29, files_byte_identical: true } +name: an example containing fences renders inside a longer fence run +given: { docs_body_with: "an example whose body holds a three-backtick fence" } +expect: { outer_fence_run: 4, body_verbatim: true } ::: ::: test -name: an ambiguous old-string without replace-all refuses as edit-mismatch -given: { workspace: fixture_two_modules, edit: old_string_matches_twice } -expect: { outcome: edit_mismatch, exit_code: 27, files_byte_identical: true } +name: a file ref excerpt is the committed span byte for byte +given: { workspace: fixture_two_modules } +expect: { excerpt_equals_committed_blob_span: true, whole_claim_serves_whole_file: true } ::: ::: test -name: a prose-only edit needs no reference and touches no store state -given: { workspace: fixture_two_modules, edit: plain_prompt_edit_valid } -expect: { applied: true, store_bytes_unchanged: true } +name: a fence resolves every containing entry narrowest first +given: { workspace: fixture_overlapping_attribution } +expect: { fence_sources_ordered_by: [span_asc, path, start], narrowest_first: true, includes_entries_beyond_owning_section: true } ::: ::: test -name: an edit touching a referenced line reads stale on both endpoints afterward -given: { workspace: fixture_two_modules, edit: plain_edit_on_referenced_code_line } -expect: { reference_status: stale, stale_endpoints: [prompt, code] } +name: fence markdown concatenated in order equals the section markdown +given: { workspace: fixture_two_modules } +expect: { fences_concat_equals_section_markdown: true } ::: ::: test -name: a session window from the environment is recorded as the usage reference -given: { workspace: fixture_two_modules, env: { NAPL_SESSION: s1 }, log: proxy_log_with_s1_lines, edit: referenced_code_edit_with_ref } -expect: { provenance_usage_session: s1, usage_span_recorded: true, aggregated_at_record: false } +name: an example fence info string carries its lang before its title +given: { workspace: fixture_two_modules } +expect: { info_string_format: " title=\"\"", lang_present_when_declared: true } ::: -::: test -name: an edit with no session window records provenance unavailable -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, env: {} } -expect: { provenance_model: unavailable, provenance_usage: unavailable } -::: +## napl prompt -## The rest of the porcelain +`napl prompt` (alias `napl p`) is the prompt builder: it reads refs from +stdin — the objects a `napl q` stage selected — and emits markdown built +from committed state at the refs' snapshot, never current head, and +never the working tree except under the explicit `--working-tree` mode +defined in the working-tree reads section. Stdin is typed-ref JSONL: one JSON object per line +carrying `head`, `lines`, and exactly one of `path` or `promptPath`; +unknown extra fields are ignored. A line that is not an object, carries +both path kinds or neither, or has a non-integer, zero, reversed, or +out-of-range line range is an error naming the input line number, never a +silent skip. Empty stdin is an error saying nothing was selected. Refs +naming more than one head in a single invocation error. Duplicate refs +(same head, path, range) emit once; input order is otherwise preserved. -`napl status` observational classification, zero mutation. `napl log` -per-module or store-wide history. `napl show ` inspect one object. -`napl diff` drift between committed outputs and working tree as a canonical -byte-stable patch. `napl blame ` derived ancestry: surviving lines -trace through accepted patches by canonical replay; rewritten regions blame -the rewriting transition, honestly coarse. `napl lint` unreachable modules -under an explicit `roots` declaration; when roots are absent the refusal -is loud, exit 2 with canonical JSON naming the missing prerequisite and -the exact declaration that would satisfy it, never an empty findings -list under exit 0; unconsumed surface entries (diagnosis only), module staleness; -findings are proposals with cascade previews; `napl lint --fix` applies -only complete deterministic mapping-safe edits through the guarded commit -path and never removes lines. `napl cost` pure calculation over an explicit -receipt set and a pinned pricing-table provenance object; unknown is -unknown, never zero. Edits price the way generations price: records the -capture verbs staged enter the same receipt set, their session-window -usage references resolve at read time against the named log's bytes, -summing the window's lines under the same pinned pricing table, and -`napl cost --session ` reports one session's calls directly from -the log. A record without a window prices `unavailable`; a window whose -log file or session lines are absent prices `unavailable` and says -which; a model the pricing table does not name prices unknown; nothing -is ever silently zero and nothing is aggregated at record time. The -same receipt set, log bytes, and pricing table produce byte-identical -reports. `napl gc` stages sweeps outside locks, swaps pointers -inside the bounded section, never blocks writers, never deletes anything -reachable from a named head. `napl fsck` envelope hashes, kind validity, -causal parent links. `napl restore` deterministic repair plans from -persisted evidence; unambiguous moves install, ambiguity blocks; liveness -and wall time never decide recovery. `napl mv ` relocation -as a semantic operation: moves a module's installed files to a new -placement, recomputes every placement-derived fact (derived entry-file -projections, attribution file paths) from the destination, passes -through the guarded commit path, and re-verifies there; failed -verification at the destination installs nothing; manifests are the -user's to update, and status diagnoses a placement the workspace -manifest no longer lists. A move made outside `napl mv` is drift like any other: -the store still holds the committed bytes, `napl restore` re-materializes -the module at its recorded placement, and when the drifted content matches -a committed state exactly the plan may instead adopt the new placement -through the commit path; anything ambiguous blocks. `napl cat-file ` -raw plumbing. `napl lsp` a language server over stdio for editor -extensions: hover, definition, references, and code lens over the -prompt-to-code mapping. Everything it serves derives from committed -records (attribution entries, module surfaces, the ir) resolved against -the workspace root; hover on a prompt line presents the attributed -generated spans with their notes, hover on a generated line presents the -owning prompt lines and note, definition jumps across the mapping in both -directions, references enumerates every attributed span, code lens counts -attributed files per prompt section. The server publishes diagnostics -derived from reference staleness: for every stale reference it emits one -diagnostic on each endpoint, the prompt span whose code moved and the -code span whose prompt changed, so sync debt is visible where the edit -happens, like a type error for documentation. Publication is a pure -derivation from committed records plus the working tree at the moment -the protocol asks, no background mutation, no clocks, no scanning loops -beyond what the editor protocol requires; opening or changing a -document refreshes that document's diagnostics and its counterparts'. -Severity is warning, never error: drift is a legal state, marked, not -policed. The diagnostic message states exactly what is known and -nothing more: which end drifted, the reference's direction, recorded -model or `unavailable`, referentially valid, and the healing verbs -(re-record through `napl edit`, or restore); no wording implies the -sentence describes the code. A fresh reference publishes nothing. -Resolving is always a human or LLM re-affirming or re-pointing the -reference through the capture verbs; the server proposes nothing and -repairs nothing. Pure reads only: no mutation, no -clocks, no background scanning. A request whose records are missing or -unparseable answers empty rather than erroring or blocking, and missing -or unparseable records degrade to no diagnostics, never an error; a stale -mapping is served marked stale, consistent with the attribution contract; -the server never hangs on a malformed or absent record. Record resolution is -anchored per document: walk up from the document's filesystem path to the -nearest ancestor directory containing `.napl` and read records there; the -server's working directory and the client's rootUri carry no meaning. -Every location the server emits that points at a module's prompt resolves -to the prompt file's actual placement found by workspace prompt discovery, -where the prompt sits inside the crate directory its code lands in; a -module name is never joined against the workspace root to fabricate a -prompt path, and a module whose prompt cannot be discovered answers empty. -Crash safety: every persisted application step leaves markers such that -recovery derives a deterministic plan from content alone. Evidence -validators are a registry keyed by media type (Claude CLI JSON envelope -ships first, and `application/x-napl-usage-proxy-jsonl-v1` validates -usage-proxy logs: one canonical JSON object per line, required fields -present, absent values the string `unavailable`, token counts -non-negative integers when present, a line failing the shape is -`invalid`); outcomes `validated`, `unsupported`, `invalid`, -`validator-error`; invalid stays visible as failed. Evidence bounds are -compiled-in constants; exceeding any is `resource-limit` before anything -publishes. +Materialization reads the snapshot's content-addressed blobs; committed +prompts and committed module outputs are the only eligible paths. A +`{path, lines}` ref resolves to its owning module through the snapshot's +output manifests — owned globs are disjoint by commit invariant, so +ownership is unique, and a discovered overlap is a store-integrity +failure, not a guess. A prompt ref yields specification prose; a file ref +yields code: one resolver, both directions. Every chunk gets a provenance +header naming `path:start-end` plus the module and note when known. +Cases render after their section's prose as canonical JSON code blocks +inside dynamically sized fences, immune to backticks in values. -::: test -name: a stale reference publishes one diagnostic on each endpoint -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, lsp: [open_prompt, open_code] } -expect: { prompt_diagnostics: 1, code_diagnostics: 1, severity: warning } -::: +Law closure by default: the `law: true` sections of every module a ref +touched are appended once, deduplicated, ordered by module then range, +under one trailing header; `--no-laws` opts out. Refs into a module whose +attribution is stale refuse by default with that module's fix directive; +`--allow-stale` proceeds and stamps `stale` into the affected provenance +headers. Unresolvable refs (absent path, unavailable snapshot) error +naming the ref's input line. ::: test -name: a fresh reference publishes nothing -given: { workspace: fixture_two_modules, lsp: [open_prompt, open_code] } -expect: { prompt_diagnostics: 0, code_diagnostics: 0 } +name: prompt materializes a prompt ref with a provenance header +given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf } +expect: { output_contains_prose: true, header_format: "path:start-end" } ::: ::: test -name: "the diagnostic message carries drifted end, direction, and provenance and nothing semantic" -given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched, lsp: [open_code] } -expect: { message_names_drifted_end: true, message_names_direction: true, message_model_or_unavailable: true, message_semantic_claims: 0 } +name: prompt materializes a generated-file ref symmetrically +given: { workspace: fixture_two_modules, stdin: file_ref_for_leaf_output } +expect: { output_contains_code: true } ::: ::: test -name: absent or unparseable records publish no diagnostics and answer empty -given: { workspace: fixture_two_modules, records: attribution_file_corrupted, lsp: [open_prompt] } -expect: { diagnostics: 0, error_responses: 0, server_alive: true } +name: prompt appends law sections by default and omits them under no-laws +given: { workspace: fixture_with_law_section, stdin: section_ref_non_law } +expect: { laws_appended_default: true, laws_with_flag: false } ::: ::: test -name: committing the drifted code clears both endpoints' diagnostics on refresh -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, then: commit_module, lsp: [change_code_document] } -expect: { prompt_diagnostics: 0, code_diagnostics: 0 } +name: prompt refuses refs into a stale module by default +given: { workspace: fixture_with_directive_case, attribution: stale, stdin: section_ref } +expect: { exit_nonzero: true, directive_present: true, allow_stale_overrides: true } ::: ::: test -name: diff renders drift between committed output and working tree as a canonical patch -given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched } -expect: { diff_nonempty: true, canonical_patch: true, byte_stable: true } +name: prompt errors on a malformed stdin line naming its line number +given: { stdin_lines: [valid_ref, "not json"] } +expect: { exit_nonzero: true, error_names_line: 2 } ::: ::: test -name: "blame over a whole-file regeneration coarsens to the transition, line ancestry survives accepted patches" -given: { workspace: fixture_two_modules, history: [patch_commit, full_rewrite_commit] } -expect: { patched_lines_trace_to_patch: true, rewritten_region_blames_transition: true } +name: prompt errors on empty stdin +given: { stdin: "" } +expect: { exit_nonzero: true, error: nothing_selected } ::: ::: test -name: lint reports an unreachable module only under declared roots and refuses without them -given: { workspace: fixture_orphan_module, roots: absent } -expect: { unreachable_analysis: refused, exit_code: 2, reason_names_roots: true, findings_field_absent: true } +name: prompt deduplicates identical refs and preserves input order +given: { stdin_lines: [ref_a, ref_b, ref_a] } +expect: { chunks: 2, order: [ref_a, ref_b] } ::: ::: test -name: lint with declared roots proposes removal of the orphan with a cascade preview and never auto-applies -given: { workspace: fixture_orphan_module, roots: [main_module] } -expect: { findings: [unreachable_napl], auto_applied: false, cascade_preview: true } +name: "prompt resolves refs against their snapshot, not current head" +given: { workspace: fixture_two_modules, ref_head: previous_snapshot, edits_after: leaf_recommitted } +expect: { materialized_bytes: previous_snapshot_bytes } ::: -::: test -name: lint --fix drops an unreferenced declared dependency through the guarded commit path -given: { workspace: fixture_unused_dep, fix: drop_unused_dep } -expect: { outcome: installed, transition_recorded: true, dep_removed: true } -::: +## Working-tree reads, byte-exact retrieval, and pre-add validation -::: test -name: a cost report over a receipt set containing verb-staged records includes the edits -given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines } -expect: { report_rows: 2, edit_row_priced: true, same_pricing_table: true } -::: +Three reads exist so that a tool amending a prompt can read exactly what +it is amending, retrieve the exact bytes it is about to splice, and +validate the result before anything stages, without a single raw file +read. All three are pure reads: no store writes, no locks, no clock, no +staging. Each is a pure function of the committed state plus the on-disk +prompt bytes it explicitly names, and byte-stable across runs over the +same inputs. + +`napl q --working-tree` builds the canonical document with every +prompt-derived fact enumerated from the on-disk prompt files instead of +the committed blobs: frontmatter, sections, `::: test` directive cases, +and body line ranges all reflect the pending working-tree prompt, +including sections and cases that exist only in the working tree. +Store-derived facts are unchanged and still committed: `head`, module +states, history, receipts. The top level carries `workingTree: true`, +and each section carries `workingTree: true` in place of a committed +`head` value, so a working-tree ref can never be mistaken for a +committed one and no hash is ever invented for uncommitted bytes. +Section file associations are the committed map's claims transported +through the prompt's committed-to-pending diff, the same deterministic +transport `add` uses: an association whose prompt range transports +cleanly is served intact, and a section whose range lands in a +rewritten or ambiguous region serves `files: []` with +`associationUnproven: true`. The view never re-associates files by +range arithmetic over the re-anchored body; an overlap that happens to +exist in the pending text is not a claim, and serving it as one would +be inventing a mapping nobody made. +Section-to-file association under a stale attribution behaves exactly as +today: sections carry their cases, `associationStale: true` marks the +pairing untrustworthy, and nothing pretends freshness. Reference +staleness fields serve exactly as in the committed document. + +`napl prompt --working-tree` resolves `promptPath` refs against the +on-disk prompt file instead of a committed blob. A working-tree ref +carries `workingTree: true` and no `head`; a ref carrying both, or a mix +of working-tree and committed refs in one invocation, is a usage error +naming the input line. Everything else about materialization holds: +provenance headers, law closure from committed state, case rendering, +error shapes. + +`napl prompt --bytes` is byte-exact retrieval, the inverse of the edit a +tool is about to make: the ref's line range materializes as the exact +source bytes of that range, no reflow, no case rendering, no law +closure, no provenance header, one trailing newline. It accepts exactly +one ref; more than one is a usage error. It composes with +`--working-tree` so a splice can be computed against the pending file. +The bytes returned are byte-identical to the named lines of the source, +so retrieve-then-splice is deterministic by construction. + +`napl check ` is dry validation of an on-disk prompt file +before `add`: frontmatter shape and module-name rules per the napl +contract, body normalization, directive grammar for every `::: test` +block including canonical JSON in `given` and `expect`, and case-name +uniqueness. The module-name rules are enforced as name grammar, not +merely as YAML: a frontmatter value that parses as a string but can +never be a legal module name (path-metacharacters, a bare `[`, empty +after trimming) is a finding at check time, never a surprise later. +Findings are canonical JSON on stdout, each naming the file, +the line, and what failed; every reported line is the physical +1-based line in the file as an editor counts it, frontmatter +included, never a body-normalized offset. Exit codes are pinned: 0 for a valid prompt, +1 when findings exist, 2 for usage including an unreadable path. The +store is never touched and nothing stages. `check` passing means the +prompt parses under the same reader `add` uses; it does not predict +`add` outcomes that depend on candidates, attribution, or placement, and +its wording never implies otherwise. `check` teaches while it refuses: +the finding for a missing opening frontmatter fence carries one +complete minimal valid prompt inline, so the first error a cold user +sees contains the whole grammar they were missing; and a checked file +whose name lacks the `.napl` extension gets a finding saying discovery +will never find it, naming the extension, whatever else is valid about +its content. ::: test -name: an edit without a window and a window with missing log lines both price unavailable naming why -given: { workspace: fixture_two_modules, receipts: [edit_without_window, edit_with_window_to_missing_lines] } -expect: { priced_unavailable: 2, reason_named_per_row: true, zeros: 0 } +name: q working-tree enumerates a section that exists only in the working tree +given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } +expect: { new_section_enumerated: true, top_level_working_tree: true, committed_document_unchanged: true } ::: ::: test -name: a model absent from the pricing table prices unknown never zero -given: { workspace: fixture_two_modules, receipts: [edit_with_unknown_model_window], log: proxy_log_unknown_model } -expect: { priced_unknown: 1, zeros: 0 } +name: working-tree sections carry no committed head and mark themselves +given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } +expect: { section_working_tree: true, section_head_absent: true } ::: ::: test -name: cost reports are byte-identical across runs over the same receipts log and table -given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines, runs: 2 } +name: the working-tree document is byte-stable across runs +given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, runs: 2 } expect: { bytes_equal: true } ::: ::: test -name: cost session reports one session's calls directly from the log -given: { workspace: fixture_two_modules, log: proxy_log_two_sessions, session: s1 } -expect: { rows_for_session: 2, rows_for_other_sessions: 0, priced_from_log: true } +name: a transported section keeps its files and a rewritten one serves none unproven +given: { workspace: fixture_two_modules, working_tree_edit: shift_one_section_rewrite_another, flags: [--working-tree] } +expect: { shifted_section_files_intact: true, rewritten_section_files: [], rewritten_section_association_unproven: true, overlap_reassociation: 0 } ::: ::: test -name: a proxy log line failing the shape validates invalid and stays visible as failed -given: { evidence: proxy_log_with_malformed_line, media_type: application_x_napl_usage_proxy_jsonl_v1 } -expect: { outcome: invalid, visible_as_failed: true } +name: prompt bytes round-trips the exact source bytes of a range +given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf, flags: [--bytes] } +expect: { bytes_equal_raw_slice: true, reflow: false, headers: false } ::: ::: test -name: cost over one receipt set and pricing version is byte-identical across runs and unknown for unknown models -given: { receipts: fixture_receipts, pricing_version: pinned, runs: 2, unknown_model_receipt: included } -expect: { reports_equal: true, unknown_model_cost: unknown } +name: prompt bytes refuses more than one ref as a usage error +given: { stdin_lines: [ref_a, ref_b], flags: [--bytes] } +expect: { exit_code: 2, error_names_line: 2 } ::: ::: test -name: gc never deletes an object reachable from the current head and never blocks a concurrent commit -given: { workspace: fixture_two_modules, concurrent: [gc, valid_commit] } -expect: { reachable_deleted: false, commit_blocked: false, unreachable_swept: true } +name: a ref carrying both a head and working-tree is a usage error naming its line +given: { stdin_lines: [ref_with_head_and_working_tree], flags: [--working-tree] } +expect: { exit_nonzero: true, error_names_line: 1 } ::: ::: test -name: fsck detects a corrupted object and a broken parent link -given: { workspace: fixture_two_modules, corrupt: [flip_byte_in_object, remove_parent_event] } -expect: { findings: [hash_mismatch, missing_parent], exit_nonzero: true } +name: check passes a valid prompt with exit zero and no findings +given: { workspace: fixture_two_modules, prompt: leaf_prompt_valid } +expect: { exit_code: 0, findings: 0, store_bytes_unchanged: true } ::: ::: test -name: restore applies an unambiguous move-repair plan and blocks on ambiguity -given: { workspace: fixture_moved_output, ambiguity: [none, two_candidates] } -expect: { unambiguous: installed, ambiguous: blocked } +name: check reports a malformed directive with a finding naming its line +given: { workspace: fixture_two_modules, prompt: leaf_prompt_broken_directive } +expect: { exit_code: 1, finding_names_line: true, store_bytes_unchanged: true } ::: ::: test -name: hover on an attributed prompt line resolves its generated spans -given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: within_entry } } -expect: { spans_resolved: true, notes_included: true } +name: the missing-fence finding carries a complete minimal prompt that itself passes check +given: { workspace: fixture_two_modules, prompt: file_without_frontmatter } +expect: { exit_code: 1, finding_example_passes_check: true } ::: ::: test -name: "hover on an unattributed line answers empty, not error" -given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: outside_entries } } -expect: { result: empty, error: false } +name: a checked file without the napl extension is told discovery will never find it +given: { workspace: fixture_two_modules, prompt: valid_content_named_greeting_md } +expect: { exit_code: 1, finding_names_extension: ".napl", store_bytes_unchanged: true } ::: ::: test -name: missing attribution records degrade to empty responses -given: { attribution: absent, request: hover } -expect: { result: empty, error: false, blocked: false } +name: a module name that yaml accepts but the grammar forbids is a finding +given: { workspace: fixture_two_modules, prompt: frontmatter_module_bare_bracket } +expect: { exit_code: 1, finding_names_module_grammar: true } ::: ::: test -name: definition from a generated line targets the prompt at its discovered placement -given: { attribution: fixture_attribution_small, prompt_placement: inside_crate_dir, request: definition, position: { file: generated_within_entry } } -expect: { location_file: discovered_prompt_path, root_joined_module_name: false } +name: findings report physical file lines as an editor counts them +given: { workspace: fixture_two_modules, prompt: empty_module_value_on_file_line_2 } +expect: { finding_line: 2, line_kind: physical_one_based } ::: +napl-store module-state v1 638 +{"attribution":{"promptHash":"1d93e039abeb6a0ff6dfcbc1a0b7cf5f034a0d040115ea5d09ae1a89051eb978","record":"8febd0b5b5494fd230183698d8b54851cad9cd4b608607440d6f3c63a8ef9c85","stale":false},"contractVersion":4,"inputKey":"232fea965cd8d68474d3a0a8e32a464311f4446f6b02fdd01616fdc5b1fb52b6","module":"commands","outputTree":"d83d2467beb91b948fff71e8ad6912f421053f3c456f4ef0d652253c7a1b8ce7","prompt":"36cb6c034405714880e39433d450c6ee1890fe9bad5e062eae3b704ac17adee0","sliceHash":"d162c1bcd1ea33118b1efe8ff25c2655e1ebd43c915714178b669e63e60a3cbf","surfaceHash":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust"} +napl-store blob v1 5346 +//! The portable path grammar and its collision-detecting normalization. +//! +//! Every path the compiler records is workspace-relative, `/`-separated, and +//! drawn from a portable segment grammar. Two spellings that normalize to the +//! same key are rejected rather than silently merged, so a workspace can never +//! mean different things on a case-sensitive and a case-folding filesystem. -::: test -name: evidence failing its validator is invalid and distinguishable from unsupported -given: { receipt_evidence: [valid_claude_json, malformed_claude_json, unknown_media_type] } -expect: { outcomes: [validated, invalid, unsupported] } -::: +use crate::error::{error, CompilerError, OutcomeCode, Result}; -::: test -name: a receipt exceeding evidence bounds is rejected as resource-limit with nothing published -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: oversized_evidence } -expect: { outcome: resource_limit, published_semantic_state: false, published_provenance: false } -::: +/// Longest permitted single path segment. +pub const MAX_SEGMENT_LEN: usize = 128; -## Determinism contract +/// Longest permitted canonical path. +pub const MAX_PATH_LEN: usize = 1024; -Every collection canonically ordered; every input explicit and content -identified; serialization byte-pinned; ambient environment excluded -(locale, timezone, machine identity, CPU count, enumeration order); reads -never write; a state transition names its base and fails on mismatch per -the decision table rather than recalculating. Same workspace compiled -twice, from different absolute paths, under different discovery orders, -locales, and timezones produces byte-identical artifacts. The test suite -proves the vectors in the frontmatter with deterministic fixtures -constructed in each crate's `tests/`. +/// Validate a workspace-relative path against the portable grammar. +/// +/// Rejects absolute paths, backslashes, empty / `.` / `..` segments, segments +/// outside `[A-Za-z0-9._-]`, segments that do not start alphanumeric, and +/// anything over the length bounds. +pub fn check_canonical_path(path: &str, code: OutcomeCode) -> Result<()> { + let reject = |why: &str| -> CompilerError { error(code, format!("path {path:?} rejected: {why}")) }; + if path.is_empty() { + return Err(reject("empty")); + } + if path.len() > MAX_PATH_LEN { + return Err(reject("exceeds maximum path length")); + } + if path.starts_with('/') { + return Err(reject("absolute")); + } + if path.contains('\\') { + return Err(reject("contains a backslash")); + } + if path.contains('\0') { + return Err(reject("contains a NUL byte")); + } + for segment in path.split('/') { + if segment.is_empty() { + return Err(reject("contains an empty segment")); + } + if segment == "." || segment == ".." { + return Err(reject("contains a relative segment")); + } + if segment.len() > MAX_SEGMENT_LEN { + return Err(reject("segment exceeds maximum length")); + } + let first = segment.chars().next().expect("segment is non-empty"); + if !first.is_ascii_alphanumeric() { + return Err(reject("segment does not start with an ASCII alphanumeric")); + } + for c in segment.chars() { + if !(c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') { + return Err(reject("segment uses a character outside the portable grammar")); + } + } + } + Ok(()) +} -::: test -name: compiling the same workspace twice yields byte-identical artifacts -given: { workspace: fixture_two_modules, runs: 2 } -expect: { artifacts_equal: true } -::: +/// The collision key for a canonical path: ASCII case folded. +pub fn normalize_path_key(path: &str) -> String { + path.to_ascii_lowercase() +} -::: test -name: artifact bytes are independent of discovery order -given: { workspace: fixture_two_modules, discovery_orders: [sorted, reversed, shuffled_seed_7] } -expect: { artifacts_equal: true } -::: +/// True when `path` is inside the directory `prefix`, or is it. +/// +/// The empty prefix is the workspace root itself, exactly as a prompt at the +/// workspace root means the workspace root is the crate directory. Every +/// workspace-relative path is inside it, so containment against the empty root +/// is total rather than vacuously false. +pub fn is_under(path: &str, prefix: &str) -> bool { + if prefix.is_empty() { + return true; + } + path == prefix || path.starts_with(&format!("{prefix}/")) +} -::: test -name: artifact bytes are independent of the absolute workspace path -given: { workspace: fixture_two_modules, mounted_at: [rootA, rootB] } -expect: { artifacts_equal: true } -::: +/// Join a placement root with a workspace-relative remainder. +/// +/// The empty root is the workspace root, so the join is the remainder itself: +/// it never fabricates a leading separator that nothing could match. +pub fn join_root(root: &str, rest: &str) -> String { + if root.is_empty() { + return rest.to_string(); + } + if rest.is_empty() { + return root.to_string(); + } + format!("{root}/{rest}") +} -::: test -name: canonical json sorts keys and is byte-stable -given: { value: "{\"b\":1,\"a\":2}", encodings: 2 } -expect: { bytes_equal: true, sorted_keys: true } -::: +/// True for the bytes an encoded segment may carry unchanged. +fn is_segment_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'_' || byte == b'-' +} + +/// Encode an unrestricted name as one path segment, injectively and reversibly. +/// +/// A module name is any non-empty single-line scalar, so it is never used raw +/// where a record path or a store key needs a segment. Every byte outside +/// `[A-Za-z0-9._-]` becomes `%XX` with uppercase hex, and a name that is exactly +/// `.` or `..` has its dots encoded as well. An encoded segment is therefore +/// never `/`, `\`, empty, `.`, or `..`, and can neither traverse nor alias a +/// directory. A name already drawn from the safe subset, and not `.` or `..`, +/// encodes to itself, so `todo-app` stays `todo-app` on disk. +pub fn encode_name(name: &str) -> String { + if name == "." || name == ".." { + return name.chars().map(|_| "%2E").collect(); + } + let mut out = String::with_capacity(name.len()); + for byte in name.as_bytes() { + if is_segment_byte(*byte) { + out.push(char::from(*byte)); + } else { + out.push_str(&format!("%{byte:02X}")); + } + } + out +} + +/// Recover the exact name an encoded segment was produced from. +/// +/// `None` is a segment this encoding could not have produced: a truncated or +/// non-hex escape, or bytes that are not valid UTF-8 once decoded. +pub fn decode_name(segment: &str) -> Option { + let bytes = segment.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut index = 0usize; + while index < bytes.len() { + if bytes[index] == b'%' { + let hex = segment.get(index + 1..index + 3)?; + out.push(u8::from_str_radix(hex, 16).ok()?); + index += 3; + } else { + out.push(bytes[index]); + index += 1; + } + } + String::from_utf8(out).ok() +} +napl-store receipt v1 1151 +{"baseStateKey":"7aa7e0b4e8cc02142a8aa14ca243a5f2058d72de2f65d7ddb7c59e3d201b692a","baseStoreSnapshot":"92d354f0a491ff544847ab42387fe54aa459f84daf67ae1b4ae57e4218aa7b7c","baseWorkspaceHash":"dd68f36b9cc9e4de689998dc3ea4e0a6f76e772fbbffa3e4e8dcf834400c5c64","candidateTreeHash":"562e142c3a950b916ff1d8cebfdd0ab3a63fb61ebb42d766e6cd8215190b007f","contractVersion":4,"inputKey":"9206162a79eefbb9bbbad00daffa25e4dcbca3fb7db9b908b65fa2829a272f7e","module":"query","outputManifest":[{"hash":"deff13703dd0b3c630077edaffafc1fa9f2fdfde297b9a84b45841a428715965","path":"crates/napl/src/check.rs"},{"hash":"6c743bf47b6f7c61be2d365b61f7143d7d1045e5f683095cce63010c64586d56","path":"crates/napl/src/docs.rs"},{"hash":"70f66c8e4c3cd38f3cda7cbf81fa3dfb8090b766f637732677c0ee9c246e3420","path":"crates/napl/src/document.rs"},{"hash":"5ec2c2137f45d02c8e8bf78ecf0652966850772097b41f3835a99116f3b78f57","path":"crates/napl/src/promptbuild.rs"},{"hash":"9d897617acebf69cdb6e944d077bbaf2c0441a4bf5c26615d86b3e250b06b900","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"sliceHash":"476bdcd17f363b2f8386f1f6076495802c94de78870b20ef3dc5dc26ae7e69d1","target":"rust"} napl-store snapshot v1 781 -{"contractVersion":4,"event":"558721ec5962d23ef0cbc9eb4771329d7f2cde12546f6d5167e49c1b85907063","states":[{"module":"agent","state":"335cdc8da2bee5a1ea22ebd01ce3aeb652efa002f052617fb3123eb318193597","target":"rust"},{"module":"commands","state":"2354c58048f64831cc4e160fab474657cd729c89a21d81320e7f221d3b1bc127","target":"rust"},{"module":"napl","state":"3691606e4e8c5b310d3148cff5211f28da63b0d2dff14c4696b4b53c1d018e77","target":"rust"},{"module":"napl_store","state":"7011890f658fb28bd29684802e5e53bb5d54d54d3c5a64cc926a56fae7ef944d","target":"rust"},{"module":"query","state":"06e076725f8e997478f279e24eaa7ed1c09d8fb7765ec4f5aa4f783e35fdbd3b","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} +{"contractVersion":4,"event":"cfa7aa5cab2183d0fb951afb38ebba6665713f8582d65c3741b185acc5d6ab31","states":[{"module":"agent","state":"cae150dea39f895fe547dc54b23789028aa44560d21f76af0c6b3c4c3af9b3d8","target":"rust"},{"module":"commands","state":"21964b817f6ebe4d1ff2a798b40b824dd21cac84149d6e06ab89fa9061f57655","target":"rust"},{"module":"napl","state":"51a52efb3c1d20e32af8c5097f4e5e4adfe186ccb7d16b3689b844dd1244cd76","target":"rust"},{"module":"napl_store","state":"7011890f658fb28bd29684802e5e53bb5d54d54d3c5a64cc926a56fae7ef944d","target":"rust"},{"module":"query","state":"d8753add6fb4489381d764e2d8abd5964e1eb8a5435669bfc1ac296d9739ffcf","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} napl-store tree v1 132 -{"entries":[{"hash":"2297837c4e3941e251dee4cd7ad9c53759fcc8c961ceb362926b8318cc485e03","kind":"dir","mode":16384,"name":"crates"}]} -napl-store snapshot v1 1005 -{"contractVersion":4,"event":"9d62cdfe887811181783f1c4a2b2876691ab6a3564c5d06fb7f2bfc0d25751f9","states":[{"module":"agent","state":"d0d406dd4f30ea6840a808bcbc3ddeea8ec8cc10c84de12ab5d0cfeef7bb6696","target":"rust"},{"module":"commands","state":"ac7d92c561ec9fa19f0e28e1392bd90c1d95ca663b038424bd8ce760c6f4cd9e","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"1c7c754a9870be661e3ada83f5192a6c7843c671e55c36d53baa5ec9279643f9","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"3bf9a4b0e8a429450fc7d3fe321ef68f7a7382e827cd76136be68a2c2fed4c31","target":"rust"},{"module":"serve","state":"dc62dc04b6d519a8cccdd4d4a3f1b1fa1a0fd830ca0ae5b01058e9d7b5347b98","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} -napl-store blob v1 7690 -module: napl -target: rust -stale: false -entries: - - promptLines: - - 1 - - 8 - file: crates/napl/src/cli.rs - lines: - - 1 - - 133 - note: "the binary reads args runs one command and exits with the contract code" - - promptLines: - - 1 - - 8 - file: crates/napl/src/commandset.rs - lines: - - 1 - - 226 - note: "the command table the core toolchain contributes to the derived registry" - - promptLines: - - 1 - - 8 - file: crates/napl/src/main.rs - lines: - - 1 - - 16 - note: "the binary entry point that spawns no process" - - promptLines: - - 9 - - 106 - file: crates/napl/src/workspace.rs - lines: - - 1 - - 251 - note: "layout and discovery walk up to the nearest napl directory" - - promptLines: - - 9 - - 106 - file: crates/napl/src/target.rs - lines: - - 1 - - 228 - note: "placement roots owned source globs and the user-owned manifest seam" - - promptLines: - - 9 - - 106 - file: crates/napl/src/transaction.rs - lines: - - 1 - - 537 - note: "the crate lib.rs projection derived from the committed module file sets" - - promptLines: - - 107 - - 134 - file: crates/napl/src/objects.rs - lines: - - 1 - - 332 - note: "P2 footprints read write and derived-projection sets over content identities" - - promptLines: - - 107 - - 134 - file: crates/napl/src/decision.rs - lines: - - 1 - - 113 - note: "the conflict rule two operations conflict when semantic write sets overlap" - - promptLines: - - 107 - - 134 - file: crates/napl/src/error.rs - lines: - - 1 - - 108 - note: "the outcome vocabulary and its exit-code mapping" - - promptLines: - - 135 - - 183 - file: crates/napl/src/version.rs - lines: - - 1 - - 1 - note: "the contract version bumped to two by the body-directive grammar" - - promptLines: - - 135 - - 183 - file: crates/napl/src/slice.rs - lines: - - 1 - - 170 - note: "identity vocabulary inputKey closure manifest baseStateKey and sliceHash" - - promptLines: - - 184 - - 244 - file: crates/napl/src/attribution.rs - lines: - - 201 - - 232 - note: "names are serialized as quoted escaped scalars that reparse identically" - - promptLines: - - 184 - - 244 - file: crates/napl/src/attribution.rs - lines: - - 277 - - 284 - note: "the attribution record path keys on the encoded name never the raw one" - - promptLines: - - 184 - - 244 - file: crates/napl/src/paths.rs - lines: - - 71 - - 119 - note: "the injective reversible name encoding used wherever a name becomes a segment" - - promptLines: - - 184 - - 244 - file: crates/napl/src/source.rs - lines: - - 73 - - 95 - note: "the module-name rule any non-empty single-line scalar and nothing narrower" - - promptLines: - - 245 - - 267 - file: crates/napl/src/compile.rs - lines: - - 1 - - 257 - note: "the compile derivation a pure read that never persists an artifact" - - promptLines: - - 245 - - 267 - file: crates/napl/src/graph.rs - lines: - - 1 - - 162 - note: "the dependency graph and dependency-ordered wave planning" - - promptLines: - - 245 - - 267 - file: crates/napl/src/paths.rs - lines: - - 1 - - 119 - note: "the portable path grammar and its collision-detecting normalization" - - promptLines: - - 268 - - 325 - file: crates/napl/src/directive.rs - lines: - - 1 - - 634 - note: "the test body-directive value grammar diagnostics and deterministic extraction" +{"entries":[{"hash":"b36cfb9654d3094df47e47c45be1d8c4fdfd9f79d8a4b32ad08a73de9a19b41d","kind":"dir","mode":16384,"name":"crates"}]} +napl-store tree v1 129 +{"entries":[{"hash":"2fbb3667fe723242b634008a9291cde399784174501f60125158b0ed9f041e46","kind":"dir","mode":16384,"name":"src"}]} +napl-store blob v1 18463 +//! `napl remove`: the end of a module's tenancy, through the guarded door. +//! +//! Removal is the mirror of a commit and takes the same path: it derives +//! everything before any lock, then holds the module lock and the pointer lock +//! for one bounded critical section that publishes a snapshot without the +//! module's state and applies a fixed list of install steps. What it publishes +//! is an absence, so there is no candidate to ingest, no surface to extract, and +//! no receipt to reproduce; everything else is the commit path unchanged, +//! including which projections it may touch: exactly the leaving module's own +//! crate, never a crate whose states did not move. +//! +//! It removes tenancy and never history. The store is append-only: every prior +//! snapshot still names the module's states, and every read verb answers about +//! them exactly as it did before. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use napl_store::store_hash::ObjectKind; +use napl_store::store_tree::{TreeChange, FILE_MODE}; +use napl_store::store_txn_core::{plan_install_steps, CommitMarker}; +use serde_json::{json, Value}; + +use crate::commit::{lock_key, recover, CommitReport}; +use crate::compile::{base_output_files, head_states}; +use crate::disk::{self, Store, POINTER_LOCK}; +use crate::error::{error, error_payload, OutcomeCode, Result}; +use crate::objects::{ + event_hash, event_json, ownership_write_key, read_module_state, snapshot_hash, snapshot_json, + state_write_key, transition_hash, transition_json, Footprint, ModuleState, Transition, +}; +use crate::store_view::{get_blob, has_object, ObjectSource}; +use crate::target::{builtin_target, is_manifest_path, projection_owned_paths, TargetDef}; +use crate::transaction::PendingObject; +use crate::workspace::{apply_step, discover_sources}; + +/// The transition kind a removal records, so history never confuses the end of a +/// tenancy with a commit that installed something. +pub const MODULE_REMOVED: &str = "module-removed"; + +/// End a module's tenancy: publish a snapshot without its state, delete the +/// files it owned, re-derive the entry projection that named it, and close its +/// attribution record. +pub fn remove_module(workspace: &Path, target_name: &str, module: &str) -> CommitReport { + match run(workspace, target_name, module) { + Ok(payload) => CommitReport { + outcome: OutcomeCode::Success, + payload, + }, + Err(err) => CommitReport { + outcome: err.code, + payload: error_payload(&err, false, false), + }, + } +} + +fn run(workspace: &Path, target_name: &str, module: &str) -> Result { + let target = builtin_target(target_name)?; + let store = disk::init(workspace)?; + recover(&store)?; + + let head = store.head()?; + let states = head_states(&store, head.as_deref())?; + let key = (target.name.clone(), module.to_string()); + let Some(state_key) = states.get(&key).cloned() else { + return Err(error( + OutcomeCode::MappingIntegrity, + format!( + "module {module:?} has no committed state for target {:?}, so there is no tenancy to end", + target.name + ), + )); + }; + + let dependents = dependents_of(&store, workspace, &states, &target, module)?; + if !dependents.is_empty() { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "module {module:?} is depended on by {}, and a removal never leaves a committed module pointing at an absence; remove the dependents first", + dependents.join(", ") + ), + )); + } + + let state = read_module_state(&store, &state_key)?; + let owned: BTreeMap = base_output_files(&store, &state)?.into_iter().collect(); + let drifted = drifted_paths(workspace, &owned)?; + if !drifted.is_empty() { + return Err(error( + OutcomeCode::BaseOutputChanged, + format!( + "module {module:?} has drifted from its committed state at {}, and what a removal removes is the committed module and never unseen edits", + drifted.join(", ") + ), + )); + } + + let leaving = crate_root(&owned, &target); + let plan = plan_changes(&store, &target, &leaving, module, &state, &owned, &states)?; + + let transition = Transition { + module: module.to_string(), + target: target.name.clone(), + input_key: state.input_key.clone().unwrap_or_default(), + base_state_key: state_key.clone(), + new_state: String::new(), + receipt: String::new(), + surface: String::new(), + footprint: Footprint { + read_set: vec![format!("base-state:{state_key}")], + write_set: vec![ + ownership_write_key(&leaving), + state_write_key(&target.name, module), + ], + derived_projections: plan.projections.clone(), + install_paths: plan.touched.clone(), + }, + kind: Some(MODULE_REMOVED.to_string()), + prompt: None, + }; + let transition_id = transition_hash(&transition); + publish_all( + &store, + &[json_pending( + ObjectKind::Transition, + &transition_json(&transition), + )], + &transition_id, + )?; + + let mut payload = install( + &store, + workspace, + &target, + module, + &state_key, + &transition_id, + &plan, + )?; + if let Some(repack) = crate::porcelain::repack_if_loose(&store)? { + payload["repack"] = repack; + } + Ok(payload) +} + +/// Every committed module that declares a dependency on the one leaving. +/// +/// The declaration that counts is the committed one, read off each state's own +/// prompt object, never the prompt file on disk: an uncommitted edit dropping a +/// `deps:` entry, or a deleted prompt file, would otherwise talk a removal past +/// the very refusal that protects the module still pointing at it. A state +/// recorded before prompt objects existed carries none, and only there does the +/// working tree answer, because committed truth is silent rather than wrong. +fn dependents_of( + store: &Store, + workspace: &Path, + states: &BTreeMap<(String, String), String>, + target: &TargetDef, + module: &str, +) -> Result> { + let sources = discover_sources(workspace)?; + let mut out: Vec = Vec::new(); + for ((state_target, tenant), state_key) in states { + if state_target != &target.name || tenant == module { + continue; + } + let state = read_module_state(store, state_key)?; + let declared = committed_deps(store, &state, tenant)? + .or_else(|| { + sources + .iter() + .find(|source| &source.module == tenant) + .map(|source| source.deps.clone()) + }) + .unwrap_or_default(); + if declared.iter().any(|dep| dep == module) { + out.push(tenant.clone()); + } + } + out.sort(); + out.dedup(); + Ok(out) +} + +/// The dependencies a committed state's own prompt declares, or `None` when the +/// state names no prompt object and the store therefore holds no answer. +fn committed_deps( + store: &Store, + state: &ModuleState, + module: &str, +) -> Result>> { + let Some(object) = &state.prompt else { + return Ok(None); + }; + if !has_object(store, object) { + return Ok(None); + } + let text = crate::staging::committed_prompt(store, object, module)?; + let parsed = crate::source::parse_source(&format!("{module}.napl"), &text)?; + Ok(Some(parsed.deps)) +} + +/// The owned paths whose working-tree bytes no longer match what was committed. +fn drifted_paths(workspace: &Path, owned: &BTreeMap) -> Result> { + let mut out: Vec = Vec::new(); + for (path, blob) in owned { + let bytes = std::fs::read(workspace.join(path)).ok(); + if crate::attribution::code_drifted(Some(blob.as_str()), bytes.as_deref()) { + out.push(path.clone()); + } + } + Ok(out) +} + +/// The crate root the removed module's files sat under, read off the bytes +/// themselves through the target's own source directory. +fn crate_root(owned: &BTreeMap, target: &TargetDef) -> String { + let marker = format!("/{}/", target.source_dir); + owned + .keys() + .filter_map(|path| path.split_once(&marker).map(|(root, _)| root.to_string())) + .next() + .unwrap_or_default() +} + +/// Everything the removal will write, derived before any lock is taken. +struct RemovalPlan { + objects: Vec, + changes: Vec, + /// Every path the install touches, deletions included, for the footprint and + /// the payload to report without either restating the other. + touched: Vec, + /// The projections this removal re-derives: the leaving crate's entry file, + /// and nothing belonging to a crate whose states did not move. + projections: Vec, + /// The crate the removal empties, present only when nothing is left in it. + vacated: Option, +} + +/// Derive the whole removal: what leaves, what the leaving crate's entry file +/// becomes, and what the user is left holding. +fn plan_changes( + store: &Store, + target: &TargetDef, + leaving: &str, + module: &str, + state: &ModuleState, + owned: &BTreeMap, + states: &BTreeMap<(String, String), String>, +) -> Result { + let projections = projection_owned_paths(target, leaving); + let mut changes: Vec = Vec::new(); + for (path, hash) in owned { + if projections.iter().any(|p| p == path) || is_manifest_path(target, path) { + continue; + } + changes.push(TreeChange::Removed { + path: path.clone(), + hash: hash.clone(), + mode: FILE_MODE, + }); + } + + let mut objects: Vec = Vec::new(); + let mut derived: Vec = Vec::new(); + let mut survivors = states.clone(); + survivors.remove(&(target.name.clone(), module.to_string())); + let (path, bytes) = re_derive(store, target, leaving, &survivors)?; + let vacated = if bytes.is_empty() { + changes.push(TreeChange::Removed { + path: path.clone(), + hash: String::new(), + mode: FILE_MODE, + }); + Some(leaving.to_string()) + } else { + objects.push(blob_pending(&bytes)); + changes.push(TreeChange::Added { + path: path.clone(), + hash: napl_store::store_hash::object_name(ObjectKind::Blob, &bytes), + mode: FILE_MODE, + }); + None + }; + derived.push(path); + + if state.attribution.is_some() { + changes.push(TreeChange::Removed { + path: crate::attribution::view_path(module), + hash: String::new(), + mode: FILE_MODE, + }); + } + + let mut touched: Vec = changes.iter().map(|c| change_path(c).to_string()).collect(); + touched.sort(); + touched.dedup(); + + Ok(RemovalPlan { + objects, + changes, + touched, + projections: derived, + vacated, + }) +} + +/// The path a tree change names, regardless of its variant. +fn change_path(change: &TreeChange) -> &str { + match change { + TreeChange::Added { path, .. } => path, + TreeChange::Removed { path, .. } => path, + TreeChange::Modified { path, .. } => path, + } +} + +#[allow(clippy::too_many_arguments)] +fn install( + store: &Store, + workspace: &Path, + target: &TargetDef, + module: &str, + state_key: &str, + transition_id: &str, + plan: &RemovalPlan, +) -> Result { + let module_lock = disk::take_lock(store, &lock_key(&target.name, module))?; + let pointer_lock = match disk::take_lock(store, POINTER_LOCK) { + Ok(guard) => guard, + Err(err) => { + disk::release_lock(module_lock)?; + return Err(err); + } + }; + let outcome = locked_remove( + store, + workspace, + target, + module, + state_key, + transition_id, + plan, + ); + disk::release_lock(pointer_lock)?; + disk::release_lock(module_lock)?; + outcome +} + +#[allow(clippy::too_many_arguments)] +fn locked_remove( + store: &Store, + workspace: &Path, + target: &TargetDef, + module: &str, + state_key: &str, + transition_id: &str, + plan: &RemovalPlan, +) -> Result { + let head = store.head()?; + let mut states = head_states(store, head.as_deref())?; + let key = (target.name.clone(), module.to_string()); + if states.get(&key).map(String::as_str) != Some(state_key) { + return Err(error( + OutcomeCode::WriteConflict, + format!("module {module:?} moved under this removal; re-run `napl remove {module}`"), + )); + } + states.remove(&key); + + let parents: Vec = match head.as_deref() { + Some(snapshot) => vec![crate::objects::snapshot_event(store, snapshot)?], + None => Vec::new(), + }; + let event = event_hash(transition_id, &parents); + let snapshot = snapshot_hash(&event, &states); + + let mut objects = vec![ + json_pending(ObjectKind::Event, &event_json(transition_id, &parents)), + json_pending(ObjectKind::Snapshot, &snapshot_json(&event, &states)), + ]; + objects.extend(plan.objects.iter().cloned()); + publish_all(store, &objects, transition_id)?; + + let steps = plan_install_steps(&plan.changes); + let marker = CommitMarker { + domain: module.to_string(), + owner: transition_id.to_string(), + snapshot: snapshot.clone(), + steps: steps.clone(), + }; + disk::write_marker(store, &marker)?; + disk::compare_and_set_head(store, head.as_deref(), &snapshot)?; + for step in &steps { + apply_step(workspace, step, &|hash| get_blob(store, hash))?; + } + disk::delete_marker(store, &snapshot)?; + + let deleted: Vec = plan + .changes + .iter() + .filter_map(|change| match change { + TreeChange::Removed { path, .. } => Some(path.clone()), + _ => None, + }) + .collect(); + + Ok(json!({ + "blobsPublished": true, + "contractVersion": crate::version::CONTRACT_VERSION, + "deleted": deleted, + "derivedProjections": plan.projections, + "head": snapshot, + "historyRetained": { + "detail": "removal ends a tenancy and never history: every prior snapshot still names this module's states, and every read verb answers about them unchanged", + "lastState": state_key, + }, + "kind": MODULE_REMOVED, + "module": module, + "outcome": "success", + "provenancePublished": true, + "readyToDelete": ready_to_delete(workspace, module, plan), + "semanticStatePublished": true, + "target": target.name, + "transition": transition_id, + })) +} + +/// What the user still owns after a removal, reported and never touched: the +/// module's prompt file, the manifest that may now carry a dependency nothing +/// needs, and, when the removal emptied a crate, the crate directory itself. +fn ready_to_delete(workspace: &Path, module: &str, plan: &RemovalPlan) -> Value { + let prompt = crate::workspace::discover_sources(workspace) + .ok() + .and_then(|sources| { + sources + .iter() + .find(|source| source.module == module) + .map(|source| source.path.clone()) + }); + json!({ + "crate": plan.vacated, + "detail": "these are yours, like every manifest: removal reports them and touches neither", + "manifest": "review the crate manifest for a dependency only the removed module needed", + "prompt": prompt, + }) +} + +/// Re-derive one crate's entry file from the states that survive the removal. +/// +/// This is the whole of the projection law at work: the entry file is a function +/// of the committed states, so the module stops being named the moment no state +/// owns its files, and nothing here has to know which line to strike. A crate +/// the removal emptied derives to no bytes at all, which is how the caller knows +/// the entry file should leave rather than be rewritten empty. +fn re_derive( + store: &Store, + target: &TargetDef, + root: &str, + states: &BTreeMap<(String, String), String>, +) -> Result<(String, Vec)> { + let src_root = crate::paths::join_root(root, &target.source_dir); + let mut files: Vec = Vec::new(); + let mut providers: Vec = Vec::new(); + for ((state_target, _module), state_key) in states { + if state_target != &target.name { + continue; + } + let state = read_module_state(store, state_key)?; + let owned = base_output_files(store, &state)?; + if !owned + .iter() + .any(|(path, _)| crate::paths::is_under(path, &src_root)) + { + continue; + } + for (path, _hash) in &owned { + files.push(path.clone()); + } + for item in crate::compile::read_surface_items(store, &state.surface_hash)? { + if item.kind != "fn" { + continue; + } + let Some(prefix) = item.path.strip_suffix("::commands") else { + continue; + }; + if !prefix.is_empty() && !prefix.contains(' ') { + providers.push(prefix.to_string()); + } + } + } + Ok(crate::target::derive_lib_projection( + target, root, &files, &providers, + )) +} + +fn publish_all(store: &Store, objects: &[PendingObject], owner: &str) -> Result<()> { + let mut seen: BTreeSet<&str> = BTreeSet::new(); + for object in objects { + if !seen.insert(object.name.as_str()) { + continue; + } + disk::publish(store, object.kind, &object.payload, owner)?; + } + Ok(()) +} + +fn json_pending(kind: ObjectKind, value: &Value) -> PendingObject { + let (name, payload) = crate::canon::hash_json(kind, value); + PendingObject { + name, + kind, + payload, + } +} + +fn blob_pending(bytes: &[u8]) -> PendingObject { + PendingObject { + name: napl_store::store_hash::object_name(ObjectKind::Blob, bytes), + kind: ObjectKind::Blob, + payload: bytes.to_vec(), + } +} +napl-store tree v1 4679 +{"entries":[{"hash":"f0987b243dce1a039a0c78f8100965a895c03501bc378b4217765c5c84982d34","kind":"file","mode":33188,"name":"attribution.rs"},{"hash":"e539c65a4600ed1784b9cc176cb28f24c140f514f9783360f1f5a27142f89f68","kind":"file","mode":33188,"name":"authoring.rs"},{"hash":"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2","kind":"file","mode":33188,"name":"blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","kind":"file","mode":33188,"name":"candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","kind":"file","mode":33188,"name":"canon.rs"},{"hash":"0b8190e7989129e7baf4eed7046e19b7608067eb5d13bed95ad416118bf34a25","kind":"file","mode":33188,"name":"capture.rs"},{"hash":"2e5b5b54aebea3db240acc2f2dc944df07b908bf97ba2067517a9757ed8c7dcb","kind":"file","mode":33188,"name":"cli.rs"},{"hash":"7f8fe1b370188a60f00e524cc5c8059ca17a347b97915097286fff2c0ec7e290","kind":"file","mode":33188,"name":"commandset.rs"},{"hash":"65a7254890fcb0844c78f9967465fd1c93c843479a6617c192291da44994f62f","kind":"file","mode":33188,"name":"commit.rs"},{"hash":"3c0db9b5d179e2f3b7123cd8e08844ea9ad28a0a6ffbc7e5794738709f5af1f4","kind":"file","mode":33188,"name":"compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","kind":"file","mode":33188,"name":"cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","kind":"file","mode":33188,"name":"decision.rs"},{"hash":"bc274cef6c4e5aca63e7c46d81e1f86e118c6ba079e7a4ccd4e9f14a8d3eb21d","kind":"file","mode":33188,"name":"directive.rs"},{"hash":"b86a4431e286225b32bae5cebc03298584fce063e8d4ee2df4633e18143f0aa7","kind":"file","mode":33188,"name":"disk.rs"},{"hash":"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812","kind":"file","mode":33188,"name":"error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","kind":"file","mode":33188,"name":"evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","kind":"file","mode":33188,"name":"graph.rs"},{"hash":"824c95b7229ac2ad5b1cc518b296443883fb56a110c86c2a2e67923c0a19ea07","kind":"file","mode":33188,"name":"lint.rs"},{"hash":"2ea965827e5c5073cbcacacd22bf2318a9067874c901b4f05f67268e99baf92c","kind":"file","mode":33188,"name":"lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","kind":"file","mode":33188,"name":"main.rs"},{"hash":"6fc1b58dad295d780c917c10ac0f793667e46c455349cff3d42e6855501a91bd","kind":"file","mode":33188,"name":"maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","kind":"file","mode":33188,"name":"mv.rs"},{"hash":"7cf9c419b2c4f7c7b17e2c1e4362861de714f4ffe54727c1a0fcb749b2e6975b","kind":"file","mode":33188,"name":"objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","kind":"file","mode":33188,"name":"paths.rs"},{"hash":"2103a2f9f3054ea0913e16c84c302e0a8869263533ecf7a91204288feb146d85","kind":"file","mode":33188,"name":"porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","kind":"file","mode":33188,"name":"receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","kind":"file","mode":33188,"name":"slice.rs"},{"hash":"3c72ec744785180719afc98553f9aa7fc4b47c427647818dbf75ba4e03cc4045","kind":"file","mode":33188,"name":"source.rs"},{"hash":"6f59cf61c11b2590df13b604d16baad2eac373cf823dcb053228eec905b1a12b","kind":"file","mode":33188,"name":"staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","kind":"file","mode":33188,"name":"store_view.rs"},{"hash":"1820f647bb5dbd74c8db7f5519d404ef0ce1520c833a579aaca893b5cad7ba08","kind":"file","mode":33188,"name":"surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","kind":"file","mode":33188,"name":"target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","kind":"file","mode":33188,"name":"textdiff.rs"},{"hash":"167749ecbb655a214510c849c92be88cb536cc7f57a330d21b0314780457b0a1","kind":"file","mode":33188,"name":"transaction.rs"},{"hash":"d4ef5888a7ecc406d8eb5173dc215c7b1a1ef1e1ddd3ab8b3a8aa6639d125017","kind":"file","mode":33188,"name":"transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","kind":"file","mode":33188,"name":"trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","kind":"file","mode":33188,"name":"version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","kind":"file","mode":33188,"name":"workspace.rs"}]} +napl-store blob v1 2658 +//! Derived line ancestry: blame by canonical replay. +//! +//! Blame is not stored. It is replayed from the accepted patches in causal +//! order: a line that survives a transition unchanged keeps the ancestry it +//! already had, and a line the transition wrote is attributed to that +//! transition. A whole-file rewrite therefore blames the rewriting transition +//! for every line, which is honestly coarse rather than falsely precise. + +use serde_json::{json, Value}; + +use crate::textdiff::{diff_lines, lines, Edit}; + +/// One revision of a file, as one accepted transition left it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Revision { + /// The transition that produced this content. + pub transition: String, + /// The file's full content after that transition. + pub content: String, +} + +/// One line of the final content, with the transition that wrote it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlameLine { + /// 1-based line number in the final content. + pub line: usize, + pub transition: String, + pub text: String, +} + +/// Replay a path's revisions into per-line ancestry. +pub fn blame(revisions: &[Revision]) -> Vec { + let mut current: Vec = Vec::new(); + let mut owners: Vec = Vec::new(); + for revision in revisions { + let next = lines(&revision.content); + let mut next_owners: Vec = Vec::with_capacity(next.len()); + for edit in diff_lines(¤t, &next) { + match edit { + // A surviving line keeps the ancestry it already had. + Edit::Keep(old_index, _) => next_owners.push(owners[old_index].clone()), + // A written line belongs to the transition that wrote it. + Edit::Insert(_) => next_owners.push(revision.transition.clone()), + Edit::Delete(_) => {} + } + } + current = next; + owners = next_owners; + } + current + .into_iter() + .zip(owners) + .enumerate() + .map(|(index, (text, transition))| BlameLine { + line: index + 1, + transition, + text, + }) + .collect() +} + +/// The canonical JSON body of a blame report. +pub fn blame_json(path: &str, lines: &[BlameLine]) -> Value { + let entries: Vec = lines + .iter() + .map(|line| { + json!({ + "line": line.line as u64, + "text": line.text, + "transition": line.transition, + }) + }) + .collect(); + json!({ + "contractVersion": crate::CONTRACT_VERSION, + "lines": entries, + "path": path, + }) +} +napl-store receipt v1 1151 +{"baseStateKey":"accaf93691b84c6794df99c7643c710529c765f12cc9faedede4b6b598a8c6b0","baseStoreSnapshot":"653c03e84a35c5b5456fac1a327f8a29ebcc4c19d96ee0f5e166aa3c73d5b7d4","baseWorkspaceHash":"1cbd0f8a55437d59751b61ddc91d2290912cf5b52204af9c179365134de2e5f3","candidateTreeHash":"05f44ac11e9808b57f775c0ffedd617fc59c8a13cef9839ed551a535e36b7148","contractVersion":4,"inputKey":"a3af18b0602f5c451e31dde831d3640aaf42c3d650c51f4b679b6dd8aa24ff6f","module":"query","outputManifest":[{"hash":"deff13703dd0b3c630077edaffafc1fa9f2fdfde297b9a84b45841a428715965","path":"crates/napl/src/check.rs"},{"hash":"7c26384c4adc5b5c857d0811275db450218c8f05177ae35a898ec59a5a5a5e03","path":"crates/napl/src/docs.rs"},{"hash":"726a20b8361501f4c26633cd92835ffb2e5f1acd584c0af76895d29bf620a7b1","path":"crates/napl/src/document.rs"},{"hash":"5ec2c2137f45d02c8e8bf78ecf0652966850772097b41f3835a99116f3b78f57","path":"crates/napl/src/promptbuild.rs"},{"hash":"be629fe4418f4226c3119cf486d936741c11b644241c9c358df2172131c722c1","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"sliceHash":"21a0afb6d5668b4db3969637babc0ca89e99d9330e65f1b1e746facc1ba809e4","target":"rust"} +napl-store transition v1 1428 +{"baseStateKey":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","contractVersion":4,"derivedProjections":["crates/napl-store/src/lib.rs"],"inputKey":"865433045eb8c750a39bd7d29f2e8631c96a03d6aedad8f19ea9907551bdc927","installPaths":["crates/napl-store/src/hash.rs","crates/napl-store/src/store_blocks/mod.rs","crates/napl-store/src/store_blocks/tests.rs","crates/napl-store/src/store_diff/mod.rs","crates/napl-store/src/store_diff/tests.rs","crates/napl-store/src/store_hash.rs","crates/napl-store/src/store_io/mod.rs","crates/napl-store/src/store_io/tests.rs","crates/napl-store/src/store_objects/mod.rs","crates/napl-store/src/store_objects/tests.rs","crates/napl-store/src/store_reduce/mod.rs","crates/napl-store/src/store_reduce/tests.rs","crates/napl-store/src/store_tree.rs","crates/napl-store/src/store_txn_core.rs"],"module":"napl_store","newState":"ba19292fc1f3d688a585b06965de172490cdea63123e6d6d4d536d2592be9025","readSet":["base-state:907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","input-key:865433045eb8c750a39bd7d29f2e8631c96a03d6aedad8f19ea9907551bdc927","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"19f4168a27ef3d98ecf9780d65c22990ad51ba16572473ef8de3f0b894cef777","surface":"50b745f71f63d7fd5b8a05fdf84474524004af8f75d47f8346b7404f99981fab","target":"rust","writeSet":["owned:crates/napl-store","module-state:rust/napl_store"]} +napl-store blob v1 1006 +module: "commands" +target: "rust" +stale: false +entries: - promptLines: - - 326 - - 357 - file: crates/napl/src/surface.rs - lines: - 1 - - 694 - note: "tree-sitter surface extraction sound by construction and loud on malformed input" + - 8 + file: "crates/napl/src/init.rs" + lines: whole + note: "two porcelain additions each a thin composition over the shared derivations, the command table contributing init and watch" - promptLines: - - 358 - - 502 - file: crates/napl/src/staging.rs - lines: - - 1 - - 220 - note: "napl add stages a candidate ingesting validating and writing the internal receipt" + - 9 + - 84 + file: "crates/napl/src/init.rs" + lines: whole + note: "napl init bootstraps the store skeleton or reports already_initialized, names the directory it ran in as the workspace root, declares no flags beyond the shared help flag, teaches the first mile through nextSteps whose minimal prompt declares roots naming its own module so the first workspace is lint-ready, and scaffolds the agent glue by writing only the entry files that are absent and never touching one that exists" - promptLines: - - 358 - - 502 - file: crates/napl/src/commit.rs + - 85 + - 106 + file: "crates/napl/src/watch.rs" lines: - 1 - - 378 - note: "napl commit the one guarded transaction over what add staged" + - 127 + note: "napl watch an io shell re-running compile on content change publishing nothing" +napl-store receipt v1 932 +{"baseStateKey":"12c16b47d6c11312d275a81a2890f4f30d72955e8a7f5210dc871bdf022bdab1","baseStoreSnapshot":"54d53ea574dd74da4263771f7fbe99734f4b9dbfdd419debce4462c1d47f2975","baseWorkspaceHash":"a531341ff0e8cad58637e69d5e861040c78566a8fbf33abbedccecbd9aa7fc64","candidateTreeHash":"64dc2a19e65f0caaff5668d614612d09ca2b044e338fe382eae811b3aea2c388","contractVersion":2,"inputKey":"8284916729e807b849c3171c40dcd8daaeb2f7c268ab4bc2d6e8297f14fd14d6","module":"query","outputManifest":[{"hash":"ab97733506282c17e4ee242d4a63eb254d9b96590bb96b0bc1b8eb4de5fd0b8c","path":"crates/napl/src/document.rs"},{"hash":"f1b16bab91743a5c12abaf26150bf40b513df99019b03e4c70825208238a8121","path":"crates/napl/src/promptbuild.rs"},{"hash":"54e74a9ea88a542e39c45393ba16f55f2204810b4beb411e87dbb1552d5fb2ac","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"sliceHash":"09bd1a89001fd2b25d7cf9d3a3b083bff520609d846b0c7ccdadf9b3a2ea805d","target":"rust"} +napl-store blob v1 954 +module: query +target: rust +stale: false +entries: - promptLines: - - 358 - - 502 - file: crates/napl/src/receipt.rs + - 1 + - 10 + file: crates/napl/src/query.rs lines: - 1 - - 194 - note: "the internal receipt everything the compiler recomputes and refuses to believe" + - 255 + note: "the query module owns napl q and napl prompt and the canonical document they share" - promptLines: - - 358 - - 502 - file: crates/napl/src/attribution.rs + - 11 + - 84 + file: crates/napl/src/document.rs lines: - 1 - - 381 - note: "attribution rides the staged record validated before install" + - 320 + note: "the canonical document docVersion 1 built purely from committed records, totally ordered, no absolute paths" - promptLines: - - 358 - - 502 - file: crates/napl/src/candidate.rs + - 85 + - 139 + file: crates/napl/src/query.rs lines: - 1 - - 150 - note: "candidate ingestion untrusted bytes become immutable content-addressed blobs" + - 255 + note: "napl q shaped exactly like jq via the embedded jaq engine, impure builtins refused, 64 MiB output ceiling" - promptLines: - - 358 - - 502 - file: crates/napl/src/disk.rs + - 140 + - 219 + file: crates/napl/src/promptbuild.rs lines: - 1 - - 203 - note: "the store shell publication head compare-and-set and module-keyed locks" + - 430 + note: "napl prompt reads typed refs and materializes prose and code from committed blobs at the refs snapshot, with law closure" +napl-store blob v1 4336 +module: "usage_proxy" +target: "rust" +stale: false +entries: - promptLines: - - 358 - - 502 - file: crates/napl/src/store_view.rs - lines: - 1 - - 152 - note: "the read seam over content-addressed objects a pure function of the bytes" + - 26 + law: true + note: "one user-facing verb and one capture-only sidecar behind it; the crate is runtime I/O by nature and its telemetry is evidence, never identity" - promptLines: - - 358 - - 502 - file: crates/napl/src/trees.rs + - 27 + - 62 + file: "crates/napl-usage-proxy/src/agent.rs" lines: - 1 - - 34 - note: "building the candidate tree over the ingested manifest" + - 208 + note: "napl agent resolves the workspace root, reuses or starts the one sidecar, injects the base url the session id and the session header, inherits the terminal whole, returns the child exit code, and stops the sidecar on --stop" - promptLines: - - 358 - - 502 - file: crates/napl/src/authoring.rs + - 27 + - 62 + file: "crates/napl-usage-proxy/src/state.rs" lines: - 1 - - 221 - note: "the internal receipt derivation and the one guarded auto-fix" + - 158 + note: "liveness and identity live in one canonical state file written whole and claimed atomically, so racing wrappers converge on one survivor and a dead pid is replaced rather than trusted" - promptLines: - - 503 - - 652 - file: crates/napl/src/porcelain.rs + - 27 + - 62 + file: "crates/napl-usage-proxy/src/session.rs" lines: - 1 - - 679 - note: "the observational porcelain status log show diff blame lint cost gc fsck restore" + - 23 + note: "a fresh session id per run, the window an edit is later joined to its calls by" - promptLines: - - 503 - - 652 - file: crates/napl/src/lsp.rs + - 27 + - 62 + file: "crates/napl-usage-proxy/src/args.rs" lines: - 1 - - 505 - note: "the language server hover definition references and code lens over the mapping" + - 28 + note: "the argv grammar of the verb: flags, the -- separator, and the empty command that is a usage error" - promptLines: - - 503 - - 652 - file: crates/napl/src/mv.rs + - 27 + - 62 + file: "crates/napl-usage-proxy/src/main.rs" lines: - 1 - - 216 - note: "relocation as a semantic operation re-rooting a module through the guarded path" + - 36 + note: "the binary entry: the agent verb and the sidecar it starts are one executable, so napl agent reaches this crate through its own command name" - promptLines: - - 503 - - 652 - file: crates/napl/src/blame.rs + - 63 + - 81 + file: "crates/napl-usage-proxy/src/proxy.rs" lines: - - 1 - - 79 - note: "derived line ancestry surviving lines trace through accepted patches" + - 215 + - 292 + note: "verbatim passthrough to the one upstream origin, session header stripped and hop-by-hop headers dropped, response streamed back as received, capture never gating the call" - promptLines: - - 503 - - 652 - file: crates/napl/src/lint.rs + - 63 + - 81 + file: "crates/napl-usage-proxy/src/upstream.rs" lines: - 1 - - 211 - note: "unreachable modules under declared roots and unconsumed surface entries" + - 33 + note: "one upstream origin over rustls, the request path and query carried across unchanged" - promptLines: - - 503 - - 652 - file: crates/napl/src/cost.rs + - 82 + - 125 + file: "crates/napl-usage-proxy/src/capture.rs" lines: - 1 - - 163 - note: "cost as a pure calculation over an explicit receipt set unknown stays unknown" + - 132 + note: "usage read from a top-level JSON body or merged from the message_start and message_delta events of an SSE stream, every other event ignored, failure to parse leaving usage unread" - promptLines: - - 503 - - 652 - file: crates/napl/src/maintenance.rs + - 82 + - 125 + file: "crates/napl-usage-proxy/src/logline.rs" lines: - 1 - - 344 - note: "gc fsck and restore deterministic repair plans from persisted evidence" + - 112 + note: "one canonical line per completed call appended whole under a gate, absent fields reported unavailable and never zero, the log append-only" - promptLines: - - 503 - - 652 - file: crates/napl/src/evidence.rs + - 82 + - 125 + file: "crates/napl-usage-proxy/src/canonical.rs" lines: - 1 - - 208 - note: "evidence validators keyed by media type with compiled-in bounds" + - 59 + note: "canonical JSON with recursively sorted keys, so a log line and a state file are the same bytes for the same facts" - promptLines: - - 503 - - 652 - file: crates/napl/src/textdiff.rs + - 82 + - 125 + file: "crates/napl-usage-proxy/src/proxy.rs" lines: - - 1 - - 152 - note: "diff renders drift as a canonical byte-stable patch" + - 23 + - 122 + note: "the response body tees into the capture as it streams and settles exactly one log line when the call completes, carrying the stripped session header and never the auth material or the bodies" - promptLines: - - 653 - - 687 - file: crates/napl/src/canon.rs + - 126 + - 135 + file: "crates/napl-usage-proxy/src/proxy.rs" lines: - - 1 - - 44 - note: "the determinism contract canonical bytes and byte-pinned serialization" + - 123 + - 214 + note: "the sidecar exits nonzero only on startup failure, a bind failure or an unusable log path, and never because of a bad request" - promptLines: - - 653 - - 687 + - 136 + - 206 law: true - note: "the determinism contract as a standing law governing every derivation in this module" -napl-store tree v1 129 -{"entries":[{"hash":"28bccc83435bfa1404f7092fc174d909c1b332197b757fed91fbe3f53260fb18","kind":"dir","mode":16384,"name":"src"}]} -napl-store module-state v1 638 -{"attribution":{"promptHash":"1d93e039abeb6a0ff6dfcbc1a0b7cf5f034a0d040115ea5d09ae1a89051eb978","record":"8febd0b5b5494fd230183698d8b54851cad9cd4b608607440d6f3c63a8ef9c85","stale":false},"contractVersion":4,"inputKey":"2e906dd519879ed63e677c9f3d4a71912a01d61c4acc4992ff7c95d2fc42d72d","module":"commands","outputTree":"d83d2467beb91b948fff71e8ad6912f421053f3c456f4ef0d652253c7a1b8ce7","prompt":"2307ccec6af2c2087ef769a86cf51a8f2784d06ed3a14229f8d7228d7909a8af","sliceHash":"b4ab36d3b275f3f1d604f90824dda2d7d7caa4ac7e106c51737a565c3374a948","surfaceHash":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust"} -napl-store event v1 158 -{"content":"1c67b12e1b8b26fe646dee9d76fc02a892715d97a14fc819107a1177defc5f84","parents":["6d3d5f796701f72b83a9e496cd95ab717b808eab8e67a8e0ce71ba5f1c871855"]} -napl-store blob v1 22107 -//! `napl q` and the query module's command table. -//! -//! `napl q` is shaped exactly like jq and IS jq: expressions are evaluated by an -//! embedded jq engine (the jaq crate) against the canonical document, stdin, or -//! a JSON file. Everything here is a pure read over committed records: no store -//! writes, no locks, no clock, no randomness, no network. Impure builtins are -//! refused at expression compile time so an expression is a pure function of its -//! input; a compiled-in 64 MiB output ceiling surfaces as `resource-limit`. - -use std::io::Read as _; -use std::io::Write as _; -use std::path::{Path, PathBuf}; - -use jaq_core::load::{Arena, File, Loader}; -use jaq_core::{data, unwrap_valr, Compiler, Ctx, Vars}; -use jaq_json::Val; -use serde_json::{json, Value}; - -use crate::canon::canonical_bytes; -use crate::cli::{ - flag, has_switch, resolve_workspace, switch, switch_short, value, CliResult, CommandSpec, - FlagSpec, WORKSPACE_FLAG, -}; -use crate::document::build_document_view; -use crate::error::Result; - -/// The compiled-in output ceiling: exceeding it is `resource-limit`. -pub const OUTPUT_CEILING_BYTES: usize = 64 * 1024 * 1024; - -/// Switches `napl q` accepts after the command word. They are not operands, so -/// the argv grammar keeps its exactly-three forms and one-input rule. The short -/// spellings `-r` and `-c` normalize to `--raw-output` and `--compact-output` -/// before this body sees it, so only the long names appear here. -const Q_SWITCHES: [&str; 3] = ["--compact-output", "--raw-output", "--working-tree"]; - -/// Impure builtins the argv pre-scan names explicitly, so a well-known name is -/// refused with a message that names it. These mirror the prompt's enumerated -/// list; the real guarantee is the reachability filter below, which removes -/// every impure builtin from the compiled set regardless of this list. -const IMPURE_BUILTINS: [&str; 7] = [ - "env", - "now", - "input", - "inputs", - "debug", - "stderr", - "input_line_number", -]; - -/// Native jaq-std filters that read the clock, timezone, or environment, or that -/// perform I/O. They are removed from the compiled filter set by construction so -/// an expression is a pure function of its input (P1): a jaq-std upgrade cannot -/// silently reintroduce a leak because a removed name is simply undefined at -/// compile time. `input`/`inputs` are never chained in the first place (they live -/// in `jaq_std::input::funs`, which needs an inputs iterator this engine does not -/// provide), and `input_line_number`/`$ENV` are likewise never registered. -pub(crate) const IMPURE_FUNS: [&str; 6] = [ - "env", - "now", - "localtime", - "strflocaltime", - "debug_empty", - "stderr_empty", -]; - -/// Standard-library definitions built on the impure native funs above (they do -/// stderr I/O), removed from the compiled definition set for the same reason so -/// the surviving defs never reference a fun that is no longer registered. -pub(crate) const IMPURE_DEFS: [&str; 3] = ["debug", "stderr", "halt_error"]; - -/// The one rendering `--output` names. Anything else is a usage error, because -/// the flag is a reading of the store rather than an open format negotiation. -const MARKDOWN_OUTPUT: &str = "markdown"; - -const Q_FLAGS: &[FlagSpec] = &[ - switch_short( - "--compact-output", - "-c", - "accepted as a synonym of the default output, which is already one compact value per line", - ), - switch_short( - "--raw-output", - "-r", - "emit string results as their raw contents, exactly as jq does", - ), - value( - "--output", - "render the selected modules' docs as one markdown envelope instead of JSON results", - ), - switch("--working-tree", "read the working tree's prompts instead of the committed records"), - WORKSPACE_FLAG, -]; - -const PROMPT_FLAGS: &[FlagSpec] = &[ - switch("--allow-stale", "materialize even where the mapping reads stale"), - switch("--bytes", "emit the selected bytes alone, without the surrounding prose"), - switch("--no-laws", "omit the module's standing laws from the materialized prompt"), - switch("--working-tree", "read the working tree's prompts instead of the committed records"), - WORKSPACE_FLAG, -]; - -const CHECK_FLAGS: &[FlagSpec] = &[WORKSPACE_FLAG]; - -/// The command table the `query` module contributes to the derived registry. -/// -/// Each entry declares its summary, exact usage line, and closed flag table, so -/// the dispatch loop can answer help and refuse undeclared flags without the -/// query commands parsing arguments of their own. -pub fn commands() -> &'static [CommandSpec] { - &[ - CommandSpec { - name: "q", - example: "napl q '.modules[].name'", - summary: "evaluate a jq expression over the canonical document", - usage: "napl q [|-] [-c|--compact-output] [-r|--raw-output] [--output=markdown] [--working-tree]", - flags: Q_FLAGS, - passthrough: false, - run: run_q, - }, - CommandSpec { - name: "prompt", - example: "napl q '.modules[] | select(.name==\"leaf\")' | napl prompt", - summary: "materialize the specification prose and pinned cases a selection names", - usage: "napl prompt [--no-laws] [--allow-stale] [--working-tree] [--bytes] < selection", - flags: PROMPT_FLAGS, - passthrough: false, - run: crate::promptbuild::run_prompt, - }, - CommandSpec { - name: "p", - example: "napl q '.modules[] | select(.name==\"leaf\")' | napl p", - summary: "the short spelling of napl prompt", - usage: "napl p [--no-laws] [--allow-stale] [--working-tree] [--bytes] < selection", - flags: PROMPT_FLAGS, - passthrough: false, - run: crate::promptbuild::run_prompt, - }, - CommandSpec { - name: "check", - example: "napl check crates/leaf/leaf.napl", - summary: "report what would stop a prompt file from being added", - usage: "napl check ", - flags: CHECK_FLAGS, - passthrough: false, - run: crate::check::run_check, - }, - ] -} - -/// Where `napl q` reads its input. -enum Input { - Stdin, - File(PathBuf), -} - -fn run_q(cwd: &Path, args: &[String]) -> Result { - let (expr, input) = match parse_argv(args) { - Ok(parsed) => parsed, - Err(msg) => return Ok(fail(2, "usage", &msg)), - }; - if let Some(builtin) = refused_builtin(&expr) { - return Ok(fail( - 3, - "expression", - &format!("impure builtin {builtin:?} is not permitted"), - )); - } - let markdown = match flag(args, "--output")? { - Some(rendering) if rendering == MARKDOWN_OUTPUT => true, - Some(rendering) => { - return Ok(fail( - 2, - "usage", - &format!("unknown output rendering {rendering:?}: --output names {MARKDOWN_OUTPUT}"), - )) - } - None => false, - }; - if markdown { - if input.is_some() { - return Ok(fail(2, "usage", MARKDOWN_READS_THE_STORE)); - } - let workspace = resolve_workspace(cwd, args)?; - let document = build_document_view(&workspace, has_switch(args, "--working-tree"))?; - return run_markdown(&workspace, &expr, &document); - } - let input_bytes = match &input { - None => { - let workspace = resolve_workspace(cwd, args)?; - let working_tree = has_switch(args, "--working-tree"); - canonical_bytes(&build_document_view(&workspace, working_tree)?) - } - Some(Input::Stdin) => { - let mut buf = Vec::new(); - if std::io::stdin().read_to_end(&mut buf).is_err() { - return Ok(fail(4, "input", "could not read stdin")); - } - buf - } - Some(Input::File(path)) => match std::fs::read(path) { - Ok(bytes) => bytes, - Err(e) => return Ok(fail(4, "input", &format!("{}: {e}", path.display()))), - }, - }; - let raw = has_switch(args, "--raw-output"); - match evaluate(&expr, &input_bytes, raw) { - Ok(out) => Ok(CliResult { - stdout: out, - exit_code: 0, - }), - Err((code, kind, msg)) => Ok(fail(code, kind, &msg)), - } -} - -/// The detail a markdown invocation carrying an input operand is refused with: -/// rendering docs is a reading of the store, never a jq transformation of bytes -/// somebody piped in. -const MARKDOWN_READS_THE_STORE: &str = - "--output=markdown renders the canonical document and takes no input operand: it is a reading of the store, not a transformation of stdin or a file"; - -/// Render the docs envelope for whatever the expression selects. -fn run_markdown(workspace: &Path, expr: &str, document: &Value) -> Result { - let bytes = canonical_bytes(document); - let values = match evaluate_values(expr, &bytes) { - Ok(values) => values, - Err((code, kind, msg)) => return Ok(fail(code, kind, &msg)), - }; - let selected = match crate::docs::select_modules(&values) { - Ok(selected) => selected, - Err(msg) => return Ok(fail(2, "usage", &msg)), - }; - let envelope = crate::docs::build_envelope(workspace, &document["head"], &selected)?; - Ok(CliResult { - stdout: canonical_bytes(&envelope), - exit_code: 0, - }) -} - -/// The compiled filter for one expression, with every impure builtin removed -/// from the definition and function sets by construction. -fn compile_filter<'a>( - expr: &'a str, - arena: &'a Arena, -) -> std::result::Result< - jaq_core::Filter>, - (i32, &'static str, String), -> { - let defs = jaq_core::defs() - .chain(jaq_std::defs().filter(|d| !IMPURE_DEFS.contains(&d.name))) - .chain(jaq_json::defs()); - let funs = jaq_core::funs() - .chain(jaq_std::funs().filter(|f| !IMPURE_FUNS.contains(&f.0))) - .chain(jaq_json::funs()); - let loader = Loader::new(defs); - let modules = loader - .load(arena, File { code: expr, path: () }) - .map_err(|errs| (3, "expression", format!("invalid expression: {errs:?}")))?; - Compiler::default() - .with_funs(funs) - .compile(modules) - .map_err(|errs| (3, "expression", format!("invalid expression: {errs:?}"))) -} - -/// Evaluate the expression and collect its results as JSON values, the shape the -/// envelope classifies rather than re-parses out of emitted bytes. -fn evaluate_values( - expr: &str, - input_bytes: &[u8], -) -> std::result::Result, (i32, &'static str, String)> { - let arena = Arena::default(); - let filter = compile_filter(expr, &arena)?; - let mut out: Vec = Vec::new(); - for input in jaq_json::read::parse_many(input_bytes) { - let input = input.map_err(|e| (4, "input", format!("invalid input: {e:?}")))?; - let ctx = Ctx::>::new(&filter.lut, Vars::new([])); - for result in filter.id.run((ctx, input)).map(unwrap_valr) { - let value = result.map_err(|e| (5, "runtime", format!("runtime error: {e:?}")))?; - let mut buf: Vec = Vec::new(); - let _ = jaq_json::write::write(&mut buf, &jaq_json::write::Pp::default(), 0, &value); - out.push(serde_json::from_slice(&buf).unwrap_or(Value::Null)); - } - } - Ok(out) -} - -/// Evaluate the expression over one JSON value or a stream of values, emitting -/// each result canonically, one per line, under the output ceiling. -fn evaluate( - expr: &str, - input_bytes: &[u8], - raw: bool, -) -> std::result::Result, (i32, &'static str, String)> { - let arena = Arena::default(); - let filter = compile_filter(expr, &arena)?; - - let mut out: Vec = Vec::new(); - for input in jaq_json::read::parse_many(input_bytes) { - let input = input.map_err(|e| (4, "input", format!("invalid input: {e:?}")))?; - let ctx = Ctx::>::new(&filter.lut, Vars::new([])); - for result in filter.id.run((ctx, input)).map(unwrap_valr) { - let value = - result.map_err(|e| (5, "runtime", format!("runtime error: {e:?}")))?; - let line = serialize(&value, raw); - if out.len() + line.len() > OUTPUT_CEILING_BYTES { - return Err((23, "resource-limit", "output exceeds the 64 MiB ceiling".into())); - } - out.extend_from_slice(&line); - } - } - Ok(out) -} - -/// Canonically serialize one jaq value: sorted keys, one trailing newline. jaq's -/// own writer emits valid JSON; re-parsing it and re-emitting canonically pins -/// the bytes. -/// -/// Under `--raw-output` a string result emits its contents instead of its JSON -/// spelling, exactly as jq does, so a shell pipeline never needs a dequoting -/// round trip. Every non-string result is unaffected, because there is no raw -/// form of an object for the flag to invent. -/// -/// The newline is a separator, not an appendix: exactly one between results and -/// one at the end. A raw string that already ends in a newline supplies its own, -/// so nothing is added, and a raw result diffs clean against the file it came -/// from instead of gaining a blank line the source never had. -fn serialize(value: &Val, raw: bool) -> Vec { - let mut buf: Vec = Vec::new(); - let _ = jaq_json::write::write(&mut buf, &jaq_json::write::Pp::default(), 0, value); - let parsed: Value = serde_json::from_slice(&buf).unwrap_or(Value::Null); - if raw { - if let Value::String(text) = &parsed { - let mut out = text.clone().into_bytes(); - if !out.ends_with(b"\n") { - out.push(b'\n'); - } - return out; - } - } - canonical_bytes(&parsed) -} - -/// The first impure builtin the expression references, or `None`. A field -/// access such as `.input` is not a builtin call; a string literal never is. -fn refused_builtin(expr: &str) -> Option { - let bytes = expr.as_bytes(); - let mut i = 0; - while i < bytes.len() { - let c = bytes[i]; - if c == b'"' { - i += 1; - while i < bytes.len() && bytes[i] != b'"' { - if bytes[i] == b'\\' { - i += 1; - } - i += 1; - } - i += 1; - continue; - } - if c == b'$' { - let start = i + 1; - let mut j = start; - while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { - j += 1; - } - if &expr[start..j] == "ENV" { - return Some("$ENV".to_string()); - } - i = j.max(i + 1); - continue; - } - if c.is_ascii_alphabetic() || c == b'_' { - let prev = if i > 0 { Some(bytes[i - 1]) } else { None }; - let mut j = i; - while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { - j += 1; - } - let name = &expr[i..j]; - if prev != Some(b'.') && IMPURE_BUILTINS.contains(&name) { - return Some(name.to_string()); - } - i = j; - continue; - } - i += 1; - } - None -} - -/// The usage detail a missing expression reports. It teaches `.` as the explicit -/// spelling of identity rather than accepting a bare invocation, because the -/// primary caller is a language-model session and a whole-document dump is a -/// context bomb; jq demands its filter the same way. -const MISSING_EXPRESSION: &str = - "no expression given: napl q takes a filter exactly as jq does, so identity is spelled explicitly, as in napl q '.' or napl q '.modules[].name'"; - -/// Parse the jq-style argv: `q [--] [input]`. More than one input is a -/// usage error; `--` is accepted before a path starting with a dash; the -/// expression is always required and there is deliberately no shorthand for -/// identity anywhere, so a bare `napl q`, with or without switches, is a usage -/// error rather than a whole-document dump into a model's context. -fn parse_argv(args: &[String]) -> std::result::Result<(String, Option), String> { - let mut operands: Vec = Vec::new(); - let mut seen_command = false; - let mut past_dashdash = false; - let mut i = 0; - while i < args.len() { - let arg = &args[i]; - if !seen_command { - if arg == "--workspace" { - i += 2; - continue; - } - if arg.starts_with("--") { - i += 1; - continue; - } - seen_command = true; - i += 1; - continue; - } - if arg == "--" && !past_dashdash { - past_dashdash = true; - i += 1; - continue; - } - if !past_dashdash && Q_SWITCHES.contains(&arg.as_str()) { - i += 1; - continue; - } - if !past_dashdash && (arg == "--workspace" || arg == "--output") { - i += 2; - continue; - } - operands.push(arg.clone()); - i += 1; - } - let Some(expr) = operands.first().cloned() else { - return Err(MISSING_EXPRESSION.to_string()); - }; - let inputs = &operands[1..]; - if inputs.len() > 1 { - return Err("more than one input given".to_string()); - } - let input = inputs.first().map(|token| { - if token == "-" { - Input::Stdin - } else { - Input::File(PathBuf::from(token)) - } - }); - Ok((expr, input)) -} - -/// Write a canonical error payload to stderr and return the pinned exit code. -fn fail(code: i32, kind: &str, detail: &str) -> CliResult { - let payload = json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "detail": detail, - "kind": kind, - "outcome": "query-error", - }); - let bytes = canonical_bytes(&payload); - let stderr = std::io::stderr(); - let mut lock = stderr.lock(); - let _ = lock.write_all(&bytes); - let _ = lock.flush(); - CliResult { - stdout: Vec::new(), - exit_code: code, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - type D = data::JustLut; + note: "the twelve pinned cases are live tests in the hand-maintained crates/napl-usage-proxy/tests seam, covering the wrapper, the cold-start race, passthrough, capture, secrecy, and concurrent appends" + - promptLines: + - 32 + - 62 + file: "crates/napl-usage-proxy/src/agent.rs" + lines: + - 1 + - 208 + note: "test reference from the wrapper heading to the agent module" + provenance: + timestamp: "2026-07-30T08:11:54Z" + usageLineEnd: "38" + usageLineStart: "2" + usageLogPath: ".napl/usage-proxy.jsonl" + usageMediaType: "application/x-napl-usage-proxy-jsonl-v1" + usageSession: "7cfe5ae0793ee8dfde9c9b624e0a2683" +napl-store snapshot v1 555 +{"contractVersion":3,"event":"00b471b31107981f48c6968244d3b44ef2f290d9b1e82004e41a788edde89df3","states":[{"module":"commands","state":"94697ec1fb16dd5f899f438ef2b257f8b6c5cb611e93acf940a8f2fe94a3713f","target":"rust"},{"module":"napl","state":"55b8614a73ee118bd8731c37ce598fe2b9fcc799e89ead46639d0e57c4307472","target":"rust"},{"module":"napl_store","state":"1506a85bc6ef2b6f1cc8426700959b114ea5532cd02cf43e57d2a82ab7b4fb8a","target":"rust"},{"module":"query","state":"cda23242a5fb8aa243b577c8d7fb4e666dd23757c9c1f8919f20af2224dadec3","target":"rust"}]} +napl-store blob v1 87033 +# NAPL: the deterministic compiler - fn std_fun_names() -> Vec<&'static str> { - let mut names: Vec<&'static str> = jaq_std::funs::().map(|f| f.0).collect(); - names.sort_unstable(); - names - } +This prompt describes the entire NAPL toolchain, rebuilt as one small Cargo +workspace of exactly three crates. It replaces the legacy 76-crate +implementation completely; git history holds the original. This prompt is +the governing contract: the workspace's `.napl` prompts and the committed +code are the two truths, and no external document outranks them. - fn std_def_names() -> Vec<&'static str> { - let mut names: Vec<&'static str> = jaq_std::defs().map(|d| d.name).collect(); - names.sort_unstable(); - names - } +## Layout: this module generates two crates on top of the ported store - #[test] - fn every_filtered_impure_fun_is_a_real_jaq_std_name() { - let names = std_fun_names(); - for impure in IMPURE_FUNS { - assert!( - names.contains(&impure), - "jaq-std no longer registers native fun {impure:?}; the reachability filter is stale and must be re-derived" - ); - } - } +The workspace layout is fixed: the records directory `.napl/` lives at the +repository root; generated code installs under `crates/`; a module's +prompt file lives inside the crate directory that receives its code, and +the crate is the folder: every module whose `.napl` file sits in +`crates//` compiles into the `` crate as its own source +module, so one crate may be described by several prompt files +(`crates/napl/napl.napl` and `crates/napl/commands.napl` both build +`crates/napl/`). Module discovery finds prompts by walking the workspace +for `*.napl` files, and the workspace root is the nearest ancestor +directory containing `.napl`. A subtree that contains its own `.napl` +directory is a separate workspace: discovery never descends into it, the +way git never descends into a nested repository. Hidden directories and +`node_modules` are never descended into. - #[test] - fn every_filtered_impure_def_is_a_real_jaq_std_name() { - let names = std_def_names(); - for impure in IMPURE_DEFS { - assert!( - names.contains(&impure), - "jaq-std no longer defines {impure:?}; the reachability filter is stale and must be re-derived" - ); - } - } +The proven store already exists as a hand-ported crate at +`crates/napl-store/` (same envelope format `napl-store v1 +\n`, SHA-256 names, canonical JSON payloads, same object +kinds, same `.napl/store/` on-disk layout with HEAD compare-and-swap, +module-keyed locks, and dead-holder steal, ported code and tests from the +legacy store crates). Use its public API through a path dependency; do not +reimplement, wrap thinly, or fork any of its types or logic. The four new +object kinds the compiler needs (`transition`, `receipt`, `surface`, +`target-def`) already exist in it additively. - #[test] - fn the_compiled_fun_set_excludes_every_impure_builtin() { - let filtered: Vec<&'static str> = jaq_std::funs::() - .filter(|f| !IMPURE_FUNS.contains(&f.0)) - .map(|f| f.0) - .collect(); - for impure in IMPURE_FUNS { - assert!( - !filtered.contains(&impure), - "impure fun {impure:?} is still reachable in the compiled set" - ); - } - } +::: test +name: the ported store hashes objects exactly as the original store crates did +given: { vectors: ported_store_hash_test_corpus } +expect: { all_pass_unmodified: true } +::: - #[test] - fn the_compiled_def_set_excludes_every_impure_definition() { - let filtered: Vec<&'static str> = jaq_std::defs() - .filter(|d| !IMPURE_DEFS.contains(&d.name)) - .map(|d| d.name) - .collect(); - for impure in IMPURE_DEFS { - assert!( - !filtered.contains(&impure), - "impure def {impure:?} is still reachable in the compiled set" - ); - } - } +::: test +name: identical payload under two object kinds gets two different names +given: { payload: "X", kind_a: blob, kind_b: snapshot } +expect: { equal: false } +::: - #[test] - fn the_jaq_std_fun_roster_is_frozen_so_a_new_builtin_trips_this_test() { - let expected: Vec<&'static str> = FROZEN_STD_FUNS.to_vec(); - assert_eq!( - std_fun_names(), - expected, - "jaq-std's native filter roster changed: classify each added or renamed name (pure -> add to FROZEN_STD_FUNS, clock/timezone/env/randomness/io -> add to IMPURE_FUNS) before shipping the upgrade" - ); - } +This module's generated output is as many crates as the design needs, side +by side in the module's owned directory. Keep it a small, clearly separated +workspace (this replaces a 76-crate explosion; do not recreate one), with +`napl-store` staying its own untouched crate. The natural split, which you +may adjust if the design genuinely wants another boundary: - /// The complete native-filter roster jaq-std registers, frozen at the pinned - /// version so any upgrade that adds or renames a filter fails the test above. - const FROZEN_STD_FUNS: [&str; 96] = [ - "acos", "acosh", "ascii_downcase", "ascii_upcase", "asin", "asinh", "atan", "atan2", - "atanh", "cbrt", "ceil", "copysign", "cos", "cosh", "debug_empty", "decode_base64", - "decode_uri", "encode_base64", "encode_uri", "endswith", "env", "erf", "erfc", - "escape_html", "escape_sh", "exp", "exp10", "exp2", "explode", "expm1", "fabs", "fdim", - "floor", "fma", "fmax", "fmin", "fmod", "frexp", "fromdateiso8601", "gmtime", "group_by", - "halt", "hypot", "ilogb", "implode", "j0", "j1", "jn", "ldexp", "lgamma", "localtime", - "log", "log10", "log1p", "log2", "ltrim", "ltrimstr", "matches", "max_by_or_empty", - "min_by_or_empty", "mktime", "modf", "nearbyint", "nextafter", "now", "pow", "remainder", - "reverse", "rint", "round", "rtrim", "rtrimstr", "scalbln", "sin", "sinh", "sort", - "sort_by", "split_", "split_matches", "sqrt", "startswith", "stderr_empty", - "strflocaltime", "strftime", "strptime", "tan", "tanh", "tgamma", "todateiso8601", "trim", - "trunc", "unescape_html", "utf8bytelength", "y0", "y1", "yn", - ]; -} -napl-store transition v1 632 -{"baseStateKey":"6d4798b1c65398c11513e3f38c5ce2f841b6d0e7f4a7c7274728fb106d666b4b","contractVersion":4,"derivedProjections":[],"inputKey":"c5d75a16ebd9b47dee925970788b6d6877b9516c34c1733a6a2b849fe75b981f","installPaths":[],"kind":"docs-only","module":"query","newState":"e69889078f43de5c958a75fc18b5cd16b9ada2088fe9fe249fc0d9444291706d","prompt":"664271626f562daa87989461e8d10f3eff40a284b4810755bbbd7aca221ff81d","readSet":["prompt:953a5312661e1d886229af4e8b9c4d1ba4f999c0f68dab2e97a4ad590c394c5f"],"surface":"cb2b0ee10d67af3091437305664b3ea3b0419b83dee9436cb4ebea480fef83d3","target":"rust","writeSet":["module-state:rust/query"]} -napl-store snapshot v1 895 -{"contractVersion":4,"event":"6bcd56affb9c34552c2a27389cb81b291c1d9e4849b43d64ba9c36481483ba08","states":[{"module":"agent","state":"f26d6fde72e9fc4f86b6909dc51455aaf37c8a17ed31d6db11e94d9a0408b324","target":"rust"},{"module":"commands","state":"723676083558c83d83926d043c8edd16bb2d48cfe3722fde330cf0351870b1a5","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"e5181ff4a21adb9dbd040fac3f0a5c958ff8273c4c141fe9f4a438eb2b02d800","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"0d9de5138a0e51434c732db16fde21ad6ecfb1f4b0f8fb2789c8b538784b1bda","target":"rust"},{"module":"usage_proxy","state":"acd8f8cd1e71585d8b25804d42455806f39659af2949e7ed369deedcb63abbb2","target":"rust"}]} -napl-store tree v1 129 -{"entries":[{"hash":"eba0c9432525af646bd212cf7103c0cb9f7726adc11db5352779581741a8f8db","kind":"dir","mode":16384,"name":"src"}]} -napl-store snapshot v1 781 -{"contractVersion":4,"event":"2507f26208d4c6efc71f3506a5ee94e70e8a69e9941137abad82037fdd5fb8f2","states":[{"module":"agent","state":"651686af1f5c9f3aad425d1ca7f0de325e4dc362cb59d9a71dc7d75f3216972c","target":"rust"},{"module":"commands","state":"efeb55ee16b2b444a3502f2e63424bcaaec34c9f676dd2c74f00944dceec5a8d","target":"rust"},{"module":"napl","state":"338c9b575a24883622bf3cb1e3276c24a26c31ef34a5097927420523727b6d26","target":"rust"},{"module":"napl_store","state":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","target":"rust"},{"module":"query","state":"79b5813169ed5adeb10cc35fc52c22e390bdef3e22fb2a3dd0b3a300ec844dc4","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} -napl-store tree v1 132 -{"entries":[{"hash":"366a22294f2c4a9eccda8fd05f4b6cbf265bb94cc47827f9bfa114df64e5695a","kind":"dir","mode":16384,"name":"crates"}]} -napl-store blob v1 17388 -use std::collections::BTreeMap; -use std::io::{BufRead, Write}; -use std::path::{Path, PathBuf}; +1. **`napl-core/`** (lib): the pure compiler. Parsing `.napl` files (YAML + frontmatter: module, deps, targets, tests; markdown body), discovery and + dependency graph, canonical path grammar, the identity vocabulary, + per-module artifact slices, wave planning, tree-sitter surface extraction + for Rust and TypeScript targets, the surface grammar, placement rules + and entry-file projection derivation, receipt verification logic, the commit + decision table as pure functions, blame line-ancestry replay, lint + diagnosis, cost calculation, evidence validation. No filesystem writes, + no locks, no clocks, no environment reads: everything enters as + arguments and leaves as values. +2. **`napl-cli/`** (bin `napl`): every command, each a thin composition of + napl-core derivations and napl-store reads plus the one guarded write + path. No private logic in the binary beyond argument parsing and + formatting. -use serde_json::{json, Value}; +The crates depend on `napl-store` by path, `serde_json`, `sha2`, +`tree-sitter`, `tree-sitter-rust`, `tree-sitter-typescript`, `clap` +(builder API only, driving the command line contract below), and nothing +that talks to a network, spawns a process, or reads clocks; those +declarations live in the user-owned manifests, not in any module's +output. Ensure `cargo test` passes for both crates and `napl-store` from +their workspace before finishing. Generated code is authored against its +final installed location, never a staging tree: acceptance runs at the +final location, so green certifies the bytes where they live. The +working-tree add forms are the operational spelling of this law: author +and verify in place, then stage from where the bytes live; assembling a +separate candidate directory is the exception, for staging bytes that +are not the working tree, never the routine. -use crate::attribution::{parse as parse_attribution, Attribution, Entry}; +When several modules share one crate, the crate's shared entry files are +derived projections owned by no module: the crate root `lib.rs` module +list is recomputed at commit from the union of the crate's committed +module file sets, and the binary's command registry is recomputed from +the command tables the crate's modules expose under one fixed convention +(each module may expose `pub fn commands() -> &'static [CommandSpec]` +from its own files; the dispatch loop iterates the derived registry and +contains no per-command match arms of its own). A module's owned output +therefore never includes the shared entry files, two modules can never +claim the same path, and a commit that would write a projection-owned +path is rejected as invalid-candidate. -/// The banner a hover carries when the record it resolved is marked stale, so a -/// reader is told the mapping was never proven rather than being shown nothing. -const STALE_MARKER: &str = "stale attribution: regenerate with a fresh commit"; +A prompt at the workspace root is legal and means the workspace root is +the crate directory, exactly as git treats the directory it was +initialized in: the placement root is the empty workspace-relative path, +every workspace-relative path is inside it, and every join against it +produces clean relative paths, never a fabricated leading separator. A +cold workspace whose first prompt sits beside `.napl` therefore commits +its first module with no configuration; placement never needs declaring +because the prompt's location is the declaration. -pub fn serve(reader: &mut R, writer: &mut W) { - let mut docs: BTreeMap = BTreeMap::new(); - while let Some(message) = read_message(reader) { - let method = message - .get("method") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - let id = message.get("id").cloned(); - match method.as_str() { - "initialize" => respond(writer, id, initialize_result()), - "initialized" => {} - "shutdown" => respond(writer, id, Value::Null), - "exit" => break, - "textDocument/didOpen" => track_open(&mut docs, &message), - "textDocument/didChange" => track_change(&mut docs, &message), - "textDocument/didClose" => track_close(&mut docs, &message), - "textDocument/hover" => respond(writer, id, hover(&docs, &message)), - "textDocument/definition" => respond(writer, id, definition(&docs, &message)), - "textDocument/references" => respond(writer, id, references(&docs, &message)), - "textDocument/codeLens" => respond(writer, id, code_lens(&docs, &message)), - _ => { - if id.is_some() { - respond(writer, id, Value::Null); - } - } - } - } -} +::: test +name: a root-level prompt places at the workspace root and its module commits +given: { workspace: fresh_init_root_prompt, candidate: valid_root_level } +expect: { placement_root: "", owned_globs_relative: true, outcome: installed } +::: -fn initialize_result() -> Value { - json!({ - "capabilities": { - "codeLensProvider": { "resolveProvider": false }, - "definitionProvider": true, - "hoverProvider": true, - "referencesProvider": true, - "textDocumentSync": 1 - }, - "serverInfo": { "name": "napl-lsp" } - }) -} +::: test +name: status for a root-level module reports a workspace-root plan with clean globs +given: { workspace: fresh_init_root_prompt, flag: module_root } +expect: { plan_placement_root: "", owned_globs_leading_separator: false } +::: -fn respond(writer: &mut W, id: Option, result: Value) { - let Some(id) = id else { - return; - }; - write_message( - writer, - &json!({ "id": id, "jsonrpc": "2.0", "result": result }), - ); -} +## The command line contract -fn read_message(reader: &mut R) -> Option { - let mut content_length: Option = None; - loop { - let mut line = String::new(); - let read = reader.read_line(&mut line).ok()?; - if read == 0 { - return None; - } - let trimmed = line.trim_end_matches(['\r', '\n']); - if trimmed.is_empty() { - break; - } - if let Some(value) = trimmed.strip_prefix("Content-Length:") { - content_length = value.trim().parse::().ok(); - } - } - let length = content_length?; - let mut buffer = vec![0u8; length]; - reader.read_exact(&mut buffer).ok()?; - serde_json::from_slice(&buffer).ok() -} +Every command the registry serves declares its full grammar in its +`CommandSpec`: name, a one-line summary, the exact usage line, and the +closed table of flags it accepts, each with arity and meaning. The +dispatch loop owns two behaviors ahead of every command, so no command +can get them wrong. First, help: `napl help` lists every registered +command with its summary, `napl help ` and `--help` or `-h` +anywhere in a command's arguments print that command's usage line, flag +table, and summary, exit 0, and never invoke the command. Help speaks +human by default and machine on request: when stdout is a terminal or +`--human` is passed, help renders readable text from the registry, +usage line, summary, flag table with meanings, and one worked example +per command; when stdout is piped or `--json` is passed, help emits +the canonical JSON registry exactly as today, byte-stable. Help is +the only command with a human rendering; every other command speaks +canonical JSON everywhere, and the two help renderings derive from +the one registry so they can never disagree. Help is a pure +read: no store access, no filesystem write, no state change of any kind, +and this holds for every command including `init` and `watch`. Second, +strict parsing: an argument beginning with `--` that the command's flag +table does not name is a usage error, never ignored; the error goes to +stderr as canonical JSON naming the unknown flag, listing the command's +valid flags, and suggesting the nearest declared flag by edit distance +when one is close, and the command does not execute. Exit 2 is the +pinned usage-error code for the whole binary: unknown command, unknown +flag, missing required flag or positional, malformed flag value. A +usage error always restates the command's exact usage line, so the +error itself teaches the grammar. -fn write_message(writer: &mut W, value: &Value) { - let body = serde_json::to_vec(value).unwrap_or_default(); - let _ = write!(writer, "Content-Length: {}\r\n\r\n", body.len()); - let _ = writer.write_all(&body); - let _ = writer.flush(); -} +The parser behind this contract is clap's builder API, constructed at +dispatch from the CommandSpec registry, never derive macros and never +per-command clap declarations: the registry stays the single source of +grammar and clap is its execution engine. clap's own renderings are +never emitted; every parse failure is caught and served as the +canonical JSON usage error above, byte-shaped by this contract alone. +`--flag value` and `--flag=value` parse identically; no command +declares short flags today, so clustering is outside the contract +until one does. The registry pre-pass, not clap, is what refuses an +undeclared flag, because the canonical JSON payload with its +nearest-name suggestion is pinned by this contract; clap normalizes +spellings and values behind it. `napl completions ` emits a completion script for +`bash`, `zsh`, or `fish`, generated from the same registry, a pure +read on stdout; an unknown shell name is a usage error. -fn track_open(docs: &mut BTreeMap, message: &Value) { - let item = &message["params"]["textDocument"]; - if let (Some(uri), Some(text)) = (item["uri"].as_str(), item["text"].as_str()) { - docs.insert(uri.to_string(), text.to_string()); - } -} +The commands whose meaning requires a committed head are exactly +`diff`, `blame`, and `notes add`; in a workspace that has none they +answer with a prerequisite error, never an empty success shape: the +payload names what is missing and the exact sequence that creates it, +from `napl init` through writing a prompt beside `.napl`, `add`, and +`commit`. `log` and `show` keep their committed contracts and answer +empty honestly. `napl blame` on a path no committed module owns, and +`napl diff` for a target under which no committed module owns output, +say so: the payload carries `tracked: false` and a reason naming the +absent baseline, never an empty patch or empty ancestry that reads as +success. Diff's operand is a target name and its usage line spells it +``; an operand that is not one (a module name, a file +path) is a usage error naming the target names that exist, so the +argument teaches its own vocabulary. The `napl notes add` refusal for a working path +that is not an object name states how an object name is obtained, +naming `napl log` and `napl show` with a one-line example. -fn track_change(docs: &mut BTreeMap, message: &Value) { - let params = &message["params"]; - let Some(uri) = params["textDocument"]["uri"].as_str() else { - return; - }; - let changes = params["contentChanges"].as_array(); - if let Some(text) = changes - .and_then(|list| list.last()) - .and_then(|change| change["text"].as_str()) - { - docs.insert(uri.to_string(), text.to_string()); - } -} +::: test +name: flag equals value and separated value parse identically +given: { workspace: fixture_two_modules, invocations: ["status --module leaf", "status --module=leaf"] } +expect: { outputs_byte_identical: true } +::: -fn track_close(docs: &mut BTreeMap, message: &Value) { - if let Some(uri) = message["params"]["textDocument"]["uri"].as_str() { - docs.remove(uri); - } -} +::: test +name: completions emits a script for a known shell and refuses an unknown one +given: { workspace: fixture_two_modules, invocations: ["completions zsh", "completions tcsh"] } +expect: { zsh_exit_code: 0, zsh_output_nonempty: true, tcsh_exit_code: 2, store_bytes_unchanged: true } +::: -fn load_records(workspace: &Path) -> Vec { - let dir = workspace.join(".napl").join("attribution"); - let mut names: Vec = match std::fs::read_dir(&dir) { - Ok(entries) => entries - .filter_map(|entry| entry.ok()) - .filter_map(|entry| entry.file_name().into_string().ok()) - .filter(|name| name.ends_with(".yaml")) - .collect(), - Err(_) => return Vec::new(), - }; - names.sort(); - let mut records: Vec = Vec::new(); - for name in names { - if let Ok(text) = std::fs::read_to_string(dir.join(&name)) { - if let Some(record) = parse_attribution(&text) { - records.push(record); - } - } - } - records -} +::: test +name: blame on an unowned path and diff on an outputless target say untracked +given: { workspace: fixture_two_modules, blame_path: file_no_module_owns, diff_target: target_without_outputs } +expect: { tracked: false, reason_names_baseline: true, exit_code: 0 } +::: -enum Located { - Prompt { module: String, line: u32 }, - Generated { file: String, line: u32 }, - None, -} +::: test +name: status reports code-side drift and stale references beside prompt freshness +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } +expect: { module_drift: true, stale_reference_count: 1, prompt_side_fresh: true } +::: -fn uri_to_path(uri: &str) -> String { - let stripped = uri.strip_prefix("file://").unwrap_or(uri); - percent_decode(stripped) -} +::: test +name: a headless workspace answers baseline commands with the prerequisite sequence +given: { workspace: fresh_init_no_commits, invocation: "blame some/file.rs" } +expect: { error_names_missing_head: true, sequence_named: [init, prompt, add, commit] } +::: -fn resolve_workspace(uri: &str) -> Option { - let path = uri_to_path(uri); - crate::workspace::find_workspace_root(Path::new(&path)) -} +::: test +name: init with a help flag prints usage and mutates nothing +given: { directory: empty, invocation: "init --help" } +expect: { exit_code: 0, created: [], usage_printed: true } +::: -fn percent_decode(text: &str) -> String { - let bytes = text.as_bytes(); - let mut out: Vec = Vec::with_capacity(bytes.len()); - let mut index = 0; - while index < bytes.len() { - if bytes[index] == b'%' && index + 2 < bytes.len() { - let high = (bytes[index + 1] as char).to_digit(16); - let low = (bytes[index + 2] as char).to_digit(16); - if let (Some(high), Some(low)) = (high, low) { - out.push((high * 16 + low) as u8); - index += 3; - continue; - } - } - out.push(bytes[index]); - index += 1; - } - String::from_utf8_lossy(&out).into_owned() -} +::: test +name: an unknown flag is a usage error naming the flag and suggesting the nearest +given: { workspace: fixture_two_modules, invocation: "add cand --modul m" } +expect: { exit_code: 2, error_names_unknown: "--modul", suggested: "--module", executed: false } +::: -fn relativize(workspace: &Path, path: &str) -> String { - match Path::new(path).strip_prefix(workspace) { - Ok(rest) => rest.to_string_lossy().replace('\\', "/"), - Err(_) => path.to_string(), - } -} +::: test +name: help lists every registered command and per-command help states exact usage +given: { workspace: fixture_two_modules, invocations: ["help", "help add"] } +expect: { all_commands_listed: true, add_usage_line_present: true, exit_code: 0, store_bytes_unchanged: true } +::: -fn stem(rel: &str) -> Option { - let name = rel.rsplit('/').next()?; - name.strip_suffix(".napl").map(str::to_string) -} +::: test +name: help renders human text under the human flag and identical json when piped +given: { workspace: fixture_two_modules, invocations: ["help --human", "help --json"] } +expect: { human_not_json: true, human_carries_example: true, json_byte_stable: true } +::: -fn generated_match(rel: &str, file: &str) -> bool { - rel == file || rel.ends_with(&format!("/{file}")) -} +Manifests are user-owned glue seams, not records: `Cargo.toml` at the +workspace root and in every crate belongs to the user, edited by hand +like any Rust project, tracked by git, consumed by cargo, and NEVER +owned, installed, validated, or rewritten by the toolchain. A candidate +containing a manifest path is rejected as invalid-candidate; no module's +owned output includes one; nothing in the store records manifest bytes. +The toolchain's only relationship to manifests is diagnosis: `status` +reports, as observations never as writes, a module placed in a crate the +workspace manifest does not list, or a crate directory missing a +manifest. Every manifest diagnostic names the exact workspace-relative +manifest path it means and says the file is the user's to edit, so the +manifest is never a thing status alludes to without locating. A dependency needed by generated code is added by editing the +manifest, and the build battery is what proves it correct. -fn document_text( - workspace: &Path, - docs: &BTreeMap, - uri: &str, - rel: &str, -) -> Option { - if let Some(text) = docs.get(uri) { - return Some(text.clone()); - } - std::fs::read_to_string(workspace.join(rel)).ok() -} +::: test +name: a candidate containing a manifest path is rejected +given: { workspace: fixture_two_modules, candidate: contains_cargo_toml } +expect: { outcome: invalid_candidate, manifest_bytes_unchanged: true } +::: -fn frontmatter_lines(text: &str) -> u32 { - let mut fences = 0; - let mut count = 0u32; - for line in text.split('\n') { - count += 1; - if line.trim_end_matches('\r') == "---" { - fences += 1; - if fences == 2 { - return count; - } - } - } - 0 -} +## The two principles -fn locate( - workspace: &Path, - docs: &BTreeMap, - records: &[Attribution], - uri: &str, - line0: u32, -) -> Located { - let path = uri_to_path(uri); - let rel = relativize(workspace, &path); - let raw_line = line0 + 1; - if let Some(module) = stem(&rel) { - if records.iter().any(|record| record.module == module) { - let offset = document_text(workspace, docs, uri, &rel) - .map(|text| frontmatter_lines(&text)) - .unwrap_or(0); - if raw_line > offset { - return Located::Prompt { - module, - line: raw_line - offset, - }; - } - return Located::None; - } - } - Located::Generated { - file: rel, - line: raw_line, - } -} +P1 ownership: the toolchain owns `.napl` source, the derived layer, the +store, and the mappings between them; every owned fact is provable from +content alone. It contains no LLM invocation, no provider identifier, no +model selection, no completion parsing, no process execution (the only +process is `napl` itself), no clock reads in any semantic path, no +randomness in any semantic path. Generation happens outside: the user's +agent session reads the plan from `napl status`, the specification through +the query surface, writes candidate code, and calls `napl add` then +`napl commit`. -fn prompt_entries<'a>(records: &'a [Attribution], module: &str, line: u32) -> Vec<&'a Entry> { - records - .iter() - .filter(|record| record.module == module) - .flat_map(|record| record.entries.iter()) - .filter(|entry| entry.prompt_start <= line && line <= entry.prompt_end) - .collect() -} +::: test +name: the binaries spawn no process and name no provider +given: { crate_source: self } +expect: { spawns_processes: false, provider_identifiers: [] } +::: -fn generated_entries<'a>( - records: &'a [Attribution], - file: &str, - line: u32, -) -> Vec<(&'a Attribution, &'a Entry)> { - let mut out: Vec<(&Attribution, &Entry)> = Vec::new(); - for record in records { - for entry in &record.entries { - // A law entry owns no file, so it never resolves from a generated line. - let Some(entry_file) = &entry.file else { - continue; - }; - if generated_match(file, entry_file) - && entry.line_start <= line - && line <= entry.line_end - { - out.push((record, entry)); - } - } - } - out -} +P2 footprint scoping: every operation declares a read set (content +identities consumed), a semantic write set (owned records changed), and its +derived projections plus physical install paths. Two operations conflict +when semantic write sets overlap or one's accepted write changes a content +identity in the other's read set. Derived projections (the crate-root +lib.rs module list, the command registry) are never conflict-bearing: +they are recomputed exactly once from the winning merged head inside +commit and rebase. Manifests are outside footprints entirely: user-owned +seams no operation reads as identity or writes at all. -fn markdown(lines: Vec) -> Value { - json!({ "contents": { "kind": "markdown", "value": lines.join("\n") } }) -} +## Identity -fn hover(docs: &BTreeMap, message: &Value) -> Value { - let params = &message["params"]; - let uri = params["textDocument"]["uri"].as_str().unwrap_or(""); - let line0 = params["position"]["line"].as_u64().unwrap_or(0) as u32; - let Some(workspace) = resolve_workspace(uri) else { - return Value::Null; - }; - let workspace = workspace.as_path(); - let records = load_records(workspace); - match locate(workspace, docs, &records, uri, line0) { - Located::Prompt { module, line } => { - let matched = prompt_entries(&records, &module, line); - if matched.is_empty() { - return Value::Null; - } - let stale = records - .iter() - .any(|record| record.module == module && record.stale); - let mut lines: Vec = Vec::new(); - if stale { - lines.push(STALE_MARKER.to_string()); - } - for entry in matched { - match &entry.file { - Some(file) => lines.push(format!( - "{}:{}-{} - {}", - file, entry.line_start, entry.line_end, entry.note - )), - None => lines.push(format!("law - {}", entry.note)), - } - } - markdown(lines) - } - Located::Generated { file, line } => { - let matched = generated_entries(&records, &file, line); - if matched.is_empty() { - return Value::Null; - } - let stale = matched.iter().any(|(record, _)| record.stale); - let mut lines: Vec = Vec::new(); - if stale { - lines.push(STALE_MARKER.to_string()); - } - for (record, entry) in matched { - let rel = discovered_prompt(workspace, &record.module) - .unwrap_or_else(|| format!("{}.napl", record.module)); - lines.push(format!( - "{}:{}-{} - {}", - rel, entry.prompt_start, entry.prompt_end, entry.note - )); - } - markdown(lines) - } - Located::None => Value::Null, - } -} +Expose `pub const CONTRACT_VERSION: u32 = 4` in napl-core (bumped from 1 +by the body-directive grammar, which changes how the same source bytes +are interpreted, from 2 by the relaxed module-name rule below, which +accepts sources contract 2 rejected, and from 3 by the capture verbs, +whose three refusal outcomes join the closed terminal-outcome table). Per module and +target: `surfaceHash` (hash of the recorded public surface), `inputKey` +(hash of the canonical closure manifest over normalized source hash, direct +dependencies' surfaceHashes, placement facts, entry-point version, target +name plus target-definition hash, CONTRACT_VERSION), `baseStateKey` (the +module's committed owned-output state hash), `sliceHash` (hash of the +module's canonical compile slice). Whole-compile `artifactHash` includes +scope and serves determinism tests only, never a commit precondition. Event +identity is `H(content hash + sorted parent hashes)`; ordinals and +timestamps are display metadata, never identity. Semantic identifiers are +content hashes, never a timestamp, pid, hostname, random value, model, or +provider. Telemetry lives in provenance records linking to semantic objects +by hash. -fn location(workspace: &Path, file: &str, start: u32, end: u32) -> Value { - let uri = format!("file://{}", workspace.join(file).to_string_lossy()); - let start_line = start.saturating_sub(1); - let end_line = end.saturating_sub(1); - json!({ - "range": { - "end": { "character": 0, "line": end_line }, - "start": { "character": 0, "line": start_line } - }, - "uri": uri - }) -} +::: test +name: "a module's inputKey covers source, direct dep surfaces, placement, target identity, and contract version" +given: { workspace: fixture_two_modules, module: consumer } +expect: { input_key_components: [source_hash, direct_dep_surface_hashes, placement_facts, target_name, target_definition_hash, entry_point_version, contract_version] } +::: -fn discovered_prompt(workspace: &Path, module: &str) -> Option { - crate::workspace::discover_sources(workspace) - .ok()? - .into_iter() - .find(|source| source.module == module) - .map(|source| source.path) -} +::: test +name: changing a leaf's surface changes the direct dependent's inputKey only +given: { workspace: fixture_chain_three, edit: rename_public_item_in_leaf } +expect: { changed_input_keys: [middle], unchanged_input_keys: [top] } +::: -fn prompt_offset(workspace: &Path, docs: &BTreeMap, rel: &str) -> u32 { - let uri = format!("file://{}", workspace.join(rel).to_string_lossy()); - document_text(workspace, docs, &uri, rel) - .map(|text| frontmatter_lines(&text)) - .unwrap_or(0) -} +::: test +name: a leaf edit absorbed by an unchanged intermediate surface leaves higher dependents' inputKeys unchanged +given: { workspace: fixture_chain_three, edit: private_body_change_in_leaf } +expect: { changed_input_keys: [], unchanged_input_keys: [middle, top] } +::: -fn definition(docs: &BTreeMap, message: &Value) -> Value { - let params = &message["params"]; - let uri = params["textDocument"]["uri"].as_str().unwrap_or(""); - let line0 = params["position"]["line"].as_u64().unwrap_or(0) as u32; - let Some(workspace) = resolve_workspace(uri) else { - return Value::Array(Vec::new()); - }; - let workspace = workspace.as_path(); - let records = load_records(workspace); - let mut out: Vec = Vec::new(); - match locate(workspace, docs, &records, uri, line0) { - Located::Prompt { module, line } => { - for entry in prompt_entries(&records, &module, line) { - if let Some(file) = &entry.file { - out.push(location(workspace, file, entry.line_start, entry.line_end)); - } - } - } - Located::Generated { file, line } => { - for (record, entry) in generated_entries(&records, &file, line) { - let Some(rel) = discovered_prompt(workspace, &record.module) else { - continue; - }; - let offset = prompt_offset(workspace, docs, &rel); - out.push(location( - workspace, - &rel, - entry.prompt_start + offset, - entry.prompt_end + offset, - )); - } - } - Located::None => {} - } - Value::Array(out) -} +::: test +name: telemetry differences never change transition identity +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipts: [with_usage, without_usage] } +expect: { transition_identities_equal: true } +::: -fn references(docs: &BTreeMap, message: &Value) -> Value { - let params = &message["params"]; - let uri = params["textDocument"]["uri"].as_str().unwrap_or(""); - let line0 = params["position"]["line"].as_u64().unwrap_or(0) as u32; - let Some(workspace) = resolve_workspace(uri) else { - return Value::Array(Vec::new()); - }; - let workspace = workspace.as_path(); - let records = load_records(workspace); - let matched: Vec<(&Attribution, &Entry)> = - match locate(workspace, docs, &records, uri, line0) { - Located::Prompt { module, line } => prompt_entries(&records, &module, line) - .into_iter() - .filter_map(|entry| { - records - .iter() - .find(|record| record.module == module) - .map(|record| (record, entry)) - }) - .collect(), - Located::Generated { file, line } => generated_entries(&records, &file, line), - Located::None => Vec::new(), - }; - let mut out: Vec = Vec::new(); - for (record, entry) in matched { - if let Some(rel) = discovered_prompt(workspace, &record.module) { - let offset = prompt_offset(workspace, docs, &rel); - out.push(location( - workspace, - &rel, - entry.prompt_start + offset, - entry.prompt_end + offset, - )); - } - if let Some(file) = &entry.file { - out.push(location(workspace, file, entry.line_start, entry.line_end)); - } - } - Value::Array(out) -} +::: test +name: history nodes are causal hashes over content plus sorted parents +given: { workspace: fixture_two_modules, commits: 2 } +expect: { event_id_components: [content_hash, sorted_parent_ids], ordinal_in_identity: false, timestamp_in_identity: false } +::: -fn code_lens(docs: &BTreeMap, message: &Value) -> Value { - let uri = message["params"]["textDocument"]["uri"].as_str().unwrap_or(""); - let Some(workspace) = resolve_workspace(uri) else { - return Value::Array(Vec::new()); - }; - let workspace = workspace.as_path(); - let records = load_records(workspace); - let path = uri_to_path(uri); - let rel = relativize(workspace, &path); - let Some(module) = stem(&rel) else { - return Value::Array(Vec::new()); - }; - let Some(record) = records.iter().find(|record| record.module == module) else { - return Value::Array(Vec::new()); - }; - let Some(text) = document_text(workspace, docs, uri, &rel) else { - return Value::Array(Vec::new()); - }; - let offset = frontmatter_lines(&text); - let mut headings: Vec = Vec::new(); - for (index, line) in text.split('\n').enumerate() { - let raw_line = index as u32 + 1; - if raw_line <= offset { - continue; - } - if line.trim_start().starts_with('#') { - headings.push(raw_line - offset); - } - } - let mut lenses: Vec = Vec::new(); - for (position, &body_line) in headings.iter().enumerate() { - let next = headings.get(position + 1).copied().unwrap_or(u32::MAX); - let mut files: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); - for entry in &record.entries { - if entry.prompt_start >= body_line && entry.prompt_start < next { - if let Some(file) = &entry.file { - files.insert(file.as_str()); - } - } - } - let raw_line = body_line + offset; - lenses.push(json!({ - "command": { - "command": "", - "title": format!("{} attributed files", files.len()) - }, - "range": { - "end": { "character": 0, "line": raw_line - 1 }, - "start": { "character": 0, "line": raw_line - 1 } - } - })); - } - Value::Array(lenses) -} -napl-store blob v1 32826 -# query: the canonical document, napl q, and napl prompt +## Module names -The language answers every question through one query surface and builds -agent context through one prompt builder. This module owns both commands -and the document they share. Everything here is a pure read: no store -writes, no locks held, no clock, no randomness, no network (P1). Reads -serve committed records, with two stated exceptions: reference staleness -is computed at read time from the working tree as well as the committed -bytes, so a hand edit to either end of a reference reads stale without -anything being stored; and the working-tree read mode below serves the -pending prompt when asked to explicitly. The same committed state plus -the same working tree produces the same bytes. This module owns `src/query.rs` (taken over -from the commands module), plus the document builder and prompt builder -sources it introduces. +A module name is any non-empty single-line scalar. The empty string is the only +value rejected, surfaced as `mapping-integrity`. No character class, case, or +identifier shape is required: `todo-app`, `todo_app`, and non-ascii names are all +legal. The same rule governs every name position the parser reads, the `module` +frontmatter value, each `deps` entry, and each declared root, so all three accept +exactly the set the module rule accepts. This relaxation replaces the contract 2 +identifier constraint and rides the CONTRACT_VERSION bump to 3, the sanctioned +identity transition. As with the 1-to-2 bump, every module's inputKey recomputes +because CONTRACT_VERSION is one of its components; no source or surface changes, +only the version does. -## The canonical document +Because a name is unrestricted, it is never used raw as a filesystem path segment +or a store key. napl-core exposes one injective, reversible encoding used at every +such site: each byte outside `[A-Za-z0-9._-]` is percent-encoded as `%XX` with +uppercase hex, and a name that is exactly `.` or `..` has its dots percent-encoded +too, so an encoded segment is never `/`, `\`, empty, `.`, or `..` and can never +traverse or alias a directory. A name already matching `[A-Za-z0-9._-]` and not +equal to `.` or `..` encodes to itself, so existing records keep their on-disk +names: `todo-app` stays `todo-app`. Every writer that keys a record or scratch +path by module name, the mapl, ir, attribution, and prompt-at-gen documents, the +staging and relocation scratch directories, and the store root map, uses the +encoded segment; decoding recovers the exact name. On a case-insensitive +filesystem two names differing only in ascii case can still share one segment; +that collision is the author's concern, distinct from the traversal safety above. -One JSON value, `docVersion 1`, built at query time from committed state: -the store head, module states, attribution records, staged-evidence usage -inside receipts, recorded surfaces, and the committed prompt bytes. Top -level: `docVersion`, `head` (the snapshot hash), `modules`, `history`, -`state`. +Wherever a module name is serialized into a record it is written as a quoted, +escaped scalar, never interpolated raw, so a name containing YAML or JSON +metacharacters reparses to the identical name. -Each module carries `name`, `promptPath`, `deps`, `targets`, -`surfaceHash`, `pending`, `attribution` (an object whose `status` is -`fresh`, `stale`, or `absent`), `sections`, `frontmatterCases` (cases -declared in frontmatter `tests:`, which have no body position), and -`files` (owned output paths), and `outline` — the prompt body's -headings in order, each `{text, level, line}` with the line -body-normalized and 1-based, so "which body line is this heading on" -is a query, never a raw file read. Sections are the module's attribution -entries, and every section is a SELF-CONTAINED REF: it carries `head`, -`module`, `promptPath`, `lines` (body-normalized, 1-based, inclusive), -`note`, `law` (true exactly when the entry owns no file), `files` (each -`{path, lines, head}`), `direction` (`prompt_to_code` or -`code_to_prompt`, per the reference contract in the napl module; entries -recorded before directions read `prompt_to_code`), `provenance` (an -object with `model`, `timestamp`, `toolVersion`, and `usage`, each -absent field served as `unavailable`, never invented), `referenceStatus` -(`fresh` or `stale`), `staleEndpoints` (empty when fresh, `code` and -`prompt` when stale), and `cases` — the `::: test` directive cases -whose block range intersects the section's range, each -`{name, given, expect, lines}` with values in canonical JSON. A directive -case intersecting no entry lands in a synthetic section with empty -`files`. When a module's attribution is stale, sections still carry -their cases (cases derive from prompt bytes, not from attribution) but -each section carries `associationStale: true` and the module's status -says `stale`: the section-to-file pairing cannot be trusted, the case -data can. +::: test +name: a non-identifier module name is accepted +given: { workspace: fixture_single_module, module_name: "todo-app" } +expect: { outcome: ok } +::: -Each file ref inside a section carries `claim`, `whole` when the entry -owns the file with no line range and `range` when it names one, so a -consumer never guesses whether `lines` bounds the claim or merely -samples it. +::: test +name: an empty module name is rejected as mapping-integrity +given: { workspace: fixture_single_module, module_name: "" } +expect: { outcome: mapping_integrity } +::: -Docs fences from the napl module's directive law surface here per -section: `sections[].docs` is the ordered list of docs payloads whose -fence sits inside the section's line range, each -`{directive, attrs, lines, body}` with inner fences verbatim in the -body. The module preamble fence, when present, is served at -`modules[].docs` beside the sections. Docs `lines` are physical prompt -coordinates, the one place physical coordinates appear in the -document, because docs fences are excluded from the semantic body that -every other coordinate speaks in; the document states both coordinate -systems explicitly. A module with no docs fences serves empty docs -lists, never null. +::: test +name: deps entries and declared roots accept the same names as the module rule +given: { module_name: "todo-app", dep_name: "a-b", root_name: "x.y" } +expect: { all_accepted: true } +::: -The committed document's docs are read from the committed prompt -object, the whole prompt file the module's state names, never from -the working tree, for the same reason the committed document's -sections, files, and attribution ignore the working tree: the -document is what the store recorded, and a docs edit that has not -passed through the docs record does not exist to it. A renderer -serving the committed document therefore shows exactly the prose a -`docs-only` or module transition installed, byte for byte, and -hovering that prose can only ever reveal code the store attributes. -Reading docs out of committed bytes is the fence split alone, never -the example validation that guards the door at commit time: those -bytes already passed the gate when they were recorded, and re-judging -them against a grammar that has since moved would let a flag rename -silently erase served prose. A state recorded before prompt objects -existed, or one whose prompt object cannot be read, serves its docs -lists empty rather than guessing from the working tree, and the -module's next commit records the baseline. Under `--working-tree` -docs come from the working-tree prompt files as before, which is the -read the LSP and the editing loop want. +::: test +name: the name encoding is injective reversible and never traverses +given: { names: ["todo-app", "a/b", "..", "café", "a b"] } +expect: { decode_roundtrip_equal: true, no_segment_in: ["/", "\\", "", ".", ".."], safe_subset_unchanged: ["todo-app"] } +::: -The document's freshness verdicts and the language server's are one -analysis: a section the LSP would squiggle as reference-stale reads -`referenceStatus: stale` here with the same endpoints, never a -hardcoded fresh. +::: test +name: a name with yaml metacharacters is written quoted and reparses identically +given: { module_name: "a: {b} #c" } +expect: { record_reparse_equal: true, written_as_quoted_scalar: true } +::: -History entries carry the transition hash, module, target, outcome, and -`usage` — `{model, input, output, cacheRead}` from the staged record's -validated usage evidence, or null when none was supplied; absent -telemetry is null, never zero. +## The compile derivation -A history entry carries the transition's kind: an ordinary install -says `installed`, and a docs-only transition says `docs-only` as both -its `kind` and its `outcome`, so any reader of the document tells -prose history from semantic history without dereferencing the -transition object. +Compilation is a pure derivation and NOT a command: parse the graph, +compute per-module slices for the changed frontier, derive +dependency-ordered waves. Never persisted, and never spelled on the CLI — +it is the internal engine of `status`, `add`, `commit`, `watch`, and the +lsp. Per module the slice carries: normalized source body and hash, direct +dependency surfaces and surfaceHashes, placement and allowed-output +constraints including the surface grammar, the owned-output base and +baseStateKey, inputKey, sliceHash, wave position, receipt requirements. It +contains no provider, model, prompt assembly, credentials, timestamps, +retry policy, pricing, or test results. `napl status --module ` +reports the module's PLAN from this derivation — owned globs, placement +root, projection ownership, receipt requirements, wave position — the +invariant requirements only, a pure function of the working tree's +prompts; candidate-dependent validation belongs to `add`, never the plan. -Canonical serialization throughout: sorted keys, total ordering for every -array (modules by name; sections by start then end line; files and cases -by path and name; history by transition sequence, transition hash as -tiebreaker), no absolute paths anywhere (`promptPath` and file paths are -workspace-relative), one trailing newline. The same committed state -produces the same document bytes on any machine. +Pending is never a bare boolean. A pending module names its reason +from a closed vocabulary: `never-committed`, `source-changed`, +`dependency-surface-moved`, `placement-changed`, `target-changed`, +`contract-version-changed`; the reason is derived by comparing the +current derivation's inputKey components against the identities the +committed state records, the same facts the store already holds, +never a guess. And because an undrifted working tree stages exactly +the committed bytes, a pending module with no drift additionally +says the landing would re-stamp identical bytes, so whether a +pending module needs a real regeneration or a no-op landing is read +off status, never discovered by staging and comparing tree hashes. ::: test -name: q with no arguments emits the canonical document byte-stably -given: { workspace: fixture_two_modules, runs: 2 } -expect: { bytes_equal: true, top_level_keys: [docVersion, head, history, modules, state] } +name: a pending module names dependency-surface-moved as its reason +given: { workspace: fixture_chain_three, intervening_commit: leaf_surface_change, flag: module_middle } +expect: { pending: true, pending_reason: dependency_surface_moved } ::: ::: test -name: the document contains no absolute paths -given: { workspace: fixture_two_modules } -expect: { absolute_paths: 0 } +name: a pending undrifted module says the landing re-stamps identical bytes +given: { workspace: fixture_chain_three, intervening_commit: leaf_surface_change, flag: module_middle } +expect: { pending: true, drift: false, reland_identical_bytes: true } ::: ::: test -name: directive cases appear inside their owning section -given: { workspace: fixture_with_directive_case, attribution: fresh } -expect: { section_cases: 1, case_fields: [expect, given, lines, name] } +name: a never committed module reads pending with its reason +given: { workspace: fixture_two_modules, module: pending_never_committed, flag: module_pending } +expect: { pending: true, pending_reason: never_committed } ::: ::: test -name: stale attribution keeps cases and marks the association stale -given: { workspace: fixture_with_directive_case, attribution: stale } -expect: { section_cases: 1, association_stale: true, module_status: stale } +name: a prompt edit reads pending as source-changed +given: { workspace: fixture_two_modules, working_tree_edit: prompt_body_touched, flag: module_edited } +expect: { pending: true, pending_reason: source_changed } ::: ::: test -name: section refs are self-contained -given: { workspace: fixture_two_modules } -expect: { section_fields_present: [direction, head, lines, module, promptPath, provenance, referenceStatus, staleEndpoints] } +name: status reports a module plan derived from the working tree +given: { workspace: fixture_two_modules, flag: module_leaf } +expect: { plan_fields: [owned_globs, placement_root, receipt_requirements, wave_position], candidate_dependent_fields: absent } ::: ::: test -name: usage from staged evidence appears in history entries -given: { workspace: fixture_two_modules, commit_with_usage: true } -expect: { history_usage_fields: [cacheRead, input, model, output], absent_usage: null } +name: status module answers live state and the derived projection beside the plan +given: { workspace: fixture_two_modules, flag: module_leaf, working_tree_edit: leaf_output_touched } +expect: { state_fields: [attribution, drift, pending, stale_references, tracked], drift: true, projection_path_present: true, projection_content_present: true } ::: ::: test -name: a docs-only transition is told apart in history without dereferencing the transition -given: { workspace: fixture_two_modules, then: [docs_edit, add_prompt_path, commit] } -expect: { last_history_kind: docs-only, last_history_outcome: docs-only, ordinary_entries_say: installed } +name: show lists attached notes inline and log counts them +given: { workspace: fixture_two_modules, history: [commit, note_attached] } +expect: { show_notes_listed: 1, show_note_text_present: true, log_note_count: 1 } ::: +## Body directives + +The prompt body grammar reserves directive blocks, `directiveVersion 1`, +and recognizing them is part of the identity contract: text that older +binaries read as inert prose can be a compile error under this grammar, so +this recognition rides a CONTRACT_VERSION bump, the sanctioned identity +transition. The first directive is `test`. An opener is a line that is +exactly `::: test`; the closer is a line that is exactly `:::`; no +nesting. An unknown `::: ` opener is a compile error, never prose, +so future directive names are non-breaking. Directive blocks are body +text: they count in normalized body lines and attribution ranges may +cover them. + +Block content is one case in an exact value grammar mapping 1:1 onto +canonical JSON, so two binaries cannot disagree on a value: one mapping +with keys `name` (required, non-empty, single line), `given` (optional, +mapping), `expect` (required, mapping). Block mappings and sequences +indent by exactly two spaces; flow `{}` and `[]` forms are allowed. +Strings are double-quoted with JSON escapes, or plain scalars ended by a +structural character (`:`, `,`, `{`, `}`, `[`, `]`, `#`) or end of line +with no leading or trailing space. Numbers are JSON number syntax; +`true`, `false`, `null` are keywords; every other plain scalar is a +string. Nothing else from YAML exists here: no comments, no duplicate +mapping keys, no anchors, aliases, tags, block scalars, multiline +scalars, multi-document markers, octal or hex, timestamps, or merge keys. +A case needing prose belongs in prose. Diagnostics are compile errors +naming the exact body line, judged in this precedence: unclosed block, +unknown directive name, nested opener, content outside the value grammar, +missing or empty name, missing expect, duplicate case name within the +module across both positions after NFC normalization and whitespace trim. +Frontmatter `tests:` remains legal; a module's case set is the union of +both positions; extraction of cases from body bytes is deterministic — +same bytes, same cases. + ::: test -name: a section's docs payloads arrive in order with their bodies verbatim -given: { workspace: fixture_with_docs_fences } -expect: { docs_fields: [attrs, body, directive, lines], order_preserved: true } +name: an unclosed test directive block is a compile error naming its opener line +given: { body: directive_without_closer } +expect: { outcome: compile_error, line_named: opener } ::: ::: test -name: "the committed document serves the docs the store recorded, not the working tree" -given: { workspace: fixture_with_docs_fences, then: [edit_docs_fence_without_recording] } -expect: { committed_docs_unchanged: true, working_tree_docs_show_edit: true } +name: "an unknown directive name is a compile error, never prose" +given: { body: unknown_directive_opener } +expect: { outcome: compile_error, treated_as_prose: false } ::: ::: test -name: a docs-only commit moves the committed document's docs and nothing else in the module -given: { workspace: fixture_with_docs_fences, then: [docs_edit, add_prompt_path, commit] } -expect: { committed_docs_show_edit: true, sections_unmoved: true, input_key_changed: false } +name: a duplicate case name across frontmatter and body errors +given: { frontmatter_case: alpha, body_directive_case: alpha } +expect: { outcome: compile_error, diagnostic: duplicate_case_name } ::: ::: test -name: a state without a prompt baseline serves empty docs rather than guessing -given: { workspace: fixture_committed_before_prompt_objects } -expect: { module_docs: [], section_docs_empty: true, working_tree_mode_still_serves_docs: true } +name: directive case extraction is deterministic +given: { body: fixture_with_two_directives, runs: 2 } +expect: { cases: 2, bytes_equal: true } ::: +## Recorded surfaces + +At commit, extract the candidate's public API with tree-sitter (pinned +grammars) and record it as a `surface` object; dependents' inputKeys pin +recorded surfaceHashes, store facts, never demand scans. The surface +grammar makes extraction sound by construction and violations reject as +`mapping-integrity`: for Rust, public items must be named `pub` +declarations at statically visible paths, no glob re-exports, no +macro-generated `pub` items; a named `pub use` is allowed and is recorded +as the unresolved written binding, never a resolved signature. For +TypeScript: explicit named exports only, no `export *`. Extraction is +syntactic, deterministic, and loud on malformed input. Loud means a +payload, never silence: extraction over bytes that do not parse +answers a parse failure naming the file and the first error's line, +distinct from a genuinely empty surface, in every seat that extracts, +the commit recorder, the `surface` read, and the query layer, so an +empty item list always means parsed and nothing public. Re-extraction from +committed output must reproduce the recorded surface byte-identically. + +The `surface` read is honest about absence the same way: a module with +no committed state under the target answers `tracked: false` naming +the absent baseline, never an empty item list that reads as a real +surface with nothing public. + ::: test -name: a module preamble fence serves title and description in module docs -given: { workspace: fixture_with_module_preamble } -expect: { module_docs_attrs: [description, title] } +name: extraction over unparseable bytes answers parse-failed never an empty surface +given: { source_bytes: syntactically_invalid_rust, extraction: rust_surface } +expect: { outcome: parse_failed, error_names_line: true, served_as_empty_surface: false } ::: ::: test -name: a module with no docs fences serves empty docs lists -given: { workspace: fixture_two_modules } -expect: { module_docs: [], section_docs_empty: true, nulls: 0 } +name: surface for an uncommitted module says untracked never an empty list +given: { workspace: fixture_two_modules, module: pending_never_committed, read: surface_for_target } +expect: { tracked: false, reason_names_baseline: true, empty_item_list_served: false } ::: ::: test -name: file refs carry the claim discriminator -given: { workspace: fixture_two_modules } -expect: { file_claims_subset_of: [range, whole] } +name: a candidate with a glob re-export is rejected as mapping-integrity +given: { workspace: fixture_two_modules, candidate: contains_glob_reexport } +expect: { outcome: mapping_integrity, published_semantic_state: false } ::: ::: test -name: reference staleness in the document matches the language server verdict -given: { workspace: fixture_with_stale_reference } -expect: { reference_status: stale, lsp_parity: true, hardcoded_fresh: false } +name: a named pub use is recorded in the surface as the written binding +given: { workspace: fixture_two_modules, candidate: valid_with_named_reexport } +expect: { surface_contains_written_binding: true, surface_contains_resolved_signature: false } ::: -## napl q +::: test +name: recorded surface re-extraction from committed output is byte-identical +given: { workspace: fixture_two_modules, candidate: valid_for_consumer } +expect: { reextraction_equal: true } +::: -`napl q` is shaped exactly like jq and IS jq: expressions are evaluated -by an embedded jq engine (the jaq crate, version pinned by the lockfile, -upgraded only through a normal committed change). Impure builtins are -rejected at expression compile time (`env`, `$ENV`, `now`, `input`, -`inputs`, `debug`, `stderr`, `input_line_number`, and anything else doing -I/O or randomness), so an expression is a pure function of its input. +## add, then commit -Argv grammar, exactly three forms; more than one input is a usage -error; `--` is accepted before a path starting with a dash; the -expression is always required and there is deliberately no shorthand -for identity anywhere, because the primary caller is a language model -session and a bare invocation that dumps the whole document is a -context bomb, not a convenience; the usage error for a missing -expression teaches `.` as the explicit spelling and jq demands its -filter the same way: +Git parity is the mental model: `add` stages, `commit` installs. The +receipt is an internal record between them, never a user-facing noun. +`napl commit` with no arguments installs every staged record in +dependency-wave order, rebasing where the decision table allows, one +outcome reported per record; `--staged ` installs one. A +multi-module add followed by a bare commit is therefore one command +each, with the sequencing and retry the table already defines owned by +the tool, never hand-looped; and when an earlier record in the same +invocation moves the head so that a later one returns write-conflict +or stale-input against it, bare commit re-derives that record against +the new head, exactly what re-running add would produce, and retries +it once, so modules sharing one crate land in one invocation instead +of hand-ordered pairs. A retry that fails again returns its outcome +honestly. A bare commit that finds nothing staged says so instead of +dressing emptiness as success: the payload names that zero records +were staged, and when any module carries working-tree drift it names +each drifted module beside the exact `napl add` invocation that +would stage it, so the empty commit is the teacher, never the trap. +Commit sweeps the staged record it installed, so a later +bare commit never re-attempts history; `napl gc` reports staged +records among the unreachable, and `napl gc --apply` sweeps them: +they are disposable litter by the git-state law, re-creatable by +re-running add, and no one should ever hand-edit `.napl` to be rid of +them. - napl q expression over the canonical document - napl q - expression over stdin - napl q expression over a JSON file +`napl add` stages a module state from any of three sources, and the +working tree is the default one, exactly as in git: -Input may be a single JSON value or a stream of values (JSONL); the -expression applies per value exactly as jq does. Each result value is -canonically serialized, one per line; `-r`/`--raw-output` emits string -results as their raw contents exactly as jq does, one result per -output line with exactly one newline between results and one at the -end, never doubled when the string itself ends in one, so shell -pipelines never need a dequoting round trip and a raw result diffs -clean against the file it came from. An empty result stream is exit 0 -with no output. Because canonical serialization is already one compact -value per line, jq's `-c`/`--compact-output` is accepted as a -harmless synonym of the default: it changes nothing and never errors, -so the jq reflex costs no usage round trip; `-c` composes with `-r` -exactly as it does in jq, where raw string output ignores it. Errors are canonical JSON on stderr with pinned exit -codes: 2 usage, 3 expression parse or refused builtin, 4 input parse, 5 -runtime or type error. Evaluation carries a compiled-in output ceiling of -64 MiB, enforced functionally and surfaced as `resource-limit` with its -existing exit code; recursion depth is bounded by the embedded engine's -own evaluation stack rather than a compiled-in constant, and an -expression exceeding it aborts rather than returning wrong data. Piping any napl command's -canonical JSON back through an expression works by construction: -`napl status | napl q '.pending' -`. +- `napl add [...]` names working-tree files. Each path + resolves to its owning module through the owned globs (unique by + commit invariant, so no `--module` is needed), and the staged state + is that module's committed output with exactly the named files + overlaid from the working tree. Paths owned by different modules + stage one record per module in one invocation. A path no module owns + is a usage error naming the owned globs that do exist. +- `napl add --module ` stages the module's whole owned tree as + it sits in the working tree. +- `napl add --module ` stages explicit candidate + bytes, for the rare case where the bytes to stage are not the + working tree. + +All three forms feed one pipeline with `[--attribution ] +[--usage ]`: ingest the staged bytes into immutable content +addressed blobs (reject links, devices, duplicate normalized paths, +absolute or parent-escaping paths, oversized entries or totals; all +later steps read ingested blobs, never the source files); parse every +staged file the target claims as source with the target's pinned +tree-sitter grammar, and refuse as `invalid-candidate` when any parse +tree carries an error node, naming the file, the first error's line, +and the malformed region, because a surface extracted from a broken +tree is not a fact worth recording; the gate is syntax only, judged by +the same grammar the surface extractor uses, and everything past +syntax, types, borrows, semantics, stays the acceptance battery's job; +commit re-runs the same gate when it recomputes the staged record, so +bytes that do not parse can never install through any door; compute +the slice and validate against it; resolve attribution as below; validate +and attach usage evidence when supplied; then write the staged record — +the internal receipt: identities generated against, base snapshot +hashes, candidate tree hash, output manifest, attribution, optional +reported fields. Validation failures surface at add time with the same +outcome classes commit uses. Staged records are transient coordination +state, disposable litter by the git-state law: never promoted to +history, safely deletable, re-creatable by re-running add. + +Attribution transports instead of being demanded. Add computes the +byte diff between the module's committed output and the staged bytes, +and the prompt's committed body and its pending body, and transports +every committed attribution claim through those diffs +deterministically: a range the diff proves untouched survives +verbatim, a range below an insertion or deletion shifts by its size, +and a claim landing in a rewritten or ambiguous region does not +transport, because the transport never guesses; a range it cannot +prove through the diff is asked for, never invented. When every claim +transports, add stages with the transported map and no `--attribution` +flag is needed: the paperwork updates itself as a consequence of the +diff already being content-addressed on both sides. When any claim +fails to transport, add refuses as `invalid-candidate` listing exactly +the claims that need affirmation, each beside the observed bound or +region it was checked against, and `--attribution` supplies +affirmations for the listed claims only, layered over the transported +remainder. Any attribution refusal, transport or explicit, names the +observed bound (the actual line count or surviving range) next to the +rejected claim, so a wrong range never costs a manual count. Each +listed claim also carries its index in the recorded map and its note +verbatim, so an affirmation is written against named prose, never +positional trust or a separate query join. + +::: test +name: a transport refusal lists each claim with its map index and note +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } +expect: { listed_claims: 1, claim_index_present: true, claim_note_verbatim: true } +::: + +A module's first add has no committed map to transport, and it demands +no hand-authored one either: absent `--attribution`, add stages the +trivial whole map, every staged file claimed in full by the whole +prompt body, recorded as whole-file claims and honestly coarse. The +cold path to a verified first commit is therefore write the prompt, +write the code, `add --module`, `commit`, with attribution present and +fresh, never `absent`, and `--no-verify` never required to get started. +A claim, in any map, may be spelled whole: naming a file with no +numbers claims all of it, and naming a prompt range as whole claims +the whole body; bounds are derived at validation time from the staged +blob and the pending body, so no line count is ever transcribed by +hand and a whole-file claim survives any growth of its file. + +Two more spellings close the remaining gaps: `napl add +--module ` stages a path under a module explicitly, for the file +that is new in a crate several modules share, where neither the +committed manifest nor the globs can disambiguate it alone; and +`napl add --check` with any form runs the entire pipeline, transport +verdict and refusal listing included, and writes nothing, not even a +staged record, the dry run that answers before anything lands. The +check verdict says exactly what it judged and what it did not: the +payload enumerates the validations that ran (placement, ingest rules, +the parse gate, attribution transport, evidence) and names what stays +unjudged, everything past syntax, the acceptance battery's job, so +`would-stage` means the record is sound and the syntax parses, never +that the code is correct. A transport refusal +reports each endpoint's verdict separately: a claim whose prompt side +failed but whose code side transported serves the transported code +range beside the failed prompt range, and the affirmation supplies +only the side that actually needs a human, never both because one +refused. An unparseable or wrong-shaped `--attribution` file is +answered like a bad ref payload: the refusal carries the complete map +contract, every field and one complete valid example, never a bare +"not parseable". The map reader speaks the same value grammar as body +directives, so block and flow spellings of one map are one map: +`lines: [4, 9]` and its two-line block form parse identically. A map +that parses but yields no entries, or that fails at any construct, is +refused naming the exact line and the construct that stopped the +reader, beside the full contract, never a bare count of zero. The +contract is also taught before any failure: `help add` and +`add --help` serve the same complete map contract and worked example +the refusal carries, so the map is learnable from help instead of +from trial and error. ::: test -name: q evaluates a jq expression over the document -given: { workspace: fixture_two_modules, expr: ".modules[].name" } -expect: { output_lines: [consumer, leaf] } +name: flow and block spellings of one attribution map parse identically +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, maps: [flow_sequences, block_sequences] } +expect: { parsed_entries_equal: true, both_stage: true } ::: ::: test -name: q accepts the compact flag as a synonym of the default output -given: { workspace: fixture_two_modules, invocations: ["q .pending", "q -c .pending", "q --compact-output .pending"] } -expect: { outputs_byte_identical: true, exit_codes: [0, 0, 0] } +name: a map failing mid-parse names the line and construct beside the contract +given: { workspace: fixture_two_modules, attribution_file: bad_construct_on_line_3 } +expect: { error_names_line: 3, error_names_construct: true, error_carries_valid_example: true } ::: ::: test -name: q without an expression is a usage error teaching the explicit dot -given: { workspace: fixture_two_modules, invocations: ["q", "q -c", "q -r"] } -expect: { exit_codes: [2, 2, 2], error_teaches_dot: true, document_dumped: false } +name: add help carries the complete attribution map contract and example +given: { workspace: fixture_two_modules, invocation: "help add" } +expect: { map_fields_listed: true, map_example_present: true, exit_code: 0, store_bytes_unchanged: true } ::: ::: test -name: q compact composes with raw output changing nothing -given: { stdin: "{\"a\":\"x\"}", expr: ".a", input: "-", flags: ["-r", "-c -r"] } -expect: { outputs_byte_identical: true, raw_string_unquoted: true } +name: a first add with no flag stages the whole map and commits verified +given: { workspace: fresh_init_root_prompt, candidate: valid_root_level, attribution_flag: absent } +expect: { add_outcome: staged, map_kind: whole_file_claims, commit_outcome: success, attribution: fresh, no_verify_used: false } ::: ::: test -name: q evaluates an expression over stdin when the input is a dash -given: { stdin: "{\"pending\":[\"m\"]}", expr: ".pending", input: "-" } -expect: { output: "[\"m\"]" } +name: a whole spelling claims the entire file and survives file growth +given: { workspace: fixture_two_modules, map_entry: whole_file_no_numbers, then: file_grows } +expect: { claim_valid: true, bounds_derived: true, retranscription_needed: false } ::: ::: test -name: q rejects an invalid expression without touching the store -given: { workspace: fixture_two_modules, expr: ".[unclosed" } -expect: { exit_code: 3, store_bytes_unchanged: true } +name: add check reports the full verdict and writes nothing +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_check } +expect: { verdict_listed_claims: 1, staged_records: 0, staging_dir_untouched: true, store_bytes_unchanged: true } ::: ::: test -name: q refuses impure builtins -given: { workspace: fixture_two_modules, expr: "now" } -expect: { exit_code: 3, error_names_builtin: true } +name: an explicit module stages a new path in a shared crate +given: { workspace: fixture_shared_crate_two_modules, working_tree: new_uncommitted_file_in_shared_glob, add_path_with_module: true } +expect: { add_outcome: staged, module_as_named: true } ::: ::: test -name: q raw output emits string contents without quotes exactly as jq dash r -given: { workspace: fixture_two_modules, expr: ".modules[0].name", flags: [-r] } -expect: { output: leaf_unquoted, trailing_newline: true } +name: add of one working-tree path stages the committed state with that file overlaid +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, add_paths: [edited_file] } +expect: { add_outcome: staged, staged_state: committed_plus_overlay, unnamed_files_at_committed_bytes: true } ::: ::: test -name: the document serves a heading outline with body line numbers -given: { workspace: fixture_two_modules } -expect: { outline_present: true, outline_entry_fields: [level, line, text], lines_body_normalized: true } +name: paths owned by two modules stage one record per module +given: { workspace: fixture_two_modules, working_tree_edit: both_modules_touched, add_paths: [file_of_leaf, file_of_consumer] } +expect: { staged_records: 2, modules: [consumer, leaf] } ::: -## The docs envelope: napl q --output=markdown - -`napl q --output=markdown` renders the canonical document's docs as -markdown, packaged in one JSON envelope, because every docs consumer -(a site, a README generator, an editor) wants the same three things at -once: prose as markdown, sections with stable identities, and the code -each section attributes. Assembling those client-side from the raw -document means re-deriving serialization the language already owns, -and every past attempt to regex prose into markdown outside the -toolchain produced escaping defects the emitter cannot, because the -emitter walks recorded structure, the fence split and the outline, -never text. - -The flag composes with the document form only; with `-` or a file -input it is exit 2, usage, teaching that markdown rendering is a -reading of the store, not a jq transformation. The expression stays -required and selects modules: it is evaluated against the canonical -document first, and its result must be the document itself, one module -value, or an array of module values; any other result is exit 2 naming -the three accepted shapes. `napl q . --output=markdown` renders every -module. - -The envelope is `{docsVersion: 1, head, modules}` under the same -canonical serialization law as the document: sorted keys, total array -orderings, no absolute paths, one trailing newline, byte-stable across -machines. Each envelope module carries `name`, `promptPath`, `title` -(the text of the first heading inside the module's preamble docs -fence), `description` (the first paragraph after that heading), both -served empty when the preamble or its parts are absent, `markdown` -(the preamble fence rendered), and `sections`. - -Envelope sections regroup the document's one-section-per-file-ref -shape into prose shape: entries sharing an identical line range merge -into one envelope section whose `files` concatenate deduplicated by -path and lines, whose `referenceStatus` is stale when any constituent -is stale, and whose provenance keeps every constituent record. A -case-only group, one with empty files, whose range sits strictly -inside exactly one surviving section joins that section, and the join -carries everything the group owned: cases append, files and staleness -merge, nothing is dropped on the floor because a guarantee that -arrives without its provenance reads fresher than the store knows it -to be. Each envelope section carries `id`, the string -`--` from its body-normalized line range, stable -across heading renames because no heading text participates; `lines`; -`markdown`, the section's docs fences rendered in order; `cases` -verbatim from the document; and `files`, where each file ref carries -the document's fields plus `excerpt`, the claimed span's bytes read -from the committed blob at the ref's recorded head, the whole file -when the claim is `whole`, verbatim to the byte. - -Each envelope section also serves its docs fence by fence, because a -hover that answers every paragraph with the whole section's claims is -answering a question nobody asked. `fences` is the ordered list of -the section's rendered docs-family fences, each `{lines, markdown, -sources}`, where `sources` resolves the fence's own position against -EVERY attribution entry whose range contains it, not just the -section it sits in, ordered by span length ascending, then path, -then start line, so the narrowest claim about the prose under the -cursor always speaks first. Each source carries the entry's files -with their excerpts, exactly as section files do. The section's -`markdown` remains the fences rendered in order, so a consumer that -wants the page renders `markdown` and a consumer that wants -granularity walks `fences`; the two are the same bytes in the same -order by construction. - -Rendered markdown is CommonMark plus GFM, nothing else: no JSX, no -raw HTML, no directive syntax survives into it. A docs fence body -passes through as the prose it already is. A note or warning directive -renders as a GFM alert blockquote, `> [!NOTE]` or `> [!WARNING]`. An -example directive renders as a fenced code block whose info string is -the example's lang plus `title=""`, and the fence run is one -backtick longer than the longest backtick run inside the body, so an -example that itself contains fences nests without truncation. -Guarantees stay data: the envelope serves cases, never a rendered -table, because tables want anchors and interaction and those belong to -whatever renders the envelope, not to the language. - ::: test -name: the docs envelope is byte-stable and canonically shaped -given: { workspace: fixture_two_modules, invocation: "q . --output=markdown", runs: 2 } -expect: { bytes_equal: true, top_level_keys: [docsVersion, head, modules], docs_version: 1 } +name: a path no module owns is a usage error naming the owned globs +given: { workspace: fixture_two_modules, add_paths: [unowned_file] } +expect: { exit_code: 2, error_names_owned_globs: true, staged_records: 0 } ::: ::: test -name: the expression selects which modules the envelope renders -given: { workspace: fixture_two_modules, invocations: ["q '.modules[0]' --output=markdown", "q '.modules' --output=markdown"] } -expect: { module_counts: [1, 2], exit_codes: [0, 0] } +name: a pure line-shift edit transports the whole map and needs no attribution flag +given: { workspace: fixture_two_modules, working_tree_edit: insert_lines_above_attributed_range, add: module_form_without_attribution } +expect: { add_outcome: staged, map_transported: true, ranges_shifted: true, attribution_flag_needed: false } ::: ::: test -name: a scalar expression result refuses markdown output naming the accepted shapes -given: { workspace: fixture_two_modules, expr: ".head", flags: ["--output=markdown"] } -expect: { exit_code: 2, error_names_shapes: [document, module, module_array] } +name: a claim in a rewritten region refuses listing that claim with its observed bound +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } +expect: { outcome: invalid_candidate, listed_claims: 1, observed_bound_present: true, untouched_claims_not_listed: true } ::: ::: test -name: markdown output refuses stdin and file inputs -given: { stdin: "{}", invocations: ["q . --output=markdown -", "q . --output=markdown some.json"] } -expect: { exit_codes: [2, 2] } +name: affirmations layer over the transported remainder and stage +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_affirmation_for_listed_claim } +expect: { add_outcome: staged, transported_claims_kept: true, affirmed_claim_recorded: true } ::: ::: test -name: envelope sections merge same-range entries and keep every provenance record -given: { workspace: fixture_overlapping_attribution } -expect: { same_range_entries_merged: true, files_deduped_by_path_and_lines: true, stale_when_any_constituent_stale: true, provenance_records_kept: all } +name: add stages a valid candidate and commit installs it +given: { workspace: fixture_two_modules, candidate: valid_with_attribution } +expect: { add_outcome: staged, commit_outcome: success } ::: ::: test -name: a contained case-only group joins its section carrying files and staleness -given: { workspace: fixture_with_directive_case } -expect: { case_only_group_joined: true, joined_cases_appended: true, joined_files_merged: true, joined_staleness_merged: true } +name: an explicit candidate whose transport is incomplete and unaffirmed fails loudly +given: { workspace: fixture_two_modules, candidate: rewritten_region_without_affirmation } +expect: { outcome: invalid_candidate, listed_claims_present: true } ::: ::: test -name: rendered markdown carries no jsx html or directive syntax -given: { workspace: fixture_two_modules, invocation: "q . --output=markdown" } -expect: { markdown_contains_jsx: false, markdown_contains_raw_html: false, markdown_contains_directive_fences: false } +name: a candidate with a parent-escaping path is rejected as invalid-candidate +given: { workspace: fixture_two_modules, candidate: contains_dotdot_path } +expect: { outcome: invalid_candidate, published_semantic_state: false } ::: ::: test -name: an example containing fences renders inside a longer fence run -given: { docs_body_with: "an example whose body holds a three-backtick fence" } -expect: { outer_fence_run: 4, body_verbatim: true } +name: tampering a staged blob after ingestion never mixes into the install +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, tamper: candidate_file_after_ingest } +expect: { outcome_in: [installed_from_immutable_blobs, store_integrity], mixed_content: false } ::: +`napl commit --staged ` is the one guarded +transaction over what add staged. Pipeline: verify the staged record +(recompute sliceHash, inputKey, baseStateKey at the recorded bases, +reject mismatches, enforce evidence bounds); extract and record the +surface; derive the complete transition (three-part footprint, member-set +delta, shared projections recomputed from the merged head); stage all +materialization outside any lock; then one bounded critical section under +the pointer lock performing only the footprint recheck and the pointer +and directory swaps, bounded by the transition's file count, never +hashing content inside the lock. + ::: test -name: a file ref excerpt is the committed span byte for byte -given: { workspace: fixture_two_modules } -expect: { excerpt_equals_committed_blob_span: true, whole_claim_serves_whole_file: true } +name: a crash between staged materialization and pointer swap leaves a deterministic recovery plan +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, crash_after: staging } +expect: { store_readable: true, recovery_plan_deterministic: true, semantic_state_unchanged: true } ::: ::: test -name: a fence resolves every containing entry narrowest first -given: { workspace: fixture_overlapping_attribution } -expect: { fence_sources_ordered_by: [span_asc, path, start], narrowest_first: true, includes_entries_beyond_owning_section: true } +name: a receipt whose recomputed slice hash disagrees is rejected as invalid-receipt +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: tampered_slice_hash } +expect: { outcome: invalid_receipt, published_semantic_state: false } ::: +The decision table, judged per arriving transition against the current +head: identities reproduce with head unmoved installs; head moved with +identities reproducing and footprints disjoint rebases then installs +(projections re-derived, never reused); inputKey failure returns +`stale-input`; baseStateKey failure returns `base-output-changed`; semantic +write overlap returns `write-conflict`; lock contention returns +`lock-contended`. Terminal outcomes, never retried unchanged: +`invalid-candidate`, `invalid-receipt`, `mapping-integrity`, +`resource-limit`, `unsupported-version`, `store-integrity`, `io-error`. +Every outcome is a canonical machine-readable JSON payload naming whether +blobs or provenance were published; no outcome publishes semantic state +partially. Exit codes: 0 success, 10 through 13 for the four returned +concurrency outcomes in table order, 20 through 26 for the seven terminal +outcomes in the order above. Staged records carry `receiptVersion`, the +per-module identities generated against, base snapshot hashes, the +candidate tree hash, the output manifest, then optional reported fields; +the compiler recomputes everything derivable and rejects on mismatch; +absent telemetry is `unavailable`, never zero. `napl notes add --to +` attaches later provenance without touching semantic state. + ::: test -name: fence markdown concatenated in order equals the section markdown -given: { workspace: fixture_two_modules } -expect: { fences_concat_equals_section_markdown: true } +name: commit installs a valid candidate against an unmoved head +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, head: unmoved } +expect: { outcome: installed, head_advanced: true, surface_recorded: true } ::: ::: test -name: an example fence info string carries its lang before its title -given: { workspace: fixture_two_modules } -expect: { info_string_format: " title=\"\"", lang_present_when_declared: true } +name: a bare commit installs every staged record in wave order +given: { workspace: fixture_two_modules, staged: [record_for_leaf, record_for_consumer], commit_args: none } +expect: { outcomes: [success, success], order: wave_order, hand_sequencing_needed: false } ::: -## napl prompt - -`napl prompt` (alias `napl p`) is the prompt builder: it reads refs from -stdin — the objects a `napl q` stage selected — and emits markdown built -from committed state at the refs' snapshot, never current head, and -never the working tree except under the explicit `--working-tree` mode -defined in the working-tree reads section. Stdin is typed-ref JSONL: one JSON object per line -carrying `head`, `lines`, and exactly one of `path` or `promptPath`; -unknown extra fields are ignored. A line that is not an object, carries -both path kinds or neither, or has a non-integer, zero, reversed, or -out-of-range line range is an error naming the input line number, never a -silent skip. Empty stdin is an error saying nothing was selected. Refs -naming more than one head in a single invocation error. Duplicate refs -(same head, path, range) emit once; input order is otherwise preserved. - -Materialization reads the snapshot's content-addressed blobs; committed -prompts and committed module outputs are the only eligible paths. A -`{path, lines}` ref resolves to its owning module through the snapshot's -output manifests — owned globs are disjoint by commit invariant, so -ownership is unique, and a discovered overlap is a store-integrity -failure, not a guess. A prompt ref yields specification prose; a file ref -yields code: one resolver, both directions. Every chunk gets a provenance -header naming `path:start-end` plus the module and note when known. -Cases render after their section's prose as canonical JSON code blocks -inside dynamically sized fences, immune to backticks in values. - -Law closure by default: the `law: true` sections of every module a ref -touched are appended once, deduplicated, ordered by module then range, -under one trailing header; `--no-laws` opts out. Refs into a module whose -attribution is stale refuse by default with that module's fix directive; -`--allow-stale` proceeds and stamps `stale` into the affected provenance -headers. Unresolvable refs (absent path, unavailable snapshot) error -naming the ref's input line. - ::: test -name: prompt materializes a prompt ref with a provenance header -given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf } -expect: { output_contains_prose: true, header_format: "path:start-end" } +name: bare commit re-derives and lands records for modules sharing one crate +given: { workspace: fixture_shared_crate_two_modules, staged: [record_for_first, record_for_second], commit_args: none } +expect: { outcomes: [success, success], retried_after_head_move: 1, hand_sequencing_needed: false } ::: ::: test -name: prompt materializes a generated-file ref symmetrically -given: { workspace: fixture_two_modules, stdin: file_ref_for_leaf_output } -expect: { output_contains_code: true } +name: commit sweeps the record it installed +given: { workspace: fixture_two_modules, staged: [record_for_leaf], commit: bare_then_bare_again } +expect: { first_outcomes: [success], second_outcomes: [], reattempted_history: 0 } ::: ::: test -name: prompt appends law sections by default and omits them under no-laws -given: { workspace: fixture_with_law_section, stdin: section_ref_non_law } -expect: { laws_appended_default: true, laws_with_flag: false } +name: a bare commit with nothing staged names the drifted modules and their add commands +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, staged: none, commit_args: none } +expect: { installed: 0, nothing_staged_named: true, drifted_modules_named: [consumer], add_command_named: true } ::: ::: test -name: prompt refuses refs into a stale module by default -given: { workspace: fixture_with_directive_case, attribution: stale, stdin: section_ref } -expect: { exit_nonzero: true, directive_present: true, allow_stale_overrides: true } +name: a per-side transport refusal serves the surviving side's transported range +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_prompt_section_only, add: module_form_with_check } +expect: { prompt_side: refused, code_side: transported, transported_code_range_served: true, affirmation_needed_sides: [prompt] } ::: ::: test -name: prompt errors on a malformed stdin line naming its line number -given: { stdin_lines: [valid_ref, "not json"] } -expect: { exit_nonzero: true, error_names_line: 2 } +name: check enumerates the parse gate and refuses a file that does not parse +given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, add: module_form_with_check } +expect: { validations_enumerated: true, parse_gate_listed: true, outcome: invalid_candidate, error_names_file_and_line: true, staged_records: 0 } ::: ::: test -name: prompt errors on empty stdin -given: { stdin: "" } -expect: { exit_nonzero: true, error: nothing_selected } +name: add refuses a candidate whose source does not parse naming file and line +given: { workspace: fixture_two_modules, candidate: syntactically_invalid_rust } +expect: { outcome: invalid_candidate, error_names_file_and_line: true, published_semantic_state: false } ::: ::: test -name: prompt deduplicates identical refs and preserves input order -given: { stdin_lines: [ref_a, ref_b, ref_a] } -expect: { chunks: 2, order: [ref_a, ref_b] } +name: commit recomputation refuses staged bytes that do not parse +given: { workspace: fixture_two_modules, staged: record_with_tampered_unparseable_blob } +expect: { outcome_in: [invalid_candidate, store_integrity], installed: false } ::: ::: test -name: "prompt resolves refs against their snapshot, not current head" -given: { workspace: fixture_two_modules, ref_head: previous_snapshot, edits_after: leaf_recommitted } -expect: { materialized_bytes: previous_snapshot_bytes } +name: a bad attribution file answers with the complete map contract and example +given: { workspace: fixture_two_modules, attribution_file: empty_object, add: module_form_with_attribution } +expect: { error_lists_all_fields: true, error_carries_valid_example: true } ::: -## Working-tree reads, byte-exact retrieval, and pre-add validation - -Three reads exist so that a tool amending a prompt can read exactly what -it is amending, retrieve the exact bytes it is about to splice, and -validate the result before anything stages, without a single raw file -read. All three are pure reads: no store writes, no locks, no clock, no -staging. Each is a pure function of the committed state plus the on-disk -prompt bytes it explicitly names, and byte-stable across runs over the -same inputs. - -`napl q --working-tree` builds the canonical document with every -prompt-derived fact enumerated from the on-disk prompt files instead of -the committed blobs: frontmatter, sections, `::: test` directive cases, -and body line ranges all reflect the pending working-tree prompt, -including sections and cases that exist only in the working tree. -Store-derived facts are unchanged and still committed: `head`, module -states, history, receipts. The top level carries `workingTree: true`, -and each section carries `workingTree: true` in place of a committed -`head` value, so a working-tree ref can never be mistaken for a -committed one and no hash is ever invented for uncommitted bytes. -Section file associations are the committed map's claims transported -through the prompt's committed-to-pending diff, the same deterministic -transport `add` uses: an association whose prompt range transports -cleanly is served intact, and a section whose range lands in a -rewritten or ambiguous region serves `files: []` with -`associationUnproven: true`. The view never re-associates files by -range arithmetic over the re-anchored body; an overlap that happens to -exist in the pending text is not a claim, and serving it as one would -be inventing a mapping nobody made. -Section-to-file association under a stale attribution behaves exactly as -today: sections carry their cases, `associationStale: true` marks the -pairing untrustworthy, and nothing pretends freshness. Reference -staleness fields serve exactly as in the committed document. - -`napl prompt --working-tree` resolves `promptPath` refs against the -on-disk prompt file instead of a committed blob. A working-tree ref -carries `workingTree: true` and no `head`; a ref carrying both, or a mix -of working-tree and committed refs in one invocation, is a usage error -naming the input line. Everything else about materialization holds: -provenance headers, law closure from committed state, case rendering, -error shapes. - -`napl prompt --bytes` is byte-exact retrieval, the inverse of the edit a -tool is about to make: the ref's line range materializes as the exact -source bytes of that range, no reflow, no case rendering, no law -closure, no provenance header, one trailing newline. It accepts exactly -one ref; more than one is a usage error. It composes with -`--working-tree` so a splice can be computed against the pending file. -The bytes returned are byte-identical to the named lines of the source, -so retrieve-then-splice is deterministic by construction. - -`napl check ` is dry validation of an on-disk prompt file -before `add`: frontmatter shape and module-name rules per the napl -contract, body normalization, directive grammar for every `::: test` -block including canonical JSON in `given` and `expect`, and case-name -uniqueness. The module-name rules are enforced as name grammar, not -merely as YAML: a frontmatter value that parses as a string but can -never be a legal module name (path-metacharacters, a bare `[`, empty -after trimming) is a finding at check time, never a surprise later. -Findings are canonical JSON on stdout, each naming the file, -the line, and what failed; every reported line is the physical -1-based line in the file as an editor counts it, frontmatter -included, never a body-normalized offset. Exit codes are pinned: 0 for a valid prompt, -1 when findings exist, 2 for usage including an unreadable path. The -store is never touched and nothing stages. `check` passing means the -prompt parses under the same reader `add` uses; it does not predict -`add` outcomes that depend on candidates, attribution, or placement, and -its wording never implies otherwise. `check` teaches while it refuses: -the finding for a missing opening frontmatter fence carries one -complete minimal valid prompt inline, so the first error a cold user -sees contains the whole grammar they were missing; and a checked file -whose name lacks the `.napl` extension gets a finding saying discovery -will never find it, naming the extension, whatever else is valid about -its content. - ::: test -name: q working-tree enumerates a section that exists only in the working tree -given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } -expect: { new_section_enumerated: true, top_level_working_tree: true, committed_document_unchanged: true } +name: the plan labels projections and names a legal first output +given: { workspace: fresh_init_root_prompt, flag: module_root } +expect: { projection_labeled_tool_derived: true, legal_first_output_named: true } ::: ::: test -name: working-tree sections carry no committed head and mark themselves -given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, flags: [--working-tree] } -expect: { section_working_tree: true, section_head_absent: true } +name: gc apply sweeps staged records and touches nothing reachable +given: { workspace: fixture_two_modules, staged: [stale_record], gc: apply } +expect: { staging_empty: true, reachable_objects_untouched: true, store_healthy: true } ::: ::: test -name: the working-tree document is byte-stable across runs -given: { workspace: fixture_two_modules, working_tree_edit: leaf_prompt_gains_section, runs: 2 } -expect: { bytes_equal: true } +name: commit against a moved head with disjoint footprints rebases and installs +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: to_unrelated_module } +expect: { outcome: installed, rebased: true } ::: ::: test -name: a transported section keeps its files and a rewritten one serves none unproven -given: { workspace: fixture_two_modules, working_tree_edit: shift_one_section_rewrite_another, flags: [--working-tree] } -expect: { shifted_section_files_intact: true, rewritten_section_files: [], rewritten_section_association_unproven: true, overlap_reassociation: 0 } +name: commit whose direct dep surface moved underneath returns stale-input +given: { workspace: fixture_chain_three, candidate: valid_for_middle, intervening_commit: leaf_surface_change } +expect: { outcome: stale_input, head_advanced: false } ::: ::: test -name: prompt bytes round-trips the exact source bytes of a range -given: { workspace: fixture_two_modules, stdin: section_ref_for_leaf, flags: [--bytes] } -expect: { bytes_equal_raw_slice: true, reflow: false, headers: false } +name: commit whose own outputs moved underneath returns base-output-changed +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: same_module_other_candidate } +expect: { outcome: base_output_changed } ::: ::: test -name: prompt bytes refuses more than one ref as a usage error -given: { stdin_lines: [ref_a, ref_b], flags: [--bytes] } -expect: { exit_code: 2, error_names_line: 2 } +name: two concurrent disjoint commits both land and neither touches a manifest +given: { workspace: fixture_two_modules, concurrent_candidates: [module_a, module_b], orders: [ab, ba] } +expect: { both_installed: true, manifest_bytes_unchanged: true } ::: ::: test -name: a ref carrying both a head and working-tree is a usage error naming its line -given: { stdin_lines: [ref_with_head_and_working_tree], flags: [--working-tree] } -expect: { exit_nonzero: true, error_names_line: 1 } +name: "absent telemetry reads as unavailable, never zero" +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: without_usage } +expect: { usage: unavailable } ::: +Attribution rides the staged record; there is no other write path for it. +`napl add --attribution ` carries the module's mapping of prompt body +line ranges to generated file line ranges with notes; add validates every +claim before staging: each file path inside the module's owned globs, +each code line range present in the candidate tree, each prompt line +range present in the current normalized prompt body; a mapping that lies +is `invalid-candidate`. An entry may own no file at all: contract-level +prose — a law — that governs the module rather than any single path; +validation accepts a file-less entry (its prompt range must still exist) +and the query layer serves it flagged as law. An add whose transport is +incomplete and whose affirmations do not cover the listed claims fails +loudly as `invalid-candidate`, naming exactly which claims are missing; +`napl add --no-verify` is the explicit escape hatch that stages anyway +and records the module's attribution as stale on install. Any prompt +edit, code transition, or move that changes what a recorded mapping +points at marks it stale the same way; nothing ever repairs a mapping +automatically. `napl status` reports attribution freshness per +module (`fresh`, `stale`, `absent`) and, for stale or absent, emits a +machine-readable directive naming the exact fix: commit with a fresh +attribution for that module. Relocation recomputes only the path prefix +of a mapping, never its line content. The lsp serves a stale mapping +marked stale rather than hiding it; absent answers empty. + +An attribution entry is a reference, and a reference has a direction: +prompt-to-code, a prompt body line range naming generated file line +ranges, or code-to-prompt, a generated file line range naming a prompt +body line range. Every entry recorded before directions existed reads as +prompt-to-code; migration is a reading, never a rewrite of stored +records. Validation is identical for both directions and referential +only, the same claims checked the same way, and nothing about a +direction implies the sentence describes the code: a reference is a +claim with provenance and freshness, and the record vocabulary never +says more. + +A reference carries a provenance block, recorded by the compiler at +staging time from what the caller supplied: recording model identity, +timestamp, tool version, and an optional usage-envelope reference +through the existing evidence seam. Every absent field is `unavailable`, +never zero, never invented, and provenance is display and pricing +metadata that never enters any identity, exactly as telemetry already +behaves. The query layer and the lsp serve the provenance block with the +entry. + +Staleness covers both ends of a reference, and working-tree drift is one +of the ways an end changes: a reference whose code side no longer +matches the committed bytes, or whose prompt side changed in the working +tree, reads stale on both endpoints, computed at read time from the +records and the working tree, never stored as a transition. A hand edit +to generated source is drift like any other: legal, visible through the +same status and diff machinery, healable by restore or by re-recording +the reference; nothing repairs a reference automatically and nothing +polices the edit that staled it. + ::: test -name: check passes a valid prompt with exit zero and no findings -given: { workspace: fixture_two_modules, prompt: leaf_prompt_valid } -expect: { exit_code: 0, findings: 0, store_bytes_unchanged: true } +name: a code-to-prompt entry stages and is served with its direction +given: { workspace: fixture_two_modules, candidate: valid_with_code_to_prompt_reference } +expect: { add_outcome: staged, served_direction: code_to_prompt } ::: ::: test -name: check reports a malformed directive with a finding naming its line -given: { workspace: fixture_two_modules, prompt: leaf_prompt_broken_directive } -expect: { exit_code: 1, finding_names_line: true, store_bytes_unchanged: true } +name: entries recorded before directions read as prompt-to-code unchanged +given: { workspace: fixture_two_modules, history: [pre_direction_attribution] } +expect: { served_direction: prompt_to_code, stored_records_rewritten: false } ::: ::: test -name: the missing-fence finding carries a complete minimal prompt that itself passes check -given: { workspace: fixture_two_modules, prompt: file_without_frontmatter } -expect: { exit_code: 1, finding_example_passes_check: true } +name: provenance is served with the entry and absent fields read unavailable +given: { workspace: fixture_two_modules, candidate: reference_without_usage_envelope } +expect: { provenance_served: true, usage: unavailable, cost_component: unavailable } ::: ::: test -name: a checked file without the napl extension is told discovery will never find it -given: { workspace: fixture_two_modules, prompt: valid_content_named_greeting_md } -expect: { exit_code: 1, finding_names_extension: ".napl", store_bytes_unchanged: true } +name: a hand edit to the code side reads stale on both endpoints +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } +expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } ::: ::: test -name: a module name that yaml accepts but the grammar forbids is a finding -given: { workspace: fixture_two_modules, prompt: frontmatter_module_bare_bracket } -expect: { exit_code: 1, finding_names_module_grammar: true } +name: a working-tree prompt edit reads stale on both endpoints +given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched } +expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } ::: ::: test -name: findings report physical file lines as an editor counts them -given: { workspace: fixture_two_modules, prompt: empty_module_value_on_file_line_2 } -expect: { finding_line: 2, line_kind: physical_one_based } +name: a code-to-prompt reference naming a missing prompt range is invalid-candidate +given: { workspace: fixture_two_modules, candidate: code_to_prompt_reference_to_missing_range } +expect: { outcome: invalid_candidate, published_semantic_state: false } ::: -napl-store module-state v1 638 -{"attribution":{"promptHash":"1d93e039abeb6a0ff6dfcbc1a0b7cf5f034a0d040115ea5d09ae1a89051eb978","record":"8febd0b5b5494fd230183698d8b54851cad9cd4b608607440d6f3c63a8ef9c85","stale":false},"contractVersion":4,"inputKey":"232fea965cd8d68474d3a0a8e32a464311f4446f6b02fdd01616fdc5b1fb52b6","module":"commands","outputTree":"d83d2467beb91b948fff71e8ad6912f421053f3c456f4ef0d652253c7a1b8ce7","prompt":"36cb6c034405714880e39433d450c6ee1890fe9bad5e062eae3b704ac17adee0","sliceHash":"d162c1bcd1ea33118b1efe8ff25c2655e1ebd43c915714178b669e63e60a3cbf","surfaceHash":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust"} -napl-store blob v1 5346 -//! The portable path grammar and its collision-detecting normalization. -//! -//! Every path the compiler records is workspace-relative, `/`-separated, and -//! drawn from a portable segment grammar. Two spellings that normalize to the -//! same key are rejected rather than silently merged, so a workspace can never -//! mean different things on a case-sensitive and a case-folding filesystem. - -use crate::error::{error, CompilerError, OutcomeCode, Result}; - -/// Longest permitted single path segment. -pub const MAX_SEGMENT_LEN: usize = 128; - -/// Longest permitted canonical path. -pub const MAX_PATH_LEN: usize = 1024; - -/// Validate a workspace-relative path against the portable grammar. -/// -/// Rejects absolute paths, backslashes, empty / `.` / `..` segments, segments -/// outside `[A-Za-z0-9._-]`, segments that do not start alphanumeric, and -/// anything over the length bounds. -pub fn check_canonical_path(path: &str, code: OutcomeCode) -> Result<()> { - let reject = |why: &str| -> CompilerError { error(code, format!("path {path:?} rejected: {why}")) }; - if path.is_empty() { - return Err(reject("empty")); - } - if path.len() > MAX_PATH_LEN { - return Err(reject("exceeds maximum path length")); - } - if path.starts_with('/') { - return Err(reject("absolute")); - } - if path.contains('\\') { - return Err(reject("contains a backslash")); - } - if path.contains('\0') { - return Err(reject("contains a NUL byte")); - } - for segment in path.split('/') { - if segment.is_empty() { - return Err(reject("contains an empty segment")); - } - if segment == "." || segment == ".." { - return Err(reject("contains a relative segment")); - } - if segment.len() > MAX_SEGMENT_LEN { - return Err(reject("segment exceeds maximum length")); - } - let first = segment.chars().next().expect("segment is non-empty"); - if !first.is_ascii_alphanumeric() { - return Err(reject("segment does not start with an ASCII alphanumeric")); - } - for c in segment.chars() { - if !(c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') { - return Err(reject("segment uses a character outside the portable grammar")); - } - } - } - Ok(()) -} - -/// The collision key for a canonical path: ASCII case folded. -pub fn normalize_path_key(path: &str) -> String { - path.to_ascii_lowercase() -} - -/// True when `path` is inside the directory `prefix`, or is it. -/// -/// The empty prefix is the workspace root itself, exactly as a prompt at the -/// workspace root means the workspace root is the crate directory. Every -/// workspace-relative path is inside it, so containment against the empty root -/// is total rather than vacuously false. -pub fn is_under(path: &str, prefix: &str) -> bool { - if prefix.is_empty() { - return true; - } - path == prefix || path.starts_with(&format!("{prefix}/")) -} - -/// Join a placement root with a workspace-relative remainder. -/// -/// The empty root is the workspace root, so the join is the remainder itself: -/// it never fabricates a leading separator that nothing could match. -pub fn join_root(root: &str, rest: &str) -> String { - if root.is_empty() { - return rest.to_string(); - } - if rest.is_empty() { - return root.to_string(); - } - format!("{root}/{rest}") -} - -/// True for the bytes an encoded segment may carry unchanged. -fn is_segment_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'_' || byte == b'-' -} - -/// Encode an unrestricted name as one path segment, injectively and reversibly. -/// -/// A module name is any non-empty single-line scalar, so it is never used raw -/// where a record path or a store key needs a segment. Every byte outside -/// `[A-Za-z0-9._-]` becomes `%XX` with uppercase hex, and a name that is exactly -/// `.` or `..` has its dots encoded as well. An encoded segment is therefore -/// never `/`, `\`, empty, `.`, or `..`, and can neither traverse nor alias a -/// directory. A name already drawn from the safe subset, and not `.` or `..`, -/// encodes to itself, so `todo-app` stays `todo-app` on disk. -pub fn encode_name(name: &str) -> String { - if name == "." || name == ".." { - return name.chars().map(|_| "%2E").collect(); - } - let mut out = String::with_capacity(name.len()); - for byte in name.as_bytes() { - if is_segment_byte(*byte) { - out.push(char::from(*byte)); - } else { - out.push_str(&format!("%{byte:02X}")); - } - } - out -} - -/// Recover the exact name an encoded segment was produced from. -/// -/// `None` is a segment this encoding could not have produced: a truncated or -/// non-hex escape, or bytes that are not valid UTF-8 once decoded. -pub fn decode_name(segment: &str) -> Option { - let bytes = segment.as_bytes(); - let mut out: Vec = Vec::with_capacity(bytes.len()); - let mut index = 0usize; - while index < bytes.len() { - if bytes[index] == b'%' { - let hex = segment.get(index + 1..index + 3)?; - out.push(u8::from_str_radix(hex, 16).ok()?); - index += 3; - } else { - out.push(bytes[index]); - index += 1; - } - } - String::from_utf8(out).ok() -} -napl-store receipt v1 1151 -{"baseStateKey":"7aa7e0b4e8cc02142a8aa14ca243a5f2058d72de2f65d7ddb7c59e3d201b692a","baseStoreSnapshot":"92d354f0a491ff544847ab42387fe54aa459f84daf67ae1b4ae57e4218aa7b7c","baseWorkspaceHash":"dd68f36b9cc9e4de689998dc3ea4e0a6f76e772fbbffa3e4e8dcf834400c5c64","candidateTreeHash":"562e142c3a950b916ff1d8cebfdd0ab3a63fb61ebb42d766e6cd8215190b007f","contractVersion":4,"inputKey":"9206162a79eefbb9bbbad00daffa25e4dcbca3fb7db9b908b65fa2829a272f7e","module":"query","outputManifest":[{"hash":"deff13703dd0b3c630077edaffafc1fa9f2fdfde297b9a84b45841a428715965","path":"crates/napl/src/check.rs"},{"hash":"6c743bf47b6f7c61be2d365b61f7143d7d1045e5f683095cce63010c64586d56","path":"crates/napl/src/docs.rs"},{"hash":"70f66c8e4c3cd38f3cda7cbf81fa3dfb8090b766f637732677c0ee9c246e3420","path":"crates/napl/src/document.rs"},{"hash":"5ec2c2137f45d02c8e8bf78ecf0652966850772097b41f3835a99116f3b78f57","path":"crates/napl/src/promptbuild.rs"},{"hash":"9d897617acebf69cdb6e944d077bbaf2c0441a4bf5c26615d86b3e250b06b900","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"sliceHash":"476bdcd17f363b2f8386f1f6076495802c94de78870b20ef3dc5dc26ae7e69d1","target":"rust"} -napl-store snapshot v1 781 -{"contractVersion":4,"event":"cfa7aa5cab2183d0fb951afb38ebba6665713f8582d65c3741b185acc5d6ab31","states":[{"module":"agent","state":"cae150dea39f895fe547dc54b23789028aa44560d21f76af0c6b3c4c3af9b3d8","target":"rust"},{"module":"commands","state":"21964b817f6ebe4d1ff2a798b40b824dd21cac84149d6e06ab89fa9061f57655","target":"rust"},{"module":"napl","state":"51a52efb3c1d20e32af8c5097f4e5e4adfe186ccb7d16b3689b844dd1244cd76","target":"rust"},{"module":"napl_store","state":"7011890f658fb28bd29684802e5e53bb5d54d54d3c5a64cc926a56fae7ef944d","target":"rust"},{"module":"query","state":"d8753add6fb4489381d764e2d8abd5964e1eb8a5435669bfc1ac296d9739ffcf","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} -napl-store tree v1 132 -{"entries":[{"hash":"b36cfb9654d3094df47e47c45be1d8c4fdfd9f79d8a4b32ad08a73de9a19b41d","kind":"dir","mode":16384,"name":"crates"}]} -napl-store tree v1 129 -{"entries":[{"hash":"2fbb3667fe723242b634008a9291cde399784174501f60125158b0ed9f041e46","kind":"dir","mode":16384,"name":"src"}]} -napl-store blob v1 18463 -//! `napl remove`: the end of a module's tenancy, through the guarded door. -//! -//! Removal is the mirror of a commit and takes the same path: it derives -//! everything before any lock, then holds the module lock and the pointer lock -//! for one bounded critical section that publishes a snapshot without the -//! module's state and applies a fixed list of install steps. What it publishes -//! is an absence, so there is no candidate to ingest, no surface to extract, and -//! no receipt to reproduce; everything else is the commit path unchanged, -//! including which projections it may touch: exactly the leaving module's own -//! crate, never a crate whose states did not move. -//! -//! It removes tenancy and never history. The store is append-only: every prior -//! snapshot still names the module's states, and every read verb answers about -//! them exactly as it did before. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; - -use napl_store::store_hash::ObjectKind; -use napl_store::store_tree::{TreeChange, FILE_MODE}; -use napl_store::store_txn_core::{plan_install_steps, CommitMarker}; -use serde_json::{json, Value}; - -use crate::commit::{lock_key, recover, CommitReport}; -use crate::compile::{base_output_files, head_states}; -use crate::disk::{self, Store, POINTER_LOCK}; -use crate::error::{error, error_payload, OutcomeCode, Result}; -use crate::objects::{ - event_hash, event_json, ownership_write_key, read_module_state, snapshot_hash, snapshot_json, - state_write_key, transition_hash, transition_json, Footprint, ModuleState, Transition, -}; -use crate::store_view::{get_blob, has_object, ObjectSource}; -use crate::target::{builtin_target, is_manifest_path, projection_owned_paths, TargetDef}; -use crate::transaction::PendingObject; -use crate::workspace::{apply_step, discover_sources}; - -/// The transition kind a removal records, so history never confuses the end of a -/// tenancy with a commit that installed something. -pub const MODULE_REMOVED: &str = "module-removed"; - -/// End a module's tenancy: publish a snapshot without its state, delete the -/// files it owned, re-derive the entry projection that named it, and close its -/// attribution record. -pub fn remove_module(workspace: &Path, target_name: &str, module: &str) -> CommitReport { - match run(workspace, target_name, module) { - Ok(payload) => CommitReport { - outcome: OutcomeCode::Success, - payload, - }, - Err(err) => CommitReport { - outcome: err.code, - payload: error_payload(&err, false, false), - }, - } -} - -fn run(workspace: &Path, target_name: &str, module: &str) -> Result { - let target = builtin_target(target_name)?; - let store = disk::init(workspace)?; - recover(&store)?; - - let head = store.head()?; - let states = head_states(&store, head.as_deref())?; - let key = (target.name.clone(), module.to_string()); - let Some(state_key) = states.get(&key).cloned() else { - return Err(error( - OutcomeCode::MappingIntegrity, - format!( - "module {module:?} has no committed state for target {:?}, so there is no tenancy to end", - target.name - ), - )); - }; - - let dependents = dependents_of(&store, workspace, &states, &target, module)?; - if !dependents.is_empty() { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "module {module:?} is depended on by {}, and a removal never leaves a committed module pointing at an absence; remove the dependents first", - dependents.join(", ") - ), - )); - } - - let state = read_module_state(&store, &state_key)?; - let owned: BTreeMap = base_output_files(&store, &state)?.into_iter().collect(); - let drifted = drifted_paths(workspace, &owned)?; - if !drifted.is_empty() { - return Err(error( - OutcomeCode::BaseOutputChanged, - format!( - "module {module:?} has drifted from its committed state at {}, and what a removal removes is the committed module and never unseen edits", - drifted.join(", ") - ), - )); - } - - let leaving = crate_root(&owned, &target); - let plan = plan_changes(&store, &target, &leaving, module, &state, &owned, &states)?; - - let transition = Transition { - module: module.to_string(), - target: target.name.clone(), - input_key: state.input_key.clone().unwrap_or_default(), - base_state_key: state_key.clone(), - new_state: String::new(), - receipt: String::new(), - surface: String::new(), - footprint: Footprint { - read_set: vec![format!("base-state:{state_key}")], - write_set: vec![ - ownership_write_key(&leaving), - state_write_key(&target.name, module), - ], - derived_projections: plan.projections.clone(), - install_paths: plan.touched.clone(), - }, - kind: Some(MODULE_REMOVED.to_string()), - prompt: None, - }; - let transition_id = transition_hash(&transition); - publish_all( - &store, - &[json_pending( - ObjectKind::Transition, - &transition_json(&transition), - )], - &transition_id, - )?; - - let mut payload = install( - &store, - workspace, - &target, - module, - &state_key, - &transition_id, - &plan, - )?; - if let Some(repack) = crate::porcelain::repack_if_loose(&store)? { - payload["repack"] = repack; - } - Ok(payload) -} - -/// Every committed module that declares a dependency on the one leaving. -/// -/// The declaration that counts is the committed one, read off each state's own -/// prompt object, never the prompt file on disk: an uncommitted edit dropping a -/// `deps:` entry, or a deleted prompt file, would otherwise talk a removal past -/// the very refusal that protects the module still pointing at it. A state -/// recorded before prompt objects existed carries none, and only there does the -/// working tree answer, because committed truth is silent rather than wrong. -fn dependents_of( - store: &Store, - workspace: &Path, - states: &BTreeMap<(String, String), String>, - target: &TargetDef, - module: &str, -) -> Result> { - let sources = discover_sources(workspace)?; - let mut out: Vec = Vec::new(); - for ((state_target, tenant), state_key) in states { - if state_target != &target.name || tenant == module { - continue; - } - let state = read_module_state(store, state_key)?; - let declared = committed_deps(store, &state, tenant)? - .or_else(|| { - sources - .iter() - .find(|source| &source.module == tenant) - .map(|source| source.deps.clone()) - }) - .unwrap_or_default(); - if declared.iter().any(|dep| dep == module) { - out.push(tenant.clone()); - } - } - out.sort(); - out.dedup(); - Ok(out) -} - -/// The dependencies a committed state's own prompt declares, or `None` when the -/// state names no prompt object and the store therefore holds no answer. -fn committed_deps( - store: &Store, - state: &ModuleState, - module: &str, -) -> Result>> { - let Some(object) = &state.prompt else { - return Ok(None); - }; - if !has_object(store, object) { - return Ok(None); - } - let text = crate::staging::committed_prompt(store, object, module)?; - let parsed = crate::source::parse_source(&format!("{module}.napl"), &text)?; - Ok(Some(parsed.deps)) -} - -/// The owned paths whose working-tree bytes no longer match what was committed. -fn drifted_paths(workspace: &Path, owned: &BTreeMap) -> Result> { - let mut out: Vec = Vec::new(); - for (path, blob) in owned { - let bytes = std::fs::read(workspace.join(path)).ok(); - if crate::attribution::code_drifted(Some(blob.as_str()), bytes.as_deref()) { - out.push(path.clone()); - } - } - Ok(out) -} - -/// The crate root the removed module's files sat under, read off the bytes -/// themselves through the target's own source directory. -fn crate_root(owned: &BTreeMap, target: &TargetDef) -> String { - let marker = format!("/{}/", target.source_dir); - owned - .keys() - .filter_map(|path| path.split_once(&marker).map(|(root, _)| root.to_string())) - .next() - .unwrap_or_default() -} -/// Everything the removal will write, derived before any lock is taken. -struct RemovalPlan { - objects: Vec, - changes: Vec, - /// Every path the install touches, deletions included, for the footprint and - /// the payload to report without either restating the other. - touched: Vec, - /// The projections this removal re-derives: the leaving crate's entry file, - /// and nothing belonging to a crate whose states did not move. - projections: Vec, - /// The crate the removal empties, present only when nothing is left in it. - vacated: Option, -} +## edit and write: the capture verbs -/// Derive the whole removal: what leaves, what the leaving crate's entry file -/// becomes, and what the user is left holding. -fn plan_changes( - store: &Store, - target: &TargetDef, - leaving: &str, - module: &str, - state: &ModuleState, - owned: &BTreeMap, - states: &BTreeMap<(String, String), String>, -) -> Result { - let projections = projection_owned_paths(target, leaving); - let mut changes: Vec = Vec::new(); - for (path, hash) in owned { - if projections.iter().any(|p| p == path) || is_manifest_path(target, path) { - continue; - } - changes.push(TreeChange::Removed { - path: path.clone(), - hash: hash.clone(), - mode: FILE_MODE, - }); - } +`napl edit` and `napl write` are the deterministic capture layer in +front of the same guarded door. They carry the familiar tool +signatures: `napl edit --old-string --new-string +[--replace-all]` replaces an exact occurrence in a file, and +`napl write ` writes stdin as the file's full content; its usage +line says the content comes from stdin, and an empty stdin refuses as +a usage error naming `--empty`, the explicit flag that authorizes +writing a zero-byte file, so a forgotten pipe can never silently +truncate. Either +side is editable, a prompt or a generated file, by a human or an LLM; +a hand edit through an ordinary editor stays equally legal, and the +verbs add capture, never exclusivity. The verbs never synthesize or +rewrite prose beyond the bytes given, never invoke a model, never +decide placement, never repair a reference, and never bypass the +guard: `add` and `commit` remain exactly what they are. - let mut objects: Vec = Vec::new(); - let mut derived: Vec = Vec::new(); - let mut survivors = states.clone(); - survivors.remove(&(target.name.clone(), module.to_string())); - let (path, bytes) = re_derive(store, target, leaving, &survivors)?; - let vacated = if bytes.is_empty() { - changes.push(TreeChange::Removed { - path: path.clone(), - hash: String::new(), - mode: FILE_MODE, - }); - Some(leaving.to_string()) - } else { - objects.push(blob_pending(&bytes)); - changes.push(TreeChange::Added { - path: path.clone(), - hash: napl_store::store_hash::object_name(ObjectKind::Blob, &bytes), - mode: FILE_MODE, - }); - None - }; - derived.push(path); +Each call, deterministically and in this order: validate, apply, +capture. When the target is a prompt file, the result of the edit is +validated first by the same reader `check` and `add` use; a result +that fails validation refuses as `prompt-invalid` and applies nothing. +An `--old-string` that matches nowhere, matches more than once +without `--replace-all`, or equals `--new-string` refuses as +`edit-mismatch` and applies nothing, so a no-op edit can never mint a +record. Only a call that will succeed touches the working tree, so +every refusal leaves every file byte-identical. - if state.attribution.is_some() { - changes.push(TreeChange::Removed { - path: crate::attribution::view_path(module), - hash: String::new(), - mode: FILE_MODE, - }); - } +A call may carry one reference: `--ref `, a single object naming +a direction and both endpoints, prompt body line range and generated +file line range, in either direction per the reference contract above. +The contract is taught, never reverse engineered: the verbs' usage +lines and flag meanings name the object's fields, and the FIRST +validation failure of a ref payload answers with the complete +contract in one refusal, every field, the closed list of valid +`direction` values, and one complete valid example, never one missing +field per attempt. +The reference is validated referentially only, exactly as strong as +attribution validation and no stronger: the file path inside the +owning module's placement, both line ranges present in the +post-edit bytes of their files. A reference naming a missing line or +a path outside placement refuses as `reference-unresolvable` and +applies nothing. A valid reference-bearing call stages a +reference-only record and commits it through the guarded transaction +in the same invocation: the transition carries the reference entry and +its provenance, no output-tree change and no surface change, and every +rule of the decision table, journaled atomicity, and recovery applies +to it unchanged. The success payload carries the recorded reference +object verbatim and its transition hash, and says plainly that the +edited file remains working-tree drift until its module is committed: +recording a reference and advancing the committed baseline are +different acts, and the payload spells the sequence that performs the +second, `napl add` for the module then `napl commit`, so nobody reads +`recorded: true` as committed and nobody feeds a bare commit nothing. +A plain apply teaches the same way: a written or edited file is +working-tree drift until staged and committed, and the success +payload of both verbs names both verbs in order. `napl show ` lists the +transition's reference entries inline exactly as it lists notes. A +call without `--ref` is a plain edit: apply, no +record, no store touch. Any existing reference whose endpoint the edit +moved needs no marking, because both-ends staleness is already +computed at read time from the working tree; the verbs store nothing +about it. - let mut touched: Vec = changes.iter().map(|c| change_path(c).to_string()).collect(); - touched.sort(); - touched.dedup(); +A reference the verb records is evidence, and evidence serves itself. +The record carries the content hash of each endpoint's post-edit +bytes, the file as the verb wrote it and the normalized prompt body it +validated against. Freshness reads those hashes first: a verb-recorded +reference whose endpoint bytes still match the working tree reads +fresh, and it goes stale only when the tree moves off the bytes the +record names, never merely because the committed baseline lags the +edit, so `recorded: true` is never followed by a stale count the verb +itself caused. At the next add, transport seeds from the same +evidence: a committed claim refusing in a rewritten region is affirmed +automatically when a reference transition recorded since the module's +committed output covers that region and its endpoint hashes match the +staged bytes, because the tool that applied the edit owns its diff; +the affirmation is the record, and no hand-authored map is ever +demanded for a range a capture verb already recorded. A recorded +reference whose hashes no longer match affirms nothing and is listed +in the refusal like any other claim, so the shortcut never guesses. - Ok(RemovalPlan { - objects, - changes, - touched, - projections: derived, - vacated, - }) -} +::: test +name: a verb-recorded reference reads fresh until the tree moves off its bytes +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: status_module } +expect: { stale_reference_count: 0, drift: true } +::: -/// The path a tree change names, regardless of its variant. -fn change_path(change: &TreeChange) -> &str { - match change { - TreeChange::Added { path, .. } => path, - TreeChange::Removed { path, .. } => path, - TreeChange::Modified { path, .. } => path, - } -} +::: test +name: add after a referenced edit self-affirms the rewritten region and stages without a map +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, add: module_form_without_attribution } +expect: { add_outcome: staged, affirmed_from_reference_record: true, attribution_flag_needed: false } +::: -#[allow(clippy::too_many_arguments)] -fn install( - store: &Store, - workspace: &Path, - target: &TargetDef, - module: &str, - state_key: &str, - transition_id: &str, - plan: &RemovalPlan, -) -> Result { - let module_lock = disk::take_lock(store, &lock_key(&target.name, module))?; - let pointer_lock = match disk::take_lock(store, POINTER_LOCK) { - Ok(guard) => guard, - Err(err) => { - disk::release_lock(module_lock)?; - return Err(err); - } - }; - let outcome = locked_remove( - store, - workspace, - target, - module, - state_key, - transition_id, - plan, - ); - disk::release_lock(pointer_lock)?; - disk::release_lock(module_lock)?; - outcome -} +::: test +name: a recorded reference whose bytes moved again affirms nothing +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: hand_edit_same_region, add: module_form_without_attribution } +expect: { outcome: invalid_candidate, listed_claims_present: true, affirmed_from_reference_record: false } +::: -#[allow(clippy::too_many_arguments)] -fn locked_remove( - store: &Store, - workspace: &Path, - target: &TargetDef, - module: &str, - state_key: &str, - transition_id: &str, - plan: &RemovalPlan, -) -> Result { - let head = store.head()?; - let mut states = head_states(store, head.as_deref())?; - let key = (target.name.clone(), module.to_string()); - if states.get(&key).map(String::as_str) != Some(state_key) { - return Err(error( - OutcomeCode::WriteConflict, - format!("module {module:?} moved under this removal; re-run `napl remove {module}`"), - )); - } - states.remove(&key); +Provenance rides the session window: `--session ` names the +usage-proxy session whose log lines are this call's evidence, and when +the flag is absent the verb reads the `NAPL_SESSION` environment +variable, so a call made under `napl agent` carries its window with no +extra argument. The recorded usage reference is the window itself, +media type, workspace-relative log path, session id, and the line span +observed at staging, priced lazily by `napl cost` at read time, never +aggregated at record time. Model identity and timestamp come from the +window's log lines, or read `unavailable` when there is no window; +the verbs read no clock, so the same call over the same working tree, +store state, and log bytes produces byte-identical records. Absent is +`unavailable`, never zero, never invented, and provenance never +enters identity. - let parents: Vec = match head.as_deref() { - Some(snapshot) => vec![crate::objects::snapshot_event(store, snapshot)?], - None => Vec::new(), - }; - let event = event_hash(transition_id, &parents); - let snapshot = snapshot_hash(&event, &states); +The three refusals join the closed terminal-outcome table under the +contract bump to 4, pinned exit codes continuing the table order: +`edit-mismatch` 27, `reference-unresolvable` 28, `prompt-invalid` 29. +None reuses `mapping-integrity`, and each is a canonical +machine-readable JSON payload naming what failed and that nothing was +applied or published. - let mut objects = vec![ - json_pending(ObjectKind::Event, &event_json(transition_id, &parents)), - json_pending(ObjectKind::Snapshot, &snapshot_json(&event, &states)), - ]; - objects.extend(plan.objects.iter().cloned()); - publish_all(store, &objects, transition_id)?; +::: test +name: an edit with a valid code-to-prompt reference stages and commits in one call +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } +expect: { outcome: installed, served_direction: code_to_prompt, output_tree_changed: false } +::: - let steps = plan_install_steps(&plan.changes); - let marker = CommitMarker { - domain: module.to_string(), - owner: transition_id.to_string(), - snapshot: snapshot.clone(), - steps: steps.clone(), - }; - disk::write_marker(store, &marker)?; - disk::compare_and_set_head(store, head.as_deref(), &snapshot)?; - for step in &steps { - apply_step(workspace, step, &|hash| get_blob(store, hash))?; - } - disk::delete_marker(store, &snapshot)?; +::: test +name: the same edit call twice produces byte-identical records +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, runs_from_same_base: 2 } +expect: { records_byte_identical: true } +::: - let deleted: Vec = plan - .changes - .iter() - .filter_map(|change| match change { - TreeChange::Removed { path, .. } => Some(path.clone()), - _ => None, - }) - .collect(); +::: test +name: a reference to a missing line refuses as reference-unresolvable and applies nothing +given: { workspace: fixture_two_modules, edit: ref_names_missing_line } +expect: { outcome: reference_unresolvable, exit_code: 28, files_byte_identical: true } +::: - Ok(json!({ - "blobsPublished": true, - "contractVersion": crate::version::CONTRACT_VERSION, - "deleted": deleted, - "derivedProjections": plan.projections, - "head": snapshot, - "historyRetained": { - "detail": "removal ends a tenancy and never history: every prior snapshot still names this module's states, and every read verb answers about them unchanged", - "lastState": state_key, - }, - "kind": MODULE_REMOVED, - "module": module, - "outcome": "success", - "provenancePublished": true, - "readyToDelete": ready_to_delete(workspace, module, plan), - "semanticStatePublished": true, - "target": target.name, - "transition": transition_id, - })) -} +::: test +name: a prompt edit that breaks the prompt refuses as prompt-invalid and applies nothing +given: { workspace: fixture_two_modules, edit: prompt_edit_breaking_directive } +expect: { outcome: prompt_invalid, exit_code: 29, files_byte_identical: true } +::: -/// What the user still owns after a removal, reported and never touched: the -/// module's prompt file, the manifest that may now carry a dependency nothing -/// needs, and, when the removal emptied a crate, the crate directory itself. -fn ready_to_delete(workspace: &Path, module: &str, plan: &RemovalPlan) -> Value { - let prompt = crate::workspace::discover_sources(workspace) - .ok() - .and_then(|sources| { - sources - .iter() - .find(|source| source.module == module) - .map(|source| source.path.clone()) - }); - json!({ - "crate": plan.vacated, - "detail": "these are yours, like every manifest: removal reports them and touches neither", - "manifest": "review the crate manifest for a dependency only the removed module needed", - "prompt": prompt, - }) -} +::: test +name: an ambiguous old-string without replace-all refuses as edit-mismatch +given: { workspace: fixture_two_modules, edit: old_string_matches_twice } +expect: { outcome: edit_mismatch, exit_code: 27, files_byte_identical: true } +::: -/// Re-derive one crate's entry file from the states that survive the removal. -/// -/// This is the whole of the projection law at work: the entry file is a function -/// of the committed states, so the module stops being named the moment no state -/// owns its files, and nothing here has to know which line to strike. A crate -/// the removal emptied derives to no bytes at all, which is how the caller knows -/// the entry file should leave rather than be rewritten empty. -fn re_derive( - store: &Store, - target: &TargetDef, - root: &str, - states: &BTreeMap<(String, String), String>, -) -> Result<(String, Vec)> { - let src_root = crate::paths::join_root(root, &target.source_dir); - let mut files: Vec = Vec::new(); - let mut providers: Vec = Vec::new(); - for ((state_target, _module), state_key) in states { - if state_target != &target.name { - continue; - } - let state = read_module_state(store, state_key)?; - let owned = base_output_files(store, &state)?; - if !owned - .iter() - .any(|(path, _)| crate::paths::is_under(path, &src_root)) - { - continue; - } - for (path, _hash) in &owned { - files.push(path.clone()); - } - for item in crate::compile::read_surface_items(store, &state.surface_hash)? { - if item.kind != "fn" { - continue; - } - let Some(prefix) = item.path.strip_suffix("::commands") else { - continue; - }; - if !prefix.is_empty() && !prefix.contains(' ') { - providers.push(prefix.to_string()); - } - } - } - Ok(crate::target::derive_lib_projection( - target, root, &files, &providers, - )) -} +::: test +name: the first bad ref payload answers with the whole contract at once +given: { workspace: fixture_two_modules, edit: ref_payload_bare_string } +expect: { error_lists_all_fields: true, error_lists_direction_values: true, error_carries_valid_example: true, files_byte_identical: true } +::: -fn publish_all(store: &Store, objects: &[PendingObject], owner: &str) -> Result<()> { - let mut seen: BTreeSet<&str> = BTreeSet::new(); - for object in objects { - if !seen.insert(object.name.as_str()) { - continue; - } - disk::publish(store, object.kind, &object.payload, owner)?; - } - Ok(()) -} +::: test +name: a recorded edit serves its reference in the payload and in show +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } +expect: { payload_reference_verbatim: true, payload_names_drift_until_commit: true, show_lists_reference: true } +::: -fn json_pending(kind: ObjectKind, value: &Value) -> PendingObject { - let (name, payload) = crate::canon::hash_json(kind, value); - PendingObject { - name, - kind, - payload, - } -} +::: test +name: a prose-only edit needs no reference and touches no store state +given: { workspace: fixture_two_modules, edit: plain_prompt_edit_valid } +expect: { applied: true, store_bytes_unchanged: true } +::: -fn blob_pending(bytes: &[u8]) -> PendingObject { - PendingObject { - name: napl_store::store_hash::object_name(ObjectKind::Blob, bytes), - kind: ObjectKind::Blob, - payload: bytes.to_vec(), - } -} -napl-store tree v1 4679 -{"entries":[{"hash":"f0987b243dce1a039a0c78f8100965a895c03501bc378b4217765c5c84982d34","kind":"file","mode":33188,"name":"attribution.rs"},{"hash":"e539c65a4600ed1784b9cc176cb28f24c140f514f9783360f1f5a27142f89f68","kind":"file","mode":33188,"name":"authoring.rs"},{"hash":"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2","kind":"file","mode":33188,"name":"blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","kind":"file","mode":33188,"name":"candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","kind":"file","mode":33188,"name":"canon.rs"},{"hash":"0b8190e7989129e7baf4eed7046e19b7608067eb5d13bed95ad416118bf34a25","kind":"file","mode":33188,"name":"capture.rs"},{"hash":"2e5b5b54aebea3db240acc2f2dc944df07b908bf97ba2067517a9757ed8c7dcb","kind":"file","mode":33188,"name":"cli.rs"},{"hash":"7f8fe1b370188a60f00e524cc5c8059ca17a347b97915097286fff2c0ec7e290","kind":"file","mode":33188,"name":"commandset.rs"},{"hash":"65a7254890fcb0844c78f9967465fd1c93c843479a6617c192291da44994f62f","kind":"file","mode":33188,"name":"commit.rs"},{"hash":"3c0db9b5d179e2f3b7123cd8e08844ea9ad28a0a6ffbc7e5794738709f5af1f4","kind":"file","mode":33188,"name":"compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","kind":"file","mode":33188,"name":"cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","kind":"file","mode":33188,"name":"decision.rs"},{"hash":"bc274cef6c4e5aca63e7c46d81e1f86e118c6ba079e7a4ccd4e9f14a8d3eb21d","kind":"file","mode":33188,"name":"directive.rs"},{"hash":"b86a4431e286225b32bae5cebc03298584fce063e8d4ee2df4633e18143f0aa7","kind":"file","mode":33188,"name":"disk.rs"},{"hash":"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812","kind":"file","mode":33188,"name":"error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","kind":"file","mode":33188,"name":"evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","kind":"file","mode":33188,"name":"graph.rs"},{"hash":"824c95b7229ac2ad5b1cc518b296443883fb56a110c86c2a2e67923c0a19ea07","kind":"file","mode":33188,"name":"lint.rs"},{"hash":"2ea965827e5c5073cbcacacd22bf2318a9067874c901b4f05f67268e99baf92c","kind":"file","mode":33188,"name":"lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","kind":"file","mode":33188,"name":"main.rs"},{"hash":"6fc1b58dad295d780c917c10ac0f793667e46c455349cff3d42e6855501a91bd","kind":"file","mode":33188,"name":"maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","kind":"file","mode":33188,"name":"mv.rs"},{"hash":"7cf9c419b2c4f7c7b17e2c1e4362861de714f4ffe54727c1a0fcb749b2e6975b","kind":"file","mode":33188,"name":"objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","kind":"file","mode":33188,"name":"paths.rs"},{"hash":"2103a2f9f3054ea0913e16c84c302e0a8869263533ecf7a91204288feb146d85","kind":"file","mode":33188,"name":"porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","kind":"file","mode":33188,"name":"receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","kind":"file","mode":33188,"name":"slice.rs"},{"hash":"3c72ec744785180719afc98553f9aa7fc4b47c427647818dbf75ba4e03cc4045","kind":"file","mode":33188,"name":"source.rs"},{"hash":"6f59cf61c11b2590df13b604d16baad2eac373cf823dcb053228eec905b1a12b","kind":"file","mode":33188,"name":"staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","kind":"file","mode":33188,"name":"store_view.rs"},{"hash":"1820f647bb5dbd74c8db7f5519d404ef0ce1520c833a579aaca893b5cad7ba08","kind":"file","mode":33188,"name":"surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","kind":"file","mode":33188,"name":"target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","kind":"file","mode":33188,"name":"textdiff.rs"},{"hash":"167749ecbb655a214510c849c92be88cb536cc7f57a330d21b0314780457b0a1","kind":"file","mode":33188,"name":"transaction.rs"},{"hash":"d4ef5888a7ecc406d8eb5173dc215c7b1a1ef1e1ddd3ab8b3a8aa6639d125017","kind":"file","mode":33188,"name":"transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","kind":"file","mode":33188,"name":"trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","kind":"file","mode":33188,"name":"version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","kind":"file","mode":33188,"name":"workspace.rs"}]} -napl-store blob v1 2658 -//! Derived line ancestry: blame by canonical replay. -//! -//! Blame is not stored. It is replayed from the accepted patches in causal -//! order: a line that survives a transition unchanged keeps the ancestry it -//! already had, and a line the transition wrote is attributed to that -//! transition. A whole-file rewrite therefore blames the rewriting transition -//! for every line, which is honestly coarse rather than falsely precise. +::: test +name: verb success payloads spell the add then commit sequence +given: { workspace: fixture_two_modules, invocations: [plain_write_owned_file, referenced_code_edit_with_ref] } +expect: { write_payload_names: [add, commit], edit_payload_names: [add, commit] } +::: -use serde_json::{json, Value}; +::: test +name: write with empty stdin refuses naming the empty flag and writes nothing +given: { workspace: fixture_two_modules, write: target_with_empty_stdin } +expect: { exit_code: 2, error_names_flag: "--empty", files_byte_identical: true } +::: -use crate::textdiff::{diff_lines, lines, Edit}; +::: test +name: an edit touching a referenced line reads stale on both endpoints afterward +given: { workspace: fixture_two_modules, edit: plain_edit_on_referenced_code_line } +expect: { reference_status: stale, stale_endpoints: [prompt, code] } +::: -/// One revision of a file, as one accepted transition left it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Revision { - /// The transition that produced this content. - pub transition: String, - /// The file's full content after that transition. - pub content: String, -} +::: test +name: a session window from the environment is recorded as the usage reference +given: { workspace: fixture_two_modules, env: { NAPL_SESSION: s1 }, log: proxy_log_with_s1_lines, edit: referenced_code_edit_with_ref } +expect: { provenance_usage_session: s1, usage_span_recorded: true, aggregated_at_record: false } +::: -/// One line of the final content, with the transition that wrote it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BlameLine { - /// 1-based line number in the final content. - pub line: usize, - pub transition: String, - pub text: String, -} +::: test +name: an edit with no session window records provenance unavailable +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, env: {} } +expect: { provenance_model: unavailable, provenance_usage: unavailable } +::: -/// Replay a path's revisions into per-line ancestry. -pub fn blame(revisions: &[Revision]) -> Vec { - let mut current: Vec = Vec::new(); - let mut owners: Vec = Vec::new(); - for revision in revisions { - let next = lines(&revision.content); - let mut next_owners: Vec = Vec::with_capacity(next.len()); - for edit in diff_lines(¤t, &next) { - match edit { - // A surviving line keeps the ancestry it already had. - Edit::Keep(old_index, _) => next_owners.push(owners[old_index].clone()), - // A written line belongs to the transition that wrote it. - Edit::Insert(_) => next_owners.push(revision.transition.clone()), - Edit::Delete(_) => {} - } - } - current = next; - owners = next_owners; - } - current - .into_iter() - .zip(owners) - .enumerate() - .map(|(index, (text, transition))| BlameLine { - line: index + 1, - transition, - text, - }) - .collect() -} +## The rest of the porcelain -/// The canonical JSON body of a blame report. -pub fn blame_json(path: &str, lines: &[BlameLine]) -> Value { - let entries: Vec = lines - .iter() - .map(|line| { - json!({ - "line": line.line as u64, - "text": line.text, - "transition": line.transition, - }) - }) - .collect(); - json!({ - "contractVersion": crate::CONTRACT_VERSION, - "lines": entries, - "path": path, - }) -} -napl-store receipt v1 1151 -{"baseStateKey":"accaf93691b84c6794df99c7643c710529c765f12cc9faedede4b6b598a8c6b0","baseStoreSnapshot":"653c03e84a35c5b5456fac1a327f8a29ebcc4c19d96ee0f5e166aa3c73d5b7d4","baseWorkspaceHash":"1cbd0f8a55437d59751b61ddc91d2290912cf5b52204af9c179365134de2e5f3","candidateTreeHash":"05f44ac11e9808b57f775c0ffedd617fc59c8a13cef9839ed551a535e36b7148","contractVersion":4,"inputKey":"a3af18b0602f5c451e31dde831d3640aaf42c3d650c51f4b679b6dd8aa24ff6f","module":"query","outputManifest":[{"hash":"deff13703dd0b3c630077edaffafc1fa9f2fdfde297b9a84b45841a428715965","path":"crates/napl/src/check.rs"},{"hash":"7c26384c4adc5b5c857d0811275db450218c8f05177ae35a898ec59a5a5a5e03","path":"crates/napl/src/docs.rs"},{"hash":"726a20b8361501f4c26633cd92835ffb2e5f1acd584c0af76895d29bf620a7b1","path":"crates/napl/src/document.rs"},{"hash":"5ec2c2137f45d02c8e8bf78ecf0652966850772097b41f3835a99116f3b78f57","path":"crates/napl/src/promptbuild.rs"},{"hash":"be629fe4418f4226c3119cf486d936741c11b644241c9c358df2172131c722c1","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"sliceHash":"21a0afb6d5668b4db3969637babc0ca89e99d9330e65f1b1e746facc1ba809e4","target":"rust"} -napl-store transition v1 1428 -{"baseStateKey":"907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","contractVersion":4,"derivedProjections":["crates/napl-store/src/lib.rs"],"inputKey":"865433045eb8c750a39bd7d29f2e8631c96a03d6aedad8f19ea9907551bdc927","installPaths":["crates/napl-store/src/hash.rs","crates/napl-store/src/store_blocks/mod.rs","crates/napl-store/src/store_blocks/tests.rs","crates/napl-store/src/store_diff/mod.rs","crates/napl-store/src/store_diff/tests.rs","crates/napl-store/src/store_hash.rs","crates/napl-store/src/store_io/mod.rs","crates/napl-store/src/store_io/tests.rs","crates/napl-store/src/store_objects/mod.rs","crates/napl-store/src/store_objects/tests.rs","crates/napl-store/src/store_reduce/mod.rs","crates/napl-store/src/store_reduce/tests.rs","crates/napl-store/src/store_tree.rs","crates/napl-store/src/store_txn_core.rs"],"module":"napl_store","newState":"ba19292fc1f3d688a585b06965de172490cdea63123e6d6d4d536d2592be9025","readSet":["base-state:907ed42e5f16a30bfbaf5543c53760a898d1ffc4ae415d97e30cc5ecd91dbd68","input-key:865433045eb8c750a39bd7d29f2e8631c96a03d6aedad8f19ea9907551bdc927","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"19f4168a27ef3d98ecf9780d65c22990ad51ba16572473ef8de3f0b894cef777","surface":"50b745f71f63d7fd5b8a05fdf84474524004af8f75d47f8346b7404f99981fab","target":"rust","writeSet":["owned:crates/napl-store","module-state:rust/napl_store"]} -napl-store blob v1 1006 -module: "commands" -target: "rust" -stale: false -entries: - - promptLines: - - 1 - - 8 - file: "crates/napl/src/init.rs" - lines: whole - note: "two porcelain additions each a thin composition over the shared derivations, the command table contributing init and watch" - - promptLines: - - 9 - - 84 - file: "crates/napl/src/init.rs" - lines: whole - note: "napl init bootstraps the store skeleton or reports already_initialized, names the directory it ran in as the workspace root, declares no flags beyond the shared help flag, teaches the first mile through nextSteps whose minimal prompt declares roots naming its own module so the first workspace is lint-ready, and scaffolds the agent glue by writing only the entry files that are absent and never touching one that exists" - - promptLines: - - 85 - - 106 - file: "crates/napl/src/watch.rs" - lines: - - 1 - - 127 - note: "napl watch an io shell re-running compile on content change publishing nothing" -napl-store receipt v1 932 -{"baseStateKey":"12c16b47d6c11312d275a81a2890f4f30d72955e8a7f5210dc871bdf022bdab1","baseStoreSnapshot":"54d53ea574dd74da4263771f7fbe99734f4b9dbfdd419debce4462c1d47f2975","baseWorkspaceHash":"a531341ff0e8cad58637e69d5e861040c78566a8fbf33abbedccecbd9aa7fc64","candidateTreeHash":"64dc2a19e65f0caaff5668d614612d09ca2b044e338fe382eae811b3aea2c388","contractVersion":2,"inputKey":"8284916729e807b849c3171c40dcd8daaeb2f7c268ab4bc2d6e8297f14fd14d6","module":"query","outputManifest":[{"hash":"ab97733506282c17e4ee242d4a63eb254d9b96590bb96b0bc1b8eb4de5fd0b8c","path":"crates/napl/src/document.rs"},{"hash":"f1b16bab91743a5c12abaf26150bf40b513df99019b03e4c70825208238a8121","path":"crates/napl/src/promptbuild.rs"},{"hash":"54e74a9ea88a542e39c45393ba16f55f2204810b4beb411e87dbb1552d5fb2ac","path":"crates/napl/src/query.rs"}],"receiptVersion":1,"sliceHash":"09bd1a89001fd2b25d7cf9d3a3b083bff520609d846b0c7ccdadf9b3a2ea805d","target":"rust"} -napl-store blob v1 954 -module: query -target: rust -stale: false -entries: - - promptLines: - - 1 - - 10 - file: crates/napl/src/query.rs - lines: - - 1 - - 255 - note: "the query module owns napl q and napl prompt and the canonical document they share" - - promptLines: - - 11 - - 84 - file: crates/napl/src/document.rs - lines: - - 1 - - 320 - note: "the canonical document docVersion 1 built purely from committed records, totally ordered, no absolute paths" - - promptLines: - - 85 - - 139 - file: crates/napl/src/query.rs - lines: - - 1 - - 255 - note: "napl q shaped exactly like jq via the embedded jaq engine, impure builtins refused, 64 MiB output ceiling" - - promptLines: - - 140 - - 219 - file: crates/napl/src/promptbuild.rs - lines: - - 1 - - 430 - note: "napl prompt reads typed refs and materializes prose and code from committed blobs at the refs snapshot, with law closure" -napl-store blob v1 4336 -module: "usage_proxy" -target: "rust" -stale: false -entries: - - promptLines: - - 1 - - 26 - law: true - note: "one user-facing verb and one capture-only sidecar behind it; the crate is runtime I/O by nature and its telemetry is evidence, never identity" - - promptLines: - - 27 - - 62 - file: "crates/napl-usage-proxy/src/agent.rs" - lines: - - 1 - - 208 - note: "napl agent resolves the workspace root, reuses or starts the one sidecar, injects the base url the session id and the session header, inherits the terminal whole, returns the child exit code, and stops the sidecar on --stop" - - promptLines: - - 27 - - 62 - file: "crates/napl-usage-proxy/src/state.rs" - lines: - - 1 - - 158 - note: "liveness and identity live in one canonical state file written whole and claimed atomically, so racing wrappers converge on one survivor and a dead pid is replaced rather than trusted" - - promptLines: - - 27 - - 62 - file: "crates/napl-usage-proxy/src/session.rs" - lines: - - 1 - - 23 - note: "a fresh session id per run, the window an edit is later joined to its calls by" - - promptLines: - - 27 - - 62 - file: "crates/napl-usage-proxy/src/args.rs" - lines: - - 1 - - 28 - note: "the argv grammar of the verb: flags, the -- separator, and the empty command that is a usage error" - - promptLines: - - 27 - - 62 - file: "crates/napl-usage-proxy/src/main.rs" - lines: - - 1 - - 36 - note: "the binary entry: the agent verb and the sidecar it starts are one executable, so napl agent reaches this crate through its own command name" - - promptLines: - - 63 - - 81 - file: "crates/napl-usage-proxy/src/proxy.rs" - lines: - - 215 - - 292 - note: "verbatim passthrough to the one upstream origin, session header stripped and hop-by-hop headers dropped, response streamed back as received, capture never gating the call" - - promptLines: - - 63 - - 81 - file: "crates/napl-usage-proxy/src/upstream.rs" - lines: - - 1 - - 33 - note: "one upstream origin over rustls, the request path and query carried across unchanged" - - promptLines: - - 82 - - 125 - file: "crates/napl-usage-proxy/src/capture.rs" - lines: - - 1 - - 132 - note: "usage read from a top-level JSON body or merged from the message_start and message_delta events of an SSE stream, every other event ignored, failure to parse leaving usage unread" - - promptLines: - - 82 - - 125 - file: "crates/napl-usage-proxy/src/logline.rs" - lines: - - 1 - - 112 - note: "one canonical line per completed call appended whole under a gate, absent fields reported unavailable and never zero, the log append-only" - - promptLines: - - 82 - - 125 - file: "crates/napl-usage-proxy/src/canonical.rs" - lines: - - 1 - - 59 - note: "canonical JSON with recursively sorted keys, so a log line and a state file are the same bytes for the same facts" - - promptLines: - - 82 - - 125 - file: "crates/napl-usage-proxy/src/proxy.rs" - lines: - - 23 - - 122 - note: "the response body tees into the capture as it streams and settles exactly one log line when the call completes, carrying the stripped session header and never the auth material or the bodies" - - promptLines: - - 126 - - 135 - file: "crates/napl-usage-proxy/src/proxy.rs" - lines: - - 123 - - 214 - note: "the sidecar exits nonzero only on startup failure, a bind failure or an unusable log path, and never because of a bad request" - - promptLines: - - 136 - - 206 - law: true - note: "the twelve pinned cases are live tests in the hand-maintained crates/napl-usage-proxy/tests seam, covering the wrapper, the cold-start race, passthrough, capture, secrecy, and concurrent appends" - - promptLines: - - 32 - - 62 - file: "crates/napl-usage-proxy/src/agent.rs" - lines: - - 1 - - 208 - note: "test reference from the wrapper heading to the agent module" - provenance: - timestamp: "2026-07-30T08:11:54Z" - usageLineEnd: "38" - usageLineStart: "2" - usageLogPath: ".napl/usage-proxy.jsonl" - usageMediaType: "application/x-napl-usage-proxy-jsonl-v1" - usageSession: "7cfe5ae0793ee8dfde9c9b624e0a2683" -napl-store snapshot v1 555 -{"contractVersion":3,"event":"00b471b31107981f48c6968244d3b44ef2f290d9b1e82004e41a788edde89df3","states":[{"module":"commands","state":"94697ec1fb16dd5f899f438ef2b257f8b6c5cb611e93acf940a8f2fe94a3713f","target":"rust"},{"module":"napl","state":"55b8614a73ee118bd8731c37ce598fe2b9fcc799e89ead46639d0e57c4307472","target":"rust"},{"module":"napl_store","state":"1506a85bc6ef2b6f1cc8426700959b114ea5532cd02cf43e57d2a82ab7b4fb8a","target":"rust"},{"module":"query","state":"cda23242a5fb8aa243b577c8d7fb4e666dd23757c9c1f8919f20af2224dadec3","target":"rust"}]} -napl-store blob v1 87033 -# NAPL: the deterministic compiler +`napl status` observational classification, zero mutation, and it +judges both ends: per module it reports `drift: true` when the +committed output differs from the working tree, and the module's stale +reference count, the same derivations diff and the lsp already serve, +so status can never read clean while the query surface reads stale. +A module fresh on the prompt side and drifted on the code side says +so in one payload. `napl status --module ` answers with the +module's live state beside its plan, tracked or pending, drift, stale +reference count, attribution status, in the same payload as the owned +globs and wave position, so the command named status never answers +with a plan alone; and the payload carries the derived entry-file +projection, path and exact content, so the line a new file needs in a +shared entry file is read from the tool, never composed by hand. +Every projection path the plan mentions is labeled what it is, +tool-derived and never user-supplied, and the plan names one concrete +legal first output path (the module name under the target's source +dir), so the obvious wrong first move, hand-writing the entry file, +is warned against before it is made. The add refusal for a module +whose owned tree holds nothing stageable says the same thing: which +paths present are projections, and what the first legal file would +be. +`napl log` entries carry each transition's note count and reference +count, and the log payload names `napl show ` as where +the full note and reference text is served, so provenance is one +named hop away, never a secret; `napl show ` lists its +attached notes inline, hash and text, so a note attached is a note +findable without plumbing. -This prompt describes the entire NAPL toolchain, rebuilt as one small Cargo -workspace of exactly three crates. It replaces the legacy 76-crate -implementation completely; git history holds the original. This prompt is -the governing contract: the workspace's `.napl` prompts and the committed -code are the two truths, and no external document outranks them. +::: test +name: log counts references and names show as the inspection verb +given: { workspace: fixture_two_modules, history: [commit, referenced_edit] } +expect: { log_reference_count: 1, show_named_in_payload: true } +::: -## Layout: this module generates two crates on top of the ported store +`napl log` +per-module or store-wide history. `napl show ` inspect one object. +`napl diff` drift between committed outputs and working tree as a canonical +byte-stable patch. `napl blame ` derived ancestry: surviving lines +trace through accepted patches by canonical replay; rewritten regions blame +the rewriting transition, honestly coarse. Blame reads committed +bytes only, and says so when it matters: a blamed path whose working +tree drifts from the committed output carries a drift notice naming +`napl diff` as where the uncommitted change lives, so ancestry over +the committed bytes is never mistaken for the present file. -The workspace layout is fixed: the records directory `.napl/` lives at the -repository root; generated code installs under `crates/`; a module's -prompt file lives inside the crate directory that receives its code, and -the crate is the folder: every module whose `.napl` file sits in -`crates//` compiles into the `` crate as its own source -module, so one crate may be described by several prompt files -(`crates/napl/napl.napl` and `crates/napl/commands.napl` both build -`crates/napl/`). Module discovery finds prompts by walking the workspace -for `*.napl` files, and the workspace root is the nearest ancestor -directory containing `.napl`. A subtree that contains its own `.napl` -directory is a separate workspace: discovery never descends into it, the -way git never descends into a nested repository. Hidden directories and -`node_modules` are never descended into. +::: test +name: blame over a drifted path names the drift and points at diff +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, blame_path: drifted_file } +expect: { drift_noticed: true, diff_named: true, ancestry_served: true } +::: -The proven store already exists as a hand-ported crate at -`crates/napl-store/` (same envelope format `napl-store v1 -\n`, SHA-256 names, canonical JSON payloads, same object -kinds, same `.napl/store/` on-disk layout with HEAD compare-and-swap, -module-keyed locks, and dead-holder steal, ported code and tests from the -legacy store crates). Use its public API through a path dependency; do not -reimplement, wrap thinly, or fork any of its types or logic. The four new -object kinds the compiler needs (`transition`, `receipt`, `surface`, -`target-def`) already exist in it additively. +`napl lint` unreachable modules +under an explicit `roots` declaration; when roots are absent the refusal +is loud, exit 2 with canonical JSON naming the missing prerequisite and +the exact declaration that would satisfy it, never an empty findings +list under exit 0; unconsumed surface entries (diagnosis only), module staleness; +findings are proposals with cascade previews; `napl lint --fix` applies +only complete deterministic mapping-safe edits through the guarded commit +path and never removes lines. `napl lint ` is the +source-language scan, a different question needing no roots: it parses +every committed module's owned working-tree file under that target +with the pinned grammar and reports one finding per file that fails, +naming the file and the first error's line. Every lint payload names +the scope it judged, graph or source syntax, so an empty findings +list is never read wider than the scan that produced it. Source +syntax findings are facts, not proposals: a target scan carrying any +finding exits 1, so automation can never read broken source as +success, and a clean scan exits 0; the graph scope keeps its +advisory exit. ::: test -name: the ported store hashes objects exactly as the original store crates did -given: { vectors: ported_store_hash_test_corpus } -expect: { all_pass_unmodified: true } +name: lint of a target exits nonzero on source findings and zero when clean +given: { workspace: fixture_two_modules, runs: [clean_tree, syntactically_invalid_owned_file], lint: target_form } +expect: { clean_exit_code: 0, broken_exit_code: 1, findings_when_broken: 1 } ::: ::: test -name: identical payload under two object kinds gets two different names -given: { payload: "X", kind_a: blob, kind_b: snapshot } -expect: { equal: false } +name: lint of a target reports a parse finding per malformed working-tree file +given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, lint: target_form } +expect: { findings: 1, error_names_file_and_line: true, scope_named: source_syntax, roots_required: false } ::: -This module's generated output is as many crates as the design needs, side -by side in the module's owned directory. Keep it a small, clearly separated -workspace (this replaces a 76-crate explosion; do not recreate one), with -`napl-store` staying its own untouched crate. The natural split, which you -may adjust if the design genuinely wants another boundary: +::: test +name: lint of a target over a clean tree answers empty and names its scope +given: { workspace: fixture_two_modules, lint: target_form } +expect: { findings: 0, scope_named: source_syntax, exit_code: 0 } +::: -1. **`napl-core/`** (lib): the pure compiler. Parsing `.napl` files (YAML - frontmatter: module, deps, targets, tests; markdown body), discovery and - dependency graph, canonical path grammar, the identity vocabulary, - per-module artifact slices, wave planning, tree-sitter surface extraction - for Rust and TypeScript targets, the surface grammar, placement rules - and entry-file projection derivation, receipt verification logic, the commit - decision table as pure functions, blame line-ancestry replay, lint - diagnosis, cost calculation, evidence validation. No filesystem writes, - no locks, no clocks, no environment reads: everything enters as - arguments and leaves as values. -2. **`napl-cli/`** (bin `napl`): every command, each a thin composition of - napl-core derivations and napl-store reads plus the one guarded write - path. No private logic in the binary beyond argument parsing and - formatting. +`napl cost` pure calculation over an explicit +receipt set and a pinned pricing-table provenance object; unknown is +unknown, never zero. Edits price the way generations price: records the +capture verbs staged enter the same receipt set, their session-window +usage references resolve at read time against the named log's bytes, +summing the window's lines under the same pinned pricing table, and +`napl cost --session ` reports one session's calls directly from +the log. A record without a window prices `unavailable`; a window whose +log file or session lines are absent prices `unavailable` and says +which; a model the pricing table does not name prices unknown; nothing +is ever silently zero and nothing is aggregated at record time. The +same receipt set, log bytes, and pricing table produce byte-identical +reports. `napl gc` stages sweeps outside locks, swaps pointers +inside the bounded section, never blocks writers, never deletes anything +reachable from a named head. `napl gc --apply` also repacks: the +reachable closure lands in one pack beside its index under +`objects/pack/` through the store's repack operation, loose files and +older packs are removed after the pack is durable, and the payload +names the pack written, the objects packed, and the files removed. +The dry `napl gc` reports what a repack would write. Every read +answers identically before and after a repack, fsck is healthy after +exactly when it was healthy before, and publication keeps writing +loose objects, so a store is always the sum of its packs plus its +loose files. Nobody is required to know any of this: exactly as git +packs itself, a successful commit whose store holds more loose +objects than a compiled-in threshold runs the same repack +automatically, after the critical section and outside every lock, +and reports what it packed under a `repack` key in the commit +payload; the threshold is a count, never a clock, so when the repack +fires is a function of store content alone, and the pack it writes +is byte-identical to the one gc would have written. A user who never +types gc keeps a small store; gc remains the explicit verb for doing +it now. Reachable means the transitive closure: +from every named head, through every event in its chain, through every +object any reachable object references by hash, output trees, +surfaces, attribution records, prompt blobs, receipts, at every +contract version history carries. `fsck`'s definition of a present +object IS gc's definition of a kept one; a store that was healthy +before a sweep is healthy after it, and blame replays the same +ancestry after a sweep as before. An edge gc does not understand is a +reason to keep, never to sweep. -The crates depend on `napl-store` by path, `serde_json`, `sha2`, -`tree-sitter`, `tree-sitter-rust`, `tree-sitter-typescript`, `clap` -(builder API only, driving the command line contract below), and nothing -that talks to a network, spawns a process, or reads clocks; those -declarations live in the user-owned manifests, not in any module's -output. Ensure `cargo test` passes for both crates and `napl-store` from -their workspace before finishing. Generated code is authored against its -final installed location, never a staging tree: acceptance runs at the -final location, so green certifies the bytes where they live. The -working-tree add forms are the operational spelling of this law: author -and verify in place, then stage from where the bytes live; assembling a -separate candidate directory is the exception, for staging bytes that -are not the working tree, never the routine. +::: test +name: gc apply on a workspace with history leaves fsck healthy and blame replaying +given: { workspace: fixture_with_multi_commit_history, gc: apply } +expect: { fsck_findings: 0, blame_replays_history: true, head_intact: true } +::: -When several modules share one crate, the crate's shared entry files are -derived projections owned by no module: the crate root `lib.rs` module -list is recomputed at commit from the union of the crate's committed -module file sets, and the binary's command registry is recomputed from -the command tables the crate's modules expose under one fixed convention -(each module may expose `pub fn commands() -> &'static [CommandSpec]` -from its own files; the dispatch loop iterates the derived registry and -contains no per-command match arms of its own). A module's owned output -therefore never includes the shared entry files, two modules can never -claim the same path, and a commit that would write a projection-owned -path is rejected as invalid-candidate. +::: test +name: gc apply repacks the reachable closure into one pack and the store still answers +given: { workspace: fixture_with_multi_commit_history, gc: apply } +expect: { packs: 1, loose_reachable_remaining: 0, all_commands_answer_identically: true, fsck_findings: 0 } +::: -A prompt at the workspace root is legal and means the workspace root is -the crate directory, exactly as git treats the directory it was -initialized in: the placement root is the empty workspace-relative path, -every workspace-relative path is inside it, and every join against it -produces clean relative paths, never a fabricated leading separator. A -cold workspace whose first prompt sits beside `.napl` therefore commits -its first module with no configuration; placement never needs declaring -because the prompt's location is the declaration. +::: test +name: a commit after a repack writes loose objects beside the pack +given: { workspace: fixture_with_multi_commit_history, gc: apply, then: valid_commit } +expect: { commit_outcome: installed, new_objects_loose: true, pack_untouched: true } +::: ::: test -name: a root-level prompt places at the workspace root and its module commits -given: { workspace: fresh_init_root_prompt, candidate: valid_root_level } -expect: { placement_root: "", owned_globs_relative: true, outcome: installed } +name: a commit crossing the loose threshold repacks automatically and says so +given: { workspace: fixture_with_multi_commit_history, loose_objects: above_threshold, then: valid_commit } +expect: { commit_outcome: installed, repack_reported: true, packs: 1, loose_reachable_remaining: 0 } ::: ::: test -name: status for a root-level module reports a workspace-root plan with clean globs -given: { workspace: fresh_init_root_prompt, flag: module_root } -expect: { plan_placement_root: "", owned_globs_leading_separator: false } +name: a commit below the loose threshold never repacks +given: { workspace: fixture_with_multi_commit_history, loose_objects: below_threshold, then: valid_commit } +expect: { commit_outcome: installed, repack_reported: false, packs_unchanged: true } ::: -## The command line contract +::: test +name: the automatic repack writes the byte-identical pack gc would have written +given: { workspace: fixture_with_multi_commit_history, compare: [auto_repack_at_commit, explicit_gc_apply] } +expect: { pack_bytes_equal: true, pack_names_equal: true } +::: -Every command the registry serves declares its full grammar in its -`CommandSpec`: name, a one-line summary, the exact usage line, and the -closed table of flags it accepts, each with arity and meaning. The -dispatch loop owns two behaviors ahead of every command, so no command -can get them wrong. First, help: `napl help` lists every registered -command with its summary, `napl help ` and `--help` or `-h` -anywhere in a command's arguments print that command's usage line, flag -table, and summary, exit 0, and never invoke the command. Help speaks -human by default and machine on request: when stdout is a terminal or -`--human` is passed, help renders readable text from the registry, -usage line, summary, flag table with meanings, and one worked example -per command; when stdout is piped or `--json` is passed, help emits -the canonical JSON registry exactly as today, byte-stable. Help is -the only command with a human rendering; every other command speaks -canonical JSON everywhere, and the two help renderings derive from -the one registry so they can never disagree. Help is a pure -read: no store access, no filesystem write, no state change of any kind, -and this holds for every command including `init` and `watch`. Second, -strict parsing: an argument beginning with `--` that the command's flag -table does not name is a usage error, never ignored; the error goes to -stderr as canonical JSON naming the unknown flag, listing the command's -valid flags, and suggesting the nearest declared flag by edit distance -when one is close, and the command does not execute. Exit 2 is the -pinned usage-error code for the whole binary: unknown command, unknown -flag, missing required flag or positional, malformed flag value. A -usage error always restates the command's exact usage line, so the -error itself teaches the grammar. +`napl fsck` envelope hashes, kind validity, +causal parent links. `napl restore` deterministic repair plans from +persisted evidence; unambiguous moves install, ambiguity blocks; liveness +and wall time never decide recovery. `napl mv ` relocation +as a semantic operation: moves a module's installed files to a new +placement, recomputes every placement-derived fact (derived entry-file +projections, attribution file paths) from the destination, passes +through the guarded commit path, and re-verifies there; failed +verification at the destination installs nothing; manifests are the +user's to update, and status diagnoses a placement the workspace +manifest no longer lists. A move made outside `napl mv` is drift like any other: +the store still holds the committed bytes, `napl restore` re-materializes +the module at its recorded placement, and when the drifted content matches +a committed state exactly the plan may instead adopt the new placement +through the commit path; anything ambiguous blocks. `napl cat-file ` +raw plumbing. `napl lsp` a language server over stdio for editor +extensions: hover, definition, references, and code lens over the +prompt-to-code mapping. Everything it serves derives from committed +records (attribution entries, module surfaces, the ir) resolved against +the workspace root; hover on a prompt line presents the attributed +generated spans with their notes, hover on a generated line presents the +owning prompt lines and note, definition jumps across the mapping in both +directions, references enumerates every attributed span, code lens counts +attributed files per prompt section. The server publishes diagnostics +derived from reference staleness: for every stale reference it emits one +diagnostic on each endpoint, the prompt span whose code moved and the +code span whose prompt changed, so sync debt is visible where the edit +happens, like a type error for documentation. Publication is a pure +derivation from committed records plus the working tree at the moment +the protocol asks, no background mutation, no clocks, no scanning loops +beyond what the editor protocol requires; opening or changing a +document refreshes that document's diagnostics and its counterparts'. +Severity is warning, never error: drift is a legal state, marked, not +policed. The diagnostic message states exactly what is known and +nothing more: which end drifted, the reference's direction, recorded +model or `unavailable`, referentially valid, and the healing verbs +(re-record through `napl edit`, or restore); no wording implies the +sentence describes the code. A fresh reference publishes nothing. +Resolving is always a human or LLM re-affirming or re-pointing the +reference through the capture verbs; the server proposes nothing and +repairs nothing. Pure reads only: no mutation, no +clocks, no background scanning. A request whose records are missing or +unparseable answers empty rather than erroring or blocking, and missing +or unparseable records degrade to no diagnostics, never an error; a stale +mapping is served marked stale, consistent with the attribution contract; +the server never hangs on a malformed or absent record. Record resolution is +anchored per document: walk up from the document's filesystem path to the +nearest ancestor directory containing `.napl` and read records there; the +server's working directory and the client's rootUri carry no meaning. +Every location the server emits that points at a module's prompt resolves +to the prompt file's actual placement found by workspace prompt discovery, +where the prompt sits inside the crate directory its code lands in; a +module name is never joined against the workspace root to fabricate a +prompt path, and a module whose prompt cannot be discovered answers empty. +Crash safety: every persisted application step leaves markers such that +recovery derives a deterministic plan from content alone. Evidence +validators are a registry keyed by media type (Claude CLI JSON envelope +ships first, and `application/x-napl-usage-proxy-jsonl-v1` validates +usage-proxy logs: one canonical JSON object per line, required fields +present, absent values the string `unavailable`, token counts +non-negative integers when present, a line failing the shape is +`invalid`); outcomes `validated`, `unsupported`, `invalid`, +`validator-error`; invalid stays visible as failed. Evidence bounds are +compiled-in constants; exceeding any is `resource-limit` before anything +publishes. -The parser behind this contract is clap's builder API, constructed at -dispatch from the CommandSpec registry, never derive macros and never -per-command clap declarations: the registry stays the single source of -grammar and clap is its execution engine. clap's own renderings are -never emitted; every parse failure is caught and served as the -canonical JSON usage error above, byte-shaped by this contract alone. -`--flag value` and `--flag=value` parse identically; no command -declares short flags today, so clustering is outside the contract -until one does. The registry pre-pass, not clap, is what refuses an -undeclared flag, because the canonical JSON payload with its -nearest-name suggestion is pinned by this contract; clap normalizes -spellings and values behind it. `napl completions ` emits a completion script for -`bash`, `zsh`, or `fish`, generated from the same registry, a pure -read on stdout; an unknown shell name is a usage error. +::: test +name: a stale reference publishes one diagnostic on each endpoint +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, lsp: [open_prompt, open_code] } +expect: { prompt_diagnostics: 1, code_diagnostics: 1, severity: warning } +::: -The commands whose meaning requires a committed head are exactly -`diff`, `blame`, and `notes add`; in a workspace that has none they -answer with a prerequisite error, never an empty success shape: the -payload names what is missing and the exact sequence that creates it, -from `napl init` through writing a prompt beside `.napl`, `add`, and -`commit`. `log` and `show` keep their committed contracts and answer -empty honestly. `napl blame` on a path no committed module owns, and -`napl diff` for a target under which no committed module owns output, -say so: the payload carries `tracked: false` and a reason naming the -absent baseline, never an empty patch or empty ancestry that reads as -success. Diff's operand is a target name and its usage line spells it -``; an operand that is not one (a module name, a file -path) is a usage error naming the target names that exist, so the -argument teaches its own vocabulary. The `napl notes add` refusal for a working path -that is not an object name states how an object name is obtained, -naming `napl log` and `napl show` with a one-line example. +::: test +name: a fresh reference publishes nothing +given: { workspace: fixture_two_modules, lsp: [open_prompt, open_code] } +expect: { prompt_diagnostics: 0, code_diagnostics: 0 } +::: + +::: test +name: "the diagnostic message carries drifted end, direction, and provenance and nothing semantic" +given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched, lsp: [open_code] } +expect: { message_names_drifted_end: true, message_names_direction: true, message_model_or_unavailable: true, message_semantic_claims: 0 } +::: + +::: test +name: absent or unparseable records publish no diagnostics and answer empty +given: { workspace: fixture_two_modules, records: attribution_file_corrupted, lsp: [open_prompt] } +expect: { diagnostics: 0, error_responses: 0, server_alive: true } +::: ::: test -name: flag equals value and separated value parse identically -given: { workspace: fixture_two_modules, invocations: ["status --module leaf", "status --module=leaf"] } -expect: { outputs_byte_identical: true } +name: committing the drifted code clears both endpoints' diagnostics on refresh +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, then: commit_module, lsp: [change_code_document] } +expect: { prompt_diagnostics: 0, code_diagnostics: 0 } ::: ::: test -name: completions emits a script for a known shell and refuses an unknown one -given: { workspace: fixture_two_modules, invocations: ["completions zsh", "completions tcsh"] } -expect: { zsh_exit_code: 0, zsh_output_nonempty: true, tcsh_exit_code: 2, store_bytes_unchanged: true } +name: diff renders drift between committed output and working tree as a canonical patch +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched } +expect: { diff_nonempty: true, canonical_patch: true, byte_stable: true } ::: ::: test -name: blame on an unowned path and diff on an outputless target say untracked -given: { workspace: fixture_two_modules, blame_path: file_no_module_owns, diff_target: target_without_outputs } -expect: { tracked: false, reason_names_baseline: true, exit_code: 0 } +name: "blame over a whole-file regeneration coarsens to the transition, line ancestry survives accepted patches" +given: { workspace: fixture_two_modules, history: [patch_commit, full_rewrite_commit] } +expect: { patched_lines_trace_to_patch: true, rewritten_region_blames_transition: true } ::: ::: test -name: status reports code-side drift and stale references beside prompt freshness -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } -expect: { module_drift: true, stale_reference_count: 1, prompt_side_fresh: true } +name: lint reports an unreachable module only under declared roots and refuses without them +given: { workspace: fixture_orphan_module, roots: absent } +expect: { unreachable_analysis: refused, exit_code: 2, reason_names_roots: true, findings_field_absent: true } ::: ::: test -name: a headless workspace answers baseline commands with the prerequisite sequence -given: { workspace: fresh_init_no_commits, invocation: "blame some/file.rs" } -expect: { error_names_missing_head: true, sequence_named: [init, prompt, add, commit] } +name: lint with declared roots proposes removal of the orphan with a cascade preview and never auto-applies +given: { workspace: fixture_orphan_module, roots: [main_module] } +expect: { findings: [unreachable_napl], auto_applied: false, cascade_preview: true } ::: ::: test -name: init with a help flag prints usage and mutates nothing -given: { directory: empty, invocation: "init --help" } -expect: { exit_code: 0, created: [], usage_printed: true } +name: lint --fix drops an unreferenced declared dependency through the guarded commit path +given: { workspace: fixture_unused_dep, fix: drop_unused_dep } +expect: { outcome: installed, transition_recorded: true, dep_removed: true } ::: ::: test -name: an unknown flag is a usage error naming the flag and suggesting the nearest -given: { workspace: fixture_two_modules, invocation: "add cand --modul m" } -expect: { exit_code: 2, error_names_unknown: "--modul", suggested: "--module", executed: false } +name: a cost report over a receipt set containing verb-staged records includes the edits +given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines } +expect: { report_rows: 2, edit_row_priced: true, same_pricing_table: true } ::: ::: test -name: help lists every registered command and per-command help states exact usage -given: { workspace: fixture_two_modules, invocations: ["help", "help add"] } -expect: { all_commands_listed: true, add_usage_line_present: true, exit_code: 0, store_bytes_unchanged: true } +name: an edit without a window and a window with missing log lines both price unavailable naming why +given: { workspace: fixture_two_modules, receipts: [edit_without_window, edit_with_window_to_missing_lines] } +expect: { priced_unavailable: 2, reason_named_per_row: true, zeros: 0 } ::: ::: test -name: help renders human text under the human flag and identical json when piped -given: { workspace: fixture_two_modules, invocations: ["help --human", "help --json"] } -expect: { human_not_json: true, human_carries_example: true, json_byte_stable: true } +name: a model absent from the pricing table prices unknown never zero +given: { workspace: fixture_two_modules, receipts: [edit_with_unknown_model_window], log: proxy_log_unknown_model } +expect: { priced_unknown: 1, zeros: 0 } ::: -Manifests are user-owned glue seams, not records: `Cargo.toml` at the -workspace root and in every crate belongs to the user, edited by hand -like any Rust project, tracked by git, consumed by cargo, and NEVER -owned, installed, validated, or rewritten by the toolchain. A candidate -containing a manifest path is rejected as invalid-candidate; no module's -owned output includes one; nothing in the store records manifest bytes. -The toolchain's only relationship to manifests is diagnosis: `status` -reports, as observations never as writes, a module placed in a crate the -workspace manifest does not list, or a crate directory missing a -manifest. Every manifest diagnostic names the exact workspace-relative -manifest path it means and says the file is the user's to edit, so the -manifest is never a thing status alludes to without locating. A dependency needed by generated code is added by editing the -manifest, and the build battery is what proves it correct. +::: test +name: cost reports are byte-identical across runs over the same receipts log and table +given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines, runs: 2 } +expect: { bytes_equal: true } +::: ::: test -name: a candidate containing a manifest path is rejected -given: { workspace: fixture_two_modules, candidate: contains_cargo_toml } -expect: { outcome: invalid_candidate, manifest_bytes_unchanged: true } +name: cost session reports one session's calls directly from the log +given: { workspace: fixture_two_modules, log: proxy_log_two_sessions, session: s1 } +expect: { rows_for_session: 2, rows_for_other_sessions: 0, priced_from_log: true } ::: -## The two principles +::: test +name: a proxy log line failing the shape validates invalid and stays visible as failed +given: { evidence: proxy_log_with_malformed_line, media_type: application_x_napl_usage_proxy_jsonl_v1 } +expect: { outcome: invalid, visible_as_failed: true } +::: -P1 ownership: the toolchain owns `.napl` source, the derived layer, the -store, and the mappings between them; every owned fact is provable from -content alone. It contains no LLM invocation, no provider identifier, no -model selection, no completion parsing, no process execution (the only -process is `napl` itself), no clock reads in any semantic path, no -randomness in any semantic path. Generation happens outside: the user's -agent session reads the plan from `napl status`, the specification through -the query surface, writes candidate code, and calls `napl add` then -`napl commit`. +::: test +name: cost over one receipt set and pricing version is byte-identical across runs and unknown for unknown models +given: { receipts: fixture_receipts, pricing_version: pinned, runs: 2, unknown_model_receipt: included } +expect: { reports_equal: true, unknown_model_cost: unknown } +::: ::: test -name: the binaries spawn no process and name no provider -given: { crate_source: self } -expect: { spawns_processes: false, provider_identifiers: [] } +name: gc never deletes an object reachable from the current head and never blocks a concurrent commit +given: { workspace: fixture_two_modules, concurrent: [gc, valid_commit] } +expect: { reachable_deleted: false, commit_blocked: false, unreachable_swept: true } ::: -P2 footprint scoping: every operation declares a read set (content -identities consumed), a semantic write set (owned records changed), and its -derived projections plus physical install paths. Two operations conflict -when semantic write sets overlap or one's accepted write changes a content -identity in the other's read set. Derived projections (the crate-root -lib.rs module list, the command registry) are never conflict-bearing: -they are recomputed exactly once from the winning merged head inside -commit and rebase. Manifests are outside footprints entirely: user-owned -seams no operation reads as identity or writes at all. +::: test +name: fsck detects a corrupted object and a broken parent link +given: { workspace: fixture_two_modules, corrupt: [flip_byte_in_object, remove_parent_event] } +expect: { findings: [hash_mismatch, missing_parent], exit_nonzero: true } +::: -## Identity +::: test +name: restore applies an unambiguous move-repair plan and blocks on ambiguity +given: { workspace: fixture_moved_output, ambiguity: [none, two_candidates] } +expect: { unambiguous: installed, ambiguous: blocked } +::: -Expose `pub const CONTRACT_VERSION: u32 = 4` in napl-core (bumped from 1 -by the body-directive grammar, which changes how the same source bytes -are interpreted, from 2 by the relaxed module-name rule below, which -accepts sources contract 2 rejected, and from 3 by the capture verbs, -whose three refusal outcomes join the closed terminal-outcome table). Per module and -target: `surfaceHash` (hash of the recorded public surface), `inputKey` -(hash of the canonical closure manifest over normalized source hash, direct -dependencies' surfaceHashes, placement facts, entry-point version, target -name plus target-definition hash, CONTRACT_VERSION), `baseStateKey` (the -module's committed owned-output state hash), `sliceHash` (hash of the -module's canonical compile slice). Whole-compile `artifactHash` includes -scope and serves determinism tests only, never a commit precondition. Event -identity is `H(content hash + sorted parent hashes)`; ordinals and -timestamps are display metadata, never identity. Semantic identifiers are -content hashes, never a timestamp, pid, hostname, random value, model, or -provider. Telemetry lives in provenance records linking to semantic objects -by hash. +::: test +name: hover on an attributed prompt line resolves its generated spans +given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: within_entry } } +expect: { spans_resolved: true, notes_included: true } +::: ::: test -name: "a module's inputKey covers source, direct dep surfaces, placement, target identity, and contract version" -given: { workspace: fixture_two_modules, module: consumer } -expect: { input_key_components: [source_hash, direct_dep_surface_hashes, placement_facts, target_name, target_definition_hash, entry_point_version, contract_version] } +name: "hover on an unattributed line answers empty, not error" +given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: outside_entries } } +expect: { result: empty, error: false } ::: ::: test -name: changing a leaf's surface changes the direct dependent's inputKey only -given: { workspace: fixture_chain_three, edit: rename_public_item_in_leaf } -expect: { changed_input_keys: [middle], unchanged_input_keys: [top] } +name: missing attribution records degrade to empty responses +given: { attribution: absent, request: hover } +expect: { result: empty, error: false, blocked: false } ::: ::: test -name: a leaf edit absorbed by an unchanged intermediate surface leaves higher dependents' inputKeys unchanged -given: { workspace: fixture_chain_three, edit: private_body_change_in_leaf } -expect: { changed_input_keys: [], unchanged_input_keys: [middle, top] } +name: definition from a generated line targets the prompt at its discovered placement +given: { attribution: fixture_attribution_small, prompt_placement: inside_crate_dir, request: definition, position: { file: generated_within_entry } } +expect: { location_file: discovered_prompt_path, root_joined_module_name: false } ::: ::: test -name: telemetry differences never change transition identity -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipts: [with_usage, without_usage] } -expect: { transition_identities_equal: true } +name: evidence failing its validator is invalid and distinguishable from unsupported +given: { receipt_evidence: [valid_claude_json, malformed_claude_json, unknown_media_type] } +expect: { outcomes: [validated, invalid, unsupported] } ::: ::: test -name: history nodes are causal hashes over content plus sorted parents -given: { workspace: fixture_two_modules, commits: 2 } -expect: { event_id_components: [content_hash, sorted_parent_ids], ordinal_in_identity: false, timestamp_in_identity: false } +name: a receipt exceeding evidence bounds is rejected as resource-limit with nothing published +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: oversized_evidence } +expect: { outcome: resource_limit, published_semantic_state: false, published_provenance: false } ::: -## Module names +## Determinism contract -A module name is any non-empty single-line scalar. The empty string is the only -value rejected, surfaced as `mapping-integrity`. No character class, case, or -identifier shape is required: `todo-app`, `todo_app`, and non-ascii names are all -legal. The same rule governs every name position the parser reads, the `module` -frontmatter value, each `deps` entry, and each declared root, so all three accept -exactly the set the module rule accepts. This relaxation replaces the contract 2 -identifier constraint and rides the CONTRACT_VERSION bump to 3, the sanctioned -identity transition. As with the 1-to-2 bump, every module's inputKey recomputes -because CONTRACT_VERSION is one of its components; no source or surface changes, -only the version does. +Every collection canonically ordered; every input explicit and content +identified; serialization byte-pinned; ambient environment excluded +(locale, timezone, machine identity, CPU count, enumeration order); reads +never write; a state transition names its base and fails on mismatch per +the decision table rather than recalculating. Same workspace compiled +twice, from different absolute paths, under different discovery orders, +locales, and timezones produces byte-identical artifacts. The test suite +proves the vectors in the frontmatter with deterministic fixtures +constructed in each crate's `tests/`. -Because a name is unrestricted, it is never used raw as a filesystem path segment -or a store key. napl-core exposes one injective, reversible encoding used at every -such site: each byte outside `[A-Za-z0-9._-]` is percent-encoded as `%XX` with -uppercase hex, and a name that is exactly `.` or `..` has its dots percent-encoded -too, so an encoded segment is never `/`, `\`, empty, `.`, or `..` and can never -traverse or alias a directory. A name already matching `[A-Za-z0-9._-]` and not -equal to `.` or `..` encodes to itself, so existing records keep their on-disk -names: `todo-app` stays `todo-app`. Every writer that keys a record or scratch -path by module name, the mapl, ir, attribution, and prompt-at-gen documents, the -staging and relocation scratch directories, and the store root map, uses the -encoded segment; decoding recovers the exact name. On a case-insensitive -filesystem two names differing only in ascii case can still share one segment; -that collision is the author's concern, distinct from the traversal safety above. +::: test +name: compiling the same workspace twice yields byte-identical artifacts +given: { workspace: fixture_two_modules, runs: 2 } +expect: { artifacts_equal: true } +::: -Wherever a module name is serialized into a record it is written as a quoted, -escaped scalar, never interpolated raw, so a name containing YAML or JSON -metacharacters reparses to the identical name. +::: test +name: artifact bytes are independent of discovery order +given: { workspace: fixture_two_modules, discovery_orders: [sorted, reversed, shuffled_seed_7] } +expect: { artifacts_equal: true } +::: ::: test -name: a non-identifier module name is accepted -given: { workspace: fixture_single_module, module_name: "todo-app" } -expect: { outcome: ok } +name: artifact bytes are independent of the absolute workspace path +given: { workspace: fixture_two_modules, mounted_at: [rootA, rootB] } +expect: { artifacts_equal: true } ::: ::: test -name: an empty module name is rejected as mapping-integrity -given: { workspace: fixture_single_module, module_name: "" } -expect: { outcome: mapping_integrity } +name: canonical json sorts keys and is byte-stable +given: { value: "{\"b\":1,\"a\":2}", encodings: 2 } +expect: { bytes_equal: true, sorted_keys: true } ::: +napl-store surface v1 4888 +{"contractVersion":4,"items":[{"kind":"const","path":"git_store::CONTRACT_TRAILER","signature":"pub const CONTRACT_TRAILER : & str = \" Napl-Contract \" ;"},{"kind":"struct","path":"git_store::ConversionOutcome","signature":"pub struct ConversionOutcome"},{"kind":"field","path":"git_store::ConversionOutcome.commit","signature":"pub commit : String"},{"kind":"field","path":"git_store::ConversionOutcome.objects","signature":"pub objects : usize"},{"kind":"field","path":"git_store::ConversionOutcome.snapshot","signature":"pub snapshot : Option < String >"},{"kind":"struct","path":"git_store::GitProvider","signature":"pub struct GitProvider"},{"kind":"method","path":"git_store::GitProvider::commit_transaction","signature":"pub fn commit_transaction ( & self , expected_snapshot : Option < & str > , snapshot : & str , contract : u32 ) -> Result < TransactionOutcome , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::commit_transaction_observed","signature":"pub fn commit_transaction_observed ( & self , expected_snapshot : Option < & str > , snapshot : & str , contract : u32 , on_prepared : & mut dyn FnMut ( ) ) -> Result < TransactionOutcome , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::committed_objects","signature":"pub fn committed_objects ( & self ) -> Result < BTreeMap < String , gix :: ObjectId > , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::contract_of","signature":"pub fn contract_of ( & self , commit : gix :: ObjectId ) -> Result < Option < u32 > , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::convert_tracked_store","signature":"pub fn convert_tracked_store ( & self , store_root : & Path ) -> Result < ConversionOutcome , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::open","signature":"pub fn open ( repository : & Path , local_root : & Path ) -> Result < GitProvider , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::publish_envelope","signature":"pub fn publish_envelope ( & self , name : & str , bytes : & [ u8 ] ) -> Result < ( ) , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::publish_object","signature":"pub fn publish_object ( & self , kind : ObjectKind , payload : & [ u8 ] ) -> Result < String , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::read_object","signature":"pub fn read_object ( & self , name : & str ) -> Result < Option < Vec < u8 > > , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::snapshot_of","signature":"pub fn snapshot_of ( & self , commit : gix :: ObjectId ) -> Result < Option < String > , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::tip","signature":"pub fn tip ( & self ) -> Result < Option < gix :: ObjectId > , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::union_merge","signature":"pub fn union_merge ( & self , other : gix :: ObjectId ) -> Result < UnionOutcome , GitStoreError >"},{"kind":"enum","path":"git_store::GitStoreError","signature":"pub enum GitStoreError { Io ( String ) , Integrity ( String ) , HeadMoved { expected : Option < String > , found : Option < String > } , Git ( String ) }"},{"kind":"const","path":"git_store::IDENTITY_EMAIL","signature":"pub const IDENTITY_EMAIL : & str = \" store@napl \" ;"},{"kind":"const","path":"git_store::IDENTITY_NAME","signature":"pub const IDENTITY_NAME : & str = \" napl-store \" ;"},{"kind":"const","path":"git_store::PENDING_FILE","signature":"pub const PENDING_FILE : & str = \" GIT-PENDING \" ;"},{"kind":"const","path":"git_store::SNAPSHOT_TRAILER","signature":"pub const SNAPSHOT_TRAILER : & str = \" Napl-Snapshot \" ;"},{"kind":"const","path":"git_store::STORE_BRANCH","signature":"pub const STORE_BRANCH : & str = \" refs/heads/napl-store \" ;"},{"kind":"struct","path":"git_store::TransactionOutcome","signature":"pub struct TransactionOutcome"},{"kind":"field","path":"git_store::TransactionOutcome.commit","signature":"pub commit : String"},{"kind":"field","path":"git_store::TransactionOutcome.objects","signature":"pub objects : usize"},{"kind":"field","path":"git_store::TransactionOutcome.tree","signature":"pub tree : String"},{"kind":"struct","path":"git_store::UnionOutcome","signature":"pub struct UnionOutcome"},{"kind":"field","path":"git_store::UnionOutcome.commit","signature":"pub commit : String"},{"kind":"field","path":"git_store::UnionOutcome.objects","signature":"pub objects : usize"},{"kind":"field","path":"git_store::UnionOutcome.unmerged_entries","signature":"pub unmerged_entries : usize"},{"kind":"fn","path":"git_store::commit_message","signature":"pub fn commit_message ( snapshot : & str , contract : u32 ) -> String"},{"kind":"fn","path":"git_store::trailer_value","signature":"pub fn trailer_value ( message : & str , key : & str ) -> Option < String >"}],"module":"git-store","target":"rust"} +napl-store tree v1 129 +{"entries":[{"hash":"ff0e2e5e8660e7185fc33bd4fbcb40c4c8ddfcd4b5c5231c31cc4ade5c1d238c","kind":"dir","mode":16384,"name":"src"}]} +napl-store tree v1 252 +{"entries":[{"hash":"2a8ec239d786d46d6bc7953121850de4aea1db75f15f4b5660469bb86eef5417","kind":"file","mode":33188,"name":"Cargo.toml"},{"hash":"d8f1404ff37c120ba753827f21ccb70d3a8829aa5c6f7e96b746a12df4d7e4bc","kind":"dir","mode":16384,"name":"src"}]} +napl-store event v1 158 +{"content":"49444e7934b9431dd7f4c8b63c158344c7521dd59af343f8307d515a3b7d250c","parents":["51ea9c40dc0d97e9bfe016daed54dee7f9b82b544ca8d76349e9b8020d9cfe40"]} +napl-store tree v1 130 +{"entries":[{"hash":"e582e40a241346ddc9877893e0b6f583abeb112dbc20113867dc72ec3c767379","kind":"dir","mode":16384,"name":"napl"}]} +napl-store blob v1 34003 +//! The read commands and the maintenance commands. +//! +//! Every read resolves HEAD exactly once and then answers entirely from objects +//! reachable from that snapshot. No read creates a directory, takes a lock, or +//! writes a byte, so the whole read surface works on a read-only filesystem. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use crate::blame::{blame, blame_json, untracked_json, Revision}; +use crate::compile::{compile, graph_for, read_surface_items, state_of}; +use crate::cost::{cost_report, line_from_reported, CostLine}; +use crate::error::{error, OutcomeCode, Result}; +use crate::graph::{topological_levels, Graph}; +use crate::slice::receipt_requirements; +use crate::lint::{lint, lint_json, LintInput, LintReport}; +use crate::maintenance::{ + fsck, fsck_json, gc_json, plan_restore, plan_sweep, reachable_from_head, restore_json, + FileFact, RestorePlan, +}; +use crate::objects::{ + event_transition, read_module_state, snapshot_event, snapshot_states, walk_events, ModuleState, +}; +use crate::receipt::normalize_reported; +use crate::store_view::{ + flatten_tree, get_blob, get_json, get_object, has_object, ObjectSource, +}; +use crate::surface::surface_json; +use crate::target::{ + builtin_target, is_manifest_path, owned_globs, placement_root, projection_owned_paths, TargetDef, +}; +use crate::textdiff::render_drift; +use napl_store::store_hash::ObjectKind; +use serde_json::{json, Value}; + +use crate::disk::{self, Store}; +use crate::workspace::{discover_sources, read_working_text}; + +/// A snapshot-consistent status report for one target: observation, zero +/// mutation. +pub fn status(workspace: &Path, target: &str) -> Result { + let store = disk::open(workspace); + let head = store.head()?; + let states = match &head { + Some(snapshot) => snapshot_states(&store, snapshot)?, + None => BTreeMap::new(), + }; + let sources = discover_sources(workspace)?; + let artifact = compile(&store, &sources, target, None)?; + let pending: Vec = artifact + .slices + .iter() + .map(|slice| slice.module.clone()) + .collect(); + + let mut modules: Vec = Vec::new(); + for ((state_target, module), state_hash) in &states { + if state_target != target { + continue; + } + let state = read_module_state(&store, state_hash)?; + let freshness = attribution_freshness(&state, module, target, &sources); + let prompt_stale = freshness["status"].as_str() != Some("fresh"); + let ends = both_ends(workspace, &store, &state, prompt_stale)?; + modules.push(json!({ + "attribution": freshness, + "baseStateKey": state_hash, + "drift": ends.drift, + "inputKey": state.input_key, + "module": module, + "outputTree": state.output_tree, + "pending": pending.contains(module), + "sliceHash": state.slice_hash, + "staleReferences": ends.stale_references as u64, + "surfaceHash": state.surface_hash, + })); + } + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "head": head, + "manifestDiagnostics": manifest_diagnostics(workspace, target, &sources), + "modules": modules, + "pending": pending, + "target": target, + })) +} + +/// What status observes about a module's code side, so status judges both ends. +/// +/// `drift` is true when the committed output differs from the working tree, and +/// `stale_references` counts the module's references reading stale right now. Both +/// come from the derivations `diff` and the language server already serve, which +/// is the whole point: status can never read clean while the query surface reads +/// stale, because there is one derivation and not two. +struct BothEnds { + drift: bool, + stale_references: usize, +} + +fn both_ends( + workspace: &Path, + store: &Store, + state: &ModuleState, + prompt_stale: bool, +) -> Result { + let mut drift = false; + let mut drifted_paths: BTreeSet = BTreeSet::new(); + if has_object(store, &state.output_tree) { + for (path, (blob, _mode)) in flatten_tree(store, &state.output_tree)? { + let bytes = std::fs::read(workspace.join(&path)).ok(); + // An unreadable working file is not drift: the reader knows nothing + // about it, so it claims nothing, exactly as the query surface does. + if crate::attribution::code_drifted(Some(blob.as_str()), bytes.as_deref()) { + drift = true; + drifted_paths.insert(path); + } + } + } + let entries = match &state.attribution { + Some(recorded) if has_object(store, &recorded.record) => { + let bytes = get_blob(store, &recorded.record)?; + crate::attribution::parse(&String::from_utf8_lossy(&bytes)) + .map(|record| record.entries) + .unwrap_or_default() + } + _ => Vec::new(), + }; + let stale_references = entries + .iter() + .filter(|entry| { + let code_drift = entry + .file + .as_ref() + .map(|file| drifted_paths.contains(file)) + .unwrap_or(false); + crate::attribution::reference_status(prompt_stale, false, code_drift).stale + }) + .count(); + Ok(BothEnds { + drift, + stale_references, + }) +} + +/// The module PLAN: the invariant requirements a conforming implementation +/// derives from the working tree's prompts alone. Owned globs, placement root, +/// projection ownership, receipt requirements, and wave position — never any +/// candidate-dependent fact, which is `add`'s job. +pub fn module_plan(workspace: &Path, target_name: &str, module: &str) -> Result { + let sources = discover_sources(workspace)?; + let target = builtin_target(target_name)?; + let source = sources.iter().find(|s| s.module == module).ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} has no prompt file in the workspace"), + ) + })?; + let graph = graph_for(&sources, target_name)?; + let levels = topological_levels(&graph)?; + let wave = *levels.get(module).ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} is not in the graph for target {target_name:?}"), + ) + })?; + let root = placement_root(source); + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "module": module, + "plan": { + "ownedGlobs": owned_globs(&target, &root), + "placementRoot": root, + "projectionOwned": projection_owned_paths(&target, &root), + "receiptRequirements": receipt_requirements(), + "wave": wave as u64, + }, + "target": target_name, + })) +} + +/// Observe, never mutate, the manifest/placement mismatches the toolchain now +/// only diagnoses: a module placed in a crate the workspace manifest does not +/// list, or a crate directory missing a manifest. Manifests are user-owned +/// seams, so these are reports, not writes. +fn manifest_diagnostics( + workspace: &Path, + target_name: &str, + sources: &[crate::source::SourceModule], +) -> Vec { + let Ok(target) = builtin_target(target_name) else { + return Vec::new(); + }; + let members = workspace_members(workspace, &target); + let mut roots: Vec = sources.iter().map(placement_root).collect(); + roots.sort(); + roots.dedup(); + let mut out: Vec = Vec::new(); + for root in roots { + if root.is_empty() { + continue; + } + if let Some(members) = &members { + if !members.contains(&root) { + out.push(manifest_diagnostic( + &root, + // The manifest this observation means is the workspace-root + // one, which holds the member list, not the crate's own. + &target.manifest_file, + "crate-not-listed-in-workspace-manifest", + &format!("the crate {root:?} holds a placed module but the workspace manifest does not list it as a member"), + )); + } + } + let manifest = workspace.join(&root).join(&target.manifest_file); + if !manifest.is_file() { + out.push(manifest_diagnostic( + &root, + &crate::paths::join_root(&root, &target.manifest_file), + "crate-directory-missing-manifest", + &format!("the crate directory {root:?} receives placed modules but has no manifest"), + )); + } + } + out +} + +/// One manifest observation, which always locates the file it means. +/// +/// A manifest is a user-owned seam: the toolchain never writes one, so a +/// diagnostic that only alluded to "the manifest" would leave the reader hunting +/// for the file they are the only one allowed to edit. Every diagnostic therefore +/// carries the exact workspace-relative path and says plainly whose file it is. +fn manifest_diagnostic( + crate_root: &str, + manifest: &str, + observation: &str, + detail: &str, +) -> Value { + json!({ + "crate": crate_root, + "detail": detail, + "manifest": manifest, + "observation": observation, + "owner": "user", + "remedy": format!("{manifest} is yours to edit; the toolchain diagnoses manifests and never writes them"), + }) +} + +/// The crate directories the workspace-root manifest lists as members, or +/// `None` when there is no readable workspace manifest to compare against. +fn workspace_members(workspace: &Path, target: &TargetDef) -> Option> { + let text = std::fs::read_to_string(workspace.join(&target.manifest_file)).ok()?; + let mut members: Vec = Vec::new(); + for line in text.lines() { + let trimmed = line.trim().trim_end_matches(','); + if let Some(inner) = trimmed + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + { + if !is_manifest_path(target, inner) && inner.contains('/') { + members.push(inner.to_string()); + } + } + } + Some(members) +} + +/// Classify a module's attribution freshness and, when it is not fresh, name the +/// exact fix. +/// +/// It is a pure observation over content already resolved: an absent record, a +/// record installed unproven, or a record whose recorded prompt hash no longer +/// matches the current normalized body all read as needing a fresh commit. A +/// record proven against the body still on disk is fresh. +fn attribution_freshness( + state: &ModuleState, + module: &str, + target: &str, + sources: &[crate::source::SourceModule], +) -> Value { + let directive = json!({ + "command": "commit", + "directive": format!( + "commit module {module:?} for target {target:?} with a fresh attribution update" + ), + "module": module, + "target": target, + }); + match &state.attribution { + None => json!({ "fix": directive, "status": "absent" }), + Some(attribution) => { + let current = sources + .iter() + .find(|source| source.module == module) + .map(|source| source.normalized_source_hash.as_str()); + if attribution.stale { + json!({ + "fix": directive, + "reason": "installed with --no-verify", + "status": "stale", + }) + } else if current != Some(attribution.prompt_hash.as_str()) { + json!({ + "fix": directive, + "reason": "the prompt changed since the attribution was recorded", + "status": "stale", + }) + } else { + json!({ "status": "fresh" }) + } + } + } +} + +/// The causal history reachable from HEAD, optionally narrowed to one module. +/// +/// The `ordinal` field is a presentation device computed from the causal graph +/// at read time. It is never stored and never enters an identity. +pub fn log(workspace: &Path, module: Option<&str>) -> Result { + let store = disk::open(workspace); + let Some(head) = store.head()? else { + return Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "entries": [], + "head": Value::Null, + })); + }; + let mut entries: Vec = Vec::new(); + for (ordinal, event) in causal_order(&store, &head)?.iter().enumerate() { + let transition_id = event_transition(&store, event)?; + let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; + if let Some(wanted) = module { + if transition["module"].as_str() != Some(wanted) { + continue; + } + } + entries.push(json!({ + "event": event, + "module": transition["module"], + "newState": transition["newState"], + "ordinal": ordinal as u64, + "target": transition["target"], + "transition": transition_id, + "writeSet": transition["writeSet"], + })); + } + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "entries": entries, + "head": head, + })) +} + +/// Describe any stored object: its kind and, for JSON kinds, its payload. +pub fn show(workspace: &Path, hash: &str) -> Result { + let store = disk::open(workspace); + let (kind, payload) = get_object(&store, hash)?; + let body = if kind == ObjectKind::Blob { + json!({ "bytes": payload.len() as u64 }) + } else { + serde_json::from_slice(&payload).map_err(|e| { + error( + OutcomeCode::StoreIntegrity, + format!("object {hash} is not JSON: {e}"), + ) + })? + }; + Ok(json!({ + "body": body, + "contractVersion": crate::version::CONTRACT_VERSION, + "hash": hash, + "kind": kind.as_wire(), + })) +} + +/// The raw payload bytes of an object, with the envelope stripped. +pub fn cat_file(workspace: &Path, hash: &str) -> Result> { + Ok(get_object(&disk::open(workspace), hash)?.1) +} + +/// The recorded surface of a module as of HEAD. +pub fn surface_of(workspace: &Path, target: &str, module: &str) -> Result { + let store = disk::open(workspace); + let Some(state) = committed_state(&store, target, module)? else { + return Ok(surface_json(module, target, &[])); + }; + let items = read_surface_items(&store, &state.surface_hash)?; + Ok(surface_json(module, target, &items)) +} + +/// Whether this workspace has a committed head at all. +/// +/// The one observation a command whose meaning requires a baseline makes before +/// it answers, so a headless workspace gets the prerequisite sequence instead of +/// an empty shape that would read as success. +pub fn has_committed_head(workspace: &Path) -> Result { + Ok(disk::open(workspace).head()?.is_some()) +} + +/// Drift between committed outputs and the working tree, as a canonical patch. +/// +/// A target no committed module owns anything under has no baseline, and the +/// honest answer is `tracked: false` with the reason: an empty patch would say +/// "nothing drifted" when the truth is "nothing was compared". +pub fn diff(workspace: &Path, target: &str) -> Result { + let store = disk::open(workspace); + let committed = committed_text(&store, target)?; + if committed.is_empty() { + return Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "reason": format!( + "no committed module owns any output under target {target:?}, so there is no baseline to diff against" + ), + "target": target, + "tracked": false, + })); + } + let paths: Vec = committed.keys().cloned().collect(); + let working = read_working_text(workspace, &paths); + let patch = render_drift(&committed, &working); + Ok(json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "patch": patch, + "target": target, + "tracked": true, + })) +} + +/// Derived line ancestry for one committed path. +/// +/// A path no committed module owns has no ancestry to replay, and empty ancestry +/// would read as a file that exists and was never written. It answers +/// `tracked: false` naming the absent baseline instead, an honest success shape. +pub fn blame_path(workspace: &Path, path: &str) -> Result { + let store = disk::open(workspace); + let Some(head) = store.head()? else { + return Ok(untracked_json( + path, + "this workspace has no committed head, so no module owns this path yet", + )); + }; + if !committed_manifest_all_targets(&store)?.contains(path) { + return Ok(untracked_json( + path, + "no committed module owns this path, so there is no committed baseline to trace it through", + )); + } + let mut revisions: Vec = Vec::new(); + for event in causal_order(&store, &head)? { + let transition_id = event_transition(&store, &event)?; + let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; + let touched = transition["installPaths"] + .as_array() + .map(|paths| paths.iter().any(|p| p.as_str() == Some(path))) + .unwrap_or(false); + if !touched { + continue; + } + let Some(state_hash) = transition["newState"].as_str() else { + continue; + }; + let state = read_module_state(&store, state_hash)?; + let files = flatten_tree(&store, &state.output_tree)?; + let Some((blob, _)) = files.get(path) else { + continue; + }; + let bytes = get_blob(&store, blob)?; + revisions.push(Revision { + transition: transition_id, + content: String::from_utf8_lossy(&bytes).into_owned(), + }); + } + Ok(blame_json(path, &blame(&revisions))) +} + +/// Diagnose a workspace. +pub fn lint_workspace(workspace: &Path, target: &str) -> Result<(Graph, LintReport, Value)> { + let store = disk::open(workspace); + let sources = discover_sources(workspace)?; + let artifact = compile(&store, &sources, target, None)?; + let graph = crate::compile::graph_for(&sources, target)?; + let definition = builtin_target(target)?; + let states = match store.head()? { + Some(snapshot) => snapshot_states(&store, &snapshot)?, + None => BTreeMap::new(), + }; + + let mut committed_input_keys: BTreeMap> = BTreeMap::new(); + let mut referenced_names: BTreeMap> = BTreeMap::new(); + let mut surface_names: BTreeMap> = BTreeMap::new(); + for module in graph.keys() { + let (state, _) = state_of(&store, &states, &definition, module)?; + committed_input_keys.insert(module.clone(), state.input_key.clone()); + surface_names.insert( + module.clone(), + read_surface_items(&store, &state.surface_hash)? + .iter() + .filter_map(|item| item.path.rsplit("::").next().map(str::to_string)) + .collect(), + ); + referenced_names.insert(module.clone(), identifiers(&store, &state)?); + } + let current_input_keys: BTreeMap = artifact + .slices + .iter() + .map(|slice| (slice.module.clone(), slice.input_key.clone())) + .collect(); + + let roots = declared_roots(&sources); + let report = lint( + &graph, + &LintInput { + roots, + committed_input_keys, + current_input_keys, + referenced_names, + surface_names, + }, + ); + let value = lint_json(&report); + Ok((graph, report, value)) +} + +/// Cost over an explicit set: receipt object names in the store, receipt file +/// paths, usage-proxy session windows, or any mixture. +/// +/// A session window is priced here, lazily, at read time: a capture verb records +/// the window as a reference and never aggregates it, so the log lines are summed +/// only when a reader asks. A model the pinned table does not know stays +/// `unknown`, never zero. +pub fn cost( + workspace: &Path, + names: &[String], + files: &[String], + sessions: &[String], +) -> Result { + let store = disk::open(workspace); + let mut lines: Vec = Vec::new(); + for session in sessions { + lines.extend(crate::cost::lines_from_session( + &std::fs::read_to_string(workspace.join(crate::capture::USAGE_LOG_PATH)) + .unwrap_or_default(), + session, + )); + } + for name in names { + let body = get_json(&store, name, ObjectKind::Receipt)?; + lines.push(receipt_line(workspace, name, &body)?); + } + for file in files { + let path = Path::new(file); + let bytes = std::fs::read(path).map_err(|e| disk::io(path, &e))?; + let body: Value = serde_json::from_slice(&bytes).map_err(|e| { + error( + OutcomeCode::InvalidReceipt, + format!("{file}: invalid JSON: {e}"), + ) + })?; + let name = crate::canon::json_hash(ObjectKind::Receipt, &body); + lines.push(receipt_line(workspace, &name, &body)?); + } + Ok(cost_report(&lines)) +} + +/// One receipt's cost line: a reported total priced directly, a recorded session +/// window priced against the log the record names. +fn receipt_line(workspace: &Path, name: &str, body: &Value) -> Result { + let module = body["module"].as_str().unwrap_or_default(); + let reported = normalize_reported(&body["reported"]); + match crate::cost::window_from_reported(&reported) { + Some(window) => { + let log = window_log(workspace, &window.log_path); + Ok(crate::cost::line_from_window( + name, + module, + &window, + log.as_deref(), + )) + } + None => line_from_reported(name, module, &reported), + } +} + +/// The bytes of a log a record names, read at the moment the reader asks. +/// +/// The path is a record's own workspace-relative path, so a path that escapes +/// the workspace or is absolute is not a log this reader will open: it reads as +/// absent, which the report already knows how to say. +fn window_log(workspace: &Path, log_path: &str) -> Option { + let candidate = Path::new(log_path); + if candidate.is_absolute() || log_path.split('/').any(|part| part == "..") { + return None; + } + std::fs::read_to_string(workspace.join(candidate)).ok() +} + +/// Sweep every object no named head reaches. +/// +/// The sweep set is computed outside the lock; only the deletions run under it, +/// and a writer publishing concurrently is never blocked because publication +/// takes no lock at all. +pub fn gc(workspace: &Path, apply: bool) -> Result { + let store = disk::init(workspace)?; + let all: BTreeSet = store.names()?.into_iter().collect(); + let reachable = match store.head()? { + Some(head) => reachable_from_head(&store, &head)?, + None => BTreeSet::new(), + }; + let swept = plan_sweep(&all, &reachable); + if apply { + for name in &swept { + disk::remove_object(&store, name)?; + } + } + Ok(gc_json(&swept, reachable.len())) +} + +/// Verify envelope hashes, kind validity, and causal parent links. +pub fn fsck_store(workspace: &Path) -> Result<(bool, Value)> { + let store = disk::open(workspace); + let findings = fsck(&store)?; + Ok((findings.is_empty(), fsck_json(&findings))) +} + +/// Derive a repair plan for a target's committed outputs, and optionally apply +/// it. +pub fn restore(workspace: &Path, target: &str, apply: bool) -> Result { + let store = disk::open(workspace); + let expected: Vec = committed_manifest(&store, target)? + .into_iter() + .map(|(path, hash)| FileFact { path, hash }) + .collect(); + let roots: BTreeSet = expected + .iter() + .filter_map(|fact| fact.path.split('/').next().map(str::to_string)) + .collect(); + let observed = observed_facts(workspace, &roots)?; + let plan = plan_restore(&expected, &observed); + if apply { + if let RestorePlan::Apply(steps) = &plan { + apply_restore(&store, workspace, steps)?; + } + } + Ok(restore_json(&plan)) +} + +/// Attach later provenance to a recorded transition without touching semantic +/// state. +/// The refusal a `--to` that is not an object name earns. +/// +/// A working path is the mistake a reader makes here, because every other verb +/// takes one, so the refusal does not merely say no: it names the two commands +/// that produce and inspect an object name and shows the one line that gets one. +fn not_an_object_name(given: &str) -> crate::error::CompilerError { + error( + OutcomeCode::Usage, + format!( + "--to expects the object name of a recorded transition, and {given:?} is not one. \ + Object names come from `napl log`, and `napl show ` inspects one, \ + for example: napl log | napl q '.entries[-1].transition' -" + ), + ) +} + +pub fn add_note(workspace: &Path, transition: &str, note: &str) -> Result { + let store = disk::init(workspace)?; + // A path is never an object name, and neither is anything else outside the + // store's name alphabet: refuse before opening anything, so the reader is + // told how to get a name rather than handed a missing-object error. + if !is_object_name(transition) || !has_object(&store, transition) { + return Err(not_an_object_name(transition)); + } + get_json(&store, transition, ObjectKind::Transition)?; + let body = json!({ "note": note, "transition": transition }); + let payload = crate::canon::canonical_bytes(&body); + let name = disk::publish(&store, ObjectKind::Blob, &payload, transition)?; + + let index = store.root.join("notes").join(format!("{transition}.json")); + let mut names: BTreeSet = match std::fs::read(&index) { + Ok(bytes) => serde_json::from_slice::>(&bytes) + .unwrap_or_default() + .into_iter() + .collect(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(), + Err(e) => return Err(disk::io(&index, &e)), + }; + names.insert(name.clone()); + let listed: Vec = names.iter().map(|n| json!(n)).collect(); + crate::workspace::write_file(&index, &crate::canon::canonical_bytes(&Value::Array(listed)))?; + Ok(json!({ + "note": name, + "outcome": "success", + "semanticStatePublished": false, + "transition": transition, + })) +} + +/// The events reachable from a head, ordered oldest first by causal depth. +pub fn causal_order(store: &Store, head: &str) -> Result> { + let head_event = snapshot_event(store, head)?; + let events = walk_events(store, &head_event, None)?; + let mut depths: BTreeMap = BTreeMap::new(); + for event in &events { + depth_of(store, event, &mut depths)?; + } + let mut ordered = events; + ordered.sort_by(|a, b| (depths[a], a).cmp(&(depths[b], b))); + Ok(ordered) +} + +fn depth_of(store: &Store, event: &str, depths: &mut BTreeMap) -> Result { + if let Some(known) = depths.get(event) { + return Ok(*known); + } + let value = get_json(store, event, ObjectKind::Event)?; + let mut depth = 0usize; + for parent in value["parents"].as_array().cloned().unwrap_or_default() { + if let Some(parent) = parent.as_str() { + depth = depth.max(depth_of(store, parent, depths)? + 1); + } + } + depths.insert(event.to_string(), depth); + Ok(depth) +} + +pub fn committed_state(store: &Store, target: &str, module: &str) -> Result> { + let Some(head) = store.head()? else { + return Ok(None); + }; + let states = snapshot_states(store, &head)?; + match states.get(&(target.to_string(), module.to_string())) { + Some(hash) => Ok(Some(read_module_state(store, hash)?)), + None => Ok(None), + } +} + +/// The materialized attribution view a module committed last, if any. +/// +/// The bytes are the record blob, which is the view itself: an internal +/// re-commit reads them to carry the mapping forward rather than fabricating a +/// new one. +pub fn committed_attribution_yaml( + store: &Store, + target: &str, + module: &str, +) -> Result>> { + let Some(state) = committed_state(store, target, module)? else { + return Ok(None); + }; + match state.attribution { + Some(attribution) => Ok(Some(get_blob(store, &attribution.record)?)), + None => Ok(None), + } +} + +/// The committed `path -> blob` manifest of every module under a target. +pub fn committed_manifest(store: &Store, target: &str) -> Result> { + let Some(head) = store.head()? else { + return Ok(BTreeMap::new()); + }; + let mut out = BTreeMap::new(); + for ((state_target, _), hash) in snapshot_states(store, &head)? { + if state_target != target { + continue; + } + let state = read_module_state(store, &hash)?; + for (path, (blob, _)) in flatten_tree(store, &state.output_tree)? { + out.insert(path, blob); + } + } + Ok(out) +} + +/// Whether a token is shaped like a store object name at all: the lowercase hex +/// of a SHA-256, and nothing else. A workspace path fails on the first separator. +fn is_object_name(given: &str) -> bool { + given.len() == 64 && given.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} -::: test -name: deps entries and declared roots accept the same names as the module rule -given: { module_name: "todo-app", dep_name: "a-b", root_name: "x.y" } -expect: { all_accepted: true } -::: +/// Every path any committed module owns, across every target: the set that +/// decides whether a path is tracked at all. +fn committed_manifest_all_targets(store: &Store) -> Result> { + let mut out: BTreeSet = BTreeSet::new(); + let Some(head) = store.head()? else { + return Ok(out); + }; + for (_, hash) in snapshot_states(store, &head)? { + let state = read_module_state(store, &hash)?; + for (path, _) in flatten_tree(store, &state.output_tree)? { + out.insert(path); + } + } + Ok(out) +} -::: test -name: the name encoding is injective reversible and never traverses -given: { names: ["todo-app", "a/b", "..", "café", "a b"] } -expect: { decode_roundtrip_equal: true, no_segment_in: ["/", "\\", "", ".", ".."], safe_subset_unchanged: ["todo-app"] } -::: +fn committed_text(store: &Store, target: &str) -> Result> { + let mut out = BTreeMap::new(); + for (path, blob) in committed_manifest(store, target)? { + let bytes = get_blob(store, &blob)?; + out.insert(path, String::from_utf8_lossy(&bytes).into_owned()); + } + Ok(out) +} -::: test -name: a name with yaml metacharacters is written quoted and reparses identically -given: { module_name: "a: {b} #c" } -expect: { record_reparse_equal: true, written_as_quoted_scalar: true } -::: +/// Every identifier a module's committed output names. +/// +/// This is a token scan, not name resolution: lint says a surface entry looks +/// unconsumed, and says so as a proposal, precisely because it cannot prove it. +fn identifiers(store: &Store, state: &ModuleState) -> Result> { + let mut out: BTreeSet = BTreeSet::new(); + if !has_object(store, &state.output_tree) { + // A module that has never committed names nothing yet. + return Ok(out); + } + for (_, (blob, _)) in flatten_tree(store, &state.output_tree)? { + let bytes = get_blob(store, &blob)?; + let text = String::from_utf8_lossy(&bytes).into_owned(); + for token in text.split(|c: char| !(c.is_alphanumeric() || c == '_')) { + if !token.is_empty() { + out.insert(token.to_string()); + } + } + } + Ok(out) +} -## The compile derivation +fn declared_roots(sources: &[crate::source::SourceModule]) -> Option> { + let mut declared: Option> = None; + for source in sources { + if let Some(roots) = &source.roots { + declared + .get_or_insert_with(BTreeSet::new) + .extend(roots.iter().cloned()); + } + } + declared.map(|set| set.into_iter().collect()) +} -Compilation is a pure derivation and NOT a command: parse the graph, -compute per-module slices for the changed frontier, derive -dependency-ordered waves. Never persisted, and never spelled on the CLI — -it is the internal engine of `status`, `add`, `commit`, `watch`, and the -lsp. Per module the slice carries: normalized source body and hash, direct -dependency surfaces and surfaceHashes, placement and allowed-output -constraints including the surface grammar, the owned-output base and -baseStateKey, inputKey, sliceHash, wave position, receipt requirements. It -contains no provider, model, prompt assembly, credentials, timestamps, -retry policy, pricing, or test results. `napl status --module ` -reports the module's PLAN from this derivation — owned globs, placement -root, projection ownership, receipt requirements, wave position — the -invariant requirements only, a pure function of the working tree's -prompts; candidate-dependent validation belongs to `add`, never the plan. +fn observed_facts(workspace: &Path, roots: &BTreeSet) -> Result> { + let mut out: Vec = Vec::new(); + for root in roots { + let dir = workspace.join(root); + if !dir.is_dir() { + continue; + } + for entry in crate::workspace::scan_candidate(&dir)? { + if entry.kind != crate::candidate::EntryKind::File { + continue; + } + out.push(FileFact { + path: crate::paths::join_root(root, &entry.path), + hash: napl_store::store_hash::object_name(ObjectKind::Blob, &entry.bytes), + }); + } + } + out.sort(); + Ok(out) +} -Pending is never a bare boolean. A pending module names its reason -from a closed vocabulary: `never-committed`, `source-changed`, -`dependency-surface-moved`, `placement-changed`, `target-changed`, -`contract-version-changed`; the reason is derived by comparing the -current derivation's inputKey components against the identities the -committed state records, the same facts the store already holds, -never a guess. And because an undrifted working tree stages exactly -the committed bytes, a pending module with no drift additionally -says the landing would re-stamp identical bytes, so whether a -pending module needs a real regeneration or a no-op landing is read -off status, never discovered by staging and comparing tree hashes. +fn apply_restore( + store: &Store, + workspace: &Path, + steps: &[crate::maintenance::RestoreStep], +) -> Result<()> { + use crate::maintenance::RestoreStep; + for step in steps { + match step { + RestoreStep::Move { from, to } => { + let target = workspace.join(to); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|e| disk::io(parent, &e))?; + } + std::fs::rename(workspace.join(from), &target) + .map_err(|e| disk::io(&target, &e))?; + } + RestoreStep::Write { path, hash } => { + let bytes = get_blob(store, hash)?; + crate::workspace::write_file(&workspace.join(path), &bytes)?; + } + RestoreStep::Remove { path } => { + let target = workspace.join(path); + match std::fs::remove_file(&target) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(disk::io(&target, &e)), + } + } + } + } + Ok(()) +} +napl-store module-state v1 562 +{"attribution":{"promptHash":"0a53f13c5e29172205697a166ba275f3517875ed8eef390067a8bd98659f5991","record":"a38d322afe4fa30f9d73cf7f101d26cb366e2dd382463b60fc5a76b5c3d62d02","stale":false},"contractVersion":4,"inputKey":"58fca4cee049991c052e54dc443f06f4d3b78ec74aad2fb6971c3138171783c9","module":"commands","outputTree":"b92d10f23649a6ab076e0a23eeda2a922fbb3e779486a81b37c96c11f63d7873","sliceHash":"e4357376b35865972053121b598b255836d940b6c9c6398b07e24e2721ba0fdc","surfaceHash":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust"} +napl-store module-state v1 559 +{"attribution":{"promptHash":"6b98457c4a34f4460528fd9867a16bd87069eb66875a351fb23205533e511bbe","record":"0f3756f4e4507027e1ed76f9e0ef890738b3fe60b53eae65468551943cb63929","stale":false},"contractVersion":4,"inputKey":"9c70814c6621c0bb8a416ee242b115b8d34b4676a32e5fa04cc2e0dcf2d3a915","module":"agent","outputTree":"c8cf73ffb7c118e5ad378bc58783365f8b56c59299ef2359aebad52205080d68","sliceHash":"ce078cd01e70ffc6eabe49a185eae3555d102b4a724398220ece69d06158f25f","surfaceHash":"ad1581ae209ba3feb19322408bfff544be7a29bc6a7db44c672b705f1afcd120","target":"rust"} +napl-store tree v1 627 +{"entries":[{"hash":"deff13703dd0b3c630077edaffafc1fa9f2fdfde297b9a84b45841a428715965","kind":"file","mode":33188,"name":"check.rs"},{"hash":"6c743bf47b6f7c61be2d365b61f7143d7d1045e5f683095cce63010c64586d56","kind":"file","mode":33188,"name":"docs.rs"},{"hash":"70f66c8e4c3cd38f3cda7cbf81fa3dfb8090b766f637732677c0ee9c246e3420","kind":"file","mode":33188,"name":"document.rs"},{"hash":"5ec2c2137f45d02c8e8bf78ecf0652966850772097b41f3835a99116f3b78f57","kind":"file","mode":33188,"name":"promptbuild.rs"},{"hash":"9d897617acebf69cdb6e944d077bbaf2c0441a4bf5c26615d86b3e250b06b900","kind":"file","mode":33188,"name":"query.rs"}]} +napl-store tree v1 136 +{"entries":[{"hash":"bf985f6e8dea8c5ba087d85225cf7c4b1cf05840e536dd8d4e03606bb9b177bf","kind":"dir","mode":16384,"name":"napl-store"}]} +napl-store receipt v1 4839 +{"baseStateKey":"3691606e4e8c5b310d3148cff5211f28da63b0d2dff14c4696b4b53c1d018e77","baseStoreSnapshot":"9ca97ad272ef096af5d4768c733e14afe852ae25a3e15c907441906d73534d79","baseWorkspaceHash":"7723df1c9a398203d03390eea12ea5e38c7e0fb4e9ee4b19001a4d0d6f389e0d","candidateTreeHash":"303b0798f7d68368d0e2fd049ea1822280309b6079e6f82306bc8e126cc71268","contractVersion":4,"inputKey":"2cc3d94395a8577715b137202cf95651e6aae2b8f87a8520f9934acfba5aa978","module":"napl","outputManifest":[{"hash":"1a5de49301e4c6d47619ec0e44eab914b1846735830b07483c031e6a8c3dc67b","path":"crates/napl/src/attribution.rs"},{"hash":"e539c65a4600ed1784b9cc176cb28f24c140f514f9783360f1f5a27142f89f68","path":"crates/napl/src/authoring.rs"},{"hash":"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2","path":"crates/napl/src/blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","path":"crates/napl/src/candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","path":"crates/napl/src/canon.rs"},{"hash":"0b8190e7989129e7baf4eed7046e19b7608067eb5d13bed95ad416118bf34a25","path":"crates/napl/src/capture.rs"},{"hash":"085f7e2d366c1756ff8f4e7bfdccbb8139c49779fd8ab00f91e3d6c787569c23","path":"crates/napl/src/cli.rs"},{"hash":"abad4283136e7fd0e880f49148d5422d673d5d1e4267976fd2a3dec47d7f34bd","path":"crates/napl/src/commandset.rs"},{"hash":"b151c3a18fb9dcae20fbaaddabd221bf11d62aa67578d2a100e67aa8b41408d4","path":"crates/napl/src/commit.rs"},{"hash":"51a5efd09235e6a66de7381faaa217a51136ca9c7d2b10285f99af8ebe4ffc6f","path":"crates/napl/src/compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","path":"crates/napl/src/cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","path":"crates/napl/src/decision.rs"},{"hash":"04a648a127be983c89a99181ee26e28401ee00f84ea0b10b6c050ecd263a1b2c","path":"crates/napl/src/directive.rs"},{"hash":"28059a89256aad1de78ecd31d7b77cae21772bbc6e96fe8ba1cdb9044048f410","path":"crates/napl/src/disk.rs"},{"hash":"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812","path":"crates/napl/src/error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","path":"crates/napl/src/evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","path":"crates/napl/src/graph.rs"},{"hash":"824c95b7229ac2ad5b1cc518b296443883fb56a110c86c2a2e67923c0a19ea07","path":"crates/napl/src/lint.rs"},{"hash":"40689b2560d6c445afd552c2f470ba3ce0d427622d96ed2cf2ad8cfdb4286321","path":"crates/napl/src/lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","path":"crates/napl/src/main.rs"},{"hash":"e9007f3c3ed117b885b5693d3b5aa763f563b986c61a05b4a77b70ca7193d3d5","path":"crates/napl/src/maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","path":"crates/napl/src/mv.rs"},{"hash":"3ae089b923327032845c386f2332ff653a08f25ccec28e0786094fa05fd77a00","path":"crates/napl/src/objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","path":"crates/napl/src/paths.rs"},{"hash":"7f7e00683ff02b159e26ddae0c5a328bc874f56440e769619915f14a5924a243","path":"crates/napl/src/porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","path":"crates/napl/src/receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","path":"crates/napl/src/slice.rs"},{"hash":"a24eb1b3fc59a87a34e7f343c3396da60352ece50b6688c36b04da55351f1312","path":"crates/napl/src/source.rs"},{"hash":"7d1bba27fd84f0edae130f1902b00abab524e3a67d0428b1a2fe7a2c01af5273","path":"crates/napl/src/staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","path":"crates/napl/src/store_view.rs"},{"hash":"1820f647bb5dbd74c8db7f5519d404ef0ce1520c833a579aaca893b5cad7ba08","path":"crates/napl/src/surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","path":"crates/napl/src/target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","path":"crates/napl/src/textdiff.rs"},{"hash":"ca46ca78bcb2940f23d3aede83fdee2d10b6910dfb0eaa9de6a8ca83a2209427","path":"crates/napl/src/transaction.rs"},{"hash":"d4ef5888a7ecc406d8eb5173dc215c7b1a1ef1e1ddd3ab8b3a8aa6639d125017","path":"crates/napl/src/transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","path":"crates/napl/src/trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","path":"crates/napl/src/version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","path":"crates/napl/src/workspace.rs"}],"receiptVersion":1,"sliceHash":"caa8549d185ee89e9676fdab8299d8d22f77d59fdb3543cbd32ad00cc145c694","target":"rust"} +napl-store blob v1 22930 +//! The canonical document, `docVersion 1`: one JSON value built at query time +//! from committed state, the value `napl q` evaluates against and `napl prompt` +//! materializes from. Everything here is a pure read: no store writes, no locks, +//! no clock, no randomness, no network. Every collection is totally ordered and +//! no absolute path appears, so the same committed state and the same working +//! tree yield the same document bytes on any machine. +//! +//! Reference staleness is the one fact read from the working tree as well as +//! from the records: a reference whose code side no longer matches the committed +//! bytes reads stale at both endpoints. It is computed here at read time and +//! never stored, so drift appears and heals without a transition. +//! +//! The working-tree view is the second stated exception. Asked for it +//! explicitly, the builder enumerates every prompt-derived fact from the on-disk +//! prompt bodies instead of the committed blobs, so a section or a case that +//! exists only in the working tree is served. Store-derived facts stay +//! committed, and no hash is invented for uncommitted bytes: a working-tree +//! section carries `workingTree: true` in place of a committed `head`. -::: test -name: a pending module names dependency-surface-moved as its reason -given: { workspace: fixture_chain_three, intervening_commit: leaf_surface_change, flag: module_middle } -expect: { pending: true, pending_reason: dependency_surface_moved } -::: +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; -::: test -name: a pending undrifted module says the landing re-stamps identical bytes -given: { workspace: fixture_chain_three, intervening_commit: leaf_surface_change, flag: module_middle } -expect: { pending: true, drift: false, reland_identical_bytes: true } -::: +use napl_store::store_hash::ObjectKind; +use serde_json::{json, Value}; -::: test -name: a never committed module reads pending with its reason -given: { workspace: fixture_two_modules, module: pending_never_committed, flag: module_pending } -expect: { pending: true, pending_reason: never_committed } -::: +use crate::attribution::{ + self, code_drifted, reference_status, Attribution, Direction, Entry, +}; +use crate::compile::compile; +use crate::directive::{extract_cases, DirectiveCase}; +use crate::disk::{self, Store}; +use crate::error::Result; +use crate::objects::{event_transition, read_module_state, snapshot_states, ModuleState}; +use crate::porcelain::{causal_order, status}; +use crate::receipt::{parse_receipt, receipt_semantic_hash}; +use crate::source::SourceModule; +use crate::store_view::{get_blob, get_json, get_object, has_object, ObjectSource}; +use crate::transport::{line_map, transport_range, Range, Transported}; +use crate::workspace::discover_sources; -::: test -name: a prompt edit reads pending as source-changed -given: { workspace: fixture_two_modules, working_tree_edit: prompt_body_touched, flag: module_edited } -expect: { pending: true, pending_reason: source_changed } -::: +/// The document schema version. Bumped only when the shape changes. +pub const DOC_VERSION: u32 = 1; -::: test -name: status reports a module plan derived from the working tree -given: { workspace: fixture_two_modules, flag: module_leaf } -expect: { plan_fields: [owned_globs, placement_root, receipt_requirements, wave_position], candidate_dependent_fields: absent } -::: +/// Build the canonical document for a workspace from committed records. +pub fn build_document(workspace: &Path) -> Result { + build_document_view(workspace, false) +} -::: test -name: status module answers live state and the derived projection beside the plan -given: { workspace: fixture_two_modules, flag: module_leaf, working_tree_edit: leaf_output_touched } -expect: { state_fields: [attribution, drift, pending, stale_references, tracked], drift: true, projection_path_present: true, projection_content_present: true } -::: +/// Build the canonical document, serving prompt-derived facts from the on-disk +/// prompt bodies when `working_tree` is set. Store-derived facts are committed +/// either way, so the two views differ only where the prompt does. +pub fn build_document_view(workspace: &Path, working_tree: bool) -> Result { + let store = disk::open(workspace); + let head = store.head()?; + let sources = discover_sources(workspace)?; + let target = "rust"; + let states = match &head { + Some(h) => snapshot_states(&store, h)?, + None => BTreeMap::new(), + }; + let artifact = compile(&store, &sources, target, None)?; + let pending: BTreeSet = artifact.slices.iter().map(|s| s.module.clone()).collect(); -::: test -name: show lists attached notes inline and log counts them -given: { workspace: fixture_two_modules, history: [commit, note_attached] } -expect: { show_notes_listed: 1, show_note_text_present: true, log_note_count: 1 } -::: + let mut names: BTreeSet = sources.iter().map(|s| s.module.clone()).collect(); + for (t, m) in states.keys() { + if t == target { + names.insert(m.clone()); + } + } -## Body directives + let usage_by_receipt = usage_index(&store)?; -The prompt body grammar reserves directive blocks, `directiveVersion 1`, -and recognizing them is part of the identity contract: text that older -binaries read as inert prose can be a compile error under this grammar, so -this recognition rides a CONTRACT_VERSION bump, the sanctioned identity -transition. The first directive is `test`. An opener is a line that is -exactly `::: test`; the closer is a line that is exactly `:::`; no -nesting. An unknown `::: ` opener is a compile error, never prose, -so future directive names are non-breaking. Directive blocks are body -text: they count in normalized body lines and attribution ranges may -cover them. + let mut modules: Vec = Vec::new(); + for name in &names { + let source = sources.iter().find(|s| &s.module == name); + let state = match states.get(&(target.to_string(), name.clone())) { + Some(h) => Some(read_module_state(&store, h)?), + None => None, + }; + modules.push(build_module( + workspace, + &store, + head.as_deref(), + name, + source, + state.as_ref(), + pending.contains(name), + working_tree, + )?); + } -Block content is one case in an exact value grammar mapping 1:1 onto -canonical JSON, so two binaries cannot disagree on a value: one mapping -with keys `name` (required, non-empty, single line), `given` (optional, -mapping), `expect` (required, mapping). Block mappings and sequences -indent by exactly two spaces; flow `{}` and `[]` forms are allowed. -Strings are double-quoted with JSON escapes, or plain scalars ended by a -structural character (`:`, `,`, `{`, `}`, `[`, `]`, `#`) or end of line -with no leading or trailing space. Numbers are JSON number syntax; -`true`, `false`, `null` are keywords; every other plain scalar is a -string. Nothing else from YAML exists here: no comments, no duplicate -mapping keys, no anchors, aliases, tags, block scalars, multiline -scalars, multi-document markers, octal or hex, timestamps, or merge keys. -A case needing prose belongs in prose. Diagnostics are compile errors -naming the exact body line, judged in this precedence: unclosed block, -unknown directive name, nested opener, content outside the value grammar, -missing or empty name, missing expect, duplicate case name within the -module across both positions after NFC normalization and whitespace trim. -Frontmatter `tests:` remains legal; a module's case set is the union of -both positions; extraction of cases from body bytes is deterministic — -same bytes, same cases. + let history = build_history(&store, head.as_deref(), &usage_by_receipt)?; + let state_block = status(workspace, target)?; -::: test -name: an unclosed test directive block is a compile error naming its opener line -given: { body: directive_without_closer } -expect: { outcome: compile_error, line_named: opener } -::: + let mut document = json!({ + "docVersion": DOC_VERSION, + "head": head.clone().map(Value::String).unwrap_or(Value::Null), + "history": history, + "modules": modules, + "state": state_block, + }); + if working_tree { + if let Some(object) = document.as_object_mut() { + object.insert("workingTree".to_string(), Value::Bool(true)); + } + } + Ok(document) +} -::: test -name: "an unknown directive name is a compile error, never prose" -given: { body: unknown_directive_opener } -expect: { outcome: compile_error, treated_as_prose: false } -::: +fn build_module( + workspace: &Path, + store: &Store, + head: Option<&str>, + name: &str, + source: Option<&SourceModule>, + state: Option<&ModuleState>, + pending: bool, + working_tree: bool, +) -> Result { + let prompt_path = source.map(|s| s.path.clone()); + let deps = source.map(|s| s.deps.clone()).unwrap_or_default(); + let targets = source.map(|s| s.targets.clone()).unwrap_or_default(); + let surface_hash = state.map(|s| s.surface_hash.clone()); + let mut frontmatter_cases = source.map(|s| s.frontmatter_cases.clone()).unwrap_or_default(); + frontmatter_cases.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); -::: test -name: a duplicate case name across frontmatter and body errors -given: { frontmatter_case: alpha, body_directive_case: alpha } -expect: { outcome: compile_error, diagnostic: duplicate_case_name } -::: + let status = attribution_status(name, source, state); + let record = committed_attribution(store, state)?; + let drift = working_drift(workspace, store, state)?; + let committed_body = committed_body(store, state)?; + let (sections, outline_body) = if working_tree { + let cases = source.map(|s| s.directive_cases.clone()).unwrap_or_default(); + let body = source.map(|s| s.normalized_body.clone()).unwrap_or_default(); + let sections = build_working_tree_sections( + name, + prompt_path.as_deref(), + committed_body.as_deref().unwrap_or(""), + &body, + record.as_ref(), + status == "stale", + &cases, + &drift, + ); + (sections, body) + } else { + let cases = committed_body + .as_deref() + .and_then(|body| extract_cases(body).ok()) + .unwrap_or_default(); + let sections = build_sections( + head, + name, + prompt_path.as_deref(), + record.as_ref(), + status == "stale", + &cases, + &drift, + ); + (sections, committed_body.clone().unwrap_or_default()) + }; + let files = owned_files(store, state)?; -::: test -name: directive case extraction is deterministic -given: { body: fixture_with_two_directives, runs: 2 } -expect: { cases: 2, bytes_equal: true } -::: + Ok(json!({ + "attribution": { "status": status }, + "deps": deps, + "files": files, + "frontmatterCases": frontmatter_cases, + "name": name, + "outline": outline(&outline_body), + "pending": pending, + "promptPath": prompt_path, + "sections": sections, + "surfaceHash": surface_hash, + "targets": targets, + })) +} -## Recorded surfaces +/// The prompt body's headings in body order, each with its level and its +/// body-normalized 1-based line. +/// +/// This exists so "which body line is this heading on" is a query rather than a +/// raw file read: the whole point of the query surface is that nobody has to open +/// a prompt and count. A heading is an ATX heading, one to six `#` followed by a +/// space; nothing else in markdown names a section. +fn outline(body: &str) -> Vec { + let mut out: Vec = Vec::new(); + for (index, line) in body.lines().enumerate() { + let hashes = line.chars().take_while(|c| *c == '#').count(); + if hashes == 0 || hashes > 6 { + continue; + } + let Some(text) = line[hashes..].strip_prefix(' ') else { + continue; + }; + out.push(json!({ + "level": hashes as u64, + "line": (index + 1) as u64, + "text": text.trim_end(), + })); + } + out +} -At commit, extract the candidate's public API with tree-sitter (pinned -grammars) and record it as a `surface` object; dependents' inputKeys pin -recorded surfaceHashes, store facts, never demand scans. The surface -grammar makes extraction sound by construction and violations reject as -`mapping-integrity`: for Rust, public items must be named `pub` -declarations at statically visible paths, no glob re-exports, no -macro-generated `pub` items; a named `pub use` is allowed and is recorded -as the unresolved written binding, never a resolved signature. For -TypeScript: explicit named exports only, no `export *`. Extraction is -syntactic, deterministic, and loud on malformed input. Loud means a -payload, never silence: extraction over bytes that do not parse -answers a parse failure naming the file and the first error's line, -distinct from a genuinely empty surface, in every seat that extracts, -the commit recorder, the `surface` read, and the query layer, so an -empty item list always means parsed and nothing public. Re-extraction from -committed output must reproduce the recorded surface byte-identically. +/// A module's attribution freshness, a pure observation over committed content +/// and the current prompt body. +fn attribution_status(_name: &str, source: Option<&SourceModule>, state: Option<&ModuleState>) -> String { + let Some(attribution) = state.and_then(|s| s.attribution.as_ref()) else { + return "absent".to_string(); + }; + let current = source.map(|s| s.normalized_source_hash.as_str()); + if attribution.stale || current != Some(attribution.prompt_hash.as_str()) { + "stale".to_string() + } else { + "fresh".to_string() + } +} -The `surface` read is honest about absence the same way: a module with -no committed state under the target answers `tracked: false` naming -the absent baseline, never an empty item list that reads as a real -surface with nothing public. +/// The committed attribution record of a module, parsed from its record blob. +fn committed_attribution(store: &Store, state: Option<&ModuleState>) -> Result> { + let Some(attribution) = state.and_then(|s| s.attribution.as_ref()) else { + return Ok(None); + }; + if !has_object(store, &attribution.record) { + return Ok(None); + } + let bytes = get_blob(store, &attribution.record)?; + Ok(attribution::parse(&String::from_utf8_lossy(&bytes))) +} -::: test -name: extraction over unparseable bytes answers parse-failed never an empty surface -given: { source_bytes: syntactically_invalid_rust, extraction: rust_surface } -expect: { outcome: parse_failed, error_names_line: true, served_as_empty_surface: false } -::: +/// The module's committed normalized prompt body, as the bytes the attribution +/// was proven against. Cases and the heading outline both derive from it, and so +/// does the diff a working-tree read transports claims through. +fn committed_body(store: &Store, state: Option<&ModuleState>) -> Result> { + let Some(attribution) = state.and_then(|s| s.attribution.as_ref()) else { + return Ok(None); + }; + if !has_object(store, &attribution.prompt_hash) { + return Ok(None); + } + let bytes = get_blob(store, &attribution.prompt_hash)?; + Ok(Some(String::from_utf8_lossy(&bytes).into_owned())) +} -::: test -name: surface for an uncommitted module says untracked never an empty list -given: { workspace: fixture_two_modules, module: pending_never_committed, read: surface_for_target } -expect: { tracked: false, reason_names_baseline: true, empty_item_list_served: false } -::: +/// Working-tree drift per owned path: true when the bytes on disk no longer hash +/// to the committed blob. This is read at query time and never stored, so a hand +/// edit to generated source shows up as drift the moment it is made and +/// disappears the moment it is healed. +fn working_drift( + workspace: &Path, + store: &Store, + state: Option<&ModuleState>, +) -> Result> { + let mut out: BTreeMap = BTreeMap::new(); + let Some(state) = state else { + return Ok(out); + }; + if !has_object(store, &state.output_tree) { + return Ok(out); + } + for (path, (blob, _mode)) in crate::store_view::flatten_tree(store, &state.output_tree)? { + let bytes = std::fs::read(workspace.join(&path)).ok(); + out.insert(path, code_drifted(Some(blob.as_str()), bytes.as_deref())); + } + Ok(out) +} -::: test -name: a candidate with a glob re-export is rejected as mapping-integrity -given: { workspace: fixture_two_modules, candidate: contains_glob_reexport } -expect: { outcome: mapping_integrity, published_semantic_state: false } -::: +/// The provenance a reader is served for one entry: every field present, every +/// absent field spelled `unavailable` rather than zero or invented. A recorded +/// usage window is served with its structure intact (media type, log path, +/// session, and line span) because it is a reference to be priced later and not +/// a total already taken. +fn provenance_json(entry: &Entry) -> Value { + entry.provenance.served_json() +} -::: test -name: a named pub use is recorded in the surface as the written binding -given: { workspace: fixture_two_modules, candidate: valid_with_named_reexport } -expect: { surface_contains_written_binding: true, surface_contains_resolved_signature: false } -::: +fn build_sections( + head: Option<&str>, + module: &str, + prompt_path: Option<&str>, + record: Option<&Attribution>, + stale: bool, + cases: &[DirectiveCase], + drift: &BTreeMap, +) -> Vec { + let head_v = head.map(|h| Value::String(h.to_string())).unwrap_or(Value::Null); + let entries: &[Entry] = record.map(|r| r.entries.as_slice()).unwrap_or(&[]); + let mut covered: Vec = vec![false; cases.len()]; + let mut sections: Vec<(u32, u32, Value)> = Vec::new(); -::: test -name: recorded surface re-extraction from committed output is byte-identical -given: { workspace: fixture_two_modules, candidate: valid_for_consumer } -expect: { reextraction_equal: true } -::: + for entry in entries { + let (start, end) = (entry.prompt_start, entry.prompt_end); + let mut section_cases: Vec = Vec::new(); + for (idx, case) in cases.iter().enumerate() { + if ranges_intersect(case.line_start, case.line_end, start, end) { + covered[idx] = true; + section_cases.push(case_json(case)); + } + } + section_cases.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + let files = match &entry.file { + Some(file) => vec![json!({ + "head": head_v, + "lines": [entry.line_start, entry.line_end], + "path": file, + })], + None => Vec::new(), + }; + let code_drift = entry + .file + .as_ref() + .and_then(|file| drift.get(file).copied()) + .unwrap_or(false); + let reference = reference_status(stale, false, code_drift); + sections.push(( + start, + end, + json!({ + "associationStale": stale, + "cases": section_cases, + "direction": entry.direction.as_str(), + "files": files, + "head": head_v, + "law": entry.file.is_none(), + "lines": [start, end], + "module": module, + "note": entry.note, + "promptPath": prompt_path, + "provenance": provenance_json(entry), + "referenceStatus": reference.as_str(), + "staleEndpoints": reference.endpoints, + }), + )); + } -## add, then commit + // A directive case intersecting no attribution entry lands in a synthetic + // section that owns no file. + for (idx, case) in cases.iter().enumerate() { + if covered[idx] { + continue; + } + let reference = reference_status(stale, false, false); + sections.push(( + case.line_start, + case.line_end, + json!({ + "associationStale": stale, + "cases": [case_json(case)], + "direction": Direction::default().as_str(), + "files": [], + "head": head_v, + "law": false, + "lines": [case.line_start, case.line_end], + "module": module, + "note": "", + "promptPath": prompt_path, + "provenance": provenance_json(&Entry::default()), + "referenceStatus": reference.as_str(), + "staleEndpoints": reference.endpoints, + }), + )); + } -Git parity is the mental model: `add` stages, `commit` installs. The -receipt is an internal record between them, never a user-facing noun. -`napl commit` with no arguments installs every staged record in -dependency-wave order, rebasing where the decision table allows, one -outcome reported per record; `--staged ` installs one. A -multi-module add followed by a bare commit is therefore one command -each, with the sequencing and retry the table already defines owned by -the tool, never hand-looped; and when an earlier record in the same -invocation moves the head so that a later one returns write-conflict -or stale-input against it, bare commit re-derives that record against -the new head, exactly what re-running add would produce, and retries -it once, so modules sharing one crate land in one invocation instead -of hand-ordered pairs. A retry that fails again returns its outcome -honestly. A bare commit that finds nothing staged says so instead of -dressing emptiness as success: the payload names that zero records -were staged, and when any module carries working-tree drift it names -each drifted module beside the exact `napl add` invocation that -would stage it, so the empty commit is the teacher, never the trap. -Commit sweeps the staged record it installed, so a later -bare commit never re-attempts history; `napl gc` reports staged -records among the unreachable, and `napl gc --apply` sweeps them: -they are disposable litter by the git-state law, re-creatable by -re-running add, and no one should ever hand-edit `.napl` to be rid of -them. + sections.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1))); + sections.into_iter().map(|(_, _, v)| v).collect() +} -`napl add` stages a module state from any of three sources, and the -working tree is the default one, exactly as in git: +/// The heading blocks of a normalized prompt body, in body order: the preamble +/// before the first `## ` heading, then one block per heading running to the +/// line before the next. A body with no heading is one block. This is what a +/// section is when the enumeration comes from the prompt itself rather than from +/// a committed attribution record, so a heading added in the working tree is a +/// section the moment it is typed. +fn body_blocks(body: &str) -> Vec<(u32, u32)> { + let lines: Vec<&str> = body.lines().collect(); + let total = lines.len() as u32; + if total == 0 { + return Vec::new(); + } + let starts: Vec = lines + .iter() + .enumerate() + .filter(|(_, line)| line.starts_with("## ")) + .map(|(index, _)| (index + 1) as u32) + .collect(); + let mut blocks: Vec<(u32, u32)> = Vec::new(); + let first = starts.first().copied().unwrap_or(total + 1); + if first > 1 { + blocks.push((1, first - 1)); + } + for (index, start) in starts.iter().enumerate() { + let end = starts.get(index + 1).map(|next| next - 1).unwrap_or(total); + blocks.push((*start, end)); + } + blocks +} -- `napl add [...]` names working-tree files. Each path - resolves to its owning module through the owned globs (unique by - commit invariant, so no `--module` is needed), and the staged state - is that module's committed output with exactly the named files - overlaid from the working tree. Paths owned by different modules - stage one record per module in one invocation. A path no module owns - is a usage error naming the owned globs that do exist. -- `napl add --module ` stages the module's whole owned tree as - it sits in the working tree. -- `napl add --module ` stages explicit candidate - bytes, for the rare case where the bytes to stage are not the - working tree. +/// Sections enumerated from the on-disk prompt body, with file associations +/// transported rather than re-derived. +/// +/// Each block carries the working-tree cases whose range it contains, so a +/// section typed a moment ago is a section. Its files come from the committed +/// map's claims transported through the committed-to-pending prompt diff, the same +/// deterministic transport `add` uses, and a claim only lands in the block that +/// contains the range the transport proved. A claim the transport could not carry +/// serves no files at all and marks its block `associationUnproven: true`, located +/// at the last line the diff proves survived at or before it. +/// +/// What this deliberately does not do is attach files by range arithmetic over the +/// re-anchored body. An overlap that happens to exist between a stale recorded +/// range and a shifted block is not a claim anybody made, and serving it as one +/// would invent a mapping: that is exactly how a view once attached a watcher's +/// file to an `init` section after the ranges moved underneath it. +#[allow(clippy::too_many_arguments)] +fn build_working_tree_sections( + module: &str, + prompt_path: Option<&str>, + committed_body: &str, + body: &str, + record: Option<&Attribution>, + stale: bool, + cases: &[DirectiveCase], + drift: &BTreeMap, +) -> Vec { + let entries: &[Entry] = record.map(|r| r.entries.as_slice()).unwrap_or(&[]); + let map = line_map(committed_body, body); + // Each recorded claim is carried once, up front, so every block reads the + // same verdict and no block can disagree with another about one claim. + let carried: Vec<(&Entry, Transported)> = entries + .iter() + .map(|entry| { + let range = Range::new(entry.prompt_start, entry.prompt_end); + (entry, transport_range(&map, range)) + }) + .collect(); -All three forms feed one pipeline with `[--attribution ] -[--usage ]`: ingest the staged bytes into immutable content -addressed blobs (reject links, devices, duplicate normalized paths, -absolute or parent-escaping paths, oversized entries or totals; all -later steps read ingested blobs, never the source files); parse every -staged file the target claims as source with the target's pinned -tree-sitter grammar, and refuse as `invalid-candidate` when any parse -tree carries an error node, naming the file, the first error's line, -and the malformed region, because a surface extracted from a broken -tree is not a fact worth recording; the gate is syntax only, judged by -the same grammar the surface extractor uses, and everything past -syntax, types, borrows, semantics, stays the acceptance battery's job; -commit re-runs the same gate when it recomputes the staged record, so -bytes that do not parse can never install through any door; compute -the slice and validate against it; resolve attribution as below; validate -and attach usage evidence when supplied; then write the staged record — -the internal receipt: identities generated against, base snapshot -hashes, candidate tree hash, output manifest, attribution, optional -reported fields. Validation failures surface at add time with the same -outcome classes commit uses. Staged records are transient coordination -state, disposable litter by the git-state law: never promoted to -history, safely deletable, re-creatable by re-running add. + let mut sections: Vec = Vec::new(); + for (start, end) in body_blocks(body) { + let mut section_cases: Vec = cases + .iter() + .filter(|case| ranges_intersect(case.line_start, case.line_end, start, end)) + .map(case_json) + .collect(); + section_cases.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); -Attribution transports instead of being demanded. Add computes the -byte diff between the module's committed output and the staged bytes, -and the prompt's committed body and its pending body, and transports -every committed attribution claim through those diffs -deterministically: a range the diff proves untouched survives -verbatim, a range below an insertion or deletion shifts by its size, -and a claim landing in a rewritten or ambiguous region does not -transport, because the transport never guesses; a range it cannot -prove through the diff is asked for, never invented. When every claim -transports, add stages with the transported map and no `--attribution` -flag is needed: the paperwork updates itself as a consequence of the -diff already being content-addressed on both sides. When any claim -fails to transport, add refuses as `invalid-candidate` listing exactly -the claims that need affirmation, each beside the observed bound or -region it was checked against, and `--attribution` supplies -affirmations for the listed claims only, layered over the transported -remainder. Any attribution refusal, transport or explicit, names the -observed bound (the actual line count or surviving range) next to the -rejected claim, so a wrong range never costs a manual count. Each -listed claim also carries its index in the recorded map and its note -verbatim, so an affirmation is written against named prose, never -positional trust or a separate query join. + let proven: Vec<&Entry> = carried + .iter() + .filter(|(_, outcome)| match outcome.proven() { + Some(range) => range.start >= start && range.end <= end, + None => false, + }) + .map(|(entry, _)| *entry) + .collect(); + let unproven = carried.iter().any(|(_, outcome)| match outcome { + Transported::Unproven { anchor, .. } => { + anchor.map(|line| line >= start && line <= end).unwrap_or(false) + } + Transported::Proven(_) => false, + }); -::: test -name: a transport refusal lists each claim with its map index and note -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } -expect: { listed_claims: 1, claim_index_present: true, claim_note_verbatim: true } -::: + let mut files: Vec<(String, u32, Value)> = if unproven { + Vec::new() + } else { + proven + .iter() + .filter_map(|entry| { + entry.file.as_ref().map(|file| { + ( + file.clone(), + entry.line_start, + json!({ + "lines": [entry.line_start, entry.line_end], + "path": file, + "workingTree": true, + }), + ) + }) + }) + .collect() + }; + files.sort_by(|a, b| (&a.0, a.1).cmp(&(&b.0, b.1))); + let files: Vec = files.into_iter().map(|(_, _, value)| value).collect(); -A module's first add has no committed map to transport, and it demands -no hand-authored one either: absent `--attribution`, add stages the -trivial whole map, every staged file claimed in full by the whole -prompt body, recorded as whole-file claims and honestly coarse. The -cold path to a verified first commit is therefore write the prompt, -write the code, `add --module`, `commit`, with attribution present and -fresh, never `absent`, and `--no-verify` never required to get started. -A claim, in any map, may be spelled whole: naming a file with no -numbers claims all of it, and naming a prompt range as whole claims -the whole body; bounds are derived at validation time from the staged -blob and the pending body, so no line count is ever transcribed by -hand and a whole-file claim survives any growth of its file. + let code_drift = proven.iter().any(|entry| { + entry + .file + .as_ref() + .and_then(|file| drift.get(file).copied()) + .unwrap_or(false) + }); + let reference = reference_status(stale, false, code_drift); + let law = !proven.is_empty() && proven.iter().all(|entry| entry.file.is_none()); + let fallback = Entry::default(); + let representative = proven.first().copied().unwrap_or(&fallback); -Two more spellings close the remaining gaps: `napl add ---module ` stages a path under a module explicitly, for the file -that is new in a crate several modules share, where neither the -committed manifest nor the globs can disambiguate it alone; and -`napl add --check` with any form runs the entire pipeline, transport -verdict and refusal listing included, and writes nothing, not even a -staged record, the dry run that answers before anything lands. The -check verdict says exactly what it judged and what it did not: the -payload enumerates the validations that ran (placement, ingest rules, -the parse gate, attribution transport, evidence) and names what stays -unjudged, everything past syntax, the acceptance battery's job, so -`would-stage` means the record is sound and the syntax parses, never -that the code is correct. A transport refusal -reports each endpoint's verdict separately: a claim whose prompt side -failed but whose code side transported serves the transported code -range beside the failed prompt range, and the affirmation supplies -only the side that actually needs a human, never both because one -refused. An unparseable or wrong-shaped `--attribution` file is -answered like a bad ref payload: the refusal carries the complete map -contract, every field and one complete valid example, never a bare -"not parseable". The map reader speaks the same value grammar as body -directives, so block and flow spellings of one map are one map: -`lines: [4, 9]` and its two-line block form parse identically. A map -that parses but yields no entries, or that fails at any construct, is -refused naming the exact line and the construct that stopped the -reader, beside the full contract, never a bare count of zero. The -contract is also taught before any failure: `help add` and -`add --help` serve the same complete map contract and worked example -the refusal carries, so the map is learnable from help instead of -from trial and error. + sections.push(json!({ + "associationStale": stale, + "associationUnproven": unproven, + "cases": section_cases, + "direction": representative.direction.as_str(), + "files": files, + "law": law, + "lines": [start, end], + "module": module, + "note": representative.note, + "promptPath": prompt_path, + "provenance": provenance_json(representative), + "referenceStatus": reference.as_str(), + "staleEndpoints": reference.endpoints, + "workingTree": true, + })); + } -::: test -name: flow and block spellings of one attribution map parse identically -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, maps: [flow_sequences, block_sequences] } -expect: { parsed_entries_equal: true, both_stage: true } -::: + sections +} -::: test -name: a map failing mid-parse names the line and construct beside the contract -given: { workspace: fixture_two_modules, attribution_file: bad_construct_on_line_3 } -expect: { error_names_line: 3, error_names_construct: true, error_carries_valid_example: true } -::: +fn case_json(case: &DirectiveCase) -> Value { + json!({ + "expect": case.expect, + "given": case.given, + "lines": [case.line_start, case.line_end], + "name": case.name, + }) +} -::: test -name: add help carries the complete attribution map contract and example -given: { workspace: fixture_two_modules, invocation: "help add" } -expect: { map_fields_listed: true, map_example_present: true, exit_code: 0, store_bytes_unchanged: true } -::: +fn ranges_intersect(a_start: u32, a_end: u32, b_start: u32, b_end: u32) -> bool { + a_start <= b_end && b_start <= a_end +} -::: test -name: a first add with no flag stages the whole map and commits verified -given: { workspace: fresh_init_root_prompt, candidate: valid_root_level, attribution_flag: absent } -expect: { add_outcome: staged, map_kind: whole_file_claims, commit_outcome: success, attribution: fresh, no_verify_used: false } -::: +/// The module's owned output paths, sorted. +fn owned_files(store: &Store, state: Option<&ModuleState>) -> Result> { + let Some(state) = state else { + return Ok(Vec::new()); + }; + if !has_object(store, &state.output_tree) { + return Ok(Vec::new()); + } + let mut paths: Vec = crate::store_view::flatten_tree(store, &state.output_tree)? + .into_keys() + .collect(); + paths.sort(); + Ok(paths) +} -::: test -name: a whole spelling claims the entire file and survives file growth -given: { workspace: fixture_two_modules, map_entry: whole_file_no_numbers, then: file_grows } -expect: { claim_valid: true, bounds_derived: true, retranscription_needed: false } -::: +/// The causal history reachable from head, oldest first, each entry carrying its +/// validated usage or null. +fn build_history( + store: &Store, + head: Option<&str>, + usage_by_receipt: &BTreeMap, +) -> Result> { + let Some(head) = head else { + return Ok(Vec::new()); + }; + let mut entries: Vec = Vec::new(); + for event in causal_order(store, head)? { + let transition_id = event_transition(store, &event)?; + let transition = get_json(store, &transition_id, ObjectKind::Transition)?; + let receipt = transition["receipt"].as_str().unwrap_or_default(); + let usage = usage_by_receipt.get(receipt).cloned().unwrap_or(Value::Null); + entries.push(json!({ + "module": transition["module"], + "outcome": "installed", + "receipt": receipt, + "target": transition["target"], + "transition": transition_id, + "usage": usage, + })); + } + Ok(entries) +} -::: test -name: add check reports the full verdict and writes nothing -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_check } -expect: { verdict_listed_claims: 1, staged_records: 0, staging_dir_untouched: true, store_bytes_unchanged: true } -::: +/// A map from a transition's semantic receipt hash to the validated usage a full +/// receipt reported. Absent telemetry is never fabricated, so only receipts with +/// a real usage block appear here; everything else reads as null. +fn usage_index(store: &Store) -> Result> { + let mut out: BTreeMap = BTreeMap::new(); + for name in store.names()? { + let Ok((kind, payload)) = get_object(store, &name) else { + continue; + }; + if kind != ObjectKind::Receipt { + continue; + } + let Ok(receipt) = parse_receipt(&payload) else { + continue; + }; + let usage = &receipt.reported["usage"]; + if !usage.is_object() { + continue; + } + let normalized = json!({ + "cacheRead": usage.get("cacheRead").cloned().unwrap_or(Value::Null), + "input": usage.get("input").cloned().unwrap_or(Value::Null), + "model": usage.get("model").cloned().unwrap_or(Value::Null), + "output": usage.get("output").cloned().unwrap_or(Value::Null), + }); + out.insert(receipt_semantic_hash(&receipt), normalized); + } + Ok(out) +} +napl-store blob v1 3673 +# commands: init and watch + +Two porcelain additions to the `napl` binary, each a thin composition +over the same napl-core derivations and napl-store reads the other +commands use. They live in this module and compile into the same crate as +the core toolchain. The query surface that used to live here belongs to +the `query` module. -::: test -name: an explicit module stages a new path in a shared crate -given: { workspace: fixture_shared_crate_two_modules, working_tree: new_uncommitted_file_in_shared_glob, add_path_with_module: true } -expect: { add_outcome: staged, module_as_named: true } -::: +## napl init -::: test -name: add of one working-tree path stages the committed state with that file overlaid -given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, add_paths: [edited_file] } -expect: { add_outcome: staged, staged_state: committed_plus_overlay, unnamed_files_at_committed_bytes: true } -::: +`napl init` bootstraps a workspace: it creates the records directory +`.napl/` with an empty store (`store/` skeleton, no HEAD, no objects) in +the current directory when no ancestor workspace exists. Running it inside +an existing workspace changes nothing and reports `already_initialized`; +it never creates a nested workspace under another workspace and never +touches an existing store's bytes. The result of init is exactly the +state a fresh `napl compile` expects: head absent, no modules recorded. +Like `git init`, the directory it runs in is the whole story: the +success payload names that directory as the workspace root, there is +nothing to configure, and a prompt written beside `.napl` is already +inside placement. Init accepts no flags beyond the shared help flag; +anything else is a usage error under the command line contract. The +payload also teaches the first mile: a `nextSteps` field carries one +complete minimal prompt, frontmatter fences with `module`, `deps`, and +`targets` plus one body line, the fact that discovery finds prompts by +the `.napl` extension, and the exact command sequence from writing that +prompt through `add` and `commit`, so a cold user never reverse +engineers the grammar one validation error at a time. The sequence +ends with one forward pointer: lint needs a `roots` declaration +before it can judge reachability, named here so its later refusal is +a reminder, never a first encounter. ::: test -name: paths owned by two modules stage one record per module -given: { workspace: fixture_two_modules, working_tree_edit: both_modules_touched, add_paths: [file_of_leaf, file_of_consumer] } -expect: { staged_records: 2, modules: [consumer, leaf] } +name: init in an empty directory creates the records skeleton with no head +given: { directory: empty } +expect: { created: [".napl/store"], head: absent, exit_zero: true, workspace_root_reported: true } ::: ::: test -name: a path no module owns is a usage error naming the owned globs -given: { workspace: fixture_two_modules, add_paths: [unowned_file] } -expect: { exit_code: 2, error_names_owned_globs: true, staged_records: 0 } +name: init teaches the first mile with a complete minimal prompt and the command sequence +given: { directory: empty } +expect: { next_steps_prompt_passes_check: true, extension_named: ".napl", sequence_named: [write_prompt, add, commit] } ::: ::: test -name: a pure line-shift edit transports the whole map and needs no attribution flag -given: { workspace: fixture_two_modules, working_tree_edit: insert_lines_above_attributed_range, add: module_form_without_attribution } -expect: { add_outcome: staged, map_transported: true, ranges_shifted: true, attribution_flag_needed: false } +name: init where a workspace already exists changes nothing and says so +given: { directory: existing_workspace } +expect: { changed_files: [], outcome: already_initialized, exit_zero: true } ::: ::: test -name: a claim in a rewritten region refuses listing that claim with its observed bound -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } -expect: { outcome: invalid_candidate, listed_claims: 1, observed_bound_present: true, untouched_claims_not_listed: true } +name: init never overwrites an existing store +given: { directory: existing_workspace_with_commits } +expect: { store_bytes_unchanged: true } ::: -::: test -name: affirmations layer over the transported remainder and stage -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_affirmation_for_listed_claim } -expect: { add_outcome: staged, transported_claims_kept: true, affirmed_claim_recorded: true } -::: +## napl watch -::: test -name: add stages a valid candidate and commit installs it -given: { workspace: fixture_two_modules, candidate: valid_with_attribution } -expect: { add_outcome: staged, commit_outcome: success } -::: +`napl watch` is an I/O shell around the pure compile derivation: it +observes the workspace's `*.napl` files and re-runs the derivation when +their content changes, emitting each result to stdout as canonical JSON. It publishes nothing: no semantic writes, no store +mutation, no locks held between runs. Change detection is by content +hash, never by timestamp, so an editor save that leaves bytes identical +produces no recompile. A malformed prompt is reported as a diagnostic in +the output stream and the watcher keeps running. Stopping the process is +the only way to end it, and stopping it loses nothing. ::: test -name: an explicit candidate whose transport is incomplete and unaffirmed fails loudly -given: { workspace: fixture_two_modules, candidate: rewritten_region_without_affirmation } -expect: { outcome: invalid_candidate, listed_claims_present: true } +name: watch re-derives compile output when a prompt changes and writes nothing semantic +given: { workspace: fixture_two_modules, edit: touch_leaf_prompt } +expect: { recompiled: true, semantic_state_published: false } ::: ::: test -name: a candidate with a parent-escaping path is rejected as invalid-candidate -given: { workspace: fixture_two_modules, candidate: contains_dotdot_path } -expect: { outcome: invalid_candidate, published_semantic_state: false } +name: watch survives a malformed prompt edit and reports it instead of dying +given: { workspace: fixture_two_modules, edit: break_leaf_frontmatter } +expect: { process_alive: true, diagnostic_reported: true } ::: +napl-store event v1 158 +{"content":"61e8b6efad6476fce80ae02829837faedd4a8659d47c23e9d69d77eed1c47dbb","parents":["20b2e6829a8ebdfd38ef09505a5a4f978dd5c83f40711ec1cd09bfbd0ecc4372"]} +napl-store blob v1 14306 +//! The `.napl` source model: frontmatter and normalized body. +//! +//! A source file is YAML frontmatter (`module`, `deps`, `targets`, `tests`, and +//! the optional `roots` declaration) followed by a markdown body. The +//! frontmatter grammar is a deliberately small subset: a scalar and a handful of +//! string sequences, in flow or block form, plus a block sequence of mappings +//! for `tests`. Anything else is an error rather than a guess, because a guess +//! would make the dependency graph depend on parser mood. -::: test -name: tampering a staged blob after ingestion never mixes into the install -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, tamper: candidate_file_after_ingest } -expect: { outcome_in: [installed_from_immutable_blobs, store_integrity], mixed_content: false } -::: +use std::collections::BTreeSet; -`napl commit --staged ` is the one guarded -transaction over what add staged. Pipeline: verify the staged record -(recompute sliceHash, inputKey, baseStateKey at the recorded bases, -reject mismatches, enforce evidence bounds); extract and record the -surface; derive the complete transition (three-part footprint, member-set -delta, shared projections recomputed from the merged head); stage all -materialization outside any lock; then one bounded critical section under -the pointer lock performing only the footprint recheck and the pointer -and directory swaps, bounded by the transition's file count, never -hashing content inside the lock. +use napl_store::store_hash::{object_name, ObjectKind}; -::: test -name: a crash between staged materialization and pointer swap leaves a deterministic recovery plan -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, crash_after: staging } -expect: { store_readable: true, recovery_plan_deterministic: true, semantic_state_unchanged: true } -::: +use crate::error::{error, OutcomeCode, Result}; -::: test -name: a receipt whose recomputed slice hash disagrees is rejected as invalid-receipt -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: tampered_slice_hash } -expect: { outcome: invalid_receipt, published_semantic_state: false } -::: +/// The file extension discovery looks for. +pub const SOURCE_EXTENSION: &str = "napl"; -The decision table, judged per arriving transition against the current -head: identities reproduce with head unmoved installs; head moved with -identities reproducing and footprints disjoint rebases then installs -(projections re-derived, never reused); inputKey failure returns -`stale-input`; baseStateKey failure returns `base-output-changed`; semantic -write overlap returns `write-conflict`; lock contention returns -`lock-contended`. Terminal outcomes, never retried unchanged: -`invalid-candidate`, `invalid-receipt`, `mapping-integrity`, -`resource-limit`, `unsupported-version`, `store-integrity`, `io-error`. -Every outcome is a canonical machine-readable JSON payload naming whether -blobs or provenance were published; no outcome publishes semantic state -partially. Exit codes: 0 success, 10 through 13 for the four returned -concurrency outcomes in table order, 20 through 26 for the seven terminal -outcomes in the order above. Staged records carry `receiptVersion`, the -per-module identities generated against, base snapshot hashes, the -candidate tree hash, the output manifest, then optional reported fields; -the compiler recomputes everything derivable and rejects on mismatch; -absent telemetry is `unavailable`, never zero. `napl notes add --to -` attaches later provenance without touching semantic state. +/// Largest source file discovery will accept, in bytes. +pub const MAX_SOURCE_BYTES: usize = 4 * 1024 * 1024; -::: test -name: commit installs a valid candidate against an unmoved head -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, head: unmoved } -expect: { outcome: installed, head_advanced: true, surface_recorded: true } -::: +/// The smallest prompt this reader accepts, complete and valid: both frontmatter +/// fences, the three required keys, and one body line. +/// +/// It lives here, beside the reader that judges it, because two commands teach +/// with it and neither may drift from the grammar: `napl init` puts it in the +/// success payload's next steps, and the missing-fence finding of `napl check` +/// embeds it inline, so the first error a cold user sees carries the whole +/// grammar they were missing. The module name is a placeholder the writer +/// replaces; every other byte is the shape. +/// +/// The frontmatter declares `roots` naming its own module, so the very first +/// workspace is lint-ready: reachability analysis refuses without a roots +/// declaration, and a minimal prompt that omits one hands the cold user a +/// workspace that fails the first lint they run. Declaring it here turns lint's +/// prerequisite into a reminder they have already met rather than a first +/// encounter with a concept nothing introduced. +pub const MINIMAL_PROMPT: &str = "---\nmodule: example\ndeps: []\nroots: [example]\ntargets: [rust]\n---\nOne sentence of specification prose.\n"; -::: test -name: a bare commit installs every staged record in wave order -given: { workspace: fixture_two_modules, staged: [record_for_leaf, record_for_consumer], commit_args: none } -expect: { outcomes: [success, success], order: wave_order, hand_sequencing_needed: false } -::: +/// One parsed `.napl` source file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceModule { + /// Declared module name; also the placement directory for built-in targets. + pub module: String, + /// Workspace-relative canonical path of the source file. + pub path: String, + /// Declared direct dependencies, sorted and deduplicated. + pub deps: Vec, + /// Declared targets, sorted and deduplicated. + pub targets: Vec, + /// Declared test names, sorted and deduplicated. + pub tests: Vec, + /// The frontmatter `tests:` cases in declared order, each a canonical JSON + /// object `{name, given, expect}` (an omitted `given`/`expect` is `null`). + /// These have no body position; they join the body directive cases in the + /// module's case set until migrated into `::: test` blocks. + pub frontmatter_cases: Vec, + /// The declared reachability roots, present only where the file declares + /// them. `None` is what makes the unreachable-module analysis refuse. + pub roots: Option>, + /// The body after normalization. + pub normalized_body: String, + /// Blob hash of the normalized body. + pub normalized_source_hash: String, + /// The `::: test` directive cases extracted from the normalized body, in + /// body order. A malformed directive block is a parse error, so a source + /// that parses always carries a well-formed case set. + pub directive_cases: Vec, +} -::: test -name: bare commit re-derives and lands records for modules sharing one crate -given: { workspace: fixture_shared_crate_two_modules, staged: [record_for_first, record_for_second], commit_args: none } -expect: { outcomes: [success, success], retried_after_head_move: 1, hand_sequencing_needed: false } -::: +/// Normalize a body for hashing: LF line endings, no trailing blanks, one final +/// newline. The normalization is what makes a source hash independent of the +/// editor that last touched the file. +pub fn normalize_body(raw: &str) -> String { + let unified = raw.replace("\r\n", "\n").replace('\r', "\n"); + let mut lines: Vec<&str> = unified + .split('\n') + .map(|line| line.trim_end_matches([' ', '\t'])) + .collect(); + while lines.last().is_some_and(|line| line.is_empty()) { + lines.pop(); + } + if lines.is_empty() { + return String::new(); + } + let mut out = lines.join("\n"); + out.push('\n'); + out +} -::: test -name: commit sweeps the record it installed -given: { workspace: fixture_two_modules, staged: [record_for_leaf], commit: bare_then_bare_again } -expect: { first_outcomes: [success], second_outcomes: [], reattempted_history: 0 } -::: +/// Validate a name in any position the parser reads one: the `module` +/// frontmatter value, a `deps` entry, or a declared root. +/// +/// A name is any non-empty single-line scalar. No character class, case, or +/// identifier shape is required, so `todo-app` and a non-ascii name are as legal +/// as `todo_app`. The empty string is the only rejection; a line break cannot +/// reach here through the line-oriented frontmatter grammar, and is refused +/// explicitly so the rule holds for every caller. +pub fn check_module_name(name: &str) -> Result<()> { + if name.is_empty() { + return Err(error( + OutcomeCode::MappingIntegrity, + "module name is empty", + )); + } + if name.contains('\n') || name.contains('\r') { + return Err(error( + OutcomeCode::MappingIntegrity, + format!("module name {name:?} spans more than one line"), + )); + } + Ok(()) +} -::: test -name: a bare commit with nothing staged names the drifted modules and their add commands -given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, staged: none, commit_args: none } -expect: { installed: 0, nothing_staged_named: true, drifted_modules_named: [consumer], add_command_named: true } -::: +/// Parse one `.napl` file already read into memory. +pub fn parse_source(path: &str, text: &str) -> Result { + let reject = |why: String| error(OutcomeCode::MappingIntegrity, format!("{path}: {why}")); + let unified = text.replace("\r\n", "\n").replace('\r', "\n"); + let rest = unified + .strip_prefix("---\n") + .ok_or_else(|| reject("missing opening frontmatter fence".into()))?; + let fence = find_fence(rest).ok_or_else(|| reject("missing closing frontmatter fence".into()))?; + let front = &rest[..fence.0]; + let body = &rest[fence.1..]; -::: test -name: a per-side transport refusal serves the surviving side's transported range -given: { workspace: fixture_two_modules, working_tree_edit: rewrite_prompt_section_only, add: module_form_with_check } -expect: { prompt_side: refused, code_side: transported, transported_code_range_served: true, affirmation_needed_sides: [prompt] } -::: + let fields = parse_frontmatter(front).map_err(&reject)?; + let module = fields + .module + .ok_or_else(|| reject("frontmatter has no `module:` key".into()))?; + check_module_name(&module)?; -::: test -name: check enumerates the parse gate and refuses a file that does not parse -given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, add: module_form_with_check } -expect: { validations_enumerated: true, parse_gate_listed: true, outcome: invalid_candidate, error_names_file_and_line: true, staged_records: 0 } -::: + let deps = sorted_unique(fields.deps); + let targets = sorted_unique(fields.targets); + let tests = sorted_unique(fields.tests); + let roots = fields.roots.map(sorted_unique); + if targets.is_empty() { + return Err(reject("frontmatter declares no targets".into())); + } + for dep in &deps { + check_module_name(dep)?; + if *dep == module { + return Err(reject(format!("module {module:?} depends on itself"))); + } + } + if let Some(roots) = &roots { + for root in roots { + check_module_name(root)?; + } + } -::: test -name: add refuses a candidate whose source does not parse naming file and line -given: { workspace: fixture_two_modules, candidate: syntactically_invalid_rust } -expect: { outcome: invalid_candidate, error_names_file_and_line: true, published_semantic_state: false } -::: + let normalized_body = normalize_body(body); + let normalized_source_hash = object_name(ObjectKind::Blob, normalized_body.as_bytes()); + // Directive recognition is part of the identity contract: a malformed block, + // an unknown opener, or a duplicate case name across frontmatter and body is + // a compile error here, naming the exact body line. + let directive_cases = crate::directive::extract_validated(&normalized_body, &tests) + .map_err(|err| error(OutcomeCode::MappingIntegrity, format!("{path}: {}", err.detail)))?; + let frontmatter_cases: Vec = fields + .front_cases + .iter() + .map(|(name, given, expect)| { + serde_json::json!({ + "expect": expect.clone().unwrap_or(serde_json::Value::Null), + "given": given.clone().unwrap_or(serde_json::Value::Null), + "name": name, + }) + }) + .collect(); + Ok(SourceModule { + module, + path: path.to_string(), + deps, + targets, + tests, + frontmatter_cases, + roots, + normalized_body, + normalized_source_hash, + directive_cases, + }) +} -::: test -name: commit recomputation refuses staged bytes that do not parse -given: { workspace: fixture_two_modules, staged: record_with_tampered_unparseable_blob } -expect: { outcome_in: [invalid_candidate, store_integrity], installed: false } -::: +/// Rewrite a source file's `deps:` declaration in place. +/// +/// The edit is line-preserving by construction: it replaces the value of the +/// existing `deps:` line and never deletes a line, which is exactly the +/// mapping-safe class `napl lint --fix` is allowed to apply. +pub fn rewrite_deps(text: &str, deps: &[String]) -> Result { + let mut sorted: Vec = deps.to_vec(); + sorted.sort(); + sorted.dedup(); + let rendered = format!("deps: [{}]", sorted.join(", ")); + let mut out: Vec = Vec::new(); + let mut replaced = false; + let mut in_front = false; + let mut fences = 0usize; + for line in text.split('\n') { + if line == "---" { + fences += 1; + in_front = fences == 1; + out.push(line.to_string()); + continue; + } + if in_front && line.starts_with("deps:") { + out.push(rendered.clone()); + replaced = true; + continue; + } + out.push(line.to_string()); + } + if !replaced { + return Err(error( + OutcomeCode::MappingIntegrity, + "source has no `deps:` line to rewrite", + )); + } + Ok(out.join("\n")) +} -::: test -name: a bad attribution file answers with the complete map contract and example -given: { workspace: fixture_two_modules, attribution_file: empty_object, add: module_form_with_attribution } -expect: { error_lists_all_fields: true, error_carries_valid_example: true } -::: +struct Frontmatter { + module: Option, + deps: Vec, + targets: Vec, + tests: Vec, + roots: Option>, + front_cases: Vec<(String, Option, Option)>, +} -::: test -name: the plan labels projections and names a legal first output -given: { workspace: fresh_init_root_prompt, flag: module_root } -expect: { projection_labeled_tool_derived: true, legal_first_output_named: true } -::: +fn find_fence(rest: &str) -> Option<(usize, usize)> { + let mut offset = 0usize; + for line in rest.split_inclusive('\n') { + let trimmed = line.strip_suffix('\n').unwrap_or(line); + if trimmed == "---" { + return Some((offset, offset + line.len())); + } + offset += line.len(); + } + None +} -::: test -name: gc apply sweeps staged records and touches nothing reachable -given: { workspace: fixture_two_modules, staged: [stale_record], gc: apply } -expect: { staging_empty: true, reachable_objects_untouched: true, store_healthy: true } -::: +fn parse_frontmatter(front: &str) -> std::result::Result { + let mut out = Frontmatter { + module: None, + deps: Vec::new(), + targets: Vec::new(), + tests: Vec::new(), + roots: None, + front_cases: Vec::new(), + }; + let mut current: Option<&'static str> = None; + for raw in front.split('\n') { + if raw.trim().is_empty() { + continue; + } + if let Some(item) = raw.strip_prefix(" - ") { + let key = current.ok_or_else(|| format!("stray list item {raw:?}"))?; + // A `tests:` item is a mapping whose `name:` is the identifier; every + // other sequence carries bare scalars. + let value = match item.trim().strip_prefix("name:") { + Some(name) if key == "tests" => parse_item(name.trim())?, + _ => parse_item(item.trim())?, + }; + if key == "tests" { + out.front_cases.push((value.clone(), None, None)); + } + push_field(&mut out, key, value); + continue; + } + if raw.starts_with(' ') { + // A continuation line of a `tests:` mapping item carries its `given` + // and `expect` flow values, read with the directive value grammar. + if current == Some("tests") { + if let Some((key, value)) = raw.trim().split_once(':') { + if let Some(case) = out.front_cases.last_mut() { + let parsed = crate::directive::parse_inline_value(value); + match key.trim() { + "given" => case.1 = parsed, + "expect" => case.2 = parsed, + _ => {} + } + } + } + continue; + } + return Err(format!("unexpected indentation in {raw:?}")); + } + let (key, value) = raw + .split_once(':') + .ok_or_else(|| format!("frontmatter line {raw:?} is not `key: value`"))?; + let value = value.trim(); + match key { + "module" => { + if out.module.is_some() { + return Err("duplicate `module:` key".into()); + } + // The key rides the diagnostic so a reader can be pointed at the + // physical line it sits on rather than at the file's first line. + out.module = Some( + parse_item(value) + .map_err(|why| format!("the `module:` value is not a name: {why}"))?, + ); + current = None; + } + "deps" | "targets" | "tests" | "roots" => { + let field: &'static str = match key { + "deps" => "deps", + "targets" => "targets", + "roots" => "roots", + _ => "tests", + }; + if field == "roots" && out.roots.is_none() { + out.roots = Some(Vec::new()); + } + if value.is_empty() { + current = Some(field); + } else { + for item in parse_flow_sequence(value)? { + push_field(&mut out, field, item); + } + current = None; + } + } + other => return Err(format!("unknown frontmatter key {other:?}")), + } + } + Ok(out) +} -::: test -name: commit against a moved head with disjoint footprints rebases and installs -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: to_unrelated_module } -expect: { outcome: installed, rebased: true } -::: +fn push_field(out: &mut Frontmatter, key: &str, value: String) { + match key { + "deps" => out.deps.push(value), + "targets" => out.targets.push(value), + "roots" => out.roots.get_or_insert_with(Vec::new).push(value), + _ => out.tests.push(value), + } +} -::: test -name: commit whose direct dep surface moved underneath returns stale-input -given: { workspace: fixture_chain_three, candidate: valid_for_middle, intervening_commit: leaf_surface_change } -expect: { outcome: stale_input, head_advanced: false } -::: +fn parse_flow_sequence(value: &str) -> std::result::Result, String> { + let inner = value + .strip_prefix('[') + .and_then(|v| v.strip_suffix(']')) + .ok_or_else(|| format!("expected a flow sequence, found {value:?}"))?; + if inner.trim().is_empty() { + return Ok(Vec::new()); + } + inner.split(',').map(|item| parse_item(item.trim())).collect() +} -::: test -name: commit whose own outputs moved underneath returns base-output-changed -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: same_module_other_candidate } -expect: { outcome: base_output_changed } -::: +fn parse_item(item: &str) -> std::result::Result { + if item.is_empty() { + return Err("empty scalar".into()); + } + let quoted = item.len() >= 2 + && ((item.starts_with('"') && item.ends_with('"')) + || (item.starts_with('\'') && item.ends_with('\''))); + let unquoted = if quoted { &item[1..item.len() - 1] } else { item }; + if unquoted.is_empty() { + return Err("empty scalar".into()); + } + Ok(unquoted.to_string()) +} -::: test -name: two concurrent disjoint commits both land and neither touches a manifest -given: { workspace: fixture_two_modules, concurrent_candidates: [module_a, module_b], orders: [ab, ba] } -expect: { both_installed: true, manifest_bytes_unchanged: true } -::: +fn sorted_unique(values: Vec) -> Vec { + let set: BTreeSet = values.into_iter().collect(); + set.into_iter().collect() +} +napl-store blob v1 3827 +use std::path::{Path, PathBuf}; -::: test -name: "absent telemetry reads as unavailable, never zero" -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: without_usage } -expect: { usage: unavailable } -::: +use serde_json::Value; -Attribution rides the staged record; there is no other write path for it. -`napl add --attribution ` carries the module's mapping of prompt body -line ranges to generated file line ranges with notes; add validates every -claim before staging: each file path inside the module's owned globs, -each code line range present in the candidate tree, each prompt line -range present in the current normalized prompt body; a mapping that lies -is `invalid-candidate`. An entry may own no file at all: contract-level -prose — a law — that governs the module rather than any single path; -validation accepts a file-less entry (its prompt range must still exist) -and the query layer serves it flagged as law. An add whose transport is -incomplete and whose affirmations do not cover the listed claims fails -loudly as `invalid-candidate`, naming exactly which claims are missing; -`napl add --no-verify` is the explicit escape hatch that stages anyway -and records the module's attribution as stale on install. Any prompt -edit, code transition, or move that changes what a recorded mapping -points at marks it stale the same way; nothing ever repairs a mapping -automatically. `napl status` reports attribution freshness per -module (`fresh`, `stale`, `absent`) and, for stale or absent, emits a -machine-readable directive naming the exact fix: commit with a fresh -attribution for that module. Relocation recomputes only the path prefix -of a mapping, never its line content. The lsp serves a stale mapping -marked stale rather than hiding it; absent answers empty. +use crate::canon::canonical_bytes; +use crate::error::{error, error_payload, outcome_exit_code, CompilerError, OutcomeCode, Result}; +use crate::workspace::require_workspace_root; -An attribution entry is a reference, and a reference has a direction: -prompt-to-code, a prompt body line range naming generated file line -ranges, or code-to-prompt, a generated file line range naming a prompt -body line range. Every entry recorded before directions existed reads as -prompt-to-code; migration is a reading, never a rewrite of stored -records. Validation is identical for both directions and referential -only, the same claims checked the same way, and nothing about a -direction implies the sentence describes the code: a reference is a -claim with provenance and freshness, and the record vocabulary never -says more. +#[derive(Clone, Copy)] +pub struct CommandSpec { + pub name: &'static str, + pub run: fn(&Path, &[String]) -> Result, +} -A reference carries a provenance block, recorded by the compiler at -staging time from what the caller supplied: recording model identity, -timestamp, tool version, and an optional usage-envelope reference -through the existing evidence seam. Every absent field is `unavailable`, -never zero, never invented, and provenance is display and pricing -metadata that never enters any identity, exactly as telemetry already -behaves. The query layer and the lsp serve the provenance block with the -entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliResult { + pub stdout: Vec, + pub exit_code: i32, +} -Staleness covers both ends of a reference, and working-tree drift is one -of the ways an end changes: a reference whose code side no longer -matches the committed bytes, or whose prompt side changed in the working -tree, reads stale on both endpoints, computed at read time from the -records and the working tree, never stored as a transition. A hand edit -to generated source is drift like any other: legal, visible through the -same status and diff machinery, healable by restore or by re-recording -the reference; nothing repairs a reference automatically and nothing -polices the edit that staled it. +pub fn run_cli(cwd: &Path, args: &[String]) -> CliResult { + match dispatch(cwd, args) { + Ok(result) => result, + Err(err) => CliResult { + stdout: canonical_bytes(&error_payload(&err, false, false)), + exit_code: outcome_exit_code(err.code), + }, + } +} -::: test -name: a code-to-prompt entry stages and is served with its direction -given: { workspace: fixture_two_modules, candidate: valid_with_code_to_prompt_reference } -expect: { add_outcome: staged, served_direction: code_to_prompt } -::: +fn dispatch(cwd: &Path, args: &[String]) -> Result { + let command = positional(args, 0).map_err(|_| usage("no command given"))?; + for spec in crate::registry() { + if spec.name == command { + return (spec.run)(cwd, args); + } + } + Err(usage(&format!("unknown command {command:?}"))) +} -::: test -name: entries recorded before directions read as prompt-to-code unchanged -given: { workspace: fixture_two_modules, history: [pre_direction_attribution] } -expect: { served_direction: prompt_to_code, stored_records_rewritten: false } -::: +pub(crate) fn ok(value: &Value) -> Result { + Ok(CliResult { + stdout: canonical_bytes(value), + exit_code: 0, + }) +} -::: test -name: provenance is served with the entry and absent fields read unavailable -given: { workspace: fixture_two_modules, candidate: reference_without_usage_envelope } -expect: { provenance_served: true, usage: unavailable, cost_component: unavailable } -::: +pub(crate) fn usage(detail: &str) -> CompilerError { + let mut names: Vec<&str> = crate::registry().iter().map(|spec| spec.name).collect(); + names.sort(); + names.dedup(); + error( + OutcomeCode::InvalidReceipt, + format!("{detail}; usage: napl <{}> ...", names.join("|")), + ) +} -::: test -name: a hand edit to the code side reads stale on both endpoints -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } -expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } -::: +pub(crate) fn resolve_workspace(cwd: &Path, args: &[String]) -> Result { + match flag(args, "--workspace")? { + Some(explicit) => Ok(PathBuf::from(explicit)), + None => require_workspace_root(cwd), + } +} -::: test -name: a working-tree prompt edit reads stale on both endpoints -given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched } -expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } -::: +fn is_flag(arg: &str) -> bool { + arg.starts_with("--") +} -::: test -name: a code-to-prompt reference naming a missing prompt range is invalid-candidate -given: { workspace: fixture_two_modules, candidate: code_to_prompt_reference_to_missing_range } -expect: { outcome: invalid_candidate, published_semantic_state: false } -::: +fn is_switch(arg: &str) -> bool { + matches!(arg, "--fix" | "--apply" | "--no-verify") +} -## edit and write: the capture verbs +pub(crate) fn positional(args: &[String], index: usize) -> Result { + positionals_from(args, 0) + .get(index) + .cloned() + .ok_or_else(|| usage(&format!("missing positional argument {index}"))) +} -`napl edit` and `napl write` are the deterministic capture layer in -front of the same guarded door. They carry the familiar tool -signatures: `napl edit --old-string --new-string -[--replace-all]` replaces an exact occurrence in a file, and -`napl write ` writes stdin as the file's full content; its usage -line says the content comes from stdin, and an empty stdin refuses as -a usage error naming `--empty`, the explicit flag that authorizes -writing a zero-byte file, so a forgotten pipe can never silently -truncate. Either -side is editable, a prompt or a generated file, by a human or an LLM; -a hand edit through an ordinary editor stays equally legal, and the -verbs add capture, never exclusivity. The verbs never synthesize or -rewrite prose beyond the bytes given, never invoke a model, never -decide placement, never repair a reference, and never bypass the -guard: `add` and `commit` remain exactly what they are. +pub(crate) fn positionals_from(args: &[String], skip: usize) -> Vec { + let mut out: Vec = Vec::new(); + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if is_flag(arg) { + if !is_switch(arg) && !arg.contains('=') { + iter.next(); + } + continue; + } + out.push(arg.clone()); + } + out.into_iter().skip(skip).collect() +} -Each call, deterministically and in this order: validate, apply, -capture. When the target is a prompt file, the result of the edit is -validated first by the same reader `check` and `add` use; a result -that fails validation refuses as `prompt-invalid` and applies nothing. -An `--old-string` that matches nowhere, matches more than once -without `--replace-all`, or equals `--new-string` refuses as -`edit-mismatch` and applies nothing, so a no-op edit can never mint a -record. Only a call that will succeed touches the working tree, so -every refusal leaves every file byte-identical. +pub(crate) fn has_switch(args: &[String], name: &str) -> bool { + args.iter().any(|arg| arg == name) +} -A call may carry one reference: `--ref `, a single object naming -a direction and both endpoints, prompt body line range and generated -file line range, in either direction per the reference contract above. -The contract is taught, never reverse engineered: the verbs' usage -lines and flag meanings name the object's fields, and the FIRST -validation failure of a ref payload answers with the complete -contract in one refusal, every field, the closed list of valid -`direction` values, and one complete valid example, never one missing -field per attempt. -The reference is validated referentially only, exactly as strong as -attribution validation and no stronger: the file path inside the -owning module's placement, both line ranges present in the -post-edit bytes of their files. A reference naming a missing line or -a path outside placement refuses as `reference-unresolvable` and -applies nothing. A valid reference-bearing call stages a -reference-only record and commits it through the guarded transaction -in the same invocation: the transition carries the reference entry and -its provenance, no output-tree change and no surface change, and every -rule of the decision table, journaled atomicity, and recovery applies -to it unchanged. The success payload carries the recorded reference -object verbatim and its transition hash, and says plainly that the -edited file remains working-tree drift until its module is committed: -recording a reference and advancing the committed baseline are -different acts, and the payload spells the sequence that performs the -second, `napl add` for the module then `napl commit`, so nobody reads -`recorded: true` as committed and nobody feeds a bare commit nothing. -A plain apply teaches the same way: a written or edited file is -working-tree drift until staged and committed, and the success -payload of both verbs names both verbs in order. `napl show ` lists the -transition's reference entries inline exactly as it lists notes. A -call without `--ref` is a plain edit: apply, no -record, no store touch. Any existing reference whose endpoint the edit -moved needs no marking, because both-ends staleness is already -computed at read time from the working tree; the verbs store nothing -about it. +pub(crate) fn flag(args: &[String], name: &str) -> Result> { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == name { + return iter + .next() + .cloned() + .map(Some) + .ok_or_else(|| usage(&format!("{name} needs a value"))); + } + if let Some(value) = arg.strip_prefix(&format!("{name}=")) { + return Ok(Some(value.to_string())); + } + } + Ok(None) +} -A reference the verb records is evidence, and evidence serves itself. -The record carries the content hash of each endpoint's post-edit -bytes, the file as the verb wrote it and the normalized prompt body it -validated against. Freshness reads those hashes first: a verb-recorded -reference whose endpoint bytes still match the working tree reads -fresh, and it goes stale only when the tree moves off the bytes the -record names, never merely because the committed baseline lags the -edit, so `recorded: true` is never followed by a stale count the verb -itself caused. At the next add, transport seeds from the same -evidence: a committed claim refusing in a rewritten region is affirmed -automatically when a reference transition recorded since the module's -committed output covers that region and its endpoint hashes match the -staged bytes, because the tool that applied the edit owns its diff; -the affirmation is the record, and no hand-authored map is ever -demanded for a range a capture verb already recorded. A recorded -reference whose hashes no longer match affirms nothing and is listed -in the refusal like any other claim, so the shortcut never guesses. +pub(crate) fn repeated_flag(args: &[String], name: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == name { + if let Some(value) = iter.next() { + out.push(value.clone()); + } + } else if let Some(value) = arg.strip_prefix(&format!("{name}=")) { + out.push(value.to_string()); + } + } + out +} -::: test -name: a verb-recorded reference reads fresh until the tree moves off its bytes -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: status_module } -expect: { stale_reference_count: 0, drift: true } -::: +pub(crate) fn require_flag(args: &[String], name: &str) -> Result { + flag(args, name)?.ok_or_else(|| usage(&format!("{name} is required"))) +} +napl-store blob v1 952 +module: query +target: rust +stale: false +entries: + - promptLines: + - 1 + - 10 + file: crates/napl/src/query.rs + lines: + - 1 + - 255 + note: "the query module owns napl q and napl prompt and the canonical document they share" + - promptLines: + - 11 + - 48 + file: crates/napl/src/document.rs + lines: + - 1 + - 320 + note: "the canonical document docVersion 1 built purely from committed records, totally ordered, no absolute paths" + - promptLines: + - 49 + - 77 + file: crates/napl/src/query.rs + lines: + - 1 + - 255 + note: "napl q shaped exactly like jq via the embedded jaq engine, impure builtins refused, 64 MiB output ceiling" + - promptLines: + - 78 + - 109 + file: crates/napl/src/promptbuild.rs + lines: + - 1 + - 430 + note: "napl prompt reads typed refs and materializes prose and code from committed blobs at the refs snapshot, with law closure" +napl-store blob v1 3489 +module: "napl_store" +target: "rust" +stale: false +entries: + - promptLines: + - 10 + - 402 + file: "crates/napl-store/src/store_hash.rs" + lines: + - 1 + - 720 + note: "store_hash section, the typed versioned length-prefixed envelope, object naming, canonical JSON, and the read verifier with its inline tests" + - promptLines: + - 403 + - 1539 + file: "crates/napl-store/src/store_objects/mod.rs" + lines: + - 1 + - 697 + note: "store_objects section, strict serde types for module-state, event, and snapshot, the enums, provenance, price table, and validation" + - promptLines: + - 1540 + - 2286 + file: "crates/napl-store/src/store_objects/tests.rs" + lines: + - 1 + - 1846 + note: "store_objects fixtures the tests pin and test and code discipline, the pinned envelopes and provenance case discipline" + - promptLines: + - 2287 + - 2976 + file: "crates/napl-store/src/store_blocks/mod.rs" + lines: + - 1 + - 558 + note: "store_blocks section, the block-index object, two-level segmentation into regions atoms and sub-spans, the sentence splitter, and reconstruction" + - promptLines: + - 2328 + - 2330 + file: "crates/napl-store/src/hash.rs" + lines: + - 5 + - 10 + note: "the hash module exposes content_hash, the bare lowercase hexadecimal SHA-256 content digest over the UTF-8 bytes" + - promptLines: + - 2977 + - 3376 + file: "crates/napl-store/src/store_blocks/tests.rs" + lines: + - 1 + - 1173 + note: "store_blocks fixtures the tests pin and test and code discipline" + - promptLines: + - 3377 + - 4446 + file: "crates/napl-store/src/store_tree.rs" + lines: + - 1 + - 1471 + note: "store_tree section, git-style Merkle directory trees, entry kinds and mode constants, tree building and diffing, with its inline tests" + - promptLines: + - 4447 + - 5383 + file: "crates/napl-store/src/store_reduce/mod.rs" + lines: + - 1 + - 833 + note: "store_reduce section, the typed query layer over the content-addressed store and the object provider" + - promptLines: + - 5384 + - 5694 + file: "crates/napl-store/src/store_reduce/tests.rs" + lines: + - 1 + - 1111 + note: "store_reduce fixtures the tests pin and test and code discipline" + - promptLines: + - 5695 + - 6594 + file: "crates/napl-store/src/store_txn_core.rs" + lines: + - 1 + - 1114 + note: "store_txn_core section, markers locks recovery and install planning as a pure decision core, with its inline tests" + - promptLines: + - 6595 + - 7461 + file: "crates/napl-store/src/store_io/mod.rs" + lines: + - 1 + - 1458 + note: "store_io section, the on-disk object store, publication, HEAD, markers, and recovery" + - promptLines: + - 7462 + - 7922 + file: "crates/napl-store/src/store_io/tests.rs" + lines: + - 1 + - 1131 + note: "store_io fixtures the tests pin and test and code discipline" + - promptLines: + - 7923 + - 8481 + file: "crates/napl-store/src/store_diff/mod.rs" + lines: + - 1 + - 724 + note: "store_diff section, hierarchical prompt alignment, attribution ownership resolution, and the LLM bundle assembly and rendering" + - promptLines: + - 8482 + - 8847 + file: "crates/napl-store/src/store_diff/tests.rs" + lines: + - 1 + - 968 + note: "store_diff fixtures the tests pin and test and code discipline" +napl-store blob v1 1007 +module: "commands" +target: "rust" +stale: false +entries: + - promptLines: + - 1 + - 8 + file: "crates/napl/src/init.rs" + lines: + - 1 + - 129 + note: "two porcelain additions each a thin composition over the shared derivations, the command table contributing init and watch" + - promptLines: + - 9 + - 53 + file: "crates/napl/src/init.rs" + lines: + - 1 + - 129 + note: "napl init bootstraps the store skeleton or reports already_initialized, names the directory it ran in as the workspace root, declares no flags beyond the shared help flag, and teaches the first mile through nextSteps: one complete minimal prompt that itself passes check, the .napl extension discovery finds prompts by, and the exact sequence from writing that prompt through add and commit" + - promptLines: + - 54 + - 75 + file: "crates/napl/src/watch.rs" + lines: + - 1 + - 127 + note: "napl watch an io shell re-running compile on content change publishing nothing" +napl-store module-state v1 634 +{"attribution":{"promptHash":"20b515293b4ef553a1a9a8050fe899858420ea8314a0a8fa403de17b79788eef","record":"d90c5f37984e18050b3787d79d1a962170be74f757f224d6b04b4437c53b0867","stale":false},"contractVersion":4,"inputKey":"d591e1cef593c066561ee6630c74f3fa8f29f9a4e5250a4aadaae4ca1a2fde50","module":"napl","outputTree":"2a345a96a9b632ddfb78cf17b496cb7d6fe2e226a0c3d1ad129a9a3c20820ac9","prompt":"427bf8f1bb7ac29049e57dee9221077958aab3a27bcea85a51d6044373140447","sliceHash":"947b3023a3aba17d2c39c2a305c288939285cd644f0890537c325883ea1c7800","surfaceHash":"f39ead3f6661b23c1064f1c5bade9ae4df82bfca5cab5cfe393bae23b8b7be11","target":"rust"} +napl-store blob v1 1152 +use std::env; +use std::path::PathBuf; +use std::process::exit; -::: test -name: add after a referenced edit self-affirms the rewritten region and stages without a map -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, add: module_form_without_attribution } -expect: { add_outcome: staged, affirmed_from_reference_record: true, attribution_flag_needed: false } -::: +fn main() { + let argv: Vec = env::args().collect(); + let invoked = argv + .first() + .map(PathBuf::from) + .and_then(|path| { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + }) + .unwrap_or_default(); + let mut rest: Vec = argv.iter().skip(1).cloned().collect(); + let agent_alias = invoked == "napl-agent"; + let verb = match rest.first().cloned() { + Some(first) if first == "agent" || first == "serve" => { + rest.remove(0); + first + } + _ if agent_alias => "agent".to_string(), + _ => String::new(), + }; + let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let code = match verb.as_str() { + "agent" => napl_usage_proxy::agent::run(&cwd, &rest), + "serve" => napl_usage_proxy::proxy::serve(&rest), + _ => { + eprintln!("usage: napl agent [--upstream ] -- [args]"); + eprintln!(" napl agent --stop"); + 2 + } + }; + exit(code); +} +napl-store blob v1 8628 +//! `napl check`: dry validation of an on-disk prompt file before `add`. +//! +//! It reuses the reader `add` uses, so passing here means the prompt parses +//! there: frontmatter shape and the module-name rules of the napl contract, body +//! normalization, the `::: test` directive grammar including canonical JSON in +//! `given` and `expect`, and case-name uniqueness across frontmatter and body. +//! Findings are canonical JSON on stdout, each naming the file, the line, and +//! what failed. The store is never opened, nothing stages, and no clock or +//! randomness is read, so the same bytes always produce the same findings. +//! +//! Passing says the prompt parses. It says nothing about outcomes that depend on +//! a candidate, an attribution map, or placement, and its wording never implies +//! otherwise. +//! +//! It teaches while it refuses. The finding for a missing opening frontmatter +//! fence embeds a complete minimal valid prompt inline, so the first error a cold +//! user sees contains the whole grammar they were missing, and a checked file +//! whose name lacks the `.napl` extension is told that discovery will never find +//! it, naming the extension, whatever else is valid about its content. -::: test -name: a recorded reference whose bytes moved again affirms nothing -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: hand_edit_same_region, add: module_form_without_attribution } -expect: { outcome: invalid_candidate, listed_claims_present: true, affirmed_from_reference_record: false } -::: +use std::io::Write as _; +use std::path::{Path, PathBuf}; -Provenance rides the session window: `--session ` names the -usage-proxy session whose log lines are this call's evidence, and when -the flag is absent the verb reads the `NAPL_SESSION` environment -variable, so a call made under `napl agent` carries its window with no -extra argument. The recorded usage reference is the window itself, -media type, workspace-relative log path, session id, and the line span -observed at staging, priced lazily by `napl cost` at read time, never -aggregated at record time. Model identity and timestamp come from the -window's log lines, or read `unavailable` when there is no window; -the verbs read no clock, so the same call over the same working tree, -store state, and log bytes produces byte-identical records. Absent is -`unavailable`, never zero, never invented, and provenance never -enters identity. +use serde_json::{json, Value}; -The three refusals join the closed terminal-outcome table under the -contract bump to 4, pinned exit codes continuing the table order: -`edit-mismatch` 27, `reference-unresolvable` 28, `prompt-invalid` 29. -None reuses `mapping-integrity`, and each is a canonical -machine-readable JSON payload naming what failed and that nothing was -applied or published. +use crate::canon::canonical_bytes; +use crate::cli::{positional, resolve_workspace, CliResult}; +use crate::error::Result; +use crate::source::{parse_source, MINIMAL_PROMPT, SOURCE_EXTENSION}; -::: test -name: an edit with a valid code-to-prompt reference stages and commits in one call -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } -expect: { outcome: installed, served_direction: code_to_prompt, output_tree_changed: false } -::: +/// The findings schema version. Bumped only when the shape changes. +pub const CHECK_VERSION: u32 = 1; -::: test -name: the same edit call twice produces byte-identical records -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, runs_from_same_base: 2 } -expect: { records_byte_identical: true } -::: +pub(crate) fn run_check(cwd: &Path, args: &[String]) -> Result { + let given = match positional(args, 1) { + Ok(path) => path, + Err(_) => return Ok(usage("napl check needs the path of a prompt file")), + }; + let path = PathBuf::from(&given); + let absolute = if path.is_absolute() { + path + } else { + cwd.join(&path) + }; + let text = match std::fs::read_to_string(&absolute) { + Ok(text) => text, + Err(err) => return Ok(usage(&format!("{given}: {err}"))), + }; -::: test -name: a reference to a missing line refuses as reference-unresolvable and applies nothing -given: { workspace: fixture_two_modules, edit: ref_names_missing_line } -expect: { outcome: reference_unresolvable, exit_code: 28, files_byte_identical: true } -::: + let file = displayed_path(cwd, args, &absolute, &given); + let mut findings: Vec = match parse_source(&file, &text) { + Ok(_) => Vec::new(), + Err(err) => vec![finding(&file, &text, &err.detail)], + }; + // A name finding is independent of content: a perfectly valid prompt under + // the wrong extension is still a file discovery will never open, and saying + // nothing about it would let a user chase a module that cannot exist. + if let Some(naming) = extension_finding(&file, &absolute) { + findings.push(naming); + } + let exit_code = if findings.is_empty() { 0 } else { 1 }; + let payload = json!({ + "checkVersion": CHECK_VERSION, + "contractVersion": crate::version::CONTRACT_VERSION, + "file": file, + "findings": findings, + "valid": findings.is_empty(), + }); + Ok(CliResult { + stdout: canonical_bytes(&payload), + exit_code, + }) +} -::: test -name: a prompt edit that breaks the prompt refuses as prompt-invalid and applies nothing -given: { workspace: fixture_two_modules, edit: prompt_edit_breaking_directive } -expect: { outcome: prompt_invalid, exit_code: 29, files_byte_identical: true } -::: +/// The path a finding names: workspace-relative where a workspace contains the +/// file, so no absolute path reaches the output, and the path as given +/// otherwise. +fn displayed_path(cwd: &Path, args: &[String], absolute: &Path, given: &str) -> String { + if let Ok(workspace) = resolve_workspace(cwd, args) { + if let Ok(relative) = absolute.strip_prefix(&workspace) { + return relative.to_string_lossy().replace('\\', "/"); + } + } + given.to_string() +} -::: test -name: an ambiguous old-string without replace-all refuses as edit-mismatch -given: { workspace: fixture_two_modules, edit: old_string_matches_twice } -expect: { outcome: edit_mismatch, exit_code: 27, files_byte_identical: true } -::: +/// One finding: the file, the file line it sits on, and what failed. The reader +/// reports the highest precedence problem it found, so a prompt with several +/// faults names the one that stops `add` first. +/// +/// A refusal is also the best teaching moment there is, so one finding carries +/// more than a complaint: a file with no opening frontmatter fence is almost +/// always a file whose author has not seen the grammar at all, and the finding +/// embeds a complete minimal valid prompt inline. It is the reader's own +/// [`MINIMAL_PROMPT`], so the example passes this very command by construction and +/// cannot drift from what the reader accepts. +fn finding(file: &str, text: &str, detail: &str) -> Value { + let stripped = detail + .strip_prefix(&format!("{file}: ")) + .unwrap_or(detail) + .to_string(); + let mut body = serde_json::Map::new(); + body.insert("detail".into(), json!(stripped)); + body.insert("file".into(), json!(file)); + body.insert("line".into(), json!(locate(text, &stripped))); + if stripped.contains("missing opening frontmatter fence") { + body.insert("example".into(), json!(MINIMAL_PROMPT)); + body.insert( + "teaching".into(), + json!( + "a prompt opens with a `---` fence, carries `module`, `deps`, and `targets`, \ + closes the fence with `---`, and then has its body. The example field is a \ + complete minimal prompt that passes napl check as written." + ), + ); + } + Value::Object(body) +} -::: test -name: the first bad ref payload answers with the whole contract at once -given: { workspace: fixture_two_modules, edit: ref_payload_bare_string } -expect: { error_lists_all_fields: true, error_lists_direction_values: true, error_carries_valid_example: true, files_byte_identical: true } -::: +/// The finding a file earns for its name rather than its content: without the +/// `.napl` extension, discovery never walks to it, so no module it declares will +/// ever exist however valid the bytes are. +fn extension_finding(file: &str, absolute: &Path) -> Option { + let extension = absolute.extension().and_then(|e| e.to_str()); + if extension == Some(SOURCE_EXTENSION) { + return None; + } + Some(json!({ + "detail": format!( + "the file name does not end in .{SOURCE_EXTENSION}, so module discovery will never find it: \ + discovery walks the workspace for files with the .{SOURCE_EXTENSION} extension, and nothing \ + else registers a prompt, whatever else is valid about this file's content" + ), + "extension": format!(".{SOURCE_EXTENSION}"), + "file": file, + "line": 1, + })) +} -::: test -name: a recorded edit serves its reference in the payload and in show -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } -expect: { payload_reference_verbatim: true, payload_names_drift_until_commit: true, show_lists_reference: true } -::: +/// The file line a diagnostic names. A directive diagnostic names a +/// body-normalized line, which the frontmatter offset turns back into a file +/// line; a frontmatter diagnostic quotes the offending line, which is searched +/// for verbatim. A diagnostic that names neither points at the file's first +/// line, which is the only honest answer available. +fn locate(text: &str, detail: &str) -> u32 { + if let Some(body_line) = number_after(detail, "body line ") { + return body_offset(text) + body_line; + } + if let Some(snippet) = first_quoted(detail) { + if let Some(line) = line_of(text, &snippet) { + return line; + } + } + 1 +} -::: test -name: a prose-only edit needs no reference and touches no store state -given: { workspace: fixture_two_modules, edit: plain_prompt_edit_valid } -expect: { applied: true, store_bytes_unchanged: true } -::: +/// The count of file lines the frontmatter occupies, including both fences, so +/// body line 1 sits at `offset + 1`. +fn body_offset(text: &str) -> u32 { + let unified = text.replace("\r\n", "\n").replace('\r', "\n"); + let mut fences = 0usize; + for (index, line) in unified.split('\n').enumerate() { + if line == "---" { + fences += 1; + if fences == 2 { + return (index + 1) as u32; + } + } + } + 0 +} -::: test -name: verb success payloads spell the add then commit sequence -given: { workspace: fixture_two_modules, invocations: [plain_write_owned_file, referenced_code_edit_with_ref] } -expect: { write_payload_names: [add, commit], edit_payload_names: [add, commit] } -::: +fn number_after(detail: &str, marker: &str) -> Option { + let rest = detail.split_once(marker)?.1; + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + digits.parse().ok() +} -::: test -name: write with empty stdin refuses naming the empty flag and writes nothing -given: { workspace: fixture_two_modules, write: target_with_empty_stdin } -expect: { exit_code: 2, error_names_flag: "--empty", files_byte_identical: true } -::: +/// The content of the first double-quoted run in a diagnostic, which is how the +/// reader quotes an offending source line. +fn first_quoted(detail: &str) -> Option { + let open = detail.find('"')?; + let rest = &detail[open + 1..]; + let close = rest.find('"')?; + let inner = &rest[..close]; + if inner.is_empty() { + return None; + } + Some(inner.to_string()) +} -::: test -name: an edit touching a referenced line reads stale on both endpoints afterward -given: { workspace: fixture_two_modules, edit: plain_edit_on_referenced_code_line } -expect: { reference_status: stale, stale_endpoints: [prompt, code] } -::: +fn line_of(text: &str, snippet: &str) -> Option { + let unified = text.replace("\r\n", "\n").replace('\r', "\n"); + unified + .split('\n') + .position(|line| line.contains(snippet)) + .map(|index| (index + 1) as u32) +} -::: test -name: a session window from the environment is recorded as the usage reference -given: { workspace: fixture_two_modules, env: { NAPL_SESSION: s1 }, log: proxy_log_with_s1_lines, edit: referenced_code_edit_with_ref } -expect: { provenance_usage_session: s1, usage_span_recorded: true, aggregated_at_record: false } -::: +/// A usage refusal: canonical JSON on stderr and the pinned exit code 2. An +/// unreadable path is usage, not a finding, because there is no prompt to judge. +fn usage(detail: &str) -> CliResult { + let payload = json!({ + "checkVersion": CHECK_VERSION, + "contractVersion": crate::version::CONTRACT_VERSION, + "detail": detail, + "outcome": "check-usage", + }); + let bytes = canonical_bytes(&payload); + let stderr = std::io::stderr(); + let mut lock = stderr.lock(); + let _ = lock.write_all(&bytes); + let _ = lock.flush(); + CliResult { + stdout: Vec::new(), + exit_code: 2, + } +} +napl-store blob v1 7361 +# commands: init and watch -::: test -name: an edit with no session window records provenance unavailable -given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, env: {} } -expect: { provenance_model: unavailable, provenance_usage: unavailable } -::: +Two porcelain additions to the `napl` binary, each a thin composition +over the same napl-core derivations and napl-store reads the other +commands use. They live in this module and compile into the same crate as +the core toolchain. The query surface that used to live here belongs to +the `query` module. -## The rest of the porcelain +## napl init -`napl status` observational classification, zero mutation, and it -judges both ends: per module it reports `drift: true` when the -committed output differs from the working tree, and the module's stale -reference count, the same derivations diff and the lsp already serve, -so status can never read clean while the query surface reads stale. -A module fresh on the prompt side and drifted on the code side says -so in one payload. `napl status --module ` answers with the -module's live state beside its plan, tracked or pending, drift, stale -reference count, attribution status, in the same payload as the owned -globs and wave position, so the command named status never answers -with a plan alone; and the payload carries the derived entry-file -projection, path and exact content, so the line a new file needs in a -shared entry file is read from the tool, never composed by hand. -Every projection path the plan mentions is labeled what it is, -tool-derived and never user-supplied, and the plan names one concrete -legal first output path (the module name under the target's source -dir), so the obvious wrong first move, hand-writing the entry file, -is warned against before it is made. The add refusal for a module -whose owned tree holds nothing stageable says the same thing: which -paths present are projections, and what the first legal file would -be. -`napl log` entries carry each transition's note count and reference -count, and the log payload names `napl show ` as where -the full note and reference text is served, so provenance is one -named hop away, never a secret; `napl show ` lists its -attached notes inline, hash and text, so a note attached is a note -findable without plumbing. +`napl init` bootstraps a workspace: it creates the records directory +`.napl/` with an empty store (`store/` skeleton, no HEAD, no objects) in +the current directory when no ancestor workspace exists. Running it inside +an existing workspace changes nothing and reports `already_initialized`; +it never creates a nested workspace under another workspace and never +touches an existing store's bytes. The result of init is exactly the +state a fresh `napl compile` expects: head absent, no modules recorded. +Like `git init`, the directory it runs in is the whole story: the +success payload names that directory as the workspace root, there is +nothing to configure, and a prompt written beside `.napl` is already +inside placement. Init accepts no flags beyond the shared help flag; +anything else is a usage error under the command line contract. The +payload also teaches the first mile: a `nextSteps` field carries one +complete minimal prompt, frontmatter fences with `module`, `deps`, and +`targets` plus one body line, the fact that discovery finds prompts by +the `.napl` extension, and the exact command sequence from writing that +prompt through `add` and `commit`, so a cold user never reverse +engineers the grammar one validation error at a time. The minimal +prompt's frontmatter includes a `roots` declaration naming its own +module, so the very first workspace is lint-ready and the sequence's +forward pointer about `roots` is a reminder, never a first encounter. -::: test -name: log counts references and names show as the inspection verb -given: { workspace: fixture_two_modules, history: [commit, referenced_edit] } -expect: { log_reference_count: 1, show_named_in_payload: true } -::: +Init also scaffolds the agent-teaching glue, wrangler-style, writing +only what is absent and never touching an existing file: a +`napl-agents.md` at the workspace root carrying the working doctrine +for LLM sessions (edit with your native tools, drift is legal and the +diagnostics mark it, the status-add-commit loop, the query surface, +capture verbs are deliberate acts), plus, when absent, a `CLAUDE.md` +containing an import reference to it and an `AGENTS.md` containing a +pointer line, so whichever harness opens the repo finds its native +entry file already pointing at the one doctrine. Every command the +scaffolded teaching spells is a valid invocation under the live +command line contract: a query example always carries its expression, +`napl q '.' --working-tree` and never a bare `napl q`, because the +expression is required and a bare invocation is a usage error. No +environment is +sniffed and no harness detected: every entry file is written, tiny +and inert, and determinism holds. When an entry file already exists, +init leaves it alone and the payload prints the one reference line +the user may add by hand. All scaffolded files are user-owned from +the moment they exist: ordinary files, never records, never +rewritten by any later command. -`napl log` -per-module or store-wide history. `napl show ` inspect one object. -`napl diff` drift between committed outputs and working tree as a canonical -byte-stable patch. `napl blame ` derived ancestry: surviving lines -trace through accepted patches by canonical replay; rewritten regions blame -the rewriting transition, honestly coarse. Blame reads committed -bytes only, and says so when it matters: a blamed path whose working -tree drifts from the committed output carries a drift notice naming -`napl diff` as where the uncommitted change lives, so ancestry over -the committed bytes is never mistaken for the present file. +When init runs inside a git repository it also scaffolds the store's +git manners under the same never-clobber law: a `.gitattributes` at +the workspace root carrying two lines, `.napl/store/objects/** -text +-diff linguist-generated` covering objects and packs alike, and +`.napl/store/HEAD binary`, written only when no `.gitattributes` +exists; when one exists, init leaves it alone and the payload prints +the exact lines to add by hand. `-text` is the load-bearing word: an +envelope's name is the hash of its exact bytes, so an eol-converting +checkout on any platform would corrupt every object against its own +name, and marking the store never-text is what makes a clone safe +everywhere. `binary` on HEAD keeps a merge from ever writing conflict +markers into the pointer. Nothing else about travel needs teaching, +because the store is ordinary tracked files and git already carries +them. Outside a git repository none of this appears, in the payload +or on disk. ::: test -name: blame over a drifted path names the drift and points at diff -given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, blame_path: drifted_file } -expect: { drift_noticed: true, diff_named: true, ancestry_served: true } +name: init in an empty directory creates the records skeleton with no head +given: { directory: empty } +expect: { created: [".napl/store"], head: absent, exit_zero: true, workspace_root_reported: true } ::: -`napl lint` unreachable modules -under an explicit `roots` declaration; when roots are absent the refusal -is loud, exit 2 with canonical JSON naming the missing prerequisite and -the exact declaration that would satisfy it, never an empty findings -list under exit 0; unconsumed surface entries (diagnosis only), module staleness; -findings are proposals with cascade previews; `napl lint --fix` applies -only complete deterministic mapping-safe edits through the guarded commit -path and never removes lines. `napl lint ` is the -source-language scan, a different question needing no roots: it parses -every committed module's owned working-tree file under that target -with the pinned grammar and reports one finding per file that fails, -naming the file and the first error's line. Every lint payload names -the scope it judged, graph or source syntax, so an empty findings -list is never read wider than the scan that produced it. Source -syntax findings are facts, not proposals: a target scan carrying any -finding exits 1, so automation can never read broken source as -success, and a clean scan exits 0; the graph scope keeps its -advisory exit. - ::: test -name: lint of a target exits nonzero on source findings and zero when clean -given: { workspace: fixture_two_modules, runs: [clean_tree, syntactically_invalid_owned_file], lint: target_form } -expect: { clean_exit_code: 0, broken_exit_code: 1, findings_when_broken: 1 } +name: init teaches the first mile with a complete minimal prompt and the command sequence +given: { directory: empty } +expect: { next_steps_prompt_passes_check: true, extension_named: ".napl", sequence_named: [write_prompt, add, commit], prompt_declares_roots: true } ::: ::: test -name: lint of a target reports a parse finding per malformed working-tree file -given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, lint: target_form } -expect: { findings: 1, error_names_file_and_line: true, scope_named: source_syntax, roots_required: false } +name: init scaffolds the agent glue only where absent and clobbers nothing +given: { directory: with_existing_claude_md } +expect: { napl_agents_md_written: true, agents_md_written: true, claude_md_untouched: true, payload_prints_reference_line: true } ::: ::: test -name: lint of a target over a clean tree answers empty and names its scope -given: { workspace: fixture_two_modules, lint: target_form } -expect: { findings: 0, scope_named: source_syntax, exit_code: 0 } +name: scaffolded teaching never spells a bare napl q and its query example carries an expression +given: { directory: empty } +expect: { bare_napl_q_absent: true, query_example_has_expression: true } ::: -`napl cost` pure calculation over an explicit -receipt set and a pinned pricing-table provenance object; unknown is -unknown, never zero. Edits price the way generations price: records the -capture verbs staged enter the same receipt set, their session-window -usage references resolve at read time against the named log's bytes, -summing the window's lines under the same pinned pricing table, and -`napl cost --session ` reports one session's calls directly from -the log. A record without a window prices `unavailable`; a window whose -log file or session lines are absent prices `unavailable` and says -which; a model the pricing table does not name prices unknown; nothing -is ever silently zero and nothing is aggregated at record time. The -same receipt set, log bytes, and pricing table produce byte-identical -reports. `napl gc` stages sweeps outside locks, swaps pointers -inside the bounded section, never blocks writers, never deletes anything -reachable from a named head. `napl gc --apply` also repacks: the -reachable closure lands in one pack beside its index under -`objects/pack/` through the store's repack operation, loose files and -older packs are removed after the pack is durable, and the payload -names the pack written, the objects packed, and the files removed. -The dry `napl gc` reports what a repack would write. Every read -answers identically before and after a repack, fsck is healthy after -exactly when it was healthy before, and publication keeps writing -loose objects, so a store is always the sum of its packs plus its -loose files. Nobody is required to know any of this: exactly as git -packs itself, a successful commit whose store holds more loose -objects than a compiled-in threshold runs the same repack -automatically, after the critical section and outside every lock, -and reports what it packed under a `repack` key in the commit -payload; the threshold is a count, never a clock, so when the repack -fires is a function of store content alone, and the pack it writes -is byte-identical to the one gc would have written. A user who never -types gc keeps a small store; gc remains the explicit verb for doing -it now. Reachable means the transitive closure: -from every named head, through every event in its chain, through every -object any reachable object references by hash, output trees, -surfaces, attribution records, prompt blobs, receipts, at every -contract version history carries. `fsck`'s definition of a present -object IS gc's definition of a kept one; a store that was healthy -before a sweep is healthy after it, and blame replays the same -ancestry after a sweep as before. An edge gc does not understand is a -reason to keep, never to sweep. - ::: test -name: gc apply on a workspace with history leaves fsck healthy and blame replaying -given: { workspace: fixture_with_multi_commit_history, gc: apply } -expect: { fsck_findings: 0, blame_replays_history: true, head_intact: true } +name: scaffolded files are inert and later commands never rewrite them +given: { directory: empty, then: [init, write_prompt, add_module, commit] } +expect: { scaffold_bytes_unchanged_after: true } ::: ::: test -name: gc apply repacks the reachable closure into one pack and the store still answers -given: { workspace: fixture_with_multi_commit_history, gc: apply } -expect: { packs: 1, loose_reachable_remaining: 0, all_commands_answer_identically: true, fsck_findings: 0 } +name: init where a workspace already exists changes nothing and says so +given: { directory: existing_workspace } +expect: { changed_files: [], outcome: already_initialized, exit_zero: true } ::: ::: test -name: a commit after a repack writes loose objects beside the pack -given: { workspace: fixture_with_multi_commit_history, gc: apply, then: valid_commit } -expect: { commit_outcome: installed, new_objects_loose: true, pack_untouched: true } +name: init never overwrites an existing store +given: { directory: existing_workspace_with_commits } +expect: { store_bytes_unchanged: true } ::: ::: test -name: a commit crossing the loose threshold repacks automatically and says so -given: { workspace: fixture_with_multi_commit_history, loose_objects: above_threshold, then: valid_commit } -expect: { commit_outcome: installed, repack_reported: true, packs: 1, loose_reachable_remaining: 0 } +name: init inside a git repository writes the gitattributes store lines only where no gitattributes exists +given: { directory: git_repo_no_gitattributes } +expect: { gitattributes_written: true, store_objects_marked_never_text_undiffed_generated: true, head_marked_binary: true } ::: ::: test -name: a commit below the loose threshold never repacks -given: { workspace: fixture_with_multi_commit_history, loose_objects: below_threshold, then: valid_commit } -expect: { commit_outcome: installed, repack_reported: false, packs_unchanged: true } +name: init leaves an existing gitattributes alone and prints the lines to add +given: { directory: git_repo_with_gitattributes } +expect: { gitattributes_bytes_unchanged: true, payload_prints_store_lines: true } ::: ::: test -name: the automatic repack writes the byte-identical pack gc would have written -given: { workspace: fixture_with_multi_commit_history, compare: [auto_repack_at_commit, explicit_gc_apply] } -expect: { pack_bytes_equal: true, pack_names_equal: true } +name: init outside a git repository scaffolds no hook and teaches no git rules +given: { directory: empty } +expect: { pre_push_hook_written: false, payload_mentions_napl_store_branch: false } ::: -`napl fsck` envelope hashes, kind validity, -causal parent links. `napl restore` deterministic repair plans from -persisted evidence; unambiguous moves install, ambiguity blocks; liveness -and wall time never decide recovery. `napl mv ` relocation -as a semantic operation: moves a module's installed files to a new -placement, recomputes every placement-derived fact (derived entry-file -projections, attribution file paths) from the destination, passes -through the guarded commit path, and re-verifies there; failed -verification at the destination installs nothing; manifests are the -user's to update, and status diagnoses a placement the workspace -manifest no longer lists. A move made outside `napl mv` is drift like any other: -the store still holds the committed bytes, `napl restore` re-materializes -the module at its recorded placement, and when the drifted content matches -a committed state exactly the plan may instead adopt the new placement -through the commit path; anything ambiguous blocks. `napl cat-file ` -raw plumbing. `napl lsp` a language server over stdio for editor -extensions: hover, definition, references, and code lens over the -prompt-to-code mapping. Everything it serves derives from committed -records (attribution entries, module surfaces, the ir) resolved against -the workspace root; hover on a prompt line presents the attributed -generated spans with their notes, hover on a generated line presents the -owning prompt lines and note, definition jumps across the mapping in both -directions, references enumerates every attributed span, code lens counts -attributed files per prompt section. The server publishes diagnostics -derived from reference staleness: for every stale reference it emits one -diagnostic on each endpoint, the prompt span whose code moved and the -code span whose prompt changed, so sync debt is visible where the edit -happens, like a type error for documentation. Publication is a pure -derivation from committed records plus the working tree at the moment -the protocol asks, no background mutation, no clocks, no scanning loops -beyond what the editor protocol requires; opening or changing a -document refreshes that document's diagnostics and its counterparts'. -Severity is warning, never error: drift is a legal state, marked, not -policed. The diagnostic message states exactly what is known and -nothing more: which end drifted, the reference's direction, recorded -model or `unavailable`, referentially valid, and the healing verbs -(re-record through `napl edit`, or restore); no wording implies the -sentence describes the code. A fresh reference publishes nothing. -Resolving is always a human or LLM re-affirming or re-pointing the -reference through the capture verbs; the server proposes nothing and -repairs nothing. Pure reads only: no mutation, no -clocks, no background scanning. A request whose records are missing or -unparseable answers empty rather than erroring or blocking, and missing -or unparseable records degrade to no diagnostics, never an error; a stale -mapping is served marked stale, consistent with the attribution contract; -the server never hangs on a malformed or absent record. Record resolution is -anchored per document: walk up from the document's filesystem path to the -nearest ancestor directory containing `.napl` and read records there; the -server's working directory and the client's rootUri carry no meaning. -Every location the server emits that points at a module's prompt resolves -to the prompt file's actual placement found by workspace prompt discovery, -where the prompt sits inside the crate directory its code lands in; a -module name is never joined against the workspace root to fabricate a -prompt path, and a module whose prompt cannot be discovered answers empty. -Crash safety: every persisted application step leaves markers such that -recovery derives a deterministic plan from content alone. Evidence -validators are a registry keyed by media type (Claude CLI JSON envelope -ships first, and `application/x-napl-usage-proxy-jsonl-v1` validates -usage-proxy logs: one canonical JSON object per line, required fields -present, absent values the string `unavailable`, token counts -non-negative integers when present, a line failing the shape is -`invalid`); outcomes `validated`, `unsupported`, `invalid`, -`validator-error`; invalid stays visible as failed. Evidence bounds are -compiled-in constants; exceeding any is `resource-limit` before anything -publishes. +## napl watch -::: test -name: a stale reference publishes one diagnostic on each endpoint -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, lsp: [open_prompt, open_code] } -expect: { prompt_diagnostics: 1, code_diagnostics: 1, severity: warning } -::: +`napl watch` is an I/O shell around the pure compile derivation: it +observes the workspace's `*.napl` files and re-runs the derivation when +their content changes, emitting each result to stdout as canonical JSON. It publishes nothing: no semantic writes, no store +mutation, no locks held between runs. Change detection is by content +hash, never by timestamp, so an editor save that leaves bytes identical +produces no recompile. A malformed prompt is reported as a diagnostic in +the output stream and the watcher keeps running. Stopping the process is +the only way to end it, and stopping it loses nothing. ::: test -name: a fresh reference publishes nothing -given: { workspace: fixture_two_modules, lsp: [open_prompt, open_code] } -expect: { prompt_diagnostics: 0, code_diagnostics: 0 } +name: watch re-derives compile output when a prompt changes and writes nothing semantic +given: { workspace: fixture_two_modules, edit: touch_leaf_prompt } +expect: { recompiled: true, semantic_state_published: false } ::: ::: test -name: "the diagnostic message carries drifted end, direction, and provenance and nothing semantic" -given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched, lsp: [open_code] } -expect: { message_names_drifted_end: true, message_names_direction: true, message_model_or_unavailable: true, message_semantic_claims: 0 } +name: watch survives a malformed prompt edit and reports it instead of dying +given: { workspace: fixture_two_modules, edit: break_leaf_frontmatter } +expect: { process_alive: true, diagnostic_reported: true } ::: +napl-store tree v1 4431 +{"entries":[{"hash":"d524c18f61e1a3d56c02b12319b23d0e96319f01e535647a95e490eb4e4880cd","kind":"file","mode":33188,"name":"attribution.rs"},{"hash":"1dec6b9fe622b28f871dae12c000036fd0604aef104c3fdcf95f38e1edcb3733","kind":"file","mode":33188,"name":"authoring.rs"},{"hash":"bcb41a480457775ca3f239c29b7f6ec05cb48e121a6b04172b3b2fe63afaca68","kind":"file","mode":33188,"name":"blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","kind":"file","mode":33188,"name":"candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","kind":"file","mode":33188,"name":"canon.rs"},{"hash":"a258b748a1c40209cc4b9d748a9257c8b314224856c232ed8982aa62002e9c31","kind":"file","mode":33188,"name":"cli.rs"},{"hash":"4df745d53cdf794e90eb98b84242a5575da64075ca21716afe7efca51caa0a30","kind":"file","mode":33188,"name":"commandset.rs"},{"hash":"8c88c9d9441329da0cfb5a19a3b073bc7ca96e5a25a2b04f1a9625a6042e68b7","kind":"file","mode":33188,"name":"commit.rs"},{"hash":"51a5efd09235e6a66de7381faaa217a51136ca9c7d2b10285f99af8ebe4ffc6f","kind":"file","mode":33188,"name":"compile.rs"},{"hash":"14768a9b96b8dd25e1efa36ddad54dd48c5feab01b57a4be8883f7ce8b524eb8","kind":"file","mode":33188,"name":"cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","kind":"file","mode":33188,"name":"decision.rs"},{"hash":"04a648a127be983c89a99181ee26e28401ee00f84ea0b10b6c050ecd263a1b2c","kind":"file","mode":33188,"name":"directive.rs"},{"hash":"28059a89256aad1de78ecd31d7b77cae21772bbc6e96fe8ba1cdb9044048f410","kind":"file","mode":33188,"name":"disk.rs"},{"hash":"0b71431883adea0e4f3dc3a77b968b69ac4f18348387362cd3c48fbc023d8bb4","kind":"file","mode":33188,"name":"error.rs"},{"hash":"5b7c0553530cac82c017fc61327a6c120e284d0a6d0966e24f0d63fe07d134f5","kind":"file","mode":33188,"name":"evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","kind":"file","mode":33188,"name":"graph.rs"},{"hash":"f0641fc8a646a08a653d7a2ba1b8fe68da672f027919a1fccb53254e7bcd456a","kind":"file","mode":33188,"name":"lint.rs"},{"hash":"9e204c59695c6aed88c0addef5eea89231043d6db19af09df3699f1c41cfe714","kind":"file","mode":33188,"name":"lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","kind":"file","mode":33188,"name":"main.rs"},{"hash":"86fabd45da78a46bbfa7e92465788159360c0abaa4402f10ca433779b419ee0d","kind":"file","mode":33188,"name":"maintenance.rs"},{"hash":"a38e6c331db0c24a485fb4ef39c6996a957e8a068a5bc4a125f99f885a551455","kind":"file","mode":33188,"name":"mv.rs"},{"hash":"3050b7652a1a7070197d7fd954cf55c585dc328306ae68a91b27d24b6b84dcfd","kind":"file","mode":33188,"name":"objects.rs"},{"hash":"003dfa7b8045ce14ca57c31215434fe8034f78efa7c2f6660e635ae4dec26152","kind":"file","mode":33188,"name":"paths.rs"},{"hash":"5247e34ebd33f612010e0c38d0bf1b4f2b001de64888ab0443c9d38810546cec","kind":"file","mode":33188,"name":"porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","kind":"file","mode":33188,"name":"receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","kind":"file","mode":33188,"name":"slice.rs"},{"hash":"7cd4b1ed38095f7a1f90c73725887799cb7dd694216ddad461640d2ffae0894a","kind":"file","mode":33188,"name":"source.rs"},{"hash":"5783fa10d0a6de649ab37dee1f87fdc7cdb383827376597ea746f1097ec78489","kind":"file","mode":33188,"name":"staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","kind":"file","mode":33188,"name":"store_view.rs"},{"hash":"9ae420e34160e7c2a6c2ba092aaa9c9ff1df622ece595849d95bf5c5eb7cbe81","kind":"file","mode":33188,"name":"surface.rs"},{"hash":"2aeba094700b60cf2ab6e0c88dd79228028a72f6b9b0e2da489dce572f2d2641","kind":"file","mode":33188,"name":"target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","kind":"file","mode":33188,"name":"textdiff.rs"},{"hash":"c2690840af51faafd65929bdca676747ab87806e1e05ec50f2d3f676156373e5","kind":"file","mode":33188,"name":"transaction.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","kind":"file","mode":33188,"name":"trees.rs"},{"hash":"c0e1835e12c0dab7e9bb223a61914b9b89cc6e5a88698093977abacbec733a74","kind":"file","mode":33188,"name":"version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","kind":"file","mode":33188,"name":"workspace.rs"}]} +napl-store transition v1 1847 +{"baseStateKey":"6d936a0e261cd28357178855ce558832bb46b36d0c3ca5fecee7424a3b0e8034","contractVersion":2,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"7130542aade84984979930a7a1ce8c8c04d6ce7710dd347c6549cd11ea69d6f8","installPaths":["crates/napl/src/attribution.rs","crates/napl/src/authoring.rs","crates/napl/src/blame.rs","crates/napl/src/candidate.rs","crates/napl/src/canon.rs","crates/napl/src/cli.rs","crates/napl/src/commandset.rs","crates/napl/src/commit.rs","crates/napl/src/compile.rs","crates/napl/src/cost.rs","crates/napl/src/decision.rs","crates/napl/src/directive.rs","crates/napl/src/disk.rs","crates/napl/src/error.rs","crates/napl/src/evidence.rs","crates/napl/src/graph.rs","crates/napl/src/lint.rs","crates/napl/src/lsp.rs","crates/napl/src/main.rs","crates/napl/src/maintenance.rs","crates/napl/src/mv.rs","crates/napl/src/objects.rs","crates/napl/src/paths.rs","crates/napl/src/porcelain.rs","crates/napl/src/receipt.rs","crates/napl/src/slice.rs","crates/napl/src/source.rs","crates/napl/src/staging.rs","crates/napl/src/store_view.rs","crates/napl/src/surface.rs","crates/napl/src/target.rs","crates/napl/src/textdiff.rs","crates/napl/src/transaction.rs","crates/napl/src/trees.rs","crates/napl/src/version.rs","crates/napl/src/workspace.rs"],"module":"napl","newState":"e835eab10ec5f271af85dce966538f70e343c0d2e6442c3b736ef58a5647d5b9","readSet":["base-state:6d936a0e261cd28357178855ce558832bb46b36d0c3ca5fecee7424a3b0e8034","input-key:7130542aade84984979930a7a1ce8c8c04d6ce7710dd347c6549cd11ea69d6f8","target-def:eff9678e5301c88ac1e42eec62c56eb3860fca247f6278b6501639f14112a8d4"],"receipt":"273d4e3a2810f5e328772aff4f262b228dcdae379b229e47daab5fe9b46c9f74","surface":"476917d9ed89b9fb76844ef0a20187142687eb250a4752d300e98a7fe1363f41","target":"rust","writeSet":["owned:crates/napl","module-state:rust/napl"]} +napl-store module-state v1 558 +{"attribution":{"promptHash":"ca1bdac62d8c3409a54378a02ae90ca229216c6024ba44e23ce62ce6f4d45f07","record":"17783b48fe0888a535aed30a280cae63ba8d0559f202b60a1b386ab438c7c982","stale":false},"contractVersion":4,"inputKey":"186a86b32eb2e89308a136602249224309e890ca1a7575687e0451a9aa41f724","module":"napl","outputTree":"3288747bf43501a729ef81fd0c69314316f9f7ef2e5dfe2d3234f61fa4cd6b6a","sliceHash":"2247f45dfd9957056f59f4560e2f2cd169cf847637caabca52ede4b86373d121","surfaceHash":"6d44d704c68324ea25ff3bcdc9bb7fe1814aab2299b55fc04a7da4f19a006f5a","target":"rust"} +napl-store blob v1 685 +module: "commands" +target: "rust" +stale: false +entries: + - promptLines: + - 1 + - 8 + file: "crates/napl/src/init.rs" + lines: + - 1 + - 59 + note: "two porcelain additions each a thin composition over the shared derivations, the command table contributing init and watch" + - promptLines: + - 9 + - 36 + file: "crates/napl/src/init.rs" + lines: + - 1 + - 59 + note: "napl init bootstraps the store skeleton or reports already_initialized" + - promptLines: + - 37 + - 58 + file: "crates/napl/src/watch.rs" + lines: + - 1 + - 127 + note: "napl watch an io shell re-running compile on content change publishing nothing" +napl-store blob v1 7789 +use std::collections::BTreeMap; +use std::path::Path; -::: test -name: absent or unparseable records publish no diagnostics and answer empty -given: { workspace: fixture_two_modules, records: attribution_file_corrupted, lsp: [open_prompt] } -expect: { diagnostics: 0, error_responses: 0, server_alive: true } -::: +use crate::authoring::draft_receipt; +use crate::commit::{commit, CommitReport}; +use crate::compile::base_output_files; +use crate::disk; +use crate::error::{error, error_payload, OutcomeCode, Result}; +use crate::porcelain::committed_state; +use crate::store_view::get_blob; +use crate::target::{builtin_target, placement_root, TargetDef}; +use crate::workspace::{discover_sources, write_file}; -::: test -name: committing the drifted code clears both endpoints' diagnostics on refresh -given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, then: commit_module, lsp: [change_code_document] } -expect: { prompt_diagnostics: 0, code_diagnostics: 0 } -::: +/// Relocate a module's installed files to a new placement through the guarded +/// commit path, re-verifying at the destination. Nothing installs unless the +/// commit succeeds. +pub fn relocate_module( + workspace: &Path, + target_name: &str, + module: &str, + dest: &str, +) -> CommitReport { + match prepare(workspace, target_name, module, dest) { + Ok(plan) => commit( + workspace, + &plan.receipt_path, + &plan.staging, + plan.attribution_path.as_deref(), + plan.no_verify, + ), + Err(err) => CommitReport { + outcome: err.code, + payload: error_payload(&err, false, false), + }, + } +} -::: test -name: diff renders drift between committed output and working tree as a canonical patch -given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched } -expect: { diff_nonempty: true, canonical_patch: true, byte_stable: true } -::: +/// The staged relocation ready for the guarded commit, including the attribution +/// carried forward with its path prefixes recomputed against the destination. +struct RelocationPlan { + staging: std::path::PathBuf, + receipt_path: std::path::PathBuf, + attribution_path: Option, + no_verify: bool, +} -::: test -name: "blame over a whole-file regeneration coarsens to the transition, line ancestry survives accepted patches" -given: { workspace: fixture_two_modules, history: [patch_commit, full_rewrite_commit] } -expect: { patched_lines_trace_to_patch: true, rewritten_region_blames_transition: true } -::: +fn prepare( + workspace: &Path, + target_name: &str, + module: &str, + dest: &str, +) -> Result { + let target = builtin_target(target_name)?; + let sources = discover_sources(workspace)?; + let source = sources + .iter() + .find(|source| source.module == module) + .ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} has no prompt file in the workspace"), + ) + })?; + let canonical = placement_root(source); + if dest != canonical { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "destination {dest:?} does not match {module:?}'s prompt directory {canonical:?}; move the prompt into the destination crate first" + ), + )); + } -::: test -name: lint reports an unreachable module only under declared roots and refuses without them -given: { workspace: fixture_orphan_module, roots: absent } -expect: { unreachable_analysis: refused, exit_code: 2, reason_names_roots: true, findings_field_absent: true } -::: + let store = disk::open(workspace); + let state = committed_state(&store, target_name, module)?.ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} has no committed output to relocate"), + ) + })?; + let blobs = base_output_files(&store, &state)?; + if blobs.is_empty() { + return Err(error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} has no committed output to relocate"), + )); + } + let mut files: BTreeMap> = BTreeMap::new(); + for (path, blob) in &blobs { + files.insert(path.clone(), get_blob(&store, blob)?); + } -::: test -name: lint with declared roots proposes removal of the orphan with a cascade preview and never auto-applies -given: { workspace: fixture_orphan_module, roots: [main_module] } -expect: { findings: [unreachable_napl], auto_applied: false, cascade_preview: true } -::: + let old_root = derive_old_root(&files, &target)?; + let relocated = relocate_files(&files, &old_root, dest)?; -::: test -name: lint --fix drops an unreferenced declared dependency through the guarded commit path -given: { workspace: fixture_unused_dep, fix: drop_unused_dep } -expect: { outcome: installed, transition_recorded: true, dep_removed: true } -::: + let staging = store.root.join("tmp").join(format!("mv-{module}")); + if staging.exists() { + std::fs::remove_dir_all(&staging).map_err(|e| disk::io(&staging, &e))?; + } + for (path, bytes) in &relocated { + write_file(&staging.join(path), bytes)?; + } -::: test -name: a cost report over a receipt set containing verb-staged records includes the edits -given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines } -expect: { report_rows: 2, edit_row_priced: true, same_pricing_table: true } -::: + let receipt = draft_receipt(workspace, target_name, module, &staging, None)?; + let receipt_path = store.root.join("tmp").join(format!("mv-{module}.json")); + write_file(&receipt_path, &crate::canon::canonical_bytes(&receipt))?; -::: test -name: an edit without a window and a window with missing log lines both price unavailable naming why -given: { workspace: fixture_two_modules, receipts: [edit_without_window, edit_with_window_to_missing_lines] } -expect: { priced_unavailable: 2, reason_named_per_row: true, zeros: 0 } -::: + // A relocation recomputes only the path prefix of a mapping, never its line + // content: the committed attribution rides along with each file re-rooted + // from the old placement to the destination. A module that never attributed + // has nothing to carry, so the relocation rides the escape hatch. + let (attribution_path, no_verify) = + match crate::porcelain::committed_attribution_yaml(&store, target_name, module)? { + Some(bytes) => { + let text = String::from_utf8(bytes).map_err(|_| { + error( + OutcomeCode::MappingIntegrity, + "committed attribution is not valid UTF-8", + ) + })?; + let mut record = crate::attribution::parse(&text).ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + "committed attribution is not a parseable record", + ) + })?; + crate::attribution::remap_prefix(&mut record, &old_root, dest); + let path = store.root.join("tmp").join(format!("mv-{module}.yaml")); + write_file(&path, &crate::attribution::to_yaml(&record))?; + (Some(path), false) + } + None => (None, true), + }; -::: test -name: a model absent from the pricing table prices unknown never zero -given: { workspace: fixture_two_modules, receipts: [edit_with_unknown_model_window], log: proxy_log_unknown_model } -expect: { priced_unknown: 1, zeros: 0 } -::: + Ok(RelocationPlan { + staging, + receipt_path, + attribution_path, + no_verify, + }) +} -::: test -name: cost reports are byte-identical across runs over the same receipts log and table -given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines, runs: 2 } -expect: { bytes_equal: true } -::: +/// The placement root a module's committed output currently occupies, read off +/// its own source tree rather than any placement rule, so a relocation reads +/// where the bytes are and not where the rule now says they should be. Manifests +/// are user-owned and never appear in committed output, so the source directory +/// marks the crate root. +fn derive_old_root(files: &BTreeMap>, target: &TargetDef) -> Result { + let marker = format!("/{}/", target.source_dir); + let mut roots: Vec = files + .keys() + .filter_map(|path| path.split_once(&marker).map(|(root, _)| root.to_string())) + .filter(|root| !root.is_empty()) + .collect(); + roots.sort(); + roots.dedup(); + match roots.len() { + 1 => Ok(roots.remove(0)), + _ => Err(error( + OutcomeCode::MappingIntegrity, + format!( + "committed output does not carry exactly one {} source tree", + target.source_dir + ), + )), + } +} -::: test -name: cost session reports one session's calls directly from the log -given: { workspace: fixture_two_modules, log: proxy_log_two_sessions, session: s1 } -expect: { rows_for_session: 2, rows_for_other_sessions: 0, priced_from_log: true } -::: +/// Re-root a module's owned files from `old_root` to `dest`. A relocation moves +/// bytes and recomputes the path prefix only; manifests are the user's to +/// update, so nothing here rewrites one. +pub fn relocate_files( + files: &BTreeMap>, + old_root: &str, + dest: &str, +) -> Result>> { + let prefix = format!("{old_root}/"); + let mut out: BTreeMap> = BTreeMap::new(); + for (path, bytes) in files { + let rest = path.strip_prefix(&prefix).ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("committed path {path:?} is outside the placement root {old_root:?}"), + ) + })?; + out.insert(format!("{dest}/{rest}"), bytes.clone()); + } + Ok(out) +} -::: test -name: a proxy log line failing the shape validates invalid and stays visible as failed -given: { evidence: proxy_log_with_malformed_line, media_type: application_x_napl_usage_proxy_jsonl_v1 } -expect: { outcome: invalid, visible_as_failed: true } -::: +/// The `/`-separated relative path from directory `from` to directory `to`, +/// where both are workspace-relative and drawn from the portable grammar. +pub fn relative_dir(from: &str, to: &str) -> String { + let from_parts: Vec<&str> = from.split('/').filter(|p| !p.is_empty()).collect(); + let to_parts: Vec<&str> = to.split('/').filter(|p| !p.is_empty()).collect(); + let mut common = 0; + while common < from_parts.len() + && common < to_parts.len() + && from_parts[common] == to_parts[common] + { + common += 1; + } + let mut parts: Vec = Vec::new(); + for _ in common..from_parts.len() { + parts.push("..".to_string()); + } + for part in &to_parts[common..] { + parts.push((*part).to_string()); + } + if parts.is_empty() { + ".".to_string() + } else { + parts.join("/") + } +} +napl-store blob v1 7179 +//! Receipts: what a producer reports, and what the compiler refuses to believe. +//! +//! A receipt states the identities its work was generated against and the output +//! it produced. Every one of those is independently recomputed at commit. The +//! optional reported fields — producer identity, usage, gate observations, +//! notes, evidence — are provenance: they are recorded, they never enter the +//! semantic identity a transition is named by, and when absent they read as +//! `unavailable` rather than as zero. -::: test -name: cost over one receipt set and pricing version is byte-identical across runs and unknown for unknown models -given: { receipts: fixture_receipts, pricing_version: pinned, runs: 2, unknown_model_receipt: included } -expect: { reports_equal: true, unknown_model_cost: unknown } -::: +use std::collections::BTreeMap; -::: test -name: gc never deletes an object reachable from the current head and never blocks a concurrent commit -given: { workspace: fixture_two_modules, concurrent: [gc, valid_commit] } -expect: { reachable_deleted: false, commit_blocked: false, unreachable_swept: true } -::: +use napl_store::store_hash::ObjectKind; +use serde_json::{json, Value}; -::: test -name: fsck detects a corrupted object and a broken parent link -given: { workspace: fixture_two_modules, corrupt: [flip_byte_in_object, remove_parent_event] } -expect: { findings: [hash_mismatch, missing_parent], exit_nonzero: true } -::: +use crate::canon::{is_object_name, json_hash, parse_json}; +use crate::error::{error, OutcomeCode, Result}; +use crate::evidence::{check_evidence_bounds, check_reported_bounds, parse_evidence, EvidenceItem}; -::: test -name: restore applies an unambiguous move-repair plan and blocks on ambiguity -given: { workspace: fixture_moved_output, ambiguity: [none, two_candidates] } -expect: { unambiguous: installed, ambiguous: blocked } -::: +/// The receipt schema version this build accepts. +pub const RECEIPT_VERSION: u32 = 1; + +/// Largest canonical size the optional reported block may occupy. +pub const MAX_REPORTED_BYTES: usize = 512 * 1024; + +/// The value recorded for telemetry a producer did not report. +pub const UNAVAILABLE: &str = "unavailable"; + +/// A parsed and structurally valid receipt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Receipt { + pub receipt_version: u32, + pub module: String, + pub target: String, + pub input_key: String, + pub base_state_key: String, + pub slice_hash: String, + pub base_workspace_hash: String, + pub base_store_snapshot: Option, + pub candidate_tree_hash: String, + pub output_manifest: BTreeMap, + /// Provenance only: never hashed into a semantic identity. + pub reported: Value, + /// The evidence items the reported block carries, already bounds-checked. + pub evidence: Vec, +} + +/// Parse a receipt from JSON bytes. +pub fn parse_receipt(bytes: &[u8]) -> Result { + let value = parse_json(bytes, "receipt", OutcomeCode::InvalidReceipt)?; + let bad = |why: String| error(OutcomeCode::InvalidReceipt, why); -::: test -name: hover on an attributed prompt line resolves its generated spans -given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: within_entry } } -expect: { spans_resolved: true, notes_included: true } -::: + let version = value["receiptVersion"] + .as_u64() + .ok_or_else(|| bad("receipt has no numeric receiptVersion".into()))?; + if version != u64::from(RECEIPT_VERSION) { + return Err(error( + OutcomeCode::UnsupportedVersion, + format!("receiptVersion {version} is not {RECEIPT_VERSION}"), + )); + } -::: test -name: "hover on an unattributed line answers empty, not error" -given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: outside_entries } } -expect: { result: empty, error: false } -::: + let text = |name: &str| -> Result { + value[name] + .as_str() + .map(str::to_string) + .ok_or_else(|| bad(format!("receipt field {name:?} is missing or not a string"))) + }; + let identity = |name: &str| -> Result { + let raw = text(name)?; + if is_object_name(&raw) { + Ok(raw) + } else { + Err(bad(format!("receipt field {name:?} is not an object name"))) + } + }; -::: test -name: missing attribution records degrade to empty responses -given: { attribution: absent, request: hover } -expect: { result: empty, error: false, blocked: false } -::: + let base_store_snapshot = match &value["baseStoreSnapshot"] { + Value::Null => None, + Value::String(s) if is_object_name(s) => Some(s.clone()), + _ => return Err(bad("baseStoreSnapshot must be null or an object name".into())), + }; -::: test -name: definition from a generated line targets the prompt at its discovered placement -given: { attribution: fixture_attribution_small, prompt_placement: inside_crate_dir, request: definition, position: { file: generated_within_entry } } -expect: { location_file: discovered_prompt_path, root_joined_module_name: false } -::: + let entries = value["outputManifest"] + .as_array() + .ok_or_else(|| bad("receipt has no outputManifest array".into()))?; + let mut output_manifest: BTreeMap = BTreeMap::new(); + for entry in entries { + let path = entry["path"] + .as_str() + .ok_or_else(|| bad("outputManifest entry has no path".into()))?; + let hash = entry["hash"] + .as_str() + .filter(|h| is_object_name(h)) + .ok_or_else(|| bad(format!("outputManifest entry {path:?} has no blob hash")))?; + if output_manifest + .insert(path.to_string(), hash.to_string()) + .is_some() + { + return Err(bad(format!("outputManifest lists {path:?} twice"))); + } + } -::: test -name: evidence failing its validator is invalid and distinguishable from unsupported -given: { receipt_evidence: [valid_claude_json, malformed_claude_json, unknown_media_type] } -expect: { outcomes: [validated, invalid, unsupported] } -::: + // Bounds are enforced before anything is published, so an oversized receipt + // can never leave a half-written store behind. + let evidence = parse_evidence(&value["reported"])?; + check_evidence_bounds(&evidence)?; + let reported = normalize_reported(&value["reported"]); + check_reported_bounds(&reported, MAX_REPORTED_BYTES)?; -::: test -name: a receipt exceeding evidence bounds is rejected as resource-limit with nothing published -given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: oversized_evidence } -expect: { outcome: resource_limit, published_semantic_state: false, published_provenance: false } -::: + Ok(Receipt { + receipt_version: RECEIPT_VERSION, + module: text("module")?, + target: text("target")?, + input_key: identity("inputKey")?, + base_state_key: identity("baseStateKey")?, + slice_hash: identity("sliceHash")?, + base_workspace_hash: identity("baseWorkspaceHash")?, + base_store_snapshot, + candidate_tree_hash: identity("candidateTreeHash")?, + output_manifest, + reported, + evidence, + }) +} -## Determinism contract +/// Fill absent telemetry with `unavailable`; never with zero. +pub fn normalize_reported(value: &Value) -> Value { + let pick = |name: &str| -> Value { + match value.get(name) { + Some(Value::Null) | None => json!(UNAVAILABLE), + Some(found) => found.clone(), + } + }; + json!({ + "evidence": pick("evidence"), + "gates": pick("gates"), + "notes": pick("notes"), + "producer": pick("producer"), + "usage": pick("usage"), + }) +} -Every collection canonically ordered; every input explicit and content -identified; serialization byte-pinned; ambient environment excluded -(locale, timezone, machine identity, CPU count, enumeration order); reads -never write; a state transition names its base and fails on mismatch per -the decision table rather than recalculating. Same workspace compiled -twice, from different absolute paths, under different discovery orders, -locales, and timezones produces byte-identical artifacts. The test suite -proves the vectors in the frontmatter with deterministic fixtures -constructed in each crate's `tests/`. +/// The semantic body of a receipt: everything the compiler recomputes, and +/// nothing a producer merely reports. +pub fn receipt_semantic_json(receipt: &Receipt) -> Value { + let manifest: Vec = receipt + .output_manifest + .iter() + .map(|(path, hash)| json!({ "hash": hash, "path": path })) + .collect(); + json!({ + "baseStateKey": receipt.base_state_key, + "baseStoreSnapshot": receipt.base_store_snapshot, + "baseWorkspaceHash": receipt.base_workspace_hash, + "candidateTreeHash": receipt.candidate_tree_hash, + "contractVersion": crate::version::CONTRACT_VERSION, + "inputKey": receipt.input_key, + "module": receipt.module, + "outputManifest": manifest, + "receiptVersion": receipt.receipt_version, + "sliceHash": receipt.slice_hash, + "target": receipt.target, + }) +} -::: test -name: compiling the same workspace twice yields byte-identical artifacts -given: { workspace: fixture_two_modules, runs: 2 } -expect: { artifacts_equal: true } -::: +/// The canonical JSON body of the receipt object as it is recorded, provenance +/// included. +pub fn receipt_json(receipt: &Receipt) -> Value { + let mut body = receipt_semantic_json(receipt) + .as_object() + .cloned() + .expect("the semantic body is an object"); + body.insert("reported".into(), receipt.reported.clone()); + Value::Object(body) +} -::: test -name: artifact bytes are independent of discovery order -given: { workspace: fixture_two_modules, discovery_orders: [sorted, reversed, shuffled_seed_7] } -expect: { artifacts_equal: true } -::: +/// The identity a transition names a receipt by. +/// +/// It covers the semantic body only, so two receipts that differ solely in +/// reported telemetry name the same transition. +pub fn receipt_semantic_hash(receipt: &Receipt) -> String { + json_hash(ObjectKind::Receipt, &receipt_semantic_json(receipt)) +} -::: test -name: artifact bytes are independent of the absolute workspace path -given: { workspace: fixture_two_modules, mounted_at: [rootA, rootB] } -expect: { artifacts_equal: true } -::: +/// The content identity of the recorded receipt object, provenance included. +pub fn receipt_hash(receipt: &Receipt) -> String { + json_hash(ObjectKind::Receipt, &receipt_json(receipt)) +} -::: test -name: canonical json sorts keys and is byte-stable -given: { value: "{\"b\":1,\"a\":2}", encodings: 2 } -expect: { bytes_equal: true, sorted_keys: true } -::: -napl-store surface v1 4888 -{"contractVersion":4,"items":[{"kind":"const","path":"git_store::CONTRACT_TRAILER","signature":"pub const CONTRACT_TRAILER : & str = \" Napl-Contract \" ;"},{"kind":"struct","path":"git_store::ConversionOutcome","signature":"pub struct ConversionOutcome"},{"kind":"field","path":"git_store::ConversionOutcome.commit","signature":"pub commit : String"},{"kind":"field","path":"git_store::ConversionOutcome.objects","signature":"pub objects : usize"},{"kind":"field","path":"git_store::ConversionOutcome.snapshot","signature":"pub snapshot : Option < String >"},{"kind":"struct","path":"git_store::GitProvider","signature":"pub struct GitProvider"},{"kind":"method","path":"git_store::GitProvider::commit_transaction","signature":"pub fn commit_transaction ( & self , expected_snapshot : Option < & str > , snapshot : & str , contract : u32 ) -> Result < TransactionOutcome , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::commit_transaction_observed","signature":"pub fn commit_transaction_observed ( & self , expected_snapshot : Option < & str > , snapshot : & str , contract : u32 , on_prepared : & mut dyn FnMut ( ) ) -> Result < TransactionOutcome , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::committed_objects","signature":"pub fn committed_objects ( & self ) -> Result < BTreeMap < String , gix :: ObjectId > , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::contract_of","signature":"pub fn contract_of ( & self , commit : gix :: ObjectId ) -> Result < Option < u32 > , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::convert_tracked_store","signature":"pub fn convert_tracked_store ( & self , store_root : & Path ) -> Result < ConversionOutcome , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::open","signature":"pub fn open ( repository : & Path , local_root : & Path ) -> Result < GitProvider , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::publish_envelope","signature":"pub fn publish_envelope ( & self , name : & str , bytes : & [ u8 ] ) -> Result < ( ) , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::publish_object","signature":"pub fn publish_object ( & self , kind : ObjectKind , payload : & [ u8 ] ) -> Result < String , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::read_object","signature":"pub fn read_object ( & self , name : & str ) -> Result < Option < Vec < u8 > > , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::snapshot_of","signature":"pub fn snapshot_of ( & self , commit : gix :: ObjectId ) -> Result < Option < String > , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::tip","signature":"pub fn tip ( & self ) -> Result < Option < gix :: ObjectId > , GitStoreError >"},{"kind":"method","path":"git_store::GitProvider::union_merge","signature":"pub fn union_merge ( & self , other : gix :: ObjectId ) -> Result < UnionOutcome , GitStoreError >"},{"kind":"enum","path":"git_store::GitStoreError","signature":"pub enum GitStoreError { Io ( String ) , Integrity ( String ) , HeadMoved { expected : Option < String > , found : Option < String > } , Git ( String ) }"},{"kind":"const","path":"git_store::IDENTITY_EMAIL","signature":"pub const IDENTITY_EMAIL : & str = \" store@napl \" ;"},{"kind":"const","path":"git_store::IDENTITY_NAME","signature":"pub const IDENTITY_NAME : & str = \" napl-store \" ;"},{"kind":"const","path":"git_store::PENDING_FILE","signature":"pub const PENDING_FILE : & str = \" GIT-PENDING \" ;"},{"kind":"const","path":"git_store::SNAPSHOT_TRAILER","signature":"pub const SNAPSHOT_TRAILER : & str = \" Napl-Snapshot \" ;"},{"kind":"const","path":"git_store::STORE_BRANCH","signature":"pub const STORE_BRANCH : & str = \" refs/heads/napl-store \" ;"},{"kind":"struct","path":"git_store::TransactionOutcome","signature":"pub struct TransactionOutcome"},{"kind":"field","path":"git_store::TransactionOutcome.commit","signature":"pub commit : String"},{"kind":"field","path":"git_store::TransactionOutcome.objects","signature":"pub objects : usize"},{"kind":"field","path":"git_store::TransactionOutcome.tree","signature":"pub tree : String"},{"kind":"struct","path":"git_store::UnionOutcome","signature":"pub struct UnionOutcome"},{"kind":"field","path":"git_store::UnionOutcome.commit","signature":"pub commit : String"},{"kind":"field","path":"git_store::UnionOutcome.objects","signature":"pub objects : usize"},{"kind":"field","path":"git_store::UnionOutcome.unmerged_entries","signature":"pub unmerged_entries : usize"},{"kind":"fn","path":"git_store::commit_message","signature":"pub fn commit_message ( snapshot : & str , contract : u32 ) -> String"},{"kind":"fn","path":"git_store::trailer_value","signature":"pub fn trailer_value ( message : & str , key : & str ) -> Option < String >"}],"module":"git-store","target":"rust"} -napl-store tree v1 129 -{"entries":[{"hash":"ff0e2e5e8660e7185fc33bd4fbcb40c4c8ddfcd4b5c5231c31cc4ade5c1d238c","kind":"dir","mode":16384,"name":"src"}]} -napl-store tree v1 252 -{"entries":[{"hash":"2a8ec239d786d46d6bc7953121850de4aea1db75f15f4b5660469bb86eef5417","kind":"file","mode":33188,"name":"Cargo.toml"},{"hash":"d8f1404ff37c120ba753827f21ccb70d3a8829aa5c6f7e96b746a12df4d7e4bc","kind":"dir","mode":16384,"name":"src"}]} -napl-store event v1 158 -{"content":"49444e7934b9431dd7f4c8b63c158344c7521dd59af343f8307d515a3b7d250c","parents":["51ea9c40dc0d97e9bfe016daed54dee7f9b82b544ca8d76349e9b8020d9cfe40"]} -napl-store tree v1 130 -{"entries":[{"hash":"e582e40a241346ddc9877893e0b6f583abeb112dbc20113867dc72ec3c767379","kind":"dir","mode":16384,"name":"napl"}]} -napl-store blob v1 34003 -//! The read commands and the maintenance commands. +/// The reported usage, or the `unavailable` marker when the producer reported +/// none. Never a fabricated zero. +pub fn reported_usage(receipt: &Receipt) -> Value { + receipt.reported["usage"].clone() +} +napl-store module-state v1 558 +{"attribution":{"promptHash":"77b321d0dd911de45787b1c235e91ee661d8fc9ebe0b4058e3d47da9633ce406","record":"074f79d004b82eddbcb9baa87700dbf0cb5ae826f94c1d886dceaa4ec79e23d8","stale":false},"contractVersion":4,"inputKey":"40b3c8b554c285c63779abe06bafd53bc68686fa1e5407ee41c664aad568c38f","module":"napl","outputTree":"be2a56ff9d16650df9a1b6ca6aae9e3e1b88c013c2a0548bf364a7bad344b0bd","sliceHash":"39040e50efd44a2cf7fc988ab39c9e38cbed6b8f32e133bbe8ccad4d07fd67b0","surfaceHash":"fe6954097a004d01c836063d2833c9bc8476458ec8019470104e81054b94fa0d","target":"rust"} +napl-store blob v1 72377 +//! `napl add`: stage a module state; `napl commit --staged`: install what add staged. //! -//! Every read resolves HEAD exactly once and then answers entirely from objects -//! reachable from that snapshot. No read creates a directory, takes a lock, or -//! writes a byte, so the whole read surface works on a read-only filesystem. +//! Git parity is the mental model: add stages, commit installs. The staged +//! record is the internal receipt between them, never a user-facing noun. It is +//! transient coordination state, disposable litter by the git-state law: never +//! promoted to history, safely deletable, and re-creatable by re-running add. +//! Add ingests the staged bytes into an immutable copy and every later step +//! reads that copy, so tampering with the source files after add cannot change +//! what commit installs. That holds for all three sources equally, because all +//! three become a list of scanned entries before ingestion sees them. +//! +//! The working tree is the default source, exactly as in git. `napl add ` +//! names files and stages the owning module's committed output with those files +//! overlaid; `napl add --module ` stages the module's whole owned tree as +//! it sits on disk; `napl add --module ` stages explicit +//! bytes, for the rare case where what must be staged is not the working tree. +//! +//! Attribution transports rather than being demanded. Add diffs the committed +//! output against the staged bytes and the committed prompt body against the +//! pending one, and carries every recorded claim through those diffs. A claim the +//! diff proves survives; a claim it does not prove is asked for, never invented. use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; +use std::path::{Path, PathBuf}; -use crate::blame::{blame, blame_json, untracked_json, Revision}; -use crate::compile::{compile, graph_for, read_surface_items, state_of}; -use crate::cost::{cost_report, line_from_reported, CostLine}; -use crate::error::{error, OutcomeCode, Result}; -use crate::graph::{topological_levels, Graph}; -use crate::slice::receipt_requirements; -use crate::lint::{lint, lint_json, LintInput, LintReport}; -use crate::maintenance::{ - fsck, fsck_json, gc_json, plan_restore, plan_sweep, reachable_from_head, restore_json, - FileFact, RestorePlan, -}; -use crate::objects::{ - event_transition, read_module_state, snapshot_event, snapshot_states, walk_events, ModuleState, -}; -use crate::receipt::normalize_reported; -use crate::store_view::{ - flatten_tree, get_blob, get_json, get_object, has_object, ObjectSource, -}; -use crate::surface::surface_json; -use crate::target::{ - builtin_target, is_manifest_path, owned_globs, placement_root, projection_owned_paths, TargetDef, -}; -use crate::textdiff::render_drift; -use napl_store::store_hash::ObjectKind; use serde_json::{json, Value}; +use crate::attribution::{self, Attribution}; +use crate::authoring::draft_receipt_for; +use crate::candidate::{default_limits, ingest, Candidate, EntryKind, ScannedEntry}; +use crate::commit::{commit_with_mode, AttributionMode, CommitReport}; use crate::disk::{self, Store}; -use crate::workspace::{discover_sources, read_working_text}; +use crate::error::{error, error_payload, OutcomeCode, Result}; +use crate::objects::{read_module_state, snapshot_states}; +use crate::source::SourceModule; +use crate::store_view::{flatten_tree, get_blob, ObjectSource}; +use crate::target::{builtin_target, is_owned_path, owned_globs, placement_root, TargetDef}; +use crate::transaction::check_candidate_shape; +use crate::transport::{ + refusal_detail, refusal_payload, transport_attribution, TransportInputs, TransportOutcome, +}; +use crate::workspace::{discover_sources, scan_candidate, write_file}; -/// A snapshot-consistent status report for one target: observation, zero -/// mutation. -pub fn status(workspace: &Path, target: &str) -> Result { - let store = disk::open(workspace); - let head = store.head()?; - let states = match &head { - Some(snapshot) => snapshot_states(&store, snapshot)?, - None => BTreeMap::new(), - }; +/// The disposable staging area for `add`, workspace-relative. +pub const STAGING_DIR: &str = ".napl/staging"; + +/// Where the bytes `add` stages come from. +/// +/// Three sources, one pipeline: each resolves to a list of scanned entries, and +/// from ingestion onward nothing downstream can tell them apart. +#[derive(Debug, Clone)] +pub enum AddSource { + /// Explicit candidate bytes in a directory: the exception, not the routine. + CandidateDir(PathBuf), + /// The module's whole owned tree, as it sits in the working tree. + WorkingTree, + /// The module's committed output with exactly these workspace-relative paths + /// overlaid from the working tree. + Overlay(Vec), +} + +/// Stage one module state from any of the three sources. +/// +/// Ingest the staged bytes into immutable blobs, validate against the slice, +/// resolve attribution by transport or affirmation, attach usage evidence, then +/// write the staged record. Validation failures surface here with the same +/// outcome classes commit uses. +#[allow(clippy::too_many_arguments)] +pub fn add( + workspace: &Path, + target_name: &str, + module: &str, + source: &AddSource, + attribution_path: Option<&Path>, + usage_path: Option<&Path>, + no_verify: bool, + check: bool, +) -> Result { + let target = builtin_target(target_name)?; let sources = discover_sources(workspace)?; - let artifact = compile(&store, &sources, target, None)?; - let pending: Vec = artifact - .slices - .iter() - .map(|slice| slice.module.clone()) - .collect(); + let prompt = sources.iter().find(|s| s.module == module).ok_or_else(|| { + error( + OutcomeCode::MappingIntegrity, + format!("module {module:?} has no prompt file in the workspace"), + ) + })?; + let root = placement_root(prompt); + let store = disk::open(workspace); - let mut modules: Vec = Vec::new(); - for ((state_target, module), state_hash) in &states { - if state_target != target { - continue; - } - let state = read_module_state(&store, state_hash)?; - let freshness = attribution_freshness(&state, module, target, &sources); - let prompt_stale = freshness["status"].as_str() != Some("fresh"); - let ends = both_ends(workspace, &store, &state, prompt_stale)?; - modules.push(json!({ - "attribution": freshness, - "baseStateKey": state_hash, - "drift": ends.drift, - "inputKey": state.input_key, + // Resolve the source to entries, then ingest once into immutable bytes; + // every later step reads these, never the files they came from. + let entries = scan_source(workspace, &store, &target, module, &sources, source)?; + let candidate = ingest(&entries, default_limits())?; + check_candidate_shape(&target, &root, &candidate)?; + // The parse gate, before anything reads the bytes as a surface: every staged + // file the target claims as source has to parse under the target's pinned + // grammar, or the record refuses naming the file, the first error's line, and + // the malformed region. Syntax only; everything past it is the battery's job. + // + // The refusal enumerates the same validations a passing `--check` verdict + // enumerates, so the reader learns which gate stopped them in the same + // vocabulary that would have told them everything passed. + if let Err(err) = crate::surface::parse_gate(&target, &root, &candidate.files) { + let mut data = match err.data { + Some(Value::Object(map)) => map, + _ => serde_json::Map::new(), + }; + data.insert("module".into(), json!(module)); + data.insert("notJudged".into(), not_judged()); + data.insert( + "validations".into(), + validations_run(attribution_path.is_some()), + ); + return Err(crate::error::error_with( + err.code, + err.detail, + Value::Object(data), + )); + } + + // The receipt is drafted against the ingested copy, not against files on + // disk, so the whole verdict is computable before anything is written. That + // is what makes `--check` a real dry run rather than a staged record deleted + // afterwards. + let receipt = draft_receipt_for(workspace, target_name, module, &candidate, usage_path)?; + + // Resolve the attribution update: transported where the diffs prove it, + // affirmed where they do not, refused when neither. + let attribution_bytes = resolve_attribution( + workspace, + &store, + &target, + &root, + module, + prompt, + &candidate, + attribution_path, + no_verify, + )?; + + if check { + // The dry run has now run the entire pipeline, transport verdict and + // refusal listing included, and it writes nothing: no staged record, no + // entry in the staging directory, no store byte touched. + // + // And it says exactly what it judged. `would-stage` is a verdict about a + // record, not about code: the staged bytes could be a file that does not + // parse in the target language and this command would still, correctly, + // answer would-stage. Enumerating the validations that ran beside the one + // that deliberately did not is what keeps the two from being confused. + return Ok(json!({ + "checked": true, + "contractVersion": crate::version::CONTRACT_VERSION, "module": module, - "outputTree": state.output_tree, - "pending": pending.contains(module), - "sliceHash": state.slice_hash, - "staleReferences": ends.stale_references as u64, - "surfaceHash": state.surface_hash, + "notJudged": not_judged(), + "outcome": "would-stage", + "staged": false, + "target": target_name, + "validations": validations_run(attribution_path.is_some()), })); } + + // A module record carries the whole prompt with it: commit records the + // working-tree prompt file as the module's prompt object, docs fences + // included. A docs record staged for the same module is therefore redundant + // the moment this one exists, and leaving both staged would make a bare + // commit's ordering decide which prose lands. The module door supersedes it. + sweep_docs_records(workspace, module, target_name)?; + let record_dir = record_dir(workspace, module, &candidate.tree_hash); + if record_dir.exists() { + std::fs::remove_dir_all(&record_dir).map_err(|e| disk::io(&record_dir, &e))?; + } + let staged_dir = record_dir.join("candidate"); + for (path, bytes) in &candidate.files { + write_file(&staged_dir.join(path), bytes)?; + } + + write_file( + &record_dir.join("receipt.json"), + &crate::canon::canonical_bytes(&receipt), + )?; + let attribution_present = match &attribution_bytes { + Some(bytes) => { + write_file(&record_dir.join("attribution.yaml"), bytes)?; + true + } + None => false, + }; + write_record( + &record_dir, + module, + target_name, + &candidate.tree_hash, + attribution_present, + if no_verify { + AttributionMode::Escape + } else { + AttributionMode::Prove + }, + )?; + Ok(json!({ "contractVersion": crate::version::CONTRACT_VERSION, - "head": head, - "manifestDiagnostics": manifest_diagnostics(workspace, target, &sources), - "modules": modules, - "pending": pending, - "target": target, + "module": module, + "outcome": "staged", + "record": display_path(workspace, &record_dir), + "target": target_name, })) } -/// What status observes about a module's code side, so status judges both ends. +/// The validations `--check` actually ran, in the order the pipeline runs them. /// -/// `drift` is true when the committed output differs from the working tree, and -/// `stale_references` counts the module's references reading stale right now. Both -/// come from the derivations `diff` and the language server already serve, which -/// is the whole point: status can never read clean while the query surface reads -/// stale, because there is one derivation and not two. -struct BothEnds { - drift: bool, - stale_references: usize, +/// Every one of these is a fact about the record: where the bytes may live, what +/// shape the ingest accepts, whether the recorded claims still point at +/// something, and whether the supplied evidence validates. None of them is a +/// fact about whether the code works. +fn validations_run(attribution_supplied: bool) -> Value { + json!([ + { + "detail": "every staged path lies inside the module's placement root and its owned globs, no path is a user-owned manifest, and no path is a derived entry-file projection", + "judged": true, + "name": "placement", + }, + { + "detail": "the staged bytes ingest into content-addressed blobs: no links or devices, no duplicate normalized paths, no absolute or parent-escaping path, nothing over the compiled-in size bounds", + "judged": true, + "name": "ingest", + }, + { + "detail": "every staged file the target claims as source parsed under the target's pinned tree-sitter grammar; a parse tree carrying an error node refuses as invalid-candidate naming the file, the first error's line, and the malformed region", + "judged": true, + "name": "parse gate", + "scope": crate::surface::SOURCE_SYNTAX_SCOPE, + }, + { + "detail": if attribution_supplied { + "every recorded claim was carried through the committed-to-staged and committed-to-pending diffs, and the supplied --attribution map was layered over the transported remainder and revalidated" + } else { + "every recorded claim was carried through the committed-to-staged and committed-to-pending diffs, and any claim the diffs do not prove is listed with both endpoints' verdicts" + }, + "judged": true, + "name": "attribution transport", + }, + { + "detail": "any supplied usage envelope was validated by its media type's registered validator and checked against the compiled-in evidence bounds", + "judged": true, + "name": "evidence", + }, + ]) } -fn both_ends( +/// What `--check` deliberately did not judge. +/// +/// The dry run answers a question about staging and about syntax. Whether the +/// code type checks, borrow checks, or passes its tests is the acceptance +/// battery's question, and this command never ran it, so `would-stage` means the +/// record is sound and the syntax parses, never that the code is correct. +fn not_judged() -> Value { + json!([ + { + "detail": "whether the staged bytes compile, type check, borrow check, or pass their tests is the acceptance battery's job and nothing here ran it; the parse gate judged syntax and only syntax, so would-stage means the record is sound and the syntax parses, never that the code is correct", + "judged": false, + "name": "everything past syntax", + }, + ]) +} + +/// `napl add [...]`: stage one record per owning module. +/// +/// Each path resolves to its owning module through the committed output manifest, +/// which is unique by the commit invariant that two modules never claim one path, +/// so no `--module` is needed. A path no module owns is a usage error that names +/// the owned globs which do exist, because the useful answer to "this is not +/// owned" is "here is what is". +/// +/// `napl add --module ` is the same form with the resolution +/// declared instead of derived: a file that is new in a crate several modules +/// share matches every one of their identical globs and is committed by none of +/// them, so neither the manifest nor the globs can name an owner. The author +/// names it, and the form exists precisely so that answer is sayable. +#[allow(clippy::too_many_arguments)] +pub fn add_working_tree_paths( workspace: &Path, - store: &Store, - state: &ModuleState, - prompt_stale: bool, -) -> Result { - let mut drift = false; - let mut drifted_paths: BTreeSet = BTreeSet::new(); - if has_object(store, &state.output_tree) { - for (path, (blob, _mode)) in flatten_tree(store, &state.output_tree)? { - let bytes = std::fs::read(workspace.join(&path)).ok(); - // An unreadable working file is not drift: the reader knows nothing - // about it, so it claims nothing, exactly as the query surface does. - if crate::attribution::code_drifted(Some(blob.as_str()), bytes.as_deref()) { - drift = true; - drifted_paths.insert(path); + cwd: &Path, + target_name: &str, + given: &[String], + explicit_module: Option<&str>, + attribution_path: Option<&Path>, + usage_path: Option<&Path>, + no_verify: bool, + check: bool, +) -> Result { + let target = builtin_target(target_name)?; + let sources = discover_sources(workspace)?; + let store = disk::open(workspace); + let ownership = committed_ownership(&store, target_name)?; + + let mut grouped: BTreeMap> = BTreeMap::new(); + let mut prompts: Vec = Vec::new(); + for raw in given { + let path = workspace_relative(workspace, cwd, raw); + // A module's own prompt path is the one path add accepts that no owned + // glob names, and it is accepted for exactly one purpose: recording a + // docs edit. Naming it under `--module` is a different sentence and stays + // an unowned path, because the docs door is the path alone. + if explicit_module.is_none() { + if let Some(prompt) = sources.iter().find(|source| source.path == path) { + prompts.push(prompt.clone()); + continue; } } + let owner = match explicit_module { + Some(named) => Some(named.to_string()), + None => ownership + .get(&path) + .cloned() + .or_else(|| sole_glob_owner(&target, &sources, &path, &ownership)), + }; + let Some(owner) = owner else { + return Err(unowned_path(&target, &sources, &path)); + }; + grouped.entry(owner).or_default().push(path); } - let entries = match &state.attribution { - Some(recorded) if has_object(store, &recorded.record) => { - let bytes = get_blob(store, &recorded.record)?; - crate::attribution::parse(&String::from_utf8_lossy(&bytes)) - .map(|record| record.entries) - .unwrap_or_default() + + let mut records: Vec = Vec::new(); + for prompt in &prompts { + if attribution_path.is_some() || usage_path.is_some() { + return Err(crate::cli::usage(&format!( + "{:?} is module {:?}'s prompt, and a prompt path records a docs edit: the new prompt bytes and nothing else, no attribution work and no usage. Drop --attribution and --usage, or stage code with `napl add --module {}`", + prompt.path, prompt.module, prompt.module + ))); } - _ => Vec::new(), + records.push(add_docs(workspace, target_name, prompt, check)?); + } + for (module, paths) in grouped { + records.push(add( + workspace, + target_name, + &module, + &AddSource::Overlay(paths), + attribution_path, + usage_path, + no_verify, + check, + )?); + } + Ok(staged_payload(target_name, records)) +} + +/// What one prompt file has done to the committed prompt it is judged against. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PromptDrift { + /// The two files are byte-identical, prose included. There is no docs edit + /// to record. + Unchanged, + /// Prose moved inside the docs fences and nothing else did: the one drift a + /// docs record carries. + DocsOnly, + /// The module state names no prompt object, so the store holds no committed + /// copy of the file. Nothing can be called drift against a baseline that does + /// not exist, and the module's next ordinary commit records one. + NoBaseline, + /// The frontmatter moved. `deps`, `targets`, `roots` and `tests` all feed + /// identities the module is keyed on, so this is a module change wearing a + /// docs edit's clothes. + Frontmatter { + line: u32, + committed: String, + working: String, + }, + /// The drift reached outside the docs fences. The line is the first semantic + /// body line that moved, in the coordinate system attribution endpoints name. + Semantic { + line: u32, + committed: String, + working: String, + }, +} + +/// Judge one prompt file against a committed baseline. +/// +/// Two byte strings have to hold still for a docs edit to be a docs edit: the +/// frontmatter, because `deps` feeds the input key and `tests` feeds the case +/// set, and the semantic body, because it is what every identity derivation +/// consumes. Prose is what is left, and prose is all a docs record may move. The +/// judgment is a pure function of the two files, so `add`, the empty-commit +/// teacher, `status` and the install recheck can never disagree about it. +pub fn judge_prompt(path: &str, committed: &str, working: &str) -> Result { + if committed == working { + return Ok(PromptDrift::Unchanged); + } + let committed_front = crate::source::frontmatter_text(path, committed)?; + let working_front = crate::source::frontmatter_text(path, working)?; + if committed_front != working_front { + let (line, was, is) = first_difference(&committed_front, &working_front); + return Ok(PromptDrift::Frontmatter { + line, + committed: was, + working: is, + }); + } + let committed_body = crate::source::parse_source(path, committed)?.normalized_body; + let working_body = crate::source::parse_source(path, working)?.normalized_body; + if committed_body != working_body { + let (line, was, is) = first_difference(&committed_body, &working_body); + return Ok(PromptDrift::Semantic { + line, + committed: was, + working: is, + }); + } + Ok(PromptDrift::DocsOnly) +} + +/// Judge the working-tree prompt of one module against its committed state, +/// answering the verdict beside the state key the verdict was reached against. +/// +/// That key is the base a docs record names. Reading it here and carrying it on +/// the record is what makes the install recheck a real one: a transition names +/// its base and fails on mismatch rather than recalculating a base from whatever +/// the head happens to be when commit runs. +fn prompt_drift( + store: &Store, + target_name: &str, + prompt: &SourceModule, +) -> Result<(PromptDrift, String)> { + let Some(state) = crate::porcelain::committed_state(store, target_name, &prompt.module)? else { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "module {:?} has no committed state for target {target_name:?}, so there is no committed prompt for a docs edit to differ from; commit the module first with `napl add --module {}`", + prompt.module, prompt.module + ), + )); }; - let stale_references = entries - .iter() - .filter(|entry| { - let code_drift = entry - .file - .as_ref() - .map(|file| drifted_paths.contains(file)) - .unwrap_or(false); - crate::attribution::reference_status(prompt_stale, false, code_drift).stale - }) - .count(); - Ok(BothEnds { - drift, - stale_references, - }) + let base_state_key = crate::objects::module_state_hash(&state); + let Some(object) = &state.prompt else { + return Ok((PromptDrift::NoBaseline, base_state_key)); + }; + let committed = committed_prompt(store, object, &prompt.module)?; + let working = std::fs::read_to_string(store.workspace.join(&prompt.path)) + .map_err(|e| disk::io(&store.workspace.join(&prompt.path), &e))?; + Ok(( + judge_prompt(&prompt.path, &committed, &working)?, + base_state_key, + )) } -/// The module PLAN: the invariant requirements a conforming implementation -/// derives from the working tree's prompts alone. Owned globs, placement root, -/// projection ownership, receipt requirements, and wave position — never any -/// candidate-dependent fact, which is `add`'s job. -pub fn module_plan(workspace: &Path, target_name: &str, module: &str) -> Result { - let sources = discover_sources(workspace)?; - let target = builtin_target(target_name)?; - let source = sources.iter().find(|s| s.module == module).ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} has no prompt file in the workspace"), - ) - })?; - let graph = graph_for(&sources, target_name)?; - let levels = topological_levels(&graph)?; - let wave = *levels.get(module).ok_or_else(|| { +/// The committed prompt file a state's prompt object names. +pub fn committed_prompt(store: &Store, object: &str, module: &str) -> Result { + String::from_utf8(get_blob(store, object)?).map_err(|_| { error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} is not in the graph for target {target_name:?}"), + OutcomeCode::StoreIntegrity, + format!("the committed prompt of module {module:?} is not valid UTF-8"), ) - })?; - let root = placement_root(source); + }) +} + +/// The first line two bodies disagree on, one-based, beside both spellings. +/// +/// A body that is a prefix of the other disagrees at the first line past the +/// shorter one, so a pure insertion or deletion names a line rather than nothing. +fn first_difference(committed: &str, working: &str) -> (u32, String, String) { + let left: Vec<&str> = committed.lines().collect(); + let right: Vec<&str> = working.lines().collect(); + for index in 0..left.len().max(right.len()) { + let a = left.get(index).copied().unwrap_or_default(); + let b = right.get(index).copied().unwrap_or_default(); + if a != b { + return (index as u32 + 1, a.to_string(), b.to_string()); + } + } + (left.len().max(right.len()) as u32, String::new(), String::new()) +} + +/// `napl add `: stage a docs record. +/// +/// The record is the new prompt bytes and nothing else: no candidate tree, no +/// attribution work, no usage. Commit installs it by writing the new prompt +/// object and re-pointing the module's prompt at it, and every derived fact +/// holds. Drift that reaches outside the docs fences refuses here rather than at +/// commit, naming the first semantic line that moved, because a docs record that +/// smuggled a semantic edit would be an unreviewed module change. +fn add_docs( + workspace: &Path, + target_name: &str, + prompt: &SourceModule, + check: bool, +) -> Result { + let store = disk::open(workspace); + let (drift, base_state_key) = prompt_drift(&store, target_name, prompt)?; + match drift { + PromptDrift::Unchanged => { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "{:?} matches the committed prompt of module {:?} byte for byte, so there is no docs edit to record", + prompt.path, prompt.module + ), + )); + } + PromptDrift::NoBaseline => { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "module {:?} names no committed prompt object, so the store holds no copy of {:?} to prove a docs edit against; its next ordinary commit records that baseline and the docs door opens", + prompt.module, prompt.path + ), + )); + } + PromptDrift::Frontmatter { + line, + committed, + working, + } => { + return Err(crate::error::error_with( + OutcomeCode::InvalidCandidate, + format!( + "{:?} changed module {:?} in its frontmatter: line {line} moved from {committed:?} to {working:?}. The frontmatter declares dependencies, targets, roots and cases, every one of which the module is keyed on, so this is not a docs edit; stage a real change with `napl add --module {}`", + prompt.path, prompt.module, prompt.module + ), + json!({ + "committedLine": committed, + "frontmatterLine": line, + "module": prompt.module, + "promptPath": prompt.path, + "teaches": format!("napl add --module {}", prompt.module), + "workingLine": working, + }), + )); + } + PromptDrift::Semantic { + line, + committed, + working, + } => { + return Err(crate::error::error_with( + OutcomeCode::InvalidCandidate, + format!( + "{:?} changed module {:?} outside its docs fences: semantic body line {line} moved from {committed:?} to {working:?}. A prompt path records a docs edit and nothing else; stage a real change with `napl add --module {}`", + prompt.path, prompt.module, prompt.module + ), + json!({ + "committedLine": committed, + "module": prompt.module, + "promptPath": prompt.path, + "semanticLine": line, + "teaches": format!("napl add --module {}", prompt.module), + "workingLine": working, + }), + )); + } + PromptDrift::DocsOnly => {} + } + + let bytes = std::fs::read(workspace.join(&prompt.path)) + .map_err(|e| disk::io(&workspace.join(&prompt.path), &e))?; + if check { + return Ok(json!({ + "checked": true, + "contractVersion": crate::version::CONTRACT_VERSION, + "kind": DOCS_RECORD, + "module": prompt.module, + "notJudged": not_judged(), + "outcome": "would-stage", + "promptPath": prompt.path, + "staged": false, + "target": target_name, + "validations": docs_validations(), + })); + } + + let object = + napl_store::store_hash::object_name(napl_store::store_hash::ObjectKind::Blob, &bytes); + // One module has at most one staged docs record. Two edits in a row would + // otherwise leave two records keyed by two different prompt hashes, and a + // bare commit would install them in whatever order their names sort in, + // letting the older prose win. Re-running add replaces the record it + // supersedes, exactly as re-running add over a candidate does. + sweep_docs_records(workspace, &prompt.module, target_name)?; + let record_dir = record_dir(workspace, &prompt.module, &object); + if record_dir.exists() { + std::fs::remove_dir_all(&record_dir).map_err(|e| disk::io(&record_dir, &e))?; + } + // The staged bytes are the whole prompt file, so commit re-parses what add + // read rather than trusting a body add derived: the ingest law holds for the + // docs door exactly as it holds for the other three. + write_file(&record_dir.join(DOCS_PROMPT_FILE), &bytes)?; + let record = json!({ + "baseStateKey": base_state_key, + "kind": DOCS_RECORD, + "module": prompt.module, + "prompt": object, + "promptPath": prompt.path, + "receiptVersion": crate::receipt::RECEIPT_VERSION, + "target": target_name, + }); + write_file( + &record_dir.join("record.json"), + &crate::canon::canonical_bytes(&record), + )?; Ok(json!({ "contractVersion": crate::version::CONTRACT_VERSION, - "module": module, - "plan": { - "ownedGlobs": owned_globs(&target, &root), - "placementRoot": root, - "projectionOwned": projection_owned_paths(&target, &root), - "receiptRequirements": receipt_requirements(), - "wave": wave as u64, - }, + "kind": DOCS_RECORD, + "module": prompt.module, + "outcome": "staged", + "promptPath": prompt.path, + "record": display_path(workspace, &record_dir), "target": target_name, })) } -/// Observe, never mutate, the manifest/placement mismatches the toolchain now -/// only diagnoses: a module placed in a crate the workspace manifest does not -/// list, or a crate directory missing a manifest. Manifests are user-owned -/// seams, so these are reports, not writes. -fn manifest_diagnostics( - workspace: &Path, - target_name: &str, - sources: &[crate::source::SourceModule], -) -> Vec { - let Ok(target) = builtin_target(target_name) else { - return Vec::new(); +/// Remove every staged docs record for one module and target. +fn sweep_docs_records(workspace: &Path, module: &str, target_name: &str) -> Result<()> { + for record in staged_records(workspace)? { + if record.module != module || record.target != target_name { + continue; + } + if record_meta(&record.path)?["kind"].as_str() != Some(DOCS_RECORD) { + continue; + } + sweep_record(workspace, &record.path)?; + } + Ok(()) +} + +/// The `kind` a docs record carries, the one spelling commit branches on. +pub const DOCS_RECORD: &str = "docs"; + +/// The staged prompt file inside a docs record directory. +pub const DOCS_PROMPT_FILE: &str = "prompt.napl"; + +/// The validations a docs record's dry run actually ran. +fn docs_validations() -> Value { + json!([ + { + "detail": "the working-tree prompt parses, and its docs fences validate under the docs vocabulary", + "judged": true, + "name": "prompt grammar", + }, + { + "detail": "the semantic body is byte-identical to the committed one, so no identity the module is keyed on moved", + "judged": true, + "name": "docs-only drift", + }, + ]) +} + +/// The payload every `add` form reports. +/// +/// `records` is the whole truth, one entry per staged module. A single-record +/// invocation also states its module and record at the top level, because that is +/// what a caller pipes straight into `commit --staged`. +pub fn staged_payload(target_name: &str, records: Vec) -> Value { + let mut payload = serde_json::Map::new(); + payload.insert( + "contractVersion".into(), + json!(crate::version::CONTRACT_VERSION), + ); + // A dry run reports the verdict it would have staged under, never `staged`, + // because nothing was. + let checked = records + .iter() + .all(|record| record["checked"].as_bool().unwrap_or(false)); + payload.insert( + "outcome".into(), + json!(if checked && !records.is_empty() { + "would-stage" + } else { + "staged" + }), + ); + payload.insert("staged".into(), json!(!checked || records.is_empty())); + payload.insert("target".into(), json!(target_name)); + if records.len() == 1 { + payload.insert("module".into(), records[0]["module"].clone()); + if let Some(record) = records[0].get("record") { + payload.insert("record".into(), record.clone()); + } + // A dry run's verdict is the whole point of the invocation, so it is + // read at the top level and never dug out of the array. + for key in ["validations", "notJudged"] { + if let Some(value) = records[0].get(key) { + payload.insert(key.into(), value.clone()); + } + } + } + payload.insert("records".into(), Value::Array(records)); + Value::Object(payload) +} + +/// Resolve a path as the caller spelled it to a workspace-relative path. +/// +/// A path already spelled relative to the workspace wins, so the spelling every +/// napl payload uses round-trips. Otherwise it is resolved against the working +/// directory, which is what a shell completion produces. +fn workspace_relative(workspace: &Path, cwd: &Path, given: &str) -> String { + let normalized = given.replace('\\', "/"); + let direct = normalized.trim_start_matches("./").to_string(); + if workspace.join(&direct).exists() { + return direct; + } + let absolute = if Path::new(given).is_absolute() { + PathBuf::from(given) + } else { + cwd.join(given) + }; + match absolute.strip_prefix(workspace) { + Ok(rest) => rest.to_string_lossy().replace('\\', "/"), + Err(_) => direct, + } +} + +/// The committed `path -> module` ownership map for one target. +fn committed_ownership(store: &Store, target_name: &str) -> Result> { + let mut out: BTreeMap = BTreeMap::new(); + let Some(head) = store.head()? else { + return Ok(out); }; - let members = workspace_members(workspace, &target); - let mut roots: Vec = sources.iter().map(placement_root).collect(); - roots.sort(); - roots.dedup(); - let mut out: Vec = Vec::new(); - for root in roots { - if root.is_empty() { + for ((state_target, module), hash) in snapshot_states(store, &head)? { + if state_target != target_name { continue; } - if let Some(members) = &members { - if !members.contains(&root) { - out.push(manifest_diagnostic( - &root, - // The manifest this observation means is the workspace-root - // one, which holds the member list, not the crate's own. - &target.manifest_file, - "crate-not-listed-in-workspace-manifest", - &format!("the crate {root:?} holds a placed module but the workspace manifest does not list it as a member"), - )); - } + let state = read_module_state(store, &hash)?; + if !crate::store_view::has_object(store, &state.output_tree) { + continue; } - let manifest = workspace.join(&root).join(&target.manifest_file); - if !manifest.is_file() { - out.push(manifest_diagnostic( - &root, - &crate::paths::join_root(&root, &target.manifest_file), - "crate-directory-missing-manifest", - &format!("the crate directory {root:?} receives placed modules but has no manifest"), - )); + for path in flatten_tree(store, &state.output_tree)?.into_keys() { + out.insert(path, module.clone()); } } - out + Ok(out) } -/// One manifest observation, which always locates the file it means. +/// The module a not-yet-committed path belongs to, when exactly one module's +/// owned globs match it and no module has committed it. /// -/// A manifest is a user-owned seam: the toolchain never writes one, so a -/// diagnostic that only alluded to "the manifest" would leave the reader hunting -/// for the file they are the only one allowed to edit. Every diagnostic therefore -/// carries the exact workspace-relative path and says plainly whose file it is. -fn manifest_diagnostic( - crate_root: &str, - manifest: &str, - observation: &str, - detail: &str, -) -> Value { - json!({ - "crate": crate_root, - "detail": detail, - "manifest": manifest, - "observation": observation, - "owner": "user", - "remedy": format!("{manifest} is yours to edit; the toolchain diagnoses manifests and never writes them"), - }) +/// Where several prompts share one crate their owned globs are identical, so this +/// answers `None` rather than picking one: a new file in a shared crate is a +/// declaration the author has to make, through `--module` or an explicit +/// candidate directory. +fn sole_glob_owner( + target: &TargetDef, + sources: &[SourceModule], + path: &str, + ownership: &BTreeMap, +) -> Option { + if ownership.contains_key(path) { + return None; + } + let mut matched: Vec<&SourceModule> = sources + .iter() + .filter(|source| source.targets.iter().any(|t| t == &target.name)) + .filter(|source| is_owned_path(target, &placement_root(source), path)) + .collect(); + matched.dedup_by(|a, b| a.module == b.module); + match matched.as_slice() { + [only] => Some(only.module.clone()), + _ => None, + } } -/// The crate directories the workspace-root manifest lists as members, or -/// `None` when there is no readable workspace manifest to compare against. -fn workspace_members(workspace: &Path, target: &TargetDef) -> Option> { - let text = std::fs::read_to_string(workspace.join(&target.manifest_file)).ok()?; - let mut members: Vec = Vec::new(); - for line in text.lines() { - let trimmed = line.trim().trim_end_matches(','); - if let Some(inner) = trimmed - .strip_prefix('"') - .and_then(|rest| rest.strip_suffix('"')) - { - if !is_manifest_path(target, inner) && inner.contains('/') { - members.push(inner.to_string()); - } +/// The refusal a path no module owns earns: a usage error that lists the owned +/// globs which do exist, so the reader learns the shape of what is ownable +/// instead of only that this was not. +fn unowned_path( + target: &TargetDef, + sources: &[SourceModule], + path: &str, +) -> crate::error::CompilerError { + let mut globs: BTreeSet = BTreeSet::new(); + for source in sources { + if !source.targets.iter().any(|t| t == &target.name) { + continue; + } + for glob in owned_globs(target, &placement_root(source)) { + globs.insert(glob); } } - Some(members) + let listed: Vec = globs.into_iter().collect(); + crate::cli::usage(&format!( + "no module owns {path:?}, so there is nothing to stage it as. The owned globs that exist are: {}", + listed.join(", ") + )) } -/// Classify a module's attribution freshness and, when it is not fresh, name the -/// exact fix. -/// -/// It is a pure observation over content already resolved: an absent record, a -/// record installed unproven, or a record whose recorded prompt hash no longer -/// matches the current normalized body all read as needing a fresh commit. A -/// record proven against the body still on disk is fresh. -fn attribution_freshness( - state: &ModuleState, +/// Turn one add source into the entry list ingestion judges. +fn scan_source( + workspace: &Path, + store: &Store, + target: &TargetDef, module: &str, - target: &str, - sources: &[crate::source::SourceModule], -) -> Value { - let directive = json!({ - "command": "commit", - "directive": format!( - "commit module {module:?} for target {target:?} with a fresh attribution update" - ), - "module": module, - "target": target, - }); - match &state.attribution { - None => json!({ "fix": directive, "status": "absent" }), - Some(attribution) => { - let current = sources - .iter() - .find(|source| source.module == module) - .map(|source| source.normalized_source_hash.as_str()); - if attribution.stale { - json!({ - "fix": directive, - "reason": "installed with --no-verify", - "status": "stale", - }) - } else if current != Some(attribution.prompt_hash.as_str()) { - json!({ - "fix": directive, - "reason": "the prompt changed since the attribution was recorded", - "status": "stale", - }) - } else { - json!({ "status": "fresh" }) + sources: &[SourceModule], + source: &AddSource, +) -> Result> { + match source { + AddSource::CandidateDir(dir) => scan_candidate(dir), + AddSource::Overlay(paths) => { + let mut staged = committed_output(store, &target.name, module)?; + if staged.is_empty() { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "module {module:?} has no committed output to overlay onto; stage the whole tree with --module {module} or an explicit candidate directory" + ), + )); + } + for path in paths { + match std::fs::read(workspace.join(path)) { + Ok(bytes) => { + staged.insert(path.clone(), bytes); + } + // A named path that is gone is a deletion, which is a real + // staged state and not an error. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + staged.remove(path); + } + Err(e) => return Err(disk::io(&workspace.join(path), &e)), + } + } + Ok(entries_from(&staged)) + } + AddSource::WorkingTree => { + let committed = committed_output(store, &target.name, module)?; + let mut staged: BTreeMap> = BTreeMap::new(); + for path in committed.keys() { + match std::fs::read(workspace.join(path)) { + Ok(bytes) => { + staged.insert(path.clone(), bytes); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(disk::io(&workspace.join(path), &e)), + } + } + for path in unclaimed_working_paths(workspace, store, target, module, sources)? { + let bytes = std::fs::read(workspace.join(&path)) + .map_err(|e| disk::io(&workspace.join(&path), &e))?; + staged.insert(path, bytes); + } + if staged.is_empty() { + return Err(nothing_stageable(workspace, target, module, sources)); } + Ok(entries_from(&staged)) } } } -/// The causal history reachable from HEAD, optionally narrowed to one module. +/// The refusal for a module whose owned tree holds nothing stageable. /// -/// The `ordinal` field is a presentation device computed from the causal graph -/// at read time. It is never stored and never enters an identity. -pub fn log(workspace: &Path, module: Option<&str>) -> Result { - let store = disk::open(workspace); - let Some(head) = store.head()? else { - return Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "entries": [], - "head": Value::Null, - })); +/// The usual cause is that the only file under the crate's source directory is +/// the entry file, which is a derived projection this module may not own. A +/// refusal that says "owns nothing" while a file plainly sits there reads as a +/// bug, so it names which present paths are projections and what the first legal +/// file would be. The obvious wrong next move, hand-writing the entry file, is +/// warned against here rather than one rejection later. +fn nothing_stageable( + workspace: &Path, + target: &TargetDef, + module: &str, + sources: &[SourceModule], +) -> crate::error::CompilerError { + let root = sources + .iter() + .find(|s| s.module == module) + .map(placement_root) + .unwrap_or_default(); + let present: Vec = crate::porcelain::projection_labels(target, &root) + .into_iter() + .filter(|label| { + label["path"] + .as_str() + .is_some_and(|path| workspace.join(path).is_file()) + }) + .collect(); + let first = crate::porcelain::first_output(target, &root, module); + let hint = match first["path"].as_str() { + Some(path) => format!( + "; the first legal file would be {path:?}, and the crate's entry file is not one of this module's to write" + ), + None => String::new(), }; - let mut entries: Vec = Vec::new(); - for (ordinal, event) in causal_order(&store, &head)?.iter().enumerate() { - let transition_id = event_transition(&store, event)?; - let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; - if let Some(wanted) = module { - if transition["module"].as_str() != Some(wanted) { - continue; - } - } - entries.push(json!({ - "event": event, - "module": transition["module"], - "newState": transition["newState"], - "ordinal": ordinal as u64, - "target": transition["target"], - "transition": transition_id, - "writeSet": transition["writeSet"], - })); - } - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "entries": entries, - "head": head, - })) + crate::error::error_with( + OutcomeCode::InvalidCandidate, + format!( + "module {module:?} owns nothing stageable in the working tree yet{hint}. Write an owned file, or stage an explicit candidate directory" + ), + json!({ + "firstOutput": first, + "ownedGlobs": owned_globs(target, &root), + "placementRoot": root, + "projectionsPresent": present, + }), + ) } -/// Describe any stored object: its kind and, for JSON kinds, its payload. -pub fn show(workspace: &Path, hash: &str) -> Result { - let store = disk::open(workspace); - let (kind, payload) = get_object(&store, hash)?; - let body = if kind == ObjectKind::Blob { - json!({ "bytes": payload.len() as u64 }) - } else { - serde_json::from_slice(&payload).map_err(|e| { - error( - OutcomeCode::StoreIntegrity, - format!("object {hash} is not JSON: {e}"), - ) - })? +/// Working-tree files under a module's owned globs that no module has committed +/// and no other module's globs also match: files this module owns by elimination. +pub fn unclaimed_working_paths( + workspace: &Path, + store: &Store, + target: &TargetDef, + module: &str, + sources: &[SourceModule], +) -> Result> { + let ownership = committed_ownership(store, &target.name)?; + let Some(source) = sources.iter().find(|s| s.module == module) else { + return Ok(Vec::new()); }; - Ok(json!({ - "body": body, - "contractVersion": crate::version::CONTRACT_VERSION, - "hash": hash, - "kind": kind.as_wire(), - })) + let root = placement_root(source); + let src_dir = crate::paths::join_root(&root, &target.source_dir); + let dir = workspace.join(&src_dir); + if !dir.is_dir() { + return Ok(Vec::new()); + } + let mut out: Vec = Vec::new(); + for entry in scan_candidate(&dir)? { + if entry.kind != EntryKind::File { + continue; + } + let path = crate::paths::join_root(&src_dir, &entry.path); + if !is_owned_path(target, &root, &path) { + continue; + } + if sole_glob_owner(target, sources, &path, &ownership).as_deref() == Some(module) { + out.push(path); + } + } + Ok(out) } -/// The raw payload bytes of an object, with the envelope stripped. -pub fn cat_file(workspace: &Path, hash: &str) -> Result> { - Ok(get_object(&disk::open(workspace), hash)?.1) +fn entries_from(files: &BTreeMap>) -> Vec { + files + .iter() + .map(|(path, bytes)| ScannedEntry { + path: path.clone(), + kind: EntryKind::File, + bytes: bytes.clone(), + }) + .collect() } -/// The recorded surface of a module as of HEAD. -pub fn surface_of(workspace: &Path, target: &str, module: &str) -> Result { - let store = disk::open(workspace); - let Some(state) = committed_state(&store, target, module)? else { - return Ok(surface_json(module, target, &[])); +/// A module's committed output as `path -> bytes`. +fn committed_output( + store: &Store, + target_name: &str, + module: &str, +) -> Result>> { + let mut out: BTreeMap> = BTreeMap::new(); + let Some(state) = crate::porcelain::committed_state(store, target_name, module)? else { + return Ok(out); }; - let items = read_surface_items(&store, &state.surface_hash)?; - Ok(surface_json(module, target, &items)) + if !crate::store_view::has_object(store, &state.output_tree) { + return Ok(out); + } + for (path, (blob, _mode)) in flatten_tree(store, &state.output_tree)? { + out.insert(path, get_blob(store, &blob)?); + } + Ok(out) } -/// Whether this workspace has a committed head at all. -/// -/// The one observation a command whose meaning requires a baseline makes before -/// it answers, so a headless workspace gets the prerequisite sequence instead of -/// an empty shape that would read as success. -pub fn has_committed_head(workspace: &Path) -> Result { - Ok(disk::open(workspace).head()?.is_some()) +/// The wire spelling of an attribution mode on a staged record. +pub fn mode_slug(mode: AttributionMode) -> &'static str { + match mode { + AttributionMode::Prove => "prove", + AttributionMode::PreProven => "pre-proven", + AttributionMode::Escape => "escape", + } } -/// Drift between committed outputs and the working tree, as a canonical patch. +/// Write the staged record's metadata: what commit needs to find the pieces and +/// to know how the attribution update was meant to be judged. /// -/// A target no committed module owns anything under has no baseline, and the -/// honest answer is `tracked: false` with the reason: an empty patch would say -/// "nothing drifted" when the truth is "nothing was compared". -pub fn diff(workspace: &Path, target: &str) -> Result { - let store = disk::open(workspace); - let committed = committed_text(&store, target)?; - if committed.is_empty() { - return Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "reason": format!( - "no committed module owns any output under target {target:?}, so there is no baseline to diff against" - ), - "target": target, - "tracked": false, - })); - } - let paths: Vec = committed.keys().cloned().collect(); - let working = read_working_text(workspace, &paths); - let patch = render_drift(&committed, &working); - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "patch": patch, - "target": target, - "tracked": true, - })) +/// The mode rides the record rather than the command line so that a staged +/// record commits the same way whoever picks it up. A record written before +/// modes existed carries only `noVerify` and reads as `escape` or `prove`. +pub fn write_record( + record_dir: &Path, + module: &str, + target_name: &str, + tree_hash: &str, + attribution_present: bool, + mode: AttributionMode, +) -> Result<()> { + let record = json!({ + "attribution": attribution_present, + "candidateTreeHash": tree_hash, + "mode": mode_slug(mode), + "module": module, + "noVerify": mode == AttributionMode::Escape, + "receiptVersion": crate::receipt::RECEIPT_VERSION, + "target": target_name, + }); + write_file( + &record_dir.join("record.json"), + &crate::canon::canonical_bytes(&record), + ) } -/// Derived line ancestry for one committed path. +/// The workspace-relative directory a staged record for one candidate lives in. +pub fn record_dir(workspace: &Path, module: &str, tree_hash: &str) -> PathBuf { + workspace.join(STAGING_DIR).join(format!( + "{}-{}", + crate::paths::encode_name(module), + &tree_hash[..16] + )) +} + +/// The workspace-relative spelling of a path, the form `add` reports and +/// `commit --staged` accepts. +pub fn relative_record(workspace: &Path, path: &Path) -> String { + display_path(workspace, path) +} + +/// Resolve the attribution update that rides this stage. /// -/// A path no committed module owns has no ancestry to replay, and empty ancestry -/// would read as a file that exists and was never written. It answers -/// `tracked: false` naming the absent baseline instead, an honest success shape. -pub fn blame_path(workspace: &Path, path: &str) -> Result { - let store = disk::open(workspace); - let Some(head) = store.head()? else { - return Ok(untracked_json( - path, - "this workspace has no committed head, so no module owns this path yet", - )); +/// The order is transport first, affirmation second, refusal last. When the +/// diffs carry every recorded claim, nothing is asked for: the paperwork updates +/// itself as a consequence of the diff already being content-addressed on both +/// sides. When they do not, the listed claims are what `--attribution` affirms, +/// and an explicit map that covers the whole record still wins whole, because an +/// author reshaping a mapping is allowed to say so outright. +#[allow(clippy::too_many_arguments)] +fn resolve_attribution( + workspace: &Path, + store: &Store, + target: &TargetDef, + root: &str, + module: &str, + prompt: &SourceModule, + candidate: &Candidate, + attribution_path: Option<&Path>, + no_verify: bool, +) -> Result>> { + let body_lines = prompt.normalized_body.lines().count() as u32; + let supplied = match attribution_path { + Some(path) => Some(std::fs::read(path).map_err(|e| disk::io(path, &e))?), + None => None, }; - if !committed_manifest_all_targets(&store)?.contains(path) { - return Ok(untracked_json( - path, - "no committed module owns this path, so there is no committed baseline to trace it through", - )); + if no_verify { + // The escape hatch proves nothing and says so: the record installs + // marked stale, and an absent map stays absent. + return Ok(supplied); } - let mut revisions: Vec = Vec::new(); - for event in causal_order(&store, &head)? { - let transition_id = event_transition(&store, &event)?; - let transition = get_json(&store, &transition_id, ObjectKind::Transition)?; - let touched = transition["installPaths"] - .as_array() - .map(|paths| paths.iter().any(|p| p.as_str() == Some(path))) - .unwrap_or(false); - if !touched { - continue; + + let transported = transport_for(workspace, store, target, module, prompt, candidate)?; + + match (transported, supplied) { + // Nothing recorded to carry: the map is the author's to supply, exactly + // as it was before transport existed. + (None, Some(bytes)) => { + let map = parse_map(&bytes)?; + attribution::validate(target, root, module, &candidate.files, body_lines, &map)?; + Ok(Some(bytes)) + } + // A module's first add has no committed map to transport, and it demands + // no hand-authored one either: the trivial whole map claims every staged + // file in full by the whole prompt body. It is honestly coarse, it is + // present and fresh rather than absent, and it is why the cold path to a + // verified first commit never needs `--no-verify`. + (None, None) => { + let whole = attribution::whole_map(module, &target.name, &candidate.files); + attribution::validate(target, root, module, &candidate.files, body_lines, &whole)?; + Ok(Some(attribution::to_yaml(&whole))) + } + (Some(outcome), Some(bytes)) => { + let map = parse_map(&bytes)?; + let layered = layer_affirmations(module, &outcome, &map)?; + attribution::validate(target, root, module, &candidate.files, body_lines, &layered)?; + Ok(Some(attribution::to_yaml(&layered))) + } + (Some(outcome), None) => { + // Transport seeds from the evidence a capture verb already recorded: + // a claim refusing in a rewritten region is affirmed automatically + // when a reference covering that region carries endpoint hashes that + // still match the staged bytes, because the tool that applied the + // edit owns its diff. A recorded reference whose hashes no longer + // match affirms nothing and stays listed. + let (outcome, affirmed) = + self_affirm(outcome, &candidate.files, &prompt.normalized_body); + if !outcome.complete() { + let mut payload = refusal_payload(module, &outcome.refusals); + if let Some(object) = payload.as_object_mut() { + object.insert("affirmedFromReferenceRecord".into(), json!(affirmed)); + } + return Err(crate::error::error_with( + OutcomeCode::InvalidCandidate, + refusal_detail(module, &outcome.refusals), + payload, + )); + } + let carried = outcome.record; + attribution::validate(target, root, module, &candidate.files, body_lines, &carried)?; + Ok(Some(attribution::to_yaml(&carried))) } - let Some(state_hash) = transition["newState"].as_str() else { - continue; - }; - let state = read_module_state(&store, state_hash)?; - let files = flatten_tree(&store, &state.output_tree)?; - let Some((blob, _)) = files.get(path) else { - continue; - }; - let bytes = get_blob(&store, blob)?; - revisions.push(Revision { - transition: transition_id, - content: String::from_utf8_lossy(&bytes).into_owned(), - }); } - Ok(blame_json(path, &blame(&revisions))) } -/// Diagnose a workspace. -pub fn lint_workspace(workspace: &Path, target: &str) -> Result<(Graph, LintReport, Value)> { - let store = disk::open(workspace); - let sources = discover_sources(workspace)?; - let artifact = compile(&store, &sources, target, None)?; - let graph = crate::compile::graph_for(&sources, target)?; - let definition = builtin_target(target)?; - let states = match store.head()? { - Some(snapshot) => snapshot_states(&store, &snapshot)?, - None => BTreeMap::new(), +/// Transport the module's recorded attribution onto the staged bytes and the +/// pending prompt body, or `None` when nothing is recorded yet. +fn transport_for( + workspace: &Path, + store: &Store, + target: &TargetDef, + module: &str, + prompt: &SourceModule, + candidate: &Candidate, +) -> Result> { + let Some(state) = crate::porcelain::committed_state(store, &target.name, module)? else { + return Ok(None); }; - - let mut committed_input_keys: BTreeMap> = BTreeMap::new(); - let mut referenced_names: BTreeMap> = BTreeMap::new(); - let mut surface_names: BTreeMap> = BTreeMap::new(); - for module in graph.keys() { - let (state, _) = state_of(&store, &states, &definition, module)?; - committed_input_keys.insert(module.clone(), state.input_key.clone()); - surface_names.insert( - module.clone(), - read_surface_items(&store, &state.surface_hash)? - .iter() - .filter_map(|item| item.path.rsplit("::").next().map(str::to_string)) - .collect(), - ); - referenced_names.insert(module.clone(), identifiers(&store, &state)?); + let Some(recorded) = state.attribution.as_ref() else { + return Ok(None); + }; + if !crate::store_view::has_object(store, &recorded.record) + || !crate::store_view::has_object(store, &recorded.prompt_hash) + { + return Ok(None); } - let current_input_keys: BTreeMap = artifact - .slices + let record_bytes = get_blob(store, &recorded.record)?; + let Some(record) = attribution::parse(&String::from_utf8_lossy(&record_bytes)) else { + return Ok(None); + }; + let committed_body = String::from_utf8_lossy(&get_blob(store, &recorded.prompt_hash)?) + .into_owned(); + let committed_files: BTreeMap = + committed_output(store, &target.name, module)? + .into_iter() + .map(|(path, bytes)| (path, String::from_utf8_lossy(&bytes).into_owned())) + .collect(); + let staged_files: BTreeMap = candidate + .files .iter() - .map(|slice| (slice.module.clone(), slice.input_key.clone())) + .map(|(path, bytes)| (path.clone(), String::from_utf8_lossy(bytes).into_owned())) .collect(); + let _ = workspace; + Ok(Some(transport_attribution( + &record, + &TransportInputs { + committed_body: &committed_body, + pending_body: &prompt.normalized_body, + committed_files: &committed_files, + staged_files: &staged_files, + }, + ))) +} - let roots = declared_roots(&sources); - let report = lint( - &graph, - &LintInput { - roots, - committed_input_keys, - current_input_keys, - referenced_names, - surface_names, +/// Affirm refused claims from the evidence a capture verb recorded. +/// +/// A reference the verbs record carries the content hash of each endpoint's +/// post-edit bytes. When those hashes still match the staged bytes and the +/// pending body, the reference proves the current content is exactly what its +/// recorder wrote, so a claim landing in that region needs no hand-authored +/// affirmation: the record IS the affirmation. The claim keeps its recorded +/// coordinates, which the hashes prove are coordinates in these bytes. +/// +/// Nothing here guesses. A reference whose hashes no longer match proves +/// nothing, affirms nothing, and its claim stays in the refusal list beside +/// every other. +fn self_affirm( + outcome: TransportOutcome, + staged_files: &BTreeMap>, + pending_body: &str, +) -> (TransportOutcome, usize) { + let body_lines = pending_body.lines().count() as u32; + // The references whose evidence still holds, by the file they name. + let mut evidence: BTreeMap> = BTreeMap::new(); + for entry in &outcome.record.entries { + let Some(file) = &entry.file else { continue }; + if !entry.self_affirming() { + continue; + } + if !entry.hashes_match(staged_files.get(file).map(Vec::as_slice), pending_body) { + continue; + } + evidence + .entry(file.clone()) + .or_default() + .push((entry.line_start, entry.line_end)); + } + if evidence.is_empty() { + return (outcome, 0); + } + + let TransportOutcome { record, refusals } = outcome; + let mut kept: Vec = Vec::new(); + let mut affirmed = 0usize; + for refusal in refusals { + let entry = &record.entries[refusal.index]; + let covered = entry + .file + .as_ref() + .and_then(|file| evidence.get(file).map(|spans| (file.clone(), spans))) + .map(|(file, spans)| { + let lines = staged_files + .get(&file) + .map(|bytes| String::from_utf8_lossy(bytes).lines().count() as u32) + .unwrap_or(0); + let in_bounds = entry.whole_file + || (entry.line_start >= 1 && entry.line_end <= lines); + let prompt_in_bounds = + entry.whole_prompt || (entry.prompt_start >= 1 && entry.prompt_end <= body_lines); + let overlaps = entry.whole_file + || spans.iter().any(|(start, end)| { + entry.line_start <= *end && *start <= entry.line_end + }); + in_bounds && prompt_in_bounds && overlaps + }) + .unwrap_or(false); + if covered { + affirmed += 1; + continue; + } + kept.push(refusal); + } + ( + TransportOutcome { + record, + refusals: kept, }, - ); - let value = lint_json(&report); - Ok((graph, report, value)) + affirmed, + ) } -/// Cost over an explicit set: receipt object names in the store, receipt file -/// paths, usage-proxy session windows, or any mixture. +/// Layer affirmations over the transported remainder. /// -/// A session window is priced here, lazily, at read time: a capture verb records -/// the window as a reference and never aggregates it, so the log lines are summed -/// only when a reader asks. A model the pinned table does not know stays -/// `unknown`, never zero. -pub fn cost( - workspace: &Path, - names: &[String], - files: &[String], - sessions: &[String], -) -> Result { - let store = disk::open(workspace); - let mut lines: Vec = Vec::new(); - for session in sessions { - lines.extend(crate::cost::lines_from_session( - &std::fs::read_to_string(workspace.join(crate::capture::USAGE_LOG_PATH)) - .unwrap_or_default(), - session, +/// A map with one entry per listed claim affirms exactly those, in the order the +/// refusal listed them, and every carried claim is kept. A map covering the whole +/// record replaces it outright. Anything between is refused rather than guessed +/// at, because a partial map whose entries cannot be matched to claims is not an +/// affirmation of anything in particular. +fn layer_affirmations( + module: &str, + outcome: &TransportOutcome, + supplied: &Attribution, +) -> Result { + if outcome.complete() || supplied.entries.len() == outcome.record.entries.len() { + return Ok(supplied.clone()); + } + if supplied.entries.len() != outcome.refusals.len() { + return Err(error( + OutcomeCode::InvalidCandidate, + format!( + "the attribution update for module {module:?} carries {} entries, but the transport listed {} claims needing affirmation and the record holds {} in all; supply one entry per listed claim, in the order they were listed, or a complete map", + supplied.entries.len(), + outcome.refusals.len(), + outcome.record.entries.len() + ), )); } - for name in names { - let body = get_json(&store, name, ObjectKind::Receipt)?; - lines.push(receipt_line(workspace, name, &body)?); + let mut layered = outcome.record.clone(); + for (affirmation, refusal) in supplied.entries.iter().zip(outcome.refusals.iter()) { + layered.entries[refusal.index] = affirmation.clone(); } - for file in files { - let path = Path::new(file); - let bytes = std::fs::read(path).map_err(|e| disk::io(path, &e))?; - let body: Value = serde_json::from_slice(&bytes).map_err(|e| { - error( - OutcomeCode::InvalidReceipt, - format!("{file}: invalid JSON: {e}"), - ) - })?; - let name = crate::canon::json_hash(ObjectKind::Receipt, &body); - lines.push(receipt_line(workspace, &name, &body)?); + Ok(layered) +} + +/// Read a supplied `--attribution` map, answering a bad one with the whole +/// contract rather than a bare "not parseable". +/// +/// The first refusal is the only one the author gets before they go rewrite the +/// file, so it carries every field, the closed vocabularies, and one complete +/// valid example. This is the same law the `--ref` payload already follows. +/// A map that fails mid-parse, or parses into no entries at all, is refused +/// naming the exact line and the construct that stopped the reader, beside the +/// full contract. A bare count of zero would describe the reader's outcome +/// rather than the author's document. +fn parse_map(bytes: &[u8]) -> Result { + let bad = |why: String, located: Option<&attribution::MapParseError>| { + crate::error::error_with( + OutcomeCode::InvalidCandidate, + why, + json!({ + "mapContract": attribution::map_contract(), + "stoppedAt": match located { + Some(err) => json!({ + "construct": err.construct, + "detail": err.detail, + "line": err.line, + }), + None => Value::Null, + }, + }), + ) + }; + let text = std::str::from_utf8(bytes) + .map_err(|_| bad("the --attribution file is not valid UTF-8".to_string(), None))?; + let parsed = attribution::parse_detailed(text).map_err(|err| { + bad( + format!( + "the --attribution file stopped being readable at line {}, reading a {}: {}", + err.line, err.construct, err.detail + ), + Some(&err), + ) + })?; + if parsed.module.is_empty() || parsed.target.is_empty() { + return Err(bad( + "the --attribution file names no module or no target".to_string(), + None, + )); } - Ok(cost_report(&lines)) + Ok(parsed) } -/// One receipt's cost line: a reported total priced directly, a recorded session -/// window priced against the log the record names. -fn receipt_line(workspace: &Path, name: &str, body: &Value) -> Result { - let module = body["module"].as_str().unwrap_or_default(); - let reported = normalize_reported(&body["reported"]); - match crate::cost::window_from_reported(&reported) { - Some(window) => { - let log = window_log(workspace, &window.log_path); - Ok(crate::cost::line_from_window( - name, - module, - &window, - log.as_deref(), - )) +/// Every staged record currently sitting in the staging directory, workspace +/// relative, with the module and target each one names. +/// +/// This is a plain directory read: staged records are disposable litter by the +/// git-state law, so what is there is the whole truth and nothing indexes them. +pub fn staged_records(workspace: &Path) -> Result> { + let dir = workspace.join(STAGING_DIR); + let mut out: Vec = Vec::new(); + let listing = match std::fs::read_dir(&dir) { + Ok(listing) => listing, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(disk::io(&dir, &e)), + }; + let mut paths: Vec = Vec::new(); + for entry in listing { + let entry = entry.map_err(|e| disk::io(&dir, &e))?; + if entry.path().join("record.json").is_file() { + paths.push(entry.path()); } - None => line_from_reported(name, module, &reported), } + // The directory hands entries back in whatever order the filesystem holds + // them, which is exactly the ambient enumeration order the determinism + // contract excludes: sort before anything reads them. + paths.sort(); + for path in paths { + let bytes = std::fs::read(path.join("record.json")) + .map_err(|e| disk::io(&path.join("record.json"), &e))?; + let meta: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + out.push(StagedRecord { + module: meta["module"].as_str().unwrap_or_default().to_string(), + target: meta["target"].as_str().unwrap_or("rust").to_string(), + relative: display_path(workspace, &path), + path, + }); + } + Ok(out) } -/// The bytes of a log a record names, read at the moment the reader asks. +/// One staged record as the staging directory holds it. +#[derive(Debug, Clone)] +pub struct StagedRecord { + pub module: String, + pub target: String, + /// The workspace-relative spelling, which is what `--staged` accepts. + pub relative: String, + pub path: PathBuf, +} + +/// `napl commit` with no arguments: install every staged record in dependency +/// wave order, one outcome reported per record. /// -/// The path is a record's own workspace-relative path, so a path that escapes -/// the workspace or is absolute is not a log this reader will open: it reads as -/// absent, which the report already knows how to say. -fn window_log(workspace: &Path, log_path: &str) -> Option { - let candidate = Path::new(log_path); - if candidate.is_absolute() || log_path.split('/').any(|part| part == "..") { - return None; +/// A multi-module add followed by a bare commit is one command each. The +/// sequencing and the retry the decision table already defines belong to the +/// tool, so nobody hand-loops over records in wave order and gets it subtly +/// wrong. Each record still goes through exactly the same guarded transaction +/// `--staged` runs, and each reports its own outcome whatever the others did. +pub fn commit_all(workspace: &Path) -> Result<(Value, i32)> { + let records = staged_records(workspace)?; + let ordered = in_wave_order(workspace, records)?; + let mut outcomes: Vec = Vec::new(); + let mut exit_code = 0; + // Set once an earlier record in this invocation has moved the head. Only + // then is a later record's write-conflict or stale-input this invocation's + // own doing, and only then is re-deriving it the tool's job rather than a + // report the caller has to act on. + let mut head_moved = false; + for record in &ordered { + let mut report = commit_staged(workspace, &record.path); + let mut retried = false; + if head_moved && retryable_against_a_moved_head(report.outcome) { + // Re-derive exactly what re-running `add` would produce now: the + // record's own ingested bytes, re-drafted against the head this + // invocation just advanced, with its resolved attribution carried + // back in as affirmations. Modules sharing one crate overlap on the + // crate's ownership write key, so the second one always arrives + // against a head the first one moved; the retry is what makes that + // pair one invocation instead of a hand-ordered sequence. + // + // A docs record has nothing to re-derive: its staged bytes are a + // prompt, and the commit reads the head it arrives at, so retrying it + // as it stands is exactly re-running add. + let docs = record_kind(&record.path).ok().flatten().as_deref() == Some(DOCS_RECORD); + if docs || rederive_against_head(workspace, record).is_ok() { + retried = true; + report = commit_staged(workspace, &record.path); + } + } + if report.outcome != OutcomeCode::Success && exit_code == 0 { + exit_code = crate::error::outcome_exit_code(report.outcome); + } + if report.outcome == OutcomeCode::Success { + head_moved = true; + } + outcomes.push(json!({ + "module": record.module, + "outcome": report.payload["outcome"].clone(), + "record": record.relative, + "report": report.payload, + "retried": retried, + "target": record.target, + })); + } + let mut payload = json!({ + "contractVersion": crate::version::CONTRACT_VERSION, + "installed": outcomes.len() as u64, + "order": ordered + .iter() + .map(|record| json!(record.module)) + .collect::>(), + "outcomes": outcomes, + }); + // A bare commit that found nothing staged says so instead of dressing + // emptiness as success, and it names the work that is actually waiting: every + // module carrying working-tree drift, beside the exact `napl add` invocation + // that would stage it. The empty commit is the teacher, never the trap. + if ordered.is_empty() { + let drifted = drifted_modules(workspace)?; + // The teacher includes docs drift: a prompt whose prose moved is work + // waiting exactly as drifted code is, and the invocation that records it + // is the prompt path itself. + let docs_drifted = docs_drifted_prompts(workspace)?; + if let Some(object) = payload.as_object_mut() { + object.insert("stagedRecords".to_string(), json!(0)); + object.insert( + "detail".to_string(), + json!(match (drifted.is_empty(), docs_drifted.is_empty()) { + (true, true) => "zero records were staged and no module carries working-tree drift, so there was nothing to install".to_string(), + (true, false) => format!( + "zero records were staged; {} prompt(s) carry docs-only drift and are listed with the exact `napl add ` that would record each", + docs_drifted.len() + ), + (false, true) => format!( + "zero records were staged; {} module(s) carry working-tree drift and are listed with the exact `napl add` invocation that would stage each", + drifted.len() + ), + (false, false) => format!( + "zero records were staged; {} module(s) carry working-tree drift and {} prompt(s) carry docs-only drift, each listed with the exact `napl add` invocation that would record it", + drifted.len(), + docs_drifted.len() + ), + }), + ); + object.insert( + "docsDriftedPrompts".to_string(), + Value::Array(docs_drifted), + ); + object.insert( + "driftedModules".to_string(), + Value::Array(drifted), + ); + } } - std::fs::read_to_string(workspace.join(candidate)).ok() + Ok((payload, exit_code)) } -/// Sweep every object no named head reaches. +/// Every module whose committed output the working tree has moved away from, +/// beside the exact `napl add` invocation that stages it. /// -/// The sweep set is computed outside the lock; only the deletions run under it, -/// and a writer publishing concurrently is never blocked because publication -/// takes no lock at all. -pub fn gc(workspace: &Path, apply: bool) -> Result { - let store = disk::init(workspace)?; - let all: BTreeSet = store.names()?.into_iter().collect(); - let reachable = match store.head()? { - Some(head) => reachable_from_head(&store, &head)?, - None => BTreeSet::new(), +/// This is the same drift `status` and `diff` report, read straight off the +/// committed output trees, so the empty bare commit can never disagree with the +/// query surface about what is waiting. +fn drifted_modules(workspace: &Path) -> Result> { + let store = disk::open(workspace); + let Some(head) = store.head()? else { + return Ok(Vec::new()); }; - let swept = plan_sweep(&all, &reachable); - if apply { - for name in &swept { - disk::remove_object(&store, name)?; + let mut listed: Vec = Vec::new(); + for ((target, module), state_hash) in snapshot_states(&store, &head)? { + let state = read_module_state(&store, &state_hash)?; + if !crate::store_view::has_object(&store, &state.output_tree) { + continue; + } + let mut paths: Vec = Vec::new(); + for (path, (blob, _mode)) in flatten_tree(&store, &state.output_tree)? { + let bytes = std::fs::read(workspace.join(&path)).ok(); + // An unreadable working file is not drift: the reader knows nothing + // about it, so it claims nothing, exactly as status does. + if attribution::code_drifted(Some(blob.as_str()), bytes.as_deref()) { + paths.push(path); + } + } + if paths.is_empty() { + continue; } + listed.push(json!({ + "addCommand": format!("napl add --module {module}"), + "driftedPaths": paths, + "module": module, + "target": target, + })); } - Ok(gc_json(&swept, reachable.len())) -} - -/// Verify envelope hashes, kind validity, and causal parent links. -pub fn fsck_store(workspace: &Path) -> Result<(bool, Value)> { - let store = disk::open(workspace); - let findings = fsck(&store)?; - Ok((findings.is_empty(), fsck_json(&findings))) + Ok(listed) } -/// Derive a repair plan for a target's committed outputs, and optionally apply -/// it. -pub fn restore(workspace: &Path, target: &str, apply: bool) -> Result { +/// Every module whose prompt carries docs-only drift, keyed by `(target, +/// module)`. +/// +/// One reader answers this question for everybody: the empty-commit teacher, so +/// it names the work that is waiting, and `status`, so a clean status and a +/// silent teacher can never disagree about whether anything is waiting. A prompt +/// whose drift reaches the frontmatter or the semantic body is not docs drift and +/// is not here: that module reads as source-changed everywhere else, and naming +/// the docs door beside it would teach the invocation that refuses. +pub fn docs_drift(workspace: &Path) -> Result> { let store = disk::open(workspace); - let expected: Vec = committed_manifest(&store, target)? - .into_iter() - .map(|(path, hash)| FileFact { path, hash }) - .collect(); - let roots: BTreeSet = expected - .iter() - .filter_map(|fact| fact.path.split('/').next().map(str::to_string)) - .collect(); - let observed = observed_facts(workspace, &roots)?; - let plan = plan_restore(&expected, &observed); - if apply { - if let RestorePlan::Apply(steps) = &plan { - apply_restore(&store, workspace, steps)?; + let mut out: BTreeSet<(String, String)> = BTreeSet::new(); + if store.head()?.is_none() { + return Ok(out); + } + for source in discover_sources(workspace)? { + for target in &source.targets { + if matches!( + prompt_drift(&store, target, &source), + Ok((PromptDrift::DocsOnly, _)) + ) { + out.insert((target.clone(), source.module.clone())); + } } } - Ok(restore_json(&plan)) + Ok(out) } -/// Attach later provenance to a recorded transition without touching semantic -/// state. -/// The refusal a `--to` that is not an object name earns. +/// The docs-drifted prompts the empty-commit teacher names, one entry per prompt +/// file beside the `napl add ` that records it. /// -/// A working path is the mistake a reader makes here, because every other verb -/// takes one, so the refusal does not merely say no: it names the two commands -/// that produce and inspect an object name and shows the one line that gets one. -fn not_an_object_name(given: &str) -> crate::error::CompilerError { - error( - OutcomeCode::Usage, - format!( - "--to expects the object name of a recorded transition, and {given:?} is not one. \ - Object names come from `napl log`, and `napl show ` inspects one, \ - for example: napl log | napl q '.entries[-1].transition' -" - ), - ) -} - -pub fn add_note(workspace: &Path, transition: &str, note: &str) -> Result { - let store = disk::init(workspace)?; - // A path is never an object name, and neither is anything else outside the - // store's name alphabet: refuse before opening anything, so the reader is - // told how to get a name rather than handed a missing-object error. - if !is_object_name(transition) || !has_object(&store, transition) { - return Err(not_an_object_name(transition)); +/// A module declaring several targets is one prompt file and one invocation, so +/// it is listed once with the targets it drifted for rather than once per target +/// with the same command repeated. +fn docs_drifted_prompts(workspace: &Path) -> Result> { + let drifted = docs_drift(workspace)?; + let mut by_path: BTreeMap)> = BTreeMap::new(); + for source in discover_sources(workspace)? { + for (target, module) in &drifted { + if module != &source.module { + continue; + } + by_path + .entry(source.path.clone()) + .or_insert_with(|| (source.module.clone(), BTreeSet::new())) + .1 + .insert(target.clone()); + } } - get_json(&store, transition, ObjectKind::Transition)?; - let body = json!({ "note": note, "transition": transition }); - let payload = crate::canon::canonical_bytes(&body); - let name = disk::publish(&store, ObjectKind::Blob, &payload, transition)?; - - let index = store.root.join("notes").join(format!("{transition}.json")); - let mut names: BTreeSet = match std::fs::read(&index) { - Ok(bytes) => serde_json::from_slice::>(&bytes) - .unwrap_or_default() - .into_iter() - .collect(), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(), - Err(e) => return Err(disk::io(&index, &e)), - }; - names.insert(name.clone()); - let listed: Vec = names.iter().map(|n| json!(n)).collect(); - crate::workspace::write_file(&index, &crate::canon::canonical_bytes(&Value::Array(listed)))?; - Ok(json!({ - "note": name, - "outcome": "success", - "semanticStatePublished": false, - "transition": transition, - })) + Ok(by_path + .into_iter() + .map(|(path, (module, targets))| { + json!({ + "addCommand": format!("napl add {path}"), + "module": module, + "promptPath": path, + "targets": targets.into_iter().collect::>(), + }) + }) + .collect()) } -/// The events reachable from a head, ordered oldest first by causal depth. -pub fn causal_order(store: &Store, head: &str) -> Result> { - let head_event = snapshot_event(store, head)?; - let events = walk_events(store, &head_event, None)?; - let mut depths: BTreeMap = BTreeMap::new(); - for event in &events { - depth_of(store, event, &mut depths)?; - } - let mut ordered = events; - ordered.sort_by(|a, b| (depths[a], a).cmp(&(depths[b], b))); - Ok(ordered) +/// The two outcomes an earlier record in the same invocation can cause: the +/// head moved under this record, and moving it is what this invocation did. +/// +/// `base-output-changed` is deliberately not here. It says the module's *own* +/// committed output moved, which no other module's commit can do, so it is a +/// genuine report to the caller rather than self-inflicted sequencing. +fn retryable_against_a_moved_head(outcome: OutcomeCode) -> bool { + matches!(outcome, OutcomeCode::WriteConflict | OutcomeCode::StaleInput) } -fn depth_of(store: &Store, event: &str, depths: &mut BTreeMap) -> Result { - if let Some(known) = depths.get(event) { - return Ok(*known); - } - let value = get_json(store, event, ObjectKind::Event)?; - let mut depth = 0usize; - for parent in value["parents"].as_array().cloned().unwrap_or_default() { - if let Some(parent) = parent.as_str() { - depth = depth.max(depth_of(store, parent, depths)? + 1); - } - } - depths.insert(event.to_string(), depth); - Ok(depth) +/// Re-derive a staged record against the current head, in place. +/// +/// The record's ingested candidate bytes are the immutable truth of what was +/// staged, whichever of the three sources produced them, so re-running the add +/// pipeline over them is exactly re-running add: the receipt is re-drafted +/// against the head that just moved, the attribution transport is recomputed, +/// and the resolved map already in the record rides back in as affirmations. +/// The record directory is keyed by the candidate tree hash, which the bytes did +/// not change, so the record rewrites itself at the same path. +fn rederive_against_head(workspace: &Path, record: &StagedRecord) -> Result<()> { + let (_, candidate_dir, attribution_path, mode) = resolve(&record.path)?; + add( + workspace, + &record.target, + &record.module, + &AddSource::CandidateDir(candidate_dir), + attribution_path.as_deref(), + None, + mode == AttributionMode::Escape, + false, + )?; + Ok(()) } -pub fn committed_state(store: &Store, target: &str, module: &str) -> Result> { - let Some(head) = store.head()? else { - return Ok(None); +/// Remove one staged record directory. +fn sweep_record(workspace: &Path, record: &Path) -> Result<()> { + let dir = if record.join("record.json").is_file() { + record.to_path_buf() + } else if record.file_name().and_then(|n| n.to_str()) == Some("record.json") { + match record.parent() { + Some(parent) => parent.to_path_buf(), + None => return Ok(()), + } + } else { + return Ok(()); }; - let states = snapshot_states(store, &head)?; - match states.get(&(target.to_string(), module.to_string())) { - Some(hash) => Ok(Some(read_module_state(store, hash)?)), - None => Ok(None), + if !dir.starts_with(workspace.join(STAGING_DIR)) { + return Ok(()); + } + match std::fs::remove_dir_all(&dir) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(disk::io(&dir, &e)), } } -/// The materialized attribution view a module committed last, if any. +/// Sort staged records into dependency wave order, the order commit installs in. /// -/// The bytes are the record blob, which is the view itself: an internal -/// re-commit reads them to carry the mapping forward rather than fabricating a -/// new one. -pub fn committed_attribution_yaml( - store: &Store, - target: &str, - module: &str, -) -> Result>> { - let Some(state) = committed_state(store, target, module)? else { - return Ok(None); - }; - match state.attribution { - Some(attribution) => Ok(Some(get_blob(store, &attribution.record)?)), - None => Ok(None), +/// A module the graph does not place sorts after every placed one rather than +/// blocking the whole batch: its own commit will say what is wrong with it, and +/// refusing to sequence is not the same as refusing to install. +fn in_wave_order(workspace: &Path, records: Vec) -> Result> { + let sources = discover_sources(workspace)?; + let mut keyed: Vec<(usize, String, StagedRecord)> = Vec::new(); + for record in records { + let wave = crate::compile::graph_for(&sources, &record.target) + .ok() + .and_then(|graph| crate::graph::topological_levels(&graph).ok()) + .and_then(|levels| levels.get(&record.module).copied()) + .unwrap_or(usize::MAX); + keyed.push((wave, record.relative.clone(), record)); } + keyed.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); + Ok(keyed.into_iter().map(|(_, _, record)| record).collect()) } -/// The committed `path -> blob` manifest of every module under a target. -pub fn committed_manifest(store: &Store, target: &str) -> Result> { - let Some(head) = store.head()? else { - return Ok(BTreeMap::new()); +/// Install what add staged: read the staged record and run the one guarded +/// commit over the immutable staged copy. +/// +/// A record that installs is swept. It has become history, and leaving it in the +/// staging directory would make the next bare commit re-attempt an installed +/// transition against a head that has already moved past it. Nobody should ever +/// hand-edit `.napl` to be rid of one, and nobody should have to. +pub fn commit_staged(workspace: &Path, record: &Path) -> CommitReport { + // A docs record installs through its own door: it carries no candidate and + // drafts no receipt, so the candidate pipeline has nothing to read. + let report = match record_kind(record) { + Ok(Some(kind)) if kind == DOCS_RECORD => crate::commit::commit_docs(workspace, record), + Ok(_) => match resolve(record) { + Ok((receipt_path, candidate_dir, attribution_path, mode)) => commit_with_mode( + workspace, + &receipt_path, + &candidate_dir, + attribution_path.as_deref(), + mode, + ), + Err(err) => CommitReport { + outcome: err.code, + payload: error_payload(&err, false, false), + }, + }, + Err(err) => CommitReport { + outcome: err.code, + payload: error_payload(&err, false, false), + }, }; - let mut out = BTreeMap::new(); - for ((state_target, _), hash) in snapshot_states(store, &head)? { - if state_target != target { - continue; - } - let state = read_module_state(store, &hash)?; - for (path, (blob, _)) in flatten_tree(store, &state.output_tree)? { - out.insert(path, blob); + if report.outcome == OutcomeCode::Success { + if let Err(err) = sweep_record(workspace, record) { + return CommitReport { + outcome: err.code, + payload: error_payload(&err, true, true), + }; } } - Ok(out) -} - -/// Whether a token is shaped like a store object name at all: the lowercase hex -/// of a SHA-256, and nothing else. A workspace path fails on the first separator. -fn is_object_name(given: &str) -> bool { - given.len() == 64 && given.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + report } -/// Every path any committed module owns, across every target: the set that -/// decides whether a path is tracked at all. -fn committed_manifest_all_targets(store: &Store) -> Result> { - let mut out: BTreeSet = BTreeSet::new(); - let Some(head) = store.head()? else { - return Ok(out); - }; - for (_, hash) in snapshot_states(store, &head)? { - let state = read_module_state(store, &hash)?; - for (path, _) in flatten_tree(store, &state.output_tree)? { - out.insert(path); - } +/// The directory a `--staged` argument names, whether the caller spelled the +/// directory or the `record.json` inside it. +pub fn record_directory(record: &Path) -> Result { + if record.join("record.json").is_file() { + Ok(record.to_path_buf()) + } else if record.file_name().and_then(|n| n.to_str()) == Some("record.json") { + Ok(record + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from("."))) + } else { + Err(error( + OutcomeCode::InvalidReceipt, + format!("no staged record at {}", record.display()), + )) } - Ok(out) } -fn committed_text(store: &Store, target: &str) -> Result> { - let mut out = BTreeMap::new(); - for (path, blob) in committed_manifest(store, target)? { - let bytes = get_blob(store, &blob)?; - out.insert(path, String::from_utf8_lossy(&bytes).into_owned()); - } - Ok(out) +/// The metadata a staged record declares. +pub fn record_meta(record_dir: &Path) -> Result { + let bytes = std::fs::read(record_dir.join("record.json")) + .map_err(|e| disk::io(&record_dir.join("record.json"), &e))?; + serde_json::from_slice(&bytes).map_err(|e| { + error( + OutcomeCode::InvalidReceipt, + format!("staged record is not JSON: {e}"), + ) + }) } -/// Every identifier a module's committed output names. -/// -/// This is a token scan, not name resolution: lint says a surface entry looks -/// unconsumed, and says so as a proposal, precisely because it cannot prove it. -fn identifiers(store: &Store, state: &ModuleState) -> Result> { - let mut out: BTreeSet = BTreeSet::new(); - if !has_object(store, &state.output_tree) { - // A module that has never committed names nothing yet. - return Ok(out); - } - for (_, (blob, _)) in flatten_tree(store, &state.output_tree)? { - let bytes = get_blob(store, &blob)?; - let text = String::from_utf8_lossy(&bytes).into_owned(); - for token in text.split(|c: char| !(c.is_alphanumeric() || c == '_')) { - if !token.is_empty() { - out.insert(token.to_string()); - } - } - } - Ok(out) +/// The `kind` a staged record declares. An ordinary candidate record declares +/// none, so a record written before docs records existed reads as `None`. +fn record_kind(record: &Path) -> Result> { + let dir = record_directory(record)?; + Ok(record_meta(&dir)?["kind"].as_str().map(str::to_string)) } -fn declared_roots(sources: &[crate::source::SourceModule]) -> Option> { - let mut declared: Option> = None; - for source in sources { - if let Some(roots) = &source.roots { - declared - .get_or_insert_with(BTreeSet::new) - .extend(roots.iter().cloned()); - } - } - declared.map(|set| set.into_iter().collect()) +fn resolve(record: &Path) -> Result<(PathBuf, PathBuf, Option, AttributionMode)> { + let record_dir = record_directory(record)?; + let meta = record_meta(&record_dir)?; + let no_verify = meta["noVerify"].as_bool().unwrap_or(false); + let mode = match meta["mode"].as_str() { + Some("pre-proven") => AttributionMode::PreProven, + Some("escape") => AttributionMode::Escape, + Some("prove") => AttributionMode::Prove, + // A record written before modes existed says only whether it escaped. + _ if no_verify => AttributionMode::Escape, + _ => AttributionMode::Prove, + }; + let attribution_path = if meta["attribution"].as_bool().unwrap_or(false) { + Some(record_dir.join("attribution.yaml")) + } else { + None + }; + Ok(( + record_dir.join("receipt.json"), + record_dir.join("candidate"), + attribution_path, + mode, + )) } -fn observed_facts(workspace: &Path, roots: &BTreeSet) -> Result> { - let mut out: Vec = Vec::new(); - for root in roots { - let dir = workspace.join(root); - if !dir.is_dir() { - continue; - } - for entry in crate::workspace::scan_candidate(&dir)? { - if entry.kind != crate::candidate::EntryKind::File { - continue; - } - out.push(FileFact { - path: crate::paths::join_root(root, &entry.path), - hash: napl_store::store_hash::object_name(ObjectKind::Blob, &entry.bytes), - }); - } - } - out.sort(); - Ok(out) +fn display_path(workspace: &Path, path: &Path) -> String { + path.strip_prefix(workspace) + .map(|rest| rest.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| path.to_string_lossy().into_owned()) } +napl-store tree v1 132 +{"entries":[{"hash":"0340a5f98d50aa16b99442f13842884d896cbf776a4253b69e6533d599724e89","kind":"dir","mode":16384,"name":"crates"}]} +napl-store snapshot v1 1005 +{"contractVersion":4,"event":"eded3d59d3f2c0ac9197b869730e53b944303a7822cdafb8a76846afe275d687","states":[{"module":"agent","state":"d0d406dd4f30ea6840a808bcbc3ddeea8ec8cc10c84de12ab5d0cfeef7bb6696","target":"rust"},{"module":"commands","state":"ac7d92c561ec9fa19f0e28e1392bd90c1d95ca663b038424bd8ce760c6f4cd9e","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"472a6986704fccc2a1f592d6fa2dd293195eea5e59e2bd6637e9a8647296b465","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"accaf93691b84c6794df99c7643c710529c765f12cc9faedede4b6b598a8c6b0","target":"rust"},{"module":"serve","state":"40ddb00b3be320909acf0ae1440e2bc5662e3002dcf77dc3367bcb215b1710fd","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} +napl-store receipt v1 815 +{"baseStateKey":"94697ec1fb16dd5f899f438ef2b257f8b6c5cb611e93acf940a8f2fe94a3713f","baseStoreSnapshot":"c3a7241b135bc24dc5b8ce9e5e1b75a7758fca92b4eaa3cd57eb90b77cc9e9fd","baseWorkspaceHash":"0193dd82442305411885e027f4e227b6accf0abd4afa4ca1c0922aa9c1a0b818","candidateTreeHash":"b92d10f23649a6ab076e0a23eeda2a922fbb3e779486a81b37c96c11f63d7873","contractVersion":3,"inputKey":"1ba3399d515df7b46c3b52a3271b11b7a3d75393dd8f4cd5b1beb38a12554ff7","module":"commands","outputManifest":[{"hash":"4b14b273d01558abe3560da549f380b808673d119fa0b3f23343a210e6098aee","path":"crates/napl/src/init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","path":"crates/napl/src/watch.rs"}],"receiptVersion":1,"sliceHash":"2e90f24f4fbfffbecfe6adc1f4e2dbca388d0715f7dfe6ad471b0e9681299972","target":"rust"} +napl-store event v1 158 +{"content":"83603a421d55207846c9b25009242c586258bfe1cd62e7ed7323c05a31bb9c3e","parents":["d18238b45189f5583b384cb01e30a51d7d2918e8c08fb75d21eee4f10550ca21"]} +napl-store tree v1 129 +{"entries":[{"hash":"6121a30bf21aa00b4424bb2b2badd08665f03995a57b5548afdf3ccb7810264f","kind":"dir","mode":16384,"name":"src"}]} +napl-store tree v1 130 +{"entries":[{"hash":"b4df455283ade5d031757e4a192db01fea9cc5b0a96c18b9786aaaec3195b611","kind":"dir","mode":16384,"name":"napl"}]} +napl-store blob v1 109428 +--- +module: napl +deps: [] +targets: [rust] +--- +:::docs +# The deterministic compiler -fn apply_restore( - store: &Store, - workspace: &Path, - steps: &[crate::maintenance::RestoreStep], -) -> Result<()> { - use crate::maintenance::RestoreStep; - for step in steps { - match step { - RestoreStep::Move { from, to } => { - let target = workspace.join(to); - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent).map_err(|e| disk::io(parent, &e))?; - } - std::fs::rename(workspace.join(from), &target) - .map_err(|e| disk::io(&target, &e))?; - } - RestoreStep::Write { path, hash } => { - let bytes = get_blob(store, hash)?; - crate::workspace::write_file(&workspace.join(path), &bytes)?; - } - RestoreStep::Remove { path } => { - let target = workspace.join(path); - match std::fs::remove_file(&target) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(disk::io(&target, &e)), - } - } - } - } - Ok(()) -} -napl-store module-state v1 562 -{"attribution":{"promptHash":"0a53f13c5e29172205697a166ba275f3517875ed8eef390067a8bd98659f5991","record":"a38d322afe4fa30f9d73cf7f101d26cb366e2dd382463b60fc5a76b5c3d62d02","stale":false},"contractVersion":4,"inputKey":"58fca4cee049991c052e54dc443f06f4d3b78ec74aad2fb6971c3138171783c9","module":"commands","outputTree":"b92d10f23649a6ab076e0a23eeda2a922fbb3e779486a81b37c96c11f63d7873","sliceHash":"e4357376b35865972053121b598b255836d940b6c9c6398b07e24e2721ba0fdc","surfaceHash":"1d77303eb410f234d96be01df90226c45d33f20b7a9d0257f89c50eb8b1dd11c","target":"rust"} -napl-store module-state v1 559 -{"attribution":{"promptHash":"6b98457c4a34f4460528fd9867a16bd87069eb66875a351fb23205533e511bbe","record":"0f3756f4e4507027e1ed76f9e0ef890738b3fe60b53eae65468551943cb63929","stale":false},"contractVersion":4,"inputKey":"9c70814c6621c0bb8a416ee242b115b8d34b4676a32e5fa04cc2e0dcf2d3a915","module":"agent","outputTree":"c8cf73ffb7c118e5ad378bc58783365f8b56c59299ef2359aebad52205080d68","sliceHash":"ce078cd01e70ffc6eabe49a185eae3555d102b4a724398220ece69d06158f25f","surfaceHash":"ad1581ae209ba3feb19322408bfff544be7a29bc6a7db44c672b705f1afcd120","target":"rust"} -napl-store tree v1 627 -{"entries":[{"hash":"deff13703dd0b3c630077edaffafc1fa9f2fdfde297b9a84b45841a428715965","kind":"file","mode":33188,"name":"check.rs"},{"hash":"6c743bf47b6f7c61be2d365b61f7143d7d1045e5f683095cce63010c64586d56","kind":"file","mode":33188,"name":"docs.rs"},{"hash":"70f66c8e4c3cd38f3cda7cbf81fa3dfb8090b766f637732677c0ee9c246e3420","kind":"file","mode":33188,"name":"document.rs"},{"hash":"5ec2c2137f45d02c8e8bf78ecf0652966850772097b41f3835a99116f3b78f57","kind":"file","mode":33188,"name":"promptbuild.rs"},{"hash":"9d897617acebf69cdb6e944d077bbaf2c0441a4bf5c26615d86b3e250b06b900","kind":"file","mode":33188,"name":"query.rs"}]} -napl-store tree v1 136 -{"entries":[{"hash":"bf985f6e8dea8c5ba087d85225cf7c4b1cf05840e536dd8d4e03606bb9b177bf","kind":"dir","mode":16384,"name":"napl-store"}]} -napl-store receipt v1 4839 -{"baseStateKey":"3691606e4e8c5b310d3148cff5211f28da63b0d2dff14c4696b4b53c1d018e77","baseStoreSnapshot":"9ca97ad272ef096af5d4768c733e14afe852ae25a3e15c907441906d73534d79","baseWorkspaceHash":"7723df1c9a398203d03390eea12ea5e38c7e0fb4e9ee4b19001a4d0d6f389e0d","candidateTreeHash":"303b0798f7d68368d0e2fd049ea1822280309b6079e6f82306bc8e126cc71268","contractVersion":4,"inputKey":"2cc3d94395a8577715b137202cf95651e6aae2b8f87a8520f9934acfba5aa978","module":"napl","outputManifest":[{"hash":"1a5de49301e4c6d47619ec0e44eab914b1846735830b07483c031e6a8c3dc67b","path":"crates/napl/src/attribution.rs"},{"hash":"e539c65a4600ed1784b9cc176cb28f24c140f514f9783360f1f5a27142f89f68","path":"crates/napl/src/authoring.rs"},{"hash":"bb3f26df6b0a5a9be1031bfd4c22d10297be448483085c5d0d558e12981a9ca2","path":"crates/napl/src/blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","path":"crates/napl/src/candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","path":"crates/napl/src/canon.rs"},{"hash":"0b8190e7989129e7baf4eed7046e19b7608067eb5d13bed95ad416118bf34a25","path":"crates/napl/src/capture.rs"},{"hash":"085f7e2d366c1756ff8f4e7bfdccbb8139c49779fd8ab00f91e3d6c787569c23","path":"crates/napl/src/cli.rs"},{"hash":"abad4283136e7fd0e880f49148d5422d673d5d1e4267976fd2a3dec47d7f34bd","path":"crates/napl/src/commandset.rs"},{"hash":"b151c3a18fb9dcae20fbaaddabd221bf11d62aa67578d2a100e67aa8b41408d4","path":"crates/napl/src/commit.rs"},{"hash":"51a5efd09235e6a66de7381faaa217a51136ca9c7d2b10285f99af8ebe4ffc6f","path":"crates/napl/src/compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","path":"crates/napl/src/cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","path":"crates/napl/src/decision.rs"},{"hash":"04a648a127be983c89a99181ee26e28401ee00f84ea0b10b6c050ecd263a1b2c","path":"crates/napl/src/directive.rs"},{"hash":"28059a89256aad1de78ecd31d7b77cae21772bbc6e96fe8ba1cdb9044048f410","path":"crates/napl/src/disk.rs"},{"hash":"8782c284c169040547f8be2ac31f2af84d891cfca618f1e33ebdcd8bb0722812","path":"crates/napl/src/error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","path":"crates/napl/src/evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","path":"crates/napl/src/graph.rs"},{"hash":"824c95b7229ac2ad5b1cc518b296443883fb56a110c86c2a2e67923c0a19ea07","path":"crates/napl/src/lint.rs"},{"hash":"40689b2560d6c445afd552c2f470ba3ce0d427622d96ed2cf2ad8cfdb4286321","path":"crates/napl/src/lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","path":"crates/napl/src/main.rs"},{"hash":"e9007f3c3ed117b885b5693d3b5aa763f563b986c61a05b4a77b70ca7193d3d5","path":"crates/napl/src/maintenance.rs"},{"hash":"bcd21ff53b6203362f6e73031b85b4e04e0f4c8436822a2396fffc0dd2f4a055","path":"crates/napl/src/mv.rs"},{"hash":"3ae089b923327032845c386f2332ff653a08f25ccec28e0786094fa05fd77a00","path":"crates/napl/src/objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","path":"crates/napl/src/paths.rs"},{"hash":"7f7e00683ff02b159e26ddae0c5a328bc874f56440e769619915f14a5924a243","path":"crates/napl/src/porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","path":"crates/napl/src/receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","path":"crates/napl/src/slice.rs"},{"hash":"a24eb1b3fc59a87a34e7f343c3396da60352ece50b6688c36b04da55351f1312","path":"crates/napl/src/source.rs"},{"hash":"7d1bba27fd84f0edae130f1902b00abab524e3a67d0428b1a2fe7a2c01af5273","path":"crates/napl/src/staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","path":"crates/napl/src/store_view.rs"},{"hash":"1820f647bb5dbd74c8db7f5519d404ef0ce1520c833a579aaca893b5cad7ba08","path":"crates/napl/src/surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","path":"crates/napl/src/target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","path":"crates/napl/src/textdiff.rs"},{"hash":"ca46ca78bcb2940f23d3aede83fdee2d10b6910dfb0eaa9de6a8ca83a2209427","path":"crates/napl/src/transaction.rs"},{"hash":"d4ef5888a7ecc406d8eb5173dc215c7b1a1ef1e1ddd3ab8b3a8aa6639d125017","path":"crates/napl/src/transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","path":"crates/napl/src/trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","path":"crates/napl/src/version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","path":"crates/napl/src/workspace.rs"}],"receiptVersion":1,"sliceHash":"caa8549d185ee89e9676fdab8299d8d22f77d59fdb3543cbd32ad00cc145c694","target":"rust"} -napl-store blob v1 22930 -//! The canonical document, `docVersion 1`: one JSON value built at query time -//! from committed state, the value `napl q` evaluates against and `napl prompt` -//! materializes from. Everything here is a pure read: no store writes, no locks, -//! no clock, no randomness, no network. Every collection is totally ordered and -//! no absolute path appears, so the same committed state and the same working -//! tree yield the same document bytes on any machine. -//! -//! Reference staleness is the one fact read from the working tree as well as -//! from the records: a reference whose code side no longer matches the committed -//! bytes reads stale at both endpoints. It is computed here at read time and -//! never stored, so drift appears and heals without a transition. -//! -//! The working-tree view is the second stated exception. Asked for it -//! explicitly, the builder enumerates every prompt-derived fact from the on-disk -//! prompt bodies instead of the committed blobs, so a section or a case that -//! exists only in the working tree is served. Store-derived facts stay -//! committed, and no hash is invented for uncommitted bytes: a working-tree -//! section carries `workingTree: true` in place of a committed `head`. +napl turns prompts into code you can trust: the same prompt, the same +dependencies, and the same target always produce the same bytes. Your +`.napl` files are the contract; the store records every step from +sentence to source line, so nothing about your codebase is ever a +mystery. +::: -use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; +# NAPL: the deterministic compiler -use napl_store::store_hash::ObjectKind; -use serde_json::{json, Value}; +This prompt describes the entire NAPL toolchain, rebuilt as one small Cargo +workspace of exactly three crates. It replaces the legacy 76-crate +implementation completely; git history holds the original. This prompt is +the governing contract: the workspace's `.napl` prompts and the committed +code are the two truths, and no external document outranks them. -use crate::attribution::{ - self, code_drifted, reference_status, Attribution, Direction, Entry, -}; -use crate::compile::compile; -use crate::directive::{extract_cases, DirectiveCase}; -use crate::disk::{self, Store}; -use crate::error::Result; -use crate::objects::{event_transition, read_module_state, snapshot_states, ModuleState}; -use crate::porcelain::{causal_order, status}; -use crate::receipt::{parse_receipt, receipt_semantic_hash}; -use crate::source::SourceModule; -use crate::store_view::{get_blob, get_json, get_object, has_object, ObjectSource}; -use crate::transport::{line_map, transport_range, Range, Transported}; -use crate::workspace::discover_sources; +## Layout: this module generates two crates on top of the ported store -/// The document schema version. Bumped only when the shape changes. -pub const DOC_VERSION: u32 = 1; +:::docs +## Your prompts live beside your code -/// Build the canonical document for a workspace from committed records. -pub fn build_document(workspace: &Path) -> Result { - build_document_view(workspace, false) -} +A module is a `.napl` file inside the crate that receives its code: +the folder is the crate, and the prompt's location is the placement +declaration. There is nothing to configure; walking your workspace +for `.napl` files IS the build graph. +::: -/// Build the canonical document, serving prompt-derived facts from the on-disk -/// prompt bodies when `working_tree` is set. Store-derived facts are committed -/// either way, so the two views differ only where the prompt does. -pub fn build_document_view(workspace: &Path, working_tree: bool) -> Result { - let store = disk::open(workspace); - let head = store.head()?; - let sources = discover_sources(workspace)?; - let target = "rust"; - let states = match &head { - Some(h) => snapshot_states(&store, h)?, - None => BTreeMap::new(), - }; - let artifact = compile(&store, &sources, target, None)?; - let pending: BTreeSet = artifact.slices.iter().map(|s| s.module.clone()).collect(); +The workspace layout is fixed: the records directory `.napl/` lives at the +repository root; generated code installs under `crates/`; a module's +prompt file lives inside the crate directory that receives its code, and +the crate is the folder: every module whose `.napl` file sits in +`crates//` compiles into the `` crate as its own source +module, so one crate may be described by several prompt files +(`crates/napl/napl.napl` and `crates/napl/commands.napl` both build +`crates/napl/`). Module discovery finds prompts by walking the workspace +for `*.napl` files, and the workspace root is the nearest ancestor +directory containing `.napl`. A subtree that contains its own `.napl` +directory is a separate workspace: discovery never descends into it, the +way git never descends into a nested repository. Hidden directories and +`node_modules` are never descended into. - let mut names: BTreeSet = sources.iter().map(|s| s.module.clone()).collect(); - for (t, m) in states.keys() { - if t == target { - names.insert(m.clone()); - } - } +The proven store already exists as a hand-ported crate at +`crates/napl-store/` (same envelope format `napl-store v1 +\n`, SHA-256 names, canonical JSON payloads, same object +kinds, same `.napl/store/` on-disk layout with HEAD compare-and-swap, +module-keyed locks, and dead-holder steal, ported code and tests from the +legacy store crates). Use its public API through a path dependency; do not +reimplement, wrap thinly, or fork any of its types or logic. The four new +object kinds the compiler needs (`transition`, `receipt`, `surface`, +`target-def`) already exist in it additively. - let usage_by_receipt = usage_index(&store)?; +::: test +name: the ported store hashes objects exactly as the original store crates did +given: { vectors: ported_store_hash_test_corpus } +expect: { all_pass_unmodified: true } +::: - let mut modules: Vec = Vec::new(); - for name in &names { - let source = sources.iter().find(|s| &s.module == name); - let state = match states.get(&(target.to_string(), name.clone())) { - Some(h) => Some(read_module_state(&store, h)?), - None => None, - }; - modules.push(build_module( - workspace, - &store, - head.as_deref(), - name, - source, - state.as_ref(), - pending.contains(name), - working_tree, - )?); - } +::: test +name: identical payload under two object kinds gets two different names +given: { payload: "X", kind_a: blob, kind_b: snapshot } +expect: { equal: false } +::: - let history = build_history(&store, head.as_deref(), &usage_by_receipt)?; - let state_block = status(workspace, target)?; +This module's generated output is as many crates as the design needs, side +by side in the module's owned directory. Keep it a small, clearly separated +workspace (this replaces a 76-crate explosion; do not recreate one), with +`napl-store` staying its own untouched crate. The natural split, which you +may adjust if the design genuinely wants another boundary: - let mut document = json!({ - "docVersion": DOC_VERSION, - "head": head.clone().map(Value::String).unwrap_or(Value::Null), - "history": history, - "modules": modules, - "state": state_block, - }); - if working_tree { - if let Some(object) = document.as_object_mut() { - object.insert("workingTree".to_string(), Value::Bool(true)); - } - } - Ok(document) -} +1. **`napl-core/`** (lib): the pure compiler. Parsing `.napl` files (YAML + frontmatter: module, deps, targets, tests; markdown body), discovery and + dependency graph, canonical path grammar, the identity vocabulary, + per-module artifact slices, wave planning, tree-sitter surface extraction + for Rust and TypeScript targets, the surface grammar, placement rules + and entry-file projection derivation, receipt verification logic, the commit + decision table as pure functions, blame line-ancestry replay, lint + diagnosis, cost calculation, evidence validation. No filesystem writes, + no locks, no clocks, no environment reads: everything enters as + arguments and leaves as values. +2. **`napl-cli/`** (bin `napl`): every command, each a thin composition of + napl-core derivations and napl-store reads plus the one guarded write + path. No private logic in the binary beyond argument parsing and + formatting. -fn build_module( - workspace: &Path, - store: &Store, - head: Option<&str>, - name: &str, - source: Option<&SourceModule>, - state: Option<&ModuleState>, - pending: bool, - working_tree: bool, -) -> Result { - let prompt_path = source.map(|s| s.path.clone()); - let deps = source.map(|s| s.deps.clone()).unwrap_or_default(); - let targets = source.map(|s| s.targets.clone()).unwrap_or_default(); - let surface_hash = state.map(|s| s.surface_hash.clone()); - let mut frontmatter_cases = source.map(|s| s.frontmatter_cases.clone()).unwrap_or_default(); - frontmatter_cases.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); +The crates depend on `napl-store` by path, `serde_json`, `sha2`, +`tree-sitter`, `tree-sitter-rust`, `tree-sitter-typescript`, `clap` +(builder API only, driving the command line contract below), and nothing +that talks to a network, spawns a process, or reads clocks; those +declarations live in the user-owned manifests, not in any module's +output. Ensure `cargo test` passes for both crates and `napl-store` from +their workspace before finishing. Generated code is authored against its +final installed location, never a staging tree: acceptance runs at the +final location, so green certifies the bytes where they live. The +working-tree add forms are the operational spelling of this law: author +and verify in place, then stage from where the bytes live; assembling a +separate candidate directory is the exception, for staging bytes that +are not the working tree, never the routine. - let status = attribution_status(name, source, state); - let record = committed_attribution(store, state)?; - let drift = working_drift(workspace, store, state)?; - let committed_body = committed_body(store, state)?; - let (sections, outline_body) = if working_tree { - let cases = source.map(|s| s.directive_cases.clone()).unwrap_or_default(); - let body = source.map(|s| s.normalized_body.clone()).unwrap_or_default(); - let sections = build_working_tree_sections( - name, - prompt_path.as_deref(), - committed_body.as_deref().unwrap_or(""), - &body, - record.as_ref(), - status == "stale", - &cases, - &drift, - ); - (sections, body) - } else { - let cases = committed_body - .as_deref() - .and_then(|body| extract_cases(body).ok()) - .unwrap_or_default(); - let sections = build_sections( - head, - name, - prompt_path.as_deref(), - record.as_ref(), - status == "stale", - &cases, - &drift, - ); - (sections, committed_body.clone().unwrap_or_default()) - }; - let files = owned_files(store, state)?; +When several modules share one crate, the crate's shared entry files are +derived projections owned by no module: the crate root `lib.rs` module +list is recomputed at commit from the union of the crate's committed +module file sets, and the binary's command registry is recomputed from +the command tables the crate's modules expose under one fixed convention +(each module may expose `pub fn commands() -> &'static [CommandSpec]` +from its own files; the dispatch loop iterates the derived registry and +contains no per-command match arms of its own). A module's owned output +therefore never includes the shared entry files, two modules can never +claim the same path, and a commit that would write a projection-owned +path is rejected as invalid-candidate. - Ok(json!({ - "attribution": { "status": status }, - "deps": deps, - "files": files, - "frontmatterCases": frontmatter_cases, - "name": name, - "outline": outline(&outline_body), - "pending": pending, - "promptPath": prompt_path, - "sections": sections, - "surfaceHash": surface_hash, - "targets": targets, - })) -} +A prompt at the workspace root is legal and means the workspace root is +the crate directory, exactly as git treats the directory it was +initialized in: the placement root is the empty workspace-relative path, +every workspace-relative path is inside it, and every join against it +produces clean relative paths, never a fabricated leading separator. A +cold workspace whose first prompt sits beside `.napl` therefore commits +its first module with no configuration; placement never needs declaring +because the prompt's location is the declaration. + +::: test +name: a root-level prompt places at the workspace root and its module commits +given: { workspace: fresh_init_root_prompt, candidate: valid_root_level } +expect: { placement_root: "", owned_globs_relative: true, outcome: installed } +::: + +::: test +name: status for a root-level module reports a workspace-root plan with clean globs +given: { workspace: fresh_init_root_prompt, flag: module_root } +expect: { plan_placement_root: "", owned_globs_leading_separator: false } +::: + +## The command line contract + +:::docs +## A CLI that answers in JSON + +Every verb prints one JSON object and exits with a stable code, so a +script, an editor, or an agent reads napl the same way you do. +Refusals are answers too: a refusal names what stopped it and the +verb that heals it, and nothing on disk changes. +::: + +:::example{lang="cli" name="ask, in json"} +napl status +napl q '.head' +::: + +Every command the registry serves declares its full grammar in its +`CommandSpec`: name, a one-line summary, the exact usage line, and the +closed table of flags it accepts, each with arity and meaning. The +dispatch loop owns two behaviors ahead of every command, so no command +can get them wrong. First, help: `napl help` lists every registered +command with its summary, `napl help ` and `--help` or `-h` +anywhere in a command's arguments print that command's usage line, flag +table, and summary, exit 0, and never invoke the command. Help speaks +human by default and machine on request: when stdout is a terminal or +`--human` is passed, help renders readable text from the registry, +usage line, summary, flag table with meanings, and one worked example +per command; when stdout is piped or `--json` is passed, help emits +the canonical JSON registry exactly as today, byte-stable. Help is +the only command with a human rendering; every other command speaks +canonical JSON everywhere, and the two help renderings derive from +the one registry so they can never disagree. Help is a pure +read: no store access, no filesystem write, no state change of any kind, +and this holds for every command including `init` and `watch`. Second, +strict parsing: an argument beginning with `--` that the command's flag +table does not name is a usage error, never ignored; the error goes to +stderr as canonical JSON naming the unknown flag, listing the command's +valid flags, and suggesting the nearest declared flag by edit distance +when one is close, and the command does not execute. Exit 2 is the +pinned usage-error code for the whole binary: unknown command, unknown +flag, missing required flag or positional, malformed flag value. A +usage error always restates the command's exact usage line, so the +error itself teaches the grammar. + +The parser behind this contract is clap's builder API, constructed at +dispatch from the CommandSpec registry, never derive macros and never +per-command clap declarations: the registry stays the single source of +grammar and clap is its execution engine. clap's own renderings are +never emitted; every parse failure is caught and served as the +canonical JSON usage error above, byte-shaped by this contract alone. +`--flag value` and `--flag=value` parse identically; no command +declares short flags today, so clustering is outside the contract +until one does. The registry pre-pass, not clap, is what refuses an +undeclared flag, because the canonical JSON payload with its +nearest-name suggestion is pinned by this contract; clap normalizes +spellings and values behind it. `napl completions ` emits a completion script for +`bash`, `zsh`, or `fish`, generated from the same registry, a pure +read on stdout; an unknown shell name is a usage error. + +The commands whose meaning requires a committed head are exactly +`diff`, `blame`, and `notes add`; in a workspace that has none they +answer with a prerequisite error, never an empty success shape: the +payload names what is missing and the exact sequence that creates it, +from `napl init` through writing a prompt beside `.napl`, `add`, and +`commit`. `log` and `show` keep their committed contracts and answer +empty honestly. `napl blame` on a path no committed module owns, and +`napl diff` for a target under which no committed module owns output, +say so: the payload carries `tracked: false` and a reason naming the +absent baseline, never an empty patch or empty ancestry that reads as +success. Diff's operand is a target name and its usage line spells it +``; an operand that is not one (a module name, a file +path) is a usage error naming the target names that exist, so the +argument teaches its own vocabulary. The `napl notes add` refusal for a working path +that is not an object name states how an object name is obtained, +naming `napl log` and `napl show` with a one-line example. + +::: test +name: flag equals value and separated value parse identically +given: { workspace: fixture_two_modules, invocations: ["status --module leaf", "status --module=leaf"] } +expect: { outputs_byte_identical: true } +::: + +::: test +name: completions emits a script for a known shell and refuses an unknown one +given: { workspace: fixture_two_modules, invocations: ["completions zsh", "completions tcsh"] } +expect: { zsh_exit_code: 0, zsh_output_nonempty: true, tcsh_exit_code: 2, store_bytes_unchanged: true } +::: + +::: test +name: blame on an unowned path and diff on an outputless target say untracked +given: { workspace: fixture_two_modules, blame_path: file_no_module_owns, diff_target: target_without_outputs } +expect: { tracked: false, reason_names_baseline: true, exit_code: 0 } +::: + +::: test +name: status reports code-side drift and stale references beside prompt freshness +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } +expect: { module_drift: true, stale_reference_count: 1, prompt_side_fresh: true } +::: + +::: test +name: a headless workspace answers baseline commands with the prerequisite sequence +given: { workspace: fresh_init_no_commits, invocation: "blame some/file.rs" } +expect: { error_names_missing_head: true, sequence_named: [init, prompt, add, commit] } +::: + +::: test +name: init with a help flag prints usage and mutates nothing +given: { directory: empty, invocation: "init --help" } +expect: { exit_code: 0, created: [], usage_printed: true } +::: + +::: test +name: an unknown flag is a usage error naming the flag and suggesting the nearest +given: { workspace: fixture_two_modules, invocation: "add cand --modul m" } +expect: { exit_code: 2, error_names_unknown: "--modul", suggested: "--module", executed: false } +::: + +::: test +name: help lists every registered command and per-command help states exact usage +given: { workspace: fixture_two_modules, invocations: ["help", "help add"] } +expect: { all_commands_listed: true, add_usage_line_present: true, exit_code: 0, store_bytes_unchanged: true } +::: + +::: test +name: help renders human text under the human flag and identical json when piped +given: { workspace: fixture_two_modules, invocations: ["help --human", "help --json"] } +expect: { human_not_json: true, human_carries_example: true, json_byte_stable: true } +::: + +Manifests are user-owned glue seams, not records: `Cargo.toml` at the +workspace root and in every crate belongs to the user, edited by hand +like any Rust project, tracked by git, consumed by cargo, and NEVER +owned, installed, validated, or rewritten by the toolchain. A candidate +containing a manifest path is rejected as invalid-candidate; no module's +owned output includes one; nothing in the store records manifest bytes. +The toolchain's only relationship to manifests is diagnosis: `status` +reports, as observations never as writes, a module placed in a crate the +workspace manifest does not list, or a crate directory missing a +manifest. Every manifest diagnostic names the exact workspace-relative +manifest path it means and says the file is the user's to edit, so the +manifest is never a thing status alludes to without locating. A dependency needed by generated code is added by editing the +manifest, and the build battery is what proves it correct. + +::: test +name: a candidate containing a manifest path is rejected +given: { workspace: fixture_two_modules, candidate: contains_cargo_toml } +expect: { outcome: invalid_candidate, manifest_bytes_unchanged: true } +::: + +## The two principles + +:::docs +## Both sides are the truth + +The prompt and the generated code are two spellings of one module. +Edit either side: drift is legal, visible, and recorded, never a +build break. Diagnostics mark exactly where the sides disagree, so +re-affirming or regenerating is always a deliberate act, never a +surprise. +::: + +P1 ownership: the toolchain owns `.napl` source, the derived layer, the +store, and the mappings between them; every owned fact is provable from +content alone. It contains no LLM invocation, no provider identifier, no +model selection, no completion parsing, no process execution (the only +process is `napl` itself), no clock reads in any semantic path, no +randomness in any semantic path. Generation happens outside: the user's +agent session reads the plan from `napl status`, the specification through +the query surface, writes candidate code, and calls `napl add` then +`napl commit`. + +::: test +name: the binaries spawn no process and name no provider +given: { crate_source: self } +expect: { spawns_processes: false, provider_identifiers: [] } +::: + +P2 footprint scoping: every operation declares a read set (content +identities consumed), a semantic write set (owned records changed), and its +derived projections plus physical install paths. Two operations conflict +when semantic write sets overlap or one's accepted write changes a content +identity in the other's read set. Derived projections (the crate-root +lib.rs module list, the command registry) are never conflict-bearing: +they are recomputed exactly once from the winning merged head inside +commit and rebase. Manifests are outside footprints entirely: user-owned +seams no operation reads as identity or writes at all. + +## Identity + +Expose `pub const CONTRACT_VERSION: u32 = 4` in napl-core (bumped from 1 +by the body-directive grammar, which changes how the same source bytes +are interpreted, from 2 by the relaxed module-name rule below, which +accepts sources contract 2 rejected, and from 3 by the capture verbs, +whose three refusal outcomes join the closed terminal-outcome table). Per module and +target: `surfaceHash` (hash of the recorded public surface), `inputKey` +(hash of the canonical closure manifest over normalized source hash, direct +dependencies' surfaceHashes, placement facts, entry-point version, target +name plus target-definition hash, CONTRACT_VERSION), `baseStateKey` (the +module's committed owned-output state hash), `sliceHash` (hash of the +module's canonical compile slice). Whole-compile `artifactHash` includes +scope and serves determinism tests only, never a commit precondition. Event +identity is `H(content hash + sorted parent hashes)`; ordinals and +timestamps are display metadata, never identity. Semantic identifiers are +content hashes, never a timestamp, pid, hostname, random value, model, or +provider. Telemetry lives in provenance records linking to semantic objects +by hash. + +::: test +name: "a module's inputKey covers source, direct dep surfaces, placement, target identity, and contract version" +given: { workspace: fixture_two_modules, module: consumer } +expect: { input_key_components: [source_hash, direct_dep_surface_hashes, placement_facts, target_name, target_definition_hash, entry_point_version, contract_version] } +::: + +::: test +name: changing a leaf's surface changes the direct dependent's inputKey only +given: { workspace: fixture_chain_three, edit: rename_public_item_in_leaf } +expect: { changed_input_keys: [middle], unchanged_input_keys: [top] } +::: -/// The prompt body's headings in body order, each with its level and its -/// body-normalized 1-based line. -/// -/// This exists so "which body line is this heading on" is a query rather than a -/// raw file read: the whole point of the query surface is that nobody has to open -/// a prompt and count. A heading is an ATX heading, one to six `#` followed by a -/// space; nothing else in markdown names a section. -fn outline(body: &str) -> Vec { - let mut out: Vec = Vec::new(); - for (index, line) in body.lines().enumerate() { - let hashes = line.chars().take_while(|c| *c == '#').count(); - if hashes == 0 || hashes > 6 { - continue; - } - let Some(text) = line[hashes..].strip_prefix(' ') else { - continue; - }; - out.push(json!({ - "level": hashes as u64, - "line": (index + 1) as u64, - "text": text.trim_end(), - })); - } - out -} +::: test +name: a leaf edit absorbed by an unchanged intermediate surface leaves higher dependents' inputKeys unchanged +given: { workspace: fixture_chain_three, edit: private_body_change_in_leaf } +expect: { changed_input_keys: [], unchanged_input_keys: [middle, top] } +::: -/// A module's attribution freshness, a pure observation over committed content -/// and the current prompt body. -fn attribution_status(_name: &str, source: Option<&SourceModule>, state: Option<&ModuleState>) -> String { - let Some(attribution) = state.and_then(|s| s.attribution.as_ref()) else { - return "absent".to_string(); - }; - let current = source.map(|s| s.normalized_source_hash.as_str()); - if attribution.stale || current != Some(attribution.prompt_hash.as_str()) { - "stale".to_string() - } else { - "fresh".to_string() - } -} +::: test +name: telemetry differences never change transition identity +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipts: [with_usage, without_usage] } +expect: { transition_identities_equal: true } +::: -/// The committed attribution record of a module, parsed from its record blob. -fn committed_attribution(store: &Store, state: Option<&ModuleState>) -> Result> { - let Some(attribution) = state.and_then(|s| s.attribution.as_ref()) else { - return Ok(None); - }; - if !has_object(store, &attribution.record) { - return Ok(None); - } - let bytes = get_blob(store, &attribution.record)?; - Ok(attribution::parse(&String::from_utf8_lossy(&bytes))) -} +::: test +name: history nodes are causal hashes over content plus sorted parents +given: { workspace: fixture_two_modules, commits: 2 } +expect: { event_id_components: [content_hash, sorted_parent_ids], ordinal_in_identity: false, timestamp_in_identity: false } +::: -/// The module's committed normalized prompt body, as the bytes the attribution -/// was proven against. Cases and the heading outline both derive from it, and so -/// does the diff a working-tree read transports claims through. -fn committed_body(store: &Store, state: Option<&ModuleState>) -> Result> { - let Some(attribution) = state.and_then(|s| s.attribution.as_ref()) else { - return Ok(None); - }; - if !has_object(store, &attribution.prompt_hash) { - return Ok(None); - } - let bytes = get_blob(store, &attribution.prompt_hash)?; - Ok(Some(String::from_utf8_lossy(&bytes).into_owned())) -} +## Module names -/// Working-tree drift per owned path: true when the bytes on disk no longer hash -/// to the committed blob. This is read at query time and never stored, so a hand -/// edit to generated source shows up as drift the moment it is made and -/// disappears the moment it is healed. -fn working_drift( - workspace: &Path, - store: &Store, - state: Option<&ModuleState>, -) -> Result> { - let mut out: BTreeMap = BTreeMap::new(); - let Some(state) = state else { - return Ok(out); - }; - if !has_object(store, &state.output_tree) { - return Ok(out); - } - for (path, (blob, _mode)) in crate::store_view::flatten_tree(store, &state.output_tree)? { - let bytes = std::fs::read(workspace.join(&path)).ok(); - out.insert(path, code_drifted(Some(blob.as_str()), bytes.as_deref())); - } - Ok(out) -} +A module name is any non-empty single-line scalar. The empty string is the only +value rejected, surfaced as `mapping-integrity`. No character class, case, or +identifier shape is required: `todo-app`, `todo_app`, and non-ascii names are all +legal. The same rule governs every name position the parser reads, the `module` +frontmatter value, each `deps` entry, and each declared root, so all three accept +exactly the set the module rule accepts. This relaxation replaces the contract 2 +identifier constraint and rides the CONTRACT_VERSION bump to 3, the sanctioned +identity transition. As with the 1-to-2 bump, every module's inputKey recomputes +because CONTRACT_VERSION is one of its components; no source or surface changes, +only the version does. -/// The provenance a reader is served for one entry: every field present, every -/// absent field spelled `unavailable` rather than zero or invented. A recorded -/// usage window is served with its structure intact (media type, log path, -/// session, and line span) because it is a reference to be priced later and not -/// a total already taken. -fn provenance_json(entry: &Entry) -> Value { - entry.provenance.served_json() -} +Because a name is unrestricted, it is never used raw as a filesystem path segment +or a store key. napl-core exposes one injective, reversible encoding used at every +such site: each byte outside `[A-Za-z0-9._-]` is percent-encoded as `%XX` with +uppercase hex, and a name that is exactly `.` or `..` has its dots percent-encoded +too, so an encoded segment is never `/`, `\`, empty, `.`, or `..` and can never +traverse or alias a directory. A name already matching `[A-Za-z0-9._-]` and not +equal to `.` or `..` encodes to itself, so existing records keep their on-disk +names: `todo-app` stays `todo-app`. Every writer that keys a record or scratch +path by module name, the mapl, ir, attribution, and prompt-at-gen documents, the +staging and relocation scratch directories, and the store root map, uses the +encoded segment; decoding recovers the exact name. On a case-insensitive +filesystem two names differing only in ascii case can still share one segment; +that collision is the author's concern, distinct from the traversal safety above. -fn build_sections( - head: Option<&str>, - module: &str, - prompt_path: Option<&str>, - record: Option<&Attribution>, - stale: bool, - cases: &[DirectiveCase], - drift: &BTreeMap, -) -> Vec { - let head_v = head.map(|h| Value::String(h.to_string())).unwrap_or(Value::Null); - let entries: &[Entry] = record.map(|r| r.entries.as_slice()).unwrap_or(&[]); - let mut covered: Vec = vec![false; cases.len()]; - let mut sections: Vec<(u32, u32, Value)> = Vec::new(); +Wherever a module name is serialized into a record it is written as a quoted, +escaped scalar, never interpolated raw, so a name containing YAML or JSON +metacharacters reparses to the identical name. - for entry in entries { - let (start, end) = (entry.prompt_start, entry.prompt_end); - let mut section_cases: Vec = Vec::new(); - for (idx, case) in cases.iter().enumerate() { - if ranges_intersect(case.line_start, case.line_end, start, end) { - covered[idx] = true; - section_cases.push(case_json(case)); - } - } - section_cases.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); - let files = match &entry.file { - Some(file) => vec![json!({ - "head": head_v, - "lines": [entry.line_start, entry.line_end], - "path": file, - })], - None => Vec::new(), - }; - let code_drift = entry - .file - .as_ref() - .and_then(|file| drift.get(file).copied()) - .unwrap_or(false); - let reference = reference_status(stale, false, code_drift); - sections.push(( - start, - end, - json!({ - "associationStale": stale, - "cases": section_cases, - "direction": entry.direction.as_str(), - "files": files, - "head": head_v, - "law": entry.file.is_none(), - "lines": [start, end], - "module": module, - "note": entry.note, - "promptPath": prompt_path, - "provenance": provenance_json(entry), - "referenceStatus": reference.as_str(), - "staleEndpoints": reference.endpoints, - }), - )); - } +::: test +name: a non-identifier module name is accepted +given: { workspace: fixture_single_module, module_name: "todo-app" } +expect: { outcome: ok } +::: - // A directive case intersecting no attribution entry lands in a synthetic - // section that owns no file. - for (idx, case) in cases.iter().enumerate() { - if covered[idx] { - continue; - } - let reference = reference_status(stale, false, false); - sections.push(( - case.line_start, - case.line_end, - json!({ - "associationStale": stale, - "cases": [case_json(case)], - "direction": Direction::default().as_str(), - "files": [], - "head": head_v, - "law": false, - "lines": [case.line_start, case.line_end], - "module": module, - "note": "", - "promptPath": prompt_path, - "provenance": provenance_json(&Entry::default()), - "referenceStatus": reference.as_str(), - "staleEndpoints": reference.endpoints, - }), - )); - } +::: test +name: an empty module name is rejected as mapping-integrity +given: { workspace: fixture_single_module, module_name: "" } +expect: { outcome: mapping_integrity } +::: - sections.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1))); - sections.into_iter().map(|(_, _, v)| v).collect() -} +::: test +name: deps entries and declared roots accept the same names as the module rule +given: { module_name: "todo-app", dep_name: "a-b", root_name: "x.y" } +expect: { all_accepted: true } +::: -/// The heading blocks of a normalized prompt body, in body order: the preamble -/// before the first `## ` heading, then one block per heading running to the -/// line before the next. A body with no heading is one block. This is what a -/// section is when the enumeration comes from the prompt itself rather than from -/// a committed attribution record, so a heading added in the working tree is a -/// section the moment it is typed. -fn body_blocks(body: &str) -> Vec<(u32, u32)> { - let lines: Vec<&str> = body.lines().collect(); - let total = lines.len() as u32; - if total == 0 { - return Vec::new(); - } - let starts: Vec = lines - .iter() - .enumerate() - .filter(|(_, line)| line.starts_with("## ")) - .map(|(index, _)| (index + 1) as u32) - .collect(); - let mut blocks: Vec<(u32, u32)> = Vec::new(); - let first = starts.first().copied().unwrap_or(total + 1); - if first > 1 { - blocks.push((1, first - 1)); - } - for (index, start) in starts.iter().enumerate() { - let end = starts.get(index + 1).map(|next| next - 1).unwrap_or(total); - blocks.push((*start, end)); - } - blocks -} +::: test +name: the name encoding is injective reversible and never traverses +given: { names: ["todo-app", "a/b", "..", "café", "a b"] } +expect: { decode_roundtrip_equal: true, no_segment_in: ["/", "\\", "", ".", ".."], safe_subset_unchanged: ["todo-app"] } +::: -/// Sections enumerated from the on-disk prompt body, with file associations -/// transported rather than re-derived. -/// -/// Each block carries the working-tree cases whose range it contains, so a -/// section typed a moment ago is a section. Its files come from the committed -/// map's claims transported through the committed-to-pending prompt diff, the same -/// deterministic transport `add` uses, and a claim only lands in the block that -/// contains the range the transport proved. A claim the transport could not carry -/// serves no files at all and marks its block `associationUnproven: true`, located -/// at the last line the diff proves survived at or before it. -/// -/// What this deliberately does not do is attach files by range arithmetic over the -/// re-anchored body. An overlap that happens to exist between a stale recorded -/// range and a shifted block is not a claim anybody made, and serving it as one -/// would invent a mapping: that is exactly how a view once attached a watcher's -/// file to an `init` section after the ranges moved underneath it. -#[allow(clippy::too_many_arguments)] -fn build_working_tree_sections( - module: &str, - prompt_path: Option<&str>, - committed_body: &str, - body: &str, - record: Option<&Attribution>, - stale: bool, - cases: &[DirectiveCase], - drift: &BTreeMap, -) -> Vec { - let entries: &[Entry] = record.map(|r| r.entries.as_slice()).unwrap_or(&[]); - let map = line_map(committed_body, body); - // Each recorded claim is carried once, up front, so every block reads the - // same verdict and no block can disagree with another about one claim. - let carried: Vec<(&Entry, Transported)> = entries - .iter() - .map(|entry| { - let range = Range::new(entry.prompt_start, entry.prompt_end); - (entry, transport_range(&map, range)) - }) - .collect(); +::: test +name: a name with yaml metacharacters is written quoted and reparses identically +given: { module_name: "a: {b} #c" } +expect: { record_reparse_equal: true, written_as_quoted_scalar: true } +::: - let mut sections: Vec = Vec::new(); - for (start, end) in body_blocks(body) { - let mut section_cases: Vec = cases - .iter() - .filter(|case| ranges_intersect(case.line_start, case.line_end, start, end)) - .map(case_json) - .collect(); - section_cases.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); +## The compile derivation - let proven: Vec<&Entry> = carried - .iter() - .filter(|(_, outcome)| match outcome.proven() { - Some(range) => range.start >= start && range.end <= end, - None => false, - }) - .map(|(entry, _)| *entry) - .collect(); - let unproven = carried.iter().any(|(_, outcome)| match outcome { - Transported::Unproven { anchor, .. } => { - anchor.map(|line| line >= start && line <= end).unwrap_or(false) - } - Transported::Proven(_) => false, - }); +Compilation is a pure derivation and NOT a command: parse the graph, +compute per-module slices for the changed frontier, derive +dependency-ordered waves. Never persisted, and never spelled on the CLI — +it is the internal engine of `status`, `add`, `commit`, `watch`, and the +lsp. Per module the slice carries: normalized source body and hash, direct +dependency surfaces and surfaceHashes, placement and allowed-output +constraints including the surface grammar, the owned-output base and +baseStateKey, inputKey, sliceHash, wave position, receipt requirements. It +contains no provider, model, prompt assembly, credentials, timestamps, +retry policy, pricing, or test results. `napl status --module ` +reports the module's PLAN from this derivation — owned globs, placement +root, projection ownership, receipt requirements, wave position — the +invariant requirements only, a pure function of the working tree's +prompts; candidate-dependent validation belongs to `add`, never the plan. - let mut files: Vec<(String, u32, Value)> = if unproven { - Vec::new() - } else { - proven - .iter() - .filter_map(|entry| { - entry.file.as_ref().map(|file| { - ( - file.clone(), - entry.line_start, - json!({ - "lines": [entry.line_start, entry.line_end], - "path": file, - "workingTree": true, - }), - ) - }) - }) - .collect() - }; - files.sort_by(|a, b| (&a.0, a.1).cmp(&(&b.0, b.1))); - let files: Vec = files.into_iter().map(|(_, _, value)| value).collect(); +Pending is never a bare boolean. A pending module names its reason +from a closed vocabulary: `never-committed`, `source-changed`, +`dependency-surface-moved`, `placement-changed`, `target-changed`, +`contract-version-changed`; the reason is derived by comparing the +current derivation's inputKey components against the identities the +committed state records, the same facts the store already holds, +never a guess. And because an undrifted working tree stages exactly +the committed bytes, a pending module with no drift additionally +says the landing would re-stamp identical bytes, so whether a +pending module needs a real regeneration or a no-op landing is read +off status, never discovered by staging and comparing tree hashes. - let code_drift = proven.iter().any(|entry| { - entry - .file - .as_ref() - .and_then(|file| drift.get(file).copied()) - .unwrap_or(false) - }); - let reference = reference_status(stale, false, code_drift); - let law = !proven.is_empty() && proven.iter().all(|entry| entry.file.is_none()); - let fallback = Entry::default(); - let representative = proven.first().copied().unwrap_or(&fallback); +::: test +name: a pending module names dependency-surface-moved as its reason +given: { workspace: fixture_chain_three, intervening_commit: leaf_surface_change, flag: module_middle } +expect: { pending: true, pending_reason: dependency_surface_moved } +::: - sections.push(json!({ - "associationStale": stale, - "associationUnproven": unproven, - "cases": section_cases, - "direction": representative.direction.as_str(), - "files": files, - "law": law, - "lines": [start, end], - "module": module, - "note": representative.note, - "promptPath": prompt_path, - "provenance": provenance_json(representative), - "referenceStatus": reference.as_str(), - "staleEndpoints": reference.endpoints, - "workingTree": true, - })); - } +::: test +name: a pending undrifted module says the landing re-stamps identical bytes +given: { workspace: fixture_chain_three, intervening_commit: leaf_surface_change, flag: module_middle } +expect: { pending: true, drift: false, reland_identical_bytes: true } +::: - sections -} +::: test +name: a never committed module reads pending with its reason +given: { workspace: fixture_two_modules, module: pending_never_committed, flag: module_pending } +expect: { pending: true, pending_reason: never_committed } +::: -fn case_json(case: &DirectiveCase) -> Value { - json!({ - "expect": case.expect, - "given": case.given, - "lines": [case.line_start, case.line_end], - "name": case.name, - }) -} +::: test +name: a prompt edit reads pending as source-changed +given: { workspace: fixture_two_modules, working_tree_edit: prompt_body_touched, flag: module_edited } +expect: { pending: true, pending_reason: source_changed } +::: -fn ranges_intersect(a_start: u32, a_end: u32, b_start: u32, b_end: u32) -> bool { - a_start <= b_end && b_start <= a_end -} +::: test +name: status reports a module plan derived from the working tree +given: { workspace: fixture_two_modules, flag: module_leaf } +expect: { plan_fields: [owned_globs, placement_root, receipt_requirements, wave_position], candidate_dependent_fields: absent } +::: -/// The module's owned output paths, sorted. -fn owned_files(store: &Store, state: Option<&ModuleState>) -> Result> { - let Some(state) = state else { - return Ok(Vec::new()); - }; - if !has_object(store, &state.output_tree) { - return Ok(Vec::new()); - } - let mut paths: Vec = crate::store_view::flatten_tree(store, &state.output_tree)? - .into_keys() - .collect(); - paths.sort(); - Ok(paths) -} +::: test +name: status module answers live state and the derived projection beside the plan +given: { workspace: fixture_two_modules, flag: module_leaf, working_tree_edit: leaf_output_touched } +expect: { state_fields: [attribution, drift, pending, stale_references, tracked], drift: true, projection_path_present: true, projection_content_present: true } +::: -/// The causal history reachable from head, oldest first, each entry carrying its -/// validated usage or null. -fn build_history( - store: &Store, - head: Option<&str>, - usage_by_receipt: &BTreeMap, -) -> Result> { - let Some(head) = head else { - return Ok(Vec::new()); - }; - let mut entries: Vec = Vec::new(); - for event in causal_order(store, head)? { - let transition_id = event_transition(store, &event)?; - let transition = get_json(store, &transition_id, ObjectKind::Transition)?; - let receipt = transition["receipt"].as_str().unwrap_or_default(); - let usage = usage_by_receipt.get(receipt).cloned().unwrap_or(Value::Null); - entries.push(json!({ - "module": transition["module"], - "outcome": "installed", - "receipt": receipt, - "target": transition["target"], - "transition": transition_id, - "usage": usage, - })); - } - Ok(entries) -} +::: test +name: show lists attached notes inline and log counts them +given: { workspace: fixture_two_modules, history: [commit, note_attached] } +expect: { show_notes_listed: 1, show_note_text_present: true, log_note_count: 1 } +::: -/// A map from a transition's semantic receipt hash to the validated usage a full -/// receipt reported. Absent telemetry is never fabricated, so only receipts with -/// a real usage block appear here; everything else reads as null. -fn usage_index(store: &Store) -> Result> { - let mut out: BTreeMap = BTreeMap::new(); - for name in store.names()? { - let Ok((kind, payload)) = get_object(store, &name) else { - continue; - }; - if kind != ObjectKind::Receipt { - continue; - } - let Ok(receipt) = parse_receipt(&payload) else { - continue; - }; - let usage = &receipt.reported["usage"]; - if !usage.is_object() { - continue; - } - let normalized = json!({ - "cacheRead": usage.get("cacheRead").cloned().unwrap_or(Value::Null), - "input": usage.get("input").cloned().unwrap_or(Value::Null), - "model": usage.get("model").cloned().unwrap_or(Value::Null), - "output": usage.get("output").cloned().unwrap_or(Value::Null), - }); - out.insert(receipt_semantic_hash(&receipt), normalized); - } - Ok(out) -} -napl-store blob v1 3673 -# commands: init and watch +## Body directives -Two porcelain additions to the `napl` binary, each a thin composition -over the same napl-core derivations and napl-store reads the other -commands use. They live in this module and compile into the same crate as -the core toolchain. The query surface that used to live here belongs to -the `query` module. +:::docs +## Prose, examples, and tests in one file -## napl init +A prompt's body is markdown with directive fences: `:::docs` for the +reference page you are reading now, `:::example` for runnable +commands, `:::note` and `:::warning` for asides, and `::: test` for +pinned behavior the toolchain enforces. Docs never change a module's +identity: editing them re-keys nothing and regenerates nothing. +::: -`napl init` bootstraps a workspace: it creates the records directory -`.napl/` with an empty store (`store/` skeleton, no HEAD, no objects) in -the current directory when no ancestor workspace exists. Running it inside -an existing workspace changes nothing and reports `already_initialized`; -it never creates a nested workspace under another workspace and never -touches an existing store's bytes. The result of init is exactly the -state a fresh `napl compile` expects: head absent, no modules recorded. -Like `git init`, the directory it runs in is the whole story: the -success payload names that directory as the workspace root, there is -nothing to configure, and a prompt written beside `.napl` is already -inside placement. Init accepts no flags beyond the shared help flag; -anything else is a usage error under the command line contract. The -payload also teaches the first mile: a `nextSteps` field carries one -complete minimal prompt, frontmatter fences with `module`, `deps`, and -`targets` plus one body line, the fact that discovery finds prompts by -the `.napl` extension, and the exact command sequence from writing that -prompt through `add` and `commit`, so a cold user never reverse -engineers the grammar one validation error at a time. The sequence -ends with one forward pointer: lint needs a `roots` declaration -before it can judge reachability, named here so its later refusal is -a reminder, never a first encounter. +The prompt body grammar reserves directive blocks, `directiveVersion 1`, +and recognizing them is part of the identity contract: text that older +binaries read as inert prose can be a compile error under this grammar, so +this recognition rides a CONTRACT_VERSION bump, the sanctioned identity +transition. The directives are `test` and the docs family stated below. +For `test`, an opener is a line that is exactly `::: test`; the closer +is a line that is exactly `:::`; nothing nests inside a test block. An +unknown `::: ` opener is a compile error, never prose, so future +directive names are non-breaking. Test blocks are body text: they count +in normalized body lines and attribution ranges may cover them. Docs +fences are the one exception, display content excluded from the +semantic body, and their own section states that law in full. + +Block content is one case in an exact value grammar mapping 1:1 onto +canonical JSON, so two binaries cannot disagree on a value: one mapping +with keys `name` (required, non-empty, single line), `given` (optional, +mapping), `expect` (required, mapping). Block mappings and sequences +indent by exactly two spaces; flow `{}` and `[]` forms are allowed. +Strings are double-quoted with JSON escapes, or plain scalars ended by a +structural character (`:`, `,`, `{`, `}`, `[`, `]`, `#`) or end of line +with no leading or trailing space. Numbers are JSON number syntax; +`true`, `false`, `null` are keywords; every other plain scalar is a +string. Nothing else from YAML exists here: no comments, no duplicate +mapping keys, no anchors, aliases, tags, block scalars, multiline +scalars, multi-document markers, octal or hex, timestamps, or merge keys. +A case needing prose belongs in prose. Diagnostics are compile errors +naming the exact body line, judged in this precedence: unclosed block, +unknown directive name, nested opener, content outside the value grammar, +missing or empty name, missing expect, duplicate case name within the +module across both positions after NFC normalization and whitespace trim. +Frontmatter `tests:` remains legal; a module's case set is the union of +both positions; extraction of cases from body bytes is deterministic — +same bytes, same cases. ::: test -name: init in an empty directory creates the records skeleton with no head -given: { directory: empty } -expect: { created: [".napl/store"], head: absent, exit_zero: true, workspace_root_reported: true } +name: an unclosed test directive block is a compile error naming its opener line +given: { body: directive_without_closer } +expect: { outcome: compile_error, line_named: opener } ::: ::: test -name: init teaches the first mile with a complete minimal prompt and the command sequence -given: { directory: empty } -expect: { next_steps_prompt_passes_check: true, extension_named: ".napl", sequence_named: [write_prompt, add, commit] } +name: "an unknown directive name is a compile error, never prose" +given: { body: unknown_directive_opener } +expect: { outcome: compile_error, treated_as_prose: false } ::: ::: test -name: init where a workspace already exists changes nothing and says so -given: { directory: existing_workspace } -expect: { changed_files: [], outcome: already_initialized, exit_zero: true } +name: a duplicate case name across frontmatter and body errors +given: { frontmatter_case: alpha, body_directive_case: alpha } +expect: { outcome: compile_error, diagnostic: duplicate_case_name } ::: ::: test -name: init never overwrites an existing store -given: { directory: existing_workspace_with_commits } -expect: { store_bytes_unchanged: true } +name: directive case extraction is deterministic +given: { body: fixture_with_two_directives, runs: 2 } +expect: { cases: 2, bytes_equal: true } ::: -## napl watch +### The docs directive -`napl watch` is an I/O shell around the pure compile derivation: it -observes the workspace's `*.napl` files and re-runs the derivation when -their content changes, emitting each result to stdout as canonical JSON. It publishes nothing: no semantic writes, no store -mutation, no locks held between runs. Change detection is by content -hash, never by timestamp, so an editor save that leaves bytes identical -produces no recompile. A malformed prompt is reported as a diagnostic in -the output stream and the watcher keeps running. Stopping the process is -the only way to end it, and stopping it loses nothing. +A prompt section may carry `::: docs` fences. The body of a docs fence +is display prose for human documentation: plain markdown plus the +language's own directive vocabulary, never a renderer's component +syntax, so any consumer that can render markdown and the named +directives can render a prompt, and no prompt ever names a website's +component. + +The docs vocabulary is language-owned and deliberately tiny: `docs` +for prose, `example` for runnable content, `note` and `warning` as +admonitions. An example body is validated: napl source in it must pass +check and CLI invocations in it must parse under the live command +grammar, so a published example cannot rot. An example fence may +carry a `lang` attribute naming what its body is, `lang="napl"` for +prompt source and `lang="cli"` for command invocations; when `lang` +is present it selects the validator, and when it is absent the +validator is chosen by inspection as before, so every existing fence +keeps its meaning. A fenced code block inside the example keeps the +validator its own info string names; the declared `lang` covers only +content that carries no annotation of its own, so a `lang` can never +switch off a validation the block already asked for. A `lang` the +language does not know is refused +naming the line, because a validated vocabulary with an unvalidated +discriminator would let examples rot behind a typo. Directives named `x-` +followed by anything are the extension namespace: carried verbatim, +never validated, a renderer's private experiment and a candidate for +promotion. A renderer meeting an unknown directive renders its body as +a plain block, never an error, so old renderers survive new +vocabulary; check keeps refusing unknown names, so tolerance is a +renderer rule, never a check rule. + +Directive names follow the micromark grammar: alphanumeric plus `-` +and `_`, never ending in `-` or `_`, so `x-tabs` is a name and a bare +`x-` is not. Attributes are HTML-like braces per the same grammar, +the `:::example{name="..."}` spelling: `{a}`, `{a=b}`, `{a="b"}`, and +`{a='b'}` are equivalent, `.class` shorthands accumulate, and a later +`#id` overrides an earlier one. + +Fence lines are unambiguous by shape: an opening line names its +directive, a closing line is bare colons. The parser keeps a fence +stack, and a closing line of N colons closes the innermost open fence +whose opening count is at most N, the same discipline code fences +use. But directive fences DO NOT NEST: a directive fence opened +inside another open directive fence is a compile error naming its +line, because no contract parses children, and a nested fence riding +verbatim serializes as broken directive syntax in every downstream +rendering of the prose. The lesson was paid for: fences are ordered +siblings, prose then example then prose, and order is meaning. A +future contract that parses children lifts this refusal explicitly; +until then refusing at the door beats emitting garbage from it. Code +fences suspend the stack, so a code block may show `:::` lines and +directive syntax freely; directive syntax shown as prose must sit in +a code fence for the same reason. Where micromark lets an unclosed +container run silently to the end of its parent, check refuses it +naming the opening line: deliberate strictness, not incompatibility. + +Where that strictness trips differs by fence kind, because the prose +fences, docs, note, and warning, hold fully functioning markdown and +prose owns headings. A `# ` or `## ` line inside an open example or +test fence still refuses immediately as an unclosed fence, nothing +but directive content belongs there. Inside an open prose fence the +same line is prose, a heading the document renders like any other +markdown, and the unclosed refusal for a prose fence fires where the +fence provably cannot close: at the end of the body, naming the +opening line. The trade is error locality for expressive prose, and +it is the right trade exactly for the fences whose whole purpose is +prose. ::: test -name: watch re-derives compile output when a prompt changes and writes nothing semantic -given: { workspace: fixture_two_modules, edit: touch_leaf_prompt } -expect: { recompiled: true, semantic_state_published: false } +name: a heading inside a prose fence is prose not an unclosed fence +given: { body_with: "a docs fence, a note fence, and a warning fence each holding `## Sub` lines before their closers" } +expect: { compiles: true, heading_lines_inside_prose_bodies: true, semantic_body_excludes_them: true } ::: ::: test -name: watch survives a malformed prompt edit and reports it instead of dying -given: { workspace: fixture_two_modules, edit: break_leaf_frontmatter } -expect: { process_alive: true, diagnostic_reported: true } +name: a heading inside an example or test fence still refuses immediately +given: { body_with: "an example fence holding a `# ` line" } +expect: { compile_error: true, error_names_opener_line: true } ::: -napl-store event v1 158 -{"content":"61e8b6efad6476fce80ae02829837faedd4a8659d47c23e9d69d77eed1c47dbb","parents":["20b2e6829a8ebdfd38ef09505a5a4f978dd5c83f40711ec1cd09bfbd0ecc4372"]} -napl-store blob v1 14306 -//! The `.napl` source model: frontmatter and normalized body. -//! -//! A source file is YAML frontmatter (`module`, `deps`, `targets`, `tests`, and -//! the optional `roots` declaration) followed by a markdown body. The -//! frontmatter grammar is a deliberately small subset: a scalar and a handful of -//! string sequences, in flow or block form, plus a block sequence of mappings -//! for `tests`. Anything else is an error rather than a guess, because a guess -//! would make the dependency graph depend on parser mood. - -use std::collections::BTreeSet; - -use napl_store::store_hash::{object_name, ObjectKind}; -use crate::error::{error, OutcomeCode, Result}; +::: test +name: an unclosed docs fence refuses at the end of the body naming its opener +given: { body_with: "a docs fence never closed, followed by section headings to EOF" } +expect: { compile_error: true, error_names_opener_line: true, later_sections_not_swallowed_silently: true } +::: -/// The file extension discovery looks for. -pub const SOURCE_EXTENSION: &str = "napl"; +::: test +name: the preamble title and description come from its markdown +given: { preamble_body: "# Reading the store\n\nOne verb reads everything.\n" } +expect: { title: "Reading the store", description: "One verb reads everything.", from_attrs: false } +::: -/// Largest source file discovery will accept, in bytes. -pub const MAX_SOURCE_BYTES: usize = 4 * 1024 * 1024; +::: test +name: docs fences accept no attributes +given: { body_with: "a docs fence opened with a title attribute" } +expect: { compile_error: true, error_names_attribute: title } +::: -/// The smallest prompt this reader accepts, complete and valid: both frontmatter -/// fences, the three required keys, and one body line. -/// -/// It lives here, beside the reader that judges it, because two commands teach -/// with it and neither may drift from the grammar: `napl init` puts it in the -/// success payload's next steps, and the missing-fence finding of `napl check` -/// embeds it inline, so the first error a cold user sees carries the whole -/// grammar they were missing. The module name is a placeholder the writer -/// replaces; every other byte is the shape. -/// -/// The frontmatter declares `roots` naming its own module, so the very first -/// workspace is lint-ready: reachability analysis refuses without a roots -/// declaration, and a minimal prompt that omits one hands the cold user a -/// workspace that fails the first lint they run. Declaring it here turns lint's -/// prerequisite into a reminder they have already met rather than a first -/// encounter with a concept nothing introduced. -pub const MINIMAL_PROMPT: &str = "---\nmodule: example\ndeps: []\nroots: [example]\ntargets: [rust]\n---\nOne sentence of specification prose.\n"; +Fences are ordered siblings within their section, and order is +meaning: prose, then a warning, then more prose renders as that +interleaved flow, which is how a caveat lands mid-explanation without +nesting. Adjacent example fences sharing a name and differing by a +variant attribute are one example in several spellings; how a +renderer groups them is the renderer's business. -/// One parsed `.napl` source file. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SourceModule { - /// Declared module name; also the placement directory for built-in targets. - pub module: String, - /// Workspace-relative canonical path of the source file. - pub path: String, - /// Declared direct dependencies, sorted and deduplicated. - pub deps: Vec, - /// Declared targets, sorted and deduplicated. - pub targets: Vec, - /// Declared test names, sorted and deduplicated. - pub tests: Vec, - /// The frontmatter `tests:` cases in declared order, each a canonical JSON - /// object `{name, given, expect}` (an omitted `given`/`expect` is `null`). - /// These have no body position; they join the body directive cases in the - /// module's case set until migrated into `::: test` blocks. - pub frontmatter_cases: Vec, - /// The declared reachability roots, present only where the file declares - /// them. `None` is what makes the unreachable-module analysis refuse. - pub roots: Option>, - /// The body after normalization. - pub normalized_body: String, - /// Blob hash of the normalized body. - pub normalized_source_hash: String, - /// The `::: test` directive cases extracted from the normalized body, in - /// body order. A malformed directive block is a parse error, so a source - /// that parses always carries a well-formed case set. - pub directive_cases: Vec, -} +The first docs fence of a module, when it sits before the module's +first section heading, is the module preamble: its body introduces the +module, and its markdown carries the module's document metadata the +way prose always has. The preamble's first heading, whatever its +level, is the module's display title; the first paragraph after that +heading is its description; any renderer may use both for a page +head, an index card, or a search snippet. There are no `title` or +`description` attributes: metadata spelled outside the prose would be +a second way to say what the markdown already says, and the docs +fence accepts no attributes at all. A module without a preamble +fence, or whose preamble carries no heading, simply has no display +title beyond its name. -/// Normalize a body for hashing: LF line endings, no trailing blanks, one final -/// newline. The normalization is what makes a source hash independent of the -/// editor that last touched the file. -pub fn normalize_body(raw: &str) -> String { - let unified = raw.replace("\r\n", "\n").replace('\r', "\n"); - let mut lines: Vec<&str> = unified - .split('\n') - .map(|line| line.trim_end_matches([' ', '\t'])) - .collect(); - while lines.last().is_some_and(|line| line.is_empty()) { - lines.pop(); - } - if lines.is_empty() { - return String::new(); - } - let mut out = lines.join("\n"); - out.push('\n'); - out -} +Docs fences are display content, not contract. The semantic body of a +prompt, the bytes that inputKey, attribution prompt hashes, and every +freshness derivation consume, is the body with all docs fences +removed, and attribution endpoints name lines of that semantic body, +so inserting or growing a docs fence shifts no endpoint. Editing, +adding, or deleting a docs fence therefore never re-keys a module, +never marks code pending, and never moves an attribution endpoint. A +prompt whose only change is inside docs fences produces the same +inputKey and the same attribution verdicts, and the store records the +new prompt bytes as a new object whose module state is unchanged. -/// Validate a name in any position the parser reads one: the `module` -/// frontmatter value, a `deps` entry, or a declared root. -/// -/// A name is any non-empty single-line scalar. No character class, case, or -/// identifier shape is required, so `todo-app` and a non-ascii name are as legal -/// as `todo_app`. The empty string is the only rejection; a line break cannot -/// reach here through the line-oriented frontmatter grammar, and is refused -/// explicitly so the rule holds for every caller. -pub fn check_module_name(name: &str) -> Result<()> { - if name.is_empty() { - return Err(error( - OutcomeCode::MappingIntegrity, - "module name is empty", - )); - } - if name.contains('\n') || name.contains('\r') { - return Err(error( - OutcomeCode::MappingIntegrity, - format!("module name {name:?} spans more than one line"), - )); - } - Ok(()) -} +::: test +name: "a docs-only edit changes prompt bytes but no key, pending, or endpoint" +given: { edit: inside_docs_fence_only } +expect: { input_key_changed: false, pending: [], endpoints_moved: 0, prompt_bytes_changed: true } +::: -/// Parse one `.napl` file already read into memory. -pub fn parse_source(path: &str, text: &str) -> Result { - let reject = |why: String| error(OutcomeCode::MappingIntegrity, format!("{path}: {why}")); - let unified = text.replace("\r\n", "\n").replace('\r', "\n"); - let rest = unified - .strip_prefix("---\n") - .ok_or_else(|| reject("missing opening frontmatter fence".into()))?; - let fence = find_fence(rest).ok_or_else(|| reject("missing closing frontmatter fence".into()))?; - let front = &rest[..fence.0]; - let body = &rest[fence.1..]; +::: test +name: a docs fence containing a code fence containing three colons parses as one fence +given: { body: docs_fence_with_code_fence_colons } +expect: { docs_payloads: 1 } +::: - let fields = parse_frontmatter(front).map_err(&reject)?; - let module = fields - .module - .ok_or_else(|| reject("frontmatter has no `module:` key".into()))?; - check_module_name(&module)?; +::: test +name: a directive fence inside an open docs fence is a compile error naming its line +given: { body: docs_fence_with_inner_example_same_count } +expect: { compile_error: true, error_names_inner_fence_line: true } +::: - let deps = sorted_unique(fields.deps); - let targets = sorted_unique(fields.targets); - let tests = sorted_unique(fields.tests); - let roots = fields.roots.map(sorted_unique); - if targets.is_empty() { - return Err(reject("frontmatter declares no targets".into())); - } - for dep in &deps { - check_module_name(dep)?; - if *dep == module { - return Err(reject(format!("module {module:?} depends on itself"))); - } - } - if let Some(roots) = &roots { - for root in roots { - check_module_name(root)?; - } - } +::: test +name: a four-colon docs fence wrapping a three-colon example refuses the same way +given: { body: four_colon_docs_wrapping_three_colon_example } +expect: { compile_error: true, error_names_inner_fence_line: true } +::: - let normalized_body = normalize_body(body); - let normalized_source_hash = object_name(ObjectKind::Blob, normalized_body.as_bytes()); - // Directive recognition is part of the identity contract: a malformed block, - // an unknown opener, or a duplicate case name across frontmatter and body is - // a compile error here, naming the exact body line. - let directive_cases = crate::directive::extract_validated(&normalized_body, &tests) - .map_err(|err| error(OutcomeCode::MappingIntegrity, format!("{path}: {}", err.detail)))?; - let frontmatter_cases: Vec = fields - .front_cases - .iter() - .map(|(name, given, expect)| { - serde_json::json!({ - "expect": expect.clone().unwrap_or(serde_json::Value::Null), - "given": given.clone().unwrap_or(serde_json::Value::Null), - "name": name, - }) - }) - .collect(); - Ok(SourceModule { - module, - path: path.to_string(), - deps, - targets, - tests, - frontmatter_cases, - roots, - normalized_body, - normalized_source_hash, - directive_cases, - }) -} +::: test +name: a fence left open at section end is refused naming the opening line +given: { body: docs_fence_without_closer } +expect: { outcome: compile_error, line_named: opener } +::: -/// Rewrite a source file's `deps:` declaration in place. -/// -/// The edit is line-preserving by construction: it replaces the value of the -/// existing `deps:` line and never deletes a line, which is exactly the -/// mapping-safe class `napl lint --fix` is allowed to apply. -pub fn rewrite_deps(text: &str, deps: &[String]) -> Result { - let mut sorted: Vec = deps.to_vec(); - sorted.sort(); - sorted.dedup(); - let rendered = format!("deps: [{}]", sorted.join(", ")); - let mut out: Vec = Vec::new(); - let mut replaced = false; - let mut in_front = false; - let mut fences = 0usize; - for line in text.split('\n') { - if line == "---" { - fences += 1; - in_front = fences == 1; - out.push(line.to_string()); - continue; - } - if in_front && line.starts_with("deps:") { - out.push(rendered.clone()); - replaced = true; - continue; - } - out.push(line.to_string()); - } - if !replaced { - return Err(error( - OutcomeCode::MappingIntegrity, - "source has no `deps:` line to rewrite", - )); - } - Ok(out.join("\n")) -} +::: test +name: an invalid directive name ending in a hyphen is refused naming the line +given: { body: docs_fence_named_x_dash } +expect: { outcome: compile_error } +::: -struct Frontmatter { - module: Option, - deps: Vec, - targets: Vec, - tests: Vec, - roots: Option>, - front_cases: Vec<(String, Option, Option)>, -} +::: test +name: an example lang attribute selects the validator +given: { body: example_lang_napl_with_cli_looking_body } +expect: { validated_as: napl_source } +::: -fn find_fence(rest: &str) -> Option<(usize, usize)> { - let mut offset = 0usize; - for line in rest.split_inclusive('\n') { - let trimmed = line.strip_suffix('\n').unwrap_or(line); - if trimmed == "---" { - return Some((offset, offset + line.len())); - } - offset += line.len(); - } - None -} +::: test +name: an unknown example lang is refused naming the line +given: { body: example_lang_unknown } +expect: { outcome: compile_error } +::: -fn parse_frontmatter(front: &str) -> std::result::Result { - let mut out = Frontmatter { - module: None, - deps: Vec::new(), - targets: Vec::new(), - tests: Vec::new(), - roots: None, - front_cases: Vec::new(), - }; - let mut current: Option<&'static str> = None; - for raw in front.split('\n') { - if raw.trim().is_empty() { - continue; - } - if let Some(item) = raw.strip_prefix(" - ") { - let key = current.ok_or_else(|| format!("stray list item {raw:?}"))?; - // A `tests:` item is a mapping whose `name:` is the identifier; every - // other sequence carries bare scalars. - let value = match item.trim().strip_prefix("name:") { - Some(name) if key == "tests" => parse_item(name.trim())?, - _ => parse_item(item.trim())?, - }; - if key == "tests" { - out.front_cases.push((value.clone(), None, None)); - } - push_field(&mut out, key, value); - continue; - } - if raw.starts_with(' ') { - // A continuation line of a `tests:` mapping item carries its `given` - // and `expect` flow values, read with the directive value grammar. - if current == Some("tests") { - if let Some((key, value)) = raw.trim().split_once(':') { - if let Some(case) = out.front_cases.last_mut() { - let parsed = crate::directive::parse_inline_value(value); - match key.trim() { - "given" => case.1 = parsed, - "expect" => case.2 = parsed, - _ => {} - } - } - } - continue; - } - return Err(format!("unexpected indentation in {raw:?}")); - } - let (key, value) = raw - .split_once(':') - .ok_or_else(|| format!("frontmatter line {raw:?} is not `key: value`"))?; - let value = value.trim(); - match key { - "module" => { - if out.module.is_some() { - return Err("duplicate `module:` key".into()); - } - // The key rides the diagnostic so a reader can be pointed at the - // physical line it sits on rather than at the file's first line. - out.module = Some( - parse_item(value) - .map_err(|why| format!("the `module:` value is not a name: {why}"))?, - ); - current = None; - } - "deps" | "targets" | "tests" | "roots" => { - let field: &'static str = match key { - "deps" => "deps", - "targets" => "targets", - "roots" => "roots", - _ => "tests", - }; - if field == "roots" && out.roots.is_none() { - out.roots = Some(Vec::new()); - } - if value.is_empty() { - current = Some(field); - } else { - for item in parse_flow_sequence(value)? { - push_field(&mut out, field, item); - } - current = None; - } - } - other => return Err(format!("unknown frontmatter key {other:?}")), - } - } - Ok(out) -} +::: test +name: a declared lang never switches off a validation a block asked for +given: { body: example_lang_cli_containing_annotated_napl_block } +expect: { napl_block_still_check_validated: true, unannotated_content_validated_as: cli } +::: -fn push_field(out: &mut Frontmatter, key: &str, value: String) { - match key { - "deps" => out.deps.push(value), - "targets" => out.targets.push(value), - "roots" => out.roots.get_or_insert_with(Vec::new).push(value), - _ => out.tests.push(value), - } -} +::: test +name: a napl code block inside a docs fence that fails check is refused naming the block line +given: { body: docs_fence_with_broken_napl_example } +expect: { outcome: compile_error, line_named: example_block } +::: -fn parse_flow_sequence(value: &str) -> std::result::Result, String> { - let inner = value - .strip_prefix('[') - .and_then(|v| v.strip_suffix(']')) - .ok_or_else(|| format!("expected a flow sequence, found {value:?}"))?; - if inner.trim().is_empty() { - return Ok(Vec::new()); - } - inner.split(',').map(|item| parse_item(item.trim())).collect() -} +::: test +name: a CLI line inside an example fence must parse under the live command grammar +given: { body: docs_example_with_ghost_cli_flag } +expect: { outcome: compile_error } +::: -fn parse_item(item: &str) -> std::result::Result { - if item.is_empty() { - return Err("empty scalar".into()); - } - let quoted = item.len() >= 2 - && ((item.starts_with('"') && item.ends_with('"')) - || (item.starts_with('\'') && item.ends_with('\''))); - let unquoted = if quoted { &item[1..item.len() - 1] } else { item }; - if unquoted.is_empty() { - return Err("empty scalar".into()); - } - Ok(unquoted.to_string()) -} +## Recorded surfaces -fn sorted_unique(values: Vec) -> Vec { - let set: BTreeSet = values.into_iter().collect(); - set.into_iter().collect() -} -napl-store blob v1 3827 -use std::path::{Path, PathBuf}; +At commit, extract the candidate's public API with tree-sitter (pinned +grammars) and record it as a `surface` object; dependents' inputKeys pin +recorded surfaceHashes, store facts, never demand scans. The surface +grammar makes extraction sound by construction and violations reject as +`mapping-integrity`: for Rust, public items must be named `pub` +declarations at statically visible paths, no glob re-exports, no +macro-generated `pub` items; a named `pub use` is allowed and is recorded +as the unresolved written binding, never a resolved signature. For +TypeScript: explicit named exports only, no `export *`. Extraction is +syntactic, deterministic, and loud on malformed input. Loud means a +payload, never silence: extraction over bytes that do not parse +answers a parse failure naming the file and the first error's line, +distinct from a genuinely empty surface, in every seat that extracts, +the commit recorder, the `surface` read, and the query layer, so an +empty item list always means parsed and nothing public. Re-extraction from +committed output must reproduce the recorded surface byte-identically. -use serde_json::Value; +The `surface` read is honest about absence the same way: a module with +no committed state under the target answers `tracked: false` naming +the absent baseline, never an empty item list that reads as a real +surface with nothing public. -use crate::canon::canonical_bytes; -use crate::error::{error, error_payload, outcome_exit_code, CompilerError, OutcomeCode, Result}; -use crate::workspace::require_workspace_root; +::: test +name: extraction over unparseable bytes answers parse-failed never an empty surface +given: { source_bytes: syntactically_invalid_rust, extraction: rust_surface } +expect: { outcome: parse_failed, error_names_line: true, served_as_empty_surface: false } +::: -#[derive(Clone, Copy)] -pub struct CommandSpec { - pub name: &'static str, - pub run: fn(&Path, &[String]) -> Result, -} +::: test +name: surface for an uncommitted module says untracked never an empty list +given: { workspace: fixture_two_modules, module: pending_never_committed, read: surface_for_target } +expect: { tracked: false, reason_names_baseline: true, empty_item_list_served: false } +::: -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CliResult { - pub stdout: Vec, - pub exit_code: i32, -} +::: test +name: a candidate with a glob re-export is rejected as mapping-integrity +given: { workspace: fixture_two_modules, candidate: contains_glob_reexport } +expect: { outcome: mapping_integrity, published_semantic_state: false } +::: -pub fn run_cli(cwd: &Path, args: &[String]) -> CliResult { - match dispatch(cwd, args) { - Ok(result) => result, - Err(err) => CliResult { - stdout: canonical_bytes(&error_payload(&err, false, false)), - exit_code: outcome_exit_code(err.code), - }, - } -} +::: test +name: a named pub use is recorded in the surface as the written binding +given: { workspace: fixture_two_modules, candidate: valid_with_named_reexport } +expect: { surface_contains_written_binding: true, surface_contains_resolved_signature: false } +::: -fn dispatch(cwd: &Path, args: &[String]) -> Result { - let command = positional(args, 0).map_err(|_| usage("no command given"))?; - for spec in crate::registry() { - if spec.name == command { - return (spec.run)(cwd, args); - } - } - Err(usage(&format!("unknown command {command:?}"))) -} +::: test +name: recorded surface re-extraction from committed output is byte-identical +given: { workspace: fixture_two_modules, candidate: valid_for_consumer } +expect: { reextraction_equal: true } +::: -pub(crate) fn ok(value: &Value) -> Result { - Ok(CliResult { - stdout: canonical_bytes(value), - exit_code: 0, - }) -} +## add, then commit -pub(crate) fn usage(detail: &str) -> CompilerError { - let mut names: Vec<&str> = crate::registry().iter().map(|spec| spec.name).collect(); - names.sort(); - names.dedup(); - error( - OutcomeCode::InvalidReceipt, - format!("{detail}; usage: napl <{}> ...", names.join("|")), - ) -} +:::docs +## One guarded door -pub(crate) fn resolve_workspace(cwd: &Path, args: &[String]) -> Result { - match flag(args, "--workspace")? { - Some(explicit) => Ok(PathBuf::from(explicit)), - None => require_workspace_root(cwd), - } -} +Every change lands the same way: `napl add` stages what you wrote, +`napl commit` verifies and installs it. There is no second path. When +something is wrong, the door refuses with a reason and the exact next +step, and nothing on disk changes. +::: -fn is_flag(arg: &str) -> bool { - arg.starts_with("--") -} +:::example{lang="cli" name="the daily loop"} +napl status +napl add crates/greeting/greeting.napl +napl commit +::: -fn is_switch(arg: &str) -> bool { - matches!(arg, "--fix" | "--apply" | "--no-verify") -} +:::note +A refusal is never a crash: exit codes are stable, the payload is +JSON, and the message tells you which verb heals it. +::: -pub(crate) fn positional(args: &[String], index: usize) -> Result { - positionals_from(args, 0) - .get(index) - .cloned() - .ok_or_else(|| usage(&format!("missing positional argument {index}"))) -} +Git parity is the mental model: `add` stages, `commit` installs. The +receipt is an internal record between them, never a user-facing noun. +`napl commit` with no arguments installs every staged record in +dependency-wave order, rebasing where the decision table allows, one +outcome reported per record; `--staged ` installs one. A +multi-module add followed by a bare commit is therefore one command +each, with the sequencing and retry the table already defines owned by +the tool, never hand-looped; and when an earlier record in the same +invocation moves the head so that a later one returns write-conflict +or stale-input against it, bare commit re-derives that record against +the new head, exactly what re-running add would produce, and retries +it once, so modules sharing one crate land in one invocation instead +of hand-ordered pairs. A retry that fails again returns its outcome +honestly. A bare commit that finds nothing staged says so instead of +dressing emptiness as success: the payload names that zero records +were staged, and when any module carries working-tree drift it names +each drifted module beside the exact `napl add` invocation that +would stage it, so the empty commit is the teacher, never the trap. +Commit sweeps the staged record it installed, so a later +bare commit never re-attempts history; `napl gc` reports staged +records among the unreachable, and `napl gc --apply` sweeps them: +they are disposable litter by the git-state law, re-creatable by +re-running add, and no one should ever hand-edit `.napl` to be rid of +them. -pub(crate) fn positionals_from(args: &[String], skip: usize) -> Vec { - let mut out: Vec = Vec::new(); - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - if is_flag(arg) { - if !is_switch(arg) && !arg.contains('=') { - iter.next(); - } - continue; - } - out.push(arg.clone()); - } - out.into_iter().skip(skip).collect() -} +`napl add` stages a module state from any of three sources, and the +working tree is the default one, exactly as in git: -pub(crate) fn has_switch(args: &[String], name: &str) -> bool { - args.iter().any(|arg| arg == name) -} +- `napl add [...]` names working-tree files. Each path + resolves to its owning module through the owned globs (unique by + commit invariant, so no `--module` is needed), and the staged state + is that module's committed output with exactly the named files + overlaid from the working tree. Paths owned by different modules + stage one record per module in one invocation. A path no module owns + is a usage error naming the owned globs that do exist. +- `napl add --module ` stages the module's whole owned tree as + it sits in the working tree. +- `napl add --module ` stages explicit candidate + bytes, for the rare case where the bytes to stage are not the + working tree. -pub(crate) fn flag(args: &[String], name: &str) -> Result> { - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - if arg == name { - return iter - .next() - .cloned() - .map(Some) - .ok_or_else(|| usage(&format!("{name} needs a value"))); - } - if let Some(value) = arg.strip_prefix(&format!("{name}=")) { - return Ok(Some(value.to_string())); - } - } - Ok(None) -} +A module's own prompt path is the one path add accepts that no owned +glob names, and it is accepted for exactly one purpose: recording a +docs edit. The committed prompt is the whole prompt file, frontmatter +included, so the judgment is one pure comparison of two files with +three outcomes. When the files differ only inside docs fences, so the +frontmatter and the semantic body are both byte-identical, +`napl add ` stages a docs record: the new prompt bytes +and nothing else, no candidate tree, no attribution work, no usage. +When the semantic body moved, the invocation refuses and names the +first semantic line that moved, teaching `napl add --module` as the +path for real changes, because a docs record that smuggled a semantic +edit would be an unreviewed module change. When the frontmatter +moved, the invocation refuses naming the frontmatter line, under the +same teaching, because frontmatter feeds the identities the module is +keyed on and is never prose. The docs record names the base state it +was drafted against, and commit fails it on mismatch rather than +recalculating, the same base-naming rule every state transition obeys. +Commit installs it by writing the new prompt object and re-pointing +the module's prompt at it while every derived fact holds: same +inputKey, same attribution map, same endpoints, same pending state, +and the transition says `docs-only` so history never confuses prose +with semantics. The two doors supersede each other rather than stack: +a module record already carries the working-tree prompt, so staging a +docs record beside it is refused as already-carried, staging a module +record sweeps a staged docs record for the same module, and a second +docs add replaces the first, so at most one record can ever speak for +a module's prompt and no ordering of ordinary commands can wedge the +staging area. `napl status` reports docs drift per module by the same +judgment, silent for work a staged record already carries, so status +and the empty-commit teacher can never disagree. The teacher includes +docs drift: a bare commit finding only docs-drifted prompts names +each one, keyed by prompt path, beside the `napl add ` +that would record it. -pub(crate) fn repeated_flag(args: &[String], name: &str) -> Vec { - let mut out: Vec = Vec::new(); - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - if arg == name { - if let Some(value) = iter.next() { - out.push(value.clone()); - } - } else if let Some(value) = arg.strip_prefix(&format!("{name}=")) { - out.push(value.to_string()); - } - } - out -} +::: test +name: a docs-only prompt edit stages and commits as a docs record leaving module state untouched +given: { edit: inside_docs_fence_only, then: [add_prompt_path, commit] } +expect: { transition: docs_only, input_key_changed: false, endpoints_moved: 0, pending: [], prompt_object_updated: true } +::: -pub(crate) fn require_flag(args: &[String], name: &str) -> Result { - flag(args, name)?.ok_or_else(|| usage(&format!("{name} is required"))) -} -napl-store blob v1 952 -module: query -target: rust -stale: false -entries: - - promptLines: - - 1 - - 10 - file: crates/napl/src/query.rs - lines: - - 1 - - 255 - note: "the query module owns napl q and napl prompt and the canonical document they share" - - promptLines: - - 11 - - 48 - file: crates/napl/src/document.rs - lines: - - 1 - - 320 - note: "the canonical document docVersion 1 built purely from committed records, totally ordered, no absolute paths" - - promptLines: - - 49 - - 77 - file: crates/napl/src/query.rs - lines: - - 1 - - 255 - note: "napl q shaped exactly like jq via the embedded jaq engine, impure builtins refused, 64 MiB output ceiling" - - promptLines: - - 78 - - 109 - file: crates/napl/src/promptbuild.rs - lines: - - 1 - - 430 - note: "napl prompt reads typed refs and materializes prose and code from committed blobs at the refs snapshot, with law closure" -napl-store blob v1 3489 -module: "napl_store" -target: "rust" -stale: false -entries: - - promptLines: - - 10 - - 402 - file: "crates/napl-store/src/store_hash.rs" - lines: - - 1 - - 720 - note: "store_hash section, the typed versioned length-prefixed envelope, object naming, canonical JSON, and the read verifier with its inline tests" - - promptLines: - - 403 - - 1539 - file: "crates/napl-store/src/store_objects/mod.rs" - lines: - - 1 - - 697 - note: "store_objects section, strict serde types for module-state, event, and snapshot, the enums, provenance, price table, and validation" - - promptLines: - - 1540 - - 2286 - file: "crates/napl-store/src/store_objects/tests.rs" - lines: - - 1 - - 1846 - note: "store_objects fixtures the tests pin and test and code discipline, the pinned envelopes and provenance case discipline" - - promptLines: - - 2287 - - 2976 - file: "crates/napl-store/src/store_blocks/mod.rs" - lines: - - 1 - - 558 - note: "store_blocks section, the block-index object, two-level segmentation into regions atoms and sub-spans, the sentence splitter, and reconstruction" - - promptLines: - - 2328 - - 2330 - file: "crates/napl-store/src/hash.rs" - lines: - - 5 - - 10 - note: "the hash module exposes content_hash, the bare lowercase hexadecimal SHA-256 content digest over the UTF-8 bytes" - - promptLines: - - 2977 - - 3376 - file: "crates/napl-store/src/store_blocks/tests.rs" - lines: - - 1 - - 1173 - note: "store_blocks fixtures the tests pin and test and code discipline" - - promptLines: - - 3377 - - 4446 - file: "crates/napl-store/src/store_tree.rs" - lines: - - 1 - - 1471 - note: "store_tree section, git-style Merkle directory trees, entry kinds and mode constants, tree building and diffing, with its inline tests" - - promptLines: - - 4447 - - 5383 - file: "crates/napl-store/src/store_reduce/mod.rs" - lines: - - 1 - - 833 - note: "store_reduce section, the typed query layer over the content-addressed store and the object provider" - - promptLines: - - 5384 - - 5694 - file: "crates/napl-store/src/store_reduce/tests.rs" - lines: - - 1 - - 1111 - note: "store_reduce fixtures the tests pin and test and code discipline" - - promptLines: - - 5695 - - 6594 - file: "crates/napl-store/src/store_txn_core.rs" - lines: - - 1 - - 1114 - note: "store_txn_core section, markers locks recovery and install planning as a pure decision core, with its inline tests" - - promptLines: - - 6595 - - 7461 - file: "crates/napl-store/src/store_io/mod.rs" - lines: - - 1 - - 1458 - note: "store_io section, the on-disk object store, publication, HEAD, markers, and recovery" - - promptLines: - - 7462 - - 7922 - file: "crates/napl-store/src/store_io/tests.rs" - lines: - - 1 - - 1131 - note: "store_io fixtures the tests pin and test and code discipline" - - promptLines: - - 7923 - - 8481 - file: "crates/napl-store/src/store_diff/mod.rs" - lines: - - 1 - - 724 - note: "store_diff section, hierarchical prompt alignment, attribution ownership resolution, and the LLM bundle assembly and rendering" - - promptLines: - - 8482 - - 8847 - file: "crates/napl-store/src/store_diff/tests.rs" - lines: - - 1 - - 968 - note: "store_diff fixtures the tests pin and test and code discipline" -napl-store blob v1 1007 -module: "commands" -target: "rust" -stale: false -entries: - - promptLines: - - 1 - - 8 - file: "crates/napl/src/init.rs" - lines: - - 1 - - 129 - note: "two porcelain additions each a thin composition over the shared derivations, the command table contributing init and watch" - - promptLines: - - 9 - - 53 - file: "crates/napl/src/init.rs" - lines: - - 1 - - 129 - note: "napl init bootstraps the store skeleton or reports already_initialized, names the directory it ran in as the workspace root, declares no flags beyond the shared help flag, and teaches the first mile through nextSteps: one complete minimal prompt that itself passes check, the .napl extension discovery finds prompts by, and the exact sequence from writing that prompt through add and commit" - - promptLines: - - 54 - - 75 - file: "crates/napl/src/watch.rs" - lines: - - 1 - - 127 - note: "napl watch an io shell re-running compile on content change publishing nothing" -napl-store module-state v1 634 -{"attribution":{"promptHash":"20b515293b4ef553a1a9a8050fe899858420ea8314a0a8fa403de17b79788eef","record":"d90c5f37984e18050b3787d79d1a962170be74f757f224d6b04b4437c53b0867","stale":false},"contractVersion":4,"inputKey":"d591e1cef593c066561ee6630c74f3fa8f29f9a4e5250a4aadaae4ca1a2fde50","module":"napl","outputTree":"2a345a96a9b632ddfb78cf17b496cb7d6fe2e226a0c3d1ad129a9a3c20820ac9","prompt":"427bf8f1bb7ac29049e57dee9221077958aab3a27bcea85a51d6044373140447","sliceHash":"947b3023a3aba17d2c39c2a305c288939285cd644f0890537c325883ea1c7800","surfaceHash":"f39ead3f6661b23c1064f1c5bade9ae4df82bfca5cab5cfe393bae23b8b7be11","target":"rust"} -napl-store blob v1 1152 -use std::env; -use std::path::PathBuf; -use std::process::exit; +::: test +name: add of a prompt path with semantic drift refuses naming the first semantic line +given: { edit: outside_docs_fence, then: [add_prompt_path] } +expect: { outcome: refused, line_named: first_semantic_change, teaches: add_module } +::: -fn main() { - let argv: Vec = env::args().collect(); - let invoked = argv - .first() - .map(PathBuf::from) - .and_then(|path| { - path.file_name() - .map(|name| name.to_string_lossy().to_string()) - }) - .unwrap_or_default(); - let mut rest: Vec = argv.iter().skip(1).cloned().collect(); - let agent_alias = invoked == "napl-agent"; - let verb = match rest.first().cloned() { - Some(first) if first == "agent" || first == "serve" => { - rest.remove(0); - first - } - _ if agent_alias => "agent".to_string(), - _ => String::new(), - }; - let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let code = match verb.as_str() { - "agent" => napl_usage_proxy::agent::run(&cwd, &rest), - "serve" => napl_usage_proxy::proxy::serve(&rest), - _ => { - eprintln!("usage: napl agent [--upstream ] -- [args]"); - eprintln!(" napl agent --stop"); - 2 - } - }; - exit(code); -} -napl-store blob v1 8628 -//! `napl check`: dry validation of an on-disk prompt file before `add`. -//! -//! It reuses the reader `add` uses, so passing here means the prompt parses -//! there: frontmatter shape and the module-name rules of the napl contract, body -//! normalization, the `::: test` directive grammar including canonical JSON in -//! `given` and `expect`, and case-name uniqueness across frontmatter and body. -//! Findings are canonical JSON on stdout, each naming the file, the line, and -//! what failed. The store is never opened, nothing stages, and no clock or -//! randomness is read, so the same bytes always produce the same findings. -//! -//! Passing says the prompt parses. It says nothing about outcomes that depend on -//! a candidate, an attribution map, or placement, and its wording never implies -//! otherwise. -//! -//! It teaches while it refuses. The finding for a missing opening frontmatter -//! fence embeds a complete minimal valid prompt inline, so the first error a cold -//! user sees contains the whole grammar they were missing, and a checked file -//! whose name lacks the `.napl` extension is told that discovery will never find -//! it, naming the extension, whatever else is valid about its content. +::: test +name: a bare commit names docs-drifted prompts beside the add that records them +given: { edit: inside_docs_fence_only, then: [commit] } +expect: { installed: 0, docs_drifted_named: true, teaches: add_prompt_path } +::: -use std::io::Write as _; -use std::path::{Path, PathBuf}; +::: test +name: a frontmatter edit refuses the docs door naming the frontmatter line +given: { edit: frontmatter_only, then: [add_prompt_path] } +expect: { outcome: refused, frontmatter_line_named: true, teaches: add_module } +::: -use serde_json::{json, Value}; +::: test +name: a docs record names its base and commit fails it on mismatch +given: { staged: docs_record, then: [module_state_moves, commit] } +expect: { outcome: write_conflict, semantic_state_published: false } +::: -use crate::canon::canonical_bytes; -use crate::cli::{positional, resolve_workspace, CliResult}; -use crate::error::Result; -use crate::source::{parse_source, MINIMAL_PROMPT, SOURCE_EXTENSION}; +::: test +name: a second docs add replaces the first so older prose can never win +given: { then: [add_prompt_path, edit_docs_again, add_prompt_path, commit] } +expect: { records_installed: 1, prompt_object: latest_bytes } +::: -/// The findings schema version. Bumped only when the shape changes. -pub const CHECK_VERSION: u32 = 1; +::: test +name: a module add supersedes a staged docs record and a docs add refuses beside a staged module record +given: { then: [add_prompt_path, add_module] } +expect: { docs_record_swept: true, reverse_order_refused: already_carried, commit_never_wedges: true } +::: -pub(crate) fn run_check(cwd: &Path, args: &[String]) -> Result { - let given = match positional(args, 1) { - Ok(path) => path, - Err(_) => return Ok(usage("napl check needs the path of a prompt file")), - }; - let path = PathBuf::from(&given); - let absolute = if path.is_absolute() { - path - } else { - cwd.join(&path) - }; - let text = match std::fs::read_to_string(&absolute) { - Ok(text) => text, - Err(err) => return Ok(usage(&format!("{given}: {err}"))), - }; +::: test +name: status reports docs drift by the same judgment and stays silent for staged work +given: { edit: inside_docs_fence_only } +expect: { status_docs_drift: true, after_add: { status_docs_drift: false } } +::: - let file = displayed_path(cwd, args, &absolute, &given); - let mut findings: Vec = match parse_source(&file, &text) { - Ok(_) => Vec::new(), - Err(err) => vec![finding(&file, &text, &err.detail)], - }; - // A name finding is independent of content: a perfectly valid prompt under - // the wrong extension is still a file discovery will never open, and saying - // nothing about it would let a user chase a module that cannot exist. - if let Some(naming) = extension_finding(&file, &absolute) { - findings.push(naming); - } - let exit_code = if findings.is_empty() { 0 } else { 1 }; - let payload = json!({ - "checkVersion": CHECK_VERSION, - "contractVersion": crate::version::CONTRACT_VERSION, - "file": file, - "findings": findings, - "valid": findings.is_empty(), - }); - Ok(CliResult { - stdout: canonical_bytes(&payload), - exit_code, - }) -} +All three forms feed one pipeline with `[--attribution ] +[--usage ]`: ingest the staged bytes into immutable content +addressed blobs (reject links, devices, duplicate normalized paths, +absolute or parent-escaping paths, oversized entries or totals; all +later steps read ingested blobs, never the source files); parse every +staged file the target claims as source with the target's pinned +tree-sitter grammar, and refuse as `invalid-candidate` when any parse +tree carries an error node, naming the file, the first error's line, +and the malformed region, because a surface extracted from a broken +tree is not a fact worth recording; the gate is syntax only, judged by +the same grammar the surface extractor uses, and everything past +syntax, types, borrows, semantics, stays the acceptance battery's job; +commit re-runs the same gate when it recomputes the staged record, so +bytes that do not parse can never install through any door; compute +the slice and validate against it; resolve attribution as below; validate +and attach usage evidence when supplied; then write the staged record — +the internal receipt: identities generated against, base snapshot +hashes, candidate tree hash, output manifest, attribution, optional +reported fields. Validation failures surface at add time with the same +outcome classes commit uses. Staged records are transient coordination +state, disposable litter by the git-state law: never promoted to +history, safely deletable, re-creatable by re-running add. -/// The path a finding names: workspace-relative where a workspace contains the -/// file, so no absolute path reaches the output, and the path as given -/// otherwise. -fn displayed_path(cwd: &Path, args: &[String], absolute: &Path, given: &str) -> String { - if let Ok(workspace) = resolve_workspace(cwd, args) { - if let Ok(relative) = absolute.strip_prefix(&workspace) { - return relative.to_string_lossy().replace('\\', "/"); - } - } - given.to_string() -} +Attribution transports instead of being demanded. Add computes the +byte diff between the module's committed output and the staged bytes, +and the prompt's committed body and its pending body, and transports +every committed attribution claim through those diffs +deterministically: a range the diff proves untouched survives +verbatim, a range below an insertion or deletion shifts by its size, +and a claim landing in a rewritten or ambiguous region does not +transport, because the transport never guesses; a range it cannot +prove through the diff is asked for, never invented. When every claim +transports, add stages with the transported map and no `--attribution` +flag is needed: the paperwork updates itself as a consequence of the +diff already being content-addressed on both sides. When any claim +fails to transport, add refuses as `invalid-candidate` listing exactly +the claims that need affirmation, each beside the observed bound or +region it was checked against, and `--attribution` supplies +affirmations for the listed claims only, layered over the transported +remainder. Any attribution refusal, transport or explicit, names the +observed bound (the actual line count or surviving range) next to the +rejected claim, so a wrong range never costs a manual count. Each +listed claim also carries its index in the recorded map and its note +verbatim, so an affirmation is written against named prose, never +positional trust or a separate query join. -/// One finding: the file, the file line it sits on, and what failed. The reader -/// reports the highest precedence problem it found, so a prompt with several -/// faults names the one that stops `add` first. -/// -/// A refusal is also the best teaching moment there is, so one finding carries -/// more than a complaint: a file with no opening frontmatter fence is almost -/// always a file whose author has not seen the grammar at all, and the finding -/// embeds a complete minimal valid prompt inline. It is the reader's own -/// [`MINIMAL_PROMPT`], so the example passes this very command by construction and -/// cannot drift from what the reader accepts. -fn finding(file: &str, text: &str, detail: &str) -> Value { - let stripped = detail - .strip_prefix(&format!("{file}: ")) - .unwrap_or(detail) - .to_string(); - let mut body = serde_json::Map::new(); - body.insert("detail".into(), json!(stripped)); - body.insert("file".into(), json!(file)); - body.insert("line".into(), json!(locate(text, &stripped))); - if stripped.contains("missing opening frontmatter fence") { - body.insert("example".into(), json!(MINIMAL_PROMPT)); - body.insert( - "teaching".into(), - json!( - "a prompt opens with a `---` fence, carries `module`, `deps`, and `targets`, \ - closes the fence with `---`, and then has its body. The example field is a \ - complete minimal prompt that passes napl check as written." - ), - ); - } - Value::Object(body) -} +::: test +name: a transport refusal lists each claim with its map index and note +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } +expect: { listed_claims: 1, claim_index_present: true, claim_note_verbatim: true } +::: -/// The finding a file earns for its name rather than its content: without the -/// `.napl` extension, discovery never walks to it, so no module it declares will -/// ever exist however valid the bytes are. -fn extension_finding(file: &str, absolute: &Path) -> Option { - let extension = absolute.extension().and_then(|e| e.to_str()); - if extension == Some(SOURCE_EXTENSION) { - return None; - } - Some(json!({ - "detail": format!( - "the file name does not end in .{SOURCE_EXTENSION}, so module discovery will never find it: \ - discovery walks the workspace for files with the .{SOURCE_EXTENSION} extension, and nothing \ - else registers a prompt, whatever else is valid about this file's content" - ), - "extension": format!(".{SOURCE_EXTENSION}"), - "file": file, - "line": 1, - })) -} +A module's first add has no committed map to transport, and it demands +no hand-authored one either: absent `--attribution`, add stages the +trivial whole map, every staged file claimed in full by the whole +prompt body, recorded as whole-file claims and honestly coarse. The +cold path to a verified first commit is therefore write the prompt, +write the code, `add --module`, `commit`, with attribution present and +fresh, never `absent`, and `--no-verify` never required to get started. +A claim, in any map, may be spelled whole: naming a file with no +numbers claims all of it, and naming a prompt range as whole claims +the whole body; bounds are derived at validation time from the staged +blob and the pending body, so no line count is ever transcribed by +hand and a whole-file claim survives any growth of its file. -/// The file line a diagnostic names. A directive diagnostic names a -/// body-normalized line, which the frontmatter offset turns back into a file -/// line; a frontmatter diagnostic quotes the offending line, which is searched -/// for verbatim. A diagnostic that names neither points at the file's first -/// line, which is the only honest answer available. -fn locate(text: &str, detail: &str) -> u32 { - if let Some(body_line) = number_after(detail, "body line ") { - return body_offset(text) + body_line; - } - if let Some(snippet) = first_quoted(detail) { - if let Some(line) = line_of(text, &snippet) { - return line; - } - } - 1 -} +Two more spellings close the remaining gaps: `napl add +--module ` stages a path under a module explicitly, for the file +that is new in a crate several modules share, where neither the +committed manifest nor the globs can disambiguate it alone; and +`napl add --check` with any form runs the entire pipeline, transport +verdict and refusal listing included, and writes nothing, not even a +staged record, the dry run that answers before anything lands. The +check verdict says exactly what it judged and what it did not: the +payload enumerates the validations that ran (placement, ingest rules, +the parse gate, attribution transport, evidence) and names what stays +unjudged, everything past syntax, the acceptance battery's job, so +`would-stage` means the record is sound and the syntax parses, never +that the code is correct. A transport refusal +reports each endpoint's verdict separately: a claim whose prompt side +failed but whose code side transported serves the transported code +range beside the failed prompt range, and the affirmation supplies +only the side that actually needs a human, never both because one +refused. An unparseable or wrong-shaped `--attribution` file is +answered like a bad ref payload: the refusal carries the complete map +contract, every field and one complete valid example, never a bare +"not parseable". The map reader speaks the same value grammar as body +directives, so block and flow spellings of one map are one map: +`lines: [4, 9]` and its two-line block form parse identically. A map +that parses but yields no entries, or that fails at any construct, is +refused naming the exact line and the construct that stopped the +reader, beside the full contract, never a bare count of zero. The +contract is also taught before any failure: `help add` and +`add --help` serve the same complete map contract and worked example +the refusal carries, so the map is learnable from help instead of +from trial and error. -/// The count of file lines the frontmatter occupies, including both fences, so -/// body line 1 sits at `offset + 1`. -fn body_offset(text: &str) -> u32 { - let unified = text.replace("\r\n", "\n").replace('\r', "\n"); - let mut fences = 0usize; - for (index, line) in unified.split('\n').enumerate() { - if line == "---" { - fences += 1; - if fences == 2 { - return (index + 1) as u32; - } - } - } - 0 -} +::: test +name: flow and block spellings of one attribution map parse identically +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, maps: [flow_sequences, block_sequences] } +expect: { parsed_entries_equal: true, both_stage: true } +::: -fn number_after(detail: &str, marker: &str) -> Option { - let rest = detail.split_once(marker)?.1; - let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); - digits.parse().ok() -} +::: test +name: a map failing mid-parse names the line and construct beside the contract +given: { workspace: fixture_two_modules, attribution_file: bad_construct_on_line_3 } +expect: { error_names_line: 3, error_names_construct: true, error_carries_valid_example: true } +::: -/// The content of the first double-quoted run in a diagnostic, which is how the -/// reader quotes an offending source line. -fn first_quoted(detail: &str) -> Option { - let open = detail.find('"')?; - let rest = &detail[open + 1..]; - let close = rest.find('"')?; - let inner = &rest[..close]; - if inner.is_empty() { - return None; - } - Some(inner.to_string()) -} +::: test +name: add help carries the complete attribution map contract and example +given: { workspace: fixture_two_modules, invocation: "help add" } +expect: { map_fields_listed: true, map_example_present: true, exit_code: 0, store_bytes_unchanged: true } +::: -fn line_of(text: &str, snippet: &str) -> Option { - let unified = text.replace("\r\n", "\n").replace('\r', "\n"); - unified - .split('\n') - .position(|line| line.contains(snippet)) - .map(|index| (index + 1) as u32) -} +::: test +name: a first add with no flag stages the whole map and commits verified +given: { workspace: fresh_init_root_prompt, candidate: valid_root_level, attribution_flag: absent } +expect: { add_outcome: staged, map_kind: whole_file_claims, commit_outcome: success, attribution: fresh, no_verify_used: false } +::: -/// A usage refusal: canonical JSON on stderr and the pinned exit code 2. An -/// unreadable path is usage, not a finding, because there is no prompt to judge. -fn usage(detail: &str) -> CliResult { - let payload = json!({ - "checkVersion": CHECK_VERSION, - "contractVersion": crate::version::CONTRACT_VERSION, - "detail": detail, - "outcome": "check-usage", - }); - let bytes = canonical_bytes(&payload); - let stderr = std::io::stderr(); - let mut lock = stderr.lock(); - let _ = lock.write_all(&bytes); - let _ = lock.flush(); - CliResult { - stdout: Vec::new(), - exit_code: 2, - } -} -napl-store blob v1 7361 -# commands: init and watch +::: test +name: a whole spelling claims the entire file and survives file growth +given: { workspace: fixture_two_modules, map_entry: whole_file_no_numbers, then: file_grows } +expect: { claim_valid: true, bounds_derived: true, retranscription_needed: false } +::: -Two porcelain additions to the `napl` binary, each a thin composition -over the same napl-core derivations and napl-store reads the other -commands use. They live in this module and compile into the same crate as -the core toolchain. The query surface that used to live here belongs to -the `query` module. +::: test +name: add check reports the full verdict and writes nothing +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_check } +expect: { verdict_listed_claims: 1, staged_records: 0, staging_dir_untouched: true, store_bytes_unchanged: true } +::: -## napl init +::: test +name: an explicit module stages a new path in a shared crate +given: { workspace: fixture_shared_crate_two_modules, working_tree: new_uncommitted_file_in_shared_glob, add_path_with_module: true } +expect: { add_outcome: staged, module_as_named: true } +::: -`napl init` bootstraps a workspace: it creates the records directory -`.napl/` with an empty store (`store/` skeleton, no HEAD, no objects) in -the current directory when no ancestor workspace exists. Running it inside -an existing workspace changes nothing and reports `already_initialized`; -it never creates a nested workspace under another workspace and never -touches an existing store's bytes. The result of init is exactly the -state a fresh `napl compile` expects: head absent, no modules recorded. -Like `git init`, the directory it runs in is the whole story: the -success payload names that directory as the workspace root, there is -nothing to configure, and a prompt written beside `.napl` is already -inside placement. Init accepts no flags beyond the shared help flag; -anything else is a usage error under the command line contract. The -payload also teaches the first mile: a `nextSteps` field carries one -complete minimal prompt, frontmatter fences with `module`, `deps`, and -`targets` plus one body line, the fact that discovery finds prompts by -the `.napl` extension, and the exact command sequence from writing that -prompt through `add` and `commit`, so a cold user never reverse -engineers the grammar one validation error at a time. The minimal -prompt's frontmatter includes a `roots` declaration naming its own -module, so the very first workspace is lint-ready and the sequence's -forward pointer about `roots` is a reminder, never a first encounter. +::: test +name: add of one working-tree path stages the committed state with that file overlaid +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, add_paths: [edited_file] } +expect: { add_outcome: staged, staged_state: committed_plus_overlay, unnamed_files_at_committed_bytes: true } +::: + +::: test +name: paths owned by two modules stage one record per module +given: { workspace: fixture_two_modules, working_tree_edit: both_modules_touched, add_paths: [file_of_leaf, file_of_consumer] } +expect: { staged_records: 2, modules: [consumer, leaf] } +::: + +::: test +name: a path no module owns is a usage error naming the owned globs +given: { workspace: fixture_two_modules, add_paths: [unowned_file] } +expect: { exit_code: 2, error_names_owned_globs: true, staged_records: 0 } +::: -Init also scaffolds the agent-teaching glue, wrangler-style, writing -only what is absent and never touching an existing file: a -`napl-agents.md` at the workspace root carrying the working doctrine -for LLM sessions (edit with your native tools, drift is legal and the -diagnostics mark it, the status-add-commit loop, the query surface, -capture verbs are deliberate acts), plus, when absent, a `CLAUDE.md` -containing an import reference to it and an `AGENTS.md` containing a -pointer line, so whichever harness opens the repo finds its native -entry file already pointing at the one doctrine. Every command the -scaffolded teaching spells is a valid invocation under the live -command line contract: a query example always carries its expression, -`napl q '.' --working-tree` and never a bare `napl q`, because the -expression is required and a bare invocation is a usage error. No -environment is -sniffed and no harness detected: every entry file is written, tiny -and inert, and determinism holds. When an entry file already exists, -init leaves it alone and the payload prints the one reference line -the user may add by hand. All scaffolded files are user-owned from -the moment they exist: ordinary files, never records, never -rewritten by any later command. +::: test +name: a pure line-shift edit transports the whole map and needs no attribution flag +given: { workspace: fixture_two_modules, working_tree_edit: insert_lines_above_attributed_range, add: module_form_without_attribution } +expect: { add_outcome: staged, map_transported: true, ranges_shifted: true, attribution_flag_needed: false } +::: -When init runs inside a git repository it also scaffolds the store's -git manners under the same never-clobber law: a `.gitattributes` at -the workspace root carrying two lines, `.napl/store/objects/** -text --diff linguist-generated` covering objects and packs alike, and -`.napl/store/HEAD binary`, written only when no `.gitattributes` -exists; when one exists, init leaves it alone and the payload prints -the exact lines to add by hand. `-text` is the load-bearing word: an -envelope's name is the hash of its exact bytes, so an eol-converting -checkout on any platform would corrupt every object against its own -name, and marking the store never-text is what makes a clone safe -everywhere. `binary` on HEAD keeps a merge from ever writing conflict -markers into the pointer. Nothing else about travel needs teaching, -because the store is ordinary tracked files and git already carries -them. Outside a git repository none of this appears, in the payload -or on disk. +::: test +name: a claim in a rewritten region refuses listing that claim with its observed bound +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_without_attribution } +expect: { outcome: invalid_candidate, listed_claims: 1, observed_bound_present: true, untouched_claims_not_listed: true } +::: ::: test -name: init in an empty directory creates the records skeleton with no head -given: { directory: empty } -expect: { created: [".napl/store"], head: absent, exit_zero: true, workspace_root_reported: true } +name: affirmations layer over the transported remainder and stage +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_attributed_region, add: module_form_with_affirmation_for_listed_claim } +expect: { add_outcome: staged, transported_claims_kept: true, affirmed_claim_recorded: true } ::: ::: test -name: init teaches the first mile with a complete minimal prompt and the command sequence -given: { directory: empty } -expect: { next_steps_prompt_passes_check: true, extension_named: ".napl", sequence_named: [write_prompt, add, commit], prompt_declares_roots: true } +name: add stages a valid candidate and commit installs it +given: { workspace: fixture_two_modules, candidate: valid_with_attribution } +expect: { add_outcome: staged, commit_outcome: success } ::: ::: test -name: init scaffolds the agent glue only where absent and clobbers nothing -given: { directory: with_existing_claude_md } -expect: { napl_agents_md_written: true, agents_md_written: true, claude_md_untouched: true, payload_prints_reference_line: true } +name: an explicit candidate whose transport is incomplete and unaffirmed fails loudly +given: { workspace: fixture_two_modules, candidate: rewritten_region_without_affirmation } +expect: { outcome: invalid_candidate, listed_claims_present: true } ::: ::: test -name: scaffolded teaching never spells a bare napl q and its query example carries an expression -given: { directory: empty } -expect: { bare_napl_q_absent: true, query_example_has_expression: true } +name: a candidate with a parent-escaping path is rejected as invalid-candidate +given: { workspace: fixture_two_modules, candidate: contains_dotdot_path } +expect: { outcome: invalid_candidate, published_semantic_state: false } ::: ::: test -name: scaffolded files are inert and later commands never rewrite them -given: { directory: empty, then: [init, write_prompt, add_module, commit] } -expect: { scaffold_bytes_unchanged_after: true } +name: tampering a staged blob after ingestion never mixes into the install +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, tamper: candidate_file_after_ingest } +expect: { outcome_in: [installed_from_immutable_blobs, store_integrity], mixed_content: false } ::: +`napl commit --staged ` is the one guarded +transaction over what add staged. Pipeline: verify the staged record +(recompute sliceHash, inputKey, baseStateKey at the recorded bases, +reject mismatches, enforce evidence bounds); extract and record the +surface; derive the complete transition (three-part footprint, member-set +delta, shared projections recomputed from the merged head); stage all +materialization outside any lock; then one bounded critical section under +the pointer lock performing only the footprint recheck and the pointer +and directory swaps, bounded by the transition's file count, never +hashing content inside the lock. + ::: test -name: init where a workspace already exists changes nothing and says so -given: { directory: existing_workspace } -expect: { changed_files: [], outcome: already_initialized, exit_zero: true } +name: a crash between staged materialization and pointer swap leaves a deterministic recovery plan +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, crash_after: staging } +expect: { store_readable: true, recovery_plan_deterministic: true, semantic_state_unchanged: true } ::: ::: test -name: init never overwrites an existing store -given: { directory: existing_workspace_with_commits } -expect: { store_bytes_unchanged: true } +name: a receipt whose recomputed slice hash disagrees is rejected as invalid-receipt +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: tampered_slice_hash } +expect: { outcome: invalid_receipt, published_semantic_state: false } ::: +The decision table, judged per arriving transition against the current +head: identities reproduce with head unmoved installs; head moved with +identities reproducing and footprints disjoint rebases then installs +(projections re-derived, never reused); inputKey failure returns +`stale-input`; baseStateKey failure returns `base-output-changed`; semantic +write overlap returns `write-conflict`; lock contention returns +`lock-contended`. Terminal outcomes, never retried unchanged: +`invalid-candidate`, `invalid-receipt`, `mapping-integrity`, +`resource-limit`, `unsupported-version`, `store-integrity`, `io-error`. +Every outcome is a canonical machine-readable JSON payload naming whether +blobs or provenance were published; no outcome publishes semantic state +partially. Exit codes: 0 success, 10 through 13 for the four returned +concurrency outcomes in table order, 20 through 26 for the seven terminal +outcomes in the order above. Staged records carry `receiptVersion`, the +per-module identities generated against, base snapshot hashes, the +candidate tree hash, the output manifest, then optional reported fields; +the compiler recomputes everything derivable and rejects on mismatch; +absent telemetry is `unavailable`, never zero. `napl notes add --to +` attaches later provenance without touching semantic state. + ::: test -name: init inside a git repository writes the gitattributes store lines only where no gitattributes exists -given: { directory: git_repo_no_gitattributes } -expect: { gitattributes_written: true, store_objects_marked_never_text_undiffed_generated: true, head_marked_binary: true } +name: commit installs a valid candidate against an unmoved head +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, head: unmoved } +expect: { outcome: installed, head_advanced: true, surface_recorded: true } ::: ::: test -name: init leaves an existing gitattributes alone and prints the lines to add -given: { directory: git_repo_with_gitattributes } -expect: { gitattributes_bytes_unchanged: true, payload_prints_store_lines: true } +name: a bare commit installs every staged record in wave order +given: { workspace: fixture_two_modules, staged: [record_for_leaf, record_for_consumer], commit_args: none } +expect: { outcomes: [success, success], order: wave_order, hand_sequencing_needed: false } ::: ::: test -name: init outside a git repository scaffolds no hook and teaches no git rules -given: { directory: empty } -expect: { pre_push_hook_written: false, payload_mentions_napl_store_branch: false } +name: bare commit re-derives and lands records for modules sharing one crate +given: { workspace: fixture_shared_crate_two_modules, staged: [record_for_first, record_for_second], commit_args: none } +expect: { outcomes: [success, success], retried_after_head_move: 1, hand_sequencing_needed: false } ::: -## napl watch +::: test +name: commit sweeps the record it installed +given: { workspace: fixture_two_modules, staged: [record_for_leaf], commit: bare_then_bare_again } +expect: { first_outcomes: [success], second_outcomes: [], reattempted_history: 0 } +::: -`napl watch` is an I/O shell around the pure compile derivation: it -observes the workspace's `*.napl` files and re-runs the derivation when -their content changes, emitting each result to stdout as canonical JSON. It publishes nothing: no semantic writes, no store -mutation, no locks held between runs. Change detection is by content -hash, never by timestamp, so an editor save that leaves bytes identical -produces no recompile. A malformed prompt is reported as a diagnostic in -the output stream and the watcher keeps running. Stopping the process is -the only way to end it, and stopping it loses nothing. +::: test +name: a bare commit with nothing staged names the drifted modules and their add commands +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, staged: none, commit_args: none } +expect: { installed: 0, nothing_staged_named: true, drifted_modules_named: [consumer], add_command_named: true } +::: ::: test -name: watch re-derives compile output when a prompt changes and writes nothing semantic -given: { workspace: fixture_two_modules, edit: touch_leaf_prompt } -expect: { recompiled: true, semantic_state_published: false } +name: a per-side transport refusal serves the surviving side's transported range +given: { workspace: fixture_two_modules, working_tree_edit: rewrite_prompt_section_only, add: module_form_with_check } +expect: { prompt_side: refused, code_side: transported, transported_code_range_served: true, affirmation_needed_sides: [prompt] } ::: ::: test -name: watch survives a malformed prompt edit and reports it instead of dying -given: { workspace: fixture_two_modules, edit: break_leaf_frontmatter } -expect: { process_alive: true, diagnostic_reported: true } +name: check enumerates the parse gate and refuses a file that does not parse +given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, add: module_form_with_check } +expect: { validations_enumerated: true, parse_gate_listed: true, outcome: invalid_candidate, error_names_file_and_line: true, staged_records: 0 } ::: -napl-store tree v1 4431 -{"entries":[{"hash":"d524c18f61e1a3d56c02b12319b23d0e96319f01e535647a95e490eb4e4880cd","kind":"file","mode":33188,"name":"attribution.rs"},{"hash":"1dec6b9fe622b28f871dae12c000036fd0604aef104c3fdcf95f38e1edcb3733","kind":"file","mode":33188,"name":"authoring.rs"},{"hash":"bcb41a480457775ca3f239c29b7f6ec05cb48e121a6b04172b3b2fe63afaca68","kind":"file","mode":33188,"name":"blame.rs"},{"hash":"fc64ab7abdea83855a745db60e01e82a09d7e896efb3242a65e95da6b1735003","kind":"file","mode":33188,"name":"candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","kind":"file","mode":33188,"name":"canon.rs"},{"hash":"a258b748a1c40209cc4b9d748a9257c8b314224856c232ed8982aa62002e9c31","kind":"file","mode":33188,"name":"cli.rs"},{"hash":"4df745d53cdf794e90eb98b84242a5575da64075ca21716afe7efca51caa0a30","kind":"file","mode":33188,"name":"commandset.rs"},{"hash":"8c88c9d9441329da0cfb5a19a3b073bc7ca96e5a25a2b04f1a9625a6042e68b7","kind":"file","mode":33188,"name":"commit.rs"},{"hash":"51a5efd09235e6a66de7381faaa217a51136ca9c7d2b10285f99af8ebe4ffc6f","kind":"file","mode":33188,"name":"compile.rs"},{"hash":"14768a9b96b8dd25e1efa36ddad54dd48c5feab01b57a4be8883f7ce8b524eb8","kind":"file","mode":33188,"name":"cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","kind":"file","mode":33188,"name":"decision.rs"},{"hash":"04a648a127be983c89a99181ee26e28401ee00f84ea0b10b6c050ecd263a1b2c","kind":"file","mode":33188,"name":"directive.rs"},{"hash":"28059a89256aad1de78ecd31d7b77cae21772bbc6e96fe8ba1cdb9044048f410","kind":"file","mode":33188,"name":"disk.rs"},{"hash":"0b71431883adea0e4f3dc3a77b968b69ac4f18348387362cd3c48fbc023d8bb4","kind":"file","mode":33188,"name":"error.rs"},{"hash":"5b7c0553530cac82c017fc61327a6c120e284d0a6d0966e24f0d63fe07d134f5","kind":"file","mode":33188,"name":"evidence.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","kind":"file","mode":33188,"name":"graph.rs"},{"hash":"f0641fc8a646a08a653d7a2ba1b8fe68da672f027919a1fccb53254e7bcd456a","kind":"file","mode":33188,"name":"lint.rs"},{"hash":"9e204c59695c6aed88c0addef5eea89231043d6db19af09df3699f1c41cfe714","kind":"file","mode":33188,"name":"lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","kind":"file","mode":33188,"name":"main.rs"},{"hash":"86fabd45da78a46bbfa7e92465788159360c0abaa4402f10ca433779b419ee0d","kind":"file","mode":33188,"name":"maintenance.rs"},{"hash":"a38e6c331db0c24a485fb4ef39c6996a957e8a068a5bc4a125f99f885a551455","kind":"file","mode":33188,"name":"mv.rs"},{"hash":"3050b7652a1a7070197d7fd954cf55c585dc328306ae68a91b27d24b6b84dcfd","kind":"file","mode":33188,"name":"objects.rs"},{"hash":"003dfa7b8045ce14ca57c31215434fe8034f78efa7c2f6660e635ae4dec26152","kind":"file","mode":33188,"name":"paths.rs"},{"hash":"5247e34ebd33f612010e0c38d0bf1b4f2b001de64888ab0443c9d38810546cec","kind":"file","mode":33188,"name":"porcelain.rs"},{"hash":"a3a8f3895135a736897c6c6730045019ed9e6fc6a16d090fb77d56cb58815715","kind":"file","mode":33188,"name":"receipt.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","kind":"file","mode":33188,"name":"slice.rs"},{"hash":"7cd4b1ed38095f7a1f90c73725887799cb7dd694216ddad461640d2ffae0894a","kind":"file","mode":33188,"name":"source.rs"},{"hash":"5783fa10d0a6de649ab37dee1f87fdc7cdb383827376597ea746f1097ec78489","kind":"file","mode":33188,"name":"staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","kind":"file","mode":33188,"name":"store_view.rs"},{"hash":"9ae420e34160e7c2a6c2ba092aaa9c9ff1df622ece595849d95bf5c5eb7cbe81","kind":"file","mode":33188,"name":"surface.rs"},{"hash":"2aeba094700b60cf2ab6e0c88dd79228028a72f6b9b0e2da489dce572f2d2641","kind":"file","mode":33188,"name":"target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","kind":"file","mode":33188,"name":"textdiff.rs"},{"hash":"c2690840af51faafd65929bdca676747ab87806e1e05ec50f2d3f676156373e5","kind":"file","mode":33188,"name":"transaction.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","kind":"file","mode":33188,"name":"trees.rs"},{"hash":"c0e1835e12c0dab7e9bb223a61914b9b89cc6e5a88698093977abacbec733a74","kind":"file","mode":33188,"name":"version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","kind":"file","mode":33188,"name":"workspace.rs"}]} -napl-store transition v1 1847 -{"baseStateKey":"6d936a0e261cd28357178855ce558832bb46b36d0c3ca5fecee7424a3b0e8034","contractVersion":2,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"7130542aade84984979930a7a1ce8c8c04d6ce7710dd347c6549cd11ea69d6f8","installPaths":["crates/napl/src/attribution.rs","crates/napl/src/authoring.rs","crates/napl/src/blame.rs","crates/napl/src/candidate.rs","crates/napl/src/canon.rs","crates/napl/src/cli.rs","crates/napl/src/commandset.rs","crates/napl/src/commit.rs","crates/napl/src/compile.rs","crates/napl/src/cost.rs","crates/napl/src/decision.rs","crates/napl/src/directive.rs","crates/napl/src/disk.rs","crates/napl/src/error.rs","crates/napl/src/evidence.rs","crates/napl/src/graph.rs","crates/napl/src/lint.rs","crates/napl/src/lsp.rs","crates/napl/src/main.rs","crates/napl/src/maintenance.rs","crates/napl/src/mv.rs","crates/napl/src/objects.rs","crates/napl/src/paths.rs","crates/napl/src/porcelain.rs","crates/napl/src/receipt.rs","crates/napl/src/slice.rs","crates/napl/src/source.rs","crates/napl/src/staging.rs","crates/napl/src/store_view.rs","crates/napl/src/surface.rs","crates/napl/src/target.rs","crates/napl/src/textdiff.rs","crates/napl/src/transaction.rs","crates/napl/src/trees.rs","crates/napl/src/version.rs","crates/napl/src/workspace.rs"],"module":"napl","newState":"e835eab10ec5f271af85dce966538f70e343c0d2e6442c3b736ef58a5647d5b9","readSet":["base-state:6d936a0e261cd28357178855ce558832bb46b36d0c3ca5fecee7424a3b0e8034","input-key:7130542aade84984979930a7a1ce8c8c04d6ce7710dd347c6549cd11ea69d6f8","target-def:eff9678e5301c88ac1e42eec62c56eb3860fca247f6278b6501639f14112a8d4"],"receipt":"273d4e3a2810f5e328772aff4f262b228dcdae379b229e47daab5fe9b46c9f74","surface":"476917d9ed89b9fb76844ef0a20187142687eb250a4752d300e98a7fe1363f41","target":"rust","writeSet":["owned:crates/napl","module-state:rust/napl"]} -napl-store module-state v1 558 -{"attribution":{"promptHash":"ca1bdac62d8c3409a54378a02ae90ca229216c6024ba44e23ce62ce6f4d45f07","record":"17783b48fe0888a535aed30a280cae63ba8d0559f202b60a1b386ab438c7c982","stale":false},"contractVersion":4,"inputKey":"186a86b32eb2e89308a136602249224309e890ca1a7575687e0451a9aa41f724","module":"napl","outputTree":"3288747bf43501a729ef81fd0c69314316f9f7ef2e5dfe2d3234f61fa4cd6b6a","sliceHash":"2247f45dfd9957056f59f4560e2f2cd169cf847637caabca52ede4b86373d121","surfaceHash":"6d44d704c68324ea25ff3bcdc9bb7fe1814aab2299b55fc04a7da4f19a006f5a","target":"rust"} -napl-store blob v1 685 -module: "commands" -target: "rust" -stale: false -entries: - - promptLines: - - 1 - - 8 - file: "crates/napl/src/init.rs" - lines: - - 1 - - 59 - note: "two porcelain additions each a thin composition over the shared derivations, the command table contributing init and watch" - - promptLines: - - 9 - - 36 - file: "crates/napl/src/init.rs" - lines: - - 1 - - 59 - note: "napl init bootstraps the store skeleton or reports already_initialized" - - promptLines: - - 37 - - 58 - file: "crates/napl/src/watch.rs" - lines: - - 1 - - 127 - note: "napl watch an io shell re-running compile on content change publishing nothing" -napl-store blob v1 7789 -use std::collections::BTreeMap; -use std::path::Path; -use crate::authoring::draft_receipt; -use crate::commit::{commit, CommitReport}; -use crate::compile::base_output_files; -use crate::disk; -use crate::error::{error, error_payload, OutcomeCode, Result}; -use crate::porcelain::committed_state; -use crate::store_view::get_blob; -use crate::target::{builtin_target, placement_root, TargetDef}; -use crate::workspace::{discover_sources, write_file}; +::: test +name: add refuses a candidate whose source does not parse naming file and line +given: { workspace: fixture_two_modules, candidate: syntactically_invalid_rust } +expect: { outcome: invalid_candidate, error_names_file_and_line: true, published_semantic_state: false } +::: -/// Relocate a module's installed files to a new placement through the guarded -/// commit path, re-verifying at the destination. Nothing installs unless the -/// commit succeeds. -pub fn relocate_module( - workspace: &Path, - target_name: &str, - module: &str, - dest: &str, -) -> CommitReport { - match prepare(workspace, target_name, module, dest) { - Ok(plan) => commit( - workspace, - &plan.receipt_path, - &plan.staging, - plan.attribution_path.as_deref(), - plan.no_verify, - ), - Err(err) => CommitReport { - outcome: err.code, - payload: error_payload(&err, false, false), - }, - } -} +::: test +name: commit recomputation refuses staged bytes that do not parse +given: { workspace: fixture_two_modules, staged: record_with_tampered_unparseable_blob } +expect: { outcome_in: [invalid_candidate, store_integrity], installed: false } +::: -/// The staged relocation ready for the guarded commit, including the attribution -/// carried forward with its path prefixes recomputed against the destination. -struct RelocationPlan { - staging: std::path::PathBuf, - receipt_path: std::path::PathBuf, - attribution_path: Option, - no_verify: bool, -} +::: test +name: a bad attribution file answers with the complete map contract and example +given: { workspace: fixture_two_modules, attribution_file: empty_object, add: module_form_with_attribution } +expect: { error_lists_all_fields: true, error_carries_valid_example: true } +::: -fn prepare( - workspace: &Path, - target_name: &str, - module: &str, - dest: &str, -) -> Result { - let target = builtin_target(target_name)?; - let sources = discover_sources(workspace)?; - let source = sources - .iter() - .find(|source| source.module == module) - .ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} has no prompt file in the workspace"), - ) - })?; - let canonical = placement_root(source); - if dest != canonical { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "destination {dest:?} does not match {module:?}'s prompt directory {canonical:?}; move the prompt into the destination crate first" - ), - )); - } +::: test +name: the plan labels projections and names a legal first output +given: { workspace: fresh_init_root_prompt, flag: module_root } +expect: { projection_labeled_tool_derived: true, legal_first_output_named: true } +::: - let store = disk::open(workspace); - let state = committed_state(&store, target_name, module)?.ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} has no committed output to relocate"), - ) - })?; - let blobs = base_output_files(&store, &state)?; - if blobs.is_empty() { - return Err(error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} has no committed output to relocate"), - )); - } - let mut files: BTreeMap> = BTreeMap::new(); - for (path, blob) in &blobs { - files.insert(path.clone(), get_blob(&store, blob)?); - } +::: test +name: gc apply sweeps staged records and touches nothing reachable +given: { workspace: fixture_two_modules, staged: [stale_record], gc: apply } +expect: { staging_empty: true, reachable_objects_untouched: true, store_healthy: true } +::: - let old_root = derive_old_root(&files, &target)?; - let relocated = relocate_files(&files, &old_root, dest)?; +::: test +name: commit against a moved head with disjoint footprints rebases and installs +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: to_unrelated_module } +expect: { outcome: installed, rebased: true } +::: - let staging = store.root.join("tmp").join(format!("mv-{module}")); - if staging.exists() { - std::fs::remove_dir_all(&staging).map_err(|e| disk::io(&staging, &e))?; - } - for (path, bytes) in &relocated { - write_file(&staging.join(path), bytes)?; - } +::: test +name: commit whose direct dep surface moved underneath returns stale-input +given: { workspace: fixture_chain_three, candidate: valid_for_middle, intervening_commit: leaf_surface_change } +expect: { outcome: stale_input, head_advanced: false } +::: - let receipt = draft_receipt(workspace, target_name, module, &staging, None)?; - let receipt_path = store.root.join("tmp").join(format!("mv-{module}.json")); - write_file(&receipt_path, &crate::canon::canonical_bytes(&receipt))?; +::: test +name: commit whose own outputs moved underneath returns base-output-changed +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, intervening_commit: same_module_other_candidate } +expect: { outcome: base_output_changed } +::: - // A relocation recomputes only the path prefix of a mapping, never its line - // content: the committed attribution rides along with each file re-rooted - // from the old placement to the destination. A module that never attributed - // has nothing to carry, so the relocation rides the escape hatch. - let (attribution_path, no_verify) = - match crate::porcelain::committed_attribution_yaml(&store, target_name, module)? { - Some(bytes) => { - let text = String::from_utf8(bytes).map_err(|_| { - error( - OutcomeCode::MappingIntegrity, - "committed attribution is not valid UTF-8", - ) - })?; - let mut record = crate::attribution::parse(&text).ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - "committed attribution is not a parseable record", - ) - })?; - crate::attribution::remap_prefix(&mut record, &old_root, dest); - let path = store.root.join("tmp").join(format!("mv-{module}.yaml")); - write_file(&path, &crate::attribution::to_yaml(&record))?; - (Some(path), false) - } - None => (None, true), - }; +::: test +name: two concurrent disjoint commits both land and neither touches a manifest +given: { workspace: fixture_two_modules, concurrent_candidates: [module_a, module_b], orders: [ab, ba] } +expect: { both_installed: true, manifest_bytes_unchanged: true } +::: - Ok(RelocationPlan { - staging, - receipt_path, - attribution_path, - no_verify, - }) -} +::: test +name: "absent telemetry reads as unavailable, never zero" +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: without_usage } +expect: { usage: unavailable } +::: -/// The placement root a module's committed output currently occupies, read off -/// its own source tree rather than any placement rule, so a relocation reads -/// where the bytes are and not where the rule now says they should be. Manifests -/// are user-owned and never appear in committed output, so the source directory -/// marks the crate root. -fn derive_old_root(files: &BTreeMap>, target: &TargetDef) -> Result { - let marker = format!("/{}/", target.source_dir); - let mut roots: Vec = files - .keys() - .filter_map(|path| path.split_once(&marker).map(|(root, _)| root.to_string())) - .filter(|root| !root.is_empty()) - .collect(); - roots.sort(); - roots.dedup(); - match roots.len() { - 1 => Ok(roots.remove(0)), - _ => Err(error( - OutcomeCode::MappingIntegrity, - format!( - "committed output does not carry exactly one {} source tree", - target.source_dir - ), - )), - } -} +Attribution rides the staged record; there is no other write path for it. +`napl add --attribution ` carries the module's mapping of prompt body +line ranges to generated file line ranges with notes; add validates every +claim before staging: each file path inside the module's owned globs, +each code line range present in the candidate tree, each prompt line +range present in the current normalized prompt body; a mapping that lies +is `invalid-candidate`. An entry may own no file at all: contract-level +prose — a law — that governs the module rather than any single path; +validation accepts a file-less entry (its prompt range must still exist) +and the query layer serves it flagged as law. An add whose transport is +incomplete and whose affirmations do not cover the listed claims fails +loudly as `invalid-candidate`, naming exactly which claims are missing; +`napl add --no-verify` is the explicit escape hatch that stages anyway +and records the module's attribution as stale on install. Any prompt +edit, code transition, or move that changes what a recorded mapping +points at marks it stale the same way; nothing ever repairs a mapping +automatically. `napl status` reports attribution freshness per +module (`fresh`, `stale`, `absent`) and, for stale or absent, emits a +machine-readable directive naming the exact fix: commit with a fresh +attribution for that module. Relocation recomputes only the path prefix +of a mapping, never its line content. The lsp serves a stale mapping +marked stale rather than hiding it; absent answers empty. -/// Re-root a module's owned files from `old_root` to `dest`. A relocation moves -/// bytes and recomputes the path prefix only; manifests are the user's to -/// update, so nothing here rewrites one. -pub fn relocate_files( - files: &BTreeMap>, - old_root: &str, - dest: &str, -) -> Result>> { - let prefix = format!("{old_root}/"); - let mut out: BTreeMap> = BTreeMap::new(); - for (path, bytes) in files { - let rest = path.strip_prefix(&prefix).ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("committed path {path:?} is outside the placement root {old_root:?}"), - ) - })?; - out.insert(format!("{dest}/{rest}"), bytes.clone()); - } - Ok(out) -} +An attribution entry is a reference, and a reference has a direction: +prompt-to-code, a prompt body line range naming generated file line +ranges, or code-to-prompt, a generated file line range naming a prompt +body line range. Every entry recorded before directions existed reads as +prompt-to-code; migration is a reading, never a rewrite of stored +records. Validation is identical for both directions and referential +only, the same claims checked the same way, and nothing about a +direction implies the sentence describes the code: a reference is a +claim with provenance and freshness, and the record vocabulary never +says more. -/// The `/`-separated relative path from directory `from` to directory `to`, -/// where both are workspace-relative and drawn from the portable grammar. -pub fn relative_dir(from: &str, to: &str) -> String { - let from_parts: Vec<&str> = from.split('/').filter(|p| !p.is_empty()).collect(); - let to_parts: Vec<&str> = to.split('/').filter(|p| !p.is_empty()).collect(); - let mut common = 0; - while common < from_parts.len() - && common < to_parts.len() - && from_parts[common] == to_parts[common] - { - common += 1; - } - let mut parts: Vec = Vec::new(); - for _ in common..from_parts.len() { - parts.push("..".to_string()); - } - for part in &to_parts[common..] { - parts.push((*part).to_string()); - } - if parts.is_empty() { - ".".to_string() - } else { - parts.join("/") - } -} -napl-store blob v1 7179 -//! Receipts: what a producer reports, and what the compiler refuses to believe. -//! -//! A receipt states the identities its work was generated against and the output -//! it produced. Every one of those is independently recomputed at commit. The -//! optional reported fields — producer identity, usage, gate observations, -//! notes, evidence — are provenance: they are recorded, they never enter the -//! semantic identity a transition is named by, and when absent they read as -//! `unavailable` rather than as zero. +A reference carries a provenance block, recorded by the compiler at +staging time from what the caller supplied: recording model identity, +timestamp, tool version, and an optional usage-envelope reference +through the existing evidence seam. Every absent field is `unavailable`, +never zero, never invented, and provenance is display and pricing +metadata that never enters any identity, exactly as telemetry already +behaves. The query layer and the lsp serve the provenance block with the +entry. -use std::collections::BTreeMap; +Staleness covers both ends of a reference, and working-tree drift is one +of the ways an end changes: a reference whose code side no longer +matches the committed bytes, or whose prompt side changed in the working +tree, reads stale on both endpoints, computed at read time from the +records and the working tree, never stored as a transition. A hand edit +to generated source is drift like any other: legal, visible through the +same status and diff machinery, healable by restore or by re-recording +the reference; nothing repairs a reference automatically and nothing +polices the edit that staled it. -use napl_store::store_hash::ObjectKind; -use serde_json::{json, Value}; +::: test +name: a code-to-prompt entry stages and is served with its direction +given: { workspace: fixture_two_modules, candidate: valid_with_code_to_prompt_reference } +expect: { add_outcome: staged, served_direction: code_to_prompt } +::: -use crate::canon::{is_object_name, json_hash, parse_json}; -use crate::error::{error, OutcomeCode, Result}; -use crate::evidence::{check_evidence_bounds, check_reported_bounds, parse_evidence, EvidenceItem}; +::: test +name: entries recorded before directions read as prompt-to-code unchanged +given: { workspace: fixture_two_modules, history: [pre_direction_attribution] } +expect: { served_direction: prompt_to_code, stored_records_rewritten: false } +::: -/// The receipt schema version this build accepts. -pub const RECEIPT_VERSION: u32 = 1; +::: test +name: provenance is served with the entry and absent fields read unavailable +given: { workspace: fixture_two_modules, candidate: reference_without_usage_envelope } +expect: { provenance_served: true, usage: unavailable, cost_component: unavailable } +::: -/// Largest canonical size the optional reported block may occupy. -pub const MAX_REPORTED_BYTES: usize = 512 * 1024; +::: test +name: a hand edit to the code side reads stale on both endpoints +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched } +expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } +::: -/// The value recorded for telemetry a producer did not report. -pub const UNAVAILABLE: &str = "unavailable"; +::: test +name: a working-tree prompt edit reads stale on both endpoints +given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched } +expect: { reference_status: stale, stale_endpoints: [prompt, code], stored_transition: false } +::: -/// A parsed and structurally valid receipt. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Receipt { - pub receipt_version: u32, - pub module: String, - pub target: String, - pub input_key: String, - pub base_state_key: String, - pub slice_hash: String, - pub base_workspace_hash: String, - pub base_store_snapshot: Option, - pub candidate_tree_hash: String, - pub output_manifest: BTreeMap, - /// Provenance only: never hashed into a semantic identity. - pub reported: Value, - /// The evidence items the reported block carries, already bounds-checked. - pub evidence: Vec, -} +::: test +name: a code-to-prompt reference naming a missing prompt range is invalid-candidate +given: { workspace: fixture_two_modules, candidate: code_to_prompt_reference_to_missing_range } +expect: { outcome: invalid_candidate, published_semantic_state: false } +::: -/// Parse a receipt from JSON bytes. -pub fn parse_receipt(bytes: &[u8]) -> Result { - let value = parse_json(bytes, "receipt", OutcomeCode::InvalidReceipt)?; - let bad = |why: String| error(OutcomeCode::InvalidReceipt, why); +## edit and write: the capture verbs - let version = value["receiptVersion"] - .as_u64() - .ok_or_else(|| bad("receipt has no numeric receiptVersion".into()))?; - if version != u64::from(RECEIPT_VERSION) { - return Err(error( - OutcomeCode::UnsupportedVersion, - format!("receiptVersion {version} is not {RECEIPT_VERSION}"), - )); - } +:::docs +## Recording an edit - let text = |name: &str| -> Result { - value[name] - .as_str() - .map(str::to_string) - .ok_or_else(|| bad(format!("receipt field {name:?} is missing or not a string"))) - }; - let identity = |name: &str| -> Result { - let raw = text(name)?; - if is_object_name(&raw) { - Ok(raw) - } else { - Err(bad(format!("receipt field {name:?} is not an object name"))) - } - }; +`napl edit` and `napl write` capture a change as a deliberate act: +which lines moved and which prompt sentence they belong to. +Attribution is a record you author, never a guess the tool makes, so +`napl blame` answers with what someone actually claimed. +::: - let base_store_snapshot = match &value["baseStoreSnapshot"] { - Value::Null => None, - Value::String(s) if is_object_name(s) => Some(s.clone()), - _ => return Err(bad("baseStoreSnapshot must be null or an object name".into())), - }; +`napl edit` and `napl write` are the deterministic capture layer in +front of the same guarded door. They carry the familiar tool +signatures: `napl edit --old-string --new-string +[--replace-all]` replaces an exact occurrence in a file, and +`napl write ` writes stdin as the file's full content; its usage +line says the content comes from stdin, and an empty stdin refuses as +a usage error naming `--empty`, the explicit flag that authorizes +writing a zero-byte file, so a forgotten pipe can never silently +truncate. Either +side is editable, a prompt or a generated file, by a human or an LLM; +a hand edit through an ordinary editor stays equally legal, and the +verbs add capture, never exclusivity. The verbs never synthesize or +rewrite prose beyond the bytes given, never invoke a model, never +decide placement, never repair a reference, and never bypass the +guard: `add` and `commit` remain exactly what they are. - let entries = value["outputManifest"] - .as_array() - .ok_or_else(|| bad("receipt has no outputManifest array".into()))?; - let mut output_manifest: BTreeMap = BTreeMap::new(); - for entry in entries { - let path = entry["path"] - .as_str() - .ok_or_else(|| bad("outputManifest entry has no path".into()))?; - let hash = entry["hash"] - .as_str() - .filter(|h| is_object_name(h)) - .ok_or_else(|| bad(format!("outputManifest entry {path:?} has no blob hash")))?; - if output_manifest - .insert(path.to_string(), hash.to_string()) - .is_some() - { - return Err(bad(format!("outputManifest lists {path:?} twice"))); - } - } +Each call, deterministically and in this order: validate, apply, +capture. When the target is a prompt file, the result of the edit is +validated first by the same reader `check` and `add` use; a result +that fails validation refuses as `prompt-invalid` and applies nothing. +An `--old-string` that matches nowhere, matches more than once +without `--replace-all`, or equals `--new-string` refuses as +`edit-mismatch` and applies nothing, so a no-op edit can never mint a +record. Only a call that will succeed touches the working tree, so +every refusal leaves every file byte-identical. - // Bounds are enforced before anything is published, so an oversized receipt - // can never leave a half-written store behind. - let evidence = parse_evidence(&value["reported"])?; - check_evidence_bounds(&evidence)?; - let reported = normalize_reported(&value["reported"]); - check_reported_bounds(&reported, MAX_REPORTED_BYTES)?; +A call may carry one reference: `--ref `, a single object naming +a direction and both endpoints, prompt body line range and generated +file line range, in either direction per the reference contract above. +The contract is taught, never reverse engineered: the verbs' usage +lines and flag meanings name the object's fields, and the FIRST +validation failure of a ref payload answers with the complete +contract in one refusal, every field, the closed list of valid +`direction` values, and one complete valid example, never one missing +field per attempt. +The reference is validated referentially only, exactly as strong as +attribution validation and no stronger: the file path inside the +owning module's placement, both line ranges present in the +post-edit bytes of their files. A reference naming a missing line or +a path outside placement refuses as `reference-unresolvable` and +applies nothing. A valid reference-bearing call stages a +reference-only record and commits it through the guarded transaction +in the same invocation: the transition carries the reference entry and +its provenance, no output-tree change and no surface change, and every +rule of the decision table, journaled atomicity, and recovery applies +to it unchanged. The success payload carries the recorded reference +object verbatim and its transition hash, and says plainly that the +edited file remains working-tree drift until its module is committed: +recording a reference and advancing the committed baseline are +different acts, and the payload spells the sequence that performs the +second, `napl add` for the module then `napl commit`, so nobody reads +`recorded: true` as committed and nobody feeds a bare commit nothing. +A plain apply teaches the same way: a written or edited file is +working-tree drift until staged and committed, and the success +payload of both verbs names both verbs in order. `napl show ` lists the +transition's reference entries inline exactly as it lists notes. A +call without `--ref` is a plain edit: apply, no +record, no store touch. Any existing reference whose endpoint the edit +moved needs no marking, because both-ends staleness is already +computed at read time from the working tree; the verbs store nothing +about it. - Ok(Receipt { - receipt_version: RECEIPT_VERSION, - module: text("module")?, - target: text("target")?, - input_key: identity("inputKey")?, - base_state_key: identity("baseStateKey")?, - slice_hash: identity("sliceHash")?, - base_workspace_hash: identity("baseWorkspaceHash")?, - base_store_snapshot, - candidate_tree_hash: identity("candidateTreeHash")?, - output_manifest, - reported, - evidence, - }) -} +A reference the verb records is evidence, and evidence serves itself. +The record carries the content hash of each endpoint's post-edit +bytes, the file as the verb wrote it and the normalized prompt body it +validated against. Freshness reads those hashes first: a verb-recorded +reference whose endpoint bytes still match the working tree reads +fresh, and it goes stale only when the tree moves off the bytes the +record names, never merely because the committed baseline lags the +edit, so `recorded: true` is never followed by a stale count the verb +itself caused. At the next add, transport seeds from the same +evidence: a committed claim refusing in a rewritten region is affirmed +automatically when a reference transition recorded since the module's +committed output covers that region and its endpoint hashes match the +staged bytes, because the tool that applied the edit owns its diff; +the affirmation is the record, and no hand-authored map is ever +demanded for a range a capture verb already recorded. A recorded +reference whose hashes no longer match affirms nothing and is listed +in the refusal like any other claim, so the shortcut never guesses. -/// Fill absent telemetry with `unavailable`; never with zero. -pub fn normalize_reported(value: &Value) -> Value { - let pick = |name: &str| -> Value { - match value.get(name) { - Some(Value::Null) | None => json!(UNAVAILABLE), - Some(found) => found.clone(), - } - }; - json!({ - "evidence": pick("evidence"), - "gates": pick("gates"), - "notes": pick("notes"), - "producer": pick("producer"), - "usage": pick("usage"), - }) -} +::: test +name: a verb-recorded reference reads fresh until the tree moves off its bytes +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: status_module } +expect: { stale_reference_count: 0, drift: true } +::: -/// The semantic body of a receipt: everything the compiler recomputes, and -/// nothing a producer merely reports. -pub fn receipt_semantic_json(receipt: &Receipt) -> Value { - let manifest: Vec = receipt - .output_manifest - .iter() - .map(|(path, hash)| json!({ "hash": hash, "path": path })) - .collect(); - json!({ - "baseStateKey": receipt.base_state_key, - "baseStoreSnapshot": receipt.base_store_snapshot, - "baseWorkspaceHash": receipt.base_workspace_hash, - "candidateTreeHash": receipt.candidate_tree_hash, - "contractVersion": crate::version::CONTRACT_VERSION, - "inputKey": receipt.input_key, - "module": receipt.module, - "outputManifest": manifest, - "receiptVersion": receipt.receipt_version, - "sliceHash": receipt.slice_hash, - "target": receipt.target, - }) -} +::: test +name: add after a referenced edit self-affirms the rewritten region and stages without a map +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, add: module_form_without_attribution } +expect: { add_outcome: staged, affirmed_from_reference_record: true, attribution_flag_needed: false } +::: -/// The canonical JSON body of the receipt object as it is recorded, provenance -/// included. -pub fn receipt_json(receipt: &Receipt) -> Value { - let mut body = receipt_semantic_json(receipt) - .as_object() - .cloned() - .expect("the semantic body is an object"); - body.insert("reported".into(), receipt.reported.clone()); - Value::Object(body) -} +::: test +name: a recorded reference whose bytes moved again affirms nothing +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, then: hand_edit_same_region, add: module_form_without_attribution } +expect: { outcome: invalid_candidate, listed_claims_present: true, affirmed_from_reference_record: false } +::: -/// The identity a transition names a receipt by. -/// -/// It covers the semantic body only, so two receipts that differ solely in -/// reported telemetry name the same transition. -pub fn receipt_semantic_hash(receipt: &Receipt) -> String { - json_hash(ObjectKind::Receipt, &receipt_semantic_json(receipt)) -} +Provenance rides the session window: `--session ` names the +usage-proxy session whose log lines are this call's evidence, and when +the flag is absent the verb reads the `NAPL_SESSION` environment +variable, so a call made under `napl agent` carries its window with no +extra argument. The recorded usage reference is the window itself, +media type, workspace-relative log path, session id, and the line span +observed at staging, priced lazily by `napl cost` at read time, never +aggregated at record time. Model identity and timestamp come from the +window's log lines, or read `unavailable` when there is no window; +the verbs read no clock, so the same call over the same working tree, +store state, and log bytes produces byte-identical records. Absent is +`unavailable`, never zero, never invented, and provenance never +enters identity. -/// The content identity of the recorded receipt object, provenance included. -pub fn receipt_hash(receipt: &Receipt) -> String { - json_hash(ObjectKind::Receipt, &receipt_json(receipt)) -} +The three refusals join the closed terminal-outcome table under the +contract bump to 4, pinned exit codes continuing the table order: +`edit-mismatch` 27, `reference-unresolvable` 28, `prompt-invalid` 29. +None reuses `mapping-integrity`, and each is a canonical +machine-readable JSON payload naming what failed and that nothing was +applied or published. -/// The reported usage, or the `unavailable` marker when the producer reported -/// none. Never a fabricated zero. -pub fn reported_usage(receipt: &Receipt) -> Value { - receipt.reported["usage"].clone() -} -napl-store module-state v1 558 -{"attribution":{"promptHash":"77b321d0dd911de45787b1c235e91ee661d8fc9ebe0b4058e3d47da9633ce406","record":"074f79d004b82eddbcb9baa87700dbf0cb5ae826f94c1d886dceaa4ec79e23d8","stale":false},"contractVersion":4,"inputKey":"40b3c8b554c285c63779abe06bafd53bc68686fa1e5407ee41c664aad568c38f","module":"napl","outputTree":"be2a56ff9d16650df9a1b6ca6aae9e3e1b88c013c2a0548bf364a7bad344b0bd","sliceHash":"39040e50efd44a2cf7fc988ab39c9e38cbed6b8f32e133bbe8ccad4d07fd67b0","surfaceHash":"fe6954097a004d01c836063d2833c9bc8476458ec8019470104e81054b94fa0d","target":"rust"} -napl-store blob v1 72377 -//! `napl add`: stage a module state; `napl commit --staged`: install what add staged. -//! -//! Git parity is the mental model: add stages, commit installs. The staged -//! record is the internal receipt between them, never a user-facing noun. It is -//! transient coordination state, disposable litter by the git-state law: never -//! promoted to history, safely deletable, and re-creatable by re-running add. -//! Add ingests the staged bytes into an immutable copy and every later step -//! reads that copy, so tampering with the source files after add cannot change -//! what commit installs. That holds for all three sources equally, because all -//! three become a list of scanned entries before ingestion sees them. -//! -//! The working tree is the default source, exactly as in git. `napl add ` -//! names files and stages the owning module's committed output with those files -//! overlaid; `napl add --module ` stages the module's whole owned tree as -//! it sits on disk; `napl add --module ` stages explicit -//! bytes, for the rare case where what must be staged is not the working tree. -//! -//! Attribution transports rather than being demanded. Add diffs the committed -//! output against the staged bytes and the committed prompt body against the -//! pending one, and carries every recorded claim through those diffs. A claim the -//! diff proves survives; a claim it does not prove is asked for, never invented. +::: test +name: an edit with a valid code-to-prompt reference stages and commits in one call +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } +expect: { outcome: installed, served_direction: code_to_prompt, output_tree_changed: false } +::: -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; +::: test +name: the same edit call twice produces byte-identical records +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, runs_from_same_base: 2 } +expect: { records_byte_identical: true } +::: -use serde_json::{json, Value}; +::: test +name: a reference to a missing line refuses as reference-unresolvable and applies nothing +given: { workspace: fixture_two_modules, edit: ref_names_missing_line } +expect: { outcome: reference_unresolvable, exit_code: 28, files_byte_identical: true } +::: -use crate::attribution::{self, Attribution}; -use crate::authoring::draft_receipt_for; -use crate::candidate::{default_limits, ingest, Candidate, EntryKind, ScannedEntry}; -use crate::commit::{commit_with_mode, AttributionMode, CommitReport}; -use crate::disk::{self, Store}; -use crate::error::{error, error_payload, OutcomeCode, Result}; -use crate::objects::{read_module_state, snapshot_states}; -use crate::source::SourceModule; -use crate::store_view::{flatten_tree, get_blob, ObjectSource}; -use crate::target::{builtin_target, is_owned_path, owned_globs, placement_root, TargetDef}; -use crate::transaction::check_candidate_shape; -use crate::transport::{ - refusal_detail, refusal_payload, transport_attribution, TransportInputs, TransportOutcome, -}; -use crate::workspace::{discover_sources, scan_candidate, write_file}; +::: test +name: a prompt edit that breaks the prompt refuses as prompt-invalid and applies nothing +given: { workspace: fixture_two_modules, edit: prompt_edit_breaking_directive } +expect: { outcome: prompt_invalid, exit_code: 29, files_byte_identical: true } +::: -/// The disposable staging area for `add`, workspace-relative. -pub const STAGING_DIR: &str = ".napl/staging"; +::: test +name: an ambiguous old-string without replace-all refuses as edit-mismatch +given: { workspace: fixture_two_modules, edit: old_string_matches_twice } +expect: { outcome: edit_mismatch, exit_code: 27, files_byte_identical: true } +::: -/// Where the bytes `add` stages come from. -/// -/// Three sources, one pipeline: each resolves to a list of scanned entries, and -/// from ingestion onward nothing downstream can tell them apart. -#[derive(Debug, Clone)] -pub enum AddSource { - /// Explicit candidate bytes in a directory: the exception, not the routine. - CandidateDir(PathBuf), - /// The module's whole owned tree, as it sits in the working tree. - WorkingTree, - /// The module's committed output with exactly these workspace-relative paths - /// overlaid from the working tree. - Overlay(Vec), -} +::: test +name: the first bad ref payload answers with the whole contract at once +given: { workspace: fixture_two_modules, edit: ref_payload_bare_string } +expect: { error_lists_all_fields: true, error_lists_direction_values: true, error_carries_valid_example: true, files_byte_identical: true } +::: -/// Stage one module state from any of the three sources. -/// -/// Ingest the staged bytes into immutable blobs, validate against the slice, -/// resolve attribution by transport or affirmation, attach usage evidence, then -/// write the staged record. Validation failures surface here with the same -/// outcome classes commit uses. -#[allow(clippy::too_many_arguments)] -pub fn add( - workspace: &Path, - target_name: &str, - module: &str, - source: &AddSource, - attribution_path: Option<&Path>, - usage_path: Option<&Path>, - no_verify: bool, - check: bool, -) -> Result { - let target = builtin_target(target_name)?; - let sources = discover_sources(workspace)?; - let prompt = sources.iter().find(|s| s.module == module).ok_or_else(|| { - error( - OutcomeCode::MappingIntegrity, - format!("module {module:?} has no prompt file in the workspace"), - ) - })?; - let root = placement_root(prompt); - let store = disk::open(workspace); +::: test +name: a recorded edit serves its reference in the payload and in show +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref } +expect: { payload_reference_verbatim: true, payload_names_drift_until_commit: true, show_lists_reference: true } +::: - // Resolve the source to entries, then ingest once into immutable bytes; - // every later step reads these, never the files they came from. - let entries = scan_source(workspace, &store, &target, module, &sources, source)?; - let candidate = ingest(&entries, default_limits())?; - check_candidate_shape(&target, &root, &candidate)?; - // The parse gate, before anything reads the bytes as a surface: every staged - // file the target claims as source has to parse under the target's pinned - // grammar, or the record refuses naming the file, the first error's line, and - // the malformed region. Syntax only; everything past it is the battery's job. - // - // The refusal enumerates the same validations a passing `--check` verdict - // enumerates, so the reader learns which gate stopped them in the same - // vocabulary that would have told them everything passed. - if let Err(err) = crate::surface::parse_gate(&target, &root, &candidate.files) { - let mut data = match err.data { - Some(Value::Object(map)) => map, - _ => serde_json::Map::new(), - }; - data.insert("module".into(), json!(module)); - data.insert("notJudged".into(), not_judged()); - data.insert( - "validations".into(), - validations_run(attribution_path.is_some()), - ); - return Err(crate::error::error_with( - err.code, - err.detail, - Value::Object(data), - )); - } +::: test +name: a prose-only edit needs no reference and touches no store state +given: { workspace: fixture_two_modules, edit: plain_prompt_edit_valid } +expect: { applied: true, store_bytes_unchanged: true } +::: - // The receipt is drafted against the ingested copy, not against files on - // disk, so the whole verdict is computable before anything is written. That - // is what makes `--check` a real dry run rather than a staged record deleted - // afterwards. - let receipt = draft_receipt_for(workspace, target_name, module, &candidate, usage_path)?; +::: test +name: verb success payloads spell the add then commit sequence +given: { workspace: fixture_two_modules, invocations: [plain_write_owned_file, referenced_code_edit_with_ref] } +expect: { write_payload_names: [add, commit], edit_payload_names: [add, commit] } +::: - // Resolve the attribution update: transported where the diffs prove it, - // affirmed where they do not, refused when neither. - let attribution_bytes = resolve_attribution( - workspace, - &store, - &target, - &root, - module, - prompt, - &candidate, - attribution_path, - no_verify, - )?; +::: test +name: write with empty stdin refuses naming the empty flag and writes nothing +given: { workspace: fixture_two_modules, write: target_with_empty_stdin } +expect: { exit_code: 2, error_names_flag: "--empty", files_byte_identical: true } +::: + +::: test +name: an edit touching a referenced line reads stale on both endpoints afterward +given: { workspace: fixture_two_modules, edit: plain_edit_on_referenced_code_line } +expect: { reference_status: stale, stale_endpoints: [prompt, code] } +::: + +::: test +name: a session window from the environment is recorded as the usage reference +given: { workspace: fixture_two_modules, env: { NAPL_SESSION: s1 }, log: proxy_log_with_s1_lines, edit: referenced_code_edit_with_ref } +expect: { provenance_usage_session: s1, usage_span_recorded: true, aggregated_at_record: false } +::: - if check { - // The dry run has now run the entire pipeline, transport verdict and - // refusal listing included, and it writes nothing: no staged record, no - // entry in the staging directory, no store byte touched. - // - // And it says exactly what it judged. `would-stage` is a verdict about a - // record, not about code: the staged bytes could be a file that does not - // parse in the target language and this command would still, correctly, - // answer would-stage. Enumerating the validations that ran beside the one - // that deliberately did not is what keeps the two from being confused. - return Ok(json!({ - "checked": true, - "contractVersion": crate::version::CONTRACT_VERSION, - "module": module, - "notJudged": not_judged(), - "outcome": "would-stage", - "staged": false, - "target": target_name, - "validations": validations_run(attribution_path.is_some()), - })); - } +::: test +name: an edit with no session window records provenance unavailable +given: { workspace: fixture_two_modules, edit: referenced_code_edit_with_ref, env: {} } +expect: { provenance_model: unavailable, provenance_usage: unavailable } +::: - // A module record carries the whole prompt with it: commit records the - // working-tree prompt file as the module's prompt object, docs fences - // included. A docs record staged for the same module is therefore redundant - // the moment this one exists, and leaving both staged would make a bare - // commit's ordering decide which prose lands. The module door supersedes it. - sweep_docs_records(workspace, module, target_name)?; - let record_dir = record_dir(workspace, module, &candidate.tree_hash); - if record_dir.exists() { - std::fs::remove_dir_all(&record_dir).map_err(|e| disk::io(&record_dir, &e))?; - } - let staged_dir = record_dir.join("candidate"); - for (path, bytes) in &candidate.files { - write_file(&staged_dir.join(path), bytes)?; - } +## The rest of the porcelain - write_file( - &record_dir.join("receipt.json"), - &crate::canon::canonical_bytes(&receipt), - )?; - let attribution_present = match &attribution_bytes { - Some(bytes) => { - write_file(&record_dir.join("attribution.yaml"), bytes)?; - true - } - None => false, - }; - write_record( - &record_dir, - module, - target_name, - &candidate.tree_hash, - attribution_present, - if no_verify { - AttributionMode::Escape - } else { - AttributionMode::Prove - }, - )?; +:::docs +## The reading verbs - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "module": module, - "outcome": "staged", - "record": display_path(workspace, &record_dir), - "target": target_name, - })) -} +`napl blame` answers where a line came from. `napl q` queries +everything the store knows with jq-style expressions. `napl fsck` +checks integrity, `napl gc` packs and sweeps, and `napl remove` ends +a module's tenancy while history keeps every prior state readable. +::: -/// The validations `--check` actually ran, in the order the pipeline runs them. -/// -/// Every one of these is a fact about the record: where the bytes may live, what -/// shape the ingest accepts, whether the recorded claims still point at -/// something, and whether the supplied evidence validates. None of them is a -/// fact about whether the code works. -fn validations_run(attribution_supplied: bool) -> Value { - json!([ - { - "detail": "every staged path lies inside the module's placement root and its owned globs, no path is a user-owned manifest, and no path is a derived entry-file projection", - "judged": true, - "name": "placement", - }, - { - "detail": "the staged bytes ingest into content-addressed blobs: no links or devices, no duplicate normalized paths, no absolute or parent-escaping path, nothing over the compiled-in size bounds", - "judged": true, - "name": "ingest", - }, - { - "detail": "every staged file the target claims as source parsed under the target's pinned tree-sitter grammar; a parse tree carrying an error node refuses as invalid-candidate naming the file, the first error's line, and the malformed region", - "judged": true, - "name": "parse gate", - "scope": crate::surface::SOURCE_SYNTAX_SCOPE, - }, - { - "detail": if attribution_supplied { - "every recorded claim was carried through the committed-to-staged and committed-to-pending diffs, and the supplied --attribution map was layered over the transported remainder and revalidated" - } else { - "every recorded claim was carried through the committed-to-staged and committed-to-pending diffs, and any claim the diffs do not prove is listed with both endpoints' verdicts" - }, - "judged": true, - "name": "attribution transport", - }, - { - "detail": "any supplied usage envelope was validated by its media type's registered validator and checked against the compiled-in evidence bounds", - "judged": true, - "name": "evidence", - }, - ]) -} +`napl status` observational classification, zero mutation, and it +judges both ends: per module it reports `drift: true` when the +committed output differs from the working tree, and the module's stale +reference count, the same derivations diff and the lsp already serve, +so status can never read clean while the query surface reads stale. +A module fresh on the prompt side and drifted on the code side says +so in one payload. `napl status --module ` answers with the +module's live state beside its plan, tracked or pending, drift, stale +reference count, attribution status, in the same payload as the owned +globs and wave position, so the command named status never answers +with a plan alone; and the payload carries the derived entry-file +projection, path and exact content, so the line a new file needs in a +shared entry file is read from the tool, never composed by hand. +Every projection path the plan mentions is labeled what it is, +tool-derived and never user-supplied, and the plan names one concrete +legal first output path (the module name under the target's source +dir), so the obvious wrong first move, hand-writing the entry file, +is warned against before it is made. The add refusal for a module +whose owned tree holds nothing stageable says the same thing: which +paths present are projections, and what the first legal file would +be. +`napl log` entries carry each transition's note count and reference +count, and the log payload names `napl show ` as where +the full note and reference text is served, so provenance is one +named hop away, never a secret; `napl show ` lists its +attached notes inline, hash and text, so a note attached is a note +findable without plumbing. -/// What `--check` deliberately did not judge. -/// -/// The dry run answers a question about staging and about syntax. Whether the -/// code type checks, borrow checks, or passes its tests is the acceptance -/// battery's question, and this command never ran it, so `would-stage` means the -/// record is sound and the syntax parses, never that the code is correct. -fn not_judged() -> Value { - json!([ - { - "detail": "whether the staged bytes compile, type check, borrow check, or pass their tests is the acceptance battery's job and nothing here ran it; the parse gate judged syntax and only syntax, so would-stage means the record is sound and the syntax parses, never that the code is correct", - "judged": false, - "name": "everything past syntax", - }, - ]) -} +::: test +name: log counts references and names show as the inspection verb +given: { workspace: fixture_two_modules, history: [commit, referenced_edit] } +expect: { log_reference_count: 1, show_named_in_payload: true } +::: -/// `napl add [...]`: stage one record per owning module. -/// -/// Each path resolves to its owning module through the committed output manifest, -/// which is unique by the commit invariant that two modules never claim one path, -/// so no `--module` is needed. A path no module owns is a usage error that names -/// the owned globs which do exist, because the useful answer to "this is not -/// owned" is "here is what is". -/// -/// `napl add --module ` is the same form with the resolution -/// declared instead of derived: a file that is new in a crate several modules -/// share matches every one of their identical globs and is committed by none of -/// them, so neither the manifest nor the globs can name an owner. The author -/// names it, and the form exists precisely so that answer is sayable. -#[allow(clippy::too_many_arguments)] -pub fn add_working_tree_paths( - workspace: &Path, - cwd: &Path, - target_name: &str, - given: &[String], - explicit_module: Option<&str>, - attribution_path: Option<&Path>, - usage_path: Option<&Path>, - no_verify: bool, - check: bool, -) -> Result { - let target = builtin_target(target_name)?; - let sources = discover_sources(workspace)?; - let store = disk::open(workspace); - let ownership = committed_ownership(&store, target_name)?; +`napl log` +per-module or store-wide history. `napl show ` inspect one object. +`napl diff` drift between committed outputs and working tree as a canonical +byte-stable patch. `napl blame ` derived ancestry: surviving lines +trace through accepted patches by canonical replay; rewritten regions blame +the rewriting transition, honestly coarse. Blame reads committed +bytes only, and says so when it matters: a blamed path whose working +tree drifts from the committed output carries a drift notice naming +`napl diff` as where the uncommitted change lives, so ancestry over +the committed bytes is never mistaken for the present file. - let mut grouped: BTreeMap> = BTreeMap::new(); - let mut prompts: Vec = Vec::new(); - for raw in given { - let path = workspace_relative(workspace, cwd, raw); - // A module's own prompt path is the one path add accepts that no owned - // glob names, and it is accepted for exactly one purpose: recording a - // docs edit. Naming it under `--module` is a different sentence and stays - // an unowned path, because the docs door is the path alone. - if explicit_module.is_none() { - if let Some(prompt) = sources.iter().find(|source| source.path == path) { - prompts.push(prompt.clone()); - continue; - } - } - let owner = match explicit_module { - Some(named) => Some(named.to_string()), - None => ownership - .get(&path) - .cloned() - .or_else(|| sole_glob_owner(&target, &sources, &path, &ownership)), - }; - let Some(owner) = owner else { - return Err(unowned_path(&target, &sources, &path)); - }; - grouped.entry(owner).or_default().push(path); - } +::: test +name: blame over a drifted path names the drift and points at diff +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched, blame_path: drifted_file } +expect: { drift_noticed: true, diff_named: true, ancestry_served: true } +::: - let mut records: Vec = Vec::new(); - for prompt in &prompts { - if attribution_path.is_some() || usage_path.is_some() { - return Err(crate::cli::usage(&format!( - "{:?} is module {:?}'s prompt, and a prompt path records a docs edit: the new prompt bytes and nothing else, no attribution work and no usage. Drop --attribution and --usage, or stage code with `napl add --module {}`", - prompt.path, prompt.module, prompt.module - ))); - } - records.push(add_docs(workspace, target_name, prompt, check)?); - } - for (module, paths) in grouped { - records.push(add( - workspace, - target_name, - &module, - &AddSource::Overlay(paths), - attribution_path, - usage_path, - no_verify, - check, - )?); - } - Ok(staged_payload(target_name, records)) -} +`napl lint` unreachable modules +under an explicit `roots` declaration; when roots are absent the refusal +is loud, exit 2 with canonical JSON naming the missing prerequisite and +the exact declaration that would satisfy it, never an empty findings +list under exit 0; unconsumed surface entries (diagnosis only), module staleness; +findings are proposals with cascade previews; `napl lint --fix` applies +only complete deterministic mapping-safe edits through the guarded commit +path and never removes lines. `napl lint ` is the +source-language scan, a different question needing no roots: it parses +every committed module's owned working-tree file under that target +with the pinned grammar and reports one finding per file that fails, +naming the file and the first error's line. Every lint payload names +the scope it judged, graph or source syntax, so an empty findings +list is never read wider than the scan that produced it. Source +syntax findings are facts, not proposals: a target scan carrying any +finding exits 1, so automation can never read broken source as +success, and a clean scan exits 0; the graph scope keeps its +advisory exit. -/// What one prompt file has done to the committed prompt it is judged against. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PromptDrift { - /// The two files are byte-identical, prose included. There is no docs edit - /// to record. - Unchanged, - /// Prose moved inside the docs fences and nothing else did: the one drift a - /// docs record carries. - DocsOnly, - /// The module state names no prompt object, so the store holds no committed - /// copy of the file. Nothing can be called drift against a baseline that does - /// not exist, and the module's next ordinary commit records one. - NoBaseline, - /// The frontmatter moved. `deps`, `targets`, `roots` and `tests` all feed - /// identities the module is keyed on, so this is a module change wearing a - /// docs edit's clothes. - Frontmatter { - line: u32, - committed: String, - working: String, - }, - /// The drift reached outside the docs fences. The line is the first semantic - /// body line that moved, in the coordinate system attribution endpoints name. - Semantic { - line: u32, - committed: String, - working: String, - }, -} +::: test +name: lint of a target exits nonzero on source findings and zero when clean +given: { workspace: fixture_two_modules, runs: [clean_tree, syntactically_invalid_owned_file], lint: target_form } +expect: { clean_exit_code: 0, broken_exit_code: 1, findings_when_broken: 1 } +::: -/// Judge one prompt file against a committed baseline. -/// -/// Two byte strings have to hold still for a docs edit to be a docs edit: the -/// frontmatter, because `deps` feeds the input key and `tests` feeds the case -/// set, and the semantic body, because it is what every identity derivation -/// consumes. Prose is what is left, and prose is all a docs record may move. The -/// judgment is a pure function of the two files, so `add`, the empty-commit -/// teacher, `status` and the install recheck can never disagree about it. -pub fn judge_prompt(path: &str, committed: &str, working: &str) -> Result { - if committed == working { - return Ok(PromptDrift::Unchanged); - } - let committed_front = crate::source::frontmatter_text(path, committed)?; - let working_front = crate::source::frontmatter_text(path, working)?; - if committed_front != working_front { - let (line, was, is) = first_difference(&committed_front, &working_front); - return Ok(PromptDrift::Frontmatter { - line, - committed: was, - working: is, - }); - } - let committed_body = crate::source::parse_source(path, committed)?.normalized_body; - let working_body = crate::source::parse_source(path, working)?.normalized_body; - if committed_body != working_body { - let (line, was, is) = first_difference(&committed_body, &working_body); - return Ok(PromptDrift::Semantic { - line, - committed: was, - working: is, - }); - } - Ok(PromptDrift::DocsOnly) -} +::: test +name: lint of a target reports a parse finding per malformed working-tree file +given: { workspace: fixture_two_modules, working_tree: syntactically_invalid_owned_file, lint: target_form } +expect: { findings: 1, error_names_file_and_line: true, scope_named: source_syntax, roots_required: false } +::: -/// Judge the working-tree prompt of one module against its committed state, -/// answering the verdict beside the state key the verdict was reached against. -/// -/// That key is the base a docs record names. Reading it here and carrying it on -/// the record is what makes the install recheck a real one: a transition names -/// its base and fails on mismatch rather than recalculating a base from whatever -/// the head happens to be when commit runs. -fn prompt_drift( - store: &Store, - target_name: &str, - prompt: &SourceModule, -) -> Result<(PromptDrift, String)> { - let Some(state) = crate::porcelain::committed_state(store, target_name, &prompt.module)? else { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "module {:?} has no committed state for target {target_name:?}, so there is no committed prompt for a docs edit to differ from; commit the module first with `napl add --module {}`", - prompt.module, prompt.module - ), - )); - }; - let base_state_key = crate::objects::module_state_hash(&state); - let Some(object) = &state.prompt else { - return Ok((PromptDrift::NoBaseline, base_state_key)); - }; - let committed = committed_prompt(store, object, &prompt.module)?; - let working = std::fs::read_to_string(store.workspace.join(&prompt.path)) - .map_err(|e| disk::io(&store.workspace.join(&prompt.path), &e))?; - Ok(( - judge_prompt(&prompt.path, &committed, &working)?, - base_state_key, - )) -} +::: test +name: lint of a target over a clean tree answers empty and names its scope +given: { workspace: fixture_two_modules, lint: target_form } +expect: { findings: 0, scope_named: source_syntax, exit_code: 0 } +::: -/// The committed prompt file a state's prompt object names. -pub fn committed_prompt(store: &Store, object: &str, module: &str) -> Result { - String::from_utf8(get_blob(store, object)?).map_err(|_| { - error( - OutcomeCode::StoreIntegrity, - format!("the committed prompt of module {module:?} is not valid UTF-8"), - ) - }) -} +`napl cost` pure calculation over an explicit +receipt set and a pinned pricing-table provenance object; unknown is +unknown, never zero. Edits price the way generations price: records the +capture verbs staged enter the same receipt set, their session-window +usage references resolve at read time against the named log's bytes, +summing the window's lines under the same pinned pricing table, and +`napl cost --session ` reports one session's calls directly from +the log. A record without a window prices `unavailable`; a window whose +log file or session lines are absent prices `unavailable` and says +which; a model the pricing table does not name prices unknown; nothing +is ever silently zero and nothing is aggregated at record time. The +same receipt set, log bytes, and pricing table produce byte-identical +reports. `napl gc` stages sweeps outside locks, swaps pointers +inside the bounded section, never blocks writers, never deletes anything +reachable from a named head. `napl gc --apply` also repacks: the +reachable closure lands in one pack beside its index under +`objects/pack/` through the store's repack operation, loose files and +older packs are removed after the pack is durable, and the payload +names the pack written, the objects packed, and the files removed. +The dry `napl gc` reports what a repack would write. Every reachable +read answers identically before and after a repack, unreachable +objects are swept with the sweep it subsumes, fsck is healthy after +exactly when it was healthy before, and publication keeps writing +loose objects, so a store is always the sum of its packs plus its +loose files. A repack runs under its own objects lock, which buys +exactly one thing: two repacks, or a repack and a sweeping gc, never +rewrite the layout at once. Publication takes no lock by design, so +a publisher racing a repack can still lose a just-written loose +object before any snapshot names it; that window is open, named +here, and closes only when the store learns a waiting lock. The +objects lock holds no invariant a crashed holder could corrupt, the +pack being staged and linked into place, so an abandoned one is +stolen, never a wedge. Nobody is +required to know any of this: every successful commit runs the same +repack automatically, after the critical section, and reports what +it packed under a `repack` key in the commit payload. On 2026-08-03 +the owner ruled the loose-threshold out and every-commit in, because +the store a reader sees is then always three paths, one pack, its +index, and HEAD, and a pull request's store footprint is always the +same three quiet lines; loose objects exist only inside the window +between a publication and the commit that lands it. The pack a +commit writes is byte-identical to the one gc would have written +over the same closure. gc remains the explicit verb for sweeping +without committing. Reachable means the transitive closure: +from every named head, through every event in its chain, through every +object any reachable object references by hash, output trees, +surfaces, attribution records, prompt blobs, receipts, at every +contract version history carries. `fsck`'s definition of a present +object IS gc's definition of a kept one; a store that was healthy +before a sweep is healthy after it, and blame replays the same +ancestry after a sweep as before. An edge gc does not understand is a +reason to keep, never to sweep. -/// The first line two bodies disagree on, one-based, beside both spellings. -/// -/// A body that is a prefix of the other disagrees at the first line past the -/// shorter one, so a pure insertion or deletion names a line rather than nothing. -fn first_difference(committed: &str, working: &str) -> (u32, String, String) { - let left: Vec<&str> = committed.lines().collect(); - let right: Vec<&str> = working.lines().collect(); - for index in 0..left.len().max(right.len()) { - let a = left.get(index).copied().unwrap_or_default(); - let b = right.get(index).copied().unwrap_or_default(); - if a != b { - return (index as u32 + 1, a.to_string(), b.to_string()); - } - } - (left.len().max(right.len()) as u32, String::new(), String::new()) -} +::: test +name: gc apply on a workspace with history leaves fsck healthy and blame replaying +given: { workspace: fixture_with_multi_commit_history, gc: apply } +expect: { fsck_findings: 0, blame_replays_history: true, head_intact: true } +::: -/// `napl add `: stage a docs record. -/// -/// The record is the new prompt bytes and nothing else: no candidate tree, no -/// attribution work, no usage. Commit installs it by writing the new prompt -/// object and re-pointing the module's prompt at it, and every derived fact -/// holds. Drift that reaches outside the docs fences refuses here rather than at -/// commit, naming the first semantic line that moved, because a docs record that -/// smuggled a semantic edit would be an unreviewed module change. -fn add_docs( - workspace: &Path, - target_name: &str, - prompt: &SourceModule, - check: bool, -) -> Result { - let store = disk::open(workspace); - let (drift, base_state_key) = prompt_drift(&store, target_name, prompt)?; - match drift { - PromptDrift::Unchanged => { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "{:?} matches the committed prompt of module {:?} byte for byte, so there is no docs edit to record", - prompt.path, prompt.module - ), - )); - } - PromptDrift::NoBaseline => { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "module {:?} names no committed prompt object, so the store holds no copy of {:?} to prove a docs edit against; its next ordinary commit records that baseline and the docs door opens", - prompt.module, prompt.path - ), - )); - } - PromptDrift::Frontmatter { - line, - committed, - working, - } => { - return Err(crate::error::error_with( - OutcomeCode::InvalidCandidate, - format!( - "{:?} changed module {:?} in its frontmatter: line {line} moved from {committed:?} to {working:?}. The frontmatter declares dependencies, targets, roots and cases, every one of which the module is keyed on, so this is not a docs edit; stage a real change with `napl add --module {}`", - prompt.path, prompt.module, prompt.module - ), - json!({ - "committedLine": committed, - "frontmatterLine": line, - "module": prompt.module, - "promptPath": prompt.path, - "teaches": format!("napl add --module {}", prompt.module), - "workingLine": working, - }), - )); - } - PromptDrift::Semantic { - line, - committed, - working, - } => { - return Err(crate::error::error_with( - OutcomeCode::InvalidCandidate, - format!( - "{:?} changed module {:?} outside its docs fences: semantic body line {line} moved from {committed:?} to {working:?}. A prompt path records a docs edit and nothing else; stage a real change with `napl add --module {}`", - prompt.path, prompt.module, prompt.module - ), - json!({ - "committedLine": committed, - "module": prompt.module, - "promptPath": prompt.path, - "semanticLine": line, - "teaches": format!("napl add --module {}", prompt.module), - "workingLine": working, - }), - )); - } - PromptDrift::DocsOnly => {} - } +::: test +name: gc apply repacks the reachable closure into one pack and the store still answers +given: { workspace: fixture_with_multi_commit_history, gc: apply } +expect: { packs: 1, loose_reachable_remaining: 0, all_commands_answer_identically: true, fsck_findings: 0 } +::: - let bytes = std::fs::read(workspace.join(&prompt.path)) - .map_err(|e| disk::io(&workspace.join(&prompt.path), &e))?; - if check { - return Ok(json!({ - "checked": true, - "contractVersion": crate::version::CONTRACT_VERSION, - "kind": DOCS_RECORD, - "module": prompt.module, - "notJudged": not_judged(), - "outcome": "would-stage", - "promptPath": prompt.path, - "staged": false, - "target": target_name, - "validations": docs_validations(), - })); - } +::: test +name: "every successful commit repacks and leaves exactly one pack, its index, and no reachable loose" +given: { workspace: fixture_with_multi_commit_history, then: valid_commit } +expect: { commit_outcome: installed, repack_reported: true, packs: 1, loose_reachable_remaining: 0 } +::: - let object = - napl_store::store_hash::object_name(napl_store::store_hash::ObjectKind::Blob, &bytes); - // One module has at most one staged docs record. Two edits in a row would - // otherwise leave two records keyed by two different prompt hashes, and a - // bare commit would install them in whatever order their names sort in, - // letting the older prose win. Re-running add replaces the record it - // supersedes, exactly as re-running add over a candidate does. - sweep_docs_records(workspace, &prompt.module, target_name)?; - let record_dir = record_dir(workspace, &prompt.module, &object); - if record_dir.exists() { - std::fs::remove_dir_all(&record_dir).map_err(|e| disk::io(&record_dir, &e))?; - } - // The staged bytes are the whole prompt file, so commit re-parses what add - // read rather than trusting a body add derived: the ingest law holds for the - // docs door exactly as it holds for the other three. - write_file(&record_dir.join(DOCS_PROMPT_FILE), &bytes)?; - let record = json!({ - "baseStateKey": base_state_key, - "kind": DOCS_RECORD, - "module": prompt.module, - "prompt": object, - "promptPath": prompt.path, - "receiptVersion": crate::receipt::RECEIPT_VERSION, - "target": target_name, - }); - write_file( - &record_dir.join("record.json"), - &crate::canon::canonical_bytes(&record), - )?; - Ok(json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "kind": DOCS_RECORD, - "module": prompt.module, - "outcome": "staged", - "promptPath": prompt.path, - "record": display_path(workspace, &record_dir), - "target": target_name, - })) -} +::: test +name: "a docs-only commit repacks through the same seam and leaves one pack, its index, and HEAD" +given: { workspace: fixture_with_multi_commit_history, then: docs_only_commit } +expect: { commit_kind: docs-only, repack_reported: true, packs: 1, loose_reachable_remaining: 0, store_paths: [pack, idx, HEAD] } +::: -/// Remove every staged docs record for one module and target. -fn sweep_docs_records(workspace: &Path, module: &str, target_name: &str) -> Result<()> { - for record in staged_records(workspace)? { - if record.module != module || record.target != target_name { - continue; - } - if record_meta(&record.path)?["kind"].as_str() != Some(DOCS_RECORD) { - continue; - } - sweep_record(workspace, &record.path)?; - } - Ok(()) -} +::: test +name: two commits in a row each land the whole closure in one pack and the store answers identically +given: { workspace: fixture_with_multi_commit_history, then: [valid_commit, valid_commit] } +expect: { packs: 1, all_commands_answer_identically: true, fsck_findings: 0 } +::: -/// The `kind` a docs record carries, the one spelling commit branches on. -pub const DOCS_RECORD: &str = "docs"; +::: test +name: the automatic repack writes the byte-identical pack gc would have written +given: { workspace: fixture_with_multi_commit_history, compare: [auto_repack_at_commit, explicit_gc_apply] } +expect: { pack_bytes_equal: true, pack_names_equal: true } +::: -/// The staged prompt file inside a docs record directory. -pub const DOCS_PROMPT_FILE: &str = "prompt.napl"; +On 2026-08-03 the owner reversed the 2026-07-31 git-store ruling: the +store's one home is the working tree, in every workspace, git or not. +The end user's story decided it: adopting napl must mean committing +files, never adopting hook managers, CI fetch wiring, or branch +policies. The `napl store migrate` verb is retired, the git-backed +provider is deleted with its module, and the tracked layout with its +packfile mitigation is promoted from floor to the only shape. What +the side-ref bought is repaid in kind: pull-request silence by the +`.gitattributes` lines init writes, store objects and packs marked +generated, undiffed, and never text, because an eol-converting +checkout would corrupt every envelope against its own name; append-only +not by construction but by fsck, which counts every reachable object +and reports absence loudly; and merging by git's ordinary file +semantics, safe for objects because they are content-addressed, +identical on both sides or absent, never in conflict. The one pointer +that can conflict is `HEAD`, so init marks it binary as well: a merge +never writes conflict markers into it, git holds it unmerged with one +side's hash in place, and resolving is choosing which snapshot the +merged branch is on, safe in either direction because a merge's store +holds both sides' objects. -/// The validations a docs record's dry run actually ran. -fn docs_validations() -> Value { - json!([ - { - "detail": "the working-tree prompt parses, and its docs fences validate under the docs vocabulary", - "judged": true, - "name": "prompt grammar", - }, - { - "detail": "the semantic body is byte-identical to the committed one, so no identity the module is keyed on moved", - "judged": true, - "name": "docs-only drift", - }, - ]) -} +A workspace inside a git repository that carries a +`refs/heads/napl-store` ref while its `.napl/store/objects/` holds no +objects is a migrated legacy clone, and every store-writing verb +refuses it with the road back, verbatim: create the objects directory, +check out the store branch's tree into it, commit it on the code +branch, delete the ref locally and on every remote after lifting any +delete protection the old teaching installed. The refusal names the +exact commands and touches nothing; the reader performs them with +ordinary git. The same ref beside a store that already holds objects +is only stale wiring, and the refusal says exactly that: delete the +ref, nothing else. -/// The payload every `add` form reports. -/// -/// `records` is the whole truth, one entry per staged module. A single-record -/// invocation also states its module and record at the top level, because that is -/// what a caller pipes straight into `commit --staged`. -pub fn staged_payload(target_name: &str, records: Vec) -> Value { - let mut payload = serde_json::Map::new(); - payload.insert( - "contractVersion".into(), - json!(crate::version::CONTRACT_VERSION), - ); - // A dry run reports the verdict it would have staged under, never `staged`, - // because nothing was. - let checked = records - .iter() - .all(|record| record["checked"].as_bool().unwrap_or(false)); - payload.insert( - "outcome".into(), - json!(if checked && !records.is_empty() { - "would-stage" - } else { - "staged" - }), - ); - payload.insert("staged".into(), json!(!checked || records.is_empty())); - payload.insert("target".into(), json!(target_name)); - if records.len() == 1 { - payload.insert("module".into(), records[0]["module"].clone()); - if let Some(record) = records[0].get("record") { - payload.insert("record".into(), record.clone()); - } - // A dry run's verdict is the whole point of the invocation, so it is - // read at the top level and never dug out of the array. - for key in ["validations", "notJudged"] { - if let Some(value) = records[0].get(key) { - payload.insert(key.into(), value.clone()); - } - } - } - payload.insert("records".into(), Value::Array(records)); - Value::Object(payload) -} +::: test +name: "store-writing verbs refuse a legacy clone, ref present and objects absent, and name the recipe" +given: { workspace: fixture_two_modules, git_repo: with_legacy_napl_store_ref, store_objects: absent, then: valid_commit_attempt } +expect: { outcome: refused, nothing_written: true, recipe_names_mkdir_checkout_commit_ref_delete: true } +::: -/// Resolve a path as the caller spelled it to a workspace-relative path. -/// -/// A path already spelled relative to the workspace wins, so the spelling every -/// napl payload uses round-trips. Otherwise it is resolved against the working -/// directory, which is what a shell completion produces. -fn workspace_relative(workspace: &Path, cwd: &Path, given: &str) -> String { - let normalized = given.replace('\\', "/"); - let direct = normalized.trim_start_matches("./").to_string(); - if workspace.join(&direct).exists() { - return direct; - } - let absolute = if Path::new(given).is_absolute() { - PathBuf::from(given) - } else { - cwd.join(given) - }; - match absolute.strip_prefix(workspace) { - Ok(rest) => rest.to_string_lossy().replace('\\', "/"), - Err(_) => direct, - } -} +::: test +name: "a stale ref beside a store that holds objects refuses with delete-the-ref alone, never the migration" +given: { workspace: fixture_two_modules, git_repo: with_legacy_napl_store_ref, store_objects: present, then: valid_commit_attempt } +expect: { outcome: refused, teaches_ref_delete_only: true, migration_recipe_absent: true } +::: -/// The committed `path -> module` ownership map for one target. -fn committed_ownership(store: &Store, target_name: &str) -> Result> { - let mut out: BTreeMap = BTreeMap::new(); - let Some(head) = store.head()? else { - return Ok(out); - }; - for ((state_target, module), hash) in snapshot_states(store, &head)? { - if state_target != target_name { - continue; - } - let state = read_module_state(store, &hash)?; - if !crate::store_view::has_object(store, &state.output_tree) { - continue; - } - for path in flatten_tree(store, &state.output_tree)?.into_keys() { - out.insert(path, module.clone()); - } - } - Ok(out) -} +::: test +name: a workspace whose legacy ref was materialized and deleted answers status and fsck clean +given: { workspace: fixture_two_modules, history: [legacy_ref_materialized_in_tree, ref_deleted] } +expect: { status_answers: true, fsck_findings: 0, objects_readable: true } +::: -/// The module a not-yet-committed path belongs to, when exactly one module's -/// owned globs match it and no module has committed it. -/// -/// Where several prompts share one crate their owned globs are identical, so this -/// answers `None` rather than picking one: a new file in a shared crate is a -/// declaration the author has to make, through `--module` or an explicit -/// candidate directory. -fn sole_glob_owner( - target: &TargetDef, - sources: &[SourceModule], - path: &str, - ownership: &BTreeMap, -) -> Option { - if ownership.contains_key(path) { - return None; - } - let mut matched: Vec<&SourceModule> = sources - .iter() - .filter(|source| source.targets.iter().any(|t| t == &target.name)) - .filter(|source| is_owned_path(target, &placement_root(source), path)) - .collect(); - matched.dedup_by(|a, b| a.module == b.module); - match matched.as_slice() { - [only] => Some(only.module.clone()), - _ => None, - } -} +`napl fsck` envelope hashes, kind validity, +causal parent links. `napl restore` deterministic repair plans from +persisted evidence; unambiguous moves install, ambiguity blocks; liveness +and wall time never decide recovery. `napl mv ` relocation +as a semantic operation: moves a module's installed files to a new +placement, recomputes every placement-derived fact (derived entry-file +projections, attribution file paths) from the destination, passes +through the guarded commit path, and re-verifies there; failed +verification at the destination installs nothing; manifests are the +user's to update, and status diagnoses a placement the workspace +manifest no longer lists. A move made outside `napl mv` is drift like any other: +the store still holds the committed bytes, `napl restore` re-materializes +the module at its recorded placement, and when the drifted content matches +a committed state exactly the plan may instead adopt the new placement +through the commit path; anything ambiguous blocks. `napl remove +` ends a module's tenancy as a semantic operation through the +same guarded door: it publishes a new snapshot without the module's +state, deletes the installed files the module owned, re-derives every +projection that mentioned it (a crate's derived entry file stops +naming the module the moment no committed state owns its files), and +closes the module's attribution record. It refuses when another +committed module depends on the one leaving, naming the dependents; +it refuses an unknown module; and it refuses when the module's +working tree diverges from its committed state, naming the drift, +because what is removed is the committed module, never unseen edits. +The prompt file is the user's, like the manifest: remove reports it +as ready to delete and touches neither. Removal removes tenancy, +never history: the store is append-only, every prior snapshot still +holds the module's states and objects, and the read verbs answer +about them exactly as before. -/// The refusal a path no module owns earns: a usage error that lists the owned -/// globs which do exist, so the reader learns the shape of what is ownable -/// instead of only that this was not. -fn unowned_path( - target: &TargetDef, - sources: &[SourceModule], - path: &str, -) -> crate::error::CompilerError { - let mut globs: BTreeSet = BTreeSet::new(); - for source in sources { - if !source.targets.iter().any(|t| t == &target.name) { - continue; - } - for glob in owned_globs(target, &placement_root(source)) { - globs.insert(glob); - } - } - let listed: Vec = globs.into_iter().collect(); - crate::cli::usage(&format!( - "no module owns {path:?}, so there is nothing to stage it as. The owned globs that exist are: {}", - listed.join(", ") - )) -} +::: test +name: "removing a leaf module drops its state, deletes its files, and re-derives the entry projection" +given: { workspace: fixture_two_modules, then: remove_leaf_module } +expect: { outcome: installed, module_files_deleted: true, snapshot_lacks_module_state: true, entry_projection_re_derived: true, fsck_findings: 0 } +::: -/// Turn one add source into the entry list ingestion judges. -fn scan_source( - workspace: &Path, - store: &Store, - target: &TargetDef, - module: &str, - sources: &[SourceModule], - source: &AddSource, -) -> Result> { - match source { - AddSource::CandidateDir(dir) => scan_candidate(dir), - AddSource::Overlay(paths) => { - let mut staged = committed_output(store, &target.name, module)?; - if staged.is_empty() { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "module {module:?} has no committed output to overlay onto; stage the whole tree with --module {module} or an explicit candidate directory" - ), - )); - } - for path in paths { - match std::fs::read(workspace.join(path)) { - Ok(bytes) => { - staged.insert(path.clone(), bytes); - } - // A named path that is gone is a deletion, which is a real - // staged state and not an error. - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - staged.remove(path); - } - Err(e) => return Err(disk::io(&workspace.join(path), &e)), - } - } - Ok(entries_from(&staged)) - } - AddSource::WorkingTree => { - let committed = committed_output(store, &target.name, module)?; - let mut staged: BTreeMap> = BTreeMap::new(); - for path in committed.keys() { - match std::fs::read(workspace.join(path)) { - Ok(bytes) => { - staged.insert(path.clone(), bytes); - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(disk::io(&workspace.join(path), &e)), - } - } - for path in unclaimed_working_paths(workspace, store, target, module, sources)? { - let bytes = std::fs::read(workspace.join(&path)) - .map_err(|e| disk::io(&workspace.join(&path), &e))?; - staged.insert(path, bytes); - } - if staged.is_empty() { - return Err(nothing_stageable(workspace, target, module, sources)); - } - Ok(entries_from(&staged)) - } - } -} +::: test +name: remove refuses a module another committed module depends on and names the dependents +given: { workspace: fixture_two_modules_dependent, then: remove_the_dependency } +expect: { outcome: refused, dependents_named: true, nothing_written: true } +::: -/// The refusal for a module whose owned tree holds nothing stageable. -/// -/// The usual cause is that the only file under the crate's source directory is -/// the entry file, which is a derived projection this module may not own. A -/// refusal that says "owns nothing" while a file plainly sits there reads as a -/// bug, so it names which present paths are projections and what the first legal -/// file would be. The obvious wrong next move, hand-writing the entry file, is -/// warned against here rather than one rejection later. -fn nothing_stageable( - workspace: &Path, - target: &TargetDef, - module: &str, - sources: &[SourceModule], -) -> crate::error::CompilerError { - let root = sources - .iter() - .find(|s| s.module == module) - .map(placement_root) - .unwrap_or_default(); - let present: Vec = crate::porcelain::projection_labels(target, &root) - .into_iter() - .filter(|label| { - label["path"] - .as_str() - .is_some_and(|path| workspace.join(path).is_file()) - }) - .collect(); - let first = crate::porcelain::first_output(target, &root, module); - let hint = match first["path"].as_str() { - Some(path) => format!( - "; the first legal file would be {path:?}, and the crate's entry file is not one of this module's to write" - ), - None => String::new(), - }; - crate::error::error_with( - OutcomeCode::InvalidCandidate, - format!( - "module {module:?} owns nothing stageable in the working tree yet{hint}. Write an owned file, or stage an explicit candidate directory" - ), - json!({ - "firstOutput": first, - "ownedGlobs": owned_globs(target, &root), - "placementRoot": root, - "projectionsPresent": present, - }), - ) -} +::: test +name: remove refuses drift and a removed module's history stays readable +given: { workspace: fixture_two_modules, runs: [remove_with_drifted_file, remove_clean_then_read_history] } +expect: { drifted_outcome: refused, drift_named: true, history_tree_at_readable: true, prior_snapshots_hold_module: true } +::: + +`napl cat-file ` +raw plumbing. `napl lsp` a language server over stdio for editor +extensions: hover, definition, references, and code lens over the +prompt-to-code mapping. Everything it serves derives from committed +records (attribution entries, module surfaces, the ir) resolved against +the workspace root; hover on a prompt line presents the attributed +generated spans with their notes, hover on a generated line presents the +owning prompt lines and note, definition jumps across the mapping in both +directions, references enumerates every attributed span, code lens counts +attributed files per prompt section. The server publishes diagnostics +derived from reference staleness: for every stale reference it emits one +diagnostic on each endpoint, the prompt span whose code moved and the +code span whose prompt changed, so sync debt is visible where the edit +happens, like a type error for documentation. Publication is a pure +derivation from committed records plus the working tree at the moment +the protocol asks, no background mutation, no clocks, no scanning loops +beyond what the editor protocol requires; opening or changing a +document refreshes that document's diagnostics and its counterparts'. +Severity is warning, never error: drift is a legal state, marked, not +policed. The diagnostic message states exactly what is known and +nothing more: which end drifted, the reference's direction, recorded +model or `unavailable`, referentially valid, and the healing verbs +(re-record through `napl edit`, or restore); no wording implies the +sentence describes the code. A fresh reference publishes nothing. +Resolving is always a human or LLM re-affirming or re-pointing the +reference through the capture verbs; the server proposes nothing and +repairs nothing. Pure reads only: no mutation, no +clocks, no background scanning. A request whose records are missing or +unparseable answers empty rather than erroring or blocking, and missing +or unparseable records degrade to no diagnostics, never an error; a stale +mapping is served marked stale, consistent with the attribution contract; +the server never hangs on a malformed or absent record. Record resolution is +anchored per document: walk up from the document's filesystem path to the +nearest ancestor directory containing `.napl` and read records there; the +server's working directory and the client's rootUri carry no meaning. +Every location the server emits that points at a module's prompt resolves +to the prompt file's actual placement found by workspace prompt discovery, +where the prompt sits inside the crate directory its code lands in; a +module name is never joined against the workspace root to fabricate a +prompt path, and a module whose prompt cannot be discovered answers empty. +Crash safety: every persisted application step leaves markers such that +recovery derives a deterministic plan from content alone. Evidence +validators are a registry keyed by media type (Claude CLI JSON envelope +ships first, and `application/x-napl-usage-proxy-jsonl-v1` validates +usage-proxy logs: one canonical JSON object per line, required fields +present, absent values the string `unavailable`, token counts +non-negative integers when present, a line failing the shape is +`invalid`); outcomes `validated`, `unsupported`, `invalid`, +`validator-error`; invalid stays visible as failed. Evidence bounds are +compiled-in constants; exceeding any is `resource-limit` before anything +publishes. -/// Working-tree files under a module's owned globs that no module has committed -/// and no other module's globs also match: files this module owns by elimination. -pub fn unclaimed_working_paths( - workspace: &Path, - store: &Store, - target: &TargetDef, - module: &str, - sources: &[SourceModule], -) -> Result> { - let ownership = committed_ownership(store, &target.name)?; - let Some(source) = sources.iter().find(|s| s.module == module) else { - return Ok(Vec::new()); - }; - let root = placement_root(source); - let src_dir = crate::paths::join_root(&root, &target.source_dir); - let dir = workspace.join(&src_dir); - if !dir.is_dir() { - return Ok(Vec::new()); - } - let mut out: Vec = Vec::new(); - for entry in scan_candidate(&dir)? { - if entry.kind != EntryKind::File { - continue; - } - let path = crate::paths::join_root(&src_dir, &entry.path); - if !is_owned_path(target, &root, &path) { - continue; - } - if sole_glob_owner(target, sources, &path, &ownership).as_deref() == Some(module) { - out.push(path); - } - } - Ok(out) -} +::: test +name: a stale reference publishes one diagnostic on each endpoint +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, lsp: [open_prompt, open_code] } +expect: { prompt_diagnostics: 1, code_diagnostics: 1, severity: warning } +::: -fn entries_from(files: &BTreeMap>) -> Vec { - files - .iter() - .map(|(path, bytes)| ScannedEntry { - path: path.clone(), - kind: EntryKind::File, - bytes: bytes.clone(), - }) - .collect() -} +::: test +name: a fresh reference publishes nothing +given: { workspace: fixture_two_modules, lsp: [open_prompt, open_code] } +expect: { prompt_diagnostics: 0, code_diagnostics: 0 } +::: -/// A module's committed output as `path -> bytes`. -fn committed_output( - store: &Store, - target_name: &str, - module: &str, -) -> Result>> { - let mut out: BTreeMap> = BTreeMap::new(); - let Some(state) = crate::porcelain::committed_state(store, target_name, module)? else { - return Ok(out); - }; - if !crate::store_view::has_object(store, &state.output_tree) { - return Ok(out); - } - for (path, (blob, _mode)) in flatten_tree(store, &state.output_tree)? { - out.insert(path, get_blob(store, &blob)?); - } - Ok(out) -} +::: test +name: "the diagnostic message carries drifted end, direction, and provenance and nothing semantic" +given: { workspace: fixture_two_modules, working_tree_edit: referenced_prompt_line_touched, lsp: [open_code] } +expect: { message_names_drifted_end: true, message_names_direction: true, message_model_or_unavailable: true, message_semantic_claims: 0 } +::: -/// The wire spelling of an attribution mode on a staged record. -pub fn mode_slug(mode: AttributionMode) -> &'static str { - match mode { - AttributionMode::Prove => "prove", - AttributionMode::PreProven => "pre-proven", - AttributionMode::Escape => "escape", - } -} +::: test +name: absent or unparseable records publish no diagnostics and answer empty +given: { workspace: fixture_two_modules, records: attribution_file_corrupted, lsp: [open_prompt] } +expect: { diagnostics: 0, error_responses: 0, server_alive: true } +::: -/// Write the staged record's metadata: what commit needs to find the pieces and -/// to know how the attribution update was meant to be judged. -/// -/// The mode rides the record rather than the command line so that a staged -/// record commits the same way whoever picks it up. A record written before -/// modes existed carries only `noVerify` and reads as `escape` or `prove`. -pub fn write_record( - record_dir: &Path, - module: &str, - target_name: &str, - tree_hash: &str, - attribution_present: bool, - mode: AttributionMode, -) -> Result<()> { - let record = json!({ - "attribution": attribution_present, - "candidateTreeHash": tree_hash, - "mode": mode_slug(mode), - "module": module, - "noVerify": mode == AttributionMode::Escape, - "receiptVersion": crate::receipt::RECEIPT_VERSION, - "target": target_name, - }); - write_file( - &record_dir.join("record.json"), - &crate::canon::canonical_bytes(&record), - ) -} +::: test +name: committing the drifted code clears both endpoints' diagnostics on refresh +given: { workspace: fixture_two_modules, working_tree_edit: referenced_code_line_touched, then: commit_module, lsp: [change_code_document] } +expect: { prompt_diagnostics: 0, code_diagnostics: 0 } +::: -/// The workspace-relative directory a staged record for one candidate lives in. -pub fn record_dir(workspace: &Path, module: &str, tree_hash: &str) -> PathBuf { - workspace.join(STAGING_DIR).join(format!( - "{}-{}", - crate::paths::encode_name(module), - &tree_hash[..16] - )) -} +::: test +name: diff renders drift between committed output and working tree as a canonical patch +given: { workspace: fixture_two_modules, working_tree_edit: consumer_output_touched } +expect: { diff_nonempty: true, canonical_patch: true, byte_stable: true } +::: -/// The workspace-relative spelling of a path, the form `add` reports and -/// `commit --staged` accepts. -pub fn relative_record(workspace: &Path, path: &Path) -> String { - display_path(workspace, path) -} +::: test +name: "blame over a whole-file regeneration coarsens to the transition, line ancestry survives accepted patches" +given: { workspace: fixture_two_modules, history: [patch_commit, full_rewrite_commit] } +expect: { patched_lines_trace_to_patch: true, rewritten_region_blames_transition: true } +::: -/// Resolve the attribution update that rides this stage. -/// -/// The order is transport first, affirmation second, refusal last. When the -/// diffs carry every recorded claim, nothing is asked for: the paperwork updates -/// itself as a consequence of the diff already being content-addressed on both -/// sides. When they do not, the listed claims are what `--attribution` affirms, -/// and an explicit map that covers the whole record still wins whole, because an -/// author reshaping a mapping is allowed to say so outright. -#[allow(clippy::too_many_arguments)] -fn resolve_attribution( - workspace: &Path, - store: &Store, - target: &TargetDef, - root: &str, - module: &str, - prompt: &SourceModule, - candidate: &Candidate, - attribution_path: Option<&Path>, - no_verify: bool, -) -> Result>> { - let body_lines = prompt.normalized_body.lines().count() as u32; - let supplied = match attribution_path { - Some(path) => Some(std::fs::read(path).map_err(|e| disk::io(path, &e))?), - None => None, - }; - if no_verify { - // The escape hatch proves nothing and says so: the record installs - // marked stale, and an absent map stays absent. - return Ok(supplied); - } +::: test +name: lint reports an unreachable module only under declared roots and refuses without them +given: { workspace: fixture_orphan_module, roots: absent } +expect: { unreachable_analysis: refused, exit_code: 2, reason_names_roots: true, findings_field_absent: true } +::: - let transported = transport_for(workspace, store, target, module, prompt, candidate)?; +::: test +name: lint with declared roots proposes removal of the orphan with a cascade preview and never auto-applies +given: { workspace: fixture_orphan_module, roots: [main_module] } +expect: { findings: [unreachable_napl], auto_applied: false, cascade_preview: true } +::: - match (transported, supplied) { - // Nothing recorded to carry: the map is the author's to supply, exactly - // as it was before transport existed. - (None, Some(bytes)) => { - let map = parse_map(&bytes)?; - attribution::validate(target, root, module, &candidate.files, body_lines, &map)?; - Ok(Some(bytes)) - } - // A module's first add has no committed map to transport, and it demands - // no hand-authored one either: the trivial whole map claims every staged - // file in full by the whole prompt body. It is honestly coarse, it is - // present and fresh rather than absent, and it is why the cold path to a - // verified first commit never needs `--no-verify`. - (None, None) => { - let whole = attribution::whole_map(module, &target.name, &candidate.files); - attribution::validate(target, root, module, &candidate.files, body_lines, &whole)?; - Ok(Some(attribution::to_yaml(&whole))) - } - (Some(outcome), Some(bytes)) => { - let map = parse_map(&bytes)?; - let layered = layer_affirmations(module, &outcome, &map)?; - attribution::validate(target, root, module, &candidate.files, body_lines, &layered)?; - Ok(Some(attribution::to_yaml(&layered))) - } - (Some(outcome), None) => { - // Transport seeds from the evidence a capture verb already recorded: - // a claim refusing in a rewritten region is affirmed automatically - // when a reference covering that region carries endpoint hashes that - // still match the staged bytes, because the tool that applied the - // edit owns its diff. A recorded reference whose hashes no longer - // match affirms nothing and stays listed. - let (outcome, affirmed) = - self_affirm(outcome, &candidate.files, &prompt.normalized_body); - if !outcome.complete() { - let mut payload = refusal_payload(module, &outcome.refusals); - if let Some(object) = payload.as_object_mut() { - object.insert("affirmedFromReferenceRecord".into(), json!(affirmed)); - } - return Err(crate::error::error_with( - OutcomeCode::InvalidCandidate, - refusal_detail(module, &outcome.refusals), - payload, - )); - } - let carried = outcome.record; - attribution::validate(target, root, module, &candidate.files, body_lines, &carried)?; - Ok(Some(attribution::to_yaml(&carried))) - } - } -} +::: test +name: lint --fix drops an unreferenced declared dependency through the guarded commit path +given: { workspace: fixture_unused_dep, fix: drop_unused_dep } +expect: { outcome: installed, transition_recorded: true, dep_removed: true } +::: -/// Transport the module's recorded attribution onto the staged bytes and the -/// pending prompt body, or `None` when nothing is recorded yet. -fn transport_for( - workspace: &Path, - store: &Store, - target: &TargetDef, - module: &str, - prompt: &SourceModule, - candidate: &Candidate, -) -> Result> { - let Some(state) = crate::porcelain::committed_state(store, &target.name, module)? else { - return Ok(None); - }; - let Some(recorded) = state.attribution.as_ref() else { - return Ok(None); - }; - if !crate::store_view::has_object(store, &recorded.record) - || !crate::store_view::has_object(store, &recorded.prompt_hash) - { - return Ok(None); - } - let record_bytes = get_blob(store, &recorded.record)?; - let Some(record) = attribution::parse(&String::from_utf8_lossy(&record_bytes)) else { - return Ok(None); - }; - let committed_body = String::from_utf8_lossy(&get_blob(store, &recorded.prompt_hash)?) - .into_owned(); - let committed_files: BTreeMap = - committed_output(store, &target.name, module)? - .into_iter() - .map(|(path, bytes)| (path, String::from_utf8_lossy(&bytes).into_owned())) - .collect(); - let staged_files: BTreeMap = candidate - .files - .iter() - .map(|(path, bytes)| (path.clone(), String::from_utf8_lossy(bytes).into_owned())) - .collect(); - let _ = workspace; - Ok(Some(transport_attribution( - &record, - &TransportInputs { - committed_body: &committed_body, - pending_body: &prompt.normalized_body, - committed_files: &committed_files, - staged_files: &staged_files, - }, - ))) -} +::: test +name: a cost report over a receipt set containing verb-staged records includes the edits +given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines } +expect: { report_rows: 2, edit_row_priced: true, same_pricing_table: true } +::: -/// Affirm refused claims from the evidence a capture verb recorded. -/// -/// A reference the verbs record carries the content hash of each endpoint's -/// post-edit bytes. When those hashes still match the staged bytes and the -/// pending body, the reference proves the current content is exactly what its -/// recorder wrote, so a claim landing in that region needs no hand-authored -/// affirmation: the record IS the affirmation. The claim keeps its recorded -/// coordinates, which the hashes prove are coordinates in these bytes. -/// -/// Nothing here guesses. A reference whose hashes no longer match proves -/// nothing, affirms nothing, and its claim stays in the refusal list beside -/// every other. -fn self_affirm( - outcome: TransportOutcome, - staged_files: &BTreeMap>, - pending_body: &str, -) -> (TransportOutcome, usize) { - let body_lines = pending_body.lines().count() as u32; - // The references whose evidence still holds, by the file they name. - let mut evidence: BTreeMap> = BTreeMap::new(); - for entry in &outcome.record.entries { - let Some(file) = &entry.file else { continue }; - if !entry.self_affirming() { - continue; - } - if !entry.hashes_match(staged_files.get(file).map(Vec::as_slice), pending_body) { - continue; - } - evidence - .entry(file.clone()) - .or_default() - .push((entry.line_start, entry.line_end)); - } - if evidence.is_empty() { - return (outcome, 0); - } +::: test +name: an edit without a window and a window with missing log lines both price unavailable naming why +given: { workspace: fixture_two_modules, receipts: [edit_without_window, edit_with_window_to_missing_lines] } +expect: { priced_unavailable: 2, reason_named_per_row: true, zeros: 0 } +::: - let TransportOutcome { record, refusals } = outcome; - let mut kept: Vec = Vec::new(); - let mut affirmed = 0usize; - for refusal in refusals { - let entry = &record.entries[refusal.index]; - let covered = entry - .file - .as_ref() - .and_then(|file| evidence.get(file).map(|spans| (file.clone(), spans))) - .map(|(file, spans)| { - let lines = staged_files - .get(&file) - .map(|bytes| String::from_utf8_lossy(bytes).lines().count() as u32) - .unwrap_or(0); - let in_bounds = entry.whole_file - || (entry.line_start >= 1 && entry.line_end <= lines); - let prompt_in_bounds = - entry.whole_prompt || (entry.prompt_start >= 1 && entry.prompt_end <= body_lines); - let overlaps = entry.whole_file - || spans.iter().any(|(start, end)| { - entry.line_start <= *end && *start <= entry.line_end - }); - in_bounds && prompt_in_bounds && overlaps - }) - .unwrap_or(false); - if covered { - affirmed += 1; - continue; - } - kept.push(refusal); - } - ( - TransportOutcome { - record, - refusals: kept, - }, - affirmed, - ) -} +::: test +name: a model absent from the pricing table prices unknown never zero +given: { workspace: fixture_two_modules, receipts: [edit_with_unknown_model_window], log: proxy_log_unknown_model } +expect: { priced_unknown: 1, zeros: 0 } +::: -/// Layer affirmations over the transported remainder. -/// -/// A map with one entry per listed claim affirms exactly those, in the order the -/// refusal listed them, and every carried claim is kept. A map covering the whole -/// record replaces it outright. Anything between is refused rather than guessed -/// at, because a partial map whose entries cannot be matched to claims is not an -/// affirmation of anything in particular. -fn layer_affirmations( - module: &str, - outcome: &TransportOutcome, - supplied: &Attribution, -) -> Result { - if outcome.complete() || supplied.entries.len() == outcome.record.entries.len() { - return Ok(supplied.clone()); - } - if supplied.entries.len() != outcome.refusals.len() { - return Err(error( - OutcomeCode::InvalidCandidate, - format!( - "the attribution update for module {module:?} carries {} entries, but the transport listed {} claims needing affirmation and the record holds {} in all; supply one entry per listed claim, in the order they were listed, or a complete map", - supplied.entries.len(), - outcome.refusals.len(), - outcome.record.entries.len() - ), - )); - } - let mut layered = outcome.record.clone(); - for (affirmation, refusal) in supplied.entries.iter().zip(outcome.refusals.iter()) { - layered.entries[refusal.index] = affirmation.clone(); - } - Ok(layered) -} +::: test +name: cost reports are byte-identical across runs over the same receipts log and table +given: { workspace: fixture_two_modules, receipts: [generation_with_usage, edit_with_session_window], log: proxy_log_with_window_lines, runs: 2 } +expect: { bytes_equal: true } +::: -/// Read a supplied `--attribution` map, answering a bad one with the whole -/// contract rather than a bare "not parseable". -/// -/// The first refusal is the only one the author gets before they go rewrite the -/// file, so it carries every field, the closed vocabularies, and one complete -/// valid example. This is the same law the `--ref` payload already follows. -/// A map that fails mid-parse, or parses into no entries at all, is refused -/// naming the exact line and the construct that stopped the reader, beside the -/// full contract. A bare count of zero would describe the reader's outcome -/// rather than the author's document. -fn parse_map(bytes: &[u8]) -> Result { - let bad = |why: String, located: Option<&attribution::MapParseError>| { - crate::error::error_with( - OutcomeCode::InvalidCandidate, - why, - json!({ - "mapContract": attribution::map_contract(), - "stoppedAt": match located { - Some(err) => json!({ - "construct": err.construct, - "detail": err.detail, - "line": err.line, - }), - None => Value::Null, - }, - }), - ) - }; - let text = std::str::from_utf8(bytes) - .map_err(|_| bad("the --attribution file is not valid UTF-8".to_string(), None))?; - let parsed = attribution::parse_detailed(text).map_err(|err| { - bad( - format!( - "the --attribution file stopped being readable at line {}, reading a {}: {}", - err.line, err.construct, err.detail - ), - Some(&err), - ) - })?; - if parsed.module.is_empty() || parsed.target.is_empty() { - return Err(bad( - "the --attribution file names no module or no target".to_string(), - None, - )); - } - Ok(parsed) -} +::: test +name: cost session reports one session's calls directly from the log +given: { workspace: fixture_two_modules, log: proxy_log_two_sessions, session: s1 } +expect: { rows_for_session: 2, rows_for_other_sessions: 0, priced_from_log: true } +::: -/// Every staged record currently sitting in the staging directory, workspace -/// relative, with the module and target each one names. -/// -/// This is a plain directory read: staged records are disposable litter by the -/// git-state law, so what is there is the whole truth and nothing indexes them. -pub fn staged_records(workspace: &Path) -> Result> { - let dir = workspace.join(STAGING_DIR); - let mut out: Vec = Vec::new(); - let listing = match std::fs::read_dir(&dir) { - Ok(listing) => listing, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), - Err(e) => return Err(disk::io(&dir, &e)), - }; - let mut paths: Vec = Vec::new(); - for entry in listing { - let entry = entry.map_err(|e| disk::io(&dir, &e))?; - if entry.path().join("record.json").is_file() { - paths.push(entry.path()); - } - } - // The directory hands entries back in whatever order the filesystem holds - // them, which is exactly the ambient enumeration order the determinism - // contract excludes: sort before anything reads them. - paths.sort(); - for path in paths { - let bytes = std::fs::read(path.join("record.json")) - .map_err(|e| disk::io(&path.join("record.json"), &e))?; - let meta: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); - out.push(StagedRecord { - module: meta["module"].as_str().unwrap_or_default().to_string(), - target: meta["target"].as_str().unwrap_or("rust").to_string(), - relative: display_path(workspace, &path), - path, - }); - } - Ok(out) -} +::: test +name: a proxy log line failing the shape validates invalid and stays visible as failed +given: { evidence: proxy_log_with_malformed_line, media_type: application_x_napl_usage_proxy_jsonl_v1 } +expect: { outcome: invalid, visible_as_failed: true } +::: -/// One staged record as the staging directory holds it. -#[derive(Debug, Clone)] -pub struct StagedRecord { - pub module: String, - pub target: String, - /// The workspace-relative spelling, which is what `--staged` accepts. - pub relative: String, - pub path: PathBuf, -} +::: test +name: cost over one receipt set and pricing version is byte-identical across runs and unknown for unknown models +given: { receipts: fixture_receipts, pricing_version: pinned, runs: 2, unknown_model_receipt: included } +expect: { reports_equal: true, unknown_model_cost: unknown } +::: -/// `napl commit` with no arguments: install every staged record in dependency -/// wave order, one outcome reported per record. -/// -/// A multi-module add followed by a bare commit is one command each. The -/// sequencing and the retry the decision table already defines belong to the -/// tool, so nobody hand-loops over records in wave order and gets it subtly -/// wrong. Each record still goes through exactly the same guarded transaction -/// `--staged` runs, and each reports its own outcome whatever the others did. -pub fn commit_all(workspace: &Path) -> Result<(Value, i32)> { - let records = staged_records(workspace)?; - let ordered = in_wave_order(workspace, records)?; - let mut outcomes: Vec = Vec::new(); - let mut exit_code = 0; - // Set once an earlier record in this invocation has moved the head. Only - // then is a later record's write-conflict or stale-input this invocation's - // own doing, and only then is re-deriving it the tool's job rather than a - // report the caller has to act on. - let mut head_moved = false; - for record in &ordered { - let mut report = commit_staged(workspace, &record.path); - let mut retried = false; - if head_moved && retryable_against_a_moved_head(report.outcome) { - // Re-derive exactly what re-running `add` would produce now: the - // record's own ingested bytes, re-drafted against the head this - // invocation just advanced, with its resolved attribution carried - // back in as affirmations. Modules sharing one crate overlap on the - // crate's ownership write key, so the second one always arrives - // against a head the first one moved; the retry is what makes that - // pair one invocation instead of a hand-ordered sequence. - // - // A docs record has nothing to re-derive: its staged bytes are a - // prompt, and the commit reads the head it arrives at, so retrying it - // as it stands is exactly re-running add. - let docs = record_kind(&record.path).ok().flatten().as_deref() == Some(DOCS_RECORD); - if docs || rederive_against_head(workspace, record).is_ok() { - retried = true; - report = commit_staged(workspace, &record.path); - } - } - if report.outcome != OutcomeCode::Success && exit_code == 0 { - exit_code = crate::error::outcome_exit_code(report.outcome); - } - if report.outcome == OutcomeCode::Success { - head_moved = true; - } - outcomes.push(json!({ - "module": record.module, - "outcome": report.payload["outcome"].clone(), - "record": record.relative, - "report": report.payload, - "retried": retried, - "target": record.target, - })); - } - let mut payload = json!({ - "contractVersion": crate::version::CONTRACT_VERSION, - "installed": outcomes.len() as u64, - "order": ordered - .iter() - .map(|record| json!(record.module)) - .collect::>(), - "outcomes": outcomes, - }); - // A bare commit that found nothing staged says so instead of dressing - // emptiness as success, and it names the work that is actually waiting: every - // module carrying working-tree drift, beside the exact `napl add` invocation - // that would stage it. The empty commit is the teacher, never the trap. - if ordered.is_empty() { - let drifted = drifted_modules(workspace)?; - // The teacher includes docs drift: a prompt whose prose moved is work - // waiting exactly as drifted code is, and the invocation that records it - // is the prompt path itself. - let docs_drifted = docs_drifted_prompts(workspace)?; - if let Some(object) = payload.as_object_mut() { - object.insert("stagedRecords".to_string(), json!(0)); - object.insert( - "detail".to_string(), - json!(match (drifted.is_empty(), docs_drifted.is_empty()) { - (true, true) => "zero records were staged and no module carries working-tree drift, so there was nothing to install".to_string(), - (true, false) => format!( - "zero records were staged; {} prompt(s) carry docs-only drift and are listed with the exact `napl add ` that would record each", - docs_drifted.len() - ), - (false, true) => format!( - "zero records were staged; {} module(s) carry working-tree drift and are listed with the exact `napl add` invocation that would stage each", - drifted.len() - ), - (false, false) => format!( - "zero records were staged; {} module(s) carry working-tree drift and {} prompt(s) carry docs-only drift, each listed with the exact `napl add` invocation that would record it", - drifted.len(), - docs_drifted.len() - ), - }), - ); - object.insert( - "docsDriftedPrompts".to_string(), - Value::Array(docs_drifted), - ); - object.insert( - "driftedModules".to_string(), - Value::Array(drifted), - ); - } - } - Ok((payload, exit_code)) -} +::: test +name: gc never deletes an object reachable from the current head and never blocks a concurrent commit +given: { workspace: fixture_two_modules, concurrent: [gc, valid_commit] } +expect: { reachable_deleted: false, commit_blocked: false, unreachable_swept: true } +::: -/// Every module whose committed output the working tree has moved away from, -/// beside the exact `napl add` invocation that stages it. -/// -/// This is the same drift `status` and `diff` report, read straight off the -/// committed output trees, so the empty bare commit can never disagree with the -/// query surface about what is waiting. -fn drifted_modules(workspace: &Path) -> Result> { - let store = disk::open(workspace); - let Some(head) = store.head()? else { - return Ok(Vec::new()); - }; - let mut listed: Vec = Vec::new(); - for ((target, module), state_hash) in snapshot_states(&store, &head)? { - let state = read_module_state(&store, &state_hash)?; - if !crate::store_view::has_object(&store, &state.output_tree) { - continue; - } - let mut paths: Vec = Vec::new(); - for (path, (blob, _mode)) in flatten_tree(&store, &state.output_tree)? { - let bytes = std::fs::read(workspace.join(&path)).ok(); - // An unreadable working file is not drift: the reader knows nothing - // about it, so it claims nothing, exactly as status does. - if attribution::code_drifted(Some(blob.as_str()), bytes.as_deref()) { - paths.push(path); - } - } - if paths.is_empty() { - continue; - } - listed.push(json!({ - "addCommand": format!("napl add --module {module}"), - "driftedPaths": paths, - "module": module, - "target": target, - })); - } - Ok(listed) -} +::: test +name: fsck detects a corrupted object and a broken parent link +given: { workspace: fixture_two_modules, corrupt: [flip_byte_in_object, remove_parent_event] } +expect: { findings: [hash_mismatch, missing_parent], exit_nonzero: true } +::: -/// Every module whose prompt carries docs-only drift, keyed by `(target, -/// module)`. -/// -/// One reader answers this question for everybody: the empty-commit teacher, so -/// it names the work that is waiting, and `status`, so a clean status and a -/// silent teacher can never disagree about whether anything is waiting. A prompt -/// whose drift reaches the frontmatter or the semantic body is not docs drift and -/// is not here: that module reads as source-changed everywhere else, and naming -/// the docs door beside it would teach the invocation that refuses. -pub fn docs_drift(workspace: &Path) -> Result> { - let store = disk::open(workspace); - let mut out: BTreeSet<(String, String)> = BTreeSet::new(); - if store.head()?.is_none() { - return Ok(out); - } - for source in discover_sources(workspace)? { - for target in &source.targets { - if matches!( - prompt_drift(&store, target, &source), - Ok((PromptDrift::DocsOnly, _)) - ) { - out.insert((target.clone(), source.module.clone())); - } - } - } - Ok(out) -} +::: test +name: restore applies an unambiguous move-repair plan and blocks on ambiguity +given: { workspace: fixture_moved_output, ambiguity: [none, two_candidates] } +expect: { unambiguous: installed, ambiguous: blocked } +::: -/// The docs-drifted prompts the empty-commit teacher names, one entry per prompt -/// file beside the `napl add ` that records it. -/// -/// A module declaring several targets is one prompt file and one invocation, so -/// it is listed once with the targets it drifted for rather than once per target -/// with the same command repeated. -fn docs_drifted_prompts(workspace: &Path) -> Result> { - let drifted = docs_drift(workspace)?; - let mut by_path: BTreeMap)> = BTreeMap::new(); - for source in discover_sources(workspace)? { - for (target, module) in &drifted { - if module != &source.module { - continue; - } - by_path - .entry(source.path.clone()) - .or_insert_with(|| (source.module.clone(), BTreeSet::new())) - .1 - .insert(target.clone()); - } - } - Ok(by_path - .into_iter() - .map(|(path, (module, targets))| { - json!({ - "addCommand": format!("napl add {path}"), - "module": module, - "promptPath": path, - "targets": targets.into_iter().collect::>(), - }) - }) - .collect()) -} +::: test +name: hover on an attributed prompt line resolves its generated spans +given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: within_entry } } +expect: { spans_resolved: true, notes_included: true } +::: -/// The two outcomes an earlier record in the same invocation can cause: the -/// head moved under this record, and moving it is what this invocation did. -/// -/// `base-output-changed` is deliberately not here. It says the module's *own* -/// committed output moved, which no other module's commit can do, so it is a -/// genuine report to the caller rather than self-inflicted sequencing. -fn retryable_against_a_moved_head(outcome: OutcomeCode) -> bool { - matches!(outcome, OutcomeCode::WriteConflict | OutcomeCode::StaleInput) -} +::: test +name: "hover on an unattributed line answers empty, not error" +given: { attribution: fixture_attribution_small, position: { file: napl.napl, line: outside_entries } } +expect: { result: empty, error: false } +::: -/// Re-derive a staged record against the current head, in place. -/// -/// The record's ingested candidate bytes are the immutable truth of what was -/// staged, whichever of the three sources produced them, so re-running the add -/// pipeline over them is exactly re-running add: the receipt is re-drafted -/// against the head that just moved, the attribution transport is recomputed, -/// and the resolved map already in the record rides back in as affirmations. -/// The record directory is keyed by the candidate tree hash, which the bytes did -/// not change, so the record rewrites itself at the same path. -fn rederive_against_head(workspace: &Path, record: &StagedRecord) -> Result<()> { - let (_, candidate_dir, attribution_path, mode) = resolve(&record.path)?; - add( - workspace, - &record.target, - &record.module, - &AddSource::CandidateDir(candidate_dir), - attribution_path.as_deref(), - None, - mode == AttributionMode::Escape, - false, - )?; - Ok(()) -} +::: test +name: missing attribution records degrade to empty responses +given: { attribution: absent, request: hover } +expect: { result: empty, error: false, blocked: false } +::: -/// Remove one staged record directory. -fn sweep_record(workspace: &Path, record: &Path) -> Result<()> { - let dir = if record.join("record.json").is_file() { - record.to_path_buf() - } else if record.file_name().and_then(|n| n.to_str()) == Some("record.json") { - match record.parent() { - Some(parent) => parent.to_path_buf(), - None => return Ok(()), - } - } else { - return Ok(()); - }; - if !dir.starts_with(workspace.join(STAGING_DIR)) { - return Ok(()); - } - match std::fs::remove_dir_all(&dir) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(disk::io(&dir, &e)), - } -} +::: test +name: definition from a generated line targets the prompt at its discovered placement +given: { attribution: fixture_attribution_small, prompt_placement: inside_crate_dir, request: definition, position: { file: generated_within_entry } } +expect: { location_file: discovered_prompt_path, root_joined_module_name: false } +::: -/// Sort staged records into dependency wave order, the order commit installs in. -/// -/// A module the graph does not place sorts after every placed one rather than -/// blocking the whole batch: its own commit will say what is wrong with it, and -/// refusing to sequence is not the same as refusing to install. -fn in_wave_order(workspace: &Path, records: Vec) -> Result> { - let sources = discover_sources(workspace)?; - let mut keyed: Vec<(usize, String, StagedRecord)> = Vec::new(); - for record in records { - let wave = crate::compile::graph_for(&sources, &record.target) - .ok() - .and_then(|graph| crate::graph::topological_levels(&graph).ok()) - .and_then(|levels| levels.get(&record.module).copied()) - .unwrap_or(usize::MAX); - keyed.push((wave, record.relative.clone(), record)); - } - keyed.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); - Ok(keyed.into_iter().map(|(_, _, record)| record).collect()) -} +::: test +name: evidence failing its validator is invalid and distinguishable from unsupported +given: { receipt_evidence: [valid_claude_json, malformed_claude_json, unknown_media_type] } +expect: { outcomes: [validated, invalid, unsupported] } +::: -/// Install what add staged: read the staged record and run the one guarded -/// commit over the immutable staged copy. -/// -/// A record that installs is swept. It has become history, and leaving it in the -/// staging directory would make the next bare commit re-attempt an installed -/// transition against a head that has already moved past it. Nobody should ever -/// hand-edit `.napl` to be rid of one, and nobody should have to. -pub fn commit_staged(workspace: &Path, record: &Path) -> CommitReport { - // A docs record installs through its own door: it carries no candidate and - // drafts no receipt, so the candidate pipeline has nothing to read. - let report = match record_kind(record) { - Ok(Some(kind)) if kind == DOCS_RECORD => crate::commit::commit_docs(workspace, record), - Ok(_) => match resolve(record) { - Ok((receipt_path, candidate_dir, attribution_path, mode)) => commit_with_mode( - workspace, - &receipt_path, - &candidate_dir, - attribution_path.as_deref(), - mode, - ), - Err(err) => CommitReport { - outcome: err.code, - payload: error_payload(&err, false, false), - }, - }, - Err(err) => CommitReport { - outcome: err.code, - payload: error_payload(&err, false, false), - }, - }; - if report.outcome == OutcomeCode::Success { - if let Err(err) = sweep_record(workspace, record) { - return CommitReport { - outcome: err.code, - payload: error_payload(&err, true, true), - }; - } - } - report -} +::: test +name: a receipt exceeding evidence bounds is rejected as resource-limit with nothing published +given: { workspace: fixture_two_modules, candidate: valid_for_consumer, receipt: oversized_evidence } +expect: { outcome: resource_limit, published_semantic_state: false, published_provenance: false } +::: -/// The directory a `--staged` argument names, whether the caller spelled the -/// directory or the `record.json` inside it. -pub fn record_directory(record: &Path) -> Result { - if record.join("record.json").is_file() { - Ok(record.to_path_buf()) - } else if record.file_name().and_then(|n| n.to_str()) == Some("record.json") { - Ok(record - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from("."))) - } else { - Err(error( - OutcomeCode::InvalidReceipt, - format!("no staged record at {}", record.display()), - )) - } -} +## Determinism contract -/// The metadata a staged record declares. -pub fn record_meta(record_dir: &Path) -> Result { - let bytes = std::fs::read(record_dir.join("record.json")) - .map_err(|e| disk::io(&record_dir.join("record.json"), &e))?; - serde_json::from_slice(&bytes).map_err(|e| { - error( - OutcomeCode::InvalidReceipt, - format!("staged record is not JSON: {e}"), - ) - }) -} +:::docs +## Same input, same bytes -/// The `kind` a staged record declares. An ordinary candidate record declares -/// none, so a record written before docs records existed reads as `None`. -fn record_kind(record: &Path) -> Result> { - let dir = record_directory(record)?; - Ok(record_meta(&dir)?["kind"].as_str().map(str::to_string)) -} +A module's identity is a hash over everything that can change its +output: the prompt's semantic body, its dependencies' surfaces, the +target definition, the contract version. When the key is unchanged +the bytes are unchanged; when it moves, napl tells you which +component moved and why. +::: -fn resolve(record: &Path) -> Result<(PathBuf, PathBuf, Option, AttributionMode)> { - let record_dir = record_directory(record)?; - let meta = record_meta(&record_dir)?; - let no_verify = meta["noVerify"].as_bool().unwrap_or(false); - let mode = match meta["mode"].as_str() { - Some("pre-proven") => AttributionMode::PreProven, - Some("escape") => AttributionMode::Escape, - Some("prove") => AttributionMode::Prove, - // A record written before modes existed says only whether it escaped. - _ if no_verify => AttributionMode::Escape, - _ => AttributionMode::Prove, - }; - let attribution_path = if meta["attribution"].as_bool().unwrap_or(false) { - Some(record_dir.join("attribution.yaml")) - } else { - None - }; - Ok(( - record_dir.join("receipt.json"), - record_dir.join("candidate"), - attribution_path, - mode, - )) -} +Every collection canonically ordered; every input explicit and content +identified; serialization byte-pinned; ambient environment excluded +(locale, timezone, machine identity, CPU count, enumeration order); reads +never write; a state transition names its base and fails on mismatch per +the decision table rather than recalculating. Same workspace compiled +twice, from different absolute paths, under different discovery orders, +locales, and timezones produces byte-identical artifacts. The test suite +proves the vectors in the frontmatter with deterministic fixtures +constructed in each crate's `tests/`. -fn display_path(workspace: &Path, path: &Path) -> String { - path.strip_prefix(workspace) - .map(|rest| rest.to_string_lossy().replace('\\', "/")) - .unwrap_or_else(|_| path.to_string_lossy().into_owned()) -} -napl-store tree v1 132 -{"entries":[{"hash":"0340a5f98d50aa16b99442f13842884d896cbf776a4253b69e6533d599724e89","kind":"dir","mode":16384,"name":"crates"}]} -napl-store snapshot v1 1005 -{"contractVersion":4,"event":"eded3d59d3f2c0ac9197b869730e53b944303a7822cdafb8a76846afe275d687","states":[{"module":"agent","state":"d0d406dd4f30ea6840a808bcbc3ddeea8ec8cc10c84de12ab5d0cfeef7bb6696","target":"rust"},{"module":"commands","state":"ac7d92c561ec9fa19f0e28e1392bd90c1d95ca663b038424bd8ce760c6f4cd9e","target":"rust"},{"module":"git-store","state":"8b694b3cf23f1e7b0340fc57cc4932e14ec33660ad068eb3b54cbe3c3a3dd6ad","target":"rust"},{"module":"napl","state":"472a6986704fccc2a1f592d6fa2dd293195eea5e59e2bd6637e9a8647296b465","target":"rust"},{"module":"napl_store","state":"6e6e8103fa5e0d694a56a5a030bbe4a829a10bd2710b989ed36f31fc8d195631","target":"rust"},{"module":"query","state":"accaf93691b84c6794df99c7643c710529c765f12cc9faedede4b6b598a8c6b0","target":"rust"},{"module":"serve","state":"40ddb00b3be320909acf0ae1440e2bc5662e3002dcf77dc3367bcb215b1710fd","target":"rust"},{"module":"usage_proxy","state":"51c3ee14ac35b611717ae900a78c6cc02fb2c36caeeba22d05aac13fc98724ed","target":"rust"}]} -napl-store receipt v1 815 -{"baseStateKey":"94697ec1fb16dd5f899f438ef2b257f8b6c5cb611e93acf940a8f2fe94a3713f","baseStoreSnapshot":"c3a7241b135bc24dc5b8ce9e5e1b75a7758fca92b4eaa3cd57eb90b77cc9e9fd","baseWorkspaceHash":"0193dd82442305411885e027f4e227b6accf0abd4afa4ca1c0922aa9c1a0b818","candidateTreeHash":"b92d10f23649a6ab076e0a23eeda2a922fbb3e779486a81b37c96c11f63d7873","contractVersion":3,"inputKey":"1ba3399d515df7b46c3b52a3271b11b7a3d75393dd8f4cd5b1beb38a12554ff7","module":"commands","outputManifest":[{"hash":"4b14b273d01558abe3560da549f380b808673d119fa0b3f23343a210e6098aee","path":"crates/napl/src/init.rs"},{"hash":"369fb41f0c2aeb5070511ba923fc98a109e663f841b38779e0bb1929f668a516","path":"crates/napl/src/watch.rs"}],"receiptVersion":1,"sliceHash":"2e90f24f4fbfffbecfe6adc1f4e2dbca388d0715f7dfe6ad471b0e9681299972","target":"rust"} -napl-store event v1 158 -{"content":"83603a421d55207846c9b25009242c586258bfe1cd62e7ed7323c05a31bb9c3e","parents":["d18238b45189f5583b384cb01e30a51d7d2918e8c08fb75d21eee4f10550ca21"]} -napl-store tree v1 129 -{"entries":[{"hash":"6121a30bf21aa00b4424bb2b2badd08665f03995a57b5548afdf3ccb7810264f","kind":"dir","mode":16384,"name":"src"}]} -napl-store tree v1 130 -{"entries":[{"hash":"b4df455283ade5d031757e4a192db01fea9cc5b0a96c18b9786aaaec3195b611","kind":"dir","mode":16384,"name":"napl"}]} +::: test +name: compiling the same workspace twice yields byte-identical artifacts +given: { workspace: fixture_two_modules, runs: 2 } +expect: { artifacts_equal: true } +::: + +::: test +name: artifact bytes are independent of discovery order +given: { workspace: fixture_two_modules, discovery_orders: [sorted, reversed, shuffled_seed_7] } +expect: { artifacts_equal: true } +::: + +::: test +name: artifact bytes are independent of the absolute workspace path +given: { workspace: fixture_two_modules, mounted_at: [rootA, rootB] } +expect: { artifacts_equal: true } +::: + +::: test +name: canonical json sorts keys and is byte-stable +given: { value: "{\"b\":1,\"a\":2}", encodings: 2 } +expect: { bytes_equal: true, sorted_keys: true } +::: napl-store receipt v1 1830 {"baseStateKey":"ba1c5035ed5d02088e38bd72fb9b3545bdc4e67aaf793a9ba0690cbb18c06e9f","baseStoreSnapshot":"f6fb47544e45265d49a84ec0e6440d1a79491e454de5d3e3024b171f8c85f7ba","baseWorkspaceHash":"cd1ddce56e031ff2cb7ac24a109b3142d438c1a17491d2e1f8cb478b64231cc1","candidateTreeHash":"8a214291d385d0008c8c11387def1d4c226bfb970b090415dc921466ff3e21ca","contractVersion":4,"inputKey":"6835ec98ced3cbe0800dfe401499d8b54826b74f8a598f81ac694d0e7f80b93c","module":"usage_proxy","outputManifest":[{"hash":"6e5fc7c645dc91d9cf4cc415d91749bf9eb96cf1a13b17cbffbf7d549cfb3c76","path":"crates/napl-usage-proxy/src/agent.rs"},{"hash":"360a819b87eecfefab1a3831b4340ac7fada4ccb2f8ccef52208b6042f556d1e","path":"crates/napl-usage-proxy/src/args.rs"},{"hash":"8b827a70205c5aadce37f553b10f080fe5c618cf23c3f7954859011d329dbe7c","path":"crates/napl-usage-proxy/src/canonical.rs"},{"hash":"7b54deca43ff224cc112166abd958048b53b2762431c41a9fdc1044144d9c96a","path":"crates/napl-usage-proxy/src/capture.rs"},{"hash":"6844a11e7ff59e2b07df44e86039349fdba2b82241a712904027c6f01fb5a215","path":"crates/napl-usage-proxy/src/logline.rs"},{"hash":"a2da8502c17775fd09cf0f6c314674688fab51a644b5a5c3ce058c4286de69d9","path":"crates/napl-usage-proxy/src/main.rs"},{"hash":"eb4ca92f81de67ad4082efbb23d7619bf0f206dd607122b4e1872a24ee1787ee","path":"crates/napl-usage-proxy/src/proxy.rs"},{"hash":"0479a5be6a51a7191b47168b7f5bbb14ac2168809a25f33b3135dd11d0882a37","path":"crates/napl-usage-proxy/src/session.rs"},{"hash":"5d710e71c5606166c6ed3f471ba86a411990b186acea3ca8aa8ca388253539dc","path":"crates/napl-usage-proxy/src/state.rs"},{"hash":"cfbdfdce716fc1b0828ea03ee5d4b1cde37529c7011f1216862eccd1cc6acc4e","path":"crates/napl-usage-proxy/src/upstream.rs"}],"receiptVersion":1,"sliceHash":"f4c606151f1a150e96cdd2520ea0595125aa4de256cb2680e9da7911fad349a3","target":"rust"} napl-store tree v1 129 @@ -254993,6 +259951,8 @@ napl-store transition v1 873 {"baseStateKey":"33a141441be88c6825e85a1495467d599a016bae61a678358f9d680a52017c99","contractVersion":4,"derivedProjections":["crates/napl-store/src/lib.rs"],"inputKey":"a81dbd9694703c7aa43996c1fd0ba96dc10741827cd8283deefbfa80995cb3ac","installPaths":["crates/napl-store/src/git_store.rs"],"module":"git-store","newState":"1f662ac905f485e56b796fadeab41facb08ad03f1099230f43e23f529db1d81e","readSet":["base-state:33a141441be88c6825e85a1495467d599a016bae61a678358f9d680a52017c99","input-key:a81dbd9694703c7aa43996c1fd0ba96dc10741827cd8283deefbfa80995cb3ac","target-def:261beca36280b22086e272bfb390db3df6f75f2000285b3acc820adbe2cd3835"],"receipt":"9884824a5dfc9c8cf97a055d9293424ae7e580a6a6ddbacb45a9f1f91f400aea","surface":"de0377465c067b511f8b1f2727ceee7caf1ec3d1f896a6c31766ce05a72b5f1c","target":"rust","writeSet":["owned:crates/napl-store","module-state:rust/git-store"]} napl-store event v1 158 {"content":"1f9bac1c043e5ab12e9c972dc7a10c527295b4816dab4592aed52c76b4c23e47","parents":["d99b6625294e2e89f80c1de9dd39c78d22bd4866fa032a7a3f3112fb960f33b1"]} +napl-store module-state v1 634 +{"attribution":{"promptHash":"84f6b94966d68b7682f665cddbe9d8e2843c00d8040563c2f5e2c2e7882d4778","record":"cb122db70f0461d8663923a7fdd714308d84b4ce56827f84aab7cfbdf01d806c","stale":false},"contractVersion":4,"inputKey":"fb8bca658e68497a7f4c93ec86167669020fd6ecec1655366e918526221b1a74","module":"napl","outputTree":"8517b3e9703a1d334fa7abda775c92fb82cc21b953ae890ba52cc02ad12d3c66","prompt":"a510adf944d1727d99e474b29cb4556e0cb647c01287bffc083e4ec8a49d32fb","sliceHash":"b8c58fa0cc35ff688a94401423ceb47396e201f13cee6abe0a804806f4fd59ac","surfaceHash":"19842377a5884b5b4dc94a7445d915f6aa844478502b531010361228d326a2ed","target":"rust"} napl-store event v1 158 {"content":"f9dee44d3c4849bd2fa732cea4dd6bc8f34f18e990f39a63d7931b8d712b47e3","parents":["ddbbd2e77f026e42ea002ce1b43457362f9cb8b4f444487ed662ad8d7c22d9b5"]} napl-store blob v1 25509 @@ -271532,6 +276492,8 @@ name: canonical json sorts keys and is byte-stable given: { value: "{\"b\":1,\"a\":2}", encodings: 2 } expect: { bytes_equal: true, sorted_keys: true } ::: +napl-store tree v1 130 +{"entries":[{"hash":"158c417b1a04825ffd7f472547f6a358d25888bb76d2cf5ab498dae69de9b252","kind":"dir","mode":16384,"name":"napl"}]} napl-store snapshot v1 781 {"contractVersion":4,"event":"baa5544bb061f7876c0291c3ab9f8982a0c341a6565f25b4d83d4560edda1889","states":[{"module":"agent","state":"60703eac1bb42950b90412134040c2284cf824c7f6b058faf835f204639eb5e9","target":"rust"},{"module":"commands","state":"87384fb9465ce6341cb179768f2b35a68b7a4f7ab1ff58a7e5cc220b82a22793","target":"rust"},{"module":"napl","state":"127285deaacf052e180769f0b6ce6ac03d3faf777d6ae7aa18e4cd77d6f95fe6","target":"rust"},{"module":"napl_store","state":"7011890f658fb28bd29684802e5e53bb5d54d54d3c5a64cc926a56fae7ef944d","target":"rust"},{"module":"query","state":"a9968cca3d5e718781aa07dfc91a9e68495df5b964c5d5b50622038329c145cc","target":"rust"},{"module":"usage_proxy","state":"73ca9d8c3eaed5463f42cc837d32329ab4b61d3643daa6301c4094b8ace89264","target":"rust"}]} napl-store blob v1 5122 @@ -291882,6 +296844,377 @@ napl-store event v1 158 {"content":"5a3783dae0e5134d66b9e0eeddcc8932d78497f25001edb986f066a75866d679","parents":["4be7212bebd28fc0aa4a937abc253a552464f375fce3e4ff4f33abb22902da57"]} napl-store transition v1 1847 {"baseStateKey":"1c9027a76e242965501c9d639d69099add69b059b264ef6b815bf03630443f30","contractVersion":2,"derivedProjections":["crates/napl/src/lib.rs"],"inputKey":"caef81ac82e2ba5e6dfb6d972b8c6f7f3cea1b348742eff708014f998ce1e6b2","installPaths":["crates/napl/src/attribution.rs","crates/napl/src/authoring.rs","crates/napl/src/blame.rs","crates/napl/src/candidate.rs","crates/napl/src/canon.rs","crates/napl/src/cli.rs","crates/napl/src/commandset.rs","crates/napl/src/commit.rs","crates/napl/src/compile.rs","crates/napl/src/cost.rs","crates/napl/src/decision.rs","crates/napl/src/directive.rs","crates/napl/src/disk.rs","crates/napl/src/error.rs","crates/napl/src/evidence.rs","crates/napl/src/graph.rs","crates/napl/src/lint.rs","crates/napl/src/lsp.rs","crates/napl/src/main.rs","crates/napl/src/maintenance.rs","crates/napl/src/mv.rs","crates/napl/src/objects.rs","crates/napl/src/paths.rs","crates/napl/src/porcelain.rs","crates/napl/src/receipt.rs","crates/napl/src/slice.rs","crates/napl/src/source.rs","crates/napl/src/staging.rs","crates/napl/src/store_view.rs","crates/napl/src/surface.rs","crates/napl/src/target.rs","crates/napl/src/textdiff.rs","crates/napl/src/transaction.rs","crates/napl/src/trees.rs","crates/napl/src/version.rs","crates/napl/src/workspace.rs"],"module":"napl","newState":"6d936a0e261cd28357178855ce558832bb46b36d0c3ca5fecee7424a3b0e8034","readSet":["base-state:1c9027a76e242965501c9d639d69099add69b059b264ef6b815bf03630443f30","input-key:caef81ac82e2ba5e6dfb6d972b8c6f7f3cea1b348742eff708014f998ce1e6b2","target-def:eff9678e5301c88ac1e42eec62c56eb3860fca247f6278b6501639f14112a8d4"],"receipt":"6a542326f971cb64936d3c2c3eafcd8b4fcbde5324c1987c8660201be2e2513b","surface":"40faaca519e9d3e7292f35c83f9b09b37aff1e1b0a08ffcd6ae1e0fed5df31bf","target":"rust","writeSet":["owned:crates/napl","module-state:rust/napl"]} +napl-store blob v1 16183 +module: "napl" +target: "rust" +stale: false +entries: + - promptLines: + - 1 + - 8 + file: "crates/napl/src/cli.rs" + lines: whole + note: "the binary reads args runs one command and exits with the contract code" + - promptLines: + - 1 + - 8 + file: "crates/napl/src/commandset.rs" + lines: whole + note: "the command table the core toolchain contributes to the derived registry, remove among its entries" + - promptLines: + - 1 + - 8 + file: "crates/napl/src/main.rs" + lines: whole + note: "the binary entry point that spawns no process" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/source.rs" + lines: whole + note: "the smallest prompt the reader accepts, stated once beside the reader that judges it so the two commands that teach with it cannot drift from the grammar discovery finds by the .napl extension" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/transaction.rs" + lines: whole + note: "the crate lib.rs projection derived from the committed module file sets" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/workspace.rs" + lines: whole + note: "layout and discovery walk up to the nearest napl directory" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/paths.rs" + lines: whole + note: "the empty placement root contains every workspace-relative path and joins against it never fabricate a leading separator" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/target.rs" + lines: whole + note: "a prompt at the workspace root places at the empty root and every join against it stays relative" + - promptLines: + - 9 + - 114 + file: "crates/napl/src/transaction.rs" + lines: whole + note: "candidate containment against the empty root admits every workspace-relative path instead of rejecting all of them" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/cli.rs" + lines: whole + note: "the declared grammar of every command plus the dispatch loop that answers help ahead of it and refuses an undeclared flag before it runs, with clap's builder API constructed at dispatch from the registry as the parser behind the contract, every parse failure caught and served as the canonical JSON usage error, and the conventional spellings reduced to one normalized argument vector so --flag=value and --flag value cannot differ" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/commandset.rs" + lines: whole + note: "each core command declares its summary exact usage line and closed flag table, diff's operand is a target name whose usage line spells it and whose refusal names the target names that exist, and store declares its one operation so an unknown one is a grammar failure like any other undeclared token" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/error.rs" + lines: whole + note: "exit 2 is the pinned usage-error code for the whole binary" + - promptLines: + - 115 + - 250 + file: "crates/napl/src/target.rs" + lines: whole + note: "placement roots owned source globs and the user-owned manifest seam a candidate may never contain" + - promptLines: + - 251 + - 278 + file: "crates/napl/src/decision.rs" + lines: whole + note: "the conflict rule two operations conflict when semantic write sets overlap" + - promptLines: + - 251 + - 278 + file: "crates/napl/src/error.rs" + lines: whole + note: "the outcome vocabulary and its exit-code mapping" + - promptLines: + - 251 + - 278 + file: "crates/napl/src/objects.rs" + lines: whole + note: "P2 footprints read write and derived-projection sets over content identities" + - promptLines: + - 279 + - 328 + file: "crates/napl/src/slice.rs" + lines: whole + note: "identity vocabulary inputKey closure manifest baseStateKey and sliceHash" + - promptLines: + - 279 + - 328 + file: "crates/napl/src/version.rs" + lines: whole + note: "the contract version bumped to four by the capture verbs whose three refusals join the closed table" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "names are serialized as quoted escaped scalars that reparse identically" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "the attribution record path keys on the encoded name never the raw one" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/paths.rs" + lines: whole + note: "the injective reversible name encoding used wherever a name becomes a segment" + - promptLines: + - 329 + - 389 + file: "crates/napl/src/source.rs" + lines: whole + note: "the module-name rule any non-empty single-line scalar and nothing narrower" + - promptLines: + - 390 + - 460 + file: "crates/napl/src/compile.rs" + lines: whole + note: "the compile derivation a pure read that never persists an artifact, and the pending reason it derives from the closed vocabulary by comparing the current inputKey components against the identities the committed record holds, with the re-stamp promise an undrifted pending module carries" + - promptLines: + - 390 + - 460 + file: "crates/napl/src/graph.rs" + lines: whole + note: "the dependency graph and dependency-ordered wave planning" + - promptLines: + - 390 + - 460 + file: "crates/napl/src/paths.rs" + lines: whole + note: "the portable path grammar and its collision-detecting normalization" + - promptLines: + - 461 + - 716 + file: "crates/napl/src/directive.rs" + lines: whole + note: "the test body-directive value grammar diagnostics and deterministic extraction" + - promptLines: + - 521 + - 714 + file: "crates/napl/src/directive.rs" + lines: whole + note: "the docs fence family with its micromark name and attribute grammar, the fence stack where a closing line of N colons closes the innermost fence opened with at most N and where code blocks suspend the stack, the refusal that names the line of a directive fence opened inside another open directive fence because fences are ordered siblings rather than a tree, and the split that lifts every top-level docs fence out of the semantic body, plus the heading tolerance that reads a `# ` or `## ` line inside an open prose fence, docs note or warning, as the prose it is while the same line inside an open example or test fence still refuses on sight" + - promptLines: + - 521 + - 714 + file: "crates/napl/src/source.rs" + lines: whole + note: "the semantic body every identity derivation consumes is the normalized body with its docs fences removed, the module preamble is the first fence standing before the first heading, and an example fence is validated so a published example cannot rot" + - promptLines: + - 521 + - 714 + file: "crates/napl/src/cli.rs" + lines: whole + note: "a napl invocation written inside an example fence is judged against the live command grammar without running anything" + - promptLines: + - 717 + - 770 + file: "crates/napl/src/surface.rs" + lines: whole + note: "tree-sitter surface extraction sound by construction and loud on malformed input" + - promptLines: + - 771 + - 1327 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "attribution rides the staged record validated before install" + - promptLines: + - 771 + - 1327 + file: "crates/napl/src/authoring.rs" + lines: whole + note: "the internal receipt derivation and the one guarded auto-fix" + - promptLines: + - 771 + - 1327 + file: "crates/napl/src/candidate.rs" + lines: whole + note: "candidate ingestion untrusted bytes become immutable content-addressed blobs" + - promptLines: + - 771 + - 1327 + file: "crates/napl/src/commit.rs" + lines: whole + note: "napl commit the one guarded transaction over what add staged, and bare commit re-derives a record against the head an earlier record in the same invocation moved and retries it once" + - promptLines: + - 771 + - 1327 + file: "crates/napl/src/disk.rs" + lines: whole + note: "the store shell publication head compare-and-set and module-keyed locks over the one tracked object layout, the door where a repository still carrying the retired store ref is refused before any verb has written a byte, the objects lock a repack holds which buys only that two repacks never rewrite the layout at once and whose abandoned copy is stolen by the one liveness question rather than left a wedge, the swept count read inside that lock so the number reported is the number removed, and a HEAD that is not a snapshot name refused as store integrity naming the merge that most likely wrote it" + - promptLines: + - 771 + - 1327 + file: "crates/napl/src/receipt.rs" + lines: whole + note: "the internal receipt everything the compiler recomputes and refuses to believe" + - promptLines: + - 771 + - 1327 + file: "crates/napl/src/staging.rs" + lines: whole + note: "napl add stages from any of the three sources through one pipeline, the dry run enumerates the validations it ran and names target-language validity as the one it did not judge, and a bad attribution map is answered with the whole map contract" + - promptLines: + - 771 + - 1327 + file: "crates/napl/src/store_view.rs" + lines: whole + note: "the read seam over content-addressed objects a pure function of the bytes" + - promptLines: + - 771 + - 1327 + file: "crates/napl/src/transport.rs" + lines: whole + note: "attribution transports through the byte diff on both ends independently, and a refusal reports each endpoint's verdict separately so the side that transported serves its transported range and only the side needing a human is asked for" + - promptLines: + - 771 + - 1327 + file: "crates/napl/src/trees.rs" + lines: whole + note: "building the candidate tree over the ingested manifest" + - promptLines: + - 771 + - 1327 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "attribution rides the staged record validated before install" + - promptLines: + - 771 + - 1327 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "attribution rides the staged record validated before install" + - promptLines: + - 771 + - 1327 + file: "crates/napl/src/attribution.rs" + lines: whole + note: "attribution rides the staged record validated before install" + - promptLines: + - 771 + - 1327 + file: "crates/napl/src/lsp.rs" + lines: whole + note: "hover states the direction the recorded provenance that the reference is referentially valid and which endpoints read stale" + - promptLines: + - 1328 + - 1526 + file: "crates/napl/src/capture.rs" + lines: whole + note: "napl edit and napl write validate apply capture in that order, and a recorded call's payload carries the reference object verbatim with its transition hash and says the edited file remains working-tree drift until its module commits" + - promptLines: + - 1328 + - 1526 + file: "crates/napl/src/commit.rs" + lines: whole + note: "a pre-proven attribution installs fresh because the verb already proved the reference against the exact bytes it names and the guard runs unchanged" + - promptLines: + - 1328 + - 1526 + file: "crates/napl/src/cost.rs" + lines: whole + note: "a recorded session window is priced lazily at read time never aggregated at record time" + - promptLines: + - 1328 + - 1526 + file: "crates/napl/src/error.rs" + lines: whole + note: "the three refusals join the closed terminal-outcome table with pinned exit codes 27 28 and 29 and none reuses mapping-integrity" + - promptLines: + - 1328 + - 1526 + file: "crates/napl/src/staging.rs" + lines: whole + note: "the staged record carries its attribution mode so a reference-only record commits through the same door add and commit already use" + - promptLines: + - 1527 + - 2013 + file: "crates/napl/src/blame.rs" + lines: whole + note: "derived line ancestry where surviving lines trace through accepted patches, and a path no committed module owns answers tracked false naming the absent baseline rather than an empty ancestry that would read as success" + - promptLines: + - 1527 + - 2013 + file: "crates/napl/src/cost.rs" + lines: whole + note: "cost as a pure calculation over an explicit receipt set where verb-staged session windows resolve against the log at read time and unknown stays unknown" + - promptLines: + - 1527 + - 2013 + file: "crates/napl/src/evidence.rs" + lines: whole + note: "evidence validators keyed by media type including the usage-proxy jsonl log with compiled-in bounds" + - promptLines: + - 1527 + - 2013 + file: "crates/napl/src/git.rs" + lines: whole + note: "what the toolchain observes about the enclosing git repository by reading files and never by linking a git library, and which refusal the retired store ref earns: the whole road back when the objects are still on the branch, and deleting the ref alone when the store already holds them, each carrying the exact commands it names" + - promptLines: + - 1527 + - 2013 + file: "crates/napl/src/remove.rs" + lines: whole + note: "remove ends a module's tenancy through the same guarded door, publishing a snapshot without its state, deleting the files it owned, re-deriving the leaving crate's entry file and no other crate's from the states the critical section itself read so no module landing in that crate meanwhile is left unnamed, closing its attribution record only where one existed, and refusing an unknown module, drift, and dependents read off the committed prompts rather than the working tree, a prompt that will not parse counting as silence and never as a verdict, while leaving the prompt file, the manifest, and an emptied crate to the user" + - promptLines: + - 1527 + - 2013 + file: "crates/napl/src/lint.rs" + lines: whole + note: "unreachable modules under declared roots where absent roots refuse loudly with the exact declaration rather than an empty findings list, and unconsumed surface entries" + - promptLines: + - 1527 + - 2013 + file: "crates/napl/src/lsp.rs" + lines: whole + note: "the language server hover definition references code lens and the diagnostics it publishes from reference staleness over the mapping" + - promptLines: + - 1527 + - 2013 + file: "crates/napl/src/maintenance.rs" + lines: whole + note: "gc fsck and restore, where reachable means the transitive closure from every named head through every event and every hash edge any reachable object carries, so an edge gc does not understand is a reason to keep and a store healthy before a sweep is healthy after it" + - promptLines: + - 1527 + - 2013 + file: "crates/napl/src/mv.rs" + lines: whole + note: "relocation as a semantic operation re-rooting a module through the guarded path" + - promptLines: + - 1527 + - 2013 + file: "crates/napl/src/porcelain.rs" + lines: whole + note: "the observational porcelain status log show diff blame plus the maintenance verbs, where the plan labels every projection path tool-derived and names one concrete legal first output, show lists a transition's reference entries inline exactly as it lists notes, blame skips a transition that names no new state because a removal records an absence, and the automatic repack reports what it swept beside what it packed and, when another repack holds the objects lock, names the contention in the payload rather than leaving an operator to read silence" + - promptLines: + - 1527 + - 2013 + file: "crates/napl/src/textdiff.rs" + lines: whole + note: "diff renders drift as a canonical byte-stable patch" + - promptLines: + - 2014 + - 2048 + file: "crates/napl/src/canon.rs" + lines: whole + note: "the determinism contract canonical bytes and byte-pinned serialization" napl-store surface v1 775 {"contractVersion":4,"items":[{"kind":"const","path":"check::CHECK_VERSION","signature":"pub const CHECK_VERSION : u32 = 1 ;"},{"kind":"const","path":"document::DOC_VERSION","signature":"pub const DOC_VERSION : u32 = 1 ;"},{"kind":"fn","path":"document::build_document","signature":"pub fn build_document ( workspace : & Path ) -> Result < Value >"},{"kind":"fn","path":"document::build_document_view","signature":"pub fn build_document_view ( workspace : & Path , working_tree : bool ) -> Result < Value >"},{"kind":"const","path":"query::OUTPUT_CEILING_BYTES","signature":"pub const OUTPUT_CEILING_BYTES : usize = 64 * 1024 * 1024 ;"},{"kind":"fn","path":"query::commands","signature":"pub fn commands ( ) -> & ' static [ CommandSpec ]"}],"module":"query","target":"rust"} napl-store event v1 158 @@ -320373,6 +325706,8 @@ fn display_path(workspace: &Path, path: &Path) -> String { } napl-store surface v1 775 {"contractVersion":3,"items":[{"kind":"const","path":"check::CHECK_VERSION","signature":"pub const CHECK_VERSION : u32 = 1 ;"},{"kind":"const","path":"document::DOC_VERSION","signature":"pub const DOC_VERSION : u32 = 1 ;"},{"kind":"fn","path":"document::build_document","signature":"pub fn build_document ( workspace : & Path ) -> Result < Value >"},{"kind":"fn","path":"document::build_document_view","signature":"pub fn build_document_view ( workspace : & Path , working_tree : bool ) -> Result < Value >"},{"kind":"const","path":"query::OUTPUT_CEILING_BYTES","signature":"pub const OUTPUT_CEILING_BYTES : usize = 64 * 1024 * 1024 ;"},{"kind":"fn","path":"query::commands","signature":"pub fn commands ( ) -> & ' static [ CommandSpec ]"}],"module":"query","target":"rust"} +napl-store receipt v1 5187 +{"baseStateKey":"76f91323327cf2f0a66946473c1176b3988c304d57f2c6c67626991dd449cdd1","baseStoreSnapshot":"8e90fde170fdfbdb66e60ba86860b0542b2f64e24aa2d3a2c3c68c00d416778a","baseWorkspaceHash":"c2160459d601287b125dd9e04275b2d124e77971fb4491f94eb960da14e33e39","candidateTreeHash":"8517b3e9703a1d334fa7abda775c92fb82cc21b953ae890ba52cc02ad12d3c66","contractVersion":4,"inputKey":"fb8bca658e68497a7f4c93ec86167669020fd6ecec1655366e918526221b1a74","module":"napl","outputManifest":[{"hash":"12166b5fd9554e4781dd2efb828ac24b17228fab1a08ad7df51069e20f8efb0f","path":"crates/napl/src/attribution.rs"},{"hash":"c777943d5bb9e51d363d75a4b45ef5b79311f0ccc4c4de6b471d93664ac0540c","path":"crates/napl/src/authoring.rs"},{"hash":"4b44f76ec9cece4314358f54861f937a5c4e875e93646399a3abbb09c2a51bea","path":"crates/napl/src/blame.rs"},{"hash":"02d2ea0745e17a3fdc17f2657ee532dfb748423bced9c1302e3178f45dfab10a","path":"crates/napl/src/candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","path":"crates/napl/src/canon.rs"},{"hash":"d85bfa83452d95a4eeb9b36dce2774cf0d64189608cf962441406db9b7fe5a3b","path":"crates/napl/src/capture.rs"},{"hash":"f5e2132da4e24805a9c5d93e78db873618fea004116575dac3a9e60446b01c20","path":"crates/napl/src/cli.rs"},{"hash":"b234a71b43518e1fac23f8b600230deca8ca847391e80393f26eb556fca4c8c2","path":"crates/napl/src/commandset.rs"},{"hash":"6dab721be0eba11dbf2919eb60718ca9689e4dbbf5e9b6abd233a795144a3cb8","path":"crates/napl/src/commit.rs"},{"hash":"5287b1e356febbfb1bb42750c5d0e218597176a3fee19f387b975d84aa0cc11b","path":"crates/napl/src/compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","path":"crates/napl/src/cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","path":"crates/napl/src/decision.rs"},{"hash":"9321a742b82fb57726c27849b15e2d1865e3521dd95838968055836eca5ac06f","path":"crates/napl/src/directive.rs"},{"hash":"94a48069d0b3d0b8deea53f54a9826ba0c934c231d8092f57a51e2618f0b1991","path":"crates/napl/src/disk.rs"},{"hash":"bba24de15a64304a2c47ff84ea2d957dd65f77a450ab1f813ef2d51fb69e7cc0","path":"crates/napl/src/error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","path":"crates/napl/src/evidence.rs"},{"hash":"b58c9905a5161853627760881b34a4296428afb5e07610dee4a3a908a5a63559","path":"crates/napl/src/git.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","path":"crates/napl/src/graph.rs"},{"hash":"bcacc1842bc9440afe08e7d396622023b8ce1db9d04b796d2842fb704c7b2b6d","path":"crates/napl/src/lint.rs"},{"hash":"0f2e50ef0121924926bdadaa29157d5abb2159c2308a44edb3e3e8fde77fbc0c","path":"crates/napl/src/lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","path":"crates/napl/src/main.rs"},{"hash":"1a3eb8a85e1d9905dd35f074dace13f08ddcd15c4b345c449bd73143ea71fb03","path":"crates/napl/src/maintenance.rs"},{"hash":"697b0b828643d41b8ce208d441a0e11fdfe044cc743514830d3c36287b46a778","path":"crates/napl/src/mv.rs"},{"hash":"7cf9c419b2c4f7c7b17e2c1e4362861de714f4ffe54727c1a0fcb749b2e6975b","path":"crates/napl/src/objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","path":"crates/napl/src/paths.rs"},{"hash":"ec7f6eba08072fca5e00c2518a34a059f3768a18473cf7a19452b476c4855776","path":"crates/napl/src/porcelain.rs"},{"hash":"156ab704b4d1d385105fd45dfe9ffa5af5cdbf7344eac0c70c3c4d26b6583167","path":"crates/napl/src/receipt.rs"},{"hash":"d3b2ea92ec386819cf5437dfdc1c7edae62cc79bb1ca259139462b71af4fea13","path":"crates/napl/src/remove.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","path":"crates/napl/src/slice.rs"},{"hash":"2c24abb95918f8bd106460536d1b502075a3e7cd6dcd9bf6e94a7e5d703d1140","path":"crates/napl/src/source.rs"},{"hash":"2e88434ca61348aa7f7a71203f65f08fd9421ce60ace0ff5d2cea854bf3f60c3","path":"crates/napl/src/staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","path":"crates/napl/src/store_view.rs"},{"hash":"5e9e418d1a1e8dccd7207bdf85f39d21e510760135c6cc2be2f149e4b82a6f78","path":"crates/napl/src/surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","path":"crates/napl/src/target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","path":"crates/napl/src/textdiff.rs"},{"hash":"bfd8ff52f2306a963616bda4a97971a9daebe88e75f34bb2f06960ea92feac39","path":"crates/napl/src/transaction.rs"},{"hash":"b73e4b4242b073030bdaf119b375999eec7f383111a0b02f8e73505eb0ecc76d","path":"crates/napl/src/transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","path":"crates/napl/src/trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","path":"crates/napl/src/version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","path":"crates/napl/src/workspace.rs"}],"receiptVersion":1,"reported":{"evidence":"unavailable","gates":"unavailable","notes":"unavailable","producer":"unavailable","usage":"unavailable"},"sliceHash":"b8c58fa0cc35ff688a94401423ceb47396e201f13cee6abe0a804806f4fd59ac","target":"rust"} napl-store module-state v1 635 {"attribution":{"promptHash":"d3b4f7308b35a3105c66bbbbec005cb91288ecd8476e45e4e46019e1dda2d3f6","record":"b33dca2b802e668813996ab89f19b5240badb523565f3230638cad68e59c0895","stale":false},"contractVersion":4,"inputKey":"36211dd33eb13d50cba7a259897552448f3000280d5fac318094e157e7bae297","module":"serve","outputTree":"0adaa7a3a40693f6d0850446b7802cdb832adad923d7738f2f1bd8017eaa9042","prompt":"552d4c4d5476b26a91d581f0c430dfaf8b2d1f3d7eed2bc6c808e4148a666cc7","sliceHash":"2e8b9a22cc56eec82899ebd728a86affcab659928239904636476041b2683b8b","surfaceHash":"0a742d0232e4b7000bd79ff29b39af501675254174d2c98163f8b7337cf7f0b7","target":"rust"} napl-store tree v1 255 @@ -324233,6 +329568,8 @@ napl-store receipt v1 703 {"baseStateKey":"a16fdf3bc7ab489d5610d2f9cf9917b2501ce06cc5b8dd236cc5464f6c93d7f1","baseStoreSnapshot":"8693e822f44a2d7e65cb5fd1e13babef8d7e44492a91b8ff979f8899a6ffad43","baseWorkspaceHash":"e0333e9ca0f11dc00a94f410e47c0285fab72fe5776962d435a09022cf797e2d","candidateTreeHash":"c8cf73ffb7c118e5ad378bc58783365f8b56c59299ef2359aebad52205080d68","contractVersion":4,"inputKey":"602b57ca3bc09fa14f4c0b714a6a0033427803220107b835ac531c8b6b1d0904","module":"agent","outputManifest":[{"hash":"7c8a02c0cb7a845df0259b0c8cf930737f4ba70bf3bd0d01b2c03b4702e6bad1","path":"crates/napl/src/agent.rs"}],"receiptVersion":1,"sliceHash":"9c1cb9deda599e895b8ad99bd5713ecde0818f840a37ef161fcf8d51c01f32de","target":"rust"} napl-store snapshot v1 445 {"contractVersion":2,"event":"1d363c106d7c95e8eb6a65a51bc01cbacc3fe7db3a98917f01f7e7c60288db9d","states":[{"module":"commands","state":"7c7e8183b9fbf594f3d61a4ca43f5154c404f912f32b8789d32c697ae883859b","target":"rust"},{"module":"napl","state":"2b720e48dff0ed702a448fc2aeabf816dff20774f62a58d08e877549d3efab39","target":"rust"},{"module":"napl_store","state":"aa1c6feb509424d6daaf1184f30c91f91fa1840466d1b59b2b379fbed233080a","target":"rust"}]} +napl-store receipt v1 5058 +{"baseStateKey":"76f91323327cf2f0a66946473c1176b3988c304d57f2c6c67626991dd449cdd1","baseStoreSnapshot":"8e90fde170fdfbdb66e60ba86860b0542b2f64e24aa2d3a2c3c68c00d416778a","baseWorkspaceHash":"c2160459d601287b125dd9e04275b2d124e77971fb4491f94eb960da14e33e39","candidateTreeHash":"8517b3e9703a1d334fa7abda775c92fb82cc21b953ae890ba52cc02ad12d3c66","contractVersion":4,"inputKey":"fb8bca658e68497a7f4c93ec86167669020fd6ecec1655366e918526221b1a74","module":"napl","outputManifest":[{"hash":"12166b5fd9554e4781dd2efb828ac24b17228fab1a08ad7df51069e20f8efb0f","path":"crates/napl/src/attribution.rs"},{"hash":"c777943d5bb9e51d363d75a4b45ef5b79311f0ccc4c4de6b471d93664ac0540c","path":"crates/napl/src/authoring.rs"},{"hash":"4b44f76ec9cece4314358f54861f937a5c4e875e93646399a3abbb09c2a51bea","path":"crates/napl/src/blame.rs"},{"hash":"02d2ea0745e17a3fdc17f2657ee532dfb748423bced9c1302e3178f45dfab10a","path":"crates/napl/src/candidate.rs"},{"hash":"3faa233db00d2363682b5851a366833aa8811cbaf84496e1cf2897ad8ecc70bb","path":"crates/napl/src/canon.rs"},{"hash":"d85bfa83452d95a4eeb9b36dce2774cf0d64189608cf962441406db9b7fe5a3b","path":"crates/napl/src/capture.rs"},{"hash":"f5e2132da4e24805a9c5d93e78db873618fea004116575dac3a9e60446b01c20","path":"crates/napl/src/cli.rs"},{"hash":"b234a71b43518e1fac23f8b600230deca8ca847391e80393f26eb556fca4c8c2","path":"crates/napl/src/commandset.rs"},{"hash":"6dab721be0eba11dbf2919eb60718ca9689e4dbbf5e9b6abd233a795144a3cb8","path":"crates/napl/src/commit.rs"},{"hash":"5287b1e356febbfb1bb42750c5d0e218597176a3fee19f387b975d84aa0cc11b","path":"crates/napl/src/compile.rs"},{"hash":"1e6ec031075491262bdc92ec1aff9d32f21bbb3d1321eb339c63659dcf893cc7","path":"crates/napl/src/cost.rs"},{"hash":"6ffd9d1640a50ad6fadba17d56062e4356b7ea92f8800bd4294489e8aed3b998","path":"crates/napl/src/decision.rs"},{"hash":"9321a742b82fb57726c27849b15e2d1865e3521dd95838968055836eca5ac06f","path":"crates/napl/src/directive.rs"},{"hash":"94a48069d0b3d0b8deea53f54a9826ba0c934c231d8092f57a51e2618f0b1991","path":"crates/napl/src/disk.rs"},{"hash":"bba24de15a64304a2c47ff84ea2d957dd65f77a450ab1f813ef2d51fb69e7cc0","path":"crates/napl/src/error.rs"},{"hash":"8521c5326dd63ea5dca430d61d928f820693639a3980a325fdc1be59ea6fe262","path":"crates/napl/src/evidence.rs"},{"hash":"b58c9905a5161853627760881b34a4296428afb5e07610dee4a3a908a5a63559","path":"crates/napl/src/git.rs"},{"hash":"85b17a1b9da17ab41d40552bc501104970abc402df25761a7be25c4d1ab05a4e","path":"crates/napl/src/graph.rs"},{"hash":"bcacc1842bc9440afe08e7d396622023b8ce1db9d04b796d2842fb704c7b2b6d","path":"crates/napl/src/lint.rs"},{"hash":"0f2e50ef0121924926bdadaa29157d5abb2159c2308a44edb3e3e8fde77fbc0c","path":"crates/napl/src/lsp.rs"},{"hash":"80e0e839b24559e619de5666aace20ab7389de802ff768fbc9ce8c3150c60271","path":"crates/napl/src/main.rs"},{"hash":"1a3eb8a85e1d9905dd35f074dace13f08ddcd15c4b345c449bd73143ea71fb03","path":"crates/napl/src/maintenance.rs"},{"hash":"697b0b828643d41b8ce208d441a0e11fdfe044cc743514830d3c36287b46a778","path":"crates/napl/src/mv.rs"},{"hash":"7cf9c419b2c4f7c7b17e2c1e4362861de714f4ffe54727c1a0fcb749b2e6975b","path":"crates/napl/src/objects.rs"},{"hash":"9e92ff5a3097c9df3b32152e737f32532deba0032d6de15038cecf672cb27168","path":"crates/napl/src/paths.rs"},{"hash":"ec7f6eba08072fca5e00c2518a34a059f3768a18473cf7a19452b476c4855776","path":"crates/napl/src/porcelain.rs"},{"hash":"156ab704b4d1d385105fd45dfe9ffa5af5cdbf7344eac0c70c3c4d26b6583167","path":"crates/napl/src/receipt.rs"},{"hash":"d3b2ea92ec386819cf5437dfdc1c7edae62cc79bb1ca259139462b71af4fea13","path":"crates/napl/src/remove.rs"},{"hash":"8faf926daba4538c95333ddd270498828a68f32f8f0a4b380b48e35810706d5a","path":"crates/napl/src/slice.rs"},{"hash":"2c24abb95918f8bd106460536d1b502075a3e7cd6dcd9bf6e94a7e5d703d1140","path":"crates/napl/src/source.rs"},{"hash":"2e88434ca61348aa7f7a71203f65f08fd9421ce60ace0ff5d2cea854bf3f60c3","path":"crates/napl/src/staging.rs"},{"hash":"ed34df3401ceaf64854bbd58ca30c07896b322b8015c49894754f9b4bf683ab8","path":"crates/napl/src/store_view.rs"},{"hash":"5e9e418d1a1e8dccd7207bdf85f39d21e510760135c6cc2be2f149e4b82a6f78","path":"crates/napl/src/surface.rs"},{"hash":"8bc4054be1568b2a5ac806e3b2397bd5dc2a9e3545d0b22139fc9035b1e27344","path":"crates/napl/src/target.rs"},{"hash":"d771457904f7ce715e4dbfbd2df8661fd038495e187b21825d77f8eea1629261","path":"crates/napl/src/textdiff.rs"},{"hash":"bfd8ff52f2306a963616bda4a97971a9daebe88e75f34bb2f06960ea92feac39","path":"crates/napl/src/transaction.rs"},{"hash":"b73e4b4242b073030bdaf119b375999eec7f383111a0b02f8e73505eb0ecc76d","path":"crates/napl/src/transport.rs"},{"hash":"060f85113b40c92ae17fd164b833840746c3801d3c5bc6e30284d503b80846a6","path":"crates/napl/src/trees.rs"},{"hash":"5bd3cf6c6e136c4fe3731558a58ed703f709259c34fc3a7445642050fe220eaa","path":"crates/napl/src/version.rs"},{"hash":"fea90d3b4d07ff0aa209694d922099430bd1816f7d7988b32f1b40e1e1b35bef","path":"crates/napl/src/workspace.rs"}],"receiptVersion":1,"sliceHash":"b8c58fa0cc35ff688a94401423ceb47396e201f13cee6abe0a804806f4fd59ac","target":"rust"} napl-store tree v1 132 {"entries":[{"hash":"b28c3ce823ddaca68bc90ef229f8b01c86fbd79261ba17e33a127123970e1f0f","kind":"dir","mode":16384,"name":"crates"}]} napl-store blob v1 13015 diff --git a/crates/napl/napl.napl b/crates/napl/napl.napl index 7db25c03..e98395e8 100644 --- a/crates/napl/napl.napl +++ b/crates/napl/napl.napl @@ -1781,6 +1781,12 @@ given: { workspace: fixture_with_multi_commit_history, then: valid_commit } expect: { commit_outcome: installed, repack_reported: true, packs: 1, loose_reachable_remaining: 0 } ::: +::: test +name: "a docs-only commit repacks through the same seam and leaves one pack, its index, and HEAD" +given: { workspace: fixture_with_multi_commit_history, then: docs_only_commit } +expect: { commit_kind: docs-only, repack_reported: true, packs: 1, loose_reachable_remaining: 0, store_paths: [pack, idx, HEAD] } +::: + ::: test name: two commits in a row each land the whole closure in one pack and the store answers identically given: { workspace: fixture_with_multi_commit_history, then: [valid_commit, valid_commit] } diff --git a/crates/napl/src/commit.rs b/crates/napl/src/commit.rs index bf910ffa..aca0b6d1 100644 --- a/crates/napl/src/commit.rs +++ b/crates/napl/src/commit.rs @@ -468,7 +468,7 @@ fn run_docs(workspace: &Path, record: &Path, published: &mut Published) -> Resul published.blobs = true; published.provenance = true; - let payload = install_docs( + let mut payload = install_docs( &store, &target_name, &module, @@ -478,6 +478,7 @@ fn run_docs(workspace: &Path, record: &Path, published: &mut Published) -> Resul &object, &prompt_path, )?; + crate::porcelain::repack_if_loose(&store)?.record(&mut payload); Ok(payload) } diff --git a/crates/napl/tests/packfiles.rs b/crates/napl/tests/packfiles.rs index 78088f8a..c5e66366 100644 --- a/crates/napl/tests/packfiles.rs +++ b/crates/napl/tests/packfiles.rs @@ -1,8 +1,9 @@ //! Packfiles end to end against the real binary. //! -//! `gc --apply` repacks, every successful commit repacks itself and says so, two -//! commits in a row each leave one pack and its index, and the pack the -//! automatic path writes is the pack `gc` would have written. +//! `gc --apply` repacks, every successful commit repacks itself and says so +//! through the code door and the docs door alike, two commits in a row each +//! leave one pack and its index, and the pack the automatic path writes is the +//! pack `gc` would have written. mod common; @@ -265,6 +266,49 @@ fn every_successful_commit_repacks_and_leaves_one_pack_its_index_and_no_reachabl assert_eq!(json(&report)["findings"].as_array().unwrap().len(), 0); } +#[test] +fn a_docs_only_commit_repacks_and_leaves_one_pack_its_index_and_head() { + let (_scratch, ws) = with_history("docs-commit-repacks"); + write( + &ws.join("crates/base/base.napl"), + "---\nmodule: base\ndeps: []\nroots: [base]\ntargets: [rust]\n---\n# base\n\nExpose a greeting.\n\n::: docs\nDisplay prose nobody hashes.\n:::\n", + ); + + let (code, out, err) = run(&ws, &["add", "crates/base/base.napl"]); + assert_eq!(code, 0, "staging the docs edit failed: {err}{out}"); + let staged = json(&out); + assert_eq!(staged["records"][0]["kind"], Value::String("docs".to_string()), "{staged}"); + let record = staged["record"].as_str().expect("a single record is named").to_string(); + + let (code, out, err) = run(&ws, &["commit", "--staged", &record]); + assert_eq!(code, 0, "the docs commit failed: {err}{out}"); + let payload = json(&out); + assert_eq!(payload["outcome"], Value::String("success".to_string()), "{payload}"); + assert_eq!(payload["kind"], Value::String("docs-only".to_string()), "{payload}"); + + let repack = payload["repack"].clone(); + assert!( + repack["pack"].is_string(), + "the docs door packs through the same seam a code commit does: {payload}" + ); + let pack = repack["pack"].as_str().unwrap().to_string(); + assert_eq!(packs(&ws), vec![pack.clone()], "one pack holds the world"); + assert_eq!(loose(&ws).len(), 0, "and nothing is left loose"); + assert_eq!( + store_object_files(&ws), + vec![ + format!("objects/pack/pack-{pack}.idx"), + format!("objects/pack/pack-{pack}.naplpack"), + ], + "the store a reader sees is the pack and its index" + ); + assert!(ws.join(".napl/store/HEAD").is_file(), "beside HEAD"); + + let (code, report, err) = run(&ws, &["fsck"]); + assert_eq!(code, 0, "{err}{report}"); + assert_eq!(json(&report)["findings"].as_array().unwrap().len(), 0); +} + #[test] fn two_commits_in_a_row_each_land_the_whole_closure_in_one_pack_and_the_store_answers_identically() {