Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
365 changes: 365 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ rust-version = "1.96"
pedantic = { level = "warn", priority = -1 }

[workspace.dependencies]
anyhow = "1.0"
cc = "1"
clap = { version = "4.6", features = ["derive", "env"] }
globset = "0.4"
serde = { version = "1.0", features = ["derive"] }
thiserror = "2.0"
toml = "1.1"
tree-sitter = "0.25"
compact-tree-sitter = { version = "0.1.0", path = "crates/compact-tree-sitter" }
tree-sitter-language = "0.1"
walkdir = "2.5"

assert_cmd = "2.2"
33 changes: 33 additions & 0 deletions crates/compact-lint/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[package]
name = "compact-lint"
version = "0.1.0"
description = "Doc-comment linter for Compact sources"
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true

[lints]
workspace = true

[lib]
name = "compact_lint"
path = "src/lib.rs"

[[bin]]
name = "compact-lint"
path = "src/main.rs"

[dependencies]
anyhow.workspace = true
clap.workspace = true
globset.workspace = true
serde.workspace = true
thiserror.workspace = true
toml.workspace = true
tree-sitter.workspace = true
compact-tree-sitter.workspace = true
walkdir.workspace = true

[dev-dependencies]
assert_cmd.workspace = true
128 changes: 128 additions & 0 deletions crates/compact-lint/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# compact-lint

Doc-comment linter for Compact sources. It parses `.compact` files with
[`compact-tree-sitter`](../compact-tree-sitter), matches every declaration against the
per-kind template in `compact-lint.toml`, and prints one finding per line.

## Usage

```sh
compact-lint check [PATHS]... [--config <file>] [--strict] [--no-format] [--compact-bin <path>]
```

- `PATHS` — files or directories, walked recursively for `.compact`. Skipped while
walking: `node_modules`, `target`, `dist`, `build`, `.git`.
- No `PATHS` — the config's `include` globs are used instead.
- `--config <file>` — use this config instead of searching upward for
`compact-lint.toml`.
- `--strict` — turn `k=?` / `rows=?` placeholders into findings. Use it on release
branches.
- `--no-format` — skip the `compact format --check` pass. Required where the `compact`
binary is unavailable, CI included.
- `--compact-bin <path>` — path to the `compact` binary. Also settable with
`COMPACT_LINT_COMPACT_BIN`; the flag wins.

Findings go to stdout, one per line, in `path:line:col: rule: message` form (the
rustc / eslint shape, so editors and `grep` both read it). The `N findings in M files`
summary goes to stderr, where `M` is the number of files checked.

## Exit codes

- `0` — no findings.
- `1` — findings.
- `2` — usage, config, IO or parser error.

## Rules

| Rule | Meaning |
| --- | --- |
| `missing-doc` | The declaration needs docs per `kinds.<kind>.docs` and has no doc comment attached. |
| `missing-tag` | A tag listed in `kinds.<kind>.tags` is absent from the doc comment. |
| `forbidden-tag` | A tag listed in `tags.forbid` is present. |
| `module-name` | `@module <Name>` does not name the module it documents. |
| `missing-constraints` | An exported non-pure circuit has no constraints tag. |
| `constraints-format` | The constraints value is not `k=<n>, rows=<n>`. |
| `constraints-placeholder` | `--strict` only: the constraints value still holds a `?`. |
| `parse` | The file holds a syntax defect; the first one is reported and the doc rules are skipped for that file. |
| `format` | `compact format --check` reported the file. |

Rule detail:

- **Attachment.** A doc comment counts as attached only when it is the declaration's
immediately preceding *named* sibling. A `//` or `/* */` comment between the two
breaks the attachment and the declaration reads as undocumented.
- **Export.** "Exported" means the declaration carries the `export` token itself. A
non-exported circuit inside an exported module is not exported.
- **Constraints.** The rule applies to exported non-pure circuits only, and that is
fixed, not configurable. A missing doc comment reports as `missing-doc`, not
`missing-constraints`.
- **`module-name`.** The documented name is the first word of the `@module` value,
without its generic arguments or trailing punctuation, so `@module Signer<T>` matches
`module Signer` and `@module Utils.` matches `module Utils`.
- **`forbidden-tag`.** It fires on any doc comment attached to a checked declaration,
including declarations whose docs are optional.

