@vertz/compiler 0.1.0 → 0.2.3
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 +20 -0
- package/dist/index.d.ts +105 -2
- package/dist/index.js +676 -8
- package/package.json +13 -10
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# @vertz/compiler
|
|
2
|
+
|
|
3
|
+
> **Internal package** — You don't use this directly. It powers `vertz build`, `vertz dev`, and `vertz codegen` behind the scenes.
|
|
4
|
+
|
|
5
|
+
Static analysis and code generation for Vertz applications. Analyzes TypeScript source code to extract application structure (routes, schemas, modules, middleware), validates conventions, and generates runtime artifacts like boot files, route tables, and OpenAPI specs.
|
|
6
|
+
|
|
7
|
+
## Who uses this
|
|
8
|
+
|
|
9
|
+
- **`@vertz/cli`** — All CLI commands (`vertz build`, `vertz dev`, `vertz check`) invoke the compiler.
|
|
10
|
+
- **`@vertz/codegen`** — Consumes the compiler's intermediate representation (IR) to generate SDKs and CLIs.
|
|
11
|
+
- **Framework contributors** — See [INTERNALS.md](./INTERNALS.md) for architecture, pipeline stages, and extension points.
|
|
12
|
+
|
|
13
|
+
## Related Packages
|
|
14
|
+
|
|
15
|
+
- [`@vertz/cli`](../cli) — The CLI that invokes the compiler
|
|
16
|
+
- [`@vertz/codegen`](../codegen) — Code generation from compiler IR
|
|
17
|
+
|
|
18
|
+
## License
|
|
19
|
+
|
|
20
|
+
MIT
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
type DiagnosticSeverity = "error" | "warning" | "info";
|
|
2
|
-
type DiagnosticCode = "VERTZ_SCHEMA_NAMING" | "VERTZ_SCHEMA_PLACEMENT" | "VERTZ_SCHEMA_EXECUTION" | "VERTZ_SCHEMA_MISSING_ID" | "VERTZ_SCHEMA_DYNAMIC_NAME" | "VERTZ_MODULE_CIRCULAR" | "VERTZ_MODULE_EXPORT_INVALID" | "VERTZ_MODULE_IMPORT_MISSING" | "VERTZ_MODULE_DUPLICATE_NAME" | "VERTZ_MODULE_DYNAMIC_NAME" | "VERTZ_MODULE_OPTIONS_INVALID" | "VERTZ_MODULE_WRONG_OWNERSHIP" | "VERTZ_SERVICE_INJECT_MISSING" | "VERTZ_SERVICE_UNUSED" | "VERTZ_SERVICE_DYNAMIC_NAME" | "VERTZ_ENV_MISSING_DEFAULT" | "VERTZ_ENV_DUPLICATE" | "VERTZ_ENV_DYNAMIC_CONFIG" | "VERTZ_MW_MISSING_NAME" | "VERTZ_MW_MISSING_HANDLER" | "VERTZ_MW_DYNAMIC_NAME" | "VERTZ_MW_NON_OBJECT_CONFIG" | "VERTZ_MW_REQUIRES_UNSATISFIED" | "VERTZ_MW_PROVIDES_COLLISION" | "VERTZ_MW_ORDER_INVALID" | "VERTZ_RT_UNKNOWN_MODULE_DEF" | "VERTZ_RT_DYNAMIC_PATH" | "VERTZ_RT_MISSING_HANDLER" | "VERTZ_RT_MISSING_PREFIX" | "VERTZ_RT_DYNAMIC_CONFIG" | "VERTZ_RT_INVALID_PATH" | "VERTZ_ROUTE_DUPLICATE" | "VERTZ_ROUTE_PARAM_MISMATCH" | "VERTZ_ROUTE_MISSING_RESPONSE" | "VERTZ_APP_MISSING" | "VERTZ_APP_NOT_FOUND" | "VERTZ_APP_DUPLICATE" | "VERTZ_APP_BASEPATH_FORMAT" | "VERTZ_APP_INLINE_MODULE" | "VERTZ_DEP_CYCLE" | "VERTZ_DEP_CIRCULAR" | "VERTZ_DEP_UNRESOLVED_INJECT" | "VERTZ_DEP_INIT_ORDER" | "VERTZ_CTX_COLLISION" | "VERTZ_DEAD_CODE";
|
|
2
|
+
type DiagnosticCode = "VERTZ_SCHEMA_NAMING" | "VERTZ_SCHEMA_PLACEMENT" | "VERTZ_SCHEMA_EXECUTION" | "VERTZ_SCHEMA_MISSING_ID" | "VERTZ_SCHEMA_DYNAMIC_NAME" | "VERTZ_MODULE_CIRCULAR" | "VERTZ_MODULE_EXPORT_INVALID" | "VERTZ_MODULE_IMPORT_MISSING" | "VERTZ_MODULE_DUPLICATE_NAME" | "VERTZ_MODULE_DYNAMIC_NAME" | "VERTZ_MODULE_OPTIONS_INVALID" | "VERTZ_MODULE_WRONG_OWNERSHIP" | "VERTZ_SERVICE_INJECT_MISSING" | "VERTZ_SERVICE_UNUSED" | "VERTZ_SERVICE_DYNAMIC_NAME" | "VERTZ_ENV_MISSING_DEFAULT" | "VERTZ_ENV_DUPLICATE" | "VERTZ_ENV_DYNAMIC_CONFIG" | "VERTZ_MW_MISSING_NAME" | "VERTZ_MW_MISSING_HANDLER" | "VERTZ_MW_DYNAMIC_NAME" | "VERTZ_MW_NON_OBJECT_CONFIG" | "VERTZ_MW_REQUIRES_UNSATISFIED" | "VERTZ_MW_PROVIDES_COLLISION" | "VERTZ_MW_ORDER_INVALID" | "VERTZ_RT_UNKNOWN_MODULE_DEF" | "VERTZ_RT_DYNAMIC_PATH" | "VERTZ_RT_MISSING_HANDLER" | "VERTZ_RT_MISSING_PREFIX" | "VERTZ_RT_DYNAMIC_CONFIG" | "VERTZ_RT_INVALID_PATH" | "VERTZ_ROUTE_DUPLICATE" | "VERTZ_ROUTE_PARAM_MISMATCH" | "VERTZ_ROUTE_MISSING_RESPONSE" | "VERTZ_APP_MISSING" | "VERTZ_APP_NOT_FOUND" | "VERTZ_APP_DUPLICATE" | "VERTZ_APP_BASEPATH_FORMAT" | "VERTZ_APP_INLINE_MODULE" | "VERTZ_DEP_CYCLE" | "VERTZ_DEP_CIRCULAR" | "VERTZ_DEP_UNRESOLVED_INJECT" | "VERTZ_DEP_INIT_ORDER" | "VERTZ_CTX_COLLISION" | "VERTZ_DEAD_CODE" | "ENTITY_MISSING_ARGS" | "ENTITY_NON_LITERAL_NAME" | "ENTITY_INVALID_NAME" | "ENTITY_DUPLICATE_NAME" | "ENTITY_CONFIG_NOT_OBJECT" | "ENTITY_MISSING_MODEL" | "ENTITY_MODEL_UNRESOLVABLE" | "ENTITY_ACTION_NAME_COLLISION" | "ENTITY_ACTION_MISSING_SCHEMA" | "ENTITY_ACTION_INVALID_METHOD" | "ENTITY_UNKNOWN_ACCESS_OP" | "ENTITY_UNRESOLVED_IMPORT" | "ENTITY_ROUTE_COLLISION" | "ENTITY_NO_ROUTES";
|
|
3
3
|
interface SourceContext {
|
|
4
4
|
lines: {
|
|
5
5
|
number: number;
|
|
@@ -37,6 +37,7 @@ interface AppIR {
|
|
|
37
37
|
modules: ModuleIR[];
|
|
38
38
|
middleware: MiddlewareIR[];
|
|
39
39
|
schemas: SchemaIR[];
|
|
40
|
+
entities: EntityIR[];
|
|
40
41
|
dependencyGraph: DependencyGraphIR;
|
|
41
42
|
diagnostics: Diagnostic[];
|
|
42
43
|
}
|
|
@@ -133,6 +134,7 @@ interface MiddlewareRef {
|
|
|
133
134
|
interface SchemaIR extends SourceLocation {
|
|
134
135
|
name: string;
|
|
135
136
|
id?: string;
|
|
137
|
+
moduleName: string;
|
|
136
138
|
namingConvention: SchemaNameParts;
|
|
137
139
|
jsonSchema?: Record<string, unknown>;
|
|
138
140
|
isNamed: boolean;
|
|
@@ -153,6 +155,60 @@ interface InlineSchemaRef {
|
|
|
153
155
|
kind: "inline";
|
|
154
156
|
sourceFile: string;
|
|
155
157
|
jsonSchema?: Record<string, unknown>;
|
|
158
|
+
resolvedFields?: ResolvedField[];
|
|
159
|
+
}
|
|
160
|
+
/** Structured field info extracted from resolved TypeScript types. */
|
|
161
|
+
interface ResolvedField {
|
|
162
|
+
name: string;
|
|
163
|
+
tsType: "string" | "number" | "boolean" | "date" | "unknown";
|
|
164
|
+
optional: boolean;
|
|
165
|
+
}
|
|
166
|
+
interface EntityIR extends SourceLocation {
|
|
167
|
+
name: string;
|
|
168
|
+
modelRef: EntityModelRef;
|
|
169
|
+
access: EntityAccessIR;
|
|
170
|
+
hooks: EntityHooksIR;
|
|
171
|
+
actions: EntityActionIR[];
|
|
172
|
+
relations: EntityRelationIR[];
|
|
173
|
+
}
|
|
174
|
+
interface EntityModelRef {
|
|
175
|
+
variableName: string;
|
|
176
|
+
importSource?: string;
|
|
177
|
+
tableName?: string;
|
|
178
|
+
schemaRefs: EntityModelSchemaRefs;
|
|
179
|
+
}
|
|
180
|
+
interface EntityModelSchemaRefs {
|
|
181
|
+
response?: SchemaRef;
|
|
182
|
+
createInput?: SchemaRef;
|
|
183
|
+
updateInput?: SchemaRef;
|
|
184
|
+
resolved: boolean;
|
|
185
|
+
}
|
|
186
|
+
interface EntityAccessIR {
|
|
187
|
+
list: EntityAccessRuleKind;
|
|
188
|
+
get: EntityAccessRuleKind;
|
|
189
|
+
create: EntityAccessRuleKind;
|
|
190
|
+
update: EntityAccessRuleKind;
|
|
191
|
+
delete: EntityAccessRuleKind;
|
|
192
|
+
custom: Record<string, EntityAccessRuleKind>;
|
|
193
|
+
}
|
|
194
|
+
type EntityAccessRuleKind = "none" | "false" | "function";
|
|
195
|
+
interface EntityHooksIR {
|
|
196
|
+
before: ("create" | "update")[];
|
|
197
|
+
after: ("create" | "update" | "delete")[];
|
|
198
|
+
}
|
|
199
|
+
interface EntityActionIR extends SourceLocation {
|
|
200
|
+
name: string;
|
|
201
|
+
method: HttpMethod;
|
|
202
|
+
path?: string;
|
|
203
|
+
params?: SchemaRef;
|
|
204
|
+
query?: SchemaRef;
|
|
205
|
+
headers?: SchemaRef;
|
|
206
|
+
body?: SchemaRef;
|
|
207
|
+
response?: SchemaRef;
|
|
208
|
+
}
|
|
209
|
+
interface EntityRelationIR {
|
|
210
|
+
name: string;
|
|
211
|
+
selection: "all" | string[];
|
|
156
212
|
}
|
|
157
213
|
interface ModuleDefContext {
|
|
158
214
|
moduleDefVariables: Map<string, string>;
|
|
@@ -215,6 +271,7 @@ declare function defineConfig(config: VertzConfig): VertzConfig;
|
|
|
215
271
|
declare function resolveConfig(config?: VertzConfig): ResolvedConfig;
|
|
216
272
|
interface Analyzer<T> {
|
|
217
273
|
analyze(): Promise<T>;
|
|
274
|
+
getDiagnostics(): Diagnostic[];
|
|
218
275
|
}
|
|
219
276
|
declare abstract class BaseAnalyzer<T> implements Analyzer<T> {
|
|
220
277
|
protected readonly project: Project;
|
|
@@ -292,6 +349,49 @@ declare class RouteAnalyzer extends BaseAnalyzer<RouteAnalyzerResult> {
|
|
|
292
349
|
private extractMiddlewareRefs;
|
|
293
350
|
private generateOperationId;
|
|
294
351
|
}
|
|
352
|
+
interface EntityAnalyzerResult {
|
|
353
|
+
entities: EntityIR[];
|
|
354
|
+
}
|
|
355
|
+
declare class EntityAnalyzer extends BaseAnalyzer<EntityAnalyzerResult> {
|
|
356
|
+
private debug;
|
|
357
|
+
analyze(): Promise<EntityAnalyzerResult>;
|
|
358
|
+
private findEntityCalls;
|
|
359
|
+
private extractEntity;
|
|
360
|
+
private extractModelRef;
|
|
361
|
+
private findImportForIdentifier;
|
|
362
|
+
private resolveModelSchemas;
|
|
363
|
+
private extractSchemaType;
|
|
364
|
+
/**
|
|
365
|
+
* Build JSON Schema from resolved fields.
|
|
366
|
+
* Maps tsType ('string' | 'number' | 'boolean' | 'date' | 'unknown') to JSON Schema types.
|
|
367
|
+
*/
|
|
368
|
+
private buildJsonSchema;
|
|
369
|
+
/**
|
|
370
|
+
* Map tsType to JSON Schema type.
|
|
371
|
+
* Handles column types like text → string, boolean → boolean, uuid → string with format,
|
|
372
|
+
* timestamp with time zone → string with date-time format, integer → integer, real/float → number.
|
|
373
|
+
*/
|
|
374
|
+
private tsTypeToJsonSchema;
|
|
375
|
+
/**
|
|
376
|
+
* Navigate through SchemaLike<T> to extract T's field info.
|
|
377
|
+
* SchemaLike<T>.parse() returns { ok: true; data: T } | { ok: false; error: Error }.
|
|
378
|
+
* We unwrap the Result union to get T from the success branch's `data` property.
|
|
379
|
+
*/
|
|
380
|
+
private resolveFieldsFromSchemaType;
|
|
381
|
+
/**
|
|
382
|
+
* Unwrap a Result type to extract T from the success branch.
|
|
383
|
+
* Handles: { ok: true; data: T } | { ok: false; error: Error } → T
|
|
384
|
+
* Falls back to the type itself if it's not a Result union (backward compat).
|
|
385
|
+
*/
|
|
386
|
+
private unwrapResultType;
|
|
387
|
+
private mapTsType;
|
|
388
|
+
private extractAccess;
|
|
389
|
+
private classifyAccessRule;
|
|
390
|
+
private extractHooks;
|
|
391
|
+
private extractActions;
|
|
392
|
+
private resolveSchemaFromExpression;
|
|
393
|
+
private extractRelations;
|
|
394
|
+
}
|
|
295
395
|
import { Expression as Expression2, SourceFile } from "ts-morph";
|
|
296
396
|
interface SchemaAnalyzerResult {
|
|
297
397
|
schemas: SchemaIR[];
|
|
@@ -338,6 +438,7 @@ interface CompilerDependencies {
|
|
|
338
438
|
middleware: Analyzer<MiddlewareAnalyzerResult>;
|
|
339
439
|
module: Analyzer<ModuleAnalyzerResult>;
|
|
340
440
|
app: Analyzer<AppAnalyzerResult>;
|
|
441
|
+
entity: Analyzer<EntityAnalyzerResult>;
|
|
341
442
|
dependencyGraph: Analyzer<DependencyGraphResult>;
|
|
342
443
|
};
|
|
343
444
|
validators: Validator[];
|
|
@@ -629,6 +730,8 @@ declare class IncrementalCompiler {
|
|
|
629
730
|
declare function createEmptyDependencyGraph(): DependencyGraphIR;
|
|
630
731
|
declare function createEmptyAppIR(): AppIR;
|
|
631
732
|
declare function addDiagnosticsToIR(ir: AppIR, diagnostics: readonly Diagnostic[]): AppIR;
|
|
733
|
+
declare function injectEntityRoutes(ir: AppIR): void;
|
|
734
|
+
declare function detectRouteCollisions(ir: AppIR): Diagnostic[];
|
|
632
735
|
declare function mergeIR(base: AppIR, partial: Partial<AppIR>): AppIR;
|
|
633
736
|
import { ChildProcess } from "node:child_process";
|
|
634
737
|
interface TypecheckDiagnostic {
|
|
@@ -721,4 +824,4 @@ declare class PlacementValidator implements Validator {
|
|
|
721
824
|
private checkFileLocation;
|
|
722
825
|
private checkMixedExports;
|
|
723
826
|
}
|
|
724
|
-
export { typecheckWatch, typecheck, resolveImportPath, resolveIdentifier, resolveExport, resolveConfig, renderSchemaRegistryFile, renderRouteTableFile, renderBootFile, parseWatchBlock, parseTscOutput, parseSchemaName2 as parseSchemaName, parseInjectRefs, parseImports, mergeIR, mergeDiagnostics, isSchemaFile, isSchemaExpression, isFromImport, hasErrors, getVariableNameForCall, getStringValue, getSourceLocation, getPropertyValue, getProperties, getNumberValue, getBooleanValue, getArrayElements, findMethodCallsOnVariable, findCallExpressions, findAffectedModules, filterBySeverity, extractSchemaId, extractObjectLiteral, extractMethodSignatures, extractIdentifierNames, defineConfig, createSchemaExecutor, createNamedSchemaRef, createInlineSchemaRef, createEmptyDependencyGraph, createEmptyAppIR, createDiagnosticFromLocation, createDiagnostic, createCompiler, categorizeChanges, buildSchemaRegistry, buildRouteTable, buildManifest, buildBootManifest, addDiagnosticsToIR, VertzConfig, Validator, ValidationConfig, ValidPart, ValidOperation, TypecheckWatchOptions, TypecheckResult, TypecheckOptions, TypecheckDiagnostic, SourceLocation, SourceContext, ServiceMethodParam, ServiceMethodIR, ServiceIR, ServiceAnalyzerResult, ServiceAnalyzer, SchemaRegistryManifest, SchemaRegistryGenerator, SchemaRegistryEntry, SchemaRef, SchemaNameParts, SchemaIR, SchemaExecutor, SchemaExecutionResult, SchemaConfig, SchemaAnalyzerResult, SchemaAnalyzer, RouterIR, RouteTableSchemas, RouteTableManifest, RouteTableGenerator, RouteTableEntry, RouteIR, RouteAnalyzerResult, RouteAnalyzer, ResolvedImport, ResolvedConfig, PlacementValidator, ParsedSchemaName, OpenAPITag, OpenAPIServer, OpenAPIResponse, OpenAPIRequestBody, OpenAPIPathItem, OpenAPIParameter, OpenAPIOperation, OpenAPIInfo, OpenAPIGenerator, OpenAPIDocument, OpenAPIConfig, NamingValidator, NamedSchemaRef, ModuleValidator, ModuleRegistration, ModuleIR, ModuleDefContext, ModuleAnalyzerResult, ModuleAnalyzer, MiddlewareRef, MiddlewareIR, MiddlewareAnalyzerResult, MiddlewareAnalyzer, ManifestRoute, ManifestModule, ManifestMiddleware, ManifestGenerator, ManifestDiagnostic, ManifestDependencyEdge, JSONSchemaObject, InlineSchemaRef, InjectRef, IncrementalResult, IncrementalCompiler, ImportRef, HttpMethod, Generator, FileChange, FileCategory, EnvVariableIR, EnvIR, EnvAnalyzerResult, EnvAnalyzer, DiagnosticSeverity, DiagnosticCode, Diagnostic, DependencyNodeKind, DependencyNode, DependencyGraphResult, DependencyGraphInput, DependencyGraphIR, DependencyGraphAnalyzer, DependencyEdgeKind, DependencyEdge, CreateDiagnosticOptions, CompletenessValidator, CompilerDependencies, CompilerConfig, Compiler, CompileResult, CategorizedChanges, CategorizeOptions, BootModuleEntry, BootMiddlewareEntry, BootManifest, BootGenerator, BaseGenerator, BaseAnalyzer, AppManifest, AppIR, AppDefinition, AppAnalyzerResult, AppAnalyzer, Analyzer };
|
|
827
|
+
export { typecheckWatch, typecheck, resolveImportPath, resolveIdentifier, resolveExport, resolveConfig, renderSchemaRegistryFile, renderRouteTableFile, renderBootFile, parseWatchBlock, parseTscOutput, parseSchemaName2 as parseSchemaName, parseInjectRefs, parseImports, mergeIR, mergeDiagnostics, isSchemaFile, isSchemaExpression, isFromImport, injectEntityRoutes, hasErrors, getVariableNameForCall, getStringValue, getSourceLocation, getPropertyValue, getProperties, getNumberValue, getBooleanValue, getArrayElements, findMethodCallsOnVariable, findCallExpressions, findAffectedModules, filterBySeverity, extractSchemaId, extractObjectLiteral, extractMethodSignatures, extractIdentifierNames, detectRouteCollisions, defineConfig, createSchemaExecutor, createNamedSchemaRef, createInlineSchemaRef, createEmptyDependencyGraph, createEmptyAppIR, createDiagnosticFromLocation, createDiagnostic, createCompiler, categorizeChanges, buildSchemaRegistry, buildRouteTable, buildManifest, buildBootManifest, addDiagnosticsToIR, VertzConfig, Validator, ValidationConfig, ValidPart, ValidOperation, TypecheckWatchOptions, TypecheckResult, TypecheckOptions, TypecheckDiagnostic, SourceLocation, SourceContext, ServiceMethodParam, ServiceMethodIR, ServiceIR, ServiceAnalyzerResult, ServiceAnalyzer, SchemaRegistryManifest, SchemaRegistryGenerator, SchemaRegistryEntry, SchemaRef, SchemaNameParts, SchemaIR, SchemaExecutor, SchemaExecutionResult, SchemaConfig, SchemaAnalyzerResult, SchemaAnalyzer, RouterIR, RouteTableSchemas, RouteTableManifest, RouteTableGenerator, RouteTableEntry, RouteIR, RouteAnalyzerResult, RouteAnalyzer, ResolvedImport, ResolvedConfig, PlacementValidator, ParsedSchemaName, OpenAPITag, OpenAPIServer, OpenAPIResponse, OpenAPIRequestBody, OpenAPIPathItem, OpenAPIParameter, OpenAPIOperation, OpenAPIInfo, OpenAPIGenerator, OpenAPIDocument, OpenAPIConfig, NamingValidator, NamedSchemaRef, ModuleValidator, ModuleRegistration, ModuleIR, ModuleDefContext, ModuleAnalyzerResult, ModuleAnalyzer, MiddlewareRef, MiddlewareIR, MiddlewareAnalyzerResult, MiddlewareAnalyzer, ManifestRoute, ManifestModule, ManifestMiddleware, ManifestGenerator, ManifestDiagnostic, ManifestDependencyEdge, JSONSchemaObject, InlineSchemaRef, InjectRef, IncrementalResult, IncrementalCompiler, ImportRef, HttpMethod, Generator, FileChange, FileCategory, EnvVariableIR, EnvIR, EnvAnalyzerResult, EnvAnalyzer, EntityRelationIR, EntityModelSchemaRefs, EntityModelRef, EntityIR, EntityHooksIR, EntityAnalyzerResult, EntityAnalyzer, EntityActionIR, EntityAccessRuleKind, EntityAccessIR, DiagnosticSeverity, DiagnosticCode, Diagnostic, DependencyNodeKind, DependencyNode, DependencyGraphResult, DependencyGraphInput, DependencyGraphIR, DependencyGraphAnalyzer, DependencyEdgeKind, DependencyEdge, CreateDiagnosticOptions, CompletenessValidator, CompilerDependencies, CompilerConfig, Compiler, CompileResult, CategorizedChanges, CategorizeOptions, BootModuleEntry, BootMiddlewareEntry, BootManifest, BootGenerator, BaseGenerator, BaseAnalyzer, AppManifest, AppIR, AppDefinition, AppAnalyzerResult, AppAnalyzer, Analyzer };
|
package/dist/index.js
CHANGED
|
@@ -211,7 +211,10 @@ class AppAnalyzer extends BaseAnalyzer {
|
|
|
211
211
|
async analyze() {
|
|
212
212
|
const allAppCalls = [];
|
|
213
213
|
for (const file2 of this.project.getSourceFiles()) {
|
|
214
|
-
const appCalls =
|
|
214
|
+
const appCalls = [
|
|
215
|
+
...findCallExpressions(file2, "vertz", "app"),
|
|
216
|
+
...findCallExpressions(file2, "vertz", "server")
|
|
217
|
+
];
|
|
215
218
|
for (const call2 of appCalls) {
|
|
216
219
|
allAppCalls.push({ call: call2, file: file2 });
|
|
217
220
|
}
|
|
@@ -628,6 +631,7 @@ class SchemaAnalyzer extends BaseAnalyzer {
|
|
|
628
631
|
name,
|
|
629
632
|
...loc,
|
|
630
633
|
id: id ?? undefined,
|
|
634
|
+
moduleName: "",
|
|
631
635
|
namingConvention: parseSchemaName(name),
|
|
632
636
|
isNamed: id !== null
|
|
633
637
|
});
|
|
@@ -1268,7 +1272,483 @@ function joinPaths(prefix, path) {
|
|
|
1268
1272
|
function sanitizePath(path) {
|
|
1269
1273
|
return path.replace(/^\//, "").replace(/[/:.]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "") || "root";
|
|
1270
1274
|
}
|
|
1275
|
+
// src/analyzers/entity-analyzer.ts
|
|
1276
|
+
import { SyntaxKind as SyntaxKind9 } from "ts-morph";
|
|
1277
|
+
var ENTITY_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
1278
|
+
var CRUD_OPS = ["list", "get", "create", "update", "delete"];
|
|
1279
|
+
|
|
1280
|
+
class EntityAnalyzer extends BaseAnalyzer {
|
|
1281
|
+
debug(msg) {
|
|
1282
|
+
if (process.env["VERTZ_DEBUG"]?.includes("entities")) {
|
|
1283
|
+
console.log(`[entity-analyzer] ${msg}`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
async analyze() {
|
|
1287
|
+
const entities = [];
|
|
1288
|
+
const seenNames = new Map;
|
|
1289
|
+
const files = this.project.getSourceFiles();
|
|
1290
|
+
this.debug(`Scanning ${files.length} source files...`);
|
|
1291
|
+
for (const file of files) {
|
|
1292
|
+
const calls = this.findEntityCalls(file);
|
|
1293
|
+
for (const call of calls) {
|
|
1294
|
+
const entity = this.extractEntity(file, call);
|
|
1295
|
+
if (!entity)
|
|
1296
|
+
continue;
|
|
1297
|
+
const existing = seenNames.get(entity.name);
|
|
1298
|
+
if (existing) {
|
|
1299
|
+
this.addDiagnostic({
|
|
1300
|
+
code: "ENTITY_DUPLICATE_NAME",
|
|
1301
|
+
severity: "error",
|
|
1302
|
+
message: `Entity "${entity.name}" is already defined at ${existing.sourceFile}:${existing.sourceLine}`,
|
|
1303
|
+
...getSourceLocation(call)
|
|
1304
|
+
});
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
seenNames.set(entity.name, entity);
|
|
1308
|
+
entities.push(entity);
|
|
1309
|
+
this.debug(`Detected entity: "${entity.name}" at ${entity.sourceFile}:${entity.sourceLine}`);
|
|
1310
|
+
this.debug(` model: ${entity.modelRef.variableName} (resolved: ${entity.modelRef.schemaRefs.resolved ? "✅" : "❌"})`);
|
|
1311
|
+
const accessStatus = CRUD_OPS.map((op) => {
|
|
1312
|
+
const kind = entity.access[op];
|
|
1313
|
+
return `${op} ${kind === "false" ? "✗" : "✓"}`;
|
|
1314
|
+
}).join(", ");
|
|
1315
|
+
this.debug(` access: ${accessStatus}`);
|
|
1316
|
+
if (entity.hooks.before.length > 0 || entity.hooks.after.length > 0) {
|
|
1317
|
+
this.debug(` hooks: before[${entity.hooks.before.join(",")}], after[${entity.hooks.after.join(",")}]`);
|
|
1318
|
+
}
|
|
1319
|
+
if (entity.actions.length > 0) {
|
|
1320
|
+
this.debug(` actions: ${entity.actions.map((a) => a.name).join(", ")}`);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
return { entities };
|
|
1325
|
+
}
|
|
1326
|
+
findEntityCalls(file) {
|
|
1327
|
+
const validCalls = [];
|
|
1328
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind9.CallExpression)) {
|
|
1329
|
+
const expr = call.getExpression();
|
|
1330
|
+
if (expr.isKind(SyntaxKind9.Identifier)) {
|
|
1331
|
+
const isValid = isFromImport(expr, "@vertz/server");
|
|
1332
|
+
if (!isValid && expr.getText() === "entity") {
|
|
1333
|
+
this.addDiagnostic({
|
|
1334
|
+
code: "ENTITY_UNRESOLVED_IMPORT",
|
|
1335
|
+
severity: "error",
|
|
1336
|
+
message: "entity() call does not resolve to @vertz/server",
|
|
1337
|
+
...getSourceLocation(call)
|
|
1338
|
+
});
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
if (isValid) {
|
|
1342
|
+
validCalls.push(call);
|
|
1343
|
+
}
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
if (expr.isKind(SyntaxKind9.PropertyAccessExpression)) {
|
|
1347
|
+
const propName = expr.getName();
|
|
1348
|
+
if (propName !== "entity")
|
|
1349
|
+
continue;
|
|
1350
|
+
const obj = expr.getExpression();
|
|
1351
|
+
if (!obj.isKind(SyntaxKind9.Identifier))
|
|
1352
|
+
continue;
|
|
1353
|
+
const sourceFile = obj.getSourceFile();
|
|
1354
|
+
const importDecl = sourceFile.getImportDeclarations().find((d) => d.getModuleSpecifierValue() === "@vertz/server" && d.getNamespaceImport()?.getText() === obj.getText());
|
|
1355
|
+
if (importDecl) {
|
|
1356
|
+
validCalls.push(call);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
return validCalls;
|
|
1361
|
+
}
|
|
1362
|
+
extractEntity(_file, call) {
|
|
1363
|
+
const args = call.getArguments();
|
|
1364
|
+
const loc = getSourceLocation(call);
|
|
1365
|
+
if (args.length < 2) {
|
|
1366
|
+
this.addDiagnostic({
|
|
1367
|
+
code: "ENTITY_MISSING_ARGS",
|
|
1368
|
+
severity: "error",
|
|
1369
|
+
message: "entity() requires two arguments: name and config",
|
|
1370
|
+
...loc
|
|
1371
|
+
});
|
|
1372
|
+
return null;
|
|
1373
|
+
}
|
|
1374
|
+
const name = getStringValue(args[0]);
|
|
1375
|
+
if (name === null) {
|
|
1376
|
+
this.addDiagnostic({
|
|
1377
|
+
code: "ENTITY_NON_LITERAL_NAME",
|
|
1378
|
+
severity: "error",
|
|
1379
|
+
message: "entity() name must be a string literal",
|
|
1380
|
+
...loc
|
|
1381
|
+
});
|
|
1382
|
+
return null;
|
|
1383
|
+
}
|
|
1384
|
+
if (!ENTITY_NAME_PATTERN.test(name)) {
|
|
1385
|
+
this.addDiagnostic({
|
|
1386
|
+
code: "ENTITY_INVALID_NAME",
|
|
1387
|
+
severity: "error",
|
|
1388
|
+
message: `Entity name must match /^[a-z][a-z0-9-]*$/. Got: "${name}"`,
|
|
1389
|
+
...loc
|
|
1390
|
+
});
|
|
1391
|
+
return null;
|
|
1392
|
+
}
|
|
1393
|
+
const configObj = extractObjectLiteral(call, 1);
|
|
1394
|
+
if (!configObj) {
|
|
1395
|
+
this.addDiagnostic({
|
|
1396
|
+
code: "ENTITY_CONFIG_NOT_OBJECT",
|
|
1397
|
+
severity: "warning",
|
|
1398
|
+
message: "entity() config must be an object literal for static analysis",
|
|
1399
|
+
...loc
|
|
1400
|
+
});
|
|
1401
|
+
return null;
|
|
1402
|
+
}
|
|
1403
|
+
const modelRef = this.extractModelRef(configObj, loc);
|
|
1404
|
+
if (!modelRef)
|
|
1405
|
+
return null;
|
|
1406
|
+
const access = this.extractAccess(configObj);
|
|
1407
|
+
const hooks = this.extractHooks(configObj);
|
|
1408
|
+
const actions = this.extractActions(configObj);
|
|
1409
|
+
const relations = this.extractRelations(configObj);
|
|
1410
|
+
for (const action of actions) {
|
|
1411
|
+
if (CRUD_OPS.includes(action.name)) {
|
|
1412
|
+
this.addDiagnostic({
|
|
1413
|
+
code: "ENTITY_ACTION_NAME_COLLISION",
|
|
1414
|
+
severity: "error",
|
|
1415
|
+
message: `Custom action "${action.name}" collides with built-in CRUD operation`,
|
|
1416
|
+
...action
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
for (const customOp of Object.keys(access.custom)) {
|
|
1421
|
+
if (!actions.some((a) => a.name === customOp)) {
|
|
1422
|
+
this.addDiagnostic({
|
|
1423
|
+
code: "ENTITY_UNKNOWN_ACCESS_OP",
|
|
1424
|
+
severity: "warning",
|
|
1425
|
+
message: `Unknown access operation "${customOp}" — not a CRUD op or custom action`,
|
|
1426
|
+
...loc
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
return { name, modelRef, access, hooks, actions, relations, ...loc };
|
|
1431
|
+
}
|
|
1432
|
+
extractModelRef(configObj, loc) {
|
|
1433
|
+
const modelExpr = getPropertyValue(configObj, "model");
|
|
1434
|
+
if (!modelExpr) {
|
|
1435
|
+
this.addDiagnostic({
|
|
1436
|
+
code: "ENTITY_MISSING_MODEL",
|
|
1437
|
+
severity: "error",
|
|
1438
|
+
message: "entity() requires a model property",
|
|
1439
|
+
...loc
|
|
1440
|
+
});
|
|
1441
|
+
return null;
|
|
1442
|
+
}
|
|
1443
|
+
const variableName = modelExpr.isKind(SyntaxKind9.Identifier) ? modelExpr.getText() : modelExpr.getText();
|
|
1444
|
+
let importSource;
|
|
1445
|
+
if (modelExpr.isKind(SyntaxKind9.Identifier)) {
|
|
1446
|
+
const importInfo = this.findImportForIdentifier(modelExpr);
|
|
1447
|
+
if (importInfo) {
|
|
1448
|
+
importSource = importInfo.importDecl.getModuleSpecifierValue();
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const schemaRefs = this.resolveModelSchemas(modelExpr);
|
|
1452
|
+
return { variableName, importSource, schemaRefs };
|
|
1453
|
+
}
|
|
1454
|
+
findImportForIdentifier(identifier) {
|
|
1455
|
+
const sourceFile = identifier.getSourceFile();
|
|
1456
|
+
const name = identifier.getText();
|
|
1457
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
1458
|
+
for (const specifier of importDecl.getNamedImports()) {
|
|
1459
|
+
const localName = specifier.getAliasNode()?.getText() ?? specifier.getName();
|
|
1460
|
+
if (localName === name) {
|
|
1461
|
+
return { importDecl, originalName: specifier.getName() };
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
const nsImport = importDecl.getNamespaceImport();
|
|
1465
|
+
if (nsImport && nsImport.getText() === name) {
|
|
1466
|
+
return { importDecl, originalName: "*" };
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
return null;
|
|
1470
|
+
}
|
|
1471
|
+
resolveModelSchemas(modelExpr) {
|
|
1472
|
+
try {
|
|
1473
|
+
const modelType = modelExpr.getType();
|
|
1474
|
+
const schemasProp = modelType.getProperty("schemas");
|
|
1475
|
+
if (!schemasProp)
|
|
1476
|
+
return { resolved: false };
|
|
1477
|
+
const schemasType = schemasProp.getTypeAtLocation(modelExpr);
|
|
1478
|
+
const response = this.extractSchemaType(schemasType, "response", modelExpr);
|
|
1479
|
+
const createInput = this.extractSchemaType(schemasType, "createInput", modelExpr);
|
|
1480
|
+
const updateInput = this.extractSchemaType(schemasType, "updateInput", modelExpr);
|
|
1481
|
+
return {
|
|
1482
|
+
response,
|
|
1483
|
+
createInput,
|
|
1484
|
+
updateInput,
|
|
1485
|
+
resolved: response !== undefined || createInput !== undefined || updateInput !== undefined
|
|
1486
|
+
};
|
|
1487
|
+
} catch {
|
|
1488
|
+
return { resolved: false };
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
extractSchemaType(parentType, propertyName, location) {
|
|
1492
|
+
const prop = parentType.getProperty(propertyName);
|
|
1493
|
+
if (!prop)
|
|
1494
|
+
return;
|
|
1495
|
+
const propType = prop.getTypeAtLocation(location);
|
|
1496
|
+
const resolvedFields = this.resolveFieldsFromSchemaType(propType, location);
|
|
1497
|
+
const jsonSchema = this.buildJsonSchema(resolvedFields);
|
|
1498
|
+
return {
|
|
1499
|
+
kind: "inline",
|
|
1500
|
+
sourceFile: location.getSourceFile().getFilePath(),
|
|
1501
|
+
jsonSchema,
|
|
1502
|
+
resolvedFields
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
buildJsonSchema(resolvedFields) {
|
|
1506
|
+
if (!resolvedFields || resolvedFields.length === 0) {
|
|
1507
|
+
return {};
|
|
1508
|
+
}
|
|
1509
|
+
const properties = {};
|
|
1510
|
+
const required = [];
|
|
1511
|
+
for (const field of resolvedFields) {
|
|
1512
|
+
const fieldSchema = this.tsTypeToJsonSchema(field.tsType);
|
|
1513
|
+
properties[field.name] = fieldSchema;
|
|
1514
|
+
if (!field.optional) {
|
|
1515
|
+
required.push(field.name);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
return {
|
|
1519
|
+
type: "object",
|
|
1520
|
+
properties,
|
|
1521
|
+
...required.length > 0 ? { required } : {}
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
tsTypeToJsonSchema(tsType) {
|
|
1525
|
+
switch (tsType) {
|
|
1526
|
+
case "string":
|
|
1527
|
+
return { type: "string" };
|
|
1528
|
+
case "number":
|
|
1529
|
+
return { type: "number" };
|
|
1530
|
+
case "boolean":
|
|
1531
|
+
return { type: "boolean" };
|
|
1532
|
+
case "date":
|
|
1533
|
+
return { type: "string", format: "date-time" };
|
|
1534
|
+
case "unknown":
|
|
1535
|
+
default:
|
|
1536
|
+
return {};
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
resolveFieldsFromSchemaType(schemaType, location) {
|
|
1540
|
+
try {
|
|
1541
|
+
const parseProp = schemaType.getProperty("parse");
|
|
1542
|
+
if (!parseProp)
|
|
1543
|
+
return;
|
|
1544
|
+
const parseType = parseProp.getTypeAtLocation(location);
|
|
1545
|
+
const callSignatures = parseType.getCallSignatures();
|
|
1546
|
+
if (callSignatures.length === 0)
|
|
1547
|
+
return;
|
|
1548
|
+
const returnType = callSignatures[0]?.getReturnType();
|
|
1549
|
+
if (!returnType)
|
|
1550
|
+
return;
|
|
1551
|
+
const dataType = this.unwrapResultType(returnType, location);
|
|
1552
|
+
if (!dataType)
|
|
1553
|
+
return;
|
|
1554
|
+
const properties = dataType.getProperties();
|
|
1555
|
+
if (properties.length === 0)
|
|
1556
|
+
return;
|
|
1557
|
+
const fields = [];
|
|
1558
|
+
for (const fieldProp of properties) {
|
|
1559
|
+
const name = fieldProp.getName();
|
|
1560
|
+
const fieldType = fieldProp.getTypeAtLocation(location);
|
|
1561
|
+
const optional = fieldProp.isOptional();
|
|
1562
|
+
const tsType = this.mapTsType(fieldType);
|
|
1563
|
+
fields.push({ name, tsType, optional });
|
|
1564
|
+
}
|
|
1565
|
+
return fields;
|
|
1566
|
+
} catch {
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
unwrapResultType(type, location) {
|
|
1571
|
+
if (type.isUnion()) {
|
|
1572
|
+
for (const member of type.getUnionTypes()) {
|
|
1573
|
+
const dataProp2 = member.getProperty("data");
|
|
1574
|
+
if (dataProp2) {
|
|
1575
|
+
return dataProp2.getTypeAtLocation(location);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
const dataProp = type.getProperty("data");
|
|
1581
|
+
if (dataProp) {
|
|
1582
|
+
return dataProp.getTypeAtLocation(location);
|
|
1583
|
+
}
|
|
1584
|
+
return type;
|
|
1585
|
+
}
|
|
1586
|
+
mapTsType(type) {
|
|
1587
|
+
const typeText = type.getText();
|
|
1588
|
+
if (type.isUnion()) {
|
|
1589
|
+
const nonUndefined = type.getUnionTypes().filter((t) => !t.isUndefined());
|
|
1590
|
+
if (nonUndefined.length === 1 && nonUndefined[0]) {
|
|
1591
|
+
return this.mapTsType(nonUndefined[0]);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
if (type.isString() || type.isStringLiteral())
|
|
1595
|
+
return "string";
|
|
1596
|
+
if (type.isNumber() || type.isNumberLiteral())
|
|
1597
|
+
return "number";
|
|
1598
|
+
if (type.isBoolean() || type.isBooleanLiteral())
|
|
1599
|
+
return "boolean";
|
|
1600
|
+
if (typeText === "Date")
|
|
1601
|
+
return "date";
|
|
1602
|
+
return "unknown";
|
|
1603
|
+
}
|
|
1604
|
+
extractAccess(configObj) {
|
|
1605
|
+
const defaults = {
|
|
1606
|
+
list: "none",
|
|
1607
|
+
get: "none",
|
|
1608
|
+
create: "none",
|
|
1609
|
+
update: "none",
|
|
1610
|
+
delete: "none",
|
|
1611
|
+
custom: {}
|
|
1612
|
+
};
|
|
1613
|
+
const accessExpr = getPropertyValue(configObj, "access");
|
|
1614
|
+
if (!accessExpr || !accessExpr.isKind(SyntaxKind9.ObjectLiteralExpression))
|
|
1615
|
+
return defaults;
|
|
1616
|
+
const result = { ...defaults };
|
|
1617
|
+
const knownOps = new Set([...CRUD_OPS]);
|
|
1618
|
+
for (const { name, value } of getProperties(accessExpr)) {
|
|
1619
|
+
const kind = this.classifyAccessRule(value);
|
|
1620
|
+
if (knownOps.has(name)) {
|
|
1621
|
+
result[name] = kind;
|
|
1622
|
+
} else {
|
|
1623
|
+
result.custom[name] = kind;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
return result;
|
|
1627
|
+
}
|
|
1628
|
+
classifyAccessRule(expr) {
|
|
1629
|
+
const boolVal = getBooleanValue(expr);
|
|
1630
|
+
if (boolVal === false)
|
|
1631
|
+
return "false";
|
|
1632
|
+
if (boolVal === true)
|
|
1633
|
+
return "none";
|
|
1634
|
+
return "function";
|
|
1635
|
+
}
|
|
1636
|
+
extractHooks(configObj) {
|
|
1637
|
+
const hooks = { before: [], after: [] };
|
|
1638
|
+
const beforeExpr = getPropertyValue(configObj, "before");
|
|
1639
|
+
if (beforeExpr?.isKind(SyntaxKind9.ObjectLiteralExpression)) {
|
|
1640
|
+
for (const { name } of getProperties(beforeExpr)) {
|
|
1641
|
+
if (name === "create" || name === "update")
|
|
1642
|
+
hooks.before.push(name);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
const afterExpr = getPropertyValue(configObj, "after");
|
|
1646
|
+
if (afterExpr?.isKind(SyntaxKind9.ObjectLiteralExpression)) {
|
|
1647
|
+
for (const { name } of getProperties(afterExpr)) {
|
|
1648
|
+
if (name === "create" || name === "update" || name === "delete")
|
|
1649
|
+
hooks.after.push(name);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
return hooks;
|
|
1653
|
+
}
|
|
1654
|
+
extractActions(configObj) {
|
|
1655
|
+
const actionsExpr = getPropertyValue(configObj, "actions");
|
|
1656
|
+
if (!actionsExpr?.isKind(SyntaxKind9.ObjectLiteralExpression))
|
|
1657
|
+
return [];
|
|
1658
|
+
return getProperties(actionsExpr).map(({ name, value }) => {
|
|
1659
|
+
const actionObj = value.isKind(SyntaxKind9.ObjectLiteralExpression) ? value : null;
|
|
1660
|
+
const loc = getSourceLocation(value);
|
|
1661
|
+
const bodyExpr = actionObj ? getPropertyValue(actionObj, "body") : null;
|
|
1662
|
+
const responseExpr = actionObj ? getPropertyValue(actionObj, "response") : null;
|
|
1663
|
+
if (!bodyExpr && !responseExpr) {
|
|
1664
|
+
this.addDiagnostic({
|
|
1665
|
+
code: "ENTITY_ACTION_MISSING_SCHEMA",
|
|
1666
|
+
severity: "warning",
|
|
1667
|
+
message: `Custom action "${name}" is missing body and response schema`,
|
|
1668
|
+
...loc
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
const body = bodyExpr ? this.resolveSchemaFromExpression(bodyExpr, loc) : undefined;
|
|
1672
|
+
const response = responseExpr ? this.resolveSchemaFromExpression(responseExpr, loc) : undefined;
|
|
1673
|
+
const methodExpr = actionObj ? getPropertyValue(actionObj, "method") : null;
|
|
1674
|
+
let method = "POST";
|
|
1675
|
+
if (methodExpr) {
|
|
1676
|
+
const methodStr = getStringValue(methodExpr);
|
|
1677
|
+
const validMethods = [
|
|
1678
|
+
"GET",
|
|
1679
|
+
"POST",
|
|
1680
|
+
"PUT",
|
|
1681
|
+
"DELETE",
|
|
1682
|
+
"PATCH",
|
|
1683
|
+
"HEAD",
|
|
1684
|
+
"OPTIONS"
|
|
1685
|
+
];
|
|
1686
|
+
if (methodStr && validMethods.includes(methodStr)) {
|
|
1687
|
+
method = methodStr;
|
|
1688
|
+
} else {
|
|
1689
|
+
this.addDiagnostic({
|
|
1690
|
+
code: "ENTITY_ACTION_INVALID_METHOD",
|
|
1691
|
+
severity: "error",
|
|
1692
|
+
message: `Custom action "${name}" has invalid method "${methodStr ?? "(non-string)"}" — must be one of GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS`,
|
|
1693
|
+
...loc
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
const pathExpr = actionObj ? getPropertyValue(actionObj, "path") : null;
|
|
1698
|
+
const path = pathExpr ? getStringValue(pathExpr) ?? undefined : undefined;
|
|
1699
|
+
const queryExpr = actionObj ? getPropertyValue(actionObj, "query") : null;
|
|
1700
|
+
const queryRef = queryExpr ? this.resolveSchemaFromExpression(queryExpr, loc) : undefined;
|
|
1701
|
+
const paramsExpr = actionObj ? getPropertyValue(actionObj, "params") : null;
|
|
1702
|
+
const paramsRef = paramsExpr ? this.resolveSchemaFromExpression(paramsExpr, loc) : undefined;
|
|
1703
|
+
const headersExpr = actionObj ? getPropertyValue(actionObj, "headers") : null;
|
|
1704
|
+
const headersRef = headersExpr ? this.resolveSchemaFromExpression(headersExpr, loc) : undefined;
|
|
1705
|
+
return {
|
|
1706
|
+
name,
|
|
1707
|
+
method,
|
|
1708
|
+
path,
|
|
1709
|
+
params: paramsRef,
|
|
1710
|
+
query: queryRef,
|
|
1711
|
+
headers: headersRef,
|
|
1712
|
+
body,
|
|
1713
|
+
response,
|
|
1714
|
+
...loc
|
|
1715
|
+
};
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
resolveSchemaFromExpression(expr, loc) {
|
|
1719
|
+
if (expr.isKind(SyntaxKind9.Identifier)) {
|
|
1720
|
+
const varName = expr.getText();
|
|
1721
|
+
return { kind: "named", schemaName: varName, sourceFile: loc.sourceFile };
|
|
1722
|
+
}
|
|
1723
|
+
try {
|
|
1724
|
+
const typeText = expr.getType().getText();
|
|
1725
|
+
return { kind: "inline", sourceFile: loc.sourceFile, jsonSchema: { __typeText: typeText } };
|
|
1726
|
+
} catch {
|
|
1727
|
+
return { kind: "inline", sourceFile: loc.sourceFile };
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
extractRelations(configObj) {
|
|
1731
|
+
const relExpr = getPropertyValue(configObj, "relations");
|
|
1732
|
+
if (!relExpr?.isKind(SyntaxKind9.ObjectLiteralExpression))
|
|
1733
|
+
return [];
|
|
1734
|
+
return getProperties(relExpr).filter(({ value }) => {
|
|
1735
|
+
const boolVal = getBooleanValue(value);
|
|
1736
|
+
return boolVal !== false;
|
|
1737
|
+
}).map(({ name, value }) => {
|
|
1738
|
+
const boolVal = getBooleanValue(value);
|
|
1739
|
+
if (boolVal === true)
|
|
1740
|
+
return { name, selection: "all" };
|
|
1741
|
+
if (value.isKind(SyntaxKind9.ObjectLiteralExpression)) {
|
|
1742
|
+
const fields = getProperties(value).map((p) => p.name);
|
|
1743
|
+
return { name, selection: fields };
|
|
1744
|
+
}
|
|
1745
|
+
return { name, selection: "all" };
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1271
1749
|
// src/compiler.ts
|
|
1750
|
+
import { mkdir } from "node:fs/promises";
|
|
1751
|
+
import { resolve } from "node:path";
|
|
1272
1752
|
import { Project } from "ts-morph";
|
|
1273
1753
|
|
|
1274
1754
|
// src/config.ts
|
|
@@ -1859,10 +2339,31 @@ function createEmptyAppIR() {
|
|
|
1859
2339
|
modules: [],
|
|
1860
2340
|
middleware: [],
|
|
1861
2341
|
schemas: [],
|
|
2342
|
+
entities: [],
|
|
1862
2343
|
dependencyGraph: createEmptyDependencyGraph(),
|
|
1863
2344
|
diagnostics: []
|
|
1864
2345
|
};
|
|
1865
2346
|
}
|
|
2347
|
+
function enrichSchemasWithModuleNames(ir) {
|
|
2348
|
+
const schemaToModule = new Map;
|
|
2349
|
+
for (const mod of ir.modules) {
|
|
2350
|
+
for (const router of mod.routers) {
|
|
2351
|
+
for (const route of router.routes) {
|
|
2352
|
+
const refs = [route.body, route.query, route.params, route.headers, route.response];
|
|
2353
|
+
for (const ref of refs) {
|
|
2354
|
+
if (ref?.kind === "named") {
|
|
2355
|
+
schemaToModule.set(ref.schemaName, mod.name);
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
const schemas = ir.schemas.map((s) => ({
|
|
2362
|
+
...s,
|
|
2363
|
+
moduleName: schemaToModule.get(s.name) ?? s.moduleName
|
|
2364
|
+
}));
|
|
2365
|
+
return { ...ir, schemas };
|
|
2366
|
+
}
|
|
1866
2367
|
function addDiagnosticsToIR(ir, diagnostics) {
|
|
1867
2368
|
return {
|
|
1868
2369
|
...ir,
|
|
@@ -1870,6 +2371,162 @@ function addDiagnosticsToIR(ir, diagnostics) {
|
|
|
1870
2371
|
};
|
|
1871
2372
|
}
|
|
1872
2373
|
|
|
2374
|
+
// src/ir/entity-route-injector.ts
|
|
2375
|
+
var SYNTHETIC_MODULE = "__entities";
|
|
2376
|
+
function toPascalCase(s) {
|
|
2377
|
+
return s.split("-").map((w) => w[0]?.toUpperCase() + w.slice(1)).join("");
|
|
2378
|
+
}
|
|
2379
|
+
function injectEntityRoutes(ir) {
|
|
2380
|
+
if (!ir.entities.length)
|
|
2381
|
+
return;
|
|
2382
|
+
const routes = [];
|
|
2383
|
+
for (const entity of ir.entities) {
|
|
2384
|
+
routes.push(...generateCrudRoutes(entity));
|
|
2385
|
+
routes.push(...generateActionRoutes(entity));
|
|
2386
|
+
}
|
|
2387
|
+
if (!routes.length)
|
|
2388
|
+
return;
|
|
2389
|
+
const router = {
|
|
2390
|
+
name: `${SYNTHETIC_MODULE}_router`,
|
|
2391
|
+
moduleName: SYNTHETIC_MODULE,
|
|
2392
|
+
prefix: "",
|
|
2393
|
+
inject: [],
|
|
2394
|
+
routes,
|
|
2395
|
+
sourceFile: "",
|
|
2396
|
+
sourceLine: 0,
|
|
2397
|
+
sourceColumn: 0
|
|
2398
|
+
};
|
|
2399
|
+
const module = {
|
|
2400
|
+
name: SYNTHETIC_MODULE,
|
|
2401
|
+
imports: [],
|
|
2402
|
+
services: [],
|
|
2403
|
+
routers: [router],
|
|
2404
|
+
exports: [],
|
|
2405
|
+
sourceFile: "",
|
|
2406
|
+
sourceLine: 0,
|
|
2407
|
+
sourceColumn: 0
|
|
2408
|
+
};
|
|
2409
|
+
ir.modules.push(module);
|
|
2410
|
+
}
|
|
2411
|
+
function generateCrudRoutes(entity) {
|
|
2412
|
+
const entityPascal = toPascalCase(entity.name);
|
|
2413
|
+
const basePath = `/${entity.name}`;
|
|
2414
|
+
const routes = [];
|
|
2415
|
+
const ops = [
|
|
2416
|
+
{ op: "list", method: "GET", path: basePath, idParam: false },
|
|
2417
|
+
{ op: "get", method: "GET", path: `${basePath}/:id`, idParam: true },
|
|
2418
|
+
{ op: "create", method: "POST", path: basePath, idParam: false },
|
|
2419
|
+
{ op: "update", method: "PATCH", path: `${basePath}/:id`, idParam: true },
|
|
2420
|
+
{ op: "delete", method: "DELETE", path: `${basePath}/:id`, idParam: true }
|
|
2421
|
+
];
|
|
2422
|
+
for (const { op, method, path } of ops) {
|
|
2423
|
+
const accessKind = entity.access[op];
|
|
2424
|
+
if (accessKind === "false")
|
|
2425
|
+
continue;
|
|
2426
|
+
const route = {
|
|
2427
|
+
method,
|
|
2428
|
+
path,
|
|
2429
|
+
fullPath: path,
|
|
2430
|
+
operationId: `${op}${entityPascal}`,
|
|
2431
|
+
middleware: [],
|
|
2432
|
+
tags: [entity.name],
|
|
2433
|
+
description: `${op} ${entity.name}`,
|
|
2434
|
+
...entity
|
|
2435
|
+
};
|
|
2436
|
+
if (entity.modelRef.schemaRefs.resolved) {
|
|
2437
|
+
if (op === "create") {
|
|
2438
|
+
route.body = entity.modelRef.schemaRefs.createInput;
|
|
2439
|
+
route.response = entity.modelRef.schemaRefs.response;
|
|
2440
|
+
} else if (op === "update") {
|
|
2441
|
+
route.body = entity.modelRef.schemaRefs.updateInput;
|
|
2442
|
+
route.response = entity.modelRef.schemaRefs.response;
|
|
2443
|
+
} else if (op === "list") {
|
|
2444
|
+
route.response = wrapInPaginatedEnvelope(entity.modelRef.schemaRefs.response);
|
|
2445
|
+
} else {
|
|
2446
|
+
route.response = entity.modelRef.schemaRefs.response;
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
routes.push(route);
|
|
2450
|
+
}
|
|
2451
|
+
return routes;
|
|
2452
|
+
}
|
|
2453
|
+
function wrapInPaginatedEnvelope(itemSchema) {
|
|
2454
|
+
if (!itemSchema)
|
|
2455
|
+
return;
|
|
2456
|
+
const itemJsonSchema = itemSchema.kind === "named" ? { $ref: `#/components/schemas/${itemSchema.schemaName}` } : itemSchema.jsonSchema ?? {};
|
|
2457
|
+
return {
|
|
2458
|
+
kind: "inline",
|
|
2459
|
+
sourceFile: itemSchema.sourceFile,
|
|
2460
|
+
jsonSchema: {
|
|
2461
|
+
type: "object",
|
|
2462
|
+
properties: {
|
|
2463
|
+
items: { type: "array", items: itemJsonSchema },
|
|
2464
|
+
total: { type: "number" },
|
|
2465
|
+
limit: { type: "number" },
|
|
2466
|
+
nextCursor: { type: ["string", "null"] },
|
|
2467
|
+
hasNextPage: { type: "boolean" }
|
|
2468
|
+
},
|
|
2469
|
+
required: ["items", "total", "limit", "nextCursor", "hasNextPage"]
|
|
2470
|
+
}
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
function generateActionRoutes(entity) {
|
|
2474
|
+
const entityPascal = toPascalCase(entity.name);
|
|
2475
|
+
return entity.actions.filter((action) => entity.access.custom[action.name] !== "false").map((action) => {
|
|
2476
|
+
const method = action.method;
|
|
2477
|
+
const path = action.path ? `/${entity.name}/${action.path}` : `/${entity.name}/:id/${action.name}`;
|
|
2478
|
+
const fullPath = path;
|
|
2479
|
+
return {
|
|
2480
|
+
method,
|
|
2481
|
+
path,
|
|
2482
|
+
fullPath,
|
|
2483
|
+
operationId: `${action.name}${entityPascal}`,
|
|
2484
|
+
params: action.params,
|
|
2485
|
+
query: action.query,
|
|
2486
|
+
headers: action.headers,
|
|
2487
|
+
body: action.body,
|
|
2488
|
+
response: action.response,
|
|
2489
|
+
middleware: [],
|
|
2490
|
+
tags: [entity.name],
|
|
2491
|
+
description: `${action.name} on ${entity.name}`,
|
|
2492
|
+
sourceFile: action.sourceFile,
|
|
2493
|
+
sourceLine: action.sourceLine,
|
|
2494
|
+
sourceColumn: action.sourceColumn
|
|
2495
|
+
};
|
|
2496
|
+
});
|
|
2497
|
+
}
|
|
2498
|
+
function detectRouteCollisions(ir) {
|
|
2499
|
+
const diagnostics = [];
|
|
2500
|
+
const seen = new Map;
|
|
2501
|
+
for (const mod of ir.modules) {
|
|
2502
|
+
if (mod.name === SYNTHETIC_MODULE)
|
|
2503
|
+
continue;
|
|
2504
|
+
for (const router of mod.routers) {
|
|
2505
|
+
for (const route of router.routes) {
|
|
2506
|
+
seen.set(route.operationId, route);
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
const entityModule = ir.modules.find((m) => m.name === SYNTHETIC_MODULE);
|
|
2511
|
+
if (entityModule) {
|
|
2512
|
+
for (const router of entityModule.routers) {
|
|
2513
|
+
for (const route of router.routes) {
|
|
2514
|
+
const existing = seen.get(route.operationId);
|
|
2515
|
+
if (existing) {
|
|
2516
|
+
diagnostics.push({
|
|
2517
|
+
code: "ENTITY_ROUTE_COLLISION",
|
|
2518
|
+
severity: "error",
|
|
2519
|
+
message: `Entity-generated operationId "${route.operationId}" collides with existing route at ${existing.sourceFile}:${existing.sourceLine}`,
|
|
2520
|
+
...route
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
seen.set(route.operationId, route);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
return diagnostics;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
1873
2530
|
// src/validators/completeness-validator.ts
|
|
1874
2531
|
var METHODS_WITHOUT_RESPONSE = new Set(["DELETE", "HEAD", "OPTIONS"]);
|
|
1875
2532
|
var RESERVED_CTX_KEYS = new Set([
|
|
@@ -2400,14 +3057,20 @@ class Compiler {
|
|
|
2400
3057
|
const moduleResult = await analyzers.module.analyze();
|
|
2401
3058
|
const middlewareResult = await analyzers.middleware.analyze();
|
|
2402
3059
|
const appResult = await analyzers.app.analyze();
|
|
3060
|
+
const entityResult = await analyzers.entity.analyze();
|
|
2403
3061
|
const depGraphResult = await analyzers.dependencyGraph.analyze();
|
|
2404
3062
|
ir.env = envResult.env;
|
|
2405
3063
|
ir.schemas = schemaResult.schemas;
|
|
2406
3064
|
ir.modules = moduleResult.modules;
|
|
2407
3065
|
ir.middleware = middlewareResult.middleware;
|
|
2408
3066
|
ir.app = appResult.app;
|
|
3067
|
+
ir.entities = entityResult.entities;
|
|
2409
3068
|
ir.dependencyGraph = depGraphResult.graph;
|
|
2410
|
-
|
|
3069
|
+
ir.diagnostics.push(...analyzers.env.getDiagnostics(), ...analyzers.schema.getDiagnostics(), ...analyzers.middleware.getDiagnostics(), ...analyzers.module.getDiagnostics(), ...analyzers.app.getDiagnostics(), ...analyzers.entity.getDiagnostics(), ...analyzers.dependencyGraph.getDiagnostics());
|
|
3070
|
+
injectEntityRoutes(ir);
|
|
3071
|
+
const collisionDiags = detectRouteCollisions(ir);
|
|
3072
|
+
ir.diagnostics.push(...collisionDiags);
|
|
3073
|
+
return enrichSchemasWithModuleNames(ir);
|
|
2411
3074
|
}
|
|
2412
3075
|
async validate(ir) {
|
|
2413
3076
|
const allDiagnostics = [];
|
|
@@ -2419,6 +3082,7 @@ class Compiler {
|
|
|
2419
3082
|
}
|
|
2420
3083
|
async generate(ir) {
|
|
2421
3084
|
const outputDir = this.config.compiler.outputDir;
|
|
3085
|
+
await mkdir(resolve(outputDir), { recursive: true });
|
|
2422
3086
|
await Promise.all(this.deps.generators.map((g) => g.generate(ir, outputDir)));
|
|
2423
3087
|
}
|
|
2424
3088
|
async compile() {
|
|
@@ -2445,6 +3109,7 @@ function createCompiler(config) {
|
|
|
2445
3109
|
middleware: new MiddlewareAnalyzer(project, resolved),
|
|
2446
3110
|
module: new ModuleAnalyzer(project, resolved),
|
|
2447
3111
|
app: new AppAnalyzer(project, resolved),
|
|
3112
|
+
entity: new EntityAnalyzer(project, resolved),
|
|
2448
3113
|
dependencyGraph: new DependencyGraphAnalyzer(project, resolved)
|
|
2449
3114
|
},
|
|
2450
3115
|
validators: [
|
|
@@ -2656,7 +3321,7 @@ async function* typecheckWatch(options = {}) {
|
|
|
2656
3321
|
const completionMarker = /Found \d+ error/;
|
|
2657
3322
|
let buffer = "";
|
|
2658
3323
|
const results = [];
|
|
2659
|
-
let
|
|
3324
|
+
let resolve2 = null;
|
|
2660
3325
|
let done = false;
|
|
2661
3326
|
const onData = (chunk) => {
|
|
2662
3327
|
buffer += chunk.toString();
|
|
@@ -2664,14 +3329,14 @@ async function* typecheckWatch(options = {}) {
|
|
|
2664
3329
|
const result = parseWatchBlock(buffer);
|
|
2665
3330
|
buffer = "";
|
|
2666
3331
|
results.push(result);
|
|
2667
|
-
|
|
3332
|
+
resolve2?.();
|
|
2668
3333
|
}
|
|
2669
3334
|
};
|
|
2670
3335
|
proc.stdout?.on("data", onData);
|
|
2671
3336
|
proc.stderr?.on("data", onData);
|
|
2672
3337
|
proc.on("close", () => {
|
|
2673
3338
|
done = true;
|
|
2674
|
-
|
|
3339
|
+
resolve2?.();
|
|
2675
3340
|
});
|
|
2676
3341
|
try {
|
|
2677
3342
|
while (!done || results.length > 0) {
|
|
@@ -2680,7 +3345,7 @@ async function* typecheckWatch(options = {}) {
|
|
|
2680
3345
|
yield next;
|
|
2681
3346
|
} else if (!done) {
|
|
2682
3347
|
await new Promise((r) => {
|
|
2683
|
-
|
|
3348
|
+
resolve2 = r;
|
|
2684
3349
|
});
|
|
2685
3350
|
}
|
|
2686
3351
|
}
|
|
@@ -2693,11 +3358,11 @@ async function typecheck(options = {}) {
|
|
|
2693
3358
|
if (options.tsconfigPath) {
|
|
2694
3359
|
args.push("--project", options.tsconfigPath);
|
|
2695
3360
|
}
|
|
2696
|
-
return new Promise((
|
|
3361
|
+
return new Promise((resolve2) => {
|
|
2697
3362
|
execFile("tsc", args, { cwd: process.cwd() }, (error, stdout, stderr) => {
|
|
2698
3363
|
const output = stdout + stderr;
|
|
2699
3364
|
const diagnostics = parseTscOutput(output);
|
|
2700
|
-
|
|
3365
|
+
resolve2({
|
|
2701
3366
|
success: !error,
|
|
2702
3367
|
diagnostics
|
|
2703
3368
|
});
|
|
@@ -2758,6 +3423,7 @@ export {
|
|
|
2758
3423
|
isSchemaFile,
|
|
2759
3424
|
isSchemaExpression,
|
|
2760
3425
|
isFromImport,
|
|
3426
|
+
injectEntityRoutes,
|
|
2761
3427
|
hasErrors,
|
|
2762
3428
|
getVariableNameForCall,
|
|
2763
3429
|
getStringValue,
|
|
@@ -2775,6 +3441,7 @@ export {
|
|
|
2775
3441
|
extractObjectLiteral,
|
|
2776
3442
|
extractMethodSignatures,
|
|
2777
3443
|
extractIdentifierNames,
|
|
3444
|
+
detectRouteCollisions,
|
|
2778
3445
|
defineConfig,
|
|
2779
3446
|
createSchemaExecutor,
|
|
2780
3447
|
createNamedSchemaRef,
|
|
@@ -2804,6 +3471,7 @@ export {
|
|
|
2804
3471
|
ManifestGenerator,
|
|
2805
3472
|
IncrementalCompiler,
|
|
2806
3473
|
EnvAnalyzer,
|
|
3474
|
+
EntityAnalyzer,
|
|
2807
3475
|
DependencyGraphAnalyzer,
|
|
2808
3476
|
CompletenessValidator,
|
|
2809
3477
|
Compiler,
|
package/package.json
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vertz/compiler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"description": "Vertz compiler — internal, no stability guarantee",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
9
|
"url": "https://github.com/vertz-dev/vertz.git",
|
|
9
10
|
"directory": "packages/compiler"
|
|
10
11
|
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public",
|
|
13
|
-
"provenance": true
|
|
14
|
-
},
|
|
15
12
|
"main": "dist/index.js",
|
|
16
13
|
"types": "dist/index.d.ts",
|
|
17
14
|
"exports": {
|
|
@@ -25,20 +22,26 @@
|
|
|
25
22
|
],
|
|
26
23
|
"scripts": {
|
|
27
24
|
"build": "bunup",
|
|
28
|
-
"test": "
|
|
25
|
+
"test": "bun test",
|
|
26
|
+
"test:type": "vitest typecheck",
|
|
29
27
|
"test:watch": "vitest",
|
|
30
28
|
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json"
|
|
31
29
|
},
|
|
32
30
|
"dependencies": {
|
|
33
|
-
"ts-morph": "^
|
|
31
|
+
"ts-morph": "^27.0.2"
|
|
34
32
|
},
|
|
35
33
|
"devDependencies": {
|
|
36
|
-
"@types/node": "^25.
|
|
37
|
-
"
|
|
34
|
+
"@types/node": "^25.3.1",
|
|
35
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
36
|
+
"bunup": "^0.16.31",
|
|
38
37
|
"typescript": "^5.7.0",
|
|
39
|
-
"vitest": "^
|
|
38
|
+
"vitest": "^4.0.18"
|
|
40
39
|
},
|
|
41
40
|
"engines": {
|
|
42
41
|
"node": ">=22"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"provenance": true
|
|
43
46
|
}
|
|
44
47
|
}
|