Skip to main content

lanekeep_js/
loader.rs

1//! Module resolution and loading for rule files.
2//!
3//! Rules are ES modules. They may import from `lanekeep` and from each other; nothing else
4//! resolves. There is no `node_modules` lookup, no bare-specifier resolution, and no way to
5//! reach a file outside the rules root.
6//!
7//! # Confinement
8//!
9//! The rules root is canonicalized once at construction, and every resolved module is
10//! canonicalized and checked against it. Canonicalizing rather than comparing strings is
11//! what makes the check hold against symlinks: a link inside the root pointing at
12//! `/etc/passwd` resolves to a path outside the root and is rejected, where a lexical
13//! comparison would see an innocent-looking relative path and allow it.
14//!
15//! Traversal is also rejected lexically, before touching the filesystem, so `../../secrets`
16//! produces a message about escaping the root rather than a confusing "not found".
17
18use std::cell::RefCell;
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21use std::rc::Rc;
22use std::sync::Arc;
23
24use lanekeep_core::files::normalize;
25use lanekeep_lang::Language;
26use rquickjs::loader::{ImportAttributes, Loader, Resolver};
27use rquickjs::module::{Declared, Module};
28use rquickjs::{Ctx, Error as JsError};
29use thiserror::Error;
30
31use crate::typescript::strip_types;
32
33/// The specifier that resolves to lanekeep's own module.
34pub const HOST_MODULE: &str = "lanekeep";
35
36/// The host module.
37///
38/// `defineRule` and `defineConfig` are identity functions, and that is not a placeholder —
39/// it is what they are. Their entire purpose is to give the TypeScript compiler something
40/// to infer against in the author's editor, which costs nothing at runtime.
41const HOST_MODULE_SOURCE: &str = r"
42    export function defineRule(rule) { return rule; }
43    export function defineConfig(config) { return config; }
44";
45
46/// Resolves a built-in rule name to its embedded source.
47///
48/// A function rather than a dependency, so this crate stays unaware of which rules ship —
49/// `lanekeep-js` sits below `lanekeep-rules`, and reaching upward for them would invert the
50/// layering for no gain.
51pub type BuiltinSource = fn(&str) -> Option<&'static str>;
52
53/// Resolves a built-in rule name to its embedded component.
54///
55/// The sibling of [`BuiltinSource`], and it lives here for one reason: **`lanekeep/<name>` has
56/// to mean one thing.** A built-in shipped as a component is not importable, and the resolver
57/// is what has to say so — a name it did not know about would come back "no built-in rule by
58/// that name", which is what a typo looks like and would send a reader hunting for a
59/// misspelling that is not there.
60///
61/// Nothing in this crate loads the bytes. `lanekeep-config` reads them through
62/// [`RuleRoot::builtin_component`] when a config names one, exactly as it reads a `.wasm` path.
63/// Keeping both lookups on one value is what stops a name resolving to a module in one place
64/// and a component in another.
65///
66/// **The index is part of the answer.** One artifact hosts several rules — the four TypeScript
67/// built-ins share one — so bytes alone name a program rather than a rule. A lookup returning
68/// only bytes would leave the caller to run whichever rule the component enumerates first, and
69/// a wrong rule reporting is indistinguishable from a right one reporting.
70pub type BuiltinComponent = fn(&str) -> Option<(&'static [u8], u32)>;
71
72/// Resolves a built-in rule name to its component's embedded source map.
73///
74/// A third hook rather than a third element of [`BuiltinComponent`], because it answers a
75/// different kind of question and almost every caller has no use for it: what a map buys is where
76/// a *thrown* rule error is reported, and nothing else. A violation's position never passes
77/// through JavaScript at all, so a build that wired this to nothing would produce identical
78/// violations and a worse stack.
79///
80/// That is also its risk, and the reason `crates/lanekeep-rules/tests/source_maps.rs` asserts the
81/// wiring end to end rather than trusting it: a caller that sets [`RuleRoot::with_builtin_components`]
82/// and forgets this one gets rules that work and diagnostics that name `entry.js`, with nothing
83/// anywhere going red.
84///
85/// `None` is the ordinary answer. Every component built from Rust is one — a panicking rule traps,
86/// and a trap reaches the host with no stack to remap.
87pub type BuiltinComponentMap = fn(&str) -> Option<&'static [u8]>;
88
89/// Resolves whether a built-in rule name is *declared* as a component, regardless of whether
90/// its host ships.
91///
92/// The sibling of [`BuiltinComponent`] that answers a different question. `BuiltinComponent`
93/// answers `None` for a name that is not a component *and* for one whose row names a host this
94/// build does not ship; this hook tells the two apart, so the resolver can refuse the broken
95/// row as a lanekeep bug rather than report a misspelling or serve a stale source.
96pub type BuiltinComponentDeclared = fn(&str) -> bool;
97
98/// The longest a component-hosted built-in's name may be before refusing it stops being
99/// actionable.
100///
101/// Not a limit on rule names in general, and nothing enforces it here — it is a budget derived
102/// from one number this crate does not control. [`ResolveError::NotAModule`] reaches a user
103/// through QuickJS, which truncates a thrown error at 255 bytes, behind rquickjs's
104/// `Error resolving module '<specifier>' from '<path>': ` framing. The name is spent twice in
105/// that — once in the specifier, once inside the message — so each character costs two bytes of
106/// whatever is left for the *project's own path*, and what gets cut is the end of the message,
107/// which is the half telling the user what to do.
108///
109/// The name is the *rule's*, as a config writes it, not the artifact's: `typescript-builtins`
110/// hosts four rules and appears in no message.
111///
112/// # It was 15, and 21 is what the TypeScript built-ins cost
113///
114/// The four rules compiled into `typescript-builtins` are 17 to 21 characters —
115/// `no-restricted-imports` is the longest thing that can appear here — and the arithmetic is
116/// `35 + (9 + name) + path + (61 + name) <= 255`, so `path <= 150 - 2 * name`. The whole
117/// message survived beside a **120**-byte config path at 15 and survives beside a **108**-byte
118/// one at 21.
119///
120/// **That is a smaller path budget, accepted deliberately and stated here**, which is one of
121/// the two honest ways this constant may move; the other is shortening the message, which costs
122/// a user the wording that tells them what to do. 108 bytes still clears
123/// `/Users/alice/projects/acme/packages/checkout/lanekeep.config.ts` — 63 — with 45 to spare,
124/// and it does not clear everything: at 27 characters `no-mutable-default-argument` would leave
125/// 96, so migrating *that* rule is a decision to take here rather than a table edit.
126///
127/// `the_refusal_survives_quickjs_beside_a_long_path` derives it and `lanekeep-rules`'
128/// `every_component_name_fits_the_refusal_message` enforces it against the names that actually
129/// ship, because this crate sits below that one and cannot see them.
130pub const MAX_COMPONENT_NAME: usize = 21;
131
132/// The default: no built-ins, so a bare `lanekeep-js` resolves only project modules.
133fn no_builtins(_name: &str) -> Option<&'static str> {
134    None
135}
136
137/// The default: no built-in components.
138fn no_builtin_components(_name: &str) -> Option<(&'static [u8], u32)> {
139    None
140}
141
142/// The default: no source maps, which is also the answer for every component that has none.
143fn no_builtin_component_maps(_name: &str) -> Option<&'static [u8]> {
144    None
145}
146
147/// The default: no name is declared as a component.
148fn no_builtin_component_declared(_name: &str) -> bool {
149    false
150}
151
152/// The prefix a built-in specifier carries, as in `lanekeep/no-default-export`.
153const BUILTIN_PREFIX: &str = "lanekeep/";
154
155/// Extensions tried for a specifier that does not name one, in order.
156const EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "mjs"];
157
158/// Why a module specifier could not be resolved.
159#[derive(Debug, Clone, PartialEq, Eq, Error)]
160pub enum ResolveError {
161    /// A bare specifier, which would be an npm package.
162    #[error(
163        "cannot import `{specifier}`\n  \
164         rule modules run in a sandbox with no package resolution, so only `lanekeep` and \
165         relative paths starting with `./` or `../` can be imported\n  \
166         if this needs a package, inline what you need from it instead"
167    )]
168    BareSpecifier {
169        /// The specifier as written.
170        specifier: String,
171    },
172
173    /// The specifier resolves outside the rules root.
174    #[error(
175        "cannot import `{specifier}`\n  \
176         it resolves outside the rules directory, and rule modules may only import from \
177         within it"
178    )]
179    EscapesRoot {
180        /// The specifier as written.
181        specifier: String,
182    },
183
184    /// Nothing exists at the specifier.
185    #[error("cannot find module `{specifier}`\n  tried: {tried}")]
186    NotFound {
187        /// The specifier as written.
188        specifier: String,
189        /// The candidate paths that were tried.
190        tried: String,
191    },
192
193    /// The module exists but could not be read.
194    #[error("cannot read module `{path}`: {detail}")]
195    Unreadable {
196        /// The path that failed.
197        path: String,
198        /// The underlying reason.
199        detail: String,
200    },
201
202    /// The specifier names a built-in that ships as a component rather than as a module.
203    ///
204    /// Its own variant rather than a [`ResolveError::NotFound`] with a different string,
205    /// because the two are different facts and only one of them is the user's mistake. A
206    /// built-in that is not there is a typo; a built-in that is a component is spelled
207    /// correctly and simply cannot be imported, and telling a reader to check their spelling
208    /// would send them looking for something that is not wrong.
209    ///
210    /// **One line, carrying both the fact and the remedy, and that is not a style choice.**
211    /// QuickJS truncates a thrown error at 255 bytes, and a resolution failure reaches the user
212    /// as `Error resolving module '<specifier>' from '<absolute path>': <this>` — 35 bytes of
213    /// framing plus the specifier plus the *project's own path* before this message starts. So
214    /// a second line is not a place to put anything: it is the first thing a real path spends
215    /// the budget on.
216    ///
217    /// A two-line version of this shipped and was wrong in exactly that way. It front-loaded
218    /// the fact and put "name it in a `lanekeep.json`" second, which survived only for a config
219    /// path of 47 characters or fewer — so every user deep enough in a monorepo to have the
220    /// problem was told they had it and not what to do. There is no in-format escape either:
221    /// `packages/lanekeep/index.d.ts` types `rules` as `Rule[]`, so a `.config.ts` cannot name
222    /// a rule by string. Converting to JSON is the only route, and that was the sentence being
223    /// cut.
224    ///
225    /// The specifier is repeated here even though the framing already carries it, because this
226    /// is a public error type and a sibling variant read alone names its subject. It costs the
227    /// budget twice over, which is why the rest is as short as it is.
228    ///
229    /// `the_refusal_survives_quickjs_beside_a_long_path` is what holds the arithmetic. See
230    /// `AGENTS.md`.
231    #[error("`lanekeep/{name}` is a rule component; name it in a `lanekeep.json`")]
232    NotAModule {
233        /// The built-in's name, without the `lanekeep/` prefix.
234        name: String,
235    },
236
237    /// The specifier names a built-in that is declared as a component, but the component's
238    /// host is missing from the built-in table.
239    ///
240    /// Its own variant rather than a [`ResolveError::NotFound`] with a different string,
241    /// because the two are different facts and only one of them is the user's mistake. A
242    /// built-in that is not there is a typo; a built-in whose component row is broken is a
243    /// lanekeep bug, and telling a reader to check their spelling would send them looking for
244    /// something that is not wrong.
245    ///
246    /// **One line, carrying both the fact and the remedy, on the same terms as
247    /// [`ResolveError::NotAModule`].** QuickJS truncates a thrown error at 255 bytes, so the
248    /// message is budgeted the same way — see [`MAX_COMPONENT_NAME`] and
249    /// `the_broken_row_refusal_survives_quickjs_beside_a_long_path`.
250    #[error("`lanekeep/{name}` is a component missing its host — lanekeep bug")]
251    ComponentHostMissing {
252        /// The built-in's name, without the `lanekeep/` prefix.
253        name: String,
254    },
255}
256
257/// Where rule modules live, and what may be imported.
258#[derive(Debug, Clone)]
259pub struct RuleRoot {
260    root: PathBuf,
261    builtins: BuiltinSource,
262    builtin_components: BuiltinComponent,
263    builtin_component_maps: BuiltinComponentMap,
264    builtin_component_declared: BuiltinComponentDeclared,
265}
266
267impl RuleRoot {
268    /// Anchor resolution at a directory.
269    ///
270    /// # Errors
271    ///
272    /// Fails if the directory does not exist or cannot be canonicalized.
273    pub fn new(root: impl AsRef<Path>) -> Result<Self, ResolveError> {
274        let root = root.as_ref();
275        let canonical = root.canonicalize().map_err(|e| ResolveError::Unreadable {
276            path: root.display().to_string(),
277            detail: e.to_string(),
278        })?;
279        Ok(Self {
280            root: canonical,
281            builtins: no_builtins,
282            builtin_components: no_builtin_components,
283            builtin_component_maps: no_builtin_component_maps,
284            builtin_component_declared: no_builtin_component_declared,
285        })
286    }
287
288    /// Serve built-in rules from embedded sources.
289    ///
290    /// Built-ins resolve before anything on disk, so a project file cannot shadow one —
291    /// a rule whose behavior depended on whether a same-named file happened to exist
292    /// would be impossible to reason about.
293    #[must_use]
294    pub const fn with_builtins(mut self, builtins: BuiltinSource) -> Self {
295        self.builtins = builtins;
296        self
297    }
298
299    /// Serve built-in rules that ship as components from embedded bytes.
300    ///
301    /// Beside [`RuleRoot::with_builtins`] rather than replacing it: a built-in is one or the
302    /// other, and which one it is is not something a config writes or a user chooses. Both
303    /// resolve under the same `lanekeep/` prefix and both resolve before the filesystem.
304    #[must_use]
305    pub const fn with_builtin_components(mut self, components: BuiltinComponent) -> Self {
306        self.builtin_components = components;
307        self
308    }
309
310    /// Serve the source maps of the built-ins that ship as components.
311    ///
312    /// Separate from [`RuleRoot::with_builtin_components`] on the terms [`BuiltinComponentMap`]
313    /// gives: a map answers a diagnostics question, most components have none, and a caller that
314    /// wires one hook and not the other loses a stack rather than a rule.
315    #[must_use]
316    pub const fn with_builtin_component_maps(mut self, maps: BuiltinComponentMap) -> Self {
317        self.builtin_component_maps = maps;
318        self
319    }
320
321    /// Serve the "declared as a component" table, so a name whose component row is broken is
322    /// refused as a lanekeep bug rather than served from a stale source or reported as a typo.
323    ///
324    /// Beside [`RuleRoot::with_builtin_components`]: the component lookup answers `None` for a
325    /// name that is not a component *and* for one whose host is missing, and this hook is what
326    /// tells the two apart.
327    #[must_use]
328    pub const fn with_builtin_component_declared(
329        mut self,
330        declared: BuiltinComponentDeclared,
331    ) -> Self {
332        self.builtin_component_declared = declared;
333        self
334    }
335
336    /// The source map of the component behind a built-in's name, or `None`.
337    ///
338    /// Asked by `lanekeep-config` beside [`RuleRoot::builtin_component`], with the same name.
339    #[must_use]
340    pub fn builtin_component_map(&self, name: &str) -> Option<&'static [u8]> {
341        (self.builtin_component_maps)(name)
342    }
343
344    /// The component behind a built-in's name and the index it sits at, or `None`.
345    ///
346    /// `lanekeep-config` asks this when a config names `lanekeep/<name>`, because a component
347    /// is resolved in Rust and never crosses into the sandbox. Nothing in this crate loads it.
348    #[must_use]
349    pub fn builtin_component(&self, name: &str) -> Option<(&'static [u8], u32)> {
350        (self.builtin_components)(name)
351    }
352
353    /// The lookup itself, for a caller that classifies many names at once.
354    #[must_use]
355    pub const fn builtin_components(&self) -> BuiltinComponent {
356        self.builtin_components
357    }
358
359    /// The canonical root.
360    #[must_use]
361    pub fn path(&self) -> &Path {
362        &self.root
363    }
364
365    /// Resolve a specifier against the module that imported it.
366    ///
367    /// # Errors
368    ///
369    /// Returns [`ResolveError`] for a bare specifier, an escape from the root, or a
370    /// specifier matching no file.
371    pub fn resolve(&self, base: &str, specifier: &str) -> Result<PathBuf, ResolveError> {
372        if specifier == HOST_MODULE {
373            return Ok(PathBuf::from(HOST_MODULE));
374        }
375
376        // Built-ins resolve before the filesystem is consulted at all.
377        if let Some(name) = specifier.strip_prefix(BUILTIN_PREFIX) {
378            // A built-in that ships as a component is spelled correctly and is still not
379            // importable, so it is refused on its own terms.
380            //
381            // **Asked before the source lookup, and the order is the whole of the guarantee.**
382            // A name can be both: the four TypeScript rules compiled into one component keep
383            // their sources, because that is what the component was built from and what their
384            // tests run through this engine. Asking the source first would answer an `import`
385            // with the QuickJS copy while a `lanekeep.json` ran the component — one id, two
386            // programs, and nothing in the output to say which one reported. The component is
387            // what ships, so the component is the answer, and the other spelling is refused
388            // rather than quietly served.
389            if (self.builtin_components)(name).is_some() {
390                return Err(ResolveError::NotAModule {
391                    name: name.to_owned(),
392                });
393            }
394            // A name declared as a component but whose host is missing is a broken table, not
395            // a misspelling and not a module. Refused here — before the source lookup — so a
396            // stale TypeScript copy is never substituted for the component that should have
397            // shipped.
398            if (self.builtin_component_declared)(name) {
399                return Err(ResolveError::ComponentHostMissing {
400                    name: name.to_owned(),
401                });
402            }
403            if (self.builtins)(name).is_some() {
404                return Ok(PathBuf::from(specifier));
405            }
406            return Err(ResolveError::NotFound {
407                specifier: specifier.to_owned(),
408                tried: "no built-in rule by that name".to_owned(),
409            });
410        }
411
412        // The entry module arrives as an already-resolved absolute path, because that is
413        // what the caller hands the engine to import. Accepting one is therefore necessary,
414        // but only for the entry: an empty base means nothing imported this.
415        //
416        // A rule writing `import '/etc/passwd'` always has a base — the importing module's
417        // own path — so it falls through to the bare-specifier rejection below rather than
418        // through this door. Containment is still checked either way.
419        if Path::new(specifier).is_absolute() {
420            if !base.is_empty() {
421                return Err(ResolveError::BareSpecifier {
422                    specifier: specifier.to_owned(),
423                });
424            }
425            return self.resolve_within(specifier, &normalize(Path::new(specifier)));
426        }
427
428        if !specifier.starts_with('.') {
429            return Err(ResolveError::BareSpecifier {
430                specifier: specifier.to_owned(),
431            });
432        }
433
434        let base_dir = if base == HOST_MODULE || base.is_empty() {
435            self.root.clone()
436        } else {
437            Path::new(base)
438                .parent()
439                .map_or_else(|| self.root.clone(), Path::to_path_buf)
440        };
441
442        self.resolve_within(specifier, &normalize(&base_dir.join(specifier)))
443    }
444
445    /// Confine an already-joined path to the root, and hand back its canonical form.
446    ///
447    /// **The containment rules, in one place, for callers that resolved a path some other
448    /// way.** [`RuleRoot::resolve`] uses it for the candidate it found; `lanekeep-config` uses
449    /// it for a `.wasm` rule reference, which is joined against the root by
450    /// `json::classify` and never goes near module resolution. Two sets of confinement rules
451    /// would be two things to keep right, and the second one is always the one that is wrong:
452    /// a lexical check alone looks complete and does not see a symlink.
453    ///
454    /// Both checks, in this order, and the order is the point. The lexical one fires whatever
455    /// is on disk, so `../../secrets` is refused identically whether or not it is there — an
456    /// error that depended on that would tell a reader something about the filesystem instead
457    /// of about their config. The canonical one is what sees through a symlink, and it can
458    /// only be made after the file is known to exist.
459    ///
460    /// # Errors
461    ///
462    /// Returns [`ResolveError::EscapesRoot`] when the path is outside the root either
463    /// lexically or after canonicalization, and [`ResolveError::Unreadable`] when it cannot be
464    /// canonicalized — which for a path that is simply not there is what "not found" looks
465    /// like at this level.
466    pub fn confine(&self, specifier: &str, joined: &Path) -> Result<PathBuf, ResolveError> {
467        if !joined.starts_with(&self.root) {
468            return Err(ResolveError::EscapesRoot {
469                specifier: specifier.to_owned(),
470            });
471        }
472
473        // Canonicalize the file that was actually found. This is the check that holds against
474        // symlinks — the lexical test above cannot see through one.
475        let canonical = joined
476            .canonicalize()
477            .map_err(|e| ResolveError::Unreadable {
478                path: joined.display().to_string(),
479                detail: e.to_string(),
480            })?;
481        if !canonical.starts_with(&self.root) {
482            return Err(ResolveError::EscapesRoot {
483                specifier: specifier.to_owned(),
484            });
485        }
486        Ok(canonical)
487    }
488
489    /// Find a file for an already-joined path, enforcing containment.
490    fn resolve_within(&self, specifier: &str, joined: &Path) -> Result<PathBuf, ResolveError> {
491        // Ahead of the candidate loop as well as inside [`RuleRoot::confine`], because a
492        // traversal that matches no file at all must still be reported as an escape rather
493        // than as "nothing found".
494        if !joined.starts_with(&self.root) {
495            return Err(ResolveError::EscapesRoot {
496                specifier: specifier.to_owned(),
497            });
498        }
499
500        let mut tried = Vec::new();
501        for candidate in candidates(joined) {
502            tried.push(candidate.display().to_string());
503            if !candidate.is_file() {
504                continue;
505            }
506            return self.confine(specifier, &candidate);
507        }
508
509        Err(ResolveError::NotFound {
510            specifier: specifier.to_owned(),
511            tried: tried.join(", "),
512        })
513    }
514
515    /// Read a resolved module, stripping types when it is TypeScript.
516    ///
517    /// Containment is re-checked here rather than trusted from [`RuleRoot::resolve`].
518    /// Reading is the operation that actually touches a file, so it should be the thing
519    /// that enforces the boundary — otherwise the guarantee depends on every caller having
520    /// gone through the resolver first, which is exactly the sort of assumption that holds
521    /// until someone adds a second caller.
522    ///
523    /// # Errors
524    ///
525    /// Returns [`ResolveError::EscapesRoot`] if the path is outside the root, or
526    /// [`ResolveError::Unreadable`] if the file cannot be read or stripping rejects it.
527    pub fn read(
528        &self,
529        path: &Path,
530        typescript: &dyn Language,
531        javascript: &dyn Language,
532    ) -> Result<String, ResolveError> {
533        if path == Path::new(HOST_MODULE) {
534            return Ok(HOST_MODULE_SOURCE.to_owned());
535        }
536
537        if let Some(name) = path.to_str().and_then(|p| p.strip_prefix(BUILTIN_PREFIX))
538            && let Some(source) = (self.builtins)(name)
539        {
540            // Built-ins are TypeScript like any other rule, so they go through the same
541            // stripping — including its verification step. A built-in that failed to strip
542            // would be a build-time bug in this repository, and should look like one.
543            return strip_types(typescript, javascript, source).map_err(|e| {
544                ResolveError::Unreadable {
545                    path: path.display().to_string(),
546                    detail: e.to_string(),
547                }
548            });
549        }
550
551        let canonical = path.canonicalize().map_err(|e| ResolveError::Unreadable {
552            path: path.display().to_string(),
553            detail: e.to_string(),
554        })?;
555        if !canonical.starts_with(&self.root) {
556            return Err(ResolveError::EscapesRoot {
557                specifier: path.display().to_string(),
558            });
559        }
560
561        let source = std::fs::read_to_string(path).map_err(|e| ResolveError::Unreadable {
562            path: path.display().to_string(),
563            detail: e.to_string(),
564        })?;
565
566        // Plain JavaScript is passed through untouched rather than run through the
567        // stripper, which would only be able to fail on it.
568        let is_typescript = path
569            .extension()
570            .and_then(|e| e.to_str())
571            .is_some_and(|e| matches!(e, "ts" | "tsx" | "mts" | "cts"));
572        if !is_typescript {
573            return Ok(source);
574        }
575
576        strip_types(typescript, javascript, &source).map_err(|e| ResolveError::Unreadable {
577            path: path.display().to_string(),
578            detail: e.to_string(),
579        })
580    }
581}
582
583/// Candidate files for a specifier, in resolution order.
584fn candidates(base: &Path) -> Vec<PathBuf> {
585    let mut out = Vec::new();
586
587    // An explicit extension is taken at face value.
588    if base.extension().is_some() {
589        out.push(base.to_path_buf());
590    }
591
592    for extension in EXTENSIONS {
593        out.push(base.with_extension(extension));
594    }
595    for extension in EXTENSIONS {
596        out.push(base.join(format!("index.{extension}")));
597    }
598
599    out
600}
601
602/// Adapts [`RuleRoot`] to the engine's resolver interface.
603#[derive(Debug, Clone)]
604pub struct RuleResolver {
605    root: RuleRoot,
606}
607
608impl RuleResolver {
609    /// Build a resolver for a rules root.
610    #[must_use]
611    pub const fn new(root: RuleRoot) -> Self {
612        Self { root }
613    }
614}
615
616impl Resolver for RuleResolver {
617    fn resolve(
618        &mut self,
619        _ctx: &Ctx<'_>,
620        base: &str,
621        name: &str,
622        _attributes: Option<ImportAttributes<'_>>,
623    ) -> rquickjs::Result<String> {
624        match self.root.resolve(base, name) {
625            Ok(path) => Ok(path.display().to_string()),
626            // The engine's error channel carries only a message, so the diagnostic is
627            // rendered here rather than lost.
628            Err(err) => Err(JsError::new_resolving_message(
629                base.to_owned(),
630                name.to_owned(),
631                err.to_string(),
632            )),
633        }
634    }
635}
636
637/// Every module the loader read, with the source it read.
638///
639/// This is what makes `ruleset_hash` cover the whole import graph rather than only the
640/// entry files. A rule that imports a shared helper has to invalidate when that helper
641/// changes, and the only component that knows the helper was involved is the loader.
642///
643/// Ordered, so the hash derived from it does not depend on load order — which varies with
644/// import structure and is not something a user changed.
645pub type LoadedModules = Rc<RefCell<BTreeMap<PathBuf, String>>>;
646
647/// Adapts [`RuleRoot`] to the engine's loader interface.
648///
649/// `Debug` is hand-written because `Arc<dyn Language>` is not `Debug`, and requiring it on
650/// the trait would burden every language implementation for one impl here.
651#[derive(Clone)]
652pub struct RuleLoader {
653    root: RuleRoot,
654    typescript: Arc<dyn Language>,
655    javascript: Arc<dyn Language>,
656    loaded: LoadedModules,
657}
658
659impl std::fmt::Debug for RuleLoader {
660    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
661        f.debug_struct("RuleLoader")
662            .field("root", &self.root)
663            .field("typescript", &self.typescript.id())
664            .field("javascript", &self.javascript.id())
665            .field("loaded", &self.loaded.borrow().len())
666            .finish()
667    }
668}
669
670impl RuleLoader {
671    /// Build a loader for a rules root.
672    ///
673    /// The languages are supplied rather than assumed so this crate does not have to know
674    /// which grammars exist.
675    #[must_use]
676    pub fn new(
677        root: RuleRoot,
678        typescript: Arc<dyn Language>,
679        javascript: Arc<dyn Language>,
680    ) -> Self {
681        Self {
682            root,
683            typescript,
684            javascript,
685            loaded: Rc::new(RefCell::new(BTreeMap::new())),
686        }
687    }
688
689    /// A handle on what this loader has read, for hashing the rule graph.
690    #[must_use]
691    pub fn loaded(&self) -> LoadedModules {
692        Rc::clone(&self.loaded)
693    }
694}
695
696impl Loader for RuleLoader {
697    fn load<'js>(
698        &mut self,
699        ctx: &Ctx<'js>,
700        name: &str,
701        _attributes: Option<ImportAttributes<'js>>,
702    ) -> rquickjs::Result<Module<'js, Declared>> {
703        let source = self
704            .root
705            .read(
706                Path::new(name),
707                self.typescript.as_ref(),
708                self.javascript.as_ref(),
709            )
710            .map_err(|err| JsError::new_loading_message(name.to_owned(), err.to_string()))?;
711
712        // Recorded before declaring, so a module that fails to compile still counts as
713        // part of the graph. Otherwise fixing the compile error would not invalidate.
714        self.loaded
715            .borrow_mut()
716            .insert(PathBuf::from(name), source.clone());
717
718        Module::declare(ctx.clone(), name, source)
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use std::fs;
725
726    use lanekeep_lang_js::{JavaScript, TypeScript};
727
728    use super::*;
729
730    /// A rules directory laid out for a test, cleaned up on drop.
731    struct Fixture {
732        dir: PathBuf,
733    }
734
735    impl Fixture {
736        fn new(name: &str, files: &[(&str, &str)]) -> Self {
737            let dir = std::env::temp_dir().join(format!("lanekeep-loader-{name}"));
738            let _ = fs::remove_dir_all(&dir);
739            fs::create_dir_all(&dir).expect("creates fixture dir");
740            for (path, contents) in files {
741                let full = dir.join(path);
742                if let Some(parent) = full.parent() {
743                    fs::create_dir_all(parent).expect("creates parent");
744                }
745                fs::write(&full, contents).expect("writes fixture file");
746            }
747            Self { dir }
748        }
749
750        fn root(&self) -> RuleRoot {
751            RuleRoot::new(&self.dir).expect("canonicalizes")
752        }
753
754        fn entry(&self, name: &str) -> String {
755            self.dir
756                .join(name)
757                .canonicalize()
758                .expect("exists")
759                .display()
760                .to_string()
761        }
762    }
763
764    impl Drop for Fixture {
765        fn drop(&mut self) {
766            let _ = fs::remove_dir_all(&self.dir);
767        }
768    }
769
770    /// Stands in for the real built-in table, so these tests do not depend on which rules
771    /// happen to ship.
772    fn stub_builtins(name: &str) -> Option<&'static str> {
773        match name {
774            "always" => Some("export default { id: 'lanekeep/always' } satisfies unknown;"),
775            // A rule with a source *and* a component: the ordinary shape of a TypeScript rule
776            // compiled ahead of time, where the source is what the artifact was built from and
777            // is kept for the tests that run it through this engine.
778            "compiled-from-source" => {
779                Some("export default { id: 'lanekeep/compiled-from-source' } satisfies unknown;")
780            }
781            // A name that is *declared* as a component but whose host is missing, and that
782            // still has a source — the exact shape the broken-table refusal must not serve.
783            "broken-row" => Some("export default { id: 'lanekeep/broken-row' } satisfies unknown;"),
784            _ => None,
785        }
786    }
787
788    #[test]
789    fn resolves_a_built_in_by_specifier() {
790        let fixture = Fixture::new("builtin-resolve", &[("a.ts", "export const a = 1;")]);
791        let root = fixture.root().with_builtins(stub_builtins);
792        assert_eq!(
793            root.resolve("", "lanekeep/always").expect("resolves"),
794            Path::new("lanekeep/always")
795        );
796    }
797
798    #[test]
799    fn an_unknown_built_in_is_not_found() {
800        // Not "bare specifier": the `lanekeep/` prefix says what the author meant, and an
801        // error about npm resolution would send them somewhere useless.
802        let fixture = Fixture::new("builtin-unknown", &[("a.ts", "export const a = 1;")]);
803        let root = fixture.root().with_builtins(stub_builtins);
804        let error = root
805            .resolve("", "lanekeep/no-such-rule")
806            .expect_err("does not resolve");
807        assert!(
808            matches!(error, ResolveError::NotFound { .. }),
809            "expected NotFound, got {error:?}"
810        );
811        assert!(error.to_string().contains("built-in"), "{error}");
812    }
813
814    /// Stands in for the real component table, on the same terms as [`stub_builtins`].
815    fn stub_builtin_components(name: &str) -> Option<(&'static [u8], u32)> {
816        match name {
817            "compiled" => Some((b"\0asm\x01\x00\x00\x00", 0)),
818            // Also served by `stub_builtins`, and at a non-zero index so that a caller
819            // discarding the index cannot pass by accident.
820            "compiled-from-source" => Some((b"\0asm\x01\x00\x00\x00", 3)),
821            _ => None,
822        }
823    }
824
825    /// Stands in for the real "declared as a component" table, on the same terms as
826    /// [`stub_builtin_components`]. `broken-row` is declared but absent from the component
827    /// table, which is the broken-table state the resolver must refuse rather than serve.
828    fn stub_builtin_component_declared(name: &str) -> bool {
829        matches!(name, "compiled" | "compiled-from-source" | "broken-row")
830    }
831
832    #[test]
833    fn a_built_in_that_is_a_component_is_refused_as_a_module() {
834        // And refused *as itself*. A component-backed built-in is spelled correctly, so the
835        // "no built-in rule by that name" message would send its author looking for a
836        // misspelling that is not there. The two facts are different and only one is a typo.
837        let fixture = Fixture::new("builtin-component", &[("a.ts", "export const a = 1;")]);
838        let root = fixture
839            .root()
840            .with_builtins(stub_builtins)
841            .with_builtin_components(stub_builtin_components);
842
843        let error = root
844            .resolve("", "lanekeep/compiled")
845            .expect_err("a component is not importable");
846
847        assert!(
848            matches!(&error, ResolveError::NotAModule { name } if name == "compiled"),
849            "expected NotAModule, got {error:?}"
850        );
851        let rendered = error.to_string();
852        assert!(rendered.contains("lanekeep/compiled"), "{rendered}");
853        assert!(rendered.contains("component"), "{rendered}");
854        assert!(
855            rendered.contains("lanekeep.json"),
856            "the message has to name the format that can reach it: {rendered}"
857        );
858    }
859
860    /// The whole message reaches a user whose project is nested as deeply as a real one.
861    ///
862    /// **The constraint is not "the message is short", and a test asserting that passed against
863    /// the bug it was written for.** QuickJS copies a thrown error into a 255-byte buffer, and
864    /// rquickjs hands it `Error resolving module '<specifier>' from '<path>': <message>` — so
865    /// what has to hold is `35 + specifier + path + message <= 255`, and the *project's path* is
866    /// two of those four terms' worth of budget that this crate does not control. A previous
867    /// version of this assertion pinned the first line at 80 bytes, which is a quantity nothing
868    /// depends on: the message it was guarding lost its remedy at any config path beyond 47
869    /// characters, and the assertion was green throughout.
870    ///
871    /// **The rule's name is spent twice** — once in rquickjs's framing, once inside the message
872    /// — so every character of it costs two of the path budget. A name of 21 characters clears a
873    /// 108-byte path; at 15 it cleared 120, and `no-mutable-default-argument`, at 27, would
874    /// clear only 96. So this is stated as a *name-length* budget rather than measured against
875    /// whichever names happen to ship, which this crate cannot see anyway: it sits below
876    /// `lanekeep-rules` deliberately.
877    ///
878    /// `PATH` moved with `MAX_COMPONENT_NAME` rather than staying put, and that is the point of
879    /// the pair: the constraint is one inequality with two knobs, so raising the name budget
880    /// without lowering the path budget would be asserting something false. See
881    /// [`MAX_COMPONENT_NAME`] for why 108 was judged enough.
882    ///
883    /// `lanekeep-rules`' `every_component_name_fits_the_refusal_message` is the other half, and
884    /// it is what makes this test's premise true rather than assumed. Neither is any use alone:
885    /// this one would pass while a longer name silently ate a user's remedy, and that one would
886    /// be enforcing a number with no derivation behind it.
887    #[test]
888    fn the_refusal_survives_quickjs_beside_a_long_path() {
889        /// What QuickJS will keep, terminator excluded.
890        const BUDGET: usize = 255;
891        /// A project path this message must not be truncated beside. Roughly
892        /// `/Users/<name>/work/<org>-monorepo/apps/<app>/packages/<pkg>/lanekeep.config.ts`.
893        const PATH: usize = 108;
894
895        // Not a literal: this is rquickjs's framing with both holes empty, so the constant
896        // cannot drift from the string it is measuring.
897        let framing = "Error resolving module '' from '': ".len();
898
899        let name = "x".repeat(MAX_COMPONENT_NAME);
900        let specifier = format!("lanekeep/{name}").len();
901        let message = ResolveError::NotAModule { name }.to_string();
902
903        let total = framing + specifier + PATH + message.len();
904        assert!(
905            total <= BUDGET,
906            "QuickJS keeps {BUDGET} bytes and this needs {total} beside a {PATH}-byte path \
907             ({} of them the message): the remedy is what gets cut\n  {message}",
908            message.len(),
909        );
910    }
911
912    #[test]
913    fn a_component_wins_over_a_source_of_the_same_name() {
914        // The one that decides what `lanekeep/<name>` means when both lookups answer, which is
915        // the ordinary shape of a rule authored in TypeScript and compiled ahead of time.
916        //
917        // **The failure this is written against is silent.** With the lookups asked the other
918        // way round the import succeeds, the sandbox evaluates the author's source, and the
919        // run reports — correctly, plausibly, and from a different program than the one a
920        // `lanekeep.json` would have run for the same name. Nothing in the output distinguishes
921        // them, so the only place this can be caught is here.
922        let fixture = Fixture::new("builtin-both", &[("a.ts", "export const a = 1;")]);
923        let root = fixture
924            .root()
925            .with_builtins(stub_builtins)
926            .with_builtin_components(stub_builtin_components);
927
928        let error = root
929            .resolve("", "lanekeep/compiled-from-source")
930            .expect_err("the component is what ships, so the import is refused");
931        assert!(
932            matches!(&error, ResolveError::NotAModule { name } if name == "compiled-from-source"),
933            "expected NotAModule, got {error:?}"
934        );
935
936        // And the source is still there to be read by whatever built the component, which is
937        // why the two lookups can disagree at all.
938        assert!(
939            stub_builtins("compiled-from-source").is_some(),
940            "the fixture must have both, or this test asserts nothing"
941        );
942    }
943
944    #[test]
945    fn a_declared_component_whose_host_is_missing_is_refused_not_served() {
946        // The broken-table state, and the decision it forces. `broken-row` is declared as a
947        // component (the declared hook answers) but its host is missing (the component hook
948        // does not), and it *also* has a source — so without the declared check this name
949        // would be silently served from its stale TypeScript. The refusal is what makes the
950        // check load-bearing rather than a reworded NotFound.
951        let fixture = Fixture::new("builtin-broken-row", &[("a.ts", "export const a = 1;")]);
952        let root = fixture
953            .root()
954            .with_builtins(stub_builtins)
955            .with_builtin_components(stub_builtin_components)
956            .with_builtin_component_declared(stub_builtin_component_declared);
957
958        let error = root
959            .resolve("", "lanekeep/broken-row")
960            .expect_err("a broken component row is refused, not served");
961
962        assert!(
963            matches!(&error, ResolveError::ComponentHostMissing { name } if name == "broken-row"),
964            "expected ComponentHostMissing, got {error:?}"
965        );
966        assert!(
967            stub_builtins("broken-row").is_some(),
968            "the fixture must have a source, or this test asserts nothing"
969        );
970    }
971
972    #[test]
973    fn the_broken_row_refusal_survives_quickjs_beside_a_long_path() {
974        // The same budget as `the_refusal_survives_quickjs_beside_a_long_path`, held for the
975        // broken-row refusal too: it reaches a user through the same QuickJS framing, so a
976        // message that does not fit beside a real path loses its remedy to truncation.
977        const BUDGET: usize = 255;
978        const PATH: usize = 108;
979
980        let framing = "Error resolving module '' from '': ".len();
981        let name = "x".repeat(MAX_COMPONENT_NAME);
982        let specifier = format!("lanekeep/{name}").len();
983        let message = ResolveError::ComponentHostMissing { name }.to_string();
984
985        let total = framing + specifier + PATH + message.len();
986        assert!(
987            total <= BUDGET,
988            "QuickJS keeps {BUDGET} bytes and this needs {total} beside a {PATH}-byte path \
989             ({} of them the message): the remedy is what gets cut\n  {message}",
990            message.len(),
991        );
992    }
993
994    #[test]
995    fn an_unknown_name_is_still_not_found_when_components_ship() {
996        // The component lookup must not turn every miss into "it is a component".
997        let fixture = Fixture::new("builtin-component-miss", &[("a.ts", "export const a = 1;")]);
998        let root = fixture
999            .root()
1000            .with_builtins(stub_builtins)
1001            .with_builtin_components(stub_builtin_components);
1002
1003        let error = root
1004            .resolve("", "lanekeep/no-such-rule")
1005            .expect_err("does not resolve");
1006
1007        assert!(
1008            matches!(error, ResolveError::NotFound { .. }),
1009            "expected NotFound, got {error:?}"
1010        );
1011    }
1012
1013    #[test]
1014    fn a_component_is_reachable_by_name_without_being_importable() {
1015        let fixture = Fixture::new(
1016            "builtin-component-bytes",
1017            &[("a.ts", "export const a = 1;")],
1018        );
1019        let root = fixture
1020            .root()
1021            .with_builtin_components(stub_builtin_components);
1022
1023        assert_eq!(
1024            root.builtin_component("compiled"),
1025            Some((b"\0asm\x01\x00\x00\x00".as_slice(), 0))
1026        );
1027        // The index travels with the bytes, so a rule of a shared component is reachable as
1028        // itself rather than as whichever rule that artifact enumerates first.
1029        assert_eq!(
1030            root.builtin_component("compiled-from-source"),
1031            Some((b"\0asm\x01\x00\x00\x00".as_slice(), 3))
1032        );
1033        assert_eq!(root.builtin_component("always"), None);
1034    }
1035
1036    #[test]
1037    fn a_file_cannot_shadow_a_built_in() {
1038        // A rules directory containing `lanekeep/always.ts` must not change what the
1039        // specifier means. A rule whose behavior depended on whether a same-named file
1040        // happened to exist would be unreasonable to debug.
1041        let fixture = Fixture::new(
1042            "builtin-shadow",
1043            &[("lanekeep/always.ts", "export default 'the wrong one';")],
1044        );
1045        let root = fixture.root().with_builtins(stub_builtins);
1046        let resolved = root.resolve("", "lanekeep/always").expect("resolves");
1047        assert_eq!(resolved, Path::new("lanekeep/always"));
1048
1049        let source = root
1050            .read(&resolved, &TypeScript, &JavaScript)
1051            .expect("reads");
1052        assert!(
1053            !source.contains("the wrong one"),
1054            "a project file shadowed a built-in: {source}"
1055        );
1056    }
1057
1058    #[test]
1059    fn a_built_in_is_stripped_of_its_types() {
1060        let fixture = Fixture::new("builtin-strip", &[("a.ts", "export const a = 1;")]);
1061        let root = fixture.root().with_builtins(stub_builtins);
1062        let source = root
1063            .read(Path::new("lanekeep/always"), &TypeScript, &JavaScript)
1064            .expect("reads");
1065        assert!(
1066            !source.contains("satisfies"),
1067            "type syntax survived stripping: {source}"
1068        );
1069    }
1070
1071    #[test]
1072    fn built_ins_are_absent_unless_provided() {
1073        // The default. A crate embedding `lanekeep-js` without the rules crate resolves
1074        // project modules only, rather than silently resolving names to nothing.
1075        let fixture = Fixture::new("builtin-default", &[("a.ts", "export const a = 1;")]);
1076        let root = fixture.root();
1077        assert!(root.resolve("", "lanekeep/always").is_err());
1078    }
1079
1080    #[test]
1081    fn resolves_the_host_module() {
1082        let fixture = Fixture::new("host", &[("a.ts", "export const a = 1;")]);
1083        let root = fixture.root();
1084        assert_eq!(
1085            root.resolve("", HOST_MODULE).expect("resolves"),
1086            Path::new(HOST_MODULE)
1087        );
1088    }
1089
1090    #[test]
1091    fn the_host_module_exports_the_authoring_helpers() {
1092        let fixture = Fixture::new("host-src", &[]);
1093        let source = fixture
1094            .root()
1095            .read(Path::new(HOST_MODULE), &TypeScript, &JavaScript)
1096            .expect("reads");
1097        assert!(source.contains("defineRule"), "{source}");
1098        assert!(source.contains("defineConfig"), "{source}");
1099    }
1100
1101    #[test]
1102    fn resolves_a_relative_import() {
1103        let fixture = Fixture::new(
1104            "relative",
1105            &[
1106                ("main.ts", "import './helper';"),
1107                ("helper.ts", "export const h = 1;"),
1108            ],
1109        );
1110        let root = fixture.root();
1111
1112        let resolved = root
1113            .resolve(&fixture.entry("main.ts"), "./helper")
1114            .expect("resolves");
1115        assert!(resolved.ends_with("helper.ts"), "{resolved:?}");
1116    }
1117
1118    #[test]
1119    fn tries_extensions_in_order() {
1120        // A `.ts` file wins over a `.js` file of the same name, because a rule directory
1121        // containing both is almost always a stale build artifact next to its source.
1122        let fixture = Fixture::new(
1123            "extensions",
1124            &[
1125                ("main.ts", ""),
1126                ("dup.ts", "export const from = 'ts';"),
1127                ("dup.js", "export const from = 'js';"),
1128            ],
1129        );
1130        let resolved = fixture
1131            .root()
1132            .resolve(&fixture.entry("main.ts"), "./dup")
1133            .expect("resolves");
1134        assert!(
1135            resolved.ends_with("dup.ts"),
1136            "expected the TypeScript file: {resolved:?}"
1137        );
1138    }
1139
1140    #[test]
1141    fn resolves_a_directory_index() {
1142        let fixture = Fixture::new(
1143            "index",
1144            &[("main.ts", ""), ("rules/index.ts", "export const r = 1;")],
1145        );
1146        let resolved = fixture
1147            .root()
1148            .resolve(&fixture.entry("main.ts"), "./rules")
1149            .expect("resolves");
1150        assert!(resolved.ends_with("index.ts"), "{resolved:?}");
1151    }
1152
1153    #[test]
1154    fn a_file_beats_a_same_named_directory_index() {
1155        // `candidates` puts every extension ahead of every `index.<extension>`, and until this
1156        // test nothing held it there — `resolves_a_directory_index` only proves an index wins
1157        // when no file competes, which it would do under either order.
1158        //
1159        // The rule is the one every bundler and TypeScript itself follows, so getting it wrong
1160        // would not look like a bug from inside a rule: `./rules` would simply mean the other
1161        // file, and both readings are individually plausible. It is also enforced twice now —
1162        // `packages/lanekeep/runtime/resolve.js` resolves a rule's imports at build time — so
1163        // leaving it unpinned here would let the port diverge from the specification with
1164        // nothing to notice, which is most of what makes this file the specification.
1165        let fixture = Fixture::new(
1166            "file-over-index",
1167            &[
1168                ("main.ts", ""),
1169                ("rules.ts", "export const from = 'file';"),
1170                ("rules/index.ts", "export const from = 'index';"),
1171            ],
1172        );
1173        let resolved = fixture
1174            .root()
1175            .resolve(&fixture.entry("main.ts"), "./rules")
1176            .expect("resolves");
1177        assert!(
1178            resolved.ends_with("rules.ts"),
1179            "expected the file, not the directory: {resolved:?}"
1180        );
1181    }
1182
1183    #[test]
1184    fn resolves_an_explicit_extension() {
1185        let fixture = Fixture::new(
1186            "explicit",
1187            &[("main.ts", ""), ("helper.ts", "export const h = 1;")],
1188        );
1189        let resolved = fixture
1190            .root()
1191            .resolve(&fixture.entry("main.ts"), "./helper.ts")
1192            .expect("resolves");
1193        assert!(resolved.ends_with("helper.ts"), "{resolved:?}");
1194    }
1195
1196    // --- what must not resolve --------------------------------------------------------
1197
1198    #[test]
1199    fn rejects_bare_specifiers() {
1200        let fixture = Fixture::new("bare", &[("main.ts", "")]);
1201        let root = fixture.root();
1202
1203        for specifier in [
1204            "lodash",
1205            "react",
1206            "node:fs",
1207            "fs",
1208            "@scope/pkg",
1209            "typescript",
1210        ] {
1211            let err = root
1212                .resolve(&fixture.entry("main.ts"), specifier)
1213                .expect_err("bare specifiers must not resolve");
1214            assert!(
1215                matches!(err, ResolveError::BareSpecifier { .. }),
1216                "{specifier} gave {err:?}"
1217            );
1218        }
1219    }
1220
1221    #[test]
1222    fn a_bare_specifier_explains_why() {
1223        let fixture = Fixture::new("bare-msg", &[("main.ts", "")]);
1224        let err = fixture
1225            .root()
1226            .resolve(&fixture.entry("main.ts"), "lodash")
1227            .expect_err("bare specifiers do not resolve");
1228
1229        assert!(matches!(err, ResolveError::BareSpecifier { .. }), "{err:?}");
1230        let rendered = err.to_string();
1231        assert!(rendered.contains("no package resolution"), "{rendered}");
1232        assert!(rendered.contains("lanekeep"), "{rendered}");
1233    }
1234
1235    #[test]
1236    fn rejects_traversal_out_of_the_root() {
1237        let fixture = Fixture::new("traversal", &[("main.ts", "")]);
1238        let root = fixture.root();
1239        let base = fixture.entry("main.ts");
1240
1241        for specifier in ["../outside", "../../etc/passwd", "./../../secrets", "../"] {
1242            let err = root
1243                .resolve(&base, specifier)
1244                .expect_err("traversal must not resolve");
1245            assert!(
1246                matches!(err, ResolveError::EscapesRoot { .. }),
1247                "{specifier} gave {err:?}"
1248            );
1249        }
1250    }
1251
1252    #[test]
1253    fn traversal_is_rejected_even_when_the_target_exists() {
1254        // The lexical check has to fire regardless of what is on disk, or the error a
1255        // reader sees depends on whether the file they tried to reach happened to be there.
1256        let fixture = Fixture::new(
1257            "traversal-real",
1258            &[("nested/main.ts", ""), ("secret.ts", "export const s = 1;")],
1259        );
1260        let root = RuleRoot::new(fixture.dir.join("nested")).expect("canonicalizes");
1261
1262        let err = root
1263            .resolve(&fixture.entry("nested/main.ts"), "../secret")
1264            .expect_err("must not escape");
1265        assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
1266    }
1267
1268    #[cfg(unix)]
1269    #[test]
1270    fn rejects_a_symlink_pointing_outside_the_root() {
1271        // The case a lexical check cannot see. `./link` looks entirely innocent; only
1272        // canonicalizing the file that was found reveals where it goes.
1273        let fixture = Fixture::new(
1274            "symlink",
1275            &[
1276                ("nested/main.ts", ""),
1277                ("outside.ts", "export const o = 1;"),
1278            ],
1279        );
1280        let root_dir = fixture.dir.join("nested");
1281        let link = root_dir.join("link.ts");
1282        std::os::unix::fs::symlink(fixture.dir.join("outside.ts"), &link).expect("creates symlink");
1283
1284        let root = RuleRoot::new(&root_dir).expect("canonicalizes");
1285        let err = root
1286            .resolve(&fixture.entry("nested/main.ts"), "./link")
1287            .expect_err("a symlink out of the root must be rejected");
1288        assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
1289    }
1290
1291    #[test]
1292    fn a_rule_may_not_import_an_absolute_path() {
1293        // The entry module legitimately arrives as an absolute path, so the resolver has
1294        // to accept one. This checks that door is only open for the entry — a rule with a
1295        // base of its own is refused.
1296        //
1297        // The absolute path is built from `temp_dir` rather than written literally,
1298        // because `Path::is_absolute` is platform-specific: `/etc/passwd` is absolute on
1299        // Unix and merely rooted on Windows, while `C:\...` is the reverse. A literal
1300        // would take a different branch on each platform and assert a different error.
1301        let fixture = Fixture::new("absolute", &[("main.ts", "")]);
1302        let root = fixture.root();
1303        let base = fixture.entry("main.ts");
1304
1305        let outside = std::env::temp_dir().join("lanekeep-absolute-probe.ts");
1306        let outside = outside.display().to_string();
1307
1308        // Written by a rule: refused, whichever way the platform classifies it.
1309        for specifier in [outside.as_str(), "/etc/passwd", "C:\\Windows\\System32\\x"] {
1310            assert!(
1311                root.resolve(&base, specifier).is_err(),
1312                "a rule must not import `{specifier}`"
1313            );
1314        }
1315
1316        // As an entry point: still refused, because it is outside the root.
1317        let err = root
1318            .resolve("", &outside)
1319            .expect_err("an entry outside the root must be refused");
1320        assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
1321    }
1322
1323    #[test]
1324    fn reports_what_it_tried_when_nothing_matches() {
1325        let fixture = Fixture::new("missing", &[("main.ts", "")]);
1326        let err = fixture
1327            .root()
1328            .resolve(&fixture.entry("main.ts"), "./nope")
1329            .expect_err("nothing to find");
1330
1331        match err {
1332            ResolveError::NotFound { tried, .. } => {
1333                assert!(tried.contains("nope.ts"), "should list candidates: {tried}");
1334                assert!(
1335                    tried.contains("index.ts"),
1336                    "should list index candidates: {tried}"
1337                );
1338            }
1339            other => panic!("wrong error: {other:?}"),
1340        }
1341    }
1342
1343    // --- reading ------------------------------------------------------------------------
1344
1345    #[test]
1346    fn strips_types_when_reading_typescript() {
1347        let fixture = Fixture::new(
1348            "read-ts",
1349            &[("a.ts", "export const a: number = 1;\ninterface B {}\n")],
1350        );
1351        let root = fixture.root();
1352        let path = root.resolve("", "./a").expect("resolves");
1353        let source = root.read(&path, &TypeScript, &JavaScript).expect("reads");
1354
1355        assert!(!source.contains(": number"), "{source}");
1356        assert!(!source.contains("interface"), "{source}");
1357        assert!(source.contains("export const a"), "{source}");
1358    }
1359
1360    #[test]
1361    fn reading_refuses_a_path_outside_the_root_even_if_resolution_was_skipped() {
1362        // Defense in depth. `resolve` already enforces this, but a future caller that
1363        // builds a path some other way must not be able to read past the boundary.
1364        let fixture = Fixture::new(
1365            "read-escape",
1366            &[
1367                ("nested/main.ts", ""),
1368                ("outside.ts", "export const o = 1;"),
1369            ],
1370        );
1371        let root = RuleRoot::new(fixture.dir.join("nested")).expect("canonicalizes");
1372
1373        let err = root
1374            .read(&fixture.dir.join("outside.ts"), &TypeScript, &JavaScript)
1375            .expect_err("reading outside the root must be refused");
1376        assert!(matches!(err, ResolveError::EscapesRoot { .. }), "{err:?}");
1377    }
1378
1379    #[test]
1380    fn passes_javascript_through_untouched() {
1381        let contents = "export const a = 1;\n";
1382        let fixture = Fixture::new("read-js", &[("a.js", contents)]);
1383        let root = fixture.root();
1384        let path = root.resolve("", "./a.js").expect("resolves");
1385        assert_eq!(
1386            root.read(&path, &TypeScript, &JavaScript).expect("reads"),
1387            contents
1388        );
1389    }
1390
1391    // --- end to end, through the engine ---------------------------------------------
1392    //
1393    // Everything above tests the resolution logic directly. These go through the engine's
1394    // Resolver and Loader adapters, which is the part that is actually wired up at runtime
1395    // and could be correct in isolation while being connected wrongly.
1396
1397    fn sandbox_for(fixture: &Fixture) -> crate::Sandbox {
1398        crate::Sandbox::with_modules(
1399            crate::Limits::default(),
1400            crate::RunClock::start(std::time::Duration::from_secs(30)),
1401            fixture.root(),
1402            Arc::new(TypeScript),
1403            Arc::new(JavaScript),
1404        )
1405        .expect("sandbox builds")
1406    }
1407
1408    #[test]
1409    fn loads_a_rule_module_that_imports_the_host_module() {
1410        let fixture = Fixture::new(
1411            "e2e-host",
1412            &[(
1413                "rule.ts",
1414                "import { defineRule } from 'lanekeep';\n\
1415                 export default defineRule({ id: 'local/example' });\n",
1416            )],
1417        );
1418        let sandbox = sandbox_for(&fixture);
1419        let path = fixture.root().resolve("", "./rule").expect("resolves");
1420
1421        let module: std::collections::HashMap<String, String> =
1422            sandbox.import_default(&path).expect("module evaluates");
1423        assert_eq!(module.get("id").map(String::as_str), Some("local/example"));
1424    }
1425
1426    #[test]
1427    fn loads_a_module_that_imports_a_sibling_and_strips_its_types() {
1428        let fixture = Fixture::new(
1429            "e2e-sibling",
1430            &[
1431                (
1432                    "rule.ts",
1433                    "import { defineRule } from 'lanekeep';\n\
1434                     import { NAME } from './shared';\n\
1435                     export default defineRule({ id: NAME });\n",
1436                ),
1437                (
1438                    "shared.ts",
1439                    "interface Unused { a: number }\n\
1440                     export const NAME: string = 'local/from-sibling';\n",
1441                ),
1442            ],
1443        );
1444        let sandbox = sandbox_for(&fixture);
1445        let path = fixture.root().resolve("", "./rule").expect("resolves");
1446
1447        let module: std::collections::HashMap<String, String> =
1448            sandbox.import_default(&path).expect("module evaluates");
1449        assert_eq!(
1450            module.get("id").map(String::as_str),
1451            Some("local/from-sibling")
1452        );
1453    }
1454
1455    #[test]
1456    fn a_bare_import_fails_at_load_with_the_explanation() {
1457        let fixture = Fixture::new(
1458            "e2e-bare",
1459            &[(
1460                "rule.ts",
1461                "import lodash from 'lodash';\nexport default lodash;\n",
1462            )],
1463        );
1464        let sandbox = sandbox_for(&fixture);
1465        let path = fixture.root().resolve("", "./rule").expect("resolves");
1466
1467        let err = sandbox
1468            .import_default::<std::collections::HashMap<String, String>>(&path)
1469            .expect_err("lodash cannot resolve");
1470        let rendered = err.to_string();
1471        assert!(rendered.contains("lodash"), "{rendered}");
1472    }
1473
1474    #[test]
1475    fn a_traversing_import_fails_at_load() {
1476        let fixture = Fixture::new(
1477            "e2e-traversal",
1478            &[
1479                (
1480                    "nested/rule.ts",
1481                    "import x from '../outside';\nexport default x;\n",
1482                ),
1483                ("outside.ts", "export default 1;\n"),
1484            ],
1485        );
1486        let root = RuleRoot::new(fixture.dir.join("nested")).expect("canonicalizes");
1487        let sandbox = crate::Sandbox::with_modules(
1488            crate::Limits::default(),
1489            crate::RunClock::start(std::time::Duration::from_secs(30)),
1490            root.clone(),
1491            Arc::new(TypeScript),
1492            Arc::new(JavaScript),
1493        )
1494        .expect("sandbox builds");
1495
1496        let path = root.resolve("", "./rule").expect("resolves");
1497        assert!(
1498            sandbox
1499                .import_default::<std::collections::HashMap<String, String>>(&path)
1500                .is_err(),
1501            "an import escaping the root must not load"
1502        );
1503    }
1504
1505    #[test]
1506    fn a_module_that_fails_to_strip_reports_the_reason() {
1507        let fixture = Fixture::new("read-bad", &[("a.ts", "enum E { A }\n")]);
1508        let root = fixture.root();
1509        let path = root.resolve("", "./a").expect("resolves");
1510        let err = root
1511            .read(&path, &TypeScript, &JavaScript)
1512            .expect_err("enums are rejected");
1513
1514        let rendered = err.to_string();
1515        assert!(
1516            rendered.contains("enum"),
1517            "should name the construct: {rendered}"
1518        );
1519    }
1520}