1use std::collections::BTreeMap;
34use std::path::{Path, PathBuf};
35use std::rc::Rc;
36use std::sync::Arc;
37use std::time::Duration;
38
39use lanekeep_cache::{CacheKey, Entry as CacheEntry, GrammarKey, RunKey, Store};
40use lanekeep_config::{Config, ConfigError, RuleSpec};
41use lanekeep_core::suppression::{self, Date, Suppressions};
42use lanekeep_core::{
43 CompiledGates, Discovery, DiscoveryError, Fact, FilePath, Location, Position, RuleId, Severity,
44 TrackedRead, Violation,
45};
46use lanekeep_js::{
47 FileAccess, HOST_API_VERSION, HostContext, Limits, ReduceContext, ReduceFact, RuleRoot,
48 RunClock, Sandbox, SandboxError,
49};
50use lanekeep_lang::{Language, LanguageRegistry};
51use lanekeep_query::{CompileError, CompiledQuery};
52use rayon::prelude::*;
53use thiserror::Error;
54
55#[derive(Debug, Clone, PartialEq, Eq, Error)]
60pub enum RunError {
61 #[error(transparent)]
63 Discovery(#[from] DiscoveryError),
64
65 #[error("rule `{rule}` has an invalid query\n{detail}")]
67 Query {
68 rule: String,
70 detail: String,
72 },
73
74 #[error("rule `{rule}` targets unknown language `{language}`\n known languages: {known}")]
76 UnknownLanguage {
77 rule: String,
79 language: String,
81 known: String,
83 },
84
85 #[error("rule `{rule}` has invalid gates: {detail}")]
87 Gates {
88 rule: String,
90 detail: String,
92 },
93
94 #[error("rule `{rule}` failed on `{file}`\n{detail}")]
96 Rule {
97 rule: String,
99 file: String,
101 detail: String,
103 },
104
105 #[error("could not start a worker: {detail}")]
107 Worker {
108 detail: String,
110 },
111}
112
113struct Prepared {
119 spec: RuleSpec,
120 gates: CompiledGates,
121 compiled: Vec<(Arc<dyn Language>, CompiledQuery)>,
123}
124
125impl Prepared {
126 fn for_language(&self, id: &str) -> Option<&(Arc<dyn Language>, CompiledQuery)> {
132 self.compiled
133 .iter()
134 .find(|(language, _)| language.id().as_str() == id)
135 }
136}
137
138#[expect(
140 clippy::struct_excessive_bools,
141 reason = "four independent run modes — caching, reducing, unused reporting, profiling — \
142 every combination of which is meaningful and reachable from the CLI. The lint \
143 is aimed at a type where a pile of bools stands in for a missing enum; these \
144 are orthogonal switches, and an enum over their sixteen combinations would be \
145 strictly worse to read and to set."
146)]
147pub struct Engine {
148 rules: Vec<Prepared>,
149 discovery: Discovery,
150 root: PathBuf,
153 run_key: RunKey,
155 caching: bool,
157 reducing: bool,
159 reporting_unused: bool,
161 profiling: bool,
163 today: Date,
168 limits: Limits,
169 rules_root: RuleRoot,
170 config_path: PathBuf,
171 typescript: Arc<dyn Language>,
172 javascript: Arc<dyn Language>,
173 languages_by_extension: BTreeMap<String, String>,
178}
179
180impl std::fmt::Debug for Engine {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 f.debug_struct("Engine")
183 .field("rules", &self.rules.len())
184 .field("root", &self.discovery.root())
185 .finish_non_exhaustive()
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
196pub struct RuleTiming {
197 pub query: Duration,
199 pub handler: Duration,
201 pub matches: u64,
206}
207
208impl RuleTiming {
209 #[must_use]
211 pub const fn total(&self) -> Duration {
212 self.query.saturating_add(self.handler)
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Default)]
218pub struct Outcome {
219 pub violations: Vec<Violation>,
221 pub files_discovered: usize,
223 pub files_parsed: usize,
225
226 pub timings: Option<BTreeMap<RuleId, RuleTiming>>,
231
232 pub dependencies: BTreeMap<FilePath, Vec<TrackedRead>>,
238}
239
240impl Engine {
241 pub fn prepare(
251 config: &Config,
252 project_root: &Path,
253 rules_root: RuleRoot,
254 config_path: &Path,
255 registry: &LanguageRegistry,
256 typescript: Arc<dyn Language>,
257 javascript: Arc<dyn Language>,
258 ) -> Result<Self, RunError> {
259 let discovery = Discovery::new(project_root, &config.include, &config.exclude)?;
260
261 let known = registry
262 .languages()
263 .map(|l| l.id().as_str())
264 .collect::<Vec<_>>()
265 .join(", ");
266
267 let mut languages_by_extension = BTreeMap::new();
268 for language in registry.languages() {
269 for extension in language.extensions() {
270 languages_by_extension
271 .insert(extension.to_ascii_lowercase(), language.id().to_string());
272 }
273 }
274
275 let mut rules = Vec::with_capacity(config.rules.len());
276 for spec in &config.rules {
277 if !spec.severity.is_enabled() {
278 continue;
279 }
280
281 let mut compiled = Vec::with_capacity(spec.languages.len());
282 for id in &spec.languages {
283 let language =
284 registry
285 .by_id(id)
286 .cloned()
287 .ok_or_else(|| RunError::UnknownLanguage {
288 rule: spec.id.to_string(),
289 language: id.clone(),
290 known: known.clone(),
291 })?;
292
293 let query = CompiledQuery::compile(language.as_ref(), &spec.query).map_err(
297 |e: CompileError| RunError::Query {
298 rule: spec.id.to_string(),
299 detail: e.to_string(),
300 },
301 )?;
302
303 compiled.push((language, query));
304 }
305
306 let gates = CompiledGates::compile(&spec.gates).map_err(|e| RunError::Gates {
307 rule: spec.id.to_string(),
308 detail: e.to_string(),
309 })?;
310
311 rules.push(Prepared {
312 spec: spec.clone(),
313 gates,
314 compiled,
315 });
316 }
317
318 let mut grammars: Vec<GrammarKey> = registry
321 .languages()
322 .map(|language| GrammarKey {
323 id: language.id().to_string(),
324 abi: u32::try_from(language.grammar_abi()).unwrap_or(u32::MAX),
325 })
326 .collect();
327 grammars.sort_by(|a, b| a.id.cmp(&b.id));
328
329 let run_key = RunKey::new(
330 engine_version(),
334 HOST_API_VERSION,
335 &config.ruleset_hash,
336 &config.config_hash,
337 &grammars,
338 );
339
340 Ok(Self {
341 rules,
342 run_key,
343 caching: true,
344 reducing: true,
345 reporting_unused: false,
346 profiling: false,
347 today: suppression::today(),
348 root: project_root
352 .canonicalize()
353 .unwrap_or_else(|_| project_root.to_path_buf()),
354 discovery,
355 limits: config.limits,
356 rules_root,
357 config_path: config_path.to_path_buf(),
358 typescript,
359 javascript,
360 languages_by_extension,
361 })
362 }
363
364 fn language_of(&self, path: &FilePath) -> Option<&str> {
366 let extension = Path::new(path.as_str())
367 .extension()?
368 .to_str()?
369 .to_ascii_lowercase();
370 self.languages_by_extension
371 .get(extension.as_str())
372 .map(String::as_str)
373 }
374
375 #[must_use]
377 pub const fn without_cache(mut self) -> Self {
378 self.caching = false;
379 self
380 }
381
382 #[must_use]
387 pub const fn profiling(mut self) -> Self {
388 self.profiling = true;
389 self
390 }
391
392 #[must_use]
398 pub const fn reporting_unused_suppressions(mut self) -> Self {
399 self.reporting_unused = true;
400 self
401 }
402
403 #[must_use]
407 pub const fn with_today(mut self, today: Date) -> Self {
408 self.today = today;
409 self
410 }
411
412 #[must_use]
422 pub const fn without_reduce(mut self) -> Self {
423 self.reducing = false;
424 self
425 }
426
427 #[must_use]
432 pub fn discover(&self) -> Vec<FilePath> {
433 self.discovery.walk()
434 }
435
436 #[must_use]
438 pub fn rule_count(&self) -> usize {
439 self.rules.len()
440 }
441
442 pub fn rules(&self) -> impl Iterator<Item = &RuleSpec> {
448 self.rules.iter().map(|prepared| &prepared.spec)
449 }
450
451 pub fn run(&self) -> Result<Outcome, RunError> {
459 let files = self.discovery.walk();
460 self.run_files(&files, Coverage::Whole)
461 }
462
463 pub fn run_over(&self, files: &[FilePath]) -> Result<Outcome, RunError> {
469 self.run_files(files, Coverage::Partial)
470 }
471
472 fn run_files(&self, files: &[FilePath], coverage: Coverage) -> Result<Outcome, RunError> {
474 let clock = RunClock::start(self.limits.global_timeout);
475
476 let cache = if self.caching {
480 Store::load(&self.root)
481 } else {
482 Store::empty()
483 };
484
485 let results: Vec<Result<FileOutcome, RunError>> = files
486 .par_iter()
487 .map_init(
488 || Worker::new(self, &clock),
498 |worker, path| self.check_file(worker, &cache, path),
499 )
500 .collect();
501
502 let mut violations = Vec::new();
503 let mut facts = Vec::new();
504 let mut files_parsed = 0;
505 let mut dependencies = BTreeMap::new();
506 let mut fresh = Store::empty();
507 let mut directives: BTreeMap<FilePath, FileDirectives> = BTreeMap::new();
508 let mut timings: BTreeMap<RuleId, RuleTiming> = BTreeMap::new();
509 for result in results {
510 let outcome = result?;
511 violations.extend(outcome.violations);
512 facts.extend(outcome.facts);
513 files_parsed += usize::from(outcome.parsed);
514 if let Some(entry) = outcome.entry {
515 fresh.insert(entry.0, entry.1);
516 }
517 for (rule, timing) in outcome.timings {
518 let entry = timings.entry(rule).or_default();
519 entry.query = entry.query.saturating_add(timing.query);
520 entry.handler = entry.handler.saturating_add(timing.handler);
521 entry.matches += timing.matches;
522 }
523 if !outcome.suppressions.is_empty() {
524 directives.insert(
525 outcome.path.clone(),
526 FileDirectives {
527 suppressions: outcome.suppressions,
528 used: outcome.used_suppressions,
529 },
530 );
531 }
532 if !outcome.reads.is_empty() {
533 dependencies.insert(outcome.path, outcome.reads);
534 }
535 }
536
537 if self.caching {
538 match coverage {
539 Coverage::Whole => fresh.save(&self.root),
542 Coverage::Partial => {
547 let mut merged = cache;
548 for key in fresh.keys().copied().collect::<Vec<_>>() {
549 if let Some(entry) = fresh.get(&key) {
550 merged.insert(key, entry.clone());
551 }
552 }
553 merged.save(&self.root);
554 }
555 }
556 }
557
558 lanekeep_core::fact::sort(&mut facts);
567
568 let reduced = self.reduce(&clock, files, &facts)?;
572 for violation in reduced {
573 match covering_elsewhere(&directives, &violation) {
576 Some((file, index)) => {
577 if let Some(found) = directives.get_mut(&file)
578 && !found.used.contains(&index)
579 {
580 found.used.push(index);
581 }
582 }
583 None => violations.push(violation),
584 }
585 }
586
587 if self.reporting_unused {
588 violations.extend(unused_violations(&directives));
589 }
590
591 lanekeep_core::sort(&mut violations);
592 Ok(Outcome {
593 violations,
594 files_discovered: files.len(),
595 files_parsed,
596 timings: self.profiling.then_some(timings),
597 dependencies,
598 })
599 }
600
601 fn reduce(
608 &self,
609 clock: &Arc<RunClock>,
610 files: &[FilePath],
611 facts: &[Fact],
612 ) -> Result<Vec<Violation>, RunError> {
613 if !self.reducing {
614 return Ok(Vec::new());
615 }
616
617 let reducing: Vec<&Prepared> = self
618 .rules
619 .iter()
620 .filter(|rule| rule.spec.has_reduce)
621 .collect();
622 if reducing.is_empty() {
623 return Ok(Vec::new());
626 }
627
628 let sandbox = self.build_sandbox(clock)?;
629 let paths: Vec<String> = files.iter().map(|f| f.as_str().to_owned()).collect();
630 let mut violations = Vec::new();
631
632 for rule in reducing {
633 let own: Vec<ReduceFact> = facts
637 .iter()
638 .filter(|fact| fact.rule_id == rule.spec.id)
639 .map(|fact| ReduceFact {
640 kind: fact.kind.clone(),
641 json: lanekeep_js::merge_file(&fact.data, fact.file.as_str()),
642 })
643 .collect();
644
645 let host = ReduceContext::new(paths.clone(), own);
646 let timeout = rule.spec.timeout.unwrap_or(self.limits.rule_timeout);
647 let call = format!(
648 "globalThis.__lanekeepConfig.rules[{}].reduce(ctx)",
649 rule_index(&rule.spec)
650 );
651
652 sandbox
653 .eval_with_reduce_host::<()>(&host, &call, timeout)
654 .map_err(|e: SandboxError| RunError::Rule {
655 rule: rule.spec.id.to_string(),
656 file: "<reduce>".to_owned(),
659 detail: e.to_string(),
660 })?;
661
662 for report in host.take_reports() {
663 violations.push(Violation {
668 rule_id: rule.spec.id.clone(),
669 location: Location::new(
670 FilePath::new(&report.file),
671 Position::new(report.line, report.column),
672 ),
673 message: report
674 .message
675 .unwrap_or_else(|| rule.spec.card.message.clone()),
676 remediation: rule.spec.card.remediation.clone(),
677 severity: rule.spec.severity,
678 fix: None,
682 });
683 }
684 }
685
686 Ok(violations)
687 }
688
689 fn build_sandbox(&self, clock: &Arc<RunClock>) -> Result<Sandbox, RunError> {
691 let sandbox = Sandbox::with_modules(
692 self.limits,
693 Arc::clone(clock),
694 self.rules_root.clone(),
695 Arc::clone(&self.typescript),
696 Arc::clone(&self.javascript),
697 )
698 .map_err(|e| RunError::Worker {
699 detail: e.to_string(),
700 })?;
701
702 lanekeep_config::evaluate_into(&sandbox, &self.rules_root, &self.config_path).map_err(
706 |e: ConfigError| RunError::Worker {
707 detail: e.to_string(),
708 },
709 )?;
710
711 Ok(sandbox)
712 }
713
714 fn check_file(
716 &self,
717 worker: &mut Worker<'_>,
718 cache: &Store,
719 path: &FilePath,
720 ) -> Result<FileOutcome, RunError> {
721 let files = Rc::new(FileAccess::rooted(self.root.clone()));
724
725 let admitted: Vec<&Prepared> = self
727 .rules
728 .iter()
729 .filter(|rule| rule.gates.admits_path(path))
730 .collect();
731 if admitted.is_empty() {
732 return Ok(FileOutcome::skipped(path.clone()));
733 }
734
735 let absolute = self.discovery.root().join(path.as_str());
736 let Ok(bytes) = std::fs::read(&absolute) else {
737 return Ok(FileOutcome::skipped(path.clone()));
742 };
743
744 let keys = self.caching.then(|| {
762 let content = lanekeep_cache::hash_bytes(&bytes);
763 (
764 self.run_key.for_file(path.as_str(), &content),
765 self.run_key
766 .for_dated_file(path.as_str(), &content, &self.today.to_string()),
767 )
768 });
769 let has_expiry = memchr::memmem::find(&bytes, b"expires:").is_some();
770
771 if let Some((plain, dated)) = keys {
772 let candidates: &[CacheKey] = if has_expiry {
775 &[dated]
776 } else {
777 &[dated, plain]
778 };
779 for key in candidates {
780 if let Some(entry) = cache.get(key)
781 && lanekeep_cache::validate(entry, &self.root)
782 {
783 return Ok(FileOutcome::cached(path.clone(), *key, entry.clone()));
784 }
785 }
786 }
787
788 let admitted: Vec<&Prepared> = admitted
790 .into_iter()
791 .filter(|rule| rule.gates.admits_content(&bytes))
792 .collect();
793 if admitted.is_empty() {
794 return Ok(FileOutcome::empty_entry(
799 path.clone(),
800 keys.map(|(plain, dated)| if has_expiry { dated } else { plain }),
801 ));
802 }
803
804 let Ok(source) = String::from_utf8(bytes) else {
805 return Ok(FileOutcome::skipped(path.clone()));
807 };
808
809 let directives = suppression::parse(&source);
812
813 let mut outcome = FileOutcome::parsed(path.clone());
814 for rule in admitted {
815 let (violations, facts, read_the_date, timing) =
816 self.run_rule(worker, &files, rule, path, &source)?;
817 outcome.violations.extend(violations);
818 outcome.facts.extend(facts);
819 outcome.read_the_date |= read_the_date;
820 if self.profiling {
821 outcome.timings.push((rule.spec.id.clone(), timing));
822 }
823 }
824
825 let mut used = Vec::new();
830 outcome.violations.retain(|violation| {
831 match directives.covering(&violation.rule_id, violation.location.position.line) {
832 Some(index) => {
833 let index = u32::try_from(index).unwrap_or(u32::MAX);
834 if !used.contains(&index) {
835 used.push(index);
836 }
837 false
838 }
839 None => true,
840 }
841 });
842 used.sort_unstable();
843 outcome.used_suppressions = used;
844 outcome
845 .violations
846 .extend(self.directive_violations(&directives, path));
847
848 outcome.suppressions = directives.valid;
849 outcome.reads = files.dependencies();
850 let date_dependent = has_expiry || outcome.read_the_date;
853 outcome.entry = keys.map(|(plain, dated)| {
854 (
855 if date_dependent { dated } else { plain },
856 CacheEntry {
857 violations: outcome.violations.clone(),
858 facts: outcome.facts.clone(),
859 dependencies: outcome.reads.clone(),
860 suppressions: outcome.suppressions.clone(),
861 used_suppressions: outcome.used_suppressions.clone(),
862 },
863 )
864 });
865
866 Ok(outcome)
867 }
868
869 fn directive_violations(&self, directives: &Suppressions, path: &FilePath) -> Vec<Violation> {
876 let mut violations = Vec::new();
877
878 let Ok(rule_id) = SUPPRESSION_RULE.parse::<RuleId>() else {
882 return violations;
883 };
884
885 for bad in &directives.malformed {
886 violations.push(Violation {
887 rule_id: rule_id.clone(),
888 location: Location::new(path.clone(), Position::new(bad.line, bad.column)),
889 message: bad.problem.clone(),
890 remediation: String::from(
891 "fix the directive, or remove it and fix what it was hiding",
892 ),
893 severity: Severity::Error,
894 fix: None,
895 });
896 }
897
898 for suppression in &directives.valid {
899 let Some(expires) = suppression.expires else {
900 continue;
901 };
902 if expires >= self.today {
903 continue;
904 }
905
906 violations.push(Violation {
907 rule_id: rule_id.clone(),
908 location: Location::new(
909 path.clone(),
910 Position::new(suppression.line, suppression.column),
911 ),
912 message: format!(
913 "suppression expired on {expires} — \"{}\"",
914 suppression.reason
915 ),
916 remediation: String::from(
917 "fix what it was suppressing, or decide it is permanent and drop the \
918 expiry",
919 ),
920 severity: Severity::Error,
921 fix: None,
922 });
923 }
924
925 violations
926 }
927
928 fn run_rule(
929 &self,
930 worker: &mut Worker<'_>,
931 files: &Rc<FileAccess>,
932 rule: &Prepared,
933 path: &FilePath,
934 source: &str,
935 ) -> Result<(Vec<Violation>, Vec<Fact>, bool, RuleTiming), RunError> {
936 let Some(language_id) = self.language_of(path) else {
940 return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
941 };
942 let Some((language, compiled_query)) = rule.for_language(language_id) else {
943 return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
944 };
945
946 let mut parser = tree_sitter::Parser::new();
947 if parser.set_language(&language.grammar()).is_err() {
948 return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
949 }
950 let Some(tree) = parser.parse(source, None) else {
951 return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
952 };
953
954 let mut timing = RuleTiming::default();
957 let clock = |on: bool| on.then(std::time::Instant::now);
958
959 let mut matches: Vec<Vec<(String, Vec<u32>)>> = Vec::new();
962 let host = HostContext::new(tree, source.to_owned(), path.as_str())
963 .with_resolver_from(language.as_ref())
964 .with_language(Arc::clone(language))
965 .with_today(&self.today.to_string())
966 .with_file_access(Rc::clone(files));
967
968 let query_started = clock(self.profiling);
969 {
970 let arena = host.arena().borrow();
971 compiled_query.for_each_match(arena.tree(), source.as_bytes(), |m| {
972 let captures = m
973 .captures
974 .iter()
975 .filter_map(|(name, node)| {
976 arena.path_of(*node).map(|path| ((*name).to_owned(), path))
977 })
978 .collect();
979 matches.push(captures);
980 });
981 }
982
983 if let Some(started) = query_started {
984 timing.query = started.elapsed();
985 timing.matches = matches.len() as u64;
986 }
987
988 if matches.is_empty() {
989 return Ok((Vec::new(), Vec::new(), false, timing));
990 }
991
992 let sandbox = worker.sandbox()?;
995
996 let timeout = rule.spec.timeout.unwrap_or(self.limits.rule_timeout);
997 let mut violations = Vec::new();
998
999 for captures in matches {
1000 let handles: Vec<(String, u32)> = {
1001 let mut arena = host.arena().borrow_mut();
1002 captures
1003 .into_iter()
1004 .filter_map(|(name, path)| arena.intern_path(path).map(|h| (name, h)))
1005 .collect()
1006 };
1007
1008 let literal = handles
1009 .iter()
1010 .map(|(name, handle)| format!("{}: {handle}", json_key(name)))
1011 .collect::<Vec<_>>()
1012 .join(", ");
1013
1014 let call = format!(
1017 "globalThis.__lanekeepConfig.rules[{}].check(ctx, {{{literal}}})",
1018 rule_index(&rule.spec)
1019 );
1020
1021 let handler_started = clock(self.profiling);
1022 let outcome = sandbox.eval_with_host_timeout::<()>(&host, &call, timeout);
1023 if let Some(started) = handler_started {
1024 timing.handler = timing.handler.saturating_add(started.elapsed());
1025 }
1026
1027 outcome.map_err(|e: SandboxError| RunError::Rule {
1028 rule: rule.spec.id.to_string(),
1029 file: path.as_str().to_owned(),
1030 detail: e.to_string(),
1031 })?;
1032 }
1033
1034 let facts = host
1035 .take_facts()
1036 .into_iter()
1037 .enumerate()
1038 .map(|(sequence, emitted)| Fact {
1039 rule_id: rule.spec.id.clone(),
1040 file: path.clone(),
1041 kind: emitted.kind,
1042 data: emitted.data,
1043 sequence: u32::try_from(sequence).unwrap_or(u32::MAX),
1047 })
1048 .collect();
1049
1050 for report in host.take_reports() {
1051 violations.push(Violation {
1052 rule_id: rule.spec.id.clone(),
1053 location: Location::new(path.clone(), Position::new(report.line, report.column)),
1054 message: report
1055 .message
1056 .unwrap_or_else(|| rule.spec.card.message.clone()),
1057 remediation: rule.spec.card.remediation.clone(),
1058 severity: rule.spec.severity,
1059 fix: report.fix,
1060 });
1061 }
1062
1063 Ok((violations, facts, host.date_was_read(), timing))
1064 }
1065}
1066
1067#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072enum Coverage {
1073 Whole,
1075 Partial,
1077}
1078
1079struct Worker<'a> {
1086 engine: &'a Engine,
1087 clock: Arc<RunClock>,
1088 sandbox: Option<Sandbox>,
1089 failed: Option<RunError>,
1092}
1093
1094impl<'a> Worker<'a> {
1095 fn new(engine: &'a Engine, clock: &Arc<RunClock>) -> Self {
1096 Self {
1097 engine,
1098 clock: Arc::clone(clock),
1099 sandbox: None,
1100 failed: None,
1101 }
1102 }
1103
1104 fn sandbox(&mut self) -> Result<&Sandbox, RunError> {
1106 if let Some(error) = &self.failed {
1107 return Err(error.clone());
1108 }
1109
1110 if self.sandbox.is_none() {
1111 match self.engine.build_sandbox(&self.clock) {
1112 Ok(sandbox) => self.sandbox = Some(sandbox),
1113 Err(error) => {
1114 self.failed = Some(error.clone());
1115 return Err(error);
1116 }
1117 }
1118 }
1119
1120 self.sandbox.as_ref().ok_or_else(|| RunError::Worker {
1121 detail: "sandbox was not built".to_owned(),
1122 })
1123 }
1124}
1125
1126struct FileOutcome {
1128 path: FilePath,
1130 violations: Vec<Violation>,
1131 facts: Vec<Fact>,
1132 reads: Vec<TrackedRead>,
1134 suppressions: Vec<suppression::Suppression>,
1136 used_suppressions: Vec<u32>,
1138 read_the_date: bool,
1140 timings: Vec<(RuleId, RuleTiming)>,
1142 entry: Option<(CacheKey, CacheEntry)>,
1144 parsed: bool,
1146}
1147
1148impl FileOutcome {
1149 const fn skipped(path: FilePath) -> Self {
1151 Self {
1152 path,
1153 violations: Vec::new(),
1154 facts: Vec::new(),
1155 reads: Vec::new(),
1156 suppressions: Vec::new(),
1157 used_suppressions: Vec::new(),
1158 read_the_date: false,
1159 timings: Vec::new(),
1160 entry: None,
1161 parsed: false,
1162 }
1163 }
1164
1165 const fn parsed(path: FilePath) -> Self {
1166 Self {
1167 path,
1168 violations: Vec::new(),
1169 facts: Vec::new(),
1170 reads: Vec::new(),
1171 suppressions: Vec::new(),
1172 used_suppressions: Vec::new(),
1173 read_the_date: false,
1174 timings: Vec::new(),
1175 entry: None,
1176 parsed: true,
1177 }
1178 }
1179
1180 fn cached(path: FilePath, key: CacheKey, entry: CacheEntry) -> Self {
1185 Self {
1186 path,
1187 violations: entry.violations.clone(),
1188 facts: entry.facts.clone(),
1189 reads: entry.dependencies.clone(),
1190 suppressions: entry.suppressions.clone(),
1191 used_suppressions: entry.used_suppressions.clone(),
1192 read_the_date: false,
1195 timings: Vec::new(),
1196 entry: Some((key, entry)),
1197 parsed: true,
1198 }
1199 }
1200
1201 fn empty_entry(path: FilePath, key: Option<CacheKey>) -> Self {
1203 Self {
1204 path,
1205 violations: Vec::new(),
1206 facts: Vec::new(),
1207 reads: Vec::new(),
1208 suppressions: Vec::new(),
1209 used_suppressions: Vec::new(),
1210 read_the_date: false,
1211 timings: Vec::new(),
1212 entry: key.map(|key| (key, CacheEntry::default())),
1213 parsed: false,
1214 }
1215 }
1216}
1217
1218struct FileDirectives {
1220 suppressions: Vec<suppression::Suppression>,
1221 used: Vec<u32>,
1223}
1224
1225fn covering_elsewhere(
1230 directives: &BTreeMap<FilePath, FileDirectives>,
1231 violation: &Violation,
1232) -> Option<(FilePath, u32)> {
1233 let found = directives.get(&violation.location.file)?;
1234 let index = found.suppressions.iter().position(|suppression| {
1235 suppression.covers(&violation.rule_id, violation.location.position.line)
1236 })?;
1237
1238 Some((
1239 violation.location.file.clone(),
1240 u32::try_from(index).unwrap_or(u32::MAX),
1241 ))
1242}
1243
1244fn unused_violations(directives: &BTreeMap<FilePath, FileDirectives>) -> Vec<Violation> {
1253 let Ok(rule_id) = SUPPRESSION_RULE.parse::<RuleId>() else {
1254 return Vec::new();
1255 };
1256
1257 let mut violations = Vec::new();
1258 for (file, found) in directives {
1259 for (index, suppression) in found.suppressions.iter().enumerate() {
1260 let index = u32::try_from(index).unwrap_or(u32::MAX);
1261 if found.used.contains(&index) {
1262 continue;
1263 }
1264
1265 violations.push(Violation {
1266 rule_id: rule_id.clone(),
1267 location: Location::new(
1268 file.clone(),
1269 Position::new(suppression.line, suppression.column),
1270 ),
1271 message: format!("suppression silenced nothing — \"{}\"", suppression.reason),
1272 remediation: String::from(
1273 "remove it: whatever it was accepting is no longer reported",
1274 ),
1275 severity: Severity::Warn,
1276 fix: None,
1277 });
1278 }
1279 }
1280 violations
1281}
1282
1283fn engine_version() -> &'static str {
1285 const FULL: &str = env!("CARGO_PKG_VERSION");
1288 match FULL.match_indices('.').nth(1) {
1289 Some((at, _)) => FULL.split_at(at).0,
1290 None => FULL,
1291 }
1292}
1293
1294const SUPPRESSION_RULE: &str = "lanekeep/suppression";
1299
1300fn rule_index(spec: &RuleSpec) -> usize {
1302 spec.index
1303}
1304
1305fn json_key(name: &str) -> String {
1307 format!("{name:?}")
1308}
1309
1310#[must_use]
1312pub fn any_failing(violations: &[Violation]) -> bool {
1313 violations.iter().any(|v| v.severity == Severity::Error)
1314}
1315
1316#[must_use]
1318pub fn rules_root_for(project_root: &Path) -> PathBuf {
1319 project_root.to_path_buf()
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324 use std::fs;
1325
1326 use lanekeep_lang_js::{JavaScript, TypeScript};
1327
1328 use super::*;
1329
1330 struct Project {
1331 dir: PathBuf,
1332 }
1333
1334 impl Project {
1335 fn new(name: &str, files: &[(&str, &str)]) -> Self {
1336 let dir = std::env::temp_dir().join(format!("lanekeep-engine-{name}"));
1337 let _ = fs::remove_dir_all(&dir);
1338 fs::create_dir_all(&dir).expect("creates dir");
1339 let project = Self { dir };
1340 for (path, contents) in files {
1341 project.write(path, contents);
1342 }
1343 project
1344 }
1345
1346 fn write(&self, path: &str, contents: &str) {
1347 let full = self.dir.join(path);
1348 if let Some(parent) = full.parent() {
1349 fs::create_dir_all(parent).expect("creates parent");
1350 }
1351 fs::write(full, contents).expect("writes");
1352 }
1353
1354 fn run(&self) -> Result<Outcome, RunError> {
1355 let root = RuleRoot::new(&self.dir).expect("canonicalizes");
1356 let config_path = self.dir.join("lanekeep.config.ts");
1357
1358 let sandbox =
1359 lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
1360 .expect("sandbox");
1361 let config = lanekeep_config::load(&sandbox, &root, &config_path)
1362 .unwrap_or_else(|e| panic!("config failed to load: {e}"));
1363
1364 let engine = Engine::prepare(
1365 &config,
1366 &self.dir,
1367 root,
1368 &config_path,
1369 &lanekeep_lang_js::registry(),
1370 Arc::new(TypeScript),
1371 Arc::new(JavaScript),
1372 )?;
1373 engine.run()
1374 }
1375 }
1376
1377 impl Drop for Project {
1378 fn drop(&mut self) {
1379 let _ = fs::remove_dir_all(&self.dir);
1380 }
1381 }
1382
1383 const DEBUGGER_RULE: &str = "import { defineRule } from 'lanekeep';\n\
1385 export default defineRule({\n\
1386 id: 'local/no-debugger',\n\
1387 query: '(debugger_statement) @stmt',\n\
1388 card: {\n\
1389 message: 'debugger statement',\n\
1390 remediation: 'remove it before committing',\n\
1391 examples: { bad: 'debugger;', good: 'console.log(x);' },\n\
1392 },\n\
1393 check(ctx, m) { ctx.report(m.stmt); },\n\
1394 });\n";
1395
1396 fn member_rule_for(language: &str) -> String {
1398 let declaration = if language.is_empty() {
1399 String::new()
1400 } else {
1401 format!(" language: {language},\n")
1402 };
1403 format!(
1404 "import {{ defineRule }} from 'lanekeep';\n\
1405 export default defineRule({{\n\
1406 id: 'local/member',\n\
1407 {declaration}\
1408 query: '(member_expression) @m',\n\
1409 card: {{\n\
1410 message: 'member expression',\n\
1411 remediation: 'n/a',\n\
1412 examples: {{ bad: 'a.b', good: 'b' }},\n\
1413 }},\n\
1414 check(ctx, m) {{ ctx.report(m.m); }},\n\
1415 }});\n"
1416 )
1417 }
1418
1419 fn config_for(include: &str) -> String {
1420 format!(
1421 "import {{ defineConfig }} from 'lanekeep';\n\
1422 import rule from './rule';\n\
1423 export default defineConfig({{ include: ['{include}'], rules: [rule] }});\n"
1424 )
1425 }
1426
1427 fn config(extra: &str) -> String {
1428 format!(
1429 "import {{ defineConfig }} from 'lanekeep';\n\
1430 import rule from './rule';\n\
1431 export default defineConfig({{ include: ['src/**/*.ts'], rules: [rule]{extra} }});\n"
1432 )
1433 }
1434
1435 #[test]
1443 fn a_default_rule_sees_inside_jsx() {
1444 let project = Project::new(
1445 "jsx-default",
1446 &[
1447 ("rule.ts", &member_rule_for("")),
1448 ("lanekeep.config.ts", &config_for("src/**/*.tsx")),
1449 (
1450 "src/Component.tsx",
1451 "export const C = () => <View style={styles.used} />;\n",
1452 ),
1453 ],
1454 );
1455
1456 let outcome = project.run().expect("runs");
1457
1458 assert_eq!(
1459 outcome.violations.len(),
1460 1,
1461 "a member expression inside JSX was not seen: {:?}",
1462 outcome.violations
1463 );
1464 }
1465
1466 #[test]
1468 fn a_default_rule_still_sees_plain_typescript() {
1469 let project = Project::new(
1470 "ts-default",
1471 &[
1472 ("rule.ts", &member_rule_for("")),
1473 ("lanekeep.config.ts", &config_for("src/**/*.ts")),
1474 ("src/plain.ts", "const x = styles.used;\n"),
1475 ],
1476 );
1477
1478 let outcome = project.run().expect("runs");
1479
1480 assert_eq!(outcome.violations.len(), 1, "{:?}", outcome.violations);
1481 }
1482
1483 #[test]
1488 fn a_rule_does_not_run_on_a_language_it_does_not_name() {
1489 let project = Project::new(
1490 "single-language",
1491 &[
1492 ("rule.ts", &member_rule_for("'typescript'")),
1493 ("lanekeep.config.ts", &config_for("src/**/*.tsx")),
1494 (
1495 "src/Component.tsx",
1496 "export const C = () => <View style={styles.used} />;\n",
1497 ),
1498 ],
1499 );
1500
1501 let outcome = project.run().expect("runs");
1502
1503 assert!(
1504 outcome.violations.is_empty(),
1505 "a typescript-only rule ran on a tsx file: {:?}",
1506 outcome.violations
1507 );
1508 }
1509
1510 #[test]
1512 fn a_rule_may_name_several_languages() {
1513 let project = Project::new(
1514 "many-languages",
1515 &[
1516 ("rule.ts", &member_rule_for("['typescript', 'tsx']")),
1517 ("lanekeep.config.ts", &config_for("src/**/*.{ts,tsx}")),
1518 ("src/plain.ts", "const x = styles.used;\n"),
1519 (
1520 "src/Component.tsx",
1521 "export const C = () => <View style={styles.used} />;\n",
1522 ),
1523 ],
1524 );
1525
1526 let outcome = project.run().expect("runs");
1527
1528 assert_eq!(outcome.violations.len(), 2, "{:?}", outcome.violations);
1529 }
1530
1531 #[test]
1533 fn an_unknown_language_in_a_list_is_reported() {
1534 let project = Project::new(
1535 "unknown-in-list",
1536 &[
1537 ("rule.ts", &member_rule_for("['typescript', 'klingon']")),
1538 ("lanekeep.config.ts", &config_for("src/**/*.ts")),
1539 ("src/plain.ts", "const x = styles.used;\n"),
1540 ],
1541 );
1542
1543 let error = project
1544 .run()
1545 .expect_err("should refuse an unknown language");
1546 assert!(
1547 error.to_string().contains("klingon"),
1548 "the error should name it: {error}"
1549 );
1550 }
1551
1552 #[test]
1553 fn runs_a_rule_over_a_corpus_end_to_end() {
1554 let project = Project::new(
1555 "end-to-end",
1556 &[
1557 ("rule.ts", DEBUGGER_RULE),
1558 ("lanekeep.config.ts", &config("")),
1559 ("src/clean.ts", "const a = 1;\n"),
1560 ("src/dirty.ts", "const b = 2;\ndebugger;\n"),
1561 ("src/also.ts", "function f() {\n debugger;\n}\n"),
1562 ],
1563 );
1564
1565 let outcome = project.run().expect("runs");
1566
1567 assert_eq!(outcome.violations.len(), 2, "{:?}", outcome.violations);
1568 let rendered: Vec<String> = outcome
1569 .violations
1570 .iter()
1571 .map(|v| format!("{} {}", v.rule_id, v.location))
1572 .collect();
1573 assert_eq!(
1574 rendered,
1575 [
1576 "local/no-debugger src/also.ts:2:3",
1577 "local/no-debugger src/dirty.ts:2:1",
1578 ]
1579 );
1580 assert_eq!(outcome.violations[0].message, "debugger statement");
1581 assert_eq!(
1582 outcome.violations[0].remediation,
1583 "remove it before committing"
1584 );
1585 }
1586
1587 #[test]
1588 fn output_is_identical_across_repeated_runs() {
1589 let mut files = vec![
1593 ("rule.ts".to_owned(), DEBUGGER_RULE.to_owned()),
1594 ("lanekeep.config.ts".to_owned(), config("")),
1595 ];
1596 for i in 0..40 {
1597 files.push((
1598 format!("src/f{i}.ts"),
1599 format!("const x{i} = 1;\ndebugger;\n"),
1600 ));
1601 }
1602 let borrowed: Vec<(&str, &str)> = files
1603 .iter()
1604 .map(|(a, b)| (a.as_str(), b.as_str()))
1605 .collect();
1606 let project = Project::new("determinism", &borrowed);
1607
1608 let first = project.run().expect("runs").violations;
1609 assert_eq!(first.len(), 40);
1610
1611 for _ in 0..4 {
1612 assert_eq!(project.run().expect("runs").violations, first);
1613 }
1614 }
1615
1616 #[test]
1617 fn exclude_keeps_files_out_of_the_run() {
1618 let project = Project::new(
1619 "exclude",
1620 &[
1621 ("rule.ts", DEBUGGER_RULE),
1622 ("lanekeep.config.ts", &config(", exclude: ['**/*.test.ts']")),
1623 ("src/a.ts", "debugger;\n"),
1624 ("src/a.test.ts", "debugger;\n"),
1625 ],
1626 );
1627
1628 let outcome = project.run().expect("runs");
1629 assert_eq!(outcome.violations.len(), 1);
1630 assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
1631 }
1632
1633 #[test]
1634 fn a_content_gate_skips_the_parse() {
1635 let gated = "import { defineRule } from 'lanekeep';\n\
1638 export default defineRule({\n\
1639 id: 'local/no-debugger',\n\
1640 query: '(debugger_statement) @stmt',\n\
1641 gates: { fileContains: ['debugger'] },\n\
1642 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
1643 check(ctx, m) { ctx.report(m.stmt); },\n\
1644 });\n";
1645
1646 let project = Project::new(
1647 "gate",
1648 &[
1649 ("rule.ts", gated),
1650 ("lanekeep.config.ts", &config("")),
1651 ("src/a.ts", "debugger;\n"),
1652 ("src/b.ts", "const b = 1;\n"),
1653 ("src/c.ts", "const c = 2;\n"),
1654 ],
1655 );
1656
1657 let outcome = project.run().expect("runs");
1658 assert_eq!(outcome.files_discovered, 3);
1659 assert_eq!(
1660 outcome.files_parsed, 1,
1661 "only the file containing the needle should parse"
1662 );
1663 assert_eq!(outcome.violations.len(), 1);
1664 }
1665
1666 #[test]
1667 fn a_rule_set_to_off_does_not_run() {
1668 let project = Project::new(
1669 "off",
1670 &[
1671 ("rule.ts", DEBUGGER_RULE),
1672 (
1673 "lanekeep.config.ts",
1674 &config(", severity: { 'local/no-debugger': 'off' }"),
1675 ),
1676 ("src/a.ts", "debugger;\n"),
1677 ],
1678 );
1679 assert!(project.run().expect("runs").violations.is_empty());
1680 }
1681
1682 #[test]
1683 fn severity_reaches_the_violation() {
1684 let project = Project::new(
1685 "severity",
1686 &[
1687 ("rule.ts", DEBUGGER_RULE),
1688 (
1689 "lanekeep.config.ts",
1690 &config(", severity: { 'local/no-debugger': 'warn' }"),
1691 ),
1692 ("src/a.ts", "debugger;\n"),
1693 ],
1694 );
1695 let outcome = project.run().expect("runs");
1696 assert_eq!(outcome.violations[0].severity, Severity::Warn);
1697 assert!(!any_failing(&outcome.violations));
1698 }
1699
1700 #[test]
1701 fn a_rule_that_throws_aborts_the_run_naming_itself_and_the_file() {
1702 let throwing = "import { defineRule } from 'lanekeep';\n\
1705 export default defineRule({\n\
1706 id: 'local/throws',\n\
1707 query: '(debugger_statement) @stmt',\n\
1708 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
1709 check() { throw new Error('rule bug'); },\n\
1710 });\n";
1711
1712 let project = Project::new(
1713 "throws",
1714 &[
1715 ("rule.ts", throwing),
1716 ("lanekeep.config.ts", &config("")),
1717 ("src/a.ts", "debugger;\n"),
1718 ],
1719 );
1720
1721 let err = project.run().expect_err("must abort");
1722 let rendered = err.to_string();
1723 assert!(rendered.contains("local/throws"), "{rendered}");
1724 assert!(rendered.contains("src/a.ts"), "{rendered}");
1725 assert!(rendered.contains("rule bug"), "{rendered}");
1726 }
1727
1728 #[test]
1729 fn an_invalid_query_fails_before_any_file_is_read() {
1730 let bad = "import { defineRule } from 'lanekeep';\n\
1731 export default defineRule({\n\
1732 id: 'local/bad-query',\n\
1733 query: '(no_such_node) @x',\n\
1734 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
1735 check() {},\n\
1736 });\n";
1737
1738 let project = Project::new(
1739 "bad-query",
1740 &[
1741 ("rule.ts", bad),
1742 ("lanekeep.config.ts", &config("")),
1743 ("src/a.ts", "debugger;\n"),
1744 ],
1745 );
1746
1747 let err = project.run().expect_err("must fail at preparation");
1748 assert!(matches!(err, RunError::Query { .. }), "{err:?}");
1749 assert!(err.to_string().contains("no_such_node"), "{err}");
1750 }
1751
1752 #[test]
1753 fn a_rule_can_use_the_host_api_it_was_given() {
1754 let rule = "import { defineRule } from 'lanekeep';\n\
1757 export default defineRule({\n\
1758 id: 'local/long-names',\n\
1759 query: '(variable_declarator name: (identifier) @name)',\n\
1760 card: { message: 'name too long', remediation: 'shorten it', examples: { bad: 'a', good: 'b' } },\n\
1761 check(ctx, m) {\n\
1762 if (ctx.text(m.name).length > 5) ctx.report(m.name, `\\\"${ctx.text(m.name)}\\\" is too long`);\n\
1763 },\n\
1764 });\n";
1765
1766 let project = Project::new(
1767 "host-api",
1768 &[
1769 ("rule.ts", rule),
1770 ("lanekeep.config.ts", &config("")),
1771 ("src/a.ts", "const ok = 1;\nconst wayTooLong = 2;\n"),
1772 ],
1773 );
1774
1775 let outcome = project.run().expect("runs");
1776 assert_eq!(outcome.violations.len(), 1);
1777 assert!(
1778 outcome.violations[0].message.contains("wayTooLong"),
1779 "{:?}",
1780 outcome.violations[0]
1781 );
1782 }
1783
1784 #[test]
1785 fn a_corpus_with_no_matches_produces_nothing() {
1786 let project = Project::new(
1787 "clean",
1788 &[
1789 ("rule.ts", DEBUGGER_RULE),
1790 ("lanekeep.config.ts", &config("")),
1791 ("src/a.ts", "const a = 1;\n"),
1792 ],
1793 );
1794 let outcome = project.run().expect("runs");
1795 assert!(outcome.violations.is_empty());
1796 assert_eq!(outcome.files_parsed, 1, "no gates means it is still parsed");
1797 }
1798
1799 const UNUSED_EXPORTS_RULE: &str = r"import { defineRule } from 'lanekeep';
1806export default defineRule({
1807 id: 'local/no-unused-exports',
1808 query: `
1809 (export_statement declaration: (function_declaration name: (identifier) @name)) @stmt
1810 (import_statement (import_clause (named_imports (import_specifier name: (identifier) @imported))))
1811 `,
1812 card: {
1813 message: 'unused export',
1814 remediation: 'delete it, or import it somewhere',
1815 examples: { bad: 'export function unused() {}', good: 'function used() {}' },
1816 },
1817 check(ctx, m) {
1818 if (m.imported) {
1819 ctx.emitFact({ kind: 'import', symbol: ctx.text(m.imported) });
1820 return;
1821 }
1822 ctx.emitFact({
1823 kind: 'export',
1824 symbol: ctx.text(m.name),
1825 line: ctx.line(m.stmt),
1826 column: ctx.column(m.stmt),
1827 });
1828 },
1829 reduce(ctx) {
1830 const imported = new Set(ctx.facts('import').map((f) => f.symbol));
1831 for (const e of ctx.facts('export')) {
1832 if (!imported.has(e.symbol)) {
1833 ctx.report({ file: e.file, line: e.line, column: e.column }, `'${e.symbol}' is exported but never imported`);
1834 }
1835 }
1836 },
1837});
1838";
1839
1840 #[test]
1841 fn a_reduce_phase_sees_facts_from_every_file() {
1842 let project = Project::new(
1843 "reduce-cross-file",
1844 &[
1845 ("rule.ts", UNUSED_EXPORTS_RULE),
1846 ("lanekeep.config.ts", &config("")),
1847 (
1848 "src/a.ts",
1849 "export function used() {}\nexport function spare() {}\n",
1850 ),
1851 ("src/b.ts", "import { used } from './a';\nused();\n"),
1852 ],
1853 );
1854
1855 let outcome = project.run().expect("runs");
1856 let found: Vec<(&str, u32, &str)> = outcome
1857 .violations
1858 .iter()
1859 .map(|v| {
1860 (
1861 v.location.file.as_str(),
1862 v.location.position.line,
1863 v.message.as_str(),
1864 )
1865 })
1866 .collect();
1867
1868 assert_eq!(
1869 found,
1870 vec![("src/a.ts", 2, "'spare' is exported but never imported")],
1871 "only the export nobody imports should be reported"
1872 );
1873 }
1874
1875 #[test]
1876 fn a_rule_with_no_reduce_still_runs() {
1877 let project = Project::new(
1879 "reduce-absent",
1880 &[
1881 ("rule.ts", DEBUGGER_RULE),
1882 ("lanekeep.config.ts", &config("")),
1883 ("src/a.ts", "debugger;\n"),
1884 ],
1885 );
1886 let outcome = project.run().expect("runs");
1887 assert_eq!(outcome.violations.len(), 1);
1888 }
1889
1890 #[test]
1891 fn a_reduce_phase_with_no_facts_reports_nothing() {
1892 let project = Project::new(
1893 "reduce-empty",
1894 &[
1895 ("rule.ts", UNUSED_EXPORTS_RULE),
1896 ("lanekeep.config.ts", &config("")),
1897 ("src/a.ts", "const a = 1;\n"),
1898 ],
1899 );
1900 assert!(project.run().expect("runs").violations.is_empty());
1901 }
1902
1903 #[test]
1904 fn the_file_list_reaches_the_reduce_phase() {
1905 const RULE: &str = r"import { defineRule } from 'lanekeep';
1906export default defineRule({
1907 id: 'local/counts-files',
1908 query: '(debugger_statement) @stmt',
1909 card: {
1910 message: 'file count',
1911 remediation: 'nothing to do',
1912 examples: { bad: 'a', good: 'b' },
1913 },
1914 check() {},
1915 reduce(ctx) {
1916 ctx.report({ file: ctx.files[0], line: ctx.files.length, column: 1 });
1917 },
1918});
1919";
1920 let project = Project::new(
1921 "reduce-files",
1922 &[
1923 ("rule.ts", RULE),
1924 ("lanekeep.config.ts", &config("")),
1925 ("src/a.ts", "const a = 1;\n"),
1926 ("src/b.ts", "const b = 1;\n"),
1927 ],
1928 );
1929
1930 let outcome = project.run().expect("runs");
1931 assert_eq!(outcome.violations.len(), 1);
1932 assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
1934 assert_eq!(outcome.violations[0].location.position.line, 2);
1935 }
1936
1937 #[test]
1938 fn a_rule_does_not_see_another_rules_facts() {
1939 const EMITTER: &str = r"import { defineRule } from 'lanekeep';
1942export default defineRule({
1943 id: 'local/emitter',
1944 query: '(export_statement) @stmt',
1945 card: { message: 'emitter', remediation: 'x', examples: { bad: 'a', good: 'b' } },
1946 check(ctx, m) { ctx.emitFact({ kind: 'thing', from: 'emitter' }); },
1947});
1948";
1949 const READER: &str = r"import { defineRule } from 'lanekeep';
1950export default defineRule({
1951 id: 'local/reader',
1952 query: '(export_statement) @stmt',
1953 card: { message: 'reader', remediation: 'x', examples: { bad: 'a', good: 'b' } },
1954 check() {},
1955 reduce(ctx) {
1956 ctx.report({ file: 'seen.ts', line: ctx.facts().length + 1, column: 1 });
1957 },
1958});
1959";
1960 let project = Project::new(
1961 "reduce-isolation",
1962 &[
1963 ("emitter.ts", EMITTER),
1964 ("reader.ts", READER),
1965 (
1966 "lanekeep.config.ts",
1967 "import { defineConfig } from 'lanekeep';\n\
1968 import emitter from './emitter';\n\
1969 import reader from './reader';\n\
1970 export default defineConfig({ include: ['src/**/*.ts'], rules: [emitter, reader] });\n",
1971 ),
1972 ("src/a.ts", "export const a = 1;\n"),
1973 ],
1974 );
1975
1976 let outcome = project.run().expect("runs");
1977 assert_eq!(outcome.violations.len(), 1);
1978 assert_eq!(
1979 outcome.violations[0].location.position.line, 1,
1980 "the reader saw the emitter's facts"
1981 );
1982 }
1983
1984 #[test]
1985 fn a_reduce_phase_that_throws_aborts_the_run() {
1986 const RULE: &str = r"import { defineRule } from 'lanekeep';
1989export default defineRule({
1990 id: 'local/throws-in-reduce',
1991 query: '(debugger_statement) @stmt',
1992 card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
1993 check() {},
1994 reduce() { throw new Error('reduce exploded'); },
1995});
1996";
1997 let project = Project::new(
1998 "reduce-throws",
1999 &[
2000 ("rule.ts", RULE),
2001 ("lanekeep.config.ts", &config("")),
2002 ("src/a.ts", "const a = 1;\n"),
2003 ],
2004 );
2005
2006 let error = project.run().expect_err("aborts");
2007 let rendered = error.to_string();
2008 assert!(rendered.contains("reduce exploded"), "{rendered}");
2009 assert!(
2010 rendered.contains("local/throws-in-reduce"),
2011 "the error should name the rule: {rendered}"
2012 );
2013 }
2014
2015 #[test]
2016 fn facts_reach_reduce_in_the_same_order_on_every_run() {
2017 const RULE: &str = r"import { defineRule } from 'lanekeep';
2026export default defineRule({
2027 id: 'local/first-fact-wins',
2028 query: '(export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name)))',
2029 card: { message: 'first', remediation: 'x', examples: { bad: 'a', good: 'b' } },
2030 check(ctx, m) { ctx.emitFact({ kind: 'sym', symbol: ctx.text(m.name) }); },
2031 reduce(ctx) {
2032 const all = ctx.facts('sym');
2033 ctx.report({ file: 'order.ts', line: 1, column: 1 }, all.map((f) => `${f.file}:${f.symbol}`).join(','));
2034 },
2035});
2036";
2037 let files: Vec<(String, String)> = (0..12)
2038 .map(|i| {
2039 (
2040 format!("src/f{i:02}.ts"),
2041 format!("export const s{i:02} = {i};\n"),
2042 )
2043 })
2044 .collect();
2045
2046 let mut layout: Vec<(&str, &str)> = vec![("rule.ts", RULE)];
2047 let config_source = config("");
2048 layout.push(("lanekeep.config.ts", &config_source));
2049 for (path, contents) in &files {
2050 layout.push((path, contents));
2051 }
2052
2053 let project = Project::new("reduce-determinism", &layout);
2054
2055 let first = project.run().expect("runs").violations[0].message.clone();
2056 for attempt in 0..4 {
2057 let again = project.run().expect("runs").violations[0].message.clone();
2058 assert_eq!(again, first, "fact order changed on attempt {attempt}");
2059 }
2060
2061 assert!(
2063 first.starts_with("src/f00.ts:s00,src/f01.ts:s01,"),
2064 "facts are not in (file, sequence) order: {first}"
2065 );
2066 }
2067
2068 #[test]
2069 fn a_rule_cannot_misattribute_a_fact_to_another_file() {
2070 const RULE: &str = r"import { defineRule } from 'lanekeep';
2072export default defineRule({
2073 id: 'local/lying-fact',
2074 query: '(export_statement) @stmt',
2075 card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
2076 check(ctx, m) { ctx.emitFact({ kind: 'e', file: 'somewhere-else.ts' }); },
2077 reduce(ctx) {
2078 for (const f of ctx.facts('e')) ctx.report({ file: f.file, line: 1, column: 1 });
2079 },
2080});
2081";
2082 let project = Project::new(
2083 "reduce-misattribution",
2084 &[
2085 ("rule.ts", RULE),
2086 ("lanekeep.config.ts", &config("")),
2087 ("src/a.ts", "export const a = 1;\n"),
2088 ],
2089 );
2090
2091 let outcome = project.run().expect("runs");
2092 assert_eq!(outcome.violations.len(), 1);
2093 assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
2094 }
2095
2096 const READING_RULE: &str = r"import { defineRule } from 'lanekeep';
2100export default defineRule({
2101 id: 'local/reads-config',
2102 query: '(export_statement) @stmt',
2103 card: {
2104 message: 'config says no',
2105 remediation: 'change the config, or the code',
2106 examples: { bad: 'export const a = 1;', good: 'const a = 1;' },
2107 },
2108 check(ctx, m) {
2109 const raw = ctx.readFile('policy.json');
2110 if (raw && JSON.parse(raw).forbidExports) ctx.report(m.stmt);
2111 },
2112});
2113";
2114
2115 #[test]
2116 fn a_rule_can_read_another_file() {
2117 let project = Project::new(
2118 "reads-allowed",
2119 &[
2120 ("rule.ts", READING_RULE),
2121 ("lanekeep.config.ts", &config("")),
2122 ("policy.json", r#"{"forbidExports":true}"#),
2123 ("src/a.ts", "export const a = 1;\n"),
2124 ],
2125 );
2126 let outcome = project.run().expect("runs");
2127 assert_eq!(outcome.violations.len(), 1, "{:?}", outcome.violations);
2128 }
2129
2130 #[test]
2131 fn what_the_file_says_changes_the_result() {
2132 let project = Project::new(
2134 "reads-content",
2135 &[
2136 ("rule.ts", READING_RULE),
2137 ("lanekeep.config.ts", &config("")),
2138 ("policy.json", r#"{"forbidExports":false}"#),
2139 ("src/a.ts", "export const a = 1;\n"),
2140 ],
2141 );
2142 assert!(project.run().expect("runs").violations.is_empty());
2143 }
2144
2145 #[test]
2146 fn a_read_is_recorded_against_the_file_that_made_it() {
2147 let mut layout: Vec<(String, String)> = vec![
2155 ("rule.ts".to_owned(), READING_RULE.to_owned()),
2156 ("lanekeep.config.ts".to_owned(), config("")),
2157 (
2158 "policy.json".to_owned(),
2159 r#"{"forbidExports":false}"#.to_owned(),
2160 ),
2161 ];
2162 for i in 0..24 {
2164 let body = if i % 2 == 0 {
2165 format!("const v{i} = {i};\n")
2166 } else {
2167 format!("export const v{i} = {i};\n")
2168 };
2169 layout.push((format!("src/f{i:02}.ts"), body));
2170 }
2171 let borrowed: Vec<(&str, &str)> = layout
2172 .iter()
2173 .map(|(p, c)| (p.as_str(), c.as_str()))
2174 .collect();
2175
2176 let project = Project::new("reads-attributed", &borrowed);
2177 let outcome = project.run().expect("runs");
2178
2179 for i in 0..24 {
2180 let file = FilePath::new(format!("src/f{i:02}.ts"));
2181 let deps = outcome.dependencies.get(&file);
2182 if i % 2 == 0 {
2183 assert!(
2184 deps.is_none(),
2185 "src/f{i:02}.ts read nothing but has {deps:?}"
2186 );
2187 } else {
2188 let deps = deps.unwrap_or_else(|| panic!("src/f{i:02}.ts should have read"));
2189 assert_eq!(deps.len(), 1);
2190 assert_eq!(deps[0].path.as_str(), "policy.json");
2191 assert!(deps[0].hash.is_some());
2192 }
2193 }
2194 }
2195
2196 #[test]
2197 fn a_missing_file_is_recorded_as_a_dependency_too() {
2198 const RULE: &str = r"import { defineRule } from 'lanekeep';
2201export default defineRule({
2202 id: 'local/wants-config',
2203 query: '(export_statement) @stmt',
2204 card: { message: 'no config', remediation: 'add one', examples: { bad: 'a', good: 'b' } },
2205 check(ctx, m) {
2206 if (!ctx.fileExists('tsconfig.json')) ctx.report(m.stmt);
2207 },
2208});
2209";
2210 let project = Project::new(
2211 "reads-absent",
2212 &[
2213 ("rule.ts", RULE),
2214 ("lanekeep.config.ts", &config("")),
2215 ("src/a.ts", "export const a = 1;\n"),
2216 ],
2217 );
2218
2219 let outcome = project.run().expect("runs");
2220 assert_eq!(outcome.violations.len(), 1);
2221
2222 let deps = outcome
2223 .dependencies
2224 .get(&FilePath::new("src/a.ts"))
2225 .expect("the miss is a dependency");
2226 assert_eq!(deps.len(), 1);
2227 assert_eq!(deps[0].path.as_str(), "tsconfig.json");
2228 assert_eq!(deps[0].hash, None, "absence is recorded as absence");
2229 }
2230
2231 #[test]
2232 fn reading_outside_the_project_aborts_the_run() {
2233 const RULE: &str = r"import { defineRule } from 'lanekeep';
2237export default defineRule({
2238 id: 'local/escapes',
2239 query: '(export_statement) @stmt',
2240 card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
2241 check(ctx) { ctx.readFile('../../../etc/passwd'); },
2242});
2243";
2244 let project = Project::new(
2245 "reads-escape",
2246 &[
2247 ("rule.ts", RULE),
2248 ("lanekeep.config.ts", &config("")),
2249 ("src/a.ts", "export const a = 1;\n"),
2250 ],
2251 );
2252
2253 let error = project.run().expect_err("aborts");
2254 let rendered = error.to_string();
2255 assert!(rendered.contains("outside the project root"), "{rendered}");
2256 assert!(rendered.contains("local/escapes"), "{rendered}");
2257 }
2258
2259 #[test]
2260 fn reading_the_same_file_from_two_files_records_it_under_both() {
2261 let project = Project::new(
2262 "reads-shared",
2263 &[
2264 ("rule.ts", READING_RULE),
2265 ("lanekeep.config.ts", &config("")),
2266 ("policy.json", r#"{"forbidExports":false}"#),
2267 ("src/a.ts", "export const a = 1;\n"),
2268 ("src/b.ts", "export const b = 1;\n"),
2269 ],
2270 );
2271
2272 let outcome = project.run().expect("runs");
2273 for file in ["src/a.ts", "src/b.ts"] {
2274 let deps = outcome
2275 .dependencies
2276 .get(&FilePath::new(file))
2277 .unwrap_or_else(|| panic!("{file} should depend on the policy"));
2278 assert_eq!(deps[0].path.as_str(), "policy.json");
2279 }
2280
2281 let a = &outcome.dependencies[&FilePath::new("src/a.ts")][0];
2284 let b = &outcome.dependencies[&FilePath::new("src/b.ts")][0];
2285 assert_eq!(a.hash, b.hash);
2286 }
2287
2288 #[test]
2289 fn dependencies_are_the_same_on_every_run() {
2290 let project = Project::new(
2291 "reads-deterministic",
2292 &[
2293 ("rule.ts", READING_RULE),
2294 ("lanekeep.config.ts", &config("")),
2295 ("policy.json", r#"{"forbidExports":false}"#),
2296 ("src/a.ts", "export const a = 1;\n"),
2297 ("src/b.ts", "export const b = 1;\n"),
2298 ("src/c.ts", "export const c = 1;\n"),
2299 ],
2300 );
2301 let first = project.run().expect("runs").dependencies;
2302 assert!(!first.is_empty());
2303 for attempt in 0..4 {
2304 assert_eq!(
2305 project.run().expect("runs").dependencies,
2306 first,
2307 "dependencies changed on attempt {attempt}"
2308 );
2309 }
2310 }
2311
2312 #[test]
2313 fn the_read_surface_is_absent_from_the_reduce_phase() {
2314 const RULE: &str = r"import { defineRule } from 'lanekeep';
2318export default defineRule({
2319 id: 'local/reduce-reads',
2320 query: '(export_statement) @stmt',
2321 card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
2322 check() {},
2323 reduce(ctx) {
2324 const absent = ctx.readFile === undefined && ctx.fileExists === undefined;
2325 ctx.report({ file: 'probe.ts', line: absent ? 1 : 2, column: 1 });
2326 },
2327});
2328";
2329 let project = Project::new(
2330 "reads-reduce",
2331 &[
2332 ("rule.ts", RULE),
2333 ("lanekeep.config.ts", &config("")),
2334 ("src/a.ts", "export const a = 1;\n"),
2335 ],
2336 );
2337
2338 let outcome = project.run().expect("runs");
2339 assert_eq!(outcome.violations.len(), 1);
2340 assert_eq!(
2341 outcome.violations[0].location.position.line, 1,
2342 "reads must not be reachable from a reduce phase"
2343 );
2344 }
2345
2346 impl Project {
2349 fn run_cold(&self) -> Result<Outcome, RunError> {
2351 self.build().map(Engine::without_cache)?.run()
2352 }
2353
2354 fn build(&self) -> Result<Engine, RunError> {
2356 let root = RuleRoot::new(&self.dir).expect("canonicalizes");
2357 let config_path = self.dir.join("lanekeep.config.ts");
2358 let sandbox =
2359 lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
2360 .expect("sandbox");
2361 let config = lanekeep_config::load(&sandbox, &root, &config_path)
2362 .unwrap_or_else(|e| panic!("config failed to load: {e}"));
2363 Engine::prepare(
2364 &config,
2365 &self.dir,
2366 root,
2367 &config_path,
2368 &lanekeep_lang_js::registry(),
2369 Arc::new(TypeScript),
2370 Arc::new(JavaScript),
2371 )
2372 }
2373
2374 fn cache(&self) -> Store {
2375 Store::load(&self.dir)
2376 }
2377 }
2378
2379 fn rendered(outcome: &Outcome) -> Vec<String> {
2380 outcome
2381 .violations
2382 .iter()
2383 .map(|v| {
2384 format!(
2385 "{}:{}:{} {} {}",
2386 v.location.file.as_str(),
2387 v.location.position.line,
2388 v.location.position.column,
2389 v.rule_id,
2390 v.message
2391 )
2392 })
2393 .collect()
2394 }
2395
2396 #[test]
2397 fn a_warm_run_agrees_with_a_cold_one() {
2398 let project = Project::new(
2399 "cache-agrees",
2400 &[
2401 ("rule.ts", DEBUGGER_RULE),
2402 ("lanekeep.config.ts", &config("")),
2403 ("src/a.ts", "debugger;\nconst a = 1;\n"),
2404 ("src/b.ts", "const b = 1;\ndebugger;\n"),
2405 ("src/c.ts", "const c = 1;\n"),
2406 ],
2407 );
2408
2409 let cold = rendered(&project.run().expect("runs"));
2410 let warm = rendered(&project.run().expect("runs"));
2411 assert_eq!(warm, cold, "the cache changed the answer");
2412 assert!(!cold.is_empty(), "the fixture should report something");
2413 }
2414
2415 #[test]
2416 fn a_run_writes_a_cache() {
2417 let project = Project::new(
2418 "cache-written",
2419 &[
2420 ("rule.ts", DEBUGGER_RULE),
2421 ("lanekeep.config.ts", &config("")),
2422 ("src/a.ts", "debugger;\n"),
2423 ],
2424 );
2425 assert!(project.cache().is_empty(), "nothing before the first run");
2426 project.run().expect("runs");
2427 assert!(!project.cache().is_empty(), "the run stored nothing");
2428 }
2429
2430 #[test]
2431 fn a_cached_result_is_actually_used() {
2432 let project = Project::new(
2436 "cache-used",
2437 &[
2438 ("rule.ts", DEBUGGER_RULE),
2439 ("lanekeep.config.ts", &config("")),
2440 ("src/a.ts", "const a = 1;\n"),
2441 ],
2442 );
2443 assert!(project.run().expect("runs").violations.is_empty());
2444
2445 let store = project.cache();
2446 let key = *store
2447 .keys()
2448 .next()
2449 .expect("the run stored an entry for the file");
2450
2451 let mut doctored = Store::empty();
2452 doctored.insert(
2453 key,
2454 lanekeep_cache::Entry {
2455 violations: vec![Violation {
2456 rule_id: "local/no-debugger".parse().expect("valid id"),
2457 location: Location::new(FilePath::new("src/a.ts"), Position::new(7, 3)),
2458 message: "from the cache".to_owned(),
2459 remediation: "nothing".to_owned(),
2460 severity: Severity::Error,
2461 fix: None,
2462 }],
2463 facts: Vec::new(),
2464 dependencies: Vec::new(),
2465 suppressions: Vec::new(),
2466 used_suppressions: Vec::new(),
2467 },
2468 );
2469 doctored.save(&project.dir);
2470
2471 let outcome = project.run().expect("runs");
2472 assert_eq!(
2473 rendered(&outcome),
2474 vec!["src/a.ts:7:3 local/no-debugger from the cache"],
2475 "the cached entry was not used"
2476 );
2477 }
2478
2479 #[test]
2480 fn editing_a_file_invalidates_it() {
2481 let project = Project::new(
2482 "cache-edited",
2483 &[
2484 ("rule.ts", DEBUGGER_RULE),
2485 ("lanekeep.config.ts", &config("")),
2486 ("src/a.ts", "const a = 1;\n"),
2487 ],
2488 );
2489 assert!(project.run().expect("runs").violations.is_empty());
2490
2491 project.write("src/a.ts", "debugger;\n");
2492 assert_eq!(
2493 project.run().expect("runs").violations.len(),
2494 1,
2495 "an edited file kept its stale result"
2496 );
2497 }
2498
2499 #[test]
2500 fn moving_a_file_invalidates_it() {
2501 let project = Project::new(
2504 "cache-moved",
2505 &[
2506 ("rule.ts", DEBUGGER_RULE),
2507 ("lanekeep.config.ts", &config("")),
2508 ("src/a.ts", "debugger;\n"),
2509 ],
2510 );
2511 project.run().expect("runs");
2512
2513 fs::remove_file(project.dir.join("src/a.ts")).expect("removes");
2514 project.write("src/moved.ts", "debugger;\n");
2515
2516 let outcome = project.run().expect("runs");
2517 assert_eq!(
2518 outcome.violations[0].location.file.as_str(),
2519 "src/moved.ts",
2520 "the violation followed the old path"
2521 );
2522 }
2523
2524 #[test]
2525 fn editing_a_tracked_dependency_invalidates_the_files_that_read_it() {
2526 let project = Project::new(
2529 "cache-dependency",
2530 &[
2531 ("rule.ts", READING_RULE),
2532 ("lanekeep.config.ts", &config("")),
2533 ("policy.json", r#"{"forbidExports":false}"#),
2534 ("src/a.ts", "export const a = 1;\n"),
2535 ],
2536 );
2537 assert!(project.run().expect("runs").violations.is_empty());
2538
2539 project.write("policy.json", r#"{"forbidExports":true}"#);
2540 assert_eq!(
2541 project.run().expect("runs").violations.len(),
2542 1,
2543 "a changed dependency did not invalidate"
2544 );
2545 }
2546
2547 #[test]
2548 fn a_dependency_that_appears_invalidates() {
2549 const RULE: &str = r"import { defineRule } from 'lanekeep';
2552export default defineRule({
2553 id: 'local/wants-config',
2554 query: '(export_statement) @stmt',
2555 card: { message: 'no config', remediation: 'add one', examples: { bad: 'a', good: 'b' } },
2556 check(ctx, m) {
2557 if (!ctx.fileExists('tsconfig.json')) ctx.report(m.stmt);
2558 },
2559});
2560";
2561 let project = Project::new(
2562 "cache-appeared",
2563 &[
2564 ("rule.ts", RULE),
2565 ("lanekeep.config.ts", &config("")),
2566 ("src/a.ts", "export const a = 1;\n"),
2567 ],
2568 );
2569 assert_eq!(project.run().expect("runs").violations.len(), 1);
2570
2571 project.write("tsconfig.json", "{}");
2572 assert!(
2573 project.run().expect("runs").violations.is_empty(),
2574 "a dependency that appeared did not invalidate"
2575 );
2576 }
2577
2578 #[test]
2579 fn changing_the_ruleset_invalidates_everything() {
2580 let project = Project::new(
2581 "cache-ruleset",
2582 &[
2583 ("rule.ts", DEBUGGER_RULE),
2584 ("lanekeep.config.ts", &config("")),
2585 ("src/a.ts", "debugger;\n"),
2586 ],
2587 );
2588 assert_eq!(project.run().expect("runs").violations.len(), 1);
2589
2590 project.write(
2592 "rule.ts",
2593 &DEBUGGER_RULE.replace("ctx.report(m.stmt);", "/* nothing */"),
2594 );
2595 assert!(
2596 project.run().expect("runs").violations.is_empty(),
2597 "an edited rule kept its stale results"
2598 );
2599 }
2600
2601 #[test]
2602 fn changing_the_config_invalidates_everything() {
2603 let project = Project::new(
2604 "cache-config",
2605 &[
2606 ("rule.ts", DEBUGGER_RULE),
2607 ("lanekeep.config.ts", &config("")),
2608 ("src/a.ts", "debugger;\n"),
2609 ],
2610 );
2611 assert_eq!(project.run().expect("runs").violations.len(), 1);
2612
2613 project.write(
2614 "lanekeep.config.ts",
2615 &config(", severity: { 'local/no-debugger': 'off' }"),
2616 );
2617 assert!(
2618 project.run().expect("runs").violations.is_empty(),
2619 "a config change did not invalidate"
2620 );
2621 }
2622
2623 #[test]
2624 fn a_corrupt_cache_still_produces_the_right_answer() {
2625 let project = Project::new(
2627 "cache-corrupt",
2628 &[
2629 ("rule.ts", DEBUGGER_RULE),
2630 ("lanekeep.config.ts", &config("")),
2631 ("src/a.ts", "debugger;\n"),
2632 ],
2633 );
2634 let expected = rendered(&project.run().expect("runs"));
2635
2636 let path = Store::path_for(&project.dir);
2637 fs::write(&path, b"\x00\x01\x02 not a cache").expect("writes");
2638
2639 assert_eq!(rendered(&project.run().expect("runs")), expected);
2640 }
2641
2642 #[test]
2643 fn caching_can_be_turned_off() {
2644 let project = Project::new(
2645 "cache-off",
2646 &[
2647 ("rule.ts", DEBUGGER_RULE),
2648 ("lanekeep.config.ts", &config("")),
2649 ("src/a.ts", "debugger;\n"),
2650 ],
2651 );
2652 let outcome = project.run_cold().expect("runs");
2653 assert_eq!(outcome.violations.len(), 1);
2654 assert!(
2655 project.cache().is_empty(),
2656 "a run with caching off wrote a cache"
2657 );
2658 }
2659
2660 #[test]
2661 fn facts_survive_a_warm_run() {
2662 let project = Project::new(
2667 "cache-facts",
2668 &[
2669 ("rule.ts", UNUSED_EXPORTS_RULE),
2670 ("lanekeep.config.ts", &config("")),
2671 (
2672 "src/a.ts",
2673 "export function used() {}\nexport function spare() {}\n",
2674 ),
2675 ("src/b.ts", "import { used } from './a';\nused();\n"),
2676 ],
2677 );
2678
2679 let cold = rendered(&project.run().expect("runs"));
2680 assert_eq!(cold.len(), 1, "{cold:?}");
2681 assert_eq!(rendered(&project.run().expect("runs")), cold);
2682 assert_eq!(rendered(&project.run().expect("runs")), cold);
2683 }
2684
2685 #[test]
2686 fn a_cache_file_does_not_churn() {
2687 let project = Project::new(
2690 "cache-stable",
2691 &[
2692 ("rule.ts", DEBUGGER_RULE),
2693 ("lanekeep.config.ts", &config("")),
2694 ("src/a.ts", "debugger;\n"),
2695 ("src/b.ts", "const b = 1;\n"),
2696 ],
2697 );
2698 project.run().expect("runs");
2699 let first = fs::read(Store::path_for(&project.dir)).expect("reads");
2700 project.run().expect("runs");
2701 let second = fs::read(Store::path_for(&project.dir)).expect("reads");
2702 assert_eq!(first, second, "the cache file churned");
2703 }
2704
2705 #[test]
2706 fn entries_for_deleted_files_do_not_accumulate() {
2707 let project = Project::new(
2708 "cache-prune",
2709 &[
2710 ("rule.ts", DEBUGGER_RULE),
2711 ("lanekeep.config.ts", &config("")),
2712 ("src/a.ts", "debugger;\n"),
2713 ("src/b.ts", "debugger;\n"),
2714 ],
2715 );
2716 project.run().expect("runs");
2717 assert_eq!(project.cache().len(), 2);
2718
2719 fs::remove_file(project.dir.join("src/b.ts")).expect("removes");
2720 project.run().expect("runs");
2721 assert_eq!(
2722 project.cache().len(),
2723 1,
2724 "an entry outlived the file it was for"
2725 );
2726 }
2727
2728 #[test]
2729 fn a_partial_run_does_not_discard_other_files_entries() {
2730 let project = Project::new(
2734 "cache-partial",
2735 &[
2736 ("rule.ts", DEBUGGER_RULE),
2737 ("lanekeep.config.ts", &config("")),
2738 ("src/a.ts", "debugger;\n"),
2739 ("src/b.ts", "const b = 1;\n"),
2740 ("src/c.ts", "const c = 1;\n"),
2741 ],
2742 );
2743 project.run().expect("runs");
2744 assert_eq!(project.cache().len(), 3);
2745
2746 let engine = project.build().expect("prepares");
2747 engine
2748 .run_over(&[FilePath::new("src/a.ts")])
2749 .expect("runs over one file");
2750
2751 assert_eq!(
2752 project.cache().len(),
2753 3,
2754 "a partial run discarded entries for files it did not look at"
2755 );
2756 }
2757
2758 #[test]
2759 fn a_full_run_still_prunes() {
2760 let project = Project::new(
2763 "cache-prune-still",
2764 &[
2765 ("rule.ts", DEBUGGER_RULE),
2766 ("lanekeep.config.ts", &config("")),
2767 ("src/a.ts", "debugger;\n"),
2768 ("src/b.ts", "const b = 1;\n"),
2769 ],
2770 );
2771 project.run().expect("runs");
2772 assert_eq!(project.cache().len(), 2);
2773
2774 fs::remove_file(project.dir.join("src/b.ts")).expect("removes");
2775 project.run().expect("runs");
2776 assert_eq!(project.cache().len(), 1);
2777 }
2778
2779 impl Project {
2782 fn run_on(&self, today: &str) -> Result<Outcome, RunError> {
2784 let date = Date::parse(today).expect("valid date");
2785 self.build().map(|engine| engine.with_today(date))?.run()
2786 }
2787 }
2788
2789 fn messages(outcome: &Outcome) -> Vec<&str> {
2790 outcome
2791 .violations
2792 .iter()
2793 .map(|v| v.message.as_str())
2794 .collect()
2795 }
2796
2797 #[test]
2798 fn a_next_line_directive_silences_the_line_below_it() {
2799 let project = Project::new(
2800 "suppress-next-line",
2801 &[
2802 ("rule.ts", DEBUGGER_RULE),
2803 ("lanekeep.config.ts", &config("")),
2804 (
2805 "src/a.ts",
2806 "// lanekeep-ignore-next-line local/no-debugger reason: legacy entry point\n\
2807 debugger;\n",
2808 ),
2809 ],
2810 );
2811 assert!(
2812 project.run().expect("runs").violations.is_empty(),
2813 "the directive did not silence the violation"
2814 );
2815 }
2816
2817 #[test]
2818 fn a_directive_silences_only_the_line_it_names() {
2819 let project = Project::new(
2820 "suppress-scope",
2821 &[
2822 ("rule.ts", DEBUGGER_RULE),
2823 ("lanekeep.config.ts", &config("")),
2824 (
2825 "src/a.ts",
2826 "// lanekeep-ignore-next-line local/no-debugger reason: legacy\n\
2827 debugger;\n\
2828 debugger;\n",
2829 ),
2830 ],
2831 );
2832 let outcome = project.run().expect("runs");
2833 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
2834 assert_eq!(outcome.violations[0].location.position.line, 3);
2835 }
2836
2837 #[test]
2838 fn a_file_directive_silences_every_line() {
2839 let project = Project::new(
2840 "suppress-file",
2841 &[
2842 ("rule.ts", DEBUGGER_RULE),
2843 ("lanekeep.config.ts", &config("")),
2844 (
2845 "src/a.ts",
2846 "// lanekeep-ignore-file local/no-debugger reason: generated fixture\n\
2847 debugger;\n\
2848 debugger;\n",
2849 ),
2850 ],
2851 );
2852 assert!(project.run().expect("runs").violations.is_empty());
2853 }
2854
2855 #[test]
2856 fn a_directive_naming_another_rule_silences_nothing() {
2857 let project = Project::new(
2858 "suppress-other-rule",
2859 &[
2860 ("rule.ts", DEBUGGER_RULE),
2861 ("lanekeep.config.ts", &config("")),
2862 (
2863 "src/a.ts",
2864 "// lanekeep-ignore-next-line local/something-else reason: unrelated\n\
2865 debugger;\n",
2866 ),
2867 ],
2868 );
2869 assert_eq!(project.run().expect("runs").violations.len(), 1);
2870 }
2871
2872 #[test]
2873 fn a_malformed_directive_is_reported() {
2874 let project = Project::new(
2878 "suppress-malformed",
2879 &[
2880 ("rule.ts", DEBUGGER_RULE),
2881 ("lanekeep.config.ts", &config("")),
2882 (
2883 "src/a.ts",
2884 "// lanekeep-ignore-next-line local/no-debugger\ndebugger;\n",
2885 ),
2886 ],
2887 );
2888
2889 let outcome = project.run().expect("runs");
2890 assert_eq!(outcome.violations.len(), 2, "{:?}", messages(&outcome));
2891 assert!(
2892 messages(&outcome)
2893 .iter()
2894 .any(|m| m.contains("no `reason:`")),
2895 "{:?}",
2896 messages(&outcome)
2897 );
2898 assert!(
2899 outcome
2900 .violations
2901 .iter()
2902 .any(|v| v.rule_id.to_string() == "lanekeep/suppression"),
2903 "reported under the wrong id"
2904 );
2905 }
2906
2907 #[test]
2908 fn an_expired_directive_is_reported_and_still_silences() {
2909 let project = Project::new(
2912 "suppress-expired",
2913 &[
2914 ("rule.ts", DEBUGGER_RULE),
2915 ("lanekeep.config.ts", &config("")),
2916 (
2917 "src/a.ts",
2918 "// lanekeep-ignore-next-line local/no-debugger reason: pending rewrite expires: 2026-01-01\n\
2919 debugger;\n",
2920 ),
2921 ],
2922 );
2923
2924 let outcome = project.run_on("2026-08-01").expect("runs");
2925 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
2926 assert!(
2927 outcome.violations[0]
2928 .message
2929 .contains("expired on 2026-01-01"),
2930 "{:?}",
2931 messages(&outcome)
2932 );
2933 assert!(
2934 outcome.violations[0].message.contains("pending rewrite"),
2935 "the reason should be quoted back: {:?}",
2936 messages(&outcome)
2937 );
2938 }
2939
2940 #[test]
2941 fn a_directive_that_has_not_expired_is_quiet() {
2942 let project = Project::new(
2943 "suppress-unexpired",
2944 &[
2945 ("rule.ts", DEBUGGER_RULE),
2946 ("lanekeep.config.ts", &config("")),
2947 (
2948 "src/a.ts",
2949 "// lanekeep-ignore-next-line local/no-debugger reason: pending expires: 2026-12-31\n\
2950 debugger;\n",
2951 ),
2952 ],
2953 );
2954 assert!(
2955 project
2956 .run_on("2026-08-01")
2957 .expect("runs")
2958 .violations
2959 .is_empty()
2960 );
2961 }
2962
2963 #[test]
2964 fn a_directive_expires_the_day_after_its_date() {
2965 let project = Project::new(
2968 "suppress-boundary",
2969 &[
2970 ("rule.ts", DEBUGGER_RULE),
2971 ("lanekeep.config.ts", &config("")),
2972 (
2973 "src/a.ts",
2974 "// lanekeep-ignore-file local/no-debugger reason: x expires: 2026-08-01\n\
2975 debugger;\n",
2976 ),
2977 ],
2978 );
2979 assert!(
2980 project
2981 .run_on("2026-08-01")
2982 .expect("runs")
2983 .violations
2984 .is_empty()
2985 );
2986 assert_eq!(
2987 project.run_on("2026-08-02").expect("runs").violations.len(),
2988 1
2989 );
2990 }
2991
2992 #[test]
2993 fn an_expiring_directive_is_not_served_stale_from_the_cache() {
2994 let project = Project::new(
2998 "suppress-cache-date",
2999 &[
3000 ("rule.ts", DEBUGGER_RULE),
3001 ("lanekeep.config.ts", &config("")),
3002 (
3003 "src/a.ts",
3004 "// lanekeep-ignore-file local/no-debugger reason: x expires: 2026-08-01\n\
3005 debugger;\n",
3006 ),
3007 ],
3008 );
3009
3010 assert!(
3011 project
3012 .run_on("2026-08-01")
3013 .expect("runs")
3014 .violations
3015 .is_empty()
3016 );
3017 let after = project.run_on("2026-08-02").expect("runs");
3018 assert_eq!(
3019 after.violations.len(),
3020 1,
3021 "a warm run served an expired suppression: {:?}",
3022 messages(&after)
3023 );
3024 }
3025
3026 #[test]
3027 fn suppressions_survive_a_warm_run() {
3028 let project = Project::new(
3029 "suppress-warm",
3030 &[
3031 ("rule.ts", DEBUGGER_RULE),
3032 ("lanekeep.config.ts", &config("")),
3033 (
3034 "src/a.ts",
3035 "// lanekeep-ignore-file local/no-debugger reason: generated\ndebugger;\n",
3036 ),
3037 ],
3038 );
3039 assert!(project.run().expect("runs").violations.is_empty());
3040 assert!(
3041 project.run().expect("runs").violations.is_empty(),
3042 "the warm run reported what the cold one suppressed"
3043 );
3044 }
3045
3046 #[test]
3047 fn a_cross_file_violation_is_silenced_by_the_directive_where_it_lands() {
3048 let project = Project::new(
3052 "suppress-cross-file",
3053 &[
3054 ("rule.ts", UNUSED_EXPORTS_RULE),
3055 ("lanekeep.config.ts", &config("")),
3056 (
3057 "src/a.ts",
3058 "export function used() {}\n\
3059 // lanekeep-ignore-next-line local/no-unused-exports reason: public API\n\
3060 export function spare() {}\n",
3061 ),
3062 ("src/b.ts", "import { used } from './a';\nused();\n"),
3063 ],
3064 );
3065
3066 let outcome = project.run().expect("runs");
3067 assert!(
3068 outcome.violations.is_empty(),
3069 "a cross-file violation ignored the directive at its site: {:?}",
3070 messages(&outcome)
3071 );
3072 }
3073
3074 #[test]
3075 fn a_cross_file_violation_survives_a_directive_for_another_rule() {
3076 let project = Project::new(
3077 "suppress-cross-file-other",
3078 &[
3079 ("rule.ts", UNUSED_EXPORTS_RULE),
3080 ("lanekeep.config.ts", &config("")),
3081 (
3082 "src/a.ts",
3083 "export function used() {}\n\
3084 // lanekeep-ignore-next-line local/unrelated reason: x\n\
3085 export function spare() {}\n",
3086 ),
3087 ("src/b.ts", "import { used } from './a';\nused();\n"),
3088 ],
3089 );
3090 assert_eq!(project.run().expect("runs").violations.len(), 1);
3091 }
3092
3093 impl Project {
3096 fn run_reporting_unused(&self) -> Result<Outcome, RunError> {
3097 self.build()
3098 .map(Engine::reporting_unused_suppressions)?
3099 .run()
3100 }
3101 }
3102
3103 #[test]
3104 fn a_suppression_that_silenced_nothing_is_reported() {
3105 let project = Project::new(
3106 "unused-reported",
3107 &[
3108 ("rule.ts", DEBUGGER_RULE),
3109 ("lanekeep.config.ts", &config("")),
3110 (
3111 "src/a.ts",
3112 "// lanekeep-ignore-next-line local/no-debugger reason: was needed once\n\
3113 const a = 1;\n",
3114 ),
3115 ],
3116 );
3117
3118 let outcome = project.run_reporting_unused().expect("runs");
3119 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3120 assert!(
3121 outcome.violations[0].message.contains("silenced nothing"),
3122 "{:?}",
3123 messages(&outcome)
3124 );
3125 assert!(
3126 outcome.violations[0].message.contains("was needed once"),
3127 "the reason should be quoted back: {:?}",
3128 messages(&outcome)
3129 );
3130 }
3131
3132 #[test]
3133 fn a_suppression_that_did_its_job_is_not_reported() {
3134 let project = Project::new(
3135 "unused-used",
3136 &[
3137 ("rule.ts", DEBUGGER_RULE),
3138 ("lanekeep.config.ts", &config("")),
3139 (
3140 "src/a.ts",
3141 "// lanekeep-ignore-next-line local/no-debugger reason: legacy\ndebugger;\n",
3142 ),
3143 ],
3144 );
3145 assert!(
3146 project
3147 .run_reporting_unused()
3148 .expect("runs")
3149 .violations
3150 .is_empty()
3151 );
3152 }
3153
3154 #[test]
3155 fn unused_suppressions_are_quiet_without_the_flag() {
3156 let project = Project::new(
3158 "unused-off",
3159 &[
3160 ("rule.ts", DEBUGGER_RULE),
3161 ("lanekeep.config.ts", &config("")),
3162 (
3163 "src/a.ts",
3164 "// lanekeep-ignore-next-line local/no-debugger reason: stale\nconst a = 1;\n",
3165 ),
3166 ],
3167 );
3168 assert!(project.run().expect("runs").violations.is_empty());
3169 }
3170
3171 #[test]
3172 fn an_unused_suppression_is_a_warning_not_an_error() {
3173 let project = Project::new(
3175 "unused-severity",
3176 &[
3177 ("rule.ts", DEBUGGER_RULE),
3178 ("lanekeep.config.ts", &config("")),
3179 (
3180 "src/a.ts",
3181 "// lanekeep-ignore-next-line local/no-debugger reason: stale\nconst a = 1;\n",
3182 ),
3183 ],
3184 );
3185 let outcome = project.run_reporting_unused().expect("runs");
3186 assert_eq!(outcome.violations[0].severity, Severity::Warn);
3187 assert!(!lanekeep_core::any_failing(&outcome.violations));
3188 }
3189
3190 #[test]
3191 fn usage_survives_a_warm_run() {
3192 let project = Project::new(
3196 "unused-warm",
3197 &[
3198 ("rule.ts", DEBUGGER_RULE),
3199 ("lanekeep.config.ts", &config("")),
3200 (
3201 "src/a.ts",
3202 "// lanekeep-ignore-next-line local/no-debugger reason: legacy\ndebugger;\n",
3203 ),
3204 ],
3205 );
3206
3207 assert!(
3208 project
3209 .run_reporting_unused()
3210 .expect("runs")
3211 .violations
3212 .is_empty()
3213 );
3214 let warm = project.run_reporting_unused().expect("runs");
3215 assert!(
3216 warm.violations.is_empty(),
3217 "a warm run called a used suppression unused: {:?}",
3218 messages(&warm)
3219 );
3220 }
3221
3222 #[test]
3223 fn a_suppression_used_only_by_a_cross_file_rule_is_not_unused() {
3224 let project = Project::new(
3227 "unused-cross-file",
3228 &[
3229 ("rule.ts", UNUSED_EXPORTS_RULE),
3230 ("lanekeep.config.ts", &config("")),
3231 (
3232 "src/a.ts",
3233 "export function used() {}\n\
3234 // lanekeep-ignore-next-line local/no-unused-exports reason: public API\n\
3235 export function spare() {}\n",
3236 ),
3237 ("src/b.ts", "import { used } from './a';\nused();\n"),
3238 ],
3239 );
3240
3241 let outcome = project.run_reporting_unused().expect("runs");
3242 assert!(
3243 outcome.violations.is_empty(),
3244 "a directive used by a cross-file rule was called unused: {:?}",
3245 messages(&outcome)
3246 );
3247 }
3248
3249 #[test]
3250 fn a_malformed_directive_is_not_also_reported_as_unused() {
3251 let project = Project::new(
3254 "unused-malformed",
3255 &[
3256 ("rule.ts", DEBUGGER_RULE),
3257 ("lanekeep.config.ts", &config("")),
3258 (
3259 "src/a.ts",
3260 "// lanekeep-ignore-next-line local/no-debugger\nconst a = 1;\n",
3261 ),
3262 ],
3263 );
3264
3265 let outcome = project.run_reporting_unused().expect("runs");
3266 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3267 assert!(
3268 outcome.violations[0].message.contains("no `reason:`"),
3269 "{:?}",
3270 messages(&outcome)
3271 );
3272 }
3273
3274 const DATE_RULE: &str = r"import { defineRule } from 'lanekeep';
3278export default defineRule({
3279 id: 'local/dated',
3280 query: '(export_statement) @stmt',
3281 card: { message: 'dated', remediation: 'x', examples: { bad: 'a', good: 'b' } },
3282 check(ctx, m) {
3283 if (ctx.today.startsWith('2027')) ctx.report(m.stmt, `it is ${ctx.today}`);
3284 },
3285});
3286";
3287
3288 #[test]
3289 fn a_rule_can_read_the_date() {
3290 let project = Project::new(
3291 "today-read",
3292 &[
3293 ("rule.ts", DATE_RULE),
3294 ("lanekeep.config.ts", &config("")),
3295 ("src/a.ts", "export const a = 1;\n"),
3296 ],
3297 );
3298 let outcome = project.run_on("2027-03-04").expect("runs");
3299 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3300 assert!(outcome.violations[0].message.contains("2027-03-04"));
3301 }
3302
3303 #[test]
3304 fn a_result_that_read_the_date_is_not_served_across_days() {
3305 let project = Project::new(
3309 "today-cache",
3310 &[
3311 ("rule.ts", DATE_RULE),
3312 ("lanekeep.config.ts", &config("")),
3313 ("src/a.ts", "export const a = 1;\n"),
3314 ],
3315 );
3316
3317 assert!(
3318 project
3319 .run_on("2026-12-31")
3320 .expect("runs")
3321 .violations
3322 .is_empty()
3323 );
3324 let later = project.run_on("2027-01-01").expect("runs");
3325 assert_eq!(
3326 later.violations.len(),
3327 1,
3328 "a warm run served a date-dependent result from another day: {:?}",
3329 messages(&later)
3330 );
3331 }
3332
3333 #[test]
3334 fn a_result_that_ignored_the_date_survives_across_days() {
3335 let project = Project::new(
3342 "today-undated",
3343 &[
3344 ("rule.ts", DEBUGGER_RULE),
3345 ("lanekeep.config.ts", &config("")),
3346 ("src/a.ts", "debugger;\n"),
3347 ],
3348 );
3349
3350 project.run_on("2026-12-31").expect("runs");
3351 let before = fs::read(Store::path_for(&project.dir)).expect("reads");
3352
3353 let outcome = project.run_on("2027-01-01").expect("runs");
3354 assert_eq!(outcome.violations.len(), 1);
3355
3356 let after = fs::read(Store::path_for(&project.dir)).expect("reads");
3357 assert_eq!(
3358 before, after,
3359 "a result that never read the date was re-keyed across days"
3360 );
3361 }
3362
3363 #[test]
3364 fn a_result_that_read_the_date_is_re_keyed_across_days() {
3365 let project = Project::new(
3368 "today-dated-key",
3369 &[
3370 ("rule.ts", DATE_RULE),
3371 ("lanekeep.config.ts", &config("")),
3372 ("src/a.ts", "export const a = 1;\n"),
3373 ],
3374 );
3375
3376 project.run_on("2026-12-31").expect("runs");
3377 let before = fs::read(Store::path_for(&project.dir)).expect("reads");
3378
3379 project.run_on("2027-01-01").expect("runs");
3380 let after = fs::read(Store::path_for(&project.dir)).expect("reads");
3381 assert_ne!(
3382 before, after,
3383 "a result that read the date kept its key across days"
3384 );
3385 }
3386
3387 #[test]
3388 fn loc_reaches_a_reduce_phase_through_a_fact() {
3389 const RULE: &str = r"import { defineRule } from 'lanekeep';
3391export default defineRule({
3392 id: 'local/loc-through-facts',
3393 query: '(export_statement) @stmt',
3394 card: { message: 'via loc', remediation: 'x', examples: { bad: 'a', good: 'b' } },
3395 check(ctx, m) { ctx.emitFact({ kind: 'site', at: ctx.loc(m.stmt) }); },
3396 reduce(ctx) {
3397 for (const f of ctx.facts('site')) ctx.report(f.at, 'reported at a remembered place');
3398 },
3399});
3400";
3401 let project = Project::new(
3402 "loc-facts",
3403 &[
3404 ("rule.ts", RULE),
3405 ("lanekeep.config.ts", &config("")),
3406 ("src/a.ts", "const x = 1;\nexport const a = 1;\n"),
3407 ],
3408 );
3409
3410 let outcome = project.run().expect("runs");
3411 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3412 assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
3413 assert_eq!(outcome.violations[0].location.position.line, 2);
3414 }
3415}