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 {
115 spec: RuleSpec,
116 query: CompiledQuery,
117 gates: CompiledGates,
118 language: Arc<dyn Language>,
119}
120
121#[expect(
123 clippy::struct_excessive_bools,
124 reason = "four independent run modes — caching, reducing, unused reporting, profiling — \
125 every combination of which is meaningful and reachable from the CLI. The lint \
126 is aimed at a type where a pile of bools stands in for a missing enum; these \
127 are orthogonal switches, and an enum over their sixteen combinations would be \
128 strictly worse to read and to set."
129)]
130pub struct Engine {
131 rules: Vec<Prepared>,
132 discovery: Discovery,
133 root: PathBuf,
136 run_key: RunKey,
138 caching: bool,
140 reducing: bool,
142 reporting_unused: bool,
144 profiling: bool,
146 today: Date,
151 limits: Limits,
152 rules_root: RuleRoot,
153 config_path: PathBuf,
154 typescript: Arc<dyn Language>,
155 javascript: Arc<dyn Language>,
156}
157
158impl std::fmt::Debug for Engine {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 f.debug_struct("Engine")
161 .field("rules", &self.rules.len())
162 .field("root", &self.discovery.root())
163 .finish_non_exhaustive()
164 }
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
174pub struct RuleTiming {
175 pub query: Duration,
177 pub handler: Duration,
179 pub matches: u64,
184}
185
186impl RuleTiming {
187 #[must_use]
189 pub const fn total(&self) -> Duration {
190 self.query.saturating_add(self.handler)
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Default)]
196pub struct Outcome {
197 pub violations: Vec<Violation>,
199 pub files_discovered: usize,
201 pub files_parsed: usize,
203
204 pub timings: Option<BTreeMap<RuleId, RuleTiming>>,
209
210 pub dependencies: BTreeMap<FilePath, Vec<TrackedRead>>,
216}
217
218impl Engine {
219 pub fn prepare(
229 config: &Config,
230 project_root: &Path,
231 rules_root: RuleRoot,
232 config_path: &Path,
233 registry: &LanguageRegistry,
234 typescript: Arc<dyn Language>,
235 javascript: Arc<dyn Language>,
236 ) -> Result<Self, RunError> {
237 let discovery = Discovery::new(project_root, &config.include, &config.exclude)?;
238
239 let known = registry
240 .languages()
241 .map(|l| l.id().as_str())
242 .collect::<Vec<_>>()
243 .join(", ");
244
245 let mut rules = Vec::with_capacity(config.rules.len());
246 for spec in &config.rules {
247 if !spec.severity.is_enabled() {
248 continue;
249 }
250
251 let language = registry.by_id(&spec.language).cloned().ok_or_else(|| {
252 RunError::UnknownLanguage {
253 rule: spec.id.to_string(),
254 language: spec.language.clone(),
255 known: known.clone(),
256 }
257 })?;
258
259 let query = CompiledQuery::compile(language.as_ref(), &spec.query).map_err(
260 |e: CompileError| RunError::Query {
261 rule: spec.id.to_string(),
262 detail: e.to_string(),
263 },
264 )?;
265
266 let gates = CompiledGates::compile(&spec.gates).map_err(|e| RunError::Gates {
267 rule: spec.id.to_string(),
268 detail: e.to_string(),
269 })?;
270
271 rules.push(Prepared {
272 spec: spec.clone(),
273 query,
274 gates,
275 language,
276 });
277 }
278
279 let mut grammars: Vec<GrammarKey> = registry
282 .languages()
283 .map(|language| GrammarKey {
284 id: language.id().to_string(),
285 abi: u32::try_from(language.grammar_abi()).unwrap_or(u32::MAX),
286 })
287 .collect();
288 grammars.sort_by(|a, b| a.id.cmp(&b.id));
289
290 let run_key = RunKey::new(
291 engine_version(),
295 HOST_API_VERSION,
296 &config.ruleset_hash,
297 &config.config_hash,
298 &grammars,
299 );
300
301 Ok(Self {
302 rules,
303 run_key,
304 caching: true,
305 reducing: true,
306 reporting_unused: false,
307 profiling: false,
308 today: suppression::today(),
309 root: project_root
313 .canonicalize()
314 .unwrap_or_else(|_| project_root.to_path_buf()),
315 discovery,
316 limits: config.limits,
317 rules_root,
318 config_path: config_path.to_path_buf(),
319 typescript,
320 javascript,
321 })
322 }
323
324 #[must_use]
326 pub const fn without_cache(mut self) -> Self {
327 self.caching = false;
328 self
329 }
330
331 #[must_use]
336 pub const fn profiling(mut self) -> Self {
337 self.profiling = true;
338 self
339 }
340
341 #[must_use]
347 pub const fn reporting_unused_suppressions(mut self) -> Self {
348 self.reporting_unused = true;
349 self
350 }
351
352 #[must_use]
356 pub const fn with_today(mut self, today: Date) -> Self {
357 self.today = today;
358 self
359 }
360
361 #[must_use]
371 pub const fn without_reduce(mut self) -> Self {
372 self.reducing = false;
373 self
374 }
375
376 #[must_use]
381 pub fn discover(&self) -> Vec<FilePath> {
382 self.discovery.walk()
383 }
384
385 #[must_use]
387 pub fn rule_count(&self) -> usize {
388 self.rules.len()
389 }
390
391 pub fn rules(&self) -> impl Iterator<Item = &RuleSpec> {
397 self.rules.iter().map(|prepared| &prepared.spec)
398 }
399
400 pub fn run(&self) -> Result<Outcome, RunError> {
408 let files = self.discovery.walk();
409 self.run_files(&files, Coverage::Whole)
410 }
411
412 pub fn run_over(&self, files: &[FilePath]) -> Result<Outcome, RunError> {
418 self.run_files(files, Coverage::Partial)
419 }
420
421 fn run_files(&self, files: &[FilePath], coverage: Coverage) -> Result<Outcome, RunError> {
423 let clock = RunClock::start(self.limits.global_timeout);
424
425 let cache = if self.caching {
429 Store::load(&self.root)
430 } else {
431 Store::empty()
432 };
433
434 let results: Vec<Result<FileOutcome, RunError>> = files
435 .par_iter()
436 .map_init(
437 || Worker::new(self, &clock),
447 |worker, path| self.check_file(worker, &cache, path),
448 )
449 .collect();
450
451 let mut violations = Vec::new();
452 let mut facts = Vec::new();
453 let mut files_parsed = 0;
454 let mut dependencies = BTreeMap::new();
455 let mut fresh = Store::empty();
456 let mut directives: BTreeMap<FilePath, FileDirectives> = BTreeMap::new();
457 let mut timings: BTreeMap<RuleId, RuleTiming> = BTreeMap::new();
458 for result in results {
459 let outcome = result?;
460 violations.extend(outcome.violations);
461 facts.extend(outcome.facts);
462 files_parsed += usize::from(outcome.parsed);
463 if let Some(entry) = outcome.entry {
464 fresh.insert(entry.0, entry.1);
465 }
466 for (rule, timing) in outcome.timings {
467 let entry = timings.entry(rule).or_default();
468 entry.query = entry.query.saturating_add(timing.query);
469 entry.handler = entry.handler.saturating_add(timing.handler);
470 entry.matches += timing.matches;
471 }
472 if !outcome.suppressions.is_empty() {
473 directives.insert(
474 outcome.path.clone(),
475 FileDirectives {
476 suppressions: outcome.suppressions,
477 used: outcome.used_suppressions,
478 },
479 );
480 }
481 if !outcome.reads.is_empty() {
482 dependencies.insert(outcome.path, outcome.reads);
483 }
484 }
485
486 if self.caching {
487 match coverage {
488 Coverage::Whole => fresh.save(&self.root),
491 Coverage::Partial => {
496 let mut merged = cache;
497 for key in fresh.keys().copied().collect::<Vec<_>>() {
498 if let Some(entry) = fresh.get(&key) {
499 merged.insert(key, entry.clone());
500 }
501 }
502 merged.save(&self.root);
503 }
504 }
505 }
506
507 lanekeep_core::fact::sort(&mut facts);
516
517 let reduced = self.reduce(&clock, files, &facts)?;
521 for violation in reduced {
522 match covering_elsewhere(&directives, &violation) {
525 Some((file, index)) => {
526 if let Some(found) = directives.get_mut(&file)
527 && !found.used.contains(&index)
528 {
529 found.used.push(index);
530 }
531 }
532 None => violations.push(violation),
533 }
534 }
535
536 if self.reporting_unused {
537 violations.extend(unused_violations(&directives));
538 }
539
540 lanekeep_core::sort(&mut violations);
541 Ok(Outcome {
542 violations,
543 files_discovered: files.len(),
544 files_parsed,
545 timings: self.profiling.then_some(timings),
546 dependencies,
547 })
548 }
549
550 fn reduce(
557 &self,
558 clock: &Arc<RunClock>,
559 files: &[FilePath],
560 facts: &[Fact],
561 ) -> Result<Vec<Violation>, RunError> {
562 if !self.reducing {
563 return Ok(Vec::new());
564 }
565
566 let reducing: Vec<&Prepared> = self
567 .rules
568 .iter()
569 .filter(|rule| rule.spec.has_reduce)
570 .collect();
571 if reducing.is_empty() {
572 return Ok(Vec::new());
575 }
576
577 let sandbox = self.build_sandbox(clock)?;
578 let paths: Vec<String> = files.iter().map(|f| f.as_str().to_owned()).collect();
579 let mut violations = Vec::new();
580
581 for rule in reducing {
582 let own: Vec<ReduceFact> = facts
586 .iter()
587 .filter(|fact| fact.rule_id == rule.spec.id)
588 .map(|fact| ReduceFact {
589 kind: fact.kind.clone(),
590 json: lanekeep_js::merge_file(&fact.data, fact.file.as_str()),
591 })
592 .collect();
593
594 let host = ReduceContext::new(paths.clone(), own);
595 let timeout = rule.spec.timeout.unwrap_or(self.limits.rule_timeout);
596 let call = format!(
597 "globalThis.__lanekeepConfig.rules[{}].reduce(ctx)",
598 rule_index(&rule.spec)
599 );
600
601 sandbox
602 .eval_with_reduce_host::<()>(&host, &call, timeout)
603 .map_err(|e: SandboxError| RunError::Rule {
604 rule: rule.spec.id.to_string(),
605 file: "<reduce>".to_owned(),
608 detail: e.to_string(),
609 })?;
610
611 for report in host.take_reports() {
612 violations.push(Violation {
617 rule_id: rule.spec.id.clone(),
618 location: Location::new(
619 FilePath::new(&report.file),
620 Position::new(report.line, report.column),
621 ),
622 message: report
623 .message
624 .unwrap_or_else(|| rule.spec.card.message.clone()),
625 remediation: rule.spec.card.remediation.clone(),
626 severity: rule.spec.severity,
627 fix: None,
631 });
632 }
633 }
634
635 Ok(violations)
636 }
637
638 fn build_sandbox(&self, clock: &Arc<RunClock>) -> Result<Sandbox, RunError> {
640 let sandbox = Sandbox::with_modules(
641 self.limits,
642 Arc::clone(clock),
643 self.rules_root.clone(),
644 Arc::clone(&self.typescript),
645 Arc::clone(&self.javascript),
646 )
647 .map_err(|e| RunError::Worker {
648 detail: e.to_string(),
649 })?;
650
651 lanekeep_config::evaluate_into(&sandbox, &self.rules_root, &self.config_path).map_err(
655 |e: ConfigError| RunError::Worker {
656 detail: e.to_string(),
657 },
658 )?;
659
660 Ok(sandbox)
661 }
662
663 fn check_file(
665 &self,
666 worker: &mut Worker<'_>,
667 cache: &Store,
668 path: &FilePath,
669 ) -> Result<FileOutcome, RunError> {
670 let files = Rc::new(FileAccess::rooted(self.root.clone()));
673
674 let admitted: Vec<&Prepared> = self
676 .rules
677 .iter()
678 .filter(|rule| rule.gates.admits_path(path))
679 .collect();
680 if admitted.is_empty() {
681 return Ok(FileOutcome::skipped(path.clone()));
682 }
683
684 let absolute = self.discovery.root().join(path.as_str());
685 let Ok(bytes) = std::fs::read(&absolute) else {
686 return Ok(FileOutcome::skipped(path.clone()));
691 };
692
693 let keys = self.caching.then(|| {
711 let content = lanekeep_cache::hash_bytes(&bytes);
712 (
713 self.run_key.for_file(path.as_str(), &content),
714 self.run_key
715 .for_dated_file(path.as_str(), &content, &self.today.to_string()),
716 )
717 });
718 let has_expiry = memchr::memmem::find(&bytes, b"expires:").is_some();
719
720 if let Some((plain, dated)) = keys {
721 let candidates: &[CacheKey] = if has_expiry {
724 &[dated]
725 } else {
726 &[dated, plain]
727 };
728 for key in candidates {
729 if let Some(entry) = cache.get(key)
730 && lanekeep_cache::validate(entry, &self.root)
731 {
732 return Ok(FileOutcome::cached(path.clone(), *key, entry.clone()));
733 }
734 }
735 }
736
737 let admitted: Vec<&Prepared> = admitted
739 .into_iter()
740 .filter(|rule| rule.gates.admits_content(&bytes))
741 .collect();
742 if admitted.is_empty() {
743 return Ok(FileOutcome::empty_entry(
748 path.clone(),
749 keys.map(|(plain, dated)| if has_expiry { dated } else { plain }),
750 ));
751 }
752
753 let Ok(source) = String::from_utf8(bytes) else {
754 return Ok(FileOutcome::skipped(path.clone()));
756 };
757
758 let directives = suppression::parse(&source);
761
762 let mut outcome = FileOutcome::parsed(path.clone());
763 for rule in admitted {
764 let (violations, facts, read_the_date, timing) =
765 self.run_rule(worker, &files, rule, path, &source)?;
766 outcome.violations.extend(violations);
767 outcome.facts.extend(facts);
768 outcome.read_the_date |= read_the_date;
769 if self.profiling {
770 outcome.timings.push((rule.spec.id.clone(), timing));
771 }
772 }
773
774 let mut used = Vec::new();
779 outcome.violations.retain(|violation| {
780 match directives.covering(&violation.rule_id, violation.location.position.line) {
781 Some(index) => {
782 let index = u32::try_from(index).unwrap_or(u32::MAX);
783 if !used.contains(&index) {
784 used.push(index);
785 }
786 false
787 }
788 None => true,
789 }
790 });
791 used.sort_unstable();
792 outcome.used_suppressions = used;
793 outcome
794 .violations
795 .extend(self.directive_violations(&directives, path));
796
797 outcome.suppressions = directives.valid;
798 outcome.reads = files.dependencies();
799 let date_dependent = has_expiry || outcome.read_the_date;
802 outcome.entry = keys.map(|(plain, dated)| {
803 (
804 if date_dependent { dated } else { plain },
805 CacheEntry {
806 violations: outcome.violations.clone(),
807 facts: outcome.facts.clone(),
808 dependencies: outcome.reads.clone(),
809 suppressions: outcome.suppressions.clone(),
810 used_suppressions: outcome.used_suppressions.clone(),
811 },
812 )
813 });
814
815 Ok(outcome)
816 }
817
818 fn directive_violations(&self, directives: &Suppressions, path: &FilePath) -> Vec<Violation> {
825 let mut violations = Vec::new();
826
827 let Ok(rule_id) = SUPPRESSION_RULE.parse::<RuleId>() else {
831 return violations;
832 };
833
834 for bad in &directives.malformed {
835 violations.push(Violation {
836 rule_id: rule_id.clone(),
837 location: Location::new(path.clone(), Position::new(bad.line, bad.column)),
838 message: bad.problem.clone(),
839 remediation: String::from(
840 "fix the directive, or remove it and fix what it was hiding",
841 ),
842 severity: Severity::Error,
843 fix: None,
844 });
845 }
846
847 for suppression in &directives.valid {
848 let Some(expires) = suppression.expires else {
849 continue;
850 };
851 if expires >= self.today {
852 continue;
853 }
854
855 violations.push(Violation {
856 rule_id: rule_id.clone(),
857 location: Location::new(
858 path.clone(),
859 Position::new(suppression.line, suppression.column),
860 ),
861 message: format!(
862 "suppression expired on {expires} — \"{}\"",
863 suppression.reason
864 ),
865 remediation: String::from(
866 "fix what it was suppressing, or decide it is permanent and drop the \
867 expiry",
868 ),
869 severity: Severity::Error,
870 fix: None,
871 });
872 }
873
874 violations
875 }
876
877 fn run_rule(
878 &self,
879 worker: &mut Worker<'_>,
880 files: &Rc<FileAccess>,
881 rule: &Prepared,
882 path: &FilePath,
883 source: &str,
884 ) -> Result<(Vec<Violation>, Vec<Fact>, bool, RuleTiming), RunError> {
885 let mut parser = tree_sitter::Parser::new();
886 if parser.set_language(&rule.language.grammar()).is_err() {
887 return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
888 }
889 let Some(tree) = parser.parse(source, None) else {
890 return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
891 };
892
893 let mut timing = RuleTiming::default();
896 let clock = |on: bool| on.then(std::time::Instant::now);
897
898 let mut matches: Vec<Vec<(String, Vec<u32>)>> = Vec::new();
901 let host = HostContext::new(tree, source.to_owned(), path.as_str())
902 .with_resolver_from(rule.language.as_ref())
903 .with_language(Arc::clone(&rule.language))
904 .with_today(&self.today.to_string())
905 .with_file_access(Rc::clone(files));
906
907 let query_started = clock(self.profiling);
908 {
909 let arena = host.arena().borrow();
910 rule.query
911 .for_each_match(arena.tree(), source.as_bytes(), |m| {
912 let captures = m
913 .captures
914 .iter()
915 .filter_map(|(name, node)| {
916 arena.path_of(*node).map(|path| ((*name).to_owned(), path))
917 })
918 .collect();
919 matches.push(captures);
920 });
921 }
922
923 if let Some(started) = query_started {
924 timing.query = started.elapsed();
925 timing.matches = matches.len() as u64;
926 }
927
928 if matches.is_empty() {
929 return Ok((Vec::new(), Vec::new(), false, timing));
930 }
931
932 let sandbox = worker.sandbox()?;
935
936 let timeout = rule.spec.timeout.unwrap_or(self.limits.rule_timeout);
937 let mut violations = Vec::new();
938
939 for captures in matches {
940 let handles: Vec<(String, u32)> = {
941 let mut arena = host.arena().borrow_mut();
942 captures
943 .into_iter()
944 .filter_map(|(name, path)| arena.intern_path(path).map(|h| (name, h)))
945 .collect()
946 };
947
948 let literal = handles
949 .iter()
950 .map(|(name, handle)| format!("{}: {handle}", json_key(name)))
951 .collect::<Vec<_>>()
952 .join(", ");
953
954 let call = format!(
957 "globalThis.__lanekeepConfig.rules[{}].check(ctx, {{{literal}}})",
958 rule_index(&rule.spec)
959 );
960
961 let handler_started = clock(self.profiling);
962 let outcome = sandbox.eval_with_host_timeout::<()>(&host, &call, timeout);
963 if let Some(started) = handler_started {
964 timing.handler = timing.handler.saturating_add(started.elapsed());
965 }
966
967 outcome.map_err(|e: SandboxError| RunError::Rule {
968 rule: rule.spec.id.to_string(),
969 file: path.as_str().to_owned(),
970 detail: e.to_string(),
971 })?;
972 }
973
974 let facts = host
975 .take_facts()
976 .into_iter()
977 .enumerate()
978 .map(|(sequence, emitted)| Fact {
979 rule_id: rule.spec.id.clone(),
980 file: path.clone(),
981 kind: emitted.kind,
982 data: emitted.data,
983 sequence: u32::try_from(sequence).unwrap_or(u32::MAX),
987 })
988 .collect();
989
990 for report in host.take_reports() {
991 violations.push(Violation {
992 rule_id: rule.spec.id.clone(),
993 location: Location::new(path.clone(), Position::new(report.line, report.column)),
994 message: report
995 .message
996 .unwrap_or_else(|| rule.spec.card.message.clone()),
997 remediation: rule.spec.card.remediation.clone(),
998 severity: rule.spec.severity,
999 fix: report.fix,
1000 });
1001 }
1002
1003 Ok((violations, facts, host.date_was_read(), timing))
1004 }
1005}
1006
1007#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1012enum Coverage {
1013 Whole,
1015 Partial,
1017}
1018
1019struct Worker<'a> {
1026 engine: &'a Engine,
1027 clock: Arc<RunClock>,
1028 sandbox: Option<Sandbox>,
1029 failed: Option<RunError>,
1032}
1033
1034impl<'a> Worker<'a> {
1035 fn new(engine: &'a Engine, clock: &Arc<RunClock>) -> Self {
1036 Self {
1037 engine,
1038 clock: Arc::clone(clock),
1039 sandbox: None,
1040 failed: None,
1041 }
1042 }
1043
1044 fn sandbox(&mut self) -> Result<&Sandbox, RunError> {
1046 if let Some(error) = &self.failed {
1047 return Err(error.clone());
1048 }
1049
1050 if self.sandbox.is_none() {
1051 match self.engine.build_sandbox(&self.clock) {
1052 Ok(sandbox) => self.sandbox = Some(sandbox),
1053 Err(error) => {
1054 self.failed = Some(error.clone());
1055 return Err(error);
1056 }
1057 }
1058 }
1059
1060 self.sandbox.as_ref().ok_or_else(|| RunError::Worker {
1061 detail: "sandbox was not built".to_owned(),
1062 })
1063 }
1064}
1065
1066struct FileOutcome {
1068 path: FilePath,
1070 violations: Vec<Violation>,
1071 facts: Vec<Fact>,
1072 reads: Vec<TrackedRead>,
1074 suppressions: Vec<suppression::Suppression>,
1076 used_suppressions: Vec<u32>,
1078 read_the_date: bool,
1080 timings: Vec<(RuleId, RuleTiming)>,
1082 entry: Option<(CacheKey, CacheEntry)>,
1084 parsed: bool,
1086}
1087
1088impl FileOutcome {
1089 const fn skipped(path: FilePath) -> Self {
1091 Self {
1092 path,
1093 violations: Vec::new(),
1094 facts: Vec::new(),
1095 reads: Vec::new(),
1096 suppressions: Vec::new(),
1097 used_suppressions: Vec::new(),
1098 read_the_date: false,
1099 timings: Vec::new(),
1100 entry: None,
1101 parsed: false,
1102 }
1103 }
1104
1105 const fn parsed(path: FilePath) -> Self {
1106 Self {
1107 path,
1108 violations: Vec::new(),
1109 facts: Vec::new(),
1110 reads: Vec::new(),
1111 suppressions: Vec::new(),
1112 used_suppressions: Vec::new(),
1113 read_the_date: false,
1114 timings: Vec::new(),
1115 entry: None,
1116 parsed: true,
1117 }
1118 }
1119
1120 fn cached(path: FilePath, key: CacheKey, entry: CacheEntry) -> Self {
1125 Self {
1126 path,
1127 violations: entry.violations.clone(),
1128 facts: entry.facts.clone(),
1129 reads: entry.dependencies.clone(),
1130 suppressions: entry.suppressions.clone(),
1131 used_suppressions: entry.used_suppressions.clone(),
1132 read_the_date: false,
1135 timings: Vec::new(),
1136 entry: Some((key, entry)),
1137 parsed: true,
1138 }
1139 }
1140
1141 fn empty_entry(path: FilePath, key: Option<CacheKey>) -> Self {
1143 Self {
1144 path,
1145 violations: Vec::new(),
1146 facts: Vec::new(),
1147 reads: Vec::new(),
1148 suppressions: Vec::new(),
1149 used_suppressions: Vec::new(),
1150 read_the_date: false,
1151 timings: Vec::new(),
1152 entry: key.map(|key| (key, CacheEntry::default())),
1153 parsed: false,
1154 }
1155 }
1156}
1157
1158struct FileDirectives {
1160 suppressions: Vec<suppression::Suppression>,
1161 used: Vec<u32>,
1163}
1164
1165fn covering_elsewhere(
1170 directives: &BTreeMap<FilePath, FileDirectives>,
1171 violation: &Violation,
1172) -> Option<(FilePath, u32)> {
1173 let found = directives.get(&violation.location.file)?;
1174 let index = found.suppressions.iter().position(|suppression| {
1175 suppression.covers(&violation.rule_id, violation.location.position.line)
1176 })?;
1177
1178 Some((
1179 violation.location.file.clone(),
1180 u32::try_from(index).unwrap_or(u32::MAX),
1181 ))
1182}
1183
1184fn unused_violations(directives: &BTreeMap<FilePath, FileDirectives>) -> Vec<Violation> {
1193 let Ok(rule_id) = SUPPRESSION_RULE.parse::<RuleId>() else {
1194 return Vec::new();
1195 };
1196
1197 let mut violations = Vec::new();
1198 for (file, found) in directives {
1199 for (index, suppression) in found.suppressions.iter().enumerate() {
1200 let index = u32::try_from(index).unwrap_or(u32::MAX);
1201 if found.used.contains(&index) {
1202 continue;
1203 }
1204
1205 violations.push(Violation {
1206 rule_id: rule_id.clone(),
1207 location: Location::new(
1208 file.clone(),
1209 Position::new(suppression.line, suppression.column),
1210 ),
1211 message: format!("suppression silenced nothing — \"{}\"", suppression.reason),
1212 remediation: String::from(
1213 "remove it: whatever it was accepting is no longer reported",
1214 ),
1215 severity: Severity::Warn,
1216 fix: None,
1217 });
1218 }
1219 }
1220 violations
1221}
1222
1223fn engine_version() -> &'static str {
1225 const FULL: &str = env!("CARGO_PKG_VERSION");
1228 match FULL.match_indices('.').nth(1) {
1229 Some((at, _)) => FULL.split_at(at).0,
1230 None => FULL,
1231 }
1232}
1233
1234const SUPPRESSION_RULE: &str = "lanekeep/suppression";
1239
1240fn rule_index(spec: &RuleSpec) -> usize {
1242 spec.index
1243}
1244
1245fn json_key(name: &str) -> String {
1247 format!("{name:?}")
1248}
1249
1250#[must_use]
1252pub fn any_failing(violations: &[Violation]) -> bool {
1253 violations.iter().any(|v| v.severity == Severity::Error)
1254}
1255
1256#[must_use]
1258pub fn rules_root_for(project_root: &Path) -> PathBuf {
1259 project_root.to_path_buf()
1260}
1261
1262#[cfg(test)]
1263mod tests {
1264 use std::fs;
1265
1266 use lanekeep_lang_js::{JavaScript, TypeScript};
1267
1268 use super::*;
1269
1270 struct Project {
1271 dir: PathBuf,
1272 }
1273
1274 impl Project {
1275 fn new(name: &str, files: &[(&str, &str)]) -> Self {
1276 let dir = std::env::temp_dir().join(format!("lanekeep-engine-{name}"));
1277 let _ = fs::remove_dir_all(&dir);
1278 fs::create_dir_all(&dir).expect("creates dir");
1279 let project = Self { dir };
1280 for (path, contents) in files {
1281 project.write(path, contents);
1282 }
1283 project
1284 }
1285
1286 fn write(&self, path: &str, contents: &str) {
1287 let full = self.dir.join(path);
1288 if let Some(parent) = full.parent() {
1289 fs::create_dir_all(parent).expect("creates parent");
1290 }
1291 fs::write(full, contents).expect("writes");
1292 }
1293
1294 fn run(&self) -> Result<Outcome, RunError> {
1295 let root = RuleRoot::new(&self.dir).expect("canonicalizes");
1296 let config_path = self.dir.join("lanekeep.config.ts");
1297
1298 let sandbox =
1299 lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
1300 .expect("sandbox");
1301 let config = lanekeep_config::load(&sandbox, &root, &config_path)
1302 .unwrap_or_else(|e| panic!("config failed to load: {e}"));
1303
1304 let engine = Engine::prepare(
1305 &config,
1306 &self.dir,
1307 root,
1308 &config_path,
1309 &lanekeep_lang_js::registry(),
1310 Arc::new(TypeScript),
1311 Arc::new(JavaScript),
1312 )?;
1313 engine.run()
1314 }
1315 }
1316
1317 impl Drop for Project {
1318 fn drop(&mut self) {
1319 let _ = fs::remove_dir_all(&self.dir);
1320 }
1321 }
1322
1323 const DEBUGGER_RULE: &str = "import { defineRule } from 'lanekeep';\n\
1325 export default defineRule({\n\
1326 id: 'local/no-debugger',\n\
1327 query: '(debugger_statement) @stmt',\n\
1328 card: {\n\
1329 message: 'debugger statement',\n\
1330 remediation: 'remove it before committing',\n\
1331 examples: { bad: 'debugger;', good: 'console.log(x);' },\n\
1332 },\n\
1333 check(ctx, m) { ctx.report(m.stmt); },\n\
1334 });\n";
1335
1336 fn config(extra: &str) -> String {
1337 format!(
1338 "import {{ defineConfig }} from 'lanekeep';\n\
1339 import rule from './rule';\n\
1340 export default defineConfig({{ include: ['src/**/*.ts'], rules: [rule]{extra} }});\n"
1341 )
1342 }
1343
1344 #[test]
1345 fn runs_a_rule_over_a_corpus_end_to_end() {
1346 let project = Project::new(
1347 "end-to-end",
1348 &[
1349 ("rule.ts", DEBUGGER_RULE),
1350 ("lanekeep.config.ts", &config("")),
1351 ("src/clean.ts", "const a = 1;\n"),
1352 ("src/dirty.ts", "const b = 2;\ndebugger;\n"),
1353 ("src/also.ts", "function f() {\n debugger;\n}\n"),
1354 ],
1355 );
1356
1357 let outcome = project.run().expect("runs");
1358
1359 assert_eq!(outcome.violations.len(), 2, "{:?}", outcome.violations);
1360 let rendered: Vec<String> = outcome
1361 .violations
1362 .iter()
1363 .map(|v| format!("{} {}", v.rule_id, v.location))
1364 .collect();
1365 assert_eq!(
1366 rendered,
1367 [
1368 "local/no-debugger src/also.ts:2:3",
1369 "local/no-debugger src/dirty.ts:2:1",
1370 ]
1371 );
1372 assert_eq!(outcome.violations[0].message, "debugger statement");
1373 assert_eq!(
1374 outcome.violations[0].remediation,
1375 "remove it before committing"
1376 );
1377 }
1378
1379 #[test]
1380 fn output_is_identical_across_repeated_runs() {
1381 let mut files = vec![
1385 ("rule.ts".to_owned(), DEBUGGER_RULE.to_owned()),
1386 ("lanekeep.config.ts".to_owned(), config("")),
1387 ];
1388 for i in 0..40 {
1389 files.push((
1390 format!("src/f{i}.ts"),
1391 format!("const x{i} = 1;\ndebugger;\n"),
1392 ));
1393 }
1394 let borrowed: Vec<(&str, &str)> = files
1395 .iter()
1396 .map(|(a, b)| (a.as_str(), b.as_str()))
1397 .collect();
1398 let project = Project::new("determinism", &borrowed);
1399
1400 let first = project.run().expect("runs").violations;
1401 assert_eq!(first.len(), 40);
1402
1403 for _ in 0..4 {
1404 assert_eq!(project.run().expect("runs").violations, first);
1405 }
1406 }
1407
1408 #[test]
1409 fn exclude_keeps_files_out_of_the_run() {
1410 let project = Project::new(
1411 "exclude",
1412 &[
1413 ("rule.ts", DEBUGGER_RULE),
1414 ("lanekeep.config.ts", &config(", exclude: ['**/*.test.ts']")),
1415 ("src/a.ts", "debugger;\n"),
1416 ("src/a.test.ts", "debugger;\n"),
1417 ],
1418 );
1419
1420 let outcome = project.run().expect("runs");
1421 assert_eq!(outcome.violations.len(), 1);
1422 assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
1423 }
1424
1425 #[test]
1426 fn a_content_gate_skips_the_parse() {
1427 let gated = "import { defineRule } from 'lanekeep';\n\
1430 export default defineRule({\n\
1431 id: 'local/no-debugger',\n\
1432 query: '(debugger_statement) @stmt',\n\
1433 gates: { fileContains: ['debugger'] },\n\
1434 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
1435 check(ctx, m) { ctx.report(m.stmt); },\n\
1436 });\n";
1437
1438 let project = Project::new(
1439 "gate",
1440 &[
1441 ("rule.ts", gated),
1442 ("lanekeep.config.ts", &config("")),
1443 ("src/a.ts", "debugger;\n"),
1444 ("src/b.ts", "const b = 1;\n"),
1445 ("src/c.ts", "const c = 2;\n"),
1446 ],
1447 );
1448
1449 let outcome = project.run().expect("runs");
1450 assert_eq!(outcome.files_discovered, 3);
1451 assert_eq!(
1452 outcome.files_parsed, 1,
1453 "only the file containing the needle should parse"
1454 );
1455 assert_eq!(outcome.violations.len(), 1);
1456 }
1457
1458 #[test]
1459 fn a_rule_set_to_off_does_not_run() {
1460 let project = Project::new(
1461 "off",
1462 &[
1463 ("rule.ts", DEBUGGER_RULE),
1464 (
1465 "lanekeep.config.ts",
1466 &config(", severity: { 'local/no-debugger': 'off' }"),
1467 ),
1468 ("src/a.ts", "debugger;\n"),
1469 ],
1470 );
1471 assert!(project.run().expect("runs").violations.is_empty());
1472 }
1473
1474 #[test]
1475 fn severity_reaches_the_violation() {
1476 let project = Project::new(
1477 "severity",
1478 &[
1479 ("rule.ts", DEBUGGER_RULE),
1480 (
1481 "lanekeep.config.ts",
1482 &config(", severity: { 'local/no-debugger': 'warn' }"),
1483 ),
1484 ("src/a.ts", "debugger;\n"),
1485 ],
1486 );
1487 let outcome = project.run().expect("runs");
1488 assert_eq!(outcome.violations[0].severity, Severity::Warn);
1489 assert!(!any_failing(&outcome.violations));
1490 }
1491
1492 #[test]
1493 fn a_rule_that_throws_aborts_the_run_naming_itself_and_the_file() {
1494 let throwing = "import { defineRule } from 'lanekeep';\n\
1497 export default defineRule({\n\
1498 id: 'local/throws',\n\
1499 query: '(debugger_statement) @stmt',\n\
1500 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
1501 check() { throw new Error('rule bug'); },\n\
1502 });\n";
1503
1504 let project = Project::new(
1505 "throws",
1506 &[
1507 ("rule.ts", throwing),
1508 ("lanekeep.config.ts", &config("")),
1509 ("src/a.ts", "debugger;\n"),
1510 ],
1511 );
1512
1513 let err = project.run().expect_err("must abort");
1514 let rendered = err.to_string();
1515 assert!(rendered.contains("local/throws"), "{rendered}");
1516 assert!(rendered.contains("src/a.ts"), "{rendered}");
1517 assert!(rendered.contains("rule bug"), "{rendered}");
1518 }
1519
1520 #[test]
1521 fn an_invalid_query_fails_before_any_file_is_read() {
1522 let bad = "import { defineRule } from 'lanekeep';\n\
1523 export default defineRule({\n\
1524 id: 'local/bad-query',\n\
1525 query: '(no_such_node) @x',\n\
1526 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
1527 check() {},\n\
1528 });\n";
1529
1530 let project = Project::new(
1531 "bad-query",
1532 &[
1533 ("rule.ts", bad),
1534 ("lanekeep.config.ts", &config("")),
1535 ("src/a.ts", "debugger;\n"),
1536 ],
1537 );
1538
1539 let err = project.run().expect_err("must fail at preparation");
1540 assert!(matches!(err, RunError::Query { .. }), "{err:?}");
1541 assert!(err.to_string().contains("no_such_node"), "{err}");
1542 }
1543
1544 #[test]
1545 fn a_rule_can_use_the_host_api_it_was_given() {
1546 let rule = "import { defineRule } from 'lanekeep';\n\
1549 export default defineRule({\n\
1550 id: 'local/long-names',\n\
1551 query: '(variable_declarator name: (identifier) @name)',\n\
1552 card: { message: 'name too long', remediation: 'shorten it', examples: { bad: 'a', good: 'b' } },\n\
1553 check(ctx, m) {\n\
1554 if (ctx.text(m.name).length > 5) ctx.report(m.name, `\\\"${ctx.text(m.name)}\\\" is too long`);\n\
1555 },\n\
1556 });\n";
1557
1558 let project = Project::new(
1559 "host-api",
1560 &[
1561 ("rule.ts", rule),
1562 ("lanekeep.config.ts", &config("")),
1563 ("src/a.ts", "const ok = 1;\nconst wayTooLong = 2;\n"),
1564 ],
1565 );
1566
1567 let outcome = project.run().expect("runs");
1568 assert_eq!(outcome.violations.len(), 1);
1569 assert!(
1570 outcome.violations[0].message.contains("wayTooLong"),
1571 "{:?}",
1572 outcome.violations[0]
1573 );
1574 }
1575
1576 #[test]
1577 fn a_corpus_with_no_matches_produces_nothing() {
1578 let project = Project::new(
1579 "clean",
1580 &[
1581 ("rule.ts", DEBUGGER_RULE),
1582 ("lanekeep.config.ts", &config("")),
1583 ("src/a.ts", "const a = 1;\n"),
1584 ],
1585 );
1586 let outcome = project.run().expect("runs");
1587 assert!(outcome.violations.is_empty());
1588 assert_eq!(outcome.files_parsed, 1, "no gates means it is still parsed");
1589 }
1590
1591 const UNUSED_EXPORTS_RULE: &str = r"import { defineRule } from 'lanekeep';
1598export default defineRule({
1599 id: 'local/no-unused-exports',
1600 query: `
1601 (export_statement declaration: (function_declaration name: (identifier) @name)) @stmt
1602 (import_statement (import_clause (named_imports (import_specifier name: (identifier) @imported))))
1603 `,
1604 card: {
1605 message: 'unused export',
1606 remediation: 'delete it, or import it somewhere',
1607 examples: { bad: 'export function unused() {}', good: 'function used() {}' },
1608 },
1609 check(ctx, m) {
1610 if (m.imported) {
1611 ctx.emitFact({ kind: 'import', symbol: ctx.text(m.imported) });
1612 return;
1613 }
1614 ctx.emitFact({
1615 kind: 'export',
1616 symbol: ctx.text(m.name),
1617 line: ctx.line(m.stmt),
1618 column: ctx.column(m.stmt),
1619 });
1620 },
1621 reduce(ctx) {
1622 const imported = new Set(ctx.facts('import').map((f) => f.symbol));
1623 for (const e of ctx.facts('export')) {
1624 if (!imported.has(e.symbol)) {
1625 ctx.report({ file: e.file, line: e.line, column: e.column }, `'${e.symbol}' is exported but never imported`);
1626 }
1627 }
1628 },
1629});
1630";
1631
1632 #[test]
1633 fn a_reduce_phase_sees_facts_from_every_file() {
1634 let project = Project::new(
1635 "reduce-cross-file",
1636 &[
1637 ("rule.ts", UNUSED_EXPORTS_RULE),
1638 ("lanekeep.config.ts", &config("")),
1639 (
1640 "src/a.ts",
1641 "export function used() {}\nexport function spare() {}\n",
1642 ),
1643 ("src/b.ts", "import { used } from './a';\nused();\n"),
1644 ],
1645 );
1646
1647 let outcome = project.run().expect("runs");
1648 let found: Vec<(&str, u32, &str)> = outcome
1649 .violations
1650 .iter()
1651 .map(|v| {
1652 (
1653 v.location.file.as_str(),
1654 v.location.position.line,
1655 v.message.as_str(),
1656 )
1657 })
1658 .collect();
1659
1660 assert_eq!(
1661 found,
1662 vec![("src/a.ts", 2, "'spare' is exported but never imported")],
1663 "only the export nobody imports should be reported"
1664 );
1665 }
1666
1667 #[test]
1668 fn a_rule_with_no_reduce_still_runs() {
1669 let project = Project::new(
1671 "reduce-absent",
1672 &[
1673 ("rule.ts", DEBUGGER_RULE),
1674 ("lanekeep.config.ts", &config("")),
1675 ("src/a.ts", "debugger;\n"),
1676 ],
1677 );
1678 let outcome = project.run().expect("runs");
1679 assert_eq!(outcome.violations.len(), 1);
1680 }
1681
1682 #[test]
1683 fn a_reduce_phase_with_no_facts_reports_nothing() {
1684 let project = Project::new(
1685 "reduce-empty",
1686 &[
1687 ("rule.ts", UNUSED_EXPORTS_RULE),
1688 ("lanekeep.config.ts", &config("")),
1689 ("src/a.ts", "const a = 1;\n"),
1690 ],
1691 );
1692 assert!(project.run().expect("runs").violations.is_empty());
1693 }
1694
1695 #[test]
1696 fn the_file_list_reaches_the_reduce_phase() {
1697 const RULE: &str = r"import { defineRule } from 'lanekeep';
1698export default defineRule({
1699 id: 'local/counts-files',
1700 query: '(debugger_statement) @stmt',
1701 card: {
1702 message: 'file count',
1703 remediation: 'nothing to do',
1704 examples: { bad: 'a', good: 'b' },
1705 },
1706 check() {},
1707 reduce(ctx) {
1708 ctx.report({ file: ctx.files[0], line: ctx.files.length, column: 1 });
1709 },
1710});
1711";
1712 let project = Project::new(
1713 "reduce-files",
1714 &[
1715 ("rule.ts", RULE),
1716 ("lanekeep.config.ts", &config("")),
1717 ("src/a.ts", "const a = 1;\n"),
1718 ("src/b.ts", "const b = 1;\n"),
1719 ],
1720 );
1721
1722 let outcome = project.run().expect("runs");
1723 assert_eq!(outcome.violations.len(), 1);
1724 assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
1726 assert_eq!(outcome.violations[0].location.position.line, 2);
1727 }
1728
1729 #[test]
1730 fn a_rule_does_not_see_another_rules_facts() {
1731 const EMITTER: &str = r"import { defineRule } from 'lanekeep';
1734export default defineRule({
1735 id: 'local/emitter',
1736 query: '(export_statement) @stmt',
1737 card: { message: 'emitter', remediation: 'x', examples: { bad: 'a', good: 'b' } },
1738 check(ctx, m) { ctx.emitFact({ kind: 'thing', from: 'emitter' }); },
1739});
1740";
1741 const READER: &str = r"import { defineRule } from 'lanekeep';
1742export default defineRule({
1743 id: 'local/reader',
1744 query: '(export_statement) @stmt',
1745 card: { message: 'reader', remediation: 'x', examples: { bad: 'a', good: 'b' } },
1746 check() {},
1747 reduce(ctx) {
1748 ctx.report({ file: 'seen.ts', line: ctx.facts().length + 1, column: 1 });
1749 },
1750});
1751";
1752 let project = Project::new(
1753 "reduce-isolation",
1754 &[
1755 ("emitter.ts", EMITTER),
1756 ("reader.ts", READER),
1757 (
1758 "lanekeep.config.ts",
1759 "import { defineConfig } from 'lanekeep';\n\
1760 import emitter from './emitter';\n\
1761 import reader from './reader';\n\
1762 export default defineConfig({ include: ['src/**/*.ts'], rules: [emitter, reader] });\n",
1763 ),
1764 ("src/a.ts", "export const a = 1;\n"),
1765 ],
1766 );
1767
1768 let outcome = project.run().expect("runs");
1769 assert_eq!(outcome.violations.len(), 1);
1770 assert_eq!(
1771 outcome.violations[0].location.position.line, 1,
1772 "the reader saw the emitter's facts"
1773 );
1774 }
1775
1776 #[test]
1777 fn a_reduce_phase_that_throws_aborts_the_run() {
1778 const RULE: &str = r"import { defineRule } from 'lanekeep';
1781export default defineRule({
1782 id: 'local/throws-in-reduce',
1783 query: '(debugger_statement) @stmt',
1784 card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
1785 check() {},
1786 reduce() { throw new Error('reduce exploded'); },
1787});
1788";
1789 let project = Project::new(
1790 "reduce-throws",
1791 &[
1792 ("rule.ts", RULE),
1793 ("lanekeep.config.ts", &config("")),
1794 ("src/a.ts", "const a = 1;\n"),
1795 ],
1796 );
1797
1798 let error = project.run().expect_err("aborts");
1799 let rendered = error.to_string();
1800 assert!(rendered.contains("reduce exploded"), "{rendered}");
1801 assert!(
1802 rendered.contains("local/throws-in-reduce"),
1803 "the error should name the rule: {rendered}"
1804 );
1805 }
1806
1807 #[test]
1808 fn facts_reach_reduce_in_the_same_order_on_every_run() {
1809 const RULE: &str = r"import { defineRule } from 'lanekeep';
1818export default defineRule({
1819 id: 'local/first-fact-wins',
1820 query: '(export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name)))',
1821 card: { message: 'first', remediation: 'x', examples: { bad: 'a', good: 'b' } },
1822 check(ctx, m) { ctx.emitFact({ kind: 'sym', symbol: ctx.text(m.name) }); },
1823 reduce(ctx) {
1824 const all = ctx.facts('sym');
1825 ctx.report({ file: 'order.ts', line: 1, column: 1 }, all.map((f) => `${f.file}:${f.symbol}`).join(','));
1826 },
1827});
1828";
1829 let files: Vec<(String, String)> = (0..12)
1830 .map(|i| {
1831 (
1832 format!("src/f{i:02}.ts"),
1833 format!("export const s{i:02} = {i};\n"),
1834 )
1835 })
1836 .collect();
1837
1838 let mut layout: Vec<(&str, &str)> = vec![("rule.ts", RULE)];
1839 let config_source = config("");
1840 layout.push(("lanekeep.config.ts", &config_source));
1841 for (path, contents) in &files {
1842 layout.push((path, contents));
1843 }
1844
1845 let project = Project::new("reduce-determinism", &layout);
1846
1847 let first = project.run().expect("runs").violations[0].message.clone();
1848 for attempt in 0..4 {
1849 let again = project.run().expect("runs").violations[0].message.clone();
1850 assert_eq!(again, first, "fact order changed on attempt {attempt}");
1851 }
1852
1853 assert!(
1855 first.starts_with("src/f00.ts:s00,src/f01.ts:s01,"),
1856 "facts are not in (file, sequence) order: {first}"
1857 );
1858 }
1859
1860 #[test]
1861 fn a_rule_cannot_misattribute_a_fact_to_another_file() {
1862 const RULE: &str = r"import { defineRule } from 'lanekeep';
1864export default defineRule({
1865 id: 'local/lying-fact',
1866 query: '(export_statement) @stmt',
1867 card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
1868 check(ctx, m) { ctx.emitFact({ kind: 'e', file: 'somewhere-else.ts' }); },
1869 reduce(ctx) {
1870 for (const f of ctx.facts('e')) ctx.report({ file: f.file, line: 1, column: 1 });
1871 },
1872});
1873";
1874 let project = Project::new(
1875 "reduce-misattribution",
1876 &[
1877 ("rule.ts", RULE),
1878 ("lanekeep.config.ts", &config("")),
1879 ("src/a.ts", "export const a = 1;\n"),
1880 ],
1881 );
1882
1883 let outcome = project.run().expect("runs");
1884 assert_eq!(outcome.violations.len(), 1);
1885 assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
1886 }
1887
1888 const READING_RULE: &str = r"import { defineRule } from 'lanekeep';
1892export default defineRule({
1893 id: 'local/reads-config',
1894 query: '(export_statement) @stmt',
1895 card: {
1896 message: 'config says no',
1897 remediation: 'change the config, or the code',
1898 examples: { bad: 'export const a = 1;', good: 'const a = 1;' },
1899 },
1900 check(ctx, m) {
1901 const raw = ctx.readFile('policy.json');
1902 if (raw && JSON.parse(raw).forbidExports) ctx.report(m.stmt);
1903 },
1904});
1905";
1906
1907 #[test]
1908 fn a_rule_can_read_another_file() {
1909 let project = Project::new(
1910 "reads-allowed",
1911 &[
1912 ("rule.ts", READING_RULE),
1913 ("lanekeep.config.ts", &config("")),
1914 ("policy.json", r#"{"forbidExports":true}"#),
1915 ("src/a.ts", "export const a = 1;\n"),
1916 ],
1917 );
1918 let outcome = project.run().expect("runs");
1919 assert_eq!(outcome.violations.len(), 1, "{:?}", outcome.violations);
1920 }
1921
1922 #[test]
1923 fn what_the_file_says_changes_the_result() {
1924 let project = Project::new(
1926 "reads-content",
1927 &[
1928 ("rule.ts", READING_RULE),
1929 ("lanekeep.config.ts", &config("")),
1930 ("policy.json", r#"{"forbidExports":false}"#),
1931 ("src/a.ts", "export const a = 1;\n"),
1932 ],
1933 );
1934 assert!(project.run().expect("runs").violations.is_empty());
1935 }
1936
1937 #[test]
1938 fn a_read_is_recorded_against_the_file_that_made_it() {
1939 let mut layout: Vec<(String, String)> = vec![
1947 ("rule.ts".to_owned(), READING_RULE.to_owned()),
1948 ("lanekeep.config.ts".to_owned(), config("")),
1949 (
1950 "policy.json".to_owned(),
1951 r#"{"forbidExports":false}"#.to_owned(),
1952 ),
1953 ];
1954 for i in 0..24 {
1956 let body = if i % 2 == 0 {
1957 format!("const v{i} = {i};\n")
1958 } else {
1959 format!("export const v{i} = {i};\n")
1960 };
1961 layout.push((format!("src/f{i:02}.ts"), body));
1962 }
1963 let borrowed: Vec<(&str, &str)> = layout
1964 .iter()
1965 .map(|(p, c)| (p.as_str(), c.as_str()))
1966 .collect();
1967
1968 let project = Project::new("reads-attributed", &borrowed);
1969 let outcome = project.run().expect("runs");
1970
1971 for i in 0..24 {
1972 let file = FilePath::new(format!("src/f{i:02}.ts"));
1973 let deps = outcome.dependencies.get(&file);
1974 if i % 2 == 0 {
1975 assert!(
1976 deps.is_none(),
1977 "src/f{i:02}.ts read nothing but has {deps:?}"
1978 );
1979 } else {
1980 let deps = deps.unwrap_or_else(|| panic!("src/f{i:02}.ts should have read"));
1981 assert_eq!(deps.len(), 1);
1982 assert_eq!(deps[0].path.as_str(), "policy.json");
1983 assert!(deps[0].hash.is_some());
1984 }
1985 }
1986 }
1987
1988 #[test]
1989 fn a_missing_file_is_recorded_as_a_dependency_too() {
1990 const RULE: &str = r"import { defineRule } from 'lanekeep';
1993export default defineRule({
1994 id: 'local/wants-config',
1995 query: '(export_statement) @stmt',
1996 card: { message: 'no config', remediation: 'add one', examples: { bad: 'a', good: 'b' } },
1997 check(ctx, m) {
1998 if (!ctx.fileExists('tsconfig.json')) ctx.report(m.stmt);
1999 },
2000});
2001";
2002 let project = Project::new(
2003 "reads-absent",
2004 &[
2005 ("rule.ts", RULE),
2006 ("lanekeep.config.ts", &config("")),
2007 ("src/a.ts", "export const a = 1;\n"),
2008 ],
2009 );
2010
2011 let outcome = project.run().expect("runs");
2012 assert_eq!(outcome.violations.len(), 1);
2013
2014 let deps = outcome
2015 .dependencies
2016 .get(&FilePath::new("src/a.ts"))
2017 .expect("the miss is a dependency");
2018 assert_eq!(deps.len(), 1);
2019 assert_eq!(deps[0].path.as_str(), "tsconfig.json");
2020 assert_eq!(deps[0].hash, None, "absence is recorded as absence");
2021 }
2022
2023 #[test]
2024 fn reading_outside_the_project_aborts_the_run() {
2025 const RULE: &str = r"import { defineRule } from 'lanekeep';
2029export default defineRule({
2030 id: 'local/escapes',
2031 query: '(export_statement) @stmt',
2032 card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
2033 check(ctx) { ctx.readFile('../../../etc/passwd'); },
2034});
2035";
2036 let project = Project::new(
2037 "reads-escape",
2038 &[
2039 ("rule.ts", RULE),
2040 ("lanekeep.config.ts", &config("")),
2041 ("src/a.ts", "export const a = 1;\n"),
2042 ],
2043 );
2044
2045 let error = project.run().expect_err("aborts");
2046 let rendered = error.to_string();
2047 assert!(rendered.contains("outside the project root"), "{rendered}");
2048 assert!(rendered.contains("local/escapes"), "{rendered}");
2049 }
2050
2051 #[test]
2052 fn reading_the_same_file_from_two_files_records_it_under_both() {
2053 let project = Project::new(
2054 "reads-shared",
2055 &[
2056 ("rule.ts", READING_RULE),
2057 ("lanekeep.config.ts", &config("")),
2058 ("policy.json", r#"{"forbidExports":false}"#),
2059 ("src/a.ts", "export const a = 1;\n"),
2060 ("src/b.ts", "export const b = 1;\n"),
2061 ],
2062 );
2063
2064 let outcome = project.run().expect("runs");
2065 for file in ["src/a.ts", "src/b.ts"] {
2066 let deps = outcome
2067 .dependencies
2068 .get(&FilePath::new(file))
2069 .unwrap_or_else(|| panic!("{file} should depend on the policy"));
2070 assert_eq!(deps[0].path.as_str(), "policy.json");
2071 }
2072
2073 let a = &outcome.dependencies[&FilePath::new("src/a.ts")][0];
2076 let b = &outcome.dependencies[&FilePath::new("src/b.ts")][0];
2077 assert_eq!(a.hash, b.hash);
2078 }
2079
2080 #[test]
2081 fn dependencies_are_the_same_on_every_run() {
2082 let project = Project::new(
2083 "reads-deterministic",
2084 &[
2085 ("rule.ts", READING_RULE),
2086 ("lanekeep.config.ts", &config("")),
2087 ("policy.json", r#"{"forbidExports":false}"#),
2088 ("src/a.ts", "export const a = 1;\n"),
2089 ("src/b.ts", "export const b = 1;\n"),
2090 ("src/c.ts", "export const c = 1;\n"),
2091 ],
2092 );
2093 let first = project.run().expect("runs").dependencies;
2094 assert!(!first.is_empty());
2095 for attempt in 0..4 {
2096 assert_eq!(
2097 project.run().expect("runs").dependencies,
2098 first,
2099 "dependencies changed on attempt {attempt}"
2100 );
2101 }
2102 }
2103
2104 #[test]
2105 fn the_read_surface_is_absent_from_the_reduce_phase() {
2106 const RULE: &str = r"import { defineRule } from 'lanekeep';
2110export default defineRule({
2111 id: 'local/reduce-reads',
2112 query: '(export_statement) @stmt',
2113 card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
2114 check() {},
2115 reduce(ctx) {
2116 const absent = ctx.readFile === undefined && ctx.fileExists === undefined;
2117 ctx.report({ file: 'probe.ts', line: absent ? 1 : 2, column: 1 });
2118 },
2119});
2120";
2121 let project = Project::new(
2122 "reads-reduce",
2123 &[
2124 ("rule.ts", RULE),
2125 ("lanekeep.config.ts", &config("")),
2126 ("src/a.ts", "export const a = 1;\n"),
2127 ],
2128 );
2129
2130 let outcome = project.run().expect("runs");
2131 assert_eq!(outcome.violations.len(), 1);
2132 assert_eq!(
2133 outcome.violations[0].location.position.line, 1,
2134 "reads must not be reachable from a reduce phase"
2135 );
2136 }
2137
2138 impl Project {
2141 fn run_cold(&self) -> Result<Outcome, RunError> {
2143 self.build().map(Engine::without_cache)?.run()
2144 }
2145
2146 fn build(&self) -> Result<Engine, RunError> {
2148 let root = RuleRoot::new(&self.dir).expect("canonicalizes");
2149 let config_path = self.dir.join("lanekeep.config.ts");
2150 let sandbox =
2151 lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
2152 .expect("sandbox");
2153 let config = lanekeep_config::load(&sandbox, &root, &config_path)
2154 .unwrap_or_else(|e| panic!("config failed to load: {e}"));
2155 Engine::prepare(
2156 &config,
2157 &self.dir,
2158 root,
2159 &config_path,
2160 &lanekeep_lang_js::registry(),
2161 Arc::new(TypeScript),
2162 Arc::new(JavaScript),
2163 )
2164 }
2165
2166 fn cache(&self) -> Store {
2167 Store::load(&self.dir)
2168 }
2169 }
2170
2171 fn rendered(outcome: &Outcome) -> Vec<String> {
2172 outcome
2173 .violations
2174 .iter()
2175 .map(|v| {
2176 format!(
2177 "{}:{}:{} {} {}",
2178 v.location.file.as_str(),
2179 v.location.position.line,
2180 v.location.position.column,
2181 v.rule_id,
2182 v.message
2183 )
2184 })
2185 .collect()
2186 }
2187
2188 #[test]
2189 fn a_warm_run_agrees_with_a_cold_one() {
2190 let project = Project::new(
2191 "cache-agrees",
2192 &[
2193 ("rule.ts", DEBUGGER_RULE),
2194 ("lanekeep.config.ts", &config("")),
2195 ("src/a.ts", "debugger;\nconst a = 1;\n"),
2196 ("src/b.ts", "const b = 1;\ndebugger;\n"),
2197 ("src/c.ts", "const c = 1;\n"),
2198 ],
2199 );
2200
2201 let cold = rendered(&project.run().expect("runs"));
2202 let warm = rendered(&project.run().expect("runs"));
2203 assert_eq!(warm, cold, "the cache changed the answer");
2204 assert!(!cold.is_empty(), "the fixture should report something");
2205 }
2206
2207 #[test]
2208 fn a_run_writes_a_cache() {
2209 let project = Project::new(
2210 "cache-written",
2211 &[
2212 ("rule.ts", DEBUGGER_RULE),
2213 ("lanekeep.config.ts", &config("")),
2214 ("src/a.ts", "debugger;\n"),
2215 ],
2216 );
2217 assert!(project.cache().is_empty(), "nothing before the first run");
2218 project.run().expect("runs");
2219 assert!(!project.cache().is_empty(), "the run stored nothing");
2220 }
2221
2222 #[test]
2223 fn a_cached_result_is_actually_used() {
2224 let project = Project::new(
2228 "cache-used",
2229 &[
2230 ("rule.ts", DEBUGGER_RULE),
2231 ("lanekeep.config.ts", &config("")),
2232 ("src/a.ts", "const a = 1;\n"),
2233 ],
2234 );
2235 assert!(project.run().expect("runs").violations.is_empty());
2236
2237 let store = project.cache();
2238 let key = *store
2239 .keys()
2240 .next()
2241 .expect("the run stored an entry for the file");
2242
2243 let mut doctored = Store::empty();
2244 doctored.insert(
2245 key,
2246 lanekeep_cache::Entry {
2247 violations: vec![Violation {
2248 rule_id: "local/no-debugger".parse().expect("valid id"),
2249 location: Location::new(FilePath::new("src/a.ts"), Position::new(7, 3)),
2250 message: "from the cache".to_owned(),
2251 remediation: "nothing".to_owned(),
2252 severity: Severity::Error,
2253 fix: None,
2254 }],
2255 facts: Vec::new(),
2256 dependencies: Vec::new(),
2257 suppressions: Vec::new(),
2258 used_suppressions: Vec::new(),
2259 },
2260 );
2261 doctored.save(&project.dir);
2262
2263 let outcome = project.run().expect("runs");
2264 assert_eq!(
2265 rendered(&outcome),
2266 vec!["src/a.ts:7:3 local/no-debugger from the cache"],
2267 "the cached entry was not used"
2268 );
2269 }
2270
2271 #[test]
2272 fn editing_a_file_invalidates_it() {
2273 let project = Project::new(
2274 "cache-edited",
2275 &[
2276 ("rule.ts", DEBUGGER_RULE),
2277 ("lanekeep.config.ts", &config("")),
2278 ("src/a.ts", "const a = 1;\n"),
2279 ],
2280 );
2281 assert!(project.run().expect("runs").violations.is_empty());
2282
2283 project.write("src/a.ts", "debugger;\n");
2284 assert_eq!(
2285 project.run().expect("runs").violations.len(),
2286 1,
2287 "an edited file kept its stale result"
2288 );
2289 }
2290
2291 #[test]
2292 fn moving_a_file_invalidates_it() {
2293 let project = Project::new(
2296 "cache-moved",
2297 &[
2298 ("rule.ts", DEBUGGER_RULE),
2299 ("lanekeep.config.ts", &config("")),
2300 ("src/a.ts", "debugger;\n"),
2301 ],
2302 );
2303 project.run().expect("runs");
2304
2305 fs::remove_file(project.dir.join("src/a.ts")).expect("removes");
2306 project.write("src/moved.ts", "debugger;\n");
2307
2308 let outcome = project.run().expect("runs");
2309 assert_eq!(
2310 outcome.violations[0].location.file.as_str(),
2311 "src/moved.ts",
2312 "the violation followed the old path"
2313 );
2314 }
2315
2316 #[test]
2317 fn editing_a_tracked_dependency_invalidates_the_files_that_read_it() {
2318 let project = Project::new(
2321 "cache-dependency",
2322 &[
2323 ("rule.ts", READING_RULE),
2324 ("lanekeep.config.ts", &config("")),
2325 ("policy.json", r#"{"forbidExports":false}"#),
2326 ("src/a.ts", "export const a = 1;\n"),
2327 ],
2328 );
2329 assert!(project.run().expect("runs").violations.is_empty());
2330
2331 project.write("policy.json", r#"{"forbidExports":true}"#);
2332 assert_eq!(
2333 project.run().expect("runs").violations.len(),
2334 1,
2335 "a changed dependency did not invalidate"
2336 );
2337 }
2338
2339 #[test]
2340 fn a_dependency_that_appears_invalidates() {
2341 const RULE: &str = r"import { defineRule } from 'lanekeep';
2344export default defineRule({
2345 id: 'local/wants-config',
2346 query: '(export_statement) @stmt',
2347 card: { message: 'no config', remediation: 'add one', examples: { bad: 'a', good: 'b' } },
2348 check(ctx, m) {
2349 if (!ctx.fileExists('tsconfig.json')) ctx.report(m.stmt);
2350 },
2351});
2352";
2353 let project = Project::new(
2354 "cache-appeared",
2355 &[
2356 ("rule.ts", RULE),
2357 ("lanekeep.config.ts", &config("")),
2358 ("src/a.ts", "export const a = 1;\n"),
2359 ],
2360 );
2361 assert_eq!(project.run().expect("runs").violations.len(), 1);
2362
2363 project.write("tsconfig.json", "{}");
2364 assert!(
2365 project.run().expect("runs").violations.is_empty(),
2366 "a dependency that appeared did not invalidate"
2367 );
2368 }
2369
2370 #[test]
2371 fn changing_the_ruleset_invalidates_everything() {
2372 let project = Project::new(
2373 "cache-ruleset",
2374 &[
2375 ("rule.ts", DEBUGGER_RULE),
2376 ("lanekeep.config.ts", &config("")),
2377 ("src/a.ts", "debugger;\n"),
2378 ],
2379 );
2380 assert_eq!(project.run().expect("runs").violations.len(), 1);
2381
2382 project.write(
2384 "rule.ts",
2385 &DEBUGGER_RULE.replace("ctx.report(m.stmt);", "/* nothing */"),
2386 );
2387 assert!(
2388 project.run().expect("runs").violations.is_empty(),
2389 "an edited rule kept its stale results"
2390 );
2391 }
2392
2393 #[test]
2394 fn changing_the_config_invalidates_everything() {
2395 let project = Project::new(
2396 "cache-config",
2397 &[
2398 ("rule.ts", DEBUGGER_RULE),
2399 ("lanekeep.config.ts", &config("")),
2400 ("src/a.ts", "debugger;\n"),
2401 ],
2402 );
2403 assert_eq!(project.run().expect("runs").violations.len(), 1);
2404
2405 project.write(
2406 "lanekeep.config.ts",
2407 &config(", severity: { 'local/no-debugger': 'off' }"),
2408 );
2409 assert!(
2410 project.run().expect("runs").violations.is_empty(),
2411 "a config change did not invalidate"
2412 );
2413 }
2414
2415 #[test]
2416 fn a_corrupt_cache_still_produces_the_right_answer() {
2417 let project = Project::new(
2419 "cache-corrupt",
2420 &[
2421 ("rule.ts", DEBUGGER_RULE),
2422 ("lanekeep.config.ts", &config("")),
2423 ("src/a.ts", "debugger;\n"),
2424 ],
2425 );
2426 let expected = rendered(&project.run().expect("runs"));
2427
2428 let path = Store::path_for(&project.dir);
2429 fs::write(&path, b"\x00\x01\x02 not a cache").expect("writes");
2430
2431 assert_eq!(rendered(&project.run().expect("runs")), expected);
2432 }
2433
2434 #[test]
2435 fn caching_can_be_turned_off() {
2436 let project = Project::new(
2437 "cache-off",
2438 &[
2439 ("rule.ts", DEBUGGER_RULE),
2440 ("lanekeep.config.ts", &config("")),
2441 ("src/a.ts", "debugger;\n"),
2442 ],
2443 );
2444 let outcome = project.run_cold().expect("runs");
2445 assert_eq!(outcome.violations.len(), 1);
2446 assert!(
2447 project.cache().is_empty(),
2448 "a run with caching off wrote a cache"
2449 );
2450 }
2451
2452 #[test]
2453 fn facts_survive_a_warm_run() {
2454 let project = Project::new(
2459 "cache-facts",
2460 &[
2461 ("rule.ts", UNUSED_EXPORTS_RULE),
2462 ("lanekeep.config.ts", &config("")),
2463 (
2464 "src/a.ts",
2465 "export function used() {}\nexport function spare() {}\n",
2466 ),
2467 ("src/b.ts", "import { used } from './a';\nused();\n"),
2468 ],
2469 );
2470
2471 let cold = rendered(&project.run().expect("runs"));
2472 assert_eq!(cold.len(), 1, "{cold:?}");
2473 assert_eq!(rendered(&project.run().expect("runs")), cold);
2474 assert_eq!(rendered(&project.run().expect("runs")), cold);
2475 }
2476
2477 #[test]
2478 fn a_cache_file_does_not_churn() {
2479 let project = Project::new(
2482 "cache-stable",
2483 &[
2484 ("rule.ts", DEBUGGER_RULE),
2485 ("lanekeep.config.ts", &config("")),
2486 ("src/a.ts", "debugger;\n"),
2487 ("src/b.ts", "const b = 1;\n"),
2488 ],
2489 );
2490 project.run().expect("runs");
2491 let first = fs::read(Store::path_for(&project.dir)).expect("reads");
2492 project.run().expect("runs");
2493 let second = fs::read(Store::path_for(&project.dir)).expect("reads");
2494 assert_eq!(first, second, "the cache file churned");
2495 }
2496
2497 #[test]
2498 fn entries_for_deleted_files_do_not_accumulate() {
2499 let project = Project::new(
2500 "cache-prune",
2501 &[
2502 ("rule.ts", DEBUGGER_RULE),
2503 ("lanekeep.config.ts", &config("")),
2504 ("src/a.ts", "debugger;\n"),
2505 ("src/b.ts", "debugger;\n"),
2506 ],
2507 );
2508 project.run().expect("runs");
2509 assert_eq!(project.cache().len(), 2);
2510
2511 fs::remove_file(project.dir.join("src/b.ts")).expect("removes");
2512 project.run().expect("runs");
2513 assert_eq!(
2514 project.cache().len(),
2515 1,
2516 "an entry outlived the file it was for"
2517 );
2518 }
2519
2520 #[test]
2521 fn a_partial_run_does_not_discard_other_files_entries() {
2522 let project = Project::new(
2526 "cache-partial",
2527 &[
2528 ("rule.ts", DEBUGGER_RULE),
2529 ("lanekeep.config.ts", &config("")),
2530 ("src/a.ts", "debugger;\n"),
2531 ("src/b.ts", "const b = 1;\n"),
2532 ("src/c.ts", "const c = 1;\n"),
2533 ],
2534 );
2535 project.run().expect("runs");
2536 assert_eq!(project.cache().len(), 3);
2537
2538 let engine = project.build().expect("prepares");
2539 engine
2540 .run_over(&[FilePath::new("src/a.ts")])
2541 .expect("runs over one file");
2542
2543 assert_eq!(
2544 project.cache().len(),
2545 3,
2546 "a partial run discarded entries for files it did not look at"
2547 );
2548 }
2549
2550 #[test]
2551 fn a_full_run_still_prunes() {
2552 let project = Project::new(
2555 "cache-prune-still",
2556 &[
2557 ("rule.ts", DEBUGGER_RULE),
2558 ("lanekeep.config.ts", &config("")),
2559 ("src/a.ts", "debugger;\n"),
2560 ("src/b.ts", "const b = 1;\n"),
2561 ],
2562 );
2563 project.run().expect("runs");
2564 assert_eq!(project.cache().len(), 2);
2565
2566 fs::remove_file(project.dir.join("src/b.ts")).expect("removes");
2567 project.run().expect("runs");
2568 assert_eq!(project.cache().len(), 1);
2569 }
2570
2571 impl Project {
2574 fn run_on(&self, today: &str) -> Result<Outcome, RunError> {
2576 let date = Date::parse(today).expect("valid date");
2577 self.build().map(|engine| engine.with_today(date))?.run()
2578 }
2579 }
2580
2581 fn messages(outcome: &Outcome) -> Vec<&str> {
2582 outcome
2583 .violations
2584 .iter()
2585 .map(|v| v.message.as_str())
2586 .collect()
2587 }
2588
2589 #[test]
2590 fn a_next_line_directive_silences_the_line_below_it() {
2591 let project = Project::new(
2592 "suppress-next-line",
2593 &[
2594 ("rule.ts", DEBUGGER_RULE),
2595 ("lanekeep.config.ts", &config("")),
2596 (
2597 "src/a.ts",
2598 "// lanekeep-ignore-next-line local/no-debugger reason: legacy entry point\n\
2599 debugger;\n",
2600 ),
2601 ],
2602 );
2603 assert!(
2604 project.run().expect("runs").violations.is_empty(),
2605 "the directive did not silence the violation"
2606 );
2607 }
2608
2609 #[test]
2610 fn a_directive_silences_only_the_line_it_names() {
2611 let project = Project::new(
2612 "suppress-scope",
2613 &[
2614 ("rule.ts", DEBUGGER_RULE),
2615 ("lanekeep.config.ts", &config("")),
2616 (
2617 "src/a.ts",
2618 "// lanekeep-ignore-next-line local/no-debugger reason: legacy\n\
2619 debugger;\n\
2620 debugger;\n",
2621 ),
2622 ],
2623 );
2624 let outcome = project.run().expect("runs");
2625 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
2626 assert_eq!(outcome.violations[0].location.position.line, 3);
2627 }
2628
2629 #[test]
2630 fn a_file_directive_silences_every_line() {
2631 let project = Project::new(
2632 "suppress-file",
2633 &[
2634 ("rule.ts", DEBUGGER_RULE),
2635 ("lanekeep.config.ts", &config("")),
2636 (
2637 "src/a.ts",
2638 "// lanekeep-ignore-file local/no-debugger reason: generated fixture\n\
2639 debugger;\n\
2640 debugger;\n",
2641 ),
2642 ],
2643 );
2644 assert!(project.run().expect("runs").violations.is_empty());
2645 }
2646
2647 #[test]
2648 fn a_directive_naming_another_rule_silences_nothing() {
2649 let project = Project::new(
2650 "suppress-other-rule",
2651 &[
2652 ("rule.ts", DEBUGGER_RULE),
2653 ("lanekeep.config.ts", &config("")),
2654 (
2655 "src/a.ts",
2656 "// lanekeep-ignore-next-line local/something-else reason: unrelated\n\
2657 debugger;\n",
2658 ),
2659 ],
2660 );
2661 assert_eq!(project.run().expect("runs").violations.len(), 1);
2662 }
2663
2664 #[test]
2665 fn a_malformed_directive_is_reported() {
2666 let project = Project::new(
2670 "suppress-malformed",
2671 &[
2672 ("rule.ts", DEBUGGER_RULE),
2673 ("lanekeep.config.ts", &config("")),
2674 (
2675 "src/a.ts",
2676 "// lanekeep-ignore-next-line local/no-debugger\ndebugger;\n",
2677 ),
2678 ],
2679 );
2680
2681 let outcome = project.run().expect("runs");
2682 assert_eq!(outcome.violations.len(), 2, "{:?}", messages(&outcome));
2683 assert!(
2684 messages(&outcome)
2685 .iter()
2686 .any(|m| m.contains("no `reason:`")),
2687 "{:?}",
2688 messages(&outcome)
2689 );
2690 assert!(
2691 outcome
2692 .violations
2693 .iter()
2694 .any(|v| v.rule_id.to_string() == "lanekeep/suppression"),
2695 "reported under the wrong id"
2696 );
2697 }
2698
2699 #[test]
2700 fn an_expired_directive_is_reported_and_still_silences() {
2701 let project = Project::new(
2704 "suppress-expired",
2705 &[
2706 ("rule.ts", DEBUGGER_RULE),
2707 ("lanekeep.config.ts", &config("")),
2708 (
2709 "src/a.ts",
2710 "// lanekeep-ignore-next-line local/no-debugger reason: pending rewrite expires: 2026-01-01\n\
2711 debugger;\n",
2712 ),
2713 ],
2714 );
2715
2716 let outcome = project.run_on("2026-08-01").expect("runs");
2717 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
2718 assert!(
2719 outcome.violations[0]
2720 .message
2721 .contains("expired on 2026-01-01"),
2722 "{:?}",
2723 messages(&outcome)
2724 );
2725 assert!(
2726 outcome.violations[0].message.contains("pending rewrite"),
2727 "the reason should be quoted back: {:?}",
2728 messages(&outcome)
2729 );
2730 }
2731
2732 #[test]
2733 fn a_directive_that_has_not_expired_is_quiet() {
2734 let project = Project::new(
2735 "suppress-unexpired",
2736 &[
2737 ("rule.ts", DEBUGGER_RULE),
2738 ("lanekeep.config.ts", &config("")),
2739 (
2740 "src/a.ts",
2741 "// lanekeep-ignore-next-line local/no-debugger reason: pending expires: 2026-12-31\n\
2742 debugger;\n",
2743 ),
2744 ],
2745 );
2746 assert!(
2747 project
2748 .run_on("2026-08-01")
2749 .expect("runs")
2750 .violations
2751 .is_empty()
2752 );
2753 }
2754
2755 #[test]
2756 fn a_directive_expires_the_day_after_its_date() {
2757 let project = Project::new(
2760 "suppress-boundary",
2761 &[
2762 ("rule.ts", DEBUGGER_RULE),
2763 ("lanekeep.config.ts", &config("")),
2764 (
2765 "src/a.ts",
2766 "// lanekeep-ignore-file local/no-debugger reason: x expires: 2026-08-01\n\
2767 debugger;\n",
2768 ),
2769 ],
2770 );
2771 assert!(
2772 project
2773 .run_on("2026-08-01")
2774 .expect("runs")
2775 .violations
2776 .is_empty()
2777 );
2778 assert_eq!(
2779 project.run_on("2026-08-02").expect("runs").violations.len(),
2780 1
2781 );
2782 }
2783
2784 #[test]
2785 fn an_expiring_directive_is_not_served_stale_from_the_cache() {
2786 let project = Project::new(
2790 "suppress-cache-date",
2791 &[
2792 ("rule.ts", DEBUGGER_RULE),
2793 ("lanekeep.config.ts", &config("")),
2794 (
2795 "src/a.ts",
2796 "// lanekeep-ignore-file local/no-debugger reason: x expires: 2026-08-01\n\
2797 debugger;\n",
2798 ),
2799 ],
2800 );
2801
2802 assert!(
2803 project
2804 .run_on("2026-08-01")
2805 .expect("runs")
2806 .violations
2807 .is_empty()
2808 );
2809 let after = project.run_on("2026-08-02").expect("runs");
2810 assert_eq!(
2811 after.violations.len(),
2812 1,
2813 "a warm run served an expired suppression: {:?}",
2814 messages(&after)
2815 );
2816 }
2817
2818 #[test]
2819 fn suppressions_survive_a_warm_run() {
2820 let project = Project::new(
2821 "suppress-warm",
2822 &[
2823 ("rule.ts", DEBUGGER_RULE),
2824 ("lanekeep.config.ts", &config("")),
2825 (
2826 "src/a.ts",
2827 "// lanekeep-ignore-file local/no-debugger reason: generated\ndebugger;\n",
2828 ),
2829 ],
2830 );
2831 assert!(project.run().expect("runs").violations.is_empty());
2832 assert!(
2833 project.run().expect("runs").violations.is_empty(),
2834 "the warm run reported what the cold one suppressed"
2835 );
2836 }
2837
2838 #[test]
2839 fn a_cross_file_violation_is_silenced_by_the_directive_where_it_lands() {
2840 let project = Project::new(
2844 "suppress-cross-file",
2845 &[
2846 ("rule.ts", UNUSED_EXPORTS_RULE),
2847 ("lanekeep.config.ts", &config("")),
2848 (
2849 "src/a.ts",
2850 "export function used() {}\n\
2851 // lanekeep-ignore-next-line local/no-unused-exports reason: public API\n\
2852 export function spare() {}\n",
2853 ),
2854 ("src/b.ts", "import { used } from './a';\nused();\n"),
2855 ],
2856 );
2857
2858 let outcome = project.run().expect("runs");
2859 assert!(
2860 outcome.violations.is_empty(),
2861 "a cross-file violation ignored the directive at its site: {:?}",
2862 messages(&outcome)
2863 );
2864 }
2865
2866 #[test]
2867 fn a_cross_file_violation_survives_a_directive_for_another_rule() {
2868 let project = Project::new(
2869 "suppress-cross-file-other",
2870 &[
2871 ("rule.ts", UNUSED_EXPORTS_RULE),
2872 ("lanekeep.config.ts", &config("")),
2873 (
2874 "src/a.ts",
2875 "export function used() {}\n\
2876 // lanekeep-ignore-next-line local/unrelated reason: x\n\
2877 export function spare() {}\n",
2878 ),
2879 ("src/b.ts", "import { used } from './a';\nused();\n"),
2880 ],
2881 );
2882 assert_eq!(project.run().expect("runs").violations.len(), 1);
2883 }
2884
2885 impl Project {
2888 fn run_reporting_unused(&self) -> Result<Outcome, RunError> {
2889 self.build()
2890 .map(Engine::reporting_unused_suppressions)?
2891 .run()
2892 }
2893 }
2894
2895 #[test]
2896 fn a_suppression_that_silenced_nothing_is_reported() {
2897 let project = Project::new(
2898 "unused-reported",
2899 &[
2900 ("rule.ts", DEBUGGER_RULE),
2901 ("lanekeep.config.ts", &config("")),
2902 (
2903 "src/a.ts",
2904 "// lanekeep-ignore-next-line local/no-debugger reason: was needed once\n\
2905 const a = 1;\n",
2906 ),
2907 ],
2908 );
2909
2910 let outcome = project.run_reporting_unused().expect("runs");
2911 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
2912 assert!(
2913 outcome.violations[0].message.contains("silenced nothing"),
2914 "{:?}",
2915 messages(&outcome)
2916 );
2917 assert!(
2918 outcome.violations[0].message.contains("was needed once"),
2919 "the reason should be quoted back: {:?}",
2920 messages(&outcome)
2921 );
2922 }
2923
2924 #[test]
2925 fn a_suppression_that_did_its_job_is_not_reported() {
2926 let project = Project::new(
2927 "unused-used",
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\ndebugger;\n",
2934 ),
2935 ],
2936 );
2937 assert!(
2938 project
2939 .run_reporting_unused()
2940 .expect("runs")
2941 .violations
2942 .is_empty()
2943 );
2944 }
2945
2946 #[test]
2947 fn unused_suppressions_are_quiet_without_the_flag() {
2948 let project = Project::new(
2950 "unused-off",
2951 &[
2952 ("rule.ts", DEBUGGER_RULE),
2953 ("lanekeep.config.ts", &config("")),
2954 (
2955 "src/a.ts",
2956 "// lanekeep-ignore-next-line local/no-debugger reason: stale\nconst a = 1;\n",
2957 ),
2958 ],
2959 );
2960 assert!(project.run().expect("runs").violations.is_empty());
2961 }
2962
2963 #[test]
2964 fn an_unused_suppression_is_a_warning_not_an_error() {
2965 let project = Project::new(
2967 "unused-severity",
2968 &[
2969 ("rule.ts", DEBUGGER_RULE),
2970 ("lanekeep.config.ts", &config("")),
2971 (
2972 "src/a.ts",
2973 "// lanekeep-ignore-next-line local/no-debugger reason: stale\nconst a = 1;\n",
2974 ),
2975 ],
2976 );
2977 let outcome = project.run_reporting_unused().expect("runs");
2978 assert_eq!(outcome.violations[0].severity, Severity::Warn);
2979 assert!(!lanekeep_core::any_failing(&outcome.violations));
2980 }
2981
2982 #[test]
2983 fn usage_survives_a_warm_run() {
2984 let project = Project::new(
2988 "unused-warm",
2989 &[
2990 ("rule.ts", DEBUGGER_RULE),
2991 ("lanekeep.config.ts", &config("")),
2992 (
2993 "src/a.ts",
2994 "// lanekeep-ignore-next-line local/no-debugger reason: legacy\ndebugger;\n",
2995 ),
2996 ],
2997 );
2998
2999 assert!(
3000 project
3001 .run_reporting_unused()
3002 .expect("runs")
3003 .violations
3004 .is_empty()
3005 );
3006 let warm = project.run_reporting_unused().expect("runs");
3007 assert!(
3008 warm.violations.is_empty(),
3009 "a warm run called a used suppression unused: {:?}",
3010 messages(&warm)
3011 );
3012 }
3013
3014 #[test]
3015 fn a_suppression_used_only_by_a_cross_file_rule_is_not_unused() {
3016 let project = Project::new(
3019 "unused-cross-file",
3020 &[
3021 ("rule.ts", UNUSED_EXPORTS_RULE),
3022 ("lanekeep.config.ts", &config("")),
3023 (
3024 "src/a.ts",
3025 "export function used() {}\n\
3026 // lanekeep-ignore-next-line local/no-unused-exports reason: public API\n\
3027 export function spare() {}\n",
3028 ),
3029 ("src/b.ts", "import { used } from './a';\nused();\n"),
3030 ],
3031 );
3032
3033 let outcome = project.run_reporting_unused().expect("runs");
3034 assert!(
3035 outcome.violations.is_empty(),
3036 "a directive used by a cross-file rule was called unused: {:?}",
3037 messages(&outcome)
3038 );
3039 }
3040
3041 #[test]
3042 fn a_malformed_directive_is_not_also_reported_as_unused() {
3043 let project = Project::new(
3046 "unused-malformed",
3047 &[
3048 ("rule.ts", DEBUGGER_RULE),
3049 ("lanekeep.config.ts", &config("")),
3050 (
3051 "src/a.ts",
3052 "// lanekeep-ignore-next-line local/no-debugger\nconst a = 1;\n",
3053 ),
3054 ],
3055 );
3056
3057 let outcome = project.run_reporting_unused().expect("runs");
3058 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3059 assert!(
3060 outcome.violations[0].message.contains("no `reason:`"),
3061 "{:?}",
3062 messages(&outcome)
3063 );
3064 }
3065
3066 const DATE_RULE: &str = r"import { defineRule } from 'lanekeep';
3070export default defineRule({
3071 id: 'local/dated',
3072 query: '(export_statement) @stmt',
3073 card: { message: 'dated', remediation: 'x', examples: { bad: 'a', good: 'b' } },
3074 check(ctx, m) {
3075 if (ctx.today.startsWith('2027')) ctx.report(m.stmt, `it is ${ctx.today}`);
3076 },
3077});
3078";
3079
3080 #[test]
3081 fn a_rule_can_read_the_date() {
3082 let project = Project::new(
3083 "today-read",
3084 &[
3085 ("rule.ts", DATE_RULE),
3086 ("lanekeep.config.ts", &config("")),
3087 ("src/a.ts", "export const a = 1;\n"),
3088 ],
3089 );
3090 let outcome = project.run_on("2027-03-04").expect("runs");
3091 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3092 assert!(outcome.violations[0].message.contains("2027-03-04"));
3093 }
3094
3095 #[test]
3096 fn a_result_that_read_the_date_is_not_served_across_days() {
3097 let project = Project::new(
3101 "today-cache",
3102 &[
3103 ("rule.ts", DATE_RULE),
3104 ("lanekeep.config.ts", &config("")),
3105 ("src/a.ts", "export const a = 1;\n"),
3106 ],
3107 );
3108
3109 assert!(
3110 project
3111 .run_on("2026-12-31")
3112 .expect("runs")
3113 .violations
3114 .is_empty()
3115 );
3116 let later = project.run_on("2027-01-01").expect("runs");
3117 assert_eq!(
3118 later.violations.len(),
3119 1,
3120 "a warm run served a date-dependent result from another day: {:?}",
3121 messages(&later)
3122 );
3123 }
3124
3125 #[test]
3126 fn a_result_that_ignored_the_date_survives_across_days() {
3127 let project = Project::new(
3134 "today-undated",
3135 &[
3136 ("rule.ts", DEBUGGER_RULE),
3137 ("lanekeep.config.ts", &config("")),
3138 ("src/a.ts", "debugger;\n"),
3139 ],
3140 );
3141
3142 project.run_on("2026-12-31").expect("runs");
3143 let before = fs::read(Store::path_for(&project.dir)).expect("reads");
3144
3145 let outcome = project.run_on("2027-01-01").expect("runs");
3146 assert_eq!(outcome.violations.len(), 1);
3147
3148 let after = fs::read(Store::path_for(&project.dir)).expect("reads");
3149 assert_eq!(
3150 before, after,
3151 "a result that never read the date was re-keyed across days"
3152 );
3153 }
3154
3155 #[test]
3156 fn a_result_that_read_the_date_is_re_keyed_across_days() {
3157 let project = Project::new(
3160 "today-dated-key",
3161 &[
3162 ("rule.ts", DATE_RULE),
3163 ("lanekeep.config.ts", &config("")),
3164 ("src/a.ts", "export const a = 1;\n"),
3165 ],
3166 );
3167
3168 project.run_on("2026-12-31").expect("runs");
3169 let before = fs::read(Store::path_for(&project.dir)).expect("reads");
3170
3171 project.run_on("2027-01-01").expect("runs");
3172 let after = fs::read(Store::path_for(&project.dir)).expect("reads");
3173 assert_ne!(
3174 before, after,
3175 "a result that read the date kept its key across days"
3176 );
3177 }
3178
3179 #[test]
3180 fn loc_reaches_a_reduce_phase_through_a_fact() {
3181 const RULE: &str = r"import { defineRule } from 'lanekeep';
3183export default defineRule({
3184 id: 'local/loc-through-facts',
3185 query: '(export_statement) @stmt',
3186 card: { message: 'via loc', remediation: 'x', examples: { bad: 'a', good: 'b' } },
3187 check(ctx, m) { ctx.emitFact({ kind: 'site', at: ctx.loc(m.stmt) }); },
3188 reduce(ctx) {
3189 for (const f of ctx.facts('site')) ctx.report(f.at, 'reported at a remembered place');
3190 },
3191});
3192";
3193 let project = Project::new(
3194 "loc-facts",
3195 &[
3196 ("rule.ts", RULE),
3197 ("lanekeep.config.ts", &config("")),
3198 ("src/a.ts", "const x = 1;\nexport const a = 1;\n"),
3199 ],
3200 );
3201
3202 let outcome = project.run().expect("runs");
3203 assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3204 assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
3205 assert_eq!(outcome.violations[0].location.position.line, 2);
3206 }
3207}