Skip to main content

lanekeep_types/tsc/
mod.rs

1//! The `tsc` provider: the project's own TypeScript compiler, behind [`TypeProvider`].
2//!
3//! # What this widens, stated where it happens
4//!
5//! Every other answer in this crate comes from bytes lanekeep read through a `FileAccess`,
6//! confined to the project root. This one comes from a process lanekeep started, running the
7//! project's own toolchain, reading whatever that toolchain reads — its `node_modules`
8//! included. That is a real widening of `docs/architecture.md` §13's confinement and it is
9//! opt-in and off by default.
10//!
11//! It is not new ambient authority in the binary: `crates/lanekeep-core/src/changed.rs`
12//! already spawns `git` for `--since` and `--staged`, with the same `env_remove` hygiene and
13//! the same two-shape error. That spawn is the model this one copies.
14//!
15//! # Why one process with serialized requests
16//!
17//! A `Program` is expensive to build and cheap to query, so the state has to outlive a single
18//! question. A process per query would rebuild it every time; a thread pool over one process
19//! would need the driver to interleave answers, which buys nothing because the checker is
20//! single-threaded anyway. One process, one mutex, one request in flight.
21//!
22//! # How a dependency of an answer reaches the cache key
23//!
24//! Not through [`Query::files`]. The compiler reads what a `tsconfig.json` tells it to read,
25//! which is a *program* rather than a per-question set of files, so recording those reads one
26//! question at a time would record them after the answers that depended on them. Instead
27//! [`TscProvider::programs`] asks the driver for the read set of the programs this run's files
28//! fall under and
29//! [`TypeProvider::begin_run`] folds that listing into the run key before any answer exists
30//! (spec §5.6).
31//!
32//! **What the listing is: every path a compiler host was asked to read, with its content
33//! hash.** The driver wraps `readFile` on the host it hands to config parsing and to
34//! `createProgram`, so a `tsconfig.json`, every file in its `extends` chain, every
35//! `package.json` module resolution consulted and every source file are all in it — none of
36//! which except the last appears in a program's `getSourceFiles()`. The driver's own
37//! `exportedType` and `complete` resolve specifiers outside any program, and they read through
38//! the same recording host, so what they consult is recorded too.
39//!
40//! **What "no checkout location" means, exactly.** Every path is spelled relative to the
41//! project root, `..` segments included, and the root and every path crossing the driver's
42//! boundary are `realpath`ed first. That is what the guarantee rests on: TypeScript resolves a
43//! `node_modules` specifier through `realpath`, so a root reached through a symlink — which on
44//! macOS `$TMPDIR` always is — would otherwise put every resolved dependency *outside* the root
45//! and list it as `../../../private/var/…/<the project's own directory name>/…`, which is the
46//! checkout's location and, under pnpm, most of a listing. What the guarantee does **not**
47//! cover is a file that genuinely lives outside the root: a monorepo sibling is listed as
48//! `../shared/b.ts`, which encodes the root's depth relative to that file and nothing else
49//! about where either sits.
50//!
51//! Three things are deliberately outside the listing. The `typescript` package's own directory
52//! is excluded whole, because its bytes are a function of the compiler version, which
53//! [`TscProvider::identity`] already folds — the package rather than only its `lib/`, since
54//! `types.typescript` may point outside the root and the manifest resolution reads on the way
55//! there would be `../`-prefixed.
56//!
57//! Absence probes — `fileExists`, `directoryExists`, a `readFile` that answered nothing — are
58//! not rows either, because an absence has no bytes to hash. They are not forgotten: a build
59//! records every one it was denied under the project root, and a refresh re-probes them, so a
60//! package that *appears* rebuilds the program that was denied it and the key is then recomputed
61//! from that build's own read set. Without that half a resolution that changed because a file
62//! arrived was invisible — `npm install` left the listing byte-identical while `lanekeep check`
63//! over the same bytes answered from the new package. See `buildAbsences` in `driver.mjs`.
64//!
65//! And a read a *query* makes outside any program is not a row. `isAssignableTo` resolves a
66//! module argument written by a rule rather than by the file, and `complete` re-resolves every
67//! import; the `package.json`s consulted on the way are inputs to that one answer and to no
68//! program the run built. As listing rows they made the run key depend on which questions the
69//! session happened to be asked — so a held session keyed differently from `lanekeep check`
70//! over identical bytes, and differently again depending on which files were cache misses last
71//! request, and such a row was never revalidated because no program held it. The driver reports
72//! them with the answer instead and [`TscProvider`] records each through the asking [`Query`]'s
73//! own [`FileAccess`]: a per-entry tracked read, exactly as every builtin-provider read is, so
74//! that file's cache entry depends on it and nothing else does. See
75//! `TscProvider::ask_recording`.
76//!
77//! Any file the compiler read whose bytes moved therefore changes the key for the whole run,
78//! which is stricter than per-file tracked reads rather than weaker than them; and any file a
79//! question read moves that question's file alone.
80
81use std::collections::BTreeSet;
82use std::io::{BufRead, BufReader, Read, Write};
83use std::path::{Path, PathBuf};
84use std::process::{Child, ChildStdin, Command, Stdio};
85use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, sync_channel};
86use std::sync::{Mutex, PoisonError};
87
88use lanekeep_core::{AnalysisBudget, FileAccess, FilePath, TypesConfig, analysis_overrun_fallback};
89
90use crate::provider::{BeginRunError, Query, TypeProvider};
91use crate::types::{Primitive, Symbol, Type};
92
93/// The driver, embedded so no installation step can leave it stale or absent.
94///
95/// **Not covered by `oracle_identity()`**: `build.rs` folds only `*.rs`. Its bytes reach the
96/// cache key through [`TscProvider::identity`] and nowhere else, which is why that fold is
97/// load-bearing rather than defensive.
98const DRIVER: &str = include_str!("driver.mjs");
99
100/// How much of the sidecar's stderr is kept for a diagnostic.
101///
102/// Bounded because the buffer is memory the run pays for and a driver in a loop could fill it,
103/// and because what a reader needs is the first failure rather than the thousandth.
104const STDERR_KEPT: usize = 8 * 1024;
105
106/// Why a provider could not answer.
107///
108/// Two shapes and a timeout, matching `lanekeep_core::changed::ChangeError` plus the one thing
109/// a long-lived process has that a `git` invocation does not.
110#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
111pub enum ProviderError {
112    /// The command could not be run at all — no Node, no such binary, no permission.
113    ///
114    /// Split from [`Self::Unloadable`] because the remedies are different and only one of them
115    /// can work. This one used to carry both cases, so a project whose Node ran perfectly well
116    /// and whose `typescript` package could not be found was told to put `types.command` on
117    /// PATH — advice about the one part of the configuration that was already right.
118    #[error(
119        "cannot start the type provider: {0}\n  \
120         `types.provider` is `tsc`, which runs the project's own toolchain, so it needs \
121         `types.command` on PATH"
122    )]
123    Unavailable(String),
124
125    /// It ran, and could not load a `typescript` this driver can use.
126    ///
127    /// The command is fine; the package is what has to move. See [`Self::Unavailable`].
128    #[error(
129        "the type provider started but could not use the project's `typescript`: {0}\n  \
130         point `types.typescript` at the package to load"
131    )]
132    Unloadable(String),
133
134    /// The driver could not be written into the project's `.lanekeep/`.
135    ///
136    /// Its own variant because it is the one failure here that is about neither the command nor
137    /// the package: nothing has been run yet, and both other remedies — put `types.command` on
138    /// PATH, point `types.typescript` somewhere else — are advice about a configuration that
139    /// may be perfectly correct. What has to move is the directory's permissions.
140    #[error(
141        "cannot write the type provider's driver to {0}\n  \
142         `types.provider` is `tsc`, which writes its sidecar into the project's `.lanekeep/`, \
143         so that directory has to be writable"
144    )]
145    Unwritable(String),
146
147    /// It ran and refused: a request failed, or the sidecar died holding one.
148    #[error("the type provider refused: {0}")]
149    Refused(String),
150
151    /// A request outlived what was left of `timeouts.analysis`.
152    #[error(
153        "the type provider did not answer within the remaining `timeouts.analysis` budget\n  \
154         the sidecar has been killed; raise `timeouts.analysis` or set `types.provider` to \
155         `builtin`"
156    )]
157    Timeout,
158}
159
160/// Whether the sidecar is still running when a refusal is built.
161///
162/// It decides one thing: whether [`TscProvider::refused`] may join the stderr drain thread
163/// before reading its buffer. Joining is what makes the child's dying words *there* rather
164/// than racing the read — and it terminates only because the child is gone and its stderr pipe
165/// with it, so asking for it on a live sidecar would hang the run instead.
166#[derive(Clone, Copy)]
167enum Sidecar {
168    /// Killed and reaped by the caller, so the drain thread is about to end.
169    Gone,
170    /// Still serving. Whatever it has written so far is what the diagnostic gets.
171    Live,
172}
173
174/// A failed exchange, before it becomes a [`ProviderError`].
175///
176/// The conversion needs the session lock released — `refused` joins a thread and reads a
177/// second mutex — so the locked half of a request names the failure and the caller builds it.
178enum Failure {
179    /// The request outlived the remaining budget.
180    Timeout,
181    /// The sidecar said no, or stopped being able to say anything.
182    Refused(String, Sidecar),
183}
184
185/// The live sidecar, and everything a request needs.
186struct Session {
187    child: Child,
188    stdin: ChildStdin,
189    lines: Receiver<std::io::Result<String>>,
190    next_id: u64,
191}
192
193/// The project's own TypeScript compiler, driven through a sidecar process.
194pub struct TscProvider {
195    session: Mutex<Session>,
196    /// The project root every relative path in a request is resolved against. Held rather
197    /// than left to the child's working directory, so a question names an absolute file and
198    /// no answer depends on where the driver happens to have been started.
199    root: PathBuf,
200    /// The handshake's budget; `hello` runs under it, and so does anything asked before a run
201    /// begins.
202    budget: AnalysisBudget,
203    /// The current run's budget, set by `begin_run`. A provider a session holds across runs
204    /// (plan 6) gets a fresh clock per run rather than one that ran out during the first.
205    run_budget: Mutex<Option<AnalysisBudget>>,
206    /// Whatever the sidecar wrote to stderr, kept for a diagnostic rather than inherited.
207    ///
208    /// Bytes, not text: the buffer is truncated at a byte bound, and decoding each 4 KiB read
209    /// separately would mangle a character that straddles two of them. It is decoded once,
210    /// where it is read.
211    stderr: std::sync::Arc<Mutex<Vec<u8>>>,
212    /// The drain thread, kept so a refusal can join it before reading the buffer above.
213    ///
214    /// Without the join, a child that died mid-sentence is read before its last write lands
215    /// and the diagnostic is the empty string — which is exactly the case the buffer exists
216    /// for. Taken out of the slot when joined, so a second refusal does not try again.
217    stderr_drain: Mutex<Option<std::thread::JoinHandle<()>>>,
218    /// The first error this provider produced, kept forever.
219    ///
220    /// **The contract Task 9 wires the engine to.** Every [`TypeProvider`] method answers "I
221    /// don't know" on a failure, because the trait has nowhere to put one — so without this a
222    /// timed-out or refused sidecar would let the file mid-check finish *degraded* and be
223    /// committed under a valid cache key, which is a limit quietly degrading a run instead of
224    /// cancelling it. The engine asks [`TscProvider::failure`] after every file; a `Some`
225    /// means discard that file's result and cancel the run with the error. First rather than
226    /// last, because the first is the one that explains the rest.
227    ///
228    /// A `Mutex` rather than a `Cell`: [`TypeProvider`] is `Send + Sync` and a provider is
229    /// shared across the engine's workers, which a `Cell` is not.
230    failure: Mutex<Option<ProviderError>>,
231    typescript_version: String,
232    identity: Vec<u8>,
233    programs_hash: Mutex<[u8; 32]>,
234    /// How many of the run's files were typed by the ad-hoc program, for [`Self::notices`].
235    ///
236    /// Set by `programs`, per run, so a provider a session holds across runs reports what the
237    /// current run found rather than what the first one did.
238    adhoc: Mutex<usize>,
239    /// The last `programs` listing's paths, as the driver spelled them — relative to the
240    /// project root, `adhoc` entries included.
241    ///
242    /// Set by [`Self::programs`], which is `begin_run`'s only caller, so this always reflects
243    /// the run `begin_run` most recently prepared. [`TypeProvider::dependency_paths`] answers
244    /// it, which is what puts a declaration file the compiler read — never a tracked read on
245    /// any [`Query`] — into `--watch`'s allowlist.
246    dependency_paths: Mutex<BTreeSet<FilePath>>,
247}
248
249impl std::fmt::Debug for TscProvider {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        f.debug_struct("TscProvider")
252            .field("typescript", &self.typescript_version)
253            .finish_non_exhaustive()
254    }
255}
256
257impl Drop for TscProvider {
258    fn drop(&mut self) {
259        // Killed rather than left to notice a closed stdin, and waited for rather than left as
260        // a zombie. A driver mid-`createProgram` does not poll its input, so a run that ended
261        // would otherwise leave a process holding a gigabyte of checker state for as long as
262        // that build takes.
263        if let Ok(mut session) = self.session.lock() {
264            let _ = session.child.kill();
265            let _ = session.child.wait();
266        }
267    }
268}
269
270impl TscProvider {
271    /// Write the driver, start the sidecar, and complete the handshake.
272    ///
273    /// # Errors
274    ///
275    /// [`ProviderError::Unavailable`] when the command cannot be run,
276    /// [`ProviderError::Unloadable`] when it runs and the `typescript` package cannot be used,
277    /// [`ProviderError::Unwritable`] when the driver cannot be written into the project's
278    /// `.lanekeep/`, [`ProviderError::Refused`] when the sidecar answers something this cannot
279    /// read, and [`ProviderError::Timeout`] when `hello` outlives the budget. The first two are
280    /// split because only one of their remedies can work; see the variants.
281    pub fn spawn(
282        root: &Path,
283        config: &TypesConfig,
284        budget: AnalysisBudget,
285    ) -> Result<Self, ProviderError> {
286        Self::spawn_with_env(root, config, budget, &[])
287    }
288
289    /// [`TscProvider::spawn`] with extra environment for the child.
290    ///
291    /// The only caller that passes anything is the budget test, which needs a real process to
292    /// spend real time. Kept as a separate entry point rather than as a parameter on `spawn`
293    /// so that no production call site can pass one by accident.
294    ///
295    /// # Errors
296    ///
297    /// As [`TscProvider::spawn`].
298    pub fn spawn_with_env(
299        root: &Path,
300        config: &TypesConfig,
301        budget: AnalysisBudget,
302        env: &[(&str, &str)],
303    ) -> Result<Self, ProviderError> {
304        // Before `write_driver`, so a configuration that cannot start anything leaves no
305        // `.lanekeep/` behind in a project that may never have had one.
306        let (program, arguments) = config
307            .command
308            .split_first()
309            .ok_or_else(|| ProviderError::Unavailable("`types.command` is empty".to_owned()))?;
310
311        // Absolute, once, before anything is built from it — because the child's working
312        // directory is this same root, and two of its arguments are paths joined onto it. A
313        // relative root was therefore applied twice: node resolved
314        // `<root>/.lanekeep/types-driver-….mjs` against a cwd that was already `<root>` and
315        // reported a missing module at `<root>/<root>/…`, a path nobody wrote. `lanekeep check
316        // .` and `lanekeep check src` are ordinary invocations, so this was every relative one.
317        //
318        // `absolute` rather than `canonicalize`: the root need only stop depending on a working
319        // directory, and the driver realpaths it and every path crossing its boundary anyway —
320        // which is what keeps the listing free of the checkout's location. Resolving links here
321        // as well would put a second spelling of the root into `.lanekeep/`'s location for no
322        // gain.
323        //
324        // `types.command[0]` is deliberately **not** given this treatment: it is a program
325        // name, resolved by the OS through `PATH`, and joining it onto the project root would
326        // make `node` mean `<root>/node`.
327        let root = std::path::absolute(root).map_err(|e| {
328            ProviderError::Unavailable(format!("cannot resolve the project root: {e}"))
329        })?;
330
331        let driver = write_driver(&root)?;
332
333        let mut command = Command::new(program);
334        command
335            .args(arguments)
336            .arg(&driver)
337            .arg(&root)
338            .arg(&config.typescript)
339            .current_dir(&root)
340            .stdin(Stdio::piped())
341            .stdout(Stdio::piped())
342            // Captured, not inherited. A stack trace out of the driver names a broken
343            // `tsconfig.json` or a lanekeep bug and must reach the reader — but through this
344            // provider's own error message, not by interleaving with lanekeep's reporters on a
345            // stream whose bytes are part of the tool's output.
346            .stderr(Stdio::piped())
347            // The same hygiene `changed.rs` applies to `git`: a variable the parent inherited
348            // must not decide what the child resolves.
349            .env_remove("NODE_OPTIONS")
350            .env_remove("NODE_PATH")
351            .env_remove("TS_NODE_PROJECT")
352            // Test-only, and removed for the same reason as the other three: a variable
353            // lanekeep never sets must not be able to arrive from the parent's environment
354            // and make every request spend real time. The one caller that wants it sets it
355            // back below, which is why the loop runs after the removes.
356            .env_remove("LANEKEEP_TSC_DRIVER_DELAY_MS");
357        for (key, value) in env {
358            command.env(key, value);
359        }
360
361        let mut child = command
362            .spawn()
363            .map_err(|e| ProviderError::Unavailable(e.to_string()))?;
364
365        let stdin = child
366            .stdin
367            .take()
368            .ok_or_else(|| ProviderError::Unavailable("no stdin on the sidecar".to_owned()))?;
369        let stdout = child
370            .stdout
371            .take()
372            .ok_or_else(|| ProviderError::Unavailable("no stdout on the sidecar".to_owned()))?;
373        let child_stderr = child
374            .stderr
375            .take()
376            .ok_or_else(|| ProviderError::Unavailable("no stderr on the sidecar".to_owned()))?;
377
378        let lines = read_lines(stdout)?;
379        let (stderr, stderr_drain) = drain_stderr(child_stderr)?;
380
381        let mut provider = Self {
382            session: Mutex::new(Session {
383                child,
384                stdin,
385                lines,
386                next_id: 0,
387            }),
388            root,
389            budget,
390            run_budget: Mutex::new(None),
391            stderr,
392            stderr_drain: Mutex::new(Some(stderr_drain)),
393            failure: Mutex::new(None),
394            typescript_version: String::new(),
395            identity: Vec::new(),
396            programs_hash: Mutex::new([0; 32]),
397            adhoc: Mutex::new(0),
398            dependency_paths: Mutex::new(BTreeSet::new()),
399        };
400
401        // The handshake, before anything else and before `run_key`. Its answer is a cache-key
402        // input, so a run that has not had it cannot key anything.
403        let hello = provider.request("hello", &serde_json::json!({}))?;
404        if let Some(error) = hello.get("error").and_then(serde_json::Value::as_str) {
405            // The package could not be loaded at all. Named with the specifier as configured,
406            // because the usual cause is a layout, not a typo: a pnpm workspace has no root
407            // `node_modules/typescript`, and the remedy is `types.typescript` naming a
408            // workspace package's copy.
409            return Err(ProviderError::Unloadable(format!(
410                "{error}; a pnpm workspace has no root `node_modules/typescript` — point \
411                 `types.typescript` at a workspace package's copy"
412            )));
413        }
414        let version = hello
415            .get("typescript")
416            .and_then(serde_json::Value::as_str)
417            .ok_or_else(|| provider.refused("`hello` carried no version", Sidecar::Live))?
418            .to_owned();
419        if let Some(missing) = hello
420            .get("unsupported")
421            .and_then(serde_json::Value::as_array)
422        {
423            let missing: Vec<&str> = missing
424                .iter()
425                .filter_map(serde_json::Value::as_str)
426                .collect();
427            return Err(ProviderError::Unloadable(format!(
428                "typescript {version} at `{}` does not provide the compiler API the driver \
429                 needs ({}); the tsc provider is written against the TypeScript 5.x compiler \
430                 API and tested through 6.0.3",
431                config.typescript,
432                missing.join(", "),
433            )));
434        }
435
436        provider.identity = fold_identity(&version, DRIVER, config);
437        provider.typescript_version = version;
438        Ok(provider)
439    }
440
441    /// The TypeScript version the sidecar loaded.
442    #[must_use]
443    pub fn typescript_version(&self) -> &str {
444        &self.typescript_version
445    }
446
447    /// Build every program the run's files belong to, and record their hash.
448    ///
449    /// Called once at prepare, before `run_key`. Eager because §5.6's dependency mechanism for
450    /// this provider is the whole program listing rather than per-query tracked reads: the
451    /// listing is the key, so it has to exist before a key does.
452    ///
453    /// # Errors
454    ///
455    /// As [`TscProvider::spawn`].
456    pub fn programs(&self, files: &[FilePath]) -> Result<(), ProviderError> {
457        // Absolute, like every other request. A relative path is resolved by the driver
458        // against its working directory, and `process.cwd()` answers the *real* path — on
459        // macOS `/private/var/...` where the root was given as `/var/...` — so the file
460        // stopped being under `projectRoot`, its `tsconfig.json` was never found, and the
461        // listing carried the checkout's location instead of a relative path.
462        //
463        // Filtered to what the driver can parse. Discovery hands over the whole corpus, and a
464        // repository is mostly not TypeScript: a file the compiler has no `ScriptKind` for
465        // reaches no program and contributes no row, so asking about it is work with no answer.
466        let listed: Vec<String> = files
467            .iter()
468            .filter(|file| typed_extension(file.as_str()))
469            .map(|file| self.absolute(file))
470            .collect();
471        let answer = self.request("programs", &serde_json::json!({ "files": listed }))?;
472        let hash = fold_programs(&answer).inspect_err(|e| self.remember(e))?;
473        if let Ok(mut slot) = self.programs_hash.lock() {
474            *slot = hash;
475        }
476        let adhoc = adhoc_count(&answer).inspect_err(|e| self.remember(e))?;
477        if let Ok(mut slot) = self.adhoc.lock() {
478            *slot = adhoc;
479        }
480        let paths = programs_paths(&answer).inspect_err(|e| self.remember(e))?;
481        if let Ok(mut slot) = self.dependency_paths.lock() {
482            *slot = paths;
483        }
484        Ok(())
485    }
486
487    /// The first error this provider produced, if it has produced one.
488    ///
489    /// See the `failure` field: every [`TypeProvider`] method answers `None`/`false` on a
490    /// failure because the trait has nowhere to put an error, so this is the only way a caller
491    /// can tell "the compiler says there is no type here" from "the compiler is gone". The
492    /// engine asks after every file and cancels the run when it is `Some`.
493    #[must_use]
494    pub fn failure(&self) -> Option<ProviderError> {
495        // Poison-tolerant: a panic in another thread while this lock was held must not turn a
496        // recorded failure into `None`, which is the answer that lets a degraded run commit.
497        // The value behind it is a plain `Option<ProviderError>` with no invariant a panic
498        // could have left half-applied.
499        self.failure
500            .lock()
501            .unwrap_or_else(PoisonError::into_inner)
502            .clone()
503    }
504
505    /// Write one raw line to the sidecar, outside the request protocol.
506    ///
507    /// Test-only, and the only way to reach the id-mismatch path through the real driver: the
508    /// driver answers a line it cannot parse with `id: 0`, which is then sitting in the stream
509    /// ahead of the next request's own answer.
510    #[cfg(test)]
511    fn write_raw(&self, line: &str) {
512        if let Ok(mut session) = self.session.lock() {
513            let _ = session.stdin.write_all(line.as_bytes());
514            let _ = session.stdin.flush();
515        }
516    }
517
518    /// Kill the sidecar under the provider's feet.
519    ///
520    /// Test-only. Forcing a refusal needs a sidecar that has stopped answering, and there is
521    /// no configuration that produces one on demand.
522    #[cfg(test)]
523    fn kill_sidecar(&self) {
524        if let Ok(mut session) = self.session.lock() {
525            let _ = stop(&mut session);
526        }
527    }
528
529    /// Keep an error if it is the first one. Later ones are dropped, not overwritten.
530    fn remember(&self, error: &ProviderError) {
531        // Poison-tolerant for the reason [`TscProvider::failure`] gives: dropping the record of
532        // a failure is the one outcome that cannot be allowed, and a poisoned lock here would
533        // do exactly that.
534        self.failure
535            .lock()
536            .unwrap_or_else(PoisonError::into_inner)
537            .get_or_insert_with(|| error.clone());
538    }
539
540    /// The hash of every program's files, as `analysis_hash` folds it.
541    #[must_use]
542    pub fn programs_hash(&self) -> [u8; 32] {
543        self.programs_hash.lock().map_or([0; 32], |slot| *slot)
544    }
545
546    /// The budget every request after the handshake runs under: the current run's when
547    /// `begin_run` set one, else the handshake's.
548    ///
549    /// Cloned rather than copied, and a clone shares the accumulator every [`AnalysisBudget`]
550    /// clone shares — so what this charges is what the engine's own copy reads.
551    fn current_budget(&self) -> AnalysisBudget {
552        self.run_budget
553            .lock()
554            .unwrap_or_else(PoisonError::into_inner)
555            .clone()
556            .unwrap_or_else(|| self.budget.clone())
557    }
558
559    /// Whatever the sidecar has said on stderr, as a suffix for a diagnostic.
560    ///
561    /// Empty when it has said nothing, so an error that already reads well is not given a
562    /// blank tail.
563    fn stderr_tail(&self) -> String {
564        let Ok(kept) = self.stderr.lock() else {
565            return String::new();
566        };
567        // Decoded once, here, over the whole buffer: the drain thread truncates at a byte
568        // bound and a character can straddle two of its reads.
569        let text = String::from_utf8_lossy(&kept);
570        if text.trim().is_empty() {
571            return String::new();
572        }
573        format!("\n  the sidecar wrote on stderr:\n{}", text.trim_end())
574    }
575
576    /// Wait for the drain thread to finish, so the buffer holds everything the child said.
577    ///
578    /// Only ever called with the child already killed and reaped — see [`Sidecar`]. The thread
579    /// ends when its read of a closed pipe returns zero, which cannot happen while the sidecar
580    /// still holds the write end.
581    fn join_stderr(&self) {
582        let handle = self
583            .stderr_drain
584            .lock()
585            .ok()
586            .and_then(|mut slot| slot.take());
587        if let Some(handle) = handle {
588            let _ = handle.join();
589        }
590    }
591
592    /// The absolute path of a file in this project, as the driver names files.
593    ///
594    /// Forward slashes throughout: the path is interpolated into JSON, where a Windows
595    /// separator opens an escape (`AGENTS.md`'s "a Windows path interpolated into a JSON
596    /// string is invalid JSON"), and `path.resolve` accepts either separator.
597    fn absolute(&self, file: &FilePath) -> String {
598        self.root
599            .join(file.as_str())
600            .to_string_lossy()
601            .replace('\\', "/")
602    }
603
604    /// `{"file", "start", "end"}` — the three fields every position-taking op shares.
605    fn locate(&self, q: &Query<'_>) -> serde_json::Value {
606        serde_json::json!({
607            "file": self.absolute(q.file),
608            "start": q.node.start_byte(),
609            "end": q.node.end_byte(),
610        })
611    }
612
613    /// One position-taking request, and its answer.
614    fn ask_about(&self, op: &str, q: &Query<'_>) -> Result<serde_json::Value, ProviderError> {
615        self.ask_recording(op, &self.locate(q), q)
616    }
617
618    /// One request, under whatever is left of the analysis budget.
619    ///
620    /// Every failure is kept by [`TscProvider::remember`] on the way out, because the
621    /// [`TypeProvider`] methods above turn one into an "I don't know" the caller cannot tell
622    /// from a real answer.
623    fn request(
624        &self,
625        op: &str,
626        body: &serde_json::Value,
627    ) -> Result<serde_json::Value, ProviderError> {
628        Ok(self.request_reporting(op, body)?.0)
629    }
630
631    /// [`Self::request`], keeping the paths the driver read while answering.
632    fn request_reporting(
633        &self,
634        op: &str,
635        body: &serde_json::Value,
636    ) -> Result<(serde_json::Value, Vec<String>), ProviderError> {
637        let error = match self.exchange(op, body) {
638            Ok(answer) => return Ok(answer),
639            Err(Failure::Timeout) => ProviderError::Timeout,
640            Err(Failure::Refused(detail, sidecar)) => self.refused(&detail, sidecar),
641        };
642        self.remember(&error);
643        Err(error)
644    }
645
646    /// One question about a node, with what answering it read recorded against that node's file.
647    ///
648    /// **This is the whole of a query's dependency mechanism, and it is deliberately not the
649    /// listing's.** A read a query makes outside any program — `isAssignableTo` resolving a
650    /// module the file does not import, `complete` re-resolving every import — is not an input
651    /// to any program the run built, so putting it in the `programs` listing made the run key
652    /// depend on which questions the session happened to be asked, and therefore on which files
653    /// were cache misses last request. Recorded here instead, through the asking [`Query`]'s own
654    /// [`FileAccess`], it is an ordinary per-file tracked read: exactly that file's cache entry
655    /// depends on it, and it is revalidated the way every other tracked read is.
656    ///
657    /// A path outside the project root — a pnpm store, a sibling package in a monorepo — cannot
658    /// be recorded: `FileAccess` confines reads to the root and refuses it. The refusal is
659    /// dropped rather than raised, because the alternative is failing a question over a file the
660    /// compiler was always allowed to read (see this module's header on what `tsc` widens). Such
661    /// a file reaches the key only when some program read it, which is where the listing already
662    /// covers it.
663    fn ask_recording(
664        &self,
665        op: &str,
666        body: &serde_json::Value,
667        q: &Query<'_>,
668    ) -> Result<serde_json::Value, ProviderError> {
669        let (value, read) = self.request_reporting(op, body)?;
670        for path in read {
671            // The hash is not wanted here — recording the read is — and an escaping path is
672            // simply not recordable.
673            let _ = q.files.hash_of(&path);
674        }
675        Ok(value)
676    }
677
678    /// The locked half of a request: write the line, wait for the matching answer, read it.
679    ///
680    /// Returns a [`Failure`] rather than a [`ProviderError`] because building one needs the
681    /// session lock released — [`TscProvider::refused`] joins a thread and takes a second
682    /// mutex — and because a failure that leaves the protocol out of step has to kill the
683    /// child here, while the lock that owns it is still held.
684    fn exchange(
685        &self,
686        op: &str,
687        body: &serde_json::Value,
688    ) -> Result<(serde_json::Value, Vec<String>), Failure> {
689        let budget = self.current_budget();
690        let mut body = body.clone();
691        let Ok(mut session) = self.session.lock() else {
692            return Err(Failure::Refused(
693                "the sidecar's mutex is poisoned".to_owned(),
694                Sidecar::Live,
695            ));
696        };
697
698        // Service time only, and the budget is read here rather than above the lock. One
699        // sidecar answers one request at a time, so a worker that arrives while another's
700        // request is in flight waits — and charging that wait would make the accumulator grow
701        // with the number of rayon workers rather than with the work: fourteen workers each
702        // waiting about 200 ms charged 2.866 s against 205 ms of wall clock, so a 60 s budget
703        // bounded roughly 60/P seconds of real analysis and the breach message quoted a
704        // duration nobody could observe. Charged from inside the lock, the sum of what every
705        // worker charges is the wall time the sidecar was busy, which is the number the
706        // message names. The same reasoning fixes where `remaining` is read: a request's I/O
707        // timeout is what is left of the budget when the sidecar is about to work on it, not
708        // when its caller joined the queue.
709        //
710        // Holding the session lock across the guard cannot deadlock: `Charge` takes no lock of
711        // any kind — it reads an `Instant` here and adds to an atomic on drop — so there is no
712        // second lock for an ordering to exist between. It drops before `session` does,
713        // because a `let` binding declared later is dropped first, so the charged window ends
714        // with the exchange rather than with the lock's release.
715        let Some(remaining) = budget.remaining() else {
716            return Err(Failure::Timeout);
717        };
718        let _charge = budget.charge();
719
720        session.next_id += 1;
721        let id = session.next_id;
722        if let Some(object) = body.as_object_mut() {
723            object.insert("id".to_owned(), serde_json::json!(id));
724            object.insert("op".to_owned(), serde_json::json!(op));
725        }
726
727        let mut line = match serde_json::to_string(&body) {
728            Ok(line) => line,
729            Err(e) => return Err(Failure::Refused(e.to_string(), Sidecar::Live)),
730        };
731        line.push('\n');
732        if let Err(e) = session
733            .stdin
734            .write_all(line.as_bytes())
735            .and_then(|()| session.stdin.flush())
736        {
737            return Err(Failure::Refused(e.to_string(), stop(&mut session)));
738        }
739
740        let answer = match session.lines.recv_timeout(remaining) {
741            Ok(Ok(line)) => line,
742            Ok(Err(e)) => return Err(Failure::Refused(e.to_string(), stop(&mut session))),
743            Err(RecvTimeoutError::Timeout) => {
744                // Killed rather than abandoned, and waited for rather than left as a zombie. A
745                // limit cancels the run, and leaving a process that is still building a program
746                // behind would spend the machine's memory on an answer nobody will read.
747                let _ = stop(&mut session);
748                return Err(Failure::Timeout);
749            }
750            Err(RecvTimeoutError::Disconnected) => {
751                return Err(Failure::Refused(
752                    "the sidecar exited without answering".to_owned(),
753                    stop(&mut session),
754                ));
755            }
756        };
757
758        let parsed: serde_json::Value = match serde_json::from_str(&answer) {
759            Ok(parsed) => parsed,
760            // A line this cannot read is a line whose place in the stream is unknown, so the
761            // next answer would be read against the wrong request. The session ends here.
762            Err(e) => {
763                return Err(Failure::Refused(
764                    format!("{e}: {answer}"),
765                    stop(&mut session),
766                ));
767            }
768        };
769
770        // The echoed id, compared rather than assumed. One stray line on stdout — the driver's
771        // own malformed-input reply carries `id: 0` — would otherwise shift every later answer
772        // by one and attribute each to the wrong question, silently and for the rest of the
773        // run.
774        let echoed = parsed.get("id").and_then(serde_json::Value::as_u64);
775        if echoed != Some(id) {
776            let echoed = echoed.map_or_else(|| "none".to_owned(), |value| value.to_string());
777            return Err(Failure::Refused(
778                format!(
779                    "the sidecar answered id {echoed} for request id {id}; the protocol is out \
780                     of step and every later answer would be attributed to the wrong question"
781                ),
782                stop(&mut session),
783            ));
784        }
785
786        if parsed.get("ok").and_then(serde_json::Value::as_bool) != Some(true) {
787            // The sidecar answered this exact request and said no: it is still serving, and
788            // whatever comes next is still in step.
789            let detail = parsed
790                .get("error")
791                .and_then(serde_json::Value::as_str)
792                .unwrap_or("no reason given");
793            return Err(Failure::Refused(detail.to_owned(), Sidecar::Live));
794        }
795        // The paths the driver read answering *this* request and attributed to no program —
796        // see `ask_recording`. Absent or malformed is an empty list rather than a failure: the
797        // field is additive, and a driver that reported none has made no claim about any file.
798        let read = parsed
799            .get("reads")
800            .and_then(serde_json::Value::as_array)
801            .map(|items| {
802                items
803                    .iter()
804                    .filter_map(|item| item.as_str().map(str::to_owned))
805                    .collect()
806            })
807            .unwrap_or_default();
808        Ok((
809            parsed
810                .get("value")
811                .cloned()
812                .unwrap_or(serde_json::Value::Null),
813            read,
814        ))
815    }
816
817    /// A refusal carrying whatever the sidecar said on the way down.
818    ///
819    /// Every refusal that has a sidecar to quote is built here, so none of them can be raised
820    /// without the sidecar's own words attached.
821    ///
822    /// Three do not, deliberately, and they are [`fold_programs`]'s: a listing that is not a
823    /// list of `[path, hash]` pairs is a *shape* the answer does not have, described in full by
824    /// the value itself, and the sidecar that produced it is still serving — so there is
825    /// nothing on stderr that the message would be improved by, and joining the drain thread
826    /// would hang on a live child. Every other [`ProviderError::Refused`] outside tests comes
827    /// through here.
828    fn refused(&self, detail: &str, sidecar: Sidecar) -> ProviderError {
829        if matches!(sidecar, Sidecar::Gone) {
830            self.join_stderr();
831        }
832        ProviderError::Refused(format!("{detail}{}", self.stderr_tail()))
833    }
834}
835
836/// Kill the sidecar and reap it, and say that it is gone.
837///
838/// Reaped rather than left as a zombie, and answered with [`Sidecar::Gone`] so the caller
839/// knows the stderr drain thread is now joinable.
840fn stop(session: &mut Session) -> Sidecar {
841    let _ = session.child.kill();
842    let _ = session.child.wait();
843    Sidecar::Gone
844}
845
846/// A program's own reads reach the key through [`TypeProvider::begin_run`]'s listing, folded
847/// before any answer exists. A query's reads — a resolution made outside any program's host,
848/// answering a single question — are the other half: `TscProvider::ask_recording` records each
849/// through the asking [`Query`]'s own `FileAccess`, as an ordinary per-entry tracked read.
850impl TypeProvider for TscProvider {
851    fn notices(&self) -> Vec<String> {
852        let count = self.adhoc.lock().map_or(0, |slot| *slot);
853        if count == 0 {
854            return Vec::new();
855        }
856        vec![format!(
857            "{count} file(s) typed without a `tsconfig.json` under the project root — there is \
858             none, or the nearest one is above it, so the project's own compiler options \
859             (`strict` among them) did not apply to them"
860        )]
861    }
862
863    /// Yes. This provider's whole cost is a process building programs and answering requests,
864    /// which is exactly what `timeouts.analysis` bounds.
865    fn spends_analysis_budget(&self) -> bool {
866        true
867    }
868
869    fn type_of(&self, q: Query<'_>) -> Option<Type> {
870        decode_type(&self.ask_about("typeOf", &q).ok()?)
871    }
872
873    fn symbol_of(&self, q: Query<'_>) -> Option<Symbol> {
874        decode_symbol(&self.ask_about("symbolOf", &q).ok()?)
875    }
876
877    fn return_type_of(&self, q: Query<'_>) -> Option<Type> {
878        decode_type(&self.ask_about("returnTypeOf", &q).ok()?)
879    }
880
881    fn is_assignable_to(&self, q: Query<'_>, module: &str, name: &str) -> Option<bool> {
882        let mut body = self.locate(&q);
883        let object = body.as_object_mut()?;
884        object.insert("module".to_owned(), serde_json::json!(module));
885        object.insert("name".to_owned(), serde_json::json!(name));
886        self.ask_recording("isAssignableTo", &body, &q)
887            .ok()?
888            .as_bool()
889    }
890
891    fn complete(&self, q: Query<'_>) -> bool {
892        // `false` on a failure, which is the honest answer: a provider that could not say
893        // whether every import resolved has not established that they did, and a rule reading
894        // `complete` is deciding whether to stay silent.
895        self.ask_recording(
896            "complete",
897            &serde_json::json!({ "file": self.absolute(q.file) }),
898            &q,
899        )
900        .ok()
901        .and_then(|value| value.as_bool())
902        .unwrap_or(false)
903    }
904
905    fn identity(&self) -> Vec<u8> {
906        self.identity.clone()
907    }
908
909    /// True once the sidecar is gone, which for this provider is unrecoverable in place.
910    ///
911    /// Every failure path that leaves the protocol out of step kills the child (see `stop`),
912    /// and nothing here starts another: the `Session` holds one `Child`, and a provider is
913    /// shared behind an `Arc` across the run's workers, so respawning under them would be a
914    /// second sidecar answering questions the first was asked. A session builds a new provider
915    /// instead — see [`TypeProvider::needs_rebuild`].
916    ///
917    /// `try_wait` rather than a flag set where the child is killed: the child may also have
918    /// died on its own — out of memory building a program is the realistic one — and a flag
919    /// would only ever know about the deaths lanekeep caused.
920    ///
921    /// `SessionProvider::for_request_with` calls this holding its own lock, so what could block
922    /// here is not `try_wait` — it does not wait — but acquiring `self.session`'s mutex. That is
923    /// sound only because no request is in flight when `for_request_with` runs: the session's
924    /// `held` lock serializes callers, and each one either returns before touching the sidecar
925    /// or acquires `self.session` itself, so this call never contends with a request already
926    /// holding it.
927    fn needs_rebuild(&self) -> bool {
928        let mut session = self.session.lock().unwrap_or_else(PoisonError::into_inner);
929        // An error from `try_wait` is a child whose state cannot be established, which is not a
930        // child this provider can go on using either.
931        !matches!(session.child.try_wait(), Ok(None))
932    }
933
934    fn dependency_paths(&self) -> BTreeSet<FilePath> {
935        // Poison-tolerant for the same reason `failure` is: a panic elsewhere while this lock
936        // was held must not turn a real listing into an empty one, which is the answer that
937        // makes `--watch` miss every declaration file this run's programs read.
938        self.dependency_paths
939            .lock()
940            .unwrap_or_else(PoisonError::into_inner)
941            .clone()
942    }
943
944    fn revalidate(&self, _files: &FileAccess) {
945        // Nothing to do: the sidecar's state is the programs it built, and `begin_run`
946        // re-answers `programs` on every prepare, held provider or not. That op refreshes
947        // every program a file of the request falls under — re-hashing everything each of
948        // them read, roots and resolved dependencies alike, and rebuilding where something
949        // moved — and those programs are exactly the ones the listing is built from. A held
950        // program no file of the request falls under is neither refreshed nor listed: a fresh
951        // provider over the same request holds no such program, so contributing its rows
952        // would key a held session differently from `lanekeep check` over identical bytes.
953        // Its rows return, refreshed, with the request that next names one of its files. The
954        // program hash is therefore already current per request without this method's help.
955        //
956        // That claim used to hold only for a program's *root* files: the driver compared roots
957        // alone, so a held session kept answering out of a stale `.d.ts` reached by resolution
958        // and `lanekeep server` disagreed with `lanekeep check` about the same bytes. See
959        // `refreshReads` in `driver.mjs`.
960    }
961
962    fn begin_run(
963        &self,
964        files: &dyn Fn() -> Vec<FilePath>,
965        budget: AnalysisBudget,
966    ) -> Result<Vec<u8>, BeginRunError> {
967        // The run's budget replaces the handshake's: a provider a session holds (plan 6) gets
968        // a fresh accumulator per run rather than one that was already spent on the first.
969        // Poison-tolerant, like the failure slot below: a fallback to the spawn-time budget would
970        // charge the run against a different accumulator with a different duration.
971        *self
972            .run_budget
973            .lock()
974            .unwrap_or_else(PoisonError::into_inner) = Some(budget.clone());
975        // And the sticky failure is cleared beside it, for the same reason and in the same
976        // breath. The record exists so one broken answer cancels the run it broke; a session
977        // that holds this provider across runs would otherwise cancel every later run for a
978        // fault the previous one already reported, with no way to make progress. A sidecar
979        // that is genuinely gone fails the very first request of the new run and records
980        // itself again.
981        //
982        // Poison-tolerant, as `remember` and `failure` are and for the mirror of their reason:
983        // a poisoned lock skipped here leaves the previous run's cancellation in place for
984        // every run the session serves afterwards, which is exactly the condition this block
985        // exists to remove.
986        *self.failure.lock().unwrap_or_else(PoisonError::into_inner) = None;
987        // Every program the run's files belong to, built now so their listing can go into the
988        // key before a key exists (§5.6). Re-answered on every prepare, so a held provider
989        // keys each run on the programs as they are then.
990        self.programs(&files()).map_err(|e| match e {
991            ProviderError::Timeout => BeginRunError::Timeout(
992                budget
993                    .overrun()
994                    .unwrap_or_else(|| analysis_overrun_fallback(budget.budget())),
995            ),
996            other => BeginRunError::Failed(other.to_string()),
997        })?;
998        Ok(self.programs_hash().to_vec())
999    }
1000
1001    /// The sticky first error, in the shape the engine takes its exit from.
1002    ///
1003    /// Read after every file. The mapping is `begin_run`'s, deliberately: one failure must
1004    /// take the same exit whether it happened while the programs were being built or while a
1005    /// rule was asking a question, or the same broken sidecar would name `timeouts.analysis`
1006    /// in one phase and the toolchain in the other.
1007    ///
1008    /// The budget the overrun is measured against is the *run's* when `begin_run` set one, so
1009    /// a provider a session holds across runs reports against the run that broke rather than
1010    /// the one that spawned it.
1011    fn failure(&self) -> Option<BeginRunError> {
1012        let budget = self.current_budget();
1013        Some(match TscProvider::failure(self)? {
1014            ProviderError::Timeout => BeginRunError::Timeout(
1015                budget
1016                    .overrun()
1017                    .unwrap_or_else(|| analysis_overrun_fallback(budget.budget())),
1018            ),
1019            other => BeginRunError::Failed(other.to_string()),
1020        })
1021    }
1022}
1023
1024/// The sidecar's stdout, one line per channel message.
1025///
1026/// A reader thread and a channel, because `BufReader::read_line` cannot be given a deadline.
1027/// This is what makes a hung sidecar killable rather than something the run waits on forever —
1028/// §5.5's "a request carries the remaining budget as its I/O timeout".
1029fn read_lines(
1030    stdout: std::process::ChildStdout,
1031) -> Result<Receiver<std::io::Result<String>>, ProviderError> {
1032    let (sender, lines): (SyncSender<std::io::Result<String>>, _) = sync_channel(16);
1033    std::thread::Builder::new()
1034        .name("lanekeep-tsc-reader".to_owned())
1035        .spawn(move || {
1036            let mut reader = BufReader::new(stdout);
1037            loop {
1038                let mut line = String::new();
1039                match reader.read_line(&mut line) {
1040                    Ok(0) => break,
1041                    Ok(_) => {
1042                        if sender.send(Ok(line)).is_err() {
1043                            break;
1044                        }
1045                    }
1046                    Err(e) => {
1047                        let _ = sender.send(Err(e));
1048                        break;
1049                    }
1050                }
1051            }
1052        })
1053        .map_err(|e| ProviderError::Unavailable(e.to_string()))?;
1054    Ok(lines)
1055}
1056
1057/// The sidecar's stderr, drained into a bounded buffer.
1058///
1059/// A thread of its own, because a child that fills its stderr pipe while nobody drains it
1060/// blocks — which would present as exactly the timeout this provider exists to distinguish
1061/// from a real one.
1062/// The stderr buffer and the thread filling it.
1063///
1064/// A named pair rather than a tuple in the signature, because the two are only ever handed out
1065/// together and clippy is right that the spelled-out type is unreadable.
1066type Drain = (std::sync::Arc<Mutex<Vec<u8>>>, std::thread::JoinHandle<()>);
1067
1068/// Bytes rather than text, and the bound applied after the push rather than before it. Checking
1069/// before would keep up to `STDERR_KEPT` plus one whole read, and decoding each read on its own
1070/// would replace any character that straddles two of them with a replacement character — so the
1071/// truncation is a byte truncation and the decode happens once, in [`TscProvider::stderr_tail`].
1072/// Reading continues past the bound with the bytes discarded, because a child whose stderr pipe
1073/// fills with nobody draining it blocks.
1074fn drain_stderr(stderr: std::process::ChildStderr) -> Result<Drain, ProviderError> {
1075    let kept = std::sync::Arc::new(Mutex::new(Vec::new()));
1076    let sink = std::sync::Arc::clone(&kept);
1077    let handle = std::thread::Builder::new()
1078        .name("lanekeep-tsc-stderr".to_owned())
1079        .spawn(move || {
1080            let mut reader = BufReader::new(stderr);
1081            let mut chunk = [0_u8; 4096];
1082            loop {
1083                match reader.read(&mut chunk) {
1084                    Ok(0) | Err(_) => break,
1085                    Ok(read) => {
1086                        let Ok(mut buffer) = sink.lock() else { break };
1087                        if buffer.len() < STDERR_KEPT {
1088                            buffer.extend_from_slice(&chunk[..read]);
1089                            buffer.truncate(STDERR_KEPT);
1090                        }
1091                    }
1092                }
1093            }
1094        })
1095        .map_err(|e| ProviderError::Unavailable(e.to_string()))?;
1096    Ok((kept, handle))
1097}
1098
1099/// The driver on disk, under the directory the watcher already ignores.
1100///
1101/// Named by its own hash, so two lanekeep versions in one project do not fight over one path,
1102/// and written to a temporary first and renamed: `std::fs::write` truncates before it writes,
1103/// so two runs starting at once would otherwise have one of them read an empty file — the
1104/// same truncate-then-write race `AGENTS.md` records for a test's fixture path.
1105///
1106/// **Older drivers are never pruned**, deliberately. The name is the content hash, so an
1107/// obsolete one is a file another lanekeep process may have open right now — a longer run
1108/// started before this binary was upgraded, or a `--watch` in another terminal — and deleting
1109/// it would kill that run's sidecar with a message about a missing module. They are a few
1110/// kilobytes each under `.lanekeep/`, which the whole directory's own removal already covers.
1111///
1112/// Called only after `types.command` has been validated, so a configuration that could never
1113/// start a sidecar does not create a `.lanekeep/` in a project that has none — and only from a
1114/// command that is actually going to *run* a check. `lanekeep rules` and `lanekeep explain`
1115/// read a run's metadata and prepare with no provider at all
1116/// (`PrepareOptions::without_provider`), so neither reaches this and neither leaves a
1117/// `.lanekeep/` behind in a project it only listed the rules of.
1118fn write_driver(root: &Path) -> Result<PathBuf, ProviderError> {
1119    let digest = blake3::hash(DRIVER.as_bytes()).to_hex();
1120    let dir = root.join(".lanekeep");
1121    // Every failure here is [`ProviderError::Unwritable`], naming the directory. They used to
1122    // be `Unavailable`, whose remedy is "`types.command` on PATH" — advice about the command,
1123    // raised by the one step that has not run it and cannot have anything against it.
1124    let unwritable =
1125        |e: &std::io::Error| ProviderError::Unwritable(format!("{}: {e}", dir.display()));
1126    std::fs::create_dir_all(&dir).map_err(|e| unwritable(&e))?;
1127    let final_path = dir.join(format!("types-driver-{}.mjs", &digest[..16]));
1128    if final_path.is_file() {
1129        return Ok(final_path);
1130    }
1131    let temporary = dir.join(format!(
1132        "types-driver-{}.{}.tmp",
1133        &digest[..16],
1134        std::process::id()
1135    ));
1136    std::fs::write(&temporary, DRIVER).map_err(|e| unwritable(&e))?;
1137    std::fs::rename(&temporary, &final_path).map_err(|e| unwritable(&e))?;
1138    Ok(final_path)
1139}
1140
1141/// The provider's identity: what it is, not what it has answered.
1142///
1143/// Three terms, length-prefixed, for the reason `TypesConfig::canonical_bytes` gives: the
1144/// TypeScript version, the driver's bytes, and the `types` block. A result computed by a
1145/// different compiler, a different driver or a different configuration is not a valid result
1146/// for this run.
1147fn fold_identity(version: &str, driver: &str, config: &TypesConfig) -> Vec<u8> {
1148    let mut hasher = blake3::Hasher::new();
1149    hasher.update(b"lanekeep-tsc-provider-v1");
1150    for field in [
1151        version.as_bytes(),
1152        driver.as_bytes(),
1153        config.canonical_bytes().as_slice(),
1154    ] {
1155        hasher.update(&u64::try_from(field.len()).unwrap_or(u64::MAX).to_le_bytes());
1156        hasher.update(field);
1157    }
1158    hasher.finalize().as_bytes().to_vec()
1159}
1160
1161/// The program listing, folded. Sorted by the driver; folded here in the order given rather
1162/// than re-sorted, so a driver that stopped sorting is a changed hash rather than a silently
1163/// identical one.
1164///
1165/// # Errors
1166///
1167/// [`ProviderError::Refused`] on any shape this cannot read. A listing that is not an array of
1168/// `[string, string]` pairs used to fold to a *constant* — the same bytes for every malformed
1169/// answer, and for every run that got one — which is a cache key that says two different
1170/// programs are the same program. Not knowing what the sidecar meant is a refusal.
1171fn fold_programs(answer: &serde_json::Value) -> Result<[u8; 32], ProviderError> {
1172    let listing = answer.get("listing").unwrap_or(answer);
1173    let rows = listing.as_array().ok_or_else(|| {
1174        ProviderError::Refused(format!(
1175            "`programs` answered {answer}, whose `listing` is not a list of `[path, hash]` pairs"
1176        ))
1177    })?;
1178    let mut hasher = blake3::Hasher::new();
1179    hasher.update(b"lanekeep-tsc-programs-v1");
1180    hasher.update(&u64::try_from(rows.len()).unwrap_or(u64::MAX).to_le_bytes());
1181    for row in rows {
1182        let pair = row.as_array().filter(|pair| pair.len() == 2);
1183        let pair = pair.ok_or_else(|| {
1184            ProviderError::Refused(format!(
1185                "`programs` answered a row {row} that is not a `[path, hash]` pair"
1186            ))
1187        })?;
1188        for field in pair {
1189            let text = field.as_str().ok_or_else(|| {
1190                ProviderError::Refused(format!(
1191                    "`programs` answered a row {row} whose fields are not both strings"
1192                ))
1193            })?;
1194            hasher.update(&u64::try_from(text.len()).unwrap_or(u64::MAX).to_le_bytes());
1195            hasher.update(text.as_bytes());
1196        }
1197    }
1198    // Which files fell to the ad-hoc program is a key input in its own right, and not one the
1199    // rows above carry: a file typed with the project's `strict` and the same file typed
1200    // without it have byte-identical listings, because the listing is paths and content
1201    // hashes. Folded as its own length-prefixed section rather than encoded into a row — a row
1202    // is `[path, hash]`, and putting a third fact inside one of two strings is exactly the
1203    // overloading the length prefixes exist to make unnecessary.
1204    hasher.update(b"lanekeep-tsc-adhoc-v1");
1205    let adhoc = adhoc_paths(answer)?;
1206    hasher.update(&u64::try_from(adhoc.len()).unwrap_or(u64::MAX).to_le_bytes());
1207    for path in adhoc {
1208        hasher.update(&u64::try_from(path.len()).unwrap_or(u64::MAX).to_le_bytes());
1209        hasher.update(path.as_bytes());
1210    }
1211    Ok(*hasher.finalize().as_bytes())
1212}
1213
1214/// The listing's own paths — the half of `programs`' answer [`fold_programs`] hashes rather
1215/// than keeps, kept here as [`TypeProvider::dependency_paths`]'s answer instead of being
1216/// folded away with the rest.
1217///
1218/// `adhoc` entries are unioned in explicitly rather than trusted to already be present in
1219/// `listing`: a file the ad hoc program typed is a dependency of the run's answers exactly as
1220/// much as one a `tsconfig.json` owns, and a `BTreeSet` makes the union free if the driver
1221/// already lists it twice.
1222///
1223/// # Errors
1224///
1225/// [`ProviderError::Refused`] on any shape [`fold_programs`] would also refuse — a listing
1226/// this cannot read as `[path, hash]` pairs. A dropped row here would silently understate the
1227/// dependency set the next `--watch` iteration relies on, which is the same reasoning
1228/// `fold_programs` gives for refusing rather than skipping.
1229fn programs_paths(answer: &serde_json::Value) -> Result<BTreeSet<FilePath>, ProviderError> {
1230    let listing = answer.get("listing").unwrap_or(answer);
1231    let rows = listing.as_array().ok_or_else(|| {
1232        ProviderError::Refused(format!(
1233            "`programs` answered {answer}, whose `listing` is not a list of `[path, hash]` pairs"
1234        ))
1235    })?;
1236    let mut paths = BTreeSet::new();
1237    for row in rows {
1238        let pair = row
1239            .as_array()
1240            .filter(|pair| pair.len() == 2)
1241            .ok_or_else(|| {
1242                ProviderError::Refused(format!(
1243                    "`programs` answered a row {row} that is not a `[path, hash]` pair"
1244                ))
1245            })?;
1246        let path = pair[0].as_str().ok_or_else(|| {
1247            ProviderError::Refused(format!(
1248                "`programs` answered a row {row} whose fields are not both strings"
1249            ))
1250        })?;
1251        paths.insert(FilePath::new(path));
1252    }
1253    for path in adhoc_paths(answer)? {
1254        paths.insert(FilePath::new(path));
1255    }
1256    Ok(paths)
1257}
1258
1259/// The files `programs` said no `tsconfig.json` under the root claims, as it spelled them.
1260///
1261/// Sorted by the driver already; sorted again here rather than trusted, because this feeds a
1262/// cache key and a key that depends on a peer's sort order is a key two runs can disagree on.
1263///
1264/// # Errors
1265///
1266/// [`ProviderError::Refused`] on an entry that is not a string, on [`fold_programs`]'s own
1267/// reasoning: this list is a cache-key input, and dropping an entry nobody could read would
1268/// fold two different answers to the same bytes. It used to be a `filter_map`, which is
1269/// exactly that — a shorter list, silently, for an answer this could not understand.
1270fn adhoc_paths(answer: &serde_json::Value) -> Result<Vec<String>, ProviderError> {
1271    let Some(rows) = answer.get("adhoc") else {
1272        return Ok(Vec::new());
1273    };
1274    let rows = rows.as_array().ok_or_else(|| {
1275        ProviderError::Refused(format!(
1276            "`programs` answered an `adhoc` of {rows}, which is not a list of paths"
1277        ))
1278    })?;
1279    let mut paths: Vec<String> = Vec::with_capacity(rows.len());
1280    for row in rows {
1281        let path = row.as_str().ok_or_else(|| {
1282            ProviderError::Refused(format!(
1283                "`programs` answered an `adhoc` entry {row} that is not a path"
1284            ))
1285        })?;
1286        paths.push(path.to_owned());
1287    }
1288    paths.sort();
1289    paths.dedup();
1290    Ok(paths)
1291}
1292
1293/// How many files fell to the ad-hoc program.
1294///
1295/// # Errors
1296///
1297/// As [`adhoc_paths`].
1298fn adhoc_count(answer: &serde_json::Value) -> Result<usize, ProviderError> {
1299    adhoc_paths(answer).map(|paths| paths.len())
1300}
1301
1302/// Whether the `tsc` driver has a `ts.ScriptKind` for this path.
1303///
1304/// The list is `scriptKindOf`'s in `crates/lanekeep-types/src/tsc/driver.mjs`, and the two are
1305/// kept in step by hand: an extension the driver types and this refuses is a file the run
1306/// silently never builds a program for, and one this admits and the driver does not is a
1307/// request that can only answer nothing.
1308fn typed_extension(path: &str) -> bool {
1309    const TYPED: &[&str] = &[
1310        ".ts", ".tsx", ".mts", ".cts", ".d.ts", ".js", ".jsx", ".mjs", ".cjs",
1311    ];
1312    let lowered = path.to_ascii_lowercase();
1313    TYPED.iter().any(|suffix| lowered.ends_with(suffix))
1314}
1315
1316/// One of the seven names [`Primitive::as_str`] renders, or nothing.
1317///
1318/// Derived from the enum rather than from a second table, for the reason `TypesProvider::all`
1319/// gives: a name is accepted exactly when there is a variant for it.
1320fn decode_primitive(name: &str) -> Option<Primitive> {
1321    [
1322        Primitive::Number,
1323        Primitive::String,
1324        Primitive::Boolean,
1325        Primitive::BigInt,
1326        Primitive::Symbol,
1327        Primitive::Null,
1328        Primitive::Undefined,
1329    ]
1330    .into_iter()
1331    .find(|candidate| candidate.as_str() == name)
1332}
1333
1334/// `{text, primitive?, union?, symbol?}` as the driver normalizes it.
1335fn decode_type(answer: &serde_json::Value) -> Option<Type> {
1336    if let Some(name) = answer.get("primitive").and_then(serde_json::Value::as_str) {
1337        return decode_primitive(name).map(Type::Primitive);
1338    }
1339    if let Some(members) = answer.get("union").and_then(serde_json::Value::as_array) {
1340        // `Type::union` rather than `Type::Union`: it is what flattens, deduplicates and
1341        // sorts on the Rust side, so a driver whose sort ever disagreed still produces one
1342        // canonical answer.
1343        let decoded: Vec<Type> = members.iter().filter_map(decode_type).collect();
1344        // A member this cannot read would silently narrow the union into a different type, so
1345        // a partial decode is `None` rather than a smaller union.
1346        if decoded.len() != members.len() {
1347            return None;
1348        }
1349        return Type::union(decoded);
1350    }
1351    let name = answer.get("text").and_then(serde_json::Value::as_str)?;
1352    Some(Type::Nominal {
1353        name: name.to_owned(),
1354        symbol: answer.get("symbol").and_then(decode_symbol),
1355    })
1356}
1357
1358/// `{name, module?, exported?}` as the driver normalizes it.
1359fn decode_symbol(answer: &serde_json::Value) -> Option<Symbol> {
1360    let name = answer.get("name").and_then(serde_json::Value::as_str)?;
1361    Some(Symbol {
1362        name: name.to_owned(),
1363        exported: answer
1364            .get("exported")
1365            .and_then(serde_json::Value::as_str)
1366            .map(str::to_owned),
1367        module: answer
1368            .get("module")
1369            .and_then(serde_json::Value::as_str)
1370            .map(str::to_owned),
1371    })
1372}
1373
1374#[cfg(test)]
1375#[expect(
1376    clippy::print_stderr,
1377    reason = "a test that finds `typescript` absent has to say so on the terminal: the \
1378              alternative is a suite that reports six passes for six tests it did not run"
1379)]
1380mod tests;