import { Buffer } from "node:buffer"; import { spawnSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { configEvaluatorFailureReason, configEvaluatorProcessFailure, } from "./internal/configEvaluatorFailure"; import { beginLintTrace } from "./internal/lintTrace"; import { normalizeContributors } from "./internal/normalizeContributors"; import type { ITtscLintPluginConfig } from "./structures"; /** A resolved contributor: Go sub-package name + absolute source directory. */ type TtscPluginContributor = { name: string; source: string; }; /** Descriptor shape returned to ttsc's plugin builder by the factory. */ type TtscPluginDescriptor = { capabilities?: { checkObservations?: true; compilerArgsCwd?: true; diagnosticsTiming?: boolean; graphNodes?: boolean; lsp?: boolean; projectContextArgs?: boolean; projectDiagnostics?: boolean; projectInputs?: boolean; residentCheck?: boolean; threadingArgs?: boolean; }; contributors?: TtscPluginContributor[]; hostInputHashes?: Record; hostInputRealpaths?: Record; hostInputs?: string[]; name: string; reportsTypeScriptDiagnostics?: boolean; source: string; stage?: "check" | "transform"; }; /** * Context object injected by ttsc into every plugin factory call. The generic * `TConfig` is the tsconfig plugin entry shape. */ type TtscPluginFactoryContext = { binary: string; cwd: string; /** This descriptor's own directory — the ESM-safe replacement for `__dirname`. */ dirname: string; /** This descriptor's own path — the ESM-safe replacement for `__filename`. */ filename: string; plugin: TConfig; /** * Caller-declared anchor for plugin config-file discovery, present when the * embedder compiles through a generated tsconfig outside the project (see * `ITtscPluginFactoryContext.pluginConfigDir`). */ pluginConfigDir?: string; projectRoot: string; tsconfig: string; }; // Namespace becomes the rule-name prefix (`/`). Mirrors ESLint // plugin namespace conventions: lowercase ASCII, digits, hyphens, and // underscores; leading character must be alphabetic so the prefix never // collides with a rule name that itself starts with a digit. Hyphens // are encoded into underscores for the Go sub-package name (see // `goSubpackageName`); the user-facing prefix keeps the original form. const NAMESPACE_PATTERN = /^[a-z][a-z0-9_-]*$/; const LINT_CONFIG_FILENAMES = [ "lint.config.json", "lint.config.js", "lint.config.mjs", "lint.config.cjs", "lint.config.ts", "lint.config.mts", "lint.config.cts", "ttsc-lint.config.json", "ttsc-lint.config.js", "ttsc-lint.config.mjs", "ttsc-lint.config.cjs", "ttsc-lint.config.ts", "ttsc-lint.config.mts", "ttsc-lint.config.cts", ]; /** * Tsconfig plugin-entry keys owned by the ttsc host framework. They are * accepted alongside the single lint-specific `configFile` key; every other key * is rejected so a stale inline option (`rules`, `format`, `extends`, legacy * `config`, `plugins`) surfaces as a clear migration error instead of being * silently ignored. Mirrors `@ttsc/banner` and `@ttsc/strip`. */ const FRAMEWORK_KEYS = new Set(["enabled", "transform"]); /** * Plugin descriptor factory consumed by ttsc package discovery. * * Contributor lint plugins come from one place: the project's lint config file * (`lint.config.{ts,cts,mts,js,cjs,mjs,json}` or `ttsc-lint.config.*`). The * tsconfig plugin entry carries no rule or plugin surface — it optionally names * the config file via `configFile`, otherwise the file is discovered by walking * upward from the tsconfig directory (or from the caller-declared * `pluginConfigDir` when the resolved tsconfig is a generated wrapper in a temp * directory). * * The factory locates the config file and evaluates contributor-bearing configs * in an isolated ttsx process. JSON without plugins or an extends chain needs * no evaluator. The extractor reads plugin maps from the resolved config and * its bases and forwards contributor Go sources to the descriptor's * `contributors` field. Its dependency envelope owns config observations; the * child's bootstrap does not inherit the parent descriptor's observation * channel. * * @evidence contracts/common.md#principled-implementation Entry validation and config-origin discovery resolve registered contributor sources and observed inputs before constructing the check-stage descriptor with supported capabilities. * @evidence contracts/common.md#clear-and-simple-design The exported factory composes validation, discovery and descriptor construction; helpers own module loading and dependency fingerprints separately. * @evidence contracts/common.md#prohibited-implementation-shortcuts Contributors use the supported Go-source extension boundary and config-file schema; no foreign method replacement or fixture-specific descriptor is installed. * @evidence contracts/common.md#meaningful-documentation Native paragraphs explain config origins, evaluator formats and contributor forwarding; separated prose and tags follow documentation guidance. */ export default function createTtscPlugin( context: TtscPluginFactoryContext, ): TtscPluginDescriptor { rejectUnsupportedEntryKeys(context.plugin); const resolvedConfig = resolveConfigFileContributors(context); // Build the descriptor without a `contributors` key when none were // declared, so consumers (and the existing key-shape regression // tests) see the same surface as before this feature shipped. const descriptor: TtscPluginDescriptor = { capabilities: { checkObservations: true, compilerArgsCwd: true, diagnosticsTiming: true, graphNodes: true, lsp: true, projectContextArgs: true, projectDiagnostics: true, projectInputs: true, residentCheck: true, threadingArgs: true, }, hostInputHashes: resolvedConfig.hostInputHashes, hostInputRealpaths: resolvedConfig.hostInputRealpaths, hostInputs: resolvedConfig.hostInputs, name: "@ttsc/lint", reportsTypeScriptDiagnostics: true, // `context.dirname` is this descriptor's own directory in every load mode — // the ESM-safe replacement for `__dirname`, which is undefined when ttsc // loads a descriptor as `.ts` source or ESM. source: path.resolve(context.dirname, "..", "plugin"), stage: "check", }; if (resolvedConfig.contributors.length > 0) { descriptor.contributors = resolvedConfig.contributors; } return descriptor; } // ──────────────────────────────────────────────────────────────────────────── // lint.config.* discovery + evaluation // ──────────────────────────────────────────────────────────────────────────── /** Plugin entries observed in a lint config file, normalized per file. */ type ConfigPluginEntry = { namespace: string; source: string }; type ConfigDependencyFingerprint = { digest: string; identityStable: boolean; kind: "directory" | "file" | "optional-file"; path: string; realpath: string | null; scope: "cache" | "watch"; }; type ConfigPluginEvaluation = { dependencies: ConfigDependencyFingerprint[]; entries: ConfigPluginEntry[]; }; /** * Resolves the contributor lint plugins declared in the project's lint config * file. * * - When the tsconfig plugin entry sets `configFile`, that exact file is loaded * (relative paths resolve against the tsconfig directory, or against the * caller-declared `pluginConfigDir` when present). * - Otherwise a `lint.config.*` / `ttsc-lint.config.*` file is discovered by * walking upward from the tsconfig directory (or from `pluginConfigDir` when * present). * * Returns no contributors while preserving observed discovery inputs when no * config file is set or discovered. The Go sidecar reports a missing config. */ function resolveConfigFileContributors( context: TtscPluginFactoryContext, ): { contributors: TtscPluginContributor[]; hostInputHashes: Record; hostInputRealpaths: Record; hostInputs: string[]; } { const configFile = readConfigFileOption(context); const explicitConfigPath = configFile === undefined ? undefined : path.resolve(pluginConfigBaseDir(context), configFile); const discovery = explicitConfigPath === undefined ? discoverLintConfigFile(context) : { configPath: explicitConfigPath, hostInputHashes: hashHostInputPaths([explicitConfigPath]), hostInputRealpaths: realpathHostInputPaths([explicitConfigPath]), hostInputs: [explicitConfigPath], }; const { configPath } = discovery; if (!configPath || !fs.existsSync(configPath)) { return { contributors: [], hostInputHashes: discovery.hostInputHashes, hostInputRealpaths: discovery.hostInputRealpaths, hostInputs: discovery.hostInputs, }; } const evaluation = readConfigPluginEntries(configPath, context); const entries = evaluation.entries; const out = normalizeContributors(entries, configPath); const dependencyInputs = evaluation.dependencies .filter( (dependency) => dependency.scope === "watch" && dependency.kind !== "directory", ) .map((dependency) => dependency.path); const hostInputHashes = { ...discovery.hostInputHashes }; const hostInputRealpaths = { ...discovery.hostInputRealpaths }; const unstableRealpaths = new Set(); const unprovenInputs = new Set(); const missingOptionalDigest = createHash("sha256") .update("missing\0") .digest("hex"); const directoryCandidateDigest = createHash("sha256") .update("ttsc:host-input:directory\0") .digest("hex"); for (const dependency of evaluation.dependencies) { if (dependency.scope !== "watch" || dependency.kind === "directory") { continue; } const input = path.resolve(dependency.path); const realpath = dependency.realpath; if (!dependency.identityStable) { delete hostInputRealpaths[input]; delete hostInputHashes[input]; unstableRealpaths.add(input); unprovenInputs.add(input); continue; } if ( Object.prototype.hasOwnProperty.call(hostInputRealpaths, input) && hostInputRealpaths[input] !== realpath ) { delete hostInputRealpaths[input]; delete hostInputHashes[input]; unstableRealpaths.add(input); } else if (!unstableRealpaths.has(input)) { hostInputRealpaths[input] = realpath; } let hash: string | null | undefined; if ( dependency.kind === "file" && /^[0-9a-f]{64}$/.test(dependency.digest) ) { hash = dependency.digest; } else if (dependency.digest === missingOptionalDigest) { // The evaluator's optional-file digest includes a state prefix. The // public host-input contract uses null for the observed missing state. hash = null; } else if (dependency.digest === directoryCandidateDigest) { // A path that is currently a directory is still an exact file candidate: // replacing it with a file changes module/config selection. hash = directoryCandidateDigest; } if (hash === undefined) { delete hostInputHashes[input]; unprovenInputs.add(input); } else if (unprovenInputs.has(input)) { // A later observation cannot revive proof another evaluation stage // could not provide for the same combined descriptor result. } else if ( Object.prototype.hasOwnProperty.call(hostInputHashes, input) && hostInputHashes[input] !== hash ) { delete hostInputHashes[input]; unprovenInputs.add(input); } else { hostInputHashes[input] = hash; } } return { contributors: out, hostInputHashes, hostInputRealpaths, hostInputs: [...discovery.hostInputs, ...dependencyInputs], }; } /** Snapshot config candidates before any discovery/evaluation side effect. */ function hashHostInputPaths( inputs: readonly string[], ): Record { return Object.fromEntries( inputs.map((input) => { const file = path.resolve(input); try { if (fs.statSync(file).isDirectory()) { return [ file, createHash("sha256") .update("ttsc:host-input:directory\0") .digest("hex"), ] as const; } return [ file, createHash("sha256").update(fs.readFileSync(file)).digest("hex"), ] as const; } catch { return [file, null] as const; } }), ); } function hostInputRealpath(file: string): string | null { try { return fs.realpathSync.native(file); } catch { return null; } } function realpathHostInputPaths( inputs: readonly string[], ): Record { return Object.fromEntries( inputs.map((input) => { const file = path.resolve(input); return [file, hostInputRealpath(file)] as const; }), ); } /** Mirror native discovery and fingerprint every candidate before selecting. */ function discoverLintConfigFile( context: TtscPluginFactoryContext, ): { configPath?: string; hostInputHashes: Record; hostInputRealpaths: Record; hostInputs: string[]; } { const hostInputHashes: Record = {}; const hostInputRealpaths: Record = {}; const hostInputs: string[] = []; const recordCandidates = (candidates: readonly string[]): void => { for (const candidate of candidates) { const absolute = path.resolve(candidate); if (Object.prototype.hasOwnProperty.call(hostInputHashes, absolute)) { continue; } hostInputs.push(absolute); Object.assign(hostInputHashes, hashHostInputPaths([absolute])); Object.assign(hostInputRealpaths, realpathHostInputPaths([absolute])); } }; for (const origin of discoveryConfigBaseDirs(context)) { for (let directory = origin; ; directory = path.dirname(directory)) { const candidates = LINT_CONFIG_FILENAMES.map((name) => path.join(directory, name), ); recordCandidates(candidates); const matches = lintConfigMatchesIn(directory); if (matches.length === 1) { return { configPath: matches[0], hostInputHashes, hostInputRealpaths, hostInputs, }; } if (matches.length > 1) { throw new Error( `@ttsc/lint: multiple lint config files found in ${directory} (${matches .map((file) => path.basename(file)) .join(", ")}); set "configFile" explicitly`, ); } const parent = path.dirname(directory); if (parent === directory) break; } } return { hostInputHashes, hostInputRealpaths, hostInputs }; } /** * Rejects any tsconfig plugin-entry key that is neither a host framework key * nor the single lint-specific `configFile` key. Rule, format, and plugin * settings live only in the lint config file, so a leftover inline key is * surfaced as an explicit migration error rather than silently ignored. */ function rejectUnsupportedEntryKeys(entry: ITtscLintPluginConfig): void { for (const key of Object.keys(entry as Record)) { if (FRAMEWORK_KEYS.has(key) || key === "configFile") { continue; } throw new Error( `@ttsc/lint: tsconfig plugin entry contains unsupported key ${JSON.stringify(key)}. ` + `Only "transform", "enabled", and "configFile" are accepted here; ` + `put lint rules, format, and contributors in a ` + `lint.config.{ts,cts,mts,js,cjs,mjs,json} file.`, ); } } /** * Reads the optional `configFile` key from the tsconfig plugin entry. It is the * only lint-specific key the entry accepts; when present it overrides * auto-discovery of the lint config file. */ function readConfigFileOption( context: TtscPluginFactoryContext, ): string | undefined { const value = (context.plugin as { configFile?: unknown }).configFile; if (value === undefined) return undefined; if (typeof value !== "string" || value.length === 0) { throw new Error(`@ttsc/lint: "configFile" must be a non-empty string path`); } return value; } function discoveryConfigBaseDirs( context: TtscPluginFactoryContext, ): string[] { if (context.pluginConfigDir) { return [path.resolve(context.cwd ?? ".", context.pluginConfigDir)]; } const tsconfigDir = tsconfigBaseDir(context); const cwd = path.resolve(context.cwd ?? context.projectRoot); return tsconfigDir === cwd ? [tsconfigDir] : [tsconfigDir, cwd]; } /** Return the non-directory candidates native discovery recognizes. */ function lintConfigMatchesIn(directory: string): string[] { const matches: string[] = []; for (const name of LINT_CONFIG_FILENAMES) { const candidate = path.join(directory, name); try { // Go's os.Stat follows symlinks and junctions. Follow them here too so a // directory or dangling link cannot stop descriptor discovery before // the native host reaches a valid ancestor config. Probing the canonical // candidate spelling also preserves Go's behavior on case-insensitive // filesystems when the directory entry uses different casing. if (!fs.statSync(candidate).isDirectory()) matches.push(candidate); } catch { // Missing, dangling, and unreadable candidates are not native matches. } } return matches; } /** * Base directory for resolving a relative `configFile` from the tsconfig plugin * entry. Mirrors the Go side (driver.PluginConfigBaseDir): the caller-declared * pluginConfigDir wins when present — the resolved tsconfig is then a generated * wrapper in a temp directory that no longer identifies the project — otherwise * the tsconfig directory, falling back to the working directory. */ function pluginConfigBaseDir( context: TtscPluginFactoryContext, ): string { if (context.pluginConfigDir) { return path.resolve(context.cwd ?? ".", context.pluginConfigDir); } return tsconfigBaseDir(context); } function tsconfigBaseDir( context: TtscPluginFactoryContext, ): string { const cwd = context.cwd ?? context.projectRoot; if (context.tsconfig) { return path.dirname( path.isAbsolute(context.tsconfig) ? context.tsconfig : path.join(cwd, context.tsconfig), ); } return path.resolve(cwd); } function readConfigPluginEntries( configPath: string, context: TtscPluginFactoryContext, ): ConfigPluginEvaluation { // A JSON config that can bring no contributor with it — no `plugins` map and // no `extends` chain to follow — has nothing to extract, and reading it runs // no user code, so the isolated evaluator is not needed to keep its strings // away from stdout. Skipping it there matters beyond the cost: the evaluator // is a real subprocess, and a host that only wanted to know whether there // were contributors would otherwise depend on a launcher being resolvable and // on a compiler accepting one more invocation. if (jsonConfigDeclaresNoContributor(configPath)) { return { dependencies: [], entries: [] }; } // Every other config uses the same isolated evaluator. Executable config can // name contributor packages whose top-level code writes to stdout, so loading // it in this host process would corrupt CLI JSON or preface the first LSP // frame. return readTtsxConfigPlugins(configPath, context); } function jsonConfigDeclaresNoContributor(configPath: string): boolean { if (path.extname(configPath).toLowerCase() !== ".json") return false; try { const value: unknown = JSON.parse(fs.readFileSync(configPath, "utf8")); return !configMayDeclareContributor(value); } catch { // A malformed config is the evaluator's diagnostic to report, not this // shortcut's, so it falls through to the path that reports it. return false; } } /** * Whether any object in a config could still bring a contributor with it. * * A `plugins` map names one directly. An `extends` chain names one indirectly: * the base it points at may be executable and may declare contributors of its * own, and following that chain is the evaluator's job. Treating a config that * extends anything as undecidable here is what keeps this shortcut from * silently dropping a contributor the base would have supplied. */ function configMayDeclareContributor(value: unknown): boolean { if (Array.isArray(value)) { return value.some((entry) => configMayDeclareContributor(entry)); } if (value === null || typeof value !== "object") return false; const record = value as Record; for (const key of ["plugins", "extends"]) { const declared = record[key]; if (declared !== undefined && declared !== null) return true; } return false; } // TypeScript source written to a temp file and executed via ttsx. The // %CONFIG_IMPORT% placeholder is replaced with a JSON-quoted file URL // before the file hits disk. The script walks the exported config object, // collects plugin source paths and dependency fingerprints into one JSON // envelope for the parent. Arbitrary in-memory plugin objects do not need to // cross the process boundary. // The URL lives in a variable so tsgo does not statically resolve it. /** * The descriptor extractor's emitted source. * * Exported so a regression can inspect the same bytes the loader executes. The * template consumes its own escapes, so reading this file's text instead would * check characters no consumer ever sees. */ export const TTSX_EXTRACTOR_SCRIPT = `// @ts-ignore -- internal loader must not require user-installed Node typings. import * as fs from "node:fs"; // @ts-ignore -- internal loader must not require user-installed Node typings. import { Buffer } from "node:buffer"; // @ts-ignore -- internal loader must not require user-installed Node typings. import { createHash } from "node:crypto"; // @ts-ignore -- internal loader must not require user-installed Node typings. import { createRequire, registerHooks } from "node:module"; // @ts-ignore -- internal loader must not require user-installed Node typings. import * as path from "node:path"; // @ts-ignore -- internal loader must not require user-installed Node typings. import { fileURLToPath, pathToFileURL } from "node:url"; const configUrl = %CONFIG_IMPORT%; const outputPath = %CONFIG_OUTPUT%; const resolutionRoot = path.resolve(%CONFIG_ROOT%); const CONFIG_KEYS = new Set([ "files", "ignores", "extends", "plugins", "rules", "format", ]); const dependencies = new Map; realpath: string | null; }>(); const graphNodes = new Map(); const graphEdges: Array<{ child: string; packageBoundary: boolean; parent: string; }> = []; const configLocation = fileURLToPath(configUrl); // Every spelling of this config the module system might key an edge under. // // Which one it uses is not knowable from here, and guessing has failed in both // directions. A path handed in by another producer can be escaped by a rule // Node does not share. Node respells a resolved file module through its real // path unless "--preserve-symlinks" is set, so a config reached through a // symlinked directory is keyed by its target. And a Windows 8.3 short name is // not a symlink: fs.realpathSync expands it, the module resolver does not, so // asking the volume there produces a spelling no edge carries. // // A seed that names a URL no edge was keyed under sits on a node with no // outgoing edges, the walk ends immediately, and every dependency recorded // after the first import is demoted from watch to cache. That failure is // silent: the build still succeeds and simply stops reacting. Seeding every // spelling costs one extra queue entry and cannot be wrong. const configUrlSpellings = [ ...new Set([ configUrl, pathToFileURL(configLocation).href, pathToFileURL(realConfigLocation()).href, ]), ]; const configEntryUrls = new Set(configUrlSpellings); const moduleProbeExtensions = [ ".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node", ] as const; const jsToTsProbeExtensions = new Map([ [".js", [".ts", ".tsx"]], [".jsx", [".tsx"]], [".mjs", [".mts"]], [".cjs", [".cts"]], ]); for (const spelling of configUrlSpellings) { graphNodes.set(spelling, configLocation); } recordDependency( "file", configLocation, createHash("sha256").update(fs.readFileSync(configLocation)).digest("hex"), configUrlSpellings, ); recordPackageManifests(configLocation, configUrlSpellings); declare const process: { cwd(): string; platform: string; stdout: { write(value: string): void }; stderr: { write(value: string): void }; exit(code?: number): never; }; const hooks = registerHooks({ resolve(specifier, context, nextResolve) { const requestedParent = context.parentURL && new URL(context.parentURL).href; if (requestedParent !== undefined && graphNodes.has(requestedParent)) { recordLocalResolutionCandidates(specifier, requestedParent); } const resolved = nextResolve(specifier, context); if (typeof resolved.url !== "string" || !resolved.url.startsWith("file:")) { return resolved; } const url = new URL(resolved.url).href; const parent = context.parentURL && new URL(context.parentURL).href; const location = fileURLToPath(url); // The entry is recognized by what was asked for, not only by what came // back. A module URL is assigned by whoever loaded it: a compiling loader // can serve the config from its emitted output, and a platform can hand // back a different spelling of the same file. Either way the URL bears no // resemblance to the one this process was given, so the config's own // imports would be rejected here — their parent is a URL no node was // recorded under — and the graph would collapse to the records made before // the first import. The request itself is unambiguous, so it decides. const entry = configEntryUrls.has(specifier) || url === new URL(configUrl).href || samePhysicalPath(location, configLocation); if (!entry && (parent === undefined || !graphNodes.has(parent))) { return resolved; } graphNodes.set(url, location); if (entry && configEntryUrls.has(specifier) && specifier !== url) { graphEdges.push({ child: url, packageBoundary: false, parent: specifier }); } if (parent !== undefined) { graphEdges.push({ child: url, packageBoundary: pathHasNodeModules(location) && !isLocalModuleSpecifier(specifier), parent, }); recordResolutionTopology( specifier, parent, url, location, context.conditions, ); } try { recordDependency( "file", location, createHash("sha256").update(fs.readFileSync(location)).digest("hex"), [url], ); } catch { // The evaluator remains authoritative for the load error. An unreadable // dependency simply makes this result non-cacheable in the parent. recordDependency("file", location, "", [url]); } return resolved; }, }); // Wrapped and settled explicitly rather than written as a top-level await. // The loader tsconfig's "module" now follows the config's own package, and // TS1378 rejects top-level await under a CommonJS module option however this // .mts file emits. The trailing catch is what a top-level await gave for // free: without it a throw from the finally would leave the promise // unsettled instead of failing the load. (async () => { try { const importedConfig = configLocation.toLowerCase().endsWith(".json") ? JSON.parse(fs.readFileSync(configLocation, "utf8").replace(/^\uFEFF/, "")) : await import(configUrl); const current = await resolveConfig(importedConfig, true); const entries = await collectPluginEntries(current, configLocation, %CONFIG_CHAIN%); fs.writeFileSync(outputPath, JSON.stringify({ dependencies: finalizeDependencies(), entries, }), "utf8"); } catch (error) { reportLoaderFailure(error); } finally { hooks.deregister(); } })().catch((error) => { // Reached only when the finally above throws: the catch already ends the // process, so this is the deregistration's own failure, not the config's. reportLoaderFailure(error); }); // reportLoaderFailure ends this loader on an error it can name, on both of the // channels the parent uses. // // The stack is for a reader and streams to stderr as it is written. The reason // is a fact about the user's config that a caller has to act on, so it travels // as data through the result file the parent already reads. Only a well-formed // envelope is honoured there, so a partially written or unrelated file leaves // the process status to speak for itself. function reportLoaderFailure(error: unknown): never { // The trailing newline ends the stack as a line of its own. Without it the // parent's own message, written to this same stream, starts mid-line. process.stderr.write((error instanceof Error && error.stack ? error.stack : String(error)) + "\\n"); try { fs.writeFileSync( outputPath, JSON.stringify({ __ttscLoaderError: error instanceof Error ? error.message : String(error) }), "utf8", ); } catch { // A reason that cannot be written leaves the exit status as the report. } return process.exit(1); } function isObject(value: unknown): value is Record { return value !== null && typeof value === "object"; } function recordDependency( kind: "directory" | "file" | "optional-file", location: string, digest: string, owners: readonly string[], ): void { const key = kind + "\\0" + location; const previous = dependencies.get(key); const mergedOwners = previous?.owners ?? new Set(); for (const owner of owners) mergedOwners.add(owner); const realpath = dependencyRealpath(location); const identityStable = previous?.identityStable !== false && (previous === undefined || previous.realpath === realpath); dependencies.set(key, { digest: !identityStable || (previous !== undefined && previous.digest !== digest) ? "" : digest, identityStable, kind, owners: mergedOwners, path: location, realpath, }); } function dependencyRealpath(location: string): string | null { try { return realPath(location); } catch { return null; } } function isLocalModuleSpecifier(specifier: string): boolean { return specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("file:") || /^[A-Za-z]:[\\\\/]/.test(specifier); } function pathHasNodeModules(location: string): boolean { return location.replaceAll("\\\\", "/").split("/").includes("node_modules"); } function recordResolutionTopology( specifier: string, parentUrl: string, childUrl: string, childLocation: string, conditions: readonly string[], ): void { const owners = [parentUrl, childUrl]; const parentLocation = graphNodes.get(parentUrl); if (parentLocation !== undefined && isLocalModuleSpecifier(specifier)) { recordDirectoryDependency(path.dirname(parentLocation), owners); } recordDirectoryDependency(path.dirname(childLocation), owners); recordPackageManifests(childLocation, owners); if (parentLocation !== undefined && !isLocalModuleSpecifier(specifier)) { recordNodeModulesSearchDirectories( parentLocation, specifier, childLocation, owners, conditions, ); } } function recordDirectoryDependency( location: string, owners: readonly string[], ): void { try { recordDependency("directory", location, directoryDigest(location), owners); } catch { recordDependency("directory", location, "", owners); } } function directoryDigest(location: string): string { const entries: Buffer[] = []; if (process.platform === "win32") { for (const entry of fs.readdirSync(location, { withFileTypes: true })) { let target = Buffer.alloc(0); if (entry.isSymbolicLink()) { try { target = Buffer.from( fs.readlinkSync(path.join(location, entry.name)), "utf8", ); } catch { target = Buffer.from(""); } } entries.push(directoryDigestRecord(Buffer.from(entry.name), entry, target)); } } else { for (const entry of fs.readdirSync(location, { encoding: "buffer", withFileTypes: true, })) { let target = Buffer.alloc(0); if (entry.isSymbolicLink()) { try { target = fs.readlinkSync( Buffer.concat([ Buffer.from(location), Buffer.from(path.sep), entry.name, ]), { encoding: "buffer" }, ); } catch { target = Buffer.from(""); } } entries.push(directoryDigestRecord(entry.name, entry, target)); } } entries.sort(Buffer.compare); const serialized = Buffer.concat( entries.flatMap((entry, index) => index === 0 ? [entry] : [Buffer.from([0]), entry], ), ); return createHash("sha256").update(serialized).digest("hex"); } function directoryDigestRecord( name: Buffer, entry: { isDirectory(): boolean; isFile(): boolean; isSymbolicLink(): boolean; }, target: Buffer, ): Buffer { const kind = entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other"; return Buffer.concat([name, Buffer.from("\\0" + kind + "\\0"), target]); } function optionalFileDigest(location: string): string { try { const entry = fs.statSync(location); if (entry.isFile()) { return createHash("sha256") .update(Buffer.concat([Buffer.from("file\\0"), fs.readFileSync(location)])) .digest("hex"); } if (entry.isDirectory()) { return createHash("sha256") .update("ttsc:host-input:directory\\0") .digest("hex"); } } catch { // Missing and unreadable candidates share the absent state. } return createHash("sha256").update("missing\\0").digest("hex"); } function recordOptionalFileDependency( location: string, owners: readonly string[], ): boolean { try { if (fs.statSync(location).isFile()) { recordDependency( "file", location, createHash("sha256").update(fs.readFileSync(location)).digest("hex"), owners, ); return true; } } catch { // The exact missing path remains a dependency of the resolution result. } recordDependency("optional-file", location, optionalFileDigest(location), owners); return false; } function moduleResolutionCandidates(base: string): string[] { const extension = path.extname(base).toLowerCase(); const substitutions = jsToTsProbeExtensions.get(extension) ?? []; const stem = base.slice(0, base.length - extension.length); return [ base, ...substitutions.map((candidate) => stem + candidate), ...moduleProbeExtensions.map((candidate) => base + candidate), path.join(base, "package.json"), ...moduleProbeExtensions.map((candidate) => path.join(base, "index" + candidate), ), ]; } /** Record exact local probes before the runtime resolver chooses one. */ function recordLocalResolutionCandidates( specifier: string, parentUrl: string, ): void { if (!isLocalModuleSpecifier(specifier)) return; let bases: string[]; try { if (specifier.startsWith("file:")) { bases = [fileURLToPath(specifier)]; } else { const directory = path.dirname(fileURLToPath(parentUrl)); const raw = path.resolve(directory, specifier); const suffixStart = specifier.search(/[?#]/); const pathname = suffixStart === -1 ? specifier : specifier.slice(0, suffixStart); bases = pathname === "" ? [raw] : [...new Set([raw, path.resolve(directory, pathname)])]; } } catch { return; } const owners = [parentUrl]; for (const base of bases) { try { if (fs.statSync(base).isFile()) { recordOptionalFileDependency(base, owners); continue; } } catch { // A missing exact spelling falls through to source/extension/directory // probes, all of which can redirect a later evaluation. } for (const candidate of moduleResolutionCandidates(base)) { recordOptionalFileDependency(candidate, owners); } } } /** CommonJS LOAD_AS_FILE / LOAD_AS_DIRECTORY candidates for one legacy path. */ function recordLegacyPackagePathCandidates( candidate: string, owners: readonly string[], ): void { for (const file of [ candidate, candidate + ".js", candidate + ".json", candidate + ".node", path.join(candidate, "package.json"), path.join(candidate, "index.js"), path.join(candidate, "index.json"), path.join(candidate, "index.node"), ]) { recordOptionalFileDependency(file, owners); } } function recordPackageManifests( location: string, owners: readonly string[], ): void { let current = path.dirname(location); while (true) { const manifest = path.join(current, "package.json"); if (recordOptionalFileDependency(manifest, owners)) return; const parent = path.dirname(current); if (parent === current || path.basename(current) === "node_modules") return; current = parent; } } function recordNodeModulesSearchDirectories( parentLocation: string, specifier: string, childLocation: string, owners: readonly string[], conditions: readonly string[], ): void { const packageName = modulePackageName(specifier); const scope = specifier.startsWith("@") && specifier.includes("/") ? specifier.slice(0, specifier.indexOf("/")) : undefined; let current = path.dirname(parentLocation); while (true) { // A newly created nearer node_modules directory can shadow the package // selected by this evaluation, so missing search levels are dependencies. recordDirectoryDependency(current, owners); const modules = path.join(current, "node_modules"); try { if (fs.statSync(modules).isDirectory()) { recordDirectoryDependency(modules, owners); if (scope !== undefined) { const scoped = path.join(modules, scope); try { if (fs.statSync(scoped).isDirectory()) { recordDirectoryDependency(scoped, owners); } } catch { // The directory digest of node_modules records a missing scope. } } } } catch { // Missing search levels do not participate in the current resolution. } if (packageName !== undefined) { const selected = recordPackageCandidateTopology( modules, packageName, specifier, childLocation, owners, conditions, ); if ( selected || resolvedPackageContains(modules, packageName, childLocation) ) { return; } } if ( packageName === undefined && samePhysicalPath(current, resolutionRoot) ) { return; } const parent = path.dirname(current); if (parent === current) return; current = parent; } } function recordPackageCandidateTopology( modules: string, packageName: string, specifier: string, childLocation: string, owners: readonly string[], conditions: readonly string[], ): boolean { const packageRoot = path.join(modules, packageName); const subpath = specifier .slice(packageName.length) .replace(/^[/\\\\]+/, ""); try { if (!fs.statSync(packageRoot).isDirectory()) { recordOptionalFileDependency( path.join(packageRoot, "package.json"), owners, ); recordLegacyPackagePathCandidates( subpath === "" ? packageRoot : path.join(packageRoot, subpath), owners, ); return false; } } catch { recordOptionalFileDependency( path.join(packageRoot, "package.json"), owners, ); recordLegacyPackagePathCandidates( subpath === "" ? packageRoot : path.join(packageRoot, subpath), owners, ); return false; } const rootTopology = recordPackageRootTopology( packageRoot, owners, subpath === "", subpath === "" ? "." : "./" + subpath.replaceAll("\\\\", "/"), childLocation, conditions, ); if (subpath !== "" && !rootTopology.hasExports) { return ( recordPackageSubpathTopology( packageRoot, subpath, childLocation, owners, ) || rootTopology.selected ); } return rootTopology.selected; } function recordPackageRootTopology( packageRoot: string, owners: readonly string[], useMain: boolean, packageSubpath: string, childLocation: string, conditions: readonly string[], ): { hasExports: boolean; selected: boolean } { const normalizedRoot = path.resolve(packageRoot); const manifest = path.join(normalizedRoot, "package.json"); const legacySelected = (): boolean => useMain && packagePathCandidateMatchesChild(normalizedRoot, childLocation, true); if (useMain) recordLegacyPackagePathCandidates(normalizedRoot, owners); if (!recordOptionalFileDependency(manifest, owners)) { const selected = legacySelected(); if (!selected) { recordPackageIndexCandidates(normalizedRoot, useMain, owners); } return { hasExports: false, selected }; } try { const value = JSON.parse(fs.readFileSync(manifest, "utf8")); if (value !== null && typeof value === "object") { const metadata = value as Record; const hasExports = metadata.exports !== undefined && metadata.exports !== null; if (hasExports) { const target = selectPackageExportsTarget( metadata.exports, packageSubpath, new Set(conditions), ); const candidate = typeof target === "string" ? packageExportsTarget(normalizedRoot, target) : undefined; const selected = candidate !== undefined && packagePathCandidateMatchesChild( candidate, childLocation, false, ); if (selected) { recordPackagePathCandidate(candidate, owners); } else if (candidate !== undefined) { // A nearer package the search skipped starts winning the moment its // own active target appears, and neither the parent node_modules // listing nor the manifest changes when only that file is created. recordOptionalFileDependency(candidate, owners); } return { hasExports: true, selected }; } let selected = legacySelected(); if (useMain && typeof metadata.main === "string") { // CommonJS main is a legacy path, not an exports target. Node resolves // it literally and permits absolute paths and paths outside the package. const main = path.resolve(normalizedRoot, metadata.main); recordPackagePathCandidate(main, owners); recordLegacyPackagePathCandidates(main, owners); selected = packagePathCandidateMatchesChild(main, childLocation, true) || selected; } if (!selected) { recordPackageIndexCandidates(normalizedRoot, useMain, owners); } return { hasExports: false, selected, }; } } catch { // Node owns malformed-manifest diagnostics; the manifest digest is enough // to invalidate this evaluation when its contents change. } const selected = legacySelected(); if (!selected) { recordPackageIndexCandidates(normalizedRoot, useMain, owners); } return { hasExports: false, selected }; } // recordPackageIndexCandidates pins the LOAD_INDEX fallbacks of a package root // this resolution walked past without selecting. An empty package directory, or // one whose manifest declares no usable entry, becomes resolvable as soon as one // of these files exists, and that creation changes neither the parent directory // listing nor the manifest digest already recorded for the candidate. function recordPackageIndexCandidates( packageRoot: string, useMain: boolean, owners: readonly string[], ): void { if (!useMain) return; for (const name of ["index.js", "index.json", "index.node"]) { recordOptionalFileDependency(path.join(packageRoot, name), owners); } } function selectPackageExportsTarget( exportsValue: unknown, packageSubpath: string, conditions: ReadonlySet, ): string | null | undefined { let mappings: unknown = exportsValue; if ( typeof mappings === "string" || Array.isArray(mappings) || (isObject(mappings) && Object.keys(mappings).every((key) => !key.startsWith("."))) ) { if (packageSubpath !== ".") return undefined; return selectPackageTarget(mappings, "", false, conditions); } if (!isObject(mappings)) return undefined; if ( Object.prototype.hasOwnProperty.call(mappings, packageSubpath) && !packageSubpath.includes("*") && !packageSubpath.endsWith("/") ) { return selectPackageTarget( mappings[packageSubpath], "", false, conditions, ); } let bestMatch = ""; let bestSubpath = ""; for (const key of Object.keys(mappings)) { const wildcard = key.indexOf("*"); if ( wildcard === -1 || key.lastIndexOf("*") !== wildcard || !packageSubpath.startsWith(key.slice(0, wildcard)) ) { continue; } const trailer = key.slice(wildcard + 1); if ( packageSubpath.length < key.length || !packageSubpath.endsWith(trailer) || packagePatternKeyCompare(bestMatch, key) !== 1 ) { continue; } bestMatch = key; bestSubpath = packageSubpath.slice( wildcard, packageSubpath.length - trailer.length, ); } return bestMatch === "" ? undefined : selectPackageTarget( mappings[bestMatch], bestSubpath, true, conditions, ); } function selectPackageTarget( target: unknown, subpath: string, pattern: boolean, conditions: ReadonlySet, ): string | null | undefined { if (typeof target === "string") { const selected = pattern ? target.replaceAll("*", subpath) : target; return validPackageExportsTarget(selected) ? selected : undefined; } if (Array.isArray(target)) { for (const item of target) { const selected = selectPackageTarget( item, subpath, pattern, conditions, ); if (selected !== undefined && selected !== null) return selected; } return null; } if (isObject(target)) { for (const [condition, value] of Object.entries(target)) { if (condition !== "default" && !conditions.has(condition)) continue; const selected = selectPackageTarget( value, subpath, pattern, conditions, ); if (selected !== undefined) return selected; } return undefined; } return target === null ? null : undefined; } function packagePatternKeyCompare(left: string, right: string): number { const leftWildcard = left.indexOf("*"); const rightWildcard = right.indexOf("*"); const leftBase = leftWildcard === -1 ? left.length : leftWildcard + 1; const rightBase = rightWildcard === -1 ? right.length : rightWildcard + 1; if (leftBase > rightBase) return -1; if (rightBase > leftBase) return 1; if (leftWildcard === -1) return 1; if (rightWildcard === -1) return -1; if (left.length > right.length) return -1; if (right.length > left.length) return 1; return 0; } function packageExportsTarget( packageRoot: string, target: string, ): string | undefined { if (!validPackageExportsTarget(target)) return undefined; try { // Node resolves an exports target as a URL against the package manifest, // so percent escapes, query strings, and fragments all take part in the // path it finally loads. Joining the raw target by hand diverges from that // whenever the target is anything but a plain relative path, and a target // Node resolves while this model rejects loses the selected file's // fingerprint, leaving a retargeted symlink cached as fresh. const packageUrl = pathToFileURL(path.join(packageRoot, "package.json")); const resolved = new URL(target, packageUrl); const packagePath = new URL(".", packageUrl).pathname; if (!resolved.pathname.startsWith(packagePath)) return undefined; return fileURLToPath(resolved); } catch { return undefined; } } function validPackageExportsTarget(target: string): boolean { if (!target.startsWith("./") || /%2f|%5c/i.test(target)) return false; const components = target .slice(2) .replaceAll("\\\\", "/") .split("/"); if ( components.some( (component) => { try { const decoded = decodeURIComponent(component); return ( decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\\\") || decoded.toLowerCase() === "node_modules" ); } catch { return true; } }, ) ) { return false; } return true; } function packagePathCandidateMatchesChild( candidate: string, childLocation: string, legacy: boolean, ): boolean { let child: string; try { child = fs.realpathSync.native(childLocation); } catch { child = path.resolve(childLocation); } const candidates = legacy ? [ candidate, candidate + ".js", candidate + ".json", candidate + ".node", path.join(candidate, "index.js"), path.join(candidate, "index.json"), path.join(candidate, "index.node"), ] : [candidate]; return candidates.some((location) => { try { return sameResolutionPath(fs.realpathSync.native(location), child); } catch { return false; } }); } function recordPackageSubpathTopology( packageRoot: string, subpath: string, childLocation: string, owners: readonly string[], ): boolean { const candidate = boundedPackageTarget(packageRoot, subpath); if (candidate === undefined) return false; recordPackagePathCandidate(candidate, owners); recordLegacyPackagePathCandidates(candidate, owners); let selected = packagePathCandidateMatchesChild( candidate, childLocation, true, ); try { if (!fs.statSync(candidate).isDirectory()) return selected; } catch { return selected; } const manifest = path.join(candidate, "package.json"); if (!recordOptionalFileDependency(manifest, owners)) return selected; try { const value = JSON.parse(fs.readFileSync(manifest, "utf8")); if (value !== null && typeof value === "object") { const metadata = value as Record; if (typeof metadata.main === "string") { const main = path.resolve(candidate, metadata.main); recordPackagePathCandidate(main, owners); recordLegacyPackagePathCandidates(main, owners); selected = packagePathCandidateMatchesChild(main, childLocation, true) || selected; } } } catch { // Node owns malformed nested-package diagnostics. } return selected; } function boundedPackageTarget( packageRoot: string, target: string, ): string | undefined { const candidate = path.resolve(packageRoot, target); const relative = path.relative(packageRoot, candidate); if ( relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative) ) { return undefined; } return candidate; } function recordPackagePathCandidate( candidate: string, owners: readonly string[], visited: Set = new Set(), depth = 0, ): void { const normalized = path.resolve(candidate); // The depth bound owns termination. A platform-wide case fold would merge // paths that differ only by case, which a per-directory case-sensitive // Windows tree keeps distinct, and would truncate a valid symlink chain. if (depth >= 64 || visited.has(normalized)) return; visited.add(normalized); const parsed = path.parse(normalized); const components = normalized .slice(parsed.root.length) .split(path.sep) .filter(Boolean); let current = parsed.root; for (let index = 0; index < components.length; index++) { const component = components[index]; const next = path.join(current, component); let entry: ReturnType; try { entry = fs.lstatSync(next); } catch { recordDirectoryDependency(current, owners); return; } if (entry.isSymbolicLink()) { // The containing directory digest carries the raw link target. recordDirectoryDependency(current, owners); try { const target = fs.readlinkSync(next); const remainder = components.slice(index + 1); recordPackagePathCandidate( path.join( path.resolve(current, target), ...remainder, ), owners, visited, depth + 1, ); } catch { // The lexical link record already carries the unreadable state. } } let isDirectory = entry.isDirectory(); if (entry.isSymbolicLink()) { try { isDirectory = fs.statSync(next).isDirectory(); } catch { return; } } if (index === components.length - 1) { recordDirectoryDependency(isDirectory ? next : current, owners); return; } if (!isDirectory) { recordDirectoryDependency(current, owners); return; } current = next; } recordDirectoryDependency(current, owners); } function modulePackageName(specifier: string): string | undefined { if (specifier.startsWith("@")) { const components = specifier.split("/"); return components.length >= 2 ? components[0] + "/" + components[1] : undefined; } const [name] = specifier.split("/"); return name && !name.startsWith("#") ? name : undefined; } function resolvedPackageContains( modules: string, packageName: string, childLocation: string, ): boolean { try { const packageRoot = fs.realpathSync(path.join(modules, packageName)); const child = fs.realpathSync(childLocation); const relative = path.relative(packageRoot, child); if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) { return false; } let ancestor = child; const depth = relative === "" ? 0 : relative.split(path.sep).length; for (let i = 0; i < depth; i++) ancestor = path.dirname(ancestor); return sameResolutionPath(packageRoot, ancestor); } catch { return false; } } function sameResolutionPath(left: string, right: string): boolean { const leftPath = path.resolve(left); const rightPath = path.resolve(right); if (leftPath === rightPath) return true; if (path.relative(leftPath, rightPath) !== "") return false; // A native lexical case fold does not establish filesystem identity. // Verify case aliases without merging differently located hard links. try { const leftInfo = fs.statSync(leftPath, { bigint: true }); const rightInfo = fs.statSync(rightPath, { bigint: true }); return leftInfo.ino !== 0n && leftInfo.dev === rightInfo.dev && leftInfo.ino === rightInfo.ino; } catch { return false; } } function samePhysicalPath(left: string, right: string): boolean { try { return sameResolutionPath(realPath(left), realPath(right)); } catch { // Exact lexical spelling is still usable when a file disappears. A case // alias remains unproven until the filesystem identifies both paths. return sameResolutionPath(left, right); } } /** * The config's real path, or its declared one when the volume will not say. * * A config can disappear between the host reading it and this loader starting, * and a throw here would replace a precise report from the import below with a * crash in bookkeeping. Seeding lexically instead only risks the demotion this * value exists to prevent, on a file that is already gone. */ function realConfigLocation(): string { try { return realPath(configLocation); } catch { return configLocation; } } function realPath(location: string): string { return fs.realpathSync.native ? fs.realpathSync.native(location) : fs.realpathSync(location); } function finalizeDependencies(): Array<{ digest: string; identityStable: boolean; kind: "directory" | "file" | "optional-file"; path: string; realpath: string | null; scope: "cache" | "watch"; }> { // The evaluator may have observed a dependency, run arbitrary config code, // and then serialize after that path changed again. Re-read every recorded // dependency under its original ownership set. Differences between recorded // observations invalidate reuse; identical digest and realpath observations // do not establish that no transient replacement occurred between them. for (const dependency of [...dependencies.values()]) { recordDependency( dependency.kind, dependency.path, currentDependencyDigest(dependency.kind, dependency.path), [...dependency.owners], ); } const watched = graphWatchReachability(); return [...dependencies.values()].map(({ owners, ...dependency }) => ({ ...dependency, scope: [...owners].some((owner) => watched.has(owner)) ? "watch" : "cache", })); } function currentDependencyDigest( kind: "directory" | "file" | "optional-file", location: string, ): string { try { if (kind === "directory") return directoryDigest(location); if (kind === "optional-file") return optionalFileDigest(location); return createHash("sha256") .update(fs.readFileSync(location)) .digest("hex"); } catch { return ""; } } function graphWatchReachability(): Set { const adjacency = new Map(); for (const edge of graphEdges) { const outgoing = adjacency.get(edge.parent) ?? []; outgoing.push(edge); adjacency.set(edge.parent, outgoing); } const queue: Array<{ url: string; watched: boolean }> = configUrlSpellings.map((url) => ({ url, watched: true })); const visited = new Set(); const watched = new Set(); for (let head = 0; head < queue.length; head++) { const state = queue[head]!; const key = state.url + "\\0" + (state.watched ? "1" : "0"); if (visited.has(key)) continue; visited.add(key); if (state.watched) watched.add(state.url); for (const edge of adjacency.get(state.url) ?? []) { const childLocation = graphNodes.get(edge.child); const childWatched = edge.packageBoundary ? false : childLocation !== undefined && !pathHasNodeModules(childLocation) ? true : state.watched; queue.push({ url: edge.child, watched: childWatched }); } } return watched; } function hasOwn(value: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); } async function resolveConfig(value: unknown, allowNamedConfig: boolean): Promise { let current = value; for (let i = 0; i < 8; i++) { if (typeof current === "function") { current = await (current as () => unknown | Promise)(); allowNamedConfig = false; continue; } if (isObject(current) && !Array.isArray(current)) { if (hasOwn(current, "default")) { const defaultValue = current.default; if (isModuleNamespace(current) || !hasConfigKey(current)) { current = defaultValue; allowNamedConfig = false; continue; } const normalizedDefault = await resolveConfig(defaultValue, false); if (isObject(normalizedDefault) && !Array.isArray(normalizedDefault)) { current = mergeConfigObjects(normalizedDefault, current); allowNamedConfig = false; continue; } } if (allowNamedConfig && hasOwn(current, "config")) { current = current.config; allowNamedConfig = false; continue; } } break; } return current; } function isModuleNamespace(value: Record): boolean { return Object.prototype.toString.call(value) === "[object Module]"; } function hasConfigKey(value: Record): boolean { for (const key of CONFIG_KEYS) { if (hasOwn(value, key)) { return true; } } return false; } function mergeConfigObjects( base: Record, override: Record, ): Record { const out: Record = {}; for (const key of CONFIG_KEYS) { if (hasOwn(base, key)) { out[key] = base[key]; } } for (const key of CONFIG_KEYS) { if (hasOwn(override, key)) { out[key] = override[key]; } } return out; } // Follow the same containing-file-relative, base-first extends chain as the // native config reader. Each explicit base is a watch input even when it lives // in node_modules; imports made by that base retain ordinary package boundaries. async function collectPluginEntries( value: unknown, location: string, chain: readonly string[], ): Promise> { const out: Array<{ namespace: string; source: string } | { configFile: string; chain: readonly string[] }> = []; if (Array.isArray(value)) { for (const item of value) out.push(...await collectPluginEntries(item, location, chain)); return out; } if (!isObject(value)) return out; if (value.extends !== undefined && value.extends !== null) { if (typeof value.extends !== "string" || value.extends.trim() === "") { throw new Error("extends must be a non-empty string path to another config file"); } const next = path.resolve(path.dirname(location), value.extends); if (chain.includes(next)) { throw new Error("extends cycle detected: " + [...chain, next].join(" -> ")); } if (chain.length >= 32) { throw new Error("extends chain exceeds the depth limit of 32: " + chain.join(" -> ")); } const nextUrl = pathToFileURL(next).href; const parentUrl = pathToFileURL(location).href; configEntryUrls.add(nextUrl); graphNodes.set(nextUrl, next); graphEdges.push({ child: nextUrl, packageBoundary: false, parent: parentUrl }); recordDependency("file", next, createHash("sha256").update(fs.readFileSync(next)).digest("hex"), [nextUrl]); recordPackageManifests(next, [nextUrl]); // The owning parent evaluates typed bases under their own synthetic project. // A dynamic import here would give a typed base the consumer's tsconfig // when it falls outside this evaluator's entry manifest. if ([".ts", ".cts", ".mts"].includes(path.extname(next).toLowerCase())) { out.push({ configFile: next, chain: [...chain, next] }); } else { const imported = next.toLowerCase().endsWith(".json") ? JSON.parse(fs.readFileSync(next, "utf8").replace(/^\uFEFF/, "")) : await import(nextUrl); const base = await resolveConfig(imported, true); out.push(...await collectPluginEntries(base, next, [...chain, next])); } } if (hasOwn(value, "plugins") && isObject(value.plugins)) { for (const [namespace, plugin] of Object.entries(value.plugins)) { const source = extractPluginSource(plugin, location); if (source === undefined || source.length === 0) { throw new Error("contributor " + JSON.stringify(namespace) + " must resolve to an object with a non-empty source string"); } out.push({ namespace, source }); } } return out; } function extractPluginSource(value: unknown, location: string): string | undefined { if (typeof value === "string") { value = createRequire(pathToFileURL(location))(value); } if (!isObject(value)) return undefined; // ESM-from-CJS interop wraps CJS modules' \`exports.default\` so the // plugin object can land under a \`.default\` indirection. Walk a few // hops so contributors authored as \`export default plugin\` and // contributors authored as plain \`module.exports = plugin\` both // resolve identically. let current: Record = value; // Bound default-wrapper traversal so a cyclic descriptor cannot loop. for (let i = 0; i < 8; i++) { if (typeof current.source === "string") break; const next = current.default; if (!isObject(next)) break; current = next; } const source = current.source; return typeof source === "string" ? source : undefined; } `; /** * Resolves contributor plugin entries declared in any executable lint config, * memoized through the shared on-disk config cache. * * Evaluating such a config spawns a full `ttsx` subprocess. Typed extends bases * additionally use their own synthetic project and launcher, so their package * format is not replaced by the consuming project's compiler options. * JavaScript and JSON bases share their containing evaluator. A monorepo build * runs one `ttsc` process per package. A completed cache entry avoids another * evaluation of the same unchanged config; simultaneous cold processes may * still evaluate it independently. The cache key covers the entry's path and * exact contents; the payload also fingerprints every local module reached from * that entry. An entry or helper edit therefore re-evaluates cleanly without * treating installed packages as project watch inputs. */ function readTtsxConfigPlugins( configPath: string, context: TtscPluginFactoryContext, ): ConfigPluginEvaluation { const resolutionRoot = path.resolve(pluginConfigBaseDir(context)); const cacheKey = configCacheKey(`plugins\0${resolutionRoot}`, configPath); if (cacheKey) { const cached = readConfigPluginCache(cacheKey); // Re-validate cached entries before trusting them: a contributor's // resolved `source` directory may have moved since the entry was // written. A stale entry falls through to a fresh evaluation rather // than being forwarded to ttsc's plugin builder as a dead path. if ( cached && cached.entries.every(isValidConfigPluginEntry) && configDependenciesAreCurrent(cached.dependencies) ) { return cached; } } // A config can be saved while it is being evaluated. Retry a bounded number // of times until every dependency still has the bytes the module hook saw. // A continuously changing config remains usable but is deliberately not // cached; watch will schedule another cycle. let evaluation: ConfigPluginEvaluation; for (let attempt = 0; attempt < 3; attempt++) { evaluation = evaluateTtsxConfigPlugins(configPath, context); if (configDependenciesAreCurrent(evaluation.dependencies)) { if (cacheKey) writeConfigPluginCache(cacheKey, evaluation); return evaluation; } } return evaluation!; } /** * Reports whether a cached plugin entry is still usable: a well-formed * namespace and an absolute `source` that still points at a directory. Pure * predicate — never throws — so a malformed cache entry simply triggers * re-evaluation instead of aborting plugin discovery. */ function isValidConfigPluginEntry(entry: unknown): entry is ConfigPluginEntry { if ( entry == null || typeof entry !== "object" || typeof (entry as ConfigPluginEntry).namespace !== "string" || typeof (entry as ConfigPluginEntry).source !== "string" ) { return false; } const { namespace, source } = entry as ConfigPluginEntry; if (!NAMESPACE_PATTERN.test(namespace) || !path.isAbsolute(source)) { return false; } try { return fs.statSync(source).isDirectory(); } catch { return false; } } /** * Evaluate typed bases with their own project and merge base-first entries and * observed inputs. JavaScript and JSON bases remain in their containing * evaluator. A call-owned memo avoids evaluating the same typed base twice; no * result escapes the validated aggregate config cache. */ function evaluateTtsxConfigPlugins( configPath: string, context: TtscPluginFactoryContext, chain: readonly string[] = [configPath], evaluated: Map = new Map(), ): ConfigPluginEvaluation { const existing = evaluated.get(configPath); if (existing !== undefined) return existing; const tempDir = createConfigLoaderTempDirectory( configPath, os.tmpdir(), process.env.TTSC_PLUGIN_RECORD_ROOT, context.projectRoot, ); try { linkNearestNodeModules(tempDir, path.dirname(configPath)); const loaderPath = path.join(tempDir, "loader.mts"); const outputPath = path.join(tempDir, "result.json"); const tsconfigPath = path.join(tempDir, "tsconfig.json"); const loaderSource = TTSX_EXTRACTOR_SCRIPT.replace( "%CONFIG_IMPORT%", JSON.stringify(pathToFileURL(configPath).href), ) .replace("%CONFIG_CHAIN%", JSON.stringify(chain)) .replace("%CONFIG_OUTPUT%", JSON.stringify(outputPath)) .replace( "%CONFIG_ROOT%", JSON.stringify(path.resolve(pluginConfigBaseDir(context))), ); fs.writeFileSync(loaderPath, loaderSource, "utf8"); fs.writeFileSync( tsconfigPath, JSON.stringify( { compilerOptions: { // `.mjs` and `.js` configs reach this loader because Node // cannot `require()` ESM synchronously; ttsx's TypeScript // project compiler refuses to admit them without `allowJs`, // so set it alongside the strict bypass to make the loader // tolerant of any of `lint.config.{ts,cts,mts,js,mjs,cjs}`. allowImportingTsExtensions: true, allowJs: true, checkJs: false, // The config is a Node module, so Node's rule decides its format: // the nearest `package.json` `type` above it. Hardcoding one answer // ran every ambiguous `.ts` config as ESM and broke `__dirname` in // an ordinary CommonJS package (#1068). `moduleResolution` stays // `bundler`, which tsgo accepts for both kinds, so extensionless // relative imports keep resolving either way. module: configModuleOption(configPath), moduleResolution: "bundler", noImplicitAny: false, outDir: path.join(tempDir, "out").replace(/\\/g, "/"), rewriteRelativeImportExtensions: true, rootDir: path.parse(tempDir).root.replace(/\\/g, "/"), skipLibCheck: true, strict: false, target: "ES2022", // TypeScript 7 includes no ambient type package unless `types` asks // for it, and this Program extends nothing, so without the wildcard // a config could not name a single Node global (#1068). The loader // directory links the config's nearest `node_modules`, so the // default `typeRoots` walk finds exactly what the project installed. types: ["*"], }, files: [ loaderPath.replace(/\\/g, "/"), ...(path.extname(configPath).toLowerCase() === ".json" ? [] : [configPath.replace(/\\/g, "/")]), ], }, null, 2, ), "utf8", ); // The config comes first, so a project that pins its own ttsc gets it. This // descriptor's own location comes second, because a fixture or a workspace // that installs only the lint package cannot resolve ttsc from the config // at all, and the descriptor always sits beside the host that loaded it. const ttsxBinary = resolveTtsxLauncher([configPath, context.dirname]); // `--no-plugins` keeps this build hermetic: the loader only needs to // type-check and run the user's lint config to extract its plugin // entries. Loading the host project's transform/check plugins // (`@nestia/core`, `typia`, …) would run their project checks // against this deliberately lenient loader tsconfig and fail the // build — e.g. `@nestia/core` rejects the loader's `strict: false`. const args = [ "--project", tsconfigPath, "--cwd", tempDir, "--no-plugins", loaderPath, ]; const tsgoBinary = resolveConfigTsgo(configPath, context); if (tsgoBinary) { args.unshift("--binary", tsgoBinary); } const env = { ...nodeConfigLoaderEnv(configPath), }; const command = ttsxThroughNodeIfNeeded(ttsxBinary, env, tempDir); const observation = beginLintTrace(); const lower = observation ? new Date().toISOString() : undefined; observation?.record("process-attempt", { pid: 0, argv: [command.binary, ...command.prefix, ...args], cwd: tempDir, cwdInherited: false, startLowerBound: lower, owner: "lint-typescript-config-plugin-extractor", }); const result = (() => { try { const actual = spawnSync(command.binary, [...command.prefix, ...args], { cwd: tempDir, env, // Both child streams are human output, and they go straight to this // process's stderr as they are written. Nothing is collected here: the // parent's stdout is reserved for compiler JSON and LSP frames, and // buffering the child only to replay it afterwards is what forced an // invented output ceiling and made a long evaluation print nothing at all. stdio: ["ignore", 2, 2], windowsHide: true, }); observation?.record("process-result", { pid: actual.pid > 0 ? actual.pid : 0, started: actual.pid > 0, exitObserved: actual.status !== null || actual.signal !== null, status: actual.status, signal: actual.signal, error: actual.error?.message, argv: [command.binary, ...command.prefix, ...args], cwd: tempDir, cwdInherited: false, startLowerBound: lower, startUpperBound: new Date().toISOString(), owner: "lint-typescript-config-plugin-extractor", method: "spawnSync", }); return actual; } catch (error) { observation?.record("process-result", { pid: 0, started: false, exitObserved: false, error: error instanceof Error ? error.message : String(error), argv: [command.binary, ...command.prefix, ...args], cwd: tempDir, cwdInherited: false, startLowerBound: lower, startUpperBound: new Date().toISOString(), owner: "lint-typescript-config-plugin-extractor", method: "spawnSync", }); throw error; } })(); const processFailure = configEvaluatorProcessFailure(result, configPath); if (processFailure) { // The evaluator's stack already reached the user's stderr as it ran. What // it could not put there is a reason a caller can act on, so that arrives // through the result file instead. const reason = configEvaluatorFailureReason(outputPath); throw reason === "" ? processFailure : new Error(`${processFailure.message}\n${reason}`); } let payload: { dependencies?: ConfigDependencyFingerprint[]; entries?: (ConfigPluginEntry | { configFile: string; chain: string[] })[]; }; try { payload = JSON.parse(fs.readFileSync(outputPath, "utf8")) as { dependencies?: ConfigDependencyFingerprint[]; entries?: ( | ConfigPluginEntry | { configFile: string; chain: string[] } )[]; }; } catch (error) { throw new Error( `@ttsc/lint: lint config ${configPath} evaluator returned invalid JSON: ${ error instanceof Error ? error.message : String(error) }`, ); } if (!Array.isArray(payload.entries)) { throw new Error( `@ttsc/lint: lint config ${configPath} evaluator omitted its plugin-entry array`, ); } const nestedDependencies: ConfigDependencyFingerprint[] = []; const entries = payload.entries.flatMap((entry): ConfigPluginEntry[] => { if ( entry !== null && typeof entry === "object" && "configFile" in entry ) { if ( typeof entry.configFile !== "string" || !path.isAbsolute(entry.configFile) || !Array.isArray(entry.chain) || entry.chain.length <= chain.length || entry.chain.length > 32 || entry.chain.some( (location, index) => typeof location !== "string" || (index < chain.length && location !== chain[index]), ) || entry.chain.at(-1) !== entry.configFile || new Set(entry.chain).size !== entry.chain.length ) throw new Error( `@ttsc/lint: lint config ${configPath} evaluator returned a malformed base-config request`, ); const base = evaluateTtsxConfigPlugins( entry.configFile, context, entry.chain, evaluated, ); nestedDependencies.push(...base.dependencies); return base.entries; } // Contributor objects supply absolute source directories. Validate that // boundary without re-routing directories through module resolution. if ( entry === null || typeof entry !== "object" || typeof entry.namespace !== "string" ) { throw new Error( `@ttsc/lint: lint config ${configPath} evaluator returned a malformed plugin entry`, ); } if (!NAMESPACE_PATTERN.test(entry.namespace)) { throw new Error( `@ttsc/lint: lint config ${configPath} namespace ${JSON.stringify(entry.namespace)} must match /^[a-z][a-z0-9_-]*$/`, ); } if (typeof entry.source !== "string" || entry.source.length === 0) { throw new Error( `@ttsc/lint: lint config ${configPath} plugin ${JSON.stringify(entry.namespace)} did not expose a "source" string`, ); } if (!path.isAbsolute(entry.source)) { throw new Error( `@ttsc/lint: lint config ${configPath} plugin ${JSON.stringify(entry.namespace)} "source" must be absolute; got ${JSON.stringify(entry.source)}`, ); } if ( !fs.existsSync(entry.source) || !fs.statSync(entry.source).isDirectory() ) { throw new Error( `@ttsc/lint: lint config ${configPath} plugin ${JSON.stringify(entry.namespace)} "source" must be an existing directory: ${entry.source}`, ); } return [{ namespace: entry.namespace, source: entry.source }]; }); const dependencies = normalizeConfigDependencyFingerprints( payload.dependencies, ); if (dependencies === undefined) { throw new Error( `@ttsc/lint: lint config ${configPath} evaluator returned malformed dependency fingerprints`, ); } const merged = new Map(); for (const dependency of [...dependencies, ...nestedDependencies]) { const key = dependency.kind + "\0" + dependency.path; const previous = merged.get(key); if ( previous !== undefined && (previous.digest !== dependency.digest || previous.realpath !== dependency.realpath) ) { throw new Error( `@ttsc/lint: config dependency changed between base evaluations: ${dependency.path}`, ); } merged.set(key, { ...dependency, identityStable: dependency.identityStable && (previous?.identityStable ?? true), scope: dependency.scope === "watch" || previous?.scope === "watch" ? "watch" : "cache", }); } const evaluation = { dependencies: [...merged.values()], entries }; evaluated.set(configPath, evaluation); return evaluation; } finally { removeEvaluationTempDir(tempDir); } } // ──────────────────────────────────────────────────────────────────────────── // Config cache (shared with the Go sidecar — packages/lint/linthost/config.go) // ──────────────────────────────────────────────────────────────────────────── /** Create evaluator storage beneath a frozen physical parent. */ function createCanonicalTempDirectory(prefix: string, parent: string): string { const physicalParent = fs.realpathSync.native(parent); if (!fs.lstatSync(physicalParent).isDirectory()) { throw new Error( `@ttsc/lint: temporary directory parent is not a directory: ${physicalParent}`, ); } const directory = fs.mkdtempSync(path.join(physicalParent, prefix)); if (!fs.lstatSync(directory).isDirectory()) { throw new Error( `@ttsc/lint: temporary directory postflight is not a directory: ${directory}`, ); } const physicalDirectory = fs.realpathSync.native(directory); if (path.dirname(physicalDirectory) !== physicalParent) { throw new Error( `@ttsc/lint: temporary directory escaped its physical parent: ${physicalDirectory}`, ); } return physicalDirectory; } /** * Namespaces the on-disk config cache. Kept in lockstep with the Go sidecar's * `configCacheVersion`; bump both when the shape or evaluator semantics * change. */ const CONFIG_CACHE_VERSION = "v12"; /** * Directory shared by this factory and the Go sidecar for cached lint configs. * The two write different files (the `kind` segment of the cache key keeps * their namespaces apart), so they coexist without collision. */ function configCacheDir(): string { return path.join(os.tmpdir(), "ttsc-lint-config-cache"); } /** Env opt-out, mirroring the Go sidecar's `TTSC_LINT_DISABLE_CONFIG_CACHE`. */ function configCacheDisabled(): boolean { return Boolean(process.env.TTSC_LINT_DISABLE_CONFIG_CACHE); } /** * Content-addressed cache key for a lint config file. Mirrors the Go sidecar's * `configCacheKey`: a version tag, a namespace `kind`, the config's absolute * path, and its exact bytes. Returns "" — a "do not cache" signal — when the * file cannot be read or the env opt-out is set. */ function configCacheKey(kind: string, configPath: string): string { if (configCacheDisabled()) return ""; let content: Buffer; try { content = fs.readFileSync(configPath); } catch { return ""; } return createHash("sha256") .update(CONFIG_CACHE_VERSION) .update("\0") .update(kind) .update("\0") .update(path.resolve(configPath)) .update("\0") .update(content) .digest("hex"); } /** * Returns the cached plugin-entry list for `cacheKey`, or undefined on any miss * — a missing file, an unreadable file, or a malformed evaluation envelope. * Every failure is a soft miss: the caller re-evaluates. */ function readConfigPluginCache( cacheKey: string, ): ConfigPluginEvaluation | undefined { let body: string; try { body = fs.readFileSync( path.join(configCacheDir(), `${cacheKey}.json`), "utf8", ); } catch { return undefined; } try { const parsed: unknown = JSON.parse(body); if ( parsed === null || typeof parsed !== "object" || !Array.isArray((parsed as ConfigPluginEvaluation).entries) || !Array.isArray((parsed as ConfigPluginEvaluation).dependencies) ) { return undefined; } const dependencies = normalizeConfigDependencyFingerprints( (parsed as ConfigPluginEvaluation).dependencies, ); if (dependencies === undefined) return undefined; return { dependencies, entries: (parsed as ConfigPluginEvaluation).entries, }; } catch { return undefined; } } /** * Writes `entries` to the config cache under `cacheKey`. Best-effort: any * failure leaves the cache cold rather than aborting plugin discovery. The * temp-file + rename keeps a concurrent reader in a sibling `ttsc` process from * observing a half-written file. */ function writeConfigPluginCache( cacheKey: string, evaluation: ConfigPluginEvaluation, ): void { try { const dir = configCacheDir(); fs.mkdirSync(dir, { recursive: true }); const tmp = path.join( dir, `${cacheKey}.${process.pid}.${randomUUID()}.tmp`, ); try { fs.writeFileSync(tmp, JSON.stringify(evaluation), "utf8"); fs.renameSync(tmp, path.join(dir, `${cacheKey}.json`)); } finally { try { fs.unlinkSync(tmp); } catch { // A successful rename already consumed the temporary path. } } } catch { // Cold cache on failure — the next invocation re-evaluates. } } function normalizeConfigDependencyFingerprints( value: unknown, ): ConfigDependencyFingerprint[] | undefined { if (!Array.isArray(value) || value.length === 0) return undefined; const dependencies = new Map(); for (const candidate of value) { if ( candidate === null || typeof candidate !== "object" || typeof (candidate as ConfigDependencyFingerprint).path !== "string" || typeof (candidate as ConfigDependencyFingerprint).digest !== "string" || typeof (candidate as ConfigDependencyFingerprint).identityStable !== "boolean" || ((candidate as ConfigDependencyFingerprint).realpath !== null && (typeof (candidate as ConfigDependencyFingerprint).realpath !== "string" || !path.isAbsolute( (candidate as ConfigDependencyFingerprint).realpath as string, ))) || !["directory", "file", "optional-file"].includes( (candidate as ConfigDependencyFingerprint).kind, ) || !["cache", "watch"].includes( (candidate as ConfigDependencyFingerprint).scope, ) ) { return undefined; } const candidatePath = (candidate as ConfigDependencyFingerprint).path; const digest = (candidate as ConfigDependencyFingerprint).digest; const kind = (candidate as ConfigDependencyFingerprint).kind; const identityStable = (candidate as ConfigDependencyFingerprint) .identityStable; const realpath = (candidate as ConfigDependencyFingerprint).realpath; const scope = (candidate as ConfigDependencyFingerprint).scope; if ( !path.isAbsolute(candidatePath) || (digest !== "" && !/^[0-9a-f]{64}$/.test(digest)) ) { return undefined; } const location = path.resolve(candidatePath); // The same lexical path can be both a traversed directory and an exact // file candidate. Those observations have different freshness contracts: // one fingerprints the listing, while the other fingerprints its entry // type. Preserve both, but still reject contradictory duplicates of one // kind. const key = kind + "\0" + location; const previous = dependencies.get(key); if ( previous !== undefined && (previous.digest !== digest || previous.identityStable !== identityStable || previous.kind !== kind || previous.realpath !== realpath || previous.scope !== scope) ) { return undefined; } dependencies.set(key, { digest, identityStable, kind, path: location, realpath, scope, }); } return [...dependencies.values()].sort((left, right) => left.path.localeCompare(right.path), ); } function configDependenciesAreCurrent( dependencies: readonly ConfigDependencyFingerprint[], ): boolean { if (dependencies.length === 0) return false; return dependencies.every((dependency) => { if ( !dependency.identityStable || dependency.realpath !== hostInputRealpath(dependency.path) ) { return false; } if (!/^[0-9a-f]{64}$/.test(dependency.digest)) return false; try { const digest = dependency.kind === "directory" ? configDirectoryDigest(dependency.path) : dependency.kind === "optional-file" ? configOptionalFileDigest(dependency.path) : createHash("sha256") .update(fs.readFileSync(dependency.path)) .digest("hex"); return digest === dependency.digest; } catch { return false; } }); } function configDirectoryDigest(location: string): string { const entries: Buffer[] = []; if (process.platform === "win32") { for (const entry of fs.readdirSync(location, { withFileTypes: true })) { let target = Buffer.alloc(0); if (entry.isSymbolicLink()) { try { target = Buffer.from( fs.readlinkSync(path.join(location, entry.name)), "utf8", ); } catch { target = Buffer.from(""); } } entries.push( configDirectoryDigestRecord(Buffer.from(entry.name), entry, target), ); } } else { for (const entry of fs.readdirSync(location, { encoding: "buffer", withFileTypes: true, })) { let target = Buffer.alloc(0); if (entry.isSymbolicLink()) { try { target = fs.readlinkSync( Buffer.concat([ Buffer.from(location), Buffer.from(path.sep), entry.name, ]), { encoding: "buffer" }, ); } catch { target = Buffer.from(""); } } entries.push(configDirectoryDigestRecord(entry.name, entry, target)); } } entries.sort(Buffer.compare); const serialized = Buffer.concat( entries.flatMap((entry, index) => index === 0 ? [entry] : [Buffer.from([0]), entry], ), ); return createHash("sha256").update(serialized).digest("hex"); } function configDirectoryDigestRecord( name: Buffer, entry: { isDirectory(): boolean; isFile(): boolean; isSymbolicLink(): boolean; }, target: Buffer, ): Buffer { const kind = entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other"; return Buffer.concat([name, Buffer.from("\0" + kind + "\0"), target]); } function configOptionalFileDigest(location: string): string { try { const entry = fs.statSync(location); if (entry.isFile()) { return createHash("sha256") .update( Buffer.concat([Buffer.from("file\0"), fs.readFileSync(location)]), ) .digest("hex"); } if (entry.isDirectory()) { return createHash("sha256") .update("ttsc:host-input:directory\0") .digest("hex"); } } catch { // Missing and unreadable candidates share the absent state. } return createHash("sha256").update("missing\0").digest("hex"); } // ──────────────────────────────────────────────────────────────────────────── // Shared helpers // ──────────────────────────────────────────────────────────────────────────── function findNearestNodeModules(start: string): string | undefined { let dir = path.resolve(start); while (true) { const candidate = path.join(dir, "node_modules"); if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { return candidate; } const parent = path.dirname(dir); if (parent === dir) return undefined; dir = parent; } } /** * The loader tsconfig's `module` for a given config file: the module kind Node * would give the file itself. * * An explicit `.cts`/`.cjs` or `.mts`/`.mjs` extension already decides the emit * format on its own, so those keep the ES-module setting and let the extension * win — the same precedence tsgo applies. Everything ambiguous walks up for the * nearest `package.json` `type`, exactly as Node does when it loads the file. */ function configModuleOption(configPath: string): string { const extension = path.extname(configPath).toLowerCase(); if (extension !== ".ts" && extension !== ".tsx" && extension !== ".js") { return "ESNext"; } return nearestPackageType(configPath) === "commonjs" ? "CommonJS" : "ESNext"; } /** * Nearest `package.json` `"type"` at or above `configPath`, mirroring Node's * package-scope lookup: the walk stops at the **first** manifest it finds, and * a manifest that declares no `"type"` means CommonJS rather than a reason to * keep climbing. Reaching the filesystem root without any manifest also means * CommonJS. */ function nearestPackageType(configPath: string): "commonjs" | "module" { let dir = path.dirname(path.resolve(configPath)); for (;;) { // One read rather than a stat-then-read: an unreadable entry and a missing // one are the same answer here — keep walking — and the Go loaders resolve // it the same way, so the four implementations of this rule cannot drift. let raw: string | undefined; try { raw = fs.readFileSync(path.join(dir, "package.json"), "utf8"); } catch { raw = undefined; } if (raw !== undefined) { try { const manifest: unknown = JSON.parse(raw); const type = typeof manifest === "object" && manifest !== null ? (manifest as { type?: unknown }).type : undefined; return type === "module" ? "module" : "commonjs"; } catch { // A manifest that does not parse still bounds the package scope; Node // refuses to look past it, and CommonJS is the format it defaults to. return "commonjs"; } } const parent = path.dirname(dir); if (parent === dir) { return "commonjs"; } dir = parent; } } function linkNearestNodeModules(tempDir: string, sourceDir: string): void { const nodeModules = findNearestNodeModules(sourceDir); if (!nodeModules) return; const link = path.join(tempDir, "node_modules"); try { fs.symlinkSync(nodeModules, link, "junction"); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "EEXIST") { throw new Error( `@ttsc/lint: failed to link node_modules from ${nodeModules}: ${ error instanceof Error ? error.message : String(error) }`, ); } } } /** * Creates the typed config evaluator below a stable same-volume cache directory * when a descriptor load has already opened one. The identity directory exists * before descriptor imports are observed, so creating and removing an evaluator * below it leaves the project directory's missing-path witness unchanged. A * moved, aliased, unwritable, or differently mounted identity directory uses * the established loader placement instead. TypeScript-Go needs the generated * loader and the lexical config path on one volume (#305), so a TEMP junction * cannot override the volume of the canonical loader path. Without a suitable * cache, an existing user temp directory outside the config tree is preferred * to writing beside the config. A TEMP path in or above the declared project or * config directory yields to an existing identity store so observed ancestor * metadata remains stable. An independent config evaluation without descriptor * input capture may retain a strict external ancestor as its historical system * temp. No safe same-volume directory is an error. * * @evidence contracts/common.md#principled-implementation Native realpath and the returned canonical child must share the config's lexical volume; a rejected empty child is removed by its physical path. A system temp overlapping a physical project or config ancestor yields to an authoritative identity store, and descriptor-load children retain conservative ancestor handling. Descriptor identities need physical child and config-ancestor checks, and alternate user temp candidates must already exist outside the config tree. * @evidence contracts/common.md#clear-and-simple-design This function owns placement, while the existing canonical creator and nearest-cache selector retain their respective creation and discovery responsibilities. * @evidence contracts/common.md#prohibited-implementation-shortcuts Selection uses the descriptor's actual cache root and native filesystem identity, and every accepted fallback satisfies the compiler's lexical-volume requirement without returning a mutable junction spelling. * @evidence contracts/common.md#meaningful-documentation The comment explains the preexisting cache preference, TEMP junction and ancestor handling, and explicit failure when no usable same-volume directory exists. */ export function createConfigLoaderTempDirectory( configPath: string, systemTemp: string = os.tmpdir(), recordRoot: string | undefined = process.env.TTSC_PLUGIN_RECORD_ROOT, projectRoot?: string, ): string { const prefix = "ttsc-lint-cfg-"; const configVolume = path.parse(configPath).root; if (configVolume === "") return createCanonicalTempDirectory(prefix, systemTemp); const configDir = path.dirname(configPath); const physicalConfigDir = fs.realpathSync.native(configDir); let physicalProjectRoot: string | undefined = physicalConfigDir; if (projectRoot?.trim()) { try { physicalProjectRoot = fs.realpathSync.native(path.resolve(projectRoot)); } catch { physicalProjectRoot = undefined; } } const contains = (ancestor: string, other: string): boolean => { const relative = path.relative(ancestor, other); return ( relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) ); }; const unavailable = (error: unknown): boolean => ["EACCES", "EPERM", "ENOENT", "ENOTDIR", "EROFS"].includes( (error as NodeJS.ErrnoException).code ?? "", ); let physicalSystemTemp: string | undefined; try { physicalSystemTemp = fs.realpathSync.native(systemTemp); } catch { // An unavailable system temp can be replaced by an existing safe owner. } const createOnConfigVolume = (parent: string): string | undefined => { let directory: string; try { directory = createCanonicalTempDirectory(prefix, parent); } catch (error) { if (unavailable(error)) return undefined; throw error; } if ( path.parse(directory).root.toLowerCase() === configVolume.toLowerCase() ) { return directory; } fs.rmdirSync(directory); return undefined; }; const systemTempOnConfigVolume = physicalSystemTemp !== undefined && path.parse(physicalSystemTemp).root.toLowerCase() === configVolume.toLowerCase(); const systemTempInsideProjectOrConfig = physicalSystemTemp !== undefined && (physicalProjectRoot === undefined || contains(physicalProjectRoot, physicalSystemTemp) || contains(physicalConfigDir, physicalSystemTemp)); const systemTempExternalAncestor = physicalSystemTemp !== undefined && !systemTempInsideProjectOrConfig && ((physicalProjectRoot !== undefined && contains(physicalSystemTemp, physicalProjectRoot)) || contains(physicalSystemTemp, physicalConfigDir)); const independentEvaluation = process.env.TTSC_PLUGIN_DESCRIPTOR_LOAD !== "1"; if ( systemTempOnConfigVolume && !systemTempInsideProjectOrConfig && !systemTempExternalAncestor ) { const directory = createOnConfigVolume(systemTemp); if (directory !== undefined) return directory; } let preferred: string | undefined; if (recordRoot && path.isAbsolute(recordRoot)) { try { const physicalRoot = fs.realpathSync.native(recordRoot); const identityPath = path.join(recordRoot, "identities"); const identityEntry = fs.lstatSync(identityPath); const physicalIdentity = fs.realpathSync.native(identityPath); if ( identityEntry.isDirectory() && !identityEntry.isSymbolicLink() && path.dirname(physicalIdentity) === physicalRoot && path.parse(physicalIdentity).root.toLowerCase() === configVolume.toLowerCase() && !contains(physicalIdentity, physicalConfigDir) ) { preferred = physicalIdentity; } } catch { // The optional cache placement is unavailable; keep the established one. } } if (preferred !== undefined) { const directory = createOnConfigVolume(preferred); if (directory !== undefined) return directory; } if ( independentEvaluation && systemTempOnConfigVolume && systemTempExternalAncestor && !recordRoot ) { const directory = createOnConfigVolume(systemTemp); if (directory !== undefined) return directory; } const nearest = loaderTempBase(configPath); const ambient = [ process.env.TMPDIR, process.env.TMP, process.env.TEMP, process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, "Temp") : undefined, ]; const candidates = nearest === configDir ? ambient : [nearest, ...ambient]; const seen = new Set(); for (const candidate of candidates) { if (!candidate || !path.isAbsolute(candidate)) continue; let physical: string; try { physical = fs.realpathSync.native(candidate); if (!fs.lstatSync(physical).isDirectory()) continue; } catch { continue; } if (seen.has(physical)) continue; seen.add(physical); if (path.parse(physical).root.toLowerCase() !== configVolume.toLowerCase()) continue; if ( (nearest === configDir || candidate !== nearest) && (contains(physical, physicalConfigDir) || contains(physicalConfigDir, physical)) ) { continue; } const directory = createOnConfigVolume(physical); if (directory !== undefined) return directory; } if ( independentEvaluation && systemTempOnConfigVolume && systemTempExternalAncestor && recordRoot ) { const directory = createOnConfigVolume(systemTemp); if (directory !== undefined) return directory; } if ( path.parse(physicalConfigDir).root.toLowerCase() === configVolume.toLowerCase() ) { const directory = createOnConfigVolume(configDir); if (directory !== undefined) return directory; } throw new Error( `@ttsc/lint: no writable temporary directory on ${configVolume} for config ${configPath}; set TEMP, TMP, or TMPDIR to an existing directory on that drive or enable a same-drive ttsc cache`, ); } /** * Finds the nearest node_modules cache, falling back to the config directory. * The caller checks the canonical drive before using either path because a * config junction can place that directory on another drive (#305). */ function loaderTempBase(configPath: string): string { const configRoot = path.parse(configPath).root; const nodeModules = findNearestNodeModules(path.dirname(configPath)); if (!nodeModules) return path.dirname(configPath); const base = path.join(nodeModules, ".cache"); try { fs.mkdirSync(base, { recursive: true }); // Resolve symlinks/junctions now (a linked node_modules is common): // Node's ESM loader realpaths the loader module at import time, and a // relative config specifier computed from the link-form path would // resolve against the wrong directory. Realpathing may also land on // another volume, which defeats the whole point — fall through then. const real = fs.realpathSync(base); if (path.parse(real).root.toLowerCase() === configRoot.toLowerCase()) { return real; } } catch { // fall through to the config's own directory } return path.dirname(configPath); } function nodeConfigLoaderEnv(configPath: string): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env }; // This child reports the config's actual dependency graph through its own // result envelope. An inherited descriptor channel would also record this // evaluator's disposable bootstrap and its ambient package-scope probes. env.TTSC_PLUGIN_DESCRIPTOR_INPUTS_ACTIVE = "0"; const parts: string[] = []; const nodeModules = findNearestNodeModules(path.dirname(configPath)); if (nodeModules) parts.push(nodeModules); if (env.NODE_PATH) parts.push(env.NODE_PATH); if (parts.length > 0) { env.NODE_PATH = parts.join(path.delimiter); } return env; } /** * The native TypeScript compiler the config evaluator must run. * * The evaluator builds the loader in an ephemeral directory, so the child it * spawns cannot discover `typescript` the way an ordinary invocation does: the * directory is not in the project, and `linkNearestNodeModules` is the only * thing that puts the project's modules within reach of it. Leaving the child * to re-derive the compiler from there made this work by inheritance — * `TTSC_TSGO_BINARY` is exported by `ttsx` to its own descendants, so a build * launched under `ttsx` passed a binary down and a build launched any other way * did not. A published consumer exports no such variable, and neither does a * process that deliberately sheds the launcher's runtime state, and for those * the evaluator failed with `ttsc: typescript is required` before it read a * line of the config. * * The host has already resolved a compiler for the project it is linting, so * the answer is asked of the project rather than of the environment. The config * comes first and the descriptor's own location second, the same order and for * the same reason as {@link resolveTtsxLauncher}. An explicit `TTSC_TSGO_BINARY` * still wins, so an embedder that pins a compiler keeps pinning it. * * Returning `undefined` leaves the child to resolve for itself, which is what * it did before: a project that cannot answer here could not answer there * either, and the child's own diagnostic is the one that names the missing * package. */ function resolveConfigTsgo( configPath: string, context: TtscPluginFactoryContext, ): string | undefined { const explicit = process.env.TTSC_TSGO_BINARY?.trim(); if (explicit) return explicit; const anchors = [ configPath, path.join(context.projectRoot, "package.json"), context.dirname, ]; for (const anchor of anchors) { const binary = tsgoBinaryFrom(anchor); if (binary !== undefined) return binary; } return undefined; } /** * The platform compiler binary of the `typescript` install `anchor` can see, or * `undefined` when this anchor reaches neither the package nor its platform * dependency. Mirrors ttsc's own resolution order so both name one file. */ function tsgoBinaryFrom(anchor: string): string | undefined { try { const manifest = createRequire(anchor).resolve("typescript/package.json"); const platform = createRequire(manifest).resolve( `@typescript/typescript-${process.platform}-${process.arch}/package.json`, ); const binary = path.join( path.dirname(platform), "lib", process.platform === "win32" ? "tsc.exe" : "tsc", ); return fs.existsSync(binary) ? binary : undefined; } catch { // This anchor cannot see the compiler; the caller tries the next one. return undefined; } } /** * Resolves the isolated config evaluator launcher from the supplied anchors. * * An explicit override wins. Otherwise package-manifest resolution locates a * ttsc installation the anchors can see. The bare `ttsx` command is the final * fallback and requires a launcher available on PATH. */ function resolveTtsxLauncher(anchors: readonly string[]): string { const explicit = process.env.TTSC_TTSX_BINARY?.trim(); if (explicit) return explicit; for (const anchor of anchors) { const launcher = ttsxLauncherFrom(anchor); if (launcher !== undefined) return launcher; } return "ttsx"; } function ttsxLauncherFrom(anchor: string): string | undefined { try { // Only the manifest is exported, so the launcher is derived from where the // manifest resolved rather than requested as a subpath. Resolution is // anchored on the config being evaluated, which is how every other // resolution in this descriptor is anchored and picks the ttsc the project // actually installed. const manifest = createRequire(anchor).resolve("ttsc/package.json"); const launcher = path.join( path.dirname(manifest), "lib", "launcher", "ttsx.js", ); if (fs.existsSync(launcher)) return launcher; } catch { // This anchor cannot see ttsc; the caller tries the next one. } return undefined; } /** * Select the Node interpreter required by the isolated checked evaluator. * * A descriptor can run under Bun, while a JavaScript ttsx launcher requires * Node's synchronous module hooks. Probe candidates in the evaluator's actual * environment and directory; a host executable or a command name alone does not * prove that capability. Native launcher overrides retain their own entry. * Candidate observations are invocation-local, without stale runtime caching. */ function ttsxThroughNodeIfNeeded( binary: string, env: NodeJS.ProcessEnv, cwd: string, ): { binary: string; prefix: string[]; } { const ext = path.extname(binary).toLowerCase(); if (![".js", ".cjs", ".mjs", ".ts", ".cts", ".mts"].includes(ext)) { return { binary, prefix: [] }; } const explicit = process.platform === "win32" ? Object.entries(env).find( ([key]) => key.toUpperCase() === "TTSC_NODE_BINARY", )?.[1] : env.TTSC_NODE_BINARY; const seen = new Set(); const args = [ "-e", 'process.stdout.write(JSON.stringify({bun:typeof globalThis.Bun === "object",registerHooks:typeof require("node:module").registerHooks === "function",executable:process.execPath}))', ]; for (const candidate of [explicit, process.execPath, "node"]) { if (!candidate?.trim() || seen.has(candidate)) continue; seen.add(candidate); const observation = beginLintTrace(); const lower = observation ? new Date().toISOString() : undefined; observation?.record("process-attempt", { pid: 0, argv: [candidate, ...args], cwd, cwdInherited: false, startLowerBound: lower, owner: "lint-config-node-runtime-probe", }); const result = spawnSync(candidate, args, { cwd, env, encoding: "utf8", timeout: 30_000, maxBuffer: 64 * 1024, windowsHide: true, }); observation?.record("process-result", { pid: result.pid > 0 ? result.pid : 0, started: result.pid > 0, exitObserved: result.status !== null || result.signal !== null, status: result.status, signal: result.signal, error: result.error?.message, argv: [candidate, ...args], cwd, cwdInherited: false, startLowerBound: lower, startUpperBound: new Date().toISOString(), owner: "lint-config-node-runtime-probe", method: "spawnSync", }); if (result.status !== 0) continue; try { const actual = JSON.parse(result.stdout) as { bun?: unknown; registerHooks?: unknown; executable?: unknown; }; if ( actual.bun === false && actual.registerHooks === true && typeof actual.executable === "string" && path.isAbsolute(actual.executable) ) { return { binary: actual.executable, prefix: [binary] }; } } catch { // An incompatible candidate did not report the required Node capability. } } throw new Error( "@ttsc/lint: isolated config evaluation requires Node.js with module.registerHooks; no compatible interpreter was found", ); } /** * Remove an evaluation temp directory without letting cleanup replace a result. * * This runs from a `finally`, so a throw here would surface instead of the * evaluation's own outcome — and on Windows a grandchild that inherited a * handle, or a scanner holding the file, can make removal fail. Leaving bytes * in the system temp directory is by far the lesser outcome. */ function removeEvaluationTempDir(directory: string): void { try { fs.rmSync(directory, { force: true, recursive: true }); } catch { // Best effort. } }