Skip to main content

lanekeep_engine/
lib.rs

1//! Rule execution engine for lanekeep.
2//!
3//! Discovery, gates, parsing, query matching and handler invocation, run over a corpus.
4//!
5//! # Why this is not in `lanekeep-core`
6//!
7//! Architecture §3 places the walker in `lanekeep-core`. That does not work: running rules
8//! requires the sandbox, and the sandbox is built *on* core — putting the walker there
9//! would make `lanekeep-core` and `lanekeep-js` mutually dependent.
10//!
11//! It cannot live in `lanekeep-cli` either, because `lanekeep-testkit` has to run rules
12//! too, and a test harness that reached into the binary crate would be a worse coupling
13//! than this one. So the walker sits above the sandbox and below both consumers.
14//!
15//! Core keeps what it always had: the types, discovery, gates, and the ordering contract.
16//!
17//! # The shape of a run
18//!
19//! ```text
20//! discover paths (sorted)
21//!   └─> for each file, in parallel:
22//!         path gates ──reject──> skip without reading
23//!         read bytes
24//!         content gates ──reject──> skip without parsing
25//!         parse once, shared by every rule targeting the file
26//!         for each admitted rule: match its query, invoke the handler per match
27//!   └─> sort violations
28//! ```
29//!
30//! One parse per file, not per rule. Parsing is the dominant cost, and a file with twenty
31//! applicable rules must not pay it twenty times.
32
33use 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/// Why a run could not complete.
56///
57/// Every variant aborts the run. A checker that could not finish must not be mistaken for
58/// one that found nothing — see architecture §6.8.
59#[derive(Debug, Clone, PartialEq, Eq, Error)]
60pub enum RunError {
61    /// Discovery could not run.
62    #[error(transparent)]
63    Discovery(#[from] DiscoveryError),
64
65    /// A rule's query does not compile.
66    #[error("rule `{rule}` has an invalid query\n{detail}")]
67    Query {
68        /// Which rule.
69        rule: String,
70        /// The rendered compile error.
71        detail: String,
72    },
73
74    /// A rule names a language nothing provides.
75    #[error("rule `{rule}` targets unknown language `{language}`\n  known languages: {known}")]
76    UnknownLanguage {
77        /// Which rule.
78        rule: String,
79        /// The language as written.
80        language: String,
81        /// What is available.
82        known: String,
83    },
84
85    /// A rule's gates are malformed.
86    #[error("rule `{rule}` has invalid gates: {detail}")]
87    Gates {
88        /// Which rule.
89        rule: String,
90        /// What is wrong.
91        detail: String,
92    },
93
94    /// The sandbox failed, including on a breached budget.
95    #[error("rule `{rule}` failed on `{file}`\n{detail}")]
96    Rule {
97        /// Which rule.
98        rule: String,
99        /// Which file it was running against.
100        file: String,
101        /// The sandbox's account of it.
102        detail: String,
103    },
104
105    /// A worker could not be set up.
106    #[error("could not start a worker: {detail}")]
107    Worker {
108        /// What went wrong.
109        detail: String,
110    },
111}
112
113/// A rule prepared for execution: metadata plus everything compiled.
114///
115/// The query is compiled once per language the rule targets, because a query is compiled
116/// against a grammar and the grammars differ. Which one a given file uses is decided by the
117/// file, not by the rule — see [`Prepared::for_language`].
118struct Prepared {
119    spec: RuleSpec,
120    gates: CompiledGates,
121    /// Compiled query per language, in the order the rule declared them.
122    compiled: Vec<(Arc<dyn Language>, CompiledQuery)>,
123}
124
125impl Prepared {
126    /// The grammar and query to use for a file of the given language, or `None` when this
127    /// rule does not target it — in which case the rule does not run on that file at all.
128    ///
129    /// Running it anyway is what the old behavior did, and it does not fail loudly: the file
130    /// parses into a tree of `ERROR` nodes and every query quietly matches nothing.
131    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/// Everything a run needs, built once and shared across workers.
139#[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    /// The project root, canonicalized once. Every tracked read is checked against it, and
151    /// canonicalizing per file would put a syscall on the hot path for a constant.
152    root: PathBuf,
153    /// Everything constant about this run that a cache key depends on.
154    run_key: RunKey,
155    /// Whether results may be read from and written to the cache.
156    caching: bool,
157    /// Whether reduce phases run.
158    reducing: bool,
159    /// Whether directives that silenced nothing are reported.
160    reporting_unused: bool,
161    /// Whether per-rule timings are collected.
162    profiling: bool,
163    /// The date `expires:` is compared against.
164    ///
165    /// Fixed once for the run, so two files checked a millisecond apart cannot disagree
166    /// about what day it is. Supplied by the host because the sandbox has no clock.
167    today: Date,
168    limits: Limits,
169    rules_root: RuleRoot,
170    config_path: PathBuf,
171    typescript: Arc<dyn Language>,
172    javascript: Arc<dyn Language>,
173    /// Extension to language id, so a file can be matched to a grammar without the registry.
174    ///
175    /// Lowercased keys, because the registry lowercases too — whether `Button.TSX` gets
176    /// checked should not depend on how someone typed it.
177    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/// Where a run spent its time, per rule.
190///
191/// The split is the point. A rule that is slow in `query` has a query matching more than it
192/// needs and wants narrowing; a rule that is slow in `handler` has code to look at. Reporting
193/// one total would leave an author guessing which, and the two have nothing in common as
194/// fixes.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
196pub struct RuleTiming {
197    /// Time matching this rule's query, in Rust.
198    pub query: Duration,
199    /// Time inside its handler, in the sandbox.
200    pub handler: Duration,
201    /// How many matches crossed the boundary.
202    ///
203    /// The number the query gate exists to keep small — §7.2 — so it belongs beside the
204    /// times rather than being inferred from them.
205    pub matches: u64,
206}
207
208impl RuleTiming {
209    /// Everything this rule cost.
210    #[must_use]
211    pub const fn total(&self) -> Duration {
212        self.query.saturating_add(self.handler)
213    }
214}
215
216/// What a run produced.
217#[derive(Debug, Clone, PartialEq, Eq, Default)]
218pub struct Outcome {
219    /// Violations, in canonical order.
220    pub violations: Vec<Violation>,
221    /// How many files discovery selected.
222    pub files_discovered: usize,
223    /// How many were actually parsed, after gates.
224    pub files_parsed: usize,
225
226    /// Where the run spent its time, per rule, when `--profile` asked.
227    ///
228    /// Absent otherwise: timing every match costs a clock read per invocation, which is
229    /// exactly the kind of thing that should not be on the path a warm run takes.
230    pub timings: Option<BTreeMap<RuleId, RuleTiming>>,
231
232    /// What each checked file's rules read beyond that file, in path order.
233    ///
234    /// Exactly the shape a cache entry needs: dependencies belong to the file whose result
235    /// they affect, not to the run. A file with no tracked reads has no entry here rather
236    /// than an empty one, so the common case costs nothing.
237    pub dependencies: BTreeMap<FilePath, Vec<TrackedRead>>,
238}
239
240impl Engine {
241    /// Prepare a run.
242    ///
243    /// Everything that can fail on a rule's own contents fails here, before any file is
244    /// read — a run that dies halfway through because rule seventeen has a typo has
245    /// already wasted the work.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`RunError`] for an invalid query, gate, or language reference.
250    pub fn prepare(
251        config: &Config,
252        project_root: &Path,
253        rules_root: RuleRoot,
254        config_path: &Path,
255        registry: &LanguageRegistry,
256        typescript: Arc<dyn Language>,
257        javascript: Arc<dyn Language>,
258    ) -> Result<Self, RunError> {
259        let discovery = Discovery::new(project_root, &config.include, &config.exclude)?;
260
261        let known = registry
262            .languages()
263            .map(|l| l.id().as_str())
264            .collect::<Vec<_>>()
265            .join(", ");
266
267        let mut languages_by_extension = BTreeMap::new();
268        for language in registry.languages() {
269            for extension in language.extensions() {
270                languages_by_extension
271                    .insert(extension.to_ascii_lowercase(), language.id().to_string());
272            }
273        }
274
275        let mut rules = Vec::with_capacity(config.rules.len());
276        for spec in &config.rules {
277            if !spec.severity.is_enabled() {
278                continue;
279            }
280
281            let mut compiled = Vec::with_capacity(spec.languages.len());
282            for id in &spec.languages {
283                let language =
284                    registry
285                        .by_id(id)
286                        .cloned()
287                        .ok_or_else(|| RunError::UnknownLanguage {
288                            rule: spec.id.to_string(),
289                            language: id.clone(),
290                            known: known.clone(),
291                        })?;
292
293                // Compiled against this grammar specifically. A query that is valid for one
294                // dialect and not another is a rule bug, and this is where it surfaces —
295                // at config load, naming the rule, rather than as silence at run time.
296                let query = CompiledQuery::compile(language.as_ref(), &spec.query).map_err(
297                    |e: CompileError| RunError::Query {
298                        rule: spec.id.to_string(),
299                        detail: e.to_string(),
300                    },
301                )?;
302
303                compiled.push((language, query));
304            }
305
306            let gates = CompiledGates::compile(&spec.gates).map_err(|e| RunError::Gates {
307                rule: spec.id.to_string(),
308                detail: e.to_string(),
309            })?;
310
311            rules.push(Prepared {
312                spec: spec.clone(),
313                gates,
314                compiled,
315            });
316        }
317
318        // Every registered grammar, so a tree-sitter bump invalidates rather than silently
319        // reusing results computed against different node shapes.
320        let mut grammars: Vec<GrammarKey> = registry
321            .languages()
322            .map(|language| GrammarKey {
323                id: language.id().to_string(),
324                abi: u32::try_from(language.grammar_abi()).unwrap_or(u32::MAX),
325            })
326            .collect();
327        grammars.sort_by(|a, b| a.id.cmp(&b.id));
328
329        let run_key = RunKey::new(
330            // Major.minor only: a patch release changes nothing a rule can observe, and
331            // invalidating every cache on one would make patch upgrades expensive for
332            // nothing.
333            engine_version(),
334            HOST_API_VERSION,
335            &config.ruleset_hash,
336            &config.config_hash,
337            &grammars,
338        );
339
340        Ok(Self {
341            rules,
342            run_key,
343            caching: true,
344            reducing: true,
345            reporting_unused: false,
346            profiling: false,
347            today: suppression::today(),
348            // Canonicalized here so every tracked read compares against the same absolute
349            // root. Falling back to the path as given keeps a non-existent root a discovery
350            // problem rather than turning it into a confusing read failure later.
351            root: project_root
352                .canonicalize()
353                .unwrap_or_else(|_| project_root.to_path_buf()),
354            discovery,
355            limits: config.limits,
356            rules_root,
357            config_path: config_path.to_path_buf(),
358            typescript,
359            javascript,
360            languages_by_extension,
361        })
362    }
363
364    /// Which language parses this file, or `None` when nothing registered claims it.
365    fn language_of(&self, path: &FilePath) -> Option<&str> {
366        let extension = Path::new(path.as_str())
367            .extension()?
368            .to_str()?
369            .to_ascii_lowercase();
370        self.languages_by_extension
371            .get(extension.as_str())
372            .map(String::as_str)
373    }
374
375    /// Turn the cache off, for `--no-cache` and for tests that need a cold run.
376    #[must_use]
377    pub const fn without_cache(mut self) -> Self {
378        self.caching = false;
379        self
380    }
381
382    /// Collect per-rule timings.
383    ///
384    /// Off by default because measuring costs a clock read per handler invocation, and the
385    /// path a warm run takes is the one place that matters most.
386    #[must_use]
387    pub const fn profiling(mut self) -> Self {
388        self.profiling = true;
389        self
390    }
391
392    /// Report suppressions that silenced nothing.
393    ///
394    /// Off by default because it is hygiene rather than correctness: a suppression whose
395    /// violation no longer exists is debt, and debt is worth surfacing on request rather
396    /// than in everyone's inner loop.
397    #[must_use]
398    pub const fn reporting_unused_suppressions(mut self) -> Self {
399        self.reporting_unused = true;
400        self
401    }
402
403    /// Fix the date `expires:` is compared against.
404    ///
405    /// For tests, which otherwise could not assert anything about expiry without waiting.
406    #[must_use]
407    pub const fn with_today(mut self, today: Date) -> Self {
408        self.today = today;
409        self
410    }
411
412    /// Skip every reduce phase.
413    ///
414    /// For a run over a deliberately partial corpus. A cross-file rule consumes facts from
415    /// every file, so running one over a subset does not give a smaller answer — it gives a
416    /// wrong one. `no-unused-exports` over three changed files would report every export in
417    /// them as unused, because the importers were never looked at.
418    ///
419    /// Skipping is therefore the only sound option, and the caller that narrowed the corpus
420    /// is the one that has to say so to the user.
421    #[must_use]
422    pub const fn without_reduce(mut self) -> Self {
423        self.reducing = false;
424        self
425    }
426
427    /// The files discovery selects, before any gate.
428    ///
429    /// For a caller narrowing the corpus: intersecting with this is what keeps `include` and
430    /// `exclude` in force, so `--staged` cannot check a file the config excluded.
431    #[must_use]
432    pub fn discover(&self) -> Vec<FilePath> {
433        self.discovery.walk()
434    }
435
436    /// How many rules will actually run. Rules set to `off` are dropped at preparation.
437    #[must_use]
438    pub fn rule_count(&self) -> usize {
439        self.rules.len()
440    }
441
442    /// The rules that will run, in the order the config declared them.
443    ///
444    /// The specs rather than a rendered listing: what a listing should look like is the
445    /// reporter's problem, and an engine that decided it would have to be changed for every
446    /// new output format.
447    pub fn rules(&self) -> impl Iterator<Item = &RuleSpec> {
448        self.rules.iter().map(|prepared| &prepared.spec)
449    }
450
451    /// Run over the whole corpus.
452    ///
453    /// # Errors
454    ///
455    /// Returns the first [`RunError`] any worker produced. Rayon's reduction is not
456    /// order-dependent, so which of several simultaneous failures surfaces is arbitrary —
457    /// but every one of them aborts the run, so the choice does not change the outcome.
458    pub fn run(&self) -> Result<Outcome, RunError> {
459        let files = self.discovery.walk();
460        self.run_files(&files, Coverage::Whole)
461    }
462
463    /// Run over an explicit file list, for `--since` and `--staged`.
464    ///
465    /// # Errors
466    ///
467    /// As [`Engine::run`].
468    pub fn run_over(&self, files: &[FilePath]) -> Result<Outcome, RunError> {
469        self.run_files(files, Coverage::Partial)
470    }
471
472    /// The shared body of [`Engine::run`] and [`Engine::run_over`].
473    fn run_files(&self, files: &[FilePath], coverage: Coverage) -> Result<Outcome, RunError> {
474        let clock = RunClock::start(self.limits.global_timeout);
475
476        // Loaded once, before any worker starts. Shared read-only across the pool: a cache
477        // that workers wrote to concurrently would need a lock on the hot path, and the
478        // whole point is to be faster than recomputing.
479        let cache = if self.caching {
480            Store::load(&self.root)
481        } else {
482            Store::empty()
483        };
484
485        let results: Vec<Result<FileOutcome, RunError>> = files
486            .par_iter()
487            .map_init(
488                // One sandbox per worker, created on first use and reused for that
489                // worker's whole share. Building one per file would pay engine startup
490                // thousands of times; sharing one across workers is impossible, since the
491                // runtime is single-threaded by construction.
492                // The sandbox is per worker and built on first use — one engine startup
493                // per thread that needs one, rather than per file, and none at all for a
494                // worker whose files all hit the cache. That last part is what makes a warm
495                // run cheap: starting QuickJS and evaluating every rule module, per worker,
496                // to then execute no JavaScript, was most of a warm run's cost.
497                || Worker::new(self, &clock),
498                |worker, path| self.check_file(worker, &cache, path),
499            )
500            .collect();
501
502        let mut violations = Vec::new();
503        let mut facts = Vec::new();
504        let mut files_parsed = 0;
505        let mut dependencies = BTreeMap::new();
506        let mut fresh = Store::empty();
507        let mut directives: BTreeMap<FilePath, FileDirectives> = BTreeMap::new();
508        let mut timings: BTreeMap<RuleId, RuleTiming> = BTreeMap::new();
509        for result in results {
510            let outcome = result?;
511            violations.extend(outcome.violations);
512            facts.extend(outcome.facts);
513            files_parsed += usize::from(outcome.parsed);
514            if let Some(entry) = outcome.entry {
515                fresh.insert(entry.0, entry.1);
516            }
517            for (rule, timing) in outcome.timings {
518                let entry = timings.entry(rule).or_default();
519                entry.query = entry.query.saturating_add(timing.query);
520                entry.handler = entry.handler.saturating_add(timing.handler);
521                entry.matches += timing.matches;
522            }
523            if !outcome.suppressions.is_empty() {
524                directives.insert(
525                    outcome.path.clone(),
526                    FileDirectives {
527                        suppressions: outcome.suppressions,
528                        used: outcome.used_suppressions,
529                    },
530                );
531            }
532            if !outcome.reads.is_empty() {
533                dependencies.insert(outcome.path, outcome.reads);
534            }
535        }
536
537        if self.caching {
538            match coverage {
539                // The run saw everything, so what it did not produce an entry for no longer
540                // exists. Saving only fresh entries is what ages deleted files out.
541                Coverage::Whole => fresh.save(&self.root),
542                // The run saw a subset. Saving only what it produced would discard the
543                // entries for every file it never looked at — so `--staged` would leave the
544                // next full run cold, which is the opposite of what an incremental entry
545                // point is for.
546                Coverage::Partial => {
547                    let mut merged = cache;
548                    for key in fresh.keys().copied().collect::<Vec<_>>() {
549                        if let Some(entry) = fresh.get(&key) {
550                            merged.insert(key, entry.clone());
551                        }
552                    }
553                    merged.save(&self.root);
554                }
555            }
556        }
557
558        // Into the one order every run will see, before any rule looks at them.
559        //
560        // Rayon's `collect` into a `Vec` already preserves input order, so on today's code
561        // path this sort changes nothing — which is exactly why it is easy to delete and
562        // must not be. The ordering guarantee belongs to the engine, not to a property of
563        // whichever collection strategy it happens to use: switching to `for_each` with a
564        // shared sink, or grouping by rule before reducing, would silently lose it. The
565        // cost is one sort of a small vector, once per run.
566        lanekeep_core::fact::sort(&mut facts);
567
568        // A cross-file rule reports at a site in some other file, which may well have been a
569        // cache hit this run — so its directives come from the outcome, whether they were
570        // parsed now or restored from the entry.
571        let reduced = self.reduce(&clock, files, &facts)?;
572        for violation in reduced {
573            // A cross-file violation can be the only thing a directive ever silences, so
574            // usage is recorded here too — otherwise it would be reported as unused.
575            match covering_elsewhere(&directives, &violation) {
576                Some((file, index)) => {
577                    if let Some(found) = directives.get_mut(&file)
578                        && !found.used.contains(&index)
579                    {
580                        found.used.push(index);
581                    }
582                }
583                None => violations.push(violation),
584            }
585        }
586
587        if self.reporting_unused {
588            violations.extend(unused_violations(&directives));
589        }
590
591        lanekeep_core::sort(&mut violations);
592        Ok(Outcome {
593            violations,
594            files_discovered: files.len(),
595            files_parsed,
596            timings: self.profiling.then_some(timings),
597            dependencies,
598        })
599    }
600
601    /// Run the reduce phase for every rule that has one.
602    ///
603    /// Single-threaded, and deliberately so: there is one pass per rule, each already sees
604    /// the whole corpus, and a rule's `reduce` is the one place a rule is allowed to be
605    /// expensive. Parallelizing across rules would buy little and would need one sandbox per
606    /// worker with the whole fact set copied into each.
607    fn reduce(
608        &self,
609        clock: &Arc<RunClock>,
610        files: &[FilePath],
611        facts: &[Fact],
612    ) -> Result<Vec<Violation>, RunError> {
613        if !self.reducing {
614            return Ok(Vec::new());
615        }
616
617        let reducing: Vec<&Prepared> = self
618            .rules
619            .iter()
620            .filter(|rule| rule.spec.has_reduce)
621            .collect();
622        if reducing.is_empty() {
623            // The common case. Building a sandbox to do nothing would put engine startup on
624            // the critical path of every run that has no cross-file rule at all.
625            return Ok(Vec::new());
626        }
627
628        let sandbox = self.build_sandbox(clock)?;
629        let paths: Vec<String> = files.iter().map(|f| f.as_str().to_owned()).collect();
630        let mut violations = Vec::new();
631
632        for rule in reducing {
633            // A rule sees only its own facts. Letting one read another's would make an
634            // internal payload shape into a contract between rules, and would make the
635            // result depend on the order rules happened to be declared in.
636            let own: Vec<ReduceFact> = facts
637                .iter()
638                .filter(|fact| fact.rule_id == rule.spec.id)
639                .map(|fact| ReduceFact {
640                    kind: fact.kind.clone(),
641                    json: lanekeep_js::merge_file(&fact.data, fact.file.as_str()),
642                })
643                .collect();
644
645            let host = ReduceContext::new(paths.clone(), own);
646            let timeout = rule.spec.timeout.unwrap_or(self.limits.rule_timeout);
647            let call = format!(
648                "globalThis.__lanekeepConfig.rules[{}].reduce(ctx)",
649                rule_index(&rule.spec)
650            );
651
652            sandbox
653                .eval_with_reduce_host::<()>(&host, &call, timeout)
654                .map_err(|e: SandboxError| RunError::Rule {
655                    rule: rule.spec.id.to_string(),
656                    // No single file is at fault in a reduce phase, and naming one would be
657                    // a lie the reader would then go and look at.
658                    file: "<reduce>".to_owned(),
659                    detail: e.to_string(),
660                })?;
661
662            for report in host.take_reports() {
663                // The path is the rule's, normalized but not checked against the corpus. A
664                // cross-file rule may legitimately report at a file the walker excluded —
665                // a config, a generated file. Autofix will need to disagree: it must never
666                // write to a path a rule invented. That check belongs with the writing.
667                violations.push(Violation {
668                    rule_id: rule.spec.id.clone(),
669                    location: Location::new(
670                        FilePath::new(&report.file),
671                        Position::new(report.line, report.column),
672                    ),
673                    message: report
674                        .message
675                        .unwrap_or_else(|| rule.spec.card.message.clone()),
676                    remediation: rule.spec.card.remediation.clone(),
677                    severity: rule.spec.severity,
678                    // A reduce phase has no parse tree, so there is no node to replace and
679                    // nothing to compute a byte range from. A cross-file finding is fixed by
680                    // hand.
681                    fix: None,
682                });
683            }
684        }
685
686        Ok(violations)
687    }
688
689    /// Build the sandbox a worker uses, evaluating the ruleset into it.
690    fn build_sandbox(&self, clock: &Arc<RunClock>) -> Result<Sandbox, RunError> {
691        let sandbox = Sandbox::with_modules(
692            self.limits,
693            Arc::clone(clock),
694            self.rules_root.clone(),
695            Arc::clone(&self.typescript),
696            Arc::clone(&self.javascript),
697        )
698        .map_err(|e| RunError::Worker {
699            detail: e.to_string(),
700        })?;
701
702        // Every worker evaluates the ruleset into its own engine. A rule's `check` is a
703        // function, and a function cannot cross between runtimes — so the modules are
704        // loaded per worker rather than the handlers being extracted and shared.
705        lanekeep_config::evaluate_into(&sandbox, &self.rules_root, &self.config_path).map_err(
706            |e: ConfigError| RunError::Worker {
707                detail: e.to_string(),
708            },
709        )?;
710
711        Ok(sandbox)
712    }
713
714    /// Check one file. Returns its violations, facts and tracked reads.
715    fn check_file(
716        &self,
717        worker: &mut Worker<'_>,
718        cache: &Store,
719        path: &FilePath,
720    ) -> Result<FileOutcome, RunError> {
721        // A fresh set of tracked reads for this file, sharing the root already canonicalized
722        // at preparation.
723        let files = Rc::new(FileAccess::rooted(self.root.clone()));
724
725        // Path gates first: rejecting here costs no read at all.
726        let admitted: Vec<&Prepared> = self
727            .rules
728            .iter()
729            .filter(|rule| rule.gates.admits_path(path))
730            .collect();
731        if admitted.is_empty() {
732            return Ok(FileOutcome::skipped(path.clone()));
733        }
734
735        let absolute = self.discovery.root().join(path.as_str());
736        let Ok(bytes) = std::fs::read(&absolute) else {
737            // A file that vanished between discovery and reading is not a failure. The
738            // tree is allowed to change under a run; what must not happen is a partial
739            // result being reported as complete, and a missing file contributes nothing
740            // either way.
741            return Ok(FileOutcome::skipped(path.clone()));
742        };
743
744        // The cache is consulted after the path gates and the read, because the key needs
745        // the file's bytes — but before the content gates and the parse, which is where the
746        // saving is. A hit costs one hash and one dependency check.
747        // A file's result can depend on what day it is, two ways: an expiring suppression in
748        // its bytes, or a rule that read `ctx.today` while checking it. Such a file gets a
749        // key with the date folded in, so its entry lives for one day; every other file gets
750        // a dateless key and its entry survives indefinitely.
751        //
752        // Folding the date into every key instead would invalidate the whole corpus daily
753        // for the sake of a handful of files. Leaving it out entirely would serve yesterday's
754        // answer — an expiry that never expires, a date comparison frozen at whenever the
755        // cache was written.
756        //
757        // The expiry is visible in the bytes, so it is known now. Whether a rule reads the
758        // date is not knowable until the rules have run, which is why both keys exist and
759        // the lookup tries the dated one first: a file that was date-dependent last run has
760        // its entry there, and if the date has moved that key simply misses.
761        let keys = self.caching.then(|| {
762            let content = lanekeep_cache::hash_bytes(&bytes);
763            (
764                self.run_key.for_file(path.as_str(), &content),
765                self.run_key
766                    .for_dated_file(path.as_str(), &content, &self.today.to_string()),
767            )
768        });
769        let has_expiry = memchr::memmem::find(&bytes, b"expires:").is_some();
770
771        if let Some((plain, dated)) = keys {
772            // Dated first. A file with an expiring suppression is *only* ever stored dated,
773            // so trying the plain key for it would be a lookup that can never hit.
774            let candidates: &[CacheKey] = if has_expiry {
775                &[dated]
776            } else {
777                &[dated, plain]
778            };
779            for key in candidates {
780                if let Some(entry) = cache.get(key)
781                    && lanekeep_cache::validate(entry, &self.root)
782                {
783                    return Ok(FileOutcome::cached(path.clone(), *key, entry.clone()));
784                }
785            }
786        }
787
788        // Content gates: one read, a substring scan, and a parse saved.
789        let admitted: Vec<&Prepared> = admitted
790            .into_iter()
791            .filter(|rule| rule.gates.admits_content(&bytes))
792            .collect();
793        if admitted.is_empty() {
794            // Still worth an entry: "nothing applies to this file" is a result, and
795            // recomputing the gates every run for a file that never matches is the cost the
796            // cache exists to remove. No rule ran, so nothing read the date — unless the
797            // file carries an expiry, which is a property of its bytes.
798            return Ok(FileOutcome::empty_entry(
799                path.clone(),
800                keys.map(|(plain, dated)| if has_expiry { dated } else { plain }),
801            ));
802        }
803
804        let Ok(source) = String::from_utf8(bytes) else {
805            // Not valid UTF-8, so not source this tool can reason about.
806            return Ok(FileOutcome::skipped(path.clone()));
807        };
808
809        // Parsed once per file, whatever rules ran: a directive is a property of the file,
810        // not of any rule.
811        let directives = suppression::parse(&source);
812
813        let mut outcome = FileOutcome::parsed(path.clone());
814        for rule in admitted {
815            let (violations, facts, read_the_date, timing) =
816                self.run_rule(worker, &files, rule, path, &source)?;
817            outcome.violations.extend(violations);
818            outcome.facts.extend(facts);
819            outcome.read_the_date |= read_the_date;
820            if self.profiling {
821                outcome.timings.push((rule.spec.id.clone(), timing));
822            }
823        }
824
825        // Applied after every rule has run, so a directive covers whatever any of them
826        // reported at that line. Which directive fired is recorded rather than discarded:
827        // it is the only moment the information exists, since a warm run sees the survivors
828        // and not what was hidden.
829        let mut used = Vec::new();
830        outcome.violations.retain(|violation| {
831            match directives.covering(&violation.rule_id, violation.location.position.line) {
832                Some(index) => {
833                    let index = u32::try_from(index).unwrap_or(u32::MAX);
834                    if !used.contains(&index) {
835                        used.push(index);
836                    }
837                    false
838                }
839                None => true,
840            }
841        });
842        used.sort_unstable();
843        outcome.used_suppressions = used;
844        outcome
845            .violations
846            .extend(self.directive_violations(&directives, path));
847
848        outcome.suppressions = directives.valid;
849        outcome.reads = files.dependencies();
850        // Dated if anything about this file's result depended on the date: an expiring
851        // directive, or a rule that read `ctx.today`.
852        let date_dependent = has_expiry || outcome.read_the_date;
853        outcome.entry = keys.map(|(plain, dated)| {
854            (
855                if date_dependent { dated } else { plain },
856                CacheEntry {
857                    violations: outcome.violations.clone(),
858                    facts: outcome.facts.clone(),
859                    dependencies: outcome.reads.clone(),
860                    suppressions: outcome.suppressions.clone(),
861                    used_suppressions: outcome.used_suppressions.clone(),
862                },
863            )
864        });
865
866        Ok(outcome)
867    }
868
869    /// Violations about the directives themselves.
870    ///
871    /// A suppression that does not work has to say so. A malformed directive silences
872    /// nothing while looking like it does, and an expired one is a deadline the author set
873    /// and then passed — reporting both is the whole reason the fields are checked rather
874    /// than best-effort parsed.
875    fn directive_violations(&self, directives: &Suppressions, path: &FilePath) -> Vec<Violation> {
876        let mut violations = Vec::new();
877
878        // Parsed once here rather than per violation. `SUPPRESSION_RULE` is a literal this
879        // crate controls, so a failure would be a build-time mistake — falling back to the
880        // rules' own namespace keeps that from being a panic in a checker.
881        let Ok(rule_id) = SUPPRESSION_RULE.parse::<RuleId>() else {
882            return violations;
883        };
884
885        for bad in &directives.malformed {
886            violations.push(Violation {
887                rule_id: rule_id.clone(),
888                location: Location::new(path.clone(), Position::new(bad.line, bad.column)),
889                message: bad.problem.clone(),
890                remediation: String::from(
891                    "fix the directive, or remove it and fix what it was hiding",
892                ),
893                severity: Severity::Error,
894                fix: None,
895            });
896        }
897
898        for suppression in &directives.valid {
899            let Some(expires) = suppression.expires else {
900                continue;
901            };
902            if expires >= self.today {
903                continue;
904            }
905
906            violations.push(Violation {
907                rule_id: rule_id.clone(),
908                location: Location::new(
909                    path.clone(),
910                    Position::new(suppression.line, suppression.column),
911                ),
912                message: format!(
913                    "suppression expired on {expires} — \"{}\"",
914                    suppression.reason
915                ),
916                remediation: String::from(
917                    "fix what it was suppressing, or decide it is permanent and drop the \
918                     expiry",
919                ),
920                severity: Severity::Error,
921                fix: None,
922            });
923        }
924
925        violations
926    }
927
928    fn run_rule(
929        &self,
930        worker: &mut Worker<'_>,
931        files: &Rc<FileAccess>,
932        rule: &Prepared,
933        path: &FilePath,
934        source: &str,
935    ) -> Result<(Vec<Violation>, Vec<Fact>, bool, RuleTiming), RunError> {
936        // The grammar is chosen by the file, not by the rule. A rule that does not target
937        // this file's language does not run on it at all — previously it ran anyway, against
938        // a grammar that could not parse the file, and matched nothing without saying so.
939        let Some(language_id) = self.language_of(path) else {
940            return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
941        };
942        let Some((language, compiled_query)) = rule.for_language(language_id) else {
943            return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
944        };
945
946        let mut parser = tree_sitter::Parser::new();
947        if parser.set_language(&language.grammar()).is_err() {
948            return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
949        }
950        let Some(tree) = parser.parse(source, None) else {
951            return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
952        };
953
954        // Only when asked. A clock read per invocation is cheap and not free, and this is
955        // the hot path.
956        let mut timing = RuleTiming::default();
957        let clock = |on: bool| on.then(std::time::Instant::now);
958
959        // Collect capture paths while the tree is borrowed, then intern once the borrow
960        // has ended — the two-phase shape the arena's ownership of the tree forces.
961        let mut matches: Vec<Vec<(String, Vec<u32>)>> = Vec::new();
962        let host = HostContext::new(tree, source.to_owned(), path.as_str())
963            .with_resolver_from(language.as_ref())
964            .with_language(Arc::clone(language))
965            .with_today(&self.today.to_string())
966            .with_file_access(Rc::clone(files));
967
968        let query_started = clock(self.profiling);
969        {
970            let arena = host.arena().borrow();
971            compiled_query.for_each_match(arena.tree(), source.as_bytes(), |m| {
972                let captures = m
973                    .captures
974                    .iter()
975                    .filter_map(|(name, node)| {
976                        arena.path_of(*node).map(|path| ((*name).to_owned(), path))
977                    })
978                    .collect();
979                matches.push(captures);
980            });
981        }
982
983        if let Some(started) = query_started {
984            timing.query = started.elapsed();
985            timing.matches = matches.len() as u64;
986        }
987
988        if matches.is_empty() {
989            return Ok((Vec::new(), Vec::new(), false, timing));
990        }
991
992        // Only now, with matches in hand, is a sandbox needed. Everything above — parsing,
993        // query matching — is Rust, and a file that matches nothing never starts one.
994        let sandbox = worker.sandbox()?;
995
996        let timeout = rule.spec.timeout.unwrap_or(self.limits.rule_timeout);
997        let mut violations = Vec::new();
998
999        for captures in matches {
1000            let handles: Vec<(String, u32)> = {
1001                let mut arena = host.arena().borrow_mut();
1002                captures
1003                    .into_iter()
1004                    .filter_map(|(name, path)| arena.intern_path(path).map(|h| (name, h)))
1005                    .collect()
1006            };
1007
1008            let literal = handles
1009                .iter()
1010                .map(|(name, handle)| format!("{}: {handle}", json_key(name)))
1011                .collect::<Vec<_>>()
1012                .join(", ");
1013
1014            // The handler is invoked through the module the config already loaded, so the
1015            // rule object here is the same one the config validated.
1016            let call = format!(
1017                "globalThis.__lanekeepConfig.rules[{}].check(ctx, {{{literal}}})",
1018                rule_index(&rule.spec)
1019            );
1020
1021            let handler_started = clock(self.profiling);
1022            let outcome = sandbox.eval_with_host_timeout::<()>(&host, &call, timeout);
1023            if let Some(started) = handler_started {
1024                timing.handler = timing.handler.saturating_add(started.elapsed());
1025            }
1026
1027            outcome.map_err(|e: SandboxError| RunError::Rule {
1028                rule: rule.spec.id.to_string(),
1029                file: path.as_str().to_owned(),
1030                detail: e.to_string(),
1031            })?;
1032        }
1033
1034        let facts = host
1035            .take_facts()
1036            .into_iter()
1037            .enumerate()
1038            .map(|(sequence, emitted)| Fact {
1039                rule_id: rule.spec.id.clone(),
1040                file: path.clone(),
1041                kind: emitted.kind,
1042                data: emitted.data,
1043                // Emission order within the file. The engine assigns it rather than
1044                // trusting the rule, so a rule cannot reorder its own facts relative to
1045                // another file's and change what `reduce` sees.
1046                sequence: u32::try_from(sequence).unwrap_or(u32::MAX),
1047            })
1048            .collect();
1049
1050        for report in host.take_reports() {
1051            violations.push(Violation {
1052                rule_id: rule.spec.id.clone(),
1053                location: Location::new(path.clone(), Position::new(report.line, report.column)),
1054                message: report
1055                    .message
1056                    .unwrap_or_else(|| rule.spec.card.message.clone()),
1057                remediation: rule.spec.card.remediation.clone(),
1058                severity: rule.spec.severity,
1059                fix: report.fix,
1060            });
1061        }
1062
1063        Ok((violations, facts, host.date_was_read(), timing))
1064    }
1065}
1066
1067/// Whether a run looked at the whole corpus or a chosen subset.
1068///
1069/// The distinction only matters when saving: a run that saw everything may prune, and a run
1070/// that saw a subset must not.
1071#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072enum Coverage {
1073    /// Everything discovery selected.
1074    Whole,
1075    /// An explicit subset, from `--since` or `--staged`.
1076    Partial,
1077}
1078
1079/// One rayon worker's reusable state.
1080///
1081/// The sandbox is built on first use rather than up front. Starting QuickJS and evaluating
1082/// every rule module into it costs real time, and a worker whose files all hit the cache —
1083/// or whose queries match nothing — never executes a line of JavaScript and does not need
1084/// one.
1085struct Worker<'a> {
1086    engine: &'a Engine,
1087    clock: Arc<RunClock>,
1088    sandbox: Option<Sandbox>,
1089    /// A failure to build, remembered so it is reported once per worker rather than
1090    /// retried for every remaining file.
1091    failed: Option<RunError>,
1092}
1093
1094impl<'a> Worker<'a> {
1095    fn new(engine: &'a Engine, clock: &Arc<RunClock>) -> Self {
1096        Self {
1097            engine,
1098            clock: Arc::clone(clock),
1099            sandbox: None,
1100            failed: None,
1101        }
1102    }
1103
1104    /// This worker's sandbox, building it if this is the first rule that needs one.
1105    fn sandbox(&mut self) -> Result<&Sandbox, RunError> {
1106        if let Some(error) = &self.failed {
1107            return Err(error.clone());
1108        }
1109
1110        if self.sandbox.is_none() {
1111            match self.engine.build_sandbox(&self.clock) {
1112                Ok(sandbox) => self.sandbox = Some(sandbox),
1113                Err(error) => {
1114                    self.failed = Some(error.clone());
1115                    return Err(error);
1116                }
1117            }
1118        }
1119
1120        self.sandbox.as_ref().ok_or_else(|| RunError::Worker {
1121            detail: "sandbox was not built".to_owned(),
1122        })
1123    }
1124}
1125
1126/// What checking one file produced.
1127struct FileOutcome {
1128    /// The file this is about, so the run can key dependencies by it.
1129    path: FilePath,
1130    violations: Vec<Violation>,
1131    facts: Vec<Fact>,
1132    /// What this file's rules read beyond it.
1133    reads: Vec<TrackedRead>,
1134    /// The file's suppression directives, for filtering reduce-phase violations.
1135    suppressions: Vec<suppression::Suppression>,
1136    /// Indices of the directives that silenced something.
1137    used_suppressions: Vec<u32>,
1138    /// Whether any rule read `ctx.today` while checking this file.
1139    read_the_date: bool,
1140    /// Per-rule timings, when profiling.
1141    timings: Vec<(RuleId, RuleTiming)>,
1142    /// What to store for this file, when caching is on.
1143    entry: Option<(CacheKey, CacheEntry)>,
1144    /// Whether the file was parsed at all, for the "n files checked" count.
1145    parsed: bool,
1146}
1147
1148impl FileOutcome {
1149    /// A file that never reached a parser — gated out, unreadable, or not UTF-8.
1150    const fn skipped(path: FilePath) -> Self {
1151        Self {
1152            path,
1153            violations: Vec::new(),
1154            facts: Vec::new(),
1155            reads: Vec::new(),
1156            suppressions: Vec::new(),
1157            used_suppressions: Vec::new(),
1158            read_the_date: false,
1159            timings: Vec::new(),
1160            entry: None,
1161            parsed: false,
1162        }
1163    }
1164
1165    const fn parsed(path: FilePath) -> Self {
1166        Self {
1167            path,
1168            violations: Vec::new(),
1169            facts: Vec::new(),
1170            reads: Vec::new(),
1171            suppressions: Vec::new(),
1172            used_suppressions: Vec::new(),
1173            read_the_date: false,
1174            timings: Vec::new(),
1175            entry: None,
1176            parsed: true,
1177        }
1178    }
1179
1180    /// A file whose result came back from the cache.
1181    ///
1182    /// Counted as parsed, because from outside the run it was checked — reporting a warm
1183    /// run as having checked nothing would make the number useless.
1184    fn cached(path: FilePath, key: CacheKey, entry: CacheEntry) -> Self {
1185        Self {
1186            path,
1187            violations: entry.violations.clone(),
1188            facts: entry.facts.clone(),
1189            reads: entry.dependencies.clone(),
1190            suppressions: entry.suppressions.clone(),
1191            used_suppressions: entry.used_suppressions.clone(),
1192            // A cache hit ran no rules, so nothing read the date this time. Whether the
1193            // entry was dated is already settled by the key it was found under.
1194            read_the_date: false,
1195            timings: Vec::new(),
1196            entry: Some((key, entry)),
1197            parsed: true,
1198        }
1199    }
1200
1201    /// A file that no rule's content gates admitted.
1202    fn empty_entry(path: FilePath, key: Option<CacheKey>) -> Self {
1203        Self {
1204            path,
1205            violations: Vec::new(),
1206            facts: Vec::new(),
1207            reads: Vec::new(),
1208            suppressions: Vec::new(),
1209            used_suppressions: Vec::new(),
1210            read_the_date: false,
1211            timings: Vec::new(),
1212            entry: key.map(|key| (key, CacheEntry::default())),
1213            parsed: false,
1214        }
1215    }
1216}
1217
1218/// One file's directives, and which of them silenced something.
1219struct FileDirectives {
1220    suppressions: Vec<suppression::Suppression>,
1221    /// Indices into `suppressions`. Carried from the cache entry on a warm run.
1222    used: Vec<u32>,
1223}
1224
1225/// Which directive silences a violation reported into some other file.
1226///
1227/// A cross-file rule reports at the site a fact came from, so the directives that matter are
1228/// that file's, not the one the rule happened to be reducing over.
1229fn covering_elsewhere(
1230    directives: &BTreeMap<FilePath, FileDirectives>,
1231    violation: &Violation,
1232) -> Option<(FilePath, u32)> {
1233    let found = directives.get(&violation.location.file)?;
1234    let index = found.suppressions.iter().position(|suppression| {
1235        suppression.covers(&violation.rule_id, violation.location.position.line)
1236    })?;
1237
1238    Some((
1239        violation.location.file.clone(),
1240        u32::try_from(index).unwrap_or(u32::MAX),
1241    ))
1242}
1243
1244/// Violations for directives that silenced nothing.
1245///
1246/// A suppression whose violation no longer exists is debt: it documents a decision about
1247/// code that has changed, and the next person to read it has no way to tell it is stale.
1248///
1249/// Reported as warnings rather than errors. Turning on a hygiene report should not fail a
1250/// build that was passing — the point is to show the debt, not to refuse to proceed until it
1251/// is paid.
1252fn unused_violations(directives: &BTreeMap<FilePath, FileDirectives>) -> Vec<Violation> {
1253    let Ok(rule_id) = SUPPRESSION_RULE.parse::<RuleId>() else {
1254        return Vec::new();
1255    };
1256
1257    let mut violations = Vec::new();
1258    for (file, found) in directives {
1259        for (index, suppression) in found.suppressions.iter().enumerate() {
1260            let index = u32::try_from(index).unwrap_or(u32::MAX);
1261            if found.used.contains(&index) {
1262                continue;
1263            }
1264
1265            violations.push(Violation {
1266                rule_id: rule_id.clone(),
1267                location: Location::new(
1268                    file.clone(),
1269                    Position::new(suppression.line, suppression.column),
1270                ),
1271                message: format!("suppression silenced nothing — \"{}\"", suppression.reason),
1272                remediation: String::from(
1273                    "remove it: whatever it was accepting is no longer reported",
1274                ),
1275                severity: Severity::Warn,
1276                fix: None,
1277            });
1278        }
1279    }
1280    violations
1281}
1282
1283/// The engine version a cache key uses: major.minor only.
1284fn engine_version() -> &'static str {
1285    // Trimmed at the second dot. A patch release changes nothing a rule can observe, so
1286    // invalidating every cache in the world on one would cost users time for nothing.
1287    const FULL: &str = env!("CARGO_PKG_VERSION");
1288    match FULL.match_indices('.').nth(1) {
1289        Some((at, _)) => FULL.split_at(at).0,
1290        None => FULL,
1291    }
1292}
1293
1294/// The id violations about suppressions are reported under.
1295///
1296/// A real namespaced id, so it sorts, suppresses and serializes like any other — and so a
1297/// consumer parsing output does not meet a special case.
1298const SUPPRESSION_RULE: &str = "lanekeep/suppression";
1299
1300/// Position of a rule in the config's `rules` array, which is how the handler is reached.
1301fn rule_index(spec: &RuleSpec) -> usize {
1302    spec.index
1303}
1304
1305/// Quote a capture name for use as an object key.
1306fn json_key(name: &str) -> String {
1307    format!("{name:?}")
1308}
1309
1310/// Convenience for callers that only need a default severity check.
1311#[must_use]
1312pub fn any_failing(violations: &[Violation]) -> bool {
1313    violations.iter().any(|v| v.severity == Severity::Error)
1314}
1315
1316/// Where the rules root sits, given a project root.
1317#[must_use]
1318pub fn rules_root_for(project_root: &Path) -> PathBuf {
1319    project_root.to_path_buf()
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324    use std::fs;
1325
1326    use lanekeep_lang_js::{JavaScript, TypeScript};
1327
1328    use super::*;
1329
1330    struct Project {
1331        dir: PathBuf,
1332    }
1333
1334    impl Project {
1335        fn new(name: &str, files: &[(&str, &str)]) -> Self {
1336            let dir = std::env::temp_dir().join(format!("lanekeep-engine-{name}"));
1337            let _ = fs::remove_dir_all(&dir);
1338            fs::create_dir_all(&dir).expect("creates dir");
1339            let project = Self { dir };
1340            for (path, contents) in files {
1341                project.write(path, contents);
1342            }
1343            project
1344        }
1345
1346        fn write(&self, path: &str, contents: &str) {
1347            let full = self.dir.join(path);
1348            if let Some(parent) = full.parent() {
1349                fs::create_dir_all(parent).expect("creates parent");
1350            }
1351            fs::write(full, contents).expect("writes");
1352        }
1353
1354        fn run(&self) -> Result<Outcome, RunError> {
1355            let root = RuleRoot::new(&self.dir).expect("canonicalizes");
1356            let config_path = self.dir.join("lanekeep.config.ts");
1357
1358            let sandbox =
1359                lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
1360                    .expect("sandbox");
1361            let config = lanekeep_config::load(&sandbox, &root, &config_path)
1362                .unwrap_or_else(|e| panic!("config failed to load: {e}"));
1363
1364            let engine = Engine::prepare(
1365                &config,
1366                &self.dir,
1367                root,
1368                &config_path,
1369                &lanekeep_lang_js::registry(),
1370                Arc::new(TypeScript),
1371                Arc::new(JavaScript),
1372            )?;
1373            engine.run()
1374        }
1375    }
1376
1377    impl Drop for Project {
1378        fn drop(&mut self) {
1379            let _ = fs::remove_dir_all(&self.dir);
1380        }
1381    }
1382
1383    /// A rule reporting every `debugger` statement — small, unambiguous, and easy to seed.
1384    const DEBUGGER_RULE: &str = "import { defineRule } from 'lanekeep';\n\
1385        export default defineRule({\n\
1386          id: 'local/no-debugger',\n\
1387          query: '(debugger_statement) @stmt',\n\
1388          card: {\n\
1389            message: 'debugger statement',\n\
1390            remediation: 'remove it before committing',\n\
1391            examples: { bad: 'debugger;', good: 'console.log(x);' },\n\
1392          },\n\
1393          check(ctx, m) { ctx.report(m.stmt); },\n\
1394        });\n";
1395
1396    /// A rule matching `x.y`, which is the shape that vanishes when JSX fails to parse.
1397    fn member_rule_for(language: &str) -> String {
1398        let declaration = if language.is_empty() {
1399            String::new()
1400        } else {
1401            format!("  language: {language},\n")
1402        };
1403        format!(
1404            "import {{ defineRule }} from 'lanekeep';\n\
1405             export default defineRule({{\n\
1406               id: 'local/member',\n\
1407             {declaration}\
1408               query: '(member_expression) @m',\n\
1409               card: {{\n\
1410                 message: 'member expression',\n\
1411                 remediation: 'n/a',\n\
1412                 examples: {{ bad: 'a.b', good: 'b' }},\n\
1413               }},\n\
1414               check(ctx, m) {{ ctx.report(m.m); }},\n\
1415             }});\n"
1416        )
1417    }
1418
1419    fn config_for(include: &str) -> String {
1420        format!(
1421            "import {{ defineConfig }} from 'lanekeep';\n\
1422             import rule from './rule';\n\
1423             export default defineConfig({{ include: ['{include}'], rules: [rule] }});\n"
1424        )
1425    }
1426
1427    fn config(extra: &str) -> String {
1428        format!(
1429            "import {{ defineConfig }} from 'lanekeep';\n\
1430             import rule from './rule';\n\
1431             export default defineConfig({{ include: ['src/**/*.ts'], rules: [rule]{extra} }});\n"
1432        )
1433    }
1434
1435    /// A rule with no `language` of its own has to see inside JSX.
1436    ///
1437    /// The default used to be `typescript` alone, and the engine parsed every file with the
1438    /// rule's grammar whatever the file was. So a `.tsx` file went through the TypeScript
1439    /// grammar, every JSX element became an `ERROR` node, and a query simply matched nothing
1440    /// inside it — with no error, no warning, and no way to tell from the output. On a React
1441    /// codebase that is most of the code.
1442    #[test]
1443    fn a_default_rule_sees_inside_jsx() {
1444        let project = Project::new(
1445            "jsx-default",
1446            &[
1447                ("rule.ts", &member_rule_for("")),
1448                ("lanekeep.config.ts", &config_for("src/**/*.tsx")),
1449                (
1450                    "src/Component.tsx",
1451                    "export const C = () => <View style={styles.used} />;\n",
1452                ),
1453            ],
1454        );
1455
1456        let outcome = project.run().expect("runs");
1457
1458        assert_eq!(
1459            outcome.violations.len(),
1460            1,
1461            "a member expression inside JSX was not seen: {:?}",
1462            outcome.violations
1463        );
1464    }
1465
1466    /// And the same rule still works on plain TypeScript, each file through its own grammar.
1467    #[test]
1468    fn a_default_rule_still_sees_plain_typescript() {
1469        let project = Project::new(
1470            "ts-default",
1471            &[
1472                ("rule.ts", &member_rule_for("")),
1473                ("lanekeep.config.ts", &config_for("src/**/*.ts")),
1474                ("src/plain.ts", "const x = styles.used;\n"),
1475            ],
1476        );
1477
1478        let outcome = project.run().expect("runs");
1479
1480        assert_eq!(outcome.violations.len(), 1, "{:?}", outcome.violations);
1481    }
1482
1483    /// A rule that names one language is not run on files belonging to another.
1484    ///
1485    /// Previously it was run on everything and the mismatch showed up as an unparsable tree
1486    /// rather than as a skip, which is the failure this whole change is about.
1487    #[test]
1488    fn a_rule_does_not_run_on_a_language_it_does_not_name() {
1489        let project = Project::new(
1490            "single-language",
1491            &[
1492                ("rule.ts", &member_rule_for("'typescript'")),
1493                ("lanekeep.config.ts", &config_for("src/**/*.tsx")),
1494                (
1495                    "src/Component.tsx",
1496                    "export const C = () => <View style={styles.used} />;\n",
1497                ),
1498            ],
1499        );
1500
1501        let outcome = project.run().expect("runs");
1502
1503        assert!(
1504            outcome.violations.is_empty(),
1505            "a typescript-only rule ran on a tsx file: {:?}",
1506            outcome.violations
1507        );
1508    }
1509
1510    /// Naming several languages runs the rule against each, compiled per grammar.
1511    #[test]
1512    fn a_rule_may_name_several_languages() {
1513        let project = Project::new(
1514            "many-languages",
1515            &[
1516                ("rule.ts", &member_rule_for("['typescript', 'tsx']")),
1517                ("lanekeep.config.ts", &config_for("src/**/*.{ts,tsx}")),
1518                ("src/plain.ts", "const x = styles.used;\n"),
1519                (
1520                    "src/Component.tsx",
1521                    "export const C = () => <View style={styles.used} />;\n",
1522                ),
1523            ],
1524        );
1525
1526        let outcome = project.run().expect("runs");
1527
1528        assert_eq!(outcome.violations.len(), 2, "{:?}", outcome.violations);
1529    }
1530
1531    /// An unknown language is still an error, however it is spelled.
1532    #[test]
1533    fn an_unknown_language_in_a_list_is_reported() {
1534        let project = Project::new(
1535            "unknown-in-list",
1536            &[
1537                ("rule.ts", &member_rule_for("['typescript', 'klingon']")),
1538                ("lanekeep.config.ts", &config_for("src/**/*.ts")),
1539                ("src/plain.ts", "const x = styles.used;\n"),
1540            ],
1541        );
1542
1543        let error = project
1544            .run()
1545            .expect_err("should refuse an unknown language");
1546        assert!(
1547            error.to_string().contains("klingon"),
1548            "the error should name it: {error}"
1549        );
1550    }
1551
1552    #[test]
1553    fn runs_a_rule_over_a_corpus_end_to_end() {
1554        let project = Project::new(
1555            "end-to-end",
1556            &[
1557                ("rule.ts", DEBUGGER_RULE),
1558                ("lanekeep.config.ts", &config("")),
1559                ("src/clean.ts", "const a = 1;\n"),
1560                ("src/dirty.ts", "const b = 2;\ndebugger;\n"),
1561                ("src/also.ts", "function f() {\n  debugger;\n}\n"),
1562            ],
1563        );
1564
1565        let outcome = project.run().expect("runs");
1566
1567        assert_eq!(outcome.violations.len(), 2, "{:?}", outcome.violations);
1568        let rendered: Vec<String> = outcome
1569            .violations
1570            .iter()
1571            .map(|v| format!("{} {}", v.rule_id, v.location))
1572            .collect();
1573        assert_eq!(
1574            rendered,
1575            [
1576                "local/no-debugger src/also.ts:2:3",
1577                "local/no-debugger src/dirty.ts:2:1",
1578            ]
1579        );
1580        assert_eq!(outcome.violations[0].message, "debugger statement");
1581        assert_eq!(
1582            outcome.violations[0].remediation,
1583            "remove it before committing"
1584        );
1585    }
1586
1587    #[test]
1588    fn output_is_identical_across_repeated_runs() {
1589        // The guarantee the whole design rests on. Files are checked in parallel, so
1590        // violations arrive in an order that varies run to run; only the sort makes the
1591        // output stable, and it has to hold across many files rather than two.
1592        let mut files = vec![
1593            ("rule.ts".to_owned(), DEBUGGER_RULE.to_owned()),
1594            ("lanekeep.config.ts".to_owned(), config("")),
1595        ];
1596        for i in 0..40 {
1597            files.push((
1598                format!("src/f{i}.ts"),
1599                format!("const x{i} = 1;\ndebugger;\n"),
1600            ));
1601        }
1602        let borrowed: Vec<(&str, &str)> = files
1603            .iter()
1604            .map(|(a, b)| (a.as_str(), b.as_str()))
1605            .collect();
1606        let project = Project::new("determinism", &borrowed);
1607
1608        let first = project.run().expect("runs").violations;
1609        assert_eq!(first.len(), 40);
1610
1611        for _ in 0..4 {
1612            assert_eq!(project.run().expect("runs").violations, first);
1613        }
1614    }
1615
1616    #[test]
1617    fn exclude_keeps_files_out_of_the_run() {
1618        let project = Project::new(
1619            "exclude",
1620            &[
1621                ("rule.ts", DEBUGGER_RULE),
1622                ("lanekeep.config.ts", &config(", exclude: ['**/*.test.ts']")),
1623                ("src/a.ts", "debugger;\n"),
1624                ("src/a.test.ts", "debugger;\n"),
1625            ],
1626        );
1627
1628        let outcome = project.run().expect("runs");
1629        assert_eq!(outcome.violations.len(), 1);
1630        assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
1631    }
1632
1633    #[test]
1634    fn a_content_gate_skips_the_parse() {
1635        // The gate's whole purpose. `files_parsed` is what proves it skipped rather than
1636        // parsed and found nothing — the violation count would look identical either way.
1637        let gated = "import { defineRule } from 'lanekeep';\n\
1638            export default defineRule({\n\
1639              id: 'local/no-debugger',\n\
1640              query: '(debugger_statement) @stmt',\n\
1641              gates: { fileContains: ['debugger'] },\n\
1642              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
1643              check(ctx, m) { ctx.report(m.stmt); },\n\
1644            });\n";
1645
1646        let project = Project::new(
1647            "gate",
1648            &[
1649                ("rule.ts", gated),
1650                ("lanekeep.config.ts", &config("")),
1651                ("src/a.ts", "debugger;\n"),
1652                ("src/b.ts", "const b = 1;\n"),
1653                ("src/c.ts", "const c = 2;\n"),
1654            ],
1655        );
1656
1657        let outcome = project.run().expect("runs");
1658        assert_eq!(outcome.files_discovered, 3);
1659        assert_eq!(
1660            outcome.files_parsed, 1,
1661            "only the file containing the needle should parse"
1662        );
1663        assert_eq!(outcome.violations.len(), 1);
1664    }
1665
1666    #[test]
1667    fn a_rule_set_to_off_does_not_run() {
1668        let project = Project::new(
1669            "off",
1670            &[
1671                ("rule.ts", DEBUGGER_RULE),
1672                (
1673                    "lanekeep.config.ts",
1674                    &config(", severity: { 'local/no-debugger': 'off' }"),
1675                ),
1676                ("src/a.ts", "debugger;\n"),
1677            ],
1678        );
1679        assert!(project.run().expect("runs").violations.is_empty());
1680    }
1681
1682    #[test]
1683    fn severity_reaches_the_violation() {
1684        let project = Project::new(
1685            "severity",
1686            &[
1687                ("rule.ts", DEBUGGER_RULE),
1688                (
1689                    "lanekeep.config.ts",
1690                    &config(", severity: { 'local/no-debugger': 'warn' }"),
1691                ),
1692                ("src/a.ts", "debugger;\n"),
1693            ],
1694        );
1695        let outcome = project.run().expect("runs");
1696        assert_eq!(outcome.violations[0].severity, Severity::Warn);
1697        assert!(!any_failing(&outcome.violations));
1698    }
1699
1700    #[test]
1701    fn a_rule_that_throws_aborts_the_run_naming_itself_and_the_file() {
1702        // §6.8: a breach cancels rather than degrading to a partial result, and the
1703        // diagnostic has to identify the culprit or it is not actionable.
1704        let throwing = "import { defineRule } from 'lanekeep';\n\
1705            export default defineRule({\n\
1706              id: 'local/throws',\n\
1707              query: '(debugger_statement) @stmt',\n\
1708              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
1709              check() { throw new Error('rule bug'); },\n\
1710            });\n";
1711
1712        let project = Project::new(
1713            "throws",
1714            &[
1715                ("rule.ts", throwing),
1716                ("lanekeep.config.ts", &config("")),
1717                ("src/a.ts", "debugger;\n"),
1718            ],
1719        );
1720
1721        let err = project.run().expect_err("must abort");
1722        let rendered = err.to_string();
1723        assert!(rendered.contains("local/throws"), "{rendered}");
1724        assert!(rendered.contains("src/a.ts"), "{rendered}");
1725        assert!(rendered.contains("rule bug"), "{rendered}");
1726    }
1727
1728    #[test]
1729    fn an_invalid_query_fails_before_any_file_is_read() {
1730        let bad = "import { defineRule } from 'lanekeep';\n\
1731            export default defineRule({\n\
1732              id: 'local/bad-query',\n\
1733              query: '(no_such_node) @x',\n\
1734              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
1735              check() {},\n\
1736            });\n";
1737
1738        let project = Project::new(
1739            "bad-query",
1740            &[
1741                ("rule.ts", bad),
1742                ("lanekeep.config.ts", &config("")),
1743                ("src/a.ts", "debugger;\n"),
1744            ],
1745        );
1746
1747        let err = project.run().expect_err("must fail at preparation");
1748        assert!(matches!(err, RunError::Query { .. }), "{err:?}");
1749        assert!(err.to_string().contains("no_such_node"), "{err}");
1750    }
1751
1752    #[test]
1753    fn a_rule_can_use_the_host_api_it_was_given() {
1754        // Proves the ctx surface is actually reachable from a real rule, not just from
1755        // the sandbox's own tests.
1756        let rule = "import { defineRule } from 'lanekeep';\n\
1757            export default defineRule({\n\
1758              id: 'local/long-names',\n\
1759              query: '(variable_declarator name: (identifier) @name)',\n\
1760              card: { message: 'name too long', remediation: 'shorten it', examples: { bad: 'a', good: 'b' } },\n\
1761              check(ctx, m) {\n\
1762                if (ctx.text(m.name).length > 5) ctx.report(m.name, `\\\"${ctx.text(m.name)}\\\" is too long`);\n\
1763              },\n\
1764            });\n";
1765
1766        let project = Project::new(
1767            "host-api",
1768            &[
1769                ("rule.ts", rule),
1770                ("lanekeep.config.ts", &config("")),
1771                ("src/a.ts", "const ok = 1;\nconst wayTooLong = 2;\n"),
1772            ],
1773        );
1774
1775        let outcome = project.run().expect("runs");
1776        assert_eq!(outcome.violations.len(), 1);
1777        assert!(
1778            outcome.violations[0].message.contains("wayTooLong"),
1779            "{:?}",
1780            outcome.violations[0]
1781        );
1782    }
1783
1784    #[test]
1785    fn a_corpus_with_no_matches_produces_nothing() {
1786        let project = Project::new(
1787            "clean",
1788            &[
1789                ("rule.ts", DEBUGGER_RULE),
1790                ("lanekeep.config.ts", &config("")),
1791                ("src/a.ts", "const a = 1;\n"),
1792            ],
1793        );
1794        let outcome = project.run().expect("runs");
1795        assert!(outcome.violations.is_empty());
1796        assert_eq!(outcome.files_parsed, 1, "no gates means it is still parsed");
1797    }
1798
1799    // --- the reduce phase ----------------------------------------------------------------
1800
1801    /// A cross-file rule: every exported symbol nobody imports.
1802    ///
1803    /// The smallest rule that genuinely cannot work per-file — whether an export is unused
1804    /// is not a property of the file that declares it.
1805    const UNUSED_EXPORTS_RULE: &str = r"import { defineRule } from 'lanekeep';
1806export default defineRule({
1807  id: 'local/no-unused-exports',
1808  query: `
1809    (export_statement declaration: (function_declaration name: (identifier) @name)) @stmt
1810    (import_statement (import_clause (named_imports (import_specifier name: (identifier) @imported))))
1811  `,
1812  card: {
1813    message: 'unused export',
1814    remediation: 'delete it, or import it somewhere',
1815    examples: { bad: 'export function unused() {}', good: 'function used() {}' },
1816  },
1817  check(ctx, m) {
1818    if (m.imported) {
1819      ctx.emitFact({ kind: 'import', symbol: ctx.text(m.imported) });
1820      return;
1821    }
1822    ctx.emitFact({
1823      kind: 'export',
1824      symbol: ctx.text(m.name),
1825      line: ctx.line(m.stmt),
1826      column: ctx.column(m.stmt),
1827    });
1828  },
1829  reduce(ctx) {
1830    const imported = new Set(ctx.facts('import').map((f) => f.symbol));
1831    for (const e of ctx.facts('export')) {
1832      if (!imported.has(e.symbol)) {
1833        ctx.report({ file: e.file, line: e.line, column: e.column }, `'${e.symbol}' is exported but never imported`);
1834      }
1835    }
1836  },
1837});
1838";
1839
1840    #[test]
1841    fn a_reduce_phase_sees_facts_from_every_file() {
1842        let project = Project::new(
1843            "reduce-cross-file",
1844            &[
1845                ("rule.ts", UNUSED_EXPORTS_RULE),
1846                ("lanekeep.config.ts", &config("")),
1847                (
1848                    "src/a.ts",
1849                    "export function used() {}\nexport function spare() {}\n",
1850                ),
1851                ("src/b.ts", "import { used } from './a';\nused();\n"),
1852            ],
1853        );
1854
1855        let outcome = project.run().expect("runs");
1856        let found: Vec<(&str, u32, &str)> = outcome
1857            .violations
1858            .iter()
1859            .map(|v| {
1860                (
1861                    v.location.file.as_str(),
1862                    v.location.position.line,
1863                    v.message.as_str(),
1864                )
1865            })
1866            .collect();
1867
1868        assert_eq!(
1869            found,
1870            vec![("src/a.ts", 2, "'spare' is exported but never imported")],
1871            "only the export nobody imports should be reported"
1872        );
1873    }
1874
1875    #[test]
1876    fn a_rule_with_no_reduce_still_runs() {
1877        // The common path must not regress: no reduce phase, no sandbox built for one.
1878        let project = Project::new(
1879            "reduce-absent",
1880            &[
1881                ("rule.ts", DEBUGGER_RULE),
1882                ("lanekeep.config.ts", &config("")),
1883                ("src/a.ts", "debugger;\n"),
1884            ],
1885        );
1886        let outcome = project.run().expect("runs");
1887        assert_eq!(outcome.violations.len(), 1);
1888    }
1889
1890    #[test]
1891    fn a_reduce_phase_with_no_facts_reports_nothing() {
1892        let project = Project::new(
1893            "reduce-empty",
1894            &[
1895                ("rule.ts", UNUSED_EXPORTS_RULE),
1896                ("lanekeep.config.ts", &config("")),
1897                ("src/a.ts", "const a = 1;\n"),
1898            ],
1899        );
1900        assert!(project.run().expect("runs").violations.is_empty());
1901    }
1902
1903    #[test]
1904    fn the_file_list_reaches_the_reduce_phase() {
1905        const RULE: &str = r"import { defineRule } from 'lanekeep';
1906export default defineRule({
1907  id: 'local/counts-files',
1908  query: '(debugger_statement) @stmt',
1909  card: {
1910    message: 'file count',
1911    remediation: 'nothing to do',
1912    examples: { bad: 'a', good: 'b' },
1913  },
1914  check() {},
1915  reduce(ctx) {
1916    ctx.report({ file: ctx.files[0], line: ctx.files.length, column: 1 });
1917  },
1918});
1919";
1920        let project = Project::new(
1921            "reduce-files",
1922            &[
1923                ("rule.ts", RULE),
1924                ("lanekeep.config.ts", &config("")),
1925                ("src/a.ts", "const a = 1;\n"),
1926                ("src/b.ts", "const b = 1;\n"),
1927            ],
1928        );
1929
1930        let outcome = project.run().expect("runs");
1931        assert_eq!(outcome.violations.len(), 1);
1932        // Discovery sorts, so `files[0]` is `src/a.ts` on every run and every platform.
1933        assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
1934        assert_eq!(outcome.violations[0].location.position.line, 2);
1935    }
1936
1937    #[test]
1938    fn a_rule_does_not_see_another_rules_facts() {
1939        // Otherwise a payload shape becomes a contract between rules, and the result starts
1940        // depending on the order rules were declared in.
1941        const EMITTER: &str = r"import { defineRule } from 'lanekeep';
1942export default defineRule({
1943  id: 'local/emitter',
1944  query: '(export_statement) @stmt',
1945  card: { message: 'emitter', remediation: 'x', examples: { bad: 'a', good: 'b' } },
1946  check(ctx, m) { ctx.emitFact({ kind: 'thing', from: 'emitter' }); },
1947});
1948";
1949        const READER: &str = r"import { defineRule } from 'lanekeep';
1950export default defineRule({
1951  id: 'local/reader',
1952  query: '(export_statement) @stmt',
1953  card: { message: 'reader', remediation: 'x', examples: { bad: 'a', good: 'b' } },
1954  check() {},
1955  reduce(ctx) {
1956    ctx.report({ file: 'seen.ts', line: ctx.facts().length + 1, column: 1 });
1957  },
1958});
1959";
1960        let project = Project::new(
1961            "reduce-isolation",
1962            &[
1963                ("emitter.ts", EMITTER),
1964                ("reader.ts", READER),
1965                (
1966                    "lanekeep.config.ts",
1967                    "import { defineConfig } from 'lanekeep';\n\
1968                     import emitter from './emitter';\n\
1969                     import reader from './reader';\n\
1970                     export default defineConfig({ include: ['src/**/*.ts'], rules: [emitter, reader] });\n",
1971                ),
1972                ("src/a.ts", "export const a = 1;\n"),
1973            ],
1974        );
1975
1976        let outcome = project.run().expect("runs");
1977        assert_eq!(outcome.violations.len(), 1);
1978        assert_eq!(
1979            outcome.violations[0].location.position.line, 1,
1980            "the reader saw the emitter's facts"
1981        );
1982    }
1983
1984    #[test]
1985    fn a_reduce_phase_that_throws_aborts_the_run() {
1986        // Same posture as a `check` that throws: a partial result reported as a complete
1987        // one is worse than no result.
1988        const RULE: &str = r"import { defineRule } from 'lanekeep';
1989export default defineRule({
1990  id: 'local/throws-in-reduce',
1991  query: '(debugger_statement) @stmt',
1992  card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
1993  check() {},
1994  reduce() { throw new Error('reduce exploded'); },
1995});
1996";
1997        let project = Project::new(
1998            "reduce-throws",
1999            &[
2000                ("rule.ts", RULE),
2001                ("lanekeep.config.ts", &config("")),
2002                ("src/a.ts", "const a = 1;\n"),
2003            ],
2004        );
2005
2006        let error = project.run().expect_err("aborts");
2007        let rendered = error.to_string();
2008        assert!(rendered.contains("reduce exploded"), "{rendered}");
2009        assert!(
2010            rendered.contains("local/throws-in-reduce"),
2011            "the error should name the rule: {rendered}"
2012        );
2013    }
2014
2015    #[test]
2016    fn facts_reach_reduce_in_the_same_order_on_every_run() {
2017        // The determinism invariant at the level a rule can observe: `ctx.facts()` is in
2018        // (file, sequence) order, so a rule that takes the first match — or builds a
2019        // "first seen wins" map — gives the same answer every run.
2020        //
2021        // This asserts the property, not the mechanism. Two things currently produce it,
2022        // rayon's order-preserving `collect` and the explicit sort, so removing either one
2023        // alone leaves this passing. The sort's own coverage is in `lanekeep_core::fact`,
2024        // where shuffled input makes its absence visible.
2025        const RULE: &str = r"import { defineRule } from 'lanekeep';
2026export default defineRule({
2027  id: 'local/first-fact-wins',
2028  query: '(export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name)))',
2029  card: { message: 'first', remediation: 'x', examples: { bad: 'a', good: 'b' } },
2030  check(ctx, m) { ctx.emitFact({ kind: 'sym', symbol: ctx.text(m.name) }); },
2031  reduce(ctx) {
2032    const all = ctx.facts('sym');
2033    ctx.report({ file: 'order.ts', line: 1, column: 1 }, all.map((f) => `${f.file}:${f.symbol}`).join(','));
2034  },
2035});
2036";
2037        let files: Vec<(String, String)> = (0..12)
2038            .map(|i| {
2039                (
2040                    format!("src/f{i:02}.ts"),
2041                    format!("export const s{i:02} = {i};\n"),
2042                )
2043            })
2044            .collect();
2045
2046        let mut layout: Vec<(&str, &str)> = vec![("rule.ts", RULE)];
2047        let config_source = config("");
2048        layout.push(("lanekeep.config.ts", &config_source));
2049        for (path, contents) in &files {
2050            layout.push((path, contents));
2051        }
2052
2053        let project = Project::new("reduce-determinism", &layout);
2054
2055        let first = project.run().expect("runs").violations[0].message.clone();
2056        for attempt in 0..4 {
2057            let again = project.run().expect("runs").violations[0].message.clone();
2058            assert_eq!(again, first, "fact order changed on attempt {attempt}");
2059        }
2060
2061        // And it is the canonical order, not merely a repeatable one.
2062        assert!(
2063            first.starts_with("src/f00.ts:s00,src/f01.ts:s01,"),
2064            "facts are not in (file, sequence) order: {first}"
2065        );
2066    }
2067
2068    #[test]
2069    fn a_rule_cannot_misattribute_a_fact_to_another_file() {
2070        // The host sets `file`, last, so a rule's own `file` key loses to it.
2071        const RULE: &str = r"import { defineRule } from 'lanekeep';
2072export default defineRule({
2073  id: 'local/lying-fact',
2074  query: '(export_statement) @stmt',
2075  card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
2076  check(ctx, m) { ctx.emitFact({ kind: 'e', file: 'somewhere-else.ts' }); },
2077  reduce(ctx) {
2078    for (const f of ctx.facts('e')) ctx.report({ file: f.file, line: 1, column: 1 });
2079  },
2080});
2081";
2082        let project = Project::new(
2083            "reduce-misattribution",
2084            &[
2085                ("rule.ts", RULE),
2086                ("lanekeep.config.ts", &config("")),
2087                ("src/a.ts", "export const a = 1;\n"),
2088            ],
2089        );
2090
2091        let outcome = project.run().expect("runs");
2092        assert_eq!(outcome.violations.len(), 1);
2093        assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
2094    }
2095
2096    // --- tracked reads -------------------------------------------------------------------
2097
2098    /// A rule that reads a sibling file and reports when it says so.
2099    const READING_RULE: &str = r"import { defineRule } from 'lanekeep';
2100export default defineRule({
2101  id: 'local/reads-config',
2102  query: '(export_statement) @stmt',
2103  card: {
2104    message: 'config says no',
2105    remediation: 'change the config, or the code',
2106    examples: { bad: 'export const a = 1;', good: 'const a = 1;' },
2107  },
2108  check(ctx, m) {
2109    const raw = ctx.readFile('policy.json');
2110    if (raw && JSON.parse(raw).forbidExports) ctx.report(m.stmt);
2111  },
2112});
2113";
2114
2115    #[test]
2116    fn a_rule_can_read_another_file() {
2117        let project = Project::new(
2118            "reads-allowed",
2119            &[
2120                ("rule.ts", READING_RULE),
2121                ("lanekeep.config.ts", &config("")),
2122                ("policy.json", r#"{"forbidExports":true}"#),
2123                ("src/a.ts", "export const a = 1;\n"),
2124            ],
2125        );
2126        let outcome = project.run().expect("runs");
2127        assert_eq!(outcome.violations.len(), 1, "{:?}", outcome.violations);
2128    }
2129
2130    #[test]
2131    fn what_the_file_says_changes_the_result() {
2132        // Otherwise the test above would pass on a `readFile` that returned nothing.
2133        let project = Project::new(
2134            "reads-content",
2135            &[
2136                ("rule.ts", READING_RULE),
2137                ("lanekeep.config.ts", &config("")),
2138                ("policy.json", r#"{"forbidExports":false}"#),
2139                ("src/a.ts", "export const a = 1;\n"),
2140            ],
2141        );
2142        assert!(project.run().expect("runs").violations.is_empty());
2143    }
2144
2145    #[test]
2146    fn a_read_is_recorded_against_the_file_that_made_it() {
2147        // The shape a cache entry needs. A dependency recorded against the run, or leaked
2148        // from the previous file on the same worker, would invalidate the wrong entries.
2149        //
2150        // Enough files that workers necessarily handle several each: with only two, rayon
2151        // puts them on separate workers with separate `FileAccess`, and a missing reset
2152        // between files cannot show. `FileAccess::clear` is covered deterministically by
2153        // its own unit test; this covers the engine actually calling it.
2154        let mut layout: Vec<(String, String)> = vec![
2155            ("rule.ts".to_owned(), READING_RULE.to_owned()),
2156            ("lanekeep.config.ts".to_owned(), config("")),
2157            (
2158                "policy.json".to_owned(),
2159                r#"{"forbidExports":false}"#.to_owned(),
2160            ),
2161        ];
2162        // Odd files export and therefore read; even files do neither.
2163        for i in 0..24 {
2164            let body = if i % 2 == 0 {
2165                format!("const v{i} = {i};\n")
2166            } else {
2167                format!("export const v{i} = {i};\n")
2168            };
2169            layout.push((format!("src/f{i:02}.ts"), body));
2170        }
2171        let borrowed: Vec<(&str, &str)> = layout
2172            .iter()
2173            .map(|(p, c)| (p.as_str(), c.as_str()))
2174            .collect();
2175
2176        let project = Project::new("reads-attributed", &borrowed);
2177        let outcome = project.run().expect("runs");
2178
2179        for i in 0..24 {
2180            let file = FilePath::new(format!("src/f{i:02}.ts"));
2181            let deps = outcome.dependencies.get(&file);
2182            if i % 2 == 0 {
2183                assert!(
2184                    deps.is_none(),
2185                    "src/f{i:02}.ts read nothing but has {deps:?}"
2186                );
2187            } else {
2188                let deps = deps.unwrap_or_else(|| panic!("src/f{i:02}.ts should have read"));
2189                assert_eq!(deps.len(), 1);
2190                assert_eq!(deps[0].path.as_str(), "policy.json");
2191                assert!(deps[0].hash.is_some());
2192            }
2193        }
2194    }
2195
2196    #[test]
2197    fn a_missing_file_is_recorded_as_a_dependency_too() {
2198        // The case that makes a cache wrong rather than cold: the answer "not there" has to
2199        // be invalidated when the file appears.
2200        const RULE: &str = r"import { defineRule } from 'lanekeep';
2201export default defineRule({
2202  id: 'local/wants-config',
2203  query: '(export_statement) @stmt',
2204  card: { message: 'no config', remediation: 'add one', examples: { bad: 'a', good: 'b' } },
2205  check(ctx, m) {
2206    if (!ctx.fileExists('tsconfig.json')) ctx.report(m.stmt);
2207  },
2208});
2209";
2210        let project = Project::new(
2211            "reads-absent",
2212            &[
2213                ("rule.ts", RULE),
2214                ("lanekeep.config.ts", &config("")),
2215                ("src/a.ts", "export const a = 1;\n"),
2216            ],
2217        );
2218
2219        let outcome = project.run().expect("runs");
2220        assert_eq!(outcome.violations.len(), 1);
2221
2222        let deps = outcome
2223            .dependencies
2224            .get(&FilePath::new("src/a.ts"))
2225            .expect("the miss is a dependency");
2226        assert_eq!(deps.len(), 1);
2227        assert_eq!(deps[0].path.as_str(), "tsconfig.json");
2228        assert_eq!(deps[0].hash, None, "absence is recorded as absence");
2229    }
2230
2231    #[test]
2232    fn reading_outside_the_project_aborts_the_run() {
2233        // Not a rule that reports nothing: a rule reaching outside the project is a rule
2234        // doing something it must never do, and a run that continued would be reporting a
2235        // result produced by code that tried.
2236        const RULE: &str = r"import { defineRule } from 'lanekeep';
2237export default defineRule({
2238  id: 'local/escapes',
2239  query: '(export_statement) @stmt',
2240  card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
2241  check(ctx) { ctx.readFile('../../../etc/passwd'); },
2242});
2243";
2244        let project = Project::new(
2245            "reads-escape",
2246            &[
2247                ("rule.ts", RULE),
2248                ("lanekeep.config.ts", &config("")),
2249                ("src/a.ts", "export const a = 1;\n"),
2250            ],
2251        );
2252
2253        let error = project.run().expect_err("aborts");
2254        let rendered = error.to_string();
2255        assert!(rendered.contains("outside the project root"), "{rendered}");
2256        assert!(rendered.contains("local/escapes"), "{rendered}");
2257    }
2258
2259    #[test]
2260    fn reading_the_same_file_from_two_files_records_it_under_both() {
2261        let project = Project::new(
2262            "reads-shared",
2263            &[
2264                ("rule.ts", READING_RULE),
2265                ("lanekeep.config.ts", &config("")),
2266                ("policy.json", r#"{"forbidExports":false}"#),
2267                ("src/a.ts", "export const a = 1;\n"),
2268                ("src/b.ts", "export const b = 1;\n"),
2269            ],
2270        );
2271
2272        let outcome = project.run().expect("runs");
2273        for file in ["src/a.ts", "src/b.ts"] {
2274            let deps = outcome
2275                .dependencies
2276                .get(&FilePath::new(file))
2277                .unwrap_or_else(|| panic!("{file} should depend on the policy"));
2278            assert_eq!(deps[0].path.as_str(), "policy.json");
2279        }
2280
2281        // The same bytes, so the same hash — a cache must not see two different
2282        // dependencies on one file.
2283        let a = &outcome.dependencies[&FilePath::new("src/a.ts")][0];
2284        let b = &outcome.dependencies[&FilePath::new("src/b.ts")][0];
2285        assert_eq!(a.hash, b.hash);
2286    }
2287
2288    #[test]
2289    fn dependencies_are_the_same_on_every_run() {
2290        let project = Project::new(
2291            "reads-deterministic",
2292            &[
2293                ("rule.ts", READING_RULE),
2294                ("lanekeep.config.ts", &config("")),
2295                ("policy.json", r#"{"forbidExports":false}"#),
2296                ("src/a.ts", "export const a = 1;\n"),
2297                ("src/b.ts", "export const b = 1;\n"),
2298                ("src/c.ts", "export const c = 1;\n"),
2299            ],
2300        );
2301        let first = project.run().expect("runs").dependencies;
2302        assert!(!first.is_empty());
2303        for attempt in 0..4 {
2304            assert_eq!(
2305                project.run().expect("runs").dependencies,
2306                first,
2307                "dependencies changed on attempt {attempt}"
2308            );
2309        }
2310    }
2311
2312    #[test]
2313    fn the_read_surface_is_absent_from_the_reduce_phase() {
2314        // Reduce reads would be run-level dependencies, not per-file ones, and storing them
2315        // in a per-file entry would attribute them to whichever file came last. Until the
2316        // cache can express that, the functions are not there to be misused.
2317        const RULE: &str = r"import { defineRule } from 'lanekeep';
2318export default defineRule({
2319  id: 'local/reduce-reads',
2320  query: '(export_statement) @stmt',
2321  card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
2322  check() {},
2323  reduce(ctx) {
2324    const absent = ctx.readFile === undefined && ctx.fileExists === undefined;
2325    ctx.report({ file: 'probe.ts', line: absent ? 1 : 2, column: 1 });
2326  },
2327});
2328";
2329        let project = Project::new(
2330            "reads-reduce",
2331            &[
2332                ("rule.ts", RULE),
2333                ("lanekeep.config.ts", &config("")),
2334                ("src/a.ts", "export const a = 1;\n"),
2335            ],
2336        );
2337
2338        let outcome = project.run().expect("runs");
2339        assert_eq!(outcome.violations.len(), 1);
2340        assert_eq!(
2341            outcome.violations[0].location.position.line, 1,
2342            "reads must not be reachable from a reduce phase"
2343        );
2344    }
2345
2346    // --- the cache -----------------------------------------------------------------------
2347
2348    impl Project {
2349        /// Run with the cache disabled, for comparing against a warm run.
2350        fn run_cold(&self) -> Result<Outcome, RunError> {
2351            self.build().map(Engine::without_cache)?.run()
2352        }
2353
2354        /// The engine, without running it.
2355        fn build(&self) -> Result<Engine, RunError> {
2356            let root = RuleRoot::new(&self.dir).expect("canonicalizes");
2357            let config_path = self.dir.join("lanekeep.config.ts");
2358            let sandbox =
2359                lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
2360                    .expect("sandbox");
2361            let config = lanekeep_config::load(&sandbox, &root, &config_path)
2362                .unwrap_or_else(|e| panic!("config failed to load: {e}"));
2363            Engine::prepare(
2364                &config,
2365                &self.dir,
2366                root,
2367                &config_path,
2368                &lanekeep_lang_js::registry(),
2369                Arc::new(TypeScript),
2370                Arc::new(JavaScript),
2371            )
2372        }
2373
2374        fn cache(&self) -> Store {
2375            Store::load(&self.dir)
2376        }
2377    }
2378
2379    fn rendered(outcome: &Outcome) -> Vec<String> {
2380        outcome
2381            .violations
2382            .iter()
2383            .map(|v| {
2384                format!(
2385                    "{}:{}:{} {} {}",
2386                    v.location.file.as_str(),
2387                    v.location.position.line,
2388                    v.location.position.column,
2389                    v.rule_id,
2390                    v.message
2391                )
2392            })
2393            .collect()
2394    }
2395
2396    #[test]
2397    fn a_warm_run_agrees_with_a_cold_one() {
2398        let project = Project::new(
2399            "cache-agrees",
2400            &[
2401                ("rule.ts", DEBUGGER_RULE),
2402                ("lanekeep.config.ts", &config("")),
2403                ("src/a.ts", "debugger;\nconst a = 1;\n"),
2404                ("src/b.ts", "const b = 1;\ndebugger;\n"),
2405                ("src/c.ts", "const c = 1;\n"),
2406            ],
2407        );
2408
2409        let cold = rendered(&project.run().expect("runs"));
2410        let warm = rendered(&project.run().expect("runs"));
2411        assert_eq!(warm, cold, "the cache changed the answer");
2412        assert!(!cold.is_empty(), "the fixture should report something");
2413    }
2414
2415    #[test]
2416    fn a_run_writes_a_cache() {
2417        let project = Project::new(
2418            "cache-written",
2419            &[
2420                ("rule.ts", DEBUGGER_RULE),
2421                ("lanekeep.config.ts", &config("")),
2422                ("src/a.ts", "debugger;\n"),
2423            ],
2424        );
2425        assert!(project.cache().is_empty(), "nothing before the first run");
2426        project.run().expect("runs");
2427        assert!(!project.cache().is_empty(), "the run stored nothing");
2428    }
2429
2430    #[test]
2431    fn a_cached_result_is_actually_used() {
2432        // Agreeing with a cold run proves nothing on its own — a cache that was never read
2433        // would agree too. So doctor the stored entry and show the doctored value comes
2434        // back: that can only happen through the cache.
2435        let project = Project::new(
2436            "cache-used",
2437            &[
2438                ("rule.ts", DEBUGGER_RULE),
2439                ("lanekeep.config.ts", &config("")),
2440                ("src/a.ts", "const a = 1;\n"),
2441            ],
2442        );
2443        assert!(project.run().expect("runs").violations.is_empty());
2444
2445        let store = project.cache();
2446        let key = *store
2447            .keys()
2448            .next()
2449            .expect("the run stored an entry for the file");
2450
2451        let mut doctored = Store::empty();
2452        doctored.insert(
2453            key,
2454            lanekeep_cache::Entry {
2455                violations: vec![Violation {
2456                    rule_id: "local/no-debugger".parse().expect("valid id"),
2457                    location: Location::new(FilePath::new("src/a.ts"), Position::new(7, 3)),
2458                    message: "from the cache".to_owned(),
2459                    remediation: "nothing".to_owned(),
2460                    severity: Severity::Error,
2461                    fix: None,
2462                }],
2463                facts: Vec::new(),
2464                dependencies: Vec::new(),
2465                suppressions: Vec::new(),
2466                used_suppressions: Vec::new(),
2467            },
2468        );
2469        doctored.save(&project.dir);
2470
2471        let outcome = project.run().expect("runs");
2472        assert_eq!(
2473            rendered(&outcome),
2474            vec!["src/a.ts:7:3 local/no-debugger from the cache"],
2475            "the cached entry was not used"
2476        );
2477    }
2478
2479    #[test]
2480    fn editing_a_file_invalidates_it() {
2481        let project = Project::new(
2482            "cache-edited",
2483            &[
2484                ("rule.ts", DEBUGGER_RULE),
2485                ("lanekeep.config.ts", &config("")),
2486                ("src/a.ts", "const a = 1;\n"),
2487            ],
2488        );
2489        assert!(project.run().expect("runs").violations.is_empty());
2490
2491        project.write("src/a.ts", "debugger;\n");
2492        assert_eq!(
2493            project.run().expect("runs").violations.len(),
2494            1,
2495            "an edited file kept its stale result"
2496        );
2497    }
2498
2499    #[test]
2500    fn moving_a_file_invalidates_it() {
2501        // Path gates make results path-sensitive, so identical bytes at a new path are not
2502        // a hit. This fixture's rule has no path gate, but the key must not depend on that.
2503        let project = Project::new(
2504            "cache-moved",
2505            &[
2506                ("rule.ts", DEBUGGER_RULE),
2507                ("lanekeep.config.ts", &config("")),
2508                ("src/a.ts", "debugger;\n"),
2509            ],
2510        );
2511        project.run().expect("runs");
2512
2513        fs::remove_file(project.dir.join("src/a.ts")).expect("removes");
2514        project.write("src/moved.ts", "debugger;\n");
2515
2516        let outcome = project.run().expect("runs");
2517        assert_eq!(
2518            outcome.violations[0].location.file.as_str(),
2519            "src/moved.ts",
2520            "the violation followed the old path"
2521        );
2522    }
2523
2524    #[test]
2525    fn editing_a_tracked_dependency_invalidates_the_files_that_read_it() {
2526        // The reason tracked effects exist. Nothing about `src/a.ts` changed, and its result
2527        // still has to be recomputed.
2528        let project = Project::new(
2529            "cache-dependency",
2530            &[
2531                ("rule.ts", READING_RULE),
2532                ("lanekeep.config.ts", &config("")),
2533                ("policy.json", r#"{"forbidExports":false}"#),
2534                ("src/a.ts", "export const a = 1;\n"),
2535            ],
2536        );
2537        assert!(project.run().expect("runs").violations.is_empty());
2538
2539        project.write("policy.json", r#"{"forbidExports":true}"#);
2540        assert_eq!(
2541            project.run().expect("runs").violations.len(),
2542            1,
2543            "a changed dependency did not invalidate"
2544        );
2545    }
2546
2547    #[test]
2548    fn a_dependency_that_appears_invalidates() {
2549        // The case a cache is wrong rather than merely cold without: a rule was told a file
2550        // was absent, and creating it has to reopen the question.
2551        const RULE: &str = r"import { defineRule } from 'lanekeep';
2552export default defineRule({
2553  id: 'local/wants-config',
2554  query: '(export_statement) @stmt',
2555  card: { message: 'no config', remediation: 'add one', examples: { bad: 'a', good: 'b' } },
2556  check(ctx, m) {
2557    if (!ctx.fileExists('tsconfig.json')) ctx.report(m.stmt);
2558  },
2559});
2560";
2561        let project = Project::new(
2562            "cache-appeared",
2563            &[
2564                ("rule.ts", RULE),
2565                ("lanekeep.config.ts", &config("")),
2566                ("src/a.ts", "export const a = 1;\n"),
2567            ],
2568        );
2569        assert_eq!(project.run().expect("runs").violations.len(), 1);
2570
2571        project.write("tsconfig.json", "{}");
2572        assert!(
2573            project.run().expect("runs").violations.is_empty(),
2574            "a dependency that appeared did not invalidate"
2575        );
2576    }
2577
2578    #[test]
2579    fn changing_the_ruleset_invalidates_everything() {
2580        let project = Project::new(
2581            "cache-ruleset",
2582            &[
2583                ("rule.ts", DEBUGGER_RULE),
2584                ("lanekeep.config.ts", &config("")),
2585                ("src/a.ts", "debugger;\n"),
2586            ],
2587        );
2588        assert_eq!(project.run().expect("runs").violations.len(), 1);
2589
2590        // Same file, different rule: it now reports nothing.
2591        project.write(
2592            "rule.ts",
2593            &DEBUGGER_RULE.replace("ctx.report(m.stmt);", "/* nothing */"),
2594        );
2595        assert!(
2596            project.run().expect("runs").violations.is_empty(),
2597            "an edited rule kept its stale results"
2598        );
2599    }
2600
2601    #[test]
2602    fn changing_the_config_invalidates_everything() {
2603        let project = Project::new(
2604            "cache-config",
2605            &[
2606                ("rule.ts", DEBUGGER_RULE),
2607                ("lanekeep.config.ts", &config("")),
2608                ("src/a.ts", "debugger;\n"),
2609            ],
2610        );
2611        assert_eq!(project.run().expect("runs").violations.len(), 1);
2612
2613        project.write(
2614            "lanekeep.config.ts",
2615            &config(", severity: { 'local/no-debugger': 'off' }"),
2616        );
2617        assert!(
2618            project.run().expect("runs").violations.is_empty(),
2619            "a config change did not invalidate"
2620        );
2621    }
2622
2623    #[test]
2624    fn a_corrupt_cache_still_produces_the_right_answer() {
2625        // Disposability, end to end: garbage on disk costs a recompute and nothing else.
2626        let project = Project::new(
2627            "cache-corrupt",
2628            &[
2629                ("rule.ts", DEBUGGER_RULE),
2630                ("lanekeep.config.ts", &config("")),
2631                ("src/a.ts", "debugger;\n"),
2632            ],
2633        );
2634        let expected = rendered(&project.run().expect("runs"));
2635
2636        let path = Store::path_for(&project.dir);
2637        fs::write(&path, b"\x00\x01\x02 not a cache").expect("writes");
2638
2639        assert_eq!(rendered(&project.run().expect("runs")), expected);
2640    }
2641
2642    #[test]
2643    fn caching_can_be_turned_off() {
2644        let project = Project::new(
2645            "cache-off",
2646            &[
2647                ("rule.ts", DEBUGGER_RULE),
2648                ("lanekeep.config.ts", &config("")),
2649                ("src/a.ts", "debugger;\n"),
2650            ],
2651        );
2652        let outcome = project.run_cold().expect("runs");
2653        assert_eq!(outcome.violations.len(), 1);
2654        assert!(
2655            project.cache().is_empty(),
2656            "a run with caching off wrote a cache"
2657        );
2658    }
2659
2660    #[test]
2661    fn facts_survive_a_warm_run() {
2662        // The reduce phase runs every time, over facts that may all have come from the
2663        // cache. A cache that dropped them would make cross-file rules go quiet on the
2664        // second run — reporting on a cold run and nothing on a warm one is the worst
2665        // possible failure, because it looks like the problem was fixed.
2666        let project = Project::new(
2667            "cache-facts",
2668            &[
2669                ("rule.ts", UNUSED_EXPORTS_RULE),
2670                ("lanekeep.config.ts", &config("")),
2671                (
2672                    "src/a.ts",
2673                    "export function used() {}\nexport function spare() {}\n",
2674                ),
2675                ("src/b.ts", "import { used } from './a';\nused();\n"),
2676            ],
2677        );
2678
2679        let cold = rendered(&project.run().expect("runs"));
2680        assert_eq!(cold.len(), 1, "{cold:?}");
2681        assert_eq!(rendered(&project.run().expect("runs")), cold);
2682        assert_eq!(rendered(&project.run().expect("runs")), cold);
2683    }
2684
2685    #[test]
2686    fn a_cache_file_does_not_churn() {
2687        // Byte-identical across runs over unchanged input. A file that rewrote itself every
2688        // run would be a spurious diff for anyone who commits it.
2689        let project = Project::new(
2690            "cache-stable",
2691            &[
2692                ("rule.ts", DEBUGGER_RULE),
2693                ("lanekeep.config.ts", &config("")),
2694                ("src/a.ts", "debugger;\n"),
2695                ("src/b.ts", "const b = 1;\n"),
2696            ],
2697        );
2698        project.run().expect("runs");
2699        let first = fs::read(Store::path_for(&project.dir)).expect("reads");
2700        project.run().expect("runs");
2701        let second = fs::read(Store::path_for(&project.dir)).expect("reads");
2702        assert_eq!(first, second, "the cache file churned");
2703    }
2704
2705    #[test]
2706    fn entries_for_deleted_files_do_not_accumulate() {
2707        let project = Project::new(
2708            "cache-prune",
2709            &[
2710                ("rule.ts", DEBUGGER_RULE),
2711                ("lanekeep.config.ts", &config("")),
2712                ("src/a.ts", "debugger;\n"),
2713                ("src/b.ts", "debugger;\n"),
2714            ],
2715        );
2716        project.run().expect("runs");
2717        assert_eq!(project.cache().len(), 2);
2718
2719        fs::remove_file(project.dir.join("src/b.ts")).expect("removes");
2720        project.run().expect("runs");
2721        assert_eq!(
2722            project.cache().len(),
2723            1,
2724            "an entry outlived the file it was for"
2725        );
2726    }
2727
2728    #[test]
2729    fn a_partial_run_does_not_discard_other_files_entries() {
2730        // `--staged` saving only what it processed would wipe the cache for every file it
2731        // never looked at, leaving the next full run cold — the opposite of what an
2732        // incremental entry point is for.
2733        let project = Project::new(
2734            "cache-partial",
2735            &[
2736                ("rule.ts", DEBUGGER_RULE),
2737                ("lanekeep.config.ts", &config("")),
2738                ("src/a.ts", "debugger;\n"),
2739                ("src/b.ts", "const b = 1;\n"),
2740                ("src/c.ts", "const c = 1;\n"),
2741            ],
2742        );
2743        project.run().expect("runs");
2744        assert_eq!(project.cache().len(), 3);
2745
2746        let engine = project.build().expect("prepares");
2747        engine
2748            .run_over(&[FilePath::new("src/a.ts")])
2749            .expect("runs over one file");
2750
2751        assert_eq!(
2752            project.cache().len(),
2753            3,
2754            "a partial run discarded entries for files it did not look at"
2755        );
2756    }
2757
2758    #[test]
2759    fn a_full_run_still_prunes() {
2760        // The other half: pruning has to keep working, or entries for deleted files
2761        // accumulate forever.
2762        let project = Project::new(
2763            "cache-prune-still",
2764            &[
2765                ("rule.ts", DEBUGGER_RULE),
2766                ("lanekeep.config.ts", &config("")),
2767                ("src/a.ts", "debugger;\n"),
2768                ("src/b.ts", "const b = 1;\n"),
2769            ],
2770        );
2771        project.run().expect("runs");
2772        assert_eq!(project.cache().len(), 2);
2773
2774        fs::remove_file(project.dir.join("src/b.ts")).expect("removes");
2775        project.run().expect("runs");
2776        assert_eq!(project.cache().len(), 1);
2777    }
2778
2779    // --- suppressions ----------------------------------------------------------------------
2780
2781    impl Project {
2782        /// Run with a fixed date, so an expiry can be asserted without waiting for one.
2783        fn run_on(&self, today: &str) -> Result<Outcome, RunError> {
2784            let date = Date::parse(today).expect("valid date");
2785            self.build().map(|engine| engine.with_today(date))?.run()
2786        }
2787    }
2788
2789    fn messages(outcome: &Outcome) -> Vec<&str> {
2790        outcome
2791            .violations
2792            .iter()
2793            .map(|v| v.message.as_str())
2794            .collect()
2795    }
2796
2797    #[test]
2798    fn a_next_line_directive_silences_the_line_below_it() {
2799        let project = Project::new(
2800            "suppress-next-line",
2801            &[
2802                ("rule.ts", DEBUGGER_RULE),
2803                ("lanekeep.config.ts", &config("")),
2804                (
2805                    "src/a.ts",
2806                    "// lanekeep-ignore-next-line local/no-debugger reason: legacy entry point\n\
2807                     debugger;\n",
2808                ),
2809            ],
2810        );
2811        assert!(
2812            project.run().expect("runs").violations.is_empty(),
2813            "the directive did not silence the violation"
2814        );
2815    }
2816
2817    #[test]
2818    fn a_directive_silences_only_the_line_it_names() {
2819        let project = Project::new(
2820            "suppress-scope",
2821            &[
2822                ("rule.ts", DEBUGGER_RULE),
2823                ("lanekeep.config.ts", &config("")),
2824                (
2825                    "src/a.ts",
2826                    "// lanekeep-ignore-next-line local/no-debugger reason: legacy\n\
2827                     debugger;\n\
2828                     debugger;\n",
2829                ),
2830            ],
2831        );
2832        let outcome = project.run().expect("runs");
2833        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
2834        assert_eq!(outcome.violations[0].location.position.line, 3);
2835    }
2836
2837    #[test]
2838    fn a_file_directive_silences_every_line() {
2839        let project = Project::new(
2840            "suppress-file",
2841            &[
2842                ("rule.ts", DEBUGGER_RULE),
2843                ("lanekeep.config.ts", &config("")),
2844                (
2845                    "src/a.ts",
2846                    "// lanekeep-ignore-file local/no-debugger reason: generated fixture\n\
2847                     debugger;\n\
2848                     debugger;\n",
2849                ),
2850            ],
2851        );
2852        assert!(project.run().expect("runs").violations.is_empty());
2853    }
2854
2855    #[test]
2856    fn a_directive_naming_another_rule_silences_nothing() {
2857        let project = Project::new(
2858            "suppress-other-rule",
2859            &[
2860                ("rule.ts", DEBUGGER_RULE),
2861                ("lanekeep.config.ts", &config("")),
2862                (
2863                    "src/a.ts",
2864                    "// lanekeep-ignore-next-line local/something-else reason: unrelated\n\
2865                     debugger;\n",
2866                ),
2867            ],
2868        );
2869        assert_eq!(project.run().expect("runs").violations.len(), 1);
2870    }
2871
2872    #[test]
2873    fn a_malformed_directive_is_reported() {
2874        // The failure this exists to prevent: a directive that looks like it works, does
2875        // not, and says nothing. Both the missing reason and the violation it failed to
2876        // suppress have to surface.
2877        let project = Project::new(
2878            "suppress-malformed",
2879            &[
2880                ("rule.ts", DEBUGGER_RULE),
2881                ("lanekeep.config.ts", &config("")),
2882                (
2883                    "src/a.ts",
2884                    "// lanekeep-ignore-next-line local/no-debugger\ndebugger;\n",
2885                ),
2886            ],
2887        );
2888
2889        let outcome = project.run().expect("runs");
2890        assert_eq!(outcome.violations.len(), 2, "{:?}", messages(&outcome));
2891        assert!(
2892            messages(&outcome)
2893                .iter()
2894                .any(|m| m.contains("no `reason:`")),
2895            "{:?}",
2896            messages(&outcome)
2897        );
2898        assert!(
2899            outcome
2900                .violations
2901                .iter()
2902                .any(|v| v.rule_id.to_string() == "lanekeep/suppression"),
2903            "reported under the wrong id"
2904        );
2905    }
2906
2907    #[test]
2908    fn an_expired_directive_is_reported_and_still_silences() {
2909        // It expired, which is worth saying — but suddenly reporting everything it covered
2910        // would turn a deadline into an avalanche on the day it passed.
2911        let project = Project::new(
2912            "suppress-expired",
2913            &[
2914                ("rule.ts", DEBUGGER_RULE),
2915                ("lanekeep.config.ts", &config("")),
2916                (
2917                    "src/a.ts",
2918                    "// lanekeep-ignore-next-line local/no-debugger reason: pending rewrite expires: 2026-01-01\n\
2919                     debugger;\n",
2920                ),
2921            ],
2922        );
2923
2924        let outcome = project.run_on("2026-08-01").expect("runs");
2925        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
2926        assert!(
2927            outcome.violations[0]
2928                .message
2929                .contains("expired on 2026-01-01"),
2930            "{:?}",
2931            messages(&outcome)
2932        );
2933        assert!(
2934            outcome.violations[0].message.contains("pending rewrite"),
2935            "the reason should be quoted back: {:?}",
2936            messages(&outcome)
2937        );
2938    }
2939
2940    #[test]
2941    fn a_directive_that_has_not_expired_is_quiet() {
2942        let project = Project::new(
2943            "suppress-unexpired",
2944            &[
2945                ("rule.ts", DEBUGGER_RULE),
2946                ("lanekeep.config.ts", &config("")),
2947                (
2948                    "src/a.ts",
2949                    "// lanekeep-ignore-next-line local/no-debugger reason: pending expires: 2026-12-31\n\
2950                     debugger;\n",
2951                ),
2952            ],
2953        );
2954        assert!(
2955            project
2956                .run_on("2026-08-01")
2957                .expect("runs")
2958                .violations
2959                .is_empty()
2960        );
2961    }
2962
2963    #[test]
2964    fn a_directive_expires_the_day_after_its_date() {
2965        // On the date itself it still holds: an expiry is a deadline, and a deadline of the
2966        // 31st is not missed on the 31st.
2967        let project = Project::new(
2968            "suppress-boundary",
2969            &[
2970                ("rule.ts", DEBUGGER_RULE),
2971                ("lanekeep.config.ts", &config("")),
2972                (
2973                    "src/a.ts",
2974                    "// lanekeep-ignore-file local/no-debugger reason: x expires: 2026-08-01\n\
2975                     debugger;\n",
2976                ),
2977            ],
2978        );
2979        assert!(
2980            project
2981                .run_on("2026-08-01")
2982                .expect("runs")
2983                .violations
2984                .is_empty()
2985        );
2986        assert_eq!(
2987            project.run_on("2026-08-02").expect("runs").violations.len(),
2988            1
2989        );
2990    }
2991
2992    #[test]
2993    fn an_expiring_directive_is_not_served_stale_from_the_cache() {
2994        // The cache-soundness case. A file cached the day before expiry must not keep its
2995        // suppressed result the day after — an expiry that a warm run ignored would never
2996        // expire at all, which is the one thing an expiry exists to prevent.
2997        let project = Project::new(
2998            "suppress-cache-date",
2999            &[
3000                ("rule.ts", DEBUGGER_RULE),
3001                ("lanekeep.config.ts", &config("")),
3002                (
3003                    "src/a.ts",
3004                    "// lanekeep-ignore-file local/no-debugger reason: x expires: 2026-08-01\n\
3005                     debugger;\n",
3006                ),
3007            ],
3008        );
3009
3010        assert!(
3011            project
3012                .run_on("2026-08-01")
3013                .expect("runs")
3014                .violations
3015                .is_empty()
3016        );
3017        let after = project.run_on("2026-08-02").expect("runs");
3018        assert_eq!(
3019            after.violations.len(),
3020            1,
3021            "a warm run served an expired suppression: {:?}",
3022            messages(&after)
3023        );
3024    }
3025
3026    #[test]
3027    fn suppressions_survive_a_warm_run() {
3028        let project = Project::new(
3029            "suppress-warm",
3030            &[
3031                ("rule.ts", DEBUGGER_RULE),
3032                ("lanekeep.config.ts", &config("")),
3033                (
3034                    "src/a.ts",
3035                    "// lanekeep-ignore-file local/no-debugger reason: generated\ndebugger;\n",
3036                ),
3037            ],
3038        );
3039        assert!(project.run().expect("runs").violations.is_empty());
3040        assert!(
3041            project.run().expect("runs").violations.is_empty(),
3042            "the warm run reported what the cold one suppressed"
3043        );
3044    }
3045
3046    #[test]
3047    fn a_cross_file_violation_is_silenced_by_the_directive_where_it_lands() {
3048        // A reduce-phase violation is reported at the site a fact came from, in a file the
3049        // rule was never "checking" — and possibly one that was a cache hit. The directives
3050        // that matter are that file's.
3051        let project = Project::new(
3052            "suppress-cross-file",
3053            &[
3054                ("rule.ts", UNUSED_EXPORTS_RULE),
3055                ("lanekeep.config.ts", &config("")),
3056                (
3057                    "src/a.ts",
3058                    "export function used() {}\n\
3059                     // lanekeep-ignore-next-line local/no-unused-exports reason: public API\n\
3060                     export function spare() {}\n",
3061                ),
3062                ("src/b.ts", "import { used } from './a';\nused();\n"),
3063            ],
3064        );
3065
3066        let outcome = project.run().expect("runs");
3067        assert!(
3068            outcome.violations.is_empty(),
3069            "a cross-file violation ignored the directive at its site: {:?}",
3070            messages(&outcome)
3071        );
3072    }
3073
3074    #[test]
3075    fn a_cross_file_violation_survives_a_directive_for_another_rule() {
3076        let project = Project::new(
3077            "suppress-cross-file-other",
3078            &[
3079                ("rule.ts", UNUSED_EXPORTS_RULE),
3080                ("lanekeep.config.ts", &config("")),
3081                (
3082                    "src/a.ts",
3083                    "export function used() {}\n\
3084                     // lanekeep-ignore-next-line local/unrelated reason: x\n\
3085                     export function spare() {}\n",
3086                ),
3087                ("src/b.ts", "import { used } from './a';\nused();\n"),
3088            ],
3089        );
3090        assert_eq!(project.run().expect("runs").violations.len(), 1);
3091    }
3092
3093    // --- unused suppressions ---------------------------------------------------------------
3094
3095    impl Project {
3096        fn run_reporting_unused(&self) -> Result<Outcome, RunError> {
3097            self.build()
3098                .map(Engine::reporting_unused_suppressions)?
3099                .run()
3100        }
3101    }
3102
3103    #[test]
3104    fn a_suppression_that_silenced_nothing_is_reported() {
3105        let project = Project::new(
3106            "unused-reported",
3107            &[
3108                ("rule.ts", DEBUGGER_RULE),
3109                ("lanekeep.config.ts", &config("")),
3110                (
3111                    "src/a.ts",
3112                    "// lanekeep-ignore-next-line local/no-debugger reason: was needed once\n\
3113                     const a = 1;\n",
3114                ),
3115            ],
3116        );
3117
3118        let outcome = project.run_reporting_unused().expect("runs");
3119        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3120        assert!(
3121            outcome.violations[0].message.contains("silenced nothing"),
3122            "{:?}",
3123            messages(&outcome)
3124        );
3125        assert!(
3126            outcome.violations[0].message.contains("was needed once"),
3127            "the reason should be quoted back: {:?}",
3128            messages(&outcome)
3129        );
3130    }
3131
3132    #[test]
3133    fn a_suppression_that_did_its_job_is_not_reported() {
3134        let project = Project::new(
3135            "unused-used",
3136            &[
3137                ("rule.ts", DEBUGGER_RULE),
3138                ("lanekeep.config.ts", &config("")),
3139                (
3140                    "src/a.ts",
3141                    "// lanekeep-ignore-next-line local/no-debugger reason: legacy\ndebugger;\n",
3142                ),
3143            ],
3144        );
3145        assert!(
3146            project
3147                .run_reporting_unused()
3148                .expect("runs")
3149                .violations
3150                .is_empty()
3151        );
3152    }
3153
3154    #[test]
3155    fn unused_suppressions_are_quiet_without_the_flag() {
3156        // Hygiene, on request. It must not appear in everyone's inner loop.
3157        let project = Project::new(
3158            "unused-off",
3159            &[
3160                ("rule.ts", DEBUGGER_RULE),
3161                ("lanekeep.config.ts", &config("")),
3162                (
3163                    "src/a.ts",
3164                    "// lanekeep-ignore-next-line local/no-debugger reason: stale\nconst a = 1;\n",
3165                ),
3166            ],
3167        );
3168        assert!(project.run().expect("runs").violations.is_empty());
3169    }
3170
3171    #[test]
3172    fn an_unused_suppression_is_a_warning_not_an_error() {
3173        // Turning on a hygiene report must not fail a build that was passing.
3174        let project = Project::new(
3175            "unused-severity",
3176            &[
3177                ("rule.ts", DEBUGGER_RULE),
3178                ("lanekeep.config.ts", &config("")),
3179                (
3180                    "src/a.ts",
3181                    "// lanekeep-ignore-next-line local/no-debugger reason: stale\nconst a = 1;\n",
3182                ),
3183            ],
3184        );
3185        let outcome = project.run_reporting_unused().expect("runs");
3186        assert_eq!(outcome.violations[0].severity, Severity::Warn);
3187        assert!(!lanekeep_core::any_failing(&outcome.violations));
3188    }
3189
3190    #[test]
3191    fn usage_survives_a_warm_run() {
3192        // The case this needed a cache field for: a warm run sees the survivors and not what
3193        // was hidden, so without the recorded usage every suppression in a cached file would
3194        // suddenly look unused.
3195        let project = Project::new(
3196            "unused-warm",
3197            &[
3198                ("rule.ts", DEBUGGER_RULE),
3199                ("lanekeep.config.ts", &config("")),
3200                (
3201                    "src/a.ts",
3202                    "// lanekeep-ignore-next-line local/no-debugger reason: legacy\ndebugger;\n",
3203                ),
3204            ],
3205        );
3206
3207        assert!(
3208            project
3209                .run_reporting_unused()
3210                .expect("runs")
3211                .violations
3212                .is_empty()
3213        );
3214        let warm = project.run_reporting_unused().expect("runs");
3215        assert!(
3216            warm.violations.is_empty(),
3217            "a warm run called a used suppression unused: {:?}",
3218            messages(&warm)
3219        );
3220    }
3221
3222    #[test]
3223    fn a_suppression_used_only_by_a_cross_file_rule_is_not_unused() {
3224        // A directive can be the only thing standing between a reduce-phase violation and
3225        // the report. Counting usage only during the per-file pass would call it unused.
3226        let project = Project::new(
3227            "unused-cross-file",
3228            &[
3229                ("rule.ts", UNUSED_EXPORTS_RULE),
3230                ("lanekeep.config.ts", &config("")),
3231                (
3232                    "src/a.ts",
3233                    "export function used() {}\n\
3234                     // lanekeep-ignore-next-line local/no-unused-exports reason: public API\n\
3235                     export function spare() {}\n",
3236                ),
3237                ("src/b.ts", "import { used } from './a';\nused();\n"),
3238            ],
3239        );
3240
3241        let outcome = project.run_reporting_unused().expect("runs");
3242        assert!(
3243            outcome.violations.is_empty(),
3244            "a directive used by a cross-file rule was called unused: {:?}",
3245            messages(&outcome)
3246        );
3247    }
3248
3249    #[test]
3250    fn a_malformed_directive_is_not_also_reported_as_unused() {
3251        // It already has a violation saying what is wrong with it. A second one saying it
3252        // silenced nothing would be true, unhelpful, and confusing.
3253        let project = Project::new(
3254            "unused-malformed",
3255            &[
3256                ("rule.ts", DEBUGGER_RULE),
3257                ("lanekeep.config.ts", &config("")),
3258                (
3259                    "src/a.ts",
3260                    "// lanekeep-ignore-next-line local/no-debugger\nconst a = 1;\n",
3261                ),
3262            ],
3263        );
3264
3265        let outcome = project.run_reporting_unused().expect("runs");
3266        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3267        assert!(
3268            outcome.violations[0].message.contains("no `reason:`"),
3269            "{:?}",
3270            messages(&outcome)
3271        );
3272    }
3273
3274    // --- ctx.today and the cache -----------------------------------------------------------
3275
3276    /// A rule that reports only when the date it is given starts with a given year.
3277    const DATE_RULE: &str = r"import { defineRule } from 'lanekeep';
3278export default defineRule({
3279  id: 'local/dated',
3280  query: '(export_statement) @stmt',
3281  card: { message: 'dated', remediation: 'x', examples: { bad: 'a', good: 'b' } },
3282  check(ctx, m) {
3283    if (ctx.today.startsWith('2027')) ctx.report(m.stmt, `it is ${ctx.today}`);
3284  },
3285});
3286";
3287
3288    #[test]
3289    fn a_rule_can_read_the_date() {
3290        let project = Project::new(
3291            "today-read",
3292            &[
3293                ("rule.ts", DATE_RULE),
3294                ("lanekeep.config.ts", &config("")),
3295                ("src/a.ts", "export const a = 1;\n"),
3296            ],
3297        );
3298        let outcome = project.run_on("2027-03-04").expect("runs");
3299        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3300        assert!(outcome.violations[0].message.contains("2027-03-04"));
3301    }
3302
3303    #[test]
3304    fn a_result_that_read_the_date_is_not_served_across_days() {
3305        // The cache-soundness case for `ctx.today`. Without tracking the read, the answer
3306        // computed in 2026 would be served in 2027 forever — a date comparison frozen at
3307        // whenever the cache happened to be written.
3308        let project = Project::new(
3309            "today-cache",
3310            &[
3311                ("rule.ts", DATE_RULE),
3312                ("lanekeep.config.ts", &config("")),
3313                ("src/a.ts", "export const a = 1;\n"),
3314            ],
3315        );
3316
3317        assert!(
3318            project
3319                .run_on("2026-12-31")
3320                .expect("runs")
3321                .violations
3322                .is_empty()
3323        );
3324        let later = project.run_on("2027-01-01").expect("runs");
3325        assert_eq!(
3326            later.violations.len(),
3327            1,
3328            "a warm run served a date-dependent result from another day: {:?}",
3329            messages(&later)
3330        );
3331    }
3332
3333    #[test]
3334    fn a_result_that_ignored_the_date_survives_across_days() {
3335        // The other half, and the reason the read is tracked rather than assumed: dating
3336        // every entry would re-key the whole corpus daily.
3337        //
3338        // Asserted on the stored *bytes*, not the entry count. A re-keyed entry replaces the
3339        // one it supersedes, so the count is identical either way — it was the count I
3340        // reached for first, and it proved nothing.
3341        let project = Project::new(
3342            "today-undated",
3343            &[
3344                ("rule.ts", DEBUGGER_RULE),
3345                ("lanekeep.config.ts", &config("")),
3346                ("src/a.ts", "debugger;\n"),
3347            ],
3348        );
3349
3350        project.run_on("2026-12-31").expect("runs");
3351        let before = fs::read(Store::path_for(&project.dir)).expect("reads");
3352
3353        let outcome = project.run_on("2027-01-01").expect("runs");
3354        assert_eq!(outcome.violations.len(), 1);
3355
3356        let after = fs::read(Store::path_for(&project.dir)).expect("reads");
3357        assert_eq!(
3358            before, after,
3359            "a result that never read the date was re-keyed across days"
3360        );
3361    }
3362
3363    #[test]
3364    fn a_result_that_read_the_date_is_re_keyed_across_days() {
3365        // The converse, on the same evidence. Together these pin both directions: dateless
3366        // entries keep their key, dated ones do not.
3367        let project = Project::new(
3368            "today-dated-key",
3369            &[
3370                ("rule.ts", DATE_RULE),
3371                ("lanekeep.config.ts", &config("")),
3372                ("src/a.ts", "export const a = 1;\n"),
3373            ],
3374        );
3375
3376        project.run_on("2026-12-31").expect("runs");
3377        let before = fs::read(Store::path_for(&project.dir)).expect("reads");
3378
3379        project.run_on("2027-01-01").expect("runs");
3380        let after = fs::read(Store::path_for(&project.dir)).expect("reads");
3381        assert_ne!(
3382            before, after,
3383            "a result that read the date kept its key across days"
3384        );
3385    }
3386
3387    #[test]
3388    fn loc_reaches_a_reduce_phase_through_a_fact() {
3389        // The shape `ctx.loc` exists for: emit it on a fact, report at it later, no glue.
3390        const RULE: &str = r"import { defineRule } from 'lanekeep';
3391export default defineRule({
3392  id: 'local/loc-through-facts',
3393  query: '(export_statement) @stmt',
3394  card: { message: 'via loc', remediation: 'x', examples: { bad: 'a', good: 'b' } },
3395  check(ctx, m) { ctx.emitFact({ kind: 'site', at: ctx.loc(m.stmt) }); },
3396  reduce(ctx) {
3397    for (const f of ctx.facts('site')) ctx.report(f.at, 'reported at a remembered place');
3398  },
3399});
3400";
3401        let project = Project::new(
3402            "loc-facts",
3403            &[
3404                ("rule.ts", RULE),
3405                ("lanekeep.config.ts", &config("")),
3406                ("src/a.ts", "const x = 1;\nexport const a = 1;\n"),
3407            ],
3408        );
3409
3410        let outcome = project.run().expect("runs");
3411        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
3412        assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
3413        assert_eq!(outcome.violations[0].location.position.line, 2);
3414    }
3415}