lanekeep_config/lib.rs
1//! Configuration loading and canonicalized hashing for lanekeep.
2//!
3//! Loads `lanekeep.config.ts` or `lanekeep.json`, resolves the rule graph, and derives the
4//! hashes feeding the cache key.
5//!
6//! # How the config is read
7//!
8//! A `lanekeep.config.ts` is a TypeScript module, so reading it means running it. A
9//! synthetic entry module imports the config's default export into a global, and a second
10//! evaluation hands back `JSON.stringify` of the parts that are data.
11//!
12//! Going through JSON rather than reaching into engine values is deliberate. It keeps
13//! every value crossing the boundary plainly serializable, it makes the whole extraction
14//! one testable string, and it sidesteps threading engine lifetimes through this crate.
15//!
16//! The one thing it cannot carry is a function, and `check` is a function. So the
17//! extraction separately records whether each rule has a callable `check` and `reduce`.
18//! Without that, a rule whose handler was misspelled would load cleanly and silently never
19//! fire — the worst failure this tool can have, because it looks exactly like passing.
20//!
21//! A `lanekeep.json` is not a program, and is read as what it is: `src/json.rs` parses,
22//! validates and resolves it in Rust, and only a rule reference naming a *TypeScript* rule
23//! reaches the sandbox — because that rule's own declaration is the only place its `id`,
24//! `query` and `card` exist. The note above `entry_source` in this file records what
25//! that cost.
26
27use std::collections::{BTreeMap, BTreeSet, HashMap};
28use std::path::{Path, PathBuf};
29use std::time::Duration;
30
31use lanekeep_core::{Examples, Gates, Namespace, RuleCard, RuleId, Severity};
32use lanekeep_js::{Limits, ResolveError, RuleRoot, RunClock, Sandbox};
33use lanekeep_wasm::{RuleSet, WasmEngine, WasmRuntime};
34use serde::Deserialize;
35use thiserror::Error;
36
37/// A 32-byte content hash.
38pub type Hash = [u8; 32];
39
40mod json;
41
42pub use json::{ResolvedRule, RuleReference};
43
44/// Render a hash the way it appears in diagnostics and cache paths.
45#[must_use]
46pub fn hex(hash: &Hash) -> String {
47 use std::fmt::Write as _;
48 hash.iter()
49 .fold(String::with_capacity(64), |mut out, byte| {
50 let _ = write!(out, "{byte:02x}");
51 out
52 })
53}
54
55/// A rule as the config declares it.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct RuleSpec {
58 /// Zero-based position in the config's `rules` array.
59 ///
60 /// This is how the engine reaches the handler: the rule object lives in the loaded
61 /// config, and indexing into it is what lets a function cross the boundary without
62 /// ever being extracted as a value.
63 ///
64 /// **It is the position in the config and the position in the entry module's array, and
65 /// those have to stay one number.** A component-backed rule has no entry in that array —
66 /// its handlers are not JavaScript — so `json::rules_module` emits a `null` placeholder to
67 /// hold its place rather than closing the gap. Numbering the array separately would leave
68 /// every rule after a component pointing at its neighbor's handler: the call succeeds, and
69 /// the violations are attributed to the wrong rule.
70 ///
71 /// **It is therefore not a position in [`Config::rules`], and is not unique across it.** A
72 /// component hosts a list of rules, so one entry in the config's array — one placeholder —
73 /// can produce several `RuleSpec`s, and every one of them carries the position of the
74 /// *reference*. Which of the component's own rules a spec is lives on
75 /// [`ComponentRule::index`], and the two numberings answer different questions: this one
76 /// names a slot in the entry module, that one names a rule inside a compiled program.
77 pub index: usize,
78 /// Namespaced identifier.
79 pub id: RuleId,
80 /// Which languages' grammars the query compiles against, and which files the rule runs on.
81 ///
82 /// A rule runs on a file only when the file's own language is one of these, and it is
83 /// then parsed with *that* grammar. Running every rule against every file with a single
84 /// declared grammar is what used to turn a `.tsx` file into a tree of `ERROR` nodes —
85 /// silently, since a query simply matches nothing inside one.
86 pub languages: Vec<String>,
87 /// Severity as the rule declares it, before config overrides.
88 pub severity: Severity,
89 /// The rule card.
90 pub card: RuleCard,
91 /// Language id → query source, one entry per language the rule targets.
92 ///
93 /// The exact cover — every declared language present, nothing extra — is enforced by
94 /// `build_rule`; the engine compiles each entry against that language's grammar.
95 pub queries: BTreeMap<String, String>,
96 /// Pre-parse gates.
97 pub gates: Gates,
98 /// A per-invocation budget overriding the default.
99 pub timeout: Option<Duration>,
100 /// Whether the rule has a `reduce` phase.
101 pub has_reduce: bool,
102 /// The compiled component this rule's handlers live in, or `None` for a TypeScript rule.
103 ///
104 /// **This is what sends a rule to one engine or the other.** `lanekeep-engine` runs a rule
105 /// with `None` through `lanekeep-js` and a rule with `Some` through `lanekeep-wasm`, in the
106 /// same run over the same corpus — the decision is a property of the rule and is made here,
107 /// where a rule is described, rather than by the engine guessing from anything else.
108 ///
109 /// Every other field of a component-backed rule is the component's own answer to
110 /// `metadata`, read once here at config load. There is no config syntax carrying an `id`, a
111 /// `query` or a card beside a `.wasm` reference, and there deliberately never was: a second
112 /// description of a rule is drift that has to be kept in step with the first.
113 pub component: Option<ComponentRule>,
114}
115
116/// Where a component-backed rule's code is, and what it is configured with.
117///
118/// **One value rather than two fields, because the two cannot be independently true.** A rule
119/// backed by a component is always configured — with `null` when the config named it with no
120/// options, which is the shape `crates/lanekeep-wasm/wit/world.wit` declares so that a guest
121/// has one code path rather than two — and a rule that is not backed by a component has no
122/// `configure` to reach. Splitting them would make "a component with nothing to configure it
123/// with" and "options belonging to no component" representable, and both are states nothing
124/// downstream knows what to do with.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct ComponentRule {
127 /// Where the bytes came from: a path confined to the rules root, or `lanekeep/<name>` for
128 /// a built-in embedded in the binary.
129 ///
130 /// Kept for diagnostics and for the order [`ComponentBytes`] are folded into
131 /// `ruleset_hash` in. Nothing reads the file again — see [`ComponentRule::bytes`].
132 ///
133 /// **A built-in's is a specifier rather than a path, and cannot collide with one.** A
134 /// confined path is absolute, because `RuleRoot::confine` canonicalizes; `lanekeep/no-unwrap`
135 /// is relative. So a project that happens to have `lanekeep/no-unwrap.wasm` inside its rules
136 /// root sorts and dedups separately, as two different rules should.
137 pub path: PathBuf,
138 /// Which of the component's rules this is: an index into what its `rules` export lists.
139 ///
140 /// **A component hosts a list, so naming one is a position and not merely a file.** Every
141 /// export but `rules` takes this index, so it is what tells `configure`, `metadata`,
142 /// `check` and `reduce` which rule they are being asked about. A component hosting one
143 /// rule is `0`, which is what every reference resolved to before a component could host
144 /// more than one.
145 ///
146 /// It is folded into `ruleset_hash` beside the component's identity rather than being
147 /// carried only for execution: two rules of one component share every byte of code, so the
148 /// index is the whole of what distinguishes the programs they run. Without it, "rule 0 and
149 /// rule 1 of this component" and "rule 0 of this component, twice" are one cache key.
150 pub index: u32,
151 /// What `configure` is called with, as JSON — `"null"` for a rule named with no options.
152 ///
153 /// A string rather than a `serde_json::Value` because that is what crosses the boundary:
154 /// a component cannot close over a host-supplied value the way a JavaScript factory does,
155 /// so its options arrive as data. Serializing once here also fixes the bytes, which
156 /// matters because they are what every worker's `configure` is handed.
157 pub options: String,
158 /// The component itself, read exactly once.
159 ///
160 /// **The rule that was described has to be the rule that runs.** The bytes used to be read
161 /// three times in a run — once to ask the component what it is, once to hash it, once to
162 /// execute it — and a file that changed between those reads would give metadata from one,
163 /// a cache key from a second and handlers from a third, with nothing to notice. That is
164 /// the same property the TypeScript path already has for free: `hash_ruleset` folds what
165 /// `RuleLoader` actually consumed, not a second read of the same paths.
166 ///
167 /// So they are read once, here, and carried: `metadata` is read from them, `ruleset_hash`
168 /// folds them, and `lanekeep-engine` loads the component from them rather than from the
169 /// path beside them.
170 ///
171 /// Behind an [`std::sync::Arc`], because a `RuleSpec` is cloned per rule when the engine
172 /// prepares and a per-rule copy of a megabyte is a cost with nothing to buy it.
173 pub bytes: ComponentBytes,
174 /// The component's sidecar source map, or `None` for one that ships without one.
175 ///
176 /// **Carried beside the bytes for the same reason they are carried at all**: the component
177 /// that was described has to be the component that runs, and the map is only correct for the
178 /// bundle it was generated from. Reading it a second time later, from a path, would let a
179 /// file that changed in between explain the positions of a program it does not describe.
180 ///
181 /// **Not a `ruleset_hash` input, and that is a decision rather than an omission.** A map
182 /// changes exactly one thing: where a *thrown* rule error is reported. It cannot move a
183 /// violation — a violation's position comes from the parse tree by way of a node handle — and
184 /// every failure it touches cancels the run, so no cache entry is ever written by a run whose
185 /// output it affected. Two runs differing only in their maps produce byte-identical output for
186 /// every file that completes.
187 pub source_map: Option<ComponentBytes>,
188 /// Whether these exact bytes were folded into the `ruleset_hash` of the `Config` this
189 /// rule sits in.
190 ///
191 /// `true` for every `ComponentRule` `describe_components` builds — the only constructor
192 /// in this crate, and its output is exactly what `build` folds into `ruleset_hash` a few
193 /// lines later, over the very `rules` this value ends up attached to. Private, so nothing
194 /// outside this crate can construct one that claims coverage it does not have: the only
195 /// other way to get a `ComponentRule` is [`ComponentRule::uncounted`], which is honest
196 /// about the alternative.
197 ///
198 /// This is `Engine::caching`'s one input for the question its field doc calls "asking
199 /// where the field came from" — a `RuleSpec` an embedder or a test attaches after
200 /// `lanekeep_config::load` returns carries a component whose bytes reached no hash, and
201 /// `lanekeep-engine` reads this flag to refuse the cache for exactly that run.
202 counted_in_ruleset_hash: bool,
203}
204
205impl ComponentRule {
206 /// Whether these bytes are folded into the `ruleset_hash` of the `Config` they arrived
207 /// with — see the field.
208 #[must_use]
209 pub const fn counted_in_ruleset_hash(&self) -> bool {
210 self.counted_in_ruleset_hash
211 }
212
213 /// Build a `ComponentRule` outside `lanekeep_config::load`.
214 ///
215 /// **Whatever this produces is not folded into any `Config`'s `ruleset_hash`,** because
216 /// nothing here computes one — that happens exactly once, inside `load`, over whichever
217 /// rules were in `Config.rules` at the moment it returned. This is for an embedder, or a
218 /// test, that attaches a component to a `RuleSpec` afterward: `lanekeep-engine`'s own
219 /// component tests are exactly that, which is why `Engine::caching` refuses the cache for
220 /// a run carrying one of these.
221 #[must_use]
222 pub fn uncounted(
223 path: PathBuf,
224 index: u32,
225 options: String,
226 bytes: impl Into<ComponentBytes>,
227 ) -> Self {
228 Self {
229 path,
230 index,
231 options,
232 bytes: bytes.into(),
233 // No map, because there is no honest way to take one here: a caller attaching a
234 // component after `load` has returned has no component-to-map pairing this crate
235 // could check, and a mispaired map reports arbitrary lines of real files. The cost
236 // is a stack in the space the guest was compiled to, which is what an embedder
237 // driving a component directly already gets.
238 source_map: None,
239 counted_in_ruleset_hash: false,
240 }
241 }
242}
243
244/// A component's bytes, shared rather than copied.
245///
246/// A newtype for one reason: [`RuleSpec`] derives `Debug`, and a bare byte slice renders every
247/// byte of a forty-kilobyte artifact into any assertion message that prints a rule. This
248/// prints what a reader can act on — how many bytes there are — and the equality that
249/// `Config`'s own `PartialEq` needs is still over the content.
250#[derive(Clone, PartialEq, Eq)]
251pub struct ComponentBytes(std::sync::Arc<[u8]>);
252
253impl ComponentBytes {
254 /// The bytes.
255 #[must_use]
256 pub fn as_slice(&self) -> &[u8] {
257 &self.0
258 }
259}
260
261impl From<Vec<u8>> for ComponentBytes {
262 fn from(bytes: Vec<u8>) -> Self {
263 Self(bytes.into())
264 }
265}
266
267impl std::fmt::Debug for ComponentBytes {
268 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269 f.debug_struct("ComponentBytes")
270 .field("len", &self.0.len())
271 .finish()
272 }
273}
274
275/// Policy for suppression directives: which shapes of valid directive a project accepts.
276///
277/// All three keys default off, so an existing config changes nothing. A policy violation is
278/// reported as an ordinary `lanekeep/suppression` violation at the directive's own position;
279/// the directive still silences what it names.
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
281pub struct SuppressionPolicy {
282 /// A valid directive with no `expires:` is reported.
283 pub require_expiry: bool,
284 /// An expiry more than this many days after the run's `today` is reported.
285 pub max_expiry_days: Option<u32>,
286 /// Any whole-file directive is reported.
287 pub forbid_file_scope: bool,
288}
289
290/// A loaded, validated configuration.
291#[expect(
292 clippy::struct_field_names,
293 reason = "`ruleset_hash` and `config_hash` are the names docs/architecture.md §8.1 \
294 gives these two cache-key inputs. Renaming them to satisfy the lint would \
295 make the code and the specification disagree about the same thing, which \
296 costs more than the repetition saves."
297)]
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct Config {
300 /// Globs selecting files to check.
301 pub include: Vec<String>,
302 /// Globs excluding files from the selection.
303 pub exclude: Vec<String>,
304 /// Rules, in the order the config listed them.
305 pub rules: Vec<RuleSpec>,
306 /// Budgets, with defaults filled in.
307 pub limits: Limits,
308 /// The project's policy for which shapes of valid directive it accepts.
309 pub suppressions: SuppressionPolicy,
310 /// Hash of every module in the rule import graph.
311 pub ruleset_hash: Hash,
312 /// Hash of the configuration values.
313 pub config_hash: Hash,
314}
315
316/// Why a configuration could not be loaded.
317#[derive(Debug, Clone, PartialEq, Eq, Error)]
318pub enum ConfigError {
319 /// The config file does not exist or sits outside the project.
320 #[error("cannot load config `{path}`: {detail}")]
321 Unreadable {
322 /// The path as given.
323 path: String,
324 /// What went wrong.
325 detail: String,
326 },
327
328 /// The config module threw, failed to parse, or breached a limit.
329 #[error("config `{path}` failed to evaluate\n{detail}")]
330 Evaluation {
331 /// The path as given.
332 path: String,
333 /// The sandbox's account of it.
334 detail: String,
335 },
336
337 /// The config evaluated but is not shaped like a config.
338 #[error("config `{path}` is not valid: {detail}")]
339 Shape {
340 /// The path as given.
341 path: String,
342 /// What is wrong.
343 detail: String,
344 },
345
346 /// A rule in the config is not usable.
347 #[error("rule {position} in `{path}` is not valid: {detail}")]
348 Rule {
349 /// One-based position in the `rules` array, so an unnamed rule can still be found.
350 position: usize,
351 /// The path as given.
352 path: String,
353 /// What is wrong.
354 detail: String,
355 },
356}
357
358/// The shape `JSON.stringify` hands back. Deliberately permissive — every field is checked
359/// afterwards, so a malformed config produces a diagnostic naming the field rather than a
360/// deserialization error naming a line of JSON the user never wrote.
361#[derive(Debug, Deserialize)]
362struct RawConfig {
363 #[serde(default)]
364 include: Vec<String>,
365 #[serde(default)]
366 exclude: Vec<String>,
367 #[serde(default)]
368 namespaces: Vec<String>,
369 #[serde(default)]
370 severity: BTreeMap<String, String>,
371 #[serde(default)]
372 timeouts: RawTimeouts,
373 #[serde(default)]
374 suppressions: RawSuppressions,
375 #[serde(default)]
376 rules: Vec<RawRule>,
377}
378
379#[derive(Debug, Default, Deserialize)]
380struct RawTimeouts {
381 rule: Option<u64>,
382 global: Option<u64>,
383}
384
385/// The `suppressions` block as written — permissive, like [`RawTimeouts`], because the
386/// validation happens in `build`, where a malformed value becomes a diagnostic naming the
387/// field rather than a deserialization error naming a line of JSON the user never wrote.
388///
389/// Keys are camelCase in both config formats, matching the schema and the TypeScript
390/// interface `lanekeep-types-gen` renders.
391#[derive(Debug, Default, Deserialize)]
392#[serde(rename_all = "camelCase")]
393struct RawSuppressions {
394 #[serde(default)]
395 require_expiry: bool,
396 #[serde(default)]
397 max_expiry_days: Option<u32>,
398 #[serde(default)]
399 forbid_file_scope: bool,
400}
401
402#[derive(Debug, Deserialize)]
403struct RawRule {
404 id: Option<String>,
405 language: Option<RawLanguages>,
406 severity: Option<String>,
407 card: Option<RawCard>,
408 query: Option<RawQueries>,
409 #[serde(default)]
410 gates: Gates,
411 timeout: Option<u64>,
412 has_check: bool,
413 has_reduce: bool,
414}
415
416/// `language: 'tsx'` and `language: ['typescript', 'tsx']` are both ordinary things to write.
417#[derive(Debug, Deserialize)]
418#[serde(untagged)]
419enum RawLanguages {
420 One(String),
421 Many(Vec<String>),
422}
423
424impl RawLanguages {
425 fn into_vec(self) -> Vec<String> {
426 match self {
427 Self::One(language) => vec![language],
428 Self::Many(languages) => languages,
429 }
430 }
431}
432
433/// The tree-sitter query a rule declares, in either of the two authoring shapes.
434///
435/// `One` is the sugar: one query string for every language the rule targets. `Many` maps a
436/// language to its own query, which is what lets one rule span grammars that do not share
437/// node vocabulary. Both normalize to one entry per declared language in `build_rule`, where
438/// the exact cover is enforced.
439///
440/// Deserialized by hand rather than with `#[serde(untagged)]`, because untagged buffers the
441/// value and, on a mismatch, reports `data did not match any variant of untagged enum
442/// RawQueries` — a message naming a private Rust type, with the field and the expected shape
443/// gone. A `query` is the field an author gets wrong most now that it holds two shapes, so
444/// its refusal has to say what a query may be.
445#[derive(Debug, Clone, PartialEq, Eq)]
446enum RawQueries {
447 One(String),
448 Many(BTreeMap<String, String>),
449}
450
451impl<'de> Deserialize<'de> for RawQueries {
452 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
453 where
454 D: serde::Deserializer<'de>,
455 {
456 use serde::de::Error;
457
458 match serde_json::Value::deserialize(deserializer)? {
459 serde_json::Value::String(query) => Ok(Self::One(query)),
460 serde_json::Value::Object(entries) => {
461 let mut queries = BTreeMap::new();
462 for (language, query) in entries {
463 let serde_json::Value::String(query) = query else {
464 return Err(D::Error::custom(format!(
465 "`query` for `{language}` must be a string, not {}",
466 json_kind(&query)
467 )));
468 };
469 queries.insert(language, query);
470 }
471 Ok(Self::Many(queries))
472 }
473 other => Err(D::Error::custom(format!(
474 "`query` must be a string, or an object mapping each language to its own \
475 query, not {}",
476 json_kind(&other)
477 ))),
478 }
479 }
480}
481
482/// What a JSON value is, for a refusal that names the shape it got.
483const fn json_kind(value: &serde_json::Value) -> &'static str {
484 match value {
485 serde_json::Value::Null => "null",
486 serde_json::Value::Bool(_) => "a boolean",
487 serde_json::Value::Number(_) => "a number",
488 serde_json::Value::String(_) => "a string",
489 serde_json::Value::Array(_) => "an array",
490 serde_json::Value::Object(_) => "an object",
491 }
492}
493
494#[derive(Debug, Deserialize)]
495struct RawCard {
496 message: Option<String>,
497 remediation: Option<String>,
498 examples: Option<RawExamples>,
499}
500
501#[derive(Debug, Deserialize)]
502struct RawExamples {
503 bad: Option<String>,
504 good: Option<String>,
505}
506
507/// The name of the synthetic entry module.
508///
509/// It has to sit inside the rules root, because the resolver treats a module's name as its
510/// path when resolving that module's imports.
511const ENTRY: &str = "__lanekeep_entry__.js";
512
513/// The script that reduces the config to JSON.
514///
515/// `has_check` and `has_reduce` are recorded here rather than inferred later, because
516/// `JSON.stringify` drops functions and there is no way to tell afterwards whether a rule
517/// had a handler or a typo.
518const EXTRACT: &str = r"
519 (() => {
520 const c = globalThis.__lanekeepConfig;
521 if (c === null || typeof c !== 'object') return JSON.stringify(null);
522 const rules = Array.isArray(c.rules) ? c.rules : [];
523 return JSON.stringify({
524 include: c.include ?? [],
525 namespaces: c.namespaces ?? [],
526 exclude: c.exclude ?? [],
527 severity: c.severity ?? {},
528 timeouts: c.timeouts ?? {},
529 suppressions: c.suppressions ?? {},
530 rules: rules.map((r) => ({
531 id: r?.id ?? null,
532 language: r?.language ?? null,
533 severity: r?.severity ?? null,
534 card: r?.card ?? null,
535 query: r?.query ?? null,
536 gates: r?.gates ?? {},
537 timeout: r?.timeout ?? null,
538 has_check: typeof r?.check === 'function',
539 has_reduce: typeof r?.reduce === 'function',
540 })),
541 });
542 })()
543";
544
545/// The entry module the loader evaluates, and — for a JSON config — everything about it
546/// that never needed evaluating.
547///
548/// # The two formats no longer share a mechanism, and what holds them together now
549///
550/// They used to. A JSON config was compiled into the same module a TypeScript one is
551/// imported by, so extraction, validation, hashing and the cache key never learned which
552/// format they came from, and `json.rs`'s header said outright that this is why "the two
553/// cannot drift." That mechanism is gone from the JSON path: `lanekeep.json` is parsed,
554/// validated and resolved in Rust, and its `include`, `exclude`, `namespaces`, `severity`
555/// and `timeouts` never become JavaScript at all.
556///
557/// **That is what the un-coupling costs.** Two code paths can drift where one could not.
558/// Three things substitute for the mechanism, and they are named here rather than left
559/// implied, because two of them are conventions and only one is enforced.
560///
561/// *Enforced.* `json::parse` builds the **shared** `RawConfig` with an exhaustive struct
562/// literal, so a field added to it is a compile error on the JSON side rather than a setting
563/// that quietly stops being carried. This is the one guard that is stronger than what it
564/// replaced — the same omission from the old entry module's `format!` string compiled.
565///
566/// *Convention.* The two paths still converge at [`build`], the only place a `Config` is
567/// constructed, a severity override applied, a card validated or a hash taken, so a
568/// divergence has to be introduced upstream of a single function rather than anywhere.
569///
570/// *Convention.* The cache-key properties §8.1 depends on are asserted against **both** paths
571/// in this file's tests, deliberately in matched pairs. Nothing enforces that a new property
572/// gets both halves; the pairing is named in the tests so that dropping one is visible.
573///
574/// # Why `lanekeep-js` is still a dependency of this crate
575///
576/// Because `lanekeep.config.ts` is still evaluated, and will be until the last rule has
577/// migrated to a component — the accepted ADR's condition 8. Nothing here is a step toward
578/// deleting the sandbox on this crate's own schedule.
579///
580/// The JSON path also still reaches the sandbox, for one thing and not for configuration: a
581/// reference naming a TypeScript rule is imported so its `defineRule` object can be read.
582/// That is rule execution, which is the part condition 8 keeps. Nothing else crosses, which
583/// `json::tests::no_configuration_data_reaches_the_entry_module` holds the line on.
584///
585/// # What unblocks removing QuickJS, and what does not
586///
587/// Un-coupling this path is one of condition 8's two preconditions. **The other is open and
588/// this change does not answer it**: the ADR's §7.6 asks what a programmable
589/// `lanekeep.config.ts` means once there is no JavaScript sandbox — arbitrary composition
590/// logic, a shared preset imported as a module and spread into another config, per
591/// `docs/architecture.md` §9. At least three shapes are plausible and no measurement picks
592/// between them: configuration stops being programmable and becomes JSON-only; configuration
593/// becomes its own component with a config-shaped WIT world; or a minimal JavaScript
594/// evaluator is deliberately retained for configuration alone, decoupled from rule
595/// execution. It is a decision about what lanekeep's configuration language should be, and
596/// nobody has made it.
597///
598/// This function reading JSON without a sandbox is *not* that decision, and must not be read
599/// as evidence for the first shape. It says a config format that was never programmable does
600/// not need an evaluator, which was true before this change too.
601fn entry_source(
602 root: &RuleRoot,
603 config_path: &Path,
604 display: &str,
605) -> Result<(String, Option<json::Parsed>), ConfigError> {
606 if json::is_json(config_path) {
607 let parsed = json::parse(config_path, root.path(), root.builtin_components())?;
608 let source = json::rules_module(&parsed.rules);
609 return Ok((source, Some(parsed)));
610 }
611
612 let specifier =
613 relative_specifier(root.path(), config_path).ok_or_else(|| ConfigError::Unreadable {
614 path: display.to_owned(),
615 detail: "the config file must sit inside the rules root".to_owned(),
616 })?;
617 Ok((
618 format!("import config from '{specifier}';\nglobalThis.__lanekeepConfig = config;\n"),
619 None,
620 ))
621}
622
623/// Evaluate the config module into a sandbox, leaving the rule objects reachable.
624///
625/// Separate from [`load`] because every worker needs the ruleset present in its own engine
626/// — a rule's `check` is a function, and a function cannot be moved between runtimes. Each
627/// worker therefore evaluates the same modules rather than receiving extracted values.
628///
629/// # Errors
630///
631/// Returns [`ConfigError`] when the config sits outside the rules root or fails to
632/// evaluate.
633pub fn evaluate_into(
634 sandbox: &Sandbox,
635 root: &RuleRoot,
636 config_path: &Path,
637) -> Result<(), ConfigError> {
638 let display = config_path.display().to_string();
639 let entry = root.path().join(ENTRY);
640 let (source, _) = entry_source(root, config_path, &display)?;
641
642 sandbox
643 .eval_module(&entry.display().to_string(), &source)
644 .map_err(|e| ConfigError::Evaluation {
645 path: display,
646 detail: e.to_string(),
647 })
648}
649
650/// Load and validate a configuration.
651///
652/// # Errors
653///
654/// Returns [`ConfigError`] when the file cannot be read, the module fails to evaluate, or
655/// the result is not shaped like a config.
656pub fn load(sandbox: &Sandbox, root: &RuleRoot, config_path: &Path) -> Result<Config, ConfigError> {
657 load_with(sandbox, root, config_path, LoadOptions::default())
658}
659
660/// What a load needs beyond the config file, for a caller that has more to say than [`load`]
661/// can carry.
662///
663/// A struct rather than two more parameters, for the reason `lanekeep-cli`'s `CheckOptions`
664/// gives: `artifacts` and the config path are both `&Path`, and adjacent parameters of one
665/// type are the shape that gets silently transposed at a call site.
666#[derive(Debug, Clone, Copy, Default)]
667pub struct LoadOptions<'a> {
668 /// A project root under which compiled components may be cached, or `None` for a load with
669 /// nowhere to write.
670 ///
671 /// **This is the difference between a component costing something per config load and
672 /// costing nothing.** With `None`, `describe_components` compiles every component from
673 /// scratch to ask it what it is, throws the compilation away, and the engine compiles the
674 /// same bytes again at prepare time. Measured on the release binary, one 26 KB Rust
675 /// component added ~58 ms to a `lanekeep rules` that checks no files at all, and two added
676 /// ~116 ms — against a §15 warm-run budget of 25 ms for the whole invocation. Config load
677 /// runs per LSP request, per MCP tool call and per `--watch` iteration, so this is paid on
678 /// every one of them.
679 ///
680 /// **Those figures are for a component of a few tens of kilobytes and do not generalize.**
681 /// The shared TypeScript built-ins are one 12.4 MiB artifact, and compiling it is about six
682 /// seconds — a hundredfold, not a factor. `docs/architecture.md` §15 has the table. So the
683 /// choice between `Some` and `None` is a question about seconds rather than milliseconds for
684 /// any caller whose config names one of those four rules, which is why
685 /// `RuleTester::for_built_in` would be unusable without a root and why `lanekeep-testkit`
686 /// names one.
687 ///
688 /// Given a root, both loads write and map artifacts under the same [`COMPONENT_CACHE_PATH`],
689 /// keyed on the specifier and the bytes — so the first run compiles once instead of twice and
690 /// every later run maps what that run wrote. The two loaders agree because both build their
691 /// `wasmtime::Engine` with `WasmEngine::new`; an artifact a different build wrote fails to
692 /// deserialize and is discarded rather than trusted.
693 ///
694 /// Named by the caller rather than inferred, because a rules root is the project root only by
695 /// the CLI's choice, and guessing would make loading a config write somewhere nobody asked
696 /// for. `lanekeep-testkit` anchors a rules root at a temporary fixture directory and names
697 /// *that* — which is the shape this field is for: the caller knows it owns the directory and
698 /// removes it, and this function could not have known either.
699 ///
700 /// [`COMPONENT_CACHE_PATH`]: lanekeep_wasm::COMPONENT_CACHE_PATH
701 pub artifacts: Option<&'a Path>,
702
703 /// Overrides `timeouts.global` from the config file, for a caller holding a more specific
704 /// statement — `--timeout`, which a user typed on this run.
705 ///
706 /// **It has to arrive here rather than be applied to the returned [`Config`], because config
707 /// load is itself a phase that runs guest code.** `describe_components` instantiates,
708 /// `configure`s and calls `metadata` on every component under a clock of its own, and that
709 /// clock is built before this function returns. A caller that loaded first and assigned to
710 /// `Config::limits` afterwards would leave that phase governed by the config file's number
711 /// while the message a breach prints tells the user to raise it with `--timeout` — advice
712 /// that could not work. `AGENTS.md` records the original instance of exactly this shape.
713 pub global_timeout: Option<Duration>,
714}
715
716/// [`load`], with everything a caller knows that the config file does not.
717///
718/// See [`LoadOptions`] for what each field buys and why it has to be known before the load
719/// rather than applied to the [`Config`] it returns.
720///
721/// # Errors
722///
723/// As [`load`].
724pub fn load_with(
725 sandbox: &Sandbox,
726 root: &RuleRoot,
727 config_path: &Path,
728 options: LoadOptions<'_>,
729) -> Result<Config, ConfigError> {
730 let display = config_path.display().to_string();
731
732 let entry = root.path().join(ENTRY);
733 let (source, parsed) = entry_source(root, config_path, &display)?;
734 sandbox
735 .eval_module(&entry.display().to_string(), &source)
736 .map_err(|e| ConfigError::Evaluation {
737 path: display.clone(),
738 detail: e.to_string(),
739 })?;
740
741 let json: String = sandbox.eval(EXTRACT).map_err(|e| ConfigError::Evaluation {
742 path: display.clone(),
743 detail: e.to_string(),
744 })?;
745
746 let extracted: Option<RawConfig> =
747 serde_json::from_str(&json).map_err(|e| ConfigError::Shape {
748 path: display.clone(),
749 detail: e.to_string(),
750 })?;
751 let extracted = extracted.ok_or_else(|| ConfigError::Shape {
752 path: display.clone(),
753 detail: "the default export is not an object — did you forget `export default`?".to_owned(),
754 })?;
755
756 // A JSON config supplies its own data; exactly one field comes back from the sandbox,
757 // and it is spelled out rather than merged, so a field added to `RawConfig` cannot
758 // quietly start being read from the wrong side.
759 let (raw, resolved) = match parsed {
760 Some(parsed) => (
761 RawConfig {
762 rules: extracted.rules,
763 ..parsed.config
764 },
765 parsed.rules,
766 ),
767 None => (extracted, Vec::new()),
768 };
769
770 build(sandbox, root, raw, &display, &resolved, options)
771}
772
773fn build(
774 sandbox: &Sandbox,
775 root: &RuleRoot,
776 raw: RawConfig,
777 display: &str,
778 resolved: &[ResolvedRule],
779 options: LoadOptions<'_>,
780) -> Result<Config, ConfigError> {
781 let overrides = parse_severity_overrides(&raw.severity, display)?;
782
783 // Namespaces this project claims, beyond the two lanekeep defines. Validated for shape
784 // here so a malformed one is reported against `namespaces` rather than against whichever
785 // rule happened to use it first.
786 let mut declared = BTreeSet::new();
787 for namespace in &raw.namespaces {
788 RuleId::namespace_from_str(namespace).map_err(|e| ConfigError::Shape {
789 path: display.to_owned(),
790 detail: format!("`namespaces` contains an invalid entry: {e}"),
791 })?;
792 if namespace == Namespace::LANEKEEP {
793 return Err(ConfigError::Shape {
794 path: display.to_owned(),
795 detail: "`lanekeep` is reserved for rules shipped with lanekeep — a rule's \
796 origin should be readable from its ID"
797 .to_owned(),
798 });
799 }
800 declared.insert(namespace.clone());
801 }
802
803 // The budgets, worked out before anything runs under them. `describe_components` executes
804 // guest code — instantiation, `configure`, `metadata` — and a component asked what it is
805 // under a budget the config did not set is a limit that was parsed and then dropped, which
806 // `AGENTS.md` records as the shape of the `--timeout` bug: accepted, validated, ignored.
807 //
808 // **The caller's override is folded in *here*, not applied to the `Config` this returns.**
809 // That is the same bug in a new phase, and it was live for the length of this branch: the CLI
810 // loaded the config, then assigned `--timeout` to `loaded.limits`, one statement after the
811 // phase it was meant to govern had already finished. A component whose `configure` overran
812 // failed with a message ending "raise it with `--timeout`", and raising it changed nothing.
813 // Resolving it before `describe_components` is what makes one number govern both phases.
814 let mut limits = Limits::default();
815 if let Some(ms) = raw.timeouts.rule {
816 limits = limits.with_rule_timeout(Duration::from_millis(ms));
817 }
818 if let Some(ms) = raw.timeouts.global {
819 limits = limits.with_global_timeout(Duration::from_millis(ms));
820 }
821 if let Some(global) = options.global_timeout {
822 limits = limits.with_global_timeout(global);
823 }
824
825 // The suppression policy, validated once here — the single construction point both
826 // config formats converge on, which is what makes a `suppressions` block written in
827 // either format behave identically. Reached by `hash_config` below, because anything a
828 // config can say has to reach one of the two hashes on purpose (`AGENTS.md`).
829 let suppressions = parse_suppressions(&raw.suppressions, display)?;
830
831 // Every component in the config, asked what it is. Once, here, before a `RuleSpec` exists
832 // — not per worker: instantiation is 82 to 96 times the cost of not instantiating, which
833 // is why `lanekeep_wasm::WasmRuntime::rule` defers it, and reading metadata through a
834 // worker's runtime would undo that for every rule in the set.
835 let mut described = describe_components(root, resolved, display, limits, options.artifacts)?;
836
837 let mut rules = Vec::with_capacity(raw.rules.len());
838 for (index, rule) in raw.rules.into_iter().enumerate() {
839 // A component's entry in `raw.rules` is the placeholder `rules_module` emitted for it,
840 // carrying nothing; what describes it is its own `metadata`. The two lists are indexed
841 // alike by construction, which is the whole reason the placeholder is there.
842 //
843 // **One reference, one placeholder, and any number of rules.** A component hosts a list,
844 // so a single entry in the config's array can produce several `RuleSpec`s — every one of
845 // them carrying `index + 1` as its position, because that is where the *reference* sits
846 // and the entry module has exactly one slot for it. A TypeScript rule after a component
847 // therefore keeps its own position whatever the component turned out to hold, which is
848 // what `RuleSpec::index` has to be true of.
849 match described.get_mut(index).and_then(Option::take) {
850 Some(hosted) => {
851 for rule in hosted {
852 rules.push(build_rule(
853 rule.raw,
854 index + 1,
855 display,
856 &overrides,
857 &declared,
858 Some(rule.component),
859 )?);
860 }
861 }
862 None => rules.push(build_rule(
863 rule,
864 index + 1,
865 display,
866 &overrides,
867 &declared,
868 None,
869 )?),
870 }
871 }
872
873 // Every description has to have been claimed by a rule. One left over means the entry
874 // module's array and the config's rule list came out different lengths, and the loop above
875 // would then have dropped a component rule without saying so — a configured rule that
876 // silently checks nothing is the failure this tool exists not to produce. Unreachable while
877 // `rules_module` emits one array entry per reference, which is exactly why it is asserted
878 // rather than assumed: the placeholder is what makes it true, and a future edit that
879 // removed it would find this instead of a wrong answer.
880 if let Some(position) = described.iter().position(Option::is_some) {
881 return Err(ConfigError::Rule {
882 position: position + 1,
883 path: display.to_owned(),
884 detail: "this component reached no rule — the entry module's rule array and the \
885 config's rule list are not the same length"
886 .to_owned(),
887 });
888 }
889
890 // The components, in the order the config listed them, taken back off the rules that were
891 // just built — so what is hashed is what was described and what will run, rather than a
892 // fresh look at the same paths.
893 let components: Vec<&ComponentRule> = rules
894 .iter()
895 .filter_map(|rule| rule.component.as_ref())
896 .collect();
897
898 let ruleset_hash = hash_ruleset(sandbox, &components);
899 let config_hash = hash_config(
900 &raw.include,
901 &raw.exclude,
902 &overrides,
903 &limits,
904 resolved,
905 &suppressions,
906 );
907
908 Ok(Config {
909 include: raw.include,
910 exclude: raw.exclude,
911 rules,
912 limits,
913 suppressions,
914 ruleset_hash,
915 config_hash,
916 })
917}
918
919fn parse_severity_overrides(
920 raw: &BTreeMap<String, String>,
921 display: &str,
922) -> Result<BTreeMap<RuleId, Severity>, ConfigError> {
923 raw.iter()
924 .map(|(id, severity)| {
925 let id = id.parse::<RuleId>().map_err(|e| ConfigError::Shape {
926 path: display.to_owned(),
927 detail: format!("in `severity`: {e}"),
928 })?;
929 let severity = severity
930 .parse::<Severity>()
931 .map_err(|e| ConfigError::Shape {
932 path: display.to_owned(),
933 detail: format!("in `severity` for `{id}`: {e}"),
934 })?;
935 Ok((id, severity))
936 })
937 .collect()
938}
939
940/// Validate the `suppressions` block into the policy the engine enforces.
941///
942/// A single place, reached by both config formats through `build` — the one function every
943/// `Config` is constructed by. `maxExpiryDays` of zero would forbid every expiry the day it
944/// was set; a horizon has to reach at least tomorrow.
945fn parse_suppressions(
946 raw: &RawSuppressions,
947 display: &str,
948) -> Result<SuppressionPolicy, ConfigError> {
949 if raw.max_expiry_days == Some(0) {
950 return Err(ConfigError::Shape {
951 path: display.to_owned(),
952 detail: "in `suppressions`: `maxExpiryDays` must be at least 1".to_owned(),
953 });
954 }
955 Ok(SuppressionPolicy {
956 require_expiry: raw.require_expiry,
957 max_expiry_days: raw.max_expiry_days,
958 forbid_file_scope: raw.forbid_file_scope,
959 })
960}
961
962/// Ask every component the config names which rules it hosts, and what each of them is.
963///
964/// One entry per resolved reference, `Some` for a component and `None` for anything else, so
965/// the answer is indexed by the config's own rule position — the same numbering
966/// `json::rules_module`'s placeholder preserves.
967///
968/// # One reference, a list of rules
969///
970/// **A component hosts a list and a reference names the component, so the entry is a `Vec`.**
971/// Every export but `rules` takes an index into that list, so describing a component means
972/// enumerating it first and then asking about each rule by position. A component hosting one
973/// rule — every component this repository shipped before this — produces a one-element list and
974/// reads exactly as it did.
975///
976/// A reference's options reach *every* rule the component hosts, because a reference names the
977/// component and there is no syntax naming one rule inside it. That is the right shape for the
978/// case that exists — a component built to host a family of related rules, configured as a
979/// family — and it is not the shape a built-in wants, where `lanekeep/no-default-export` has to
980/// mean one rule of a shared artifact. That is a *resolution* question rather than a
981/// description one: it is answered by what `json::classify` hands back, not here.
982///
983/// # Once for the run, and deliberately not through a worker's runtime
984///
985/// Every component is compiled, instantiated, configured and asked about each of its rules
986/// here. That is the cost `lanekeep_wasm::WasmRuntime::rule` exists to avoid paying per worker
987/// — #96's spike measured eager instantiation at 82 to 96 times the lazy arrangement — and it
988/// is paid exactly once, before any worker exists, because a rule that cannot describe itself
989/// cannot be run at all. Nothing built here outlives this function: the engine, the rule set
990/// and the runtime are dropped on the way out, and what survives is the metadata and the path.
991///
992/// **The enumeration costs one instantiation per component that the description then repeats,**
993/// because `RuleSet::add` takes an index and cannot discover one — `rules` is an export, so
994/// asking needs a store and an instance, and a rule set holds neither. The throwaway instance
995/// lives in a runtime of its own, built and dropped inside the loop, so that at most one
996/// instance beyond the description's own is resident at a time rather than one per component.
997///
998/// # What each answer is for
999///
1000/// `metadata` fills every field of the `RuleSpec` a TypeScript rule fills from its own
1001/// `defineRule` call, and it goes through `build_rule` exactly as an extracted TypeScript rule
1002/// does — so a component's id, namespace, card, query and severity are validated by the same
1003/// code, and a component cannot smuggle past a check a TypeScript rule has to satisfy.
1004///
1005/// `has-check` and `has-reduce` are asked rather than assumed, which closes the one place a
1006/// component used to be taken at its config's word about a question it can answer itself.
1007///
1008/// `configure` is not called here and is not skipped: `RuleSet::add` records the options and
1009/// `WasmRuntime::rule` hands them over on the way to the instance `metadata` is read from. So a
1010/// component that refuses its options fails at config load, naming the rule and carrying the
1011/// guest's own message, and the same call happens again on every worker that later builds an
1012/// instance of its own.
1013///
1014/// `rules` is the one export asked *before* configuration, and it is why the world splits it
1015/// from `metadata` rather than returning a list of those. A factory rule's card and query come
1016/// from applying the factory to its options, so metadata has to be read after `configure`; but
1017/// configuring rule *i* means knowing that *i* exists. A rule's id cannot depend on its
1018/// options — the id is how a config names the rule in the first place — so the ids enumerate
1019/// first and everything else follows configuration.
1020///
1021/// # Confinement, before a byte is read — and a built-in has nothing to confine
1022///
1023/// A built-in component is embedded in this binary. There is no path in the config, no file on
1024/// disk and nothing to canonicalize, so the paragraphs below are about a `.wasm` *path*
1025/// reference and only about that. That is not a weaker check for built-ins; it is the absence
1026/// of the thing the check exists to constrain, and it is the same reason a built-in module
1027/// cannot be shadowed by a project file.
1028///
1029/// A rule reference is a string in a config file and a component is *executed*, so where it is
1030/// allowed to point is a trust boundary rather than a convenience. `json::classify` joins the
1031/// specifier against the rules root and normalizes it, which is purely lexical and does not
1032/// confine anything: `Path::join` lets an absolute specifier replace the root outright, and no
1033/// lexical rule can see through a symlink.
1034///
1035/// [`RuleRoot::confine`] is the check, and it is the containment half of the one a module
1036/// import goes through rather than a second set written here: the lexical test that refuses
1037/// `../../evil.wasm` whatever is on disk, then the canonicalization that refuses a symlink
1038/// pointing out of the root. It runs before [`std::fs::read`], so a reference that escapes is
1039/// refused without its bytes ever being loaded, let alone compiled or instantiated.
1040///
1041/// **Containment is all of it, and a module import is held to more.** `RuleRoot::resolve`
1042/// additionally refuses *any* absolute specifier a rule writes, as a bare specifier, before
1043/// containment is considered at all — so `import '/etc/passwd'` and
1044/// `import '/inside/the/root/x'` are both refused, and only the first would be refused here.
1045/// An absolute `.wasm` path that lands inside the rules root is therefore accepted. That is not
1046/// an escape and nothing about the trust boundary turns on it; it is written down because the
1047/// two paths are otherwise easy to read as identical, and the next person to compare them
1048/// should find the difference recorded rather than discover it.
1049///
1050/// # One read
1051///
1052/// The bytes are read here and carried on [`ComponentRule`]. `metadata` is read from them,
1053/// `hash_ruleset` folds them and `lanekeep-engine` executes them, so the rule that was
1054/// described is the rule that runs. Reading three times would let a file that changed in
1055/// between describe one rule, key another and run a third.
1056fn describe_components(
1057 root: &RuleRoot,
1058 resolved: &[ResolvedRule],
1059 display: &str,
1060 limits: Limits,
1061 artifacts: Option<&Path>,
1062) -> Result<Vec<Option<Vec<Described>>>, ConfigError> {
1063 let mut described: Vec<Option<Vec<Described>>> = resolved.iter().map(|_| None).collect();
1064 if !resolved.iter().any(|rule| rule.reference.is_component()) {
1065 return Ok(described);
1066 }
1067
1068 let fail = |position: usize, detail: String| ConfigError::Rule {
1069 position: position + 1,
1070 path: display.to_owned(),
1071 detail,
1072 };
1073 let broken = |detail: String| ConfigError::Shape {
1074 path: display.to_owned(),
1075 detail,
1076 };
1077
1078 let engine = WasmEngine::new().map_err(|e| broken(e.to_string()))?;
1079 let mut set = RuleSet::new(&engine).map_err(|e| broken(e.to_string()))?;
1080 // With the on-disk artifact cache when the caller named a project root, and without one
1081 // otherwise. A rules root is not a project root — `lanekeep-testkit` anchors one at a
1082 // temporary fixture directory — so guessing a location to write `.lanekeep/components` into
1083 // would make loading a config write somewhere nobody asked for. Naming it is
1084 // `LoadOptions::artifacts`, passed through `load_with`; the CLI names the project it was
1085 // pointed at, and `lanekeep-testkit` names the throwaway project it created and removes.
1086 //
1087 // It matters because without one this compiles every component only to throw the
1088 // compilation away, and the engine compiles the same bytes again at prepare time — on every
1089 // config load, and config load runs per LSP request, per MCP tool call and per `--watch`
1090 // iteration. With one, both loads map the same artifact.
1091 //
1092 // **The cost of taking the uncached arm is set by the largest component named, and the two
1093 // sizes that ship differ by two orders of magnitude.** This sentence used to say "~58 ms per
1094 // component" without qualification; that figure was measured against components of about
1095 // 26 KB and is still right for them — a run naming only `lanekeep/no-unwrap` is 80 ms cold
1096 // and its `.cwasm` 356 KB. `typescript-builtins.wasm` is 12.4 MiB, and compiling it is
1097 // **about six seconds** (`docs/architecture.md` §15's table: 6,115 ms cold for one rule of
1098 // it against 32 ms for a module rule). Paid twice, on every load, by every caller that takes
1099 // this arm — which is `lanekeep_config::load` and whoever calls it. `lanekeep-testkit` used
1100 // to be the example here and no longer is: it names its own throwaway project, which it
1101 // created and removes, and takes the cached arm.
1102 let loader = artifacts.map_or_else(
1103 lanekeep_wasm::ComponentLoader::without_cache,
1104 lanekeep_wasm::ComponentLoader::for_project_root,
1105 );
1106
1107 // **Compilation first, and outside the clock that starts below.** Reading and compiling a
1108 // component is host work: it is bounded by the machine and by whether `.lanekeep/components`
1109 // is warm, and by nothing a rule or a config did. Charging it to the run budget made a cold
1110 // run and a warm run over identical input take different exits — a 12.4 MiB JavaScript
1111 // component is seconds to compile and microseconds to map — which puts the compile cache
1112 // into the determinism tuple, where `(bytes, path, ruleset, config, tracked reads)` has no
1113 // term for it.
1114 //
1115 // It has a budget of its own rather than none, and its own diagnostic: the global budget's
1116 // message ends "narrow what is being checked", which is advice that cannot work against a
1117 // fixed compile cost.
1118
1119 let compiled = compile_components(
1120 root,
1121 resolved,
1122 &engine,
1123 &loader,
1124 COMPILE_BUDGET_PER_COMPONENT,
1125 )
1126 .map_err(|(position, detail)| fail(position, detail))?;
1127
1128 // The one clock, started before any guest code runs and shared by the enumeration and the
1129 // description. Two clocks would give each phase the whole global budget, so a config load
1130 // could take twice what the user set and report neither overrun — and the split above does
1131 // not make a second one, because it takes *host* work out of this one rather than putting
1132 // guest work under another.
1133 let clock = RunClock::start(limits.global_timeout);
1134
1135 let mut added = Vec::new();
1136 for entry in &compiled {
1137 let position = entry.position;
1138 let rule = &resolved[position];
1139 let options = &entry.options;
1140
1141 let ids = hosted_rules(&engine, limits, &clock, &entry.admitted)
1142 .map_err(|e| fail(position, e.to_string()))?;
1143
1144 // A component hosting nothing is a configured rule that can never report, which is the
1145 // failure this tool exists not to produce — and it is silent, because an empty list
1146 // reads downstream exactly like a reference nobody wrote. Refused where the reference
1147 // is, so the diagnostic names the entry. The check is a pure helper so it is testable
1148 // without building a component that answers `rules()` with nothing.
1149 if let Err(detail) = no_rules_detail(&ids, &rule.specifier) {
1150 return Err(fail(position, detail));
1151 }
1152
1153 let wanted = contributed(&ids, entry.only, &rule.specifier)
1154 .map_err(|detail| fail(position, detail))?;
1155
1156 for (index, id) in wanted {
1157 // The rule's own id rather than the specifier, because a slot's name is what a
1158 // diagnostic shows a reader and one specifier now stands for several rules.
1159 let slot = set
1160 .add(&id, &entry.admitted, index, options.clone())
1161 .map_err(|e| fail(position, e.to_string()))?;
1162
1163 added.push((
1164 position,
1165 slot,
1166 // The id the component *enumerated*, carried to where its `metadata` is read.
1167 // Two exports answer this question and nothing had ever compared them: a guest
1168 // whose `rules()` and `metadata()` disagree registers a slot under one id and
1169 // builds a `RuleSpec` with another, so a suppression comment naming the id a
1170 // user was shown would silently match nothing.
1171 id,
1172 ComponentRule {
1173 path: entry.origin.clone(),
1174 index,
1175 options: options.clone(),
1176 // An `Arc` clone: the rules of one component share the read, which is what
1177 // makes "read once" per reference rather than per rule.
1178 bytes: entry.bytes.clone(),
1179 // And the map beside them, so `lanekeep-engine` loads this component with
1180 // the map this description was made against rather than looking for one.
1181 source_map: entry.source_map.clone(),
1182 // The one constructor whose output `build` folds into `ruleset_hash` —
1183 // see the field.
1184 counted_in_ruleset_hash: true,
1185 },
1186 ));
1187 }
1188 }
1189
1190 let mut runtime = WasmRuntime::for_rules(engine, std::sync::Arc::new(set), limits, clock);
1191
1192 for (position, slot, enumerated, component) in added {
1193 let metadata = runtime
1194 .metadata(slot)
1195 .map_err(|e| fail(position, e.to_string()))?;
1196
1197 // The guest's two accounts of itself, compared once, here. `rules()` is what a rule was
1198 // registered under and `metadata().id` is what it reports as; the world declares them
1199 // separately because a rule's id has to be knowable before it is configured, and
1200 // separate answers can differ. Everything downstream trusts one or the other without
1201 // being in a position to notice.
1202 if metadata.id != enumerated {
1203 return Err(fail(
1204 position,
1205 format!(
1206 "`{}` enumerates a rule as `{enumerated}` and that rule's metadata calls \
1207 it `{}` — a component has to answer its own id the same way twice",
1208 rule_specifier(resolved, position),
1209 metadata.id
1210 ),
1211 ));
1212 }
1213 let has_check = runtime
1214 .has_check(slot)
1215 .map_err(|e| fail(position, e.to_string()))?;
1216 let has_reduce = runtime
1217 .has_reduce(slot)
1218 .map_err(|e| fail(position, e.to_string()))?;
1219
1220 if let Some(entry) = described.get_mut(position) {
1221 entry.get_or_insert_with(Vec::new).push(Described {
1222 raw: raw_rule_from(metadata, has_check, has_reduce),
1223 component,
1224 });
1225 }
1226 }
1227
1228 Ok(described)
1229}
1230
1231/// The detail string for refusing a component whose `rules()` answered nothing.
1232///
1233/// An empty list is a configured rule that can never report — and it is silent, because an
1234/// empty list reads downstream exactly like a reference nobody wrote. Lifted out of
1235/// [`describe_components`] so the refusal is unit-testable without a `.wasm` fixture: the
1236/// question is whether an empty id list and a specifier produce the refusal, nothing a
1237/// component has to run to answer. The caller wraps the detail in [`ConfigError::Rule`].
1238fn no_rules_detail(ids: &[String], specifier: &str) -> Result<(), String> {
1239 if ids.is_empty() {
1240 return Err(format!(
1241 "`{specifier}` is a component that hosts no rules — there is nothing for this entry \
1242 to run"
1243 ));
1244 }
1245 Ok(())
1246}
1247
1248/// How long one component may take to read, compile and admit.
1249///
1250/// **Not the global run budget, and deliberately not user-configurable.** Compilation is host
1251/// work — it scales with the machine and with whether `.lanekeep/components` is warm, and with
1252/// nothing a rule or a config did — so charging it to the budget that bounds *rule execution*
1253/// makes a cold run and a warm run over identical input take different exits. That is the
1254/// determinism invariant, and `(bytes, path, ruleset, config, tracked reads)` has no term for a
1255/// compile cache.
1256///
1257/// Bounded rather than unbounded, because "it is host work" is not "it may take forever": a
1258/// component that never finishes compiling is a hung tool, and a diagnostic naming compilation
1259/// is one a user can act on.
1260///
1261/// # Ten minutes, and the first number tried was sixty seconds
1262///
1263/// **This is a safety net and not a performance limit, and the difference is the whole
1264/// calibration.** Sixty seconds was picked as roughly ten times the 6.2 s a 12.4 MiB
1265/// StarlingMonkey component takes on an idle laptop — which is exactly the mistake of
1266/// calibrating a wall-clock bound on the fastest machine in the loop. Measured 2026-08-10, the
1267/// `lanekeep-cli` tests cross-compiled for `x86_64-unknown-linux-gnu` and run on Linux under
1268/// QEMU with eight test binaries at once: **one component took 231.3 s to compile**, tripped
1269/// this, and turned twenty-odd unrelated CLI tests red with a message about compilation. The
1270/// same test alone on the same machine finished in 23.6 s.
1271///
1272/// So a bound whose whole purpose is "this cannot possibly be legitimate" has to sit above the
1273/// slowest *legitimate* case, and emulated or heavily contended hardware is an order of
1274/// magnitude slower than an idle laptop rather than a factor of two. Ten minutes per component
1275/// is above everything observed and still far below the point at which a user would conclude
1276/// the process had crashed.
1277///
1278/// **The comparison and the message are tested; the wall-clock value is not, and cannot be.**
1279/// [`compile_overrun`] is a pure function over an elapsed `Duration` and a count, so a test
1280/// drives the arithmetic and the wording with synthetic microseconds — which is what makes this
1281/// branch reachable without a fixture that spends ten minutes getting there. "No fixture can
1282/// afford it, so none of it can be tested" was the reasoning this constant shipped with for one
1283/// round, and it was a false dichotomy: only an *end-to-end* test needs a real slow compile.
1284///
1285/// What no test asserts is that ten minutes is the right number of minutes — that is a judgment
1286/// against measurements, recorded above.
1287///
1288/// **It cannot preempt a single compilation**, and nothing here pretends otherwise: wasmtime's
1289/// compile is synchronous with no interrupt, so this is checked between components. A config
1290/// naming one component that hangs is bounded by nothing here. What it does bound is the
1291/// aggregate, which is the case that scales — and the check is written per component so that a
1292/// config with twenty of them is not held to one component's budget.
1293const COMPILE_BUDGET_PER_COMPONENT: Duration = Duration::from_mins(10);
1294
1295/// One config entry's component, read and compiled, before any guest code runs.
1296///
1297/// Carried by value between the two passes so the compilation is done once. `admitted` is the
1298/// only thing `RuleSet::add` accepts, and it is what makes the import check unavoidable.
1299struct Compiled {
1300 /// The entry's position in `resolved`, for the diagnostic and for `described`.
1301 position: usize,
1302 /// Provenance for [`ComponentRule::path`].
1303 origin: PathBuf,
1304 /// The bytes this was compiled from, folded into `ruleset_hash`.
1305 bytes: ComponentBytes,
1306 /// The component's source map, carried to `ComponentRule` so the engine loads with it too.
1307 source_map: Option<ComponentBytes>,
1308 /// Which rule of the component this entry names, or every one of them.
1309 only: Option<u32>,
1310 /// The entry's options as JSON, serialized once so every worker gets the same bytes.
1311 options: String,
1312 /// The compiled, import-checked component.
1313 ///
1314 /// Behind an [`Arc`] because a shared component — the four migrated built-ins are one — is
1315 /// named once per rule *reference*, and the whole point of the load memo is to deserialize
1316 /// it once and hand the same [`lanekeep_wasm::Loaded`] to every reference. [`RuleSet::add`]
1317 /// then shares the instance on [`lanekeep_wasm::Loaded::identity`], which it already did; the
1318 /// work this avoids is the deserialize, not the instantiation.
1319 admitted: std::sync::Arc<lanekeep_wasm::Loaded>,
1320}
1321
1322/// Read and compile every component the config names, before the run clock starts.
1323///
1324/// **The first of two passes, and the split is what keeps host work out of the run budget.**
1325/// See [`COMPILE_BUDGET_PER_COMPONENT`] for why, and for what bounds this instead.
1326///
1327/// # Errors
1328///
1329/// Returns `(position, detail)` — the caller knows the config's path and wraps it. A component
1330/// that cannot be read, that escapes the rules root, that this build does not have, or that the
1331/// loader refuses fails here, before anything is executed.
1332fn compile_components(
1333 root: &RuleRoot,
1334 resolved: &[ResolvedRule],
1335 engine: &std::sync::Arc<WasmEngine>,
1336 loader: &lanekeep_wasm::ComponentLoader,
1337 budget: Duration,
1338) -> Result<Vec<Compiled>, (usize, String)> {
1339 let started = std::time::Instant::now();
1340 let mut compiled = Vec::new();
1341
1342 // **One deserialize per component, not one per rule reference.** A shared component — the
1343 // four migrated built-ins are one — is named once per rule, and deserializing the same
1344 // ~34 MB artifact four times is the §15 defect: the warm column grows faster than the rule
1345 // count. The loader is lock-free by design (`&self`, so parallel loads never contend), so
1346 // this memo lives here rather than behind a lock in the loader. It is keyed on the
1347 // component's content identity — `blake3::hash` of the bytes, the same digest
1348 // [`lanekeep_wasm::Loaded::identity`] carries and [`RuleSet::add`] already shares instances
1349 // on — and not on the name, because two different components can share a name across
1350 // configs. `RuleSet::add` then shares the instance, which it already did; the work this
1351 // skips is the deserialize.
1352 let mut memo: HashMap<[u8; 32], std::sync::Arc<lanekeep_wasm::Loaded>> = HashMap::new();
1353
1354 for (position, rule) in resolved.iter().enumerate() {
1355 // Whether this reference is a component at all comes first, so a config of TypeScript
1356 // rules with one component in it does no work per rule that is thrown away. Extracting
1357 // the two byte sources into `component_bytes` put the serialization above this test for
1358 // a while, which was a small silent regression on the common shape.
1359 let Some(ComponentSource {
1360 origin,
1361 bytes,
1362 only,
1363 source_map,
1364 }) = component_bytes(root, rule).map_err(|detail| (position, detail))?
1365 else {
1366 continue;
1367 };
1368
1369 // `null` for a rule named with no options, which is the world's own shape for it —
1370 // serialized once here so that every worker's `configure` is handed the same bytes.
1371 let options = rule
1372 .options
1373 .as_ref()
1374 .map_or_else(|| "null".to_owned(), json::literal);
1375
1376 // The identity of these bytes — content rather than name, as above. Hashed here to look
1377 // the memo up *before* paying for a load, so a second reference to one component skips
1378 // `load_mapped` entirely. `load_mapped` hashes the same bytes again to name its
1379 // artifact, so the first reference pays two hashes; that is one hash per unique
1380 // component rather than one per reference, and a blake3 of 34 MB is milliseconds against
1381 // the seconds a deserialize costs.
1382 let identity = *blake3::hash(bytes.as_slice()).as_bytes();
1383
1384 let admitted = if let Some(existing) = memo.get(&identity) {
1385 // The source map is a property of the component, not of the identity, and
1386 // [`RuleSet::add`] already collapses every reference of one identity to the first
1387 // one's map — so handing the first reference's `Loaded` to the rest is consistent
1388 // with the invariant rather than a new assumption about it.
1389 std::sync::Arc::clone(existing)
1390 } else {
1391 let fresh = std::sync::Arc::new(
1392 loader
1393 .load_mapped(
1394 engine,
1395 &rule.specifier,
1396 bytes.as_slice(),
1397 source_map.as_ref().map(ComponentBytes::as_slice),
1398 )
1399 .map_err(|e| (position, e.to_string()))?,
1400 );
1401 memo.insert(identity, std::sync::Arc::clone(&fresh));
1402 fresh
1403 };
1404
1405 compiled.push(Compiled {
1406 position,
1407 origin,
1408 bytes,
1409 source_map,
1410 only,
1411 options,
1412 admitted,
1413 });
1414
1415 // Checked after each component rather than before, because a compilation cannot be
1416 // interrupted once it has started.
1417 if let Some(detail) = compile_overrun(started.elapsed(), compiled.len(), budget) {
1418 return Err((position, detail));
1419 }
1420 }
1421
1422 Ok(compiled)
1423}
1424
1425/// Whether the compilation pass has overrun its budget, and what to say if it has.
1426///
1427/// **A pure function so the budget can be tested at all.** Everything else about the pass needs a
1428/// real component and a real compiler; this is the arithmetic and the wording, and separating it
1429/// is what lets a test drive the comparison with synthetic microsecond `Duration`s instead of a
1430/// fixture that would have to spend ten minutes to reach the branch. The alternative on offer was
1431/// an end-to-end test costing exactly the budget, which is why the branch went untested for a
1432/// round — the dichotomy was false and this is the third thing this change has had to learn it
1433/// about.
1434///
1435/// The budget scales with how many components were asked for, so a config with twenty of them is
1436/// not held to one component's allowance. `saturating_mul` rather than `*`: `Duration`
1437/// multiplication panics on overflow, and the count comes from a config.
1438fn compile_overrun(elapsed: Duration, compiled: usize, budget: Duration) -> Option<String> {
1439 let allowed = budget.saturating_mul(u32::try_from(compiled).unwrap_or(u32::MAX));
1440 if elapsed <= allowed {
1441 return None;
1442 }
1443
1444 Some(format!(
1445 "compiling the rule components took {elapsed:.1?}, past the {allowed:.1?} allowed for \
1446 {compiled} of them\n \
1447 this is the cost of turning WebAssembly into machine code and not of running any rule, \
1448 so narrowing what is checked will not help\n \
1449 a warm `.lanekeep/components` skips it entirely — if this recurs on every run, that \
1450 directory is not writable"
1451 ))
1452}
1453
1454/// Which of a component's rules one config entry stands for, as `(index, id)`.
1455///
1456/// **A built-in names one rule; a `.wasm` path names the artifact.** `lanekeep/no-unwrap` is a
1457/// rule, and the fact that its artifact happens to host one is an accident of how it was built —
1458/// `lanekeep/no-default-export` names a rule of an artifact hosting four, and a reference
1459/// contributing every rule of that component would turn one config entry into four, each of them
1460/// configured with options meant for one. A path has no name to narrow by, so it contributes the
1461/// whole component, which is what a family of rules shipped together is.
1462///
1463/// # Errors
1464///
1465/// Returns the diagnostic detail, without a position: the caller knows which entry this was.
1466///
1467/// An index the component does not have is refused here rather than left to `RuleSet::add`,
1468/// whose message would be about a slot. What went wrong is that `lanekeep_rules`' table and the
1469/// artifact it names disagree, and nobody reading "index 3 is out of range" would go looking for
1470/// that.
1471///
1472/// Whether the *right* rule sits at that index is not answerable from here — a component's ids
1473/// are its own, and a fixture's need not look like a built-in's. `lanekeep-rules`'
1474/// `tests/component_rules.rs` makes that claim, against the real artifacts, in the gate.
1475fn contributed(
1476 ids: &[String],
1477 only: Option<u32>,
1478 specifier: &str,
1479) -> Result<Vec<(u32, String)>, String> {
1480 let Some(index) = only else {
1481 return ids
1482 .iter()
1483 .enumerate()
1484 .map(|(index, id)| {
1485 u32::try_from(index)
1486 .map_err(|_| format!("`{specifier}` lists more rules than an index can name"))
1487 .map(|index| (index, id.clone()))
1488 })
1489 .collect();
1490 };
1491
1492 let declared = ids.get(index as usize).ok_or_else(|| {
1493 format!(
1494 "`{specifier}` is recorded at index {index} of a component hosting {} rule(s) — \
1495 the built-in table and the component disagree",
1496 ids.len()
1497 )
1498 })?;
1499 Ok(vec![(index, declared.clone())])
1500}
1501
1502/// The specifier of the rule at a position, for a diagnostic raised after the loop that had it.
1503///
1504/// The description phase runs over `added` rather than over `resolved`, so the entry a failure
1505/// belongs to is reached by position. An empty string rather than a panic for a position that
1506/// is not there, which cannot happen — every position in `added` came from `resolved` — because
1507/// a diagnostic is not worth aborting a load over.
1508fn rule_specifier(resolved: &[ResolvedRule], position: usize) -> &str {
1509 resolved
1510 .get(position)
1511 .map_or("", |rule| rule.specifier.as_str())
1512}
1513
1514/// Which rules a component hosts, by id, in the order it lists them.
1515///
1516/// **The one question that has to be asked before a rule set can be built.** `RuleSet::add`
1517/// takes an index into this list and cannot discover one for itself: `rules` is an export, so
1518/// asking needs a store and an instance, and a rule set deliberately holds neither.
1519///
1520/// A runtime of its own, built and dropped here. Two things follow. The instance is transient,
1521/// so the enumeration does not leave one resident per component beside the description's; and
1522/// this store is not the store the description runs in, so a component that traps while being
1523/// enumerated poisons nothing that outlives the failure — which costs nothing either way, since
1524/// every failure here aborts the load.
1525///
1526/// The clock is the caller's rather than a fresh one, so the global budget covers the
1527/// enumeration and the description together.
1528///
1529/// # Errors
1530///
1531/// [`lanekeep_wasm::WasmError`] if the world cannot be linked, the component cannot be
1532/// instantiated under the run's limits, or the guest traps while listing its rules.
1533fn hosted_rules(
1534 engine: &std::sync::Arc<WasmEngine>,
1535 limits: Limits,
1536 clock: &std::sync::Arc<RunClock>,
1537 admitted: &lanekeep_wasm::Loaded,
1538) -> Result<Vec<String>, lanekeep_wasm::WasmError> {
1539 let mut probe = WasmRuntime::new(
1540 std::sync::Arc::clone(engine),
1541 limits,
1542 std::sync::Arc::clone(clock),
1543 )?;
1544 let instance = probe.instantiate(admitted)?;
1545 probe.call_rules(&instance)
1546}
1547
1548/// Where one reference's component bytes come from, or `None` if it names no component.
1549///
1550/// **The two sources of a component, in one place.** A built-in is embedded in this binary and a
1551/// project rule is a file inside the rules root, and everything downstream — admission, the rule
1552/// set, `metadata`, `ruleset_hash`, execution — treats them identically from here on. Keeping the
1553/// two arms together is what makes that reading true rather than approximately true: a difference
1554/// between them has to be written in this function, where it can be seen.
1555///
1556/// The first element is provenance for [`ComponentRule::path`] — a canonical path for a file, and
1557/// the `lanekeep/<name>` specifier for a built-in, which is relative and so can never collide
1558/// with one. The third says **which** of the component's rules the reference names: `Some(index)`
1559/// for a built-in, whose name is a rule's, and `None` for a path, which names the artifact and so
1560/// contributes every rule in it.
1561///
1562/// # A loose `.wasm` is reachable and is not a supported interface
1563///
1564/// The file arm below means a `lanekeep.json` naming `./rules/mine.wasm` loads and runs it, and
1565/// the containment tests beside it are real. It is nonetheless **not** a documented feature:
1566/// `schema/lanekeep.schema.json` describes built-ins and `./path.ts` only, and
1567/// `docs/authoring-rust-rules.md` is about the built-ins in this repository rather than about a
1568/// project shipping its own component.
1569///
1570/// That is a decision rather than an oversight, taken because supporting it means promising
1571/// something not yet true. A third-party component binds against `crates/lanekeep-wasm/wit`,
1572/// whose bytes are a *cache key* and not a stability promise — it changes without ceremony, and
1573/// this branch changed it twice — so a rule built against one lanekeep would silently target a
1574/// world the next one does not have. Advertising the path before there is a versioned world and
1575/// a published authoring story would be committing to an ABI nothing currently keeps.
1576///
1577/// The arm stays because built-ins and fixtures reach it by the same route, and narrowing it to
1578/// built-ins would put a difference between the two sources back into a function whose whole
1579/// purpose is that there is not one. Anyone deciding to support it should add the schema entry
1580/// and the authoring documentation in that change, and say what the world's stability is.
1581///
1582/// # Errors
1583///
1584/// Returns the diagnostic detail, without a position: the caller knows which rule this was and
1585/// wraps it. A built-in that the lookup does not know is *unreachable* while `json::classify`
1586/// asks the very lookup this reads — the reference is only that variant because the name
1587/// answered. It is refused rather than assumed away because the two calls are in different
1588/// crates, and a rules root rebuilt between them without its components would otherwise produce
1589/// a rule with no `check` rather than an explanation.
1590/// What a config entry's component reference resolved to, before anything is compiled.
1591///
1592/// A struct rather than a tuple because it grew a fourth member whose meaning is not readable
1593/// from its position — three of these are `Option`s or paths and the reader has to be told which
1594/// is which.
1595struct ComponentSource {
1596 /// Provenance for [`ComponentRule::path`]: a confined path, or `lanekeep/<name>`.
1597 origin: PathBuf,
1598 /// The component itself, read exactly once.
1599 bytes: ComponentBytes,
1600 /// Which of the component's rules this entry names, or every one of them.
1601 only: Option<u32>,
1602 /// The component's source map, if it ships one.
1603 source_map: Option<ComponentBytes>,
1604}
1605
1606fn component_bytes(
1607 root: &RuleRoot,
1608 rule: &ResolvedRule,
1609) -> Result<Option<ComponentSource>, String> {
1610 match &rule.reference {
1611 // Embedded in this binary, so there is no path to confine and no file to read — and
1612 // nothing a project file could shadow, which is the guarantee a built-in module has too.
1613 RuleReference::BuiltinComponent(name) => {
1614 let (bytes, index) = root.builtin_component(name).ok_or_else(|| {
1615 format!(
1616 "`lanekeep/{name}` was resolved as a built-in component and this build has \
1617 no component by that name"
1618 )
1619 })?;
1620 Ok(Some(ComponentSource {
1621 origin: PathBuf::from(format!("lanekeep/{name}")),
1622 bytes: bytes.to_vec().into(),
1623 only: Some(index),
1624 // Asked with the same name and from the same table, so the map a component
1625 // gets is its own or none.
1626 source_map: root
1627 .builtin_component_map(name)
1628 .map(|map| map.to_vec().into()),
1629 }))
1630 }
1631
1632 RuleReference::Component(path) => {
1633 // Confinement before the read, and before anything is compiled or run.
1634 //
1635 // The message is this crate's rather than the resolver's, because the resolver's is
1636 // written for an `import` and says so — "rule modules may only import from within
1637 // it" names nothing a user who wrote a `.wasm` path would recognize. The *check* is
1638 // the resolver's, which is the half that must not be duplicated.
1639 let confined = root.confine(&rule.specifier, path).map_err(|e| match e {
1640 ResolveError::EscapesRoot { .. } => format!(
1641 "`{}` resolves outside the rules root, and a rule component must sit \
1642 inside it",
1643 rule.specifier
1644 ),
1645 ResolveError::Unreadable { detail, .. } => {
1646 format!("cannot read `{}`: {detail}", path.display())
1647 }
1648 other => other.to_string(),
1649 })?;
1650
1651 let bytes: ComponentBytes = std::fs::read(&confined)
1652 .map_err(|e| format!("cannot read `{}`: {e}", confined.display()))?
1653 .into();
1654 // No sidecar is read for a project component, deliberately. `<name>.wasm.map` is the
1655 // obvious convention and it would be a second file whose freshness against the first
1656 // nothing checks: a map left behind by a previous build reports positions in real
1657 // files that have nothing to do with the failure, and the two cannot be paired
1658 // without a digest neither of them carries. A built-in's map is embedded in this
1659 // binary beside its component, which is what makes that pairing hold there.
1660 Ok(Some(ComponentSource {
1661 origin: confined,
1662 bytes,
1663 only: None,
1664 source_map: None,
1665 }))
1666 }
1667
1668 RuleReference::Builtin(_) | RuleReference::Module(_) => Ok(None),
1669 }
1670}
1671
1672/// A component's own account of itself, in the shape [`build_rule`] validates.
1673struct Described {
1674 raw: RawRule,
1675 component: ComponentRule,
1676}
1677
1678/// What a component answered, as the rule declaration the rest of this file already knows how
1679/// to check.
1680///
1681/// Deliberately a [`RawRule`] rather than a `RuleSpec`: converging on the same validation is
1682/// the point. A component that named an undeclared namespace, an empty query or an unusable
1683/// card is refused by the code that refuses a TypeScript rule for the same reasons, in the
1684/// same words.
1685fn raw_rule_from(
1686 metadata: lanekeep_wasm::bindings::types::RuleMetadata,
1687 has_check: bool,
1688 has_reduce: bool,
1689) -> RawRule {
1690 RawRule {
1691 id: Some(metadata.id),
1692 language: Some(RawLanguages::Many(metadata.languages)),
1693 severity: Some(metadata.severity),
1694 card: Some(RawCard {
1695 message: Some(metadata.card.message),
1696 remediation: Some(metadata.card.remediation),
1697 examples: Some(RawExamples {
1698 bad: Some(metadata.card.examples.bad),
1699 good: Some(metadata.card.examples.good),
1700 }),
1701 }),
1702 query: Some(RawQueries::Many(
1703 metadata
1704 .queries
1705 .into_iter()
1706 .map(|q| (q.language, q.query))
1707 .collect(),
1708 )),
1709 gates: Gates {
1710 path_matches: metadata.gates.path_matches,
1711 path_not_matches: metadata.gates.path_not_matches,
1712 file_contains: metadata.gates.file_contains,
1713 file_not_contains: metadata.gates.file_not_contains,
1714 },
1715 timeout: metadata.timeout,
1716 has_check,
1717 has_reduce,
1718 }
1719}
1720
1721fn build_rule(
1722 raw: RawRule,
1723 position: usize,
1724 display: &str,
1725 overrides: &BTreeMap<RuleId, Severity>,
1726 declared: &BTreeSet<String>,
1727 component: Option<ComponentRule>,
1728) -> Result<RuleSpec, ConfigError> {
1729 let fail = |detail: String| ConfigError::Rule {
1730 position,
1731 path: display.to_owned(),
1732 detail,
1733 };
1734
1735 let id = raw
1736 .id
1737 .ok_or_else(|| fail("missing `id`".to_owned()))?
1738 .parse::<RuleId>()
1739 .map_err(|e| fail(e.to_string()))?;
1740
1741 // A namespace nobody declared is a typo, and this is the only layer that can tell.
1742 // Parsing accepts any well-formed namespace so a team can use its own; declaring it is
1743 // what keeps `lanekep/foo` from becoming a valid ID that quietly matches nothing.
1744 if !id.namespace().is_built_in() && !declared.contains(id.namespace().as_str()) {
1745 let mut known: Vec<String> = Namespace::built_ins()
1746 .iter()
1747 .map(|n| format!("`{n}`"))
1748 .collect();
1749 known.extend(declared.iter().map(|n| format!("`{n}`")));
1750 return Err(fail(format!(
1751 "rule namespace `{}` is not declared — add it to `namespaces` in the config, \
1752 or use one of {}",
1753 id.namespace(),
1754 known.join(", ")
1755 )));
1756 }
1757
1758 // The check that JSON extraction exists to make possible. A rule whose handler is
1759 // missing or misspelled would otherwise load cleanly and never report, which is
1760 // indistinguishable from the code being fine.
1761 if !raw.has_check {
1762 return Err(fail(format!(
1763 "`{id}` has no `check` function — a rule without one can never report anything"
1764 )));
1765 }
1766
1767 let card = raw
1768 .card
1769 .ok_or_else(|| fail(format!("`{id}` has no `card`")))?;
1770 let examples = card.examples.unwrap_or(RawExamples {
1771 bad: None,
1772 good: None,
1773 });
1774 let card = RuleCard {
1775 message: card.message.unwrap_or_default(),
1776 remediation: card.remediation.unwrap_or_default(),
1777 examples: Examples {
1778 bad: examples.bad.unwrap_or_default(),
1779 good: examples.good.unwrap_or_default(),
1780 },
1781 };
1782 card.validate()
1783 .map_err(|problems| fail(format!("`{id}` has an unusable card: {problems:?}")))?;
1784
1785 let declared = raw
1786 .severity
1787 .map(|s| s.parse::<Severity>())
1788 .transpose()
1789 .map_err(|e| fail(format!("`{id}`: {e}")))?
1790 .unwrap_or(Severity::Error);
1791
1792 // Both TypeScript dialects by default, because a rule written for TypeScript is meant for
1793 // the TypeScript in the project — and in any React codebase most of that lives in `.tsx`,
1794 // which the TypeScript grammar cannot parse.
1795 let languages = raw.language.map_or_else(
1796 || vec!["typescript".to_owned(), "tsx".to_owned()],
1797 RawLanguages::into_vec,
1798 );
1799 // An empty list is not "every language", it is *no file at all* — a rule runs only on a
1800 // file whose own language it names — and it is silent: the rule loads, matches nothing and
1801 // reports nothing, which is indistinguishable from the code being clean. The world declares
1802 // that the host refuses one at load (`crates/lanekeep-wasm/wit/world.wit`); this is that
1803 // refusal, and it covers a TypeScript rule writing `language: []` for the same reason.
1804 if languages.is_empty() {
1805 return Err(fail(format!(
1806 "`{id}` names no language — a rule runs only on files whose language it names, so \
1807 an empty list means it can never run"
1808 )));
1809 }
1810
1811 let queries = match raw.query {
1812 None => return Err(fail(format!("`{id}` has no `query`"))),
1813 Some(RawQueries::One(query)) => {
1814 if query.trim().is_empty() {
1815 return Err(fail(format!("`{id}` has an empty `query`")));
1816 }
1817 languages
1818 .iter()
1819 .cloned()
1820 .map(|language| (language, query.clone()))
1821 .collect()
1822 }
1823 Some(RawQueries::Many(queries)) => {
1824 // The exact cover, shared word for word with the component gate
1825 // (`lanekeep-wasm`'s `validate_metadata`) through `lanekeep_core::query_cover`,
1826 // so the two paths cannot drift in what they accept or in how they say no. The
1827 // duplicate arm can never fire here — a `BTreeMap` cannot hold a language twice
1828 // — and lives in the shared check for the path that can, a component's
1829 // `list<query-for>`.
1830 lanekeep_core::query_cover::check(&languages, queries.keys().map(String::as_str))
1831 .map_err(|problem| fail(format!("`{id}` {}", problem.describe())))?;
1832 // Per-entry emptiness is this gate's alone, deliberately: probe fixtures answer
1833 // `metadata` with an empty query on purpose, so the host gate admits one and
1834 // the last gate before a rule runs — this one — refuses it.
1835 for (language, query) in &queries {
1836 if query.trim().is_empty() {
1837 return Err(fail(format!(
1838 "`{id}` has an empty `query` for `{language}`"
1839 )));
1840 }
1841 }
1842 queries
1843 }
1844 };
1845
1846 Ok(RuleSpec {
1847 index: position - 1,
1848 // Config severity wins over what the rule declares, per §9.
1849 severity: overrides.get(&id).copied().unwrap_or(declared),
1850 id,
1851 languages,
1852 card,
1853 queries,
1854 gates: raw.gates,
1855 timeout: raw.timeout.map(Duration::from_millis),
1856 has_reduce: raw.has_reduce,
1857 component,
1858 })
1859}
1860
1861/// Hash the code every rule in this run is made of: modules the loader read, and components.
1862///
1863/// # A correction to the architecture
1864///
1865/// §8 says `ruleset_hash` must be over *canonicalized* rule definitions, so that
1866/// reformatting does not invalidate while editing a regex does. That was written when rules
1867/// were declarative data, where canonicalizing means normalizing a parsed value.
1868///
1869/// Rules are now TypeScript, and canonicalizing arbitrary TypeScript would mean shipping a
1870/// formatter and agreeing on its output forever. So this hashes module source bytes:
1871/// reformatting a rule *does* invalidate its cached results.
1872///
1873/// That is over-invalidation, which costs a recompute. The alternative error —
1874/// under-invalidating and serving results computed by code that no longer exists — is the
1875/// one §8 exists to prevent, and it is not symmetric with this one.
1876///
1877/// # Two kinds of rule code, and why both are folded here rather than one replacing the other
1878///
1879/// A component's bytes are the same input as a module's source: the code that decided the
1880/// answer. The plan for this change described the component fold as replacing the module walk,
1881/// which would be correct in a world where every rule is a component and is a silent
1882/// under-invalidation in this one — two built-ins are components and every other rule in this
1883/// tree is a module, so dropping the walk would take almost the whole ruleset out of the cache
1884/// key. So both are folded, and the module walk leaves when the last module does.
1885///
1886/// A component is hashed by its **bytes and not its path**. A resolved component path is
1887/// absolute, and putting it in would make the key depend on where the checkout sits — a cache
1888/// invalidated by moving a directory, for nothing. Which component a rule *names* is
1889/// `hash_config`'s to carry, through the specifier; this hash is about the code.
1890///
1891/// # A component is folded once, and each rule of it separately
1892///
1893/// **A component hosts a list of rules, so "the code" and "a rule" stopped being the same
1894/// thing.** Folding a component's bytes once per rule it hosts is not wrong, and it is two
1895/// other things that are: quadratic in the rule count — four rules on the 12.34 MiB
1896/// TypeScript component would fold 49 MiB — and unable to tell "two rules of one component"
1897/// from "one component named twice", because both are the same bytes twice.
1898///
1899/// So the fold is in two parts. Every **distinct component** contributes its bytes once, in a
1900/// fixed order; then every **rule** contributes which of those components it runs in, which of
1901/// that component's rules it is, and what it was configured with. The first part is the
1902/// programs, the second is what is being asked of them, and neither describes the other.
1903///
1904/// *Distinct* is by **content**: two references to one artifact by different paths are the
1905/// same program, and one path read twice across a rewrite is two. That is the same relation
1906/// `lanekeep_wasm::Loaded::identity` expresses as a blake3 digest, realized here by comparing
1907/// the bytes rather than by digesting them — the bytes are already in hand, and a digest pass
1908/// costs a walk over megabytes on a path that runs per LSP request, per MCP call and per
1909/// `--watch` iteration.
1910///
1911/// A rule names its component **by position in that sorted list** rather than by repeating its
1912/// identity. That is what keeps the two parts from being two descriptions of one thing: a
1913/// position says nothing about the bytes, so the component fold stays the only place the code
1914/// reaches the key, and `two_components_cannot_run_together_into_one` keeps testing the
1915/// delimiting it is about rather than being answered by a digest folded elsewhere.
1916///
1917/// Duplicates collapse in both parts: naming one component twice, at the same rule and with the
1918/// same options, is a configuration difference and not a different program.
1919///
1920/// # It folds bytes it is handed, and does not go and read them
1921///
1922/// **This is the same property the module half has, and it used to be the one thing the
1923/// component half did not.** `sandbox.loaded_modules()` is what the loader actually consumed,
1924/// so a module that changed after it was read still hashes as the source that produced the
1925/// answer. The component half used to take the *paths* and read them again — a second read,
1926/// several milliseconds after `describe_components` read the same files to ask them what they
1927/// are, and before `lanekeep-engine` read them a third time to run them. A file that changed
1928/// in between would describe one rule, key another and execute a third, and nothing would
1929/// notice. So the bytes arrive on [`ComponentRule`], read once, and this folds those.
1930///
1931/// **Absence is therefore no longer representable here, and that is stronger than the marker
1932/// it replaces rather than weaker.** This used to fold a present/absent byte, so that "the
1933/// component is missing" and "the component is there" could not hash alike — §8.2's rule that
1934/// a run which could not read a rule and one that could must not share a key. A component that
1935/// cannot be read now fails config load outright: there is no `Config`, so there is no key and
1936/// no run, which is what that rule was protecting against in the first place.
1937/// `a_component_that_is_not_there_is_refused_by_position` is where that lives now.
1938///
1939/// The bytes are still length-prefixed, and that is unrelated to the marker: a `.wasm` is
1940/// arbitrary binary and can contain whichever byte a separator would be, so without the length
1941/// two components could concatenate into one byte sequence.
1942/// `two_components_cannot_run_together_into_one` is what says so.
1943///
1944/// # `components` is empty for a TypeScript config, so this half is JSON-only today
1945///
1946/// Only a `lanekeep.json` produces a [`RuleReference::Component`], so a TypeScript config
1947/// builds no [`ComponentRule`] and there are no component bytes to miss. **The day it can name
1948/// one, this is the branch that silently stops covering them** — and the shape above is what
1949/// makes that harder to get wrong than it was: the bytes come from the rules that were built,
1950/// so whoever teaches the TypeScript path to name a component gets the fold for free rather
1951/// than having to remember a second list.
1952fn hash_ruleset(sandbox: &Sandbox, components: &[&ComponentRule]) -> Hash {
1953 let mut hasher = blake3::Hasher::new();
1954 hasher.update(b"lanekeep-ruleset-v2");
1955
1956 if let Some(loaded) = sandbox.loaded_modules() {
1957 // The map is ordered, so the hash does not depend on load order — which varies with
1958 // import structure and is not something the user changed.
1959 for (path, source) in loaded.borrow().iter() {
1960 hasher.update(path.to_string_lossy().as_bytes());
1961 hasher.update(&[0]);
1962 hasher.update(source.as_bytes());
1963 hasher.update(&[0]);
1964 }
1965 }
1966
1967 // The distinct programs, in the order their bytes sort in — which is a fixed order that
1968 // depends on nothing outside the bytes themselves, so reordering a config's rules is not a
1969 // different ruleset. The path is not consulted at all, for either the order or the identity:
1970 // it is absolute, so it would throw a cache away for moving a checkout, and which component
1971 // a rule *names* is `hash_config`'s through the specifier.
1972 //
1973 // **The bytes are in the key because "read once" is per reference, not per path.**
1974 // `component_bytes` reads once per `ResolvedRule`, and nothing deduplicates `rules`, so a
1975 // config may legitimately name one file twice — `["./r.wasm", {"rule": "./r.wasm", "options":
1976 // {…}}]` is how a rule is used bare and configured in the same run. If the file is rewritten
1977 // between those two reads, two `ComponentRule`s carry one path and different bytes, and both
1978 // execute what they carry. Those are two programs and this folds both, which is the single
1979 // claim this whole function exists to make.
1980 //
1981 // Keying rather than preventing, deliberately. Caching the first read and reusing it would
1982 // close the window by changing which bytes the second rule *runs*, which is a semantic change
1983 // to fix a hashing bug — and it cannot make the read atomic either, since there is no
1984 // snapshot of a live filesystem to take. Hashing what actually ran is the property that was
1985 // claimed. In the ordinary case both entries have identical bytes and this collapses them.
1986 let mut distinct: Vec<&[u8]> = components
1987 .iter()
1988 .map(|component| component.bytes.as_slice())
1989 .collect();
1990 distinct.sort_unstable();
1991 distinct.dedup();
1992
1993 hasher.update(b"components");
1994 length_prefixed(&mut hasher, &(distinct.len() as u64).to_le_bytes());
1995 for bytes in &distinct {
1996 length_prefixed(&mut hasher, bytes);
1997 }
1998
1999 // What is being asked of those programs: for each rule, which one it runs in, which of that
2000 // one's rules it is, and what it was configured with. Sorted and deduplicated for the reason
2001 // the components are — the order a config lists its rules in is `hash_config`'s, and naming
2002 // the same rule of the same component twice with the same options is one program either way.
2003 //
2004 // The component is named by its position in `distinct` rather than by its bytes or a digest
2005 // of them, so that this fold says nothing about the code and the fold above stays the only
2006 // place the code reaches the key. Repeating an identity here would leave the component fold
2007 // provable-by-accident: the delimiting it exists for would be backed up by a second copy of
2008 // the same information, and the test that asserts it would pass with the delimiting gone.
2009 //
2010 // `Err` from the search is unreachable — every slice searched for came out of the very list
2011 // being searched — and is folded to its insertion point rather than unwrapped, because a
2012 // panic on a value derived from a user's config is not something this crate does.
2013 let mut rules: Vec<(usize, u32, &str)> = components
2014 .iter()
2015 .map(|component| {
2016 let bytes = component.bytes.as_slice();
2017 let at = match distinct.binary_search(&bytes) {
2018 Ok(at) | Err(at) => at,
2019 };
2020 (at, component.index, component.options.as_str())
2021 })
2022 .collect();
2023 rules.sort_unstable();
2024 rules.dedup();
2025
2026 hasher.update(b"rules");
2027 length_prefixed(&mut hasher, &(rules.len() as u64).to_le_bytes());
2028 for (component, index, options) in rules {
2029 // Both fixed-width, so neither needs delimiting from the other or from the options
2030 // that follow.
2031 hasher.update(&(component as u64).to_le_bytes());
2032 hasher.update(&index.to_le_bytes());
2033 length_prefixed(&mut hasher, options.as_bytes());
2034 }
2035
2036 *hasher.finalize().as_bytes()
2037}
2038
2039/// Hash a variable-length field with its length in front.
2040///
2041/// `u64` rather than `usize`, because `usize::to_le_bytes` is four bytes on a 32-bit host
2042/// and eight on a 64-bit one, and a hash that depends on the width of the machine that
2043/// computed it is not deterministic. The saturating conversion is unreachable — it needs a
2044/// field larger than 16 exabytes — and is written this way because a panic on user input is
2045/// not something this crate does.
2046fn length_prefixed(hasher: &mut blake3::Hasher, bytes: &[u8]) {
2047 hasher.update(&u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes());
2048 hasher.update(bytes);
2049}
2050
2051/// Hash the configuration values.
2052///
2053/// Canonicalized properly, because these *are* structured data: the severity map is ordered
2054/// so writing the same entries in a different order hashes the same, and the budgets are
2055/// hashed as numbers rather than as whatever the user typed.
2056///
2057/// `resolved` is a JSON config's rule references and their options, and is empty for a
2058/// TypeScript one — where the same information lives inside the config module's own source
2059/// and reaches the key through `ruleset_hash` instead. `docs/architecture.md` §8.1 lists
2060/// options under this hash, and until the JSON path resolved its references in Rust there
2061/// was nowhere they could be read from: they were interpolated into the synthetic entry
2062/// module, which `Sandbox::eval_module` evaluates directly rather than through the loader,
2063/// so it is not among the modules `hash_ruleset` walks. Editing an option in a
2064/// `lanekeep.json` therefore invalidated nothing, and a warm run kept answering the previous
2065/// configuration.
2066fn hash_config(
2067 include: &[String],
2068 exclude: &[String],
2069 severity: &BTreeMap<RuleId, Severity>,
2070 limits: &Limits,
2071 resolved: &[ResolvedRule],
2072 suppressions: &SuppressionPolicy,
2073) -> Hash {
2074 let mut hasher = blake3::Hasher::new();
2075 hasher.update(b"lanekeep-config-v1");
2076
2077 for (label, globs) in [
2078 (b"include".as_slice(), include),
2079 (b"exclude".as_slice(), exclude),
2080 ] {
2081 hasher.update(label);
2082 // Include and exclude are order-insensitive in effect, so hashing them in the
2083 // order written would invalidate on a reordering that changes nothing.
2084 let mut sorted: Vec<&String> = globs.iter().collect();
2085 sorted.sort();
2086 for glob in sorted {
2087 hasher.update(glob.as_bytes());
2088 hasher.update(&[0]);
2089 }
2090 }
2091
2092 hasher.update(b"severity");
2093 for (id, level) in severity {
2094 hasher.update(id.to_string().as_bytes());
2095 hasher.update(&[0]);
2096 hasher.update(level.as_str().as_bytes());
2097 hasher.update(&[0]);
2098 }
2099
2100 hasher.update(b"limits");
2101 for value in [
2102 limits.rule_timeout.as_millis(),
2103 limits.global_timeout.as_millis(),
2104 limits.memory_bytes as u128,
2105 ] {
2106 hasher.update(&value.to_le_bytes());
2107 }
2108
2109 // The suppression policy, folded as the structured data it is: presence and value of
2110 // `max_expiry_days`, not the JSON a user happened to write. This is the sixth input, on
2111 // purpose — `AGENTS.md` records the shape of the alternative: a value a config can say
2112 // that reaches no hash is a warm run answering the previous configuration.
2113 hasher.update(b"suppressions");
2114 hasher.update(&[u8::from(suppressions.require_expiry)]);
2115 match suppressions.max_expiry_days {
2116 Some(days) => {
2117 hasher.update(&[1]);
2118 hasher.update(&days.to_le_bytes());
2119 }
2120 None => {
2121 hasher.update(&[0]);
2122 }
2123 }
2124 hasher.update(&[u8::from(suppressions.forbid_file_scope)]);
2125
2126 // In the order written, which over-invalidates on a reordering that changes nothing —
2127 // rules are sorted by ID before they are reported, so their position is not an input to
2128 // any result. That is the same asymmetry `hash_ruleset` documents: a recompute costs
2129 // time, and serving a result computed under a different configuration costs correctness.
2130 hasher.update(b"rules");
2131 for rule in resolved {
2132 length_prefixed(&mut hasher, rule.specifier.as_bytes());
2133 // An explicit discriminant for which form the config wrote, because `"x"` and
2134 // `{"rule": "x"}` are different configurations — one uses a rule as it comes, the
2135 // other configures it with `null`, and a factory reading `options?.strict` behaves
2136 // differently under the two. Omitting the tag would leave them distinguished only by
2137 // the incidental fact that an absent field and a serialized `null` are different
2138 // lengths, which is true and is not something to depend on.
2139 if let Some(options) = &rule.options {
2140 hasher.update(&[1]);
2141 length_prefixed(&mut hasher, json::literal(options).as_bytes());
2142 } else {
2143 hasher.update(&[0]);
2144 }
2145 }
2146
2147 *hasher.finalize().as_bytes()
2148}
2149
2150/// A `./`-relative specifier from the root to a file inside it.
2151fn relative_specifier(root: &Path, file: &Path) -> Option<String> {
2152 let file = file.canonicalize().ok()?;
2153 let relative = file.strip_prefix(root).ok()?;
2154 let joined = relative
2155 .components()
2156 .map(|c| c.as_os_str().to_string_lossy())
2157 .collect::<Vec<_>>()
2158 .join("/");
2159 Some(format!("./{joined}"))
2160}
2161
2162/// Build a sandbox able to load configuration from a rules root.
2163///
2164/// # Errors
2165///
2166/// Returns [`ConfigError::Unreadable`] if the sandbox cannot be constructed.
2167pub fn sandbox_for(
2168 root: &RuleRoot,
2169 typescript: std::sync::Arc<dyn lanekeep_js::Language>,
2170 javascript: std::sync::Arc<dyn lanekeep_js::Language>,
2171) -> Result<Sandbox, ConfigError> {
2172 let limits = Limits::default();
2173 Sandbox::with_modules(
2174 limits,
2175 RunClock::start(limits.global_timeout),
2176 root.clone(),
2177 typescript,
2178 javascript,
2179 )
2180 .map_err(|e| ConfigError::Unreadable {
2181 path: root.path().display().to_string(),
2182 detail: e.to_string(),
2183 })
2184}
2185
2186/// Where a config file is expected, relative to a project root.
2187#[must_use]
2188pub fn default_config_paths(project_root: &Path) -> Vec<PathBuf> {
2189 [
2190 // First, so a project holding both is not silently checked against the other one.
2191 "lanekeep.json",
2192 "lanekeep.config.ts",
2193 "lanekeep.config.js",
2194 "lanekeep.config.mjs",
2195 ]
2196 .iter()
2197 .map(|name| project_root.join(name))
2198 .collect()
2199}
2200
2201#[cfg(test)]
2202mod tests {
2203 use std::fs;
2204 use std::sync::Arc;
2205
2206 use lanekeep_lang_js::{JavaScript, TypeScript};
2207
2208 use super::*;
2209
2210 struct Fixture {
2211 dir: PathBuf,
2212 }
2213
2214 impl Fixture {
2215 fn new(name: &str, files: &[(&str, &str)]) -> Self {
2216 let dir = std::env::temp_dir().join(format!("lanekeep-config-{name}"));
2217 let _ = fs::remove_dir_all(&dir);
2218 fs::create_dir_all(&dir).expect("creates dir");
2219 let fixture = Self { dir };
2220 fixture.write_all(files);
2221 fixture
2222 }
2223
2224 fn write_all(&self, files: &[(&str, &str)]) {
2225 for (path, contents) in files {
2226 let full = self.dir.join(path);
2227 if let Some(parent) = full.parent() {
2228 fs::create_dir_all(parent).expect("creates parent");
2229 }
2230 fs::write(&full, contents).expect("writes");
2231 }
2232 }
2233
2234 fn load_config(&self) -> Result<Config, ConfigError> {
2235 self.load_named("lanekeep.config.ts")
2236 }
2237
2238 fn load_json(&self) -> Result<Config, ConfigError> {
2239 self.load_named("lanekeep.json")
2240 }
2241
2242 fn load_named(&self, name: &str) -> Result<Config, ConfigError> {
2243 load_from(&self.dir, name)
2244 }
2245
2246 /// A sandbox over this fixture, with nothing loaded into it.
2247 ///
2248 /// For the component half of `ruleset_hash`, whose tests want a fold over bytes rather
2249 /// than over rules: the files they name are a few bytes long and are not components at
2250 /// all, which is what lets them assert on separators, ordering and absence without
2251 /// building a real artifact apiece. Going through `load` would refuse every one of them
2252 /// long before the hash was reached.
2253 fn empty_sandbox(&self) -> Sandbox {
2254 let root = RuleRoot::new(&self.dir).expect("canonicalizes");
2255 sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox")
2256 }
2257
2258 /// Copy one of `lanekeep-wasm`'s committed fixture components into this fixture.
2259 ///
2260 /// By path at run time rather than `include_bytes!`, because `lanekeep-wasm` excludes
2261 /// its whole `tests/` tree from the published package — a compile-time include would
2262 /// make this crate fail to build for anyone who vendored it, where a copy that is only
2263 /// reached by a test fails nowhere else.
2264 fn write_component(&self, at: &str, fixture: &str) {
2265 let from = Path::new(env!("CARGO_MANIFEST_DIR"))
2266 .join("../lanekeep-wasm/tests/fixtures")
2267 .join(format!("{fixture}.wasm"));
2268 let full = self.dir.join(at);
2269 if let Some(parent) = full.parent() {
2270 fs::create_dir_all(parent).expect("creates parent");
2271 }
2272 fs::copy(&from, &full).expect("the fixture ships");
2273 }
2274
2275 /// A component-backed rule over a file inside this fixture.
2276 ///
2277 /// **The bytes are read when this is called, not when the hash is taken**, which is
2278 /// the property `hash_ruleset` now has and is why every test below that edits a file
2279 /// calls this again afterwards. Reading at hash time is exactly the bug that shape
2280 /// removes: the hash would then be over a read nobody else made.
2281 fn component(&self, name: &str) -> ComponentRule {
2282 self.component_at(name, 0)
2283 }
2284
2285 /// The same, naming one of a multi-rule component's rules.
2286 ///
2287 /// Separate from [`Fixture::component`] rather than a parameter on it, because rule `0`
2288 /// is what every test that is not about the index means, and spelling a `0` at a dozen
2289 /// call sites would make the index look like something those tests had chosen.
2290 fn component_at(&self, name: &str, index: u32) -> ComponentRule {
2291 let path = self.dir.join(name);
2292 // `expect`, not `unwrap_or_default`: a mistyped name would otherwise become empty
2293 // bytes, and two tests here compare hashes that would then be equal for the wrong
2294 // reason — `the_ruleset_hash_ignores_where_a_component_sits` and
2295 // `..._ignores_the_order_and_the_repetition_of_a_component` both assert *equality*,
2296 // so they pass vacuously against two empty files. The engine's `backed_by` says the
2297 // same thing for the same reason.
2298 let bytes = fs::read(&path).expect("the component file is where the test put it");
2299 ComponentRule {
2300 path,
2301 index,
2302 options: "null".to_owned(),
2303 bytes: bytes.into(),
2304 // No map: these fixtures are hand-written `.wasm` bytes with nothing beside
2305 // them, and a map is not a `ruleset_hash` input, so it is outside every claim
2306 // these tests make.
2307 source_map: None,
2308 // Irrelevant to what is under test here — these tests drive `hash_ruleset`
2309 // directly rather than through `Engine::caching` — but `true` is the honest
2310 // answer: a real `load` is what every one of these fixtures simulates.
2311 counted_in_ruleset_hash: true,
2312 }
2313 }
2314 }
2315
2316 impl Drop for Fixture {
2317 fn drop(&mut self) {
2318 let _ = fs::remove_dir_all(&self.dir);
2319 }
2320 }
2321
2322 /// Load a config with the rules root at a chosen directory.
2323 ///
2324 /// Separate from [`Fixture::load_named`] because the confinement tests need a root that is
2325 /// *inside* the fixture, so that something the fixture wrote is genuinely outside it.
2326 fn load_from(dir: &Path, name: &str) -> Result<Config, ConfigError> {
2327 let root = RuleRoot::new(dir).expect("canonicalizes");
2328 let sandbox =
2329 sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
2330 load(&sandbox, &root, &dir.join(name))
2331 }
2332
2333 /// The same, with a built-in component table installed.
2334 ///
2335 /// Separate rather than a parameter on every caller, because "no built-in ships as a
2336 /// component" is what the rest of this suite means and should keep saying.
2337 fn load_with_components(
2338 dir: &Path,
2339 name: &str,
2340 components: lanekeep_js::BuiltinComponent,
2341 ) -> Result<Config, ConfigError> {
2342 let root = RuleRoot::new(dir)
2343 .expect("canonicalizes")
2344 .with_builtin_components(components);
2345 let sandbox =
2346 sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
2347 load(&sandbox, &root, &dir.join(name))
2348 }
2349
2350 /// The `metadata` fixture's bytes, served as though they were embedded in the binary.
2351 ///
2352 /// Read at run time rather than `include_bytes!`, for the reason [`Fixture::write_component`]
2353 /// records: `lanekeep-wasm` excludes its whole `tests/` tree from the published package, and
2354 /// a compile-time include would put a path that does not exist for a vendored checkout into
2355 /// this crate's source. A `OnceLock` is what turns a run-time read into the `&'static [u8]`
2356 /// a [`lanekeep_js::BuiltinComponent`] has to return.
2357 fn built_in_component_bytes() -> &'static [u8] {
2358 static BYTES: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
2359 BYTES.get_or_init(|| {
2360 fs::read(
2361 Path::new(env!("CARGO_MANIFEST_DIR"))
2362 .join("../lanekeep-wasm/tests/fixtures/metadata.wasm"),
2363 )
2364 .expect("the fixture ships")
2365 })
2366 }
2367
2368 /// The `two-rules` fixture's bytes, on the same terms as [`built_in_component_bytes`].
2369 fn shared_component_bytes() -> &'static [u8] {
2370 static BYTES: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
2371 BYTES.get_or_init(|| {
2372 fs::read(
2373 Path::new(env!("CARGO_MANIFEST_DIR"))
2374 .join("../lanekeep-wasm/tests/fixtures/two-rules.wasm"),
2375 )
2376 .expect("the fixture ships")
2377 })
2378 }
2379
2380 /// The `js-globals` fixture's bytes: 12.4 MiB of StarlingMonkey hosting five rules.
2381 ///
2382 /// The only component in the tree whose *compilation* costs enough to be measured against a
2383 /// budget, which is what `compiling_a_component_is_not_charged_to_the_run_budget` needs.
2384 fn big_component_bytes() -> &'static [u8] {
2385 static BYTES: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
2386 BYTES.get_or_init(|| {
2387 fs::read(
2388 Path::new(env!("CARGO_MANIFEST_DIR"))
2389 .join("../lanekeep-wasm/tests/fixtures/js-globals.wasm"),
2390 )
2391 .expect("the fixture ships")
2392 })
2393 }
2394
2395 /// The `two-faced` fixture's bytes: a component whose `rules()` and `metadata()` disagree.
2396 fn two_faced_component_bytes() -> &'static [u8] {
2397 static BYTES: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
2398 BYTES.get_or_init(|| {
2399 fs::read(
2400 Path::new(env!("CARGO_MANIFEST_DIR"))
2401 .join("../lanekeep-wasm/tests/fixtures/two-faced.wasm"),
2402 )
2403 .expect("the fixture ships")
2404 })
2405 }
2406
2407 /// A built-in table standing in for `lanekeep_rules`.
2408 ///
2409 /// A stub rather than the real table: which rules have migrated is not what these tests are
2410 /// about, and naming one would make the next migration edit assertions unrelated to it.
2411 ///
2412 /// Three entries, and the last two are the shape this crate has to get right: one artifact,
2413 /// two names, a different index each. `shared-second` is the case a lookup returning only
2414 /// bytes cannot express — it is the *second* rule of a component whose first rule is a
2415 /// perfectly good one to run by mistake.
2416 fn built_in_components(name: &str) -> Option<(&'static [u8], u32)> {
2417 match name {
2418 "metadata" => Some((built_in_component_bytes(), 0)),
2419 "shared-first" => Some((shared_component_bytes(), 0)),
2420 "shared-second" => Some((shared_component_bytes(), 1)),
2421 // An index past the end of what that component hosts, for the disagreement a
2422 // drifted table would produce.
2423 "shared-missing" => Some((shared_component_bytes(), 7)),
2424 // A component that answers its own id differently from its two exports.
2425 "two-faced" => Some((two_faced_component_bytes(), 0)),
2426 // The big one, at `probe/context` — index 1 of five, and the rule of that fixture
2427 // with an ordinary `check`.
2428 "big" => Some((big_component_bytes(), 1)),
2429 _ => None,
2430 }
2431 }
2432
2433 /// A minimal, valid rule module.
2434 fn rule(id: &str) -> String {
2435 format!(
2436 "import {{ defineRule }} from 'lanekeep';\n\
2437 export default defineRule({{\n\
2438 id: '{id}',\n\
2439 query: '(identifier) @id',\n\
2440 card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
2441 check(ctx, m) {{ ctx.report(m.id); }},\n\
2442 }});\n"
2443 )
2444 }
2445
2446 /// A rule factory: what `{ "rule": ..., "options": ... }` and `noRestrictedImports({...})`
2447 /// both name. The options are captured and ignored; what matters here is that a value
2448 /// reached the rule.
2449 fn factory_rule(id: &str) -> String {
2450 format!(
2451 "import {{ defineRule }} from 'lanekeep';\n\
2452 export default (options) => defineRule({{\n\
2453 id: '{id}',\n\
2454 query: '(identifier) @id',\n\
2455 card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
2456 check(ctx, m) {{ ctx.report(m.id); }},\n\
2457 }});\n"
2458 )
2459 }
2460
2461 fn config_with(body: &str) -> String {
2462 format!(
2463 "import {{ defineConfig }} from 'lanekeep';\n\
2464 import rule from './rule';\n\
2465 export default defineConfig({{ {body} }});\n"
2466 )
2467 }
2468
2469 #[test]
2470 fn loads_a_valid_config() {
2471 let fixture = Fixture::new(
2472 "valid",
2473 &[
2474 ("rule.ts", &rule("local/example")),
2475 (
2476 "lanekeep.config.ts",
2477 &config_with(
2478 "include: ['src/**/*.ts'], exclude: ['**/*.test.ts'], rules: [rule]",
2479 ),
2480 ),
2481 ],
2482 );
2483
2484 let config = fixture.load_config().expect("loads");
2485 assert_eq!(config.include, ["src/**/*.ts"]);
2486 assert_eq!(config.exclude, ["**/*.test.ts"]);
2487 assert_eq!(config.rules.len(), 1);
2488 assert_eq!(config.rules[0].id.to_string(), "local/example");
2489 assert_eq!(config.rules[0].card.message, "no");
2490 assert!(!config.rules[0].has_reduce);
2491 }
2492
2493 /// A team can group its rules under its own namespace, which `local/` alone does not
2494 /// allow — everything project-authored ends up in one bucket regardless of who wrote it.
2495 #[test]
2496 fn a_declared_namespace_is_accepted() {
2497 let fixture = Fixture::new(
2498 "declared-namespace",
2499 &[
2500 ("rule.ts", &rule("pera/no-numeric-sizes")),
2501 (
2502 "lanekeep.config.ts",
2503 &config_with("namespaces: ['pera'], rules: [rule]"),
2504 ),
2505 ],
2506 );
2507
2508 let config = fixture.load_config().expect("loads");
2509 assert_eq!(config.rules[0].id.to_string(), "pera/no-numeric-sizes");
2510 assert!(!config.rules[0].id.is_built_in());
2511 }
2512
2513 /// And the property that made a closed set worth having in the first place: a namespace
2514 /// nobody declared is a typo, and it fails at load rather than becoming a valid ID that
2515 /// silently matches nothing.
2516 #[test]
2517 fn an_undeclared_namespace_is_rejected() {
2518 let fixture = Fixture::new(
2519 "undeclared-namespace",
2520 &[
2521 ("rule.ts", &rule("lanekep/no-default-export")),
2522 ("lanekeep.config.ts", &config_with("rules: [rule]")),
2523 ],
2524 );
2525
2526 let error = fixture
2527 .load_config()
2528 .expect_err("an undeclared namespace should be refused")
2529 .to_string();
2530 assert!(error.contains("lanekep"), "{error}");
2531 assert!(
2532 error.contains("namespaces"),
2533 "should say how to fix it: {error}"
2534 );
2535 }
2536
2537 /// `lanekeep/` stays reserved, so a rule's origin is readable from its ID alone.
2538 #[test]
2539 fn the_lanekeep_namespace_cannot_be_claimed() {
2540 let fixture = Fixture::new(
2541 "reserved-namespace",
2542 &[
2543 ("rule.ts", &rule("local/example")),
2544 (
2545 "lanekeep.config.ts",
2546 &config_with("namespaces: ['lanekeep'], rules: [rule]"),
2547 ),
2548 ],
2549 );
2550
2551 let error = fixture
2552 .load_config()
2553 .expect_err("claiming the reserved namespace should be refused")
2554 .to_string();
2555 assert!(error.contains("reserved"), "{error}");
2556 }
2557
2558 /// A rule with no language of its own targets both TypeScript dialects, because in a
2559 /// React codebase most TypeScript is `.tsx`.
2560 #[test]
2561 fn a_rule_defaults_to_both_typescript_dialects() {
2562 let fixture = Fixture::new(
2563 "default-languages",
2564 &[
2565 ("rule.ts", &rule("local/example")),
2566 ("lanekeep.config.ts", &config_with("rules: [rule]")),
2567 ],
2568 );
2569
2570 let config = fixture.load_config().expect("loads");
2571 assert_eq!(config.rules[0].languages, ["typescript", "tsx"]);
2572 }
2573
2574 /// One or several, both spelled the way a rule author would write them.
2575 #[test]
2576 fn a_rule_may_declare_one_language_or_several() {
2577 for (declaration, expected) in [
2578 ("language: 'tsx',", vec!["tsx"]),
2579 (
2580 "language: ['typescript', 'tsx'],",
2581 vec!["typescript", "tsx"],
2582 ),
2583 ] {
2584 let module = format!(
2585 "import {{ defineRule }} from 'lanekeep';\n\
2586 export default defineRule({{\n\
2587 id: 'local/example',\n\
2588 {declaration}\n\
2589 query: '(identifier) @id',\n\
2590 card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
2591 check(ctx, m) {{ ctx.report(m.id); }},\n\
2592 }});\n"
2593 );
2594 let fixture = Fixture::new(
2595 "language-forms",
2596 &[
2597 ("rule.ts", &module),
2598 ("lanekeep.config.ts", &config_with("rules: [rule]")),
2599 ],
2600 );
2601
2602 let config = fixture.load_config().expect("loads");
2603 assert_eq!(config.rules[0].languages, expected, "{declaration}");
2604 }
2605 }
2606
2607 #[test]
2608 fn a_rule_without_a_check_function_is_rejected() {
2609 // The failure JSON extraction exists to catch. Without this the rule loads, never
2610 // fires, and looks exactly like the code being clean.
2611 //
2612 // The handler is named `onMatch` rather than a misspelling of `check`, because the
2613 // spell checker flags a real typo in source even inside a fixture — and allowing it
2614 // globally to keep the joke would be a poor trade. What matters is that `check` is
2615 // absent, not how it came to be.
2616 let fixture = Fixture::new(
2617 "no-check",
2618 &[
2619 (
2620 "rule.ts",
2621 "import { defineRule } from 'lanekeep';\n\
2622 export default defineRule({\n\
2623 id: 'local/typo',\n\
2624 query: '(identifier) @id',\n\
2625 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
2626 onMatch(ctx, m) {},\n\
2627 });\n",
2628 ),
2629 ("lanekeep.config.ts", &config_with("rules: [rule]")),
2630 ],
2631 );
2632
2633 let err = fixture.load_config().expect_err("must be rejected");
2634 let rendered = err.to_string();
2635 assert!(rendered.contains("check"), "{rendered}");
2636 assert!(rendered.contains("never report"), "{rendered}");
2637 }
2638
2639 #[test]
2640 fn a_rule_with_a_bare_id_is_rejected() {
2641 let fixture = Fixture::new(
2642 "bare-id",
2643 &[
2644 ("rule.ts", &rule("example")),
2645 ("lanekeep.config.ts", &config_with("rules: [rule]")),
2646 ],
2647 );
2648 let rendered = fixture
2649 .load_config()
2650 .expect_err("must be rejected")
2651 .to_string();
2652 assert!(rendered.contains("namespace"), "{rendered}");
2653 }
2654
2655 #[test]
2656 fn a_rule_with_an_unusable_card_is_rejected() {
2657 let fixture = Fixture::new(
2658 "bad-card",
2659 &[
2660 (
2661 "rule.ts",
2662 "import { defineRule } from 'lanekeep';\n\
2663 export default defineRule({\n\
2664 id: 'local/empty',\n\
2665 query: '(identifier) @id',\n\
2666 card: { message: '', remediation: '', examples: { bad: '', good: '' } },\n\
2667 check() {},\n\
2668 });\n",
2669 ),
2670 ("lanekeep.config.ts", &config_with("rules: [rule]")),
2671 ],
2672 );
2673 assert!(fixture.load_config().is_err());
2674 }
2675
2676 #[test]
2677 fn a_missing_default_export_says_so() {
2678 // The engine catches this at link time, before extraction runs, and its message is
2679 // better than a generic one would be — it names the module and the missing export.
2680 let fixture = Fixture::new(
2681 "no-default",
2682 &[
2683 ("rule.ts", &rule("local/x")),
2684 ("lanekeep.config.ts", "export const notDefault = 1;\n"),
2685 ],
2686 );
2687 let rendered = fixture
2688 .load_config()
2689 .expect_err("must be rejected")
2690 .to_string();
2691 assert!(rendered.contains("default"), "{rendered}");
2692 }
2693
2694 #[test]
2695 fn a_default_export_that_is_not_an_object_says_so() {
2696 // This one does reach our own check: the export exists, so the engine is happy,
2697 // and only the shape is wrong.
2698 let fixture = Fixture::new(
2699 "default-not-object",
2700 &[
2701 ("rule.ts", &rule("local/x")),
2702 ("lanekeep.config.ts", "export default 42;\n"),
2703 ],
2704 );
2705 let rendered = fixture
2706 .load_config()
2707 .expect_err("must be rejected")
2708 .to_string();
2709 assert!(rendered.contains("export default"), "{rendered}");
2710 }
2711
2712 #[test]
2713 fn config_severity_overrides_what_the_rule_declares() {
2714 let fixture = Fixture::new(
2715 "severity",
2716 &[
2717 ("rule.ts", &rule("local/example")),
2718 (
2719 "lanekeep.config.ts",
2720 &config_with("rules: [rule], severity: { 'local/example': 'warn' }"),
2721 ),
2722 ],
2723 );
2724 let config = fixture.load_config().expect("loads");
2725 assert_eq!(config.rules[0].severity, Severity::Warn);
2726 }
2727
2728 #[test]
2729 fn timeouts_fall_back_to_the_defaults() {
2730 let fixture = Fixture::new(
2731 "timeouts-default",
2732 &[
2733 ("rule.ts", &rule("local/example")),
2734 ("lanekeep.config.ts", &config_with("rules: [rule]")),
2735 ],
2736 );
2737 let config = fixture.load_config().expect("loads");
2738 assert_eq!(config.limits, Limits::default());
2739 }
2740
2741 #[test]
2742 fn timeouts_can_be_overridden() {
2743 let fixture = Fixture::new(
2744 "timeouts-set",
2745 &[
2746 ("rule.ts", &rule("local/example")),
2747 (
2748 "lanekeep.config.ts",
2749 &config_with("rules: [rule], timeouts: { rule: 2000, global: 30000 }"),
2750 ),
2751 ],
2752 );
2753 let config = fixture.load_config().expect("loads");
2754 assert_eq!(config.limits.rule_timeout, Duration::from_secs(2));
2755 assert_eq!(config.limits.global_timeout, Duration::from_secs(30));
2756 }
2757
2758 // --- components -----------------------------------------------------------------
2759
2760 #[test]
2761 fn a_component_reference_resolves_to_a_spec_carrying_its_own_metadata() {
2762 // Every field below comes from the component's own `metadata` export and from
2763 // nowhere else — there is no config syntax carrying any of it, which is the whole
2764 // reason the export exists.
2765 let fixture = Fixture::new("component-metadata", &[]);
2766 fixture.write_component("rules/metadata.wasm", "metadata");
2767 fixture.write_all(&[(
2768 "lanekeep.json",
2769 r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
2770 "rules": ["./rules/metadata.wasm"]}"#,
2771 )]);
2772
2773 let config = fixture
2774 .load_json()
2775 .expect("a component reference is resolvable");
2776
2777 let rule = &config.rules[0];
2778 assert_eq!(rule.id.to_string(), "fixture/metadata");
2779 assert_eq!(
2780 rule.queries.get("rust"),
2781 Some(&"(call_expression) @call".to_owned())
2782 );
2783 assert_eq!(rule.languages, ["rust"]);
2784 assert_eq!(rule.card.message, "a fixture");
2785 assert_eq!(rule.card.remediation, "do the other thing");
2786 // All four, and the fixture sets all four to different values on purpose. `raw_rule_from`
2787 // assigns them from a plain struct literal, so a dropped or swapped field is not a type
2788 // error — asserting two of the four leaves the other two mapped by nothing, and both
2789 // mutations pass. This is the shape of the Task 1 finding recurring one layer up.
2790 assert_eq!(rule.gates.path_matches, ["src/**/*.rs"]);
2791 assert_eq!(rule.gates.path_not_matches, ["**/generated/**"]);
2792 assert_eq!(rule.gates.file_contains, ["call"]);
2793 assert_eq!(rule.gates.file_not_contains, ["skip"]);
2794 assert_eq!(rule.timeout, Some(Duration::from_millis(1500)));
2795 assert!(
2796 !rule.has_reduce,
2797 "the fixture answers `has-reduce` with false, and the config must take that \
2798 answer rather than assuming one"
2799 );
2800 let component = rule
2801 .component
2802 .as_ref()
2803 .expect("the bytes travel with the rule");
2804 assert_eq!(
2805 component.bytes.as_slice(),
2806 fs::read(fixture.dir.join("rules/metadata.wasm"))
2807 .expect("the fixture is there")
2808 .as_slice(),
2809 "the rule carries the component it was described from"
2810 );
2811 // This crate is the only one that can truthfully answer this: `describe_components`'s
2812 // output is exactly what `build` folds into `ruleset_hash`, a few lines below where the
2813 // rule this test just built came from. `Engine::caching` (`lanekeep-engine`) trusts this
2814 // flag rather than re-deriving it, so a `false` here would silently take every
2815 // component-backed run's cache off — and nothing outside this crate can tell, because a
2816 // hand-built `ComponentRule` looks identical otherwise. Paired with
2817 // `an_uncounted_component_is_not_counted_in_ruleset_hash` below, this closes both
2818 // mutants of `ComponentRule::counted_in_ruleset_hash` inside this crate's own suite: this
2819 // one alone only kills `replace ... with false`, since nothing here is `false` for a
2820 // mutant hardcoding `true` to disagree with.
2821 assert!(
2822 component.counted_in_ruleset_hash(),
2823 "a component `load` resolved must be counted in `ruleset_hash`"
2824 );
2825 }
2826
2827 #[test]
2828 fn all_four_gates_survive_extraction_from_a_typescript_module() {
2829 // A rule module declaring all four gates, each set to a different value on purpose.
2830 // `EXTRACT` passes a rule's `gates` object through and `Gates` deserializes it under
2831 // `rename_all = "camelCase"` — asserting two of the four would leave the other two
2832 // mapped by nothing, and both a dropped-EXTRACT-field and a dropped-deserialize-field
2833 // mutant would pass.
2834 let fixture = Fixture::new(
2835 "ts-module-gates",
2836 &[
2837 (
2838 "rule.ts",
2839 "import { defineRule } from 'lanekeep';\n\
2840 export default defineRule({\n\
2841 id: 'local/example',\n\
2842 query: '(identifier) @id',\n\
2843 gates: {\n\
2844 pathMatches: ['src/**/*.rs'],\n\
2845 pathNotMatches: ['**/generated/**'],\n\
2846 fileContains: ['call'],\n\
2847 fileNotContains: ['skip'],\n\
2848 },\n\
2849 card: { message: 'no', remediation: 'do this', examples: { bad: 'a', good: 'b' } },\n\
2850 check(ctx, m) { ctx.report(m.id); },\n\
2851 });\n",
2852 ),
2853 ("lanekeep.config.ts", &config_with("rules: [rule]")),
2854 ],
2855 );
2856 let config = fixture.load_config().expect("loads");
2857 assert_eq!(config.rules[0].gates.path_matches, ["src/**/*.rs"]);
2858 assert_eq!(config.rules[0].gates.path_not_matches, ["**/generated/**"]);
2859 assert_eq!(config.rules[0].gates.file_contains, ["call"]);
2860 assert_eq!(config.rules[0].gates.file_not_contains, ["skip"]);
2861 }
2862
2863 /// The two entry points differ in exactly one observable way, and what it is worth ranges
2864 /// from tens of milliseconds to seconds.
2865 ///
2866 /// `load` has nowhere to write, so it compiles each component only to discard the
2867 /// compilation, and the engine compiles the same bytes again at prepare time.
2868 /// [`load_with`] given a [`LoadOptions::artifacts`] root leaves a `.cwasm` under
2869 /// `COMPONENT_CACHE_PATH` that both this load and the engine's own loader map — measured at
2870 /// ~58 ms per component per load before, and at TypeScript parity after, against components
2871 /// of about 26 KB. The shared TypeScript component is 12.4 MiB and about six seconds, so the
2872 /// same difference is three orders of magnitude wider there.
2873 ///
2874 /// Asserted on the artifact rather than on a duration: a timing assertion on a loaded machine
2875 /// is a flake, and the file either exists or it does not. Both directions are asserted,
2876 /// because a change making *every* load write would pass a one-sided test while putting a
2877 /// cache directory somewhere its caller never named — which is what `load` must not do, since
2878 /// it is handed a rules root and a rules root is not a place to write. A caller that *does*
2879 /// own the directory says so: `lanekeep-testkit` names its own throwaway project, which is
2880 /// the difference between choosing a location and guessing one.
2881 #[test]
2882 fn only_a_load_given_a_project_root_caches_what_it_compiled() {
2883 let files = &[(
2884 "lanekeep.json",
2885 r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
2886 "rules": ["./rules/metadata.wasm"]}"#,
2887 )];
2888
2889 let plain = Fixture::new("artifact-cache-absent", files);
2890 plain.write_component("rules/metadata.wasm", "metadata");
2891 plain.load_json().expect("the component resolves");
2892 assert!(
2893 !plain.dir.join(lanekeep_wasm::COMPONENT_CACHE_PATH).exists(),
2894 "`load` names no project root, so it must not write a cache directory into one"
2895 );
2896
2897 let cached = Fixture::new("artifact-cache-present", files);
2898 cached.write_component("rules/metadata.wasm", "metadata");
2899 let root = RuleRoot::new(&cached.dir).expect("canonicalizes");
2900 let sandbox =
2901 sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
2902 load_with(
2903 &sandbox,
2904 &root,
2905 &cached.dir.join("lanekeep.json"),
2906 LoadOptions {
2907 artifacts: Some(&cached.dir),
2908 ..LoadOptions::default()
2909 },
2910 )
2911 .expect("the component resolves");
2912
2913 let artifacts = cached.dir.join(lanekeep_wasm::COMPONENT_CACHE_PATH);
2914 let written: Vec<_> = fs::read_dir(&artifacts)
2915 .expect("the cache directory is there")
2916 .filter_map(|entry| entry.ok().map(|e| e.path()))
2917 .filter(|path| path.extension().is_some_and(|ext| ext == "cwasm"))
2918 .collect();
2919 assert_eq!(
2920 written.len(),
2921 1,
2922 "one component was described, so one artifact should be cached; found {written:?}"
2923 );
2924 }
2925
2926 /// Compiling a component is not charged to the run budget.
2927 ///
2928 /// **The invariant this protects is determinism, not speed.** The global budget bounds rule
2929 /// execution; compiling WebAssembly to machine code is host work whose cost depends on the
2930 /// machine and on whether `.lanekeep/components` is warm. Charging it to that budget made a
2931 /// cold run and a warm run over *identical input* take different exits — 12.4 MiB is seconds
2932 /// to compile and microseconds to map — which puts the compile cache into
2933 /// `(bytes, path, ruleset, config, tracked reads)`, where it has no term.
2934 ///
2935 /// **The ratio is what makes this a test rather than a race.** `js-globals.wasm` is the
2936 /// 12.4 MiB StarlingMonkey fixture: measured 6.2 s to compile in a release build and about
2937 /// twice that in a debug one, against guest work here — five `rules()` entries, five
2938 /// `metadata` reads — of well under a millisecond. A 1 s budget therefore sits three orders
2939 /// of magnitude above what the clocked phase spends and an order of magnitude below what the
2940 /// unclocked one does, so no plausible machine makes this decide the wrong way. Before the
2941 /// split it failed; there is no arrangement of this fixture under which it passes by luck.
2942 ///
2943 /// No artifacts directory, deliberately: with one, the second run of this test would map
2944 /// rather than compile and the test would stop testing anything.
2945 ///
2946 /// Reached as a *built-in* rather than by path so that one of the fixture's five rules is
2947 /// described rather than all of them — `probe/cross` is `reduce`-only and `build_rule`
2948 /// rightly refuses a rule with neither pass, which is a different failure and would mask
2949 /// this one.
2950 #[test]
2951 fn compiling_a_component_is_not_charged_to_the_run_budget() {
2952 let fixture = Fixture::new("config-compile-unclocked", &[]);
2953 fixture.write_all(&[(
2954 "lanekeep.json",
2955 r#"{"include": ["**/*.ts"], "namespaces": ["probe"],
2956 "timeouts": {"global": 1000},
2957 "rules": ["lanekeep/big"]}"#,
2958 )]);
2959
2960 let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
2961 .expect("a 1 s budget bounds guest work, and compiling is not guest work");
2962 assert_eq!(
2963 config.rules.len(),
2964 1,
2965 "the component's rule has to have been described, or nothing was clocked at all"
2966 );
2967 assert_eq!(config.rules[0].id.to_string(), "probe/context");
2968 }
2969
2970 /// The compilation budget's arithmetic, driven with synthetic durations.
2971 ///
2972 /// **The branch this covers went a whole round untested**, on the reasoning that a fixture
2973 /// slow enough to trip a ten-minute budget would cost ten minutes to run. That is true of an
2974 /// end-to-end test and false of the comparison, which is why the comparison is a pure
2975 /// function now: everything below runs in microseconds and would have failed against
2976 /// `if false && elapsed > allowed`, the mutation that produced no failures at all.
2977 ///
2978 /// The budget is a parameter here rather than the shipped constant, deliberately: what is
2979 /// under test is the comparison, and a test that took its expectations from
2980 /// `COMPILE_BUDGET_PER_COMPONENT` would agree with itself if that constant became zero. The
2981 /// shipped value is a judgment against measurements and is documented where it is declared.
2982 #[test]
2983 fn the_compile_budget_is_a_comparison_against_a_scaling_allowance() {
2984 /// A budget with no relationship to the shipped one, so nothing below can be satisfied
2985 /// by arithmetic that ignores its argument.
2986 const BUDGET: Duration = Duration::from_micros(250);
2987
2988 // Under, at, and over — the boundary included, because `>` and `>=` are the two
2989 // plausible spellings and only one of them is written.
2990 assert_eq!(compile_overrun(Duration::ZERO, 1, BUDGET), None);
2991 assert_eq!(compile_overrun(BUDGET, 1, BUDGET), None);
2992 assert!(
2993 compile_overrun(BUDGET + Duration::from_micros(1), 1, BUDGET).is_some(),
2994 "a microsecond past the allowance is past it"
2995 );
2996
2997 // And it scales, which is the whole reason the count is a parameter: a config with three
2998 // components is not held to one component's allowance.
2999 let three = BUDGET * 3;
3000 assert_eq!(compile_overrun(three, 3, BUDGET), None);
3001 assert!(compile_overrun(three, 2, BUDGET).is_some());
3002 assert!(
3003 compile_overrun(three + Duration::from_micros(1), 3, BUDGET).is_some(),
3004 "three components get three allowances and not a fourth"
3005 );
3006
3007 // A count of zero has no allowance at all. Unreachable — the check runs after a
3008 // component was pushed — and asserted because "scales with the count" has to mean
3009 // something at the bottom of the range too.
3010 assert!(compile_overrun(Duration::from_micros(1), 0, BUDGET).is_some());
3011 }
3012
3013 #[test]
3014 fn the_compile_budget_message_says_what_it_is_and_what_will_not_help() {
3015 const BUDGET: Duration = Duration::from_micros(250);
3016
3017 let detail = compile_overrun(BUDGET * 9, 2, BUDGET)
3018 .expect("nine allowances against two components is an overrun");
3019
3020 // The numbers a reader needs to tell "this machine is slow" from "this is a hang".
3021 assert!(
3022 detail.contains("compiling the rule components took"),
3023 "{detail}"
3024 );
3025 assert!(detail.contains("2 of them"), "{detail}");
3026
3027 // And the two things that distinguish this diagnostic from the global budget's, which
3028 // is the reason it exists rather than being folded into that one: the global message
3029 // ends "narrow what is being checked", which cannot help against a fixed compile cost.
3030 assert!(
3031 detail.contains("not of running any rule"),
3032 "the message has to say this is not a rule's fault: {detail}"
3033 );
3034 assert!(
3035 detail.contains("narrowing what is checked will not help"),
3036 "and that the other budget's advice does not apply: {detail}"
3037 );
3038 assert!(
3039 detail.contains(".lanekeep/components"),
3040 "and where the remedy actually is: {detail}"
3041 );
3042 }
3043
3044 /// And the pass *calls* it, which the two tests above cannot say.
3045 ///
3046 /// **The gap they leave is the one the reviewer's mutation actually sat in.** Disabling the
3047 /// comparison inside `compile_overrun` now fails them both; deleting the `if let` that calls
3048 /// it would not, because a pure function tested in isolation says nothing about whether
3049 /// anything reached it. The budget is a parameter for exactly this reason — a zero budget
3050 /// makes the first component an overrun, so the call site is reachable in milliseconds
3051 /// rather than in the ten minutes the shipped value would need.
3052 ///
3053 /// `world-shape.wasm` rather than the 12.4 MiB one: what is under test is that the check
3054 /// runs, and the smallest component that compiles at all is the fastest way to find out.
3055 #[test]
3056 fn the_compilation_pass_checks_its_budget() {
3057 let fixture = Fixture::new("config-compile-budget-call", &[]);
3058 fixture.write_component("rules/probe.wasm", "world-shape");
3059
3060 let root = RuleRoot::new(&fixture.dir).expect("canonicalizes");
3061 let engine = WasmEngine::new().expect("the shipped configuration builds an engine");
3062 let loader = lanekeep_wasm::ComponentLoader::without_cache();
3063 let resolved = vec![ResolvedRule {
3064 specifier: "./rules/probe.wasm".to_owned(),
3065 // From the *canonical* root, which is what `json::classify` builds and what
3066 // `RuleRoot::confine` compares against — the fixture's own `dir` is not canonical on
3067 // macOS, where `/var` is a symlink to `/private/var`.
3068 reference: RuleReference::Component(root.path().join("rules/probe.wasm")),
3069 options: None,
3070 }];
3071
3072 // A budget of zero: any elapsed time at all is past it, so the first component overruns.
3073 // `Compiled` holds a `wasmtime::Component` and so is not `Debug`, which `expect_err`
3074 // needs; matched rather than unwrapped.
3075 let Err((position, detail)) =
3076 compile_components(&root, &resolved, &engine, &loader, Duration::ZERO)
3077 else {
3078 panic!("no compilation finishes in zero time");
3079 };
3080 assert_eq!(position, 0, "the diagnostic names the entry that overran");
3081 assert!(
3082 detail.contains("compiling the rule components took"),
3083 "and it is the compilation diagnostic rather than a load failure: {detail}"
3084 );
3085
3086 // The same pass under the shipped budget completes, so the failure above is the budget
3087 // and not the fixture — without this, a component that simply failed to load would
3088 // satisfy every assertion above.
3089 let compiled = compile_components(
3090 &root,
3091 &resolved,
3092 &engine,
3093 &loader,
3094 COMPILE_BUDGET_PER_COMPONENT,
3095 )
3096 .expect("the same component compiles fine under the shipped budget");
3097 assert_eq!(compiled.len(), 1);
3098 }
3099
3100 /// Four references to one shared component deserialize it once, not once per reference.
3101 ///
3102 /// `docs/architecture.md` §15 names this as a defect rather than a property:
3103 /// [`compile_components`] calls [`ComponentLoader::load_mapped`] once per rule *reference*,
3104 /// so a config naming every rule of a shared component deserializes the same bytes
3105 /// repeatedly. The loader is deliberately lock-free — `&self` throughout, so parallel loads
3106 /// have no contention — so the dedup belongs here rather than in the loader, keyed on the
3107 /// component's content identity: the blake3 of its bytes, the same
3108 /// [`lanekeep_wasm::Loaded::identity`] [`RuleSet::add`] already shares instances on.
3109 #[test]
3110 fn four_references_to_one_component_load_it_once() {
3111 let fixture = Fixture::new("config-load-one-component", &[]);
3112 fixture.write_component("rules/shared.wasm", "world-shape");
3113
3114 let root = RuleRoot::new(&fixture.dir).expect("canonicalizes");
3115 let engine = WasmEngine::new().expect("the shipped configuration builds an engine");
3116 let loader = lanekeep_wasm::ComponentLoader::without_cache();
3117 // Four references to the same component path — the shape of a config naming every rule
3118 // of one shared component, which is what the four migrated built-ins are.
3119 let path = root.path().join("rules/shared.wasm");
3120 let resolved: Vec<ResolvedRule> = (0..4)
3121 .map(|_| ResolvedRule {
3122 specifier: "./rules/shared.wasm".to_owned(),
3123 reference: RuleReference::Component(path.clone()),
3124 options: None,
3125 })
3126 .collect();
3127
3128 let compiled = compile_components(
3129 &root,
3130 &resolved,
3131 &engine,
3132 &loader,
3133 COMPILE_BUDGET_PER_COMPONENT,
3134 )
3135 .expect("the shared component compiles");
3136
3137 assert_eq!(compiled.len(), 4, "one Compiled per reference");
3138 assert_eq!(
3139 loader.compilations(),
3140 1,
3141 "one component compiled once, not once per reference"
3142 );
3143 assert_eq!(
3144 loader.embedded_loads(),
3145 1,
3146 "and deserialized once — the memo hands one Loaded to every reference"
3147 );
3148 assert!(
3149 Arc::ptr_eq(&compiled[0].admitted, &compiled[1].admitted),
3150 "the same Loaded is handed to the second reference"
3151 );
3152 assert!(
3153 Arc::ptr_eq(&compiled[0].admitted, &compiled[3].admitted),
3154 "and to every one after it"
3155 );
3156 }
3157
3158 /// A caller's `--timeout` has to govern config load, because config load runs guest code.
3159 ///
3160 /// **Asserts the raise, which is the direction that can fail.** `AGENTS.md` records why: a
3161 /// test that only *lowers* a budget passes against a budget that is ignored, because the run
3162 /// completes either way and completion is what such a test asserts. Raising is different —
3163 /// the un-overridden load must fail first, so the override is the only thing that can make
3164 /// the second one succeed.
3165 ///
3166 /// This is the `--timeout` trap recurring in a phase that did not exist when it was first
3167 /// found. The flag used to be applied to the `Config` *after* `load` returned, one statement
3168 /// below a config load that had already instantiated, configured and read `metadata` from
3169 /// every component under the config file's number. A component whose `configure` overran
3170 /// failed with a message ending "raise it with `--timeout`", and raising it changed nothing.
3171 ///
3172 /// The 50 ms against a burn of roughly a third of a second is a ratio, not a deadline: a
3173 /// slower machine only makes the breach breach harder, and the raised case has seconds of
3174 /// room. Both halves go through `load_with` rather than the CLI, because the CLI is where the
3175 /// bug was and a test that reproduced its structure would inherit it.
3176 #[test]
3177 fn a_raised_global_timeout_governs_config_load_and_not_only_the_run() {
3178 let files: &[(&str, &str)] = &[(
3179 "lanekeep.json",
3180 r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
3181 "timeouts": {"global": 50},
3182 "rules": [{"rule": "./rules/metadata.wasm", "options": {"burn": true}}]}"#,
3183 )];
3184 let fixture = Fixture::new("config-load-budget", files);
3185 fixture.write_component("rules/metadata.wasm", "metadata");
3186 let root = RuleRoot::new(&fixture.dir).expect("canonicalizes");
3187 let sandbox =
3188 sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
3189 let config_path = fixture.dir.join("lanekeep.json");
3190
3191 // The config's own budget is far below what the fixture spends in `configure`, so the
3192 // phase breaches. Asserted on the message as well as on the failure, because a fixture
3193 // that failed to load for some unrelated reason would satisfy `is_err` and would make
3194 // the raise below prove nothing.
3195 let breached = load_with(&sandbox, &root, &config_path, LoadOptions::default())
3196 .expect_err("50 ms is far below what the fixture's `configure` spends");
3197 let text = breached.to_string();
3198 assert!(
3199 text.contains("budget"),
3200 "the breach must be the budget rather than something incidental, got: {text}"
3201 );
3202
3203 // And raising it is what that message tells the user to do.
3204 load_with(
3205 &sandbox,
3206 &root,
3207 &config_path,
3208 LoadOptions {
3209 global_timeout: Some(Duration::from_secs(30)),
3210 ..LoadOptions::default()
3211 },
3212 )
3213 .expect("a raised budget must reach the phase that breached under the lower one");
3214 }
3215
3216 #[test]
3217 fn a_built_in_that_ships_as_a_component_resolves_without_a_path() {
3218 // The same claim as the test above, for the reference a *user* writes. `lanekeep init`
3219 // scaffolds `"lanekeep/<name>"`, and two of the rules that spelling names are compiled
3220 // components — so this is the shape every real config takes, where a `.wasm` path is
3221 // the shape a project rule takes.
3222 let fixture = Fixture::new("builtin-component-load", &[]);
3223 fixture.write_all(&[(
3224 "lanekeep.json",
3225 r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
3226 "rules": ["lanekeep/metadata"]}"#,
3227 )]);
3228
3229 let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
3230 .expect("a built-in component is resolvable by specifier");
3231
3232 let rule = &config.rules[0];
3233 // Everything about the rule is the component's own answer, exactly as for a path
3234 // reference. Nothing in the config said any of it.
3235 assert_eq!(rule.id.to_string(), "fixture/metadata");
3236 assert_eq!(
3237 rule.queries.get("rust"),
3238 Some(&"(call_expression) @call".to_owned())
3239 );
3240 assert_eq!(rule.languages, ["rust"]);
3241
3242 let component = rule
3243 .component
3244 .as_ref()
3245 .expect("a built-in component reaches the engine as a component");
3246 assert_eq!(
3247 component.bytes.as_slice(),
3248 built_in_component_bytes(),
3249 "the rule carries the embedded bytes it was described from"
3250 );
3251 // A specifier, not a path: there is no file, so there is nothing to canonicalize. It is
3252 // relative, which is what keeps it from ever colliding with a confined path — those are
3253 // absolute.
3254 assert_eq!(component.path, PathBuf::from("lanekeep/metadata"));
3255 assert!(
3256 !component.path.is_absolute(),
3257 "a built-in's provenance must not look like a resolved path"
3258 );
3259 assert!(
3260 component.counted_in_ruleset_hash(),
3261 "a built-in component `load` resolved must be counted in `ruleset_hash`"
3262 );
3263 }
3264
3265 /// A built-in names **one** rule of its component, and it is the one the table recorded.
3266 ///
3267 /// **The defect this is written against is silent and it is the whole point of the change
3268 /// that introduced it.** A built-in reference used to contribute every rule its component
3269 /// hosts, which was indistinguishable from correct while every component hosted one. With a
3270 /// shared artifact it means `lanekeep/no-default-export` runs four rules — each of them
3271 /// configured with options meant for one — and a run that reports four rules' violations
3272 /// where one was configured looks like a thorough tool rather than a broken one.
3273 ///
3274 /// `shared-second` is the sharp case: index 1 of a component whose index 0 is a perfectly
3275 /// good rule to run by mistake. Asserting the *count* alone would pass against a reference
3276 /// that contributed rule 0 instead, and asserting the id alone would pass against one that
3277 /// contributed both. Both, together, are what pin it.
3278 #[test]
3279 fn a_built_in_contributes_the_one_rule_its_table_recorded() {
3280 let fixture = Fixture::new("builtin-component-narrowed", &[]);
3281 fixture.write_all(&[(
3282 "lanekeep.json",
3283 r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
3284 "rules": ["lanekeep/shared-second"]}"#,
3285 )]);
3286
3287 let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
3288 .expect("a rule of a shared component is resolvable by specifier");
3289
3290 assert_eq!(
3291 config.rules.len(),
3292 1,
3293 "one entry naming one rule of a two-rule component must produce one rule, not the \
3294 component's whole list: {:?}",
3295 config
3296 .rules
3297 .iter()
3298 .map(|rule| rule.id.to_string())
3299 .collect::<Vec<_>>()
3300 );
3301
3302 let rule = &config.rules[0];
3303 assert_eq!(
3304 rule.id.to_string(),
3305 "fixture/second",
3306 "the reference is recorded at index 1 and index 0 is a rule that would run happily"
3307 );
3308 assert_eq!(
3309 rule.component
3310 .as_ref()
3311 .expect("a built-in component reaches the engine as a component")
3312 .index,
3313 1,
3314 "the engine dispatches on this, so it has to be the recorded index and not a \
3315 position in the config"
3316 );
3317 // And the *first* rule of the same component is reachable in its own right, so this is
3318 // narrowing rather than an artifact of only ever asking for one thing. Fetched with
3319 // `expect` rather than compared through `get`: `assert_ne!` on two `Option`s passes
3320 // vacuously when the key is absent, which is exactly the case this assertion exists
3321 // to rule out.
3322 let query = rule
3323 .queries
3324 .get("rust")
3325 .expect("the narrowed rule targets rust");
3326 assert_ne!(query, "(call_expression) @0");
3327 }
3328
3329 /// The other index of the same artifact, so "it narrows" is not "it always picks index 1".
3330 #[test]
3331 fn each_rule_of_one_shared_component_is_reachable_as_itself() {
3332 let fixture = Fixture::new("builtin-component-both", &[]);
3333 fixture.write_all(&[(
3334 "lanekeep.json",
3335 r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
3336 "rules": ["lanekeep/shared-first", "lanekeep/shared-second"]}"#,
3337 )]);
3338
3339 let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
3340 .expect("two rules of one artifact both resolve");
3341
3342 let ids: Vec<String> = config
3343 .rules
3344 .iter()
3345 .map(|rule| rule.id.to_string())
3346 .collect();
3347 assert_eq!(ids, vec!["fixture/first", "fixture/second"]);
3348
3349 let indices: Vec<u32> = config
3350 .rules
3351 .iter()
3352 .filter_map(|rule| rule.component.as_ref().map(|component| component.index))
3353 .collect();
3354 assert_eq!(indices, vec![0, 1], "each names its own slot");
3355
3356 // One artifact, read once per reference and carried by value, so both rules hold the
3357 // same bytes — which is what lets `ruleset_hash` collapse them and `RuleSet::add` give
3358 // them one instance.
3359 let bytes: Vec<&[u8]> = config
3360 .rules
3361 .iter()
3362 .filter_map(|rule| rule.component.as_ref().map(|c| c.bytes.as_slice()))
3363 .collect();
3364 assert_eq!(bytes.len(), 2);
3365 assert_eq!(bytes[0], bytes[1], "two rules, one component");
3366 }
3367
3368 /// A table recording an index the component does not have is refused, naming the disagreement.
3369 ///
3370 /// The failure mode a name-to-index table has that a lookup through the component does not:
3371 /// it can drift. Refused here rather than left to `RuleSet::add`, whose message would be
3372 /// about a slot — nobody reading "index 7 is out of range" would go looking for a built-in
3373 /// table that had moved.
3374 #[test]
3375 fn a_table_recording_an_index_its_component_does_not_have_is_refused() {
3376 let fixture = Fixture::new("builtin-component-drifted", &[]);
3377 fixture.write_all(&[(
3378 "lanekeep.json",
3379 r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
3380 "rules": ["lanekeep/shared-missing"]}"#,
3381 )]);
3382
3383 let error = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
3384 .expect_err("a recorded index the component does not have cannot be dispatched");
3385
3386 let rendered = error.to_string();
3387 assert!(
3388 rendered.contains("lanekeep/shared-missing"),
3389 "the refusal has to name the entry: {rendered}"
3390 );
3391 assert!(
3392 rendered.contains("index 7"),
3393 "and the index it could not find: {rendered}"
3394 );
3395 assert!(
3396 rendered.contains("hosting 2 rule(s)"),
3397 "and what the component actually hosts, which is the other half of a \
3398 disagreement: {rendered}"
3399 );
3400 assert!(
3401 rendered.contains("disagree"),
3402 "the diagnostic is about two things not matching, not about a bad number: \
3403 {rendered}"
3404 );
3405 }
3406
3407 /// A component has to answer its own id the same way twice.
3408 ///
3409 /// `world rule` splits `rules` from `metadata` because a rule's id must be knowable before
3410 /// the rule is configured, and `metadata` is read after `configure`. Two exports therefore
3411 /// answer one question, and until this check nothing compared them.
3412 ///
3413 /// **What a disagreement costs, and why it is silent.** The slot is registered under the id
3414 /// `rules()` reported and the `RuleSpec` is built from `metadata()`, so the rule *runs* under
3415 /// one name and is *reported* under another. Nothing fails: a violation appears, carrying an
3416 /// id that a suppression comment or a `--rule` filter naming the configured rule will not
3417 /// match, and neither of those says so.
3418 ///
3419 /// The fixture exists for this and nothing else — every other component in
3420 /// `crates/lanekeep-wasm/tests/fixtures/` answers consistently, which is exactly why none of
3421 /// them can catch a host that never asked. It also reaches `rule_specifier`, whose fallback
3422 /// nothing else exercises: this diagnostic is raised in the description pass, after the loop
3423 /// that had the entry's specifier in hand.
3424 #[test]
3425 fn a_component_that_answers_two_different_ids_for_one_rule_is_refused() {
3426 let fixture = Fixture::new("component-two-faced", &[]);
3427 fixture.write_all(&[(
3428 "lanekeep.json",
3429 r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
3430 "rules": ["lanekeep/two-faced"]}"#,
3431 )]);
3432
3433 let error = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
3434 .expect_err("a component whose two accounts of itself disagree cannot be loaded");
3435
3436 let rendered = error.to_string();
3437 assert!(
3438 rendered.contains("lanekeep/two-faced"),
3439 "the refusal has to name the config entry, which is what a reader can act on: \
3440 {rendered}"
3441 );
3442 assert!(
3443 rendered.contains("fixture/enumerated"),
3444 "and the id it was registered under: {rendered}"
3445 );
3446 assert!(
3447 rendered.contains("fixture/described"),
3448 "and the id its metadata answered, or a reader cannot see what disagreed: \
3449 {rendered}"
3450 );
3451 }
3452
3453 /// And a component that agrees with itself loads, so the check above is not rejecting
3454 /// everything.
3455 ///
3456 /// The pair matters more than usual here: an agreement check written as an unconditional
3457 /// refusal would pass the test above and fail nothing else in this file, because most of
3458 /// this suite's components are reached by path rather than as built-ins.
3459 #[test]
3460 fn a_component_that_agrees_with_itself_is_not_refused() {
3461 let fixture = Fixture::new("component-consistent", &[]);
3462 fixture.write_all(&[(
3463 "lanekeep.json",
3464 r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
3465 "rules": ["lanekeep/metadata"]}"#,
3466 )]);
3467
3468 let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
3469 .expect("a component whose two exports agree must load");
3470 assert_eq!(config.rules[0].id.to_string(), "fixture/metadata");
3471 }
3472
3473 /// A component whose `rules()` answers nothing is refused, and the refusal names the entry.
3474 ///
3475 /// The check is reached through [`no_rules_detail`] rather than a `.wasm` fixture that
3476 /// answers `rules()` with an empty list, because the question is whether an empty id list and
3477 /// a specifier produce the refusal — nothing a component has to run to answer. Deleting the
3478 /// branch in [`describe_components`] used to survive the whole suite; this reaches it
3479 /// directly, the way `a_component_naming_no_language_is_refused` reaches `validate_metadata`.
3480 #[test]
3481 fn a_component_hosting_no_rules_is_refused_with_its_specifier() {
3482 let error = no_rules_detail(&[], "./rules/empty.wasm")
3483 .expect_err("an empty rule list is nothing to run");
3484 assert!(
3485 error.contains("./rules/empty.wasm"),
3486 "the refusal has to name the entry, which is what a reader can act on: {error}"
3487 );
3488 assert!(
3489 error.contains("hosts no rules"),
3490 "and what is wrong with it: {error}"
3491 );
3492 }
3493
3494 /// And a component hosting rules is not refused, so the check above is not an unconditional
3495 /// refusal. The pair is what makes the first test mean something: a `no_rules_detail` that
3496 /// always erred would pass it and fail here.
3497 #[test]
3498 fn a_component_hosting_rules_is_not_refused() {
3499 assert!(
3500 no_rules_detail(&["fixture/one".to_owned()], "./rules/one.wasm").is_ok(),
3501 "a non-empty id list is a component with something to run"
3502 );
3503 }
3504
3505 #[test]
3506 fn the_same_specifier_is_a_module_in_a_build_where_no_component_ships() {
3507 // The pair, and it is the assertion that makes the one above mean something. With no
3508 // component table installed, `lanekeep/metadata` is an ordinary built-in module
3509 // specifier — and no such module ships, so it is refused as a missing rule rather than
3510 // silently becoming something else. A `classify` that ignored the lookup would pass the
3511 // test above and fail here.
3512 let fixture = Fixture::new("builtin-component-absent", &[]);
3513 fixture.write_all(&[(
3514 "lanekeep.json",
3515 r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
3516 "rules": ["lanekeep/metadata"]}"#,
3517 )]);
3518
3519 let error = fixture
3520 .load_json()
3521 .expect_err("nothing ships under that name in this build");
3522
3523 let rendered = error.to_string();
3524 assert!(
3525 rendered.contains("lanekeep/metadata"),
3526 "the refusal has to name the specifier: {rendered}"
3527 );
3528 }
3529
3530 /// The other half of the pair above. `load` is not the only way to build a
3531 /// `ComponentRule` — `ComponentRule::uncounted` is the door this crate hands an embedder or
3532 /// a test that attaches a component outside `load` — and it has to answer honestly too, or
3533 /// a mutant hardcoding `counted_in_ruleset_hash` to `true` would pass every test in this
3534 /// crate: nothing above ever exercises a value that is genuinely `false`.
3535 #[test]
3536 fn an_uncounted_component_is_not_counted_in_ruleset_hash() {
3537 let component = ComponentRule::uncounted(
3538 PathBuf::from("rules/mine.wasm"),
3539 0,
3540 "null".to_owned(),
3541 b"\0asm".to_vec(),
3542 );
3543 assert!(
3544 !component.counted_in_ruleset_hash(),
3545 "bytes nobody hashed must not claim to be counted"
3546 );
3547 }
3548
3549 // --- an empty language list ---------------------------------------------------------
3550 //
3551 // A rule runs only on a file whose language it names, so an empty list is not "every
3552 // language", it is *no file at all* — and silently: the rule loads, matches nothing and
3553 // reports nothing, which is what a clean codebase looks like. `wit/world.wit` declares
3554 // that the host refuses one at load; both ways a rule can arrive have to be held to it,
3555 // and the check is one piece of code in `build_rule` precisely so that they are.
3556
3557 #[test]
3558 fn a_typescript_rule_naming_no_language_is_refused() {
3559 let fixture = Fixture::new(
3560 "empty-languages-ts",
3561 &[
3562 (
3563 "rule.ts",
3564 "import { defineRule } from 'lanekeep';\n\
3565 export default defineRule({\n\
3566 id: 'local/silent',\n\
3567 language: [],\n\
3568 query: '(identifier) @id',\n\
3569 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
3570 check(ctx, m) { ctx.report(m.id); },\n\
3571 });\n",
3572 ),
3573 ("lanekeep.config.ts", &config_with("rules: [rule]")),
3574 ],
3575 );
3576
3577 let error = fixture
3578 .load_config()
3579 .expect_err("a rule that can never run must not load");
3580 let rendered = error.to_string();
3581 assert!(rendered.contains("local/silent"), "{rendered}");
3582 assert!(rendered.contains("names no language"), "{rendered}");
3583 }
3584
3585 #[test]
3586 fn a_component_naming_no_language_is_refused() {
3587 // The same refusal on the other path, driven through the two functions the wasm path
3588 // uses — `raw_rule_from` turns what a guest answered into a rule declaration, and
3589 // `build_rule` validates it. It stops short of a real guest for one reason: no
3590 // committed fixture answers an empty list, and adding a `.wasm` artifact whose only
3591 // purpose is to be rejected before it ever runs buys nothing this does not.
3592 let described = Described {
3593 raw: raw_rule_from(
3594 lanekeep_wasm::bindings::types::RuleMetadata {
3595 id: "fixture/silent".to_owned(),
3596 languages: Vec::new(),
3597 severity: "error".to_owned(),
3598 card: lanekeep_wasm::bindings::types::RuleCard {
3599 message: "m".to_owned(),
3600 remediation: "r".to_owned(),
3601 examples: lanekeep_wasm::bindings::types::RuleExamples {
3602 bad: "a".to_owned(),
3603 good: "b".to_owned(),
3604 },
3605 },
3606 queries: vec![lanekeep_wasm::bindings::types::QueryFor {
3607 language: "rust".to_owned(),
3608 query: "(call_expression) @call".to_owned(),
3609 }],
3610 gates: lanekeep_wasm::bindings::types::RuleGates {
3611 path_matches: Vec::new(),
3612 path_not_matches: Vec::new(),
3613 file_contains: Vec::new(),
3614 file_not_contains: Vec::new(),
3615 },
3616 timeout: None,
3617 },
3618 true,
3619 false,
3620 ),
3621 component: ComponentRule {
3622 path: PathBuf::from("silent.wasm"),
3623 index: 0,
3624 options: "null".to_owned(),
3625 bytes: Vec::new().into(),
3626 source_map: None,
3627 // This test drives `build_rule` directly, below `describe_components` and
3628 // `hash_ruleset` both — irrelevant to either, so `true` for the same reason
3629 // `Fixture::component` gives it.
3630 counted_in_ruleset_hash: true,
3631 },
3632 };
3633
3634 let declared = BTreeSet::from(["fixture".to_owned()]);
3635 let error = build_rule(
3636 described.raw,
3637 1,
3638 "lanekeep.json",
3639 &BTreeMap::new(),
3640 &declared,
3641 Some(described.component),
3642 )
3643 .expect_err("a component that can never run must not load");
3644
3645 let rendered = error.to_string();
3646 assert!(rendered.contains("fixture/silent"), "{rendered}");
3647 assert!(rendered.contains("names no language"), "{rendered}");
3648 }
3649
3650 // --- a language whose query is missing, in either direction ---------------------------
3651 //
3652 // One rule names one query per language it targets, so a rule can span grammars that do
3653 // not share node vocabulary. A declared language with no query of its own would run on
3654 // nothing, and a query for a language the rule does not target would never run — the
3655 // same silent failure the empty-`languages` refusal guards. Both directions are refused
3656 // in `build_rule`, naming the language.
3657
3658 #[test]
3659 fn a_typescript_rule_declaring_a_language_without_a_query_is_refused() {
3660 let fixture = Fixture::new(
3661 "missing-query-for-language-ts",
3662 &[
3663 (
3664 "rule.ts",
3665 "import { defineRule } from 'lanekeep';\n\
3666 export default defineRule({\n\
3667 id: 'local/multi',\n\
3668 language: ['typescript', 'python'],\n\
3669 query: { typescript: '(call_expression) @call' },\n\
3670 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
3671 check(ctx, m) { ctx.report(m.call); },\n\
3672 });\n",
3673 ),
3674 ("lanekeep.config.ts", &config_with("rules: [rule]")),
3675 ],
3676 );
3677
3678 let error = fixture
3679 .load_config()
3680 .expect_err("a language with no query of its own must not load");
3681 let rendered = error.to_string();
3682 assert!(rendered.contains("local/multi"), "{rendered}");
3683 assert!(rendered.contains("python"), "{rendered}");
3684 }
3685
3686 #[test]
3687 fn a_typescript_rule_declaring_a_query_for_an_undeclared_language_is_refused() {
3688 let fixture = Fixture::new(
3689 "undeclared-language-query-ts",
3690 &[
3691 (
3692 "rule.ts",
3693 "import { defineRule } from 'lanekeep';\n\
3694 export default defineRule({\n\
3695 id: 'local/multi',\n\
3696 language: ['typescript'],\n\
3697 query: { typescript: '(call_expression) @call', python: '(call) @call' },\n\
3698 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
3699 check(ctx, m) { ctx.report(m.call); },\n\
3700 });\n",
3701 ),
3702 ("lanekeep.config.ts", &config_with("rules: [rule]")),
3703 ],
3704 );
3705
3706 let error = fixture
3707 .load_config()
3708 .expect_err("a query for a language the rule does not target must not load");
3709 let rendered = error.to_string();
3710 assert!(rendered.contains("local/multi"), "{rendered}");
3711 assert!(rendered.contains("python"), "{rendered}");
3712 }
3713
3714 // --- an empty or malformed query, refused with the message these tests pin -----------
3715 //
3716 // The empty-query refusals went unasserted for a while: the one fixture that reached
3717 // them grew an unusable card too, the card check fires first, and nothing else drove
3718 // them — so deleting both `trim().is_empty()` blocks left the whole suite green. These
3719 // pin the messages through the real TypeScript pipeline.
3720
3721 #[test]
3722 fn a_typescript_rule_with_an_empty_query_is_refused() {
3723 let fixture = Fixture::new(
3724 "empty-query-string-ts",
3725 &[
3726 (
3727 "rule.ts",
3728 "import { defineRule } from 'lanekeep';\n\
3729 export default defineRule({\n\
3730 id: 'local/empty',\n\
3731 query: '',\n\
3732 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
3733 check(ctx, m) { ctx.report(m.call); },\n\
3734 });\n",
3735 ),
3736 ("lanekeep.config.ts", &config_with("rules: [rule]")),
3737 ],
3738 );
3739
3740 let error = fixture
3741 .load_config()
3742 .expect_err("an empty query can never match, so it must not load");
3743 let rendered = error.to_string();
3744 assert!(rendered.contains("local/empty"), "{rendered}");
3745 assert!(rendered.contains("has an empty `query`"), "{rendered}");
3746 }
3747
3748 #[test]
3749 fn a_typescript_rule_with_an_empty_query_for_one_language_is_refused() {
3750 let fixture = Fixture::new(
3751 "empty-query-for-language-ts",
3752 &[
3753 (
3754 "rule.ts",
3755 "import { defineRule } from 'lanekeep';\n\
3756 export default defineRule({\n\
3757 id: 'local/multi',\n\
3758 language: ['typescript', 'python'],\n\
3759 query: { typescript: '(call_expression) @call', python: ' ' },\n\
3760 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
3761 check(ctx, m) { ctx.report(m.call); },\n\
3762 });\n",
3763 ),
3764 ("lanekeep.config.ts", &config_with("rules: [rule]")),
3765 ],
3766 );
3767
3768 let error = fixture
3769 .load_config()
3770 .expect_err("an empty query for one language can never match on it");
3771 let rendered = error.to_string();
3772 assert!(rendered.contains("local/multi"), "{rendered}");
3773 assert!(rendered.contains("empty `query` for"), "{rendered}");
3774 assert!(rendered.contains("python"), "{rendered}");
3775 }
3776
3777 #[test]
3778 fn a_query_of_the_wrong_shape_is_refused_naming_the_field() {
3779 // The refusal has to say what a `query` may be. An untagged enum reported "data did
3780 // not match any variant of untagged enum RawQueries" here — a private type's name,
3781 // with the field and the expected shapes gone.
3782 let fixture = Fixture::new(
3783 "malformed-query-ts",
3784 &[
3785 (
3786 "rule.ts",
3787 "import { defineRule } from 'lanekeep';\n\
3788 export default defineRule({\n\
3789 id: 'local/malformed',\n\
3790 query: 42,\n\
3791 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
3792 check(ctx, m) { ctx.report(m.call); },\n\
3793 });\n",
3794 ),
3795 ("lanekeep.config.ts", &config_with("rules: [rule]")),
3796 ],
3797 );
3798
3799 let error = fixture
3800 .load_config()
3801 .expect_err("a number is not a query in either shape");
3802 let rendered = error.to_string();
3803 assert!(
3804 rendered.contains("`query` must be a string, or an object"),
3805 "{rendered}"
3806 );
3807 assert!(rendered.contains("not a number"), "{rendered}");
3808 }
3809
3810 #[test]
3811 fn a_query_entry_of_the_wrong_shape_is_refused_naming_its_language() {
3812 let fixture = Fixture::new(
3813 "malformed-query-entry-ts",
3814 &[
3815 (
3816 "rule.ts",
3817 "import { defineRule } from 'lanekeep';\n\
3818 export default defineRule({\n\
3819 id: 'local/malformed',\n\
3820 query: { typescript: 5 },\n\
3821 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
3822 check(ctx, m) { ctx.report(m.call); },\n\
3823 });\n",
3824 ),
3825 ("lanekeep.config.ts", &config_with("rules: [rule]")),
3826 ],
3827 );
3828
3829 let error = fixture
3830 .load_config()
3831 .expect_err("a number is not a query for a language either");
3832 let rendered = error.to_string();
3833 assert!(
3834 rendered.contains("`query` for `typescript` must be a string"),
3835 "{rendered}"
3836 );
3837 assert!(rendered.contains("not a number"), "{rendered}");
3838 }
3839
3840 #[test]
3841 fn a_component_declaring_a_language_without_a_query_is_refused() {
3842 // The same refusal on the component path, driven through the two functions the wasm
3843 // path uses — `raw_rule_from` and `build_rule` — exactly as the empty-languages test
3844 // drives its refusal.
3845 let described = Described {
3846 raw: raw_rule_from(
3847 lanekeep_wasm::bindings::types::RuleMetadata {
3848 id: "fixture/silent".to_owned(),
3849 languages: vec!["rust".to_owned(), "go".to_owned()],
3850 severity: "error".to_owned(),
3851 card: lanekeep_wasm::bindings::types::RuleCard {
3852 message: "m".to_owned(),
3853 remediation: "r".to_owned(),
3854 examples: lanekeep_wasm::bindings::types::RuleExamples {
3855 bad: "a".to_owned(),
3856 good: "b".to_owned(),
3857 },
3858 },
3859 queries: vec![lanekeep_wasm::bindings::types::QueryFor {
3860 language: "rust".to_owned(),
3861 query: "(call_expression) @call".to_owned(),
3862 }],
3863 gates: lanekeep_wasm::bindings::types::RuleGates {
3864 path_matches: Vec::new(),
3865 path_not_matches: Vec::new(),
3866 file_contains: Vec::new(),
3867 file_not_contains: Vec::new(),
3868 },
3869 timeout: None,
3870 },
3871 true,
3872 false,
3873 ),
3874 component: ComponentRule {
3875 path: PathBuf::from("silent.wasm"),
3876 index: 0,
3877 options: "null".to_owned(),
3878 bytes: Vec::new().into(),
3879 source_map: None,
3880 counted_in_ruleset_hash: true,
3881 },
3882 };
3883
3884 let declared = BTreeSet::from(["fixture".to_owned()]);
3885 let error = build_rule(
3886 described.raw,
3887 1,
3888 "lanekeep.json",
3889 &BTreeMap::new(),
3890 &declared,
3891 Some(described.component),
3892 )
3893 .expect_err("a language with no query of its own must not load");
3894
3895 let rendered = error.to_string();
3896 assert!(rendered.contains("fixture/silent"), "{rendered}");
3897 assert!(rendered.contains("go"), "{rendered}");
3898 }
3899
3900 #[test]
3901 fn a_component_declaring_a_query_for_an_undeclared_language_is_refused() {
3902 let described = Described {
3903 raw: raw_rule_from(
3904 lanekeep_wasm::bindings::types::RuleMetadata {
3905 id: "fixture/silent".to_owned(),
3906 languages: vec!["rust".to_owned()],
3907 severity: "error".to_owned(),
3908 card: lanekeep_wasm::bindings::types::RuleCard {
3909 message: "m".to_owned(),
3910 remediation: "r".to_owned(),
3911 examples: lanekeep_wasm::bindings::types::RuleExamples {
3912 bad: "a".to_owned(),
3913 good: "b".to_owned(),
3914 },
3915 },
3916 queries: vec![
3917 lanekeep_wasm::bindings::types::QueryFor {
3918 language: "rust".to_owned(),
3919 query: "(call_expression) @call".to_owned(),
3920 },
3921 lanekeep_wasm::bindings::types::QueryFor {
3922 language: "go".to_owned(),
3923 query: "(call_expression) @call".to_owned(),
3924 },
3925 ],
3926 gates: lanekeep_wasm::bindings::types::RuleGates {
3927 path_matches: Vec::new(),
3928 path_not_matches: Vec::new(),
3929 file_contains: Vec::new(),
3930 file_not_contains: Vec::new(),
3931 },
3932 timeout: None,
3933 },
3934 true,
3935 false,
3936 ),
3937 component: ComponentRule {
3938 path: PathBuf::from("silent.wasm"),
3939 index: 0,
3940 options: "null".to_owned(),
3941 bytes: Vec::new().into(),
3942 source_map: None,
3943 counted_in_ruleset_hash: true,
3944 },
3945 };
3946
3947 let declared = BTreeSet::from(["fixture".to_owned()]);
3948 let error = build_rule(
3949 described.raw,
3950 1,
3951 "lanekeep.json",
3952 &BTreeMap::new(),
3953 &declared,
3954 Some(described.component),
3955 )
3956 .expect_err("a query for a language the rule does not target must not load");
3957
3958 let rendered = error.to_string();
3959 assert!(rendered.contains("fixture/silent"), "{rendered}");
3960 assert!(rendered.contains("go"), "{rendered}");
3961 }
3962
3963 #[test]
3964 fn per_language_queries_survive_extraction_from_a_typescript_module() {
3965 // A rule declaring one query per language, each set to a different value on purpose —
3966 // asserting two of the two leaves neither mapped by nothing.
3967 let fixture = Fixture::new(
3968 "ts-module-per-language-queries",
3969 &[
3970 (
3971 "rule.ts",
3972 "import { defineRule } from 'lanekeep';\n\
3973 export default defineRule({\n\
3974 id: 'local/multi',\n\
3975 language: ['typescript', 'python'],\n\
3976 query: {\n\
3977 typescript: '(call_expression) @call',\n\
3978 python: '(call) @call',\n\
3979 },\n\
3980 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
3981 check(ctx, m) { ctx.report(m.call); },\n\
3982 });\n",
3983 ),
3984 ("lanekeep.config.ts", &config_with("rules: [rule]")),
3985 ],
3986 );
3987
3988 let config = fixture.load_config().expect("loads");
3989 assert_eq!(
3990 config.rules[0].queries,
3991 BTreeMap::from([
3992 (
3993 "typescript".to_owned(),
3994 "(call_expression) @call".to_owned()
3995 ),
3996 ("python".to_owned(), "(call) @call".to_owned()),
3997 ])
3998 );
3999 }
4000
4001 #[test]
4002 fn a_single_string_query_is_expanded_to_every_declared_language() {
4003 // The sugar shape: one string for every language the rule targets, so `One` becomes
4004 // one entry per declared language in `build_rule`.
4005 let fixture = Fixture::new(
4006 "ts-module-query-sugar",
4007 &[
4008 (
4009 "rule.ts",
4010 "import { defineRule } from 'lanekeep';\n\
4011 export default defineRule({\n\
4012 id: 'local/multi',\n\
4013 language: ['typescript', 'python'],\n\
4014 query: '(call_expression) @call',\n\
4015 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
4016 check(ctx, m) { ctx.report(m.call); },\n\
4017 });\n",
4018 ),
4019 ("lanekeep.config.ts", &config_with("rules: [rule]")),
4020 ],
4021 );
4022
4023 let config = fixture.load_config().expect("loads");
4024 assert_eq!(
4025 config.rules[0].queries,
4026 BTreeMap::from([
4027 (
4028 "typescript".to_owned(),
4029 "(call_expression) @call".to_owned()
4030 ),
4031 ("python".to_owned(), "(call_expression) @call".to_owned()),
4032 ])
4033 );
4034 }
4035
4036 #[test]
4037 fn a_component_is_held_to_the_same_card_and_query_a_typescript_rule_is() {
4038 // End to end, through a real guest: `world-shape.wasm` answers `metadata` with an empty
4039 // card and an empty query, because it is a probe rather than a rule. A component's
4040 // answers go through `build_rule` exactly as an extracted TypeScript rule's do, so it
4041 // is refused for the reasons a TypeScript rule would be. The card check fires first,
4042 // so the card refusal is what this fixture reaches — asserted below, so a reordering
4043 // that changed which refusal answers does not pass unnoticed. The empty-*query*
4044 // refusal is pinned by its own tests above, and on the component path by
4045 // `a_component_with_an_empty_query_for_a_language_is_refused`.
4046 let fixture = Fixture::new("component-validated", &[]);
4047 fixture.write_component("rules/probe.wasm", "world-shape");
4048 fixture.write_all(&[(
4049 "lanekeep.json",
4050 r#"{"namespaces": ["fixture"], "rules": ["./rules/probe.wasm"]}"#,
4051 )]);
4052
4053 let error = fixture
4054 .load_json()
4055 .expect_err("a probe is not a usable rule");
4056 assert!(
4057 matches!(error, ConfigError::Rule { position: 1, .. }),
4058 "{error:?}"
4059 );
4060 assert!(
4061 error.to_string().contains("fixture/world-shape"),
4062 "the component's own id should name it: {error}"
4063 );
4064 assert!(
4065 error.to_string().contains("unusable card"),
4066 "the card check fires first for this probe: {error}"
4067 );
4068 }
4069
4070 #[test]
4071 fn a_component_with_an_empty_query_for_a_language_is_refused() {
4072 // The host gate deliberately admits an empty query string — probe fixtures answer
4073 // `metadata` with one on purpose — so the refusal belongs to the last gate before a
4074 // rule runs, `build_rule`, and this drives it through a real guest: the `metadata`
4075 // fixture's `{"empty-query":true}` flag makes its `metadata` answer a well-formed
4076 // card and an empty query for its one language.
4077 let fixture = Fixture::new("component-empty-query", &[]);
4078 fixture.write_component("rules/probe.wasm", "metadata");
4079 fixture.write_all(&[(
4080 "lanekeep.json",
4081 r#"{"namespaces": ["fixture"],
4082 "rules": [{"rule": "./rules/probe.wasm", "options": {"empty-query": true}}]}"#,
4083 )]);
4084
4085 let error = fixture
4086 .load_json()
4087 .expect_err("an empty query for a language can never match on it");
4088 let rendered = error.to_string();
4089 assert!(rendered.contains("fixture/metadata"), "{rendered}");
4090 assert!(rendered.contains("empty `query` for"), "{rendered}");
4091 assert!(rendered.contains("rust"), "{rendered}");
4092 }
4093
4094 // --- confinement ------------------------------------------------------------------
4095 //
4096 // A rule reference is a string in a config file and a component is *executed*, so where
4097 // one may point is a trust boundary. The cases below are the sibling's: `crates/
4098 // lanekeep-js/src/loader.rs` refuses traversal, an absolute path and a symlink out of the
4099 // root for a module import, and a `.wasm` reference has to be refused for the same reasons
4100 // — through `RuleRoot::confine`, which is that same check rather than a second one.
4101
4102 #[test]
4103 fn a_component_reference_may_not_traverse_out_of_the_rules_root() {
4104 // Refused whatever is on disk: `secret.wasm` is real and is one directory up. An error
4105 // that depended on whether the target existed would tell a reader about the filesystem
4106 // rather than about their config.
4107 let fixture = Fixture::new("component-traversal", &[]);
4108 fixture.write_component("secret.wasm", "metadata");
4109 fs::create_dir_all(fixture.dir.join("project")).expect("creates the inner root");
4110
4111 for specifier in ["../secret.wasm", "../../secret.wasm", "./../secret.wasm"] {
4112 fs::write(
4113 fixture.dir.join("project/lanekeep.json"),
4114 format!(r#"{{"namespaces": ["fixture"], "rules": ["{specifier}"]}}"#),
4115 )
4116 .expect("writes");
4117
4118 let error = load_from(&fixture.dir.join("project"), "lanekeep.json")
4119 .expect_err("traversal must not resolve");
4120 assert!(
4121 matches!(error, ConfigError::Rule { position: 1, .. }),
4122 "{specifier} gave {error:?}"
4123 );
4124 assert!(
4125 error.to_string().contains("outside the rules root"),
4126 "{specifier} gave {error}"
4127 );
4128 }
4129 }
4130
4131 #[test]
4132 fn a_component_reference_may_not_be_an_absolute_path() {
4133 // Built from `temp_dir` rather than written literally: `Path::is_absolute` is
4134 // platform-specific, so a literal would take a different branch on each platform. What
4135 // makes this reachable at all is that `Path::join` lets an absolute path replace the
4136 // base outright, so joining it against the rules root does not confine it.
4137 let fixture = Fixture::new("component-absolute", &[]);
4138 fixture.write_component("outside.wasm", "metadata");
4139
4140 let outside = fixture.dir.join("outside.wasm");
4141 let inner = fixture.dir.join("project");
4142 fs::create_dir_all(&inner).expect("creates the inner root");
4143 // Two platform hazards sit between this path and the check it is here to reach, and
4144 // both refuse it for a reason that is not confinement.
4145 //
4146 // Forward slashes, because `validate_specifier` rejects any specifier containing a
4147 // backslash — a guard that predates components and exists because a specifier is
4148 // interpolated into generated JavaScript. A Windows path spelled `C:\Users\...` is
4149 // therefore refused one layer above `confine`, with a message about quoting. Spelled
4150 // `C:/Users/...` it is still absolute — Rust accepts either separator on Windows — and
4151 // it reaches the confinement check this test names. On Unix the replacement is a no-op.
4152 //
4153 // Then `serde_json` rather than `format!`, because a backslash also begins an escape
4154 // inside a JSON string, so an interpolated Windows path makes the config fail to
4155 // *parse*. Belt and braces: the replacement above already removes them, and encoding
4156 // properly keeps this true if the path ever carries something else JSON reserves.
4157 let forward = outside.display().to_string().replace('\\', "/");
4158 let specifier = serde_json::to_string(&forward).expect("a path is a JSON string");
4159 fs::write(
4160 inner.join("lanekeep.json"),
4161 format!(r#"{{"namespaces": ["fixture"], "rules": [{specifier}]}}"#),
4162 )
4163 .expect("writes");
4164
4165 let error = load_from(&inner, "lanekeep.json").expect_err("an absolute path is refused");
4166 assert!(
4167 error.to_string().contains("outside the rules root"),
4168 "{error}"
4169 );
4170 }
4171
4172 #[cfg(unix)]
4173 #[test]
4174 fn a_component_reference_may_not_be_a_symlink_out_of_the_rules_root() {
4175 // The case a lexical check cannot see, and the reason `confine` canonicalizes rather
4176 // than only normalizing. `./link.wasm` sits inside the root and looks entirely
4177 // innocent.
4178 let fixture = Fixture::new("component-symlink", &[]);
4179 fixture.write_component("outside.wasm", "metadata");
4180 let inner = fixture.dir.join("project");
4181 fs::create_dir_all(&inner).expect("creates the inner root");
4182 std::os::unix::fs::symlink(fixture.dir.join("outside.wasm"), inner.join("link.wasm"))
4183 .expect("creates symlink");
4184 fs::write(
4185 inner.join("lanekeep.json"),
4186 r#"{"namespaces": ["fixture"], "rules": ["./link.wasm"]}"#,
4187 )
4188 .expect("writes");
4189
4190 let error = load_from(&inner, "lanekeep.json").expect_err("a symlink out is refused");
4191 assert!(
4192 error.to_string().contains("outside the rules root"),
4193 "{error}"
4194 );
4195 }
4196
4197 #[cfg(unix)]
4198 #[test]
4199 fn an_escaping_component_is_refused_before_its_bytes_are_read() {
4200 // Confinement that happened after the read would already have loaded, compiled and
4201 // instantiated whatever the reference pointed at — the check would be a report rather
4202 // than a guard. Pointing at a path that is *unreadable* rather than absent separates
4203 // the two: read-then-check reports the permission error, check-then-read reports the
4204 // escape.
4205 let fixture = Fixture::new("component-escape-before-read", &[]);
4206 fixture.write_component("outside.wasm", "metadata");
4207 let outside = fixture.dir.join("outside.wasm");
4208 fs::set_permissions(
4209 &outside,
4210 std::os::unix::fs::PermissionsExt::from_mode(0o000),
4211 )
4212 .expect("makes it unreadable");
4213
4214 let inner = fixture.dir.join("project");
4215 fs::create_dir_all(&inner).expect("creates the inner root");
4216 fs::write(
4217 inner.join("lanekeep.json"),
4218 r#"{"namespaces": ["fixture"], "rules": ["../outside.wasm"]}"#,
4219 )
4220 .expect("writes");
4221
4222 let error = load_from(&inner, "lanekeep.json").expect_err("refused");
4223 let rendered = error.to_string();
4224 assert!(
4225 rendered.contains("outside the rules root"),
4226 "the escape must be what stopped it, not the read: {rendered}"
4227 );
4228 assert!(
4229 !rendered.contains("Permission denied"),
4230 "nothing may be read before the reference is confined: {rendered}"
4231 );
4232
4233 // Left readable, or the fixture's own cleanup cannot remove it.
4234 fs::set_permissions(
4235 &outside,
4236 std::os::unix::fs::PermissionsExt::from_mode(0o644),
4237 )
4238 .expect("restores");
4239 }
4240
4241 /// A TypeScript rule sitting after a component still reaches its own handler.
4242 ///
4243 /// **The silent failure this is written against.** `RuleSpec::index` is how the engine
4244 /// reaches a TypeScript handler — it is spelled `__lanekeepConfig.rules[index].check(...)`
4245 /// — and a component contributes nothing to that array. Numbering the array separately
4246 /// from the config would leave rule 2 of a mixed config at array position 1: the call
4247 /// succeeds, the wrong rule's handler runs, and every violation is reported under a
4248 /// neighbor's id. Nothing errors and nothing looks wrong.
4249 ///
4250 /// So the two numberings are one numbering, and this is what says so end to end: the
4251 /// TypeScript rule's `index` is its position in the config, and the entry module has that
4252 /// rule at that position.
4253 #[test]
4254 fn a_typescript_rule_after_a_component_keeps_its_own_index() {
4255 let fixture = Fixture::new(
4256 "component-mixed-order",
4257 &[("second.ts", &rule("local/second"))],
4258 );
4259 fixture.write_component("rules/metadata.wasm", "metadata");
4260 fixture.write_all(&[(
4261 "lanekeep.json",
4262 r#"{"namespaces": ["fixture"],
4263 "rules": ["./rules/metadata.wasm", "./second"]}"#,
4264 )]);
4265
4266 let config = fixture.load_json().expect("loads");
4267
4268 assert_eq!(config.rules[0].id.to_string(), "fixture/metadata");
4269 assert_eq!(config.rules[0].index, 0);
4270 assert_eq!(config.rules[1].id.to_string(), "local/second");
4271 assert_eq!(
4272 config.rules[1].index, 1,
4273 "the TypeScript rule's index is its position in the array the engine indexes"
4274 );
4275 assert!(config.rules[1].component.is_none());
4276 }
4277
4278 /// One reference, one component, and every rule the component hosts.
4279 ///
4280 /// **The rule a config names is not the unit a component is.** A component hosts a list —
4281 /// which is what makes one 12.34 MiB JavaScript engine worth building rules on rather than
4282 /// one copy per rule — so describing one reference means enumerating it and then describing
4283 /// each rule by position. A description that stopped at rule 0 would load cleanly and leave
4284 /// every later rule of the component configured, cached and never run, which looks exactly
4285 /// like a codebase that is clean.
4286 #[test]
4287 fn one_component_describes_every_rule_it_hosts() {
4288 let fixture = Fixture::new(
4289 "component-many-rules",
4290 &[("second.ts", &rule("local/last"))],
4291 );
4292 fixture.write_component("rules/two-rules.wasm", "two-rules");
4293 fixture.write_all(&[(
4294 "lanekeep.json",
4295 r#"{"namespaces": ["fixture"],
4296 "rules": ["./rules/two-rules.wasm", "./second"]}"#,
4297 )]);
4298
4299 let config = fixture.load_json().expect("loads");
4300
4301 let ids: Vec<String> = config.rules.iter().map(|r| r.id.to_string()).collect();
4302 assert_eq!(ids, ["fixture/first", "fixture/second", "local/last"]);
4303
4304 // Each rule described as itself, not as its neighbor. The fixture's query is the one
4305 // field that has nothing to do with its configuration, so two rules collapsing into one
4306 // description shows up here whatever `configure` did.
4307 assert_eq!(
4308 config.rules[0].queries.get("rust"),
4309 Some(&"(call_expression) @0".to_owned())
4310 );
4311 assert_eq!(
4312 config.rules[1].queries.get("rust"),
4313 Some(&"(call_expression) @1".to_owned())
4314 );
4315
4316 let first = config.rules[0]
4317 .component
4318 .as_ref()
4319 .expect("a component-backed rule");
4320 let second = config.rules[1]
4321 .component
4322 .as_ref()
4323 .expect("a component-backed rule");
4324 assert_eq!((first.index, second.index), (0, 1));
4325 assert_eq!(
4326 first.bytes, second.bytes,
4327 "two rules of one component are one artifact, read once"
4328 );
4329
4330 // The entry module has one slot for the reference and none for what it turned out to
4331 // hold, so both rules carry the reference's own position — and the TypeScript rule
4332 // after them keeps the position it was written at. Numbering `Config::rules` instead
4333 // would leave `local/last` reaching the component's placeholder, which is `null`.
4334 assert_eq!(config.rules[0].index, 0);
4335 assert_eq!(config.rules[1].index, 0);
4336 assert_eq!(
4337 config.rules[2].index, 1,
4338 "a rule after a multi-rule component still indexes the array the engine indexes"
4339 );
4340 assert!(config.rules[2].component.is_none());
4341 }
4342
4343 /// And the options a reference carries reach every one of them, before metadata is read.
4344 ///
4345 /// A reference names a component, and there is no syntax naming one rule inside it, so a
4346 /// family of rules shipped in one artifact is configured as a family. The fixture echoes
4347 /// its `tag` back through `metadata`, which is the one export whose answer is allowed to
4348 /// depend on `configure` — so this also says the two calls happened in that order, for
4349 /// each rule rather than for the first.
4350 #[test]
4351 fn a_multi_rule_components_options_reach_each_of_its_rules() {
4352 let fixture = Fixture::new("component-many-options", &[]);
4353 fixture.write_component("rules/two-rules.wasm", "two-rules");
4354 fixture.write_all(&[(
4355 "lanekeep.json",
4356 r#"{"namespaces": ["fixture"],
4357 "rules": [{"rule": "./rules/two-rules.wasm", "options": {"tag": "alpha"}}]}"#,
4358 )]);
4359
4360 let config = fixture.load_json().expect("loads");
4361
4362 let messages: Vec<&str> = config
4363 .rules
4364 .iter()
4365 .map(|r| r.card.message.as_str())
4366 .collect();
4367 assert_eq!(
4368 messages,
4369 ["fixture/first tag=alpha", "fixture/second tag=alpha"]
4370 );
4371 for spec in &config.rules {
4372 assert_eq!(
4373 spec.component
4374 .as_ref()
4375 .expect("a component-backed rule")
4376 .options,
4377 r#"{"tag":"alpha"}"#
4378 );
4379 }
4380 }
4381
4382 #[test]
4383 fn a_component_carries_the_options_it_was_configured_with() {
4384 // A component cannot close over a host-supplied value, so its options travel with it
4385 // as data — all the way to every worker's `configure`. A rule named with no options is
4386 // still configured, with `null`, which is the world's own shape for it.
4387 let fixture = Fixture::new("component-options", &[]);
4388 fixture.write_component("rules/metadata.wasm", "metadata");
4389 fixture.write_all(&[(
4390 "lanekeep.json",
4391 r#"{"namespaces": ["fixture"],
4392 "rules": [{"rule": "./rules/metadata.wasm", "options": {"allow": ["a.rs"]}}]}"#,
4393 )]);
4394
4395 let config = fixture.load_json().expect("loads");
4396 let component = config.rules[0]
4397 .component
4398 .as_ref()
4399 .expect("a component-backed rule");
4400 assert_eq!(component.options, r#"{"allow":["a.rs"]}"#);
4401
4402 fixture.write_all(&[(
4403 "lanekeep.json",
4404 r#"{"namespaces": ["fixture"], "rules": ["./rules/metadata.wasm"]}"#,
4405 )]);
4406 let bare = fixture.load_json().expect("loads");
4407 assert_eq!(
4408 bare.rules[0]
4409 .component
4410 .as_ref()
4411 .expect("a component-backed rule")
4412 .options,
4413 "null"
4414 );
4415 }
4416
4417 #[test]
4418 fn a_component_that_refuses_its_options_is_refused_at_load() {
4419 // A misconfigured rule is not a rule that misbehaved, and the difference is what the
4420 // user can do about it. The guest's own message has to survive to the diagnostic,
4421 // because it is the only part that names what was wrong with the configuration.
4422 let fixture = Fixture::new("component-bad-options", &[]);
4423 fixture.write_component("rules/metadata.wasm", "metadata");
4424 fixture.write_all(&[(
4425 "lanekeep.json",
4426 r#"{"namespaces": ["fixture"],
4427 "rules": [{"rule": "./rules/metadata.wasm", "options": [1, 2]}]}"#,
4428 )]);
4429
4430 let error = fixture
4431 .load_json()
4432 .expect_err("the fixture refuses an array");
4433
4434 assert!(
4435 matches!(error, ConfigError::Rule { position: 1, .. }),
4436 "the diagnostic should name which entry: {error:?}"
4437 );
4438 assert!(
4439 error.to_string().contains("expected an object"),
4440 "the guest's own message should survive: {error}"
4441 );
4442 }
4443
4444 /// A component that cannot be read fails the load, so it never reaches a hash.
4445 ///
4446 /// **This is where §8.2's "absence is a dependency" went, and it is stronger here.**
4447 /// `ruleset_hash` used to fold a present/absent marker per component, so that a missing
4448 /// one and a present one could not share a cache key; it folds bytes that were already
4449 /// read now, and cannot see absence at all. It does not need to: the run whose key would
4450 /// have been wrong does not happen. A missing component is refused before a `Config`
4451 /// exists, naming which entry, so there is nothing to serve a stale answer to.
4452 #[test]
4453 fn a_component_that_is_not_there_is_refused_by_position() {
4454 let fixture = Fixture::new(
4455 "component-missing",
4456 &[
4457 ("first.ts", &rule("local/first")),
4458 (
4459 "lanekeep.json",
4460 r#"{"rules": ["./first", "./rules/gone.wasm"]}"#,
4461 ),
4462 ],
4463 );
4464
4465 let error = fixture.load_json().expect_err("there are no bytes to run");
4466 assert!(
4467 matches!(error, ConfigError::Rule { position: 2, .. }),
4468 "the diagnostic should name which entry: {error:?}"
4469 );
4470 assert!(error.to_string().contains("gone.wasm"), "{error}");
4471 }
4472
4473 // --- hashing --------------------------------------------------------------------
4474
4475 #[test]
4476 fn the_ruleset_hash_covers_an_imported_helper() {
4477 // The §8 property, and the reason the loader records what it read rather than the
4478 // config naming its own inputs. A rule importing a helper has to invalidate when
4479 // that helper changes — nothing else in the system knows the helper was involved.
4480 let files: &[(&str, &str)] = &[
4481 ("helper.ts", "export const QUERY = '(identifier) @id';\n"),
4482 (
4483 "rule.ts",
4484 "import { defineRule } from 'lanekeep';\n\
4485 import { QUERY } from './helper';\n\
4486 export default defineRule({\n\
4487 id: 'local/example',\n\
4488 query: QUERY,\n\
4489 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
4490 check() {},\n\
4491 });\n",
4492 ),
4493 ("lanekeep.config.ts", ""),
4494 ];
4495 let fixture = Fixture::new("helper-hash", files);
4496 fixture.write_all(&[("lanekeep.config.ts", &config_with("rules: [rule]"))]);
4497
4498 let before = fixture.load_config().expect("loads").ruleset_hash;
4499
4500 fixture.write_all(&[("helper.ts", "export const QUERY = '(string) @s';\n")]);
4501 let after = fixture.load_config().expect("loads").ruleset_hash;
4502
4503 assert_ne!(
4504 hex(&before),
4505 hex(&after),
4506 "changing an imported helper must invalidate the ruleset hash"
4507 );
4508 }
4509
4510 #[test]
4511 fn the_ruleset_hash_is_stable_when_nothing_changed() {
4512 let fixture = Fixture::new(
4513 "stable-hash",
4514 &[
4515 ("rule.ts", &rule("local/example")),
4516 ("lanekeep.config.ts", &config_with("rules: [rule]")),
4517 ],
4518 );
4519 let first = fixture.load_config().expect("loads").ruleset_hash;
4520 let second = fixture.load_config().expect("loads").ruleset_hash;
4521 assert_eq!(hex(&first), hex(&second));
4522 }
4523
4524 #[test]
4525 fn the_ruleset_hash_covers_a_components_bytes() {
4526 // The component half of the same property `the_ruleset_hash_covers_an_imported_helper`
4527 // asserts for modules: editing the code a rule is made of must invalidate.
4528 let fixture = Fixture::new("component-bytes", &[("mine.wasm", "\u{0}asm-one")]);
4529 let sandbox = fixture.empty_sandbox();
4530
4531 let before = hash_ruleset(&sandbox, &[&fixture.component("mine.wasm")]);
4532 fixture.write_all(&[("mine.wasm", "\u{0}asm-two")]);
4533 let after = hash_ruleset(&sandbox, &[&fixture.component("mine.wasm")]);
4534
4535 assert_ne!(
4536 hex(&before),
4537 hex(&after),
4538 "rebuilding a rule component must invalidate its cached results"
4539 );
4540 }
4541
4542 #[test]
4543 fn two_rules_from_one_component_fold_its_bytes_once() {
4544 // Same component, two rules. The bytes must reach ruleset_hash once, or the hash is
4545 // quadratic in a component's rule count and cannot tell "one component, two rules"
4546 // from "one component named twice".
4547 let fixture = Fixture::new("component-two-rules", &[("a.wasm", "\u{0}asm-two-rules")]);
4548 let sandbox = fixture.empty_sandbox();
4549
4550 let one = hash_ruleset(
4551 &sandbox,
4552 &[
4553 &fixture.component_at("a.wasm", 0),
4554 &fixture.component_at("a.wasm", 1),
4555 ],
4556 );
4557 let twice = hash_ruleset(
4558 &sandbox,
4559 &[
4560 &fixture.component_at("a.wasm", 0),
4561 &fixture.component_at("a.wasm", 0),
4562 ],
4563 );
4564 assert_ne!(
4565 one, twice,
4566 "distinct rule indices must not hash the same as the same index twice"
4567 );
4568
4569 // And the once-ness the name is about, which the inequality above does not reach: it
4570 // holds whether the bytes were folded once or twice, so on its own this test survived
4571 // `distinct.dedup()` being deleted.
4572 //
4573 // **Repetition is the only lever that can observe how many times a component was
4574 // folded, and that is forced by the encoding rather than chosen.** The two halves cannot
4575 // be compared separately — a hash has no halves — so seeing the component fold's
4576 // multiplicity means holding the rule fold still while it moves, and the rule fold
4577 // encodes the rule count. Listing a rule a second time is the one edit that changes
4578 // nothing there, because rules deduplicate too. So: a component named three times for
4579 // two rules is folded exactly as it is when it is named twice.
4580 let listed_again = hash_ruleset(
4581 &sandbox,
4582 &[
4583 &fixture.component_at("a.wasm", 0),
4584 &fixture.component_at("a.wasm", 1),
4585 &fixture.component_at("a.wasm", 0),
4586 ],
4587 );
4588 assert_eq!(
4589 one, listed_again,
4590 "a component's bytes must reach the fold once however many times it is listed"
4591 );
4592 }
4593
4594 /// A rule index means nothing on its own, so the fold has to say which component it is in.
4595 ///
4596 /// The half `two_rules_from_one_component_fold_its_bytes_once` cannot reach. Both rulesets
4597 /// below hold the same two components and the same two indices — the deal is swapped — so
4598 /// the component fold is identical between them and a rule fold recording only the index
4599 /// would sort to the same pair. They run different code: one asks `a` for its second rule
4600 /// and `b` for its first, the other the reverse.
4601 #[test]
4602 fn a_rule_is_folded_against_the_component_it_runs_in() {
4603 let fixture = Fixture::new(
4604 "component-rule-pairing",
4605 &[("a.wasm", "\u{0}asm-a"), ("b.wasm", "\u{0}asm-b")],
4606 );
4607 let sandbox = fixture.empty_sandbox();
4608
4609 let dealt = hash_ruleset(
4610 &sandbox,
4611 &[
4612 &fixture.component_at("a.wasm", 0),
4613 &fixture.component_at("b.wasm", 1),
4614 ],
4615 );
4616 let swapped = hash_ruleset(
4617 &sandbox,
4618 &[
4619 &fixture.component_at("a.wasm", 1),
4620 &fixture.component_at("b.wasm", 0),
4621 ],
4622 );
4623
4624 assert_ne!(
4625 hex(&dealt),
4626 hex(&swapped),
4627 "which component a rule index belongs to is part of the ruleset"
4628 );
4629 }
4630
4631 /// And the options a rule was configured with, which decide what a factory rule *is*.
4632 ///
4633 /// `hash_config` folds a JSON config's options too, through `resolved`. That is not this
4634 /// claim: `resolved` is empty for a TypeScript config, and the day that path can name a
4635 /// component the options would reach no key at all. The code a component runs includes what
4636 /// it was configured to be, so it is folded where the code is.
4637 #[test]
4638 fn the_ruleset_hash_covers_the_options_a_component_was_configured_with() {
4639 let fixture = Fixture::new("component-options-hash", &[("a.wasm", "\u{0}asm-a")]);
4640 let sandbox = fixture.empty_sandbox();
4641
4642 let bare = fixture.component("a.wasm");
4643 let mut configured = fixture.component("a.wasm");
4644 configured.options = r#"{"limit":1}"#.to_owned();
4645
4646 assert_ne!(
4647 hex(&hash_ruleset(&sandbox, &[&bare])),
4648 hex(&hash_ruleset(&sandbox, &[&configured])),
4649 "a component configured differently is a different ruleset"
4650 );
4651 }
4652
4653 #[test]
4654 fn two_components_cannot_run_together_into_one() {
4655 // The reason a component's bytes are length-prefixed, and now the only thing that says
4656 // so — the length is the whole of the delimiting.
4657 //
4658 // A module's source is text and its separator is a NUL. A component is arbitrary
4659 // binary, so there is no byte available to separate one from the next: whichever were
4660 // chosen could appear inside a component. Without the length, these two rulesets are
4661 // genuinely different and fold to the identical byte sequence, under an identical
4662 // component count:
4663 //
4664 // A: 'A' 'A' | 'B' 'B' 'C' 'C' a = "AA", b = "BBCC"
4665 // B: 'A' 'A' 'B' 'B' | 'C' 'C' a = "AABB", b = "CC"
4666 //
4667 // **The data used to carry a `\x01` and stopped discriminating when it was no longer
4668 // needed.** The bytes were built around the old present/absent marker acting as the
4669 // delimiter, so removing the marker made the two rows genuinely different sequences and
4670 // this test passed with `length_prefixed` deleted. Concatenation is the property; the
4671 // data has to be a real collision under it.
4672 //
4673 // **Re-derived once more when `hash_ruleset` split into a component fold and a rule
4674 // fold**, and it survived unchanged — which is a fact about the encoding that was
4675 // chosen and is not a reason to skip the check. The components are ordered by their
4676 // *bytes* now rather than by their paths, and `"AA" < "BBCC"` exactly as
4677 // `a.wasm < b.wasm` did, so both rows still fold to `AABBCC`. Ordering them by a digest
4678 // instead would have made each row's order a coin flip and this collision a one-in-four
4679 // accident. What the rule fold contributes is identical between the rows — both are two
4680 // rules, at index 0, with `null` options, in components 0 and 1 — so the length prefix
4681 // is still the only thing telling the rows apart. Verified by deleting it and watching
4682 // this test fail, which is the only form the check has.
4683 //
4684 // Two different rulesets sharing a cache key is the one failure `docs/architecture.md`
4685 // §8.1 exists to prevent, so it is asserted here rather than left to the fact that
4686 // nothing writes a `.wasm` by hand.
4687 let fixture = Fixture::new("component-run-together", &[("a.wasm", ""), ("b.wasm", "")]);
4688 let sandbox = fixture.empty_sandbox();
4689
4690 fixture.write_all(&[("a.wasm", "AA"), ("b.wasm", "BBCC")]);
4691 let split_early = hash_ruleset(
4692 &sandbox,
4693 &[&fixture.component("a.wasm"), &fixture.component("b.wasm")],
4694 );
4695
4696 fixture.write_all(&[("a.wasm", "AABB"), ("b.wasm", "CC")]);
4697 let split_late = hash_ruleset(
4698 &sandbox,
4699 &[&fixture.component("a.wasm"), &fixture.component("b.wasm")],
4700 );
4701
4702 assert_ne!(
4703 hex(&split_early),
4704 hex(&split_late),
4705 "two components must not be able to concatenate into one byte sequence — the \
4706 length is the only thing delimiting them, because any separator byte can appear \
4707 inside a component"
4708 );
4709 }
4710
4711 #[test]
4712 fn the_ruleset_hash_ignores_where_a_component_sits() {
4713 // A resolved component path is absolute. Hashing it would mean a cache thrown away by
4714 // moving a checkout, for a change to nothing a rule can observe — and which component
4715 // a rule *names* is already `config_hash`'s, through the specifier.
4716 let fixture = Fixture::new(
4717 "component-path",
4718 &[("a.wasm", "\u{0}asm-same"), ("nested/b.wasm", "")],
4719 );
4720 fixture.write_all(&[("nested/b.wasm", "\u{0}asm-same")]);
4721 let sandbox = fixture.empty_sandbox();
4722
4723 assert_eq!(
4724 hex(&hash_ruleset(&sandbox, &[&fixture.component("a.wasm")])),
4725 hex(&hash_ruleset(
4726 &sandbox,
4727 &[&fixture.component("nested/b.wasm")]
4728 )),
4729 "the same component bytes are the same ruleset wherever they sit"
4730 );
4731 }
4732
4733 #[test]
4734 fn the_ruleset_hash_ignores_the_order_and_the_repetition_of_a_component() {
4735 // The "change nothing, assert the key does not move" half. `ruleset_hash` is about the
4736 // code a run is made of; which rules a config lists, in what order and how often, is
4737 // `hash_config`'s — where the order is deliberately *not* normalized. Sorting and
4738 // deduplicating here means a config edit that only reorders costs no recompute.
4739 let fixture = Fixture::new(
4740 "component-order",
4741 &[("one.wasm", "\u{0}asm-one"), ("two.wasm", "\u{0}asm-two")],
4742 );
4743 let sandbox = fixture.empty_sandbox();
4744 let one = fixture.component("one.wasm");
4745 let two = fixture.component("two.wasm");
4746
4747 let canonical = hex(&hash_ruleset(&sandbox, &[&one, &two]));
4748 assert_eq!(
4749 canonical,
4750 hex(&hash_ruleset(&sandbox, &[&two, &one])),
4751 "reordering two components is not a different ruleset"
4752 );
4753 assert_eq!(
4754 canonical,
4755 hex(&hash_ruleset(&sandbox, &[&one, &two, &one])),
4756 "naming one component twice is not a different ruleset"
4757 );
4758 }
4759
4760 /// One path can carry two byte sequences, and both have to reach the key — not just the
4761 /// count of them, but the bytes themselves.
4762 ///
4763 /// `component_bytes` reads once per `ResolvedRule` and nothing deduplicates `rules`, so a
4764 /// config naming one file twice — bare in one entry and with options in another — reads it
4765 /// twice. A rewrite between those reads produces a pair that carry one path and two
4766 /// different byte sequences, and both rules go on to execute the bytes they carry.
4767 ///
4768 /// **The mutant this data discriminates is a component fold that records *how many* distinct
4769 /// byte sequences there are but not *what* they are.** The rules fold names a component by
4770 /// its position in `distinct` and nothing about its code, so it cannot catch that mutant
4771 /// alone: two rulesets with the same positions, indices and options but different byte
4772 /// values would hash equal. The comparison below holds the rules fold fixed — both pairs
4773 /// sort to the same two positions, same index, same options — and varies only the bytes, so
4774 /// a fold that dropped the bytes makes the two equal. Comparing against a "collapsed" pair
4775 /// (`[before, before]`) does not isolate the fold, because the rules fold already differs
4776 /// there (one rule against two) and backstops whatever the component fold did.
4777 ///
4778 /// The window is microseconds and the trigger is exotic. It is asserted anyway because the
4779 /// claim it falsifies — the bytes hashed are the bytes that run — is the one the component
4780 /// half of `ruleset_hash` exists to make, and a claim with one shape that breaks it is not
4781 /// quite the claim.
4782 #[test]
4783 fn one_path_with_two_byte_sequences_reaches_the_ruleset_hash_as_both() {
4784 let fixture = Fixture::new("component-torn-read", &[("r.wasm", "\u{0}asm-before")]);
4785 let sandbox = fixture.empty_sandbox();
4786
4787 // The first reference's read.
4788 let before = fixture.component("r.wasm");
4789 // The file is rewritten, and the second reference reads what is there now. Both carry
4790 // the same path, because it is the same file.
4791 fixture.write_all(&[("r.wasm", "\u{0}asm-after")]);
4792 let after = fixture.component("r.wasm");
4793 assert_eq!(
4794 before.path, after.path,
4795 "the fixture is one file, read twice"
4796 );
4797 assert_ne!(
4798 before.bytes.as_slice(),
4799 after.bytes.as_slice(),
4800 "the rewrite is what makes this pair interesting"
4801 );
4802
4803 // A third read of the same file, rewritten again to a byte sequence that sorts to the
4804 // same position `after` does — both precede `before` (`after` < `again` < `before`) — so
4805 // the rules fold (positions, index, options) is identical to the first pair's. The only
4806 // thing that differs between the two rulesets is the byte value of the second component.
4807 fixture.write_all(&[("r.wasm", "\u{0}asm-again")]);
4808 let again = fixture.component("r.wasm");
4809 assert_eq!(after.path, again.path, "still one file, read a third time");
4810 assert_ne!(
4811 after.bytes.as_slice(),
4812 again.bytes.as_slice(),
4813 "the second rewrite is a third byte sequence, not a reread of the second"
4814 );
4815
4816 assert_ne!(
4817 hex(&hash_ruleset(&sandbox, &[&before, &after])),
4818 hex(&hash_ruleset(&sandbox, &[&before, &again])),
4819 "two rulesets whose rules fold agrees but whose second component's bytes differ \
4820 must not key equal — a component fold that hashed the count of distinct programs \
4821 but not the bytes made these equal"
4822 );
4823 }
4824
4825 #[test]
4826 fn the_ruleset_hash_still_covers_modules_when_a_component_is_present() {
4827 // The deviation this change makes from its own plan, asserted rather than described.
4828 // The plan said the component fold *replaces* the module walk; two built-ins are
4829 // components and every other rule in this tree is a module, so that would have taken
4830 // almost the whole ruleset out of the cache key.
4831 let files: &[(&str, &str)] = &[
4832 ("rule.ts", &rule("local/example")),
4833 ("lanekeep.config.ts", ""),
4834 ];
4835 let fixture = Fixture::new("component-and-module", files);
4836 fixture.write_all(&[("lanekeep.config.ts", &config_with("rules: [rule]"))]);
4837 fixture.write_all(&[("mine.wasm", "\u{0}asm")]);
4838
4839 let mine = fixture.component("mine.wasm");
4840 let root = RuleRoot::new(&fixture.dir).expect("canonicalizes");
4841 let hash_after_loading = |source: &str| {
4842 fixture.write_all(&[("rule.ts", source)]);
4843 let sandbox =
4844 sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
4845 evaluate_into(&sandbox, &root, &fixture.dir.join("lanekeep.config.ts"))
4846 .expect("evaluates");
4847 hash_ruleset(&sandbox, &[&mine])
4848 };
4849
4850 assert_ne!(
4851 hex(&hash_after_loading(&rule("local/example"))),
4852 hex(&hash_after_loading(&rule("local/renamed"))),
4853 "a module edit must still invalidate when a component is in the ruleset too"
4854 );
4855 }
4856
4857 #[test]
4858 fn the_config_hash_ignores_glob_order() {
4859 // Include and exclude are order-insensitive in effect, so reordering them must not
4860 // throw away a warm cache for a change that alters nothing.
4861 let make = |globs: &str, tag: &str| {
4862 Fixture::new(
4863 &format!("glob-order-{tag}"),
4864 &[
4865 ("rule.ts", &rule("local/example")),
4866 (
4867 "lanekeep.config.ts",
4868 &config_with(&format!("rules: [rule], include: {globs}")),
4869 ),
4870 ],
4871 )
4872 .load_config()
4873 .expect("loads")
4874 .config_hash
4875 };
4876
4877 assert_eq!(
4878 hex(&make("['a/**', 'b/**']", "sorted")),
4879 hex(&make("['b/**', 'a/**' ]", "reversed")),
4880 "reordering globs must not change the config hash"
4881 );
4882 }
4883
4884 #[test]
4885 fn the_config_hash_changes_with_severity() {
4886 let make = |extra: &str, tag: &str| {
4887 Fixture::new(
4888 &format!("severity-hash-{tag}"),
4889 &[
4890 ("rule.ts", &rule("local/example")),
4891 (
4892 "lanekeep.config.ts",
4893 &config_with(&format!("rules: [rule]{extra}")),
4894 ),
4895 ],
4896 )
4897 .load_config()
4898 .expect("loads")
4899 .config_hash
4900 };
4901
4902 assert_ne!(
4903 hex(&make("", "none")),
4904 hex(&make(", severity: { 'local/example': 'warn' }", "warn")),
4905 "changing a severity must invalidate"
4906 );
4907 }
4908
4909 #[test]
4910 fn the_config_hash_changes_with_a_timeout() {
4911 let make = |extra: &str, tag: &str| {
4912 Fixture::new(
4913 &format!("timeout-hash-{tag}"),
4914 &[
4915 ("rule.ts", &rule("local/example")),
4916 (
4917 "lanekeep.config.ts",
4918 &config_with(&format!("rules: [rule]{extra}")),
4919 ),
4920 ],
4921 )
4922 .load_config()
4923 .expect("loads")
4924 .config_hash
4925 };
4926
4927 assert_ne!(
4928 hex(&make("", "d")),
4929 hex(&make(", timeouts: { rule: 5000 }", "t"))
4930 );
4931 }
4932
4933 #[test]
4934 fn the_config_hash_changes_with_a_suppression_policy() {
4935 // Every key of the block is a `config_hash` input, asserted separately — a fold that
4936 // dropped one arm would leave a policy edit that invalidates nothing, which is the
4937 // exact "reaches no hash" shape `AGENTS.md` records. `maxExpiryDays` gets a value
4938 // change as well as a presence change, because Some(30) and Some(31) must not hash
4939 // alike any more than None and Some must.
4940 let make = |extra: &str, tag: &str| {
4941 Fixture::new(
4942 &format!("suppression-hash-{tag}"),
4943 &[
4944 ("rule.ts", &rule("local/example")),
4945 (
4946 "lanekeep.config.ts",
4947 &config_with(&format!("rules: [rule]{extra}")),
4948 ),
4949 ],
4950 )
4951 .load_config()
4952 .expect("loads")
4953 .config_hash
4954 };
4955
4956 let none = hex(&make("", "none"));
4957 assert_ne!(
4958 none,
4959 hex(&make(", suppressions: { requireExpiry: true }", "require")),
4960 "turning on requireExpiry must invalidate"
4961 );
4962 assert_ne!(
4963 none,
4964 hex(&make(", suppressions: { maxExpiryDays: 30 }", "days")),
4965 "adding maxExpiryDays must invalidate"
4966 );
4967 assert_ne!(
4968 none,
4969 hex(&make(", suppressions: { forbidFileScope: true }", "file")),
4970 "turning on forbidFileScope must invalidate"
4971 );
4972 assert_ne!(
4973 hex(&make(", suppressions: { maxExpiryDays: 30 }", "days30")),
4974 hex(&make(", suppressions: { maxExpiryDays: 31 }", "days31")),
4975 "changing maxExpiryDays must invalidate"
4976 );
4977 }
4978
4979 #[test]
4980 fn the_suppression_policy_is_read_from_a_typescript_config() {
4981 let fixture = Fixture::new(
4982 "suppression-policy-ts",
4983 &[
4984 ("rule.ts", &rule("local/example")),
4985 (
4986 "lanekeep.config.ts",
4987 &config_with(
4988 "rules: [rule], suppressions: { requireExpiry: true, \
4989 maxExpiryDays: 30, forbidFileScope: true }",
4990 ),
4991 ),
4992 ],
4993 );
4994 let config = fixture.load_config().expect("loads");
4995 assert_eq!(
4996 config.suppressions,
4997 SuppressionPolicy {
4998 require_expiry: true,
4999 max_expiry_days: Some(30),
5000 forbid_file_scope: true,
5001 }
5002 );
5003 }
5004
5005 #[test]
5006 fn a_zero_max_expiry_days_is_refused() {
5007 // `build` is the one place both formats construct a `Config`, so one test proves the
5008 // validation for both — unlike the hashing properties, which are asserted in pairs.
5009 let fixture = Fixture::new(
5010 "suppression-zero",
5011 &[
5012 ("rule.ts", &rule("local/example")),
5013 (
5014 "lanekeep.config.ts",
5015 &config_with("rules: [rule], suppressions: { maxExpiryDays: 0 }"),
5016 ),
5017 ],
5018 );
5019 let error = fixture
5020 .load_config()
5021 .expect_err("a zero horizon is refused");
5022 assert!(format!("{error}").contains("maxExpiryDays"), "{error}");
5023 }
5024
5025 /// A JSON rule's options are a cache-key input, and were reaching neither hash.
5026 ///
5027 /// The same config in the same directory, one option value edited: before this was
5028 /// fixed both hashes came back byte-identical, so a warm run kept answering the
5029 /// previous configuration. `docs/architecture.md` §8.1 lists options under
5030 /// `config_hash`, and the JSON path is where they are known as data.
5031 ///
5032 /// The fixture is rewritten in place rather than built twice under different names.
5033 /// Two directories would move `ruleset_hash` on their own — it hashes each module's
5034 /// path alongside its source — which is a difference that looks like the assertion
5035 /// passing and is not.
5036 #[test]
5037 fn the_config_hash_changes_with_a_json_rule_option() {
5038 let config =
5039 |options: &str| format!(r#"{{"rules": [{{"rule": "./rule", "options": {options}}}]}}"#);
5040 let fixture = Fixture::new(
5041 "json-option-hash",
5042 &[
5043 ("rule.ts", &factory_rule("local/example")),
5044 ("lanekeep.json", &config(r#"{"limit": 1}"#)),
5045 ],
5046 );
5047
5048 let before = fixture.load_json().expect("loads");
5049 fixture.write_all(&[("lanekeep.json", &config(r#"{"limit": 2}"#))]);
5050 let after = fixture.load_json().expect("loads");
5051
5052 assert_ne!(
5053 hex(&before.config_hash),
5054 hex(&after.config_hash),
5055 "editing a rule option must invalidate"
5056 );
5057 assert_eq!(
5058 hex(&before.ruleset_hash),
5059 hex(&after.ruleset_hash),
5060 "no module changed, so the ruleset hash must not move — which is exactly why \
5061 the config hash has to"
5062 );
5063 }
5064
5065 // --- hashing, the JSON path -------------------------------------------------------
5066 //
5067 // Matched pairs of the six above. The two formats used to be one mechanism — a JSON
5068 // config was compiled into the module a TypeScript one is imported by — so asserting
5069 // these properties once covered both. It no longer does, and these are what replaced
5070 // that guarantee. A property that holds on one path and not the other is drift, and
5071 // drift in a cache key is silent: the run completes and answers with yesterday's
5072 // configuration.
5073
5074 #[test]
5075 fn the_ruleset_hash_covers_an_imported_helper_for_json() {
5076 let fixture = Fixture::new(
5077 "json-helper-hash",
5078 &[
5079 ("helper.ts", "export const QUERY = '(identifier) @id';\n"),
5080 (
5081 "rule.ts",
5082 "import { defineRule } from 'lanekeep';\n\
5083 import { QUERY } from './helper';\n\
5084 export default defineRule({\n\
5085 id: 'local/example',\n\
5086 query: QUERY,\n\
5087 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
5088 check() {},\n\
5089 });\n",
5090 ),
5091 ("lanekeep.json", r#"{"rules": ["./rule"]}"#),
5092 ],
5093 );
5094
5095 let before = fixture.load_json().expect("loads").ruleset_hash;
5096 fixture.write_all(&[("helper.ts", "export const QUERY = '(string) @s';\n")]);
5097 let after = fixture.load_json().expect("loads").ruleset_hash;
5098
5099 assert_ne!(
5100 hex(&before),
5101 hex(&after),
5102 "changing an imported helper must invalidate the ruleset hash"
5103 );
5104 }
5105
5106 #[test]
5107 fn the_ruleset_hash_is_stable_when_nothing_changed_for_json() {
5108 let fixture = Fixture::new(
5109 "json-stable-hash",
5110 &[
5111 ("rule.ts", &rule("local/example")),
5112 ("lanekeep.json", r#"{"rules": ["./rule"]}"#),
5113 ],
5114 );
5115 let first = fixture.load_json().expect("loads").ruleset_hash;
5116 let second = fixture.load_json().expect("loads").ruleset_hash;
5117 assert_eq!(hex(&first), hex(&second));
5118 }
5119
5120 #[test]
5121 fn the_config_hash_ignores_glob_order_for_json() {
5122 let make = |globs: &str, tag: &str| {
5123 Fixture::new(
5124 &format!("json-glob-order-{tag}"),
5125 &[
5126 ("rule.ts", &rule("local/example")),
5127 (
5128 "lanekeep.json",
5129 &format!(r#"{{"rules": ["./rule"], "include": {globs}}}"#),
5130 ),
5131 ],
5132 )
5133 .load_json()
5134 .expect("loads")
5135 .config_hash
5136 };
5137
5138 assert_eq!(
5139 hex(&make(r#"["a/**", "b/**"]"#, "sorted")),
5140 hex(&make(r#"["b/**", "a/**"]"#, "reversed")),
5141 "reordering globs must not change the config hash"
5142 );
5143 }
5144
5145 /// The same property one level down, for the values only this path can see.
5146 ///
5147 /// `serde_json::Map` is a `BTreeMap` in this build, so the options blob serializes in
5148 /// key order whatever order it was written in. That is a property of a dependency's
5149 /// feature set rather than of anything written here — `preserve_order` would reverse it
5150 /// silently, and the only symptom would be a cache that stops hitting.
5151 #[test]
5152 fn the_config_hash_ignores_option_key_order() {
5153 let make = |options: &str, tag: &str| {
5154 Fixture::new(
5155 &format!("json-option-order-{tag}"),
5156 &[
5157 ("rule.ts", &factory_rule("local/example")),
5158 (
5159 "lanekeep.json",
5160 &format!(r#"{{"rules": [{{"rule": "./rule", "options": {options}}}]}}"#),
5161 ),
5162 ],
5163 )
5164 .load_json()
5165 .expect("loads")
5166 .config_hash
5167 };
5168
5169 assert_eq!(
5170 hex(&make(r#"{"a": 1, "b": 2}"#, "sorted")),
5171 hex(&make(r#"{"b": 2, "a": 1}"#, "reversed")),
5172 "reordering option keys must not change the config hash"
5173 );
5174 }
5175
5176 #[test]
5177 fn the_config_hash_changes_with_severity_for_json() {
5178 let make = |severity: &str, tag: &str| {
5179 Fixture::new(
5180 &format!("json-severity-hash-{tag}"),
5181 &[
5182 ("rule.ts", &rule("local/example")),
5183 (
5184 "lanekeep.json",
5185 &format!(r#"{{"rules": ["./rule"], "severity": {severity}}}"#),
5186 ),
5187 ],
5188 )
5189 .load_json()
5190 .expect("loads")
5191 .config_hash
5192 };
5193
5194 assert_ne!(
5195 hex(&make("{}", "none")),
5196 hex(&make(r#"{"local/example": "warn"}"#, "warn")),
5197 "changing a severity must invalidate"
5198 );
5199 }
5200
5201 #[test]
5202 fn the_config_hash_changes_with_a_timeout_for_json() {
5203 let make = |timeouts: &str, tag: &str| {
5204 Fixture::new(
5205 &format!("json-timeout-hash-{tag}"),
5206 &[
5207 ("rule.ts", &rule("local/example")),
5208 (
5209 "lanekeep.json",
5210 &format!(r#"{{"rules": ["./rule"], "timeouts": {timeouts}}}"#),
5211 ),
5212 ],
5213 )
5214 .load_json()
5215 .expect("loads")
5216 .config_hash
5217 };
5218
5219 assert_ne!(hex(&make("{}", "d")), hex(&make(r#"{"rule": 5000}"#, "t")));
5220 }
5221
5222 #[test]
5223 fn the_config_hash_changes_with_a_suppression_policy_for_json() {
5224 // The JSON half of the matched pair, and every key gets a turn: a fold that dropped
5225 // one arm would leave that key's edits invalidating nothing, on the path that knows
5226 // the policy as data. The fixture is rewritten in place so `ruleset_hash` is provably
5227 // stable — the two hashes must disagree because the policy changed, not because a
5228 // module moved.
5229 let config = |suppressions: &str| {
5230 format!(r#"{{"rules": ["./rule"], "suppressions": {suppressions}}}"#)
5231 };
5232
5233 for (label, edited) in [
5234 ("requireExpiry", r#"{"requireExpiry": true}"#),
5235 ("maxExpiryDays", r#"{"maxExpiryDays": 30}"#),
5236 ("forbidFileScope", r#"{"forbidFileScope": true}"#),
5237 ] {
5238 let fixture = Fixture::new(
5239 &format!("json-suppression-hash-{label}"),
5240 &[
5241 ("rule.ts", &rule("local/example")),
5242 ("lanekeep.json", &config("{}")),
5243 ],
5244 );
5245
5246 let before = fixture.load_json().expect("loads");
5247 fixture.write_all(&[("lanekeep.json", &config(edited))]);
5248 let after = fixture.load_json().expect("loads");
5249
5250 assert_ne!(
5251 hex(&before.config_hash),
5252 hex(&after.config_hash),
5253 "editing `{label}` must invalidate"
5254 );
5255 assert_eq!(
5256 hex(&before.ruleset_hash),
5257 hex(&after.ruleset_hash),
5258 "no module changed, so the ruleset hash must not move — which is exactly \
5259 why the config hash has to"
5260 );
5261 }
5262
5263 // A value change too, not just presence: Some(30) and Some(31) must not hash alike.
5264 let fixture = Fixture::new(
5265 "json-suppression-hash-days-value",
5266 &[
5267 ("rule.ts", &rule("local/example")),
5268 ("lanekeep.json", &config(r#"{"maxExpiryDays": 30}"#)),
5269 ],
5270 );
5271 let before = fixture.load_json().expect("loads");
5272 fixture.write_all(&[("lanekeep.json", &config(r#"{"maxExpiryDays": 31}"#))]);
5273 let after = fixture.load_json().expect("loads");
5274 assert_ne!(
5275 hex(&before.config_hash),
5276 hex(&after.config_hash),
5277 "changing maxExpiryDays must invalidate"
5278 );
5279 assert_eq!(hex(&before.ruleset_hash), hex(&after.ruleset_hash));
5280 }
5281
5282 #[test]
5283 fn the_suppression_policy_is_read_from_a_json_config() {
5284 let fixture = Fixture::new(
5285 "suppression-policy-json",
5286 &[
5287 ("rule.ts", &rule("local/example")),
5288 (
5289 "lanekeep.json",
5290 r#"{"rules": ["./rule"], "suppressions": {"requireExpiry": true, "maxExpiryDays": 30, "forbidFileScope": true}}"#,
5291 ),
5292 ],
5293 );
5294 let config = fixture.load_json().expect("loads");
5295 assert_eq!(
5296 config.suppressions,
5297 SuppressionPolicy {
5298 require_expiry: true,
5299 max_expiry_days: Some(30),
5300 forbid_file_scope: true,
5301 }
5302 );
5303 }
5304
5305 /// `"x"` and `{ "rule": "x" }` are different configurations and must not hash alike.
5306 ///
5307 /// One uses a rule as it comes; the other configures it, with `null`. A rule factory
5308 /// reading `options?.strict` behaves differently under the two, so a key that could not
5309 /// tell them apart would serve one's results for the other. The fixture's default export
5310 /// is deliberately usable both ways, so the *only* difference between the two runs is
5311 /// the form the config wrote.
5312 #[test]
5313 fn the_config_hash_tells_a_bare_rule_from_a_configured_one() {
5314 let module = "import { defineRule } from 'lanekeep';\n\
5315 const built = defineRule({\n\
5316 id: 'local/example',\n\
5317 query: '(identifier) @id',\n\
5318 card: { message: 'no', remediation: 'do this', examples: { bad: 'a', good: 'b' } },\n\
5319 check(ctx, m) { ctx.report(m.id); },\n\
5320 });\n\
5321 export default Object.assign((options) => built, built);\n";
5322
5323 let make = |rules: &str, tag: &str| {
5324 Fixture::new(
5325 &format!("json-rule-form-{tag}"),
5326 &[
5327 ("rule.ts", module),
5328 ("lanekeep.json", &format!(r#"{{"rules": [{rules}]}}"#)),
5329 ],
5330 )
5331 .load_json()
5332 .expect("loads")
5333 .config_hash
5334 };
5335
5336 assert_ne!(
5337 hex(&make(r#""./rule""#, "bare")),
5338 hex(&make(r#"{"rule": "./rule"}"#, "configured")),
5339 "a rule used as it comes and a rule configured with `null` are not the same run"
5340 );
5341 }
5342
5343 /// `config_hash` says *which* rule was configured, not merely that something was.
5344 ///
5345 /// Today `ruleset_hash` would notice this on its own, because two references load two
5346 /// different modules. It is pinned here anyway, because Task 15 turns that hash into a
5347 /// path-sorted fold over component bytes, and a property held only by the hash that is
5348 /// about to be rewritten is a property about to be lost quietly. The two configs below
5349 /// differ in nothing `config_hash` sees except the specifier.
5350 #[test]
5351 fn the_config_hash_tells_apart_two_rules_with_the_same_options() {
5352 let make = |name: &str| {
5353 Fixture::new(
5354 &format!("json-which-rule-{name}"),
5355 &[
5356 (
5357 &format!("{name}.ts"),
5358 &factory_rule(&format!("local/{name}")),
5359 ),
5360 (
5361 "lanekeep.json",
5362 &format!(r#"{{"rules": [{{"rule": "./{name}", "options": {{"x": 1}}}}]}}"#),
5363 ),
5364 ],
5365 )
5366 .load_json()
5367 .expect("loads")
5368 .config_hash
5369 };
5370
5371 assert_ne!(
5372 hex(&make("a")),
5373 hex(&make("b")),
5374 "the same options on a different rule is a different configuration"
5375 );
5376 }
5377
5378 /// The two formats saying the same thing produce the same configuration.
5379 ///
5380 /// This is the assertion the shared entry module used to make unnecessary. It cannot
5381 /// compare the hashes — a TypeScript config is itself a module in the rule graph, so
5382 /// `ruleset_hash` legitimately differs — but everything a run actually does is decided
5383 /// by the fields below, and those must agree exactly.
5384 #[test]
5385 fn the_two_formats_load_the_same_configuration() {
5386 let typescript = Fixture::new(
5387 "parity-ts",
5388 &[
5389 ("rule.ts", &rule("local/example")),
5390 (
5391 "lanekeep.config.ts",
5392 &config_with(
5393 "rules: [rule], include: ['src/**/*.ts'], exclude: ['**/*.test.ts'], \
5394 severity: { 'local/example': 'warn' }, \
5395 timeouts: { rule: 2000, global: 30000 }",
5396 ),
5397 ),
5398 ],
5399 )
5400 .load_config()
5401 .expect("the TypeScript config loads");
5402
5403 let json = Fixture::new(
5404 "parity-json",
5405 &[
5406 ("rule.ts", &rule("local/example")),
5407 (
5408 "lanekeep.json",
5409 r#"{"rules": ["./rule"], "include": ["src/**/*.ts"],
5410 "exclude": ["**/*.test.ts"], "severity": {"local/example": "warn"},
5411 "timeouts": {"rule": 2000, "global": 30000}}"#,
5412 ),
5413 ],
5414 )
5415 .load_json()
5416 .expect("the JSON config loads");
5417
5418 assert_eq!(typescript.include, json.include);
5419 assert_eq!(typescript.exclude, json.exclude);
5420 assert_eq!(typescript.limits, json.limits);
5421 assert_eq!(typescript.rules, json.rules);
5422 }
5423
5424 /// The un-coupling, as a property of the source rather than of a call graph.
5425 ///
5426 /// `src/json.rs` names neither the sandbox crate nor any type this crate's root imports
5427 /// from it. The crate as a whole still depends on it, and deliberately — see the note
5428 /// above `entry_source`.
5429 ///
5430 /// **Grepping for `lanekeep_js` alone is not enough, and the gap is the spelling a
5431 /// refactor would reach for first.** The `use lanekeep_js::{…}` below is at the crate
5432 /// root, and a `use` at the root is in scope for every descendant module, so this
5433 /// compiles inside `json.rs`, reaches the sandbox, and contains no `lanekeep_js` at all:
5434 ///
5435 /// ```ignore
5436 /// use crate::{ConfigError, Sandbox};
5437 /// fn probe(s: &Sandbox) -> bool { s.eval::<bool>("true").unwrap_or(false) }
5438 /// ```
5439 ///
5440 /// The forbidden names are therefore read out of that import line rather than listed
5441 /// here, so importing a fifth type from that crate extends this check instead of quietly
5442 /// outgrowing it.
5443 ///
5444 /// What it does not cover, stated rather than left to be discovered: reaching the sandbox
5445 /// without naming a type, through some crate-level function that takes one. No such
5446 /// function exists for `json.rs` to call today. This is a source check, not a proof.
5447 #[test]
5448 fn the_json_path_names_nothing_from_the_sandbox_crate() {
5449 let root = include_str!("lib.rs");
5450 let import = root
5451 .lines()
5452 .find(|line| line.starts_with("use lanekeep_js::{"))
5453 .expect("the crate root imports the sandbox crate in one braced list");
5454
5455 let mut forbidden: Vec<&str> = import
5456 .trim_start_matches("use lanekeep_js::{")
5457 .trim_end_matches("};")
5458 .split(',')
5459 .map(str::trim)
5460 .filter(|name| !name.is_empty())
5461 .collect();
5462 assert!(
5463 forbidden.len() > 1
5464 && forbidden
5465 .iter()
5466 .all(|n| n.chars().all(char::is_alphanumeric)),
5467 "the import list should have parsed into type names: {forbidden:?}"
5468 );
5469 forbidden.push("lanekeep_js");
5470
5471 let source = include_str!("json.rs");
5472 for name in forbidden {
5473 assert!(
5474 !source.contains(name),
5475 "src/json.rs must resolve a JSON config without the sandbox, and it names \
5476 `{name}`"
5477 );
5478 }
5479 }
5480
5481 #[test]
5482 fn hex_renders_a_full_hash() {
5483 assert_eq!(hex(&[0u8; 32]).len(), 64);
5484 assert_eq!(hex(&[0xab; 32]), "ab".repeat(32));
5485 }
5486}