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