@goboost/scanner-typescript 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Scanner entrypoint — orchestrates all extractors and outputs normalized IR JSON.
3
+ *
4
+ * Produces DUAL IR per the goBoost Decoupled Universal Extractor architecture:
5
+ * - goboost.api.json (API IR v2.0.0) — controllers, DTOs, guards
6
+ * - goboost.db.json (DB IR v1.0.0) — tables, columns, relations, enums (from Prisma)
7
+ *
8
+ * Constitution v1.2.0 compliance:
9
+ * - Principle V: Prisma as single source of truth for DB schema
10
+ * - Principle VIII: The Framework Rosetta Stone — JSII-compliant IR
11
+ */
12
+ import { Project } from 'ts-morph';
13
+ import { extractControllers } from './controller-extractor';
14
+ import { extractDTOs, collectReferencedDTOs, markSharedDTOs } from './dto-extractor';
15
+ import { extractPrismaSchema, DBRoot } from './prisma-schema-extractor';
16
+ import { extractGuards } from './guard-extractor';
17
+ import { detectUnsupported, canGenerate } from './unsupported-detector';
18
+
19
+ const API_IR_VERSION = '2.0.0';
20
+
21
+ interface ScanOptions {
22
+ projectPath: string;
23
+ tsConfigPath?: string;
24
+ prismaSchemaPath?: string;
25
+ }
26
+
27
+ /** API IR root (goboost.api.json v2.0.0) — matches internal/ir/api_types.go */
28
+ interface APIRoot {
29
+ version: string;
30
+ sourceFramework: string;
31
+ projectRoot: string;
32
+ scannedAt: string;
33
+ controllers: any[];
34
+ dtos: Record<string, any>;
35
+ guards: Record<string, any>;
36
+ meta?: { generatorVersion?: string; scanDuration?: string };
37
+ }
38
+
39
+ interface ScanResult {
40
+ apiIR: APIRoot;
41
+ dbIR: DBRoot;
42
+ }
43
+
44
+ export async function scan(options: ScanOptions): Promise<ScanResult> {
45
+ const { projectPath, tsConfigPath } = options;
46
+ const startTime = Date.now();
47
+
48
+ // Initialize ts-morph project for API extraction
49
+ const project = new Project({
50
+ tsConfigFilePath: tsConfigPath || `${projectPath}/tsconfig.json`,
51
+ skipAddingFilesFromTsConfig: false,
52
+ });
53
+
54
+ // ---- API IR extraction ----
55
+
56
+ // Extract controllers
57
+ const controllers = extractControllers(project);
58
+
59
+ // Detect circular service dependencies
60
+ const circularDeps = detectCircularDependencies(project, controllers);
61
+ if (circularDeps.length > 0) {
62
+ console.error('Warning: Circular service dependencies detected:');
63
+ for (const dep of circularDeps) {
64
+ console.error(` - ${dep}`);
65
+ }
66
+ }
67
+
68
+ // Collect and extract DTOs
69
+ const referencedDTOs = collectReferencedDTOs(controllers);
70
+ const dtos = extractDTOs(project, referencedDTOs);
71
+ markSharedDTOs(dtos, controllers);
72
+
73
+ // Extract guards
74
+ const guards = extractGuards(project);
75
+
76
+ // Detect unsupported features per route
77
+ for (const controller of controllers) {
78
+ const controllerUnsupported: string[] = [];
79
+
80
+ const controllerCyclicDeps = circularDeps.filter(d => d.startsWith(controller.name));
81
+ for (const dep of controllerCyclicDeps) {
82
+ if (!controllerUnsupported.includes('circular-dependency')) {
83
+ controllerUnsupported.push('circular-dependency');
84
+ }
85
+ }
86
+
87
+ for (const route of controller.routes) {
88
+ const unsupported = detectUnsupported(project, {
89
+ handlerName: route.handlerName,
90
+ unsupportedFeatures: [],
91
+ });
92
+ route.unsupportedFeatures = unsupported;
93
+ route.canGenerate = canGenerate(unsupported);
94
+
95
+ for (const feature of unsupported) {
96
+ if (!controllerUnsupported.includes(feature)) {
97
+ controllerUnsupported.push(feature);
98
+ }
99
+ }
100
+ }
101
+
102
+ controller.unsupportedFeatures = controllerUnsupported;
103
+ }
104
+
105
+ const scanDuration = `${Date.now() - startTime}ms`;
106
+
107
+ // Build API IR
108
+ const apiIR: APIRoot = {
109
+ version: API_IR_VERSION,
110
+ sourceFramework: 'nestjs',
111
+ projectRoot: projectPath,
112
+ scannedAt: new Date().toISOString(),
113
+ controllers,
114
+ dtos,
115
+ guards,
116
+ meta: {
117
+ generatorVersion: '1.1.0',
118
+ scanDuration,
119
+ },
120
+ };
121
+
122
+ // Normalize API IR for determinism
123
+ normalizeAPIIR(apiIR);
124
+
125
+ // ---- DB IR extraction (Prisma DMMF) ----
126
+
127
+ const dbIR = await extractPrismaSchema({
128
+ projectPath,
129
+ schemaPath: options.prismaSchemaPath,
130
+ });
131
+
132
+ return { apiIR, dbIR };
133
+ }
134
+
135
+ /**
136
+ * Detect circular dependencies between services in controllers
137
+ */
138
+ function detectCircularDependencies(project: Project, controllers: any[]): string[] {
139
+ const circularDeps: string[] = [];
140
+ const visited = new Set<string>();
141
+ const dependencyGraph = new Map<string, Set<string>>();
142
+
143
+ for (const controller of controllers) {
144
+ if (controller.dependencies && controller.dependencies.length > 0) {
145
+ const deps = new Set<string>();
146
+ for (const dep of controller.dependencies) {
147
+ const serviceName = extractServiceName(dep);
148
+ if (serviceName) {
149
+ deps.add(serviceName);
150
+ }
151
+ }
152
+ dependencyGraph.set(controller.name, deps);
153
+ }
154
+ }
155
+
156
+ function dfs(node: string, path: string[]): boolean {
157
+ if (visited.has(node)) {
158
+ const cycleStart = path.indexOf(node);
159
+ if (cycleStart !== -1) {
160
+ const cycle = [...path.slice(cycleStart), node].join(' -> ');
161
+ circularDeps.push(`${node}: ${cycle}`);
162
+ }
163
+ return true;
164
+ }
165
+
166
+ visited.add(node);
167
+ path.push(node);
168
+
169
+ const deps = dependencyGraph.get(node);
170
+ if (deps) {
171
+ for (const dep of deps) {
172
+ if (path.includes(dep) || dfs(dep, [...path])) {
173
+ // Continue to find all cycles
174
+ }
175
+ }
176
+ }
177
+
178
+ path.pop();
179
+ return false;
180
+ }
181
+
182
+ for (const controller of controllers) {
183
+ visited.clear();
184
+ dfs(controller.name, []);
185
+ }
186
+
187
+ return circularDeps;
188
+ }
189
+
190
+ function extractServiceName(typeText: string): string | null {
191
+ const match = typeText.match(/(\w+Service)/);
192
+ return match ? match[1] : null;
193
+ }
194
+
195
+ /**
196
+ * Normalize API IR for deterministic output
197
+ */
198
+ function normalizeAPIIR(ir: APIRoot): void {
199
+ for (const controller of ir.controllers) {
200
+ controller.routes.sort((a: any, b: any) => {
201
+ const methodCompare = a.httpMethod.localeCompare(b.httpMethod);
202
+ if (methodCompare !== 0) return methodCompare;
203
+ return a.path.localeCompare(b.path);
204
+ });
205
+ }
206
+
207
+ ir.dtos = sortObjectKeys(ir.dtos);
208
+ ir.guards = sortObjectKeys(ir.guards);
209
+ }
210
+
211
+ function sortObjectKeys<T>(obj: Record<string, T>): Record<string, T> {
212
+ const sorted: Record<string, T> = {};
213
+ const keys = Object.keys(obj).sort();
214
+ for (const key of keys) {
215
+ sorted[key] = obj[key];
216
+ }
217
+ return sorted;
218
+ }
219
+
220
+ /**
221
+ * CLI entrypoint — outputs dual IR JSON to stdout or files
222
+ */
223
+ if (require.main === module) {
224
+ const args = process.argv.slice(2);
225
+
226
+ let projectPath = process.cwd();
227
+ let tsConfigPath: string | undefined;
228
+ let prismaSchemaPath: string | undefined;
229
+ let outputApiPath: string | undefined;
230
+ let outputDbPath: string | undefined;
231
+
232
+ for (let i = 0; i < args.length; i++) {
233
+ if (args[i] === '--project' || args[i] === '-p') {
234
+ projectPath = args[++i];
235
+ } else if (args[i] === '--tsconfig' || args[i] === '-t') {
236
+ tsConfigPath = args[++i];
237
+ } else if (args[i] === '--prisma-schema') {
238
+ prismaSchemaPath = args[++i];
239
+ } else if (args[i] === '--output-api') {
240
+ outputApiPath = args[++i];
241
+ } else if (args[i] === '--output-db') {
242
+ outputDbPath = args[++i];
243
+ } else if (!args[i].startsWith('-') && i === 0) {
244
+ projectPath = args[i];
245
+ }
246
+ }
247
+
248
+ scan({ projectPath, tsConfigPath, prismaSchemaPath })
249
+ .then(({ apiIR, dbIR }) => {
250
+ const apiJson = JSON.stringify(apiIR, null, 2);
251
+ const dbJson = JSON.stringify(dbIR, null, 2);
252
+
253
+ if (outputApiPath) {
254
+ const fs = require('fs');
255
+ const path = require('path');
256
+ fs.mkdirSync(path.dirname(outputApiPath), { recursive: true });
257
+ fs.writeFileSync(outputApiPath, apiJson, 'utf-8');
258
+ console.error(`API IR written to: ${outputApiPath}`);
259
+ }
260
+
261
+ if (outputDbPath) {
262
+ const fs = require('fs');
263
+ const path = require('path');
264
+ fs.mkdirSync(path.dirname(outputDbPath), { recursive: true });
265
+ fs.writeFileSync(outputDbPath, dbJson, 'utf-8');
266
+ console.error(`DB IR written to: ${outputDbPath}`);
267
+ }
268
+
269
+ // If no output paths specified, write both to stdout as a combined object
270
+ if (!outputApiPath && !outputDbPath) {
271
+ console.log(JSON.stringify({ api: apiIR, db: dbIR }, null, 2));
272
+ }
273
+ })
274
+ .catch((err) => {
275
+ console.error(JSON.stringify({
276
+ error: true,
277
+ message: err.message,
278
+ stack: err.stack,
279
+ }));
280
+ process.exit(1);
281
+ });
282
+ }
283
+
284
+ export { scan as scanProject };
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Prisma Schema Extractor — parses schema.prisma via @prisma/internals getDMMF()
3
+ * and produces goboost.db.json (DB IR v1.0.0) matching internal/ir/db_types.go.
4
+ *
5
+ * Constitution v1.2.0 Principle V: Single Shared Database — schema migrations
6
+ * managed from Prisma as the single source of truth. Performance Boosters use Gorm models
7
+ * generated from the canonical schema.
8
+ */
9
+ import { getDMMF } from '@prisma/internals';
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+
13
+ // ---- DB IR types (mirrors internal/ir/db_types.go) ----
14
+
15
+ export interface DBRoot {
16
+ version: string;
17
+ source: string;
18
+ databaseType: string;
19
+ schemaName: string;
20
+ extractedAt: string;
21
+ tables: TableSpec[];
22
+ enums: EnumSpec[];
23
+ }
24
+
25
+ export interface TableSpec {
26
+ name: string;
27
+ schema: string;
28
+ columns: ColumnSpec[];
29
+ relations: RelationSpec[];
30
+ }
31
+
32
+ export interface ColumnSpec {
33
+ name: string;
34
+ goType: string;
35
+ dbType: string;
36
+ isPrimary: boolean;
37
+ isAutoIncrement: boolean;
38
+ isNullable: boolean;
39
+ isUnique: boolean;
40
+ defaultValue?: string;
41
+ gormTag?: string;
42
+ }
43
+
44
+ export interface RelationSpec {
45
+ name: string;
46
+ type: string;
47
+ fromTable: string;
48
+ toTable: string;
49
+ fromColumns?: string[];
50
+ }
51
+
52
+ export interface EnumSpec {
53
+ name: string;
54
+ values: string[];
55
+ }
56
+
57
+ // ---- Prisma type → Go type mapping ----
58
+
59
+ const PRISMA_TO_GO_TYPE: Record<string, string> = {
60
+ 'String': 'string',
61
+ 'Int': 'int64',
62
+ 'BigInt': 'int64',
63
+ 'Float': 'float64',
64
+ 'Decimal': 'float64',
65
+ 'Boolean': 'bool',
66
+ 'DateTime': 'time.Time',
67
+ 'Json': 'datatypes.JSON',
68
+ 'Bytes': '[]byte',
69
+ };
70
+
71
+ const PRISMA_TO_DB_TYPE: Record<string, string> = {
72
+ 'String': 'VARCHAR(255)',
73
+ 'Int': 'INTEGER',
74
+ 'BigInt': 'BIGINT',
75
+ 'Float': 'DOUBLE PRECISION',
76
+ 'Decimal': 'DECIMAL',
77
+ 'Boolean': 'BOOLEAN',
78
+ 'DateTime': 'TIMESTAMP',
79
+ 'Json': 'JSONB',
80
+ 'Bytes': 'BYTEA',
81
+ };
82
+
83
+ // ---- Relation type mapping ----
84
+
85
+ function mapRelationType(kind: string, isList: boolean): string {
86
+ if (isList) {
87
+ return 'one-to-many';
88
+ }
89
+ // A non-list relation field on the side with the FK is many-to-one
90
+ return 'many-to-one';
91
+ }
92
+
93
+ // ---- Main extraction function ----
94
+
95
+ export interface ExtractOptions {
96
+ projectPath: string;
97
+ schemaPath?: string;
98
+ }
99
+
100
+ /**
101
+ * Detect the database provider from the schema.prisma datasource block.
102
+ */
103
+ function detectDatabaseType(schemaContent: string): string {
104
+ const providerMatch = schemaContent.match(
105
+ /datasource\s+\w+\s*\{[^}]*provider\s*=\s*"(\w+)"/s
106
+ );
107
+ if (providerMatch) {
108
+ const provider = providerMatch[1].toLowerCase();
109
+ switch (provider) {
110
+ case 'postgresql': return 'postgresql';
111
+ case 'mysql': return 'mysql';
112
+ case 'sqlite': return 'sqlite';
113
+ case 'sqlserver': return 'sqlserver';
114
+ case 'mongodb': return 'mongodb';
115
+ case 'cockroachdb': return 'cockroachdb';
116
+ default: return provider;
117
+ }
118
+ }
119
+ return 'postgresql'; // Default per Constitution (Principle V)
120
+ }
121
+
122
+ /**
123
+ * Extract DB IR from a Prisma schema file using @prisma/internals getDMMF().
124
+ * Output format matches internal/ir/db_types.go (DBRoot).
125
+ */
126
+ export async function extractPrismaSchema(options: ExtractOptions): Promise<DBRoot> {
127
+ const { projectPath } = options;
128
+
129
+ // 1. Locate schema.prisma
130
+ const schemaPath = options.schemaPath || findPrismaSchema(projectPath);
131
+ if (!schemaPath) {
132
+ // No schema.prisma found — return empty DB IR (per contract: source="none")
133
+ return {
134
+ version: '1.0.0',
135
+ source: 'none',
136
+ databaseType: 'postgresql',
137
+ schemaName: 'public',
138
+ extractedAt: new Date().toISOString(),
139
+ tables: [],
140
+ enums: [],
141
+ };
142
+ }
143
+
144
+ // 2. Read schema content
145
+ const schemaContent = fs.readFileSync(schemaPath, 'utf-8');
146
+ const databaseType = detectDatabaseType(schemaContent);
147
+
148
+ // 3. Parse via getDMMF (offline — no database connection required)
149
+ const dmmf = await getDMMF({ datamodel: schemaContent });
150
+
151
+ // 4. Map DMMF models → TableSpec[]
152
+ const tables: TableSpec[] = dmmf.datamodel.models.map((model) => {
153
+ const columns: ColumnSpec[] = [];
154
+ const relations: RelationSpec[] = [];
155
+
156
+ for (const field of model.fields) {
157
+ if (field.kind === 'scalar' || field.kind === 'enum') {
158
+ columns.push(mapFieldToColumn(field, model));
159
+ } else if (field.kind === 'object') {
160
+ relations.push(mapFieldToRelation(field, model));
161
+ }
162
+ }
163
+
164
+ return {
165
+ name: model.name,
166
+ schema: 'public',
167
+ columns,
168
+ relations: relations.length > 0 ? relations : [],
169
+ };
170
+ });
171
+
172
+ // 5. Map DMMF enums → EnumSpec[]
173
+ const enums: EnumSpec[] = dmmf.datamodel.enums.map((e) => ({
174
+ name: e.name,
175
+ values: e.values.map((v) => v.name),
176
+ }));
177
+
178
+ // 6. Sort for determinism
179
+ tables.sort((a, b) => a.name.localeCompare(b.name));
180
+ for (const table of tables) {
181
+ table.columns.sort((a, b) => {
182
+ // Primary keys first, then alphabetical
183
+ if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1;
184
+ return a.name.localeCompare(b.name);
185
+ });
186
+ table.relations.sort((a, b) => a.name.localeCompare(b.name));
187
+ }
188
+ enums.sort((a, b) => a.name.localeCompare(b.name));
189
+
190
+ return {
191
+ version: '1.0.0',
192
+ source: 'prisma',
193
+ databaseType,
194
+ schemaName: 'public',
195
+ extractedAt: new Date().toISOString(),
196
+ tables,
197
+ enums,
198
+ };
199
+ }
200
+
201
+ // ---- Field mappers ----
202
+
203
+ function mapFieldToColumn(field: any, model: any): ColumnSpec {
204
+ const isPrimary = field.isId || false;
205
+ const isAutoIncrement = hasDefaultAutoincrement(field);
206
+ const goType = field.kind === 'enum'
207
+ ? 'string'
208
+ : (PRISMA_TO_GO_TYPE[field.type] || 'string');
209
+ const dbType = field.kind === 'enum'
210
+ ? `VARCHAR(255)`
211
+ : (PRISMA_TO_DB_TYPE[field.type] || 'VARCHAR(255)');
212
+
213
+ // Apply @db.* native type overrides if present
214
+ const nativeType = field.nativeType;
215
+ let resolvedDbType = dbType;
216
+ if (nativeType) {
217
+ resolvedDbType = formatNativeType(nativeType);
218
+ }
219
+
220
+ const col: ColumnSpec = {
221
+ name: field.name,
222
+ goType,
223
+ dbType: resolvedDbType,
224
+ isPrimary,
225
+ isAutoIncrement,
226
+ isNullable: !field.isRequired,
227
+ isUnique: field.isUnique || false,
228
+ };
229
+
230
+ // Default value
231
+ const defaultVal = extractDefaultValue(field);
232
+ if (defaultVal) {
233
+ col.defaultValue = defaultVal;
234
+ }
235
+
236
+ // Build GORM tag
237
+ const gormTag = buildGormTag(col, field);
238
+ if (gormTag) {
239
+ col.gormTag = gormTag;
240
+ }
241
+
242
+ return col;
243
+ }
244
+
245
+ function mapFieldToRelation(field: any, model: any): RelationSpec {
246
+ const isList = field.isList;
247
+ const relationType = mapRelationType(field.relationName || '', isList);
248
+
249
+ const rel: RelationSpec = {
250
+ name: field.name,
251
+ type: relationType,
252
+ fromTable: model.name,
253
+ toTable: field.type,
254
+ };
255
+
256
+ // Determine FK columns from the relation's fromFields
257
+ if (field.relationFromFields && field.relationFromFields.length > 0) {
258
+ rel.fromColumns = field.relationFromFields;
259
+ }
260
+
261
+ return rel;
262
+ }
263
+
264
+ // ---- Helpers ----
265
+
266
+ function hasDefaultAutoincrement(field: any): boolean {
267
+ if (!field.default) return false;
268
+ if (typeof field.default === 'object' && field.default.name === 'autoincrement') {
269
+ return true;
270
+ }
271
+ return false;
272
+ }
273
+
274
+ function extractDefaultValue(field: any): string | undefined {
275
+ if (!field.default) return undefined;
276
+
277
+ if (typeof field.default === 'object') {
278
+ // Prisma function defaults: autoincrement(), now(), uuid(), cuid(), etc.
279
+ const name = field.default.name;
280
+ if (name === 'autoincrement') return undefined; // Handled by isAutoIncrement
281
+ if (name === 'now') return 'CURRENT_TIMESTAMP';
282
+ if (name === 'uuid') return 'gen_random_uuid()';
283
+ if (name === 'cuid') return 'gen_random_uuid()'; // Approximate
284
+ if (name === 'dbgenerated') {
285
+ const args = field.default.args;
286
+ return args && args.length > 0 ? String(args[0]) : undefined;
287
+ }
288
+ return name + '()';
289
+ }
290
+
291
+ // Scalar defaults
292
+ return String(field.default);
293
+ }
294
+
295
+ function formatNativeType(nativeType: any): string {
296
+ if (!nativeType) return 'VARCHAR(255)';
297
+ // nativeType is [typeName, args[]]
298
+ const [typeName, args] = nativeType;
299
+ if (args && args.length > 0) {
300
+ return `${typeName.toUpperCase()}(${args.join(', ')})`;
301
+ }
302
+ return typeName.toUpperCase();
303
+ }
304
+
305
+ function buildGormTag(col: ColumnSpec, field: any): string {
306
+ const parts: string[] = [];
307
+
308
+ parts.push(`column:${col.name}`);
309
+ parts.push(`type:${col.dbType}`);
310
+
311
+ if (col.isPrimary) {
312
+ parts.push('primaryKey');
313
+ }
314
+ if (col.isAutoIncrement) {
315
+ parts.push('autoIncrement');
316
+ }
317
+ if (!col.isNullable) {
318
+ parts.push('not null');
319
+ }
320
+ if (col.isUnique) {
321
+ parts.push('uniqueIndex');
322
+ }
323
+ if (col.defaultValue) {
324
+ parts.push(`default:${col.defaultValue}`);
325
+ }
326
+
327
+ return parts.join(';');
328
+ }
329
+
330
+ function findPrismaSchema(projectPath: string): string | null {
331
+ // Standard Prisma schema locations
332
+ const candidates = [
333
+ path.join(projectPath, 'prisma', 'schema.prisma'),
334
+ path.join(projectPath, 'schema.prisma'),
335
+ path.join(projectPath, 'prisma', 'schema'),
336
+ ];
337
+
338
+ for (const candidate of candidates) {
339
+ if (fs.existsSync(candidate)) {
340
+ return candidate;
341
+ }
342
+ }
343
+
344
+ return null;
345
+ }