typespun-codegen 0.0.7 → 0.0.9
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/README.md +15 -7
- package/dist/analyzer/analyze.d.ts +4 -0
- package/dist/analyzer/analyze.d.ts.map +1 -0
- package/dist/analyzer/analyze.js +414 -0
- package/dist/analyzer/annotations.d.ts +14 -0
- package/dist/analyzer/annotations.d.ts.map +1 -0
- package/dist/analyzer/annotations.js +102 -0
- package/dist/analyzer/default-expression.d.ts +10 -0
- package/dist/analyzer/default-expression.d.ts.map +1 -0
- package/dist/analyzer/default-expression.js +67 -0
- package/dist/analyzer/diagnostic.d.ts +5 -0
- package/dist/analyzer/diagnostic.d.ts.map +1 -0
- package/dist/analyzer/diagnostic.js +16 -0
- package/dist/analyzer/ir.d.ts +2 -0
- package/dist/analyzer/ir.d.ts.map +1 -0
- package/dist/analyzer/ir.js +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +3 -0
- package/dist/cli/diagnostics.d.ts +7 -0
- package/dist/cli/diagnostics.d.ts.map +1 -0
- package/dist/cli/diagnostics.js +26 -0
- package/dist/cli/init.d.ts +19 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +531 -0
- package/dist/cli/main.d.ts +13 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +164 -0
- package/dist/contracts.d.ts +33 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +1 -0
- package/dist/emitter/emit.d.ts +13 -0
- package/dist/emitter/emit.d.ts.map +1 -0
- package/dist/emitter/emit.js +65 -0
- package/dist/emitter/fingerprint.d.ts +10 -0
- package/dist/emitter/fingerprint.d.ts.map +1 -0
- package/dist/emitter/fingerprint.js +28 -0
- package/dist/generate.d.ts +29 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +267 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/project/config.d.ts +20 -0
- package/dist/project/config.d.ts.map +1 -0
- package/dist/project/config.js +144 -0
- package/dist/project/defaults.d.ts +22 -0
- package/dist/project/defaults.d.ts.map +1 -0
- package/dist/project/defaults.js +216 -0
- package/dist/project/discovery.d.ts +9 -0
- package/dist/project/discovery.d.ts.map +1 -0
- package/dist/project/discovery.js +66 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# typespun-codegen
|
|
6
6
|
|
|
7
|
-
The TypeScript compiler and `typespun` command-line tool for
|
|
7
|
+
The TypeScript compiler and `typespun-codegen` command-line tool for
|
|
8
8
|
[Typespun](https://github.com/omkar273/typespun). It initializes a project,
|
|
9
9
|
analyzes one TypeScript configuration declaration, and emits a deterministic
|
|
10
10
|
loader for the separate [`typespun`](https://www.npmjs.com/package/typespun)
|
|
@@ -13,8 +13,9 @@ runtime package.
|
|
|
13
13
|
[](https://www.npmjs.com/package/typespun-codegen)
|
|
14
14
|
[](LICENSE)
|
|
15
15
|
|
|
16
|
-
The package name is `typespun-codegen`;
|
|
17
|
-
`typespun`. There is no supported programmatic compiler API in version
|
|
16
|
+
The package name is `typespun-codegen`; its direct executable is
|
|
17
|
+
`typespun-codegen`. There is no supported programmatic compiler API in version
|
|
18
|
+
0.1.
|
|
18
19
|
|
|
19
20
|
## Start with `init`
|
|
20
21
|
|
|
@@ -57,8 +58,8 @@ You can scaffold before installing either package with a package-qualified
|
|
|
57
58
|
zero-install command:
|
|
58
59
|
|
|
59
60
|
```sh
|
|
60
|
-
bunx --package typespun-codegen typespun init
|
|
61
|
-
npx --package=typespun-codegen typespun init
|
|
61
|
+
bunx --package typespun-codegen typespun-codegen init
|
|
62
|
+
npx --package=typespun-codegen typespun-codegen init
|
|
62
63
|
```
|
|
63
64
|
|
|
64
65
|
In that case, `init` creates the project files and exits successfully but does
|
|
@@ -73,8 +74,15 @@ bun add --dev typespun-codegen
|
|
|
73
74
|
bun run config:generate
|
|
74
75
|
```
|
|
75
76
|
|
|
76
|
-
The installed
|
|
77
|
-
|
|
77
|
+
The shortest installed workflow is:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
bun add typespun
|
|
81
|
+
bun add --dev typespun-codegen
|
|
82
|
+
bun typespun init --env-prefix APP
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
After this, both dependencies are local and generation happens immediately.
|
|
78
86
|
|
|
79
87
|
`init` preserves existing scripts and initialized files. Re-running it is
|
|
80
88
|
byte-stable. It refuses conflicting options, input/output aliases, and an
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../../src/analyzer/analyze.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,YAAY,CAAC;AAG5B,OAAO,KAAK,EACV,aAAa,EAId,MAAM,iBAAiB,CAAC;AASzB,wBAAgB,cAAc,CAC5B,OAAO,EAAE,EAAE,CAAC,OAAO,EACnB,SAAS,EAAE,MAAM,EACjB,SAAS,SAAK,GACb,aAAa,CAqmBf"}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
import { validateTypedValue } from 'typespun/generated';
|
|
4
|
+
import { readAnnotations, typespunSymbols } from './annotations.js';
|
|
5
|
+
import { locationOf, sortDiagnostics } from './diagnostic.js';
|
|
6
|
+
import { evaluateDefault } from './default-expression.js';
|
|
7
|
+
function isJavaScriptSource(node) {
|
|
8
|
+
return /\.(?:[cm]?js|jsx)$/i.test(node.getSourceFile().fileName);
|
|
9
|
+
}
|
|
10
|
+
export function analyzeProgram(program, inputPath, envPrefix = '') {
|
|
11
|
+
const checker = program.getTypeChecker();
|
|
12
|
+
const diagnostics = [];
|
|
13
|
+
const fields = [];
|
|
14
|
+
const report = (code, message, node) => diagnostics.push({ code, message, location: locationOf(node) });
|
|
15
|
+
const symbols = typespunSymbols(program);
|
|
16
|
+
const annotationsFor = (node) => readAnnotations(node, report, checker, symbols);
|
|
17
|
+
const source = program.getSourceFile(path.resolve(inputPath));
|
|
18
|
+
if (!source)
|
|
19
|
+
return {
|
|
20
|
+
inputPath,
|
|
21
|
+
fields,
|
|
22
|
+
diagnostics: [
|
|
23
|
+
{
|
|
24
|
+
code: 'input_not_found',
|
|
25
|
+
message: 'Schema input is not in the TypeScript program.',
|
|
26
|
+
location: { file: inputPath, line: 1, column: 1 },
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
if (isJavaScriptSource(source)) {
|
|
31
|
+
report('javascript_schema', 'JavaScript schema declarations are not supported.', source);
|
|
32
|
+
return { inputPath, fields, diagnostics: sortDiagnostics(diagnostics) };
|
|
33
|
+
}
|
|
34
|
+
const moduleSymbol = checker.getSymbolAtLocation(source);
|
|
35
|
+
const moduleExports = moduleSymbol
|
|
36
|
+
? checker.getExportsOfModule(moduleSymbol)
|
|
37
|
+
: [];
|
|
38
|
+
const exportedSymbols = new Set(moduleExports.map((symbol) => symbol.flags & ts.SymbolFlags.Alias
|
|
39
|
+
? checker.getAliasedSymbol(symbol)
|
|
40
|
+
: symbol));
|
|
41
|
+
const roots = source.statements.filter((node) => (ts.isInterfaceDeclaration(node) || ts.isClassDeclaration(node)) &&
|
|
42
|
+
!!node.name &&
|
|
43
|
+
exportedSymbols.has(checker.getSymbolAtLocation(node.name)) &&
|
|
44
|
+
annotationsFor(node).root);
|
|
45
|
+
if (roots.length !== 1) {
|
|
46
|
+
report('root_count', 'The input must export exactly one marked configuration root.', source);
|
|
47
|
+
return { inputPath, fields, diagnostics };
|
|
48
|
+
}
|
|
49
|
+
const root = roots[0];
|
|
50
|
+
const rootSymbol = checker.getSymbolAtLocation(root.name);
|
|
51
|
+
const exportNames = moduleExports
|
|
52
|
+
.filter((symbol) => (symbol.flags & ts.SymbolFlags.Alias
|
|
53
|
+
? checker.getAliasedSymbol(symbol)
|
|
54
|
+
: symbol) === rootSymbol)
|
|
55
|
+
.map((symbol) => symbol.name)
|
|
56
|
+
.sort();
|
|
57
|
+
const exportName = exportNames.find((name) => name === root.name.text) ??
|
|
58
|
+
exportNames.find((name) => name !== 'default') ??
|
|
59
|
+
'default';
|
|
60
|
+
const rootExport = exportName === 'default'
|
|
61
|
+
? { kind: 'default' }
|
|
62
|
+
: { kind: 'named', name: exportName };
|
|
63
|
+
if (root.typeParameters?.length) {
|
|
64
|
+
report('generic_schema', 'Configuration roots cannot have type parameters.', root.name ?? root);
|
|
65
|
+
return { inputPath, fields, diagnostics: sortDiagnostics(diagnostics) };
|
|
66
|
+
}
|
|
67
|
+
const rootType = checker.getTypeAtLocation(root);
|
|
68
|
+
if ((rootType.symbol?.declarations?.length ?? 0) > 1) {
|
|
69
|
+
report('declaration_merging', 'Configuration roots cannot use declaration merging.', root.name ?? root);
|
|
70
|
+
return { inputPath, fields, diagnostics: sortDiagnostics(diagnostics) };
|
|
71
|
+
}
|
|
72
|
+
for (const index of checker.getIndexInfosOfType(rootType)) {
|
|
73
|
+
report('unsupported_type', 'Index signatures are not supported.', index.declaration ?? root);
|
|
74
|
+
}
|
|
75
|
+
for (const signature of [
|
|
76
|
+
...rootType.getCallSignatures(),
|
|
77
|
+
...rootType.getConstructSignatures(),
|
|
78
|
+
]) {
|
|
79
|
+
report('unsupported_type', 'Call and construct signatures are not supported.', signature.declaration ?? root);
|
|
80
|
+
}
|
|
81
|
+
const invalidMembers = new Set();
|
|
82
|
+
if (ts.isClassDeclaration(root)) {
|
|
83
|
+
for (const heritage of root.heritageClauses ?? [])
|
|
84
|
+
report('class_inheritance', 'Configuration classes cannot inherit other classes.', heritage);
|
|
85
|
+
for (const member of root.members) {
|
|
86
|
+
if (!ts.isPropertyDeclaration(member) ||
|
|
87
|
+
ts.isPrivateIdentifier(member.name) ||
|
|
88
|
+
member.modifiers?.some((modifier) => [
|
|
89
|
+
ts.SyntaxKind.StaticKeyword,
|
|
90
|
+
ts.SyntaxKind.PrivateKeyword,
|
|
91
|
+
ts.SyntaxKind.ProtectedKeyword,
|
|
92
|
+
].includes(modifier.kind))) {
|
|
93
|
+
report('invalid_class_member', 'Configuration classes allow only public instance data properties.', member);
|
|
94
|
+
invalidMembers.add(member);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function containsIntersection(node, seen = new Set()) {
|
|
99
|
+
if (!node)
|
|
100
|
+
return false;
|
|
101
|
+
if (ts.isIntersectionTypeNode(node))
|
|
102
|
+
return true;
|
|
103
|
+
if (ts.isParenthesizedTypeNode(node))
|
|
104
|
+
return containsIntersection(node.type, seen);
|
|
105
|
+
if (ts.isTypeReferenceNode(node) ||
|
|
106
|
+
ts.isExpressionWithTypeArguments(node)) {
|
|
107
|
+
let symbol = checker.getSymbolAtLocation(ts.isTypeReferenceNode(node) ? node.typeName : node.expression);
|
|
108
|
+
if (symbol?.flags && symbol.flags & ts.SymbolFlags.Alias)
|
|
109
|
+
symbol = checker.getAliasedSymbol(symbol);
|
|
110
|
+
if (!symbol || seen.has(symbol))
|
|
111
|
+
return false;
|
|
112
|
+
seen.add(symbol);
|
|
113
|
+
return (symbol.declarations?.some((declaration) => ts.isTypeAliasDeclaration(declaration) &&
|
|
114
|
+
containsIntersection(declaration.type, seen)) ?? false);
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const validatedHeritage = new Set();
|
|
119
|
+
function validateHeritage(type) {
|
|
120
|
+
const javaScriptDeclaration = type.symbol?.declarations?.find(isJavaScriptSource);
|
|
121
|
+
if (javaScriptDeclaration) {
|
|
122
|
+
report('javascript_schema', 'JavaScript schema declarations are not supported.', javaScriptDeclaration);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
for (const declaration of type.symbol?.declarations ?? []) {
|
|
126
|
+
if (!ts.isInterfaceDeclaration(declaration) ||
|
|
127
|
+
validatedHeritage.has(declaration))
|
|
128
|
+
continue;
|
|
129
|
+
validatedHeritage.add(declaration);
|
|
130
|
+
for (const clause of declaration.heritageClauses ?? []) {
|
|
131
|
+
for (const base of clause.types) {
|
|
132
|
+
if (containsIntersection(base)) {
|
|
133
|
+
report('unsupported_type', 'Intersections are not supported in heritage.', base);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
validateHeritage(checker.getTypeAtLocation(base));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
validateHeritage(rootType);
|
|
143
|
+
function kindOf(type) {
|
|
144
|
+
if (type.flags & ts.TypeFlags.String)
|
|
145
|
+
return { type: 'string' };
|
|
146
|
+
if (type.flags & ts.TypeFlags.Number)
|
|
147
|
+
return { type: 'number' };
|
|
148
|
+
if (type.flags & ts.TypeFlags.Boolean)
|
|
149
|
+
return { type: 'boolean' };
|
|
150
|
+
if (type.flags & ts.TypeFlags.StringLiteral)
|
|
151
|
+
return { type: 'enum', values: [type.value] };
|
|
152
|
+
if (type.isUnion() &&
|
|
153
|
+
type.types.every((item) => item.flags & ts.TypeFlags.StringLiteral)) {
|
|
154
|
+
return {
|
|
155
|
+
type: 'enum',
|
|
156
|
+
values: type.types.map((item) => item.value),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (checker.isArrayType(type)) {
|
|
160
|
+
const element = checker.getTypeArguments(type)[0];
|
|
161
|
+
const kind = element && kindOf(element);
|
|
162
|
+
if (kind && ['string', 'number', 'boolean'].includes(kind.type))
|
|
163
|
+
return {
|
|
164
|
+
type: 'array',
|
|
165
|
+
element: kind.type,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const activeTypes = new Set();
|
|
171
|
+
const activeProperties = new Set();
|
|
172
|
+
function hasConditionalType(node, seen = new Set()) {
|
|
173
|
+
if (ts.isConditionalTypeNode(node))
|
|
174
|
+
return true;
|
|
175
|
+
if (ts.isTypeReferenceNode(node)) {
|
|
176
|
+
let symbol = checker.getSymbolAtLocation(node.typeName);
|
|
177
|
+
if (symbol?.flags && symbol.flags & ts.SymbolFlags.Alias)
|
|
178
|
+
symbol = checker.getAliasedSymbol(symbol);
|
|
179
|
+
if (symbol && !seen.has(symbol)) {
|
|
180
|
+
seen.add(symbol);
|
|
181
|
+
if (symbol.declarations?.some((declaration) => ts.isTypeAliasDeclaration(declaration) &&
|
|
182
|
+
hasConditionalType(declaration.type, seen)))
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return (ts.forEachChild(node, (child) => hasConditionalType(child, seen) || undefined) ?? false);
|
|
187
|
+
}
|
|
188
|
+
function canChangeShape(node) {
|
|
189
|
+
if (node.type && hasConditionalType(node.type))
|
|
190
|
+
return true;
|
|
191
|
+
for (let parent = node.parent; parent && !ts.isSourceFile(parent); parent = parent.parent) {
|
|
192
|
+
if (ts.isConditionalTypeNode(parent))
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
const envNames = new Set();
|
|
198
|
+
const defaultsPaths = new Set();
|
|
199
|
+
function mergeInlineDefaults(lower, upper) {
|
|
200
|
+
if (!lower ||
|
|
201
|
+
!upper ||
|
|
202
|
+
typeof lower !== 'object' ||
|
|
203
|
+
typeof upper !== 'object' ||
|
|
204
|
+
Array.isArray(lower) ||
|
|
205
|
+
Array.isArray(upper))
|
|
206
|
+
return upper;
|
|
207
|
+
const lowerObject = lower;
|
|
208
|
+
const upperObject = upper;
|
|
209
|
+
return Object.fromEntries([
|
|
210
|
+
...new Set([...Object.keys(lowerObject), ...Object.keys(upperObject)]),
|
|
211
|
+
].map((key) => [
|
|
212
|
+
key,
|
|
213
|
+
Object.hasOwn(upperObject, key)
|
|
214
|
+
? mergeInlineDefaults(lowerObject[key], upperObject[key])
|
|
215
|
+
: lowerObject[key],
|
|
216
|
+
]));
|
|
217
|
+
}
|
|
218
|
+
function isObjectShape(type) {
|
|
219
|
+
return (!!(type.flags & ts.TypeFlags.Object) &&
|
|
220
|
+
!checker.isArrayType(type) &&
|
|
221
|
+
!checker.isTupleType(type) &&
|
|
222
|
+
checker.getIndexInfosOfType(type).length === 0 &&
|
|
223
|
+
type.getCallSignatures().length === 0 &&
|
|
224
|
+
type.getConstructSignatures().length === 0 &&
|
|
225
|
+
!(type.symbol?.declarations?.some((declaration) => program.isSourceFileDefaultLibrary(declaration.getSourceFile())) ?? false));
|
|
226
|
+
}
|
|
227
|
+
function visit(type, propertyPath, defaultsPath, optionalParents, secret, parentDefault) {
|
|
228
|
+
activeTypes.add(type);
|
|
229
|
+
for (const property of checker.getPropertiesOfType(type)) {
|
|
230
|
+
const node = property.valueDeclaration ?? property.declarations?.[0];
|
|
231
|
+
if (!node || invalidMembers.has(node))
|
|
232
|
+
continue;
|
|
233
|
+
if (isJavaScriptSource(node)) {
|
|
234
|
+
report('javascript_schema', 'JavaScript schema declarations are not supported.', node);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const annotations = annotationsFor(node);
|
|
238
|
+
if (annotations.ignore)
|
|
239
|
+
continue;
|
|
240
|
+
const name = property.getName();
|
|
241
|
+
if (!(ts.isPropertySignature(node) || ts.isPropertyDeclaration(node))) {
|
|
242
|
+
report('unsupported_type', 'Only data properties are supported.', node);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (ts.isPropertyDeclaration(node) &&
|
|
246
|
+
(ts.isPrivateIdentifier(node.name) ||
|
|
247
|
+
!!(ts.getCombinedModifierFlags(node) &
|
|
248
|
+
(ts.ModifierFlags.Private |
|
|
249
|
+
ts.ModifierFlags.Protected |
|
|
250
|
+
ts.ModifierFlags.Static)))) {
|
|
251
|
+
report('invalid_class_member', 'Configuration classes allow only public instance data properties.', node);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
// Generic types can keep producing fresh identities forever, including
|
|
255
|
+
// inheritance that substitutes a fresh type into a type-parameter field.
|
|
256
|
+
// Bound that undecidable expansion separately from proven type cycles.
|
|
257
|
+
if (activeProperties.has(node) && propertyPath.length >= 128) {
|
|
258
|
+
report('schema_too_deep', 'Generic type expansion exceeds the supported analysis depth of 128.', node);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (!ts.isIdentifier(node.name) ||
|
|
262
|
+
['__proto__', 'constructor', 'prototype'].includes(name)) {
|
|
263
|
+
report('invalid_property', 'Properties must use safe identifier names.', node);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (containsIntersection(node.type)) {
|
|
267
|
+
report('unsupported_type', 'Intersections are not supported.', node);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (annotations.key !== undefined &&
|
|
271
|
+
(!annotations.key ||
|
|
272
|
+
annotations.key.includes('.') ||
|
|
273
|
+
['__proto__', 'constructor', 'prototype'].includes(annotations.key))) {
|
|
274
|
+
report('invalid_annotation', 'Key must be one nonempty, safe path segment.', node);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (annotations.env !== undefined &&
|
|
278
|
+
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(annotations.env)) {
|
|
279
|
+
report('invalid_annotation', 'Env must be a valid complete environment name.', node);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (ts.isPropertyDeclaration(node) && node.initializer) {
|
|
283
|
+
const result = evaluateDefault(node.initializer, checker);
|
|
284
|
+
if (!result.ok) {
|
|
285
|
+
report('invalid_default', 'Initializers must be supported static values.', node);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (!annotations.hasDefault) {
|
|
289
|
+
annotations.hasDefault = true;
|
|
290
|
+
annotations.defaultValue = result.value;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (parentDefault &&
|
|
294
|
+
typeof parentDefault === 'object' &&
|
|
295
|
+
Object.hasOwn(parentDefault, name)) {
|
|
296
|
+
annotations.hasDefault = true;
|
|
297
|
+
annotations.defaultValue = mergeInlineDefaults(annotations.defaultValue, parentDefault[name]);
|
|
298
|
+
}
|
|
299
|
+
const currentPath = [...propertyPath, name];
|
|
300
|
+
const currentDefaultsPath = [...defaultsPath, annotations.key ?? name];
|
|
301
|
+
const optional = !!(property.flags & ts.SymbolFlags.Optional);
|
|
302
|
+
let propertyType = checker.getTypeOfSymbolAtLocation(property, node);
|
|
303
|
+
if (propertyType.isUnion() &&
|
|
304
|
+
propertyType.types.some((item) => item.flags & ts.TypeFlags.Null)) {
|
|
305
|
+
report('unsupported_type', 'Nullable unions are not supported.', node);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (optional)
|
|
309
|
+
propertyType = checker.getNonNullableType(propertyType);
|
|
310
|
+
const javaScriptDeclaration = propertyType.symbol?.declarations?.find(isJavaScriptSource);
|
|
311
|
+
if (javaScriptDeclaration) {
|
|
312
|
+
report('javascript_schema', 'JavaScript schema declarations are not supported.', javaScriptDeclaration);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const kind = kindOf(propertyType);
|
|
316
|
+
const defaultsKey = JSON.stringify(currentDefaultsPath);
|
|
317
|
+
if (defaultsPaths.has(defaultsKey)) {
|
|
318
|
+
report('duplicate_defaults_path', 'Multiple fields resolve to conflicting defaults paths.', node);
|
|
319
|
+
}
|
|
320
|
+
defaultsPaths.add(defaultsKey);
|
|
321
|
+
if (!kind && isObjectShape(propertyType)) {
|
|
322
|
+
validateHeritage(propertyType);
|
|
323
|
+
if (annotations.env !== undefined)
|
|
324
|
+
report('object_env', 'Env annotations apply only to leaves.', node);
|
|
325
|
+
const resolvesTypeParameter = node.type &&
|
|
326
|
+
!!(checker.getTypeFromTypeNode(node.type).flags &
|
|
327
|
+
ts.TypeFlags.TypeParameter);
|
|
328
|
+
if (activeTypes.has(propertyType) ||
|
|
329
|
+
(activeProperties.has(node) &&
|
|
330
|
+
!resolvesTypeParameter &&
|
|
331
|
+
!canChangeShape(node))) {
|
|
332
|
+
report('recursive_type', 'Circular configuration shapes are not supported.', node);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (checker.getPropertiesOfType(propertyType).length === 0) {
|
|
336
|
+
report('unsupported_type', 'Empty object configuration shapes are not supported.', node);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (annotations.hasDefault &&
|
|
340
|
+
(!annotations.defaultValue ||
|
|
341
|
+
typeof annotations.defaultValue !== 'object' ||
|
|
342
|
+
Array.isArray(annotations.defaultValue))) {
|
|
343
|
+
report('invalid_default', 'Object fields require object defaults.', node);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (annotations.hasDefault &&
|
|
347
|
+
Object.keys(annotations.defaultValue).some((key) => ['__proto__', 'constructor', 'prototype'].includes(key) ||
|
|
348
|
+
!checker.getPropertyOfType(propertyType, key))) {
|
|
349
|
+
report('invalid_default', 'Object defaults contain unknown or unsafe properties.', node);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const alreadyActive = activeProperties.has(node);
|
|
353
|
+
activeProperties.add(node);
|
|
354
|
+
const fieldsBeforeVisit = fields.length;
|
|
355
|
+
const diagnosticsBeforeVisit = diagnostics.length;
|
|
356
|
+
visit(propertyType, currentPath, currentDefaultsPath, optional ? [...optionalParents, currentPath] : optionalParents, secret || annotations.secret, annotations.defaultValue);
|
|
357
|
+
if (!alreadyActive)
|
|
358
|
+
activeProperties.delete(node);
|
|
359
|
+
if (fields.length === fieldsBeforeVisit &&
|
|
360
|
+
diagnostics.length === diagnosticsBeforeVisit) {
|
|
361
|
+
report('unsupported_type', 'Configuration object shapes must contain at least one included leaf.', node);
|
|
362
|
+
}
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (!kind) {
|
|
366
|
+
report('unsupported_type', 'This field type is not supported.', node);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (annotations.hasDefault &&
|
|
370
|
+
validateTypedValue(kind, annotations.defaultValue) !== undefined) {
|
|
371
|
+
report('invalid_default', 'The inline default does not match its field type.', node);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const snake = currentPath
|
|
375
|
+
.map((part) => part
|
|
376
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
377
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
378
|
+
.toUpperCase())
|
|
379
|
+
.join('_');
|
|
380
|
+
const prefix = envPrefix.replace(/_+$/, '');
|
|
381
|
+
const envName = annotations.env ?? (prefix ? `${prefix}_${snake}` : snake);
|
|
382
|
+
if (envNames.has(envName))
|
|
383
|
+
report('duplicate_env', 'Multiple fields resolve to the same environment name.', node);
|
|
384
|
+
envNames.add(envName);
|
|
385
|
+
fields.push({
|
|
386
|
+
propertyPath: currentPath,
|
|
387
|
+
defaultsPath: currentDefaultsPath,
|
|
388
|
+
envName,
|
|
389
|
+
kind,
|
|
390
|
+
required: !optional,
|
|
391
|
+
secret: secret || annotations.secret,
|
|
392
|
+
hasDefault: annotations.hasDefault,
|
|
393
|
+
...(annotations.hasDefault
|
|
394
|
+
? { defaultValue: annotations.defaultValue }
|
|
395
|
+
: {}),
|
|
396
|
+
optionalParents,
|
|
397
|
+
location: locationOf(node),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
activeTypes.delete(type);
|
|
401
|
+
}
|
|
402
|
+
visit(rootType, [], [], [], false);
|
|
403
|
+
if (fields.length === 0 && diagnostics.length === 0) {
|
|
404
|
+
report('unsupported_type', 'Configuration roots must contain at least one included leaf.', root);
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
...(diagnostics.length === 0 && root.name
|
|
408
|
+
? { rootName: root.name.text, rootExport }
|
|
409
|
+
: {}),
|
|
410
|
+
inputPath,
|
|
411
|
+
fields,
|
|
412
|
+
diagnostics: sortDiagnostics(diagnostics),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export interface Annotations {
|
|
3
|
+
root: boolean;
|
|
4
|
+
ignore: boolean;
|
|
5
|
+
secret: boolean;
|
|
6
|
+
key?: string;
|
|
7
|
+
env?: string;
|
|
8
|
+
hasDefault: boolean;
|
|
9
|
+
defaultValue?: unknown;
|
|
10
|
+
}
|
|
11
|
+
export type Report = (code: string, message: string, node: ts.Node) => void;
|
|
12
|
+
export declare function typespunSymbols(program: ts.Program): Map<ts.Symbol, string>;
|
|
13
|
+
export declare function readAnnotations(node: ts.Node, report: Report, checker?: ts.TypeChecker, symbols?: Map<ts.Symbol, string>): Annotations;
|
|
14
|
+
//# sourceMappingURL=annotations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"annotations.d.ts","sourceRoot":"","sources":["../../src/analyzer/annotations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,YAAY,CAAC;AAG5B,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,OAAO,CAAC;IACpB,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,MAAM,MAAM,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,IAAI,CAAC;AAE5E,wBAAgB,eAAe,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,GAAG,GAAG,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CA+B3E;AAED,wBAAgB,eAAe,CAC7B,IAAI,EAAE,EAAE,CAAC,IAAI,EACb,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,EAAE,CAAC,WAAW,EACxB,OAAO,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,GAC/B,WAAW,CA+Eb"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { evaluateDefault } from './default-expression.js';
|
|
3
|
+
export function typespunSymbols(program) {
|
|
4
|
+
const checker = program.getTypeChecker();
|
|
5
|
+
const symbols = new Map();
|
|
6
|
+
for (const source of program.getSourceFiles()) {
|
|
7
|
+
for (const statement of source.statements) {
|
|
8
|
+
if (!(ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)))
|
|
9
|
+
continue;
|
|
10
|
+
const specifier = statement.moduleSpecifier;
|
|
11
|
+
if (!specifier ||
|
|
12
|
+
!ts.isStringLiteral(specifier) ||
|
|
13
|
+
specifier.text !== 'typespun')
|
|
14
|
+
continue;
|
|
15
|
+
const moduleSymbol = checker.getSymbolAtLocation(specifier);
|
|
16
|
+
if (!moduleSymbol)
|
|
17
|
+
continue;
|
|
18
|
+
for (const symbol of checker.getExportsOfModule(moduleSymbol)) {
|
|
19
|
+
symbols.set(symbol.flags & ts.SymbolFlags.Alias
|
|
20
|
+
? checker.getAliasedSymbol(symbol)
|
|
21
|
+
: symbol, symbol.name);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return symbols;
|
|
26
|
+
}
|
|
27
|
+
export function readAnnotations(node, report, checker, symbols) {
|
|
28
|
+
const result = {
|
|
29
|
+
root: false,
|
|
30
|
+
ignore: false,
|
|
31
|
+
secret: false,
|
|
32
|
+
hasDefault: false,
|
|
33
|
+
};
|
|
34
|
+
for (const tag of ts.getJSDocTags(node)) {
|
|
35
|
+
const text = ts.getTextOfJSDocComment(tag.comment)?.trim() ?? '';
|
|
36
|
+
switch (tag.tagName.text) {
|
|
37
|
+
case 'typespun':
|
|
38
|
+
result.root = true;
|
|
39
|
+
break;
|
|
40
|
+
case 'ignore':
|
|
41
|
+
result.ignore = true;
|
|
42
|
+
break;
|
|
43
|
+
case 'secret':
|
|
44
|
+
result.secret = true;
|
|
45
|
+
break;
|
|
46
|
+
case 'key':
|
|
47
|
+
result.key = text;
|
|
48
|
+
break;
|
|
49
|
+
case 'env':
|
|
50
|
+
result.env = text;
|
|
51
|
+
break;
|
|
52
|
+
case 'default':
|
|
53
|
+
try {
|
|
54
|
+
result.defaultValue = JSON.parse(text);
|
|
55
|
+
result.hasDefault = true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
report('invalid_default', 'Inline defaults must use valid JSON.', tag);
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (checker && symbols && ts.canHaveDecorators(node)) {
|
|
64
|
+
for (const decorator of ts.getDecorators(node) ?? []) {
|
|
65
|
+
if (!ts.isCallExpression(decorator.expression))
|
|
66
|
+
continue;
|
|
67
|
+
const call = decorator.expression;
|
|
68
|
+
let symbol = checker.getSymbolAtLocation(call.expression);
|
|
69
|
+
if (symbol?.flags && symbol.flags & ts.SymbolFlags.Alias)
|
|
70
|
+
symbol = checker.getAliasedSymbol(symbol);
|
|
71
|
+
const name = symbol && symbols.get(symbol);
|
|
72
|
+
if (!name)
|
|
73
|
+
continue;
|
|
74
|
+
if (name === 'Config')
|
|
75
|
+
result.root = true;
|
|
76
|
+
if (name === 'Ignore')
|
|
77
|
+
result.ignore = true;
|
|
78
|
+
if (name === 'Secret')
|
|
79
|
+
result.secret = true;
|
|
80
|
+
if (name === 'Default' || name === 'Key' || name === 'Env') {
|
|
81
|
+
const value = call.arguments.length === 1 && call.arguments[0]
|
|
82
|
+
? evaluateDefault(call.arguments[0], checker)
|
|
83
|
+
: { ok: false };
|
|
84
|
+
if (!value.ok) {
|
|
85
|
+
report(name === 'Default' ? 'invalid_default' : 'invalid_annotation', 'Annotation arguments must be supported static values.', decorator);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (name === 'Default') {
|
|
89
|
+
result.hasDefault = true;
|
|
90
|
+
result.defaultValue = value.value;
|
|
91
|
+
}
|
|
92
|
+
else if (typeof value.value !== 'string')
|
|
93
|
+
report('invalid_annotation', 'Key and Env require a string.', decorator);
|
|
94
|
+
else if (name === 'Key')
|
|
95
|
+
result.key = value.value;
|
|
96
|
+
else
|
|
97
|
+
result.env = value.value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export type StaticValue = {
|
|
3
|
+
readonly ok: true;
|
|
4
|
+
readonly value: unknown;
|
|
5
|
+
} | {
|
|
6
|
+
readonly ok: false;
|
|
7
|
+
};
|
|
8
|
+
/** Reads syntax only. No schema expressions or user modules are executed. */
|
|
9
|
+
export declare function evaluateDefault(expression: ts.Expression, checker: ts.TypeChecker): StaticValue;
|
|
10
|
+
//# sourceMappingURL=default-expression.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"default-expression.d.ts","sourceRoot":"","sources":["../../src/analyzer/default-expression.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,YAAY,CAAC;AAE5B,MAAM,MAAM,WAAW,GACnB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAC9C;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAA;CAAE,CAAC;AAG3B,6EAA6E;AAC7E,wBAAgB,eAAe,CAC7B,UAAU,EAAE,EAAE,CAAC,UAAU,EACzB,OAAO,EAAE,EAAE,CAAC,WAAW,GACtB,WAAW,CAyEb"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
const invalid = { ok: false };
|
|
3
|
+
/** Reads syntax only. No schema expressions or user modules are executed. */
|
|
4
|
+
export function evaluateDefault(expression, checker) {
|
|
5
|
+
if (ts.isParenthesizedExpression(expression) ||
|
|
6
|
+
ts.isAsExpression(expression) ||
|
|
7
|
+
ts.isSatisfiesExpression(expression) ||
|
|
8
|
+
ts.isTypeAssertionExpression(expression)) {
|
|
9
|
+
return evaluateDefault(expression.expression, checker);
|
|
10
|
+
}
|
|
11
|
+
if (ts.isStringLiteral(expression))
|
|
12
|
+
return { ok: true, value: expression.text };
|
|
13
|
+
if (ts.isNumericLiteral(expression)) {
|
|
14
|
+
const value = Number(expression.text);
|
|
15
|
+
return Number.isFinite(value) ? { ok: true, value } : invalid;
|
|
16
|
+
}
|
|
17
|
+
if (expression.kind === ts.SyntaxKind.TrueKeyword)
|
|
18
|
+
return { ok: true, value: true };
|
|
19
|
+
if (expression.kind === ts.SyntaxKind.FalseKeyword)
|
|
20
|
+
return { ok: true, value: false };
|
|
21
|
+
if (ts.isPrefixUnaryExpression(expression) &&
|
|
22
|
+
expression.operator === ts.SyntaxKind.MinusToken &&
|
|
23
|
+
ts.isNumericLiteral(expression.operand)) {
|
|
24
|
+
const value = -Number(expression.operand.text);
|
|
25
|
+
return Number.isFinite(value) ? { ok: true, value } : invalid;
|
|
26
|
+
}
|
|
27
|
+
if (ts.isArrayLiteralExpression(expression)) {
|
|
28
|
+
const values = [];
|
|
29
|
+
for (const element of expression.elements) {
|
|
30
|
+
const result = evaluateDefault(element, checker);
|
|
31
|
+
if (!result.ok)
|
|
32
|
+
return invalid;
|
|
33
|
+
values.push(result.value);
|
|
34
|
+
}
|
|
35
|
+
return { ok: true, value: values };
|
|
36
|
+
}
|
|
37
|
+
if (ts.isObjectLiteralExpression(expression)) {
|
|
38
|
+
const value = {};
|
|
39
|
+
for (const property of expression.properties) {
|
|
40
|
+
if (!ts.isPropertyAssignment(property) ||
|
|
41
|
+
!(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)))
|
|
42
|
+
return invalid;
|
|
43
|
+
const key = property.name.text;
|
|
44
|
+
if (['__proto__', 'constructor', 'prototype'].includes(key) ||
|
|
45
|
+
Object.hasOwn(value, key))
|
|
46
|
+
return invalid;
|
|
47
|
+
const result = evaluateDefault(property.initializer, checker);
|
|
48
|
+
if (!result.ok)
|
|
49
|
+
return invalid;
|
|
50
|
+
value[key] = result.value;
|
|
51
|
+
}
|
|
52
|
+
return { ok: true, value };
|
|
53
|
+
}
|
|
54
|
+
if (ts.isPropertyAccessExpression(expression) ||
|
|
55
|
+
ts.isElementAccessExpression(expression)) {
|
|
56
|
+
const symbol = checker.getSymbolAtLocation(ts.isPropertyAccessExpression(expression) ? expression.name : expression);
|
|
57
|
+
if (symbol?.flags && symbol.flags & ts.SymbolFlags.EnumMember) {
|
|
58
|
+
const declaration = symbol.valueDeclaration;
|
|
59
|
+
const value = declaration && ts.isEnumMember(declaration)
|
|
60
|
+
? checker.getConstantValue(declaration)
|
|
61
|
+
: undefined;
|
|
62
|
+
if (typeof value === 'string')
|
|
63
|
+
return { ok: true, value };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return invalid;
|
|
67
|
+
}
|