@tsdoctor/vfs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # @tsdoctor/vfs
2
+
3
+ [![npm](https://img.shields.io/npm/v/@tsdoctor%2Fvfs?label=npm&color=cb3837)](https://www.npmjs.com/package/@tsdoctor/vfs)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+ [![TypeScript 6.0](https://img.shields.io/badge/TypeScript-6.0-3178c6.svg)](https://www.typescriptlang.org/)
7
+
8
+ The virtual TypeScript project behind a documentation build: the `Vfs` currency type, declaration-backed virtual packages, `@typescript/vfs` environments over them, and the compiler-option resolution that decides how code in those environments type-checks.
9
+
10
+ ## Why @tsdoctor/vfs
11
+
12
+ Tooling that type-checks code samples needs a TypeScript project that never touches disk — a map of paths to sources, a synthetic `package.json` so module resolution behaves like the real thing, and a language service built over both. Two independent concerns need exactly that substrate: fetching third-party declarations from a registry, and reconstructing declarations from an API model. This package is the substrate they share, so neither has to depend on the other.
13
+
14
+ It owns the compiler options too, and that pairing is deliberate. Options arrive in two spellings: the tsconfig one a user writes (`target: "es2022"`), and the programmatic one the compiler takes (`ts.ScriptTarget.ES2022`). A build that converts between them in more than one place will eventually disagree with itself. Here the conversion happens once, options a documentation tool cannot act on are rejected at the boundary instead of cast through it, and the environment that consumes them lives next door.
15
+
16
+ `@tsdoctor/registry` builds on it to resolve external package types; `@tsdoctor/model` builds on it to turn an API Extractor model into a virtual package.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install @tsdoctor/vfs effect @effected/tsconfig-json
22
+ ```
23
+
24
+ ```bash
25
+ pnpm add @tsdoctor/vfs effect @effected/tsconfig-json
26
+ ```
27
+
28
+ Requires Node.js >=24.11.0. This is an ESM-only package. `effect` and `@effected/tsconfig-json` are required peers — the compiler-option schemas are built from the kit's own field definitions, so they load with the entry point. `typescript` and `@typescript/vfs` are optional and needed only by `TsEnvironment.make`, which imports them lazily:
29
+
30
+ ```bash
31
+ # for TsEnvironment.make
32
+ npm install typescript @typescript/vfs
33
+ ```
34
+
35
+ A consumer that only builds and merges VFS maps never loads the compiler.
36
+
37
+ ## What you get
38
+
39
+ ### The VFS substrate
40
+
41
+ - **`Vfs`** — the currency type: a `Map<string, string>` of `node_modules/`-prefixed paths to file contents. Every loading operation produces one and every TypeScript integration consumes one.
42
+ - **`mergeVfs` / `prefixVfs`** — combine maps left to right (later wins on a path collision), and root one package's entries under `node_modules/<name>/`.
43
+ - **`isTypeDefinition`** — whether a path names a declaration file. The single spelling of that predicate.
44
+ - **`VirtualPackage`** — a named, versioned set of declaration entries that renders to a `Vfs` with a synthetic `package.json`. `create` for a single `index.d.ts`, `createMultiEntry` for one declaration file per entry point behind a synthetic `exports` map. Subclass-friendly, which is how an API-model-backed package is built on top of it.
45
+ - **`TsEnvironment.make`** — a `@typescript/vfs` `VirtualTypeScriptEnvironment` over a `Vfs` plus the TypeScript default libs. Takes options in the tsconfig spelling and converts internally, so a caller needs no compile-time dependency on `typescript`. A missing optional peer fails as a typed **`TsEnvironmentError`** rather than crashing at import.
46
+
47
+ ### Compiler-option resolution
48
+
49
+ - **`TypeResolutionCompilerOptions`** — the whitelist of options allowed to influence how an example type-checks, picked from `@effected/tsconfig-json`'s own field schemas rather than restated. Passing through options the tool does not understand would let an unrelated build setting silently change a documentation build.
50
+ - **`decodeCompilerOptions`** — decode untrusted input in either spelling and narrow it to the whitelist, returning a `Result` that fails on a value the compiler cannot act on instead of casting it through.
51
+ - **`toProgrammaticCompilerOptions`** — the one conversion from the tsconfig spelling to the compiler's numeric enums. Fingerprint the encoded value, not the decoded one, or two spellings of the same configuration build two identical environments under different keys.
52
+ - **`parseTsConfig`** — read a `tsconfig.json` into whitelisted options, with `extends` chain resolution (package specifiers included), JSONC parsing and relative paths owned by `@effected/tsconfig-json`. Failures raise **`TsConfigParseError`** carrying the config path.
53
+ - **`DEFAULT_COMPILER_OPTIONS`** — the documentation defaults: ESNext target and module, bundler resolution, non-strict, `skipLibCheck`, and a `lib` covering ESNext plus DOM. Held in the tsconfig spelling, the same one users write.
54
+ - **`mergeCompilerOptions`** — layer one option set over another, later winning per key. Arrays (`lib`, `types`) are replaced wholesale rather than concatenated, matching TypeScript's own `extends` semantics.
55
+ - **`resolveTypeScriptConfig`** — the full cascade: defaults, then a global config, then a per-API one, each level loading its `tsconfig` before merging its inline `compilerOptions` on top. `resolveTypeScriptConfigSingle` resolves one level synchronously (path-based `tsconfig` only); `resolveTypeScriptConfigSingleAsync` also accepts a `tsconfig` given as a loader function.
56
+ - **`TypeScriptConfig` / `CompilerOptionsInput`** — how a caller points at configuration, and the deliberately loose shape their inline options arrive in before decoding. Two types rather than one, so untrusted input and decoded output cannot be confused at a call site.
57
+
58
+ ## Quick start
59
+
60
+ Build a virtual package from declaration text, resolve the compiler options an example should be checked under, and type-check against both.
61
+
62
+ ```ts
63
+ import { Effect } from "effect";
64
+ import { TsEnvironment, VirtualPackage, mergeVfs, resolveTypeScriptConfig } from "@tsdoctor/vfs";
65
+
66
+ const vfs = mergeVfs(
67
+ VirtualPackage.create(
68
+ "my-types",
69
+ "1.0.0",
70
+ "export declare const answer: number;\nexport interface User { readonly id: string }\n",
71
+ ).toVfs(),
72
+ );
73
+ console.log([...vfs.keys()]);
74
+ // ["node_modules/my-types/package.json", "node_modules/my-types/index.d.ts"]
75
+
76
+ // Defaults, then the project's tsconfig, then an inline override on top.
77
+ const compilerOptions = await resolveTypeScriptConfig(process.cwd(), { tsconfig: "tsconfig.json" }, {
78
+ compilerOptions: { strict: true },
79
+ });
80
+
81
+ const program = Effect.gen(function* () {
82
+ const environment = yield* TsEnvironment.make({ vfs, compilerOptions, projectRoot: "/docs" });
83
+ environment.createFile("/docs/sample.ts", 'import { answer } from "my-types";\nexport const x: number = answer;\n');
84
+ return environment.languageService.getSemanticDiagnostics("/docs/sample.ts");
85
+ });
86
+
87
+ console.log((await Effect.runPromise(program)).length);
88
+ // diagnostic count — 0 when the sample type-checks against the virtual package
89
+ ```
90
+
91
+ `resolveTypeScriptConfig` returns the tsconfig spelling, which is what `TsEnvironment.make` takes; nothing in this example needs to import `typescript`.
92
+
93
+ ## Provenance
94
+
95
+ Extracted from `@tsdoctor/registry`, which had no internal consumer for the VFS half while `@tsdoctor/model` needed it. Hosting these types in the registry would have forced a dependency in one direction or the other — the registry onto an API model it does not read, or the model onto the registry's CDN and cache stack it does not use. The registry kept its own job, fetching and caching external package types into a `Vfs`, and shed the `typescript`, `@typescript/vfs` and `@effected/tsconfig-json` peers along with the environment builder. The compiler-option resolution came from the RSPress adapter, where it sat beside the environment it configures.
96
+
97
+ ## License
98
+
99
+ [MIT](LICENSE)
@@ -0,0 +1,95 @@
1
+ import { isTypeDefinition } from "./Vfs.js";
2
+ import { Effect, Schema } from "effect";
3
+
4
+ //#region src/TsEnvironment.ts
5
+ /**
6
+ * Raised when building a virtual TypeScript environment fails — including
7
+ * when the optional `typescript` / `@typescript/vfs` /
8
+ * `@effected/tsconfig-json` peers are not installed.
9
+ *
10
+ * @public
11
+ */
12
+ var TsEnvironmentError = class extends Schema.TaggedError()("TsEnvironmentError", {
13
+ /** The underlying failure, preserved structurally. */
14
+ cause: Schema.Defect() }) {
15
+ get message() {
16
+ return "Failed to create the virtual TypeScript environment";
17
+ }
18
+ };
19
+ /**
20
+ * The `@typescript/vfs` seam: builds a `VirtualTypeScriptEnvironment` over a
21
+ * {@link Vfs} plus the TypeScript default lib files.
22
+ *
23
+ * @remarks
24
+ * The ONLY module touching the optional `typescript` / `@typescript/vfs` /
25
+ * `@effected/tsconfig-json` peers, and it loads all three lazily inside
26
+ * {@link TsEnvironment.make} — a consumer that never calls it never loads
27
+ * the compiler, and a missing peer fails typed as
28
+ * {@link TsEnvironmentError} instead of crashing at import time. Keep every
29
+ * one of them behind that dynamic `import()`: a static value import here is
30
+ * reachable from `index.ts`, so it would turn an omitted optional peer into
31
+ * an `ERR_MODULE_NOT_FOUND` on the entry graph for consumers who never
32
+ * touch this module. Only the type-only `CompilerOptions` import is safe
33
+ * statically, because it erases. The underlying `createDefaultMapFromNodeModules` /
34
+ * `createFSBackedSystem` read the real filesystem through TypeScript's own
35
+ * `sys`, outside the Effect `FileSystem` service — accepted and documented;
36
+ * this module is why the package is integrated tier on its own surface.
37
+ *
38
+ * No cache map (v3's `createTypeScriptCache` returned a one-entry `Map`
39
+ * keyed by `JSON.stringify(compilerOptions)`): a consumer that wants keyed
40
+ * reuse holds its own map.
41
+ *
42
+ * `VirtualTypeScriptEnvironment` is deliberately not re-exported — import
43
+ * the type from `@typescript/vfs`, which consumers of this module already
44
+ * declare.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * import { TsEnvironment } from "@tsdoctor/vfs";
49
+ *
50
+ * const environment = TsEnvironment.make({
51
+ * vfs,
52
+ * compilerOptions: { strict: true, target: "es2022" },
53
+ * });
54
+ * ```
55
+ *
56
+ * @public
57
+ */
58
+ var TsEnvironment = class {
59
+ constructor() {}
60
+ /** Build a `VirtualTypeScriptEnvironment` over a {@link Vfs}. */
61
+ static make(options) {
62
+ return Effect.gen(function* () {
63
+ const [tsModule, tsVfs, { TsEnumCodec }] = yield* Effect.tryPromise({
64
+ try: () => Promise.all([
65
+ import("typescript"),
66
+ import("@typescript/vfs"),
67
+ import("@effected/tsconfig-json")
68
+ ]),
69
+ catch: (cause) => new TsEnvironmentError({ cause })
70
+ });
71
+ return yield* Effect.try({
72
+ try: () => {
73
+ const typescript = tsModule.default;
74
+ const projectRoot = options.projectRoot ?? process.cwd();
75
+ const compilerOptions = TsEnumCodec.encodeCompilerOptions(options.compilerOptions);
76
+ const executing = typescript.sys?.getExecutingFilePath?.();
77
+ const libDirectory = executing === void 0 ? void 0 : executing.slice(0, Math.max(executing.lastIndexOf("/"), executing.lastIndexOf("\\")));
78
+ const system = new Map(tsVfs.createDefaultMapFromNodeModules(compilerOptions, typescript, libDirectory));
79
+ const rootFiles = [];
80
+ for (const [path, content] of options.vfs) {
81
+ const rooted = path.startsWith("/") ? path : `${projectRoot}/${path}`;
82
+ system.set(rooted, content);
83
+ if (isTypeDefinition(rooted)) rootFiles.push(rooted);
84
+ }
85
+ const sys = tsVfs.createFSBackedSystem(system, projectRoot, typescript, libDirectory);
86
+ return tsVfs.createVirtualTypeScriptEnvironment(sys, rootFiles, typescript, compilerOptions);
87
+ },
88
+ catch: (cause) => new TsEnvironmentError({ cause })
89
+ });
90
+ }).pipe(Effect.withSpan("TsEnvironment.make"));
91
+ }
92
+ };
93
+
94
+ //#endregion
95
+ export { TsEnvironment, TsEnvironmentError };
@@ -0,0 +1,98 @@
1
+ import { decodeCompilerOptions } from "./TypeResolutionOptions.js";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { TsconfigLoaderSync } from "@effected/tsconfig-json";
5
+ import { Result } from "effect";
6
+
7
+ //#region src/TsconfigParser.ts
8
+ /**
9
+ * Reading a `tsconfig.json` into the compiler options the plugin consumes.
10
+ *
11
+ * @remarks
12
+ * A thin adapter over `@effected/tsconfig-json`'s `TsconfigLoaderSync`, which
13
+ * owns `extends` chain resolution (including package specifiers), JSONC
14
+ * parsing and relative-path handling. This module used to hand-roll all three
15
+ * over TypeScript's `parseJsonConfigFileContent`.
16
+ *
17
+ * **The loader returns the tsconfig SPELLING, not the programmatic one.**
18
+ * `target` is `"es2025"` rather than `ts.ScriptTarget.ES2025`, and `lib` is
19
+ * `["esnext"]` rather than `["lib.esnext.d.ts"]`. That is fine, and it is why
20
+ * the normalization seam had to land first: `toProgrammaticCompilerOptions`
21
+ * (`twoslash-transformer.ts`) converts at ONE place, and
22
+ * {@link TypeResolutionCompilerOptions} accepts both spellings by design. Do
23
+ * not convert here — a second conversion site is exactly the drift that made
24
+ * three of four resolution paths load zero lib files once already.
25
+ *
26
+ * @packageDocumentation
27
+ */
28
+ /**
29
+ * Error thrown when tsconfig.json parsing fails.
30
+ *
31
+ * @remarks
32
+ * Retained as the plugin's own type rather than surfacing the kit's
33
+ * `TsconfigParseError`/`TsconfigExtendsError` directly: the adapter's
34
+ * `typescript-config.ts` branches on `instanceof TsConfigParseError` to decide
35
+ * whether a failure is already reported, and both kit errors mean the same
36
+ * thing to that caller. It now also carries a decode failure from
37
+ * {@link decodeCompilerOptions}, which is the same thing again: a tsconfig
38
+ * this tool cannot act on.
39
+ *
40
+ * @public
41
+ */
42
+ var TsConfigParseError = class extends Error {
43
+ configPath;
44
+ cause;
45
+ constructor(configPath, message, cause) {
46
+ super(`Failed to parse tsconfig at ${configPath}: ${message}`);
47
+ this.configPath = configPath;
48
+ this.cause = cause;
49
+ this.name = "TsConfigParseError";
50
+ }
51
+ };
52
+ /**
53
+ * The sync host the kit loader reads through.
54
+ *
55
+ * @remarks
56
+ * `node:path` satisfies `SyncPath` verbatim. The filesystem half is two
57
+ * functions, so no shim module is needed.
58
+ */
59
+ const syncHost = {
60
+ fileSystem: {
61
+ exists: existsSync,
62
+ readFile: (filePath) => readFileSync(filePath, "utf8")
63
+ },
64
+ path
65
+ };
66
+ /**
67
+ * Parse a `tsconfig.json` and extract the compiler options used for type
68
+ * resolution.
69
+ *
70
+ * @param configPath - Path to tsconfig.json (relative or absolute)
71
+ * @param projectRoot - Project root directory for resolving relative paths
72
+ * @returns The declared compiler options, in the tsconfig spelling
73
+ * @throws TsConfigParseError if the config cannot be read or parsed
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * const options = parseTsConfig("tsconfig.json", "/path/to/project");
78
+ * // Returns: { target: "es2025", module: "nodenext", lib: ["esnext"], ... }
79
+ * ```
80
+ *
81
+ * @public
82
+ */
83
+ function parseTsConfig(configPath, projectRoot) {
84
+ const absolutePath = path.isAbsolute(configPath) ? configPath : path.resolve(projectRoot, configPath);
85
+ if (!existsSync(absolutePath)) throw new TsConfigParseError(absolutePath, "File not found");
86
+ let options;
87
+ try {
88
+ options = TsconfigLoaderSync.compilerOptions(absolutePath, syncHost);
89
+ } catch (error) {
90
+ throw new TsConfigParseError(absolutePath, error instanceof Error ? error.message : String(error), error);
91
+ }
92
+ const decoded = decodeCompilerOptions(options);
93
+ if (Result.isFailure(decoded)) throw new TsConfigParseError(absolutePath, decoded.failure.message, decoded.failure);
94
+ return decoded.success;
95
+ }
96
+
97
+ //#endregion
98
+ export { TsConfigParseError, parseTsConfig };
@@ -0,0 +1,98 @@
1
+ import { CompilerOptions, CompilerOptionsFromProgrammatic, TsEnumCodec } from "@effected/tsconfig-json";
2
+ import { Result, Schema } from "effect";
3
+
4
+ //#region src/TypeResolutionOptions.ts
5
+ const fields = CompilerOptions.schema.fields;
6
+ /**
7
+ * The compiler options that may influence how a documentation example
8
+ * type-checks.
9
+ *
10
+ * @remarks
11
+ * Deliberately a **whitelist**, picked from `@effected/tsconfig-json`'s
12
+ * `CompilerOptions` rather than restated. Everything here reaches the
13
+ * TypeScript environment, and passing through options the tool does not
14
+ * understand would let a consumer's unrelated build setting silently change
15
+ * how their examples type-check.
16
+ *
17
+ * Picking from the kit's own field schemas is what keeps this a policy
18
+ * statement rather than a second definition of tsconfig: the accepted values,
19
+ * their spellings and their case-insensitivity are the kit's to own, and this
20
+ * module owns only the choice of WHICH options are in scope.
21
+ *
22
+ * @public
23
+ */
24
+ const TypeResolutionCompilerOptions = Schema.Struct({
25
+ target: fields.target,
26
+ module: fields.module,
27
+ moduleResolution: fields.moduleResolution,
28
+ jsx: fields.jsx,
29
+ lib: fields.lib,
30
+ types: fields.types,
31
+ typeRoots: fields.typeRoots,
32
+ strict: fields.strict,
33
+ skipLibCheck: fields.skipLibCheck,
34
+ esModuleInterop: fields.esModuleInterop,
35
+ allowSyntheticDefaultImports: fields.allowSyntheticDefaultImports
36
+ });
37
+ /** The whitelisted keys, for narrowing a decoded `CompilerOptions`. */
38
+ const KEYS = Object.keys(TypeResolutionCompilerOptions.fields);
39
+ /**
40
+ * Narrow a decoded `CompilerOptions` to the whitelist.
41
+ *
42
+ * @remarks
43
+ * Empty `lib` / `types` arrays are **dropped rather than passed through**:
44
+ * `lib: []` REPLACES the default library set with nothing (arrays are replaced
45
+ * wholesale, not merged), which would type-check every example against no
46
+ * globals at all. An absent key inherits the default; an empty one does not.
47
+ */
48
+ const narrow = (decoded) => {
49
+ const out = {};
50
+ for (const key of KEYS) {
51
+ const value = decoded[key];
52
+ if (value === void 0) continue;
53
+ if (Array.isArray(value) && value.length === 0) continue;
54
+ out[key] = value;
55
+ }
56
+ return out;
57
+ };
58
+ /**
59
+ * Decode compiler options written in either spelling into the whitelist.
60
+ *
61
+ * @remarks
62
+ * Accepts the tsconfig spelling a user writes (`target: "es2025"`, and
63
+ * case-insensitively `lib: ["ESNext", "DOM"]`) and the programmatic spelling a
64
+ * caller holding `ts.CompilerOptions` has (`target: ts.ScriptTarget.ES2025`),
65
+ * because a consumer configuring a documentation build in TypeScript
66
+ * reasonably produces either.
67
+ *
68
+ * **Fails rather than guesses.** A value with no entry in the enum tables — a
69
+ * numeric target from a future TypeScript, a misspelled module kind — is
70
+ * rejected on the error channel instead of being passed through. Degrading to
71
+ * a default here would type-check every example against a configuration the
72
+ * user did not ask for, and produce confidently wrong output with no error:
73
+ * the failure mode this seam exists to prevent.
74
+ *
75
+ * @public
76
+ */
77
+ const decodeCompilerOptions = (input) => Result.map(Schema.decodeUnknownResult(CompilerOptionsFromProgrammatic)(input), (decoded) => narrow(decoded));
78
+ /**
79
+ * Convert whitelisted options to the numeric-enum form the TypeScript compiler
80
+ * takes.
81
+ *
82
+ * @remarks
83
+ * The ONE conversion site between the tsconfig spelling and the programmatic
84
+ * one. Two consequences follow from it being single:
85
+ *
86
+ * - Any environment fingerprint MUST be computed on the ENCODED value.
87
+ * Otherwise `{lib:["ESNext"]}` and `{lib:["lib.esnext.d.ts"]}` build two
88
+ * identical TypeScript environments under different keys.
89
+ * - There is no cast here. The whitelist is a subset of the kit's own
90
+ * `CompilerOptions`, so it is assignable to the encoder by construction —
91
+ * which is precisely what a hand-rolled options type could not be.
92
+ *
93
+ * @public
94
+ */
95
+ const toProgrammaticCompilerOptions = (options) => TsEnumCodec.encodeCompilerOptions(options);
96
+
97
+ //#endregion
98
+ export { TypeResolutionCompilerOptions, decodeCompilerOptions, toProgrammaticCompilerOptions };
@@ -0,0 +1,226 @@
1
+ import { decodeCompilerOptions } from "./TypeResolutionOptions.js";
2
+ import { TsConfigParseError, parseTsConfig } from "./TsconfigParser.js";
3
+ import { Result } from "effect";
4
+
5
+ //#region src/TypeScriptConfig.ts
6
+ /**
7
+ * Default TypeScript compiler options for Twoslash and type resolution.
8
+ *
9
+ * These defaults are optimized for documentation:
10
+ * - Modern ES targets (ESNext)
11
+ * - Bundler module resolution for broad compatibility
12
+ * - Lenient settings (non-strict) since docs often show simplified examples
13
+ * - Skip lib checks for faster processing
14
+ *
15
+ * @remarks
16
+ * Numeric values correspond to TypeScript enums:
17
+ * - target: 99 = ESNext
18
+ * - module: 99 = ESNext
19
+ * - moduleResolution: 100 = Bundler
20
+ *
21
+ * @public
22
+ */
23
+ const DEFAULT_COMPILER_OPTIONS = {
24
+ target: "esnext",
25
+ module: "esnext",
26
+ moduleResolution: "bundler",
27
+ lib: ["esnext", "dom"],
28
+ strict: false,
29
+ skipLibCheck: true,
30
+ esModuleInterop: true,
31
+ allowSyntheticDefaultImports: true
32
+ };
33
+ /**
34
+ * Merge two TypeResolutionCompilerOptions objects.
35
+ * Properties from `override` take precedence over `base`.
36
+ *
37
+ * @param base - Base compiler options
38
+ * @param override - Options to merge on top (takes precedence)
39
+ * @returns Merged options
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * const base = { target: 99, lib: ["ESNext"] };
44
+ * const override = { lib: ["ESNext", "DOM"], strict: true };
45
+ * const merged = mergeCompilerOptions(base, override);
46
+ * // Result: { target: 99, lib: ["ESNext", "DOM"], strict: true }
47
+ * ```
48
+ */
49
+ /**
50
+ * Decode user-supplied compiler options, failing loudly.
51
+ *
52
+ * @remarks
53
+ * The values reaching here are whatever a consumer wrote in their config, in
54
+ * either spelling. A value the enum tables cannot map is REJECTED rather than
55
+ * passed through: degrading to a default would type-check every example
56
+ * against a configuration the user did not ask for, and say nothing about it.
57
+ * `layers/type-environment.ts` turns this throw into a `ConfigValidationError`,
58
+ * which reaches the `issues.json` artifact.
59
+ */
60
+ function decodeInput(input, source) {
61
+ const decoded = decodeCompilerOptions(input);
62
+ if (Result.isFailure(decoded)) throw new TsConfigParseError(source, decoded.failure.message, decoded.failure);
63
+ return decoded.success;
64
+ }
65
+ /**
66
+ * Merge one set of compiler options over another, later winning per key.
67
+ *
68
+ * @remarks
69
+ * Arrays (`lib`, `types`) are REPLACED wholesale rather than concatenated,
70
+ * matching TypeScript's own `extends` semantics: declaring `lib` means "these
71
+ * libraries", not "these as well as the defaults".
72
+ *
73
+ * @param base - the options to start from
74
+ * @param override - the options to layer on top, or `undefined` for a copy of `base`
75
+ * @returns a new object; neither argument is mutated
76
+ *
77
+ * @public
78
+ */
79
+ function mergeCompilerOptions(base, override) {
80
+ if (!override) return { ...base };
81
+ const merged = { ...base };
82
+ for (const [key, value] of Object.entries(override)) if (value !== void 0) merged[key] = value;
83
+ return merged;
84
+ }
85
+ /**
86
+ * Resolve a single TypeScriptConfig to compiler options (sync version).
87
+ * Only handles path-based tsconfig - use resolveTypeScriptConfigSingleAsync for function-based.
88
+ *
89
+ * Follows the priority cascade:
90
+ * 1. Parse tsconfig.json if specified (path only, not function)
91
+ * 2. Merge compilerOptions on top
92
+ *
93
+ * @param config - TypeScript config with optional tsconfig path and/or compilerOptions
94
+ * @param projectRoot - Project root for resolving relative tsconfig paths
95
+ * @returns Resolved compiler options (not merged with defaults)
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * // Just tsconfig
100
+ * resolveTypeScriptConfigSingle({ tsconfig: "tsconfig.json" }, "/project");
101
+ *
102
+ * // Just compilerOptions
103
+ * resolveTypeScriptConfigSingle({ compilerOptions: { target: "esnext" } }, "/project");
104
+ *
105
+ * // Both (compilerOptions override tsconfig)
106
+ * resolveTypeScriptConfigSingle({
107
+ * tsconfig: "tsconfig.json",
108
+ * compilerOptions: { strict: false }
109
+ * }, "/project");
110
+ * ```
111
+ *
112
+ * @public
113
+ */
114
+ function resolveTypeScriptConfigSingle(config, projectRoot) {
115
+ if (!config) return {};
116
+ let options = {};
117
+ if (config.tsconfig && typeof config.tsconfig !== "function") {
118
+ const tsconfigPath = String(config.tsconfig);
119
+ try {
120
+ options = parseTsConfig(tsconfigPath, projectRoot);
121
+ } catch (error) {
122
+ if (error instanceof TsConfigParseError) throw error;
123
+ throw new TsConfigParseError(tsconfigPath, error instanceof Error ? error.message : String(error), error);
124
+ }
125
+ }
126
+ if (config.compilerOptions) options = mergeCompilerOptions(options, decodeInput(config.compilerOptions, "compilerOptions"));
127
+ return options;
128
+ }
129
+ /**
130
+ * Resolve a single TypeScriptConfig to compiler options (async version).
131
+ * Handles both path-based and function-based tsconfig.
132
+ *
133
+ * Follows the priority cascade:
134
+ * 1. Load tsconfig (from path or function)
135
+ * 2. Merge compilerOptions on top
136
+ *
137
+ * @param config - TypeScript config with optional tsconfig path/function and/or compilerOptions
138
+ * @param projectRoot - Project root for resolving relative tsconfig paths
139
+ * @returns Promise resolving to compiler options (not merged with defaults)
140
+ *
141
+ * @example
142
+ * ```ts
143
+ * // Path-based tsconfig
144
+ * await resolveTypeScriptConfigSingleAsync({ tsconfig: "tsconfig.json" }, "/project");
145
+ *
146
+ * // Function-based tsconfig
147
+ * await resolveTypeScriptConfigSingleAsync({
148
+ * tsconfig: async () => ({ target: 99, lib: ["ESNext"] })
149
+ * }, "/project");
150
+ *
151
+ * // Both (compilerOptions override tsconfig)
152
+ * await resolveTypeScriptConfigSingleAsync({
153
+ * tsconfig: async () => ({ target: 99 }),
154
+ * compilerOptions: { strict: false }
155
+ * }, "/project");
156
+ * ```
157
+ *
158
+ * @public
159
+ */
160
+ async function resolveTypeScriptConfigSingleAsync(config, projectRoot) {
161
+ if (!config) return {};
162
+ let options = {};
163
+ if (config.tsconfig) {
164
+ if (typeof config.tsconfig === "function") options = decodeInput(await config.tsconfig(), "tsconfig()");
165
+ else {
166
+ const tsconfigPath = String(config.tsconfig);
167
+ try {
168
+ options = parseTsConfig(tsconfigPath, projectRoot);
169
+ } catch (error) {
170
+ if (error instanceof TsConfigParseError) throw error;
171
+ throw new TsConfigParseError(tsconfigPath, error instanceof Error ? error.message : String(error), error);
172
+ }
173
+ }
174
+ }
175
+ if (config.compilerOptions) options = mergeCompilerOptions(options, decodeInput(config.compilerOptions, "compilerOptions"));
176
+ return options;
177
+ }
178
+ /**
179
+ * Resolve TypeScript compiler options from a cascade of configurations (async).
180
+ *
181
+ * Resolution order (later levels override earlier):
182
+ * 1. DEFAULT_COMPILER_OPTIONS (sensible defaults)
183
+ * 2. Global config
184
+ * 3. API-level config
185
+ *
186
+ * At each level, if a TypeScriptConfig has both `tsconfig` and `compilerOptions`,
187
+ * the tsconfig is loaded first, then compilerOptions are merged on top.
188
+ *
189
+ * @param projectRoot - Project root directory for resolving relative paths
190
+ * @param global - Global plugin TypeScript configuration
191
+ * @param api - API-level TypeScript configuration
192
+ * @returns Promise resolving to fully resolved compiler options
193
+ *
194
+ * @example
195
+ * ```ts
196
+ * // Simple global config
197
+ * const options = await resolveTypeScriptConfig("/project", {
198
+ * tsconfig: "tsconfig.json"
199
+ * });
200
+ *
201
+ * // With async tsconfig loader
202
+ * const options = await resolveTypeScriptConfig("/project", {
203
+ * tsconfig: async () => ({ target: 99, lib: ["ESNext"] })
204
+ * });
205
+ *
206
+ * // With API override
207
+ * const options = await resolveTypeScriptConfig(
208
+ * "/project",
209
+ * { tsconfig: "tsconfig.json" },
210
+ * { compilerOptions: { strict: false } }
211
+ * );
212
+ * ```
213
+ *
214
+ * @public
215
+ */
216
+ async function resolveTypeScriptConfig(projectRoot, global, api) {
217
+ let options = { ...DEFAULT_COMPILER_OPTIONS };
218
+ const globalOptions = await resolveTypeScriptConfigSingleAsync(global, projectRoot);
219
+ options = mergeCompilerOptions(options, globalOptions);
220
+ const apiOptions = await resolveTypeScriptConfigSingleAsync(api, projectRoot);
221
+ options = mergeCompilerOptions(options, apiOptions);
222
+ return options;
223
+ }
224
+
225
+ //#endregion
226
+ export { DEFAULT_COMPILER_OPTIONS, mergeCompilerOptions, resolveTypeScriptConfig, resolveTypeScriptConfigSingle, resolveTypeScriptConfigSingleAsync };