use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use lanekeep_cache::{CacheKey, Entry as CacheEntry, GrammarKey, RunKey, Store};
use lanekeep_config::{Config, ConfigError, RuleSpec};
use lanekeep_core::suppression::{self, Date, Suppressions};
use lanekeep_core::{
CompiledGates, Discovery, DiscoveryError, Fact, FilePath, Location, Position, RuleId, Severity,
TrackedRead, Violation,
};
use lanekeep_js::{
FileAccess, HOST_API_VERSION, HostContext, Limits, ReduceContext, ReduceFact, RuleRoot,
RunClock, Sandbox, SandboxError,
};
use lanekeep_lang::{Language, LanguageRegistry};
use lanekeep_query::{CompileError, CompiledQuery};
use rayon::prelude::*;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum RunError {
#[error(transparent)]
Discovery(#[from] DiscoveryError),
#[error("rule `{rule}` has an invalid query\n{detail}")]
Query {
rule: String,
detail: String,
},
#[error("rule `{rule}` targets unknown language `{language}`\n known languages: {known}")]
UnknownLanguage {
rule: String,
language: String,
known: String,
},
#[error("rule `{rule}` has invalid gates: {detail}")]
Gates {
rule: String,
detail: String,
},
#[error("rule `{rule}` failed on `{file}`\n{detail}")]
Rule {
rule: String,
file: String,
detail: String,
},
#[error("could not start a worker: {detail}")]
Worker {
detail: String,
},
}
struct Prepared {
spec: RuleSpec,
gates: CompiledGates,
compiled: Vec<(Arc<dyn Language>, CompiledQuery)>,
}
impl Prepared {
fn for_language(&self, id: &str) -> Option<&(Arc<dyn Language>, CompiledQuery)> {
self.compiled
.iter()
.find(|(language, _)| language.id().as_str() == id)
}
}
#[expect(
clippy::struct_excessive_bools,
reason = "four independent run modes — caching, reducing, unused reporting, profiling — \
every combination of which is meaningful and reachable from the CLI. The lint \
is aimed at a type where a pile of bools stands in for a missing enum; these \
are orthogonal switches, and an enum over their sixteen combinations would be \
strictly worse to read and to set."
)]
pub struct Engine {
rules: Vec<Prepared>,
discovery: Discovery,
root: PathBuf,
run_key: RunKey,
caching: bool,
reducing: bool,
reporting_unused: bool,
profiling: bool,
today: Date,
limits: Limits,
rules_root: RuleRoot,
config_path: PathBuf,
typescript: Arc<dyn Language>,
javascript: Arc<dyn Language>,
languages_by_extension: BTreeMap<String, String>,
}
impl std::fmt::Debug for Engine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Engine")
.field("rules", &self.rules.len())
.field("root", &self.discovery.root())
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RuleTiming {
pub query: Duration,
pub handler: Duration,
pub matches: u64,
}
impl RuleTiming {
#[must_use]
pub const fn total(&self) -> Duration {
self.query.saturating_add(self.handler)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Outcome {
pub violations: Vec<Violation>,
pub files_discovered: usize,
pub files_parsed: usize,
pub timings: Option<BTreeMap<RuleId, RuleTiming>>,
pub dependencies: BTreeMap<FilePath, Vec<TrackedRead>>,
}
impl Engine {
pub fn prepare(
config: &Config,
project_root: &Path,
rules_root: RuleRoot,
config_path: &Path,
registry: &LanguageRegistry,
typescript: Arc<dyn Language>,
javascript: Arc<dyn Language>,
) -> Result<Self, RunError> {
let discovery = Discovery::new(project_root, &config.include, &config.exclude)?;
let known = registry
.languages()
.map(|l| l.id().as_str())
.collect::<Vec<_>>()
.join(", ");
let mut languages_by_extension = BTreeMap::new();
for language in registry.languages() {
for extension in language.extensions() {
languages_by_extension
.insert(extension.to_ascii_lowercase(), language.id().to_string());
}
}
let mut rules = Vec::with_capacity(config.rules.len());
for spec in &config.rules {
if !spec.severity.is_enabled() {
continue;
}
let mut compiled = Vec::with_capacity(spec.languages.len());
for id in &spec.languages {
let language =
registry
.by_id(id)
.cloned()
.ok_or_else(|| RunError::UnknownLanguage {
rule: spec.id.to_string(),
language: id.clone(),
known: known.clone(),
})?;
let query = CompiledQuery::compile(language.as_ref(), &spec.query).map_err(
|e: CompileError| RunError::Query {
rule: spec.id.to_string(),
detail: e.to_string(),
},
)?;
compiled.push((language, query));
}
let gates = CompiledGates::compile(&spec.gates).map_err(|e| RunError::Gates {
rule: spec.id.to_string(),
detail: e.to_string(),
})?;
rules.push(Prepared {
spec: spec.clone(),
gates,
compiled,
});
}
let mut grammars: Vec<GrammarKey> = registry
.languages()
.map(|language| GrammarKey {
id: language.id().to_string(),
abi: u32::try_from(language.grammar_abi()).unwrap_or(u32::MAX),
})
.collect();
grammars.sort_by(|a, b| a.id.cmp(&b.id));
let run_key = RunKey::new(
engine_version(),
HOST_API_VERSION,
&config.ruleset_hash,
&config.config_hash,
&grammars,
);
Ok(Self {
rules,
run_key,
caching: true,
reducing: true,
reporting_unused: false,
profiling: false,
today: suppression::today(),
root: project_root
.canonicalize()
.unwrap_or_else(|_| project_root.to_path_buf()),
discovery,
limits: config.limits,
rules_root,
config_path: config_path.to_path_buf(),
typescript,
javascript,
languages_by_extension,
})
}
fn language_of(&self, path: &FilePath) -> Option<&str> {
let extension = Path::new(path.as_str())
.extension()?
.to_str()?
.to_ascii_lowercase();
self.languages_by_extension
.get(extension.as_str())
.map(String::as_str)
}
#[must_use]
pub const fn without_cache(mut self) -> Self {
self.caching = false;
self
}
#[must_use]
pub const fn profiling(mut self) -> Self {
self.profiling = true;
self
}
#[must_use]
pub const fn reporting_unused_suppressions(mut self) -> Self {
self.reporting_unused = true;
self
}
#[must_use]
pub const fn with_today(mut self, today: Date) -> Self {
self.today = today;
self
}
#[must_use]
pub const fn without_reduce(mut self) -> Self {
self.reducing = false;
self
}
#[must_use]
pub fn discover(&self) -> Vec<FilePath> {
self.discovery.walk()
}
#[must_use]
pub fn rule_count(&self) -> usize {
self.rules.len()
}
pub fn rules(&self) -> impl Iterator<Item = &RuleSpec> {
self.rules.iter().map(|prepared| &prepared.spec)
}
pub fn run(&self) -> Result<Outcome, RunError> {
let files = self.discovery.walk();
self.run_files(&files, Coverage::Whole)
}
pub fn run_over(&self, files: &[FilePath]) -> Result<Outcome, RunError> {
self.run_files(files, Coverage::Partial)
}
fn run_files(&self, files: &[FilePath], coverage: Coverage) -> Result<Outcome, RunError> {
let clock = RunClock::start(self.limits.global_timeout);
let cache = if self.caching {
Store::load(&self.root)
} else {
Store::empty()
};
let results: Vec<Result<FileOutcome, RunError>> = files
.par_iter()
.map_init(
|| Worker::new(self, &clock),
|worker, path| self.check_file(worker, &cache, path),
)
.collect();
let mut violations = Vec::new();
let mut facts = Vec::new();
let mut files_parsed = 0;
let mut dependencies = BTreeMap::new();
let mut fresh = Store::empty();
let mut directives: BTreeMap<FilePath, FileDirectives> = BTreeMap::new();
let mut timings: BTreeMap<RuleId, RuleTiming> = BTreeMap::new();
for result in results {
let outcome = result?;
violations.extend(outcome.violations);
facts.extend(outcome.facts);
files_parsed += usize::from(outcome.parsed);
if let Some(entry) = outcome.entry {
fresh.insert(entry.0, entry.1);
}
for (rule, timing) in outcome.timings {
let entry = timings.entry(rule).or_default();
entry.query = entry.query.saturating_add(timing.query);
entry.handler = entry.handler.saturating_add(timing.handler);
entry.matches += timing.matches;
}
if !outcome.suppressions.is_empty() {
directives.insert(
outcome.path.clone(),
FileDirectives {
suppressions: outcome.suppressions,
used: outcome.used_suppressions,
},
);
}
if !outcome.reads.is_empty() {
dependencies.insert(outcome.path, outcome.reads);
}
}
if self.caching {
match coverage {
Coverage::Whole => fresh.save(&self.root),
Coverage::Partial => {
let mut merged = cache;
for key in fresh.keys().copied().collect::<Vec<_>>() {
if let Some(entry) = fresh.get(&key) {
merged.insert(key, entry.clone());
}
}
merged.save(&self.root);
}
}
}
lanekeep_core::fact::sort(&mut facts);
let reduced = self.reduce(&clock, files, &facts)?;
for violation in reduced {
match covering_elsewhere(&directives, &violation) {
Some((file, index)) => {
if let Some(found) = directives.get_mut(&file)
&& !found.used.contains(&index)
{
found.used.push(index);
}
}
None => violations.push(violation),
}
}
if self.reporting_unused {
violations.extend(unused_violations(&directives));
}
lanekeep_core::sort(&mut violations);
Ok(Outcome {
violations,
files_discovered: files.len(),
files_parsed,
timings: self.profiling.then_some(timings),
dependencies,
})
}
fn reduce(
&self,
clock: &Arc<RunClock>,
files: &[FilePath],
facts: &[Fact],
) -> Result<Vec<Violation>, RunError> {
if !self.reducing {
return Ok(Vec::new());
}
let reducing: Vec<&Prepared> = self
.rules
.iter()
.filter(|rule| rule.spec.has_reduce)
.collect();
if reducing.is_empty() {
return Ok(Vec::new());
}
let sandbox = self.build_sandbox(clock)?;
let paths: Vec<String> = files.iter().map(|f| f.as_str().to_owned()).collect();
let mut violations = Vec::new();
for rule in reducing {
let own: Vec<ReduceFact> = facts
.iter()
.filter(|fact| fact.rule_id == rule.spec.id)
.map(|fact| ReduceFact {
kind: fact.kind.clone(),
json: lanekeep_js::merge_file(&fact.data, fact.file.as_str()),
})
.collect();
let host = ReduceContext::new(paths.clone(), own);
let timeout = rule.spec.timeout.unwrap_or(self.limits.rule_timeout);
let call = format!(
"globalThis.__lanekeepConfig.rules[{}].reduce(ctx)",
rule_index(&rule.spec)
);
sandbox
.eval_with_reduce_host::<()>(&host, &call, timeout)
.map_err(|e: SandboxError| RunError::Rule {
rule: rule.spec.id.to_string(),
file: "<reduce>".to_owned(),
detail: e.to_string(),
})?;
for report in host.take_reports() {
violations.push(Violation {
rule_id: rule.spec.id.clone(),
location: Location::new(
FilePath::new(&report.file),
Position::new(report.line, report.column),
),
message: report
.message
.unwrap_or_else(|| rule.spec.card.message.clone()),
remediation: rule.spec.card.remediation.clone(),
severity: rule.spec.severity,
fix: None,
});
}
}
Ok(violations)
}
fn build_sandbox(&self, clock: &Arc<RunClock>) -> Result<Sandbox, RunError> {
let sandbox = Sandbox::with_modules(
self.limits,
Arc::clone(clock),
self.rules_root.clone(),
Arc::clone(&self.typescript),
Arc::clone(&self.javascript),
)
.map_err(|e| RunError::Worker {
detail: e.to_string(),
})?;
lanekeep_config::evaluate_into(&sandbox, &self.rules_root, &self.config_path).map_err(
|e: ConfigError| RunError::Worker {
detail: e.to_string(),
},
)?;
Ok(sandbox)
}
fn check_file(
&self,
worker: &mut Worker<'_>,
cache: &Store,
path: &FilePath,
) -> Result<FileOutcome, RunError> {
let files = Rc::new(FileAccess::rooted(self.root.clone()));
let admitted: Vec<&Prepared> = self
.rules
.iter()
.filter(|rule| rule.gates.admits_path(path))
.collect();
if admitted.is_empty() {
return Ok(FileOutcome::skipped(path.clone()));
}
let absolute = self.discovery.root().join(path.as_str());
let Ok(bytes) = std::fs::read(&absolute) else {
return Ok(FileOutcome::skipped(path.clone()));
};
let keys = self.caching.then(|| {
let content = lanekeep_cache::hash_bytes(&bytes);
(
self.run_key.for_file(path.as_str(), &content),
self.run_key
.for_dated_file(path.as_str(), &content, &self.today.to_string()),
)
});
let has_expiry = memchr::memmem::find(&bytes, b"expires:").is_some();
if let Some((plain, dated)) = keys {
let candidates: &[CacheKey] = if has_expiry {
&[dated]
} else {
&[dated, plain]
};
for key in candidates {
if let Some(entry) = cache.get(key)
&& lanekeep_cache::validate(entry, &self.root)
{
return Ok(FileOutcome::cached(path.clone(), *key, entry.clone()));
}
}
}
let admitted: Vec<&Prepared> = admitted
.into_iter()
.filter(|rule| rule.gates.admits_content(&bytes))
.collect();
if admitted.is_empty() {
return Ok(FileOutcome::empty_entry(
path.clone(),
keys.map(|(plain, dated)| if has_expiry { dated } else { plain }),
));
}
let Ok(source) = String::from_utf8(bytes) else {
return Ok(FileOutcome::skipped(path.clone()));
};
let directives = suppression::parse(&source);
let mut outcome = FileOutcome::parsed(path.clone());
for rule in admitted {
let (violations, facts, read_the_date, timing) =
self.run_rule(worker, &files, rule, path, &source)?;
outcome.violations.extend(violations);
outcome.facts.extend(facts);
outcome.read_the_date |= read_the_date;
if self.profiling {
outcome.timings.push((rule.spec.id.clone(), timing));
}
}
let mut used = Vec::new();
outcome.violations.retain(|violation| {
match directives.covering(&violation.rule_id, violation.location.position.line) {
Some(index) => {
let index = u32::try_from(index).unwrap_or(u32::MAX);
if !used.contains(&index) {
used.push(index);
}
false
}
None => true,
}
});
used.sort_unstable();
outcome.used_suppressions = used;
outcome
.violations
.extend(self.directive_violations(&directives, path));
outcome.suppressions = directives.valid;
outcome.reads = files.dependencies();
let date_dependent = has_expiry || outcome.read_the_date;
outcome.entry = keys.map(|(plain, dated)| {
(
if date_dependent { dated } else { plain },
CacheEntry {
violations: outcome.violations.clone(),
facts: outcome.facts.clone(),
dependencies: outcome.reads.clone(),
suppressions: outcome.suppressions.clone(),
used_suppressions: outcome.used_suppressions.clone(),
},
)
});
Ok(outcome)
}
fn directive_violations(&self, directives: &Suppressions, path: &FilePath) -> Vec<Violation> {
let mut violations = Vec::new();
let Ok(rule_id) = SUPPRESSION_RULE.parse::<RuleId>() else {
return violations;
};
for bad in &directives.malformed {
violations.push(Violation {
rule_id: rule_id.clone(),
location: Location::new(path.clone(), Position::new(bad.line, bad.column)),
message: bad.problem.clone(),
remediation: String::from(
"fix the directive, or remove it and fix what it was hiding",
),
severity: Severity::Error,
fix: None,
});
}
for suppression in &directives.valid {
let Some(expires) = suppression.expires else {
continue;
};
if expires >= self.today {
continue;
}
violations.push(Violation {
rule_id: rule_id.clone(),
location: Location::new(
path.clone(),
Position::new(suppression.line, suppression.column),
),
message: format!(
"suppression expired on {expires} — \"{}\"",
suppression.reason
),
remediation: String::from(
"fix what it was suppressing, or decide it is permanent and drop the \
expiry",
),
severity: Severity::Error,
fix: None,
});
}
violations
}
fn run_rule(
&self,
worker: &mut Worker<'_>,
files: &Rc<FileAccess>,
rule: &Prepared,
path: &FilePath,
source: &str,
) -> Result<(Vec<Violation>, Vec<Fact>, bool, RuleTiming), RunError> {
let Some(language_id) = self.language_of(path) else {
return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
};
let Some((language, compiled_query)) = rule.for_language(language_id) else {
return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
};
let mut parser = tree_sitter::Parser::new();
if parser.set_language(&language.grammar()).is_err() {
return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
}
let Some(tree) = parser.parse(source, None) else {
return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
};
let mut timing = RuleTiming::default();
let clock = |on: bool| on.then(std::time::Instant::now);
let mut matches: Vec<Vec<(String, Vec<u32>)>> = Vec::new();
let host = HostContext::new(tree, source.to_owned(), path.as_str())
.with_resolver_from(language.as_ref())
.with_language(Arc::clone(language))
.with_today(&self.today.to_string())
.with_file_access(Rc::clone(files));
let query_started = clock(self.profiling);
{
let arena = host.arena().borrow();
compiled_query.for_each_match(arena.tree(), source.as_bytes(), |m| {
let captures = m
.captures
.iter()
.filter_map(|(name, node)| {
arena.path_of(*node).map(|path| ((*name).to_owned(), path))
})
.collect();
matches.push(captures);
});
}
if let Some(started) = query_started {
timing.query = started.elapsed();
timing.matches = matches.len() as u64;
}
if matches.is_empty() {
return Ok((Vec::new(), Vec::new(), false, timing));
}
let sandbox = worker.sandbox()?;
let timeout = rule.spec.timeout.unwrap_or(self.limits.rule_timeout);
let mut violations = Vec::new();
for captures in matches {
let handles: Vec<(String, u32)> = {
let mut arena = host.arena().borrow_mut();
captures
.into_iter()
.filter_map(|(name, path)| arena.intern_path(path).map(|h| (name, h)))
.collect()
};
let literal = handles
.iter()
.map(|(name, handle)| format!("{}: {handle}", json_key(name)))
.collect::<Vec<_>>()
.join(", ");
let call = format!(
"globalThis.__lanekeepConfig.rules[{}].check(ctx, {{{literal}}})",
rule_index(&rule.spec)
);
let handler_started = clock(self.profiling);
let outcome = sandbox.eval_with_host_timeout::<()>(&host, &call, timeout);
if let Some(started) = handler_started {
timing.handler = timing.handler.saturating_add(started.elapsed());
}
outcome.map_err(|e: SandboxError| RunError::Rule {
rule: rule.spec.id.to_string(),
file: path.as_str().to_owned(),
detail: e.to_string(),
})?;
}
let facts = host
.take_facts()
.into_iter()
.enumerate()
.map(|(sequence, emitted)| Fact {
rule_id: rule.spec.id.clone(),
file: path.clone(),
kind: emitted.kind,
data: emitted.data,
sequence: u32::try_from(sequence).unwrap_or(u32::MAX),
})
.collect();
for report in host.take_reports() {
violations.push(Violation {
rule_id: rule.spec.id.clone(),
location: Location::new(path.clone(), Position::new(report.line, report.column)),
message: report
.message
.unwrap_or_else(|| rule.spec.card.message.clone()),
remediation: rule.spec.card.remediation.clone(),
severity: rule.spec.severity,
fix: report.fix,
});
}
Ok((violations, facts, host.date_was_read(), timing))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Coverage {
Whole,
Partial,
}
struct Worker<'a> {
engine: &'a Engine,
clock: Arc<RunClock>,
sandbox: Option<Sandbox>,
failed: Option<RunError>,
}
impl<'a> Worker<'a> {
fn new(engine: &'a Engine, clock: &Arc<RunClock>) -> Self {
Self {
engine,
clock: Arc::clone(clock),
sandbox: None,
failed: None,
}
}
fn sandbox(&mut self) -> Result<&Sandbox, RunError> {
if let Some(error) = &self.failed {
return Err(error.clone());
}
if self.sandbox.is_none() {
match self.engine.build_sandbox(&self.clock) {
Ok(sandbox) => self.sandbox = Some(sandbox),
Err(error) => {
self.failed = Some(error.clone());
return Err(error);
}
}
}
self.sandbox.as_ref().ok_or_else(|| RunError::Worker {
detail: "sandbox was not built".to_owned(),
})
}
}
struct FileOutcome {
path: FilePath,
violations: Vec<Violation>,
facts: Vec<Fact>,
reads: Vec<TrackedRead>,
suppressions: Vec<suppression::Suppression>,
used_suppressions: Vec<u32>,
read_the_date: bool,
timings: Vec<(RuleId, RuleTiming)>,
entry: Option<(CacheKey, CacheEntry)>,
parsed: bool,
}
impl FileOutcome {
const fn skipped(path: FilePath) -> Self {
Self {
path,
violations: Vec::new(),
facts: Vec::new(),
reads: Vec::new(),
suppressions: Vec::new(),
used_suppressions: Vec::new(),
read_the_date: false,
timings: Vec::new(),
entry: None,
parsed: false,
}
}
const fn parsed(path: FilePath) -> Self {
Self {
path,
violations: Vec::new(),
facts: Vec::new(),
reads: Vec::new(),
suppressions: Vec::new(),
used_suppressions: Vec::new(),
read_the_date: false,
timings: Vec::new(),
entry: None,
parsed: true,
}
}
fn cached(path: FilePath, key: CacheKey, entry: CacheEntry) -> Self {
Self {
path,
violations: entry.violations.clone(),
facts: entry.facts.clone(),
reads: entry.dependencies.clone(),
suppressions: entry.suppressions.clone(),
used_suppressions: entry.used_suppressions.clone(),
read_the_date: false,
timings: Vec::new(),
entry: Some((key, entry)),
parsed: true,
}
}
fn empty_entry(path: FilePath, key: Option<CacheKey>) -> Self {
Self {
path,
violations: Vec::new(),
facts: Vec::new(),
reads: Vec::new(),
suppressions: Vec::new(),
used_suppressions: Vec::new(),
read_the_date: false,
timings: Vec::new(),
entry: key.map(|key| (key, CacheEntry::default())),
parsed: false,
}
}
}
struct FileDirectives {
suppressions: Vec<suppression::Suppression>,
used: Vec<u32>,
}
fn covering_elsewhere(
directives: &BTreeMap<FilePath, FileDirectives>,
violation: &Violation,
) -> Option<(FilePath, u32)> {
let found = directives.get(&violation.location.file)?;
let index = found.suppressions.iter().position(|suppression| {
suppression.covers(&violation.rule_id, violation.location.position.line)
})?;
Some((
violation.location.file.clone(),
u32::try_from(index).unwrap_or(u32::MAX),
))
}
fn unused_violations(directives: &BTreeMap<FilePath, FileDirectives>) -> Vec<Violation> {
let Ok(rule_id) = SUPPRESSION_RULE.parse::<RuleId>() else {
return Vec::new();
};
let mut violations = Vec::new();
for (file, found) in directives {
for (index, suppression) in found.suppressions.iter().enumerate() {
let index = u32::try_from(index).unwrap_or(u32::MAX);
if found.used.contains(&index) {
continue;
}
violations.push(Violation {
rule_id: rule_id.clone(),
location: Location::new(
file.clone(),
Position::new(suppression.line, suppression.column),
),
message: format!("suppression silenced nothing — \"{}\"", suppression.reason),
remediation: String::from(
"remove it: whatever it was accepting is no longer reported",
),
severity: Severity::Warn,
fix: None,
});
}
}
violations
}
fn engine_version() -> &'static str {
const FULL: &str = env!("CARGO_PKG_VERSION");
match FULL.match_indices('.').nth(1) {
Some((at, _)) => FULL.split_at(at).0,
None => FULL,
}
}
const SUPPRESSION_RULE: &str = "lanekeep/suppression";
fn rule_index(spec: &RuleSpec) -> usize {
spec.index
}
fn json_key(name: &str) -> String {
format!("{name:?}")
}
#[must_use]
pub fn any_failing(violations: &[Violation]) -> bool {
violations.iter().any(|v| v.severity == Severity::Error)
}
#[must_use]
pub fn rules_root_for(project_root: &Path) -> PathBuf {
project_root.to_path_buf()
}
#[cfg(test)]
mod tests {
use std::fs;
use lanekeep_lang_js::{JavaScript, TypeScript};
use super::*;
struct Project {
dir: PathBuf,
}
impl Project {
fn new(name: &str, files: &[(&str, &str)]) -> Self {
let dir = std::env::temp_dir().join(format!("lanekeep-engine-{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("creates dir");
let project = Self { dir };
for (path, contents) in files {
project.write(path, contents);
}
project
}
fn write(&self, path: &str, contents: &str) {
let full = self.dir.join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).expect("creates parent");
}
fs::write(full, contents).expect("writes");
}
fn run(&self) -> Result<Outcome, RunError> {
let root = RuleRoot::new(&self.dir).expect("canonicalizes");
let config_path = self.dir.join("lanekeep.config.ts");
let sandbox =
lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
.expect("sandbox");
let config = lanekeep_config::load(&sandbox, &root, &config_path)
.unwrap_or_else(|e| panic!("config failed to load: {e}"));
let engine = Engine::prepare(
&config,
&self.dir,
root,
&config_path,
&lanekeep_lang_js::registry(),
Arc::new(TypeScript),
Arc::new(JavaScript),
)?;
engine.run()
}
}
impl Drop for Project {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
const DEBUGGER_RULE: &str = "import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/no-debugger',\n\
query: '(debugger_statement) @stmt',\n\
card: {\n\
message: 'debugger statement',\n\
remediation: 'remove it before committing',\n\
examples: { bad: 'debugger;', good: 'console.log(x);' },\n\
},\n\
check(ctx, m) { ctx.report(m.stmt); },\n\
});\n";
fn member_rule_for(language: &str) -> String {
let declaration = if language.is_empty() {
String::new()
} else {
format!(" language: {language},\n")
};
format!(
"import {{ defineRule }} from 'lanekeep';\n\
export default defineRule({{\n\
id: 'local/member',\n\
{declaration}\
query: '(member_expression) @m',\n\
card: {{\n\
message: 'member expression',\n\
remediation: 'n/a',\n\
examples: {{ bad: 'a.b', good: 'b' }},\n\
}},\n\
check(ctx, m) {{ ctx.report(m.m); }},\n\
}});\n"
)
}
fn config_for(include: &str) -> String {
format!(
"import {{ defineConfig }} from 'lanekeep';\n\
import rule from './rule';\n\
export default defineConfig({{ include: ['{include}'], rules: [rule] }});\n"
)
}
fn config(extra: &str) -> String {
format!(
"import {{ defineConfig }} from 'lanekeep';\n\
import rule from './rule';\n\
export default defineConfig({{ include: ['src/**/*.ts'], rules: [rule]{extra} }});\n"
)
}
#[test]
fn a_default_rule_sees_inside_jsx() {
let project = Project::new(
"jsx-default",
&[
("rule.ts", &member_rule_for("")),
("lanekeep.config.ts", &config_for("src/**/*.tsx")),
(
"src/Component.tsx",
"export const C = () => <View style={styles.used} />;\n",
),
],
);
let outcome = project.run().expect("runs");
assert_eq!(
outcome.violations.len(),
1,
"a member expression inside JSX was not seen: {:?}",
outcome.violations
);
}
#[test]
fn a_default_rule_still_sees_plain_typescript() {
let project = Project::new(
"ts-default",
&[
("rule.ts", &member_rule_for("")),
("lanekeep.config.ts", &config_for("src/**/*.ts")),
("src/plain.ts", "const x = styles.used;\n"),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 1, "{:?}", outcome.violations);
}
#[test]
fn a_rule_does_not_run_on_a_language_it_does_not_name() {
let project = Project::new(
"single-language",
&[
("rule.ts", &member_rule_for("'typescript'")),
("lanekeep.config.ts", &config_for("src/**/*.tsx")),
(
"src/Component.tsx",
"export const C = () => <View style={styles.used} />;\n",
),
],
);
let outcome = project.run().expect("runs");
assert!(
outcome.violations.is_empty(),
"a typescript-only rule ran on a tsx file: {:?}",
outcome.violations
);
}
#[test]
fn a_rule_may_name_several_languages() {
let project = Project::new(
"many-languages",
&[
("rule.ts", &member_rule_for("['typescript', 'tsx']")),
("lanekeep.config.ts", &config_for("src/**/*.{ts,tsx}")),
("src/plain.ts", "const x = styles.used;\n"),
(
"src/Component.tsx",
"export const C = () => <View style={styles.used} />;\n",
),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 2, "{:?}", outcome.violations);
}
#[test]
fn an_unknown_language_in_a_list_is_reported() {
let project = Project::new(
"unknown-in-list",
&[
("rule.ts", &member_rule_for("['typescript', 'klingon']")),
("lanekeep.config.ts", &config_for("src/**/*.ts")),
("src/plain.ts", "const x = styles.used;\n"),
],
);
let error = project
.run()
.expect_err("should refuse an unknown language");
assert!(
error.to_string().contains("klingon"),
"the error should name it: {error}"
);
}
#[test]
fn runs_a_rule_over_a_corpus_end_to_end() {
let project = Project::new(
"end-to-end",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/clean.ts", "const a = 1;\n"),
("src/dirty.ts", "const b = 2;\ndebugger;\n"),
("src/also.ts", "function f() {\n debugger;\n}\n"),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 2, "{:?}", outcome.violations);
let rendered: Vec<String> = outcome
.violations
.iter()
.map(|v| format!("{} {}", v.rule_id, v.location))
.collect();
assert_eq!(
rendered,
[
"local/no-debugger src/also.ts:2:3",
"local/no-debugger src/dirty.ts:2:1",
]
);
assert_eq!(outcome.violations[0].message, "debugger statement");
assert_eq!(
outcome.violations[0].remediation,
"remove it before committing"
);
}
#[test]
fn output_is_identical_across_repeated_runs() {
let mut files = vec![
("rule.ts".to_owned(), DEBUGGER_RULE.to_owned()),
("lanekeep.config.ts".to_owned(), config("")),
];
for i in 0..40 {
files.push((
format!("src/f{i}.ts"),
format!("const x{i} = 1;\ndebugger;\n"),
));
}
let borrowed: Vec<(&str, &str)> = files
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect();
let project = Project::new("determinism", &borrowed);
let first = project.run().expect("runs").violations;
assert_eq!(first.len(), 40);
for _ in 0..4 {
assert_eq!(project.run().expect("runs").violations, first);
}
}
#[test]
fn exclude_keeps_files_out_of_the_run() {
let project = Project::new(
"exclude",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config(", exclude: ['**/*.test.ts']")),
("src/a.ts", "debugger;\n"),
("src/a.test.ts", "debugger;\n"),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 1);
assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
}
#[test]
fn a_content_gate_skips_the_parse() {
let gated = "import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/no-debugger',\n\
query: '(debugger_statement) @stmt',\n\
gates: { fileContains: ['debugger'] },\n\
card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
check(ctx, m) { ctx.report(m.stmt); },\n\
});\n";
let project = Project::new(
"gate",
&[
("rule.ts", gated),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
("src/b.ts", "const b = 1;\n"),
("src/c.ts", "const c = 2;\n"),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.files_discovered, 3);
assert_eq!(
outcome.files_parsed, 1,
"only the file containing the needle should parse"
);
assert_eq!(outcome.violations.len(), 1);
}
#[test]
fn a_rule_set_to_off_does_not_run() {
let project = Project::new(
"off",
&[
("rule.ts", DEBUGGER_RULE),
(
"lanekeep.config.ts",
&config(", severity: { 'local/no-debugger': 'off' }"),
),
("src/a.ts", "debugger;\n"),
],
);
assert!(project.run().expect("runs").violations.is_empty());
}
#[test]
fn severity_reaches_the_violation() {
let project = Project::new(
"severity",
&[
("rule.ts", DEBUGGER_RULE),
(
"lanekeep.config.ts",
&config(", severity: { 'local/no-debugger': 'warn' }"),
),
("src/a.ts", "debugger;\n"),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations[0].severity, Severity::Warn);
assert!(!any_failing(&outcome.violations));
}
#[test]
fn a_rule_that_throws_aborts_the_run_naming_itself_and_the_file() {
let throwing = "import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/throws',\n\
query: '(debugger_statement) @stmt',\n\
card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
check() { throw new Error('rule bug'); },\n\
});\n";
let project = Project::new(
"throws",
&[
("rule.ts", throwing),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
],
);
let err = project.run().expect_err("must abort");
let rendered = err.to_string();
assert!(rendered.contains("local/throws"), "{rendered}");
assert!(rendered.contains("src/a.ts"), "{rendered}");
assert!(rendered.contains("rule bug"), "{rendered}");
}
#[test]
fn an_invalid_query_fails_before_any_file_is_read() {
let bad = "import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/bad-query',\n\
query: '(no_such_node) @x',\n\
card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
check() {},\n\
});\n";
let project = Project::new(
"bad-query",
&[
("rule.ts", bad),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
],
);
let err = project.run().expect_err("must fail at preparation");
assert!(matches!(err, RunError::Query { .. }), "{err:?}");
assert!(err.to_string().contains("no_such_node"), "{err}");
}
#[test]
fn a_rule_can_use_the_host_api_it_was_given() {
let rule = "import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/long-names',\n\
query: '(variable_declarator name: (identifier) @name)',\n\
card: { message: 'name too long', remediation: 'shorten it', examples: { bad: 'a', good: 'b' } },\n\
check(ctx, m) {\n\
if (ctx.text(m.name).length > 5) ctx.report(m.name, `\\\"${ctx.text(m.name)}\\\" is too long`);\n\
},\n\
});\n";
let project = Project::new(
"host-api",
&[
("rule.ts", rule),
("lanekeep.config.ts", &config("")),
("src/a.ts", "const ok = 1;\nconst wayTooLong = 2;\n"),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 1);
assert!(
outcome.violations[0].message.contains("wayTooLong"),
"{:?}",
outcome.violations[0]
);
}
#[test]
fn a_corpus_with_no_matches_produces_nothing() {
let project = Project::new(
"clean",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "const a = 1;\n"),
],
);
let outcome = project.run().expect("runs");
assert!(outcome.violations.is_empty());
assert_eq!(outcome.files_parsed, 1, "no gates means it is still parsed");
}
const UNUSED_EXPORTS_RULE: &str = r"import { defineRule } from 'lanekeep';
export default defineRule({
id: 'local/no-unused-exports',
query: `
(export_statement declaration: (function_declaration name: (identifier) @name)) @stmt
(import_statement (import_clause (named_imports (import_specifier name: (identifier) @imported))))
`,
card: {
message: 'unused export',
remediation: 'delete it, or import it somewhere',
examples: { bad: 'export function unused() {}', good: 'function used() {}' },
},
check(ctx, m) {
if (m.imported) {
ctx.emitFact({ kind: 'import', symbol: ctx.text(m.imported) });
return;
}
ctx.emitFact({
kind: 'export',
symbol: ctx.text(m.name),
line: ctx.line(m.stmt),
column: ctx.column(m.stmt),
});
},
reduce(ctx) {
const imported = new Set(ctx.facts('import').map((f) => f.symbol));
for (const e of ctx.facts('export')) {
if (!imported.has(e.symbol)) {
ctx.report({ file: e.file, line: e.line, column: e.column }, `'${e.symbol}' is exported but never imported`);
}
}
},
});
";
#[test]
fn a_reduce_phase_sees_facts_from_every_file() {
let project = Project::new(
"reduce-cross-file",
&[
("rule.ts", UNUSED_EXPORTS_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"export function used() {}\nexport function spare() {}\n",
),
("src/b.ts", "import { used } from './a';\nused();\n"),
],
);
let outcome = project.run().expect("runs");
let found: Vec<(&str, u32, &str)> = outcome
.violations
.iter()
.map(|v| {
(
v.location.file.as_str(),
v.location.position.line,
v.message.as_str(),
)
})
.collect();
assert_eq!(
found,
vec![("src/a.ts", 2, "'spare' is exported but never imported")],
"only the export nobody imports should be reported"
);
}
#[test]
fn a_rule_with_no_reduce_still_runs() {
let project = Project::new(
"reduce-absent",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 1);
}
#[test]
fn a_reduce_phase_with_no_facts_reports_nothing() {
let project = Project::new(
"reduce-empty",
&[
("rule.ts", UNUSED_EXPORTS_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "const a = 1;\n"),
],
);
assert!(project.run().expect("runs").violations.is_empty());
}
#[test]
fn the_file_list_reaches_the_reduce_phase() {
const RULE: &str = r"import { defineRule } from 'lanekeep';
export default defineRule({
id: 'local/counts-files',
query: '(debugger_statement) @stmt',
card: {
message: 'file count',
remediation: 'nothing to do',
examples: { bad: 'a', good: 'b' },
},
check() {},
reduce(ctx) {
ctx.report({ file: ctx.files[0], line: ctx.files.length, column: 1 });
},
});
";
let project = Project::new(
"reduce-files",
&[
("rule.ts", RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "const a = 1;\n"),
("src/b.ts", "const b = 1;\n"),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 1);
assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
assert_eq!(outcome.violations[0].location.position.line, 2);
}
#[test]
fn a_rule_does_not_see_another_rules_facts() {
const EMITTER: &str = r"import { defineRule } from 'lanekeep';
export default defineRule({
id: 'local/emitter',
query: '(export_statement) @stmt',
card: { message: 'emitter', remediation: 'x', examples: { bad: 'a', good: 'b' } },
check(ctx, m) { ctx.emitFact({ kind: 'thing', from: 'emitter' }); },
});
";
const READER: &str = r"import { defineRule } from 'lanekeep';
export default defineRule({
id: 'local/reader',
query: '(export_statement) @stmt',
card: { message: 'reader', remediation: 'x', examples: { bad: 'a', good: 'b' } },
check() {},
reduce(ctx) {
ctx.report({ file: 'seen.ts', line: ctx.facts().length + 1, column: 1 });
},
});
";
let project = Project::new(
"reduce-isolation",
&[
("emitter.ts", EMITTER),
("reader.ts", READER),
(
"lanekeep.config.ts",
"import { defineConfig } from 'lanekeep';\n\
import emitter from './emitter';\n\
import reader from './reader';\n\
export default defineConfig({ include: ['src/**/*.ts'], rules: [emitter, reader] });\n",
),
("src/a.ts", "export const a = 1;\n"),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 1);
assert_eq!(
outcome.violations[0].location.position.line, 1,
"the reader saw the emitter's facts"
);
}
#[test]
fn a_reduce_phase_that_throws_aborts_the_run() {
const RULE: &str = r"import { defineRule } from 'lanekeep';
export default defineRule({
id: 'local/throws-in-reduce',
query: '(debugger_statement) @stmt',
card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
check() {},
reduce() { throw new Error('reduce exploded'); },
});
";
let project = Project::new(
"reduce-throws",
&[
("rule.ts", RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "const a = 1;\n"),
],
);
let error = project.run().expect_err("aborts");
let rendered = error.to_string();
assert!(rendered.contains("reduce exploded"), "{rendered}");
assert!(
rendered.contains("local/throws-in-reduce"),
"the error should name the rule: {rendered}"
);
}
#[test]
fn facts_reach_reduce_in_the_same_order_on_every_run() {
const RULE: &str = r"import { defineRule } from 'lanekeep';
export default defineRule({
id: 'local/first-fact-wins',
query: '(export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name)))',
card: { message: 'first', remediation: 'x', examples: { bad: 'a', good: 'b' } },
check(ctx, m) { ctx.emitFact({ kind: 'sym', symbol: ctx.text(m.name) }); },
reduce(ctx) {
const all = ctx.facts('sym');
ctx.report({ file: 'order.ts', line: 1, column: 1 }, all.map((f) => `${f.file}:${f.symbol}`).join(','));
},
});
";
let files: Vec<(String, String)> = (0..12)
.map(|i| {
(
format!("src/f{i:02}.ts"),
format!("export const s{i:02} = {i};\n"),
)
})
.collect();
let mut layout: Vec<(&str, &str)> = vec![("rule.ts", RULE)];
let config_source = config("");
layout.push(("lanekeep.config.ts", &config_source));
for (path, contents) in &files {
layout.push((path, contents));
}
let project = Project::new("reduce-determinism", &layout);
let first = project.run().expect("runs").violations[0].message.clone();
for attempt in 0..4 {
let again = project.run().expect("runs").violations[0].message.clone();
assert_eq!(again, first, "fact order changed on attempt {attempt}");
}
assert!(
first.starts_with("src/f00.ts:s00,src/f01.ts:s01,"),
"facts are not in (file, sequence) order: {first}"
);
}
#[test]
fn a_rule_cannot_misattribute_a_fact_to_another_file() {
const RULE: &str = r"import { defineRule } from 'lanekeep';
export default defineRule({
id: 'local/lying-fact',
query: '(export_statement) @stmt',
card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
check(ctx, m) { ctx.emitFact({ kind: 'e', file: 'somewhere-else.ts' }); },
reduce(ctx) {
for (const f of ctx.facts('e')) ctx.report({ file: f.file, line: 1, column: 1 });
},
});
";
let project = Project::new(
"reduce-misattribution",
&[
("rule.ts", RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "export const a = 1;\n"),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 1);
assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
}
const READING_RULE: &str = r"import { defineRule } from 'lanekeep';
export default defineRule({
id: 'local/reads-config',
query: '(export_statement) @stmt',
card: {
message: 'config says no',
remediation: 'change the config, or the code',
examples: { bad: 'export const a = 1;', good: 'const a = 1;' },
},
check(ctx, m) {
const raw = ctx.readFile('policy.json');
if (raw && JSON.parse(raw).forbidExports) ctx.report(m.stmt);
},
});
";
#[test]
fn a_rule_can_read_another_file() {
let project = Project::new(
"reads-allowed",
&[
("rule.ts", READING_RULE),
("lanekeep.config.ts", &config("")),
("policy.json", r#"{"forbidExports":true}"#),
("src/a.ts", "export const a = 1;\n"),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 1, "{:?}", outcome.violations);
}
#[test]
fn what_the_file_says_changes_the_result() {
let project = Project::new(
"reads-content",
&[
("rule.ts", READING_RULE),
("lanekeep.config.ts", &config("")),
("policy.json", r#"{"forbidExports":false}"#),
("src/a.ts", "export const a = 1;\n"),
],
);
assert!(project.run().expect("runs").violations.is_empty());
}
#[test]
fn a_read_is_recorded_against_the_file_that_made_it() {
let mut layout: Vec<(String, String)> = vec![
("rule.ts".to_owned(), READING_RULE.to_owned()),
("lanekeep.config.ts".to_owned(), config("")),
(
"policy.json".to_owned(),
r#"{"forbidExports":false}"#.to_owned(),
),
];
for i in 0..24 {
let body = if i % 2 == 0 {
format!("const v{i} = {i};\n")
} else {
format!("export const v{i} = {i};\n")
};
layout.push((format!("src/f{i:02}.ts"), body));
}
let borrowed: Vec<(&str, &str)> = layout
.iter()
.map(|(p, c)| (p.as_str(), c.as_str()))
.collect();
let project = Project::new("reads-attributed", &borrowed);
let outcome = project.run().expect("runs");
for i in 0..24 {
let file = FilePath::new(format!("src/f{i:02}.ts"));
let deps = outcome.dependencies.get(&file);
if i % 2 == 0 {
assert!(
deps.is_none(),
"src/f{i:02}.ts read nothing but has {deps:?}"
);
} else {
let deps = deps.unwrap_or_else(|| panic!("src/f{i:02}.ts should have read"));
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].path.as_str(), "policy.json");
assert!(deps[0].hash.is_some());
}
}
}
#[test]
fn a_missing_file_is_recorded_as_a_dependency_too() {
const RULE: &str = r"import { defineRule } from 'lanekeep';
export default defineRule({
id: 'local/wants-config',
query: '(export_statement) @stmt',
card: { message: 'no config', remediation: 'add one', examples: { bad: 'a', good: 'b' } },
check(ctx, m) {
if (!ctx.fileExists('tsconfig.json')) ctx.report(m.stmt);
},
});
";
let project = Project::new(
"reads-absent",
&[
("rule.ts", RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "export const a = 1;\n"),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 1);
let deps = outcome
.dependencies
.get(&FilePath::new("src/a.ts"))
.expect("the miss is a dependency");
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].path.as_str(), "tsconfig.json");
assert_eq!(deps[0].hash, None, "absence is recorded as absence");
}
#[test]
fn reading_outside_the_project_aborts_the_run() {
const RULE: &str = r"import { defineRule } from 'lanekeep';
export default defineRule({
id: 'local/escapes',
query: '(export_statement) @stmt',
card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
check(ctx) { ctx.readFile('../../../etc/passwd'); },
});
";
let project = Project::new(
"reads-escape",
&[
("rule.ts", RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "export const a = 1;\n"),
],
);
let error = project.run().expect_err("aborts");
let rendered = error.to_string();
assert!(rendered.contains("outside the project root"), "{rendered}");
assert!(rendered.contains("local/escapes"), "{rendered}");
}
#[test]
fn reading_the_same_file_from_two_files_records_it_under_both() {
let project = Project::new(
"reads-shared",
&[
("rule.ts", READING_RULE),
("lanekeep.config.ts", &config("")),
("policy.json", r#"{"forbidExports":false}"#),
("src/a.ts", "export const a = 1;\n"),
("src/b.ts", "export const b = 1;\n"),
],
);
let outcome = project.run().expect("runs");
for file in ["src/a.ts", "src/b.ts"] {
let deps = outcome
.dependencies
.get(&FilePath::new(file))
.unwrap_or_else(|| panic!("{file} should depend on the policy"));
assert_eq!(deps[0].path.as_str(), "policy.json");
}
let a = &outcome.dependencies[&FilePath::new("src/a.ts")][0];
let b = &outcome.dependencies[&FilePath::new("src/b.ts")][0];
assert_eq!(a.hash, b.hash);
}
#[test]
fn dependencies_are_the_same_on_every_run() {
let project = Project::new(
"reads-deterministic",
&[
("rule.ts", READING_RULE),
("lanekeep.config.ts", &config("")),
("policy.json", r#"{"forbidExports":false}"#),
("src/a.ts", "export const a = 1;\n"),
("src/b.ts", "export const b = 1;\n"),
("src/c.ts", "export const c = 1;\n"),
],
);
let first = project.run().expect("runs").dependencies;
assert!(!first.is_empty());
for attempt in 0..4 {
assert_eq!(
project.run().expect("runs").dependencies,
first,
"dependencies changed on attempt {attempt}"
);
}
}
#[test]
fn the_read_surface_is_absent_from_the_reduce_phase() {
const RULE: &str = r"import { defineRule } from 'lanekeep';
export default defineRule({
id: 'local/reduce-reads',
query: '(export_statement) @stmt',
card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
check() {},
reduce(ctx) {
const absent = ctx.readFile === undefined && ctx.fileExists === undefined;
ctx.report({ file: 'probe.ts', line: absent ? 1 : 2, column: 1 });
},
});
";
let project = Project::new(
"reads-reduce",
&[
("rule.ts", RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "export const a = 1;\n"),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 1);
assert_eq!(
outcome.violations[0].location.position.line, 1,
"reads must not be reachable from a reduce phase"
);
}
impl Project {
fn run_cold(&self) -> Result<Outcome, RunError> {
self.build().map(Engine::without_cache)?.run()
}
fn build(&self) -> Result<Engine, RunError> {
let root = RuleRoot::new(&self.dir).expect("canonicalizes");
let config_path = self.dir.join("lanekeep.config.ts");
let sandbox =
lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
.expect("sandbox");
let config = lanekeep_config::load(&sandbox, &root, &config_path)
.unwrap_or_else(|e| panic!("config failed to load: {e}"));
Engine::prepare(
&config,
&self.dir,
root,
&config_path,
&lanekeep_lang_js::registry(),
Arc::new(TypeScript),
Arc::new(JavaScript),
)
}
fn cache(&self) -> Store {
Store::load(&self.dir)
}
}
fn rendered(outcome: &Outcome) -> Vec<String> {
outcome
.violations
.iter()
.map(|v| {
format!(
"{}:{}:{} {} {}",
v.location.file.as_str(),
v.location.position.line,
v.location.position.column,
v.rule_id,
v.message
)
})
.collect()
}
#[test]
fn a_warm_run_agrees_with_a_cold_one() {
let project = Project::new(
"cache-agrees",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\nconst a = 1;\n"),
("src/b.ts", "const b = 1;\ndebugger;\n"),
("src/c.ts", "const c = 1;\n"),
],
);
let cold = rendered(&project.run().expect("runs"));
let warm = rendered(&project.run().expect("runs"));
assert_eq!(warm, cold, "the cache changed the answer");
assert!(!cold.is_empty(), "the fixture should report something");
}
#[test]
fn a_run_writes_a_cache() {
let project = Project::new(
"cache-written",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
],
);
assert!(project.cache().is_empty(), "nothing before the first run");
project.run().expect("runs");
assert!(!project.cache().is_empty(), "the run stored nothing");
}
#[test]
fn a_cached_result_is_actually_used() {
let project = Project::new(
"cache-used",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "const a = 1;\n"),
],
);
assert!(project.run().expect("runs").violations.is_empty());
let store = project.cache();
let key = *store
.keys()
.next()
.expect("the run stored an entry for the file");
let mut doctored = Store::empty();
doctored.insert(
key,
lanekeep_cache::Entry {
violations: vec![Violation {
rule_id: "local/no-debugger".parse().expect("valid id"),
location: Location::new(FilePath::new("src/a.ts"), Position::new(7, 3)),
message: "from the cache".to_owned(),
remediation: "nothing".to_owned(),
severity: Severity::Error,
fix: None,
}],
facts: Vec::new(),
dependencies: Vec::new(),
suppressions: Vec::new(),
used_suppressions: Vec::new(),
},
);
doctored.save(&project.dir);
let outcome = project.run().expect("runs");
assert_eq!(
rendered(&outcome),
vec!["src/a.ts:7:3 local/no-debugger from the cache"],
"the cached entry was not used"
);
}
#[test]
fn editing_a_file_invalidates_it() {
let project = Project::new(
"cache-edited",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "const a = 1;\n"),
],
);
assert!(project.run().expect("runs").violations.is_empty());
project.write("src/a.ts", "debugger;\n");
assert_eq!(
project.run().expect("runs").violations.len(),
1,
"an edited file kept its stale result"
);
}
#[test]
fn moving_a_file_invalidates_it() {
let project = Project::new(
"cache-moved",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
],
);
project.run().expect("runs");
fs::remove_file(project.dir.join("src/a.ts")).expect("removes");
project.write("src/moved.ts", "debugger;\n");
let outcome = project.run().expect("runs");
assert_eq!(
outcome.violations[0].location.file.as_str(),
"src/moved.ts",
"the violation followed the old path"
);
}
#[test]
fn editing_a_tracked_dependency_invalidates_the_files_that_read_it() {
let project = Project::new(
"cache-dependency",
&[
("rule.ts", READING_RULE),
("lanekeep.config.ts", &config("")),
("policy.json", r#"{"forbidExports":false}"#),
("src/a.ts", "export const a = 1;\n"),
],
);
assert!(project.run().expect("runs").violations.is_empty());
project.write("policy.json", r#"{"forbidExports":true}"#);
assert_eq!(
project.run().expect("runs").violations.len(),
1,
"a changed dependency did not invalidate"
);
}
#[test]
fn a_dependency_that_appears_invalidates() {
const RULE: &str = r"import { defineRule } from 'lanekeep';
export default defineRule({
id: 'local/wants-config',
query: '(export_statement) @stmt',
card: { message: 'no config', remediation: 'add one', examples: { bad: 'a', good: 'b' } },
check(ctx, m) {
if (!ctx.fileExists('tsconfig.json')) ctx.report(m.stmt);
},
});
";
let project = Project::new(
"cache-appeared",
&[
("rule.ts", RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "export const a = 1;\n"),
],
);
assert_eq!(project.run().expect("runs").violations.len(), 1);
project.write("tsconfig.json", "{}");
assert!(
project.run().expect("runs").violations.is_empty(),
"a dependency that appeared did not invalidate"
);
}
#[test]
fn changing_the_ruleset_invalidates_everything() {
let project = Project::new(
"cache-ruleset",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
],
);
assert_eq!(project.run().expect("runs").violations.len(), 1);
project.write(
"rule.ts",
&DEBUGGER_RULE.replace("ctx.report(m.stmt);", "/* nothing */"),
);
assert!(
project.run().expect("runs").violations.is_empty(),
"an edited rule kept its stale results"
);
}
#[test]
fn changing_the_config_invalidates_everything() {
let project = Project::new(
"cache-config",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
],
);
assert_eq!(project.run().expect("runs").violations.len(), 1);
project.write(
"lanekeep.config.ts",
&config(", severity: { 'local/no-debugger': 'off' }"),
);
assert!(
project.run().expect("runs").violations.is_empty(),
"a config change did not invalidate"
);
}
#[test]
fn a_corrupt_cache_still_produces_the_right_answer() {
let project = Project::new(
"cache-corrupt",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
],
);
let expected = rendered(&project.run().expect("runs"));
let path = Store::path_for(&project.dir);
fs::write(&path, b"\x00\x01\x02 not a cache").expect("writes");
assert_eq!(rendered(&project.run().expect("runs")), expected);
}
#[test]
fn caching_can_be_turned_off() {
let project = Project::new(
"cache-off",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
],
);
let outcome = project.run_cold().expect("runs");
assert_eq!(outcome.violations.len(), 1);
assert!(
project.cache().is_empty(),
"a run with caching off wrote a cache"
);
}
#[test]
fn facts_survive_a_warm_run() {
let project = Project::new(
"cache-facts",
&[
("rule.ts", UNUSED_EXPORTS_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"export function used() {}\nexport function spare() {}\n",
),
("src/b.ts", "import { used } from './a';\nused();\n"),
],
);
let cold = rendered(&project.run().expect("runs"));
assert_eq!(cold.len(), 1, "{cold:?}");
assert_eq!(rendered(&project.run().expect("runs")), cold);
assert_eq!(rendered(&project.run().expect("runs")), cold);
}
#[test]
fn a_cache_file_does_not_churn() {
let project = Project::new(
"cache-stable",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
("src/b.ts", "const b = 1;\n"),
],
);
project.run().expect("runs");
let first = fs::read(Store::path_for(&project.dir)).expect("reads");
project.run().expect("runs");
let second = fs::read(Store::path_for(&project.dir)).expect("reads");
assert_eq!(first, second, "the cache file churned");
}
#[test]
fn entries_for_deleted_files_do_not_accumulate() {
let project = Project::new(
"cache-prune",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
("src/b.ts", "debugger;\n"),
],
);
project.run().expect("runs");
assert_eq!(project.cache().len(), 2);
fs::remove_file(project.dir.join("src/b.ts")).expect("removes");
project.run().expect("runs");
assert_eq!(
project.cache().len(),
1,
"an entry outlived the file it was for"
);
}
#[test]
fn a_partial_run_does_not_discard_other_files_entries() {
let project = Project::new(
"cache-partial",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
("src/b.ts", "const b = 1;\n"),
("src/c.ts", "const c = 1;\n"),
],
);
project.run().expect("runs");
assert_eq!(project.cache().len(), 3);
let engine = project.build().expect("prepares");
engine
.run_over(&[FilePath::new("src/a.ts")])
.expect("runs over one file");
assert_eq!(
project.cache().len(),
3,
"a partial run discarded entries for files it did not look at"
);
}
#[test]
fn a_full_run_still_prunes() {
let project = Project::new(
"cache-prune-still",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
("src/b.ts", "const b = 1;\n"),
],
);
project.run().expect("runs");
assert_eq!(project.cache().len(), 2);
fs::remove_file(project.dir.join("src/b.ts")).expect("removes");
project.run().expect("runs");
assert_eq!(project.cache().len(), 1);
}
impl Project {
fn run_on(&self, today: &str) -> Result<Outcome, RunError> {
let date = Date::parse(today).expect("valid date");
self.build().map(|engine| engine.with_today(date))?.run()
}
}
fn messages(outcome: &Outcome) -> Vec<&str> {
outcome
.violations
.iter()
.map(|v| v.message.as_str())
.collect()
}
#[test]
fn a_next_line_directive_silences_the_line_below_it() {
let project = Project::new(
"suppress-next-line",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-next-line local/no-debugger reason: legacy entry point\n\
debugger;\n",
),
],
);
assert!(
project.run().expect("runs").violations.is_empty(),
"the directive did not silence the violation"
);
}
#[test]
fn a_directive_silences_only_the_line_it_names() {
let project = Project::new(
"suppress-scope",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-next-line local/no-debugger reason: legacy\n\
debugger;\n\
debugger;\n",
),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
assert_eq!(outcome.violations[0].location.position.line, 3);
}
#[test]
fn a_file_directive_silences_every_line() {
let project = Project::new(
"suppress-file",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-file local/no-debugger reason: generated fixture\n\
debugger;\n\
debugger;\n",
),
],
);
assert!(project.run().expect("runs").violations.is_empty());
}
#[test]
fn a_directive_naming_another_rule_silences_nothing() {
let project = Project::new(
"suppress-other-rule",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-next-line local/something-else reason: unrelated\n\
debugger;\n",
),
],
);
assert_eq!(project.run().expect("runs").violations.len(), 1);
}
#[test]
fn a_malformed_directive_is_reported() {
let project = Project::new(
"suppress-malformed",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-next-line local/no-debugger\ndebugger;\n",
),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 2, "{:?}", messages(&outcome));
assert!(
messages(&outcome)
.iter()
.any(|m| m.contains("no `reason:`")),
"{:?}",
messages(&outcome)
);
assert!(
outcome
.violations
.iter()
.any(|v| v.rule_id.to_string() == "lanekeep/suppression"),
"reported under the wrong id"
);
}
#[test]
fn an_expired_directive_is_reported_and_still_silences() {
let project = Project::new(
"suppress-expired",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-next-line local/no-debugger reason: pending rewrite expires: 2026-01-01\n\
debugger;\n",
),
],
);
let outcome = project.run_on("2026-08-01").expect("runs");
assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
assert!(
outcome.violations[0]
.message
.contains("expired on 2026-01-01"),
"{:?}",
messages(&outcome)
);
assert!(
outcome.violations[0].message.contains("pending rewrite"),
"the reason should be quoted back: {:?}",
messages(&outcome)
);
}
#[test]
fn a_directive_that_has_not_expired_is_quiet() {
let project = Project::new(
"suppress-unexpired",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-next-line local/no-debugger reason: pending expires: 2026-12-31\n\
debugger;\n",
),
],
);
assert!(
project
.run_on("2026-08-01")
.expect("runs")
.violations
.is_empty()
);
}
#[test]
fn a_directive_expires_the_day_after_its_date() {
let project = Project::new(
"suppress-boundary",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-file local/no-debugger reason: x expires: 2026-08-01\n\
debugger;\n",
),
],
);
assert!(
project
.run_on("2026-08-01")
.expect("runs")
.violations
.is_empty()
);
assert_eq!(
project.run_on("2026-08-02").expect("runs").violations.len(),
1
);
}
#[test]
fn an_expiring_directive_is_not_served_stale_from_the_cache() {
let project = Project::new(
"suppress-cache-date",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-file local/no-debugger reason: x expires: 2026-08-01\n\
debugger;\n",
),
],
);
assert!(
project
.run_on("2026-08-01")
.expect("runs")
.violations
.is_empty()
);
let after = project.run_on("2026-08-02").expect("runs");
assert_eq!(
after.violations.len(),
1,
"a warm run served an expired suppression: {:?}",
messages(&after)
);
}
#[test]
fn suppressions_survive_a_warm_run() {
let project = Project::new(
"suppress-warm",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-file local/no-debugger reason: generated\ndebugger;\n",
),
],
);
assert!(project.run().expect("runs").violations.is_empty());
assert!(
project.run().expect("runs").violations.is_empty(),
"the warm run reported what the cold one suppressed"
);
}
#[test]
fn a_cross_file_violation_is_silenced_by_the_directive_where_it_lands() {
let project = Project::new(
"suppress-cross-file",
&[
("rule.ts", UNUSED_EXPORTS_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"export function used() {}\n\
// lanekeep-ignore-next-line local/no-unused-exports reason: public API\n\
export function spare() {}\n",
),
("src/b.ts", "import { used } from './a';\nused();\n"),
],
);
let outcome = project.run().expect("runs");
assert!(
outcome.violations.is_empty(),
"a cross-file violation ignored the directive at its site: {:?}",
messages(&outcome)
);
}
#[test]
fn a_cross_file_violation_survives_a_directive_for_another_rule() {
let project = Project::new(
"suppress-cross-file-other",
&[
("rule.ts", UNUSED_EXPORTS_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"export function used() {}\n\
// lanekeep-ignore-next-line local/unrelated reason: x\n\
export function spare() {}\n",
),
("src/b.ts", "import { used } from './a';\nused();\n"),
],
);
assert_eq!(project.run().expect("runs").violations.len(), 1);
}
impl Project {
fn run_reporting_unused(&self) -> Result<Outcome, RunError> {
self.build()
.map(Engine::reporting_unused_suppressions)?
.run()
}
}
#[test]
fn a_suppression_that_silenced_nothing_is_reported() {
let project = Project::new(
"unused-reported",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-next-line local/no-debugger reason: was needed once\n\
const a = 1;\n",
),
],
);
let outcome = project.run_reporting_unused().expect("runs");
assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
assert!(
outcome.violations[0].message.contains("silenced nothing"),
"{:?}",
messages(&outcome)
);
assert!(
outcome.violations[0].message.contains("was needed once"),
"the reason should be quoted back: {:?}",
messages(&outcome)
);
}
#[test]
fn a_suppression_that_did_its_job_is_not_reported() {
let project = Project::new(
"unused-used",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-next-line local/no-debugger reason: legacy\ndebugger;\n",
),
],
);
assert!(
project
.run_reporting_unused()
.expect("runs")
.violations
.is_empty()
);
}
#[test]
fn unused_suppressions_are_quiet_without_the_flag() {
let project = Project::new(
"unused-off",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-next-line local/no-debugger reason: stale\nconst a = 1;\n",
),
],
);
assert!(project.run().expect("runs").violations.is_empty());
}
#[test]
fn an_unused_suppression_is_a_warning_not_an_error() {
let project = Project::new(
"unused-severity",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-next-line local/no-debugger reason: stale\nconst a = 1;\n",
),
],
);
let outcome = project.run_reporting_unused().expect("runs");
assert_eq!(outcome.violations[0].severity, Severity::Warn);
assert!(!lanekeep_core::any_failing(&outcome.violations));
}
#[test]
fn usage_survives_a_warm_run() {
let project = Project::new(
"unused-warm",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-next-line local/no-debugger reason: legacy\ndebugger;\n",
),
],
);
assert!(
project
.run_reporting_unused()
.expect("runs")
.violations
.is_empty()
);
let warm = project.run_reporting_unused().expect("runs");
assert!(
warm.violations.is_empty(),
"a warm run called a used suppression unused: {:?}",
messages(&warm)
);
}
#[test]
fn a_suppression_used_only_by_a_cross_file_rule_is_not_unused() {
let project = Project::new(
"unused-cross-file",
&[
("rule.ts", UNUSED_EXPORTS_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"export function used() {}\n\
// lanekeep-ignore-next-line local/no-unused-exports reason: public API\n\
export function spare() {}\n",
),
("src/b.ts", "import { used } from './a';\nused();\n"),
],
);
let outcome = project.run_reporting_unused().expect("runs");
assert!(
outcome.violations.is_empty(),
"a directive used by a cross-file rule was called unused: {:?}",
messages(&outcome)
);
}
#[test]
fn a_malformed_directive_is_not_also_reported_as_unused() {
let project = Project::new(
"unused-malformed",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
(
"src/a.ts",
"// lanekeep-ignore-next-line local/no-debugger\nconst a = 1;\n",
),
],
);
let outcome = project.run_reporting_unused().expect("runs");
assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
assert!(
outcome.violations[0].message.contains("no `reason:`"),
"{:?}",
messages(&outcome)
);
}
const DATE_RULE: &str = r"import { defineRule } from 'lanekeep';
export default defineRule({
id: 'local/dated',
query: '(export_statement) @stmt',
card: { message: 'dated', remediation: 'x', examples: { bad: 'a', good: 'b' } },
check(ctx, m) {
if (ctx.today.startsWith('2027')) ctx.report(m.stmt, `it is ${ctx.today}`);
},
});
";
#[test]
fn a_rule_can_read_the_date() {
let project = Project::new(
"today-read",
&[
("rule.ts", DATE_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "export const a = 1;\n"),
],
);
let outcome = project.run_on("2027-03-04").expect("runs");
assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
assert!(outcome.violations[0].message.contains("2027-03-04"));
}
#[test]
fn a_result_that_read_the_date_is_not_served_across_days() {
let project = Project::new(
"today-cache",
&[
("rule.ts", DATE_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "export const a = 1;\n"),
],
);
assert!(
project
.run_on("2026-12-31")
.expect("runs")
.violations
.is_empty()
);
let later = project.run_on("2027-01-01").expect("runs");
assert_eq!(
later.violations.len(),
1,
"a warm run served a date-dependent result from another day: {:?}",
messages(&later)
);
}
#[test]
fn a_result_that_ignored_the_date_survives_across_days() {
let project = Project::new(
"today-undated",
&[
("rule.ts", DEBUGGER_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "debugger;\n"),
],
);
project.run_on("2026-12-31").expect("runs");
let before = fs::read(Store::path_for(&project.dir)).expect("reads");
let outcome = project.run_on("2027-01-01").expect("runs");
assert_eq!(outcome.violations.len(), 1);
let after = fs::read(Store::path_for(&project.dir)).expect("reads");
assert_eq!(
before, after,
"a result that never read the date was re-keyed across days"
);
}
#[test]
fn a_result_that_read_the_date_is_re_keyed_across_days() {
let project = Project::new(
"today-dated-key",
&[
("rule.ts", DATE_RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "export const a = 1;\n"),
],
);
project.run_on("2026-12-31").expect("runs");
let before = fs::read(Store::path_for(&project.dir)).expect("reads");
project.run_on("2027-01-01").expect("runs");
let after = fs::read(Store::path_for(&project.dir)).expect("reads");
assert_ne!(
before, after,
"a result that read the date kept its key across days"
);
}
#[test]
fn loc_reaches_a_reduce_phase_through_a_fact() {
const RULE: &str = r"import { defineRule } from 'lanekeep';
export default defineRule({
id: 'local/loc-through-facts',
query: '(export_statement) @stmt',
card: { message: 'via loc', remediation: 'x', examples: { bad: 'a', good: 'b' } },
check(ctx, m) { ctx.emitFact({ kind: 'site', at: ctx.loc(m.stmt) }); },
reduce(ctx) {
for (const f of ctx.facts('site')) ctx.report(f.at, 'reported at a remembered place');
},
});
";
let project = Project::new(
"loc-facts",
&[
("rule.ts", RULE),
("lanekeep.config.ts", &config("")),
("src/a.ts", "const x = 1;\nexport const a = 1;\n"),
],
);
let outcome = project.run().expect("runs");
assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
assert_eq!(outcome.violations[0].location.position.line, 2);
}
}