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