@sembl/compiler 0.1.0 → 0.2.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/dist/cli/index.js +542 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/{chunk-Y4FKH2ZU.js → index.cjs} +136 -36
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +220 -0
- package/dist/index.d.ts +41 -2
- package/dist/index.js +524 -12
- package/dist/index.js.map +1 -1
- package/package.json +16 -7
- package/dist/chunk-Y4FKH2ZU.js.map +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
emitSchemas,
|
|
4
|
-
extractSchemas
|
|
5
|
-
} from "../chunk-Y4FKH2ZU.js";
|
|
6
2
|
|
|
7
3
|
// src/cli/index.ts
|
|
8
4
|
import { Command } from "commander";
|
|
@@ -10,6 +6,459 @@ import { Command } from "commander";
|
|
|
10
6
|
// src/cli/commands/extract.ts
|
|
11
7
|
import { resolve } from "path";
|
|
12
8
|
import { glob } from "glob";
|
|
9
|
+
|
|
10
|
+
// src/extractor/ast-extractor.ts
|
|
11
|
+
import { Project, ScriptTarget } from "ts-morph";
|
|
12
|
+
|
|
13
|
+
// src/extractor/decorator-parser.ts
|
|
14
|
+
import {
|
|
15
|
+
Node,
|
|
16
|
+
SyntaxKind
|
|
17
|
+
} from "ts-morph";
|
|
18
|
+
var CONSTRAINT_KEYS = {
|
|
19
|
+
maxLength: "number",
|
|
20
|
+
minLength: "number",
|
|
21
|
+
minimum: "number",
|
|
22
|
+
maximum: "number",
|
|
23
|
+
minItems: "number",
|
|
24
|
+
maxItems: "number",
|
|
25
|
+
pattern: "string"
|
|
26
|
+
};
|
|
27
|
+
function getDecoratorStringArg(decorator) {
|
|
28
|
+
if (!decorator.isDecoratorFactory()) {
|
|
29
|
+
return void 0;
|
|
30
|
+
}
|
|
31
|
+
const args = decorator.getArguments();
|
|
32
|
+
if (args.length === 0) {
|
|
33
|
+
return void 0;
|
|
34
|
+
}
|
|
35
|
+
const arg = args[0];
|
|
36
|
+
const text = arg.getText();
|
|
37
|
+
if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) {
|
|
38
|
+
return text.slice(1, -1);
|
|
39
|
+
}
|
|
40
|
+
if (text.startsWith("`") && text.endsWith("`")) {
|
|
41
|
+
return text.slice(1, -1);
|
|
42
|
+
}
|
|
43
|
+
return void 0;
|
|
44
|
+
}
|
|
45
|
+
function parseSchemaDecorator(classDecl) {
|
|
46
|
+
const decorator = classDecl.getDecorator("Schema");
|
|
47
|
+
if (!decorator) {
|
|
48
|
+
return void 0;
|
|
49
|
+
}
|
|
50
|
+
return getDecoratorStringArg(decorator);
|
|
51
|
+
}
|
|
52
|
+
function parseDescribeDecorator(propDecl) {
|
|
53
|
+
const decorator = propDecl.getDecorator("Describe");
|
|
54
|
+
if (!decorator) {
|
|
55
|
+
return void 0;
|
|
56
|
+
}
|
|
57
|
+
return getDecoratorStringArg(decorator);
|
|
58
|
+
}
|
|
59
|
+
function readNumberLiteral(node) {
|
|
60
|
+
if (Node.isNumericLiteral(node)) {
|
|
61
|
+
return node.getLiteralValue();
|
|
62
|
+
}
|
|
63
|
+
if (Node.isPrefixUnaryExpression(node)) {
|
|
64
|
+
const operand = node.getOperand();
|
|
65
|
+
if (Node.isNumericLiteral(operand)) {
|
|
66
|
+
const operator = node.getOperatorToken();
|
|
67
|
+
if (operator === SyntaxKind.MinusToken) {
|
|
68
|
+
return -operand.getLiteralValue();
|
|
69
|
+
}
|
|
70
|
+
if (operator === SyntaxKind.PlusToken) {
|
|
71
|
+
return operand.getLiteralValue();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return void 0;
|
|
76
|
+
}
|
|
77
|
+
function readStringLiteral(node) {
|
|
78
|
+
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
|
|
79
|
+
return node.getLiteralValue();
|
|
80
|
+
}
|
|
81
|
+
return void 0;
|
|
82
|
+
}
|
|
83
|
+
function readConstraintEntry(property, constraints, scope) {
|
|
84
|
+
if (!Node.isPropertyAssignment(property)) {
|
|
85
|
+
scope.context.warn(
|
|
86
|
+
scope,
|
|
87
|
+
`@Constrain entry \`${property.getText()}\` is not a \`key: value\` pair of compile-time constants and cannot be read from source. Skipping it.`
|
|
88
|
+
);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const nameNode = property.getNameNode();
|
|
92
|
+
if (!Node.isIdentifier(nameNode) && !Node.isStringLiteral(nameNode)) {
|
|
93
|
+
scope.context.warn(
|
|
94
|
+
scope,
|
|
95
|
+
`@Constrain key \`${nameNode.getText()}\` is computed and cannot be read from source. Skipping it.`
|
|
96
|
+
);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const key = nameNode.getText().replace(/^["']|["']$/g, "");
|
|
100
|
+
if (!Object.prototype.hasOwnProperty.call(CONSTRAINT_KEYS, key)) {
|
|
101
|
+
scope.context.warn(
|
|
102
|
+
scope,
|
|
103
|
+
`@Constrain key "${key}" is not a FieldConstraints property. Expected one of: ${Object.keys(CONSTRAINT_KEYS).join(", ")}. Skipping it.`
|
|
104
|
+
);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
const expected = CONSTRAINT_KEYS[key];
|
|
108
|
+
const initializer = property.getInitializerOrThrow();
|
|
109
|
+
const value = expected === "number" ? readNumberLiteral(initializer) : readStringLiteral(initializer);
|
|
110
|
+
if (value === void 0) {
|
|
111
|
+
scope.context.warn(
|
|
112
|
+
scope,
|
|
113
|
+
`@Constrain value for "${key}" is \`${initializer.getText()}\`, which is not a ${expected} literal the compiler can read from source. Skipping it.`
|
|
114
|
+
);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
constraints[key] = value;
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
function parseConstrainDecorator(propDecl, scope) {
|
|
121
|
+
const decorator = propDecl.getDecorator("Constrain");
|
|
122
|
+
if (!decorator) {
|
|
123
|
+
return void 0;
|
|
124
|
+
}
|
|
125
|
+
const args = decorator.isDecoratorFactory() ? decorator.getArguments() : [];
|
|
126
|
+
const argument = args[0];
|
|
127
|
+
if (argument === void 0 || !Node.isObjectLiteralExpression(argument)) {
|
|
128
|
+
scope.context.warn(
|
|
129
|
+
scope,
|
|
130
|
+
`@Constrain expects an inline object literal of compile-time constants, but ${argument === void 0 ? "it was called with no argument" : `was given \`${argument.getText()}\``}. Ignoring the decorator.`
|
|
131
|
+
);
|
|
132
|
+
return void 0;
|
|
133
|
+
}
|
|
134
|
+
const constraints = {};
|
|
135
|
+
let accepted = 0;
|
|
136
|
+
for (const property of argument.getProperties()) {
|
|
137
|
+
if (readConstraintEntry(property, constraints, scope)) {
|
|
138
|
+
accepted += 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return accepted > 0 ? constraints : void 0;
|
|
142
|
+
}
|
|
143
|
+
function parseValuesFromDecorator(propDecl, scope) {
|
|
144
|
+
const decorator = propDecl.getDecorator("ValuesFrom");
|
|
145
|
+
if (!decorator) {
|
|
146
|
+
return void 0;
|
|
147
|
+
}
|
|
148
|
+
const sourceId = getDecoratorStringArg(decorator);
|
|
149
|
+
if (sourceId === void 0) {
|
|
150
|
+
scope.context.warn(
|
|
151
|
+
scope,
|
|
152
|
+
`@ValuesFrom expects a string literal naming the enum source, which the caller resolves at coercion time. Ignoring the decorator.`
|
|
153
|
+
);
|
|
154
|
+
return void 0;
|
|
155
|
+
}
|
|
156
|
+
return sourceId;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/extractor/extraction-context.ts
|
|
160
|
+
import { createHash } from "crypto";
|
|
161
|
+
function createExtractionContext() {
|
|
162
|
+
const warnings = [];
|
|
163
|
+
const synthesizedSchemas = {};
|
|
164
|
+
const nestedReferences = [];
|
|
165
|
+
return {
|
|
166
|
+
warnings,
|
|
167
|
+
synthesizedSchemas,
|
|
168
|
+
warn(scope, message) {
|
|
169
|
+
warnings.push(`${scope.className}.${scope.propertyPath}: ${message}`);
|
|
170
|
+
},
|
|
171
|
+
registerSynthesizedSchema(schema) {
|
|
172
|
+
synthesizedSchemas[schema.id] = schema;
|
|
173
|
+
},
|
|
174
|
+
recordNestedReference(scope, nestedSchemaId, typeText) {
|
|
175
|
+
nestedReferences.push({
|
|
176
|
+
className: scope.className,
|
|
177
|
+
propertyPath: scope.propertyPath,
|
|
178
|
+
nestedSchemaId,
|
|
179
|
+
typeText
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
reportUnresolvedNestedSchemas(knownSchemaIds) {
|
|
183
|
+
for (const reference of nestedReferences) {
|
|
184
|
+
if (knownSchemaIds.has(reference.nestedSchemaId)) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
warnings.push(
|
|
188
|
+
`${reference.className}.${reference.propertyPath}: type \`${reference.typeText}\` resolves to nested schema "${reference.nestedSchemaId}", which is not a @Schema-decorated class in this extraction. It will emit as an object with no properties. Decorate it with @Schema if it is yours to change, or use a type the contract supports \u2014 a \`Date\`, for instance, extracts as an ISO-8601 string.`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function synthesizedSchemaId(scope, structuralSignature) {
|
|
195
|
+
const path = `${scope.className}_${scope.propertyPath}`.replace(
|
|
196
|
+
/[^A-Za-z0-9_]/g,
|
|
197
|
+
"_"
|
|
198
|
+
);
|
|
199
|
+
const digest = createHash("sha256").update(structuralSignature).digest("hex").slice(0, 8);
|
|
200
|
+
return `${path}__${digest}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/extractor/type-resolver.ts
|
|
204
|
+
function reportUnsupported(type, scope, reason) {
|
|
205
|
+
scope.context.warn(
|
|
206
|
+
scope,
|
|
207
|
+
`unsupported type \`${type.getText(scope.node)}\` \u2014 ${reason}. Falling back to string.`
|
|
208
|
+
);
|
|
209
|
+
return { kind: "string" };
|
|
210
|
+
}
|
|
211
|
+
function resolveInlineObjectType(type, scope) {
|
|
212
|
+
const properties = type.getProperties();
|
|
213
|
+
if (properties.length === 0) {
|
|
214
|
+
const isMap = type.getStringIndexType() !== void 0 || type.getNumberIndexType() !== void 0;
|
|
215
|
+
return reportUnsupported(
|
|
216
|
+
type,
|
|
217
|
+
scope,
|
|
218
|
+
isMap ? "a map with an index signature has no FieldType equivalent, so its entries cannot be described to the model; declare a @Schema class with the keys you expect, or take the value as a JSON string and parse it yourself" : "an object type with no properties has nothing to extract"
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const fields = properties.map((property) => {
|
|
222
|
+
const name = property.getName();
|
|
223
|
+
return {
|
|
224
|
+
name,
|
|
225
|
+
// Members of an inline type carry no @Describe, so the owning field's
|
|
226
|
+
// description is the only semantics the model gets for them.
|
|
227
|
+
description: "",
|
|
228
|
+
type: resolveFieldType(property.getTypeAtLocation(scope.node), {
|
|
229
|
+
...scope,
|
|
230
|
+
propertyPath: `${scope.propertyPath}.${name}`
|
|
231
|
+
}),
|
|
232
|
+
required: !property.isOptional()
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
const id = synthesizedSchemaId(
|
|
236
|
+
scope,
|
|
237
|
+
JSON.stringify(fields.map((f) => [f.name, f.required, f.type]))
|
|
238
|
+
);
|
|
239
|
+
scope.context.registerSynthesizedSchema({
|
|
240
|
+
id,
|
|
241
|
+
description: `Inline object type declared at ${scope.className}.${scope.propertyPath}.`,
|
|
242
|
+
fields
|
|
243
|
+
});
|
|
244
|
+
return { kind: "object", nestedSchemaId: id };
|
|
245
|
+
}
|
|
246
|
+
function resolveFieldType(type, scope) {
|
|
247
|
+
if (type.isUnion()) {
|
|
248
|
+
const members = type.getUnionTypes().filter((t) => !t.isUndefined() && !t.isNull());
|
|
249
|
+
if (members.length === 1) {
|
|
250
|
+
return resolveFieldType(members[0], scope);
|
|
251
|
+
}
|
|
252
|
+
if (members.length === 0) {
|
|
253
|
+
return reportUnsupported(type, scope, "there is no value type to extract");
|
|
254
|
+
}
|
|
255
|
+
if (members.every((t) => t.isStringLiteral())) {
|
|
256
|
+
return {
|
|
257
|
+
kind: "enum",
|
|
258
|
+
values: members.map((t) => t.getLiteralValue())
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (members.every((t) => t.isBoolean() || t.isBooleanLiteral())) {
|
|
262
|
+
return { kind: "boolean" };
|
|
263
|
+
}
|
|
264
|
+
if (members.every((t) => t.isNumber() || t.isNumberLiteral())) {
|
|
265
|
+
return { kind: "number" };
|
|
266
|
+
}
|
|
267
|
+
return reportUnsupported(
|
|
268
|
+
type,
|
|
269
|
+
scope,
|
|
270
|
+
"a union mixing several kinds of value has no single FieldType; split it into separate fields, or narrow it to one kind"
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (type.isString() || type.isStringLiteral()) {
|
|
274
|
+
return { kind: "string" };
|
|
275
|
+
}
|
|
276
|
+
if (type.isNumber() || type.isNumberLiteral()) {
|
|
277
|
+
return { kind: "number" };
|
|
278
|
+
}
|
|
279
|
+
if (type.isBoolean() || type.isBooleanLiteral()) {
|
|
280
|
+
return { kind: "boolean" };
|
|
281
|
+
}
|
|
282
|
+
if (type.isArray()) {
|
|
283
|
+
const elementType = type.getArrayElementTypeOrThrow();
|
|
284
|
+
return { kind: "array", items: resolveFieldType(elementType, scope) };
|
|
285
|
+
}
|
|
286
|
+
if (type.isEnum()) {
|
|
287
|
+
const members = type.getUnionTypes().map((t) => t.getLiteralValue()).filter((v) => typeof v === "string");
|
|
288
|
+
if (members.length > 0) {
|
|
289
|
+
return { kind: "enum", values: members };
|
|
290
|
+
}
|
|
291
|
+
return reportUnsupported(
|
|
292
|
+
type,
|
|
293
|
+
scope,
|
|
294
|
+
"its members are not string values, so they cannot be offered to the model as an enum"
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
if (type.isObject()) {
|
|
298
|
+
const symbol = type.getSymbol() ?? type.getAliasSymbol();
|
|
299
|
+
const typeName = symbol?.getName();
|
|
300
|
+
if (typeName && typeName !== "__type" && typeName !== "Object") {
|
|
301
|
+
scope.context.recordNestedReference(
|
|
302
|
+
scope,
|
|
303
|
+
typeName,
|
|
304
|
+
type.getText(scope.node)
|
|
305
|
+
);
|
|
306
|
+
return { kind: "object", nestedSchemaId: typeName };
|
|
307
|
+
}
|
|
308
|
+
return resolveInlineObjectType(type, scope);
|
|
309
|
+
}
|
|
310
|
+
return reportUnsupported(
|
|
311
|
+
type,
|
|
312
|
+
scope,
|
|
313
|
+
"it maps to none of string, number, boolean, array, enum, or a @Schema class"
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/extractor/class-visitor.ts
|
|
318
|
+
function describeKind(type) {
|
|
319
|
+
return type.kind === "array" ? `${describeKind(type.items)}[]` : type.kind;
|
|
320
|
+
}
|
|
321
|
+
function applyValuesFrom(type, sourceId, scope) {
|
|
322
|
+
if (type.kind === "string") {
|
|
323
|
+
return { kind: "dynamicEnum", sourceId };
|
|
324
|
+
}
|
|
325
|
+
if (type.kind === "array" && type.items.kind === "string") {
|
|
326
|
+
return { kind: "array", items: { kind: "dynamicEnum", sourceId } };
|
|
327
|
+
}
|
|
328
|
+
scope.context.warn(
|
|
329
|
+
scope,
|
|
330
|
+
`@ValuesFrom("${sourceId}") applies to a string or string[] field, but this field resolved to ${describeKind(type)}. Leaving the type unchanged.`
|
|
331
|
+
);
|
|
332
|
+
return type;
|
|
333
|
+
}
|
|
334
|
+
function visitClass(classDecl, context) {
|
|
335
|
+
const description = parseSchemaDecorator(classDecl);
|
|
336
|
+
if (description === void 0) {
|
|
337
|
+
return void 0;
|
|
338
|
+
}
|
|
339
|
+
const className = classDecl.getName();
|
|
340
|
+
if (!className) {
|
|
341
|
+
return void 0;
|
|
342
|
+
}
|
|
343
|
+
const fields = [];
|
|
344
|
+
for (const prop of classDecl.getProperties()) {
|
|
345
|
+
const fieldDescription = parseDescribeDecorator(prop);
|
|
346
|
+
if (fieldDescription === void 0) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const name = prop.getName();
|
|
350
|
+
const isOptional = prop.hasQuestionToken();
|
|
351
|
+
const scope = {
|
|
352
|
+
className,
|
|
353
|
+
propertyPath: name,
|
|
354
|
+
node: prop,
|
|
355
|
+
context
|
|
356
|
+
};
|
|
357
|
+
let type = resolveFieldType(prop.getType(), scope);
|
|
358
|
+
const sourceId = parseValuesFromDecorator(prop, scope);
|
|
359
|
+
if (sourceId !== void 0) {
|
|
360
|
+
type = applyValuesFrom(type, sourceId, scope);
|
|
361
|
+
}
|
|
362
|
+
const constraints = parseConstrainDecorator(prop, scope);
|
|
363
|
+
fields.push({
|
|
364
|
+
name,
|
|
365
|
+
description: fieldDescription,
|
|
366
|
+
type,
|
|
367
|
+
required: !isOptional,
|
|
368
|
+
...constraints !== void 0 ? { constraints } : {}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
id: className,
|
|
373
|
+
description,
|
|
374
|
+
fields
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/extractor/ast-extractor.ts
|
|
379
|
+
function extractSchemas(options) {
|
|
380
|
+
const project = new Project({
|
|
381
|
+
tsConfigFilePath: options.tsconfigPath,
|
|
382
|
+
skipAddingFilesFromTsConfig: true,
|
|
383
|
+
compilerOptions: {
|
|
384
|
+
experimentalDecorators: true,
|
|
385
|
+
strict: true,
|
|
386
|
+
// Without a target the default lib is ES5, so anything newer — `Map`,
|
|
387
|
+
// `Set` — resolves to `any` and lands in the unsupported-type warning as
|
|
388
|
+
// "any", naming a type the author never wrote.
|
|
389
|
+
target: ScriptTarget.ES2022
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
for (const pattern of options.filePatterns) {
|
|
393
|
+
project.addSourceFilesAtPaths(pattern);
|
|
394
|
+
}
|
|
395
|
+
const context = createExtractionContext();
|
|
396
|
+
const schemas = {};
|
|
397
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
398
|
+
for (const classDecl of sourceFile.getClasses()) {
|
|
399
|
+
const schema = visitClass(classDecl, context);
|
|
400
|
+
if (schema) {
|
|
401
|
+
schemas[schema.id] = schema;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
for (const [id, schema] of Object.entries(context.synthesizedSchemas)) {
|
|
406
|
+
schemas[id] = schema;
|
|
407
|
+
}
|
|
408
|
+
context.reportUnresolvedNestedSchemas(new Set(Object.keys(schemas)));
|
|
409
|
+
return { schemas, warnings: [...context.warnings] };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/generator/schema-emitter.ts
|
|
413
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
414
|
+
import { join } from "path";
|
|
415
|
+
function schemaToSource(schema) {
|
|
416
|
+
const json = JSON.stringify(schema, null, 2);
|
|
417
|
+
return `// Auto-generated by sembl extract \u2014 do not edit
|
|
418
|
+
import type { RuntimeSchema } from "@sembl/core";
|
|
419
|
+
|
|
420
|
+
export const ${schema.id}Schema: RuntimeSchema = ${json};
|
|
421
|
+
`;
|
|
422
|
+
}
|
|
423
|
+
function emitSchemas(bundle, outputDir) {
|
|
424
|
+
mkdirSync(outputDir, { recursive: true });
|
|
425
|
+
const emittedFiles = [];
|
|
426
|
+
const schemaIds = [];
|
|
427
|
+
for (const [id, schema] of Object.entries(bundle.schemas)) {
|
|
428
|
+
const fileName = `${id}.schema.ts`;
|
|
429
|
+
const filePath = join(outputDir, fileName);
|
|
430
|
+
writeFileSync(filePath, schemaToSource(schema), "utf-8");
|
|
431
|
+
emittedFiles.push(filePath);
|
|
432
|
+
schemaIds.push(id);
|
|
433
|
+
}
|
|
434
|
+
const indexLines = [
|
|
435
|
+
"// Auto-generated by sembl extract \u2014 do not edit",
|
|
436
|
+
'import type { SchemaBundle } from "@sembl/core";',
|
|
437
|
+
""
|
|
438
|
+
];
|
|
439
|
+
for (const id of schemaIds) {
|
|
440
|
+
indexLines.push(`import { ${id}Schema } from "./${id}.schema.js";`);
|
|
441
|
+
}
|
|
442
|
+
indexLines.push("");
|
|
443
|
+
indexLines.push("export const bundle: SchemaBundle = {");
|
|
444
|
+
indexLines.push(" schemas: {");
|
|
445
|
+
for (const id of schemaIds) {
|
|
446
|
+
indexLines.push(` ${id}: ${id}Schema,`);
|
|
447
|
+
}
|
|
448
|
+
indexLines.push(" },");
|
|
449
|
+
indexLines.push("};");
|
|
450
|
+
indexLines.push("");
|
|
451
|
+
for (const id of schemaIds) {
|
|
452
|
+
indexLines.push(`export { ${id}Schema } from "./${id}.schema.js";`);
|
|
453
|
+
}
|
|
454
|
+
indexLines.push("");
|
|
455
|
+
const indexPath = join(outputDir, "index.ts");
|
|
456
|
+
writeFileSync(indexPath, indexLines.join("\n"), "utf-8");
|
|
457
|
+
emittedFiles.push(indexPath);
|
|
458
|
+
return emittedFiles;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// src/cli/commands/extract.ts
|
|
13
462
|
async function extractCommand(options) {
|
|
14
463
|
const inputDir = resolve(options.input);
|
|
15
464
|
const outputDir = resolve(options.output);
|
|
@@ -60,6 +509,80 @@ async function extractCommand(options) {
|
|
|
60
509
|
};
|
|
61
510
|
}
|
|
62
511
|
|
|
512
|
+
// src/cli/commands/eval.ts
|
|
513
|
+
import { pathToFileURL } from "url";
|
|
514
|
+
import { resolve as resolve2, join as join2 } from "path";
|
|
515
|
+
import {
|
|
516
|
+
runEval,
|
|
517
|
+
loadFixtures,
|
|
518
|
+
loadReport,
|
|
519
|
+
saveReport,
|
|
520
|
+
diffReports,
|
|
521
|
+
formatReport,
|
|
522
|
+
replayOrRecord
|
|
523
|
+
} from "@sembl/testing";
|
|
524
|
+
async function loadConfig(path) {
|
|
525
|
+
const url = pathToFileURL(resolve2(path)).href;
|
|
526
|
+
const mod = await import(url);
|
|
527
|
+
const config = mod.default ?? mod;
|
|
528
|
+
if (!config.schema || typeof config.schema !== "object" || !("id" in config.schema)) {
|
|
529
|
+
throw new Error(`${path} must export a \`schema\` (a RuntimeSchema or defineSchema result)`);
|
|
530
|
+
}
|
|
531
|
+
if (!config.provider || typeof config.provider.complete !== "function") {
|
|
532
|
+
throw new Error(`${path} must export a \`provider\``);
|
|
533
|
+
}
|
|
534
|
+
return config;
|
|
535
|
+
}
|
|
536
|
+
async function evalCommand(options) {
|
|
537
|
+
let config;
|
|
538
|
+
let fixtures;
|
|
539
|
+
try {
|
|
540
|
+
config = await loadConfig(options.config);
|
|
541
|
+
fixtures = loadFixtures(options.fixtures);
|
|
542
|
+
} catch (error) {
|
|
543
|
+
console.error(`sembl eval: ${error instanceof Error ? error.message : String(error)}`);
|
|
544
|
+
return { exitCode: 1 };
|
|
545
|
+
}
|
|
546
|
+
if (fixtures.length === 0) {
|
|
547
|
+
console.error(`sembl eval: no fixtures found in ${resolve2(options.fixtures)}`);
|
|
548
|
+
return { exitCode: 1 };
|
|
549
|
+
}
|
|
550
|
+
const provider = options.replay ? replayOrRecord(resolve2(options.replay), config.provider) : config.provider;
|
|
551
|
+
const out = options.out ?? join2(resolve2(options.fixtures), ".sembl-eval", "last-run.json");
|
|
552
|
+
const previous = loadReport(out);
|
|
553
|
+
const report = await runEval({
|
|
554
|
+
...config.coerceOptions,
|
|
555
|
+
schema: config.schema,
|
|
556
|
+
bundle: config.bundle,
|
|
557
|
+
enumResolver: config.enumResolver,
|
|
558
|
+
prices: config.prices,
|
|
559
|
+
provider,
|
|
560
|
+
fixtures,
|
|
561
|
+
mode: options.mode,
|
|
562
|
+
provenance: options.provenance,
|
|
563
|
+
concurrency: options.concurrency
|
|
564
|
+
});
|
|
565
|
+
const diff = previous ? diffReports(previous, report) : void 0;
|
|
566
|
+
console.log(formatReport(report, diff));
|
|
567
|
+
saveReport(report, out);
|
|
568
|
+
console.log(`
|
|
569
|
+
Report written to ${out}${previous ? " (deltas are against the previous run)" : ""}`);
|
|
570
|
+
const below = (value, floor) => floor !== void 0 && (value === null || value < floor);
|
|
571
|
+
let exitCode = 0;
|
|
572
|
+
if (below(report.totals.recall, options.minRecall)) {
|
|
573
|
+
console.error(`sembl eval: recall ${fmt(report.totals.recall)} is below --min-recall ${options.minRecall}`);
|
|
574
|
+
exitCode = 1;
|
|
575
|
+
}
|
|
576
|
+
if (below(report.totals.precision, options.minPrecision)) {
|
|
577
|
+
console.error(`sembl eval: precision ${fmt(report.totals.precision)} is below --min-precision ${options.minPrecision}`);
|
|
578
|
+
exitCode = 1;
|
|
579
|
+
}
|
|
580
|
+
return { report, diff, exitCode };
|
|
581
|
+
}
|
|
582
|
+
function fmt(value) {
|
|
583
|
+
return value === null ? "n/a" : value.toFixed(2);
|
|
584
|
+
}
|
|
585
|
+
|
|
63
586
|
// src/cli/index.ts
|
|
64
587
|
var program = new Command();
|
|
65
588
|
program.name("sembl").description("SEMBL schema compiler \u2014 extract runtime schemas from decorated TypeScript classes").version("0.1.0");
|
|
@@ -72,5 +595,20 @@ program.command("extract").description("Extract @Schema-decorated classes into R
|
|
|
72
595
|
});
|
|
73
596
|
process.exitCode = result.exitCode;
|
|
74
597
|
});
|
|
598
|
+
program.command("eval").description("Run fixtures through a schema and report per-field precision and recall").requiredOption("-c, --config <path>", "JS module exporting { schema, provider, \u2026 }").requiredOption("-f, --fixtures <dir>", "Directory of fixture JSON files").option("-o, --out <file>", "Where to write the report (default: <fixtures>/.sembl-eval/last-run.json)").option("--mode <mode>", "coerce or partialCoerce", "coerce").option("--provenance", "Ask for provenance and show confidence on mismatches").option("--concurrency <n>", "Fixtures to run at once", "1").option("--replay <dir>", "Replay recordings from this directory; record misses through the provider").option("--min-recall <fraction>", "Fail when overall recall is below this").option("--min-precision <fraction>", "Fail when overall precision is below this").action(async (options) => {
|
|
599
|
+
const mode = options.mode === "partialCoerce" ? "partialCoerce" : "coerce";
|
|
600
|
+
const result = await evalCommand({
|
|
601
|
+
config: options.config,
|
|
602
|
+
fixtures: options.fixtures,
|
|
603
|
+
out: options.out,
|
|
604
|
+
mode,
|
|
605
|
+
provenance: options.provenance,
|
|
606
|
+
concurrency: Number(options.concurrency),
|
|
607
|
+
replay: options.replay,
|
|
608
|
+
minRecall: options.minRecall !== void 0 ? Number(options.minRecall) : void 0,
|
|
609
|
+
minPrecision: options.minPrecision !== void 0 ? Number(options.minPrecision) : void 0
|
|
610
|
+
});
|
|
611
|
+
process.exitCode = result.exitCode;
|
|
612
|
+
});
|
|
75
613
|
program.parse();
|
|
76
614
|
//# sourceMappingURL=index.js.map
|