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 prepared: Vec<Result<Prepared, RunError>> =
292 config
293 .rules
294 .par_iter()
295 .filter(|spec| spec.severity.is_enabled())
296 .map(|spec| {
297 let mut compiled = Vec::with_capacity(spec.languages.len());
298 for id in &spec.languages {
299 let language = registry.by_id(id).cloned().ok_or_else(|| {
300 RunError::UnknownLanguage {
301 rule: spec.id.to_string(),
302 language: id.clone(),
303 known: known.clone(),
304 }
305 })?;
306
307 let query = CompiledQuery::compile(language.as_ref(), &spec.query)
312 .map_err(|e: CompileError| RunError::Query {
313 rule: spec.id.to_string(),
314 detail: e.to_string(),
315 })?;
316
317 compiled.push((language, query));
318 }
319
320 let gates =
321 CompiledGates::compile(&spec.gates).map_err(|e| RunError::Gates {
322 rule: spec.id.to_string(),
323 detail: e.to_string(),
324 })?;
325
326 Ok(Prepared {
327 spec: spec.clone(),
328 gates,
329 compiled,
330 })
331 })
332 .collect();
333
334 let mut rules = Vec::with_capacity(prepared.len());
338 for result in prepared {
339 rules.push(result?);
340 }
341
342 let mut grammars: Vec<GrammarKey> = registry
345 .languages()
346 .map(|language| GrammarKey {
347 id: language.id().to_string(),
348 abi: u32::try_from(language.grammar_abi()).unwrap_or(u32::MAX),
349 })
350 .collect();
351 grammars.sort_by(|a, b| a.id.cmp(&b.id));
352
353 let run_key = RunKey::new(
354 engine_version(),
358 HOST_API_VERSION,
359 &config.ruleset_hash,
360 &config.config_hash,
361 &grammars,
362 );
363
364 Ok(Self {
365 rules,
366 run_key,
367 caching: true,
368 reducing: true,
369 reporting_unused: false,
370 profiling: false,
371 today: suppression::today(),
372 root: project_root
376 .canonicalize()
377 .unwrap_or_else(|_| project_root.to_path_buf()),
378 discovery,
379 limits: config.limits,
380 rules_root,
381 config_path: config_path.to_path_buf(),
382 typescript,
383 javascript,
384 languages_by_extension,
385 })
386 }
387
388 fn language_of(&self, path: &FilePath) -> Option<&str> {
390 let extension = Path::new(path.as_str())
391 .extension()?
392 .to_str()?
393 .to_ascii_lowercase();
394 self.languages_by_extension
395 .get(extension.as_str())
396 .map(String::as_str)
397 }
398
399 #[must_use]
401 pub const fn without_cache(mut self) -> Self {
402 self.caching = false;
403 self
404 }
405
406 #[must_use]
411 pub const fn profiling(mut self) -> Self {
412 self.profiling = true;
413 self
414 }
415
416 #[must_use]
422 pub const fn reporting_unused_suppressions(mut self) -> Self {
423 self.reporting_unused = true;
424 self
425 }
426
427 #[must_use]
431 pub const fn with_today(mut self, today: Date) -> Self {
432 self.today = today;
433 self
434 }
435
436 #[must_use]
446 pub const fn without_reduce(mut self) -> Self {
447 self.reducing = false;
448 self
449 }
450
451 #[must_use]
456 pub fn discover(&self) -> Vec<FilePath> {
457 self.discovery.walk()
458 }
459
460 #[must_use]
462 pub fn rule_count(&self) -> usize {
463 self.rules.len()
464 }
465
466 pub fn rules(&self) -> impl Iterator<Item = &RuleSpec> {
472 self.rules.iter().map(|prepared| &prepared.spec)
473 }
474
475 pub fn run(&self) -> Result<Outcome, RunError> {
483 let files = self.discovery.walk();
484 self.run_files(&files, Coverage::Whole)
485 }
486
487 pub fn run_over(&self, files: &[FilePath]) -> Result<Outcome, RunError> {
493 self.run_files(files, Coverage::Partial)
494 }
495
496 fn run_files(&self, files: &[FilePath], coverage: Coverage) -> Result<Outcome, RunError> {
498 let clock = RunClock::start(self.limits.global_timeout);
499
500 let cache = if self.caching {
504 Store::load(&self.root)
505 } else {
506 Store::empty()
507 };
508
509 let results: Vec<Result<FileOutcome, RunError>> = files
510 .par_iter()
511 .map_init(
512 || Worker::new(self, &clock),
522 |worker, path| self.check_file(worker, &cache, path),
523 )
524 .collect();
525
526 let mut violations = Vec::new();
527 let mut facts = Vec::new();
528 let mut files_parsed = 0;
529 let mut dependencies = BTreeMap::new();
530 let mut fresh = Store::empty();
531 let mut directives: BTreeMap<FilePath, FileDirectives> = BTreeMap::new();
532 let mut timings: BTreeMap<RuleId, RuleTiming> = BTreeMap::new();
533 for result in results {
534 let outcome = result?;
535 violations.extend(outcome.violations);
536 facts.extend(outcome.facts);
537 files_parsed += usize::from(outcome.parsed);
538 if let Some(entry) = outcome.entry {
539 fresh.insert(entry.0, entry.1);
540 }
541 for (rule, timing) in outcome.timings {
542 let entry = timings.entry(rule).or_default();
543 entry.query = entry.query.saturating_add(timing.query);
544 entry.handler = entry.handler.saturating_add(timing.handler);
545 entry.matches += timing.matches;
546 }
547 if !outcome.suppressions.is_empty() {
548 directives.insert(
549 outcome.path.clone(),
550 FileDirectives {
551 suppressions: outcome.suppressions,
552 used: outcome.used_suppressions,
553 },
554 );
555 }
556 if !outcome.reads.is_empty() {
557 dependencies.insert(outcome.path, outcome.reads);
558 }
559 }
560
561 if self.caching {
562 match coverage {
563 Coverage::Whole => fresh.save(&self.root),
566 Coverage::Partial => {
571 let mut merged = cache;
572 for key in fresh.keys().copied().collect::<Vec<_>>() {
573 if let Some(entry) = fresh.get(&key) {
574 merged.insert(key, entry.clone());
575 }
576 }
577 merged.save(&self.root);
578 }
579 }
580 }
581
582 lanekeep_core::fact::sort(&mut facts);
591
592 let reduced = self.reduce(&clock, files, &facts)?;
596 for violation in reduced {
597 match covering_elsewhere(&directives, &violation) {
600 Some((file, index)) => {
601 if let Some(found) = directives.get_mut(&file)
602 && !found.used.contains(&index)
603 {
604 found.used.push(index);
605 }
606 }
607 None => violations.push(violation),
608 }
609 }
610
611 if self.reporting_unused {
612 violations.extend(unused_violations(&directives));
613 }
614
615 lanekeep_core::sort(&mut violations);
616 Ok(Outcome {
617 violations,
618 files_discovered: files.len(),
619 files_parsed,
620 timings: self.profiling.then_some(timings),
621 dependencies,
622 })
623 }
624
625 fn reduce(
632 &self,
633 clock: &Arc<RunClock>,
634 files: &[FilePath],
635 facts: &[Fact],
636 ) -> Result<Vec<Violation>, RunError> {
637 if !self.reducing {
638 return Ok(Vec::new());
639 }
640
641 let reducing: Vec<&Prepared> = self
642 .rules
643 .iter()
644 .filter(|rule| rule.spec.has_reduce)
645 .collect();
646 if reducing.is_empty() {
647 return Ok(Vec::new());
650 }
651
652 let sandbox = self.build_sandbox(clock)?;
653 let paths: Vec<String> = files.iter().map(|f| f.as_str().to_owned()).collect();
654 let mut violations = Vec::new();
655
656 for rule in reducing {
657 let own: Vec<ReduceFact> = facts
661 .iter()
662 .filter(|fact| fact.rule_id == rule.spec.id)
663 .map(|fact| ReduceFact {
664 kind: fact.kind.clone(),
665 json: lanekeep_js::merge_file(&fact.data, fact.file.as_str()),
666 })
667 .collect();
668
669 let host = ReduceContext::new(paths.clone(), own);
670 let timeout = rule.spec.timeout.unwrap_or(self.limits.rule_timeout);
671 let call = format!(
672 "globalThis.__lanekeepConfig.rules[{}].reduce(ctx)",
673 rule_index(&rule.spec)
674 );
675
676 sandbox
677 .eval_with_reduce_host::<()>(&host, &call, timeout)
678 .map_err(|e: SandboxError| RunError::Rule {
679 rule: rule.spec.id.to_string(),
680 file: "<reduce>".to_owned(),
683 detail: e.to_string(),
684 })?;
685
686 for report in host.take_reports() {
687 violations.push(Violation {
692 rule_id: rule.spec.id.clone(),
693 location: Location::new(
694 FilePath::new(&report.file),
695 Position::new(report.line, report.column),
696 ),
697 message: report
698 .message
699 .unwrap_or_else(|| rule.spec.card.message.clone()),
700 remediation: rule.spec.card.remediation.clone(),
701 severity: rule.spec.severity,
702 fix: None,
706 });
707 }
708 }
709
710 Ok(violations)
711 }
712
713 fn build_sandbox(&self, clock: &Arc<RunClock>) -> Result<Sandbox, RunError> {
715 let sandbox = Sandbox::with_modules(
716 self.limits,
717 Arc::clone(clock),
718 self.rules_root.clone(),
719 Arc::clone(&self.typescript),
720 Arc::clone(&self.javascript),
721 )
722 .map_err(|e| RunError::Worker {
723 detail: e.to_string(),
724 })?;
725
726 lanekeep_config::evaluate_into(&sandbox, &self.rules_root, &self.config_path).map_err(
730 |e: ConfigError| RunError::Worker {
731 detail: e.to_string(),
732 },
733 )?;
734
735 Ok(sandbox)
736 }
737
738 fn check_file(
740 &self,
741 worker: &mut Worker<'_>,
742 cache: &Store,
743 path: &FilePath,
744 ) -> Result<FileOutcome, RunError> {
745 let files = Rc::new(FileAccess::rooted(self.root.clone()));
748
749 let admitted: Vec<&Prepared> = self
751 .rules
752 .iter()
753 .filter(|rule| rule.gates.admits_path(path))
754 .collect();
755 if admitted.is_empty() {
756 return Ok(FileOutcome::skipped(path.clone()));
757 }
758
759 let absolute = self.discovery.root().join(path.as_str());
760 let Ok(bytes) = std::fs::read(&absolute) else {
761 return Ok(FileOutcome::skipped(path.clone()));
766 };
767
768 let keys = self.caching.then(|| {
786 let content = lanekeep_cache::hash_bytes(&bytes);
787 (
788 self.run_key.for_file(path.as_str(), &content),
789 self.run_key
790 .for_dated_file(path.as_str(), &content, &self.today.to_string()),
791 )
792 });
793 let has_expiry = memchr::memmem::find(&bytes, b"expires:").is_some();
794
795 if let Some((plain, dated)) = keys {
796 let candidates: &[CacheKey] = if has_expiry {
799 &[dated]
800 } else {
801 &[dated, plain]
802 };
803 for key in candidates {
804 if let Some(entry) = cache.get(key)
805 && lanekeep_cache::validate(entry, &self.root)
806 {
807 return Ok(FileOutcome::cached(path.clone(), *key, entry.clone()));
808 }
809 }
810 }
811
812 let admitted: Vec<&Prepared> = admitted
814 .into_iter()
815 .filter(|rule| rule.gates.admits_content(&bytes))
816 .collect();
817 if admitted.is_empty() {
818 return Ok(FileOutcome::empty_entry(
823 path.clone(),
824 keys.map(|(plain, dated)| if has_expiry { dated } else { plain }),
825 ));
826 }
827
828 let Ok(source) = String::from_utf8(bytes) else {
829 return Ok(FileOutcome::skipped(path.clone()));
831 };
832
833 let directives = suppression::parse(&source);
836
837 let mut outcome = FileOutcome::parsed(path.clone());
838 let Some(tree) = self.parse_once(path, &source, &admitted) else {
839 return Ok(outcome);
840 };
841
842 for rule in admitted {
843 let (violations, facts, read_the_date, timing) =
844 self.run_rule(worker, &files, rule, path, &source, &tree)?;
845 outcome.violations.extend(violations);
846 outcome.facts.extend(facts);
847 outcome.read_the_date |= read_the_date;
848 if self.profiling {
849 outcome.timings.push((rule.spec.id.clone(), timing));
850 }
851 }
852
853 let mut used = Vec::new();
858 outcome.violations.retain(|violation| {
859 match directives.covering(&violation.rule_id, violation.location.position.line) {
860 Some(index) => {
861 let index = u32::try_from(index).unwrap_or(u32::MAX);
862 if !used.contains(&index) {
863 used.push(index);
864 }
865 false
866 }
867 None => true,
868 }
869 });
870 used.sort_unstable();
871 outcome.used_suppressions = used;
872 outcome
873 .violations
874 .extend(self.directive_violations(&directives, path));
875
876 outcome.suppressions = directives.valid;
877 outcome.reads = files.dependencies();
878 let date_dependent = has_expiry || outcome.read_the_date;
881 outcome.entry = keys.map(|(plain, dated)| {
882 (
883 if date_dependent { dated } else { plain },
884 CacheEntry {
885 violations: outcome.violations.clone(),
886 facts: outcome.facts.clone(),
887 dependencies: outcome.reads.clone(),
888 suppressions: outcome.suppressions.clone(),
889 used_suppressions: outcome.used_suppressions.clone(),
890 },
891 )
892 });
893
894 Ok(outcome)
895 }
896
897 fn directive_violations(&self, directives: &Suppressions, path: &FilePath) -> Vec<Violation> {
904 let mut violations = Vec::new();
905
906 let Ok(rule_id) = SUPPRESSION_RULE.parse::<RuleId>() else {
910 return violations;
911 };
912
913 for bad in &directives.malformed {
914 violations.push(Violation {
915 rule_id: rule_id.clone(),
916 location: Location::new(path.clone(), Position::new(bad.line, bad.column)),
917 message: bad.problem.clone(),
918 remediation: String::from(
919 "fix the directive, or remove it and fix what it was hiding",
920 ),
921 severity: Severity::Error,
922 fix: None,
923 });
924 }
925
926 for suppression in &directives.valid {
927 let Some(expires) = suppression.expires else {
928 continue;
929 };
930 if expires >= self.today {
931 continue;
932 }
933
934 violations.push(Violation {
935 rule_id: rule_id.clone(),
936 location: Location::new(
937 path.clone(),
938 Position::new(suppression.line, suppression.column),
939 ),
940 message: format!(
941 "suppression expired on {expires} — \"{}\"",
942 suppression.reason
943 ),
944 remediation: String::from(
945 "fix what it was suppressing, or decide it is permanent and drop the \
946 expiry",
947 ),
948 severity: Severity::Error,
949 fix: None,
950 });
951 }
952
953 violations
954 }
955
956 fn parse_once(
970 &self,
971 path: &FilePath,
972 source: &str,
973 admitted: &[&Prepared],
974 ) -> Option<tree_sitter::Tree> {
975 let language_id = self.language_of(path)?;
976 let (language, _) = admitted
977 .iter()
978 .find_map(|rule| rule.for_language(language_id))?;
979
980 let mut parser = tree_sitter::Parser::new();
981 parser.set_language(&language.grammar()).ok()?;
982 parser.parse(source, None)
983 }
984
985 fn run_rule(
986 &self,
987 worker: &mut Worker<'_>,
988 files: &Rc<FileAccess>,
989 rule: &Prepared,
990 path: &FilePath,
991 source: &str,
992 tree: &tree_sitter::Tree,
993 ) -> Result<(Vec<Violation>, Vec<Fact>, bool, RuleTiming), RunError> {
994 let Some(language_id) = self.language_of(path) else {
998 return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
999 };
1000 let Some((language, compiled_query)) = rule.for_language(language_id) else {
1001 return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
1002 };
1003
1004 let tree = tree.clone();
1013
1014 let mut timing = RuleTiming::default();
1017 let clock = |on: bool| on.then(std::time::Instant::now);
1018
1019 let mut matches: Vec<Vec<(String, Vec<u32>)>> = Vec::new();
1022 let host = HostContext::new(tree, source.to_owned(), path.as_str())
1023 .with_resolver_from(language.as_ref())
1024 .with_language(Arc::clone(language))
1025 .with_today(&self.today.to_string())
1026 .with_file_access(Rc::clone(files));
1027
1028 let query_started = clock(self.profiling);
1029 {
1030 let arena = host.arena().borrow();
1031 compiled_query.for_each_match(arena.tree(), source.as_bytes(), |m| {
1032 let captures = m
1033 .captures
1034 .iter()
1035 .filter_map(|(name, node)| {
1036 arena.path_of(*node).map(|path| ((*name).to_owned(), path))
1037 })
1038 .collect();
1039 matches.push(captures);
1040 });
1041 }
1042
1043 if let Some(started) = query_started {
1044 timing.query = started.elapsed();
1045 timing.matches = matches.len() as u64;
1046 }
1047
1048 if matches.is_empty() {
1049 return Ok((Vec::new(), Vec::new(), false, timing));
1050 }
1051
1052 let sandbox = worker.sandbox()?;
1055
1056 let timeout = rule.spec.timeout.unwrap_or(self.limits.rule_timeout);
1057 let mut violations = Vec::new();
1058
1059 for captures in matches {
1060 let handles: Vec<(String, u32)> = {
1061 let mut arena = host.arena().borrow_mut();
1062 captures
1063 .into_iter()
1064 .filter_map(|(name, path)| arena.intern_path(path).map(|h| (name, h)))
1065 .collect()
1066 };
1067
1068 let literal = handles
1069 .iter()
1070 .map(|(name, handle)| format!("{}: {handle}", json_key(name)))
1071 .collect::<Vec<_>>()
1072 .join(", ");
1073
1074 let call = format!(
1077 "globalThis.__lanekeepConfig.rules[{}].check(ctx, {{{literal}}})",
1078 rule_index(&rule.spec)
1079 );
1080
1081 let handler_started = clock(self.profiling);
1082 let outcome = sandbox.eval_with_host_timeout::<()>(&host, &call, timeout);
1083 if let Some(started) = handler_started {
1084 timing.handler = timing.handler.saturating_add(started.elapsed());
1085 }
1086
1087 outcome.map_err(|e: SandboxError| RunError::Rule {
1088 rule: rule.spec.id.to_string(),
1089 file: path.as_str().to_owned(),
1090 detail: e.to_string(),
1091 })?;
1092 }
1093
1094 let facts = host
1095 .take_facts()
1096 .into_iter()
1097 .enumerate()
1098 .map(|(sequence, emitted)| Fact {
1099 rule_id: rule.spec.id.clone(),
1100 file: path.clone(),
1101 kind: emitted.kind,
1102 data: emitted.data,
1103 sequence: u32::try_from(sequence).unwrap_or(u32::MAX),
1107 })
1108 .collect();
1109
1110 for report in host.take_reports() {
1111 violations.push(Violation {
1112 rule_id: rule.spec.id.clone(),
1113 location: Location::new(path.clone(), Position::new(report.line, report.column)),
1114 message: report
1115 .message
1116 .unwrap_or_else(|| rule.spec.card.message.clone()),
1117 remediation: rule.spec.card.remediation.clone(),
1118 severity: rule.spec.severity,
1119 fix: report.fix,
1120 });
1121 }
1122
1123 Ok((violations, facts, host.date_was_read(), timing))
1124 }
1125}
1126
1127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1132enum Coverage {
1133 Whole,
1135 Partial,
1137}
1138
1139struct Worker<'a> {
1146 engine: &'a Engine,
1147 clock: Arc<RunClock>,
1148 sandbox: Option<Sandbox>,
1149 failed: Option<RunError>,
1152}
1153
1154impl<'a> Worker<'a> {
1155 fn new(engine: &'a Engine, clock: &Arc<RunClock>) -> Self {
1156 Self {
1157 engine,
1158 clock: Arc::clone(clock),
1159 sandbox: None,
1160 failed: None,
1161 }
1162 }
1163
1164 fn sandbox(&mut self) -> Result<&Sandbox, RunError> {
1166 if let Some(error) = &self.failed {
1167 return Err(error.clone());
1168 }
1169
1170 if self.sandbox.is_none() {
1171 match self.engine.build_sandbox(&self.clock) {
1172 Ok(sandbox) => self.sandbox = Some(sandbox),
1173 Err(error) => {
1174 self.failed = Some(error.clone());
1175 return Err(error);
1176 }
1177 }
1178 }
1179
1180 self.sandbox.as_ref().ok_or_else(|| RunError::Worker {
1181 detail: "sandbox was not built".to_owned(),
1182 })
1183 }
1184}
1185
1186struct FileOutcome {
1188 path: FilePath,
1190 violations: Vec<Violation>,
1191 facts: Vec<Fact>,
1192 reads: Vec<TrackedRead>,
1194 suppressions: Vec<suppression::Suppression>,
1196 used_suppressions: Vec<u32>,
1198 read_the_date: bool,
1200 timings: Vec<(RuleId, RuleTiming)>,
1202 entry: Option<(CacheKey, CacheEntry)>,
1204 parsed: bool,
1206}
1207
1208impl FileOutcome {
1209 const fn skipped(path: FilePath) -> Self {
1211 Self {
1212 path,
1213 violations: Vec::new(),
1214 facts: Vec::new(),
1215 reads: Vec::new(),
1216 suppressions: Vec::new(),
1217 used_suppressions: Vec::new(),
1218 read_the_date: false,
1219 timings: Vec::new(),
1220 entry: None,
1221 parsed: false,
1222 }
1223 }
1224
1225 const fn parsed(path: FilePath) -> Self {
1226 Self {
1227 path,
1228 violations: Vec::new(),
1229 facts: Vec::new(),
1230 reads: Vec::new(),
1231 suppressions: Vec::new(),
1232 used_suppressions: Vec::new(),
1233 read_the_date: false,
1234 timings: Vec::new(),
1235 entry: None,
1236 parsed: true,
1237 }
1238 }
1239
1240 fn cached(path: FilePath, key: CacheKey, entry: CacheEntry) -> Self {
1245 Self {
1246 path,
1247 violations: entry.violations.clone(),
1248 facts: entry.facts.clone(),
1249 reads: entry.dependencies.clone(),
1250 suppressions: entry.suppressions.clone(),
1251 used_suppressions: entry.used_suppressions.clone(),
1252 read_the_date: false,
1255 timings: Vec::new(),
1256 entry: Some((key, entry)),
1257 parsed: true,
1258 }
1259 }
1260
1261 fn empty_entry(path: FilePath, key: Option<CacheKey>) -> Self {
1263 Self {
1264 path,
1265 violations: Vec::new(),
1266 facts: Vec::new(),
1267 reads: Vec::new(),
1268 suppressions: Vec::new(),
1269 used_suppressions: Vec::new(),
1270 read_the_date: false,
1271 timings: Vec::new(),
1272 entry: key.map(|key| (key, CacheEntry::default())),
1273 parsed: false,
1274 }
1275 }
1276}
1277
1278struct FileDirectives {
1280 suppressions: Vec<suppression::Suppression>,
1281 used: Vec<u32>,
1283}
1284
1285fn covering_elsewhere(
1290 directives: &BTreeMap<FilePath, FileDirectives>,
1291 violation: &Violation,
1292) -> Option<(FilePath, u32)> {
1293 let found = directives.get(&violation.location.file)?;
1294 let index = found.suppressions.iter().position(|suppression| {
1295 suppression.covers(&violation.rule_id, violation.location.position.line)
1296 })?;
1297
1298 Some((
1299 violation.location.file.clone(),
1300 u32::try_from(index).unwrap_or(u32::MAX),
1301 ))
1302}
1303
1304fn unused_violations(directives: &BTreeMap<FilePath, FileDirectives>) -> Vec<Violation> {
1313 let Ok(rule_id) = SUPPRESSION_RULE.parse::<RuleId>() else {
1314 return Vec::new();
1315 };
1316
1317 let mut violations = Vec::new();
1318 for (file, found) in directives {
1319 for (index, suppression) in found.suppressions.iter().enumerate() {
1320 let index = u32::try_from(index).unwrap_or(u32::MAX);
1321 if found.used.contains(&index) {
1322 continue;
1323 }
1324
1325 violations.push(Violation {
1326 rule_id: rule_id.clone(),
1327 location: Location::new(
1328 file.clone(),
1329 Position::new(suppression.line, suppression.column),
1330 ),
1331 message: format!("suppression silenced nothing — \"{}\"", suppression.reason),
1332 remediation: String::from(
1333 "remove it: whatever it was accepting is no longer reported",
1334 ),
1335 severity: Severity::Warn,
1336 fix: None,
1337 });
1338 }
1339 }
1340 violations
1341}
1342
1343fn engine_version() -> &'static str {
1345 const FULL: &str = env!("CARGO_PKG_VERSION");
1348 match FULL.match_indices('.').nth(1) {
1349 Some((at, _)) => FULL.split_at(at).0,
1350 None => FULL,
1351 }
1352}
1353
1354const SUPPRESSION_RULE: &str = "lanekeep/suppression";
1359
1360fn rule_index(spec: &RuleSpec) -> usize {
1362 spec.index
1363}
1364
1365fn json_key(name: &str) -> String {
1367 format!("{name:?}")
1368}
1369
1370#[must_use]
1372pub fn any_failing(violations: &[Violation]) -> bool {
1373 violations.iter().any(|v| v.severity == Severity::Error)
1374}
1375
1376#[must_use]
1378pub fn rules_root_for(project_root: &Path) -> PathBuf {
1379 project_root.to_path_buf()
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384 use std::fs;
1385
1386 use lanekeep_lang_js::{JavaScript, TypeScript};
1387
1388 use super::*;
1389
1390 struct Project {
1391 dir: PathBuf,
1392 }
1393
1394 impl Project {
1395 fn new(name: &str, files: &[(&str, &str)]) -> Self {
1396 let dir = std::env::temp_dir().join(format!("lanekeep-engine-{name}"));
1397 let _ = fs::remove_dir_all(&dir);
1398 fs::create_dir_all(&dir).expect("creates dir");
1399 let project = Self { dir };
1400 for (path, contents) in files {
1401 project.write(path, contents);
1402 }
1403 project
1404 }
1405
1406 fn write(&self, path: &str, contents: &str) {
1407 let full = self.dir.join(path);
1408 if let Some(parent) = full.parent() {
1409 fs::create_dir_all(parent).expect("creates parent");
1410 }
1411 fs::write(full, contents).expect("writes");
1412 }
1413
1414 fn run(&self) -> Result<Outcome, RunError> {
1415 let root = RuleRoot::new(&self.dir).expect("canonicalizes");
1416 let config_path = self.dir.join("lanekeep.config.ts");
1417
1418 let sandbox =
1419 lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
1420 .expect("sandbox");
1421 let config = lanekeep_config::load(&sandbox, &root, &config_path)
1422 .unwrap_or_else(|e| panic!("config failed to load: {e}"));
1423
1424 let engine = Engine::prepare(
1425 &config,
1426 &self.dir,
1427 root,
1428 &config_path,
1429 &lanekeep_lang_js::registry(),
1430 Arc::new(TypeScript),
1431 Arc::new(JavaScript),
1432 )?;
1433 engine.run()
1434 }
1435 }
1436
1437 impl Drop for Project {
1438 fn drop(&mut self) {
1439 let _ = fs::remove_dir_all(&self.dir);
1440 }
1441 }
1442
1443 const DEBUGGER_RULE: &str = "import { defineRule } from 'lanekeep';\n\
1445 export default defineRule({\n\
1446 id: 'local/no-debugger',\n\
1447 query: '(debugger_statement) @stmt',\n\
1448 card: {\n\
1449 message: 'debugger statement',\n\
1450 remediation: 'remove it before committing',\n\
1451 examples: { bad: 'debugger;', good: 'console.log(x);' },\n\
1452 },\n\
1453 check(ctx, m) { ctx.report(m.stmt); },\n\
1454 });\n";
1455
1456 fn member_rule_for(language: &str) -> String {
1458 let declaration = if language.is_empty() {
1459 String::new()
1460 } else {
1461 format!(" language: {language},\n")
1462 };
1463 format!(
1464 "import {{ defineRule }} from 'lanekeep';\n\
1465 export default defineRule({{\n\
1466 id: 'local/member',\n\
1467 {declaration}\
1468 query: '(member_expression) @m',\n\
1469 card: {{\n\
1470 message: 'member expression',\n\
1471 remediation: 'n/a',\n\
1472 examples: {{ bad: 'a.b', good: 'b' }},\n\
1473 }},\n\
1474 check(ctx, m) {{ ctx.report(m.m); }},\n\
1475 }});\n"
1476 )
1477 }
1478
1479 fn config_for(include: &str) -> String {
1480 format!(
1481 "import {{ defineConfig }} from 'lanekeep';\n\
1482 import rule from './rule';\n\
1483 export default defineConfig({{ include: ['{include}'], rules: [rule] }});\n"
1484 )
1485 }
1486
1487 fn config(extra: &str) -> String {
1488 format!(
1489 "import {{ defineConfig }} from 'lanekeep';\n\
1490 import rule from './rule';\n\
1491 export default defineConfig({{ include: ['src/**/*.ts'], rules: [rule]{extra} }});\n"
1492 )
1493 }
1494
1495 #[test]
1503 fn a_default_rule_sees_inside_jsx() {
1504 let project = Project::new(
1505 "jsx-default",
1506 &[
1507 ("rule.ts", &member_rule_for("")),
1508 ("lanekeep.config.ts", &config_for("src/**/*.tsx")),
1509 (
1510 "src/Component.tsx",
1511 "export const C = () => <View style={styles.used} />;\n",
1512 ),
1513 ],
1514 );
1515
1516 let outcome = project.run().expect("runs");
1517
1518 assert_eq!(
1519 outcome.violations.len(),
1520 1,
1521 "a member expression inside JSX was not seen: {:?}",
1522 outcome.violations
1523 );
1524 }
1525
1526 #[test]
1528 fn a_default_rule_still_sees_plain_typescript() {
1529 let project = Project::new(
1530 "ts-default",
1531 &[
1532 ("rule.ts", &member_rule_for("")),
1533 ("lanekeep.config.ts", &config_for("src/**/*.ts")),
1534 ("src/plain.ts", "const x = styles.used;\n"),
1535 ],
1536 );
1537
1538 let outcome = project.run().expect("runs");
1539
1540 assert_eq!(outcome.violations.len(), 1, "{:?}", outcome.violations);
1541 }
1542
1543 #[test]
1548 fn a_rule_does_not_run_on_a_language_it_does_not_name() {
1549 let project = Project::new(
1550 "single-language",
1551 &[
1552 ("rule.ts", &member_rule_for("'typescript'")),
1553 ("lanekeep.config.ts", &config_for("src/**/*.tsx")),
1554 (
1555 "src/Component.tsx",
1556 "export const C = () => <View style={styles.used} />;\n",
1557 ),
1558 ],
1559 );
1560
1561 let outcome = project.run().expect("runs");
1562
1563 assert!(
1564 outcome.violations.is_empty(),
1565 "a typescript-only rule ran on a tsx file: {:?}",
1566 outcome.violations
1567 );
1568 }
1569
1570 #[test]
1572 fn a_rule_may_name_several_languages() {
1573 let project = Project::new(
1574 "many-languages",
1575 &[
1576 ("rule.ts", &member_rule_for("['typescript', 'tsx']")),
1577 ("lanekeep.config.ts", &config_for("src/**/*.{ts,tsx}")),
1578 ("src/plain.ts", "const x = styles.used;\n"),
1579 (
1580 "src/Component.tsx",
1581 "export const C = () => <View style={styles.used} />;\n",
1582 ),
1583 ],
1584 );
1585
1586 let outcome = project.run().expect("runs");
1587
1588 assert_eq!(outcome.violations.len(), 2, "{:?}", outcome.violations);
1589 }
1590
1591 #[test]
1593 fn an_unknown_language_in_a_list_is_reported() {
1594 let project = Project::new(
1595 "unknown-in-list",
1596 &[
1597 ("rule.ts", &member_rule_for("['typescript', 'klingon']")),
1598 ("lanekeep.config.ts", &config_for("src/**/*.ts")),
1599 ("src/plain.ts", "const x = styles.used;\n"),
1600 ],
1601 );
1602
1603 let error = project
1604 .run()
1605 .expect_err("should refuse an unknown language");
1606 assert!(
1607 error.to_string().contains("klingon"),
1608 "the error should name it: {error}"
1609 );
1610 }
1611
1612 #[test]
1613 fn runs_a_rule_over_a_corpus_end_to_end() {
1614 let project = Project::new(
1615 "end-to-end",
1616 &[
1617 ("rule.ts", DEBUGGER_RULE),
1618 ("lanekeep.config.ts", &config("")),
1619 ("src/clean.ts", "const a = 1;\n"),
1620 ("src/dirty.ts", "const b = 2;\ndebugger;\n"),
1621 ("src/also.ts", "function f() {\n debugger;\n}\n"),
1622 ],
1623 );
1624
1625 let outcome = project.run().expect("runs");
1626
1627 assert_eq!(outcome.violations.len(), 2, "{:?}", outcome.violations);
1628 let rendered: Vec<String> = outcome
1629 .violations
1630 .iter()
1631 .map(|v| format!("{} {}", v.rule_id, v.location))
1632 .collect();
1633 assert_eq!(
1634 rendered,
1635 [
1636 "local/no-debugger src/also.ts:2:3",
1637 "local/no-debugger src/dirty.ts:2:1",
1638 ]
1639 );
1640 assert_eq!(outcome.violations[0].message, "debugger statement");
1641 assert_eq!(
1642 outcome.violations[0].remediation,
1643 "remove it before committing"
1644 );
1645 }
1646
1647 #[test]
1648 fn output_is_identical_across_repeated_runs() {
1649 let mut files = vec![
1653 ("rule.ts".to_owned(), DEBUGGER_RULE.to_owned()),
1654 ("lanekeep.config.ts".to_owned(), config("")),
1655 ];
1656 for i in 0..40 {
1657 files.push((
1658 format!("src/f{i}.ts"),
1659 format!("const x{i} = 1;\ndebugger;\n"),
1660 ));
1661 }
1662 let borrowed: Vec<(&str, &str)> = files
1663 .iter()
1664 .map(|(a, b)| (a.as_str(), b.as_str()))
1665 .collect();
1666 let project = Project::new("determinism", &borrowed);
1667
1668 let first = project.run().expect("runs").violations;
1669 assert_eq!(first.len(), 40);
1670
1671 for _ in 0..4 {
1672 assert_eq!(project.run().expect("runs").violations, first);
1673 }
1674 }
1675
1676 #[test]
1677 fn exclude_keeps_files_out_of_the_run() {
1678 let project = Project::new(
1679 "exclude",
1680 &[
1681 ("rule.ts", DEBUGGER_RULE),
1682 ("lanekeep.config.ts", &config(", exclude: ['**/*.test.ts']")),
1683 ("src/a.ts", "debugger;\n"),
1684 ("src/a.test.ts", "debugger;\n"),
1685 ],
1686 );
1687
1688 let outcome = project.run().expect("runs");
1689 assert_eq!(outcome.violations.len(), 1);
1690 assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
1691 }
1692
1693 #[test]
1694 fn a_content_gate_skips_the_parse() {
1695 let gated = "import { defineRule } from 'lanekeep';\n\
1698 export default defineRule({\n\
1699 id: 'local/no-debugger',\n\
1700 query: '(debugger_statement) @stmt',\n\
1701 gates: { fileContains: ['debugger'] },\n\
1702 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
1703 check(ctx, m) { ctx.report(m.stmt); },\n\
1704 });\n";
1705
1706 let project = Project::new(
1707 "gate",
1708 &[
1709 ("rule.ts", gated),
1710 ("lanekeep.config.ts", &config("")),
1711 ("src/a.ts", "debugger;\n"),
1712 ("src/b.ts", "const b = 1;\n"),
1713 ("src/c.ts", "const c = 2;\n"),
1714 ],
1715 );
1716
1717 let outcome = project.run().expect("runs");
1718 assert_eq!(outcome.files_discovered, 3);
1719 assert_eq!(
1720 outcome.files_parsed, 1,
1721 "only the file containing the needle should parse"
1722 );
1723 assert_eq!(outcome.violations.len(), 1);
1724 }
1725
1726 #[test]
1727 fn a_rule_set_to_off_does_not_run() {
1728 let project = Project::new(
1729 "off",
1730 &[
1731 ("rule.ts", DEBUGGER_RULE),
1732 (
1733 "lanekeep.config.ts",
1734 &config(", severity: { 'local/no-debugger': 'off' }"),
1735 ),
1736 ("src/a.ts", "debugger;\n"),
1737 ],
1738 );
1739 assert!(project.run().expect("runs").violations.is_empty());
1740 }
1741
1742 #[test]
1743 fn severity_reaches_the_violation() {
1744 let project = Project::new(
1745 "severity",
1746 &[
1747 ("rule.ts", DEBUGGER_RULE),
1748 (
1749 "lanekeep.config.ts",
1750 &config(", severity: { 'local/no-debugger': 'warn' }"),
1751 ),
1752 ("src/a.ts", "debugger;\n"),
1753 ],
1754 );
1755 let outcome = project.run().expect("runs");
1756 assert_eq!(outcome.violations[0].severity, Severity::Warn);
1757 assert!(!any_failing(&outcome.violations));
1758 }
1759
1760 #[test]
1761 fn a_rule_that_throws_aborts_the_run_naming_itself_and_the_file() {
1762 let throwing = "import { defineRule } from 'lanekeep';\n\
1765 export default defineRule({\n\
1766 id: 'local/throws',\n\
1767 query: '(debugger_statement) @stmt',\n\
1768 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
1769 check() { throw new Error('rule bug'); },\n\
1770 });\n";
1771
1772 let project = Project::new(
1773 "throws",
1774 &[
1775 ("rule.ts", throwing),
1776 ("lanekeep.config.ts", &config("")),
1777 ("src/a.ts", "debugger;\n"),
1778 ],
1779 );
1780
1781 let err = project.run().expect_err("must abort");
1782 let rendered = err.to_string();
1783 assert!(rendered.contains("local/throws"), "{rendered}");
1784 assert!(rendered.contains("src/a.ts"), "{rendered}");
1785 assert!(rendered.contains("rule bug"), "{rendered}");
1786 }
1787
1788 #[test]
1789 fn an_invalid_query_fails_before_any_file_is_read() {
1790 let bad = "import { defineRule } from 'lanekeep';\n\
1791 export default defineRule({\n\
1792 id: 'local/bad-query',\n\
1793 query: '(no_such_node) @x',\n\
1794 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
1795 check() {},\n\
1796 });\n";
1797
1798 let project = Project::new(
1799 "bad-query",
1800 &[
1801 ("rule.ts", bad),
1802 ("lanekeep.config.ts", &config("")),
1803 ("src/a.ts", "debugger;\n"),
1804 ],
1805 );
1806
1807 let err = project.run().expect_err("must fail at preparation");
1808 assert!(matches!(err, RunError::Query { .. }), "{err:?}");
1809 assert!(err.to_string().contains("no_such_node"), "{err}");
1810 }
1811
1812 #[test]
1813 fn a_file_is_parsed_once_however_many_rules_run_on_it() {
1814 let source = include_str!("lib.rs");
1825 let body = source.split("#[cfg(test)]").next().unwrap_or(source);
1826 let parsers = body.matches("tree_sitter::Parser::new()").count();
1827
1828 assert_eq!(
1829 parsers, 1,
1830 "the engine constructs {parsers} tree-sitter parsers outside tests; there must be \
1831 exactly one, in `check_file`, shared by every rule that runs on the file"
1832 );
1833 }
1834
1835 #[test]
1836 fn two_broken_queries_always_name_the_same_rule() {
1837 let broken = |name: &str| {
1843 format!(
1844 "import {{ defineRule }} from 'lanekeep';\n\
1845 export default defineRule({{\n\
1846 id: 'local/{name}',\n\
1847 query: '(no_such_node_{name}) @x',\n\
1848 card: {{ message: 'm', remediation: 'r', examples: {{ bad: 'a', good: 'b' }} }},\n\
1849 check() {{}},\n\
1850 }});\n"
1851 )
1852 };
1853
1854 let config = "import { defineConfig } from 'lanekeep';\n\
1855 import first from './first';\n\
1856 import second from './second';\n\
1857 export default defineConfig({ include: ['src/**/*.ts'], rules: [first, second] });\n";
1858
1859 for attempt in 0..12 {
1861 let project = Project::new(
1862 &format!("two-broken-{attempt}"),
1863 &[
1864 ("first.ts", &broken("first")),
1865 ("second.ts", &broken("second")),
1866 ("lanekeep.config.ts", config),
1867 ("src/a.ts", "debugger;\n"),
1868 ],
1869 );
1870
1871 let err = project.run().expect_err("must fail at preparation");
1872 assert!(
1873 err.to_string().contains("no_such_node_first"),
1874 "attempt {attempt} named the wrong rule: {err}"
1875 );
1876 }
1877 }
1878
1879 #[test]
1880 fn a_rule_can_use_the_host_api_it_was_given() {
1881 let rule = "import { defineRule } from 'lanekeep';\n\
1884 export default defineRule({\n\
1885 id: 'local/long-names',\n\
1886 query: '(variable_declarator name: (identifier) @name)',\n\
1887 card: { message: 'name too long', remediation: 'shorten it', examples: { bad: 'a', good: 'b' } },\n\
1888 check(ctx, m) {\n\
1889 if (ctx.text(m.name).length > 5) ctx.report(m.name, `\\\"${ctx.text(m.name)}\\\" is too long`);\n\
1890 },\n\
1891 });\n";
1892
1893 let project = Project::new(
1894 "host-api",
1895 &[
1896 ("rule.ts", rule),
1897 ("lanekeep.config.ts", &config("")),
1898 ("src/a.ts", "const ok = 1;\nconst wayTooLong = 2;\n"),
1899 ],
1900 );
1901
1902 let outcome = project.run().expect("runs");
1903 assert_eq!(outcome.violations.len(), 1);
1904 assert!(
1905 outcome.violations[0].message.contains("wayTooLong"),
1906 "{:?}",
1907 outcome.violations[0]
1908 );
1909 }
1910
1911 #[test]
1912 fn a_corpus_with_no_matches_produces_nothing() {
1913 let project = Project::new(
1914 "clean",
1915 &[
1916 ("rule.ts", DEBUGGER_RULE),
1917 ("lanekeep.config.ts", &config("")),
1918 ("src/a.ts", "const a = 1;\n"),
1919 ],
1920 );
1921 let outcome = project.run().expect("runs");
1922 assert!(outcome.violations.is_empty());
1923 assert_eq!(outcome.files_parsed, 1, "no gates means it is still parsed");
1924 }
1925
1926 const UNUSED_EXPORTS_RULE: &str = r"import { defineRule } from 'lanekeep';
1933export default defineRule({
1934 id: 'local/no-unused-exports',
1935 query: `
1936 (export_statement declaration: (function_declaration name: (identifier) @name)) @stmt
1937 (import_statement (import_clause (named_imports (import_specifier name: (identifier) @imported))))
1938 `,
1939 card: {
1940 message: 'unused export',
1941 remediation: 'delete it, or import it somewhere',
1942 examples: { bad: 'export function unused() {}', good: 'function used() {}' },
1943 },
1944 check(ctx, m) {
1945 if (m.imported) {
1946 ctx.emitFact({ kind: 'import', symbol: ctx.text(m.imported) });
1947 return;
1948 }
1949 ctx.emitFact({
1950 kind: 'export',
1951 symbol: ctx.text(m.name),
1952 line: ctx.line(m.stmt),
1953 column: ctx.column(m.stmt),
1954 });
1955 },
1956 reduce(ctx) {
1957 const imported = new Set(ctx.facts('import').map((f) => f.symbol));
1958 for (const e of ctx.facts('export')) {
1959 if (!imported.has(e.symbol)) {
1960 ctx.report({ file: e.file, line: e.line, column: e.column }, `'${e.symbol}' is exported but never imported`);
1961 }
1962 }
1963 },
1964});
1965";
1966
1967 #[test]
1968 fn a_reduce_phase_sees_facts_from_every_file() {
1969 let project = Project::new(
1970 "reduce-cross-file",
1971 &[
1972 ("rule.ts", UNUSED_EXPORTS_RULE),
1973 ("lanekeep.config.ts", &config("")),
1974 (
1975 "src/a.ts",
1976 "export function used() {}\nexport function spare() {}\n",
1977 ),
1978 ("src/b.ts", "import { used } from './a';\nused();\n"),
1979 ],
1980 );
1981
1982 let outcome = project.run().expect("runs");
1983 let found: Vec<(&str, u32, &str)> = outcome
1984 .violations
1985 .iter()
1986 .map(|v| {
1987 (
1988 v.location.file.as_str(),
1989 v.location.position.line,
1990 v.message.as_str(),
1991 )
1992 })
1993 .collect();
1994
1995 assert_eq!(
1996 found,
1997 vec![("src/a.ts", 2, "'spare' is exported but never imported")],
1998 "only the export nobody imports should be reported"
1999 );
2000 }
2001
2002 #[test]
2003 fn a_rule_with_no_reduce_still_runs() {
2004 let project = Project::new(
2006 "reduce-absent",
2007 &[
2008 ("rule.ts", DEBUGGER_RULE),
2009 ("lanekeep.config.ts", &config("")),
2010 ("src/a.ts", "debugger;\n"),
2011 ],
2012 );
2013 let outcome = project.run().expect("runs");
2014 assert_eq!(outcome.violations.len(), 1);
2015 }
2016
2017 #[test]
2018 fn a_reduce_phase_with_no_facts_reports_nothing() {
2019 let project = Project::new(
2020 "reduce-empty",
2021 &[
2022 ("rule.ts", UNUSED_EXPORTS_RULE),
2023 ("lanekeep.config.ts", &config("")),
2024 ("src/a.ts", "const a = 1;\n"),
2025 ],
2026 );
2027 assert!(project.run().expect("runs").violations.is_empty());
2028 }
2029
2030 #[test]
2031 fn the_file_list_reaches_the_reduce_phase() {
2032 const RULE: &str = r"import { defineRule } from 'lanekeep';
2033export default defineRule({
2034 id: 'local/counts-files',
2035 query: '(debugger_statement) @stmt',
2036 card: {
2037 message: 'file count',
2038 remediation: 'nothing to do',
2039 examples: { bad: 'a', good: 'b' },
2040 },
2041 check() {},
2042 reduce(ctx) {
2043 ctx.report({ file: ctx.files[0], line: ctx.files.length, column: 1 });
2044 },
2045});
2046";
2047 let project = Project::new(
2048 "reduce-files",
2049 &[
2050 ("rule.ts", RULE),
2051 ("lanekeep.config.ts", &config("")),
2052 ("src/a.ts", "const a = 1;\n"),
2053 ("src/b.ts", "const b = 1;\n"),
2054 ],
2055 );
2056
2057 let outcome = project.run().expect("runs");
2058 assert_eq!(outcome.violations.len(), 1);
2059 assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
2061 assert_eq!(outcome.violations[0].location.position.line, 2);
2062 }
2063
2064 #[test]
2065 fn a_rule_does_not_see_another_rules_facts() {
2066 const EMITTER: &str = r"import { defineRule } from 'lanekeep';
2069export default defineRule({
2070 id: 'local/emitter',
2071 query: '(export_statement) @stmt',
2072 card: { message: 'emitter', remediation: 'x', examples: { bad: 'a', good: 'b' } },
2073 check(ctx, m) { ctx.emitFact({ kind: 'thing', from: 'emitter' }); },
2074});
2075";
2076 const READER: &str = r"import { defineRule } from 'lanekeep';
2077export default defineRule({
2078 id: 'local/reader',
2079 query: '(export_statement) @stmt',
2080 card: { message: 'reader', remediation: 'x', examples: { bad: 'a', good: 'b' } },
2081 check() {},
2082 reduce(ctx) {
2083 ctx.report({ file: 'seen.ts', line: ctx.facts().length + 1, column: 1 });
2084 },
2085});
2086";
2087 let project = Project::new(
2088 "reduce-isolation",
2089 &[
2090 ("emitter.ts", EMITTER),
2091 ("reader.ts", READER),
2092 (
2093 "lanekeep.config.ts",
2094 "import { defineConfig } from 'lanekeep';\n\
2095 import emitter from './emitter';\n\
2096 import reader from './reader';\n\
2097 export default defineConfig({ include: ['src/**/*.ts'], rules: [emitter, reader] });\n",
2098 ),
2099 ("src/a.ts", "export const a = 1;\n"),
2100 ],
2101 );
2102
2103 let outcome = project.run().expect("runs");
2104 assert_eq!(outcome.violations.len(), 1);
2105 assert_eq!(
2106 outcome.violations[0].location.position.line, 1,
2107 "the reader saw the emitter's facts"
2108 );
2109 }
2110
2111 #[test]
2112 fn a_reduce_phase_that_throws_aborts_the_run() {
2113 const RULE: &str = r"import { defineRule } from 'lanekeep';
2116export default defineRule({
2117 id: 'local/throws-in-reduce',
2118 query: '(debugger_statement) @stmt',
2119 card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
2120 check() {},
2121 reduce() { throw new Error('reduce exploded'); },
2122});
2123";
2124 let project = Project::new(
2125 "reduce-throws",
2126 &[
2127 ("rule.ts", RULE),
2128 ("lanekeep.config.ts", &config("")),
2129 ("src/a.ts", "const a = 1;\n"),
2130 ],
2131 );
2132
2133 let error = project.run().expect_err("aborts");
2134 let rendered = error.to_string();
2135 assert!(rendered.contains("reduce exploded"), "{rendered}");
2136 assert!(
2137 rendered.contains("local/throws-in-reduce"),
2138 "the error should name the rule: {rendered}"
2139 );
2140 }
2141
2142 #[test]
2143 fn facts_reach_reduce_in_the_same_order_on_every_run() {
2144 const RULE: &str = r"import { defineRule } from 'lanekeep';
2153export default defineRule({
2154 id: 'local/first-fact-wins',
2155 query: '(export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name)))',
2156 card: { message: 'first', remediation: 'x', examples: { bad: 'a', good: 'b' } },
2157 check(ctx, m) { ctx.emitFact({ kind: 'sym', symbol: ctx.text(m.name) }); },
2158 reduce(ctx) {
2159 const all = ctx.facts('sym');
2160 ctx.report({ file: 'order.ts', line: 1, column: 1 }, all.map((f) => `${f.file}:${f.symbol}`).join(','));
2161 },
2162});
2163";
2164 let files: Vec<(String, String)> = (0..12)
2165 .map(|i| {
2166 (
2167 format!("src/f{i:02}.ts"),
2168 format!("export const s{i:02} = {i};\n"),
2169 )
2170 })
2171 .collect();
2172
2173 let mut layout: Vec<(&str, &str)> = vec![("rule.ts", RULE)];
2174 let config_source = config("");
2175 layout.push(("lanekeep.config.ts", &config_source));
2176 for (path, contents) in &files {
2177 layout.push((path, contents));
2178 }
2179
2180 let project = Project::new("reduce-determinism", &layout);
2181
2182 let first = project.run().expect("runs").violations[0].message.clone();
2183 for attempt in 0..4 {
2184 let again = project.run().expect("runs").violations[0].message.clone();
2185 assert_eq!(again, first, "fact order changed on attempt {attempt}");
2186 }
2187
2188 assert!(
2190 first.starts_with("src/f00.ts:s00,src/f01.ts:s01,"),
2191 "facts are not in (file, sequence) order: {first}"
2192 );
2193 }
2194
2195 #[test]
2196 fn a_rule_cannot_misattribute_a_fact_to_another_file() {
2197 const RULE: &str = r"import { defineRule } from 'lanekeep';
2199export default defineRule({
2200 id: 'local/lying-fact',
2201 query: '(export_statement) @stmt',
2202 card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
2203 check(ctx, m) { ctx.emitFact({ kind: 'e', file: 'somewhere-else.ts' }); },
2204 reduce(ctx) {
2205 for (const f of ctx.facts('e')) ctx.report({ file: f.file, line: 1, column: 1 });
2206 },
2207});
2208";
2209 let project = Project::new(
2210 "reduce-misattribution",
2211 &[
2212 ("rule.ts", RULE),
2213 ("lanekeep.config.ts", &config("")),
2214 ("src/a.ts", "export const a = 1;\n"),
2215 ],
2216 );
2217
2218 let outcome = project.run().expect("runs");
2219 assert_eq!(outcome.violations.len(), 1);
2220 assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
2221 }
2222
2223 const READING_RULE: &str = r"import { defineRule } from 'lanekeep';
2227export default defineRule({
2228 id: 'local/reads-config',
2229 query: '(export_statement) @stmt',
2230 card: {
2231 message: 'config says no',
2232 remediation: 'change the config, or the code',
2233 examples: { bad: 'export const a = 1;', good: 'const a = 1;' },
2234 },
2235 check(ctx, m) {
2236 const raw = ctx.readFile('policy.json');
2237 if (raw && JSON.parse(raw).forbidExports) ctx.report(m.stmt);
2238 },
2239});
2240";
2241
2242 #[test]
2243 fn a_rule_can_read_another_file() {
2244 let project = Project::new(
2245 "reads-allowed",
2246 &[
2247 ("rule.ts", READING_RULE),
2248 ("lanekeep.config.ts", &config("")),
2249 ("policy.json", r#"{"forbidExports":true}"#),
2250 ("src/a.ts", "export const a = 1;\n"),
2251 ],
2252 );
2253 let outcome = project.run().expect("runs");
2254 assert_eq!(outcome.violations.len(), 1, "{:?}", outcome.violations);
2255 }
2256
2257 #[test]
2258 fn what_the_file_says_changes_the_result() {
2259 let project = Project::new(
2261 "reads-content",
2262 &[
2263 ("rule.ts", READING_RULE),
2264 ("lanekeep.config.ts", &config("")),
2265 ("policy.json", r#"{"forbidExports":false}"#),
2266 ("src/a.ts", "export const a = 1;\n"),
2267 ],
2268 );
2269 assert!(project.run().expect("runs").violations.is_empty());
2270 }
2271
2272 #[test]
2273 fn a_read_is_recorded_against_the_file_that_made_it() {
2274 let mut layout: Vec<(String, String)> = vec![
2282 ("rule.ts".to_owned(), READING_RULE.to_owned()),
2283 ("lanekeep.config.ts".to_owned(), config("")),
2284 (
2285 "policy.json".to_owned(),
2286 r#"{"forbidExports":false}"#.to_owned(),
2287 ),
2288 ];
2289 for i in 0..24 {
2291 let body = if i % 2 == 0 {
2292 format!("const v{i} = {i};\n")
2293 } else {
2294 format!("export const v{i} = {i};\n")
2295 };
2296 layout.push((format!("src/f{i:02}.ts"), body));
2297 }
2298 let borrowed: Vec<(&str, &str)> = layout
2299 .iter()
2300 .map(|(p, c)| (p.as_str(), c.as_str()))
2301 .collect();
2302
2303 let project = Project::new("reads-attributed", &borrowed);
2304 let outcome = project.run().expect("runs");
2305
2306 for i in 0..24 {
2307 let file = FilePath::new(format!("src/f{i:02}.ts"));
2308 let deps = outcome.dependencies.get(&file);
2309 if i % 2 == 0 {
2310 assert!(
2311 deps.is_none(),
2312 "src/f{i:02}.ts read nothing but has {deps:?}"
2313 );
2314 } else {
2315 let deps = deps.unwrap_or_else(|| panic!("src/f{i:02}.ts should have read"));
2316 assert_eq!(deps.len(), 1);
2317 assert_eq!(deps[0].path.as_str(), "policy.json");
2318 assert!(deps[0].hash.is_some());
2319 }
2320 }
2321 }
2322
2323 #[test]
2324 fn a_missing_file_is_recorded_as_a_dependency_too() {
2325 const RULE: &str = r"import { defineRule } from 'lanekeep';
2328export default defineRule({
2329 id: 'local/wants-config',
2330 query: '(export_statement) @stmt',
2331 card: { message: 'no config', remediation: 'add one', examples: { bad: 'a', good: 'b' } },
2332 check(ctx, m) {
2333 if (!ctx.fileExists('tsconfig.json')) ctx.report(m.stmt);
2334 },
2335});
2336";
2337 let project = Project::new(
2338 "reads-absent",
2339 &[
2340 ("rule.ts", RULE),
2341 ("lanekeep.config.ts", &config("")),
2342 ("src/a.ts", "export const a = 1;\n"),
2343 ],
2344 );
2345
2346 let outcome = project.run().expect("runs");
2347 assert_eq!(outcome.violations.len(), 1);
2348
2349 let deps = outcome
2350 .dependencies
2351 .get(&FilePath::new("src/a.ts"))
2352 .expect("the miss is a dependency");
2353 assert_eq!(deps.len(), 1);
2354 assert_eq!(deps[0].path.as_str(), "tsconfig.json");
2355 assert_eq!(deps[0].hash, None, "absence is recorded as absence");
2356 }
2357
2358 #[test]
2359 fn reading_outside_the_project_aborts_the_run() {
2360 const RULE: &str = r"import { defineRule } from 'lanekeep';
2364export default defineRule({
2365 id: 'local/escapes',
2366 query: '(export_statement) @stmt',
2367 card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
2368 check(ctx) { ctx.readFile('../../../etc/passwd'); },
2369});
2370";
2371 let project = Project::new(
2372 "reads-escape",
2373 &[
2374 ("rule.ts", RULE),
2375 ("lanekeep.config.ts", &config("")),
2376 ("src/a.ts", "export const a = 1;\n"),
2377 ],
2378 );
2379
2380 let error = project.run().expect_err("aborts");
2381 let rendered = error.to_string();
2382 assert!(rendered.contains("outside the project root"), "{rendered}");
2383 assert!(rendered.contains("local/escapes"), "{rendered}");
2384 }
2385
2386 #[test]
2387 fn reading_the_same_file_from_two_files_records_it_under_both() {
2388 let project = Project::new(
2389 "reads-shared",
2390 &[
2391 ("rule.ts", READING_RULE),
2392 ("lanekeep.config.ts", &config("")),
2393 ("policy.json", r#"{"forbidExports":false}"#),
2394 ("src/a.ts", "export const a = 1;\n"),
2395 ("src/b.ts", "export const b = 1;\n"),
2396 ],
2397 );
2398
2399 let outcome = project.run().expect("runs");
2400 for file in ["src/a.ts", "src/b.ts"] {
2401 let deps = outcome
2402 .dependencies
2403 .get(&FilePath::new(file))
2404 .unwrap_or_else(|| panic!("{file} should depend on the policy"));
2405 assert_eq!(deps[0].path.as_str(), "policy.json");
2406 }
2407
2408 let a = &outcome.dependencies[&FilePath::new("src/a.ts")][0];
2411 let b = &outcome.dependencies[&FilePath::new("src/b.ts")][0];
2412 assert_eq!(a.hash, b.hash);
2413 }
2414
2415 #[test]
2416 fn dependencies_are_the_same_on_every_run() {
2417 let project = Project::new(
2418 "reads-deterministic",
2419 &[
2420 ("rule.ts", READING_RULE),
2421 ("lanekeep.config.ts", &config("")),
2422 ("policy.json", r#"{"forbidExports":false}"#),
2423 ("src/a.ts", "export const a = 1;\n"),
2424 ("src/b.ts", "export const b = 1;\n"),
2425 ("src/c.ts", "export const c = 1;\n"),
2426 ],
2427 );
2428 let first = project.run().expect("runs").dependencies;
2429 assert!(!first.is_empty());
2430 for attempt in 0..4 {
2431 assert_eq!(
2432 project.run().expect("runs").dependencies,
2433 first,
2434 "dependencies changed on attempt {attempt}"
2435 );
2436 }
2437 }
2438
2439 #[test]
2440 fn the_read_surface_is_absent_from_the_reduce_phase() {
2441 const RULE: &str = r"import { defineRule } from 'lanekeep';
2445export default defineRule({
2446 id: 'local/reduce-reads',
2447 query: '(export_statement) @stmt',
2448 card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
2449 check() {},
2450 reduce(ctx) {
2451 const absent = ctx.readFile === undefined && ctx.fileExists === undefined;
2452 ctx.report({ file: 'probe.ts', line: absent ? 1 : 2, column: 1 });
2453 },
2454});
2455";
2456 let project = Project::new(
2457 "reads-reduce",
2458 &[
2459 ("rule.ts", RULE),
2460 ("lanekeep.config.ts", &config("")),
2461 ("src/a.ts", "export const a = 1;\n"),
2462 ],
2463 );
2464
2465 let outcome = project.run().expect("runs");
2466 assert_eq!(outcome.violations.len(), 1);
2467 assert_eq!(
2468 outcome.violations[0].location.position.line, 1,
2469 "reads must not be reachable from a reduce phase"
2470 );
2471 }
2472
2473 impl Project {
2476 fn run_cold(&self) -> Result<Outcome, RunError> {
2478 self.build().map(Engine::without_cache)?.run()
2479 }
2480
2481 fn build(&self) -> Result<Engine, RunError> {
2483 let root = RuleRoot::new(&self.dir).expect("canonicalizes");
2484 let config_path = self.dir.join("lanekeep.config.ts");
2485 let sandbox =
2486 lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
2487 .expect("sandbox");
2488 let config = lanekeep_config::load(&sandbox, &root, &config_path)
2489 .unwrap_or_else(|e| panic!("config failed to load: {e}"));
2490 Engine::prepare(
2491 &config,
2492 &self.dir,
2493 root,
2494 &config_path,
2495 &lanekeep_lang_js::registry(),
2496 Arc::new(TypeScript),
2497 Arc::new(JavaScript),
2498 )
2499 }
2500
2501 fn cache(&self) -> Store {
2502 Store::load(&self.dir)
2503 }
2504 }
2505
2506 fn rendered(outcome: &Outcome) -> Vec<String> {
2507 outcome
2508 .violations
2509 .iter()
2510 .map(|v| {
2511 format!(
2512 "{}:{}:{} {} {}",
2513 v.location.file.as_str(),
2514 v.location.position.line,
2515 v.location.position.column,
2516 v.rule_id,
2517 v.message
2518 )
2519 })
2520 .collect()
2521 }
2522
2523 #[test]
2524 fn a_warm_run_agrees_with_a_cold_one() {
2525 let project = Project::new(
2526 "cache-agrees",
2527 &[
2528 ("rule.ts", DEBUGGER_RULE),
2529 ("lanekeep.config.ts", &config("")),
2530 ("src/a.ts", "debugger;\nconst a = 1;\n"),
2531 ("src/b.ts", "const b = 1;\ndebugger;\n"),
2532 ("src/c.ts", "const c = 1;\n"),
2533 ],
2534 );
2535
2536 let cold = rendered(&project.run().expect("runs"));
2537 let warm = rendered(&project.run().expect("runs"));
2538 assert_eq!(warm, cold, "the cache changed the answer");
2539 assert!(!cold.is_empty(), "the fixture should report something");
2540 }
2541
2542 #[test]
2543 fn a_run_writes_a_cache() {
2544 let project = Project::new(
2545 "cache-written",
2546 &[
2547 ("rule.ts", DEBUGGER_RULE),
2548 ("lanekeep.config.ts", &config("")),
2549 ("src/a.ts", "debugger;\n"),
2550 ],
2551 );
2552 assert!(project.cache().is_empty(), "nothing before the first run");
2553 project.run().expect("runs");
2554 assert!(!project.cache().is_empty(), "the run stored nothing");
2555 }
2556
2557 #[test]
2558 fn a_cached_result_is_actually_used() {
2559 let project = Project::new(
2563 "cache-used",
2564 &[
2565 ("rule.ts", DEBUGGER_RULE),
2566 ("lanekeep.config.ts", &config("")),
2567 ("src/a.ts", "const a = 1;\n"),
2568 ],
2569 );
2570 assert!(project.run().expect("runs").violations.is_empty());
2571
2572 let store = project.cache();
2573 let key = *store
2574 .keys()
2575 .next()
2576 .expect("the run stored an entry for the file");
2577
2578 let mut doctored = Store::empty();
2579 doctored.insert(
2580 key,
2581 lanekeep_cache::Entry {
2582 violations: vec![Violation {
2583 rule_id: "local/no-debugger".parse().expect("valid id"),
2584 location: Location::new(FilePath::new("src/a.ts"), Position::new(7, 3)),
2585 message: "from the cache".to_owned(),
2586 remediation: "nothing".to_owned(),
2587 severity: Severity::Error,
2588 fix: None,
2589 }],
2590 facts: Vec::new(),
2591 dependencies: Vec::new(),
2592 suppressions: Vec::new(),
2593 used_suppressions: Vec::new(),
2594 },
2595 );
2596 doctored.save(&project.dir);
2597
2598 let outcome = project.run().expect("runs");
2599 assert_eq!(
2600 rendered(&outcome),
2601 vec!["src/a.ts:7:3 local/no-debugger from the cache"],
2602 "the cached entry was not used"
2603 );
2604 }
2605
2606 #[test]
2607 fn editing_a_file_invalidates_it() {
2608 let project = Project::new(
2609 "cache-edited",
2610 &[
2611 ("rule.ts", DEBUGGER_RULE),
2612 ("lanekeep.config.ts", &config("")),
2613 ("src/a.ts", "const a = 1;\n"),
2614 ],
2615 );
2616 assert!(project.run().expect("runs").violations.is_empty());
2617
2618 project.write("src/a.ts", "debugger;\n");
2619 assert_eq!(
2620 project.run().expect("runs").violations.len(),
2621 1,
2622 "an edited file kept its stale result"
2623 );
2624 }
2625
2626 #[test]
2627 fn moving_a_file_invalidates_it() {
2628 let project = Project::new(
2631 "cache-moved",
2632 &[
2633 ("rule.ts", DEBUGGER_RULE),
2634 ("lanekeep.config.ts", &config("")),
2635 ("src/a.ts", "debugger;\n"),
2636 ],
2637 );
2638 project.run().expect("runs");
2639
2640 fs::remove_file(project.dir.join("src/a.ts")).expect("removes");
2641 project.write("src/moved.ts", "debugger;\n");
2642
2643 let outcome = project.run().expect("runs");
2644 assert_eq!(
2645 outcome.violations[0].location.file.as_str(),
2646 "src/moved.ts",
2647 "the violation followed the old path"
2648 );
2649 }
2650
2651 #[test]
2652 fn editing_a_tracked_dependency_invalidates_the_files_that_read_it() {
2653 let project = Project::new(
2656 "cache-dependency",
2657 &[
2658 ("rule.ts", READING_RULE),
2659 ("lanekeep.config.ts", &config("")),
2660 ("policy.json", r#"{"forbidExports":false}"#),
2661 ("src/a.ts", "export const a = 1;\n"),
2662 ],
2663 );
2664 assert!(project.run().expect("runs").violations.is_empty());
2665
2666 project.write("policy.json", r#"{"forbidExports":true}"#);
2667 assert_eq!(
2668 project.run().expect("runs").violations.len(),
2669 1,
2670 "a changed dependency did not invalidate"
2671 );
2672 }
2673
2674 #[test]
2675 fn a_dependency_that_appears_invalidates() {
2676 const RULE: &str = r"import { defineRule } from 'lanekeep';
2679export default defineRule({
2680 id: 'local/wants-config',
2681 query: '(export_statement) @stmt',
2682 card: { message: 'no config', remediation: 'add one', examples: { bad: 'a', good: 'b' } },
2683 check(ctx, m) {
2684 if (!ctx.fileExists('tsconfig.json')) ctx.report(m.stmt);
2685 },
2686});
2687";
2688 let project = Project::new(
2689 "cache-appeared",
2690 &[
2691 ("rule.ts", RULE),
2692 ("lanekeep.config.ts", &config("")),
2693 ("src/a.ts", "export const a = 1;\n"),
2694 ],
2695 );
2696 assert_eq!(project.run().expect("runs").violations.len(), 1);
2697
2698 project.write("tsconfig.json", "{}");
2699 assert!(
2700 project.run().expect("runs").violations.is_empty(),
2701 "a dependency that appeared did not invalidate"
2702 );
2703 }
2704
2705 #[test]
2706 fn changing_the_ruleset_invalidates_everything() {
2707 let project = Project::new(
2708 "cache-ruleset",
2709 &[
2710 ("rule.ts", DEBUGGER_RULE),
2711 ("lanekeep.config.ts", &config("")),
2712 ("src/a.ts", "debugger;\n"),
2713 ],
2714 );
2715 assert_eq!(project.run().expect("runs").violations.len(), 1);
2716
2717 project.write(
2719 "rule.ts",
2720 &DEBUGGER_RULE.replace("ctx.report(m.stmt);", "/* nothing */"),
2721 );
2722 assert!(
2723 project.run().expect("runs").violations.is_empty(),
2724 "an edited rule kept its stale results"
2725 );
2726 }
2727
2728 #[test]
2729 fn changing_the_config_invalidates_everything() {
2730 let project = Project::new(
2731 "cache-config",
2732 &[
2733 ("rule.ts", DEBUGGER_RULE),
2734 ("lanekeep.config.ts", &config("")),
2735 ("src/a.ts", "debugger;\n"),
2736 ],
2737 );
2738 assert_eq!(project.run().expect("runs").violations.len(), 1);
2739
2740 project.write(
2741 "lanekeep.config.ts",
2742 &config(", severity: { 'local/no-debugger': 'off' }"),
2743 );
2744 assert!(
2745 project.run().expect("runs").violations.is_empty(),
2746 "a config change did not invalidate"
2747 );
2748 }
2749
2750 #[test]
2751 fn a_corrupt_cache_still_produces_the_right_answer() {
2752 let project = Project::new(
2754 "cache-corrupt",
2755 &[
2756 ("rule.ts", DEBUGGER_RULE),
2757 ("lanekeep.config.ts", &config("")),
2758 ("src/a.ts", "debugger;\n"),
2759 ],
2760 );
2761 let expected = rendered(&project.run().expect("runs"));
2762
2763 let path = Store::path_for(&project.dir);
2764 fs::write(&path, b"\x00\x01\x02 not a cache").expect("writes");
2765
2766 assert_eq!(rendered(&project.run().expect("runs")), expected);
2767 }
2768
2769 #[test]
2770 fn caching_can_be_turned_off() {
2771 let project = Project::new(
2772 "cache-off",
2773 &[
2774 ("rule.ts", DEBUGGER_RULE),
2775 ("lanekeep.config.ts", &config("")),
2776 ("src/a.ts", "debugger;\n"),
2777 ],
2778 );
2779 let outcome = project.run_cold().expect("runs");
2780 assert_eq!(outcome.violations.len(), 1);
2781 assert!(
2782 project.cache().is_empty(),
2783 "a run with caching off wrote a cache"
2784 );
2785 }
2786
2787 #[test]
2788 fn facts_survive_a_warm_run() {
2789 let project = Project::new(
2794 "cache-facts",
2795 &[
2796 ("rule.ts", UNUSED_EXPORTS_RULE),
2797 ("lanekeep.config.ts", &config("")),
2798 (
2799 "src/a.ts",
2800 "export function used() {}\nexport function spare() {}\n",
2801 ),
2802 ("src/b.ts", "import { used } from './a';\nused();\n"),
2803 ],
2804 );
2805
2806 let cold = rendered(&project.run().expect("runs"));
2807 assert_eq!(cold.len(), 1, "{cold:?}");
2808 assert_eq!(rendered(&project.run().expect("runs")), cold);
2809 assert_eq!(rendered(&project.run().expect("runs")), cold);
2810 }
2811
2812 #[test]
2813 fn a_cache_file_does_not_churn() {
2814 let project = Project::new(
2817 "cache-stable",
2818 &[
2819 ("rule.ts", DEBUGGER_RULE),
2820 ("lanekeep.config.ts", &config("")),
2821 ("src/a.ts", "debugger;\n"),
2822 ("src/b.ts", "const b = 1;\n"),
2823 ],
2824 );
2825 project.run().expect("runs");
2826 let first = fs::read(Store::path_for(&project.dir)).expect("reads");
2827 project.run().expect("runs");
2828 let second = fs::read(Store::path_for(&project.dir)).expect("reads");
2829 assert_eq!(first, second, "the cache file churned");
2830 }
2831
2832 #[test]
2833 fn entries_for_deleted_files_do_not_accumulate() {
2834 let project = Project::new(
2835 "cache-prune",
2836 &[
2837 ("rule.ts", DEBUGGER_RULE),
2838 ("lanekeep.config.ts", &config("")),
2839 ("src/a.ts", "debugger;\n"),
2840 ("src/b.ts", "debugger;\n"),
2841 ],
2842 );
2843 project.run().expect("runs");
2844 assert_eq!(project.cache().len(), 2);
2845
2846 fs::remove_file(project.dir.join("src/b.ts")).expect("removes");
2847 project.run().expect("runs");
2848 assert_eq!(
2849 project.cache().len(),
2850 1,
2851 "an entry outlived the file it was for"
2852 );
2853 }
2854
2855 #[test]
2856 fn a_partial_run_does_not_discard_other_files_entries() {
2857 let project = Project::new(
2861 "cache-partial",
2862 &[
2863 ("rule.ts", DEBUGGER_RULE),
2864 ("lanekeep.config.ts", &config("")),
2865 ("src/a.ts", "debugger;\n"),
2866 ("src/b.ts", "const b = 1;\n"),
2867 ("src/c.ts", "const c = 1;\n"),
2868 ],
2869 );
2870 project.run().expect("runs");
2871 assert_eq!(project.cache().len(), 3);
2872
2873 let engine = project.build().expect("prepares");
2874 engine
2875 .run_over(&[FilePath::new("src/a.ts")])
2876 .expect("runs over one file");
2877
2878 assert_eq!(
2879 project.cache().len(),
2880 3,
2881 "a partial run discarded entries for files it did not look at"
2882 );
2883 }
2884
2885 #[test]
2886 fn a_full_run_still_prunes() {
2887 let project = Project::new(
2890 "cache-prune-still",
2891 &[
2892 ("rule.ts", DEBUGGER_RULE),
2893 ("lanekeep.config.ts", &config("")),
2894 ("src/a.ts", "debugger;\n"),
2895 ("src/b.ts", "const b = 1;\n"),
2896 ],
2897 );
2898 project.run().expect("runs");
2899 assert_eq!(project.cache().len(), 2);
2900
2901 fs::remove_file(project.dir.join("src/b.ts")).expect("removes");
2902 project.run().expect("runs");
2903 assert_eq!(project.cache().len(), 1);
2904 }
2905
2906 impl Project {
2909 fn run_on(&self, today: &str) -> Result<Outcome, RunError> {
2911 let date = Date::parse(today).expect("valid date");
2912 self.build().map(|engine| engine.with_today(date))?.run()
2913 }
2914 }
2915
2916 fn messages(outcome: &Outcome) -> Vec<&str> {
2917 outcome
2918 .violations
2919 .iter()
2920 .map(|v| v.message.as_str())
2921 .collect()
2922 }
2923
2924 #[test]
2925 fn a_next_line_directive_silences_the_line_below_it() {
2926 let project = Project::new(
2927 "suppress-next-line",
2928 &[
2929 ("rule.ts", DEBUGGER_RULE),
2930 ("lanekeep.config.ts", &config("")),
2931 (
2932 "src/a.ts",
2933 "// lanekeep-ignore-next-line local/no-debugger reason: legacy entry point\n\
2934 debugger;\n",
2935 ),
2936 ],
2937 );
2938 assert!(
2939 project.run().expect("runs").violations.is_empty(),
2940 "the directive did not silence the violation"
2941 );
2942 }
2943
2944 #[test]
2945 fn a_directive_silences_only_the_line_it_names() {
2946 let project = Project::new(
2947 "suppress-scope",
2948 &[
2949 ("rule.ts", DEBUGGER_RULE),
2950 ("lanekeep.config.ts", &config("")),
2951 (
2952 "src/a.ts",
2953 "// lanekeep-ignore-next-line local/no-debugger reason: legacy\n\
2954 debugger;\n\
2955 debugger;\n",
2956 ),
2957 ],
2958 );
2959 let outcome = project.run().expect("runs");
2960 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
2961 assert_eq!(outcome.violations[0].location.position.line, 3);
2962 }
2963
2964 #[test]
2965 fn a_file_directive_silences_every_line() {
2966 let project = Project::new(
2967 "suppress-file",
2968 &[
2969 ("rule.ts", DEBUGGER_RULE),
2970 ("lanekeep.config.ts", &config("")),
2971 (
2972 "src/a.ts",
2973 "// lanekeep-ignore-file local/no-debugger reason: generated fixture\n\
2974 debugger;\n\
2975 debugger;\n",
2976 ),
2977 ],
2978 );
2979 assert!(project.run().expect("runs").violations.is_empty());
2980 }
2981
2982 #[test]
2983 fn a_directive_naming_another_rule_silences_nothing() {
2984 let project = Project::new(
2985 "suppress-other-rule",
2986 &[
2987 ("rule.ts", DEBUGGER_RULE),
2988 ("lanekeep.config.ts", &config("")),
2989 (
2990 "src/a.ts",
2991 "// lanekeep-ignore-next-line local/something-else reason: unrelated\n\
2992 debugger;\n",
2993 ),
2994 ],
2995 );
2996 assert_eq!(project.run().expect("runs").violations.len(), 1);
2997 }
2998
2999 #[test]
3000 fn a_malformed_directive_is_reported() {
3001 let project = Project::new(
3005 "suppress-malformed",
3006 &[
3007 ("rule.ts", DEBUGGER_RULE),
3008 ("lanekeep.config.ts", &config("")),
3009 (
3010 "src/a.ts",
3011 "// lanekeep-ignore-next-line local/no-debugger\ndebugger;\n",
3012 ),
3013 ],
3014 );
3015
3016 let outcome = project.run().expect("runs");
3017 assert_eq!(outcome.violations.len(), 2, "{:?}", messages(&outcome));
3018 assert!(
3019 messages(&outcome)
3020 .iter()
3021 .any(|m| m.contains("no `reason:`")),
3022 "{:?}",
3023 messages(&outcome)
3024 );
3025 assert!(
3026 outcome
3027 .violations
3028 .iter()
3029 .any(|v| v.rule_id.to_string() == "lanekeep/suppression"),
3030 "reported under the wrong id"
3031 );
3032 }
3033
3034 #[test]
3035 fn an_expired_directive_is_reported_and_still_silences() {
3036 let project = Project::new(
3039 "suppress-expired",
3040 &[
3041 ("rule.ts", DEBUGGER_RULE),
3042 ("lanekeep.config.ts", &config("")),
3043 (
3044 "src/a.ts",
3045 "// lanekeep-ignore-next-line local/no-debugger reason: pending rewrite expires: 2026-01-01\n\
3046 debugger;\n",
3047 ),
3048 ],
3049 );
3050
3051 let outcome = project.run_on("2026-08-01").expect("runs");
3052 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3053 assert!(
3054 outcome.violations[0]
3055 .message
3056 .contains("expired on 2026-01-01"),
3057 "{:?}",
3058 messages(&outcome)
3059 );
3060 assert!(
3061 outcome.violations[0].message.contains("pending rewrite"),
3062 "the reason should be quoted back: {:?}",
3063 messages(&outcome)
3064 );
3065 }
3066
3067 #[test]
3068 fn a_directive_that_has_not_expired_is_quiet() {
3069 let project = Project::new(
3070 "suppress-unexpired",
3071 &[
3072 ("rule.ts", DEBUGGER_RULE),
3073 ("lanekeep.config.ts", &config("")),
3074 (
3075 "src/a.ts",
3076 "// lanekeep-ignore-next-line local/no-debugger reason: pending expires: 2026-12-31\n\
3077 debugger;\n",
3078 ),
3079 ],
3080 );
3081 assert!(
3082 project
3083 .run_on("2026-08-01")
3084 .expect("runs")
3085 .violations
3086 .is_empty()
3087 );
3088 }
3089
3090 #[test]
3091 fn a_directive_expires_the_day_after_its_date() {
3092 let project = Project::new(
3095 "suppress-boundary",
3096 &[
3097 ("rule.ts", DEBUGGER_RULE),
3098 ("lanekeep.config.ts", &config("")),
3099 (
3100 "src/a.ts",
3101 "// lanekeep-ignore-file local/no-debugger reason: x expires: 2026-08-01\n\
3102 debugger;\n",
3103 ),
3104 ],
3105 );
3106 assert!(
3107 project
3108 .run_on("2026-08-01")
3109 .expect("runs")
3110 .violations
3111 .is_empty()
3112 );
3113 assert_eq!(
3114 project.run_on("2026-08-02").expect("runs").violations.len(),
3115 1
3116 );
3117 }
3118
3119 #[test]
3120 fn an_expiring_directive_is_not_served_stale_from_the_cache() {
3121 let project = Project::new(
3125 "suppress-cache-date",
3126 &[
3127 ("rule.ts", DEBUGGER_RULE),
3128 ("lanekeep.config.ts", &config("")),
3129 (
3130 "src/a.ts",
3131 "// lanekeep-ignore-file local/no-debugger reason: x expires: 2026-08-01\n\
3132 debugger;\n",
3133 ),
3134 ],
3135 );
3136
3137 assert!(
3138 project
3139 .run_on("2026-08-01")
3140 .expect("runs")
3141 .violations
3142 .is_empty()
3143 );
3144 let after = project.run_on("2026-08-02").expect("runs");
3145 assert_eq!(
3146 after.violations.len(),
3147 1,
3148 "a warm run served an expired suppression: {:?}",
3149 messages(&after)
3150 );
3151 }
3152
3153 #[test]
3154 fn suppressions_survive_a_warm_run() {
3155 let project = Project::new(
3156 "suppress-warm",
3157 &[
3158 ("rule.ts", DEBUGGER_RULE),
3159 ("lanekeep.config.ts", &config("")),
3160 (
3161 "src/a.ts",
3162 "// lanekeep-ignore-file local/no-debugger reason: generated\ndebugger;\n",
3163 ),
3164 ],
3165 );
3166 assert!(project.run().expect("runs").violations.is_empty());
3167 assert!(
3168 project.run().expect("runs").violations.is_empty(),
3169 "the warm run reported what the cold one suppressed"
3170 );
3171 }
3172
3173 #[test]
3174 fn a_cross_file_violation_is_silenced_by_the_directive_where_it_lands() {
3175 let project = Project::new(
3179 "suppress-cross-file",
3180 &[
3181 ("rule.ts", UNUSED_EXPORTS_RULE),
3182 ("lanekeep.config.ts", &config("")),
3183 (
3184 "src/a.ts",
3185 "export function used() {}\n\
3186 // lanekeep-ignore-next-line local/no-unused-exports reason: public API\n\
3187 export function spare() {}\n",
3188 ),
3189 ("src/b.ts", "import { used } from './a';\nused();\n"),
3190 ],
3191 );
3192
3193 let outcome = project.run().expect("runs");
3194 assert!(
3195 outcome.violations.is_empty(),
3196 "a cross-file violation ignored the directive at its site: {:?}",
3197 messages(&outcome)
3198 );
3199 }
3200
3201 #[test]
3202 fn a_cross_file_violation_survives_a_directive_for_another_rule() {
3203 let project = Project::new(
3204 "suppress-cross-file-other",
3205 &[
3206 ("rule.ts", UNUSED_EXPORTS_RULE),
3207 ("lanekeep.config.ts", &config("")),
3208 (
3209 "src/a.ts",
3210 "export function used() {}\n\
3211 // lanekeep-ignore-next-line local/unrelated reason: x\n\
3212 export function spare() {}\n",
3213 ),
3214 ("src/b.ts", "import { used } from './a';\nused();\n"),
3215 ],
3216 );
3217 assert_eq!(project.run().expect("runs").violations.len(), 1);
3218 }
3219
3220 impl Project {
3223 fn run_reporting_unused(&self) -> Result<Outcome, RunError> {
3224 self.build()
3225 .map(Engine::reporting_unused_suppressions)?
3226 .run()
3227 }
3228 }
3229
3230 #[test]
3231 fn a_suppression_that_silenced_nothing_is_reported() {
3232 let project = Project::new(
3233 "unused-reported",
3234 &[
3235 ("rule.ts", DEBUGGER_RULE),
3236 ("lanekeep.config.ts", &config("")),
3237 (
3238 "src/a.ts",
3239 "// lanekeep-ignore-next-line local/no-debugger reason: was needed once\n\
3240 const a = 1;\n",
3241 ),
3242 ],
3243 );
3244
3245 let outcome = project.run_reporting_unused().expect("runs");
3246 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3247 assert!(
3248 outcome.violations[0].message.contains("silenced nothing"),
3249 "{:?}",
3250 messages(&outcome)
3251 );
3252 assert!(
3253 outcome.violations[0].message.contains("was needed once"),
3254 "the reason should be quoted back: {:?}",
3255 messages(&outcome)
3256 );
3257 }
3258
3259 #[test]
3260 fn a_suppression_that_did_its_job_is_not_reported() {
3261 let project = Project::new(
3262 "unused-used",
3263 &[
3264 ("rule.ts", DEBUGGER_RULE),
3265 ("lanekeep.config.ts", &config("")),
3266 (
3267 "src/a.ts",
3268 "// lanekeep-ignore-next-line local/no-debugger reason: legacy\ndebugger;\n",
3269 ),
3270 ],
3271 );
3272 assert!(
3273 project
3274 .run_reporting_unused()
3275 .expect("runs")
3276 .violations
3277 .is_empty()
3278 );
3279 }
3280
3281 #[test]
3282 fn unused_suppressions_are_quiet_without_the_flag() {
3283 let project = Project::new(
3285 "unused-off",
3286 &[
3287 ("rule.ts", DEBUGGER_RULE),
3288 ("lanekeep.config.ts", &config("")),
3289 (
3290 "src/a.ts",
3291 "// lanekeep-ignore-next-line local/no-debugger reason: stale\nconst a = 1;\n",
3292 ),
3293 ],
3294 );
3295 assert!(project.run().expect("runs").violations.is_empty());
3296 }
3297
3298 #[test]
3299 fn an_unused_suppression_is_a_warning_not_an_error() {
3300 let project = Project::new(
3302 "unused-severity",
3303 &[
3304 ("rule.ts", DEBUGGER_RULE),
3305 ("lanekeep.config.ts", &config("")),
3306 (
3307 "src/a.ts",
3308 "// lanekeep-ignore-next-line local/no-debugger reason: stale\nconst a = 1;\n",
3309 ),
3310 ],
3311 );
3312 let outcome = project.run_reporting_unused().expect("runs");
3313 assert_eq!(outcome.violations[0].severity, Severity::Warn);
3314 assert!(!lanekeep_core::any_failing(&outcome.violations));
3315 }
3316
3317 #[test]
3318 fn usage_survives_a_warm_run() {
3319 let project = Project::new(
3323 "unused-warm",
3324 &[
3325 ("rule.ts", DEBUGGER_RULE),
3326 ("lanekeep.config.ts", &config("")),
3327 (
3328 "src/a.ts",
3329 "// lanekeep-ignore-next-line local/no-debugger reason: legacy\ndebugger;\n",
3330 ),
3331 ],
3332 );
3333
3334 assert!(
3335 project
3336 .run_reporting_unused()
3337 .expect("runs")
3338 .violations
3339 .is_empty()
3340 );
3341 let warm = project.run_reporting_unused().expect("runs");
3342 assert!(
3343 warm.violations.is_empty(),
3344 "a warm run called a used suppression unused: {:?}",
3345 messages(&warm)
3346 );
3347 }
3348
3349 #[test]
3350 fn a_suppression_used_only_by_a_cross_file_rule_is_not_unused() {
3351 let project = Project::new(
3354 "unused-cross-file",
3355 &[
3356 ("rule.ts", UNUSED_EXPORTS_RULE),
3357 ("lanekeep.config.ts", &config("")),
3358 (
3359 "src/a.ts",
3360 "export function used() {}\n\
3361 // lanekeep-ignore-next-line local/no-unused-exports reason: public API\n\
3362 export function spare() {}\n",
3363 ),
3364 ("src/b.ts", "import { used } from './a';\nused();\n"),
3365 ],
3366 );
3367
3368 let outcome = project.run_reporting_unused().expect("runs");
3369 assert!(
3370 outcome.violations.is_empty(),
3371 "a directive used by a cross-file rule was called unused: {:?}",
3372 messages(&outcome)
3373 );
3374 }
3375
3376 #[test]
3377 fn a_malformed_directive_is_not_also_reported_as_unused() {
3378 let project = Project::new(
3381 "unused-malformed",
3382 &[
3383 ("rule.ts", DEBUGGER_RULE),
3384 ("lanekeep.config.ts", &config("")),
3385 (
3386 "src/a.ts",
3387 "// lanekeep-ignore-next-line local/no-debugger\nconst a = 1;\n",
3388 ),
3389 ],
3390 );
3391
3392 let outcome = project.run_reporting_unused().expect("runs");
3393 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3394 assert!(
3395 outcome.violations[0].message.contains("no `reason:`"),
3396 "{:?}",
3397 messages(&outcome)
3398 );
3399 }
3400
3401 const DATE_RULE: &str = r"import { defineRule } from 'lanekeep';
3405export default defineRule({
3406 id: 'local/dated',
3407 query: '(export_statement) @stmt',
3408 card: { message: 'dated', remediation: 'x', examples: { bad: 'a', good: 'b' } },
3409 check(ctx, m) {
3410 if (ctx.today.startsWith('2027')) ctx.report(m.stmt, `it is ${ctx.today}`);
3411 },
3412});
3413";
3414
3415 #[test]
3416 fn a_rule_can_read_the_date() {
3417 let project = Project::new(
3418 "today-read",
3419 &[
3420 ("rule.ts", DATE_RULE),
3421 ("lanekeep.config.ts", &config("")),
3422 ("src/a.ts", "export const a = 1;\n"),
3423 ],
3424 );
3425 let outcome = project.run_on("2027-03-04").expect("runs");
3426 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3427 assert!(outcome.violations[0].message.contains("2027-03-04"));
3428 }
3429
3430 #[test]
3431 fn a_result_that_read_the_date_is_not_served_across_days() {
3432 let project = Project::new(
3436 "today-cache",
3437 &[
3438 ("rule.ts", DATE_RULE),
3439 ("lanekeep.config.ts", &config("")),
3440 ("src/a.ts", "export const a = 1;\n"),
3441 ],
3442 );
3443
3444 assert!(
3445 project
3446 .run_on("2026-12-31")
3447 .expect("runs")
3448 .violations
3449 .is_empty()
3450 );
3451 let later = project.run_on("2027-01-01").expect("runs");
3452 assert_eq!(
3453 later.violations.len(),
3454 1,
3455 "a warm run served a date-dependent result from another day: {:?}",
3456 messages(&later)
3457 );
3458 }
3459
3460 #[test]
3461 fn a_result_that_ignored_the_date_survives_across_days() {
3462 let project = Project::new(
3469 "today-undated",
3470 &[
3471 ("rule.ts", DEBUGGER_RULE),
3472 ("lanekeep.config.ts", &config("")),
3473 ("src/a.ts", "debugger;\n"),
3474 ],
3475 );
3476
3477 project.run_on("2026-12-31").expect("runs");
3478 let before = fs::read(Store::path_for(&project.dir)).expect("reads");
3479
3480 let outcome = project.run_on("2027-01-01").expect("runs");
3481 assert_eq!(outcome.violations.len(), 1);
3482
3483 let after = fs::read(Store::path_for(&project.dir)).expect("reads");
3484 assert_eq!(
3485 before, after,
3486 "a result that never read the date was re-keyed across days"
3487 );
3488 }
3489
3490 #[test]
3491 fn a_result_that_read_the_date_is_re_keyed_across_days() {
3492 let project = Project::new(
3495 "today-dated-key",
3496 &[
3497 ("rule.ts", DATE_RULE),
3498 ("lanekeep.config.ts", &config("")),
3499 ("src/a.ts", "export const a = 1;\n"),
3500 ],
3501 );
3502
3503 project.run_on("2026-12-31").expect("runs");
3504 let before = fs::read(Store::path_for(&project.dir)).expect("reads");
3505
3506 project.run_on("2027-01-01").expect("runs");
3507 let after = fs::read(Store::path_for(&project.dir)).expect("reads");
3508 assert_ne!(
3509 before, after,
3510 "a result that read the date kept its key across days"
3511 );
3512 }
3513
3514 #[test]
3515 fn loc_reaches_a_reduce_phase_through_a_fact() {
3516 const RULE: &str = r"import { defineRule } from 'lanekeep';
3518export default defineRule({
3519 id: 'local/loc-through-facts',
3520 query: '(export_statement) @stmt',
3521 card: { message: 'via loc', remediation: 'x', examples: { bad: 'a', good: 'b' } },
3522 check(ctx, m) { ctx.emitFact({ kind: 'site', at: ctx.loc(m.stmt) }); },
3523 reduce(ctx) {
3524 for (const f of ctx.facts('site')) ctx.report(f.at, 'reported at a remembered place');
3525 },
3526});
3527";
3528 let project = Project::new(
3529 "loc-facts",
3530 &[
3531 ("rule.ts", RULE),
3532 ("lanekeep.config.ts", &config("")),
3533 ("src/a.ts", "const x = 1;\nexport const a = 1;\n"),
3534 ],
3535 );
3536
3537 let outcome = project.run().expect("runs");
3538 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3539 assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
3540 assert_eq!(outcome.violations[0].location.position.line, 2);
3541 }
3542}