@tsdoctor/registry 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.
@@ -0,0 +1,183 @@
1
+ import { compileWildcard, extractTypesFromExport, findMainTypePath, getExportValue, isSafeRelativePath, isTypeDefinition, normalizePath } from "./internal/resolution.js";
2
+ import { PackageSpec } from "./PackageSpec.js";
3
+ import { Option, Schema } from "effect";
4
+
5
+ //#region src/TypeResolver.ts
6
+ /**
7
+ * A resolved module: the declaration file a specifier resolves to within a
8
+ * package.
9
+ *
10
+ * @public
11
+ */
12
+ var ResolvedModule = class extends Schema.Class("ResolvedModule")({
13
+ /** The file path relative to the package root (no `./` prefix). */
14
+ filePath: Schema.String,
15
+ /** Whether the path names a TypeScript declaration file. */
16
+ isTypeDefinition: Schema.Boolean,
17
+ /** The package the path belongs to. */
18
+ package: PackageSpec
19
+ }) {};
20
+ const DUNDER_KEYS = /* @__PURE__ */ new Set([
21
+ "__proto__",
22
+ "constructor",
23
+ "prototype"
24
+ ]);
25
+ /**
26
+ * Build a `ResolvedModule` only when the manifest-supplied path stays inside
27
+ * the package: resolved paths reach `PackageFetcher.downloadFile`'s URL and
28
+ * the cache's join, so an absolute or `..`-bearing path from a hostile
29
+ * manifest must not survive resolution.
30
+ */
31
+ const safeResolved = (filePath, pkg) => {
32
+ const normalized = normalizePath(filePath.replace(/^\.\//, ""));
33
+ if (!isSafeRelativePath(normalized)) return Option.none();
34
+ return Option.some(ResolvedModule.make({
35
+ filePath: normalized,
36
+ isTypeDefinition: isTypeDefinition(normalized),
37
+ package: pkg
38
+ }));
39
+ };
40
+ /**
41
+ * Pure `package.json` → declaration-file resolution.
42
+ *
43
+ * @remarks
44
+ * Stateless pure functions — no service, no layer (v3's `Layer.succeed` over
45
+ * stateless functions was ceremony), and no fictional error channel: v3
46
+ * declared a `ResolutionError` its total implementation could never raise.
47
+ * Here {@link TypeResolver.resolveImport} is honest in the other direction
48
+ * too — it returns `Option.none()` where v3 fabricated a guessed fallback
49
+ * path, leaving fallback policy to the caller.
50
+ *
51
+ * All map inputs are untrusted CDN data; the machinery underneath is depth-
52
+ * guarded, wildcard-bounded and prototype-pollution-safe (see
53
+ * `internal/resolution.ts`), and every resolved path is validated to stay
54
+ * inside the package before a `ResolvedModule` is constructed.
55
+ *
56
+ * @public
57
+ */
58
+ var TypeResolver = class TypeResolver {
59
+ constructor() {}
60
+ /**
61
+ * Resolve an import specifier (`"zod"`, `"zod/lib/types"`) against a
62
+ * manifest.
63
+ *
64
+ * @remarks
65
+ * Resolution order: the `exports` map (`types` condition, then
66
+ * `import`/`default`, fallback arrays in order), then `typesVersions["*"]`
67
+ * (exact, then bounded wildcards), then — for the root specifier only —
68
+ * the top-level `types`/`typings` fields. `Option.none()` when the
69
+ * manifest offers no evidence for the subpath, or when the evidence names
70
+ * a path outside the package (hostile manifest — fails closed).
71
+ */
72
+ static resolveImport(specifier, manifest, pkg) {
73
+ let subpath;
74
+ if (specifier === pkg.name) subpath = ".";
75
+ else if (specifier.startsWith(`${pkg.name}/`)) subpath = `./${specifier.slice(pkg.name.length + 1)}`;
76
+ else {
77
+ const bare = specifier.replace(/^\/+/, "");
78
+ subpath = bare === "" || bare === "." ? "." : bare.startsWith("./") ? bare : `./${bare}`;
79
+ }
80
+ if (manifest.exports !== void 0) {
81
+ const exportValue = getExportValue(manifest.exports, subpath);
82
+ const typesPath = extractTypesFromExport(exportValue);
83
+ if (typesPath !== null) return safeResolved(typesPath, pkg);
84
+ }
85
+ if (manifest.typesVersions !== void 0 && Object.hasOwn(manifest.typesVersions, "*")) {
86
+ const versionMap = manifest.typesVersions["*"];
87
+ if (versionMap !== void 0) {
88
+ const lookupPath = subpath === "." ? "." : subpath.replace(/^\.\//, "");
89
+ if (!DUNDER_KEYS.has(lookupPath) && Object.hasOwn(versionMap, lookupPath)) {
90
+ const mapped = versionMap[lookupPath];
91
+ const first = Array.isArray(mapped) ? mapped[0] : mapped;
92
+ if (typeof first === "string") return safeResolved(first, pkg);
93
+ }
94
+ for (const pattern of Object.keys(versionMap)) {
95
+ if (DUNDER_KEYS.has(pattern) || !pattern.includes("*")) continue;
96
+ if (!Object.hasOwn(versionMap, pattern)) continue;
97
+ const regex = compileWildcard(pattern);
98
+ if (regex === null) continue;
99
+ const match = regex.exec(lookupPath);
100
+ if (match === null) continue;
101
+ const mapped = versionMap[pattern];
102
+ const first = Array.isArray(mapped) ? mapped[0] : mapped;
103
+ if (typeof first === "string") return safeResolved(first.replace(/\*/g, match[1] ?? ""), pkg);
104
+ }
105
+ }
106
+ }
107
+ if (subpath === ".") {
108
+ if (manifest.types !== void 0) return safeResolved(manifest.types, pkg);
109
+ if (manifest.typings !== void 0) return safeResolved(manifest.typings, pkg);
110
+ }
111
+ return Option.none();
112
+ }
113
+ /**
114
+ * Resolve the manifest's main type entry.
115
+ *
116
+ * @remarks
117
+ * Total by the documented `index.d.ts` convention floor: `types`/`typings`,
118
+ * then the root export's types condition, then a declaration-extension
119
+ * swap of `main`, then `index.d.ts`. A main path that escapes the package
120
+ * (hostile manifest) also falls to the floor rather than surviving.
121
+ */
122
+ static resolveMainEntry(manifest, pkg) {
123
+ return Option.getOrElse(safeResolved(findMainTypePath(manifest), pkg), () => ResolvedModule.make({
124
+ filePath: "index.d.ts",
125
+ isTypeDefinition: true,
126
+ package: pkg
127
+ }));
128
+ }
129
+ /**
130
+ * Enumerate every entry point that exposes type definitions: the main
131
+ * entry plus each `exports` subpath with a types-bearing condition,
132
+ * deduplicated by file path.
133
+ *
134
+ * @remarks
135
+ * Wildcard export keys (`"./*"`) are skipped: enumeration has no captured
136
+ * segment to substitute, so a pattern entry would emit a literal
137
+ * `dist/*.d.ts`. Pattern subpaths resolve through
138
+ * {@link TypeResolver.resolveImport}, which has the concrete specifier.
139
+ * Entries whose paths escape the package are skipped.
140
+ */
141
+ static resolveTypeEntries(manifest, pkg) {
142
+ const entries = [TypeResolver.resolveMainEntry(manifest, pkg)];
143
+ if (manifest.exports !== void 0 && typeof manifest.exports === "object" && !Array.isArray(manifest.exports)) for (const key of Object.keys(manifest.exports)) {
144
+ if (DUNDER_KEYS.has(key)) continue;
145
+ if (!key.startsWith(".")) continue;
146
+ if (key.includes("*")) continue;
147
+ if (!Object.hasOwn(manifest.exports, key)) continue;
148
+ const typesPath = extractTypesFromExport(manifest.exports[key]);
149
+ if (typesPath !== null) {
150
+ const entry = safeResolved(typesPath, pkg);
151
+ if (Option.isSome(entry)) entries.push(entry.value);
152
+ }
153
+ }
154
+ const seen = /* @__PURE__ */ new Set();
155
+ return entries.filter((entry) => {
156
+ if (!entry.isTypeDefinition) return false;
157
+ if (seen.has(entry.filePath)) return false;
158
+ seen.add(entry.filePath);
159
+ return true;
160
+ });
161
+ }
162
+ /**
163
+ * The conventional declaration-file path for a JavaScript file path
164
+ * (`lib/index.js` → `lib/index.d.ts`, `.mjs` → `.d.mts`, `.cjs` →
165
+ * `.d.cts`).
166
+ *
167
+ * @remarks
168
+ * The input is a tree path from the CDN — untrusted — so a path that is
169
+ * absolute or escapes the package yields `Option.none()` instead of a
170
+ * `ResolvedModule` that could reach a download URL.
171
+ */
172
+ static findTypeDefinition(jsFilePath, pkg) {
173
+ let typePath;
174
+ if (jsFilePath.endsWith(".mjs")) typePath = jsFilePath.replace(/\.mjs$/, ".d.mts");
175
+ else if (jsFilePath.endsWith(".cjs")) typePath = jsFilePath.replace(/\.cjs$/, ".d.cts");
176
+ else if (jsFilePath.endsWith(".js")) typePath = jsFilePath.replace(/\.js$/, ".d.ts");
177
+ else typePath = `${jsFilePath.replace(/\.(m?js|cjs)$/, "")}.d.ts`;
178
+ return safeResolved(typePath, pkg);
179
+ }
180
+ };
181
+
182
+ //#endregion
183
+ export { ResolvedModule, TypeResolver };
package/Vfs.js ADDED
@@ -0,0 +1,33 @@
1
+ //#region src/Vfs.ts
2
+ /**
3
+ * Merge VFS maps left to right into a new map; later entries win on path
4
+ * collisions.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { mergeVfs } from "@tsdoctor/registry";
9
+ *
10
+ * const combined = mergeVfs(vfsA, vfsB);
11
+ * ```
12
+ *
13
+ * @public
14
+ */
15
+ const mergeVfs = (...maps) => {
16
+ const out = /* @__PURE__ */ new Map();
17
+ for (const map of maps) for (const [path, content] of map) out.set(path, content);
18
+ return out;
19
+ };
20
+ /**
21
+ * Prefix every path in `entries` with `node_modules/<name>/`, normalizing
22
+ * away leading slashes.
23
+ *
24
+ * @public
25
+ */
26
+ const prefixVfs = (name, entries) => {
27
+ const out = /* @__PURE__ */ new Map();
28
+ for (const [path, content] of entries) out.set(`node_modules/${name}/${path.replace(/^\/+/, "")}`, content);
29
+ return out;
30
+ };
31
+
32
+ //#endregion
33
+ export { mergeVfs, prefixVfs };
@@ -0,0 +1,126 @@
1
+ import { Effect, FileSystem, Schema } from "effect";
2
+
3
+ //#region src/VirtualPackage.ts
4
+ /**
5
+ * A synthetic npm package built from locally supplied TypeScript declaration
6
+ * content, for inclusion in a {@link Vfs} without fetching from the CDN.
7
+ *
8
+ * @remarks
9
+ * Useful when you have locally generated `.d.ts` files — API Extractor
10
+ * output, hand-written ambient declarations — and want them in the same VFS
11
+ * `TypeRegistry` builds from remote packages. Instances are transient: they
12
+ * are never persisted to the disk cache.
13
+ *
14
+ * The class is deliberately subclass-friendly (the rspress consumer extends
15
+ * it): construct via `VirtualPackage.make(...)` or the statics, and extend
16
+ * with `class Mine extends VirtualPackage { ... }`.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import { VirtualPackage } from "@tsdoctor/registry";
21
+ *
22
+ * const pkg = VirtualPackage.create("@my-org/api-types", "1.0.0", "export interface User { id: string }");
23
+ * const vfs = pkg.toVfs();
24
+ * // node_modules/@my-org/api-types/package.json, node_modules/@my-org/api-types/index.d.ts
25
+ * ```
26
+ *
27
+ * @public
28
+ */
29
+ var VirtualPackage = class VirtualPackage extends Schema.Class("VirtualPackage")({
30
+ /** The package name (e.g. `"@my-org/api-types"`). */
31
+ name: Schema.String,
32
+ /** The package version. */
33
+ version: Schema.String,
34
+ /** Entry file names (e.g. `"index.d.ts"`) mapped to declaration source. */
35
+ entries: Schema.ReadonlyMap(Schema.String, Schema.String)
36
+ }) {
37
+ /**
38
+ * Single-entry factory: a virtual package whose sole entry point is
39
+ * `index.d.ts`.
40
+ */
41
+ static create(name, version, declarations) {
42
+ return VirtualPackage.make({
43
+ name,
44
+ version,
45
+ entries: /* @__PURE__ */ new Map([["index.d.ts", declarations]])
46
+ });
47
+ }
48
+ /**
49
+ * Multi-entry factory: one `.d.ts` per entry point, exposed through a
50
+ * synthetic `exports` map.
51
+ *
52
+ * @remarks
53
+ * An empty entries map is developer wiring, not input — it would produce a
54
+ * package whose `types` points at a file that does not exist — so it
55
+ * throws at construction (defect posture), as does an entry set whose
56
+ * names collide after extension normalization (see
57
+ * {@link VirtualPackage.toVfs}).
58
+ */
59
+ static createMultiEntry(name, version, entries) {
60
+ if (entries.size === 0) throw new Error(`VirtualPackage.createMultiEntry: "${name}" needs at least one entry file`);
61
+ return VirtualPackage.make({
62
+ name,
63
+ version,
64
+ entries
65
+ });
66
+ }
67
+ /**
68
+ * Load a single `.d.ts` file from disk as a virtual package with one
69
+ * `index.d.ts` entry.
70
+ *
71
+ * @remarks
72
+ * Reads through the platform-agnostic `FileSystem` service; the
73
+ * `PlatformError` surfaces typed.
74
+ */
75
+ static fromFile(name, version, filePath) {
76
+ return Effect.gen(function* () {
77
+ const content = yield* (yield* FileSystem.FileSystem).readFileString(filePath);
78
+ return VirtualPackage.create(name, version, content);
79
+ }).pipe(Effect.withSpan("VirtualPackage.fromFile"));
80
+ }
81
+ /**
82
+ * The package's {@link Vfs}: a synthetic `package.json` plus every entry
83
+ * file, each path prefixed `node_modules/<name>/`.
84
+ *
85
+ * @remarks
86
+ * The `package.json` uses `types` for a single entry and an `exports` map
87
+ * for multiple entries, so TypeScript module resolution works against the
88
+ * generated VFS.
89
+ */
90
+ toVfs() {
91
+ const vfs = /* @__PURE__ */ new Map();
92
+ const prefix = `node_modules/${this.name}`;
93
+ vfs.set(`${prefix}/package.json`, this.toPackageJson());
94
+ for (const [fileName, content] of this.entries) {
95
+ if (fileName === "package.json") throw new Error(`VirtualPackage: "${this.name}" cannot define package.json as an entry`);
96
+ vfs.set(`${prefix}/${fileName}`, content);
97
+ }
98
+ return vfs;
99
+ }
100
+ toPackageJson() {
101
+ if (this.entries.size === 0) throw new Error(`VirtualPackage: "${this.name}" has no entry files — nothing to point types at`);
102
+ const manifest = {
103
+ name: this.name,
104
+ version: this.version
105
+ };
106
+ if (this.entries.size === 1) {
107
+ const [only] = this.entries.keys();
108
+ manifest.types = only ?? "index.d.ts";
109
+ } else {
110
+ manifest.exports = {};
111
+ const sources = /* @__PURE__ */ new Map();
112
+ for (const fileName of this.entries.keys()) {
113
+ const baseName = fileName.replace(/\.d\.(m|c)?ts$/, "");
114
+ const key = baseName === "index" ? "." : `./${baseName}`;
115
+ const previous = sources.get(key);
116
+ if (previous !== void 0) throw new Error(`VirtualPackage: "${this.name}" entries "${previous}" and "${fileName}" both normalize to the export key "${key}"`);
117
+ sources.set(key, fileName);
118
+ manifest.exports[key] = { types: `./${fileName}` };
119
+ }
120
+ }
121
+ return JSON.stringify(manifest, null, 2);
122
+ }
123
+ };
124
+
125
+ //#endregion
126
+ export { VirtualPackage };