Skip to main content

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}