Skip to content

Commit 4b2dde7

Browse files
committed
feat(lint): add escape-hatch + tab-mix rules
Rounds out the v0.1 rule pack with the two cheap, high-value rules from the spec/plan that need no external tooling. - `escape-hatch` (warn): surfaces soundness escape hatches — the termination-checker overrides (`TERMINATING`, `NON_TERMINATING`, `NO_TERMINATION_CHECK`) and the trust primitives (`believe_me`/`primTrustMe`). Warn, not hard-block: these are sometimes legitimately budgeted (echo-types' quarantine discipline), so the rule makes them visible without blocking promotion. Precise: it ignores the words in `--` comments and only matches whole tokens (so `believe_me_helper` does not trip it). - `tab-mix` (warn): a tab in leading whitespace (Agda style is spaces). Verification - cargo fmt/clippy(-D warnings)/test clean; 28 tests (+7 unit). - Re-dogfooded on echo-types: 0 escape-hatch/tab-mix hits, confirmed a TRUE negative by ground-truth grep — the only two `.agda` files containing the word "TERMINATING" carry it in `-- TERMINATING CONDITION` comments, not pragmas, which the rule correctly skips. A synthetic file with a real `{-# TERMINATING #-}` + `believe_me` produces exactly two warns. `missing-without-k` is intentionally not added: `missing-safe-pragma` already reports a missing `--without-K`. Still deferred: `unpinned-headline` (needs Smoke.agda parsing + configurable regex), `unused-import` (shells out to agda-unused), content-hash invalidation, the Groove manifest, and the `.machine_readable/` RSR retrofit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GiSiEfgZCte35dyykgBHs
1 parent ace54f3 commit 4b2dde7

4 files changed

Lines changed: 220 additions & 3 deletions

File tree

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ record.
1919
reachability is the *union*, so a module verified from any root is not
2020
an orphan)
2121
- `unjustified-postulate``postulate` without an adjacent `-- JUSTIFY:` comment
22+
- `escape-hatch` (warn) — termination overrides (`TERMINATING`,
23+
`NON_TERMINATING`, `NO_TERMINATION_CHECK`) and trust primitives
24+
(`believe_me`/`primTrustMe`)
25+
- `tab-mix` (warn) — a tab in leading whitespace
2226
- Workspace state machine — transitions are file moves, each logged to
2327
`.arghda/events.jsonl` (`claim`, `promote`, `reject`, `requeue`,
2428
`invalidate`)
@@ -40,9 +44,11 @@ plus standalone scratch files. `scan` also flags the files deliberately
4044
outside the `--safe --without-K` kernel cone (`Fidelity.agda`, the cubical
4145
island, the postulated shadow).
4246

43-
Not yet: the remaining lint rules (`missing-without-k`, `unpinned-headline`,
44-
`unused-import`, `tab-mix`), content-hash invalidation of `proven`, the
45-
Groove service manifest, and the `.machine_readable/` RSR retrofit.
47+
Not yet: `unpinned-headline` (needs `Smoke.agda` parsing + a configurable
48+
regex) and `unused-import` (shells out to `agda-unused`); content-hash
49+
invalidation of `proven`; the Groove service manifest; and the
50+
`.machine_readable/` RSR retrofit. (`missing-without-k` is subsumed by
51+
`missing-safe-pragma`, which already reports a missing `--without-K`.)
4652

4753
## Build
4854

