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;