diff --git a/.gitignore b/.gitignore index e2ebbac..a66d7c3 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ artifacts/ # compact-compiler's per-source circuit-info cache, written next to the # .compact sources it measures. .circuit-info.json +# Except the seeded cache the fill-constraints tests read. +!crates/compact-lint/tests/fixtures/**/.circuit-info.json midnight-level-db compactc diff --git a/Cargo.lock b/Cargo.lock index 7246e94..778d546 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" @@ -115,6 +121,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "clap" version = "4.6.7" @@ -146,7 +158,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -170,9 +182,11 @@ dependencies = [ "clap", "compact-tree-sitter", "globset", + "portable-pty", "serde", + "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.20", "toml", "tree-sitter", "walkdir", @@ -193,6 +207,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "equivalent" version = "1.0.2" @@ -215,6 +235,17 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -279,6 +310,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -303,6 +340,18 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -315,6 +364,27 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "predicates" version = "3.1.4" @@ -401,7 +471,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -444,7 +514,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -470,6 +540,33 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -488,6 +585,17 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.5" @@ -518,13 +626,33 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -535,7 +663,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -628,6 +756,22 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -637,6 +781,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -658,6 +808,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 753653f..3e63a37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,13 +16,15 @@ anyhow = "1.0" cc = "1" clap = { version = "4.6", features = ["derive", "env"] } globset = "0.4" +portable-pty = "0.9" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" 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" +tempfile = "3.27" walkdir = "2.5" assert_cmd = "2.2" -tempfile = "3.27" diff --git a/crates/compact-lint/Cargo.toml b/crates/compact-lint/Cargo.toml index 384ce97..014ed0b 100644 --- a/crates/compact-lint/Cargo.toml +++ b/crates/compact-lint/Cargo.toml @@ -22,7 +22,10 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true globset.workspace = true +portable-pty.workspace = true serde.workspace = true +serde_json.workspace = true +tempfile.workspace = true thiserror.workspace = true toml.workspace = true tree-sitter.workspace = true @@ -31,4 +34,3 @@ walkdir.workspace = true [dev-dependencies] assert_cmd.workspace = true -tempfile.workspace = true diff --git a/crates/compact-lint/README.md b/crates/compact-lint/README.md index 5f2cfd6..9deeb0b 100644 --- a/crates/compact-lint/README.md +++ b/crates/compact-lint/README.md @@ -3,13 +3,15 @@ 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. `fix` -rewrites the comments the repairable rules report. +rewrites the comments the repairable rules report. `fill-constraints` compiles the +contracts and writes the measured `k` and `rows` into the annotations. ## Usage ```sh compact-lint check [PATHS]... [--config ] [--strict] [--no-format] [--compact-bin ] compact-lint fix [PATHS]... [--config ] [--dry-run] +compact-lint fill-constraints [PATHS]... [--config ] [--dry-run] [--no-compile] [--compact-bin ] [--artifacts ] ``` - `PATHS` — files or directories, walked recursively for `.compact`. Skipped while @@ -80,6 +82,100 @@ text and applied from the highest offset down; several edits on one comment merg single replacement of it. Files are rewritten through a sibling `.tmp` file and renamed into place, keeping the file's line ending and its trailing newline, or lack of one. +## Fill constraints + +`fill-constraints` measures every circuit that already carries the constraints tag and +writes the value in, so `check --strict` is clean afterwards and a second run is a no-op. +`k` and `rows` reach only the compiler's terminal output, so each contract is compiled +under a pty and the progress lines are parsed. + +```sh +compact-lint fill-constraints [PATHS]... [--config ] [--dry-run] [--no-compile] [--compact-bin ] [--artifacts ] +``` + +- `PATHS` and `--config` resolve exactly as they do for `check`. +- `--dry-run` — report the changes and write nothing, the measurement cache included. +- `--no-compile` — read the `.circuit-info.json` caches instead of compiling. A source + with no cached entry is an error. +- `--compact-bin ` — path to the `compact` binary, as for `check`. Also settable + with `COMPACT_LINT_COMPACT_BIN`. +- `--artifacts ` — where the compiler writes. Without it a temporary directory is + used and removed at the end. + +Scope is the annotations that already exist: every exported non-pure circuit whose doc +comment carries `constraints.tag`, whatever its value. A circuit with no annotation is +`fix`'s job and is left alone here. A file holding a parse defect is skipped, so `check` +stays the place a syntax error is reported. + +Changes go to stdout, one per line: + +```text +contracts/src/access/Ownable.compact:59:6: fill: @constraints k=?, rows=? -> k=13, rows=4273 +``` + +The `N values filled in M files, U unmeasured` summary goes to stderr. `M` counts the +files that changed; `U` counts every tagged circuit left without a value, including the +ones in a file with no measurement source. + +### How a source is resolved + +A library module does not compile on its own, so its constraints are measured through the +mock contract that exports the same circuit names. For each file, in order: + +1. `constraints.overrides`, an exact path-to-path mapping. +2. `constraints.self`, globs for files that are contracts and compile themselves. +3. `constraints.sources`, templates expanded and tried until one exists. `{dir}` is the + file's directory, `{parent}` its parent, `{stem}` its name without the extension. + +Each distinct source is compiled once per run, whatever the number of files it measures. + +### The cache + +After a compile, the measurements are merged into `.circuit-info.json` beside the source, +keyed by the source's file name. It is the file the TypeScript builder in +`packages/builder` writes, in the same shape, so the two tools share it: + +```json +{ + "generatedAt": "2026-09-15T08:38:49.000Z", + "files": { + "MockOwnable.compact": [{ "name": "owner", "k": 7, "rows": 74 }] + } +} +``` + +Other files' entries are kept and `generatedAt` is refreshed. `--no-compile` reads this +file instead of running the compiler. + +### Unmeasured + +A tagged circuit is *unmeasured* when its source compiled but produced no measurement for +its name. That happens where the mock has no exported circuit of that name — `initialize` +is called from the mock's constructor, never exported — or where the compiler's +`contract-info.json` marks the circuit `proof: false`, which carries no constraints. The +value is left alone and the circuit is reported: + +```text +contracts/src/access/Ownable.compact:37:6: constraints-unmeasured: circuit `initialize` has no measurement in contracts/src/access/test/mocks/MockOwnable.compact +``` + +A file with no measurement source at all is reported once, with the candidates tried: + +```text +contracts/src/utils/Utils.compact:1:1: constraints-unmeasurable: no measurement source for contracts/src/utils/Utils.compact; tried contracts/src/utils/test/mocks/MockUtils.compact, contracts/src/test/mocks/MockUtils.compact +``` + +Exit codes: + +- `0` — every tagged circuit was measured, whether or not a value changed. +- `1` — at least one circuit is unmeasured or unmeasurable. +- `2` — usage, config, IO, compiler or missing-cache error. A failed compile prints the + source and the last 20 lines of the compiler's cleaned output; the raw pty stream is + never printed. + +Files are rewritten exactly as `fix` rewrites them: through a sibling `.tmp` file, keeping +the line ending, and only the annotation's own line changes. + ## Rules | Rule | Meaning | @@ -124,6 +220,12 @@ exclude = ["**/test/mocks/**", "**/archive/**"] [constraints] tag = "@constraints" +compiler = "0.34.0" +sources = ["{dir}/test/mocks/Mock{stem}.compact", "{parent}/test/mocks/Mock{stem}.compact"] +self = ["**/presets/**"] + +[constraints.overrides] +"contracts/src/utils/Utils.compact" = "contracts/src/utils/test/mocks/MockUtils.compact" [tags] forbid = ["@return"] @@ -145,6 +247,10 @@ Defaults when no config file is found: | `include` | `["**/*.compact"]` | | `exclude` | `[]` | | `constraints.tag` | `"@constraints"` | +| `constraints.compiler` | none, so `compact` picks its default toolchain | +| `constraints.sources` | `["{dir}/test/mocks/Mock{stem}.compact", "{parent}/test/mocks/Mock{stem}.compact"]` | +| `constraints.self` | `[]` | +| `constraints.overrides` | `{}` | | `tags.forbid` | `[]` | | `tags.rename` | `{}` | | `fix.placeholder` | `"TODO"` | @@ -164,6 +270,11 @@ Defaults when no config file is found: spellings are validated like every other tag. An unmapped forbidden tag is reported, never rewritten. - `fix.placeholder` is the text `fix` writes where it has no value of its own. +- `constraints.compiler` is passed as `+` to `compact compile`. +- `constraints.sources` templates are tried in order; the first existing file wins. +- `constraints.self` marks files that compile themselves, so no mock is looked for. +- `constraints.overrides` keys and values are paths relative to the config's directory, + or to the current directory when the config came from `--config`. `examples/compact-contracts.toml` is the config for OpenZeppelin/compact-contracts. @@ -189,3 +300,10 @@ paths relative to the case directory. `tests/fixtures/fix-/` holds one `fix` case per rule: a `compact-lint.toml`, `before/`, `after/` and `expected.txt`. The run copies `before/` into a temporary directory, so the fixtures are never rewritten in place. + +`tests/fixtures/fill-/` holds the `fill-constraints` cases in the same shape. They +run against `tests/fixtures/fill-fake-compact/bin/compact`, a shell script that prints the +compiler's progress lines for a hard-coded table and writes a matching +`contract-info.json`, so the tests need no toolchain. It still runs through the pty, so +that path is covered too. `tests/fixtures/fill-transcript/pty.txt` is a captured +transcript of a real compile, for the parser's unit tests. diff --git a/crates/compact-lint/examples/compact-contracts.toml b/crates/compact-lint/examples/compact-contracts.toml index d20c3a9..ef9489e 100644 --- a/crates/compact-lint/examples/compact-contracts.toml +++ b/crates/compact-lint/examples/compact-contracts.toml @@ -8,6 +8,9 @@ exclude = ["**/test/mocks/**", "**/archive/**"] # `@constraints` is the default; set `tag` to point the rule at another annotation. [constraints] tag = "@constraints" +# Presets are contracts, so fill-constraints compiles them directly. Everything else is +# measured through the mock beside it or in its parent, the default `sources` templates. +self = ["**/presets/**"] [tags] forbid = ["@return"] diff --git a/crates/compact-lint/src/config.rs b/crates/compact-lint/src/config.rs index 3bddff2..35b02b6 100644 --- a/crates/compact-lint/src/config.rs +++ b/crates/compact-lint/src/config.rs @@ -117,17 +117,42 @@ impl KindsConfig { } } -/// The circuit constraints annotation. +/// Where `fill-constraints` looks for the contract that measures a file, tried in order. +/// +/// `{dir}` is the file's directory, `{parent}` its parent, `{stem}` its name without +/// the extension. +pub const DEFAULT_SOURCES: [&str; 2] = [ + "{dir}/test/mocks/Mock{stem}.compact", + "{parent}/test/mocks/Mock{stem}.compact", +]; + +/// The circuit constraints annotation, and how `fill-constraints` measures it. #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields, default)] pub struct ConstraintsConfig { pub tag: Tag, + /// Toolchain version passed as `+`; none leaves `compact` on its default. + pub compiler: Option, + /// Measurement-source templates, first existing file wins. + pub sources: Vec, + /// Globs for files that are contracts and compile themselves. + #[serde(rename = "self")] + pub own: Vec, + /// File to its measurement source, both relative to the config's directory. + pub overrides: BTreeMap, } impl Default for ConstraintsConfig { fn default() -> Self { Self { tag: Tag::new("@constraints"), + compiler: None, + sources: DEFAULT_SOURCES + .iter() + .map(|&template| template.to_owned()) + .collect(), + own: Vec::new(), + overrides: BTreeMap::new(), } } } @@ -281,6 +306,13 @@ impl Config { pub fn exclude_set(&self, path: &Path) -> Result { glob_set(&self.exclude, path) } + + /// Compiles the `constraints.self` globs; an empty set matches nothing. + /// # Errors + /// Returns an error when a pattern is not a valid glob. + pub fn own_set(&self, path: &Path) -> Result { + glob_set(&self.constraints.own, path) + } } /// Why a placeholder cannot go on a `*` line of a doc comment, or `None` when it can. @@ -424,6 +456,44 @@ mod tests { assert_eq!(config.fix.placeholder, "FIXME"); } + #[test] + fn constraints_default_to_the_mock_templates_and_no_compiler_pin() { + let constraints = Config::default().constraints; + + assert_eq!(constraints.compiler, None); + assert_eq!(constraints.sources, super::DEFAULT_SOURCES); + assert!(constraints.own.is_empty()); + assert!(constraints.overrides.is_empty()); + } + + #[test] + fn a_constraints_table_keeps_the_templates_it_does_not_name() { + let config: Config = toml::from_str( + "[constraints]\ncompiler = \"0.34.0\"\nself = [\"**/presets/**\"]\n[constraints.overrides]\n\"a.compact\" = \"b.compact\"\n", + ) + .expect("the snippet is valid config"); + + assert_eq!(config.constraints.compiler.as_deref(), Some("0.34.0")); + assert_eq!(config.constraints.own, ["**/presets/**"]); + assert_eq!(config.constraints.sources, super::DEFAULT_SOURCES); + assert_eq!( + config + .constraints + .overrides + .get("a.compact") + .map(String::as_str), + Some("b.compact") + ); + } + + #[test] + fn an_unknown_constraints_key_is_rejected() { + let error = toml::from_str::("[constraints]\nsorces = []\n") + .expect_err("the key is misspelled"); + + assert!(error.to_string().contains("sorces"), "{error}"); + } + #[test] fn a_rename_of_a_tag_that_is_not_forbidden_is_rejected() { let error = rejection("[tags]\nrename = { \"@return\" = \"@returns\" }\n"); diff --git a/crates/compact-lint/src/edit.rs b/crates/compact-lint/src/edit.rs index 281d859..ac71c5d 100644 --- a/crates/compact-lint/src/edit.rs +++ b/crates/compact-lint/src/edit.rs @@ -4,7 +4,14 @@ //! offset down, so positions never shift, and edits that target the same doc comment are //! merged into one replacement of that comment. +use std::ffi::{OsStr, OsString}; +use std::fs::{File, OpenOptions}; +use std::io::{ErrorKind, Write}; use std::ops::Range; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use thiserror::Error; use crate::report::Position; @@ -56,6 +63,97 @@ pub enum DocOp { line: usize, name: String, }, + /// Everything after a tag on one line replaced with a new value. + SetTagValue { + /// 0-based line index inside the original comment. + line: usize, + tag: String, + value: String, + }, +} + +#[derive(Debug, Error)] +pub enum WriteError { + #[error("cannot read {path}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("cannot write {path}")] + Write { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +/// How many unique names to try before giving up on the temporary file. +const TEMPORARY_ATTEMPTS: u32 = 8; + +/// Writes through a sibling temporary file, so a failed write never truncates the source. +/// # Errors +/// Returns an error when the temporary file, the source's mode, or the rename fails. +pub fn write_atomically(path: &Path, text: &str) -> Result<(), WriteError> { + let (temporary, mut handle) = create_temporary(path)?; + + let write = handle + .write_all(text.as_bytes()) + .and_then(|()| handle.sync_all()) + .and_then(|()| std::fs::metadata(path)) + .and_then(|metadata| std::fs::set_permissions(&temporary, metadata.permissions())) + .and_then(|()| std::fs::rename(&temporary, path)); + + write.map_err(|source| { + // The rename never happened, so the temporary file is ours to clean up. + let _ = std::fs::remove_file(&temporary); + WriteError::Write { + path: path.to_owned(), + source, + } + }) +} + +/// Creates a sibling temporary file under a name nothing else holds. +/// +/// A predictable name lets anything else with write access to the directory plant a +/// file the write would truncate, so the name carries the pid and a timestamp and the +/// file is created exclusively. +fn create_temporary(path: &Path) -> Result<(PathBuf, File), WriteError> { + let directory = path.parent().unwrap_or(Path::new(".")); + let stem = path.file_name().unwrap_or(OsStr::new("source")); + let pid = std::process::id(); + + let mut last = None; + for attempt in 0..TEMPORARY_ATTEMPTS { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.subsec_nanos()); + let mut name = OsString::from("."); + name.push(stem); + name.push(format!(".{pid}.{nanos}.{attempt}.tmp")); + let candidate = directory.join(name); + + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&candidate) + { + Ok(handle) => return Ok((candidate, handle)), + Err(error) if error.kind() == ErrorKind::AlreadyExists => last = Some(error), + Err(source) => { + return Err(WriteError::Write { + path: path.to_owned(), + source, + }); + } + } + } + + Err(WriteError::Write { + path: path.to_owned(), + source: last.unwrap_or_else(|| ErrorKind::AlreadyExists.into()), + }) } /// The line ending the file already uses, taken from its first line. @@ -142,6 +240,13 @@ fn render_doc(doc: &str, indent: usize, newline: &str, ops: &[&DocOp]) -> String *target = renamed; } } + DocOp::SetTagValue { line, tag, value } => { + if let Some(target) = lines.get_mut(line + shift) + && let Some(rewritten) = set_tag_value(target, tag, value) + { + *target = rewritten; + } + } _ => {} } } @@ -289,6 +394,22 @@ fn rename_tag(line: &str, from: &str, to: &str) -> Option { None } +/// Replaces everything after the tag, keeping a one-line comment's closing delimiter. +fn set_tag_value(line: &str, tag: &str, value: &str) -> Option { + let start = line.find(tag)?; + let rest = line.get(start + tag.len()..)?; + if rest.starts_with(|character: char| character.is_ascii_alphabetic()) { + return None; + } + + let closer = if rest.trim_end().ends_with("*/") { + " */" + } else { + "" + }; + Some(format!("{}{tag} {value}{closer}", &line[..start])) +} + /// Replaces the first word after `@module`, keeping whatever follows it. fn set_module_name(line: &str, name: &str) -> Option { let tag = "@module"; @@ -317,6 +438,7 @@ fn set_module_name(line: &str, name: &str) -> Option { mod tests { use super::{ DocOp, Edit, EditKind, apply, newline_of, rename_tag, render_doc, set_module_name, + set_tag_value, }; use crate::report::Position; @@ -543,6 +665,56 @@ mod tests { ); } + #[test] + fn a_constraints_value_is_replaced_where_it_stands() { + let doc = "/**\n * @description Bumps.\n *\n * @constraints k=?, rows=?\n */"; + + assert_eq!( + rendered( + doc, + 0, + &[DocOp::SetTagValue { + line: 3, + tag: "@constraints".to_owned(), + value: "k=13, rows=4273".to_owned(), + }] + ), + "/**\n * @description Bumps.\n *\n * @constraints k=13, rows=4273\n */" + ); + } + + #[test] + fn a_value_rewrite_drops_what_trailed_the_old_one() { + assert_eq!( + set_tag_value( + " * @constraints k=1 rows=2 (stale)", + "@constraints", + "k=3, rows=4" + ), + Some(" * @constraints k=3, rows=4".to_owned()) + ); + assert_eq!( + set_tag_value(" * @constraintsX k=1", "@constraints", "k=3"), + None + ); + } + + #[test] + fn a_one_line_doc_keeps_its_closing_delimiter() { + assert_eq!( + rendered( + "/** @constraints k=?, rows=? */", + 2, + &[DocOp::SetTagValue { + line: 0, + tag: "@constraints".to_owned(), + value: "k=7, rows=74".to_owned(), + }] + ), + "/** @constraints k=7, rows=74 */" + ); + } + #[test] fn crlf_sources_keep_their_line_ending() { let source = "/**\r\n * @description Bumps.\r\n */\r\n"; diff --git a/crates/compact-lint/src/fill.rs b/crates/compact-lint/src/fill.rs new file mode 100644 index 0000000..5ee0dd6 --- /dev/null +++ b/crates/compact-lint/src/fill.rs @@ -0,0 +1,297 @@ +//! The `fill-constraints` subcommand: measure every tagged circuit and write the value in. +//! +//! Scope is the constraints annotations that already exist. A circuit with no annotation +//! is `fix`'s job, so this pass never adds one. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +use crate::config::ConfigError; +use crate::edit::{DocOp, Edit, EditKind, WriteError, apply, newline_of, write_atomically}; +use crate::measure::{self, Compiler, Constraints, MeasureError}; +use crate::report::Position; +use crate::rules::{ConstraintSite, LintError, Linter}; +use crate::source::{Resolver, Source}; +use crate::target::{self, TargetError}; + +/// The rule name for a tagged circuit its source does not measure. +const UNMEASURED: &str = "constraints-unmeasured"; + +/// The rule name for a file with no measurement source at all. +const UNMEASURABLE: &str = "constraints-unmeasurable"; + +#[derive(Debug, Error)] +pub enum FillError { + #[error(transparent)] + Target(#[from] TargetError), + #[error(transparent)] + Config(#[from] ConfigError), + #[error(transparent)] + Lint(#[from] LintError), + #[error(transparent)] + Measure(#[from] MeasureError), + #[error(transparent)] + Write(#[from] WriteError), + #[error("cannot read {path}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +/// Everything the subcommand needs, already resolved from flags and env. +pub struct Options { + pub paths: Vec, + pub config_path: Option, + pub dry_run: bool, + /// Read the `.circuit-info.json` caches instead of compiling. + pub no_compile: bool, + pub compact_bin: OsString, + /// Where the compiler writes; a run without one uses a temporary directory. + pub artifacts: PathBuf, +} + +/// The report lines and the counts behind the summary. +pub struct Outcome { + /// One line per change or gap, in file order. + pub lines: Vec, + pub filled: usize, + pub files: usize, + pub unmeasured: usize, +} + +impl Outcome { + #[must_use] + pub fn summary(&self) -> String { + format!( + "{} values filled in {} files, {} unmeasured", + self.filled, self.files, self.unmeasured + ) + } +} + +/// One file's annotations and the contract they are measured through. +struct Work { + path: PathBuf, + text: String, + sites: Vec, + source: Source, +} + +/// Runs the subcommand with `cwd` as the working directory. +/// # Errors +/// Returns an error when the config, the file walk, the parser, the compiler or a write +/// fails. +pub fn run(options: &Options, cwd: &Path) -> Result { + let target = target::resolve(&options.paths, options.config_path.as_deref(), cwd)?; + let resolver = Resolver::new(&target.config, &target.base, cwd)?; + let tag = target.config.constraints.tag.clone(); + + let mut linter = Linter::new()?; + let mut work = Vec::new(); + for path in &target.files { + let text = std::fs::read_to_string(path).map_err(|source| FillError::Read { + path: path.clone(), + source, + })?; + let sites = linter.constraint_sites(path, &text, &target.config)?; + if sites.is_empty() { + continue; + } + let source = resolver.resolve(path); + work.push(Work { + path: path.clone(), + text, + sites, + source, + }); + } + + let measured = measure_sources( + &work, + options, + target.config.constraints.compiler.as_ref(), + cwd, + )?; + + let mut outcome = Outcome { + lines: Vec::new(), + filled: 0, + files: 0, + unmeasured: 0, + }; + for file in &work { + fill_file(file, &measured, &tag, options, &mut outcome)?; + } + Ok(outcome) +} + +/// Compiles each distinct source once, or reads its cache under `--no-compile`. +fn measure_sources( + work: &[Work], + options: &Options, + version: Option<&String>, + cwd: &Path, +) -> Result>, FillError> { + let mut sources: Vec<&PathBuf> = work + .iter() + .filter_map(|file| match &file.source { + Source::Found(source) => Some(source), + Source::Missing(_) => None, + }) + .collect(); + sources.sort(); + sources.dedup(); + + let compiler = Compiler { + binary: options.compact_bin.clone(), + version: version.cloned(), + artifacts: options.artifacts.clone(), + cwd: cwd.to_owned(), + }; + + let mut measured = BTreeMap::new(); + for source in sources { + let list = if options.no_compile { + measure::read_cache(source)?.ok_or_else(|| MeasureError::CacheMiss { + path: measure::cache_path(source), + name: source.display().to_string(), + })? + } else { + let list = compiler.measure(source)?; + // A preview writes nothing, the cache included. + if !options.dry_run { + measure::write_cache(source, &list)?; + } + list + }; + + measured.insert( + source.clone(), + list.iter() + .map(|entry| (entry.name.clone(), entry.constraints())) + .collect(), + ); + } + Ok(measured) +} + +/// Rewrites one file's annotations and records what changed and what could not be measured. +fn fill_file( + file: &Work, + measured: &BTreeMap>, + tag: &crate::doc::Tag, + options: &Options, + outcome: &mut Outcome, +) -> Result<(), FillError> { + let source = match &file.source { + Source::Found(source) => source, + Source::Missing(tried) => { + outcome.lines.push(unmeasurable_line(&file.path, tried)); + outcome.unmeasured += file.sites.len(); + return Ok(()); + } + }; + let constraints = measured.get(source); + + let mut edits = Vec::new(); + for site in &file.sites { + let Some(measurement) = constraints.and_then(|found| found.get(&site.circuit)) else { + outcome + .lines + .push(unmeasured_line(&file.path, site, source)); + outcome.unmeasured += 1; + continue; + }; + + let value = measurement.value(); + if value == site.value { + continue; + } + + outcome.lines.push(format!( + "{}: fill: {tag} {} -> {value}", + at(&file.path, site.position), + site.value + )); + edits.push(Edit { + position: site.position, + message: String::new(), + kind: EditKind::Doc { + range: site.doc.range.clone(), + indent: site.doc.indent, + op: DocOp::SetTagValue { + line: site.line_offset, + tag: tag.to_string(), + value, + }, + }, + }); + } + + if edits.is_empty() { + return Ok(()); + } + + outcome.filled += edits.len(); + outcome.files += 1; + if !options.dry_run { + let newline = newline_of(&file.text); + write_atomically(&file.path, &apply(&file.text, &edits, newline))?; + } + Ok(()) +} + +fn unmeasured_line(path: &Path, site: &ConstraintSite, source: &Path) -> String { + format!( + "{}: {UNMEASURED}: circuit `{}` has no measurement in {}", + at(path, site.position), + site.circuit, + source.display() + ) +} + +fn unmeasurable_line(path: &Path, tried: &[PathBuf]) -> String { + let tried: Vec = tried + .iter() + .map(|path| path.display().to_string()) + .collect(); + format!( + "{}: {UNMEASURABLE}: no measurement source for {}; tried {}", + at(path, Position::file_start()), + path.display(), + if tried.is_empty() { + "nothing".to_owned() + } else { + tried.join(", ") + } + ) +} + +fn at(path: &Path, position: Position) -> String { + format!("{}:{}:{}", path.display(), position.line, position.column) +} + +#[cfg(test)] +mod tests { + use super::Outcome; + + #[test] + fn the_summary_counts_values_files_and_gaps() { + let outcome = Outcome { + lines: Vec::new(), + filled: 12, + files: 4, + unmeasured: 1, + }; + + assert_eq!( + outcome.summary(), + "12 values filled in 4 files, 1 unmeasured" + ); + } +} diff --git a/crates/compact-lint/src/fix.rs b/crates/compact-lint/src/fix.rs index 4e44f5f..d8830c6 100644 --- a/crates/compact-lint/src/fix.rs +++ b/crates/compact-lint/src/fix.rs @@ -1,16 +1,12 @@ //! The `fix` subcommand: turn the fixable issues into edits and rewrite the files. -use std::ffi::{OsStr, OsString}; -use std::fs::{File, OpenOptions}; -use std::io::{ErrorKind, Write}; use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; use crate::config::Config; use crate::doc::Tag; -use crate::edit::{DocOp, Edit, EditKind, apply, newline_of}; +use crate::edit::{DocOp, Edit, EditKind, WriteError, apply, newline_of, write_atomically}; use crate::model::DeclKind; use crate::report::Position; use crate::rules::{Issue, LintError, Linter}; @@ -37,12 +33,8 @@ pub enum FixError { #[source] source: std::io::Error, }, - #[error("cannot write {path}")] - Write { - path: PathBuf, - #[source] - source: std::io::Error, - }, + #[error(transparent)] + Write(#[from] WriteError), } /// Everything the `fix` subcommand needs, already resolved from flags. @@ -312,71 +304,6 @@ fn skeleton( format!("{}{newline}{margin}", lines.join(newline)) } -/// How many unique names to try before giving up on the temporary file. -const TEMPORARY_ATTEMPTS: u32 = 8; - -/// Writes through a sibling temporary file, so a failed write never truncates the source. -fn write_atomically(path: &Path, text: &str) -> Result<(), FixError> { - let (temporary, mut handle) = create_temporary(path)?; - - let write = handle - .write_all(text.as_bytes()) - .and_then(|()| handle.sync_all()) - .and_then(|()| std::fs::metadata(path)) - .and_then(|metadata| std::fs::set_permissions(&temporary, metadata.permissions())) - .and_then(|()| std::fs::rename(&temporary, path)); - - write.map_err(|source| { - // The rename never happened, so the temporary file is ours to clean up. - let _ = std::fs::remove_file(&temporary); - FixError::Write { - path: path.to_owned(), - source, - } - }) -} - -/// Creates a sibling temporary file under a name nothing else holds. -/// -/// A predictable name lets another process plant a file the write would truncate, so -/// the name carries the pid and a timestamp and the file is created exclusively. -fn create_temporary(path: &Path) -> Result<(PathBuf, File), FixError> { - let directory = path.parent().unwrap_or(Path::new(".")); - let stem = path.file_name().unwrap_or(OsStr::new("source")); - let pid = std::process::id(); - - let mut last = None; - for attempt in 0..TEMPORARY_ATTEMPTS { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |since| since.subsec_nanos()); - let mut name = OsString::from("."); - name.push(stem); - name.push(format!(".{pid}.{nanos}.{attempt}.tmp")); - let candidate = directory.join(name); - - match OpenOptions::new() - .write(true) - .create_new(true) - .open(&candidate) - { - Ok(handle) => return Ok((candidate, handle)), - Err(error) if error.kind() == ErrorKind::AlreadyExists => last = Some(error), - Err(source) => { - return Err(FixError::Write { - path: path.to_owned(), - source, - }); - } - } - } - - Err(FixError::Write { - path: path.to_owned(), - source: last.unwrap_or_else(|| ErrorKind::AlreadyExists.into()), - }) -} - /// The line `fix` prints for one edit. #[must_use] pub fn line(path: &Path, position: Position, message: &str) -> String { diff --git a/crates/compact-lint/src/lib.rs b/crates/compact-lint/src/lib.rs index 2eb94bb..99ade4b 100644 --- a/crates/compact-lint/src/lib.rs +++ b/crates/compact-lint/src/lib.rs @@ -4,6 +4,8 @@ //! declaration against the per-kind doc-comment template in `compact-lint.toml`, and //! yields one [`rules::Issue`] per violation. `check` renders an issue as a //! [`report::Finding`]; `fix` renders the repairable ones as an [`edit::Edit`]. +//! `fill-constraints` takes the other path: [`measure`] compiles a contract and +//! [`fill`] writes the measured values into the annotations that already exist. #![forbid(unsafe_code)] @@ -12,9 +14,12 @@ pub mod config; pub mod discover; pub mod doc; pub mod edit; +pub mod fill; pub mod fix; pub mod format; +pub mod measure; pub mod model; pub mod report; pub mod rules; +pub mod source; pub mod target; diff --git a/crates/compact-lint/src/main.rs b/crates/compact-lint/src/main.rs index e47c785..b264b39 100644 --- a/crates/compact-lint/src/main.rs +++ b/crates/compact-lint/src/main.rs @@ -9,10 +9,11 @@ use std::process::ExitCode; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; +use tempfile::TempDir; use compact_lint::check::{self, Outcome}; -use compact_lint::fix; use compact_lint::format::{COMPACT_BIN_ENV, DEFAULT_COMPACT_BIN}; +use compact_lint::{fill, fix}; /// Exit code for a run that produced findings, or a `--dry-run` that would edit. const EXIT_FINDINGS: u8 = 1; @@ -37,6 +38,8 @@ enum Command { Check(CheckArgs), /// Rewrite doc comments so the rules `fix` covers stop reporting. Fix(FixArgs), + /// Measure every tagged circuit and write the values into its constraints tag. + FillConstraints(FillArgs), } #[derive(Debug, clap::Args)] @@ -75,6 +78,32 @@ struct FixArgs { dry_run: bool, } +#[derive(Debug, clap::Args)] +struct FillArgs { + /// Files or directories to fill; 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 the changes without writing them. + #[arg(long)] + dry_run: bool, + + /// Read the .circuit-info.json caches instead of compiling. + #[arg(long)] + no_compile: bool, + + /// Path to the `compact` binary. + #[arg(long, value_name = "PATH", env = COMPACT_BIN_ENV)] + compact_bin: Option, + + /// Directory the compiler writes to; kept after the run when given. + #[arg(long, value_name = "DIR")] + artifacts: Option, +} + fn main() -> ExitCode { match run() { Ok(code) => code, @@ -92,6 +121,7 @@ fn run() -> Result { match cli.command { Command::Check(args) => run_check(args, &cwd), Command::Fix(args) => run_fix(args, &cwd), + Command::FillConstraints(args) => run_fill(args, &cwd), } } @@ -133,6 +163,53 @@ fn run_fix(args: FixArgs, cwd: &std::path::Path) -> Result { }) } +fn run_fill(args: FillArgs, cwd: &std::path::Path) -> Result { + let (artifacts, temporary) = artifacts_dir(args.artifacts)?; + + let options = fill::Options { + paths: args.paths, + config_path: args.config, + dry_run: args.dry_run, + no_compile: args.no_compile, + compact_bin: args + .compact_bin + .unwrap_or_else(|| OsString::from(DEFAULT_COMPACT_BIN)), + artifacts, + }; + + let outcome = fill::run(&options, cwd).context("filling the constraints")?; + emit_lines(&outcome.lines, &outcome.summary()).context("writing the report")?; + + drop(temporary); + Ok(if outcome.unmeasured == 0 { + ExitCode::SUCCESS + } else { + ExitCode::from(EXIT_FINDINGS) + }) +} + +/// The artifacts directory, plus the temporary one to remove once the run ends. +fn artifacts_dir(given: Option) -> Result<(PathBuf, Option)> { + if let Some(path) = given { + return Ok((path, None)); + } + + let directory = tempfile::tempdir().context("creating the artifacts directory")?; + Ok((directory.path().to_owned(), Some(directory))) +} + +fn emit_lines(lines: &[String], summary: &str) -> std::io::Result<()> { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + for line in lines { + writeln!(out, "{line}")?; + } + out.flush()?; + + eprintln!("{summary}"); + Ok(()) +} + fn emit_findings(outcome: &Outcome) -> std::io::Result<()> { let stdout = std::io::stdout(); let mut out = stdout.lock(); diff --git a/crates/compact-lint/src/measure.rs b/crates/compact-lint/src/measure.rs new file mode 100644 index 0000000..954bc13 --- /dev/null +++ b/crates/compact-lint/src/measure.rs @@ -0,0 +1,602 @@ +//! Measuring a circuit's proving key size and row count with `compact compile`. +//! +//! `k` and `rows` reach only the compiler's terminal progress output, so the compile runs +//! under a pty and the transcript is parsed. Results are cached in the +//! `.circuit-info.json` file the TypeScript builder writes, in the same shape. + +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// The measurement cache, written beside the compiled source. +pub const CACHE_FILE_NAME: &str = ".circuit-info.json"; + +/// The compiler's own report of the circuits it built, under the artifacts directory. +const CONTRACT_INFO_PATH: [&str; 2] = ["compiler", "contract-info.json"]; + +/// Compiler-output lines kept in a failure report. +const TAIL_LINES: usize = 20; + +/// Terminal geometry for the compile; a wide row keeps a progress line off the wrap. +const PTY_SIZE: PtySize = PtySize { + rows: 60, + cols: 200, + pixel_width: 0, + pixel_height: 0, +}; + +#[derive(Debug, Error)] +pub enum MeasureError { + #[error("cannot run `{binary}` under a pty")] + Spawn { + binary: String, + #[source] + source: Box, + }, + #[error("cannot read the compiler output for {file}")] + Transcript { + file: PathBuf, + #[source] + cause: std::io::Error, + }, + #[error("compiling {file} failed:\n{tail}")] + Compile { file: PathBuf, tail: String }, + #[error("cannot read {path}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("cannot write {path}")] + Write { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("cannot parse {path}")] + Parse { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("{path} has no cached measurement for {name}; drop --no-compile to compile it")] + CacheMiss { path: PathBuf, name: String }, +} + +/// One circuit's measured constraints. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct Constraints { + pub k: u32, + pub rows: u32, +} + +impl Constraints { + /// The annotation value, the one spelling `check` accepts. + #[must_use] + pub fn value(self) -> String { + format!("k={}, rows={}", self.k, self.rows) + } +} + +/// One entry of the `.circuit-info.json` cache. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct Measurement { + pub name: String, + pub k: u32, + pub rows: u32, +} + +impl Measurement { + #[must_use] + pub const fn constraints(&self) -> Constraints { + Constraints { + k: self.k, + rows: self.rows, + } + } +} + +/// The `.circuit-info.json` shape, shared with the TypeScript builder. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +struct CircuitInfo { + #[serde(rename = "generatedAt")] + generated_at: String, + files: BTreeMap>, +} + +/// The compiler's circuit list; only `proof` circuits carry constraints. +#[derive(Debug, Deserialize)] +struct ContractInfo { + #[serde(default)] + circuits: Vec, +} + +#[derive(Debug, Deserialize)] +struct ContractCircuit { + name: String, + #[serde(default = "proves")] + proof: bool, +} + +/// An absent `proof` flag predates the field, when every circuit was proved. +const fn proves() -> bool { + true +} + +/// One `compact compile` invocation, reused for every source in a run. +pub struct Compiler { + pub binary: OsString, + /// Toolchain version passed as `+`. + pub version: Option, + /// Where each source's output directory is created. + pub artifacts: PathBuf, + /// The child's working directory, so a relative source path resolves as it reads. + pub cwd: PathBuf, +} + +impl Compiler { + /// Compiles one source and returns the circuits the compiler measured. + /// # Errors + /// Returns an error when the binary cannot run, the compile fails, or the + /// artifacts cannot be read. + pub fn measure(&self, source: &Path) -> Result, MeasureError> { + let stem = source + .file_stem() + .unwrap_or_else(|| OsStr::new("contract")) + .to_owned(); + let output = self.artifacts.join(&stem); + std::fs::create_dir_all(&output).map_err(|cause| MeasureError::Write { + path: output.clone(), + source: cause, + })?; + + let (status, raw) = self.spawn(source, &output)?; + let cleaned = clean(&raw); + if status != 0 { + return Err(MeasureError::Compile { + file: source.to_owned(), + tail: tail(&cleaned, TAIL_LINES), + }); + } + + let mut measured = parse(&cleaned); + if let Some(proved) = proved_circuits(&output)? { + measured.retain(|measurement| proved.contains(&measurement.name)); + } + Ok(measured) + } + + /// Runs the compile under a pty and returns its exit code and raw transcript. + fn spawn(&self, source: &Path, output: &Path) -> Result<(i32, String), MeasureError> { + let pty = native_pty_system(); + let pair = pty + .openpty(PTY_SIZE) + .map_err(|cause| self.spawn_error(cause))?; + + let mut command = CommandBuilder::new(&self.binary); + command.arg("compile"); + if let Some(version) = &self.version { + command.arg(format!("+{version}")); + } + command.arg(source); + command.arg(output); + command.env("TERM", "xterm-256color"); + command.cwd(&self.cwd); + + let mut child = pair + .slave + .spawn_command(command) + .map_err(|cause| self.spawn_error(cause))?; + drop(pair.slave); + + let mut reader = pair + .master + .try_clone_reader() + .map_err(|cause| self.spawn_error(cause))?; + let mut raw = Vec::new(); + reader + .read_to_end(&mut raw) + .map_err(|cause| MeasureError::Transcript { + file: source.to_owned(), + cause, + })?; + + let status = child.wait().map_err(|cause| MeasureError::Transcript { + file: source.to_owned(), + cause, + })?; + drop(pair.master); + + let code = i32::try_from(status.exit_code()).unwrap_or(i32::MAX); + Ok((code, String::from_utf8_lossy(&raw).into_owned())) + } + + fn spawn_error(&self, cause: anyhow::Error) -> MeasureError { + MeasureError::Spawn { + binary: self.binary.to_string_lossy().into_owned(), + source: cause.into(), + } + } +} + +/// The names of the circuits the compiler proved, or `None` without a contract report. +fn proved_circuits(output: &Path) -> Result>, MeasureError> { + let path = CONTRACT_INFO_PATH + .iter() + .fold(output.to_owned(), |path, segment| path.join(segment)); + if !path.is_file() { + return Ok(None); + } + + let text = std::fs::read_to_string(&path).map_err(|source| MeasureError::Read { + path: path.clone(), + source, + })?; + let info: ContractInfo = + serde_json::from_str(&text).map_err(|source| MeasureError::Parse { path, source })?; + + Ok(Some( + info.circuits + .into_iter() + .filter(|circuit| circuit.proof) + .map(|circuit| circuit.name) + .collect(), + )) +} + +/// Reads a source's entry from the cache beside it. +/// # Errors +/// Returns an error when the cache is unreadable or malformed. +pub fn read_cache(source: &Path) -> Result>, MeasureError> { + let path = cache_path(source); + if !path.is_file() { + return Ok(None); + } + + let info = load_cache(&path)?; + Ok(info.files.get(&file_key(source)).cloned()) +} + +/// Writes a source's entry into the cache beside it, keeping every other entry. +/// # Errors +/// Returns an error when the cache is unreadable, malformed or unwritable. +pub fn write_cache(source: &Path, measured: &[Measurement]) -> Result { + let path = cache_path(source); + let mut info = if path.is_file() { + load_cache(&path)? + } else { + CircuitInfo::default() + }; + + info.generated_at = timestamp(SystemTime::now()); + info.files.insert(file_key(source), measured.to_vec()); + + let mut text = serde_json::to_string_pretty(&info).map_err(|source| MeasureError::Parse { + path: path.clone(), + source, + })?; + text.push('\n'); + std::fs::write(&path, text).map_err(|source| MeasureError::Write { + path: path.clone(), + source, + })?; + Ok(path) +} + +fn load_cache(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|source| MeasureError::Read { + path: path.to_owned(), + source, + })?; + serde_json::from_str(&text).map_err(|source| MeasureError::Parse { + path: path.to_owned(), + source, + }) +} + +/// The cache sits in the source's own directory. +#[must_use] +pub fn cache_path(source: &Path) -> PathBuf { + source + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(CACHE_FILE_NAME) +} + +/// Entries are keyed by the source's file name, not its path. +fn file_key(source: &Path) -> String { + source + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() +} + +/// Strips ANSI escapes, spinner frames and carriage-return redraws from pty output. +#[must_use] +pub fn clean(raw: &str) -> String { + let stripped = strip_escapes(raw); + let mut out = String::with_capacity(stripped.len()); + for (index, line) in stripped.split('\n').enumerate() { + if index > 0 { + out.push('\n'); + } + out.push_str(&clean_line(line)); + } + out +} + +/// The compiler redraws a line over itself, so only the text after the last `\r` is visible. +fn clean_line(line: &str) -> String { + let body = line.strip_suffix('\r').unwrap_or(line); + let visible = body.rsplit('\r').next().unwrap_or(body); + + let mut out = String::with_capacity(visible.len()); + let mut spaced = false; + for character in visible.chars() { + if is_spinner(character) { + continue; + } + if character.is_whitespace() { + spaced = true; + continue; + } + if spaced && !out.is_empty() { + out.push(' '); + } + spaced = false; + out.push(character); + } + out +} + +/// Braille frames and the tick / cross the compiler marks a finished circuit with. +fn is_spinner(character: char) -> bool { + matches!(character, '\u{2800}'..='\u{28ff}' | '✓' | '✔' | '✗' | '✘') +} + +/// Drops CSI, OSC and character-set escape sequences, keeping the visible text. +fn strip_escapes(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut characters = raw.chars(); + + while let Some(character) = characters.next() { + if character != '\u{1b}' { + out.push(character); + continue; + } + match characters.next() { + // A CSI sequence runs to its first byte in the 0x40..0x7e range. + Some('[') => { + for parameter in characters.by_ref() { + if ('\u{40}'..='\u{7e}').contains(¶meter) { + break; + } + } + } + // An OSC sequence runs to a BEL or a string terminator. + Some(']') => { + let mut escaped = false; + for parameter in characters.by_ref() { + if parameter == '\u{7}' || (escaped && parameter == '\\') { + break; + } + escaped = parameter == '\u{1b}'; + } + } + Some('(' | ')') => { + characters.next(); + } + _ => {} + } + } + out +} + +/// Every measured circuit in a cleaned transcript; the last line for a name wins. +#[must_use] +pub fn parse(cleaned: &str) -> Vec { + let mut found: BTreeMap = BTreeMap::new(); + for line in cleaned.lines() { + if let Some(measurement) = parse_line(line) { + found.insert(measurement.name.clone(), measurement); + } + } + found.into_values().collect() +} + +/// Matches `circuit "" (k=, rows=)`, the compiler's per-circuit progress line. +fn parse_line(line: &str) -> Option { + let after = line.split_once("circuit ")?.1.trim_start(); + let (name, rest) = after.strip_prefix('"')?.split_once('"')?; + let fields = rest.trim_start().strip_prefix('(')?.split_once(')')?.0; + let (k, rows) = fields.split_once(',')?; + + Some(Measurement { + name: name.to_owned(), + k: field(k, "k")?, + rows: field(rows, "rows")?, + }) +} + +/// One `=` field of a progress line. +fn field(text: &str, name: &str) -> Option { + text.trim() + .strip_prefix(name)? + .trim_start() + .strip_prefix('=')? + .trim() + .parse() + .ok() +} + +/// The last `keep` non-blank lines of a cleaned transcript, with repeats dropped. +#[must_use] +pub fn tail(cleaned: &str, keep: usize) -> String { + let mut lines: Vec<&str> = Vec::new(); + for line in cleaned.lines().map(str::trim) { + if line.is_empty() || lines.last() == Some(&line) { + continue; + } + lines.push(line); + } + + let start = lines.len().saturating_sub(keep); + lines[start..].join("\n") +} + +/// An RFC 3339 UTC timestamp, the spelling the TypeScript builder writes. +fn timestamp(now: SystemTime) -> String { + let seconds = now + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs()); + let (days, rest) = (seconds / 86_400, seconds % 86_400); + let (year, month, day) = civil_date(i64::try_from(days).unwrap_or(0)); + + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.000Z", + rest / 3600, + (rest % 3600) / 60, + rest % 60 + ) +} + +/// Days since the epoch to a civil date, by Howard Hinnant's `civil_from_days`. +fn civil_date(days: i64) -> (i64, i64, i64) { + let shifted = days + 719_468; + let era = shifted.div_euclid(146_097); + let day_of_era = shifted.rem_euclid(146_097); + let year_of_era = + (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let shifted_month = (5 * day_of_year + 2) / 153; + + let day = day_of_year - (153 * shifted_month + 2) / 5 + 1; + let month = if shifted_month < 10 { + shifted_month + 3 + } else { + shifted_month - 9 + }; + (year + i64::from(month <= 2), month, day) +} + +#[cfg(test)] +mod tests { + use super::{ + CACHE_FILE_NAME, Measurement, civil_date, clean, parse, read_cache, tail, timestamp, + write_cache, + }; + use std::path::Path; + use std::time::{Duration, UNIX_EPOCH}; + + /// A pty transcript of `compact compile … MockOwnable.compact`, trimmed to two frames. + fn transcript() -> String { + std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/fill-transcript/pty.txt"), + ) + .expect("the captured transcript is readable") + } + + fn measurement(name: &str, k: u32, rows: u32) -> Measurement { + Measurement { + name: name.to_owned(), + k, + rows, + } + } + + #[test] + fn a_pty_transcript_yields_one_measurement_per_circuit() { + let measured = parse(&clean(&transcript())); + + assert_eq!( + measured, + [ + measurement("_transferOwnership", 10, 598), + measurement("_unsafeTransferOwnership", 13, 4868), + measurement("_unsafeUncheckedTransferOwnership", 10, 595), + measurement("assertOnlyOwner", 13, 4273), + measurement("owner", 7, 74), + measurement("renounceOwnership", 13, 4277), + measurement("transferOwnership", 13, 4871), + ] + ); + } + + #[test] + fn the_last_line_for_a_name_wins_over_the_partial_frames_above_it() { + let frames = " circuit \"owner\" (k=7)\n circuit \"owner\" (k=7, rows=9999)\n circuit \"owner\" (k=7, rows=74)\n"; + + assert_eq!(parse(frames), [measurement("owner", 7, 74)]); + } + + #[test] + fn escapes_and_redraws_leave_only_the_visible_text() { + let raw = "\u{1b}[2K circuit \"owner\" \u{1b}[32m|\u{1b}[0m \r\u{1b}[2K circuit \"owner\" (k=7, rows=74)\r\n"; + + assert_eq!(clean(raw), "circuit \"owner\" (k=7, rows=74)\n"); + } + + #[test] + fn a_line_without_both_fields_is_not_a_measurement() { + assert!(parse(" circuit \"owner\" (k=7)\n").is_empty()); + assert!(parse("Compiling 7 circuits:\n").is_empty()); + assert!(parse(" circuit \"owner\" (rows=74, k=7)\n").is_empty()); + } + + #[test] + fn a_failure_tail_keeps_the_last_distinct_lines() { + let cleaned = "one\none\ntwo\n\nthree\nfour\n"; + + assert_eq!(tail(cleaned, 2), "three\nfour"); + assert_eq!(tail(cleaned, 20), "one\ntwo\nthree\nfour"); + } + + #[test] + fn a_cache_write_keeps_the_entries_of_other_sources() { + let directory = tempfile::tempdir().expect("a temporary directory is available"); + let source = directory.path().join("MockOwnable.compact"); + std::fs::write( + directory.path().join(CACHE_FILE_NAME), + "{\n \"generatedAt\": \"2026-01-01T00:00:00.000Z\",\n \"files\": {\n \"Other.compact\": [{ \"name\": \"run\", \"k\": 3, \"rows\": 4 }]\n }\n}\n", + ) + .expect("the cache is writable"); + + let written = [measurement("owner", 7, 74)]; + write_cache(&source, &written).expect("the cache is writable"); + + assert_eq!( + read_cache(&source).expect("the cache is readable"), + Some(written.to_vec()) + ); + assert_eq!( + read_cache(&directory.path().join("Other.compact")).expect("the cache is readable"), + Some(vec![measurement("run", 3, 4)]) + ); + } + + #[test] + fn a_source_with_no_cache_entry_reads_as_absent() { + let directory = tempfile::tempdir().expect("a temporary directory is available"); + + assert_eq!( + read_cache(&directory.path().join("Gone.compact")).expect("the cache is readable"), + None + ); + } + + #[test] + fn a_timestamp_renders_as_utc_rfc_3339() { + let moment = UNIX_EPOCH + Duration::from_hours(490_896); + + assert_eq!(timestamp(moment), "2026-01-01T00:00:00.000Z"); + assert_eq!(civil_date(0), (1970, 1, 1)); + } +} diff --git a/crates/compact-lint/src/rules.rs b/crates/compact-lint/src/rules.rs index 3c5ab94..782feb6 100644 --- a/crates/compact-lint/src/rules.rs +++ b/crates/compact-lint/src/rules.rs @@ -31,6 +31,23 @@ pub struct DocRef { pub indent: usize, } +/// A constraints annotation already present on an exported non-pure circuit. +/// +/// `check` only reports the annotations it faults; `fill-constraints` rewrites every one +/// of them, so it reads the sites directly. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConstraintSite { + /// The circuit's name, which is the name its measurement carries. + pub circuit: String, + /// Position of the tag's `@`. + pub position: Position, + /// 0-based line index of the tag inside the doc comment. + pub line_offset: usize, + /// The annotation's value, first line only. + pub value: String, + pub doc: DocRef, +} + /// One rule violation, carrying what `check` prints and what `fix` needs to repair it. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Issue { @@ -205,6 +222,34 @@ impl Linter { Ok(issues) } + /// Every constraints annotation in one file, in source order. + /// + /// A file holding a parse defect yields none, because its declarations are not + /// trustworthy enough to rewrite. + /// # Errors + /// Returns an error when tree-sitter produces no tree for `source`. + pub fn constraint_sites( + &mut self, + display: &Path, + source: &str, + config: &Config, + ) -> Result, LintError> { + let tree = self + .parser + .parse(source, None) + .ok_or_else(|| LintError::NoTree(display.to_owned()))?; + let root = tree.root_node(); + + if first_defect(root).is_some() { + return Ok(Vec::new()); + } + + Ok(declarations(root, source) + .iter() + .filter_map(|declaration| constraint_site(declaration, &config.constraints.tag)) + .collect()) + } + /// Checks one file. `display` is the path printed in findings. /// # Errors /// Returns an error when tree-sitter produces no tree for `source`. @@ -349,6 +394,26 @@ const fn takes_constraints(declaration: &Declaration) -> bool { matches!(declaration.kind, DeclKind::Circuit) && declaration.exported && !declaration.pure } +/// The annotation on one declaration, where it is a named circuit that takes one. +fn constraint_site(declaration: &Declaration, tag: &Tag) -> Option { + if !takes_constraints(declaration) { + return None; + } + let attached = declaration.doc.as_ref()?; + let occurrence = attached.comment.first(tag)?; + + Some(ConstraintSite { + circuit: declaration.name.clone()?, + position: attached.tag_position(occurrence), + line_offset: occurrence.line_offset, + value: value_of(occurrence).to_owned(), + doc: DocRef { + range: attached.range.clone(), + indent: attached.start_column, + }, + }) +} + fn check_constraints( declaration: &Declaration, doc: &DocRef, @@ -374,8 +439,7 @@ fn check_constraints( 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 value = value_of(occurrence); let position = attached.tag_position(occurrence); let Some(placeholders) = parse_constraints(value) else { @@ -396,6 +460,11 @@ fn check_constraints( } } +/// The annotation is one line; anything below it is prose, not part of the value. +fn value_of(occurrence: &crate::doc::TagOccurrence) -> &str { + occurrence.value.lines().next().unwrap_or_default().trim() +} + /// Matches `k=,*rows=`; returns whether either field is a `?` placeholder. fn parse_constraints(value: &str) -> Option { let rest = value.strip_prefix("k=")?; diff --git a/crates/compact-lint/src/source.rs b/crates/compact-lint/src/source.rs new file mode 100644 index 0000000..3e486b0 --- /dev/null +++ b/crates/compact-lint/src/source.rs @@ -0,0 +1,285 @@ +//! Which contract a file's circuits are measured through. +//! +//! A library module does not compile on its own, so its constraints are measured through +//! the mock contract that exports the same circuit names. + +use std::path::{Component, Path, PathBuf}; + +use crate::config::{Config, ConfigError}; + +/// Where a file's measurements come from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Source { + /// The measurement source, which is the file itself for a contract. + Found(PathBuf), + /// No candidate exists, in the order they were tried. + Missing(Vec), +} + +/// The templates and globs, compiled once per run. +pub struct Resolver { + /// Directory the config's relative paths and globs are anchored at. + base: PathBuf, + cwd: PathBuf, + own: globset::GlobSet, + sources: Vec, + overrides: Vec<(PathBuf, PathBuf)>, +} + +impl Resolver { + /// # Errors + /// Returns an error when a `constraints.self` pattern is not a valid glob. + pub fn new(config: &Config, base: &Path, cwd: &Path) -> Result { + Ok(Self { + base: base.to_owned(), + cwd: cwd.to_owned(), + own: config.own_set(base)?, + sources: config.constraints.sources.clone(), + overrides: config + .constraints + .overrides + .iter() + .map(|(file, source)| (base.join(file), base.join(source))) + .collect(), + }) + } + + /// Resolves one file, trying overrides, then the `self` globs, then the templates. + #[must_use] + pub fn resolve(&self, file: &Path) -> Source { + let absolute = normalize(&self.absolute(file)); + let mut tried = Vec::new(); + + for (from, to) in &self.overrides { + if normalize(from) == absolute { + return self.first_existing(std::iter::once(self.shown(to)), &mut tried); + } + } + + let relative = absolute.strip_prefix(&self.base).unwrap_or(&absolute); + if self.own.is_match(relative) { + return Source::Found(file.to_owned()); + } + + self.first_existing(self.candidates(file), &mut tried) + } + + /// The templates expanded for one file, in config order. + fn candidates(&self, file: &Path) -> impl Iterator { + let directory = file.parent().unwrap_or(Path::new(".")).to_owned(); + let parent = directory.parent().map(Path::to_owned); + let stem = file + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + + self.sources + .clone() + .into_iter() + .filter_map(move |template| expand(&template, &directory, parent.as_deref(), &stem)) + } + + fn first_existing( + &self, + candidates: impl Iterator, + tried: &mut Vec, + ) -> Source { + for candidate in candidates { + if self.absolute(&candidate).is_file() { + return Source::Found(candidate); + } + tried.push(candidate); + } + Source::Missing(std::mem::take(tried)) + } + + /// Paths under the working directory print relative to it, as the file list does. + fn shown(&self, path: &Path) -> PathBuf { + path.strip_prefix(&self.cwd).unwrap_or(path).to_owned() + } + + fn absolute(&self, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_owned() + } else { + self.cwd.join(path) + } + } +} + +/// Fills a template's placeholders; a template needing `{parent}` at the root drops out. +fn expand(template: &str, directory: &Path, parent: Option<&Path>, stem: &str) -> Option { + let mut filled = template.replace("{stem}", stem); + + if filled.contains("{parent}") { + filled = filled.replace("{parent}", &display(parent?)); + } + if filled.contains("{dir}") { + filled = filled.replace("{dir}", &display(directory)); + } + Some(normalize(Path::new(&filled))) +} + +/// An empty directory renders as `.`, so a template never collapses into an absolute path. +fn display(path: &Path) -> String { + let text = path.to_string_lossy(); + if text.is_empty() { + ".".to_owned() + } else { + text.into_owned() + } +} + +/// Resolves `.` and `..` lexically, so two spellings of one path compare equal. +/// +/// A `..` with nothing to cancel is kept, so a relative path that climbs out of its own +/// directory still points where it did. +fn normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => match out.components().next_back() { + Some(Component::Normal(_)) => { + out.pop(); + } + Some(Component::RootDir | Component::Prefix(_)) => {} + _ => out.push(Component::ParentDir), + }, + other => out.push(other), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::{Resolver, Source}; + use crate::config::Config; + use std::path::{Path, PathBuf}; + + /// A tree with a module, its mock one level up, an extension and a preset. + fn tree() -> tempfile::TempDir { + let directory = tempfile::tempdir().expect("a temporary directory is available"); + for relative in [ + "src/access/Ownable.compact", + "src/access/test/mocks/MockOwnable.compact", + "src/token/extensions/Capped.compact", + "src/token/test/mocks/MockCapped.compact", + "src/multisig/presets/Vault.compact", + "src/utils/Utils.compact", + "src/utils/test/mocks/MockUtilities.compact", + "src/orphan/Orphan.compact", + ] { + let path = directory.path().join(relative); + std::fs::create_dir_all(path.parent().expect("the path has a directory")) + .expect("the tree is writable"); + std::fs::write(&path, "").expect("the source is writable"); + } + directory + } + + fn resolve(config: &Config, root: &Path, file: &str) -> Source { + Resolver::new(config, root, root) + .expect("the globs compile") + .resolve(Path::new(file)) + } + + #[test] + fn a_module_measures_through_the_mock_beside_it() { + let tree = tree(); + + assert_eq!( + resolve( + &Config::default(), + tree.path(), + "src/access/Ownable.compact" + ), + Source::Found(PathBuf::from("src/access/test/mocks/MockOwnable.compact")) + ); + } + + #[test] + fn an_extension_measures_through_the_mock_in_its_parent() { + let tree = tree(); + + assert_eq!( + resolve( + &Config::default(), + tree.path(), + "src/token/extensions/Capped.compact" + ), + Source::Found(PathBuf::from("src/token/test/mocks/MockCapped.compact")) + ); + } + + #[test] + fn a_preset_matching_a_self_glob_measures_through_itself() { + let tree = tree(); + let mut config = Config::default(); + config.constraints.own = vec!["**/presets/**".to_owned()]; + + assert_eq!( + resolve(&config, tree.path(), "src/multisig/presets/Vault.compact"), + Source::Found(PathBuf::from("src/multisig/presets/Vault.compact")) + ); + } + + #[test] + fn an_override_wins_over_the_templates() { + let tree = tree(); + let mut config = Config::default(); + config.constraints.overrides.insert( + "src/utils/Utils.compact".to_owned(), + "src/utils/test/mocks/MockUtilities.compact".to_owned(), + ); + + assert_eq!( + resolve(&config, tree.path(), "src/utils/Utils.compact"), + Source::Found(PathBuf::from("src/utils/test/mocks/MockUtilities.compact")) + ); + } + + #[test] + fn a_parent_relative_template_resolves_outside_the_working_directory() { + let tree = tempfile::tempdir().expect("a temporary directory is available"); + for relative in ["work/Ownable.compact", "mocks/Ownable.compact"] { + let path = tree.path().join(relative); + std::fs::create_dir_all(path.parent().expect("the path has a directory")) + .expect("the tree is writable"); + std::fs::write(&path, "").expect("the source is writable"); + } + + let mut config = Config::default(); + config.constraints.sources = vec!["../mocks/{stem}.compact".to_owned()]; + let work = tree.path().join("work"); + + assert_eq!( + resolve(&config, &work, "Ownable.compact"), + Source::Found(PathBuf::from("../mocks/Ownable.compact")) + ); + } + + #[test] + fn normalize_keeps_a_parent_it_cannot_cancel() { + assert_eq!( + super::normalize(Path::new("../mocks/./Ownable.compact")), + PathBuf::from("../mocks/Ownable.compact") + ); + assert_eq!(super::normalize(Path::new("/..")), PathBuf::from("/")); + } + + #[test] + fn a_file_with_no_candidate_reports_the_paths_it_tried() { + let tree = tree(); + + assert_eq!( + resolve(&Config::default(), tree.path(), "src/orphan/Orphan.compact"), + Source::Missing(vec![ + PathBuf::from("src/orphan/test/mocks/MockOrphan.compact"), + PathBuf::from("src/test/mocks/MockOrphan.compact"), + ]) + ); + } +} diff --git a/crates/compact-lint/src/target.rs b/crates/compact-lint/src/target.rs index fe2f907..e528f32 100644 --- a/crates/compact-lint/src/target.rs +++ b/crates/compact-lint/src/target.rs @@ -15,10 +15,12 @@ pub enum TargetError { Discover(#[from] DiscoverError), } -/// The config and the files it selects, shared by `check` and `fix`. +/// The config and the files it selects, shared by every subcommand. pub struct Target { pub config: Config, pub files: Vec, + /// Directory the config's own relative paths and globs are anchored at. + pub base: PathBuf, } /// Resolves the config and the file list for one run. @@ -57,7 +59,11 @@ pub fn resolve( }; let files = files.iter().map(|path| display_path(path, cwd)).collect(); - Ok(Target { config, files }) + Ok(Target { + config, + files, + base, + }) } /// Discovery matches the exclude globs against paths relative to the config's diff --git a/crates/compact-lint/tests/fill.rs b/crates/compact-lint/tests/fill.rs new file mode 100644 index 0000000..8c8cbcb --- /dev/null +++ b/crates/compact-lint/tests/fill.rs @@ -0,0 +1,289 @@ +//! `compact-lint fill-constraints` end to end, against a fake `compact` binary. +//! +//! The fake at `tests/fixtures/fill-fake-compact/bin/compact` prints the compiler's +//! progress lines for a hard-coded table and writes a matching contract report, so the +//! pty path is exercised where the real toolchain is absent. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use assert_cmd::prelude::*; +use tempfile::TempDir; + +/// Exit code for a run that left a tagged circuit unmeasured. +const EXIT_UNMEASURED: i32 = 1; + +/// Exit code for a config, IO or compiler error. +const EXIT_ERROR: i32 = 2; + +fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} + +fn case(name: &str) -> PathBuf { + fixtures() + .join(format!("fill-{name}")) + .canonicalize() + .expect("the fixture case exists") +} + +fn fake_compact() -> PathBuf { + fixtures() + .join("fill-fake-compact/bin/compact") + .canonicalize() + .expect("the fake compiler exists") +} + +fn binary() -> Command { + Command::cargo_bin("compact-lint").expect("the binary is built alongside this test") +} + +/// Copies the case's config and `before/` tree into a fresh temporary directory. +fn work(name: &str) -> TempDir { + let directory = TempDir::new().expect("a temporary directory is available"); + let root = case(name); + + std::fs::copy( + root.join("compact-lint.toml"), + directory.path().join("compact-lint.toml"), + ) + .expect("the case has a config"); + copy_tree(&root.join("before"), directory.path()); + + directory +} + +fn copy_tree(from: &Path, to: &Path) { + for entry in std::fs::read_dir(from).expect("the tree is readable") { + let entry = entry.expect("the entry is readable"); + let target = to.join(entry.file_name()); + if entry.path().is_dir() { + std::fs::create_dir_all(&target).expect("the target is writable"); + copy_tree(&entry.path(), &target); + } else { + std::fs::copy(entry.path(), &target).expect("the file is copyable"); + } + } +} + +/// Every `.compact` file under `root`, keyed by its path relative to it. +fn sources(root: &Path) -> BTreeMap> { + let mut found = BTreeMap::new(); + collect(root, root, &mut found); + found +} + +fn collect(root: &Path, directory: &Path, out: &mut BTreeMap>) { + for entry in std::fs::read_dir(directory).expect("the tree is readable") { + let entry = entry.expect("the entry is readable"); + let path = entry.path(); + if path.is_dir() { + collect(root, &path, out); + } else if path + .extension() + .is_some_and(|extension| extension == "compact") + { + let relative = path.strip_prefix(root).expect("the path is under the root"); + out.insert( + relative.to_owned(), + std::fs::read(&path).expect("the source is readable"), + ); + } + } +} + +fn expected(name: &str) -> String { + std::fs::read_to_string(case(name).join("expected.txt")).expect("the case has an expectation") +} + +/// Runs `fill-constraints` inside `directory`, pointed at the fake compiler. +fn fill(directory: &Path, extra: &[&str]) -> Output { + binary() + .current_dir(directory) + .args(["fill-constraints", "--compact-bin"]) + .arg(fake_compact()) + .args(extra) + .output() + .expect("the binary ran") +} + +fn code_and_stdout(output: &Output) -> (i32, String) { + ( + output.status.code().expect("the binary was not signalled"), + String::from_utf8_lossy(&output.stdout).into_owned(), + ) +} + +/// Runs another subcommand inside `directory` and returns its exit code and stdout. +fn run(directory: &Path, args: &[&str]) -> (i32, String) { + let output = binary() + .current_dir(directory) + .args(args) + .output() + .expect("the binary ran"); + code_and_stdout(&output) +} + +/// The whole contract of a measurable case: it fills, it lands, it settles, it previews. +/// +/// `settled` is what a second run still reports, which is the unmeasured circuits. +fn assert_case(name: &str, extra: &[&str], exit: i32, settled: &str) { + let directory = work(name); + let root = directory.path(); + + let (code, stdout) = code_and_stdout(&fill(root, extra)); + assert_eq!(stdout, expected(name), "stdout for case {name}"); + assert_eq!(code, exit, "exit code for case {name}"); + assert_eq!( + sources(root), + sources(&case(name).join("after")), + "rewritten sources for case {name}" + ); + + let (code, stdout) = run(root, &["check", "--strict", "--no-format"]); + assert_eq!(stdout, "", "check is clean after filling case {name}"); + assert_eq!(code, 0, "check exit code after filling case {name}"); + + let (code, stdout) = code_and_stdout(&fill(root, extra)); + assert_eq!(stdout, settled, "a second run on case {name}"); + assert_eq!(code, exit, "second run exit code for case {name}"); + + let preview = work(name); + let before = sources(preview.path()); + let mut arguments = extra.to_vec(); + arguments.push("--dry-run"); + let (code, stdout) = code_and_stdout(&fill(preview.path(), &arguments)); + assert_eq!(stdout, expected(name), "dry-run stdout for case {name}"); + assert_eq!(code, exit, "dry-run exit code for case {name}"); + assert_eq!( + sources(preview.path()), + before, + "dry-run wrote to case {name}" + ); +} + +#[test] +fn placeholders_and_stale_values_take_the_measurement() { + let unmeasured = expected("basic") + .lines() + .filter(|line| line.contains("constraints-unmeasured")) + .collect::>() + .join("\n"); + + assert_case("basic", &[], EXIT_UNMEASURED, &format!("{unmeasured}\n")); +} + +#[test] +fn a_preset_matching_a_self_glob_compiles_itself() { + assert_case("self", &[], 0, ""); +} + +#[test] +fn an_override_names_the_contract_that_measures_a_file() { + assert_case("override", &[], 0, ""); +} + +#[test] +fn a_seeded_cache_fills_without_a_compiler() { + assert_case("no-compile", &["--no-compile"], 0, ""); +} + +#[test] +fn a_circuit_the_report_marks_unproved_stays_unmeasured() { + let directory = work("basic"); + let (_, stdout) = code_and_stdout(&fill(directory.path(), &[])); + + assert!( + stdout.contains("circuit `initialize` has no measurement"), + "{stdout}" + ); + assert!(!stdout.contains("k=5, rows=10"), "{stdout}"); +} + +#[test] +fn a_file_with_no_measurement_source_reports_once_and_exits_one() { + let directory = work("missing-source"); + let root = directory.path(); + let before = sources(root); + + let (code, stdout) = code_and_stdout(&fill(root, &[])); + + assert_eq!(stdout, expected("missing-source")); + assert_eq!(code, EXIT_UNMEASURED); + assert_eq!(sources(root), before); +} + +#[test] +fn a_failed_compile_exits_two_with_the_output_tail() { + let directory = work("compiler-fails"); + let output = fill(directory.path(), &[]); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!(output.status.code(), Some(EXIT_ERROR)); + assert!(stderr.contains("MockBroken.compact"), "{stderr}"); + assert!(stderr.contains("type mismatch"), "{stderr}"); + assert!(!stderr.contains('\u{1b}'), "the raw pty stream leaked"); +} + +#[test] +fn a_missing_cache_entry_exits_two_and_names_the_flag() { + let directory = work("basic"); + let output = fill(directory.path(), &["--no-compile"]); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!(output.status.code(), Some(EXIT_ERROR)); + assert!(stderr.contains(".circuit-info.json"), "{stderr}"); + assert!(stderr.contains("--no-compile"), "{stderr}"); +} + +#[test] +fn a_compile_writes_the_cache_the_builder_shares() { + let directory = work("override"); + fill(directory.path(), &[]); + + let cache = std::fs::read_to_string( + directory + .path() + .join("src/utils/test/mocks/.circuit-info.json"), + ) + .expect("the cache was written"); + + assert!(cache.contains("\"generatedAt\""), "{cache}"); + assert!(cache.contains("\"MockUtilities.compact\""), "{cache}"); + assert!(cache.contains("\"rows\": 310"), "{cache}"); +} + +#[test] +fn the_summary_goes_to_stderr_and_the_changes_to_stdout() { + let directory = work("basic"); + let output = fill(directory.path(), &[]); + + assert_eq!( + String::from_utf8_lossy(&output.stderr).trim_end(), + "2 values filled in 1 files, 1 unmeasured" + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains(": fill: "), + "the changes belong on stdout" + ); +} + +#[test] +fn kept_artifacts_land_in_the_directory_the_flag_names() { + let directory = work("override"); + let artifacts = directory.path().join("artifacts"); + fill( + directory.path(), + &[ + "--artifacts", + artifacts.to_str().expect("the path is UTF-8"), + ], + ); + + assert!( + artifacts + .join("MockUtilities/compiler/contract-info.json") + .is_file() + ); +} diff --git a/crates/compact-lint/tests/fixtures/fill-basic/after/src/access/Ownable.compact b/crates/compact-lint/tests/fixtures/fill-basic/after/src/access/Ownable.compact new file mode 100644 index 0000000..ee555b2 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-basic/after/src/access/Ownable.compact @@ -0,0 +1,33 @@ +/** + * @module Ownable + * @description Test fixture. + */ +export module Ownable { + /** + * @description Returns the owner. + * + * @constraints k=7, rows=74 + */ + export circuit owner(): [] { } + + /** + * @description Transfers ownership. + * + * @constraints k=13, rows=4871 + */ + export circuit transferOwnership(): [] { } + + /** + * @description Renounces ownership. + * + * @constraints k=13, rows=4277 + */ + export circuit renounceOwnership(): [] { } + + /** + * @description Sets the first owner. + * + * @constraints k=5, rows=10 + */ + export circuit initialize(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/fill-basic/after/src/access/test/mocks/MockOwnable.compact b/crates/compact-lint/tests/fixtures/fill-basic/after/src/access/test/mocks/MockOwnable.compact new file mode 100644 index 0000000..bbb1510 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-basic/after/src/access/test/mocks/MockOwnable.compact @@ -0,0 +1,6 @@ +/** + * @module MockStub + * @description Compiled by the fake compiler, never read by it. + */ +export module MockStub { +} diff --git a/crates/compact-lint/tests/fixtures/fill-basic/before/src/access/Ownable.compact b/crates/compact-lint/tests/fixtures/fill-basic/before/src/access/Ownable.compact new file mode 100644 index 0000000..d65ad1a --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-basic/before/src/access/Ownable.compact @@ -0,0 +1,33 @@ +/** + * @module Ownable + * @description Test fixture. + */ +export module Ownable { + /** + * @description Returns the owner. + * + * @constraints k=?, rows=? + */ + export circuit owner(): [] { } + + /** + * @description Transfers ownership. + * + * @constraints k=9, rows=42 + */ + export circuit transferOwnership(): [] { } + + /** + * @description Renounces ownership. + * + * @constraints k=13, rows=4277 + */ + export circuit renounceOwnership(): [] { } + + /** + * @description Sets the first owner. + * + * @constraints k=5, rows=10 + */ + export circuit initialize(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/fill-basic/before/src/access/test/mocks/MockOwnable.compact b/crates/compact-lint/tests/fixtures/fill-basic/before/src/access/test/mocks/MockOwnable.compact new file mode 100644 index 0000000..bbb1510 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-basic/before/src/access/test/mocks/MockOwnable.compact @@ -0,0 +1,6 @@ +/** + * @module MockStub + * @description Compiled by the fake compiler, never read by it. + */ +export module MockStub { +} diff --git a/crates/compact-lint/tests/fixtures/fill-basic/compact-lint.toml b/crates/compact-lint/tests/fixtures/fill-basic/compact-lint.toml new file mode 100644 index 0000000..e5a81d8 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-basic/compact-lint.toml @@ -0,0 +1,8 @@ +version = 1 +exclude = ["**/test/mocks/**"] + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/fill-basic/expected.txt b/crates/compact-lint/tests/fixtures/fill-basic/expected.txt new file mode 100644 index 0000000..a9bdabc --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-basic/expected.txt @@ -0,0 +1,3 @@ +src/access/Ownable.compact:9:6: fill: @constraints k=?, rows=? -> k=7, rows=74 +src/access/Ownable.compact:16:6: fill: @constraints k=9, rows=42 -> k=13, rows=4871 +src/access/Ownable.compact:30:6: constraints-unmeasured: circuit `initialize` has no measurement in src/access/test/mocks/MockOwnable.compact diff --git a/crates/compact-lint/tests/fixtures/fill-compiler-fails/before/src/broken/Broken.compact b/crates/compact-lint/tests/fixtures/fill-compiler-fails/before/src/broken/Broken.compact new file mode 100644 index 0000000..ce4dfef --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-compiler-fails/before/src/broken/Broken.compact @@ -0,0 +1,12 @@ +/** + * @module Broken + * @description Test fixture. + */ +export module Broken { + /** + * @description Runs. + * + * @constraints k=?, rows=? + */ + export circuit run(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/fill-compiler-fails/before/src/broken/test/mocks/MockBroken.compact b/crates/compact-lint/tests/fixtures/fill-compiler-fails/before/src/broken/test/mocks/MockBroken.compact new file mode 100644 index 0000000..bbb1510 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-compiler-fails/before/src/broken/test/mocks/MockBroken.compact @@ -0,0 +1,6 @@ +/** + * @module MockStub + * @description Compiled by the fake compiler, never read by it. + */ +export module MockStub { +} diff --git a/crates/compact-lint/tests/fixtures/fill-compiler-fails/compact-lint.toml b/crates/compact-lint/tests/fixtures/fill-compiler-fails/compact-lint.toml new file mode 100644 index 0000000..e5a81d8 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-compiler-fails/compact-lint.toml @@ -0,0 +1,8 @@ +version = 1 +exclude = ["**/test/mocks/**"] + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/fill-fake-compact/bin/compact b/crates/compact-lint/tests/fixtures/fill-fake-compact/bin/compact new file mode 100755 index 0000000..b975fe0 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-fake-compact/bin/compact @@ -0,0 +1,70 @@ +#!/bin/sh +# Stand-in for `compact compile`, so the fill tests run where the toolchain is absent. +# It prints the progress lines the real compiler puts on its terminal and writes the +# matching contract report, for a table keyed by the source file name. + +set -e + +if [ "$1" != "compile" ]; then + echo "fake compact: unsupported command $*" >&2 + exit 64 +fi +shift + +case "$1" in + +*) shift ;; +esac + +source=$1 +output=$2 +name=$(basename "$source") + +# name k rows proof +case "$name" in + MockOwnable.compact) + table="owner 7 74 true +transferOwnership 13 4871 true +renounceOwnership 13 4277 true +initialize 5 10 false" + ;; + Vault.compact) + table="deposit 12 2048 true +withdraw 14 8192 true" + ;; + MockUtilities.compact) + table="hash 9 310 true" + ;; + MockBroken.compact) + echo "Compiling 1 circuits:" + echo "Exception in thread \"main\" java.lang.RuntimeException" + echo " Error: type mismatch at $source line 12" + echo " Error: compilation aborted" >&2 + exit 3 + ;; + *) + echo "fake compact: no table for $name" >&2 + exit 65 + ;; +esac + +count=$(echo "$table" | wc -l | tr -d ' ') +echo "Compiling $count circuits:" + +echo "$table" | while read -r circuit k rows proof; do + [ -n "$circuit" ] || continue + # The real compiler redraws the line, so the same circuit appears without rows first. + printf ' circuit "%s" (k=%s)\n' "$circuit" "$k" + printf ' circuit "%s" (k=%s, rows=%s)\n' "$circuit" "$k" "$rows" +done + +mkdir -p "$output/compiler" +{ + echo '{' + echo ' "compiler-version": "0.0.0-fake",' + echo ' "circuits": [' + echo "$table" | awk 'NF { if (n++) printf ",\n"; printf " { \"name\": \"%s\", \"pure\": false, \"proof\": %s }", $1, $4 } END { printf "\n" }' + echo ' ]' + echo '}' +} > "$output/compiler/contract-info.json" + +echo "Overall progress [====================] $count/$count" diff --git a/crates/compact-lint/tests/fixtures/fill-missing-source/after/src/orphan/Orphan.compact b/crates/compact-lint/tests/fixtures/fill-missing-source/after/src/orphan/Orphan.compact new file mode 100644 index 0000000..c2bc805 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-missing-source/after/src/orphan/Orphan.compact @@ -0,0 +1,12 @@ +/** + * @module Orphan + * @description Test fixture. + */ +export module Orphan { + /** + * @description Runs. + * + * @constraints k=?, rows=? + */ + export circuit run(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/fill-missing-source/before/src/orphan/Orphan.compact b/crates/compact-lint/tests/fixtures/fill-missing-source/before/src/orphan/Orphan.compact new file mode 100644 index 0000000..c2bc805 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-missing-source/before/src/orphan/Orphan.compact @@ -0,0 +1,12 @@ +/** + * @module Orphan + * @description Test fixture. + */ +export module Orphan { + /** + * @description Runs. + * + * @constraints k=?, rows=? + */ + export circuit run(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/fill-missing-source/compact-lint.toml b/crates/compact-lint/tests/fixtures/fill-missing-source/compact-lint.toml new file mode 100644 index 0000000..584076c --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-missing-source/compact-lint.toml @@ -0,0 +1,7 @@ +version = 1 + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/fill-missing-source/expected.txt b/crates/compact-lint/tests/fixtures/fill-missing-source/expected.txt new file mode 100644 index 0000000..1a91510 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-missing-source/expected.txt @@ -0,0 +1 @@ +src/orphan/Orphan.compact:1:1: constraints-unmeasurable: no measurement source for src/orphan/Orphan.compact; tried src/orphan/test/mocks/MockOrphan.compact, src/test/mocks/MockOrphan.compact diff --git a/crates/compact-lint/tests/fixtures/fill-no-compile/after/src/access/Ownable.compact b/crates/compact-lint/tests/fixtures/fill-no-compile/after/src/access/Ownable.compact new file mode 100644 index 0000000..73ff33b --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-no-compile/after/src/access/Ownable.compact @@ -0,0 +1,12 @@ +/** + * @module Ownable + * @description Test fixture. + */ +export module Ownable { + /** + * @description Returns the owner. + * + * @constraints k=7, rows=74 + */ + export circuit owner(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/fill-no-compile/after/src/access/test/mocks/MockOwnable.compact b/crates/compact-lint/tests/fixtures/fill-no-compile/after/src/access/test/mocks/MockOwnable.compact new file mode 100644 index 0000000..bbb1510 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-no-compile/after/src/access/test/mocks/MockOwnable.compact @@ -0,0 +1,6 @@ +/** + * @module MockStub + * @description Compiled by the fake compiler, never read by it. + */ +export module MockStub { +} diff --git a/crates/compact-lint/tests/fixtures/fill-no-compile/before/src/access/Ownable.compact b/crates/compact-lint/tests/fixtures/fill-no-compile/before/src/access/Ownable.compact new file mode 100644 index 0000000..ac40ca7 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-no-compile/before/src/access/Ownable.compact @@ -0,0 +1,12 @@ +/** + * @module Ownable + * @description Test fixture. + */ +export module Ownable { + /** + * @description Returns the owner. + * + * @constraints k=?, rows=? + */ + export circuit owner(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/fill-no-compile/before/src/access/test/mocks/.circuit-info.json b/crates/compact-lint/tests/fixtures/fill-no-compile/before/src/access/test/mocks/.circuit-info.json new file mode 100644 index 0000000..0c0fa8e --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-no-compile/before/src/access/test/mocks/.circuit-info.json @@ -0,0 +1,12 @@ +{ + "generatedAt": "2026-01-01T00:00:00.000Z", + "files": { + "MockOwnable.compact": [ + { + "name": "owner", + "k": 7, + "rows": 74 + } + ] + } +} diff --git a/crates/compact-lint/tests/fixtures/fill-no-compile/before/src/access/test/mocks/MockOwnable.compact b/crates/compact-lint/tests/fixtures/fill-no-compile/before/src/access/test/mocks/MockOwnable.compact new file mode 100644 index 0000000..bbb1510 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-no-compile/before/src/access/test/mocks/MockOwnable.compact @@ -0,0 +1,6 @@ +/** + * @module MockStub + * @description Compiled by the fake compiler, never read by it. + */ +export module MockStub { +} diff --git a/crates/compact-lint/tests/fixtures/fill-no-compile/compact-lint.toml b/crates/compact-lint/tests/fixtures/fill-no-compile/compact-lint.toml new file mode 100644 index 0000000..e5a81d8 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-no-compile/compact-lint.toml @@ -0,0 +1,8 @@ +version = 1 +exclude = ["**/test/mocks/**"] + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/fill-no-compile/expected.txt b/crates/compact-lint/tests/fixtures/fill-no-compile/expected.txt new file mode 100644 index 0000000..0e5a1e3 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-no-compile/expected.txt @@ -0,0 +1 @@ +src/access/Ownable.compact:9:6: fill: @constraints k=?, rows=? -> k=7, rows=74 diff --git a/crates/compact-lint/tests/fixtures/fill-override/after/src/utils/Utils.compact b/crates/compact-lint/tests/fixtures/fill-override/after/src/utils/Utils.compact new file mode 100644 index 0000000..5e0f523 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-override/after/src/utils/Utils.compact @@ -0,0 +1,12 @@ +/** + * @module Utils + * @description Test fixture. + */ +export module Utils { + /** + * @description Hashes a value. + * + * @constraints k=9, rows=310 + */ + export circuit hash(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/fill-override/after/src/utils/test/mocks/MockUtilities.compact b/crates/compact-lint/tests/fixtures/fill-override/after/src/utils/test/mocks/MockUtilities.compact new file mode 100644 index 0000000..bbb1510 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-override/after/src/utils/test/mocks/MockUtilities.compact @@ -0,0 +1,6 @@ +/** + * @module MockStub + * @description Compiled by the fake compiler, never read by it. + */ +export module MockStub { +} diff --git a/crates/compact-lint/tests/fixtures/fill-override/before/src/utils/Utils.compact b/crates/compact-lint/tests/fixtures/fill-override/before/src/utils/Utils.compact new file mode 100644 index 0000000..37b6b59 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-override/before/src/utils/Utils.compact @@ -0,0 +1,12 @@ +/** + * @module Utils + * @description Test fixture. + */ +export module Utils { + /** + * @description Hashes a value. + * + * @constraints k=?, rows=? + */ + export circuit hash(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/fill-override/before/src/utils/test/mocks/MockUtilities.compact b/crates/compact-lint/tests/fixtures/fill-override/before/src/utils/test/mocks/MockUtilities.compact new file mode 100644 index 0000000..bbb1510 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-override/before/src/utils/test/mocks/MockUtilities.compact @@ -0,0 +1,6 @@ +/** + * @module MockStub + * @description Compiled by the fake compiler, never read by it. + */ +export module MockStub { +} diff --git a/crates/compact-lint/tests/fixtures/fill-override/compact-lint.toml b/crates/compact-lint/tests/fixtures/fill-override/compact-lint.toml new file mode 100644 index 0000000..4f16f10 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-override/compact-lint.toml @@ -0,0 +1,11 @@ +version = 1 +exclude = ["**/test/mocks/**"] + +[constraints.overrides] +"src/utils/Utils.compact" = "src/utils/test/mocks/MockUtilities.compact" + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/fill-override/expected.txt b/crates/compact-lint/tests/fixtures/fill-override/expected.txt new file mode 100644 index 0000000..f718af9 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-override/expected.txt @@ -0,0 +1 @@ +src/utils/Utils.compact:9:6: fill: @constraints k=?, rows=? -> k=9, rows=310 diff --git a/crates/compact-lint/tests/fixtures/fill-self/after/src/multisig/presets/Vault.compact b/crates/compact-lint/tests/fixtures/fill-self/after/src/multisig/presets/Vault.compact new file mode 100644 index 0000000..cec7bfe --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-self/after/src/multisig/presets/Vault.compact @@ -0,0 +1,19 @@ +/** + * @module Vault + * @description Test fixture. + */ +export module Vault { + /** + * @description Deposits a coin. + * + * @constraints k=12, rows=2048 + */ + export circuit deposit(): [] { } + + /** + * @description Withdraws a coin. + * + * @constraints k=14, rows=8192 + */ + export circuit withdraw(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/fill-self/before/src/multisig/presets/Vault.compact b/crates/compact-lint/tests/fixtures/fill-self/before/src/multisig/presets/Vault.compact new file mode 100644 index 0000000..e020fe1 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-self/before/src/multisig/presets/Vault.compact @@ -0,0 +1,19 @@ +/** + * @module Vault + * @description Test fixture. + */ +export module Vault { + /** + * @description Deposits a coin. + * + * @constraints k=?, rows=? + */ + export circuit deposit(): [] { } + + /** + * @description Withdraws a coin. + * + * @constraints k=?, rows=? + */ + export circuit withdraw(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/fill-self/compact-lint.toml b/crates/compact-lint/tests/fixtures/fill-self/compact-lint.toml new file mode 100644 index 0000000..8545132 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-self/compact-lint.toml @@ -0,0 +1,10 @@ +version = 1 + +[constraints] +self = ["**/presets/**"] + +[kinds.module] +tags = ["@module", "@description"] + +[kinds.circuit] +tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/fill-self/expected.txt b/crates/compact-lint/tests/fixtures/fill-self/expected.txt new file mode 100644 index 0000000..88499e9 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-self/expected.txt @@ -0,0 +1,2 @@ +src/multisig/presets/Vault.compact:9:6: fill: @constraints k=?, rows=? -> k=12, rows=2048 +src/multisig/presets/Vault.compact:16:6: fill: @constraints k=?, rows=? -> k=14, rows=8192 diff --git a/crates/compact-lint/tests/fixtures/fill-transcript/pty.txt b/crates/compact-lint/tests/fixtures/fill-transcript/pty.txt new file mode 100644 index 0000000..a52a733 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-transcript/pty.txt @@ -0,0 +1,19 @@ +Compiling 7 circuits: + circuit "_transferOwnership" |  circuit "_transferOwnership" (k=10) |  circuit "_transferOwnership" (k=10, rows=598) |  circuit "_transferOwnership" (k=10, rows=598) | + circuit "_unsafeTransferOwnership" |    circuit "_transferOwnership" (k=10, rows=598) | + circuit "_unsafeTransferOwnership" (k=13) |    circuit "_transferOwnership" (k=10, rows=598) | + circuit "_unsafeTransferOwnership" (k=13, rows=4868) |    circuit "_transferOwnership" (k=10, rows=598) | + circuit "_unsafeTransferOwnership" (k=13, rows=4868) | + circuit "_unsafeUncheckedTransferOwnership" |    circuit "_unsafeUncheckedTransferOwnership" (k=10, rows=595)   + circuit "assertOnlyOwner" (k=13, rows=4273)   + circuit "owner" (k=7, rows=74)   + circuit "renounceOwnership" (k=13, rows=4277)   + circuit "transferOwnership" (k=13, rows=4871)   +Overall progress [====================] 7/7          circuit "_transferOwnership" (k=10, rows=598)   + circuit "_unsafeTransferOwnership" (k=13, rows=4868)   + circuit "_unsafeUncheckedTransferOwnership" (k=10, rows=595)   + circuit "assertOnlyOwner" (k=13, rows=4273)   + circuit "owner" (k=7, rows=74)   + circuit "renounceOwnership" (k=13, rows=4277)   + circuit "transferOwnership" (k=13, rows=4871)   +Overall progress [====================] 7/7