src/lint/escape_hatch.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
//! `escape-hatch` (warn) — surface soundness escape hatches.
2+
//!
3+
//! Flags termination-checker overrides (`{-# TERMINATING #-}`,
4+
//! `NON_TERMINATING`, `NO_TERMINATION_CHECK`) and the trust primitives
5+
//! `believe_me` / `primTrustMe`. These are sometimes legitimately budgeted
6+
//! (see echo-types' quarantine discipline), so this is a *warn*, not a
7+
//! hard-block: it makes the hatch visible to the operator / visual layer
8+
//! without blocking promotion. The postulate case is handled separately by
9+
//! `unjustified-postulate` (hard-block when there is no `-- JUSTIFY:`).
10+
11+
use super::{LintContext, LintRule};
12+
use crate::diagnostic::{Diagnostic, LintReport, Severity};
13+
use anyhow::{Context, Result};
14+
use std::fs;
15+
use std::path::Path;
16+
17+
pub struct EscapeHatch;
18+
19+
const TERMINATION_PRAGMAS: &[&str] = &["TERMINATING", "NON_TERMINATING", "NO_TERMINATION_CHECK"];
20+
const TRUST_PRIMS: &[&str] = &["believe_me", "primTrustMe"];
21+
22+
impl LintRule for EscapeHatch {
23+
fn name(&self) -> &'static str {
24+
"escape-hatch"
25+
}
26+
27+
fn run(&self, file: &Path, _ctx: &LintContext<'_>, report: &mut LintReport) -> Result<()> {
28+
let contents =
29+
fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
30+
for (i, line) in contents.lines().enumerate() {
31+
let trimmed = line.trim_start();
32+
if trimmed.starts_with("--") {
33+
continue; // whole-line comment
34+
}
35+
// Termination-checker override pragmas.
36+
if trimmed.starts_with("{-#") {
37+
for p in TERMINATION_PRAGMAS {
38+
if line.contains(p) {
39+
report.push(warn(
40+
self.name(),
41+
file,
42+
i + 1,
43+
format!("termination escape pragma `{p}`"),
44+
));
45+
}
46+
}
47+
}
48+
// Trust primitives, ignoring any trailing line comment.
49+
let code = line.split(" --").next().unwrap_or(line);
50+
for prim in TRUST_PRIMS {
51+
if has_token(code, prim) {
52+
report.push(warn(
53+
self.name(),
54+
file,
55+
i + 1,
56+
format!("trust primitive `{prim}`"),
57+
));
58+
}
59+
}
60+
}
61+
Ok(())
62+
}
63+
}
64+
65+
fn warn(rule: &str, file: &Path, line: usize, message: String) -> Diagnostic {
66+
Diagnostic {
67+
rule: rule.to_string(),
68+
severity: Severity::Warn,
69+
file: file.to_path_buf(),
70+
message,
71+
line: Some(line),
72+
}
73+
}
74+
75+
/// `tok` appears in `s` as a whitespace/paren-delimited token (so a longer
76+
/// identifier like `believe_me_helper` does not match).
77+
fn has_token(s: &str, tok: &str) -> bool {
78+
s.split(|c: char| c.is_whitespace() || "(){};,".contains(c))
79+
.any(|w| w == tok)
80+
}
81+
82+
#[cfg(test)]
83+
mod tests {
84+
use super::*;
85+
use crate::lint::run_lints;
86+
use std::path::PathBuf;
87+
88+
fn lint_str(body: &str) -> LintReport {
89+
let tmp = tempfile::NamedTempFile::new().unwrap();
90+
std::fs::write(tmp.path(), body).unwrap();
91+
let roots: [PathBuf; 0] = [];
92+
let ctx = LintContext {
93+
include_root: tmp.path().parent().unwrap(),
94+
entry_modules: &roots,
95+
};
96+
let rules: Vec<Box<dyn LintRule>> = vec![Box::new(EscapeHatch)];
97+
run_lints(tmp.path(), &ctx, &rules).unwrap()
98+
}
99+
100+
#[test]
101+
fn terminating_pragma_is_warned() {
102+
let r = lint_str("module M where\n{-# TERMINATING #-}\nf : Set\n");
103+
assert!(!r.has_hard_block());
104+
assert_eq!(r.warns().count(), 1);
105+
assert!(r.diagnostics[0].message.contains("TERMINATING"));
106+
}
107+
108+
#[test]
109+
fn believe_me_token_is_warned() {
110+
let r = lint_str("module M where\nx = believe_me 0\n");
111+
assert_eq!(r.warns().count(), 1);
112+
assert!(r.diagnostics[0].message.contains("believe_me"));
113+
}
114+
115+
#[test]
116+
fn believe_me_in_comment_is_ignored() {
117+
let r = lint_str("module M where\nx = 0 -- todo: avoid believe_me here\n");
118+
assert!(r.diagnostics.is_empty());
119+
}
120+
121+
#[test]
122+
fn longer_identifier_does_not_match() {
123+
let r = lint_str("module M where\nbelieve_me_helper = 0\n");
124+
assert!(r.diagnostics.is_empty());
125+
}
126+
127+
#[test]
128+
fn clean_file_is_silent() {
129+
let r = lint_str("module M where\nf : Set\nf = Set\n");
130+
assert!(r.diagnostics.is_empty());
131+
}
132+
}

