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}