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, HashMap};
34use std::path::{Path, PathBuf};
35use std::sync::Arc;
36use std::time::Duration;
37
38use lanekeep_cache::{CacheKey, Entry as CacheEntry, GrammarKey, RunKey, Store};
39use lanekeep_config::{ComponentBytes, Config, ConfigError, RuleSpec};
40use lanekeep_core::suppression::{self, Date, Scope, Suppressions};
41use lanekeep_core::{
42    CompiledGates, Discovery, DiscoveryError, Fact, FilePath, Location, Position, RuleId, Severity,
43    TrackedRead, Violation,
44};
45use lanekeep_js::{
46    FileAccess, HOST_API_VERSION, HostContext, Limits, ReduceContext, ReduceFact, RuleRoot,
47    RunClock, Sandbox, SandboxError,
48};
49use lanekeep_lang::{Language, LanguageRegistry};
50use lanekeep_query::{CompileError, CompiledQuery};
51use lanekeep_wasm::bindings::types;
52use lanekeep_wasm::host::{CheckContext, ReduceContext as ComponentReduceContext};
53use lanekeep_wasm::{
54    ComponentLoader, EXTERNAL_BINDINGS, ExternalBinding, Resource, RuleSet, RuleSlot, WasmEngine,
55    WasmError, WasmRuntime,
56};
57use rayon::prelude::*;
58use thiserror::Error;
59
60/// Why a run could not complete.
61///
62/// Every variant aborts the run. A checker that could not finish must not be mistaken for
63/// one that found nothing — see architecture §6.8.
64#[derive(Debug, Clone, PartialEq, Eq, Error)]
65pub enum RunError {
66    /// Discovery could not run.
67    #[error(transparent)]
68    Discovery(#[from] DiscoveryError),
69
70    /// A rule's query does not compile.
71    ///
72    /// Names the language as well as the rule: a rule holds one query per language it
73    /// declares, so a position like `query:3:12` locates a point in one of several sources,
74    /// and only two of the compiler's error kinds name the grammar themselves.
75    #[error("rule `{rule}` has an invalid query for `{language}`\n{detail}")]
76    Query {
77        /// Which rule.
78        rule: String,
79        /// Which language's query, since a rule holds one per language.
80        language: String,
81        /// The rendered compile error.
82        detail: String,
83    },
84
85    /// A rule reached query compilation with no query for one of its languages.
86    ///
87    /// Unreachable through config loading, which enforces the exact cover between a rule's
88    /// languages and its queries — so this is the engine's own bookkeeping failing, named as
89    /// such rather than dressed as a config error under an "invalid query" header.
90    #[error(
91        "rule `{rule}` has no query for language `{language}` — this is an engine bug, not \
92         a config error"
93    )]
94    MissingQuery {
95        /// Which rule.
96        rule: String,
97        /// The language whose query the engine failed to carry.
98        language: String,
99    },
100
101    /// A rule names a language nothing provides.
102    #[error("rule `{rule}` targets unknown language `{language}`\n  known languages: {known}")]
103    UnknownLanguage {
104        /// Which rule.
105        rule: String,
106        /// The language as written.
107        language: String,
108        /// What is available.
109        known: String,
110    },
111
112    /// The WebAssembly runtime could not be described, so a run cannot be keyed against it.
113    ///
114    /// Not about any rule. A cached result is only valid for the compilation environment it
115    /// was produced under, and that environment is read off an engine built from
116    /// `lanekeep_wasm`'s one configuration — so a host where `wasmtime` cannot realize it is a
117    /// host where no result can be filed under a key that means anything. Guessing a value
118    /// instead would put entries under a key describing nothing, which is the one failure
119    /// `docs/architecture.md` §8.1 is arranged against.
120    ///
121    /// **What this costs, stated plainly: a host where `wasmtime` cannot build an engine can
122    /// now run no rules at all — including a ruleset that is entirely TypeScript and needs no
123    /// WebAssembly whatever.** That is a real reduction in reach for no benefit until a
124    /// component actually executes, and it is the price of keying every run against the
125    /// environment rather than only the runs that use it. The alternative is a sentinel for
126    /// "no wasm here", which is a second code path through the cache key whose correctness
127    /// nothing would exercise until the first component arrived. Worth revisiting if this ever
128    /// fires on a real host; nothing has seen it fire, because the configuration is three
129    /// tunables on a supported target.
130    #[error(
131        "the WebAssembly runtime could not be configured on this host\n  {detail}\n  \
132         this is a broken build rather than anything about a rule"
133    )]
134    WasmRuntime {
135        /// What `wasmtime` said.
136        detail: String,
137    },
138
139    /// A rule's gates are malformed.
140    #[error("rule `{rule}` has invalid gates: {detail}")]
141    Gates {
142        /// Which rule.
143        rule: String,
144        /// What is wrong.
145        detail: String,
146    },
147
148    /// A rule's component could not be loaded, or could not be linked against the host world.
149    ///
150    /// Separate from [`RunError::Rule`], which is a rule that ran and failed. This one never
151    /// ran: its bytes are missing, its import list reaches for something the sandbox does not
152    /// grant, or its exports do not satisfy `lanekeep:host`'s `rule` world. All three are
153    /// properties of the artifact rather than of any file, which is why there is no `file`
154    /// field, and all three are found before a file is read.
155    #[error("rule `{rule}` could not load its component\n{detail}")]
156    Component {
157        /// Which rule.
158        rule: String,
159        /// What the component runtime said.
160        detail: String,
161    },
162
163    /// The run's wall-clock budget was spent, noticed between one file and the next.
164    ///
165    /// **The only limit breach that names no rule and no file, because it is about neither.**
166    /// Both engines already report a spent run budget from inside a handler — QuickJS from its
167    /// interrupt handler, wasmtime from an epoch check compiled into guest code — and those
168    /// arrive as [`RunError::Rule`], carrying whichever rule happened to be executing. That is
169    /// the right shape for a breach a rule was at least present for. It is the wrong shape for
170    /// this one: nothing was executing, so there is no culprit to name and naming one would
171    /// send a reader to a rule that is not the problem.
172    ///
173    /// The wording is deliberately the same as both engines', because the user-facing fact is
174    /// the same and which mechanism noticed is lanekeep's business rather than theirs.
175    #[error(
176        "the run exceeded its {budget:?} budget after {elapsed:?}\n  \
177         no single rule necessarily misbehaved — the total simply ran too long\n  \
178         raise it with `--timeout`, or narrow what is being checked"
179    )]
180    RunTimeout {
181        /// The global budget.
182        budget: Duration,
183        /// How long the run had actually been going.
184        elapsed: Duration,
185    },
186
187    /// The sandbox failed, including on a breached budget.
188    #[error("rule `{rule}` failed on `{file}`\n{detail}")]
189    Rule {
190        /// Which rule.
191        rule: String,
192        /// Which file it was running against.
193        file: String,
194        /// The sandbox's account of it.
195        detail: String,
196    },
197
198    /// A worker could not be set up.
199    #[error("could not start a worker: {detail}")]
200    Worker {
201        /// What went wrong.
202        detail: String,
203    },
204}
205
206/// A rule prepared for execution: metadata plus everything compiled.
207///
208/// The query is compiled once per language the rule targets, because a query is compiled
209/// against a grammar and the grammars differ. Which one a given file uses is decided by the
210/// file, not by the rule — see [`Prepared::for_language`].
211struct Prepared {
212    /// This rule's position in [`Engine::rules`].
213    ///
214    /// Carried on the rule rather than paired with it in the admitted list, because that
215    /// list is rebuilt for every file — on a warm run too, before the cache is consulted —
216    /// and widening its elements to a tuple measured about 5 ms over the §15 corpus.
217    index: usize,
218    spec: RuleSpec,
219    gates: CompiledGates,
220    /// Compiled query per language, in the order the rule declared them.
221    compiled: Vec<(Arc<dyn Language>, CompiledQuery)>,
222    /// Where this rule's handlers live in the run's [`RuleSet`], or `None` for a TypeScript
223    /// rule executed through the QuickJS sandbox.
224    ///
225    /// **This is the whole of the dispatch decision**, and it is read off
226    /// [`RuleSpec::component`] rather than derived from anything else, so a rule that names a
227    /// component runs as a component and a rule that does not cannot accidentally become one.
228    /// Both kinds coexist in one run over one corpus, which is what the second path exists for:
229    /// two built-ins are components and the rest are TypeScript, so replacing the first path
230    /// rather than adding beside it would leave most of the ruleset unable to run.
231    slot: Option<RuleSlot>,
232}
233
234impl Prepared {
235    /// The grammar and query to use for a file of the given language, or `None` when this
236    /// rule does not target it — in which case the rule does not run on that file at all.
237    ///
238    /// Running it anyway is what the old behavior did, and it does not fail loudly: the file
239    /// parses into a tree of `ERROR` nodes and every query quietly matches nothing.
240    fn for_language(&self, id: &str) -> Option<&(Arc<dyn Language>, CompiledQuery)> {
241        self.compiled
242            .iter()
243            .find(|(language, _)| language.id().as_str() == id)
244    }
245}
246
247/// The file a rule is about to run against, and the tree every rule on it shares.
248struct FileUnderCheck<'a> {
249    path: &'a FilePath,
250    source: &'a str,
251    tree: &'a tree_sitter::Tree,
252    /// The grammar that parsed it — the file's, never a rule's.
253    ///
254    /// Carried on the file rather than looked up per rule because the component engine needs
255    /// it once per *file*: `lanekeep_wasm::host::CheckContext` is built per file and requires a
256    /// grammar at construction, which is how that crate makes "a context that cannot compile a
257    /// scoped query" an unrepresentable state rather than an answer.
258    language: &'a Arc<dyn Language>,
259}
260
261/// Walk the tree for one component rule alone, through the context's own arena.
262///
263/// The fallback path: no combined query for this language, or `--profile` asked for the
264/// per-rule split. Collected through the *context's* arena rather than a temporary one so a
265/// capture path is taken from the same tree that will later intern it into a handle.
266fn walk_for(host: &CheckContext, query: &CompiledQuery, source: &str) -> RuleMatches {
267    let arena = host.arena();
268    let mut found: RuleMatches = Vec::new();
269    query.for_each_match(arena.tree(), source.as_bytes(), |m| {
270        let captures = m
271            .captures
272            .iter()
273            .filter_map(|(name, node)| arena.path_of(*node).map(|path| ((*name).to_owned(), path)))
274            .collect();
275        found.push(captures);
276    });
277    found
278}
279
280/// What every component rule on one file shares.
281///
282/// The two things that are per *file* rather than per rule, carried together because they are
283/// the same decision: the read memo, and the context the arena and the query cache live in.
284/// Splitting them would let one be built per rule while the other was not, which is the
285/// disagreement the sharing exists to prevent.
286struct ComponentPass<'a> {
287    files: &'a Arc<FileAccess>,
288    /// Opened by the first component rule with a match, and taken back when the file is done.
289    context: &'a mut Option<Resource<CheckContext>>,
290}
291
292/// One match's captures: the capture name, and a structural path to the node it bound.
293///
294/// A path rather than a node because a node borrows its tree, and these outlive the borrow
295/// — see `NodeArena::path_of`. It being structural is also what lets one traversal serve
296/// every rule: the path interns correctly into any arena over the same tree.
297type MatchCaptures = Vec<(String, Vec<u32>)>;
298
299/// Every match one rule found in one file.
300type RuleMatches = Vec<MatchCaptures>;
301
302/// Matches from one traversal, indexed by position in [`Engine::rules`].
303type MatchesByRule = Vec<RuleMatches>;
304
305/// One language's patterns, accumulated across rules before anything is compiled.
306struct Concatenation {
307    language: Arc<dyn Language>,
308    source: String,
309    owners: Vec<usize>,
310}
311
312/// Every rule's query for one language, compiled as a single multi-pattern query.
313///
314/// Twenty rules used to mean twenty `QueryCursor` walks of the same tree. tree-sitter is
315/// built to evaluate many patterns in one traversal — that is what a `highlights.scm` is —
316/// and doing it that way measured 20× faster over the §15 corpus at identical capture
317/// counts. It is the single biggest cost left in a cold run.
318///
319/// Correctness rests on two facts. `pattern_index` says which pattern produced a match, so
320/// matches can be handed back to the rule that asked for them. And a capture path is a walk
321/// of child indices from the root — see `NodeArena::path_of` — so it is a property of the
322/// tree's *shape*, not of any one arena, and a path collected here interns correctly into
323/// every rule's own arena afterwards.
324struct CombinedQuery {
325    language: Arc<dyn Language>,
326    source: String,
327    /// `owners[pattern_index]` is the index into [`Engine::rules`] that contributed it.
328    ///
329    /// A rule's query source may hold several patterns, so this is not one entry per rule.
330    owners: Vec<usize>,
331    /// Compiled on first use, and never on a run that has no use for it.
332    ///
333    /// Compiling eagerly cost a warm run 26 ms — every file was a cache hit, no query ran,
334    /// and the whole compilation was thrown away. Warm is the scenario in the inner loop
335    /// and the one with the tightest budget, so paying for cold there is the wrong trade.
336    compiled: std::sync::OnceLock<Option<CompiledQuery>>,
337}
338
339impl CombinedQuery {
340    /// The compiled query, or `None` if the concatenation will not serve.
341    ///
342    /// `None` sends the file down the per-rule path: slower, never wrong. Every part was
343    /// already compiled individually at preparation, which is where a broken query is
344    /// reported against the rule that owns it, so this only catches a concatenation
345    /// rejected for a reason no single pattern was.
346    fn query(&self) -> Option<&CompiledQuery> {
347        self.compiled
348            .get_or_init(|| {
349                CompiledQuery::compile(self.language.as_ref(), &self.source)
350                    .ok()
351                    // Only sound if tree-sitter numbered the patterns the way the
352                    // concatenation did. It always has; checking turns a silent
353                    // misattribution — one rule's matches handed to another — into a
354                    // fallback.
355                    .filter(|query| query.pattern_count() == self.owners.len())
356            })
357            .as_ref()
358    }
359}
360
361/// Build one multi-pattern query per language, over every rule that declares it.
362///
363/// Rules are visited in `rules` order and their patterns appended in that order, so
364/// `owners` is built alongside the source it describes and the two cannot drift. Each
365/// language's combined query concatenates that language's own query per rule, selected from
366/// the rule's per-language map.
367///
368/// Nothing is compiled here — see [`CombinedQuery::query`], which does it on first use so a
369/// warm run never pays for a query it will not run.
370fn combine_queries(rules: &[Prepared]) -> BTreeMap<String, CombinedQuery> {
371    let mut sources: BTreeMap<String, Concatenation> = BTreeMap::new();
372
373    for (index, rule) in rules.iter().enumerate() {
374        for (language, query) in &rule.compiled {
375            let entry = sources
376                .entry(language.id().as_str().to_owned())
377                .or_insert_with(|| Concatenation {
378                    language: Arc::clone(language),
379                    source: String::new(),
380                    owners: Vec::new(),
381                });
382            entry
383                .source
384                .push_str(&rule.spec.queries[language.id().as_str()]);
385            // A query source need not end in a newline, and two patterns run together on
386            // one line is a different query from the two of them.
387            entry.source.push('\n');
388            entry
389                .owners
390                .extend(std::iter::repeat_n(index, query.pattern_count()));
391        }
392    }
393
394    sources
395        .into_iter()
396        .map(|(id, parts)| {
397            (
398                id,
399                CombinedQuery {
400                    language: parts.language,
401                    source: parts.source,
402                    owners: parts.owners,
403                    compiled: std::sync::OnceLock::new(),
404                },
405            )
406        })
407        .collect()
408}
409
410/// Everything a run needs, built once and shared across workers.
411#[expect(
412    clippy::struct_excessive_bools,
413    reason = "four independent run modes — caching, reducing, unused reporting, profiling — \
414              every combination of which is meaningful and reachable from the CLI. The lint \
415              is aimed at a type where a pile of bools stands in for a missing enum; these \
416              are orthogonal switches, and an enum over their sixteen combinations would be \
417              strictly worse to read and to set."
418)]
419pub struct Engine {
420    rules: Vec<Prepared>,
421    /// One multi-pattern query per language, over every rule that declares it.
422    ///
423    /// Empty for a language no rule targets, and not built at all until a file needs it.
424    /// Assembling the sources measured ~4 ms over the §15 ruleset, which a warm run — every
425    /// file a cache hit, no query run — would have paid for nothing. Warm has the tightest
426    /// budget of the three scenarios, so it does not subsidize cold.
427    combined: std::sync::OnceLock<BTreeMap<String, CombinedQuery>>,
428    discovery: Discovery,
429    /// The project root, canonicalized once. Every tracked read is checked against it, and
430    /// canonicalizing per file would put a syscall on the hot path for a constant.
431    root: PathBuf,
432    /// Everything constant about this run that a cache key depends on.
433    run_key: RunKey,
434    /// The component engine and the run's linked rule set, or `None` when no rule is backed
435    /// by a component.
436    ///
437    /// `None` is no longer the common case in this tree — two built-ins are components, so any
438    /// config naming one is `Some` — and it is not merely an empty set: building one starts an
439    /// epoch ticker thread, so a run with no component rule must not build one at all.
440    components: Option<Components>,
441    /// Whether results may be read from and written to the cache.
442    ///
443    /// **On exactly when every rule's component bytes reached `ruleset_hash`, off when any one
444    /// of them did not** — a correctness condition rather than a policy, read per rule off
445    /// `ComponentRule::counted_in_ruleset_hash` rather than off whether this run has a
446    /// component at all. Vacuously on for a run with no component, which is now only a run whose
447    /// config names neither of the two built-ins that ship as one.
448    ///
449    /// **Why bytes have to reach the key at all.** A component's bytes are the code that
450    /// decides a rule's answer, exactly as a TypeScript module's source is, so a cache key that
451    /// does not depend on them cannot tell two different rules apart. Serving a warm answer
452    /// under a key like that would swap a rule's component for a different one between two runs
453    /// and go on reporting the first one's answer forever — demonstrated by
454    /// `swapping_a_component_between_runs_changes_the_answer`, which still fails without this.
455    ///
456    /// **This used to be `components.is_none()` — off for *any* run that loaded a component —
457    /// because there was no rule for which the condition above could hold.** `RuleSpec::component`
458    /// was set on a `Config` *after* `lanekeep_config::load` had already computed `ruleset_hash`,
459    /// and `load_components` then read the `.wasm` file itself, untracked: a component's bytes
460    /// could reach no cache-key input, for any component, ever. `lanekeep-config` now resolves a
461    /// `.wasm` reference itself, reads its bytes once and folds *those* into `ruleset_hash`
462    /// before any `Config` exists, and `load_components` loads the component from the bytes the
463    /// rule carries rather than reading the path again — see
464    /// `a_run_executes_the_bytes_its_rule_carries_and_not_the_path_beside_them`. So the blanket
465    /// refusal became too conservative for every rule that path produces, which
466    /// `a_component_backed_run_writes_and_reuses_its_cache` (`lanekeep-cli`) now holds against a
467    /// real `lanekeep.json` end to end.
468    ///
469    /// **What is still refused, and how this tells the two apart.** A `RuleSpec` an embedder or
470    /// a test attaches to a `Config` *after* `load` returns — which is what every hand-built spec
471    /// in the `components` tests below does — carries bytes that reached no hash, because there
472    /// was no configuration that named them at the time `ruleset_hash` was computed. Nothing
473    /// about the resulting `RuleSpec` looks different from a configured one's; the only way to
474    /// tell them apart is to ask where the `ComponentRule` came from, which is exactly what
475    /// `counted_in_ruleset_hash` answers — `true` for the one constructor `lanekeep-config` uses
476    /// while building a `Config`, `false` for `ComponentRule::uncounted`, the only other way to
477    /// produce one. `a_run_with_a_component_rule_does_not_touch_the_cache` asserts `caching`
478    /// itself directly for a hand-built spec and still must find it `false`.
479    ///
480    /// **Refusing the cache rather than folding the bytes here**, still, for two reasons that
481    /// both held before this could tell rules apart and hold just as well now. The correct fold
482    /// already exists in `lanekeep-config`'s `hash_ruleset` — sorted and deduplicated by path,
483    /// hashed by length-prefixed bytes — and a second implementation of a cache-key encoding in
484    /// a second crate is exactly the drift that produced this sub-project's one real cache bug,
485    /// where reusing a text separator for arbitrary binary let two rulesets share a key.
486    /// Trusting a flag `lanekeep-config` already computed sidesteps that; recomputing or
487    /// re-verifying the hash here would not. And a guard that turns the cache *off* has no
488    /// encoding to get wrong: the failure mode of getting this flag wrong is a cold run, where
489    /// the failure mode of a wrong fold is a wrong answer served with confidence.
490    ///
491    /// It is per run rather than per rule because a cache entry is per *file* and holds every
492    /// rule's findings for it, so there is no finer granularity that is sound: one hand-built
493    /// component anywhere in the ruleset takes caching off for the whole run, even for a file no
494    /// such rule targets.
495    caching: bool,
496    /// Whether reduce phases run.
497    reducing: bool,
498    /// Whether directives that silenced nothing are reported.
499    reporting_unused: bool,
500    /// Whether per-rule timings are collected.
501    profiling: bool,
502    /// The date `expires:` is compared against.
503    ///
504    /// Fixed once for the run, so two files checked a millisecond apart cannot disagree
505    /// about what day it is. Supplied by the host because the sandbox has no clock.
506    today: Date,
507    /// The project's policy for which shapes of valid directive it accepts.
508    ///
509    /// Enforced in [`Self::directive_violations`] — the same post-cache stage that reports
510    /// malformed and expired directives. Two consequences are load-bearing. The violations
511    /// are emitted *after* the pass that applies directives, which is what makes
512    /// `lanekeep/suppression` unsuppressible. And the cached entry already carries them, so a
513    /// warm run reports them identically with no new key input: `maxExpiryDays` compares
514    /// against `today`, and a file whose bytes contain `expires:` already gets a one-day
515    /// dated key, while `requireExpiry` and `forbidFileScope` are date-independent and cache
516    /// under the plain key.
517    suppression_policy: lanekeep_config::SuppressionPolicy,
518    limits: Limits,
519    rules_root: RuleRoot,
520    config_path: PathBuf,
521    typescript: Arc<dyn Language>,
522    javascript: Arc<dyn Language>,
523    /// Extension to language id, so a file can be matched to a grammar without the registry.
524    ///
525    /// Lowercased keys, because the registry lowercases too — whether `Button.TSX` gets
526    /// checked should not depend on how someone typed it.
527    languages_by_extension: BTreeMap<String, String>,
528}
529
530/// The component half of a run, walled off so its one constructor cannot be gone around.
531///
532/// **A module for two fields, and the module is the point.** `EXTERNAL_BINDINGS` enforcement
533/// used to be a statement in `load_components`; deleting it left every test passing, because
534/// nothing asserted the comparison was *invoked*. Moving it into a constructor fixed that and
535/// left a second way to be wrong — a struct literal beside the constructor, which a mutation
536/// confirmed still compiled and still passed. Private fields behind a module seam remove that
537/// too, on exactly the reasoning `lanekeep_wasm::load::Loaded` uses for the import check: the
538/// door that skips the check is the one that does not exist.
539mod components {
540    use std::sync::Arc;
541
542    use lanekeep_wasm::{RuleSet, WasmEngine};
543
544    use super::{RunError, declared_bindings_match};
545
546    /// The component engine and the run's linked rule set.
547    ///
548    /// Two `Arc`s and nothing else, which is the arrangement `lanekeep-wasm` requires rather
549    /// than a convenience: one [`WasmEngine`] because it is the unit compiled code is cached in
550    /// and the owner of the one epoch ticker, and one [`RuleSet`] because `instantiate_pre`
551    /// resolves and type-checks a component's imports independently of how many stores will
552    /// instantiate it. Nothing here is instantiated — an instance belongs to a store, and a
553    /// store belongs to a worker.
554    pub(super) struct Components {
555        engine: Arc<WasmEngine>,
556        rules: Arc<RuleSet>,
557    }
558
559    impl Components {
560        /// The only way to make one, and it is the only way because of what it checks.
561        ///
562        /// # Errors
563        ///
564        /// Returns [`RunError::Worker`] when the set bound an interface beside the declared
565        /// world that `lanekeep_wasm::EXTERNAL_BINDINGS` — the list the cache key was computed
566        /// from — does not name.
567        pub(super) fn linked(engine: Arc<WasmEngine>, rules: RuleSet) -> Result<Self, RunError> {
568            declared_bindings_match(rules.external_bindings())?;
569            Ok(Self {
570                engine,
571                rules: Arc::new(rules),
572            })
573        }
574
575        /// The shared engine, for building a worker's store.
576        pub(super) const fn engine(&self) -> &Arc<WasmEngine> {
577            &self.engine
578        }
579
580        /// The run's linked rule set.
581        pub(super) const fn rules(&self) -> &Arc<RuleSet> {
582            &self.rules
583        }
584    }
585}
586
587use components::Components;
588
589impl std::fmt::Debug for Engine {
590    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
591        f.debug_struct("Engine")
592            .field("rules", &self.rules.len())
593            .field("root", &self.discovery.root())
594            .finish_non_exhaustive()
595    }
596}
597
598/// Where a run spent its time, per rule.
599///
600/// The split is the point. A rule that is slow in `query` has a query matching more than it
601/// needs and wants narrowing; a rule that is slow in `handler` has code to look at. Reporting
602/// one total would leave an author guessing which, and the two have nothing in common as
603/// fixes.
604#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
605pub struct RuleTiming {
606    /// Time matching this rule's query, in Rust.
607    pub query: Duration,
608    /// Time inside its handler, in the sandbox.
609    pub handler: Duration,
610    /// How many matches crossed the boundary.
611    ///
612    /// The number the query gate exists to keep small — §7.2 — so it belongs beside the
613    /// times rather than being inferred from them.
614    pub matches: u64,
615}
616
617impl RuleTiming {
618    /// Everything this rule cost.
619    #[must_use]
620    pub const fn total(&self) -> Duration {
621        self.query.saturating_add(self.handler)
622    }
623}
624
625/// What a run produced.
626#[derive(Debug, Clone, PartialEq, Eq, Default)]
627pub struct Outcome {
628    /// Violations, in canonical order.
629    pub violations: Vec<Violation>,
630    /// How many files discovery selected.
631    pub files_discovered: usize,
632    /// How many were actually parsed, after gates.
633    pub files_parsed: usize,
634
635    /// Where the run spent its time, per rule, when `--profile` asked.
636    ///
637    /// Absent otherwise: timing every match costs a clock read per invocation, which is
638    /// exactly the kind of thing that should not be on the path a warm run takes.
639    pub timings: Option<BTreeMap<RuleId, RuleTiming>>,
640
641    /// What each checked file's rules read beyond that file, in path order.
642    ///
643    /// Exactly the shape a cache entry needs: dependencies belong to the file whose result
644    /// they affect, not to the run. A file with no tracked reads has no entry here rather
645    /// than an empty one, so the common case costs nothing.
646    pub dependencies: BTreeMap<FilePath, Vec<TrackedRead>>,
647}
648
649impl Engine {
650    /// Prepare a run.
651    ///
652    /// Everything that can fail on a rule's own contents fails here, before any file is
653    /// read — a run that dies halfway through because rule seventeen has a typo has
654    /// already wasted the work.
655    ///
656    /// # Errors
657    ///
658    /// Returns [`RunError`] for an invalid query, gate, or language reference.
659    #[expect(
660        clippy::too_many_lines,
661        reason = "the per-language query compile loop belongs here — a broken query surfaces \
662                  at prepare time, naming its rule — and extracting it would move that \
663                  diagnostic away from the place it is reported"
664    )]
665    pub fn prepare(
666        config: &Config,
667        project_root: &Path,
668        rules_root: RuleRoot,
669        config_path: &Path,
670        registry: &LanguageRegistry,
671        typescript: Arc<dyn Language>,
672        javascript: Arc<dyn Language>,
673    ) -> Result<Self, RunError> {
674        let discovery = Discovery::new(project_root, &config.include, &config.exclude)?;
675
676        let known = registry
677            .languages()
678            .map(|l| l.id().as_str())
679            .collect::<Vec<_>>()
680            .join(", ");
681
682        let mut languages_by_extension = BTreeMap::new();
683        for language in registry.languages() {
684            for extension in language.extensions() {
685                languages_by_extension
686                    .insert(extension.to_ascii_lowercase(), language.id().to_string());
687            }
688        }
689
690        // Compiled in parallel, because this is the single most expensive thing a run does
691        // before it has looked at a file: a tree-sitter query costs a couple of milliseconds
692        // to compile, and a rule compiles one per language it declares. Twenty rules over two
693        // languages is forty compilations and most of a warm run's wall clock — measured at
694        // ~88 ms against a ~55 ms warm run, so construction cost more than the work.
695        //
696        // Every compilation is independent, so this is parallelism with no shared state and
697        // no ordering to preserve *during* it. What must stay ordered is the result: `rules`
698        // is indexed by the config's rule order, and a run's violations are sorted by rule id,
699        // so a shuffled `rules` would be a different program. `collect` into a `Vec<Result<_>>`
700        // preserves input order regardless of completion order, which is what makes this safe.
701        //
702        // Deliberately *not* made lazy. Compiling on first use would take a warm run's cost to
703        // nearly zero, and would cost the guarantee the comment below describes: a broken query
704        // is reported here, naming its rule, rather than staying silent until some file happens
705        // to need it.
706        let prepared: Vec<Result<Prepared, RunError>> =
707            config
708                .rules
709                .par_iter()
710                .filter(|spec| spec.severity.is_enabled())
711                .map(|spec| {
712                    let mut compiled = Vec::with_capacity(spec.languages.len());
713                    for id in &spec.languages {
714                        let language = registry.by_id(id).cloned().ok_or_else(|| {
715                            RunError::UnknownLanguage {
716                                rule: spec.id.to_string(),
717                                language: id.clone(),
718                                known: known.clone(),
719                            }
720                        })?;
721
722                        // Compiled against this grammar specifically, from this language's own
723                        // query string selected from the per-language map. A query that is valid
724                        // for one dialect and not another is a rule bug, and this is where it
725                        // surfaces — at config load, naming the rule, rather than as silence at
726                        // run time.
727                        // The exact cover was validated at config load; a missing entry here
728                        // is a bug in the engine's own bookkeeping, named rather than
729                        // silently compiled against nothing.
730                        let source = spec.queries.get(id.as_str()).ok_or_else(|| {
731                            RunError::MissingQuery {
732                                rule: spec.id.to_string(),
733                                language: id.clone(),
734                            }
735                        })?;
736                        let query = CompiledQuery::compile(language.as_ref(), source).map_err(
737                            |e: CompileError| RunError::Query {
738                                rule: spec.id.to_string(),
739                                language: id.clone(),
740                                detail: e.to_string(),
741                            },
742                        )?;
743
744                        compiled.push((language, query));
745                    }
746
747                    let gates =
748                        CompiledGates::compile(&spec.gates).map_err(|e| RunError::Gates {
749                            rule: spec.id.to_string(),
750                            detail: e.to_string(),
751                        })?;
752
753                    Ok(Prepared {
754                        // Filled in below, once config order is known.
755                        index: 0,
756                        spec: spec.clone(),
757                        gates,
758                        compiled,
759                        // Filled in below too, by the one place components are loaded.
760                        slot: None,
761                    })
762                })
763                .collect();
764
765        // The first failure by *config order*, not by whichever thread finished first. Two
766        // broken rules must always name the same one, or the same project reports a different
767        // error between runs.
768        let mut rules = Vec::with_capacity(prepared.len());
769        for result in prepared {
770            rules.push(result?);
771        }
772        for (index, rule) in rules.iter_mut().enumerate() {
773            rule.index = index;
774        }
775
776        // Every component this run will execute, compiled, import-checked and linked against
777        // the host world — once, here, before any worker exists. A rule whose bytes are
778        // missing or whose imports reach past the sandbox fails now, naming itself, rather
779        // than on whichever file happened to match it first.
780        //
781        // Writes precompiled artifacts under the project's own `.lanekeep/components`, and falls
782        // back to compiling in-process when that is not writable. Compiling twenty components costs
783        // about 186 ms against about 0.74 ms to map twenty precompiled ones, which is 23% of the
784        // whole cold budget spent before a file is read.
785        let loader = ComponentLoader::for_project_root(project_root);
786        let components = load_components(&mut rules, &loader)?;
787
788        // Every registered grammar, so a tree-sitter bump invalidates rather than silently
789        // reusing results computed against different node shapes.
790        let mut grammars: Vec<GrammarKey> = registry
791            .languages()
792            .map(|language| GrammarKey {
793                id: language.id().to_string(),
794                abi: u32::try_from(language.grammar_abi()).unwrap_or(u32::MAX),
795            })
796            .collect();
797        grammars.sort_by(|a, b| a.id.cmp(&b.id));
798
799        let run_key = run_key(&config.ruleset_hash, &config.config_hash, &grammars)?;
800
801        // On unless some rule's component carries bytes `ruleset_hash` never saw — see the
802        // field. Vacuously true when there is no component at all, which keeps a TypeScript-only
803        // run caching exactly as it always did. Read from `rules` rather than from `components`,
804        // because the question is "did every component's bytes reach the key", and answering it
805        // needs each rule's own `ComponentRule`, not merely whether the run has one.
806        let caching = rules
807            .iter()
808            .filter_map(|rule| rule.spec.component.as_ref())
809            .all(lanekeep_config::ComponentRule::counted_in_ruleset_hash);
810
811        Ok(Self {
812            rules,
813            combined: std::sync::OnceLock::new(),
814            run_key,
815            caching,
816            components,
817            reducing: true,
818            reporting_unused: false,
819            profiling: false,
820            today: suppression::today(),
821            // Canonicalized here so every tracked read compares against the same absolute
822            // root. Falling back to the path as given keeps a non-existent root a discovery
823            // problem rather than turning it into a confusing read failure later.
824            root: project_root
825                .canonicalize()
826                .unwrap_or_else(|_| project_root.to_path_buf()),
827            discovery,
828            limits: config.limits,
829            suppression_policy: config.suppressions,
830            rules_root,
831            config_path: config_path.to_path_buf(),
832            typescript,
833            javascript,
834            languages_by_extension,
835        })
836    }
837
838    /// Which language parses this file, or `None` when nothing registered claims it.
839    fn language_of(&self, path: &FilePath) -> Option<&str> {
840        let extension = Path::new(path.as_str())
841            .extension()?
842            .to_str()?
843            .to_ascii_lowercase();
844        self.languages_by_extension
845            .get(extension.as_str())
846            .map(String::as_str)
847    }
848
849    /// Turn the cache off, for `--no-cache` and for tests that need a cold run.
850    #[must_use]
851    pub const fn without_cache(mut self) -> Self {
852        self.caching = false;
853        self
854    }
855
856    /// Collect per-rule timings.
857    ///
858    /// Off by default because measuring costs a clock read per handler invocation, and the
859    /// path a warm run takes is the one place that matters most.
860    #[must_use]
861    pub const fn profiling(mut self) -> Self {
862        self.profiling = true;
863        self
864    }
865
866    /// Report suppressions that silenced nothing.
867    ///
868    /// Off by default because it is hygiene rather than correctness: a suppression whose
869    /// violation no longer exists is debt, and debt is worth surfacing on request rather
870    /// than in everyone's inner loop.
871    #[must_use]
872    pub const fn reporting_unused_suppressions(mut self) -> Self {
873        self.reporting_unused = true;
874        self
875    }
876
877    /// Fix the date `expires:` is compared against.
878    ///
879    /// For tests, which otherwise could not assert anything about expiry without waiting.
880    #[must_use]
881    pub const fn with_today(mut self, today: Date) -> Self {
882        self.today = today;
883        self
884    }
885
886    /// Skip every reduce phase.
887    ///
888    /// For a run over a deliberately partial corpus. A cross-file rule consumes facts from
889    /// every file, so running one over a subset does not give a smaller answer — it gives a
890    /// wrong one. `no-unused-exports` over three changed files would report every export in
891    /// them as unused, because the importers were never looked at.
892    ///
893    /// Skipping is therefore the only sound option, and the caller that narrowed the corpus
894    /// is the one that has to say so to the user.
895    #[must_use]
896    pub const fn without_reduce(mut self) -> Self {
897        self.reducing = false;
898        self
899    }
900
901    /// The files discovery selects, before any gate.
902    ///
903    /// For a caller narrowing the corpus: intersecting with this is what keeps `include` and
904    /// `exclude` in force, so `--staged` cannot check a file the config excluded.
905    #[must_use]
906    pub fn discover(&self) -> Vec<FilePath> {
907        self.discovery.walk()
908    }
909
910    /// How many rules will actually run. Rules set to `off` are dropped at preparation.
911    #[must_use]
912    pub fn rule_count(&self) -> usize {
913        self.rules.len()
914    }
915
916    /// The rules that will run, in the order the config declared them.
917    ///
918    /// The specs rather than a rendered listing: what a listing should look like is the
919    /// reporter's problem, and an engine that decided it would have to be changed for every
920    /// new output format.
921    pub fn rules(&self) -> impl Iterator<Item = &RuleSpec> {
922        self.rules.iter().map(|prepared| &prepared.spec)
923    }
924
925    /// Run over the whole corpus.
926    ///
927    /// # Errors
928    ///
929    /// Returns the first [`RunError`] any worker produced. Rayon's reduction is not
930    /// order-dependent, so which of several simultaneous failures surfaces is arbitrary —
931    /// but every one of them aborts the run, so the choice does not change the outcome.
932    pub fn run(&self) -> Result<Outcome, RunError> {
933        let files = self.discovery.walk();
934        self.run_files(&files, Coverage::Whole)
935    }
936
937    /// Run over an explicit file list, for `--since` and `--staged`.
938    ///
939    /// # Errors
940    ///
941    /// As [`Engine::run`].
942    pub fn run_over(&self, files: &[FilePath]) -> Result<Outcome, RunError> {
943        self.run_files(files, Coverage::Partial)
944    }
945
946    /// The shared body of [`Engine::run`] and [`Engine::run_over`].
947    fn run_files(&self, files: &[FilePath], coverage: Coverage) -> Result<Outcome, RunError> {
948        let clock = RunClock::start(self.limits.global_timeout);
949
950        // Loaded once, before any worker starts. Shared read-only across the pool: a cache
951        // that workers wrote to concurrently would need a lock on the hot path, and the
952        // whole point is to be faster than recomputing.
953        let cache = if self.caching {
954            Store::load(&self.root)
955        } else {
956            Store::empty()
957        };
958
959        let results: Vec<Result<FileOutcome, RunError>> = files
960            .par_iter()
961            .map_init(
962                // One sandbox per worker, created on first use and reused for that
963                // worker's whole share. Building one per file would pay engine startup
964                // thousands of times; sharing one across workers is impossible, since the
965                // runtime is single-threaded by construction.
966                // The sandbox is per worker and built on first use — one engine startup
967                // per thread that needs one, rather than per file, and none at all for a
968                // worker whose files all hit the cache. That last part is what makes a warm
969                // run cheap: starting QuickJS and evaluating every rule module, per worker,
970                // to then execute no JavaScript, was most of a warm run's cost.
971                || Worker::new(self, &clock),
972                |worker, path| self.check_file(worker, &cache, path),
973            )
974            .collect();
975
976        let mut violations = Vec::new();
977        let mut facts = Vec::new();
978        let mut files_parsed = 0;
979        let mut dependencies = BTreeMap::new();
980        let mut fresh = Store::empty();
981        let mut directives: BTreeMap<FilePath, FileDirectives> = BTreeMap::new();
982        let mut timings: BTreeMap<RuleId, RuleTiming> = BTreeMap::new();
983        // The first failure by *file order*, kept rather than returned, because the entries
984        // every other file produced are still owed to the cache — see the save below. Which
985        // failure is reported does not change: it is the same one `?` would have taken, since
986        // rayon's `collect` preserves input order.
987        let mut failure: Option<RunError> = None;
988        for result in results {
989            let outcome = match result {
990                Ok(outcome) => outcome,
991                Err(error) => {
992                    failure.get_or_insert(error);
993                    continue;
994                }
995            };
996            violations.extend(outcome.violations);
997            facts.extend(outcome.facts);
998            files_parsed += usize::from(outcome.parsed);
999            if let Some(entry) = outcome.entry {
1000                fresh.insert(entry.0, entry.1);
1001            }
1002            for (rule, timing) in outcome.timings {
1003                let entry = timings.entry(rule).or_default();
1004                entry.query = entry.query.saturating_add(timing.query);
1005                entry.handler = entry.handler.saturating_add(timing.handler);
1006                entry.matches += timing.matches;
1007            }
1008            if !outcome.suppressions.is_empty() {
1009                directives.insert(
1010                    outcome.path.clone(),
1011                    FileDirectives {
1012                        suppressions: outcome.suppressions,
1013                        used: outcome.used_suppressions,
1014                    },
1015                );
1016            }
1017            if !outcome.reads.is_empty() {
1018                dependencies.insert(outcome.path, outcome.reads);
1019            }
1020        }
1021
1022        // Saved before the failure is propagated, and merged rather than pruned when there is
1023        // one.
1024        //
1025        // §6.8: a limit breach cancels the run, and **cache entries for files that fully
1026        // completed are still committed**. Each is independently valid — it records every rule
1027        // running against those bytes to completion — and dropping them means a corpus that
1028        // dies on a cold run dies identically on every retry, with no way to make progress.
1029        // That was latent while nothing enforced the run budget outside a handler, because a
1030        // corpus of cheap invocations simply finished; the check at the top of `check_file` is
1031        // what turns it into the ordinary case.
1032        //
1033        // Pruning is the part that must not happen. A run that stopped early holds entries for
1034        // a fraction of the corpus and never looked at the rest, so a fresh-only save would age
1035        // out every file it never reached and leave the next run *colder* than the one that
1036        // failed. That is the same reasoning `Coverage::Partial` already carries, arrived at
1037        // from the other direction: pruning is sound only for a run that saw everything, and an
1038        // aborted run did not.
1039        if self.caching {
1040            match coverage {
1041                // The run saw everything, so what it did not produce an entry for no longer
1042                // exists. Saving only fresh entries is what ages deleted files out.
1043                Coverage::Whole if failure.is_none() => fresh.save(&self.root),
1044                // The run saw a subset — because it was given one, or because it stopped part
1045                // way through. Saving only what it produced would discard the entries for
1046                // every file it never looked at, so `--staged` would leave the next full run
1047                // cold, which is the opposite of what an incremental entry point is for.
1048                Coverage::Whole | Coverage::Partial => {
1049                    let mut merged = cache;
1050                    for key in fresh.keys().copied().collect::<Vec<_>>() {
1051                        if let Some(entry) = fresh.get(&key) {
1052                            merged.insert(key, entry.clone());
1053                        }
1054                    }
1055                    merged.save(&self.root);
1056                }
1057            }
1058        }
1059
1060        if let Some(error) = failure {
1061            return Err(error);
1062        }
1063
1064        // Into the one order every run will see, before any rule looks at them.
1065        //
1066        // Rayon's `collect` into a `Vec` already preserves input order, so on today's code
1067        // path this sort changes nothing — which is exactly why it is easy to delete and
1068        // must not be. The ordering guarantee belongs to the engine, not to a property of
1069        // whichever collection strategy it happens to use: switching to `for_each` with a
1070        // shared sink, or grouping by rule before reducing, would silently lose it. The
1071        // cost is one sort of a small vector, once per run.
1072        lanekeep_core::fact::sort(&mut facts);
1073
1074        // A cross-file rule reports at a site in some other file, which may well have been a
1075        // cache hit this run — so its directives come from the outcome, whether they were
1076        // parsed now or restored from the entry.
1077        let reduced = self.reduce(&clock, files, &facts)?;
1078        for violation in reduced {
1079            // A cross-file violation can be the only thing a directive ever silences, so
1080            // usage is recorded here too — otherwise it would be reported as unused.
1081            match covering_elsewhere(&directives, &violation) {
1082                Some((file, index)) => {
1083                    if let Some(found) = directives.get_mut(&file)
1084                        && !found.used.contains(&index)
1085                    {
1086                        found.used.push(index);
1087                    }
1088                }
1089                None => violations.push(violation),
1090            }
1091        }
1092
1093        if self.reporting_unused {
1094            violations.extend(unused_violations(&directives));
1095        }
1096
1097        lanekeep_core::sort(&mut violations);
1098        Ok(Outcome {
1099            violations,
1100            files_discovered: files.len(),
1101            files_parsed,
1102            timings: self.profiling.then_some(timings),
1103            dependencies,
1104        })
1105    }
1106
1107    /// Run the reduce phase for every rule that has one.
1108    ///
1109    /// Single-threaded, and deliberately so: there is one pass per rule, each already sees
1110    /// the whole corpus, and a rule's `reduce` is the one place a rule is allowed to be
1111    /// expensive. Parallelizing across rules would buy little and would need one sandbox per
1112    /// worker with the whole fact set copied into each.
1113    fn reduce(
1114        &self,
1115        clock: &Arc<RunClock>,
1116        files: &[FilePath],
1117        facts: &[Fact],
1118    ) -> Result<Vec<Violation>, RunError> {
1119        if !self.reducing {
1120            return Ok(Vec::new());
1121        }
1122
1123        let reducing: Vec<&Prepared> = self
1124            .rules
1125            .iter()
1126            .filter(|rule| rule.spec.has_reduce)
1127            .collect();
1128        if reducing.is_empty() {
1129            // The common case. Building a sandbox to do nothing would put engine startup on
1130            // the critical path of every run that has no cross-file rule at all.
1131            return Ok(Vec::new());
1132        }
1133
1134        let paths: Vec<String> = files.iter().map(|f| f.as_str().to_owned()).collect();
1135        let mut violations = Vec::new();
1136
1137        // Each engine's cross-file pass, and each built only if something needs it. A ruleset
1138        // whose only cross-file rule is a component must not start QuickJS and evaluate every
1139        // module into it, and the reverse holds just as strongly: building a component runtime
1140        // spawns the epoch ticker.
1141        let (module_rules, component_rules): (Vec<&Prepared>, Vec<&Prepared>) =
1142            reducing.into_iter().partition(|rule| rule.slot.is_none());
1143
1144        if !component_rules.is_empty() {
1145            violations.extend(self.reduce_components(clock, &component_rules, &paths, facts)?);
1146        }
1147        if module_rules.is_empty() {
1148            return Ok(violations);
1149        }
1150
1151        let sandbox = self.build_sandbox(clock)?;
1152
1153        for rule in module_rules {
1154            // A rule sees only its own facts. Letting one read another's would make an
1155            // internal payload shape into a contract between rules, and would make the
1156            // result depend on the order rules happened to be declared in.
1157            let own: Vec<ReduceFact> = facts
1158                .iter()
1159                .filter(|fact| fact.rule_id == rule.spec.id)
1160                .map(|fact| ReduceFact {
1161                    kind: fact.kind.clone(),
1162                    json: lanekeep_js::merge_file(&fact.data, fact.file.as_str()),
1163                })
1164                .collect();
1165
1166            let host = ReduceContext::new(paths.clone(), own);
1167            let timeout = rule.spec.timeout.unwrap_or(self.limits.rule_timeout);
1168            let call = format!(
1169                "globalThis.__lanekeepConfig.rules[{}].reduce(ctx)",
1170                rule_index(&rule.spec)
1171            );
1172
1173            sandbox
1174                .eval_with_reduce_host::<()>(&host, &call, timeout)
1175                .map_err(|e: SandboxError| RunError::Rule {
1176                    rule: rule.spec.id.to_string(),
1177                    // No single file is at fault in a reduce phase, and naming one would be
1178                    // a lie the reader would then go and look at.
1179                    file: "<reduce>".to_owned(),
1180                    detail: e.to_string(),
1181                })?;
1182
1183            for report in host.take_reports() {
1184                // The path is the rule's, normalized but not checked against the corpus. A
1185                // cross-file rule may legitimately report at a file the walker excluded —
1186                // a config, a generated file. Autofix will need to disagree: it must never
1187                // write to a path a rule invented. That check belongs with the writing.
1188                violations.push(Violation {
1189                    rule_id: rule.spec.id.clone(),
1190                    location: Location::new(
1191                        FilePath::new(&report.file),
1192                        Position::new(report.line, report.column),
1193                    ),
1194                    message: report
1195                        .message
1196                        .unwrap_or_else(|| rule.spec.card.message.clone()),
1197                    remediation: rule.spec.card.remediation.clone(),
1198                    severity: rule.spec.severity,
1199                    // A reduce phase has no parse tree, so there is no node to replace and
1200                    // nothing to compute a byte range from. A cross-file finding is fixed by
1201                    // hand.
1202                    fix: None,
1203                });
1204            }
1205        }
1206
1207        Ok(violations)
1208    }
1209
1210    /// The cross-file pass for every component-backed rule that has one.
1211    ///
1212    /// One store for the whole phase, instantiating each reducing rule once. It is not a
1213    /// worker's store: workers are gone by now, and a reduce pass is single-threaded.
1214    ///
1215    /// # A fact's file is a field, and this is where getting that wrong would have shown
1216    ///
1217    /// The JavaScript path splices `"file"` into the payload with `lanekeep_js::merge_file`,
1218    /// because its `ReduceFact` carries only `kind` and `json` and a rule reads `fact.file` off
1219    /// the parsed object. The world's `emitted-fact` has a `file` field of its own, so the
1220    /// component path carries it there and **must not** merge. Doing both produces a payload
1221    /// with a literal duplicate `"file"` key — valid enough for most parsers to accept and
1222    /// silently pick one of, and invisible from the host side, because the host forwards `data`
1223    /// exactly as the guest wrote it.
1224    ///
1225    /// # Errors
1226    ///
1227    /// Returns [`RunError::Rule`] for a trapping guest or a breached budget, and
1228    /// [`RunError::Worker`] when the runtime cannot be built.
1229    fn reduce_components(
1230        &self,
1231        clock: &Arc<RunClock>,
1232        reducing: &[&Prepared],
1233        paths: &[String],
1234        facts: &[Fact],
1235    ) -> Result<Vec<Violation>, RunError> {
1236        let components = self.components.as_ref().ok_or_else(|| RunError::Worker {
1237            detail: "a component rule has a reduce phase in a run that loaded no components"
1238                .to_owned(),
1239        })?;
1240        let mut runtime = WasmRuntime::for_rules(
1241            Arc::clone(components.engine()),
1242            Arc::clone(components.rules()),
1243            self.limits,
1244            Arc::clone(clock),
1245        );
1246
1247        let mut violations = Vec::new();
1248        for rule in reducing {
1249            let Some(slot) = rule.slot else { continue };
1250            let fail = |detail: String| RunError::Rule {
1251                rule: rule.spec.id.to_string(),
1252                // No single file is at fault in a reduce phase, and naming one would be a lie
1253                // the reader would then go and look at.
1254                file: "<reduce>".to_owned(),
1255                detail,
1256            };
1257
1258            // A rule sees only its own facts, exactly as on the JavaScript path, and in the
1259            // order `lanekeep_core::fact::sort` already put them in.
1260            let own: Vec<types::EmittedFact> = facts
1261                .iter()
1262                .filter(|fact| fact.rule_id == rule.spec.id)
1263                .map(|fact| types::EmittedFact {
1264                    kind: fact.kind.clone(),
1265                    file: fact.file.as_str().to_owned(),
1266                    data: fact.data.clone(),
1267                })
1268                .collect();
1269
1270            let resource = runtime
1271                .host_mut()
1272                .push_reduce_context(ComponentReduceContext::new(paths.to_vec(), own))
1273                .map_err(|e| fail(e.to_string()))?;
1274
1275            let timeout = rule.spec.timeout.unwrap_or(self.limits.rule_timeout);
1276            let outcome = runtime.reduce_with_timeout(slot, &resource, timeout);
1277
1278            // Taken before the failure is propagated, so the context does not outlive the call
1279            // that needed it even on the path that ends the run.
1280            let mut taken = runtime
1281                .host_mut()
1282                .take_reduce_context(resource)
1283                .map_err(|e| fail(e.to_string()))?;
1284            outcome.map_err(|e: WasmError| fail(e.to_string()))?;
1285
1286            for report in taken.take_reports() {
1287                // The path is the rule's, normalized but not checked against the corpus — the
1288                // same posture the JavaScript path takes, for the same reason.
1289                violations.push(Violation {
1290                    rule_id: rule.spec.id.clone(),
1291                    location: Location::new(
1292                        FilePath::new(&report.file),
1293                        Position::new(report.line, report.column),
1294                    ),
1295                    message: report
1296                        .message
1297                        .unwrap_or_else(|| rule.spec.card.message.clone()),
1298                    remediation: rule.spec.card.remediation.clone(),
1299                    severity: rule.spec.severity,
1300                    // A reduce phase has no parse tree, so there is no node to replace.
1301                    fix: None,
1302                });
1303            }
1304        }
1305
1306        Ok(violations)
1307    }
1308
1309    /// Build the sandbox a worker uses, evaluating the ruleset into it.
1310    fn build_sandbox(&self, clock: &Arc<RunClock>) -> Result<Sandbox, RunError> {
1311        let sandbox = Sandbox::with_modules(
1312            self.limits,
1313            Arc::clone(clock),
1314            self.rules_root.clone(),
1315            Arc::clone(&self.typescript),
1316            Arc::clone(&self.javascript),
1317        )
1318        .map_err(|e| RunError::Worker {
1319            detail: e.to_string(),
1320        })?;
1321
1322        // Every worker evaluates the ruleset into its own engine. A rule's `check` is a
1323        // function, and a function cannot cross between runtimes — so the modules are
1324        // loaded per worker rather than the handlers being extracted and shared.
1325        lanekeep_config::evaluate_into(&sandbox, &self.rules_root, &self.config_path).map_err(
1326            |e: ConfigError| RunError::Worker {
1327                detail: e.to_string(),
1328            },
1329        )?;
1330
1331        Ok(sandbox)
1332    }
1333
1334    /// Check one file. Returns its violations, facts and tracked reads.
1335    fn check_file(
1336        &self,
1337        worker: &mut Worker<'_>,
1338        cache: &Store,
1339        path: &FilePath,
1340    ) -> Result<FileOutcome, RunError> {
1341        // **The run's budget, asked where the run's time is actually spent.**
1342        //
1343        // Both engines poll it from inside a handler and nowhere else: QuickJS from its
1344        // interrupt handler, wasmtime from the epoch checks Cranelift compiles into guest
1345        // code. Neither runs while this engine is reading a file, hashing it, parsing it or
1346        // evaluating a query — and §15 says that is most of a cold run. So a rule whose
1347        // handler returns after a handful of operations could overrun the budget without ever
1348        // being asked to stop: `AGENTS.md` recorded four hundred files against a one-line rule
1349        // running to completion under a one-millisecond budget, and the component path had the
1350        // same gap for the same reason.
1351        //
1352        // One check, here, closes it for both, because this sits above the dispatch that
1353        // chooses between them. A file boundary is also the only place a run *can* be stopped
1354        // without degrading it: everything before this line for this file has not happened
1355        // yet, and everything after it happens in full or not at all.
1356        //
1357        // It costs one clock read per file, on a path that already reads the file from disk.
1358        // That matters because `Worker`'s own count is per rayon *chunk* rather than per
1359        // thread — but this is per file either way, and `RunClock::is_expired` allocates
1360        // nothing and takes no lock.
1361        if worker.clock.is_expired() {
1362            return Err(RunError::RunTimeout {
1363                budget: worker.clock.global_timeout(),
1364                elapsed: worker.clock.elapsed(),
1365            });
1366        }
1367
1368        // A fresh set of tracked reads for this file, sharing the root already canonicalized
1369        // at preparation.
1370        //
1371        // **One per file, and now shared by both engines rather than one per engine.** An
1372        // `Arc` rather than an `Rc` because `lanekeep_wasm::host::CheckContext` has to be
1373        // `Send`; the sharing itself is the point, since two memos over one file would let two
1374        // rules see a file rewritten between them differently, and the two dependency lists
1375        // could not be merged afterwards — `tracked::sort` orders by path and does not dedupe,
1376        // so a disagreement about one path becomes two contradictory entries for it.
1377        let files = Arc::new(FileAccess::rooted(self.root.clone()));
1378
1379        // Path gates first: rejecting here costs no read at all.
1380        //
1381        let admitted: Vec<&Prepared> = self
1382            .rules
1383            .iter()
1384            .filter(|rule| rule.gates.admits_path(path))
1385            .collect();
1386        if admitted.is_empty() {
1387            return Ok(FileOutcome::skipped(path.clone()));
1388        }
1389
1390        let absolute = self.discovery.root().join(path.as_str());
1391        let Ok(bytes) = std::fs::read(&absolute) else {
1392            // A file that vanished between discovery and reading is not a failure. The
1393            // tree is allowed to change under a run; what must not happen is a partial
1394            // result being reported as complete, and a missing file contributes nothing
1395            // either way.
1396            return Ok(FileOutcome::skipped(path.clone()));
1397        };
1398
1399        // The cache is consulted after the path gates and the read, because the key needs
1400        // the file's bytes — but before the content gates and the parse, which is where the
1401        // saving is. A hit costs one hash and one dependency check.
1402        // A file's result can depend on what day it is, two ways: an expiring suppression in
1403        // its bytes, or a rule that read `ctx.today` while checking it. Such a file gets a
1404        // key with the date folded in, so its entry lives for one day; every other file gets
1405        // a dateless key and its entry survives indefinitely.
1406        //
1407        // Folding the date into every key instead would invalidate the whole corpus daily
1408        // for the sake of a handful of files. Leaving it out entirely would serve yesterday's
1409        // answer — an expiry that never expires, a date comparison frozen at whenever the
1410        // cache was written.
1411        //
1412        // The expiry is visible in the bytes, so it is known now. Whether a rule reads the
1413        // date is not knowable until the rules have run, which is why both keys exist and
1414        // the lookup tries the dated one first: a file that was date-dependent last run has
1415        // its entry there, and if the date has moved that key simply misses.
1416        let keys = self.caching.then(|| {
1417            let content = lanekeep_cache::hash_bytes(&bytes);
1418            (
1419                self.run_key.for_file(path.as_str(), &content),
1420                self.run_key
1421                    .for_dated_file(path.as_str(), &content, &self.today.to_string()),
1422            )
1423        });
1424        let has_expiry = memchr::memmem::find(&bytes, b"expires:").is_some();
1425
1426        if let Some((plain, dated)) = keys {
1427            // Dated first. A file with an expiring suppression is *only* ever stored dated,
1428            // so trying the plain key for it would be a lookup that can never hit.
1429            let candidates: &[CacheKey] = if has_expiry {
1430                &[dated]
1431            } else {
1432                &[dated, plain]
1433            };
1434            for key in candidates {
1435                if let Some(entry) = cache.get(key)
1436                    && lanekeep_cache::validate(entry, &self.root)
1437                {
1438                    return Ok(FileOutcome::cached(path.clone(), *key, entry.clone()));
1439                }
1440            }
1441        }
1442
1443        // Content gates: one read, a substring scan, and a parse saved.
1444        let admitted: Vec<&Prepared> = admitted
1445            .into_iter()
1446            .filter(|rule| rule.gates.admits_content(&bytes))
1447            .collect();
1448        if admitted.is_empty() {
1449            // Still worth an entry: "nothing applies to this file" is a result, and
1450            // recomputing the gates every run for a file that never matches is the cost the
1451            // cache exists to remove. No rule ran, so nothing read the date — unless the
1452            // file carries an expiry, which is a property of its bytes.
1453            return Ok(FileOutcome::empty_entry(
1454                path.clone(),
1455                keys.map(|(plain, dated)| if has_expiry { dated } else { plain }),
1456            ));
1457        }
1458
1459        let Ok(source) = String::from_utf8(bytes) else {
1460            // Not valid UTF-8, so not source this tool can reason about.
1461            return Ok(FileOutcome::skipped(path.clone()));
1462        };
1463
1464        // Parsed once per file, whatever rules ran: a directive is a property of the file,
1465        // not of any rule.
1466        let directives = suppression::parse(&source);
1467
1468        let mut outcome = FileOutcome::parsed(path.clone());
1469        let Some((language, tree)) = self.parse_once(path, &source, &admitted) else {
1470            return Ok(outcome);
1471        };
1472
1473        // One traversal for every rule, where the ruleset allows it. `collected[i]` holds
1474        // the matches for `self.rules[i]`; `None` means no combined pass ran and each rule
1475        // walks the tree itself.
1476        let file = FileUnderCheck {
1477            path,
1478            source: &source,
1479            tree: &tree,
1480            language: &language,
1481        };
1482        self.dispatch(worker, &files, &admitted, &file, &mut outcome)?;
1483        self.apply_directives(&mut outcome, &directives, path);
1484
1485        outcome.suppressions = directives.valid;
1486        outcome.reads = files.dependencies();
1487        // Dated if anything about this file's result depended on the date: an expiring
1488        // directive, or a rule that read `ctx.today`.
1489        let date_dependent = has_expiry || outcome.read_the_date;
1490        outcome.entry = keys.map(|(plain, dated)| {
1491            (
1492                if date_dependent { dated } else { plain },
1493                CacheEntry {
1494                    violations: outcome.violations.clone(),
1495                    facts: outcome.facts.clone(),
1496                    dependencies: outcome.reads.clone(),
1497                    suppressions: outcome.suppressions.clone(),
1498                    used_suppressions: outcome.used_suppressions.clone(),
1499                },
1500            )
1501        });
1502
1503        Ok(outcome)
1504    }
1505
1506    /// Run every admitted rule over one parsed file, through whichever engine backs it.
1507    ///
1508    /// **The dispatch, and it is one `if let` on one field.** Both arms produce the same four
1509    /// things, so nothing downstream — sorting, suppression, the cache entry — can tell which
1510    /// engine an answer came from. That is the requirement rather than a nicety: two engines
1511    /// feeding one output must not introduce a second ordering or a second shape of result.
1512    fn dispatch(
1513        &self,
1514        worker: &mut Worker<'_>,
1515        files: &Arc<FileAccess>,
1516        admitted: &[&Prepared],
1517        file: &FileUnderCheck<'_>,
1518        outcome: &mut FileOutcome,
1519    ) -> Result<(), RunError> {
1520        let mut collected = self.collect_matches(file, admitted);
1521
1522        // One component context for the whole file, opened by the first component rule that has
1523        // a match and shared by every one after it. Per file rather than per rule for the reason
1524        // `files` is: it is what makes the arena, the query cache and — through `files` — the
1525        // read memo one thing rather than one per rule.
1526        let mut context: Option<Resource<CheckContext>> = None;
1527
1528        for rule in admitted {
1529            // Taken, not cloned: each bucket is read exactly once, and copying capture
1530            // paths per rule would give back a share of what the single traversal saved.
1531            let matches = collected.as_mut().map(|by_rule| {
1532                by_rule
1533                    .get_mut(rule.index)
1534                    .map(std::mem::take)
1535                    .unwrap_or_default()
1536            });
1537
1538            let (violations, facts, read_the_date, timing) = if let Some(slot) = rule.slot {
1539                let mut pass = ComponentPass {
1540                    files,
1541                    context: &mut context,
1542                };
1543                let outcome = self.run_component_rule(worker, &mut pass, rule, slot, file, matches);
1544                worker.poison_on(&outcome)?
1545            } else {
1546                self.run_rule(worker, files, rule, file, matches)?
1547            };
1548
1549            outcome.violations.extend(violations);
1550            outcome.facts.extend(facts);
1551            outcome.read_the_date |= read_the_date;
1552            if self.profiling {
1553                outcome.timings.push((rule.spec.id.clone(), timing));
1554            }
1555        }
1556
1557        // Give the store its entry back. A context holds the parse tree and the file's whole
1558        // source, so leaving one behind per file would grow a worker's store with the corpus —
1559        // charged against the same per-store memory ceiling a rule is charged against, and
1560        // silent until a large enough run.
1561        if let Some(resource) = context.take() {
1562            let taken = worker
1563                .runtime()?
1564                .host_mut()
1565                .take_check_context(resource)
1566                .map_err(|e| RunError::Rule {
1567                    rule: "<components>".to_owned(),
1568                    file: file.path.as_str().to_owned(),
1569                    detail: e.to_string(),
1570                })?;
1571            // Read once for the file rather than per rule: the flag is sticky for the life of
1572            // the context, so any component rule that asked dates this file's cache entry.
1573            outcome.read_the_date |= taken.date_was_read();
1574        }
1575
1576        Ok(())
1577    }
1578
1579    /// Violations about the directives themselves.
1580    ///
1581    /// A suppression that does not work has to say so. A malformed directive silences
1582    /// nothing while looking like it does, and an expired one is a deadline the author set
1583    /// and then passed — reporting both is the whole reason the fields are checked rather
1584    /// than best-effort parsed.
1585    fn directive_violations(&self, directives: &Suppressions, path: &FilePath) -> Vec<Violation> {
1586        let mut violations = Vec::new();
1587
1588        // Parsed once here rather than per violation. `SUPPRESSION_RULE` is a literal this
1589        // crate controls, so a failure would be a build-time mistake — falling back to the
1590        // rules' own namespace keeps that from being a panic in a checker.
1591        let Ok(rule_id) = SUPPRESSION_RULE.parse::<RuleId>() else {
1592            return violations;
1593        };
1594
1595        for bad in &directives.malformed {
1596            violations.push(Violation {
1597                rule_id: rule_id.clone(),
1598                location: Location::new(path.clone(), Position::new(bad.line, bad.column)),
1599                message: bad.problem.clone(),
1600                remediation: String::from(
1601                    "fix the directive, or remove it and fix what it was hiding",
1602                ),
1603                severity: Severity::Error,
1604                fix: None,
1605            });
1606        }
1607
1608        for suppression in &directives.valid {
1609            // Each distinct problem reported once, in a fixed order — expired first, then the
1610            // policy checks as the config's keys read — so a directive that breaks several
1611            // rules of the policy produces the same list in every run.
1612            if let Some(expires) = suppression.expires
1613                && expires < self.today
1614            {
1615                violations.push(Violation {
1616                    rule_id: rule_id.clone(),
1617                    location: Location::new(
1618                        path.clone(),
1619                        Position::new(suppression.line, suppression.column),
1620                    ),
1621                    message: format!(
1622                        "suppression expired on {expires} — \"{}\"",
1623                        suppression.reason
1624                    ),
1625                    remediation: String::from(
1626                        "fix what it was suppressing, or decide it is permanent and drop the \
1627                         expiry",
1628                    ),
1629                    severity: Severity::Error,
1630                    fix: None,
1631                });
1632            }
1633
1634            if self.suppression_policy.require_expiry && suppression.expires.is_none() {
1635                violations.push(Violation {
1636                    rule_id: rule_id.clone(),
1637                    location: Location::new(
1638                        path.clone(),
1639                        Position::new(suppression.line, suppression.column),
1640                    ),
1641                    message: String::from(
1642                        "suppression has no `expires:` — `suppressions.requireExpiry` \
1643                         requires one",
1644                    ),
1645                    remediation: String::from("add `expires: YYYY-MM-DD`, or turn the policy off"),
1646                    severity: Severity::Error,
1647                    fix: None,
1648                });
1649            }
1650
1651            if let Some(max) = self.suppression_policy.max_expiry_days
1652                && let Some(expires) = suppression.expires
1653                && expires > self.today.add_days(max)
1654            {
1655                violations.push(Violation {
1656                    rule_id: rule_id.clone(),
1657                    location: Location::new(
1658                        path.clone(),
1659                        Position::new(suppression.line, suppression.column),
1660                    ),
1661                    message: format!(
1662                        "suppression expires {expires} — more than {max} days out under \
1663                         `suppressions.maxExpiryDays`"
1664                    ),
1665                    remediation: String::from(
1666                        "bring the expiry inside the policy's horizon, or raise the horizon",
1667                    ),
1668                    severity: Severity::Error,
1669                    fix: None,
1670                });
1671            }
1672
1673            if self.suppression_policy.forbid_file_scope && suppression.scope == Scope::File {
1674                violations.push(Violation {
1675                    rule_id: rule_id.clone(),
1676                    location: Location::new(
1677                        path.clone(),
1678                        Position::new(suppression.line, suppression.column),
1679                    ),
1680                    message: String::from(
1681                        "file-scope suppression is forbidden — \
1682                         `suppressions.forbidFileScope` is on",
1683                    ),
1684                    remediation: String::from(
1685                        "narrow it to the lines that need it, or turn the policy off",
1686                    ),
1687                    severity: Severity::Error,
1688                    fix: None,
1689                });
1690            }
1691        }
1692
1693        violations
1694    }
1695
1696    /// Parse a file once, for every rule that will run on it.
1697    ///
1698    /// §2's "run compiled queries (one pass)" and §7's "single shared parse". `run_rule` built
1699    /// its own parser instead, so a file admitted by twenty rules was parsed twenty times —
1700    /// most of a cold run, and invisible, because parsing per rule produces identical output.
1701    ///
1702    /// The grammar comes from the rules rather than from a registry the engine would have to
1703    /// hold: every admitted rule that targets this file targets the same grammar for it, so
1704    /// the first one that does is as good as any.
1705    ///
1706    /// `None` when the language is unknown or the grammar cannot parse the file. That is not
1707    /// an error — it is what the per-rule early returns did before, and callers depend on a
1708    /// file like that simply producing no violations.
1709    /// Run every admitted rule's patterns in one traversal, bucketed by rule.
1710    ///
1711    /// `None` means the caller should fall back to a query per rule: either no combined
1712    /// query exists for this language, or `--profile` is on. Profiling deliberately takes
1713    /// the slow path, because the per-rule split it reports — query time against handler
1714    /// time — is a measurement of one rule in isolation, and a shared traversal has no
1715    /// honest way to divide itself between the rules that share it. See §15.
1716    ///
1717    /// Patterns belonging to rules a gate excluded still run; their matches are dropped
1718    /// here rather than never produced. That costs a little evaluation and saves the
1719    /// traversal, and it cannot change a result: a gate that rejects a file means the rule
1720    /// does not run on it, and a bucket that is thrown away is a rule that did not run.
1721    fn collect_matches(
1722        &self,
1723        file: &FileUnderCheck<'_>,
1724        admitted: &[&Prepared],
1725    ) -> Option<MatchesByRule> {
1726        if self.profiling {
1727            return None;
1728        }
1729        let FileUnderCheck {
1730            path,
1731            source,
1732            tree,
1733            language: _,
1734        } = *file;
1735        let combined = self
1736            .combined
1737            .get_or_init(|| combine_queries(&self.rules))
1738            .get(self.language_of(path)?)?;
1739
1740        // One arena for the whole file, only to turn nodes into paths. Each rule still gets
1741        // its own arena and its own handles; a path is structural, so it crosses freely.
1742        let arena = lanekeep_js::NodeArena::new(tree.clone(), source.to_owned());
1743
1744        let mut by_rule: MatchesByRule = vec![Vec::new(); self.rules.len()];
1745        let wanted: Vec<bool> = {
1746            let mut wanted = vec![false; self.rules.len()];
1747            for rule in admitted {
1748                wanted[rule.index] = true;
1749            }
1750            wanted
1751        };
1752
1753        combined
1754            .query()?
1755            .for_each_match(arena.tree(), source.as_bytes(), |m| {
1756                let Some(&owner) = combined.owners.get(m.pattern_index) else {
1757                    return;
1758                };
1759                if !wanted[owner] {
1760                    return;
1761                }
1762                let captures = m
1763                    .captures
1764                    .iter()
1765                    .filter_map(|(name, node)| {
1766                        arena.path_of(*node).map(|path| ((*name).to_owned(), path))
1767                    })
1768                    .collect();
1769                by_rule[owner].push(captures);
1770            });
1771
1772        Some(by_rule)
1773    }
1774
1775    /// Drop the violations this file's directives silence, and record which fired.
1776    ///
1777    /// Applied after every rule has run, so a directive covers whatever any of them
1778    /// reported at that line. Which directive fired is recorded rather than discarded: it
1779    /// is the only moment the information exists, since a warm run sees the survivors and
1780    /// not what was hidden.
1781    fn apply_directives(
1782        &self,
1783        outcome: &mut FileOutcome,
1784        directives: &Suppressions,
1785        path: &FilePath,
1786    ) {
1787        let mut used = Vec::new();
1788        outcome.violations.retain(|violation| {
1789            match directives.covering(&violation.rule_id, violation.location.position.line) {
1790                Some(index) => {
1791                    let index = u32::try_from(index).unwrap_or(u32::MAX);
1792                    if !used.contains(&index) {
1793                        used.push(index);
1794                    }
1795                    false
1796                }
1797                None => true,
1798            }
1799        });
1800        used.sort_unstable();
1801        outcome.used_suppressions = used;
1802        outcome
1803            .violations
1804            .extend(self.directive_violations(directives, path));
1805    }
1806
1807    /// Parse the file, and hand back the grammar that did it alongside the tree.
1808    ///
1809    /// The grammar comes back because the component engine needs it once per file rather than
1810    /// once per rule — see [`FileUnderCheck::language`] — and because looking it up a second
1811    /// time would be a second answer to a question that already has one.
1812    fn parse_once(
1813        &self,
1814        path: &FilePath,
1815        source: &str,
1816        admitted: &[&Prepared],
1817    ) -> Option<(Arc<dyn Language>, tree_sitter::Tree)> {
1818        let language_id = self.language_of(path)?;
1819        let (language, _) = admitted
1820            .iter()
1821            .find_map(|rule| rule.for_language(language_id))?;
1822
1823        // lanekeep-ignore-next-line local/one-parser-per-file reason: the one shared per-file parse every rule's query runs against
1824        let mut parser = tree_sitter::Parser::new();
1825        parser.set_language(&language.grammar()).ok()?;
1826        let tree = parser.parse(source, None)?;
1827        Some((Arc::clone(language), tree))
1828    }
1829
1830    /// Run one component-backed rule over one file.
1831    ///
1832    /// The counterpart of [`Engine::run_rule`], and deliberately the same signature and the
1833    /// same four return values: a caller must not be able to tell which engine answered.
1834    ///
1835    /// # What is *not* here, and that is the simplification
1836    ///
1837    /// No source text is manufactured and nothing is parsed on the hot path. The JavaScript
1838    /// path builds `globalThis.__lanekeepConfig.rules[i].check(ctx, {…})` per match and hands
1839    /// it to a parser; here the captures become a WIT `match` — a list of name/handle pairs —
1840    /// and the rule's typed `check` export is called with it.
1841    ///
1842    /// # Errors
1843    ///
1844    /// Returns [`RunError::Rule`] for a trapping guest or a breached budget, both of which
1845    /// cancel the run. That is what keeps a poisoned store from being reused: any host refusal
1846    /// traps, and `imports: { default: trappable }` marks the whole store unenterable with no
1847    /// way to reset it — so a store that has trapped must never see another file, and every
1848    /// error here is propagated rather than skipped.
1849    fn run_component_rule(
1850        &self,
1851        worker: &mut Worker<'_>,
1852        pass: &mut ComponentPass<'_>,
1853        rule: &Prepared,
1854        slot: RuleSlot,
1855        file: &FileUnderCheck<'_>,
1856        precollected: Option<RuleMatches>,
1857    ) -> Result<(Vec<Violation>, Vec<Fact>, bool, RuleTiming), RunError> {
1858        let FileUnderCheck {
1859            path,
1860            source,
1861            tree: _,
1862            language: _,
1863        } = *file;
1864
1865        // The grammar is chosen by the file, not by the rule — the same gate the JavaScript
1866        // path applies, applied identically, so the two engines cannot disagree about which
1867        // files a rule runs on.
1868        let Some(language_id) = self.language_of(path) else {
1869            return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
1870        };
1871        let Some((_, compiled_query)) = rule.for_language(language_id) else {
1872            return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
1873        };
1874
1875        let mut timing = RuleTiming::default();
1876        let clock = |on: bool| on.then(std::time::Instant::now);
1877        let query_started = clock(self.profiling);
1878
1879        // Matches first, and the context only if there are any. A rule whose query matches
1880        // nothing on this file must not open a context, instantiate anything, or copy the
1881        // file's source into an arena.
1882        let precollected_is_empty = precollected.as_ref().is_some_and(Vec::is_empty);
1883        if precollected_is_empty {
1884            if let Some(started) = query_started {
1885                timing.query = started.elapsed();
1886            }
1887            return Ok((Vec::new(), Vec::new(), false, timing));
1888        }
1889
1890        self.open_context(worker, pass, file)?;
1891        let Some(resource) = pass.context.as_ref() else {
1892            return Err(RunError::Worker {
1893                detail: "the file's component context was not opened".to_owned(),
1894            });
1895        };
1896        let runtime = worker.runtime()?;
1897
1898        // Already matched, in one traversal shared with every other rule on this file — or not,
1899        // in which case this rule walks the tree alone.
1900        let matches = if let Some(found) = precollected {
1901            found
1902        } else {
1903            let host = runtime
1904                .host_mut()
1905                .check_context_mut(resource)
1906                .map_err(|e| Self::component_failure(rule, path, &e.to_string()))?;
1907            walk_for(host, compiled_query, source)
1908        };
1909
1910        if let Some(started) = query_started {
1911            timing.query = started.elapsed();
1912            timing.matches = matches.len() as u64;
1913        }
1914        if matches.is_empty() {
1915            return Ok((Vec::new(), Vec::new(), false, timing));
1916        }
1917
1918        let timeout = rule.spec.timeout.unwrap_or(self.limits.rule_timeout);
1919        for captures in matches {
1920            let entries: Vec<types::MatchEntry> = {
1921                let host = runtime
1922                    .host_mut()
1923                    .check_context_mut(resource)
1924                    .map_err(|e| Self::component_failure(rule, path, &e.to_string()))?;
1925                let arena = host.arena_mut();
1926                captures
1927                    .into_iter()
1928                    .filter_map(|(name, path)| {
1929                        arena
1930                            .intern_path(path)
1931                            .map(|node| types::MatchEntry { name, node })
1932                    })
1933                    .collect()
1934            };
1935
1936            let handler_started = clock(self.profiling);
1937            let outcome = runtime.check_with_timeout(slot, resource, &entries, timeout);
1938            if let Some(started) = handler_started {
1939                timing.handler = timing.handler.saturating_add(started.elapsed());
1940            }
1941            outcome.map_err(|e: WasmError| Self::component_failure(rule, path, &e.to_string()))?;
1942        }
1943
1944        // Taken per rule rather than per file, which is what attributes a report to the rule
1945        // that made it: the context is shared, and `take_reports` empties it.
1946        let host = runtime
1947            .host_mut()
1948            .check_context_mut(resource)
1949            .map_err(|e| Self::component_failure(rule, path, &e.to_string()))?;
1950        let reports = host.take_reports();
1951        let emitted = host.take_facts();
1952
1953        let facts = emitted
1954            .into_iter()
1955            .enumerate()
1956            .map(|(sequence, fact)| Fact {
1957                rule_id: rule.spec.id.clone(),
1958                file: path.clone(),
1959                kind: fact.kind,
1960                // The payload exactly as the guest serialized it. **Nothing merges a `file`
1961                // key into it**, unlike the JavaScript path: `lanekeep-js`'s reduce phase
1962                // splices one in because its `ReduceFact` carries only `kind` and `json`,
1963                // where the world's `emitted-fact` has a `file` field of its own. Doing both
1964                // would put a literal duplicate `"file"` key in the payload a component reads.
1965                data: fact.data,
1966                sequence: u32::try_from(sequence).unwrap_or(u32::MAX),
1967            })
1968            .collect();
1969
1970        // The date flag is sticky and belongs to the context, so it is read once when the file
1971        // is finished rather than claimed per rule — see `check_file`.
1972        Ok((
1973            Self::violations_from(rule, path, reports),
1974            facts,
1975            false,
1976            timing,
1977        ))
1978    }
1979
1980    /// Turn a component's reports into violations, under the rule's own identity.
1981    ///
1982    /// Identical in shape to what [`Engine::run_rule`] does with `lanekeep_js::Report`, and
1983    /// deliberately so: a rule supplies a position and optionally a message, and the id,
1984    /// severity, remediation and default message come from the engine. That is what stops a
1985    /// rule reporting under someone else's name, and it must not depend on which engine ran it.
1986    fn violations_from(
1987        rule: &Prepared,
1988        path: &FilePath,
1989        reports: Vec<lanekeep_wasm::host::Report>,
1990    ) -> Vec<Violation> {
1991        reports
1992            .into_iter()
1993            .map(|report| Violation {
1994                rule_id: rule.spec.id.clone(),
1995                location: Location::new(path.clone(), Position::new(report.line, report.column)),
1996                message: report
1997                    .message
1998                    .unwrap_or_else(|| rule.spec.card.message.clone()),
1999                remediation: rule.spec.card.remediation.clone(),
2000                severity: rule.spec.severity,
2001                fix: report.fix,
2002            })
2003            .collect()
2004    }
2005
2006    /// Open the file's component context, if this is the first rule that needs one.
2007    ///
2008    /// The resource stays in the caller's `Option` rather than being handed back by value:
2009    /// a `Resource` is an owned table entry, so two of them naming one rep would be two claims
2010    /// on the same context and a double delete when the file is finished.
2011    fn open_context(
2012        &self,
2013        worker: &mut Worker<'_>,
2014        pass: &mut ComponentPass<'_>,
2015        file: &FileUnderCheck<'_>,
2016    ) -> Result<(), RunError> {
2017        if pass.context.is_some() {
2018            return Ok(());
2019        }
2020
2021        let mut built = CheckContext::new(
2022            lanekeep_js::NodeArena::new(file.tree.clone(), file.source.to_owned()),
2023            file.path.as_str(),
2024            Arc::clone(file.language),
2025        )
2026        .with_file_access(Arc::clone(pass.files))
2027        .with_today(&self.today.to_string());
2028        if let Some(resolver) = file.language.resolver() {
2029            built = built.with_resolver(resolver);
2030        }
2031
2032        let resource = worker
2033            .runtime()?
2034            .host_mut()
2035            .push_check_context(built)
2036            .map_err(|e| RunError::Rule {
2037                rule: "<components>".to_owned(),
2038                file: file.path.as_str().to_owned(),
2039                detail: e.to_string(),
2040            })?;
2041        *pass.context = Some(resource);
2042        Ok(())
2043    }
2044
2045    /// One shape for every way a component rule can fail on a file.
2046    fn component_failure(rule: &Prepared, path: &FilePath, detail: &str) -> RunError {
2047        RunError::Rule {
2048            rule: rule.spec.id.to_string(),
2049            file: path.as_str().to_owned(),
2050            detail: detail.to_owned(),
2051        }
2052    }
2053
2054    fn run_rule(
2055        &self,
2056        worker: &mut Worker<'_>,
2057        files: &Arc<FileAccess>,
2058        rule: &Prepared,
2059        file: &FileUnderCheck<'_>,
2060        precollected: Option<RuleMatches>,
2061    ) -> Result<(Vec<Violation>, Vec<Fact>, bool, RuleTiming), RunError> {
2062        let FileUnderCheck {
2063            path,
2064            source,
2065            tree,
2066            language: _,
2067        } = *file;
2068        // The grammar is chosen by the file, not by the rule. A rule that does not target
2069        // this file's language does not run on it at all — previously it ran anyway, against
2070        // a grammar that could not parse the file, and matched nothing without saying so.
2071        let Some(language_id) = self.language_of(path) else {
2072            return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
2073        };
2074        let Some((language, compiled_query)) = rule.for_language(language_id) else {
2075            return Ok((Vec::new(), Vec::new(), false, RuleTiming::default()));
2076        };
2077
2078        // The tree is parsed once for the file and handed to every rule — §2's "run compiled
2079        // queries (one pass)" and §7's "single shared parse".
2080        //
2081        // It was parsed here instead, per rule, so a file admitted by twenty rules was parsed
2082        // twenty times. That is most of a cold run: the profile attributed it to query time,
2083        // which made twelve rules matching *nothing* look like they cost 400 ms of matching
2084        // each. `Tree::clone` is `ts_tree_copy`, a refcounted copy rather than a re-parse, so
2085        // each rule still gets an owned tree for its arena at almost no cost.
2086        let tree = tree.clone();
2087
2088        // Only when asked. A clock read per invocation is cheap and not free, and this is
2089        // the hot path.
2090        let mut timing = RuleTiming::default();
2091        let clock = |on: bool| on.then(std::time::Instant::now);
2092
2093        // Collect capture paths while the tree is borrowed, then intern once the borrow
2094        // has ended — the two-phase shape the arena's ownership of the tree forces.
2095        let mut matches: RuleMatches = Vec::new();
2096
2097        let host = HostContext::new(tree, source.to_owned(), path.as_str())
2098            .with_resolver_from(language.as_ref())
2099            .with_language(Arc::clone(language))
2100            .with_today(&self.today.to_string())
2101            .with_file_access(Arc::clone(files));
2102
2103        let query_started = clock(self.profiling);
2104        if let Some(found) = precollected {
2105            // Already matched, in one traversal shared with every other rule on this file.
2106            matches = found;
2107        } else {
2108            // No combined query for this language, or profiling asked for the per-rule
2109            // split. Walk the tree for this rule alone.
2110            let arena = host.arena().borrow();
2111            compiled_query.for_each_match(arena.tree(), source.as_bytes(), |m| {
2112                let captures = m
2113                    .captures
2114                    .iter()
2115                    .filter_map(|(name, node)| {
2116                        arena.path_of(*node).map(|path| ((*name).to_owned(), path))
2117                    })
2118                    .collect();
2119                matches.push(captures);
2120            });
2121        }
2122
2123        if let Some(started) = query_started {
2124            timing.query = started.elapsed();
2125            timing.matches = matches.len() as u64;
2126        }
2127
2128        if matches.is_empty() {
2129            return Ok((Vec::new(), Vec::new(), false, timing));
2130        }
2131
2132        // Only now, with matches in hand, is a sandbox needed. Everything above — parsing,
2133        // query matching — is Rust, and a file that matches nothing never starts one.
2134        let sandbox = worker.sandbox()?;
2135
2136        let timeout = rule.spec.timeout.unwrap_or(self.limits.rule_timeout);
2137        let mut violations = Vec::new();
2138
2139        for captures in matches {
2140            let handles: Vec<(String, u32)> = {
2141                let mut arena = host.arena().borrow_mut();
2142                captures
2143                    .into_iter()
2144                    .filter_map(|(name, path)| arena.intern_path(path).map(|h| (name, h)))
2145                    .collect()
2146            };
2147
2148            let literal = handles
2149                .iter()
2150                .map(|(name, handle)| format!("{}: {handle}", json_key(name)))
2151                .collect::<Vec<_>>()
2152                .join(", ");
2153
2154            // The handler is invoked through the module the config already loaded, so the
2155            // rule object here is the same one the config validated.
2156            let call = format!(
2157                "globalThis.__lanekeepConfig.rules[{}].check(ctx, {{{literal}}})",
2158                rule_index(&rule.spec)
2159            );
2160
2161            let handler_started = clock(self.profiling);
2162            let outcome = sandbox.eval_with_host_timeout::<()>(&host, &call, timeout);
2163            if let Some(started) = handler_started {
2164                timing.handler = timing.handler.saturating_add(started.elapsed());
2165            }
2166
2167            outcome.map_err(|e: SandboxError| RunError::Rule {
2168                rule: rule.spec.id.to_string(),
2169                file: path.as_str().to_owned(),
2170                detail: e.to_string(),
2171            })?;
2172        }
2173
2174        let facts = host
2175            .take_facts()
2176            .into_iter()
2177            .enumerate()
2178            .map(|(sequence, emitted)| Fact {
2179                rule_id: rule.spec.id.clone(),
2180                file: path.clone(),
2181                kind: emitted.kind,
2182                data: emitted.data,
2183                // Emission order within the file. The engine assigns it rather than
2184                // trusting the rule, so a rule cannot reorder its own facts relative to
2185                // another file's and change what `reduce` sees.
2186                sequence: u32::try_from(sequence).unwrap_or(u32::MAX),
2187            })
2188            .collect();
2189
2190        for report in host.take_reports() {
2191            violations.push(Violation {
2192                rule_id: rule.spec.id.clone(),
2193                location: Location::new(path.clone(), Position::new(report.line, report.column)),
2194                message: report
2195                    .message
2196                    .unwrap_or_else(|| rule.spec.card.message.clone()),
2197                remediation: rule.spec.card.remediation.clone(),
2198                severity: rule.spec.severity,
2199                fix: report.fix,
2200            });
2201        }
2202
2203        Ok((violations, facts, host.date_was_read(), timing))
2204    }
2205}
2206
2207/// Whether a run looked at the whole corpus or a chosen subset.
2208///
2209/// The distinction only matters when saving: a run that saw everything may prune, and a run
2210/// that saw a subset must not.
2211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2212enum Coverage {
2213    /// Everything discovery selected.
2214    Whole,
2215    /// An explicit subset, from `--since` or `--staged`.
2216    Partial,
2217}
2218
2219/// One rayon worker's reusable state.
2220///
2221/// The sandbox is built on first use rather than up front. Starting QuickJS and evaluating
2222/// every rule module into it costs real time, and a worker whose files all hit the cache —
2223/// or whose queries match nothing — never executes a line of JavaScript and does not need
2224/// one.
2225struct Worker<'a> {
2226    engine: &'a Engine,
2227    clock: Arc<RunClock>,
2228    sandbox: Option<Sandbox>,
2229    /// A failure to build, remembered so it is reported once per worker rather than
2230    /// retried for every remaining file.
2231    failed: Option<RunError>,
2232    /// This worker's component store, built on first use exactly as the sandbox is.
2233    ///
2234    /// **One store per worker holding one instance per component — and rayon decides how many
2235    /// workers there are.** `lanekeep_wasm::WasmRuntime::for_rules` instantiates nothing (it
2236    /// allocates one `None` per component instance the ruleset needs), which is what makes it
2237    /// safe to build from rayon's initializer, since `map_init` runs that per *chunk* rather than
2238    /// per thread. Instantiation then happens in `WasmRuntime::rule`, at most once per component
2239    /// instance per store — several rules of one component share one, which is the point of the
2240    /// rule index the world's exports take.
2241    ///
2242    /// That is a bound per `Worker`, not per thread, and the difference is not small: measured
2243    /// through this engine at ten thousand files times ten rules, **1,038 stores and 10,380
2244    /// instantiations at fourteen threads**, varying between runs because rayon splits on how the
2245    /// work is going. `lanekeep_wasm::runtime::MEMORY_RESERVATION` used to be justified on
2246    /// "roughly three hundred and fifty instantiations, and it does not grow with the corpus";
2247    /// that half is false and its documentation now carries the re-derivation, the crossover, and
2248    /// why the constant is left where it is anyway.
2249    ///
2250    /// **The lever, if this ever needs bounding, is here rather than there.** `with_min_len` on
2251    /// `run_files`'s `par_iter` would cap the store count directly — and it is a bigger change
2252    /// than it looks, because this same initializer builds the QuickJS sandbox and one sandbox
2253    /// per chunk is the more expensive of the two. It would move the JavaScript path's measured
2254    /// behavior, so it needs a benchmark rather than an argument.
2255    wasm: Option<WasmRuntime>,
2256    /// The first component failure this worker saw, if it saw one.
2257    ///
2258    /// A trapped store cannot be entered again, so every file after the first failure would
2259    /// otherwise be reported with wasmtime's own bookkeeping message rather than with what
2260    /// actually went wrong. See [`Worker::poison_on`].
2261    poisoned: Option<RunError>,
2262}
2263
2264impl<'a> Worker<'a> {
2265    fn new(engine: &'a Engine, clock: &Arc<RunClock>) -> Self {
2266        Self {
2267            engine,
2268            clock: Arc::clone(clock),
2269            sandbox: None,
2270            failed: None,
2271            wasm: None,
2272            poisoned: None,
2273        }
2274    }
2275
2276    /// Remember a component failure, and hand it straight back.
2277    ///
2278    /// **A trap poisons the whole store, and the store outlives the file.** `bindgen!` is
2279    /// configured with `imports: { default: trappable }`, so any host refusal — and any guest
2280    /// trap — sets a store-wide flag with no public reset: a later, unrelated call on the same
2281    /// store fails with wasmtime's own `cannot enter component instance`, which names nothing
2282    /// that went wrong. Every such failure already cancels the run, so nothing is *rescued* by
2283    /// noticing; what is rescued is the diagnostic. rayon keeps handing this worker its
2284    /// remaining files, and which of several failures surfaces from the reduction is arbitrary,
2285    /// so without this the run can be reported against a file that was fine and a message that
2286    /// describes the runtime's bookkeeping rather than the rule.
2287    fn poison_on<T>(&mut self, outcome: &Result<T, RunError>) -> Result<T, RunError>
2288    where
2289        T: Clone,
2290    {
2291        match outcome {
2292            Ok(value) => Ok(value.clone()),
2293            Err(error) => {
2294                if self.poisoned.is_none() {
2295                    self.poisoned = Some(error.clone());
2296                }
2297                Err(error.clone())
2298            }
2299        }
2300    }
2301
2302    /// This worker's component runtime, building it if this is the first component rule that
2303    /// needs one.
2304    ///
2305    /// A cached failure, as [`Worker::sandbox`] has one — but for the opposite reason. There it
2306    /// remembers a build that failed so the build is not retried per file; here it remembers a
2307    /// *store* that trapped, because the store cannot be used again and its own account of that
2308    /// is uninformative. See [`Worker::poison_on`].
2309    fn runtime(&mut self) -> Result<&mut WasmRuntime, RunError> {
2310        if let Some(error) = &self.poisoned {
2311            return Err(error.clone());
2312        }
2313
2314        if self.wasm.is_none() {
2315            let components = self
2316                .engine
2317                .components
2318                .as_ref()
2319                .ok_or_else(|| RunError::Worker {
2320                    detail: "a component rule was dispatched in a run that loaded no components"
2321                        .to_owned(),
2322                })?;
2323            self.wasm = Some(WasmRuntime::for_rules(
2324                Arc::clone(components.engine()),
2325                Arc::clone(components.rules()),
2326                self.engine.limits,
2327                Arc::clone(&self.clock),
2328            ));
2329        }
2330
2331        self.wasm.as_mut().ok_or_else(|| RunError::Worker {
2332            detail: "the component runtime was not built".to_owned(),
2333        })
2334    }
2335
2336    /// This worker's sandbox, building it if this is the first rule that needs one.
2337    fn sandbox(&mut self) -> Result<&Sandbox, RunError> {
2338        if let Some(error) = &self.failed {
2339            return Err(error.clone());
2340        }
2341
2342        if self.sandbox.is_none() {
2343            match self.engine.build_sandbox(&self.clock) {
2344                Ok(sandbox) => self.sandbox = Some(sandbox),
2345                Err(error) => {
2346                    self.failed = Some(error.clone());
2347                    return Err(error);
2348                }
2349            }
2350        }
2351
2352        self.sandbox.as_ref().ok_or_else(|| RunError::Worker {
2353            detail: "sandbox was not built".to_owned(),
2354        })
2355    }
2356}
2357
2358/// What checking one file produced.
2359struct FileOutcome {
2360    /// The file this is about, so the run can key dependencies by it.
2361    path: FilePath,
2362    violations: Vec<Violation>,
2363    facts: Vec<Fact>,
2364    /// What this file's rules read beyond it.
2365    reads: Vec<TrackedRead>,
2366    /// The file's suppression directives, for filtering reduce-phase violations.
2367    suppressions: Vec<suppression::Suppression>,
2368    /// Indices of the directives that silenced something.
2369    used_suppressions: Vec<u32>,
2370    /// Whether any rule read `ctx.today` while checking this file.
2371    read_the_date: bool,
2372    /// Per-rule timings, when profiling.
2373    timings: Vec<(RuleId, RuleTiming)>,
2374    /// What to store for this file, when caching is on.
2375    entry: Option<(CacheKey, CacheEntry)>,
2376    /// Whether the file was parsed at all, for the "n files checked" count.
2377    parsed: bool,
2378}
2379
2380impl FileOutcome {
2381    /// A file that never reached a parser — gated out, unreadable, or not UTF-8.
2382    const fn skipped(path: FilePath) -> Self {
2383        Self {
2384            path,
2385            violations: Vec::new(),
2386            facts: Vec::new(),
2387            reads: Vec::new(),
2388            suppressions: Vec::new(),
2389            used_suppressions: Vec::new(),
2390            read_the_date: false,
2391            timings: Vec::new(),
2392            entry: None,
2393            parsed: false,
2394        }
2395    }
2396
2397    const fn parsed(path: FilePath) -> Self {
2398        Self {
2399            path,
2400            violations: Vec::new(),
2401            facts: Vec::new(),
2402            reads: Vec::new(),
2403            suppressions: Vec::new(),
2404            used_suppressions: Vec::new(),
2405            read_the_date: false,
2406            timings: Vec::new(),
2407            entry: None,
2408            parsed: true,
2409        }
2410    }
2411
2412    /// A file whose result came back from the cache.
2413    ///
2414    /// Counted as parsed, because from outside the run it was checked — reporting a warm
2415    /// run as having checked nothing would make the number useless.
2416    fn cached(path: FilePath, key: CacheKey, entry: CacheEntry) -> Self {
2417        Self {
2418            path,
2419            violations: entry.violations.clone(),
2420            facts: entry.facts.clone(),
2421            reads: entry.dependencies.clone(),
2422            suppressions: entry.suppressions.clone(),
2423            used_suppressions: entry.used_suppressions.clone(),
2424            // A cache hit ran no rules, so nothing read the date this time. Whether the
2425            // entry was dated is already settled by the key it was found under.
2426            read_the_date: false,
2427            timings: Vec::new(),
2428            entry: Some((key, entry)),
2429            parsed: true,
2430        }
2431    }
2432
2433    /// A file that no rule's content gates admitted.
2434    fn empty_entry(path: FilePath, key: Option<CacheKey>) -> Self {
2435        Self {
2436            path,
2437            violations: Vec::new(),
2438            facts: Vec::new(),
2439            reads: Vec::new(),
2440            suppressions: Vec::new(),
2441            used_suppressions: Vec::new(),
2442            read_the_date: false,
2443            timings: Vec::new(),
2444            entry: key.map(|key| (key, CacheEntry::default())),
2445            parsed: false,
2446        }
2447    }
2448}
2449
2450/// One file's directives, and which of them silenced something.
2451struct FileDirectives {
2452    suppressions: Vec<suppression::Suppression>,
2453    /// Indices into `suppressions`. Carried from the cache entry on a warm run.
2454    used: Vec<u32>,
2455}
2456
2457/// Which directive silences a violation reported into some other file.
2458///
2459/// A cross-file rule reports at the site a fact came from, so the directives that matter are
2460/// that file's, not the one the rule happened to be reducing over.
2461fn covering_elsewhere(
2462    directives: &BTreeMap<FilePath, FileDirectives>,
2463    violation: &Violation,
2464) -> Option<(FilePath, u32)> {
2465    let found = directives.get(&violation.location.file)?;
2466    let index = found.suppressions.iter().position(|suppression| {
2467        suppression.covers(&violation.rule_id, violation.location.position.line)
2468    })?;
2469
2470    Some((
2471        violation.location.file.clone(),
2472        u32::try_from(index).unwrap_or(u32::MAX),
2473    ))
2474}
2475
2476/// Violations for directives that silenced nothing.
2477///
2478/// A suppression whose violation no longer exists is debt: it documents a decision about
2479/// code that has changed, and the next person to read it has no way to tell it is stale.
2480///
2481/// Reported as warnings rather than errors. Turning on a hygiene report should not fail a
2482/// build that was passing — the point is to show the debt, not to refuse to proceed until it
2483/// is paid.
2484fn unused_violations(directives: &BTreeMap<FilePath, FileDirectives>) -> Vec<Violation> {
2485    let Ok(rule_id) = SUPPRESSION_RULE.parse::<RuleId>() else {
2486        return Vec::new();
2487    };
2488
2489    let mut violations = Vec::new();
2490    for (file, found) in directives {
2491        for (index, suppression) in found.suppressions.iter().enumerate() {
2492            let index = u32::try_from(index).unwrap_or(u32::MAX);
2493            if found.used.contains(&index) {
2494                continue;
2495            }
2496
2497            violations.push(Violation {
2498                rule_id: rule_id.clone(),
2499                location: Location::new(
2500                    file.clone(),
2501                    Position::new(suppression.line, suppression.column),
2502                ),
2503                message: format!("suppression silenced nothing — \"{}\"", suppression.reason),
2504                remediation: String::from(
2505                    "remove it: whatever it was accepting is no longer reported",
2506                ),
2507                severity: Severity::Warn,
2508                fix: None,
2509            });
2510        }
2511    }
2512    violations
2513}
2514
2515/// Load, check and link every component-backed rule, filling in its slot.
2516///
2517/// Returns `None` when no rule names a component, and that is the case worth stating: building
2518/// a [`WasmEngine`] spawns the epoch ticker thread that enforces both wall-clock budgets, so a
2519/// run with no component rule — which is every run this tree can express today — must not build
2520/// one. Nothing is instantiated here either way; an instance belongs to a store and a store
2521/// belongs to a worker.
2522///
2523/// The order is the ruleset's, so a broken component is reported against the first rule in
2524/// config order that has one rather than against whichever load finished first.
2525///
2526/// # Errors
2527///
2528/// Returns [`RunError::Component`] when a component's bytes cannot be read, cannot be compiled,
2529/// reach for an import the sandbox does not permit, or do not satisfy the `rule` world; and
2530/// [`RunError::Worker`] when the runtime itself cannot be built or has bound something the
2531/// cache key does not know about.
2532fn load_components(
2533    rules: &mut [Prepared],
2534    loader: &ComponentLoader,
2535) -> Result<Option<Components>, RunError> {
2536    if rules.iter().all(|rule| rule.spec.component.is_none()) {
2537        return Ok(None);
2538    }
2539
2540    let engine = WasmEngine::new().map_err(|e: WasmError| RunError::Worker {
2541        detail: e.to_string(),
2542    })?;
2543    let mut set = RuleSet::new(&engine).map_err(|e| RunError::Worker {
2544        detail: e.to_string(),
2545    })?;
2546
2547    // **One deserialize per component, not one per rule reference** — the second pass of the §15
2548    // defect. `lanekeep_config::compile_components` already dedups by identity on its own pass;
2549    // this is the engine's own load, at prepare time, which the same four rules re-pay in full.
2550    // The loader is lock-free (`&self`), so the memo is here rather than behind a lock in it,
2551    // keyed on the component's content identity — `blake3::hash` of the bytes, the same digest
2552    // `Loaded::identity` carries and `RuleSet::add` already shares instances on — and not on
2553    // the name, because two different components can share a name across configs. `RuleSet::add`
2554    // shares the instance on that identity, which it already did; the work this skips is the
2555    // deserialize.
2556    let mut memo: HashMap<[u8; 32], lanekeep_wasm::Loaded> = HashMap::new();
2557
2558    for rule in rules.iter_mut() {
2559        let Some(component) = rule.spec.component.clone() else {
2560            continue;
2561        };
2562        let name = rule.spec.id.to_string();
2563        // The bytes the rule carries, not a fresh read of the path beside them. `hash_ruleset`
2564        // folded these exact bytes and `lanekeep-config` read this rule's metadata out of them,
2565        // so executing a second read would let a file that changed in between describe one
2566        // rule, key another and run a third — with every check passing and nothing to notice.
2567        //
2568        // The identity of those bytes — content rather than name, as above — is hashed here to
2569        // look the memo up *before* paying for a load, so a second rule of one component skips
2570        // `load_mapped` entirely. The `Loaded` lives in the memo for this call; `RuleSet::add`
2571        // borrows it, copies the identity and the source map it needs, and returns — so the
2572        // borrow ends before the next iteration mutates the memo. The map drops at the end of
2573        // the call, after `Components::linked` has taken what it keeps.
2574        let identity = *blake3::hash(component.bytes.as_slice()).as_bytes();
2575        // `entry` rather than `contains_key` + `get`: the same lookup answers whether to load
2576        // and hands back the `Loaded` for `RuleSet::add`, so a second rule of one component
2577        // borrows the first rule's load without paying for another deserialize — and without an
2578        // `expect` that this crate's non-test source avoids.
2579        let admitted = match memo.entry(identity) {
2580            std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(),
2581            std::collections::hash_map::Entry::Vacant(entry) => {
2582                let fresh = loader
2583                    .load_mapped(
2584                        &engine,
2585                        &name,
2586                        component.bytes.as_slice(),
2587                        // The map the config carried, not one looked up here. It is only correct
2588                        // for the bundle beside it, and this crate has no way to check that
2589                        // pairing — `lanekeep-config` read both out of one table.
2590                        component.source_map.as_ref().map(ComponentBytes::as_slice),
2591                    )
2592                    .map_err(|e: WasmError| RunError::Component {
2593                        rule: name.clone(),
2594                        detail: e.to_string(),
2595                    })?;
2596                entry.insert(fresh)
2597            }
2598        };
2599        // The options travel with the rule rather than being handed over later, because an
2600        // instance is built lazily per worker: `RuleSet::add` records them and
2601        // `WasmRuntime::rule` hands them to every instance it builds. A configuration step
2602        // performed here instead would reach whichever store this thread happens to hold and
2603        // none of the others, which is a rule answering differently depending on how rayon
2604        // split the corpus.
2605        //
2606        // **The index is the rule, and the component is only where it lives.** A component
2607        // hosts a list, so `lanekeep_config::describe_components` produces one `RuleSpec` per
2608        // rule, each carrying the `ComponentRule::index` its description was read at. Every
2609        // export the world declares takes that index, so it is the whole of what distinguishes
2610        // the programs two rules of one component run — their code is byte-identical. Naming a
2611        // constant here instead would run the same rule under each of its neighbors' ids, with
2612        // the id, the query and the card all correct and only the handler wrong.
2613        let slot = set
2614            .add(&name, admitted, component.index, component.options)
2615            .map_err(|e| RunError::Component {
2616                rule: name,
2617                detail: e.to_string(),
2618            })?;
2619        rule.slot = Some(slot);
2620    }
2621
2622    Components::linked(engine, set).map(Some)
2623}
2624
2625/// Refuse a run that bound an interface the cache key was not computed against.
2626///
2627/// **The half of `EXTERNAL_BINDINGS` that was a signature and is now a check.**
2628/// `RuleSet::linker_mut` takes an [`ExternalBinding`], so nothing reaches the linker without
2629/// naming what fixes its answers — but nothing compared that declaration against
2630/// [`EXTERNAL_BINDINGS`], which is the list `lanekeep_wasm::host_api_hash` actually folds into
2631/// the key. A binding made at the call site and left out of the constant is a run whose rules
2632/// can reach something no cached result knows about, with every key identical.
2633///
2634/// It could not be closed in `lanekeep-wasm`: the key is computed when a configuration is
2635/// loaded, before any `RuleSet` exists. It closes here because this is the first place that
2636/// holds both — a linked set, and the constant the key was built from.
2637///
2638/// Both lists are empty today and the comparison is exact, including order: a declaration is a
2639/// cache-key input, and two runs binding the same interfaces in different orders fold to
2640/// different hashes, so accepting them as equal here would be accepting a key mismatch.
2641fn declared_bindings_match(bound: &[ExternalBinding]) -> Result<(), RunError> {
2642    if bound == EXTERNAL_BINDINGS {
2643        return Ok(());
2644    }
2645
2646    let render = |bindings: &[ExternalBinding]| {
2647        if bindings.is_empty() {
2648            return "nothing".to_owned();
2649        }
2650        bindings
2651            .iter()
2652            .map(|b| format!("`{}` ({})", b.interface(), b.behavior()))
2653            .collect::<Vec<_>>()
2654            .join(", ")
2655    };
2656
2657    Err(RunError::Worker {
2658        detail: format!(
2659            "this run bound {} beside the declared world, and the cache key was computed \
2660             against {}\n  \
2661             a bound interface is a cache-key input: add it to `lanekeep_wasm::EXTERNAL_BINDINGS` \
2662             so a result computed without it is not served to a run that has it",
2663            render(bound),
2664            render(EXTERNAL_BINDINGS),
2665        ),
2666    })
2667}
2668
2669/// Everything about a run that every file's key shares.
2670///
2671/// A named function rather than a call inside [`Engine::prepare`], because it is the one place
2672/// the five run-wide inputs are actually assembled and a value dropped here is dropped from
2673/// every key in the run. Inline it and "the compilation environment reaches a real run's key"
2674/// becomes a claim about a private field of a struct that needs a project on disk to build.
2675///
2676/// # Errors
2677///
2678/// Returns [`RunError::WasmRuntime`] when `wasmtime` cannot describe its own compilation
2679/// environment on this host.
2680fn run_key(
2681    ruleset_hash: &[u8],
2682    config_hash: &[u8],
2683    grammars: &[GrammarKey],
2684) -> Result<RunKey, RunError> {
2685    let compile_env = lanekeep_wasm::compile_env_hash().map_err(|e| RunError::WasmRuntime {
2686        detail: e.to_string(),
2687    })?;
2688
2689    Ok(RunKey::new(
2690        // Major.minor only: a patch release changes nothing a rule can observe, and
2691        // invalidating every cache on one would make patch upgrades expensive for nothing.
2692        engine_version(),
2693        &host_api_hash(),
2694        &compile_env,
2695        ruleset_hash,
2696        config_hash,
2697        grammars,
2698    ))
2699}
2700
2701/// Everything a rule may reach, from both engines, in one cache-key field.
2702///
2703/// This crate is where the two host surfaces meet, so it is where they are folded. QuickJS's
2704/// `ctx` is still a hand-maintained `u32` — `lanekeep_js::HOST_API_VERSION`, whose own
2705/// documentation says nothing detects a missed bump — and a component's surface is
2706/// [`lanekeep_wasm::host_api_hash`], a content hash of the WIT file every binding is generated
2707/// from plus whatever the host binds beside that world.
2708///
2709/// **Both, and not the newer one instead of the older.** Every rule in this tree is still
2710/// TypeScript, so dropping the `ctx` version would take the only host surface a run actually
2711/// uses out of the key: adding a `ctx` function would then serve results computed by a build
2712/// where it did not exist, which is the failure the field exists to prevent. The `u32` leaves
2713/// with the last JavaScript rule and not before.
2714fn host_api_hash() -> [u8; 32] {
2715    fold_host_api(HOST_API_VERSION, &lanekeep_wasm::host_api_hash())
2716}
2717
2718/// The fold, separated from its inputs so a test can vary them.
2719///
2720/// `HOST_API_VERSION` is a `const` and the WIT hash is derived, so neither can be moved in a
2721/// test against the real function — and "both halves are in the key" is exactly the claim that
2722/// is worth nothing unasserted. This is the same reasoning `lanekeep_wasm::key` uses for its
2723/// two folds.
2724fn fold_host_api(ctx_version: u32, wasm_world: &[u8]) -> [u8; 32] {
2725    let mut hasher = blake3::Hasher::new();
2726    hasher.update(b"lanekeep-host-api");
2727    hasher.update(&ctx_version.to_le_bytes());
2728    hasher.update(wasm_world);
2729    *hasher.finalize().as_bytes()
2730}
2731
2732/// The engine version a cache key uses: major.minor only.
2733fn engine_version() -> &'static str {
2734    // Trimmed at the second dot. A patch release changes nothing a rule can observe, so
2735    // invalidating every cache in the world on one would cost users time for nothing.
2736    const FULL: &str = env!("CARGO_PKG_VERSION");
2737    match FULL.match_indices('.').nth(1) {
2738        Some((at, _)) => FULL.split_at(at).0,
2739        None => FULL,
2740    }
2741}
2742
2743/// The id violations about suppressions are reported under.
2744///
2745/// A real namespaced id, so it sorts, suppresses and serializes like any other — and so a
2746/// consumer parsing output does not meet a special case.
2747const SUPPRESSION_RULE: &str = "lanekeep/suppression";
2748
2749/// Position of a rule in the config's `rules` array, which is how the handler is reached.
2750fn rule_index(spec: &RuleSpec) -> usize {
2751    spec.index
2752}
2753
2754/// Quote a capture name for use as an object key.
2755fn json_key(name: &str) -> String {
2756    format!("{name:?}")
2757}
2758
2759/// Convenience for callers that only need a default severity check.
2760#[must_use]
2761pub fn any_failing(violations: &[Violation]) -> bool {
2762    violations.iter().any(|v| v.severity == Severity::Error)
2763}
2764
2765/// Where the rules root sits, given a project root.
2766#[must_use]
2767pub fn rules_root_for(project_root: &Path) -> PathBuf {
2768    project_root.to_path_buf()
2769}
2770
2771#[cfg(test)]
2772mod tests {
2773    use std::fs;
2774
2775    use lanekeep_lang_js::{JavaScript, TypeScript};
2776
2777    use super::*;
2778
2779    #[test]
2780    fn both_host_surfaces_reach_the_cache_key() {
2781        // Two engines, one field. A change to either has the same consequence — a rule could
2782        // not have called something that did not exist — so a fold that dropped one would
2783        // serve stale results for exactly the rules that engine runs.
2784        let base = fold_host_api(1, b"world");
2785        assert_ne!(
2786            base,
2787            fold_host_api(2, b"world"),
2788            "a `ctx` function added to QuickJS must invalidate"
2789        );
2790        assert_ne!(
2791            base,
2792            fold_host_api(1, b"a-wider-world"),
2793            "a function added to the WIT world must invalidate"
2794        );
2795    }
2796
2797    #[test]
2798    fn the_host_api_fold_reads_the_real_world_and_the_real_ctx_version() {
2799        // The fold is only worth testing if the shipped call feeds it the shipped values.
2800        assert_eq!(
2801            host_api_hash(),
2802            fold_host_api(HOST_API_VERSION, &lanekeep_wasm::host_api_hash())
2803        );
2804    }
2805
2806    #[test]
2807    fn the_two_wasm_inputs_reach_a_real_runs_key() {
2808        // Both of the new fields, asserted at the place they are assembled rather than at
2809        // `RunKey`'s door. A hash that is derived correctly and then not passed is the same
2810        // stale-answer bug as one that is never derived, and the tests either side of this one
2811        // pass against exactly that.
2812        let grammars = [GrammarKey {
2813            id: "typescript".to_owned(),
2814            abi: 15,
2815        }];
2816        let content = lanekeep_core::ContentHash::new([7; 32]);
2817        let real = run_key(b"ruleset", b"config", &grammars).expect("the runtime describes itself");
2818
2819        for (label, host_api, compile_env) in [
2820            (
2821                "the WebAssembly compilation environment",
2822                host_api_hash().to_vec(),
2823                Vec::new(),
2824            ),
2825            (
2826                "the host API surface",
2827                Vec::new(),
2828                lanekeep_wasm::compile_env_hash()
2829                    .expect("the runtime describes itself")
2830                    .to_vec(),
2831            ),
2832        ] {
2833            let without = RunKey::new(
2834                engine_version(),
2835                &host_api,
2836                &compile_env,
2837                b"ruleset",
2838                b"config",
2839                &grammars,
2840            );
2841            assert_ne!(
2842                real.for_file("src/a.ts", &content),
2843                without.for_file("src/a.ts", &content),
2844                "{label} must reach the key a run actually files results under"
2845            );
2846        }
2847    }
2848
2849    #[test]
2850    fn all_three_run_budget_breaches_are_worded_identically() {
2851        // `RunError::RunTimeout`'s own documentation says the wording is "deliberately the same
2852        // as both engines'", and until this test that was a claim rather than a fact: the
2853        // string is written out in full in three crates and nothing compared any copy to any
2854        // other. This is the only crate that could — `lanekeep-js` does not depend on
2855        // `lanekeep-wasm`, and `lanekeep-core`, which both depend on, holds no copy to share.
2856        //
2857        // Drift is quiet because which copy a user sees is a race. QuickJS notices from its
2858        // interrupt handler, wasmtime from an epoch check compiled into guest code, and
2859        // `check_file` between one file and the next; on the same corpus under the same budget,
2860        // two runs can be stopped by two different mechanisms. Reword one and lanekeep says two
2861        // different things about one fact, with nothing to say which run gets which.
2862        //
2863        // It is also the text `crates/lanekeep-cli/tests/timeout.rs` matches on to tell a
2864        // global breach from a per-rule one, since every limit exits 2. That test cannot tell
2865        // which copy produced the output it read, so a reworded copy leaves it green against
2866        // the other two.
2867        let budget = Duration::from_millis(250);
2868        let elapsed = Duration::from_millis(1_337);
2869
2870        // Unqualified because `unused_qualifications` is denied and all three are already in
2871        // scope; the crate each comes from is `lanekeep-engine`, `lanekeep-js` and
2872        // `lanekeep-wasm` in that order.
2873        let walker = RunError::RunTimeout { budget, elapsed }.to_string();
2874        let quickjs = SandboxError::RunTimeout { budget, elapsed }.to_string();
2875        let wasm = WasmError::RunTimeout { budget, elapsed }.to_string();
2876
2877        assert_eq!(
2878            walker, quickjs,
2879            "the walker and QuickJS report one breach in two voices"
2880        );
2881        assert_eq!(
2882            walker, wasm,
2883            "the walker and wasmtime report one breach in two voices"
2884        );
2885
2886        // And that what all three agree on is what the CLI's timeout test looks for, rather
2887        // than three copies in perfect agreement about some other text. Both halves: the
2888        // opening is what distinguishes this breach from a per-rule one, and the closing is the
2889        // actionable half — `crates/lanekeep-cli/src/main.rs` records that it was once a lie,
2890        // printed by the code that had dropped the flag it names.
2891        for phrase in ["the run exceeded its", "raise it with `--timeout`"] {
2892            assert!(walker.contains(phrase), "`{phrase}` is gone from: {walker}");
2893        }
2894    }
2895
2896    struct Project {
2897        dir: PathBuf,
2898    }
2899
2900    impl Project {
2901        fn new(name: &str, files: &[(&str, &str)]) -> Self {
2902            let dir = std::env::temp_dir().join(format!("lanekeep-engine-{name}"));
2903            let _ = fs::remove_dir_all(&dir);
2904            fs::create_dir_all(&dir).expect("creates dir");
2905            let project = Self { dir };
2906            for (path, contents) in files {
2907                project.write(path, contents);
2908            }
2909            project
2910        }
2911
2912        fn write(&self, path: &str, contents: &str) {
2913            let full = self.dir.join(path);
2914            if let Some(parent) = full.parent() {
2915                fs::create_dir_all(parent).expect("creates parent");
2916            }
2917            fs::write(full, contents).expect("writes");
2918        }
2919
2920        fn run(&self) -> Result<Outcome, RunError> {
2921            self.prepare_with("lanekeep.config.ts")?.run()
2922        }
2923
2924        /// The engine over the fixture's config under `name`, without running it.
2925        fn prepare_with(&self, name: &str) -> Result<Engine, RunError> {
2926            let root = RuleRoot::new(&self.dir).expect("canonicalizes");
2927            let config_path = self.dir.join(name);
2928
2929            let sandbox =
2930                lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
2931                    .expect("sandbox");
2932            let config = lanekeep_config::load(&sandbox, &root, &config_path)
2933                .unwrap_or_else(|e| panic!("config failed to load: {e}"));
2934
2935            Engine::prepare(
2936                &config,
2937                &self.dir,
2938                root,
2939                &config_path,
2940                &lanekeep_lang_js::registry(),
2941                Arc::new(TypeScript),
2942                Arc::new(JavaScript),
2943            )
2944        }
2945    }
2946
2947    impl Drop for Project {
2948        fn drop(&mut self) {
2949            let _ = fs::remove_dir_all(&self.dir);
2950        }
2951    }
2952
2953    /// A rule reporting every `debugger` statement — small, unambiguous, and easy to seed.
2954    const DEBUGGER_RULE: &str = "import { defineRule } from 'lanekeep';\n\
2955        export default defineRule({\n\
2956          id: 'local/no-debugger',\n\
2957          query: '(debugger_statement) @stmt',\n\
2958          card: {\n\
2959            message: 'debugger statement',\n\
2960            remediation: 'remove it before committing',\n\
2961            examples: { bad: 'debugger;', good: 'console.log(x);' },\n\
2962          },\n\
2963          check(ctx, m) { ctx.report(m.stmt); },\n\
2964        });\n";
2965
2966    /// A rule matching `x.y`, which is the shape that vanishes when JSX fails to parse.
2967    fn member_rule_for(language: &str) -> String {
2968        let declaration = if language.is_empty() {
2969            String::new()
2970        } else {
2971            format!("  language: {language},\n")
2972        };
2973        format!(
2974            "import {{ defineRule }} from 'lanekeep';\n\
2975             export default defineRule({{\n\
2976               id: 'local/member',\n\
2977             {declaration}\
2978               query: '(member_expression) @m',\n\
2979               card: {{\n\
2980                 message: 'member expression',\n\
2981                 remediation: 'n/a',\n\
2982                 examples: {{ bad: 'a.b', good: 'b' }},\n\
2983               }},\n\
2984               check(ctx, m) {{ ctx.report(m.m); }},\n\
2985             }});\n"
2986        )
2987    }
2988
2989    fn config_for(include: &str) -> String {
2990        format!(
2991            "import {{ defineConfig }} from 'lanekeep';\n\
2992             import rule from './rule';\n\
2993             export default defineConfig({{ include: ['{include}'], rules: [rule] }});\n"
2994        )
2995    }
2996
2997    fn config(extra: &str) -> String {
2998        format!(
2999            "import {{ defineConfig }} from 'lanekeep';\n\
3000             import rule from './rule';\n\
3001             export default defineConfig({{ include: ['src/**/*.ts'], rules: [rule]{extra} }});\n"
3002        )
3003    }
3004
3005    /// A rule with no `language` of its own has to see inside JSX.
3006    ///
3007    /// The default used to be `typescript` alone, and the engine parsed every file with the
3008    /// rule's grammar whatever the file was. So a `.tsx` file went through the TypeScript
3009    /// grammar, every JSX element became an `ERROR` node, and a query simply matched nothing
3010    /// inside it — with no error, no warning, and no way to tell from the output. On a React
3011    /// codebase that is most of the code.
3012    #[test]
3013    fn a_default_rule_sees_inside_jsx() {
3014        let project = Project::new(
3015            "jsx-default",
3016            &[
3017                ("rule.ts", &member_rule_for("")),
3018                ("lanekeep.config.ts", &config_for("src/**/*.tsx")),
3019                (
3020                    "src/Component.tsx",
3021                    "export const C = () => <View style={styles.used} />;\n",
3022                ),
3023            ],
3024        );
3025
3026        let outcome = project.run().expect("runs");
3027
3028        assert_eq!(
3029            outcome.violations.len(),
3030            1,
3031            "a member expression inside JSX was not seen: {:?}",
3032            outcome.violations
3033        );
3034    }
3035
3036    /// And the same rule still works on plain TypeScript, each file through its own grammar.
3037    #[test]
3038    fn a_default_rule_still_sees_plain_typescript() {
3039        let project = Project::new(
3040            "ts-default",
3041            &[
3042                ("rule.ts", &member_rule_for("")),
3043                ("lanekeep.config.ts", &config_for("src/**/*.ts")),
3044                ("src/plain.ts", "const x = styles.used;\n"),
3045            ],
3046        );
3047
3048        let outcome = project.run().expect("runs");
3049
3050        assert_eq!(outcome.violations.len(), 1, "{:?}", outcome.violations);
3051    }
3052
3053    /// A rule that names one language is not run on files belonging to another.
3054    ///
3055    /// Previously it was run on everything and the mismatch showed up as an unparsable tree
3056    /// rather than as a skip, which is the failure this whole change is about.
3057    #[test]
3058    fn a_rule_does_not_run_on_a_language_it_does_not_name() {
3059        let project = Project::new(
3060            "single-language",
3061            &[
3062                ("rule.ts", &member_rule_for("'typescript'")),
3063                ("lanekeep.config.ts", &config_for("src/**/*.tsx")),
3064                (
3065                    "src/Component.tsx",
3066                    "export const C = () => <View style={styles.used} />;\n",
3067                ),
3068            ],
3069        );
3070
3071        let outcome = project.run().expect("runs");
3072
3073        assert!(
3074            outcome.violations.is_empty(),
3075            "a typescript-only rule ran on a tsx file: {:?}",
3076            outcome.violations
3077        );
3078    }
3079
3080    /// Naming several languages runs the rule against each, compiled per grammar.
3081    #[test]
3082    fn a_rule_may_name_several_languages() {
3083        let project = Project::new(
3084            "many-languages",
3085            &[
3086                ("rule.ts", &member_rule_for("['typescript', 'tsx']")),
3087                ("lanekeep.config.ts", &config_for("src/**/*.{ts,tsx}")),
3088                ("src/plain.ts", "const x = styles.used;\n"),
3089                (
3090                    "src/Component.tsx",
3091                    "export const C = () => <View style={styles.used} />;\n",
3092                ),
3093            ],
3094        );
3095
3096        let outcome = project.run().expect("runs");
3097
3098        assert_eq!(outcome.violations.len(), 2, "{:?}", outcome.violations);
3099    }
3100
3101    /// An unknown language is still an error, however it is spelled.
3102    #[test]
3103    fn an_unknown_language_in_a_list_is_reported() {
3104        let project = Project::new(
3105            "unknown-in-list",
3106            &[
3107                ("rule.ts", &member_rule_for("['typescript', 'klingon']")),
3108                ("lanekeep.config.ts", &config_for("src/**/*.ts")),
3109                ("src/plain.ts", "const x = styles.used;\n"),
3110            ],
3111        );
3112
3113        let error = project
3114            .run()
3115            .expect_err("should refuse an unknown language");
3116        assert!(
3117            error.to_string().contains("klingon"),
3118            "the error should name it: {error}"
3119        );
3120    }
3121
3122    #[test]
3123    fn runs_a_rule_over_a_corpus_end_to_end() {
3124        let project = Project::new(
3125            "end-to-end",
3126            &[
3127                ("rule.ts", DEBUGGER_RULE),
3128                ("lanekeep.config.ts", &config("")),
3129                ("src/clean.ts", "const a = 1;\n"),
3130                ("src/dirty.ts", "const b = 2;\ndebugger;\n"),
3131                ("src/also.ts", "function f() {\n  debugger;\n}\n"),
3132            ],
3133        );
3134
3135        let outcome = project.run().expect("runs");
3136
3137        assert_eq!(outcome.violations.len(), 2, "{:?}", outcome.violations);
3138        let rendered: Vec<String> = outcome
3139            .violations
3140            .iter()
3141            .map(|v| format!("{} {}", v.rule_id, v.location))
3142            .collect();
3143        assert_eq!(
3144            rendered,
3145            [
3146                "local/no-debugger src/also.ts:2:3",
3147                "local/no-debugger src/dirty.ts:2:1",
3148            ]
3149        );
3150        assert_eq!(outcome.violations[0].message, "debugger statement");
3151        assert_eq!(
3152            outcome.violations[0].remediation,
3153            "remove it before committing"
3154        );
3155    }
3156
3157    #[test]
3158    fn output_is_identical_across_repeated_runs() {
3159        // The guarantee the whole design rests on. Files are checked in parallel, so
3160        // violations arrive in an order that varies run to run; only the sort makes the
3161        // output stable, and it has to hold across many files rather than two.
3162        let mut files = vec![
3163            ("rule.ts".to_owned(), DEBUGGER_RULE.to_owned()),
3164            ("lanekeep.config.ts".to_owned(), config("")),
3165        ];
3166        for i in 0..40 {
3167            files.push((
3168                format!("src/f{i}.ts"),
3169                format!("const x{i} = 1;\ndebugger;\n"),
3170            ));
3171        }
3172        let borrowed: Vec<(&str, &str)> = files
3173            .iter()
3174            .map(|(a, b)| (a.as_str(), b.as_str()))
3175            .collect();
3176        let project = Project::new("determinism", &borrowed);
3177
3178        let first = project.run().expect("runs").violations;
3179        assert_eq!(first.len(), 40);
3180
3181        for _ in 0..4 {
3182            assert_eq!(project.run().expect("runs").violations, first);
3183        }
3184    }
3185
3186    #[test]
3187    fn exclude_keeps_files_out_of_the_run() {
3188        let project = Project::new(
3189            "exclude",
3190            &[
3191                ("rule.ts", DEBUGGER_RULE),
3192                ("lanekeep.config.ts", &config(", exclude: ['**/*.test.ts']")),
3193                ("src/a.ts", "debugger;\n"),
3194                ("src/a.test.ts", "debugger;\n"),
3195            ],
3196        );
3197
3198        let outcome = project.run().expect("runs");
3199        assert_eq!(outcome.violations.len(), 1);
3200        assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
3201    }
3202
3203    #[test]
3204    fn a_content_gate_skips_the_parse() {
3205        // The gate's whole purpose. `files_parsed` is what proves it skipped rather than
3206        // parsed and found nothing — the violation count would look identical either way.
3207        let gated = "import { defineRule } from 'lanekeep';\n\
3208            export default defineRule({\n\
3209              id: 'local/no-debugger',\n\
3210              query: '(debugger_statement) @stmt',\n\
3211              gates: { fileContains: ['debugger'] },\n\
3212              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
3213              check(ctx, m) { ctx.report(m.stmt); },\n\
3214            });\n";
3215
3216        let project = Project::new(
3217            "gate",
3218            &[
3219                ("rule.ts", gated),
3220                ("lanekeep.config.ts", &config("")),
3221                ("src/a.ts", "debugger;\n"),
3222                ("src/b.ts", "const b = 1;\n"),
3223                ("src/c.ts", "const c = 2;\n"),
3224            ],
3225        );
3226
3227        let outcome = project.run().expect("runs");
3228        assert_eq!(outcome.files_discovered, 3);
3229        assert_eq!(
3230            outcome.files_parsed, 1,
3231            "only the file containing the needle should parse"
3232        );
3233        assert_eq!(outcome.violations.len(), 1);
3234    }
3235
3236    #[test]
3237    fn a_rule_set_to_off_does_not_run() {
3238        let project = Project::new(
3239            "off",
3240            &[
3241                ("rule.ts", DEBUGGER_RULE),
3242                (
3243                    "lanekeep.config.ts",
3244                    &config(", severity: { 'local/no-debugger': 'off' }"),
3245                ),
3246                ("src/a.ts", "debugger;\n"),
3247            ],
3248        );
3249        assert!(project.run().expect("runs").violations.is_empty());
3250    }
3251
3252    #[test]
3253    fn severity_reaches_the_violation() {
3254        let project = Project::new(
3255            "severity",
3256            &[
3257                ("rule.ts", DEBUGGER_RULE),
3258                (
3259                    "lanekeep.config.ts",
3260                    &config(", severity: { 'local/no-debugger': 'warn' }"),
3261                ),
3262                ("src/a.ts", "debugger;\n"),
3263            ],
3264        );
3265        let outcome = project.run().expect("runs");
3266        assert_eq!(outcome.violations[0].severity, Severity::Warn);
3267        assert!(!any_failing(&outcome.violations));
3268    }
3269
3270    #[test]
3271    fn a_rule_that_throws_aborts_the_run_naming_itself_and_the_file() {
3272        // §6.8: a breach cancels rather than degrading to a partial result, and the
3273        // diagnostic has to identify the culprit or it is not actionable.
3274        let throwing = "import { defineRule } from 'lanekeep';\n\
3275            export default defineRule({\n\
3276              id: 'local/throws',\n\
3277              query: '(debugger_statement) @stmt',\n\
3278              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
3279              check() { throw new Error('rule bug'); },\n\
3280            });\n";
3281
3282        let project = Project::new(
3283            "throws",
3284            &[
3285                ("rule.ts", throwing),
3286                ("lanekeep.config.ts", &config("")),
3287                ("src/a.ts", "debugger;\n"),
3288            ],
3289        );
3290
3291        let err = project.run().expect_err("must abort");
3292        let rendered = err.to_string();
3293        assert!(rendered.contains("local/throws"), "{rendered}");
3294        assert!(rendered.contains("src/a.ts"), "{rendered}");
3295        assert!(rendered.contains("rule bug"), "{rendered}");
3296    }
3297
3298    #[test]
3299    fn an_invalid_query_fails_before_any_file_is_read() {
3300        let bad = "import { defineRule } from 'lanekeep';\n\
3301            export default defineRule({\n\
3302              id: 'local/bad-query',\n\
3303              query: '(no_such_node) @x',\n\
3304              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
3305              check() {},\n\
3306            });\n";
3307
3308        let project = Project::new(
3309            "bad-query",
3310            &[
3311                ("rule.ts", bad),
3312                ("lanekeep.config.ts", &config("")),
3313                ("src/a.ts", "debugger;\n"),
3314            ],
3315        );
3316
3317        let err = project.run().expect_err("must fail at preparation");
3318        assert!(matches!(err, RunError::Query { .. }), "{err:?}");
3319        assert!(err.to_string().contains("no_such_node"), "{err}");
3320    }
3321
3322    #[test]
3323    fn the_combined_and_per_rule_paths_report_the_same_thing() {
3324        // There are two ways to match a file — one traversal for every rule, or one per
3325        // rule — and `--profile` is what chooses between them. Two paths through the hot
3326        // path is a place for divergence, and divergence here is silent: a rule whose
3327        // matches were handed to the wrong owner, or dropped, reports fewer violations and
3328        // nothing says so.
3329        //
3330        // Rules deliberately share capture names and node kinds. A combined query numbers
3331        // captures across the whole query rather than per pattern, so `@stmt` meaning one
3332        // thing in the third rule and another in the fifth is exactly the confusion an
3333        // owner map has to survive.
3334        let rule = |id: &str, query: &str| {
3335            format!(
3336                "import {{ defineRule }} from 'lanekeep';\n\
3337                 export default defineRule({{\n\
3338                 \x20 id: 'local/{id}',\n\
3339                 \x20 severity: 'error',\n\
3340                 \x20 card: {{ message: '{id}', remediation: 'n/a', \
3341                 examples: {{ bad: 'a', good: 'b' }} }},\n\
3342                 \x20 query: '{query}',\n\
3343                 \x20 check(ctx, m) {{ if (m.stmt) ctx.report(m.stmt); }},\n\
3344                 }});\n"
3345            )
3346        };
3347
3348        let project = Project::new(
3349            "both-paths",
3350            &[
3351                // Two patterns, and first, so pattern indices stop coinciding with rule
3352                // indices. With one pattern per rule the identity map is accidentally
3353                // correct, and a test built that way passes against an engine that ignores
3354                // the owner map entirely — which is how the first version of this test was
3355                // written, and it did.
3356                (
3357                    "rules/a.ts",
3358                    &rule(
3359                        "a",
3360                        "(debugger_statement) @stmt (lexical_declaration) @stmt",
3361                    ),
3362                ),
3363                ("rules/b.ts", &rule("b", "(class_declaration) @stmt")),
3364                ("rules/c.ts", &rule("c", "(debugger_statement) @stmt")),
3365                (
3366                    "rules/d.ts",
3367                    &rule("d", "(call_expression function: (identifier) @fn) @stmt"),
3368                ),
3369                (
3370                    "lanekeep.config.ts",
3371                    "import { defineConfig } from 'lanekeep';\n\
3372                     import a from './rules/a';\n\
3373                     import b from './rules/b';\n\
3374                     import c from './rules/c';\n\
3375                     import d from './rules/d';\n\
3376                     export default defineConfig({\n\
3377                     \x20 include: ['src/**/*.ts'],\n\
3378                     \x20 rules: [a, b, c, d],\n\
3379                     });\n",
3380                ),
3381                (
3382                    "src/one.ts",
3383                    "export class A {\n  go() {\n    debugger;\n    helper();\n  }\n}\n",
3384                ),
3385                (
3386                    "src/two.ts",
3387                    "export class B {}\nexport function f() {\n  other();\n  debugger;\n}\n",
3388                ),
3389                ("src/three.ts", "export const n = 1;\n"),
3390            ],
3391        );
3392
3393        let combined = project
3394            .build()
3395            .map(Engine::without_cache)
3396            .expect("engine")
3397            .run()
3398            .expect("combined run");
3399        let per_rule = project
3400            .build()
3401            .map(Engine::without_cache)
3402            .expect("engine")
3403            .profiling()
3404            .run()
3405            .expect("per-rule run");
3406
3407        assert_eq!(
3408            rendered(&combined),
3409            rendered(&per_rule),
3410            "the shared traversal and the per-rule queries disagree"
3411        );
3412        // Not vacuous: a pair of empty runs would compare equal and assert nothing.
3413        assert!(
3414            combined.violations.len() >= 6,
3415            "the fixture should produce violations from several rules, got {}",
3416            combined.violations.len()
3417        );
3418    }
3419
3420    #[test]
3421    fn every_pattern_in_a_combined_query_is_owned_by_the_rule_that_wrote_it() {
3422        // The map from pattern to rule is positional, so it is only correct if tree-sitter
3423        // numbers patterns in the order they were concatenated. Asserted directly, because
3424        // an off-by-one here does not fail — it hands one rule's matches to its neighbor,
3425        // and both rules keep reporting.
3426        let project = Project::new(
3427            "owner-map",
3428            &[
3429                // Two patterns in one rule, so the mapping cannot be one entry per rule.
3430                (
3431                    "rules/two.ts",
3432                    "import { defineRule } from 'lanekeep';\n\
3433                     export default defineRule({\n\
3434                     \x20 id: 'local/two',\n\
3435                     \x20 severity: 'error',\n\
3436                     \x20 card: { message: 'two', remediation: 'n/a', \
3437                     examples: { bad: 'a', good: 'b' } },\n\
3438                     \x20 query: '(debugger_statement) @stmt (class_declaration) @stmt',\n\
3439                     \x20 check(ctx, m) { if (m.stmt) ctx.report(m.stmt); },\n\
3440                     });\n",
3441                ),
3442                (
3443                    "rules/one.ts",
3444                    "import { defineRule } from 'lanekeep';\n\
3445                     export default defineRule({\n\
3446                     \x20 id: 'local/one',\n\
3447                     \x20 severity: 'error',\n\
3448                     \x20 card: { message: 'one', remediation: 'n/a', \
3449                     examples: { bad: 'a', good: 'b' } },\n\
3450                     \x20 query: '(function_declaration) @stmt',\n\
3451                     \x20 check(ctx, m) { if (m.stmt) ctx.report(m.stmt); },\n\
3452                     });\n",
3453                ),
3454                (
3455                    "lanekeep.config.ts",
3456                    "import { defineConfig } from 'lanekeep';\n\
3457                     import two from './rules/two';\n\
3458                     import one from './rules/one';\n\
3459                     export default defineConfig({\n\
3460                     \x20 include: ['src/**/*.ts'],\n\
3461                     \x20 rules: [two, one],\n\
3462                     });\n",
3463                ),
3464                ("src/a.ts", "export class C {}\n"),
3465            ],
3466        );
3467
3468        let engine = project.build().expect("engine");
3469        let combined = combine_queries(&engine.rules);
3470        let combined = combined
3471            .get("typescript")
3472            .expect("typescript has a combined query");
3473
3474        // Three patterns: two from the first rule, one from the second, in that order.
3475        assert_eq!(combined.owners, vec![0, 0, 1], "{:?}", combined.owners);
3476        assert_eq!(
3477            combined.query().expect("compiles").pattern_count(),
3478            combined.owners.len()
3479        );
3480    }
3481
3482    #[test]
3483    fn two_broken_queries_always_name_the_same_rule() {
3484        // Queries compile in parallel, so which thread finishes first is not fixed. The
3485        // reported error must be the first by *config order* regardless — a project whose
3486        // rules are both broken must not be told about a different one each run, because
3487        // "fix that rule" followed by an error about another one reads as the tool being
3488        // wrong rather than as two problems.
3489        let broken = |name: &str| {
3490            format!(
3491                "import {{ defineRule }} from 'lanekeep';\n\
3492                 export default defineRule({{\n\
3493                   id: 'local/{name}',\n\
3494                   query: '(no_such_node_{name}) @x',\n\
3495                   card: {{ message: 'm', remediation: 'r', examples: {{ bad: 'a', good: 'b' }} }},\n\
3496                   check() {{}},\n\
3497                 }});\n"
3498            )
3499        };
3500
3501        let config = "import { defineConfig } from 'lanekeep';\n\
3502             import first from './first';\n\
3503             import second from './second';\n\
3504             export default defineConfig({ include: ['src/**/*.ts'], rules: [first, second] });\n";
3505
3506        // Repeated, because a race reported once is a race that passes sometimes.
3507        for attempt in 0..12 {
3508            let project = Project::new(
3509                &format!("two-broken-{attempt}"),
3510                &[
3511                    ("first.ts", &broken("first")),
3512                    ("second.ts", &broken("second")),
3513                    ("lanekeep.config.ts", config),
3514                    ("src/a.ts", "debugger;\n"),
3515                ],
3516            );
3517
3518            let err = project.run().expect_err("must fail at preparation");
3519            assert!(
3520                err.to_string().contains("no_such_node_first"),
3521                "attempt {attempt} named the wrong rule: {err}"
3522            );
3523        }
3524    }
3525
3526    #[test]
3527    fn a_rule_can_use_the_host_api_it_was_given() {
3528        // Proves the ctx surface is actually reachable from a real rule, not just from
3529        // the sandbox's own tests.
3530        let rule = "import { defineRule } from 'lanekeep';\n\
3531            export default defineRule({\n\
3532              id: 'local/long-names',\n\
3533              query: '(variable_declarator name: (identifier) @name)',\n\
3534              card: { message: 'name too long', remediation: 'shorten it', examples: { bad: 'a', good: 'b' } },\n\
3535              check(ctx, m) {\n\
3536                if (ctx.text(m.name).length > 5) ctx.report(m.name, `\\\"${ctx.text(m.name)}\\\" is too long`);\n\
3537              },\n\
3538            });\n";
3539
3540        let project = Project::new(
3541            "host-api",
3542            &[
3543                ("rule.ts", rule),
3544                ("lanekeep.config.ts", &config("")),
3545                ("src/a.ts", "const ok = 1;\nconst wayTooLong = 2;\n"),
3546            ],
3547        );
3548
3549        let outcome = project.run().expect("runs");
3550        assert_eq!(outcome.violations.len(), 1);
3551        assert!(
3552            outcome.violations[0].message.contains("wayTooLong"),
3553            "{:?}",
3554            outcome.violations[0]
3555        );
3556    }
3557
3558    #[test]
3559    fn a_corpus_with_no_matches_produces_nothing() {
3560        let project = Project::new(
3561            "clean",
3562            &[
3563                ("rule.ts", DEBUGGER_RULE),
3564                ("lanekeep.config.ts", &config("")),
3565                ("src/a.ts", "const a = 1;\n"),
3566            ],
3567        );
3568        let outcome = project.run().expect("runs");
3569        assert!(outcome.violations.is_empty());
3570        assert_eq!(outcome.files_parsed, 1, "no gates means it is still parsed");
3571    }
3572
3573    // --- the reduce phase ----------------------------------------------------------------
3574
3575    /// A cross-file rule: every exported symbol nobody imports.
3576    ///
3577    /// The smallest rule that genuinely cannot work per-file — whether an export is unused
3578    /// is not a property of the file that declares it.
3579    const UNUSED_EXPORTS_RULE: &str = r"import { defineRule } from 'lanekeep';
3580export default defineRule({
3581  id: 'local/no-unused-exports',
3582  query: `
3583    (export_statement declaration: (function_declaration name: (identifier) @name)) @stmt
3584    (import_statement (import_clause (named_imports (import_specifier name: (identifier) @imported))))
3585  `,
3586  card: {
3587    message: 'unused export',
3588    remediation: 'delete it, or import it somewhere',
3589    examples: { bad: 'export function unused() {}', good: 'function used() {}' },
3590  },
3591  check(ctx, m) {
3592    if (m.imported) {
3593      ctx.emitFact({ kind: 'import', symbol: ctx.text(m.imported) });
3594      return;
3595    }
3596    ctx.emitFact({
3597      kind: 'export',
3598      symbol: ctx.text(m.name),
3599      line: ctx.line(m.stmt),
3600      column: ctx.column(m.stmt),
3601    });
3602  },
3603  reduce(ctx) {
3604    const imported = new Set(ctx.facts('import').map((f) => f.symbol));
3605    for (const e of ctx.facts('export')) {
3606      if (!imported.has(e.symbol)) {
3607        ctx.report({ file: e.file, line: e.line, column: e.column }, `'${e.symbol}' is exported but never imported`);
3608      }
3609    }
3610  },
3611});
3612";
3613
3614    #[test]
3615    fn a_reduce_phase_sees_facts_from_every_file() {
3616        let project = Project::new(
3617            "reduce-cross-file",
3618            &[
3619                ("rule.ts", UNUSED_EXPORTS_RULE),
3620                ("lanekeep.config.ts", &config("")),
3621                (
3622                    "src/a.ts",
3623                    "export function used() {}\nexport function spare() {}\n",
3624                ),
3625                ("src/b.ts", "import { used } from './a';\nused();\n"),
3626            ],
3627        );
3628
3629        let outcome = project.run().expect("runs");
3630        let found: Vec<(&str, u32, &str)> = outcome
3631            .violations
3632            .iter()
3633            .map(|v| {
3634                (
3635                    v.location.file.as_str(),
3636                    v.location.position.line,
3637                    v.message.as_str(),
3638                )
3639            })
3640            .collect();
3641
3642        assert_eq!(
3643            found,
3644            vec![("src/a.ts", 2, "'spare' is exported but never imported")],
3645            "only the export nobody imports should be reported"
3646        );
3647    }
3648
3649    #[test]
3650    fn a_rule_with_no_reduce_still_runs() {
3651        // The common path must not regress: no reduce phase, no sandbox built for one.
3652        let project = Project::new(
3653            "reduce-absent",
3654            &[
3655                ("rule.ts", DEBUGGER_RULE),
3656                ("lanekeep.config.ts", &config("")),
3657                ("src/a.ts", "debugger;\n"),
3658            ],
3659        );
3660        let outcome = project.run().expect("runs");
3661        assert_eq!(outcome.violations.len(), 1);
3662    }
3663
3664    #[test]
3665    fn a_reduce_phase_with_no_facts_reports_nothing() {
3666        let project = Project::new(
3667            "reduce-empty",
3668            &[
3669                ("rule.ts", UNUSED_EXPORTS_RULE),
3670                ("lanekeep.config.ts", &config("")),
3671                ("src/a.ts", "const a = 1;\n"),
3672            ],
3673        );
3674        assert!(project.run().expect("runs").violations.is_empty());
3675    }
3676
3677    #[test]
3678    fn the_file_list_reaches_the_reduce_phase() {
3679        const RULE: &str = r"import { defineRule } from 'lanekeep';
3680export default defineRule({
3681  id: 'local/counts-files',
3682  query: '(debugger_statement) @stmt',
3683  card: {
3684    message: 'file count',
3685    remediation: 'nothing to do',
3686    examples: { bad: 'a', good: 'b' },
3687  },
3688  check() {},
3689  reduce(ctx) {
3690    ctx.report({ file: ctx.files[0], line: ctx.files.length, column: 1 });
3691  },
3692});
3693";
3694        let project = Project::new(
3695            "reduce-files",
3696            &[
3697                ("rule.ts", RULE),
3698                ("lanekeep.config.ts", &config("")),
3699                ("src/a.ts", "const a = 1;\n"),
3700                ("src/b.ts", "const b = 1;\n"),
3701            ],
3702        );
3703
3704        let outcome = project.run().expect("runs");
3705        assert_eq!(outcome.violations.len(), 1);
3706        // Discovery sorts, so `files[0]` is `src/a.ts` on every run and every platform.
3707        assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
3708        assert_eq!(outcome.violations[0].location.position.line, 2);
3709    }
3710
3711    #[test]
3712    fn a_rule_does_not_see_another_rules_facts() {
3713        // Otherwise a payload shape becomes a contract between rules, and the result starts
3714        // depending on the order rules were declared in.
3715        const EMITTER: &str = r"import { defineRule } from 'lanekeep';
3716export default defineRule({
3717  id: 'local/emitter',
3718  query: '(export_statement) @stmt',
3719  card: { message: 'emitter', remediation: 'x', examples: { bad: 'a', good: 'b' } },
3720  check(ctx, m) { ctx.emitFact({ kind: 'thing', from: 'emitter' }); },
3721});
3722";
3723        const READER: &str = r"import { defineRule } from 'lanekeep';
3724export default defineRule({
3725  id: 'local/reader',
3726  query: '(export_statement) @stmt',
3727  card: { message: 'reader', remediation: 'x', examples: { bad: 'a', good: 'b' } },
3728  check() {},
3729  reduce(ctx) {
3730    ctx.report({ file: 'seen.ts', line: ctx.facts().length + 1, column: 1 });
3731  },
3732});
3733";
3734        let project = Project::new(
3735            "reduce-isolation",
3736            &[
3737                ("emitter.ts", EMITTER),
3738                ("reader.ts", READER),
3739                (
3740                    "lanekeep.config.ts",
3741                    "import { defineConfig } from 'lanekeep';\n\
3742                     import emitter from './emitter';\n\
3743                     import reader from './reader';\n\
3744                     export default defineConfig({ include: ['src/**/*.ts'], rules: [emitter, reader] });\n",
3745                ),
3746                ("src/a.ts", "export const a = 1;\n"),
3747            ],
3748        );
3749
3750        let outcome = project.run().expect("runs");
3751        assert_eq!(outcome.violations.len(), 1);
3752        assert_eq!(
3753            outcome.violations[0].location.position.line, 1,
3754            "the reader saw the emitter's facts"
3755        );
3756    }
3757
3758    #[test]
3759    fn a_reduce_phase_that_throws_aborts_the_run() {
3760        // Same posture as a `check` that throws: a partial result reported as a complete
3761        // one is worse than no result.
3762        const RULE: &str = r"import { defineRule } from 'lanekeep';
3763export default defineRule({
3764  id: 'local/throws-in-reduce',
3765  query: '(debugger_statement) @stmt',
3766  card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
3767  check() {},
3768  reduce() { throw new Error('reduce exploded'); },
3769});
3770";
3771        let project = Project::new(
3772            "reduce-throws",
3773            &[
3774                ("rule.ts", RULE),
3775                ("lanekeep.config.ts", &config("")),
3776                ("src/a.ts", "const a = 1;\n"),
3777            ],
3778        );
3779
3780        let error = project.run().expect_err("aborts");
3781        let rendered = error.to_string();
3782        assert!(rendered.contains("reduce exploded"), "{rendered}");
3783        assert!(
3784            rendered.contains("local/throws-in-reduce"),
3785            "the error should name the rule: {rendered}"
3786        );
3787    }
3788
3789    #[test]
3790    fn facts_reach_reduce_in_the_same_order_on_every_run() {
3791        // The determinism invariant at the level a rule can observe: `ctx.facts()` is in
3792        // (file, sequence) order, so a rule that takes the first match — or builds a
3793        // "first seen wins" map — gives the same answer every run.
3794        //
3795        // This asserts the property, not the mechanism. Two things currently produce it,
3796        // rayon's order-preserving `collect` and the explicit sort, so removing either one
3797        // alone leaves this passing. The sort's own coverage is in `lanekeep_core::fact`,
3798        // where shuffled input makes its absence visible.
3799        const RULE: &str = r"import { defineRule } from 'lanekeep';
3800export default defineRule({
3801  id: 'local/first-fact-wins',
3802  query: '(export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name)))',
3803  card: { message: 'first', remediation: 'x', examples: { bad: 'a', good: 'b' } },
3804  check(ctx, m) { ctx.emitFact({ kind: 'sym', symbol: ctx.text(m.name) }); },
3805  reduce(ctx) {
3806    const all = ctx.facts('sym');
3807    ctx.report({ file: 'order.ts', line: 1, column: 1 }, all.map((f) => `${f.file}:${f.symbol}`).join(','));
3808  },
3809});
3810";
3811        let files: Vec<(String, String)> = (0..12)
3812            .map(|i| {
3813                (
3814                    format!("src/f{i:02}.ts"),
3815                    format!("export const s{i:02} = {i};\n"),
3816                )
3817            })
3818            .collect();
3819
3820        let mut layout: Vec<(&str, &str)> = vec![("rule.ts", RULE)];
3821        let config_source = config("");
3822        layout.push(("lanekeep.config.ts", &config_source));
3823        for (path, contents) in &files {
3824            layout.push((path, contents));
3825        }
3826
3827        let project = Project::new("reduce-determinism", &layout);
3828
3829        let first = project.run().expect("runs").violations[0].message.clone();
3830        for attempt in 0..4 {
3831            let again = project.run().expect("runs").violations[0].message.clone();
3832            assert_eq!(again, first, "fact order changed on attempt {attempt}");
3833        }
3834
3835        // And it is the canonical order, not merely a repeatable one.
3836        assert!(
3837            first.starts_with("src/f00.ts:s00,src/f01.ts:s01,"),
3838            "facts are not in (file, sequence) order: {first}"
3839        );
3840    }
3841
3842    #[test]
3843    fn a_rule_cannot_misattribute_a_fact_to_another_file() {
3844        // The host sets `file`, last, so a rule's own `file` key loses to it.
3845        const RULE: &str = r"import { defineRule } from 'lanekeep';
3846export default defineRule({
3847  id: 'local/lying-fact',
3848  query: '(export_statement) @stmt',
3849  card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
3850  check(ctx, m) { ctx.emitFact({ kind: 'e', file: 'somewhere-else.ts' }); },
3851  reduce(ctx) {
3852    for (const f of ctx.facts('e')) ctx.report({ file: f.file, line: 1, column: 1 });
3853  },
3854});
3855";
3856        let project = Project::new(
3857            "reduce-misattribution",
3858            &[
3859                ("rule.ts", RULE),
3860                ("lanekeep.config.ts", &config("")),
3861                ("src/a.ts", "export const a = 1;\n"),
3862            ],
3863        );
3864
3865        let outcome = project.run().expect("runs");
3866        assert_eq!(outcome.violations.len(), 1);
3867        assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
3868    }
3869
3870    // --- tracked reads -------------------------------------------------------------------
3871
3872    /// A rule that reads a sibling file and reports when it says so.
3873    const READING_RULE: &str = r"import { defineRule } from 'lanekeep';
3874export default defineRule({
3875  id: 'local/reads-config',
3876  query: '(export_statement) @stmt',
3877  card: {
3878    message: 'config says no',
3879    remediation: 'change the config, or the code',
3880    examples: { bad: 'export const a = 1;', good: 'const a = 1;' },
3881  },
3882  check(ctx, m) {
3883    const raw = ctx.readFile('policy.json');
3884    if (raw && JSON.parse(raw).forbidExports) ctx.report(m.stmt);
3885  },
3886});
3887";
3888
3889    #[test]
3890    fn a_rule_can_read_another_file() {
3891        let project = Project::new(
3892            "reads-allowed",
3893            &[
3894                ("rule.ts", READING_RULE),
3895                ("lanekeep.config.ts", &config("")),
3896                ("policy.json", r#"{"forbidExports":true}"#),
3897                ("src/a.ts", "export const a = 1;\n"),
3898            ],
3899        );
3900        let outcome = project.run().expect("runs");
3901        assert_eq!(outcome.violations.len(), 1, "{:?}", outcome.violations);
3902    }
3903
3904    #[test]
3905    fn what_the_file_says_changes_the_result() {
3906        // Otherwise the test above would pass on a `readFile` that returned nothing.
3907        let project = Project::new(
3908            "reads-content",
3909            &[
3910                ("rule.ts", READING_RULE),
3911                ("lanekeep.config.ts", &config("")),
3912                ("policy.json", r#"{"forbidExports":false}"#),
3913                ("src/a.ts", "export const a = 1;\n"),
3914            ],
3915        );
3916        assert!(project.run().expect("runs").violations.is_empty());
3917    }
3918
3919    #[test]
3920    fn a_read_is_recorded_against_the_file_that_made_it() {
3921        // The shape a cache entry needs. A dependency recorded against the run, or leaked
3922        // from the previous file on the same worker, would invalidate the wrong entries.
3923        //
3924        // Enough files that workers necessarily handle several each: with only two, rayon
3925        // puts them on separate workers with separate `FileAccess`, and a missing reset
3926        // between files cannot show. `FileAccess::clear` is covered deterministically by
3927        // its own unit test; this covers the engine actually calling it.
3928        let mut layout: Vec<(String, String)> = vec![
3929            ("rule.ts".to_owned(), READING_RULE.to_owned()),
3930            ("lanekeep.config.ts".to_owned(), config("")),
3931            (
3932                "policy.json".to_owned(),
3933                r#"{"forbidExports":false}"#.to_owned(),
3934            ),
3935        ];
3936        // Odd files export and therefore read; even files do neither.
3937        for i in 0..24 {
3938            let body = if i % 2 == 0 {
3939                format!("const v{i} = {i};\n")
3940            } else {
3941                format!("export const v{i} = {i};\n")
3942            };
3943            layout.push((format!("src/f{i:02}.ts"), body));
3944        }
3945        let borrowed: Vec<(&str, &str)> = layout
3946            .iter()
3947            .map(|(p, c)| (p.as_str(), c.as_str()))
3948            .collect();
3949
3950        let project = Project::new("reads-attributed", &borrowed);
3951        let outcome = project.run().expect("runs");
3952
3953        for i in 0..24 {
3954            let file = FilePath::new(format!("src/f{i:02}.ts"));
3955            let deps = outcome.dependencies.get(&file);
3956            if i % 2 == 0 {
3957                assert!(
3958                    deps.is_none(),
3959                    "src/f{i:02}.ts read nothing but has {deps:?}"
3960                );
3961            } else {
3962                let deps = deps.unwrap_or_else(|| panic!("src/f{i:02}.ts should have read"));
3963                assert_eq!(deps.len(), 1);
3964                assert_eq!(deps[0].path.as_str(), "policy.json");
3965                assert!(deps[0].hash.is_some());
3966            }
3967        }
3968    }
3969
3970    #[test]
3971    fn a_missing_file_is_recorded_as_a_dependency_too() {
3972        // The case that makes a cache wrong rather than cold: the answer "not there" has to
3973        // be invalidated when the file appears.
3974        const RULE: &str = r"import { defineRule } from 'lanekeep';
3975export default defineRule({
3976  id: 'local/wants-config',
3977  query: '(export_statement) @stmt',
3978  card: { message: 'no config', remediation: 'add one', examples: { bad: 'a', good: 'b' } },
3979  check(ctx, m) {
3980    if (!ctx.fileExists('tsconfig.json')) ctx.report(m.stmt);
3981  },
3982});
3983";
3984        let project = Project::new(
3985            "reads-absent",
3986            &[
3987                ("rule.ts", RULE),
3988                ("lanekeep.config.ts", &config("")),
3989                ("src/a.ts", "export const a = 1;\n"),
3990            ],
3991        );
3992
3993        let outcome = project.run().expect("runs");
3994        assert_eq!(outcome.violations.len(), 1);
3995
3996        let deps = outcome
3997            .dependencies
3998            .get(&FilePath::new("src/a.ts"))
3999            .expect("the miss is a dependency");
4000        assert_eq!(deps.len(), 1);
4001        assert_eq!(deps[0].path.as_str(), "tsconfig.json");
4002        assert_eq!(deps[0].hash, None, "absence is recorded as absence");
4003    }
4004
4005    #[test]
4006    fn reading_outside_the_project_aborts_the_run() {
4007        // Not a rule that reports nothing: a rule reaching outside the project is a rule
4008        // doing something it must never do, and a run that continued would be reporting a
4009        // result produced by code that tried.
4010        const RULE: &str = r"import { defineRule } from 'lanekeep';
4011export default defineRule({
4012  id: 'local/escapes',
4013  query: '(export_statement) @stmt',
4014  card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
4015  check(ctx) { ctx.readFile('../../../etc/passwd'); },
4016});
4017";
4018        let project = Project::new(
4019            "reads-escape",
4020            &[
4021                ("rule.ts", RULE),
4022                ("lanekeep.config.ts", &config("")),
4023                ("src/a.ts", "export const a = 1;\n"),
4024            ],
4025        );
4026
4027        let error = project.run().expect_err("aborts");
4028        let rendered = error.to_string();
4029        assert!(rendered.contains("outside the project root"), "{rendered}");
4030        assert!(rendered.contains("local/escapes"), "{rendered}");
4031    }
4032
4033    #[test]
4034    fn reading_the_same_file_from_two_files_records_it_under_both() {
4035        let project = Project::new(
4036            "reads-shared",
4037            &[
4038                ("rule.ts", READING_RULE),
4039                ("lanekeep.config.ts", &config("")),
4040                ("policy.json", r#"{"forbidExports":false}"#),
4041                ("src/a.ts", "export const a = 1;\n"),
4042                ("src/b.ts", "export const b = 1;\n"),
4043            ],
4044        );
4045
4046        let outcome = project.run().expect("runs");
4047        for file in ["src/a.ts", "src/b.ts"] {
4048            let deps = outcome
4049                .dependencies
4050                .get(&FilePath::new(file))
4051                .unwrap_or_else(|| panic!("{file} should depend on the policy"));
4052            assert_eq!(deps[0].path.as_str(), "policy.json");
4053        }
4054
4055        // The same bytes, so the same hash — a cache must not see two different
4056        // dependencies on one file.
4057        let a = &outcome.dependencies[&FilePath::new("src/a.ts")][0];
4058        let b = &outcome.dependencies[&FilePath::new("src/b.ts")][0];
4059        assert_eq!(a.hash, b.hash);
4060    }
4061
4062    #[test]
4063    fn dependencies_are_the_same_on_every_run() {
4064        let project = Project::new(
4065            "reads-deterministic",
4066            &[
4067                ("rule.ts", READING_RULE),
4068                ("lanekeep.config.ts", &config("")),
4069                ("policy.json", r#"{"forbidExports":false}"#),
4070                ("src/a.ts", "export const a = 1;\n"),
4071                ("src/b.ts", "export const b = 1;\n"),
4072                ("src/c.ts", "export const c = 1;\n"),
4073            ],
4074        );
4075        let first = project.run().expect("runs").dependencies;
4076        assert!(!first.is_empty());
4077        for attempt in 0..4 {
4078            assert_eq!(
4079                project.run().expect("runs").dependencies,
4080                first,
4081                "dependencies changed on attempt {attempt}"
4082            );
4083        }
4084    }
4085
4086    #[test]
4087    fn the_read_surface_is_absent_from_the_reduce_phase() {
4088        // Reduce reads would be run-level dependencies, not per-file ones, and storing them
4089        // in a per-file entry would attribute them to whichever file came last. Until the
4090        // cache can express that, the functions are not there to be misused.
4091        const RULE: &str = r"import { defineRule } from 'lanekeep';
4092export default defineRule({
4093  id: 'local/reduce-reads',
4094  query: '(export_statement) @stmt',
4095  card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },
4096  check() {},
4097  reduce(ctx) {
4098    const absent = ctx.readFile === undefined && ctx.fileExists === undefined;
4099    ctx.report({ file: 'probe.ts', line: absent ? 1 : 2, column: 1 });
4100  },
4101});
4102";
4103        let project = Project::new(
4104            "reads-reduce",
4105            &[
4106                ("rule.ts", RULE),
4107                ("lanekeep.config.ts", &config("")),
4108                ("src/a.ts", "export const a = 1;\n"),
4109            ],
4110        );
4111
4112        let outcome = project.run().expect("runs");
4113        assert_eq!(outcome.violations.len(), 1);
4114        assert_eq!(
4115            outcome.violations[0].location.position.line, 1,
4116            "reads must not be reachable from a reduce phase"
4117        );
4118    }
4119
4120    // --- the cache -----------------------------------------------------------------------
4121
4122    impl Project {
4123        /// Run with the cache disabled, for comparing against a warm run.
4124        fn run_cold(&self) -> Result<Outcome, RunError> {
4125            self.build().map(Engine::without_cache)?.run()
4126        }
4127
4128        /// The engine over the fixture's `lanekeep.config.ts`, without running it.
4129        fn build(&self) -> Result<Engine, RunError> {
4130            self.prepare_with("lanekeep.config.ts")
4131        }
4132
4133        fn cache(&self) -> Store {
4134            Store::load(&self.dir)
4135        }
4136    }
4137
4138    fn rendered(outcome: &Outcome) -> Vec<String> {
4139        outcome
4140            .violations
4141            .iter()
4142            .map(|v| {
4143                format!(
4144                    "{}:{}:{} {} {}",
4145                    v.location.file.as_str(),
4146                    v.location.position.line,
4147                    v.location.position.column,
4148                    v.rule_id,
4149                    v.message
4150                )
4151            })
4152            .collect()
4153    }
4154
4155    #[test]
4156    fn a_warm_run_agrees_with_a_cold_one() {
4157        let project = Project::new(
4158            "cache-agrees",
4159            &[
4160                ("rule.ts", DEBUGGER_RULE),
4161                ("lanekeep.config.ts", &config("")),
4162                ("src/a.ts", "debugger;\nconst a = 1;\n"),
4163                ("src/b.ts", "const b = 1;\ndebugger;\n"),
4164                ("src/c.ts", "const c = 1;\n"),
4165            ],
4166        );
4167
4168        let cold = rendered(&project.run().expect("runs"));
4169        let warm = rendered(&project.run().expect("runs"));
4170        assert_eq!(warm, cold, "the cache changed the answer");
4171        assert!(!cold.is_empty(), "the fixture should report something");
4172    }
4173
4174    #[test]
4175    fn a_run_writes_a_cache() {
4176        let project = Project::new(
4177            "cache-written",
4178            &[
4179                ("rule.ts", DEBUGGER_RULE),
4180                ("lanekeep.config.ts", &config("")),
4181                ("src/a.ts", "debugger;\n"),
4182            ],
4183        );
4184        assert!(project.cache().is_empty(), "nothing before the first run");
4185        project.run().expect("runs");
4186        assert!(!project.cache().is_empty(), "the run stored nothing");
4187    }
4188
4189    #[test]
4190    fn a_cached_result_is_actually_used() {
4191        // Agreeing with a cold run proves nothing on its own — a cache that was never read
4192        // would agree too. So doctor the stored entry and show the doctored value comes
4193        // back: that can only happen through the cache.
4194        let project = Project::new(
4195            "cache-used",
4196            &[
4197                ("rule.ts", DEBUGGER_RULE),
4198                ("lanekeep.config.ts", &config("")),
4199                ("src/a.ts", "const a = 1;\n"),
4200            ],
4201        );
4202        assert!(project.run().expect("runs").violations.is_empty());
4203
4204        let store = project.cache();
4205        let key = *store
4206            .keys()
4207            .next()
4208            .expect("the run stored an entry for the file");
4209
4210        let mut doctored = Store::empty();
4211        doctored.insert(
4212            key,
4213            lanekeep_cache::Entry {
4214                violations: vec![Violation {
4215                    rule_id: "local/no-debugger".parse().expect("valid id"),
4216                    location: Location::new(FilePath::new("src/a.ts"), Position::new(7, 3)),
4217                    message: "from the cache".to_owned(),
4218                    remediation: "nothing".to_owned(),
4219                    severity: Severity::Error,
4220                    fix: None,
4221                }],
4222                facts: Vec::new(),
4223                dependencies: Vec::new(),
4224                suppressions: Vec::new(),
4225                used_suppressions: Vec::new(),
4226            },
4227        );
4228        doctored.save(&project.dir);
4229
4230        let outcome = project.run().expect("runs");
4231        assert_eq!(
4232            rendered(&outcome),
4233            vec!["src/a.ts:7:3 local/no-debugger from the cache"],
4234            "the cached entry was not used"
4235        );
4236    }
4237
4238    #[test]
4239    fn editing_a_file_invalidates_it() {
4240        let project = Project::new(
4241            "cache-edited",
4242            &[
4243                ("rule.ts", DEBUGGER_RULE),
4244                ("lanekeep.config.ts", &config("")),
4245                ("src/a.ts", "const a = 1;\n"),
4246            ],
4247        );
4248        assert!(project.run().expect("runs").violations.is_empty());
4249
4250        project.write("src/a.ts", "debugger;\n");
4251        assert_eq!(
4252            project.run().expect("runs").violations.len(),
4253            1,
4254            "an edited file kept its stale result"
4255        );
4256    }
4257
4258    #[test]
4259    fn moving_a_file_invalidates_it() {
4260        // Path gates make results path-sensitive, so identical bytes at a new path are not
4261        // a hit. This fixture's rule has no path gate, but the key must not depend on that.
4262        let project = Project::new(
4263            "cache-moved",
4264            &[
4265                ("rule.ts", DEBUGGER_RULE),
4266                ("lanekeep.config.ts", &config("")),
4267                ("src/a.ts", "debugger;\n"),
4268            ],
4269        );
4270        project.run().expect("runs");
4271
4272        fs::remove_file(project.dir.join("src/a.ts")).expect("removes");
4273        project.write("src/moved.ts", "debugger;\n");
4274
4275        let outcome = project.run().expect("runs");
4276        assert_eq!(
4277            outcome.violations[0].location.file.as_str(),
4278            "src/moved.ts",
4279            "the violation followed the old path"
4280        );
4281    }
4282
4283    #[test]
4284    fn editing_a_tracked_dependency_invalidates_the_files_that_read_it() {
4285        // The reason tracked effects exist. Nothing about `src/a.ts` changed, and its result
4286        // still has to be recomputed.
4287        let project = Project::new(
4288            "cache-dependency",
4289            &[
4290                ("rule.ts", READING_RULE),
4291                ("lanekeep.config.ts", &config("")),
4292                ("policy.json", r#"{"forbidExports":false}"#),
4293                ("src/a.ts", "export const a = 1;\n"),
4294            ],
4295        );
4296        assert!(project.run().expect("runs").violations.is_empty());
4297
4298        project.write("policy.json", r#"{"forbidExports":true}"#);
4299        assert_eq!(
4300            project.run().expect("runs").violations.len(),
4301            1,
4302            "a changed dependency did not invalidate"
4303        );
4304    }
4305
4306    #[test]
4307    fn a_dependency_that_appears_invalidates() {
4308        // The case a cache is wrong rather than merely cold without: a rule was told a file
4309        // was absent, and creating it has to reopen the question.
4310        const RULE: &str = r"import { defineRule } from 'lanekeep';
4311export default defineRule({
4312  id: 'local/wants-config',
4313  query: '(export_statement) @stmt',
4314  card: { message: 'no config', remediation: 'add one', examples: { bad: 'a', good: 'b' } },
4315  check(ctx, m) {
4316    if (!ctx.fileExists('tsconfig.json')) ctx.report(m.stmt);
4317  },
4318});
4319";
4320        let project = Project::new(
4321            "cache-appeared",
4322            &[
4323                ("rule.ts", RULE),
4324                ("lanekeep.config.ts", &config("")),
4325                ("src/a.ts", "export const a = 1;\n"),
4326            ],
4327        );
4328        assert_eq!(project.run().expect("runs").violations.len(), 1);
4329
4330        project.write("tsconfig.json", "{}");
4331        assert!(
4332            project.run().expect("runs").violations.is_empty(),
4333            "a dependency that appeared did not invalidate"
4334        );
4335    }
4336
4337    #[test]
4338    fn changing_the_ruleset_invalidates_everything() {
4339        let project = Project::new(
4340            "cache-ruleset",
4341            &[
4342                ("rule.ts", DEBUGGER_RULE),
4343                ("lanekeep.config.ts", &config("")),
4344                ("src/a.ts", "debugger;\n"),
4345            ],
4346        );
4347        assert_eq!(project.run().expect("runs").violations.len(), 1);
4348
4349        // Same file, different rule: it now reports nothing.
4350        project.write(
4351            "rule.ts",
4352            &DEBUGGER_RULE.replace("ctx.report(m.stmt);", "/* nothing */"),
4353        );
4354        assert!(
4355            project.run().expect("runs").violations.is_empty(),
4356            "an edited rule kept its stale results"
4357        );
4358    }
4359
4360    #[test]
4361    fn changing_the_config_invalidates_everything() {
4362        let project = Project::new(
4363            "cache-config",
4364            &[
4365                ("rule.ts", DEBUGGER_RULE),
4366                ("lanekeep.config.ts", &config("")),
4367                ("src/a.ts", "debugger;\n"),
4368            ],
4369        );
4370        assert_eq!(project.run().expect("runs").violations.len(), 1);
4371
4372        project.write(
4373            "lanekeep.config.ts",
4374            &config(", severity: { 'local/no-debugger': 'off' }"),
4375        );
4376        assert!(
4377            project.run().expect("runs").violations.is_empty(),
4378            "a config change did not invalidate"
4379        );
4380    }
4381
4382    #[test]
4383    fn a_corrupt_cache_still_produces_the_right_answer() {
4384        // Disposability, end to end: garbage on disk costs a recompute and nothing else.
4385        let project = Project::new(
4386            "cache-corrupt",
4387            &[
4388                ("rule.ts", DEBUGGER_RULE),
4389                ("lanekeep.config.ts", &config("")),
4390                ("src/a.ts", "debugger;\n"),
4391            ],
4392        );
4393        let expected = rendered(&project.run().expect("runs"));
4394
4395        let path = Store::path_for(&project.dir);
4396        fs::write(&path, b"\x00\x01\x02 not a cache").expect("writes");
4397
4398        assert_eq!(rendered(&project.run().expect("runs")), expected);
4399    }
4400
4401    #[test]
4402    fn caching_can_be_turned_off() {
4403        let project = Project::new(
4404            "cache-off",
4405            &[
4406                ("rule.ts", DEBUGGER_RULE),
4407                ("lanekeep.config.ts", &config("")),
4408                ("src/a.ts", "debugger;\n"),
4409            ],
4410        );
4411        let outcome = project.run_cold().expect("runs");
4412        assert_eq!(outcome.violations.len(), 1);
4413        assert!(
4414            project.cache().is_empty(),
4415            "a run with caching off wrote a cache"
4416        );
4417    }
4418
4419    #[test]
4420    fn facts_survive_a_warm_run() {
4421        // The reduce phase runs every time, over facts that may all have come from the
4422        // cache. A cache that dropped them would make cross-file rules go quiet on the
4423        // second run — reporting on a cold run and nothing on a warm one is the worst
4424        // possible failure, because it looks like the problem was fixed.
4425        let project = Project::new(
4426            "cache-facts",
4427            &[
4428                ("rule.ts", UNUSED_EXPORTS_RULE),
4429                ("lanekeep.config.ts", &config("")),
4430                (
4431                    "src/a.ts",
4432                    "export function used() {}\nexport function spare() {}\n",
4433                ),
4434                ("src/b.ts", "import { used } from './a';\nused();\n"),
4435            ],
4436        );
4437
4438        let cold = rendered(&project.run().expect("runs"));
4439        assert_eq!(cold.len(), 1, "{cold:?}");
4440        assert_eq!(rendered(&project.run().expect("runs")), cold);
4441        assert_eq!(rendered(&project.run().expect("runs")), cold);
4442    }
4443
4444    #[test]
4445    fn a_cache_file_does_not_churn() {
4446        // Byte-identical across runs over unchanged input. A file that rewrote itself every
4447        // run would be a spurious diff for anyone who commits it.
4448        let project = Project::new(
4449            "cache-stable",
4450            &[
4451                ("rule.ts", DEBUGGER_RULE),
4452                ("lanekeep.config.ts", &config("")),
4453                ("src/a.ts", "debugger;\n"),
4454                ("src/b.ts", "const b = 1;\n"),
4455            ],
4456        );
4457        project.run().expect("runs");
4458        let first = fs::read(Store::path_for(&project.dir)).expect("reads");
4459        project.run().expect("runs");
4460        let second = fs::read(Store::path_for(&project.dir)).expect("reads");
4461        assert_eq!(first, second, "the cache file churned");
4462    }
4463
4464    #[test]
4465    fn entries_for_deleted_files_do_not_accumulate() {
4466        let project = Project::new(
4467            "cache-prune",
4468            &[
4469                ("rule.ts", DEBUGGER_RULE),
4470                ("lanekeep.config.ts", &config("")),
4471                ("src/a.ts", "debugger;\n"),
4472                ("src/b.ts", "debugger;\n"),
4473            ],
4474        );
4475        project.run().expect("runs");
4476        assert_eq!(project.cache().len(), 2);
4477
4478        fs::remove_file(project.dir.join("src/b.ts")).expect("removes");
4479        project.run().expect("runs");
4480        assert_eq!(
4481            project.cache().len(),
4482            1,
4483            "an entry outlived the file it was for"
4484        );
4485    }
4486
4487    #[test]
4488    fn a_partial_run_does_not_discard_other_files_entries() {
4489        // `--staged` saving only what it processed would wipe the cache for every file it
4490        // never looked at, leaving the next full run cold — the opposite of what an
4491        // incremental entry point is for.
4492        let project = Project::new(
4493            "cache-partial",
4494            &[
4495                ("rule.ts", DEBUGGER_RULE),
4496                ("lanekeep.config.ts", &config("")),
4497                ("src/a.ts", "debugger;\n"),
4498                ("src/b.ts", "const b = 1;\n"),
4499                ("src/c.ts", "const c = 1;\n"),
4500            ],
4501        );
4502        project.run().expect("runs");
4503        assert_eq!(project.cache().len(), 3);
4504
4505        let engine = project.build().expect("prepares");
4506        engine
4507            .run_over(&[FilePath::new("src/a.ts")])
4508            .expect("runs over one file");
4509
4510        assert_eq!(
4511            project.cache().len(),
4512            3,
4513            "a partial run discarded entries for files it did not look at"
4514        );
4515    }
4516
4517    #[test]
4518    fn a_full_run_still_prunes() {
4519        // The other half: pruning has to keep working, or entries for deleted files
4520        // accumulate forever.
4521        let project = Project::new(
4522            "cache-prune-still",
4523            &[
4524                ("rule.ts", DEBUGGER_RULE),
4525                ("lanekeep.config.ts", &config("")),
4526                ("src/a.ts", "debugger;\n"),
4527                ("src/b.ts", "const b = 1;\n"),
4528            ],
4529        );
4530        project.run().expect("runs");
4531        assert_eq!(project.cache().len(), 2);
4532
4533        fs::remove_file(project.dir.join("src/b.ts")).expect("removes");
4534        project.run().expect("runs");
4535        assert_eq!(project.cache().len(), 1);
4536    }
4537
4538    // --- suppressions ----------------------------------------------------------------------
4539
4540    /// The directive tokens, assembled rather than written.
4541    ///
4542    /// lanekeep checks its own source, and directives are found by scanning bytes rather
4543    /// than by walking comments — so a token written out in a fixture below would be a
4544    /// directive in *this* file too, either reported as malformed or silently silencing its
4545    /// rule for four thousand lines. Assembling it leaves the fixture's bytes exactly as the
4546    /// scanner should see them while this file carries no directive of its own.
4547    const NEXT_LINE: &str = concat!("lanekeep", "-ignore-next-line");
4548
4549    /// The whole-file token. Assembled for the same reason as [`NEXT_LINE`].
4550    const WHOLE_FILE: &str = concat!("lanekeep", "-ignore-file");
4551
4552    impl Project {
4553        /// Run with a fixed date, so an expiry can be asserted without waiting for one.
4554        fn run_on(&self, today: &str) -> Result<Outcome, RunError> {
4555            let date = Date::parse(today).expect("valid date");
4556            self.build().map(|engine| engine.with_today(date))?.run()
4557        }
4558    }
4559
4560    fn messages(outcome: &Outcome) -> Vec<&str> {
4561        outcome
4562            .violations
4563            .iter()
4564            .map(|v| v.message.as_str())
4565            .collect()
4566    }
4567
4568    #[test]
4569    fn a_next_line_directive_silences_the_line_below_it() {
4570        let project = Project::new(
4571            "suppress-next-line",
4572            &[
4573                ("rule.ts", DEBUGGER_RULE),
4574                ("lanekeep.config.ts", &config("")),
4575                (
4576                    "src/a.ts",
4577                    &format!(
4578                        "// {NEXT_LINE} local/no-debugger reason: legacy entry point\n\
4579                         debugger;\n"
4580                    ),
4581                ),
4582            ],
4583        );
4584        assert!(
4585            project.run().expect("runs").violations.is_empty(),
4586            "the directive did not silence the violation"
4587        );
4588    }
4589
4590    #[test]
4591    fn a_directive_silences_only_the_line_it_names() {
4592        let project = Project::new(
4593            "suppress-scope",
4594            &[
4595                ("rule.ts", DEBUGGER_RULE),
4596                ("lanekeep.config.ts", &config("")),
4597                (
4598                    "src/a.ts",
4599                    &format!(
4600                        "// {NEXT_LINE} local/no-debugger reason: legacy\n\
4601                         debugger;\n\
4602                         debugger;\n"
4603                    ),
4604                ),
4605            ],
4606        );
4607        let outcome = project.run().expect("runs");
4608        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
4609        assert_eq!(outcome.violations[0].location.position.line, 3);
4610    }
4611
4612    #[test]
4613    fn a_file_directive_silences_every_line() {
4614        let project = Project::new(
4615            "suppress-file",
4616            &[
4617                ("rule.ts", DEBUGGER_RULE),
4618                ("lanekeep.config.ts", &config("")),
4619                (
4620                    "src/a.ts",
4621                    &format!(
4622                        "// {WHOLE_FILE} local/no-debugger reason: generated fixture\n\
4623                         debugger;\n\
4624                         debugger;\n"
4625                    ),
4626                ),
4627            ],
4628        );
4629        assert!(project.run().expect("runs").violations.is_empty());
4630    }
4631
4632    #[test]
4633    fn a_directive_naming_another_rule_silences_nothing() {
4634        let project = Project::new(
4635            "suppress-other-rule",
4636            &[
4637                ("rule.ts", DEBUGGER_RULE),
4638                ("lanekeep.config.ts", &config("")),
4639                (
4640                    "src/a.ts",
4641                    &format!(
4642                        "// {NEXT_LINE} local/something-else reason: unrelated\n\
4643                         debugger;\n"
4644                    ),
4645                ),
4646            ],
4647        );
4648        assert_eq!(project.run().expect("runs").violations.len(), 1);
4649    }
4650
4651    #[test]
4652    fn a_malformed_directive_is_reported() {
4653        // The failure this exists to prevent: a directive that looks like it works, does
4654        // not, and says nothing. Both the missing reason and the violation it failed to
4655        // suppress have to surface.
4656        let project = Project::new(
4657            "suppress-malformed",
4658            &[
4659                ("rule.ts", DEBUGGER_RULE),
4660                ("lanekeep.config.ts", &config("")),
4661                (
4662                    "src/a.ts",
4663                    &format!("// {NEXT_LINE} local/no-debugger\ndebugger;\n"),
4664                ),
4665            ],
4666        );
4667
4668        let outcome = project.run().expect("runs");
4669        assert_eq!(outcome.violations.len(), 2, "{:?}", messages(&outcome));
4670        assert!(
4671            messages(&outcome)
4672                .iter()
4673                .any(|m| m.contains("no `reason:`")),
4674            "{:?}",
4675            messages(&outcome)
4676        );
4677        assert!(
4678            outcome
4679                .violations
4680                .iter()
4681                .any(|v| v.rule_id.to_string() == "lanekeep/suppression"),
4682            "reported under the wrong id"
4683        );
4684    }
4685
4686    #[test]
4687    fn an_expired_directive_is_reported_and_still_silences() {
4688        // It expired, which is worth saying — but suddenly reporting everything it covered
4689        // would turn a deadline into an avalanche on the day it passed.
4690        let project = Project::new(
4691            "suppress-expired",
4692            &[
4693                ("rule.ts", DEBUGGER_RULE),
4694                ("lanekeep.config.ts", &config("")),
4695                (
4696                    "src/a.ts",
4697                    &format!(
4698                        "// {NEXT_LINE} local/no-debugger reason: pending rewrite expires: 2026-01-01\n\
4699                         debugger;\n"
4700                    ),
4701                ),
4702            ],
4703        );
4704
4705        let outcome = project.run_on("2026-08-01").expect("runs");
4706        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
4707        assert!(
4708            outcome.violations[0]
4709                .message
4710                .contains("expired on 2026-01-01"),
4711            "{:?}",
4712            messages(&outcome)
4713        );
4714        assert!(
4715            outcome.violations[0].message.contains("pending rewrite"),
4716            "the reason should be quoted back: {:?}",
4717            messages(&outcome)
4718        );
4719    }
4720
4721    #[test]
4722    fn a_directive_that_has_not_expired_is_quiet() {
4723        let project = Project::new(
4724            "suppress-unexpired",
4725            &[
4726                ("rule.ts", DEBUGGER_RULE),
4727                ("lanekeep.config.ts", &config("")),
4728                (
4729                    "src/a.ts",
4730                    &format!(
4731                        "// {NEXT_LINE} local/no-debugger reason: pending expires: 2026-12-31\n\
4732                         debugger;\n"
4733                    ),
4734                ),
4735            ],
4736        );
4737        assert!(
4738            project
4739                .run_on("2026-08-01")
4740                .expect("runs")
4741                .violations
4742                .is_empty()
4743        );
4744    }
4745
4746    #[test]
4747    fn a_directive_expires_the_day_after_its_date() {
4748        // On the date itself it still holds: an expiry is a deadline, and a deadline of the
4749        // 31st is not missed on the 31st.
4750        let project = Project::new(
4751            "suppress-boundary",
4752            &[
4753                ("rule.ts", DEBUGGER_RULE),
4754                ("lanekeep.config.ts", &config("")),
4755                (
4756                    "src/a.ts",
4757                    &format!(
4758                        "// {WHOLE_FILE} local/no-debugger reason: x expires: 2026-08-01\n\
4759                         debugger;\n"
4760                    ),
4761                ),
4762            ],
4763        );
4764        assert!(
4765            project
4766                .run_on("2026-08-01")
4767                .expect("runs")
4768                .violations
4769                .is_empty()
4770        );
4771        assert_eq!(
4772            project.run_on("2026-08-02").expect("runs").violations.len(),
4773            1
4774        );
4775    }
4776
4777    #[test]
4778    fn an_expiring_directive_is_not_served_stale_from_the_cache() {
4779        // The cache-soundness case. A file cached the day before expiry must not keep its
4780        // suppressed result the day after — an expiry that a warm run ignored would never
4781        // expire at all, which is the one thing an expiry exists to prevent.
4782        let project = Project::new(
4783            "suppress-cache-date",
4784            &[
4785                ("rule.ts", DEBUGGER_RULE),
4786                ("lanekeep.config.ts", &config("")),
4787                (
4788                    "src/a.ts",
4789                    &format!(
4790                        "// {WHOLE_FILE} local/no-debugger reason: x expires: 2026-08-01\n\
4791                         debugger;\n"
4792                    ),
4793                ),
4794            ],
4795        );
4796
4797        assert!(
4798            project
4799                .run_on("2026-08-01")
4800                .expect("runs")
4801                .violations
4802                .is_empty()
4803        );
4804        let after = project.run_on("2026-08-02").expect("runs");
4805        assert_eq!(
4806            after.violations.len(),
4807            1,
4808            "a warm run served an expired suppression: {:?}",
4809            messages(&after)
4810        );
4811    }
4812
4813    #[test]
4814    fn suppressions_survive_a_warm_run() {
4815        let project = Project::new(
4816            "suppress-warm",
4817            &[
4818                ("rule.ts", DEBUGGER_RULE),
4819                ("lanekeep.config.ts", &config("")),
4820                (
4821                    "src/a.ts",
4822                    &format!("// {WHOLE_FILE} local/no-debugger reason: generated\ndebugger;\n"),
4823                ),
4824            ],
4825        );
4826        assert!(project.run().expect("runs").violations.is_empty());
4827        assert!(
4828            project.run().expect("runs").violations.is_empty(),
4829            "the warm run reported what the cold one suppressed"
4830        );
4831    }
4832
4833    #[test]
4834    fn a_cross_file_violation_is_silenced_by_the_directive_where_it_lands() {
4835        // A reduce-phase violation is reported at the site a fact came from, in a file the
4836        // rule was never "checking" — and possibly one that was a cache hit. The directives
4837        // that matter are that file's.
4838        let project = Project::new(
4839            "suppress-cross-file",
4840            &[
4841                ("rule.ts", UNUSED_EXPORTS_RULE),
4842                ("lanekeep.config.ts", &config("")),
4843                (
4844                    "src/a.ts",
4845                    &format!(
4846                        "export function used() {{}}\n\
4847                         // {NEXT_LINE} local/no-unused-exports reason: public API\n\
4848                         export function spare() {{}}\n"
4849                    ),
4850                ),
4851                ("src/b.ts", "import { used } from './a';\nused();\n"),
4852            ],
4853        );
4854
4855        let outcome = project.run().expect("runs");
4856        assert!(
4857            outcome.violations.is_empty(),
4858            "a cross-file violation ignored the directive at its site: {:?}",
4859            messages(&outcome)
4860        );
4861    }
4862
4863    #[test]
4864    fn a_cross_file_violation_survives_a_directive_for_another_rule() {
4865        let project = Project::new(
4866            "suppress-cross-file-other",
4867            &[
4868                ("rule.ts", UNUSED_EXPORTS_RULE),
4869                ("lanekeep.config.ts", &config("")),
4870                (
4871                    "src/a.ts",
4872                    &format!(
4873                        "export function used() {{}}\n\
4874                         // {NEXT_LINE} local/unrelated reason: x\n\
4875                         export function spare() {{}}\n"
4876                    ),
4877                ),
4878                ("src/b.ts", "import { used } from './a';\nused();\n"),
4879            ],
4880        );
4881        assert_eq!(project.run().expect("runs").violations.len(), 1);
4882    }
4883
4884    // --- unused suppressions ---------------------------------------------------------------
4885
4886    impl Project {
4887        fn run_reporting_unused(&self) -> Result<Outcome, RunError> {
4888            self.build()
4889                .map(Engine::reporting_unused_suppressions)?
4890                .run()
4891        }
4892    }
4893
4894    #[test]
4895    fn a_suppression_that_silenced_nothing_is_reported() {
4896        let project = Project::new(
4897            "unused-reported",
4898            &[
4899                ("rule.ts", DEBUGGER_RULE),
4900                ("lanekeep.config.ts", &config("")),
4901                (
4902                    "src/a.ts",
4903                    &format!(
4904                        "// {NEXT_LINE} local/no-debugger reason: was needed once\n\
4905                         const a = 1;\n"
4906                    ),
4907                ),
4908            ],
4909        );
4910
4911        let outcome = project.run_reporting_unused().expect("runs");
4912        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
4913        assert!(
4914            outcome.violations[0].message.contains("silenced nothing"),
4915            "{:?}",
4916            messages(&outcome)
4917        );
4918        assert!(
4919            outcome.violations[0].message.contains("was needed once"),
4920            "the reason should be quoted back: {:?}",
4921            messages(&outcome)
4922        );
4923    }
4924
4925    #[test]
4926    fn a_suppression_that_did_its_job_is_not_reported() {
4927        let project = Project::new(
4928            "unused-used",
4929            &[
4930                ("rule.ts", DEBUGGER_RULE),
4931                ("lanekeep.config.ts", &config("")),
4932                (
4933                    "src/a.ts",
4934                    &format!("// {NEXT_LINE} local/no-debugger reason: legacy\ndebugger;\n"),
4935                ),
4936            ],
4937        );
4938        assert!(
4939            project
4940                .run_reporting_unused()
4941                .expect("runs")
4942                .violations
4943                .is_empty()
4944        );
4945    }
4946
4947    #[test]
4948    fn unused_suppressions_are_quiet_without_the_flag() {
4949        // Hygiene, on request. It must not appear in everyone's inner loop.
4950        let project = Project::new(
4951            "unused-off",
4952            &[
4953                ("rule.ts", DEBUGGER_RULE),
4954                ("lanekeep.config.ts", &config("")),
4955                (
4956                    "src/a.ts",
4957                    &format!("// {NEXT_LINE} local/no-debugger reason: stale\nconst a = 1;\n"),
4958                ),
4959            ],
4960        );
4961        assert!(project.run().expect("runs").violations.is_empty());
4962    }
4963
4964    #[test]
4965    fn an_unused_suppression_is_a_warning_not_an_error() {
4966        // Turning on a hygiene report must not fail a build that was passing.
4967        let project = Project::new(
4968            "unused-severity",
4969            &[
4970                ("rule.ts", DEBUGGER_RULE),
4971                ("lanekeep.config.ts", &config("")),
4972                (
4973                    "src/a.ts",
4974                    &format!("// {NEXT_LINE} local/no-debugger reason: stale\nconst a = 1;\n"),
4975                ),
4976            ],
4977        );
4978        let outcome = project.run_reporting_unused().expect("runs");
4979        assert_eq!(outcome.violations[0].severity, Severity::Warn);
4980        assert!(!lanekeep_core::any_failing(&outcome.violations));
4981    }
4982
4983    #[test]
4984    fn usage_survives_a_warm_run() {
4985        // The case this needed a cache field for: a warm run sees the survivors and not what
4986        // was hidden, so without the recorded usage every suppression in a cached file would
4987        // suddenly look unused.
4988        let project = Project::new(
4989            "unused-warm",
4990            &[
4991                ("rule.ts", DEBUGGER_RULE),
4992                ("lanekeep.config.ts", &config("")),
4993                (
4994                    "src/a.ts",
4995                    &format!("// {NEXT_LINE} local/no-debugger reason: legacy\ndebugger;\n"),
4996                ),
4997            ],
4998        );
4999
5000        assert!(
5001            project
5002                .run_reporting_unused()
5003                .expect("runs")
5004                .violations
5005                .is_empty()
5006        );
5007        let warm = project.run_reporting_unused().expect("runs");
5008        assert!(
5009            warm.violations.is_empty(),
5010            "a warm run called a used suppression unused: {:?}",
5011            messages(&warm)
5012        );
5013    }
5014
5015    #[test]
5016    fn a_suppression_used_only_by_a_cross_file_rule_is_not_unused() {
5017        // A directive can be the only thing standing between a reduce-phase violation and
5018        // the report. Counting usage only during the per-file pass would call it unused.
5019        let project = Project::new(
5020            "unused-cross-file",
5021            &[
5022                ("rule.ts", UNUSED_EXPORTS_RULE),
5023                ("lanekeep.config.ts", &config("")),
5024                (
5025                    "src/a.ts",
5026                    &format!(
5027                        "export function used() {{}}\n\
5028                         // {NEXT_LINE} local/no-unused-exports reason: public API\n\
5029                         export function spare() {{}}\n"
5030                    ),
5031                ),
5032                ("src/b.ts", "import { used } from './a';\nused();\n"),
5033            ],
5034        );
5035
5036        let outcome = project.run_reporting_unused().expect("runs");
5037        assert!(
5038            outcome.violations.is_empty(),
5039            "a directive used by a cross-file rule was called unused: {:?}",
5040            messages(&outcome)
5041        );
5042    }
5043
5044    #[test]
5045    fn a_malformed_directive_is_not_also_reported_as_unused() {
5046        // It already has a violation saying what is wrong with it. A second one saying it
5047        // silenced nothing would be true, unhelpful, and confusing.
5048        let project = Project::new(
5049            "unused-malformed",
5050            &[
5051                ("rule.ts", DEBUGGER_RULE),
5052                ("lanekeep.config.ts", &config("")),
5053                (
5054                    "src/a.ts",
5055                    &format!("// {NEXT_LINE} local/no-debugger\nconst a = 1;\n"),
5056                ),
5057            ],
5058        );
5059
5060        let outcome = project.run_reporting_unused().expect("runs");
5061        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
5062        assert!(
5063            outcome.violations[0].message.contains("no `reason:`"),
5064            "{:?}",
5065            messages(&outcome)
5066        );
5067    }
5068
5069    // --- the suppression policy ------------------------------------------------------------
5070    //
5071    // The `suppressions` block's three keys, each tested on → violation at the directive's
5072    // position naming the policy, off → silence, on both config formats.
5073
5074    #[test]
5075    fn a_directive_without_an_expiry_is_reported_when_require_expiry_is_on() {
5076        let project = Project::new(
5077            "require-expiry-on",
5078            &[
5079                ("rule.ts", DEBUGGER_RULE),
5080                (
5081                    "lanekeep.config.ts",
5082                    &config(", suppressions: { requireExpiry: true }"),
5083                ),
5084                (
5085                    "src/a.ts",
5086                    &format!("// {NEXT_LINE} local/no-debugger reason: legacy\n debugger;\n"),
5087                ),
5088            ],
5089        );
5090        let outcome = project.run().expect("runs");
5091        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
5092        let v = &outcome.violations[0];
5093        assert_eq!(v.rule_id.to_string(), "lanekeep/suppression");
5094        assert_eq!(v.location.position.line, 1, "reported at the directive");
5095        assert!(
5096            v.message.contains("suppressions.requireExpiry"),
5097            "{}",
5098            v.message
5099        );
5100    }
5101
5102    #[test]
5103    fn a_directive_without_an_expiry_is_quiet_when_require_expiry_is_off() {
5104        let project = Project::new(
5105            "require-expiry-off",
5106            &[
5107                ("rule.ts", DEBUGGER_RULE),
5108                ("lanekeep.config.ts", &config("")),
5109                (
5110                    "src/a.ts",
5111                    &format!("// {NEXT_LINE} local/no-debugger reason: legacy\n debugger;\n"),
5112                ),
5113            ],
5114        );
5115        assert!(project.run().expect("runs").violations.is_empty());
5116    }
5117
5118    #[test]
5119    fn an_expiry_beyond_the_horizon_is_reported_when_max_expiry_days_is_set() {
5120        let project = Project::new(
5121            "horizon-on",
5122            &[
5123                ("rule.ts", DEBUGGER_RULE),
5124                (
5125                    "lanekeep.config.ts",
5126                    &config(", suppressions: { maxExpiryDays: 90 }"),
5127                ),
5128                (
5129                    "src/a.ts",
5130                    &format!(
5131                        "// {NEXT_LINE} local/no-debugger reason: legacy expires: 2026-10-31\n \
5132                         debugger;\n"
5133                    ),
5134                ),
5135            ],
5136        );
5137        let outcome = project.run_on("2026-08-01").expect("runs");
5138        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
5139        assert!(
5140            outcome.violations[0]
5141                .message
5142                .contains("suppressions.maxExpiryDays")
5143        );
5144        assert!(outcome.violations[0].message.contains("2026-10-31"));
5145        assert_eq!(outcome.violations[0].location.position.line, 1);
5146    }
5147
5148    #[test]
5149    fn an_expiry_within_the_horizon_is_quiet() {
5150        // 90 days after 2026-08-01 is exactly 2026-10-30; "more than N days" is strict.
5151        let project = Project::new(
5152            "horizon-off",
5153            &[
5154                ("rule.ts", DEBUGGER_RULE),
5155                (
5156                    "lanekeep.config.ts",
5157                    &config(", suppressions: { maxExpiryDays: 90 }"),
5158                ),
5159                (
5160                    "src/a.ts",
5161                    &format!(
5162                        "// {NEXT_LINE} local/no-debugger reason: legacy expires: 2026-10-30\n \
5163                         debugger;\n"
5164                    ),
5165                ),
5166            ],
5167        );
5168        assert!(
5169            project
5170                .run_on("2026-08-01")
5171                .expect("runs")
5172                .violations
5173                .is_empty()
5174        );
5175    }
5176
5177    #[test]
5178    fn a_file_scope_directive_is_reported_when_forbid_file_scope_is_on() {
5179        let project = Project::new(
5180            "file-scope-on",
5181            &[
5182                ("rule.ts", DEBUGGER_RULE),
5183                (
5184                    "lanekeep.config.ts",
5185                    &config(", suppressions: { forbidFileScope: true }"),
5186                ),
5187                (
5188                    "src/a.ts",
5189                    &format!("// {WHOLE_FILE} local/no-debugger reason: generated\n debugger;\n"),
5190                ),
5191            ],
5192        );
5193        let outcome = project.run().expect("runs");
5194        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
5195        assert!(
5196            outcome.violations[0]
5197                .message
5198                .contains("suppressions.forbidFileScope")
5199        );
5200        assert_eq!(outcome.violations[0].location.position.line, 1);
5201    }
5202
5203    #[test]
5204    fn a_file_scope_directive_is_quiet_when_forbid_file_scope_is_off() {
5205        let project = Project::new(
5206            "file-scope-off",
5207            &[
5208                ("rule.ts", DEBUGGER_RULE),
5209                ("lanekeep.config.ts", &config("")),
5210                (
5211                    "src/a.ts",
5212                    &format!("// {WHOLE_FILE} local/no-debugger reason: generated\n debugger;\n"),
5213                ),
5214            ],
5215        );
5216        assert!(project.run().expect("runs").violations.is_empty());
5217    }
5218
5219    // The JSON path carries the policy into enforcement too — the format drift the config
5220    // layer's matched pairs exist to catch would otherwise leave the JSON path silent.
5221    #[test]
5222    fn a_directive_without_an_expiry_is_reported_when_require_expiry_is_on_for_json() {
5223        let project = Project::new(
5224            "require-expiry-on-json",
5225            &[
5226                ("rule.ts", DEBUGGER_RULE),
5227                (
5228                    "lanekeep.json",
5229                    r#"{"include": ["src/**/*.ts"], "rules": ["./rule"],
5230                       "suppressions": {"requireExpiry": true}}"#,
5231                ),
5232                (
5233                    "src/a.ts",
5234                    &format!("// {NEXT_LINE} local/no-debugger reason: legacy\n debugger;\n"),
5235                ),
5236            ],
5237        );
5238        let outcome = project.run_json().expect("runs");
5239        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
5240        assert!(
5241            outcome.violations[0]
5242                .message
5243                .contains("suppressions.requireExpiry")
5244        );
5245    }
5246
5247    #[test]
5248    fn a_directive_without_an_expiry_is_quiet_when_require_expiry_is_off_for_json() {
5249        let project = Project::new(
5250            "require-expiry-off-json",
5251            &[
5252                ("rule.ts", DEBUGGER_RULE),
5253                (
5254                    "lanekeep.json",
5255                    r#"{"include": ["src/**/*.ts"], "rules": ["./rule"]}"#,
5256                ),
5257                (
5258                    "src/a.ts",
5259                    &format!("// {NEXT_LINE} local/no-debugger reason: legacy\n debugger;\n"),
5260                ),
5261            ],
5262        );
5263        assert!(project.run_json().expect("runs").violations.is_empty());
5264    }
5265
5266    #[test]
5267    fn an_expiry_beyond_the_horizon_is_reported_when_max_expiry_days_is_set_for_json() {
5268        let project = Project::new(
5269            "horizon-on-json",
5270            &[
5271                ("rule.ts", DEBUGGER_RULE),
5272                (
5273                    "lanekeep.json",
5274                    r#"{"include": ["src/**/*.ts"], "rules": ["./rule"],
5275                       "suppressions": {"maxExpiryDays": 90}}"#,
5276                ),
5277                (
5278                    "src/a.ts",
5279                    &format!(
5280                        "// {NEXT_LINE} local/no-debugger reason: legacy expires: 2026-10-31\n \
5281                         debugger;\n"
5282                    ),
5283                ),
5284            ],
5285        );
5286        let date = Date::parse("2026-08-01").expect("valid date");
5287        let outcome = project
5288            .prepare_with("lanekeep.json")
5289            .map(|engine| engine.with_today(date))
5290            .expect("prepares")
5291            .run()
5292            .expect("runs");
5293        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
5294        assert!(
5295            outcome.violations[0]
5296                .message
5297                .contains("suppressions.maxExpiryDays")
5298        );
5299    }
5300
5301    #[test]
5302    fn an_expiry_within_the_horizon_is_quiet_for_json() {
5303        let project = Project::new(
5304            "horizon-off-json",
5305            &[
5306                ("rule.ts", DEBUGGER_RULE),
5307                (
5308                    "lanekeep.json",
5309                    r#"{"include": ["src/**/*.ts"], "rules": ["./rule"],
5310                       "suppressions": {"maxExpiryDays": 90}}"#,
5311                ),
5312                (
5313                    "src/a.ts",
5314                    &format!(
5315                        "// {NEXT_LINE} local/no-debugger reason: legacy expires: 2026-10-30\n \
5316                         debugger;\n"
5317                    ),
5318                ),
5319            ],
5320        );
5321        let date = Date::parse("2026-08-01").expect("valid date");
5322        let outcome = project
5323            .prepare_with("lanekeep.json")
5324            .map(|engine| engine.with_today(date))
5325            .expect("prepares")
5326            .run()
5327            .expect("runs");
5328        assert!(outcome.violations.is_empty(), "{:?}", messages(&outcome));
5329    }
5330
5331    #[test]
5332    fn a_far_future_expiry_is_quiet_without_max_expiry_days_for_json() {
5333        // The "off" direction for `maxExpiryDays`, spelled on the JSON path: no policy block
5334        // at all, an expiry far beyond any horizon — silence, because a key that is off
5335        // reports nothing.
5336        let project = Project::new(
5337            "horizon-absent-json",
5338            &[
5339                ("rule.ts", DEBUGGER_RULE),
5340                (
5341                    "lanekeep.json",
5342                    r#"{"include": ["src/**/*.ts"], "rules": ["./rule"]}"#,
5343                ),
5344                (
5345                    "src/a.ts",
5346                    &format!(
5347                        "// {NEXT_LINE} local/no-debugger reason: legacy expires: 2099-01-01\n \
5348                         debugger;\n"
5349                    ),
5350                ),
5351            ],
5352        );
5353        let date = Date::parse("2026-08-01").expect("valid date");
5354        let outcome = project
5355            .prepare_with("lanekeep.json")
5356            .map(|engine| engine.with_today(date))
5357            .expect("prepares")
5358            .run()
5359            .expect("runs");
5360        assert!(outcome.violations.is_empty(), "{:?}", messages(&outcome));
5361    }
5362
5363    #[test]
5364    fn a_file_scope_directive_is_reported_when_forbid_file_scope_is_on_for_json() {
5365        let project = Project::new(
5366            "file-scope-on-json",
5367            &[
5368                ("rule.ts", DEBUGGER_RULE),
5369                (
5370                    "lanekeep.json",
5371                    r#"{"include": ["src/**/*.ts"], "rules": ["./rule"],
5372                       "suppressions": {"forbidFileScope": true}}"#,
5373                ),
5374                (
5375                    "src/a.ts",
5376                    &format!("// {WHOLE_FILE} local/no-debugger reason: generated\n debugger;\n"),
5377                ),
5378            ],
5379        );
5380        let outcome = project.run_json().expect("runs");
5381        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
5382        assert!(
5383            outcome.violations[0]
5384                .message
5385                .contains("suppressions.forbidFileScope")
5386        );
5387    }
5388
5389    #[test]
5390    fn a_file_scope_directive_is_quiet_when_forbid_file_scope_is_off_for_json() {
5391        let project = Project::new(
5392            "file-scope-off-json",
5393            &[
5394                ("rule.ts", DEBUGGER_RULE),
5395                (
5396                    "lanekeep.json",
5397                    r#"{"include": ["src/**/*.ts"], "rules": ["./rule"]}"#,
5398                ),
5399                (
5400                    "src/a.ts",
5401                    &format!("// {WHOLE_FILE} local/no-debugger reason: generated\n debugger;\n"),
5402                ),
5403            ],
5404        );
5405        assert!(project.run_json().expect("runs").violations.is_empty());
5406    }
5407
5408    #[test]
5409    fn a_directive_with_several_policy_problems_reports_each_deterministically() {
5410        let project = Project::new(
5411            "policy-multi",
5412            &[
5413                ("rule.ts", DEBUGGER_RULE),
5414                (
5415                    "lanekeep.config.ts",
5416                    &config(", suppressions: { requireExpiry: true, forbidFileScope: true }"),
5417                ),
5418                (
5419                    "src/a.ts",
5420                    &format!("// {WHOLE_FILE} local/no-debugger reason: generated\n debugger;\n"),
5421                ),
5422            ],
5423        );
5424        let outcome = project.run().expect("runs");
5425        assert_eq!(outcome.violations.len(), 2, "{:?}", messages(&outcome));
5426        assert!(
5427            outcome.violations[0]
5428                .message
5429                .contains("suppressions.requireExpiry")
5430        );
5431        assert!(
5432            outcome.violations[1]
5433                .message
5434                .contains("suppressions.forbidFileScope")
5435        );
5436    }
5437
5438    #[test]
5439    fn an_expired_and_policy_violating_directive_reports_each_problem_once() {
5440        let project = Project::new(
5441            "expired-and-forbidden",
5442            &[
5443                ("rule.ts", DEBUGGER_RULE),
5444                (
5445                    "lanekeep.config.ts",
5446                    &config(", suppressions: { forbidFileScope: true }"),
5447                ),
5448                (
5449                    "src/a.ts",
5450                    &format!(
5451                        "// {WHOLE_FILE} local/no-debugger reason: legacy expires: 2025-01-01\n \
5452                         debugger;\n"
5453                    ),
5454                ),
5455            ],
5456        );
5457        let outcome = project.run_on("2026-08-01").expect("runs");
5458        assert_eq!(outcome.violations.len(), 2, "{:?}", messages(&outcome));
5459        assert!(
5460            outcome.violations[0].message.contains("expired"),
5461            "{}",
5462            outcome.violations[0].message
5463        );
5464        assert!(
5465            outcome.violations[1]
5466                .message
5467                .contains("suppressions.forbidFileScope")
5468        );
5469    }
5470
5471    #[test]
5472    fn a_directive_naming_suppression_cannot_silence_a_policy_violation() {
5473        // The policy polices, or it is not a policy: `lanekeep/suppression` is exempt from
5474        // suppression entirely, because the violations about directives are emitted after the
5475        // pass that applies them.
5476        let project = Project::new(
5477            "suppression-unsuppressible",
5478            &[
5479                ("rule.ts", DEBUGGER_RULE),
5480                (
5481                    "lanekeep.config.ts",
5482                    &config(", suppressions: { requireExpiry: true }"),
5483                ),
5484                (
5485                    "src/a.ts",
5486                    &format!(
5487                        "// {WHOLE_FILE} lanekeep/suppression reason: policy does not apply to me \
5488                         expires: 2099-01-01\n\
5489                         // {NEXT_LINE} local/no-debugger reason: legacy\n\
5490                         debugger;\n"
5491                    ),
5492                ),
5493            ],
5494        );
5495        let outcome = project.run().expect("runs");
5496        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
5497        let v = &outcome.violations[0];
5498        assert_eq!(v.rule_id.to_string(), "lanekeep/suppression");
5499        assert_eq!(v.location.position.line, 2);
5500        assert!(v.message.contains("suppressions.requireExpiry"));
5501    }
5502
5503    #[test]
5504    fn a_directive_naming_suppression_cannot_silence_a_malformed_directive() {
5505        // Current behavior for malformed-directive reports, established as the baseline the
5506        // policy inherits: a whole-file directive naming `lanekeep/suppression` does not hide
5507        // a malformed directive's report — line 4's report is on a line the whole-file
5508        // directive covers, and it is still there. The debugger is silenced by line 2's own
5509        // valid directive so the malformed report is the only thing left to find.
5510        let project = Project::new(
5511            "malformed-unsuppressible",
5512            &[
5513                ("rule.ts", DEBUGGER_RULE),
5514                ("lanekeep.config.ts", &config("")),
5515                (
5516                    "src/a.ts",
5517                    &format!(
5518                        "// {WHOLE_FILE} lanekeep/suppression reason: does not cover me\n\
5519                         // {NEXT_LINE} local/no-debugger reason: legacy\n\
5520                         debugger;\n\
5521                         // {NEXT_LINE} local/no-debugger\n"
5522                    ),
5523                ),
5524            ],
5525        );
5526        let outcome = project.run().expect("runs");
5527        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
5528        assert_eq!(
5529            outcome.violations[0].rule_id.to_string(),
5530            "lanekeep/suppression"
5531        );
5532        assert_eq!(outcome.violations[0].location.position.line, 4);
5533        assert!(outcome.violations[0].message.contains("no `reason:`"));
5534    }
5535
5536    #[test]
5537    fn a_policy_violation_survives_a_warm_run() {
5538        // Enforcement happens at the same post-cache stage as the directive violations, so the
5539        // cached entry already carries the policy violation and a warm run reports it
5540        // identically — no new key input beyond `config_hash`.
5541        let project = Project::new(
5542            "policy-warm",
5543            &[
5544                ("rule.ts", DEBUGGER_RULE),
5545                (
5546                    "lanekeep.config.ts",
5547                    &config(", suppressions: { requireExpiry: true }"),
5548                ),
5549                (
5550                    "src/a.ts",
5551                    &format!("// {NEXT_LINE} local/no-debugger reason: legacy\n debugger;\n"),
5552                ),
5553            ],
5554        );
5555        let cold = rendered(&project.run().expect("runs"));
5556        let warm = rendered(&project.run().expect("runs"));
5557        assert_eq!(warm, cold, "the cache changed the answer");
5558        assert_eq!(
5559            cold.len(),
5560            1,
5561            "the fixture should report the policy violation"
5562        );
5563    }
5564
5565    // --- ctx.today and the cache -----------------------------------------------------------
5566
5567    /// A rule that reports only when the date it is given starts with a given year.
5568    const DATE_RULE: &str = r"import { defineRule } from 'lanekeep';
5569export default defineRule({
5570  id: 'local/dated',
5571  query: '(export_statement) @stmt',
5572  card: { message: 'dated', remediation: 'x', examples: { bad: 'a', good: 'b' } },
5573  check(ctx, m) {
5574    if (ctx.today.startsWith('2027')) ctx.report(m.stmt, `it is ${ctx.today}`);
5575  },
5576});
5577";
5578
5579    #[test]
5580    fn a_rule_can_read_the_date() {
5581        let project = Project::new(
5582            "today-read",
5583            &[
5584                ("rule.ts", DATE_RULE),
5585                ("lanekeep.config.ts", &config("")),
5586                ("src/a.ts", "export const a = 1;\n"),
5587            ],
5588        );
5589        let outcome = project.run_on("2027-03-04").expect("runs");
5590        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
5591        assert!(outcome.violations[0].message.contains("2027-03-04"));
5592    }
5593
5594    #[test]
5595    fn a_result_that_read_the_date_is_not_served_across_days() {
5596        // The cache-soundness case for `ctx.today`. Without tracking the read, the answer
5597        // computed in 2026 would be served in 2027 forever — a date comparison frozen at
5598        // whenever the cache happened to be written.
5599        let project = Project::new(
5600            "today-cache",
5601            &[
5602                ("rule.ts", DATE_RULE),
5603                ("lanekeep.config.ts", &config("")),
5604                ("src/a.ts", "export const a = 1;\n"),
5605            ],
5606        );
5607
5608        assert!(
5609            project
5610                .run_on("2026-12-31")
5611                .expect("runs")
5612                .violations
5613                .is_empty()
5614        );
5615        let later = project.run_on("2027-01-01").expect("runs");
5616        assert_eq!(
5617            later.violations.len(),
5618            1,
5619            "a warm run served a date-dependent result from another day: {:?}",
5620            messages(&later)
5621        );
5622    }
5623
5624    #[test]
5625    fn a_result_that_ignored_the_date_survives_across_days() {
5626        // The other half, and the reason the read is tracked rather than assumed: dating
5627        // every entry would re-key the whole corpus daily.
5628        //
5629        // Asserted on the stored *bytes*, not the entry count. A re-keyed entry replaces the
5630        // one it supersedes, so the count is identical either way — it was the count I
5631        // reached for first, and it proved nothing.
5632        let project = Project::new(
5633            "today-undated",
5634            &[
5635                ("rule.ts", DEBUGGER_RULE),
5636                ("lanekeep.config.ts", &config("")),
5637                ("src/a.ts", "debugger;\n"),
5638            ],
5639        );
5640
5641        project.run_on("2026-12-31").expect("runs");
5642        let before = fs::read(Store::path_for(&project.dir)).expect("reads");
5643
5644        let outcome = project.run_on("2027-01-01").expect("runs");
5645        assert_eq!(outcome.violations.len(), 1);
5646
5647        let after = fs::read(Store::path_for(&project.dir)).expect("reads");
5648        assert_eq!(
5649            before, after,
5650            "a result that never read the date was re-keyed across days"
5651        );
5652    }
5653
5654    #[test]
5655    fn a_result_that_read_the_date_is_re_keyed_across_days() {
5656        // The converse, on the same evidence. Together these pin both directions: dateless
5657        // entries keep their key, dated ones do not.
5658        let project = Project::new(
5659            "today-dated-key",
5660            &[
5661                ("rule.ts", DATE_RULE),
5662                ("lanekeep.config.ts", &config("")),
5663                ("src/a.ts", "export const a = 1;\n"),
5664            ],
5665        );
5666
5667        project.run_on("2026-12-31").expect("runs");
5668        let before = fs::read(Store::path_for(&project.dir)).expect("reads");
5669
5670        project.run_on("2027-01-01").expect("runs");
5671        let after = fs::read(Store::path_for(&project.dir)).expect("reads");
5672        assert_ne!(
5673            before, after,
5674            "a result that read the date kept its key across days"
5675        );
5676    }
5677
5678    #[test]
5679    fn loc_reaches_a_reduce_phase_through_a_fact() {
5680        // The shape `ctx.loc` exists for: emit it on a fact, report at it later, no glue.
5681        const RULE: &str = r"import { defineRule } from 'lanekeep';
5682export default defineRule({
5683  id: 'local/loc-through-facts',
5684  query: '(export_statement) @stmt',
5685  card: { message: 'via loc', remediation: 'x', examples: { bad: 'a', good: 'b' } },
5686  check(ctx, m) { ctx.emitFact({ kind: 'site', at: ctx.loc(m.stmt) }); },
5687  reduce(ctx) {
5688    for (const f of ctx.facts('site')) ctx.report(f.at, 'reported at a remembered place');
5689  },
5690});
5691";
5692        let project = Project::new(
5693            "loc-facts",
5694            &[
5695                ("rule.ts", RULE),
5696                ("lanekeep.config.ts", &config("")),
5697                ("src/a.ts", "const x = 1;\nexport const a = 1;\n"),
5698            ],
5699        );
5700
5701        let outcome = project.run().expect("runs");
5702        assert_eq!(outcome.violations.len(), 1, "{:?}", messages(&outcome));
5703        assert_eq!(outcome.violations[0].location.file.as_str(), "src/a.ts");
5704        assert_eq!(outcome.violations[0].location.position.line, 2);
5705    }
5706
5707    /// The run's own wall-clock budget, which is spent mostly outside any sandbox.
5708    ///
5709    /// `AGENTS.md` recorded the gap these cover: the budget was polled by QuickJS's interrupt
5710    /// handler and by nothing else, so it only bounded a run *while JavaScript was executing*.
5711    /// Four hundred files against a one-line rule ran to completion under a one-millisecond
5712    /// budget, because §15's cold cost is dominated by Rust-side reading, parsing and query
5713    /// matching and none of that is a place the handler runs.
5714    ///
5715    /// # Why these fixtures are cheap on purpose, when every other budget test is expensive
5716    ///
5717    /// The rest of this repository's limit tests need a rule that burns real bytecode, or they
5718    /// pass because the work was fast rather than because a limit was enforced. Here the
5719    /// requirement is the exact opposite and for the same reason: the handler has to be so
5720    /// cheap that the *only* thing that can stop the run is the check outside it. A rule doing
5721    /// real work would be stopped by the interrupt handler, and the test would pass against
5722    /// the bug.
5723    ///
5724    /// Measured against the commit before this one, this corpus ran to completion: 400 files
5725    /// and 400 `check` invocations in 84 ms under a 1 ms budget, with nothing ever asked to
5726    /// stop. That measurement is what says these cases are not passing for a reason unrelated
5727    /// to the check they are about.
5728    mod run_budget {
5729        use super::*;
5730
5731        /// Enough files that a millisecond cannot cover them.
5732        ///
5733        /// The number from the `AGENTS.md` trap, and the margin is wide rather than tuned: the
5734        /// corpus takes ~84 ms in a debug build, so the budget below is breached roughly eighty
5735        /// times over.
5736        const FILES: usize = 400;
5737
5738        /// A file the rule matches, so a handler really is invoked once per file.
5739        const MATCHED: &str = "export function a() {\n  debugger;\n}\n";
5740
5741        /// `FILES` identical files under one global budget, with `no-debugger` over them.
5742        fn corpus(name: &str, global_ms: u64) -> Project {
5743            let config = config(&format!(", timeouts: {{ global: {global_ms} }}"));
5744            let mut owned: Vec<(String, String)> = vec![
5745                ("rule.ts".to_owned(), DEBUGGER_RULE.to_owned()),
5746                ("lanekeep.config.ts".to_owned(), config),
5747            ];
5748            for i in 0..FILES {
5749                owned.push((format!("src/f{i}.ts"), MATCHED.to_owned()));
5750            }
5751            let borrowed: Vec<(&str, &str)> = owned
5752                .iter()
5753                .map(|(a, b)| (a.as_str(), b.as_str()))
5754                .collect();
5755            Project::new(name, &borrowed)
5756        }
5757
5758        #[test]
5759        fn a_corpus_of_cheap_invocations_is_stopped_by_the_runs_budget() {
5760            let project = corpus("run-budget-lowered", 1);
5761
5762            let error = project
5763                .run_cold()
5764                .expect_err("a run whose budget is spent must not finish the corpus");
5765
5766            // The variant, not the wording, and that is what makes this discriminating. A
5767            // breach the interrupt handler noticed arrives as `RunError::Rule`, naming a rule
5768            // and a file; this one is the walker's own, and it names neither because neither
5769            // is at fault.
5770            assert!(
5771                matches!(error, RunError::RunTimeout { .. }),
5772                "the run had to be stopped between files rather than inside a handler: {error}"
5773            );
5774        }
5775
5776        #[test]
5777        fn the_same_corpus_completes_when_the_budget_is_raised() {
5778            // `AGENTS.md`: a test that only *lowers* a limit passes against a limit that is
5779            // read and then dropped, because the run completes either way. This is the half
5780            // that discriminates — and it is also the control for the case above, since
5781            // without it "the run was stopped" is equally consistent with a corpus that can
5782            // no longer be checked at all.
5783            let project = corpus("run-budget-raised", 60_000);
5784
5785            let outcome = project
5786                .run_cold()
5787                .expect("a minute is ample for four hundred one-line files");
5788            assert_eq!(
5789                outcome.violations.len(),
5790                FILES,
5791                "every file has to have been checked, or the case above stopped nothing"
5792            );
5793        }
5794
5795        /// A rule that throws on the one file whose text says `boom`, and nowhere else.
5796        const SELECTIVE_RULE: &str = "import { defineRule } from 'lanekeep';\n\
5797            export default defineRule({\n\
5798              id: 'local/selective',\n\
5799              query: '(identifier) @id',\n\
5800              card: { message: 'x', remediation: 'y', examples: { bad: 'a', good: 'b' } },\n\
5801              check(ctx, m) { if (ctx.text(m.id) === 'boom') throw new Error('kaboom'); },\n\
5802            });\n";
5803
5804        #[test]
5805        fn an_aborted_run_still_commits_the_files_that_finished() {
5806            // Architecture §6.8: cache entries for files that fully completed are still
5807            // committed, because otherwise a corpus that dies on a cold run dies identically
5808            // on every retry and there is no way to make progress. That mattered little while
5809            // the run budget went unenforced — the run simply finished. It is load-bearing the
5810            // moment the check above exists.
5811            //
5812            // The abort here is a thrown rule rather than a timeout, deliberately: which files
5813            // finish is then a property of the corpus rather than of how fast the machine is.
5814            const GOOD: usize = 40;
5815            let mut owned: Vec<(String, String)> = vec![
5816                ("rule.ts".to_owned(), SELECTIVE_RULE.to_owned()),
5817                ("lanekeep.config.ts".to_owned(), config("")),
5818            ];
5819            for i in 0..GOOD {
5820                owned.push((
5821                    format!("src/f{i}.ts"),
5822                    "export const fine = 1;\n".to_owned(),
5823                ));
5824            }
5825            owned.push((
5826                "src/zzz.ts".to_owned(),
5827                "export const boom = 1;\n".to_owned(),
5828            ));
5829            let borrowed: Vec<(&str, &str)> = owned
5830                .iter()
5831                .map(|(a, b)| (a.as_str(), b.as_str()))
5832                .collect();
5833            let project = Project::new("run-budget-partial-cache", &borrowed);
5834
5835            project.run().expect_err("one file's rule throws");
5836
5837            assert_eq!(
5838                Store::load(&project.dir).len(),
5839                GOOD,
5840                "every file that completed in full has to have an entry, and the one that did \
5841                 not must have none"
5842            );
5843        }
5844
5845        #[test]
5846        fn an_aborted_run_does_not_prune_what_it_never_reached() {
5847            // The other half, and the one that would quietly destroy a cache rather than
5848            // merely fail to fill it. A run that saw the whole corpus may prune, because what
5849            // it produced no entry for no longer exists — that is what ages a deleted file
5850            // out. A run the budget stopped produced entries for a fraction of the corpus and
5851            // never looked at the rest, so saving only what it produced would age out every
5852            // file it never reached, and the next run would be *colder* than the one that
5853            // failed.
5854            let project = corpus("run-budget-no-prune", 60_000);
5855            project.run().expect("a minute is ample");
5856            assert_eq!(
5857                Store::load(&project.dir).len(),
5858                FILES,
5859                "the whole corpus is cached"
5860            );
5861
5862            // The same corpus under a budget it cannot meet. Lowering it changes `config_hash`
5863            // — `timeouts.global` is a cache-key input — so this run is cold as well as short,
5864            // which is the worst case for the save: almost nothing of the corpus is fresh, and
5865            // everything that is already stored belongs to a key this run will never write.
5866            project.write("lanekeep.config.ts", &config(", timeouts: { global: 1 }"));
5867            let error = project.run().expect_err("one millisecond is not enough");
5868            assert!(matches!(error, RunError::RunTimeout { .. }), "{error}");
5869
5870            assert!(
5871                Store::load(&project.dir).len() >= FILES,
5872                "an aborted run pruned entries for files it never reached"
5873            );
5874        }
5875    }
5876
5877    /// The second dispatch path: rules whose handlers are a WebAssembly component.
5878    ///
5879    /// Every test above runs TypeScript rules through QuickJS and keeps doing so, which is what
5880    /// makes this module a check that a path was *added*. The two engines share one corpus, one
5881    /// clock, one read memo per file, and one sorted output.
5882    mod components {
5883        use lanekeep_config::ComponentRule;
5884
5885        use super::*;
5886
5887        /// The rule-shaped fixture, built by `just wasm-fixtures`.
5888        ///
5889        /// Referenced by path rather than `include_bytes!` because the engine's own loader is
5890        /// what is under test — it reads the file, precompiles it into the project's
5891        /// `.lanekeep/components`, and checks its import list before anything can instantiate.
5892        fn fixture() -> PathBuf {
5893            Path::new(env!("CARGO_MANIFEST_DIR"))
5894                .join("../lanekeep-wasm/tests/fixtures/engine-rule.wasm")
5895        }
5896
5897        /// The query the fixture is written against: it reports at `@target`.
5898        const QUERY: &str = "(variable_declarator name: (identifier) @target)";
5899
5900        /// A component at a path, as the field the engine dispatches on.
5901        ///
5902        /// **The bytes are read here, which is where they come from in production too.**
5903        /// `lanekeep-config` reads a component once, at config load, and carries the bytes on
5904        /// the rule; nothing downstream reads the path again, so the rule that was described is
5905        /// the rule that runs. These tests hand-build the spec rather than going through a
5906        /// config, so this is the equivalent read.
5907        ///
5908        /// `"null"` because none of these tests configures the rule: it is the shape the world
5909        /// gives a rule named with no options, and it is what the fixture's `configure`
5910        /// accepts. It is spelled out at every call rather than defaulted, because a component
5911        /// that reaches a worker with nothing recorded for it would be configured with
5912        /// whatever this crate guessed.
5913        ///
5914        /// Built through [`ComponentRule::uncounted`] rather than a struct literal — the field
5915        /// that marks a `ComponentRule` as counted in some `ruleset_hash` is private to
5916        /// `lanekeep-config`, on purpose, so that only `lanekeep_config::load`'s own pipeline can
5917        /// claim it. Every rule built here is attached to a `Config` *after* `load` returns —
5918        /// see `Project::prepared` — so `uncounted` is not a workaround, it is what these tests
5919        /// actually are: `Engine::caching`'s field doc calls this exact shape out as the one
5920        /// still refused.
5921        fn backed_by(path: PathBuf) -> ComponentRule {
5922            let bytes = fs::read(&path).expect("the component is where the test put it");
5923            with_bytes(path, bytes)
5924        }
5925
5926        /// The same, with the bytes chosen — for the cases about a component that cannot run.
5927        fn with_bytes(path: PathBuf, bytes: Vec<u8>) -> ComponentRule {
5928            // Rule `0`: every component these hand-built specs reach hosts exactly one rule, so
5929            // it is the only index there is to name. The engine dispatches on whatever is here
5930            // — `each_rule_of_one_component_runs_the_code_its_own_index_names` is what says so,
5931            // and it goes through a real `lanekeep.json` rather than this helper, because a
5932            // component hosting a list is described rather than hand-built.
5933            ComponentRule::uncounted(path, 0, "null".to_owned(), bytes)
5934        }
5935
5936        /// A `RuleSpec` backed by the fixture component.
5937        ///
5938        /// Built by hand, and that is not a shortcut around anything: `lanekeep-config` can
5939        /// produce one now, and what the engine dispatches on is this field either way, so a
5940        /// hand-built spec exercises exactly the production path without needing a `.wasm`
5941        /// reference in every fixture config.
5942        fn component_rule(id: &str, index: usize, has_reduce: bool) -> RuleSpec {
5943            RuleSpec {
5944                index,
5945                id: id.parse().expect("a well-formed rule id"),
5946                languages: vec!["typescript".to_owned()],
5947                severity: Severity::Error,
5948                card: lanekeep_core::RuleCard {
5949                    message: "a component rule fired".to_owned(),
5950                    remediation: "n/a".to_owned(),
5951                    examples: lanekeep_core::Examples {
5952                        bad: "const x = 1;".to_owned(),
5953                        good: "nothing".to_owned(),
5954                    },
5955                },
5956                queries: BTreeMap::from([("typescript".to_owned(), QUERY.to_owned())]),
5957                gates: lanekeep_core::Gates::default(),
5958                timeout: None,
5959                has_reduce,
5960                component: Some(backed_by(fixture())),
5961            }
5962        }
5963
5964        impl Project {
5965            /// Prepare an engine over this project's config plus some component-backed rules.
5966            ///
5967            /// Fallible variant, for the tests about a component that cannot be loaded.
5968            fn prepared(&self, extra: Vec<RuleSpec>) -> Result<Engine, RunError> {
5969                let root = RuleRoot::new(&self.dir).expect("canonicalizes");
5970                let config_path = self.dir.join("lanekeep.config.ts");
5971                let sandbox =
5972                    lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
5973                        .expect("sandbox");
5974                let mut config = lanekeep_config::load(&sandbox, &root, &config_path)
5975                    .unwrap_or_else(|e| panic!("config failed to load: {e}"));
5976                config.rules.extend(extra);
5977
5978                Engine::prepare(
5979                    &config,
5980                    &self.dir,
5981                    root,
5982                    &config_path,
5983                    &lanekeep_lang_js::registry(),
5984                    Arc::new(TypeScript),
5985                    Arc::new(JavaScript),
5986                )
5987            }
5988
5989            /// The prepared engine, for a test reaching inside it.
5990            fn engine(&self, extra: Vec<RuleSpec>) -> Engine {
5991                self.prepared(extra).expect("prepares")
5992            }
5993
5994            /// Load the project's config, add component-backed rules to it, and run cold.
5995            fn run_with(&self, extra: Vec<RuleSpec>) -> Result<Outcome, RunError> {
5996                self.prepared(extra)?.without_cache().run()
5997            }
5998
5999            /// Copy a `.wasm` fixture into this project, under a path a config can name.
6000            ///
6001            /// A binary copy rather than [`Project::write`], and inside the project rather than
6002            /// referenced where it is built, because `RuleRoot::confine` refuses a rule
6003            /// specifier that leaves the rules root.
6004            fn write_component(&self, at: &str, fixture: &str) {
6005                let from = Path::new(env!("CARGO_MANIFEST_DIR"))
6006                    .join("../lanekeep-wasm/tests/fixtures")
6007                    .join(format!("{fixture}.wasm"));
6008                let full = self.dir.join(at);
6009                if let Some(parent) = full.parent() {
6010                    fs::create_dir_all(parent).expect("creates parent");
6011                }
6012                fs::copy(&from, &full).expect("the fixture ships");
6013            }
6014
6015            /// Load this project's `lanekeep.json` and run it cold, over every language.
6016            ///
6017            /// Two things separate it from [`Project::prepared`], and both are the point rather
6018            /// than convenience. The config is a `lanekeep.json`, because that is the only
6019            /// format that can name a component — so a rule reaching the engine through it was
6020            /// described by `lanekeep_config::describe_components` rather than hand-built here,
6021            /// which is what makes a multi-rule component expressible at all. And the registry
6022            /// is every supported language rather than the JavaScript family, because a
6023            /// component's rules declare whichever language they were written against and the
6024            /// engine refuses a rule naming one it does not know.
6025            ///
6026            /// `pub(super)` because the suppression-policy tests also run a `lanekeep.json`
6027            /// through the engine, and their assertions — violation presence and message text —
6028            /// do not depend on either choice this runner makes, so a second runner with a
6029            /// second set of semantics would be drift waiting to happen.
6030            pub(super) fn run_json(&self) -> Result<Outcome, RunError> {
6031                let root = RuleRoot::new(&self.dir).expect("canonicalizes");
6032                let config_path = self.dir.join("lanekeep.json");
6033                let sandbox =
6034                    lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
6035                        .expect("sandbox");
6036                let config = lanekeep_config::load(&sandbox, &root, &config_path)
6037                    .unwrap_or_else(|e| panic!("config failed to load: {e}"));
6038
6039                Engine::prepare(
6040                    &config,
6041                    &self.dir,
6042                    root,
6043                    &config_path,
6044                    &lanekeep_languages::registry(),
6045                    Arc::new(TypeScript),
6046                    Arc::new(JavaScript),
6047                )?
6048                .without_cache()
6049                .run()
6050            }
6051        }
6052
6053        /// A config declaring the `local` namespace and importing whichever rule modules it is
6054        /// given, in order.
6055        fn config_with(modules: &[&str]) -> String {
6056            let mut imports = String::new();
6057            for (i, m) in modules.iter().enumerate() {
6058                use std::fmt::Write as _;
6059                let _ = writeln!(imports, "import r{i} from '{m}';");
6060            }
6061            let names: Vec<String> = (0..modules.len()).map(|i| format!("r{i}")).collect();
6062            format!(
6063                "import {{ defineConfig }} from 'lanekeep';\n\
6064                 {imports}\
6065                 export default defineConfig({{ include: ['src/**/*.ts'], \
6066                 namespaces: ['local'], rules: [{}] }});\n",
6067                names.join(", ")
6068            )
6069        }
6070
6071        /// A TypeScript rule reporting every `debugger` statement, under a chosen id.
6072        fn debugger_rule(id: &str) -> String {
6073            format!(
6074                "import {{ defineRule }} from 'lanekeep';\n\
6075                 export default defineRule({{\n\
6076                   id: '{id}',\n\
6077                   query: '(debugger_statement) @stmt',\n\
6078                   card: {{ message: 'debugger statement', remediation: 'remove it',\n\
6079                     examples: {{ bad: 'debugger;', good: 'x;' }} }},\n\
6080                   check(ctx, m) {{ ctx.report(m.stmt); }},\n\
6081                 }});\n"
6082            )
6083        }
6084
6085        /// Every violation as `rule|file|line:column|message`, which is what an ordering
6086        /// assertion has to compare.
6087        fn rendered(outcome: &Outcome) -> Vec<String> {
6088            outcome
6089                .violations
6090                .iter()
6091                .map(|v| {
6092                    format!(
6093                        "{}|{}|{}:{}|{}",
6094                        v.rule_id,
6095                        v.location.file,
6096                        v.location.position.line,
6097                        v.location.position.column,
6098                        v.message
6099                    )
6100                })
6101                .collect()
6102        }
6103
6104        #[test]
6105        fn a_component_rule_reports_at_the_node_its_query_captured() {
6106            // The whole dispatch path in one assertion: the query ran in Rust, the captures
6107            // crossed as a WIT `match`, the guest read the node's text through the host, and the
6108            // position on the violation is the one the query found rather than the root.
6109            let rule_a = debugger_rule("local/alpha");
6110            let project = Project::new(
6111                "component-basic",
6112                &[
6113                    ("rule-a.ts", &rule_a),
6114                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6115                    ("src/a.ts", "const alpha = 1;\nconst beta = 2;\n"),
6116                ],
6117            );
6118
6119            let outcome = project
6120                .run_with(vec![component_rule("local/middle", 1, false)])
6121                .expect("runs");
6122
6123            assert_eq!(
6124                rendered(&outcome),
6125                vec![
6126                    "local/middle|src/a.ts|1:7|component saw `alpha`".to_owned(),
6127                    "local/middle|src/a.ts|2:7|component saw `beta`".to_owned(),
6128                ],
6129                "a component rule must report where its query matched"
6130            );
6131        }
6132
6133        #[test]
6134        fn both_engines_run_in_one_corpus_and_feed_one_sorted_output() {
6135            // The property this task creates. Two dispatch paths, three rules, and one order.
6136            //
6137            // The component rule's id sorts *between* the two TypeScript rules and it is
6138            // declared *after* both, so an engine that ran one path and then the other and
6139            // concatenated would put it last. Sorting by `(ruleId, file, line, column)` is what
6140            // makes the two indistinguishable downstream.
6141            let alpha = debugger_rule("local/alpha");
6142            let zeta = debugger_rule("local/zeta");
6143            let project = Project::new(
6144                "component-mixed",
6145                &[
6146                    ("rule-a.ts", &alpha),
6147                    ("rule-z.ts", &zeta),
6148                    (
6149                        "lanekeep.config.ts",
6150                        &config_with(&["./rule-a", "./rule-z"]),
6151                    ),
6152                    ("src/a.ts", "const alpha = 1;\ndebugger;\n"),
6153                    ("src/b.ts", "debugger;\nconst beta = 2;\n"),
6154                ],
6155            );
6156
6157            let outcome = project
6158                .run_with(vec![component_rule("local/middle", 2, false)])
6159                .expect("runs");
6160
6161            assert_eq!(
6162                rendered(&outcome),
6163                vec![
6164                    "local/alpha|src/a.ts|2:1|debugger statement".to_owned(),
6165                    "local/alpha|src/b.ts|1:1|debugger statement".to_owned(),
6166                    "local/middle|src/a.ts|1:7|component saw `alpha`".to_owned(),
6167                    "local/middle|src/b.ts|2:7|component saw `beta`".to_owned(),
6168                    "local/zeta|src/a.ts|2:1|debugger statement".to_owned(),
6169                    "local/zeta|src/b.ts|1:1|debugger statement".to_owned(),
6170                ],
6171                "the two engines' violations must interleave by id, not group by engine"
6172            );
6173        }
6174
6175        #[test]
6176        fn each_rule_of_one_component_runs_the_code_its_own_index_names() {
6177            // **The dispatch, and the one arrangement that can see it.** Every other component
6178            // test here names a fixture hosting a single rule, so rule 0 is the only rule there
6179            // is and an engine that dispatched on the index is indistinguishable from one that
6180            // wrote `0` at the call site. `two-rules` hosts two, whose ids, queries and card
6181            // messages all differ, so running the wrong one is visible rather than plausible.
6182            //
6183            // What each half of a violation comes from is what makes the failure legible. The
6184            // `rule_id` is the *spec's* — the host attributes a report to the rule it invoked
6185            // for — so it is right either way. The message is the *guest's*: `two-rules` writes
6186            // its own id into it, and the capture name it saw. So an engine dispatching on `0`
6187            // reports `fixture/second|…|fixture/first: 1` — rule 1's query, rule 0's code, under
6188            // rule 1's name — which says "the engine ran the wrong rule" and not merely "this
6189            // did not match".
6190            //
6191            // The corpus is mixed and so is the ruleset: the component's rules are Rust and the
6192            // QuickJS rule is TypeScript, and the QuickJS rule is declared *last* while sorting
6193            // *between* the two component rules. So the single sorted output covers all three.
6194            let project = Project::new(
6195                "component-by-index",
6196                &[
6197                    ("middle.ts", &debugger_rule("fixture/middle")),
6198                    (
6199                        "lanekeep.json",
6200                        r#"{"include": ["src/**/*.rs", "src/**/*.ts"],
6201                            "namespaces": ["fixture"],
6202                            "rules": [{"rule": "./rules/two-rules.wasm",
6203                                       "options": {"tag": "alpha"}},
6204                                      "./middle"]}"#,
6205                    ),
6206                    ("src/a.rs", "fn main() {\n    helper();\n}\n"),
6207                    ("src/b.ts", "debugger;\n"),
6208                ],
6209            );
6210            project.write_component("rules/two-rules.wasm", "two-rules");
6211
6212            let outcome = project.run_json().expect("runs");
6213
6214            assert_eq!(
6215                rendered(&outcome),
6216                vec![
6217                    "fixture/first|src/a.rs|1:1|fixture/first: 0".to_owned(),
6218                    "fixture/middle|src/b.ts|1:1|debugger statement".to_owned(),
6219                    "fixture/second|src/a.rs|1:1|fixture/second: 1".to_owned(),
6220                ],
6221                "each rule of a component must run the code its own index names, and all three \
6222                 must land in one order"
6223            );
6224
6225            // And the config described each of them as itself. The remediation is the spec's
6226            // side of the same claim the message makes from the guest's side — it comes from
6227            // the card `metadata(index)` returned, so two rules collapsing into one description
6228            // would show here even if dispatch were right.
6229            let remediations: Vec<&str> = outcome
6230                .violations
6231                .iter()
6232                .map(|v| v.remediation.as_str())
6233                .collect();
6234            assert_eq!(
6235                remediations,
6236                [
6237                    "fixture/first remediation",
6238                    "remove it",
6239                    "fixture/second remediation"
6240                ]
6241            );
6242        }
6243
6244        #[test]
6245        fn a_mixed_run_is_byte_identical_to_itself() {
6246            // Determinism across the two paths, which is the invariant a second engine is most
6247            // likely to break: rayon assigns files to workers differently between runs, and the
6248            // component path adds a second source of per-worker state.
6249            let alpha = debugger_rule("local/alpha");
6250            let project = Project::new(
6251                "component-deterministic",
6252                &[
6253                    ("rule-a.ts", &alpha),
6254                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6255                    ("src/a.ts", "const alpha = 1;\ndebugger;\n"),
6256                    ("src/b.ts", "const beta = 2;\n"),
6257                    ("src/c.ts", "const gamma = 3;\ndebugger;\n"),
6258                    ("src/d.ts", "const delta = 4;\n"),
6259                ],
6260            );
6261
6262            let first = rendered(
6263                &project
6264                    .run_with(vec![component_rule("local/middle", 1, true)])
6265                    .expect("runs"),
6266            );
6267            assert!(!first.is_empty(), "the fixture corpus produces violations");
6268
6269            for round in 1..8 {
6270                let again = rendered(
6271                    &project
6272                        .run_with(vec![component_rule("local/middle", 1, true)])
6273                        .expect("runs"),
6274                );
6275                assert_eq!(again, first, "run {round} disagreed with the first");
6276            }
6277        }
6278
6279        #[test]
6280        fn a_component_rules_facts_carry_their_file_in_the_field_and_not_in_the_payload() {
6281            // The engine-side duplicate-key hazard, and the only place it is visible.
6282            //
6283            // `lanekeep-js`'s reduce phase splices `"file"` into a fact's payload, because its
6284            // `ReduceFact` carries no file of its own. The world's `emitted-fact` has a `file`
6285            // field, so the component path fills that instead — and an engine that did both
6286            // would send a payload with two `"file"` keys. Nothing host-side would notice: the
6287            // host forwards `data` exactly as the guest wrote it. The fixture reports every
6288            // fact back as `kind|file|data`, which is what makes the payload assertable.
6289            let alpha = debugger_rule("local/alpha");
6290            let project = Project::new(
6291                "component-facts",
6292                &[
6293                    ("rule-a.ts", &alpha),
6294                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6295                    ("src/a.ts", "const alpha = 1;\n"),
6296                    ("src/b.ts", "const beta = 2;\n"),
6297                ],
6298            );
6299
6300            let outcome = project
6301                .run_with(vec![component_rule("local/middle", 1, true)])
6302                .expect("runs");
6303
6304            let reduce_reports: Vec<&str> = outcome
6305                .violations
6306                .iter()
6307                .filter(|v| v.message.starts_with("seen|"))
6308                .map(|v| v.message.as_str())
6309                .collect();
6310            assert_eq!(
6311                reduce_reports,
6312                vec![
6313                    "seen|src/a.ts|{\"text\":\"alpha\"}",
6314                    "seen|src/b.ts|{\"text\":\"beta\"}",
6315                ],
6316                "a fact's file belongs in the record field, and the payload is the guest's"
6317            );
6318
6319            for report in reduce_reports {
6320                let payload = report.rsplit('|').next().expect("a payload");
6321                assert!(
6322                    !payload.contains("\"file\""),
6323                    "the engine merged a file key into a payload that already had a field: \
6324                     {report}"
6325                );
6326            }
6327        }
6328
6329        #[test]
6330        fn a_component_rules_cross_file_violations_are_reported_at_the_facts_file() {
6331            let project = Project::new(
6332                "component-reduce-site",
6333                &[
6334                    ("rule-a.ts", &debugger_rule("local/alpha")),
6335                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6336                    ("src/a.ts", "const alpha = 1;\n"),
6337                ],
6338            );
6339
6340            let outcome = project
6341                .run_with(vec![component_rule("local/middle", 1, true)])
6342                .expect("runs");
6343
6344            let sites: Vec<String> = outcome
6345                .violations
6346                .iter()
6347                .filter(|v| v.message.starts_with("seen|"))
6348                .map(|v| format!("{}:{}", v.location.file, v.location.position.line))
6349                .collect();
6350            assert_eq!(sites, vec!["src/a.ts:1".to_owned()]);
6351        }
6352
6353        #[test]
6354        fn a_gate_keeps_a_component_rule_off_a_file_exactly_as_it_does_a_module_rule() {
6355            // The gates run in Rust before either engine is reached, so a component must not
6356            // acquire a second answer to "does this rule run here".
6357            let mut gated = component_rule("local/middle", 1, false);
6358            gated.gates = lanekeep_core::Gates {
6359                file_contains: vec!["beta".to_owned()],
6360                ..lanekeep_core::Gates::default()
6361            };
6362
6363            let project = Project::new(
6364                "component-gated",
6365                &[
6366                    ("rule-a.ts", &debugger_rule("local/alpha")),
6367                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6368                    ("src/a.ts", "const alpha = 1;\n"),
6369                    ("src/b.ts", "const beta = 2;\n"),
6370                ],
6371            );
6372
6373            let outcome = project.run_with(vec![gated]).expect("runs");
6374            assert_eq!(
6375                rendered(&outcome),
6376                vec!["local/middle|src/b.ts|1:7|component saw `beta`".to_owned()],
6377                "the content gate must exclude the file that does not hold the token"
6378            );
6379        }
6380
6381        #[test]
6382        fn a_component_rule_does_not_run_on_a_language_it_does_not_declare() {
6383            // The grammar is chosen by the file and the rule declares which files it wants;
6384            // both engines apply the same gate, so a `.tsx` file is not checked by a rule that
6385            // names only `typescript`.
6386            let mut rule = component_rule("local/middle", 1, false);
6387            rule.languages = vec!["tsx".to_owned()];
6388            rule.queries = BTreeMap::from([("tsx".to_owned(), QUERY.to_owned())]);
6389
6390            let project = Project::new(
6391                "component-language",
6392                &[
6393                    ("rule-a.ts", &debugger_rule("local/alpha")),
6394                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6395                    ("src/a.ts", "const alpha = 1;\n"),
6396                ],
6397            );
6398
6399            let outcome = project.run_with(vec![rule]).expect("runs");
6400            assert!(outcome.violations.is_empty(), "{:?}", rendered(&outcome));
6401        }
6402
6403        #[test]
6404        fn swapping_a_component_between_runs_changes_the_answer() {
6405            // **This is a real staleness bug the shipped guard prevents, demonstrated rather
6406            // than argued.** A hand-built `RuleSpec::component` is attached to a `Config`
6407            // *after* `lanekeep_config::load` computed `ruleset_hash`, so its bytes reach no
6408            // cache-key input. With the cache on and no guard, swapping the component for a
6409            // different one between two runs would serve the first one's answer forever. (A
6410            // component a *config* names is folded into `ruleset_hash` by `lanekeep-config`;
6411            // this path is the one that is not.)
6412            //
6413            // Written against the copy the run actually loads, so the swap is the only
6414            // difference: same rule id, same path, same query, different bytes.
6415            let project = Project::new(
6416                "component-swap",
6417                &[
6418                    ("rule-a.ts", &debugger_rule("local/alpha")),
6419                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6420                    ("src/a.ts", "const alpha = 1;\n"),
6421                ],
6422            );
6423            let installed = project.dir.join("rule.wasm");
6424            fs::copy(fixture(), &installed).expect("installs the rule-shaped component");
6425
6426            // Rebuilt from the path on each run, because that is what a run does: a component's
6427            // bytes are read when the config is loaded, and each run loads the config again. A
6428            // spec built once and reused across both runs would be carrying the *first*
6429            // component's bytes into the second run, which is a property of this test rather
6430            // than of the engine.
6431            let with_installed = || {
6432                let mut rule = component_rule("local/middle", 1, false);
6433                rule.component = Some(backed_by(installed.clone()));
6434                rule
6435            };
6436
6437            let outcome = project
6438                .prepared(vec![with_installed()])
6439                .expect("prepares")
6440                .run()
6441                .expect("runs");
6442            let first = messages(&outcome);
6443            assert!(first.contains(&"component saw `alpha`"), "{first:?}");
6444
6445            // A different component at the same path. `limits.wasm` answers an unrecognized
6446            // probe by saying so, which is a message the first one cannot produce.
6447            fs::copy(
6448                Path::new(env!("CARGO_MANIFEST_DIR"))
6449                    .join("../lanekeep-wasm/tests/fixtures/limits.wasm"),
6450                &installed,
6451            )
6452            .expect("swaps the component");
6453
6454            let outcome = project
6455                .prepared(vec![with_installed()])
6456                .expect("prepares")
6457                .run()
6458                .expect("runs");
6459            let second = messages(&outcome);
6460            assert!(
6461                second.iter().any(|m| m.contains("unknown probe")),
6462                "a swapped component must not be answered from the first one's cache: {second:?}"
6463            );
6464        }
6465
6466        /// A run executes the bytes its rule carries, not whatever is at the path beside them.
6467        ///
6468        /// **The engine leg of "one read".** `lanekeep-config` reads a component once, when the
6469        /// config is loaded: it asks those bytes what the rule is and folds those bytes into
6470        /// `ruleset_hash`. If the engine read the path again it would run a *third* version —
6471        /// code no cache key describes and no metadata described — and every check in the
6472        /// system would pass while doing it.
6473        ///
6474        /// The exact mirror of `swapping_a_component_between_runs_changes_the_answer`. There a
6475        /// swap between two runs has to be **noticed**, because each run reads the file afresh.
6476        /// Here a swap inside one run has to be **ignored**, because the read already happened.
6477        /// Both directions are needed: a design that re-read the path would pass the first and
6478        /// fail this one, and a design that cached bytes across runs would pass this one and
6479        /// fail the first.
6480        #[test]
6481        fn a_run_executes_the_bytes_its_rule_carries_and_not_the_path_beside_them() {
6482            let project = Project::new(
6483                "component-carried-bytes",
6484                &[
6485                    ("rule-a.ts", &debugger_rule("local/alpha")),
6486                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6487                    ("src/a.ts", "const alpha = 1;\n"),
6488                ],
6489            );
6490            let installed = project.dir.join("rule.wasm");
6491            fs::copy(fixture(), &installed).expect("installs the rule-shaped component");
6492
6493            let mut rule = component_rule("local/middle", 1, false);
6494            // Reads the file, exactly as `lanekeep-config` does at config load.
6495            rule.component = Some(backed_by(installed.clone()));
6496
6497            // A different component at the same path, after the rule was built and before the
6498            // run. `limits.wasm` answers an unrecognized probe by saying so, which is a message
6499            // the rule-shaped fixture cannot produce — so which bytes ran is readable from the
6500            // output rather than inferred.
6501            fs::copy(
6502                Path::new(env!("CARGO_MANIFEST_DIR"))
6503                    .join("../lanekeep-wasm/tests/fixtures/limits.wasm"),
6504                &installed,
6505            )
6506            .expect("swaps the component on disk");
6507
6508            let outcome = project.run_with(vec![rule]).expect("runs");
6509            let reported = messages(&outcome);
6510
6511            assert!(
6512                reported.contains(&"component saw `alpha`"),
6513                "the run must execute the bytes the rule carried: {reported:?}"
6514            );
6515            assert!(
6516                !reported.iter().any(|m| m.contains("unknown probe")),
6517                "nothing may re-read the path: {reported:?}"
6518            );
6519        }
6520
6521        #[test]
6522        fn a_run_with_a_component_rule_does_not_touch_the_cache() {
6523            // The guard behind the test above, asserted directly rather than only through its
6524            // effect. Refusing the cache — rather than folding the component's bytes into the
6525            // key here — is deliberate: the correct fold already exists in `lanekeep-config`,
6526            // sorted, deduplicated and length-prefixed, and a second implementation of a
6527            // cache-key encoding in a second crate is precisely the drift that produced this
6528            // sub-project's one real cache bug. See `Engine::caching`.
6529            let project = Project::new(
6530                "component-no-cache",
6531                &[
6532                    ("rule-a.ts", &debugger_rule("local/alpha")),
6533                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6534                    ("src/a.ts", "const alpha = 1;\n"),
6535                ],
6536            );
6537
6538            let with_component = project
6539                .prepared(vec![component_rule("local/middle", 1, false)])
6540                .expect("prepares");
6541            assert!(
6542                !with_component.caching,
6543                "a run that loaded a component must not read or write the cache"
6544            );
6545            with_component.run().expect("runs");
6546            assert!(
6547                !Store::path_for(&project.dir).exists(),
6548                "and must leave no cache behind"
6549            );
6550
6551            // The same project with no component rule caches exactly as it always did, so the
6552            // guard is scoped to the thing that is unsound rather than turning the cache off.
6553            let typescript_only = project.prepared(Vec::new()).expect("prepares");
6554            assert!(typescript_only.caching);
6555            typescript_only.run().expect("runs");
6556            assert!(Store::path_for(&project.dir).exists());
6557        }
6558
6559        /// The same rule, with a query that also asks the fixture to burn real time first.
6560        ///
6561        /// A pattern-level capture beside the node-level one, so the guest receives both names
6562        /// and the violation still lands at `@target`.
6563        fn burning_rule(id: &str, timeout: Duration) -> RuleSpec {
6564            let mut rule = component_rule(id, 1, false);
6565            rule.queries
6566                .insert("typescript".to_owned(), format!("({QUERY}) @burn"));
6567            rule.timeout = Some(timeout);
6568            rule
6569        }
6570
6571        /// A project whose config sets the default per-invocation budget.
6572        fn burning_project(name: &str, default_timeout_ms: u64) -> Project {
6573            let config = format!(
6574                "import {{ defineConfig }} from 'lanekeep';\n\
6575                 import r0 from './rule-a';\n\
6576                 export default defineConfig({{ include: ['src/**/*.ts'], \
6577                 namespaces: ['local'], timeouts: {{ rule: {default_timeout_ms} }}, \
6578                 rules: [r0] }});\n"
6579            );
6580            Project::new(
6581                name,
6582                &[
6583                    ("rule-a.ts", &debugger_rule("local/alpha")),
6584                    ("lanekeep.config.ts", &config),
6585                    ("src/a.ts", "const alpha = 1;\n"),
6586                ],
6587            )
6588        }
6589
6590        #[test]
6591        fn a_component_rules_own_timeout_is_applied_and_not_merely_read() {
6592            // `AGENTS.md`'s "validating a flag is not applying it", asserted in both
6593            // directions, because only one of them discriminates. A rule declaring a *smaller*
6594            // budget than the config's aborts either way if the run is slow enough — so the
6595            // load-bearing half is the **raise**: a rule declaring a budget far larger than a
6596            // config default it would otherwise breach has to complete.
6597            //
6598            // The fixture burns real bytecode for this. A handler that returns immediately is
6599            // never asked to stop, because the budget is polled from epoch checks compiled into
6600            // guest code — so a fast fixture would pass against an engine that ignored the
6601            // value entirely.
6602            let raised = burning_project("component-timeout-raised", 20);
6603            raised
6604                .run_with(vec![burning_rule("local/middle", Duration::from_hours(1))])
6605                .expect("a rule that raised its own budget must complete");
6606
6607            let lowered = burning_project("component-timeout-lowered", 3_600_000);
6608            let error = lowered
6609                .run_with(vec![burning_rule(
6610                    "local/middle",
6611                    Duration::from_millis(20),
6612                )])
6613                .expect_err("a rule that lowered its own budget must be stopped");
6614            assert!(matches!(error, RunError::Rule { .. }), "{error}");
6615            assert!(error.to_string().contains("local/middle"), "{error}");
6616        }
6617
6618        #[test]
6619        fn the_runs_global_budget_reaches_a_component_rule() {
6620            // The clock is the run's, not the worker's and not the rule's. A component rule
6621            // that overruns the whole run's wall-clock budget has to be stopped by *that*
6622            // budget and say so, rather than being blamed for its own per-invocation one —
6623            // which it has not breached here, since it is given an hour.
6624            let config = "import { defineConfig } from 'lanekeep';\n\
6625                 import r0 from './rule-a';\n\
6626                 export default defineConfig({ include: ['src/**/*.ts'], \
6627                 namespaces: ['local'], timeouts: { global: 50 }, rules: [r0] });\n";
6628            let project = Project::new(
6629                "component-global-budget",
6630                &[
6631                    ("rule-a.ts", &debugger_rule("local/alpha")),
6632                    ("lanekeep.config.ts", config),
6633                    ("src/a.ts", "const alpha = 1;\n"),
6634                ],
6635            );
6636
6637            let error = project
6638                .run_with(vec![burning_rule("local/middle", Duration::from_hours(1))])
6639                .expect_err("the run's own budget must stop it")
6640                .to_string();
6641            assert!(
6642                error.contains("the run exceeded its"),
6643                "the global budget must be what is blamed, not the rule's: {error}"
6644            );
6645        }
6646
6647        #[test]
6648        fn a_spent_run_budget_stops_a_component_rule_before_the_guest_is_entered() {
6649            // The same outer check as `run_budget`'s cases, on the other dispatch path — and it
6650            // is one check rather than two, which is the point: it sits in `check_file`, above
6651            // the `if let Some(slot)` that chooses an engine, so neither engine can be the one
6652            // that has it.
6653            //
6654            // What makes this discriminating is the *variant*. Measured against the commit
6655            // before this one, the same fixture failed with `RunError::Rule` — the epoch
6656            // mechanism noticed, mid-instantiation, and blamed `local/middle` for `src/a.ts`.
6657            // That is a rule and a file named for a breach that is about neither, and it is
6658            // only luck that anything noticed at all: `AGENTS.md` records that epoch checks
6659            // live inside guest code, so a tick that lands between two calls is invisible to
6660            // them. `RunError::RunTimeout` can only come from the walker.
6661            //
6662            // A budget of zero is a run whose clock is spent before the first file, which is
6663            // the one arrangement in which nothing but the outer check can fire — the guest is
6664            // never entered, so there is no epoch deadline to trip. `lanekeep-wasm`'s own
6665            // limit tests avoid a born-expired clock for the opposite reason, that
6666            // instantiation is itself a budgeted guest call; here that is exactly what must
6667            // not happen.
6668            let config = "import { defineConfig } from 'lanekeep';\n\
6669                 import r0 from './rule-a';\n\
6670                 export default defineConfig({ include: ['src/**/*.ts'], \
6671                 namespaces: ['local'], timeouts: { global: 0 }, rules: [r0] });\n";
6672            let project = Project::new(
6673                "component-spent-budget",
6674                &[
6675                    ("rule-a.ts", &debugger_rule("local/alpha")),
6676                    ("lanekeep.config.ts", config),
6677                    ("src/a.ts", "const alpha = 1;\n"),
6678                ],
6679            );
6680
6681            let error = project
6682                .run_with(vec![component_rule("local/middle", 1, false)])
6683                .expect_err("a spent run budget stops the run");
6684            assert!(
6685                matches!(error, RunError::RunTimeout { .. }),
6686                "the walker had to stop this before any guest ran: {error}"
6687            );
6688        }
6689
6690        #[test]
6691        fn each_reducing_component_rule_sees_only_its_own_facts() {
6692            // A rule reading another's facts would make an internal payload shape into a
6693            // contract between rules, and would make a result depend on the order rules were
6694            // declared in. Two reducing rules is the smallest case that can tell the filter
6695            // from its absence — with one, "its own facts" and "every fact" are the same list.
6696            let project = Project::new(
6697                "component-fact-isolation",
6698                &[
6699                    ("rule-a.ts", &debugger_rule("local/alpha")),
6700                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6701                    ("src/a.ts", "const alpha = 1;\n"),
6702                    ("src/b.ts", "const beta = 2;\n"),
6703                ],
6704            );
6705
6706            let outcome = project
6707                .run_with(vec![
6708                    component_rule("local/first", 1, true),
6709                    component_rule("local/second", 2, true),
6710                ])
6711                .expect("runs");
6712
6713            for id in ["local/first", "local/second"] {
6714                let seen: Vec<&str> = outcome
6715                    .violations
6716                    .iter()
6717                    .filter(|v| v.rule_id.to_string() == id && v.message.starts_with("seen|"))
6718                    .map(|v| v.message.as_str())
6719                    .collect();
6720                assert_eq!(
6721                    seen,
6722                    vec![
6723                        "seen|src/a.ts|{\"text\":\"alpha\"}",
6724                        "seen|src/b.ts|{\"text\":\"beta\"}",
6725                    ],
6726                    "`{id}` must see its own two facts and not the other rule's as well"
6727                );
6728            }
6729        }
6730
6731        #[test]
6732        fn a_profiled_run_walks_the_tree_per_component_rule_and_agrees_with_the_shared_pass() {
6733            // `--profile` turns the one-traversal-per-file pass off, because the per-rule split
6734            // it reports cannot be divided honestly between rules that share a traversal. So
6735            // there is a second, otherwise untested path into a component rule: the rule walks
6736            // the tree alone, through the context's own arena.
6737            //
6738            // Two claims, and the second is what makes the first worth having: the answers are
6739            // the same as the shared pass produces, and the language gate still applies — which
6740            // on the shared pass is enforced by the combined query having no pattern for this
6741            // rule at all, and here is enforced by nothing but the check itself.
6742            let project = Project::new(
6743                "component-profiled",
6744                &[
6745                    ("rule-a.ts", &debugger_rule("local/alpha")),
6746                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6747                    ("src/a.ts", "const alpha = 1;\n"),
6748                ],
6749            );
6750
6751            let outcome = project
6752                .prepared(vec![component_rule("local/middle", 1, false)])
6753                .expect("prepares")
6754                .without_cache()
6755                .profiling()
6756                .run()
6757                .expect("runs");
6758            assert_eq!(
6759                rendered(&outcome),
6760                vec!["local/middle|src/a.ts|1:7|component saw `alpha`".to_owned()],
6761                "the per-rule walk must agree with the shared traversal"
6762            );
6763            let timings = outcome.timings.expect("profiling collects timings");
6764            let middle = timings
6765                .get(&"local/middle".parse::<RuleId>().expect("a rule id"))
6766                .expect("the component rule is timed like any other");
6767            assert_eq!(middle.matches, 1, "the match count comes from the walk");
6768
6769            // A rule declaring *several* languages, with the file's second in the list. This is
6770            // the case that distinguishes "the grammar the file chose" from "the first grammar
6771            // the rule compiled": both are present, only one parses this tree, and a query
6772            // compiled against the other matches nothing at all — silently, which is exactly
6773            // the failure mode `AGENTS.md` records from the `.tsx` migration.
6774            let mut both = component_rule("local/middle", 1, false);
6775            both.languages = vec!["tsx".to_owned(), "typescript".to_owned()];
6776            both.queries = BTreeMap::from([
6777                ("tsx".to_owned(), QUERY.to_owned()),
6778                ("typescript".to_owned(), QUERY.to_owned()),
6779            ]);
6780            let outcome = project
6781                .prepared(vec![both])
6782                .expect("prepares")
6783                .without_cache()
6784                .profiling()
6785                .run()
6786                .expect("runs");
6787            assert_eq!(
6788                rendered(&outcome),
6789                vec!["local/middle|src/a.ts|1:7|component saw `alpha`".to_owned()],
6790                "the grammar is the file's, not the first one the rule happened to declare"
6791            );
6792
6793            // The same rule, declaring a language this file is not. On the profiled path the
6794            // combined query is not built, so the only thing keeping it off the file is the
6795            // gate in the dispatch itself.
6796            let mut elsewhere = component_rule("local/middle", 1, false);
6797            elsewhere.languages = vec!["tsx".to_owned()];
6798            elsewhere.queries = BTreeMap::from([("tsx".to_owned(), QUERY.to_owned())]);
6799            let outcome = project
6800                .prepared(vec![elsewhere])
6801                .expect("prepares")
6802                .without_cache()
6803                .profiling()
6804                .run()
6805                .expect("runs");
6806            assert!(
6807                outcome.violations.is_empty(),
6808                "a rule that does not name this file's language must not run on it: {:?}",
6809                rendered(&outcome)
6810            );
6811        }
6812
6813        #[test]
6814        fn a_worker_whose_store_has_trapped_keeps_reporting_what_went_wrong() {
6815            // `bindgen!` is configured with `imports: { default: trappable }`, so a trap sets a
6816            // store-wide flag with no public reset: the *next* call on that store fails with
6817            // wasmtime's `cannot enter component instance`, which describes the runtime's
6818            // bookkeeping rather than anything that went wrong. rayon keeps handing this worker
6819            // its remaining files, and which of several failures surfaces from the reduction is
6820            // arbitrary — so a run could be reported against a file that was fine, with a
6821            // message naming nothing.
6822            //
6823            // Nothing is rescued by noticing: every failure here cancels the run either way.
6824            // What is rescued is the diagnostic, and this is the assertion that it is.
6825            let project = Project::new(
6826                "component-poisoned",
6827                &[
6828                    ("rule-a.ts", &debugger_rule("local/alpha")),
6829                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6830                    ("src/a.ts", "const alpha = 1;\n"),
6831                    ("src/b.ts", "const beta = 2;\n"),
6832                ],
6833            );
6834
6835            // The `limits` fixture spins forever when the first capture is named `spin`, which
6836            // is the shortest route to a store that has trapped.
6837            let mut spinner = component_rule("local/middle", 1, false);
6838            spinner.component = Some(backed_by(
6839                Path::new(env!("CARGO_MANIFEST_DIR"))
6840                    .join("../lanekeep-wasm/tests/fixtures/limits.wasm"),
6841            ));
6842            spinner.queries.insert(
6843                "typescript".to_owned(),
6844                "(variable_declarator) @spin".to_owned(),
6845            );
6846            spinner.timeout = Some(Duration::from_millis(30));
6847
6848            let engine = project.engine(vec![spinner]).without_cache();
6849            let clock = RunClock::start(engine.limits.global_timeout);
6850            let cache = Store::empty();
6851            let mut worker = Worker::new(&engine, &clock);
6852
6853            let files = engine.discover();
6854            let Err(first) = engine.check_file(&mut worker, &cache, &files[0]) else {
6855                panic!("a spinning rule must breach its budget")
6856            };
6857            let Err(second) = engine.check_file(&mut worker, &cache, &files[1]) else {
6858                panic!("the store has trapped and cannot be entered again")
6859            };
6860
6861            assert_eq!(
6862                second.to_string(),
6863                first.to_string(),
6864                "the second file must be told what actually went wrong"
6865            );
6866            assert!(
6867                !second
6868                    .to_string()
6869                    .contains("cannot enter component instance"),
6870                "{second}"
6871            );
6872        }
6873
6874        /// A component that cannot be used is reported against its rule, before any file is read.
6875        ///
6876        /// **It used to be a missing *file*, and the engine no longer reads one.** A rule
6877        /// carries its component's bytes, read once when the config was loaded, so the case
6878        /// "the path is not there" belongs to `lanekeep-config` now —
6879        /// `a_component_that_is_not_there_is_refused_by_position` is where it lives. What is
6880        /// left here is the property that survived the move and matters at this layer: bytes
6881        /// that cannot become a component stop the run at prepare time, naming the rule, rather
6882        /// than on whichever file happened to match it first.
6883        #[test]
6884        fn an_unusable_component_is_reported_against_its_rule_before_any_file_is_read() {
6885            let mut rule = component_rule("local/middle", 1, false);
6886            rule.component = Some(with_bytes(PathBuf::from("rule.wasm"), Vec::new()));
6887
6888            let project = Project::new(
6889                "component-unusable",
6890                &[
6891                    ("rule-a.ts", &debugger_rule("local/alpha")),
6892                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6893                    ("src/a.ts", "const alpha = 1;\n"),
6894                ],
6895            );
6896
6897            let error = project.run_with(vec![rule]).expect_err("must not run");
6898            let rendered = error.to_string();
6899            assert!(matches!(error, RunError::Component { .. }), "{rendered}");
6900            assert!(rendered.contains("local/middle"), "{rendered}");
6901        }
6902
6903        #[test]
6904        fn a_component_that_is_not_a_component_is_refused() {
6905            let project = Project::new(
6906                "component-garbage",
6907                &[
6908                    ("rule-a.ts", &debugger_rule("local/alpha")),
6909                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6910                    ("src/a.ts", "const alpha = 1;\n"),
6911                    ("not-a-rule.wasm", "this is not WebAssembly"),
6912                ],
6913            );
6914
6915            let mut rule = component_rule("local/middle", 1, false);
6916            rule.component = Some(backed_by(project.dir.join("not-a-rule.wasm")));
6917
6918            let error = project.run_with(vec![rule]).expect_err("must not run");
6919            assert!(matches!(error, RunError::Component { .. }), "{error}");
6920        }
6921
6922        #[test]
6923        fn a_run_with_no_component_rule_builds_no_component_engine() {
6924            // Building one spawns the epoch ticker thread that enforces both wall-clock
6925            // budgets, and compiles nothing. Every run this tree can express today is this one,
6926            // so "beside" has to mean "and costs nothing when unused".
6927            let project = Project::new(
6928                "component-absent",
6929                &[
6930                    ("rule-a.ts", &debugger_rule("local/alpha")),
6931                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6932                    ("src/a.ts", "debugger;\n"),
6933                ],
6934            );
6935
6936            let engine = project.engine(Vec::new());
6937            assert!(
6938                engine.components.is_none(),
6939                "a TypeScript-only ruleset must not build a component engine"
6940            );
6941        }
6942
6943        #[test]
6944        fn a_worker_instantiates_a_component_rule_once_however_many_files_it_handles() {
6945            // The bound `MEMORY_RESERVATION` is chosen on: one instance per (worker, component).
6946            // Driven through one `Worker` directly rather than through `run`, because rayon
6947            // decides how many workers exist and the claim is about one of them.
6948            let project = Project::new(
6949                "component-instantiations",
6950                &[
6951                    ("rule-a.ts", &debugger_rule("local/alpha")),
6952                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6953                    ("src/a.ts", "const alpha = 1;\n"),
6954                    ("src/b.ts", "const beta = 2;\n"),
6955                    ("src/c.ts", "const gamma = 3;\n"),
6956                ],
6957            );
6958
6959            let engine = project
6960                .engine(vec![component_rule("local/middle", 1, false)])
6961                .without_cache();
6962            let clock = RunClock::start(engine.limits.global_timeout);
6963            let cache = Store::empty();
6964            let mut worker = Worker::new(&engine, &clock);
6965
6966            for path in engine.discover() {
6967                engine
6968                    .check_file(&mut worker, &cache, &path)
6969                    .expect("checks");
6970            }
6971
6972            let runtime = worker.wasm.as_ref().expect("a component rule ran");
6973            assert_eq!(
6974                runtime.instantiations(),
6975                1,
6976                "three files sharing one worker must instantiate the rule once"
6977            );
6978            assert!(
6979                runtime.host().holds_no_contexts(),
6980                "each file's context must be given back, or a worker's store grows with the \
6981                 corpus"
6982            );
6983        }
6984
6985        #[test]
6986        fn a_worker_whose_component_rules_never_match_instantiates_nothing() {
6987            // The case eager instantiation pays 82 to 96 times over for. A worker that never
6988            // reaches a match must not build a store's worth of instances.
6989            let project = Project::new(
6990                "component-unmatched",
6991                &[
6992                    ("rule-a.ts", &debugger_rule("local/alpha")),
6993                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
6994                    ("src/a.ts", "debugger;\n"),
6995                ],
6996            );
6997
6998            let engine = project
6999                .engine(vec![component_rule("local/middle", 1, false)])
7000                .without_cache();
7001            let clock = RunClock::start(engine.limits.global_timeout);
7002            let cache = Store::empty();
7003            let mut worker = Worker::new(&engine, &clock);
7004
7005            for path in engine.discover() {
7006                engine
7007                    .check_file(&mut worker, &cache, &path)
7008                    .expect("checks");
7009            }
7010
7011            assert!(
7012                worker.wasm.is_none(),
7013                "a worker with no component match must not build a store at all"
7014            );
7015        }
7016
7017        /// A `Prepared` rule from a hand-built spec — the work `Engine::prepare` does for one
7018        /// TypeScript rule, so [`load_components`](super::load_components) can be driven directly
7019        /// with a test loader rather than through a whole `Engine::prepare`.
7020        fn prepared(spec: RuleSpec) -> Prepared {
7021            let language = lanekeep_lang_js::registry()
7022                .by_id("typescript")
7023                .expect("typescript is registered")
7024                .clone();
7025            let query = CompiledQuery::compile(language.as_ref(), &spec.queries["typescript"])
7026                .expect("the query compiles");
7027            let gates = CompiledGates::compile(&spec.gates).expect("the gates compile");
7028            Prepared {
7029                index: 0,
7030                spec,
7031                gates,
7032                compiled: vec![(language, query)],
7033                slot: None,
7034            }
7035        }
7036
7037        /// `load_components` deserializes a shared component once at prepare time, not once per
7038        /// rule.
7039        ///
7040        /// The second of the two passes the §15 defect names: the engine's own `load_components`
7041        /// called [`ComponentLoader::load_mapped`] per rule, so the same component was
7042        /// deserialized again at prepare time. The dedup is keyed on the same content identity as
7043        /// `lanekeep_config::compile_components`, keeping the loader itself lock-free.
7044        #[test]
7045        fn load_components_deserializes_one_shared_component_once() {
7046            let loader = ComponentLoader::without_cache();
7047            // Four rules of one component: distinct ids and indices, the same fixture bytes —
7048            // the shape of a config naming every rule a shared component hosts.
7049            let mut rules: Vec<Prepared> = ["a", "b", "c", "d"]
7050                .iter()
7051                .enumerate()
7052                .map(|(index, id)| prepared(component_rule(&format!("local/{id}"), index, false)))
7053                .collect();
7054            for (index, rule) in rules.iter_mut().enumerate() {
7055                rule.index = index;
7056            }
7057
7058            let components = load_components(&mut rules, &loader).expect("loads");
7059            assert!(components.is_some(), "the config named a component");
7060            assert_eq!(
7061                loader.compilations(),
7062                1,
7063                "one shared component compiled once at prepare time, not once per rule"
7064            );
7065            assert_eq!(
7066                loader.embedded_loads(),
7067                1,
7068                "and deserialized once — one Loaded handed to every rule of it"
7069            );
7070        }
7071
7072        #[test]
7073        fn one_read_memo_serves_both_engines_over_one_file() {
7074            // The hazard a shared `FileAccess` closes. Two rules on one file, one in each
7075            // engine, both reading the same path: with one memo per engine the second reader
7076            // sees whatever is on disk *now*, and the two dependency lists disagree about the
7077            // path's hash — which `tracked::sort` cannot repair, because it orders by path and
7078            // does not dedupe.
7079            //
7080            // Asserted on the recorded dependency list rather than on what a rule saw, because
7081            // that list is the cache-entry input and a duplicate in it is a cache entry that can
7082            // never be validated.
7083            const READER: &str = "import { defineRule } from 'lanekeep';\n\
7084                export default defineRule({\n\
7085                  id: 'local/alpha',\n\
7086                  query: '(variable_declarator) @d',\n\
7087                  card: { message: 'read', remediation: 'x',\n\
7088                    examples: { bad: 'a', good: 'b' } },\n\
7089                  check(ctx, m) { ctx.readFile('shared.json'); ctx.report(m.d); },\n\
7090                });\n";
7091
7092            let project = Project::new(
7093                "component-one-memo",
7094                &[
7095                    ("rule-a.ts", READER),
7096                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
7097                    ("src/a.ts", "const alpha = 1;\n"),
7098                    ("shared.json", "{\"v\":1}\n"),
7099                ],
7100            );
7101
7102            let outcome = project
7103                .run_with(vec![component_rule("local/middle", 1, false)])
7104                .expect("runs");
7105
7106            let reads = outcome
7107                .dependencies
7108                .get(&FilePath::new("src/a.ts"))
7109                .expect("the file recorded a tracked read");
7110            assert_eq!(
7111                reads.len(),
7112                1,
7113                "one path read once must be one dependency, not one per engine: {reads:?}"
7114            );
7115            assert_eq!(reads[0].path.as_str(), "shared.json");
7116            assert!(reads[0].hash.is_some(), "the file was there and was read");
7117        }
7118
7119        #[test]
7120        fn two_component_rules_on_one_file_share_one_context() {
7121            // Found by mutation: every other test here has exactly one component rule, so
7122            // "one context per file" and "one context per rule" are the same arrangement and
7123            // nothing could tell them apart. Two rules on one file is the smallest case where
7124            // they differ.
7125            //
7126            // What per-file buys is the arena, the query cache and a single entry in the
7127            // store's table. The table is what an assertion can reach: a per-rule context
7128            // would replace the file's entry without deleting it, so the first rule's arena —
7129            // the parse tree and the file's whole source — would be stranded in a store that
7130            // lives for the rest of the worker's share of the corpus.
7131            let project = Project::new(
7132                "component-two-rules",
7133                &[
7134                    ("rule-a.ts", &debugger_rule("local/alpha")),
7135                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
7136                    ("src/a.ts", "const alpha = 1;\n"),
7137                ],
7138            );
7139
7140            let engine = project
7141                .engine(vec![
7142                    component_rule("local/first", 1, false),
7143                    component_rule("local/second", 2, false),
7144                ])
7145                .without_cache();
7146            let clock = RunClock::start(engine.limits.global_timeout);
7147            let cache = Store::empty();
7148            let mut worker = Worker::new(&engine, &clock);
7149
7150            let mut reported = Vec::new();
7151            for path in engine.discover() {
7152                reported.extend(
7153                    engine
7154                        .check_file(&mut worker, &cache, &path)
7155                        .expect("checks")
7156                        .violations,
7157                );
7158            }
7159
7160            // Both rules ran, and each got its own reports rather than one of them collecting
7161            // the other's — the context is shared, so attribution comes from taking the reports
7162            // between rules and not from the context.
7163            let mut ids: Vec<String> = reported.iter().map(|v| v.rule_id.to_string()).collect();
7164            ids.sort();
7165            assert_eq!(
7166                ids,
7167                vec!["local/first".to_owned(), "local/second".to_owned()],
7168                "both component rules must report, once each"
7169            );
7170
7171            let runtime = worker.wasm.as_ref().expect("a component rule ran");
7172            assert!(
7173                runtime.host().holds_no_contexts(),
7174                "two rules on one file must leave one context behind, and it must be given back"
7175            );
7176            assert_eq!(
7177                runtime.instantiations(),
7178                2,
7179                "two rules is two instances, and two rules on one file is still two"
7180            );
7181        }
7182
7183        #[test]
7184        fn a_component_rules_read_reaches_the_dependency_list_at_all() {
7185            // The half of the shared-memo claim that a duplicate count cannot make. If the
7186            // component engine held a `FileAccess` of its own, its reads would be recorded
7187            // against a memo the engine never reads back — so they would not be duplicated,
7188            // they would be *gone*, and the file's cache entry would not be invalidated when
7189            // the file it depended on changed. Only a component rule reads here, so the entry
7190            // exists if and only if the two engines share one access.
7191            let project = Project::new(
7192                "component-only-reader",
7193                &[
7194                    ("rule-a.ts", &debugger_rule("local/alpha")),
7195                    ("lanekeep.config.ts", &config_with(&["./rule-a"])),
7196                    ("src/a.ts", "const alpha = 1;\n"),
7197                    ("shared.json", "{\"v\":1}\n"),
7198                ],
7199            );
7200
7201            let outcome = project
7202                .run_with(vec![component_rule("local/middle", 1, false)])
7203                .expect("runs");
7204
7205            let reads = outcome
7206                .dependencies
7207                .get(&FilePath::new("src/a.ts"))
7208                .expect("a component rule's tracked read must reach the dependency list");
7209            assert_eq!(reads.len(), 1, "{reads:?}");
7210            assert_eq!(reads[0].path.as_str(), "shared.json");
7211        }
7212
7213        #[test]
7214        fn a_run_that_binds_an_undeclared_interface_cannot_be_assembled() {
7215            // **The wiring, not the comparison.** The check used to be a statement in
7216            // `load_components`, and deleting that statement left all one hundred engine tests
7217            // passing with no dead-code warning, because the test below calls the comparison
7218            // directly. So this drives a real `RuleSet` through the real recording path —
7219            // `linker_mut` takes the declaration and pushes it — and then through the only
7220            // constructor a run's component set has.
7221            let engine = WasmEngine::new().expect("the runtime builds");
7222            let mut set = RuleSet::new(&engine).expect("the world links");
7223            // The linker itself is not wanted — what is under test is that reaching for it
7224            // records the declaration, which is what `linker_mut` does on the way.
7225            let _ = set.linker_mut(&ExternalBinding::declare(
7226                "wasi:random/random",
7227                "a fixed 64-byte cycle, all zeroes",
7228            ));
7229
7230            let error = Components::linked(Arc::clone(&engine), set)
7231                .err()
7232                .expect("a set that bound something undeclared must not become a run")
7233                .to_string();
7234            assert!(error.contains("wasi:random/random"), "{error}");
7235
7236            // And a set that bound nothing assembles, so the refusal is about the binding rather
7237            // than about component runs in general.
7238            let clean = RuleSet::new(&engine).expect("the world links");
7239            assert!(Components::linked(engine, clean).is_ok());
7240        }
7241
7242        #[test]
7243        fn a_declaration_that_does_not_match_the_cache_keys_own_list_stops_the_run() {
7244            // `EXTERNAL_BINDINGS` was a signature and nothing compared it against what a run
7245            // actually bound. A binding declared at a call site and left out of the constant is
7246            // a run whose rules reach something no cached result knows about, with every
7247            // cache-key input identical.
7248            assert!(
7249                declared_bindings_match(EXTERNAL_BINDINGS).is_ok(),
7250                "a run that binds exactly the declared list is accepted"
7251            );
7252
7253            let undeclared = [ExternalBinding::declare(
7254                "wasi:random/random",
7255                "a fixed 64-byte cycle, all zeroes",
7256            )];
7257            let error = declared_bindings_match(&undeclared)
7258                .expect_err("an undeclared binding must stop the run")
7259                .to_string();
7260            assert!(error.contains("wasi:random/random"), "{error}");
7261            assert!(error.contains("EXTERNAL_BINDINGS"), "{error}");
7262        }
7263
7264        #[test]
7265        fn a_rule_with_per_language_queries_reports_on_every_language_it_declares() {
7266            // One rule spanning two grammars that do not share node vocabulary — Python
7267            // spells a call `call`, TypeScript `call_expression` — with a query per
7268            // language. Both file types must report in one run; a rule that compiled the
7269            // TypeScript query against Python would fail at prepare (the python grammar has
7270            // no `call_expression` node kind), and one that compiled Python's `call` query
7271            // against the TypeScript grammar would silently match nothing.
7272            let project = Project::new(
7273                "per-language-queries",
7274                &[
7275                    (
7276                        "rule.ts",
7277                        "import { defineRule } from 'lanekeep';\n\
7278                        export default defineRule({\n\
7279                          id: 'local/multi',\n\
7280                          language: ['typescript', 'python'],\n\
7281                          query: {\n\
7282                            typescript: '(call_expression) @call',\n\
7283                            python: '(call) @call',\n\
7284                          },\n\
7285                          card: { message: 'call', remediation: 'avoid', \
7286                            examples: { bad: 'f()', good: 'f' } },\n\
7287                          check(ctx, m) { ctx.report(m.call); },\n\
7288                        });\n",
7289                    ),
7290                    (
7291                        "lanekeep.json",
7292                        r#"{"include": ["src/**/*.ts", "src/**/*.py"],
7293                        "namespaces": ["local"], "rules": ["./rule"]}"#,
7294                    ),
7295                    ("src/a.ts", "foo();\n"),
7296                    ("src/b.py", "foo()\n"),
7297                ],
7298            );
7299
7300            let outcome = project.run_json().expect("runs");
7301            assert_eq!(
7302                rendered(&outcome),
7303                vec![
7304                    "local/multi|src/a.ts|1:1|call".to_owned(),
7305                    "local/multi|src/b.py|1:1|call".to_owned(),
7306                ],
7307            );
7308        }
7309
7310        #[test]
7311        fn an_invalid_query_names_which_languages_query_it_is() {
7312            // A rule holds one query per language, so "invalid query at 2:1" locates a
7313            // point in one of several sources — and only two of the compiler's error kinds
7314            // name the grammar themselves. The error has to say whose query failed. Down
7315            // here rather than beside the single-language invalid-query test, because this
7316            // module's runner is the one whose registry knows every language.
7317            let project = Project::new(
7318                "bad-query-language",
7319                &[
7320                    (
7321                        "rule.ts",
7322                        "import { defineRule } from 'lanekeep';\n\
7323                        export default defineRule({\n\
7324                          id: 'local/multi',\n\
7325                          language: ['typescript', 'python'],\n\
7326                          query: {\n\
7327                            typescript: '(call_expression) @call',\n\
7328                            python: '(call_expression) @call',\n\
7329                          },\n\
7330                          card: { message: 'm', remediation: 'r', \
7331                            examples: { bad: 'a', good: 'b' } },\n\
7332                          check(ctx, m) { ctx.report(m.call); },\n\
7333                        });\n",
7334                    ),
7335                    (
7336                        "lanekeep.json",
7337                        r#"{"include": ["src/**/*.ts", "src/**/*.py"],
7338                        "namespaces": ["local"], "rules": ["./rule"]}"#,
7339                    ),
7340                    ("src/a.py", "f()\n"),
7341                ],
7342            );
7343
7344            let err = project.run_json().expect_err("must fail at preparation");
7345            assert!(matches!(err, RunError::Query { .. }), "{err:?}");
7346            let rendered = err.to_string();
7347            assert!(rendered.contains("for `python`"), "{rendered}");
7348            assert!(rendered.contains("call_expression"), "{rendered}");
7349        }
7350
7351        #[test]
7352        fn a_component_with_per_language_queries_reports_on_every_language_it_declares() {
7353            // The component half of the test above, and the reason it lives in this module:
7354            // the world's `queries: list<query-for>` is the raw shape of the whole change,
7355            // and every other committed fixture declares exactly one language — so without
7356            // this, per-language dispatch through a real guest was covered by nothing, and
7357            // deleting the per-language selection from the component path would have left
7358            // every test green. `polyglot.wasm` declares typescript and python with a
7359            // different query for each grammar's own vocabulary.
7360            let project = Project::new(
7361                "per-language-queries-component",
7362                &[
7363                    (
7364                        "lanekeep.json",
7365                        r#"{"include": ["src/**/*.ts", "src/**/*.py"],
7366                            "namespaces": ["fixture"],
7367                            "rules": ["./rules/polyglot.wasm"]}"#,
7368                    ),
7369                    ("src/a.ts", "foo();\n"),
7370                    ("src/b.py", "foo()\n"),
7371                ],
7372            );
7373            project.write_component("rules/polyglot.wasm", "polyglot");
7374
7375            let outcome = project.run_json().expect("runs");
7376            assert_eq!(
7377                rendered(&outcome),
7378                vec![
7379                    "fixture/polyglot|src/a.ts|1:1|called".to_owned(),
7380                    "fixture/polyglot|src/b.py|1:1|called".to_owned(),
7381                ],
7382            );
7383        }
7384    }
7385}