@nudojs/harvester 0.1.0 → 0.2.1

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 Nudo Contributors
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/dist/index.d.ts CHANGED
@@ -13,7 +13,10 @@ type HarvestedEnv = {
13
13
  skipped: number;
14
14
  };
15
15
  };
16
- declare function harvestDts(files: string[]): HarvestedEnv;
16
+ declare function harvestDts(files: string[], opts?: {
17
+ maxFileBytes?: number;
18
+ maxMs?: number;
19
+ }): HarvestedEnv;
17
20
  declare function emitEnvModule(env: HarvestedEnv, pkgName: string): string;
18
21
 
19
22
  export { type HarvestedEnv, emitEnvModule, harvestDts };
package/dist/index.js CHANGED
@@ -1,12 +1,14 @@
1
1
  // src/index.ts
2
- import { readFileSync } from "fs";
2
+ import { readFileSync, statSync } from "fs";
3
3
  import ts from "typescript";
4
4
  import { typeValueEquals, getFnSig, T } from "@nudojs/core";
5
5
  var MAX_ALIAS_DEPTH = 8;
6
6
  function scopeKey(scope) {
7
7
  return scope.moduleName ?? "\0global";
8
8
  }
9
- function harvestDts(files) {
9
+ function harvestDts(files, opts) {
10
+ const maxBytes = opts?.maxFileBytes ?? 15e5;
11
+ const deadline = opts?.maxMs !== void 0 ? Date.now() + opts.maxMs : void 0;
10
12
  const ctx = {
11
13
  globals: {},
12
14
  modules: {},
@@ -24,16 +26,33 @@ function harvestDts(files) {
24
26
  symbols: 0,
25
27
  skipped: 0
26
28
  };
29
+ let parsed = 0;
27
30
  for (const file of files) {
28
- const content = readFileSync(file, "utf8");
31
+ if (deadline !== void 0 && Date.now() > deadline) {
32
+ ctx.skipped += files.length - parsed;
33
+ break;
34
+ }
35
+ let content;
36
+ try {
37
+ const st = statSync(file);
38
+ if (st.size > maxBytes) {
39
+ ctx.skipped++;
40
+ continue;
41
+ }
42
+ content = readFileSync(file, "utf8");
43
+ } catch {
44
+ ctx.skipped++;
45
+ continue;
46
+ }
29
47
  const sourceFile = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true);
30
48
  collectStatements(ctx, sourceFile.statements, { moduleName: void 0, ns: [] });
49
+ parsed++;
31
50
  }
32
51
  materialize(ctx);
33
52
  return {
34
53
  globals: ctx.globals,
35
54
  modules: ctx.modules,
36
- stats: { files: files.length, symbols: ctx.symbols, skipped: ctx.skipped }
55
+ stats: { files: parsed, symbols: ctx.symbols, skipped: ctx.skipped }
37
56
  };
38
57
  }
39
58
  function emitEnvModule(env, pkgName) {
package/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "@nudojs/harvester",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Harvests TypeScript .d.ts declarations into Nudo env definitions",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "main": "src/index.ts",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
8
9
  "exports": {
9
- ".": "./src/index.ts"
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
10
14
  },
15
+ "files": [
16
+ "dist"
17
+ ],
11
18
  "repository": {
12
19
  "type": "git",
13
20
  "url": "git+https://github.com/nudojs/nudo.git",
@@ -18,11 +25,11 @@
18
25
  "provenance": true,
19
26
  "registry": "https://registry.npmjs.org"
20
27
  },
28
+ "dependencies": {
29
+ "typescript": "^5.8.0",
30
+ "@nudojs/core": "0.3.1"
31
+ },
21
32
  "scripts": {
22
33
  "build": "tsup src/index.ts --format esm --dts"
23
- },
24
- "dependencies": {
25
- "@nudojs/core": "workspace:*",
26
- "typescript": "^5.8.0"
27
34
  }
28
- }
35
+ }
@@ -1,110 +0,0 @@
1
- import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
- import { rmSync, writeFileSync } from "node:fs";
3
- import nodePath from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { harvestDts, emitEnvModule, type HarvestedEnv } from "../index.ts";
6
-
7
- const typesNode = nodePath.resolve(
8
- nodePath.dirname(fileURLToPath(import.meta.url)),
9
- "../../../..",
10
- "node_modules/@types/node",
11
- );
12
-
13
- const files = [
14
- "fs.d.ts",
15
- "path.d.ts",
16
- "os.d.ts",
17
- "buffer.d.ts",
18
- "buffer.buffer.d.ts",
19
- "url.d.ts",
20
- "process.d.ts",
21
- ].map((f) => nodePath.join(typesNode, f));
22
-
23
- let env: HarvestedEnv;
24
- let code: string;
25
-
26
- beforeAll(() => {
27
- env = harvestDts(files);
28
- code = emitEnvModule(env, "@types/node");
29
- }, 120_000);
30
-
31
- describe("harvestDts", () => {
32
- it("registers both node:-prefixed and bare module keys", () => {
33
- expect(env.modules["node:fs"]).toBeDefined();
34
- expect(env.modules.fs).toBeDefined();
35
- expect(env.modules["node:path"]).toBeDefined();
36
- expect(env.modules.path).toBeDefined();
37
- expect(env.modules["node:os"]).toBeDefined();
38
- expect(env.modules.os).toBeDefined();
39
- // The bare key aliases the same record as its node: twin.
40
- expect(env.modules.fs).toBe(env.modules["node:fs"]);
41
- });
42
-
43
- it("maps fs.readFileSync to a function returning union(string, Buffer)", () => {
44
- const fn = env.modules["node:fs"].readFileSync as Record<string, unknown>;
45
- expect(fn.kind).toBe("function");
46
- const sig = fn._signature as {
47
- returnType: { kind: string; members?: Array<{ kind: string; type?: string; className?: string }> };
48
- };
49
- expect(sig).toBeDefined();
50
- expect(sig.returnType.kind).toBe("union");
51
- const members = sig.returnType.members ?? [];
52
- const hasString = members.some((m) => m.kind === "primitive" && m.type === "string");
53
- const hasBuffer = members.some((m) => m.kind === "instance" && m.className === "Buffer");
54
- expect(hasString).toBe(true);
55
- expect(hasBuffer).toBe(true);
56
- });
57
-
58
- it("maps path.join and os.platform", () => {
59
- expect(env.modules.path.join.kind).toBe("function");
60
- const platform = env.modules.os.platform as Record<string, unknown>;
61
- expect(platform.kind).toBe("function");
62
- const ret = (platform._signature as { returnType: { kind: string; type?: string; members?: unknown[] } })
63
- .returnType;
64
- const isString = ret.kind === "primitive" && ret.type === "string";
65
- const isStringLiteralUnion =
66
- ret.kind === "union" &&
67
- (ret.members ?? []).every(
68
- (m) => (m as { kind: string; value: unknown }).kind === "literal" && typeof (m as { value: unknown }).value === "string",
69
- );
70
- expect(isString || isStringLiteralUnion).toBe(true);
71
- });
72
-
73
- it("exposes harvest stats and global-block symbols", () => {
74
- expect(env.stats.files).toBeGreaterThanOrEqual(3);
75
- expect(env.stats.files).toBe(files.length);
76
- expect(env.stats.symbols).toBeGreaterThan(50);
77
- // process.d.ts declares `global { var process: NodeJS.Process; }`.
78
- expect(env.globals.process).toBeDefined();
79
- expect(env.globals.process.kind).toBe("instance");
80
- });
81
- });
82
-
83
- describe("emitEnvModule", () => {
84
- const generatedUrl = new URL("./generated-env.tmp.ts", import.meta.url);
85
- const generatedPath = fileURLToPath(generatedUrl);
86
-
87
- afterAll(() => {
88
- rmSync(generatedPath, { force: true });
89
- });
90
-
91
- it("emits importable TS source", () => {
92
- expect(code).toContain("// Auto-generated by nudo harvest — DO NOT EDIT");
93
- expect(code).toContain('import { type TypeValue, T } from "@nudojs/core"');
94
- expect(code).toContain("export function defineEnv");
95
- expect(code).toContain("T.fnSig");
96
- expect(code).toContain("T.instanceOf");
97
- writeFileSync(generatedPath, code);
98
- });
99
-
100
- it("emitted module evaluates and defines the env", { timeout: 120_000 }, async () => {
101
- const mod = (await import("./generated-env.tmp.ts")) as {
102
- defineEnv: () => { globals: Record<string, unknown>; modules: Record<string, Record<string, unknown>> };
103
- };
104
- const evaluated = mod.defineEnv();
105
- expect(evaluated.modules.fs).toBeDefined();
106
- expect(evaluated.modules["node:fs"].readFileSync).toBeDefined();
107
- expect((evaluated.modules["node:fs"].readFileSync as { kind: string }).kind).toBe("function");
108
- expect((evaluated.globals.process as { kind: string }).kind).toBe("instance");
109
- });
110
- });
package/src/index.ts DELETED
@@ -1,730 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import ts from "typescript";
3
- import { type TypeValue, typeValueEquals, getFnSig, T } from "@nudojs/core";
4
-
5
- /**
6
- * The result of harvesting a set of `.d.ts` files: a Nudo env shaped exactly
7
- * like `EnvDefinition` (`{ globals, modules }`), plus harvest statistics.
8
- */
9
- export type HarvestedEnv = {
10
- globals: Record<string, TypeValue>;
11
- modules: Record<string, Record<string, TypeValue>>;
12
- stats: { files: number; symbols: number; skipped: number };
13
- };
14
-
15
- type NamedTypeDecl = ts.InterfaceDeclaration | ts.ClassDeclaration;
16
-
17
- /**
18
- * Runtime symbol collected in phase 1, materialized to a TypeValue in phase 2
19
- * once the whole symbol table is populated (cross-file references resolve
20
- * regardless of file order).
21
- */
22
- type PendingSymbol =
23
- | { t: "fn"; scope: Scope; name: string; decls: ts.FunctionDeclaration[] }
24
- | { t: "var"; scope: Scope; name: string; typeNode?: ts.TypeNode }
25
- /** class / value-position interface re-export; `lookup` is the symbol-table name when the exported name differs */
26
- | { t: "value"; scope: Scope; name: string; lookup?: string }
27
- | { t: "enum"; scope: Scope; name: string };
28
-
29
- interface HarvestContext {
30
- globals: Record<string, TypeValue>;
31
- modules: Record<string, Record<string, TypeValue>>;
32
- /** simple name → interface/class declaration (first wins) */
33
- interfaces: Map<string, NamedTypeDecl>;
34
- /** dotted name ("NodeJS.Process") → declaration (first wins) */
35
- qualifiedInterfaces: Map<string, NamedTypeDecl>;
36
- /** simple name → type alias declaration (first wins) */
37
- aliases: Map<string, ts.TypeAliasDeclaration>;
38
- /** dotted name → type alias declaration (first wins) */
39
- qualifiedAliases: Map<string, ts.TypeAliasDeclaration>;
40
- /** simple name → class declaration (runtime values, for `export { X }` re-exports) */
41
- classes: Map<string, ts.ClassDeclaration>;
42
- instanceCache: Map<string, TypeValue>;
43
- expanding: Set<string>;
44
- /** [aliasModule, targetModule] pairs from `export * from` / `export = x` forms */
45
- moduleAliases: Array<[alias: string, target: string]>;
46
- /** `import x = require("mod")` bindings, per scope key */
47
- importEquals: Map<string, Map<string, string>>;
48
- declaredModules: Set<string>;
49
- pending: PendingSymbol[];
50
- symbols: number;
51
- skipped: number;
52
- }
53
-
54
- interface Scope {
55
- /** enclosing `declare module "..."` name, or undefined for global scope */
56
- moduleName: string | undefined;
57
- /** enclosing namespace chain, e.g. ["NodeJS"] */
58
- ns: string[];
59
- }
60
-
61
- const MAX_ALIAS_DEPTH = 8;
62
-
63
- function scopeKey(scope: Scope): string {
64
- return scope.moduleName ?? "\0global";
65
- }
66
-
67
- // ---------------------------------------------------------------------------
68
- // Public API
69
- // ---------------------------------------------------------------------------
70
-
71
- export function harvestDts(files: string[]): HarvestedEnv {
72
- const ctx: HarvestContext = {
73
- globals: {},
74
- modules: {},
75
- interfaces: new Map(),
76
- qualifiedInterfaces: new Map(),
77
- aliases: new Map(),
78
- qualifiedAliases: new Map(),
79
- classes: new Map(),
80
- instanceCache: new Map(),
81
- expanding: new Set(),
82
- moduleAliases: [],
83
- importEquals: new Map(),
84
- declaredModules: new Set(),
85
- pending: [],
86
- symbols: 0,
87
- skipped: 0,
88
- };
89
-
90
- // Phase 1: collect declarations into the shared symbol table.
91
- for (const file of files) {
92
- const content = readFileSync(file, "utf8");
93
- const sourceFile = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true);
94
- collectStatements(ctx, sourceFile.statements, { moduleName: undefined, ns: [] });
95
- }
96
-
97
- // Phase 2: materialize TypeValues with the complete symbol table available.
98
- materialize(ctx);
99
-
100
- return {
101
- globals: ctx.globals,
102
- modules: ctx.modules,
103
- stats: { files: files.length, symbols: ctx.symbols, skipped: ctx.skipped },
104
- };
105
- }
106
-
107
- export function emitEnvModule(env: HarvestedEnv, pkgName: string): string {
108
- // Module records can be shared (e.g. "fs" aliasing "node:fs"); emit those
109
- // once as a const and reference it under every key.
110
- const moduleEntries = Object.entries(env.modules);
111
- const keysByRecord = new Map<Record<string, TypeValue>, string[]>();
112
- for (const [moduleName, record] of moduleEntries) {
113
- const keys = keysByRecord.get(record);
114
- if (keys) keys.push(moduleName);
115
- else keysByRecord.set(record, [moduleName]);
116
- }
117
- const constNameByRecord = new Map<Record<string, TypeValue>, string>();
118
- const constLines: string[] = [];
119
- let constIndex = 0;
120
- for (const [record, keys] of keysByRecord) {
121
- if (keys.length < 2) continue;
122
- const constName = `mod${constIndex++}`;
123
- constNameByRecord.set(record, constName);
124
- constLines.push(` const ${constName}: Record<string, TypeValue> = {`);
125
- emitEntries(constLines, record, 2);
126
- constLines.push(` };`);
127
- constLines.push("");
128
- }
129
-
130
- const lines: string[] = [];
131
- lines.push("// Auto-generated by nudo harvest — DO NOT EDIT");
132
- lines.push(`// Source package: ${pkgName}`);
133
- if (constLines.length > 0) {
134
- lines.push('import { type TypeValue, T } from "@nudojs/core";');
135
- } else {
136
- lines.push(`import { T } from "@nudojs/core";`);
137
- }
138
- lines.push("");
139
- lines.push("export function defineEnv() {");
140
- lines.push(...constLines);
141
- lines.push(" return {");
142
- lines.push(" globals: {");
143
- emitEntries(lines, env.globals, 3);
144
- lines.push(" },");
145
- lines.push(" modules: {");
146
- for (const [moduleName, record] of moduleEntries) {
147
- const constName = constNameByRecord.get(record);
148
- if (constName !== undefined) {
149
- lines.push(`${indent(3)}${emitKey(moduleName)}: ${constName},`);
150
- continue;
151
- }
152
- lines.push(`${indent(3)}${emitKey(moduleName)}: {`);
153
- emitEntries(lines, record, 4);
154
- lines.push(`${indent(3)}},`);
155
- }
156
- lines.push(" },");
157
- lines.push(" };");
158
- lines.push("}");
159
- lines.push("");
160
- return lines.join("\n");
161
- }
162
-
163
- // ---------------------------------------------------------------------------
164
- // Phase 1: collect declarations
165
- // ---------------------------------------------------------------------------
166
-
167
- function recordFor(ctx: HarvestContext, scope: Scope): Record<string, TypeValue> {
168
- if (scope.moduleName === undefined) return ctx.globals;
169
- let rec = ctx.modules[scope.moduleName];
170
- if (!rec) {
171
- rec = {};
172
- ctx.modules[scope.moduleName] = rec;
173
- }
174
- return rec;
175
- }
176
-
177
- function collectStatements(ctx: HarvestContext, statements: readonly ts.Statement[], scope: Scope): void {
178
- for (const stmt of statements) {
179
- collectStatement(ctx, stmt, scope);
180
- }
181
- }
182
-
183
- function collectStatement(ctx: HarvestContext, stmt: ts.Statement, scope: Scope): void {
184
- if (ts.isModuleDeclaration(stmt)) {
185
- const body = stmt.body;
186
- if (!body || !ts.isModuleBlock(body)) {
187
- ctx.skipped++;
188
- return;
189
- }
190
- if (ts.isStringLiteral(stmt.name)) {
191
- // `declare module "node:fs" { ... }`
192
- ctx.declaredModules.add(stmt.name.text);
193
- collectStatements(ctx, body.statements, { moduleName: stmt.name.text, ns: [] });
194
- } else if (stmt.name.text === "global") {
195
- // `global { ... }` inside a module → globals
196
- collectStatements(ctx, body.statements, { moduleName: undefined, ns: [] });
197
- } else {
198
- // `namespace X { ... }` → types registered with qualified names,
199
- // runtime symbols flattened into the enclosing scope.
200
- collectStatements(ctx, body.statements, { moduleName: scope.moduleName, ns: [...scope.ns, stmt.name.text] });
201
- }
202
- return;
203
- }
204
-
205
- if (ts.isFunctionDeclaration(stmt)) {
206
- if (!stmt.name) {
207
- ctx.skipped++;
208
- return;
209
- }
210
- const name = stmt.name.text;
211
- const existing = ctx.pending.find((p) => p.t === "fn" && p.name === name && scopeKey(p.scope) === scopeKey(scope));
212
- if (existing && existing.t === "fn") {
213
- existing.decls.push(stmt); // overload
214
- return;
215
- }
216
- ctx.pending.push({ t: "fn", scope, name, decls: [stmt] });
217
- ctx.symbols++;
218
- return;
219
- }
220
-
221
- if (ts.isVariableStatement(stmt)) {
222
- for (const decl of stmt.declarationList.declarations) {
223
- if (!ts.isIdentifier(decl.name)) {
224
- ctx.skipped++;
225
- continue;
226
- }
227
- ctx.pending.push({ t: "var", scope, name: decl.name.text, typeNode: decl.type });
228
- ctx.symbols++;
229
- }
230
- return;
231
- }
232
-
233
- if (ts.isInterfaceDeclaration(stmt)) {
234
- registerTypeDecl(ctx, scope, stmt.name.text, stmt);
235
- return;
236
- }
237
-
238
- if (ts.isTypeAliasDeclaration(stmt)) {
239
- registerAlias(ctx, scope, stmt);
240
- return;
241
- }
242
-
243
- if (ts.isClassDeclaration(stmt)) {
244
- if (!stmt.name) {
245
- ctx.skipped++;
246
- return;
247
- }
248
- registerTypeDecl(ctx, scope, stmt.name.text, stmt);
249
- if (!ctx.classes.has(stmt.name.text)) ctx.classes.set(stmt.name.text, stmt);
250
- // A class is also a runtime value of its scope.
251
- ctx.pending.push({ t: "value", scope, name: stmt.name.text });
252
- ctx.symbols++;
253
- return;
254
- }
255
-
256
- if (ts.isEnumDeclaration(stmt)) {
257
- if (stmt.name) {
258
- ctx.pending.push({ t: "enum", scope, name: stmt.name.text });
259
- ctx.symbols++;
260
- }
261
- return;
262
- }
263
-
264
- if (ts.isImportDeclaration(stmt)) {
265
- // Type-only imports; names resolve through the shared flat symbol table.
266
- return;
267
- }
268
-
269
- if (ts.isImportEqualsDeclaration(stmt)) {
270
- // `import path = require("node:path")` — record for `export = path` aliasing.
271
- if (
272
- ts.isIdentifier(stmt.name) &&
273
- stmt.moduleReference &&
274
- ts.isExternalModuleReference(stmt.moduleReference) &&
275
- ts.isStringLiteral(stmt.moduleReference.expression)
276
- ) {
277
- const key = scopeKey(scope);
278
- let bindings = ctx.importEquals.get(key);
279
- if (!bindings) {
280
- bindings = new Map();
281
- ctx.importEquals.set(key, bindings);
282
- }
283
- bindings.set(stmt.name.text, stmt.moduleReference.expression.text);
284
- }
285
- return;
286
- }
287
-
288
- if (ts.isExportDeclaration(stmt)) {
289
- const specifier = stmt.moduleSpecifier;
290
- if (specifier && ts.isStringLiteral(specifier)) {
291
- if (stmt.exportClause && ts.isNamespaceExport(stmt.exportClause)) {
292
- // `export * as promises from "..."` — namespace re-export, v1 skip.
293
- ctx.skipped++;
294
- return;
295
- }
296
- if (!stmt.exportClause) {
297
- // `export * from "node:fs"` — alias the module.
298
- if (scope.moduleName !== undefined) {
299
- ctx.moduleAliases.push([scope.moduleName, specifier.text]);
300
- }
301
- return;
302
- }
303
- // Named re-exports from another module — v1 skip.
304
- ctx.skipped++;
305
- return;
306
- }
307
- // `export { Buffer }` — re-export a local runtime value.
308
- if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
309
- for (const el of stmt.exportClause.elements) {
310
- const localName = (el.propertyName ?? el.name).text;
311
- const exportedName = el.name.text;
312
- const isValue =
313
- ctx.classes.has(localName) ||
314
- (!el.isTypeOnly && (ctx.interfaces.has(localName) || ctx.qualifiedInterfaces.has(localName)));
315
- if (isValue) {
316
- ctx.pending.push({ t: "value", scope, name: exportedName, lookup: localName });
317
- ctx.symbols++;
318
- } else {
319
- ctx.skipped++;
320
- }
321
- }
322
- return;
323
- }
324
- ctx.skipped++;
325
- return;
326
- }
327
-
328
- if (ts.isExportAssignment(stmt)) {
329
- // `export = x` where x is `import x = require("mod")` → module alias.
330
- if (stmt.expression && ts.isIdentifier(stmt.expression) && scope.moduleName !== undefined) {
331
- const target = ctx.importEquals.get(scopeKey(scope))?.get(stmt.expression.text);
332
- if (target !== undefined) {
333
- ctx.moduleAliases.push([scope.moduleName, target]);
334
- return;
335
- }
336
- }
337
- ctx.skipped++;
338
- return;
339
- }
340
-
341
- ctx.skipped++;
342
- }
343
-
344
- function registerTypeDecl(ctx: HarvestContext, scope: Scope, name: string, decl: NamedTypeDecl): void {
345
- if (!ctx.interfaces.has(name)) ctx.interfaces.set(name, decl);
346
- if (ts.isClassDeclaration(decl)) return; // classes register only their simple name
347
- const qualified = [...scope.ns, name].join(".");
348
- if (qualified !== name && !ctx.qualifiedInterfaces.has(qualified)) {
349
- ctx.qualifiedInterfaces.set(qualified, decl);
350
- }
351
- }
352
-
353
- function registerAlias(ctx: HarvestContext, scope: Scope, stmt: ts.TypeAliasDeclaration): void {
354
- const name = stmt.name.text;
355
- if (!ctx.aliases.has(name)) ctx.aliases.set(name, stmt);
356
- const qualified = [...scope.ns, name].join(".");
357
- if (qualified !== name && !ctx.qualifiedAliases.has(qualified)) {
358
- ctx.qualifiedAliases.set(qualified, stmt);
359
- }
360
- }
361
-
362
- // ---------------------------------------------------------------------------
363
- // Phase 2: materialize TypeValues
364
- // ---------------------------------------------------------------------------
365
-
366
- function materialize(ctx: HarvestContext): void {
367
- for (const p of ctx.pending) {
368
- const rec = recordFor(ctx, p.scope);
369
- if (p.t === "fn") {
370
- if (rec[p.name] !== undefined) continue; // same name already handled (first wins)
371
- const value = signatureToFnSig(ctx, p.decls[0]!); // first overload's parameter list
372
- const sig = getFnSig(value);
373
- if (sig) {
374
- // Merge return types across overloads; drop unknown members when a
375
- // known one exists so unresolved references don't erase information.
376
- const returns: TypeValue[] = [];
377
- for (const decl of p.decls) {
378
- if (!decl.type) continue;
379
- const ret = mapType(ctx, decl.type, 0);
380
- if (ret.kind !== "unknown") returns.push(ret);
381
- }
382
- sig.returnType =
383
- returns.length > 0
384
- ? returns.reduce((acc, r) => (typeValueEquals(acc, r) ? acc : T.union(acc, r)))
385
- : T.unknown;
386
- }
387
- rec[p.name] = value;
388
- continue;
389
- }
390
- if (rec[p.name] !== undefined) continue;
391
- switch (p.t) {
392
- case "var": {
393
- // 映射为 unknown 的声明零信息量,且会遮蔽 evaluator 内置
394
- // (如 URL/AbortController 的 createXxxType 构造),跳过更优。
395
- const mapped = p.typeNode ? mapType(ctx, p.typeNode, 0) : T.unknown;
396
- if (mapped.kind === "unknown") {
397
- ctx.skipped++;
398
- break;
399
- }
400
- rec[p.name] = mapped;
401
- break;
402
- }
403
- case "value": {
404
- // class 作为运行时值 = 构造函数:new X() 走 fnSig → instance。
405
- // 裸 instance 会被 new 的通用路径当作非函数 callee 退化为 unknown。
406
- const cls = ctx.classes.get(p.lookup ?? p.name);
407
- if (cls) {
408
- const ctor = (cls.members as readonly ts.ClassElement[]).find(ts.isConstructorDeclaration);
409
- const params: TypeValue[] = [];
410
- if (ctor) {
411
- for (const param of ctor.parameters) {
412
- let type = param.type ? mapType(ctx, param.type, 0) : T.unknown;
413
- if (param.questionToken) type = T.union(type, T.undefined);
414
- params.push(type);
415
- }
416
- }
417
- rec[p.name] = T.fnSig(params, instanceFor(ctx, p.lookup ?? p.name));
418
- } else {
419
- rec[p.name] = instanceFor(ctx, p.lookup ?? p.name);
420
- }
421
- break;
422
- }
423
- case "enum":
424
- rec[p.name] = T.unknown;
425
- break;
426
- }
427
- }
428
-
429
- // Apply module aliases (`declare module "fs" { export * from "node:fs"; }`
430
- // and the `export = path` form).
431
- for (const [alias, target] of ctx.moduleAliases) {
432
- if (ctx.modules[target] && !ctx.modules[alias]) {
433
- ctx.modules[alias] = ctx.modules[target]!;
434
- }
435
- }
436
- }
437
-
438
- // ---------------------------------------------------------------------------
439
- // Type mapping
440
- // ---------------------------------------------------------------------------
441
-
442
- function typeNameString(name: ts.EntityName): string {
443
- if (ts.isIdentifier(name)) return name.text;
444
- return `${typeNameString(name.left)}.${name.right.text}`;
445
- }
446
-
447
- function memberName(node: { name?: ts.PropertyName }): string | undefined {
448
- const n = node.name;
449
- if (!n) return undefined;
450
- if (ts.isIdentifier(n) || ts.isStringLiteral(n) || ts.isNumericLiteral(n)) return n.text;
451
- return undefined; // computed / private names
452
- }
453
-
454
- function instanceFor(ctx: HarvestContext, key: string): TypeValue {
455
- const cached = ctx.instanceCache.get(key);
456
- if (cached !== undefined) return cached;
457
- const decl = ctx.interfaces.get(key) ?? ctx.qualifiedInterfaces.get(key);
458
- if (!decl) return T.unknown;
459
- if (ctx.expanding.has(key)) {
460
- // Recursion guard: reference by name without expanding members.
461
- return T.instanceOf(decl.name?.text ?? key, {});
462
- }
463
- ctx.expanding.add(key);
464
- const properties: Record<string, TypeValue> = {};
465
- try {
466
- const members = decl.members as readonly (ts.TypeElement | ts.ClassElement)[];
467
- for (const member of members) {
468
- const modifiers = (member as { modifiers?: readonly { kind: ts.SyntaxKind }[] }).modifiers;
469
- if (modifiers?.some((mod) => mod.kind === ts.SyntaxKind.StaticKeyword)) continue;
470
- if (ts.isPropertySignature(member) || ts.isPropertyDeclaration(member)) {
471
- const name = memberName(member);
472
- if (name === undefined) continue;
473
- let type = member.type ? mapType(ctx, member.type, 0) : T.unknown;
474
- if (member.questionToken) type = T.union(type, T.undefined);
475
- properties[name] = type;
476
- } else if (ts.isMethodSignature(member) || ts.isMethodDeclaration(member)) {
477
- const name = memberName(member);
478
- if (name === undefined || properties[name] !== undefined) continue; // first overload wins
479
- properties[name] = signatureToFnSig(ctx, member);
480
- } else if (ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)) {
481
- const name = memberName(member);
482
- if (name === undefined || properties[name] !== undefined || !member.type) continue;
483
- properties[name] = mapType(ctx, member.type, 0);
484
- }
485
- // call / construct / index signatures are not representable — skipped.
486
- }
487
- } finally {
488
- ctx.expanding.delete(key);
489
- }
490
- const value = T.instanceOf(decl.name?.text ?? key, properties);
491
- ctx.instanceCache.set(key, value);
492
- return value;
493
- }
494
-
495
- function signatureToFnSig(ctx: HarvestContext, sig: ts.SignatureDeclaration): TypeValue {
496
- const paramTypes = sig.parameters.map((param) => {
497
- let type: TypeValue;
498
- if (param.dotDotDotToken) {
499
- const inner = param.type && ts.isArrayTypeNode(param.type) ? param.type.elementType : param.type;
500
- type = inner ? T.array(mapType(ctx, inner, 0)) : T.unknown;
501
- } else {
502
- type = param.type ? mapType(ctx, param.type, 0) : T.unknown;
503
- }
504
- if (param.questionToken && !param.dotDotDotToken) type = T.union(type, T.undefined);
505
- return type;
506
- });
507
- const returnType = sig.type ? mapType(ctx, sig.type, 0) : T.unknown;
508
- return T.fnSig(paramTypes, returnType);
509
- }
510
-
511
- function mapTypeMembers(
512
- ctx: HarvestContext,
513
- members: readonly ts.TypeElement[],
514
- depth: number,
515
- ): Record<string, TypeValue> {
516
- const properties: Record<string, TypeValue> = {};
517
- for (const member of members) {
518
- if (ts.isPropertySignature(member)) {
519
- const name = memberName(member);
520
- if (name === undefined) continue;
521
- let type = member.type ? mapType(ctx, member.type, depth) : T.unknown;
522
- if (member.questionToken) type = T.union(type, T.undefined);
523
- properties[name] = type;
524
- } else if (ts.isMethodSignature(member)) {
525
- const name = memberName(member);
526
- if (name === undefined || properties[name] !== undefined) continue; // first overload wins
527
- properties[name] = signatureToFnSig(ctx, member);
528
- }
529
- // index / call / construct signatures skipped
530
- }
531
- return properties;
532
- }
533
-
534
- function mapTypeRef(ctx: HarvestContext, node: ts.TypeReferenceNode, depth: number): TypeValue {
535
- const name = typeNameString(node.typeName);
536
- const args = node.typeArguments ?? [];
537
- if ((name === "Array" || name === "ReadonlyArray") && args.length >= 1) {
538
- return T.array(mapType(ctx, args[0], depth));
539
- }
540
- if (name === "Promise" && args.length >= 1) {
541
- return T.promise(mapType(ctx, args[0], depth));
542
- }
543
- if (name === "Record") {
544
- // Index-signature objects are approximated as an open object type.
545
- return T.object({});
546
- }
547
- if (ctx.interfaces.has(name) || ctx.qualifiedInterfaces.has(name)) {
548
- return instanceFor(ctx, name);
549
- }
550
- const alias = ctx.aliases.get(name) ?? ctx.qualifiedAliases.get(name);
551
- if (alias) return mapType(ctx, alias.type, depth + 1);
552
- return T.unknown;
553
- }
554
-
555
- function mapType(ctx: HarvestContext, node: ts.TypeNode | undefined, depth: number): TypeValue {
556
- if (!node || depth > MAX_ALIAS_DEPTH) return T.unknown;
557
-
558
- if (ts.isParenthesizedTypeNode(node)) return mapType(ctx, node.type, depth);
559
- if (ts.isTypeReferenceNode(node)) return mapTypeRef(ctx, node, depth);
560
-
561
- if (ts.isArrayTypeNode(node)) return T.array(mapType(ctx, node.elementType, depth));
562
- if (ts.isTupleTypeNode(node)) {
563
- for (const element of node.elements) {
564
- const inner = ts.isNamedTupleMember(element) ? element.type : element;
565
- return T.array(mapType(ctx, inner, depth)); // approximate by the first element
566
- }
567
- return T.unknown;
568
- }
569
-
570
- if (ts.isUnionTypeNode(node)) {
571
- return T.union(...node.types.map((t) => mapType(ctx, t, depth)));
572
- }
573
-
574
- if (ts.isIntersectionTypeNode(node)) {
575
- const members = node.types.map((t) => mapType(ctx, t, depth));
576
- if (members.length > 0 && members.every((m) => m.kind === "object")) {
577
- const properties: Record<string, TypeValue> = {};
578
- for (const member of members) {
579
- Object.assign(properties, (member as { properties: Record<string, TypeValue> }).properties);
580
- }
581
- return T.object(properties);
582
- }
583
- return members[0] ?? T.unknown;
584
- }
585
-
586
- if (ts.isLiteralTypeNode(node)) {
587
- const lit = node.literal;
588
- if (ts.isStringLiteral(lit)) return T.literal(lit.text);
589
- if (ts.isNumericLiteral(lit)) return T.literal(Number(lit.text));
590
- if (lit.kind === ts.SyntaxKind.TrueKeyword) return T.literal(true);
591
- if (lit.kind === ts.SyntaxKind.FalseKeyword) return T.literal(false);
592
- if (
593
- ts.isPrefixUnaryExpression(lit) &&
594
- lit.operator === ts.SyntaxKind.MinusToken &&
595
- ts.isNumericLiteral(lit.operand)
596
- ) {
597
- return T.literal(-Number(lit.operand.text));
598
- }
599
- return T.unknown;
600
- }
601
-
602
- if (ts.isFunctionTypeNode(node)) return signatureToFnSig(ctx, node);
603
-
604
- if (ts.isTypeLiteralNode(node)) {
605
- // 带构造签名的对象字面量类型(如 @types/node 的 var URL: { new(...): URL; ... })
606
- // 是构造函数形状:new X() 需要 function callee + fnSig 返回实例。
607
- const ctor = node.members.find(ts.isConstructSignatureDeclaration);
608
- if (ctor) {
609
- const params: TypeValue[] = [];
610
- for (const param of ctor.parameters) {
611
- let type = param.type ? mapType(ctx, param.type, depth) : T.unknown;
612
- if (param.questionToken) type = T.union(type, T.undefined);
613
- params.push(type);
614
- }
615
- return T.fnSig(params, ctor.type ? mapType(ctx, ctor.type, depth) : T.unknown);
616
- }
617
- return T.object(mapTypeMembers(ctx, node.members, depth));
618
- }
619
-
620
- if (ts.isTypeOperatorNode(node)) {
621
- return node.operator === ts.SyntaxKind.ReadonlyKeyword ? mapType(ctx, node.type, depth) : T.unknown;
622
- }
623
-
624
- switch (node.kind) {
625
- case ts.SyntaxKind.StringKeyword:
626
- return T.string;
627
- case ts.SyntaxKind.NumberKeyword:
628
- return T.number;
629
- case ts.SyntaxKind.BooleanKeyword:
630
- return T.boolean;
631
- case ts.SyntaxKind.BigIntKeyword:
632
- return T.bigint;
633
- case ts.SyntaxKind.SymbolKeyword:
634
- return T.symbol;
635
- case ts.SyntaxKind.AnyKeyword:
636
- case ts.SyntaxKind.UnknownKeyword:
637
- case ts.SyntaxKind.ObjectKeyword:
638
- return T.unknown;
639
- case ts.SyntaxKind.VoidKeyword:
640
- case ts.SyntaxKind.UndefinedKeyword:
641
- return T.undefined;
642
- case ts.SyntaxKind.NeverKeyword:
643
- return T.never;
644
- case ts.SyntaxKind.NullKeyword:
645
- return T.null;
646
- default:
647
- // typeof X, keyof, conditional / mapped / indexed / template-literal /
648
- // import() types and everything else — conservative degradation.
649
- return T.unknown;
650
- }
651
- }
652
-
653
- // ---------------------------------------------------------------------------
654
- // Emission
655
- // ---------------------------------------------------------------------------
656
-
657
- function indent(level: number): string {
658
- return " ".repeat(level);
659
- }
660
-
661
- function emitKey(key: string): string {
662
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
663
- }
664
-
665
- function emitEntries(lines: string[], record: Record<string, TypeValue>, level: number): void {
666
- for (const [key, value] of Object.entries(record)) {
667
- lines.push(`${indent(level)}${emitKey(key)}: ${emitType(value, new Set())},`);
668
- }
669
- }
670
-
671
- function emitType(tv: TypeValue, expanding: Set<string>): string {
672
- switch (tv.kind) {
673
- case "literal":
674
- if (tv.value === null) return "T.null";
675
- if (tv.value === undefined) return "T.undefined";
676
- return `T.literal(${JSON.stringify(tv.value)})`;
677
- case "primitive":
678
- return `T.${tv.type}`;
679
- case "unknown":
680
- return "T.unknown";
681
- case "never":
682
- return "T.never";
683
- case "array":
684
- return `T.array(${emitType(tv.element, expanding)})`;
685
- case "tuple":
686
- return `T.tuple([${tv.elements.map((e) => emitType(e, expanding)).join(", ")}])`;
687
- case "promise":
688
- return `T.promise(${emitType(tv.value, expanding)})`;
689
- case "object": {
690
- const entries = Object.entries(tv.properties).map(
691
- ([key, value]) => `${emitKey(key)}: ${emitType(value, expanding)}`,
692
- );
693
- return `T.object({ ${entries.join(", ")} })`;
694
- }
695
- case "instance": {
696
- if (expanding.has(tv.className)) {
697
- // Recursion guard on the emit side: truncate to a bare named instance.
698
- return `T.instanceOf(${JSON.stringify(tv.className)}, {})`;
699
- }
700
- expanding.add(tv.className);
701
- try {
702
- const entries = Object.entries(tv.properties).map(
703
- ([key, value]) => `${emitKey(key)}: ${emitType(value, expanding)}`,
704
- );
705
- return `T.instanceOf(${JSON.stringify(tv.className)}, { ${entries.join(", ")} })`;
706
- } finally {
707
- expanding.delete(tv.className);
708
- }
709
- }
710
- case "union": {
711
- if (tv.members.length === 0) return "T.never";
712
- if (tv.members.length === 1) return emitType(tv.members[0]!, expanding);
713
- return `T.union(${tv.members.map((m) => emitType(m, expanding)).join(", ")})`;
714
- }
715
- case "function": {
716
- const sig = getFnSig(tv);
717
- if (!sig) return "T.unknown";
718
- const params = sig.paramTypes.map((p) => emitType(p, expanding)).join(", ");
719
- const ret = emitType(sig.returnType, expanding);
720
- if (sig.throwsType && sig.throwsType.kind !== "never") {
721
- return `T.fnSig([${params}], ${ret}, ${emitType(sig.throwsType, expanding)})`;
722
- }
723
- return `T.fnSig([${params}], ${ret})`;
724
- }
725
- case "refined":
726
- return emitType(tv.base, expanding);
727
- default:
728
- return "T.unknown";
729
- }
730
- }
package/tsconfig.json DELETED
@@ -1,11 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "dist",
5
- "rootDir": "src"
6
- },
7
- "include": ["src"],
8
- "references": [
9
- { "path": "../core" }
10
- ]
11
- }