## Config

`compact-lint.toml`, searched for upward from the current directory. Every field is
optional; unknown fields are rejected. A config found by walking upward anchors the
`include` globs at its own directory; a config named with `--config` is a shareable
preset, so the globs stay anchored at the current directory.

```toml
version = 1
include = ["contracts/src/**/*.compact"]
exclude = ["**/test/mocks/**", "**/archive/**"]

[constraints]
tag = "@constraints"

[tags]
forbid = ["@return"]

[kinds.module]
docs = "exported"
tags = ["@module", "@description"]
```

Defaults when no config file is found:

| Key | Default |
| --- | --- |
| `version` | `1`, the only version this build accepts |
| `include` | `["**/*.compact"]` |
| `exclude` | `[]` |
| `constraints.tag` | `"@constraints"` |
| `tags.forbid` | `[]` |
| `kinds.<kind>.docs` | `"exported"` |
| `kinds.<kind>.tags` | `[]` |

- `kinds` takes one table per kind: `module`, `circuit`, `ledger`, `witness`,
`constructor`, `struct`, `enum`, `contract`, `type`. `pragma`, `import`, `include`,
`export { … }` and `contract implements` are never checked.
- `docs` is `"all"`, `"exported"` or `"none"`.
- `constructor` carries no `export` token, so `"exported"` means "never" for it. Use
`"all"` or `"none"` there.
- `exclude` filters files found by walking a directory. A file named on the command
line is always checked.

`examples/compact-contracts.toml` is the config for OpenZeppelin/compact-contracts.

## Doc-comment model

- `/**`, `*/` and the leading ` * ` gutter are stripped.
- A tag line's first non-blank token is `@` followed by one or more ASCII letters.
- A tag's value is the rest of its line plus every following line until the next tag or
a blank line.
- Repeated tags are kept in source order; `@param` normally repeats.
- `@param {Type} name - text` is not parsed further.

## Tests

```sh
cargo test --workspace
```

`tests/fixtures/<case>/` holds one case per rule plus a clean case: a
`compact-lint.toml`, the `.compact` sources, and `expected.txt`, the exact stdout with
paths relative to the case directory.
40 changes: 40 additions & 0 deletions crates/compact-lint/examples/compact-contracts.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# compact-lint config for OpenZeppelin/compact-contracts.
# Copy it to the repository root as compact-lint.toml.

version = 1
include = ["contracts/src/**/*.compact"]
exclude = ["**/test/mocks/**", "**/archive/**"]

# `@constraints` is the default; set `tag` to point the rule at another annotation.
[constraints]
tag = "@constraints"

[tags]
forbid = ["@return"]

[kinds.module]
docs = "exported"
tags = ["@module", "@description"]

[kinds.circuit]
tags = ["@description"]

[kinds.ledger]
tags = ["@description"]

[kinds.witness]
docs = "all"
tags = ["@description"]

[kinds.struct]
tags = ["@description"]

[kinds.enum]
tags = ["@description"]

[kinds.constructor]
docs = "none"

[kinds.contract]

[kinds.type]
153 changes: 153 additions & 0 deletions crates/compact-lint/src/check.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//! The `check` subcommand: resolve config, collect files, run the rules.

use std::ffi::OsString;
use std::path::{Path, PathBuf};

use thiserror::Error;

use crate::config::{Config, ConfigError, discover as discover_config};
use crate::discover::{self, DiscoverError};
use crate::format::{self, FormatError};
use crate::report::Finding;
use crate::rules::{LintError, Linter};

