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