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