@cordy/electro-generator 1.0.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.
@@ -0,0 +1,76 @@
1
+ import { WindowDefinition } from "@cordy/electro";
2
+
3
+ //#region src/types.d.ts
4
+ /**
5
+ * Codegen scan result types.
6
+ *
7
+ * These are the shapes the AST scanner produces and the generator consumes.
8
+ * First iteration uses method names only — full type extraction is future work.
9
+ */
10
+ interface ScannedService {
11
+ id: string;
12
+ scope: string;
13
+ methods: string[];
14
+ filePath: string;
15
+ varName: string;
16
+ exported: boolean;
17
+ }
18
+ interface ScannedTask {
19
+ id: string;
20
+ varName: string;
21
+ filePath: string;
22
+ exported: boolean;
23
+ }
24
+ interface ScannedEvent {
25
+ id: string;
26
+ varName: string;
27
+ filePath: string;
28
+ exported: boolean;
29
+ }
30
+ interface ScannedFeature {
31
+ id: string;
32
+ filePath: string;
33
+ dependencies: string[];
34
+ services: ScannedService[];
35
+ tasks: ScannedTask[];
36
+ events: ScannedEvent[];
37
+ publishedEvents: string[];
38
+ }
39
+ interface ScanResult {
40
+ features: ScannedFeature[];
41
+ }
42
+ interface GeneratedFile {
43
+ path: string;
44
+ content: string;
45
+ }
46
+ //#endregion
47
+ //#region src/generator.d.ts
48
+ interface GeneratorInput {
49
+ scanResult: ScanResult;
50
+ windows: readonly WindowDefinition[];
51
+ /** Root output directory (e.g. `.electro/`). Generated files go into `generated/` subdirectory. */
52
+ outputDir: string;
53
+ /** Source directory where `electro-env.d.ts` will be written (e.g. `src/`). */
54
+ srcDir: string;
55
+ }
56
+ interface GeneratorOutput {
57
+ /** Files to write into `outputDir` (generated preload scripts, bridge types). */
58
+ files: GeneratedFile[];
59
+ /** Feature registry types file to write into `srcDir`. */
60
+ envTypes: GeneratedFile;
61
+ }
62
+ /**
63
+ * Generate all output files from scan results and window definitions.
64
+ */
65
+ declare function generate(input: GeneratorInput): GeneratorOutput;
66
+ //#endregion
67
+ //#region src/scanner.d.ts
68
+ /**
69
+ * Scan TypeScript source files and extract feature/service metadata.
70
+ *
71
+ * @param basePath - Root directory to scan (e.g., `./src`)
72
+ * @returns Aggregated scan result with all discovered features and services.
73
+ */
74
+ declare function scan(basePath: string): Promise<ScanResult>;
75
+ //#endregion
76
+ export { type GeneratedFile, type GeneratorInput, type GeneratorOutput, type ScanResult, type ScannedFeature, type ScannedService, type ScannedTask, generate, scan };
package/dist/index.mjs ADDED
@@ -0,0 +1,605 @@
1
+ import { dirname, join, relative } from "node:path";
2
+ import { PolicyEngine } from "@cordy/electro";
3
+ import { readFileSync } from "node:fs";
4
+ import { Visitor, parseSync } from "oxc-parser";
5
+
6
+ //#region src/generator.ts
7
+ /**
8
+ * Code Generator — produces preload scripts, bridge types, and context types.
9
+ *
10
+ * Takes a ScanResult + window definitions and emits generated files.
11
+ * Uses PolicyEngine for deny-by-default per-window filtering.
12
+ */
13
+ const HEADER = "// Auto-generated by Electro codegen. Do not edit.\n";
14
+ /** Quote a property key if it's not a valid JS identifier. */
15
+ function q(name) {
16
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `"${name}"`;
17
+ }
18
+ /**
19
+ * Build the IPC stub expression for a single method.
20
+ * Format: `(...args: unknown[]) => ipcRenderer.invoke("feature:service:method", ...args)`
21
+ */
22
+ function methodStub(featureId, serviceId, method) {
23
+ return `(...args: unknown[]) => ipcRenderer.invoke("${`${featureId}:${serviceId}:${method}`}", ...args)`;
24
+ }
25
+ /**
26
+ * Generate a preload script for a specific window.
27
+ *
28
+ * Structure: `window.electro.{featureId}.{serviceId}.{method}()`
29
+ * Only EXPOSED scope services are included.
30
+ */
31
+ function generatePreload(windowName, features, policy, preloadExtension) {
32
+ const allowedFeatures = features.filter((f) => policy.canAccess(windowName, f.id));
33
+ const featureEntries = [];
34
+ for (const feature of allowedFeatures) {
35
+ const exposedServices = feature.services.filter((s) => s.scope === "exposed");
36
+ if (exposedServices.length === 0) {
37
+ featureEntries.push(` ${q(feature.id)}: {},`);
38
+ continue;
39
+ }
40
+ const serviceEntries = [];
41
+ for (const service of exposedServices) {
42
+ if (service.methods.length === 0) {
43
+ serviceEntries.push(` ${q(service.id)}: {},`);
44
+ continue;
45
+ }
46
+ const methodEntries = service.methods.map((m) => ` ${q(m)}: ${methodStub(feature.id, service.id, m)},`).join("\n");
47
+ serviceEntries.push(` ${q(service.id)}: {\n${methodEntries}\n },`);
48
+ }
49
+ featureEntries.push(` ${q(feature.id)}: {\n${serviceEntries.join("\n")}\n },`);
50
+ }
51
+ let content = `${HEADER}
52
+ import { contextBridge, ipcRenderer } from "electron";
53
+
54
+ contextBridge.exposeInMainWorld("electro", ${featureEntries.length > 0 ? `{\n${featureEntries.join("\n")}\n}` : "{}"});
55
+ `;
56
+ if (preloadExtension) content += `\n// User extension\nimport "${preloadExtension}";\n`;
57
+ return {
58
+ path: `generated/preload/${windowName}.gen.ts`,
59
+ content
60
+ };
61
+ }
62
+ /**
63
+ * Generate bridge type declarations for a specific window.
64
+ * Makes `window.electro` type-safe in the renderer.
65
+ */
66
+ function generateBridgeTypes(windowName, features, policy) {
67
+ const allowedFeatures = features.filter((f) => policy.canAccess(windowName, f.id));
68
+ const featureTypes = [];
69
+ for (const feature of allowedFeatures) {
70
+ const exposedServices = feature.services.filter((s) => s.scope === "exposed");
71
+ if (exposedServices.length === 0) {
72
+ featureTypes.push(` ${q(feature.id)}: Record<string, never>;`);
73
+ continue;
74
+ }
75
+ const serviceTypes = [];
76
+ for (const service of exposedServices) {
77
+ if (service.methods.length === 0) {
78
+ serviceTypes.push(` ${q(service.id)}: Record<string, never>;`);
79
+ continue;
80
+ }
81
+ const methodTypes = service.methods.map((m) => ` ${q(m)}(...args: unknown[]): Promise<unknown>;`).join("\n");
82
+ serviceTypes.push(` ${q(service.id)}: {\n${methodTypes}\n };`);
83
+ }
84
+ featureTypes.push(` ${q(feature.id)}: {\n${serviceTypes.join("\n")}\n };`);
85
+ }
86
+ const content = `${HEADER}
87
+ export interface ElectroBridge ${featureTypes.length > 0 ? `{\n${featureTypes.join("\n")}\n }` : "Record<string, never>"}
88
+
89
+ declare global {
90
+ interface Window {
91
+ electro: ElectroBridge;
92
+ }
93
+ }
94
+ `;
95
+ return {
96
+ path: `generated/windows/${windowName}.bridge.d.ts`,
97
+ content
98
+ };
99
+ }
100
+ /** Calculate the import path from the generated env types file to a source file. */
101
+ function toImportPath(envTypesDir, sourceFilePath) {
102
+ const importPath = relative(dirname(join(envTypesDir, "electro-env.d.ts")), sourceFilePath).replace(/\.ts$/, "");
103
+ return importPath.startsWith(".") ? importPath : `./${importPath}`;
104
+ }
105
+ const FEATURE_TYPES_HEADER = `// Auto-generated by Electro codegen. Do not edit.
106
+ // Feature context types — provides IDE completions for getService/getTask/getFeature.
107
+
108
+ export {};
109
+
110
+ /* ── Utility types ────────────────────────────────────── */
111
+ type _SvcApi<T> = T extends { api(): infer R } ? NonNullable<R> : never;
112
+ type _TaskPayload<T> = T extends { start(payload?: infer P): any } ? P : void;
113
+ type _EventPayload<T> = T extends { payload(): infer P } ? P : unknown;
114
+ `;
115
+ /**
116
+ * Generate feature type declarations for the main process.
117
+ * Emits a single FeatureMap interface with per-feature entries for
118
+ * services, tasks, and dependencies. Uses typeof import() for full type inference.
119
+ */
120
+ function generateWindowTypes(windows) {
121
+ if (windows.length === 0) return "";
122
+ const entries = [];
123
+ for (const win of windows) {
124
+ const electronType = (win.type ?? "base-window") === "browser-window" ? "import(\"electron\").BrowserWindow" : "import(\"electron\").BaseWindow";
125
+ entries.push(` "${win.name}": ${electronType};`);
126
+ }
127
+ return `\n interface WindowMap {\n${entries.join("\n")}\n }\n`;
128
+ }
129
+ function generateFeatureTypes(features, srcDir, windows) {
130
+ const seenFeatures = /* @__PURE__ */ new Set();
131
+ const featureBlocks = [];
132
+ const svcOwnerEntries = [];
133
+ const taskOwnerEntries = [];
134
+ for (const feature of features) {
135
+ if (seenFeatures.has(feature.id)) {
136
+ console.warn(`[generator] Duplicate feature id "${feature.id}" — first wins`);
137
+ continue;
138
+ }
139
+ seenFeatures.add(feature.id);
140
+ const seenServices = /* @__PURE__ */ new Set();
141
+ const serviceEntries = [];
142
+ for (const svc of feature.services) {
143
+ if (seenServices.has(svc.id)) {
144
+ console.warn(`[generator] Duplicate service id "${svc.id}" in feature "${feature.id}" — first wins`);
145
+ continue;
146
+ }
147
+ seenServices.add(svc.id);
148
+ svcOwnerEntries.push(` "${svc.id}": "${feature.id}";`);
149
+ if (svc.exported) {
150
+ const importPath = toImportPath(srcDir, svc.filePath);
151
+ serviceEntries.push(` "${svc.id}": _SvcApi<typeof import("${importPath}").${svc.varName}>;`);
152
+ } else serviceEntries.push(` "${svc.id}": unknown;`);
153
+ }
154
+ const seenTasks = /* @__PURE__ */ new Set();
155
+ const taskEntries = [];
156
+ for (const task of feature.tasks) {
157
+ if (seenTasks.has(task.id)) {
158
+ console.warn(`[generator] Duplicate task id "${task.id}" in feature "${feature.id}" — first wins`);
159
+ continue;
160
+ }
161
+ seenTasks.add(task.id);
162
+ taskOwnerEntries.push(` "${task.id}": "${feature.id}";`);
163
+ if (task.exported) {
164
+ const importPath = toImportPath(srcDir, task.filePath);
165
+ taskEntries.push(` "${task.id}": _TaskPayload<typeof import("${importPath}").${task.varName}>;`);
166
+ } else taskEntries.push(` "${task.id}": void;`);
167
+ }
168
+ const seenEvents = /* @__PURE__ */ new Set();
169
+ const eventEntries = [];
170
+ for (const evt of feature.events ?? []) {
171
+ if (seenEvents.has(evt.id)) {
172
+ console.warn(`[generator] Duplicate event id "${evt.id}" in feature "${feature.id}" — first wins`);
173
+ continue;
174
+ }
175
+ seenEvents.add(evt.id);
176
+ if (evt.exported) {
177
+ const importPath = toImportPath(srcDir, evt.filePath);
178
+ eventEntries.push(` "${evt.id}": _EventPayload<typeof import("${importPath}").${evt.varName}>;`);
179
+ } else eventEntries.push(` "${evt.id}": unknown;`);
180
+ }
181
+ const deps = feature.dependencies ?? [];
182
+ const depsType = deps.length > 0 ? deps.map((d) => `"${d}"`).join(" | ") : "never";
183
+ const svcBody = serviceEntries.length > 0 ? `{\n${serviceEntries.join("\n")}\n }` : "{}";
184
+ const taskBody = taskEntries.length > 0 ? `{\n${taskEntries.join("\n")}\n }` : "{}";
185
+ const eventBody = eventEntries.length > 0 ? `{\n${eventEntries.join("\n")}\n }` : "{}";
186
+ featureBlocks.push(` "${feature.id}": {
187
+ services: ${svcBody};
188
+ tasks: ${taskBody};
189
+ events: ${eventBody};
190
+ dependencies: ${depsType};
191
+ };`);
192
+ }
193
+ return {
194
+ path: "electro-env.d.ts",
195
+ content: `${FEATURE_TYPES_HEADER}
196
+ declare module "@cordy/electro" {
197
+ interface FeatureMap ${featureBlocks.length > 0 ? `{\n${featureBlocks.join("\n")}\n }` : "{}"}
198
+
199
+ interface ServiceOwnerMap ${svcOwnerEntries.length > 0 ? `{\n${svcOwnerEntries.join("\n")}\n }` : "{}"}
200
+
201
+ interface TaskOwnerMap ${taskOwnerEntries.length > 0 ? `{\n${taskOwnerEntries.join("\n")}\n }` : "{}"}
202
+ ${generateWindowTypes(windows)}}
203
+ `
204
+ };
205
+ }
206
+ /**
207
+ * Generate all output files from scan results and window definitions.
208
+ */
209
+ function generate(input) {
210
+ const { scanResult, windows, outputDir, srcDir } = input;
211
+ const policy = new PolicyEngine(windows);
212
+ const files = [];
213
+ for (const win of windows) {
214
+ const knownIds = new Set(scanResult.features.map((f) => f.id));
215
+ for (const fId of win.features ?? []) if (!knownIds.has(fId)) console.warn(`[generator] Window "${win.name}" references unknown feature "${fId}"`);
216
+ files.push(generatePreload(win.name, scanResult.features, policy, win.preload));
217
+ files.push(generateBridgeTypes(win.name, scanResult.features, policy));
218
+ }
219
+ return {
220
+ files,
221
+ envTypes: generateFeatureTypes(scanResult.features, srcDir, windows)
222
+ };
223
+ }
224
+
225
+ //#endregion
226
+ //#region src/scanner.ts
227
+ /**
228
+ * AST Scanner — OXC-based feature/service discovery.
229
+ *
230
+ * Parses TypeScript source files with OXC and extracts metadata from
231
+ * `createFeature()` and `createService()` calls. Only string literals
232
+ * are extracted; computed or dynamic values are skipped with a warning.
233
+ *
234
+ * Uses node:fs + tinyglobby (cross-runtime: works in Bun and vitest workers).
235
+ */
236
+ const EXCLUDE_PATTERNS = [
237
+ /\.d\.ts$/,
238
+ /\.test\.ts$/,
239
+ /\.spec\.ts$/,
240
+ /\.gen\.ts$/
241
+ ];
242
+ function shouldInclude(filePath) {
243
+ return filePath.endsWith(".ts") && !EXCLUDE_PATTERNS.some((p) => p.test(filePath));
244
+ }
245
+ /** Extract a string literal value from an AST node. Returns null for non-literals. */
246
+ function getStringLiteral(node) {
247
+ if (!node) return null;
248
+ if (node.type === "Literal" && typeof node.value === "string") return node.value;
249
+ return null;
250
+ }
251
+ /** Extract an array of string literals from an ArrayExpression node. */
252
+ function getStringArray(node) {
253
+ if (!node || node.type !== "ArrayExpression") return [];
254
+ const elements = node.elements;
255
+ const result = [];
256
+ for (const el of elements) {
257
+ const val = getStringLiteral(el);
258
+ if (val !== null) result.push(val);
259
+ }
260
+ return result;
261
+ }
262
+ /** Get a property value from an ObjectExpression by key name. */
263
+ function getObjectProperty(obj, key) {
264
+ const props = obj.properties;
265
+ for (const prop of props) {
266
+ if (prop.type !== "Property") continue;
267
+ const propKey = prop.key;
268
+ if (propKey.type === "Identifier" && propKey.name === key) return prop.value;
269
+ }
270
+ return null;
271
+ }
272
+ /** Extract property names from an ObjectExpression (method names from api() return). */
273
+ function getPropertyNames(obj) {
274
+ const props = obj.properties;
275
+ const names = [];
276
+ for (const prop of props) {
277
+ if (prop.type !== "Property") continue;
278
+ const key = prop.key;
279
+ if (key.type === "Identifier" && typeof key.name === "string") names.push(key.name);
280
+ }
281
+ return names;
282
+ }
283
+ /**
284
+ * Extract method names from the `api` property.
285
+ *
286
+ * Handles:
287
+ * - `api: () => ({ method1() {}, method2() {} })` — arrow with parens
288
+ * - `api: () => { return { method1() {}, method2() {} } }` — arrow with block
289
+ * - `api(): Type { return { ... } }` — method shorthand
290
+ */
291
+ function extractMethodsFromApi(apiNode) {
292
+ if (apiNode.type === "ArrowFunctionExpression") {
293
+ let body = apiNode.body;
294
+ if (body.type === "ParenthesizedExpression") body = body.expression;
295
+ if (body.type === "ObjectExpression") return getPropertyNames(body);
296
+ if (body.type === "BlockStatement") return extractMethodsFromBlock(body);
297
+ }
298
+ if (apiNode.type === "FunctionExpression") return extractMethodsFromBlock(apiNode.body);
299
+ return [];
300
+ }
301
+ /** Find the first return statement with an object literal in a block. */
302
+ function extractMethodsFromBlock(block) {
303
+ const stmts = block.body;
304
+ for (const stmt of stmts) if (stmt.type === "ReturnStatement") {
305
+ const arg = stmt.argument;
306
+ if (arg?.type === "ObjectExpression") return getPropertyNames(arg);
307
+ }
308
+ return [];
309
+ }
310
+ /**
311
+ * Resolve the scope value from an AST node.
312
+ *
313
+ * Handles:
314
+ * - `ServiceScope.EXPOSED` (MemberExpression)
315
+ * - `"exposed"` (string literal)
316
+ */
317
+ function resolveScope(node) {
318
+ const literal = getStringLiteral(node);
319
+ if (literal) return literal;
320
+ if (node.type === "MemberExpression" && node.computed === false) return {
321
+ EXPOSED: "exposed",
322
+ INTERNAL: "internal",
323
+ PRIVATE: "private"
324
+ }[node.property.name.toUpperCase()] ?? null;
325
+ return null;
326
+ }
327
+ /**
328
+ * Extract published event names from a file's AST.
329
+ * Looks for `ctx.events.publish("eventName", ...)` patterns.
330
+ */
331
+ function extractPublishedEvents(program) {
332
+ const events = [];
333
+ new Visitor({ CallExpression(node) {
334
+ const callee = node.callee;
335
+ if (callee.type === "MemberExpression" && callee.computed === false && callee.property.type === "Identifier" && callee.property.name === "publish") {
336
+ const obj = callee.object;
337
+ if (obj.type === "MemberExpression" && obj.computed === false && obj.property.type === "Identifier" && obj.property.name === "events") {
338
+ const args = node.arguments;
339
+ if (args.length > 0) {
340
+ const eventName = getStringLiteral(args[0]);
341
+ if (eventName) events.push(eventName);
342
+ }
343
+ }
344
+ }
345
+ } }).visit(program);
346
+ return events;
347
+ }
348
+ /**
349
+ * Walk the program body and collect variable names that appear in
350
+ * `export` declarations (ExportNamedDeclaration with VariableDeclaration).
351
+ */
352
+ function extractExportedNames(program) {
353
+ const names = /* @__PURE__ */ new Set();
354
+ const body = program.body;
355
+ if (!body) return names;
356
+ for (const node of body) {
357
+ if (node.type !== "ExportNamedDeclaration") continue;
358
+ const declaration = node.declaration;
359
+ if (declaration?.type === "VariableDeclaration") {
360
+ const declarators = declaration.declarations;
361
+ for (const decl of declarators) {
362
+ const id = decl.id;
363
+ if (id?.type === "Identifier" && typeof id.name === "string") names.add(id.name);
364
+ }
365
+ } else if (!declaration) {
366
+ const specifiers = node.specifiers;
367
+ if (specifiers) for (const spec of specifiers) {
368
+ if (spec.type !== "ExportSpecifier") continue;
369
+ const local = spec.local;
370
+ if (local?.type === "Identifier" && typeof local.name === "string") names.add(local.name);
371
+ }
372
+ }
373
+ }
374
+ return names;
375
+ }
376
+ /**
377
+ * Extract createService() calls from a file.
378
+ * Uses VariableDeclarator to capture `const x = createService({...})` patterns.
379
+ * Returns services keyed by their variable name for later resolution.
380
+ */
381
+ function extractServices(program, filePath, exportedNames) {
382
+ const services = [];
383
+ new Visitor({ VariableDeclarator(node) {
384
+ const init = node.init;
385
+ if (!init || init.type !== "CallExpression") return;
386
+ const callee = init.callee;
387
+ if (callee.type !== "Identifier" || callee.name !== "createService") return;
388
+ const args = init.arguments;
389
+ if (args.length < 1 || args[0].type !== "ObjectExpression") return;
390
+ const config = args[0];
391
+ const id = getStringLiteral(getObjectProperty(config, "id"));
392
+ if (!id) {
393
+ console.warn(`[scanner] Skipping createService() with non-literal id in ${filePath}`);
394
+ return;
395
+ }
396
+ const scopeNode = getObjectProperty(config, "scope");
397
+ const scope = scopeNode ? resolveScope(scopeNode) : null;
398
+ if (!scope) {
399
+ console.warn(`[scanner] Skipping createService("${id}") with unresolvable scope in ${filePath}`);
400
+ return;
401
+ }
402
+ const apiNode = getObjectProperty(config, "api");
403
+ const methods = apiNode ? extractMethodsFromApi(apiNode) : [];
404
+ if (apiNode && methods.length === 0) console.warn(`[scanner] createService("${id}") api() didn't return an object literal in ${filePath}`);
405
+ const idNode = node.id;
406
+ const varName = idNode.type === "Identifier" && typeof idNode.name === "string" ? idNode.name : id;
407
+ services.push({
408
+ varName,
409
+ service: {
410
+ id,
411
+ scope,
412
+ methods,
413
+ filePath,
414
+ varName,
415
+ exported: exportedNames.has(varName)
416
+ }
417
+ });
418
+ } }).visit(program);
419
+ return services;
420
+ }
421
+ /**
422
+ * Extract createTask() calls from a file.
423
+ * Uses VariableDeclarator to capture `const x = createTask({...})` patterns.
424
+ * Returns tasks keyed by their variable name for later resolution.
425
+ */
426
+ function extractTasks(program, filePath, exportedNames) {
427
+ const tasks = [];
428
+ new Visitor({ VariableDeclarator(node) {
429
+ const init = node.init;
430
+ if (!init || init.type !== "CallExpression") return;
431
+ const callee = init.callee;
432
+ if (callee.type !== "Identifier" || callee.name !== "createTask") return;
433
+ const args = init.arguments;
434
+ if (args.length < 1 || args[0].type !== "ObjectExpression") return;
435
+ const config = args[0];
436
+ const id = getStringLiteral(getObjectProperty(config, "id"));
437
+ if (!id) {
438
+ console.warn(`[scanner] Skipping createTask() with non-literal id in ${filePath}`);
439
+ return;
440
+ }
441
+ const idNode = node.id;
442
+ const varName = idNode.type === "Identifier" && typeof idNode.name === "string" ? idNode.name : id;
443
+ tasks.push({
444
+ varName,
445
+ task: {
446
+ id,
447
+ varName,
448
+ filePath,
449
+ exported: exportedNames.has(varName)
450
+ }
451
+ });
452
+ } }).visit(program);
453
+ return tasks;
454
+ }
455
+ /**
456
+ * Extract createEvent() calls from a file.
457
+ * Uses VariableDeclarator to capture `const x = createEvent(...)` patterns.
458
+ */
459
+ function extractEvents(program, filePath, exportedNames) {
460
+ const events = [];
461
+ new Visitor({ VariableDeclarator(node) {
462
+ const init = node.init;
463
+ if (!init || init.type !== "CallExpression") return;
464
+ const callee = init.callee;
465
+ if (callee.type !== "Identifier" || callee.name !== "createEvent") return;
466
+ const args = init.arguments;
467
+ if (args.length < 1) return;
468
+ const id = getStringLiteral(args[0]);
469
+ if (!id) {
470
+ console.warn(`[scanner] Skipping createEvent() with non-literal id in ${filePath}`);
471
+ return;
472
+ }
473
+ const idNode = node.id;
474
+ const varName = idNode.type === "Identifier" && typeof idNode.name === "string" ? idNode.name : id;
475
+ events.push({
476
+ varName,
477
+ event: {
478
+ id,
479
+ varName,
480
+ filePath,
481
+ exported: exportedNames.has(varName)
482
+ }
483
+ });
484
+ } }).visit(program);
485
+ return events;
486
+ }
487
+ /** Extract createFeature() calls from a file. */
488
+ function extractFeatures(program, filePath) {
489
+ const features = [];
490
+ const events = extractPublishedEvents(program);
491
+ new Visitor({ CallExpression(node) {
492
+ const callee = node.callee;
493
+ if (callee.type !== "Identifier" || callee.name !== "createFeature") return;
494
+ const args = node.arguments;
495
+ if (args.length < 1 || args[0].type !== "ObjectExpression") return;
496
+ const config = args[0];
497
+ const id = getStringLiteral(getObjectProperty(config, "id"));
498
+ if (!id) {
499
+ console.warn(`[scanner] Skipping createFeature() with non-literal id in ${filePath}`);
500
+ return;
501
+ }
502
+ const dependencies = getStringArray(getObjectProperty(config, "dependencies"));
503
+ const servicesNode = getObjectProperty(config, "services");
504
+ const serviceVarNames = [];
505
+ if (servicesNode?.type === "ArrayExpression") {
506
+ const elements = servicesNode.elements;
507
+ for (const el of elements) if (el?.type === "Identifier" && typeof el.name === "string") serviceVarNames.push(el.name);
508
+ }
509
+ const tasksNode = getObjectProperty(config, "tasks");
510
+ const taskVarNames = [];
511
+ if (tasksNode?.type === "ArrayExpression") {
512
+ const taskElements = tasksNode.elements;
513
+ for (const el of taskElements) if (el?.type === "Identifier" && typeof el.name === "string") taskVarNames.push(el.name);
514
+ }
515
+ const eventsNode = getObjectProperty(config, "events");
516
+ const eventVarNames = [];
517
+ if (eventsNode?.type === "ArrayExpression") {
518
+ const eventElements = eventsNode.elements;
519
+ for (const el of eventElements) if (el?.type === "Identifier" && typeof el.name === "string") eventVarNames.push(el.name);
520
+ }
521
+ features.push({
522
+ id,
523
+ dependencies,
524
+ serviceVarNames,
525
+ taskVarNames,
526
+ eventVarNames,
527
+ publishedEvents: events,
528
+ filePath
529
+ });
530
+ } }).visit(program);
531
+ return features;
532
+ }
533
+ async function discoverFiles(basePath) {
534
+ const { globSync } = await import("tinyglobby");
535
+ return globSync(["**/*.ts"], {
536
+ cwd: basePath,
537
+ absolute: true,
538
+ ignore: ["node_modules/**"]
539
+ }).filter(shouldInclude);
540
+ }
541
+ /**
542
+ * Scan TypeScript source files and extract feature/service metadata.
543
+ *
544
+ * @param basePath - Root directory to scan (e.g., `./src`)
545
+ * @returns Aggregated scan result with all discovered features and services.
546
+ */
547
+ async function scan(basePath) {
548
+ const files = await discoverFiles(basePath);
549
+ const allServices = [];
550
+ const allTasks = [];
551
+ const allEvents = [];
552
+ const allFeatures = [];
553
+ for (const filePath of files) {
554
+ const result = parseSync(filePath, readFileSync(filePath, "utf-8"), { sourceType: "module" });
555
+ if (result.errors.length > 0) for (const err of result.errors) console.warn(`[scanner] Parse error in ${filePath}: ${err.message}`);
556
+ const program = result.program;
557
+ const exportedNames = extractExportedNames(program);
558
+ const services = extractServices(program, filePath, exportedNames);
559
+ const tasks = extractTasks(program, filePath, exportedNames);
560
+ const events = extractEvents(program, filePath, exportedNames);
561
+ const features = extractFeatures(program, filePath);
562
+ allServices.push(...services);
563
+ allTasks.push(...tasks);
564
+ allEvents.push(...events);
565
+ allFeatures.push(...features);
566
+ }
567
+ const serviceByVarName = /* @__PURE__ */ new Map();
568
+ for (const { varName, service } of allServices) serviceByVarName.set(varName, service);
569
+ const taskByVarName = /* @__PURE__ */ new Map();
570
+ for (const { varName, task } of allTasks) taskByVarName.set(varName, task);
571
+ const eventByVarName = /* @__PURE__ */ new Map();
572
+ for (const { varName, event } of allEvents) eventByVarName.set(varName, event);
573
+ return { features: allFeatures.map((raw) => {
574
+ const services = [];
575
+ for (const varName of raw.serviceVarNames) {
576
+ const svc = serviceByVarName.get(varName);
577
+ if (svc) services.push(svc);
578
+ else console.warn(`[scanner] Feature "${raw.id}" references unknown service variable "${varName}" in ${raw.filePath}`);
579
+ }
580
+ const tasks = [];
581
+ for (const varName of raw.taskVarNames) {
582
+ const task = taskByVarName.get(varName);
583
+ if (task) tasks.push(task);
584
+ else console.warn(`[scanner] Feature "${raw.id}" references unknown task variable "${varName}" in ${raw.filePath}`);
585
+ }
586
+ const resolvedEvents = [];
587
+ for (const varName of raw.eventVarNames) {
588
+ const evt = eventByVarName.get(varName);
589
+ if (evt) resolvedEvents.push(evt);
590
+ else console.warn(`[scanner] Feature "${raw.id}" references unknown event variable "${varName}" in ${raw.filePath}`);
591
+ }
592
+ return {
593
+ id: raw.id,
594
+ filePath: raw.filePath,
595
+ dependencies: raw.dependencies,
596
+ services,
597
+ tasks,
598
+ events: resolvedEvents,
599
+ publishedEvents: raw.publishedEvents
600
+ };
601
+ }) };
602
+ }
603
+
604
+ //#endregion
605
+ export { generate, scan };
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@cordy/electro-generator",
3
+ "version": "1.0.0",
4
+ "description": "Code generator for @cordy/electro — scans features and emits preload scripts and bridge types",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "keywords": [
9
+ "electron",
10
+ "codegen",
11
+ "preload",
12
+ "bridge",
13
+ "typescript"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/cordy-dev/electro.git",
18
+ "directory": "packages/electro-generator"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/cordy-dev/electro/issues"
22
+ },
23
+ "homepage": "https://github.com/cordy-dev/electro#readme",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "engines": {
28
+ "bun": ">=1.0.0"
29
+ },
30
+ "main": "./dist/index.mjs",
31
+ "types": "./dist/index.d.mts",
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "exports": {
36
+ ".": {
37
+ "types": "./dist/index.d.mts",
38
+ "import": "./dist/index.mjs"
39
+ }
40
+ },
41
+ "scripts": {
42
+ "build": "tsdown",
43
+ "test": "vitest",
44
+ "prepublishOnly": "bun run build"
45
+ },
46
+ "peerDependencies": {
47
+ "@cordy/electro": "1.0.0"
48
+ },
49
+ "dependencies": {
50
+ "oxc-parser": "^0.114.0",
51
+ "tinyglobby": "^0.2.15"
52
+ },
53
+ "devDependencies": {
54
+ "@cordy/electro": "1.0.0",
55
+ "tsdown": "^0.20.3",
56
+ "vitest": "v4.1.0-beta.3"
57
+ }
58
+ }