From 7b74838826f66fcf24d771e16c4184a7f7d98ee5 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Tue, 15 Sep 2026 09:43:29 +0200 Subject: [PATCH 1/4] feat(compact-lint): add the check command Adds the compact-lint CLI with one subcommand, check, that enforces per-declaration-kind doc-comment templates from compact-lint.toml and optionally runs compact format --check. Findings print one per line as path:line:col: rule: message on stdout; exit 1 on findings, 2 on any error. fix and fill-constraints come later and only add tables to the same toml. * doc: tags parsed into an ordered list with line and column, so a finding points at the @ of the offending tag. * rules: missing-doc, missing-tag, forbidden-tag, module-name, missing-constraints, constraints-format, constraints-placeholder (--strict), parse, format. Constraints apply to exported non-pure circuits only. * config: every field optional, unknown keys and tags without their @ rejected; an explicit --config is a preset anchored at the cwd. * tests: one fixture case per rule through the binary, plus unit tests per module. --- Cargo.lock | 365 +++++++++++++++ Cargo.toml | 10 + crates/compact-lint/Cargo.toml | 33 ++ crates/compact-lint/README.md | 128 ++++++ .../examples/compact-contracts.toml | 40 ++ crates/compact-lint/src/check.rs | 117 +++++ crates/compact-lint/src/config.rs | 305 +++++++++++++ crates/compact-lint/src/discover.rs | 114 +++++ crates/compact-lint/src/doc.rs | 211 +++++++++ crates/compact-lint/src/format.rs | 156 +++++++ crates/compact-lint/src/lib.rs | 16 + crates/compact-lint/src/main.rs | 114 +++++ crates/compact-lint/src/model.rs | 177 ++++++++ crates/compact-lint/src/report.rs | 142 ++++++ crates/compact-lint/src/rules.rs | 415 ++++++++++++++++++ crates/compact-lint/tests/check.rs | 172 ++++++++ .../tests/fixtures/clean/Clean.compact | 56 +++ .../tests/fixtures/clean/compact-lint.toml | 27 ++ .../tests/fixtures/clean/expected.txt | 0 .../constraints-format/Malformed.compact | 17 + .../constraints-format/compact-lint.toml | 27 ++ .../fixtures/constraints-format/expected.txt | 2 + .../constraints-placeholder/Unfilled.compact | 11 + .../constraints-placeholder/compact-lint.toml | 27 ++ .../constraints-placeholder/expected.txt | 1 + .../fixtures/forbidden-tag/Returns.compact | 18 + .../fixtures/forbidden-tag/compact-lint.toml | 27 ++ .../tests/fixtures/forbidden-tag/expected.txt | 2 + .../missing-constraints/Unmeasured.compact | 17 + .../missing-constraints/compact-lint.toml | 27 ++ .../fixtures/missing-constraints/expected.txt | 1 + .../tests/fixtures/missing-doc/Gaps.compact | 20 + .../fixtures/missing-doc/compact-lint.toml | 27 ++ .../tests/fixtures/missing-doc/expected.txt | 3 + .../fixtures/missing-tag/Untagged.compact | 10 + .../fixtures/missing-tag/compact-lint.toml | 27 ++ .../tests/fixtures/missing-tag/expected.txt | 2 + .../fixtures/module-name/Renamed.compact | 6 + .../fixtures/module-name/compact-lint.toml | 27 ++ .../tests/fixtures/module-name/expected.txt | 1 + .../tests/fixtures/parse/Broken.compact | 3 + .../tests/fixtures/parse/compact-lint.toml | 2 + .../tests/fixtures/parse/expected.txt | 1 + 43 files changed, 2904 insertions(+) create mode 100644 crates/compact-lint/Cargo.toml create mode 100644 crates/compact-lint/README.md create mode 100644 crates/compact-lint/examples/compact-contracts.toml create mode 100644 crates/compact-lint/src/check.rs create mode 100644 crates/compact-lint/src/config.rs create mode 100644 crates/compact-lint/src/discover.rs create mode 100644 crates/compact-lint/src/doc.rs create mode 100644 crates/compact-lint/src/format.rs create mode 100644 crates/compact-lint/src/lib.rs create mode 100644 crates/compact-lint/src/main.rs create mode 100644 crates/compact-lint/src/model.rs create mode 100644 crates/compact-lint/src/report.rs create mode 100644 crates/compact-lint/src/rules.rs create mode 100644 crates/compact-lint/tests/check.rs create mode 100644 crates/compact-lint/tests/fixtures/clean/Clean.compact create mode 100644 crates/compact-lint/tests/fixtures/clean/compact-lint.toml create mode 100644 crates/compact-lint/tests/fixtures/clean/expected.txt create mode 100644 crates/compact-lint/tests/fixtures/constraints-format/Malformed.compact create mode 100644 crates/compact-lint/tests/fixtures/constraints-format/compact-lint.toml create mode 100644 crates/compact-lint/tests/fixtures/constraints-format/expected.txt create mode 100644 crates/compact-lint/tests/fixtures/constraints-placeholder/Unfilled.compact create mode 100644 crates/compact-lint/tests/fixtures/constraints-placeholder/compact-lint.toml create mode 100644 crates/compact-lint/tests/fixtures/constraints-placeholder/expected.txt create mode 100644 crates/compact-lint/tests/fixtures/forbidden-tag/Returns.compact create mode 100644 crates/compact-lint/tests/fixtures/forbidden-tag/compact-lint.toml create mode 100644 crates/compact-lint/tests/fixtures/forbidden-tag/expected.txt create mode 100644 crates/compact-lint/tests/fixtures/missing-constraints/Unmeasured.compact create mode 100644 crates/compact-lint/tests/fixtures/missing-constraints/compact-lint.toml create mode 100644 crates/compact-lint/tests/fixtures/missing-constraints/expected.txt create mode 100644 crates/compact-lint/tests/fixtures/missing-doc/Gaps.compact create mode 100644 crates/compact-lint/tests/fixtures/missing-doc/compact-lint.toml create mode 100644 crates/compact-lint/tests/fixtures/missing-doc/expected.txt create mode 100644 crates/compact-lint/tests/fixtures/missing-tag/Untagged.compact create mode 100644 crates/compact-lint/tests/fixtures/missing-tag/compact-lint.toml create mode 100644 crates/compact-lint/tests/fixtures/missing-tag/expected.txt create mode 100644 crates/compact-lint/tests/fixtures/module-name/Renamed.compact create mode 100644 crates/compact-lint/tests/fixtures/module-name/compact-lint.toml create mode 100644 crates/compact-lint/tests/fixtures/module-name/expected.txt create mode 100644 crates/compact-lint/tests/fixtures/parse/Broken.compact create mode 100644 crates/compact-lint/tests/fixtures/parse/compact-lint.toml create mode 100644 crates/compact-lint/tests/fixtures/parse/expected.txt diff --git a/Cargo.lock b/Cargo.lock index f1a0406..80f353a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,88 @@ 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 = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[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 = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "cc" version = "1.4.6" @@ -21,6 +103,68 @@ dependencies = [ "shlex", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact-lint" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_cmd", + "clap", + "compact-tree-sitter", + "globset", + "serde", + "thiserror", + "toml", + "tree-sitter", + "walkdir", +] + [[package]] name = "compact-tree-sitter" version = "0.1.0" @@ -30,6 +174,12 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "equivalent" version = "1.0.2" @@ -42,12 +192,31 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +[[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.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "indexmap" version = "2.14.2" @@ -58,18 +227,69 @@ dependencies = [ "hashbrown", ] +[[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 = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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" @@ -117,6 +337,15 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[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" @@ -124,6 +353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -160,6 +390,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "2.0.1" @@ -172,6 +411,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "3.0.5" @@ -183,6 +428,71 @@ dependencies = [ "unicode-ident", ] +[[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", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tree-sitter" version = "0.25.10" @@ -209,6 +519,61 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[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 = "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 = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index af07435..5e096be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,16 @@ rust-version = "1.96" pedantic = { level = "warn", priority = -1 } [workspace.dependencies] +anyhow = "1.0" cc = "1" +clap = { version = "4.6", features = ["derive", "env"] } +globset = "0.4" +serde = { version = "1.0", features = ["derive"] } +thiserror = "2.0" +toml = "1.1" tree-sitter = "0.25" +compact-tree-sitter = { version = "0.1.0", path = "crates/compact-tree-sitter" } tree-sitter-language = "0.1" +walkdir = "2.5" + +assert_cmd = "2.2" diff --git a/crates/compact-lint/Cargo.toml b/crates/compact-lint/Cargo.toml new file mode 100644 index 0000000..0983475 --- /dev/null +++ b/crates/compact-lint/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "compact-lint" +version = "0.1.0" +description = "Doc-comment linter for Compact sources" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[lib] +name = "compact_lint" +path = "src/lib.rs" + +[[bin]] +name = "compact-lint" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +globset.workspace = true +serde.workspace = true +thiserror.workspace = true +toml.workspace = true +tree-sitter.workspace = true +compact-tree-sitter.workspace = true +walkdir.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true diff --git a/crates/compact-lint/README.md b/crates/compact-lint/README.md new file mode 100644 index 0000000..28bd628 --- /dev/null +++ b/crates/compact-lint/README.md @@ -0,0 +1,128 @@ +# compact-lint + +Doc-comment linter for Compact sources. It parses `.compact` files with +[`compact-tree-sitter`](../compact-tree-sitter), matches every declaration against the +per-kind template in `compact-lint.toml`, and prints one finding per line. + +## Usage + +```sh +compact-lint check [PATHS]... [--config ] [--strict] [--no-format] [--compact-bin ] +``` + +- `PATHS` — files or directories, walked recursively for `.compact`. Skipped while + walking: `node_modules`, `target`, `dist`, `build`, `.git`. +- No `PATHS` — the config's `include` globs are used instead. +- `--config ` — use this config instead of searching upward for + `compact-lint.toml`. +- `--strict` — turn `k=?` / `rows=?` placeholders into findings. Use it on release + branches. +- `--no-format` — skip the `compact format --check` pass. Required where the `compact` + binary is unavailable, CI included. +- `--compact-bin ` — path to the `compact` binary. Also settable with + `COMPACT_LINT_COMPACT_BIN`; the flag wins. + +Findings go to stdout, one per line, in `path:line:col: rule: message` form (the +rustc / eslint shape, so editors and `grep` both read it). The `N findings in M files` +summary goes to stderr, where `M` is the number of files checked. + +## Exit codes + +- `0` — no findings. +- `1` — findings. +- `2` — usage, config, IO or parser error. + +## Rules + +| Rule | Meaning | +| --- | --- | +| `missing-doc` | The declaration needs docs per `kinds..docs` and has no doc comment attached. | +| `missing-tag` | A tag listed in `kinds..tags` is absent from the doc comment. | +| `forbidden-tag` | A tag listed in `tags.forbid` is present. | +| `module-name` | `@module ` does not name the module it documents. | +| `missing-constraints` | An exported non-pure circuit has no constraints tag. | +| `constraints-format` | The constraints value is not `k=, rows=`. | +| `constraints-placeholder` | `--strict` only: the constraints value still holds a `?`. | +| `parse` | The file holds a syntax defect; the first one is reported and the doc rules are skipped for that file. | +| `format` | `compact format --check` reported the file. | + +Rule detail: + +- **Attachment.** A doc comment counts as attached only when it is the declaration's + immediately preceding *named* sibling. A `//` or `/* */` comment between the two + breaks the attachment and the declaration reads as undocumented. +- **Export.** "Exported" means the declaration carries the `export` token itself. A + non-exported circuit inside an exported module is not exported. +- **Constraints.** The rule applies to exported non-pure circuits only, and that is + fixed, not configurable. A missing doc comment reports as `missing-doc`, not + `missing-constraints`. +- **`module-name`.** The documented name is the first word of the `@module` value, + without its generic arguments or trailing punctuation, so `@module Signer` matches + `module Signer` and `@module Utils.` matches `module Utils`. +- **`forbidden-tag`.** It fires on any doc comment attached to a checked declaration, + including declarations whose docs are optional. + +## Config + +`compact-lint.toml`, searched for upward from the current directory. Every field is +optional; unknown fields are rejected. A config found by walking upward anchors the +`include` globs at its own directory; a config named with `--config` is a shareable +preset, so the globs stay anchored at the current directory. + +```toml +version = 1 +include = ["contracts/src/**/*.compact"] +exclude = ["**/test/mocks/**", "**/archive/**"] + +[constraints] +tag = "@constraints" + +[tags] +forbid = ["@return"] + +[kinds.module] +docs = "exported" +tags = ["@module", "@description"] +``` + +Defaults when no config file is found: + +| Key | Default | +| --- | --- | +| `version` | `1`, the only version this build accepts | +| `include` | `["**/*.compact"]` | +| `exclude` | `[]` | +| `constraints.tag` | `"@constraints"` | +| `tags.forbid` | `[]` | +| `kinds..docs` | `"exported"` | +| `kinds..tags` | `[]` | + +- `kinds` takes one table per kind: `module`, `circuit`, `ledger`, `witness`, + `constructor`, `struct`, `enum`, `contract`, `type`. `pragma`, `import`, `include`, + `export { … }` and `contract implements` are never checked. +- `docs` is `"all"`, `"exported"` or `"none"`. +- `constructor` carries no `export` token, so `"exported"` means "never" for it. Use + `"all"` or `"none"` there. +- `exclude` filters files found by walking a directory. A file named on the command + line is always checked. + +`examples/compact-contracts.toml` is the config for OpenZeppelin/compact-contracts. + +## Doc-comment model + +- `/**`, `*/` and the leading ` * ` gutter are stripped. +- A tag line's first non-blank token is `@` followed by one or more ASCII letters. +- A tag's value is the rest of its line plus every following line until the next tag or + a blank line. +- Repeated tags are kept in source order; `@param` normally repeats. +- `@param {Type} name - text` is not parsed further. + +## Tests + +```sh +cargo test --workspace +``` + +`tests/fixtures//` holds one case per rule plus a clean case: a +`compact-lint.toml`, the `.compact` sources, and `expected.txt`, the exact stdout with +paths relative to the case directory. diff --git a/crates/compact-lint/examples/compact-contracts.toml b/crates/compact-lint/examples/compact-contracts.toml new file mode 100644 index 0000000..402186d --- /dev/null +++ b/crates/compact-lint/examples/compact-contracts.toml @@ -0,0 +1,40 @@ +# compact-lint config for OpenZeppelin/compact-contracts. +# Copy it to the repository root as compact-lint.toml. + +version = 1 +include = ["contracts/src/**/*.compact"] +exclude = ["**/test/mocks/**", "**/archive/**"] + +# `@constraints` is the default; set `tag` to point the rule at another annotation. +[constraints] +tag = "@constraints" + +[tags] +forbid = ["@return"] + +[kinds.module] +docs = "exported" +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] + +[kinds.ledger] +tags = ["@description"] + +[kinds.witness] +docs = "all" +tags = ["@description"] + +[kinds.struct] +tags = ["@description"] + +[kinds.enum] +tags = ["@description"] + +[kinds.constructor] +docs = "none" + +[kinds.contract] + +[kinds.type] diff --git a/crates/compact-lint/src/check.rs b/crates/compact-lint/src/check.rs new file mode 100644 index 0000000..2f2d2a8 --- /dev/null +++ b/crates/compact-lint/src/check.rs @@ -0,0 +1,117 @@ +//! The `check` subcommand: resolve config, collect files, run the rules. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +use crate::config::{Config, ConfigError, discover as discover_config}; +use crate::discover::{self, DiscoverError}; +use crate::format::{self, FormatError}; +use crate::report::Finding; +use crate::rules::{LintError, Linter}; + +#[derive(Debug, Error)] +pub enum CheckError { + #[error(transparent)] + Config(#[from] ConfigError), + #[error(transparent)] + Discover(#[from] DiscoverError), + #[error(transparent)] + Lint(#[from] LintError), + #[error(transparent)] + Format(#[from] FormatError), + #[error("cannot read {path}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +/// Everything the `check` subcommand needs, already resolved from flags and env. +pub struct Options { + pub paths: Vec, + pub config_path: Option, + pub strict: bool, + pub no_format: bool, + pub compact_bin: OsString, +} + +/// The findings, plus how many files produced them. +pub struct Outcome { + pub findings: Vec, + pub files_checked: usize, +} + +impl Outcome { + #[must_use] + pub fn summary(&self) -> String { + format!( + "{} findings in {} files", + self.findings.len(), + self.files_checked + ) + } +} + +/// Runs the subcommand with `cwd` as the working directory. +/// # Errors +/// Returns an error when the config, the file walk, the parser or the formatter fails. +pub fn run(options: &Options, cwd: &Path) -> Result { + let (config, config_path, base) = resolve_config(options, cwd)?; + let source = config_path.as_deref().unwrap_or(Path::new("")); + + let exclude = config.exclude_set(source)?; + let files = if options.paths.is_empty() { + let include = config.include_set(source)?; + discover::from_globs(&base, &include, &exclude)? + } else { + discover::from_paths(&options.paths, &base, &exclude)? + }; + let files: Vec = files.iter().map(|path| display_path(path, cwd)).collect(); + + let mut linter = Linter::new()?; + let mut findings = Vec::new(); + for path in &files { + let text = std::fs::read_to_string(path).map_err(|source| CheckError::Read { + path: path.clone(), + source, + })?; + findings.extend(linter.check(path, &text, &config, options.strict)?); + } + + if !options.no_format { + findings.extend(format::check(&options.compact_bin, &files)?); + } + + findings.sort_by(|left, right| left.sort_key().cmp(&right.sort_key())); + Ok(Outcome { + findings, + files_checked: files.len(), + }) +} + +/// An explicit `--config` is a shareable preset, so glob discovery stays anchored at +/// `cwd`; a config found by walking upward anchors discovery at its own directory. +fn resolve_config( + options: &Options, + cwd: &Path, +) -> Result<(Config, Option, PathBuf), ConfigError> { + if let Some(path) = options.config_path.as_deref() { + return Ok((Config::load(path)?, Some(path.to_owned()), cwd.to_owned())); + } + + match discover_config(cwd) { + Some(path) => { + let base = path.parent().unwrap_or(cwd).to_owned(); + Ok((Config::load(&path)?, Some(path), base)) + } + None => Ok((Config::default(), None, cwd.to_owned())), + } +} + +/// Paths under the working directory print relative to it; anything else prints as is. +fn display_path(path: &Path, cwd: &Path) -> PathBuf { + path.strip_prefix(cwd).unwrap_or(path).to_owned() +} diff --git a/crates/compact-lint/src/config.rs b/crates/compact-lint/src/config.rs new file mode 100644 index 0000000..0d7ae2b --- /dev/null +++ b/crates/compact-lint/src/config.rs @@ -0,0 +1,305 @@ +//! `compact-lint.toml`: discovery, parsing, and the built-in defaults. + +use std::path::{Path, PathBuf}; + +use globset::{Glob, GlobSet, GlobSetBuilder}; +use serde::Deserialize; +use thiserror::Error; + +use crate::doc::Tag; +use crate::model::DeclKind; + +/// The config file name searched for upward from the current directory. +pub const CONFIG_FILE_NAME: &str = "compact-lint.toml"; + +/// The only schema version this build understands. +pub const SUPPORTED_VERSION: u32 = 1; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("cannot read config {path}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("cannot parse config {path}")] + Parse { + path: PathBuf, + #[source] + source: toml::de::Error, + }, + #[error( + "config {path} is version {found}, but this build understands version {SUPPORTED_VERSION}" + )] + Version { path: PathBuf, found: u32 }, + #[error( + "config {path} lists tag {tag:?}; tags are spelled with their `@`, like `@description`" + )] + Tag { path: PathBuf, tag: String }, + #[error("config {path} has an invalid glob {pattern:?}")] + Glob { + path: PathBuf, + pattern: String, + #[source] + source: globset::Error, + }, +} + +/// How much of a kind needs documenting. +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DocsPolicy { + /// Every declaration of the kind. + All, + /// Only declarations carrying the `export` token. + #[default] + Exported, + /// The kind is not checked for docs. + None, +} + +/// Per-kind requirements. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, default)] +pub struct KindConfig { + pub docs: DocsPolicy, + pub tags: Vec, +} + +/// One table per checked declaration kind. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, default)] +pub struct KindsConfig { + pub module: KindConfig, + pub circuit: KindConfig, + pub ledger: KindConfig, + pub witness: KindConfig, + pub constructor: KindConfig, + #[serde(rename = "struct")] + pub struct_: KindConfig, + #[serde(rename = "enum")] + pub enum_: KindConfig, + pub contract: KindConfig, + #[serde(rename = "type")] + pub type_: KindConfig, +} + +impl KindsConfig { + #[must_use] + pub fn get(&self, kind: DeclKind) -> &KindConfig { + match kind { + DeclKind::Module => &self.module, + DeclKind::Circuit => &self.circuit, + DeclKind::Ledger => &self.ledger, + DeclKind::Witness => &self.witness, + DeclKind::Constructor => &self.constructor, + DeclKind::Struct => &self.struct_, + DeclKind::Enum => &self.enum_, + DeclKind::Contract => &self.contract, + DeclKind::Type => &self.type_, + } + } +} + +/// The circuit constraints annotation. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, default)] +pub struct ConstraintsConfig { + pub tag: Tag, +} + +impl Default for ConstraintsConfig { + fn default() -> Self { + Self { + tag: Tag::new("@constraints"), + } + } +} + +/// Tags no doc comment may carry. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, default)] +pub struct TagsConfig { + pub forbid: Vec, +} + +/// A parsed `compact-lint.toml`, or the built-in defaults when no file was found. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, default)] +pub struct Config { + pub version: u32, + pub include: Vec, + pub exclude: Vec, + pub constraints: ConstraintsConfig, + pub tags: TagsConfig, + pub kinds: KindsConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + version: SUPPORTED_VERSION, + include: vec!["**/*.compact".to_owned()], + exclude: Vec::new(), + constraints: ConstraintsConfig::default(), + tags: TagsConfig::default(), + kinds: KindsConfig::default(), + } + } +} + +impl Config { + /// Parses a config file and rejects a version this build does not implement. + /// # Errors + /// Returns an error when the file is unreadable, malformed, or a future version. + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read { + path: path.to_owned(), + source, + })?; + + let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse { + path: path.to_owned(), + source, + })?; + + if config.version != SUPPORTED_VERSION { + return Err(ConfigError::Version { + path: path.to_owned(), + found: config.version, + }); + } + + if let Some(tag) = config.tags().find(|tag| !tag.is_well_formed()) { + return Err(ConfigError::Tag { + path: path.to_owned(), + tag: tag.to_string(), + }); + } + + Ok(config) + } + + /// Every tag the config mentions: required per kind, forbidden, and the constraints tag. + fn tags(&self) -> impl Iterator { + let per_kind = [ + &self.kinds.module, + &self.kinds.circuit, + &self.kinds.ledger, + &self.kinds.witness, + &self.kinds.constructor, + &self.kinds.struct_, + &self.kinds.enum_, + &self.kinds.contract, + &self.kinds.type_, + ] + .into_iter() + .flat_map(|kind| kind.tags.iter()); + + per_kind + .chain(self.tags.forbid.iter()) + .chain(std::iter::once(&self.constraints.tag)) + } + + /// Compiles the `include` globs. + /// # Errors + /// Returns an error when a pattern is not a valid glob. + pub fn include_set(&self, path: &Path) -> Result { + glob_set(&self.include, path) + } + + /// Compiles the `exclude` globs; an empty set matches nothing. + /// # Errors + /// Returns an error when a pattern is not a valid glob. + pub fn exclude_set(&self, path: &Path) -> Result { + glob_set(&self.exclude, path) + } +} + +fn glob_set(patterns: &[String], path: &Path) -> Result { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + let glob = Glob::new(pattern).map_err(|source| ConfigError::Glob { + path: path.to_owned(), + pattern: pattern.clone(), + source, + })?; + builder.add(glob); + } + builder.build().map_err(|source| ConfigError::Glob { + path: path.to_owned(), + pattern: String::new(), + source, + }) +} + +/// Walks up from `start` looking for `compact-lint.toml`. +#[must_use] +pub fn discover(start: &Path) -> Option { + for directory in start.ancestors() { + let candidate = directory.join(CONFIG_FILE_NAME); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::{Config, DocsPolicy, SUPPORTED_VERSION}; + use crate::doc::Tag; + use crate::model::DeclKind; + + #[test] + fn defaults_require_docs_on_exported_declarations_only() { + let config = Config::default(); + + assert_eq!(config.version, SUPPORTED_VERSION); + assert_eq!(config.constraints.tag, Tag::new("@constraints")); + for kind in [DeclKind::Module, DeclKind::Circuit, DeclKind::Type] { + assert_eq!(config.kinds.get(kind).docs, DocsPolicy::Exported); + assert!(config.kinds.get(kind).tags.is_empty()); + } + } + + #[test] + fn omitted_tables_fall_back_to_defaults() { + let config: Config = toml::from_str("version = 1\n[kinds.witness]\ndocs = \"all\"\n") + .expect("the snippet is valid config"); + + assert_eq!(config.kinds.witness.docs, DocsPolicy::All); + assert_eq!(config.kinds.circuit.docs, DocsPolicy::Exported); + assert_eq!(config.include, ["**/*.compact"]); + } + + #[test] + fn an_unknown_key_is_rejected() { + let error = toml::from_str::("verison = 1\n").expect_err("the key is misspelled"); + + assert!(error.to_string().contains("verison"), "{error}"); + } + + #[test] + fn a_tag_without_its_at_sign_is_rejected() { + let dir = std::env::temp_dir().join(format!("compact-lint-config-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("the temp dir is writable"); + let path = dir.join("compact-lint.toml"); + std::fs::write(&path, "[kinds.circuit]\ntags = [\"description\"]\n") + .expect("the config is writable"); + + let error = Config::load(&path).expect_err("the tag lacks its @"); + + assert!(error.to_string().contains("\"description\""), "{error}"); + std::fs::remove_dir_all(&dir).expect("the temp dir is removable"); + } + + #[test] + fn an_unknown_kind_is_rejected() { + let error = + toml::from_str::("[kinds.circiut]\n").expect_err("the kind is misspelled"); + + assert!(error.to_string().contains("circiut"), "{error}"); + } +} diff --git a/crates/compact-lint/src/discover.rs b/crates/compact-lint/src/discover.rs new file mode 100644 index 0000000..f647c9f --- /dev/null +++ b/crates/compact-lint/src/discover.rs @@ -0,0 +1,114 @@ +//! Turning CLI paths and config globs into the list of files to check. + +use std::path::{Path, PathBuf}; + +use globset::GlobSet; +use thiserror::Error; +use walkdir::WalkDir; + +/// Directories that never hold first-party Compact sources. +const SKIPPED_DIRS: [&str; 5] = ["node_modules", "target", "dist", "build", ".git"]; + +const COMPACT_EXTENSION: &str = "compact"; + +#[derive(Debug, Error)] +pub enum DiscoverError { + #[error("cannot walk {path}")] + Walk { + path: PathBuf, + #[source] + source: walkdir::Error, + }, + #[error("path {0} does not exist")] + Missing(PathBuf), +} + +/// Collects the `.compact` files under the given paths. +/// +/// A path named on the command line is always checked, even when `exclude` matches +/// it; files found by walking a directory are filtered by `exclude`. +/// # Errors +/// Returns an error when a path does not exist or a directory cannot be walked. +pub fn from_paths( + paths: &[PathBuf], + base: &Path, + exclude: &GlobSet, +) -> Result, DiscoverError> { + let mut files = Vec::new(); + + for path in paths { + if path.is_file() { + files.push(path.clone()); + } else if path.is_dir() { + walk(path, base, exclude, None, &mut files)?; + } else { + return Err(DiscoverError::Missing(path.clone())); + } + } + + Ok(sorted(files)) +} + +/// Collects the `.compact` files under `base` that `include` matches and `exclude` does not. +/// # Errors +/// Returns an error when `base` cannot be walked. +pub fn from_globs( + base: &Path, + include: &GlobSet, + exclude: &GlobSet, +) -> Result, DiscoverError> { + let mut files = Vec::new(); + walk(base, base, exclude, Some(include), &mut files)?; + Ok(sorted(files)) +} + +fn walk( + root: &Path, + base: &Path, + exclude: &GlobSet, + include: Option<&GlobSet>, + out: &mut Vec, +) -> Result<(), DiscoverError> { + let walker = WalkDir::new(root) + .follow_links(false) + .into_iter() + .filter_entry(|entry| entry.depth() == 0 || !is_skipped_dir(entry.file_name())); + + for entry in walker { + let entry = entry.map_err(|source| DiscoverError::Walk { + path: root.to_owned(), + source, + })?; + + if !entry.file_type().is_file() { + continue; + } + + let path = entry.path(); + if path + .extension() + .is_none_or(|extension| extension != COMPACT_EXTENSION) + { + continue; + } + + let relative = path.strip_prefix(base).unwrap_or(path); + if include.is_some_and(|set| !set.is_match(relative)) || exclude.is_match(relative) { + continue; + } + + out.push(path.to_owned()); + } + + Ok(()) +} + +fn is_skipped_dir(name: &std::ffi::OsStr) -> bool { + SKIPPED_DIRS.iter().any(|skipped| name == *skipped) +} + +fn sorted(mut files: Vec) -> Vec { + files.sort(); + files.dedup(); + files +} diff --git a/crates/compact-lint/src/doc.rs b/crates/compact-lint/src/doc.rs new file mode 100644 index 0000000..685007f --- /dev/null +++ b/crates/compact-lint/src/doc.rs @@ -0,0 +1,211 @@ +//! Parsing of `/** … */` doc comments into an ordered tag list. + +use std::fmt; + +use serde::Deserialize; + +/// A doc-comment tag name, stored with its leading `@` (`@description`). +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[serde(transparent)] +pub struct Tag(String); + +impl Tag { + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// `@` followed by at least one ASCII letter, the same shape the parser recognises. + #[must_use] + pub fn is_well_formed(&self) -> bool { + self.0.strip_prefix('@').is_some_and(|name| { + !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_alphabetic()) + }) + } +} + +impl fmt::Display for Tag { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +/// One tag occurrence: its name, its value, and where it sits inside the comment. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TagOccurrence { + pub tag: Tag, + pub value: String, + /// 0-based line index inside the doc comment, counted from the `/**` line. + pub line_offset: usize, + /// 0-based byte offset of the `@` in its line; on the first line, counted after `/**`. + pub column_offset: usize, +} + +/// A parsed doc comment. Repeated tags (`@param`) are kept in source order. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DocComment { + tags: Vec, +} + +impl DocComment { + /// Parses the raw `/** … */` text, stripping the delimiters and the ` * ` gutter. + #[must_use] + pub fn parse(raw: &str) -> Self { + let opened = raw.strip_prefix("/**").unwrap_or(raw); + let inner = opened.strip_suffix("*/").unwrap_or(opened); + + let mut tags: Vec = Vec::new(); + let mut open: Option = None; + + for (line_offset, line) in inner.lines().enumerate() { + let content = strip_gutter(line).trim(); + + if content.is_empty() { + open = None; + continue; + } + + if let Some((tag, value)) = split_tag(content) { + tags.push(TagOccurrence { + tag, + value: value.to_owned(), + line_offset, + column_offset: line.find('@').unwrap_or(0), + }); + open = Some(tags.len() - 1); + continue; + } + + // A non-blank, non-tag line continues the tag above it. + if let Some(index) = open + && let Some(occurrence) = tags.get_mut(index) + { + if !occurrence.value.is_empty() { + occurrence.value.push('\n'); + } + occurrence.value.push_str(content); + } + } + + Self { tags } + } + + /// Every tag occurrence, in source order. + #[must_use] + pub fn tags(&self) -> &[TagOccurrence] { + &self.tags + } + + #[must_use] + pub fn has(&self, tag: &Tag) -> bool { + self.tags.iter().any(|occurrence| &occurrence.tag == tag) + } + + /// The first occurrence of `tag`, which is the one every rule reads. + #[must_use] + pub fn first(&self, tag: &Tag) -> Option<&TagOccurrence> { + self.tags.iter().find(|occurrence| &occurrence.tag == tag) + } + + /// The value of the first occurrence of `tag`. + #[must_use] + pub fn value(&self, tag: &Tag) -> Option<&str> { + self.first(tag).map(|occurrence| occurrence.value.as_str()) + } +} + +/// Removes the leading whitespace, one `*`, and one following space from a comment line. +fn strip_gutter(line: &str) -> &str { + let trimmed = line.trim_start(); + match trimmed.strip_prefix('*') { + Some(rest) => rest.strip_prefix(' ').unwrap_or(rest), + None => trimmed, + } +} + +/// A tag line starts with `@` followed by at least one ASCII letter. +fn split_tag(content: &str) -> Option<(Tag, &str)> { + let rest = content.strip_prefix('@')?; + let name_len = rest + .find(|character: char| !character.is_ascii_alphabetic()) + .unwrap_or(rest.len()); + if name_len == 0 { + return None; + } + + let (name, value) = rest.split_at(name_len); + Some((Tag::new(format!("@{name}")), value.trim())) +} + +#[cfg(test)] +mod tests { + use super::{DocComment, Tag}; + + #[test] + fn gutter_and_delimiters_are_stripped() { + let doc = DocComment::parse("/**\n * @description Adds two numbers.\n */"); + + assert_eq!(doc.tags().len(), 1); + assert_eq!(doc.tags()[0].tag, Tag::new("@description")); + assert_eq!(doc.tags()[0].value, "Adds two numbers."); + assert_eq!(doc.tags()[0].line_offset, 1); + assert_eq!(doc.tags()[0].column_offset, 3); + } + + #[test] + fn a_blank_line_ends_a_value() { + let doc = DocComment::parse("/**\n * @notice first\n *\n * loose prose\n */"); + + assert_eq!(doc.value(&Tag::new("@notice")), Some("first")); + } + + #[test] + fn a_non_tag_line_continues_the_tag_above_it() { + let doc = DocComment::parse("/**\n * @notice first\n * second\n */"); + + assert_eq!(doc.value(&Tag::new("@notice")), Some("first\nsecond")); + } + + #[test] + fn repeated_tags_keep_source_order() { + let doc = DocComment::parse( + "/**\n * @param {Uint<8>} a - left\n * @param {Uint<8>} b - right\n */", + ); + + let params: Vec<&str> = doc + .tags() + .iter() + .filter(|occurrence| occurrence.tag == Tag::new("@param")) + .map(|occurrence| occurrence.value.as_str()) + .collect(); + assert_eq!(params, ["{Uint<8>} a - left", "{Uint<8>} b - right"]); + } + + #[test] + fn return_and_returns_are_distinct_tags() { + let doc = DocComment::parse("/**\n * @returns The sum.\n */"); + + assert!(doc.has(&Tag::new("@returns"))); + assert!(!doc.has(&Tag::new("@return"))); + } + + #[test] + fn an_at_sign_without_letters_is_not_a_tag() { + let doc = DocComment::parse("/**\n * @1234 not a tag\n * mail@example.com\n */"); + + assert!(doc.tags().is_empty()); + } + + #[test] + fn a_single_line_comment_parses() { + let doc = DocComment::parse("/** @description One liner. */"); + + assert_eq!(doc.value(&Tag::new("@description")), Some("One liner.")); + assert_eq!(doc.tags()[0].line_offset, 0); + assert_eq!(doc.tags()[0].column_offset, 1); + } +} diff --git a/crates/compact-lint/src/format.rs b/crates/compact-lint/src/format.rs new file mode 100644 index 0000000..d3a0c46 --- /dev/null +++ b/crates/compact-lint/src/format.rs @@ -0,0 +1,156 @@ +//! The optional `compact format --check` pass. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use thiserror::Error; + +use crate::report::{Finding, Position, RuleId}; + +/// Environment override for the `compact` binary; the `--compact-bin` flag wins over it. +pub const COMPACT_BIN_ENV: &str = "COMPACT_LINT_COMPACT_BIN"; + +/// The default binary name, resolved through `PATH`. +pub const DEFAULT_COMPACT_BIN: &str = "compact"; + +pub const MESSAGE: &str = "not formatted; run `compact format`"; + +#[derive(Debug, Error)] +pub enum FormatError { + #[error("cannot run `{binary}`; pass --no-format to skip the format check")] + BinaryMissing { binary: String }, + #[error("cannot run `{binary}`")] + Spawn { + binary: String, + #[source] + source: std::io::Error, + }, +} + +/// Runs `compact format --check` once over every file and maps its report to findings. +/// +/// The formatter writes one `:` line per unformatted file to stderr, followed by +/// an indented diff. An output that holds no such line yields one finding on the first +/// checked file carrying the formatter's own first line. +/// # Errors +/// Returns an error when the `compact` binary is missing or cannot be spawned. +pub fn check(binary: &OsStr, files: &[PathBuf]) -> Result, FormatError> { + if files.is_empty() { + return Ok(Vec::new()); + } + + let output = Command::new(binary) + .arg("format") + .arg("--check") + .args(files) + .output() + .map_err(|source| { + let binary = binary.to_string_lossy().into_owned(); + if source.kind() == std::io::ErrorKind::NotFound { + FormatError::BinaryMissing { binary } + } else { + FormatError::Spawn { binary, source } + } + })?; + + if output.status.success() { + return Ok(Vec::new()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let reported: Vec<&str> = reported_paths(&stderr) + .chain(reported_paths(&stdout)) + .collect(); + + if reported.is_empty() { + let raw = first_line(&stderr) + .or_else(|| first_line(&stdout)) + .unwrap_or("compact format --check failed"); + return Ok(vec![Finding::new( + files[0].clone(), + Position::file_start(), + RuleId::FORMAT, + raw.to_owned(), + )]); + } + + Ok(reported + .into_iter() + .map(|path| { + Finding::new( + resolve(path, files), + Position::file_start(), + RuleId::FORMAT, + MESSAGE.to_owned(), + ) + }) + .collect()) +} + +/// Unformatted files are announced as a flush-left `:` line. +fn reported_paths(output: &str) -> impl Iterator { + output.lines().filter_map(|line| { + let path = line.strip_suffix(':')?; + (!path.is_empty() && !path.starts_with(char::is_whitespace) && path.ends_with(".compact")) + .then_some(path) + }) +} + +/// Prefers the path spelling the caller passed in, so output stays consistent. +/// Matching is by whole path components, so `Token.compact` never claims `NativeToken.compact`. +fn resolve(reported: &str, files: &[PathBuf]) -> PathBuf { + let reported = Path::new(reported); + files + .iter() + .find(|file| reported.ends_with(file) || file.ends_with(reported)) + .cloned() + .unwrap_or_else(|| reported.to_owned()) +} + +fn first_line(output: &str) -> Option<&str> { + output.lines().map(str::trim).find(|line| !line.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::{reported_paths, resolve}; + use std::path::PathBuf; + + const SAMPLE: &str = "/tmp/bad.compact:\n 18 | export ledger round: Counter;\n- 21 | - round.increment(1);\n+ 21 | + round.increment(1);\n\nError: formatting failed\n"; + + #[test] + fn flush_left_path_lines_become_findings() { + let paths: Vec<&str> = reported_paths(SAMPLE).collect(); + + assert_eq!(paths, ["/tmp/bad.compact"]); + } + + #[test] + fn indented_diff_lines_are_not_paths() { + let paths: Vec<&str> = reported_paths(" a.compact:\n\ttrailing:\n").collect(); + + assert!(paths.is_empty()); + } + + #[test] + fn an_absolute_report_maps_back_to_the_relative_path_passed_in() { + let files = [PathBuf::from("contracts/src/Token.compact")]; + + assert_eq!( + resolve("/work/contracts/src/Token.compact", &files), + PathBuf::from("contracts/src/Token.compact") + ); + } + + #[test] + fn a_shorter_file_name_with_the_same_suffix_is_not_a_match() { + let files = [PathBuf::from("Token.compact")]; + + assert_eq!( + resolve("/work/NativeToken.compact", &files), + PathBuf::from("/work/NativeToken.compact") + ); + } +} diff --git a/crates/compact-lint/src/lib.rs b/crates/compact-lint/src/lib.rs new file mode 100644 index 0000000..fdaf6e9 --- /dev/null +++ b/crates/compact-lint/src/lib.rs @@ -0,0 +1,16 @@ +//! Doc-comment linter for Compact sources. +//! +//! The crate parses `.compact` files with the bundled tree-sitter grammar, matches each +//! declaration against the per-kind doc-comment template in `compact-lint.toml`, and +//! reports one [`report::Finding`] per violation. + +#![forbid(unsafe_code)] + +pub mod check; +pub mod config; +pub mod discover; +pub mod doc; +pub mod format; +pub mod model; +pub mod report; +pub mod rules; diff --git a/crates/compact-lint/src/main.rs b/crates/compact-lint/src/main.rs new file mode 100644 index 0000000..dc4d364 --- /dev/null +++ b/crates/compact-lint/src/main.rs @@ -0,0 +1,114 @@ +//! `compact-lint` command line entry point. + +#![forbid(unsafe_code)] + +use std::ffi::OsString; +use std::io::Write; +use std::path::PathBuf; +use std::process::ExitCode; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; + +use compact_lint::check::{self, Options, Outcome}; +use compact_lint::format::{COMPACT_BIN_ENV, DEFAULT_COMPACT_BIN}; + +/// Exit code for a run that produced findings. +const EXIT_FINDINGS: u8 = 1; + +/// Exit code for a usage, config, IO or parser error. +const EXIT_ERROR: u8 = 2; + +#[derive(Debug, Parser)] +#[command( + name = "compact-lint", + version, + about = "Doc-comment linter for Compact sources" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Check doc comments against the per-kind templates in compact-lint.toml. + Check(CheckArgs), +} + +#[derive(Debug, clap::Args)] +struct CheckArgs { + /// Files or directories to check; defaults to the config's include globs. + paths: Vec, + + /// Config file to use instead of searching upward for compact-lint.toml. + #[arg(long, value_name = "FILE")] + config: Option, + + /// Report `k=?` / `rows=?` placeholders in the constraints tag. + #[arg(long)] + strict: bool, + + /// Skip the `compact format --check` pass. + #[arg(long)] + no_format: bool, + + /// Path to the `compact` binary. + #[arg(long, value_name = "PATH", env = COMPACT_BIN_ENV)] + compact_bin: Option, +} + +fn main() -> ExitCode { + match run() { + Ok(code) => code, + Err(error) => { + report_error(&error); + ExitCode::from(EXIT_ERROR) + } + } +} + +fn run() -> Result { + let cli = Cli::parse(); + let Command::Check(args) = cli.command; + + let cwd = std::env::current_dir().context("reading the current directory")?; + let options = Options { + paths: args.paths, + config_path: args.config, + strict: args.strict, + no_format: args.no_format, + compact_bin: args + .compact_bin + .unwrap_or_else(|| OsString::from(DEFAULT_COMPACT_BIN)), + }; + + let outcome = check::run(&options, &cwd).context("running the check")?; + emit(&outcome).context("writing the report")?; + + Ok(if outcome.findings.is_empty() { + ExitCode::SUCCESS + } else { + ExitCode::from(EXIT_FINDINGS) + }) +} + +fn emit(outcome: &Outcome) -> std::io::Result<()> { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + for finding in &outcome.findings { + writeln!(out, "{finding}")?; + } + out.flush()?; + + eprintln!("{}", outcome.summary()); + Ok(()) +} + +/// Prints the error and every source under it, one cause per line. +fn report_error(error: &anyhow::Error) { + eprintln!("compact-lint: {error}"); + for cause in error.chain().skip(1) { + eprintln!(" caused by: {cause}"); + } +} diff --git a/crates/compact-lint/src/model.rs b/crates/compact-lint/src/model.rs new file mode 100644 index 0000000..968eee8 --- /dev/null +++ b/crates/compact-lint/src/model.rs @@ -0,0 +1,177 @@ +//! Declarations the linter checks, lifted out of the tree-sitter tree. + +use std::fmt; + +use tree_sitter::Node; + +use crate::doc::{DocComment, TagOccurrence}; +use crate::report::Position; + +/// The declaration kinds the linter has rules for. +/// +/// `pragma`, `include`, `import`, `export { … }` and `contract implements` are +/// never checked, so they have no variant here. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum DeclKind { + Module, + Circuit, + Ledger, + Witness, + Constructor, + Struct, + Enum, + Contract, + Type, +} + +impl DeclKind { + /// Maps a tree-sitter node kind, returning `None` for unchecked declarations. + #[must_use] + pub fn from_node_kind(node_kind: &str) -> Option { + match node_kind { + "module_declaration" => Some(Self::Module), + "circuit_declaration" => Some(Self::Circuit), + "ledger_declaration" => Some(Self::Ledger), + "witness_declaration" => Some(Self::Witness), + "constructor_declaration" => Some(Self::Constructor), + "struct_declaration" => Some(Self::Struct), + "enum_declaration" => Some(Self::Enum), + "contract_declaration" => Some(Self::Contract), + "type_declaration" => Some(Self::Type), + _ => None, + } + } + + /// The name used in config keys and messages. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Module => "module", + Self::Circuit => "circuit", + Self::Ledger => "ledger", + Self::Witness => "witness", + Self::Constructor => "constructor", + Self::Struct => "struct", + Self::Enum => "enum", + Self::Contract => "contract", + Self::Type => "type", + } + } +} + +impl fmt::Display for DeclKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// One declaration with the facts the rules need. +#[derive(Clone, Debug)] +pub struct Declaration { + pub kind: DeclKind, + /// The `name` field; `constructor` has none. + pub name: Option, + /// The declaration carries the `export` token itself; nesting never grants it. + pub exported: bool, + pub pure: bool, + pub position: Position, + /// The doc comment immediately preceding the declaration, if one attaches. + pub doc: Option, +} + +/// A doc comment bound to a declaration, with the source line it starts on. +#[derive(Clone, Debug)] +pub struct AttachedDoc { + pub comment: DocComment, + /// 0-based row of the `/**` line, for turning a tag's offset into a position. + pub start_row: usize, + pub start_column: usize, +} + +impl AttachedDoc { + /// The position of a tag's `@`; a first-line offset is counted after the `/**`. + #[must_use] + pub const fn tag_position(&self, occurrence: &TagOccurrence) -> Position { + let column = if occurrence.line_offset == 0 { + self.start_column + "/**".len() + occurrence.column_offset + } else { + occurrence.column_offset + }; + Position::from_zero_based(self.start_row + occurrence.line_offset, column) + } +} + +/// Collects every checked declaration, descending into module bodies. +#[must_use] +pub fn declarations(root: Node<'_>, source: &str) -> Vec { + let mut found = Vec::new(); + collect(root, source, &mut found); + found +} + +fn collect(parent: Node<'_>, source: &str, out: &mut Vec) { + let mut cursor = parent.walk(); + for node in parent.named_children(&mut cursor) { + let Some(kind) = DeclKind::from_node_kind(node.kind()) else { + continue; + }; + + out.push(declaration(kind, node, source)); + + // Only modules nest; every other body is an opaque token run. + if kind == DeclKind::Module + && let Some(body) = node.child_by_field_name("body") + { + collect(body, source, out); + } + } +} + +fn declaration(kind: DeclKind, node: Node<'_>, source: &str) -> Declaration { + let mut cursor = node.walk(); + let mut exported = false; + let mut pure = false; + for child in node.children(&mut cursor) { + match child.kind() { + "export" => exported = true, + "pure" => pure = true, + _ => {} + } + } + + let name = node + .child_by_field_name("name") + .and_then(|name| text(name, source)) + .map(str::to_owned); + + let start = node.start_position(); + + Declaration { + kind, + name, + exported, + pure, + position: Position::from_zero_based(start.row, start.column), + doc: attached_doc(node, source), + } +} + +/// A doc comment attaches only as the immediately preceding named sibling; a line +/// or block comment in between breaks the attachment. +fn attached_doc(node: Node<'_>, source: &str) -> Option { + let previous = node.prev_named_sibling()?; + if previous.kind() != "doc_comment" { + return None; + } + + let start = previous.start_position(); + Some(AttachedDoc { + comment: DocComment::parse(text(previous, source)?), + start_row: start.row, + start_column: start.column, + }) +} + +fn text<'a>(node: Node<'_>, source: &'a str) -> Option<&'a str> { + source.get(node.start_byte()..node.end_byte()) +} diff --git a/crates/compact-lint/src/report.rs b/crates/compact-lint/src/report.rs new file mode 100644 index 0000000..b2d9f17 --- /dev/null +++ b/crates/compact-lint/src/report.rs @@ -0,0 +1,142 @@ +//! Findings and the one-line `path:line:col: rule: message` rendering. + +use std::fmt; +use std::path::{Path, PathBuf}; + +/// A lint rule's stable identifier, as printed in the output and matched by CI greps. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct RuleId(&'static str); + +impl RuleId { + pub const MISSING_DOC: Self = Self("missing-doc"); + pub const MISSING_TAG: Self = Self("missing-tag"); + pub const FORBIDDEN_TAG: Self = Self("forbidden-tag"); + pub const MODULE_NAME: Self = Self("module-name"); + pub const MISSING_CONSTRAINTS: Self = Self("missing-constraints"); + pub const CONSTRAINTS_FORMAT: Self = Self("constraints-format"); + pub const CONSTRAINTS_PLACEHOLDER: Self = Self("constraints-placeholder"); + pub const PARSE: Self = Self("parse"); + pub const FORMAT: Self = Self("format"); + + /// Every rule this build can emit, in the order the README lists them. + pub const ALL: [Self; 9] = [ + Self::MISSING_DOC, + Self::MISSING_TAG, + Self::FORBIDDEN_TAG, + Self::MODULE_NAME, + Self::MISSING_CONSTRAINTS, + Self::CONSTRAINTS_FORMAT, + Self::CONSTRAINTS_PLACEHOLDER, + Self::PARSE, + Self::FORMAT, + ]; + + #[must_use] + pub const fn as_str(self) -> &'static str { + self.0 + } +} + +impl fmt::Display for RuleId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +/// A 1-based source position; column 1 is the first byte of a line. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Position { + pub line: usize, + pub column: usize, +} + +impl Position { + /// Converts a tree-sitter 0-based row/column pair. + #[must_use] + pub const fn from_zero_based(row: usize, column: usize) -> Self { + Self { + line: row + 1, + column: column + 1, + } + } + + /// The start of a file, used by whole-file findings. + #[must_use] + pub const fn file_start() -> Self { + Self { line: 1, column: 1 } + } +} + +/// One lint violation at one source position. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Finding { + pub path: PathBuf, + pub position: Position, + pub rule: RuleId, + pub message: String, +} + +impl Finding { + pub fn new( + path: impl Into, + position: Position, + rule: RuleId, + message: String, + ) -> Self { + Self { + path: path.into(), + position, + rule, + message, + } + } + + /// Sorts findings by file, then position, then rule, so output is stable across runs. + #[must_use] + pub fn sort_key(&self) -> (&Path, Position, RuleId) { + (self.path.as_path(), self.position, self.rule) + } +} + +impl fmt::Display for Finding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "{}:{}:{}: {}: {}", + self.path.display(), + self.position.line, + self.position.column, + self.rule, + self.message + ) + } +} + +#[cfg(test)] +mod tests { + use super::{Finding, Position, RuleId}; + + #[test] + fn finding_renders_as_path_line_col_rule_message() { + let finding = Finding::new( + "contracts/src/access/Ownable.compact", + Position::from_zero_based(41, 0), + RuleId::MISSING_DOC, + "circuit `assertOnlyOwner` has no doc comment".to_owned(), + ); + + assert_eq!( + finding.to_string(), + "contracts/src/access/Ownable.compact:42:1: missing-doc: circuit `assertOnlyOwner` has no doc comment" + ); + } + + #[test] + fn rule_ids_are_unique() { + let mut ids: Vec<&str> = RuleId::ALL.iter().map(|rule| rule.as_str()).collect(); + ids.sort_unstable(); + let count = ids.len(); + ids.dedup(); + assert_eq!(ids.len(), count); + } +} diff --git a/crates/compact-lint/src/rules.rs b/crates/compact-lint/src/rules.rs new file mode 100644 index 0000000..9bc0bf7 --- /dev/null +++ b/crates/compact-lint/src/rules.rs @@ -0,0 +1,415 @@ +//! The doc-comment rules, run over one parsed file at a time. + +use std::path::{Path, PathBuf}; + +use thiserror::Error; +use tree_sitter::{Node, Parser}; + +use crate::config::{Config, DocsPolicy}; +use crate::doc::Tag; +use crate::model::{DeclKind, Declaration, declarations}; +use crate::report::{Finding, Position, RuleId}; + +#[derive(Debug, Error)] +pub enum LintError { + #[error("the bundled Compact grammar does not match the linked tree-sitter ABI")] + Language(#[source] tree_sitter::LanguageError), + #[error("tree-sitter returned no parse tree for {0}")] + NoTree(PathBuf), +} + +/// A reusable parser plus the rules that run over its trees. +pub struct Linter { + parser: Parser, +} + +impl Linter { + /// # Errors + /// Returns an error when the bundled grammar does not match the linked ABI. + pub fn new() -> Result { + let mut parser = Parser::new(); + parser + .set_language(&compact_tree_sitter::LANGUAGE.into()) + .map_err(LintError::Language)?; + Ok(Self { parser }) + } + + /// Checks one file. `display` is the path printed in findings. + /// + /// A tree holding an `ERROR` or `MISSING` node yields a single `parse` finding and + /// no doc findings, because declarations around the defect are not trustworthy. + /// # Errors + /// Returns an error when tree-sitter produces no tree for `source`. + pub fn check( + &mut self, + display: &Path, + source: &str, + config: &Config, + strict: bool, + ) -> Result, LintError> { + let tree = self + .parser + .parse(source, None) + .ok_or_else(|| LintError::NoTree(display.to_owned()))?; + let root = tree.root_node(); + + if let Some(defect) = first_defect(root) { + return Ok(vec![defect.finding(display)]); + } + + let mut findings = Vec::new(); + for declaration in declarations(root, source) { + check_declaration(&declaration, display, config, strict, &mut findings); + } + findings.sort_by(|left, right| left.sort_key().cmp(&right.sort_key())); + Ok(findings) + } +} + +struct Defect { + position: Position, + kind: String, + missing: bool, +} + +impl Defect { + fn finding(&self, display: &Path) -> Finding { + let message = if self.missing { + format!("parse error: missing {}", self.kind) + } else { + format!("parse error: unexpected {}", self.kind) + }; + Finding::new(display, self.position, RuleId::PARSE, message) + } +} + +/// The first `ERROR` or `MISSING` node in document order. +fn first_defect(node: Node<'_>) -> Option { + if node.is_error() || node.is_missing() { + let start = node.start_position(); + return Some(Defect { + position: Position::from_zero_based(start.row, start.column), + kind: node.kind().to_owned(), + missing: node.is_missing(), + }); + } + + if !node.has_error() { + return None; + } + + let mut cursor = node.walk(); + node.children(&mut cursor).find_map(first_defect) +} + +fn check_declaration( + declaration: &Declaration, + display: &Path, + config: &Config, + strict: bool, + findings: &mut Vec, +) { + let kind_config = config.kinds.get(declaration.kind); + let requires_docs = match kind_config.docs { + DocsPolicy::All => true, + DocsPolicy::Exported => declaration.exported, + DocsPolicy::None => false, + }; + + let Some(attached) = declaration.doc.as_ref() else { + if requires_docs { + findings.push(Finding::new( + display, + declaration.position, + RuleId::MISSING_DOC, + format!("{} has no doc comment", subject(declaration)), + )); + } + return; + }; + + // A forbidden tag is a finding wherever it appears, documented or not. + for forbidden in &config.tags.forbid { + for occurrence in attached.comment.tags() { + if &occurrence.tag == forbidden { + findings.push(Finding::new( + display, + attached.tag_position(occurrence), + RuleId::FORBIDDEN_TAG, + format!("forbidden tag {forbidden}"), + )); + } + } + } + + if declaration.kind == DeclKind::Module { + check_module_name(declaration, display, findings); + } + + if !requires_docs { + return; + } + + for required in &kind_config.tags { + if !attached.comment.has(required) { + findings.push(Finding::new( + display, + declaration.position, + RuleId::MISSING_TAG, + format!("{} doc comment has no {required}", subject(declaration)), + )); + } + } + + check_constraints(declaration, display, config, strict, findings); +} + +/// `@module ` must name the module it documents. +fn check_module_name(declaration: &Declaration, display: &Path, findings: &mut Vec) { + let tag = Tag::new("@module"); + let (Some(attached), Some(name)) = (declaration.doc.as_ref(), declaration.name.as_deref()) + else { + return; + }; + let Some(occurrence) = attached.comment.first(&tag) else { + return; + }; + + let documented = module_name(&occurrence.value); + if documented != name { + findings.push(Finding::new( + display, + attached.tag_position(occurrence), + RuleId::MODULE_NAME, + format!("@module names `{documented}`, but the module is `{name}`"), + )); + } +} + +/// The documented name is the first word, without generic arguments or trailing punctuation. +fn module_name(value: &str) -> &str { + let first = value.split_whitespace().next().unwrap_or_default(); + let without_generics = first.split('<').next().unwrap_or(first); + without_generics.trim_end_matches(['.', ',', ':', ';']) +} + +/// Exported non-pure circuits carry the constraints annotation. +fn check_constraints( + declaration: &Declaration, + display: &Path, + config: &Config, + strict: bool, + findings: &mut Vec, +) { + if declaration.kind != DeclKind::Circuit || !declaration.exported || declaration.pure { + return; + } + let Some(attached) = declaration.doc.as_ref() else { + return; + }; + let tag = &config.constraints.tag; + + let Some(occurrence) = attached.comment.first(tag) else { + findings.push(Finding::new( + display, + declaration.position, + RuleId::MISSING_CONSTRAINTS, + format!("{} doc comment has no {tag}", subject(declaration)), + )); + return; + }; + + // The annotation is one line; anything below it is prose, not part of the value. + let value = occurrence.value.lines().next().unwrap_or_default().trim(); + let position = attached.tag_position(occurrence); + + let Some(placeholders) = parse_constraints(value) else { + findings.push(Finding::new( + display, + position, + RuleId::CONSTRAINTS_FORMAT, + format!("{tag} value `{value}` is not `k=, rows=`"), + )); + return; + }; + + if strict && placeholders { + findings.push(Finding::new( + display, + position, + RuleId::CONSTRAINTS_PLACEHOLDER, + format!("{tag} value `{value}` still holds a placeholder"), + )); + } +} + +/// Matches `k=,*rows=`; returns whether either field is a `?` placeholder. +fn parse_constraints(value: &str) -> Option { + let rest = value.strip_prefix("k=")?; + let (k, rest) = rest.split_once(',')?; + let rows = rest.trim_start().strip_prefix("rows=")?; + + let k_placeholder = is_placeholder(k)?; + let rows_placeholder = is_placeholder(rows)?; + Some(k_placeholder || rows_placeholder) +} + +/// A constraints field is either a decimal count or a `?` placeholder. +fn is_placeholder(field: &str) -> Option { + match field { + "?" => Some(true), + digits if !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) => { + Some(false) + } + _ => None, + } +} + +/// The kind plus the backquoted name, or a bare kind when the declaration has no name. +fn subject(declaration: &Declaration) -> String { + match declaration.name.as_deref() { + Some(name) => format!("{} `{name}`", declaration.kind), + None => declaration.kind.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::{Linter, module_name, parse_constraints}; + use crate::config::{Config, DocsPolicy}; + use crate::doc::Tag; + use std::path::Path; + + fn findings(source: &str, config: &Config, strict: bool) -> Vec { + Linter::new() + .expect("the bundled grammar loads") + .check(Path::new("a.compact"), source, config, strict) + .expect("the source produced a tree") + .iter() + .map(ToString::to_string) + .collect() + } + + fn circuit_config() -> Config { + let mut config = Config::default(); + config.kinds.circuit.tags = vec![Tag::new("@description")]; + config + } + + #[test] + fn a_non_exported_circuit_inside_an_exported_module_needs_no_docs() { + let source = "/** @description M. */\nexport module M {\n circuit helper(): [] { }\n}\n"; + + assert!(findings(source, &Config::default(), false).is_empty()); + } + + #[test] + fn an_exported_module_without_a_doc_comment_is_reported() { + let source = "export module M {\n}\n"; + + assert_eq!( + findings(source, &Config::default(), false), + ["a.compact:1:1: missing-doc: module `M` has no doc comment"] + ); + } + + #[test] + fn a_line_comment_between_the_doc_and_the_declaration_breaks_attachment() { + let source = "/** @description M. */\n// a note\nexport module M {\n}\n"; + + assert_eq!( + findings(source, &Config::default(), false), + ["a.compact:3:1: missing-doc: module `M` has no doc comment"] + ); + } + + #[test] + fn a_pure_exported_circuit_needs_no_constraints() { + let source = "/** @description Adds. */\nexport pure circuit add(a: Uint<8>): Uint<8> { return a; }\n"; + + assert!(findings(source, &circuit_config(), false).is_empty()); + } + + #[test] + fn an_exported_circuit_without_constraints_is_reported() { + let source = + "/** @description Bumps. */\nexport circuit bump(): [] { count.increment(1); }\n"; + + assert_eq!( + findings(source, &circuit_config(), false), + ["a.compact:2:1: missing-constraints: circuit `bump` doc comment has no @constraints"] + ); + } + + #[test] + fn a_placeholder_is_a_finding_only_under_strict() { + let source = "/**\n * @description Bumps.\n * @constraints k=?, rows=?\n */\nexport circuit bump(): [] { }\n"; + let config = circuit_config(); + + assert!(findings(source, &config, false).is_empty()); + assert_eq!( + findings(source, &config, true), + [ + "a.compact:3:4: constraints-placeholder: @constraints value `k=?, rows=?` still holds a placeholder" + ] + ); + } + + #[test] + fn a_forbidden_tag_is_reported_even_where_docs_are_optional() { + let source = "/**\n * @return nothing\n */\ncircuit hidden(): [] { }\n"; + let mut config = Config::default(); + config.tags.forbid = vec![Tag::new("@return")]; + + assert_eq!( + findings(source, &config, false), + ["a.compact:2:4: forbidden-tag: forbidden tag @return"] + ); + } + + #[test] + fn a_witness_under_docs_all_is_checked_without_export() { + let source = "witness wit_secret(): Bytes<32>;\n"; + let mut config = Config::default(); + config.kinds.witness.docs = DocsPolicy::All; + + assert_eq!( + findings(source, &config, false), + ["a.compact:1:1: missing-doc: witness `wit_secret` has no doc comment"] + ); + } + + #[test] + fn a_parse_defect_suppresses_the_doc_rules() { + let source = "export module M {\nexport circuit\n"; + + let reported = findings(source, &Config::default(), false); + assert_eq!(reported.len(), 1, "{reported:?}"); + assert!(reported[0].contains("parse error"), "{reported:?}"); + } + + #[test] + fn the_constraints_tag_is_configurable() { + let source = "/**\n * @description Bumps.\n * @circuitInfo k=6, rows=28\n */\nexport circuit bump(): [] { }\n"; + let mut config = circuit_config(); + config.constraints.tag = Tag::new("@circuitInfo"); + + assert!(findings(source, &config, false).is_empty()); + } + + #[test] + fn constraints_accept_flexible_spacing_after_the_comma() { + assert_eq!(parse_constraints("k=10, rows=626"), Some(false)); + assert_eq!(parse_constraints("k=10,rows=626"), Some(false)); + assert_eq!(parse_constraints("k=?, rows=626"), Some(true)); + assert_eq!(parse_constraints("k=10, rows=626 (approx)"), None); + assert_eq!(parse_constraints("rows=626, k=10"), None); + } + + #[test] + fn a_documented_module_name_drops_generics_and_trailing_punctuation() { + assert_eq!(module_name("Signer"), "Signer"); + assert_eq!(module_name("Utils."), "Utils"); + assert_eq!(module_name("ShieldedToken (archived)"), "ShieldedToken"); + } +} diff --git a/crates/compact-lint/tests/check.rs b/crates/compact-lint/tests/check.rs new file mode 100644 index 0000000..452c09d --- /dev/null +++ b/crates/compact-lint/tests/check.rs @@ -0,0 +1,172 @@ +//! `compact-lint check` end to end, one fixture case per rule. +//! +//! Every case runs with `--no-format`, because CI has no `compact` binary. + +use std::path::PathBuf; +use std::process::Command; + +use assert_cmd::prelude::*; + +/// Exit code for a run that produced findings. +const EXIT_FINDINGS: i32 = 1; + +/// Exit code for a usage, config, IO or parser error. +const EXIT_ERROR: i32 = 2; + +fn case(name: &str) -> PathBuf { + // The child reports its working directory resolved, so the expectation must be too. + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) + .canonicalize() + .expect("the fixture case exists") +} + +fn binary() -> Command { + Command::cargo_bin("compact-lint").expect("the binary is built alongside this test") +} + +fn expected(name: &str) -> String { + std::fs::read_to_string(case(name).join("expected.txt")).expect("the case has an expectation") +} + +/// Runs `check` inside a case directory and returns its exit code and stdout. +fn run(name: &str, extra: &[&str]) -> (i32, String) { + let output = binary() + .current_dir(case(name)) + .args(["check", "--no-format"]) + .args(extra) + .output() + .expect("the binary ran"); + + ( + output.status.code().expect("the binary was not signalled"), + String::from_utf8(output.stdout).expect("the report is UTF-8"), + ) +} + +fn assert_case(name: &str, extra: &[&str], code: i32) { + let (actual_code, stdout) = run(name, extra); + + assert_eq!(stdout, expected(name), "stdout for case {name}"); + assert_eq!(actual_code, code, "exit code for case {name}"); +} + +#[test] +fn a_fully_documented_module_reports_nothing() { + assert_case("clean", &[], 0); +} + +#[test] +fn undocumented_declarations_and_detached_docs_report_missing_doc() { + assert_case("missing-doc", &[], EXIT_FINDINGS); +} + +#[test] +fn a_doc_comment_without_a_required_tag_reports_missing_tag() { + assert_case("missing-tag", &[], EXIT_FINDINGS); +} + +#[test] +fn a_forbidden_tag_reports_wherever_it_appears() { + assert_case("forbidden-tag", &[], EXIT_FINDINGS); +} + +#[test] +fn a_module_tag_naming_another_module_reports_module_name() { + assert_case("module-name", &[], EXIT_FINDINGS); +} + +#[test] +fn an_unannotated_exported_circuit_reports_missing_constraints() { + assert_case("missing-constraints", &[], EXIT_FINDINGS); +} + +#[test] +fn a_malformed_constraints_value_reports_constraints_format() { + assert_case("constraints-format", &[], EXIT_FINDINGS); +} + +#[test] +fn a_placeholder_reports_only_under_strict() { + let (code, stdout) = run("constraints-placeholder", &[]); + assert_eq!(stdout, ""); + assert_eq!(code, 0); + + assert_case("constraints-placeholder", &["--strict"], EXIT_FINDINGS); +} + +#[test] +fn a_broken_file_reports_parse_instead_of_crashing() { + assert_case("parse", &[], EXIT_FINDINGS); +} + +#[test] +fn an_explicit_path_replaces_the_include_globs() { + let output = binary() + .current_dir(case("missing-doc")) + .args(["check", "--no-format", "Gaps.compact"]) + .output() + .expect("the binary ran"); + + assert_eq!( + String::from_utf8(output.stdout).expect("the report is UTF-8"), + expected("missing-doc") + ); +} + +#[test] +fn an_explicit_config_anchors_discovery_at_the_working_directory() { + let config = case("clean").join("compact-lint.toml"); + let output = binary() + .current_dir(case("missing-doc")) + .args(["check", "--no-format", "--config"]) + .arg(&config) + .output() + .expect("the binary ran"); + + assert_eq!( + String::from_utf8(output.stdout).expect("the report is UTF-8"), + expected("missing-doc") + ); +} + +#[test] +fn a_missing_compact_binary_exits_two_and_names_the_escape_hatch() { + let output = binary() + .current_dir(case("clean")) + .args(["check", "--compact-bin", "compact-lint-no-such-binary"]) + .output() + .expect("the binary ran"); + + let stderr = String::from_utf8(output.stderr).expect("the error is UTF-8"); + assert_eq!(output.status.code(), Some(EXIT_ERROR), "{stderr}"); + assert!(stderr.contains("--no-format"), "{stderr}"); +} + +#[test] +fn an_unreadable_config_exits_two() { + let output = binary() + .current_dir(case("clean")) + .args(["check", "--no-format", "--config", "compact-lint.toml.nope"]) + .output() + .expect("the binary ran"); + + assert_eq!(output.status.code(), Some(EXIT_ERROR)); +} + +#[test] +fn the_summary_goes_to_stderr_and_the_findings_to_stdout() { + let output = binary() + .current_dir(case("module-name")) + .args(["check", "--no-format"]) + .output() + .expect("the binary ran"); + + let stderr = String::from_utf8(output.stderr).expect("the summary is UTF-8"); + assert_eq!(stderr.trim_end(), "1 findings in 1 files"); + assert!( + String::from_utf8_lossy(&output.stdout).contains("module-name"), + "the rule id belongs on stdout" + ); +} diff --git a/crates/compact-lint/tests/fixtures/clean/Clean.compact b/crates/compact-lint/tests/fixtures/clean/Clean.compact new file mode 100644 index 0000000..030f785 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/clean/Clean.compact @@ -0,0 +1,56 @@ +pragma language_version >= 0.23.0; + +/** + * @module Clean + * @description A module that satisfies every rule. + */ +export module Clean { + import CompactStandardLibrary; + + /** + * @description Whether the module is initialized. + */ + export ledger _isInitialized: Boolean; + + /** + * @description The caller's secret key. + */ + witness wit_Secret(): Bytes<32>; + + /** + * @description A single counter reading. + */ + export struct Reading { + value: Uint<64> + } + + /** + * @description The directions a counter moves in. + */ + export enum Direction { + Up, + Down + } + + constructor() { + _isInitialized = false; + } + + /** + * @description Doubles its argument. + * @param {Uint<8>} value - The value to double. + * @returns {Uint<16>} The doubled value. + */ + export pure circuit double(value: Uint<8>): Uint<16> { + return value as Uint<16> * 2; + } + + /** + * @description Marks the module initialized. + * @constraints k=10, rows=626 + * @returns {[]} Empty tuple. + */ + export circuit initialize(): [] { + _isInitialized = true; + } +} diff --git a/crates/compact-lint/tests/fixtures/clean/compact-lint.toml b/crates/compact-lint/tests/fixtures/clean/compact-lint.toml new file mode 100644 index 0000000..0813e24 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/clean/compact-lint.toml @@ -0,0 +1,27 @@ +version = 1 +include = ["**/*.compact"] + +[tags] +forbid = ["@return"] + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] + +[kinds.ledger] +tags = ["@description"] + +[kinds.witness] +docs = "all" +tags = ["@description"] + +[kinds.struct] +tags = ["@description"] + +[kinds.enum] +tags = ["@description"] + +[kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/clean/expected.txt b/crates/compact-lint/tests/fixtures/clean/expected.txt new file mode 100644 index 0000000..e69de29 diff --git a/crates/compact-lint/tests/fixtures/constraints-format/Malformed.compact b/crates/compact-lint/tests/fixtures/constraints-format/Malformed.compact new file mode 100644 index 0000000..9e2365a --- /dev/null +++ b/crates/compact-lint/tests/fixtures/constraints-format/Malformed.compact @@ -0,0 +1,17 @@ +/** + * @module Malformed + * @description A module whose annotations do not parse. + */ +export module Malformed { + /** + * @description The comma is missing. + * @constraints k=10 rows=626 + */ + export circuit first(): [] { } + + /** + * @description The fields are the wrong way round. + * @constraints rows=626, k=10 + */ + export circuit second(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/constraints-format/compact-lint.toml b/crates/compact-lint/tests/fixtures/constraints-format/compact-lint.toml new file mode 100644 index 0000000..0813e24 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/constraints-format/compact-lint.toml @@ -0,0 +1,27 @@ +version = 1 +include = ["**/*.compact"] + +[tags] +forbid = ["@return"] + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] + +[kinds.ledger] +tags = ["@description"] + +[kinds.witness] +docs = "all" +tags = ["@description"] + +[kinds.struct] +tags = ["@description"] + +[kinds.enum] +tags = ["@description"] + +[kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/constraints-format/expected.txt b/crates/compact-lint/tests/fixtures/constraints-format/expected.txt new file mode 100644 index 0000000..a1b1ac8 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/constraints-format/expected.txt @@ -0,0 +1,2 @@ +Malformed.compact:8:6: constraints-format: @constraints value `k=10 rows=626` is not `k=, rows=` +Malformed.compact:14:6: constraints-format: @constraints value `rows=626, k=10` is not `k=, rows=` diff --git a/crates/compact-lint/tests/fixtures/constraints-placeholder/Unfilled.compact b/crates/compact-lint/tests/fixtures/constraints-placeholder/Unfilled.compact new file mode 100644 index 0000000..4b12883 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/constraints-placeholder/Unfilled.compact @@ -0,0 +1,11 @@ +/** + * @module Unfilled + * @description A module whose annotation is still a placeholder. + */ +export module Unfilled { + /** + * @description Not measured yet. + * @constraints k=?, rows=? + */ + export circuit run(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/constraints-placeholder/compact-lint.toml b/crates/compact-lint/tests/fixtures/constraints-placeholder/compact-lint.toml new file mode 100644 index 0000000..0813e24 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/constraints-placeholder/compact-lint.toml @@ -0,0 +1,27 @@ +version = 1 +include = ["**/*.compact"] + +[tags] +forbid = ["@return"] + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] + +[kinds.ledger] +tags = ["@description"] + +[kinds.witness] +docs = "all" +tags = ["@description"] + +[kinds.struct] +tags = ["@description"] + +[kinds.enum] +tags = ["@description"] + +[kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/constraints-placeholder/expected.txt b/crates/compact-lint/tests/fixtures/constraints-placeholder/expected.txt new file mode 100644 index 0000000..ba3214e --- /dev/null +++ b/crates/compact-lint/tests/fixtures/constraints-placeholder/expected.txt @@ -0,0 +1 @@ +Unfilled.compact:8:6: constraints-placeholder: @constraints value `k=?, rows=?` still holds a placeholder diff --git a/crates/compact-lint/tests/fixtures/forbidden-tag/Returns.compact b/crates/compact-lint/tests/fixtures/forbidden-tag/Returns.compact new file mode 100644 index 0000000..61836af --- /dev/null +++ b/crates/compact-lint/tests/fixtures/forbidden-tag/Returns.compact @@ -0,0 +1,18 @@ +/** + * @module Returns + * @description A module that still uses the old return tag. + */ +export module Returns { + /** + * @description Does nothing. + * @constraints k=6, rows=28 + * @return {[]} - Empty tuple. + */ + export circuit run(): [] { } + + /** + * @description Not exported, so its docs are optional, but the tag is not. + * @return {[]} - Empty tuple. + */ + circuit helper(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/forbidden-tag/compact-lint.toml b/crates/compact-lint/tests/fixtures/forbidden-tag/compact-lint.toml new file mode 100644 index 0000000..0813e24 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/forbidden-tag/compact-lint.toml @@ -0,0 +1,27 @@ +version = 1 +include = ["**/*.compact"] + +[tags] +forbid = ["@return"] + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] + +[kinds.ledger] +tags = ["@description"] + +[kinds.witness] +docs = "all" +tags = ["@description"] + +[kinds.struct] +tags = ["@description"] + +[kinds.enum] +tags = ["@description"] + +[kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/forbidden-tag/expected.txt b/crates/compact-lint/tests/fixtures/forbidden-tag/expected.txt new file mode 100644 index 0000000..0edaa79 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/forbidden-tag/expected.txt @@ -0,0 +1,2 @@ +Returns.compact:9:6: forbidden-tag: forbidden tag @return +Returns.compact:15:6: forbidden-tag: forbidden tag @return diff --git a/crates/compact-lint/tests/fixtures/missing-constraints/Unmeasured.compact b/crates/compact-lint/tests/fixtures/missing-constraints/Unmeasured.compact new file mode 100644 index 0000000..f8e0a62 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/missing-constraints/Unmeasured.compact @@ -0,0 +1,17 @@ +/** + * @module Unmeasured + * @description A module whose exported circuit is unmeasured. + */ +export module Unmeasured { + /** + * @description Writes to the ledger without an annotation. + */ + export circuit run(): [] { } + + /** + * @description Pure circuits are never annotated. + */ + export pure circuit double(value: Uint<8>): Uint<16> { + return value as Uint<16> * 2; + } +} diff --git a/crates/compact-lint/tests/fixtures/missing-constraints/compact-lint.toml b/crates/compact-lint/tests/fixtures/missing-constraints/compact-lint.toml new file mode 100644 index 0000000..0813e24 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/missing-constraints/compact-lint.toml @@ -0,0 +1,27 @@ +version = 1 +include = ["**/*.compact"] + +[tags] +forbid = ["@return"] + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] + +[kinds.ledger] +tags = ["@description"] + +[kinds.witness] +docs = "all" +tags = ["@description"] + +[kinds.struct] +tags = ["@description"] + +[kinds.enum] +tags = ["@description"] + +[kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/missing-constraints/expected.txt b/crates/compact-lint/tests/fixtures/missing-constraints/expected.txt new file mode 100644 index 0000000..18ec688 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/missing-constraints/expected.txt @@ -0,0 +1 @@ +Unmeasured.compact:9:3: missing-constraints: circuit `run` doc comment has no @constraints diff --git a/crates/compact-lint/tests/fixtures/missing-doc/Gaps.compact b/crates/compact-lint/tests/fixtures/missing-doc/Gaps.compact new file mode 100644 index 0000000..13a4d3e --- /dev/null +++ b/crates/compact-lint/tests/fixtures/missing-doc/Gaps.compact @@ -0,0 +1,20 @@ +/** + * @module Gaps + * @description A module whose members are undocumented. + */ +export module Gaps { + export ledger _owner: Bytes<32>; + + export enum Kind { + One + } + + /** + * @description Detached by the line comment below it. + * @constraints k=6, rows=28 + */ + // The comment that breaks the attachment. + export circuit run(): [] { + _owner = default>; + } +} diff --git a/crates/compact-lint/tests/fixtures/missing-doc/compact-lint.toml b/crates/compact-lint/tests/fixtures/missing-doc/compact-lint.toml new file mode 100644 index 0000000..0813e24 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/missing-doc/compact-lint.toml @@ -0,0 +1,27 @@ +version = 1 +include = ["**/*.compact"] + +[tags] +forbid = ["@return"] + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] + +[kinds.ledger] +tags = ["@description"] + +[kinds.witness] +docs = "all" +tags = ["@description"] + +[kinds.struct] +tags = ["@description"] + +[kinds.enum] +tags = ["@description"] + +[kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/missing-doc/expected.txt b/crates/compact-lint/tests/fixtures/missing-doc/expected.txt new file mode 100644 index 0000000..b3649c7 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/missing-doc/expected.txt @@ -0,0 +1,3 @@ +Gaps.compact:6:3: missing-doc: ledger `_owner` has no doc comment +Gaps.compact:8:3: missing-doc: enum `Kind` has no doc comment +Gaps.compact:17:3: missing-doc: circuit `run` has no doc comment diff --git a/crates/compact-lint/tests/fixtures/missing-tag/Untagged.compact b/crates/compact-lint/tests/fixtures/missing-tag/Untagged.compact new file mode 100644 index 0000000..98d8116 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/missing-tag/Untagged.compact @@ -0,0 +1,10 @@ +/** + * @module Untagged + */ +export module Untagged { + /** + * The module has no @description, and neither has this circuit. + * @constraints k=6, rows=28 + */ + export circuit run(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/missing-tag/compact-lint.toml b/crates/compact-lint/tests/fixtures/missing-tag/compact-lint.toml new file mode 100644 index 0000000..0813e24 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/missing-tag/compact-lint.toml @@ -0,0 +1,27 @@ +version = 1 +include = ["**/*.compact"] + +[tags] +forbid = ["@return"] + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] + +[kinds.ledger] +tags = ["@description"] + +[kinds.witness] +docs = "all" +tags = ["@description"] + +[kinds.struct] +tags = ["@description"] + +[kinds.enum] +tags = ["@description"] + +[kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/missing-tag/expected.txt b/crates/compact-lint/tests/fixtures/missing-tag/expected.txt new file mode 100644 index 0000000..9fb4a3b --- /dev/null +++ b/crates/compact-lint/tests/fixtures/missing-tag/expected.txt @@ -0,0 +1,2 @@ +Untagged.compact:4:1: missing-tag: module `Untagged` doc comment has no @description +Untagged.compact:9:3: missing-tag: circuit `run` doc comment has no @description diff --git a/crates/compact-lint/tests/fixtures/module-name/Renamed.compact b/crates/compact-lint/tests/fixtures/module-name/Renamed.compact new file mode 100644 index 0000000..8ea00b6 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/module-name/Renamed.compact @@ -0,0 +1,6 @@ +/** + * @module Stale + * @description The tag still names the module's old name. + */ +export module Renamed { +} diff --git a/crates/compact-lint/tests/fixtures/module-name/compact-lint.toml b/crates/compact-lint/tests/fixtures/module-name/compact-lint.toml new file mode 100644 index 0000000..0813e24 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/module-name/compact-lint.toml @@ -0,0 +1,27 @@ +version = 1 +include = ["**/*.compact"] + +[tags] +forbid = ["@return"] + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] + +[kinds.ledger] +tags = ["@description"] + +[kinds.witness] +docs = "all" +tags = ["@description"] + +[kinds.struct] +tags = ["@description"] + +[kinds.enum] +tags = ["@description"] + +[kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/module-name/expected.txt b/crates/compact-lint/tests/fixtures/module-name/expected.txt new file mode 100644 index 0000000..6d31a1c --- /dev/null +++ b/crates/compact-lint/tests/fixtures/module-name/expected.txt @@ -0,0 +1 @@ +Renamed.compact:2:4: module-name: @module names `Stale`, but the module is `Renamed` diff --git a/crates/compact-lint/tests/fixtures/parse/Broken.compact b/crates/compact-lint/tests/fixtures/parse/Broken.compact new file mode 100644 index 0000000..c21593b --- /dev/null +++ b/crates/compact-lint/tests/fixtures/parse/Broken.compact @@ -0,0 +1,3 @@ +export module Broken { + export circuit +} diff --git a/crates/compact-lint/tests/fixtures/parse/compact-lint.toml b/crates/compact-lint/tests/fixtures/parse/compact-lint.toml new file mode 100644 index 0000000..56ee774 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/parse/compact-lint.toml @@ -0,0 +1,2 @@ +version = 1 +include = ["**/*.compact"] diff --git a/crates/compact-lint/tests/fixtures/parse/expected.txt b/crates/compact-lint/tests/fixtures/parse/expected.txt new file mode 100644 index 0000000..b50d9d7 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/parse/expected.txt @@ -0,0 +1 @@ +Broken.compact:2:3: parse: parse error: unexpected ERROR From 68baa98be3a9c08c279ab5a3b278118e9d56d3ec Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 16 Sep 2026 06:40:49 +0200 Subject: [PATCH 2/4] fix(compact-lint): resolve explicit paths against cwd Discovery matches exclude globs against paths relative to the config's directory, but an explicit CLI path stayed relative to the process cwd. Running `check archive` from a subdirectory produced `archive/X.compact`, which never matched an exclude written as `contracts/archive/**`, so excluded files were silently linted. Anchor relative CLI paths at cwd first; the display step still strips cwd, so the report is unchanged. --- crates/compact-lint/src/check.rs | 38 ++++++++++++++++++- crates/compact-lint/tests/check.rs | 15 ++++++++ .../exclude-explicit-dir/compact-lint.toml | 9 +++++ .../contracts/archive/Old.compact | 7 ++++ .../contracts/live/Live.compact | 7 ++++ .../exclude-explicit-dir/expected.txt | 1 + 6 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 crates/compact-lint/tests/fixtures/exclude-explicit-dir/compact-lint.toml create mode 100644 crates/compact-lint/tests/fixtures/exclude-explicit-dir/contracts/archive/Old.compact create mode 100644 crates/compact-lint/tests/fixtures/exclude-explicit-dir/contracts/live/Live.compact create mode 100644 crates/compact-lint/tests/fixtures/exclude-explicit-dir/expected.txt diff --git a/crates/compact-lint/src/check.rs b/crates/compact-lint/src/check.rs index 2f2d2a8..76ee393 100644 --- a/crates/compact-lint/src/check.rs +++ b/crates/compact-lint/src/check.rs @@ -67,7 +67,8 @@ pub fn run(options: &Options, cwd: &Path) -> Result { let include = config.include_set(source)?; discover::from_globs(&base, &include, &exclude)? } else { - discover::from_paths(&options.paths, &base, &exclude)? + let paths = absolute_paths(&options.paths, cwd); + discover::from_paths(&paths, &base, &exclude)? }; let files: Vec = files.iter().map(|path| display_path(path, cwd)).collect(); @@ -111,7 +112,42 @@ fn resolve_config( } } +/// Discovery matches the exclude globs against paths relative to the config's +/// directory, so a CLI path has to be anchored at `cwd` first: a bare `archive` +/// given from a subdirectory would otherwise never match `contracts/archive/**`. +fn absolute_paths(paths: &[PathBuf], cwd: &Path) -> Vec { + paths + .iter() + .map(|path| { + if path.is_absolute() { + path.clone() + } else { + cwd.join(path) + } + }) + .collect() +} + /// Paths under the working directory print relative to it; anything else prints as is. fn display_path(path: &Path, cwd: &Path) -> PathBuf { path.strip_prefix(cwd).unwrap_or(path).to_owned() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_relative_path_is_anchored_at_the_working_directory() { + let resolved = absolute_paths(&[PathBuf::from("archive")], Path::new("/repo/contracts")); + + assert_eq!(resolved, vec![PathBuf::from("/repo/contracts/archive")]); + } + + #[test] + fn an_absolute_path_is_left_alone() { + let resolved = absolute_paths(&[PathBuf::from("/elsewhere/X.compact")], Path::new("/repo")); + + assert_eq!(resolved, vec![PathBuf::from("/elsewhere/X.compact")]); + } +} diff --git a/crates/compact-lint/tests/check.rs b/crates/compact-lint/tests/check.rs index 452c09d..c4fc99f 100644 --- a/crates/compact-lint/tests/check.rs +++ b/crates/compact-lint/tests/check.rs @@ -115,6 +115,21 @@ fn an_explicit_path_replaces_the_include_globs() { ); } +#[test] +fn an_exclude_glob_applies_to_an_explicit_path_given_from_a_subdirectory() { + let output = binary() + .current_dir(case("exclude-explicit-dir").join("contracts")) + .args(["check", "--no-format", "live", "archive"]) + .output() + .expect("the binary ran"); + + assert_eq!( + String::from_utf8(output.stdout).expect("the report is UTF-8"), + expected("exclude-explicit-dir") + ); + assert_eq!(output.status.code(), Some(EXIT_FINDINGS)); +} + #[test] fn an_explicit_config_anchors_discovery_at_the_working_directory() { let config = case("clean").join("compact-lint.toml"); diff --git a/crates/compact-lint/tests/fixtures/exclude-explicit-dir/compact-lint.toml b/crates/compact-lint/tests/fixtures/exclude-explicit-dir/compact-lint.toml new file mode 100644 index 0000000..02f0d72 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/exclude-explicit-dir/compact-lint.toml @@ -0,0 +1,9 @@ +version = 1 +include = ["contracts/**/*.compact"] +exclude = ["contracts/archive/**"] + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.ledger] +tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/exclude-explicit-dir/contracts/archive/Old.compact b/crates/compact-lint/tests/fixtures/exclude-explicit-dir/contracts/archive/Old.compact new file mode 100644 index 0000000..57a9386 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/exclude-explicit-dir/contracts/archive/Old.compact @@ -0,0 +1,7 @@ +/** + * @module Old + * @description A retired module the config excludes. + */ +export module Old { + export ledger _owner: Bytes<32>; +} diff --git a/crates/compact-lint/tests/fixtures/exclude-explicit-dir/contracts/live/Live.compact b/crates/compact-lint/tests/fixtures/exclude-explicit-dir/contracts/live/Live.compact new file mode 100644 index 0000000..10781e5 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/exclude-explicit-dir/contracts/live/Live.compact @@ -0,0 +1,7 @@ +/** + * @module Live + * @description A maintained module. + */ +export module Live { + export ledger _owner: Bytes<32>; +} diff --git a/crates/compact-lint/tests/fixtures/exclude-explicit-dir/expected.txt b/crates/compact-lint/tests/fixtures/exclude-explicit-dir/expected.txt new file mode 100644 index 0000000..31799ef --- /dev/null +++ b/crates/compact-lint/tests/fixtures/exclude-explicit-dir/expected.txt @@ -0,0 +1 @@ +live/Live.compact:6:3: missing-doc: ledger `_owner` has no doc comment From 5ec15844e94eec845297c615531c8aa38b2a11e9 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 16 Sep 2026 06:41:17 +0200 Subject: [PATCH 3/4] refactor(compact-lint): mark Tag::new must_use A constructor with no side effects; matches the rest of Tag. --- crates/compact-lint/src/doc.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/compact-lint/src/doc.rs b/crates/compact-lint/src/doc.rs index 685007f..4da3200 100644 --- a/crates/compact-lint/src/doc.rs +++ b/crates/compact-lint/src/doc.rs @@ -10,6 +10,7 @@ use serde::Deserialize; pub struct Tag(String); impl Tag { + #[must_use] pub fn new(name: impl Into) -> Self { Self(name.into()) } From 3423bd66efcc24b134ce939680ff434e6a526b9f Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 16 Sep 2026 06:42:15 +0200 Subject: [PATCH 4/4] fix(compact-lint): check constraints under docs = none The constraints rule ran after the per-kind docs gate, so kinds.circuit.docs = "none" silently disabled it. The rule is fixed rather than configurable, so it now runs before the gate; only the required-tag loop stays behind it. --- crates/compact-lint/src/rules.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/compact-lint/src/rules.rs b/crates/compact-lint/src/rules.rs index 9bc0bf7..4e8cefc 100644 --- a/crates/compact-lint/src/rules.rs +++ b/crates/compact-lint/src/rules.rs @@ -146,6 +146,9 @@ fn check_declaration( check_module_name(declaration, display, findings); } + // The constraints rule is fixed, so it stays outside the per-kind docs policy. + check_constraints(declaration, display, config, strict, findings); + if !requires_docs { return; } @@ -160,8 +163,6 @@ fn check_declaration( )); } } - - check_constraints(declaration, display, config, strict, findings); } /// `@module ` must name the module it documents. @@ -341,6 +342,19 @@ mod tests { ); } + #[test] + fn the_constraints_rule_survives_docs_none() { + let source = + "/** @description Bumps. */\nexport circuit bump(): [] { count.increment(1); }\n"; + let mut config = circuit_config(); + config.kinds.circuit.docs = DocsPolicy::None; + + assert_eq!( + findings(source, &config, false), + ["a.compact:2:1: missing-constraints: circuit `bump` doc comment has no @constraints"] + ); + } + #[test] fn a_placeholder_is_a_finding_only_under_strict() { let source = "/**\n * @description Bumps.\n * @constraints k=?, rows=?\n */\nexport circuit bump(): [] { }\n";