diff --git a/.gitignore b/.gitignore index 48f64fe..569a4a2 100644 --- a/.gitignore +++ b/.gitignore @@ -33,11 +33,8 @@ managed/ # never gets committed — including under examples/, where the walkthrough # expects you to compile the contract yourself before deploying. 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 +# Except the seeded artifacts the fill-constraints tests read. +!crates/compact-lint/tests/fixtures/**/artifacts/ midnight-level-db compactc diff --git a/Cargo.lock b/Cargo.lock index 778d546..4aaaacd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,6 +177,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" name = "compact-lint" version = "0.1.0" dependencies = [ + "anstyle", "anyhow", "assert_cmd", "clap", @@ -185,6 +186,7 @@ dependencies = [ "portable-pty", "serde", "serde_json", + "similar", "tempfile", "thiserror 2.0.20", "toml", @@ -573,6 +575,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "streaming-iterator" version = "0.1.9" diff --git a/Cargo.toml b/Cargo.toml index a235a01..45eaeb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ codegen-units = 1 pedantic = { level = "warn", priority = -1 } [workspace.dependencies] +anstyle = "1.0" anyhow = "1.0" cc = "1" clap = { version = "4.6", features = ["derive", "env"] } @@ -26,6 +27,7 @@ globset = "0.4" portable-pty = "0.9" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +similar = "2.7" thiserror = "2.0" toml = "1.1" tree-sitter = "0.25" diff --git a/crates/compact-lint/Cargo.toml b/crates/compact-lint/Cargo.toml index 014ed0b..c7a65b4 100644 --- a/crates/compact-lint/Cargo.toml +++ b/crates/compact-lint/Cargo.toml @@ -19,12 +19,14 @@ name = "compact-lint" path = "src/main.rs" [dependencies] +anstyle.workspace = true anyhow.workspace = true clap.workspace = true globset.workspace = true portable-pty.workspace = true serde.workspace = true serde_json.workspace = true +similar.workspace = true tempfile.workspace = true thiserror.workspace = true toml.workspace = true diff --git a/crates/compact-lint/README.md b/crates/compact-lint/README.md index b99e898..71fa305 100644 --- a/crates/compact-lint/README.md +++ b/crates/compact-lint/README.md @@ -2,7 +2,7 @@ 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` +per-kind template in `compact.toml`, and prints one diagnostic per violation. `fix` rewrites the comments the repairable rules report. `fill-constraints` compiles the contracts and writes the measured `k` and `rows` into the annotations. @@ -16,31 +16,131 @@ 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 ] +compact-lint check [PATHS]... [--config ] [--strict] [--no-format] [--compact-bin ] [OUTPUT] +compact-lint fix [PATHS]... [--config ] [--dry-run] [OUTPUT] +compact-lint fill-constraints [PATHS]... [--config ] [--dry-run] [--no-compile] [--compact-bin ] [--artifacts ] [OUTPUT] ``` - `PATHS` — files or directories, walked recursively for `.compact`. Skipped while walking: `node_modules`, `target`, `dist`, `build`, `.git`. - No `PATHS` — the config's `include` globs are used instead. - `--config ` — use this config instead of searching upward for - `compact-lint.toml`. -- `--strict` — turn `k=?` / `rows=?` placeholders into findings. Use it on release - branches. + `compact.toml`. +- `--strict` — report `k=?` / `rows=?` placeholders as errors instead of warnings. Use + it on release branches. - `--no-format` — skip the `compact format --check` pass. Required where the `compact` - binary is unavailable, CI included. + binary is unavailable, CI included. Same effect as `format = "off"` in `[lint.rules]`. - `--compact-bin ` — path to the `compact` binary. Also settable with `COMPACT_LINT_COMPACT_BIN`; the flag wins. -Findings go to stdout, one per line, in `path:line:col: rule: message` form (the -rustc / eslint shape, so editors and `grep` both read it). The `N findings in M files` -summary goes to stderr, where `M` is the number of files checked. +`OUTPUT` is the same set of flags on all three subcommands: + +- `--reporter ` — output format, default `default`. +- `--diagnostic-level ` — lowest level shown, default `info`. The + summary still counts what it hides. +- `--error-on-warnings` — exit `1` on a run that only warned. +- `--max-diagnostics ` — diagnostics shown before the rest are only counted, + default `20`. +- `--colors ` — default: colour when stdout is a TTY and `NO_COLOR` is unset. +- `--timings` — print a per-phase wall-clock breakdown under the summary. + +## Output + +- Diagnostics go to stdout; the truncation notice and the summary go to stderr. +- `default` — Biome-style block per diagnostic: header, message, code frame, `i` advice, + and the fix as a line diff where one exists. +- `concise` — ` path:line:col: : `, one line per diagnostic. +- `github` — `::error|warning|notice title=…,file=…,line=…::` workflow commands. +- Glyphs: `×` error, `!` warning, `i` info. +- Rule names read `lint/`, except `parse`, `format` and `fill`, which are bare. +- `FIXABLE` in a header means `fix` repairs it; `FIXED` means this run already did. +- The summary reads `Checked N files in .` plus what the run did, then + `Found N errors.` and `Found N warnings.` lines when either is non-zero. +- A clean run prints only the summary line. + +```text +Gaps.compact:6:3 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Ledger `_owner` has no doc comment. + + 4 │ */ + 5 │ export module Gaps { + > 6 │ export ledger _owner: Bytes<32>; + │ ^^^^^^^^^^^^^^^^^^^^ + 7 │ + 8 │ export enum Kind { + + i Add a doc comment above it, or run compact-lint fix. + + i Unsafe fix: Insert a doc skeleton for ledger `_owner` + + 6 │ + ··/** + 7 │ + ···*·@description·TODO + 8 │ + ···*/ +``` + +with this on stderr: + +```text +Checked 1 file in 2ms. No fixes applied. +Found 3 errors. +``` + +## Performance + +- The lint pass is tens of milliseconds for a repo of a few dozen files. +- `compact format --check` dominates a full run; the cost is the compiler's, not the + linter's. +- `--timings` prints the breakdown that shows it, longest phase first. +- `--no-format`, or `format = "off"` in `[lint.rules]`, drops that phase. + +```text +Timings + format check 2.7s 98.4% compact format --check, 39 files, 1 process + fix previews 30ms 1.1% 481 previews + parse and rules 9ms 0.4% 39 files, 516 issues + config and walk 1ms 0.0% 39 files matched + render 518µs 0.0% 20 diagnostics shown + total 2.7s +``` + +- The total is the sum of the phases, not the run's wall clock. +- `fix` reports `edits` and `write`, `dry run` for a preview; `fill-constraints` reports + one `compile` row per source, named in the detail column. + +## Diagnostic levels + +- Every rule reports at a level: `error`, `warn`, `info`, or `off`. +- `off` stops the rule running; its findings never reach the report or the counts. +- Set them in `[lint.rules]`, keyed by rule id: + +```toml +[lint.rules] +missing-doc = "error" +constraints-placeholder = "warn" +format = "off" +``` + +Defaults: + +| Rule | Level | +| --- | --- | +| `missing-doc`, `missing-tag`, `forbidden-tag`, `module-name` | `error` | +| `tag-order` | `error` | +| `missing-constraints`, `constraints-format` | `error` | +| `parse`, `format` | `error` | +| `unknown-section` | `warn` | +| `constraints-placeholder` | `warn` | +| `constraints-unmeasured`, `constraints-unmeasurable` | `warn` | + +- `--strict` promotes `constraints-placeholder` to `error` for that run; a rule set to + `off` stays off. +- An unknown key in `[lint.rules]` is a config error. ## Exit codes -- `0` — no findings. -- `1` — findings. +- `0` — no error-level diagnostics. +- `1` — at least one error-level diagnostic, or a warning with `--error-on-warnings`. - `2` — usage, config, IO or parser error. ## Fix @@ -56,8 +156,15 @@ compact-lint fix [PATHS]... [--config ] [--dry-run] - `--dry-run` — report the edits and write nothing. Exit `1` when there are edits, so CI gates on it the way it gates on `cargo fmt --check`. -Edits go to stdout, one per line, in `path:line:col: fix: ` form. The -`N edits in M files` summary goes to stderr, where `M` is the number of files changed. +- One `info` diagnostic per edit goes to stdout, with the rewrite as a line diff. +- An offered fix reads `Safe fix:` when it writes a value the tool knows (`forbidden-tag` + renames, `module-name`) and `Unsafe fix:` when it writes a `TODO` or `k=?, rows=?` + placeholder a human still has to replace (`missing-doc`, `missing-tag`, + `missing-constraints`). `fill-constraints` writes measured values, so its fixes are + safe. +- A write run heads each with `FIXED`; `--dry-run` heads each with `FIXABLE` and keeps + the code frame of the unchanged file. +- The summary reads `Fixed N files.` for a write run, `No fixes applied.` for a preview. Exit codes: @@ -69,14 +176,17 @@ What each rule's fix does: | Rule | Fix | | --- | --- | -| `missing-doc` | Inserts a doc skeleton above the declaration, indented to it. Body lines come from `kinds..tags` in config order; `@module` takes the declaration's name, every other tag takes `fix.placeholder`. Exported non-pure circuits also get the constraints line. A kind with neither gets a single placeholder line. | -| `missing-tag` | Inserts each missing tag after the block of the last tag that precedes it in `kinds..tags`, else at the top of the body. A missing `@description` adopts untagged prose that opens the body instead of writing a placeholder. A `/** … */` one-liner expands to the multi-line form first. | +| `missing-doc` | Inserts a doc skeleton above the declaration, indented to it. Body lines come from `kinds..tags` in config order; `@module` takes the declaration's name, a headed entry becomes a `@tag Heading:` line with `fix.placeholder` under it, every other tag takes `fix.placeholder`. Exported non-pure circuits also get the constraints line. A kind with neither gets a single placeholder line. | +| `missing-tag` | Inserts each missing entry after the block of the last entry that precedes it in the template order, optional ones included, else at the top of the body. A headed entry goes in as a two-line section block; the predecessor's block is found by heading, not by bare tag. A missing bare `@description` adopts untagged prose that opens the body instead of writing a placeholder. A `/** … */` one-liner expands to the multi-line form first. | +| `tag-order` | Splits the comment body into one block per tag, sorts the blocks matching a listed entry into config order, and writes them back into the same slots. Prose before the first tag, unlisted blocks and the blank lines separating slots stay put, so a second run reports nothing. | | `missing-constraints` | Inserts ` k=?, rows=?` after the `@description` block, with a blank ` *` line before it and, where prose follows, one after. Without a `@description` it opens the body. | | `forbidden-tag` | Renames the tag in place, and only where `tags.rename` maps it. | | `module-name` | Replaces the first word of the `@module` value with the module's name, keeping what follows. | Not fixed: +- `unknown-section` — the heading is either a section the config should list or prose to + fold into one, and only a human picks. - `constraints-format` — a human wrote a measurement the rule cannot parse; guessing at it would lose the value. - `constraints-placeholder` — filling `k=?` needs a real measurement. @@ -84,7 +194,8 @@ Not fixed: - `format` — that is `compact format`'s job. - A forbidden tag with no `tags.rename` entry stays a `check` finding. -Existing lines are never reordered or reflowed. Edits are computed against the original +Lines are never reflowed, and only `tag-order` moves them, whole blocks at a time. Edits +are computed against the original text and applied from the highest offset down; several edits on one comment merge into a 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. @@ -101,28 +212,35 @@ compact-lint fill-constraints [PATHS]... [--config ] [--dry-run] [--no-com ``` - `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. +- `--dry-run` — report the changes and write nothing, the artifact file included. +- `--no-compile` — read each contract's `circuit-info.json` instead of compiling. A source + with no artifact file 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. +- `--artifacts ` — the artifacts tree to read and write, overriding + `constraints.artifacts`. 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: +Each filled value is an `info` diagnostic carrying the annotation's before and after: ```text -contracts/src/access/Ownable.compact:59:6: fill: @constraints k=?, rows=? -> k=13, rows=4273 +contracts/src/access/Ownable.compact:59:6 fill FIXED ━━━━━━━━━━ + + i Circuit `transferOwnership` measures k=13, rows=4273. + + i Applied fix: Set @constraints to k=13, rows=4273 + + 59 │ - ···*·@constraints·k=?,·rows=? + 59 │ + ···*·@constraints·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. +The `Filled N values in M files.` summary goes to stderr, with `Found N warnings.` under +it. `M` counts the files that changed; the warnings are the tagged circuits left without +a value, including the ones in a file with no measurement source. ### How a source is resolved @@ -136,23 +254,30 @@ mock contract that exports the same circuit names. For each file, in order: Each distinct source is compiled once per run, whatever the number of files it measures. -### The cache +### The artifact file -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: +Each compiled contract carries its measurements in `circuit-info.json`, beside the +compiler's own `compiler/contract-info.json` in its artifact directory. 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 }] - } + "source": "contracts/src/access/test/mocks/MockOwnable.compact", + "circuits": [{ "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. +- `constraints.artifacts` names the tree, relative to the `compact.toml` directory, and + defaults to `artifacts`. `--artifacts ` overrides it. +- A compile writes `//circuit-info.json`, replacing what was there, with + `source` relative to the `compact.toml` directory. +- `--no-compile` reads `//circuit-info.json`, and where that is absent + searches the tree for one `/circuit-info.json`, which is where a hierarchical + build puts it. Two directories of one stem is an error. +- In compact-contracts a full ZK `yarn compile` writes the file `--no-compile` reads, so + the linter never recompiles what the builder already measured. ### Unmeasured @@ -160,23 +285,17 @@ A tagged circuit is *unmeasured* when its source compiled but produced no measur 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: +value is left alone and the circuit is reported as `constraints-unmeasured`, at `warn` +by default, with the source that measures it named in the advice. -```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 -``` +A file with no measurement source at all is reported once as `constraints-unmeasurable`, +with the candidates tried named in the advice. 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 +- `2` — usage, config, IO, compiler or missing-artifact 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. @@ -188,12 +307,16 @@ the line ending, and only the annotation's own line changes. | Rule | Meaning | | --- | --- | | `missing-doc` | The declaration needs docs per `kinds..docs` and has no doc comment attached. | -| `missing-tag` | A tag listed in `kinds..tags` is absent from the doc comment. | +| `missing-tag` | An entry listed in `kinds..tags` is absent from the doc comment. | +| `unknown-section` | A section heading neither `kinds..tags` nor `kinds..sections` lists for that tag. | +| `tag-order` | Listed entries appear in an order the template order does not have. | | `forbidden-tag` | A tag listed in `tags.forbid` is present. | | `module-name` | `@module ` does not name the module it documents. | | `missing-constraints` | An exported non-pure circuit has no constraints tag. | | `constraints-format` | The constraints value is not `k=, rows=`. | -| `constraints-placeholder` | `--strict` only: the constraints value still holds a `?`. | +| `constraints-placeholder` | The constraints value still holds a `?`. | +| `constraints-unmeasured` | `fill-constraints` only: the source compiled but measured no circuit of that name. | +| `constraints-unmeasurable` | `fill-constraints` only: no measurement source resolves for the file. | | `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. | @@ -212,45 +335,62 @@ Rule detail: `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. +- **`unknown-section`.** Gated per tag: a kind that lists no headed entry for `@description` + never gets the rule on `@description`. It is not fixable. +- **`tag-order`.** The order is `kinds..sections` where the kind has one, else + `kinds..tags`. Occurrences of tags no entry lists are ignored, and one doc comment + yields one finding, at the first occurrence that breaks the order. ## 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. +`compact.toml`, searched for upward from the current directory. The linter reads its +`[lint]` table; every key below is a key of that table. + +- The file is shared with [`compact-deploy`](../../packages/deployer), which owns + `[profile]`, `[networks.*]`, `[wallet]` and `[contracts.*]` in the same file. +- Tables the linter does not own are ignored, so a deployer-only key is never a lint + error. +- Every field under `[lint]` is optional; an unknown one is rejected. +- A `compact.toml` with no `[lint]` table is a config error, exit `2`. No `compact.toml` + anywhere up the tree is not: the run uses the built-in defaults. +- 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 +[lint] include = ["contracts/src/**/*.compact"] exclude = ["**/test/mocks/**", "**/archive/**"] -[constraints] +[lint.constraints] tag = "@constraints" compiler = "0.34.0" sources = ["{dir}/test/mocks/Mock{stem}.compact", "{parent}/test/mocks/Mock{stem}.compact"] self = ["**/presets/**"] -[constraints.overrides] +[lint.constraints.overrides] "contracts/src/utils/Utils.compact" = "contracts/src/utils/test/mocks/MockUtils.compact" -[tags] +[lint.tags] forbid = ["@return"] rename = { "@return" = "@returns" } -[fix] +[lint.fix] placeholder = "TODO" -[kinds.module] +[lint.rules] +constraints-placeholder = "warn" + +[lint.kinds.module] docs = "exported" -tags = ["@module", "@description"] +tags = ["@module", "@description", "@notice Privacy", "@notice Security"] +sections = ["@module", "@description", "@dev Notation", "@notice Privacy", "@notice Security"] ``` Defaults when no config file is found: | Key | Default | | --- | --- | -| `version` | `1`, the only version this build accepts | | `include` | `["**/*.compact"]` | | `exclude` | `[]` | | `constraints.tag` | `"@constraints"` | @@ -261,13 +401,29 @@ Defaults when no config file is found: | `tags.forbid` | `[]` | | `tags.rename` | `{}` | | `fix.placeholder` | `"TODO"` | +| `rules.` | `"error"`, except the four listed under Diagnostic levels | | `kinds..docs` | `"exported"` | | `kinds..tags` | `[]` | +| `kinds..sections` | `[]` | - `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"`. +- A `tags` or `sections` entry is `"@tag"` or `"@tag Heading"`, split at the first space. +- `tags` is what every doc comment of the kind must carry. +- `sections`, when non-empty, is the kind's complete template: every allowed section, in + the order a header writes them, required and optional alike. +- A required entry must also appear in `sections`; a `tags` entry missing from a non-empty + `sections` is a config error. +- `tag-order` and the `fix` insert point read `sections` where the kind has one, else + `tags`. +- A headed entry matches the occurrence of its tag whose value opens `Heading:`, matched + exactly and case-sensitively; what follows the colon is free. A bare entry matches any + occurrence of its tag. +- A heading is non-empty, carries no `:`, and appears once per list. +- The linter ships no section list of its own; a kind with no headed entry never reports + `unknown-section` or orders sections. - `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 @@ -277,13 +433,14 @@ 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. +- `rules` takes one key per rule id, valued `"off"`, `"info"`, `"warn"` or `"error"`. - `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. +`examples/compact.toml` is the config for OpenZeppelin/compact-contracts. ## Doc-comment model @@ -292,6 +449,8 @@ Defaults when no config file is found: - 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. +- A value whose first line reads `Heading:` opens a section; the heading is letters, + digits, spaces and hyphens, and anything else reads as prose. - `@param {Type} name - text` is not parsed further. ## Tests @@ -301,14 +460,18 @@ cargo test --workspace ``` `tests/fixtures//` holds one `check` 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. - -`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 +`compact.toml`, the `.compact` sources, and `expected.txt`, the exact stdout with +paths relative to the case directory. A case that also runs under a flag carries the +second expectation beside it, such as `expected-strict.txt`. The `clean` case carries +the deployer's tables too, and `no-lint-table` carries nothing else. + +`tests/fixtures/fix-/` holds one `fix` case per rule: a `compact.toml`, +`before/`, `after/`, `expected.txt` for the write run and `expected-dry-run.txt` for the +preview. 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, plus +`expected-settled.txt` for what a second run still reports. 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 diff --git a/crates/compact-lint/examples/compact-contracts.toml b/crates/compact-lint/examples/compact-contracts.toml deleted file mode 100644 index ef9489e..0000000 --- a/crates/compact-lint/examples/compact-contracts.toml +++ /dev/null @@ -1,44 +0,0 @@ -# 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" -# 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"] -rename = { "@return" = "@returns" } - -[kinds.module] -docs = "exported" -tags = ["@module", "@description"] - -[kinds.circuit] -tags = ["@description"] - -[kinds.ledger] -tags = ["@description"] - -[kinds.witness] -docs = "all" -tags = ["@description"] - -[kinds.struct] -tags = ["@description"] - -[kinds.enum] -tags = ["@description"] - -[kinds.constructor] -docs = "none" - -[kinds.contract] - -[kinds.type] diff --git a/crates/compact-lint/examples/compact.toml b/crates/compact-lint/examples/compact.toml new file mode 100644 index 0000000..8444234 --- /dev/null +++ b/crates/compact-lint/examples/compact.toml @@ -0,0 +1,97 @@ +# compact-lint config for OpenZeppelin/compact-contracts. +# Copy it to the repository root as compact.toml. +# +# One project, one config file. compact-deploy's `[profile]`, `[networks.*]`, +# `[wallet]` and `[contracts.*]` tables live in this same file and the linter +# ignores them; see examples/fungible-token/compact.toml for their shape. + +[lint] +include = ["contracts/src/**/*.compact"] +exclude = ["**/test/mocks/**", "**/archive/**"] + +# `@constraints` is the default; set `tag` to point the rule at another annotation. +[lint.constraints] +tag = "@constraints" +# Where the builder and fill-constraints write `/circuit-info.json`, relative to +# this file. `--artifacts ` overrides it. +artifacts = "contracts/artifacts" +# 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/**"] + +[lint.tags] +forbid = ["@return"] +rename = { "@return" = "@returns" } + +# Every rule's default level, spelled out. Set one to "off" to stop it running, or to +# "warn" / "info" to keep it out of the exit code. `check --strict` promotes +# constraints-placeholder to "error" for that run. +# [lint.rules] +# missing-doc = "error" +# missing-tag = "error" +# unknown-section = "warn" +# tag-order = "error" +# forbidden-tag = "error" +# module-name = "error" +# missing-constraints = "error" +# constraints-format = "error" +# constraints-placeholder = "warn" +# constraints-unmeasured = "warn" +# constraints-unmeasurable = "warn" +# parse = "error" +# format = "error" + +# The module header template. `tags` is what every header must carry, `sections` is every +# allowed section in template order, and a required entry also appears in `sections`. Both +# lists are a starting point: moving an entry into `tags` makes it required, and a heading +# `sections` does not name is reported as unknown-section. +[lint.kinds.module] +docs = "all" +tags = [ + "@module", + "@description", + "@notice Privacy", + "@notice Security", + "@notice Concurrency", + "@notice Scope", + "@dev Performance", +] +sections = [ + "@module", + "@description", + "@dev Notation", + "@dev Derivations", + "@dev Parameterization", + "@dev Compatibility", + "@notice Privacy", + "@notice Security", + "@notice Concurrency", + "@notice Scope", + "@notice Client-side duties", + "@notice Scalability", + "@dev Performance", + "@dev Composition", +] + +[lint.kinds.circuit] +tags = ["@description"] + +[lint.kinds.ledger] +tags = ["@description"] + +[lint.kinds.witness] +docs = "all" +tags = ["@description"] + +[lint.kinds.struct] +tags = ["@description"] + +[lint.kinds.enum] +tags = ["@description"] + +[lint.kinds.constructor] +docs = "none" + +[lint.kinds.contract] + +[lint.kinds.type] diff --git a/crates/compact-lint/src/check.rs b/crates/compact-lint/src/check.rs index 7fbc195..ad222f9 100644 --- a/crates/compact-lint/src/check.rs +++ b/crates/compact-lint/src/check.rs @@ -2,13 +2,19 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use thiserror::Error; +use crate::config::Config; +use crate::diagnostic::{Badge, Diagnostic, FixPreview, Level}; +use crate::edit::{apply, newline_of}; +use crate::fix; use crate::format::{self, FormatError}; -use crate::report::Finding; +use crate::report::RuleId; use crate::rules::{LintError, Linter}; use crate::target::{self, TargetError}; +use crate::timing::{Phase, Timings}; #[derive(Debug, Error)] pub enum CheckError { @@ -35,46 +41,142 @@ pub struct Options { pub compact_bin: OsString, } -/// The findings, plus how many files produced them. +/// The diagnostics, plus how many files produced them. pub struct Outcome { - pub findings: Vec, + pub diagnostics: Vec, pub files_checked: usize, } -impl Outcome { - #[must_use] - pub fn summary(&self) -> String { - format!( - "{} findings in {} files", - self.findings.len(), - self.files_checked - ) - } -} - /// Runs the subcommand with `cwd` as the working directory. /// # Errors /// Returns an error when the config, the file walk, the parser or the formatter fails. -pub fn run(options: &Options, cwd: &Path) -> Result { +pub fn run(options: &Options, cwd: &Path, timings: &mut Timings) -> Result { + let walk = Phase::start("config and walk"); let target = target::resolve(&options.paths, options.config_path.as_deref(), cwd)?; + walk.stop(timings, format!("{} files matched", target.files.len())); let mut linter = Linter::new()?; - let mut findings = Vec::new(); + let mut diagnostics = Vec::new(); + let mut parsing = Duration::ZERO; + let mut previewing = Duration::ZERO; + let mut issue_count = 0; + let mut preview_count = 0; + for path in &target.files { + let started = Instant::now(); let text = std::fs::read_to_string(path).map_err(|source| CheckError::Read { path: path.clone(), source, })?; - findings.extend(linter.check(path, &text, &target.config, options.strict)?); + let newline = newline_of(&text); + let issues = linter.issues(path, &text, &target.config)?; + parsing += started.elapsed(); + issue_count += issues.len(); + + for issue in issues { + let level = level_of(issue.rule(), &target.config, options.strict); + let diagnostic = issue.diagnostic(path, level, &target.config); + + let started = Instant::now(); + let preview = fix::edit_for(&issue, &target.config, newline).map(|edit| { + let after = apply(&text, std::slice::from_ref(&edit), newline); + FixPreview::between(fix::title(&edit, Badge::Fixable), &text, &after) + }); + previewing += started.elapsed(); + + diagnostics.push(match preview { + Some(preview) => { + preview_count += 1; + diagnostic.fixed_by(preview, Badge::Fixable) + } + None => diagnostic, + }); + } } - if !options.no_format { - findings.extend(format::check(&options.compact_bin, &target.files)?); + timings.record( + "parse and rules", + parsing, + format!("{} files, {issue_count} issues", target.files.len()), + ); + timings.record( + "fix previews", + previewing, + format!("{preview_count} previews"), + ); + + let format_level = target.config.rules.get(RuleId::FORMAT); + if options.no_format || format_level == Level::Off { + timings.record("format check", Duration::ZERO, skipped(options.no_format)); + } else { + let phase = Phase::start("format check"); + let found = format::check(&options.compact_bin, &target.files, format_level)?; + phase.stop( + timings, + format!( + "compact format --check, {} files, 1 process", + target.files.len() + ), + ); + diagnostics.extend(found); } - findings.sort_by(|left, right| left.sort_key().cmp(&right.sort_key())); + diagnostics.sort_by(|left, right| left.sort_key().cmp(&right.sort_key())); Ok(Outcome { - findings, + diagnostics, files_checked: target.files.len(), }) } + +/// Why the format pass did not run, for its timing row. +fn skipped(no_format: bool) -> &'static str { + if no_format { + "skipped by --no-format" + } else { + "skipped by format = \"off\"" + } +} + +/// `--strict` promotes a placeholder to an error; a rule turned off stays off. +fn level_of(rule: RuleId, config: &Config, strict: bool) -> Level { + let level = config.rules.get(rule); + if strict && rule == RuleId::CONSTRAINTS_PLACEHOLDER && level != Level::Off { + Level::Error + } else { + level + } +} + +#[cfg(test)] +mod tests { + use super::level_of; + use crate::config::Config; + use crate::diagnostic::Level; + use crate::report::RuleId; + + #[test] + fn strict_promotes_a_placeholder_and_leaves_the_other_rules_alone() { + let config = Config::default(); + + assert_eq!( + level_of(RuleId::CONSTRAINTS_PLACEHOLDER, &config, false), + Level::Warn + ); + assert_eq!( + level_of(RuleId::CONSTRAINTS_PLACEHOLDER, &config, true), + Level::Error + ); + assert_eq!(level_of(RuleId::MISSING_DOC, &config, true), Level::Error); + } + + #[test] + fn strict_does_not_revive_a_placeholder_rule_turned_off() { + let mut config = Config::default(); + config.rules.constraints_placeholder = Level::Off; + + assert_eq!( + level_of(RuleId::CONSTRAINTS_PLACEHOLDER, &config, true), + Level::Off + ); + } +} diff --git a/crates/compact-lint/src/config.rs b/crates/compact-lint/src/config.rs index 35b02b6..5890037 100644 --- a/crates/compact-lint/src/config.rs +++ b/crates/compact-lint/src/config.rs @@ -1,4 +1,7 @@ -//! `compact-lint.toml`: discovery, parsing, and the built-in defaults. +//! `compact.toml`: discovery, parsing, and the built-in defaults. +//! +//! The file is shared with `compact-deploy`. The linter reads its `[lint]` table and +//! ignores every other one. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -7,14 +10,13 @@ use globset::{Glob, GlobSet, GlobSetBuilder}; use serde::Deserialize; use thiserror::Error; -use crate::doc::Tag; +use crate::diagnostic::Level; +use crate::doc::{Tag, TagSpec}; use crate::model::DeclKind; +use crate::report::RuleId; /// The config file name searched for upward from the current directory. -pub const CONFIG_FILE_NAME: &str = "compact-lint.toml"; - -/// The only schema version this build understands. -pub const SUPPORTED_VERSION: u32 = 1; +pub const CONFIG_FILE_NAME: &str = "compact.toml"; #[derive(Debug, Error)] pub enum ConfigError { @@ -30,14 +32,32 @@ pub enum ConfigError { #[source] source: toml::de::Error, }, - #[error( - "config {path} is version {found}, but this build understands version {SUPPORTED_VERSION}" - )] - Version { path: PathBuf, found: u32 }, + #[error("{CONFIG_FILE_NAME} at {path} has no [lint] table")] + MissingLintTable { path: PathBuf }, #[error( "config {path} lists tag {tag:?}; tags are spelled with their `@`, like `@description`" )] Tag { path: PathBuf, tag: String }, + #[error("config {path}: kinds.{kind}.tags lists {entry}, but kinds.{kind}.sections does not")] + NotInSections { + path: PathBuf, + kind: &'static str, + entry: String, + }, + #[error( + "config {path} lists {entry:?} under kinds.{kind}; a heading is non-empty and carries no `:`" + )] + Heading { + path: PathBuf, + kind: &'static str, + entry: String, + }, + #[error("config {path} lists {entry:?} twice under kinds.{kind}")] + DuplicateEntry { + path: PathBuf, + kind: &'static str, + entry: String, + }, #[error("config {path} renames {tag}, which tags.forbid does not list")] RenameUnforbidden { path: PathBuf, tag: String }, #[error("config {path} renames {from} to {to}, which tags.forbid also lists")] @@ -79,7 +99,21 @@ pub enum DocsPolicy { #[serde(deny_unknown_fields, default)] pub struct KindConfig { pub docs: DocsPolicy, - pub tags: Vec, + /// Entries every doc comment of the kind carries; each one also appears in `sections`. + pub tags: Vec, + /// Every section the template allows, in template order; empty leaves `tags` the order. + pub sections: Vec, +} + +impl KindConfig { + /// The ordered template: `sections` where the kind has one, else the required entries. + pub fn entries(&self) -> impl Iterator { + if self.sections.is_empty() { + self.tags.iter() + } else { + self.sections.iter() + } + } } /// One table per checked declaration kind. @@ -101,6 +135,22 @@ pub struct KindsConfig { } impl KindsConfig { + /// Every kind table, keyed by the name its config section uses. + #[must_use] + pub fn all(&self) -> [(&'static str, &KindConfig); 9] { + [ + ("module", &self.module), + ("circuit", &self.circuit), + ("ledger", &self.ledger), + ("witness", &self.witness), + ("constructor", &self.constructor), + ("struct", &self.struct_), + ("enum", &self.enum_), + ("contract", &self.contract), + ("type", &self.type_), + ] + } + #[must_use] pub fn get(&self, kind: DeclKind) -> &KindConfig { match kind { @@ -126,6 +176,9 @@ pub const DEFAULT_SOURCES: [&str; 2] = [ "{parent}/test/mocks/Mock{stem}.compact", ]; +/// Where the compiler writes, and where `fill-constraints` reads a measurement from. +pub const DEFAULT_ARTIFACTS: &str = "artifacts"; + /// The circuit constraints annotation, and how `fill-constraints` measures it. #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields, default)] @@ -133,6 +186,8 @@ pub struct ConstraintsConfig { pub tag: Tag, /// Toolchain version passed as `+`; none leaves `compact` on its default. pub compiler: Option, + /// Artifacts tree, relative to this file's directory; `--artifacts` overrides it. + pub artifacts: PathBuf, /// Measurement-source templates, first existing file wins. pub sources: Vec, /// Globs for files that are contracts and compile themselves. @@ -147,6 +202,7 @@ impl Default for ConstraintsConfig { Self { tag: Tag::new("@constraints"), compiler: None, + artifacts: PathBuf::from(DEFAULT_ARTIFACTS), sources: DEFAULT_SOURCES .iter() .map(|&template| template.to_owned()) @@ -157,6 +213,82 @@ impl Default for ConstraintsConfig { } } +/// The level each rule reports at; `off` stops it running. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, default)] +pub struct RulesConfig { + #[serde(rename = "missing-doc")] + pub missing_doc: Level, + #[serde(rename = "missing-tag")] + pub missing_tag: Level, + #[serde(rename = "unknown-section")] + pub unknown_section: Level, + #[serde(rename = "tag-order")] + pub tag_order: Level, + #[serde(rename = "forbidden-tag")] + pub forbidden_tag: Level, + #[serde(rename = "module-name")] + pub module_name: Level, + #[serde(rename = "missing-constraints")] + pub missing_constraints: Level, + #[serde(rename = "constraints-format")] + pub constraints_format: Level, + #[serde(rename = "constraints-placeholder")] + pub constraints_placeholder: Level, + #[serde(rename = "constraints-unmeasured")] + pub constraints_unmeasured: Level, + #[serde(rename = "constraints-unmeasurable")] + pub constraints_unmeasurable: Level, + pub parse: Level, + pub format: Level, +} + +impl Default for RulesConfig { + fn default() -> Self { + Self { + missing_doc: Level::Error, + missing_tag: Level::Error, + // A heading the template does not list is a candidate for it, not a defect. + unknown_section: Level::Warn, + tag_order: Level::Error, + forbidden_tag: Level::Error, + module_name: Level::Error, + missing_constraints: Level::Error, + constraints_format: Level::Error, + // A placeholder is a value waiting on a measurement, not a defect; `--strict` + // promotes it on release branches. + constraints_placeholder: Level::Warn, + constraints_unmeasured: Level::Warn, + constraints_unmeasurable: Level::Warn, + parse: Level::Error, + format: Level::Error, + } + } +} + +impl RulesConfig { + #[must_use] + pub fn get(&self, rule: RuleId) -> Level { + match rule { + RuleId::MISSING_DOC => self.missing_doc, + RuleId::MISSING_TAG => self.missing_tag, + RuleId::UNKNOWN_SECTION => self.unknown_section, + RuleId::TAG_ORDER => self.tag_order, + RuleId::FORBIDDEN_TAG => self.forbidden_tag, + RuleId::MODULE_NAME => self.module_name, + RuleId::MISSING_CONSTRAINTS => self.missing_constraints, + RuleId::CONSTRAINTS_FORMAT => self.constraints_format, + RuleId::CONSTRAINTS_PLACEHOLDER => self.constraints_placeholder, + RuleId::CONSTRAINTS_UNMEASURED => self.constraints_unmeasured, + RuleId::CONSTRAINTS_UNMEASURABLE => self.constraints_unmeasurable, + RuleId::PARSE => self.parse, + RuleId::FORMAT => self.format, + // `fill` reports what changed, so it is not configurable. + _ => Level::Info, + } + } +} + /// Tags no doc comment may carry, and the replacements `fix` writes for them. #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields, default)] @@ -181,54 +313,59 @@ impl Default for FixConfig { } } -/// A parsed `compact-lint.toml`, or the built-in defaults when no file was found. +/// The whole `compact.toml`. Tables other tools own are ignored, so the root rejects +/// nothing. +#[derive(Debug, Default, Deserialize)] +struct Document { + lint: Option, +} + +/// A parsed `[lint]` table, or the built-in defaults when no file was found. #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields, default)] pub struct Config { - pub version: u32, pub include: Vec, pub exclude: Vec, pub constraints: ConstraintsConfig, pub tags: TagsConfig, pub fix: FixConfig, pub kinds: KindsConfig, + pub rules: RulesConfig, } impl Default for Config { fn default() -> Self { Self { - version: SUPPORTED_VERSION, include: vec!["**/*.compact".to_owned()], exclude: Vec::new(), constraints: ConstraintsConfig::default(), tags: TagsConfig::default(), fix: FixConfig::default(), kinds: KindsConfig::default(), + rules: RulesConfig::default(), } } } impl Config { - /// Parses a config file and rejects a version this build does not implement. + /// Parses a config file's `[lint]` table. /// # Errors - /// Returns an error when the file is unreadable, malformed, or a future version. + /// Returns an error when the file is unreadable or malformed, or carries no `[lint]` + /// table. pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read { path: path.to_owned(), source, })?; - let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse { + let document: Document = toml::from_str(&text).map_err(|source| ConfigError::Parse { path: path.to_owned(), source, })?; - if config.version != SUPPORTED_VERSION { - return Err(ConfigError::Version { - path: path.to_owned(), - found: config.version, - }); - } + let config = document.lint.ok_or_else(|| ConfigError::MissingLintTable { + path: path.to_owned(), + })?; if let Some(tag) = config.tags().find(|tag| !tag.is_well_formed()) { return Err(ConfigError::Tag { @@ -236,6 +373,7 @@ impl Config { tag: tag.to_string(), }); } + config.validate_entries(path)?; for (from, to) in &config.tags.rename { if !config.tags.forbid.contains(from) { @@ -264,21 +402,59 @@ impl Config { Ok(config) } - /// Every tag the config mentions: required per kind, forbidden, and the constraints tag. + /// A heading is well formed, no list repeats an entry, and a required entry takes its + /// place in the section order. + fn validate_entries(&self, path: &Path) -> Result<(), ConfigError> { + for (kind, config) in self.kinds.all() { + for list in [&config.tags, &config.sections] { + let mut seen: Vec = Vec::new(); + for entry in list { + if !entry.has_well_formed_heading() { + return Err(ConfigError::Heading { + path: path.to_owned(), + kind, + entry: entry.to_string(), + }); + } + let spelling = entry.to_string(); + if seen.contains(&spelling) { + return Err(ConfigError::DuplicateEntry { + path: path.to_owned(), + kind, + entry: spelling, + }); + } + seen.push(spelling); + } + } + + if config.sections.is_empty() { + continue; + } + if let Some(entry) = config + .tags + .iter() + .find(|required| !config.sections.contains(required)) + { + return Err(ConfigError::NotInSections { + path: path.to_owned(), + kind, + entry: entry.to_string(), + }); + } + } + Ok(()) + } + + /// Every tag the config mentions: the kind templates, the forbidden set, and the + /// constraints tag. fn tags(&self) -> impl Iterator { - let per_kind = [ - &self.kinds.module, - &self.kinds.circuit, - &self.kinds.ledger, - &self.kinds.witness, - &self.kinds.constructor, - &self.kinds.struct_, - &self.kinds.enum_, - &self.kinds.contract, - &self.kinds.type_, - ] - .into_iter() - .flat_map(|kind| kind.tags.iter()); + let per_kind = self.kinds.all().into_iter().flat_map(|(_, kind)| { + kind.tags + .iter() + .chain(kind.sections.iter()) + .map(|entry| &entry.tag) + }); per_kind .chain(self.tags.forbid.iter()) @@ -346,7 +522,7 @@ fn glob_set(patterns: &[String], path: &Path) -> Result { }) } -/// Walks up from `start` looking for `compact-lint.toml`. +/// Walks up from `start` looking for `compact.toml`. #[must_use] pub fn discover(start: &Path) -> Option { for directory in start.ancestors() { @@ -360,9 +536,11 @@ pub fn discover(start: &Path) -> Option { #[cfg(test)] mod tests { - use super::{CONFIG_FILE_NAME, Config, DocsPolicy, SUPPORTED_VERSION}; - use crate::doc::Tag; + use super::{CONFIG_FILE_NAME, Config, DocsPolicy}; + use crate::diagnostic::Level; + use crate::doc::{Tag, TagSpec}; use crate::model::DeclKind; + use crate::report::RuleId; use tempfile::TempDir; /// Loads `snippet` as a config file and returns the rejection it produced. @@ -376,11 +554,42 @@ mod tests { .to_string() } + #[test] + fn every_rule_defaults_to_error_but_the_ones_awaiting_a_measurement() { + let rules = Config::default().rules; + + assert_eq!(rules.get(RuleId::MISSING_DOC), Level::Error); + assert_eq!(rules.get(RuleId::PARSE), Level::Error); + assert_eq!(rules.get(RuleId::FORMAT), Level::Error); + assert_eq!(rules.get(RuleId::TAG_ORDER), Level::Error); + assert_eq!(rules.get(RuleId::UNKNOWN_SECTION), Level::Warn); + assert_eq!(rules.get(RuleId::CONSTRAINTS_PLACEHOLDER), Level::Warn); + assert_eq!(rules.get(RuleId::CONSTRAINTS_UNMEASURED), Level::Warn); + assert_eq!(rules.get(RuleId::CONSTRAINTS_UNMEASURABLE), Level::Warn); + } + + #[test] + fn a_rules_table_sets_only_the_rules_it_names() { + let config: Config = toml::from_str("[rules]\nmissing-doc = \"off\"\nformat = \"warn\"\n") + .expect("the snippet is valid config"); + + assert_eq!(config.rules.get(RuleId::MISSING_DOC), Level::Off); + assert_eq!(config.rules.get(RuleId::FORMAT), Level::Warn); + assert_eq!(config.rules.get(RuleId::MISSING_TAG), Level::Error); + } + + #[test] + fn an_unknown_rule_key_is_rejected() { + let error = toml::from_str::("[rules]\nmissing-docs = \"off\"\n") + .expect_err("the rule is misspelled"); + + assert!(error.to_string().contains("missing-docs"), "{error}"); + } + #[test] fn defaults_require_docs_on_exported_declarations_only() { let config = Config::default(); - assert_eq!(config.version, SUPPORTED_VERSION); assert_eq!(config.constraints.tag, Tag::new("@constraints")); for kind in [DeclKind::Module, DeclKind::Circuit, DeclKind::Type] { assert_eq!(config.kinds.get(kind).docs, DocsPolicy::Exported); @@ -390,7 +599,7 @@ mod tests { #[test] fn omitted_tables_fall_back_to_defaults() { - let config: Config = toml::from_str("version = 1\n[kinds.witness]\ndocs = \"all\"\n") + let config: Config = toml::from_str("[kinds.witness]\ndocs = \"all\"\n") .expect("the snippet is valid config"); assert_eq!(config.kinds.witness.docs, DocsPolicy::All); @@ -400,17 +609,17 @@ mod tests { #[test] fn an_unknown_key_is_rejected() { - let error = toml::from_str::("verison = 1\n").expect_err("the key is misspelled"); + let error = toml::from_str::("inclued = []\n").expect_err("the key is misspelled"); - assert!(error.to_string().contains("verison"), "{error}"); + assert!(error.to_string().contains("inclued"), "{error}"); } #[test] fn a_tag_without_its_at_sign_is_rejected() { let dir = std::env::temp_dir().join(format!("compact-lint-config-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("the temp dir is writable"); - let path = dir.join("compact-lint.toml"); - std::fs::write(&path, "[kinds.circuit]\ntags = [\"description\"]\n") + let path = dir.join("compact.toml"); + std::fs::write(&path, "[lint.kinds.circuit]\ntags = [\"description\"]\n") .expect("the config is writable"); let error = Config::load(&path).expect_err("the tag lacks its @"); @@ -419,6 +628,86 @@ mod tests { std::fs::remove_dir_all(&dir).expect("the temp dir is removable"); } + #[test] + fn a_tag_entry_keeps_the_heading_after_its_tag() { + let config: Config = toml::from_str( + "[kinds.module]\ntags = [\"@module\", \"@notice Privacy\"]\nsections = [\"@dev Notation\"]\n", + ) + .expect("the snippet is valid config"); + + assert_eq!(config.kinds.module.tags[0], TagSpec::parse("@module")); + assert_eq!( + config.kinds.module.tags[1].heading.as_deref(), + Some("Privacy") + ); + assert_eq!(config.kinds.module.sections[0].tag, Tag::new("@dev")); + assert!(config.kinds.circuit.sections.is_empty()); + } + + #[test] + fn a_required_entry_missing_from_the_section_order_is_rejected() { + let error = load_document( + "not-in-sections", + "[lint.kinds.module]\ntags = [\"@module\", \"@notice Privacy\"]\nsections = [\"@module\", \"@dev Notation\"]\n", + ) + .expect_err("a required entry takes its place in the section order"); + + assert!( + error.to_string().contains( + "kinds.module.tags lists @notice Privacy, but kinds.module.sections does not" + ), + "{error}" + ); + } + + #[test] + fn the_section_order_replaces_the_tag_order_where_the_kind_has_one() { + let bare: Config = + toml::from_str("[kinds.module]\ntags = [\"@module\", \"@description\"]\n") + .expect("the snippet is valid config"); + let ordered: Config = toml::from_str( + "[kinds.module]\ntags = [\"@module\", \"@notice Privacy\"]\nsections = [\"@module\", \"@dev Notation\", \"@notice Privacy\"]\n", + ) + .expect("the snippet is valid config"); + + let spelled = |config: &Config| -> Vec { + config + .kinds + .module + .entries() + .map(ToString::to_string) + .collect() + }; + + assert_eq!(spelled(&bare), ["@module", "@description"]); + assert_eq!( + spelled(&ordered), + ["@module", "@dev Notation", "@notice Privacy"] + ); + } + + #[test] + fn a_heading_carrying_a_colon_is_rejected() { + let error = load_document( + "colon-heading", + "[lint.kinds.module]\ntags = [\"@notice Privacy:\"]\n", + ) + .expect_err("the occurrence writes the colon, not the config"); + + assert!(error.to_string().contains("@notice Privacy:"), "{error}"); + } + + #[test] + fn an_entry_listed_twice_in_one_list_is_rejected() { + let error = load_document( + "duplicate-entry", + "[lint.kinds.module]\nsections = [\"@notice Privacy\", \"@notice Privacy\"]\n", + ) + .expect_err("a list names an entry once"); + + assert!(error.to_string().contains("twice"), "{error}"); + } + #[test] fn a_rename_maps_a_forbidden_tag_to_its_replacement() { let config: Config = toml::from_str( @@ -437,9 +726,12 @@ mod tests { fn a_rename_target_without_its_at_sign_is_rejected() { let dir = std::env::temp_dir().join(format!("compact-lint-rename-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("the temp dir is writable"); - let path = dir.join("compact-lint.toml"); - std::fs::write(&path, "[tags]\nrename = { \"@return\" = \"returns\" }\n") - .expect("the config is writable"); + let path = dir.join("compact.toml"); + std::fs::write( + &path, + "[lint.tags]\nrename = { \"@return\" = \"returns\" }\n", + ) + .expect("the config is writable"); let error = Config::load(&path).expect_err("the replacement lacks its @"); @@ -461,6 +753,7 @@ mod tests { let constraints = Config::default().constraints; assert_eq!(constraints.compiler, None); + assert_eq!(constraints.artifacts, std::path::Path::new("artifacts")); assert_eq!(constraints.sources, super::DEFAULT_SOURCES); assert!(constraints.own.is_empty()); assert!(constraints.overrides.is_empty()); @@ -469,11 +762,15 @@ mod tests { #[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", + "[constraints]\ncompiler = \"0.34.0\"\nartifacts = \"contracts/artifacts\"\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.artifacts, + std::path::Path::new("contracts/artifacts") + ); assert_eq!(config.constraints.own, ["**/presets/**"]); assert_eq!(config.constraints.sources, super::DEFAULT_SOURCES); assert_eq!( @@ -496,7 +793,7 @@ mod tests { #[test] fn a_rename_of_a_tag_that_is_not_forbidden_is_rejected() { - let error = rejection("[tags]\nrename = { \"@return\" = \"@returns\" }\n"); + let error = rejection("[lint.tags]\nrename = { \"@return\" = \"@returns\" }\n"); assert!(error.contains("@return"), "{error}"); assert!(error.contains("tags.forbid does not list"), "{error}"); @@ -505,7 +802,7 @@ mod tests { #[test] fn a_rename_onto_a_forbidden_tag_is_rejected() { let error = rejection( - "[tags]\nforbid = [\"@return\", \"@returns\"]\nrename = { \"@return\" = \"@returns\" }\n", + "[lint.tags]\nforbid = [\"@return\", \"@returns\"]\nrename = { \"@return\" = \"@returns\" }\n", ); assert!(error.contains("tags.forbid also lists"), "{error}"); @@ -513,21 +810,21 @@ mod tests { #[test] fn an_empty_fix_placeholder_is_rejected() { - let error = rejection("[fix]\nplaceholder = \" \"\n"); + let error = rejection("[lint.fix]\nplaceholder = \" \"\n"); assert!(error.contains("is empty"), "{error}"); } #[test] fn a_multi_line_fix_placeholder_is_rejected() { - let error = rejection("[fix]\nplaceholder = \"TO\\nDO\"\n"); + let error = rejection("[lint.fix]\nplaceholder = \"TO\\nDO\"\n"); assert!(error.contains("more than one line"), "{error}"); } #[test] fn a_fix_placeholder_closing_the_comment_is_rejected() { - let error = rejection("[fix]\nplaceholder = \"TODO */\"\n"); + let error = rejection("[lint.fix]\nplaceholder = \"TODO */\"\n"); assert!(error.contains("close the comment"), "{error}"); } @@ -539,4 +836,45 @@ mod tests { assert!(error.to_string().contains("circiut"), "{error}"); } + + /// Writes `text` to a `compact.toml` of its own and returns what `load` made of it. + fn load_document(name: &str, text: &str) -> Result { + let dir = std::env::temp_dir().join(format!("compact-lint-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("the temp dir is writable"); + let path = dir.join("compact.toml"); + std::fs::write(&path, text).expect("the config is writable"); + + let loaded = Config::load(&path); + + std::fs::remove_dir_all(&dir).expect("the temp dir is removable"); + loaded + } + + #[test] + fn tables_another_tool_owns_are_ignored() { + let config = load_document( + "foreign", + "[profile]\ndefault_network = \"local\"\n\n[lint]\ninclude = [\"src/**/*.compact\"]\n", + ) + .expect("the deployer's tables are not the linter's business"); + + assert_eq!(config.include, ["src/**/*.compact"]); + } + + #[test] + fn a_file_without_a_lint_table_is_an_error() { + let error = load_document("no-lint", "[profile]\ndefault_network = \"local\"\n") + .expect_err("the file carries no [lint] table"); + + assert!(error.to_string().contains("has no [lint] table"), "{error}"); + } + + #[test] + fn a_typo_under_the_lint_table_is_still_rejected() { + let error = load_document("lint-typo", "[lint]\ninclued = []\n") + .expect_err("the key is misspelled"); + let source = std::error::Error::source(&error).expect("the parser named the key"); + + assert!(source.to_string().contains("inclued"), "{source}"); + } } diff --git a/crates/compact-lint/src/diagnostic.rs b/crates/compact-lint/src/diagnostic.rs new file mode 100644 index 0000000..b9a1a03 --- /dev/null +++ b/crates/compact-lint/src/diagnostic.rs @@ -0,0 +1,969 @@ +//! Diagnostics and the three reporters that render them. +//! +//! A [`Diagnostic`] carries everything a reporter prints: where, which rule, at which +//! [`Level`], and optionally a [`FixPreview`] rendered as a line diff. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anstyle::{AnsiColor, Color, Style}; +use serde::Deserialize; +use similar::{ChangeTag, DiffTag, TextDiff}; + +use crate::report::{Position, RuleId}; + +/// Total width a header fills, rule glyphs included. +const HEADER_WIDTH: usize = 100; + +/// Shortest run of header rule glyphs, whatever the header's length. +const MIN_RULE_GLYPHS: usize = 10; + +/// Body indentation under a header. +const INDENT: &str = " "; + +/// Source lines shown on each side of the finding line. +const CONTEXT: usize = 2; + +/// Printed when the cap hides diagnostics. +pub const TRUNCATION_NOTICE: &str = + "The number of diagnostics exceeds the limit allowed. Use --max-diagnostics to increase it."; + +/// How loudly a rule reports, and whether it runs at all. +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum Level { + /// The rule is not run and its findings are dropped. + Off, + Info, + Warn, + #[default] + Error, +} + +impl Level { + /// The glyph the default and concise reporters print. + #[must_use] + pub const fn glyph(self) -> char { + match self { + Self::Error => '\u{d7}', + Self::Warn => '!', + Self::Off | Self::Info => 'i', + } + } + + /// The GitHub workflow-command name. + #[must_use] + pub const fn github(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warning", + Self::Off | Self::Info => "notice", + } + } + + const fn color(self) -> AnsiColor { + match self { + Self::Error => AnsiColor::Red, + Self::Warn => AnsiColor::Yellow, + Self::Off | Self::Info => AnsiColor::Cyan, + } + } +} + +/// The source range a diagnostic underlines, end exclusive. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Span { + pub start: Position, + pub end: Position, +} + +impl Span { + #[must_use] + pub const fn new(start: Position, end: Position) -> Self { + Self { start, end } + } + + /// A span covering `width` columns of one line. + #[must_use] + pub const fn columns(start: Position, width: usize) -> Self { + Self { + start, + end: Position { + line: start.line, + column: start.column + width, + }, + } + } +} + +/// The line window an edit rewrites, before and after it applies. +/// +/// `first_line` is the 1-based line the window opens on. Both sides open there, because +/// the window starts at the first line the two texts differ on. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FixPreview { + pub title: String, + pub before: String, + pub after: String, + pub first_line: usize, +} + +impl FixPreview { + /// The window two versions of one file differ over, or `None` when they match. + #[must_use] + pub fn between(title: String, before: &str, after: &str) -> Option { + let diff = TextDiff::from_lines(before, after); + let mut changed = diff.ops().iter().filter(|op| op.tag() != DiffTag::Equal); + + let first = changed.next()?; + let last = changed.next_back().unwrap_or(first); + let old = first.old_range().start..last.old_range().end; + let new = first.new_range().start..last.new_range().end; + + Some(Self { + title, + before: diff.old_slices().get(old.clone())?.concat(), + after: diff.new_slices().get(new)?.concat(), + first_line: old.start + 1, + }) + } +} + +/// Whether the run applied the fix or only offers it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Badge { + Fixable, + Fixed, +} + +impl Badge { + const fn as_str(self) -> &'static str { + match self { + Self::Fixable => "FIXABLE", + Self::Fixed => "FIXED", + } + } +} + +/// One reported problem, with everything the reporters print for it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Diagnostic { + pub path: PathBuf, + /// Absent for a whole-file diagnostic, which prints no `line:col`. + pub span: Option, + pub rule: RuleId, + pub level: Level, + pub message: String, + pub advice: Vec, + pub fix: Option, + pub badge: Option, +} + +impl Diagnostic { + pub fn new(path: impl Into, rule: RuleId, level: Level, message: String) -> Self { + Self { + path: path.into(), + span: None, + rule, + level, + message, + advice: Vec::new(), + fix: None, + badge: None, + } + } + + #[must_use] + pub const fn at(mut self, span: Span) -> Self { + self.span = Some(span); + self + } + + #[must_use] + pub fn advise(mut self, advice: impl Into) -> Self { + self.advice.push(advice.into()); + self + } + + /// Attaches the preview and the badge that names its state. + #[must_use] + pub fn fixed_by(mut self, preview: Option, badge: Badge) -> Self { + if preview.is_some() { + self.badge = Some(badge); + } + self.fix = preview; + self + } + + /// Sorts by file, then position, then rule, so output is stable across runs. + #[must_use] + pub fn sort_key(&self) -> (&Path, Position, RuleId) { + ( + self.path.as_path(), + self.span + .map_or_else(Position::file_start, |span| span.start), + self.rule, + ) + } +} + +/// The rule name in a header, a concise line and a GitHub `title`. +/// +/// Doc rules are namespaced the way Biome namespaces its lint rules; the passes that are +/// not doc rules keep their bare name. +#[must_use] +pub fn display_name(rule: RuleId) -> String { + match rule { + RuleId::PARSE | RuleId::FORMAT | RuleId::FILL => rule.as_str().to_owned(), + _ => format!("lint/{rule}"), + } +} + +/// Capitalises the first letter. +#[must_use] +pub fn capitalise(body: &str) -> String { + let mut characters = body.chars(); + match characters.next() { + Some(first) => format!("{}{}", first.to_uppercase(), characters.as_str()), + None => String::new(), + } +} + +/// Capitalises the first letter and closes the sentence with a period. +#[must_use] +pub fn sentence(body: &str) -> String { + let opened = capitalise(body); + if opened.ends_with('.') { + opened + } else { + format!("{opened}.") + } +} + +/// How many diagnostics are at error and at warning level. +#[must_use] +pub fn counts(diagnostics: &[Diagnostic]) -> (usize, usize) { + let errors = diagnostics + .iter() + .filter(|diagnostic| diagnostic.level == Level::Error) + .count(); + let warnings = diagnostics + .iter() + .filter(|diagnostic| diagnostic.level == Level::Warn) + .count(); + (errors, warnings) +} + +/// Which reporter renders a run. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)] +pub enum Reporter { + /// Header, code frame, advice and fix diff. + #[default] + Default, + /// One line per diagnostic. + Concise, + /// GitHub workflow commands. + Github, +} + +/// Everything the output flags resolve to. +#[derive(Clone, Copy, Debug)] +pub struct Output { + pub reporter: Reporter, + /// Lowest level shown; diagnostics below it still count in the summary. + pub level: Level, + /// Cap on shown diagnostics; `None` lifts it. + pub max: Option, + pub colors: bool, +} + +impl Default for Output { + fn default() -> Self { + Self { + reporter: Reporter::default(), + level: Level::Info, + max: Some(20), + colors: false, + } + } +} + +impl Output { + /// Writes every shown diagnostic and reports what it printed. + /// # Errors + /// Returns an error when the writer fails. + pub fn render( + &self, + out: &mut impl Write, + diagnostics: &[Diagnostic], + ) -> std::io::Result { + let mut sources = Sources::default(); + let mut shown = 0; + let mut hidden = 0; + + for diagnostic in diagnostics { + if diagnostic.level < self.level || diagnostic.level == Level::Off { + continue; + } + if self.max.is_some_and(|max| shown >= max) { + hidden += 1; + continue; + } + shown += 1; + + match self.reporter { + Reporter::Default => { + out.write_all(default_block(diagnostic, &mut sources, self.colors).as_bytes())?; + } + Reporter::Concise => { + out.write_all(concise_line(diagnostic, self.colors).as_bytes())?; + } + Reporter::Github => out.write_all(github_line(diagnostic).as_bytes())?, + } + } + + out.flush()?; + Ok(Rendered { shown, hidden }) + } +} + +/// What one render printed, and what the cap held back. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Rendered { + pub shown: usize, + pub hidden: usize, +} + +/// The two-line notice printed when the cap hid diagnostics. +#[must_use] +pub fn truncation(hidden: usize) -> String { + format!("{TRUNCATION_NOTICE}\nDiagnostics not shown: {hidden}.") +} + +/// What a run did to the files it read, as the summary phrases it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + /// `check`, and `fix --dry-run`. + NoFixes, + /// Files rewritten by `fix`. + Fixed(usize), + /// Values written by `fill-constraints`, and the files that took them. + Filled { values: usize, files: usize }, +} + +/// The stderr summary, built from a measured duration so tests can pin it. +#[derive(Clone, Copy, Debug)] +pub struct Summary { + pub files: usize, + pub duration: Duration, + pub action: Action, + pub errors: usize, + pub warnings: usize, +} + +impl Summary { + #[must_use] + pub fn render(&self) -> String { + let action = match self.action { + Action::NoFixes => " No fixes applied.".to_owned(), + Action::Fixed(files) => format!(" Fixed {}.", plural(files, "file")), + Action::Filled { values, files } => format!( + " Filled {} in {}.", + plural(values, "value"), + plural(files, "file") + ), + }; + + let mut out = format!( + "Checked {} in {}.{action}", + plural(self.files, "file"), + duration(self.duration) + ); + if self.errors > 0 { + let _ = write!(out, "\nFound {}.", plural(self.errors, "error")); + } + if self.warnings > 0 { + let _ = write!(out, "\nFound {}.", plural(self.warnings, "warning")); + } + out + } +} + +fn plural(count: usize, word: &str) -> String { + if count == 1 { + format!("{count} {word}") + } else { + format!("{count} {word}s") + } +} + +/// Sub-millisecond runs keep their microseconds; a run over a second reads in seconds. +pub(crate) fn duration(elapsed: Duration) -> String { + let micros = elapsed.as_micros(); + if micros < 1_000 { + format!("{micros}\u{b5}s") + } else if micros < 1_000_000 { + format!("{}ms", micros / 1_000) + } else { + format!("{:.1}s", elapsed.as_secs_f64()) + } +} + +/// File texts read for code frames, read once per file. +#[derive(Default)] +struct Sources { + texts: BTreeMap>, +} + +impl Sources { + fn get(&mut self, path: &Path) -> Option<&str> { + self.texts + .entry(path.to_owned()) + .or_insert_with(|| std::fs::read_to_string(path).ok()) + .as_deref() + } +} + +fn paint(text: &str, style: Style, colors: bool) -> String { + if colors { + format!("{}{text}{}", style.render(), style.render_reset()) + } else { + text.to_owned() + } +} + +const fn level_style(level: Level) -> Style { + Style::new().fg_color(Some(Color::Ansi(level.color()))) +} + +fn header(diagnostic: &Diagnostic, colors: bool) -> String { + let location = match diagnostic.span { + Some(span) => format!( + "{}:{}:{}", + diagnostic.path.display(), + span.start.line, + span.start.column + ), + None => diagnostic.path.display().to_string(), + }; + let name = display_name(diagnostic.rule); + + let plain = match diagnostic.badge { + Some(badge) => format!("{location} {name} {} ", badge.as_str()), + None => format!("{location} {name} "), + }; + let glyphs = HEADER_WIDTH + .saturating_sub(plain.chars().count()) + .max(MIN_RULE_GLYPHS); + + let painted = paint(&location, Style::new().bold(), colors); + format!( + "{}{}{}\n", + painted, + &plain[location.len()..], + "\u{2501}".repeat(glyphs) + ) +} + +/// Header, message, code frame, advice, fix diff; one blank line between blocks. +fn default_block(diagnostic: &Diagnostic, sources: &mut Sources, colors: bool) -> String { + let style = level_style(diagnostic.level); + let mut out = header(diagnostic, colors); + + let glyph = paint(&diagnostic.level.glyph().to_string(), style.bold(), colors); + let _ = write!(out, "\n{INDENT}{glyph} {}\n", diagnostic.message); + + // An applied fix already rewrote the file, so the diff below stands in for the frame. + if diagnostic.badge != Some(Badge::Fixed) + && let Some(span) = diagnostic.span + && let Some(source) = sources.get(&diagnostic.path) + && let Some(frame) = frame(source, span, style, colors) + { + let _ = write!(out, "\n{frame}"); + } + + let info = paint("i", level_style(Level::Info).bold(), colors); + for advice in &diagnostic.advice { + let _ = write!(out, "\n{INDENT}{info} {advice}\n"); + } + + if let Some(preview) = &diagnostic.fix { + let _ = write!(out, "\n{INDENT}{info} {}\n", preview.title); + let _ = write!(out, "\n{}", diff(preview, colors)); + } + + out.push('\n'); + out +} + +/// The finding line with up to [`CONTEXT`] lines on each side, and the underline. +fn frame(source: &str, span: Span, style: Style, colors: bool) -> Option { + let lines: Vec<&str> = source.lines().collect(); + let index = span.start.line.checked_sub(1)?; + if index >= lines.len() { + return None; + } + + let from = index.saturating_sub(CONTEXT); + let to = (index + CONTEXT).min(lines.len() - 1); + let width = (to + 1).to_string().len(); + + let mut out = String::new(); + for (number, text) in (from..=to).map(|line| (line + 1, lines[line])) { + let marker = if number == span.start.line { + "> " + } else { + " " + }; + let _ = writeln!(out, "{INDENT}{marker}{number:>width$} \u{2502} {text}"); + if number == span.start.line { + let _ = writeln!( + out, + "{INDENT} {:width$} \u{2502} {}", + "", + underline(text, span, style, colors) + ); + } + } + Some(out) +} + +/// Carets under the span, padded to its start; a multi-line span stops at the line end. +fn underline(text: &str, span: Span, style: Style, colors: bool) -> String { + let start = boundary(text, span.start.column.saturating_sub(1)); + let end = if span.end.line == span.start.line { + boundary(text, span.end.column.saturating_sub(1)).max(start) + } else { + text.len() + }; + + let pad = text[..start].chars().count(); + let carets = text[start..end].chars().count().max(1); + format!( + "{}{}", + " ".repeat(pad), + paint(&"^".repeat(carets), style.bold(), colors) + ) +} + +/// The nearest char boundary at or below `byte`, clamped to the line. +fn boundary(text: &str, byte: usize) -> usize { + let mut at = byte.min(text.len()); + while at > 0 && !text.is_char_boundary(at) { + at -= 1; + } + at +} + +/// The preview's window as a two-column line diff, spaces shown as `·` on changed lines. +fn diff(preview: &FixPreview, colors: bool) -> String { + let text = TextDiff::from_lines(&preview.before, &preview.after); + + let mut rows: Vec<(Option, Option, &'static str, String)> = Vec::new(); + let mut old = preview.first_line; + let mut new = preview.first_line; + + for change in text.iter_all_changes() { + let value = change.value().trim_end_matches(['\n', '\r']); + match change.tag() { + ChangeTag::Delete => { + rows.push((Some(old), None, "- ", value.replace(' ', "\u{b7}"))); + old += 1; + } + ChangeTag::Insert => { + rows.push((None, Some(new), "+ ", value.replace(' ', "\u{b7}"))); + new += 1; + } + ChangeTag::Equal => { + rows.push((Some(old), Some(new), " ", value.to_owned())); + old += 1; + new += 1; + } + } + } + + let old_width = column_width(rows.iter().filter_map(|row| row.0)); + let new_width = column_width(rows.iter().filter_map(|row| row.1)); + + let mut out = String::new(); + for (old, new, marker, text) in rows { + let style = match marker { + "- " => level_style(Level::Error), + "+ " => Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green))), + _ => Style::new(), + }; + let body = paint(&format!("{marker}{text}"), style, colors); + let _ = writeln!( + out, + "{INDENT} {:>old_width$} {:>new_width$} \u{2502} {body}", + old.map_or_else(String::new, |line| line.to_string()), + new.map_or_else(String::new, |line| line.to_string()), + ); + } + out +} + +fn column_width(numbers: impl Iterator) -> usize { + numbers + .map(|number| number.to_string().len()) + .max() + .unwrap_or(1) +} + +fn concise_line(diagnostic: &Diagnostic, colors: bool) -> String { + let location = match diagnostic.span { + Some(span) => format!( + "{}:{}:{}", + diagnostic.path.display(), + span.start.line, + span.start.column + ), + None => diagnostic.path.display().to_string(), + }; + let glyph = paint( + &diagnostic.level.glyph().to_string(), + level_style(diagnostic.level).bold(), + colors, + ); + + format!( + "{glyph} {location}: {}: {}\n", + display_name(diagnostic.rule), + diagnostic.message + ) +} + +fn github_line(diagnostic: &Diagnostic) -> String { + let position = match diagnostic.span { + Some(span) => format!( + ",line={},endLine={},col={},endColumn={}", + span.start.line, span.end.line, span.start.column, span.end.column + ), + None => String::new(), + }; + + format!( + "::{} title={},file={}{position}::{}\n", + diagnostic.level.github(), + github_property(&display_name(diagnostic.rule)), + github_property(&diagnostic.path.display().to_string()), + github_message(&diagnostic.message) + ) +} + +/// A workflow command ends at a newline, so the message carries none; `%` opens an +/// escape, so it escapes itself. +fn github_message(text: &str) -> String { + text.replace('%', "%25") + .replace('\r', "%0D") + .replace('\n', "%0A") +} + +/// A property value also ends at the comma or colon that opens the next field. +fn github_property(text: &str) -> String { + github_message(text).replace(',', "%2C").replace(':', "%3A") +} + +#[cfg(test)] +mod tests { + use super::{ + Action, Badge, Diagnostic, FixPreview, Level, Output, Position, Rendered, Reporter, Span, + Summary, diff, duration, frame, header, level_style, + }; + use crate::report::RuleId; + use std::time::Duration; + + fn at(line: usize, column: usize) -> Position { + Position { line, column } + } + + fn diagnostic() -> Diagnostic { + Diagnostic::new( + "Gaps.compact", + RuleId::MISSING_DOC, + Level::Error, + "Ledger `_owner` has no doc comment.".to_owned(), + ) + .at(Span::columns(at(6, 3), 20)) + } + + #[test] + fn a_frame_underlines_the_span_at_its_column() { + let source = "one\ntwo\nthree four\nfive\nsix\n"; + let rendered = frame( + source, + Span::columns(at(3, 7), 4), + level_style(Level::Error), + false, + ) + .expect("the line is in the source"); + + assert_eq!( + rendered, + concat!( + " 1 \u{2502} one\n", + " 2 \u{2502} two\n", + " > 3 \u{2502} three four\n", + " \u{2502} ^^^^\n", + " 4 \u{2502} five\n", + " 5 \u{2502} six\n", + ) + ); + } + + #[test] + fn a_span_past_the_line_end_underlines_one_column() { + let rendered = frame( + "short\n", + Span::columns(at(1, 40), 3), + level_style(Level::Error), + false, + ) + .expect("the line is in the source"); + + assert!(rendered.ends_with("\u{2502} ^\n"), "{rendered}"); + } + + #[test] + fn a_header_fills_to_one_hundred_columns() { + let rendered = header(&diagnostic(), false); + + assert_eq!(rendered.chars().count(), 101, "{rendered}"); + assert!(rendered.starts_with("Gaps.compact:6:3 lint/missing-doc \u{2501}")); + } + + #[test] + fn a_long_header_keeps_ten_rule_glyphs() { + let long = "a".repeat(140); + let rendered = header( + &Diagnostic::new(long, RuleId::FORMAT, Level::Error, String::new()), + false, + ); + + assert!(rendered.ends_with(&format!("format {}\n", "\u{2501}".repeat(10)))); + } + + #[test] + fn a_badge_sits_between_the_rule_and_the_glyphs() { + let rendered = header( + &diagnostic().fixed_by( + Some(FixPreview { + title: "Unsafe fix: Insert a doc skeleton".to_owned(), + before: String::new(), + after: String::new(), + first_line: 1, + }), + Badge::Fixable, + ), + false, + ); + + assert!( + rendered.contains("lint/missing-doc FIXABLE \u{2501}"), + "{rendered}" + ); + } + + #[test] + fn a_diff_marks_changed_lines_and_shows_their_spaces() { + let preview = FixPreview::between( + "Unsafe fix: Rename @return to @returns".to_owned(), + "/**\n * @return x\n */\n", + "/**\n * @returns x\n */\n", + ) + .expect("the texts differ"); + + assert_eq!(preview.first_line, 2); + assert_eq!( + diff(&preview, false), + concat!( + " 2 \u{2502} - \u{b7}*\u{b7}@return\u{b7}x\n", + " 2 \u{2502} + \u{b7}*\u{b7}@returns\u{b7}x\n", + ) + ); + } + + #[test] + fn an_insertion_numbers_only_the_new_side() { + let preview = FixPreview::between( + "Unsafe fix: Insert @description".to_owned(), + "a\nb\n", + "a\nnew\nb\n", + ) + .expect("the texts differ"); + + assert_eq!(preview.first_line, 2); + assert_eq!(diff(&preview, false), " 2 \u{2502} + new\n"); + } + + #[test] + fn identical_texts_have_no_preview() { + assert!(FixPreview::between(String::new(), "a\n", "a\n").is_none()); + } + + #[test] + fn colors_wrap_the_glyph_and_stay_out_of_the_plain_render() { + let mut painted = Vec::new(); + Output { + colors: true, + ..Output::default() + } + .render(&mut painted, std::slice::from_ref(&diagnostic())) + .expect("the writer accepts the render"); + + let mut plain = Vec::new(); + Output::default() + .render(&mut plain, std::slice::from_ref(&diagnostic())) + .expect("the writer accepts the render"); + + assert!(String::from_utf8_lossy(&painted).contains('\u{1b}')); + assert!(!String::from_utf8_lossy(&plain).contains('\u{1b}')); + } + + #[test] + fn the_cap_counts_what_it_hides() { + let diagnostics = [diagnostic(), diagnostic(), diagnostic()]; + let mut out = Vec::new(); + + let rendered = Output { + max: Some(1), + ..Output::default() + } + .render(&mut out, &diagnostics) + .expect("the writer accepts the render"); + + assert_eq!( + rendered, + Rendered { + shown: 1, + hidden: 2 + } + ); + } + + #[test] + fn the_level_filter_hides_the_quieter_diagnostics() { + let warning = Diagnostic::new( + "a.compact", + RuleId::CONSTRAINTS_PLACEHOLDER, + Level::Warn, + "A warning.".to_owned(), + ); + let mut out = Vec::new(); + + Output { + level: Level::Error, + reporter: Reporter::Concise, + ..Output::default() + } + .render(&mut out, &[warning]) + .expect("the writer accepts the render"); + + assert!(out.is_empty(), "{}", String::from_utf8_lossy(&out)); + } + + #[test] + fn the_concise_and_github_reporters_render_one_line_each() { + let mut concise = Vec::new(); + Output { + reporter: Reporter::Concise, + ..Output::default() + } + .render(&mut concise, std::slice::from_ref(&diagnostic())) + .expect("the writer accepts the render"); + + let mut github = Vec::new(); + Output { + reporter: Reporter::Github, + ..Output::default() + } + .render(&mut github, std::slice::from_ref(&diagnostic())) + .expect("the writer accepts the render"); + + assert_eq!( + String::from_utf8_lossy(&concise), + "\u{d7} Gaps.compact:6:3: lint/missing-doc: Ledger `_owner` has no doc comment.\n" + ); + assert_eq!( + String::from_utf8_lossy(&github), + "::error title=lint/missing-doc,file=Gaps.compact,line=6,endLine=6,col=3,endColumn=23::Ledger `_owner` has no doc comment.\n" + ); + } + + #[test] + fn the_github_reporter_escapes_what_would_end_a_field() { + let awkward = Diagnostic::new( + "src/a,b/Token.compact", + RuleId::CONSTRAINTS_FORMAT, + Level::Error, + "@constraints value `k=10%` is not `k=, rows=`.".to_owned(), + ) + .at(Span::columns(at(3, 6), 12)); + + assert_eq!( + super::github_line(&awkward), + "::error title=lint/constraints-format,file=src/a%2Cb/Token.compact,line=3,endLine=3,col=6,endColumn=18::@constraints value `k=10%25` is not `k=, rows=`.\n" + ); + } + + #[test] + fn a_whole_file_diagnostic_prints_no_line_or_column() { + let whole = Diagnostic::new( + "a.compact", + RuleId::FORMAT, + Level::Error, + "Not formatted.".to_owned(), + ); + + assert!(super::concise_line(&whole, false).starts_with("\u{d7} a.compact: format: ")); + assert_eq!( + super::github_line(&whole), + "::error title=format,file=a.compact::Not formatted.\n" + ); + } + + #[test] + fn the_summary_counts_singulars_and_plurals_apart() { + let summary = Summary { + files: 1, + duration: Duration::from_millis(5), + action: Action::NoFixes, + errors: 1, + warnings: 2, + }; + + assert_eq!( + summary.render(), + "Checked 1 file in 5ms. No fixes applied.\nFound 1 error.\nFound 2 warnings." + ); + } + + #[test] + fn a_clean_summary_names_neither_errors_nor_warnings() { + let summary = Summary { + files: 3, + duration: Duration::from_millis(4), + action: Action::Filled { + values: 1, + files: 1, + }, + errors: 0, + warnings: 0, + }; + + assert_eq!( + summary.render(), + "Checked 3 files in 4ms. Filled 1 value in 1 file." + ); + } + + #[test] + fn durations_read_in_the_unit_that_fits() { + assert_eq!(duration(Duration::from_micros(400)), "400\u{b5}s"); + assert_eq!(duration(Duration::from_millis(5)), "5ms"); + assert_eq!(duration(Duration::from_millis(1234)), "1.2s"); + } +} diff --git a/crates/compact-lint/src/doc.rs b/crates/compact-lint/src/doc.rs index 4da3200..db0a695 100644 --- a/crates/compact-lint/src/doc.rs +++ b/crates/compact-lint/src/doc.rs @@ -35,6 +35,90 @@ impl fmt::Display for Tag { } } +/// A config tag entry: a tag, optionally narrowed to one named section of the template. +/// +/// `@notice` matches every `@notice`; `@notice Security` matches only the occurrence whose +/// value opens `Security:`. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(from = "String")] +pub struct TagSpec { + pub tag: Tag, + pub heading: Option, +} + +impl TagSpec { + #[must_use] + pub const fn bare(tag: Tag) -> Self { + Self { tag, heading: None } + } + + /// Splits `@tag Heading` at its first space; the heading is the rest, trimmed. + #[must_use] + pub fn parse(entry: &str) -> Self { + match entry.split_once(' ') { + Some((tag, heading)) => Self { + tag: Tag::new(tag), + heading: Some(heading.trim().to_owned()), + }, + None => Self::bare(Tag::new(entry)), + } + } + + /// A heading is non-empty and carries no `:`, which the occurrence writes itself. + #[must_use] + pub fn has_well_formed_heading(&self) -> bool { + self.heading + .as_ref() + .is_none_or(|heading| !heading.is_empty() && !heading.contains(':')) + } + + /// The name the entry goes by in prose: its heading, or its tag when it has none. + #[must_use] + pub fn label(&self) -> &str { + self.heading.as_deref().unwrap_or_else(|| self.tag.as_str()) + } + + /// Whether `occurrence` is this entry: the same tag, and for a headed entry a value + /// opening `Heading:`. + #[must_use] + pub fn matches(&self, occurrence: &TagOccurrence) -> bool { + occurrence.tag == self.tag + && self.opens_with_heading(occurrence.value.lines().next().unwrap_or_default()) + } + + /// Whether a gutter-stripped comment line opens this entry's block. + #[must_use] + pub fn matches_line(&self, content: &str) -> bool { + split_tag(content) + .is_some_and(|(tag, value)| tag == self.tag && self.opens_with_heading(value)) + } + + fn opens_with_heading(&self, value: &str) -> bool { + match &self.heading { + None => true, + Some(heading) => value + .trim() + .strip_prefix(heading.as_str()) + .is_some_and(|rest| rest.starts_with(':')), + } + } +} + +impl From for TagSpec { + fn from(entry: String) -> Self { + Self::parse(&entry) + } +} + +impl fmt::Display for TagSpec { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.heading { + Some(heading) => write!(formatter, "{} {heading}", self.tag), + None => formatter.write_str(self.tag.as_str()), + } + } +} + /// One tag occurrence: its name, its value, and where it sits inside the comment. #[derive(Clone, Debug, PartialEq, Eq)] pub struct TagOccurrence { @@ -46,6 +130,20 @@ pub struct TagOccurrence { pub column_offset: usize, } +impl TagOccurrence { + /// The section heading the value opens with: the text before its first `:`, where + /// that prefix is letters, digits, spaces and hyphens. + #[must_use] + pub fn heading(&self) -> Option<&str> { + let (prefix, _) = self.value.lines().next()?.split_once(':')?; + let spelled = !prefix.is_empty() + && prefix.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, ' ' | '-') + }); + spelled.then_some(prefix) + } +} + /// A parsed doc comment. Repeated tags (`@param`) are kept in source order. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct DocComment { @@ -144,7 +242,7 @@ fn split_tag(content: &str) -> Option<(Tag, &str)> { #[cfg(test)] mod tests { - use super::{DocComment, Tag}; + use super::{DocComment, Tag, TagSpec}; #[test] fn gutter_and_delimiters_are_stripped() { @@ -201,6 +299,84 @@ mod tests { assert!(doc.tags().is_empty()); } + #[test] + fn an_entry_splits_into_a_tag_and_the_heading_after_its_first_space() { + assert_eq!( + TagSpec::parse("@module"), + TagSpec::bare(Tag::new("@module")) + ); + assert_eq!( + TagSpec::parse("@notice Client-side duties"), + TagSpec { + tag: Tag::new("@notice"), + heading: Some("Client-side duties".to_owned()), + } + ); + assert_eq!( + TagSpec::parse("@dev Notation ").heading.as_deref(), + Some("Notation") + ); + assert_eq!( + TagSpec::parse("@notice Security").to_string(), + "@notice Security" + ); + } + + #[test] + fn a_heading_is_well_formed_when_it_is_non_empty_and_carries_no_colon() { + assert!(TagSpec::parse("@module").has_well_formed_heading()); + assert!(TagSpec::parse("@notice Security").has_well_formed_heading()); + assert!(!TagSpec::parse("@notice ").has_well_formed_heading()); + assert!(!TagSpec::parse("@notice Security:").has_well_formed_heading()); + } + + #[test] + fn a_headed_entry_matches_only_the_occurrence_opening_with_its_heading() { + let doc = DocComment::parse( + "/**\n * @notice Privacy:\n * - Public, per tx.\n * @notice Security:\n * - Nonces.\n */", + ); + let privacy = TagSpec::parse("@notice Privacy"); + let gotchas = TagSpec::parse("@notice Gotchas"); + let bare = TagSpec::parse("@notice"); + + let matched: Vec = doc.tags().iter().map(|one| privacy.matches(one)).collect(); + assert_eq!(matched, [true, false]); + assert!(!doc.tags().iter().any(|one| gotchas.matches(one))); + assert!(doc.tags().iter().all(|one| bare.matches(one))); + } + + #[test] + fn an_entry_matches_the_comment_line_that_opens_its_block() { + let security = TagSpec::parse("@notice Security"); + + assert!(security.matches_line("@notice Security: nonces are spend-critical.")); + assert!(!security.matches_line("@notice Securityish: no.")); + assert!(!security.matches_line("@dev Security: wrong tag.")); + assert!(TagSpec::parse("@description").matches_line("@description Anything.")); + } + + #[test] + fn a_heading_is_the_value_up_to_its_first_colon() { + let doc = DocComment::parse( + "/**\n * @dev Notation:\n * @notice Client-side duties:\n * @param {T} a - A.\n * @description A note-based token: amounts\n */", + ); + let headings: Vec> = doc + .tags() + .iter() + .map(super::TagOccurrence::heading) + .collect(); + + assert_eq!( + headings, + [ + Some("Notation"), + Some("Client-side duties"), + None, + Some("A note-based token"), + ] + ); + } + #[test] fn a_single_line_comment_parses() { let doc = DocComment::parse("/** @description One liner. */"); diff --git a/crates/compact-lint/src/edit.rs b/crates/compact-lint/src/edit.rs index ac71c5d..b8aaffb 100644 --- a/crates/compact-lint/src/edit.rs +++ b/crates/compact-lint/src/edit.rs @@ -13,12 +13,18 @@ use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; -use crate::report::Position; +use crate::diagnostic::Span; +use crate::doc::{Tag, TagSpec}; +use crate::report::RuleId; -/// One repair, carrying the line `fix` prints for it. +/// The tag the constraints annotation anchors itself on. +const DESCRIPTION_TAG: &str = "@description"; + +/// One repair, carrying the rule it answers and the span its diagnostic underlines. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Edit { - pub position: Position, + pub span: Span, + pub rule: RuleId, pub message: String, pub kind: EditKind, } @@ -37,14 +43,29 @@ pub enum EditKind { }, } +impl EditKind { + /// Whether the rewrite needs no follow-up. + /// + /// An edit that writes a value the tool already knows is safe; one that writes a + /// placeholder for a human to replace is not. + #[must_use] + pub const fn is_safe(&self) -> bool { + match self { + Self::Insert { .. } => false, + Self::Doc { op, .. } => op.is_safe(), + } + } +} + /// One change to the lines of a doc comment. #[derive(Clone, Debug, PartialEq, Eq)] pub enum DocOp { - /// A `@tag value` line added after the last of `after` present, else at the top of the body. + /// A tag block added after the last of `after` present, else at the top of the body. InsertTag { - line: String, - /// Tags that precede this one in config order. - after: Vec, + /// The block's lines: a `@tag value` line, plus a placeholder line for a section. + lines: Vec, + /// Entries that precede this one in config order. + after: Vec, /// Untagged prose opening the body becomes this tag's value instead of the placeholder. adopts_prose: bool, }, @@ -70,6 +91,26 @@ pub enum DocOp { tag: String, value: String, }, + /// The tag blocks that match a listed entry, written back into their slots in config + /// order. + ReorderBlocks { + /// The template, required entries first, in the order it lists them. + entries: Vec, + }, +} + +impl DocOp { + /// Whether the rewrite needs no follow-up; see [`EditKind::is_safe`]. + #[must_use] + pub const fn is_safe(&self) -> bool { + match self { + Self::RenameTag { .. } + | Self::SetModuleName { .. } + | Self::SetTagValue { .. } + | Self::ReorderBlocks { .. } => true, + Self::InsertTag { .. } | Self::InsertConstraints(_) => false, + } + } } #[derive(Debug, Error)] @@ -253,12 +294,12 @@ fn render_doc(doc: &str, indent: usize, newline: &str, ops: &[&DocOp]) -> String for op in ops { if let DocOp::InsertTag { - line, + lines: inserted, after, adopts_prose, } = op { - insert_tag(&mut lines, &gutter, line, after, *adopts_prose); + insert_tag(&mut lines, &gutter, inserted, after, *adopts_prose); } } @@ -268,6 +309,14 @@ fn render_doc(doc: &str, indent: usize, newline: &str, ops: &[&DocOp]) -> String } } + // The order the template wants is decided on the finished comment, so a reorder runs + // over the lines every other op already wrote. + for op in ops { + if let DocOp::ReorderBlocks { entries } = op { + reorder_blocks(&mut lines, entries); + } + } + lines.join(newline) } @@ -286,14 +335,14 @@ fn expand(single: &str, margin: &str, gutter: &str) -> Vec { ] } -/// A tag line lands after the block of the last tag in `after` that is present, else at +/// A tag block lands after the block of the last entry in `after` that is present, else at /// the top of the body. With `adopts_prose`, untagged prose opening the body takes the /// tag instead of a new line going in. fn insert_tag( lines: &mut Vec, gutter: &str, - line: &str, - after: &[String], + inserted: &[String], + after: &[TagSpec], adopts_prose: bool, ) { let closer = lines.len().saturating_sub(1); @@ -301,14 +350,77 @@ fn insert_tag( if adopts_prose && let Some(first) = (1..closer).find(|index| !is_blank(&lines[*index])) && !starts_tag(content(&lines[first])) - && let Some((tag, _)) = line.split_once(' ') + && let Some((tag, _)) = inserted.first().and_then(|line| line.split_once(' ')) { lines[first] = format!("{gutter}{tag} {}", content(&lines[first])); return; } let at = tag_block_end(lines, closer, after).map_or(1.min(closer), |end| end + 1); - lines.insert(at, format!("{gutter}{line}")); + for (offset, line) in inserted.iter().enumerate() { + lines.insert(at + offset, format!("{gutter}{line}")); + } +} + +/// Blocks matching a listed entry are written back into their slots in config order. +/// Prose before the first tag, blocks no entry lists, and the blank lines separating the +/// slots all stay where they are. +fn reorder_blocks(lines: &mut Vec, entries: &[TagSpec]) { + let closer = lines.len().saturating_sub(1); + if closer < 2 { + return; + } + + let prose_end = (1..closer) + .find(|index| starts_tag(content(&lines[*index]))) + .unwrap_or(closer); + + // A block runs from its tag line over its continuations; the blank lines under it + // separate slots rather than belonging to what fills one. + let mut blocks: Vec> = Vec::new(); + let mut separators: Vec> = Vec::new(); + for line in lines.iter().take(closer).skip(prose_end) { + if starts_tag(content(line)) { + blocks.push(Vec::new()); + separators.push(Vec::new()); + } + match separators.last_mut() { + Some(separator) if is_blank(line) => separator.push(line.clone()), + _ => { + if let (Some(block), Some(separator)) = (blocks.last_mut(), separators.last_mut()) { + block.append(separator); + block.push(line.clone()); + } + } + } + } + + let mut slots: Vec = Vec::new(); + let mut listed: Vec<(usize, Vec)> = Vec::new(); + for (slot, block) in blocks.iter().enumerate() { + let position = block.first().and_then(|line| { + entries + .iter() + .position(|entry| entry.matches_line(content(line))) + }); + if let Some(position) = position { + slots.push(slot); + listed.push((position, block.clone())); + } + } + listed.sort_by_key(|(position, _)| *position); + + for (slot, (_, block)) in slots.iter().zip(listed) { + blocks[*slot] = block; + } + + let mut rebuilt: Vec = lines[..prose_end].to_vec(); + for (block, separator) in blocks.into_iter().zip(separators) { + rebuilt.extend(block); + rebuilt.extend(separator); + } + rebuilt.extend_from_slice(&lines[closer..]); + *lines = rebuilt; } /// The constraints line follows the `@description` block, or opens the body without one. @@ -334,17 +446,14 @@ fn insert_constraints(lines: &mut Vec, margin: &str, gutter: &str, body: /// The last line of the `@description` block: its tag line plus every continuation. fn description_end(lines: &[String], closer: usize) -> Option { - tag_block_end(lines, closer, &["@description".to_owned()]) + tag_block_end(lines, closer, &[TagSpec::bare(Tag::new(DESCRIPTION_TAG))]) } -/// The last line of the block opened by the last occurrence of any tag in `tags`. -fn tag_block_end(lines: &[String], closer: usize, tags: &[String]) -> Option { +/// The last line of the block opened by the last occurrence of any entry in `entries`. +fn tag_block_end(lines: &[String], closer: usize, entries: &[TagSpec]) -> Option { let start = (1..closer).rev().find(|index| { let text = content(&lines[*index]); - tags.iter().any(|tag| { - text.strip_prefix(tag.as_str()) - .is_some_and(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace)) - }) + entries.iter().any(|entry| entry.matches_line(text)) })?; let mut end = start; @@ -440,11 +549,14 @@ mod tests { DocOp, Edit, EditKind, apply, newline_of, rename_tag, render_doc, set_module_name, set_tag_value, }; - use crate::report::Position; + use crate::diagnostic::Span; + use crate::doc::TagSpec; + use crate::report::{Position, RuleId}; fn doc_edit(range: std::ops::Range, indent: usize, op: DocOp) -> Edit { Edit { - position: Position::file_start(), + span: Span::columns(Position::file_start(), 1), + rule: RuleId::MISSING_TAG, message: String::new(), kind: EditKind::Doc { range, indent, op }, } @@ -452,17 +564,68 @@ mod tests { fn tag(line: &str, after: &[&str]) -> DocOp { DocOp::InsertTag { - line: line.to_owned(), - after: after.iter().map(ToString::to_string).collect(), + lines: vec![line.to_owned()], + after: after.iter().map(|entry| TagSpec::parse(entry)).collect(), adopts_prose: line.starts_with("@description"), } } + fn section(heading: &str, after: &[&str]) -> DocOp { + DocOp::InsertTag { + lines: vec![format!("{heading}:"), "TODO".to_owned()], + after: after.iter().map(|entry| TagSpec::parse(entry)).collect(), + adopts_prose: false, + } + } + + fn reorder(entries: &[&str]) -> DocOp { + DocOp::ReorderBlocks { + entries: entries.iter().map(|entry| TagSpec::parse(entry)).collect(), + } + } + fn rendered(doc: &str, indent: usize, ops: &[DocOp]) -> String { let borrowed: Vec<&DocOp> = ops.iter().collect(); render_doc(doc, indent, "\n", &borrowed) } + #[test] + fn only_the_rewrites_that_write_a_known_value_are_safe() { + assert!( + DocOp::RenameTag { + line: 1, + from: "@return".to_owned(), + to: "@returns".to_owned(), + } + .is_safe() + ); + assert!( + DocOp::SetModuleName { + line: 1, + name: "Renamed".to_owned(), + } + .is_safe() + ); + assert!( + DocOp::SetTagValue { + line: 1, + tag: "@constraints".to_owned(), + value: "k=7, rows=74".to_owned(), + } + .is_safe() + ); + + assert!(!tag("@description TODO", &[]).is_safe()); + assert!(!DocOp::InsertConstraints("@constraints k=?, rows=?".to_owned()).is_safe()); + assert!( + !EditKind::Insert { + offset: 0, + text: "/**\n * TODO\n */\n".to_owned(), + } + .is_safe() + ); + } + #[test] fn a_tag_goes_in_at_the_top_of_the_body() { let doc = "/**\n * @constraints k=6, rows=28\n */"; @@ -515,6 +678,55 @@ mod tests { ); } + #[test] + fn a_section_goes_in_as_a_heading_line_and_a_placeholder_under_it() { + let doc = "/**\n * @notice Privacy:\n * - Amounts stay private.\n * @dev Notation:\n * - `H(...)`: the digest.\n */"; + + assert_eq!( + rendered(doc, 0, &[section("@notice Security", &["@notice Privacy"])]), + "/**\n * @notice Privacy:\n * - Amounts stay private.\n * @notice Security:\n * TODO\n * @dev Notation:\n * - `H(...)`: the digest.\n */" + ); + } + + #[test] + fn a_section_lands_after_the_block_its_heading_names_not_the_last_of_its_tag() { + let doc = + "/**\n * @notice Privacy:\n * - Amounts.\n * @notice Scope:\n * - Notes only.\n */"; + + assert_eq!( + rendered(doc, 0, &[section("@notice Security", &["@notice Privacy"])]), + "/**\n * @notice Privacy:\n * - Amounts.\n * @notice Security:\n * TODO\n * @notice Scope:\n * - Notes only.\n */" + ); + } + + #[test] + fn listed_blocks_move_into_config_order_and_unlisted_ones_hold_their_slot() { + let doc = "/**\n * Prose first.\n * @notice Security:\n * - Nonces.\n *\n * @param {T} a - A.\n * @module Token\n * @notice Privacy:\n * - Amounts.\n */"; + + assert_eq!( + rendered( + doc, + 0, + &[reorder(&["@module", "@notice Privacy", "@notice Security"])] + ), + "/**\n * Prose first.\n * @module Token\n *\n * @param {T} a - A.\n * @notice Privacy:\n * - Amounts.\n * @notice Security:\n * - Nonces.\n */" + ); + } + + #[test] + fn a_comment_already_in_order_comes_back_unchanged() { + let doc = "/**\n * @module Token\n * @notice Privacy:\n * - Amounts.\n * @notice Security:\n * - Nonces.\n */"; + + assert_eq!( + rendered( + doc, + 0, + &[reorder(&["@module", "@notice Privacy", "@notice Security"])] + ), + doc + ); + } + #[test] fn a_single_line_doc_expands_before_a_tag_goes_in() { let doc = "/** @description Approved accounts. */"; @@ -616,7 +828,8 @@ mod tests { "export ledger a: Uint<8>;\n/**\n * @return x\n */\nexport circuit run(): [] { }\n"; let edits = [ Edit { - position: Position::file_start(), + span: Span::columns(Position::file_start(), 1), + rule: RuleId::MISSING_DOC, message: String::new(), kind: EditKind::Insert { offset: 0, diff --git a/crates/compact-lint/src/fill.rs b/crates/compact-lint/src/fill.rs index 5ee0dd6..9527352 100644 --- a/crates/compact-lint/src/fill.rs +++ b/crates/compact-lint/src/fill.rs @@ -9,19 +9,15 @@ use std::path::{Path, PathBuf}; use thiserror::Error; -use crate::config::ConfigError; +use crate::config::{Config, ConfigError}; +use crate::diagnostic::{Badge, Diagnostic, FixPreview, Level, Span}; 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::report::{Position, RuleId}; 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"; +use crate::timing::{Phase, Timings}; #[derive(Debug, Error)] pub enum FillError { @@ -48,32 +44,51 @@ pub struct Options { pub paths: Vec, pub config_path: Option, pub dry_run: bool, - /// Read the `.circuit-info.json` caches instead of compiling. + /// Read each contract's `circuit-info.json` 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, + /// `--artifacts`; none takes `constraints.artifacts` under the config's directory. + pub artifacts: Option, +} + +/// The artifacts tree, and how a file written into it spells the source it measured. +struct Artifacts { + directory: PathBuf, + /// The config's directory, which a recorded source path is relative to. + base: PathBuf, +} + +impl Artifacts { + /// The compiled file as its artifact records it: relative to the config's directory, + /// with forward slashes. + fn recorded(&self, source: &Path, cwd: &Path) -> String { + let absolute = if source.is_absolute() { + source.to_owned() + } else { + cwd.join(source) + }; + + absolute + .strip_prefix(&self.base) + .unwrap_or(&absolute) + .components() + .map(|part| part.as_os_str().to_string_lossy()) + .collect::>() + .join("/") + } } -/// The report lines and the counts behind the summary. +/// The diagnostics and the counts behind the summary. pub struct Outcome { - /// One line per change or gap, in file order. - pub lines: Vec, + pub diagnostics: Vec, pub filled: usize, + /// Files that took a value. pub files: usize, + /// Files the run read, changed or not. + pub checked: 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, @@ -86,11 +101,14 @@ struct Work { /// # 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 { +pub fn run(options: &Options, cwd: &Path, timings: &mut Timings) -> Result { + let walk = Phase::start("config and walk"); let target = target::resolve(&options.paths, options.config_path.as_deref(), cwd)?; let resolver = Resolver::new(&target.config, &target.base, cwd)?; + walk.stop(timings, format!("{} files matched", target.files.len())); let tag = target.config.constraints.tag.clone(); + let sources = Phase::start("resolve sources"); let mut linter = Linter::new()?; let mut work = Vec::new(); for path in &target.files { @@ -111,31 +129,50 @@ pub fn run(options: &Options, cwd: &Path) -> Result { }); } + sources.stop(timings, format!("{} files with annotations", work.len())); + + let artifacts = Artifacts { + directory: options + .artifacts + .clone() + .unwrap_or_else(|| target.base.join(&target.config.constraints.artifacts)), + base: target.base.clone(), + }; let measured = measure_sources( &work, options, target.config.constraints.compiler.as_ref(), cwd, + &artifacts, + timings, )?; + let rewrite = Phase::start("parse and rewrite"); let mut outcome = Outcome { - lines: Vec::new(), + diagnostics: Vec::new(), filled: 0, files: 0, + checked: target.files.len(), unmeasured: 0, }; for file in &work { - fill_file(file, &measured, &tag, options, &mut outcome)?; + fill_file(file, &measured, &tag, &target.config, options, &mut outcome)?; } + rewrite.stop( + timings, + format!("{} values in {} files", outcome.filled, outcome.files), + ); Ok(outcome) } -/// Compiles each distinct source once, or reads its cache under `--no-compile`. +/// Compiles each distinct source once, or reads its artifact under `--no-compile`. fn measure_sources( work: &[Work], options: &Options, version: Option<&String>, cwd: &Path, + artifacts: &Artifacts, + timings: &mut Timings, ) -> Result>, FillError> { let mut sources: Vec<&PathBuf> = work .iter() @@ -150,25 +187,35 @@ fn measure_sources( let compiler = Compiler { binary: options.compact_bin.clone(), version: version.cloned(), - artifacts: options.artifacts.clone(), + artifacts: artifacts.directory.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(), - })? + let phase = Phase::start(if options.no_compile { + "read artifact" + } else { + "compile" + }); + let (list, detail) = if options.no_compile { + let path = measure::locate(&artifacts.directory, source)?; + let list = measure::read_artifact(&path)?; + (list, path.display().to_string()) } else { let list = compiler.measure(source)?; - // A preview writes nothing, the cache included. + // A preview writes nothing, the artifact included. if !options.dry_run { - measure::write_cache(source, &list)?; + measure::write_artifact( + &artifacts.directory, + source, + &artifacts.recorded(source, cwd), + &list, + )?; } - list + (list, source.display().to_string()) }; + phase.stop(timings, detail); measured.insert( source.clone(), @@ -185,26 +232,46 @@ fn fill_file( file: &Work, measured: &BTreeMap>, tag: &crate::doc::Tag, + config: &Config, options: &Options, outcome: &mut Outcome, ) -> Result<(), FillError> { + let badge = if options.dry_run { + Badge::Fixable + } else { + Badge::Fixed + }; + let newline = newline_of(&file.text); + 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(); + let level = config.rules.get(RuleId::CONSTRAINTS_UNMEASURABLE); + if level != Level::Off { + outcome + .diagnostics + .push(unmeasurable(&file.path, tried, level)); + outcome.unmeasured += file.sites.len(); + } return Ok(()); } }; let constraints = measured.get(source); + let unmeasured_level = config.rules.get(RuleId::CONSTRAINTS_UNMEASURED); 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; + if unmeasured_level != Level::Off { + outcome.diagnostics.push(unmeasured( + &file.path, + site, + source, + tag, + unmeasured_level, + )); + outcome.unmeasured += 1; + } continue; }; @@ -213,14 +280,10 @@ fn fill_file( continue; } - outcome.lines.push(format!( - "{}: fill: {tag} {} -> {value}", - at(&file.path, site.position), - site.value - )); - edits.push(Edit { - position: site.position, - message: String::new(), + let edit = Edit { + span: Span::columns(site.position, tag.as_str().len()), + rule: RuleId::FILL, + message: format!("set {tag} to {value}"), kind: EditKind::Doc { range: site.doc.range.clone(), indent: site.doc.indent, @@ -230,7 +293,25 @@ fn fill_file( value, }, }, - }); + }; + + let after = apply(&file.text, std::slice::from_ref(&edit), newline); + let title = crate::fix::title(&edit, badge); + outcome.diagnostics.push( + Diagnostic::new( + file.path.clone(), + RuleId::FILL, + Level::Info, + format!( + "Circuit `{}` measures {}.", + site.circuit, + measurement.value() + ), + ) + .at(edit.span) + .fixed_by(FixPreview::between(title, &file.text, &after), badge), + ); + edits.push(edit); } if edits.is_empty() { @@ -240,58 +321,110 @@ fn fill_file( 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() +/// A tagged circuit whose source compiled but produced no measurement for its name. +fn unmeasured( + path: &Path, + site: &ConstraintSite, + source: &Path, + tag: &crate::doc::Tag, + level: Level, +) -> Diagnostic { + Diagnostic::new( + path, + RuleId::CONSTRAINTS_UNMEASURED, + level, + format!("Circuit `{}` has no measurement.", site.circuit), ) + .at(Span::columns(site.position, tag.as_str().len())) + .advise(format!( + "It is measured through {}, which exports no circuit of that name.", + source.display() + )) } -fn unmeasurable_line(path: &Path, tried: &[PathBuf]) -> String { +/// A file no template, glob or override resolves a measurement source for. +fn unmeasurable(path: &Path, tried: &[PathBuf], level: Level) -> Diagnostic { 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(), + + Diagnostic::new( + path, + RuleId::CONSTRAINTS_UNMEASURABLE, + level, + format!("No measurement source for {}.", path.display()), + ) + .at(Span::columns(Position::file_start(), 1)) + .advise(format!( + "Tried {}; add one, or name it in constraints.overrides.", 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; + use super::{Position, unmeasurable, unmeasured}; + use crate::diagnostic::Level; + use crate::doc::Tag; + use crate::rules::{ConstraintSite, DocRef}; + use std::path::{Path, PathBuf}; #[test] - fn the_summary_counts_values_files_and_gaps() { - let outcome = Outcome { - lines: Vec::new(), - filled: 12, - files: 4, - unmeasured: 1, + fn an_unmeasured_circuit_names_the_source_in_its_advice() { + let site = ConstraintSite { + circuit: "initialize".to_owned(), + position: Position { + line: 30, + column: 6, + }, + line_offset: 2, + value: "k=?, rows=?".to_owned(), + doc: DocRef { + range: 0..1, + indent: 2, + }, }; + let diagnostic = unmeasured( + Path::new("src/access/Ownable.compact"), + &site, + Path::new("src/access/test/mocks/MockOwnable.compact"), + &Tag::new("@constraints"), + Level::Warn, + ); + + assert_eq!( + diagnostic.message, + "Circuit `initialize` has no measurement." + ); + assert!( + diagnostic.advice[0].contains("MockOwnable.compact"), + "{:?}", + diagnostic.advice + ); + } + + #[test] + fn an_unmeasurable_file_lists_every_candidate_it_tried() { + let diagnostic = unmeasurable( + Path::new("src/orphan/Orphan.compact"), + &[PathBuf::from("a.compact"), PathBuf::from("b.compact")], + Level::Warn, + ); + assert_eq!( - outcome.summary(), - "12 values filled in 4 files, 1 unmeasured" + diagnostic.advice[0], + "Tried a.compact, b.compact; add one, or name it in constraints.overrides." ); } } diff --git a/crates/compact-lint/src/fix.rs b/crates/compact-lint/src/fix.rs index d8830c6..aee4599 100644 --- a/crates/compact-lint/src/fix.rs +++ b/crates/compact-lint/src/fix.rs @@ -1,16 +1,19 @@ //! The `fix` subcommand: turn the fixable issues into edits and rewrite the files. use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use thiserror::Error; use crate::config::Config; -use crate::doc::Tag; +use crate::diagnostic::{Badge, Diagnostic, FixPreview, Level, Span, capitalise}; +use crate::doc::TagSpec; 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}; +use crate::report::RuleId; +use crate::rules::{DocRef, Issue, LintError, Linter}; use crate::target::{self, TargetError}; +use crate::timing::{Phase, Timings}; /// The value written into an inserted constraints annotation. const UNMEASURED: &str = "k=?, rows=?"; @@ -44,76 +47,147 @@ pub struct Options { pub dry_run: bool, } -/// The edits one file took. -pub struct FileEdits { - pub path: PathBuf, - pub edits: Vec, -} - -/// Every file the run changed, in the order they were visited. +/// What the run changed, and the diagnostic for every edit. pub struct Outcome { - pub files: Vec, + pub diagnostics: Vec, + /// Files the run visited, changed or not. + pub checked: usize, + /// Files that took at least one edit. + pub changed: usize, } impl Outcome { #[must_use] pub fn edits(&self) -> usize { - self.files.iter().map(|file| file.edits.len()).sum() - } - - #[must_use] - pub fn summary(&self) -> String { - format!("{} edits in {} files", self.edits(), self.files.len()) + self.diagnostics.len() } } /// Runs the subcommand with `cwd` as the working directory. /// # Errors /// Returns an error when the config, the file walk, the parser or a write fails. -pub fn run(options: &Options, cwd: &Path) -> Result { +pub fn run(options: &Options, cwd: &Path, timings: &mut Timings) -> Result { + let walk = Phase::start("config and walk"); let target = target::resolve(&options.paths, options.config_path.as_deref(), cwd)?; + walk.stop(timings, format!("{} files matched", target.files.len())); + + let badge = if options.dry_run { + Badge::Fixable + } else { + Badge::Fixed + }; let mut linter = Linter::new()?; - let mut files = Vec::new(); + let mut diagnostics = Vec::new(); + let mut changed = 0; + let mut parsing = Duration::ZERO; + let mut editing = Duration::ZERO; + let mut writing = Duration::ZERO; + let mut issue_count = 0; for path in &target.files { + let started = Instant::now(); let source = std::fs::read_to_string(path).map_err(|source| FixError::Read { path: path.clone(), source, })?; let newline = newline_of(&source); - let issues = linter.issues(path, &source, &target.config, false)?; - let edits: Vec = issues + let issues = linter.issues(path, &source, &target.config)?; + parsing += started.elapsed(); + issue_count += issues.len(); + + let started = Instant::now(); + let repairs: Vec<(&Issue, Edit)> = issues .iter() .filter(|issue| !supplied_by_a_rename(issue, &issues, &target.config)) - .filter_map(|issue| edit_for(issue, &target.config, newline)) + .filter_map(|issue| edit_for(issue, &target.config, newline).map(|edit| (issue, edit))) .collect(); - if edits.is_empty() { + if repairs.is_empty() { + editing += started.elapsed(); continue; } + for (issue, edit) in &repairs { + diagnostics.push(diagnostic(path, issue, edit, &source, newline, badge)); + } + + let edits: Vec = repairs.into_iter().map(|(_, edit)| edit).collect(); + editing += started.elapsed(); + + let started = Instant::now(); if !options.dry_run { write_atomically(path, &apply(&source, &edits, newline))?; } - files.push(FileEdits { - path: path.clone(), - edits, - }); + writing += started.elapsed(); + changed += 1; } - Ok(Outcome { files }) + timings.record( + "parse and rules", + parsing, + format!("{} files, {issue_count} issues", target.files.len()), + ); + timings.record("edits", editing, format!("{} edits", diagnostics.len())); + if options.dry_run { + timings.record("dry run", writing, format!("{changed} files would change")); + } else { + timings.record("write", writing, format!("{changed} files")); + } + + Ok(Outcome { + diagnostics, + checked: target.files.len(), + changed, + }) +} + +/// The `i` line above a fix diff: what it does, and whether it is already done. +/// +/// An offered fix that writes a placeholder is `Unsafe`, the way Biome marks a fix the +/// reader still has to finish. +#[must_use] +pub fn title(edit: &Edit, badge: Badge) -> String { + let what = capitalise(&edit.message); + match badge { + Badge::Fixed => format!("Applied fix: {what}"), + Badge::Fixable if edit.kind.is_safe() => format!("Safe fix: {what}"), + Badge::Fixable => format!("Unsafe fix: {what}"), + } +} + +/// One repair as a diagnostic: the issue states the problem, the title states the fix. +fn diagnostic( + path: &Path, + issue: &Issue, + edit: &Edit, + source: &str, + newline: &str, + badge: Badge, +) -> Diagnostic { + let after = apply(source, std::slice::from_ref(edit), newline); + + Diagnostic::new(path, edit.rule, Level::Info, issue.message()) + .at(edit.span) + .fixed_by( + FixPreview::between(title(edit, badge), source, &after), + badge, + ) } /// Whether a forbidden tag in the same doc comment is renamed to the tag this /// `missing-tag` issue reports. /// -/// Inserting it as well would leave the comment carrying the tag twice. +/// Inserting it as well would leave the comment carrying the tag twice. A headed entry +/// is exempt: a rename writes a bare tag, which is not the section that is missing. fn supplied_by_a_rename(issue: &Issue, issues: &[Issue], config: &Config) -> bool { - let Issue::MissingTag { tag, doc, .. } = issue else { + let Issue::MissingTag { entry, doc, .. } = issue else { return false; }; + if entry.heading.is_some() { + return false; + } issues.iter().any(|other| { matches!( @@ -122,7 +196,7 @@ fn supplied_by_a_rename(issue: &Issue, issues: &[Issue], config: &Config) -> boo tag: forbidden, doc: other_doc, .. - } if other_doc == doc && config.rename_of(forbidden) == Some(tag) + } if other_doc == doc && config.rename_of(forbidden) == Some(&entry.tag) ) }) } @@ -133,18 +207,20 @@ fn supplied_by_a_rename(issue: &Issue, issues: &[Issue], config: &Config) -> boo /// cannot invent, and `parse` and `format` are not doc-comment issues at all. #[must_use] pub fn edit_for(issue: &Issue, config: &Config, newline: &str) -> Option { + let span = issue.span(); match issue { Issue::MissingDoc { - position, subject, kind, name, offset, indent, constraints, + .. } => Some(Edit { - position: *position, - message: format!("inserted doc skeleton for {subject}"), + span, + rule: RuleId::MISSING_DOC, + message: format!("insert a doc skeleton for {subject}"), kind: EditKind::Insert { offset: *offset, text: skeleton( @@ -159,103 +235,123 @@ pub fn edit_for(issue: &Issue, config: &Config, newline: &str) -> Option { }), Issue::MissingTag { - position, kind, - tag, + entry, name, doc, .. - } => Some(Edit { - position: *position, - message: format!("inserted {tag}"), - kind: EditKind::Doc { - range: doc.range.clone(), - indent: doc.indent, - op: missing_tag_op(*kind, tag, name.as_deref(), config), - }, - }), + } => Some(doc_edit( + span, + RuleId::MISSING_TAG, + format!("insert {entry}"), + doc, + missing_tag_op(*kind, entry, name.as_deref(), config), + )), - Issue::MissingConstraints { - position, tag, doc, .. - } => Some(Edit { - position: *position, - message: format!("inserted {tag} {UNMEASURED}"), - kind: EditKind::Doc { - range: doc.range.clone(), - indent: doc.indent, - op: DocOp::InsertConstraints(format!("{tag} {UNMEASURED}")), + Issue::TagOrder { kind, doc, .. } => Some(doc_edit( + span, + RuleId::TAG_ORDER, + "reorder the doc comment blocks".to_owned(), + doc, + DocOp::ReorderBlocks { + entries: config.kinds.get(*kind).entries().cloned().collect(), }, - }), + )), + + Issue::MissingConstraints { tag, doc, .. } => Some(doc_edit( + span, + RuleId::MISSING_CONSTRAINTS, + format!("insert {tag} {UNMEASURED}"), + doc, + DocOp::InsertConstraints(format!("{tag} {UNMEASURED}")), + )), Issue::ForbiddenTag { - position, tag, line_offset, doc, + .. } => { let replacement = config.rename_of(tag)?; - Some(Edit { - position: *position, - message: format!("renamed {tag} to {replacement}"), - kind: EditKind::Doc { - range: doc.range.clone(), - indent: doc.indent, - op: DocOp::RenameTag { - line: *line_offset, - from: tag.to_string(), - to: replacement.to_string(), - }, + Some(doc_edit( + span, + RuleId::FORBIDDEN_TAG, + format!("rename {tag} to {replacement}"), + doc, + DocOp::RenameTag { + line: *line_offset, + from: tag.to_string(), + to: replacement.to_string(), }, - }) + )) } Issue::ModuleName { - position, name, line_offset, doc, .. - } => Some(Edit { - position: *position, - message: format!("set {MODULE_TAG} to `{name}`"), - kind: EditKind::Doc { - range: doc.range.clone(), - indent: doc.indent, - op: DocOp::SetModuleName { - line: *line_offset, - name: name.clone(), - }, + } => Some(doc_edit( + span, + RuleId::MODULE_NAME, + format!("set {MODULE_TAG} to `{name}`"), + doc, + DocOp::SetModuleName { + line: *line_offset, + name: name.clone(), }, - }), + )), Issue::Parse { .. } + | Issue::UnknownSection { .. } | Issue::ConstraintsFormat { .. } | Issue::ConstraintsPlaceholder { .. } => None, } } -/// The tag line lands after the tags that precede it in config order; `@description` +/// One rewrite of the doc comment the issue points at. +fn doc_edit(span: Span, rule: RuleId, message: String, doc: &DocRef, op: DocOp) -> Edit { + Edit { + span, + rule, + message, + kind: EditKind::Doc { + range: doc.range.clone(), + indent: doc.indent, + op, + }, + } +} + +/// The block lands after the entries that precede it in template order, optional ones +/// included, so it follows whichever neighbour the comment carries; a bare `@description` /// adopts prose that opens the body. -fn missing_tag_op(kind: DeclKind, tag: &Tag, name: Option<&str>, config: &Config) -> DocOp { +fn missing_tag_op(kind: DeclKind, entry: &TagSpec, name: Option<&str>, config: &Config) -> DocOp { DocOp::InsertTag { - line: tag_line(tag, name, config), + lines: entry_lines(entry, name, config), after: config .kinds .get(kind) - .tags - .iter() - .take_while(|required| *required != tag) - .map(ToString::to_string) + .entries() + .take_while(|listed| *listed != entry) + .cloned() .collect(), - adopts_prose: tag.as_str() == DESCRIPTION_TAG, + adopts_prose: entry.heading.is_none() && entry.tag.as_str() == DESCRIPTION_TAG, } } +/// A section opens its own block, with the placeholder on the line under its heading. /// `@module` takes the declaration's own name; every other tag takes the placeholder. -fn tag_line(tag: &Tag, name: Option<&str>, config: &Config) -> String { - match name { - Some(name) if tag.as_str() == MODULE_TAG => format!("{tag} {name}"), - _ => format!("{tag} {}", config.fix.placeholder), +fn entry_lines(entry: &TagSpec, name: Option<&str>, config: &Config) -> Vec { + match (&entry.heading, name) { + (Some(heading), _) => vec![ + format!("{} {heading}:", entry.tag), + config.fix.placeholder.clone(), + ], + (None, Some(name)) if entry.tag.as_str() == MODULE_TAG => { + vec![format!("{} {name}", entry.tag)] + } + (None, _) => vec![format!("{} {}", entry.tag, config.fix.placeholder)], } } @@ -276,7 +372,7 @@ fn skeleton( .get(kind) .tags .iter() - .map(|tag| tag_line(tag, name, config)) + .flat_map(|entry| entry_lines(entry, name, config)) .collect(); if constraints { @@ -304,31 +400,61 @@ fn skeleton( format!("{}{newline}{margin}", lines.join(newline)) } -/// The line `fix` prints for one edit. -#[must_use] -pub fn line(path: &Path, position: Position, message: &str) -> String { - format!( - "{}:{}:{}: fix: {message}", - path.display(), - position.line, - position.column - ) -} - #[cfg(test)] mod tests { - use super::skeleton; + use super::{skeleton, supplied_by_a_rename}; use crate::config::Config; - use crate::doc::Tag; + use crate::doc::{Tag, TagSpec}; use crate::model::DeclKind; + use crate::rules::{Issue, Linter}; + use std::path::Path; + + /// The messages of the issues that still become edits after the rename filter. + fn kept(source: &str, config: &Config) -> Vec { + let issues = Linter::new() + .expect("the bundled grammar loads") + .issues(Path::new("a.compact"), source, config) + .expect("the source produced a tree"); + + issues + .iter() + .filter(|issue| !supplied_by_a_rename(issue, &issues, config)) + .map(Issue::message) + .collect() + } fn config() -> Config { let mut config = Config::default(); - config.kinds.module.tags = vec![Tag::new("@module"), Tag::new("@description")]; - config.kinds.circuit.tags = vec![Tag::new("@description")]; + config.kinds.module.tags = vec![TagSpec::parse("@module"), TagSpec::parse("@description")]; + config.kinds.circuit.tags = vec![TagSpec::parse("@description")]; config } + #[test] + fn a_rename_supplies_the_bare_tag_but_not_the_section_of_the_same_name() { + let source = "/**\n * @note Something.\n */\nexport circuit run(): [] { }\n"; + let mut config = Config::default(); + config.tags.forbid = vec![Tag::new("@note")]; + config + .tags + .rename + .insert(Tag::new("@note"), Tag::new("@notice")); + config.kinds.circuit.tags = vec![ + TagSpec::parse("@notice"), + TagSpec::parse("@notice Security"), + ]; + + // The bare `@notice` is gone: the rename writes it. The section is not. + assert_eq!( + kept(source, &config), + [ + "Forbidden tag @note.", + "Circuit `run` doc comment has no @constraints.", + "Circuit `run` doc comment has no `@notice Security` section.", + ] + ); + } + #[test] fn a_module_skeleton_names_the_module() { assert_eq!( @@ -337,6 +463,21 @@ mod tests { ); } + #[test] + fn a_skeleton_gives_every_section_a_heading_line_and_a_placeholder_under_it() { + let mut config = config(); + config + .kinds + .module + .tags + .push(TagSpec::parse("@notice Privacy")); + + assert_eq!( + skeleton(DeclKind::Module, Some("Token"), false, &config, 0, "\n"), + "/**\n * @module Token\n * @description TODO\n * @notice Privacy:\n * TODO\n */\n" + ); + } + #[test] fn an_exported_circuit_skeleton_carries_the_constraints_line() { assert_eq!( diff --git a/crates/compact-lint/src/format.rs b/crates/compact-lint/src/format.rs index d3a0c46..c867883 100644 --- a/crates/compact-lint/src/format.rs +++ b/crates/compact-lint/src/format.rs @@ -6,7 +6,8 @@ use std::process::Command; use thiserror::Error; -use crate::report::{Finding, Position, RuleId}; +use crate::diagnostic::{Diagnostic, Level, sentence}; +use crate::report::RuleId; /// Environment override for the `compact` binary; the `--compact-bin` flag wins over it. pub const COMPACT_BIN_ENV: &str = "COMPACT_LINT_COMPACT_BIN"; @@ -14,7 +15,11 @@ pub const COMPACT_BIN_ENV: &str = "COMPACT_LINT_COMPACT_BIN"; /// The default binary name, resolved through `PATH`. pub const DEFAULT_COMPACT_BIN: &str = "compact"; -pub const MESSAGE: &str = "not formatted; run `compact format`"; +/// The message every reported file carries; the repair is the diagnostic's advice. +pub const MESSAGE: &str = "Not formatted."; + +/// The advice every `format` diagnostic carries. +pub const ADVICE: &str = "Run compact format to fix it."; #[derive(Debug, Error)] pub enum FormatError { @@ -28,14 +33,18 @@ pub enum FormatError { }, } -/// Runs `compact format --check` once over every file and maps its report to findings. +/// Runs `compact format --check` once over every file and maps its report to diagnostics. /// /// The formatter writes one `:` line per unformatted file to stderr, followed by -/// an indented diff. An output that holds no such line yields one finding on the first +/// an indented diff. An output that holds no such line yields one diagnostic on the first /// checked file carrying the formatter's own first line. /// # Errors /// Returns an error when the `compact` binary is missing or cannot be spawned. -pub fn check(binary: &OsStr, files: &[PathBuf]) -> Result, FormatError> { +pub fn check( + binary: &OsStr, + files: &[PathBuf], + level: Level, +) -> Result, FormatError> { if files.is_empty() { return Ok(Vec::new()); } @@ -68,23 +77,21 @@ pub fn check(binary: &OsStr, files: &[PathBuf]) -> Result, FormatEr let raw = first_line(&stderr) .or_else(|| first_line(&stdout)) .unwrap_or("compact format --check failed"); - return Ok(vec![Finding::new( - files[0].clone(), - Position::file_start(), - RuleId::FORMAT, - raw.to_owned(), - )]); + return Ok(vec![ + Diagnostic::new(files[0].clone(), RuleId::FORMAT, level, sentence(raw)).advise(ADVICE), + ]); } Ok(reported .into_iter() .map(|path| { - Finding::new( + Diagnostic::new( resolve(path, files), - Position::file_start(), RuleId::FORMAT, + level, MESSAGE.to_owned(), ) + .advise(ADVICE) }) .collect()) } diff --git a/crates/compact-lint/src/lib.rs b/crates/compact-lint/src/lib.rs index 99ade4b..965645b 100644 --- a/crates/compact-lint/src/lib.rs +++ b/crates/compact-lint/src/lib.rs @@ -1,9 +1,9 @@ //! Doc-comment linter for Compact sources. //! //! The crate parses `.compact` files with the bundled tree-sitter grammar, matches each -//! declaration against the per-kind doc-comment template in `compact-lint.toml`, and -//! yields one [`rules::Issue`] per violation. `check` renders an issue as a -//! [`report::Finding`]; `fix` renders the repairable ones as an [`edit::Edit`]. +//! declaration against the per-kind doc-comment template in `compact.toml`'s `[lint]` +//! table, and yields one [`rules::Issue`] per violation. `check` renders an issue as a +//! [`diagnostic::Diagnostic`]; `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. @@ -11,6 +11,7 @@ pub mod check; pub mod config; +pub mod diagnostic; pub mod discover; pub mod doc; pub mod edit; @@ -23,3 +24,4 @@ pub mod report; pub mod rules; pub mod source; pub mod target; +pub mod timing; diff --git a/crates/compact-lint/src/main.rs b/crates/compact-lint/src/main.rs index b264b39..bd8dd19 100644 --- a/crates/compact-lint/src/main.rs +++ b/crates/compact-lint/src/main.rs @@ -3,24 +3,30 @@ #![forbid(unsafe_code)] use std::ffi::OsString; -use std::io::Write; +use std::io::{IsTerminal, Write}; use std::path::PathBuf; use std::process::ExitCode; +use std::str::FromStr; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use tempfile::TempDir; -use compact_lint::check::{self, Outcome}; +use compact_lint::check; +use compact_lint::diagnostic::{Action, Diagnostic, Level, Output, Reporter, Summary, counts}; use compact_lint::format::{COMPACT_BIN_ENV, DEFAULT_COMPACT_BIN}; -use compact_lint::{fill, fix}; +use compact_lint::timing::{Phase, Timings}; +use compact_lint::{diagnostic, fill, fix}; -/// Exit code for a run that produced findings, or a `--dry-run` that would edit. +/// Exit code for a run that produced errors, or a `--dry-run` that would edit. const EXIT_FINDINGS: u8 = 1; /// Exit code for a usage, config, IO or parser error. const EXIT_ERROR: u8 = 2; +/// Shown diagnostics per run, before `--max-diagnostics` changes it. +const DEFAULT_MAX_DIAGNOSTICS: &str = "20"; + #[derive(Debug, Parser)] #[command( name = "compact-lint", @@ -34,7 +40,7 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { - /// Check doc comments against the per-kind templates in compact-lint.toml. + /// Check doc comments against the per-kind templates in compact.toml's [lint] table. Check(CheckArgs), /// Rewrite doc comments so the rules `fix` covers stop reporting. Fix(FixArgs), @@ -42,16 +48,94 @@ enum Command { FillConstraints(FillArgs), } +/// The flags every subcommand shares for what it prints and how loudly. +#[derive(Debug, clap::Args)] +struct OutputArgs { + /// Output format. + #[arg(long, value_enum, default_value_t = Reporter::Default, value_name = "NAME")] + reporter: Reporter, + + /// Lowest level shown; the summary still counts what it hides. + #[arg(long, value_enum, default_value_t = LevelArg::Info, value_name = "LEVEL")] + diagnostic_level: LevelArg, + + /// Exit 1 when the run produced warnings but no errors. + #[arg(long)] + error_on_warnings: bool, + + /// Diagnostics shown before the rest are counted instead; `none` lifts the cap. + #[arg(long, default_value = DEFAULT_MAX_DIAGNOSTICS, value_name = "NONE|N")] + max_diagnostics: MaxDiagnostics, + + /// Colour the output; the default colours a TTY with `NO_COLOR` unset. + #[arg(long, value_enum, value_name = "WHEN")] + colors: Option, + + /// Print a per-phase wall-clock breakdown under the summary. + #[arg(long)] + timings: bool, +} + +/// The levels a reader can ask for; `off` is a rule setting, not a filter. +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +enum LevelArg { + Info, + Warn, + Error, +} + +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +enum ColorsArg { + Off, + Force, +} + +/// `--max-diagnostics`, either a cap or `none`. +#[derive(Clone, Copy, Debug)] +struct MaxDiagnostics(Option); + +impl FromStr for MaxDiagnostics { + type Err = String; + + fn from_str(text: &str) -> Result { + if text == "none" { + return Ok(Self(None)); + } + text.parse() + .map(|cap| Self(Some(cap))) + .map_err(|_| format!("expected a whole number or `none`, got {text:?}")) + } +} + +impl OutputArgs { + fn output(&self) -> Output { + Output { + reporter: self.reporter, + level: match self.diagnostic_level { + LevelArg::Info => Level::Info, + LevelArg::Warn => Level::Warn, + LevelArg::Error => Level::Error, + }, + max: self.max_diagnostics.0, + colors: match self.colors { + Some(ColorsArg::Off) => false, + Some(ColorsArg::Force) => true, + None => std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(), + }, + } + } +} + #[derive(Debug, clap::Args)] struct CheckArgs { /// Files or directories to check; defaults to the config's include globs. paths: Vec, - /// Config file to use instead of searching upward for compact-lint.toml. + /// Config file to use instead of searching upward for compact.toml. #[arg(long, value_name = "FILE")] config: Option, - /// Report `k=?` / `rows=?` placeholders in the constraints tag. + /// Report `k=?` / `rows=?` placeholders as errors instead of warnings. #[arg(long)] strict: bool, @@ -62,6 +146,9 @@ struct CheckArgs { /// Path to the `compact` binary. #[arg(long, value_name = "PATH", env = COMPACT_BIN_ENV)] compact_bin: Option, + + #[command(flatten)] + output: OutputArgs, } #[derive(Debug, clap::Args)] @@ -69,13 +156,16 @@ struct FixArgs { /// Files or directories to fix; defaults to the config's include globs. paths: Vec, - /// Config file to use instead of searching upward for compact-lint.toml. + /// Config file to use instead of searching upward for compact.toml. #[arg(long, value_name = "FILE")] config: Option, /// Report the edits without writing them; exits 1 when there are any. #[arg(long)] dry_run: bool, + + #[command(flatten)] + output: OutputArgs, } #[derive(Debug, clap::Args)] @@ -83,7 +173,7 @@ 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. + /// Config file to use instead of searching upward for compact.toml. #[arg(long, value_name = "FILE")] config: Option, @@ -91,7 +181,7 @@ struct FillArgs { #[arg(long)] dry_run: bool, - /// Read the .circuit-info.json caches instead of compiling. + /// Read each contract's circuit-info.json instead of compiling. #[arg(long)] no_compile: bool, @@ -99,9 +189,12 @@ struct FillArgs { #[arg(long, value_name = "PATH", env = COMPACT_BIN_ENV)] compact_bin: Option, - /// Directory the compiler writes to; kept after the run when given. + /// Artifacts tree to read and write; overrides constraints.artifacts. #[arg(long, value_name = "DIR")] artifacts: Option, + + #[command(flatten)] + output: OutputArgs, } fn main() -> ExitCode { @@ -136,14 +229,26 @@ fn run_check(args: CheckArgs, cwd: &std::path::Path) -> Result { .unwrap_or_else(|| OsString::from(DEFAULT_COMPACT_BIN)), }; - let outcome = check::run(&options, cwd).context("running the check")?; - emit_findings(&outcome).context("writing the report")?; - - Ok(if outcome.findings.is_empty() { - ExitCode::SUCCESS - } else { - ExitCode::from(EXIT_FINDINGS) - }) + let mut timings = Timings::default(); + let started = Instant::now(); + let outcome = check::run(&options, cwd, &mut timings).context("running the check")?; + let printed = report( + &args.output, + &outcome.diagnostics, + RunFacts { + files: outcome.files_checked, + duration: started.elapsed(), + action: Action::NoFixes, + }, + &mut timings, + ) + .context("writing the report")?; + + Ok(exit_code( + printed.errors, + printed.warnings, + args.output.error_on_warnings, + )) } fn run_fix(args: FixArgs, cwd: &std::path::Path) -> Result { @@ -153,19 +258,37 @@ fn run_fix(args: FixArgs, cwd: &std::path::Path) -> Result { dry_run: args.dry_run, }; - let outcome = fix::run(&options, cwd).context("running the fix")?; - emit_edits(&outcome).context("writing the report")?; - - Ok(if options.dry_run && outcome.edits() > 0 { - ExitCode::from(EXIT_FINDINGS) + let mut timings = Timings::default(); + let started = Instant::now(); + let outcome = fix::run(&options, cwd, &mut timings).context("running the fix")?; + let action = if options.dry_run { + Action::NoFixes } else { - ExitCode::SUCCESS - }) + Action::Fixed(outcome.changed) + }; + let printed = report( + &args.output, + &outcome.diagnostics, + RunFacts { + files: outcome.checked, + duration: started.elapsed(), + action, + }, + &mut timings, + ) + .context("writing the report")?; + + if options.dry_run && outcome.edits() > 0 { + return Ok(ExitCode::from(EXIT_FINDINGS)); + } + Ok(exit_code( + printed.errors, + printed.warnings, + args.output.error_on_warnings, + )) } 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, @@ -174,70 +297,108 @@ fn run_fill(args: FillArgs, cwd: &std::path::Path) -> Result { compact_bin: args .compact_bin .unwrap_or_else(|| OsString::from(DEFAULT_COMPACT_BIN)), - artifacts, + artifacts: args.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) - }) + let mut timings = Timings::default(); + let started = Instant::now(); + let outcome = fill::run(&options, cwd, &mut timings).context("filling the constraints")?; + let printed = report( + &args.output, + &outcome.diagnostics, + RunFacts { + files: outcome.checked, + duration: started.elapsed(), + action: Action::Filled { + values: outcome.filled, + files: outcome.files, + }, + }, + &mut timings, + ) + .context("writing the report")?; + + if outcome.unmeasured > 0 { + return Ok(ExitCode::from(EXIT_FINDINGS)); + } + Ok(exit_code( + printed.errors, + printed.warnings, + args.output.error_on_warnings, + )) } -/// 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)); - } +/// What a run did, everything the summary needs that the diagnostics do not carry. +#[derive(Clone, Copy, Debug)] +struct RunFacts { + files: usize, + duration: Duration, + action: Action, +} - let directory = tempfile::tempdir().context("creating the artifacts directory")?; - Ok((directory.path().to_owned(), Some(directory))) +/// What the report counted, for the exit code. +#[derive(Clone, Copy, Debug)] +struct Printed { + errors: usize, + warnings: usize, } -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}")?; +/// Emits the report as its own timed phase, then the breakdown when `--timings` is set. +fn report( + args: &OutputArgs, + diagnostics: &[Diagnostic], + facts: RunFacts, + timings: &mut Timings, +) -> std::io::Result { + let phase = Phase::start("render"); + let (printed, shown) = emit(args, diagnostics, facts)?; + phase.stop(timings, format!("{shown} diagnostics shown")); + + if args.timings { + writeln!(std::io::stderr().lock(), "{}", timings.render())?; } - out.flush()?; - - eprintln!("{summary}"); - Ok(()) + Ok(printed) } -fn emit_findings(outcome: &Outcome) -> std::io::Result<()> { +/// Writes the diagnostics to stdout and the truncation notice and summary to stderr. +fn emit( + args: &OutputArgs, + diagnostics: &[Diagnostic], + facts: RunFacts, +) -> std::io::Result<(Printed, usize)> { + let (errors, warnings) = counts(diagnostics); + let stdout = std::io::stdout(); let mut out = stdout.lock(); - for finding in &outcome.findings { - writeln!(out, "{finding}")?; + let rendered = args.output().render(&mut out, diagnostics)?; + + let stderr = std::io::stderr(); + let mut error_out = stderr.lock(); + if rendered.hidden > 0 { + writeln!(error_out, "{}", diagnostic::truncation(rendered.hidden))?; } - out.flush()?; + writeln!( + error_out, + "{}", + Summary { + files: facts.files, + duration: facts.duration, + action: facts.action, + errors, + warnings, + } + .render() + )?; - eprintln!("{}", outcome.summary()); - Ok(()) + Ok((Printed { errors, warnings }, rendered.shown)) } -fn emit_edits(outcome: &fix::Outcome) -> std::io::Result<()> { - let stdout = std::io::stdout(); - let mut out = stdout.lock(); - for file in &outcome.files { - for edit in &file.edits { - writeln!( - out, - "{}", - fix::line(&file.path, edit.position, &edit.message) - )?; - } +fn exit_code(errors: usize, warnings: usize, error_on_warnings: bool) -> ExitCode { + if errors > 0 || (error_on_warnings && warnings > 0) { + ExitCode::from(EXIT_FINDINGS) + } else { + ExitCode::SUCCESS } - out.flush()?; - - eprintln!("{}", outcome.summary()); - Ok(()) } /// Prints the error and every source under it, one cause per line. diff --git a/crates/compact-lint/src/measure.rs b/crates/compact-lint/src/measure.rs index 954bc13..5110448 100644 --- a/crates/compact-lint/src/measure.rs +++ b/crates/compact-lint/src/measure.rs @@ -1,8 +1,8 @@ //! 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. +//! under a pty and the transcript is parsed. Results go to the `circuit-info.json` the +//! TypeScript builder writes in a contract's artifact directory, in the same shape. use std::collections::BTreeMap; use std::ffi::{OsStr, OsString}; @@ -13,13 +13,17 @@ use std::time::{SystemTime, UNIX_EPOCH}; use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use serde::{Deserialize, Serialize}; use thiserror::Error; +use walkdir::WalkDir; -/// The measurement cache, written beside the compiled source. -pub const CACHE_FILE_NAME: &str = ".circuit-info.json"; +/// One contract's measurements, beside the compiler's own output in its artifact directory. +pub const ARTIFACT_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"]; +/// A dependency tree carries its own artifacts, so the search never descends into one. +const SKIPPED_DIR: &str = "node_modules"; + /// Compiler-output lines kept in a failure report. const TAIL_LINES: usize = 20; @@ -65,8 +69,21 @@ pub enum MeasureError { #[source] source: serde_json::Error, }, - #[error("{path} has no cached measurement for {name}; drop --no-compile to compile it")] - CacheMiss { path: PathBuf, name: String }, + #[error( + "no measurement for {file} at {path}; compile it, or run fill-constraints without --no-compile" + )] + CacheMiss { file: PathBuf, path: PathBuf }, + #[error("several artifact directories are named {stem}: {}", list(paths))] + AmbiguousArtifact { stem: String, paths: Vec }, +} + +/// Paths as one comma-separated line, for an error message. +fn list(paths: &[PathBuf]) -> String { + paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") } /// One circuit's measured constraints. @@ -84,7 +101,7 @@ impl Constraints { } } -/// One entry of the `.circuit-info.json` cache. +/// One circuit of the `circuit-info.json` list. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct Measurement { pub name: String, @@ -102,12 +119,16 @@ impl Measurement { } } -/// The `.circuit-info.json` shape, shared with the TypeScript builder. +/// 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 compiled file, as its writer spelled it; the directory, not this, locates a source. + #[serde(default)] + source: String, + #[serde(default)] + circuits: Vec, } /// The compiler's circuit list; only `proof` circuits carry constraints. @@ -146,11 +167,7 @@ impl Compiler { /// 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); + let output = self.artifacts.join(stem(source)); std::fs::create_dir_all(&output).map_err(|cause| MeasureError::Write { path: output.clone(), source: cause, @@ -250,33 +267,89 @@ fn proved_circuits(output: &Path) -> Result>, MeasureError> { )) } -/// Reads a source's entry from the cache beside it. +/// A source's artifact file, in the directory the compiler writes for it. +#[must_use] +pub fn artifact_path(artifacts: &Path, source: &Path) -> PathBuf { + artifacts.join(stem(source)).join(ARTIFACT_FILE_NAME) +} + +/// Finds a source's artifact file: its own directory first, then a search of the tree. +/// +/// A hierarchical build nests the contract directory under the subdirectory it was +/// compiled from. /// # 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); +/// Returns an error when no directory under `artifacts` holds the file, or several do. +pub fn locate(artifacts: &Path, source: &Path) -> Result { + let flat = artifact_path(artifacts, source); + if flat.is_file() { + return Ok(flat); + } + + let mut nested = search(artifacts, &stem(source)); + if nested.len() > 1 { + return Err(MeasureError::AmbiguousArtifact { + stem: stem(source), + paths: nested, + }); } + nested.pop().ok_or_else(|| MeasureError::CacheMiss { + file: source.to_owned(), + path: flat, + }) +} - let info = load_cache(&path)?; - Ok(info.files.get(&file_key(source)).cloned()) +/// Every artifact file under `artifacts` whose contract directory is named `stem`. +fn search(artifacts: &Path, stem: &str) -> Vec { + WalkDir::new(artifacts) + .follow_links(false) + .sort_by_file_name() + .into_iter() + .filter_entry(|entry| entry.depth() == 0 || entry.file_name() != SKIPPED_DIR) + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_dir() && entry.file_name() == OsStr::new(stem)) + .map(|entry| entry.path().join(ARTIFACT_FILE_NAME)) + .filter(|path| path.is_file()) + .collect() } -/// Writes a source's entry into the cache beside it, keeping every other entry. +/// Reads one contract's measurements from its artifact file. /// # 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() - }; +/// Returns an error when the file is unreadable or malformed. +pub fn read_artifact(path: &Path) -> Result, MeasureError> { + let text = std::fs::read_to_string(path).map_err(|source| MeasureError::Read { + path: path.to_owned(), + source, + })?; + let info: CircuitInfo = serde_json::from_str(&text).map_err(|source| MeasureError::Parse { + path: path.to_owned(), + source, + })?; + Ok(info.circuits) +} - info.generated_at = timestamp(SystemTime::now()); - info.files.insert(file_key(source), measured.to_vec()); +/// Writes one contract's measurements, replacing whatever the file held. +/// +/// `recorded` is the compiled file's path as the artifact spells it. +/// # Errors +/// Returns an error when the directory or the file cannot be written. +pub fn write_artifact( + artifacts: &Path, + source: &Path, + recorded: &str, + measured: &[Measurement], +) -> Result { + let path = artifact_path(artifacts, source); + let directory = path.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(directory).map_err(|source| MeasureError::Write { + path: directory.to_owned(), + source, + })?; + let info = CircuitInfo { + generated_at: timestamp(SystemTime::now()), + source: recorded.to_owned(), + circuits: measured.to_vec(), + }; let mut text = serde_json::to_string_pretty(&info).map_err(|source| MeasureError::Parse { path: path.clone(), source, @@ -289,31 +362,11 @@ pub fn write_cache(source: &Path, measured: &[Measurement]) -> Result 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 { +/// The contract directory's name: the source's file stem. +fn stem(source: &Path) -> String { source - .file_name() - .unwrap_or_default() + .file_stem() + .unwrap_or_else(|| OsStr::new("contract")) .to_string_lossy() .into_owned() } @@ -490,8 +543,8 @@ fn civil_date(days: i64) -> (i64, i64, i64) { #[cfg(test)] mod tests { use super::{ - CACHE_FILE_NAME, Measurement, civil_date, clean, parse, read_cache, tail, timestamp, - write_cache, + ARTIFACT_FILE_NAME, MeasureError, Measurement, civil_date, clean, locate, parse, + read_artifact, tail, timestamp, write_artifact, }; use std::path::Path; use std::time::{Duration, UNIX_EPOCH}; @@ -559,36 +612,90 @@ mod tests { 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"); + /// Seeds `//circuit-info.json` with one measured circuit. + fn seed(artifacts: &Path, directory: &str) { + let target = artifacts.join(directory); + std::fs::create_dir_all(&target).expect("the tree is writable"); 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", + target.join(ARTIFACT_FILE_NAME), + "{\n \"generatedAt\": \"2026-01-01T00:00:00.000Z\",\n \"source\": \"src/MockOwnable.compact\",\n \"circuits\": [{ \"name\": \"owner\", \"k\": 7, \"rows\": 74 }]\n}\n", ) - .expect("the cache is writable"); + .expect("the artifact is writable"); + } + #[test] + fn a_written_artifact_reads_back_with_the_source_it_records() { + let directory = tempfile::tempdir().expect("a temporary directory is available"); + let source = Path::new("src/access/test/mocks/MockOwnable.compact"); 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()) + let path = write_artifact( + directory.path(), + source, + "src/access/test/mocks/MockOwnable.compact", + &written, + ) + .expect("the artifact is writable"); + let text = std::fs::read_to_string(&path).expect("the artifact is readable"); + + assert_eq!(path, directory.path().join("MockOwnable/circuit-info.json")); + assert!( + text.contains("\"source\": \"src/access/test/mocks/MockOwnable.compact\""), + "{text}" ); assert_eq!( - read_cache(&directory.path().join("Other.compact")).expect("the cache is readable"), - Some(vec![measurement("run", 3, 4)]) + read_artifact(&path).expect("the artifact parses"), + written.to_vec() ); } #[test] - fn a_source_with_no_cache_entry_reads_as_absent() { + fn a_nested_contract_directory_is_found_by_its_stem() { let directory = tempfile::tempdir().expect("a temporary directory is available"); + seed(directory.path(), "access/MockOwnable"); + + let found = locate( + directory.path(), + Path::new("src/access/test/mocks/MockOwnable.compact"), + ) + .expect("the nested artifact is found"); assert_eq!( - read_cache(&directory.path().join("Gone.compact")).expect("the cache is readable"), - None + found, + directory + .path() + .join("access/MockOwnable/circuit-info.json") + ); + } + + #[test] + fn two_contract_directories_of_one_stem_are_ambiguous() { + let directory = tempfile::tempdir().expect("a temporary directory is available"); + seed(directory.path(), "access/MockOwnable"); + seed(directory.path(), "token/MockOwnable"); + + let error = locate(directory.path(), Path::new("MockOwnable.compact")) + .expect_err("the stem names two directories"); + + assert!( + matches!(error, MeasureError::AmbiguousArtifact { ref stem, ref paths } + if stem == "MockOwnable" && paths.len() == 2), + "{error}" + ); + } + + #[test] + fn a_source_with_no_artifact_names_the_path_it_tried() { + let directory = tempfile::tempdir().expect("a temporary directory is available"); + + let error = locate(directory.path(), Path::new("src/Gone.compact")) + .expect_err("nothing was compiled"); + + assert!( + error + .to_string() + .contains("Gone/circuit-info.json; compile it"), + "{error}" ); } diff --git a/crates/compact-lint/src/model.rs b/crates/compact-lint/src/model.rs index 5634f9c..d90131e 100644 --- a/crates/compact-lint/src/model.rs +++ b/crates/compact-lint/src/model.rs @@ -76,6 +76,8 @@ pub struct Declaration { pub exported: bool, pub pure: bool, pub position: Position, + /// End of the name token, or of the first token where the kind has no name. + pub end: Position, /// Byte offset of the declaration's first token, where `fix` inserts a skeleton. pub offset: usize, /// The doc comment immediately preceding the declaration, if one attaches. @@ -150,6 +152,10 @@ fn declaration(kind: DeclKind, node: Node<'_>, source: &str) -> Declaration { .map(str::to_owned); let start = node.start_position(); + let end = node + .child_by_field_name("name") + .or_else(|| node.child(0)) + .map_or_else(|| node.end_position(), |token| token.end_position()); Declaration { kind, @@ -157,6 +163,7 @@ fn declaration(kind: DeclKind, node: Node<'_>, source: &str) -> Declaration { exported, pure, position: Position::from_zero_based(start.row, start.column), + end: Position::from_zero_based(end.row, end.column), offset: node.start_byte(), doc: attached_doc(node, source), } diff --git a/crates/compact-lint/src/report.rs b/crates/compact-lint/src/report.rs index b2d9f17..9073ce5 100644 --- a/crates/compact-lint/src/report.rs +++ b/crates/compact-lint/src/report.rs @@ -1,7 +1,6 @@ -//! Findings and the one-line `path:line:col: rule: message` rendering. +//! Rule identifiers and source positions. use std::fmt; -use std::path::{Path, PathBuf}; /// A lint rule's stable identifier, as printed in the output and matched by CI greps. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -10,23 +9,34 @@ pub struct RuleId(&'static str); impl RuleId { pub const MISSING_DOC: Self = Self("missing-doc"); pub const MISSING_TAG: Self = Self("missing-tag"); + pub const UNKNOWN_SECTION: Self = Self("unknown-section"); + pub const TAG_ORDER: Self = Self("tag-order"); pub const FORBIDDEN_TAG: Self = Self("forbidden-tag"); pub const MODULE_NAME: Self = Self("module-name"); pub const MISSING_CONSTRAINTS: Self = Self("missing-constraints"); pub const CONSTRAINTS_FORMAT: Self = Self("constraints-format"); pub const CONSTRAINTS_PLACEHOLDER: Self = Self("constraints-placeholder"); + pub const CONSTRAINTS_UNMEASURED: Self = Self("constraints-unmeasured"); + pub const CONSTRAINTS_UNMEASURABLE: Self = Self("constraints-unmeasurable"); pub const PARSE: Self = Self("parse"); pub const FORMAT: Self = Self("format"); - /// Every rule this build can emit, in the order the README lists them. - pub const ALL: [Self; 9] = [ + /// A value `fill-constraints` wrote. It reports what changed, so it takes no level. + pub const FILL: Self = Self("fill"); + + /// Every rule a `[rules]` table can set a level for, in the order the README lists them. + pub const ALL: [Self; 13] = [ Self::MISSING_DOC, Self::MISSING_TAG, + Self::UNKNOWN_SECTION, + Self::TAG_ORDER, Self::FORBIDDEN_TAG, Self::MODULE_NAME, Self::MISSING_CONSTRAINTS, Self::CONSTRAINTS_FORMAT, Self::CONSTRAINTS_PLACEHOLDER, + Self::CONSTRAINTS_UNMEASURED, + Self::CONSTRAINTS_UNMEASURABLE, Self::PARSE, Self::FORMAT, ]; @@ -67,69 +77,9 @@ impl Position { } } -/// One lint violation at one source position. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Finding { - pub path: PathBuf, - pub position: Position, - pub rule: RuleId, - pub message: String, -} - -impl Finding { - pub fn new( - path: impl Into, - position: Position, - rule: RuleId, - message: String, - ) -> Self { - Self { - path: path.into(), - position, - rule, - message, - } - } - - /// Sorts findings by file, then position, then rule, so output is stable across runs. - #[must_use] - pub fn sort_key(&self) -> (&Path, Position, RuleId) { - (self.path.as_path(), self.position, self.rule) - } -} - -impl fmt::Display for Finding { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - formatter, - "{}:{}:{}: {}: {}", - self.path.display(), - self.position.line, - self.position.column, - self.rule, - self.message - ) - } -} - #[cfg(test)] mod tests { - use super::{Finding, Position, RuleId}; - - #[test] - fn finding_renders_as_path_line_col_rule_message() { - let finding = Finding::new( - "contracts/src/access/Ownable.compact", - Position::from_zero_based(41, 0), - RuleId::MISSING_DOC, - "circuit `assertOnlyOwner` has no doc comment".to_owned(), - ); - - assert_eq!( - finding.to_string(), - "contracts/src/access/Ownable.compact:42:1: missing-doc: circuit `assertOnlyOwner` has no doc comment" - ); - } + use super::RuleId; #[test] fn rule_ids_are_unique() { diff --git a/crates/compact-lint/src/rules.rs b/crates/compact-lint/src/rules.rs index 782feb6..fbee42a 100644 --- a/crates/compact-lint/src/rules.rs +++ b/crates/compact-lint/src/rules.rs @@ -1,6 +1,6 @@ //! The doc-comment rules, run over one parsed file at a time. //! -//! Detection yields [`Issue`]s. `check` renders them as [`Finding`]s and `fix` renders +//! Detection yields [`Issue`]s. `check` renders them as [`Diagnostic`]s and `fix` renders //! the fixable ones as edits, so both subcommands read the same detection pass. use std::ops::Range; @@ -10,9 +10,13 @@ use thiserror::Error; use tree_sitter::{Node, Parser}; use crate::config::{Config, DocsPolicy}; -use crate::doc::Tag; +use crate::diagnostic::{Diagnostic, Level, Span, sentence}; +use crate::doc::{Tag, TagSpec}; use crate::model::{DeclKind, Declaration, declarations}; -use crate::report::{Finding, Position, RuleId}; +use crate::report::{Position, RuleId}; + +/// The tag whose value must name the module it documents. +const MODULE_TAG: &str = "@module"; #[derive(Debug, Error)] pub enum LintError { @@ -53,11 +57,13 @@ pub struct ConstraintSite { pub enum Issue { Parse { position: Position, + end: Position, node_kind: String, missing: bool, }, MissingDoc { position: Position, + end: Position, subject: String, kind: DeclKind, name: Option, @@ -70,13 +76,29 @@ pub enum Issue { }, MissingTag { position: Position, + end: Position, subject: String, kind: DeclKind, - tag: Tag, + entry: TagSpec, /// The declaration's name, which `@module` takes as its value. name: Option, doc: DocRef, }, + UnknownSection { + position: Position, + kind: DeclKind, + tag: Tag, + heading: String, + }, + TagOrder { + position: Position, + kind: DeclKind, + /// The entry at the occurrence that breaks the order. + entry: TagSpec, + /// The entry the template orders after it, seen earlier in the comment. + previous: TagSpec, + doc: DocRef, + }, ForbiddenTag { position: Position, tag: Tag, @@ -94,6 +116,7 @@ pub enum Issue { }, MissingConstraints { position: Position, + end: Position, subject: String, tag: Tag, doc: DocRef, @@ -117,6 +140,8 @@ impl Issue { Self::Parse { position, .. } | Self::MissingDoc { position, .. } | Self::MissingTag { position, .. } + | Self::UnknownSection { position, .. } + | Self::TagOrder { position, .. } | Self::ForbiddenTag { position, .. } | Self::ModuleName { position, .. } | Self::MissingConstraints { position, .. } @@ -131,6 +156,8 @@ impl Issue { Self::Parse { .. } => RuleId::PARSE, Self::MissingDoc { .. } => RuleId::MISSING_DOC, Self::MissingTag { .. } => RuleId::MISSING_TAG, + Self::UnknownSection { .. } => RuleId::UNKNOWN_SECTION, + Self::TagOrder { .. } => RuleId::TAG_ORDER, Self::ForbiddenTag { .. } => RuleId::FORBIDDEN_TAG, Self::ModuleName { .. } => RuleId::MODULE_NAME, Self::MissingConstraints { .. } => RuleId::MISSING_CONSTRAINTS, @@ -139,9 +166,10 @@ impl Issue { } } + /// The sentence a reporter prints, capitalised and closed like Biome's. #[must_use] pub fn message(&self) -> String { - match self { + let body = match self { Self::Parse { node_kind, missing, .. } => { @@ -152,26 +180,117 @@ impl Issue { } } Self::MissingDoc { subject, .. } => format!("{subject} has no doc comment"), - Self::MissingTag { subject, tag, .. } - | Self::MissingConstraints { subject, tag, .. } => { + Self::MissingTag { subject, entry, .. } => match entry.heading { + Some(_) => format!("{subject} doc comment has no `{entry}` section"), + None => format!("{subject} doc comment has no {entry}"), + }, + Self::MissingConstraints { subject, tag, .. } => { format!("{subject} doc comment has no {tag}") } + Self::UnknownSection { tag, heading, .. } => { + format!("`{tag} {heading}` is not a section the template lists") + } + Self::TagOrder { + entry, previous, .. + } => format!( + "`{previous}` comes before `{entry}`; the template orders {} first", + entry.label() + ), Self::ForbiddenTag { tag, .. } => format!("forbidden tag {tag}"), Self::ModuleName { documented, name, .. - } => format!("@module names `{documented}`, but the module is `{name}`"), + } => format!("{MODULE_TAG} names `{documented}`, but the module is `{name}`"), Self::ConstraintsFormat { tag, value, .. } => { format!("{tag} value `{value}` is not `k=, rows=`") } Self::ConstraintsPlaceholder { tag, value, .. } => { format!("{tag} value `{value}` still holds a placeholder") } + }; + sentence(&body) + } + + /// The one-sentence `i` line telling the reader what to do about it. + #[must_use] + pub fn advice(&self, config: &Config) -> String { + match self { + Self::Parse { .. } => { + "Fix the syntax error; the doc rules are skipped for this file.".to_owned() + } + Self::MissingDoc { .. } => { + "Add a doc comment above it, or run compact-lint fix.".to_owned() + } + Self::MissingTag { entry, .. } => match &entry.heading { + Some(heading) => format!( + "Add a {} {heading}: section, or run compact-lint fix.", + entry.tag + ), + None => format!("Add {entry} to the doc comment, or run compact-lint fix."), + }, + Self::UnknownSection { kind, .. } => { + format!("Add it to kinds.{kind}.sections, or fold it into a listed section.") + } + Self::TagOrder { .. } => "Run compact-lint fix to reorder the blocks.".to_owned(), + Self::ForbiddenTag { tag, .. } => match config.rename_of(tag) { + Some(replacement) => format!("Rename it to {replacement}."), + None => "Remove it from the doc comment.".to_owned(), + }, + Self::ModuleName { name, .. } => { + format!("Set the tag to {name}, or run compact-lint fix.") + } + Self::MissingConstraints { tag, .. } => format!( + "Run compact-lint fill-constraints after adding the tag, or add it as {tag} k=?, rows=? to fill later." + ), + Self::ConstraintsFormat { .. } => "Write the value as k=, rows=.".to_owned(), + Self::ConstraintsPlaceholder { .. } => { + "Run compact-lint fill-constraints to measure it.".to_owned() + } + } + } + + /// What a reporter underlines: the declaration head, or the tag token itself. + #[must_use] + pub fn span(&self) -> Span { + match self { + Self::Parse { position, end, .. } + | Self::MissingDoc { position, end, .. } + | Self::MissingTag { position, end, .. } + | Self::MissingConstraints { position, end, .. } => Span::new(*position, *end), + Self::ForbiddenTag { position, tag, .. } + | Self::ConstraintsFormat { position, tag, .. } + | Self::ConstraintsPlaceholder { position, tag, .. } => { + Span::columns(*position, tag.as_str().len()) + } + Self::UnknownSection { + position, + tag, + heading, + .. + } => Span::columns(*position, heading_width(tag, Some(heading))), + Self::TagOrder { + position, entry, .. + } => Span::columns( + *position, + heading_width(&entry.tag, entry.heading.as_deref()), + ), + Self::ModuleName { position, .. } => Span::columns(*position, MODULE_TAG.len()), } } + /// The diagnostic for this issue, without the fix preview the caller attaches. #[must_use] - pub fn finding(&self, display: &Path) -> Finding { - Finding::new(display, self.position(), self.rule(), self.message()) + pub fn diagnostic(&self, display: &Path, level: Level, config: &Config) -> Diagnostic { + Diagnostic::new(display, self.rule(), level, self.message()) + .at(self.span()) + .advise(self.advice(config)) + } +} + +/// The tag token, and for a section the heading and the colon closing it. +fn heading_width(tag: &Tag, heading: Option<&str>) -> usize { + match heading { + Some(heading) => tag.as_str().len() + " ".len() + heading.len() + ":".len(), + None => tag.as_str().len(), } } @@ -202,7 +321,6 @@ impl Linter { display: &Path, source: &str, config: &Config, - strict: bool, ) -> Result, LintError> { let tree = self .parser @@ -211,13 +329,18 @@ impl Linter { let root = tree.root_node(); if let Some(defect) = first_defect(root) { - return Ok(vec![defect]); + return Ok(if config.rules.get(RuleId::PARSE) == Level::Off { + Vec::new() + } else { + vec![defect] + }); } let mut issues = Vec::new(); for declaration in declarations(root, source) { - check_declaration(&declaration, config, strict, &mut issues); + check_declaration(&declaration, config, &mut issues); } + issues.retain(|issue| config.rules.get(issue.rule()) != Level::Off); issues.sort_by_key(|issue| (issue.position(), issue.rule())); Ok(issues) } @@ -249,31 +372,16 @@ impl Linter { .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`. - pub fn check( - &mut self, - display: &Path, - source: &str, - config: &Config, - strict: bool, - ) -> Result, LintError> { - Ok(self - .issues(display, source, config, strict)? - .iter() - .map(|issue| issue.finding(display)) - .collect()) - } } /// The first `ERROR` or `MISSING` node in document order. fn first_defect(node: Node<'_>) -> Option { if node.is_error() || node.is_missing() { let start = node.start_position(); + let end = node.end_position(); return Some(Issue::Parse { position: Position::from_zero_based(start.row, start.column), + end: Position::from_zero_based(end.row, end.column), node_kind: node.kind().to_owned(), missing: node.is_missing(), }); @@ -287,12 +395,7 @@ fn first_defect(node: Node<'_>) -> Option { node.children(&mut cursor).find_map(first_defect) } -fn check_declaration( - declaration: &Declaration, - config: &Config, - strict: bool, - issues: &mut Vec, -) { +fn check_declaration(declaration: &Declaration, config: &Config, issues: &mut Vec) { let kind_config = config.kinds.get(declaration.kind); let requires_docs = match kind_config.docs { DocsPolicy::All => true, @@ -304,6 +407,7 @@ fn check_declaration( if requires_docs { issues.push(Issue::MissingDoc { position: declaration.position, + end: declaration.end, subject: subject(declaration), kind: declaration.kind, name: declaration.name.clone(), @@ -339,29 +443,101 @@ fn check_declaration( } // The constraints rule is fixed, so it stays outside the per-kind docs policy. - check_constraints(declaration, &doc, config, strict, issues); + check_constraints(declaration, &doc, config, issues); if !requires_docs { return; } for required in &kind_config.tags { - if !attached.comment.has(required) { + if !attached + .comment + .tags() + .iter() + .any(|occurrence| required.matches(occurrence)) + { issues.push(Issue::MissingTag { position: declaration.position, + end: declaration.end, subject: subject(declaration), kind: declaration.kind, - tag: required.clone(), + entry: required.clone(), name: declaration.name.clone(), doc: doc.clone(), }); } } + + check_sections(declaration, &doc, config, issues); +} + +/// Headings the template does not list, and the order the listed entries appear in. +fn check_sections( + declaration: &Declaration, + doc: &DocRef, + config: &Config, + issues: &mut Vec, +) { + let Some(attached) = declaration.doc.as_ref() else { + return; + }; + let kind_config = config.kinds.get(declaration.kind); + let template: Vec<&TagSpec> = kind_config.entries().collect(); + + for occurrence in attached.comment.tags() { + let Some(heading) = occurrence.heading() else { + continue; + }; + // The rule is gated per tag: a tag the config heads nowhere is left alone. + let mut headed = kind_config + .tags + .iter() + .chain(kind_config.sections.iter()) + .filter(|entry| entry.tag == occurrence.tag && entry.heading.is_some()) + .peekable(); + if headed.peek().is_none() || headed.any(|entry| entry.label() == heading) { + continue; + } + + issues.push(Issue::UnknownSection { + position: attached.tag_position(occurrence), + kind: declaration.kind, + tag: occurrence.tag.clone(), + heading: heading.to_owned(), + }); + } + + let mut highest: Option<(usize, &TagSpec)> = None; + for occurrence in attached.comment.tags() { + let Some((index, entry)) = template + .iter() + .copied() + .enumerate() + .find(|(_, entry)| entry.matches(occurrence)) + else { + continue; + }; + + match highest { + Some((top, previous)) if index < top => { + issues.push(Issue::TagOrder { + position: attached.tag_position(occurrence), + kind: declaration.kind, + entry: entry.clone(), + previous: previous.clone(), + doc: doc.clone(), + }); + return; + } + Some((top, _)) if index == top => {} + _ => highest = Some((index, entry)), + } + } } /// `@module ` must name the module it documents. fn check_module_name(declaration: &Declaration, doc: &DocRef, issues: &mut Vec) { - let tag = Tag::new("@module"); + let tag = Tag::new(MODULE_TAG); let (Some(attached), Some(name)) = (declaration.doc.as_ref(), declaration.name.as_deref()) else { return; @@ -418,7 +594,6 @@ fn check_constraints( declaration: &Declaration, doc: &DocRef, config: &Config, - strict: bool, issues: &mut Vec, ) { if !takes_constraints(declaration) { @@ -432,6 +607,7 @@ fn check_constraints( let Some(occurrence) = attached.comment.first(tag) else { issues.push(Issue::MissingConstraints { position: declaration.position, + end: declaration.end, subject: subject(declaration), tag: tag.clone(), doc: doc.clone(), @@ -451,7 +627,7 @@ fn check_constraints( return; }; - if strict && placeholders { + if placeholders { issues.push(Issue::ConstraintsPlaceholder { position, tag: tag.clone(), @@ -499,22 +675,53 @@ fn subject(declaration: &Declaration) -> String { mod tests { use super::{Linter, module_name, parse_constraints}; use crate::config::{Config, DocsPolicy}; - use crate::doc::Tag; + use crate::diagnostic::Level; + use crate::diagnostic::sentence; + use crate::doc::{Tag, TagSpec}; + use crate::report::RuleId; use std::path::Path; - fn findings(source: &str, config: &Config, strict: bool) -> Vec { + /// One `line:col rule message` line per issue, the fields every reporter starts from. + fn reported(source: &str, config: &Config) -> Vec { Linter::new() .expect("the bundled grammar loads") - .check(Path::new("a.compact"), source, config, strict) + .issues(Path::new("a.compact"), source, config) .expect("the source produced a tree") .iter() - .map(ToString::to_string) + .map(|issue| { + let span = issue.span(); + format!( + "{}:{} {} {}", + span.start.line, + span.start.column, + issue.rule(), + issue.message() + ) + }) .collect() } fn circuit_config() -> Config { let mut config = Config::default(); - config.kinds.circuit.tags = vec![Tag::new("@description")]; + config.kinds.circuit.tags = vec![TagSpec::parse("@description")]; + config + } + + /// A module template of two required sections, with an optional one between them. + fn template_config() -> Config { + let mut config = Config::default(); + config.kinds.module.docs = DocsPolicy::All; + config.kinds.module.tags = vec![ + TagSpec::parse("@module"), + TagSpec::parse("@notice Privacy"), + TagSpec::parse("@notice Security"), + ]; + config.kinds.module.sections = vec![ + TagSpec::parse("@module"), + TagSpec::parse("@notice Privacy"), + TagSpec::parse("@dev Notation"), + TagSpec::parse("@notice Security"), + ]; config } @@ -522,7 +729,7 @@ mod tests { fn a_non_exported_circuit_inside_an_exported_module_needs_no_docs() { let source = "/** @description M. */\nexport module M {\n circuit helper(): [] { }\n}\n"; - assert!(findings(source, &Config::default(), false).is_empty()); + assert!(reported(source, &Config::default()).is_empty()); } #[test] @@ -530,8 +737,8 @@ mod tests { let source = "export module M {\n}\n"; assert_eq!( - findings(source, &Config::default(), false), - ["a.compact:1:1: missing-doc: module `M` has no doc comment"] + reported(source, &Config::default()), + ["1:1 missing-doc Module `M` has no doc comment."] ); } @@ -540,8 +747,8 @@ mod tests { let source = "/** @description M. */\n// a note\nexport module M {\n}\n"; assert_eq!( - findings(source, &Config::default(), false), - ["a.compact:3:1: missing-doc: module `M` has no doc comment"] + reported(source, &Config::default()), + ["3:1 missing-doc Module `M` has no doc comment."] ); } @@ -549,7 +756,7 @@ mod tests { fn a_pure_exported_circuit_needs_no_constraints() { let source = "/** @description Adds. */\nexport pure circuit add(a: Uint<8>): Uint<8> { return a; }\n"; - assert!(findings(source, &circuit_config(), false).is_empty()); + assert!(reported(source, &circuit_config()).is_empty()); } #[test] @@ -558,8 +765,8 @@ mod tests { "/** @description Bumps. */\nexport circuit bump(): [] { count.increment(1); }\n"; assert_eq!( - findings(source, &circuit_config(), false), - ["a.compact:2:1: missing-constraints: circuit `bump` doc comment has no @constraints"] + reported(source, &circuit_config()), + ["2:1 missing-constraints Circuit `bump` doc comment has no @constraints."] ); } @@ -571,25 +778,109 @@ mod tests { config.kinds.circuit.docs = DocsPolicy::None; assert_eq!( - findings(source, &config, false), - ["a.compact:2:1: missing-constraints: circuit `bump` doc comment has no @constraints"] + reported(source, &config), + ["2:1 missing-constraints Circuit `bump` doc comment has no @constraints."] + ); + } + + #[test] + fn a_headed_entry_absent_from_the_comment_reports_the_section() { + let source = "/**\n * @module M\n * @notice Privacy:\n * - Amounts.\n */\nmodule M {\n}\n"; + + assert_eq!( + reported(source, &template_config()), + ["6:1 missing-tag Module `M` doc comment has no `@notice Security` section."] + ); + } + + #[test] + fn a_heading_the_template_does_not_list_reports_unknown_section() { + let source = "/**\n * @module M\n * @notice Privacy:\n * - Amounts.\n * @notice Security:\n * - Nonces.\n * @notice Gotchas:\n * - None.\n */\nmodule M {\n}\n"; + + assert_eq!( + reported(source, &template_config()), + ["7:4 unknown-section `@notice Gotchas` is not a section the template lists."] + ); + } + + #[test] + fn a_heading_on_a_tag_the_template_heads_nowhere_is_left_alone() { + let mut config = template_config(); + config.kinds.module.tags = vec![TagSpec::parse("@description")]; + config.kinds.module.sections = Vec::new(); + let source = + "/**\n * @description A note-based token: amounts stay private.\n */\nmodule M {\n}\n"; + + assert!(reported(source, &config).is_empty()); + } + + #[test] + fn listed_entries_out_of_config_order_report_tag_order_once() { + let source = "/**\n * @module M\n * @notice Security:\n * - Nonces.\n * @notice Privacy:\n * - Amounts.\n * @dev Notation:\n * - `H`.\n */\nmodule M {\n}\n"; + + assert_eq!( + reported(source, &template_config()), + [ + "5:4 tag-order `@notice Security` comes before `@notice Privacy`; the template orders Privacy first." + ] ); } #[test] - fn a_placeholder_is_a_finding_only_under_strict() { + fn a_tag_no_entry_lists_is_ignored_by_the_order_check() { + let source = "/**\n * @module M\n * @notice Privacy:\n * - Amounts.\n * @param {T} a - A.\n * @notice Security:\n * - Nonces.\n */\nmodule M {\n}\n"; + + assert!(reported(source, &template_config()).is_empty()); + } + + #[test] + fn a_declaration_span_ends_at_its_name() { + let source = "export module M {\n}\n"; + let issues = Linter::new() + .expect("the bundled grammar loads") + .issues(Path::new("a.compact"), source, &Config::default()) + .expect("the source produced a tree"); + + assert_eq!(issues[0].span().end.column, "export module M".len() + 1); + } + + #[test] + fn a_tag_span_covers_the_tag_token() { + let source = "/**\n * @return nothing\n */\ncircuit hidden(): [] { }\n"; + let mut config = Config::default(); + config.tags.forbid = vec![Tag::new("@return")]; + + let issues = Linter::new() + .expect("the bundled grammar loads") + .issues(Path::new("a.compact"), source, &config) + .expect("the source produced a tree"); + let span = issues[0].span(); + + assert_eq!(span.start.column, 4); + assert_eq!(span.end.column, 11); + } + + #[test] + fn a_placeholder_is_reported_without_strict_too() { let source = "/**\n * @description Bumps.\n * @constraints k=?, rows=?\n */\nexport circuit bump(): [] { }\n"; - let config = circuit_config(); - assert!(findings(source, &config, false).is_empty()); assert_eq!( - findings(source, &config, true), + reported(source, &circuit_config()), [ - "a.compact:3:4: constraints-placeholder: @constraints value `k=?, rows=?` still holds a placeholder" + "3:4 constraints-placeholder @constraints value `k=?, rows=?` still holds a placeholder." ] ); } + #[test] + fn a_rule_set_to_off_produces_no_issue() { + let source = "export module M {\n}\n"; + let mut config = Config::default(); + config.rules.missing_doc = Level::Off; + + assert!(reported(source, &config).is_empty()); + } + #[test] fn a_forbidden_tag_is_reported_even_where_docs_are_optional() { let source = "/**\n * @return nothing\n */\ncircuit hidden(): [] { }\n"; @@ -597,11 +888,29 @@ mod tests { config.tags.forbid = vec![Tag::new("@return")]; assert_eq!( - findings(source, &config, false), - ["a.compact:2:4: forbidden-tag: forbidden tag @return"] + reported(source, &config), + ["2:4 forbidden-tag Forbidden tag @return."] ); } + #[test] + fn a_rename_becomes_the_advice_for_a_forbidden_tag() { + let source = "/**\n * @return nothing\n */\ncircuit hidden(): [] { }\n"; + let mut config = Config::default(); + config.tags.forbid = vec![Tag::new("@return")]; + config + .tags + .rename + .insert(Tag::new("@return"), Tag::new("@returns")); + + let issues = Linter::new() + .expect("the bundled grammar loads") + .issues(Path::new("a.compact"), source, &config) + .expect("the source produced a tree"); + + assert_eq!(issues[0].advice(&config), "Rename it to @returns."); + } + #[test] fn a_witness_under_docs_all_is_checked_without_export() { let source = "witness wit_secret(): Bytes<32>;\n"; @@ -609,8 +918,8 @@ mod tests { config.kinds.witness.docs = DocsPolicy::All; assert_eq!( - findings(source, &config, false), - ["a.compact:1:1: missing-doc: witness `wit_secret` has no doc comment"] + reported(source, &config), + ["1:1 missing-doc Witness `wit_secret` has no doc comment."] ); } @@ -618,9 +927,18 @@ mod tests { fn a_parse_defect_suppresses_the_doc_rules() { let source = "export module M {\nexport circuit\n"; - let reported = findings(source, &Config::default(), false); - assert_eq!(reported.len(), 1, "{reported:?}"); - assert!(reported[0].contains("parse error"), "{reported:?}"); + let issues = reported(source, &Config::default()); + assert_eq!(issues.len(), 1, "{issues:?}"); + assert!(issues[0].contains("Parse error"), "{issues:?}"); + } + + #[test] + fn parse_set_to_off_leaves_a_broken_file_silent() { + let source = "export module M {\nexport circuit\n"; + let mut config = Config::default(); + config.rules.parse = Level::Off; + + assert!(reported(source, &config).is_empty()); } #[test] @@ -629,7 +947,7 @@ mod tests { let mut config = circuit_config(); config.constraints.tag = Tag::new("@circuitInfo"); - assert!(findings(source, &config, false).is_empty()); + assert!(reported(source, &config).is_empty()); } #[test] @@ -647,4 +965,21 @@ mod tests { assert_eq!(module_name("Utils."), "Utils"); assert_eq!(module_name("ShieldedToken (archived)"), "ShieldedToken"); } + + #[test] + fn a_message_opens_with_a_capital_and_closes_with_one_period() { + assert_eq!(sentence("forbidden tag @return"), "Forbidden tag @return."); + assert_eq!(sentence("@module names `A`."), "@module names `A`."); + } + + #[test] + fn every_configurable_rule_has_a_level() { + let rules = Config::default().rules; + + assert!( + RuleId::ALL + .iter() + .all(|rule| rules.get(*rule) != Level::Info) + ); + } } diff --git a/crates/compact-lint/src/timing.rs b/crates/compact-lint/src/timing.rs new file mode 100644 index 0000000..e24e108 --- /dev/null +++ b/crates/compact-lint/src/timing.rs @@ -0,0 +1,217 @@ +//! The per-phase wall-clock breakdown `--timings` prints. +//! +//! A phase is one span of a run the reader can act on: the file walk, the rules, the +//! `compact format --check` subprocess. Each carries a detail column naming what it +//! worked on, so a slow phase says why it was slow. + +use std::fmt::Write as _; +use std::time::{Duration, Instant}; + +use crate::diagnostic::duration; + +/// The block's first line. +const HEADING: &str = "Timings"; + +/// The name of the closing row. +const TOTAL: &str = "total"; + +/// Indentation under the heading. +const INDENT: &str = " "; + +/// Separator between columns. +const GAP: &str = " "; + +/// Width of the share column, sized for `100.0%`. +const SHARE_WIDTH: usize = 6; + +/// A phase being measured, recorded where it ends. +#[derive(Debug)] +pub struct Phase { + name: &'static str, + started: Instant, +} + +impl Phase { + /// Opens a phase named `name`. + #[must_use] + pub fn start(name: &'static str) -> Self { + Self { + name, + started: Instant::now(), + } + } + + /// Ends the phase now and records it. + pub fn stop(self, timings: &mut Timings, detail: impl Into) { + timings.record(self.name, self.started.elapsed(), detail); + } +} + +/// One recorded phase. +#[derive(Debug)] +struct Measured { + name: &'static str, + elapsed: Duration, + detail: String, +} + +/// Every phase of one run, in the order it was recorded. +#[derive(Debug, Default)] +pub struct Timings { + phases: Vec, +} + +impl Timings { + /// Records a phase the caller timed itself, for work spread over a loop. + pub fn record(&mut self, name: &'static str, elapsed: Duration, detail: impl Into) { + self.phases.push(Measured { + name, + elapsed, + detail: detail.into(), + }); + } + + /// Renders the block, longest phase first and the total last. + /// + /// The total is the sum of the phases, not the run's wall clock, so a phase nobody + /// measured is missing from both. + #[must_use] + pub fn render(&self) -> String { + let total: Duration = self.phases.iter().map(|phase| phase.elapsed).sum(); + + let mut rows: Vec<(&Measured, String)> = self + .phases + .iter() + .map(|phase| (phase, duration(phase.elapsed))) + .collect(); + // Sorting is stable, so phases of equal length stay in the order they ran. + rows.sort_by_key(|(phase, _)| std::cmp::Reverse(phase.elapsed)); + + let total_elapsed = duration(total); + let name_width = rows + .iter() + .map(|(phase, _)| phase.name.len()) + .chain([TOTAL.len()]) + .max() + .unwrap_or_default(); + // Padding counts characters, so a microsecond figure must be measured the same way. + let elapsed_width = rows + .iter() + .map(|(_, elapsed)| elapsed.chars().count()) + .chain([total_elapsed.chars().count()]) + .max() + .unwrap_or_default(); + + let mut out = HEADING.to_owned(); + for (phase, elapsed) in &rows { + let line = format!( + "{INDENT}{name:elapsed_width$}{GAP}{share:>SHARE_WIDTH$}{GAP}{detail}", + name = phase.name, + share = share(phase.elapsed, total), + detail = phase.detail, + ); + let _ = write!(out, "\n{}", line.trim_end()); + } + let _ = write!( + out, + "\n{INDENT}{TOTAL:elapsed_width$}" + ); + out + } +} + +/// A phase's share of the total, `0.0%` for a run that measured nothing. +fn share(elapsed: Duration, total: Duration) -> String { + let share = if total.is_zero() { + 0.0 + } else { + elapsed.as_secs_f64() / total.as_secs_f64() * 100.0 + }; + format!("{share:.1}%") +} + +#[cfg(test)] +mod tests { + use super::{Phase, Timings}; + use std::time::Duration; + + fn timings() -> Timings { + let mut timings = Timings::default(); + timings.record( + "config and walk", + Duration::from_millis(1), + "39 files matched", + ); + timings.record( + "parse and rules", + Duration::from_millis(40), + "39 files, 512 issues", + ); + timings.record( + "format check", + Duration::from_millis(3_200), + "compact format --check, 39 files, 1 process", + ); + timings + } + + #[test] + fn phases_render_longest_first_with_the_total_last() { + let rendered = timings().render(); + + let names: Vec<&str> = rendered + .lines() + .skip(1) + .filter_map(|line| line.split_whitespace().next()) + .collect(); + assert_eq!(names, ["format", "parse", "config", "total"]); + } + + #[test] + fn the_columns_line_up_and_the_shares_sum_to_the_whole() { + assert_eq!( + timings().render(), + "Timings\n\ + \x20 format check 3.2s 98.7% compact format --check, 39 files, 1 process\n\ + \x20 parse and rules 40ms 1.2% 39 files, 512 issues\n\ + \x20 config and walk 1ms 0.0% 39 files matched\n\ + \x20 total 3.2s" + ); + } + + #[test] + fn a_microsecond_phase_keeps_the_columns_aligned() { + let mut timings = Timings::default(); + timings.record("parse and rules", Duration::from_millis(52), "39 files"); + timings.record("render", Duration::from_micros(299), "20 diagnostics shown"); + + assert_eq!( + timings.render(), + "Timings\n\ + \x20 parse and rules 52ms 99.4% 39 files\n\ + \x20 render 299\u{b5}s 0.6% 20 diagnostics shown\n\ + \x20 total 52ms" + ); + } + + #[test] + fn a_phase_without_a_detail_leaves_no_trailing_space() { + let mut timings = Timings::default(); + timings.record("render", Duration::from_millis(2), ""); + + assert_eq!( + timings.render(), + "Timings\n render 2ms 100.0%\n total 2ms" + ); + } + + #[test] + fn a_run_that_measured_nothing_renders_a_zero_total() { + let mut timings = Timings::default(); + Phase::start("config and walk").stop(&mut timings, "0 files matched"); + + let rendered = timings.render(); + assert!(rendered.starts_with("Timings\n"), "{rendered}"); + assert!(rendered.contains("0 files matched"), "{rendered}"); + } +} diff --git a/crates/compact-lint/tests/check.rs b/crates/compact-lint/tests/check.rs index c4fc99f..ff75112 100644 --- a/crates/compact-lint/tests/check.rs +++ b/crates/compact-lint/tests/check.rs @@ -1,13 +1,15 @@ //! `compact-lint check` end to end, one fixture case per rule. //! -//! Every case runs with `--no-format`, because CI has no `compact` binary. +//! Every case runs with `--no-format`, because CI has no `compact` binary, and with +//! `--colors=off`, so the expectation holds whatever the runner's terminal is. +use std::fmt::Write as _; use std::path::PathBuf; use std::process::Command; use assert_cmd::prelude::*; -/// Exit code for a run that produced findings. +/// Exit code for a run that produced errors. const EXIT_FINDINGS: i32 = 1; /// Exit code for a usage, config, IO or parser error. @@ -26,15 +28,15 @@ fn binary() -> Command { Command::cargo_bin("compact-lint").expect("the binary is built alongside this test") } -fn expected(name: &str) -> String { - std::fs::read_to_string(case(name).join("expected.txt")).expect("the case has an expectation") +fn expected(name: &str, file: &str) -> String { + std::fs::read_to_string(case(name).join(file)).expect("the case has an expectation") } -/// Runs `check` inside a case directory and returns its exit code and stdout. -fn run(name: &str, extra: &[&str]) -> (i32, String) { +/// Runs `check` inside a case directory and returns its exit code, stdout and stderr. +fn run(name: &str, extra: &[&str]) -> (i32, String, String) { let output = binary() .current_dir(case(name)) - .args(["check", "--no-format"]) + .args(["check", "--no-format", "--colors=off"]) .args(extra) .output() .expect("the binary ran"); @@ -42,19 +44,51 @@ fn run(name: &str, extra: &[&str]) -> (i32, String) { ( output.status.code().expect("the binary was not signalled"), String::from_utf8(output.stdout).expect("the report is UTF-8"), + String::from_utf8(output.stderr).expect("the summary is UTF-8"), ) } +/// Replaces the measured duration, the one part of the summary a run cannot repeat. +fn without_duration(stderr: &str) -> String { + let mut out = String::new(); + for line in stderr.lines() { + match line + .split_once(" in ") + .filter(|_| line.starts_with("Checked ")) + { + Some((head, tail)) => match tail.split_once(". ") { + Some((_, rest)) => { + let _ = write!(out, "{head} in . {rest}"); + } + None => out.push_str(line), + }, + None => out.push_str(line), + } + out.push('\n'); + } + out +} + fn assert_case(name: &str, extra: &[&str], code: i32) { - let (actual_code, stdout) = run(name, extra); + assert_expectation(name, "expected.txt", extra, code); +} + +fn assert_expectation(name: &str, file: &str, extra: &[&str], code: i32) { + let (actual_code, stdout, _) = run(name, extra); - assert_eq!(stdout, expected(name), "stdout for case {name}"); + assert_eq!(stdout, expected(name, file), "stdout for case {name}"); assert_eq!(actual_code, code, "exit code for case {name}"); } #[test] fn a_fully_documented_module_reports_nothing() { assert_case("clean", &[], 0); + + let (_, _, stderr) = run("clean", &[]); + assert_eq!( + without_duration(&stderr), + "Checked 1 file in . No fixes applied.\n" + ); } #[test] @@ -67,6 +101,30 @@ fn a_doc_comment_without_a_required_tag_reports_missing_tag() { assert_case("missing-tag", &[], EXIT_FINDINGS); } +#[test] +fn a_doc_comment_without_a_required_section_names_it_in_the_finding() { + assert_case("sections-missing", &[], EXIT_FINDINGS); +} + +#[test] +fn a_heading_the_template_does_not_list_warns_only_where_the_tag_has_headings() { + assert_case("unknown-section", &[], 0); + + let (_, stdout, stderr) = run("unknown-section", &[]); + assert!(!stdout.contains("@description"), "{stdout}"); + assert!(stderr.ends_with("Found 1 warning.\n"), "{stderr}"); +} + +#[test] +fn listed_entries_out_of_template_order_report_tag_order() { + assert_case("tag-order", &[], EXIT_FINDINGS); +} + +#[test] +fn a_module_following_the_example_template_reports_nothing() { + assert_case("template-clean", &[], 0); +} + #[test] fn a_forbidden_tag_reports_wherever_it_appears() { assert_case("forbidden-tag", &[], EXIT_FINDINGS); @@ -88,12 +146,17 @@ fn a_malformed_constraints_value_reports_constraints_format() { } #[test] -fn a_placeholder_reports_only_under_strict() { - let (code, stdout) = run("constraints-placeholder", &[]); - assert_eq!(stdout, ""); - assert_eq!(code, 0); +fn a_placeholder_is_a_warning_until_strict_makes_it_an_error() { + assert_case("constraints-placeholder", &[], 0); + assert_expectation( + "constraints-placeholder", + "expected-strict.txt", + &["--strict"], + EXIT_FINDINGS, + ); - assert_case("constraints-placeholder", &["--strict"], EXIT_FINDINGS); + let (_, _, stderr) = run("constraints-placeholder", &[]); + assert!(stderr.ends_with("Found 1 warning.\n"), "{stderr}"); } #[test] @@ -101,17 +164,100 @@ fn a_broken_file_reports_parse_instead_of_crashing() { assert_case("parse", &[], EXIT_FINDINGS); } +#[test] +fn a_rule_set_to_off_is_silent_and_one_set_to_warn_does_not_fail_the_run() { + assert_case("rules-levels", &[], 0); + + let (_, stdout, stderr) = run("rules-levels", &[]); + assert!(!stdout.contains("missing-doc"), "{stdout}"); + assert!(stderr.ends_with("Found 1 warning.\n"), "{stderr}"); +} + +#[test] +fn the_concise_reporter_prints_one_line_per_diagnostic() { + assert_expectation( + "reporter-concise", + "expected.txt", + &["--reporter=concise"], + EXIT_FINDINGS, + ); +} + +#[test] +fn the_github_reporter_prints_workflow_commands() { + assert_expectation( + "reporter-github", + "expected.txt", + &["--reporter=github"], + EXIT_FINDINGS, + ); +} + +#[test] +fn the_cap_shows_the_first_diagnostics_and_counts_the_rest() { + assert_expectation( + "max-diagnostics", + "expected.txt", + &["--max-diagnostics=1"], + EXIT_FINDINGS, + ); + + let (_, _, stderr) = run("max-diagnostics", &["--max-diagnostics=1"]); + assert!( + stderr.starts_with("The number of diagnostics exceeds"), + "{stderr}" + ); + assert!(stderr.contains("Diagnostics not shown: 2.\n"), "{stderr}"); + + let (_, _, lifted) = run("max-diagnostics", &["--max-diagnostics=none"]); + assert!(!lifted.contains("Diagnostics not shown"), "{lifted}"); +} + +#[test] +fn timings_print_a_phase_breakdown_under_the_summary_only_when_asked() { + let (_, _, stderr) = run("clean", &["--timings"]); + assert!(stderr.contains("\nTimings\n"), "{stderr}"); + assert!(stderr.contains("parse and rules"), "{stderr}"); + assert!(stderr.contains("\n total"), "{stderr}"); + + let (_, _, quiet) = run("clean", &[]); + assert!(!quiet.contains("Timings"), "{quiet}"); +} + +#[test] +fn error_on_warnings_fails_a_run_that_only_warned() { + assert_case("error-on-warnings", &["--error-on-warnings"], EXIT_FINDINGS); + assert_case("error-on-warnings", &[], 0); +} + +#[test] +fn the_diagnostic_level_hides_warnings_without_dropping_them_from_the_summary() { + assert_expectation( + "diagnostic-level", + "expected.txt", + &["--diagnostic-level=error"], + EXIT_FINDINGS, + ); + + let (_, stdout, stderr) = run("diagnostic-level", &["--diagnostic-level=error"]); + assert!(!stdout.contains("constraints-placeholder"), "{stdout}"); + assert!( + stderr.ends_with("Found 1 error.\nFound 1 warning.\n"), + "{stderr}" + ); +} + #[test] fn an_explicit_path_replaces_the_include_globs() { let output = binary() .current_dir(case("missing-doc")) - .args(["check", "--no-format", "Gaps.compact"]) + .args(["check", "--no-format", "--colors=off", "Gaps.compact"]) .output() .expect("the binary ran"); assert_eq!( String::from_utf8(output.stdout).expect("the report is UTF-8"), - expected("missing-doc") + expected("missing-doc", "expected.txt") ); } @@ -119,30 +265,30 @@ fn an_explicit_path_replaces_the_include_globs() { fn an_exclude_glob_applies_to_an_explicit_path_given_from_a_subdirectory() { let output = binary() .current_dir(case("exclude-explicit-dir").join("contracts")) - .args(["check", "--no-format", "live", "archive"]) + .args(["check", "--no-format", "--colors=off", "live", "archive"]) .output() .expect("the binary ran"); assert_eq!( String::from_utf8(output.stdout).expect("the report is UTF-8"), - expected("exclude-explicit-dir") + expected("exclude-explicit-dir", "expected.txt") ); assert_eq!(output.status.code(), Some(EXIT_FINDINGS)); } #[test] fn an_explicit_config_anchors_discovery_at_the_working_directory() { - let config = case("clean").join("compact-lint.toml"); + let config = case("clean").join("compact.toml"); let output = binary() .current_dir(case("missing-doc")) - .args(["check", "--no-format", "--config"]) + .args(["check", "--no-format", "--colors=off", "--config"]) .arg(&config) .output() .expect("the binary ran"); assert_eq!( String::from_utf8(output.stdout).expect("the report is UTF-8"), - expected("missing-doc") + expected("missing-doc", "expected.txt") ); } @@ -159,11 +305,19 @@ fn a_missing_compact_binary_exits_two_and_names_the_escape_hatch() { assert!(stderr.contains("--no-format"), "{stderr}"); } +#[test] +fn a_config_holding_only_another_tool_tables_exits_two() { + let (code, _, stderr) = run("no-lint-table", &[]); + + assert_eq!(code, EXIT_ERROR, "{stderr}"); + assert!(stderr.contains("has no [lint] table"), "{stderr}"); +} + #[test] fn an_unreadable_config_exits_two() { let output = binary() .current_dir(case("clean")) - .args(["check", "--no-format", "--config", "compact-lint.toml.nope"]) + .args(["check", "--no-format", "--config", "compact.toml.nope"]) .output() .expect("the binary ran"); @@ -171,17 +325,15 @@ fn an_unreadable_config_exits_two() { } #[test] -fn the_summary_goes_to_stderr_and_the_findings_to_stdout() { - let output = binary() - .current_dir(case("module-name")) - .args(["check", "--no-format"]) - .output() - .expect("the binary ran"); +fn the_summary_goes_to_stderr_and_the_diagnostics_to_stdout() { + let (_, stdout, stderr) = run("module-name", &[]); - let stderr = String::from_utf8(output.stderr).expect("the summary is UTF-8"); - assert_eq!(stderr.trim_end(), "1 findings in 1 files"); + assert_eq!( + without_duration(&stderr), + "Checked 1 file in . No fixes applied.\nFound 1 error.\n" + ); assert!( - String::from_utf8_lossy(&output.stdout).contains("module-name"), + stdout.contains("lint/module-name"), "the rule id belongs on stdout" ); } diff --git a/crates/compact-lint/tests/fill.rs b/crates/compact-lint/tests/fill.rs index 8c8cbcb..0ba93ba 100644 --- a/crates/compact-lint/tests/fill.rs +++ b/crates/compact-lint/tests/fill.rs @@ -45,8 +45,8 @@ fn work(name: &str) -> TempDir { let root = case(name); std::fs::copy( - root.join("compact-lint.toml"), - directory.path().join("compact-lint.toml"), + root.join("compact.toml"), + directory.path().join("compact.toml"), ) .expect("the case has a config"); copy_tree(&root.join("before"), directory.path()); @@ -93,15 +93,15 @@ fn collect(root: &Path, directory: &Path, out: &mut BTreeMap>) } } -fn expected(name: &str) -> String { - std::fs::read_to_string(case(name).join("expected.txt")).expect("the case has an expectation") +fn expected(name: &str, file: &str) -> String { + std::fs::read_to_string(case(name).join(file)).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"]) + .args(["fill-constraints", "--colors=off", "--compact-bin"]) .arg(fake_compact()) .args(extra) .output() @@ -120,6 +120,7 @@ fn run(directory: &Path, args: &[&str]) -> (i32, String) { let output = binary() .current_dir(directory) .args(args) + .arg("--colors=off") .output() .expect("the binary ran"); code_and_stdout(&output) @@ -127,13 +128,18 @@ fn run(directory: &Path, args: &[&str]) -> (i32, String) { /// 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) { +/// A case holds `expected.txt` for the write run, `expected-settled.txt` for what a +/// second run still reports, and `expected-dry-run.txt` for the preview. +fn assert_case(name: &str, extra: &[&str], exit: i32) { 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!( + stdout, + expected(name, "expected.txt"), + "stdout for case {name}" + ); assert_eq!(code, exit, "exit code for case {name}"); assert_eq!( sources(root), @@ -146,7 +152,11 @@ fn assert_case(name: &str, extra: &[&str], exit: i32, settled: &str) { 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!( + stdout, + expected(name, "expected-settled.txt"), + "a second run on case {name}" + ); assert_eq!(code, exit, "second run exit code for case {name}"); let preview = work(name); @@ -154,7 +164,11 @@ fn assert_case(name: &str, extra: &[&str], exit: i32, settled: &str) { 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!( + stdout, + expected(name, "expected-dry-run.txt"), + "dry-run stdout for case {name}" + ); assert_eq!(code, exit, "dry-run exit code for case {name}"); assert_eq!( sources(preview.path()), @@ -165,28 +179,22 @@ fn assert_case(name: &str, extra: &[&str], exit: i32, settled: &str) { #[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")); + assert_case("basic", &[], EXIT_UNMEASURED); } #[test] fn a_preset_matching_a_self_glob_compiles_itself() { - assert_case("self", &[], 0, ""); + assert_case("self", &[], 0); } #[test] fn an_override_names_the_contract_that_measures_a_file() { - assert_case("override", &[], 0, ""); + assert_case("override", &[], 0); } #[test] -fn a_seeded_cache_fills_without_a_compiler() { - assert_case("no-compile", &["--no-compile"], 0, ""); +fn a_seeded_artifact_fills_without_a_compiler() { + assert_case("no-compile", &["--no-compile"], 0); } #[test] @@ -195,10 +203,13 @@ fn a_circuit_the_report_marks_unproved_stays_unmeasured() { let (_, stdout) = code_and_stdout(&fill(directory.path(), &[])); assert!( - stdout.contains("circuit `initialize` has no measurement"), + stdout.contains("Circuit `initialize` has no measurement."), + "{stdout}" + ); + assert!( + !stdout.contains("Set @constraints to k=5, rows=10"), "{stdout}" ); - assert!(!stdout.contains("k=5, rows=10"), "{stdout}"); } #[test] @@ -209,7 +220,7 @@ fn a_file_with_no_measurement_source_reports_once_and_exits_one() { let (code, stdout) = code_and_stdout(&fill(root, &[])); - assert_eq!(stdout, expected("missing-source")); + assert_eq!(stdout, expected("missing-source", "expected.txt")); assert_eq!(code, EXIT_UNMEASURED); assert_eq!(sources(root), before); } @@ -227,44 +238,100 @@ fn a_failed_compile_exits_two_with_the_output_tail() { } #[test] -fn a_missing_cache_entry_exits_two_and_names_the_flag() { +fn a_missing_artifact_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("artifacts/MockOwnable/circuit-info.json"), + "{stderr}" + ); assert!(stderr.contains("--no-compile"), "{stderr}"); } #[test] -fn a_compile_writes_the_cache_the_builder_shares() { +fn a_compile_writes_the_artifact_circuit_info() { let directory = work("override"); fill(directory.path(), &[]); - let cache = std::fs::read_to_string( + let artifact = std::fs::read_to_string( + directory + .path() + .join("artifacts/MockUtilities/circuit-info.json"), + ) + .expect("the artifact was written"); + + assert!(artifact.contains("\"generatedAt\""), "{artifact}"); + assert!( + artifact.contains("\"source\": \"src/utils/test/mocks/MockUtilities.compact\""), + "{artifact}" + ); + assert!(artifact.contains("\"rows\": 310"), "{artifact}"); + assert!( directory .path() - .join("src/utils/test/mocks/.circuit-info.json"), + .join("artifacts/MockUtilities/compiler/contract-info.json") + .is_file(), + "the compiler's own report sits beside it" + ); +} + +#[test] +fn a_hierarchical_artifact_tree_is_found_by_stem() { + let directory = work("no-compile"); + let artifacts = directory.path().join("artifacts"); + std::fs::create_dir_all(artifacts.join("access")).expect("the tree is writable"); + std::fs::rename( + artifacts.join("MockOwnable"), + artifacts.join("access/MockOwnable"), ) - .expect("the cache was written"); + .expect("the contract directory is movable"); - assert!(cache.contains("\"generatedAt\""), "{cache}"); - assert!(cache.contains("\"MockUtilities.compact\""), "{cache}"); - assert!(cache.contains("\"rows\": 310"), "{cache}"); + let (code, stdout) = code_and_stdout(&fill(directory.path(), &["--no-compile"])); + + assert_eq!(stdout, expected("no-compile", "expected.txt")); + assert_eq!(code, 0); +} + +#[test] +fn two_artifacts_with_one_stem_are_an_error() { + let directory = work("no-compile"); + let artifacts = directory.path().join("artifacts"); + for subdirectory in ["access", "token"] { + let target = artifacts.join(subdirectory).join("MockOwnable"); + std::fs::create_dir_all(&target).expect("the tree is writable"); + std::fs::copy( + artifacts.join("MockOwnable/circuit-info.json"), + target.join("circuit-info.json"), + ) + .expect("the artifact is copyable"); + } + std::fs::remove_dir_all(artifacts.join("MockOwnable")).expect("the tree is writable"); + + 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("named MockOwnable"), "{stderr}"); + assert!(stderr.contains("access/MockOwnable"), "{stderr}"); + assert!(stderr.contains("token/MockOwnable"), "{stderr}"); } #[test] fn the_summary_goes_to_stderr_and_the_changes_to_stdout() { let directory = work("basic"); let output = fill(directory.path(), &[]); + let stderr = String::from_utf8_lossy(&output.stderr); - assert_eq!( - String::from_utf8_lossy(&output.stderr).trim_end(), - "2 values filled in 1 files, 1 unmeasured" + assert!(stderr.starts_with("Checked 1 file in "), "{stderr}"); + assert!( + stderr.ends_with(". Filled 2 values in 1 file.\nFound 1 warning.\n"), + "{stderr}" ); assert!( - String::from_utf8_lossy(&output.stdout).contains(": fill: "), + String::from_utf8_lossy(&output.stdout).contains(" fill FIXED "), "the changes belong on stdout" ); } diff --git a/crates/compact-lint/tests/fix.rs b/crates/compact-lint/tests/fix.rs index a92aa1f..a1998c7 100644 --- a/crates/compact-lint/tests/fix.rs +++ b/crates/compact-lint/tests/fix.rs @@ -1,7 +1,8 @@ //! `compact-lint fix` end to end, one fixture case per rule. //! -//! A case holds `compact-lint.toml`, `before/`, `after/` and `expected.txt`. The run -//! works on a copy of `before/`, so the fixtures themselves are never rewritten. +//! A case holds `compact.toml`, `before/`, `after/`, `expected.txt` for the write +//! run and `expected-dry-run.txt` for the preview. The run works on a copy of `before/`, +//! so the fixtures themselves are never rewritten. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -31,8 +32,8 @@ fn work(name: &str) -> TempDir { let root = case(name); std::fs::copy( - root.join("compact-lint.toml"), - directory.path().join("compact-lint.toml"), + root.join("compact.toml"), + directory.path().join("compact.toml"), ) .expect("the case has a config"); copy_tree(&root.join("before"), directory.path()); @@ -79,8 +80,8 @@ fn collect(root: &Path, directory: &Path, out: &mut BTreeMap>) } } -fn expected(name: &str) -> String { - std::fs::read_to_string(case(name).join("expected.txt")).expect("the case has an expectation") +fn expected(name: &str, file: &str) -> String { + std::fs::read_to_string(case(name).join(file)).expect("the case has an expectation") } /// Runs a subcommand inside `directory` and returns its exit code and stdout. @@ -88,6 +89,7 @@ fn run(directory: &Path, args: &[&str]) -> (i32, String) { let output = binary() .current_dir(directory) .args(args) + .arg("--colors=off") .output() .expect("the binary ran"); @@ -103,7 +105,11 @@ fn assert_case(name: &str) { let root = directory.path(); let (code, stdout) = run(root, &["fix"]); - assert_eq!(stdout, expected(name), "stdout for case {name}"); + assert_eq!( + stdout, + expected(name, "expected.txt"), + "stdout for case {name}" + ); assert_eq!(code, 0, "exit code for case {name}"); assert_eq!( sources(root), @@ -111,7 +117,9 @@ fn assert_case(name: &str) { "rewritten sources for case {name}" ); - let (code, stdout) = run(root, &["check", "--no-format"]); + // An inserted annotation is a placeholder, which `check` warns about until it is + // measured; nothing at error level may survive the fix. + let (code, stdout) = run(root, &["check", "--no-format", "--diagnostic-level=error"]); assert_eq!(stdout, "", "check is clean after fixing case {name}"); assert_eq!(code, 0, "check exit code after fixing case {name}"); @@ -122,7 +130,11 @@ fn assert_case(name: &str) { let preview = work(name); let before = sources(preview.path()); let (code, stdout) = run(preview.path(), &["fix", "--dry-run"]); - assert_eq!(stdout, expected(name), "dry-run stdout for case {name}"); + assert_eq!( + stdout, + expected(name, "expected-dry-run.txt"), + "dry-run stdout for case {name}" + ); assert_eq!(code, EXIT_EDITS, "dry-run exit code for case {name}"); assert_eq!( sources(preview.path()), @@ -141,6 +153,11 @@ fn a_required_tag_goes_into_the_comment_that_lacks_it() { assert_case("missing-tag"); } +#[test] +fn sections_are_inserted_by_heading_and_the_blocks_come_back_in_template_order() { + assert_case("sections"); +} + #[test] fn the_constraints_annotation_follows_the_description_block() { assert_case("constraints"); @@ -173,6 +190,22 @@ fn a_crlf_source_keeps_its_line_endings() { assert_case("crlf"); } +#[test] +fn a_second_fix_leaves_the_reordered_sections_byte_for_byte() { + let directory = work("sections"); + let root = directory.path(); + + let (_, _) = run(root, &["fix"]); + let once = sources(root); + + let (code, stdout) = run(root, &["fix"]); + + assert_eq!(stdout, ""); + assert_eq!(code, 0); + assert_eq!(sources(root), once); + assert_eq!(once, sources(&case("sections").join("after"))); +} + #[test] fn nothing_fixable_writes_nothing_and_exits_zero() { let directory = work("unfixable"); @@ -194,35 +227,51 @@ fn the_summary_goes_to_stderr_and_the_edits_to_stdout() { let directory = work("rename"); let output = binary() .current_dir(directory.path()) - .arg("fix") + .args(["fix", "--colors=off"]) .output() .expect("the binary ran"); let stderr = String::from_utf8(output.stderr).expect("the summary is UTF-8"); - assert_eq!(stderr.trim_end(), "2 edits in 1 files"); + assert!(stderr.starts_with("Checked 1 file in "), "{stderr}"); + assert!(stderr.ends_with(". Fixed 1 file.\n"), "{stderr}"); assert!( - String::from_utf8_lossy(&output.stdout).contains(": fix: "), + String::from_utf8_lossy(&output.stdout).contains(" FIXED "), "the edits belong on stdout" ); } +#[test] +fn a_concise_run_prints_one_line_per_edit() { + let directory = work("rename"); + let (code, stdout) = run(directory.path(), &["fix", "--reporter=concise"]); + + assert_eq!( + stdout, + concat!( + "i Returns.compact:9:6: lint/forbidden-tag: Forbidden tag @return.\n", + "i Returns.compact:15:6: lint/forbidden-tag: Forbidden tag @return.\n", + ) + ); + assert_eq!(code, 0); +} + #[test] fn an_explicit_config_replaces_the_one_beside_the_sources() { let directory = work("rename"); - std::fs::remove_file(directory.path().join("compact-lint.toml")) + std::fs::remove_file(directory.path().join("compact.toml")) .expect("the copied config is removable"); - let config = case("rename").join("compact-lint.toml"); + let config = case("rename").join("compact.toml"); let output = binary() .current_dir(directory.path()) - .args(["fix", "--config"]) + .args(["fix", "--colors=off", "--config"]) .arg(&config) .output() .expect("the binary ran"); assert_eq!( String::from_utf8(output.stdout).expect("the report is UTF-8"), - expected("rename") + expected("rename", "expected.txt") ); } diff --git a/crates/compact-lint/tests/fixtures/clean/compact.toml b/crates/compact-lint/tests/fixtures/clean/compact.toml new file mode 100644 index 0000000..b55b8bc --- /dev/null +++ b/crates/compact-lint/tests/fixtures/clean/compact.toml @@ -0,0 +1,42 @@ +# compact-deploy's tables, here so the case proves the linter ignores them. +[profile] +default_network = "local" + +[networks.local] +network_id = "undeployed" +indexer = "http://127.0.0.1:8088/api/v4/graphql" +indexer_ws = "ws://127.0.0.1:8088/api/v4/graphql/ws" +node = "http://127.0.0.1:9944" +node_ws = "ws://127.0.0.1:9944" + +[contracts.Clean] +artifact = "Clean" +signing_key_file = "deploy/Clean.signingkey" + +[lint] +include = ["**/*.compact"] + +[lint.tags] +forbid = ["@return"] + +[lint.kinds.module] +tags = ["@module", "@description"] + +[lint.kinds.circuit] +tags = ["@description"] + +[lint.kinds.ledger] +tags = ["@description"] + +[lint.kinds.witness] +docs = "all" +tags = ["@description"] + +[lint.kinds.struct] +tags = ["@description"] + +[lint.kinds.enum] +tags = ["@description"] + +[lint.kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/forbidden-tag/compact-lint.toml b/crates/compact-lint/tests/fixtures/constraints-format/compact.toml similarity index 59% rename from crates/compact-lint/tests/fixtures/forbidden-tag/compact-lint.toml rename to crates/compact-lint/tests/fixtures/constraints-format/compact.toml index 0813e24..01fa5a7 100644 --- a/crates/compact-lint/tests/fixtures/forbidden-tag/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/constraints-format/compact.toml @@ -1,27 +1,27 @@ -version = 1 +[lint] include = ["**/*.compact"] -[tags] +[lint.tags] forbid = ["@return"] -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] -[kinds.ledger] +[lint.kinds.ledger] tags = ["@description"] -[kinds.witness] +[lint.kinds.witness] docs = "all" tags = ["@description"] -[kinds.struct] +[lint.kinds.struct] tags = ["@description"] -[kinds.enum] +[lint.kinds.enum] tags = ["@description"] -[kinds.constructor] +[lint.kinds.constructor] docs = "none" diff --git a/crates/compact-lint/tests/fixtures/constraints-format/expected.txt b/crates/compact-lint/tests/fixtures/constraints-format/expected.txt index a1b1ac8..cc4ddc9 100644 --- a/crates/compact-lint/tests/fixtures/constraints-format/expected.txt +++ b/crates/compact-lint/tests/fixtures/constraints-format/expected.txt @@ -1,2 +1,26 @@ -Malformed.compact:8:6: constraints-format: @constraints value `k=10 rows=626` is not `k=, rows=` -Malformed.compact:14:6: constraints-format: @constraints value `rows=626, k=10` is not `k=, rows=` +Malformed.compact:8:6 lint/constraints-format ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × @constraints value `k=10 rows=626` is not `k=, rows=`. + + 6 │ /** + 7 │ * @description The comma is missing. + > 8 │ * @constraints k=10 rows=626 + │ ^^^^^^^^^^^^ + 9 │ */ + 10 │ export circuit first(): [] { } + + i Write the value as k=, rows=. + +Malformed.compact:14:6 lint/constraints-format ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × @constraints value `rows=626, k=10` is not `k=, rows=`. + + 12 │ /** + 13 │ * @description The fields are the wrong way round. + > 14 │ * @constraints rows=626, k=10 + │ ^^^^^^^^^^^^ + 15 │ */ + 16 │ export circuit second(): [] { } + + i Write the value as k=, rows=. + diff --git a/crates/compact-lint/tests/fixtures/clean/compact-lint.toml b/crates/compact-lint/tests/fixtures/constraints-placeholder/compact.toml similarity index 59% rename from crates/compact-lint/tests/fixtures/clean/compact-lint.toml rename to crates/compact-lint/tests/fixtures/constraints-placeholder/compact.toml index 0813e24..01fa5a7 100644 --- a/crates/compact-lint/tests/fixtures/clean/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/constraints-placeholder/compact.toml @@ -1,27 +1,27 @@ -version = 1 +[lint] include = ["**/*.compact"] -[tags] +[lint.tags] forbid = ["@return"] -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] -[kinds.ledger] +[lint.kinds.ledger] tags = ["@description"] -[kinds.witness] +[lint.kinds.witness] docs = "all" tags = ["@description"] -[kinds.struct] +[lint.kinds.struct] tags = ["@description"] -[kinds.enum] +[lint.kinds.enum] tags = ["@description"] -[kinds.constructor] +[lint.kinds.constructor] docs = "none" diff --git a/crates/compact-lint/tests/fixtures/constraints-placeholder/expected-strict.txt b/crates/compact-lint/tests/fixtures/constraints-placeholder/expected-strict.txt new file mode 100644 index 0000000..bbb6b7f --- /dev/null +++ b/crates/compact-lint/tests/fixtures/constraints-placeholder/expected-strict.txt @@ -0,0 +1,13 @@ +Unfilled.compact:8:6 lint/constraints-placeholder ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × @constraints value `k=?, rows=?` still holds a placeholder. + + 6 │ /** + 7 │ * @description Not measured yet. + > 8 │ * @constraints k=?, rows=? + │ ^^^^^^^^^^^^ + 9 │ */ + 10 │ export circuit run(): [] { } + + i Run compact-lint fill-constraints to measure it. + diff --git a/crates/compact-lint/tests/fixtures/constraints-placeholder/expected.txt b/crates/compact-lint/tests/fixtures/constraints-placeholder/expected.txt index ba3214e..44a95a6 100644 --- a/crates/compact-lint/tests/fixtures/constraints-placeholder/expected.txt +++ b/crates/compact-lint/tests/fixtures/constraints-placeholder/expected.txt @@ -1 +1,13 @@ -Unfilled.compact:8:6: constraints-placeholder: @constraints value `k=?, rows=?` still holds a placeholder +Unfilled.compact:8:6 lint/constraints-placeholder ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! @constraints value `k=?, rows=?` still holds a placeholder. + + 6 │ /** + 7 │ * @description Not measured yet. + > 8 │ * @constraints k=?, rows=? + │ ^^^^^^^^^^^^ + 9 │ */ + 10 │ export circuit run(): [] { } + + i Run compact-lint fill-constraints to measure it. + diff --git a/crates/compact-lint/tests/fixtures/diagnostic-level/Mixed.compact b/crates/compact-lint/tests/fixtures/diagnostic-level/Mixed.compact new file mode 100644 index 0000000..316ad86 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/diagnostic-level/Mixed.compact @@ -0,0 +1,16 @@ +/** + * @module Mixed + * @description A module with one warning and one error. + */ +export module Mixed { + /** + * @description Not measured yet. + * @constraints k=?, rows=? + */ + export circuit run(): [] { } + + /** + * @description Unannotated. + */ + export circuit bump(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/constraints-format/compact-lint.toml b/crates/compact-lint/tests/fixtures/diagnostic-level/compact.toml similarity index 59% rename from crates/compact-lint/tests/fixtures/constraints-format/compact-lint.toml rename to crates/compact-lint/tests/fixtures/diagnostic-level/compact.toml index 0813e24..01fa5a7 100644 --- a/crates/compact-lint/tests/fixtures/constraints-format/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/diagnostic-level/compact.toml @@ -1,27 +1,27 @@ -version = 1 +[lint] include = ["**/*.compact"] -[tags] +[lint.tags] forbid = ["@return"] -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] -[kinds.ledger] +[lint.kinds.ledger] tags = ["@description"] -[kinds.witness] +[lint.kinds.witness] docs = "all" tags = ["@description"] -[kinds.struct] +[lint.kinds.struct] tags = ["@description"] -[kinds.enum] +[lint.kinds.enum] tags = ["@description"] -[kinds.constructor] +[lint.kinds.constructor] docs = "none" diff --git a/crates/compact-lint/tests/fixtures/diagnostic-level/expected.txt b/crates/compact-lint/tests/fixtures/diagnostic-level/expected.txt new file mode 100644 index 0000000..66526c9 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/diagnostic-level/expected.txt @@ -0,0 +1,17 @@ +Mixed.compact:15:3 lint/missing-constraints FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Circuit `bump` doc comment has no @constraints. + + 13 │ * @description Unannotated. + 14 │ */ + > 15 │ export circuit bump(): [] { } + │ ^^^^^^^^^^^^^^^^^^^ + 16 │ } + + i Run compact-lint fill-constraints after adding the tag, or add it as @constraints k=?, rows=? to fill later. + + i Unsafe fix: Insert @constraints k=?, rows=? + + 14 │ + ···* + 15 │ + ···*·@constraints·k=?,·rows=? + diff --git a/crates/compact-lint/tests/fixtures/error-on-warnings/Warned.compact b/crates/compact-lint/tests/fixtures/error-on-warnings/Warned.compact new file mode 100644 index 0000000..08673d6 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/error-on-warnings/Warned.compact @@ -0,0 +1,11 @@ +/** + * @module Warned + * @description A module whose only finding is a warning. + */ +export module Warned { + /** + * @description Not measured yet. + * @constraints k=?, rows=? + */ + export circuit run(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/constraints-placeholder/compact-lint.toml b/crates/compact-lint/tests/fixtures/error-on-warnings/compact.toml similarity index 59% rename from crates/compact-lint/tests/fixtures/constraints-placeholder/compact-lint.toml rename to crates/compact-lint/tests/fixtures/error-on-warnings/compact.toml index 0813e24..01fa5a7 100644 --- a/crates/compact-lint/tests/fixtures/constraints-placeholder/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/error-on-warnings/compact.toml @@ -1,27 +1,27 @@ -version = 1 +[lint] include = ["**/*.compact"] -[tags] +[lint.tags] forbid = ["@return"] -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] -[kinds.ledger] +[lint.kinds.ledger] tags = ["@description"] -[kinds.witness] +[lint.kinds.witness] docs = "all" tags = ["@description"] -[kinds.struct] +[lint.kinds.struct] tags = ["@description"] -[kinds.enum] +[lint.kinds.enum] tags = ["@description"] -[kinds.constructor] +[lint.kinds.constructor] docs = "none" diff --git a/crates/compact-lint/tests/fixtures/error-on-warnings/expected.txt b/crates/compact-lint/tests/fixtures/error-on-warnings/expected.txt new file mode 100644 index 0000000..26aece6 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/error-on-warnings/expected.txt @@ -0,0 +1,13 @@ +Warned.compact:8:6 lint/constraints-placeholder ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! @constraints value `k=?, rows=?` still holds a placeholder. + + 6 │ /** + 7 │ * @description Not measured yet. + > 8 │ * @constraints k=?, rows=? + │ ^^^^^^^^^^^^ + 9 │ */ + 10 │ export circuit run(): [] { } + + i Run compact-lint fill-constraints to measure it. + diff --git a/crates/compact-lint/tests/fixtures/exclude-explicit-dir/compact-lint.toml b/crates/compact-lint/tests/fixtures/exclude-explicit-dir/compact.toml similarity index 73% rename from crates/compact-lint/tests/fixtures/exclude-explicit-dir/compact-lint.toml rename to crates/compact-lint/tests/fixtures/exclude-explicit-dir/compact.toml index 02f0d72..1b5ec7d 100644 --- a/crates/compact-lint/tests/fixtures/exclude-explicit-dir/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/exclude-explicit-dir/compact.toml @@ -1,9 +1,9 @@ -version = 1 +[lint] include = ["contracts/**/*.compact"] exclude = ["contracts/archive/**"] -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.ledger] +[lint.kinds.ledger] tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/exclude-explicit-dir/expected.txt b/crates/compact-lint/tests/fixtures/exclude-explicit-dir/expected.txt index 31799ef..de040ed 100644 --- a/crates/compact-lint/tests/fixtures/exclude-explicit-dir/expected.txt +++ b/crates/compact-lint/tests/fixtures/exclude-explicit-dir/expected.txt @@ -1 +1,18 @@ -live/Live.compact:6:3: missing-doc: ledger `_owner` has no doc comment +live/Live.compact:6:3 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Ledger `_owner` has no doc comment. + + 4 │ */ + 5 │ export module Live { + > 6 │ export ledger _owner: Bytes<32>; + │ ^^^^^^^^^^^^^^^^^^^^ + 7 │ } + + i Add a doc comment above it, or run compact-lint fix. + + i Unsafe fix: Insert a doc skeleton for ledger `_owner` + + 6 │ + ··/** + 7 │ + ···*·@description·TODO + 8 │ + ···*/ + diff --git a/crates/compact-lint/tests/fixtures/fill-no-compile/compact-lint.toml b/crates/compact-lint/tests/fixtures/fill-basic/compact.toml similarity index 65% rename from crates/compact-lint/tests/fixtures/fill-no-compile/compact-lint.toml rename to crates/compact-lint/tests/fixtures/fill-basic/compact.toml index e5a81d8..2edfac9 100644 --- a/crates/compact-lint/tests/fixtures/fill-no-compile/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/fill-basic/compact.toml @@ -1,8 +1,8 @@ -version = 1 +[lint] exclude = ["**/test/mocks/**"] -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/fill-basic/expected-dry-run.txt b/crates/compact-lint/tests/fixtures/fill-basic/expected-dry-run.txt new file mode 100644 index 0000000..dbeaeb3 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-basic/expected-dry-run.txt @@ -0,0 +1,45 @@ +src/access/Ownable.compact:9:6 fill FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `owner` measures k=7, rows=74. + + 7 │ * @description Returns the owner. + 8 │ * + > 9 │ * @constraints k=?, rows=? + │ ^^^^^^^^^^^^ + 10 │ */ + 11 │ export circuit owner(): [] { } + + i Safe fix: Set @constraints to k=7, rows=74 + + 9 │ - ···*·@constraints·k=?,·rows=? + 9 │ + ···*·@constraints·k=7,·rows=74 + +src/access/Ownable.compact:16:6 fill FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `transferOwnership` measures k=13, rows=4871. + + 14 │ * @description Transfers ownership. + 15 │ * + > 16 │ * @constraints k=9, rows=42 + │ ^^^^^^^^^^^^ + 17 │ */ + 18 │ export circuit transferOwnership(): [] { } + + i Safe fix: Set @constraints to k=13, rows=4871 + + 16 │ - ···*·@constraints·k=9,·rows=42 + 16 │ + ···*·@constraints·k=13,·rows=4871 + +src/access/Ownable.compact:30:6 lint/constraints-unmeasured ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Circuit `initialize` has no measurement. + + 28 │ * @description Sets the first owner. + 29 │ * + > 30 │ * @constraints k=5, rows=10 + │ ^^^^^^^^^^^^ + 31 │ */ + 32 │ export circuit initialize(): [] { } + + i It is measured through src/access/test/mocks/MockOwnable.compact, which exports no circuit of that name. + diff --git a/crates/compact-lint/tests/fixtures/fill-basic/expected-settled.txt b/crates/compact-lint/tests/fixtures/fill-basic/expected-settled.txt new file mode 100644 index 0000000..0808b59 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-basic/expected-settled.txt @@ -0,0 +1,13 @@ +src/access/Ownable.compact:30:6 lint/constraints-unmeasured ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Circuit `initialize` has no measurement. + + 28 │ * @description Sets the first owner. + 29 │ * + > 30 │ * @constraints k=5, rows=10 + │ ^^^^^^^^^^^^ + 31 │ */ + 32 │ export circuit initialize(): [] { } + + i It is measured through src/access/test/mocks/MockOwnable.compact, which exports no circuit of that name. + diff --git a/crates/compact-lint/tests/fixtures/fill-basic/expected.txt b/crates/compact-lint/tests/fixtures/fill-basic/expected.txt index a9bdabc..15e834a 100644 --- a/crates/compact-lint/tests/fixtures/fill-basic/expected.txt +++ b/crates/compact-lint/tests/fixtures/fill-basic/expected.txt @@ -1,3 +1,31 @@ -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 +src/access/Ownable.compact:9:6 fill FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `owner` measures k=7, rows=74. + + i Applied fix: Set @constraints to k=7, rows=74 + + 9 │ - ···*·@constraints·k=?,·rows=? + 9 │ + ···*·@constraints·k=7,·rows=74 + +src/access/Ownable.compact:16:6 fill FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `transferOwnership` measures k=13, rows=4871. + + i Applied fix: Set @constraints to k=13, rows=4871 + + 16 │ - ···*·@constraints·k=9,·rows=42 + 16 │ + ···*·@constraints·k=13,·rows=4871 + +src/access/Ownable.compact:30:6 lint/constraints-unmeasured ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Circuit `initialize` has no measurement. + + 28 │ * @description Sets the first owner. + 29 │ * + > 30 │ * @constraints k=5, rows=10 + │ ^^^^^^^^^^^^ + 31 │ */ + 32 │ export circuit initialize(): [] { } + + i It is measured through src/access/test/mocks/MockOwnable.compact, which exports no circuit of that name. + diff --git a/crates/compact-lint/tests/fixtures/fill-basic/compact-lint.toml b/crates/compact-lint/tests/fixtures/fill-compiler-fails/compact.toml similarity index 65% rename from crates/compact-lint/tests/fixtures/fill-basic/compact-lint.toml rename to crates/compact-lint/tests/fixtures/fill-compiler-fails/compact.toml index e5a81d8..2edfac9 100644 --- a/crates/compact-lint/tests/fixtures/fill-basic/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/fill-compiler-fails/compact.toml @@ -1,8 +1,8 @@ -version = 1 +[lint] exclude = ["**/test/mocks/**"] -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/fill-missing-source/compact-lint.toml b/crates/compact-lint/tests/fixtures/fill-missing-source/compact.toml similarity index 55% rename from crates/compact-lint/tests/fixtures/fill-missing-source/compact-lint.toml rename to crates/compact-lint/tests/fixtures/fill-missing-source/compact.toml index 584076c..e131424 100644 --- a/crates/compact-lint/tests/fixtures/fill-missing-source/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/fill-missing-source/compact.toml @@ -1,7 +1,7 @@ -version = 1 +[lint] -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/fill-missing-source/expected-dry-run.txt b/crates/compact-lint/tests/fixtures/fill-missing-source/expected-dry-run.txt new file mode 100644 index 0000000..e4df198 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-missing-source/expected-dry-run.txt @@ -0,0 +1,11 @@ +src/orphan/Orphan.compact:1:1 lint/constraints-unmeasurable ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! No measurement source for src/orphan/Orphan.compact. + + > 1 │ /** + │ ^ + 2 │ * @module Orphan + 3 │ * @description Test fixture. + + i Tried src/orphan/test/mocks/MockOrphan.compact, src/test/mocks/MockOrphan.compact; add one, or name it in constraints.overrides. + diff --git a/crates/compact-lint/tests/fixtures/fill-missing-source/expected-settled.txt b/crates/compact-lint/tests/fixtures/fill-missing-source/expected-settled.txt new file mode 100644 index 0000000..e4df198 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-missing-source/expected-settled.txt @@ -0,0 +1,11 @@ +src/orphan/Orphan.compact:1:1 lint/constraints-unmeasurable ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! No measurement source for src/orphan/Orphan.compact. + + > 1 │ /** + │ ^ + 2 │ * @module Orphan + 3 │ * @description Test fixture. + + i Tried src/orphan/test/mocks/MockOrphan.compact, src/test/mocks/MockOrphan.compact; add one, or name it in constraints.overrides. + diff --git a/crates/compact-lint/tests/fixtures/fill-missing-source/expected.txt b/crates/compact-lint/tests/fixtures/fill-missing-source/expected.txt index 1a91510..e4df198 100644 --- a/crates/compact-lint/tests/fixtures/fill-missing-source/expected.txt +++ b/crates/compact-lint/tests/fixtures/fill-missing-source/expected.txt @@ -1 +1,11 @@ -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 +src/orphan/Orphan.compact:1:1 lint/constraints-unmeasurable ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! No measurement source for src/orphan/Orphan.compact. + + > 1 │ /** + │ ^ + 2 │ * @module Orphan + 3 │ * @description Test fixture. + + i Tried src/orphan/test/mocks/MockOrphan.compact, src/test/mocks/MockOrphan.compact; add one, or name it in constraints.overrides. + diff --git a/crates/compact-lint/tests/fixtures/fill-no-compile/before/artifacts/MockOwnable/circuit-info.json b/crates/compact-lint/tests/fixtures/fill-no-compile/before/artifacts/MockOwnable/circuit-info.json new file mode 100644 index 0000000..5eef405 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-no-compile/before/artifacts/MockOwnable/circuit-info.json @@ -0,0 +1,11 @@ +{ + "generatedAt": "2026-01-01T00:00:00.000Z", + "source": "src/access/test/mocks/MockOwnable.compact", + "circuits": [ + { + "name": "owner", + "k": 7, + "rows": 74 + } + ] +} 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 deleted file mode 100644 index 0c0fa8e..0000000 --- a/crates/compact-lint/tests/fixtures/fill-no-compile/before/src/access/test/mocks/.circuit-info.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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-compiler-fails/compact-lint.toml b/crates/compact-lint/tests/fixtures/fill-no-compile/compact.toml similarity index 65% rename from crates/compact-lint/tests/fixtures/fill-compiler-fails/compact-lint.toml rename to crates/compact-lint/tests/fixtures/fill-no-compile/compact.toml index e5a81d8..2edfac9 100644 --- a/crates/compact-lint/tests/fixtures/fill-compiler-fails/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/fill-no-compile/compact.toml @@ -1,8 +1,8 @@ -version = 1 +[lint] exclude = ["**/test/mocks/**"] -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/fill-no-compile/expected-dry-run.txt b/crates/compact-lint/tests/fixtures/fill-no-compile/expected-dry-run.txt new file mode 100644 index 0000000..2af2854 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-no-compile/expected-dry-run.txt @@ -0,0 +1,16 @@ +src/access/Ownable.compact:9:6 fill FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `owner` measures k=7, rows=74. + + 7 │ * @description Returns the owner. + 8 │ * + > 9 │ * @constraints k=?, rows=? + │ ^^^^^^^^^^^^ + 10 │ */ + 11 │ export circuit owner(): [] { } + + i Safe fix: Set @constraints to k=7, rows=74 + + 9 │ - ···*·@constraints·k=?,·rows=? + 9 │ + ···*·@constraints·k=7,·rows=74 + diff --git a/crates/compact-lint/tests/fixtures/fill-no-compile/expected-settled.txt b/crates/compact-lint/tests/fixtures/fill-no-compile/expected-settled.txt new file mode 100644 index 0000000..e69de29 diff --git a/crates/compact-lint/tests/fixtures/fill-no-compile/expected.txt b/crates/compact-lint/tests/fixtures/fill-no-compile/expected.txt index 0e5a1e3..ea1b5f1 100644 --- a/crates/compact-lint/tests/fixtures/fill-no-compile/expected.txt +++ b/crates/compact-lint/tests/fixtures/fill-no-compile/expected.txt @@ -1 +1,9 @@ -src/access/Ownable.compact:9:6: fill: @constraints k=?, rows=? -> k=7, rows=74 +src/access/Ownable.compact:9:6 fill FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `owner` measures k=7, rows=74. + + i Applied fix: Set @constraints to k=7, rows=74 + + 9 │ - ···*·@constraints·k=?,·rows=? + 9 │ + ···*·@constraints·k=7,·rows=74 + diff --git a/crates/compact-lint/tests/fixtures/fill-override/compact-lint.toml b/crates/compact-lint/tests/fixtures/fill-override/compact.toml similarity index 68% rename from crates/compact-lint/tests/fixtures/fill-override/compact-lint.toml rename to crates/compact-lint/tests/fixtures/fill-override/compact.toml index 4f16f10..a903294 100644 --- a/crates/compact-lint/tests/fixtures/fill-override/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/fill-override/compact.toml @@ -1,11 +1,11 @@ -version = 1 +[lint] exclude = ["**/test/mocks/**"] -[constraints.overrides] +[lint.constraints.overrides] "src/utils/Utils.compact" = "src/utils/test/mocks/MockUtilities.compact" -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/fill-override/expected-dry-run.txt b/crates/compact-lint/tests/fixtures/fill-override/expected-dry-run.txt new file mode 100644 index 0000000..2899131 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-override/expected-dry-run.txt @@ -0,0 +1,16 @@ +src/utils/Utils.compact:9:6 fill FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `hash` measures k=9, rows=310. + + 7 │ * @description Hashes a value. + 8 │ * + > 9 │ * @constraints k=?, rows=? + │ ^^^^^^^^^^^^ + 10 │ */ + 11 │ export circuit hash(): [] { } + + i Safe fix: Set @constraints to k=9, rows=310 + + 9 │ - ···*·@constraints·k=?,·rows=? + 9 │ + ···*·@constraints·k=9,·rows=310 + diff --git a/crates/compact-lint/tests/fixtures/fill-override/expected-settled.txt b/crates/compact-lint/tests/fixtures/fill-override/expected-settled.txt new file mode 100644 index 0000000..e69de29 diff --git a/crates/compact-lint/tests/fixtures/fill-override/expected.txt b/crates/compact-lint/tests/fixtures/fill-override/expected.txt index f718af9..069f732 100644 --- a/crates/compact-lint/tests/fixtures/fill-override/expected.txt +++ b/crates/compact-lint/tests/fixtures/fill-override/expected.txt @@ -1 +1,9 @@ -src/utils/Utils.compact:9:6: fill: @constraints k=?, rows=? -> k=9, rows=310 +src/utils/Utils.compact:9:6 fill FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `hash` measures k=9, rows=310. + + i Applied fix: Set @constraints to k=9, rows=310 + + 9 │ - ···*·@constraints·k=?,·rows=? + 9 │ + ···*·@constraints·k=9,·rows=310 + diff --git a/crates/compact-lint/tests/fixtures/fill-self/compact-lint.toml b/crates/compact-lint/tests/fixtures/fill-self/compact.toml similarity index 56% rename from crates/compact-lint/tests/fixtures/fill-self/compact-lint.toml rename to crates/compact-lint/tests/fixtures/fill-self/compact.toml index 8545132..d4c9f2f 100644 --- a/crates/compact-lint/tests/fixtures/fill-self/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/fill-self/compact.toml @@ -1,10 +1,10 @@ -version = 1 +[lint] -[constraints] +[lint.constraints] self = ["**/presets/**"] -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/fill-self/expected-dry-run.txt b/crates/compact-lint/tests/fixtures/fill-self/expected-dry-run.txt new file mode 100644 index 0000000..470061e --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fill-self/expected-dry-run.txt @@ -0,0 +1,32 @@ +src/multisig/presets/Vault.compact:9:6 fill FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `deposit` measures k=12, rows=2048. + + 7 │ * @description Deposits a coin. + 8 │ * + > 9 │ * @constraints k=?, rows=? + │ ^^^^^^^^^^^^ + 10 │ */ + 11 │ export circuit deposit(): [] { } + + i Safe fix: Set @constraints to k=12, rows=2048 + + 9 │ - ···*·@constraints·k=?,·rows=? + 9 │ + ···*·@constraints·k=12,·rows=2048 + +src/multisig/presets/Vault.compact:16:6 fill FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `withdraw` measures k=14, rows=8192. + + 14 │ * @description Withdraws a coin. + 15 │ * + > 16 │ * @constraints k=?, rows=? + │ ^^^^^^^^^^^^ + 17 │ */ + 18 │ export circuit withdraw(): [] { } + + i Safe fix: Set @constraints to k=14, rows=8192 + + 16 │ - ···*·@constraints·k=?,·rows=? + 16 │ + ···*·@constraints·k=14,·rows=8192 + diff --git a/crates/compact-lint/tests/fixtures/fill-self/expected-settled.txt b/crates/compact-lint/tests/fixtures/fill-self/expected-settled.txt new file mode 100644 index 0000000..e69de29 diff --git a/crates/compact-lint/tests/fixtures/fill-self/expected.txt b/crates/compact-lint/tests/fixtures/fill-self/expected.txt index 88499e9..b6ef8af 100644 --- a/crates/compact-lint/tests/fixtures/fill-self/expected.txt +++ b/crates/compact-lint/tests/fixtures/fill-self/expected.txt @@ -1,2 +1,18 @@ -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 +src/multisig/presets/Vault.compact:9:6 fill FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `deposit` measures k=12, rows=2048. + + i Applied fix: Set @constraints to k=12, rows=2048 + + 9 │ - ···*·@constraints·k=?,·rows=? + 9 │ + ···*·@constraints·k=12,·rows=2048 + +src/multisig/presets/Vault.compact:16:6 fill FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `withdraw` measures k=14, rows=8192. + + i Applied fix: Set @constraints to k=14, rows=8192 + + 16 │ - ···*·@constraints·k=?,·rows=? + 16 │ + ···*·@constraints·k=14,·rows=8192 + diff --git a/crates/compact-lint/tests/fixtures/fix-constraints/compact-lint.toml b/crates/compact-lint/tests/fixtures/fix-constraints/compact.toml similarity index 52% rename from crates/compact-lint/tests/fixtures/fix-constraints/compact-lint.toml rename to crates/compact-lint/tests/fixtures/fix-constraints/compact.toml index 832f4ee..0a6450a 100644 --- a/crates/compact-lint/tests/fixtures/fix-constraints/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/fix-constraints/compact.toml @@ -1,13 +1,13 @@ -version = 1 +[lint] include = ["**/*.compact"] -[constraints] +[lint.constraints] tag = "@constraints" -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] -[kinds.constructor] +[lint.kinds.constructor] docs = "none" diff --git a/crates/compact-lint/tests/fixtures/fix-constraints/expected-dry-run.txt b/crates/compact-lint/tests/fixtures/fix-constraints/expected-dry-run.txt new file mode 100644 index 0000000..8b9a461 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-constraints/expected-dry-run.txt @@ -0,0 +1,48 @@ +Unmeasured.compact:9:3 lint/missing-constraints FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `bump` doc comment has no @constraints. + + 7 │ * @description Bumps the counter. + 8 │ */ + > 9 │ export circuit bump(): [] { } + │ ^^^^^^^^^^^^^^^^^^^ + 10 │ + 11 │ /** + + i Unsafe fix: Insert @constraints k=?, rows=? + + 8 │ + ···* + 9 │ + ···*·@constraints·k=?,·rows=? + +Unmeasured.compact:18:3 lint/missing-constraints FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `send` doc comment has no @constraints. + + 16 │ * @returns {[]} Empty tuple. + 17 │ */ + > 18 │ export circuit send(to: Bytes<32>): [] { } + │ ^^^^^^^^^^^^^^^^^^^ + 19 │ + 20 │ /** + + i Unsafe fix: Insert @constraints k=?, rows=? + + 15 │ + ···*·@constraints·k=?,·rows=? + 16 │ + ···* + +Unmeasured.compact:23:3 lint/missing-constraints FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `run` doc comment has no @constraints. + + 21 │ * @notice The annotation goes to the top, since there is no description. + 22 │ */ + > 23 │ export circuit run(): [] { } + │ ^^^^^^^^^^^^^^^^^^ + 24 │ + 25 │ /** + + i Unsafe fix: Insert @constraints k=?, rows=? + + 21 │ + ···*·@constraints·k=?,·rows=? + 22 │ + ···* + diff --git a/crates/compact-lint/tests/fixtures/fix-constraints/expected.txt b/crates/compact-lint/tests/fixtures/fix-constraints/expected.txt index 9b49823..cd1b0d5 100644 --- a/crates/compact-lint/tests/fixtures/fix-constraints/expected.txt +++ b/crates/compact-lint/tests/fixtures/fix-constraints/expected.txt @@ -1,3 +1,27 @@ -Unmeasured.compact:9:3: fix: inserted @constraints k=?, rows=? -Unmeasured.compact:18:3: fix: inserted @constraints k=?, rows=? -Unmeasured.compact:23:3: fix: inserted @constraints k=?, rows=? +Unmeasured.compact:9:3 lint/missing-constraints FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `bump` doc comment has no @constraints. + + i Applied fix: Insert @constraints k=?, rows=? + + 8 │ + ···* + 9 │ + ···*·@constraints·k=?,·rows=? + +Unmeasured.compact:18:3 lint/missing-constraints FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `send` doc comment has no @constraints. + + i Applied fix: Insert @constraints k=?, rows=? + + 15 │ + ···*·@constraints·k=?,·rows=? + 16 │ + ···* + +Unmeasured.compact:23:3 lint/missing-constraints FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `run` doc comment has no @constraints. + + i Applied fix: Insert @constraints k=?, rows=? + + 21 │ + ···*·@constraints·k=?,·rows=? + 22 │ + ···* + diff --git a/crates/compact-lint/tests/fixtures/fix-crlf/compact-lint.toml b/crates/compact-lint/tests/fixtures/fix-crlf/compact.toml similarity index 63% rename from crates/compact-lint/tests/fixtures/fix-crlf/compact-lint.toml rename to crates/compact-lint/tests/fixtures/fix-crlf/compact.toml index 5e3068c..de1e948 100644 --- a/crates/compact-lint/tests/fixtures/fix-crlf/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/fix-crlf/compact.toml @@ -1,18 +1,18 @@ -version = 1 +[lint] include = ["**/*.compact"] -[tags] +[lint.tags] forbid = ["@return"] rename = { "@return" = "@returns" } -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] -[kinds.ledger] +[lint.kinds.ledger] tags = ["@description"] -[kinds.constructor] +[lint.kinds.constructor] docs = "none" diff --git a/crates/compact-lint/tests/fixtures/fix-crlf/expected-dry-run.txt b/crates/compact-lint/tests/fixtures/fix-crlf/expected-dry-run.txt new file mode 100644 index 0000000..506bff8 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-crlf/expected-dry-run.txt @@ -0,0 +1,49 @@ +Windows.compact:6:3 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Ledger `_count` has no doc comment. + + 4 │ */ + 5 │ export module Windows { + > 6 │ export ledger _count: Counter; + │ ^^^^^^^^^^^^^^^^^^^^ + 7 │ + 8 │ /** + + i Unsafe fix: Insert a doc skeleton for ledger `_count` + + 6 │ + ··/** + 7 │ + ···*·@description·TODO + 8 │ + ···*/ + +Windows.compact:10:6 lint/forbidden-tag FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Forbidden tag @return. + + 8 │ /** + 9 │ * @description Bumps the counter. + > 10 │ * @return {[]} - Empty tuple. + │ ^^^^^^^ + 11 │ */ + 12 │ export circuit bump(): [] { } + + i Safe fix: Rename @return to @returns + + 10 │ - ···*·@return·{[]}·-·Empty·tuple. + 10 │ + ···*·@returns·{[]}·-·Empty·tuple. + +Windows.compact:12:3 lint/missing-constraints FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `bump` doc comment has no @constraints. + + 10 │ * @return {[]} - Empty tuple. + 11 │ */ + > 12 │ export circuit bump(): [] { } + │ ^^^^^^^^^^^^^^^^^^^ + 13 │ } + + i Unsafe fix: Insert @constraints k=?, rows=? + + 10 │ + ···* + 11 │ + ···*·@constraints·k=?,·rows=? + 12 │ + ···* + diff --git a/crates/compact-lint/tests/fixtures/fix-crlf/expected.txt b/crates/compact-lint/tests/fixtures/fix-crlf/expected.txt index 994f2b4..84838b2 100644 --- a/crates/compact-lint/tests/fixtures/fix-crlf/expected.txt +++ b/crates/compact-lint/tests/fixtures/fix-crlf/expected.txt @@ -1,3 +1,29 @@ -Windows.compact:6:3: fix: inserted doc skeleton for ledger `_count` -Windows.compact:10:6: fix: renamed @return to @returns -Windows.compact:12:3: fix: inserted @constraints k=?, rows=? +Windows.compact:6:3 lint/missing-doc FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Ledger `_count` has no doc comment. + + i Applied fix: Insert a doc skeleton for ledger `_count` + + 6 │ + ··/** + 7 │ + ···*·@description·TODO + 8 │ + ···*/ + +Windows.compact:10:6 lint/forbidden-tag FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Forbidden tag @return. + + i Applied fix: Rename @return to @returns + + 10 │ - ···*·@return·{[]}·-·Empty·tuple. + 10 │ + ···*·@returns·{[]}·-·Empty·tuple. + +Windows.compact:12:3 lint/missing-constraints FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `bump` doc comment has no @constraints. + + i Applied fix: Insert @constraints k=?, rows=? + + 10 │ + ···* + 11 │ + ···*·@constraints·k=?,·rows=? + 12 │ + ···* + diff --git a/crates/compact-lint/tests/fixtures/fix-missing-tag/compact-lint.toml b/crates/compact-lint/tests/fixtures/fix-missing-tag/compact.toml similarity index 57% rename from crates/compact-lint/tests/fixtures/fix-missing-tag/compact-lint.toml rename to crates/compact-lint/tests/fixtures/fix-missing-tag/compact.toml index 3af7b21..a8f7e9a 100644 --- a/crates/compact-lint/tests/fixtures/fix-missing-tag/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/fix-missing-tag/compact.toml @@ -1,14 +1,14 @@ -version = 1 +[lint] include = ["**/*.compact"] -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] -[kinds.ledger] +[lint.kinds.ledger] tags = ["@description"] -[kinds.constructor] +[lint.kinds.constructor] docs = "none" diff --git a/crates/compact-lint/tests/fixtures/fix-missing-tag/expected-dry-run.txt b/crates/compact-lint/tests/fixtures/fix-missing-tag/expected-dry-run.txt new file mode 100644 index 0000000..67311e1 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-missing-tag/expected-dry-run.txt @@ -0,0 +1,78 @@ +Untagged.compact:4:1 lint/missing-tag FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Module `Untagged` doc comment has no @description. + + 2 │ * @module Untagged + 3 │ */ + > 4 │ export module Untagged { + │ ^^^^^^^^^^^^^^^^^^^^^^ + 5 │ /** Approved accounts. Absent means not allowed. */ + 6 │ export ledger _allowed: Set>; + + i Unsafe fix: Insert @description + + 3 │ + ·*·@description·TODO + +Untagged.compact:6:3 lint/missing-tag FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Ledger `_allowed` doc comment has no @description. + + 4 │ export module Untagged { + 5 │ /** Approved accounts. Absent means not allowed. */ + > 6 │ export ledger _allowed: Set>; + │ ^^^^^^^^^^^^^^^^^^^^^^ + 7 │ + 8 │ /** + + i Unsafe fix: Insert @description + + 5 │ - ··/**·Approved·accounts.·Absent·means·not·allowed.·*/ + 5 │ + ··/** + 6 │ + ···*·@description·Approved·accounts.·Absent·means·not·allowed. + 7 │ + ···*/ + +Untagged.compact:12:3 lint/missing-tag FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `run` doc comment has no @description. + + 10 │ * @constraints k=6, rows=28 + 11 │ */ + > 12 │ export circuit run(): [] { } + │ ^^^^^^^^^^^^^^^^^^ + 13 │ + 14 │ /** + + i Unsafe fix: Insert @description + + 9 │ - ···*·The·circuit·has·no·description·either. + 9 │ + ···*·@description·The·circuit·has·no·description·either. + +Untagged.compact:17:3 lint/missing-constraints FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `store` doc comment has no @constraints. + + 15 │ * @param {Uint<8>} value - The value to store. + 16 │ */ + > 17 │ export circuit store(value: Uint<8>): [] { } + │ ^^^^^^^^^^^^^^^^^^^^ + 18 │ } + + i Unsafe fix: Insert @constraints k=?, rows=? + + 15 │ + ···*·@constraints·k=?,·rows=? + 16 │ + ···* + +Untagged.compact:17:3 lint/missing-tag FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `store` doc comment has no @description. + + 15 │ * @param {Uint<8>} value - The value to store. + 16 │ */ + > 17 │ export circuit store(value: Uint<8>): [] { } + │ ^^^^^^^^^^^^^^^^^^^^ + 18 │ } + + i Unsafe fix: Insert @description + + 15 │ + ···*·@description·TODO + diff --git a/crates/compact-lint/tests/fixtures/fix-missing-tag/expected.txt b/crates/compact-lint/tests/fixtures/fix-missing-tag/expected.txt index a33b4e2..b3401a1 100644 --- a/crates/compact-lint/tests/fixtures/fix-missing-tag/expected.txt +++ b/crates/compact-lint/tests/fixtures/fix-missing-tag/expected.txt @@ -1,5 +1,45 @@ -Untagged.compact:4:1: fix: inserted @description -Untagged.compact:6:3: fix: inserted @description -Untagged.compact:12:3: fix: inserted @description -Untagged.compact:17:3: fix: inserted @constraints k=?, rows=? -Untagged.compact:17:3: fix: inserted @description +Untagged.compact:4:1 lint/missing-tag FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Module `Untagged` doc comment has no @description. + + i Applied fix: Insert @description + + 3 │ + ·*·@description·TODO + +Untagged.compact:6:3 lint/missing-tag FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Ledger `_allowed` doc comment has no @description. + + i Applied fix: Insert @description + + 5 │ - ··/**·Approved·accounts.·Absent·means·not·allowed.·*/ + 5 │ + ··/** + 6 │ + ···*·@description·Approved·accounts.·Absent·means·not·allowed. + 7 │ + ···*/ + +Untagged.compact:12:3 lint/missing-tag FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `run` doc comment has no @description. + + i Applied fix: Insert @description + + 9 │ - ···*·The·circuit·has·no·description·either. + 9 │ + ···*·@description·The·circuit·has·no·description·either. + +Untagged.compact:17:3 lint/missing-constraints FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `store` doc comment has no @constraints. + + i Applied fix: Insert @constraints k=?, rows=? + + 15 │ + ···*·@constraints·k=?,·rows=? + 16 │ + ···* + +Untagged.compact:17:3 lint/missing-tag FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `store` doc comment has no @description. + + i Applied fix: Insert @description + + 15 │ + ···*·@description·TODO + diff --git a/crates/compact-lint/tests/fixtures/fix-module-name/compact-lint.toml b/crates/compact-lint/tests/fixtures/fix-module-name/compact.toml similarity index 60% rename from crates/compact-lint/tests/fixtures/fix-module-name/compact-lint.toml rename to crates/compact-lint/tests/fixtures/fix-module-name/compact.toml index d404f25..bbc8338 100644 --- a/crates/compact-lint/tests/fixtures/fix-module-name/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/fix-module-name/compact.toml @@ -1,8 +1,8 @@ -version = 1 +[lint] include = ["**/*.compact"] -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.constructor] +[lint.kinds.constructor] docs = "none" diff --git a/crates/compact-lint/tests/fixtures/fix-module-name/expected-dry-run.txt b/crates/compact-lint/tests/fixtures/fix-module-name/expected-dry-run.txt new file mode 100644 index 0000000..85f96fa --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-module-name/expected-dry-run.txt @@ -0,0 +1,31 @@ +Renamed.compact:2:4 lint/module-name FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i @module names `Stale`, but the module is `Renamed`. + + 1 │ /** + > 2 │ * @module Stale (archived) + │ ^^^^^^^ + 3 │ * @description The tag still names the module's old name. + 4 │ */ + + i Safe fix: Set @module to `Renamed` + + 2 │ - ·*·@module·Stale·(archived) + 2 │ + ·*·@module·Renamed·(archived) + +Renamed.compact:7:6 lint/module-name FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i @module names `Inner`, but the module is `Nested`. + + 5 │ export module Renamed { + 6 │ /** + > 7 │ * @module Inner. + │ ^^^^^^^ + 8 │ * @description The inner tag names nothing that exists. + 9 │ */ + + i Safe fix: Set @module to `Nested` + + 7 │ - ···*·@module·Inner. + 7 │ + ···*·@module·Nested + diff --git a/crates/compact-lint/tests/fixtures/fix-module-name/expected.txt b/crates/compact-lint/tests/fixtures/fix-module-name/expected.txt index 5e9811a..f22ed9f 100644 --- a/crates/compact-lint/tests/fixtures/fix-module-name/expected.txt +++ b/crates/compact-lint/tests/fixtures/fix-module-name/expected.txt @@ -1,2 +1,18 @@ -Renamed.compact:2:4: fix: set @module to `Renamed` -Renamed.compact:7:6: fix: set @module to `Nested` +Renamed.compact:2:4 lint/module-name FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i @module names `Stale`, but the module is `Renamed`. + + i Applied fix: Set @module to `Renamed` + + 2 │ - ·*·@module·Stale·(archived) + 2 │ + ·*·@module·Renamed·(archived) + +Renamed.compact:7:6 lint/module-name FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i @module names `Inner`, but the module is `Nested`. + + i Applied fix: Set @module to `Nested` + + 7 │ - ···*·@module·Inner. + 7 │ + ···*·@module·Nested + diff --git a/crates/compact-lint/tests/fixtures/fix-rename-supplies-tag/compact-lint.toml b/crates/compact-lint/tests/fixtures/fix-rename-supplies-tag/compact.toml similarity index 72% rename from crates/compact-lint/tests/fixtures/fix-rename-supplies-tag/compact-lint.toml rename to crates/compact-lint/tests/fixtures/fix-rename-supplies-tag/compact.toml index 815000c..9b89c5e 100644 --- a/crates/compact-lint/tests/fixtures/fix-rename-supplies-tag/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/fix-rename-supplies-tag/compact.toml @@ -1,12 +1,12 @@ -version = 1 +[lint] include = ["**/*.compact"] -[tags] +[lint.tags] forbid = ["@return"] rename = { "@return" = "@returns" } -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description", "@returns"] diff --git a/crates/compact-lint/tests/fixtures/fix-rename-supplies-tag/expected-dry-run.txt b/crates/compact-lint/tests/fixtures/fix-rename-supplies-tag/expected-dry-run.txt new file mode 100644 index 0000000..48c2d23 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-rename-supplies-tag/expected-dry-run.txt @@ -0,0 +1,16 @@ +Supplied.compact:9:6 lint/forbidden-tag FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Forbidden tag @return. + + 7 │ * @description Does nothing. + 8 │ * @constraints k=6, rows=28 + > 9 │ * @return {[]} - Empty tuple. + │ ^^^^^^^ + 10 │ */ + 11 │ export circuit run(): [] { } + + i Safe fix: Rename @return to @returns + + 9 │ - ···*·@return·{[]}·-·Empty·tuple. + 9 │ + ···*·@returns·{[]}·-·Empty·tuple. + diff --git a/crates/compact-lint/tests/fixtures/fix-rename-supplies-tag/expected.txt b/crates/compact-lint/tests/fixtures/fix-rename-supplies-tag/expected.txt index 9a3fad0..c4077ff 100644 --- a/crates/compact-lint/tests/fixtures/fix-rename-supplies-tag/expected.txt +++ b/crates/compact-lint/tests/fixtures/fix-rename-supplies-tag/expected.txt @@ -1 +1,9 @@ -Supplied.compact:9:6: fix: renamed @return to @returns +Supplied.compact:9:6 lint/forbidden-tag FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Forbidden tag @return. + + i Applied fix: Rename @return to @returns + + 9 │ - ···*·@return·{[]}·-·Empty·tuple. + 9 │ + ···*·@returns·{[]}·-·Empty·tuple. + diff --git a/crates/compact-lint/tests/fixtures/fix-rename/compact-lint.toml b/crates/compact-lint/tests/fixtures/fix-rename/compact.toml similarity index 65% rename from crates/compact-lint/tests/fixtures/fix-rename/compact-lint.toml rename to crates/compact-lint/tests/fixtures/fix-rename/compact.toml index ad6ecc5..05f63b3 100644 --- a/crates/compact-lint/tests/fixtures/fix-rename/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/fix-rename/compact.toml @@ -1,15 +1,15 @@ -version = 1 +[lint] include = ["**/*.compact"] -[tags] +[lint.tags] forbid = ["@return"] rename = { "@return" = "@returns" } -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] -[kinds.constructor] +[lint.kinds.constructor] docs = "none" diff --git a/crates/compact-lint/tests/fixtures/fix-rename/expected-dry-run.txt b/crates/compact-lint/tests/fixtures/fix-rename/expected-dry-run.txt new file mode 100644 index 0000000..786a769 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-rename/expected-dry-run.txt @@ -0,0 +1,32 @@ +Returns.compact:9:6 lint/forbidden-tag FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Forbidden tag @return. + + 7 │ * @description Does nothing. + 8 │ * @constraints k=6, rows=28 + > 9 │ * @return {[]} - Empty tuple. + │ ^^^^^^^ + 10 │ */ + 11 │ export circuit run(): [] { } + + i Safe fix: Rename @return to @returns + + 9 │ - ···*·@return·{[]}·-·Empty·tuple. + 9 │ + ···*·@returns·{[]}·-·Empty·tuple. + +Returns.compact:15:6 lint/forbidden-tag FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Forbidden tag @return. + + 13 │ /** + 14 │ * @description Not exported, so its docs are optional, but the tag is not. + > 15 │ * @return {[]} - Empty tuple. + │ ^^^^^^^ + 16 │ * @returns {[]} - The new spelling is left alone. + 17 │ */ + + i Safe fix: Rename @return to @returns + + 15 │ - ···*·@return·{[]}·-·Empty·tuple. + 15 │ + ···*·@returns·{[]}·-·Empty·tuple. + diff --git a/crates/compact-lint/tests/fixtures/fix-rename/expected.txt b/crates/compact-lint/tests/fixtures/fix-rename/expected.txt index 50c5c2a..13a3e1a 100644 --- a/crates/compact-lint/tests/fixtures/fix-rename/expected.txt +++ b/crates/compact-lint/tests/fixtures/fix-rename/expected.txt @@ -1,2 +1,18 @@ -Returns.compact:9:6: fix: renamed @return to @returns -Returns.compact:15:6: fix: renamed @return to @returns +Returns.compact:9:6 lint/forbidden-tag FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Forbidden tag @return. + + i Applied fix: Rename @return to @returns + + 9 │ - ···*·@return·{[]}·-·Empty·tuple. + 9 │ + ···*·@returns·{[]}·-·Empty·tuple. + +Returns.compact:15:6 lint/forbidden-tag FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Forbidden tag @return. + + i Applied fix: Rename @return to @returns + + 15 │ - ···*·@return·{[]}·-·Empty·tuple. + 15 │ + ···*·@returns·{[]}·-·Empty·tuple. + diff --git a/crates/compact-lint/tests/fixtures/fix-sections/after/Missing.compact b/crates/compact-lint/tests/fixtures/fix-sections/after/Missing.compact new file mode 100644 index 0000000..d8087b3 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-sections/after/Missing.compact @@ -0,0 +1,16 @@ +pragma language_version >= 0.23.0; + +/** + * @module Missing + * @description A module whose header stops before its security section. + * + * @notice Privacy: + * - Amounts stay private. + * + * @dev Notation: + * - `H(...)`: a `Bytes<32>` digest. + * @notice Security: + * TODO + */ +export module Missing { +} diff --git a/crates/compact-lint/tests/fixtures/fix-sections/after/Order.compact b/crates/compact-lint/tests/fixtures/fix-sections/after/Order.compact new file mode 100644 index 0000000..cabe809 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-sections/after/Order.compact @@ -0,0 +1,16 @@ +pragma language_version >= 0.23.0; + +/** + * @module Order + * @description A module whose sections run out of template order. + * + * @notice Privacy: + * - Amounts stay private. + * + * @see The compatibility suite pins the wire format. + * + * @notice Security: + * - Nonces are spend-critical. + */ +export module Order { +} diff --git a/crates/compact-lint/tests/fixtures/fix-sections/after/Skeleton.compact b/crates/compact-lint/tests/fixtures/fix-sections/after/Skeleton.compact new file mode 100644 index 0000000..13c6f80 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-sections/after/Skeleton.compact @@ -0,0 +1,12 @@ +pragma language_version >= 0.23.0; + +/** + * @module Skeleton + * @description TODO + * @notice Privacy: + * TODO + * @notice Security: + * TODO + */ +export module Skeleton { +} diff --git a/crates/compact-lint/tests/fixtures/fix-sections/before/Missing.compact b/crates/compact-lint/tests/fixtures/fix-sections/before/Missing.compact new file mode 100644 index 0000000..3eccd42 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-sections/before/Missing.compact @@ -0,0 +1,14 @@ +pragma language_version >= 0.23.0; + +/** + * @module Missing + * @description A module whose header stops before its security section. + * + * @notice Privacy: + * - Amounts stay private. + * + * @dev Notation: + * - `H(...)`: a `Bytes<32>` digest. + */ +export module Missing { +} diff --git a/crates/compact-lint/tests/fixtures/fix-sections/before/Order.compact b/crates/compact-lint/tests/fixtures/fix-sections/before/Order.compact new file mode 100644 index 0000000..ab6f159 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-sections/before/Order.compact @@ -0,0 +1,16 @@ +pragma language_version >= 0.23.0; + +/** + * @module Order + * @description A module whose sections run out of template order. + * + * @notice Security: + * - Nonces are spend-critical. + * + * @see The compatibility suite pins the wire format. + * + * @notice Privacy: + * - Amounts stay private. + */ +export module Order { +} diff --git a/crates/compact-lint/tests/fixtures/fix-sections/before/Skeleton.compact b/crates/compact-lint/tests/fixtures/fix-sections/before/Skeleton.compact new file mode 100644 index 0000000..7e9d256 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-sections/before/Skeleton.compact @@ -0,0 +1,4 @@ +pragma language_version >= 0.23.0; + +export module Skeleton { +} diff --git a/crates/compact-lint/tests/fixtures/fix-sections/compact.toml b/crates/compact-lint/tests/fixtures/fix-sections/compact.toml new file mode 100644 index 0000000..110df54 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-sections/compact.toml @@ -0,0 +1,16 @@ +[lint] +include = ["**/*.compact"] + +[lint.kinds.module] +docs = "all" +tags = ["@module", "@description", "@notice Privacy", "@notice Security"] +sections = [ + "@module", + "@description", + "@notice Privacy", + "@dev Notation", + "@notice Security", +] + +[lint.kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/fix-sections/expected-dry-run.txt b/crates/compact-lint/tests/fixtures/fix-sections/expected-dry-run.txt new file mode 100644 index 0000000..f86ba90 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-sections/expected-dry-run.txt @@ -0,0 +1,61 @@ +Missing.compact:13:1 lint/missing-tag FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Module `Missing` doc comment has no `@notice Security` section. + + 11 │ * - `H(...)`: a `Bytes<32>` digest. + 12 │ */ + > 13 │ export module Missing { + │ ^^^^^^^^^^^^^^^^^^^^^ + 14 │ } + + i Unsafe fix: Insert @notice Security + + 12 │ + ·*·@notice·Security: + 13 │ + ·*·TODO + +Order.compact:12:4 lint/tag-order FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i `@notice Security` comes before `@notice Privacy`; the template orders Privacy first. + + 10 │ * @see The compatibility suite pins the wire format. + 11 │ * + > 12 │ * @notice Privacy: + │ ^^^^^^^^^^^^^^^^ + 13 │ * - Amounts stay private. + 14 │ */ + + i Safe fix: Reorder the doc comment blocks + + 7 │ - ·*·@notice·Security: + 8 │ - ·*·-·Nonces·are·spend-critical. + 7 │ + ·*·@notice·Privacy: + 8 │ + ·*·-·Amounts·stay·private. + 9 9 │ * + 10 10 │ * @see The compatibility suite pins the wire format. + 11 11 │ * + 12 │ - ·*·@notice·Privacy: + 13 │ - ·*·-·Amounts·stay·private. + 12 │ + ·*·@notice·Security: + 13 │ + ·*·-·Nonces·are·spend-critical. + +Skeleton.compact:3:1 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Module `Skeleton` has no doc comment. + + 1 │ pragma language_version >= 0.23.0; + 2 │ + > 3 │ export module Skeleton { + │ ^^^^^^^^^^^^^^^^^^^^^^ + 4 │ } + + i Unsafe fix: Insert a doc skeleton for module `Skeleton` + + 3 │ + /** + 4 │ + ·*·@module·Skeleton + 5 │ + ·*·@description·TODO + 6 │ + ·*·@notice·Privacy: + 7 │ + ·*·TODO + 8 │ + ·*·@notice·Security: + 9 │ + ·*·TODO + 10 │ + ·*/ + diff --git a/crates/compact-lint/tests/fixtures/fix-sections/expected.txt b/crates/compact-lint/tests/fixtures/fix-sections/expected.txt new file mode 100644 index 0000000..a032734 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-sections/expected.txt @@ -0,0 +1,42 @@ +Missing.compact:13:1 lint/missing-tag FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Module `Missing` doc comment has no `@notice Security` section. + + i Applied fix: Insert @notice Security + + 12 │ + ·*·@notice·Security: + 13 │ + ·*·TODO + +Order.compact:12:4 lint/tag-order FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i `@notice Security` comes before `@notice Privacy`; the template orders Privacy first. + + i Applied fix: Reorder the doc comment blocks + + 7 │ - ·*·@notice·Security: + 8 │ - ·*·-·Nonces·are·spend-critical. + 7 │ + ·*·@notice·Privacy: + 8 │ + ·*·-·Amounts·stay·private. + 9 9 │ * + 10 10 │ * @see The compatibility suite pins the wire format. + 11 11 │ * + 12 │ - ·*·@notice·Privacy: + 13 │ - ·*·-·Amounts·stay·private. + 12 │ + ·*·@notice·Security: + 13 │ + ·*·-·Nonces·are·spend-critical. + +Skeleton.compact:3:1 lint/missing-doc FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Module `Skeleton` has no doc comment. + + i Applied fix: Insert a doc skeleton for module `Skeleton` + + 3 │ + /** + 4 │ + ·*·@module·Skeleton + 5 │ + ·*·@description·TODO + 6 │ + ·*·@notice·Privacy: + 7 │ + ·*·TODO + 8 │ + ·*·@notice·Security: + 9 │ + ·*·TODO + 10 │ + ·*/ + diff --git a/crates/compact-lint/tests/fixtures/fix-skeleton/compact-lint.toml b/crates/compact-lint/tests/fixtures/fix-skeleton/compact.toml similarity index 62% rename from crates/compact-lint/tests/fixtures/fix-skeleton/compact-lint.toml rename to crates/compact-lint/tests/fixtures/fix-skeleton/compact.toml index 5fd3408..234ff29 100644 --- a/crates/compact-lint/tests/fixtures/fix-skeleton/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/fix-skeleton/compact.toml @@ -1,31 +1,31 @@ -version = 1 +[lint] include = ["**/*.compact"] -[fix] +[lint.fix] placeholder = "TODO" -[tags] +[lint.tags] forbid = ["@return"] rename = { "@return" = "@returns" } -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] -[kinds.ledger] +[lint.kinds.ledger] tags = ["@description"] -[kinds.witness] +[lint.kinds.witness] docs = "all" tags = ["@description"] -[kinds.struct] +[lint.kinds.struct] tags = ["@description"] -[kinds.enum] +[lint.kinds.enum] tags = ["@description"] -[kinds.constructor] +[lint.kinds.constructor] docs = "none" diff --git a/crates/compact-lint/tests/fixtures/fix-skeleton/expected-dry-run.txt b/crates/compact-lint/tests/fixtures/fix-skeleton/expected-dry-run.txt new file mode 100644 index 0000000..c0b7620 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/fix-skeleton/expected-dry-run.txt @@ -0,0 +1,139 @@ +Bare.compact:3:1 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Module `Bare` has no doc comment. + + 1 │ pragma language_version >= 0.23.0; + 2 │ + > 3 │ export module Bare { + │ ^^^^^^^^^^^^^^^^^^ + 4 │ import CompactStandardLibrary; + 5 │ + + i Unsafe fix: Insert a doc skeleton for module `Bare` + + 3 │ + /** + 4 │ + ·*·@module·Bare + 5 │ + ·*·@description·TODO + 6 │ + ·*/ + +Bare.compact:6:3 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Ledger `_count` has no doc comment. + + 4 │ import CompactStandardLibrary; + 5 │ + > 6 │ export ledger _count: Counter; + │ ^^^^^^^^^^^^^^^^^^^^ + 7 │ + 8 │ witness wit_Secret(): Bytes<32>; + + i Unsafe fix: Insert a doc skeleton for ledger `_count` + + 6 │ + ··/** + 7 │ + ···*·@description·TODO + 8 │ + ···*/ + +Bare.compact:8:3 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Witness `wit_Secret` has no doc comment. + + 6 │ export ledger _count: Counter; + 7 │ + > 8 │ witness wit_Secret(): Bytes<32>; + │ ^^^^^^^^^^^^^^^^^^ + 9 │ + 10 │ export struct Reading { + + i Unsafe fix: Insert a doc skeleton for witness `wit_Secret` + + 8 │ + ··/** + 9 │ + ···*·@description·TODO + 10 │ + ···*/ + +Bare.compact:10:3 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Struct `Reading` has no doc comment. + + 8 │ witness wit_Secret(): Bytes<32>; + 9 │ + > 10 │ export struct Reading { + │ ^^^^^^^^^^^^^^^^^^^^^ + 11 │ value: Uint<64> + 12 │ } + + i Unsafe fix: Insert a doc skeleton for struct `Reading` + + 10 │ + ··/** + 11 │ + ···*·@description·TODO + 12 │ + ···*/ + +Bare.compact:14:3 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Enum `Direction` has no doc comment. + + 12 │ } + 13 │ + > 14 │ export enum Direction { + │ ^^^^^^^^^^^^^^^^^^^^^ + 15 │ Up, + 16 │ Down + + i Unsafe fix: Insert a doc skeleton for enum `Direction` + + 14 │ + ··/** + 15 │ + ···*·@description·TODO + 16 │ + ···*/ + +Bare.compact:19:3 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Type `Alias` has no doc comment. + + 17 │ } + 18 │ + > 19 │ export type Alias = Bytes<32>; + │ ^^^^^^^^^^^^^^^^^ + 20 │ + 21 │ constructor() { + + i Unsafe fix: Insert a doc skeleton for type `Alias` + + 19 │ + ··/** + 20 │ + ···*·TODO + 21 │ + ···*/ + +Bare.compact:25:3 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `double` has no doc comment. + + 23 │ } + 24 │ + > 25 │ export pure circuit double(value: Uint<8>): Uint<16> { + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^ + 26 │ return value as Uint<16> * 2; + 27 │ } + + i Unsafe fix: Insert a doc skeleton for circuit `double` + + 25 │ + ··/** + 26 │ + ···*·@description·TODO + 27 │ + ···*/ + +Bare.compact:29:3 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `bump` has no doc comment. + + 27 │ } + 28 │ + > 29 │ export circuit bump(): [] { + │ ^^^^^^^^^^^^^^^^^^^ + 30 │ _count.increment(1); + 31 │ } + + i Unsafe fix: Insert a doc skeleton for circuit `bump` + + 29 │ + ··/** + 30 │ + ···*·@description·TODO + 31 │ + ···* + 32 │ + ···*·@constraints·k=?,·rows=? + 33 │ + ···*/ + diff --git a/crates/compact-lint/tests/fixtures/fix-skeleton/expected.txt b/crates/compact-lint/tests/fixtures/fix-skeleton/expected.txt index 8dadb68..f872b5e 100644 --- a/crates/compact-lint/tests/fixtures/fix-skeleton/expected.txt +++ b/crates/compact-lint/tests/fixtures/fix-skeleton/expected.txt @@ -1,8 +1,83 @@ -Bare.compact:3:1: fix: inserted doc skeleton for module `Bare` -Bare.compact:6:3: fix: inserted doc skeleton for ledger `_count` -Bare.compact:8:3: fix: inserted doc skeleton for witness `wit_Secret` -Bare.compact:10:3: fix: inserted doc skeleton for struct `Reading` -Bare.compact:14:3: fix: inserted doc skeleton for enum `Direction` -Bare.compact:19:3: fix: inserted doc skeleton for type `Alias` -Bare.compact:25:3: fix: inserted doc skeleton for circuit `double` -Bare.compact:29:3: fix: inserted doc skeleton for circuit `bump` +Bare.compact:3:1 lint/missing-doc FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Module `Bare` has no doc comment. + + i Applied fix: Insert a doc skeleton for module `Bare` + + 3 │ + /** + 4 │ + ·*·@module·Bare + 5 │ + ·*·@description·TODO + 6 │ + ·*/ + +Bare.compact:6:3 lint/missing-doc FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Ledger `_count` has no doc comment. + + i Applied fix: Insert a doc skeleton for ledger `_count` + + 6 │ + ··/** + 7 │ + ···*·@description·TODO + 8 │ + ···*/ + +Bare.compact:8:3 lint/missing-doc FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Witness `wit_Secret` has no doc comment. + + i Applied fix: Insert a doc skeleton for witness `wit_Secret` + + 8 │ + ··/** + 9 │ + ···*·@description·TODO + 10 │ + ···*/ + +Bare.compact:10:3 lint/missing-doc FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Struct `Reading` has no doc comment. + + i Applied fix: Insert a doc skeleton for struct `Reading` + + 10 │ + ··/** + 11 │ + ···*·@description·TODO + 12 │ + ···*/ + +Bare.compact:14:3 lint/missing-doc FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Enum `Direction` has no doc comment. + + i Applied fix: Insert a doc skeleton for enum `Direction` + + 14 │ + ··/** + 15 │ + ···*·@description·TODO + 16 │ + ···*/ + +Bare.compact:19:3 lint/missing-doc FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Type `Alias` has no doc comment. + + i Applied fix: Insert a doc skeleton for type `Alias` + + 19 │ + ··/** + 20 │ + ···*·TODO + 21 │ + ···*/ + +Bare.compact:25:3 lint/missing-doc FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `double` has no doc comment. + + i Applied fix: Insert a doc skeleton for circuit `double` + + 25 │ + ··/** + 26 │ + ···*·@description·TODO + 27 │ + ···*/ + +Bare.compact:29:3 lint/missing-doc FIXED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Circuit `bump` has no doc comment. + + i Applied fix: Insert a doc skeleton for circuit `bump` + + 29 │ + ··/** + 30 │ + ···*·@description·TODO + 31 │ + ···* + 32 │ + ···*·@constraints·k=?,·rows=? + 33 │ + ···*/ + diff --git a/crates/compact-lint/tests/fixtures/fix-unfixable/compact-lint.toml b/crates/compact-lint/tests/fixtures/fix-unfixable/compact.toml similarity index 70% rename from crates/compact-lint/tests/fixtures/fix-unfixable/compact-lint.toml rename to crates/compact-lint/tests/fixtures/fix-unfixable/compact.toml index 9bb3ab8..3e20ce9 100644 --- a/crates/compact-lint/tests/fixtures/fix-unfixable/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/fix-unfixable/compact.toml @@ -1,15 +1,15 @@ -version = 1 +[lint] include = ["**/*.compact"] # `@throws` is forbidden but has no replacement, so `fix` leaves it where it is. -[tags] +[lint.tags] forbid = ["@throws"] -[kinds.module] +[lint.kinds.module] tags = ["@module", "@description"] -[kinds.circuit] +[lint.kinds.circuit] tags = ["@description"] -[kinds.constructor] +[lint.kinds.constructor] docs = "none" diff --git a/crates/compact-lint/tests/fixtures/fix-unfixable/expected-dry-run.txt b/crates/compact-lint/tests/fixtures/fix-unfixable/expected-dry-run.txt new file mode 100644 index 0000000..e69de29 diff --git a/crates/compact-lint/tests/fixtures/forbidden-tag/compact.toml b/crates/compact-lint/tests/fixtures/forbidden-tag/compact.toml new file mode 100644 index 0000000..01fa5a7 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/forbidden-tag/compact.toml @@ -0,0 +1,27 @@ +[lint] +include = ["**/*.compact"] + +[lint.tags] +forbid = ["@return"] + +[lint.kinds.module] +tags = ["@module", "@description"] + +[lint.kinds.circuit] +tags = ["@description"] + +[lint.kinds.ledger] +tags = ["@description"] + +[lint.kinds.witness] +docs = "all" +tags = ["@description"] + +[lint.kinds.struct] +tags = ["@description"] + +[lint.kinds.enum] +tags = ["@description"] + +[lint.kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/forbidden-tag/expected.txt b/crates/compact-lint/tests/fixtures/forbidden-tag/expected.txt index 0edaa79..088a709 100644 --- a/crates/compact-lint/tests/fixtures/forbidden-tag/expected.txt +++ b/crates/compact-lint/tests/fixtures/forbidden-tag/expected.txt @@ -1,2 +1,26 @@ -Returns.compact:9:6: forbidden-tag: forbidden tag @return -Returns.compact:15:6: forbidden-tag: forbidden tag @return +Returns.compact:9:6 lint/forbidden-tag ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Forbidden tag @return. + + 7 │ * @description Does nothing. + 8 │ * @constraints k=6, rows=28 + > 9 │ * @return {[]} - Empty tuple. + │ ^^^^^^^ + 10 │ */ + 11 │ export circuit run(): [] { } + + i Remove it from the doc comment. + +Returns.compact:15:6 lint/forbidden-tag ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Forbidden tag @return. + + 13 │ /** + 14 │ * @description Not exported, so its docs are optional, but the tag is not. + > 15 │ * @return {[]} - Empty tuple. + │ ^^^^^^^ + 16 │ */ + 17 │ circuit helper(): [] { } + + i Remove it from the doc comment. + diff --git a/crates/compact-lint/tests/fixtures/max-diagnostics/Many.compact b/crates/compact-lint/tests/fixtures/max-diagnostics/Many.compact new file mode 100644 index 0000000..86ba24a --- /dev/null +++ b/crates/compact-lint/tests/fixtures/max-diagnostics/Many.compact @@ -0,0 +1,11 @@ +/** + * @module Many + * @description A module with more findings than the cap shows. + */ +export module Many { + export ledger _first: Bytes<32>; + + export ledger _second: Bytes<32>; + + export ledger _third: Bytes<32>; +} diff --git a/crates/compact-lint/tests/fixtures/max-diagnostics/compact.toml b/crates/compact-lint/tests/fixtures/max-diagnostics/compact.toml new file mode 100644 index 0000000..01fa5a7 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/max-diagnostics/compact.toml @@ -0,0 +1,27 @@ +[lint] +include = ["**/*.compact"] + +[lint.tags] +forbid = ["@return"] + +[lint.kinds.module] +tags = ["@module", "@description"] + +[lint.kinds.circuit] +tags = ["@description"] + +[lint.kinds.ledger] +tags = ["@description"] + +[lint.kinds.witness] +docs = "all" +tags = ["@description"] + +[lint.kinds.struct] +tags = ["@description"] + +[lint.kinds.enum] +tags = ["@description"] + +[lint.kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/max-diagnostics/expected.txt b/crates/compact-lint/tests/fixtures/max-diagnostics/expected.txt new file mode 100644 index 0000000..baf2938 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/max-diagnostics/expected.txt @@ -0,0 +1,19 @@ +Many.compact:6:3 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Ledger `_first` has no doc comment. + + 4 │ */ + 5 │ export module Many { + > 6 │ export ledger _first: Bytes<32>; + │ ^^^^^^^^^^^^^^^^^^^^ + 7 │ + 8 │ export ledger _second: Bytes<32>; + + i Add a doc comment above it, or run compact-lint fix. + + i Unsafe fix: Insert a doc skeleton for ledger `_first` + + 6 │ + ··/** + 7 │ + ···*·@description·TODO + 8 │ + ···*/ + diff --git a/crates/compact-lint/tests/fixtures/missing-constraints/compact-lint.toml b/crates/compact-lint/tests/fixtures/missing-constraints/compact-lint.toml deleted file mode 100644 index 0813e24..0000000 --- a/crates/compact-lint/tests/fixtures/missing-constraints/compact-lint.toml +++ /dev/null @@ -1,27 +0,0 @@ -version = 1 -include = ["**/*.compact"] - -[tags] -forbid = ["@return"] - -[kinds.module] -tags = ["@module", "@description"] - -[kinds.circuit] -tags = ["@description"] - -[kinds.ledger] -tags = ["@description"] - -[kinds.witness] -docs = "all" -tags = ["@description"] - -[kinds.struct] -tags = ["@description"] - -[kinds.enum] -tags = ["@description"] - -[kinds.constructor] -docs = "none" diff --git a/crates/compact-lint/tests/fixtures/missing-constraints/compact.toml b/crates/compact-lint/tests/fixtures/missing-constraints/compact.toml new file mode 100644 index 0000000..01fa5a7 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/missing-constraints/compact.toml @@ -0,0 +1,27 @@ +[lint] +include = ["**/*.compact"] + +[lint.tags] +forbid = ["@return"] + +[lint.kinds.module] +tags = ["@module", "@description"] + +[lint.kinds.circuit] +tags = ["@description"] + +[lint.kinds.ledger] +tags = ["@description"] + +[lint.kinds.witness] +docs = "all" +tags = ["@description"] + +[lint.kinds.struct] +tags = ["@description"] + +[lint.kinds.enum] +tags = ["@description"] + +[lint.kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/missing-constraints/expected.txt b/crates/compact-lint/tests/fixtures/missing-constraints/expected.txt index 18ec688..d63153c 100644 --- a/crates/compact-lint/tests/fixtures/missing-constraints/expected.txt +++ b/crates/compact-lint/tests/fixtures/missing-constraints/expected.txt @@ -1 +1,18 @@ -Unmeasured.compact:9:3: missing-constraints: circuit `run` doc comment has no @constraints +Unmeasured.compact:9:3 lint/missing-constraints FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Circuit `run` doc comment has no @constraints. + + 7 │ * @description Writes to the ledger without an annotation. + 8 │ */ + > 9 │ export circuit run(): [] { } + │ ^^^^^^^^^^^^^^^^^^ + 10 │ + 11 │ /** + + i Run compact-lint fill-constraints after adding the tag, or add it as @constraints k=?, rows=? to fill later. + + i Unsafe fix: Insert @constraints k=?, rows=? + + 8 │ + ···* + 9 │ + ···*·@constraints·k=?,·rows=? + diff --git a/crates/compact-lint/tests/fixtures/missing-doc/compact-lint.toml b/crates/compact-lint/tests/fixtures/missing-doc/compact-lint.toml deleted file mode 100644 index 0813e24..0000000 --- a/crates/compact-lint/tests/fixtures/missing-doc/compact-lint.toml +++ /dev/null @@ -1,27 +0,0 @@ -version = 1 -include = ["**/*.compact"] - -[tags] -forbid = ["@return"] - -[kinds.module] -tags = ["@module", "@description"] - -[kinds.circuit] -tags = ["@description"] - -[kinds.ledger] -tags = ["@description"] - -[kinds.witness] -docs = "all" -tags = ["@description"] - -[kinds.struct] -tags = ["@description"] - -[kinds.enum] -tags = ["@description"] - -[kinds.constructor] -docs = "none" diff --git a/crates/compact-lint/tests/fixtures/missing-doc/compact.toml b/crates/compact-lint/tests/fixtures/missing-doc/compact.toml new file mode 100644 index 0000000..01fa5a7 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/missing-doc/compact.toml @@ -0,0 +1,27 @@ +[lint] +include = ["**/*.compact"] + +[lint.tags] +forbid = ["@return"] + +[lint.kinds.module] +tags = ["@module", "@description"] + +[lint.kinds.circuit] +tags = ["@description"] + +[lint.kinds.ledger] +tags = ["@description"] + +[lint.kinds.witness] +docs = "all" +tags = ["@description"] + +[lint.kinds.struct] +tags = ["@description"] + +[lint.kinds.enum] +tags = ["@description"] + +[lint.kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/missing-doc/expected.txt b/crates/compact-lint/tests/fixtures/missing-doc/expected.txt index b3649c7..0230db5 100644 --- a/crates/compact-lint/tests/fixtures/missing-doc/expected.txt +++ b/crates/compact-lint/tests/fixtures/missing-doc/expected.txt @@ -1,3 +1,59 @@ -Gaps.compact:6:3: missing-doc: ledger `_owner` has no doc comment -Gaps.compact:8:3: missing-doc: enum `Kind` has no doc comment -Gaps.compact:17:3: missing-doc: circuit `run` has no doc comment +Gaps.compact:6:3 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Ledger `_owner` has no doc comment. + + 4 │ */ + 5 │ export module Gaps { + > 6 │ export ledger _owner: Bytes<32>; + │ ^^^^^^^^^^^^^^^^^^^^ + 7 │ + 8 │ export enum Kind { + + i Add a doc comment above it, or run compact-lint fix. + + i Unsafe fix: Insert a doc skeleton for ledger `_owner` + + 6 │ + ··/** + 7 │ + ···*·@description·TODO + 8 │ + ···*/ + +Gaps.compact:8:3 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Enum `Kind` has no doc comment. + + 6 │ export ledger _owner: Bytes<32>; + 7 │ + > 8 │ export enum Kind { + │ ^^^^^^^^^^^^^^^^ + 9 │ One + 10 │ } + + i Add a doc comment above it, or run compact-lint fix. + + i Unsafe fix: Insert a doc skeleton for enum `Kind` + + 8 │ + ··/** + 9 │ + ···*·@description·TODO + 10 │ + ···*/ + +Gaps.compact:17:3 lint/missing-doc FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Circuit `run` has no doc comment. + + 15 │ */ + 16 │ // The comment that breaks the attachment. + > 17 │ export circuit run(): [] { + │ ^^^^^^^^^^^^^^^^^^ + 18 │ _owner = default>; + 19 │ } + + i Add a doc comment above it, or run compact-lint fix. + + i Unsafe fix: Insert a doc skeleton for circuit `run` + + 17 │ + ··/** + 18 │ + ···*·@description·TODO + 19 │ + ···* + 20 │ + ···*·@constraints·k=?,·rows=? + 21 │ + ···*/ + diff --git a/crates/compact-lint/tests/fixtures/missing-tag/compact-lint.toml b/crates/compact-lint/tests/fixtures/missing-tag/compact-lint.toml deleted file mode 100644 index 0813e24..0000000 --- a/crates/compact-lint/tests/fixtures/missing-tag/compact-lint.toml +++ /dev/null @@ -1,27 +0,0 @@ -version = 1 -include = ["**/*.compact"] - -[tags] -forbid = ["@return"] - -[kinds.module] -tags = ["@module", "@description"] - -[kinds.circuit] -tags = ["@description"] - -[kinds.ledger] -tags = ["@description"] - -[kinds.witness] -docs = "all" -tags = ["@description"] - -[kinds.struct] -tags = ["@description"] - -[kinds.enum] -tags = ["@description"] - -[kinds.constructor] -docs = "none" diff --git a/crates/compact-lint/tests/fixtures/missing-tag/compact.toml b/crates/compact-lint/tests/fixtures/missing-tag/compact.toml new file mode 100644 index 0000000..01fa5a7 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/missing-tag/compact.toml @@ -0,0 +1,27 @@ +[lint] +include = ["**/*.compact"] + +[lint.tags] +forbid = ["@return"] + +[lint.kinds.module] +tags = ["@module", "@description"] + +[lint.kinds.circuit] +tags = ["@description"] + +[lint.kinds.ledger] +tags = ["@description"] + +[lint.kinds.witness] +docs = "all" +tags = ["@description"] + +[lint.kinds.struct] +tags = ["@description"] + +[lint.kinds.enum] +tags = ["@description"] + +[lint.kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/missing-tag/expected.txt b/crates/compact-lint/tests/fixtures/missing-tag/expected.txt index 9fb4a3b..8cf0d4a 100644 --- a/crates/compact-lint/tests/fixtures/missing-tag/expected.txt +++ b/crates/compact-lint/tests/fixtures/missing-tag/expected.txt @@ -1,2 +1,34 @@ -Untagged.compact:4:1: missing-tag: module `Untagged` doc comment has no @description -Untagged.compact:9:3: missing-tag: circuit `run` doc comment has no @description +Untagged.compact:4:1 lint/missing-tag FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Module `Untagged` doc comment has no @description. + + 2 │ * @module Untagged + 3 │ */ + > 4 │ export module Untagged { + │ ^^^^^^^^^^^^^^^^^^^^^^ + 5 │ /** + 6 │ * The module has no @description, and neither has this circuit. + + i Add @description to the doc comment, or run compact-lint fix. + + i Unsafe fix: Insert @description + + 3 │ + ·*·@description·TODO + +Untagged.compact:9:3 lint/missing-tag FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Circuit `run` doc comment has no @description. + + 7 │ * @constraints k=6, rows=28 + 8 │ */ + > 9 │ export circuit run(): [] { } + │ ^^^^^^^^^^^^^^^^^^ + 10 │ } + + i Add @description to the doc comment, or run compact-lint fix. + + i Unsafe fix: Insert @description + + 6 │ - ···*·The·module·has·no·@description,·and·neither·has·this·circuit. + 6 │ + ···*·@description·The·module·has·no·@description,·and·neither·has·this·circuit. + diff --git a/crates/compact-lint/tests/fixtures/module-name/compact-lint.toml b/crates/compact-lint/tests/fixtures/module-name/compact-lint.toml deleted file mode 100644 index 0813e24..0000000 --- a/crates/compact-lint/tests/fixtures/module-name/compact-lint.toml +++ /dev/null @@ -1,27 +0,0 @@ -version = 1 -include = ["**/*.compact"] - -[tags] -forbid = ["@return"] - -[kinds.module] -tags = ["@module", "@description"] - -[kinds.circuit] -tags = ["@description"] - -[kinds.ledger] -tags = ["@description"] - -[kinds.witness] -docs = "all" -tags = ["@description"] - -[kinds.struct] -tags = ["@description"] - -[kinds.enum] -tags = ["@description"] - -[kinds.constructor] -docs = "none" diff --git a/crates/compact-lint/tests/fixtures/module-name/compact.toml b/crates/compact-lint/tests/fixtures/module-name/compact.toml new file mode 100644 index 0000000..01fa5a7 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/module-name/compact.toml @@ -0,0 +1,27 @@ +[lint] +include = ["**/*.compact"] + +[lint.tags] +forbid = ["@return"] + +[lint.kinds.module] +tags = ["@module", "@description"] + +[lint.kinds.circuit] +tags = ["@description"] + +[lint.kinds.ledger] +tags = ["@description"] + +[lint.kinds.witness] +docs = "all" +tags = ["@description"] + +[lint.kinds.struct] +tags = ["@description"] + +[lint.kinds.enum] +tags = ["@description"] + +[lint.kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/module-name/expected.txt b/crates/compact-lint/tests/fixtures/module-name/expected.txt index 6d31a1c..8370e2f 100644 --- a/crates/compact-lint/tests/fixtures/module-name/expected.txt +++ b/crates/compact-lint/tests/fixtures/module-name/expected.txt @@ -1 +1,17 @@ -Renamed.compact:2:4: module-name: @module names `Stale`, but the module is `Renamed` +Renamed.compact:2:4 lint/module-name FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × @module names `Stale`, but the module is `Renamed`. + + 1 │ /** + > 2 │ * @module Stale + │ ^^^^^^^ + 3 │ * @description The tag still names the module's old name. + 4 │ */ + + i Set the tag to Renamed, or run compact-lint fix. + + i Safe fix: Set @module to `Renamed` + + 2 │ - ·*·@module·Stale + 2 │ + ·*·@module·Renamed + diff --git a/crates/compact-lint/tests/fixtures/no-lint-table/compact.toml b/crates/compact-lint/tests/fixtures/no-lint-table/compact.toml new file mode 100644 index 0000000..e7d243d --- /dev/null +++ b/crates/compact-lint/tests/fixtures/no-lint-table/compact.toml @@ -0,0 +1,14 @@ +# compact-deploy's tables only: the file exists, the linter's table does not. +[profile] +default_network = "local" + +[networks.local] +network_id = "undeployed" +indexer = "http://127.0.0.1:8088/api/v4/graphql" +indexer_ws = "ws://127.0.0.1:8088/api/v4/graphql/ws" +node = "http://127.0.0.1:9944" +node_ws = "ws://127.0.0.1:9944" + +[contracts.Token] +artifact = "Token" +signing_key_file = "deploy/Token.signingkey" diff --git a/crates/compact-lint/tests/fixtures/parse/compact-lint.toml b/crates/compact-lint/tests/fixtures/parse/compact.toml similarity index 69% rename from crates/compact-lint/tests/fixtures/parse/compact-lint.toml rename to crates/compact-lint/tests/fixtures/parse/compact.toml index 56ee774..e7401e9 100644 --- a/crates/compact-lint/tests/fixtures/parse/compact-lint.toml +++ b/crates/compact-lint/tests/fixtures/parse/compact.toml @@ -1,2 +1,2 @@ -version = 1 +[lint] include = ["**/*.compact"] diff --git a/crates/compact-lint/tests/fixtures/parse/expected.txt b/crates/compact-lint/tests/fixtures/parse/expected.txt index b50d9d7..0035b68 100644 --- a/crates/compact-lint/tests/fixtures/parse/expected.txt +++ b/crates/compact-lint/tests/fixtures/parse/expected.txt @@ -1 +1,11 @@ -Broken.compact:2:3: parse: parse error: unexpected ERROR +Broken.compact:2:3 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Parse error: unexpected ERROR. + + 1 │ export module Broken { + > 2 │ export circuit + │ ^^^^^^^^^^^^^^ + 3 │ } + + i Fix the syntax error; the doc rules are skipped for this file. + diff --git a/crates/compact-lint/tests/fixtures/reporter-concise/Two.compact b/crates/compact-lint/tests/fixtures/reporter-concise/Two.compact new file mode 100644 index 0000000..4ff04b6 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/reporter-concise/Two.compact @@ -0,0 +1,16 @@ +/** + * @module Two + * @description A module with one finding of each level. + */ +export module Two { + /** + * @description Not measured yet. + * @constraints k=?, rows=? + */ + export circuit run(): [] { } + + /** + * @description Unannotated. + */ + export circuit bump(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/reporter-concise/compact.toml b/crates/compact-lint/tests/fixtures/reporter-concise/compact.toml new file mode 100644 index 0000000..01fa5a7 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/reporter-concise/compact.toml @@ -0,0 +1,27 @@ +[lint] +include = ["**/*.compact"] + +[lint.tags] +forbid = ["@return"] + +[lint.kinds.module] +tags = ["@module", "@description"] + +[lint.kinds.circuit] +tags = ["@description"] + +[lint.kinds.ledger] +tags = ["@description"] + +[lint.kinds.witness] +docs = "all" +tags = ["@description"] + +[lint.kinds.struct] +tags = ["@description"] + +[lint.kinds.enum] +tags = ["@description"] + +[lint.kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/reporter-concise/expected.txt b/crates/compact-lint/tests/fixtures/reporter-concise/expected.txt new file mode 100644 index 0000000..fb70c75 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/reporter-concise/expected.txt @@ -0,0 +1,2 @@ +! Two.compact:8:6: lint/constraints-placeholder: @constraints value `k=?, rows=?` still holds a placeholder. +× Two.compact:15:3: lint/missing-constraints: Circuit `bump` doc comment has no @constraints. diff --git a/crates/compact-lint/tests/fixtures/reporter-github/Two.compact b/crates/compact-lint/tests/fixtures/reporter-github/Two.compact new file mode 100644 index 0000000..4ff04b6 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/reporter-github/Two.compact @@ -0,0 +1,16 @@ +/** + * @module Two + * @description A module with one finding of each level. + */ +export module Two { + /** + * @description Not measured yet. + * @constraints k=?, rows=? + */ + export circuit run(): [] { } + + /** + * @description Unannotated. + */ + export circuit bump(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/reporter-github/compact.toml b/crates/compact-lint/tests/fixtures/reporter-github/compact.toml new file mode 100644 index 0000000..01fa5a7 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/reporter-github/compact.toml @@ -0,0 +1,27 @@ +[lint] +include = ["**/*.compact"] + +[lint.tags] +forbid = ["@return"] + +[lint.kinds.module] +tags = ["@module", "@description"] + +[lint.kinds.circuit] +tags = ["@description"] + +[lint.kinds.ledger] +tags = ["@description"] + +[lint.kinds.witness] +docs = "all" +tags = ["@description"] + +[lint.kinds.struct] +tags = ["@description"] + +[lint.kinds.enum] +tags = ["@description"] + +[lint.kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/reporter-github/expected.txt b/crates/compact-lint/tests/fixtures/reporter-github/expected.txt new file mode 100644 index 0000000..f56af50 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/reporter-github/expected.txt @@ -0,0 +1,2 @@ +::warning title=lint/constraints-placeholder,file=Two.compact,line=8,endLine=8,col=6,endColumn=18::@constraints value `k=?, rows=?` still holds a placeholder. +::error title=lint/missing-constraints,file=Two.compact,line=15,endLine=15,col=3,endColumn=22::Circuit `bump` doc comment has no @constraints. diff --git a/crates/compact-lint/tests/fixtures/rules-levels/Levels.compact b/crates/compact-lint/tests/fixtures/rules-levels/Levels.compact new file mode 100644 index 0000000..eda738a --- /dev/null +++ b/crates/compact-lint/tests/fixtures/rules-levels/Levels.compact @@ -0,0 +1,14 @@ +/** + * @module Levels + * @description A module whose rules are set to other levels. + */ +export module Levels { + export ledger _owner: Bytes<32>; + + /** + * @description Does nothing. + * @constraints k=6, rows=28 + * @return {[]} - Empty tuple. + */ + export circuit run(): [] { } +} diff --git a/crates/compact-lint/tests/fixtures/rules-levels/compact.toml b/crates/compact-lint/tests/fixtures/rules-levels/compact.toml new file mode 100644 index 0000000..02a0b35 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/rules-levels/compact.toml @@ -0,0 +1,32 @@ +[lint] +include = ["**/*.compact"] + +[lint.tags] +forbid = ["@return"] +rename = { "@return" = "@returns" } + +[lint.kinds.module] +tags = ["@module", "@description"] + +[lint.kinds.circuit] +tags = ["@description"] + +[lint.kinds.ledger] +tags = ["@description"] + +[lint.kinds.witness] +docs = "all" +tags = ["@description"] + +[lint.kinds.struct] +tags = ["@description"] + +[lint.kinds.enum] +tags = ["@description"] + +[lint.kinds.constructor] +docs = "none" + +[lint.rules] +missing-doc = "off" +forbidden-tag = "warn" diff --git a/crates/compact-lint/tests/fixtures/rules-levels/expected.txt b/crates/compact-lint/tests/fixtures/rules-levels/expected.txt new file mode 100644 index 0000000..c88533c --- /dev/null +++ b/crates/compact-lint/tests/fixtures/rules-levels/expected.txt @@ -0,0 +1,18 @@ +Levels.compact:11:6 lint/forbidden-tag FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Forbidden tag @return. + + 9 │ * @description Does nothing. + 10 │ * @constraints k=6, rows=28 + > 11 │ * @return {[]} - Empty tuple. + │ ^^^^^^^ + 12 │ */ + 13 │ export circuit run(): [] { } + + i Rename it to @returns. + + i Safe fix: Rename @return to @returns + + 11 │ - ···*·@return·{[]}·-·Empty·tuple. + 11 │ + ···*·@returns·{[]}·-·Empty·tuple. + diff --git a/crates/compact-lint/tests/fixtures/sections-missing/Sections.compact b/crates/compact-lint/tests/fixtures/sections-missing/Sections.compact new file mode 100644 index 0000000..69b4da1 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/sections-missing/Sections.compact @@ -0,0 +1,14 @@ +pragma language_version >= 0.23.0; + +/** + * @module Sections + * @description A module whose header stops before its security section. + * + * @notice Privacy: + * - Amounts stay private. + * + * @dev Notation: + * - `H(...)`: a `Bytes<32>` digest. + */ +export module Sections { +} diff --git a/crates/compact-lint/tests/fixtures/sections-missing/compact.toml b/crates/compact-lint/tests/fixtures/sections-missing/compact.toml new file mode 100644 index 0000000..3432db9 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/sections-missing/compact.toml @@ -0,0 +1,13 @@ +[lint] +include = ["**/*.compact"] + +[lint.kinds.module] +docs = "all" +tags = ["@module", "@description", "@notice Privacy", "@notice Security"] +sections = [ + "@module", + "@description", + "@notice Privacy", + "@dev Notation", + "@notice Security", +] diff --git a/crates/compact-lint/tests/fixtures/sections-missing/expected.txt b/crates/compact-lint/tests/fixtures/sections-missing/expected.txt new file mode 100644 index 0000000..288e525 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/sections-missing/expected.txt @@ -0,0 +1,17 @@ +Sections.compact:13:1 lint/missing-tag FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Module `Sections` doc comment has no `@notice Security` section. + + 11 │ * - `H(...)`: a `Bytes<32>` digest. + 12 │ */ + > 13 │ export module Sections { + │ ^^^^^^^^^^^^^^^^^^^^^^ + 14 │ } + + i Add a @notice Security: section, or run compact-lint fix. + + i Unsafe fix: Insert @notice Security + + 12 │ + ·*·@notice·Security: + 13 │ + ·*·TODO + diff --git a/crates/compact-lint/tests/fixtures/tag-order/Ordered.compact b/crates/compact-lint/tests/fixtures/tag-order/Ordered.compact new file mode 100644 index 0000000..bbfb946 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/tag-order/Ordered.compact @@ -0,0 +1,14 @@ +pragma language_version >= 0.23.0; + +/** + * @module Ordered + * @description A module whose security section runs ahead of its privacy one. + * + * @notice Security: + * - Nonces are spend-critical. + * + * @notice Privacy: + * - Amounts stay private. + */ +export module Ordered { +} diff --git a/crates/compact-lint/tests/fixtures/tag-order/compact.toml b/crates/compact-lint/tests/fixtures/tag-order/compact.toml new file mode 100644 index 0000000..3432db9 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/tag-order/compact.toml @@ -0,0 +1,13 @@ +[lint] +include = ["**/*.compact"] + +[lint.kinds.module] +docs = "all" +tags = ["@module", "@description", "@notice Privacy", "@notice Security"] +sections = [ + "@module", + "@description", + "@notice Privacy", + "@dev Notation", + "@notice Security", +] diff --git a/crates/compact-lint/tests/fixtures/tag-order/expected.txt b/crates/compact-lint/tests/fixtures/tag-order/expected.txt new file mode 100644 index 0000000..4083b3b --- /dev/null +++ b/crates/compact-lint/tests/fixtures/tag-order/expected.txt @@ -0,0 +1,24 @@ +Ordered.compact:10:4 lint/tag-order FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × `@notice Security` comes before `@notice Privacy`; the template orders Privacy first. + + 8 │ * - Nonces are spend-critical. + 9 │ * + > 10 │ * @notice Privacy: + │ ^^^^^^^^^^^^^^^^ + 11 │ * - Amounts stay private. + 12 │ */ + + i Run compact-lint fix to reorder the blocks. + + i Safe fix: Reorder the doc comment blocks + + 7 │ + ·*·@notice·Privacy: + 8 │ + ·*·-·Amounts·stay·private. + 9 │ + ·* + 7 10 │ * @notice Security: + 8 11 │ * - Nonces are spend-critical. + 9 │ - ·* + 10 │ - ·*·@notice·Privacy: + 11 │ - ·*·-·Amounts·stay·private. + diff --git a/crates/compact-lint/tests/fixtures/template-clean/Template.compact b/crates/compact-lint/tests/fixtures/template-clean/Template.compact new file mode 100644 index 0000000..8429807 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/template-clean/Template.compact @@ -0,0 +1,51 @@ +pragma language_version >= 0.23.0; + +/** + * @module Template + * @description + * A tally kept as one public counter, with the caller hidden. + * + * @dev Notation: + * - `tally`: the public counter this module owns. + * + * @dev Compatibility: + * The wire format is the counter alone; nothing here is versioned. + * + * @notice Privacy: + * - Public: the entry point, and the counter itself. + * - Private: which account called, and how often. + * + * @notice Security: + * - `bump` is ungated: re-exporting it is a permissionless write. + * - A failed assert rejects the whole tx and the caller pays no fee. + * + * @notice Concurrency: + * - `increment` commutes, so two calls in one block both land. + * + * @notice Scope: + * Counting only: no roles, no initialization, no supply. + * + * @notice Scalability: + * - State growth is one counter, whatever the number of calls. + * + * @dev Performance: + * - Cost is one counter write per call; per-circuit `k` and rows are on each + * `@constraints` line. + */ +export module Template { + import CompactStandardLibrary; + + /** + * @description The number of bumps the module has seen. + */ + export ledger tally: Counter; + + /** + * @description Adds one to the tally. + * @constraints k=10, rows=626 + * @returns {[]} Empty tuple. + */ + export circuit bump(): [] { + tally.increment(1); + } +} diff --git a/crates/compact-lint/tests/fixtures/template-clean/compact.toml b/crates/compact-lint/tests/fixtures/template-clean/compact.toml new file mode 100644 index 0000000..6c3bc72 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/template-clean/compact.toml @@ -0,0 +1,57 @@ +# The kind tables of examples/compact.toml, with the include glob pointed at the case. +[lint] +include = ["**/*.compact"] + +[lint.constraints] +tag = "@constraints" + +[lint.tags] +forbid = ["@return"] +rename = { "@return" = "@returns" } + +[lint.kinds.module] +docs = "all" +tags = [ + "@module", + "@description", + "@notice Privacy", + "@notice Security", + "@notice Concurrency", + "@notice Scope", + "@dev Performance", +] +sections = [ + "@module", + "@description", + "@dev Notation", + "@dev Derivations", + "@dev Parameterization", + "@dev Compatibility", + "@notice Privacy", + "@notice Security", + "@notice Concurrency", + "@notice Scope", + "@notice Client-side duties", + "@notice Scalability", + "@dev Performance", + "@dev Composition", +] + +[lint.kinds.circuit] +tags = ["@description"] + +[lint.kinds.ledger] +tags = ["@description"] + +[lint.kinds.witness] +docs = "all" +tags = ["@description"] + +[lint.kinds.struct] +tags = ["@description"] + +[lint.kinds.enum] +tags = ["@description"] + +[lint.kinds.constructor] +docs = "none" diff --git a/crates/compact-lint/tests/fixtures/template-clean/expected.txt b/crates/compact-lint/tests/fixtures/template-clean/expected.txt new file mode 100644 index 0000000..e69de29 diff --git a/crates/compact-lint/tests/fixtures/unknown-section/Headings.compact b/crates/compact-lint/tests/fixtures/unknown-section/Headings.compact new file mode 100644 index 0000000..21571cd --- /dev/null +++ b/crates/compact-lint/tests/fixtures/unknown-section/Headings.compact @@ -0,0 +1,23 @@ +pragma language_version >= 0.23.0; + +/** + * @module Headings + * @description A module carrying one section the template does not list. + * + * @notice Privacy: + * - Amounts stay private. + * + * @notice Gotchas: + * - The nonce is spend-critical. + * + * @dev Notation: + * - `H(...)`: a `Bytes<32>` digest. + */ +export module Headings { + /** + * @description A helper: it doubles its argument. + */ + export pure circuit double(value: Uint<8>): Uint<16> { + return value as Uint<16> * 2; + } +} diff --git a/crates/compact-lint/tests/fixtures/unknown-section/compact.toml b/crates/compact-lint/tests/fixtures/unknown-section/compact.toml new file mode 100644 index 0000000..e17a6b5 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/unknown-section/compact.toml @@ -0,0 +1,11 @@ +[lint] +include = ["**/*.compact"] + +[lint.kinds.module] +docs = "all" +tags = ["@module", "@description", "@notice Privacy"] +sections = ["@module", "@description", "@notice Privacy", "@dev Notation"] + +# No headed `@description` entry, so a heading on that tag is nobody's business. +[lint.kinds.circuit] +tags = ["@description"] diff --git a/crates/compact-lint/tests/fixtures/unknown-section/expected.txt b/crates/compact-lint/tests/fixtures/unknown-section/expected.txt new file mode 100644 index 0000000..63418c4 --- /dev/null +++ b/crates/compact-lint/tests/fixtures/unknown-section/expected.txt @@ -0,0 +1,13 @@ +Headings.compact:10:4 lint/unknown-section ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! `@notice Gotchas` is not a section the template lists. + + 8 │ * - Amounts stay private. + 9 │ * + > 10 │ * @notice Gotchas: + │ ^^^^^^^^^^^^^^^^ + 11 │ * - The nonce is spend-critical. + 12 │ * + + i Add it to kinds.module.sections, or fold it into a listed section. + diff --git a/examples/fungible-token/compact.toml b/examples/fungible-token/compact.toml index 672d90a..85a6f57 100644 --- a/examples/fungible-token/compact.toml +++ b/examples/fungible-token/compact.toml @@ -46,3 +46,5 @@ signing_key_file = "deploy/TokenExample.signingkey" # them from here. The programmatic flow (`yarn deploy:*`) overrides # them inline via the `args:` field on `runDeploy()`. args = { module = "./deploy/TokenExample.args.mjs", export = "args" } + +# compact-lint reads a [lint] table from this same file; see crates/compact-lint/examples/compact.toml. diff --git a/packages/deployer/src/config/compact-config.test.ts b/packages/deployer/src/config/compact-config.test.ts index 2ef37c6..25d4a62 100644 --- a/packages/deployer/src/config/compact-config.test.ts +++ b/packages/deployer/src/config/compact-config.test.ts @@ -38,6 +38,24 @@ describe('CompactConfig', () => { expect(config.contract('Token').artifact).toBe('src/artifacts/Token/Token'); }); + it('ignores the [lint] table that compact-lint owns', async () => { + const dir = tmpRepo(`${MIN_VALID} +[lint] +include = ["contracts/src/**/*.compact"] + +[lint.rules] +missing-doc = "error" + +[lint.kinds.module] +docs = "exported" +tags = ["@module", "@description"] +`); + const config = await CompactConfig.load(undefined, dir); + expect(config.defaultNetwork).toBe('local'); + expect(config.network('local').network_id).toBe('undeployed'); + expect(config.contract('Token').artifact).toBe('src/artifacts/Token/Token'); + }); + it('should throw with the available set when a lookup misses', async () => { const dir = tmpRepo(MIN_VALID); const config = await CompactConfig.load(undefined, dir); diff --git a/packages/linter/README.md b/packages/linter/README.md index 6bf577b..226b482 100644 --- a/packages/linter/README.md +++ b/packages/linter/README.md @@ -16,8 +16,9 @@ yarn compact-linter check contracts/src yarn compact-linter fix --dry-run ``` -Commands, flags, exit codes and `compact-lint.toml` are documented in the -[crate README](../../crates/compact-lint/README.md). +Commands, flags, exit codes and the `[lint]` table of `compact.toml` are documented in +the [crate README](../../crates/compact-lint/README.md). `compact.toml` is the project's +one config file, shared with `compact-deploy`. ## Where the binary comes from