Skip to main content

lanekeep_config/
json.rs

1//! `lanekeep.json` — configuration without writing TypeScript.
2//!
3//! Rules are programs and that is not negotiable; it is the decision ADR-0007 rests on. But
4//! *configuration* is a different thing from a rule, and requiring a Go or Python team to
5//! write a `.ts` file to say which rules they want was a coupling with nothing behind it.
6//! `lanekeep init` in a Go project scaffolding TypeScript is the shape of the problem.
7//!
8//! # How it works
9//!
10//! This file parses, validates and resolves a JSON config in Rust, with no JavaScript
11//! evaluated at any point. A rule reference — a bare string or a `{ "rule", "options" }`
12//! object — resolves to a [`RuleReference`] naming a built-in, a compiled component or a
13//! rule module, with its `options` carried alongside as data.
14//!
15//! ```json
16//! {
17//!   "include": ["src/**/*.go"],
18//!   "rules": [
19//!     "lanekeep/no-package-init",
20//!     { "rule": "lanekeep/no-restricted-imports", "options": { "restrictions": [] } },
21//!     "./lanekeep/rules/no-naked-return.ts"
22//!   ]
23//! }
24//! ```
25//!
26//! # What the two forms mean
27//!
28//! A bare string is a rule used as it comes. The object form configures it with options,
29//! which is what `noRestrictedImports({ ... })` does in a TypeScript config — so the
30//! distinction a rule author already makes between a rule and a rule factory survives,
31//! rather than being guessed at from whether `options` happens to be present.
32//!
33//! # It used to be compiled into JavaScript, and why it no longer is
34//!
35//! Until this file was un-coupled, a JSON config was compiled into the entry module the
36//! TypeScript path produces and handed to the same loader, so that nothing downstream knew
37//! which format it came from and "the two cannot drift in behavior." That was a deliberate
38//! design and a good one; it is also the mechanism that made `lanekeep.json` depend on a
39//! JavaScript sandbox, which is why removing QuickJS would have broken *config loading*
40//! rather than only rule execution.
41//!
42//! What replaces it is convergence rather than a shared mechanism: both formats still meet
43//! at exactly one place — `crate::build` — which validates, hashes and constructs the
44//! `Config`. Nothing about a rule's identity, severity, card, query or budget is decided
45//! twice. See `lib.rs`'s note above `entry_source` for what the change costs and what now
46//! holds the two paths together.
47//!
48//! One thing does still cross into JavaScript on this path, and it is rule *execution*, not
49//! configuration: a reference naming a TypeScript rule is rendered into a rules-only entry
50//! module by [`rules_module`], because a TypeScript rule's `id`, `query` and `card` live
51//! inside its own `defineRule` call and nothing but evaluating it can read them. A component
52//! is the other way round — it answers `metadata` itself, so it contributes no import and
53//! nothing for the sandbox to evaluate.
54//!
55//! **A config naming only components still evaluates an entry module, and that is a residue
56//! rather than a requirement.** `crate::load` always evaluates and always runs `EXTRACT`, so a
57//! components-only config produces `globalThis.__lanekeepConfig = { rules: [null] }` and gets
58//! a list of nulls back. Nothing is read from it. Skipping the sandbox when no reference names
59//! a module is a real simplification and is not made here, because the same entry module is
60//! what every worker evaluates and that path has to agree with this one.
61
62use std::fmt::Write as _;
63use std::path::{Path, PathBuf};
64
65use lanekeep_core::files::normalize;
66use serde::Deserialize;
67use serde_json::Value;
68
69use crate::ConfigError;
70
71/// Resolves a built-in rule name to its embedded component.
72///
73/// Structurally the same type the rules root carries and `crate::load` hands in — a `fn` alias
74/// is transparent, so the two are one type and a change to either is a compile error at the
75/// call site rather than a drift.
76///
77/// **Declared here rather than imported, and the reason is the point of this file.** Resolving a
78/// `lanekeep.json` reaches no sandbox, and `the_json_path_names_nothing_from_the_sandbox_crate`
79/// enforces that by refusing the sandbox crate's name anywhere in this source. A lookup function
80/// is not a sandbox and importing the alias would not make it one — but the check reads names
81/// rather than intent, deliberately, because the alternative is a check that has to be argued
82/// with every time it fires. This file does not need the import, so it does not take it.
83pub(crate) type BuiltinComponent = fn(&str) -> Option<(&'static [u8], u32)>;
84
85/// The prefix a built-in rule reference carries, as in `lanekeep/no-package-init`.
86const BUILTIN_PREFIX: &str = "lanekeep/";
87
88/// The extension marking a reference as a compiled rule component.
89const COMPONENT_EXTENSION: &str = "wasm";
90
91/// Whether this path is a JSON config rather than a module.
92pub(crate) fn is_json(path: &Path) -> bool {
93    path.extension()
94        .is_some_and(|e| e.eq_ignore_ascii_case("json"))
95}
96
97/// What a rule reference in a `lanekeep.json` names.
98///
99/// Deciding this in Rust is the whole of the un-coupling: a reference used to become an
100/// `import` statement whose meaning only the module loader knew, and is now a value the
101/// rest of the crate can read without evaluating anything.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum RuleReference {
104    /// A rule shipped with lanekeep, authored in TypeScript. Carries the name after the
105    /// prefix, so `"lanekeep/no-package-init"` is `Builtin("no-package-init")`.
106    ///
107    /// Which bytes that name resolves to is the loader's business; the name is what a config
108    /// wrote, and it is decided here.
109    Builtin(String),
110
111    /// A rule shipped with lanekeep, compiled to a component.
112    ///
113    /// **The distinction is not one a config writes.** Both spellings are `lanekeep/<name>`,
114    /// and which of the two a name is depends only on how that rule happens to be authored in
115    /// the build the user is running. A rule migrating from TypeScript to Rust must not require
116    /// anybody to edit their config, which is the whole point of resolving the prefix here
117    /// rather than making the format carry the answer.
118    ///
119    /// Its own variant rather than a [`RuleReference::Component`] holding a synthetic path,
120    /// because a built-in has no path: its bytes are embedded in the binary, so there is
121    /// nothing to confine, nothing to read and nothing a project file could shadow.
122    BuiltinComponent(String),
123
124    /// A compiled rule component on disk, as in `"./rules/no-package-init.wasm"`.
125    ///
126    /// The path is the reference resolved against the rules root — the same anchor a
127    /// relative module specifier resolves against, since the synthetic entry module sits
128    /// there.
129    ///
130    /// Resolved rather than merely recognized: `crate::describe_components` loads these bytes
131    /// at config load and asks the component what it is, because a `.wasm` carries its own
132    /// `id`, `query`, `card` and gates and there is no config syntax for any of them.
133    Component(PathBuf),
134
135    /// A rule module on disk, as in `"./lanekeep/rules/mine.ts"`.
136    ///
137    /// Carries the specifier as written rather than a path, because the extension is
138    /// optional — `./rule` finds `rule.ts` — and reproducing the loader's search here would
139    /// be a second implementation of it.
140    Module(String),
141}
142
143impl RuleReference {
144    /// Whether this reference's handlers live in a component rather than in JavaScript.
145    ///
146    /// One question asked in three places — the entry module's placeholder, the early return in
147    /// `crate::describe_components`, and the description loop — so that adding a third way for a
148    /// component to be named cannot leave one of them behind. A `matches!` at each site is what
149    /// let `BuiltinComponent` be added and forgotten, and every symptom of forgetting is silent:
150    /// a placeholder not emitted shifts every later rule's handler by one, and a reference not
151    /// described becomes a rule with no `check`.
152    #[must_use]
153    pub const fn is_component(&self) -> bool {
154        matches!(self, Self::Component(_) | Self::BuiltinComponent(_))
155    }
156}
157
158/// A rule reference, resolved, with the options it was configured with.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct ResolvedRule {
161    /// The reference exactly as the config wrote it.
162    pub specifier: String,
163    /// What it names.
164    pub reference: RuleReference,
165    /// The options it was configured with, as data.
166    ///
167    /// `None` is the bare-string form — a rule used as it comes. `Some` is the object form,
168    /// including `{ "rule": "x" }` with no `options` key, which configures it with `null`.
169    /// The distinction is the one a rule author already makes between a rule and a rule
170    /// factory, and it is not inferred from whether a value happens to be present.
171    ///
172    /// A component cannot close over a host-supplied value the way a JavaScript factory does,
173    /// so `crates/lanekeep-wasm/wit/world.wit` declares `configure(options-json)` for exactly
174    /// that reason — a call made once after instantiation, taking this field serialized as
175    /// JSON (`null` for the bare-string form). `crate::describe_components` serializes it once
176    /// and records it with the rule, so the bytes every worker's `configure` is handed are the
177    /// same bytes.
178    pub options: Option<Value>,
179}
180
181/// A `lanekeep.json`, parsed and resolved.
182pub(crate) struct Parsed {
183    /// Everything that is configuration data, in the shape [`crate::build`] consumes.
184    ///
185    /// The same struct the TypeScript path fills in from `JSON.stringify`, with `rules`
186    /// left empty — those arrive from extraction, and are the one field on this path that
187    /// still comes from the sandbox.
188    pub(crate) config: crate::RawConfig,
189    /// The rule references, resolved.
190    pub(crate) rules: Vec<ResolvedRule>,
191}
192
193/// A `lanekeep.json`, as written.
194///
195/// `deny_unknown_fields` on purpose: a misspelled key in a config is a setting that silently
196/// does nothing, which is the failure this whole file exists to avoid producing more of.
197#[derive(Debug, Deserialize)]
198#[serde(deny_unknown_fields)]
199struct JsonConfig {
200    /// Editors read this to offer completion and validation. Accepted and ignored — the
201    /// point of it is that a user gets help before lanekeep ever runs.
202    #[serde(rename = "$schema", default)]
203    _schema: Option<String>,
204
205    #[serde(default)]
206    include: Vec<String>,
207    #[serde(default)]
208    exclude: Vec<String>,
209    #[serde(default)]
210    namespaces: Vec<String>,
211    #[serde(default)]
212    severity: std::collections::BTreeMap<String, String>,
213    #[serde(default)]
214    timeouts: crate::RawTimeouts,
215    #[serde(default)]
216    suppressions: crate::RawSuppressions,
217    #[serde(default)]
218    rules: Vec<JsonRule>,
219}
220
221/// A rule, either used as it comes or configured with options.
222#[derive(Debug, Deserialize)]
223#[serde(untagged)]
224enum JsonRule {
225    /// `"lanekeep/no-default-export"` or `"./lanekeep/rules/mine.ts"`.
226    Plain(String),
227    /// `{ "rule": "...", "options": { ... } }`.
228    Configured {
229        rule: String,
230        #[serde(default)]
231        options: Value,
232    },
233}
234
235impl JsonRule {
236    fn specifier(&self) -> &str {
237        match self {
238            Self::Plain(specifier) => specifier,
239            Self::Configured { rule, .. } => rule,
240        }
241    }
242
243    fn options(&self) -> Option<Value> {
244        match self {
245            Self::Plain(_) => None,
246            Self::Configured { options, .. } => Some(options.clone()),
247        }
248    }
249}
250
251/// Read a JSON config and resolve every rule reference, evaluating nothing.
252///
253/// `rules_root` anchors a relative reference, because that is where the synthetic entry
254/// module sits and therefore what a relative specifier has always resolved against.
255///
256/// # Errors
257///
258/// Returns [`ConfigError`] when the file cannot be read, is not valid JSON, or names a rule
259/// in a way that cannot mean anything.
260pub(crate) fn parse(
261    config_path: &Path,
262    rules_root: &Path,
263    components: BuiltinComponent,
264) -> Result<Parsed, ConfigError> {
265    let display = config_path.display().to_string();
266
267    let text = std::fs::read_to_string(config_path).map_err(|e| ConfigError::Unreadable {
268        path: display.clone(),
269        detail: e.to_string(),
270    })?;
271
272    let config: JsonConfig = serde_json::from_str(&text).map_err(|e| ConfigError::Shape {
273        path: display.clone(),
274        detail: e.to_string(),
275    })?;
276
277    let mut rules = Vec::with_capacity(config.rules.len());
278    for rule in &config.rules {
279        let specifier = rule.specifier();
280        validate_specifier(specifier, &display)?;
281        rules.push(ResolvedRule {
282            specifier: specifier.to_owned(),
283            reference: classify(specifier, rules_root, components),
284            options: rule.options(),
285        });
286    }
287
288    Ok(Parsed {
289        // Every field named, and no `..Default::default()`. This is the load-bearing line of
290        // the whole un-coupling: the two config formats no longer share a mechanism, so the
291        // thing that has to be prevented is one of them quietly not carrying a setting. An
292        // exhaustive literal against the *shared* struct makes that a compile error — adding
293        // `presets` to `RawConfig` fails here with `error[E0063]: missing field 'presets'`,
294        // naming this line — where the arrangement this replaced had no equivalent: the same
295        // omission from the old entry module's `format!` string compiled fine and produced a
296        // config silently missing a setting. Do not "tidy" this into a struct-update.
297        config: crate::RawConfig {
298            include: config.include,
299            exclude: config.exclude,
300            namespaces: config.namespaces,
301            severity: config.severity,
302            timeouts: config.timeouts,
303            suppressions: config.suppressions,
304            rules: Vec::new(),
305        },
306        rules,
307    })
308}
309
310/// Decide what a validated specifier names.
311///
312/// Built-ins are recognized before anything else, exactly as the module loader does, so a
313/// file on disk cannot shadow one. A `.wasm` extension is what distinguishes a compiled
314/// component from a source module; nothing else is ambiguous, because a rule module's
315/// extension is optional and a component's never is — bytes are not searched for by guessing
316/// suffixes.
317///
318/// # Whether a built-in is a component is asked, not spelled
319///
320/// `components` is the same lookup the rules root answers module resolution from, so one value
321/// decides what `lanekeep/<name>` means everywhere. Splitting it would let a name be a module
322/// here and a component there — and the failure would be a rule that loads and never runs,
323/// which reads exactly like a clean codebase.
324///
325/// A name the lookup does not know stays a [`RuleReference::Builtin`] rather than becoming an
326/// error here: an unknown built-in is refused by the loader, with the message that already
327/// names the specifier and says no such rule ships. Two refusals for one mistake would differ
328/// in wording by the format the user happened to write.
329fn classify(specifier: &str, rules_root: &Path, components: BuiltinComponent) -> RuleReference {
330    if let Some(name) = specifier.strip_prefix(BUILTIN_PREFIX) {
331        return if components(name).is_some() {
332            RuleReference::BuiltinComponent(name.to_owned())
333        } else {
334            RuleReference::Builtin(name.to_owned())
335        };
336    }
337    let path = Path::new(specifier);
338    if path
339        .extension()
340        .is_some_and(|e| e.eq_ignore_ascii_case(COMPONENT_EXTENSION))
341    {
342        return RuleReference::Component(normalize(&rules_root.join(path)));
343    }
344    RuleReference::Module(specifier.to_owned())
345}
346
347/// Compile the resolved rules into the entry module the loader evaluates.
348///
349/// Only the rules: `include`, `exclude`, `namespaces`, `severity` and `timeouts` are read in
350/// Rust by [`parse`] and never become JavaScript. What is left here is the one thing a
351/// sandbox is still required for — reading a TypeScript rule's own declaration.
352///
353/// # A component holds its place in the array and contributes no JavaScript
354///
355/// A component is resolved in Rust: its `id`, `query` and card come from its own `metadata`
356/// export, so there is nothing here to import and nothing for the sandbox to evaluate. What
357/// it emits instead is a literal `null` in the array, and that is load-bearing rather than
358/// tidy.
359///
360/// `RuleSpec::index` is how the engine reaches a *TypeScript* handler — it is spelled
361/// `globalThis.__lanekeepConfig.rules[index].check(...)` — and it is also the position
362/// `lanekeep-config` builds every rule at. Skipping a component would make those two numbers
363/// disagree the moment a config mixes the kinds: rule 3 in the config would be rule 2 in the
364/// array, and every rule after a component would dispatch to its neighbor. That failure is
365/// silent — a rule object is a rule object, the call succeeds, and the violations are simply
366/// attributed to the wrong rule. A placeholder keeps one numbering for both, so there is no
367/// mapping to get wrong.
368///
369/// Nothing ever reads the placeholder: a component-backed rule dispatches on
370/// `RuleSpec::component`, not through this array. The one thing that touches it is `EXTRACT`,
371/// which is written with `?.` throughout and yields a rule with no `id` and no `check` — which
372/// is exactly what a component's entry in the extracted array should look like, since its real
373/// answers come from somewhere else entirely.
374pub(crate) fn rules_module(rules: &[ResolvedRule]) -> String {
375    let mut imports = String::new();
376    let mut references = Vec::with_capacity(rules.len());
377
378    for (index, rule) in rules.iter().enumerate() {
379        if rule.reference.is_component() {
380            references.push("null".to_owned());
381            continue;
382        }
383
384        let binding = format!("__lanekeepRule{index}");
385        let _ = writeln!(
386            imports,
387            "import {binding} from {};",
388            js_string(&rule.specifier)
389        );
390
391        references.push(match &rule.options {
392            None => binding,
393            Some(options) => {
394                let specifier = js_string(&rule.specifier);
395                format!(
396                    "(function() {{ var __r = {binding}; var __o = {literal}; \
397                     if (typeof __r === 'function') return __r(__o); \
398                     if (__o !== null && __o !== undefined) \
399                     throw new Error({specifier} + ' takes no options — \
400                     it exports a rule object, not a factory'); \
401                     return __r; }})()",
402                    binding = binding,
403                    literal = literal(options),
404                    specifier = specifier,
405                )
406            }
407        });
408    }
409
410    format!(
411        "{imports}globalThis.__lanekeepConfig = {{ rules: [{}] }};\n",
412        references.join(", "),
413    )
414}
415
416/// Reject a specifier that cannot mean what it says.
417///
418/// A quote or a newline would end the import statement early and let the rest of the string
419/// be read as code. Nothing legitimate needs either, so refusing is free — and a config file
420/// is exactly the kind of thing that gets generated by a script one day.
421///
422/// Applied to every reference rather than only to the ones still rendered into JavaScript.
423/// A path carrying a quote is not a path anyone means, and a check that holds for some
424/// reference kinds and not others is a check whose coverage depends on a classification made
425/// somewhere else.
426fn validate_specifier(specifier: &str, display: &str) -> Result<(), ConfigError> {
427    if specifier.is_empty() {
428        return Err(ConfigError::Shape {
429            path: display.to_owned(),
430            detail: "a rule entry is an empty string".to_owned(),
431        });
432    }
433    if specifier.contains(['\'', '"', '\\', '\n', '\r']) {
434        return Err(ConfigError::Shape {
435            path: display.to_owned(),
436            detail: format!(
437                "the rule specifier {specifier:?} contains a quote, a backslash or a newline"
438            ),
439        });
440    }
441    Ok(())
442}
443
444/// A JSON value as a JavaScript literal.
445///
446/// JSON is very nearly a subset of JavaScript, and the exception matters: U+2028 and U+2029
447/// are ordinary characters in a JSON string and line terminators in older JavaScript, so a
448/// config containing one would produce a module that does not parse. Escaping them costs
449/// nothing and removes the question.
450///
451/// Also what the options blob is hashed as, where the escaping is irrelevant and the
452/// canonical ordering is not: `serde_json::Map` is a `BTreeMap` here, so two configs writing
453/// the same option keys in a different order serialize identically and hash the same.
454pub(crate) fn literal<T: serde::Serialize>(value: &T) -> String {
455    serde_json::to_string(value)
456        .unwrap_or_else(|_| "null".to_owned())
457        .replace('\u{2028}', "\\u2028")
458        .replace('\u{2029}', "\\u2029")
459}
460
461/// A JavaScript single-quoted string. Only called on specifiers already validated above.
462fn js_string(value: &str) -> String {
463    format!("'{value}'")
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    /// Where a fixture config is written: one directory per caller, named by the caller.
471    ///
472    /// **Two derived names have already raced here, and the second looked like a fix.** The
473    /// first keyed the directory on the config's *length*, so two thirty-eight-byte configs
474    /// shared one file and each test read whichever had been written last. Keying on a hash
475    /// of the *content* was the obvious repair and is still wrong: two tests can legitimately
476    /// use the identical config — `a_component_reference_resolves_to_a_path` and
477    /// `a_component_reference_imports_nothing` both write
478    /// `{"rules": ["./rules/mine.wasm"]}` — and `std::fs::write` truncates before it writes,
479    /// so the sibling thread reads an empty file and fails with `EOF while parsing a value at
480    /// line 1 column 0`. Measured five failures in eighty runs of
481    /// `cargo test -p lanekeep-config`. Same bytes is not the same as no race; truncate-then-
482    /// write is not atomic.
483    ///
484    /// An explicit name is the only version with no derivation to be clever about. Two tests
485    /// passing the same name is a visible duplicate rather than a scheduling-dependent one.
486    fn fixture_dir(name: &str) -> PathBuf {
487        std::env::temp_dir().join(format!("lanekeep-json-{name}"))
488    }
489
490    /// No built-in ships as a component, so `lanekeep/<name>` is a module.
491    ///
492    /// The default for these tests, so that the ones about imports and options say what they
493    /// always said regardless of which real rules have migrated.
494    fn no_components(_name: &str) -> Option<(&'static [u8], u32)> {
495        None
496    }
497
498    /// One built-in ships as a component, named for what it is rather than after a real rule.
499    ///
500    /// A stub rather than `lanekeep_rules::component`, on the same terms as the loader's own
501    /// stub: which rules ship is not what these tests are about, and pinning them to the real
502    /// table would make a future migration edit assertions that have nothing to do with it.
503    fn one_component(name: &str) -> Option<(&'static [u8], u32)> {
504        match name {
505            "compiled" => Some((b"\0asm\x01\x00\x00\x00", 0)),
506            _ => None,
507        }
508    }
509
510    fn parse_config(name: &str, json: &str) -> Result<Parsed, ConfigError> {
511        parse_config_with(name, json, no_components)
512    }
513
514    fn parse_config_with(
515        name: &str,
516        json: &str,
517        components: BuiltinComponent,
518    ) -> Result<Parsed, ConfigError> {
519        let dir = fixture_dir(name);
520        std::fs::create_dir_all(&dir).expect("creates dir");
521        let path = dir.join("lanekeep.json");
522        std::fs::write(&path, json).expect("writes");
523        parse(&path, &dir, components)
524    }
525
526    fn compile(name: &str, json: &str) -> Result<String, ConfigError> {
527        let parsed = parse_config(name, json)?;
528        Ok(rules_module(&parsed.rules))
529    }
530
531    #[test]
532    fn a_bare_rule_is_imported_and_used_as_it_comes() {
533        let source =
534            compile("bare-rule", r#"{"rules": ["lanekeep/no-default-export"]}"#).expect("compiles");
535        assert!(source.contains("import __lanekeepRule0 from 'lanekeep/no-default-export';"));
536        assert!(source.contains("rules: [__lanekeepRule0]"));
537    }
538
539    #[test]
540    fn a_configured_rule_is_called_with_its_options() {
541        // The distinction a rule author already makes between a rule and a rule factory.
542        // The entry module wraps the call in a guard that checks `typeof __r === 'function'`
543        // so a non-factory rule given options throws a descriptive error rather than
544        // `not a function` from QuickJS.
545        let source = compile(
546            "configured-rule",
547            r#"{"rules": [{"rule": "lanekeep/no-restricted-imports",
548                "options": {"restrictions": [{"module": "stripe"}]}}]}"#,
549        )
550        .expect("compiles");
551        assert!(
552            source.contains(r#"{"restrictions":[{"module":"stripe"}]}"#),
553            "the options literal must appear in the entry module:\n{source}"
554        );
555        assert!(
556            source.contains("__lanekeepRule0"),
557            "the rule binding must be referenced:\n{source}"
558        );
559    }
560
561    #[test]
562    fn a_local_rule_keeps_its_relative_path() {
563        let source =
564            compile("local-rule", r#"{"rules": ["./lanekeep/rules/mine.ts"]}"#).expect("compiles");
565        assert!(source.contains("from './lanekeep/rules/mine.ts';"));
566    }
567
568    #[test]
569    fn globs_and_namespaces_survive() {
570        let parsed = parse_config(
571            "globs-and-namespaces",
572            r#"{"include": ["src/**/*.go"], "exclude": ["**/*_test.go"], "namespaces": ["acme"]}"#,
573        )
574        .expect("parses");
575        assert_eq!(parsed.config.include, ["src/**/*.go"]);
576        assert_eq!(parsed.config.exclude, ["**/*_test.go"]);
577        assert_eq!(parsed.config.namespaces, ["acme"]);
578    }
579
580    /// The data half never becomes JavaScript, which is what un-coupling this path meant.
581    ///
582    /// Asserted on the generated module rather than on a call graph, because the module is
583    /// the whole of what the sandbox is asked to evaluate: if a value is not in it, no
584    /// amount of evaluation can reach it.
585    #[test]
586    fn no_configuration_data_reaches_the_entry_module() {
587        let source = compile(
588            "no-data-in-module",
589            r#"{"include": ["src/**/*.go"], "exclude": ["**/*_test.go"],
590                "namespaces": ["acme"], "severity": {"acme/a": "warn"},
591                "timeouts": {"rule": 100, "global": 5000},
592                "suppressions": {"requireExpiry": true, "maxExpiryDays": 30,
593                                 "forbidFileScope": true},
594                "rules": ["lanekeep/no-default-export"]}"#,
595        )
596        .expect("compiles");
597
598        for absent in [
599            "src/**/*.go",
600            "*_test.go",
601            "acme",
602            "warn",
603            "severity",
604            "timeouts",
605            "suppressions",
606            "requireExpiry",
607            "maxExpiryDays",
608            "forbidFileScope",
609            "include",
610            "exclude",
611        ] {
612            assert!(
613                !source.contains(absent),
614                "`{absent}` should not reach the sandbox: {source}"
615            );
616        }
617    }
618
619    #[test]
620    fn severity_and_timeouts_are_read_in_rust() {
621        let parsed = parse_config(
622            "severity-and-timeouts",
623            r#"{"severity": {"acme/a": "warn"}, "timeouts": {"rule": 100, "global": 5000}}"#,
624        )
625        .expect("parses");
626        assert_eq!(
627            parsed.config.severity.get("acme/a").map(String::as_str),
628            Some("warn")
629        );
630        assert_eq!(parsed.config.timeouts.rule, Some(100));
631        assert_eq!(parsed.config.timeouts.global, Some(5000));
632    }
633
634    #[test]
635    fn suppressions_are_read_in_rust() {
636        let parsed = parse_config(
637            "suppressions-in-rust",
638            r#"{"suppressions": {"requireExpiry": true, "maxExpiryDays": 30, "forbidFileScope": true}}"#,
639        )
640        .expect("parses");
641        assert!(parsed.config.suppressions.require_expiry);
642        assert_eq!(parsed.config.suppressions.max_expiry_days, Some(30));
643        assert!(parsed.config.suppressions.forbid_file_scope);
644    }
645
646    #[test]
647    fn a_builtin_resolves_to_its_name() {
648        let parsed =
649            parse_config("builtin", r#"{"rules": ["lanekeep/no-package-init"]}"#).expect("parses");
650        assert_eq!(
651            parsed.rules[0].reference,
652            RuleReference::Builtin("no-package-init".to_owned())
653        );
654        assert_eq!(parsed.rules[0].options, None);
655    }
656
657    #[test]
658    fn a_module_reference_keeps_its_specifier() {
659        let parsed =
660            parse_config("module-ref", r#"{"rules": ["./rules/mine.ts"]}"#).expect("parses");
661        assert_eq!(
662            parsed.rules[0].reference,
663            RuleReference::Module("./rules/mine.ts".to_owned())
664        );
665    }
666
667    /// A component is recognized by its extension and resolved against the rules root.
668    #[test]
669    fn a_component_reference_resolves_to_a_path() {
670        let parsed =
671            parse_config("component-path", r#"{"rules": ["./rules/mine.wasm"]}"#).expect("parses");
672        assert_eq!(
673            parsed.rules[0].reference,
674            RuleReference::Component(
675                fixture_dir("component-path")
676                    .join("rules")
677                    .join("mine.wasm")
678            )
679        );
680    }
681
682    /// A component is resolved in Rust, so it contributes no import and no rule object.
683    #[test]
684    fn a_component_reference_imports_nothing() {
685        let source = compile(
686            "component-placeholder",
687            r#"{"rules": ["./rules/mine.wasm"]}"#,
688        )
689        .expect("a component is resolved without the sandbox");
690        assert_eq!(source, "globalThis.__lanekeepConfig = { rules: [null] };\n");
691    }
692
693    /// And the reason its place is held rather than closed up.
694    #[test]
695    fn a_rule_after_a_component_keeps_its_position_in_the_array() {
696        // `RuleSpec::index` is spelled `__lanekeepConfig.rules[index].check(...)` by the
697        // engine, and it is also the position `build` numbers every rule at. Skipping the
698        // component would make rule 2 of the config rule 1 of the array, so every rule after a
699        // component would dispatch to its neighbor — a call that succeeds, and violations
700        // attributed to the wrong rule.
701        let source = compile(
702            "component-numbering",
703            r#"{"rules": ["./rules/mine.wasm", "lanekeep/no-default-export", "./mine.ts"]}"#,
704        )
705        .expect("compiles");
706
707        assert!(
708            source.contains("import __lanekeepRule1 from 'lanekeep/no-default-export';"),
709            "{source}"
710        );
711        assert!(
712            source.contains("rules: [null, __lanekeepRule1, __lanekeepRule2]"),
713            "{source}"
714        );
715    }
716
717    /// A built-in that ships as a component is one, and the config says nothing about it.
718    #[test]
719    fn a_built_in_with_a_component_resolves_to_one() {
720        let parsed = parse_config_with(
721            "builtin-component",
722            r#"{"rules": ["lanekeep/compiled"]}"#,
723            one_component,
724        )
725        .expect("parses");
726
727        assert_eq!(
728            parsed.rules[0].reference,
729            RuleReference::BuiltinComponent("compiled".to_owned())
730        );
731        // The specifier is unchanged, which is the property that matters to a user: a rule
732        // migrating from TypeScript to Rust must not need anybody to edit a config.
733        assert_eq!(parsed.rules[0].specifier, "lanekeep/compiled");
734    }
735
736    /// The same name, in a build where that rule is still TypeScript.
737    ///
738    /// The pair is the point. One config, two builds, and the only difference is which table
739    /// the rule is in — so an assertion on either alone would pass against a `classify` that
740    /// ignored the lookup entirely.
741    #[test]
742    fn the_same_built_in_is_a_module_when_no_component_ships() {
743        let parsed = parse_config_with(
744            "builtin-still-typescript",
745            r#"{"rules": ["lanekeep/compiled"]}"#,
746            no_components,
747        )
748        .expect("parses");
749
750        assert_eq!(
751            parsed.rules[0].reference,
752            RuleReference::Builtin("compiled".to_owned())
753        );
754    }
755
756    /// A built-in component contributes no import, exactly as a `.wasm` path does.
757    #[test]
758    fn a_built_in_component_imports_nothing() {
759        let source = {
760            let parsed = parse_config_with(
761                "builtin-component-placeholder",
762                r#"{"rules": ["lanekeep/compiled"]}"#,
763                one_component,
764            )
765            .expect("parses");
766            rules_module(&parsed.rules)
767        };
768        assert_eq!(source, "globalThis.__lanekeepConfig = { rules: [null] };\n");
769    }
770
771    /// And holds its place, for the same reason a `.wasm` path does.
772    ///
773    /// Asserted separately from `a_rule_after_a_component_keeps_its_position_in_the_array`
774    /// rather than trusted to it: the two reach the placeholder through different arms of
775    /// `classify`, and a `matches!` that named only one of them would leave this arm shifting
776    /// every later rule's handler by one — silently, since the call still succeeds.
777    #[test]
778    fn a_rule_after_a_built_in_component_keeps_its_position() {
779        let parsed = parse_config_with(
780            "builtin-component-numbering",
781            r#"{"rules": ["lanekeep/compiled", "lanekeep/no-default-export", "./mine.ts"]}"#,
782            one_component,
783        )
784        .expect("parses");
785        let source = rules_module(&parsed.rules);
786
787        assert!(
788            source.contains("import __lanekeepRule1 from 'lanekeep/no-default-export';"),
789            "{source}"
790        );
791        assert!(
792            source.contains("rules: [null, __lanekeepRule1, __lanekeepRule2]"),
793            "{source}"
794        );
795    }
796
797    /// The object form's `options` are data on the way through, whatever the reference is.
798    #[test]
799    fn options_are_carried_as_data() {
800        let parsed = parse_config(
801            "options-as-data",
802            r#"{"rules": [{"rule": "lanekeep/x", "options": {"limit": 3}}, {"rule": "lanekeep/y"}]}"#,
803        )
804        .expect("parses");
805        assert_eq!(
806            parsed.rules[0].options,
807            Some(serde_json::json!({"limit": 3}))
808        );
809        // The object form with no `options` key configures with `null`, which is not the
810        // same as the bare-string form and must not collapse into it.
811        assert_eq!(parsed.rules[1].options, Some(Value::Null));
812    }
813
814    #[test]
815    fn an_absent_severity_map_is_empty_rather_than_missing() {
816        let parsed = parse_config("absent-severity", r#"{"rules": []}"#).expect("parses");
817        assert!(parsed.config.severity.is_empty());
818        assert_eq!(parsed.config.timeouts.rule, None);
819        assert_eq!(parsed.config.timeouts.global, None);
820    }
821
822    #[test]
823    fn the_schema_key_is_accepted_and_ignored() {
824        // Editors read it to offer completion. Rejecting it would make the one thing that
825        // helps a user before lanekeep runs an error.
826        compile(
827            "schema-key",
828            r#"{"$schema": "https://example.com/s.json", "rules": []}"#,
829        )
830        .expect("a $schema key is not an error");
831    }
832
833    #[test]
834    fn an_unknown_key_is_refused() {
835        // A misspelled key is a setting that silently does nothing.
836        let error = compile("unknown-key", r#"{"includes": ["src/**"]}"#).expect_err("refused");
837        assert!(
838            format!("{error}").contains("includes"),
839            "the error should name the key: {error}"
840        );
841    }
842
843    #[test]
844    fn a_specifier_that_would_escape_the_import_is_refused() {
845        // Nothing legitimate needs a quote in a module specifier, and a config file is
846        // exactly the kind of thing a script generates one day.
847        for (name, hostile) in [
848            (
849                "hostile-quote",
850                r#"{"rules": ["a'; globalThis.x = 1; import b from 'c"]}"#,
851            ),
852            (
853                "hostile-newline",
854                "{\"rules\": [\"a\\nimport b from 'c'\"]}",
855            ),
856        ] {
857            compile(name, hostile).expect_err("a specifier with a quote or newline is refused");
858        }
859    }
860
861    #[test]
862    fn an_empty_specifier_is_refused() {
863        compile("empty-specifier", r#"{"rules": [""]}"#)
864            .expect_err("an empty specifier cannot import anything");
865    }
866
867    #[test]
868    fn malformed_json_is_reported_as_shape() {
869        let error = compile("malformed", "{ not json }").expect_err("refused");
870        assert!(matches!(error, ConfigError::Shape { .. }));
871    }
872
873    /// The published schema and the parser describe the same file.
874    ///
875    /// They drift in two directions and both are silent. A key the schema declares but the
876    /// parser refuses makes an editor bless a config that then fails to load. A key the
877    /// parser accepts but the schema omits makes an editor underline correct config, which
878    /// is how a user learns to ignore the schema.
879    ///
880    /// So this reads the shipped schema and checks every property it declares actually
881    /// parses, and that the set is exactly the expected one.
882    ///
883    /// **It catches one of those two directions, not both, and the docstring used to claim
884    /// otherwise.** A field added to the schema alone changes `declared` and fails here. A
885    /// field added to `JsonConfig` alone changes neither the schema file nor the list below,
886    /// so it passes — the list is a hand-maintained third copy, and the check is really
887    /// "schema versus list" rather than "schema versus parser". Closing it needs the struct's
888    /// own field names, which `serde` does not expose and which nothing here can read without
889    /// parsing this file's source. Left open deliberately, and stated, because a comment
890    /// claiming a guarantee that is not there is worse than the gap: it is the reason nobody
891    /// looks again.
892    #[test]
893    fn the_shipped_schema_and_the_parser_agree() {
894        let schema: Value =
895            serde_json::from_str(include_str!("../../../schema/lanekeep.schema.json"))
896                .expect("the shipped schema is valid JSON");
897
898        let mut declared: Vec<&str> = schema["properties"]
899            .as_object()
900            .expect("the schema declares properties")
901            .keys()
902            .map(String::as_str)
903            .collect();
904        declared.sort_unstable();
905
906        assert_eq!(
907            declared,
908            [
909                "$schema",
910                "exclude",
911                "include",
912                "namespaces",
913                "rules",
914                "severity",
915                "suppressions",
916                "timeouts"
917            ],
918            "the schema's fields changed; the parser below has to change with it"
919        );
920
921        // Every one of them, together, in a single config the parser must accept.
922        let everything = r#"{
923            "$schema": "https://example.com/s.json",
924            "include": ["src/**"],
925            "exclude": ["**/x"],
926            "namespaces": ["acme"],
927            "severity": {"acme/a": "warn"},
928            "timeouts": {"rule": 100, "global": 5000},
929            "suppressions": {"requireExpiry": true, "maxExpiryDays": 30,
930                             "forbidFileScope": true},
931            "rules": ["lanekeep/no-default-export"]
932        }"#;
933        compile("schema-agreement", everything)
934            .expect("the parser accepts every field the schema declares");
935    }
936
937    #[test]
938    fn json_is_recognized_by_extension() {
939        assert!(is_json(Path::new("lanekeep.json")));
940        assert!(is_json(Path::new("LANEKEEP.JSON")));
941        assert!(!is_json(Path::new("lanekeep.config.ts")));
942    }
943}