Skip to main content

lanekeep_js/
host.rs

1//! The `ctx` object rule code receives.
2//!
3//! This is the trust boundary made concrete. Everything a rule can do to the outside world
4//! it does through a function installed here; anything not installed does not exist. Adding
5//! to this surface widens what a rule may reach and bumps the host API version that feeds
6//! the cache key.
7//!
8//! # What is here so far
9//!
10//! Reporting, tree navigation, binding resolution, facts, and tracked file reads.
11//!
12//! # Two contexts, not one
13//!
14//! [`HostContext`] serves the per-file pass and [`ReduceContext`] serves the reduce phase,
15//! and neither is a subset of the other by accident:
16//!
17//! - `emitFact` exists only in the per-file context. A reduce phase that could emit facts
18//!   could feed itself, and there is no second pass for the result to reach.
19//! - `facts` and `files` exist only in the reduce context. If `check` could read the corpus,
20//!   a file's result would depend on files other than itself, and caching that result
21//!   against its own content would be unsound. This is not a stylistic split — it is what
22//!   makes per-file cache entries mean anything.
23
24use std::cell::{Cell, RefCell};
25use std::collections::BTreeMap;
26use std::fmt::Write as _;
27use std::rc::Rc;
28use std::sync::Arc;
29
30use rquickjs::function::Opt;
31use rquickjs::object::Accessor;
32use rquickjs::{Ctx, Function, Object, Value};
33
34use lanekeep_lang::binding::BindingResolver;
35
36use lanekeep_core::files::FileAccess;
37use lanekeep_core::fix::Fix;
38use lanekeep_nodes::{Handle, NodeArena};
39use lanekeep_query::CompiledQuery;
40
41/// The version of the `ctx` surface this build exposes.
42///
43/// **Bump this whenever `ctx` gains, loses or changes a function.** It is a cache-key input,
44/// and it has to be: a result computed by a build where `ctx.readFile` did not exist is not
45/// a valid result for a build where it does — the rule could not have called something that
46/// was not there, so its cached verdict was reached without evidence it would have used.
47///
48/// Nothing detects this automatically. A function added without bumping it serves stale
49/// results silently, which is the failure mode the whole cache design is arranged against.
50///
51/// **The component engine does not have this problem, and the difference is worth knowing
52/// about while this number is still here.** `lanekeep-wasm`'s half of the same cache-key field
53/// is a content hash of `wit/world.wit`, the file its bindings are generated from, so it moves
54/// when the surface moves and nobody has to remember. `lanekeep_engine` folds both into one
55/// field; this one leaves with the last JavaScript rule, and until then it is the half that
56/// can be got wrong.
57///
58/// History:
59/// - `1` — reporting, navigation, binding resolution, `emitFact`, `readFile`, `fileExists`.
60/// - `2` — `structureFingerprint`: the structural-summary primitive (normalized subtree
61///   hash plus exact node count), computed host-side in one walk.
62pub const HOST_API_VERSION: u32 = 2;
63
64/// A fact a rule emitted, before the engine attaches the file and rule it came from.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct EmittedFact {
67    /// The `kind` field, lifted out so the reduce phase can filter without parsing.
68    pub kind: String,
69    /// The whole fact, as `JSON.stringify` rendered it. Always a JSON object.
70    pub data: String,
71}
72
73/// A violation a rule asked for.
74///
75/// Deliberately not a `lanekeep_core::Violation`. A rule supplies a position and optionally
76/// a message; the rule's identity, severity and card come from the engine, which is what
77/// stops a rule from reporting under someone else's name.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Report {
80    /// The node reported at, when the rule passed one.
81    pub node: Option<Handle>,
82    /// One-based line.
83    pub line: u32,
84    /// One-based column.
85    pub column: u32,
86    /// A message overriding the rule card's, when the rule supplied one.
87    pub message: Option<String>,
88    /// A replacement the rule offered.
89    pub fix: Option<Fix>,
90}
91
92/// Host state for one file, shared with the functions installed on `ctx`.
93///
94/// `Debug` is hand-written: requiring it on `BindingResolver` would burden every language
95/// implementation for the sake of one derive here.
96#[derive(Clone)]
97pub struct HostContext {
98    arena: Rc<RefCell<NodeArena>>,
99    reports: Rc<RefCell<Vec<Report>>>,
100    facts: Rc<RefCell<Vec<EmittedFact>>>,
101    file_path: Rc<str>,
102    resolver: Option<Arc<dyn BindingResolver>>,
103    /// Tracked, confined access to the rest of the project, shared with every rule on this
104    /// file — and, since a run may execute both engines over one corpus, with the component
105    /// engine's context for the same file.
106    ///
107    /// An [`Arc`] rather than an [`Rc`], which is the one place this type's sharing is not
108    /// purely a QuickJS matter: `lanekeep_wasm::host::CheckContext` is required to be `Send`,
109    /// so the shared handle both engines hold has to be one. Nothing else about the arrangement
110    /// changes — an access still belongs to one file and is still touched by one worker.
111    files: Option<Arc<FileAccess>>,
112    /// The grammar `querySubtree` and `closestAncestor` compile against.
113    language: Option<Arc<dyn lanekeep_lang::Language>>,
114    /// The date a rule sees as `ctx.today`, if the host supplied one.
115    today: Option<Rc<str>>,
116    /// Whether anything actually read `ctx.today` while checking this file.
117    ///
118    /// Tracked rather than assumed, because reading the date makes a file's result depend on
119    /// what day it is. Assuming every file might read it would date every cache entry and
120    /// invalidate the whole corpus daily; assuming none does would serve yesterday's answer.
121    date_read: Rc<Cell<bool>>,
122    /// Queries compiled so far this file, by source.
123    ///
124    /// A rule that calls `querySubtree` inside a handler calls it once per match, with the
125    /// same query string every time. Compiling per call would make the second-cheapest
126    /// operation in the host the most expensive one. Failures are cached too, so a bad
127    /// query is reported once rather than recompiled on every match.
128    queries: QueryCache,
129}
130
131/// Queries compiled so far for one file, by source.
132///
133/// A named type because it appears in three signatures, and because the shape — a shared,
134/// mutable map whose values are *either* a compiled query or the reason it did not compile —
135/// says more with a name than inline.
136type QueryCache = Rc<RefCell<BTreeMap<String, Result<Rc<CompiledQuery>, String>>>>;
137
138impl std::fmt::Debug for HostContext {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.debug_struct("HostContext")
141            .field("file_path", &self.file_path)
142            .field("interned_nodes", &self.arena.borrow().len())
143            .field("reports", &self.reports.borrow().len())
144            .field("facts", &self.facts.borrow().len())
145            .field("has_resolver", &self.resolver.is_some())
146            .field("has_file_access", &self.files.is_some())
147            .field("has_language", &self.language.is_some())
148            .field("has_today", &self.today.is_some())
149            .field("date_read", &self.date_read.get())
150            .field("compiled_queries", &self.queries.borrow().len())
151            .finish()
152    }
153}
154
155impl HostContext {
156    /// Build a context over a parsed file.
157    #[must_use]
158    pub fn new(tree: tree_sitter::Tree, source: String, file_path: &str) -> Self {
159        Self {
160            arena: Rc::new(RefCell::new(NodeArena::new(tree, source))),
161            reports: Rc::new(RefCell::new(Vec::new())),
162            facts: Rc::new(RefCell::new(Vec::new())),
163            file_path: Rc::from(file_path),
164            resolver: None,
165            files: None,
166            language: None,
167            today: None,
168            date_read: Rc::new(Cell::new(false)),
169            queries: Rc::new(RefCell::new(BTreeMap::new())),
170        }
171    }
172
173    /// Attach whatever resolver a language provides, if any.
174    #[must_use]
175    pub fn with_resolver_from(self, language: &dyn lanekeep_lang::Language) -> Self {
176        match language.resolver() {
177            Some(resolver) => self.with_resolver(resolver),
178            None => self,
179        }
180    }
181
182    /// Supply the date rules see as `ctx.today`.
183    ///
184    /// Fixed for the run by the caller, not read here: two files checked a millisecond apart
185    /// must not disagree about what day it is. Without one, `ctx.today` is absent rather than
186    /// empty — a rule comparing against `undefined` would silently take a branch nobody
187    /// intended, where a missing property is a `TypeError` naming what it reached for.
188    #[must_use]
189    pub fn with_today(mut self, today: &str) -> Self {
190        self.today = Some(Rc::from(today));
191        self
192    }
193
194    /// Whether anything read `ctx.today` while checking this file.
195    ///
196    /// The caller needs this to decide whether the result may be cached across days.
197    #[must_use]
198    pub fn date_was_read(&self) -> bool {
199        self.date_read.get()
200    }
201
202    /// Attach the grammar `querySubtree` and `closestAncestor` compile queries against.
203    ///
204    /// Without one they are absent rather than present-and-failing. A rule reaching for them
205    /// then gets a `TypeError` naming the function, which is the truthful answer — a stub
206    /// returning nothing would look like a query that matched nothing.
207    #[must_use]
208    pub fn with_language(mut self, language: Arc<dyn lanekeep_lang::Language>) -> Self {
209        self.language = Some(language);
210        self
211    }
212
213    /// Attach a binding resolver, enabling the import-resolution functions.
214    ///
215    /// Without one, those functions return `false` or `undefined` rather than being
216    /// absent. A rule written against a language that has no resolver then behaves as
217    /// though nothing resolves, which is a truthful answer — where a missing function
218    /// would be a `TypeError` blamed on the rule.
219    #[must_use]
220    pub fn with_resolver(mut self, resolver: Arc<dyn BindingResolver>) -> Self {
221        self.resolver = Some(resolver);
222        self
223    }
224
225    /// Allow tracked reads of other files in the project.
226    ///
227    /// Without one, `readFile` and `fileExists` are absent rather than present-and-failing.
228    /// A rule reaching for them then gets a `TypeError` naming the function, which is the
229    /// truthful answer — where a stub returning `undefined` would look like an empty project
230    /// and produce a rule that silently checks nothing.
231    ///
232    /// Takes an [`Arc`] rather than an [`Rc`] so the *same* access can be handed to the
233    /// component engine's context for the same file. Two memos over one file would let two
234    /// rules see a file rewritten between them differently, which is the determinism invariant
235    /// and not a tidiness question — see [`FileAccess`]'s own `seen` field.
236    #[must_use]
237    pub fn with_file_access(mut self, files: Arc<FileAccess>) -> Self {
238        self.files = Some(files);
239        self
240    }
241
242    /// The arena, for interning query captures before invoking a handler.
243    #[must_use]
244    pub fn arena(&self) -> &Rc<RefCell<NodeArena>> {
245        &self.arena
246    }
247
248    /// Take everything reported so far, leaving the context empty.
249    #[must_use]
250    pub fn take_reports(&self) -> Vec<Report> {
251        std::mem::take(&mut self.reports.borrow_mut())
252    }
253
254    /// Take everything emitted so far, in emission order, leaving the context empty.
255    #[must_use]
256    pub fn take_facts(&self) -> Vec<EmittedFact> {
257        std::mem::take(&mut self.facts.borrow_mut())
258    }
259
260    /// Build the `ctx` object.
261    ///
262    /// # Errors
263    ///
264    /// Returns an engine error if a property cannot be defined, which would mean a broken
265    /// build rather than anything about a rule.
266    pub fn build<'js>(&self, ctx: &Ctx<'js>) -> rquickjs::Result<Object<'js>> {
267        let object = Object::new(ctx.clone())?;
268
269        object.set("filePath", &*self.file_path)?;
270        object.set("root", NodeArena::ROOT)?;
271        {
272            let arena = self.arena.borrow();
273            object.set("fileText", arena.source())?;
274        }
275
276        self.install_navigation(ctx, &object)?;
277        self.install_bindings(ctx, &object)?;
278        self.install_reporting(ctx, &object)?;
279        self.install_facts(ctx, &object)?;
280        self.install_reads(ctx, &object)?;
281        self.install_queries(ctx, &object)?;
282
283        // `ctx.today` is a property backed by a getter, so reading it can be *observed*. A
284        // plain value would be indistinguishable from an unread one, and the cache would
285        // have to guess which files depend on the date.
286        if let Some(today) = self.today.clone() {
287            let date_read = Rc::clone(&self.date_read);
288            object.prop(
289                "today",
290                Accessor::from(move || {
291                    date_read.set(true);
292                    today.to_string()
293                }),
294            )?;
295        }
296
297        Ok(object)
298    }
299
300    /// Reading the tree.
301    fn install_navigation<'js>(
302        &self,
303        ctx: &Ctx<'js>,
304        object: &Object<'js>,
305    ) -> rquickjs::Result<()> {
306        // --- tree navigation -----------------------------------------------------------
307        //
308        // Every one of these takes a handle and returns plain data. A handle that does not
309        // resolve yields `undefined` or an empty array rather than throwing: rule code is
310        // arbitrary and may pass any number, and a thrown error there would be reported as
311        // a rule bug when the real cause is a typo in a handle variable.
312
313        let arena = Rc::clone(&self.arena);
314        object.set(
315            "kind",
316            Function::new(ctx.clone(), move |handle: Handle| {
317                arena.borrow().kind(handle).map(ToOwned::to_owned)
318            })?,
319        )?;
320
321        let arena = Rc::clone(&self.arena);
322        object.set(
323            "text",
324            Function::new(ctx.clone(), move |handle: Handle| {
325                arena.borrow().text(handle).map(ToOwned::to_owned)
326            })?,
327        )?;
328
329        let arena = Rc::clone(&self.arena);
330        object.set(
331            "isNamed",
332            Function::new(ctx.clone(), move |handle: Handle| {
333                arena.borrow().is_named(handle)
334            })?,
335        )?;
336
337        // Position is exposed as two primitives rather than as a `{line, column}` object.
338        //
339        // Building the object in Rust needs the `Ctx`, and a host function can neither
340        // capture one — cloning a `Ctx` into a `'static` closure keeps the context alive
341        // past `JS_FreeRuntime` and aborts the process on an unfreed-objects assertion —
342        // nor take one as a parameter, because the returned object's lifetime cannot be
343        // named inside a closure.
344        //
345        // A rule that wants the pair writes `{ line: ctx.line(n), column: ctx.column(n) }`,
346        // which is a fair trade for two fewer ways to get the boundary wrong.
347        let arena = Rc::clone(&self.arena);
348        object.set(
349            "line",
350            Function::new(ctx.clone(), move |handle: Handle| {
351                arena.borrow().position(handle).map(|(line, _)| line)
352            })?,
353        )?;
354
355        let arena = Rc::clone(&self.arena);
356        object.set(
357            "column",
358            Function::new(ctx.clone(), move |handle: Handle| {
359                arena.borrow().position(handle).map(|(_, column)| column)
360            })?,
361        )?;
362
363        let arena = Rc::clone(&self.arena);
364        object.set(
365            "parent",
366            Function::new(ctx.clone(), move |handle: Handle| {
367                arena.borrow_mut().parent(handle)
368            })?,
369        )?;
370
371        let arena = Rc::clone(&self.arena);
372        object.set(
373            "children",
374            Function::new(ctx.clone(), move |handle: Handle| {
375                arena.borrow_mut().children(handle)
376            })?,
377        )?;
378
379        let arena = Rc::clone(&self.arena);
380        object.set(
381            "namedChildren",
382            Function::new(ctx.clone(), move |handle: Handle| {
383                arena.borrow_mut().named_children(handle)
384            })?,
385        )?;
386
387        let arena = Rc::clone(&self.arena);
388        object.set(
389            "ancestors",
390            Function::new(ctx.clone(), move |handle: Handle| {
391                arena.borrow_mut().ancestors(handle)
392            })?,
393        )?;
394
395        // A structural summary rather than a walk: the host folds the subtree in one pass
396        // and hands back `{ hash, nodes }`, so a rule pays one crossing per call where
397        // walking `kind`/`children` would pay one per node. A dead handle is `undefined`,
398        // the same answer `kind` gives — nothing rather than a fabricated shape.
399        //
400        // The object is built with the `Ctx` this closure takes as its first parameter,
401        // which is the one shape a host function may use to return an object — see
402        // `querySubtree`'s closure for the reason the arena is not captured alongside it.
403        let arena = Rc::clone(&self.arena);
404        object.set(
405            "structureFingerprint",
406            Function::new(
407                ctx.clone(),
408                move |ctx: Ctx<'js>, handle: Handle| -> rquickjs::Result<Value<'js>> {
409                    let Some(fingerprint) = arena.borrow().structure_fingerprint(handle) else {
410                        return Ok(Value::new_undefined(ctx.clone()));
411                    };
412                    let object = Object::new(ctx.clone())?;
413                    object.set("hash", fingerprint.hash)?;
414                    object.set("nodes", fingerprint.nodes)?;
415                    Ok(object.into_value())
416                },
417            )?,
418        )?;
419
420        Ok(())
421    }
422
423    /// Resolving what an identifier refers to.
424    fn install_bindings<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
425        // --- binding resolution ----------------------------------------------------------
426        //
427        // The light semantic layer of §6.4. A rule matching `makeStyles(...)` on identifier
428        // text alone is wrong twice: it misses `import { makeStyles as ms }`, and it fires
429        // on a local `const makeStyles` that has nothing to do with the import.
430
431        // What each question *means* — which module and export count as a match, and how a
432        // `*` in a pattern behaves — lives on `Binding` in `lanekeep-lang`, because
433        // `lanekeep-wasm` answers `check-context.resolves-to-import` and
434        // `check-context.is-imported-from` with the identical predicate. A copy in each
435        // engine would let one file resolve differently depending on which one ran the rule.
436        let arena = Rc::clone(&self.arena);
437        let resolver = self.resolver.clone();
438        object.set(
439            "resolvesToImport",
440            Function::new(
441                ctx.clone(),
442                move |handle: Handle, module: String, name: Opt<String>| {
443                    let Some(resolver) = resolver.as_deref() else {
444                        return false;
445                    };
446                    arena
447                        .borrow()
448                        .resolve_binding(handle, resolver)
449                        .is_some_and(|binding| binding.is_import_of(&module, name.0.as_deref()))
450                },
451            )?,
452        )?;
453
454        let arena = Rc::clone(&self.arena);
455        let resolver = self.resolver.clone();
456        object.set(
457            "isImportedFrom",
458            Function::new(ctx.clone(), move |handle: Handle, pattern: String| {
459                let Some(resolver) = resolver.as_deref() else {
460                    return false;
461                };
462                arena
463                    .borrow()
464                    .resolve_binding(handle, resolver)
465                    .is_some_and(|binding| binding.is_imported_from(&pattern))
466            })?,
467        )?;
468
469        let arena = Rc::clone(&self.arena);
470        let resolver = self.resolver.clone();
471        object.set(
472            "bindingKind",
473            Function::new(ctx.clone(), move |handle: Handle| {
474                let resolver = resolver.as_deref()?;
475                arena
476                    .borrow()
477                    .resolve_binding(handle, resolver)
478                    .map(|binding| binding.kind_str().to_owned())
479            })?,
480        )?;
481
482        let arena = Rc::clone(&self.arena);
483        let resolver = self.resolver.clone();
484        object.set(
485            "isShadowed",
486            Function::new(ctx.clone(), move |handle: Handle| {
487                resolver
488                    .as_deref()
489                    .is_some_and(|resolver| arena.borrow().is_shadowed(handle, resolver))
490            })?,
491        )?;
492
493        Ok(())
494    }
495
496    /// Recording violations.
497    fn install_reporting<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
498        // --- reporting -------------------------------------------------------------------
499
500        // --- positions -------------------------------------------------------------------
501
502        let arena = Rc::clone(&self.arena);
503        let file_path = Rc::clone(&self.file_path);
504        object.set(
505            "loc",
506            // `{ file, line, column }`, which is exactly the shape a fact carries and the
507            // shape a reduce phase reports at. A rule that emits `at: ctx.loc(m.node)` and
508            // later calls `ctx.report(f.at)` needs no glue between the two.
509            Function::new(
510                ctx.clone(),
511                move |ctx: Ctx<'js>, handle: Handle| -> rquickjs::Result<Value<'js>> {
512                    let Some((line, column)) = arena.borrow().position(handle) else {
513                        // The same posture as reporting at an unresolvable handle: nothing,
514                        // rather than a made-up position a reader would go and look at.
515                        return Ok(Value::new_undefined(ctx.clone()));
516                    };
517
518                    let object = Object::new(ctx.clone())?;
519                    object.set("file", &*file_path)?;
520                    object.set("line", line)?;
521                    object.set("column", column)?;
522                    Ok(object.into_value())
523                },
524            )?,
525        )?;
526
527        let arena = Rc::clone(&self.arena);
528        let reports = Rc::clone(&self.reports);
529        object.set(
530            "report",
531            // The second argument is either a message or an options object. A union rather
532            // than two functions, because `ctx.report(node, 'why')` is the overwhelmingly
533            // common call and should stay the short one — and because a rule that wants a
534            // fix usually wants a specific message too.
535            Function::new(
536                ctx.clone(),
537                move |ctx: Ctx<'js>,
538                      handle: Handle,
539                      options: Opt<Value<'js>>|
540                      -> rquickjs::Result<()> {
541                    // A report at an unresolvable handle is dropped rather than recorded at
542                    // a made-up position. Reporting at 1:1 would point a reader at an
543                    // unrelated line, which is worse than the rule appearing not to fire.
544                    let Some((line, column)) = arena.borrow().position(handle) else {
545                        return Ok(());
546                    };
547
548                    let (message, fix) = match options.0 {
549                        None => (None, None),
550                        Some(value) if value.is_string() => (value.get::<String>().ok(), None),
551                        Some(value) => {
552                            let Some(object) = value.as_object() else {
553                                return Err(throw(
554                                    &ctx,
555                                    "ctx.report expects a message string or an options \
556                                     object — { message?, fix? }",
557                                ));
558                            };
559                            let message = object.get::<_, String>("message").ok();
560                            let fix = read_fix(&ctx, object, &arena)?;
561                            (message, fix)
562                        }
563                    };
564
565                    reports.borrow_mut().push(Report {
566                        node: Some(handle),
567                        line,
568                        column,
569                        message,
570                        fix,
571                    });
572                    Ok(())
573                },
574            )?,
575        )?;
576
577        Ok(())
578    }
579
580    /// Emitting facts for the reduce phase.
581    fn install_facts<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
582        // --- facts -----------------------------------------------------------------------
583
584        let facts = Rc::clone(&self.facts);
585        object.set(
586            "emitFact",
587            Function::new(
588                ctx.clone(),
589                move |ctx: Ctx<'js>, fact: Value<'js>| -> rquickjs::Result<()> {
590                    let Some(fact_object) = fact.as_object() else {
591                        return Err(throw(&ctx, "ctx.emitFact expects an object"));
592                    };
593
594                    // `kind` is what `ctx.facts('export')` filters on. A fact without one
595                    // could never be retrieved, so emitting it is always a mistake — and a
596                    // silent one, since the rule would look like it was working right up
597                    // until `reduce` found nothing.
598                    let kind = match fact_object.get::<_, String>("kind") {
599                        Ok(kind) if !kind.is_empty() => kind,
600                        _ => {
601                            return Err(throw(
602                                &ctx,
603                                "ctx.emitFact requires a non-empty string `kind` — it is what \
604                                 ctx.facts(kind) selects on, so a fact without one can never \
605                                 be read back",
606                            ));
607                        }
608                    };
609
610                    // `JSON.stringify` is the definition of serializable here rather than a
611                    // check alongside it, so what a rule can emit is exactly what it could
612                    // write to a file — and exactly what a cache entry can hold. A cycle
613                    // throws from inside stringify and surfaces as the rule's error.
614                    let Some(json) = ctx.json_stringify(fact)? else {
615                        return Err(throw(
616                            &ctx,
617                            "ctx.emitFact could not serialize this fact — facts are cached, \
618                             so they have to survive JSON",
619                        ));
620                    };
621
622                    facts.borrow_mut().push(EmittedFact {
623                        kind,
624                        data: json.to_string()?,
625                    });
626                    Ok(())
627                },
628            )?,
629        )?;
630
631        Ok(())
632    }
633
634    /// Running queries from inside a handler.
635    fn install_queries<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
636        // --- scoped queries --------------------------------------------------------------
637
638        let Some(language) = self.language.clone() else {
639            return Ok(());
640        };
641
642        let arena = Rc::clone(&self.arena);
643        let queries = Rc::clone(&self.queries);
644        let grammar = Arc::clone(&language);
645        object.set(
646            "querySubtree",
647            Function::new(
648                ctx.clone(),
649                move |ctx: Ctx<'js>,
650                      handle: Handle,
651                      source: String|
652                      -> rquickjs::Result<Value<'js>> {
653                    let compiled = compile(&queries, grammar.as_ref(), &source)
654                        .map_err(|problem| throw(&ctx, &problem))?;
655
656                    let matches = arena.borrow().query_subtree(handle, &compiled);
657                    let interned = intern_matches(&arena, matches);
658                    captures_to_js(&ctx, interned)
659                },
660            )?,
661        )?;
662
663        let arena = Rc::clone(&self.arena);
664        let queries = Rc::clone(&self.queries);
665        object.set(
666            "closestAncestor",
667            Function::new(
668                ctx.clone(),
669                move |ctx: Ctx<'js>,
670                      handle: Handle,
671                      source: String|
672                      -> rquickjs::Result<Value<'js>> {
673                    let compiled = compile(&queries, language.as_ref(), &source)
674                        .map_err(|problem| throw(&ctx, &problem))?;
675
676                    let found = arena.borrow().closest_ancestor_paths(handle, &compiled);
677                    let Some(captures) = found else {
678                        // Nothing matched. `undefined` rather than an empty object, so
679                        // `if (!ctx.closestAncestor(...))` reads correctly — an empty object
680                        // is truthy in JavaScript and would silently take the wrong branch.
681                        return Ok(Value::new_undefined(ctx.clone()));
682                    };
683
684                    let interned = intern_matches(&arena, vec![captures]);
685                    let one = interned.into_iter().next().unwrap_or_default();
686                    let object = Object::new(ctx.clone())?;
687                    for (name, handle) in one {
688                        object.set(name, handle)?;
689                    }
690                    Ok(object.into_value())
691                },
692            )?,
693        )?;
694
695        Ok(())
696    }
697
698    /// Reading other files in the project.
699    fn install_reads<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
700        // --- tracked reads -----------------------------------------------------------------
701
702        let Some(files) = self.files.clone() else {
703            return Ok(());
704        };
705
706        let reader = Arc::clone(&files);
707        object.set(
708            "readFile",
709            Function::new(
710                ctx.clone(),
711                move |ctx: Ctx<'js>, path: String| -> rquickjs::Result<Option<String>> {
712                    // A refusal throws; an absence returns `undefined`. The distinction is
713                    // the point: "not there" is an ordinary answer a rule should handle,
714                    // and "you tried to leave the project" is a bug in the rule.
715                    reader.read(&path).map_err(|e| throw(&ctx, &e.to_string()))
716                },
717            )?,
718        )?;
719
720        let reader = Arc::clone(&files);
721        object.set(
722            "fileExists",
723            Function::new(
724                ctx.clone(),
725                move |ctx: Ctx<'js>, path: String| -> rquickjs::Result<bool> {
726                    reader
727                        .exists(&path)
728                        .map_err(|e| throw(&ctx, &e.to_string()))
729                },
730            )?,
731        )?;
732
733        Ok(())
734    }
735}
736
737/// A violation a reduce phase asked for.
738///
739/// Carries a file of its own: a cross-file rule reports at the site the fact came from,
740/// which is by definition not "the file being checked" — there isn't one.
741#[derive(Debug, Clone, PartialEq, Eq)]
742pub struct ReduceReport {
743    /// Path of the file to report against, as the rule gave it.
744    pub file: String,
745    /// One-based line.
746    pub line: u32,
747    /// One-based column.
748    pub column: u32,
749    /// A message overriding the rule card's, when the rule supplied one.
750    pub message: Option<String>,
751}
752
753/// One fact as the reduce phase will see it: payload plus the file it came from.
754#[derive(Debug, Clone, PartialEq, Eq)]
755pub struct ReduceFact {
756    /// The `kind`, for filtering.
757    pub kind: String,
758    /// The payload as JSON, with `file` merged in.
759    pub json: String,
760}
761
762/// Host state for the reduce phase of one rule.
763///
764/// Built per rule rather than per run, because a rule sees only its own facts — letting one
765/// rule read another's would turn a private payload shape into a contract between rules.
766#[derive(Debug, Clone)]
767pub struct ReduceContext {
768    files: Rc<[String]>,
769    facts: Rc<[ReduceFact]>,
770    reports: Rc<RefCell<Vec<ReduceReport>>>,
771}
772
773impl ReduceContext {
774    /// Build a context over one rule's facts and the discovered file list.
775    #[must_use]
776    pub fn new(files: Vec<String>, facts: Vec<ReduceFact>) -> Self {
777        Self {
778            files: files.into(),
779            facts: facts.into(),
780            reports: Rc::new(RefCell::new(Vec::new())),
781        }
782    }
783
784    /// Take everything reported so far, leaving the context empty.
785    #[must_use]
786    pub fn take_reports(&self) -> Vec<ReduceReport> {
787        std::mem::take(&mut self.reports.borrow_mut())
788    }
789
790    /// Build the `ctx` object the reduce phase receives.
791    ///
792    /// # Errors
793    ///
794    /// Returns an engine error if a property cannot be defined, which would mean a broken
795    /// build rather than anything about a rule.
796    pub fn build<'js>(&self, ctx: &Ctx<'js>) -> rquickjs::Result<Object<'js>> {
797        let object = Object::new(ctx.clone())?;
798
799        object.set("files", &*self.files)?;
800
801        // No `filePath`, no `root`, no `fileText`, no navigation. There is no file and no
802        // tree here — that is invariant 1, and a context that offered them would be lying
803        // about what the phase can see.
804
805        let facts = Rc::clone(&self.facts);
806        object.set(
807            "facts",
808            Function::new(
809                ctx.clone(),
810                move |ctx: Ctx<'js>, kind: Opt<String>| -> rquickjs::Result<Value<'js>> {
811                    // One array, one parse. Handing back N separately-parsed objects would
812                    // cost N crossings for a phase whose whole job is bulk work.
813                    let wanted = kind.0;
814                    let mut json = String::from("[");
815                    for fact in facts
816                        .iter()
817                        .filter(|f| wanted.as_ref().is_none_or(|k| *k == f.kind))
818                    {
819                        if json.len() > 1 {
820                            json.push(',');
821                        }
822                        json.push_str(&fact.json);
823                    }
824                    json.push(']');
825
826                    ctx.json_parse(json)
827                },
828            )?,
829        )?;
830
831        let reports = Rc::clone(&self.reports);
832        object.set(
833            "report",
834            Function::new(
835                ctx.clone(),
836                move |ctx: Ctx<'js>,
837                      at: Value<'js>,
838                      message: Opt<Value<'js>>|
839                      -> rquickjs::Result<()> {
840                    let Some(at) = at.as_object() else {
841                        return Err(throw(
842                            &ctx,
843                            "ctx.report in a reduce phase expects { file, line, column } — \
844                             there is no parse tree here, so there are no nodes to report at",
845                        ));
846                    };
847
848                    let (Ok(file), Ok(line), Ok(column)) = (
849                        at.get::<_, String>("file"),
850                        at.get::<_, u32>("line"),
851                        at.get::<_, u32>("column"),
852                    ) else {
853                        return Err(throw(
854                            &ctx,
855                            "ctx.report in a reduce phase needs `file`, `line` and `column` — \
856                             emit them on the fact during the per-file pass, where the node \
857                             positions are still available",
858                        ));
859                    };
860
861                    // A bare string or `{ message }`, because the per-file `ctx.report` takes
862                    // options as its second argument and nobody remembers that this one is
863                    // different. Accepting both costs nothing and removes a papercut whose
864                    // only symptom was a type-conversion error naming neither argument.
865                    let message = match message.0 {
866                        None => None,
867                        Some(value) if value.is_undefined() || value.is_null() => None,
868                        Some(value) => {
869                            if let Some(text) = value.as_string() {
870                                Some(text.to_string()?)
871                            } else if let Some(options) = value.as_object() {
872                                match options.get::<_, Value<'js>>("message") {
873                                    Ok(found) if found.is_string() => found
874                                        .as_string()
875                                        .map(rquickjs::String::to_string)
876                                        .transpose()?,
877                                    _ => {
878                                        return Err(throw(
879                                            &ctx,
880                                            "ctx.report in a reduce phase takes a message: \
881                                             either a string, or { message }",
882                                        ));
883                                    }
884                                }
885                            } else {
886                                return Err(throw(
887                                    &ctx,
888                                    "ctx.report in a reduce phase takes a message: either a \
889                                     string, or { message }",
890                                ));
891                            }
892                        }
893                    };
894
895                    reports.borrow_mut().push(ReduceReport {
896                        file,
897                        line,
898                        column,
899                        message,
900                    });
901                    Ok(())
902                },
903            )?,
904        )?;
905
906        Ok(object)
907    }
908}
909
910/// Read a `fix` off a report's options object.
911///
912/// `{ node, text, safe? }`. The range comes from a node handle rather than from raw offsets:
913/// a rule already has the node it matched, and offsets it computed itself are offsets it can
914/// get wrong — the one mistake that would let a fix corrupt a file.
915fn read_fix<'js>(
916    ctx: &Ctx<'js>,
917    options: &Object<'js>,
918    arena: &Rc<RefCell<NodeArena>>,
919) -> rquickjs::Result<Option<Fix>> {
920    let Ok(value) = options.get::<_, Value<'js>>("fix") else {
921        return Ok(None);
922    };
923    if value.is_undefined() || value.is_null() {
924        return Ok(None);
925    }
926
927    let Some(fix) = value.as_object() else {
928        return Err(throw(
929            &ctx.clone(),
930            "ctx.report's `fix` expects { node, text, safe? }",
931        ));
932    };
933
934    let (Ok(handle), Ok(replacement)) =
935        (fix.get::<_, Handle>("node"), fix.get::<_, String>("text"))
936    else {
937        return Err(throw(
938            &ctx.clone(),
939            "ctx.report's `fix` needs a `node` to replace and the `text` to put there",
940        ));
941    };
942
943    let Some((start, end)) = arena.borrow().byte_range(handle) else {
944        // The same posture as reporting at an unresolvable handle: drop the fix rather than
945        // guess at a range. A fix at the wrong offsets would rewrite the wrong code.
946        return Ok(None);
947    };
948
949    Ok(Some(Fix {
950        start,
951        end,
952        replacement,
953        // Absent means suggestion. The cautious mistake costs a manual edit; the other one
954        // rewrites code silently.
955        safe: fix.get::<_, bool>("safe").unwrap_or(false),
956    }))
957}
958
959/// Compile a query, or return the failure this file already saw for it.
960fn compile(
961    cache: &QueryCache,
962    language: &dyn lanekeep_lang::Language,
963    source: &str,
964) -> Result<Rc<CompiledQuery>, String> {
965    if let Some(found) = cache.borrow().get(source) {
966        return found.clone();
967    }
968
969    let compiled = CompiledQuery::compile(language, source)
970        .map(Rc::new)
971        .map_err(|e| e.to_string());
972    cache
973        .borrow_mut()
974        .insert(source.to_owned(), compiled.clone());
975    compiled
976}
977
978/// Turn capture paths into handles, once the tree borrow has ended.
979fn intern_matches(
980    arena: &Rc<RefCell<NodeArena>>,
981    matches: Vec<Vec<(String, Vec<u32>)>>,
982) -> Vec<Vec<(String, Handle)>> {
983    let mut arena = arena.borrow_mut();
984    matches
985        .into_iter()
986        .map(|captures| {
987            captures
988                .into_iter()
989                .filter_map(|(name, path)| arena.intern_path(path).map(|handle| (name, handle)))
990                .collect()
991        })
992        .collect()
993}
994
995/// Render matches as an array of `{ captureName: handle }`.
996fn captures_to_js<'js>(
997    ctx: &Ctx<'js>,
998    matches: Vec<Vec<(String, Handle)>>,
999) -> rquickjs::Result<Value<'js>> {
1000    let array = rquickjs::Array::new(ctx.clone())?;
1001    for (index, captures) in matches.into_iter().enumerate() {
1002        let object = Object::new(ctx.clone())?;
1003        for (name, handle) in captures {
1004            object.set(name, handle)?;
1005        }
1006        array.set(index, object)?;
1007    }
1008    Ok(array.into_value())
1009}
1010
1011/// Throw a `TypeError` carrying a message meant for a rule author.
1012///
1013/// A real `Error` object, not a thrown string: the sandbox reports a thrown string by
1014/// telling the author to throw an `Error` instead, which is sound advice about their code
1015/// and nonsense when the host is the one that threw. It would also lose the message.
1016fn throw(ctx: &Ctx<'_>, message: &str) -> rquickjs::Error {
1017    rquickjs::Exception::throw_type(ctx, message)
1018}
1019
1020/// Merge a `file` into a fact's serialized payload.
1021///
1022/// Textual because the input is `JSON.stringify` output — always an object, always with the
1023/// braces at the ends — so this is one allocation rather than a parse, an insert and a
1024/// re-serialize, per fact, for a phase whose input is the whole corpus.
1025///
1026/// `file` goes **last** on purpose. A rule is free to put its own `file` in a fact, and JSON
1027/// parsing takes the last of duplicate keys — so the host's value wins, and a rule cannot
1028/// misattribute a violation by shadowing it.
1029#[must_use]
1030pub fn merge_file(data: &str, file: &str) -> String {
1031    let inner = data
1032        .trim()
1033        .strip_prefix('{')
1034        .and_then(|rest| rest.strip_suffix('}'))
1035        .unwrap_or_default()
1036        .trim();
1037
1038    let mut out = String::with_capacity(data.len() + file.len() + 12);
1039    out.push('{');
1040    if !inner.is_empty() {
1041        out.push_str(inner);
1042        out.push(',');
1043    }
1044    out.push_str("\"file\":");
1045    escape_json_string(file, &mut out);
1046    out.push('}');
1047    out
1048}
1049
1050/// Append `text` as a quoted JSON string.
1051fn escape_json_string(text: &str, out: &mut String) {
1052    out.push('"');
1053    for ch in text.chars() {
1054        match ch {
1055            '"' => out.push_str("\\\""),
1056            '\\' => out.push_str("\\\\"),
1057            '\n' => out.push_str("\\n"),
1058            '\r' => out.push_str("\\r"),
1059            '\t' => out.push_str("\\t"),
1060            c if (c as u32) < 0x20 => {
1061                // Writing rather than formatting into a temporary: this runs once per
1062                // character of every fact's file path.
1063                let _ = write!(out, "\\u{:04x}", c as u32);
1064            }
1065            c => out.push(c),
1066        }
1067    }
1068    out.push('"');
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073    use lanekeep_lang::Language;
1074    use lanekeep_lang_js::TypeScript;
1075
1076    use super::*;
1077    use crate::{Limits, Sandbox};
1078
1079    fn parse(source: &str) -> tree_sitter::Tree {
1080        let mut parser = tree_sitter::Parser::new();
1081        parser
1082            .set_language(&TypeScript.grammar())
1083            .expect("grammar loads");
1084        parser.parse(source, None).expect("parses")
1085    }
1086
1087    fn host(source: &str) -> HostContext {
1088        HostContext::new(parse(source), source.to_owned(), "src/example.ts")
1089            .with_resolver(Arc::new(lanekeep_lang_js::binding::JsBindingResolver))
1090    }
1091
1092    /// A host with no resolver, for the degraded path.
1093    fn host_without_resolver(source: &str) -> HostContext {
1094        HostContext::new(parse(source), source.to_owned(), "src/example.ts")
1095    }
1096
1097    /// The handle of the last identifier reading `name`, the way a query capture arrives.
1098    fn handle_of(host: &HostContext, name: &str) -> Handle {
1099        let mut arena = host.arena().borrow_mut();
1100        let source = arena.source().to_owned();
1101
1102        let path = {
1103            let mut best: Option<tree_sitter::Node<'_>> = None;
1104            let mut stack = vec![arena.tree().root_node()];
1105            while let Some(node) = stack.pop() {
1106                if node.kind() == "identifier"
1107                    && source.get(node.byte_range()) == Some(name)
1108                    && best.is_none_or(|b| node.start_byte() > b.start_byte())
1109                {
1110                    best = Some(node);
1111                }
1112                let mut cursor = node.walk();
1113                stack.extend(node.children(&mut cursor));
1114            }
1115            arena
1116                .path_of(best.unwrap_or_else(|| panic!("no identifier `{name}`")))
1117                .expect("has a path")
1118        };
1119
1120        arena.intern_path(path).expect("interns")
1121    }
1122
1123    /// Evaluate rule-shaped code with `ctx` in scope.
1124    fn run<T>(host: &HostContext, code: &str) -> T
1125    where
1126        T: for<'js> rquickjs::FromJs<'js> + Default,
1127    {
1128        let sandbox = Sandbox::with_limits(Limits::default()).expect("sandbox builds");
1129        sandbox.eval_with_host(host, code).expect("evaluates")
1130    }
1131
1132    #[test]
1133    fn exposes_the_file_path_and_text() {
1134        let host = host("const x = 1;");
1135        assert_eq!(run::<String>(&host, "ctx.filePath"), "src/example.ts");
1136        assert_eq!(run::<String>(&host, "ctx.fileText"), "const x = 1;");
1137    }
1138
1139    #[test]
1140    fn navigates_from_the_root() {
1141        let host = host("const x = 1;\nconst y = 2;");
1142        assert_eq!(run::<String>(&host, "ctx.kind(ctx.root)"), "program");
1143        assert_eq!(run::<u32>(&host, "ctx.namedChildren(ctx.root).length"), 2);
1144        assert_eq!(
1145            run::<String>(&host, "ctx.kind(ctx.namedChildren(ctx.root)[0])"),
1146            "lexical_declaration"
1147        );
1148    }
1149
1150    #[test]
1151    fn reads_text_and_position() {
1152        let host = host("const x = 1;\nconst y = 2;");
1153        assert_eq!(
1154            run::<String>(&host, "ctx.text(ctx.namedChildren(ctx.root)[1])"),
1155            "const y = 2;"
1156        );
1157        assert_eq!(
1158            run::<u32>(&host, "ctx.line(ctx.namedChildren(ctx.root)[1])"),
1159            2
1160        );
1161        assert_eq!(
1162            run::<u32>(&host, "ctx.column(ctx.namedChildren(ctx.root)[1])"),
1163            1
1164        );
1165    }
1166
1167    #[test]
1168    fn walks_up_and_back_down() {
1169        let host = host("const x = 1;");
1170        assert!(run::<bool>(
1171            &host,
1172            "const d = ctx.namedChildren(ctx.root)[0];
1173             const inner = ctx.namedChildren(d)[0];
1174             ctx.parent(inner) === d && ctx.parent(d) === ctx.root"
1175        ));
1176    }
1177
1178    #[test]
1179    fn handles_compare_equal_for_the_same_node() {
1180        // Rules use `===` on handles. If the same node interned twice produced two
1181        // numbers, a rule asking "is this capture the same node as that one" would
1182        // silently always say no.
1183        let host = host("const x = 1;");
1184        assert!(run::<bool>(
1185            &host,
1186            "ctx.namedChildren(ctx.root)[0] === ctx.namedChildren(ctx.root)[0]"
1187        ));
1188    }
1189
1190    #[test]
1191    fn ancestors_end_at_the_root() {
1192        let host = host("function f() { return 1; }");
1193        assert!(run::<bool>(
1194            &host,
1195            "const fn = ctx.namedChildren(ctx.root)[0];
1196             const body = ctx.namedChildren(fn).at(-1);
1197             const stmt = ctx.namedChildren(body)[0];
1198             const a = ctx.ancestors(stmt);
1199             a[0] === body && a.at(-1) === ctx.root"
1200        ));
1201    }
1202
1203    #[test]
1204    fn named_children_omits_anonymous_tokens() {
1205        let host = host("const x = 1;");
1206        assert!(run::<bool>(
1207            &host,
1208            "const d = ctx.namedChildren(ctx.root)[0];
1209             ctx.children(d).length > ctx.namedChildren(d).length"
1210        ));
1211    }
1212
1213    #[test]
1214    fn an_unresolvable_handle_returns_nothing_rather_than_throwing() {
1215        // Rule code is arbitrary and will pass stale or invented numbers. Throwing here
1216        // would be reported as a rule bug when the cause is a mistyped variable.
1217        let host = host("const x = 1;");
1218        assert!(run::<bool>(
1219            &host,
1220            "ctx.kind(9999) === undefined &&
1221             ctx.text(9999) === undefined &&
1222             ctx.line(9999) === undefined &&
1223             ctx.column(9999) === undefined &&
1224             ctx.parent(9999) === undefined &&
1225             ctx.children(9999).length === 0 &&
1226             ctx.ancestors(9999).length === 0 &&
1227             ctx.structureFingerprint(9999) === undefined"
1228        ));
1229    }
1230
1231    // --- structure fingerprint ------------------------------------------------------------
1232
1233    #[test]
1234    fn structure_fingerprint_is_exposed_on_ctx() {
1235        // The constants are the ones `crates/lanekeep-wasm/tests/js_globals.rs` asserts for
1236        // the same source through the compiled-TypeScript component — the two surfaces must
1237        // agree, because both fold the same `NodeArena`.
1238        let host = host("const x = 1;\n");
1239        assert_eq!(
1240            run::<String>(&host, "ctx.structureFingerprint(ctx.root).hash"),
1241            "a0f2e92a59b964c75383ee14e32e0087bb376c7cc39572ff0b888a04d3dd9e4b"
1242        );
1243        assert_eq!(
1244            run::<u32>(&host, "ctx.structureFingerprint(ctx.root).nodes"),
1245            8
1246        );
1247    }
1248
1249    #[test]
1250    fn structure_fingerprint_erases_identifiers_through_ctx() {
1251        let a = host("function f() { return a + b }");
1252        let b = host("function g() { return c + d }");
1253        assert_eq!(
1254            run::<String>(&a, "ctx.structureFingerprint(ctx.root).hash"),
1255            run::<String>(&b, "ctx.structureFingerprint(ctx.root).hash")
1256        );
1257    }
1258
1259    #[test]
1260    fn structure_fingerprint_of_a_dead_handle_is_undefined() {
1261        let host = host("const x = 1;");
1262        assert!(run::<bool>(
1263            &host,
1264            "ctx.structureFingerprint(9999) === undefined"
1265        ));
1266    }
1267
1268    // --- reporting ---------------------------------------------------------------------
1269
1270    #[test]
1271    fn records_a_report_at_the_node_position() {
1272        let host = host("const x = 1;\nconst y = 2;");
1273        let _: () = run(&host, "ctx.report(ctx.namedChildren(ctx.root)[1])");
1274
1275        let reports = host.take_reports();
1276        assert_eq!(reports.len(), 1);
1277        assert_eq!(reports[0].line, 2);
1278        assert_eq!(reports[0].column, 1);
1279        assert_eq!(reports[0].message, None);
1280    }
1281
1282    #[test]
1283    fn records_an_overriding_message() {
1284        let host = host("const x = 1;");
1285        let _: () = run(&host, "ctx.report(ctx.root, 'something specific')");
1286
1287        let reports = host.take_reports();
1288        assert_eq!(reports[0].message.as_deref(), Some("something specific"));
1289    }
1290
1291    #[test]
1292    fn records_every_report_in_order() {
1293        let host = host("const a = 1;\nconst b = 2;\nconst c = 3;");
1294        let _: () = run(
1295            &host,
1296            "for (const d of ctx.namedChildren(ctx.root)) { ctx.report(d, ctx.text(d)); }",
1297        );
1298
1299        let reports = host.take_reports();
1300        let lines: Vec<u32> = reports.iter().map(|r| r.line).collect();
1301        assert_eq!(lines, [1, 2, 3]);
1302        assert_eq!(reports[2].message.as_deref(), Some("const c = 3;"));
1303    }
1304
1305    #[test]
1306    fn a_report_at_an_unresolvable_handle_is_dropped() {
1307        // Recording it at 1:1 would point a reader at an unrelated line, which is worse
1308        // than the rule appearing not to have fired.
1309        let host = host("const x = 1;");
1310        let _: () = run(&host, "ctx.report(9999)");
1311        assert!(host.take_reports().is_empty());
1312    }
1313
1314    #[test]
1315    fn taking_reports_empties_the_context() {
1316        let host = host("const x = 1;");
1317        let _: () = run(&host, "ctx.report(ctx.root)");
1318
1319        assert_eq!(host.take_reports().len(), 1);
1320        assert!(
1321            host.take_reports().is_empty(),
1322            "reports must not be reported twice"
1323        );
1324    }
1325
1326    #[test]
1327    fn a_rule_that_throws_still_leaves_earlier_reports() {
1328        // A handler may report several times and then hit a bug. What it already found is
1329        // still true, and discarding it would make the failure harder to diagnose rather
1330        // than easier.
1331        let host = host("const x = 1;");
1332        let sandbox = Sandbox::with_limits(Limits::default()).expect("sandbox builds");
1333        let result: Result<(), _> =
1334            sandbox.eval_with_host(&host, "ctx.report(ctx.root); throw new Error('later')");
1335
1336        assert!(result.is_err());
1337        assert_eq!(host.take_reports().len(), 1);
1338    }
1339
1340    #[test]
1341    fn navigation_is_bounded_by_the_rule_timeout() {
1342        // The host API is reachable from arbitrary rule code, so a loop calling into it
1343        // must still be interruptible. Nothing here may take a lock the interrupt handler
1344        // needs, or the run would hang instead of failing.
1345        let host = host("const x = 1;");
1346        let sandbox = Sandbox::with_limits(
1347            Limits::default().with_rule_timeout(std::time::Duration::from_millis(120)),
1348        )
1349        .expect("sandbox builds");
1350
1351        let result: Result<(), _> = sandbox.eval_with_host(
1352            &host,
1353            "for (;;) { ctx.kind(ctx.root); ctx.children(ctx.root); }",
1354        );
1355        assert!(
1356            matches!(result, Err(crate::SandboxError::RuleTimeout { .. })),
1357            "expected a timeout, got {result:?}"
1358        );
1359    }
1360
1361    #[test]
1362    fn the_sandbox_still_withholds_everything_it_did_before() {
1363        // Installing `ctx` must not have widened the surface as a side effect.
1364        let host = host("const x = 1;");
1365        assert!(run::<bool>(
1366            &host,
1367            "typeof Date === 'undefined' &&
1368             typeof performance === 'undefined' &&
1369             typeof Math.random === 'undefined' &&
1370             typeof fetch === 'undefined' &&
1371             typeof process === 'undefined'"
1372        ));
1373    }
1374
1375    // --- binding resolution -------------------------------------------------------------
1376
1377    #[test]
1378    fn resolves_an_import_through_its_alias() {
1379        // The case §6.4 exists for. A rule looking for `makeStyles` has to find `ms`.
1380        let host = host("import { makeStyles as ms } from '@rneui/themed';\nms();");
1381        let handle = handle_of(&host, "ms");
1382
1383        assert!(run::<bool>(
1384            &host,
1385            &format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'makeStyles')")
1386        ));
1387        assert!(!run::<bool>(
1388            &host,
1389            &format!("ctx.resolvesToImport({handle}, 'somewhere-else', 'makeStyles')")
1390        ));
1391        assert!(!run::<bool>(
1392            &host,
1393            &format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'notThatOne')")
1394        ));
1395    }
1396
1397    #[test]
1398    fn a_local_declaration_does_not_resolve_to_the_import_it_shadows() {
1399        // The false positive this prevents: a rule keyed on the name firing on a local
1400        // that has nothing to do with the import.
1401        let host = host(
1402            "import { makeStyles } from '@rneui/themed';\n\
1403             function f() { const makeStyles = () => {}; return makeStyles(); }",
1404        );
1405        let handle = handle_of(&host, "makeStyles");
1406
1407        assert!(!run::<bool>(
1408            &host,
1409            &format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'makeStyles')")
1410        ));
1411        assert_eq!(
1412            run::<String>(&host, &format!("ctx.bindingKind({handle})")),
1413            "const"
1414        );
1415        assert!(run::<bool>(&host, &format!("ctx.isShadowed({handle})")));
1416    }
1417
1418    #[test]
1419    fn omitting_the_name_matches_any_export_of_the_module() {
1420        let host = host("import { a } from 'm';\na();");
1421        let handle = handle_of(&host, "a");
1422        assert!(run::<bool>(
1423            &host,
1424            &format!("ctx.resolvesToImport({handle}, 'm')")
1425        ));
1426    }
1427
1428    #[test]
1429    fn matches_a_module_by_glob() {
1430        let host = host("import { a } from '@scope/pkg';\na();");
1431        let handle = handle_of(&host, "a");
1432
1433        assert!(run::<bool>(
1434            &host,
1435            &format!("ctx.isImportedFrom({handle}, '@scope/*')")
1436        ));
1437        assert!(run::<bool>(
1438            &host,
1439            &format!("ctx.isImportedFrom({handle}, '*/pkg')")
1440        ));
1441        assert!(run::<bool>(
1442            &host,
1443            &format!("ctx.isImportedFrom({handle}, '@scope/pkg')")
1444        ));
1445        assert!(!run::<bool>(
1446            &host,
1447            &format!("ctx.isImportedFrom({handle}, '@other/*')")
1448        ));
1449    }
1450
1451    #[test]
1452    fn reports_binding_kinds() {
1453        for (source, name, expected) in [
1454            ("import { a } from 'm';\na();", "a", "import"),
1455            ("const b = 1;\nb;", "b", "const"),
1456            ("let c = 1;\nc;", "c", "let"),
1457            ("function d() {}\nd();", "d", "function"),
1458            ("class E {}\nnew E();", "E", "class"),
1459            ("function f(p) { return p; }", "p", "param"),
1460        ] {
1461            let host = host(source);
1462            let handle = handle_of(&host, name);
1463            assert_eq!(
1464                run::<String>(&host, &format!("ctx.bindingKind({handle})")),
1465                expected,
1466                "for {name} in {source}"
1467            );
1468        }
1469    }
1470
1471    #[test]
1472    fn an_undeclared_name_has_no_binding_kind() {
1473        let host = host("globalThing();");
1474        let handle = handle_of(&host, "globalThing");
1475        assert!(run::<bool>(
1476            &host,
1477            &format!("ctx.bindingKind({handle}) === undefined")
1478        ));
1479    }
1480
1481    #[test]
1482    fn without_a_resolver_nothing_resolves_rather_than_throwing() {
1483        // A language with no resolver should make rules see "nothing resolves", not a
1484        // TypeError blamed on the rule for calling a function that is missing.
1485        let host = host_without_resolver("import { a } from 'm';\na();");
1486        assert!(run::<bool>(
1487            &host,
1488            "ctx.resolvesToImport(0, 'm', 'a') === false &&
1489             ctx.isImportedFrom(0, '*') === false &&
1490             ctx.isShadowed(0) === false &&
1491             ctx.bindingKind(0) === undefined"
1492        ));
1493    }
1494
1495    // The pattern dialect itself is tested where it now lives, in `lanekeep-lang`'s
1496    // `glob_matching_handles_the_shapes_that_appear_in_rules`. What stays here is
1497    // `matches_a_module_by_glob` above, which is about `ctx.isImportedFrom` reaching it
1498    // through a resolved binding.
1499
1500    #[test]
1501    fn navigation_stays_lazy() {
1502        // The arena must not have materialized the tree just because `ctx` exists.
1503        let host = host("const a = 1; const b = 2; function c() { return [1,2,3] }");
1504        assert!(
1505            host.arena().borrow().is_empty(),
1506            "nothing should be interned yet"
1507        );
1508
1509        let _: () = run(&host, "ctx.kind(ctx.root)");
1510        assert!(
1511            host.arena().borrow().is_empty(),
1512            "reading the root's kind should not intern anything new"
1513        );
1514    }
1515
1516    // --- facts -------------------------------------------------------------------------
1517
1518    /// Run source with a per-file `ctx` and return the facts it emitted.
1519    fn emitted(source: &str) -> Vec<EmittedFact> {
1520        let host = host("const a = 1;");
1521        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1522        sandbox
1523            .eval_with_host::<()>(&host, source)
1524            .expect("evaluates");
1525        host.take_facts()
1526    }
1527
1528    #[test]
1529    fn a_fact_is_captured_with_its_kind_and_payload() {
1530        let facts = emitted("ctx.emitFact({ kind: 'export', symbol: 'parse' })");
1531        assert_eq!(facts.len(), 1);
1532        assert_eq!(facts[0].kind, "export");
1533        assert!(
1534            facts[0].data.contains(r#""symbol":"parse""#),
1535            "{:?}",
1536            facts[0]
1537        );
1538    }
1539
1540    #[test]
1541    fn facts_are_kept_in_emission_order() {
1542        // Within a file, the order a rule emitted in is the only order it can have meant.
1543        let facts = emitted(
1544            "ctx.emitFact({ kind: 'a', n: 1 }); \
1545             ctx.emitFact({ kind: 'b', n: 2 }); \
1546             ctx.emitFact({ kind: 'a', n: 3 });",
1547        );
1548        assert_eq!(
1549            facts.iter().map(|f| f.kind.as_str()).collect::<Vec<_>>(),
1550            vec!["a", "b", "a"]
1551        );
1552    }
1553
1554    #[test]
1555    fn a_fact_without_a_kind_is_rejected() {
1556        // Not dropped. A fact with no kind can never be selected by `ctx.facts(kind)`, so
1557        // accepting it would leave the rule looking correct until reduce found nothing.
1558        let host = host("const a = 1;");
1559        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1560        let error = sandbox
1561            .eval_with_host::<()>(&host, "ctx.emitFact({ symbol: 'parse' })")
1562            .expect_err("is rejected");
1563        assert!(error.to_string().contains("kind"), "{error}");
1564        assert!(host.take_facts().is_empty());
1565    }
1566
1567    #[test]
1568    fn a_fact_with_an_empty_kind_is_rejected() {
1569        let host = host("const a = 1;");
1570        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1571        assert!(
1572            sandbox
1573                .eval_with_host::<()>(&host, "ctx.emitFact({ kind: '' })")
1574                .is_err()
1575        );
1576    }
1577
1578    #[test]
1579    fn a_fact_that_is_not_an_object_is_rejected() {
1580        let host = host("const a = 1;");
1581        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1582        for bad in ["'export'", "42", "null", "undefined"] {
1583            assert!(
1584                sandbox
1585                    .eval_with_host::<()>(&host, &format!("ctx.emitFact({bad})"))
1586                    .is_err(),
1587                "`{bad}` should not be emittable"
1588            );
1589        }
1590    }
1591
1592    #[test]
1593    fn a_cyclic_fact_is_rejected_rather_than_hanging() {
1594        let host = host("const a = 1;");
1595        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1596        let error = sandbox
1597            .eval_with_host::<()>(
1598                &host,
1599                "const f = { kind: 'x' }; f.self = f; ctx.emitFact(f)",
1600            )
1601            .expect_err("is rejected");
1602        // JSON.stringify's own error. Letting it through unchanged is right: it says
1603        // "circular structure", which is more specific than anything this layer knows.
1604        assert!(!error.to_string().is_empty());
1605    }
1606
1607    #[test]
1608    fn the_reduce_surface_is_absent_from_the_per_file_context() {
1609        // The invariant that makes per-file cache entries mean anything: a file's result
1610        // must not depend on any other file.
1611        let host = host("const a = 1;");
1612        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1613        for absent in ["ctx.facts", "ctx.files"] {
1614            let present: bool = sandbox
1615                .eval_with_host(&host, &format!("{absent} !== undefined"))
1616                .expect("evaluates");
1617            assert!(
1618                !present,
1619                "`{absent}` must not exist during the per-file pass"
1620            );
1621        }
1622    }
1623
1624    // --- the reduce context ------------------------------------------------------------
1625
1626    fn reduce_fact(kind: &str, json: &str) -> ReduceFact {
1627        ReduceFact {
1628            kind: kind.to_owned(),
1629            json: json.to_owned(),
1630        }
1631    }
1632
1633    /// The reduce phase's budget in these tests. Generous: nothing here is timing-sensitive.
1634    fn budget() -> std::time::Duration {
1635        std::time::Duration::from_secs(5)
1636    }
1637
1638    /// The per-file `ctx.report` takes `(node, { message })` and this one takes a location
1639    /// and a message, so both spellings of the second argument have to work. Rejecting the
1640    /// object form produced a type-conversion error naming neither argument, which is a
1641    /// remarkably unhelpful thing to be told.
1642    #[test]
1643    fn a_reduce_report_takes_a_string_or_an_options_object() {
1644        for expression in [
1645            r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, 'plain string')",
1646            r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, { message: 'plain string' })",
1647        ] {
1648            let context = ReduceContext::new(vec!["a.ts".to_owned()], vec![]);
1649            let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1650            sandbox
1651                .eval_with_reduce_host::<()>(&context, expression, budget())
1652                .unwrap_or_else(|e| panic!("{expression} should be accepted: {e}"));
1653
1654            let reports = context.take_reports();
1655            assert_eq!(reports.len(), 1, "{expression}");
1656            assert_eq!(
1657                reports[0].message.as_deref(),
1658                Some("plain string"),
1659                "{expression}"
1660            );
1661        }
1662    }
1663
1664    /// Anything else is refused rather than quietly dropped, so a rule reporting with the
1665    /// wrong shape hears about it.
1666    #[test]
1667    fn a_reduce_report_refuses_a_message_that_is_neither() {
1668        let context = ReduceContext::new(vec!["a.ts".to_owned()], vec![]);
1669        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1670        let error = sandbox
1671            .eval_with_reduce_host::<()>(
1672                &context,
1673                r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, { detail: 'wrong key' })",
1674                budget(),
1675            )
1676            .expect_err("should refuse");
1677        assert!(
1678            error.to_string().contains("message"),
1679            "the error should say what it wanted: {error}"
1680        );
1681    }
1682
1683    #[test]
1684    fn facts_come_back_as_objects() {
1685        let context = ReduceContext::new(
1686            vec!["a.ts".to_owned()],
1687            vec![reduce_fact(
1688                "export",
1689                r#"{"kind":"export","symbol":"parse","file":"a.ts"}"#,
1690            )],
1691        );
1692        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1693        let symbol: String = sandbox
1694            .eval_with_reduce_host(&context, "ctx.facts('export')[0].symbol", budget())
1695            .expect("evaluates");
1696        assert_eq!(symbol, "parse");
1697    }
1698
1699    #[test]
1700    fn facts_filter_by_kind_and_default_to_everything() {
1701        let context = ReduceContext::new(
1702            vec![],
1703            vec![
1704                reduce_fact("export", r#"{"kind":"export","file":"a.ts"}"#),
1705                reduce_fact("import", r#"{"kind":"import","file":"b.ts"}"#),
1706                reduce_fact("export", r#"{"kind":"export","file":"c.ts"}"#),
1707            ],
1708        );
1709        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1710        let counts: Vec<i32> = sandbox
1711            .eval_with_reduce_host(
1712                &context,
1713                "[ctx.facts('export').length, ctx.facts('import').length, ctx.facts().length]",
1714                budget(),
1715            )
1716            .expect("evaluates");
1717        assert_eq!(counts, vec![2, 1, 3]);
1718    }
1719
1720    #[test]
1721    fn an_unknown_kind_yields_an_empty_array_rather_than_undefined() {
1722        // So `for (const f of ctx.facts('nope'))` is a no-op instead of a TypeError.
1723        let context = ReduceContext::new(vec![], vec![]);
1724        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1725        let length: i32 = sandbox
1726            .eval_with_reduce_host(&context, "ctx.facts('nope').length", budget())
1727            .expect("evaluates");
1728        assert_eq!(length, 0);
1729    }
1730
1731    #[test]
1732    fn the_file_list_is_visible() {
1733        let context = ReduceContext::new(vec!["a.ts".to_owned(), "b.ts".to_owned()], vec![]);
1734        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1735        let files: Vec<String> = sandbox
1736            .eval_with_reduce_host(&context, "ctx.files", budget())
1737            .expect("evaluates");
1738        assert_eq!(files, vec!["a.ts".to_owned(), "b.ts".to_owned()]);
1739    }
1740
1741    #[test]
1742    fn reporting_names_a_file_of_its_own() {
1743        let context = ReduceContext::new(vec![], vec![]);
1744        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1745        sandbox
1746            .eval_with_reduce_host::<()>(
1747                &context,
1748                "ctx.report({ file: 'b.ts', line: 4, column: 2 }, 'unused export')",
1749                budget(),
1750            )
1751            .expect("evaluates");
1752        assert_eq!(
1753            context.take_reports(),
1754            vec![ReduceReport {
1755                file: "b.ts".to_owned(),
1756                line: 4,
1757                column: 2,
1758                message: Some("unused export".to_owned()),
1759            }]
1760        );
1761    }
1762
1763    #[test]
1764    fn the_message_is_optional() {
1765        let context = ReduceContext::new(vec![], vec![]);
1766        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1767        sandbox
1768            .eval_with_reduce_host::<()>(
1769                &context,
1770                "ctx.report({ file: 'b.ts', line: 1, column: 1 })",
1771                budget(),
1772            )
1773            .expect("evaluates");
1774        let reports = context.take_reports();
1775        assert_eq!(reports.len(), 1);
1776        assert_eq!(reports[0].message, None);
1777    }
1778
1779    #[test]
1780    fn reporting_without_a_position_is_rejected() {
1781        // A cross-file violation with no site is unactionable, and defaulting to 1:1 would
1782        // point a reader at an unrelated line.
1783        let context = ReduceContext::new(vec![], vec![]);
1784        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1785        for bad in [
1786            "ctx.report({ file: 'b.ts' })",
1787            "ctx.report({ line: 1, column: 1 })",
1788            "ctx.report(3)",
1789            "ctx.report('b.ts')",
1790        ] {
1791            let error = sandbox
1792                .eval_with_reduce_host::<()>(&context, bad, budget())
1793                .expect_err("is rejected");
1794            assert!(!error.to_string().is_empty(), "`{bad}` should be rejected");
1795        }
1796        assert!(context.take_reports().is_empty());
1797    }
1798
1799    #[test]
1800    fn the_per_file_surface_is_absent_from_the_reduce_context() {
1801        // Invariant 1: the reduce phase never touches parse trees. A context that offered
1802        // navigation would be lying about what the phase can see.
1803        let context = ReduceContext::new(vec![], vec![]);
1804        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1805        for absent in [
1806            "ctx.emitFact",
1807            "ctx.text",
1808            "ctx.kind",
1809            "ctx.parent",
1810            "ctx.namedChildren",
1811            "ctx.filePath",
1812            "ctx.fileText",
1813            "ctx.root",
1814        ] {
1815            let present: bool = sandbox
1816                .eval_with_reduce_host(&context, &format!("{absent} !== undefined"), budget())
1817                .expect("evaluates");
1818            assert!(
1819                !present,
1820                "`{absent}` must not exist during the reduce phase"
1821            );
1822        }
1823    }
1824
1825    // --- merging the file into a payload -------------------------------------------------
1826
1827    #[test]
1828    fn merge_file_adds_the_field() {
1829        assert_eq!(
1830            merge_file(r#"{"kind":"export"}"#, "src/a.ts"),
1831            r#"{"kind":"export","file":"src/a.ts"}"#
1832        );
1833    }
1834
1835    #[test]
1836    fn merge_file_handles_an_empty_payload() {
1837        assert_eq!(merge_file("{}", "a.ts"), r#"{"file":"a.ts"}"#);
1838    }
1839
1840    #[test]
1841    fn merge_file_overrides_a_file_the_rule_supplied() {
1842        // Last duplicate key wins in JSON, so the host's value is the one that survives —
1843        // a rule cannot misattribute a violation to a file it did not come from.
1844        let merged = merge_file(r#"{"kind":"export","file":"lies.ts"}"#, "truth.ts");
1845        assert!(merged.ends_with(r#""file":"truth.ts"}"#), "{merged}");
1846
1847        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1848        let file: String = sandbox
1849            .eval(&format!("JSON.parse({merged:?}).file"))
1850            .expect("parses");
1851        assert_eq!(file, "truth.ts");
1852    }
1853
1854    #[test]
1855    fn merge_file_escapes_the_path() {
1856        // A path is untrusted input as far as this function is concerned: it comes from the
1857        // filesystem, and a quote in it would otherwise produce a payload that no longer
1858        // parses — or worse, one that parses into something else.
1859        let awkward = "a\"b\\c\nd.ts";
1860        let merged = merge_file("{}", awkward);
1861        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1862        let file: String = sandbox
1863            .eval(&format!("JSON.parse({merged:?}).file"))
1864            .expect("parses");
1865        assert_eq!(file, awkward);
1866    }
1867
1868    // --- scoped queries ------------------------------------------------------------------
1869
1870    /// A host with a language attached, so the query functions exist.
1871    fn host_with_language(source: &str) -> HostContext {
1872        HostContext::new(parse(source), source.to_owned(), "src/example.ts")
1873            .with_resolver(Arc::new(lanekeep_lang_js::binding::JsBindingResolver))
1874            .with_language(Arc::new(TypeScript))
1875    }
1876
1877    #[test]
1878    fn a_subtree_query_finds_only_what_is_inside() {
1879        // The point of scoping: a rule that matched a function and wants to look inside it
1880        // should not have to filter the whole file's matches by position.
1881        let source = "function a() { const x = 1; }\nfunction b() { const y = 2; const z = 3; }\n";
1882        let host = host_with_language(source);
1883        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1884
1885        let names: Vec<String> = sandbox
1886            .eval_with_host(
1887                &host,
1888                "const fns = ctx.querySubtree(ctx.root, '(function_declaration) @fn');\n\
1889                 const inner = ctx.querySubtree(fns[1].fn, '(variable_declarator name: (identifier) @name)');\n\
1890                 inner.map((m) => ctx.text(m.name))",
1891            )
1892            .expect("evaluates");
1893
1894        assert_eq!(names, vec!["y".to_owned(), "z".to_owned()]);
1895    }
1896
1897    #[test]
1898    fn a_subtree_query_with_no_matches_is_an_empty_array() {
1899        // So `for (const m of ctx.querySubtree(...))` is a no-op rather than a TypeError.
1900        let host = host_with_language("const a = 1;\n");
1901        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1902        let length: i32 = sandbox
1903            .eval_with_host(
1904                &host,
1905                "ctx.querySubtree(ctx.root, '(debugger_statement) @d').length",
1906            )
1907            .expect("evaluates");
1908        assert_eq!(length, 0);
1909    }
1910
1911    #[test]
1912    fn an_invalid_query_is_reported_to_the_rule() {
1913        let host = host_with_language("const a = 1;\n");
1914        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1915        let error = sandbox
1916            .eval_with_host::<()>(&host, "ctx.querySubtree(ctx.root, '(((')")
1917            .expect_err("is rejected");
1918        assert!(!error.to_string().is_empty());
1919    }
1920
1921    #[test]
1922    fn a_general_predicate_is_rejected_by_both_query_functions() {
1923        // querySubtree and closestAncestor compile through the same path, so a predicate
1924        // lanekeep refuses must be refused by both — a rule cannot smuggle one past by
1925        // choosing a different way in.
1926        let host = host_with_language("const a = 1;\n");
1927        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1928
1929        let error = sandbox
1930            .eval_with_host::<()>(
1931                &host,
1932                "ctx.querySubtree(ctx.root, '((identifier) @id (#is? @id \"a\"))')",
1933            )
1934            .expect_err("is rejected");
1935        assert!(error.to_string().contains("#is?"), "{error}");
1936
1937        let error = sandbox
1938            .eval_with_host::<()>(
1939                &host,
1940                "const d = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
1941                 ctx.closestAncestor(d[0].n, '((program) @p (#is? @p \"x\"))')",
1942            )
1943            .expect_err("is rejected");
1944        assert!(error.to_string().contains("#is?"), "{error}");
1945    }
1946
1947    #[test]
1948    fn closest_ancestor_finds_the_nearest_one() {
1949        // Nearest, not outermost — the whole reason a rule walks upward.
1950        let source = "function outer() { function inner() { const x = 1; } }\n";
1951        let host = host_with_language(source);
1952        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1953
1954        let name: String = sandbox
1955            .eval_with_host(
1956                &host,
1957                "const decls = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
1958                 const found = ctx.closestAncestor(decls[0].n, '(function_declaration name: (identifier) @name) @fn');\n\
1959                 ctx.text(found.name)",
1960            )
1961            .expect("evaluates");
1962
1963        assert_eq!(name, "inner");
1964    }
1965
1966    #[test]
1967    fn closest_ancestor_returns_undefined_when_nothing_matches() {
1968        // `undefined` rather than an empty object: an empty object is truthy, so
1969        // `if (!ctx.closestAncestor(...))` would silently take the wrong branch.
1970        let host = host_with_language("const a = 1;\n");
1971        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1972        let absent: bool = sandbox
1973            .eval_with_host(
1974                &host,
1975                "const d = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
1976                 ctx.closestAncestor(d[0].n, '(class_declaration) @c') === undefined",
1977            )
1978            .expect("evaluates");
1979        assert!(absent);
1980    }
1981
1982    #[test]
1983    fn closest_ancestor_does_not_match_the_node_itself_from_inside() {
1984        // A query matching something *within* an ancestor must not make that ancestor the
1985        // answer — otherwise the outermost node matches every time.
1986        let source = "function outer() { function inner() { const x = 1; } }\n";
1987        let host = host_with_language(source);
1988        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1989
1990        let name: String = sandbox
1991            .eval_with_host(
1992                &host,
1993                "const d = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
1994                 const found = ctx.closestAncestor(d[0].n, '(statement_block) @block');\n\
1995                 ctx.kind(found.block)",
1996            )
1997            .expect("evaluates");
1998        assert_eq!(name, "statement_block");
1999    }
2000
2001    #[test]
2002    fn the_query_functions_are_absent_without_a_language() {
2003        // A stub returning nothing would look like a query that matched nothing.
2004        let host = host("const a = 1;");
2005        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2006        for absent in ["ctx.querySubtree", "ctx.closestAncestor"] {
2007            let present: bool = sandbox
2008                .eval_with_host(&host, &format!("{absent} !== undefined"))
2009                .expect("evaluates");
2010            assert!(!present, "`{absent}` should not exist without a language");
2011        }
2012    }
2013
2014    // --- ctx.loc and ctx.today -----------------------------------------------------------
2015
2016    #[test]
2017    fn loc_gives_the_shape_a_fact_and_a_reduce_report_both_use() {
2018        // A rule that emits `at: ctx.loc(node)` and later calls `ctx.report(f.at)` needs no
2019        // glue between the two, which is the whole reason this returns an object.
2020        let source = "const alpha = 1;\nconst beta = 2;\n";
2021        let host = host(source);
2022        let handle = handle_of(&host, "beta");
2023        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2024
2025        let rendered: String = sandbox
2026            .eval_with_host(
2027                &host,
2028                &format!("const l = ctx.loc({handle}); `${{l.file}}:${{l.line}}:${{l.column}}`"),
2029            )
2030            .expect("evaluates");
2031        assert_eq!(rendered, "src/example.ts:2:7");
2032    }
2033
2034    #[test]
2035    fn loc_at_an_unresolvable_handle_is_undefined() {
2036        // The same posture as reporting at one: nothing, rather than a made-up position a
2037        // reader would go and look at.
2038        let host = host("const a = 1;");
2039        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2040        let absent: bool = sandbox
2041            .eval_with_host(&host, "ctx.loc(9999) === undefined")
2042            .expect("evaluates");
2043        assert!(absent);
2044    }
2045
2046    #[test]
2047    fn today_is_what_the_host_supplied() {
2048        let host = host("const a = 1;").with_today("2026-08-01");
2049        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2050        let today: String = sandbox
2051            .eval_with_host(&host, "ctx.today")
2052            .expect("evaluates");
2053        assert_eq!(today, "2026-08-01");
2054    }
2055
2056    #[test]
2057    fn today_is_absent_when_the_host_supplied_none() {
2058        // Absent rather than empty: a rule comparing against `undefined` would silently take
2059        // a branch nobody intended, where a missing property is a `TypeError` naming what it
2060        // reached for.
2061        let host = host("const a = 1;");
2062        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2063        let absent: bool = sandbox
2064            .eval_with_host(&host, "ctx.today === undefined")
2065            .expect("evaluates");
2066        assert!(absent);
2067    }
2068
2069    #[test]
2070    fn reading_today_is_observed_and_not_reading_it_is_not() {
2071        // The property the cache depends on. A plain value would be indistinguishable from
2072        // an unread one, and every file would have to be treated as date-dependent.
2073        let unread = host("const a = 1;").with_today("2026-08-01");
2074        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2075        sandbox
2076            .eval_with_host::<i32>(&unread, "1 + 1")
2077            .expect("evaluates");
2078        assert!(!unread.date_was_read(), "nothing read the date");
2079
2080        let read = host("const a = 1;").with_today("2026-08-01");
2081        sandbox
2082            .eval_with_host::<String>(&read, "ctx.today")
2083            .expect("evaluates");
2084        assert!(read.date_was_read(), "the read was not observed");
2085    }
2086
2087    #[test]
2088    fn today_does_not_bring_a_clock_with_it() {
2089        // The invariant it sits next to: a rule gets a date, not the ability to observe time.
2090        let host = host("const a = 1;").with_today("2026-08-01");
2091        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2092        for absent in ["Date", "performance"] {
2093            let present: bool = sandbox
2094                .eval_with_host(&host, &format!("typeof {absent} !== 'undefined'"))
2095                .expect("evaluates");
2096            assert!(!present, "`{absent}` must not exist");
2097        }
2098    }
2099}