Skip to main content

lanekeep_config/
lib.rs

1//! Configuration loading and canonicalized hashing for lanekeep.
2//!
3//! Loads `lanekeep.config.ts`, resolves the rule graph, and derives the hashes feeding the
4//! cache key.
5//!
6//! # How the config is read
7//!
8//! The config is a TypeScript module, so reading it means running it. A synthetic entry
9//! module imports the config's default export into a global, and a second evaluation hands
10//! 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
21use std::collections::{BTreeMap, BTreeSet};
22use std::path::{Path, PathBuf};
23use std::time::Duration;
24
25use lanekeep_core::{Examples, Gates, Namespace, RuleCard, RuleId, Severity};
26use lanekeep_js::{Limits, RuleRoot, RunClock, Sandbox};
27use serde::Deserialize;
28use thiserror::Error;
29
30/// A 32-byte content hash.
31pub type Hash = [u8; 32];
32
33/// Render a hash the way it appears in diagnostics and cache paths.
34#[must_use]
35pub fn hex(hash: &Hash) -> String {
36    use std::fmt::Write as _;
37    hash.iter()
38        .fold(String::with_capacity(64), |mut out, byte| {
39            let _ = write!(out, "{byte:02x}");
40            out
41        })
42}
43
44/// A rule as the config declares it.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct RuleSpec {
47    /// Zero-based position in the config's `rules` array.
48    ///
49    /// This is how the engine reaches the handler: the rule object lives in the loaded
50    /// config, and indexing into it is what lets a function cross the boundary without
51    /// ever being extracted as a value.
52    pub index: usize,
53    /// Namespaced identifier.
54    pub id: RuleId,
55    /// Which languages' grammars the query compiles against, and which files the rule runs on.
56    ///
57    /// A rule runs on a file only when the file's own language is one of these, and it is
58    /// then parsed with *that* grammar. Running every rule against every file with a single
59    /// declared grammar is what used to turn a `.tsx` file into a tree of `ERROR` nodes —
60    /// silently, since a query simply matches nothing inside one.
61    pub languages: Vec<String>,
62    /// Severity as the rule declares it, before config overrides.
63    pub severity: Severity,
64    /// The rule card.
65    pub card: RuleCard,
66    /// The tree-sitter query gating the handler.
67    pub query: String,
68    /// Pre-parse gates.
69    pub gates: Gates,
70    /// A per-invocation budget overriding the default.
71    pub timeout: Option<Duration>,
72    /// Whether the rule has a `reduce` phase.
73    pub has_reduce: bool,
74}
75
76/// A loaded, validated configuration.
77#[expect(
78    clippy::struct_field_names,
79    reason = "`ruleset_hash` and `config_hash` are the names docs/architecture.md §8.1 \
80              gives these two cache-key inputs. Renaming them to satisfy the lint would \
81              make the code and the specification disagree about the same thing, which \
82              costs more than the repetition saves."
83)]
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct Config {
86    /// Globs selecting files to check.
87    pub include: Vec<String>,
88    /// Globs excluding files from the selection.
89    pub exclude: Vec<String>,
90    /// Rules, in the order the config listed them.
91    pub rules: Vec<RuleSpec>,
92    /// Budgets, with defaults filled in.
93    pub limits: Limits,
94    /// Hash of every module in the rule import graph.
95    pub ruleset_hash: Hash,
96    /// Hash of the configuration values.
97    pub config_hash: Hash,
98}
99
100/// Why a configuration could not be loaded.
101#[derive(Debug, Clone, PartialEq, Eq, Error)]
102pub enum ConfigError {
103    /// The config file does not exist or sits outside the project.
104    #[error("cannot load config `{path}`: {detail}")]
105    Unreadable {
106        /// The path as given.
107        path: String,
108        /// What went wrong.
109        detail: String,
110    },
111
112    /// The config module threw, failed to parse, or breached a limit.
113    #[error("config `{path}` failed to evaluate\n{detail}")]
114    Evaluation {
115        /// The path as given.
116        path: String,
117        /// The sandbox's account of it.
118        detail: String,
119    },
120
121    /// The config evaluated but is not shaped like a config.
122    #[error("config `{path}` is not valid: {detail}")]
123    Shape {
124        /// The path as given.
125        path: String,
126        /// What is wrong.
127        detail: String,
128    },
129
130    /// A rule in the config is not usable.
131    #[error("rule {position} in `{path}` is not valid: {detail}")]
132    Rule {
133        /// One-based position in the `rules` array, so an unnamed rule can still be found.
134        position: usize,
135        /// The path as given.
136        path: String,
137        /// What is wrong.
138        detail: String,
139    },
140}
141
142/// The shape `JSON.stringify` hands back. Deliberately permissive — every field is checked
143/// afterwards, so a malformed config produces a diagnostic naming the field rather than a
144/// deserialization error naming a line of JSON the user never wrote.
145#[derive(Debug, Deserialize)]
146struct RawConfig {
147    #[serde(default)]
148    include: Vec<String>,
149    #[serde(default)]
150    exclude: Vec<String>,
151    #[serde(default)]
152    namespaces: Vec<String>,
153    #[serde(default)]
154    severity: BTreeMap<String, String>,
155    #[serde(default)]
156    timeouts: RawTimeouts,
157    #[serde(default)]
158    rules: Vec<RawRule>,
159}
160
161#[derive(Debug, Default, Deserialize)]
162struct RawTimeouts {
163    rule: Option<u64>,
164    global: Option<u64>,
165}
166
167#[derive(Debug, Deserialize)]
168struct RawRule {
169    id: Option<String>,
170    language: Option<RawLanguages>,
171    severity: Option<String>,
172    card: Option<RawCard>,
173    query: Option<String>,
174    #[serde(default)]
175    gates: Gates,
176    timeout: Option<u64>,
177    has_check: bool,
178    has_reduce: bool,
179}
180
181/// `language: 'tsx'` and `language: ['typescript', 'tsx']` are both ordinary things to write.
182#[derive(Debug, Deserialize)]
183#[serde(untagged)]
184enum RawLanguages {
185    One(String),
186    Many(Vec<String>),
187}
188
189impl RawLanguages {
190    fn into_vec(self) -> Vec<String> {
191        match self {
192            Self::One(language) => vec![language],
193            Self::Many(languages) => languages,
194        }
195    }
196}
197
198#[derive(Debug, Deserialize)]
199struct RawCard {
200    message: Option<String>,
201    remediation: Option<String>,
202    examples: Option<RawExamples>,
203}
204
205#[derive(Debug, Deserialize)]
206struct RawExamples {
207    bad: Option<String>,
208    good: Option<String>,
209}
210
211/// The name of the synthetic entry module.
212///
213/// It has to sit inside the rules root, because the resolver treats a module's name as its
214/// path when resolving that module's imports.
215const ENTRY: &str = "__lanekeep_entry__.js";
216
217/// The script that reduces the config to JSON.
218///
219/// `has_check` and `has_reduce` are recorded here rather than inferred later, because
220/// `JSON.stringify` drops functions and there is no way to tell afterwards whether a rule
221/// had a handler or a typo.
222const EXTRACT: &str = r"
223    (() => {
224        const c = globalThis.__lanekeepConfig;
225        if (c === null || typeof c !== 'object') return JSON.stringify(null);
226        const rules = Array.isArray(c.rules) ? c.rules : [];
227        return JSON.stringify({
228            include: c.include ?? [],
229            namespaces: c.namespaces ?? [],
230            exclude: c.exclude ?? [],
231            severity: c.severity ?? {},
232            timeouts: c.timeouts ?? {},
233            rules: rules.map((r) => ({
234                id: r?.id ?? null,
235                language: r?.language ?? null,
236                severity: r?.severity ?? null,
237                card: r?.card ?? null,
238                query: r?.query ?? null,
239                gates: r?.gates ?? {},
240                timeout: r?.timeout ?? null,
241                has_check: typeof r?.check === 'function',
242                has_reduce: typeof r?.reduce === 'function',
243            })),
244        });
245    })()
246";
247
248/// Evaluate the config module into a sandbox, leaving the rule objects reachable.
249///
250/// Separate from [`load`] because every worker needs the ruleset present in its own engine
251/// — a rule's `check` is a function, and a function cannot be moved between runtimes. Each
252/// worker therefore evaluates the same modules rather than receiving extracted values.
253///
254/// # Errors
255///
256/// Returns [`ConfigError`] when the config sits outside the rules root or fails to
257/// evaluate.
258pub fn evaluate_into(
259    sandbox: &Sandbox,
260    root: &RuleRoot,
261    config_path: &Path,
262) -> Result<(), ConfigError> {
263    let display = config_path.display().to_string();
264    let specifier =
265        relative_specifier(root.path(), config_path).ok_or_else(|| ConfigError::Unreadable {
266            path: display.clone(),
267            detail: "the config file must sit inside the rules root".to_owned(),
268        })?;
269
270    let entry = root.path().join(ENTRY);
271    let source =
272        format!("import config from '{specifier}';\nglobalThis.__lanekeepConfig = config;\n");
273
274    sandbox
275        .eval_module(&entry.display().to_string(), &source)
276        .map_err(|e| ConfigError::Evaluation {
277            path: display,
278            detail: e.to_string(),
279        })
280}
281
282/// Load and validate a configuration.
283///
284/// # Errors
285///
286/// Returns [`ConfigError`] when the file cannot be read, the module fails to evaluate, or
287/// the result is not shaped like a config.
288pub fn load(sandbox: &Sandbox, root: &RuleRoot, config_path: &Path) -> Result<Config, ConfigError> {
289    let display = config_path.display().to_string();
290
291    let specifier =
292        relative_specifier(root.path(), config_path).ok_or_else(|| ConfigError::Unreadable {
293            path: display.clone(),
294            detail: "the config file must sit inside the rules root".to_owned(),
295        })?;
296
297    let entry = root.path().join(ENTRY);
298    let source =
299        format!("import config from '{specifier}';\nglobalThis.__lanekeepConfig = config;\n");
300    sandbox
301        .eval_module(&entry.display().to_string(), &source)
302        .map_err(|e| ConfigError::Evaluation {
303            path: display.clone(),
304            detail: e.to_string(),
305        })?;
306
307    let json: String = sandbox.eval(EXTRACT).map_err(|e| ConfigError::Evaluation {
308        path: display.clone(),
309        detail: e.to_string(),
310    })?;
311
312    let raw: Option<RawConfig> = serde_json::from_str(&json).map_err(|e| ConfigError::Shape {
313        path: display.clone(),
314        detail: e.to_string(),
315    })?;
316    let raw = raw.ok_or_else(|| ConfigError::Shape {
317        path: display.clone(),
318        detail: "the default export is not an object — did you forget `export default`?".to_owned(),
319    })?;
320
321    build(sandbox, raw, &display)
322}
323
324fn build(sandbox: &Sandbox, raw: RawConfig, display: &str) -> Result<Config, ConfigError> {
325    let overrides = parse_severity_overrides(&raw.severity, display)?;
326
327    // Namespaces this project claims, beyond the two lanekeep defines. Validated for shape
328    // here so a malformed one is reported against `namespaces` rather than against whichever
329    // rule happened to use it first.
330    let mut declared = BTreeSet::new();
331    for namespace in &raw.namespaces {
332        RuleId::namespace_from_str(namespace).map_err(|e| ConfigError::Shape {
333            path: display.to_owned(),
334            detail: format!("`namespaces` contains an invalid entry: {e}"),
335        })?;
336        if namespace == Namespace::LANEKEEP {
337            return Err(ConfigError::Shape {
338                path: display.to_owned(),
339                detail: "`lanekeep` is reserved for rules shipped with lanekeep — a rule's \
340                         origin should be readable from its ID"
341                    .to_owned(),
342            });
343        }
344        declared.insert(namespace.clone());
345    }
346
347    let mut rules = Vec::with_capacity(raw.rules.len());
348    for (index, rule) in raw.rules.into_iter().enumerate() {
349        rules.push(build_rule(rule, index + 1, display, &overrides, &declared)?);
350    }
351
352    let mut limits = Limits::default();
353    if let Some(ms) = raw.timeouts.rule {
354        limits = limits.with_rule_timeout(Duration::from_millis(ms));
355    }
356    if let Some(ms) = raw.timeouts.global {
357        limits = limits.with_global_timeout(Duration::from_millis(ms));
358    }
359
360    let ruleset_hash = hash_ruleset(sandbox);
361    let config_hash = hash_config(&raw.include, &raw.exclude, &overrides, &limits);
362
363    Ok(Config {
364        include: raw.include,
365        exclude: raw.exclude,
366        rules,
367        limits,
368        ruleset_hash,
369        config_hash,
370    })
371}
372
373fn parse_severity_overrides(
374    raw: &BTreeMap<String, String>,
375    display: &str,
376) -> Result<BTreeMap<RuleId, Severity>, ConfigError> {
377    raw.iter()
378        .map(|(id, severity)| {
379            let id = id.parse::<RuleId>().map_err(|e| ConfigError::Shape {
380                path: display.to_owned(),
381                detail: format!("in `severity`: {e}"),
382            })?;
383            let severity = severity
384                .parse::<Severity>()
385                .map_err(|e| ConfigError::Shape {
386                    path: display.to_owned(),
387                    detail: format!("in `severity` for `{id}`: {e}"),
388                })?;
389            Ok((id, severity))
390        })
391        .collect()
392}
393
394fn build_rule(
395    raw: RawRule,
396    position: usize,
397    display: &str,
398    overrides: &BTreeMap<RuleId, Severity>,
399    declared: &BTreeSet<String>,
400) -> Result<RuleSpec, ConfigError> {
401    let fail = |detail: String| ConfigError::Rule {
402        position,
403        path: display.to_owned(),
404        detail,
405    };
406
407    let id = raw
408        .id
409        .ok_or_else(|| fail("missing `id`".to_owned()))?
410        .parse::<RuleId>()
411        .map_err(|e| fail(e.to_string()))?;
412
413    // A namespace nobody declared is a typo, and this is the only layer that can tell.
414    // Parsing accepts any well-formed namespace so a team can use its own; declaring it is
415    // what keeps `lanekep/foo` from becoming a valid ID that quietly matches nothing.
416    if !id.namespace().is_built_in() && !declared.contains(id.namespace().as_str()) {
417        let mut known: Vec<String> = Namespace::built_ins()
418            .iter()
419            .map(|n| format!("`{n}`"))
420            .collect();
421        known.extend(declared.iter().map(|n| format!("`{n}`")));
422        return Err(fail(format!(
423            "rule namespace `{}` is not declared — add it to `namespaces` in the config, \
424             or use one of {}",
425            id.namespace(),
426            known.join(", ")
427        )));
428    }
429
430    // The check that JSON extraction exists to make possible. A rule whose handler is
431    // missing or misspelled would otherwise load cleanly and never report, which is
432    // indistinguishable from the code being fine.
433    if !raw.has_check {
434        return Err(fail(format!(
435            "`{id}` has no `check` function — a rule without one can never report anything"
436        )));
437    }
438
439    let query = raw
440        .query
441        .ok_or_else(|| fail(format!("`{id}` has no `query`")))?;
442    if query.trim().is_empty() {
443        return Err(fail(format!("`{id}` has an empty `query`")));
444    }
445
446    let card = raw
447        .card
448        .ok_or_else(|| fail(format!("`{id}` has no `card`")))?;
449    let examples = card.examples.unwrap_or(RawExamples {
450        bad: None,
451        good: None,
452    });
453    let card = RuleCard {
454        message: card.message.unwrap_or_default(),
455        remediation: card.remediation.unwrap_or_default(),
456        examples: Examples {
457            bad: examples.bad.unwrap_or_default(),
458            good: examples.good.unwrap_or_default(),
459        },
460    };
461    card.validate()
462        .map_err(|problems| fail(format!("`{id}` has an unusable card: {problems:?}")))?;
463
464    let declared = raw
465        .severity
466        .map(|s| s.parse::<Severity>())
467        .transpose()
468        .map_err(|e| fail(format!("`{id}`: {e}")))?
469        .unwrap_or(Severity::Error);
470
471    Ok(RuleSpec {
472        index: position - 1,
473        // Config severity wins over what the rule declares, per §9.
474        severity: overrides.get(&id).copied().unwrap_or(declared),
475        id,
476        // Both TypeScript dialects by default, because a rule written for TypeScript is
477        // meant for the TypeScript in the project — and in any React codebase most of that
478        // lives in `.tsx`, which the TypeScript grammar cannot parse.
479        languages: raw.language.map_or_else(
480            || vec!["typescript".to_owned(), "tsx".to_owned()],
481            RawLanguages::into_vec,
482        ),
483        card,
484        query,
485        gates: raw.gates,
486        timeout: raw.timeout.map(Duration::from_millis),
487        has_reduce: raw.has_reduce,
488    })
489}
490
491/// Hash every module the loader read.
492///
493/// # A correction to the architecture
494///
495/// §8 says `ruleset_hash` must be over *canonicalized* rule definitions, so that
496/// reformatting does not invalidate while editing a regex does. That was written when rules
497/// were declarative data, where canonicalizing means normalizing a parsed value.
498///
499/// Rules are now TypeScript, and canonicalizing arbitrary TypeScript would mean shipping a
500/// formatter and agreeing on its output forever. So this hashes module source bytes:
501/// reformatting a rule *does* invalidate its cached results.
502///
503/// That is over-invalidation, which costs a recompute. The alternative error —
504/// under-invalidating and serving results computed by code that no longer exists — is the
505/// one §8 exists to prevent, and it is not symmetric with this one.
506fn hash_ruleset(sandbox: &Sandbox) -> Hash {
507    let mut hasher = blake3::Hasher::new();
508    hasher.update(b"lanekeep-ruleset-v1");
509
510    if let Some(loaded) = sandbox.loaded_modules() {
511        // The map is ordered, so the hash does not depend on load order — which varies with
512        // import structure and is not something the user changed.
513        for (path, source) in loaded.borrow().iter() {
514            hasher.update(path.to_string_lossy().as_bytes());
515            hasher.update(&[0]);
516            hasher.update(source.as_bytes());
517            hasher.update(&[0]);
518        }
519    }
520
521    *hasher.finalize().as_bytes()
522}
523
524/// Hash the configuration values.
525///
526/// Canonicalized properly, because these *are* structured data: the severity map is ordered
527/// so writing the same entries in a different order hashes the same, and the budgets are
528/// hashed as numbers rather than as whatever the user typed.
529fn hash_config(
530    include: &[String],
531    exclude: &[String],
532    severity: &BTreeMap<RuleId, Severity>,
533    limits: &Limits,
534) -> Hash {
535    let mut hasher = blake3::Hasher::new();
536    hasher.update(b"lanekeep-config-v1");
537
538    for (label, globs) in [
539        (b"include".as_slice(), include),
540        (b"exclude".as_slice(), exclude),
541    ] {
542        hasher.update(label);
543        // Include and exclude are order-insensitive in effect, so hashing them in the
544        // order written would invalidate on a reordering that changes nothing.
545        let mut sorted: Vec<&String> = globs.iter().collect();
546        sorted.sort();
547        for glob in sorted {
548            hasher.update(glob.as_bytes());
549            hasher.update(&[0]);
550        }
551    }
552
553    hasher.update(b"severity");
554    for (id, level) in severity {
555        hasher.update(id.to_string().as_bytes());
556        hasher.update(&[0]);
557        hasher.update(level.as_str().as_bytes());
558        hasher.update(&[0]);
559    }
560
561    hasher.update(b"limits");
562    for value in [
563        limits.rule_timeout.as_millis(),
564        limits.global_timeout.as_millis(),
565        limits.memory_bytes as u128,
566    ] {
567        hasher.update(&value.to_le_bytes());
568    }
569
570    *hasher.finalize().as_bytes()
571}
572
573/// A `./`-relative specifier from the root to a file inside it.
574fn relative_specifier(root: &Path, file: &Path) -> Option<String> {
575    let file = file.canonicalize().ok()?;
576    let relative = file.strip_prefix(root).ok()?;
577    let joined = relative
578        .components()
579        .map(|c| c.as_os_str().to_string_lossy())
580        .collect::<Vec<_>>()
581        .join("/");
582    Some(format!("./{joined}"))
583}
584
585/// Build a sandbox able to load configuration from a rules root.
586///
587/// # Errors
588///
589/// Returns [`ConfigError::Unreadable`] if the sandbox cannot be constructed.
590pub fn sandbox_for(
591    root: &RuleRoot,
592    typescript: std::sync::Arc<dyn lanekeep_js::Language>,
593    javascript: std::sync::Arc<dyn lanekeep_js::Language>,
594) -> Result<Sandbox, ConfigError> {
595    let limits = Limits::default();
596    Sandbox::with_modules(
597        limits,
598        RunClock::start(limits.global_timeout),
599        root.clone(),
600        typescript,
601        javascript,
602    )
603    .map_err(|e| ConfigError::Unreadable {
604        path: root.path().display().to_string(),
605        detail: e.to_string(),
606    })
607}
608
609/// Where a config file is expected, relative to a project root.
610#[must_use]
611pub fn default_config_paths(project_root: &Path) -> Vec<PathBuf> {
612    [
613        "lanekeep.config.ts",
614        "lanekeep.config.js",
615        "lanekeep.config.mjs",
616    ]
617    .iter()
618    .map(|name| project_root.join(name))
619    .collect()
620}
621
622#[cfg(test)]
623mod tests {
624    use std::fs;
625    use std::sync::Arc;
626
627    use lanekeep_lang_js::{JavaScript, TypeScript};
628
629    use super::*;
630
631    struct Fixture {
632        dir: PathBuf,
633    }
634
635    impl Fixture {
636        fn new(name: &str, files: &[(&str, &str)]) -> Self {
637            let dir = std::env::temp_dir().join(format!("lanekeep-config-{name}"));
638            let _ = fs::remove_dir_all(&dir);
639            fs::create_dir_all(&dir).expect("creates dir");
640            let fixture = Self { dir };
641            fixture.write_all(files);
642            fixture
643        }
644
645        fn write_all(&self, files: &[(&str, &str)]) {
646            for (path, contents) in files {
647                let full = self.dir.join(path);
648                if let Some(parent) = full.parent() {
649                    fs::create_dir_all(parent).expect("creates parent");
650                }
651                fs::write(&full, contents).expect("writes");
652            }
653        }
654
655        fn load_config(&self) -> Result<Config, ConfigError> {
656            let root = RuleRoot::new(&self.dir).expect("canonicalizes");
657            let sandbox =
658                sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
659            load(&sandbox, &root, &self.dir.join("lanekeep.config.ts"))
660        }
661    }
662
663    impl Drop for Fixture {
664        fn drop(&mut self) {
665            let _ = fs::remove_dir_all(&self.dir);
666        }
667    }
668
669    /// A minimal, valid rule module.
670    fn rule(id: &str) -> String {
671        format!(
672            "import {{ defineRule }} from 'lanekeep';\n\
673             export default defineRule({{\n\
674               id: '{id}',\n\
675               query: '(identifier) @id',\n\
676               card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
677               check(ctx, m) {{ ctx.report(m.id); }},\n\
678             }});\n"
679        )
680    }
681
682    fn config_with(body: &str) -> String {
683        format!(
684            "import {{ defineConfig }} from 'lanekeep';\n\
685             import rule from './rule';\n\
686             export default defineConfig({{ {body} }});\n"
687        )
688    }
689
690    #[test]
691    fn loads_a_valid_config() {
692        let fixture = Fixture::new(
693            "valid",
694            &[
695                ("rule.ts", &rule("local/example")),
696                (
697                    "lanekeep.config.ts",
698                    &config_with(
699                        "include: ['src/**/*.ts'], exclude: ['**/*.test.ts'], rules: [rule]",
700                    ),
701                ),
702            ],
703        );
704
705        let config = fixture.load_config().expect("loads");
706        assert_eq!(config.include, ["src/**/*.ts"]);
707        assert_eq!(config.exclude, ["**/*.test.ts"]);
708        assert_eq!(config.rules.len(), 1);
709        assert_eq!(config.rules[0].id.to_string(), "local/example");
710        assert_eq!(config.rules[0].card.message, "no");
711        assert!(!config.rules[0].has_reduce);
712    }
713
714    /// A team can group its rules under its own namespace, which `local/` alone does not
715    /// allow — everything project-authored ends up in one bucket regardless of who wrote it.
716    #[test]
717    fn a_declared_namespace_is_accepted() {
718        let fixture = Fixture::new(
719            "declared-namespace",
720            &[
721                ("rule.ts", &rule("pera/no-numeric-sizes")),
722                (
723                    "lanekeep.config.ts",
724                    &config_with("namespaces: ['pera'], rules: [rule]"),
725                ),
726            ],
727        );
728
729        let config = fixture.load_config().expect("loads");
730        assert_eq!(config.rules[0].id.to_string(), "pera/no-numeric-sizes");
731        assert!(!config.rules[0].id.is_built_in());
732    }
733
734    /// And the property that made a closed set worth having in the first place: a namespace
735    /// nobody declared is a typo, and it fails at load rather than becoming a valid ID that
736    /// silently matches nothing.
737    #[test]
738    fn an_undeclared_namespace_is_rejected() {
739        let fixture = Fixture::new(
740            "undeclared-namespace",
741            &[
742                ("rule.ts", &rule("lanekep/no-default-export")),
743                ("lanekeep.config.ts", &config_with("rules: [rule]")),
744            ],
745        );
746
747        let error = fixture
748            .load_config()
749            .expect_err("an undeclared namespace should be refused")
750            .to_string();
751        assert!(error.contains("lanekep"), "{error}");
752        assert!(
753            error.contains("namespaces"),
754            "should say how to fix it: {error}"
755        );
756    }
757
758    /// `lanekeep/` stays reserved, so a rule's origin is readable from its ID alone.
759    #[test]
760    fn the_lanekeep_namespace_cannot_be_claimed() {
761        let fixture = Fixture::new(
762            "reserved-namespace",
763            &[
764                ("rule.ts", &rule("local/example")),
765                (
766                    "lanekeep.config.ts",
767                    &config_with("namespaces: ['lanekeep'], rules: [rule]"),
768                ),
769            ],
770        );
771
772        let error = fixture
773            .load_config()
774            .expect_err("claiming the reserved namespace should be refused")
775            .to_string();
776        assert!(error.contains("reserved"), "{error}");
777    }
778
779    /// A rule with no language of its own targets both TypeScript dialects, because in a
780    /// React codebase most TypeScript is `.tsx`.
781    #[test]
782    fn a_rule_defaults_to_both_typescript_dialects() {
783        let fixture = Fixture::new(
784            "default-languages",
785            &[
786                ("rule.ts", &rule("local/example")),
787                ("lanekeep.config.ts", &config_with("rules: [rule]")),
788            ],
789        );
790
791        let config = fixture.load_config().expect("loads");
792        assert_eq!(config.rules[0].languages, ["typescript", "tsx"]);
793    }
794
795    /// One or several, both spelled the way a rule author would write them.
796    #[test]
797    fn a_rule_may_declare_one_language_or_several() {
798        for (declaration, expected) in [
799            ("language: 'tsx',", vec!["tsx"]),
800            (
801                "language: ['typescript', 'tsx'],",
802                vec!["typescript", "tsx"],
803            ),
804        ] {
805            let module = format!(
806                "import {{ defineRule }} from 'lanekeep';\n\
807                 export default defineRule({{\n\
808                   id: 'local/example',\n\
809                 {declaration}\n\
810                   query: '(identifier) @id',\n\
811                   card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
812                   check(ctx, m) {{ ctx.report(m.id); }},\n\
813                 }});\n"
814            );
815            let fixture = Fixture::new(
816                "language-forms",
817                &[
818                    ("rule.ts", &module),
819                    ("lanekeep.config.ts", &config_with("rules: [rule]")),
820                ],
821            );
822
823            let config = fixture.load_config().expect("loads");
824            assert_eq!(config.rules[0].languages, expected, "{declaration}");
825        }
826    }
827
828    #[test]
829    fn a_rule_without_a_check_function_is_rejected() {
830        // The failure JSON extraction exists to catch. Without this the rule loads, never
831        // fires, and looks exactly like the code being clean.
832        //
833        // The handler is named `onMatch` rather than a misspelling of `check`, because the
834        // spell checker flags a real typo in source even inside a fixture — and allowing it
835        // globally to keep the joke would be a poor trade. What matters is that `check` is
836        // absent, not how it came to be.
837        let fixture = Fixture::new(
838            "no-check",
839            &[
840                (
841                    "rule.ts",
842                    "import { defineRule } from 'lanekeep';\n\
843                     export default defineRule({\n\
844                       id: 'local/typo',\n\
845                       query: '(identifier) @id',\n\
846                       card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
847                       onMatch(ctx, m) {},\n\
848                     });\n",
849                ),
850                ("lanekeep.config.ts", &config_with("rules: [rule]")),
851            ],
852        );
853
854        let err = fixture.load_config().expect_err("must be rejected");
855        let rendered = err.to_string();
856        assert!(rendered.contains("check"), "{rendered}");
857        assert!(rendered.contains("never report"), "{rendered}");
858    }
859
860    #[test]
861    fn a_rule_with_a_bare_id_is_rejected() {
862        let fixture = Fixture::new(
863            "bare-id",
864            &[
865                ("rule.ts", &rule("example")),
866                ("lanekeep.config.ts", &config_with("rules: [rule]")),
867            ],
868        );
869        let rendered = fixture
870            .load_config()
871            .expect_err("must be rejected")
872            .to_string();
873        assert!(rendered.contains("namespace"), "{rendered}");
874    }
875
876    #[test]
877    fn a_rule_with_an_unusable_card_is_rejected() {
878        let fixture = Fixture::new(
879            "bad-card",
880            &[
881                (
882                    "rule.ts",
883                    "import { defineRule } from 'lanekeep';\n\
884                     export default defineRule({\n\
885                       id: 'local/empty',\n\
886                       query: '(identifier) @id',\n\
887                       card: { message: '', remediation: '', examples: { bad: '', good: '' } },\n\
888                       check() {},\n\
889                     });\n",
890                ),
891                ("lanekeep.config.ts", &config_with("rules: [rule]")),
892            ],
893        );
894        assert!(fixture.load_config().is_err());
895    }
896
897    #[test]
898    fn a_missing_default_export_says_so() {
899        // The engine catches this at link time, before extraction runs, and its message is
900        // better than a generic one would be — it names the module and the missing export.
901        let fixture = Fixture::new(
902            "no-default",
903            &[
904                ("rule.ts", &rule("local/x")),
905                ("lanekeep.config.ts", "export const notDefault = 1;\n"),
906            ],
907        );
908        let rendered = fixture
909            .load_config()
910            .expect_err("must be rejected")
911            .to_string();
912        assert!(rendered.contains("default"), "{rendered}");
913    }
914
915    #[test]
916    fn a_default_export_that_is_not_an_object_says_so() {
917        // This one does reach our own check: the export exists, so the engine is happy,
918        // and only the shape is wrong.
919        let fixture = Fixture::new(
920            "default-not-object",
921            &[
922                ("rule.ts", &rule("local/x")),
923                ("lanekeep.config.ts", "export default 42;\n"),
924            ],
925        );
926        let rendered = fixture
927            .load_config()
928            .expect_err("must be rejected")
929            .to_string();
930        assert!(rendered.contains("export default"), "{rendered}");
931    }
932
933    #[test]
934    fn config_severity_overrides_what_the_rule_declares() {
935        let fixture = Fixture::new(
936            "severity",
937            &[
938                ("rule.ts", &rule("local/example")),
939                (
940                    "lanekeep.config.ts",
941                    &config_with("rules: [rule], severity: { 'local/example': 'warn' }"),
942                ),
943            ],
944        );
945        let config = fixture.load_config().expect("loads");
946        assert_eq!(config.rules[0].severity, Severity::Warn);
947    }
948
949    #[test]
950    fn timeouts_fall_back_to_the_defaults() {
951        let fixture = Fixture::new(
952            "timeouts-default",
953            &[
954                ("rule.ts", &rule("local/example")),
955                ("lanekeep.config.ts", &config_with("rules: [rule]")),
956            ],
957        );
958        let config = fixture.load_config().expect("loads");
959        assert_eq!(config.limits, Limits::default());
960    }
961
962    #[test]
963    fn timeouts_can_be_overridden() {
964        let fixture = Fixture::new(
965            "timeouts-set",
966            &[
967                ("rule.ts", &rule("local/example")),
968                (
969                    "lanekeep.config.ts",
970                    &config_with("rules: [rule], timeouts: { rule: 2000, global: 30000 }"),
971                ),
972            ],
973        );
974        let config = fixture.load_config().expect("loads");
975        assert_eq!(config.limits.rule_timeout, Duration::from_secs(2));
976        assert_eq!(config.limits.global_timeout, Duration::from_secs(30));
977    }
978
979    // --- hashing --------------------------------------------------------------------
980
981    #[test]
982    fn the_ruleset_hash_covers_an_imported_helper() {
983        // The §8 property, and the reason the loader records what it read rather than the
984        // config naming its own inputs. A rule importing a helper has to invalidate when
985        // that helper changes — nothing else in the system knows the helper was involved.
986        let files: &[(&str, &str)] = &[
987            ("helper.ts", "export const QUERY = '(identifier) @id';\n"),
988            (
989                "rule.ts",
990                "import { defineRule } from 'lanekeep';\n\
991                 import { QUERY } from './helper';\n\
992                 export default defineRule({\n\
993                   id: 'local/example',\n\
994                   query: QUERY,\n\
995                   card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
996                   check() {},\n\
997                 });\n",
998            ),
999            ("lanekeep.config.ts", ""),
1000        ];
1001        let fixture = Fixture::new("helper-hash", files);
1002        fixture.write_all(&[("lanekeep.config.ts", &config_with("rules: [rule]"))]);
1003
1004        let before = fixture.load_config().expect("loads").ruleset_hash;
1005
1006        fixture.write_all(&[("helper.ts", "export const QUERY = '(string) @s';\n")]);
1007        let after = fixture.load_config().expect("loads").ruleset_hash;
1008
1009        assert_ne!(
1010            hex(&before),
1011            hex(&after),
1012            "changing an imported helper must invalidate the ruleset hash"
1013        );
1014    }
1015
1016    #[test]
1017    fn the_ruleset_hash_is_stable_when_nothing_changed() {
1018        let fixture = Fixture::new(
1019            "stable-hash",
1020            &[
1021                ("rule.ts", &rule("local/example")),
1022                ("lanekeep.config.ts", &config_with("rules: [rule]")),
1023            ],
1024        );
1025        let first = fixture.load_config().expect("loads").ruleset_hash;
1026        let second = fixture.load_config().expect("loads").ruleset_hash;
1027        assert_eq!(hex(&first), hex(&second));
1028    }
1029
1030    #[test]
1031    fn the_config_hash_ignores_glob_order() {
1032        // Include and exclude are order-insensitive in effect, so reordering them must not
1033        // throw away a warm cache for a change that alters nothing.
1034        let make = |globs: &str| {
1035            Fixture::new(
1036                &format!("glob-order-{}", globs.len()),
1037                &[
1038                    ("rule.ts", &rule("local/example")),
1039                    (
1040                        "lanekeep.config.ts",
1041                        &config_with(&format!("rules: [rule], include: {globs}")),
1042                    ),
1043                ],
1044            )
1045            .load_config()
1046            .expect("loads")
1047            .config_hash
1048        };
1049
1050        assert_eq!(
1051            hex(&make("['a/**', 'b/**']")),
1052            hex(&make("['b/**', 'a/**' ]")),
1053            "reordering globs must not change the config hash"
1054        );
1055    }
1056
1057    #[test]
1058    fn the_config_hash_changes_with_severity() {
1059        let make = |extra: &str, tag: &str| {
1060            Fixture::new(
1061                &format!("severity-hash-{tag}"),
1062                &[
1063                    ("rule.ts", &rule("local/example")),
1064                    (
1065                        "lanekeep.config.ts",
1066                        &config_with(&format!("rules: [rule]{extra}")),
1067                    ),
1068                ],
1069            )
1070            .load_config()
1071            .expect("loads")
1072            .config_hash
1073        };
1074
1075        assert_ne!(
1076            hex(&make("", "none")),
1077            hex(&make(", severity: { 'local/example': 'warn' }", "warn")),
1078            "changing a severity must invalidate"
1079        );
1080    }
1081
1082    #[test]
1083    fn the_config_hash_changes_with_a_timeout() {
1084        let make = |extra: &str, tag: &str| {
1085            Fixture::new(
1086                &format!("timeout-hash-{tag}"),
1087                &[
1088                    ("rule.ts", &rule("local/example")),
1089                    (
1090                        "lanekeep.config.ts",
1091                        &config_with(&format!("rules: [rule]{extra}")),
1092                    ),
1093                ],
1094            )
1095            .load_config()
1096            .expect("loads")
1097            .config_hash
1098        };
1099
1100        assert_ne!(
1101            hex(&make("", "d")),
1102            hex(&make(", timeouts: { rule: 5000 }", "t"))
1103        );
1104    }
1105
1106    #[test]
1107    fn hex_renders_a_full_hash() {
1108        assert_eq!(hex(&[0u8; 32]).len(), 64);
1109        assert_eq!(hex(&[0xab; 32]), "ab".repeat(32));
1110    }
1111}