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