#[derive(Debug, Error)]
pub enum CheckError {
#[error(transparent)]
Config(#[from] ConfigError),
#[error(transparent)]
Discover(#[from] DiscoverError),
#[error(transparent)]
Lint(#[from] LintError),
#[error(transparent)]
Format(#[from] FormatError),
#[error("cannot read {path}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
}

/// Everything the `check` subcommand needs, already resolved from flags and env.
pub struct Options {
pub paths: Vec<PathBuf>,
pub config_path: Option<PathBuf>,
pub strict: bool,
pub no_format: bool,
pub compact_bin: OsString,
}

/// The findings, plus how many files produced them.
pub struct Outcome {
pub findings: Vec<Finding>,
pub files_checked: usize,
}

impl Outcome {
#[must_use]
pub fn summary(&self) -> String {
format!(
"{} findings in {} files",
self.findings.len(),
self.files_checked
)
}
}

/// Runs the subcommand with `cwd` as the working directory.
/// # Errors
/// Returns an error when the config, the file walk, the parser or the formatter fails.
pub fn run(options: &Options, cwd: &Path) -> Result<Outcome, CheckError> {
let (config, config_path, base) = resolve_config(options, cwd)?;
let source = config_path.as_deref().unwrap_or(Path::new("<defaults>"));

let exclude = config.exclude_set(source)?;
let files = if options.paths.is_empty() {
let include = config.include_set(source)?;
discover::from_globs(&base, &include, &exclude)?
} else {
let paths = absolute_paths(&options.paths, cwd);
discover::from_paths(&paths, &base, &exclude)?
};
let files: Vec<PathBuf> = files.iter().map(|path| display_path(path, cwd)).collect();

let mut linter = Linter::new()?;
let mut findings = Vec::new();
for path in &files {
let text = std::fs::read_to_string(path).map_err(|source| CheckError::Read {
path: path.clone(),
source,
})?;
findings.extend(linter.check(path, &text, &config, options.strict)?);
}

if !options.no_format {
findings.extend(format::check(&options.compact_bin, &files)?);
}

findings.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
Ok(Outcome {
findings,
files_checked: files.len(),
})
}

/// An explicit `--config` is a shareable preset, so glob discovery stays anchored at
/// `cwd`; a config found by walking upward anchors discovery at its own directory.
fn resolve_config(
options: &Options,
cwd: &Path,
) -> Result<(Config, Option<PathBuf>, PathBuf), ConfigError> {
if let Some(path) = options.config_path.as_deref() {
return Ok((Config::load(path)?, Some(path.to_owned()), cwd.to_owned()));
}

match discover_config(cwd) {
Some(path) => {
let base = path.parent().unwrap_or(cwd).to_owned();
Ok((Config::load(&path)?, Some(path), base))
}
None => Ok((Config::default(), None, cwd.to_owned())),
}
}

/// Discovery matches the exclude globs against paths relative to the config's
/// directory, so a CLI path has to be anchored at `cwd` first: a bare `archive`
/// given from a subdirectory would otherwise never match `contracts/archive/**`.
fn absolute_paths(paths: &[PathBuf], cwd: &Path) -> Vec<PathBuf> {
paths
.iter()
.map(|path| {
if path.is_absolute() {
path.clone()
} else {
cwd.join(path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

./dir and ../dir paths bypass exclude.

cwd.join(path) keeps the . and .. components. After strip_prefix(base), the path is contracts/./archive/Old.compact or contracts/live/../archive/Old.compact, and contracts/archive/** does not match it.

Reproduce with the exclude-explicit-dir fixture:

  • From contracts/, check --no-format archive gives 0 findings in 0 files (correct).
  • From contracts/, check --no-format ./archive gives archive/Old.compact:6:3: missing-doc and exit 1.
  • From contracts/live/, check --no-format ../archive gives ../archive/Old.compact:6:3: missing-doc and exit 1.

The same cause makes Normal.compact and contracts/../Normal.compact count as two files.

Fix: normalize each path after the join (remove ., resolve ..), or canonicalize base and each path. Add the ./archive and ../archive forms to the existing test.

}
})
.collect()
}

/// Paths under the working directory print relative to it; anything else prints as is.
fn display_path(path: &Path, cwd: &Path) -> PathBuf {
path.strip_prefix(cwd).unwrap_or(path).to_owned()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn a_relative_path_is_anchored_at_the_working_directory() {
let resolved = absolute_paths(&[PathBuf::from("archive")], Path::new("/repo/contracts"));

assert_eq!(resolved, vec![PathBuf::from("/repo/contracts/archive")]);
}

#[test]
fn an_absolute_path_is_left_alone() {
let resolved = absolute_paths(&[PathBuf::from("/elsewhere/X.compact")], Path::new("/repo"));

assert_eq!(resolved, vec![PathBuf::from("/elsewhere/X.compact")]);
}
}
Loading
Loading