src/lint/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ use crate::diagnostic::LintReport;
22
use anyhow::Result;
33
use std::path::{Path, PathBuf};
44

5+
pub mod escape_hatch;
56
pub mod orphan_module;
67
pub mod postulate;
78
pub mod safe_pragma;
9+
pub mod tab_mix;
810

911
/// Context handed to every rule.
1012
#[derive(Clone, Debug)]
@@ -28,6 +30,8 @@ pub fn default_rules() -> Vec<Box<dyn LintRule>> {
2830
Box::new(safe_pragma::SafePragma),
2931
Box::new(orphan_module::OrphanModule),
3032
Box::new(postulate::UnjustifiedPostulate),
33+
Box::new(escape_hatch::EscapeHatch),
34+
Box::new(tab_mix::TabMix),
3135
]
3236
}
3337

src/lint/tab_mix.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//! `tab-mix` (warn) — flag tabs used for indentation.
2+
//!
3+
//! Agda's layout rule and the estate style use spaces; a stray tab in
4+
//! leading whitespace breaks alignment and, inside a layout block, can
5+
//! change how the file parses. Reported once per file (first offending
6+
//! line) to keep the signal quiet.
7+
8+
use super::{LintContext, LintRule};
9+
use crate::diagnostic::{Diagnostic, LintReport, Severity};
10+
use anyhow::{Context, Result};
11+
use std::fs;
12+
use std::path::Path;
13+
14+
pub struct TabMix;
15+
16+
impl LintRule for TabMix {
17+
fn name(&self) -> &'static str {
18+
"tab-mix"
19+
}
20+
21+
fn run(&self, file: &Path, _ctx: &LintContext<'_>, report: &mut LintReport) -> Result<()> {
22+
let contents =
23+
fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
24+
for (i, line) in contents.lines().enumerate() {
25+
let leading_has_tab = line
26+
.chars()
27+
.take_while(|c| *c == ' ' || *c == '\t')
28+
.any(|c| c == '\t');
29+
if leading_has_tab {
30+
report.push(Diagnostic {
31+
rule: self.name().to_string(),
32+
severity: Severity::Warn,
33+
file: file.to_path_buf(),
34+
message: "tab in leading whitespace (Agda style is spaces)".to_string(),
35+
line: Some(i + 1),
36+
});
37+
break; // one report per file is enough signal
38+
}
39+
}
40+
Ok(())
41+
}
42+
}
43+
44+
#[cfg(test)]
45+
mod tests {
46+
use super::*;
47+
use crate::lint::run_lints;
48+
use std::path::PathBuf;
49+
50+
fn lint_str(body: &str) -> LintReport {
51+
let tmp = tempfile::NamedTempFile::new().unwrap();
52+
std::fs::write(tmp.path(), body).unwrap();
53+
let roots: [PathBuf; 0] = [];
54+
let ctx = LintContext {
55+
include_root: tmp.path().parent().unwrap(),
56+
entry_modules: &roots,
57+
};
58+
let rules: Vec<Box<dyn LintRule>> = vec![Box::new(TabMix)];
59+
run_lints(tmp.path(), &ctx, &rules).unwrap()
60+
}
61+
62+
#[test]
63+
fn leading_tab_is_warned_not_blocked() {
64+
let r = lint_str("module M where\n\tx = 1\n");
65+
assert!(!r.has_hard_block());
66+
assert_eq!(r.warns().count(), 1);
67+
assert_eq!(r.diagnostics[0].line, Some(2));
68+
}
69+
70+
#[test]
71+
fn space_indented_is_clean() {
72+
let r = lint_str("module M where\n x = 1\n");
73+
assert!(r.diagnostics.is_empty());
74+
}
75+
}

0 commit comments

Comments
 (0)