@zmdb/compiler 1.0.0-beta.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 +674 -0
- package/README.md +40 -0
- package/dist/codegen/index.d.ts +50 -0
- package/dist/codegen/index.d.ts.map +1 -0
- package/dist/codegen/index.js +364 -0
- package/dist/codegen/index.js.map +1 -0
- package/dist/codegen/scan.d.ts +105 -0
- package/dist/codegen/scan.d.ts.map +1 -0
- package/dist/codegen/scan.js +450 -0
- package/dist/codegen/scan.js.map +1 -0
- package/dist/codegen/witness.d.ts +76 -0
- package/dist/codegen/witness.d.ts.map +1 -0
- package/dist/codegen/witness.js +690 -0
- package/dist/codegen/witness.js.map +1 -0
- package/dist/config/contract.d.ts +41 -0
- package/dist/config/contract.d.ts.map +1 -0
- package/dist/config/contract.js +5 -0
- package/dist/config/contract.js.map +1 -0
- package/dist/config/index.d.ts +53 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +277 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/index.zmdb.generated.d.ts +12 -0
- package/dist/config/index.zmdb.generated.js +28 -0
- package/dist/config/index.zmdb.witness.d.ts +3 -0
- package/dist/config/index.zmdb.witness.d.ts.map +1 -0
- package/dist/config/index.zmdb.witness.js +20 -0
- package/dist/config/index.zmdb.witness.js.map +1 -0
- package/dist/configured-plugin.d.ts +11 -0
- package/dist/configured-plugin.d.ts.map +1 -0
- package/dist/configured-plugin.js +27 -0
- package/dist/configured-plugin.js.map +1 -0
- package/dist/emit/index.d.ts +103 -0
- package/dist/emit/index.d.ts.map +1 -0
- package/dist/emit/index.js +1356 -0
- package/dist/emit/index.js.map +1 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +254 -0
- package/dist/index.js.map +1 -0
- package/dist/lint/ast.d.ts +17 -0
- package/dist/lint/ast.d.ts.map +1 -0
- package/dist/lint/ast.js +77 -0
- package/dist/lint/ast.js.map +1 -0
- package/dist/lint/host-types.d.ts +9 -0
- package/dist/lint/host-types.d.ts.map +1 -0
- package/dist/lint/host-types.js +2 -0
- package/dist/lint/host-types.js.map +1 -0
- package/dist/lint/index.d.ts +23 -0
- package/dist/lint/index.d.ts.map +1 -0
- package/dist/lint/index.js +53 -0
- package/dist/lint/index.js.map +1 -0
- package/dist/lint/rules/no-distributed-nullable-tags.d.ts +3 -0
- package/dist/lint/rules/no-distributed-nullable-tags.d.ts.map +1 -0
- package/dist/lint/rules/no-distributed-nullable-tags.js +71 -0
- package/dist/lint/rules/no-distributed-nullable-tags.js.map +1 -0
- package/dist/lint/rules/no-empty-patch.d.ts +3 -0
- package/dist/lint/rules/no-empty-patch.d.ts.map +1 -0
- package/dist/lint/rules/no-empty-patch.js +26 -0
- package/dist/lint/rules/no-empty-patch.js.map +1 -0
- package/dist/lint/rules/no-interpolated-sql.d.ts +3 -0
- package/dist/lint/rules/no-interpolated-sql.d.ts.map +1 -0
- package/dist/lint/rules/no-interpolated-sql.js +35 -0
- package/dist/lint/rules/no-interpolated-sql.js.map +1 -0
- package/dist/lint/rules/no-unbounded-find.d.ts +3 -0
- package/dist/lint/rules/no-unbounded-find.d.ts.map +1 -0
- package/dist/lint/rules/no-unbounded-find.js +26 -0
- package/dist/lint/rules/no-unbounded-find.js.map +1 -0
- package/dist/lint/rules/no-unknown-json-column.d.ts +3 -0
- package/dist/lint/rules/no-unknown-json-column.d.ts.map +1 -0
- package/dist/lint/rules/no-unknown-json-column.js +35 -0
- package/dist/lint/rules/no-unknown-json-column.js.map +1 -0
- package/dist/lint/rules/require-sql-on-number.d.ts +3 -0
- package/dist/lint/rules/require-sql-on-number.d.ts.map +1 -0
- package/dist/lint/rules/require-sql-on-number.js +34 -0
- package/dist/lint/rules/require-sql-on-number.js.map +1 -0
- package/dist/lint/types.d.ts +12 -0
- package/dist/lint/types.d.ts.map +1 -0
- package/dist/lint/types.js +2 -0
- package/dist/lint/types.js.map +1 -0
- package/dist/metro/metro.d.ts +22 -0
- package/dist/metro/metro.d.ts.map +1 -0
- package/dist/metro/metro.js +242 -0
- package/dist/metro/metro.js.map +1 -0
- package/dist/protobuf/decode.d.ts +20 -0
- package/dist/protobuf/decode.d.ts.map +1 -0
- package/dist/protobuf/decode.js +289 -0
- package/dist/protobuf/decode.js.map +1 -0
- package/dist/protobuf/descriptor.d.ts +16 -0
- package/dist/protobuf/descriptor.d.ts.map +1 -0
- package/dist/protobuf/descriptor.js +303 -0
- package/dist/protobuf/descriptor.js.map +1 -0
- package/dist/protobuf/encode.d.ts +20 -0
- package/dist/protobuf/encode.d.ts.map +1 -0
- package/dist/protobuf/encode.js +203 -0
- package/dist/protobuf/encode.js.map +1 -0
- package/dist/protobuf/grpc-ir.d.ts +16 -0
- package/dist/protobuf/grpc-ir.d.ts.map +1 -0
- package/dist/protobuf/grpc-ir.js +2 -0
- package/dist/protobuf/grpc-ir.js.map +1 -0
- package/dist/protobuf/plan.d.ts +42 -0
- package/dist/protobuf/plan.d.ts.map +1 -0
- package/dist/protobuf/plan.js +158 -0
- package/dist/protobuf/plan.js.map +1 -0
- package/dist/reflect/callsites.d.ts +40 -0
- package/dist/reflect/callsites.d.ts.map +1 -0
- package/dist/reflect/callsites.js +153 -0
- package/dist/reflect/callsites.js.map +1 -0
- package/dist/reflect/index.d.ts +106 -0
- package/dist/reflect/index.d.ts.map +1 -0
- package/dist/reflect/index.js +1421 -0
- package/dist/reflect/index.js.map +1 -0
- package/dist/reflect/session.d.ts +118 -0
- package/dist/reflect/session.d.ts.map +1 -0
- package/dist/reflect/session.js +228 -0
- package/dist/reflect/session.js.map +1 -0
- package/dist/testing/index.d.ts +69 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +210 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/transform/index.d.ts +74 -0
- package/dist/transform/index.d.ts.map +1 -0
- package/dist/transform/index.js +572 -0
- package/dist/transform/index.js.map +1 -0
- package/dist/unplugin/index.d.ts +53 -0
- package/dist/unplugin/index.d.ts.map +1 -0
- package/dist/unplugin/index.js +130 -0
- package/dist/unplugin/index.js.map +1 -0
- package/dist/unplugin/inline-bench.d.ts +29 -0
- package/dist/unplugin/inline-bench.d.ts.map +1 -0
- package/dist/unplugin/inline-bench.js +82 -0
- package/dist/unplugin/inline-bench.js.map +1 -0
- package/package.json +110 -0
- package/src/codegen/index.ts +451 -0
- package/src/codegen/scan.ts +574 -0
- package/src/codegen/witness.ts +819 -0
- package/src/config/contract.ts +48 -0
- package/src/config/index.ts +384 -0
- package/src/config/index.zmdb.generated.d.ts +12 -0
- package/src/config/index.zmdb.generated.js +28 -0
- package/src/config/index.zmdb.witness.ts +22 -0
- package/src/configured-plugin.ts +35 -0
- package/src/emit/index.ts +1496 -0
- package/src/errors.ts +6 -0
- package/src/index.ts +336 -0
- package/src/lint/ast.ts +96 -0
- package/src/lint/host-types.ts +9 -0
- package/src/lint/index.ts +80 -0
- package/src/lint/rules/no-distributed-nullable-tags.ts +76 -0
- package/src/lint/rules/no-empty-patch.ts +26 -0
- package/src/lint/rules/no-interpolated-sql.ts +45 -0
- package/src/lint/rules/no-unbounded-find.ts +26 -0
- package/src/lint/rules/no-unknown-json-column.ts +36 -0
- package/src/lint/rules/require-sql-on-number.ts +34 -0
- package/src/lint/types.ts +11 -0
- package/src/metro/metro.ts +313 -0
- package/src/protobuf/decode.ts +375 -0
- package/src/protobuf/descriptor.ts +353 -0
- package/src/protobuf/encode.ts +277 -0
- package/src/protobuf/grpc-ir.ts +17 -0
- package/src/protobuf/plan.ts +244 -0
- package/src/reflect/callsites.ts +198 -0
- package/src/reflect/index.ts +1723 -0
- package/src/reflect/session.ts +276 -0
- package/src/testing/index.ts +298 -0
- package/src/transform/index.ts +690 -0
- package/src/unplugin/index.ts +182 -0
- package/src/unplugin/inline-bench.ts +92 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
// The source-analysis half of no-bundler project compilation: what to generate, which canonical
|
|
2
|
+
// package export each call resolves to, and what the generated module has to import.
|
|
3
|
+
//
|
|
4
|
+
// Nothing here asks the checker anything. That is deliberate — every question this file
|
|
5
|
+
// answers is about *text*: which calls carry a type argument, which names that type
|
|
6
|
+
// argument mentions, and where those names came from. Answering them syntactically keeps
|
|
7
|
+
// the expensive half (one `tsgo` session, one reflection per type) to exactly the call
|
|
8
|
+
// sites that survive this pass.
|
|
9
|
+
//
|
|
10
|
+
// The one non-obvious job is the import block of the witness module. `is<Omit<User, 'id'>>`
|
|
11
|
+
// is written in a file where `Omit` is global and `User` is imported from `./models.ts`;
|
|
12
|
+
// the same type argument in a *different* file resolves to nothing. So the witness is
|
|
13
|
+
// written next to the source it came from and re-states the source's own imports, filtered
|
|
14
|
+
// to the names the type arguments actually mention. A name the witness could not import is
|
|
15
|
+
// a refusal with the name in it, not a guess (plan D4).
|
|
16
|
+
|
|
17
|
+
import type { Node, SourceFile } from 'typescript/unstable/ast';
|
|
18
|
+
import {
|
|
19
|
+
isClassDeclaration,
|
|
20
|
+
isEnumDeclaration,
|
|
21
|
+
isExportDeclaration,
|
|
22
|
+
isExportKeyword,
|
|
23
|
+
isIdentifier,
|
|
24
|
+
isImportDeclaration,
|
|
25
|
+
isInterfaceDeclaration,
|
|
26
|
+
isModuleDeclaration,
|
|
27
|
+
isNamedExports,
|
|
28
|
+
isNamedImports,
|
|
29
|
+
isNamespaceImport,
|
|
30
|
+
isPropertyAccessExpression,
|
|
31
|
+
isQualifiedName,
|
|
32
|
+
isStringLiteral,
|
|
33
|
+
isTypeAliasDeclaration,
|
|
34
|
+
isTypeQueryNode,
|
|
35
|
+
isTypeReferenceNode,
|
|
36
|
+
isVariableStatement,
|
|
37
|
+
} from 'typescript/unstable/ast/is';
|
|
38
|
+
import type { Checker } from 'typescript/unstable/sync';
|
|
39
|
+
|
|
40
|
+
import { CALL_OWNERS, findOwnedCallSites, type CallSite } from '../reflect/callsites.js';
|
|
41
|
+
import { CALLEES } from '../transform/index.js';
|
|
42
|
+
|
|
43
|
+
/** How a name got into a module's scope, in a form that can be written down again. */
|
|
44
|
+
export interface ImportBinding {
|
|
45
|
+
readonly specifier: string;
|
|
46
|
+
readonly kind: 'default' | 'named' | 'namespace';
|
|
47
|
+
/** The name in the exporting module. Only meaningful for `named`. */
|
|
48
|
+
readonly original: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Everything about one module that the witness generator needs, all of it syntactic. */
|
|
52
|
+
export interface ModuleFacts {
|
|
53
|
+
/** Local binding name → the import that introduced it. */
|
|
54
|
+
readonly imports: ReadonlyMap<string, ImportBinding>;
|
|
55
|
+
/** Names this module declares itself, type or value. */
|
|
56
|
+
readonly locals: ReadonlySet<string>;
|
|
57
|
+
/** Names this module exports, however it exports them. */
|
|
58
|
+
readonly exported: ReadonlySet<string>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One thing the generated module will export, and the call it comes from. */
|
|
62
|
+
export interface Entry {
|
|
63
|
+
readonly callee: string;
|
|
64
|
+
/** The type argument, verbatim from the source it was read in. */
|
|
65
|
+
readonly typeText: string;
|
|
66
|
+
/** The non-default shallow depth, when this call carries one. */
|
|
67
|
+
readonly depthText?: string;
|
|
68
|
+
/** Literal value arguments captured into a zero-argument generated artifact. */
|
|
69
|
+
readonly argumentsText?: readonly string[];
|
|
70
|
+
/** The export name in the generated module. Derived, so it is stable across runs. */
|
|
71
|
+
readonly name: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A call site in a source file, paired with the entry it maps onto. */
|
|
75
|
+
export interface SiteEntry {
|
|
76
|
+
readonly site: CallSite;
|
|
77
|
+
readonly entry: Entry;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** What the codegen could not do, and which name made it impossible. */
|
|
81
|
+
export interface ScanRefusal {
|
|
82
|
+
readonly typeText: string;
|
|
83
|
+
readonly reason: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ScanResult {
|
|
87
|
+
readonly entries: readonly Entry[];
|
|
88
|
+
/** Sites in the *source* file, in source order, for the rewrite. */
|
|
89
|
+
readonly sites: readonly SiteEntry[];
|
|
90
|
+
readonly refusals: readonly ScanRefusal[];
|
|
91
|
+
/** Module specifier → the names the witness must import from it. */
|
|
92
|
+
readonly typeImports: readonly TypeImport[];
|
|
93
|
+
/** Callee → where the source got it from, so the witness can get it from there too. */
|
|
94
|
+
readonly calleeSources: ReadonlyMap<string, string>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface TypeImport {
|
|
98
|
+
readonly specifier: string;
|
|
99
|
+
readonly kind: 'default' | 'named' | 'namespace';
|
|
100
|
+
/** `original as local`, or just `local` when they match. */
|
|
101
|
+
readonly local: string;
|
|
102
|
+
readonly original: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// -----------------------------------------------------------------------------
|
|
106
|
+
// Reading a module
|
|
107
|
+
// -----------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether a declaration carries `export`.
|
|
111
|
+
*
|
|
112
|
+
* boundary: `modifiers` is declared on each of the dozen node types that can have one, and
|
|
113
|
+
* not on `Node`. Asking for it as an optional property is how a walk that accepts any node
|
|
114
|
+
* reads it without a `switch` over those dozen types — and the `Array.isArray` on the next
|
|
115
|
+
* line is what makes the read sound rather than the cast.
|
|
116
|
+
*/
|
|
117
|
+
function exported(node: Node): boolean {
|
|
118
|
+
const modifiers = (node as { modifiers?: readonly Node[] }).modifiers;
|
|
119
|
+
return Array.isArray(modifiers) && modifiers.some(modifier => isExportKeyword(modifier));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Import bindings, local declarations and exports of one module. */
|
|
123
|
+
export function moduleFacts(sourceFile: SourceFile): ModuleFacts {
|
|
124
|
+
const imports = new Map<string, ImportBinding>();
|
|
125
|
+
const locals = new Set<string>();
|
|
126
|
+
const exports = new Set<string>();
|
|
127
|
+
|
|
128
|
+
for (const statement of sourceFile.statements) {
|
|
129
|
+
if (isImportDeclaration(statement)) {
|
|
130
|
+
const specifierNode = statement.moduleSpecifier;
|
|
131
|
+
if (!isStringLiteral(specifierNode)) continue;
|
|
132
|
+
const specifier = specifierNode.text;
|
|
133
|
+
const clause = statement.importClause;
|
|
134
|
+
if (!clause) continue;
|
|
135
|
+
if (clause.name) {
|
|
136
|
+
imports.set(clause.name.text, { specifier, kind: 'default', original: 'default' });
|
|
137
|
+
}
|
|
138
|
+
const bindings = clause.namedBindings;
|
|
139
|
+
if (bindings === undefined) continue;
|
|
140
|
+
if (isNamespaceImport(bindings)) {
|
|
141
|
+
imports.set(bindings.name.text, { specifier, kind: 'namespace', original: '*' });
|
|
142
|
+
} else if (isNamedImports(bindings)) {
|
|
143
|
+
for (const element of bindings.elements) {
|
|
144
|
+
if (!isIdentifier(element.name)) continue;
|
|
145
|
+
const original =
|
|
146
|
+
element.propertyName && isIdentifier(element.propertyName) ? element.propertyName.text : element.name.text;
|
|
147
|
+
imports.set(element.name.text, { specifier, kind: 'named', original });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (
|
|
154
|
+
isInterfaceDeclaration(statement) ||
|
|
155
|
+
isTypeAliasDeclaration(statement) ||
|
|
156
|
+
isClassDeclaration(statement) ||
|
|
157
|
+
isEnumDeclaration(statement) ||
|
|
158
|
+
isModuleDeclaration(statement)
|
|
159
|
+
) {
|
|
160
|
+
const name = statement.name;
|
|
161
|
+
if (name && isIdentifier(name)) {
|
|
162
|
+
locals.add(name.text);
|
|
163
|
+
if (exported(statement)) exports.add(name.text);
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (isVariableStatement(statement)) {
|
|
169
|
+
// Only for `typeof x` in a type argument. The declaration list can destructure, and
|
|
170
|
+
// a destructured binding is not something a type query can name, so plain
|
|
171
|
+
// identifiers are the whole of what is useful here.
|
|
172
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
173
|
+
if (!isIdentifier(declaration.name)) continue;
|
|
174
|
+
locals.add(declaration.name.text);
|
|
175
|
+
if (exported(statement)) exports.add(declaration.name.text);
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (isExportDeclaration(statement)) {
|
|
181
|
+
const bindings = statement.exportClause;
|
|
182
|
+
if (bindings && isNamedExports(bindings)) {
|
|
183
|
+
for (const element of bindings.elements) {
|
|
184
|
+
if (isIdentifier(element.name)) exports.add(element.name.text);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (exported(statement)) {
|
|
191
|
+
// boundary: same shape as `exported` above — `name` belongs to the declaration types,
|
|
192
|
+
// not to `Node`, and this branch is the fall-through for the statement kinds the cases
|
|
193
|
+
// above did not name. `isIdentifier` is what proves the read, so a node with no `name`
|
|
194
|
+
// and a node whose name is a computed property both fall out here.
|
|
195
|
+
const name = (statement as { name?: Node }).name;
|
|
196
|
+
if (name && isIdentifier(name)) exports.add(name.text);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { imports, locals, exported: exports };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The names a type argument depends on, as written.
|
|
205
|
+
*
|
|
206
|
+
* Only the *head* of a reference counts: `ns.T` needs `ns`, `Omit<User, 'id'>` needs
|
|
207
|
+
* `Omit` and `User`, and the `'id'` needs nothing. Collecting every identifier instead
|
|
208
|
+
* would drag in the property names of an inline object type and then refuse the call site
|
|
209
|
+
* because `email` is not importable.
|
|
210
|
+
*/
|
|
211
|
+
export function referencedNames(node: Node): ReadonlySet<string> {
|
|
212
|
+
const names = new Set<string>();
|
|
213
|
+
|
|
214
|
+
const head = (name: Node): void => {
|
|
215
|
+
if (isIdentifier(name)) names.add(name.text);
|
|
216
|
+
else if (isQualifiedName(name)) head(name.left);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const walk = (current: Node): void => {
|
|
220
|
+
if (isTypeReferenceNode(current)) head(current.typeName);
|
|
221
|
+
else if (isTypeQueryNode(current)) head(current.exprName);
|
|
222
|
+
current.forEachChild(walk);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
walk(node);
|
|
226
|
+
return names;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// -----------------------------------------------------------------------------
|
|
230
|
+
// Naming
|
|
231
|
+
// -----------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
/** `is` → `Is`, so the export reads as a sentence: `zmdbIsUser`. */
|
|
234
|
+
function capitalise(text: string): string {
|
|
235
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const PREFIXES: Readonly<Record<string, string>> = {
|
|
239
|
+
is: 'Is',
|
|
240
|
+
isShallow: 'IsShallow',
|
|
241
|
+
equals: 'Equals',
|
|
242
|
+
assert: 'Assert',
|
|
243
|
+
assertShallow: 'AssertShallow',
|
|
244
|
+
assertEquals: 'AssertEquals',
|
|
245
|
+
validate: 'Validate',
|
|
246
|
+
validateShallow: 'ValidateShallow',
|
|
247
|
+
random: 'Random',
|
|
248
|
+
toJsonSchema: 'JsonSchema',
|
|
249
|
+
schemaOf: 'Schema',
|
|
250
|
+
toolFor: 'Tool',
|
|
251
|
+
grpcDescriptor: 'GrpcDescriptor',
|
|
252
|
+
loadGrpcService: 'LoadGrpcService',
|
|
253
|
+
protoDescriptor: 'ProtoDescriptor',
|
|
254
|
+
protoDecode: 'ProtoDecode',
|
|
255
|
+
protoEncode: 'ProtoEncode',
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** How long a slug may get before it stops being a name and starts being the type. */
|
|
259
|
+
const MAX_SLUG = 48;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* A stable export name for one entry.
|
|
263
|
+
*
|
|
264
|
+
* Stability is the requirement, not beauty: the name is written into the user's source, so
|
|
265
|
+
* it has to come out the same on every run or the codegen produces a diff per invocation.
|
|
266
|
+
* It is derived only from the callee and the type text, both of which are in the source.
|
|
267
|
+
*/
|
|
268
|
+
export function exportName(
|
|
269
|
+
callee: string,
|
|
270
|
+
typeText: string,
|
|
271
|
+
taken: ReadonlySet<string>,
|
|
272
|
+
depthText?: string,
|
|
273
|
+
identityText?: string,
|
|
274
|
+
): string {
|
|
275
|
+
const slug = `${typeText} ${identityText ?? ''}`
|
|
276
|
+
.replaceAll(/[^A-Za-z0-9]+/g, ' ')
|
|
277
|
+
.trim()
|
|
278
|
+
.split(/\s+/)
|
|
279
|
+
.map(capitalise)
|
|
280
|
+
.join('')
|
|
281
|
+
.slice(0, MAX_SLUG);
|
|
282
|
+
const depth =
|
|
283
|
+
depthText === undefined
|
|
284
|
+
? ''
|
|
285
|
+
: `Depth${depthText
|
|
286
|
+
.replaceAll(/[^A-Za-z0-9]+/g, ' ')
|
|
287
|
+
.trim()
|
|
288
|
+
.split(/\s+/)
|
|
289
|
+
.map(capitalise)
|
|
290
|
+
.join('')}`;
|
|
291
|
+
const base = `zmdb${PREFIXES[callee] ?? capitalise(callee)}${slug}${depth}`;
|
|
292
|
+
if (!taken.has(base)) return base;
|
|
293
|
+
// Two different types with the same slug — `Pick<User, 'id'>` and `Pick<User, "id">`,
|
|
294
|
+
// say. Numbering is ugly and deterministic, which is the right trade for a generated
|
|
295
|
+
// name; guessing that they are the same type would be neither.
|
|
296
|
+
for (let n = 2; ; n += 1) {
|
|
297
|
+
const candidate = `${base}_${String(n)}`;
|
|
298
|
+
if (!taken.has(candidate)) return candidate;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// -----------------------------------------------------------------------------
|
|
303
|
+
// Where the callee came from
|
|
304
|
+
// -----------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The package a callee lives in, for a call that did not import it by name.
|
|
308
|
+
*
|
|
309
|
+
* Only reached by `zmdb.is<User>(x)` where `zmdb` turns out not to be an import either —
|
|
310
|
+
* an unusual file. The subpaths are the truth about where each function is declared, and a
|
|
311
|
+
* project that installed only the product package has them all re-exported from `zmdb`, so the
|
|
312
|
+
* import the witness writes resolves in both layouts as long as the source's own import
|
|
313
|
+
* could be read. When it could not, this is the better guess than nothing.
|
|
314
|
+
*/
|
|
315
|
+
const DEFAULT_MODULES: Readonly<Record<string, string>> = {
|
|
316
|
+
is: '@zmdb/validator',
|
|
317
|
+
equals: '@zmdb/validator',
|
|
318
|
+
assert: '@zmdb/validator',
|
|
319
|
+
assertShallow: '@zmdb/validator',
|
|
320
|
+
assertEquals: '@zmdb/validator',
|
|
321
|
+
validate: '@zmdb/validator',
|
|
322
|
+
validateShallow: '@zmdb/validator',
|
|
323
|
+
isShallow: '@zmdb/validator',
|
|
324
|
+
random: '@zmdb/validator',
|
|
325
|
+
toJsonSchema: '@zmdb/schema/openapi',
|
|
326
|
+
schemaOf: '@zmdb/schema',
|
|
327
|
+
toolFor: '@zmdb/ai',
|
|
328
|
+
grpcDescriptor: '@zmdb/protobuf',
|
|
329
|
+
loadGrpcService: '@zmdb/protobuf',
|
|
330
|
+
protoDescriptor: '@zmdb/protobuf',
|
|
331
|
+
protoDecode: '@zmdb/protobuf',
|
|
332
|
+
protoEncode: '@zmdb/protobuf',
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Which module the witness should import a callee from.
|
|
337
|
+
*
|
|
338
|
+
* The source's own import, whenever there is one, and not a fixed table: a project that
|
|
339
|
+
* installed `zmdb` can import `is` from the product root, and a witness that reached past
|
|
340
|
+
* it to `@zmdb/validator` would import a package that is not in the
|
|
341
|
+
* consumer's dependencies. `zmdb.is<User>(x)` resolves through the namespace's own import
|
|
342
|
+
* for the same reason.
|
|
343
|
+
*/
|
|
344
|
+
function calleeSpecifier(facts: ModuleFacts, site: CallSite): string {
|
|
345
|
+
if (site.specifier !== undefined) return site.specifier;
|
|
346
|
+
const direct = facts.imports.get(site.callee);
|
|
347
|
+
if (direct) return direct.specifier;
|
|
348
|
+
const target = site.node.expression;
|
|
349
|
+
if (isPropertyAccessExpression(target) && isIdentifier(target.expression)) {
|
|
350
|
+
const namespace = facts.imports.get(target.expression.text);
|
|
351
|
+
if (namespace) return namespace.specifier;
|
|
352
|
+
}
|
|
353
|
+
return DEFAULT_MODULES[site.callee] ?? '@zmdb/validator';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// -----------------------------------------------------------------------------
|
|
357
|
+
// The scan
|
|
358
|
+
// -----------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
export interface ScanInput {
|
|
361
|
+
readonly checker: Checker;
|
|
362
|
+
readonly sourceFile: SourceFile;
|
|
363
|
+
/** The previous run's witness, when there is one. See `scan` for why it is read. */
|
|
364
|
+
readonly witnessFile?: SourceFile | undefined;
|
|
365
|
+
/**
|
|
366
|
+
* Whether the project lets a relative import name a `.ts` file
|
|
367
|
+
* (`allowImportingTsExtensions`). Only consulted for a file that has no relative import to
|
|
368
|
+
* copy the style from; see `relativeExtension`.
|
|
369
|
+
*/
|
|
370
|
+
readonly tsExtensions?: boolean | undefined;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* How to spell a relative import of a sibling `.ts` file, as this module spells it.
|
|
375
|
+
*
|
|
376
|
+
* There is no universally right answer — `./models.ts` needs
|
|
377
|
+
* `allowImportingTsExtensions`, and `./models.js` names a file that does not exist until
|
|
378
|
+
* something compiles it — so the witness copies whatever the source does. Only when the
|
|
379
|
+
* source has no relative import at all does the project's own setting decide.
|
|
380
|
+
*/
|
|
381
|
+
function relativeExtension(facts: ModuleFacts, tsExtensions: boolean | undefined): string {
|
|
382
|
+
let sawJs = false;
|
|
383
|
+
for (const binding of facts.imports.values()) {
|
|
384
|
+
if (!binding.specifier.startsWith('.')) continue;
|
|
385
|
+
if (/\.[cm]?tsx?$/.test(binding.specifier)) return '.ts';
|
|
386
|
+
if (/\.[cm]?jsx?$/.test(binding.specifier)) sawJs = true;
|
|
387
|
+
}
|
|
388
|
+
if (sawJs) return '.js';
|
|
389
|
+
return tsExtensions === false ? '.js' : '.ts';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** `/a/b/models.ts` → `./models.ts` or `./models.js`, per `relativeExtension`. */
|
|
393
|
+
function siblingSpecifier(fileName: string, extension: string): string {
|
|
394
|
+
const name = basename(fileName);
|
|
395
|
+
const stem = name.replace(/\.[cm]?tsx?$/, '');
|
|
396
|
+
return `./${stem}${extension}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Everything the generator needs for one source file.
|
|
401
|
+
*
|
|
402
|
+
* Two inputs rather than one, because the rewrite is destructive: after a run the source
|
|
403
|
+
* says `zmdbIsUser(data)` and the `is<User>` that produced it is gone. The witness keeps
|
|
404
|
+
* it — and keeps it in a form the compiler checks, so a renamed or deleted `User` is a
|
|
405
|
+
* build error in a generated file rather than a validator that quietly describes a type
|
|
406
|
+
* nobody declares any more. So the entry set is the union of the two, and an entry the
|
|
407
|
+
* source has stopped referencing is dropped, which is what stops the witness accumulating
|
|
408
|
+
* validators for code that no longer exists.
|
|
409
|
+
*/
|
|
410
|
+
export function scan(input: ScanInput): ScanResult {
|
|
411
|
+
const { checker, sourceFile, witnessFile } = input;
|
|
412
|
+
const sourceText = sourceFile.text;
|
|
413
|
+
|
|
414
|
+
const entries: Entry[] = [];
|
|
415
|
+
const sites: SiteEntry[] = [];
|
|
416
|
+
const refusals: ScanRefusal[] = [];
|
|
417
|
+
const calleeSources = new Map<string, string>();
|
|
418
|
+
const taken = new Set<string>();
|
|
419
|
+
/** Callee, type, depth and captured literals → one shared generated export. */
|
|
420
|
+
const byKey = new Map<string, Entry>();
|
|
421
|
+
/** Heads still to resolve, and the file each was written in. */
|
|
422
|
+
const pending: { readonly names: ReadonlySet<string>; readonly file: SourceFile; readonly typeText: string }[] = [];
|
|
423
|
+
|
|
424
|
+
const add = (
|
|
425
|
+
callee: string,
|
|
426
|
+
typeText: string,
|
|
427
|
+
file: SourceFile,
|
|
428
|
+
node: Node,
|
|
429
|
+
depthText?: string,
|
|
430
|
+
depthNode?: Node,
|
|
431
|
+
argumentsText?: readonly string[],
|
|
432
|
+
): Entry => {
|
|
433
|
+
const identity = argumentsText?.join('\u0000') ?? '';
|
|
434
|
+
const key = `${callee}\u0000${typeText}\u0000${depthText ?? ''}\u0000${identity}`;
|
|
435
|
+
const existing = byKey.get(key);
|
|
436
|
+
if (existing) return existing;
|
|
437
|
+
const entry: Entry = {
|
|
438
|
+
callee,
|
|
439
|
+
typeText,
|
|
440
|
+
...(depthText === undefined ? {} : { depthText }),
|
|
441
|
+
...(argumentsText === undefined ? {} : { argumentsText }),
|
|
442
|
+
name: exportName(callee, typeText, taken, depthText, identity),
|
|
443
|
+
};
|
|
444
|
+
taken.add(entry.name);
|
|
445
|
+
byKey.set(key, entry);
|
|
446
|
+
entries.push(entry);
|
|
447
|
+
const names = new Set(referencedNames(node));
|
|
448
|
+
if (depthNode !== undefined) {
|
|
449
|
+
for (const name of referencedNames(depthNode)) names.add(name);
|
|
450
|
+
}
|
|
451
|
+
pending.push({ names, file, typeText });
|
|
452
|
+
return entry;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const depthOf = (site: CallSite, file: SourceFile): { readonly text?: string; readonly node?: Node } => {
|
|
456
|
+
if (!site.callee.endsWith('Shallow')) return {};
|
|
457
|
+
const node = site.node.typeArguments?.[1];
|
|
458
|
+
if (node === undefined) return {};
|
|
459
|
+
const text = file.text.slice(node.getStart(), node.end).trim();
|
|
460
|
+
return text === '1' ? {} : { text, node };
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const capturedArguments = (site: CallSite, file: SourceFile): readonly string[] | undefined => {
|
|
464
|
+
if (site.callee !== 'grpcDescriptor' && site.callee !== 'loadGrpcService') return undefined;
|
|
465
|
+
const args = site.node.arguments;
|
|
466
|
+
if (args.length !== 2 || !args.every(argument => isStringLiteral(argument))) {
|
|
467
|
+
refusals.push({
|
|
468
|
+
typeText: file.text.slice(site.typeArgument.getStart(), site.typeArgument.end),
|
|
469
|
+
reason: `\`${site.callee}<S>()\` needs exactly two string literals: service and package`,
|
|
470
|
+
});
|
|
471
|
+
return [];
|
|
472
|
+
}
|
|
473
|
+
return args.map(argument => file.text.slice(argument.getStart(), argument.end));
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// The witness is written next to the source, so the source's own relative specifiers
|
|
477
|
+
// resolve unchanged from it. `facts` is therefore consulted per originating file: a
|
|
478
|
+
// carried entry's names were written in the witness and resolve against the witness.
|
|
479
|
+
const factsFor = new Map<SourceFile, ModuleFacts>();
|
|
480
|
+
const facts = (file: SourceFile): ModuleFacts => {
|
|
481
|
+
const cached = factsFor.get(file);
|
|
482
|
+
if (cached) return cached;
|
|
483
|
+
const fresh = moduleFacts(file);
|
|
484
|
+
factsFor.set(file, fresh);
|
|
485
|
+
return fresh;
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// Carried entries first, so an export name never moves because a new call site was added
|
|
489
|
+
// above an old one — the source references these names, and a rename would be a diff in
|
|
490
|
+
// hand-written code for no reason.
|
|
491
|
+
if (witnessFile) {
|
|
492
|
+
for (const site of findOwnedCallSites(witnessFile, checker, CALLEES, CALL_OWNERS)) {
|
|
493
|
+
const typeText = witnessFile.text.slice(site.typeArgument.getStart(), site.typeArgument.end);
|
|
494
|
+
const depth = depthOf(site, witnessFile);
|
|
495
|
+
const argumentsText = capturedArguments(site, witnessFile);
|
|
496
|
+
const name = exportName(site.callee, typeText, new Set(), depth.text, argumentsText?.join('\u0000'));
|
|
497
|
+
// Referenced by identifier, not by import: the source may have been edited by hand
|
|
498
|
+
// since, and what matters is whether the name is still used anywhere in it.
|
|
499
|
+
if (!referencesName(sourceText, name)) continue;
|
|
500
|
+
add(site.callee, typeText, witnessFile, site.typeArgument, depth.text, depth.node, argumentsText);
|
|
501
|
+
calleeSources.set(site.callee, calleeSpecifier(facts(witnessFile), site));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
for (const site of findOwnedCallSites(sourceFile, checker, CALLEES, CALL_OWNERS)) {
|
|
506
|
+
const typeText = sourceText.slice(site.typeArgument.getStart(), site.typeArgument.end);
|
|
507
|
+
const depth = depthOf(site, sourceFile);
|
|
508
|
+
const argumentsText = capturedArguments(site, sourceFile);
|
|
509
|
+
sites.push({
|
|
510
|
+
site,
|
|
511
|
+
entry: add(site.callee, typeText, sourceFile, site.typeArgument, depth.text, depth.node, argumentsText),
|
|
512
|
+
});
|
|
513
|
+
// The source's answer overrides the witness's: the witness only ever says what a
|
|
514
|
+
// previous run wrote, and the user may since have changed where they import from.
|
|
515
|
+
calleeSources.set(site.callee, calleeSpecifier(facts(sourceFile), site));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const typeImports = new Map<string, TypeImport>();
|
|
519
|
+
const extension = relativeExtension(facts(sourceFile), input.tsExtensions);
|
|
520
|
+
|
|
521
|
+
for (const { names, file, typeText } of pending) {
|
|
522
|
+
for (const name of names) {
|
|
523
|
+
if (typeImports.has(name)) continue;
|
|
524
|
+
const binding = facts(file).imports.get(name);
|
|
525
|
+
if (binding) {
|
|
526
|
+
typeImports.set(name, {
|
|
527
|
+
specifier: binding.specifier,
|
|
528
|
+
kind: binding.kind,
|
|
529
|
+
local: name,
|
|
530
|
+
original: binding.original,
|
|
531
|
+
});
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
// Declared where the call was written. Importable only if that module exports it —
|
|
535
|
+
// and the generated module is a different module, so "it is right there" is not
|
|
536
|
+
// enough.
|
|
537
|
+
const own = facts(file);
|
|
538
|
+
if (own.locals.has(name)) {
|
|
539
|
+
if (own.exported.has(name)) {
|
|
540
|
+
typeImports.set(name, {
|
|
541
|
+
specifier: siblingSpecifier(file.fileName, extension),
|
|
542
|
+
kind: 'named',
|
|
543
|
+
local: name,
|
|
544
|
+
original: name,
|
|
545
|
+
});
|
|
546
|
+
} else {
|
|
547
|
+
refusals.push({
|
|
548
|
+
typeText,
|
|
549
|
+
reason:
|
|
550
|
+
`\`${name}\` is declared in ${basename(file.fileName)} but not exported, so the generated ` +
|
|
551
|
+
'module cannot name it. Export it, or move it to a module that does.',
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
// Not imported and not declared here: global (`Date`, `Omit`, `Record`), which needs
|
|
557
|
+
// no import at all. A name that is neither global nor importable is already a
|
|
558
|
+
// compile error in the source, so there is nothing for this pass to add.
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return { entries, sites, refusals, typeImports: [...typeImports.values()], calleeSources };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Whether `text` uses `name` as an identifier, rather than as part of a longer one. */
|
|
566
|
+
export function referencesName(text: string, name: string): boolean {
|
|
567
|
+
const pattern = new RegExp(`(?<![\\w$])${name}(?![\\w$])`);
|
|
568
|
+
return pattern.test(text);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function basename(path: string): string {
|
|
572
|
+
const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
|
|
573
|
+
return cut === -1 ? path : path.slice(cut + 1);
|
|
574
|
+
}
|