@venn-lang/dts 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 +21 -0
- package/README.md +128 -0
- package/dist/index.d.mts +78 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +292 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -0
- package/src/convert/index.ts +7 -0
- package/src/convert/shapes.ts +73 -0
- package/src/convert/to-spec.ts +145 -0
- package/src/index.ts +4 -0
- package/src/read-package-types.ts +92 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vinicius Borges
|
|
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,128 @@
|
|
|
1
|
+
# @venn-lang/dts
|
|
2
|
+
|
|
3
|
+
> Reads the types an installed npm package publishes and returns them as Venn `TypeSpec`s.
|
|
4
|
+
|
|
5
|
+
A Venn file can import from a package it installed: `import { z } from "zod"`. For the checker to say
|
|
6
|
+
anything at all about `z`, something has to work out what `zod` publishes. That is this package. It
|
|
7
|
+
asks the TypeScript compiler rather than parsing `.d.ts` text, and hands back plain data that
|
|
8
|
+
[`@venn-lang/core`](../core) can check against without ever learning what npm is.
|
|
9
|
+
|
|
10
|
+
Node only: it loads the TypeScript compiler API.
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { readPackageTypes } from "@venn-lang/dts";
|
|
16
|
+
|
|
17
|
+
const types = readPackageTypes({
|
|
18
|
+
package: "zod",
|
|
19
|
+
from: "/project/target/package.json",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
types.exports.z; // a TypeSpec
|
|
23
|
+
types.covered; // { total: 42, dynamic: 3 }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`from` is the file the specifier is resolved against, using the same rules TypeScript itself uses.
|
|
27
|
+
The CLI passes the generated `target/package.json`, beside which the package manager wrote
|
|
28
|
+
`node_modules`.
|
|
29
|
+
|
|
30
|
+
A package that declares this:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
export declare function sum(a: number, b?: number): number;
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
comes back as this:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
{
|
|
40
|
+
kind: "fn",
|
|
41
|
+
params: [{ kind: "prim", name: "number" }, { kind: "prim", name: "number" }],
|
|
42
|
+
result: { kind: "prim", name: "number" },
|
|
43
|
+
takes: 1,
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`takes` is how many arguments the caller must actually pass. Venn checks arity exactly, so without it
|
|
48
|
+
`z.string()` would read as the wrong number of arguments against a signature with one optional
|
|
49
|
+
parameter.
|
|
50
|
+
|
|
51
|
+
## API
|
|
52
|
+
|
|
53
|
+
| Export | Signature | What it does |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| `readPackageTypes` | `({ package, from }) => PackageTypes` | Resolves the package's declaration file, runs a program over it, and converts every module export. |
|
|
56
|
+
| `PackageTypes` | `interface` | `{ package, exports: Record<string, TypeSpec>, covered: { total, dynamic } }`. |
|
|
57
|
+
| `toSpec` | `(type: ts.Type, conv: Conversion) => TypeSpec` | One TypeScript type as a `TypeSpec`. For callers that already hold a `ts.TypeChecker`. |
|
|
58
|
+
| `Conversion` | `interface` | What one conversion carries: the checker, the current depth, and the shared state (types already read, types open, budget left). |
|
|
59
|
+
|
|
60
|
+
`covered` is counted, not claimed. "94% of exports typed" is a number that can be checked and driven
|
|
61
|
+
up, and it tells a reader far more than "fully compatible", which would be false for any package
|
|
62
|
+
built on conditional types.
|
|
63
|
+
|
|
64
|
+
A package that cannot be resolved, or that ships no declarations, is not an error. Plenty ship none.
|
|
65
|
+
The answer is an empty result, and every imported name is `dynamic`, which is the truth about it.
|
|
66
|
+
|
|
67
|
+
## How a TypeScript type is projected
|
|
68
|
+
|
|
69
|
+
Everything TypeScript can say lands on one of the shapes in [`@venn-lang/types`](../types) or degrades to
|
|
70
|
+
`dynamic`. It never fails.
|
|
71
|
+
|
|
72
|
+
| TypeScript | `TypeSpec` |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `string`, `number`, `boolean` | `prim` `string` / `number` / `bool` |
|
|
75
|
+
| `void`, `undefined` | `prim void` |
|
|
76
|
+
| `null` | `prim null` |
|
|
77
|
+
| `any`, `unknown`, `never` | `dynamic` |
|
|
78
|
+
| `"GET"`, `200` | `literal` |
|
|
79
|
+
| `T \| U` | `union`, with the `undefined` and `null` branches folded away |
|
|
80
|
+
| `T[]` | `list` |
|
|
81
|
+
| `[A, B]` | `list` of `A \| B`, since the language has no fixed-length list |
|
|
82
|
+
| A callable | `fn`, from the first signature only |
|
|
83
|
+
| An object with properties | `record`, `open` |
|
|
84
|
+
| Anything else | `dynamic` |
|
|
85
|
+
|
|
86
|
+
`string | undefined` is one type in Venn, a string that may not be there, so it arrives as
|
|
87
|
+
`prim string`. Writing it as a two-branch union would make every optional field something the reader
|
|
88
|
+
has to take apart before using it.
|
|
89
|
+
|
|
90
|
+
A method is a field whose type is a function, because that is what it is in Venn too. `schema.parse`
|
|
91
|
+
is reached the same way `schema.shape` is.
|
|
92
|
+
|
|
93
|
+
Records are `open`. The package's own type is the authority on what it holds and this reading of it is
|
|
94
|
+
not, so anything missed stays reachable instead of being refused.
|
|
95
|
+
|
|
96
|
+
Three limits keep a real package finishable. Conversion stops at depth 4, at a budget of 20,000
|
|
97
|
+
types, and at any type that leads back to itself. A package's types form a graph rather than a tree:
|
|
98
|
+
`zod`'s `z` holds hundreds of members and most of them lead back to the same handful of shapes, so
|
|
99
|
+
reading each branch on its own re-expands the same types until the machine runs out of memory.
|
|
100
|
+
Reading each type once and stopping at a budget is what makes this finish.
|
|
101
|
+
|
|
102
|
+
## Why the compiler
|
|
103
|
+
|
|
104
|
+
A modern package's exported types are built out of generics, conditional types and mapped types, and
|
|
105
|
+
none of those mean anything until something resolves them. Parsing the text gives back the machinery;
|
|
106
|
+
asking the compiler gives back the answer. A declaration of `Unwrap<Promise<string>>` arrives here as
|
|
107
|
+
`prim string`.
|
|
108
|
+
|
|
109
|
+
TypeScript is pulled in under the alias `tsc-api`, pinned to the 5.x line. TypeScript 7 is Go-native
|
|
110
|
+
and ships no JavaScript API until 7.1, so its package exports a version string and nothing else. 7
|
|
111
|
+
builds this repository, and 5.9 is a library this one package calls.
|
|
112
|
+
|
|
113
|
+
## Where it is used
|
|
114
|
+
|
|
115
|
+
`venn add` and `venn install` derive the types once, after the package manager has run, and write
|
|
116
|
+
them to `target/types/<package>.json`. `venn check` reads them back and binds them to the names a
|
|
117
|
+
file imported, so a wrong argument to `z.object` is caught without running anything.
|
|
118
|
+
|
|
119
|
+
Deriving is done at install rather than on every check because reading a large package through the
|
|
120
|
+
compiler takes about a second, and the answer only changes when what is installed does. The CLI also
|
|
121
|
+
loads this package with a dynamic `import()`, so `venn run` never pays for ten megabytes of compiler
|
|
122
|
+
it will not use.
|
|
123
|
+
|
|
124
|
+
## See also
|
|
125
|
+
|
|
126
|
+
- [`@venn-lang/types`](../types) defines `TypeSpec`, the vocabulary everything here is converted into.
|
|
127
|
+
- [`@venn-lang/core`](../core) binds these specs to imported names and checks the calls against them.
|
|
128
|
+
- [`@venn-lang/cli`](../cli) runs the derivation at install time and reads the result at check time.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import ts from "tsc-api";
|
|
2
|
+
import { TypeSpec } from "@venn-lang/types";
|
|
3
|
+
//#region src/convert/to-spec.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* What one conversion carries: the checker, how deep it is, and what it has
|
|
6
|
+
* already worked out.
|
|
7
|
+
*
|
|
8
|
+
* The shared state is not an optimisation. A real package's types form a graph,
|
|
9
|
+
* not a tree (`zod`'s `z` holds hundreds of members, most of them leading back
|
|
10
|
+
* to the same handful of shapes), so a branch read on its own re-expands the
|
|
11
|
+
* same types until the machine runs out of memory. Reading each type once, and
|
|
12
|
+
* stopping at a budget, is what makes this finish.
|
|
13
|
+
*/
|
|
14
|
+
interface Conversion {
|
|
15
|
+
checker: ts.TypeChecker;
|
|
16
|
+
depth: number;
|
|
17
|
+
state: ConversionState;
|
|
18
|
+
}
|
|
19
|
+
/** What every branch of one conversion shares. */
|
|
20
|
+
interface ConversionState {
|
|
21
|
+
done: Map<ts.Type, TypeSpec>;
|
|
22
|
+
/** Types being read right now, so one that contains itself does not loop. */
|
|
23
|
+
open: Set<ts.Type>;
|
|
24
|
+
/** How many more types may be read before the rest is `dynamic`. */
|
|
25
|
+
left: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* One TypeScript type, as the language's own.
|
|
29
|
+
*
|
|
30
|
+
* Everything TS can say projects onto the ten shapes or degrades to `dynamic`,
|
|
31
|
+
* never to a failure. Generics, conditionals and mapped types are gone by the
|
|
32
|
+
* time anything reaches here: the compiler resolved them, so what arrives is the
|
|
33
|
+
* answer rather than the machinery.
|
|
34
|
+
*
|
|
35
|
+
* @param type a type the checker has already resolved
|
|
36
|
+
* @param conv the checker, the current depth and the state shared across branches
|
|
37
|
+
* @returns the type as a `TypeSpec`, or `dynamic` once the depth, the budget or
|
|
38
|
+
* a cycle stops the reading
|
|
39
|
+
*/
|
|
40
|
+
declare function toSpec(type: ts.Type, conv: Conversion): TypeSpec;
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/read-package-types.d.ts
|
|
43
|
+
/** What one installed package publishes, as this language's types. */
|
|
44
|
+
interface PackageTypes {
|
|
45
|
+
package: string;
|
|
46
|
+
/** Each export, by the name it is imported under. */
|
|
47
|
+
exports: Record<string, TypeSpec>;
|
|
48
|
+
/** How many exports were read, and how many came back `dynamic`. */
|
|
49
|
+
covered: {
|
|
50
|
+
total: number;
|
|
51
|
+
dynamic: number;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The types an installed package publishes.
|
|
56
|
+
*
|
|
57
|
+
* Read through the TypeScript compiler rather than by parsing `.d.ts` text: a
|
|
58
|
+
* modern package's exported types are built out of generics, conditionals and
|
|
59
|
+
* mapped types, which have no meaning until something resolves them. Parsing
|
|
60
|
+
* gives back the machinery; asking the compiler gives back the answer.
|
|
61
|
+
*
|
|
62
|
+
* The compiler comes in under the alias `tsc-api`, pinned to the 5.x line,
|
|
63
|
+
* because TypeScript 7 is Go-native and ships no JavaScript API until 7.1. So
|
|
64
|
+
* 7 builds this repo and 5.9 is a library this one package calls.
|
|
65
|
+
*
|
|
66
|
+
* @returns every export as a {@link TypeSpec}, with a count of how many came
|
|
67
|
+
* back `dynamic`. A package that ships no types is not an error: `exports` is
|
|
68
|
+
* empty and `covered.total` is zero.
|
|
69
|
+
*/
|
|
70
|
+
declare function readPackageTypes(args: {
|
|
71
|
+
/** The package name, as it is imported. */
|
|
72
|
+
package: string;
|
|
73
|
+
/** A file to resolve from, such as the generated `package.json` in `target/`. */
|
|
74
|
+
from: string;
|
|
75
|
+
}): PackageTypes;
|
|
76
|
+
//#endregion
|
|
77
|
+
export { type Conversion, type PackageTypes, readPackageTypes, toSpec };
|
|
78
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/convert/to-spec.ts","../src/read-package-types.ts"],"mappings":";;;;;;;;;;;;;UAoBiB;EACf,SAAS,GAAG;EACZ;EACA,OAAO;;;UAIQ;EACf,MAAM,IAAI,GAAG,MAAM;;EAEnB,MAAM,IAAI,GAAG;;EAEb;;;;;;;;;;;;;;;iBAqBc,OAAO,MAAM,GAAG,MAAM,MAAM,aAAa;;;;UChDxC;EACf;;EAEA,SAAS,eAAe;;EAExB;IAAW;IAAe;;;;;;;;;;;;;;;;;;;iBAmBZ,iBAAiB;;EAE/B;;EAEA;IACE"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import ts from "tsc-api";
|
|
2
|
+
//#region src/convert/to-spec.ts
|
|
3
|
+
/** How far into a type to look, and how much of one to read at all. */
|
|
4
|
+
const DEPTH = 4;
|
|
5
|
+
const BUDGET = 2e4;
|
|
6
|
+
const DYNAMIC = { kind: "dynamic" };
|
|
7
|
+
/** A state with nothing read yet and the full budget left. */
|
|
8
|
+
function newState() {
|
|
9
|
+
return {
|
|
10
|
+
done: /* @__PURE__ */ new Map(),
|
|
11
|
+
open: /* @__PURE__ */ new Set(),
|
|
12
|
+
left: BUDGET
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* One TypeScript type, as the language's own.
|
|
17
|
+
*
|
|
18
|
+
* Everything TS can say projects onto the ten shapes or degrades to `dynamic`,
|
|
19
|
+
* never to a failure. Generics, conditionals and mapped types are gone by the
|
|
20
|
+
* time anything reaches here: the compiler resolved them, so what arrives is the
|
|
21
|
+
* answer rather than the machinery.
|
|
22
|
+
*
|
|
23
|
+
* @param type a type the checker has already resolved
|
|
24
|
+
* @param conv the checker, the current depth and the state shared across branches
|
|
25
|
+
* @returns the type as a `TypeSpec`, or `dynamic` once the depth, the budget or
|
|
26
|
+
* a cycle stops the reading
|
|
27
|
+
*/
|
|
28
|
+
function toSpec(type, conv) {
|
|
29
|
+
const known = conv.state.done.get(type);
|
|
30
|
+
if (known) return known;
|
|
31
|
+
if (conv.depth > DEPTH || conv.state.left <= 0 || conv.state.open.has(type)) return DYNAMIC;
|
|
32
|
+
conv.state.left--;
|
|
33
|
+
conv.state.open.add(type);
|
|
34
|
+
const spec = convert(type, conv);
|
|
35
|
+
conv.state.open.delete(type);
|
|
36
|
+
if (conv.depth < DEPTH) conv.state.done.set(type, spec);
|
|
37
|
+
return spec;
|
|
38
|
+
}
|
|
39
|
+
function convert(type, conv) {
|
|
40
|
+
const flags = type.flags;
|
|
41
|
+
const primitive = primitiveOf(flags);
|
|
42
|
+
if (primitive) return primitive;
|
|
43
|
+
if (flags & ts.TypeFlags.StringLiteral) return literal(type.value);
|
|
44
|
+
if (flags & ts.TypeFlags.NumberLiteral) return literal(type.value);
|
|
45
|
+
if (flags & ts.TypeFlags.Union) return unionOf(type, deeper(conv));
|
|
46
|
+
return structural(type, deeper(conv));
|
|
47
|
+
}
|
|
48
|
+
function deeper(conv) {
|
|
49
|
+
return {
|
|
50
|
+
...conv,
|
|
51
|
+
depth: conv.depth + 1
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const PRIMITIVES = [
|
|
55
|
+
[ts.TypeFlags.String, {
|
|
56
|
+
kind: "prim",
|
|
57
|
+
name: "string"
|
|
58
|
+
}],
|
|
59
|
+
[ts.TypeFlags.Number, {
|
|
60
|
+
kind: "prim",
|
|
61
|
+
name: "number"
|
|
62
|
+
}],
|
|
63
|
+
[ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral, {
|
|
64
|
+
kind: "prim",
|
|
65
|
+
name: "bool"
|
|
66
|
+
}],
|
|
67
|
+
[ts.TypeFlags.Void | ts.TypeFlags.Undefined, {
|
|
68
|
+
kind: "prim",
|
|
69
|
+
name: "void"
|
|
70
|
+
}],
|
|
71
|
+
[ts.TypeFlags.Null, {
|
|
72
|
+
kind: "prim",
|
|
73
|
+
name: "null"
|
|
74
|
+
}],
|
|
75
|
+
[ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never, DYNAMIC]
|
|
76
|
+
];
|
|
77
|
+
function primitiveOf(flags) {
|
|
78
|
+
for (const [mask, spec] of PRIMITIVES) if (flags & mask) return spec;
|
|
79
|
+
}
|
|
80
|
+
function literal(value) {
|
|
81
|
+
return {
|
|
82
|
+
kind: "literal",
|
|
83
|
+
value
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A union, with the branches TypeScript adds for absence folded away.
|
|
88
|
+
*
|
|
89
|
+
* `string | undefined` is one type in this language, a string that may not be
|
|
90
|
+
* there. Writing it as a two-branch union would make every optional field
|
|
91
|
+
* something the reader has to take apart before using.
|
|
92
|
+
*/
|
|
93
|
+
function unionOf(type, conv) {
|
|
94
|
+
const members = type.types.filter((one) => !(one.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null))).map((one) => toSpec(one, conv));
|
|
95
|
+
if (members.length === 0) return {
|
|
96
|
+
kind: "prim",
|
|
97
|
+
name: "null"
|
|
98
|
+
};
|
|
99
|
+
return members.length === 1 ? members[0] : {
|
|
100
|
+
kind: "union",
|
|
101
|
+
members
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** An array, a callable, or an object with properties, in that order. */
|
|
105
|
+
function structural(type, conv) {
|
|
106
|
+
const element = elementOf(type, conv);
|
|
107
|
+
if (element) return {
|
|
108
|
+
kind: "list",
|
|
109
|
+
element
|
|
110
|
+
};
|
|
111
|
+
const calls = conv.checker.getSignaturesOfType(type, ts.SignatureKind.Call);
|
|
112
|
+
if (calls.length > 0) return fnSpec(calls[0], conv);
|
|
113
|
+
return type.getProperties().length > 0 ? recordSpec(type, conv) : DYNAMIC;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* What a list holds, when the type is one.
|
|
117
|
+
*
|
|
118
|
+
* A tuple reads as a list of whatever its slots hold together: the language has
|
|
119
|
+
* no fixed-length list, and `list<string | number>` is nearer the truth than
|
|
120
|
+
* saying nothing at all.
|
|
121
|
+
*/
|
|
122
|
+
function elementOf(type, conv) {
|
|
123
|
+
const checker = conv.checker;
|
|
124
|
+
if (checker.isArrayType(type)) {
|
|
125
|
+
const [arg] = checker.getTypeArguments(type);
|
|
126
|
+
return arg ? toSpec(arg, conv) : DYNAMIC;
|
|
127
|
+
}
|
|
128
|
+
if (!checker.isTupleType(type)) return void 0;
|
|
129
|
+
const members = checker.getTypeArguments(type).map((one) => toSpec(one, conv));
|
|
130
|
+
if (members.length === 0) return DYNAMIC;
|
|
131
|
+
return members.length === 1 ? members[0] : {
|
|
132
|
+
kind: "union",
|
|
133
|
+
members
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/convert/shapes.ts
|
|
138
|
+
/**
|
|
139
|
+
* A callable, as the language writes one.
|
|
140
|
+
*
|
|
141
|
+
* The first signature only. An overloaded function has several and the language
|
|
142
|
+
* has one shape for a function; the first is what TypeScript itself shows on
|
|
143
|
+
* hover, so a reader sees the same thing in both editors rather than two
|
|
144
|
+
* different half-truths.
|
|
145
|
+
*/
|
|
146
|
+
function fnSpec(signature, conv) {
|
|
147
|
+
return {
|
|
148
|
+
kind: "fn",
|
|
149
|
+
params: signature.getParameters().map((param) => paramSpec(param, conv)),
|
|
150
|
+
result: toSpec(conv.checker.getReturnTypeOfSignature(signature), conv),
|
|
151
|
+
takes: requiredCount(signature)
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* How many arguments the caller must actually pass.
|
|
156
|
+
*
|
|
157
|
+
* Read from the declarations rather than from the checker's own count, which is
|
|
158
|
+
* not part of its public surface. A parameter stops being required once it
|
|
159
|
+
* carries a `?`, a default or a `...`, and so does everything after it.
|
|
160
|
+
*/
|
|
161
|
+
function requiredCount(signature) {
|
|
162
|
+
const params = signature.getParameters();
|
|
163
|
+
let required = 0;
|
|
164
|
+
for (const param of params) {
|
|
165
|
+
if (isOptional(param)) break;
|
|
166
|
+
required++;
|
|
167
|
+
}
|
|
168
|
+
return required;
|
|
169
|
+
}
|
|
170
|
+
function isOptional(param) {
|
|
171
|
+
const decl = param.valueDeclaration ?? param.declarations?.[0];
|
|
172
|
+
if (!decl || !ts.isParameter(decl)) return false;
|
|
173
|
+
return Boolean(decl.questionToken ?? decl.initializer ?? decl.dotDotDotToken);
|
|
174
|
+
}
|
|
175
|
+
function paramSpec(param, conv) {
|
|
176
|
+
const decl = param.valueDeclaration ?? param.declarations?.[0];
|
|
177
|
+
if (!decl) return { kind: "dynamic" };
|
|
178
|
+
return toSpec(conv.checker.getTypeOfSymbolAtLocation(param, decl), conv);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* An object, as a record of what it holds.
|
|
182
|
+
*
|
|
183
|
+
* A method is a field whose type is a function, which is what it is in this
|
|
184
|
+
* language too. So `schema.parse` is reached the same way `schema.shape` is,
|
|
185
|
+
* and the reader is not asked to know which of the two the author chose.
|
|
186
|
+
*/
|
|
187
|
+
function recordSpec(type, conv) {
|
|
188
|
+
const fields = {};
|
|
189
|
+
for (const property of type.getProperties()) {
|
|
190
|
+
const spec = propertySpec(property, conv);
|
|
191
|
+
if (spec) fields[property.getName()] = spec;
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
kind: "record",
|
|
195
|
+
fields,
|
|
196
|
+
open: true
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function propertySpec(property, conv) {
|
|
200
|
+
const decl = property.valueDeclaration ?? property.declarations?.[0];
|
|
201
|
+
if (!decl) return void 0;
|
|
202
|
+
return toSpec(conv.checker.getTypeOfSymbolAtLocation(property, decl), conv);
|
|
203
|
+
}
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/read-package-types.ts
|
|
206
|
+
/**
|
|
207
|
+
* The types an installed package publishes.
|
|
208
|
+
*
|
|
209
|
+
* Read through the TypeScript compiler rather than by parsing `.d.ts` text: a
|
|
210
|
+
* modern package's exported types are built out of generics, conditionals and
|
|
211
|
+
* mapped types, which have no meaning until something resolves them. Parsing
|
|
212
|
+
* gives back the machinery; asking the compiler gives back the answer.
|
|
213
|
+
*
|
|
214
|
+
* The compiler comes in under the alias `tsc-api`, pinned to the 5.x line,
|
|
215
|
+
* because TypeScript 7 is Go-native and ships no JavaScript API until 7.1. So
|
|
216
|
+
* 7 builds this repo and 5.9 is a library this one package calls.
|
|
217
|
+
*
|
|
218
|
+
* @returns every export as a {@link TypeSpec}, with a count of how many came
|
|
219
|
+
* back `dynamic`. A package that ships no types is not an error: `exports` is
|
|
220
|
+
* empty and `covered.total` is zero.
|
|
221
|
+
*/
|
|
222
|
+
function readPackageTypes(args) {
|
|
223
|
+
const declarations = declarationFile(args);
|
|
224
|
+
if (!declarations) return empty(args.package);
|
|
225
|
+
const program = ts.createProgram([declarations], OPTIONS);
|
|
226
|
+
const source = program.getSourceFile(declarations);
|
|
227
|
+
const checker = program.getTypeChecker();
|
|
228
|
+
const symbol = source && checker.getSymbolAtLocation(source);
|
|
229
|
+
if (!symbol) return empty(args.package);
|
|
230
|
+
return counted(args.package, exportsOf({
|
|
231
|
+
symbol,
|
|
232
|
+
checker
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
const OPTIONS = {
|
|
236
|
+
target: ts.ScriptTarget.ESNext,
|
|
237
|
+
module: ts.ModuleKind.ESNext,
|
|
238
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
239
|
+
skipLibCheck: true,
|
|
240
|
+
strict: true
|
|
241
|
+
};
|
|
242
|
+
/** Where the package's declarations are, as TypeScript itself resolves them. */
|
|
243
|
+
function declarationFile(args) {
|
|
244
|
+
const host = ts.createCompilerHost(OPTIONS);
|
|
245
|
+
const file = ts.resolveModuleName(args.package, args.from, OPTIONS, host).resolvedModule?.resolvedFileName;
|
|
246
|
+
return file && /\.d\.[cm]?ts$/.test(file) ? file : void 0;
|
|
247
|
+
}
|
|
248
|
+
function exportsOf(args) {
|
|
249
|
+
const out = {};
|
|
250
|
+
const conv = {
|
|
251
|
+
checker: args.checker,
|
|
252
|
+
depth: 0,
|
|
253
|
+
state: newState()
|
|
254
|
+
};
|
|
255
|
+
for (const each of args.checker.getExportsOfModule(args.symbol)) {
|
|
256
|
+
const decl = each.valueDeclaration ?? each.declarations?.[0];
|
|
257
|
+
if (!decl) continue;
|
|
258
|
+
out[each.getName()] = toSpec(args.checker.getTypeOfSymbolAtLocation(each, decl), conv);
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* How much of the package came across.
|
|
264
|
+
*
|
|
265
|
+
* Counted and reported rather than claimed: "94% of exports typed" can be
|
|
266
|
+
* checked and driven up, unlike "fully compatible".
|
|
267
|
+
*/
|
|
268
|
+
function counted(name, exports) {
|
|
269
|
+
const all = Object.values(exports);
|
|
270
|
+
return {
|
|
271
|
+
package: name,
|
|
272
|
+
exports,
|
|
273
|
+
covered: {
|
|
274
|
+
total: all.length,
|
|
275
|
+
dynamic: all.filter((one) => one.kind === "dynamic").length
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function empty(name) {
|
|
280
|
+
return {
|
|
281
|
+
package: name,
|
|
282
|
+
exports: {},
|
|
283
|
+
covered: {
|
|
284
|
+
total: 0,
|
|
285
|
+
dynamic: 0
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
//#endregion
|
|
290
|
+
export { readPackageTypes, toSpec };
|
|
291
|
+
|
|
292
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/convert/to-spec.ts","../src/convert/shapes.ts","../src/read-package-types.ts"],"sourcesContent":["import type { TypeSpec } from \"@venn-lang/types\";\nimport ts from \"tsc-api\";\nimport { fnSpec, recordSpec } from \"./shapes.js\";\n\n/** How far into a type to look, and how much of one to read at all. */\nconst DEPTH = 4;\nconst BUDGET = 20_000;\n\nconst DYNAMIC: TypeSpec = { kind: \"dynamic\" };\n\n/**\n * What one conversion carries: the checker, how deep it is, and what it has\n * already worked out.\n *\n * The shared state is not an optimisation. A real package's types form a graph,\n * not a tree (`zod`'s `z` holds hundreds of members, most of them leading back\n * to the same handful of shapes), so a branch read on its own re-expands the\n * same types until the machine runs out of memory. Reading each type once, and\n * stopping at a budget, is what makes this finish.\n */\nexport interface Conversion {\n checker: ts.TypeChecker;\n depth: number;\n state: ConversionState;\n}\n\n/** What every branch of one conversion shares. */\nexport interface ConversionState {\n done: Map<ts.Type, TypeSpec>;\n /** Types being read right now, so one that contains itself does not loop. */\n open: Set<ts.Type>;\n /** How many more types may be read before the rest is `dynamic`. */\n left: number;\n}\n\n/** A state with nothing read yet and the full budget left. */\nexport function newState(): ConversionState {\n return { done: new Map(), open: new Set(), left: BUDGET };\n}\n\n/**\n * One TypeScript type, as the language's own.\n *\n * Everything TS can say projects onto the ten shapes or degrades to `dynamic`,\n * never to a failure. Generics, conditionals and mapped types are gone by the\n * time anything reaches here: the compiler resolved them, so what arrives is the\n * answer rather than the machinery.\n *\n * @param type a type the checker has already resolved\n * @param conv the checker, the current depth and the state shared across branches\n * @returns the type as a `TypeSpec`, or `dynamic` once the depth, the budget or\n * a cycle stops the reading\n */\nexport function toSpec(type: ts.Type, conv: Conversion): TypeSpec {\n const known = conv.state.done.get(type);\n if (known) return known;\n if (conv.depth > DEPTH || conv.state.left <= 0 || conv.state.open.has(type)) return DYNAMIC;\n conv.state.left--;\n conv.state.open.add(type);\n const spec = convert(type, conv);\n conv.state.open.delete(type);\n // Only a reading that ran to the end is worth keeping: one cut short by the\n // depth limit says more about where it was met than about the type.\n if (conv.depth < DEPTH) conv.state.done.set(type, spec);\n return spec;\n}\n\nfunction convert(type: ts.Type, conv: Conversion): TypeSpec {\n const flags = type.flags;\n const primitive = primitiveOf(flags);\n if (primitive) return primitive;\n if (flags & ts.TypeFlags.StringLiteral) return literal((type as ts.StringLiteralType).value);\n if (flags & ts.TypeFlags.NumberLiteral) return literal((type as ts.NumberLiteralType).value);\n if (flags & ts.TypeFlags.Union) return unionOf(type as ts.UnionType, deeper(conv));\n return structural(type, deeper(conv));\n}\n\nfunction deeper(conv: Conversion): Conversion {\n return { ...conv, depth: conv.depth + 1 };\n}\n\nconst PRIMITIVES: [ts.TypeFlags, TypeSpec][] = [\n [ts.TypeFlags.String, { kind: \"prim\", name: \"string\" }],\n [ts.TypeFlags.Number, { kind: \"prim\", name: \"number\" }],\n [ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral, { kind: \"prim\", name: \"bool\" }],\n [ts.TypeFlags.Void | ts.TypeFlags.Undefined, { kind: \"prim\", name: \"void\" }],\n [ts.TypeFlags.Null, { kind: \"prim\", name: \"null\" }],\n [ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never, DYNAMIC],\n];\n\nfunction primitiveOf(flags: ts.TypeFlags): TypeSpec | undefined {\n for (const [mask, spec] of PRIMITIVES) {\n if (flags & mask) return spec;\n }\n return undefined;\n}\n\nfunction literal(value: string | number): TypeSpec {\n return { kind: \"literal\", value };\n}\n\n/**\n * A union, with the branches TypeScript adds for absence folded away.\n *\n * `string | undefined` is one type in this language, a string that may not be\n * there. Writing it as a two-branch union would make every optional field\n * something the reader has to take apart before using.\n */\nfunction unionOf(type: ts.UnionType, conv: Conversion): TypeSpec {\n const members = type.types\n .filter((one) => !(one.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)))\n .map((one) => toSpec(one, conv));\n if (members.length === 0) return { kind: \"prim\", name: \"null\" };\n return members.length === 1 ? (members[0] as TypeSpec) : { kind: \"union\", members };\n}\n\n/** An array, a callable, or an object with properties, in that order. */\nfunction structural(type: ts.Type, conv: Conversion): TypeSpec {\n const element = elementOf(type, conv);\n if (element) return { kind: \"list\", element };\n const calls = conv.checker.getSignaturesOfType(type, ts.SignatureKind.Call);\n if (calls.length > 0) return fnSpec(calls[0] as ts.Signature, conv);\n return type.getProperties().length > 0 ? recordSpec(type, conv) : DYNAMIC;\n}\n\n/**\n * What a list holds, when the type is one.\n *\n * A tuple reads as a list of whatever its slots hold together: the language has\n * no fixed-length list, and `list<string | number>` is nearer the truth than\n * saying nothing at all.\n */\nfunction elementOf(type: ts.Type, conv: Conversion): TypeSpec | undefined {\n const checker = conv.checker;\n if (checker.isArrayType(type)) {\n const [arg] = checker.getTypeArguments(type as ts.TypeReference);\n return arg ? toSpec(arg, conv) : DYNAMIC;\n }\n if (!checker.isTupleType(type)) return undefined;\n const members = checker\n .getTypeArguments(type as ts.TypeReference)\n .map((one) => toSpec(one, conv));\n if (members.length === 0) return DYNAMIC;\n return members.length === 1 ? (members[0] as TypeSpec) : { kind: \"union\", members };\n}\n","import type { TypeSpec } from \"@venn-lang/types\";\nimport ts from \"tsc-api\";\nimport { type Conversion, toSpec } from \"./to-spec.js\";\n\n/**\n * A callable, as the language writes one.\n *\n * The first signature only. An overloaded function has several and the language\n * has one shape for a function; the first is what TypeScript itself shows on\n * hover, so a reader sees the same thing in both editors rather than two\n * different half-truths.\n */\nexport function fnSpec(signature: ts.Signature, conv: Conversion): TypeSpec {\n const params = signature.getParameters().map((param) => paramSpec(param, conv));\n const result = toSpec(conv.checker.getReturnTypeOfSignature(signature), conv);\n // Optional parameters are ordinary in TypeScript and this language checks\n // arity exactly, so `takes` is what keeps a call with no arguments legal\n // against a signature whose only parameter is optional, as in `z.string()`.\n return { kind: \"fn\", params, result, takes: requiredCount(signature) };\n}\n\n/**\n * How many arguments the caller must actually pass.\n *\n * Read from the declarations rather than from the checker's own count, which is\n * not part of its public surface. A parameter stops being required once it\n * carries a `?`, a default or a `...`, and so does everything after it.\n */\nfunction requiredCount(signature: ts.Signature): number {\n const params = signature.getParameters();\n let required = 0;\n for (const param of params) {\n if (isOptional(param)) break;\n required++;\n }\n return required;\n}\n\nfunction isOptional(param: ts.Symbol): boolean {\n const decl = param.valueDeclaration ?? param.declarations?.[0];\n if (!decl || !ts.isParameter(decl)) return false;\n return Boolean(decl.questionToken ?? decl.initializer ?? decl.dotDotDotToken);\n}\n\nfunction paramSpec(param: ts.Symbol, conv: Conversion): TypeSpec {\n const decl = param.valueDeclaration ?? param.declarations?.[0];\n if (!decl) return { kind: \"dynamic\" };\n return toSpec(conv.checker.getTypeOfSymbolAtLocation(param, decl), conv);\n}\n\n/**\n * An object, as a record of what it holds.\n *\n * A method is a field whose type is a function, which is what it is in this\n * language too. So `schema.parse` is reached the same way `schema.shape` is,\n * and the reader is not asked to know which of the two the author chose.\n */\nexport function recordSpec(type: ts.Type, conv: Conversion): TypeSpec {\n const fields: Record<string, TypeSpec> = {};\n for (const property of type.getProperties()) {\n const spec = propertySpec(property, conv);\n if (spec) fields[property.getName()] = spec;\n }\n // Open, because a package's own type is the authority on what it holds and\n // this reading of it is not: anything missed should be reachable, not refused.\n return { kind: \"record\", fields, open: true };\n}\n\nfunction propertySpec(property: ts.Symbol, conv: Conversion): TypeSpec | undefined {\n const decl = property.valueDeclaration ?? property.declarations?.[0];\n if (!decl) return undefined;\n return toSpec(conv.checker.getTypeOfSymbolAtLocation(property, decl), conv);\n}\n","import type { TypeSpec } from \"@venn-lang/types\";\nimport ts from \"tsc-api\";\nimport { newState, toSpec } from \"./convert/index.js\";\n\n/** What one installed package publishes, as this language's types. */\nexport interface PackageTypes {\n package: string;\n /** Each export, by the name it is imported under. */\n exports: Record<string, TypeSpec>;\n /** How many exports were read, and how many came back `dynamic`. */\n covered: { total: number; dynamic: number };\n}\n\n/**\n * The types an installed package publishes.\n *\n * Read through the TypeScript compiler rather than by parsing `.d.ts` text: a\n * modern package's exported types are built out of generics, conditionals and\n * mapped types, which have no meaning until something resolves them. Parsing\n * gives back the machinery; asking the compiler gives back the answer.\n *\n * The compiler comes in under the alias `tsc-api`, pinned to the 5.x line,\n * because TypeScript 7 is Go-native and ships no JavaScript API until 7.1. So\n * 7 builds this repo and 5.9 is a library this one package calls.\n *\n * @returns every export as a {@link TypeSpec}, with a count of how many came\n * back `dynamic`. A package that ships no types is not an error: `exports` is\n * empty and `covered.total` is zero.\n */\nexport function readPackageTypes(args: {\n /** The package name, as it is imported. */\n package: string;\n /** A file to resolve from, such as the generated `package.json` in `target/`. */\n from: string;\n}): PackageTypes {\n const declarations = declarationFile(args);\n if (!declarations) return empty(args.package);\n const program = ts.createProgram([declarations], OPTIONS);\n const source = program.getSourceFile(declarations);\n const checker = program.getTypeChecker();\n const symbol = source && checker.getSymbolAtLocation(source);\n if (!symbol) return empty(args.package);\n return counted(args.package, exportsOf({ symbol, checker }));\n}\n\nconst OPTIONS: ts.CompilerOptions = {\n target: ts.ScriptTarget.ESNext,\n module: ts.ModuleKind.ESNext,\n moduleResolution: ts.ModuleResolutionKind.Bundler,\n skipLibCheck: true,\n strict: true,\n};\n\n/** Where the package's declarations are, as TypeScript itself resolves them. */\nfunction declarationFile(args: { package: string; from: string }): string | undefined {\n const host = ts.createCompilerHost(OPTIONS);\n const found = ts.resolveModuleName(args.package, args.from, OPTIONS, host);\n const file = found.resolvedModule?.resolvedFileName;\n // `.d.ts`, `.d.mts` and `.d.cts` are all declarations. zod ships the last of\n // the three, so a filter naming only the first two finds nothing at all.\n return file && /\\.d\\.[cm]?ts$/.test(file) ? file : undefined;\n}\n\nfunction exportsOf(args: { symbol: ts.Symbol; checker: ts.TypeChecker }): Record<string, TypeSpec> {\n const out: Record<string, TypeSpec> = {};\n const conv = { checker: args.checker, depth: 0, state: newState() };\n for (const each of args.checker.getExportsOfModule(args.symbol)) {\n const decl = each.valueDeclaration ?? each.declarations?.[0];\n if (!decl) continue;\n out[each.getName()] = toSpec(args.checker.getTypeOfSymbolAtLocation(each, decl), conv);\n }\n return out;\n}\n\n/**\n * How much of the package came across.\n *\n * Counted and reported rather than claimed: \"94% of exports typed\" can be\n * checked and driven up, unlike \"fully compatible\".\n */\nfunction counted(name: string, exports: Record<string, TypeSpec>): PackageTypes {\n const all = Object.values(exports);\n return {\n package: name,\n exports,\n covered: { total: all.length, dynamic: all.filter((one) => one.kind === \"dynamic\").length },\n };\n}\n\nfunction empty(name: string): PackageTypes {\n return { package: name, exports: {}, covered: { total: 0, dynamic: 0 } };\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@venn-lang/dts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reads the types an installed npm package publishes and returns them as Venn TypeSpecs.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"venn",
|
|
7
|
+
"testing",
|
|
8
|
+
"e2e",
|
|
9
|
+
"typescript",
|
|
10
|
+
"declarations"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/venn-lang/venn/tree/main/packages/dts#readme",
|
|
13
|
+
"bugs": "https://github.com/venn-lang/venn/issues",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/venn-lang/venn.git",
|
|
17
|
+
"directory": "packages/dts"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Vinicius Borges",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"development": "./src/index.ts",
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
"import": "./dist/index.mjs",
|
|
28
|
+
"default": "./dist/index.mjs"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src",
|
|
34
|
+
"!src/**/*.test.ts",
|
|
35
|
+
"!src/**/*.suite.ts"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"tsc-api": "npm:typescript@^5.9.0",
|
|
42
|
+
"@venn-lang/types": "0.1.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^24",
|
|
46
|
+
"tsdown": "^0.22.14",
|
|
47
|
+
"vitest": "^4.1.10"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsdown",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"typecheck": "tsc --noEmit"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { TypeSpec } from "@venn-lang/types";
|
|
2
|
+
import ts from "tsc-api";
|
|
3
|
+
import { type Conversion, toSpec } from "./to-spec.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A callable, as the language writes one.
|
|
7
|
+
*
|
|
8
|
+
* The first signature only. An overloaded function has several and the language
|
|
9
|
+
* has one shape for a function; the first is what TypeScript itself shows on
|
|
10
|
+
* hover, so a reader sees the same thing in both editors rather than two
|
|
11
|
+
* different half-truths.
|
|
12
|
+
*/
|
|
13
|
+
export function fnSpec(signature: ts.Signature, conv: Conversion): TypeSpec {
|
|
14
|
+
const params = signature.getParameters().map((param) => paramSpec(param, conv));
|
|
15
|
+
const result = toSpec(conv.checker.getReturnTypeOfSignature(signature), conv);
|
|
16
|
+
// Optional parameters are ordinary in TypeScript and this language checks
|
|
17
|
+
// arity exactly, so `takes` is what keeps a call with no arguments legal
|
|
18
|
+
// against a signature whose only parameter is optional, as in `z.string()`.
|
|
19
|
+
return { kind: "fn", params, result, takes: requiredCount(signature) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How many arguments the caller must actually pass.
|
|
24
|
+
*
|
|
25
|
+
* Read from the declarations rather than from the checker's own count, which is
|
|
26
|
+
* not part of its public surface. A parameter stops being required once it
|
|
27
|
+
* carries a `?`, a default or a `...`, and so does everything after it.
|
|
28
|
+
*/
|
|
29
|
+
function requiredCount(signature: ts.Signature): number {
|
|
30
|
+
const params = signature.getParameters();
|
|
31
|
+
let required = 0;
|
|
32
|
+
for (const param of params) {
|
|
33
|
+
if (isOptional(param)) break;
|
|
34
|
+
required++;
|
|
35
|
+
}
|
|
36
|
+
return required;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isOptional(param: ts.Symbol): boolean {
|
|
40
|
+
const decl = param.valueDeclaration ?? param.declarations?.[0];
|
|
41
|
+
if (!decl || !ts.isParameter(decl)) return false;
|
|
42
|
+
return Boolean(decl.questionToken ?? decl.initializer ?? decl.dotDotDotToken);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function paramSpec(param: ts.Symbol, conv: Conversion): TypeSpec {
|
|
46
|
+
const decl = param.valueDeclaration ?? param.declarations?.[0];
|
|
47
|
+
if (!decl) return { kind: "dynamic" };
|
|
48
|
+
return toSpec(conv.checker.getTypeOfSymbolAtLocation(param, decl), conv);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* An object, as a record of what it holds.
|
|
53
|
+
*
|
|
54
|
+
* A method is a field whose type is a function, which is what it is in this
|
|
55
|
+
* language too. So `schema.parse` is reached the same way `schema.shape` is,
|
|
56
|
+
* and the reader is not asked to know which of the two the author chose.
|
|
57
|
+
*/
|
|
58
|
+
export function recordSpec(type: ts.Type, conv: Conversion): TypeSpec {
|
|
59
|
+
const fields: Record<string, TypeSpec> = {};
|
|
60
|
+
for (const property of type.getProperties()) {
|
|
61
|
+
const spec = propertySpec(property, conv);
|
|
62
|
+
if (spec) fields[property.getName()] = spec;
|
|
63
|
+
}
|
|
64
|
+
// Open, because a package's own type is the authority on what it holds and
|
|
65
|
+
// this reading of it is not: anything missed should be reachable, not refused.
|
|
66
|
+
return { kind: "record", fields, open: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function propertySpec(property: ts.Symbol, conv: Conversion): TypeSpec | undefined {
|
|
70
|
+
const decl = property.valueDeclaration ?? property.declarations?.[0];
|
|
71
|
+
if (!decl) return undefined;
|
|
72
|
+
return toSpec(conv.checker.getTypeOfSymbolAtLocation(property, decl), conv);
|
|
73
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { TypeSpec } from "@venn-lang/types";
|
|
2
|
+
import ts from "tsc-api";
|
|
3
|
+
import { fnSpec, recordSpec } from "./shapes.js";
|
|
4
|
+
|
|
5
|
+
/** How far into a type to look, and how much of one to read at all. */
|
|
6
|
+
const DEPTH = 4;
|
|
7
|
+
const BUDGET = 20_000;
|
|
8
|
+
|
|
9
|
+
const DYNAMIC: TypeSpec = { kind: "dynamic" };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What one conversion carries: the checker, how deep it is, and what it has
|
|
13
|
+
* already worked out.
|
|
14
|
+
*
|
|
15
|
+
* The shared state is not an optimisation. A real package's types form a graph,
|
|
16
|
+
* not a tree (`zod`'s `z` holds hundreds of members, most of them leading back
|
|
17
|
+
* to the same handful of shapes), so a branch read on its own re-expands the
|
|
18
|
+
* same types until the machine runs out of memory. Reading each type once, and
|
|
19
|
+
* stopping at a budget, is what makes this finish.
|
|
20
|
+
*/
|
|
21
|
+
export interface Conversion {
|
|
22
|
+
checker: ts.TypeChecker;
|
|
23
|
+
depth: number;
|
|
24
|
+
state: ConversionState;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What every branch of one conversion shares. */
|
|
28
|
+
export interface ConversionState {
|
|
29
|
+
done: Map<ts.Type, TypeSpec>;
|
|
30
|
+
/** Types being read right now, so one that contains itself does not loop. */
|
|
31
|
+
open: Set<ts.Type>;
|
|
32
|
+
/** How many more types may be read before the rest is `dynamic`. */
|
|
33
|
+
left: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A state with nothing read yet and the full budget left. */
|
|
37
|
+
export function newState(): ConversionState {
|
|
38
|
+
return { done: new Map(), open: new Set(), left: BUDGET };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One TypeScript type, as the language's own.
|
|
43
|
+
*
|
|
44
|
+
* Everything TS can say projects onto the ten shapes or degrades to `dynamic`,
|
|
45
|
+
* never to a failure. Generics, conditionals and mapped types are gone by the
|
|
46
|
+
* time anything reaches here: the compiler resolved them, so what arrives is the
|
|
47
|
+
* answer rather than the machinery.
|
|
48
|
+
*
|
|
49
|
+
* @param type a type the checker has already resolved
|
|
50
|
+
* @param conv the checker, the current depth and the state shared across branches
|
|
51
|
+
* @returns the type as a `TypeSpec`, or `dynamic` once the depth, the budget or
|
|
52
|
+
* a cycle stops the reading
|
|
53
|
+
*/
|
|
54
|
+
export function toSpec(type: ts.Type, conv: Conversion): TypeSpec {
|
|
55
|
+
const known = conv.state.done.get(type);
|
|
56
|
+
if (known) return known;
|
|
57
|
+
if (conv.depth > DEPTH || conv.state.left <= 0 || conv.state.open.has(type)) return DYNAMIC;
|
|
58
|
+
conv.state.left--;
|
|
59
|
+
conv.state.open.add(type);
|
|
60
|
+
const spec = convert(type, conv);
|
|
61
|
+
conv.state.open.delete(type);
|
|
62
|
+
// Only a reading that ran to the end is worth keeping: one cut short by the
|
|
63
|
+
// depth limit says more about where it was met than about the type.
|
|
64
|
+
if (conv.depth < DEPTH) conv.state.done.set(type, spec);
|
|
65
|
+
return spec;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function convert(type: ts.Type, conv: Conversion): TypeSpec {
|
|
69
|
+
const flags = type.flags;
|
|
70
|
+
const primitive = primitiveOf(flags);
|
|
71
|
+
if (primitive) return primitive;
|
|
72
|
+
if (flags & ts.TypeFlags.StringLiteral) return literal((type as ts.StringLiteralType).value);
|
|
73
|
+
if (flags & ts.TypeFlags.NumberLiteral) return literal((type as ts.NumberLiteralType).value);
|
|
74
|
+
if (flags & ts.TypeFlags.Union) return unionOf(type as ts.UnionType, deeper(conv));
|
|
75
|
+
return structural(type, deeper(conv));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function deeper(conv: Conversion): Conversion {
|
|
79
|
+
return { ...conv, depth: conv.depth + 1 };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const PRIMITIVES: [ts.TypeFlags, TypeSpec][] = [
|
|
83
|
+
[ts.TypeFlags.String, { kind: "prim", name: "string" }],
|
|
84
|
+
[ts.TypeFlags.Number, { kind: "prim", name: "number" }],
|
|
85
|
+
[ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral, { kind: "prim", name: "bool" }],
|
|
86
|
+
[ts.TypeFlags.Void | ts.TypeFlags.Undefined, { kind: "prim", name: "void" }],
|
|
87
|
+
[ts.TypeFlags.Null, { kind: "prim", name: "null" }],
|
|
88
|
+
[ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never, DYNAMIC],
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
function primitiveOf(flags: ts.TypeFlags): TypeSpec | undefined {
|
|
92
|
+
for (const [mask, spec] of PRIMITIVES) {
|
|
93
|
+
if (flags & mask) return spec;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function literal(value: string | number): TypeSpec {
|
|
99
|
+
return { kind: "literal", value };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A union, with the branches TypeScript adds for absence folded away.
|
|
104
|
+
*
|
|
105
|
+
* `string | undefined` is one type in this language, a string that may not be
|
|
106
|
+
* there. Writing it as a two-branch union would make every optional field
|
|
107
|
+
* something the reader has to take apart before using.
|
|
108
|
+
*/
|
|
109
|
+
function unionOf(type: ts.UnionType, conv: Conversion): TypeSpec {
|
|
110
|
+
const members = type.types
|
|
111
|
+
.filter((one) => !(one.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)))
|
|
112
|
+
.map((one) => toSpec(one, conv));
|
|
113
|
+
if (members.length === 0) return { kind: "prim", name: "null" };
|
|
114
|
+
return members.length === 1 ? (members[0] as TypeSpec) : { kind: "union", members };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** An array, a callable, or an object with properties, in that order. */
|
|
118
|
+
function structural(type: ts.Type, conv: Conversion): TypeSpec {
|
|
119
|
+
const element = elementOf(type, conv);
|
|
120
|
+
if (element) return { kind: "list", element };
|
|
121
|
+
const calls = conv.checker.getSignaturesOfType(type, ts.SignatureKind.Call);
|
|
122
|
+
if (calls.length > 0) return fnSpec(calls[0] as ts.Signature, conv);
|
|
123
|
+
return type.getProperties().length > 0 ? recordSpec(type, conv) : DYNAMIC;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What a list holds, when the type is one.
|
|
128
|
+
*
|
|
129
|
+
* A tuple reads as a list of whatever its slots hold together: the language has
|
|
130
|
+
* no fixed-length list, and `list<string | number>` is nearer the truth than
|
|
131
|
+
* saying nothing at all.
|
|
132
|
+
*/
|
|
133
|
+
function elementOf(type: ts.Type, conv: Conversion): TypeSpec | undefined {
|
|
134
|
+
const checker = conv.checker;
|
|
135
|
+
if (checker.isArrayType(type)) {
|
|
136
|
+
const [arg] = checker.getTypeArguments(type as ts.TypeReference);
|
|
137
|
+
return arg ? toSpec(arg, conv) : DYNAMIC;
|
|
138
|
+
}
|
|
139
|
+
if (!checker.isTupleType(type)) return undefined;
|
|
140
|
+
const members = checker
|
|
141
|
+
.getTypeArguments(type as ts.TypeReference)
|
|
142
|
+
.map((one) => toSpec(one, conv));
|
|
143
|
+
if (members.length === 0) return DYNAMIC;
|
|
144
|
+
return members.length === 1 ? (members[0] as TypeSpec) : { kind: "union", members };
|
|
145
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// @venn-lang/dts: the types an installed npm package publishes, read through the
|
|
2
|
+
// TypeScript compiler. Node-only, since it loads the compiler itself.
|
|
3
|
+
export { type Conversion, toSpec } from "./convert/index.js";
|
|
4
|
+
export { type PackageTypes, readPackageTypes } from "./read-package-types.js";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { TypeSpec } from "@venn-lang/types";
|
|
2
|
+
import ts from "tsc-api";
|
|
3
|
+
import { newState, toSpec } from "./convert/index.js";
|
|
4
|
+
|
|
5
|
+
/** What one installed package publishes, as this language's types. */
|
|
6
|
+
export interface PackageTypes {
|
|
7
|
+
package: string;
|
|
8
|
+
/** Each export, by the name it is imported under. */
|
|
9
|
+
exports: Record<string, TypeSpec>;
|
|
10
|
+
/** How many exports were read, and how many came back `dynamic`. */
|
|
11
|
+
covered: { total: number; dynamic: number };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The types an installed package publishes.
|
|
16
|
+
*
|
|
17
|
+
* Read through the TypeScript compiler rather than by parsing `.d.ts` text: a
|
|
18
|
+
* modern package's exported types are built out of generics, conditionals and
|
|
19
|
+
* mapped types, which have no meaning until something resolves them. Parsing
|
|
20
|
+
* gives back the machinery; asking the compiler gives back the answer.
|
|
21
|
+
*
|
|
22
|
+
* The compiler comes in under the alias `tsc-api`, pinned to the 5.x line,
|
|
23
|
+
* because TypeScript 7 is Go-native and ships no JavaScript API until 7.1. So
|
|
24
|
+
* 7 builds this repo and 5.9 is a library this one package calls.
|
|
25
|
+
*
|
|
26
|
+
* @returns every export as a {@link TypeSpec}, with a count of how many came
|
|
27
|
+
* back `dynamic`. A package that ships no types is not an error: `exports` is
|
|
28
|
+
* empty and `covered.total` is zero.
|
|
29
|
+
*/
|
|
30
|
+
export function readPackageTypes(args: {
|
|
31
|
+
/** The package name, as it is imported. */
|
|
32
|
+
package: string;
|
|
33
|
+
/** A file to resolve from, such as the generated `package.json` in `target/`. */
|
|
34
|
+
from: string;
|
|
35
|
+
}): PackageTypes {
|
|
36
|
+
const declarations = declarationFile(args);
|
|
37
|
+
if (!declarations) return empty(args.package);
|
|
38
|
+
const program = ts.createProgram([declarations], OPTIONS);
|
|
39
|
+
const source = program.getSourceFile(declarations);
|
|
40
|
+
const checker = program.getTypeChecker();
|
|
41
|
+
const symbol = source && checker.getSymbolAtLocation(source);
|
|
42
|
+
if (!symbol) return empty(args.package);
|
|
43
|
+
return counted(args.package, exportsOf({ symbol, checker }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const OPTIONS: ts.CompilerOptions = {
|
|
47
|
+
target: ts.ScriptTarget.ESNext,
|
|
48
|
+
module: ts.ModuleKind.ESNext,
|
|
49
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
50
|
+
skipLibCheck: true,
|
|
51
|
+
strict: true,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Where the package's declarations are, as TypeScript itself resolves them. */
|
|
55
|
+
function declarationFile(args: { package: string; from: string }): string | undefined {
|
|
56
|
+
const host = ts.createCompilerHost(OPTIONS);
|
|
57
|
+
const found = ts.resolveModuleName(args.package, args.from, OPTIONS, host);
|
|
58
|
+
const file = found.resolvedModule?.resolvedFileName;
|
|
59
|
+
// `.d.ts`, `.d.mts` and `.d.cts` are all declarations. zod ships the last of
|
|
60
|
+
// the three, so a filter naming only the first two finds nothing at all.
|
|
61
|
+
return file && /\.d\.[cm]?ts$/.test(file) ? file : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function exportsOf(args: { symbol: ts.Symbol; checker: ts.TypeChecker }): Record<string, TypeSpec> {
|
|
65
|
+
const out: Record<string, TypeSpec> = {};
|
|
66
|
+
const conv = { checker: args.checker, depth: 0, state: newState() };
|
|
67
|
+
for (const each of args.checker.getExportsOfModule(args.symbol)) {
|
|
68
|
+
const decl = each.valueDeclaration ?? each.declarations?.[0];
|
|
69
|
+
if (!decl) continue;
|
|
70
|
+
out[each.getName()] = toSpec(args.checker.getTypeOfSymbolAtLocation(each, decl), conv);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How much of the package came across.
|
|
77
|
+
*
|
|
78
|
+
* Counted and reported rather than claimed: "94% of exports typed" can be
|
|
79
|
+
* checked and driven up, unlike "fully compatible".
|
|
80
|
+
*/
|
|
81
|
+
function counted(name: string, exports: Record<string, TypeSpec>): PackageTypes {
|
|
82
|
+
const all = Object.values(exports);
|
|
83
|
+
return {
|
|
84
|
+
package: name,
|
|
85
|
+
exports,
|
|
86
|
+
covered: { total: all.length, dynamic: all.filter((one) => one.kind === "dynamic").length },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function empty(name: string): PackageTypes {
|
|
91
|
+
return { package: name, exports: {}, covered: { total: 0, dynamic: 0 } };
|
|
92
|
+
}
|