diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4df927..eea79d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,17 +6,16 @@ on: branches: [main] jobs: - test: + rust: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: dtolnay/rust-toolchain@1.94.0 with: - python-version: ${{ matrix.python-version }} - - run: python -m pip install -r requirements-lock.txt - - run: python -m pip install -e . --no-deps - - run: python -m unittest discover -s tests -p 'test_*.py' + components: clippy,rustfmt + - run: cargo fmt --all -- --check + - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - run: cargo test --workspace --all-features --locked + - run: cargo build --workspace --release --locked + - run: scripts/ndf check --root tests/fixtures/markdown/project --format json - run: git diff --check diff --git a/.gitignore b/.gitignore index 6c8e6a3..710d0b5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ *.sqlite __pycache__/ *.py[cod] +/target/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ffc54..59ac414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to NDF are documented here. ### Added +- Standalone Rust 1.94.0 knowledge compiler and versioned JSON CLI. - Canonical graph IR 0.1 with project-scoped `ndf://` identities. - Strict Markdown, PTO ASL, repository, test, and evidence adapters. - Deterministic SQLite schema v1 with semantic build hashes and provenance. @@ -15,6 +16,8 @@ All notable changes to NDF are documented here. ### Changed +- Replaced the pre-release Python prototype with a Rust-only workspace; Python + remains available only in Git history as migration evidence. - NDF format 0.2 separates `kind`, `modality`, `refinement`, `domain`, and `status`. - SQLite is defined as derived, disposable state rather than an authored @@ -26,4 +29,5 @@ All notable changes to NDF are documented here. adapter. - PTO-SPEC ASL retains architecture authority and is normalized through the PTO adapter. +- NDF has no supported Python package, import API, or PyO3 compatibility layer. - Format, IR, JSON output, CLI, and plugin surfaces remain experimental. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..921376f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,856 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "ndf-cli" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "clap", + "ndf-compiler", + "ndf-core", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "ndf-compiler" +version = "0.1.0" +dependencies = [ + "globset", + "ndf-core", + "rusqlite", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2", + "tempfile", + "thiserror", + "walkdir", +] + +[[package]] +name = "ndf-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f215412 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[workspace] +members = [ + "crates/ndf-core", + "crates/ndf-compiler", + "crates/ndf-cli", +] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.94.0" +license = "Apache-2.0" + +[workspace.dependencies] +assert_cmd = "=2.2.2" +clap = { version = "=4.6.6", features = ["derive"] } +globset = "=0.4.20" +ndf-core = { path = "crates/ndf-core" } +ndf-compiler = { path = "crates/ndf-compiler" } +predicates = "=3.1.4" +pulldown-cmark = "=0.13.4" +rusqlite = { version = "=0.40.2", features = ["bundled"] } +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" +serde_yaml_ng = "=0.10.0" +sha2 = "=0.11.0" +tempfile = "=3.27.0" +thiserror = "=2.0.20" +walkdir = "=2.5.0" diff --git a/README.md b/README.md index a5d2633..fe2ef9a 100644 --- a/README.md +++ b/README.md @@ -6,20 +6,27 @@ truth; generated indexes are disposable query artifacts. ## Development -NDF requires Python 3.11 or newer. +NDF requires Rust 1.94.0. The repository is a locked Cargo workspace: ```bash -python -m pip install -r requirements-lock.txt -python -m pip install -e . --no-deps -python -m unittest discover -s tests -p 'test_*.py' +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-features --locked +cargo build --workspace --release --locked ``` Print the independently versioned public surfaces with: ```bash -ndf --machine-version +scripts/ndf --machine-version ``` +`ndf-core` owns canonical identities, graph types, and public version constants. +`ndf-compiler` owns adapters, validation, SQLite materialization, and graph +workflows. `ndf-cli` is the only supported automation interface. NDF ships no +Python package or Python bindings; downstream tools invoke the versioned JSON +CLI from an exact Git pin. + ## Documentation - [English specification](normative_language.md) diff --git a/crates/ndf-cli/Cargo.toml b/crates/ndf-cli/Cargo.toml new file mode 100644 index 0000000..73f4c1c --- /dev/null +++ b/crates/ndf-cli/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ndf-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[[bin]] +name = "ndf" +path = "src/main.rs" + +[dependencies] +clap.workspace = true +ndf-compiler.workspace = true +ndf-core.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +tempfile.workspace = true diff --git a/crates/ndf-cli/src/args.rs b/crates/ndf-cli/src/args.rs new file mode 100644 index 0000000..302c247 --- /dev/null +++ b/crates/ndf-cli/src/args.rs @@ -0,0 +1,133 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand, ValueEnum}; + +#[derive(Debug, Parser)] +#[command(name = "ndf", arg_required_else_help = true)] +pub struct Arguments { + #[arg(long)] + pub machine_version: bool, + #[command(subcommand)] + pub command: Option, +} + +impl Arguments { + pub fn command_label(&self) -> &'static str { + match self.command.as_ref() { + Some(Command::Build { .. }) => "build", + Some(Command::Check { .. }) => "check", + Some(Command::Show { .. }) => "show", + Some(Command::Trace { .. }) => "trace", + Some(Command::Report { command }) => match command { + ReportCommand::Coverage { .. } => "report coverage", + ReportCommand::Dependencies { .. } => "report dependencies", + }, + Some(Command::Diff { .. }) => "diff", + Some(Command::Export { .. }) => "export", + Some(Command::Id { .. }) => "id allocate", + Some(Command::Fix { .. }) => "fix", + None => "unknown", + } + } +} + +#[derive(Debug, Subcommand)] +pub enum Command { + Build { + #[arg(long)] + root: PathBuf, + #[arg(long)] + output: PathBuf, + #[arg(long, value_enum, default_value_t = JsonFormat::Json)] + format: JsonFormat, + }, + Check { + #[arg(long)] + root: PathBuf, + #[arg(long, value_enum, default_value_t = JsonFormat::Json)] + format: JsonFormat, + }, + Show { + uri: String, + #[arg(long, default_value = "graph.sqlite")] + index: PathBuf, + #[arg(long, value_enum, default_value_t = JsonFormat::Json)] + format: JsonFormat, + }, + Trace { + uri: String, + #[arg(long = "edge")] + edges: Vec, + #[arg(long, default_value = "graph.sqlite")] + index: PathBuf, + #[arg(long, value_enum, default_value_t = JsonFormat::Json)] + format: JsonFormat, + }, + Report { + #[command(subcommand)] + command: ReportCommand, + }, + Diff { + before: PathBuf, + after: PathBuf, + #[arg(long, value_enum, default_value_t = JsonFormat::Json)] + format: JsonFormat, + }, + Export { + #[arg(long)] + index: PathBuf, + #[arg(long)] + output: PathBuf, + #[arg(long, value_enum)] + format: ExportFormat, + }, + Id { + #[command(subcommand)] + command: IdCommand, + }, + Fix { + paths: Vec, + #[arg(long)] + generated_only: bool, + #[arg(long)] + check: bool, + }, +} + +#[derive(Debug, Subcommand)] +pub enum ReportCommand { + Coverage { + #[arg(long, default_value = "graph.sqlite")] + index: PathBuf, + #[arg(long, value_enum, default_value_t = JsonFormat::Json)] + format: JsonFormat, + }, + Dependencies { + #[arg(long)] + root: PathBuf, + #[arg(long, value_enum, default_value_t = JsonFormat::Json)] + format: JsonFormat, + }, +} + +#[derive(Debug, Subcommand)] +pub enum IdCommand { + Allocate { + prefix: String, + #[arg(long, default_value = "graph.sqlite")] + index: PathBuf, + #[arg(long, value_enum, default_value_t = JsonFormat::Json)] + format: JsonFormat, + }, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum JsonFormat { + Json, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum ExportFormat { + #[value(name = "ndf-json")] + NdfJson, +} diff --git a/crates/ndf-cli/src/envelope.rs b/crates/ndf-cli/src/envelope.rs new file mode 100644 index 0000000..ddeac27 --- /dev/null +++ b/crates/ndf-cli/src/envelope.rs @@ -0,0 +1,52 @@ +use serde::Serialize; +use serde_json::Value; + +#[derive(Debug, Serialize)] +pub struct Envelope { + schema_version: &'static str, + command: &'static str, + pub ok: bool, + data: Value, + diagnostics: Vec, +} + +impl Envelope { + pub fn success( + command: &'static str, + data: T, + diagnostics: Vec, + ) -> Result { + Ok(Self { + schema_version: "0.1", + command, + ok: true, + data: serde_json::to_value(data)?, + diagnostics, + }) + } + + pub fn checked( + command: &'static str, + ok: bool, + data: T, + diagnostics: Vec, + ) -> Result { + Ok(Self { + schema_version: "0.1", + command, + ok, + data: serde_json::to_value(data)?, + diagnostics, + }) + } + + pub fn failure(command: &'static str, message: impl Into) -> Self { + Self { + schema_version: "0.1", + command, + ok: false, + data: Value::Object(serde_json::Map::new()), + diagnostics: vec![serde_json::json!({"message": message.into()})], + } + } +} diff --git a/crates/ndf-cli/src/main.rs b/crates/ndf-cli/src/main.rs new file mode 100644 index 0000000..71f2e33 --- /dev/null +++ b/crates/ndf-cli/src/main.rs @@ -0,0 +1,53 @@ +use std::process::ExitCode; + +use clap::Parser; +use ndf_core::version::{CLI_STABILITY, FORMAT_VERSION, IR_VERSION, PLUGIN_API, TOOL_VERSION}; +use serde::Serialize; + +use crate::args::Arguments; +use crate::envelope::Envelope; + +mod args; +mod envelope; +mod run; + +#[derive(Serialize)] +struct MachineVersion<'a> { + cli_stability: &'a str, + format_version: &'a str, + ir_version: &'a str, + plugin_api: &'a str, + tool_version: &'a str, +} + +fn main() -> ExitCode { + let arguments = Arguments::parse(); + if arguments.machine_version { + let payload = MachineVersion { + cli_stability: CLI_STABILITY, + format_version: FORMAT_VERSION, + ir_version: IR_VERSION, + plugin_api: PLUGIN_API, + tool_version: TOOL_VERSION, + }; + println!( + "{}", + serde_json::to_string(&payload).expect("machine version must serialize") + ); + return ExitCode::SUCCESS; + } + let command = arguments.command_label(); + let envelope = match run::run(arguments) { + Ok(envelope) => envelope, + Err(error) => Envelope::failure(command, error.to_string()), + }; + println!( + "{}", + serde_json::to_string(&envelope).expect("command envelope must serialize") + ); + if envelope.ok { + ExitCode::SUCCESS + } else { + ExitCode::from(1) + } +} diff --git a/crates/ndf-cli/src/run.rs b/crates/ndf-cli/src/run.rs new file mode 100644 index 0000000..3b584f3 --- /dev/null +++ b/crates/ndf-cli/src/run.rs @@ -0,0 +1,258 @@ +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Command as ProcessCommand; + +use ndf_compiler::CompilerError; +use ndf_compiler::adapters::parse_project; +use ndf_compiler::dependencies::resolve_dependencies; +use ndf_compiler::diff::semantic_diff; +use ndf_compiler::export::export_graph; +use ndf_compiler::fix::{FixMode, markdown_paths, safe_fix}; +use ndf_compiler::id_allocator::allocate_id; +use ndf_compiler::index::{BuildProvenance, NdfIndex, build_index}; +use ndf_compiler::manifest::{ProjectLock, ProjectManifest}; +use ndf_compiler::query::trace; +use ndf_compiler::report::{CoveragePolicy, coverage}; +use ndf_compiler::rules::{ValidationPolicy, validate_graph}; +use ndf_core::identity::NodeId; +use ndf_core::model::{Diagnostic, DiagnosticSeverity, Graph}; +use ndf_core::version::{FORMAT_VERSION, IR_VERSION, TOOL_VERSION}; +use serde_json::{Value, json}; + +use crate::args::{Arguments, Command, IdCommand, ReportCommand}; +use crate::envelope::Envelope; + +pub fn run(arguments: Arguments) -> Result { + match arguments.command { + Some(Command::Build { + root, + output, + format: _, + }) => { + let (manifest, graph) = project_graph(&root)?; + let build = build_index(&graph, &output, &provenance(&root, &manifest))?; + Envelope::checked( + "build", + !has_errors(&graph), + json!({ + "output": build.output, + "semantic_hash": build.semantic_hash, + "node_count": build.node_count, + "edge_count": build.edge_count, + }), + diagnostic_values(&graph.diagnostics)?, + ) + .map_err(Into::into) + } + Some(Command::Check { root, format: _ }) => { + let (_, graph) = project_graph(&root)?; + Envelope::checked( + "check", + !has_errors(&graph), + json!({ + "node_count": graph.nodes.len(), + "edge_count": graph.edges.len(), + }), + diagnostic_values(&graph.diagnostics)?, + ) + .map_err(Into::into) + } + Some(Command::Show { + uri, + index, + format: _, + }) => { + let index = NdfIndex::open(&index)?; + let resolved = index.resolve_uri(&uri)?; + let node = index + .get_node(&resolved)? + .ok_or_else(|| CompilerError::Contract(format!("unknown NDF ID: {uri}")))?; + Envelope::success("show", node, Vec::new()).map_err(Into::into) + } + Some(Command::Trace { + uri, + edges, + index, + format: _, + }) => { + let index = NdfIndex::open(&index)?; + let edge_types: Vec<_> = if edges.is_empty() { + vec!["refines"] + } else { + edges.iter().map(String::as_str).collect() + }; + Envelope::success("trace", trace(&index, &uri, &edge_types)?, Vec::new()) + .map_err(Into::into) + } + Some(Command::Report { command }) => match command { + ReportCommand::Coverage { index, format: _ } => { + let index = NdfIndex::open(&index)?; + Envelope::success( + "report coverage", + coverage(&index, &CoveragePolicy::default())?, + Vec::new(), + ) + .map_err(Into::into) + } + ReportCommand::Dependencies { root, format: _ } => { + let manifest = ProjectManifest::load(&root.join("ndf.yaml"))?; + let lock = ProjectLock::load(&root.join("ndf.lock"))?; + Envelope::success( + "report dependencies", + resolve_dependencies(&manifest, &lock, &root)?, + Vec::new(), + ) + .map_err(Into::into) + } + }, + Some(Command::Diff { + before, + after, + format: _, + }) => { + let before = NdfIndex::open(&before)?; + let after = NdfIndex::open(&after)?; + Envelope::success("diff", semantic_diff(&before, &after)?, Vec::new()) + .map_err(Into::into) + } + Some(Command::Export { + index, + output, + format: _, + }) => { + let index = NdfIndex::open(&index)?; + Envelope::success("export", export_graph(&index, &output)?, Vec::new()) + .map_err(Into::into) + } + Some(Command::Id { command }) => match command { + IdCommand::Allocate { + prefix, + index, + format: _, + } => { + let mut occupied = BTreeSet::new(); + if index.exists() { + let index = NdfIndex::open(&index)?; + for node in index.all_nodes()? { + let id = NodeId::parse(&node.id, None) + .map_err(|error| CompilerError::Contract(error.to_string()))?; + occupied.insert(id.local_id().to_owned()); + } + } + Envelope::success( + "id allocate", + json!({"id": allocate_id(&prefix, &occupied)?}), + Vec::new(), + ) + .map_err(Into::into) + } + }, + Some(Command::Fix { + mut paths, + generated_only, + check, + }) => { + if paths.is_empty() { + paths = markdown_paths(&std::env::current_dir().map_err(|source| { + CompilerError::Read { + path: PathBuf::from("."), + source, + } + })?)?; + } + let result = safe_fix( + &paths, + &FixMode { + generated_only, + check, + ..FixMode::default() + }, + )?; + Envelope::checked( + "fix", + result.diagnostics.is_empty(), + json!({"changed": result.changed}), + diagnostic_values(&result.diagnostics)?, + ) + .map_err(Into::into) + } + None => Err(CompilerError::Contract("a command is required".to_owned())), + } +} + +fn project_graph(root: &Path) -> Result<(ProjectManifest, Graph), CompilerError> { + let manifest = ProjectManifest::load(&root.join("ndf.yaml"))?; + let parsed = parse_project(&manifest)?; + let diagnostics = validate_graph(&parsed, &ValidationPolicy::default()); + Ok(( + manifest, + Graph { + nodes: parsed.nodes, + edges: parsed.edges, + diagnostics, + }, + )) +} + +fn provenance(root: &Path, manifest: &ProjectManifest) -> BuildProvenance { + BuildProvenance { + project_commits: vec![(manifest.project.clone(), revision(root))], + format_version: FORMAT_VERSION.to_owned(), + ir_version: IR_VERSION.to_owned(), + tool_version: TOOL_VERSION.to_owned(), + normative_roots: normative_roots(root, manifest), + } +} + +fn revision(root: &Path) -> String { + let output = ProcessCommand::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(root) + .output(); + let revision = output + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|value| value.trim().to_owned()) + .unwrap_or_default(); + if revision.len() == 40 { + revision + } else { + "0".repeat(40) + } +} + +fn normative_roots(root: &Path, manifest: &ProjectManifest) -> Vec { + manifest + .roots + .iter() + .map(|pattern| { + let prefix = pattern + .find(['*', '?', '[']) + .map_or(pattern.as_str(), |index| &pattern[..index]) + .trim_end_matches('/'); + if prefix.is_empty() { + root.to_path_buf() + } else { + root.join(prefix) + } + }) + .collect::>() + .into_iter() + .collect() +} + +fn diagnostic_values(diagnostics: &[Diagnostic]) -> Result, CompilerError> { + diagnostics + .iter() + .map(serde_json::to_value) + .collect::>() + .map_err(Into::into) +} + +fn has_errors(graph: &Graph) -> bool { + graph + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) +} diff --git a/crates/ndf-cli/tests/commands.rs b/crates/ndf-cli/tests/commands.rs new file mode 100644 index 0000000..f5b3358 --- /dev/null +++ b/crates/ndf-cli/tests/commands.rs @@ -0,0 +1,179 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::Value; + +fn repository() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn ndf(cwd: &Path, arguments: &[&str]) -> (i32, Value) { + let output = Command::new(assert_cmd::cargo::cargo_bin!("ndf")) + .args(arguments) + .current_dir(cwd) + .output() + .unwrap(); + let payload = serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "invalid JSON ({error}): stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }); + (output.status.code().unwrap(), payload) +} + +fn assert_envelope(status: i32, payload: &Value, command: &str) { + assert_eq!(status, 0, "{payload}"); + assert_eq!(payload["schema_version"], "0.1"); + assert_eq!(payload["command"], command); + assert_eq!(payload["ok"], true); + assert!(payload.get("data").is_some()); + assert!(payload["diagnostics"].is_array()); +} + +#[test] +fn graph_commands_emit_versioned_machine_envelopes() { + let directory = tempfile::tempdir().unwrap(); + let fixture = repository().join("tests/fixtures/markdown/project"); + let root = fixture.to_string_lossy(); + let commands: &[(&[&str], &str)] = &[ + ( + &[ + "build", + "--root", + &root, + "--output", + "graph.sqlite", + "--format", + "json", + ], + "build", + ), + (&["check", "--root", &root, "--format", "json"], "check"), + ( + &[ + "show", + "ndf://fixture/PIPE-ISSUE-001", + "--index", + "graph.sqlite", + "--format", + "json", + ], + "show", + ), + ( + &[ + "trace", + "PIPE-ISSUE-010", + "--edge", + "refines", + "--format", + "json", + ], + "trace", + ), + ( + &["report", "coverage", "--format", "json"], + "report coverage", + ), + ( + &["diff", "graph.sqlite", "graph.sqlite", "--format", "json"], + "diff", + ), + ( + &[ + "export", + "--index", + "graph.sqlite", + "--output", + "graph.json", + "--format", + "ndf-json", + ], + "export", + ), + ( + &["id", "allocate", "PIPE-ISSUE", "--format", "json"], + "id allocate", + ), + (&["fix", "--generated-only", "--check"], "fix"), + ]; + + for (arguments, command) in commands { + let (status, payload) = ndf(directory.path(), arguments); + assert_envelope(status, &payload, command); + } +} + +#[test] +fn dependency_report_reads_an_exact_local_checkout() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let dependency = root.join("deps/tool"); + fs::create_dir_all(&dependency).unwrap(); + for arguments in [ + &["init", "-q"][..], + &["config", "user.email", "ndf@example.invalid"], + &["config", "user.name", "NDF Test"], + ] { + assert!( + Command::new("git") + .args(arguments) + .current_dir(&dependency) + .status() + .unwrap() + .success() + ); + } + fs::write(dependency.join("README.md"), "fixture\n").unwrap(); + for arguments in [&["add", "README.md"][..], &["commit", "-qm", "fixture"]] { + assert!( + Command::new("git") + .args(arguments) + .current_dir(&dependency) + .status() + .unwrap() + .success() + ); + } + let revision = String::from_utf8( + Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&dependency) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + let revision = revision.trim(); + fs::write( + root.join("ndf.yaml"), + "format_version: '0.2'\nproject: fixture\nroots: []\nid_prefixes: [REQ]\ndependencies:\n tool:\n path: deps/tool\n", + ) + .unwrap(); + fs::write( + root.join("ndf.lock"), + format!( + "format_version: '0.1'\ndependencies:\n tool:\n uri: https://example.invalid/tool.git\n revision: {revision}\n path: deps/tool\n" + ), + ) + .unwrap(); + + let root_argument = root.to_string_lossy(); + let (status, payload) = ndf( + root, + &[ + "report", + "dependencies", + "--root", + &root_argument, + "--format", + "json", + ], + ); + + assert_envelope(status, &payload, "report dependencies"); + assert_eq!(payload["data"][0]["revision"], revision); +} diff --git a/crates/ndf-cli/tests/errors.rs b/crates/ndf-cli/tests/errors.rs new file mode 100644 index 0000000..57aaecf --- /dev/null +++ b/crates/ndf-cli/tests/errors.rs @@ -0,0 +1,71 @@ +use std::collections::BTreeSet; +use std::path::Path; +use std::process::Command; + +use ndf_compiler::fix::{FixMode, safe_fix}; +use ndf_compiler::id_allocator::allocate_id; +use serde_json::Value; + +#[test] +fn invalid_invocation_exits_two_and_runtime_errors_use_the_envelope() { + let binary = assert_cmd::cargo::cargo_bin!("ndf"); + let invalid = Command::new(binary) + .args(["build", "--unknown"]) + .output() + .unwrap(); + assert_eq!(invalid.status.code(), Some(2)); + + let directory = tempfile::tempdir().unwrap(); + let runtime = Command::new(binary) + .args(["show", "MISSING", "--index", "missing.sqlite"]) + .current_dir(directory.path()) + .output() + .unwrap(); + let payload: Value = serde_json::from_slice(&runtime.stdout).unwrap(); + assert_eq!(runtime.status.code(), Some(1)); + assert_eq!(payload["schema_version"], "0.1"); + assert_eq!(payload["command"], "show"); + assert_eq!(payload["ok"], false); + assert!(payload["diagnostics"][0]["message"].is_string()); +} + +#[test] +fn id_allocation_is_monotonic_and_safe_fix_refuses_semantic_changes() { + let occupied = BTreeSet::from(["PIPE-ISSUE-001".to_owned(), "PIPE-ISSUE-003".to_owned()]); + assert_eq!( + allocate_id("PIPE-ISSUE", &occupied).unwrap(), + "PIPE-ISSUE-004" + ); + assert!(allocate_id("pipe issue", &occupied).is_err()); + + let result = safe_fix( + &[], + &FixMode { + requested_changes: BTreeSet::from(["status".to_owned()]), + ..FixMode::default() + }, + ) + .unwrap(); + assert_eq!(result.diagnostics[0].code, "NDF-FIX-001"); +} + +#[test] +fn fix_check_reports_reordering_without_writing() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("design.md"); + let original = "\n"; + std::fs::write(&path, original).unwrap(); + + let result = safe_fix( + std::slice::from_ref(&path), + &FixMode { + check: true, + ..FixMode::default() + }, + ) + .unwrap(); + + assert_eq!(result.changed, [path.to_string_lossy()]); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original); + assert_eq!(Path::new(&result.changed[0]), path); +} diff --git a/crates/ndf-cli/tests/machine_version.rs b/crates/ndf-cli/tests/machine_version.rs new file mode 100644 index 0000000..046a868 --- /dev/null +++ b/crates/ndf-cli/tests/machine_version.rs @@ -0,0 +1,23 @@ +use assert_cmd::Command; +use serde_json::Value; + +#[test] +fn machine_version_matches_the_frozen_contract() { + let expected: Value = + serde_json::from_str(include_str!("../../../tests/golden/machine-version.json")) + .expect("machine-version golden JSON must parse"); + + let output = Command::new(assert_cmd::cargo::cargo_bin!("ndf")) + .arg("--machine-version") + .output() + .expect("ndf must run"); + + assert_eq!( + output.status.code(), + expected["exit_code"].as_i64().map(|value| value as i32) + ); + assert_eq!( + serde_json::from_slice::(&output.stdout).expect("ndf must emit JSON"), + expected["payload"], + ); +} diff --git a/crates/ndf-cli/tests/release_contract.rs b/crates/ndf-cli/tests/release_contract.rs new file mode 100644 index 0000000..715e4a1 --- /dev/null +++ b/crates/ndf-cli/tests/release_contract.rs @@ -0,0 +1,18 @@ +use std::path::{Path, PathBuf}; + +fn repository() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +#[test] +fn release_tree_has_no_python_ndf_runtime() { + let root = repository(); + for forbidden in ["pyproject.toml", "requirements-lock.txt", "src/ndf"] { + assert!( + !root.join(forbidden).exists(), + "{forbidden} must be removed" + ); + } + assert!(root.join("Cargo.lock").is_file()); + assert!(root.join("scripts/ndf").is_file()); +} diff --git a/crates/ndf-compiler/Cargo.toml b/crates/ndf-compiler/Cargo.toml new file mode 100644 index 0000000..bf2a070 --- /dev/null +++ b/crates/ndf-compiler/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ndf-compiler" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +globset.workspace = true +ndf-core.workspace = true +rusqlite.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml_ng.workspace = true +sha2.workspace = true +tempfile.workspace = true +thiserror.workspace = true +walkdir.workspace = true + +[dev-dependencies] +serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/ndf-compiler/src/adapters/evidence.rs b/crates/ndf-compiler/src/adapters/evidence.rs new file mode 100644 index 0000000..5ea6a41 --- /dev/null +++ b/crates/ndf-compiler/src/adapters/evidence.rs @@ -0,0 +1,183 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use ndf_core::identity::NodeId; +use ndf_core::model::{ + Diagnostic, DiagnosticSeverity, Edge, LifecycleStatus, Node, NodeKind, ParseResult, SourceSpan, +}; +use serde_json::{Map, Value}; + +use crate::CompilerError; +use crate::manifest::ProjectManifest; + +const REQUIRED: &[&str] = &[ + "format_version", + "id", + "command", + "result", + "tool_version", + "source_revision", + "timestamp", + "links", +]; + +pub fn parse_evidence_manifest( + path: &Path, + project: &ProjectManifest, +) -> Result { + let relative = path + .strip_prefix(&project.root) + .map(|value| value.to_string_lossy().replace('\\', "/")) + .map_err(|_| { + CompilerError::Contract(format!( + "evidence is outside project root: {}", + path.display() + )) + })?; + let text = fs::read_to_string(path).map_err(|source| CompilerError::Read { + path: path.to_path_buf(), + source, + })?; + let record: Value = match serde_json::from_str(&text) { + Ok(record) => record, + Err(error) => { + return Ok(invalid( + &relative, + error.line() as u32, + format!("invalid evidence JSON: {error}"), + "Repair the JSON evidence manifest.", + )); + } + }; + let Some(record) = record.as_object() else { + return Ok(schema_error(&relative)); + }; + if !valid_schema(record) { + return Ok(schema_error(&relative)); + } + + let identity = NodeId::new( + &project.project, + record["id"].as_str().expect("validated string"), + ) + .map_err(|error| CompilerError::Contract(error.to_string()))?; + let node = Node { + id: identity.clone(), + kind: NodeKind::Evidence, + title: record["command"] + .as_str() + .expect("validated string") + .to_owned(), + source: SourceSpan::new(&relative, 1).expect("line one is valid"), + modality: None, + refinement: None, + domain: None, + status: LifecycleStatus::Active, + owner: Some(project.project.clone()), + body: String::new(), + attributes: [ + "command", + "result", + "tool_version", + "source_revision", + "timestamp", + ] + .into_iter() + .map(|key| { + ( + key.to_owned(), + record[key].as_str().expect("validated string").to_owned(), + ) + }) + .collect(), + }; + let mut edges = Vec::new(); + for link in record["links"].as_array().expect("validated links") { + let link = link.as_object().expect("validated link object"); + let source = NodeId::parse( + link["clause"].as_str().expect("validated string"), + Some(&project.project), + ) + .map_err(|error| CompilerError::Contract(error.to_string()))?; + edges.push( + Edge::new( + source, + identity.clone(), + "evidenced-by", + Some(SourceSpan::new(&relative, 1).expect("line one is valid")), + ) + .expect("built-in edge is non-empty"), + ); + } + Ok(ParseResult { + nodes: vec![node], + edges, + diagnostics: Vec::new(), + }) +} + +fn valid_schema(record: &Map) -> bool { + let keys: BTreeSet<_> = record.keys().map(String::as_str).collect(); + let required: BTreeSet<_> = REQUIRED.iter().copied().collect(); + if keys != required + || record["format_version"].as_str() != Some("0.1") + || !["pass", "fail", "inconclusive"].contains(&record["result"].as_str().unwrap_or("")) + || ![ + "id", + "command", + "tool_version", + "source_revision", + "timestamp", + ] + .into_iter() + .all(|key| record[key].is_string()) + { + return false; + } + let revision = record["source_revision"].as_str().expect("string checked"); + let timestamp = record["timestamp"].as_str().expect("string checked"); + if revision.len() != 40 + || !revision + .chars() + .all(|character| character.is_ascii_hexdigit() && !character.is_ascii_uppercase()) + || !timestamp.contains('T') + || !(timestamp.ends_with('Z') || timestamp.contains('+')) + { + return false; + } + record["links"].as_array().is_some_and(|links| { + links.iter().all(|link| { + let Some(link) = link.as_object() else { + return false; + }; + link.len() == 2 + && link.get("clause").is_some_and(Value::is_string) + && link.get("type").and_then(Value::as_str) == Some("evidenced-by") + }) + }) +} + +fn schema_error(path: &str) -> ParseResult { + invalid( + path, + 1, + "evidence manifest does not match the required fields", + "Provide every reproducibility field and no unknown fields.", + ) +} + +fn invalid(path: &str, line: u32, message: impl Into, repair: &str) -> ParseResult { + ParseResult { + diagnostics: vec![Diagnostic { + code: "NDF-EVID-001".to_owned(), + severity: DiagnosticSeverity::Error, + message: message.into(), + primary: SourceSpan::new(path, line.max(1)).expect("positive JSON line"), + related: Vec::new(), + repair: Some(repair.to_owned()), + subject: None, + }], + ..ParseResult::default() + } +} diff --git a/crates/ndf-compiler/src/adapters/mod.rs b/crates/ndf-compiler/src/adapters/mod.rs new file mode 100644 index 0000000..f8fbeb4 --- /dev/null +++ b/crates/ndf-compiler/src/adapters/mod.rs @@ -0,0 +1,119 @@ +use std::path::Path; + +use globset::{Glob, GlobSetBuilder}; +use ndf_core::graph::GraphBuilder; +use ndf_core::model::{Graph, ParseResult}; +use walkdir::WalkDir; + +use crate::CompilerError; +use crate::manifest::ProjectManifest; +use crate::markdown::parse_markdown; + +pub mod evidence; +pub mod pto_asl; +pub mod repository; + +pub trait Adapter: Sync { + fn name(&self) -> &'static str; + fn parse(&self, path: &Path, manifest: &ProjectManifest) -> Result; +} + +struct MarkdownAdapter; +struct PtoAslAdapter; +struct EvidenceAdapter; + +impl Adapter for MarkdownAdapter { + fn name(&self) -> &'static str { + "markdown" + } + + fn parse(&self, path: &Path, manifest: &ProjectManifest) -> Result { + parse_markdown(path, manifest) + } +} + +impl Adapter for PtoAslAdapter { + fn name(&self) -> &'static str { + "pto-asl" + } + + fn parse(&self, path: &Path, manifest: &ProjectManifest) -> Result { + pto_asl::parse_pto_asl(path, manifest) + } +} + +impl Adapter for EvidenceAdapter { + fn name(&self) -> &'static str { + "evidence" + } + + fn parse(&self, path: &Path, manifest: &ProjectManifest) -> Result { + evidence::parse_evidence_manifest(path, manifest) + } +} + +static MARKDOWN: MarkdownAdapter = MarkdownAdapter; +static PTO_ASL: PtoAslAdapter = PtoAslAdapter; +static EVIDENCE: EvidenceAdapter = EvidenceAdapter; + +pub fn adapter_for(path: &Path) -> Option<&'static dyn Adapter> { + match path.extension().and_then(|extension| extension.to_str()) { + Some("md") => Some(&MARKDOWN), + Some("asl") => Some(&PTO_ASL), + Some("json") => Some(&EVIDENCE), + _ => None, + } +} + +pub fn parse_project(manifest: &ProjectManifest) -> Result { + let mut globs = GlobSetBuilder::new(); + for pattern in &manifest.roots { + globs.add(Glob::new(pattern).map_err(|error| { + CompilerError::Contract(format!("invalid project root glob {pattern}: {error}")) + })?); + } + let globs = globs + .build() + .map_err(|error| CompilerError::Contract(format!("invalid project root globs: {error}")))?; + let mut paths = Vec::new(); + for entry in WalkDir::new(&manifest.root) { + let entry = entry.map_err(|error| { + CompilerError::Contract(format!( + "failed to walk project {}: {error}", + manifest.root.display() + )) + })?; + if !entry.file_type().is_file() { + continue; + } + let relative = entry + .path() + .strip_prefix(&manifest.root) + .expect("walked path is under project root"); + if globs.is_match(relative) { + paths.push(entry.path().to_path_buf()); + } + } + paths.sort(); + + let mut graph = GraphBuilder::default(); + for path in paths { + let adapter = adapter_for(&path).ok_or_else(|| { + CompilerError::Contract(format!( + "no built-in adapter for project source: {}", + path.display() + )) + })?; + let parsed = adapter.parse(&path, manifest)?; + for node in parsed.nodes { + graph.add_node(node); + } + for edge in parsed.edges { + graph.add_edge(edge); + } + for diagnostic in parsed.diagnostics { + graph.add_diagnostic(diagnostic); + } + } + Ok(graph.finish()) +} diff --git a/crates/ndf-compiler/src/adapters/pto_asl.rs b/crates/ndf-compiler/src/adapters/pto_asl.rs new file mode 100644 index 0000000..86eb2af --- /dev/null +++ b/crates/ndf-compiler/src/adapters/pto_asl.rs @@ -0,0 +1,538 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use ndf_core::identity::NodeId; +use ndf_core::model::{ + Diagnostic, DiagnosticSeverity, Edge, LifecycleStatus, Node, NodeKind, ParseResult, SourceSpan, +}; +use serde_json::Value; + +use crate::CompilerError; +use crate::manifest::ProjectManifest; + +const METADATA_PREFIX: &str = "// ndf: "; +const INSTRUCTION_PREFIX: &str = "// PTO-INSTRUCTION: "; +const LAYERS: &[&str] = &[ + "architecture", + "scalar", + "block", + "tile", + "state", + "memory", + "concurrency", +]; + +pub fn instruction_clause_id(surface: &str, mnemonic: &str) -> Result { + let surface = slug(surface); + let mnemonic = slug(mnemonic); + if surface.is_empty() || mnemonic.is_empty() { + return Err(CompilerError::Contract( + "instruction NDF identity requires surface and mnemonic".to_owned(), + )); + } + Ok(format!("PTO-INST-{surface}-{mnemonic}")) +} + +pub fn parse_pto_asl(path: &Path, project: &ProjectManifest) -> Result { + let text = fs::read_to_string(path).map_err(|source| CompilerError::Read { + path: path.to_path_buf(), + source, + })?; + parse_pto_asl_text(&text, path, project) +} + +pub fn parse_pto_asl_text( + text: &str, + source: &Path, + project: &ProjectManifest, +) -> Result { + let path = relative_path(source, project)?; + let mut nodes = Vec::new(); + let mut edges = Vec::new(); + let mut diagnostics = Vec::new(); + let mut active_id: Option = None; + let mut active_line = 0; + let mut metadata_line = 0; + let mut metadata_raw: Option = None; + let mut body_lines = Vec::new(); + let mut region_invalid = false; + + for (index, line) in text.lines().enumerate() { + let line_number = (index + 1) as u32; + if active_id.is_none() && line.starts_with(INSTRUCTION_PREFIX) { + match instruction_node(line, &path, line_number, project)? { + Ok(node) => nodes.push(node), + Err(diagnostic) => diagnostics.push(diagnostic), + } + continue; + } + + let begin = marker(line, "// NDF-BEGIN: "); + let end = marker(line, "// NDF-END: "); + if let Some(begin) = begin { + if let Some(active) = active_id.as_deref() { + diagnostics.push(diagnostic( + "NDF-ASL-001", + format!("nested NDF region: {begin}"), + &path, + line_number, + "Close the active region before starting another.", + Some(node_id(project, active)?), + )); + region_invalid = true; + continue; + } + active_id = Some(begin.to_owned()); + active_line = line_number; + metadata_line = 0; + metadata_raw = None; + body_lines.clear(); + region_invalid = false; + continue; + } + if let Some(end) = end { + let Some(active) = active_id.as_deref() else { + diagnostics.push(diagnostic( + "NDF-ASL-001", + format!("unmatched NDF end: {end}"), + &path, + line_number, + "Remove the end marker or add its begin marker.", + None, + )); + continue; + }; + let subject = node_id(project, active)?; + if end != active { + diagnostics.push(diagnostic( + "NDF-ASL-001", + format!("mismatched NDF end {end} for {active}"), + &path, + line_number, + "Use the active clause ID in the end marker.", + Some(subject.clone()), + )); + region_invalid = true; + } + let values = if let Some(raw) = metadata_raw.as_deref() { + let (values, mut metadata_diagnostics) = + parse_metadata(raw, &path, metadata_line, &subject); + if !metadata_diagnostics.is_empty() { + region_invalid = true; + } + diagnostics.append(&mut metadata_diagnostics); + values + } else { + diagnostics.push(diagnostic( + "NDF-ASL-002", + format!("NDF clause {active} has no metadata"), + &path, + active_line, + "Add the required PTO NDF metadata line.", + Some(subject.clone()), + )); + region_invalid = true; + BTreeMap::new() + }; + let body = body_lines.join("\n").trim().to_owned(); + if body.is_empty() { + diagnostics.push(diagnostic( + "NDF-ASL-003", + format!("NDF clause {active} has an empty body"), + &path, + active_line, + "Add one or more ASL comment body lines.", + Some(subject.clone()), + )); + region_invalid = true; + } + if !region_invalid { + let kind = &values["kind"]; + let node = Node { + id: subject, + kind: kind_contract(kind).expect("validated kind").1, + title: clause_title(active), + source: SourceSpan::new(&path, active_line).expect("positive ASL line"), + modality: None, + refinement: Some(values["level"].clone()), + domain: Some(values["layer"].clone()), + status: status(&values["status"]).expect("validated status"), + owner: Some("pto-spec".to_owned()), + body: body.clone(), + attributes: BTreeMap::from([("pto_kind".to_owned(), kind.clone())]), + }; + for target in references(&body) { + edges.push( + Edge::new( + node.id.clone(), + node_id(project, target)?, + "references", + Some(node.source.clone()), + ) + .expect("non-empty built-in edge type"), + ); + } + nodes.push(node); + } + active_id = None; + continue; + } + + let Some(active) = active_id.as_deref() else { + if line.starts_with("// NDF-BEGIN:") { + diagnostics.push(diagnostic( + "NDF-ASL-001", + "invalid PTO NDF clause ID", + &path, + line_number, + "Use a PTO-prefixed uppercase clause ID.", + None, + )); + } + continue; + }; + if let Some(raw) = line.strip_prefix(METADATA_PREFIX) { + if metadata_raw.is_some() { + diagnostics.push(diagnostic( + "NDF-ASL-002", + "duplicate NDF metadata line", + &path, + line_number, + "Keep exactly one metadata line per region.", + Some(node_id(project, active)?), + )); + region_invalid = true; + } else { + metadata_raw = Some(raw.to_owned()); + metadata_line = line_number; + } + } else if let Some(comment) = line.strip_prefix("//") { + body_lines.push(comment.strip_prefix(' ').unwrap_or(comment).to_owned()); + } else { + diagnostics.push(diagnostic( + "NDF-ASL-003", + "NDF body line must be an ASL comment", + &path, + line_number, + "Prefix normative body lines with //.", + Some(node_id(project, active)?), + )); + region_invalid = true; + } + } + + if let Some(active) = active_id { + diagnostics.push(diagnostic( + "NDF-ASL-001", + format!("unterminated NDF clause {active}"), + &path, + active_line, + "Add the matching NDF-END marker.", + Some(node_id(project, &active)?), + )); + } + Ok(ParseResult { + nodes, + edges, + diagnostics, + }) +} + +fn instruction_node( + line: &str, + path: &str, + line_number: u32, + project: &ProjectManifest, +) -> Result, CompilerError> { + let parsed: Result = serde_json::from_str(&line[INSTRUCTION_PREFIX.len()..]); + let metadata = match parsed { + Ok(Value::Object(metadata)) => metadata, + Ok(_) => { + return Ok(Err(diagnostic( + "NDF-ASL-004", + "invalid PTO instruction metadata: expected object", + path, + line_number, + "Provide JSON string fields surface and mnemonic.", + None, + ))); + } + Err(error) => { + return Ok(Err(diagnostic( + "NDF-ASL-004", + format!("invalid PTO instruction metadata: {error}"), + path, + line_number, + "Provide JSON string fields surface and mnemonic.", + None, + ))); + } + }; + let (Some(surface), Some(mnemonic)) = ( + metadata.get("surface").and_then(Value::as_str), + metadata.get("mnemonic").and_then(Value::as_str), + ) else { + return Ok(Err(diagnostic( + "NDF-ASL-004", + "invalid PTO instruction metadata: missing string surface or mnemonic", + path, + line_number, + "Provide JSON string fields surface and mnemonic.", + None, + ))); + }; + Ok(Ok(Node { + id: node_id(project, &instruction_clause_id(surface, mnemonic)?)?, + kind: NodeKind::Definition, + title: format!("{surface} {mnemonic} instruction"), + source: SourceSpan::new(path, line_number).expect("positive ASL line"), + modality: None, + refinement: None, + domain: Some(surface.to_lowercase()), + status: LifecycleStatus::Active, + owner: Some("pto-spec".to_owned()), + body: String::new(), + attributes: BTreeMap::from([ + ("surface".to_owned(), surface.to_owned()), + ("mnemonic".to_owned(), mnemonic.to_owned()), + ]), + })) +} + +fn parse_metadata( + raw: &str, + path: &str, + line: u32, + subject: &NodeId, +) -> (BTreeMap, Vec) { + let mut values = BTreeMap::new(); + let mut diagnostics = Vec::new(); + for token in raw.split_whitespace() { + let Some((name, value)) = token.split_once('=') else { + diagnostics.push(diagnostic( + "NDF-ASL-002", + format!("invalid NDF metadata token: {token}"), + path, + line, + "Write metadata as one key=value token per field.", + Some(subject.clone()), + )); + continue; + }; + if value.contains('=') { + diagnostics.push(diagnostic( + "NDF-ASL-002", + format!("invalid NDF metadata token: {token}"), + path, + line, + "Write metadata as one key=value token per field.", + Some(subject.clone()), + )); + continue; + } + if values.insert(name.to_owned(), value.to_owned()).is_some() { + diagnostics.push(diagnostic( + "NDF-ASL-002", + format!("duplicate NDF metadata field: {name}"), + path, + line, + "Keep exactly one value for each metadata field.", + Some(subject.clone()), + )); + } + } + for name in ["kind", "layer", "level", "status"] { + if !values.contains_key(name) { + diagnostics.push(diagnostic( + "NDF-ASL-002", + format!("missing NDF metadata field: {name}"), + path, + line, + "Add all required PTO NDF metadata fields.", + Some(subject.clone()), + )); + } + } + for name in values.keys() { + if !["kind", "level", "layer", "status"].contains(&name.as_str()) { + diagnostics.push(diagnostic( + "NDF-ASL-002", + format!("unknown NDF metadata field: {name}"), + path, + line, + "Remove the unsupported metadata field.", + Some(subject.clone()), + )); + } + } + if let Some(kind) = values.get("kind") { + if let Some((level, _)) = kind_contract(kind) { + if values.get("level").is_some_and(|actual| actual != level) { + diagnostics.push(diagnostic( + "NDF-ASL-002", + format!("kind {kind} requires level {level}"), + path, + line, + "Use the level assigned to the PTO kind.", + Some(subject.clone()), + )); + } + } else { + diagnostics.push(diagnostic( + "NDF-ASL-002", + format!("unknown NDF kind: {kind}"), + path, + line, + "Use intent, contract, mechanism, or executable.", + Some(subject.clone()), + )); + } + } + if let Some(layer) = values.get("layer") + && !LAYERS.contains(&layer.as_str()) + { + diagnostics.push(diagnostic( + "NDF-ASL-002", + format!("unknown NDF layer: {layer}"), + path, + line, + "Use a registered PTO layer.", + Some(subject.clone()), + )); + } + if let Some(value) = values.get("status") + && status(value).is_none() + { + diagnostics.push(diagnostic( + "NDF-ASL-002", + format!("unknown NDF status: {value}"), + path, + line, + "Use open or accepted.", + Some(subject.clone()), + )); + } + (values, diagnostics) +} + +fn kind_contract(kind: &str) -> Option<(&'static str, NodeKind)> { + match kind { + "intent" => Some(("L0", NodeKind::Architecture)), + "contract" => Some(("L1", NodeKind::Requirement)), + "mechanism" => Some(("L2", NodeKind::Architecture)), + "executable" => Some(("L3", NodeKind::Model)), + _ => None, + } +} + +fn status(value: &str) -> Option { + match value { + "open" => Some(LifecycleStatus::Open), + "accepted" => Some(LifecycleStatus::Active), + _ => None, + } +} + +fn marker<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { + line.strip_prefix(prefix) + .filter(|value| valid_clause_id(value)) +} + +fn valid_clause_id(value: &str) -> bool { + value.strip_prefix("PTO-").is_some_and(|rest| { + !rest.is_empty() + && rest.split('-').all(|part| { + !part.is_empty() + && part + .chars() + .all(|item| item.is_ascii_uppercase() || item.is_ascii_digit()) + }) + }) +} + +fn references(body: &str) -> Vec<&str> { + let mut remaining = body; + let mut result = Vec::new(); + while let Some(start) = remaining.find("[[") { + remaining = &remaining[start + 2..]; + let Some(end) = remaining.find("]]") else { + break; + }; + let candidate = &remaining[..end]; + if valid_clause_id(candidate) { + result.push(candidate); + } + remaining = &remaining[end + 2..]; + } + result +} + +fn slug(value: &str) -> String { + let mut output = String::new(); + for character in value.to_uppercase().chars() { + if character.is_ascii_alphanumeric() { + output.push(character); + } else if !output.is_empty() && !output.ends_with('-') { + output.push('-'); + } + } + output.trim_end_matches('-').to_owned() +} + +fn clause_title(value: &str) -> String { + value + .strip_prefix("PTO-") + .unwrap_or(value) + .split('-') + .map(|word| { + let mut characters = word.chars(); + characters + .next() + .map(|first| { + format!( + "{}{}", + first.to_ascii_uppercase(), + characters.as_str().to_ascii_lowercase() + ) + }) + .unwrap_or_default() + }) + .collect::>() + .join(" ") +} + +fn node_id(project: &ProjectManifest, local: &str) -> Result { + NodeId::new(&project.project, local).map_err(|error| CompilerError::Contract(error.to_string())) +} + +fn relative_path(source: &Path, project: &ProjectManifest) -> Result { + source + .strip_prefix(&project.root) + .map(|path| path.to_string_lossy().replace('\\', "/")) + .map_err(|_| { + CompilerError::Contract(format!( + "source is outside project root: {}", + source.display() + )) + }) +} + +fn diagnostic( + code: &str, + message: impl Into, + path: &str, + line: u32, + repair: &str, + subject: Option, +) -> Diagnostic { + Diagnostic { + code: code.to_owned(), + severity: DiagnosticSeverity::Error, + message: message.into(), + primary: SourceSpan::new(path, line).expect("positive ASL line"), + related: Vec::new(), + repair: Some(repair.to_owned()), + subject, + } +} diff --git a/crates/ndf-compiler/src/adapters/repository.rs b/crates/ndf-compiler/src/adapters/repository.rs new file mode 100644 index 0000000..856e1c4 --- /dev/null +++ b/crates/ndf-compiler/src/adapters/repository.rs @@ -0,0 +1,188 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; +use std::process::Command; + +use globset::{Glob, GlobSetBuilder}; +use ndf_core::identity::NodeId; +use ndf_core::model::{Edge, LifecycleStatus, Node, NodeKind, ParseResult, SourceSpan}; +use sha2::{Digest, Sha256}; +use walkdir::WalkDir; + +use crate::CompilerError; +use crate::manifest::ProjectManifest; + +const EDGE_TYPES: &[&str] = &["implements", "verifies"]; + +pub fn index_repository_paths( + root: &Path, + globs: &[&str], + project: &ProjectManifest, +) -> Result { + let mut builder = GlobSetBuilder::new(); + for pattern in globs { + builder.add(Glob::new(pattern).map_err(|error| { + CompilerError::Contract(format!("invalid glob {pattern}: {error}")) + })?); + } + let matcher = builder + .build() + .map_err(|error| CompilerError::Contract(format!("invalid repository globs: {error}")))?; + let mut paths = BTreeSet::new(); + for entry in WalkDir::new(root) { + let entry = entry.map_err(|error| { + CompilerError::Contract(format!("failed to walk {}: {error}", root.display())) + })?; + if !entry.file_type().is_file() { + continue; + } + let relative = entry + .path() + .strip_prefix(root) + .expect("walked path is inside root"); + if matcher.is_match(relative) { + paths.insert(relative.to_path_buf()); + } + } + + let revision = revision(root); + let mut nodes = Vec::new(); + let mut edges = Vec::new(); + for relative_path in paths { + let path = root.join(&relative_path); + let relative = relative_path.to_string_lossy().replace('\\', "/"); + let content = fs::read_to_string(&path).map_err(|source| CompilerError::Read { + path: path.clone(), + source, + })?; + let kind = kind(&relative_path); + let node = Node { + id: NodeId::new( + &project.project, + derived_id(&project.project, &relative, kind), + ) + .map_err(|error| CompilerError::Contract(error.to_string()))?, + kind, + title: relative.clone(), + source: SourceSpan::new(&relative, 1).expect("line one is valid"), + modality: None, + refinement: None, + domain: None, + status: LifecycleStatus::Active, + owner: Some(project.project.clone()), + body: String::new(), + attributes: BTreeMap::from([ + ("path".to_owned(), relative), + ("revision".to_owned(), revision.clone()), + ("content_hash".to_owned(), sha256(&content)), + ]), + }; + edges.extend(edges_for(&node, &content, project)?); + nodes.push(node); + } + Ok(ParseResult { + nodes, + edges, + diagnostics: Vec::new(), + }) +} + +fn revision(root: &Path) -> String { + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(root) + .output(); + let value = output + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|value| value.trim().to_owned()) + .unwrap_or_default(); + if exact_revision(&value) { + value + } else { + "0".repeat(40) + } +} + +fn kind(path: &Path) -> NodeKind { + if path.components().any(|part| part.as_os_str() == "tests") + || path + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("test_")) + { + NodeKind::Test + } else { + NodeKind::Source + } +} + +fn derived_id(project: &str, path: &str, kind: NodeKind) -> String { + let kind = if kind == NodeKind::Test { + "test" + } else { + "source" + }; + let digest = sha256(&format!("{project}\0{kind}\0{path}")); + let prefix = if kind == "test" { "TEST" } else { "SRC" }; + format!("{prefix}-{}", digest[..16].to_ascii_uppercase()) +} + +fn edges_for( + node: &Node, + content: &str, + project: &ProjectManifest, +) -> Result, CompilerError> { + let mut result = Vec::new(); + for (index, line) in content.lines().enumerate() { + let trimmed = line.trim_start(); + let comment = trimmed + .strip_prefix('#') + .or_else(|| trimmed.strip_prefix("//")); + let Some(payload) = comment + .map(str::trim_start) + .and_then(|comment| comment.strip_prefix("ndf:")) + else { + continue; + }; + for token in payload.split_whitespace() { + let Some((edge_type, targets)) = token.split_once('=') else { + continue; + }; + if !EDGE_TYPES.contains(&edge_type) { + continue; + } + for target in targets.split(',') { + let target = NodeId::parse(target, Some(&project.project)) + .map_err(|error| CompilerError::Contract(error.to_string()))?; + result.push( + Edge::new( + node.id.clone(), + target, + edge_type, + Some( + SourceSpan::new(&node.source.path, (index + 1) as u32) + .expect("repository line is positive"), + ), + ) + .expect("registered edge type is non-empty"), + ); + } + } + } + Ok(result) +} + +fn sha256(value: &str) -> String { + Sha256::digest(value.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn exact_revision(value: &str) -> bool { + value.len() == 40 + && value + .chars() + .all(|character| character.is_ascii_hexdigit() && !character.is_ascii_uppercase()) +} diff --git a/crates/ndf-compiler/src/baseline.rs b/crates/ndf-compiler/src/baseline.rs new file mode 100644 index 0000000..3ff95c5 --- /dev/null +++ b/crates/ndf-compiler/src/baseline.rs @@ -0,0 +1,191 @@ +use std::collections::{HashMap, HashSet}; +use std::fmt::Write; +use std::time::{SystemTime, UNIX_EPOCH}; + +use ndf_core::identity::NodeId; +use ndf_core::model::{Diagnostic, DiagnosticSeverity, SourceSpan}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct BaselineEntry { + pub fingerprint: String, + pub code: String, + pub severity: DiagnosticSeverity, + pub subject: String, + pub path: String, + pub owner: Option, + pub expires: Option, +} + +impl BaselineEntry { + pub fn from_diagnostic(diagnostic: &Diagnostic, owner: &str, expires: &str) -> Self { + Self { + fingerprint: diagnostic_fingerprint(diagnostic), + code: diagnostic.code.clone(), + severity: diagnostic.severity, + subject: diagnostic + .subject + .as_ref() + .map_or_else(String::new, ToString::to_string), + path: normalize_path(&diagnostic.primary.path), + owner: Some(owner.to_owned()), + expires: Some(expires.to_owned()), + } + } +} + +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub struct DiagnosticBaseline { + pub entries: Vec, +} + +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub struct BaselineResult { + pub existing: Vec, + pub new: Vec, + pub worsened: Vec, + pub expired: Vec, +} + +pub fn diagnostic_fingerprint(diagnostic: &Diagnostic) -> String { + let payload = [ + diagnostic.code.clone(), + diagnostic + .subject + .as_ref() + .map_or_else(String::new, ToString::to_string), + normalize_path(&diagnostic.primary.path), + collapse_whitespace(&diagnostic.message), + ] + .join("\0"); + let digest = Sha256::digest(payload.as_bytes()); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut encoded, "{byte:02x}").expect("writing to a string cannot fail"); + } + encoded +} + +pub fn compare_baseline(current: &[Diagnostic], baseline: &DiagnosticBaseline) -> BaselineResult { + let mut by_fingerprint = HashMap::new(); + let mut invalid_fingerprints = HashSet::new(); + let mut result = BaselineResult::default(); + let today = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock must be after the Unix epoch") + .as_secs() + / 86_400; + + for entry in &baseline.entries { + let expiry = entry.expires.as_deref().and_then(parse_date_days); + if entry.owner.as_deref().is_none_or(str::is_empty) || expiry.is_none() { + invalid_fingerprints.insert(entry.fingerprint.as_str()); + result.new.push(integrity_diagnostic(entry)); + continue; + } + if expiry.is_some_and(|day| day < today as i64) { + result.expired.push(entry.clone()); + } + by_fingerprint.insert(entry.fingerprint.as_str(), entry); + } + + for diagnostic in current { + let fingerprint = diagnostic_fingerprint(diagnostic); + if invalid_fingerprints.contains(fingerprint.as_str()) { + continue; + } + match by_fingerprint.get(fingerprint.as_str()) { + None => result.new.push(diagnostic.clone()), + Some(entry) if severity_rank(diagnostic.severity) > severity_rank(entry.severity) => { + result.worsened.push(diagnostic.clone()); + } + Some(_) => result.existing.push(diagnostic.clone()), + } + } + result +} + +fn integrity_diagnostic(entry: &BaselineEntry) -> Diagnostic { + Diagnostic { + code: "NDF-BASE-003".to_owned(), + severity: DiagnosticSeverity::Error, + message: format!("baseline entry {} lacks owner or expiry", entry.fingerprint), + primary: SourceSpan::new( + if entry.path.is_empty() { + "ndf-baseline.json" + } else { + &entry.path + }, + 1, + ) + .expect("line one is valid"), + related: Vec::new(), + repair: Some("Assign an owner and ISO expiry date to the baseline entry.".to_owned()), + subject: if entry.subject.is_empty() { + None + } else { + NodeId::parse(&entry.subject, None).ok() + }, + } +} + +fn severity_rank(severity: DiagnosticSeverity) -> u8 { + match severity { + DiagnosticSeverity::Information => 0, + DiagnosticSeverity::Warning => 1, + DiagnosticSeverity::Error => 2, + } +} + +fn normalize_path(path: &str) -> String { + path.replace('\\', "/") + .split('/') + .filter(|component| !component.is_empty() && *component != ".") + .collect::>() + .join("/") +} + +fn collapse_whitespace(message: &str) -> String { + message.split_whitespace().collect::>().join(" ") +} + +fn parse_date_days(value: &str) -> Option { + if value.len() != 10 || &value[4..5] != "-" || &value[7..8] != "-" { + return None; + } + let year: i64 = value[..4].parse().ok()?; + let month: u32 = value[5..7].parse().ok()?; + let day: u32 = value[8..].parse().ok()?; + let month_lengths = [ + 31, + 28 + u32::from(is_leap_year(year)), + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + if !(1..=12).contains(&month) || day == 0 || day > month_lengths[(month - 1) as usize] { + return None; + } + Some(days_from_civil(year, month, day)) +} + +fn is_leap_year(year: i64) -> bool { + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) +} + +fn days_from_civil(year: i64, month: u32, day: u32) -> i64 { + let adjusted_year = year - i64::from(month <= 2); + let era = adjusted_year.div_euclid(400); + let year_of_era = adjusted_year - era * 400; + let shifted_month = i64::from(month) + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * shifted_month + 2) / 5 + i64::from(day) - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - 719_468 +} diff --git a/crates/ndf-compiler/src/dependencies.rs b/crates/ndf-compiler/src/dependencies.rs new file mode 100644 index 0000000..1aea574 --- /dev/null +++ b/crates/ndf-compiler/src/dependencies.rs @@ -0,0 +1,113 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde::Serialize; +use serde_yaml_ng::Value; + +use crate::CompilerError; +use crate::manifest::{ProjectLock, ProjectManifest}; + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct ResolvedProject { + pub project_id: String, + pub path: PathBuf, + pub revision: String, + pub dirty: bool, +} + +pub fn resolve_dependencies( + manifest: &ProjectManifest, + lock: &ProjectLock, + workspace: &Path, +) -> Result, CompilerError> { + let allow_dirty = manifest + .policies + .get("ndf") + .and_then(Value::as_mapping) + .and_then(|mapping| mapping.get(Value::String("allow_dirty".to_owned()))) + .and_then(Value::as_bool) + .unwrap_or(false); + let mut resolved = Vec::new(); + for project in manifest.dependencies.keys() { + let dependency = lock + .dependencies + .iter() + .find(|dependency| &dependency.project == project) + .ok_or_else(|| { + contract(format!( + "NDF-DEP-001: dependency is missing from lock: {project}" + )) + })?; + if !exact_revision(&dependency.revision) { + return Err(contract(format!( + "NDF-DEP-002: dependency revision is not an exact commit: {project}" + ))); + } + let path = workspace.join(&dependency.path); + if !path.is_dir() { + return Err(contract(format!( + "NDF-DEP-001: dependency checkout is missing: {project}" + ))); + } + let path = path.canonicalize().map_err(|source| CompilerError::Read { + path: path.clone(), + source, + })?; + let revision_output = git(&path, &["rev-parse", "HEAD"])?; + if !revision_output.status.success() { + return Err(contract(format!( + "NDF-DEP-001: dependency is not a Git checkout: {project}" + ))); + } + let revision = String::from_utf8_lossy(&revision_output.stdout) + .trim() + .to_owned(); + if revision != dependency.revision { + return Err(contract(format!( + "NDF-DEP-003: dependency revision mismatch for {project}: expected {}, got {revision}", + dependency.revision + ))); + } + let status = git(&path, &["status", "--porcelain"])?; + if !status.status.success() { + return Err(contract(format!( + "NDF-DEP-001: dependency is not a Git checkout: {project}" + ))); + } + let dirty = !status.stdout.is_empty(); + if dirty && !allow_dirty { + return Err(contract(format!( + "NDF-DEP-004: dependency checkout is dirty: {project}" + ))); + } + resolved.push(ResolvedProject { + project_id: project.clone(), + path, + revision, + dirty, + }); + } + Ok(resolved) +} + +fn git(path: &Path, arguments: &[&str]) -> Result { + Command::new("git") + .args(arguments) + .current_dir(path) + .output() + .map_err(|source| CompilerError::Read { + path: path.to_path_buf(), + source, + }) +} + +fn exact_revision(value: &str) -> bool { + value.len() == 40 + && value + .chars() + .all(|character| character.is_ascii_hexdigit() && !character.is_ascii_uppercase()) +} + +fn contract(message: impl Into) -> CompilerError { + CompilerError::Contract(message.into()) +} diff --git a/crates/ndf-compiler/src/diff.rs b/crates/ndf-compiler/src/diff.rs new file mode 100644 index 0000000..cfb45b1 --- /dev/null +++ b/crates/ndf-compiler/src/diff.rs @@ -0,0 +1,112 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; + +use crate::CompilerError; +use crate::index::{NdfIndex, NodeRecord}; + +pub type EdgeTuple = [String; 3]; +type SemanticRecord<'a> = ( + &'a str, + &'a str, + Option<&'a str>, + Option<&'a str>, + Option<&'a str>, + &'a str, + Option<&'a str>, + &'a str, +); + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct SemanticDiff { + pub added: Vec, + pub removed: Vec, + pub modified: Vec, + pub moved: Vec, + pub superseded: Vec, + pub edge_only: Vec, + pub edges_added: Vec, + pub edges_removed: Vec, +} + +fn semantic_record(node: &NodeRecord) -> SemanticRecord<'_> { + ( + &node.kind, + &node.title, + node.modality.as_deref(), + node.refinement.as_deref(), + node.domain.as_deref(), + &node.status, + node.owner.as_deref(), + &node.body, + ) +} + +fn edge_set(index: &NdfIndex) -> Result, CompilerError> { + Ok(index + .all_edges()? + .into_iter() + .map(|edge| [edge.source, edge.edge_type, edge.target]) + .collect()) +} + +pub fn semantic_diff(before: &NdfIndex, after: &NdfIndex) -> Result { + let before_nodes: BTreeMap<_, _> = before + .all_nodes()? + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect(); + let after_nodes: BTreeMap<_, _> = after + .all_nodes()? + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect(); + let before_uris: BTreeSet<_> = before_nodes.keys().cloned().collect(); + let after_uris: BTreeSet<_> = after_nodes.keys().cloned().collect(); + let added: BTreeSet<_> = after_uris.difference(&before_uris).cloned().collect(); + let removed: BTreeSet<_> = before_uris.difference(&after_uris).cloned().collect(); + let mut modified = BTreeSet::new(); + let mut moved = BTreeSet::new(); + let mut superseded = BTreeSet::new(); + + for uri in before_uris.intersection(&after_uris) { + let old = &before_nodes[uri]; + let new = &after_nodes[uri]; + if old.status != "superseded" && new.status == "superseded" { + superseded.insert(uri.clone()); + } else if semantic_record(old) != semantic_record(new) { + modified.insert(uri.clone()); + } else if old.source != new.source { + moved.insert(uri.clone()); + } + } + + let old_edges = edge_set(before)?; + let new_edges = edge_set(after)?; + let edges_added: BTreeSet<_> = new_edges.difference(&old_edges).cloned().collect(); + let edges_removed: BTreeSet<_> = old_edges.difference(&new_edges).cloned().collect(); + let edge_nodes: BTreeSet<_> = edges_added + .union(&edges_removed) + .flat_map(|edge| [edge[0].clone(), edge[2].clone()]) + .collect(); + let classified: BTreeSet<_> = added + .iter() + .chain(&removed) + .chain(&modified) + .chain(&moved) + .chain(&superseded) + .cloned() + .collect(); + let edge_only = edge_nodes.difference(&classified).cloned().collect(); + + Ok(SemanticDiff { + added: added.into_iter().collect(), + removed: removed.into_iter().collect(), + modified: modified.into_iter().collect(), + moved: moved.into_iter().collect(), + superseded: superseded.into_iter().collect(), + edge_only, + edges_added: edges_added.into_iter().collect(), + edges_removed: edges_removed.into_iter().collect(), + }) +} diff --git a/crates/ndf-compiler/src/export.rs b/crates/ndf-compiler/src/export.rs new file mode 100644 index 0000000..6b66e75 --- /dev/null +++ b/crates/ndf-compiler/src/export.rs @@ -0,0 +1,186 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; + +use ndf_core::model::SourceSpan; +use serde::Serialize; + +use crate::CompilerError; +use crate::index::{BuildRecord, DiagnosticRecord, IndexedEdge, NdfIndex, NodeRecord}; + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct ExportResult { + pub output: PathBuf, + pub semantic_hash: String, + pub node_count: usize, + pub edge_count: usize, +} + +#[derive(Serialize)] +struct ExportProvenance<'a> { + format_version: &'a str, + ir_version: &'a str, + semantic_hash: &'a str, + tool_version: &'a str, + projects: &'a BTreeMap, +} + +#[derive(Serialize)] +struct ExportDiagnostic { + code: String, + severity: String, + message: String, + primary: SourceSpan, + related: Vec, + repair: Option, + subject: Option, +} + +pub fn export_graph(index: &NdfIndex, output: &Path) -> Result { + let parent = output.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent).map_err(|source| CompilerError::Write { + path: parent.to_path_buf(), + source, + })?; + let provenance = index.provenance()?; + let projects = index.project_revisions()?; + let (node_count, edge_count) = index.counts()?; + let mut temporary = tempfile::Builder::new() + .prefix(&format!( + ".{}.", + output.file_name().unwrap_or_default().to_string_lossy() + )) + .suffix(".tmp") + .tempfile_in(parent) + .map_err(|source| CompilerError::Write { + path: parent.to_path_buf(), + source, + })?; + { + let mut writer = BufWriter::new(temporary.as_file_mut()); + write_bytes( + &mut writer, + output, + b"{\n \"schema_version\": \"0.1\",\n \"nodes\": [", + )?; + let mut first = true; + index.visit_nodes(|node| { + write_record(&mut writer, output, &mut first, &portable_node(node)) + })?; + write_bytes(&mut writer, output, b"\n ],\n \"edges\": [")?; + let mut first = true; + index.visit_edges(|edge| { + write_record(&mut writer, output, &mut first, &portable_edge(edge)) + })?; + write_bytes(&mut writer, output, b"\n ],\n \"diagnostics\": [")?; + let mut first = true; + index.visit_diagnostics(|diagnostic| { + write_record( + &mut writer, + output, + &mut first, + &portable_diagnostic(diagnostic), + ) + })?; + write_bytes(&mut writer, output, b"\n ],\n \"provenance\": ")?; + serde_json::to_writer_pretty(&mut writer, &ExportProvenance::new(&provenance, &projects))?; + write_bytes(&mut writer, output, b"\n}\n")?; + writer.flush().map_err(|source| CompilerError::Write { + path: output.to_path_buf(), + source, + })?; + } + temporary + .persist(output) + .map_err(|error| CompilerError::Write { + path: output.to_path_buf(), + source: error.error, + })?; + Ok(ExportResult { + output: output.to_path_buf(), + semantic_hash: provenance.semantic_hash, + node_count, + edge_count, + }) +} + +impl<'a> ExportProvenance<'a> { + fn new(record: &'a BuildRecord, projects: &'a BTreeMap) -> Self { + Self { + format_version: &record.format_version, + ir_version: &record.ir_version, + semantic_hash: &record.semantic_hash, + tool_version: &record.tool_version, + projects, + } + } +} + +fn write_record( + writer: &mut impl Write, + output: &Path, + first: &mut bool, + record: &T, +) -> Result<(), CompilerError> { + if !*first { + write_bytes(writer, output, b",")?; + } + *first = false; + write_bytes(writer, output, b"\n ")?; + serde_json::to_writer(&mut *writer, record)?; + Ok(()) +} + +fn write_bytes(writer: &mut impl Write, output: &Path, bytes: &[u8]) -> Result<(), CompilerError> { + writer + .write_all(bytes) + .map_err(|source| CompilerError::Write { + path: output.to_path_buf(), + source, + }) +} + +fn portable_path(value: &str) -> String { + let path = Path::new(value); + if path.is_absolute() { + return format!( + "/{}", + path.file_name().unwrap_or_default().to_string_lossy() + ); + } + value.replace('\\', "/") +} + +fn portable_span(mut span: SourceSpan) -> SourceSpan { + span.path = portable_path(&span.path); + span +} + +fn portable_node(mut node: NodeRecord) -> NodeRecord { + node.source = portable_span(node.source); + node +} + +fn portable_edge(mut edge: IndexedEdge) -> IndexedEdge { + edge.source_span = edge.source_span.map(portable_span); + edge +} + +fn portable_diagnostic(record: DiagnosticRecord) -> ExportDiagnostic { + ExportDiagnostic { + code: record.code, + severity: record.severity, + message: record.message, + primary: SourceSpan { + path: portable_path(&record.source_path), + line: record.line, + column: 1, + end_line: None, + end_column: None, + }, + related: Vec::new(), + repair: record.repair, + subject: record.subject_uri, + } +} diff --git a/crates/ndf-compiler/src/fix.rs b/crates/ndf-compiler/src/fix.rs new file mode 100644 index 0000000..8ac9930 --- /dev/null +++ b/crates/ndf-compiler/src/fix.rs @@ -0,0 +1,178 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use ndf_core::model::{Diagnostic, DiagnosticSeverity, SourceSpan}; +use serde::Serialize; + +use crate::CompilerError; + +const SEMANTIC_FIELDS: &[&str] = &[ + "prose", + "authority", + "status", + "kind", + "modality", + "refinement", + "domain", + "edges", +]; + +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub struct FixMode { + pub generated_only: bool, + pub check: bool, + pub requested_changes: BTreeSet, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct FixResult { + pub changed: Vec, + pub diagnostics: Vec, +} + +pub fn safe_fix(paths: &[PathBuf], mode: &FixMode) -> Result { + let forbidden: BTreeSet<_> = mode + .requested_changes + .iter() + .filter(|field| SEMANTIC_FIELDS.contains(&field.as_str())) + .cloned() + .collect(); + if !forbidden.is_empty() { + return Ok(FixResult { + changed: Vec::new(), + diagnostics: vec![refusal(&forbidden)], + }); + } + + let mut ordered = paths.to_vec(); + ordered.sort(); + let mut changed = Vec::new(); + for path in ordered { + let content = fs::read_to_string(&path).map_err(|source| CompilerError::Read { + path: path.clone(), + source, + })?; + let mut updated = sort_generated_blocks(&content); + if !mode.generated_only { + updated = sort_metadata(&updated); + } + if updated == content { + continue; + } + changed.push(path.to_string_lossy().into_owned()); + if !mode.check { + fs::write(&path, updated).map_err(|source| CompilerError::Write { + path: path.clone(), + source, + })?; + } + } + Ok(FixResult { + changed, + diagnostics: Vec::new(), + }) +} + +fn refusal(fields: &BTreeSet) -> Diagnostic { + Diagnostic { + code: "NDF-FIX-001".to_owned(), + severity: DiagnosticSeverity::Error, + message: format!( + "safe fix cannot alter semantic fields: {}", + fields.iter().cloned().collect::>().join(", ") + ), + primary: SourceSpan::new("", 1).expect("line one is valid"), + related: Vec::new(), + repair: Some("Edit normative semantics explicitly and request review.".to_owned()), + subject: None, + } +} + +fn sort_metadata(content: &str) -> String { + content + .split_inclusive('\n') + .map(sort_metadata_line) + .collect() +} + +fn sort_metadata_line(line: &str) -> String { + let (body, newline) = line + .strip_suffix('\n') + .map_or((line, ""), |body| (body, "\n")); + let leading_len = body.len() - body.trim_start().len(); + let leading = &body[..leading_len]; + let candidate = &body[leading_len..]; + let Some(after_prefix) = candidate.strip_prefix("") else { + return line.to_owned(); + }; + if !after_prefix[end + 3..].chars().all(char::is_whitespace) { + return line.to_owned(); + } + let trailing = &after_prefix[end + 3..]; + let mut tokens: Vec<_> = after_prefix[..end].split_whitespace().collect(); + tokens.sort_unstable(); + format!( + "{leading}{trailing}{newline}", + tokens.join(" ") + ) +} + +fn sort_generated_blocks(content: &str) -> String { + const START: &str = ""; + const END: &str = ""; + let mut position = 0; + let mut output = String::new(); + loop { + let Some(relative_start) = content[position..].find(START) else { + output.push_str(&content[position..]); + break; + }; + let start = position + relative_start; + let body_start = start + START.len(); + let Some(relative_end) = content[body_start..].find(END) else { + output.push_str(&content[position..]); + break; + }; + let end = body_start + relative_end; + let mut lines: Vec<_> = content[body_start..end] + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + lines.sort_unstable(); + output.push_str(&content[position..body_start]); + output.push('\n'); + output.push_str(&lines.join("\n")); + output.push('\n'); + position = end; + } + output +} + +pub fn markdown_paths(root: &Path) -> Result, CompilerError> { + let mut pending = vec![root.to_path_buf()]; + let mut paths = Vec::new(); + while let Some(directory) = pending.pop() { + let entries = fs::read_dir(&directory).map_err(|source| CompilerError::Read { + path: directory.clone(), + source, + })?; + for entry in entries { + let entry = entry.map_err(|source| CompilerError::Read { + path: directory.clone(), + source, + })?; + let path = entry.path(); + if path.is_dir() { + pending.push(path); + } else if path.extension().is_some_and(|extension| extension == "md") { + paths.push(path); + } + } + } + paths.sort(); + Ok(paths) +} diff --git a/crates/ndf-compiler/src/id_allocator.rs b/crates/ndf-compiler/src/id_allocator.rs new file mode 100644 index 0000000..c3cd246 --- /dev/null +++ b/crates/ndf-compiler/src/id_allocator.rs @@ -0,0 +1,33 @@ +use std::collections::BTreeSet; + +use crate::CompilerError; + +pub fn allocate_id(prefix: &str, occupied: &BTreeSet) -> Result { + if !valid_prefix(prefix) { + return Err(CompilerError::Contract(format!( + "invalid ID prefix: {prefix:?}" + ))); + } + let marker = format!("{prefix}-"); + let next = occupied + .iter() + .filter_map(|value| value.strip_prefix(&marker)) + .filter(|suffix| !suffix.is_empty() && suffix.chars().all(|item| item.is_ascii_digit())) + .filter_map(|suffix| suffix.parse::().ok()) + .max() + .unwrap_or(0) + + 1; + Ok(format!("{prefix}-{next:03}")) +} + +fn valid_prefix(prefix: &str) -> bool { + let characters: Vec<_> = prefix.chars().collect(); + characters.len() >= 2 + && characters[0].is_ascii_uppercase() + && characters + .last() + .is_some_and(|last| last.is_ascii_uppercase() || last.is_ascii_digit()) + && characters + .iter() + .all(|item| item.is_ascii_uppercase() || item.is_ascii_digit() || *item == '-') +} diff --git a/crates/ndf-compiler/src/index.rs b/crates/ndf-compiler/src/index.rs new file mode 100644 index 0000000..06de2d3 --- /dev/null +++ b/crates/ndf-compiler/src/index.rs @@ -0,0 +1,928 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use ndf_core::identity::NodeId; +use ndf_core::model::{ + Diagnostic, DiagnosticSeverity, Edge, Graph, LifecycleStatus, Modality, Node, NodeKind, + SourceSpan, +}; +use rusqlite::{Connection, OpenFlags, Row, Transaction, params, params_from_iter}; +use serde::ser::{SerializeSeq, SerializeStruct, Serializer}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::CompilerError; +use crate::baseline::diagnostic_fingerprint; + +const SCHEMA: &str = include_str!("../../../sql/schema-v1.sql"); + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct BuildProvenance { + pub project_commits: Vec<(String, String)>, + pub format_version: String, + pub ir_version: String, + pub tool_version: String, + pub normative_roots: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct IndexBuild { + pub output: PathBuf, + pub semantic_hash: String, + pub node_count: usize, + pub edge_count: usize, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct NodeRecord { + pub id: String, + pub kind: String, + pub title: String, + pub source: SourceSpan, + pub modality: Option, + pub refinement: Option, + pub domain: Option, + pub status: String, + pub owner: Option, + pub body: String, + pub attributes: BTreeMap, +} + +#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct IndexedEdge { + pub source: String, + pub target: String, + #[serde(rename = "type")] + pub edge_type: String, + pub source_span: Option, + pub attributes: BTreeMap, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct BuildRecord { + pub format_version: String, + pub ir_version: String, + pub semantic_hash: String, + pub tool_version: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct DiagnosticRecord { + pub code: String, + pub severity: String, + pub message: String, + pub subject_uri: Option, + pub source_path: String, + pub line: u32, + pub repair: Option, +} + +pub(crate) struct CoverageUris { + pub unverified: Vec, + pub open_items: Vec, + pub tbd: Vec, +} + +#[derive(Debug)] +pub struct NdfIndex { + connection: Connection, +} + +impl NdfIndex { + pub fn open(path: &Path) -> Result { + let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + connection.pragma_update(None, "foreign_keys", true)?; + Ok(Self { connection }) + } + + pub fn foreign_keys_enabled(&self) -> Result { + Ok(self + .connection + .pragma_query_value(None, "foreign_keys", |row| row.get::<_, i64>(0))? + != 0) + } + + pub fn get_node(&self, uri: &str) -> Result, CompilerError> { + let uri = canonical_uri(uri)?; + let mut statement = self + .connection + .prepare("SELECT * FROM nodes WHERE uri = ?")?; + let mut rows = statement.query([uri])?; + rows.next()? + .map(node_record) + .transpose() + .map_err(Into::into) + } + + pub fn all_nodes(&self) -> Result, CompilerError> { + self.filtered_nodes(&[]) + } + + pub(crate) fn visit_nodes( + &self, + mut visitor: impl FnMut(NodeRecord) -> Result<(), CompilerError>, + ) -> Result<(), CompilerError> { + let mut statement = self + .connection + .prepare("SELECT * FROM nodes ORDER BY uri")?; + let mut rows = statement.query([])?; + while let Some(row) = rows.next()? { + visitor(node_record(row)?)?; + } + Ok(()) + } + + pub(crate) fn filtered_nodes( + &self, + filters: &[(&str, &str)], + ) -> Result, CompilerError> { + let where_clause = if filters.is_empty() { + String::new() + } else { + format!( + " WHERE {}", + filters + .iter() + .map(|(field, _)| format!("{field} = ?")) + .collect::>() + .join(" AND ") + ) + }; + let sql = format!("SELECT * FROM nodes{where_clause} ORDER BY uri"); + let mut statement = self.connection.prepare(&sql)?; + let values = filters.iter().map(|(_, value)| *value); + let records = statement + .query_map(params_from_iter(values), node_record)? + .collect::, _>>()?; + Ok(records) + } + + pub fn resolve_uri(&self, value: &str) -> Result { + if value.starts_with("ndf://") { + return canonical_uri(value); + } + let mut statement = self + .connection + .prepare("SELECT uri FROM nodes WHERE local_id = ? ORDER BY uri")?; + let rows = statement + .query_map([value], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + match rows.as_slice() { + [] => Err(CompilerError::Contract(format!("unknown NDF ID: {value}"))), + [uri] => Ok(uri.clone()), + _ => Err(CompilerError::Contract(format!( + "ambiguous local NDF ID: {value}" + ))), + } + } + + pub fn out_edges( + &self, + uri: &str, + edge_type: Option<&str>, + ) -> Result, CompilerError> { + self.edges("source_uri", uri, edge_type) + } + + pub fn in_edges( + &self, + uri: &str, + edge_type: Option<&str>, + ) -> Result, CompilerError> { + self.edges("target_uri", uri, edge_type) + } + + pub(crate) fn out_edges_by_types( + &self, + uri: &str, + edge_types: &[&str], + ) -> Result, CompilerError> { + let uri = canonical_uri(uri)?; + if edge_types.is_empty() { + return Ok(Vec::new()); + } + let placeholders = std::iter::repeat_n("?", edge_types.len()) + .collect::>() + .join(", "); + let sql = format!( + "SELECT * FROM edges WHERE source_uri = ? AND edge_type IN ({placeholders}) ORDER BY source_uri, edge_type, target_uri, edge_id" + ); + let mut values = Vec::with_capacity(edge_types.len() + 1); + values.push(uri.as_str()); + values.extend_from_slice(edge_types); + let mut statement = self.connection.prepare(&sql)?; + Ok(statement + .query_map(params_from_iter(values), edge_record)? + .collect::, _>>()?) + } + + fn edges( + &self, + direction: &str, + uri: &str, + edge_type: Option<&str>, + ) -> Result, CompilerError> { + let uri = canonical_uri(uri)?; + let (sql, values) = match edge_type { + Some(edge_type) => ( + format!( + "SELECT * FROM edges WHERE {direction} = ? AND edge_type = ? ORDER BY source_uri, edge_type, target_uri, edge_id" + ), + vec![uri.as_str(), edge_type], + ), + None => ( + format!( + "SELECT * FROM edges WHERE {direction} = ? ORDER BY source_uri, edge_type, target_uri, edge_id" + ), + vec![uri.as_str()], + ), + }; + let mut statement = self.connection.prepare(&sql)?; + Ok(statement + .query_map(params_from_iter(values), edge_record)? + .collect::, _>>()?) + } + + pub fn all_edges(&self) -> Result, CompilerError> { + let mut statement = self + .connection + .prepare("SELECT * FROM edges ORDER BY source_uri, edge_type, target_uri, edge_id")?; + Ok(statement + .query_map([], edge_record)? + .collect::, _>>()?) + } + + pub(crate) fn visit_edges( + &self, + mut visitor: impl FnMut(IndexedEdge) -> Result<(), CompilerError>, + ) -> Result<(), CompilerError> { + let mut statement = self + .connection + .prepare("SELECT * FROM edges ORDER BY source_uri, edge_type, target_uri, edge_id")?; + let mut rows = statement.query([])?; + while let Some(row) = rows.next()? { + visitor(edge_record(row)?)?; + } + Ok(()) + } + + pub fn provenance(&self) -> Result { + self.connection + .query_row( + "SELECT format_version, ir_version, semantic_hash, tool_version FROM builds WHERE build_id = 1", + [], + |row| { + Ok(BuildRecord { + format_version: row.get(0)?, + ir_version: row.get(1)?, + semantic_hash: row.get(2)?, + tool_version: row.get(3)?, + }) + }, + ) + .map_err(|error| match error { + rusqlite::Error::QueryReturnedNoRows => { + CompilerError::Contract("index has no build provenance".to_owned()) + } + other => other.into(), + }) + } + + pub fn project_revisions(&self) -> Result, CompilerError> { + let mut statement = self + .connection + .prepare("SELECT project_id, revision FROM projects ORDER BY project_id")?; + Ok(statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?) + } + + pub fn diagnostic_records(&self) -> Result, CompilerError> { + let mut statement = self.connection.prepare( + "SELECT code, severity, message, subject_uri, source_path, line, repair FROM diagnostics ORDER BY diagnostic_id", + )?; + Ok(statement + .query_map([], diagnostic_record)? + .collect::, _>>()?) + } + + pub(crate) fn visit_diagnostics( + &self, + mut visitor: impl FnMut(DiagnosticRecord) -> Result<(), CompilerError>, + ) -> Result<(), CompilerError> { + let mut statement = self.connection.prepare( + "SELECT code, severity, message, subject_uri, source_path, line, repair FROM diagnostics ORDER BY diagnostic_id", + )?; + let mut rows = statement.query([])?; + while let Some(row) = rows.next()? { + visitor(diagnostic_record(row)?)?; + } + Ok(()) + } + + pub(crate) fn coverage_uris(&self, refinement: &str) -> Result { + let unverified = self.uri_query( + "SELECT uri FROM nodes n WHERE kind = 'requirement' AND status = 'active' AND modality = 'must' AND refinement = ? AND NOT EXISTS (SELECT 1 FROM edges e WHERE e.target_uri = n.uri AND e.edge_type = 'verifies') ORDER BY uri", + [refinement], + )?; + let open_items = self.uri_query( + "SELECT uri FROM nodes WHERE kind IN ('question', 'option') AND status = 'open' ORDER BY uri", + [], + )?; + let tbd = self.uri_query( + "SELECT uri FROM nodes WHERE modality = 'tbd' ORDER BY uri", + [], + )?; + Ok(CoverageUris { + unverified, + open_items, + tbd, + }) + } + + fn uri_query( + &self, + sql: &str, + parameters: [&str; N], + ) -> Result, CompilerError> { + let mut statement = self.connection.prepare(sql)?; + Ok(statement + .query_map(params_from_iter(parameters), |row| row.get(0))? + .collect::, _>>()?) + } + + pub fn counts(&self) -> Result<(usize, usize), CompilerError> { + let nodes: i64 = self + .connection + .query_row("SELECT COUNT(*) FROM nodes", [], |row| row.get(0))?; + let edges: i64 = self + .connection + .query_row("SELECT COUNT(*) FROM edges", [], |row| row.get(0))?; + Ok((nodes as usize, edges as usize)) + } +} + +fn canonical_uri(value: &str) -> Result { + NodeId::parse(value, None) + .map(|id| id.to_string()) + .map_err(|error| CompilerError::Contract(error.to_string())) +} + +fn node_record(row: &Row<'_>) -> rusqlite::Result { + Ok(NodeRecord { + id: row.get("uri")?, + kind: row.get("kind")?, + title: row.get("title")?, + source: SourceSpan { + path: row.get("source_path")?, + line: row.get("line")?, + column: row.get("column_number")?, + end_line: None, + end_column: None, + }, + modality: row.get("modality")?, + refinement: row.get("refinement")?, + domain: row.get("domain")?, + status: row.get("status")?, + owner: row.get("owner")?, + body: row.get("body")?, + attributes: BTreeMap::new(), + }) +} + +fn edge_record(row: &Row<'_>) -> rusqlite::Result { + let source_path: Option = row.get("source_path")?; + let source_span = match source_path { + Some(path) => Some(SourceSpan { + path, + line: row.get("line")?, + column: 1, + end_line: None, + end_column: None, + }), + None => None, + }; + Ok(IndexedEdge { + source: row.get("source_uri")?, + target: row.get("target_uri")?, + edge_type: row.get("edge_type")?, + source_span, + attributes: BTreeMap::new(), + }) +} + +fn diagnostic_record(row: &Row<'_>) -> rusqlite::Result { + Ok(DiagnosticRecord { + code: row.get(0)?, + severity: row.get(1)?, + message: row.get(2)?, + subject_uri: row.get(3)?, + source_path: row.get(4)?, + line: row.get(5)?, + repair: row.get(6)?, + }) +} + +pub fn build_index( + graph: &Graph, + output: &Path, + provenance: &BuildProvenance, +) -> Result { + validate_build(graph, output, provenance)?; + let parent = output.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent).map_err(|source| CompilerError::Write { + path: parent.to_path_buf(), + source, + })?; + let canonical = CanonicalOrder::new(graph); + let semantic_hash = semantic_hash(&canonical, provenance)?; + let temporary = tempfile::Builder::new() + .prefix(&format!( + ".{}.", + output.file_name().unwrap_or_default().to_string_lossy() + )) + .suffix(".tmp") + .tempfile_in(parent) + .map_err(|source| CompilerError::Write { + path: parent.to_path_buf(), + source, + })?; + let temporary_path = temporary.into_temp_path(); + { + let mut connection = Connection::open(&temporary_path)?; + connection.pragma_update(None, "foreign_keys", true)?; + connection.execute_batch(SCHEMA)?; + let transaction = connection.transaction()?; + insert_graph(&transaction, graph, &canonical, provenance, &semantic_hash)?; + let violations: i64 = + transaction.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; + if violations != 0 { + return Err(CompilerError::Contract(format!( + "SQLite foreign key violations: {violations}" + ))); + } + transaction.commit()?; + } + temporary_path + .persist(output) + .map_err(|error| CompilerError::Write { + path: output.to_path_buf(), + source: error.error, + })?; + Ok(IndexBuild { + output: output.to_path_buf(), + semantic_hash, + node_count: graph.nodes.len(), + edge_count: graph.edges.len(), + diagnostics: canonical + .diagnostics + .iter() + .map(|diagnostic| (*diagnostic).clone()) + .collect(), + }) +} + +fn validate_build( + graph: &Graph, + output: &Path, + provenance: &BuildProvenance, +) -> Result<(), CompilerError> { + let projects: BTreeSet<_> = provenance + .project_commits + .iter() + .map(|(project, _)| project.as_str()) + .collect(); + let mut missing: Vec<_> = graph + .nodes + .iter() + .map(|node| node.id.project_id()) + .filter(|project| !projects.contains(project)) + .collect(); + missing.sort_unstable(); + missing.dedup(); + if let Some(project) = missing.first() { + return Err(CompilerError::Contract(format!( + "missing project revision for: {project}" + ))); + } + let resolved_output = absolute_path(output)?; + for root in &provenance.normative_roots { + let resolved_root = absolute_path(root)?; + if resolved_output.starts_with(&resolved_root) { + return Err(CompilerError::Contract(format!( + "refusing to write SQLite index inside normative root: {}", + root.display() + ))); + } + } + Ok(()) +} + +fn absolute_path(path: &Path) -> Result { + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + std::env::current_dir() + .map(|current| current.join(path)) + .map_err(|source| CompilerError::Read { + path: path.to_path_buf(), + source, + }) +} + +struct CanonicalOrder<'a> { + nodes: Vec<&'a Node>, + edges: Vec<&'a Edge>, + diagnostics: Vec<&'a Diagnostic>, +} + +impl<'a> CanonicalOrder<'a> { + fn new(graph: &'a Graph) -> Self { + let mut nodes: Vec<_> = graph.nodes.iter().collect(); + nodes.sort_by(|left, right| left.id.cmp(&right.id)); + let mut edges: Vec<_> = graph.edges.iter().collect(); + edges.sort_by(|left, right| { + (&left.source, &left.edge_type, &left.target).cmp(&( + &right.source, + &right.edge_type, + &right.target, + )) + }); + let mut diagnostics: Vec<_> = graph.diagnostics.iter().collect(); + diagnostics.sort_by(|left, right| { + (&left.primary.path, left.primary.line, &left.code).cmp(&( + &right.primary.path, + right.primary.line, + &right.code, + )) + }); + Self { + nodes, + edges, + diagnostics, + } + } +} + +#[derive(Serialize)] +struct CanonicalPayload<'a> { + format_version: &'a str, + graph: CanonicalGraph<'a>, + ir_version: &'a str, + project_commits: &'a [(String, String)], + tool_version: &'a str, +} + +struct CanonicalGraph<'a> { + order: &'a CanonicalOrder<'a>, +} + +impl Serialize for CanonicalGraph<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut state = serializer.serialize_struct("Graph", 4)?; + state.serialize_field( + "diagnostics", + &CanonicalDiagnostics(&self.order.diagnostics), + )?; + state.serialize_field("edges", &CanonicalEdges(&self.order.edges))?; + state.serialize_field("nodes", &CanonicalNodes(&self.order.nodes))?; + state.serialize_field("schema_version", "0.1")?; + state.end() + } +} + +struct CanonicalNodes<'a>(&'a [&'a Node]); + +impl Serialize for CanonicalNodes<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut sequence = serializer.serialize_seq(Some(self.0.len()))?; + for node in self.0 { + sequence.serialize_element(&CanonicalNode::from(*node))?; + } + sequence.end() + } +} + +#[derive(Serialize)] +struct CanonicalNode<'a> { + attributes: &'a BTreeMap, + body: &'a str, + domain: Option<&'a str>, + id: &'a NodeId, + kind: NodeKind, + modality: Option, + owner: Option<&'a str>, + refinement: Option<&'a str>, + source: CanonicalSpan<'a>, + status: LifecycleStatus, + title: &'a str, +} + +impl<'a> From<&'a Node> for CanonicalNode<'a> { + fn from(node: &'a Node) -> Self { + Self { + attributes: &node.attributes, + body: &node.body, + domain: node.domain.as_deref(), + id: &node.id, + kind: node.kind, + modality: node.modality, + owner: node.owner.as_deref(), + refinement: node.refinement.as_deref(), + source: CanonicalSpan::from(&node.source), + status: node.status, + title: &node.title, + } + } +} + +struct CanonicalEdges<'a>(&'a [&'a Edge]); + +impl Serialize for CanonicalEdges<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut sequence = serializer.serialize_seq(Some(self.0.len()))?; + for edge in self.0 { + sequence.serialize_element(&CanonicalEdge::from(*edge))?; + } + sequence.end() + } +} + +#[derive(Serialize)] +struct CanonicalEdge<'a> { + attributes: &'a BTreeMap, + source: &'a NodeId, + source_span: Option>, + target: &'a NodeId, + #[serde(rename = "type")] + edge_type: &'a str, +} + +impl<'a> From<&'a Edge> for CanonicalEdge<'a> { + fn from(edge: &'a Edge) -> Self { + Self { + attributes: &edge.attributes, + source: &edge.source, + source_span: edge.source_span.as_ref().map(CanonicalSpan::from), + target: &edge.target, + edge_type: &edge.edge_type, + } + } +} + +struct CanonicalDiagnostics<'a>(&'a [&'a Diagnostic]); + +impl Serialize for CanonicalDiagnostics<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut sequence = serializer.serialize_seq(Some(self.0.len()))?; + for diagnostic in self.0 { + sequence.serialize_element(&CanonicalDiagnostic::from(*diagnostic))?; + } + sequence.end() + } +} + +#[derive(Serialize)] +struct CanonicalDiagnostic<'a> { + code: &'a str, + message: &'a str, + primary: CanonicalSpan<'a>, + related: CanonicalSpans<'a>, + repair: Option<&'a str>, + severity: DiagnosticSeverity, + subject: Option<&'a NodeId>, +} + +impl<'a> From<&'a Diagnostic> for CanonicalDiagnostic<'a> { + fn from(diagnostic: &'a Diagnostic) -> Self { + Self { + code: &diagnostic.code, + message: &diagnostic.message, + primary: CanonicalSpan::from(&diagnostic.primary), + related: CanonicalSpans(&diagnostic.related), + repair: diagnostic.repair.as_deref(), + severity: diagnostic.severity, + subject: diagnostic.subject.as_ref(), + } + } +} + +struct CanonicalSpans<'a>(&'a [SourceSpan]); + +impl Serialize for CanonicalSpans<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut sequence = serializer.serialize_seq(Some(self.0.len()))?; + for span in self.0 { + sequence.serialize_element(&CanonicalSpan::from(span))?; + } + sequence.end() + } +} + +#[derive(Clone, Copy, Serialize)] +struct CanonicalSpan<'a> { + column: u32, + end_column: Option, + end_line: Option, + line: u32, + path: &'a str, +} + +impl<'a> CanonicalSpan<'a> { + fn new(span: &'a SourceSpan) -> Self { + Self { + column: span.column, + end_column: span.end_column, + end_line: span.end_line, + line: span.line, + path: &span.path, + } + } +} + +impl<'a> From<&'a SourceSpan> for CanonicalSpan<'a> { + fn from(span: &'a SourceSpan) -> Self { + Self::new(span) + } +} + +struct HashWriter(Sha256); + +impl Write for HashWriter { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + self.0.update(buffer); + Ok(buffer.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn semantic_hash( + graph: &CanonicalOrder<'_>, + provenance: &BuildProvenance, +) -> Result { + let mut project_commits = provenance.project_commits.clone(); + project_commits.sort(); + let payload = CanonicalPayload { + format_version: &provenance.format_version, + graph: CanonicalGraph { order: graph }, + ir_version: &provenance.ir_version, + project_commits: &project_commits, + tool_version: &provenance.tool_version, + }; + let mut writer = HashWriter(Sha256::new()); + serde_json::to_writer(&mut writer, &payload)?; + let digest = writer.0.finalize(); + Ok(digest.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +fn insert_graph( + transaction: &Transaction<'_>, + graph: &Graph, + canonical: &CanonicalOrder<'_>, + provenance: &BuildProvenance, + semantic_hash: &str, +) -> Result<(), CompilerError> { + { + let mut statement = + transaction.prepare("INSERT INTO projects(project_id, revision) VALUES (?, ?)")?; + let mut projects = provenance.project_commits.clone(); + projects.sort(); + for (project, revision) in projects { + statement.execute(params![project, revision])?; + } + } + { + let mut statement = transaction.prepare("INSERT INTO sources(path) VALUES (?)")?; + for path in source_paths(graph) { + statement.execute(params![path])?; + } + } + { + let mut node_statement = transaction.prepare( + "INSERT INTO nodes(uri, project_id, local_id, kind, title, source_path, line, column_number, modality, refinement, domain, status, owner, body) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + )?; + let mut attribute_statement = + transaction.prepare("INSERT INTO node_attrs(node_uri, key, value) VALUES (?, ?, ?)")?; + for node in &canonical.nodes { + node_statement.execute(params![ + node.id.to_string(), + node.id.project_id(), + node.id.local_id(), + enum_text(node.kind)?, + node.title, + node.source.path, + node.source.line, + node.source.column, + optional_enum_text(node.modality)?, + node.refinement, + node.domain, + enum_text(node.status)?, + node.owner, + node.body, + ])?; + for (key, value) in &node.attributes { + attribute_statement.execute(params![node.id.to_string(), key, value])?; + } + } + } + { + let mut statement = transaction.prepare( + "INSERT INTO edges(edge_id, source_uri, edge_type, target_uri, source_path, line, attributes_json) VALUES (?, ?, ?, ?, ?, ?, ?)", + )?; + for (index, edge) in canonical.edges.iter().enumerate() { + statement.execute(params![ + (index + 1) as i64, + edge.source.to_string(), + edge.edge_type, + edge.target.to_string(), + edge.source_span.as_ref().map(|span| span.path.as_str()), + edge.source_span.as_ref().map(|span| span.line), + serde_json::to_string(&edge.attributes)?, + ])?; + } + } + { + let mut statement = transaction.prepare( + "INSERT INTO diagnostics(diagnostic_id, fingerprint, code, severity, message, subject_uri, source_path, line, repair) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + )?; + for (index, diagnostic) in canonical.diagnostics.iter().enumerate() { + statement.execute(params![ + (index + 1) as i64, + diagnostic_fingerprint(diagnostic), + diagnostic.code, + enum_text(diagnostic.severity)?, + diagnostic.message, + diagnostic.subject.as_ref().map(ToString::to_string), + diagnostic.primary.path, + diagnostic.primary.line, + diagnostic.repair, + ])?; + } + } + transaction.execute( + "INSERT INTO builds(build_id, schema_version, semantic_hash, format_version, ir_version, tool_version) VALUES (1, '1', ?, ?, ?, ?)", + params![ + semantic_hash, + provenance.format_version, + provenance.ir_version, + provenance.tool_version, + ], + )?; + Ok(()) +} + +fn source_paths(graph: &Graph) -> BTreeSet<&str> { + let mut paths = BTreeSet::new(); + for node in &graph.nodes { + paths.insert(node.source.path.as_str()); + } + for edge in &graph.edges { + if let Some(span) = &edge.source_span { + paths.insert(span.path.as_str()); + } + } + for diagnostic in &graph.diagnostics { + paths.insert(diagnostic.primary.path.as_str()); + for span in &diagnostic.related { + paths.insert(span.path.as_str()); + } + } + paths +} + +fn enum_text(value: T) -> Result { + let Value::String(value) = serde_json::to_value(value)? else { + return Err(CompilerError::Contract( + "enum did not serialize as a string".to_owned(), + )); + }; + Ok(value) +} + +fn optional_enum_text(value: Option) -> Result, CompilerError> { + value.map(enum_text).transpose() +} diff --git a/crates/ndf-compiler/src/lib.rs b/crates/ndf-compiler/src/lib.rs new file mode 100644 index 0000000..3b871a1 --- /dev/null +++ b/crates/ndf-compiler/src/lib.rs @@ -0,0 +1,44 @@ +//! NDF parsing, validation, indexing, and query workflows. + +pub mod adapters; +pub mod baseline; +pub mod dependencies; +pub mod diff; +pub mod export; +pub mod fix; +pub mod id_allocator; +pub mod index; +pub mod manifest; +pub mod markdown; +pub mod query; +pub mod report; +pub mod rules; + +use std::path::PathBuf; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CompilerError { + #[error("failed to read {path}: {source}")] + Read { + path: PathBuf, + source: std::io::Error, + }, + #[error("invalid YAML in {path}: {source}")] + Yaml { + path: PathBuf, + source: serde_yaml_ng::Error, + }, + #[error("SQLite error: {0}")] + Sql(#[from] rusqlite::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + #[error("failed to write {path}: {source}")] + Write { + path: PathBuf, + source: std::io::Error, + }, + #[error("{0}")] + Contract(String), +} diff --git a/crates/ndf-compiler/src/manifest.rs b/crates/ndf-compiler/src/manifest.rs new file mode 100644 index 0000000..0d0a55d --- /dev/null +++ b/crates/ndf-compiler/src/manifest.rs @@ -0,0 +1,264 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_yaml_ng::{Mapping, Value}; + +use crate::CompilerError; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct DependencyDeclaration { + pub path: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ProjectManifest { + pub format_version: String, + pub project: String, + pub roots: Vec, + pub id_prefixes: Vec, + pub domains: Vec, + pub policies: BTreeMap, + pub root: PathBuf, + pub dependencies: BTreeMap, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct LockedDependency { + pub project: String, + pub uri: String, + pub revision: String, + pub path: String, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct ProjectLock { + pub format_version: String, + pub dependencies: Vec, +} + +impl ProjectManifest { + pub fn load(path: &Path) -> Result { + let mapping = load_mapping(path)?; + reject_unknown( + &mapping, + &[ + "format_version", + "project", + "roots", + "id_prefixes", + "domains", + "policies", + "dependencies", + ], + "manifest", + )?; + let format_version = required_string(&mapping, "format_version")?; + let project = required_string(&mapping, "project")?; + let roots = required_strings(&mapping, "roots")?; + let id_prefixes = required_strings(&mapping, "id_prefixes")?; + let domains = optional_strings(&mapping, "domains")?; + let policies = optional_named_mapping(&mapping, "policies")?; + let dependency_values = optional_named_mapping(&mapping, "dependencies")?; + let mut dependencies = BTreeMap::new(); + for (name, value) in dependency_values { + let declaration = value + .as_mapping() + .ok_or_else(|| contract("each dependency declaration must be a named mapping"))?; + reject_unknown(declaration, &["path"], "dependency declaration")?; + if declaration.len() != 1 { + return Err(contract( + "dependency declaration must contain only a string path", + )); + } + dependencies.insert( + name, + DependencyDeclaration { + path: required_string(declaration, "path")?, + }, + ); + } + Ok(Self { + format_version, + project, + roots, + id_prefixes, + domains, + policies, + root: path + .parent() + .unwrap_or_else(|| Path::new(".")) + .canonicalize() + .map_err(|source| CompilerError::Read { + path: path.to_path_buf(), + source, + })?, + dependencies, + }) + } + + pub fn for_test( + project: &str, + root: &Path, + id_prefixes: [&str; P], + domains: [&str; D], + ) -> Self { + Self { + format_version: "0.2".to_owned(), + project: project.to_owned(), + roots: vec!["*.md".to_owned()], + id_prefixes: id_prefixes.into_iter().map(str::to_owned).collect(), + domains: domains.into_iter().map(str::to_owned).collect(), + policies: BTreeMap::new(), + root: root.to_path_buf(), + dependencies: BTreeMap::new(), + } + } +} + +impl ProjectLock { + pub fn load(path: &Path) -> Result { + let mapping = load_mapping(path)?; + reject_unknown(&mapping, &["format_version", "dependencies"], "lock")?; + let format_version = required_string(&mapping, "format_version")?; + let dependency_values = required_named_mapping(&mapping, "dependencies")?; + let mut dependencies = Vec::new(); + for (project, value) in dependency_values { + let fields = value + .as_mapping() + .ok_or_else(|| contract("each dependency must be a named mapping"))?; + reject_unknown(fields, &["uri", "revision", "path"], "dependency")?; + let uri = required_string(fields, "uri")?; + let revision = required_string(fields, "revision")?; + let dependency_path = required_string(fields, "path")?; + if revision.len() != 40 + || !revision + .chars() + .all(|character| character.is_ascii_digit() || ('a'..='f').contains(&character)) + { + return Err(contract( + "dependency revision must be 40 lowercase hexadecimal characters", + )); + } + dependencies.push(LockedDependency { + project, + uri, + revision, + path: dependency_path, + }); + } + Ok(Self { + format_version, + dependencies, + }) + } +} + +fn contract(message: impl Into) -> CompilerError { + CompilerError::Contract(message.into()) +} + +fn load_mapping(path: &Path) -> Result { + let text = fs::read_to_string(path).map_err(|source| CompilerError::Read { + path: path.to_path_buf(), + source, + })?; + let value: Value = serde_yaml_ng::from_str(&text).map_err(|source| CompilerError::Yaml { + path: path.to_path_buf(), + source, + })?; + value + .as_mapping() + .cloned() + .ok_or_else(|| contract(format!("expected a YAML mapping in {}", path.display()))) +} + +fn key(name: &str) -> Value { + Value::String(name.to_owned()) +} + +fn required<'a>(mapping: &'a Mapping, name: &str) -> Result<&'a Value, CompilerError> { + mapping + .get(key(name)) + .ok_or_else(|| contract(format!("missing required field: {name}"))) +} + +fn required_string(mapping: &Mapping, name: &str) -> Result { + required(mapping, name)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| contract(format!("{name} must be a string"))) +} + +fn required_strings(mapping: &Mapping, name: &str) -> Result, CompilerError> { + string_list(required(mapping, name)?, name) +} + +fn optional_strings(mapping: &Mapping, name: &str) -> Result, CompilerError> { + mapping + .get(key(name)) + .map_or_else(|| Ok(Vec::new()), |value| string_list(value, name)) +} + +fn string_list(value: &Value, name: &str) -> Result, CompilerError> { + let items = value + .as_sequence() + .ok_or_else(|| contract(format!("{name} must be a list of strings")))?; + items + .iter() + .map(|item| { + item.as_str() + .map(str::to_owned) + .ok_or_else(|| contract(format!("{name} must be a list of strings"))) + }) + .collect() +} + +fn required_named_mapping( + mapping: &Mapping, + name: &str, +) -> Result, CompilerError> { + named_mapping(required(mapping, name)?, name) +} + +fn optional_named_mapping( + mapping: &Mapping, + name: &str, +) -> Result, CompilerError> { + mapping + .get(key(name)) + .map_or_else(|| Ok(BTreeMap::new()), |value| named_mapping(value, name)) +} + +fn named_mapping(value: &Value, name: &str) -> Result, CompilerError> { + let mapping = value + .as_mapping() + .ok_or_else(|| contract(format!("{name} must be a mapping")))?; + mapping + .iter() + .map(|(key, value)| { + key.as_str() + .map(|name| (name.to_owned(), value.clone())) + .ok_or_else(|| contract(format!("{name} keys must be strings"))) + }) + .collect() +} + +fn reject_unknown(mapping: &Mapping, allowed: &[&str], context: &str) -> Result<(), CompilerError> { + let mut unknown: Vec<_> = mapping + .keys() + .filter_map(Value::as_str) + .filter(|name| !allowed.contains(name)) + .collect(); + unknown.sort_unstable(); + if let Some(name) = unknown.first() { + let label = match context { + "manifest" => "unsupported manifest field", + "lock" => "unsupported lock field", + "dependency" => "unsupported dependency field", + _ => "unsupported dependency declaration field", + }; + return Err(contract(format!("{label}: {name}"))); + } + Ok(()) +} diff --git a/crates/ndf-compiler/src/markdown.rs b/crates/ndf-compiler/src/markdown.rs new file mode 100644 index 0000000..f27836d --- /dev/null +++ b/crates/ndf-compiler/src/markdown.rs @@ -0,0 +1,607 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; + +use ndf_core::graph::GraphBuilder; +use ndf_core::identity::NodeId; +use ndf_core::model::{ + Diagnostic, DiagnosticSeverity, Edge, LifecycleStatus, Modality, Node, NodeKind, ParseResult, + SourceSpan, +}; +use serde_yaml_ng::{Mapping, Value}; + +use crate::CompilerError; +use crate::manifest::ProjectManifest; + +const EDGE_FIELDS: &[&str] = &[ + "refines", + "depends-on", + "conflicts-with", + "verifies", + "derived-from", + "implements", + "affects", + "blocks", + "supersedes", + "references", + "evidenced-by", +]; +const CANONICAL_FIELDS: &[&str] = &[ + "kind", + "modality", + "refinement", + "domain", + "status", + "owner", +]; + +pub fn parse_markdown( + path: &Path, + project: &ProjectManifest, +) -> Result { + let relative_path = relative_path(path, project)?; + let text = fs::read_to_string(path).map_err(|source| CompilerError::Read { + path: path.to_path_buf(), + source, + })?; + let lines: Vec<_> = text.lines().collect(); + let (front, content_start, front_diagnostics) = parse_front_matter(&lines, &relative_path)?; + let Some(front) = front else { + return Ok(ParseResult { + diagnostics: front_diagnostics, + ..ParseResult::default() + }); + }; + let required = ["doc_id", "status", "authority", "owner"]; + for name in required { + if !front.contains_key(Value::String(name.to_owned())) { + return Ok(only_diagnostic(diagnostic( + "NDF-MD-003", + DiagnosticSeverity::Error, + format!("missing front matter field: {name}"), + &relative_path, + 1, + "Add all required document front matter fields.", + ))); + } + } + let mut unknown_front: Vec<_> = front + .keys() + .filter_map(Value::as_str) + .filter(|name| !required.contains(name)) + .collect(); + unknown_front.sort_unstable(); + if let Some(name) = unknown_front.first() { + return Ok(only_diagnostic(diagnostic( + "NDF-MD-003", + DiagnosticSeverity::Error, + format!("unsupported front matter field: {name}"), + &relative_path, + 1, + "Remove unsupported document front matter.", + ))); + } + + let doc_id = front_string(&front, "doc_id")?; + let owner = front_string(&front, "owner")?; + let authority = front_string(&front, "authority")?; + let status_value = front_string(&front, "status")?; + let Some(document_status) = lifecycle_status(&status_value) else { + return Ok(only_diagnostic(diagnostic( + "NDF-MD-004", + DiagnosticSeverity::Error, + format!("invalid document status: {status_value}"), + &relative_path, + 1, + "Use a canonical lifecycle status.", + ))); + }; + + let title = first_document_title(&lines[content_start..]).unwrap_or_else(|| doc_id.clone()); + let mut attributes = BTreeMap::new(); + attributes.insert("authority".to_owned(), authority); + let document = Node { + id: NodeId::new(&project.project, &doc_id) + .map_err(|error| CompilerError::Contract(error.to_string()))?, + kind: NodeKind::Document, + title, + source: SourceSpan::new(&relative_path, 1).expect("line one is valid"), + modality: None, + refinement: None, + domain: None, + status: document_status, + owner: Some(owner.clone()), + body: String::new(), + attributes, + }; + + let mut builder = GraphBuilder::default(); + builder.add_node(document); + for item in front_diagnostics { + builder.add_diagnostic(item); + } + let mut fence: Option = None; + for index in content_start..lines.len() { + if let Some(marker) = fence_marker(lines[index]) { + fence = match fence { + None => Some(marker), + Some(active) if active == marker => None, + active => active, + }; + continue; + } + if fence.is_some() { + continue; + } + let Some(heading) = parse_heading(lines[index]) else { + continue; + }; + let Some(local_id) = heading.local_id else { + continue; + }; + let metadata_index = index + 1; + if metadata_index >= lines.len() { + continue; + } + let (metadata, diagnostics) = parse_metadata( + lines[metadata_index], + &relative_path, + (metadata_index + 1) as u32, + ); + for item in diagnostics { + builder.add_diagnostic(item); + } + let Some(metadata) = metadata else { + continue; + }; + let (metadata, migration) = + normalize_metadata(metadata, &relative_path, (metadata_index + 1) as u32); + if let Some(migration) = migration { + builder.add_diagnostic(migration); + } + let Some(kind) = node_kind(metadata.get("kind").map_or("information", String::as_str)) + else { + builder.add_diagnostic(invalid_value(&relative_path, metadata_index + 1, "kind")); + continue; + }; + let modality = match metadata.get("modality") { + Some(value) => match modality(value) { + Some(value) => Some(value), + None => { + builder.add_diagnostic(invalid_value( + &relative_path, + metadata_index + 1, + "modality", + )); + continue; + } + }, + None => None, + }; + let status_text = metadata.get("status").map_or("draft", String::as_str); + let Some(status) = lifecycle_status(status_text) else { + builder.add_diagnostic(invalid_value(&relative_path, metadata_index + 1, "status")); + continue; + }; + let node = Node { + id: NodeId::new(&project.project, local_id) + .map_err(|error| CompilerError::Contract(error.to_string()))?, + kind, + title: heading.title, + source: SourceSpan::new(&relative_path, (index + 1) as u32) + .expect("document line is positive"), + modality, + refinement: metadata.get("refinement").cloned(), + domain: metadata.get("domain").cloned(), + status, + owner: Some( + metadata + .get("owner") + .cloned() + .unwrap_or_else(|| owner.clone()), + ), + body: body(&lines, metadata_index + 1), + attributes: BTreeMap::new(), + }; + let source_id = node.id.clone(); + builder.add_node(node); + for edge_type in EDGE_FIELDS { + let Some(targets) = metadata.get(*edge_type) else { + continue; + }; + for target in targets.split(',').map(str::trim) { + builder.add_edge( + Edge::new( + source_id.clone(), + NodeId::parse(target, Some(&project.project)) + .map_err(|error| CompilerError::Contract(error.to_string()))?, + *edge_type, + Some( + SourceSpan::new(&relative_path, (metadata_index + 1) as u32) + .expect("metadata line is positive"), + ), + ) + .expect("registered edge types are not empty"), + ); + } + } + } + let graph = builder.finish(); + Ok(ParseResult { + nodes: graph.nodes, + edges: graph.edges, + diagnostics: graph.diagnostics, + }) +} + +struct Heading<'a> { + title: String, + local_id: Option<&'a str>, +} + +fn parse_heading(line: &str) -> Option> { + let hashes = line + .chars() + .take_while(|character| *character == '#') + .count(); + if !(1..=6).contains(&hashes) || !line[hashes..].starts_with(char::is_whitespace) { + return None; + } + let content = line[hashes..].trim(); + if let Some(prefix) = content.strip_suffix('}') + && let Some((title, local_id)) = prefix.rsplit_once(" {#") + && !local_id.is_empty() + { + return Some(Heading { + title: title.to_owned(), + local_id: Some(local_id), + }); + } + Some(Heading { + title: content.to_owned(), + local_id: None, + }) +} + +fn fence_marker(line: &str) -> Option { + let trimmed = line.trim_start(); + let marker = trimmed.chars().next()?; + if !matches!(marker, '`' | '~') + || trimmed.chars().take_while(|value| *value == marker).count() < 3 + { + return None; + } + Some(marker) +} + +fn first_document_title(lines: &[&str]) -> Option { + let mut fence = None; + for line in lines { + if let Some(marker) = fence_marker(line) { + fence = match fence { + None => Some(marker), + Some(active) if active == marker => None, + active => active, + }; + continue; + } + if fence.is_none() + && line.starts_with("# ") + && let Some(heading) = parse_heading(line) + { + return Some(heading.title); + } + } + None +} + +fn parse_front_matter( + lines: &[&str], + path: &str, +) -> Result<(Option, usize, Vec), CompilerError> { + if lines.first().map(|line| line.trim()) != Some("---") { + return Ok(( + None, + 0, + vec![diagnostic( + "NDF-MD-001", + DiagnosticSeverity::Error, + "document front matter must start with ---", + path, + 1, + "Add complete YAML front matter at the start of the file.", + )], + )); + } + let Some(closing) = lines + .iter() + .enumerate() + .skip(1) + .find_map(|(index, line)| (line.trim() == "---").then_some(index)) + else { + return Ok(( + None, + 0, + vec![diagnostic( + "NDF-MD-001", + DiagnosticSeverity::Error, + "document front matter is not closed", + path, + 1, + "Close the YAML front matter with ---. ", + )], + )); + }; + let yaml = lines[1..closing].join("\n"); + let value: Value = match serde_yaml_ng::from_str(&yaml) { + Ok(value) => value, + Err(error) => { + return Ok(( + None, + closing + 1, + vec![diagnostic( + "NDF-MD-002", + DiagnosticSeverity::Error, + format!("invalid front matter: {error}"), + path, + 1, + "Remove duplicate keys and repair the YAML mapping.", + )], + )); + } + }; + Ok((value.as_mapping().cloned(), closing + 1, Vec::new())) +} + +fn parse_metadata( + line: &str, + path: &str, + line_number: u32, +) -> (Option>, Vec) { + let stripped = line.trim(); + if stripped.starts_with("") { + return ( + None, + vec![diagnostic( + "NDF-MD-001", + DiagnosticSeverity::Error, + "NDF metadata comment is not closed", + path, + line_number, + "Close the metadata comment with --> on the same line.", + )], + ); + } + let Some(content) = stripped + .strip_prefix("")) + else { + return (None, Vec::new()); + }; + let mut metadata = BTreeMap::new(); + for token in content.split_whitespace() { + let Some((name, value)) = token.split_once('=') else { + return ( + None, + vec![diagnostic( + "NDF-MD-001", + DiagnosticSeverity::Error, + format!("invalid NDF metadata token: {token}"), + path, + line_number, + "Write metadata as key=value tokens.", + )], + ); + }; + if metadata.contains_key(name) { + return ( + None, + vec![diagnostic( + "NDF-MD-002", + DiagnosticSeverity::Error, + format!("duplicate NDF metadata key: {name}"), + path, + line_number, + "Keep exactly one value for each metadata key.", + )], + ); + } + metadata.insert(name.to_owned(), value.to_owned()); + } + let allowed: BTreeSet<_> = CANONICAL_FIELDS + .iter() + .chain(EDGE_FIELDS) + .chain(["level", "layer"].iter()) + .copied() + .collect(); + if let Some(name) = metadata + .keys() + .find(|name| !allowed.contains(name.as_str())) + { + return ( + None, + vec![diagnostic( + "NDF-MD-003", + DiagnosticSeverity::Error, + format!("unsupported NDF metadata field: {name}"), + path, + line_number, + "Remove the field or register it in the project format.", + )], + ); + } + (Some(metadata), Vec::new()) +} + +fn normalize_metadata( + mut metadata: BTreeMap, + path: &str, + line: u32, +) -> (BTreeMap, Option) { + let mut migrated = false; + if metadata.get("kind").is_some_and(|value| value == "req") { + metadata.insert("kind".to_owned(), "requirement".to_owned()); + migrated = true; + } + if let Some(value) = metadata.remove("level") { + metadata.insert("modality".to_owned(), value); + migrated = true; + } + if let Some(value) = metadata.remove("layer") { + metadata.insert("refinement".to_owned(), value); + migrated = true; + } + if metadata + .get("status") + .is_some_and(|value| value == "stable") + { + metadata.insert("status".to_owned(), "active".to_owned()); + migrated = true; + } + let diagnostic = migrated.then(|| { + diagnostic( + "NDF-MIG-001", + DiagnosticSeverity::Information, + "legacy DavinciOO metadata was mapped to canonical fields", + path, + line, + "Rewrite the clause with kind, modality, refinement, and status.", + ) + }); + (metadata, diagnostic) +} + +fn body(lines: &[&str], start: usize) -> String { + let mut collected = Vec::new(); + let mut fence = None; + for line in &lines[start..] { + if let Some(marker) = fence_marker(line) { + fence = match fence { + None => Some(marker), + Some(active) if active == marker => None, + active => active, + }; + collected.push(*line); + continue; + } + if fence.is_none() && parse_heading(line).is_some() { + break; + } + collected.push(*line); + } + collected.join("\n").trim().to_owned() +} + +fn diagnostic( + code: &str, + severity: DiagnosticSeverity, + message: impl Into, + path: &str, + line: u32, + repair: &str, +) -> Diagnostic { + Diagnostic { + code: code.to_owned(), + severity, + message: message.into(), + primary: SourceSpan::new(path, line).expect("diagnostic line is positive"), + related: Vec::new(), + repair: Some(repair.to_owned()), + subject: None, + } +} + +fn only_diagnostic(diagnostic: Diagnostic) -> ParseResult { + ParseResult { + diagnostics: vec![diagnostic], + ..ParseResult::default() + } +} + +fn invalid_value(path: &str, line: usize, field: &str) -> Diagnostic { + diagnostic( + "NDF-MD-004", + DiagnosticSeverity::Error, + format!("invalid canonical metadata value: {field}"), + path, + line as u32, + "Use canonical kind, modality, and lifecycle values.", + ) +} + +fn relative_path(path: &Path, project: &ProjectManifest) -> Result { + path.canonicalize() + .map_err(|source| CompilerError::Read { + path: path.to_path_buf(), + source, + })? + .strip_prefix( + project + .root + .canonicalize() + .map_err(|source| CompilerError::Read { + path: project.root.clone(), + source, + })?, + ) + .map(|relative| relative.to_string_lossy().replace('\\', "/")) + .map_err(|_| { + CompilerError::Contract(format!( + "document is outside project root: {}", + path.display() + )) + }) +} + +fn front_string(mapping: &Mapping, name: &str) -> Result { + mapping + .get(Value::String(name.to_owned())) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| { + CompilerError::Contract(format!("front matter field must be a string: {name}")) + }) +} + +fn node_kind(value: &str) -> Option { + Some(match value { + "document" => NodeKind::Document, + "requirement" => NodeKind::Requirement, + "definition" => NodeKind::Definition, + "architecture" => NodeKind::Architecture, + "constraint" => NodeKind::Constraint, + "option" => NodeKind::Option, + "verification" => NodeKind::Verification, + "decision" => NodeKind::Decision, + "question" => NodeKind::Question, + "model" => NodeKind::Model, + "source" => NodeKind::Source, + "test" => NodeKind::Test, + "evidence" => NodeKind::Evidence, + "information" => NodeKind::Information, + _ => return None, + }) +} + +fn modality(value: &str) -> Option { + Some(match value { + "must" => Modality::Must, + "should" => Modality::Should, + "may" => Modality::May, + "tbd" => Modality::Tbd, + _ => return None, + }) +} + +fn lifecycle_status(value: &str) -> Option { + Some(match value { + "draft" => LifecycleStatus::Draft, + "active" => LifecycleStatus::Active, + "deprecated" => LifecycleStatus::Deprecated, + "superseded" => LifecycleStatus::Superseded, + "retired" => LifecycleStatus::Retired, + "open" => LifecycleStatus::Open, + "resolved" => LifecycleStatus::Resolved, + _ => return None, + }) +} diff --git a/crates/ndf-compiler/src/query.rs b/crates/ndf-compiler/src/query.rs new file mode 100644 index 0000000..c422eaf --- /dev/null +++ b/crates/ndf-compiler/src/query.rs @@ -0,0 +1,113 @@ +use std::collections::HashSet; + +use serde::Serialize; + +use crate::CompilerError; +use crate::index::{IndexedEdge, NdfIndex, NodeRecord}; + +const QUERY_FIELDS: &[&str] = &[ + "kind", + "modality", + "refinement", + "domain", + "status", + "owner", +]; + +pub fn query_nodes(index: &NdfIndex, expression: &str) -> Result, CompilerError> { + let mut filters = Vec::new(); + for clause in expression.split(" and ") { + let Some((field, value)) = clause.split_once('=') else { + return Err(CompilerError::Contract(format!( + "invalid query clause: {clause}" + ))); + }; + let field = field.trim(); + let value = value.trim(); + if !QUERY_FIELDS.contains(&field) { + return Err(CompilerError::Contract(format!( + "unsupported query field: {field}" + ))); + } + filters.push((field, value)); + } + index.filtered_nodes(&filters) +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct TraceEdge { + pub source: String, + #[serde(rename = "type")] + pub edge_type: String, + pub target: String, +} + +impl From for TraceEdge { + fn from(edge: IndexedEdge) -> Self { + Self { + source: edge.source, + edge_type: edge.edge_type, + target: edge.target, + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct TraceTree { + pub root: String, + pub nodes: Vec, + pub edges: Vec, + pub cycles: Vec, +} + +struct Frame { + uri: String, + edges: Vec, + next_edge: usize, +} + +pub fn trace(index: &NdfIndex, uri: &str, edge_types: &[&str]) -> Result { + let root = index.resolve_uri(uri)?; + let mut visited = HashSet::from([root.clone()]); + let mut active = HashSet::from([root.clone()]); + let mut nodes = vec![root.clone()]; + let mut edges = Vec::new(); + let mut cycles = Vec::new(); + let mut stack = vec![Frame { + edges: index.out_edges_by_types(&root, edge_types)?, + uri: root.clone(), + next_edge: 0, + }]; + + while let Some(frame) = stack.last_mut() { + if frame.next_edge == frame.edges.len() { + let completed = stack.pop().expect("stack contains current frame"); + active.remove(&completed.uri); + continue; + } + let edge = frame.edges[frame.next_edge].clone(); + frame.next_edge += 1; + let trace_edge = TraceEdge::from(edge.clone()); + edges.push(trace_edge.clone()); + if active.contains(&edge.target) { + cycles.push(trace_edge); + continue; + } + if visited.insert(edge.target.clone()) { + nodes.push(edge.target.clone()); + active.insert(edge.target.clone()); + stack.push(Frame { + edges: index.out_edges_by_types(&edge.target, edge_types)?, + uri: edge.target, + next_edge: 0, + }); + } + } + + Ok(TraceTree { + root, + nodes, + edges, + cycles, + }) +} diff --git a/crates/ndf-compiler/src/report.rs b/crates/ndf-compiler/src/report.rs new file mode 100644 index 0000000..b1b5e7d --- /dev/null +++ b/crates/ndf-compiler/src/report.rs @@ -0,0 +1,36 @@ +use serde::Serialize; + +use crate::CompilerError; +use crate::index::NdfIndex; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct CoveragePolicy { + pub refinement: String, +} + +impl Default for CoveragePolicy { + fn default() -> Self { + Self { + refinement: "L1".to_owned(), + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct CoverageReport { + pub unverified: Vec, + pub open_items: Vec, + pub tbd: Vec, +} + +pub fn coverage( + index: &NdfIndex, + policy: &CoveragePolicy, +) -> Result { + let uris = index.coverage_uris(&policy.refinement)?; + Ok(CoverageReport { + unverified: uris.unverified, + open_items: uris.open_items, + tbd: uris.tbd, + }) +} diff --git a/crates/ndf-compiler/src/rules.rs b/crates/ndf-compiler/src/rules.rs new file mode 100644 index 0000000..a1dea37 --- /dev/null +++ b/crates/ndf-compiler/src/rules.rs @@ -0,0 +1,145 @@ +use std::collections::{BTreeSet, HashMap}; + +use ndf_core::identity::NodeId; +use ndf_core::model::{ + Diagnostic, DiagnosticSeverity, Graph, LifecycleStatus, Modality, Node, NodeKind, SourceSpan, +}; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct ValidationPolicy { + pub mandatory_edge_types: BTreeSet<&'static str>, + pub ownership_kinds: BTreeSet, +} + +impl Default for ValidationPolicy { + fn default() -> Self { + Self { + mandatory_edge_types: ["refines", "depends-on", "verifies", "implements"] + .into_iter() + .collect(), + ownership_kinds: [ + NodeKind::Requirement, + NodeKind::Constraint, + NodeKind::Verification, + NodeKind::Decision, + NodeKind::Question, + ] + .into_iter() + .collect(), + } + } +} + +pub fn validate_graph(graph: &Graph, policy: &ValidationPolicy) -> Vec { + let mut diagnostics = graph.diagnostics.clone(); + let nodes_by_id: HashMap<&NodeId, &Node> = + graph.nodes.iter().map(|node| (&node.id, node)).collect(); + + for edge in &graph.edges { + if !policy + .mandatory_edge_types + .contains(edge.edge_type.as_str()) + { + continue; + } + if nodes_by_id.contains_key(&edge.source) && nodes_by_id.contains_key(&edge.target) { + continue; + } + let primary = edge + .source_span + .clone() + .or_else(|| { + nodes_by_id + .get(&edge.source) + .map(|node| node.source.clone()) + }) + .unwrap_or_else(|| SourceSpan::new("", 1).expect("line one is valid")); + diagnostics.push(Diagnostic { + code: "NDF-EDGE-001".to_owned(), + severity: DiagnosticSeverity::Error, + message: format!("mandatory edge {} has a dangling endpoint", edge.edge_type), + primary, + related: Vec::new(), + repair: Some("Resolve both endpoints or remove the mandatory edge.".to_owned()), + subject: Some(edge.source.clone()), + }); + } + + for node in &graph.nodes { + if node.modality == Some(Modality::Must) && !contains_word(&node.body, "MUST") { + diagnostics.push(node_diagnostic( + "NDF-MOD-001", + format!("must requirement {} does not contain MUST", node.id), + node, + "Add normative MUST language or change the modality.", + )); + } + if node.kind == NodeKind::Information && contains_word(&node.body, "MUST") { + diagnostics.push(node_diagnostic( + "NDF-MOD-002", + format!("information node {} contains MUST", node.id), + node, + "Move the statement to a normative node or remove MUST.", + )); + } + if node.attributes.get("previous_status").map(String::as_str) == Some("active") + && node.status == LifecycleStatus::Draft + { + diagnostics.push(node_diagnostic( + "NDF-LIFE-001", + format!("node {} cannot move from active to draft", node.id), + node, + "Deprecate or supersede the active node instead.", + )); + } + if policy.ownership_kinds.contains(&node.kind) && node.owner.is_none() { + diagnostics.push(node_diagnostic( + "NDF-OWN-001", + format!("explicit normative node {} has no owner", node.id), + node, + "Assign an accountable owner.", + )); + } + } + + diagnostics.sort_by(|left, right| { + ( + &left.primary.path, + left.primary.line, + &left.code, + left.subject.as_ref(), + ) + .cmp(&( + &right.primary.path, + right.primary.line, + &right.code, + right.subject.as_ref(), + )) + }); + diagnostics +} + +fn node_diagnostic(code: &str, message: String, node: &Node, repair: &str) -> Diagnostic { + Diagnostic { + code: code.to_owned(), + severity: DiagnosticSeverity::Error, + message, + primary: node.source.clone(), + related: Vec::new(), + repair: Some(repair.to_owned()), + subject: Some(node.id.clone()), + } +} + +fn contains_word(text: &str, word: &str) -> bool { + text.match_indices(word).any(|(start, _)| { + let before = text[..start].chars().next_back(); + let end = start + word.len(); + let after = text[end..].chars().next(); + !before.is_some_and(is_word_character) && !after.is_some_and(is_word_character) + }) +} + +fn is_word_character(character: char) -> bool { + character.is_alphanumeric() || character == '_' +} diff --git a/crates/ndf-compiler/tests/baseline.rs b/crates/ndf-compiler/tests/baseline.rs new file mode 100644 index 0000000..604e566 --- /dev/null +++ b/crates/ndf-compiler/tests/baseline.rs @@ -0,0 +1,74 @@ +use ndf_compiler::baseline::{ + BaselineEntry, DiagnosticBaseline, compare_baseline, diagnostic_fingerprint, +}; +use ndf_core::identity::NodeId; +use ndf_core::model::{Diagnostic, DiagnosticSeverity, SourceSpan}; + +fn diagnostic(severity: DiagnosticSeverity, line: u32) -> Diagnostic { + Diagnostic { + code: "NDF-MOD-001".to_owned(), + severity, + message: "requirement ndf://fixture/PIPE-001\nis invalid".to_owned(), + primary: SourceSpan::new("docs\\pipeline.md", line).unwrap(), + related: Vec::new(), + repair: None, + subject: Some(NodeId::new("fixture", "PIPE-001").unwrap()), + } +} + +#[test] +fn fingerprint_ignores_line_movement_path_separators_and_whitespace_runs() { + let before = diagnostic(DiagnosticSeverity::Warning, 10); + let mut after = diagnostic(DiagnosticSeverity::Warning, 80); + after.primary.path = "docs/pipeline.md".to_owned(); + after.message = "requirement ndf://fixture/PIPE-001 is invalid".to_owned(); + + assert_eq!( + diagnostic_fingerprint(&before), + diagnostic_fingerprint(&after) + ); +} + +#[test] +fn baseline_classifies_existing_new_and_worsened_diagnostics() { + let warning = diagnostic(DiagnosticSeverity::Warning, 10); + let baseline = DiagnosticBaseline { + entries: vec![BaselineEntry::from_diagnostic( + &warning, + "core", + "2099-12-31", + )], + }; + + let existing = compare_baseline(std::slice::from_ref(&warning), &baseline); + assert_eq!(existing.existing.as_slice(), std::slice::from_ref(&warning)); + + let mut new_code = warning.clone(); + new_code.code = "NDF-OWN-001".to_owned(); + let new_result = compare_baseline(&[new_code.clone()], &baseline); + assert_eq!(new_result.new, [new_code]); + + let error = diagnostic(DiagnosticSeverity::Error, 70); + let worsened = compare_baseline(std::slice::from_ref(&error), &baseline); + assert_eq!(worsened.worsened, [error]); +} + +#[test] +fn baseline_requires_owner_and_valid_expiry() { + let current = diagnostic(DiagnosticSeverity::Warning, 10); + let baseline = DiagnosticBaseline { + entries: vec![BaselineEntry { + fingerprint: diagnostic_fingerprint(¤t), + code: current.code.clone(), + severity: current.severity, + subject: current.subject.as_ref().unwrap().to_string(), + path: current.primary.path.clone(), + owner: None, + expires: None, + }], + }; + + let result = compare_baseline(&[current], &baseline); + + assert_eq!(result.new[0].code, "NDF-BASE-003"); +} diff --git a/crates/ndf-compiler/tests/dependencies.rs b/crates/ndf-compiler/tests/dependencies.rs new file mode 100644 index 0000000..f7c83cf --- /dev/null +++ b/crates/ndf-compiler/tests/dependencies.rs @@ -0,0 +1,106 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; +use std::process::Command; + +use ndf_compiler::dependencies::resolve_dependencies; +use ndf_compiler::manifest::{ + DependencyDeclaration, LockedDependency, ProjectLock, ProjectManifest, +}; + +fn git(path: &Path, arguments: &[&str]) -> String { + let output = Command::new("git") + .args(arguments) + .current_dir(path) + .output() + .unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +fn git_repository(path: &Path) -> String { + fs::create_dir_all(path).unwrap(); + git(path, &["init", "-q"]); + git(path, &["config", "user.email", "ndf@example.invalid"]); + git(path, &["config", "user.name", "NDF Test"]); + fs::write(path.join("README.md"), "fixture\n").unwrap(); + git(path, &["add", "README.md"]); + git(path, &["commit", "-qm", "fixture"]); + git(path, &["rev-parse", "HEAD"]) +} + +fn contracts(root: &Path, revision: &str) -> (ProjectManifest, ProjectLock) { + let mut manifest = ProjectManifest::for_test("fixture", root, ["REQ"], []); + manifest.dependencies = BTreeMap::from([( + "tool".to_owned(), + DependencyDeclaration { + path: "deps/tool".to_owned(), + }, + )]); + let lock = ProjectLock { + format_version: "0.1".to_owned(), + dependencies: vec![LockedDependency { + project: "tool".to_owned(), + uri: "https://example.invalid/tool.git".to_owned(), + revision: revision.to_owned(), + path: "deps/tool".to_owned(), + }], + }; + (manifest, lock) +} + +#[test] +fn exact_clean_git_revision_is_resolved_without_repository_mutation() { + let directory = tempfile::tempdir().unwrap(); + let dependency = directory.path().join("deps/tool"); + let revision = git_repository(&dependency); + let before = git(&dependency, &["status", "--porcelain=v1"]); + let (manifest, lock) = contracts(directory.path(), &revision); + + let resolved = resolve_dependencies(&manifest, &lock, directory.path()).unwrap(); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].project_id, "tool"); + assert_eq!(resolved[0].revision, revision); + assert!(!resolved[0].dirty); + assert_eq!(git(&dependency, &["status", "--porcelain=v1"]), before); +} + +#[test] +fn missing_symbolic_mismatched_and_dirty_dependencies_are_rejected() { + let missing = tempfile::tempdir().unwrap(); + let (manifest, lock) = contracts(missing.path(), &"0".repeat(40)); + assert!( + resolve_dependencies(&manifest, &lock, missing.path()) + .unwrap_err() + .to_string() + .contains("NDF-DEP-001") + ); + + let directory = tempfile::tempdir().unwrap(); + let dependency = directory.path().join("deps/tool"); + let revision = git_repository(&dependency); + let (manifest, mut lock) = contracts(directory.path(), &revision); + lock.dependencies[0].revision = "main".to_owned(); + assert!( + resolve_dependencies(&manifest, &lock, directory.path()) + .unwrap_err() + .to_string() + .contains("NDF-DEP-002") + ); + lock.dependencies[0].revision = "f".repeat(40); + assert!( + resolve_dependencies(&manifest, &lock, directory.path()) + .unwrap_err() + .to_string() + .contains("NDF-DEP-003") + ); + lock.dependencies[0].revision = revision; + fs::write(dependency.join("dirty.txt"), "dirty\n").unwrap(); + assert!( + resolve_dependencies(&manifest, &lock, directory.path()) + .unwrap_err() + .to_string() + .contains("NDF-DEP-004") + ); +} diff --git a/crates/ndf-compiler/tests/index.rs b/crates/ndf-compiler/tests/index.rs new file mode 100644 index 0000000..45d9444 --- /dev/null +++ b/crates/ndf-compiler/tests/index.rs @@ -0,0 +1,162 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use ndf_compiler::index::{BuildProvenance, NdfIndex, build_index}; +use ndf_core::identity::NodeId; +use ndf_core::model::{Edge, Graph, LifecycleStatus, Node, NodeKind, SourceSpan}; +use rusqlite::types::ValueRef; +use serde_json::{Value, json}; + +const TABLES: &[&str] = &[ + "projects", + "sources", + "nodes", + "node_attrs", + "aliases", + "edges", + "diagnostics", + "baselines", + "builds", +]; + +fn repository() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn node(local_id: &str, kind: NodeKind, title: &str, line: u32, body: &str) -> Node { + Node { + id: NodeId::new("fixture", local_id).unwrap(), + kind, + title: title.to_owned(), + source: SourceSpan::new("docs/pipeline.md", line).unwrap(), + modality: None, + refinement: None, + domain: None, + status: LifecycleStatus::Active, + owner: Some("core".to_owned()), + body: body.to_owned(), + attributes: BTreeMap::new(), + } +} + +fn graph(reverse: bool) -> Graph { + let contract = node( + "PIPE-010", + NodeKind::Requirement, + "Contract", + 10, + "The core MUST issue in order.", + ); + let mechanism = node("PIPE-020", NodeKind::Architecture, "Mechanism", 20, ""); + let edge = Edge::new( + mechanism.id.clone(), + contract.id.clone(), + "refines", + Some(mechanism.source.clone()), + ) + .unwrap(); + Graph { + nodes: if reverse { + vec![contract, mechanism] + } else { + vec![mechanism, contract] + }, + edges: vec![edge], + diagnostics: Vec::new(), + } +} + +fn provenance() -> BuildProvenance { + BuildProvenance { + project_commits: vec![("fixture".to_owned(), "1".repeat(40))], + format_version: "0.2".to_owned(), + ir_version: "0.1".to_owned(), + tool_version: "0.1.0".to_owned(), + normative_roots: Vec::new(), + } +} + +fn logical_rows(path: &Path) -> Value { + let connection = rusqlite::Connection::open(path).unwrap(); + let mut tables = serde_json::Map::new(); + for table in TABLES { + let mut statement = connection + .prepare(&format!("SELECT * FROM {table}")) + .unwrap(); + let column_count = statement.column_count(); + let mut rows: Vec = statement + .query_map([], |row| { + let values = (0..column_count) + .map(|index| match row.get_ref(index).unwrap() { + ValueRef::Null => Value::Null, + ValueRef::Integer(value) => json!(value), + ValueRef::Real(value) => json!(value), + ValueRef::Text(value) => { + Value::String(String::from_utf8(value.to_vec()).unwrap()) + } + ValueRef::Blob(value) => Value::String(format!("blob:{}", value.len())), + }) + .collect(); + Ok(Value::Array(values)) + }) + .unwrap() + .map(Result::unwrap) + .collect(); + rows.sort_by_key(|row| serde_json::to_string(row).unwrap()); + tables.insert((*table).to_owned(), Value::Array(rows)); + } + Value::Object(tables) +} + +#[test] +fn index_hash_and_rows_match_the_frozen_contract() { + let expected: Value = serde_json::from_str( + &fs::read_to_string(repository().join("tests/golden/index.json")).unwrap(), + ) + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + let output = directory.path().join("graph.sqlite"); + + let build = build_index(&graph(false), &output, &provenance()).unwrap(); + + assert_eq!(build.semantic_hash, expected["semantic_hash"]); + assert_eq!(build.node_count, expected["node_count"]); + assert_eq!(build.edge_count, expected["edge_count"]); + assert_eq!(logical_rows(&output), expected["rows"]); + let index = NdfIndex::open(&output).unwrap(); + assert!(index.foreign_keys_enabled().unwrap()); +} + +#[test] +fn insertion_order_does_not_change_hash_or_rows() { + let directory = tempfile::tempdir().unwrap(); + let forward = directory.path().join("forward.sqlite"); + let reverse = directory.path().join("reverse.sqlite"); + + let first = build_index(&graph(false), &forward, &provenance()).unwrap(); + let second = build_index(&graph(true), &reverse, &provenance()).unwrap(); + + assert_eq!(first.semantic_hash, second.semantic_hash); + assert_eq!(logical_rows(&forward), logical_rows(&reverse)); +} + +#[test] +fn failed_build_preserves_existing_output_and_rejects_normative_root() { + let directory = tempfile::tempdir().unwrap(); + let output = directory.path().join("graph.sqlite"); + fs::write(&output, b"original").unwrap(); + let mut missing_revision = provenance(); + missing_revision.project_commits.clear(); + + let error = build_index(&graph(false), &output, &missing_revision).unwrap_err(); + assert!(error.to_string().contains("missing project revision")); + assert_eq!(fs::read(&output).unwrap(), b"original"); + + let docs = directory.path().join("docs"); + fs::create_dir(&docs).unwrap(); + let mut protected = provenance(); + protected.normative_roots.push(docs.clone()); + let error = build_index(&graph(false), &docs.join("graph.sqlite"), &protected).unwrap_err(); + assert!(error.to_string().contains("normative root")); +} diff --git a/crates/ndf-compiler/tests/manifest.rs b/crates/ndf-compiler/tests/manifest.rs new file mode 100644 index 0000000..02479bf --- /dev/null +++ b/crates/ndf-compiler/tests/manifest.rs @@ -0,0 +1,55 @@ +use std::fs; + +use ndf_compiler::manifest::{ProjectLock, ProjectManifest}; + +fn fixture(path: &str) -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/markdown/project") + .join(path) +} + +#[test] +fn complete_manifest_and_lock_are_normalized() { + let manifest = ProjectManifest::load(&fixture("ndf.yaml")).unwrap(); + let lock = ProjectLock::load(&fixture("ndf.lock")).unwrap(); + + assert_eq!(manifest.format_version, "0.2"); + assert_eq!(manifest.project, "fixture"); + assert_eq!(manifest.roots, ["docs/**/*.md"]); + assert_eq!(manifest.id_prefixes, ["DOC", "PIPE"]); + assert_eq!(lock.dependencies[0].project, "pto-spec"); + assert_eq!( + lock.dependencies[0].revision, + "0123456789abcdef0123456789abcdef01234567" + ); +} + +#[test] +fn lock_rejects_a_symbolic_revision() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("ndf.lock"); + fs::write( + &path, + "format_version: '0.1'\ndependencies:\n pto-spec:\n uri: https://github.com/PTO-ISA/pto-spec.git\n revision: main\n path: external/pto-spec\n", + ) + .unwrap(); + + let error = ProjectLock::load(&path).unwrap_err(); + + assert!( + error + .to_string() + .contains("dependency revision must be 40 lowercase hexadecimal characters") + ); +} + +#[test] +fn manifest_reports_the_first_missing_required_field() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("ndf.yaml"); + fs::write(&path, "format_version: '0.2'\nproject: fixture\n").unwrap(); + + let error = ProjectManifest::load(&path).unwrap_err(); + + assert!(error.to_string().contains("missing required field: roots")); +} diff --git a/crates/ndf-compiler/tests/markdown.rs b/crates/ndf-compiler/tests/markdown.rs new file mode 100644 index 0000000..f0c75b6 --- /dev/null +++ b/crates/ndf-compiler/tests/markdown.rs @@ -0,0 +1,60 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use ndf_compiler::manifest::ProjectManifest; +use ndf_compiler::markdown::parse_markdown; +use ndf_core::model::Graph; +use serde_json::Value; + +fn repository() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn graph_value(path: &Path, manifest: &ProjectManifest) -> Value { + let parsed = parse_markdown(path, manifest).unwrap(); + serde_json::to_value(Graph { + nodes: parsed.nodes, + edges: parsed.edges, + diagnostics: parsed.diagnostics, + }) + .unwrap() +} + +#[test] +fn valid_markdown_matches_the_frozen_contract() { + let root = repository().join("tests/fixtures/markdown/project"); + let manifest = ProjectManifest::load(&root.join("ndf.yaml")).unwrap(); + let expected: Value = serde_json::from_str( + &fs::read_to_string(repository().join("tests/golden/markdown-valid.json")).unwrap(), + ) + .unwrap(); + + let actual = graph_value(&root.join("docs/pipeline.md"), &manifest); + + assert_eq!(actual, expected); +} + +#[test] +fn invalid_and_legacy_markdown_match_the_frozen_contract() { + let root = repository().join("tests/fixtures/oracle/markdown-invalid"); + let manifest = ProjectManifest::for_test("fixture", &root, ["DOC", "PIPE"], ["core"]); + let expected: BTreeMap = serde_json::from_str( + &fs::read_to_string(repository().join("tests/golden/markdown-invalid.json")).unwrap(), + ) + .unwrap(); + + let actual: BTreeMap = fs::read_dir(&root) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "md")) + .map(|path| { + ( + path.file_name().unwrap().to_string_lossy().into_owned(), + graph_value(&path, &manifest), + ) + }) + .collect(); + + assert_eq!(actual, expected); +} diff --git a/crates/ndf-compiler/tests/pto_asl.rs b/crates/ndf-compiler/tests/pto_asl.rs new file mode 100644 index 0000000..eb0c406 --- /dev/null +++ b/crates/ndf-compiler/tests/pto_asl.rs @@ -0,0 +1,69 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use ndf_compiler::adapters::pto_asl::{instruction_clause_id, parse_pto_asl}; +use ndf_compiler::manifest::ProjectManifest; +use ndf_core::model::{Graph, LifecycleStatus, NodeKind}; +use serde_json::Value; + +fn repository() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +#[test] +fn pto_asl_matches_the_frozen_oracle() { + let root = repository().join("tests/fixtures/pto-asl"); + let project = ProjectManifest::for_test( + "pto-spec", + &root, + ["PTO"], + [ + "architecture", + "scalar", + "block", + "tile", + "state", + "memory", + "concurrency", + ], + ); + let expected: BTreeMap = serde_json::from_str( + &fs::read_to_string(repository().join("tests/golden/pto-asl.json")).unwrap(), + ) + .unwrap(); + let actual: BTreeMap = fs::read_dir(&root) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "asl")) + .map(|path| { + let parsed = parse_pto_asl(&path, &project).unwrap(); + ( + path.file_name().unwrap().to_string_lossy().into_owned(), + serde_json::to_value(Graph { + nodes: parsed.nodes, + edges: parsed.edges, + diagnostics: parsed.diagnostics, + }) + .unwrap(), + ) + }) + .collect(); + + assert_eq!(actual, expected); +} + +#[test] +fn instruction_identity_and_clause_contracts_are_preserved() { + assert_eq!( + instruction_clause_id("block", "BSTART.TLOAD").unwrap(), + "PTO-INST-BLOCK-BSTART-TLOAD" + ); + let root = repository().join("tests/fixtures/pto-asl"); + let project = ProjectManifest::for_test("pto-spec", &root, ["PTO"], ["tile"]); + let parsed = parse_pto_asl(&root.join("accept.asl"), &project).unwrap(); + assert_eq!(parsed.nodes[1].kind, NodeKind::Requirement); + assert_eq!(parsed.nodes[1].refinement.as_deref(), Some("L1")); + assert_eq!(parsed.nodes[1].status, LifecycleStatus::Active); + assert_eq!(parsed.edges[0].edge_type, "references"); +} diff --git a/crates/ndf-compiler/tests/query.rs b/crates/ndf-compiler/tests/query.rs new file mode 100644 index 0000000..ba2b333 --- /dev/null +++ b/crates/ndf-compiler/tests/query.rs @@ -0,0 +1,93 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use ndf_compiler::index::{BuildProvenance, NdfIndex, build_index}; +use ndf_compiler::manifest::ProjectManifest; +use ndf_compiler::markdown::parse_markdown; +use ndf_compiler::query::{query_nodes, trace}; +use ndf_compiler::report::{CoveragePolicy, coverage}; +use ndf_core::model::Graph; +use serde_json::{Value, json}; + +fn repository() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn fixture_index(output: &Path) { + let root = repository().join("tests/fixtures/markdown/project"); + let manifest = ProjectManifest::load(&root.join("ndf.yaml")).unwrap(); + let parsed = parse_markdown(&root.join("docs/pipeline.md"), &manifest).unwrap(); + let graph = Graph { + nodes: parsed.nodes, + edges: parsed.edges, + diagnostics: parsed.diagnostics, + }; + build_index( + &graph, + output, + &BuildProvenance { + project_commits: vec![("fixture".to_owned(), "1".repeat(40))], + format_version: "0.2".to_owned(), + ir_version: "0.1".to_owned(), + tool_version: "0.1.0".to_owned(), + normative_roots: Vec::new(), + }, + ) + .unwrap(); +} + +#[test] +fn query_trace_and_coverage_match_the_frozen_oracle() { + let expected: Value = serde_json::from_str( + &fs::read_to_string(repository().join("tests/golden/query-report.json")).unwrap(), + ) + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + let output = directory.path().join("graph.sqlite"); + fixture_index(&output); + let index = NdfIndex::open(&output).unwrap(); + + let actual = json!({ + "query": query_nodes(&index, "kind=requirement and status=active").unwrap(), + "trace": trace(&index, "PIPE-ISSUE-010", &["refines"]).unwrap(), + "coverage": coverage(&index, &CoveragePolicy::default()).unwrap(), + }); + + assert_eq!(actual, expected); +} + +#[test] +fn query_rejects_invalid_clauses_and_trace_reports_cycles() { + let directory = tempfile::tempdir().unwrap(); + let output = directory.path().join("graph.sqlite"); + fixture_index(&output); + let index = NdfIndex::open(&output).unwrap(); + + assert_eq!( + query_nodes(&index, "title=Issue contract") + .unwrap_err() + .to_string(), + "unsupported query field: title" + ); + assert_eq!( + query_nodes(&index, "kind").unwrap_err().to_string(), + "invalid query clause: kind" + ); + + // Insert a cycle through the database to isolate trace traversal behavior. + let connection = rusqlite::Connection::open(&output).unwrap(); + connection + .execute( + "INSERT INTO edges(edge_id, source_uri, edge_type, target_uri, attributes_json) VALUES (99, ?, 'refines', ?, '{}')", + [ + "ndf://fixture/PIPE-ISSUE-001", + "ndf://fixture/PIPE-ISSUE-010", + ], + ) + .unwrap(); + drop(connection); + + let cycle = trace(&index, "PIPE-ISSUE-010", &["refines"]).unwrap(); + assert_eq!(cycle.cycles.len(), 1); + assert_eq!(cycle.cycles[0].target, "ndf://fixture/PIPE-ISSUE-010"); +} diff --git a/crates/ndf-compiler/tests/repository_evidence.rs b/crates/ndf-compiler/tests/repository_evidence.rs new file mode 100644 index 0000000..87315fb --- /dev/null +++ b/crates/ndf-compiler/tests/repository_evidence.rs @@ -0,0 +1,76 @@ +use std::path::{Path, PathBuf}; + +use ndf_compiler::adapters::adapter_for; +use ndf_compiler::adapters::evidence::parse_evidence_manifest; +use ndf_compiler::adapters::repository::index_repository_paths; +use ndf_compiler::manifest::ProjectManifest; +use ndf_core::model::NodeKind; + +fn repository() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +#[test] +fn repository_paths_get_deterministic_source_test_nodes_and_edges() { + let root = repository().join("tests/fixtures/repository"); + let project = ProjectManifest::for_test("fixture", &root, ["REQ"], ["core"]); + let forward = + index_repository_paths(&root, &["src/**/*.py", "tests/**/*.py"], &project).unwrap(); + let reverse = + index_repository_paths(&root, &["tests/**/*.py", "src/**/*.py"], &project).unwrap(); + + assert_eq!(forward, reverse); + assert_eq!(forward.nodes[0].kind, NodeKind::Source); + assert_eq!(forward.nodes[1].kind, NodeKind::Test); + assert_eq!(forward.edges[0].edge_type, "implements"); + assert_eq!(forward.edges[1].edge_type, "verifies"); + assert_eq!(forward.nodes[0].attributes["path"], "src/core.py"); + assert_eq!(forward.nodes[0].attributes["content_hash"].len(), 64); +} + +#[test] +fn evidence_manifest_is_validated_and_linked_to_its_clause() { + let root = repository().join("tests/fixtures/repository"); + let project = ProjectManifest::for_test("fixture", &root, ["EVID"], ["core"]); + let parsed = parse_evidence_manifest(&root.join("evidence/core.json"), &project).unwrap(); + + assert!(parsed.diagnostics.is_empty()); + assert_eq!(parsed.nodes[0].kind, NodeKind::Evidence); + assert_eq!(parsed.nodes[0].id.local_id(), "EVID-CORE-001"); + assert_eq!(parsed.edges[0].source.to_string(), "ndf://fixture/REQ-001"); + assert_eq!( + parsed.edges[0].target.to_string(), + "ndf://fixture/EVID-CORE-001" + ); + assert_eq!(parsed.edges[0].edge_type, "evidenced-by"); +} + +#[test] +fn built_in_adapter_registry_is_static_and_extension_selected() { + assert_eq!( + adapter_for(Path::new("design.md")).unwrap().name(), + "markdown" + ); + assert_eq!( + adapter_for(Path::new("spec.asl")).unwrap().name(), + "pto-asl" + ); + assert_eq!( + adapter_for(Path::new("evidence.json")).unwrap().name(), + "evidence" + ); + assert!(adapter_for(Path::new("generated.bin")).is_none()); +} + +#[test] +fn invalid_evidence_is_a_structured_diagnostic() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("invalid.json"); + std::fs::write(&path, r#"{"format_version":"0.1","id":"EVID-1"}"#).unwrap(); + let project = ProjectManifest::for_test("fixture", directory.path(), ["EVID"], ["core"]); + + let parsed = parse_evidence_manifest(&path, &project).unwrap(); + + assert!(parsed.nodes.is_empty()); + assert_eq!(parsed.diagnostics[0].code, "NDF-EVID-001"); +} diff --git a/crates/ndf-compiler/tests/rules.rs b/crates/ndf-compiler/tests/rules.rs new file mode 100644 index 0000000..ea1d85c --- /dev/null +++ b/crates/ndf-compiler/tests/rules.rs @@ -0,0 +1,87 @@ +use std::collections::BTreeMap; + +use ndf_compiler::rules::{ValidationPolicy, validate_graph}; +use ndf_core::identity::NodeId; +use ndf_core::model::{Edge, Graph, LifecycleStatus, Modality, Node, NodeKind, SourceSpan}; + +fn requirement(body: &str, owner: Option<&str>) -> Node { + Node { + id: NodeId::new("fixture", "PIPE-001").unwrap(), + kind: NodeKind::Requirement, + title: "Issue contract".to_owned(), + source: SourceSpan::new("docs/pipeline.md", 10).unwrap(), + modality: Some(Modality::Must), + refinement: Some("L1".to_owned()), + domain: Some("core".to_owned()), + status: LifecycleStatus::Active, + owner: owner.map(str::to_owned), + body: body.to_owned(), + attributes: BTreeMap::new(), + } +} + +fn codes(graph: &Graph) -> Vec { + validate_graph(graph, &ValidationPolicy::default()) + .into_iter() + .map(|diagnostic| diagnostic.code) + .collect() +} + +#[test] +fn dangling_mandatory_edge_is_rejected() { + let source = requirement("The pipeline MUST issue in order.", Some("core")); + let edge = Edge::new( + source.id.clone(), + NodeId::new("fixture", "PIPE-MISSING").unwrap(), + "refines", + None, + ) + .unwrap(); + let graph = Graph { + nodes: vec![source], + edges: vec![edge], + diagnostics: Vec::new(), + }; + + assert_eq!(codes(&graph), ["NDF-EDGE-001"]); +} + +#[test] +fn must_and_information_language_are_checked_in_both_directions() { + let must = requirement("The pipeline issues in order.", Some("core")); + let information = Node { + id: NodeId::new("fixture", "PIPE-INFO-001").unwrap(), + kind: NodeKind::Information, + title: "Background".to_owned(), + source: SourceSpan::new("docs/pipeline.md", 20).unwrap(), + modality: None, + refinement: None, + domain: None, + status: LifecycleStatus::Draft, + owner: None, + body: "The implementation MUST issue in order.".to_owned(), + attributes: BTreeMap::new(), + }; + let graph = Graph { + nodes: vec![information, must], + edges: Vec::new(), + diagnostics: Vec::new(), + }; + + assert_eq!(codes(&graph), ["NDF-MOD-001", "NDF-MOD-002"]); +} + +#[test] +fn lifecycle_and_owner_invariants_are_enforced() { + let mut node = requirement("The pipeline MUST issue in order.", None); + node.status = LifecycleStatus::Draft; + node.attributes + .insert("previous_status".to_owned(), "active".to_owned()); + let graph = Graph { + nodes: vec![node], + edges: Vec::new(), + diagnostics: Vec::new(), + }; + + assert_eq!(codes(&graph), ["NDF-LIFE-001", "NDF-OWN-001"]); +} diff --git a/crates/ndf-compiler/tests/scale.rs b/crates/ndf-compiler/tests/scale.rs new file mode 100644 index 0000000..39ab086 --- /dev/null +++ b/crates/ndf-compiler/tests/scale.rs @@ -0,0 +1,66 @@ +use std::fs::File; +use std::io::{BufReader, Read}; +use std::path::Path; +use std::time::Instant; + +use ndf_compiler::index::{BuildProvenance, build_index}; +use sha2::{Digest, Sha256}; + +#[path = "../../../tests/scale/generate.rs"] +mod generate; + +#[test] +fn release_scale_contract() { + if std::env::var("NDF_RUN_SCALE_TESTS").as_deref() != Ok("1") { + eprintln!("set NDF_RUN_SCALE_TESTS=1 to run the release scale contract"); + return; + } + let graph = generate::graph(); + let provenance = BuildProvenance { + project_commits: vec![( + "scale".to_owned(), + "0123456789abcdef0123456789abcdef01234567".to_owned(), + )], + format_version: "0.2".to_owned(), + ir_version: "0.1".to_owned(), + tool_version: "0.1.0".to_owned(), + normative_roots: Vec::new(), + }; + let directory = tempfile::tempdir().unwrap(); + let first_path = directory.path().join("first.sqlite"); + let second_path = directory.path().join("second.sqlite"); + + let started = Instant::now(); + let first = build_index(&graph, &first_path, &provenance).unwrap(); + let first_seconds = started.elapsed().as_secs_f64(); + let started = Instant::now(); + let second = build_index(&graph, &second_path, &provenance).unwrap(); + let second_seconds = started.elapsed().as_secs_f64(); + + assert_eq!(first.node_count, generate::NODE_COUNT); + assert_eq!(first.edge_count, generate::EDGE_COUNT); + assert_eq!(first.semantic_hash, second.semantic_hash); + assert_eq!(file_hash(&first_path), file_hash(&second_path)); + assert!( + first_seconds < 10.0 && second_seconds < 10.0, + "scale builds took {first_seconds:.3}s and {second_seconds:.3}s" + ); + println!( + "NDF_SCALE first_seconds={first_seconds:.6} second_seconds={second_seconds:.6} bytes={}", + first_path.metadata().unwrap().len() + ); +} + +fn file_hash(path: &Path) -> [u8; 32] { + let mut reader = BufReader::new(File::open(path).unwrap()); + let mut hash = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = reader.read(&mut buffer).unwrap(); + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + hash.finalize().into() +} diff --git a/crates/ndf-compiler/tests/workflows.rs b/crates/ndf-compiler/tests/workflows.rs new file mode 100644 index 0000000..d625991 --- /dev/null +++ b/crates/ndf-compiler/tests/workflows.rs @@ -0,0 +1,192 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use ndf_compiler::diff::semantic_diff; +use ndf_compiler::export::export_graph; +use ndf_compiler::index::{BuildProvenance, NdfIndex, build_index}; +use ndf_core::identity::NodeId; +use ndf_core::model::{Edge, Graph, LifecycleStatus, Node, NodeKind, SourceSpan}; + +fn provenance() -> BuildProvenance { + BuildProvenance { + project_commits: vec![("fixture".to_owned(), "0".repeat(40))], + format_version: "0.2".to_owned(), + ir_version: "0.1".to_owned(), + tool_version: "0.1.0".to_owned(), + normative_roots: Vec::new(), + } +} + +fn node(local_id: &str, title: &str, path: &str, status: LifecycleStatus) -> Node { + Node { + id: NodeId::new("fixture", local_id).unwrap(), + kind: NodeKind::Architecture, + title: title.to_owned(), + source: SourceSpan::new(path, 10).unwrap(), + modality: None, + refinement: None, + domain: None, + status, + owner: Some("core".to_owned()), + body: format!("Body for {local_id}"), + attributes: BTreeMap::new(), + } +} + +#[test] +fn semantic_diff_classifies_node_and_edge_changes() { + let directory = tempfile::tempdir().unwrap(); + let before_path = directory.path().join("before.sqlite"); + let after_path = directory.path().join("after.sqlite"); + let before_nodes = vec![ + node("MOVED", "Moved", "docs/old.md", LifecycleStatus::Active), + node( + "MODIFIED", + "Before", + "docs/design.md", + LifecycleStatus::Active, + ), + node( + "REMOVED", + "Removed", + "docs/design.md", + LifecycleStatus::Active, + ), + node( + "SUPERSEDED", + "Superseded", + "docs/design.md", + LifecycleStatus::Active, + ), + node( + "EDGE-A", + "Edge A", + "docs/design.md", + LifecycleStatus::Active, + ), + node( + "EDGE-B", + "Edge B", + "docs/design.md", + LifecycleStatus::Active, + ), + ]; + let after_nodes = vec![ + node("MOVED", "Moved", "docs/new.md", LifecycleStatus::Active), + node( + "MODIFIED", + "After", + "docs/design.md", + LifecycleStatus::Active, + ), + node("ADDED", "Added", "docs/design.md", LifecycleStatus::Active), + node( + "SUPERSEDED", + "Superseded", + "docs/design.md", + LifecycleStatus::Superseded, + ), + node( + "EDGE-A", + "Edge A", + "docs/design.md", + LifecycleStatus::Active, + ), + node( + "EDGE-B", + "Edge B", + "docs/design.md", + LifecycleStatus::Active, + ), + ]; + let edge = Edge::new( + NodeId::new("fixture", "EDGE-A").unwrap(), + NodeId::new("fixture", "EDGE-B").unwrap(), + "depends-on", + None, + ) + .unwrap(); + build_index( + &Graph { + nodes: before_nodes, + ..Graph::default() + }, + &before_path, + &provenance(), + ) + .unwrap(); + build_index( + &Graph { + nodes: after_nodes, + edges: vec![edge], + diagnostics: Vec::new(), + }, + &after_path, + &provenance(), + ) + .unwrap(); + + let before = NdfIndex::open(&before_path).unwrap(); + let after = NdfIndex::open(&after_path).unwrap(); + let result = semantic_diff(&before, &after).unwrap(); + + assert_eq!(result.added, ["ndf://fixture/ADDED"]); + assert_eq!(result.removed, ["ndf://fixture/REMOVED"]); + assert_eq!(result.modified, ["ndf://fixture/MODIFIED"]); + assert_eq!(result.moved, ["ndf://fixture/MOVED"]); + assert_eq!(result.superseded, ["ndf://fixture/SUPERSEDED"]); + assert_eq!( + result.edge_only, + ["ndf://fixture/EDGE-A", "ndf://fixture/EDGE-B"] + ); + assert_eq!( + result.edges_added, + [[ + "ndf://fixture/EDGE-A".to_owned(), + "depends-on".to_owned(), + "ndf://fixture/EDGE-B".to_owned(), + ]] + ); +} + +#[test] +fn export_is_deterministic_portable_and_carries_provenance() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let index_path = root.join("graph.sqlite"); + let first_path = root.join("first.json"); + let second_path = root.join("second.json"); + let graph = Graph { + nodes: vec![node( + "NODE-001", + "Node", + &root.join("docs/node.md").to_string_lossy(), + LifecycleStatus::Active, + )], + ..Graph::default() + }; + build_index(&graph, &index_path, &provenance()).unwrap(); + let index = NdfIndex::open(&index_path).unwrap(); + + let first = export_graph(&index, &first_path).unwrap(); + let second = export_graph(&index, &second_path).unwrap(); + let payload: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&first_path).unwrap()).unwrap(); + + assert_eq!( + fs::read(&first_path).unwrap(), + fs::read(&second_path).unwrap() + ); + assert_eq!(first.semantic_hash, second.semantic_hash); + assert_eq!(payload["provenance"]["format_version"], "0.2"); + assert_eq!(payload["nodes"][0]["id"], "ndf://fixture/NODE-001"); + assert!( + !fs::read_to_string(&first_path) + .unwrap() + .contains(root.to_string_lossy().as_ref()) + ); + assert_eq!(first.output, Path::new(&first_path)); + assert_eq!(first.node_count, 1); + assert_eq!(first.edge_count, 0); +} diff --git a/crates/ndf-core/Cargo.toml b/crates/ndf-core/Cargo.toml new file mode 100644 index 0000000..7221ca4 --- /dev/null +++ b/crates/ndf-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ndf-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/ndf-core/src/graph.rs b/crates/ndf-core/src/graph.rs new file mode 100644 index 0000000..bc85f63 --- /dev/null +++ b/crates/ndf-core/src/graph.rs @@ -0,0 +1,68 @@ +use std::collections::HashMap; + +use crate::identity::NodeId; +use crate::model::{Diagnostic, DiagnosticSeverity, Edge, Graph, Node}; + +#[derive(Debug, Default)] +pub struct GraphBuilder { + nodes: HashMap, + edges: Vec, + diagnostics: Vec, +} + +impl GraphBuilder { + pub fn add_node(&mut self, node: Node) { + if let Some(existing) = self.nodes.get(&node.id) { + self.diagnostics.push(Diagnostic { + code: "NDF-ID-001".to_owned(), + severity: DiagnosticSeverity::Error, + message: format!("duplicate canonical NDF ID: {}", node.id), + primary: node.source, + related: vec![existing.source.clone()], + repair: Some( + "Assign a unique explicit ID or remove the duplicate clause.".to_owned(), + ), + subject: None, + }); + return; + } + self.nodes.insert(node.id.clone(), node); + } + + pub fn add_edge(&mut self, edge: Edge) { + self.edges.push(edge); + } + + pub fn add_diagnostic(&mut self, diagnostic: Diagnostic) { + self.diagnostics.push(diagnostic); + } + + pub fn finish(self) -> Graph { + let mut nodes: Vec<_> = self.nodes.into_values().collect(); + nodes.sort_by(|left, right| left.id.cmp(&right.id)); + + let mut edges = self.edges; + edges.sort_by(|left, right| { + (&left.source, &left.edge_type, &left.target).cmp(&( + &right.source, + &right.edge_type, + &right.target, + )) + }); + + let mut diagnostics = self.diagnostics; + diagnostics.sort_by(|left, right| { + (&left.primary.path, left.primary.line, &left.code).cmp(&( + &right.primary.path, + right.primary.line, + &right.code, + )) + }); + + Graph { + nodes, + edges, + diagnostics, + } + } +} diff --git a/crates/ndf-core/src/identity.rs b/crates/ndf-core/src/identity.rs new file mode 100644 index 0000000..8862e2f --- /dev/null +++ b/crates/ndf-core/src/identity.rs @@ -0,0 +1,122 @@ +use std::fmt::{Display, Formatter}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use thiserror::Error; + +#[derive(Debug, Clone, Eq, Error, PartialEq)] +pub enum IdentityError { + #[error("invalid project ID: '{0}'")] + InvalidProject(String), + #[error("invalid local ID: '{0}'")] + InvalidLocal(String), + #[error("current project is required for a local NDF ID")] + MissingCurrentProject, + #[error("invalid NDF URI: '{0}'")] + InvalidUri(String), +} + +#[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct NodeId { + project_id: String, + local_id: String, +} + +impl NodeId { + pub fn new( + project_id: impl Into, + local_id: impl Into, + ) -> Result { + let project_id = project_id.into(); + let local_id = local_id.into(); + validate_project_id(&project_id)?; + validate_local_id(&local_id)?; + Ok(Self { + project_id, + local_id, + }) + } + + pub fn parse(value: &str, current_project: Option<&str>) -> Result { + let Some(rest) = value.strip_prefix("ndf://") else { + let project = current_project.ok_or(IdentityError::MissingCurrentProject)?; + return Self::new(project, value); + }; + if value.contains('?') || value.contains('#') { + return Err(IdentityError::InvalidUri(value.to_owned())); + } + let Some((project, local_id)) = rest.split_once('/') else { + return Err(IdentityError::InvalidUri(value.to_owned())); + }; + if project.is_empty() || local_id.is_empty() || local_id.contains('/') { + return Err(IdentityError::InvalidUri(value.to_owned())); + } + Self::new(project, local_id) + } + + pub fn project_id(&self) -> &str { + &self.project_id + } + + pub fn local_id(&self) -> &str { + &self.local_id + } + + pub fn uri(&self) -> String { + format!("ndf://{}/{}", self.project_id, self.local_id) + } +} + +impl Display for NodeId { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.uri()) + } +} + +impl Serialize for NodeId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.uri()) + } +} + +impl<'de> Deserialize<'de> for NodeId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value, None).map_err(serde::de::Error::custom) + } +} + +fn validate_project_id(value: &str) -> Result<(), IdentityError> { + let mut characters = value.chars(); + if !characters + .next() + .is_some_and(|character| character.is_ascii_lowercase() || character.is_ascii_digit()) + || !characters.all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || matches!(character, '.' | '_' | '-') + }) + { + return Err(IdentityError::InvalidProject(value.to_owned())); + } + Ok(()) +} + +fn validate_local_id(value: &str) -> Result<(), IdentityError> { + let mut characters = value.chars(); + if !characters + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + || !characters.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | ':' | '-') + }) + { + return Err(IdentityError::InvalidLocal(value.to_owned())); + } + Ok(()) +} diff --git a/crates/ndf-core/src/lib.rs b/crates/ndf-core/src/lib.rs new file mode 100644 index 0000000..2c242d9 --- /dev/null +++ b/crates/ndf-core/src/lib.rs @@ -0,0 +1,4 @@ +pub mod graph; +pub mod identity; +pub mod model; +pub mod version; diff --git a/crates/ndf-core/src/model.rs b/crates/ndf-core/src/model.rs new file mode 100644 index 0000000..7a64f08 --- /dev/null +++ b/crates/ndf-core/src/model.rs @@ -0,0 +1,197 @@ +use std::collections::BTreeMap; + +use serde::ser::SerializeStruct; +use serde::{Serialize, Serializer}; +use thiserror::Error; + +use crate::identity::NodeId; + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum NodeKind { + Document, + Requirement, + Definition, + Architecture, + Constraint, + Option, + Verification, + Decision, + Question, + Model, + Source, + Test, + Evidence, + Information, +} + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Modality { + Must, + Should, + May, + Tbd, +} + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum LifecycleStatus { + Draft, + Active, + Deprecated, + Superseded, + Retired, + Open, + Resolved, +} + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DiagnosticSeverity { + Information, + Warning, + Error, +} + +#[derive(Debug, Clone, Eq, Error, PartialEq)] +pub enum ModelError { + #[error("line must be positive")] + InvalidLine, + #[error("column must be positive")] + InvalidColumn, + #[error("end line must not precede start line")] + InvalidEndLine, + #[error("end column must be positive")] + InvalidEndColumn, + #[error("edge type must not be empty")] + EmptyEdgeType, +} + +#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct SourceSpan { + pub path: String, + pub line: u32, + pub column: u32, + pub end_line: Option, + pub end_column: Option, +} + +impl SourceSpan { + pub fn new(path: impl Into, line: u32) -> Result { + Self::with_bounds(path, line, 1, None, None) + } + + pub fn with_bounds( + path: impl Into, + line: u32, + column: u32, + end_line: Option, + end_column: Option, + ) -> Result { + if line == 0 { + return Err(ModelError::InvalidLine); + } + if column == 0 { + return Err(ModelError::InvalidColumn); + } + if end_line.is_some_and(|end| end < line) { + return Err(ModelError::InvalidEndLine); + } + if end_column == Some(0) { + return Err(ModelError::InvalidEndColumn); + } + Ok(Self { + path: path.into(), + line, + column, + end_line, + end_column, + }) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct Node { + pub id: NodeId, + pub kind: NodeKind, + pub title: String, + pub source: SourceSpan, + pub modality: Option, + pub refinement: Option, + pub domain: Option, + pub status: LifecycleStatus, + pub owner: Option, + pub body: String, + pub attributes: BTreeMap, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct Edge { + pub source: NodeId, + pub target: NodeId, + #[serde(rename = "type")] + pub edge_type: String, + pub source_span: Option, + pub attributes: BTreeMap, +} + +impl Edge { + pub fn new( + source: NodeId, + target: NodeId, + edge_type: impl Into, + source_span: Option, + ) -> Result { + let edge_type = edge_type.into(); + if edge_type.trim().is_empty() { + return Err(ModelError::EmptyEdgeType); + } + Ok(Self { + source, + target, + edge_type, + source_span, + attributes: BTreeMap::new(), + }) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct Diagnostic { + pub code: String, + pub severity: DiagnosticSeverity, + pub message: String, + pub primary: SourceSpan, + pub related: Vec, + pub repair: Option, + pub subject: Option, +} + +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub struct Graph { + pub nodes: Vec, + pub edges: Vec, + pub diagnostics: Vec, +} + +impl Serialize for Graph { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut state = serializer.serialize_struct("Graph", 4)?; + state.serialize_field("schema_version", "0.1")?; + state.serialize_field("nodes", &self.nodes)?; + state.serialize_field("edges", &self.edges)?; + state.serialize_field("diagnostics", &self.diagnostics)?; + state.end() + } +} + +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub struct ParseResult { + pub nodes: Vec, + pub edges: Vec, + pub diagnostics: Vec, +} diff --git a/crates/ndf-core/src/version.rs b/crates/ndf-core/src/version.rs new file mode 100644 index 0000000..b1ffba2 --- /dev/null +++ b/crates/ndf-core/src/version.rs @@ -0,0 +1,5 @@ +pub const TOOL_VERSION: &str = "0.1.0"; +pub const FORMAT_VERSION: &str = "0.2"; +pub const IR_VERSION: &str = "0.1"; +pub const CLI_STABILITY: &str = "experimental"; +pub const PLUGIN_API: &str = "experimental"; diff --git a/crates/ndf-core/tests/graph.rs b/crates/ndf-core/tests/graph.rs new file mode 100644 index 0000000..7516f80 --- /dev/null +++ b/crates/ndf-core/tests/graph.rs @@ -0,0 +1,72 @@ +use std::collections::BTreeMap; + +use ndf_core::graph::GraphBuilder; +use ndf_core::identity::NodeId; +use ndf_core::model::{DiagnosticSeverity, Edge, LifecycleStatus, Node, NodeKind, SourceSpan}; +use serde_json::json; + +fn node(local_id: &str, line: u32) -> Node { + Node { + id: NodeId::new("fixture", local_id).unwrap(), + kind: NodeKind::Requirement, + title: local_id.to_owned(), + source: SourceSpan::new("docs/design.md", line).unwrap(), + modality: None, + refinement: Some("L1".to_owned()), + domain: Some("core".to_owned()), + status: LifecycleStatus::Active, + owner: Some("core".to_owned()), + body: "The core MUST preserve behavior.".to_owned(), + attributes: BTreeMap::new(), + } +} + +#[test] +fn duplicate_id_keeps_the_first_node_and_emits_ndf_id_001() { + let mut builder = GraphBuilder::default(); + builder.add_node(node("REQ-001", 4)); + builder.add_node(node("REQ-001", 9)); + + let graph = builder.finish(); + + assert_eq!(graph.nodes.len(), 1); + assert_eq!(graph.nodes[0].source.line, 4); + assert_eq!(graph.diagnostics.len(), 1); + assert_eq!(graph.diagnostics[0].code, "NDF-ID-001"); + assert_eq!(graph.diagnostics[0].severity, DiagnosticSeverity::Error); + assert_eq!(graph.diagnostics[0].primary.line, 9); + assert_eq!(graph.diagnostics[0].related[0].line, 4); +} + +#[test] +fn finish_sorts_nodes_and_serializes_explicit_null_fields() { + let mut builder = GraphBuilder::default(); + builder.add_node(node("REQ-020", 20)); + builder.add_node(node("REQ-010", 10)); + + let graph = builder.finish(); + let value = serde_json::to_value(&graph).unwrap(); + + assert_eq!(graph.nodes[0].id.local_id(), "REQ-010"); + assert_eq!(value["schema_version"], "0.1"); + assert_eq!(value["nodes"][0]["modality"], json!(null)); + assert_eq!(value["nodes"][0]["source"]["end_line"], json!(null)); + assert_eq!(value["nodes"][0]["attributes"], json!({})); +} + +#[test] +fn source_span_rejects_line_zero() { + let error = SourceSpan::new("docs/design.md", 0).unwrap_err(); + + assert_eq!(error.to_string(), "line must be positive"); +} + +#[test] +fn edge_rejects_an_empty_type() { + let source = NodeId::new("fixture", "REQ-001").unwrap(); + let target = NodeId::new("fixture", "REQ-002").unwrap(); + + let error = Edge::new(source, target, "", None).unwrap_err(); + + assert_eq!(error.to_string(), "edge type must not be empty"); +} diff --git a/crates/ndf-core/tests/identity.rs b/crates/ndf-core/tests/identity.rs new file mode 100644 index 0000000..4d0ab36 --- /dev/null +++ b/crates/ndf-core/tests/identity.rs @@ -0,0 +1,61 @@ +use ndf_core::identity::NodeId; +use serde_json::Value; + +#[test] +fn identity_behavior_matches_the_frozen_contract() { + let expected: Value = serde_json::from_str(include_str!("../../../tests/golden/identity.json")) + .expect("identity golden JSON must parse"); + + let local = NodeId::parse("VEC-ISSUE-001", Some("davincioo")) + .expect("local ID must resolve inside a project"); + let canonical = NodeId::parse("ndf://pto-spec/PTO-INST-TLOAD", None) + .expect("canonical URI must parse without a current project"); + + assert_eq!(local.to_string(), expected["local"].as_str().unwrap()); + assert_eq!( + canonical.project_id(), + expected["canonical"]["project_id"].as_str().unwrap() + ); + assert_eq!( + canonical.local_id(), + expected["canonical"]["local_id"].as_str().unwrap() + ); + assert_eq!( + canonical.to_string(), + expected["canonical"]["uri"].as_str().unwrap() + ); + assert_eq!( + NodeId::parse("VEC-ISSUE-001", None) + .expect_err("local ID without project must fail") + .to_string(), + expected["errors"]["missing_project"].as_str().unwrap(), + ); + assert_eq!( + NodeId::new("DavinciOO", "PIPE-001") + .expect_err("uppercase project must fail") + .to_string(), + expected["errors"]["invalid_project"].as_str().unwrap(), + ); + assert_eq!( + NodeId::parse("ndf://pto-spec/path/with/slash", None) + .expect_err("URI with nested path must fail") + .to_string(), + expected["errors"]["invalid_uri"].as_str().unwrap(), + ); + assert_eq!( + NodeId::parse("ndf://DavinciOO/PIPE-001", None) + .expect_err("canonical URI must retain project validation") + .to_string(), + "invalid project ID: 'DavinciOO'", + ); +} + +#[test] +fn node_id_serializes_as_its_canonical_uri() { + let id = NodeId::new("davincioo", "PIPE-001").unwrap(); + + assert_eq!( + serde_json::to_value(id).unwrap(), + "ndf://davincioo/PIPE-001" + ); +} diff --git a/crates/ndf-core/tests/spec_sync.rs b/crates/ndf-core/tests/spec_sync.rs new file mode 100644 index 0000000..8ea76f2 --- /dev/null +++ b/crates/ndf-core/tests/spec_sync.rs @@ -0,0 +1,55 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +fn repository() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +#[test] +fn english_and_chinese_publish_the_same_machine_contract() { + let root = repository(); + let english = contract(&root.join("normative_language.md")); + let chinese = contract(&root.join("normative_language_cn.md")); + assert_eq!(english, chinese); + assert_eq!( + english, + r#"format_version: "0.2" +ir_version: "0.1" +machine_output_schema: "0.1" +derived_index: sqlite-v1 +identity_scheme: ndf +canonical_fields: + - kind + - modality + - refinement + - domain + - status +required_files: + - ndf.yaml + - ndf.lock +agent_gate: + - build + - check + - diff + - coverage +maturity: + format: experimental + ir: experimental + cli: experimental + plugin_api: experimental"# + ); +} + +fn contract(path: &Path) -> String { + let text = fs::read_to_string(path).unwrap(); + let start_marker = ""; + let end_marker = ""; + let start = text.find(start_marker).unwrap() + start_marker.len(); + let end = text[start..].find(end_marker).unwrap() + start; + let block = text[start..end].trim(); + block + .strip_prefix("```yaml\n") + .and_then(|value| value.strip_suffix("\n```")) + .expect("format contract must be one YAML code block") + .to_owned() +} diff --git a/docs/design/2026-08-10-ndf-knowledge-compiler-cross-repo-design.md b/docs/design/2026-08-10-ndf-knowledge-compiler-cross-repo-design.md index af54621..12629b0 100644 --- a/docs/design/2026-08-10-ndf-knowledge-compiler-cross-repo-design.md +++ b/docs/design/2026-08-10-ndf-knowledge-compiler-cross-repo-design.md @@ -111,8 +111,11 @@ the compatibility boundary between authoring formats and query features. | `ndf-query` | Node lookup, graph traversal, coverage, status, and semantic diff | | `ndf-cli` | Human and agent command surface with JSON output | -These may initially be Python modules in one package. They are boundaries, not -a requirement to publish separate packages. +The compiler is implemented in Rust. The initial Cargo workspace groups these +boundaries into `ndf-core`, `ndf-compiler`, and `ndf-cli` rather than creating a +crate for every row in the table. The detailed migration and release contract is +defined in +[`2026-08-10-ndf-rust-compiler-migration-design.md`](../superpowers/specs/2026-08-10-ndf-rust-compiler-migration-design.md). ## 6. Canonical Data Model @@ -321,6 +324,9 @@ It must include small, redistributed fixtures representing both PTO ASL NDF and DavinciOO Markdown NDF. Full external repositories remain end-to-end consumers, not vendored test data. +The supported compiler is a standalone Rust binary. The merged Python prototype +is a migration oracle only and is removed before the first `v0.1.0` release. + ### 10.2 `PTO-ISA/pto-spec` Owns: diff --git a/docs/format/migration-v0.1-to-v0.2.md b/docs/format/migration-v0.1-to-v0.2.md index 0b735ce..2259e4a 100644 --- a/docs/format/migration-v0.1-to-v0.2.md +++ b/docs/format/migration-v0.1-to-v0.2.md @@ -57,10 +57,15 @@ URI such as `ndf://pto-spec/PTO-TILE-CAPACITY` across projects. Run: ```bash -ndf build --root . --output .ndf-cache/graph.sqlite --format json -ndf check --root . --format json +cargo build --locked --release -p ndf-cli +target/release/ndf build --root . --output .ndf-cache/graph.sqlite --format json +target/release/ndf check --root . --format json ``` +When NDF is checked out as a submodule, use its `scripts/ndf` wrapper or invoke +the built binary through a subprocess and parse only the versioned JSON +envelope. Do not install or import a Python NDF package. + Do not commit the SQLite database. Commit authored changes, manifests, locks, and reviewed evidence instead. diff --git a/docs/performance/reference-machine.md b/docs/performance/reference-machine.md new file mode 100644 index 0000000..e257a09 --- /dev/null +++ b/docs/performance/reference-machine.md @@ -0,0 +1,27 @@ +# NDF Release Performance Reference Machine + +The NDF `v0.1.0` release-scale contract uses the following reference system: + +- Apple M5 (`arm64`) +- 32 GiB physical memory +- macOS 26.5.2 (Darwin 25.5.0) +- Rust 1.94.0, release profile, locked dependencies + +The workload materializes the same deterministic graph twice. Each build contains +100,000 nodes and 1,000,000 edges. Both builds must complete in less than 10 +seconds, produce byte-identical SQLite databases and semantic hashes, and keep +peak resident memory below 536,870,912 bytes. + +The 2026-08-10 release-candidate measurement completed the two builds in 1.459 +seconds and 1.457 seconds. Peak resident memory was 326,434,816 bytes, and the +materialized SQLite index was 239,931,392 bytes. + +Run the complete measurement with: + +```bash +scripts/check-release-performance +``` + +The script uses `/usr/bin/time -l` on macOS and `/usr/bin/time -v` on Linux. CI +keeps correctness and determinism as hard gates; this reference-machine check is +the release gate because shared CI timing is noisy. diff --git a/docs/superpowers/plans/2026-08-10-ndf-rust-compiler-migration.md b/docs/superpowers/plans/2026-08-10-ndf-rust-compiler-migration.md new file mode 100644 index 0000000..46ba177 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-ndf-rust-compiler-migration.md @@ -0,0 +1,613 @@ +# NDF Rust Compiler Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the merged NDF Python prototype with a standalone Rust compiler that preserves the accepted format, IR, diagnostics, SQLite, and CLI contracts before the first NDF release. + +**Architecture:** A three-crate Cargo workspace separates canonical graph contracts (`ndf-core`), compiler and database behavior (`ndf-compiler`), and the process interface (`ndf-cli`). Python remains executable only while language-neutral golden fixtures and cross-language parity tests are built; it is deleted after Rust satisfies correctness, determinism, and scale gates. + +**Tech Stack:** Rust 1.94.0, Cargo edition 2024, Clap 4.6.6, Serde 1.0.229, serde_json 1.0.151, serde_yaml_ng 0.10.0, Rusqlite 0.40.2 with bundled SQLite, SHA-2 0.11.0, thiserror 2.0.20, pulldown-cmark 0.13.4, globset 0.4.20, walkdir 2.5.0, tempfile 3.27.0, assert_cmd 2.2.2, predicates 3.1.4, GitHub Actions. + +## Global Constraints + +- Preserve NDF format `0.2`, IR `0.1`, machine-output schema `0.1`, SQLite schema `1`, and current diagnostic codes. +- The first `v0.1.0` tag points to the merged Rust release commit; do not tag or pin the Python prototype. +- Markdown, ASL, source, test, and evidence files remain normative inputs; SQLite remains disposable derived state. +- PTO-SPEC ASL remains authoritative for PTO architecture; DavinciOO NDF clauses remain authoritative for DavinciOO microarchitecture. +- Provide one standalone `ndf` binary. Downstream repositories must not install or import the NDF Python package. +- Commit `Cargo.lock` and pin Rust `1.94.0` in `rust-toolchain.toml`. +- Preserve command names, arguments, exit meanings, JSON envelopes, field names, ordering, source spans, and semantic hashes unless an explicit contract test approves a change. +- Use map/set graph construction, one SQLite transaction with prepared batches, SQL query filtering, and streaming JSON export. +- Do not add PyO3, supported Python bindings, writable database APIs, runtime-loaded Rust dynamic libraries, or a background service. +- Execute inline in the existing isolated worktree without subagents. +- Observe every Rust production test fail before implementing the behavior. + +## File Map + +```text +Cargo.toml # workspace and shared dependency versions +Cargo.lock # exact dependency graph +rust-toolchain.toml # Rust 1.94.0 with rustfmt and clippy +crates/ndf-core/src/ # identity, model, graph builder, diagnostics, versions +crates/ndf-compiler/src/ # manifests, parsers, rules, adapters, SQLite, queries +crates/ndf-cli/src/ # Clap commands, JSON envelopes, exit behavior +schemas/ # existing language-neutral JSON Schemas +sql/schema-v1.sql # language-neutral SQLite schema +tests/fixtures/ # shared source fixtures +tests/golden/ # normalized Python oracle results +tests/oracle/ # temporary migration-only Python harness +tests/scale/ # deterministic scale workload +``` + +--- + +### Task 1: Freeze Python behavior into language-neutral golden files + +**Files:** +- Create: `tests/oracle/generate_golden.py` +- Create: `tests/oracle/normalize.py` +- Create: `tests/test_oracle_golden.py` +- Create: `tests/golden/{machine-version,identity,markdown-valid,markdown-invalid,pto-asl,index,query-report}.json` + +**Interfaces:** +- Consumes: merged `src/ndf` and current `tests/fixtures`. +- Produces: `python3 tests/oracle/generate_golden.py --check`. +- Produces: sorted UTF-8 JSON with repository-relative POSIX paths, fixed revision `1111111111111111111111111111111111111111`, and one trailing newline. + +- [ ] **Step 1: Write the failing consistency test** + +```python +class OracleGoldenTest(unittest.TestCase): + def test_committed_golden_matches_python(self) -> None: + result = subprocess.run( + [sys.executable, "tests/oracle/generate_golden.py", "--check"], + cwd=Path(__file__).parents[1], text=True, capture_output=True, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) +``` + +- [ ] **Step 2: Verify RED** + +Run: `python3 -m unittest tests.test_oracle_golden -v` +Expected: FAIL because the generator is absent. + +- [ ] **Step 3: Implement normalization and generation** + +```python +def normalized_json(value: object) -> str: + return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n" +``` + +Generate golden output through public Python functions and `ndf.cli.main`. In +check mode, compare without writing and print every mismatched filename. + +- [ ] **Step 4: Verify GREEN and commit** + +Run: `python3 tests/oracle/generate_golden.py && python3 -m unittest tests.test_oracle_golden -v` +Expected: PASS. + +```bash +git add tests/oracle tests/golden tests/test_oracle_golden.py +git commit -m "test: freeze the Python NDF behavior" +``` + +--- + +### Task 2: Scaffold the Cargo workspace and version command + +**Files:** +- Create: `Cargo.toml`, `Cargo.lock`, `rust-toolchain.toml` +- Create: `crates/ndf-core/{Cargo.toml,src/lib.rs,src/version.rs}` +- Create: `crates/ndf-compiler/{Cargo.toml,src/lib.rs}` +- Create: `crates/ndf-cli/{Cargo.toml,src/main.rs,tests/machine_version.rs}` +- Modify: `.github/workflows/ci.yml`, `.gitignore` + +**Interfaces:** +- Produces: binary `ndf` and `ndf_core::version` constants. +- Produces: `ndf --machine-version` equal to `tests/golden/machine-version.json`. + +- [ ] **Step 1: Write the failing binary test** + +```rust +#[test] +fn machine_version_matches_the_oracle() { + let expected: serde_json::Value = serde_json::from_str(include_str!( + "../../../tests/golden/machine-version.json" + )).unwrap(); + let output = assert_cmd::Command::new(assert_cmd::cargo::cargo_bin!("ndf")) + .arg("--machine-version").output().unwrap(); + assert!(output.status.success()); + assert_eq!(serde_json::from_slice::(&output.stdout).unwrap(), expected); +} +``` + +- [ ] **Step 2: Verify RED** + +Run: `cargo test -p ndf-cli --test machine_version` +Expected: FAIL because the workspace is absent. + +- [ ] **Step 3: Implement the minimal workspace** + +Use resolver `2`, edition `2024`, Rust `1.94`, and the exact versions in this +plan header. Enable `clap/derive`, `serde/derive`, and `rusqlite/bundled`. +Implement only the five version constants and `--machine-version`. + +- [ ] **Step 4: Add Rust CI and verify GREEN** + +Run: `cargo fmt --all -- --check && cargo clippy --workspace --all-targets --all-features -- -D warnings && cargo test --workspace --locked && python3 -m unittest tests.test_oracle_golden -v` +Expected: PASS. Keep Python CI until parity is complete. + +- [ ] **Step 5: Commit** + +```bash +git add Cargo.toml Cargo.lock rust-toolchain.toml crates .github/workflows/ci.yml .gitignore +git commit -m "feat: scaffold the Rust NDF compiler" +``` + +--- + +### Task 3: Implement canonical identities and constant-time graph building + +**Files:** +- Create: `crates/ndf-core/src/{identity,model,graph}.rs` +- Create: `crates/ndf-core/tests/{identity,graph}.rs` +- Modify: `crates/ndf-core/src/lib.rs` + +**Interfaces:** +- Produces: `NodeId::parse(value, current_project)`, `Display`, and `uri()`. +- Produces: Serde types `SourceSpan`, `Node`, `Edge`, `Diagnostic`, `ParseResult`, and `Graph`. +- Produces: `GraphBuilder::{add_node,add_edge,add_diagnostic,finish}` using `HashMap`. + +- [ ] **Step 1: Write failing identity and duplicate tests** + +```rust +#[test] +fn duplicate_id_keeps_the_first_node_and_emits_ndf_id_001() { + let mut builder = GraphBuilder::default(); + builder.add_node(node("REQ-001", 4)); + builder.add_node(node("REQ-001", 9)); + let graph = builder.finish(); + assert_eq!(graph.nodes.len(), 1); + assert_eq!(graph.diagnostics[0].code, "NDF-ID-001"); +} +``` + +- [ ] **Step 2: Verify RED** + +Run: `cargo test -p ndf-core --test identity --test graph` +Expected: FAIL because the types are absent. + +- [ ] **Step 3: Implement and sort only at freeze boundaries** + +Validate project/local components, preserve explicit null JSON fields, and sort +nodes by URI, edges by `(source,type,target)`, and diagnostics by +`(path,line,code)` in `finish()`. + +- [ ] **Step 4: Verify GREEN and commit** + +Run: `cargo test -p ndf-core --locked && cargo clippy -p ndf-core --all-targets -- -D warnings` +Expected: PASS, including comparison with `tests/golden/identity.json`. + +```bash +git add crates/ndf-core +git commit -m "feat: define the Rust NDF graph model" +``` + +--- + +### Task 4: Port manifests, locks, and Markdown parsing + +**Files:** +- Create: `crates/ndf-compiler/src/{manifest,markdown}.rs` +- Create: `crates/ndf-compiler/tests/{manifest,markdown}.rs` +- Modify: `crates/ndf-compiler/src/lib.rs` + +**Interfaces:** +- Produces: `ProjectManifest::load`, `ProjectLock::load`, and `parse_markdown`. +- Preserves: front matter, heading IDs, NDF comments, fenced-code exclusion, + clause bodies, edges, source spans, and `NDF-MIG-001`. + +- [ ] **Step 1: Write failing golden tests** + +```rust +#[test] +fn valid_markdown_matches_the_oracle() { + let manifest = ProjectManifest::load(&fixture("markdown/project/ndf.yaml")).unwrap(); + let parsed = parse_markdown(&fixture("markdown/project/docs/pipeline.md"), &manifest).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), golden("markdown-valid.json")); +} +``` + +- [ ] **Step 2: Verify RED** + +Run: `cargo test -p ndf-compiler --test manifest --test markdown` +Expected: FAIL because the parser is absent. + +- [ ] **Step 3: Implement strict parsing** + +Use Serde YAML structs with unknown fields denied and 40-hex lock validation. +Use pulldown-cmark offset events for headings/fences and source slices for the +immediately following metadata comment. Map only the four documented legacy +fields and emit `NDF-MIG-001`; do not infer ambiguous mappings. + +- [ ] **Step 4: Verify GREEN and commit** + +Run: `cargo test -p ndf-compiler --test manifest --test markdown --locked` +Expected: PASS against valid and invalid golden JSON. + +```bash +git add crates/ndf-compiler +git commit -m "feat: parse NDF projects in Rust" +``` + +--- + +### Task 5: Port generic rules and no-regression baselines + +**Files:** +- Create: `crates/ndf-compiler/src/{rules,baseline}.rs` +- Create: `crates/ndf-compiler/tests/{rules,baseline}.rs` + +**Interfaces:** +- Produces: `validate_graph`, `diagnostic_fingerprint`, and `compare_baseline`. +- Preserves: dangling-edge, prefix, refinement, lifecycle, owner, and baseline diagnostics. + +- [ ] **Step 1: Write failing rule tests** + +```rust +#[test] +fn dangling_mandatory_edge_is_an_error() { + let diagnostics = validate_graph(&graph_with_missing_target(), &ValidationPolicy::default()); + assert_eq!(diagnostics[0].code, "NDF-REF-001"); + assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error); +} +``` + +- [ ] **Step 2: Verify RED** + +Run: `cargo test -p ndf-compiler --test rules --test baseline` +Expected: FAIL because rule APIs are absent. + +- [ ] **Step 3: Implement deterministic rules and fingerprints** + +Port one Python rule per focused test. Fingerprint the normalized tuple +`(code,subject,relative-path,line,message-class)` and exclude absolute roots. + +- [ ] **Step 4: Verify GREEN and commit** + +Run: `cargo test -p ndf-compiler --test rules --test baseline --locked` +Expected: PASS and ordered diagnostics equal `markdown-invalid.json`. + +```bash +git add crates/ndf-compiler +git commit -m "feat: enforce NDF rules in Rust" +``` + +--- + +### Task 6: Build deterministic SQLite indexes atomically + +**Files:** +- Move: `src/ndf/sql/schema-v1.sql` to `sql/schema-v1.sql` +- Create: `crates/ndf-compiler/src/index.rs` +- Create: `crates/ndf-compiler/tests/{index,index_determinism}.rs` + +**Interfaces:** +- Produces: `BuildProvenance`, `IndexBuild`, `NdfIndex::open`, and `build_index`. +- Preserves: schema `1`, foreign keys, provenance, semantic hashes, and semantic rows. + +- [ ] **Step 1: Write failing determinism and atomicity tests** + +```rust +#[test] +fn two_builds_have_equal_hashes_and_semantic_rows() { + let first = build_fixture_index("first.sqlite"); + let second = build_fixture_index("second.sqlite"); + assert_eq!(first.semantic_hash, second.semantic_hash); + assert_eq!(dump_semantic_rows(&first.output), dump_semantic_rows(&second.output)); +} +``` + +- [ ] **Step 2: Verify RED** + +Run: `cargo test -p ndf-compiler --test index --test index_determinism` +Expected: FAIL because the index API is absent. + +- [ ] **Step 3: Implement canonical hashing and batch insertion** + +Stream canonical JSON fragments into SHA-256. Build a temporary database beside +the destination, apply `sql/schema-v1.sql`, insert through prepared statements +inside one transaction, close it, then atomically persist it. Refuse output under +a normative root and preserve the prior index on failure. + +- [ ] **Step 4: Verify GREEN and commit** + +Run: `cargo test -p ndf-compiler --test index --test index_determinism --locked` +Expected: PASS and semantic output equal `tests/golden/index.json`. + +```bash +git add sql crates/ndf-compiler src/ndf/sql/schema-v1.sql +git commit -m "feat: materialize NDF indexes in Rust" +``` + +--- + +### Task 7: Port SQL-backed graph workflows + +**Files:** +- Create: `crates/ndf-compiler/src/{query,report,diff,export}.rs` +- Create: `crates/ndf-compiler/tests/{query,workflows}.rs` + +**Interfaces:** +- Produces: URI resolution, node lookup, typed in/out edges, trace, coverage, + semantic diff, and buffered NDF JSON export. + +- [ ] **Step 1: Write failing workflow parity test** + +```rust +#[test] +fn query_and_report_match_the_oracle() { + let index = fixture_index(); + let actual = json!({ + "show": index.get_node("ndf://davincioo/PIPE-ISSUE-001").unwrap(), + "trace": trace(&index, "PIPE-ISSUE-010", &["refines"]).unwrap(), + "coverage": coverage(&index, &CoveragePolicy::default()).unwrap(), + }); + assert_eq!(actual, golden("query-report.json")); +} +``` + +- [ ] **Step 2: Verify RED** + +Run: `cargo test -p ndf-compiler --test query --test workflows` +Expected: FAIL because workflow modules are absent. + +- [ ] **Step 3: Implement SQL filtering and streaming export** + +Filter URI, direction, type, status, kind, domain, and coverage candidates in +SQL. Preserve cycle-safe deterministic trace order. Stream ordered export records +through a buffered Serde serializer without building a second graph value. + +- [ ] **Step 4: Verify GREEN and commit** + +Run: `cargo test -p ndf-compiler --test query --test workflows --locked` +Expected: PASS. + +```bash +git add crates/ndf-compiler +git commit -m "feat: expose Rust NDF graph workflows" +``` + +--- + +### Task 8: Port PTO ASL, repository, evidence, and dependency adapters + +**Files:** +- Create: `crates/ndf-compiler/src/adapters/{mod,pto_asl,repository,evidence}.rs` +- Create: `crates/ndf-compiler/src/dependencies.rs` +- Create: `crates/ndf-compiler/tests/{pto_asl,repository_evidence,dependencies}.rs` + +**Interfaces:** +- Produces: internal `Adapter` trait with `parse(&self, path, manifest) -> Result` and a manifest-selected static registry. +- Produces: `parse_pto_asl`, `index_repository`, `load_evidence`, and `resolve_dependencies`. +- Requires: adapters inspect checked-out state but never clone, fetch, checkout, reset, or write repositories. + +- [ ] **Step 1: Write failing PTO and dependency tests** + +```rust +#[test] +fn pto_asl_matches_the_oracle() { + assert_eq!(serde_json::to_value(parse_pto_fixture()).unwrap(), golden("pto-asl.json")); +} +``` + +- [ ] **Step 2: Verify RED** + +Run: `cargo test -p ndf-compiler --test pto_asl --test repository_evidence --test dependencies` +Expected: FAIL because adapters are absent. + +- [ ] **Step 3: Port explicit mappings and pure resolution** + +Implement the internal `Adapter` trait and statically register the built-in +Markdown and PTO ASL adapters. Preserve ASL authority metadata and current clause identity/body/reference rules. +Derive source/test IDs from project, path, revision, and content identity. Validate +evidence against the existing schema. Permit only read-only Git inspection. + +- [ ] **Step 4: Verify GREEN and commit** + +Run: `cargo test -p ndf-compiler --test pto_asl --test repository_evidence --test dependencies --locked` +Expected: PASS. + +```bash +git add crates/ndf-compiler +git commit -m "feat: port NDF adapters to Rust" +``` + +--- + +### Task 9: Complete the Rust CLI, ID allocation, and safe fixes + +**Files:** +- Create: `crates/ndf-compiler/src/{id_allocator,fix}.rs` +- Create: `crates/ndf-cli/src/{args,envelope,run}.rs` +- Modify: `crates/ndf-cli/src/main.rs` +- Create: `crates/ndf-cli/tests/{commands,errors}.rs` + +**Interfaces:** +- Produces: `build`, `check`, `show`, `trace`, `report coverage`, + `report dependencies`, `diff`, `export`, `id allocate`, and `fix`. +- Produces: `{schema_version,command,ok,data,diagnostics}`; exit `0` for success, + `1` for source/policy failure, and `2` for invalid invocation. + +- [ ] **Step 1: Write one failing end-to-end test per command** + +```rust +#[test] +fn check_emits_the_versioned_envelope() { + let output = ndf().args(["check", "--root", fixture_root(), "--format", "json"]) + .output().unwrap(); + let value: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["schema_version"], "0.1"); + assert_eq!(value["command"], "check"); + assert_eq!(value["ok"], true); +} +``` + +- [ ] **Step 2: Verify RED** + +Run: `cargo test -p ndf-cli --test commands --test errors` +Expected: FAIL because only `--machine-version` exists. + +- [ ] **Step 3: Implement command dispatch and structured errors** + +Keep Clap types in `args.rs`, JSON construction in `envelope.rs`, and compiler +calls in `run.rs`. Allocate the lowest unused suffix. Safe fix may normalize +metadata or generated navigation but must reject semantic choices. + +- [ ] **Step 4: Verify GREEN and commit** + +Run: `cargo test -p ndf-cli --locked && cargo clippy -p ndf-cli --all-targets -- -D warnings` +Expected: PASS for all commands, unknown/ambiguous IDs, invalid invocation, and fix refusal. + +```bash +git add crates/ndf-cli crates/ndf-compiler +git commit -m "feat: complete the Rust NDF command surface" +``` + +--- + +### Task 10: Prove parity, determinism, and scale + +**Files:** +- Create: `tests/oracle/compare_rust.py`, `tests/test_rust_parity.py` +- Create: `tests/scale/generate.rs`, `crates/ndf-compiler/tests/scale.rs` +- Create: `scripts/check-release-performance` +- Create: `docs/performance/reference-machine.md` + +**Interfaces:** +- Produces: complete Python-versus-Rust golden parity. +- Produces: opt-in 100,000-node/1,000,000-edge release test. +- Enforces: elapsed time below 10 seconds and peak RSS below 536,870,912 bytes on the documented reference machine. + +- [ ] **Step 1: Write the failing parity test** + +```python +class RustParityTest(unittest.TestCase): + def test_rust_matches_every_golden_contract(self) -> None: + result = subprocess.run([sys.executable, "tests/oracle/compare_rust.py"], + cwd=REPOSITORY, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) +``` + +- [ ] **Step 2: Verify RED** + +Run: `python3 -m unittest tests.test_rust_parity -v` +Expected: FAIL because the comparison harness is absent or reports named parity gaps. + +- [ ] **Step 3: Close every parity gap** + +Build `target/release/ndf`. Compare parsed JSON, exit codes, semantic hashes, and +semantic SQLite rows. Normalize only temporary roots and path separators; never +normalize diagnostics, spans, messages, order, hashes, or data fields. + +- [ ] **Step 4: Add deterministic scale evidence** + +Generate the scale graph from numeric sequences. Build it twice. Measure with +`/usr/bin/time -l` on macOS or `/usr/bin/time -v` on Linux. Reject time +`>= 10.0` seconds or RSS `>= 536870912` bytes. + +- [ ] **Step 5: Verify and commit** + +Run: `cargo test --workspace --locked && python3 -m unittest tests.test_oracle_golden tests.test_rust_parity -v && NDF_RUN_SCALE_TESTS=1 cargo test -p ndf-compiler --test scale --release --locked && scripts/check-release-performance` +Expected: PASS under both scale thresholds. + +```bash +git add tests scripts docs/performance crates/ndf-compiler/tests/scale.rs +git commit -m "test: prove Rust NDF parity and scale" +``` + +--- + +### Task 11: Remove Python and prepare the Rust-only release + +**Files:** +- Delete: `pyproject.toml`, `requirements-lock.txt`, `src/ndf/`, tracked `src/ndf.egg-info/` +- Delete: `tests/index_test_support.py` +- Delete: `tests/test_baseline.py`, `tests/test_cli.py`, `tests/test_dependencies.py` +- Delete: `tests/test_diff.py`, `tests/test_evidence_adapter.py`, `tests/test_export.py` +- Delete: `tests/test_fix.py`, `tests/test_id_allocator.py`, `tests/test_identity.py` +- Delete: `tests/test_index.py`, `tests/test_index_scale.py`, `tests/test_manifest.py` +- Delete: `tests/test_markdown_parser.py`, `tests/test_model.py`, `tests/test_pto_asl_adapter.py` +- Delete: `tests/test_query.py`, `tests/test_report.py`, `tests/test_repository_adapter.py` +- Delete: `tests/test_rules.py`, `tests/test_spec_sync.py` +- Delete: `tests/oracle/generate_golden.py`, `tests/oracle/normalize.py`, `tests/oracle/compare_rust.py` +- Delete: `tests/test_oracle_golden.py`, `tests/test_rust_parity.py`, `tests/__init__.py` +- Create: `scripts/ndf`, `crates/ndf-cli/tests/release_contract.rs` +- Create: `crates/ndf-core/tests/spec_sync.rs` +- Modify: `.github/workflows/ci.yml`, `README.md`, `CHANGELOG.md` +- Modify: `docs/format/migration-v0.1-to-v0.2.md` +- Modify: `docs/superpowers/plans/2026-08-10-ndf-three-repository-refactor.md` + +**Interfaces:** +- Produces: Rust-only source tree and `scripts/ndf` wrapper using `cargo run --locked --release -p ndf-cli -- "$@"`. +- Produces: three-repository plan using only the NDF binary JSON interface. + +- [ ] **Step 1: Write the failing release-tree test** + +```rust +#[test] +fn release_tree_has_no_python_ndf_runtime() { + let root = repository_root(); + for forbidden in ["pyproject.toml", "requirements-lock.txt", "src/ndf"] { + assert!(!root.join(forbidden).exists(), "{forbidden} must be removed"); + } + assert!(root.join("Cargo.lock").is_file()); + assert!(root.join("scripts/ndf").is_file()); +} +``` + +- [ ] **Step 2: Verify RED** + +Run: `cargo test -p ndf-cli --test release_contract --locked` +Expected: FAIL on the existing Python runtime files. + +- [ ] **Step 3: Convert permanent parity assertions and delete Python** + +Move every permanent golden assertion into Rust integration tests, then delete +the Python package and migration harness. Remove NDF pip/PyYAML CI. Keep golden +JSON fixtures and performance evidence. + +- [ ] **Step 4: Update documentation and downstream commands** + +Document Rust 1.94.0, Cargo use, crate boundaries, and lack of Python bindings. +Change PTO-SPEC and DavinciOO NDF setup from pip/imports to +`cargo build --locked --release` plus CLI JSON subprocesses. + +- [ ] **Step 5: Run the release-candidate gate** + +Run: `cargo fmt --all -- --check && cargo clippy --workspace --all-targets --all-features -- -D warnings && cargo test --workspace --all-features --locked && cargo build --workspace --release --locked && scripts/ndf check --root tests/fixtures/markdown/project --format json && NDF_RUN_SCALE_TESTS=1 cargo test -p ndf-compiler --test scale --release --locked && scripts/check-release-performance && git diff --check` +Expected: PASS with no database or cache artifacts. + +- [ ] **Step 6: Commit, merge by PR, and tag the merged commit** + +```bash +git add -A +git commit -m "feat: ship NDF as a Rust knowledge compiler" +``` + +Push and open a ready PR. After CI and merge, fetch `origin/main`, verify the +merge contains this commit, then create and push annotated `v0.1.0` on the exact +merged commit. Never tag the feature-branch commit. + +--- + +## Completion Gate + +- Python-versus-Rust parity passes before Python deletion. +- Permanent Rust tests cover every committed golden contract. +- Formatting, Clippy with warnings denied, all workspace tests, and release build pass. +- Identical builds have equal semantic hashes and semantic SQLite rows. +- The scale workload completes under 10 seconds and 512 MiB. +- No NDF Python package, PyYAML lock, database, or cache remains tracked. +- The three-repository plan contains no NDF Python install or import step. +- `v0.1.0` points to the merged Rust commit before PTO-SPEC advances its pin. diff --git a/docs/superpowers/plans/2026-08-10-ndf-three-repository-refactor.md b/docs/superpowers/plans/2026-08-10-ndf-three-repository-refactor.md index b62a63a..3cbf605 100644 --- a/docs/superpowers/plans/2026-08-10-ndf-three-repository-refactor.md +++ b/docs/superpowers/plans/2026-08-10-ndf-three-repository-refactor.md @@ -6,7 +6,7 @@ **Architecture:** Markdown, ASL, source, test, and evidence files remain Git-tracked sources of truth. Parser adapters normalize them into a versioned canonical IR, generic and project rules validate that IR, and a deterministic SQLite database provides disposable query indexes. The dependency direction is strictly `DavinciOO -> normative_language`, `DavinciOO -> pto-spec`, and `pto-spec -> normative_language`; no upstream repository imports DavinciOO. -**Tech Stack:** Python 3.11+, `dataclasses`, `enum.StrEnum`, `argparse`, `sqlite3`, `json`, `pathlib`, PyYAML 6.x, `unittest`, Markdown, Git, Git submodules, GitHub Actions. +**Tech Stack:** Rust 1.94.0, Cargo edition 2024, Clap, Serde, Rusqlite with bundled SQLite, Markdown, Git, Git submodules, GitHub Actions; repository-local Python tests may invoke the NDF binary but NDF itself has no Python runtime. ## Global Constraints @@ -23,6 +23,15 @@ - Keep English and Chinese NDF specifications synchronized in the same commit. - Execute the plan inline, in repository-isolated worktrees, with one repository and one task active at a time. +## Rust Compiler Foundation + +Before Task 9, execute +[`2026-08-10-ndf-rust-compiler-migration.md`](2026-08-10-ndf-rust-compiler-migration.md) +in full. The first `v0.1.0` tag points to its merged Rust release commit. + +Downstream repositories build NDF with `cargo build --locked --release` and call +its versioned JSON CLI. NDF has no Python runtime or import surface. + --- ## Repository and PR Sequence @@ -53,659 +62,9 @@ exists. Each downstream PR records the consumed commit in its lock and PR body. --- -### Task 1: Scaffold the NDF package and freeze public version surfaces - -**Repository:** `PTO-ISA/normative_language` - -**Files:** -- Create: `pyproject.toml` -- Create: `requirements-lock.txt` -- Create: `src/ndf/__init__.py` -- Create: `src/ndf/__main__.py` -- Create: `src/ndf/version.py` -- Create: `src/ndf/cli.py` -- Create: `tests/test_cli.py` -- Create: `.github/workflows/ci.yml` -- Create: `.gitignore` -- Create: `README.md` - -**Interfaces:** -- Produces: `ndf.cli.main(argv: Sequence[str] | None = None) -> int` -- Produces: `python -m ndf ...` as the installation-independent CLI entrypoint. -- Produces: `ndf --machine-version` JSON with `tool_version`, `format_version`, `ir_version`, `cli_stability`, and `plugin_api`. - -- [ ] **Step 1: Write the failing CLI contract test** - -```python -import json -import unittest -from contextlib import redirect_stdout -from io import StringIO - -from ndf.cli import main - - -class CliVersionTest(unittest.TestCase): - def test_machine_version_has_independent_surfaces(self) -> None: - output = StringIO() - with redirect_stdout(output): - status = main(["--machine-version"]) - self.assertEqual(status, 0) - self.assertEqual( - json.loads(output.getvalue()), - { - "cli_stability": "experimental", - "format_version": "0.2", - "ir_version": "0.1", - "plugin_api": "experimental", - "tool_version": "0.1.0", - }, - ) -``` - -- [ ] **Step 2: Run the test and confirm the package is absent** - -Run: `python3 -m unittest tests.test_cli -v` -Expected: FAIL with `ModuleNotFoundError: No module named 'ndf'`. - -- [ ] **Step 3: Add the minimal package and CLI** - -Use setuptools with package root `src`, require Python `>=3.11`, and declare -`PyYAML>=6.0,<7` as the only runtime dependency. Add: - -```toml -[project.scripts] -ndf = "ndf.cli:entrypoint" -``` - -Implement `version.py` constants and `main()` with `argparse`. `entrypoint()` -raises `SystemExit(main())` so tests can call `main()` directly; -`src/ndf/__main__.py` imports and invokes `entrypoint()`. -Pin `PyYAML==6.0.2` in `requirements-lock.txt` for CI and downstream -reproducibility. - -- [ ] **Step 4: Add CI and ignore generated state** - -The workflow uses Python 3.11 and 3.12 and runs: - -```bash -python -m pip install -r requirements-lock.txt -python -m pip install -e . --no-deps -python -m unittest discover -s tests -p 'test_*.py' -git diff --check -``` - -Ignore `.venv/`, `build/`, `dist/`, `*.egg-info/`, `*.sqlite`, and -`.ndf-cache/`. - -- [ ] **Step 5: Verify and commit** - -Run: `python3 -m pip install -r requirements-lock.txt && python3 -m pip install -e . --no-deps && python3 -m unittest discover -s tests -p 'test_*.py' && git diff --check` -Expected: PASS. - -```bash -git add pyproject.toml requirements-lock.txt src tests .github .gitignore README.md -git commit -m "feat: scaffold the NDF knowledge compiler" -``` - ---- - -### Task 2: Define canonical identities, nodes, edges, and diagnostics - -**Repository:** `PTO-ISA/normative_language` - -**Files:** -- Create: `src/ndf/model.py` -- Create: `src/ndf/identity.py` -- Create: `schemas/ndf-ir.schema.json` -- Create: `schemas/ndf-diagnostic.schema.json` -- Create: `tests/test_model.py` -- Create: `tests/test_identity.py` - -**Interfaces:** -- Produces: `canonical_uri(project_id: str, local_id: str) -> str` -- Produces: `parse_uri(value: str, current_project: str | None = None) -> NodeId` -- Produces immutable `SourceSpan`, `Node`, `Edge`, `Diagnostic`, `Graph`, and `ParseResult` dataclasses. -- `NodeId` is an immutable `(project_id, local_id)` value whose string form is - the canonical URI; `ParseResult` contains `nodes`, `edges`, and `diagnostics`. - -- [ ] **Step 1: Write identity tests** - -```python -class IdentityTest(unittest.TestCase): - def test_local_id_resolves_inside_current_project(self) -> None: - self.assertEqual( - str(parse_uri("VEC-ISSUE-001", current_project="davincioo")), - "ndf://davincioo/VEC-ISSUE-001", - ) - - def test_cross_project_reference_requires_uri(self) -> None: - with self.assertRaisesRegex(ValueError, "current project is required"): - parse_uri("VEC-ISSUE-001") -``` - -- [ ] **Step 2: Write model invariant tests** - -Test that an `Edge` rejects an empty type, a `SourceSpan` rejects line zero, -and `Graph.add_node()` emits `NDF-ID-001` for a duplicate canonical URI. - -- [ ] **Step 3: Run tests and confirm missing symbols** - -Run: `python3 -m unittest tests.test_identity tests.test_model -v` -Expected: FAIL on missing `ndf.identity` and `ndf.model`. - -- [ ] **Step 4: Implement canonical types** - -Use `StrEnum` for `NodeKind`, `Modality`, `LifecycleStatus`, and -`DiagnosticSeverity`. Store project-owned `domain` and `refinement` as strings. -Use this diagnostic shape: - -```python -@dataclass(frozen=True) -class Diagnostic: - code: str - severity: DiagnosticSeverity - message: str - primary: SourceSpan - related: tuple[SourceSpan, ...] = () - repair: str | None = None -``` - -`Graph` provides insertion-independent `sorted_nodes()` and `sorted_edges()`. -Add JSON Schemas for the serialized graph and diagnostic envelopes. The model -tests load both schema documents as JSON and assert their required properties -match the dataclass serializers without adding a JSON-Schema runtime dependency. - -- [ ] **Step 5: Verify and commit** - -Run: `python3 -m unittest tests.test_identity tests.test_model -v` -Expected: PASS. - -```bash -git add src/ndf/model.py src/ndf/identity.py schemas tests -git commit -m "feat: define the canonical NDF graph model" -``` - ---- - -### Task 3: Parse manifests, locks, document front matter, and Markdown clauses - -**Repository:** `PTO-ISA/normative_language` - -**Files:** -- Create: `src/ndf/manifest.py` -- Create: `src/ndf/parsers/__init__.py` -- Create: `src/ndf/parsers/markdown.py` -- Create: `schemas/ndf-project.schema.json` -- Create: `schemas/ndf-lock.schema.json` -- Create: `schemas/ndf-front-matter.schema.json` -- Create: `tests/fixtures/markdown/project/ndf.yaml` -- Create: `tests/fixtures/markdown/project/ndf.lock` -- Create: `tests/fixtures/markdown/project/docs/pipeline.md` -- Create: `tests/test_manifest.py` -- Create: `tests/test_markdown_parser.py` - -**Interfaces:** -- Produces: `load_manifest(path: Path) -> ProjectManifest` -- Produces: `load_lock(path: Path) -> ProjectLock` -- Produces: `parse_markdown(path: Path, project: ProjectManifest) -> ParseResult` -- `ProjectManifest` contains format version, project ID, roots, prefixes, - domains, and policies; `ProjectLock` contains exact dependency revisions. - -- [ ] **Step 1: Add a complete fixture** - -The fixture contains file front matter, informative prose, a fenced code block -with a fake heading, and two real clauses: - -````markdown ---- -doc_id: DOC-DAVINCIOO-PIPELINE -status: active -authority: normative -owner: core ---- - -# Pipeline - -```text -## Not a clause {#FAKE-001} -``` - -## Issue contract {#PIPE-ISSUE-001} - - -The core MUST issue no more than two instructions per cycle. - -## Issue mechanism {#PIPE-ISSUE-010} - - -Two issue slots MUST arbitrate oldest-ready first. -```` - -- [ ] **Step 2: Write parser tests** - -Assert exactly three nodes, one `refines` edge, correct source spans, and no -`FAKE-001`. Assert stable diagnostics for invalid delimiters, duplicate keys, -and unsupported fields. - -- [ ] **Step 3: Run tests and confirm failure** - -Run: `python3 -m unittest tests.test_manifest tests.test_markdown_parser -v` -Expected: FAIL on missing parser modules. - -- [ ] **Step 4: Implement strict parsing** - -Use `yaml.safe_load`. Require project `format_version`, `project`, `roots`, and -`id_prefixes`. Accept only 40-hex dependency revisions in `ndf.lock`. Track -fenced blocks so headings inside them are ignored. - -Map legacy DavinciOO fields and emit `NDF-MIG-001` information: - -```text -kind=req -> kind=requirement -level=must -> modality=must -layer=L1 -> refinement=L1 -status=stable -> status=active -``` - -- [ ] **Step 5: Verify and commit** - -Run: `python3 -m unittest tests.test_manifest tests.test_markdown_parser -v` -Expected: PASS. - -```bash -git add src/ndf/parsers src/ndf/manifest.py schemas tests -git commit -m "feat: parse NDF projects and Markdown clauses" -``` - ---- +### Completed foundation: Rust NDF v0.1.0 -### Task 4: Implement generic validation and diagnostic baselines - -**Repository:** `PTO-ISA/normative_language` - -**Files:** -- Create: `src/ndf/rules.py` -- Create: `src/ndf/plugins.py` -- Create: `src/ndf/baseline.py` -- Create: `schemas/ndf-diagnostic-baseline.schema.json` -- Create: `tests/test_rules.py` -- Create: `tests/test_baseline.py` - -**Interfaces:** -- Produces: `validate_graph(graph: Graph, policy: ValidationPolicy) -> tuple[Diagnostic, ...]` -- Produces: `compare_baseline(current: Iterable[Diagnostic], baseline: DiagnosticBaseline) -> BaselineResult` -- Produces: `load_project_rules(manifest: ProjectManifest) -> tuple[Rule, ...]` -- `ValidationPolicy` is the normalized generic/project rule configuration; - `DiagnosticBaseline` is the parsed owned-debt file; `BaselineResult` exposes - `existing`, `new`, `worsened`, and `expired` diagnostics. - -- [ ] **Step 1: Write failing rule tests** - -Cover duplicate IDs, dangling mandatory edges, a `must` requirement without -`MUST`, `MUST` in information text, invalid `active -> draft`, and an explicit -node without an owner. Assert codes: - -```text -NDF-ID-001 NDF-EDGE-001 NDF-MOD-001 NDF-MOD-002 NDF-LIFE-001 NDF-OWN-001 -``` - -- [ ] **Step 2: Write baseline no-regression tests** - -Accept an identical diagnostic, reject a new code, reject a severity increase, -and emit `NDF-BASE-003` when an entry lacks owner or expiry milestone. - -- [ ] **Step 3: Run tests and confirm failure** - -Run: `python3 -m unittest tests.test_rules tests.test_baseline -v` -Expected: FAIL on missing modules. - -- [ ] **Step 4: Implement rules and fingerprinting** - -Fingerprint from `(code, canonical URI, normalized path, normalized message -parameters)`, not line number. Baseline comparison never suppresses a -diagnostic; it classifies it as existing or new. - -For format 0.2, project extension points are declarative, namespaced rule -configuration from `ndf.yaml`; do not load arbitrary Python entry points. Reject -unknown rule namespaces with `NDF-PLUGIN-001`, so a future plugin ABI can be -added without silently reinterpreting current manifests. - -- [ ] **Step 5: Verify and commit** - -Run: `python3 -m unittest tests.test_rules tests.test_baseline -v` -Expected: PASS. - -```bash -git add src/ndf/rules.py src/ndf/plugins.py src/ndf/baseline.py schemas tests -git commit -m "feat: enforce NDF graph invariants" -``` - ---- - -### Task 5: Build a deterministic SQLite index - -**Repository:** `PTO-ISA/normative_language` - -**Files:** -- Create: `src/ndf/index.py` -- Create: `src/ndf/sql/schema-v1.sql` -- Create: `tests/test_index.py` -- Create: `tests/test_index_scale.py` - -**Interfaces:** -- Produces: `build_index(graph: Graph, output: Path, provenance: BuildProvenance) -> IndexBuild` -- Produces: `open_index(path: Path) -> NdfIndex` -- `NdfIndex` provides `get_node(uri)`, `out_edges(uri, edge_type=None)`, and - `in_edges(uri, edge_type=None)`. -- `BuildProvenance` contains project commits and NDF versions; `IndexBuild` - contains output path, semantic hash, node/edge counts, and diagnostics. - -- [ ] **Step 1: Write the determinism test** - -Build indexes from the same graph with reversed insertion order. Query logical -tables by primary key and assert identical rows and identical -`IndexBuild.semantic_hash` values. - -- [ ] **Step 2: Write immutability and provenance tests** - -Assert atomic replacement, refusal to write inside a normative root, enabled -foreign keys, and stored project commits, format version, IR version, and tool -version. - -- [ ] **Step 3: Run tests and confirm failure** - -Run: `python3 -m unittest tests.test_index -v` -Expected: FAIL on missing `ndf.index`. - -- [ ] **Step 4: Implement schema v1 and builder** - -Create `projects`, `sources`, `nodes`, `node_attrs`, `aliases`, `edges`, -`diagnostics`, `baselines`, and `builds`. Insert sorted records in one -transaction, run `PRAGMA foreign_key_check`, and atomically replace output. - -- [ ] **Step 5: Add a provisional scale test** - -Generate 100,000 nodes and 1,000,000 edges. Run only when -`NDF_RUN_SCALE_TESTS=1`; record build time and size without setting a time gate -until real measurements establish one. - -- [ ] **Step 6: Verify and commit** - -Run: `python3 -m unittest tests.test_index -v` -Expected: PASS. - -```bash -git add src/ndf/index.py src/ndf/sql tests -git commit -m "feat: materialize deterministic NDF indexes" -``` - ---- - -### Task 6: Add query, trace, coverage, diff, ID, and safe-fix capabilities - -**Repository:** `PTO-ISA/normative_language` - -**Files:** -- Create: `src/ndf/query.py` -- Create: `src/ndf/report.py` -- Create: `src/ndf/diff.py` -- Create: `src/ndf/export.py` -- Create: `src/ndf/id_allocator.py` -- Create: `src/ndf/fix.py` -- Modify: `src/ndf/cli.py` -- Create: `tests/test_query.py` -- Create: `tests/test_report.py` -- Create: `tests/test_diff.py` -- Create: `tests/test_export.py` -- Create: `tests/test_id_allocator.py` -- Create: `tests/test_fix.py` - -**Interfaces:** -- Produces: `query_nodes(index, expression: str) -> tuple[NodeRecord, ...]` -- Produces: `trace(index, uri: str, edge_types: frozenset[str]) -> TraceTree` -- Produces: `coverage(index, policy: CoveragePolicy) -> CoverageReport` -- Produces: `semantic_diff(before: NdfIndex, after: NdfIndex) -> SemanticDiff` -- Produces: `export_graph(index: NdfIndex, output: Path, format: ExportFormat) -> ExportResult` -- Produces: `allocate_id(prefix: str, occupied: Iterable[str]) -> str` -- Produces: `safe_fix(paths: Sequence[Path], mode: FixMode) -> FixResult` -- `NodeRecord` is the indexed node projection; `TraceTree`, `CoverageReport`, - `SemanticDiff`, `ExportResult`, and `FixResult` are immutable - JSON-serializable dataclasses. `CoveragePolicy`, `ExportFormat`, and - `FixMode` are explicit immutable configurations. - -- [ ] **Step 1: Write machine-output CLI tests** - -Test these experimental spellings: - -```bash -ndf build --root FIXTURE --output graph.sqlite --format json -ndf check --root FIXTURE --format json -ndf show ndf://fixture/PIPE-ISSUE-001 --index graph.sqlite --format json -ndf trace PIPE-ISSUE-001 --edge refines --format json -ndf report coverage --format json -ndf diff before.sqlite after.sqlite --format json -ndf export --index graph.sqlite --output graph.json --format ndf-json -ndf id allocate PIPE-ISSUE --format json -ndf fix --generated-only --check -``` - -Each JSON object contains `schema_version`, `command`, `ok`, `data`, and -`diagnostics`. - -- [ ] **Step 2: Write query, coverage, and trace tests** - -Coverage reports active `must` L1 requirements without incoming `verifies`, -open questions/options, and `tbd` clauses. Trace terminates on cycles and -reports the cycle edge. - -- [ ] **Step 3: Write semantic-diff tests** - -Assert added, removed, modified, moved, superseded, and edge-only changes. A -move with the same explicit URI and body hash is `moved`, not remove/add. - -- [ ] **Step 4: Implement capabilities and CLI routing** - -Keep human output concise. `safe_fix` may reorder metadata and regenerate -marked navigation; return `NDF-FIX-001` if asked to alter prose, authority, -status, or semantic edges. - -The `ndf-json` export is deterministic, validates against -`schemas/ndf-ir.schema.json`, includes build provenance, and omits absolute -machine-local paths. This export is the semantic before/after artifact used by -downstream move PRs. - -- [ ] **Step 5: Verify and commit** - -Run: `python3 -m unittest discover -s tests -p 'test_*.py'` -Expected: PASS. - -```bash -git add src/ndf tests -git commit -m "feat: expose NDF graph workflows" -``` - ---- - -### Task 7: Implement the PTO ASL adapter and freeze parity fixtures - -**Repository:** `PTO-ISA/normative_language` - -**Files:** -- Create: `src/ndf/adapters/__init__.py` -- Create: `src/ndf/adapters/pto_asl.py` -- Create: `tests/fixtures/pto-asl/accept.asl` -- Create: `tests/fixtures/pto-asl/reject-duplicate.asl` -- Create: `tests/fixtures/pto-asl/reject-metadata.asl` -- Create: `tests/test_pto_asl_adapter.py` -- Create: `docs/adapters/pto-asl.md` - -**Interfaces:** -- Produces: `parse_pto_asl(path: Path, project: ProjectManifest) -> ParseResult` -- Maps PTO `kind=intent|contract|mechanism|executable` to refinement - `L0|L1|L2|L3`, PTO `layer` to `domain`, and PTO status to canonical lifecycle. - -- [ ] **Step 1: Create redistributable parity fixtures** - -Use the behavior in PTO-SPEC `tests/scripts/test_ndf.py`, preserving diagnostic -meaning without copying unrelated PTO content. - -- [ ] **Step 2: Write adapter tests** - -Assert region matching, comment-only bodies, metadata validation, instruction -identity generation, `[[PTO-*]]` references, and source lines. - -- [ ] **Step 3: Run tests and confirm failure** - -Run: `python3 -m unittest tests.test_pto_asl_adapter -v` -Expected: FAIL on missing adapter. - -- [ ] **Step 4: Implement without importing PTO-SPEC code** - -Keep PTO syntax policy in this adapter. Return canonical nodes and diagnostics; -do not expose PTO-specific dataclasses through the generic API. - -- [ ] **Step 5: Verify and commit** - -Run: `python3 -m unittest discover -s tests -p 'test_*.py'` -Expected: PASS. - -```bash -git add src/ndf/adapters tests/fixtures/pto-asl tests/test_pto_asl_adapter.py docs/adapters -git commit -m "feat: add the PTO ASL NDF adapter" -``` - ---- - -### Task 7A: Index repository artifacts, evidence, and pinned dependencies - -**Repository:** `PTO-ISA/normative_language` - -**Files:** -- Create: `src/ndf/adapters/repository.py` -- Create: `src/ndf/adapters/evidence.py` -- Create: `src/ndf/dependencies.py` -- Create: `schemas/ndf-evidence.schema.json` -- Create: `tests/fixtures/repository/src/core.py` -- Create: `tests/fixtures/repository/tests/test_core.py` -- Create: `tests/fixtures/repository/evidence/core.json` -- Create: `tests/test_repository_adapter.py` -- Create: `tests/test_evidence_adapter.py` -- Create: `tests/test_dependencies.py` - -**Interfaces:** -- Produces: `index_repository_paths(root: Path, globs: Sequence[str], project: ProjectManifest) -> ParseResult` -- Produces: `parse_evidence_manifest(path: Path, project: ProjectManifest) -> ParseResult` -- Produces: `resolve_dependencies(manifest: ProjectManifest, lock: ProjectLock, workspace: Path) -> tuple[ResolvedProject, ...]` -- `ResolvedProject` records canonical project ID, local path, exact Git revision, - dirty-state policy, and dependency provenance. - -- [ ] **Step 1: Write source, test, and evidence fixture tests** - -Assert deterministic derived IDs for source and test artifacts, explicit IDs -for evidence records, and `implements`, `verifies`, and `evidenced-by` edges to -normative clauses. Reordering the input globs must not change identities or -serialized output. - -- [ ] **Step 2: Write dependency resolution tests** - -Cover an exact detached commit, a submodule at the locked revision, a missing -dependency, a symbolic branch name, a revision mismatch, and a dirty dependency -when `allow_dirty` is false. Assert stable diagnostics: - -```text -NDF-DEP-001 NDF-DEP-002 NDF-DEP-003 NDF-DEP-004 -``` - -- [ ] **Step 3: Run tests and confirm failure** - -Run: `python3 -m unittest tests.test_repository_adapter tests.test_evidence_adapter tests.test_dependencies -v` -Expected: FAIL on missing adapter and dependency modules. - -- [ ] **Step 4: Implement the bounded repository adapters** - -Repository paths are descriptive nodes, not normative clauses. Derive their -IDs from `(project ID, normalized repository-relative path, artifact kind)`; -store Git revision and content hash as attributes. Evidence manifests validate -against `schemas/ndf-evidence.schema.json` and must name the command, result, -tool version, source revision, timestamp, and linked clause IDs. - -Dependency resolution reads only manifest paths and exact revisions from -`ndf.lock`. It may inspect a checked-out Git worktree or submodule, but it must -not clone, fetch, or mutate repositories. Add `ndf report dependencies ---format json` so CI can record the resolved compatibility tuple. - -- [ ] **Step 5: Verify and commit** - -Run: `python3 -m unittest discover -s tests -p 'test_*.py'` -Expected: PASS. - -```bash -git add src/ndf/adapters src/ndf/dependencies.py schemas tests -git commit -m "feat: index repository evidence and pinned dependencies" -``` - ---- - -### Task 8: Revise the NDF specification and publish experimental release 0.1.0 - -**Repository:** `PTO-ISA/normative_language` - -**Files:** -- Modify: `normative_language.md` -- Modify: `normative_language_cn.md` -- Create: `docs/format/migration-v0.1-to-v0.2.md` -- Create: `CHANGELOG.md` -- Create: `tests/test_spec_sync.py` -- Modify: `README.md` - -**Interfaces:** -- Produces format `0.2`, IR `0.1`, and machine-output schema `0.1`. -- Produces Git tag `v0.1.0` after merge and CI success. - -- [ ] **Step 1: Write specification contract tests** - -Require both language files to carry matching versions and sections for derived -indexes, canonical dimensions, namespaced identity, front matter, lock files, -agent enforcement, and maturity levels. - -- [ ] **Step 2: Run the sync test and confirm failure** - -Run: `python3 -m unittest tests.test_spec_sync -v` -Expected: FAIL because the new sections are absent. - -- [ ] **Step 3: Update English and Chinese specifications together** - -Replace “no database” with derived-index rules. Replace overloaded fields with -`kind/modality/refinement/domain/status`. Document legacy adapters, `ndf://` -URIs, file front matter, `ndf.lock`, maturity, JSON output, and agent gates. - -- [ ] **Step 4: Write migration guide and changelog** - -Include before/after DavinciOO Markdown and PTO ASL mappings. State that CLI -spelling and plugin ABI remain experimental. - -- [ ] **Step 5: Run the release-candidate gate** - -```bash -python3 -m unittest discover -s tests -p 'test_*.py' -ndf check --root tests/fixtures/markdown/project --format json -git diff --check -``` - -Expected: PASS and no untracked SQLite database. - -- [ ] **Step 6: Commit, merge through PR, then tag** - -```bash -git add normative_language*.md README.md CHANGELOG.md docs/format tests/test_spec_sync.py -git commit -m "docs: define NDF knowledge compiler format 0.2" -git tag -a v0.1.0 -m "NDF knowledge compiler experimental 0.1.0" -``` - -Push the tag only after it points to the merged release commit. - ---- +The standalone Rust compiler, frozen golden contracts, deterministic SQLite index, complete JSON CLI, and release-scale evidence are implemented in `PTO-ISA/normative_language`. The release PR must merge before the exact merged commit is tagged `v0.1.0`; downstream work begins only after that tag exists. ### Task 9: Pin NDF in PTO-SPEC, prove parity, and publish compatibility patch 0.1.1 @@ -739,9 +98,11 @@ ASL roots, `PTO-*` prefixes, and the PTO ASL adapter. - [ ] **Step 2: Write the failing full-tree parity test** -Load `scripts/ndf.py` as old oracle and `tools/ndf/src/ndf` as candidate. -Compare canonical clause IDs, bodies, paths, lines, references, and normalized -validation errors. Keep field translation explicit in the test. +Load `scripts/ndf.py` as the old oracle. Build the pinned Rust candidate with +`cargo build --manifest-path tools/ndf/Cargo.toml --locked --release` and invoke +`tools/ndf/target/release/ndf` through `subprocess`. Compare canonical clause +IDs, bodies, paths, lines, references, and normalized validation errors. Keep +field translation explicit in the test; do not import candidate internals. - [ ] **Step 3: Run the parity test** @@ -805,13 +166,11 @@ Expected: FAIL only on the new wrapper contract. - [ ] **Step 3: Replace the wrapper** -Import the pinned source locally: +Make `scripts/check-ndf` execute the pinned binary: -```python -NDF_SRC = ROOT / "tools" / "ndf" / "src" -sys.path.insert(0, str(NDF_SRC)) -from ndf.cli import main as ndf_main -raise SystemExit(ndf_main(["check", "--root", str(ROOT), "--format", "human"])) +```bash +cargo build --manifest-path "$ROOT/tools/ndf/Cargo.toml" --locked --release -p ndf-cli +exec "$ROOT/tools/ndf/target/release/ndf" check --root "$ROOT" --format json ``` Keep `ndf_legacy.py` for one experimental compatibility window. Parity tests @@ -819,12 +178,11 @@ run it; normal CI does not. - [ ] **Step 4: Update documentation and checkout gates** -Document pin updates. Ensure CI initializes `tools/ndf` recursively and -installs the pinned runtime before `make pr-check`: +Document pin updates. Ensure CI initializes `tools/ndf` recursively and builds +the pinned runtime before `make pr-check`: ```bash -python3 -m pip install -r tools/ndf/requirements-lock.txt -python3 -m pip install -e tools/ndf --no-deps +cargo build --manifest-path tools/ndf/Cargo.toml --locked --release -p ndf-cli ``` `make pr-check` must still run `./scripts/check-ndf`. @@ -897,14 +255,15 @@ Never baseline duplicate explicit IDs, invalid locks, or non-determinism. - [ ] **Step 5: Implement the build wrapper** -Import only `toolchains/ndf/src`, support `--check`, `--changed`, and `--output`, -write under `.ndf-cache/` by default, and fail on new/worsened diagnostics. +Invoke `toolchains/ndf/target/release/ndf` as a subprocess, parse only its +versioned JSON envelope, support `--check`, `--changed`, and `--output`, write +under `.ndf-cache/` by default, and fail on new/worsened diagnostics. Do not +import or duplicate NDF compiler logic. - [ ] **Step 6: Verify and commit** ```bash -python3 -m pip install -r toolchains/ndf/requirements-lock.txt -python3 -m pip install -e toolchains/ndf --no-deps +cargo build --manifest-path toolchains/ndf/Cargo.toml --locked --release -p ndf-cli python3 toolchains/scripts/build_ndf_index.py --check pytest srcs/tests/unit/test_ndf_project_contract.py -q git diff --check @@ -950,16 +309,16 @@ Expected: FAIL with missing contract phrases. - [ ] **Step 3: Add guidance and the local hook** Add a local pre-commit hook for changed docs/NDF/config. It runs changed-scope -validation and never writes files. Configure the hook as an isolated Python -hook with `PyYAML==6.0.2` as an additional dependency. Keep full validation in -CI. +validation and never writes files. Configure it as a repository-local system +hook that calls the project wrapper; the wrapper invokes the pinned Rust binary. +Keep full validation in CI. - [ ] **Step 4: Add the CI job** -Checkout recursive submodules, use Python 3.12, install -`toolchains/ndf/requirements-lock.txt` plus the local NDF package with -`--no-deps`, then run project-contract tests and full index check. Upload JSON -diagnostics on failure, not SQLite. +Checkout recursive submodules, install Rust 1.94.0 and Python 3.12, build NDF +with `cargo build --manifest-path toolchains/ndf/Cargo.toml --locked --release`, +then run project-contract tests and the full index check. Upload JSON diagnostics +on failure, not SQLite. - [ ] **Step 5: Verify and commit** @@ -1026,7 +385,7 @@ rg -n 'designs/DavinciOO/(ndf|superscalar_v5|old|pic)' . --glob '!isa/**' ```bash python3 toolchains/scripts/build_ndf_index.py --output /tmp/davincioo-after.sqlite -PYTHONPATH=toolchains/ndf/src python3 -m ndf diff /tmp/davincioo-before.sqlite /tmp/davincioo-after.sqlite --format json +toolchains/ndf/target/release/ndf diff /tmp/davincioo-before.sqlite /tmp/davincioo-after.sqlite --format json ``` Expected: move and reference-location changes only. Fix semantic changes before @@ -1206,22 +565,21 @@ Run from clean worktrees after upstream pins merge: ```bash # PTO-ISA/normative_language -python3 -m pip install -r requirements-lock.txt -python3 -m pip install -e . --no-deps -python3 -m unittest discover -s tests -p 'test_*.py' -ndf check --root tests/fixtures/markdown/project --format json +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-features --locked +cargo build --workspace --release --locked +scripts/ndf check --root tests/fixtures/markdown/project --format json # PTO-ISA/pto-spec git submodule update --init --recursive -python3 -m pip install -r tools/ndf/requirements-lock.txt -python3 -m pip install -e tools/ndf --no-deps +cargo build --manifest-path tools/ndf/Cargo.toml --locked --release -p ndf-cli make pr-check # hengliao1972/DavinciOO git submodule update --init --recursive python3 -m pip install -e ".[dev]" -python3 -m pip install -r toolchains/ndf/requirements-lock.txt -python3 -m pip install -e toolchains/ndf --no-deps +cargo build --manifest-path toolchains/ndf/Cargo.toml --locked --release -p ndf-cli python3 toolchains/scripts/build_ndf_index.py --check python3 toolchains/scripts/generate_docs_navigation.py --check pytest srcs/tests/unit -q diff --git a/docs/superpowers/specs/2026-08-10-ndf-rust-compiler-migration-design.md b/docs/superpowers/specs/2026-08-10-ndf-rust-compiler-migration-design.md new file mode 100644 index 0000000..fd7fcbb --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-ndf-rust-compiler-migration-design.md @@ -0,0 +1,228 @@ +# NDF Rust Compiler Migration Design + +**Status:** Accepted +**Date:** 2026-08-10 +**Decision:** Replace the merged Python prototype with a standalone Rust implementation before the first NDF release or downstream pin. + +## Summary + +NDF will ship as a Rust knowledge compiler. The merged Python implementation at +commit `b0df8a0e9d95ed1946204606392049d2ef3133c1` defines the prototype behavior, +but it will not become a supported runtime. During migration, it serves only as +a semantic oracle for fixtures and parity tests. After the Rust implementation +matches the accepted contracts, the Python package and its runtime dependencies +will be removed. + +The first `v0.1.0` tag will point to the merged Rust release commit. PTO-SPEC and +DavinciOO must not pin NDF before that tag exists. + +## Motivation + +The Python prototype proves the NDF data model, diagnostics, SQLite schema, and +cross-repository workflow. It also reveals two scale risks: + +- immutable tuple-based graph construction becomes quadratic as the corpus grows; +- the million-edge index test peaks near 1.23 GB because the full graph and its + serialized forms coexist in memory. + +These are primarily data-flow problems, but the repository is still early enough +to choose a compiled implementation without a compatibility migration for users. +Rust provides predictable memory ownership, efficient streaming, one distributable +binary, and a stronger foundation for future parallel parsing and graph analysis. + +## Goals + +- Preserve NDF format `0.2`, canonical IR `0.1`, machine-output schema `0.1`, + diagnostic codes, source spans, and deterministic semantic hashes. +- Provide one standalone `ndf` binary with no Python runtime dependency. +- Scale to at least 100,000 nodes and 1,000,000 edges without materializing + avoidable duplicate graph representations. +- Keep PTO-SPEC ASL authoritative for PTO architecture and DavinciOO NDF clauses + authoritative for DavinciOO microarchitecture. +- Keep adapters and rules extensible without committing to an unstable dynamic + library ABI. +- Preserve exact, reproducible downstream pins through Git submodules and + `Cargo.lock`. + +## Non-goals + +- Do not redesign the NDF source format, canonical fields, identity rules, or + authority model during the language migration. +- Do not keep supported Python bindings or a PyO3 compatibility layer. +- Do not introduce a background service, hosted database, or writable SQLite API. +- Do not introduce runtime-loaded Rust dynamic libraries in `v0.1.0`. +- Do not publish the Python prototype as an NDF release. + +## Considered Approaches + +### Standalone Rust compiler + +This is the selected approach. Rust owns parsing, validation, indexing, querying, +and the CLI. Python exists only long enough to generate and verify golden +behavior. This approach minimizes long-term runtime and maintenance surfaces. + +### Rust core with PyO3 bindings + +This would preserve Python imports while accelerating graph and SQLite work. It +was rejected because no downstream user depends on the Python API yet, and PyO3 +would add packaging, ABI, and dual-language compatibility work before it creates +user value. + +### Rust indexer with a Python front end + +This would migrate the measured hot path first. It was rejected because parsing, +rules, CLI error handling, and adapter behavior would remain split across two +languages, making later extension and release packaging harder. + +## Architecture + +Use one Cargo workspace with three initial crates: + +| Crate | Responsibility | +| --- | --- | +| `ndf-core` | Canonical identities, nodes, edges, diagnostics, source spans, graph builder, serialization, and version surfaces | +| `ndf-compiler` | Manifests, locks, Markdown and ASL adapters, generic rules, baselines, SQLite indexing, queries, reports, diff, export, and safe fixes | +| `ndf-cli` | Command parsing, stable JSON envelope, process exit codes, and the `ndf` binary | + +The workspace retains the conceptual boundaries described by the cross-repository +design without creating one crate per module. Split another crate only when an +independent consumer or dependency boundary requires it. + +The repository keeps JSON Schemas and SQL migrations outside crate internals: + +```text +Cargo.toml +Cargo.lock +rust-toolchain.toml +crates/ + ndf-core/ + ndf-compiler/ + ndf-cli/ +schemas/ +sql/ +tests/ + fixtures/ + golden/ +``` + +Pin Rust `1.94.0` in `rust-toolchain.toml`. Commit `Cargo.lock`. Use a small, +auditable dependency set: Serde for contracts, Clap for the CLI, a YAML parser +for manifests, SHA-256 for semantic hashes, and SQLite with a reproducible bundled +build. Record exact dependency selections in the implementation plan. + +## Data Flow and Memory Model + +Parsing writes into a mutable `GraphBuilder` backed by maps and sets keyed by +canonical URI. Duplicate detection is expected constant time. The builder freezes +into deterministically ordered records only at an API boundary that requires a +complete graph. + +Index construction uses one SQLite transaction and prepared batch inserts. It +streams sorted records where possible and computes the semantic hash incrementally +from canonical serialization. Query, coverage, trace, and diff operations push +filtering and traversal into SQL instead of loading every row into Rust. + +JSON export writes through a buffered serializer. It must not assemble the full +export document as a second in-memory graph. + +## Compatibility Boundary + +The Rust migration preserves these externally observable contracts: + +- `ndf --machine-version` field names and values; +- command names, required arguments, exit-code meanings, and the versioned JSON + response envelope; +- canonical `ndf:///` identity parsing; +- serialized node, edge, diagnostic, report, diff, and export field names; +- stable diagnostic codes, severities, primary spans, related spans, and repairs; +- deterministic node and edge ordering; +- SQLite schema version `1`, query results, provenance, and semantic hash; +- legacy DavinciOO Markdown mappings and PTO ASL adapter mappings. + +The CLI and plugin maturity remain experimental. Deliberate contract changes +still require a fixture change, release note, and explicit version decision; they +must not appear accidentally as a consequence of the rewrite. + +## Extension Model + +Rust traits define internal parser, adapter, rule, and report boundaries. The +`v0.1.0` binary registers built-in adapters statically from the project manifest. +This avoids unstable Rust dynamic-library ABI commitments. + +When an independently released adapter is needed, NDF will add a versioned +JSON/NDJSON subprocess protocol. That protocol can support Rust or other +languages without loading foreign code into the compiler process. It is deferred +until a real external adapter supplies its requirements. + +## Error Handling + +Expected source and policy failures become structured NDF diagnostics and return +exit status `1`. Invalid invocations return a JSON error envelope when machine +output is requested and a concise CLI error otherwise. Internal failures carry +context through typed Rust errors and never produce a panic backtrace as the +normal user interface. + +The compiler writes SQLite output atomically through a temporary file in the +destination directory. A failed build leaves the previous valid index untouched. + +## Migration Sequence + +1. Freeze Python behavior into language-neutral fixtures and JSON golden files. +2. Add the Rust workspace and reproduce machine-version and identity contracts. +3. Port manifests, locks, Markdown parsing, graph validation, and baselines. +4. Port deterministic SQLite indexing, queries, coverage, trace, diff, and export. +5. Port PTO ASL, repository, test, evidence, and dependency adapters. +6. Reproduce the complete CLI and run Python-versus-Rust parity over all fixtures. +7. Meet correctness, determinism, and scale gates in release mode. +8. Delete the Python package, PyYAML lock, Python-specific CI, and compatibility + wrappers. +9. Merge the Rust implementation and tag the exact merge commit as `v0.1.0`. +10. Pin `v0.1.0` in PTO-SPEC, then continue the existing upstream-to-downstream + migration into DavinciOO. + +Python parity tests run during steps 1 through 7. Step 8 replaces them with +language-neutral golden tests so future development does not require Python. +The original prototype remains recoverable from Git history. + +## Verification Strategy + +Every feature is ported with test-driven development. A Rust test first consumes +an existing fixture or a newly frozen Python result and fails for the missing Rust +behavior. The minimal Rust implementation then makes it pass. + +Release verification includes: + +- unit tests for identities, canonical model invariants, parsers, rules, and + query algorithms; +- integration tests for every CLI command and JSON envelope; +- golden parity for accepted output, rejected input, diagnostics, source spans, + semantic hashes, and SQLite query results; +- two-build determinism from identical locked sources; +- an opt-in release benchmark with 100,000 nodes and 1,000,000 edges; +- clean-checkout end-to-end tests for the bundled Markdown and PTO ASL fixtures. + +The release benchmark must finish in less than 10 seconds and peak below 512 MiB +on the documented reference machine. CI records timing and size evidence but does +not use a noisy microbenchmark to reject ordinary pull requests. Determinism and +correctness remain hard CI gates. + +## Downstream Integration + +PTO-SPEC and DavinciOO consume an exact NDF Git submodule commit. Their wrappers +invoke the Rust binary built with `cargo build --locked --release`; CI caches the +Cargo target directory but treats the binary as derived state. + +No downstream workflow installs NDF with `pip`, imports `ndf` as a Python module, +or depends on PyYAML. Cross-repository integration uses the CLI JSON contract and +versioned files, not Rust crate internals. + +## Rollback and Release Gate + +Do not create `v0.1.0` and do not advance PTO-SPEC or DavinciOO pins until all +Rust parity and release gates pass on the merged commit. If the migration cannot +meet a contract, fix the Rust implementation or explicitly revise the design; +do not silently fall back to a supported dual-language architecture. + +The Python prototype remains available at merge commit +`b0df8a0e9d95ed1946204606392049d2ef3133c1` for diagnosis. It is not a release, +runtime dependency, or alternate supported implementation. diff --git a/normative_language.md b/normative_language.md index f09c7d4..d2d2c44 100644 --- a/normative_language.md +++ b/normative_language.md @@ -551,7 +551,7 @@ are not part of the 0.1.0 CLI contract. **Phase 1 — Format freeze (weeks 1–2).** Write `ndf.yaml` schema, clause grammar, ID rules, edge types, layer ladder as a short normative document — *itself written in NDF*, the first dogfood. -**Phase 2 — Minimum toolchain (weeks 2–6).** `check`, `new-id`, `export`, `trace`, `publish` in Python over a Markdown parser; CI recipe. (Doorstop and Spec Kit demonstrate every needed technique; this is assembly, not research.) +**Phase 2 — Minimum toolchain (weeks 2–6).** Build the standalone Rust knowledge compiler with `check`, ID allocation, `export`, and `trace`; publish a locked CI recipe and keep the JSON CLI as the automation boundary. (Doorstop and Spec Kit demonstrate every needed technique; this is assembly, not research.) **Phase 3 — Pilot on a real design (weeks 4–12, overlapping).** A bounded but honest target — e.g., a 4-port L2 Ethernet switch model — with agentic design cycles run spec-first: work orders cite clause IDs, feedback compiles to spec commits, coverage tracked. The pilot's metric is §11's question list. diff --git a/normative_language_cn.md b/normative_language_cn.md index 6bc1c2e..88aeb19 100644 --- a/normative_language_cn.md +++ b/normative_language_cn.md @@ -545,7 +545,7 @@ kind/refinement,把 ASL `layer` 映射为 domain,把 ASL 生命周期映射 **阶段 1——格式冻结(第 1–2 周)。** 把 `ndf.yaml` schema、条款文法、ID 规则、边类型、层阶梯写成一份简短的规范性文档——*这份文档本身就用 NDF 书写*,第一次吃自己的狗粮。 -**阶段 2——最小工具链(第 2–6 周)。** 基于 Markdown 解析器用 Python 实现 `check`、`new-id`、`export`、`trace`、`publish`;给出 CI 配方。(Doorstop 和 Spec Kit 已演示了所需的每一项技术;这是组装,不是研究。) +**阶段 2——最小工具链(第 2–6 周)。** 构建独立的 Rust 知识编译器,实现 `check`、ID 分配、`export` 与 `trace`;发布锁定依赖的 CI 配方,并把 JSON CLI 作为自动化边界。(Doorstop 和 Spec Kit 已演示了所需的每一项技术;这是组装,不是研究。) **阶段 3——真实设计试点(第 4–12 周,与前重叠)。** 一个有边界但真实的目标——例如一个 4 端口 L2 以太网交换机模型——以规范优先的方式运行 agentic 设计周期:工单引用条款 ID,反馈编译为规范提交,覆盖率受追踪。试点的度量就是 §11 的问题清单。 diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index b7c004c..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,20 +0,0 @@ -[build-system] -requires = ["setuptools>=77"] -build-backend = "setuptools.build_meta" - -[project] -name = "ndf" -version = "0.1.0" -description = "Normative Document Format knowledge compiler" -readme = "README.md" -requires-python = ">=3.11" -dependencies = ["PyYAML>=6.0,<7"] - -[project.scripts] -ndf = "ndf.cli:entrypoint" - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -ndf = ["sql/*.sql"] diff --git a/requirements-lock.txt b/requirements-lock.txt deleted file mode 100644 index 8392d54..0000000 --- a/requirements-lock.txt +++ /dev/null @@ -1 +0,0 @@ -PyYAML==6.0.2 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..28bb9c5 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.94.0" +components = ["clippy", "rustfmt"] +profile = "minimal" diff --git a/scripts/check-release-performance b/scripts/check-release-performance new file mode 100755 index 0000000..b57a2c8 --- /dev/null +++ b/scripts/check-release-performance @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$repo_root" + +cargo test -p ndf-compiler --test scale --release --locked --no-run >/dev/null +test_binary=$(find target/release/deps -type f -name 'scale-*' ! -name '*.d' -perm -111 -print0 | xargs -0 ls -t | head -n 1) +metrics=$(mktemp) +output=$(mktemp) +trap 'rm -f "$metrics" "$output"' EXIT + +if [[ $(uname -s) == Darwin ]]; then + /usr/bin/time -l -o "$metrics" env NDF_RUN_SCALE_TESTS=1 "$test_binary" --exact release_scale_contract --nocapture >"$output" + rss_bytes=$(awk '/maximum resident set size/ {print $1}' "$metrics") +else + /usr/bin/time -v -o "$metrics" env NDF_RUN_SCALE_TESTS=1 "$test_binary" --exact release_scale_contract --nocapture >"$output" + rss_kib=$(awk -F: '/Maximum resident set size/ {gsub(/[[:space:]]/, "", $2); print $2}' "$metrics") + rss_bytes=$((rss_kib * 1024)) +fi + +cat "$output" +first_seconds=$(sed -n 's/.*first_seconds=\([0-9.]*\).*/\1/p' "$output") +second_seconds=$(sed -n 's/.*second_seconds=\([0-9.]*\).*/\1/p' "$output") + +awk -v first="$first_seconds" -v second="$second_seconds" 'BEGIN { if (first >= 10.0 || second >= 10.0) exit 1 }' +if (( rss_bytes >= 536870912 )); then + echo "NDF release performance failed: peak RSS ${rss_bytes} bytes" >&2 + exit 1 +fi +echo "NDF release performance passed: builds ${first_seconds}s/${second_seconds}s, peak RSS ${rss_bytes} bytes" diff --git a/scripts/ndf b/scripts/ndf new file mode 100755 index 0000000..93ecd24 --- /dev/null +++ b/scripts/ndf @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +exec cargo run --manifest-path "$repo_root/Cargo.toml" --locked --release -p ndf-cli -- "$@" diff --git a/src/ndf/sql/schema-v1.sql b/sql/schema-v1.sql similarity index 100% rename from src/ndf/sql/schema-v1.sql rename to sql/schema-v1.sql diff --git a/src/ndf/__init__.py b/src/ndf/__init__.py deleted file mode 100644 index eec17b0..0000000 --- a/src/ndf/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Normative Document Format knowledge compiler.""" - -from .version import TOOL_VERSION - -__all__ = ["TOOL_VERSION"] diff --git a/src/ndf/__main__.py b/src/ndf/__main__.py deleted file mode 100644 index a80190f..0000000 --- a/src/ndf/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .cli import entrypoint - - -if __name__ == "__main__": - entrypoint() diff --git a/src/ndf/adapters/__init__.py b/src/ndf/adapters/__init__.py deleted file mode 100644 index a968069..0000000 --- a/src/ndf/adapters/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Project-specific adapters for canonical NDF graph construction.""" diff --git a/src/ndf/adapters/evidence.py b/src/ndf/adapters/evidence.py deleted file mode 100644 index ef59398..0000000 --- a/src/ndf/adapters/evidence.py +++ /dev/null @@ -1,89 +0,0 @@ -import json -from pathlib import Path - -from ndf.identity import NodeId, parse_uri -from ndf.manifest import ProjectManifest -from ndf.model import ( - Diagnostic, - DiagnosticSeverity, - Edge, - LifecycleStatus, - Node, - NodeKind, - ParseResult, - SourceSpan, -) - - -_REQUIRED = { - "format_version", - "id", - "command", - "result", - "tool_version", - "source_revision", - "timestamp", - "links", -} - - -def parse_evidence_manifest(path: Path, project: ProjectManifest) -> ParseResult: - relative = path.resolve().relative_to(project.root.resolve()).as_posix() - try: - record = json.loads(path.read_text()) - except json.JSONDecodeError as error: - return ParseResult( - diagnostics=( - Diagnostic( - "NDF-EVID-001", - DiagnosticSeverity.ERROR, - f"invalid evidence JSON: {error}", - SourceSpan(relative, error.lineno), - repair="Repair the JSON evidence manifest.", - ), - ) - ) - if not isinstance(record, dict) or set(record) != _REQUIRED: - return ParseResult( - diagnostics=( - Diagnostic( - "NDF-EVID-001", - DiagnosticSeverity.ERROR, - "evidence manifest does not match the required fields", - SourceSpan(relative, 1), - repair="Provide every reproducibility field and no unknown fields.", - ), - ) - ) - identity = NodeId(project.project, str(record["id"])) - attributes = tuple( - sorted( - (key, str(record[key])) - for key in ( - "command", - "result", - "tool_version", - "source_revision", - "timestamp", - ) - ) - ) - node = Node( - id=identity, - kind=NodeKind.EVIDENCE, - title=str(record["command"]), - source=SourceSpan(relative, 1), - status=LifecycleStatus.ACTIVE, - owner=project.project, - attributes=attributes, - ) - edges = tuple( - Edge( - source=parse_uri(link["clause"], current_project=project.project), - target=identity, - type=link["type"], - source_span=SourceSpan(relative, 1), - ) - for link in record["links"] - ) - return ParseResult((node,), edges, ()) diff --git a/src/ndf/adapters/pto_asl.py b/src/ndf/adapters/pto_asl.py deleted file mode 100644 index 0c63789..0000000 --- a/src/ndf/adapters/pto_asl.py +++ /dev/null @@ -1,419 +0,0 @@ -import json -import re -from pathlib import Path - -from ndf.identity import NodeId -from ndf.manifest import ProjectManifest -from ndf.model import ( - Diagnostic, - DiagnosticSeverity, - Edge, - Graph, - LifecycleStatus, - Node, - NodeKind, - ParseResult, - SourceSpan, -) - - -_CLAUSE_ID = re.compile(r"PTO-[A-Z0-9]+(?:-[A-Z0-9]+)*") -_REGION_BEGIN = re.compile(r"^// NDF-BEGIN: (PTO-[A-Z0-9]+(?:-[A-Z0-9]+)*)$") -_REGION_END = re.compile(r"^// NDF-END: (PTO-[A-Z0-9]+(?:-[A-Z0-9]+)*)$") -_REFERENCE = re.compile(r"\[\[(PTO-[A-Z0-9]+(?:-[A-Z0-9]+)*)\]\]") -_METADATA_PREFIX = "// ndf: " -_INSTRUCTION_PREFIX = "// PTO-INSTRUCTION: " -_KINDS = { - "intent": ("L0", NodeKind.ARCHITECTURE), - "contract": ("L1", NodeKind.REQUIREMENT), - "mechanism": ("L2", NodeKind.ARCHITECTURE), - "executable": ("L3", NodeKind.MODEL), -} -_LAYERS = { - "architecture", - "scalar", - "block", - "tile", - "state", - "memory", - "concurrency", -} -_STATUSES = { - "open": LifecycleStatus.OPEN, - "accepted": LifecycleStatus.ACTIVE, -} - - -def instruction_clause_id(surface: str, mnemonic: str) -> str: - surface_slug = re.sub(r"[^A-Z0-9]+", "-", surface.upper()).strip("-") - mnemonic_slug = re.sub(r"[^A-Z0-9]+", "-", mnemonic.upper()).strip("-") - if not surface_slug or not mnemonic_slug: - raise ValueError("instruction NDF identity requires surface and mnemonic") - return f"PTO-INST-{surface_slug}-{mnemonic_slug}" - - -def _relative_path(source: Path, project: ProjectManifest) -> str: - return source.resolve().relative_to(project.root.resolve()).as_posix() - - -def _diagnostic( - code: str, - message: str, - path: str, - line: int, - repair: str, - subject: NodeId | None = None, -) -> Diagnostic: - return Diagnostic( - code=code, - severity=DiagnosticSeverity.ERROR, - message=message, - primary=SourceSpan(path, line), - repair=repair, - subject=subject, - ) - - -def _parse_metadata( - raw: str, - path: str, - line: int, - subject: NodeId, -) -> tuple[dict[str, str], tuple[Diagnostic, ...]]: - values: dict[str, str] = {} - diagnostics: list[Diagnostic] = [] - for token in raw.split(): - if token.count("=") != 1: - diagnostics.append( - _diagnostic( - "NDF-ASL-002", - f"invalid NDF metadata token: {token}", - path, - line, - "Write metadata as one key=value token per field.", - subject, - ) - ) - continue - name, value = token.split("=", 1) - if name in values: - diagnostics.append( - _diagnostic( - "NDF-ASL-002", - f"duplicate NDF metadata field: {name}", - path, - line, - "Keep exactly one value for each metadata field.", - subject, - ) - ) - values[name] = value - - required = {"kind", "level", "layer", "status"} - for name in sorted(required - set(values)): - diagnostics.append( - _diagnostic( - "NDF-ASL-002", - f"missing NDF metadata field: {name}", - path, - line, - "Add all required PTO NDF metadata fields.", - subject, - ) - ) - for name in sorted(set(values) - required): - diagnostics.append( - _diagnostic( - "NDF-ASL-002", - f"unknown NDF metadata field: {name}", - path, - line, - "Remove the unsupported metadata field.", - subject, - ) - ) - - kind = values.get("kind") - if kind not in _KINDS: - if kind is not None: - diagnostics.append( - _diagnostic( - "NDF-ASL-002", - f"unknown NDF kind: {kind}", - path, - line, - "Use intent, contract, mechanism, or executable.", - subject, - ) - ) - elif values.get("level") != _KINDS[kind][0]: - diagnostics.append( - _diagnostic( - "NDF-ASL-002", - f"kind {kind} requires level {_KINDS[kind][0]}", - path, - line, - "Use the level assigned to the PTO kind.", - subject, - ) - ) - if "layer" in values and values["layer"] not in _LAYERS: - diagnostics.append( - _diagnostic( - "NDF-ASL-002", - f"unknown NDF layer: {values['layer']}", - path, - line, - "Use a registered PTO layer.", - subject, - ) - ) - if "status" in values and values["status"] not in _STATUSES: - diagnostics.append( - _diagnostic( - "NDF-ASL-002", - f"unknown NDF status: {values['status']}", - path, - line, - "Use open or accepted.", - subject, - ) - ) - return values, tuple(diagnostics) - - -def _instruction_node( - line: str, - path: str, - line_number: int, - project: ProjectManifest, -) -> tuple[Node | None, Diagnostic | None]: - try: - metadata = json.loads(line[len(_INSTRUCTION_PREFIX) :]) - surface = metadata["surface"] - mnemonic = metadata["mnemonic"] - local_id = instruction_clause_id(surface, mnemonic) - except (json.JSONDecodeError, KeyError, TypeError, ValueError) as error: - return ( - None, - _diagnostic( - "NDF-ASL-004", - f"invalid PTO instruction metadata: {error}", - path, - line_number, - "Provide JSON string fields surface and mnemonic.", - ), - ) - return ( - Node( - id=NodeId(project.project, local_id), - kind=NodeKind.DEFINITION, - title=f"{surface} {mnemonic} instruction", - source=SourceSpan(path, line_number), - domain=str(surface).lower(), - status=LifecycleStatus.ACTIVE, - owner="pto-spec", - attributes=(("surface", str(surface)), ("mnemonic", str(mnemonic))), - ), - None, - ) - - -def parse_pto_asl(path: Path, project: ProjectManifest) -> ParseResult: - return parse_pto_asl_text(path.read_text(), path, project) - - -def parse_pto_asl_text( - text: str, - source: Path, - project: ProjectManifest, -) -> ParseResult: - path = _relative_path(source, project) - graph = Graph() - edges: list[Edge] = [] - diagnostics: list[Diagnostic] = [] - active_id: str | None = None - active_line = 0 - metadata_line = 0 - metadata_raw: str | None = None - body_lines: list[str] = [] - region_invalid = False - - for line_number, line in enumerate(text.splitlines(), start=1): - if active_id is None and line.startswith(_INSTRUCTION_PREFIX): - node, diagnostic = _instruction_node(line, path, line_number, project) - if node: - graph = graph.add_node(node) - if diagnostic: - diagnostics.append(diagnostic) - continue - - begin = _REGION_BEGIN.fullmatch(line) - end = _REGION_END.fullmatch(line) - if begin: - if active_id is not None: - diagnostics.append( - _diagnostic( - "NDF-ASL-001", - f"nested NDF region: {begin.group(1)}", - path, - line_number, - "Close the active region before starting another.", - NodeId(project.project, active_id), - ) - ) - region_invalid = True - continue - active_id = begin.group(1) - active_line = line_number - metadata_line = 0 - metadata_raw = None - body_lines = [] - region_invalid = False - continue - if end: - if active_id is None: - diagnostics.append( - _diagnostic( - "NDF-ASL-001", - f"unmatched NDF end: {end.group(1)}", - path, - line_number, - "Remove the end marker or add its begin marker.", - ) - ) - continue - subject = NodeId(project.project, active_id) - if end.group(1) != active_id: - diagnostics.append( - _diagnostic( - "NDF-ASL-001", - f"mismatched NDF end {end.group(1)} for {active_id}", - path, - line_number, - "Use the active clause ID in the end marker.", - subject, - ) - ) - region_invalid = True - if metadata_raw is None: - diagnostics.append( - _diagnostic( - "NDF-ASL-002", - f"NDF clause {active_id} has no metadata", - path, - active_line, - "Add the required PTO NDF metadata line.", - subject, - ) - ) - values = {} - region_invalid = True - else: - values, metadata_diagnostics = _parse_metadata( - metadata_raw, - path, - metadata_line, - subject, - ) - diagnostics.extend(metadata_diagnostics) - region_invalid = region_invalid or bool(metadata_diagnostics) - body = "\n".join(body_lines).strip() - if not body: - diagnostics.append( - _diagnostic( - "NDF-ASL-003", - f"NDF clause {active_id} has an empty body", - path, - active_line, - "Add one or more ASL comment body lines.", - subject, - ) - ) - region_invalid = True - if not region_invalid: - kind = values["kind"] - node = Node( - id=subject, - kind=_KINDS[kind][1], - title=active_id.replace("PTO-", "").replace("-", " ").title(), - source=SourceSpan(path, active_line), - refinement=values["level"], - domain=values["layer"], - status=_STATUSES[values["status"]], - owner="pto-spec", - body=body, - attributes=(("pto_kind", kind),), - ) - graph = graph.add_node(node) - for target in _REFERENCE.findall(body): - edges.append( - Edge( - source=node.id, - target=NodeId(project.project, target), - type="references", - source_span=SourceSpan(path, active_line), - ) - ) - active_id = None - continue - - if active_id is None: - if line.startswith("// NDF-BEGIN:"): - diagnostics.append( - _diagnostic( - "NDF-ASL-001", - "invalid PTO NDF clause ID", - path, - line_number, - "Use a PTO-prefixed uppercase clause ID.", - ) - ) - continue - if line.startswith(_METADATA_PREFIX): - if metadata_raw is not None: - diagnostics.append( - _diagnostic( - "NDF-ASL-002", - "duplicate NDF metadata line", - path, - line_number, - "Keep exactly one metadata line per region.", - NodeId(project.project, active_id), - ) - ) - region_invalid = True - else: - metadata_raw = line[len(_METADATA_PREFIX) :] - metadata_line = line_number - elif line.startswith("//"): - body_lines.append(line[2:].removeprefix(" ")) - else: - diagnostics.append( - _diagnostic( - "NDF-ASL-003", - "NDF body line must be an ASL comment", - path, - line_number, - "Prefix normative body lines with //.", - NodeId(project.project, active_id), - ) - ) - region_invalid = True - - if active_id is not None: - diagnostics.append( - _diagnostic( - "NDF-ASL-001", - f"unterminated NDF clause {active_id}", - path, - active_line, - "Add the matching NDF-END marker.", - NodeId(project.project, active_id), - ) - ) - return ParseResult( - nodes=graph.nodes, - edges=tuple(edges), - diagnostics=graph.diagnostics + tuple(diagnostics), - ) diff --git a/src/ndf/adapters/repository.py b/src/ndf/adapters/repository.py deleted file mode 100644 index 1b4cfa1..0000000 --- a/src/ndf/adapters/repository.py +++ /dev/null @@ -1,108 +0,0 @@ -import hashlib -import re -import subprocess -from pathlib import Path -from collections.abc import Sequence - -from ndf.identity import NodeId, parse_uri -from ndf.manifest import ProjectManifest -from ndf.model import Edge, LifecycleStatus, Node, NodeKind, ParseResult, SourceSpan - - -_LINKS = re.compile(r"^\s*(?:#|//)\s*ndf:\s*(.*?)\s*$") -_EDGE_TYPES = {"implements", "verifies"} - - -def _revision(root: Path) -> str: - result = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=root, - text=True, - capture_output=True, - check=False, - ) - value = result.stdout.strip() - return value if len(value) == 40 else "0" * 40 - - -def _kind(path: Path) -> NodeKind: - if "tests" in path.parts or path.name.startswith("test_"): - return NodeKind.TEST - return NodeKind.SOURCE - - -def _derived_id(project: str, path: str, kind: NodeKind) -> str: - digest = hashlib.sha256(f"{project}\0{kind.value}\0{path}".encode()).hexdigest() - prefix = "TEST" if kind is NodeKind.TEST else "SRC" - return f"{prefix}-{digest[:16].upper()}" - - -def _edges( - node: Node, - content: str, - project: ProjectManifest, -) -> tuple[Edge, ...]: - edges: list[Edge] = [] - for line_number, line in enumerate(content.splitlines(), start=1): - match = _LINKS.match(line) - if not match: - continue - for token in match.group(1).split(): - if "=" not in token: - continue - edge_type, targets = token.split("=", 1) - if edge_type not in _EDGE_TYPES: - continue - for target in targets.split(","): - edges.append( - Edge( - source=node.id, - target=parse_uri(target, current_project=project.project), - type=edge_type, - source_span=SourceSpan(node.source.path, line_number), - ) - ) - return tuple(edges) - - -def index_repository_paths( - root: Path, - globs: Sequence[str], - project: ProjectManifest, -) -> ParseResult: - paths = sorted( - { - path - for pattern in globs - for path in root.glob(pattern) - if path.is_file() - }, - key=lambda path: path.relative_to(root).as_posix(), - ) - revision = _revision(root) - nodes: list[Node] = [] - edges: list[Edge] = [] - for path in paths: - relative = path.relative_to(root).as_posix() - content = path.read_text() - kind = _kind(Path(relative)) - node = Node( - id=NodeId(project.project, _derived_id(project.project, relative, kind)), - kind=kind, - title=relative, - source=SourceSpan(relative, 1), - status=LifecycleStatus.ACTIVE, - owner=project.project, - attributes=tuple( - sorted( - { - "path": relative, - "revision": revision, - "content_hash": hashlib.sha256(content.encode()).hexdigest(), - }.items() - ) - ), - ) - nodes.append(node) - edges.extend(_edges(node, content, project)) - return ParseResult(tuple(nodes), tuple(edges), ()) diff --git a/src/ndf/baseline.py b/src/ndf/baseline.py deleted file mode 100644 index 679d3b0..0000000 --- a/src/ndf/baseline.py +++ /dev/null @@ -1,135 +0,0 @@ -import hashlib -import re -from dataclasses import dataclass -from datetime import date -from pathlib import PurePosixPath -from collections.abc import Iterable - -from .identity import parse_uri -from .model import Diagnostic, DiagnosticSeverity, SourceSpan - - -_WHITESPACE = re.compile(r"\s+") -_SEVERITY_RANK = { - DiagnosticSeverity.INFORMATION: 0, - DiagnosticSeverity.WARNING: 1, - DiagnosticSeverity.ERROR: 2, -} - - -def _normalized_path(value: str) -> str: - return PurePosixPath(value.replace("\\", "/")).as_posix() - - -def diagnostic_fingerprint(diagnostic: Diagnostic) -> str: - payload = "\0".join( - ( - diagnostic.code, - str(diagnostic.subject) if diagnostic.subject else "", - _normalized_path(diagnostic.primary.path), - _WHITESPACE.sub(" ", diagnostic.message).strip(), - ) - ) - return hashlib.sha256(payload.encode()).hexdigest() - - -@dataclass(frozen=True) -class BaselineEntry: - fingerprint: str - code: str - severity: DiagnosticSeverity - subject: str - path: str - owner: str | None - expires: str | None - - @classmethod - def from_diagnostic( - cls, - diagnostic: Diagnostic, - *, - owner: str, - expires: str, - ) -> "BaselineEntry": - return cls( - fingerprint=diagnostic_fingerprint(diagnostic), - code=diagnostic.code, - severity=diagnostic.severity, - subject=str(diagnostic.subject) if diagnostic.subject else "", - path=_normalized_path(diagnostic.primary.path), - owner=owner, - expires=expires, - ) - - -@dataclass(frozen=True) -class DiagnosticBaseline: - entries: tuple[BaselineEntry, ...] = () - - -@dataclass(frozen=True) -class BaselineResult: - existing: tuple[Diagnostic, ...] = () - new: tuple[Diagnostic, ...] = () - worsened: tuple[Diagnostic, ...] = () - expired: tuple[BaselineEntry, ...] = () - - -def _integrity_diagnostic(entry: BaselineEntry) -> Diagnostic: - subject = parse_uri(entry.subject) if entry.subject else None - return Diagnostic( - code="NDF-BASE-003", - severity=DiagnosticSeverity.ERROR, - message=f"baseline entry {entry.fingerprint} lacks owner or expiry", - primary=SourceSpan(entry.path or "ndf-baseline.json", 1), - repair="Assign an owner and ISO expiry date to the baseline entry.", - subject=subject, - ) - - -def compare_baseline( - current: Iterable[Diagnostic], - baseline: DiagnosticBaseline, -) -> BaselineResult: - by_fingerprint: dict[str, BaselineEntry] = {} - invalid_fingerprints: set[str] = set() - integrity: list[Diagnostic] = [] - expired: list[BaselineEntry] = [] - today = date.today() - - for entry in baseline.entries: - if not entry.owner or not entry.expires: - invalid_fingerprints.add(entry.fingerprint) - integrity.append(_integrity_diagnostic(entry)) - continue - try: - expiry = date.fromisoformat(entry.expires) - except ValueError: - invalid_fingerprints.add(entry.fingerprint) - integrity.append(_integrity_diagnostic(entry)) - continue - if expiry < today: - expired.append(entry) - by_fingerprint[entry.fingerprint] = entry - - existing: list[Diagnostic] = [] - new: list[Diagnostic] = list(integrity) - worsened: list[Diagnostic] = [] - for diagnostic in current: - fingerprint = diagnostic_fingerprint(diagnostic) - if fingerprint in invalid_fingerprints: - continue - entry = by_fingerprint.get(fingerprint) - if entry is None: - new.append(diagnostic) - elif _SEVERITY_RANK[diagnostic.severity] > _SEVERITY_RANK[entry.severity]: - worsened.append(diagnostic) - else: - existing.append(diagnostic) - - return BaselineResult( - existing=tuple(existing), - new=tuple(new), - worsened=tuple(worsened), - expired=tuple(expired), - ) diff --git a/src/ndf/cli.py b/src/ndf/cli.py deleted file mode 100644 index 874c9b6..0000000 --- a/src/ndf/cli.py +++ /dev/null @@ -1,270 +0,0 @@ -import argparse -import json -import subprocess -from collections.abc import Sequence -from pathlib import Path - -from .diff import semantic_diff -from .dependencies import resolve_dependencies -from .export import ExportFormat, export_graph -from .fix import FixMode, safe_fix -from .id_allocator import allocate_id -from .index import BuildProvenance, build_index, open_index -from .manifest import ProjectManifest, load_lock, load_manifest -from .model import DiagnosticSeverity, Graph -from .parsers.markdown import parse_markdown -from .query import trace -from .report import CoveragePolicy, coverage -from .rules import ValidationPolicy, validate_graph -from .version import ( - CLI_STABILITY, - FORMAT_VERSION, - IR_VERSION, - PLUGIN_API, - TOOL_VERSION, -) - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="ndf") - parser.add_argument("--machine-version", action="store_true") - commands = parser.add_subparsers(dest="command") - - build = commands.add_parser("build") - build.add_argument("--root", type=Path, required=True) - build.add_argument("--output", type=Path, required=True) - build.add_argument("--format", choices=("json",), default="json") - - check = commands.add_parser("check") - check.add_argument("--root", type=Path, required=True) - check.add_argument("--format", choices=("json",), default="json") - - show = commands.add_parser("show") - show.add_argument("uri") - show.add_argument("--index", type=Path, default=Path("graph.sqlite")) - show.add_argument("--format", choices=("json",), default="json") - - trace_command = commands.add_parser("trace") - trace_command.add_argument("uri") - trace_command.add_argument("--edge", action="append", default=[]) - trace_command.add_argument("--index", type=Path, default=Path("graph.sqlite")) - trace_command.add_argument("--format", choices=("json",), default="json") - - report = commands.add_parser("report") - report.add_argument("report_name", choices=("coverage", "dependencies")) - report.add_argument("--index", type=Path, default=Path("graph.sqlite")) - report.add_argument("--root", type=Path) - report.add_argument("--format", choices=("json",), default="json") - - diff = commands.add_parser("diff") - diff.add_argument("before", type=Path) - diff.add_argument("after", type=Path) - diff.add_argument("--format", choices=("json",), default="json") - - export = commands.add_parser("export") - export.add_argument("--index", type=Path, required=True) - export.add_argument("--output", type=Path, required=True) - export.add_argument("--format", choices=("ndf-json",), required=True) - - identity = commands.add_parser("id") - identity.add_argument("id_command", choices=("allocate",)) - identity.add_argument("prefix") - identity.add_argument("--index", type=Path, default=Path("graph.sqlite")) - identity.add_argument("--format", choices=("json",), default="json") - - fix = commands.add_parser("fix") - fix.add_argument("paths", nargs="*", type=Path) - fix.add_argument("--generated-only", action="store_true") - fix.add_argument("--check", action="store_true") - return parser - - -def _version_payload() -> dict[str, str]: - return { - "cli_stability": CLI_STABILITY, - "format_version": FORMAT_VERSION, - "ir_version": IR_VERSION, - "plugin_api": PLUGIN_API, - "tool_version": TOOL_VERSION, - } - - -def _emit(command: str, ok: bool, data: object, diagnostics: Sequence[dict] = ()) -> int: - print( - json.dumps( - { - "schema_version": "0.1", - "command": command, - "ok": ok, - "data": data, - "diagnostics": list(diagnostics), - }, - sort_keys=True, - ) - ) - return 0 if ok else 1 - - -def _project_graph(root: Path) -> tuple[ProjectManifest, Graph]: - manifest = load_manifest(root / "ndf.yaml") - graph = Graph() - edges = [] - diagnostics = [] - for pattern in manifest.roots: - for path in sorted(root.glob(pattern)): - if not path.is_file(): - continue - result = parse_markdown(path, manifest) - for node in result.nodes: - graph = graph.add_node(node) - edges.extend(result.edges) - diagnostics.extend(result.diagnostics) - parsed = Graph( - nodes=graph.nodes, - edges=tuple(edges), - diagnostics=graph.diagnostics + tuple(diagnostics), - ) - validated = validate_graph(parsed, ValidationPolicy()) - return manifest, Graph(parsed.nodes, parsed.edges, validated) - - -def _revision(root: Path) -> str: - result = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=root, - text=True, - capture_output=True, - check=False, - ) - revision = result.stdout.strip() - return revision if len(revision) == 40 else "0" * 40 - - -def _normative_roots(root: Path, manifest: ProjectManifest) -> tuple[Path, ...]: - roots = set() - for pattern in manifest.roots: - prefix = pattern.split("*", 1)[0].rstrip("/") - roots.add(root / prefix if prefix else root) - return tuple(sorted(roots)) - - -def _build_provenance(root: Path, manifest: ProjectManifest) -> BuildProvenance: - return BuildProvenance( - project_commits=((manifest.project, _revision(root)),), - format_version=FORMAT_VERSION, - ir_version=IR_VERSION, - tool_version=TOOL_VERSION, - normative_roots=_normative_roots(root, manifest), - ) - - -def _diagnostics(graph: Graph) -> list[dict]: - return [diagnostic.to_dict() for diagnostic in graph.diagnostics] - - -def _has_errors(graph: Graph) -> bool: - return any( - diagnostic.severity is DiagnosticSeverity.ERROR - for diagnostic in graph.diagnostics - ) - - -def _run(args: argparse.Namespace) -> int: - if args.command == "build": - manifest, graph = _project_graph(args.root) - build = build_index( - graph, - args.output, - _build_provenance(args.root, manifest), - ) - return _emit( - "build", - not _has_errors(graph), - { - "output": str(build.output), - "semantic_hash": build.semantic_hash, - "node_count": build.node_count, - "edge_count": build.edge_count, - }, - _diagnostics(graph), - ) - if args.command == "check": - _, graph = _project_graph(args.root) - return _emit( - "check", - not _has_errors(graph), - {"node_count": len(graph.nodes), "edge_count": len(graph.edges)}, - _diagnostics(graph), - ) - if args.command == "show": - with open_index(args.index) as index: - uri = index.resolve_uri(args.uri) - node = index.get_node(uri) - if node is None: - raise ValueError(f"unknown NDF ID: {args.uri}") - return _emit("show", True, node.to_dict()) - if args.command == "trace": - edge_types = frozenset(args.edge or ["refines"]) - with open_index(args.index) as index: - return _emit("trace", True, trace(index, args.uri, edge_types).to_dict()) - if args.command == "report": - if args.report_name == "dependencies": - if args.root is None: - raise ValueError("--root is required for dependency reports") - manifest = load_manifest(args.root / "ndf.yaml") - lock = load_lock(args.root / "ndf.lock") - resolved = resolve_dependencies(manifest, lock, args.root) - return _emit( - "report dependencies", - True, - [project.to_dict() for project in resolved], - ) - with open_index(args.index) as index: - report = coverage(index, CoveragePolicy()) - return _emit("report coverage", True, report.to_dict()) - if args.command == "diff": - with open_index(args.before) as before, open_index(args.after) as after: - return _emit("diff", True, semantic_diff(before, after).to_dict()) - if args.command == "export": - with open_index(args.index) as index: - result = export_graph(index, args.output, ExportFormat(args.format)) - return _emit("export", True, result.to_dict()) - if args.command == "id": - occupied: tuple[str, ...] = () - if args.index.exists(): - with open_index(args.index) as index: - occupied = tuple(node.id.local_id for node in index.all_nodes()) - return _emit( - "id allocate", - True, - {"id": allocate_id(args.prefix, occupied)}, - ) - if args.command == "fix": - paths = tuple(args.paths) or tuple(sorted(Path.cwd().rglob("*.md"))) - result = safe_fix( - paths, - FixMode(generated_only=args.generated_only, check=args.check), - ) - return _emit( - "fix", - not result.diagnostics, - {"changed": list(result.changed)}, - [diagnostic.to_dict() for diagnostic in result.diagnostics], - ) - raise ValueError("a command is required") - - -def main(argv: Sequence[str] | None = None) -> int: - args = _parser().parse_args(argv) - if args.machine_version: - print(json.dumps(_version_payload(), sort_keys=True)) - return 0 - try: - return _run(args) - except Exception as error: - command = args.command or "unknown" - return _emit(command, False, {}, ({"message": str(error)},)) - - -def entrypoint() -> None: - raise SystemExit(main()) diff --git a/src/ndf/dependencies.py b/src/ndf/dependencies.py deleted file mode 100644 index 20b11ce..0000000 --- a/src/ndf/dependencies.py +++ /dev/null @@ -1,69 +0,0 @@ -import re -import subprocess -from dataclasses import dataclass -from pathlib import Path - -from .manifest import ProjectLock, ProjectManifest - - -_REVISION = re.compile(r"^[0-9a-f]{40}$") - - -@dataclass(frozen=True) -class ResolvedProject: - project_id: str - path: Path - revision: str - dirty: bool - - def to_dict(self) -> dict: - return { - "project_id": self.project_id, - "path": self.path.as_posix(), - "revision": self.revision, - "dirty": self.dirty, - } - - -def _git(path: Path, *arguments: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["git", *arguments], - cwd=path, - text=True, - capture_output=True, - check=False, - ) - - -def resolve_dependencies( - manifest: ProjectManifest, - lock: ProjectLock, - workspace: Path, -) -> tuple[ResolvedProject, ...]: - locked = {dependency.project: dependency for dependency in lock.dependencies} - policy = dict(manifest.policies).get("ndf", {}) - allow_dirty = bool(policy.get("allow_dirty", False)) if isinstance(policy, dict) else False - resolved: list[ResolvedProject] = [] - for project in sorted(manifest.dependencies): - dependency = locked.get(project) - if dependency is None: - raise ValueError(f"NDF-DEP-001: dependency is missing from lock: {project}") - if not _REVISION.fullmatch(dependency.revision): - raise ValueError(f"NDF-DEP-002: dependency revision is not an exact commit: {project}") - path = (workspace / dependency.path).resolve() - if not path.is_dir(): - raise ValueError(f"NDF-DEP-001: dependency checkout is missing: {project}") - revision_result = _git(path, "rev-parse", "HEAD") - if revision_result.returncode != 0: - raise ValueError(f"NDF-DEP-001: dependency is not a Git checkout: {project}") - revision = revision_result.stdout.strip() - if revision != dependency.revision: - raise ValueError( - f"NDF-DEP-003: dependency revision mismatch for {project}: " - f"expected {dependency.revision}, got {revision}" - ) - dirty = bool(_git(path, "status", "--porcelain").stdout.strip()) - if dirty and not allow_dirty: - raise ValueError(f"NDF-DEP-004: dependency checkout is dirty: {project}") - resolved.append(ResolvedProject(project, path, revision, dirty)) - return tuple(resolved) diff --git a/src/ndf/diff.py b/src/ndf/diff.py deleted file mode 100644 index 294db45..0000000 --- a/src/ndf/diff.py +++ /dev/null @@ -1,95 +0,0 @@ -from dataclasses import dataclass - -from .index import NdfIndex, NodeRecord - - -EdgeTuple = tuple[str, str, str] - - -@dataclass(frozen=True) -class SemanticDiff: - added: tuple[str, ...] - removed: tuple[str, ...] - modified: tuple[str, ...] - moved: tuple[str, ...] - superseded: tuple[str, ...] - edge_only: tuple[str, ...] - edges_added: tuple[EdgeTuple, ...] - edges_removed: tuple[EdgeTuple, ...] - - def to_dict(self) -> dict: - return { - "added": list(self.added), - "removed": list(self.removed), - "modified": list(self.modified), - "moved": list(self.moved), - "superseded": list(self.superseded), - "edge_only": list(self.edge_only), - "edges_added": [list(edge) for edge in self.edges_added], - "edges_removed": [list(edge) for edge in self.edges_removed], - } - - -def _semantic_record(node: NodeRecord) -> tuple: - return ( - node.kind, - node.title, - node.modality, - node.refinement, - node.domain, - node.status, - node.owner, - node.body, - ) - - -def _edge_set(index: NdfIndex) -> set[EdgeTuple]: - return { - (str(edge.source), edge.type, str(edge.target)) - for edge in index.all_edges() - } - - -def semantic_diff(before: NdfIndex, after: NdfIndex) -> SemanticDiff: - before_nodes = {str(node.id): node for node in before.all_nodes()} - after_nodes = {str(node.id): node for node in after.all_nodes()} - before_uris = set(before_nodes) - after_uris = set(after_nodes) - added = after_uris - before_uris - removed = before_uris - after_uris - modified: set[str] = set() - moved: set[str] = set() - superseded: set[str] = set() - - for uri in before_uris & after_uris: - old = before_nodes[uri] - new = after_nodes[uri] - if old.status != "superseded" and new.status == "superseded": - superseded.add(uri) - continue - if _semantic_record(old) != _semantic_record(new): - modified.add(uri) - elif old.source != new.source: - moved.add(uri) - - old_edges = _edge_set(before) - new_edges = _edge_set(after) - edges_added = new_edges - old_edges - edges_removed = old_edges - new_edges - edge_nodes = { - endpoint - for source, _, target in edges_added | edges_removed - for endpoint in (source, target) - } - classified = added | removed | modified | moved | superseded - edge_only = edge_nodes - classified - return SemanticDiff( - tuple(sorted(added)), - tuple(sorted(removed)), - tuple(sorted(modified)), - tuple(sorted(moved)), - tuple(sorted(superseded)), - tuple(sorted(edge_only)), - tuple(sorted(edges_added)), - tuple(sorted(edges_removed)), - ) diff --git a/src/ndf/export.py b/src/ndf/export.py deleted file mode 100644 index 958f6d1..0000000 --- a/src/ndf/export.py +++ /dev/null @@ -1,104 +0,0 @@ -import json -from dataclasses import dataclass -from enum import StrEnum -from pathlib import Path, PurePosixPath - -from .index import NdfIndex - - -class ExportFormat(StrEnum): - NDF_JSON = "ndf-json" - - -@dataclass(frozen=True) -class ExportResult: - output: Path - semantic_hash: str - node_count: int - edge_count: int - - def to_dict(self) -> dict: - return { - "output": str(self.output), - "semantic_hash": self.semantic_hash, - "node_count": self.node_count, - "edge_count": self.edge_count, - } - - -def _portable_path(value: str) -> str: - path = Path(value) - if path.is_absolute(): - return PurePosixPath("", path.name).as_posix() - return PurePosixPath(value.replace("\\", "/")).as_posix() - - -def _portable_node(record: dict) -> dict: - record["source"]["path"] = _portable_path(record["source"]["path"]) - return record - - -def _portable_edge(record: dict) -> dict: - if record["source_span"]: - record["source_span"]["path"] = _portable_path( - record["source_span"]["path"] - ) - return record - - -def export_graph( - index: NdfIndex, - output: Path, - format: ExportFormat, -) -> ExportResult: - if format is not ExportFormat.NDF_JSON: - raise ValueError(f"unsupported export format: {format}") - provenance = index.provenance() - diagnostics = [] - for record in index.diagnostic_records(): - diagnostics.append( - { - "code": record["code"], - "severity": record["severity"], - "message": record["message"], - "primary": { - "path": _portable_path(record["source_path"]), - "line": record["line"], - "column": 1, - "end_line": None, - "end_column": None, - }, - "related": [], - "repair": record["repair"], - "subject": record["subject_uri"], - } - ) - payload = { - "schema_version": "0.1", - "nodes": [_portable_node(node.to_dict()) for node in index.all_nodes()], - "edges": [_portable_edge(edge.to_dict()) for edge in index.all_edges()], - "diagnostics": diagnostics, - "provenance": { - **provenance, - "projects": index.project_revisions(), - }, - } - schema_path = Path(__file__).resolve().parents[2] / "schemas/ndf-ir.schema.json" - schema = json.loads(schema_path.read_text()) - missing = set(schema["required"]) - set(payload) - if missing: - raise ValueError(f"NDF export is missing schema fields: {sorted(missing)}") - encoded = json.dumps( - payload, - sort_keys=True, - indent=2, - ensure_ascii=False, - ) + "\n" - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(encoded) - return ExportResult( - output=output, - semantic_hash=provenance["semantic_hash"], - node_count=len(payload["nodes"]), - edge_count=len(payload["edges"]), - ) diff --git a/src/ndf/fix.py b/src/ndf/fix.py deleted file mode 100644 index a8bb1e5..0000000 --- a/src/ndf/fix.py +++ /dev/null @@ -1,95 +0,0 @@ -import re -from collections.abc import Sequence -from dataclasses import dataclass -from pathlib import Path - -from .model import Diagnostic, DiagnosticSeverity, SourceSpan - - -_METADATA = re.compile(r"^(\s*)(\s*)$", re.MULTILINE) -_SEMANTIC_FIELDS = { - "prose", - "authority", - "status", - "kind", - "modality", - "refinement", - "domain", - "edges", -} - - -@dataclass(frozen=True) -class FixMode: - generated_only: bool = False - check: bool = False - requested_changes: tuple[str, ...] = () - - -@dataclass(frozen=True) -class FixResult: - changed: tuple[str, ...] - diagnostics: tuple[Diagnostic, ...] - - def to_dict(self) -> dict: - return { - "changed": list(self.changed), - "diagnostics": [item.to_dict() for item in self.diagnostics], - } - - -def _refusal(fields: set[str]) -> Diagnostic: - return Diagnostic( - code="NDF-FIX-001", - severity=DiagnosticSeverity.ERROR, - message=f"safe fix cannot alter semantic fields: {', '.join(sorted(fields))}", - primary=SourceSpan("", 1), - repair="Edit normative semantics explicitly and request review.", - ) - - -def _sort_metadata(match: re.Match[str]) -> str: - tokens = match.group(2).split() - return f"{match.group(1)}{match.group(3)}" - - -def _sort_generated_blocks(content: str) -> str: - start_marker = "" - end_marker = "" - position = 0 - pieces: list[str] = [] - while True: - start = content.find(start_marker, position) - if start < 0: - pieces.append(content[position:]) - break - end = content.find(end_marker, start + len(start_marker)) - if end < 0: - pieces.append(content[position:]) - break - body_start = start + len(start_marker) - body = content[body_start:end] - lines = sorted(line for line in body.splitlines() if line.strip()) - pieces.append(content[position:body_start]) - pieces.append("\n" + "\n".join(lines) + "\n") - position = end - return "".join(pieces) - - -def safe_fix(paths: Sequence[Path], mode: FixMode) -> FixResult: - forbidden = set(mode.requested_changes) & _SEMANTIC_FIELDS - if forbidden: - return FixResult((), (_refusal(forbidden),)) - - changed: list[str] = [] - for path in sorted(paths): - content = path.read_text() - updated = _sort_generated_blocks(content) - if not mode.generated_only: - updated = _METADATA.sub(_sort_metadata, updated) - if updated == content: - continue - changed.append(str(path)) - if not mode.check: - path.write_text(updated) - return FixResult(tuple(changed), ()) diff --git a/src/ndf/id_allocator.py b/src/ndf/id_allocator.py deleted file mode 100644 index 0f6b490..0000000 --- a/src/ndf/id_allocator.py +++ /dev/null @@ -1,18 +0,0 @@ -import re -from collections.abc import Iterable - - -_PREFIX = re.compile(r"^[A-Z][A-Z0-9-]*[A-Z0-9]$") - - -def allocate_id(prefix: str, occupied: Iterable[str]) -> str: - if not _PREFIX.fullmatch(prefix): - raise ValueError(f"invalid ID prefix: {prefix!r}") - suffixes: list[int] = [] - pattern = re.compile(rf"^{re.escape(prefix)}-(\d+)$") - for value in occupied: - match = pattern.fullmatch(value) - if match: - suffixes.append(int(match.group(1))) - next_suffix = max(suffixes, default=0) + 1 - return f"{prefix}-{next_suffix:03d}" diff --git a/src/ndf/identity.py b/src/ndf/identity.py deleted file mode 100644 index 44f2c5e..0000000 --- a/src/ndf/identity.py +++ /dev/null @@ -1,53 +0,0 @@ -import re -from dataclasses import dataclass -from urllib.parse import urlsplit - - -_PROJECT_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$") -_LOCAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") - - -def _validate_project_id(value: str) -> None: - if not _PROJECT_ID.fullmatch(value): - raise ValueError(f"invalid project ID: {value!r}") - - -def _validate_local_id(value: str) -> None: - if not _LOCAL_ID.fullmatch(value): - raise ValueError(f"invalid local ID: {value!r}") - - -def canonical_uri(project_id: str, local_id: str) -> str: - _validate_project_id(project_id) - _validate_local_id(local_id) - return f"ndf://{project_id}/{local_id}" - - -@dataclass(frozen=True, order=True) -class NodeId: - project_id: str - local_id: str - - def __post_init__(self) -> None: - _validate_project_id(self.project_id) - _validate_local_id(self.local_id) - - def __str__(self) -> str: - return canonical_uri(self.project_id, self.local_id) - - -def parse_uri(value: str, current_project: str | None = None) -> NodeId: - if not value.startswith("ndf://"): - if current_project is None: - raise ValueError("current project is required for a local NDF ID") - return NodeId(current_project, value) - - parsed = urlsplit(value) - if parsed.scheme != "ndf" or not parsed.netloc: - raise ValueError(f"invalid NDF URI: {value!r}") - if parsed.query or parsed.fragment: - raise ValueError(f"invalid NDF URI: {value!r}") - local_id = parsed.path.removeprefix("/") - if not local_id or "/" in local_id: - raise ValueError(f"invalid NDF URI: {value!r}") - return NodeId(parsed.netloc, local_id) diff --git a/src/ndf/index.py b/src/ndf/index.py deleted file mode 100644 index 71797ea..0000000 --- a/src/ndf/index.py +++ /dev/null @@ -1,412 +0,0 @@ -import hashlib -import json -import os -import sqlite3 -import tempfile -from dataclasses import dataclass -from importlib import resources -from pathlib import Path - -from .baseline import diagnostic_fingerprint -from .identity import NodeId, parse_uri -from .model import Edge, Graph, SourceSpan - - -@dataclass(frozen=True) -class BuildProvenance: - project_commits: tuple[tuple[str, str], ...] - format_version: str - ir_version: str - tool_version: str - normative_roots: tuple[Path, ...] = () - - -@dataclass(frozen=True) -class IndexBuild: - output: Path - semantic_hash: str - node_count: int - edge_count: int - diagnostics: tuple - - -@dataclass(frozen=True) -class NodeRecord: - id: NodeId - kind: str - title: str - source: SourceSpan - modality: str | None - refinement: str | None - domain: str | None - status: str - owner: str | None - body: str - - def to_dict(self) -> dict: - return { - "id": str(self.id), - "kind": self.kind, - "title": self.title, - "source": self.source.to_dict(), - "modality": self.modality, - "refinement": self.refinement, - "domain": self.domain, - "status": self.status, - "owner": self.owner, - "body": self.body, - "attributes": {}, - } - - -IndexedNode = NodeRecord - - -@dataclass(frozen=True) -class IndexedEdge: - source: NodeId - target: NodeId - type: str - source_span: SourceSpan | None - - def to_dict(self) -> dict: - return { - "source": str(self.source), - "target": str(self.target), - "type": self.type, - "source_span": self.source_span.to_dict() if self.source_span else None, - "attributes": {}, - } - - -class NdfIndex: - def __init__(self, path: Path) -> None: - self.path = path - self._connection = sqlite3.connect(path) - self._connection.row_factory = sqlite3.Row - self._connection.execute("PRAGMA foreign_keys = ON") - - def __enter__(self) -> "NdfIndex": - return self - - def __exit__(self, *_: object) -> None: - self.close() - - def close(self) -> None: - self._connection.close() - - def foreign_keys_enabled(self) -> bool: - row = self._connection.execute("PRAGMA foreign_keys").fetchone() - return bool(row[0]) - - def get_node(self, uri: str) -> NodeRecord | None: - row = self._connection.execute( - "SELECT * FROM nodes WHERE uri = ?", - (str(parse_uri(uri)),), - ).fetchone() - if row is None: - return None - return self._node_record(row) - - @staticmethod - def _node_record(row: sqlite3.Row) -> NodeRecord: - return NodeRecord( - id=parse_uri(row["uri"]), - kind=row["kind"], - title=row["title"], - source=SourceSpan( - row["source_path"], - row["line"], - row["column_number"], - ), - modality=row["modality"], - refinement=row["refinement"], - domain=row["domain"], - status=row["status"], - owner=row["owner"], - body=row["body"], - ) - - def all_nodes(self) -> tuple[NodeRecord, ...]: - rows = self._connection.execute("SELECT * FROM nodes ORDER BY uri") - return tuple(self._node_record(row) for row in rows) - - def resolve_uri(self, value: str) -> str: - if value.startswith("ndf://"): - return str(parse_uri(value)) - rows = self._connection.execute( - "SELECT uri FROM nodes WHERE local_id = ? ORDER BY uri", - (value,), - ).fetchall() - if not rows: - raise ValueError(f"unknown NDF ID: {value}") - if len(rows) > 1: - raise ValueError(f"ambiguous local NDF ID: {value}") - return rows[0]["uri"] - - def out_edges( - self, - uri: str, - edge_type: str | None = None, - ) -> tuple[IndexedEdge, ...]: - return self._edges("source_uri", uri, edge_type) - - def in_edges( - self, - uri: str, - edge_type: str | None = None, - ) -> tuple[IndexedEdge, ...]: - return self._edges("target_uri", uri, edge_type) - - def _edges( - self, - direction: str, - uri: str, - edge_type: str | None, - ) -> tuple[IndexedEdge, ...]: - parameters: tuple[str, ...] - if edge_type is None: - query = ( - f"SELECT * FROM edges WHERE {direction} = ? " - "ORDER BY source_uri, edge_type, target_uri, edge_id" - ) - parameters = (str(parse_uri(uri)),) - else: - query = ( - f"SELECT * FROM edges WHERE {direction} = ? AND edge_type = ? " - "ORDER BY source_uri, edge_type, target_uri, edge_id" - ) - parameters = (str(parse_uri(uri)), edge_type) - return tuple(self._indexed_edge(row) for row in self._connection.execute(query, parameters)) - - @staticmethod - def _indexed_edge(row: sqlite3.Row) -> IndexedEdge: - span = ( - SourceSpan(row["source_path"], row["line"]) - if row["source_path"] is not None - else None - ) - return IndexedEdge( - source=parse_uri(row["source_uri"]), - target=parse_uri(row["target_uri"]), - type=row["edge_type"], - source_span=span, - ) - - def provenance(self) -> dict[str, str]: - row = self._connection.execute( - "SELECT format_version, ir_version, semantic_hash, tool_version " - "FROM builds WHERE build_id = 1" - ).fetchone() - if row is None: - raise ValueError("index has no build provenance") - return dict(row) - - def all_edges(self) -> tuple[IndexedEdge, ...]: - rows = self._connection.execute( - "SELECT * FROM edges ORDER BY source_uri, edge_type, target_uri, edge_id" - ) - return tuple(self._indexed_edge(row) for row in rows) - - def diagnostic_records(self) -> tuple[dict, ...]: - rows = self._connection.execute( - "SELECT * FROM diagnostics ORDER BY diagnostic_id" - ) - return tuple(dict(row) for row in rows) - - def project_revisions(self) -> dict[str, str]: - rows = self._connection.execute( - "SELECT project_id, revision FROM projects ORDER BY project_id" - ) - return {row["project_id"]: row["revision"] for row in rows} - - -def open_index(path: Path) -> NdfIndex: - return NdfIndex(path) - - -def _semantic_hash(graph: Graph, provenance: BuildProvenance) -> str: - payload = { - "format_version": provenance.format_version, - "graph": graph.to_dict(), - "ir_version": provenance.ir_version, - "project_commits": sorted(provenance.project_commits), - "tool_version": provenance.tool_version, - } - encoded = json.dumps( - payload, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - ).encode() - return hashlib.sha256(encoded).hexdigest() - - -def _validate_build( - graph: Graph, - output: Path, - provenance: BuildProvenance, -) -> None: - revisions = dict(provenance.project_commits) - missing = sorted( - {node.id.project_id for node in graph.nodes if node.id.project_id not in revisions} - ) - if missing: - raise ValueError(f"missing project revision for: {missing[0]}") - resolved_output = output.resolve() - for root in provenance.normative_roots: - if resolved_output.is_relative_to(root.resolve()): - raise ValueError(f"refusing to write SQLite index inside normative root: {root}") - - -def _source_paths(graph: Graph) -> tuple[str, ...]: - paths = {node.source.path for node in graph.nodes} - for edge in graph.edges: - if edge.source_span: - paths.add(edge.source_span.path) - for diagnostic in graph.diagnostics: - paths.add(diagnostic.primary.path) - paths.update(span.path for span in diagnostic.related) - return tuple(sorted(paths)) - - -def _insert_graph( - connection: sqlite3.Connection, - graph: Graph, - provenance: BuildProvenance, - semantic_hash: str, -) -> None: - connection.executemany( - "INSERT INTO projects(project_id, revision) VALUES (?, ?)", - sorted(provenance.project_commits), - ) - connection.executemany( - "INSERT INTO sources(path) VALUES (?)", - ((path,) for path in _source_paths(graph)), - ) - - for node in graph.sorted_nodes(): - connection.execute( - """INSERT INTO nodes( - uri, project_id, local_id, kind, title, source_path, line, - column_number, modality, refinement, domain, status, owner, body - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - str(node.id), - node.id.project_id, - node.id.local_id, - node.kind.value, - node.title, - node.source.path, - node.source.line, - node.source.column, - node.modality.value if node.modality else None, - node.refinement, - node.domain, - node.status.value, - node.owner, - node.body, - ), - ) - connection.executemany( - "INSERT INTO node_attrs(node_uri, key, value) VALUES (?, ?, ?)", - ((str(node.id), key, value) for key, value in sorted(node.attributes)), - ) - - for edge_id, edge in enumerate(graph.sorted_edges(), start=1): - connection.execute( - """INSERT INTO edges( - edge_id, source_uri, edge_type, target_uri, source_path, line, - attributes_json - ) VALUES (?, ?, ?, ?, ?, ?, ?)""", - ( - edge_id, - str(edge.source), - edge.type, - str(edge.target), - edge.source_span.path if edge.source_span else None, - edge.source_span.line if edge.source_span else None, - json.dumps(dict(edge.attributes), sort_keys=True, separators=(",", ":")), - ), - ) - - ordered_diagnostics = sorted( - graph.diagnostics, - key=lambda item: ( - item.primary.path, - item.primary.line, - item.code, - str(item.subject) if item.subject else "", - ), - ) - for diagnostic_id, diagnostic in enumerate(ordered_diagnostics, start=1): - connection.execute( - """INSERT INTO diagnostics( - diagnostic_id, fingerprint, code, severity, message, subject_uri, - source_path, line, repair - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - diagnostic_id, - diagnostic_fingerprint(diagnostic), - diagnostic.code, - diagnostic.severity.value, - diagnostic.message, - str(diagnostic.subject) if diagnostic.subject else None, - diagnostic.primary.path, - diagnostic.primary.line, - diagnostic.repair, - ), - ) - - connection.execute( - """INSERT INTO builds( - build_id, schema_version, semantic_hash, format_version, ir_version, - tool_version - ) VALUES (1, '1', ?, ?, ?, ?)""", - ( - semantic_hash, - provenance.format_version, - provenance.ir_version, - provenance.tool_version, - ), - ) - - -def build_index( - graph: Graph, - output: Path, - provenance: BuildProvenance, -) -> IndexBuild: - _validate_build(graph, output, provenance) - output.parent.mkdir(parents=True, exist_ok=True) - semantic_hash = _semantic_hash(graph, provenance) - schema = resources.files("ndf").joinpath("sql/schema-v1.sql").read_text() - temporary_file = tempfile.NamedTemporaryFile( - prefix=f".{output.name}.", - suffix=".tmp", - dir=output.parent, - delete=False, - ) - temporary_path = Path(temporary_file.name) - temporary_file.close() - - try: - with sqlite3.connect(temporary_path) as connection: - connection.execute("PRAGMA foreign_keys = ON") - connection.executescript(schema) - _insert_graph(connection, graph, provenance, semantic_hash) - violations = connection.execute("PRAGMA foreign_key_check").fetchall() - if violations: - raise ValueError(f"SQLite foreign key violations: {violations}") - os.replace(temporary_path, output) - except Exception: - temporary_path.unlink(missing_ok=True) - raise - - return IndexBuild( - output=output, - semantic_hash=semantic_hash, - node_count=len(graph.nodes), - edge_count=len(graph.edges), - diagnostics=graph.diagnostics, - ) diff --git a/src/ndf/manifest.py b/src/ndf/manifest.py deleted file mode 100644 index e7cf041..0000000 --- a/src/ndf/manifest.py +++ /dev/null @@ -1,173 +0,0 @@ -import re -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -import yaml - - -_COMMIT = re.compile(r"^[0-9a-f]{40}$") - - -class _UniqueKeyLoader(yaml.SafeLoader): - pass - - -def _construct_mapping( - loader: _UniqueKeyLoader, - node: yaml.MappingNode, - deep: bool = False, -) -> dict[str, Any]: - mapping: dict[str, Any] = {} - for key_node, value_node in node.value: - key = loader.construct_object(key_node, deep=deep) - if key in mapping: - raise ValueError(f"duplicate YAML key: {key}") - mapping[key] = loader.construct_object(value_node, deep=deep) - return mapping - - -_UniqueKeyLoader.add_constructor( - yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, - _construct_mapping, -) - - -@dataclass(frozen=True) -class ProjectManifest: - format_version: str - project: str - roots: tuple[str, ...] - id_prefixes: tuple[str, ...] - domains: tuple[str, ...] - policies: tuple[tuple[str, Any], ...] - root: Path - dependencies: tuple[str, ...] = () - - -@dataclass(frozen=True) -class LockedDependency: - project: str - uri: str - revision: str - path: str - - -@dataclass(frozen=True) -class ProjectLock: - format_version: str - dependencies: tuple[LockedDependency, ...] - - -def _load_mapping(path: Path) -> dict[str, Any]: - try: - value = yaml.load(path.read_text(), Loader=_UniqueKeyLoader) - except yaml.YAMLError as error: - raise ValueError(f"invalid YAML in {path}: {error}") from error - if not isinstance(value, dict): - raise ValueError(f"expected a YAML mapping in {path}") - return value - - -def _required(mapping: dict[str, Any], field: str) -> Any: - if field not in mapping: - raise ValueError(f"missing required field: {field}") - return mapping[field] - - -def _string_tuple(value: Any, field: str) -> tuple[str, ...]: - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - raise ValueError(f"{field} must be a list of strings") - return tuple(value) - - -def load_manifest(path: Path) -> ProjectManifest: - mapping = _load_mapping(path) - allowed = { - "format_version", - "project", - "roots", - "id_prefixes", - "domains", - "policies", - "dependencies", - } - unknown = sorted(set(mapping) - allowed) - if unknown: - raise ValueError(f"unsupported manifest field: {unknown[0]}") - - format_version = _required(mapping, "format_version") - project = _required(mapping, "project") - roots = _string_tuple(_required(mapping, "roots"), "roots") - id_prefixes = _string_tuple( - _required(mapping, "id_prefixes"), - "id_prefixes", - ) - domains = _string_tuple(mapping.get("domains", []), "domains") - policies = mapping.get("policies", {}) - dependencies = mapping.get("dependencies", {}) - if not isinstance(format_version, str): - raise ValueError("format_version must be a string") - if not isinstance(project, str): - raise ValueError("project must be a string") - if not isinstance(policies, dict): - raise ValueError("policies must be a mapping") - if not isinstance(dependencies, dict): - raise ValueError("dependencies must be a mapping") - for name, declaration in dependencies.items(): - if not isinstance(name, str) or not isinstance(declaration, dict): - raise ValueError("each dependency declaration must be a named mapping") - if set(declaration) != {"path"} or not isinstance(declaration["path"], str): - raise ValueError("dependency declaration must contain only a string path") - - return ProjectManifest( - format_version=format_version, - project=project, - roots=roots, - id_prefixes=id_prefixes, - domains=domains, - policies=tuple(sorted(policies.items())), - root=path.parent.resolve(), - dependencies=tuple(sorted(dependencies)), - ) - - -def load_lock(path: Path) -> ProjectLock: - mapping = _load_mapping(path) - allowed = {"format_version", "dependencies"} - unknown = sorted(set(mapping) - allowed) - if unknown: - raise ValueError(f"unsupported lock field: {unknown[0]}") - - format_version = _required(mapping, "format_version") - dependencies = _required(mapping, "dependencies") - if not isinstance(format_version, str): - raise ValueError("format_version must be a string") - if not isinstance(dependencies, dict): - raise ValueError("dependencies must be a mapping") - - normalized: list[LockedDependency] = [] - for project, value in sorted(dependencies.items()): - if not isinstance(project, str) or not isinstance(value, dict): - raise ValueError("each dependency must be a named mapping") - unknown_dependency_fields = sorted(set(value) - {"uri", "revision", "path"}) - if unknown_dependency_fields: - raise ValueError( - f"unsupported dependency field: {unknown_dependency_fields[0]}" - ) - uri = _required(value, "uri") - revision = _required(value, "revision") - dependency_path = _required(value, "path") - if not all(isinstance(item, str) for item in (uri, revision, dependency_path)): - raise ValueError("dependency uri, revision, and path must be strings") - if not _COMMIT.fullmatch(revision): - raise ValueError("dependency revision must be 40 lowercase hexadecimal characters") - normalized.append( - LockedDependency( - project=project, - uri=uri, - revision=revision, - path=dependency_path, - ) - ) - return ProjectLock(format_version, tuple(normalized)) diff --git a/src/ndf/model.py b/src/ndf/model.py deleted file mode 100644 index f3755e6..0000000 --- a/src/ndf/model.py +++ /dev/null @@ -1,204 +0,0 @@ -from dataclasses import dataclass -from enum import StrEnum -from typing import Any - -from .identity import NodeId - - -class NodeKind(StrEnum): - DOCUMENT = "document" - REQUIREMENT = "requirement" - DEFINITION = "definition" - ARCHITECTURE = "architecture" - CONSTRAINT = "constraint" - OPTION = "option" - VERIFICATION = "verification" - DECISION = "decision" - QUESTION = "question" - MODEL = "model" - SOURCE = "source" - TEST = "test" - EVIDENCE = "evidence" - INFORMATION = "information" - - -class Modality(StrEnum): - MUST = "must" - SHOULD = "should" - MAY = "may" - TBD = "tbd" - - -class LifecycleStatus(StrEnum): - DRAFT = "draft" - ACTIVE = "active" - DEPRECATED = "deprecated" - SUPERSEDED = "superseded" - RETIRED = "retired" - OPEN = "open" - RESOLVED = "resolved" - - -class DiagnosticSeverity(StrEnum): - INFORMATION = "information" - WARNING = "warning" - ERROR = "error" - - -@dataclass(frozen=True, order=True) -class SourceSpan: - path: str - line: int - column: int = 1 - end_line: int | None = None - end_column: int | None = None - - def __post_init__(self) -> None: - if self.line < 1: - raise ValueError("line must be positive") - if self.column < 1: - raise ValueError("column must be positive") - if self.end_line is not None and self.end_line < self.line: - raise ValueError("end line must not precede start line") - if self.end_column is not None and self.end_column < 1: - raise ValueError("end column must be positive") - - def to_dict(self) -> dict[str, Any]: - return { - "path": self.path, - "line": self.line, - "column": self.column, - "end_line": self.end_line, - "end_column": self.end_column, - } - - -@dataclass(frozen=True) -class Node: - id: NodeId - kind: NodeKind - title: str - source: SourceSpan - modality: Modality | None = None - refinement: str | None = None - domain: str | None = None - status: LifecycleStatus = LifecycleStatus.DRAFT - owner: str | None = None - body: str = "" - attributes: tuple[tuple[str, str], ...] = () - - def to_dict(self) -> dict[str, Any]: - return { - "id": str(self.id), - "kind": self.kind.value, - "title": self.title, - "source": self.source.to_dict(), - "modality": self.modality.value if self.modality else None, - "refinement": self.refinement, - "domain": self.domain, - "status": self.status.value, - "owner": self.owner, - "body": self.body, - "attributes": dict(self.attributes), - } - - -@dataclass(frozen=True) -class Edge: - source: NodeId - target: NodeId - type: str - source_span: SourceSpan | None = None - attributes: tuple[tuple[str, str], ...] = () - - def __post_init__(self) -> None: - if not self.type.strip(): - raise ValueError("edge type must not be empty") - - def to_dict(self) -> dict[str, Any]: - return { - "source": str(self.source), - "target": str(self.target), - "type": self.type, - "source_span": self.source_span.to_dict() if self.source_span else None, - "attributes": dict(self.attributes), - } - - -@dataclass(frozen=True) -class Diagnostic: - code: str - severity: DiagnosticSeverity - message: str - primary: SourceSpan - related: tuple[SourceSpan, ...] = () - repair: str | None = None - subject: NodeId | None = None - - def to_dict(self) -> dict[str, Any]: - return { - "code": self.code, - "severity": self.severity.value, - "message": self.message, - "primary": self.primary.to_dict(), - "related": [span.to_dict() for span in self.related], - "repair": self.repair, - "subject": str(self.subject) if self.subject else None, - } - - -@dataclass(frozen=True) -class Graph: - nodes: tuple[Node, ...] = () - edges: tuple[Edge, ...] = () - diagnostics: tuple[Diagnostic, ...] = () - - def add_node(self, node: Node) -> "Graph": - existing = next((item for item in self.nodes if item.id == node.id), None) - if existing is None: - return Graph(self.nodes + (node,), self.edges, self.diagnostics) - diagnostic = Diagnostic( - code="NDF-ID-001", - severity=DiagnosticSeverity.ERROR, - message=f"duplicate canonical NDF ID: {node.id}", - primary=node.source, - related=(existing.source,), - repair="Assign a unique explicit ID or remove the duplicate clause.", - ) - return Graph(self.nodes, self.edges, self.diagnostics + (diagnostic,)) - - def sorted_nodes(self) -> tuple[Node, ...]: - return tuple(sorted(self.nodes, key=lambda node: str(node.id))) - - def sorted_edges(self) -> tuple[Edge, ...]: - return tuple( - sorted( - self.edges, - key=lambda edge: (str(edge.source), edge.type, str(edge.target)), - ) - ) - - def to_dict(self) -> dict[str, Any]: - return { - "schema_version": "0.1", - "nodes": [node.to_dict() for node in self.sorted_nodes()], - "edges": [edge.to_dict() for edge in self.sorted_edges()], - "diagnostics": [ - diagnostic.to_dict() - for diagnostic in sorted( - self.diagnostics, - key=lambda item: ( - item.primary.path, - item.primary.line, - item.code, - ), - ) - ], - } - - -@dataclass(frozen=True) -class ParseResult: - nodes: tuple[Node, ...] = () - edges: tuple[Edge, ...] = () - diagnostics: tuple[Diagnostic, ...] = () diff --git a/src/ndf/parsers/__init__.py b/src/ndf/parsers/__init__.py deleted file mode 100644 index 2a6a806..0000000 --- a/src/ndf/parsers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Source adapters that normalize authored documents into the NDF graph.""" diff --git a/src/ndf/parsers/markdown.py b/src/ndf/parsers/markdown.py deleted file mode 100644 index 369a097..0000000 --- a/src/ndf/parsers/markdown.py +++ /dev/null @@ -1,444 +0,0 @@ -import re -import shlex -from pathlib import Path -from typing import Any - -import yaml - -from ndf.identity import NodeId, parse_uri -from ndf.manifest import ProjectManifest, _UniqueKeyLoader -from ndf.model import ( - Diagnostic, - DiagnosticSeverity, - Edge, - Graph, - LifecycleStatus, - Modality, - Node, - NodeKind, - ParseResult, - SourceSpan, -) - - -_HEADING = re.compile(r"^(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z0-9._:-]+)\})?\s*$") -_FENCE = re.compile(r"^\s*(`{3,}|~{3,})") -_METADATA_PREFIX = ""): - return ( - None, - ( - _diagnostic( - "NDF-MD-001", - DiagnosticSeverity.ERROR, - "NDF metadata comment is not closed", - relative_path, - line_number, - "Close the metadata comment with --> on the same line.", - ), - ), - ) - if not (stripped.startswith(_METADATA_PREFIX) and stripped.endswith("-->")): - return None, () - - content = stripped[len(_METADATA_PREFIX) : -3].strip() - try: - tokens = shlex.split(content) - except ValueError as error: - return ( - None, - ( - _diagnostic( - "NDF-MD-001", - DiagnosticSeverity.ERROR, - f"invalid NDF metadata delimiter: {error}", - relative_path, - line_number, - "Repair quoting and keep metadata on one line.", - ), - ), - ) - - metadata: dict[str, str] = {} - for token in tokens: - if "=" not in token: - return ( - None, - ( - _diagnostic( - "NDF-MD-001", - DiagnosticSeverity.ERROR, - f"invalid NDF metadata token: {token}", - relative_path, - line_number, - "Write metadata as key=value tokens.", - ), - ), - ) - key, value = token.split("=", 1) - if key in metadata: - return ( - None, - ( - _diagnostic( - "NDF-MD-002", - DiagnosticSeverity.ERROR, - f"duplicate NDF metadata key: {key}", - relative_path, - line_number, - "Keep exactly one value for each metadata key.", - ), - ), - ) - metadata[key] = value - - unknown = sorted(set(metadata) - _CANONICAL_FIELDS - _LEGACY_FIELDS) - if unknown: - return ( - None, - ( - _diagnostic( - "NDF-MD-003", - DiagnosticSeverity.ERROR, - f"unsupported NDF metadata field: {unknown[0]}", - relative_path, - line_number, - "Remove the field or register it in the project format.", - ), - ), - ) - return metadata, () - - -def _normalize_metadata( - metadata: dict[str, str], - relative_path: str, - line_number: int, -) -> tuple[dict[str, str], tuple[Diagnostic, ...]]: - normalized = dict(metadata) - migrated = False - if normalized.get("kind") == "req": - normalized["kind"] = "requirement" - migrated = True - if "level" in normalized: - normalized["modality"] = normalized.pop("level") - migrated = True - if "layer" in normalized: - normalized["refinement"] = normalized.pop("layer") - migrated = True - if normalized.get("status") == "stable": - normalized["status"] = "active" - migrated = True - if not migrated: - return normalized, () - return ( - normalized, - ( - _diagnostic( - "NDF-MIG-001", - DiagnosticSeverity.INFORMATION, - "legacy DavinciOO metadata was mapped to canonical fields", - relative_path, - line_number, - "Rewrite the clause with kind, modality, refinement, and status.", - ), - ), - ) - - -def _body(lines: list[str], start: int) -> str: - collected: list[str] = [] - fence: str | None = None - for line in lines[start:]: - fence_match = _FENCE.match(line) - if fence_match: - marker = fence_match.group(1) - if fence is None: - fence = marker[0] - elif marker[0] == fence: - fence = None - collected.append(line) - continue - if fence is None and _HEADING.match(line): - break - collected.append(line) - return "\n".join(collected).strip() - - -def parse_markdown(path: Path, project: ProjectManifest) -> ParseResult: - relative_path = _relative_path(path, project) - lines = path.read_text().splitlines() - front_matter, content_start, front_diagnostics = _front_matter(lines, relative_path) - if front_matter is None: - return ParseResult(diagnostics=front_diagnostics) - - required_front = {"doc_id", "status", "authority", "owner"} - missing_front = sorted(required_front - set(front_matter)) - if missing_front: - return ParseResult( - diagnostics=( - _diagnostic( - "NDF-MD-003", - DiagnosticSeverity.ERROR, - f"missing front matter field: {missing_front[0]}", - relative_path, - 1, - "Add all required document front matter fields.", - ), - ) - ) - unknown_front = sorted(set(front_matter) - required_front) - if unknown_front: - return ParseResult( - diagnostics=( - _diagnostic( - "NDF-MD-003", - DiagnosticSeverity.ERROR, - f"unsupported front matter field: {unknown_front[0]}", - relative_path, - 1, - "Remove unsupported document front matter.", - ), - ) - ) - - title = str(front_matter["doc_id"]) - fence: str | None = None - for line in lines[content_start:]: - fence_match = _FENCE.match(line) - if fence_match: - marker = fence_match.group(1) - if fence is None: - fence = marker[0] - elif marker[0] == fence: - fence = None - continue - heading = _HEADING.match(line) - if fence is None and heading and len(heading.group(1)) == 1: - title = heading.group(2) - break - - try: - document_status = LifecycleStatus(str(front_matter["status"])) - except ValueError: - return ParseResult( - diagnostics=( - _diagnostic( - "NDF-MD-004", - DiagnosticSeverity.ERROR, - f"invalid document status: {front_matter['status']}", - relative_path, - 1, - "Use a canonical lifecycle status.", - ), - ) - ) - - document = Node( - id=NodeId(project.project, str(front_matter["doc_id"])), - kind=NodeKind.DOCUMENT, - title=title, - source=SourceSpan(relative_path, 1), - status=document_status, - owner=str(front_matter["owner"]), - attributes=(("authority", str(front_matter["authority"])),), - ) - graph = Graph().add_node(document) - edges: list[Edge] = [] - diagnostics: list[Diagnostic] = list(front_diagnostics) - fence = None - - for index, line in enumerate(lines[content_start:], start=content_start): - fence_match = _FENCE.match(line) - if fence_match: - marker = fence_match.group(1) - if fence is None: - fence = marker[0] - elif marker[0] == fence: - fence = None - continue - if fence is not None: - continue - heading = _HEADING.match(line) - if not heading or not heading.group(3): - continue - heading_line = index + 1 - metadata_index = index + 1 - if metadata_index >= len(lines): - continue - metadata, metadata_diagnostics = _parse_metadata( - lines[metadata_index], - relative_path, - metadata_index + 1, - ) - diagnostics.extend(metadata_diagnostics) - if metadata is None: - continue - normalized, migration_diagnostics = _normalize_metadata( - metadata, - relative_path, - metadata_index + 1, - ) - diagnostics.extend(migration_diagnostics) - try: - node = Node( - id=NodeId(project.project, heading.group(3)), - kind=NodeKind(normalized.get("kind", "information")), - title=heading.group(2), - source=SourceSpan(relative_path, heading_line), - modality=( - Modality(normalized["modality"]) - if "modality" in normalized - else None - ), - refinement=normalized.get("refinement"), - domain=normalized.get("domain"), - status=LifecycleStatus(normalized.get("status", "draft")), - owner=normalized.get("owner", str(front_matter["owner"])), - body=_body(lines, metadata_index + 1), - ) - except ValueError as error: - diagnostics.append( - _diagnostic( - "NDF-MD-004", - DiagnosticSeverity.ERROR, - f"invalid canonical metadata value: {error}", - relative_path, - metadata_index + 1, - "Use canonical kind, modality, and lifecycle values.", - ) - ) - continue - graph = graph.add_node(node) - for edge_type in sorted(_EDGE_FIELDS): - if edge_type not in normalized: - continue - for target in normalized[edge_type].split(","): - edges.append( - Edge( - source=node.id, - target=parse_uri(target.strip(), current_project=project.project), - type=edge_type, - source_span=SourceSpan(relative_path, metadata_index + 1), - ) - ) - - return ParseResult( - nodes=graph.nodes, - edges=tuple(edges), - diagnostics=graph.diagnostics + tuple(diagnostics), - ) diff --git a/src/ndf/plugins.py b/src/ndf/plugins.py deleted file mode 100644 index 05bf83a..0000000 --- a/src/ndf/plugins.py +++ /dev/null @@ -1,27 +0,0 @@ -from dataclasses import dataclass -from typing import Any - -from .manifest import ProjectManifest - - -@dataclass(frozen=True) -class Rule: - namespace: str - name: str - value: Any - - -def load_project_rules(manifest: ProjectManifest) -> tuple[Rule, ...]: - rules: list[Rule] = [] - for namespace, configuration in manifest.policies: - if namespace != "ndf": - raise ValueError( - f"NDF-PLUGIN-001: unsupported rule namespace: {namespace}" - ) - if not isinstance(configuration, dict): - raise ValueError( - "NDF-PLUGIN-001: rule namespace configuration must be a mapping" - ) - for name, value in sorted(configuration.items()): - rules.append(Rule(namespace, name, value)) - return tuple(rules) diff --git a/src/ndf/query.py b/src/ndf/query.py deleted file mode 100644 index 1f7adf8..0000000 --- a/src/ndf/query.py +++ /dev/null @@ -1,92 +0,0 @@ -from dataclasses import dataclass - -from .index import IndexedEdge, NdfIndex, NodeRecord - - -_QUERY_FIELDS = { - "kind", - "modality", - "refinement", - "domain", - "status", - "owner", -} - - -def query_nodes(index: NdfIndex, expression: str) -> tuple[NodeRecord, ...]: - filters: list[tuple[str, str]] = [] - for clause in expression.split(" and "): - if "=" not in clause: - raise ValueError(f"invalid query clause: {clause}") - field, value = (part.strip() for part in clause.split("=", 1)) - if field not in _QUERY_FIELDS: - raise ValueError(f"unsupported query field: {field}") - filters.append((field, value)) - return tuple( - node - for node in index.all_nodes() - if all(getattr(node, field) == value for field, value in filters) - ) - - -@dataclass(frozen=True) -class TraceEdge: - source: str - type: str - target: str - - @classmethod - def from_indexed(cls, edge: IndexedEdge) -> "TraceEdge": - return cls(str(edge.source), edge.type, str(edge.target)) - - def to_dict(self) -> dict[str, str]: - return {"source": self.source, "type": self.type, "target": self.target} - - -@dataclass(frozen=True) -class TraceTree: - root: str - nodes: tuple[str, ...] - edges: tuple[TraceEdge, ...] - cycles: tuple[TraceEdge, ...] - - def to_dict(self) -> dict: - return { - "root": self.root, - "nodes": list(self.nodes), - "edges": [edge.to_dict() for edge in self.edges], - "cycles": [edge.to_dict() for edge in self.cycles], - } - - -def trace( - index: NdfIndex, - uri: str, - edge_types: frozenset[str], -) -> TraceTree: - root = index.resolve_uri(uri) - visited: set[str] = set() - active: set[str] = set() - nodes: list[str] = [] - edges: list[TraceEdge] = [] - cycles: list[TraceEdge] = [] - - def visit(current: str) -> None: - if current not in visited: - visited.add(current) - nodes.append(current) - active.add(current) - for indexed_edge in index.out_edges(current): - if indexed_edge.type not in edge_types: - continue - edge = TraceEdge.from_indexed(indexed_edge) - edges.append(edge) - if edge.target in active: - cycles.append(edge) - continue - if edge.target not in visited: - visit(edge.target) - active.remove(current) - - visit(root) - return TraceTree(root, tuple(nodes), tuple(edges), tuple(cycles)) diff --git a/src/ndf/report.py b/src/ndf/report.py deleted file mode 100644 index b40658c..0000000 --- a/src/ndf/report.py +++ /dev/null @@ -1,47 +0,0 @@ -from dataclasses import dataclass - -from .index import NdfIndex - - -@dataclass(frozen=True) -class CoveragePolicy: - refinement: str = "L1" - - -@dataclass(frozen=True) -class CoverageReport: - unverified: tuple[str, ...] - open_items: tuple[str, ...] - tbd: tuple[str, ...] - - def to_dict(self) -> dict[str, list[str]]: - return { - "unverified": list(self.unverified), - "open_items": list(self.open_items), - "tbd": list(self.tbd), - } - - -def coverage(index: NdfIndex, policy: CoveragePolicy) -> CoverageReport: - unverified: list[str] = [] - open_items: list[str] = [] - tbd: list[str] = [] - for node in index.all_nodes(): - uri = str(node.id) - if ( - node.kind == "requirement" - and node.status == "active" - and node.modality == "must" - and node.refinement == policy.refinement - and not index.in_edges(uri, "verifies") - ): - unverified.append(uri) - if node.kind in {"question", "option"} and node.status == "open": - open_items.append(uri) - if node.modality == "tbd": - tbd.append(uri) - return CoverageReport( - tuple(sorted(unverified)), - tuple(sorted(open_items)), - tuple(sorted(tbd)), - ) diff --git a/src/ndf/rules.py b/src/ndf/rules.py deleted file mode 100644 index 03d2f9e..0000000 --- a/src/ndf/rules.py +++ /dev/null @@ -1,134 +0,0 @@ -import re -from dataclasses import dataclass, field - -from .model import ( - Diagnostic, - DiagnosticSeverity, - Graph, - LifecycleStatus, - Modality, - Node, - NodeKind, - SourceSpan, -) - - -_MUST = re.compile(r"\bMUST\b") - - -@dataclass(frozen=True) -class ValidationPolicy: - mandatory_edge_types: frozenset[str] = field( - default_factory=lambda: frozenset( - {"refines", "depends-on", "verifies", "implements"} - ) - ) - ownership_kinds: frozenset[NodeKind] = field( - default_factory=lambda: frozenset( - { - NodeKind.REQUIREMENT, - NodeKind.CONSTRAINT, - NodeKind.VERIFICATION, - NodeKind.DECISION, - NodeKind.QUESTION, - } - ) - ) - - -def _node_diagnostic( - code: str, - message: str, - node: Node, - repair: str, -) -> Diagnostic: - return Diagnostic( - code=code, - severity=DiagnosticSeverity.ERROR, - message=message, - primary=node.source, - repair=repair, - subject=node.id, - ) - - -def validate_graph( - graph: Graph, - policy: ValidationPolicy, -) -> tuple[Diagnostic, ...]: - diagnostics = list(graph.diagnostics) - nodes_by_id = {node.id: node for node in graph.nodes} - - for edge in graph.edges: - if edge.type not in policy.mandatory_edge_types: - continue - missing = edge.source not in nodes_by_id or edge.target not in nodes_by_id - if not missing: - continue - source = nodes_by_id.get(edge.source) - primary = edge.source_span or ( - source.source if source else SourceSpan("", 1) - ) - diagnostics.append( - Diagnostic( - code="NDF-EDGE-001", - severity=DiagnosticSeverity.ERROR, - message=f"mandatory edge {edge.type} has a dangling endpoint", - primary=primary, - repair="Resolve both endpoints or remove the mandatory edge.", - subject=edge.source, - ) - ) - - for node in graph.nodes: - if node.modality is Modality.MUST and not _MUST.search(node.body): - diagnostics.append( - _node_diagnostic( - "NDF-MOD-001", - f"must requirement {node.id} does not contain MUST", - node, - "Add normative MUST language or change the modality.", - ) - ) - if node.kind is NodeKind.INFORMATION and _MUST.search(node.body): - diagnostics.append( - _node_diagnostic( - "NDF-MOD-002", - f"information node {node.id} contains MUST", - node, - "Move the statement to a normative node or remove MUST.", - ) - ) - - previous_status = dict(node.attributes).get("previous_status") - if previous_status == LifecycleStatus.ACTIVE.value and node.status is LifecycleStatus.DRAFT: - diagnostics.append( - _node_diagnostic( - "NDF-LIFE-001", - f"node {node.id} cannot move from active to draft", - node, - "Deprecate or supersede the active node instead.", - ) - ) - - if node.kind in policy.ownership_kinds and not node.owner: - diagnostics.append( - _node_diagnostic( - "NDF-OWN-001", - f"explicit normative node {node.id} has no owner", - node, - "Assign an accountable owner.", - ) - ) - - return tuple( - sorted( - diagnostics, - key=lambda item: ( - item.primary.path, - item.primary.line, - item.code, - str(item.subject) if item.subject else "", - ), - ) - ) diff --git a/src/ndf/version.py b/src/ndf/version.py deleted file mode 100644 index c644996..0000000 --- a/src/ndf/version.py +++ /dev/null @@ -1,5 +0,0 @@ -TOOL_VERSION = "0.1.0" -FORMAT_VERSION = "0.2" -IR_VERSION = "0.1" -CLI_STABILITY = "experimental" -PLUGIN_API = "experimental" diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/fixtures/oracle/markdown-invalid/duplicate.md b/tests/fixtures/oracle/markdown-invalid/duplicate.md new file mode 100644 index 0000000..c9cf809 --- /dev/null +++ b/tests/fixtures/oracle/markdown-invalid/duplicate.md @@ -0,0 +1,13 @@ +--- +doc_id: DOC-FIXTURE-DUPLICATE +status: active +authority: normative +owner: core +--- + +# Duplicate metadata + +## Contract {#PIPE-DUPLICATE-001} + + +The core MUST stop. diff --git a/tests/fixtures/oracle/markdown-invalid/legacy.md b/tests/fixtures/oracle/markdown-invalid/legacy.md new file mode 100644 index 0000000..6521544 --- /dev/null +++ b/tests/fixtures/oracle/markdown-invalid/legacy.md @@ -0,0 +1,13 @@ +--- +doc_id: DOC-FIXTURE-LEGACY +status: active +authority: normative +owner: core +--- + +# Legacy metadata + +## Contract {#PIPE-LEGACY-001} + + +The core MUST stop. diff --git a/tests/fixtures/oracle/markdown-invalid/unclosed.md b/tests/fixtures/oracle/markdown-invalid/unclosed.md new file mode 100644 index 0000000..d4cfd06 --- /dev/null +++ b/tests/fixtures/oracle/markdown-invalid/unclosed.md @@ -0,0 +1,13 @@ +--- +doc_id: DOC-FIXTURE-UNCLOSED +status: active +authority: normative +owner: core +--- + +# Unclosed metadata + +## Contract {#PIPE-UNCLOSED-001} + + +The core MUST stop. diff --git a/tests/golden/identity.json b/tests/golden/identity.json new file mode 100644 index 0000000..40417dd --- /dev/null +++ b/tests/golden/identity.json @@ -0,0 +1,13 @@ +{ + "canonical": { + "local_id": "PTO-INST-TLOAD", + "project_id": "pto-spec", + "uri": "ndf://pto-spec/PTO-INST-TLOAD" + }, + "errors": { + "invalid_project": "invalid project ID: 'DavinciOO'", + "invalid_uri": "invalid NDF URI: 'ndf://pto-spec/path/with/slash'", + "missing_project": "current project is required for a local NDF ID" + }, + "local": "ndf://davincioo/VEC-ISSUE-001" +} diff --git a/tests/golden/index.json b/tests/golden/index.json new file mode 100644 index 0000000..9c0f028 --- /dev/null +++ b/tests/golden/index.json @@ -0,0 +1,77 @@ +{ + "edge_count": 1, + "node_count": 2, + "rows": { + "aliases": [], + "baselines": [], + "builds": [ + [ + 1, + "1", + "14aa2013a1ce4b0abf1c0907649a687071bfc61ebbd5e2c0959681ff88cac91d", + "0.2", + "0.1", + "0.1.0" + ] + ], + "diagnostics": [], + "edges": [ + [ + 1, + "ndf://fixture/PIPE-020", + "refines", + "ndf://fixture/PIPE-010", + "docs/pipeline.md", + 20, + "{}" + ] + ], + "node_attrs": [], + "nodes": [ + [ + "ndf://fixture/PIPE-010", + "fixture", + "PIPE-010", + "requirement", + "Contract", + "docs/pipeline.md", + 10, + 1, + null, + null, + null, + "active", + "core", + "The core MUST issue in order." + ], + [ + "ndf://fixture/PIPE-020", + "fixture", + "PIPE-020", + "architecture", + "Mechanism", + "docs/pipeline.md", + 20, + 1, + null, + null, + null, + "active", + "core", + "" + ] + ], + "projects": [ + [ + "fixture", + "1111111111111111111111111111111111111111" + ] + ], + "sources": [ + [ + "docs/pipeline.md" + ] + ] + }, + "semantic_hash": "14aa2013a1ce4b0abf1c0907649a687071bfc61ebbd5e2c0959681ff88cac91d" +} diff --git a/tests/golden/machine-version.json b/tests/golden/machine-version.json new file mode 100644 index 0000000..3f2631b --- /dev/null +++ b/tests/golden/machine-version.json @@ -0,0 +1,10 @@ +{ + "exit_code": 0, + "payload": { + "cli_stability": "experimental", + "format_version": "0.2", + "ir_version": "0.1", + "plugin_api": "experimental", + "tool_version": "0.1.0" + } +} diff --git a/tests/golden/markdown-invalid.json b/tests/golden/markdown-invalid.json new file mode 100644 index 0000000..13f3f23 --- /dev/null +++ b/tests/golden/markdown-invalid.json @@ -0,0 +1,197 @@ +{ + "duplicate.md": { + "diagnostics": [ + { + "code": "NDF-MD-002", + "message": "duplicate NDF metadata key: kind", + "primary": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 11, + "path": "duplicate.md" + }, + "related": [], + "repair": "Keep exactly one value for each metadata key.", + "severity": "error", + "subject": null + } + ], + "edges": [], + "nodes": [ + { + "attributes": { + "authority": "normative" + }, + "body": "", + "domain": null, + "id": "ndf://fixture/DOC-FIXTURE-DUPLICATE", + "kind": "document", + "modality": null, + "owner": "core", + "refinement": null, + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 1, + "path": "duplicate.md" + }, + "status": "active", + "title": "Duplicate metadata" + } + ], + "schema_version": "0.1" + }, + "legacy.md": { + "diagnostics": [ + { + "code": "NDF-MIG-001", + "message": "legacy DavinciOO metadata was mapped to canonical fields", + "primary": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 11, + "path": "legacy.md" + }, + "related": [], + "repair": "Rewrite the clause with kind, modality, refinement, and status.", + "severity": "information", + "subject": null + } + ], + "edges": [], + "nodes": [ + { + "attributes": { + "authority": "normative" + }, + "body": "", + "domain": null, + "id": "ndf://fixture/DOC-FIXTURE-LEGACY", + "kind": "document", + "modality": null, + "owner": "core", + "refinement": null, + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 1, + "path": "legacy.md" + }, + "status": "active", + "title": "Legacy metadata" + }, + { + "attributes": {}, + "body": "The core MUST stop.", + "domain": null, + "id": "ndf://fixture/PIPE-LEGACY-001", + "kind": "requirement", + "modality": "must", + "owner": "core", + "refinement": "L1", + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 10, + "path": "legacy.md" + }, + "status": "active", + "title": "Contract" + } + ], + "schema_version": "0.1" + }, + "unclosed.md": { + "diagnostics": [ + { + "code": "NDF-MD-001", + "message": "NDF metadata comment is not closed", + "primary": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 11, + "path": "unclosed.md" + }, + "related": [], + "repair": "Close the metadata comment with --> on the same line.", + "severity": "error", + "subject": null + } + ], + "edges": [], + "nodes": [ + { + "attributes": { + "authority": "normative" + }, + "body": "", + "domain": null, + "id": "ndf://fixture/DOC-FIXTURE-UNCLOSED", + "kind": "document", + "modality": null, + "owner": "core", + "refinement": null, + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 1, + "path": "unclosed.md" + }, + "status": "active", + "title": "Unclosed metadata" + } + ], + "schema_version": "0.1" + }, + "unsupported.md": { + "diagnostics": [ + { + "code": "NDF-MD-003", + "message": "unsupported NDF metadata field: priority", + "primary": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 11, + "path": "unsupported.md" + }, + "related": [], + "repair": "Remove the field or register it in the project format.", + "severity": "error", + "subject": null + } + ], + "edges": [], + "nodes": [ + { + "attributes": { + "authority": "normative" + }, + "body": "", + "domain": null, + "id": "ndf://fixture/DOC-FIXTURE-UNSUPPORTED", + "kind": "document", + "modality": null, + "owner": "core", + "refinement": null, + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 1, + "path": "unsupported.md" + }, + "status": "active", + "title": "Unsupported metadata" + } + ], + "schema_version": "0.1" + } +} diff --git a/tests/golden/markdown-valid.json b/tests/golden/markdown-valid.json new file mode 100644 index 0000000..e0862a8 --- /dev/null +++ b/tests/golden/markdown-valid.json @@ -0,0 +1,80 @@ +{ + "diagnostics": [], + "edges": [ + { + "attributes": {}, + "source": "ndf://fixture/PIPE-ISSUE-010", + "source_span": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 20, + "path": "docs/pipeline.md" + }, + "target": "ndf://fixture/PIPE-ISSUE-001", + "type": "refines" + } + ], + "nodes": [ + { + "attributes": { + "authority": "normative" + }, + "body": "", + "domain": null, + "id": "ndf://fixture/DOC-FIXTURE-PIPELINE", + "kind": "document", + "modality": null, + "owner": "core", + "refinement": null, + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 1, + "path": "docs/pipeline.md" + }, + "status": "active", + "title": "Pipeline" + }, + { + "attributes": {}, + "body": "The core MUST issue no more than two instructions per cycle.", + "domain": "core", + "id": "ndf://fixture/PIPE-ISSUE-001", + "kind": "requirement", + "modality": "must", + "owner": "core", + "refinement": "L1", + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 14, + "path": "docs/pipeline.md" + }, + "status": "active", + "title": "Issue contract" + }, + { + "attributes": {}, + "body": "Two issue slots MUST arbitrate oldest-ready first.", + "domain": "core", + "id": "ndf://fixture/PIPE-ISSUE-010", + "kind": "requirement", + "modality": "must", + "owner": "core", + "refinement": "L2", + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 19, + "path": "docs/pipeline.md" + }, + "status": "active", + "title": "Issue mechanism" + } + ], + "schema_version": "0.1" +} diff --git a/tests/golden/pto-asl.json b/tests/golden/pto-asl.json new file mode 100644 index 0000000..3448991 --- /dev/null +++ b/tests/golden/pto-asl.json @@ -0,0 +1,161 @@ +{ + "accept.asl": { + "diagnostics": [], + "edges": [ + { + "attributes": {}, + "source": "ndf://pto-spec/PTO-TILE-CAPACITY", + "source_span": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 3, + "path": "accept.asl" + }, + "target": "ndf://pto-spec/PTO-INST-TILE-TLOAD", + "type": "references" + } + ], + "nodes": [ + { + "attributes": { + "mnemonic": "TLOAD", + "surface": "tile" + }, + "body": "", + "domain": "tile", + "id": "ndf://pto-spec/PTO-INST-TILE-TLOAD", + "kind": "definition", + "modality": null, + "owner": "pto-spec", + "refinement": null, + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 1, + "path": "accept.asl" + }, + "status": "active", + "title": "tile TLOAD instruction" + }, + { + "attributes": { + "pto_kind": "contract" + }, + "body": "Tile capacity is defined per selected PE; see [[PTO-INST-TILE-TLOAD]].", + "domain": "tile", + "id": "ndf://pto-spec/PTO-TILE-CAPACITY", + "kind": "requirement", + "modality": null, + "owner": "pto-spec", + "refinement": "L1", + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 3, + "path": "accept.asl" + }, + "status": "active", + "title": "Tile Capacity" + }, + { + "attributes": { + "pto_kind": "mechanism" + }, + "body": "The implementation tracks capacity in tile state.", + "domain": "tile", + "id": "ndf://pto-spec/PTO-TILE-CAPACITY-MECHANISM", + "kind": "architecture", + "modality": null, + "owner": "pto-spec", + "refinement": "L2", + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 8, + "path": "accept.asl" + }, + "status": "open", + "title": "Tile Capacity Mechanism" + } + ], + "schema_version": "0.1" + }, + "reject-duplicate.asl": { + "diagnostics": [ + { + "code": "NDF-ASL-002", + "message": "duplicate NDF metadata field: status", + "primary": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 2, + "path": "reject-duplicate.asl" + }, + "related": [], + "repair": "Keep exactly one value for each metadata field.", + "severity": "error", + "subject": "ndf://pto-spec/PTO-TILE-CAPACITY" + } + ], + "edges": [], + "nodes": [], + "schema_version": "0.1" + }, + "reject-metadata.asl": { + "diagnostics": [ + { + "code": "NDF-ASL-002", + "message": "kind contract requires level L1", + "primary": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 2, + "path": "reject-metadata.asl" + }, + "related": [], + "repair": "Use the level assigned to the PTO kind.", + "severity": "error", + "subject": "ndf://pto-spec/PTO-TILE-CAPACITY" + }, + { + "code": "NDF-ASL-002", + "message": "unknown NDF layer: unknown", + "primary": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 2, + "path": "reject-metadata.asl" + }, + "related": [], + "repair": "Use a registered PTO layer.", + "severity": "error", + "subject": "ndf://pto-spec/PTO-TILE-CAPACITY" + }, + { + "code": "NDF-ASL-002", + "message": "unknown NDF status: final", + "primary": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 2, + "path": "reject-metadata.asl" + }, + "related": [], + "repair": "Use open or accepted.", + "severity": "error", + "subject": "ndf://pto-spec/PTO-TILE-CAPACITY" + } + ], + "edges": [], + "nodes": [], + "schema_version": "0.1" + } +} diff --git a/tests/golden/query-report.json b/tests/golden/query-report.json new file mode 100644 index 0000000..2d41403 --- /dev/null +++ b/tests/golden/query-report.json @@ -0,0 +1,64 @@ +{ + "coverage": { + "open_items": [], + "tbd": [], + "unverified": [ + "ndf://fixture/PIPE-ISSUE-001" + ] + }, + "query": [ + { + "attributes": {}, + "body": "The core MUST issue no more than two instructions per cycle.", + "domain": "core", + "id": "ndf://fixture/PIPE-ISSUE-001", + "kind": "requirement", + "modality": "must", + "owner": "core", + "refinement": "L1", + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 14, + "path": "docs/pipeline.md" + }, + "status": "active", + "title": "Issue contract" + }, + { + "attributes": {}, + "body": "Two issue slots MUST arbitrate oldest-ready first.", + "domain": "core", + "id": "ndf://fixture/PIPE-ISSUE-010", + "kind": "requirement", + "modality": "must", + "owner": "core", + "refinement": "L2", + "source": { + "column": 1, + "end_column": null, + "end_line": null, + "line": 19, + "path": "docs/pipeline.md" + }, + "status": "active", + "title": "Issue mechanism" + } + ], + "trace": { + "cycles": [], + "edges": [ + { + "source": "ndf://fixture/PIPE-ISSUE-010", + "target": "ndf://fixture/PIPE-ISSUE-001", + "type": "refines" + } + ], + "nodes": [ + "ndf://fixture/PIPE-ISSUE-010", + "ndf://fixture/PIPE-ISSUE-001" + ], + "root": "ndf://fixture/PIPE-ISSUE-010" + } +} diff --git a/tests/index_test_support.py b/tests/index_test_support.py deleted file mode 100644 index 3145869..0000000 --- a/tests/index_test_support.py +++ /dev/null @@ -1,15 +0,0 @@ -from pathlib import Path - -from ndf.index import BuildProvenance, build_index -from ndf.model import Graph - - -def build_fixture_index(graph: Graph, output: Path) -> None: - projects = sorted({node.id.project_id for node in graph.nodes}) - provenance = BuildProvenance( - project_commits=tuple((project, "0" * 40) for project in projects), - format_version="0.2", - ir_version="0.1", - tool_version="0.1.0", - ) - build_index(graph, output, provenance) diff --git a/tests/scale/generate.rs b/tests/scale/generate.rs new file mode 100644 index 0000000..8cf6fe6 --- /dev/null +++ b/tests/scale/generate.rs @@ -0,0 +1,41 @@ +use std::collections::BTreeMap; + +use ndf_core::identity::NodeId; +use ndf_core::model::{Edge, Graph, LifecycleStatus, Node, NodeKind, SourceSpan}; + +pub const NODE_COUNT: usize = 100_000; +pub const EDGE_COUNT: usize = 1_000_000; + +pub fn graph() -> Graph { + let nodes: Vec<_> = (0..NODE_COUNT) + .map(|index| Node { + id: NodeId::new("scale", format!("NODE-{index:06}")).unwrap(), + kind: NodeKind::Information, + title: format!("Node {index}"), + source: SourceSpan::new("generated/scale.md", (index + 1) as u32).unwrap(), + modality: None, + refinement: None, + domain: None, + status: LifecycleStatus::Active, + owner: None, + body: String::new(), + attributes: BTreeMap::new(), + }) + .collect(); + let edges = (0..EDGE_COUNT) + .map(|index| { + Edge::new( + nodes[index % NODE_COUNT].id.clone(), + nodes[(index + 1) % NODE_COUNT].id.clone(), + "references", + None, + ) + .unwrap() + }) + .collect(); + Graph { + nodes, + edges, + diagnostics: Vec::new(), + } +} diff --git a/tests/test_baseline.py b/tests/test_baseline.py deleted file mode 100644 index e785c52..0000000 --- a/tests/test_baseline.py +++ /dev/null @@ -1,112 +0,0 @@ -import unittest - -from ndf.baseline import ( - BaselineEntry, - DiagnosticBaseline, - compare_baseline, - diagnostic_fingerprint, -) -from ndf.identity import NodeId -from ndf.model import Diagnostic, DiagnosticSeverity, SourceSpan - - -class DiagnosticBaselineTest(unittest.TestCase): - def test_identical_diagnostic_is_classified_as_existing(self) -> None: - diagnostic = self._diagnostic("NDF-MOD-001", DiagnosticSeverity.WARNING) - baseline = DiagnosticBaseline( - entries=( - BaselineEntry.from_diagnostic( - diagnostic, - owner="core", - expires="2099-12-31", - ), - ) - ) - - result = compare_baseline((diagnostic,), baseline) - - self.assertEqual(result.existing, (diagnostic,)) - self.assertEqual(result.new, ()) - self.assertEqual(result.worsened, ()) - - def test_new_diagnostic_code_is_rejected(self) -> None: - existing = self._diagnostic("NDF-MOD-001", DiagnosticSeverity.WARNING) - new = self._diagnostic("NDF-OWN-001", DiagnosticSeverity.ERROR) - baseline = DiagnosticBaseline( - entries=( - BaselineEntry.from_diagnostic( - existing, - owner="core", - expires="2099-12-31", - ), - ) - ) - - result = compare_baseline((new,), baseline) - - self.assertEqual(result.new, (new,)) - - def test_severity_increase_is_classified_as_worsened(self) -> None: - warning = self._diagnostic("NDF-MOD-001", DiagnosticSeverity.WARNING) - error = self._diagnostic("NDF-MOD-001", DiagnosticSeverity.ERROR) - baseline = DiagnosticBaseline( - entries=( - BaselineEntry.from_diagnostic( - warning, - owner="core", - expires="2099-12-31", - ), - ) - ) - - result = compare_baseline((error,), baseline) - - self.assertEqual(result.worsened, (error,)) - - def test_missing_owner_or_expiry_emits_baseline_integrity_error(self) -> None: - diagnostic = self._diagnostic("NDF-MOD-001", DiagnosticSeverity.WARNING) - baseline = DiagnosticBaseline( - entries=( - BaselineEntry( - fingerprint=diagnostic_fingerprint(diagnostic), - code=diagnostic.code, - severity=diagnostic.severity, - subject=str(diagnostic.subject), - path=diagnostic.primary.path, - owner=None, - expires=None, - ), - ) - ) - - result = compare_baseline((diagnostic,), baseline) - - self.assertEqual([item.code for item in result.new], ["NDF-BASE-003"]) - - def test_fingerprint_ignores_line_movement(self) -> None: - before = self._diagnostic("NDF-MOD-001", DiagnosticSeverity.WARNING, line=10) - after = self._diagnostic("NDF-MOD-001", DiagnosticSeverity.WARNING, line=80) - - self.assertEqual( - diagnostic_fingerprint(before), - diagnostic_fingerprint(after), - ) - - @staticmethod - def _diagnostic( - code: str, - severity: DiagnosticSeverity, - *, - line: int = 10, - ) -> Diagnostic: - return Diagnostic( - code=code, - severity=severity, - message="requirement ndf://fixture/PIPE-001 is invalid", - primary=SourceSpan("docs/pipeline.md", line), - subject=NodeId("fixture", "PIPE-001"), - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index c3b0147..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,94 +0,0 @@ -import json -import tempfile -import unittest -from contextlib import chdir -from contextlib import redirect_stdout -from io import StringIO -from pathlib import Path - -from ndf.cli import main - - -class CliVersionTest(unittest.TestCase): - def test_machine_version_has_independent_surfaces(self) -> None: - output = StringIO() - with redirect_stdout(output): - status = main(["--machine-version"]) - - self.assertEqual(status, 0) - self.assertEqual( - json.loads(output.getvalue()), - { - "cli_stability": "experimental", - "format_version": "0.2", - "ir_version": "0.1", - "plugin_api": "experimental", - "tool_version": "0.1.0", - }, - ) - - def test_experimental_commands_emit_versioned_machine_envelopes(self) -> None: - fixture = Path(__file__).parent / "fixtures" / "markdown" / "project" - with tempfile.TemporaryDirectory() as directory, chdir(directory): - commands = ( - ( - [ - "build", - "--root", - str(fixture), - "--output", - "graph.sqlite", - "--format", - "json", - ], - "build", - ), - (["check", "--root", str(fixture), "--format", "json"], "check"), - ( - [ - "show", - "ndf://fixture/PIPE-ISSUE-001", - "--index", - "graph.sqlite", - "--format", - "json", - ], - "show", - ), - (["trace", "PIPE-ISSUE-010", "--edge", "refines", "--format", "json"], "trace"), - (["report", "coverage", "--format", "json"], "report coverage"), - (["diff", "graph.sqlite", "graph.sqlite", "--format", "json"], "diff"), - ( - [ - "export", - "--index", - "graph.sqlite", - "--output", - "graph.json", - "--format", - "ndf-json", - ], - "export", - ), - (["id", "allocate", "PIPE-ISSUE", "--format", "json"], "id allocate"), - (["fix", "--generated-only", "--check"], "fix"), - ) - - for arguments, command in commands: - with self.subTest(command=command): - output = StringIO() - with redirect_stdout(output): - status = main(arguments) - payload = json.loads(output.getvalue()) - self.assertEqual(status, 0) - self.assertEqual( - set(payload), - {"schema_version", "command", "ok", "data", "diagnostics"}, - ) - self.assertEqual(payload["schema_version"], "0.1") - self.assertEqual(payload["command"], command) - self.assertEqual(payload["ok"], True) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py deleted file mode 100644 index 9d681a3..0000000 --- a/tests/test_dependencies.py +++ /dev/null @@ -1,155 +0,0 @@ -import json -import subprocess -import tempfile -import unittest -from contextlib import redirect_stdout -from io import StringIO -from pathlib import Path - -from ndf.cli import main -from ndf.dependencies import resolve_dependencies -from ndf.manifest import LockedDependency, ProjectLock, ProjectManifest - - -class DependencyResolutionTest(unittest.TestCase): - def test_exact_clean_git_revision_is_resolved(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - revision = self._git_repo(root / "deps" / "tool") - manifest, lock = self._contracts(root, revision) - - resolved = resolve_dependencies(manifest, lock, root) - - self.assertEqual(len(resolved), 1) - self.assertEqual(resolved[0].project_id, "tool") - self.assertEqual(resolved[0].revision, revision) - self.assertEqual(resolved[0].dirty, False) - - def test_missing_dependency_is_rejected(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - manifest, lock = self._contracts(root, "0" * 40) - - with self.assertRaisesRegex(ValueError, "NDF-DEP-001"): - resolve_dependencies(manifest, lock, root) - - def test_symbolic_revision_is_rejected(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - self._git_repo(root / "deps" / "tool") - manifest, _ = self._contracts(root, "0" * 40) - lock = ProjectLock( - "0.1", - (LockedDependency("tool", "https://example.invalid/tool.git", "main", "deps/tool"),), - ) - - with self.assertRaisesRegex(ValueError, "NDF-DEP-002"): - resolve_dependencies(manifest, lock, root) - - def test_revision_mismatch_is_rejected(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - self._git_repo(root / "deps" / "tool") - manifest, lock = self._contracts(root, "f" * 40) - - with self.assertRaisesRegex(ValueError, "NDF-DEP-003"): - resolve_dependencies(manifest, lock, root) - - def test_dirty_dependency_is_rejected(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - dependency = root / "deps" / "tool" - revision = self._git_repo(dependency) - (dependency / "dirty.txt").write_text("dirty\n") - manifest, lock = self._contracts(root, revision) - - with self.assertRaisesRegex(ValueError, "NDF-DEP-004"): - resolve_dependencies(manifest, lock, root) - - def test_cli_reports_resolved_compatibility_tuple(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - revision = self._git_repo(root / "deps" / "tool") - (root / "ndf.yaml").write_text( - """format_version: '0.2' -project: fixture -roots: [] -id_prefixes: [REQ] -dependencies: - tool: - path: deps/tool -""" - ) - (root / "ndf.lock").write_text( - f"""format_version: '0.1' -dependencies: - tool: - uri: https://example.invalid/tool.git - revision: {revision} - path: deps/tool -""" - ) - output = StringIO() - - with redirect_stdout(output): - status = main( - [ - "report", - "dependencies", - "--root", - str(root), - "--format", - "json", - ] - ) - - payload = json.loads(output.getvalue()) - self.assertEqual(status, 0) - self.assertEqual(payload["command"], "report dependencies") - self.assertEqual(payload["data"][0]["revision"], revision) - - @staticmethod - def _contracts(root: Path, revision: str) -> tuple[ProjectManifest, ProjectLock]: - manifest = ProjectManifest( - format_version="0.2", - project="fixture", - roots=(), - id_prefixes=("REQ",), - domains=(), - policies=(), - root=root, - dependencies=("tool",), - ) - lock = ProjectLock( - "0.1", - ( - LockedDependency( - "tool", - "https://example.invalid/tool.git", - revision, - "deps/tool", - ), - ), - ) - return manifest, lock - - @staticmethod - def _git_repo(path: Path) -> str: - path.mkdir(parents=True) - subprocess.run(["git", "init", "-q"], cwd=path, check=True) - subprocess.run(["git", "config", "user.email", "ndf@example.invalid"], cwd=path, check=True) - subprocess.run(["git", "config", "user.name", "NDF Test"], cwd=path, check=True) - (path / "README.md").write_text("fixture\n") - subprocess.run(["git", "add", "README.md"], cwd=path, check=True) - subprocess.run(["git", "commit", "-qm", "fixture"], cwd=path, check=True) - return subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=path, - check=True, - text=True, - capture_output=True, - ).stdout.strip() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_diff.py b/tests/test_diff.py deleted file mode 100644 index 6786547..0000000 --- a/tests/test_diff.py +++ /dev/null @@ -1,86 +0,0 @@ -import tempfile -import unittest -from pathlib import Path - -from ndf.diff import semantic_diff -from ndf.identity import NodeId -from ndf.index import open_index -from ndf.model import Edge, Graph, LifecycleStatus, Node, NodeKind, SourceSpan -from tests.index_test_support import build_fixture_index - - -class SemanticDiffTest(unittest.TestCase): - def test_classifies_node_and_edge_changes(self) -> None: - before_nodes = ( - self._node("MOVED", "Moved", "docs/old.md"), - self._node("MODIFIED", "Before", "docs/design.md"), - self._node("REMOVED", "Removed", "docs/design.md"), - self._node("SUPERSEDED", "Superseded", "docs/design.md"), - self._node("EDGE-A", "Edge A", "docs/design.md"), - self._node("EDGE-B", "Edge B", "docs/design.md"), - ) - after_nodes = ( - self._node("MOVED", "Moved", "docs/new.md"), - self._node("MODIFIED", "After", "docs/design.md"), - self._node("ADDED", "Added", "docs/design.md"), - self._node( - "SUPERSEDED", - "Superseded", - "docs/design.md", - LifecycleStatus.SUPERSEDED, - ), - self._node("EDGE-A", "Edge A", "docs/design.md"), - self._node("EDGE-B", "Edge B", "docs/design.md"), - ) - after_edges = ( - Edge( - NodeId("fixture", "EDGE-A"), - NodeId("fixture", "EDGE-B"), - "depends-on", - ), - ) - - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - before_path = root / "before.sqlite" - after_path = root / "after.sqlite" - build_fixture_index(Graph(nodes=before_nodes), before_path) - build_fixture_index(Graph(nodes=after_nodes, edges=after_edges), after_path) - - with open_index(before_path) as before, open_index(after_path) as after: - result = semantic_diff(before, after) - - self.assertEqual(result.added, ("ndf://fixture/ADDED",)) - self.assertEqual(result.removed, ("ndf://fixture/REMOVED",)) - self.assertEqual(result.modified, ("ndf://fixture/MODIFIED",)) - self.assertEqual(result.moved, ("ndf://fixture/MOVED",)) - self.assertEqual(result.superseded, ("ndf://fixture/SUPERSEDED",)) - self.assertEqual( - result.edge_only, - ("ndf://fixture/EDGE-A", "ndf://fixture/EDGE-B"), - ) - self.assertEqual( - result.edges_added, - (("ndf://fixture/EDGE-A", "depends-on", "ndf://fixture/EDGE-B"),), - ) - - @staticmethod - def _node( - local_id: str, - title: str, - path: str, - status: LifecycleStatus = LifecycleStatus.ACTIVE, - ) -> Node: - return Node( - id=NodeId("fixture", local_id), - kind=NodeKind.ARCHITECTURE, - title=title, - source=SourceSpan(path, 10), - status=status, - owner="core", - body=f"Body for {local_id}", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_evidence_adapter.py b/tests/test_evidence_adapter.py deleted file mode 100644 index 5233c87..0000000 --- a/tests/test_evidence_adapter.py +++ /dev/null @@ -1,53 +0,0 @@ -import json -import unittest -from pathlib import Path - -from ndf.adapters.evidence import parse_evidence_manifest -from ndf.manifest import ProjectManifest -from ndf.model import NodeKind - - -class EvidenceAdapterTest(unittest.TestCase): - def test_explicit_evidence_record_links_to_normative_clause(self) -> None: - root = Path(__file__).parent / "fixtures" / "repository" - project = ProjectManifest( - format_version="0.2", - project="fixture", - roots=("evidence/*.json",), - id_prefixes=("EVID",), - domains=("core",), - policies=(), - root=root, - ) - - result = parse_evidence_manifest(root / "evidence/core.json", project) - - self.assertEqual(result.diagnostics, ()) - self.assertEqual(result.nodes[0].id.local_id, "EVID-CORE-001") - self.assertEqual(result.nodes[0].kind, NodeKind.EVIDENCE) - self.assertEqual(str(result.edges[0].source), "ndf://fixture/REQ-001") - self.assertEqual(str(result.edges[0].target), "ndf://fixture/EVID-CORE-001") - self.assertEqual(result.edges[0].type, "evidenced-by") - - def test_evidence_schema_requires_reproducibility_fields(self) -> None: - schema = json.loads( - (Path(__file__).parents[1] / "schemas/ndf-evidence.schema.json").read_text() - ) - - self.assertEqual( - set(schema["required"]), - { - "format_version", - "id", - "command", - "result", - "tool_version", - "source_revision", - "timestamp", - "links", - }, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_export.py b/tests/test_export.py deleted file mode 100644 index 902ae48..0000000 --- a/tests/test_export.py +++ /dev/null @@ -1,41 +0,0 @@ -import json -import tempfile -import unittest -from pathlib import Path - -from ndf.export import ExportFormat, export_graph -from ndf.identity import NodeId -from ndf.index import open_index -from ndf.model import Graph, Node, NodeKind, SourceSpan -from tests.index_test_support import build_fixture_index - - -class GraphExportTest(unittest.TestCase): - def test_ndf_json_export_is_deterministic_and_carries_provenance(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - node = Node( - id=NodeId("fixture", "NODE-001"), - kind=NodeKind.INFORMATION, - title="Node", - source=SourceSpan(str(root / "docs" / "node.md"), 1), - ) - index_path = root / "graph.sqlite" - first_path = root / "first.json" - second_path = root / "second.json" - build_fixture_index(Graph(nodes=(node,)), index_path) - - with open_index(index_path) as index: - first = export_graph(index, first_path, ExportFormat.NDF_JSON) - second = export_graph(index, second_path, ExportFormat.NDF_JSON) - - payload = json.loads(first_path.read_text()) - self.assertEqual(first_path.read_bytes(), second_path.read_bytes()) - self.assertEqual(first.semantic_hash, second.semantic_hash) - self.assertEqual(payload["provenance"]["format_version"], "0.2") - self.assertEqual(payload["nodes"][0]["id"], "ndf://fixture/NODE-001") - self.assertNotIn(str(root), first_path.read_text()) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_fix.py b/tests/test_fix.py deleted file mode 100644 index aebe50d..0000000 --- a/tests/test_fix.py +++ /dev/null @@ -1,46 +0,0 @@ -import tempfile -import unittest -from pathlib import Path - -from ndf.fix import FixMode, safe_fix - - -class SafeFixTest(unittest.TestCase): - def test_check_mode_reports_metadata_reordering_without_writing(self) -> None: - content = "\n" - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "design.md" - path.write_text(content) - - result = safe_fix((path,), FixMode(check=True)) - - self.assertEqual(result.changed, (str(path),)) - self.assertEqual(path.read_text(), content) - self.assertEqual(result.diagnostics, ()) - - def test_fix_reorders_metadata_without_changing_values(self) -> None: - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "design.md" - path.write_text( - "\n" - ) - - result = safe_fix((path,), FixMode()) - - self.assertEqual(result.changed, (str(path),)) - self.assertEqual( - path.read_text(), - "\n", - ) - - def test_semantic_change_request_is_refused(self) -> None: - result = safe_fix( - (), - FixMode(requested_changes=("status",)), - ) - - self.assertEqual([item.code for item in result.diagnostics], ["NDF-FIX-001"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_id_allocator.py b/tests/test_id_allocator.py deleted file mode 100644 index cc4c8fa..0000000 --- a/tests/test_id_allocator.py +++ /dev/null @@ -1,19 +0,0 @@ -import unittest - -from ndf.id_allocator import allocate_id - - -class IdAllocatorTest(unittest.TestCase): - def test_allocates_first_available_monotonic_suffix(self) -> None: - self.assertEqual( - allocate_id("PIPE-ISSUE", ("PIPE-ISSUE-001", "PIPE-ISSUE-003")), - "PIPE-ISSUE-004", - ) - - def test_rejects_invalid_prefix(self) -> None: - with self.assertRaisesRegex(ValueError, "invalid ID prefix"): - allocate_id("pipe issue", ()) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_identity.py b/tests/test_identity.py deleted file mode 100644 index 2449364..0000000 --- a/tests/test_identity.py +++ /dev/null @@ -1,32 +0,0 @@ -import unittest - -from ndf.identity import NodeId, canonical_uri, parse_uri - - -class IdentityTest(unittest.TestCase): - def test_local_id_resolves_inside_current_project(self) -> None: - self.assertEqual( - str(parse_uri("VEC-ISSUE-001", current_project="davincioo")), - "ndf://davincioo/VEC-ISSUE-001", - ) - - def test_cross_project_reference_requires_uri(self) -> None: - with self.assertRaisesRegex(ValueError, "current project is required"): - parse_uri("VEC-ISSUE-001") - - def test_canonical_uri_round_trips(self) -> None: - identity = parse_uri("ndf://pto-spec/PTO-INST-TLOAD") - - self.assertEqual(identity, NodeId("pto-spec", "PTO-INST-TLOAD")) - self.assertEqual( - canonical_uri(identity.project_id, identity.local_id), - "ndf://pto-spec/PTO-INST-TLOAD", - ) - - def test_invalid_project_id_is_rejected(self) -> None: - with self.assertRaisesRegex(ValueError, "invalid project ID"): - canonical_uri("DavinciOO", "PIPE-001") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_index.py b/tests/test_index.py deleted file mode 100644 index c25713b..0000000 --- a/tests/test_index.py +++ /dev/null @@ -1,163 +0,0 @@ -import sqlite3 -import tempfile -import unittest -from pathlib import Path - -from ndf.identity import NodeId -from ndf.index import BuildProvenance, build_index, open_index -from ndf.model import Edge, Graph, LifecycleStatus, Node, NodeKind, SourceSpan - - -class DeterministicIndexTest(unittest.TestCase): - def test_reversed_insertion_order_has_identical_rows_and_hash(self) -> None: - first, second = self._nodes() - edge = Edge(second.id, first.id, "refines", second.source) - forward = Graph(nodes=(first, second), edges=(edge,)) - reverse = Graph(nodes=(second, first), edges=(edge,)) - - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - first_build = build_index( - forward, - root / "forward.sqlite", - self._provenance(root), - ) - second_build = build_index( - reverse, - root / "reverse.sqlite", - self._provenance(root), - ) - - self.assertEqual(first_build.semantic_hash, second_build.semantic_hash) - self.assertEqual( - self._logical_rows(root / "forward.sqlite"), - self._logical_rows(root / "reverse.sqlite"), - ) - - def test_existing_output_is_replaced_only_after_successful_build(self) -> None: - first, _ = self._nodes() - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - output = root / "graph.sqlite" - output.write_bytes(b"original") - incomplete = BuildProvenance( - project_commits=(), - format_version="0.2", - ir_version="0.1", - tool_version="0.1.0", - normative_roots=(root / "docs",), - ) - - with self.assertRaisesRegex(ValueError, "missing project revision"): - build_index(Graph(nodes=(first,)), output, incomplete) - self.assertEqual(output.read_bytes(), b"original") - - build_index(Graph(nodes=(first,)), output, self._provenance(root)) - self.assertEqual(output.read_bytes()[:16], b"SQLite format 3\x00") - - def test_output_inside_normative_root_is_rejected(self) -> None: - first, _ = self._nodes() - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - normative_root = root / "docs" - normative_root.mkdir() - provenance = self._provenance(root, normative_roots=(normative_root,)) - - with self.assertRaisesRegex(ValueError, "normative root"): - build_index( - Graph(nodes=(first,)), - normative_root / "graph.sqlite", - provenance, - ) - - def test_open_index_enables_foreign_keys_and_exposes_graph_queries(self) -> None: - first, second = self._nodes() - graph = Graph( - nodes=(first, second), - edges=(Edge(second.id, first.id, "refines", second.source),), - ) - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - output = root / "graph.sqlite" - build = build_index(graph, output, self._provenance(root)) - - with open_index(output) as index: - self.assertEqual(index.foreign_keys_enabled(), True) - self.assertEqual(index.get_node(str(first.id)).title, "Contract") - self.assertEqual( - [edge.type for edge in index.out_edges(str(second.id))], - ["refines"], - ) - self.assertEqual( - [str(edge.source) for edge in index.in_edges(str(first.id))], - ["ndf://fixture/PIPE-020"], - ) - self.assertEqual( - index.provenance(), - { - "format_version": "0.2", - "ir_version": "0.1", - "semantic_hash": build.semantic_hash, - "tool_version": "0.1.0", - }, - ) - - @staticmethod - def _nodes() -> tuple[Node, Node]: - first = Node( - id=NodeId("fixture", "PIPE-010"), - kind=NodeKind.REQUIREMENT, - title="Contract", - source=SourceSpan("docs/pipeline.md", 10), - status=LifecycleStatus.ACTIVE, - owner="core", - body="The core MUST issue in order.", - ) - second = Node( - id=NodeId("fixture", "PIPE-020"), - kind=NodeKind.ARCHITECTURE, - title="Mechanism", - source=SourceSpan("docs/pipeline.md", 20), - status=LifecycleStatus.ACTIVE, - owner="core", - ) - return first, second - - @staticmethod - def _provenance( - root: Path, - *, - normative_roots: tuple[Path, ...] | None = None, - ) -> BuildProvenance: - return BuildProvenance( - project_commits=( - ("fixture", "0123456789abcdef0123456789abcdef01234567"), - ), - format_version="0.2", - ir_version="0.1", - tool_version="0.1.0", - normative_roots=normative_roots or (root / "docs",), - ) - - @staticmethod - def _logical_rows(path: Path) -> dict[str, list[tuple]]: - tables = ( - "projects", - "sources", - "nodes", - "node_attrs", - "aliases", - "edges", - "diagnostics", - "baselines", - "builds", - ) - with sqlite3.connect(path) as connection: - return { - table: sorted(connection.execute(f"SELECT * FROM {table}").fetchall()) - for table in tables - } - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_index_scale.py b/tests/test_index_scale.py deleted file mode 100644 index 472b12e..0000000 --- a/tests/test_index_scale.py +++ /dev/null @@ -1,56 +0,0 @@ -import os -import tempfile -import time -import unittest -from pathlib import Path - -from ndf.identity import NodeId -from ndf.index import BuildProvenance, build_index -from ndf.model import Edge, Graph, Node, NodeKind, SourceSpan - - -@unittest.skipUnless( - os.environ.get("NDF_RUN_SCALE_TESTS") == "1", - "set NDF_RUN_SCALE_TESTS=1 to run the provisional scale measurement", -) -class ProvisionalIndexScaleTest(unittest.TestCase): - def test_builds_one_hundred_thousand_nodes_and_one_million_edges(self) -> None: - nodes = tuple( - Node( - id=NodeId("scale", f"NODE-{index:06d}"), - kind=NodeKind.INFORMATION, - title=f"Node {index}", - source=SourceSpan("generated/scale.md", index + 1), - ) - for index in range(100_000) - ) - edges = tuple( - Edge( - source=nodes[index % len(nodes)].id, - target=nodes[(index + 1) % len(nodes)].id, - type="references", - ) - for index in range(1_000_000) - ) - provenance = BuildProvenance( - project_commits=( - ("scale", "0123456789abcdef0123456789abcdef01234567"), - ), - format_version="0.2", - ir_version="0.1", - tool_version="0.1.0", - ) - - with tempfile.TemporaryDirectory() as directory: - output = Path(directory) / "scale.sqlite" - started = time.monotonic() - build = build_index(Graph(nodes=nodes, edges=edges), output, provenance) - elapsed = time.monotonic() - started - - self.assertEqual(build.node_count, 100_000) - self.assertEqual(build.edge_count, 1_000_000) - print(f"NDF scale build: {elapsed:.3f}s, {output.stat().st_size} bytes") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_manifest.py b/tests/test_manifest.py deleted file mode 100644 index bd75b1b..0000000 --- a/tests/test_manifest.py +++ /dev/null @@ -1,80 +0,0 @@ -import json -import tempfile -import unittest -from pathlib import Path - -from ndf.manifest import load_lock, load_manifest - - -class ManifestTest(unittest.TestCase): - def setUp(self) -> None: - self.project_root = ( - Path(__file__).parent / "fixtures" / "markdown" / "project" - ) - - def test_complete_manifest_and_lock_are_normalized(self) -> None: - manifest = load_manifest(self.project_root / "ndf.yaml") - lock = load_lock(self.project_root / "ndf.lock") - - self.assertEqual(manifest.format_version, "0.2") - self.assertEqual(manifest.project, "fixture") - self.assertEqual(manifest.roots, ("docs/**/*.md",)) - self.assertEqual(manifest.id_prefixes, ("DOC", "PIPE")) - self.assertEqual(lock.dependencies[0].project, "pto-spec") - self.assertEqual( - lock.dependencies[0].revision, - "0123456789abcdef0123456789abcdef01234567", - ) - - def test_lock_rejects_non_commit_revision(self) -> None: - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "ndf.lock" - path.write_text( - """format_version: '0.1' -dependencies: - pto-spec: - uri: https://github.com/PTO-ISA/pto-spec.git - revision: main - path: external/pto-spec -""" - ) - - with self.assertRaisesRegex(ValueError, "40 lowercase hexadecimal"): - load_lock(path) - - def test_manifest_rejects_missing_required_field(self) -> None: - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "ndf.yaml" - path.write_text("format_version: '0.2'\nproject: fixture\n") - - with self.assertRaisesRegex(ValueError, "missing required field: roots"): - load_manifest(path) - - def test_manifest_schemas_declare_runtime_required_fields(self) -> None: - repository_root = Path(__file__).parents[1] - project_schema = json.loads( - (repository_root / "schemas/ndf-project.schema.json").read_text() - ) - lock_schema = json.loads( - (repository_root / "schemas/ndf-lock.schema.json").read_text() - ) - front_matter_schema = json.loads( - (repository_root / "schemas/ndf-front-matter.schema.json").read_text() - ) - - self.assertEqual( - set(project_schema["required"]), - {"format_version", "project", "roots", "id_prefixes"}, - ) - self.assertEqual( - set(lock_schema["required"]), - {"format_version", "dependencies"}, - ) - self.assertEqual( - set(front_matter_schema["required"]), - {"doc_id", "status", "authority", "owner"}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_markdown_parser.py b/tests/test_markdown_parser.py deleted file mode 100644 index 1cb8e8c..0000000 --- a/tests/test_markdown_parser.py +++ /dev/null @@ -1,118 +0,0 @@ -import tempfile -import unittest -from pathlib import Path - -from ndf.manifest import ProjectManifest, load_manifest -from ndf.model import DiagnosticSeverity, Modality, NodeKind -from ndf.parsers.markdown import parse_markdown - - -class MarkdownParserTest(unittest.TestCase): - def setUp(self) -> None: - self.project_root = ( - Path(__file__).parent / "fixtures" / "markdown" / "project" - ) - self.manifest = load_manifest(self.project_root / "ndf.yaml") - - def test_complete_document_produces_nodes_edges_and_source_spans(self) -> None: - result = parse_markdown( - self.project_root / "docs" / "pipeline.md", - self.manifest, - ) - - self.assertEqual(len(result.nodes), 3) - self.assertEqual(len(result.edges), 1) - self.assertEqual(result.diagnostics, ()) - self.assertEqual( - [node.id.local_id for node in result.nodes], - ["DOC-FIXTURE-PIPELINE", "PIPE-ISSUE-001", "PIPE-ISSUE-010"], - ) - self.assertNotIn("FAKE-001", [node.id.local_id for node in result.nodes]) - self.assertEqual(result.nodes[1].source.line, 14) - self.assertEqual(result.nodes[2].source.line, 19) - self.assertEqual(result.nodes[1].kind, NodeKind.REQUIREMENT) - self.assertEqual(result.nodes[1].modality, Modality.MUST) - self.assertEqual(str(result.edges[0].source), "ndf://fixture/PIPE-ISSUE-010") - self.assertEqual(str(result.edges[0].target), "ndf://fixture/PIPE-ISSUE-001") - self.assertEqual(result.edges[0].type, "refines") - - def test_unclosed_metadata_comment_emits_delimiter_diagnostic(self) -> None: - result = self._parse_text( - """--- -doc_id: DOC-FIXTURE-BROKEN -status: active -authority: normative -owner: core ---- -# Broken -## Contract {#PIPE-BROKEN-001} - -The core MUST stop. -""" - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_model.py b/tests/test_model.py deleted file mode 100644 index 7bd6298..0000000 --- a/tests/test_model.py +++ /dev/null @@ -1,114 +0,0 @@ -import json -import unittest -from pathlib import Path - -from ndf.identity import NodeId -from ndf.model import ( - DiagnosticSeverity, - Edge, - Graph, - LifecycleStatus, - Node, - NodeKind, - SourceSpan, -) - - -class ModelInvariantTest(unittest.TestCase): - def test_source_span_rejects_line_zero(self) -> None: - with self.assertRaisesRegex(ValueError, "line must be positive"): - SourceSpan("docs/pipeline.md", 0) - - def test_edge_rejects_empty_type(self) -> None: - identity = NodeId("davincioo", "PIPE-001") - with self.assertRaisesRegex(ValueError, "edge type must not be empty"): - Edge(source=identity, target=identity, type="") - - def test_duplicate_node_emits_stable_diagnostic(self) -> None: - identity = NodeId("davincioo", "PIPE-001") - first = Node( - id=identity, - kind=NodeKind.REQUIREMENT, - title="Issue contract", - source=SourceSpan("docs/pipeline.md", 10), - status=LifecycleStatus.ACTIVE, - ) - duplicate = Node( - id=identity, - kind=NodeKind.REQUIREMENT, - title="Duplicate issue contract", - source=SourceSpan("docs/duplicate.md", 4), - status=LifecycleStatus.ACTIVE, - ) - - graph = Graph().add_node(first).add_node(duplicate) - - self.assertEqual(len(graph.nodes), 1) - self.assertEqual(len(graph.diagnostics), 1) - self.assertEqual(graph.diagnostics[0].code, "NDF-ID-001") - self.assertEqual(graph.diagnostics[0].severity, DiagnosticSeverity.ERROR) - self.assertEqual(graph.diagnostics[0].primary, duplicate.source) - self.assertEqual(graph.diagnostics[0].related, (first.source,)) - - def test_graph_sorting_is_insertion_independent(self) -> None: - later = Node( - id=NodeId("davincioo", "PIPE-020"), - kind=NodeKind.INFORMATION, - title="Later", - source=SourceSpan("docs/pipeline.md", 20), - ) - earlier = Node( - id=NodeId("davincioo", "PIPE-010"), - kind=NodeKind.INFORMATION, - title="Earlier", - source=SourceSpan("docs/pipeline.md", 10), - ) - - graph = Graph().add_node(later).add_node(earlier) - - self.assertEqual( - [str(node.id) for node in graph.sorted_nodes()], - ["ndf://davincioo/PIPE-010", "ndf://davincioo/PIPE-020"], - ) - - graph = Graph( - edges=( - Edge(later.id, earlier.id, "refines"), - Edge(earlier.id, later.id, "depends-on"), - ) - ) - self.assertEqual( - [edge.type for edge in graph.sorted_edges()], - ["depends-on", "refines"], - ) - - def test_serialized_graph_matches_declared_schema_surface(self) -> None: - graph = Graph().add_node( - Node( - id=NodeId("davincioo", "PIPE-001"), - kind=NodeKind.REQUIREMENT, - title="Issue contract", - source=SourceSpan("docs/pipeline.md", 10), - status=LifecycleStatus.ACTIVE, - ) - ) - repository_root = Path(__file__).parents[1] - graph_schema = json.loads( - (repository_root / "schemas/ndf-ir.schema.json").read_text() - ) - diagnostic_schema = json.loads( - (repository_root / "schemas/ndf-diagnostic.schema.json").read_text() - ) - - self.assertEqual( - set(graph.to_dict()), - set(graph_schema["required"]), - ) - self.assertEqual( - set(Graph().add_node(graph.nodes[0]).add_node(graph.nodes[0]).diagnostics[0].to_dict()), - set(diagnostic_schema["required"]), - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_pto_asl_adapter.py b/tests/test_pto_asl_adapter.py deleted file mode 100644 index 8c57330..0000000 --- a/tests/test_pto_asl_adapter.py +++ /dev/null @@ -1,120 +0,0 @@ -import unittest -from pathlib import Path - -from ndf.manifest import ProjectManifest -from ndf.model import LifecycleStatus, NodeKind -from ndf.adapters.pto_asl import instruction_clause_id, parse_pto_asl - - -class PtoAslAdapterTest(unittest.TestCase): - def setUp(self) -> None: - self.fixture_root = Path(__file__).parent / "fixtures" / "pto-asl" - self.project = ProjectManifest( - format_version="0.2", - project="pto-spec", - roots=("*.asl",), - id_prefixes=("PTO",), - domains=( - "architecture", - "scalar", - "block", - "tile", - "state", - "memory", - "concurrency", - ), - policies=(), - root=self.fixture_root, - ) - - def test_valid_regions_and_instruction_metadata_become_canonical_graph(self) -> None: - result = parse_pto_asl(self.fixture_root / "accept.asl", self.project) - - self.assertEqual(result.diagnostics, ()) - self.assertEqual( - [node.id.local_id for node in result.nodes], - [ - "PTO-INST-TILE-TLOAD", - "PTO-TILE-CAPACITY", - "PTO-TILE-CAPACITY-MECHANISM", - ], - ) - contract = result.nodes[1] - mechanism = result.nodes[2] - self.assertEqual(contract.kind, NodeKind.REQUIREMENT) - self.assertEqual(contract.refinement, "L1") - self.assertEqual(contract.domain, "tile") - self.assertEqual(contract.status, LifecycleStatus.ACTIVE) - self.assertEqual(contract.source.line, 3) - self.assertEqual(mechanism.kind, NodeKind.ARCHITECTURE) - self.assertEqual(mechanism.status, LifecycleStatus.OPEN) - self.assertEqual( - [(edge.type, edge.target.local_id) for edge in result.edges], - [("references", "PTO-INST-TILE-TLOAD")], - ) - - def test_non_comment_body_line_is_rejected(self) -> None: - path = self.fixture_root / "accept.asl" - text = path.read_text().replace( - "// The implementation tracks capacity in tile state.", - "let capacity = 1;", - ) - - result = parse_pto_asl_text(text, path, self.project) - - self.assertIn("NDF-ASL-003", [item.code for item in result.diagnostics]) - - def test_duplicate_metadata_field_is_rejected(self) -> None: - result = parse_pto_asl( - self.fixture_root / "reject-duplicate.asl", - self.project, - ) - - self.assertEqual([item.code for item in result.diagnostics], ["NDF-ASL-002"]) - - def test_kind_level_layer_and_status_are_validated(self) -> None: - result = parse_pto_asl( - self.fixture_root / "reject-metadata.asl", - self.project, - ) - - self.assertEqual( - [item.code for item in result.diagnostics], - ["NDF-ASL-002", "NDF-ASL-002", "NDF-ASL-002"], - ) - - def test_mismatched_region_end_is_rejected(self) -> None: - path = self.fixture_root / "accept.asl" - text = path.read_text().replace( - "NDF-END: PTO-TILE-CAPACITY", - "NDF-END: PTO-OTHER", - 1, - ) - - result = parse_pto_asl_text(text, path, self.project) - - self.assertIn("NDF-ASL-001", [item.code for item in result.diagnostics]) - - def test_instruction_identity_is_surface_scoped_and_stable(self) -> None: - self.assertEqual( - instruction_clause_id("tile", "TLOAD"), - "PTO-INST-TILE-TLOAD", - ) - self.assertEqual( - instruction_clause_id("block", "BSTART.TLOAD"), - "PTO-INST-BLOCK-BSTART-TLOAD", - ) - - -def parse_pto_asl_text( - text: str, - source: Path, - project: ProjectManifest, -): - from ndf.adapters.pto_asl import parse_pto_asl_text as parse - - return parse(text, source, project) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_query.py b/tests/test_query.py deleted file mode 100644 index 45dc9cf..0000000 --- a/tests/test_query.py +++ /dev/null @@ -1,71 +0,0 @@ -import tempfile -import unittest -from pathlib import Path - -from ndf.identity import NodeId -from ndf.index import open_index -from ndf.model import Edge, Graph, LifecycleStatus, Modality, Node, NodeKind, SourceSpan -from ndf.query import query_nodes, trace -from tests.index_test_support import build_fixture_index - - -class QueryAndTraceTest(unittest.TestCase): - def test_query_filters_canonical_node_fields(self) -> None: - requirement = self._node("REQ-001", NodeKind.REQUIREMENT, modality=Modality.MUST) - information = self._node("INFO-001", NodeKind.INFORMATION) - with tempfile.TemporaryDirectory() as directory: - output = Path(directory) / "graph.sqlite" - build_fixture_index(Graph(nodes=(information, requirement)), output) - - with open_index(output) as index: - result = query_nodes(index, "kind=requirement and status=active") - - self.assertEqual([str(node.id) for node in result], ["ndf://fixture/REQ-001"]) - - def test_trace_terminates_on_cycle_and_reports_cycle_edge(self) -> None: - first = self._node("REQ-001", NodeKind.REQUIREMENT) - second = self._node("REQ-002", NodeKind.REQUIREMENT) - graph = Graph( - nodes=(first, second), - edges=( - Edge(first.id, second.id, "refines"), - Edge(second.id, first.id, "refines"), - ), - ) - with tempfile.TemporaryDirectory() as directory: - output = Path(directory) / "graph.sqlite" - build_fixture_index(graph, output) - - with open_index(output) as index: - result = trace(index, "REQ-001", frozenset({"refines"})) - - self.assertEqual(result.root, "ndf://fixture/REQ-001") - self.assertEqual( - result.nodes, - ("ndf://fixture/REQ-001", "ndf://fixture/REQ-002"), - ) - self.assertEqual(len(result.cycles), 1) - self.assertEqual(result.cycles[0].target, "ndf://fixture/REQ-001") - - @staticmethod - def _node( - local_id: str, - kind: NodeKind, - *, - modality: Modality | None = None, - ) -> Node: - return Node( - id=NodeId("fixture", local_id), - kind=kind, - title=local_id, - source=SourceSpan("docs/design.md", 10), - modality=modality, - refinement="L1", - status=LifecycleStatus.ACTIVE, - owner="core", - body="The core MUST preserve behavior." if modality else "Background.", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_report.py b/tests/test_report.py deleted file mode 100644 index 19055ed..0000000 --- a/tests/test_report.py +++ /dev/null @@ -1,60 +0,0 @@ -import tempfile -import unittest -from pathlib import Path - -from ndf.identity import NodeId -from ndf.index import open_index -from ndf.model import Edge, Graph, LifecycleStatus, Modality, Node, NodeKind, SourceSpan -from ndf.report import CoveragePolicy, coverage -from tests.index_test_support import build_fixture_index - - -class CoverageReportTest(unittest.TestCase): - def test_reports_unverified_open_and_tbd_contracts(self) -> None: - covered = self._node("REQ-COVERED", NodeKind.REQUIREMENT, Modality.MUST, "L1") - uncovered = self._node("REQ-UNCOVERED", NodeKind.REQUIREMENT, Modality.MUST, "L1") - verification = self._node("VER-001", NodeKind.VERIFICATION, None, "L2") - question = self._node("QUESTION-001", NodeKind.QUESTION, None, "L0", LifecycleStatus.OPEN) - option = self._node("OPTION-001", NodeKind.OPTION, None, "L0", LifecycleStatus.OPEN) - tbd = self._node("REQ-TBD", NodeKind.REQUIREMENT, Modality.TBD, "L1") - graph = Graph( - nodes=(covered, uncovered, verification, question, option, tbd), - edges=(Edge(verification.id, covered.id, "verifies"),), - ) - with tempfile.TemporaryDirectory() as directory: - output = Path(directory) / "graph.sqlite" - build_fixture_index(graph, output) - - with open_index(output) as index: - report = coverage(index, CoveragePolicy(refinement="L1")) - - self.assertEqual(report.unverified, ("ndf://fixture/REQ-UNCOVERED",)) - self.assertEqual( - report.open_items, - ("ndf://fixture/OPTION-001", "ndf://fixture/QUESTION-001"), - ) - self.assertEqual(report.tbd, ("ndf://fixture/REQ-TBD",)) - - @staticmethod - def _node( - local_id: str, - kind: NodeKind, - modality: Modality | None, - refinement: str, - status: LifecycleStatus = LifecycleStatus.ACTIVE, - ) -> Node: - return Node( - id=NodeId("fixture", local_id), - kind=kind, - title=local_id, - source=SourceSpan("docs/design.md", 10), - modality=modality, - refinement=refinement, - status=status, - owner="core", - body="The core MUST preserve behavior." if modality is Modality.MUST else "", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_repository_adapter.py b/tests/test_repository_adapter.py deleted file mode 100644 index a466de7..0000000 --- a/tests/test_repository_adapter.py +++ /dev/null @@ -1,48 +0,0 @@ -import unittest -from pathlib import Path - -from ndf.adapters.repository import index_repository_paths -from ndf.manifest import ProjectManifest -from ndf.model import NodeKind - - -class RepositoryAdapterTest(unittest.TestCase): - def setUp(self) -> None: - self.root = Path(__file__).parent / "fixtures" / "repository" - self.project = ProjectManifest( - format_version="0.2", - project="fixture", - roots=("src/**/*.py", "tests/**/*.py"), - id_prefixes=("REQ",), - domains=("core",), - policies=(), - root=self.root, - ) - - def test_source_and_test_paths_get_deterministic_nodes_and_edges(self) -> None: - forward = index_repository_paths( - self.root, - ("src/**/*.py", "tests/**/*.py"), - self.project, - ) - reverse = index_repository_paths( - self.root, - ("tests/**/*.py", "src/**/*.py"), - self.project, - ) - - self.assertEqual(forward, reverse) - self.assertEqual( - [node.kind for node in forward.nodes], - [NodeKind.SOURCE, NodeKind.TEST], - ) - self.assertEqual( - [(edge.type, edge.target.local_id) for edge in forward.edges], - [("implements", "REQ-001"), ("verifies", "REQ-001")], - ) - self.assertEqual(dict(forward.nodes[0].attributes)["path"], "src/core.py") - self.assertEqual(len(dict(forward.nodes[0].attributes)["content_hash"]), 64) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_rules.py b/tests/test_rules.py deleted file mode 100644 index bb0a6fe..0000000 --- a/tests/test_rules.py +++ /dev/null @@ -1,118 +0,0 @@ -import unittest -from pathlib import Path - -from ndf.identity import NodeId -from ndf.manifest import ProjectManifest -from ndf.model import Edge, Graph, LifecycleStatus, Modality, Node, NodeKind, SourceSpan -from ndf.plugins import Rule, load_project_rules -from ndf.rules import ValidationPolicy, validate_graph - - -class GraphRuleTest(unittest.TestCase): - def test_duplicate_identity_is_preserved_as_validation_error(self) -> None: - node = self._requirement("PIPE-001") - graph = Graph().add_node(node).add_node(node) - - self.assertEqual(self._codes(graph), ["NDF-ID-001"]) - - def test_dangling_mandatory_edge_is_rejected(self) -> None: - source = self._requirement("PIPE-001") - graph = Graph( - nodes=(source,), - edges=( - Edge(source.id, NodeId("fixture", "PIPE-MISSING"), "refines"), - ), - ) - - self.assertEqual(self._codes(graph), ["NDF-EDGE-001"]) - - def test_must_requirement_without_normative_keyword_is_rejected(self) -> None: - node = self._requirement("PIPE-001", body="The pipeline issues in order.") - - self.assertEqual(self._codes(Graph(nodes=(node,))), ["NDF-MOD-001"]) - - def test_information_text_cannot_create_normative_requirement(self) -> None: - node = Node( - id=NodeId("fixture", "PIPE-INFO-001"), - kind=NodeKind.INFORMATION, - title="Background", - source=SourceSpan("docs/pipeline.md", 20), - body="The implementation MUST issue in order.", - ) - - self.assertEqual(self._codes(Graph(nodes=(node,))), ["NDF-MOD-002"]) - - def test_lifecycle_cannot_move_from_active_back_to_draft(self) -> None: - node = self._requirement( - "PIPE-001", - status=LifecycleStatus.DRAFT, - attributes=(("previous_status", "active"),), - ) - - self.assertEqual(self._codes(Graph(nodes=(node,))), ["NDF-LIFE-001"]) - - def test_explicit_normative_node_requires_owner(self) -> None: - node = self._requirement("PIPE-001", owner=None) - - self.assertEqual(self._codes(Graph(nodes=(node,))), ["NDF-OWN-001"]) - - def test_known_project_rules_are_declarative(self) -> None: - manifest = ProjectManifest( - format_version="0.2", - project="fixture", - roots=("docs/**/*.md",), - id_prefixes=("PIPE",), - domains=("core",), - policies=(("ndf", {"ownership_required": True}),), - root=Path.cwd(), - ) - - self.assertEqual( - load_project_rules(manifest), - (Rule("ndf", "ownership_required", True),), - ) - - def test_unknown_rule_namespace_is_rejected(self) -> None: - manifest = ProjectManifest( - format_version="0.2", - project="fixture", - roots=("docs/**/*.md",), - id_prefixes=("PIPE",), - domains=("core",), - policies=(("python.module", {"rule": "load_me"}),), - root=Path.cwd(), - ) - - with self.assertRaisesRegex(ValueError, "NDF-PLUGIN-001"): - load_project_rules(manifest) - - def _codes(self, graph: Graph) -> list[str]: - return [ - diagnostic.code - for diagnostic in validate_graph(graph, ValidationPolicy()) - ] - - @staticmethod - def _requirement( - local_id: str, - *, - body: str = "The pipeline MUST issue in order.", - owner: str | None = "core", - status: LifecycleStatus = LifecycleStatus.ACTIVE, - attributes: tuple[tuple[str, str], ...] = (), - ) -> Node: - return Node( - id=NodeId("fixture", local_id), - kind=NodeKind.REQUIREMENT, - title="Issue contract", - source=SourceSpan("docs/pipeline.md", 10), - modality=Modality.MUST, - status=status, - owner=owner, - body=body, - attributes=attributes, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_spec_sync.py b/tests/test_spec_sync.py deleted file mode 100644 index 054de7b..0000000 --- a/tests/test_spec_sync.py +++ /dev/null @@ -1,57 +0,0 @@ -import unittest -from pathlib import Path - -import yaml - - -class SpecificationContractTest(unittest.TestCase): - def test_english_and_chinese_publish_the_same_machine_contract(self) -> None: - repository_root = Path(__file__).parents[1] - english = self._contract(repository_root / "normative_language.md") - chinese = self._contract(repository_root / "normative_language_cn.md") - - self.assertEqual(english, chinese) - self.assertEqual( - english, - { - "agent_gate": ["build", "check", "diff", "coverage"], - "canonical_fields": [ - "kind", - "modality", - "refinement", - "domain", - "status", - ], - "derived_index": "sqlite-v1", - "format_version": "0.2", - "identity_scheme": "ndf", - "ir_version": "0.1", - "machine_output_schema": "0.1", - "maturity": { - "cli": "experimental", - "format": "experimental", - "ir": "experimental", - "plugin_api": "experimental", - }, - "required_files": ["ndf.yaml", "ndf.lock"], - }, - ) - - @staticmethod - def _contract(path: Path) -> dict: - text = path.read_text() - start_marker = "" - end_marker = "" - start = text.index(start_marker) + len(start_marker) - end = text.index(end_marker, start) - block = text[start:end].strip() - if not block.startswith("```yaml\n") or not block.endswith("\n```"): - raise AssertionError(f"{path.name} contract must be a YAML code block") - value = yaml.safe_load(block[len("```yaml\n") : -len("\n```")]) - if not isinstance(value, dict): - raise AssertionError(f"{path.name} contract must be a mapping") - return value - - -if __name__ == "__main__": - unittest.main()