@supacloud/compiler 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { resolve } from "node:path";
4
+ import { resolve as resolve3 } from "node:path";
5
5
 
6
6
  // src/analyze.ts
7
7
  import { existsSync } from "node:fs";
8
+ import { createHash } from "node:crypto";
8
9
  import { join, relative, sep } from "node:path";
9
10
  import {
10
11
  Node,
@@ -22,10 +23,20 @@ var ROUTE_DECORATORS = {
22
23
  Options: "OPTIONS"
23
24
  };
24
25
  var SCOPES = ["application", "request", "job"];
25
- async function analyzeProject(rootDir, include) {
26
- const project = createProject(rootDir);
27
- const patterns = (include ?? DEFAULT_INCLUDE).map((glob) => join(rootDir, glob));
28
- project.addSourceFilesAtPaths(patterns);
26
+ async function analyzeProject(rootDir, include, cache) {
27
+ let project;
28
+ if (cache?.project) {
29
+ project = cache.project;
30
+ for (const sf of project.getSourceFiles()) {
31
+ sf.refreshFromFileSystemSync();
32
+ }
33
+ } else {
34
+ project = createProject(rootDir);
35
+ const patterns = (include ?? DEFAULT_INCLUDE).map((glob) => join(rootDir, glob));
36
+ project.addSourceFilesAtPaths(patterns);
37
+ if (cache)
38
+ cache.project = project;
39
+ }
29
40
  const sourceFiles = project.getSourceFiles().filter((sf) => !sf.getFilePath().includes("node_modules") && !sf.isDeclarationFile()).sort((a, b) => a.getFilePath().localeCompare(b.getFilePath()));
30
41
  const ctx = {
31
42
  rootDir,
@@ -75,7 +86,207 @@ async function analyzeProject(rootDir, include) {
75
86
  for (const c of candidates) {
76
87
  nameByNode.set(c.node, stringLiteralProp(c.options, "name") ?? c.className);
77
88
  }
78
- const modules = candidates.map((c) => parseModule(c, nameByNode, ctx));
89
+ let modules = [];
90
+ let reusedModules = [];
91
+ let reanalyzedModules = [];
92
+ if (cache) {
93
+ const currentFileHashes = new Map;
94
+ for (const sf of sourceFiles) {
95
+ const rel = sourcePath(rootDir, sf.getFilePath());
96
+ const hash = createHash("sha256").update(sf.getFullText()).digest("hex");
97
+ currentFileHashes.set(rel, hash);
98
+ }
99
+ const changedFiles = new Set;
100
+ for (const [file, hash] of currentFileHashes.entries()) {
101
+ if (cache.fileHashes.get(file) !== hash) {
102
+ changedFiles.add(file);
103
+ }
104
+ }
105
+ for (const file of cache.fileHashes.keys()) {
106
+ if (!currentFileHashes.has(file)) {
107
+ changedFiles.add(file);
108
+ }
109
+ }
110
+ const modulesToKeep = new Map;
111
+ const finalModules = [];
112
+ const finalDiagnostics = [];
113
+ const affectedModuleNames = cache.dependencyGraph && typeof cache.dependencyGraph.getAffectedModules === "function" ? new Set(cache.dependencyGraph.getAffectedModules(Array.from(changedFiles))) : new Set;
114
+ for (const [modName, entry] of cache.modules.entries()) {
115
+ const hasChangedFile = entry.ownedFiles.some((f) => changedFiles.has(f));
116
+ const moduleFileExists = currentFileHashes.has(entry.module.file);
117
+ const isAffectedByDep = affectedModuleNames.has(modName);
118
+ if (!hasChangedFile && !isAffectedByDep && moduleFileExists) {
119
+ modulesToKeep.set(modName, entry);
120
+ reusedModules.push(modName);
121
+ finalModules.push(entry.module);
122
+ if (entry.diagnostics)
123
+ finalDiagnostics.push(...entry.diagnostics);
124
+ }
125
+ }
126
+ for (const c of candidates) {
127
+ const modName = nameByNode.get(c.node) ?? c.className;
128
+ if (modulesToKeep.has(modName)) {
129
+ continue;
130
+ }
131
+ const diagBefore = ctx.diagnostics.length;
132
+ const parsed = parseModule(c, nameByNode, ctx);
133
+ const moduleDiagnostics = ctx.diagnostics.slice(diagBefore);
134
+ const ownedFiles = new Set;
135
+ ownedFiles.add(parsed.file);
136
+ for (const p of parsed.providers)
137
+ if (p.file)
138
+ ownedFiles.add(p.file);
139
+ for (const ctrl of parsed.controllers)
140
+ if (ctrl.file)
141
+ ownedFiles.add(ctrl.file);
142
+ const fileHashes = {};
143
+ for (const f of ownedFiles) {
144
+ fileHashes[f] = currentFileHashes.get(f) ?? "";
145
+ }
146
+ cache.modules.set(parsed.name, {
147
+ module: parsed,
148
+ ownedFiles: [...ownedFiles],
149
+ fileHashes,
150
+ diagnostics: moduleDiagnostics
151
+ });
152
+ reanalyzedModules.push(parsed.name);
153
+ finalModules.push(parsed);
154
+ finalDiagnostics.push(...moduleDiagnostics);
155
+ }
156
+ for (const modName of [...cache.modules.keys()]) {
157
+ if (!modulesToKeep.has(modName) && !reanalyzedModules.includes(modName)) {
158
+ cache.modules.delete(modName);
159
+ }
160
+ }
161
+ cache.fileHashes = currentFileHashes;
162
+ cache.lastStats = { reusedModules, reanalyzedModules };
163
+ ctx.diagnostics = finalDiagnostics;
164
+ modules = finalModules;
165
+ } else {
166
+ modules = candidates.map((c) => parseModule(c, nameByNode, ctx));
167
+ }
168
+ const allRegisteredClasses = new Set;
169
+ const allRegisteredControllers = new Set;
170
+ const allRegisteredCommands = new Set;
171
+ for (const m of modules) {
172
+ for (const p of m.providers) {
173
+ if (p.useClass)
174
+ allRegisteredClasses.add(p.useClass);
175
+ if (p.kind === "class")
176
+ allRegisteredClasses.add(p.token);
177
+ }
178
+ for (const c of m.controllers) {
179
+ allRegisteredControllers.add(c.className);
180
+ }
181
+ for (const cmd of m.commands) {
182
+ allRegisteredCommands.add(cmd.className);
183
+ }
184
+ }
185
+ const rootProviders = [];
186
+ const standaloneControllers = [];
187
+ const standaloneCommands = [];
188
+ for (const [name, classInfo] of ctx.classesByName.entries()) {
189
+ if (!allRegisteredClasses.has(name)) {
190
+ const injectable = parseInjectableOptions(classInfo.decl, ctx);
191
+ if (injectable?.providedIn === "root") {
192
+ const { deps, optionalDeps, selfDeps, skipSelfDeps, hostDeps, missing } = classDeps(classInfo.decl, ctx);
193
+ const file = sourcePath(ctx.rootDir, classInfo.file);
194
+ const line = classInfo.decl.getStartLineNumber();
195
+ if (missing) {
196
+ warn(ctx, "missing-deps", `root provider ${name} 的部分构造依赖无法静态解析`, file, line);
197
+ }
198
+ rootProviders.push({
199
+ token: name,
200
+ tokenKind: "class",
201
+ kind: "class",
202
+ useClass: name,
203
+ scope: injectable.scope ?? "application",
204
+ deps,
205
+ optionalDeps: optionalDeps.length > 0 ? optionalDeps : undefined,
206
+ selfDeps: selfDeps.length > 0 ? selfDeps : undefined,
207
+ skipSelfDeps: skipSelfDeps.length > 0 ? skipSelfDeps : undefined,
208
+ hostDeps: hostDeps.length > 0 ? hostDeps : undefined,
209
+ providedIn: "root",
210
+ hasOnDestroy: classInfo.decl.getMethod("onDestroy") !== undefined || undefined,
211
+ exported: true,
212
+ file,
213
+ line,
214
+ importPath: modulePath(ctx.rootDir, classInfo.file)
215
+ });
216
+ }
217
+ }
218
+ if (!allRegisteredControllers.has(name)) {
219
+ const controllerDec = findDecorator(classInfo.decl, "Controller");
220
+ if (controllerDec) {
221
+ const arg = controllerDec.getArguments()[0];
222
+ const isStandalone = arg && Node.isObjectLiteralExpression(arg) && booleanProp(arg, "standalone");
223
+ if (isStandalone) {
224
+ const ctrl = parseController(classInfo.decl, ctx);
225
+ if (ctrl)
226
+ standaloneControllers.push(ctrl);
227
+ }
228
+ }
229
+ }
230
+ if (!allRegisteredCommands.has(name)) {
231
+ const commandDec = findDecorator(classInfo.decl, "Command");
232
+ if (commandDec) {
233
+ const meta = decoratorObjectArg(commandDec);
234
+ if (meta && booleanProp(meta, "standalone")) {
235
+ standaloneCommands.push({
236
+ className: classInfo.decl.getName() ?? name,
237
+ name: stringLiteralProp(meta, "name") ?? classInfo.decl.getName() ?? name,
238
+ permission: stringLiteralProp(meta, "permission"),
239
+ transaction: commandModeProp(meta, "transaction") ?? "none",
240
+ audit: stringLiteralProp(meta, "audit"),
241
+ idempotency: commandModeProp(meta, "idempotency") ?? "none",
242
+ standalone: true
243
+ });
244
+ }
245
+ }
246
+ }
247
+ }
248
+ for (const [name, tokenInfo] of ctx.tokensByName.entries()) {
249
+ if (tokenInfo.providedIn === "root" && !allRegisteredClasses.has(name)) {
250
+ rootProviders.push({
251
+ token: name,
252
+ tokenKind: "injection-token",
253
+ kind: "factory",
254
+ scope: tokenInfo.scope ?? "application",
255
+ deps: [],
256
+ providedIn: "root",
257
+ exported: true,
258
+ file: sourcePath(ctx.rootDir, tokenInfo.file),
259
+ line: tokenInfo.line ?? 1,
260
+ importPath: modulePath(ctx.rootDir, tokenInfo.file)
261
+ });
262
+ }
263
+ }
264
+ if (rootProviders.length > 0 || standaloneControllers.length > 0 || standaloneCommands.length > 0) {
265
+ const existingRoot = modules.find((m) => m.name === "root" || m.name === "app");
266
+ if (existingRoot) {
267
+ existingRoot.providers.push(...rootProviders);
268
+ existingRoot.controllers.push(...standaloneControllers);
269
+ existingRoot.commands.push(...standaloneCommands);
270
+ for (const p of rootProviders) {
271
+ if (!existingRoot.exports.includes(p.token))
272
+ existingRoot.exports.push(p.token);
273
+ }
274
+ } else {
275
+ const fallbackFile = rootProviders[0]?.file ?? standaloneControllers[0]?.file ?? "root.ts";
276
+ modules.unshift({
277
+ name: "root",
278
+ className: "RootModule",
279
+ file: fallbackFile,
280
+ line: 1,
281
+ imports: [],
282
+ providers: rootProviders,
283
+ controllers: standaloneControllers,
284
+ commands: standaloneCommands,
285
+ queries: [],
286
+ exports: rootProviders.map((p) => p.token)
287
+ });
288
+ }
289
+ }
79
290
  const providedTokens = new Set(modules.flatMap((m) => m.providers.map((p) => p.token)));
80
291
  const referenced = new Set;
81
292
  for (const m of modules) {
@@ -94,7 +305,8 @@ async function analyzeProject(rootDir, include) {
94
305
  modules,
95
306
  externalTokens,
96
307
  diagnostics: ctx.diagnostics,
97
- tokenNames
308
+ tokenNames,
309
+ cacheStats: cache ? { reusedModules, reanalyzedModules } : undefined
98
310
  };
99
311
  }
100
312
  function createProject(rootDir) {
@@ -129,7 +341,7 @@ function parseTokenVariable(decl, file) {
129
341
  if (init.getExpression().getText() !== "InjectionToken")
130
342
  return;
131
343
  const [nameArg, optionsArg] = init.getArguments();
132
- const info = { name: decl.getName(), file };
344
+ const info = { name: decl.getName(), file, line: decl.getStartLineNumber() };
133
345
  if (nameArg && Node.isStringLiteral(nameArg)) {
134
346
  info.stringName = nameArg.getLiteralText();
135
347
  }
@@ -138,6 +350,14 @@ function parseTokenVariable(decl, file) {
138
350
  if (scope && SCOPES.includes(scope)) {
139
351
  info.scope = scope;
140
352
  }
353
+ const providedIn = stringLiteralProp(optionsArg, "providedIn");
354
+ if (providedIn === "root") {
355
+ info.providedIn = "root";
356
+ }
357
+ const factory = getProp(optionsArg, "factory");
358
+ if (factory) {
359
+ info.hasFactory = true;
360
+ }
141
361
  }
142
362
  return info;
143
363
  }
@@ -146,7 +366,8 @@ function parseModule(candidate, nameByNode, ctx) {
146
366
  const name = nameByNode.get(candidate.node) ?? className;
147
367
  const tags = arrayProp(options, "tags").map((el) => Node.isStringLiteral(el) ? el.getLiteralText() : el.getText().replace(/['"]/g, "")).filter(Boolean);
148
368
  const imports = arrayProp(options, "imports").map((el) => {
149
- const decl = Node.isIdentifier(el) ? resolveDeclaration(el)[0] : undefined;
369
+ const unwrapped = unwrapForwardRef(el);
370
+ const decl = Node.isIdentifier(unwrapped) ? resolveDeclaration(unwrapped)[0] : undefined;
150
371
  if (decl) {
151
372
  const known = nameByNode.get(decl);
152
373
  if (known)
@@ -211,7 +432,8 @@ function parseModule(candidate, nameByNode, ctx) {
211
432
  permission: stringLiteralProp(meta, "permission"),
212
433
  transaction: commandModeProp(meta, "transaction") ?? "none",
213
434
  audit: stringLiteralProp(meta, "audit"),
214
- idempotency: commandModeProp(meta, "idempotency") ?? "none"
435
+ idempotency: commandModeProp(meta, "idempotency") ?? "none",
436
+ standalone: booleanProp(meta, "standalone") || undefined
215
437
  });
216
438
  }
217
439
  }
@@ -247,11 +469,13 @@ function commandModeProp(object, name) {
247
469
  function parseProvider(el, exportsSet, ctx) {
248
470
  const file = sourcePath(ctx.rootDir, el.getSourceFile().getFilePath());
249
471
  const line = el.getStartLineNumber();
250
- if (Node.isIdentifier(el)) {
251
- const decl = resolveDeclaration(el)[0];
472
+ const unwrappedEl = unwrapForwardRef(el);
473
+ if (Node.isIdentifier(unwrappedEl)) {
474
+ const decl = resolveDeclaration(unwrappedEl)[0];
252
475
  const cls = decl && Node.isClassDeclaration(decl) ? decl : undefined;
253
- const className = cls?.getName() ?? el.getText();
254
- const { deps, missing } = cls ? classDeps(cls, ctx) : { deps: [], missing: false };
476
+ const className = cls?.getName() ?? unwrappedEl.getText();
477
+ const { deps, optionalDeps, selfDeps, skipSelfDeps, hostDeps, missing } = cls ? classDeps(cls, ctx) : { deps: [], optionalDeps: [], selfDeps: [], skipSelfDeps: [], hostDeps: [], missing: false };
478
+ const injectable = cls ? parseInjectableOptions(cls, ctx) : undefined;
255
479
  if (missing) {
256
480
  warn(ctx, "missing-deps", `provider ${className} 的部分构造依赖无法静态解析`, file, line);
257
481
  }
@@ -262,6 +486,12 @@ function parseProvider(el, exportsSet, ctx) {
262
486
  useClass: className,
263
487
  scope: resolveScope({ cls, tokenName: className }, ctx),
264
488
  deps,
489
+ optionalDeps: optionalDeps.length > 0 ? optionalDeps : undefined,
490
+ selfDeps: selfDeps.length > 0 ? selfDeps : undefined,
491
+ skipSelfDeps: skipSelfDeps.length > 0 ? skipSelfDeps : undefined,
492
+ hostDeps: hostDeps.length > 0 ? hostDeps : undefined,
493
+ providedIn: injectable?.providedIn,
494
+ hasOnDestroy: cls?.getMethod("onDestroy") !== undefined || undefined,
265
495
  exported: exportsSet.has(className),
266
496
  file,
267
497
  line,
@@ -276,22 +506,33 @@ function parseProvider(el, exportsSet, ctx) {
276
506
  const { name: token, kind: tokenKind } = tokenNameOf(provideExpr, ctx);
277
507
  const explicitScope = parseScopeProp(el);
278
508
  const explicitDeps = arrayProp(el, "deps").map((d) => tokenNameOf(d, ctx).name);
509
+ const multi = booleanProp(el, "multi");
279
510
  const useClassExpr = getProp(el, "useClass");
280
511
  const useValueExpr = getProp(el, "useValue");
281
512
  const useFactoryExpr = getProp(el, "useFactory");
282
513
  const useExistingExpr = getProp(el, "useExisting");
283
514
  if (useClassExpr) {
284
- const decl = Node.isIdentifier(useClassExpr) ? resolveDeclaration(useClassExpr)[0] : undefined;
515
+ const unwrappedClass = unwrapForwardRef(useClassExpr);
516
+ const decl = Node.isIdentifier(unwrappedClass) ? resolveDeclaration(unwrappedClass)[0] : undefined;
285
517
  const cls = decl && Node.isClassDeclaration(decl) ? decl : undefined;
286
- const useClass = cls?.getName() ?? useClassExpr.getText();
518
+ const useClass = cls?.getName() ?? unwrappedClass.getText();
287
519
  let deps = explicitDeps;
520
+ let optionalDeps = [];
521
+ let selfDeps = [];
522
+ let skipSelfDeps = [];
523
+ let hostDeps = [];
288
524
  if (deps.length === 0 && cls) {
289
525
  const result = classDeps(cls, ctx);
290
526
  deps = result.deps;
527
+ optionalDeps = result.optionalDeps;
528
+ selfDeps = result.selfDeps;
529
+ skipSelfDeps = result.skipSelfDeps;
530
+ hostDeps = result.hostDeps;
291
531
  if (result.missing) {
292
532
  warn(ctx, "missing-deps", `provider ${token} (useClass ${useClass}) 的部分构造依赖无法静态解析`, file, line);
293
533
  }
294
534
  }
535
+ const injectable = cls ? parseInjectableOptions(cls, ctx) : undefined;
295
536
  return {
296
537
  token,
297
538
  tokenKind,
@@ -299,6 +540,13 @@ function parseProvider(el, exportsSet, ctx) {
299
540
  useClass,
300
541
  scope: resolveScope({ explicit: explicitScope, cls, tokenName: token }, ctx),
301
542
  deps,
543
+ optionalDeps: optionalDeps.length > 0 ? optionalDeps : undefined,
544
+ selfDeps: selfDeps.length > 0 ? selfDeps : undefined,
545
+ skipSelfDeps: skipSelfDeps.length > 0 ? skipSelfDeps : undefined,
546
+ hostDeps: hostDeps.length > 0 ? hostDeps : undefined,
547
+ multi: multi ?? undefined,
548
+ providedIn: injectable?.providedIn,
549
+ hasOnDestroy: cls?.getMethod("onDestroy") !== undefined || undefined,
302
550
  exported: exportsSet.has(token),
303
551
  file,
304
552
  line,
@@ -313,6 +561,7 @@ function parseProvider(el, exportsSet, ctx) {
313
561
  useValueExpr: useValueExpr.getText(),
314
562
  scope: resolveScope({ explicit: explicitScope, tokenName: token }, ctx),
315
563
  deps: [],
564
+ multi: multi ?? undefined,
316
565
  exported: exportsSet.has(token),
317
566
  file,
318
567
  line,
@@ -331,6 +580,7 @@ function parseProvider(el, exportsSet, ctx) {
331
580
  useFactoryName: factoryName,
332
581
  scope: resolveScope({ explicit: explicitScope, tokenName: token }, ctx),
333
582
  deps: explicitDeps,
583
+ multi: multi ?? undefined,
334
584
  exported: exportsSet.has(token),
335
585
  file,
336
586
  line,
@@ -346,6 +596,7 @@ function parseProvider(el, exportsSet, ctx) {
346
596
  useExisting: target,
347
597
  scope: resolveScope({ explicit: explicitScope, tokenName: token }, ctx),
348
598
  deps: [target],
599
+ multi: multi ?? undefined,
349
600
  exported: exportsSet.has(token),
350
601
  file,
351
602
  line
@@ -353,18 +604,36 @@ function parseProvider(el, exportsSet, ctx) {
353
604
  }
354
605
  return;
355
606
  }
356
- function parseController(el, ctx) {
357
- if (!Node.isIdentifier(el))
358
- return;
359
- const decl = resolveDeclaration(el)[0];
360
- if (!decl || !Node.isClassDeclaration(decl))
607
+ function parseController(input, ctx) {
608
+ let decl;
609
+ if (Node.isClassDeclaration(input)) {
610
+ decl = input;
611
+ } else {
612
+ const unwrapped = unwrapForwardRef(input);
613
+ const resolved = Node.isIdentifier(unwrapped) ? resolveDeclaration(unwrapped)[0] : undefined;
614
+ if (resolved && Node.isClassDeclaration(resolved)) {
615
+ decl = resolved;
616
+ }
617
+ }
618
+ if (!decl)
361
619
  return;
362
620
  const controllerDec = findDecorator(decl, "Controller");
363
621
  if (!controllerDec)
364
622
  return;
623
+ let path = "/";
624
+ let standalone;
365
625
  const pathArg = controllerDec.getArguments()[0];
366
- const path = pathArg && Node.isStringLiteral(pathArg) ? pathArg.getLiteralText() : "/";
367
- const { deps, missing } = classDeps(decl, ctx);
626
+ if (pathArg) {
627
+ if (Node.isStringLiteral(pathArg)) {
628
+ path = pathArg.getLiteralText();
629
+ } else if (Node.isObjectLiteralExpression(pathArg)) {
630
+ const p = stringLiteralProp(pathArg, "path");
631
+ if (p)
632
+ path = p;
633
+ standalone = booleanProp(pathArg, "standalone");
634
+ }
635
+ }
636
+ const { deps, optionalDeps, selfDeps, skipSelfDeps, missing } = classDeps(decl, ctx);
368
637
  const file = sourcePath(ctx.rootDir, decl.getSourceFile().getFilePath());
369
638
  if (missing) {
370
639
  warn(ctx, "missing-deps", `controller ${decl.getName()} 的部分构造依赖无法静态解析`, file, decl.getStartLineNumber());
@@ -372,6 +641,14 @@ function parseController(el, ctx) {
372
641
  const injectable = parseInjectableOptions(decl, ctx);
373
642
  const routes = [];
374
643
  const schemaImports = {};
644
+ const classGuards = [];
645
+ for (const dec of decl.getDecorators()) {
646
+ if (decoratorName(dec) === "UseGuards") {
647
+ for (const gArg of dec.getArguments()) {
648
+ classGuards.push(tokenText(gArg));
649
+ }
650
+ }
651
+ }
375
652
  for (const method of decl.getMethods()) {
376
653
  for (const dec of method.getDecorators()) {
377
654
  const name = decoratorName(dec);
@@ -380,11 +657,158 @@ function parseController(el, ctx) {
380
657
  continue;
381
658
  const args = dec.getArguments();
382
659
  const pathArg2 = args[0];
660
+ const routePath = pathArg2 && Node.isStringLiteral(pathArg2) ? pathArg2.getLiteralText() : "/";
383
661
  const route = {
384
662
  method: httpMethod,
385
- path: pathArg2 && Node.isStringLiteral(pathArg2) ? pathArg2.getLiteralText() : "/",
663
+ path: routePath,
386
664
  handler: method.getName()
387
665
  };
666
+ const pathParams = [];
667
+ const paramRegex = /:([a-zA-Z0-9_]+)/g;
668
+ let match;
669
+ while ((match = paramRegex.exec(routePath)) !== null) {
670
+ pathParams.push(match[1]);
671
+ }
672
+ if (pathParams.length > 0)
673
+ route.pathParams = pathParams;
674
+ const paramBindings = [];
675
+ const queryBindings = [];
676
+ const paramTransforms = {};
677
+ const paramDefaults = {};
678
+ const queryTransforms = {};
679
+ const queryDefaults = {};
680
+ let hasBodyBinding = false;
681
+ const handlerParams = [];
682
+ for (const p of method.getParameters()) {
683
+ const pName = p.getName();
684
+ let hasBindingDecorator = false;
685
+ let paramNode;
686
+ for (const pDec of p.getDecorators()) {
687
+ const dName = decoratorName(pDec);
688
+ const dArgs = pDec.getArguments();
689
+ if (dName === "Param") {
690
+ hasBindingDecorator = true;
691
+ const parsed = parseBindingOptions(dArgs, pName);
692
+ paramBindings.push(parsed.name);
693
+ if (parsed.transform)
694
+ paramTransforms[parsed.name] = parsed.transform;
695
+ if (parsed.default !== undefined)
696
+ paramDefaults[parsed.name] = parsed.default;
697
+ paramNode = {
698
+ name: pName,
699
+ kind: "param",
700
+ bindingName: parsed.name,
701
+ transform: parsed.transform,
702
+ default: parsed.default
703
+ };
704
+ } else if (dName === "Query") {
705
+ hasBindingDecorator = true;
706
+ const parsed = parseBindingOptions(dArgs, pName);
707
+ queryBindings.push(parsed.name);
708
+ if (parsed.transform)
709
+ queryTransforms[parsed.name] = parsed.transform;
710
+ if (parsed.default !== undefined)
711
+ queryDefaults[parsed.name] = parsed.default;
712
+ paramNode = {
713
+ name: pName,
714
+ kind: "query",
715
+ bindingName: parsed.name,
716
+ transform: parsed.transform,
717
+ default: parsed.default
718
+ };
719
+ } else if (dName === "Body") {
720
+ hasBindingDecorator = true;
721
+ hasBodyBinding = true;
722
+ paramNode = { name: pName, kind: "body" };
723
+ } else if (dName === "Headers") {
724
+ hasBindingDecorator = true;
725
+ paramNode = { name: pName, kind: "headers" };
726
+ }
727
+ }
728
+ if (!hasBindingDecorator && pathParams.includes(pName)) {
729
+ paramBindings.push(pName);
730
+ const typeText = p.getType().getText();
731
+ let inferredTransform;
732
+ if (typeText === "number") {
733
+ paramTransforms[pName] = "number";
734
+ inferredTransform = "number";
735
+ } else if (typeText === "boolean") {
736
+ paramTransforms[pName] = "boolean";
737
+ inferredTransform = "boolean";
738
+ }
739
+ paramNode = {
740
+ name: pName,
741
+ kind: "param",
742
+ bindingName: pName,
743
+ transform: inferredTransform
744
+ };
745
+ } else if (!hasBindingDecorator) {
746
+ if (pName === "req" || pName === "ctx" || pName === "context") {
747
+ paramNode = { name: pName, kind: "context" };
748
+ } else {
749
+ paramNode = { name: pName, kind: "unknown" };
750
+ }
751
+ }
752
+ if (paramNode)
753
+ handlerParams.push(paramNode);
754
+ }
755
+ if (paramBindings.length > 0)
756
+ route.paramBindings = paramBindings;
757
+ if (queryBindings.length > 0)
758
+ route.queryBindings = queryBindings;
759
+ if (Object.keys(paramTransforms).length > 0)
760
+ route.paramTransforms = paramTransforms;
761
+ if (Object.keys(paramDefaults).length > 0)
762
+ route.paramDefaults = paramDefaults;
763
+ if (Object.keys(queryTransforms).length > 0)
764
+ route.queryTransforms = queryTransforms;
765
+ if (Object.keys(queryDefaults).length > 0)
766
+ route.queryDefaults = queryDefaults;
767
+ if (hasBodyBinding)
768
+ route.hasBodyBinding = true;
769
+ if (handlerParams.length > 0)
770
+ route.handlerParams = handlerParams;
771
+ const routeGuards = [...classGuards];
772
+ const routeCanDeactivate = [];
773
+ for (const mDec of method.getDecorators()) {
774
+ const dName = decoratorName(mDec);
775
+ const mArgs = mDec.getArguments();
776
+ if (dName === "UseGuards") {
777
+ for (const gArg of mArgs) {
778
+ routeGuards.push(tokenText(gArg));
779
+ }
780
+ } else if (dName === "CanDeactivate") {
781
+ for (const gArg of mArgs) {
782
+ routeCanDeactivate.push(tokenText(gArg));
783
+ }
784
+ } else if (dName === "Title") {
785
+ const tArg = mArgs[0];
786
+ if (tArg && Node.isStringLiteral(tArg)) {
787
+ route.title = tArg.getLiteralText();
788
+ }
789
+ } else if (dName === "Data") {
790
+ const dArg = mArgs[0];
791
+ if (dArg && Node.isObjectLiteralExpression(dArg)) {
792
+ route.data = { ...route.data, ...parseObjectLiteralValues(dArg) };
793
+ }
794
+ } else if (dName === "Resolve") {
795
+ const rArg = mArgs[0];
796
+ if (rArg && Node.isObjectLiteralExpression(rArg)) {
797
+ const resolvers = route.resolvers ?? {};
798
+ for (const prop of rArg.getProperties()) {
799
+ if (Node.isPropertyAssignment(prop)) {
800
+ const rName = prop.getName();
801
+ const init = prop.getInitializer();
802
+ if (init)
803
+ resolvers[rName] = tokenText(init);
804
+ }
805
+ }
806
+ if (Object.keys(resolvers).length > 0) {
807
+ route.resolvers = resolvers;
808
+ }
809
+ }
810
+ }
811
+ }
388
812
  const optionsArg = args[1];
389
813
  if (optionsArg && Node.isObjectLiteralExpression(optionsArg)) {
390
814
  for (const field of ["body", "params", "query", "response"]) {
@@ -401,6 +825,68 @@ function parseController(el, ctx) {
401
825
  const commandDecl = resolveDeclaration(commandExpr)[0];
402
826
  route.command = commandDecl && Node.isClassDeclaration(commandDecl) ? commandDecl.getName() ?? commandExpr.getText() : commandExpr.getText();
403
827
  }
828
+ const guardsExpr = getProp(optionsArg, "guards");
829
+ if (guardsExpr && Node.isArrayLiteralExpression(guardsExpr)) {
830
+ for (const el of guardsExpr.getElements()) {
831
+ routeGuards.push(tokenText(el));
832
+ }
833
+ }
834
+ const canMatchExpr = getProp(optionsArg, "canMatch");
835
+ if (canMatchExpr && Node.isArrayLiteralExpression(canMatchExpr)) {
836
+ const canMatchList = [];
837
+ for (const el of canMatchExpr.getElements()) {
838
+ canMatchList.push(tokenText(el));
839
+ }
840
+ if (canMatchList.length > 0) {
841
+ route.canMatch = canMatchList;
842
+ }
843
+ }
844
+ const canDeactivateExpr = getProp(optionsArg, "canDeactivate");
845
+ if (canDeactivateExpr && Node.isArrayLiteralExpression(canDeactivateExpr)) {
846
+ for (const el of canDeactivateExpr.getElements()) {
847
+ routeCanDeactivate.push(tokenText(el));
848
+ }
849
+ }
850
+ const resolversExpr = getProp(optionsArg, "resolvers");
851
+ if (resolversExpr && Node.isObjectLiteralExpression(resolversExpr)) {
852
+ const resolvers = {};
853
+ for (const prop of resolversExpr.getProperties()) {
854
+ if (Node.isPropertyAssignment(prop)) {
855
+ const rName = prop.getName();
856
+ const init = prop.getInitializer();
857
+ if (init)
858
+ resolvers[rName] = tokenText(init);
859
+ }
860
+ }
861
+ if (Object.keys(resolvers).length > 0) {
862
+ route.resolvers = resolvers;
863
+ }
864
+ }
865
+ const redirectToExpr = getProp(optionsArg, "redirectTo");
866
+ if (redirectToExpr && Node.isStringLiteral(redirectToExpr)) {
867
+ route.redirectTo = redirectToExpr.getLiteralText();
868
+ }
869
+ const pathMatchExpr = getProp(optionsArg, "pathMatch");
870
+ if (pathMatchExpr && Node.isStringLiteral(pathMatchExpr)) {
871
+ const val = pathMatchExpr.getLiteralText();
872
+ if (val === "full" || val === "prefix") {
873
+ route.pathMatch = val;
874
+ }
875
+ }
876
+ const titleExpr = getProp(optionsArg, "title");
877
+ if (titleExpr && Node.isStringLiteral(titleExpr)) {
878
+ route.title = titleExpr.getLiteralText();
879
+ }
880
+ const dataExpr = getProp(optionsArg, "data");
881
+ if (dataExpr && Node.isObjectLiteralExpression(dataExpr)) {
882
+ route.data = { ...route.data, ...parseObjectLiteralValues(dataExpr) };
883
+ }
884
+ }
885
+ if (routeGuards.length > 0) {
886
+ route.guards = routeGuards;
887
+ }
888
+ if (routeCanDeactivate.length > 0) {
889
+ route.canDeactivate = routeCanDeactivate;
404
890
  }
405
891
  routes.push(route);
406
892
  }
@@ -410,6 +896,10 @@ function parseController(el, ctx) {
410
896
  path,
411
897
  scope: injectable?.scope ?? "request",
412
898
  deps,
899
+ optionalDeps: optionalDeps.length > 0 ? optionalDeps : undefined,
900
+ selfDeps: selfDeps.length > 0 ? selfDeps : undefined,
901
+ skipSelfDeps: skipSelfDeps.length > 0 ? skipSelfDeps : undefined,
902
+ standalone: standalone || undefined,
413
903
  routes,
414
904
  file,
415
905
  importPath: modulePath(ctx.rootDir, decl.getSourceFile().getFilePath()),
@@ -418,28 +908,84 @@ function parseController(el, ctx) {
418
908
  }
419
909
  function classDeps(cls, ctx) {
420
910
  const injectable = parseInjectableOptions(cls, ctx);
421
- if (injectable?.deps)
422
- return { deps: injectable.deps, missing: false };
911
+ if (injectable?.deps) {
912
+ return {
913
+ deps: injectable.deps,
914
+ optionalDeps: [],
915
+ selfDeps: [],
916
+ skipSelfDeps: [],
917
+ hostDeps: [],
918
+ missing: false
919
+ };
920
+ }
423
921
  const ctor = cls.getConstructors()[0];
424
- if (!ctor || ctor.getParameters().length === 0)
425
- return { deps: [], missing: false };
426
- const injectParams = parseInjectParams(cls);
427
922
  const deps = [];
923
+ const optionalDeps = [];
924
+ const selfDeps = [];
925
+ const skipSelfDeps = [];
926
+ const hostDeps = [];
428
927
  let missing = false;
429
- ctor.getParameters().forEach((param, index) => {
430
- const injected = injectParams.get(index);
431
- if (injected) {
432
- deps.push(injected);
433
- return;
434
- }
435
- const byType = paramTypeTokenName(param, ctx);
436
- if (byType) {
437
- deps.push(byType);
438
- } else {
439
- missing = true;
928
+ if (ctor && ctor.getParameters().length > 0) {
929
+ const injectParams = parseInjectParams(cls);
930
+ const optionalIndices = parseOptionalParams(cls);
931
+ const selfIndices = parseModifierParams(cls, "Self");
932
+ const skipSelfIndices = parseModifierParams(cls, "SkipSelf");
933
+ const hostIndices = parseModifierParams(cls, "Host");
934
+ ctor.getParameters().forEach((param, index) => {
935
+ const isOptional = optionalIndices.has(index);
936
+ const injected = injectParams.get(index);
937
+ const tokenName = injected ?? paramTypeTokenName(param, ctx);
938
+ if (tokenName) {
939
+ deps.push(tokenName);
940
+ if (isOptional)
941
+ optionalDeps.push(tokenName);
942
+ if (selfIndices.has(index))
943
+ selfDeps.push(tokenName);
944
+ if (skipSelfIndices.has(index))
945
+ skipSelfDeps.push(tokenName);
946
+ if (hostIndices.has(index))
947
+ hostDeps.push(tokenName);
948
+ } else {
949
+ if (!isOptional)
950
+ missing = true;
951
+ }
952
+ });
953
+ }
954
+ for (const prop of cls.getProperties()) {
955
+ const init = prop.getInitializer();
956
+ if (init && Node.isCallExpression(init)) {
957
+ const callName = init.getExpression().getText().split(".").pop();
958
+ if (callName === "inject") {
959
+ const [tokenArg, optionsArg] = init.getArguments();
960
+ if (tokenArg) {
961
+ const tokenName = tokenText(tokenArg);
962
+ if (tokenName) {
963
+ if (!deps.includes(tokenName))
964
+ deps.push(tokenName);
965
+ if (optionsArg && Node.isObjectLiteralExpression(optionsArg)) {
966
+ const isOptional = booleanProp(optionsArg, "optional");
967
+ if (isOptional && !optionalDeps.includes(tokenName)) {
968
+ optionalDeps.push(tokenName);
969
+ }
970
+ const isSelf = booleanProp(optionsArg, "self");
971
+ if (isSelf && !selfDeps.includes(tokenName)) {
972
+ selfDeps.push(tokenName);
973
+ }
974
+ const isSkipSelf = booleanProp(optionsArg, "skipSelf");
975
+ if (isSkipSelf && !skipSelfDeps.includes(tokenName)) {
976
+ skipSelfDeps.push(tokenName);
977
+ }
978
+ const isHost = booleanProp(optionsArg, "host");
979
+ if (isHost && !hostDeps.includes(tokenName)) {
980
+ hostDeps.push(tokenName);
981
+ }
982
+ }
983
+ }
984
+ }
985
+ }
440
986
  }
441
- });
442
- return { deps, missing };
987
+ }
988
+ return { deps, optionalDeps, selfDeps, skipSelfDeps, hostDeps, missing };
443
989
  }
444
990
  function paramTypeTokenName(param, ctx) {
445
991
  const typeNode = param.getTypeNode();
@@ -460,9 +1006,11 @@ function parseInjectableOptions(cls, ctx) {
460
1006
  if (!obj)
461
1007
  return {};
462
1008
  const scope = stringLiteralProp(obj, "scope");
1009
+ const providedIn = stringLiteralProp(obj, "providedIn");
463
1010
  const depsExpr = getProp(obj, "deps");
464
1011
  return {
465
1012
  scope: scope && SCOPES.includes(scope) ? scope : undefined,
1013
+ providedIn: providedIn === "root" ? "root" : undefined,
466
1014
  deps: depsExpr ? arrayProp(obj, "deps").map((el) => ctx ? tokenNameOf(el, ctx).name : el.getText()) : undefined
467
1015
  };
468
1016
  }
@@ -482,15 +1030,61 @@ function parseInjectParams(cls) {
482
1030
  });
483
1031
  return result;
484
1032
  }
1033
+ function parseOptionalParams(cls) {
1034
+ const result = new Set;
1035
+ const ctor = cls.getConstructors()[0];
1036
+ if (!ctor)
1037
+ return result;
1038
+ ctor.getParameters().forEach((param, index) => {
1039
+ for (const dec of param.getDecorators()) {
1040
+ if (decoratorName(dec) === "Optional")
1041
+ result.add(index);
1042
+ }
1043
+ if (param.hasQuestionToken())
1044
+ result.add(index);
1045
+ });
1046
+ return result;
1047
+ }
1048
+ function parseModifierParams(cls, modifierName) {
1049
+ const result = new Set;
1050
+ const ctor = cls.getConstructors()[0];
1051
+ if (!ctor)
1052
+ return result;
1053
+ ctor.getParameters().forEach((param, index) => {
1054
+ for (const dec of param.getDecorators()) {
1055
+ if (decoratorName(dec) === modifierName)
1056
+ result.add(index);
1057
+ }
1058
+ });
1059
+ return result;
1060
+ }
1061
+ function unwrapForwardRef(expr) {
1062
+ if (Node.isCallExpression(expr)) {
1063
+ const exprText = expr.getExpression().getText();
1064
+ if (exprText === "forwardRef" || exprText.endsWith(".forwardRef")) {
1065
+ const arg = expr.getArguments()[0];
1066
+ if (arg && (Node.isArrowFunction(arg) || Node.isFunctionExpression(arg))) {
1067
+ const body = arg.getBody();
1068
+ if (body && Node.isExpression(body)) {
1069
+ return unwrapForwardRef(body);
1070
+ }
1071
+ }
1072
+ }
1073
+ }
1074
+ return expr;
1075
+ }
485
1076
  function tokenText(expr) {
486
- if (Node.isIdentifier(expr)) {
487
- const decl = resolveDeclaration(expr)[0];
1077
+ const unwrapped = unwrapForwardRef(expr);
1078
+ if (Node.isStringLiteral(unwrapped))
1079
+ return unwrapped.getLiteralText();
1080
+ if (Node.isIdentifier(unwrapped)) {
1081
+ const decl = resolveDeclaration(unwrapped)[0];
488
1082
  if (decl && Node.isClassDeclaration(decl))
489
- return decl.getName() ?? expr.getText();
1083
+ return decl.getName() ?? unwrapped.getText();
490
1084
  if (decl && Node.isVariableDeclaration(decl))
491
1085
  return decl.getName();
492
1086
  }
493
- return expr.getText();
1087
+ return unwrapped.getText();
494
1088
  }
495
1089
  function resolveScope(input, ctx) {
496
1090
  if (input.explicit)
@@ -506,8 +1100,9 @@ function resolveScope(input, ctx) {
506
1100
  return "application";
507
1101
  }
508
1102
  function tokenNameOf(expr, ctx) {
509
- if (Node.isIdentifier(expr)) {
510
- const decl = resolveDeclaration(expr)[0];
1103
+ const unwrapped = unwrapForwardRef(expr);
1104
+ if (Node.isIdentifier(unwrapped)) {
1105
+ const decl = resolveDeclaration(unwrapped)[0];
511
1106
  if (decl && Node.isClassDeclaration(decl)) {
512
1107
  return { name: decl.getName() ?? expr.getText(), kind: "class" };
513
1108
  }
@@ -585,10 +1180,90 @@ function arrayProp(obj, name) {
585
1180
  const expr = getProp(obj, name);
586
1181
  return expr && Node.isArrayLiteralExpression(expr) ? expr.getElements() : [];
587
1182
  }
1183
+ function booleanProp(obj, name) {
1184
+ const expr = getProp(obj, name);
1185
+ if (!expr)
1186
+ return;
1187
+ if (expr.getKind() === SyntaxKind.TrueKeyword)
1188
+ return true;
1189
+ if (expr.getKind() === SyntaxKind.FalseKeyword)
1190
+ return false;
1191
+ return;
1192
+ }
588
1193
  function parseScopeProp(obj) {
589
1194
  const scope = stringLiteralProp(obj, "scope");
590
1195
  return scope && SCOPES.includes(scope) ? scope : undefined;
591
1196
  }
1197
+ function parseBindingOptions(args, defaultName) {
1198
+ let name = defaultName;
1199
+ let transform;
1200
+ let defaultValue;
1201
+ const first = args[0];
1202
+ const second = args[1];
1203
+ if (first && Node.isStringLiteral(first)) {
1204
+ name = first.getLiteralText();
1205
+ } else if (first && Node.isObjectLiteralExpression(first)) {
1206
+ const nameProp = getProp(first, "name");
1207
+ if (nameProp && Node.isStringLiteral(nameProp)) {
1208
+ name = nameProp.getLiteralText();
1209
+ }
1210
+ const trProp = getProp(first, "transform");
1211
+ if (trProp && Node.isStringLiteral(trProp)) {
1212
+ const val = trProp.getLiteralText();
1213
+ if (val === "number" || val === "boolean" || val === "string") {
1214
+ transform = val;
1215
+ }
1216
+ }
1217
+ const defProp = getProp(first, "default");
1218
+ if (defProp) {
1219
+ defaultValue = parseLiteralValue(defProp);
1220
+ }
1221
+ }
1222
+ if (second && Node.isObjectLiteralExpression(second)) {
1223
+ const trProp = getProp(second, "transform");
1224
+ if (trProp && Node.isStringLiteral(trProp)) {
1225
+ const val = trProp.getLiteralText();
1226
+ if (val === "number" || val === "boolean" || val === "string") {
1227
+ transform = val;
1228
+ }
1229
+ }
1230
+ const defProp = getProp(second, "default");
1231
+ if (defProp) {
1232
+ defaultValue = parseLiteralValue(defProp);
1233
+ }
1234
+ }
1235
+ return { name, transform, default: defaultValue };
1236
+ }
1237
+ function parseLiteralValue(node) {
1238
+ if (Node.isStringLiteral(node))
1239
+ return node.getLiteralText();
1240
+ if (Node.isNumericLiteral(node))
1241
+ return node.getLiteralValue();
1242
+ if (node.getKindName() === "TrueKeyword")
1243
+ return true;
1244
+ if (node.getKindName() === "FalseKeyword")
1245
+ return false;
1246
+ if (Node.isArrayLiteralExpression(node)) {
1247
+ return node.getElements().map(parseLiteralValue);
1248
+ }
1249
+ if (Node.isObjectLiteralExpression(node)) {
1250
+ return parseObjectLiteralValues(node);
1251
+ }
1252
+ return;
1253
+ }
1254
+ function parseObjectLiteralValues(obj) {
1255
+ const result = {};
1256
+ for (const prop of obj.getProperties()) {
1257
+ if (Node.isPropertyAssignment(prop)) {
1258
+ const name = prop.getName();
1259
+ const init = prop.getInitializer();
1260
+ if (init) {
1261
+ result[name] = parseLiteralValue(init);
1262
+ }
1263
+ }
1264
+ }
1265
+ return result;
1266
+ }
592
1267
  function modulePath(rootDir, absFile) {
593
1268
  return sourcePath(rootDir, absFile).replace(/\.(ts|tsx|js|mts|cts)$/, "");
594
1269
  }
@@ -600,7 +1275,7 @@ function warn(ctx, code, message, file, line) {
600
1275
  }
601
1276
 
602
1277
  // src/generate.ts
603
- import { mkdir, writeFile } from "node:fs/promises";
1278
+ import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
604
1279
  import { join as join2 } from "node:path";
605
1280
 
606
1281
  // src/util.ts
@@ -633,6 +1308,28 @@ function isRequestContextToken(token, tokenNames) {
633
1308
  function isJobContextToken(token, tokenNames) {
634
1309
  return token === "JOB_CONTEXT" || tokenNames?.[token] === JOB_CONTEXT_TOKEN_NAME;
635
1310
  }
1311
+ function joinRoutePaths(prefix, path) {
1312
+ const joined = `${prefix}/${path}`.replace(/\/{2,}/g, "/");
1313
+ const normalized = joined.length > 1 ? joined.replace(/\/+$/, "") : joined;
1314
+ return normalized;
1315
+ }
1316
+ function findClosestMatch(target, candidates) {
1317
+ if (candidates.length === 0)
1318
+ return;
1319
+ if (candidates.length === 1)
1320
+ return candidates[0];
1321
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
1322
+ const targetNorm = norm(target);
1323
+ for (const c of candidates) {
1324
+ if (norm(c) === targetNorm)
1325
+ return c;
1326
+ }
1327
+ for (const c of candidates) {
1328
+ if (norm(c).includes(targetNorm) || targetNorm.includes(norm(c)))
1329
+ return c;
1330
+ }
1331
+ return candidates[0];
1332
+ }
636
1333
 
637
1334
  // src/generate.ts
638
1335
  var HEADER = "// GENERATED BY @supacloud/compiler — do not edit";
@@ -645,6 +1342,28 @@ var INTERFACES = `export interface CompiledRoute {
645
1342
  query?: unknown;
646
1343
  response?: unknown;
647
1344
  command?: string;
1345
+ guards?: string[];
1346
+ canMatch?: string[];
1347
+ canDeactivate?: string[];
1348
+ resolvers?: Record<string, string>;
1349
+ redirectTo?: string;
1350
+ pathMatch?: "full" | "prefix";
1351
+ paramTransforms?: Record<string, "number" | "boolean" | "string">;
1352
+ paramDefaults?: Record<string, unknown>;
1353
+ queryTransforms?: Record<string, "number" | "boolean" | "string">;
1354
+ queryDefaults?: Record<string, unknown>;
1355
+ title?: string;
1356
+ data?: Record<string, unknown>;
1357
+ invoker?: (
1358
+ controller: unknown,
1359
+ request: {
1360
+ params?: Record<string, unknown>;
1361
+ query?: Record<string, unknown>;
1362
+ body?: unknown;
1363
+ headers?: Record<string, unknown>;
1364
+ context?: unknown;
1365
+ },
1366
+ ) => Promise<unknown> | unknown;
648
1367
  }
649
1368
 
650
1369
  export interface CompiledCommand {
@@ -654,6 +1373,7 @@ export interface CompiledCommand {
654
1373
  transaction: "required" | "none";
655
1374
  audit?: string;
656
1375
  idempotency: "required" | "none";
1376
+ standalone?: boolean;
657
1377
  }
658
1378
 
659
1379
  export interface CompiledController {
@@ -683,7 +1403,40 @@ export interface CompiledModule {
683
1403
  commands: CompiledCommand[];
684
1404
  }`;
685
1405
  function renderApplication(graph, options) {
686
- const modules = topoSortModules(graph.modules);
1406
+ let modules = topoSortModules(graph.modules);
1407
+ if (options.treeShakeUnusedProviders) {
1408
+ const referencedTokens = new Set;
1409
+ for (const mod of graph.modules) {
1410
+ for (const exp of mod.exports)
1411
+ referencedTokens.add(exp);
1412
+ for (const ctrl of mod.controllers) {
1413
+ for (const d of ctrl.deps)
1414
+ referencedTokens.add(d);
1415
+ for (const d of ctrl.optionalDeps ?? [])
1416
+ referencedTokens.add(d);
1417
+ for (const d of ctrl.selfDeps ?? [])
1418
+ referencedTokens.add(d);
1419
+ for (const d of ctrl.skipSelfDeps ?? [])
1420
+ referencedTokens.add(d);
1421
+ }
1422
+ for (const p of mod.providers) {
1423
+ for (const d of p.deps ?? [])
1424
+ referencedTokens.add(d);
1425
+ for (const d of p.optionalDeps ?? [])
1426
+ referencedTokens.add(d);
1427
+ for (const d of p.selfDeps ?? [])
1428
+ referencedTokens.add(d);
1429
+ for (const d of p.skipSelfDeps ?? [])
1430
+ referencedTokens.add(d);
1431
+ if (p.useExisting)
1432
+ referencedTokens.add(p.useExisting);
1433
+ }
1434
+ }
1435
+ modules = modules.map((mod) => ({
1436
+ ...mod,
1437
+ providers: mod.providers.filter((p) => p.providedIn !== "root" || p.multi || referencedTokens.has(p.token) || p.exported)
1438
+ }));
1439
+ }
687
1440
  const imports = new ImportManager;
688
1441
  const factorySections = [];
689
1442
  const descriptorEntries = [];
@@ -705,6 +1458,34 @@ function renderApplication(graph, options) {
705
1458
  " ];",
706
1459
  "}",
707
1460
  "",
1461
+ "export async function initializeApplication(services: Record<string, unknown>): Promise<void> {",
1462
+ ' const initializers = (services.appInitializer ?? (services as any)["supacloud.app-initializer"]) as unknown;',
1463
+ " if (Array.isArray(initializers)) {",
1464
+ " for (const init of initializers) {",
1465
+ ' if (typeof init === "function") await init();',
1466
+ " }",
1467
+ ' } else if (typeof initializers === "function") {',
1468
+ " await (initializers as () => unknown)();",
1469
+ " }",
1470
+ "}",
1471
+ "",
1472
+ "export async function destroyApplication(services: Record<string, unknown>): Promise<void> {",
1473
+ ' const destroyRef = (services.destroyRef ?? (services as any)["supacloud.destroy-ref"]) as { destroy?: () => Promise<void>; _teardowns?: Array<() => void | Promise<void>> } | undefined;',
1474
+ ' if (destroyRef && typeof destroyRef.destroy === "function") {',
1475
+ " await destroyRef.destroy();",
1476
+ " } else if (destroyRef && Array.isArray(destroyRef._teardowns)) {",
1477
+ " for (const teardown of [...destroyRef._teardowns].reverse()) {",
1478
+ ' if (typeof teardown === "function") await teardown();',
1479
+ " }",
1480
+ " }",
1481
+ " const instances = Object.values(services);",
1482
+ " for (const inst of instances.reverse()) {",
1483
+ ' if (inst && typeof (inst as any).onDestroy === "function") {',
1484
+ " await (inst as any).onDestroy();",
1485
+ " }",
1486
+ " }",
1487
+ "}",
1488
+ "",
708
1489
  ...factorySections,
709
1490
  ""
710
1491
  ].join(`
@@ -714,10 +1495,14 @@ function renderApplication(graph, options) {
714
1495
  modules: graph.modules,
715
1496
  externalTokens: graph.externalTokens
716
1497
  };
1498
+ const clientCode = options.generateClient ? renderClient(graph, options) : undefined;
1499
+ const permissionsCode = options.generatePermissions ? renderPermissions(graph) : undefined;
717
1500
  return {
718
1501
  applicationCode: code,
719
1502
  manifestJson: JSON.stringify(manifest, null, 2) + `
720
- `
1503
+ `,
1504
+ clientCode,
1505
+ permissionsCode
721
1506
  };
722
1507
  }
723
1508
  async function generateApplication(graph, options) {
@@ -725,9 +1510,32 @@ async function generateApplication(graph, options) {
725
1510
  await mkdir(options.outDir, { recursive: true });
726
1511
  const applicationPath = join2(options.outDir, "application.ts");
727
1512
  const manifestPath = join2(options.outDir, "app.manifest.json");
728
- await writeFile(applicationPath, rendered.applicationCode, "utf8");
729
- await writeFile(manifestPath, rendered.manifestJson, "utf8");
730
- return [applicationPath, manifestPath];
1513
+ await writeFileAtomic(applicationPath, rendered.applicationCode);
1514
+ await writeFileAtomic(manifestPath, rendered.manifestJson);
1515
+ const written = [applicationPath, manifestPath];
1516
+ if (rendered.clientCode) {
1517
+ const clientPath = join2(options.outDir, "client.ts");
1518
+ await writeFileAtomic(clientPath, rendered.clientCode);
1519
+ written.push(clientPath);
1520
+ }
1521
+ if (rendered.permissionsCode) {
1522
+ const permissionsPath = join2(options.outDir, "permissions.ts");
1523
+ await writeFileAtomic(permissionsPath, rendered.permissionsCode);
1524
+ written.push(permissionsPath);
1525
+ }
1526
+ return written;
1527
+ }
1528
+ async function writeFileAtomic(path, content) {
1529
+ const temporaryPath = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
1530
+ try {
1531
+ await writeFile(temporaryPath, content, "utf8");
1532
+ await rename(temporaryPath, path);
1533
+ } catch (error) {
1534
+ await unlink(temporaryPath).catch(() => {
1535
+ return;
1536
+ });
1537
+ throw error;
1538
+ }
731
1539
  }
732
1540
  function factoryOfScope(scope) {
733
1541
  return scope === "application" ? "services" : scope;
@@ -864,6 +1672,81 @@ class ModuleGenerator {
864
1672
  }
865
1673
  if (route.command)
866
1674
  fields.push(`command: ${JSON.stringify(route.command)}`);
1675
+ if (route.guards && route.guards.length > 0) {
1676
+ fields.push(`guards: ${JSON.stringify(route.guards)}`);
1677
+ }
1678
+ if (route.canMatch && route.canMatch.length > 0) {
1679
+ fields.push(`canMatch: ${JSON.stringify(route.canMatch)}`);
1680
+ }
1681
+ if (route.canDeactivate && route.canDeactivate.length > 0) {
1682
+ fields.push(`canDeactivate: ${JSON.stringify(route.canDeactivate)}`);
1683
+ }
1684
+ if (route.resolvers && Object.keys(route.resolvers).length > 0) {
1685
+ fields.push(`resolvers: ${JSON.stringify(route.resolvers)}`);
1686
+ }
1687
+ if (route.redirectTo) {
1688
+ fields.push(`redirectTo: ${JSON.stringify(route.redirectTo)}`);
1689
+ }
1690
+ if (route.pathMatch) {
1691
+ fields.push(`pathMatch: ${JSON.stringify(route.pathMatch)}`);
1692
+ }
1693
+ if (route.paramTransforms && Object.keys(route.paramTransforms).length > 0) {
1694
+ fields.push(`paramTransforms: ${JSON.stringify(route.paramTransforms)}`);
1695
+ }
1696
+ if (route.paramDefaults && Object.keys(route.paramDefaults).length > 0) {
1697
+ fields.push(`paramDefaults: ${JSON.stringify(route.paramDefaults)}`);
1698
+ }
1699
+ if (route.queryTransforms && Object.keys(route.queryTransforms).length > 0) {
1700
+ fields.push(`queryTransforms: ${JSON.stringify(route.queryTransforms)}`);
1701
+ }
1702
+ if (route.queryDefaults && Object.keys(route.queryDefaults).length > 0) {
1703
+ fields.push(`queryDefaults: ${JSON.stringify(route.queryDefaults)}`);
1704
+ }
1705
+ if (route.title) {
1706
+ fields.push(`title: ${JSON.stringify(route.title)}`);
1707
+ }
1708
+ if (route.data && Object.keys(route.data).length > 0) {
1709
+ fields.push(`data: ${JSON.stringify(route.data)}`);
1710
+ }
1711
+ const invokerArgs = (route.handlerParams ?? []).map((hp) => {
1712
+ if (hp.kind === "param") {
1713
+ const accessor = `req.params?.[${JSON.stringify(hp.bindingName ?? hp.name)}]`;
1714
+ const fallback = hp.default !== undefined ? JSON.stringify(hp.default) : "undefined";
1715
+ if (hp.transform === "number") {
1716
+ return `(${accessor} !== undefined ? Number(${accessor}) : ${fallback})`;
1717
+ }
1718
+ if (hp.transform === "boolean") {
1719
+ return `(${accessor} !== undefined ? Boolean(${accessor}) : ${fallback})`;
1720
+ }
1721
+ if (hp.transform === "string") {
1722
+ return `(${accessor} !== undefined ? String(${accessor}) : ${fallback})`;
1723
+ }
1724
+ return `(${accessor} !== undefined ? ${accessor} : ${fallback})`;
1725
+ }
1726
+ if (hp.kind === "query") {
1727
+ const accessor = `req.query?.[${JSON.stringify(hp.bindingName ?? hp.name)}]`;
1728
+ const fallback = hp.default !== undefined ? JSON.stringify(hp.default) : "undefined";
1729
+ if (hp.transform === "number") {
1730
+ return `(${accessor} !== undefined ? Number(${accessor}) : ${fallback})`;
1731
+ }
1732
+ if (hp.transform === "boolean") {
1733
+ return `(${accessor} !== undefined ? Boolean(${accessor}) : ${fallback})`;
1734
+ }
1735
+ if (hp.transform === "string") {
1736
+ return `(${accessor} !== undefined ? String(${accessor}) : ${fallback})`;
1737
+ }
1738
+ return `(${accessor} !== undefined ? ${accessor} : ${fallback})`;
1739
+ }
1740
+ if (hp.kind === "body")
1741
+ return "req.body";
1742
+ if (hp.kind === "headers")
1743
+ return "req.headers";
1744
+ if (hp.kind === "context")
1745
+ return "(req.context ?? req)";
1746
+ return "undefined";
1747
+ });
1748
+ const callArgs = invokerArgs.length > 0 ? invokerArgs.join(", ") : "req";
1749
+ fields.push(`invoker: async (ctrl: any, req: any) => await (ctrl as any).${route.handler}(${callArgs})`);
867
1750
  return `{ ${fields.join(", ")} }`;
868
1751
  });
869
1752
  return [
@@ -909,11 +1792,24 @@ ${indent(item, 2)}`).join(",")}
909
1792
  const controllers = this.module.controllers.filter((c) => factoryOfScope(c.scope) === kind);
910
1793
  const lines = [];
911
1794
  const returns = new Map;
1795
+ const multiGroups = new Map;
912
1796
  for (const provider of providers) {
913
- const emitted = this.emitProvider(provider, kind);
914
- if (emitted.constLine)
915
- lines.push(emitted.constLine);
916
- returns.set(emitted.key, emitted.expr);
1797
+ if (provider.multi) {
1798
+ const emitted = this.emitProvider(provider, kind, true);
1799
+ if (emitted.constLine)
1800
+ lines.push(emitted.constLine);
1801
+ const list = multiGroups.get(emitted.key) ?? [];
1802
+ list.push(emitted.expr);
1803
+ multiGroups.set(emitted.key, list);
1804
+ } else {
1805
+ const emitted = this.emitProvider(provider, kind, false);
1806
+ if (emitted.constLine)
1807
+ lines.push(emitted.constLine);
1808
+ returns.set(emitted.key, emitted.expr);
1809
+ }
1810
+ }
1811
+ for (const [key, exprs] of multiGroups) {
1812
+ returns.set(key, `[${exprs.join(", ")}]`);
917
1813
  }
918
1814
  for (const controller of controllers) {
919
1815
  const emitted = this.emitController(controller, kind);
@@ -925,34 +1821,40 @@ ${indent(item, 2)}`).join(",")}
925
1821
  return lines.join(`
926
1822
  `);
927
1823
  }
928
- emitProvider(provider, kind) {
1824
+ emitProvider(provider, kind, isMulti = false) {
929
1825
  const key = camelName(provider.token);
930
1826
  switch (provider.kind) {
931
1827
  case "class": {
932
1828
  const useClass = this.imports.add(provider.useClass ?? provider.token, provider.importPath);
933
- const args = provider.deps.map((dep) => this.depExpr(dep, kind)).join(", ");
934
- const local = this.localVar(provider.token, kind);
1829
+ const args = provider.deps.map((dep) => this.depExpr(dep, kind, provider.optionalDeps?.includes(dep))).join(", ");
1830
+ const local = this.localVar(isMulti ? provider.useClass ?? `${provider.token}Item` : provider.token, kind);
935
1831
  return { constLine: `const ${local} = new ${useClass}(${args});`, key, expr: local };
936
1832
  }
937
1833
  case "value": {
938
1834
  const expr = provider.importPath ? this.imports.add(provider.useValueExpr ?? "undefined", provider.importPath) : provider.useValueExpr ?? "undefined";
939
- const local = this.localVar(provider.token, kind);
1835
+ const local = this.localVar(isMulti ? `${provider.token}Item` : provider.token, kind);
940
1836
  return { constLine: `const ${local} = ${expr};`, key, expr: local };
941
1837
  }
942
1838
  case "factory": {
1839
+ if (provider.tokenKind === "injection-token" && !provider.useFactoryName) {
1840
+ const tokenIdent = this.imports.add(provider.token, provider.importPath);
1841
+ const local2 = this.localVar(isMulti ? `${provider.token}Item` : provider.token, kind);
1842
+ const constLine = `const ${local2} = typeof ${tokenIdent} === "object" && ${tokenIdent} && "factory" in ${tokenIdent} && typeof (${tokenIdent} as any).factory === "function" ? (${tokenIdent} as any).factory() : undefined;`;
1843
+ return { constLine, key, expr: local2 };
1844
+ }
943
1845
  const factory = this.imports.add(provider.useFactoryName ?? "", provider.importPath);
944
- const args = provider.deps.map((dep) => this.depExpr(dep, kind)).join(", ");
945
- const local = this.localVar(provider.token, kind);
1846
+ const args = provider.deps.map((dep) => this.depExpr(dep, kind, provider.optionalDeps?.includes(dep))).join(", ");
1847
+ const local = this.localVar(isMulti ? provider.useFactoryName ?? `${provider.token}Item` : provider.token, kind);
946
1848
  return { constLine: `const ${local} = ${factory}(${args});`, key, expr: local };
947
1849
  }
948
1850
  case "existing": {
949
- return { key, expr: this.depExpr(provider.useExisting ?? provider.token, kind) };
1851
+ return { key, expr: this.depExpr(provider.useExisting ?? provider.token, kind, provider.optionalDeps?.includes(provider.token)) };
950
1852
  }
951
1853
  }
952
1854
  }
953
1855
  emitController(controller, kind) {
954
1856
  const className = this.imports.add(controller.className, controller.importPath);
955
- const args = controller.deps.map((dep) => this.depExpr(dep, kind)).join(", ");
1857
+ const args = controller.deps.map((dep) => this.depExpr(dep, kind, controller.optionalDeps?.includes(dep))).join(", ");
956
1858
  const key = camelName(controller.className);
957
1859
  const local = this.localVar(controller.className, kind);
958
1860
  return { constLine: `const ${local} = new ${className}(${args});`, key, expr: local };
@@ -972,7 +1874,7 @@ ${indent(item, 2)}`).join(",")}
972
1874
  locals.set(token, local);
973
1875
  return local;
974
1876
  }
975
- depExpr(token, kind) {
1877
+ depExpr(token, kind, isOptional = false) {
976
1878
  if (kind === "request" && isRequestContextToken(token, this.graph.tokenNames))
977
1879
  return "ctx";
978
1880
  if (kind === "job" && isJobContextToken(token, this.graph.tokenNames))
@@ -983,7 +1885,7 @@ ${indent(item, 2)}`).join(",")}
983
1885
  return this.locals[kind].get(token) ?? camelName(token);
984
1886
  }
985
1887
  if (own.kind === "existing" && factoryOfScope(own.scope) === kind) {
986
- return this.depExpr(own.useExisting ?? token, kind);
1888
+ return this.depExpr(own.useExisting ?? token, kind, isOptional);
987
1889
  }
988
1890
  if (kind === "services") {
989
1891
  return `services.${camelName(token)}`;
@@ -998,9 +1900,18 @@ ${indent(item, 2)}`).join(",")}
998
1900
  return `imported.${importName}.${camelName(token)}`;
999
1901
  return `imported.${importName}.${camelName(token)}`;
1000
1902
  }
1903
+ for (const mod of this.graph.modules) {
1904
+ const rootProv = mod.providers.find((p) => p.token === token && p.providedIn === "root");
1905
+ if (rootProv) {
1906
+ return `imported.${mod.name}.${camelName(token)}`;
1907
+ }
1908
+ }
1909
+ if (isOptional && !this.graph.externalTokens.includes(token)) {
1910
+ return "undefined";
1911
+ }
1001
1912
  if (kind === "services")
1002
- return `deps.${camelName(token)}`;
1003
- return `services.${camelName(token)}`;
1913
+ return isOptional ? `(deps.${camelName(token)} ?? undefined)` : `deps.${camelName(token)}`;
1914
+ return isOptional ? `(services.${camelName(token)} ?? undefined)` : `services.${camelName(token)}`;
1004
1915
  }
1005
1916
  }
1006
1917
  function orderProviders(providers) {
@@ -1022,46 +1933,266 @@ function orderProviders(providers) {
1022
1933
  }
1023
1934
  return result;
1024
1935
  }
1025
-
1026
- // src/profiles.ts
1027
- var MODULAR_MONOLITH_RULES = [
1028
- {
1029
- sourceTag: "type:feature",
1030
- bannedDependenciesWithTags: ["type:feature", "type:root", "type:app"]
1031
- },
1032
- {
1033
- sourceTag: "type:root",
1034
- onlyDependOnLibsWithTags: ["type:feature", "type:core", "type:shared", "type:domain"]
1035
- },
1036
- {
1037
- sourceTag: "type:app",
1038
- onlyDependOnLibsWithTags: ["type:feature", "type:core", "type:shared", "type:domain"]
1039
- },
1040
- {
1041
- sourceTag: "type:core",
1042
- bannedDependenciesWithTags: ["type:feature", "type:root", "type:app"]
1043
- },
1044
- {
1045
- sourceTag: "type:shared",
1046
- bannedDependenciesWithTags: ["type:feature", "type:core", "type:root", "type:app"]
1936
+ function renderClient(graph, _options) {
1937
+ const controllerEntries = [];
1938
+ const allRoutes = [];
1939
+ for (const module of graph.modules) {
1940
+ for (const controller of module.controllers) {
1941
+ const controllerKey = camelName(controller.className.replace(/Controller$/, ""));
1942
+ const routeMethods = [];
1943
+ for (const route of controller.routes) {
1944
+ const fullPath = joinRoutePaths(controller.path, route.path);
1945
+ allRoutes.push({
1946
+ method: route.method,
1947
+ path: fullPath,
1948
+ controller: controller.className,
1949
+ handler: route.handler,
1950
+ command: route.command,
1951
+ guards: route.guards,
1952
+ canMatch: route.canMatch,
1953
+ canDeactivate: route.canDeactivate,
1954
+ resolvers: route.resolvers,
1955
+ redirectTo: route.redirectTo,
1956
+ pathMatch: route.pathMatch,
1957
+ paramTransforms: route.paramTransforms,
1958
+ paramDefaults: route.paramDefaults,
1959
+ queryTransforms: route.queryTransforms,
1960
+ queryDefaults: route.queryDefaults,
1961
+ title: route.title,
1962
+ data: route.data
1963
+ });
1964
+ routeMethods.push(`
1965
+ ${route.handler}: (options: {
1966
+ params${route.pathParams && route.pathParams.length > 0 || (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).length > 0 ? "" : "?"}: ${route.pathParams && route.pathParams.length > 0 || (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).length > 0 ? `{ ${(route.pathParams && route.pathParams.length > 0 ? route.pathParams : (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).map((p) => p.slice(1))).map((p) => `${p}: string | number`).join("; ")} }` : "Record<string, string | number>"};
1967
+ query?: Record<string, unknown>;
1968
+ body?: unknown;
1969
+ headers?: Record<string, string>;
1970
+ }${route.pathParams && route.pathParams.length > 0 || (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).length > 0 ? "" : " = {}"}) => request(${JSON.stringify(route.method)}, ${JSON.stringify(fullPath)}, options),`);
1971
+ }
1972
+ controllerEntries.push(`
1973
+ ${controllerKey}: {${routeMethods.join("")}
1974
+ },`);
1975
+ }
1047
1976
  }
1048
- ];
1049
- var ANGULAR_ENTERPRISE_RULES = [
1050
- {
1051
- sourceTag: "type:feature",
1052
- bannedDependenciesWithTags: ["type:feature", "type:root", "type:app"]
1053
- },
1054
- {
1055
- sourceTag: "type:ui",
1056
- bannedDependenciesWithTags: ["type:feature", "type:root", "type:app"]
1057
- },
1058
- {
1059
- sourceTag: "type:data-access",
1060
- bannedDependenciesWithTags: ["type:feature", "type:ui", "type:root", "type:app"]
1061
- },
1062
- {
1063
- sourceTag: "type:util",
1064
- bannedDependenciesWithTags: ["type:feature", "type:ui", "type:data-access", "type:root", "type:app"]
1977
+ return [
1978
+ HEADER,
1979
+ "",
1980
+ "export interface ClientRequestOptions {",
1981
+ " params?: Record<string, string | number>;",
1982
+ " query?: Record<string, unknown>;",
1983
+ " body?: unknown;",
1984
+ " headers?: Record<string, string>;",
1985
+ "}",
1986
+ "",
1987
+ "export type HttpInterceptorFn = (",
1988
+ " req: { method: string; url: string; headers: Record<string, string>; body?: unknown },",
1989
+ " next: (req: { method: string; url: string; headers: Record<string, string>; body?: unknown }) => Promise<Response>,",
1990
+ ") => Promise<Response>;",
1991
+ "",
1992
+ "export interface ApiClientConfig {",
1993
+ " baseUrl?: string;",
1994
+ " fetch?: typeof fetch;",
1995
+ " headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);",
1996
+ " interceptors?: HttpInterceptorFn[];",
1997
+ "}",
1998
+ "",
1999
+ "export const API_ROUTES = " + JSON.stringify(allRoutes, null, 2) + " as const;",
2000
+ "",
2001
+ "export type AppRoutePath = typeof API_ROUTES[number]['path'];",
2002
+ "",
2003
+ "/**",
2004
+ " * Type-safe URL builder replacing route path parameters and appending query parameters.",
2005
+ " */",
2006
+ "export function buildRouteUrl(",
2007
+ " path: string,",
2008
+ " params?: Record<string, string | number>,",
2009
+ " query?: Record<string, unknown>,",
2010
+ "): string {",
2011
+ " let url = path;",
2012
+ " if (params) {",
2013
+ " for (const [key, value] of Object.entries(params)) {",
2014
+ " url = url.replace(`:${key}`, encodeURIComponent(String(value)));",
2015
+ " }",
2016
+ " }",
2017
+ " if (query) {",
2018
+ " const searchParams = new URLSearchParams();",
2019
+ " for (const [k, v] of Object.entries(query)) {",
2020
+ " if (v !== undefined && v !== null) searchParams.set(k, String(v));",
2021
+ " }",
2022
+ " const qs = searchParams.toString();",
2023
+ ' if (qs) url += (url.includes("?") ? "&" : "?") + qs;',
2024
+ " }",
2025
+ " return url;",
2026
+ "}",
2027
+ "",
2028
+ "export function createApiClient(config: ApiClientConfig = {}) {",
2029
+ " const fetcher = config.fetch ?? globalThis.fetch.bind(globalThis);",
2030
+ ' const baseUrl = (config.baseUrl ?? "").replace(/\\/+$/, "");',
2031
+ "",
2032
+ " async function request<T = unknown>(",
2033
+ " method: string,",
2034
+ " path: string,",
2035
+ " options: ClientRequestOptions = {},",
2036
+ " ): Promise<T> {",
2037
+ " let url = `${baseUrl}${path}`;",
2038
+ " if (options.params) {",
2039
+ " for (const [key, value] of Object.entries(options.params)) {",
2040
+ " url = url.replace(`:${key}`, encodeURIComponent(String(value)));",
2041
+ " }",
2042
+ " }",
2043
+ " if (options.query) {",
2044
+ " const searchParams = new URLSearchParams();",
2045
+ " for (const [k, v] of Object.entries(options.query)) {",
2046
+ " if (v !== undefined && v !== null) searchParams.set(k, String(v));",
2047
+ " }",
2048
+ " const qs = searchParams.toString();",
2049
+ ' if (qs) url += (url.includes("?") ? "&" : "?") + qs;',
2050
+ " }",
2051
+ ' const customHeaders = typeof config.headers === "function" ? await config.headers() : config.headers;',
2052
+ " const headers: Record<string, string> = {",
2053
+ ' "content-type": "application/json",',
2054
+ " ...customHeaders,",
2055
+ " ...options.headers,",
2056
+ " };",
2057
+ " const interceptors = config.interceptors ?? [];",
2058
+ " const executeChain = (",
2059
+ " index: number,",
2060
+ " reqPayload: { method: string; url: string; headers: Record<string, string>; body?: unknown },",
2061
+ " ): Promise<Response> => {",
2062
+ " if (index < interceptors.length) {",
2063
+ " return interceptors[index](reqPayload, (nextPayload) => executeChain(index + 1, nextPayload));",
2064
+ " }",
2065
+ " return fetcher(reqPayload.url, {",
2066
+ " method: reqPayload.method,",
2067
+ " headers: reqPayload.headers,",
2068
+ " body: reqPayload.body !== undefined ? JSON.stringify(reqPayload.body) : undefined,",
2069
+ " });",
2070
+ " };",
2071
+ " const response = await executeChain(0, { method, url, headers, body: options.body });",
2072
+ " if (!response.ok) {",
2073
+ " const errBody = await response.text();",
2074
+ " throw new Error(`API request failed: ${method} ${path} -> ${response.status} ${errBody}`);",
2075
+ " }",
2076
+ ' const contentType = response.headers?.get("content-type") ?? "";',
2077
+ ' if (contentType.includes("application/json")) {',
2078
+ " return response.json() as Promise<T>;",
2079
+ " }",
2080
+ " return response.text() as Promise<T>;",
2081
+ " }",
2082
+ "",
2083
+ " return {",
2084
+ " request,",
2085
+ " buildRouteUrl,",
2086
+ " routes: API_ROUTES,",
2087
+ ...controllerEntries,
2088
+ " };",
2089
+ "}",
2090
+ "",
2091
+ "export type ApiClient = ReturnType<typeof createApiClient>;",
2092
+ ""
2093
+ ].join(`
2094
+ `);
2095
+ }
2096
+ function renderPermissions(graph) {
2097
+ const permissions = new Set;
2098
+ const bindings = [];
2099
+ for (const module of graph.modules) {
2100
+ for (const command of module.commands) {
2101
+ if (command.permission)
2102
+ permissions.add(command.permission);
2103
+ }
2104
+ for (const controller of module.controllers) {
2105
+ for (const route of controller.routes) {
2106
+ let perm;
2107
+ if (route.command) {
2108
+ const cmd = module.commands.find((c) => c.className === route.command);
2109
+ perm = cmd?.permission;
2110
+ }
2111
+ if (perm)
2112
+ permissions.add(perm);
2113
+ bindings.push({
2114
+ method: route.method,
2115
+ path: joinRoutePaths(controller.path, route.path),
2116
+ controller: controller.className,
2117
+ handler: route.handler,
2118
+ command: route.command,
2119
+ permission: perm
2120
+ });
2121
+ }
2122
+ }
2123
+ }
2124
+ const sortedPerms = [...permissions].sort();
2125
+ const enumEntries = sortedPerms.map((perm) => {
2126
+ const key = pascalName(perm.replace(/[^A-Za-z0-9]+/g, " "));
2127
+ return ` ${key}: ${JSON.stringify(perm)},`;
2128
+ });
2129
+ return [
2130
+ HEADER,
2131
+ "",
2132
+ "export const AppPermissions = {",
2133
+ ...enumEntries,
2134
+ "} as const;",
2135
+ "",
2136
+ "export type AppPermission = (typeof AppPermissions)[keyof typeof AppPermissions];",
2137
+ "",
2138
+ "export interface RoutePermissionBinding {",
2139
+ " method: string;",
2140
+ " path: string;",
2141
+ " controller: string;",
2142
+ " handler: string;",
2143
+ " command?: string;",
2144
+ " permission?: string;",
2145
+ "}",
2146
+ "",
2147
+ "export const RoutePermissions: RoutePermissionBinding[] = " + JSON.stringify(bindings, null, 2) + ";",
2148
+ "",
2149
+ "export function hasPermission(granted: string[], required: AppPermission | string): boolean {",
2150
+ ' return granted.includes("*") || granted.includes(required);',
2151
+ "}",
2152
+ ""
2153
+ ].join(`
2154
+ `);
2155
+ }
2156
+
2157
+ // src/profiles.ts
2158
+ var MODULAR_MONOLITH_RULES = [
2159
+ {
2160
+ sourceTag: "type:feature",
2161
+ bannedDependenciesWithTags: ["type:feature", "type:root", "type:app"]
2162
+ },
2163
+ {
2164
+ sourceTag: "type:root",
2165
+ onlyDependOnLibsWithTags: ["type:feature", "type:core", "type:shared", "type:domain"]
2166
+ },
2167
+ {
2168
+ sourceTag: "type:app",
2169
+ onlyDependOnLibsWithTags: ["type:feature", "type:core", "type:shared", "type:domain"]
2170
+ },
2171
+ {
2172
+ sourceTag: "type:core",
2173
+ bannedDependenciesWithTags: ["type:feature", "type:root", "type:app"]
2174
+ },
2175
+ {
2176
+ sourceTag: "type:shared",
2177
+ bannedDependenciesWithTags: ["type:feature", "type:core", "type:root", "type:app"]
2178
+ }
2179
+ ];
2180
+ var ANGULAR_ENTERPRISE_RULES = [
2181
+ {
2182
+ sourceTag: "type:feature",
2183
+ bannedDependenciesWithTags: ["type:feature", "type:root", "type:app"]
2184
+ },
2185
+ {
2186
+ sourceTag: "type:ui",
2187
+ bannedDependenciesWithTags: ["type:feature", "type:root", "type:app"]
2188
+ },
2189
+ {
2190
+ sourceTag: "type:data-access",
2191
+ bannedDependenciesWithTags: ["type:feature", "type:ui", "type:root", "type:app"]
2192
+ },
2193
+ {
2194
+ sourceTag: "type:util",
2195
+ bannedDependenciesWithTags: ["type:feature", "type:ui", "type:data-access", "type:root", "type:app"]
1065
2196
  },
1066
2197
  {
1067
2198
  sourceTag: "type:shared",
@@ -1196,6 +2327,53 @@ var SCOPE_LIFETIME_RANK = {
1196
2327
  request: 1,
1197
2328
  job: 1
1198
2329
  };
2330
+ var COMPILER_DIAGNOSTIC_CODES = {
2331
+ "circular-dependency": { code: "SC1001", docsUrl: "https://supacloud.dev/errors/SC1001" },
2332
+ "scope-violation": { code: "SC1002", docsUrl: "https://supacloud.dev/errors/SC1002" },
2333
+ "module-boundary-violation": { code: "SC1003", docsUrl: "https://supacloud.dev/errors/SC1003" },
2334
+ "module-boundary": { code: "SC1003", docsUrl: "https://supacloud.dev/errors/SC1003" },
2335
+ "circular-module-import": { code: "SC1004", docsUrl: "https://supacloud.dev/errors/SC1004" },
2336
+ "orphan-module": { code: "SC1005", docsUrl: "https://supacloud.dev/errors/SC1005" },
2337
+ "invalid-boundary-preset": { code: "SC1006", docsUrl: "https://supacloud.dev/errors/SC1006" },
2338
+ "circular-existing-alias": { code: "SC1007", docsUrl: "https://supacloud.dev/errors/SC1007" },
2339
+ "missing-deps": { code: "SC2001", docsUrl: "https://supacloud.dev/errors/SC2001" },
2340
+ "unresolved-token": { code: "SC2001", docsUrl: "https://supacloud.dev/errors/SC2001" },
2341
+ "duplicate-token": { code: "SC2002", docsUrl: "https://supacloud.dev/errors/SC2002" },
2342
+ "duplicate-module": { code: "SC2002", docsUrl: "https://supacloud.dev/errors/SC2002" },
2343
+ "disallow-controller-direct-db": { code: "SC2003", docsUrl: "https://supacloud.dev/errors/SC2003" },
2344
+ "self-dependency-violation": { code: "SC2004", docsUrl: "https://supacloud.dev/errors/SC2004" },
2345
+ "skip-self-dependency-violation": { code: "SC2005", docsUrl: "https://supacloud.dev/errors/SC2005" },
2346
+ "export-unprovided-token": { code: "SC2006", docsUrl: "https://supacloud.dev/errors/SC2006" },
2347
+ "unresolved-alias-target": { code: "SC2007", docsUrl: "https://supacloud.dev/errors/SC2007" },
2348
+ "self-referencing-alias": { code: "SC2008", docsUrl: "https://supacloud.dev/errors/SC2008" },
2349
+ "shadowed-route": { code: "SC3001", docsUrl: "https://supacloud.dev/errors/SC3001" },
2350
+ "unresolved-route-redirect": { code: "SC3002", docsUrl: "https://supacloud.dev/errors/SC3002" },
2351
+ "circular-route-redirect": { code: "SC3003", docsUrl: "https://supacloud.dev/errors/SC3003" },
2352
+ "invalid-http-method-body": { code: "SC3004", docsUrl: "https://supacloud.dev/errors/SC3004" },
2353
+ "unmatched-route-parameter": { code: "SC3005", docsUrl: "https://supacloud.dev/errors/SC3005" },
2354
+ "missing-route-parameter-binding": { code: "SC3006", docsUrl: "https://supacloud.dev/errors/SC3006" },
2355
+ "duplicate-route": { code: "SC3007", docsUrl: "https://supacloud.dev/errors/SC3007" },
2356
+ "missing-body-schema": { code: "SC3008", docsUrl: "https://supacloud.dev/errors/SC3008" },
2357
+ "unused-route-schema": { code: "SC3009", docsUrl: "https://supacloud.dev/errors/SC3009" },
2358
+ "malformed-route-path": { code: "SC3010", docsUrl: "https://supacloud.dev/errors/SC3010" },
2359
+ "duplicate-path-param": { code: "SC3011", docsUrl: "https://supacloud.dev/errors/SC3011" },
2360
+ "wildcard-not-trailing": { code: "SC3012", docsUrl: "https://supacloud.dev/errors/SC3012" },
2361
+ "invalid-query-param-name": { code: "SC3013", docsUrl: "https://supacloud.dev/errors/SC3013" },
2362
+ "unmatched-path-param-decorator": { code: "SC3014", docsUrl: "https://supacloud.dev/errors/SC3014" },
2363
+ "invalid-query-default-type": { code: "SC3015", docsUrl: "https://supacloud.dev/errors/SC3015" },
2364
+ "disallowed-body-on-get-delete": { code: "SC3016", docsUrl: "https://supacloud.dev/errors/SC3016" },
2365
+ "duplicate-query-param-binding": { code: "SC3017", docsUrl: "https://supacloud.dev/errors/SC3017" },
2366
+ "conflicting-route-method": { code: "SC3018", docsUrl: "https://supacloud.dev/errors/SC3018" },
2367
+ "missing-param-colon": { code: "SC3019", docsUrl: "https://supacloud.dev/errors/SC3019" },
2368
+ "missing-token-factory": { code: "SC2009", docsUrl: "https://supacloud.dev/errors/SC2009" },
2369
+ "command-missing-permission": { code: "SC4001", docsUrl: "https://supacloud.dev/errors/SC4001" },
2370
+ "duplicate-command": { code: "SC4002", docsUrl: "https://supacloud.dev/errors/SC4002" },
2371
+ "route-command-unresolved": { code: "SC4003", docsUrl: "https://supacloud.dev/errors/SC4003" },
2372
+ "command-governance-unsupported": { code: "SC4004", docsUrl: "https://supacloud.dev/errors/SC4004" },
2373
+ "route-command-binding-disabled": { code: "SC4005", docsUrl: "https://supacloud.dev/errors/SC4005" },
2374
+ "command-transaction-readonly": { code: "SC4006", docsUrl: "https://supacloud.dev/errors/SC4006" },
2375
+ "unused-root-provider": { code: "SC5001", docsUrl: "https://supacloud.dev/errors/SC5001" }
2376
+ };
1199
2377
  function validateGraph(graph, options = false) {
1200
2378
  const strict = typeof options === "boolean" ? options : options.strict ?? false;
1201
2379
  const diagnostics = [];
@@ -1207,12 +2385,15 @@ function validateGraph(graph, options = false) {
1207
2385
  rules: options.moduleBoundaries
1208
2386
  });
1209
2387
  } catch (err) {
2388
+ const meta = COMPILER_DIAGNOSTIC_CODES["invalid-boundary-preset"];
1210
2389
  diagnostics.push({
1211
2390
  severity: "error",
1212
2391
  code: "invalid-boundary-preset",
1213
2392
  message: err instanceof Error ? err.message : String(err),
1214
2393
  file: graph.modules[0]?.file,
1215
- line: graph.modules[0]?.line
2394
+ line: graph.modules[0]?.line,
2395
+ errorCode: meta?.code,
2396
+ docsUrl: meta?.docsUrl
1216
2397
  });
1217
2398
  }
1218
2399
  }
@@ -1236,17 +2417,43 @@ function validateGraph(graph, options = false) {
1236
2417
  if (provider)
1237
2418
  return { module: imported, provider };
1238
2419
  }
2420
+ for (const mod of graph.modules) {
2421
+ const rootProvider = mod.providers.find((p) => p.token === token && p.providedIn === "root");
2422
+ if (rootProvider)
2423
+ return { module: mod, provider: rootProvider };
2424
+ }
1239
2425
  return;
1240
2426
  }
1241
- const error = (code, message, file, line) => {
1242
- diagnostics.push({ severity: "error", code, message, file, line });
2427
+ const error = (code, message, file, line, suggestion) => {
2428
+ const meta = COMPILER_DIAGNOSTIC_CODES[code];
2429
+ diagnostics.push({
2430
+ severity: "error",
2431
+ code,
2432
+ message,
2433
+ file,
2434
+ line,
2435
+ suggestion,
2436
+ errorCode: meta?.code,
2437
+ docsUrl: meta?.docsUrl
2438
+ });
1243
2439
  };
1244
- const warn2 = (code, message, file, line) => {
1245
- diagnostics.push({ severity: strict ? "error" : "warn", code, message, file, line });
2440
+ const warn2 = (code, message, file, line, suggestion) => {
2441
+ const meta = COMPILER_DIAGNOSTIC_CODES[code];
2442
+ diagnostics.push({
2443
+ severity: strict ? "error" : "warn",
2444
+ code,
2445
+ message,
2446
+ file,
2447
+ line,
2448
+ suggestion,
2449
+ errorCode: meta?.code,
2450
+ docsUrl: meta?.docsUrl
2451
+ });
1246
2452
  };
1247
2453
  const modulesByName = new Map;
1248
2454
  const commandsByName = new Map;
1249
2455
  const routesByKey = new Map;
2456
+ const declaredRoutes = [];
1250
2457
  for (const module of graph.modules) {
1251
2458
  const previousModule = modulesByName.get(module.name);
1252
2459
  if (previousModule) {
@@ -1266,20 +2473,128 @@ function validateGraph(graph, options = false) {
1266
2473
  for (const module of graph.modules) {
1267
2474
  for (const controller of module.controllers) {
1268
2475
  for (const route of controller.routes) {
1269
- const fullPath = joinRoutePaths(controller.path, route.path);
2476
+ if (route.path.includes("//") || controller.path.includes("//")) {
2477
+ error("malformed-route-path", `Route ${route.method} ${route.path} has malformed path: contains consecutive slashes '//'.`, controller.file, undefined, "Remove duplicate consecutive slashes from the route path.");
2478
+ } else if (/(^|\/):(\/|$)/.test(route.path) || route.path.endsWith("/:")) {
2479
+ error("malformed-route-path", `Route ${route.method} ${route.path} has malformed path: parameter colon ':' is missing a parameter identifier.`, controller.file, undefined, "Specify a valid parameter name following the colon (e.g. ':id').");
2480
+ } else if (route.path.includes("?") || route.path.includes("#")) {
2481
+ error("malformed-route-path", `Route ${route.method} ${route.path} has malformed path: contains invalid URL query '?' or fragment '#' character.`, controller.file, undefined, "Declare query parameters using @Query() decorators instead of in the route path.");
2482
+ }
2483
+ if (route.path.includes("**")) {
2484
+ const segments = route.path.split("/").filter(Boolean);
2485
+ const wildcardIdx = segments.indexOf("**");
2486
+ if (wildcardIdx !== -1 && wildcardIdx !== segments.length - 1) {
2487
+ error("wildcard-not-trailing", `Route ${route.method} '${route.path}' defines wildcard '**' in the middle of the path. In Angular Router semantics, wildcard '**' must be the trailing segment.`, controller.file, undefined, `Move the wildcard '**' to the end of the route path, e.g. '${segments.slice(0, wildcardIdx).join("/")}/**'.`);
2488
+ }
2489
+ }
2490
+ const fullPath = joinRoutePaths2(controller.path, route.path);
2491
+ const rawFullPath = joinRawRoutePaths(controller.path, route.path);
1270
2492
  const key = `${route.method} ${fullPath}`;
2493
+ const openApiMatch = route.path.match(/\{([a-zA-Z0-9_]+)\}/);
2494
+ if (openApiMatch) {
2495
+ error("missing-param-colon", `Route path '${route.path}' in '${controller.className}.${route.handler}' uses OpenAPI-style '{${openApiMatch[1]}}'. SupaCloud routes require Express/Angular-style ':${openApiMatch[1]}'.`, controller.file, undefined, `Replace '{${openApiMatch[1]}}' with ':${openApiMatch[1]}'.`);
2496
+ }
1271
2497
  const previous = routesByKey.get(key);
1272
2498
  if (previous) {
1273
2499
  error("duplicate-route", `路由 ${key} 重复(首次声明于模块 ${previous.module.name} 的 ${previous.controller.className})`, controller.file);
1274
2500
  } else {
1275
2501
  routesByKey.set(key, { module, controller });
1276
2502
  }
2503
+ for (const prev of declaredRoutes) {
2504
+ if (prev.method === route.method && isRouteShadowed(prev.rawFullPath, rawFullPath)) {
2505
+ warn2("shadowed-route", `Route ${route.method} ${rawFullPath} (${controller.className}.${route.handler}) is shadowed by earlier parameterized route ${prev.method} ${prev.rawFullPath} (${prev.controller.className}.${prev.handler}) and will never be matched.`, controller.file, undefined, `Move specific route '${route.path}' before parameterized route '${prev.path}'.`);
2506
+ }
2507
+ }
2508
+ declaredRoutes.push({ method: route.method, path: route.path, fullPath, rawFullPath, controller, module, handler: route.handler, redirectTo: route.redirectTo });
1277
2509
  if (route.command && !module.commands.some((command) => command.className === route.command)) {
1278
2510
  error("route-command-unresolved", `路由 ${key} 绑定的 command 类 ${route.command} 未在模块 ${module.name} 声明`, controller.file);
1279
2511
  }
2512
+ if ((route.method === "GET" || route.method === "HEAD") && route.command) {
2513
+ const boundCommand = module.commands.find((c) => c.className === route.command);
2514
+ if (boundCommand && boundCommand.transaction === "required") {
2515
+ error("command-transaction-readonly", `GET route '${route.path}' in '${controller.className}.${route.handler}' binds mutating command '${route.command}' with transaction: 'required'. Mutating transactions are not permitted on read-only HTTP GET requests.`, controller.file, undefined, `Use POST, PUT, or PATCH for mutating command routes, or set transaction: 'none'.`);
2516
+ }
2517
+ }
1280
2518
  if (typeof options === "object" && options.allowRouteCommandBindings === false && route.command) {
1281
2519
  error("route-command-binding-disallowed", `Route ${key} binds command ${route.command}, but route-level command bindings are disabled by policy. Use an application service (${controller.className}.${route.handler}, ${controller.file}).`, controller.file);
1282
2520
  }
2521
+ if (route.redirectTo) {
2522
+ const target = route.redirectTo.replace(/\/+$/, "");
2523
+ const current = fullPath.replace(/\/+$/, "");
2524
+ if (target === current || target === route.path.replace(/\/+$/, "")) {
2525
+ error("circular-route-redirect", `Route ${key} defines circular redirectTo '${route.redirectTo}'`, controller.file);
2526
+ }
2527
+ }
2528
+ const pathParams = route.pathParams ?? [];
2529
+ const seenParams = new Set;
2530
+ for (const p of pathParams) {
2531
+ if (seenParams.has(p)) {
2532
+ error("duplicate-path-param", `Route ${route.method} '${route.path}' defines duplicate path parameter ':${p}'. Each parameter in a route path must be unique.`, controller.file, undefined, `Rename the duplicate parameter ':${p}' to a unique name (e.g. ':${p}Id').`);
2533
+ }
2534
+ seenParams.add(p);
2535
+ }
2536
+ const paramBindings = route.paramBindings ?? [];
2537
+ for (const binding of paramBindings) {
2538
+ if (!binding || binding.trim().length === 0) {
2539
+ error("unmatched-path-param-decorator", `Controller ${controller.className} handler ${route.handler} specifies an empty @Param() parameter binding.`, controller.file, undefined, `Specify a non-empty path parameter name matching a segment in route path '${route.path}'.`);
2540
+ } else if (/[#?&=/\s]/.test(binding)) {
2541
+ error("unmatched-path-param-decorator", `Controller ${controller.className} handler ${route.handler} specifies invalid @Param('${binding}') with illegal character. Path parameter names cannot contain '#', '?', '&', '=', '/', or whitespace.`, controller.file, undefined, `Rename path parameter binding '${binding}' to a valid identifier matching route path segment.`);
2542
+ } else if (!pathParams.includes(binding)) {
2543
+ const suggestion = findClosestMatch(binding, pathParams);
2544
+ error("unmatched-path-param", `Controller ${controller.className} handler ${route.handler} binds @Param('${binding}'), but route path '${route.path}' does not define parameter ':${binding}'.`, controller.file, undefined, suggestion ? `Did you mean @Param('${suggestion}')?` : undefined);
2545
+ }
2546
+ }
2547
+ if (paramBindings.length > 0) {
2548
+ for (const param of pathParams) {
2549
+ if (!paramBindings.includes(param)) {
2550
+ warn2("missing-path-param", `Route path '${route.path}' defines parameter ':${param}', but handler ${controller.className}.${route.handler} does not bind it with @Param('${param}').`, controller.file, undefined, `Add @Param('${param}') to ${route.handler} arguments.`);
2551
+ }
2552
+ }
2553
+ }
2554
+ const queryBindings = route.queryBindings ?? [];
2555
+ const seenQueries = new Set;
2556
+ for (const q of queryBindings) {
2557
+ if (!q || q.trim().length === 0) {
2558
+ error("invalid-query-param-name", `Controller ${controller.className} handler ${route.handler} specifies an empty @Query() parameter binding.`, controller.file, undefined, `Specify a non-empty parameter name in @Query('paramName').`);
2559
+ } else if (/[#?&=/\s]/.test(q)) {
2560
+ error("invalid-query-param-name", `Controller ${controller.className} handler ${route.handler} specifies invalid @Query('${q}') with illegal character. Query parameter names cannot contain '#', '?', '&', '=', '/', or whitespace.`, controller.file, undefined, `Rename query parameter '${q}' to a valid identifier name without reserved characters.`);
2561
+ } else if (seenQueries.has(q)) {
2562
+ error("duplicate-query-param-binding", `Controller ${controller.className} handler ${route.handler} specifies duplicate @Query('${q}') parameter binding. Each query parameter should only be bound once per handler.`, controller.file, undefined, `Remove or rename the duplicate @Query('${q}') parameter binding in ${route.handler}.`);
2563
+ }
2564
+ seenQueries.add(q);
2565
+ }
2566
+ if (route.queryDefaults && route.queryTransforms) {
2567
+ for (const [paramName, defVal] of Object.entries(route.queryDefaults)) {
2568
+ const transform = route.queryTransforms[paramName];
2569
+ if (transform === "number" && typeof defVal !== "number") {
2570
+ error("invalid-query-default-type", `Controller ${controller.className} handler ${route.handler} specifies transform 'number' for @Query('${paramName}'), but default value '${String(defVal)}' is not a number.`, controller.file, undefined, `Provide a numeric default (e.g. default: 0) or change transform type to 'string'.`);
2571
+ } else if (transform === "boolean" && typeof defVal !== "boolean") {
2572
+ error("invalid-query-default-type", `Controller ${controller.className} handler ${route.handler} specifies transform 'boolean' for @Query('${paramName}'), but default value '${String(defVal)}' is not a boolean.`, controller.file, undefined, `Provide a boolean default (e.g. default: false) or change transform type.`);
2573
+ }
2574
+ }
2575
+ }
2576
+ if ((route.method === "GET" || route.method === "HEAD" || route.method === "OPTIONS" || route.method === "DELETE") && (route.hasBodyBinding || route.body)) {
2577
+ error("disallowed-body-on-get-delete", `Route handler ${controller.className}.${route.handler} binds @Body() or declares body schema on HTTP ${route.method} route '${route.path}'. Request bodies are not supported on ${route.method} requests.`, controller.file, undefined, `Use POST, PUT, or PATCH for routes accepting a request body, or bind parameters via @Query() / @Param().`);
2578
+ }
2579
+ if (route.hasBodyBinding && (route.method === "GET" || route.method === "HEAD" || route.method === "OPTIONS")) {
2580
+ error("invalid-body-binding", `Route handler ${controller.className}.${route.handler} binds @Body() on HTTP ${route.method} route '${route.path}'. Request bodies are not supported on ${route.method} requests.`, controller.file, undefined, `Use POST, PUT, or PATCH for routes accepting a request body, or bind parameters via @Query() / @Param().`);
2581
+ } else if (route.hasBodyBinding && !route.body) {
2582
+ warn2("missing-body-schema", `Route handler ${controller.className}.${route.handler} binds @Body() on route '${route.path}', but route definition does not specify a body validation schema.`, controller.file, undefined, `Add schema to route options (e.g. body: Schema) for compile-time and runtime validation.`);
2583
+ } else if (route.body && !route.hasBodyBinding && !route.command) {
2584
+ warn2("unused-route-schema", `Route '${route.path}' defines body schema '${route.body}', but handler ${controller.className}.${route.handler} does not bind @Body().`, controller.file, undefined, `Bind parameter with @Body() in ${controller.className}.${route.handler} or remove unused body schema option.`);
2585
+ }
2586
+ }
2587
+ const handlerMethodMap = new Map;
2588
+ for (const route of controller.routes) {
2589
+ const methods = handlerMethodMap.get(route.handler) ?? [];
2590
+ methods.push(route.method);
2591
+ handlerMethodMap.set(route.handler, methods);
2592
+ }
2593
+ for (const [handler, methods] of handlerMethodMap.entries()) {
2594
+ const uniqueMethods = Array.from(new Set(methods));
2595
+ if (uniqueMethods.length > 1) {
2596
+ warn2("conflicting-route-method", `Controller ${controller.className} handler '${handler}' is mapped to multiple HTTP methods: ${uniqueMethods.join(", ")}.`, controller.file, undefined, `Separate distinct HTTP methods into separate controller handlers.`);
2597
+ }
1283
2598
  }
1284
2599
  if (typeof options === "object" && options.disallowControllerDirectDb) {
1285
2600
  for (const dep of controller.deps) {
@@ -1289,6 +2604,73 @@ function validateGraph(graph, options = false) {
1289
2604
  }
1290
2605
  }
1291
2606
  }
2607
+ if (controller.selfDeps && controller.selfDeps.length > 0) {
2608
+ for (const dep of controller.selfDeps) {
2609
+ const own = module.providers.find((p) => p.token === dep);
2610
+ if (!own) {
2611
+ error("self-resolution-failed", `模块 ${module.name} 的 controller ${controller.className} 参数标记了 @Self(),但 ${dep} 未在当前模块内部提供`, controller.file, undefined, `Provide '${dep}' in module '${module.name}' or remove @Self().`);
2612
+ }
2613
+ }
2614
+ }
2615
+ if (controller.skipSelfDeps && controller.skipSelfDeps.length > 0) {
2616
+ for (const dep of controller.skipSelfDeps) {
2617
+ const own = module.providers.find((p) => p.token === dep);
2618
+ if (own) {
2619
+ error("skip-self-resolution-failed", `模块 ${module.name} 的 controller ${controller.className} 参数标记了 @SkipSelf(),但 ${dep} 在当前模块内部声明了 provider`, controller.file, undefined, `Remove '${dep}' from module '${module.name}' providers or remove @SkipSelf().`);
2620
+ }
2621
+ }
2622
+ }
2623
+ }
2624
+ }
2625
+ const allTargetPaths = declaredRoutes.map((r) => r.rawFullPath);
2626
+ for (const item of declaredRoutes) {
2627
+ if (item.redirectTo) {
2628
+ const target = item.redirectTo;
2629
+ if (target.startsWith("/") && !target.startsWith("//")) {
2630
+ const normalizedTarget = target.replace(/\/+$/, "") || "/";
2631
+ const matchesTarget = declaredRoutes.some((candidate) => {
2632
+ if (candidate.rawFullPath === normalizedTarget)
2633
+ return true;
2634
+ return routeMatchesTarget(candidate.rawFullPath, normalizedTarget);
2635
+ });
2636
+ if (!matchesTarget) {
2637
+ const suggestion = findClosestMatch(normalizedTarget, allTargetPaths);
2638
+ warn2("unresolved-route-redirect", `Route ${item.method} ${item.rawFullPath} (${item.controller.className}.${item.handler}) redirects to '${target}', but no matching route was found in the application graph.`, item.controller.file, undefined, suggestion ? `Did you mean '${suggestion}'?` : undefined);
2639
+ }
2640
+ }
2641
+ }
2642
+ }
2643
+ const routeByRawPath = new Map;
2644
+ for (const item of declaredRoutes) {
2645
+ if (!routeByRawPath.has(item.rawFullPath)) {
2646
+ routeByRawPath.set(item.rawFullPath, item);
2647
+ }
2648
+ }
2649
+ const reportedRedirectCycles = new Set;
2650
+ for (const item of declaredRoutes) {
2651
+ if (item.redirectTo) {
2652
+ const chain = [item.rawFullPath];
2653
+ let curr = item;
2654
+ while (curr && curr.redirectTo) {
2655
+ const target = curr.redirectTo.replace(/\/+$/, "") || "/";
2656
+ if (chain.includes(target)) {
2657
+ const cycle = [...chain.slice(chain.indexOf(target)), target];
2658
+ if (cycle.length > 2) {
2659
+ const cycleKey = [...cycle].sort().join("|");
2660
+ if (!reportedRedirectCycles.has(cycleKey)) {
2661
+ reportedRedirectCycles.add(cycleKey);
2662
+ const meta = COMPILER_DIAGNOSTIC_CODES["circular-route-redirect"];
2663
+ error("circular-route-redirect", `Route redirect chain forms a cycle: ${cycle.join(" -> ")}`, item.controller.file, undefined, "Break the redirect loop by terminating at a concrete non-redirect route.");
2664
+ }
2665
+ }
2666
+ break;
2667
+ }
2668
+ chain.push(target);
2669
+ const next = routeByRawPath.get(target);
2670
+ if (!next || !next.redirectTo)
2671
+ break;
2672
+ curr = next;
2673
+ }
1292
2674
  }
1293
2675
  }
1294
2676
  for (const module of graph.modules) {
@@ -1296,33 +2678,58 @@ function validateGraph(graph, options = false) {
1296
2678
  for (const provider of module.providers) {
1297
2679
  const first = seen.get(provider.token);
1298
2680
  if (first) {
1299
- error("duplicate-token", `模块 ${module.name} 重复注册 token ${provider.token}(首次注册于 ${first.file}:${first.line})`, provider.file, provider.line);
2681
+ if (first.multi && provider.multi) {
2682
+ continue;
2683
+ }
2684
+ error("duplicate-token", `模块 ${module.name} 重复注册 token ${provider.token}(首次注册于 ${first.file}:${first.line})`, provider.file, provider.line, "If multiple providers are intended for this token, specify 'multi: true' on each provider definition (Angular multi-providers pattern).");
1300
2685
  } else {
1301
2686
  seen.set(provider.token, provider);
1302
2687
  }
1303
2688
  }
1304
2689
  for (const provider of module.providers) {
2690
+ if (provider.selfDeps && provider.selfDeps.length > 0) {
2691
+ for (const dep of provider.selfDeps) {
2692
+ const own = module.providers.find((p) => p.token === dep);
2693
+ if (!own) {
2694
+ error("self-resolution-failed", `模块 ${module.name} 的 provider ${provider.token} 参数标记了 @Self(),但 ${dep} 未在当前模块内部提供`, provider.file, provider.line, `Provide '${dep}' in module '${module.name}' or remove @Self().`);
2695
+ }
2696
+ }
2697
+ }
2698
+ if (provider.skipSelfDeps && provider.skipSelfDeps.length > 0) {
2699
+ for (const dep of provider.skipSelfDeps) {
2700
+ const own = module.providers.find((p) => p.token === dep);
2701
+ if (own) {
2702
+ error("skip-self-resolution-failed", `模块 ${module.name} 的 provider ${provider.token} 参数标记了 @SkipSelf(),但 ${dep} 在当前模块内部声明了 provider`, provider.file, provider.line, `Remove '${dep}' from module '${module.name}' providers or remove @SkipSelf().`);
2703
+ }
2704
+ }
2705
+ }
1305
2706
  for (const dep of provider.deps) {
2707
+ const isOptional = provider.optionalDeps?.includes(dep);
1306
2708
  const resolved = resolveDep(module, dep);
1307
2709
  if (!resolved) {
2710
+ if (isOptional) {
2711
+ continue;
2712
+ }
1308
2713
  if (!graph.externalTokens.includes(dep)) {
1309
2714
  if (globalProviders.has(dep)) {
1310
2715
  const owner = globalProviders.get(dep);
1311
- error("module-boundary", `模块 ${module.name} 的 provider ${provider.token} 依赖 ${dep},该 token 由模块 ${owner.module.name} 提供但未被 import`, provider.file, provider.line);
2716
+ error("module-boundary", `模块 ${module.name} 的 provider ${provider.token} 依赖 ${dep},该 token 由模块 ${owner.module.name} 提供但未被 import`, provider.file, provider.line, `Import module '${owner.module.name}' in '${module.name}', add '${dep}' to '${owner.module.name}' exports, or mark @Injectable({ providedIn: 'root' }).`);
2717
+ } else if (dep.includes("TOKEN") || dep.endsWith("Token") || dep.length > 2 && dep === dep.toUpperCase()) {
2718
+ error("missing-token-factory", `InjectionToken '${dep}' referenced by provider '${provider.token}' has no provider in module '${module.name}' and no default factory function.`, provider.file, provider.line, `Provide '${dep}' in @Module({ providers: [...] }) or declare it with new InjectionToken('${dep}', { factory: () => ... }).`);
1312
2719
  } else {
1313
- error("unresolved-token", `模块 ${module.name} 的 provider ${provider.token} 依赖的 token ${dep} 无法解析`, provider.file, provider.line);
2720
+ error("unresolved-token", `模块 ${module.name} 的 provider ${provider.token} 依赖的 token ${dep} 无法解析`, provider.file, provider.line, `Provide '${dep}' in a module, mark constructor parameter @Optional(), or define @Injectable({ providedIn: 'root' }).`);
1314
2721
  }
1315
2722
  }
1316
2723
  continue;
1317
2724
  }
1318
2725
  if (SCOPE_LIFETIME_RANK[resolved.provider.scope] > SCOPE_LIFETIME_RANK[provider.scope]) {
1319
- error("scope-violation", `模块 ${module.name} 的 ${provider.scope} provider ${provider.token} 不能依赖 ${resolved.provider.scope} provider ${dep}`, provider.file, provider.line);
2726
+ error("scope-violation", `模块 ${module.name} 的 ${provider.scope} provider ${provider.token} 不能依赖 ${resolved.provider.scope} provider ${dep}`, provider.file, provider.line, `Change provider '${provider.token}' scope to '${resolved.provider.scope}', or inject a factory/context instead.`);
1320
2727
  }
1321
2728
  }
1322
2729
  }
1323
2730
  for (const command of module.commands) {
1324
2731
  if (!command.permission) {
1325
- error("command-missing-permission", `模块 ${module.name} 的 command ${command.name} (${command.className}) 未声明 permission`, module.file, module.line);
2732
+ error("command-missing-permission", `模块 ${module.name} 的 command ${command.name} (${command.className}) 未声明 permission`, module.file, module.line, "Add 'permission: string' to @Command({ ... }) or configure command execution capabilities permission=false.");
1326
2733
  }
1327
2734
  if (typeof options === "object" && options.commandCapabilities) {
1328
2735
  const caps = options.commandCapabilities;
@@ -1375,18 +2782,116 @@ function validateGraph(graph, options = false) {
1375
2782
  }
1376
2783
  }
1377
2784
  }
2785
+ const referencedTokens = new Set;
2786
+ for (const mod of graph.modules) {
2787
+ for (const exp of mod.exports)
2788
+ referencedTokens.add(exp);
2789
+ for (const ctrl of mod.controllers) {
2790
+ for (const d of ctrl.deps ?? [])
2791
+ referencedTokens.add(d);
2792
+ for (const d of ctrl.optionalDeps ?? [])
2793
+ referencedTokens.add(d);
2794
+ for (const d of ctrl.selfDeps ?? [])
2795
+ referencedTokens.add(d);
2796
+ for (const d of ctrl.skipSelfDeps ?? [])
2797
+ referencedTokens.add(d);
2798
+ }
2799
+ for (const p of mod.providers) {
2800
+ for (const d of p.deps ?? [])
2801
+ referencedTokens.add(d);
2802
+ for (const d of p.optionalDeps ?? [])
2803
+ referencedTokens.add(d);
2804
+ for (const d of p.selfDeps ?? [])
2805
+ referencedTokens.add(d);
2806
+ for (const d of p.skipSelfDeps ?? [])
2807
+ referencedTokens.add(d);
2808
+ if (p.useExisting)
2809
+ referencedTokens.add(p.useExisting);
2810
+ }
2811
+ }
2812
+ for (const mod of graph.modules) {
2813
+ for (const provider of mod.providers) {
2814
+ if (provider.providedIn === "root" && !provider.multi && !referencedTokens.has(provider.token) && !provider.exported) {
2815
+ warn2("unused-root-provider", `Root provider "${provider.token}" is declared with providedIn: 'root' but is never injected or depended on by any module, controller, or command.`, provider.file, provider.line, `Inject "${provider.token}" in a service or controller, export it, or remove providedIn: 'root' to enable tree-shaking.`);
2816
+ }
2817
+ }
2818
+ }
2819
+ for (const module of graph.modules) {
2820
+ for (const expToken of module.exports) {
2821
+ const resolved = resolveDep(module, expToken);
2822
+ if (resolved)
2823
+ continue;
2824
+ if (module.imports.includes(expToken))
2825
+ continue;
2826
+ error("export-unprovided-token", `Module '${module.name}' exports token '${expToken}', but it is neither provided in '${module.name}' nor imported from an imported module.`, module.file, module.line, `Add a provider for '${expToken}' to '${module.name}.providers', or remove '${expToken}' from exports.`);
2827
+ }
2828
+ }
2829
+ for (const module of graph.modules) {
2830
+ for (const provider of module.providers) {
2831
+ if (provider.useExisting) {
2832
+ const target = provider.useExisting;
2833
+ if (target === provider.token) {
2834
+ error("self-referencing-alias", `Module '${module.name}' defines provider '${provider.token}' with useExisting referencing itself.`, provider.file ?? module.file, provider.line ?? module.line, `Change useExisting to reference a different provider token, or remove the self-referencing alias.`);
2835
+ } else {
2836
+ const resolved = resolveDep(module, target);
2837
+ if (!resolved && !graph.externalTokens.includes(target)) {
2838
+ error("unresolved-alias-target", `Module '${module.name}' defines provider '${provider.token}' with useExisting: '${target}', but '${target}' is neither provided in '${module.name}' nor imported from an imported module.`, provider.file ?? module.file, provider.line ?? module.line, `Add a provider for '${target}' to '${module.name}.providers' or an imported module, or update useExisting to reference an available token.`);
2839
+ }
2840
+ }
2841
+ }
2842
+ }
2843
+ }
1378
2844
  diagnostics.push(...detectCycles(graph, resolveDep));
2845
+ diagnostics.push(...detectExistingAliasCycles(graph, resolveDep));
1379
2846
  diagnostics.push(...detectModuleCycles(graph));
1380
2847
  if (typeof options === "object" && options.detectOrphanModules) {
1381
2848
  diagnostics.push(...detectOrphanModules(graph));
1382
2849
  }
1383
2850
  return diagnostics;
1384
2851
  }
1385
- function joinRoutePaths(prefix, path) {
2852
+ function joinRoutePaths2(prefix, path) {
1386
2853
  const joined = `${prefix}/${path}`.replace(/\/{2,}/g, "/");
1387
2854
  const normalized = joined.length > 1 ? joined.replace(/\/+$/, "") : joined;
1388
2855
  return normalized.replace(/:[^/]+/g, ":param");
1389
2856
  }
2857
+ function joinRawRoutePaths(prefix, path) {
2858
+ const joined = `${prefix}/${path}`.replace(/\/{2,}/g, "/");
2859
+ return joined.length > 1 ? joined.replace(/\/+$/, "") : joined;
2860
+ }
2861
+ function isRouteShadowed(earlierPath, laterPath) {
2862
+ const earlierSegments = earlierPath.split("/").filter(Boolean);
2863
+ const laterSegments = laterPath.split("/").filter(Boolean);
2864
+ if (earlierSegments.length !== laterSegments.length) {
2865
+ return false;
2866
+ }
2867
+ let hasParamShadowing = false;
2868
+ for (let i = 0;i < earlierSegments.length; i += 1) {
2869
+ const e = earlierSegments[i];
2870
+ const l = laterSegments[i];
2871
+ if (e === l) {
2872
+ continue;
2873
+ }
2874
+ if (e.startsWith(":") && !l.startsWith(":")) {
2875
+ hasParamShadowing = true;
2876
+ continue;
2877
+ }
2878
+ return false;
2879
+ }
2880
+ return hasParamShadowing;
2881
+ }
2882
+ function routeMatchesTarget(routePattern, targetPath) {
2883
+ const pSegs = routePattern.split("/").filter(Boolean);
2884
+ const tSegs = targetPath.split("/").filter(Boolean);
2885
+ if (pSegs.length !== tSegs.length)
2886
+ return false;
2887
+ for (let i = 0;i < pSegs.length; i += 1) {
2888
+ if (pSegs[i].startsWith(":"))
2889
+ continue;
2890
+ if (pSegs[i] !== tSegs[i])
2891
+ return false;
2892
+ }
2893
+ return true;
2894
+ }
1390
2895
  function detectCycles(graph, resolveDep) {
1391
2896
  const diagnostics = [];
1392
2897
  const nodeId = (ref) => `${ref.module.name}:${ref.provider.token}`;
@@ -1405,12 +2910,16 @@ function detectCycles(graph, resolveDep) {
1405
2910
  const cycleKey = cycle.map((item) => nodeId(item)).sort().join("|");
1406
2911
  if (!reported.has(cycleKey)) {
1407
2912
  reported.add(cycleKey);
2913
+ const meta = COMPILER_DIAGNOSTIC_CODES["circular-dependency"];
1408
2914
  diagnostics.push({
1409
2915
  severity: "error",
1410
2916
  code: "circular-dependency",
1411
2917
  message: `provider 循环依赖: ${path}`,
1412
2918
  file: ref.provider.file,
1413
- line: ref.provider.line
2919
+ line: ref.provider.line,
2920
+ suggestion: "Break the cycle by extracting common dependencies into a separate service or injecting @Optional().",
2921
+ errorCode: meta?.code,
2922
+ docsUrl: meta?.docsUrl
1414
2923
  });
1415
2924
  }
1416
2925
  return;
@@ -1429,6 +2938,47 @@ function detectCycles(graph, resolveDep) {
1429
2938
  visit(ref);
1430
2939
  return diagnostics;
1431
2940
  }
2941
+ function detectExistingAliasCycles(graph, resolveDep) {
2942
+ const diagnostics = [];
2943
+ const existingProviders = [];
2944
+ for (const module of graph.modules) {
2945
+ for (const provider of module.providers) {
2946
+ if (provider.useExisting) {
2947
+ existingProviders.push({ module, provider });
2948
+ }
2949
+ }
2950
+ }
2951
+ const reported = new Set;
2952
+ for (const start of existingProviders) {
2953
+ const visited = [start.provider.token];
2954
+ let current = start;
2955
+ while (current && current.provider.useExisting) {
2956
+ const targetToken = current.provider.useExisting;
2957
+ if (visited.includes(targetToken)) {
2958
+ const cycle = [...visited.slice(visited.indexOf(targetToken)), targetToken];
2959
+ const cycleKey = [...cycle].sort().join("|");
2960
+ if (!reported.has(cycleKey)) {
2961
+ reported.add(cycleKey);
2962
+ const meta = COMPILER_DIAGNOSTIC_CODES["circular-existing-alias"];
2963
+ diagnostics.push({
2964
+ severity: "error",
2965
+ code: "circular-existing-alias",
2966
+ message: `Provider alias cycle detected in useExisting: ${cycle.join(" -> ")}`,
2967
+ file: start.provider.file,
2968
+ line: start.provider.line,
2969
+ suggestion: "Break the alias cycle by pointing useExisting to a concrete provider instead of a circular alias.",
2970
+ errorCode: meta?.code,
2971
+ docsUrl: meta?.docsUrl
2972
+ });
2973
+ }
2974
+ break;
2975
+ }
2976
+ visited.push(targetToken);
2977
+ current = resolveDep(current.module, targetToken);
2978
+ }
2979
+ }
2980
+ return diagnostics;
2981
+ }
1432
2982
  function detectModuleCycles(graph) {
1433
2983
  const diagnostics = [];
1434
2984
  const moduleMap = new Map(graph.modules.map((m) => [m.name, m]));
@@ -1445,12 +2995,16 @@ function detectModuleCycles(graph) {
1445
2995
  if (!reported.has(cycleKey)) {
1446
2996
  reported.add(cycleKey);
1447
2997
  const mod2 = moduleMap.get(name);
2998
+ const meta = COMPILER_DIAGNOSTIC_CODES["circular-module-import"];
1448
2999
  diagnostics.push({
1449
3000
  severity: "error",
1450
3001
  code: "circular-module-import",
1451
3002
  message: `Module circular import detected: ${cycle.join(" -> ")}`,
1452
3003
  file: mod2?.file,
1453
- line: mod2?.line
3004
+ line: mod2?.line,
3005
+ suggestion: "Refactor module imports into a unidirectional acyclic graph.",
3006
+ errorCode: meta?.code,
3007
+ docsUrl: meta?.docsUrl
1454
3008
  });
1455
3009
  }
1456
3010
  return;
@@ -1498,12 +3052,15 @@ function detectOrphanModules(graph) {
1498
3052
  }
1499
3053
  for (const mod of graph.modules) {
1500
3054
  if (!reachable.has(mod.name)) {
3055
+ const meta = COMPILER_DIAGNOSTIC_CODES["orphan-module"];
1501
3056
  diagnostics.push({
1502
3057
  severity: "warn",
1503
3058
  code: "orphan-module",
1504
3059
  message: `Module '${mod.name}' is declared but not reachable from any root module (${rootModules.map((r) => r.name).join(", ")})`,
1505
3060
  file: mod.file,
1506
- line: mod.line
3061
+ line: mod.line,
3062
+ errorCode: meta?.code,
3063
+ docsUrl: meta?.docsUrl
1507
3064
  });
1508
3065
  }
1509
3066
  }
@@ -1514,7 +3071,7 @@ function detectOrphanModules(graph) {
1514
3071
  import { existsSync as existsSync2, readFileSync } from "node:fs";
1515
3072
  import { join as join3 } from "node:path";
1516
3073
  async function compileProject(options) {
1517
- const graph = await analyzeProject(options.rootDir, options.include);
3074
+ const graph = await analyzeProject(options.rootDir, options.include, options.cache);
1518
3075
  const diagnostics = [
1519
3076
  ...graph.diagnostics ?? [],
1520
3077
  ...validateGraph(graph, {
@@ -1533,14 +3090,25 @@ async function compileProject(options) {
1533
3090
  diagnostic.severity = "error";
1534
3091
  }
1535
3092
  }
1536
- const written = await generateApplication(graph, {
3093
+ const hasErrors = diagnostics.some((diagnostic) => diagnostic.severity === "error");
3094
+ const written = !hasErrors || options.writeOnError !== false ? await generateApplication(graph, {
1537
3095
  rootDir: options.rootDir,
1538
- outDir: options.outDir
1539
- });
1540
- return { diagnostics, graph, written };
3096
+ outDir: options.outDir,
3097
+ generateClient: options.generateClient,
3098
+ generatePermissions: options.generatePermissions,
3099
+ treeShakeUnusedProviders: options.treeShakeUnusedProviders
3100
+ }) : [];
3101
+ const stats = graph.cacheStats ? {
3102
+ cacheHit: graph.cacheStats.reanalyzedModules.length === 0,
3103
+ changedFiles: [],
3104
+ affectedModules: graph.cacheStats.reanalyzedModules,
3105
+ reanalyzedModules: graph.cacheStats.reanalyzedModules,
3106
+ reusedModules: graph.cacheStats.reusedModules
3107
+ } : undefined;
3108
+ return { diagnostics, graph, written, stats };
1541
3109
  }
1542
3110
  async function checkProject(options) {
1543
- const graph = await analyzeProject(options.rootDir, options.include);
3111
+ const graph = await analyzeProject(options.rootDir, options.include, options.cache);
1544
3112
  const diagnostics = [
1545
3113
  ...graph.diagnostics ?? [],
1546
3114
  ...validateGraph(graph, {
@@ -1561,12 +3129,21 @@ async function checkProject(options) {
1561
3129
  }
1562
3130
  const rendered = renderApplication(graph, {
1563
3131
  rootDir: options.rootDir,
1564
- outDir: options.outDir
3132
+ outDir: options.outDir,
3133
+ generateClient: options.generateClient,
3134
+ generatePermissions: options.generatePermissions,
3135
+ treeShakeUnusedProviders: options.treeShakeUnusedProviders
1565
3136
  });
1566
3137
  const expectedFiles = {
1567
3138
  "application.ts": rendered.applicationCode,
1568
3139
  "app.manifest.json": rendered.manifestJson
1569
3140
  };
3141
+ if (rendered.clientCode) {
3142
+ expectedFiles["client.ts"] = rendered.clientCode;
3143
+ }
3144
+ if (rendered.permissionsCode) {
3145
+ expectedFiles["permissions.ts"] = rendered.permissionsCode;
3146
+ }
1570
3147
  const mismatches = [];
1571
3148
  for (const [filename, expectedContent] of Object.entries(expectedFiles)) {
1572
3149
  const diskPath = join3(options.outDir, filename);
@@ -1587,6 +3164,509 @@ async function checkProject(options) {
1587
3164
  };
1588
3165
  }
1589
3166
 
3167
+ // src/inspect.ts
3168
+ import { existsSync as existsSync3 } from "node:fs";
3169
+ import { join as join4 } from "node:path";
3170
+ function formatGraph(graph) {
3171
+ const lines = [];
3172
+ for (const module of graph.modules) {
3173
+ lines.push(`MODULE ${module.name}`);
3174
+ lines.push(` file: ${module.file}:${module.line}`);
3175
+ lines.push(` imports: ${module.imports.length > 0 ? module.imports.join(", ") : "-"}`);
3176
+ lines.push(` providers: ${module.providers.length > 0 ? module.providers.map((p) => p.token).join(", ") : "-"}`);
3177
+ lines.push(` controllers: ${module.controllers.length > 0 ? module.controllers.map((c) => c.className).join(", ") : "-"}`);
3178
+ lines.push(` commands: ${module.commands.length > 0 ? module.commands.map((c) => c.name).join(", ") : "-"}`);
3179
+ }
3180
+ lines.push(`EXTERNAL TOKENS ${graph.externalTokens.length > 0 ? graph.externalTokens.join(", ") : "-"}`);
3181
+ return lines.join(`
3182
+ `);
3183
+ }
3184
+ function explainGraph(graph, subject) {
3185
+ const module = graph.modules.find((candidate) => candidate.name === subject);
3186
+ if (module)
3187
+ return explainModule(graph, module);
3188
+ const provider = findProvider(graph, subject);
3189
+ if (provider)
3190
+ return explainProvider(graph, provider.module, provider.provider);
3191
+ if (graph.externalTokens.includes(subject)) {
3192
+ const references = graph.modules.flatMap((candidate) => [
3193
+ ...candidate.providers.filter((item) => item.deps.includes(subject)).map((item) => `${candidate.name}.${item.token}`),
3194
+ ...candidate.controllers.filter((item) => item.deps.includes(subject)).map((item) => `${candidate.name}.${item.className}`)
3195
+ ]);
3196
+ return [
3197
+ `EXTERNAL TOKEN ${subject}`,
3198
+ " provided by: platform runtime",
3199
+ ` references: ${references.length > 0 ? references.join(", ") : "-"}`
3200
+ ].join(`
3201
+ `);
3202
+ }
3203
+ const known = [...graph.modules.map((item) => item.name), ...graph.externalTokens].sort();
3204
+ throw new Error(`No module, provider, or external token named "${subject}". Known names: ${known.join(", ") || "(none)"}`);
3205
+ }
3206
+ function doctorProject(rootDir, outDir, graph, upToDate, diagnostics = []) {
3207
+ const checks = [
3208
+ {
3209
+ name: "project-root",
3210
+ ok: existsSync3(rootDir),
3211
+ detail: existsSync3(rootDir) ? rootDir : `missing: ${rootDir}`
3212
+ },
3213
+ {
3214
+ name: "tsconfig",
3215
+ ok: existsSync3(join4(rootDir, "tsconfig.json")),
3216
+ detail: existsSync3(join4(rootDir, "tsconfig.json")) ? "tsconfig.json found" : "tsconfig.json missing"
3217
+ },
3218
+ {
3219
+ name: "modules",
3220
+ ok: graph.modules.length > 0,
3221
+ detail: `${graph.modules.length} module(s) discovered`
3222
+ },
3223
+ {
3224
+ name: "generated-artifacts",
3225
+ ok: upToDate,
3226
+ detail: upToDate ? "application.ts and app.manifest.json are up to date" : "generated artifacts are missing or stale"
3227
+ }
3228
+ ];
3229
+ const allDiagnostics = [...graph.diagnostics ?? [], ...diagnostics];
3230
+ return {
3231
+ checks,
3232
+ diagnostics: allDiagnostics,
3233
+ errors: allDiagnostics.filter((diagnostic) => diagnostic.severity === "error").length + checks.filter((check) => !check.ok).length
3234
+ };
3235
+ }
3236
+ function explainModule(graph, module) {
3237
+ const dependents = graph.modules.filter((candidate) => candidate.imports.includes(module.name)).map((candidate) => candidate.name);
3238
+ return [
3239
+ `MODULE ${module.name}`,
3240
+ ` file: ${module.file}:${module.line}`,
3241
+ ` imports: ${module.imports.length > 0 ? module.imports.join(", ") : "-"}`,
3242
+ ` imported by: ${dependents.length > 0 ? dependents.join(", ") : "-"}`,
3243
+ ` providers: ${module.providers.length > 0 ? module.providers.map((provider) => provider.token).join(", ") : "-"}`,
3244
+ ` controllers: ${module.controllers.length > 0 ? module.controllers.map((controller) => controller.className).join(", ") : "-"}`,
3245
+ ` commands: ${module.commands.length > 0 ? module.commands.map((command) => command.name).join(", ") : "-"}`
3246
+ ].join(`
3247
+ `);
3248
+ }
3249
+ function explainProvider(graph, module, provider) {
3250
+ const dependents = graph.modules.flatMap((candidate) => [
3251
+ ...candidate.providers.filter((item) => item.deps.includes(provider.token)).map((item) => `${candidate.name}.${item.token}`),
3252
+ ...candidate.controllers.filter((item) => item.deps.includes(provider.token)).map((item) => `${candidate.name}.${item.className}`)
3253
+ ]);
3254
+ return [
3255
+ `PROVIDER ${provider.token}`,
3256
+ ` module: ${module.name}`,
3257
+ ` file: ${provider.file}:${provider.line}`,
3258
+ ` kind: ${provider.kind}`,
3259
+ ` scope: ${provider.scope}`,
3260
+ ` exported: ${provider.exported ? "yes" : "no"}`,
3261
+ ` deps: ${provider.deps.length > 0 ? provider.deps.join(", ") : "-"}`,
3262
+ ` depended on by: ${dependents.length > 0 ? dependents.join(", ") : "-"}`
3263
+ ].join(`
3264
+ `);
3265
+ }
3266
+ function findProvider(graph, subject) {
3267
+ for (const module of graph.modules) {
3268
+ const provider = module.providers.find((candidate) => candidate.token === subject || candidate.useClass === subject || candidate.useFactoryName === subject);
3269
+ if (provider)
3270
+ return { module, provider };
3271
+ }
3272
+ return;
3273
+ }
3274
+ function exportGraphMermaid(graph) {
3275
+ const lines = ["graph TD"];
3276
+ for (const mod of graph.modules) {
3277
+ const safeId = mod.name.replace(/[^a-zA-Z0-9_]/g, "_");
3278
+ lines.push(` ${safeId}["${mod.className ?? mod.name}"]`);
3279
+ for (const imp of mod.imports) {
3280
+ const safeImp = imp.replace(/[^a-zA-Z0-9_]/g, "_");
3281
+ lines.push(` ${safeId} --> ${safeImp}`);
3282
+ }
3283
+ }
3284
+ return lines.join(`
3285
+ `);
3286
+ }
3287
+ function exportGraphDot(graph) {
3288
+ const lines = [
3289
+ "digraph ApplicationGraph {",
3290
+ " rankdir=LR;",
3291
+ ' node [shape=box, fontname="Helvetica"];'
3292
+ ];
3293
+ for (const mod of graph.modules) {
3294
+ lines.push(` "${mod.name}" [label="${mod.className ?? mod.name}"];`);
3295
+ for (const imp of mod.imports) {
3296
+ lines.push(` "${mod.name}" -> "${imp}";`);
3297
+ }
3298
+ }
3299
+ lines.push("}");
3300
+ return lines.join(`
3301
+ `);
3302
+ }
3303
+
3304
+ // src/watch.ts
3305
+ import { watch } from "node:fs";
3306
+ import { relative as relative3, resolve as resolve2 } from "node:path";
3307
+
3308
+ // src/incremental.ts
3309
+ import { createHash as createHash2 } from "node:crypto";
3310
+ import { access, readdir, readFile } from "node:fs/promises";
3311
+ import { relative as relative2, resolve, sep as sep2 } from "node:path";
3312
+ function createDependencyGraphCache() {
3313
+ return {
3314
+ modules: new Map,
3315
+ fileHashes: new Map
3316
+ };
3317
+ }
3318
+ function createIncrementalCompiler() {
3319
+ let previousSnapshot;
3320
+ let previousResult;
3321
+ const cache = createDependencyGraphCache();
3322
+ return {
3323
+ async compile(options, changedPaths) {
3324
+ const snapshot = changedPaths && previousSnapshot ? await updateSnapshot(previousSnapshot, options, changedPaths) : await createSnapshot(options);
3325
+ const changedFiles = changedPaths && previousSnapshot ? diffFiles(previousSnapshot.files, snapshot.files) : diffFiles(previousSnapshot?.files, snapshot.files);
3326
+ const cacheHit = Boolean(previousSnapshot && previousSnapshot.optionsKey === snapshot.optionsKey && changedFiles.length === 0);
3327
+ if (cacheHit && previousResult) {
3328
+ return {
3329
+ ...previousResult,
3330
+ stats: {
3331
+ cacheHit: true,
3332
+ changedFiles: [],
3333
+ affectedModules: [],
3334
+ reusedModules: previousResult.graph.modules.map((m) => m.name),
3335
+ reanalyzedModules: []
3336
+ }
3337
+ };
3338
+ }
3339
+ if (previousResult && previousSnapshot && changedFiles.length > 0 && !await requiresGraphRebuild(options.rootDir, changedFiles)) {
3340
+ const stats2 = {
3341
+ cacheHit: true,
3342
+ changedFiles,
3343
+ affectedModules: findAffectedModules(previousResult.graph.modules, previousResult.graph.modules, changedFiles),
3344
+ reusedModules: previousResult.graph.modules.map((m) => m.name),
3345
+ reanalyzedModules: []
3346
+ };
3347
+ previousSnapshot = snapshot;
3348
+ return { ...previousResult, written: [], stats: stats2 };
3349
+ }
3350
+ const activeCache = options.cache ?? cache;
3351
+ if (!activeCache.dependencyGraph && previousResult) {
3352
+ activeCache.dependencyGraph = new ModuleDependencyGraph(previousResult.graph.modules);
3353
+ }
3354
+ const result = await compileProject({ ...options, cache: activeCache });
3355
+ const affectedModules = previousResult ? findAffectedModules(previousResult.graph.modules, result.graph.modules, changedFiles) : result.graph.modules.map((module) => module.name);
3356
+ const reusedModules = result.graph.cacheStats?.reusedModules ?? [];
3357
+ const reanalyzedModules = result.graph.cacheStats?.reanalyzedModules ?? affectedModules;
3358
+ if (activeCache) {
3359
+ activeCache.dependencyGraph = new ModuleDependencyGraph(result.graph.modules);
3360
+ }
3361
+ const stats = {
3362
+ cacheHit: false,
3363
+ changedFiles,
3364
+ affectedModules,
3365
+ reusedModules,
3366
+ reanalyzedModules
3367
+ };
3368
+ previousSnapshot = snapshot;
3369
+ previousResult = result;
3370
+ return { ...result, stats };
3371
+ },
3372
+ reset() {
3373
+ previousSnapshot = undefined;
3374
+ previousResult = undefined;
3375
+ cache.modules.clear();
3376
+ cache.fileHashes.clear();
3377
+ cache.dependencyGraph = undefined;
3378
+ },
3379
+ getCache() {
3380
+ return cache;
3381
+ }
3382
+ };
3383
+ }
3384
+ async function updateSnapshot(previous, options, changedPaths) {
3385
+ const rootDir = resolve(options.rootDir);
3386
+ const outDir = resolve(options.outDir);
3387
+ const files = { ...previous.files };
3388
+ for (const changedPath of changedPaths) {
3389
+ const absolutePath = resolve(rootDir, changedPath);
3390
+ if (absolutePath === outDir || absolutePath.startsWith(`${outDir}/`))
3391
+ continue;
3392
+ const relativePath = relative2(rootDir, absolutePath).split(sep2).join("/");
3393
+ try {
3394
+ await access(absolutePath);
3395
+ const content = await readFile(absolutePath);
3396
+ files[relativePath] = createHash2("sha256").update(content).digest("hex");
3397
+ } catch {
3398
+ delete files[relativePath];
3399
+ }
3400
+ }
3401
+ return { files, optionsKey: optionsKeyOf(options) };
3402
+ }
3403
+ async function createSnapshot(options) {
3404
+ const rootDir = resolve(options.rootDir);
3405
+ const outDir = resolve(options.outDir);
3406
+ const paths = await listSourceFiles(rootDir, outDir);
3407
+ const files = {};
3408
+ for (const path of paths) {
3409
+ const content = await readFile(path);
3410
+ files[relative2(rootDir, path).split(sep2).join("/")] = createHash2("sha256").update(content).digest("hex");
3411
+ }
3412
+ return { files, optionsKey: optionsKeyOf(options) };
3413
+ }
3414
+ function optionsKeyOf(options) {
3415
+ return JSON.stringify({
3416
+ include: options.include,
3417
+ strict: options.strict,
3418
+ moduleBoundaryPreset: options.moduleBoundaryPreset,
3419
+ moduleBoundaries: options.moduleBoundaries,
3420
+ allowRouteCommandBindings: options.allowRouteCommandBindings,
3421
+ commandCapabilities: options.commandCapabilities,
3422
+ disallowControllerDirectDb: options.disallowControllerDirectDb,
3423
+ detectOrphanModules: options.detectOrphanModules,
3424
+ generateClient: options.generateClient,
3425
+ generatePermissions: options.generatePermissions
3426
+ });
3427
+ }
3428
+ async function requiresGraphRebuild(rootDir, changedFiles) {
3429
+ for (const relativePath of changedFiles) {
3430
+ const path = resolve(rootDir, relativePath);
3431
+ try {
3432
+ const source = await readFile(path, "utf8");
3433
+ if (/@(?:Module|Injectable|Inject|Controller|Command|Query)\b|new\s+InjectionToken\b|\bdefineModule\s*\(/.test(source))
3434
+ return true;
3435
+ } catch {
3436
+ return true;
3437
+ }
3438
+ }
3439
+ return false;
3440
+ }
3441
+ async function listSourceFiles(rootDir, outDir) {
3442
+ const result = [];
3443
+ const visit = async (directory) => {
3444
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
3445
+ const path = resolve(directory, entry.name);
3446
+ if (entry.isDirectory()) {
3447
+ if (entry.name === "node_modules" || entry.name === ".git" || path === outDir)
3448
+ continue;
3449
+ await visit(path);
3450
+ } else if (/\.(tsx?|mts|cts)$/.test(entry.name)) {
3451
+ result.push(path);
3452
+ }
3453
+ }
3454
+ };
3455
+ await visit(rootDir);
3456
+ return result.sort();
3457
+ }
3458
+ function diffFiles(previous, current) {
3459
+ if (!previous)
3460
+ return Object.keys(current);
3461
+ const names = new Set([...Object.keys(previous), ...Object.keys(current)]);
3462
+ return [...names].filter((name) => previous[name] !== current[name]).sort();
3463
+ }
3464
+
3465
+ class ModuleDependencyGraph {
3466
+ imports = new Map;
3467
+ dependents = new Map;
3468
+ fileOwners = new Map;
3469
+ moduleMap = new Map;
3470
+ constructor(modules = []) {
3471
+ this.rebuild(modules);
3472
+ }
3473
+ rebuild(modules) {
3474
+ this.imports.clear();
3475
+ this.dependents.clear();
3476
+ this.fileOwners.clear();
3477
+ this.moduleMap.clear();
3478
+ for (const mod of modules) {
3479
+ this.moduleMap.set(mod.name, mod);
3480
+ this.imports.set(mod.name, new Set(mod.imports));
3481
+ if (!this.dependents.has(mod.name)) {
3482
+ this.dependents.set(mod.name, new Set);
3483
+ }
3484
+ this.indexFile(mod.file, mod.name);
3485
+ for (const p of mod.providers) {
3486
+ if (p.importPath)
3487
+ this.indexFile(p.importPath, mod.name);
3488
+ if (p.file)
3489
+ this.indexFile(p.file, mod.name);
3490
+ }
3491
+ for (const c of mod.controllers) {
3492
+ if (c.importPath)
3493
+ this.indexFile(c.importPath, mod.name);
3494
+ if (c.file)
3495
+ this.indexFile(c.file, mod.name);
3496
+ }
3497
+ }
3498
+ for (const [modName, imps] of this.imports.entries()) {
3499
+ for (const imp of imps) {
3500
+ if (!this.dependents.has(imp)) {
3501
+ this.dependents.set(imp, new Set);
3502
+ }
3503
+ this.dependents.get(imp).add(modName);
3504
+ }
3505
+ }
3506
+ }
3507
+ indexFile(path, moduleName) {
3508
+ if (!path)
3509
+ return;
3510
+ const normalized = path.replace(/\.(tsx?|mts|cts)$/, "");
3511
+ if (!this.fileOwners.has(normalized)) {
3512
+ this.fileOwners.set(normalized, new Set);
3513
+ }
3514
+ this.fileOwners.get(normalized).add(moduleName);
3515
+ }
3516
+ getModulesOwningFile(filePath) {
3517
+ const normalized = filePath.replace(/\.(tsx?|mts|cts)$/, "");
3518
+ return Array.from(this.fileOwners.get(normalized) ?? []);
3519
+ }
3520
+ getAffectedModules(changedFiles) {
3521
+ if (changedFiles.length === 0)
3522
+ return [];
3523
+ const directlyAffected = new Set;
3524
+ for (const file of changedFiles) {
3525
+ for (const modName of this.getModulesOwningFile(file)) {
3526
+ directlyAffected.add(modName);
3527
+ }
3528
+ }
3529
+ if (directlyAffected.size === 0) {
3530
+ return Array.from(this.moduleMap.keys());
3531
+ }
3532
+ const affected = new Set(directlyAffected);
3533
+ const queue = Array.from(directlyAffected);
3534
+ while (queue.length > 0) {
3535
+ const current = queue.shift();
3536
+ const dependents = this.dependents.get(current);
3537
+ if (dependents) {
3538
+ for (const dep of dependents) {
3539
+ if (!affected.has(dep)) {
3540
+ affected.add(dep);
3541
+ queue.push(dep);
3542
+ }
3543
+ }
3544
+ }
3545
+ }
3546
+ return Array.from(this.moduleMap.keys()).filter((name) => affected.has(name));
3547
+ }
3548
+ getDirectImports(moduleName) {
3549
+ return Array.from(this.imports.get(moduleName) ?? []);
3550
+ }
3551
+ getDirectDependents(moduleName) {
3552
+ return Array.from(this.dependents.get(moduleName) ?? []);
3553
+ }
3554
+ }
3555
+ function findAffectedModules(previous, current, changedFiles) {
3556
+ const depGraph = new ModuleDependencyGraph(current);
3557
+ return depGraph.getAffectedModules(changedFiles);
3558
+ }
3559
+
3560
+ // src/watch.ts
3561
+ var DEFAULT_DEBOUNCE_MS = 100;
3562
+ function watchProject(options) {
3563
+ const rootDir = resolve2(options.rootDir);
3564
+ const outDir = resolve2(options.outDir);
3565
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
3566
+ let timer;
3567
+ let closed = false;
3568
+ let compiling = false;
3569
+ let pending = false;
3570
+ const pendingPaths = new Set;
3571
+ let watcher;
3572
+ const incremental = createIncrementalCompiler();
3573
+ let initialEvent;
3574
+ let resolveReady;
3575
+ let rejectReady;
3576
+ const ready = new Promise((resolvePromise, rejectPromise) => {
3577
+ resolveReady = resolvePromise;
3578
+ rejectReady = rejectPromise;
3579
+ });
3580
+ const emit = (event) => {
3581
+ options.onEvent?.(event);
3582
+ if (event.initial)
3583
+ initialEvent = event;
3584
+ };
3585
+ const compile = async (initial, changedPaths = []) => {
3586
+ if (closed && !initial)
3587
+ return;
3588
+ if (compiling) {
3589
+ pending = true;
3590
+ return;
3591
+ }
3592
+ compiling = true;
3593
+ const startedAt = performance.now();
3594
+ options.onEvent?.({
3595
+ type: "compile-start",
3596
+ initial,
3597
+ durationMs: 0,
3598
+ diagnostics: [],
3599
+ written: []
3600
+ });
3601
+ try {
3602
+ const result = await incremental.compile({ ...options, writeOnError: false }, changedPaths);
3603
+ const durationMs = Math.round(performance.now() - startedAt);
3604
+ const hasErrors = result.diagnostics.some((diagnostic) => diagnostic.severity === "error");
3605
+ emit({
3606
+ type: hasErrors ? "compile-error" : "compiled",
3607
+ initial,
3608
+ durationMs,
3609
+ diagnostics: result.diagnostics,
3610
+ written: result.written,
3611
+ stats: result.stats
3612
+ });
3613
+ } catch (error) {
3614
+ rejectReady(error);
3615
+ throw error;
3616
+ } finally {
3617
+ compiling = false;
3618
+ if (pending && !closed) {
3619
+ pending = false;
3620
+ compile(false, [...pendingPaths]);
3621
+ pendingPaths.clear();
3622
+ }
3623
+ }
3624
+ };
3625
+ const schedule = (changedPath) => {
3626
+ if (closed)
3627
+ return;
3628
+ if (changedPath)
3629
+ pendingPaths.add(changedPath);
3630
+ if (timer)
3631
+ clearTimeout(timer);
3632
+ timer = setTimeout(() => {
3633
+ timer = undefined;
3634
+ compile(false, [...pendingPaths]);
3635
+ pendingPaths.clear();
3636
+ }, debounceMs);
3637
+ };
3638
+ compile(true).then(() => {
3639
+ if (closed)
3640
+ return;
3641
+ watcher = watch(rootDir, { recursive: true }, (_eventType, filename) => {
3642
+ if (!filename)
3643
+ return schedule();
3644
+ const changedPath = resolve2(rootDir, filename.toString());
3645
+ const relativePath = relative3(outDir, changedPath);
3646
+ if (!relativePath.startsWith("..") && relativePath !== "")
3647
+ return;
3648
+ if (/\.(tsx?|mts|cts)$/.test(changedPath))
3649
+ schedule(relative3(rootDir, changedPath));
3650
+ });
3651
+ if (initialEvent)
3652
+ resolveReady(initialEvent);
3653
+ }).catch(() => {
3654
+ return;
3655
+ });
3656
+ return {
3657
+ ready,
3658
+ async close() {
3659
+ closed = true;
3660
+ if (timer)
3661
+ clearTimeout(timer);
3662
+ watcher?.close();
3663
+ await ready.catch(() => {
3664
+ return;
3665
+ });
3666
+ }
3667
+ };
3668
+ }
3669
+
1590
3670
  // src/cli.ts
1591
3671
  function printUsage() {
1592
3672
  console.log(`
@@ -1595,15 +3675,27 @@ function printUsage() {
1595
3675
  Usage:
1596
3676
  supacloud-compiler compile [rootDir] [options]
1597
3677
  supacloud-compiler check [rootDir] [options]
3678
+ supacloud-compiler dev [rootDir] [options]
3679
+ supacloud-compiler graph [rootDir] [options]
3680
+ supacloud-compiler explain <name> [rootDir] [options]
3681
+ supacloud-compiler doctor [rootDir] [options]
1598
3682
 
1599
3683
  Commands:
1600
3684
  compile Compile application modules and generate artifacts
1601
3685
  check Check artifact drift and run governance gates
3686
+ dev Watch source files and recompile on changes
3687
+ graph Print the discovered application graph
3688
+ explain Explain a module, provider, or external token
3689
+ doctor Run project and generated-artifact health checks
1602
3690
 
1603
3691
  Options:
1604
3692
  --root, -r <dir> Application source root (default: current directory or first positional argument)
1605
3693
  --out, -o <dir> Artifact output directory (default: <rootDir>/generated)
1606
3694
  --strict Treat all warnings as errors
3695
+ --client Generate typed API client in client.ts
3696
+ --permissions Generate typed permissions registry in permissions.ts
3697
+ --debounce <ms> Debounce source changes in dev mode (default: 100)
3698
+ --json Print machine-readable output for graph/explain/doctor
1607
3699
  --preset, -p <name> Architecture preset ('modular-monolith' | 'angular-enterprise' | 'clean-architecture')
1608
3700
  --help, -h Show this help
1609
3701
  `);
@@ -1615,7 +3707,7 @@ async function run() {
1615
3707
  process.exit(0);
1616
3708
  }
1617
3709
  const command = args[0];
1618
- if (command !== "compile" && command !== "check") {
3710
+ if (!["compile", "check", "dev", "graph", "explain", "doctor"].includes(command)) {
1619
3711
  console.error(`Error: unknown command "${command}"`);
1620
3712
  printUsage();
1621
3713
  process.exit(1);
@@ -1623,7 +3715,12 @@ async function run() {
1623
3715
  let rootDir = ".";
1624
3716
  let outDir;
1625
3717
  let strict = false;
3718
+ let generateClient = false;
3719
+ let generatePermissions = false;
1626
3720
  let preset;
3721
+ let debounceMs = 100;
3722
+ let query;
3723
+ let json = false;
1627
3724
  for (let i = 1;i < args.length; i++) {
1628
3725
  const arg = args[i];
1629
3726
  if (arg === "--root" || arg === "-r") {
@@ -1632,26 +3729,41 @@ async function run() {
1632
3729
  outDir = args[++i];
1633
3730
  } else if (arg === "--strict") {
1634
3731
  strict = true;
3732
+ } else if (arg === "--client") {
3733
+ generateClient = true;
3734
+ } else if (arg === "--permissions") {
3735
+ generatePermissions = true;
3736
+ } else if (arg === "--debounce") {
3737
+ debounceMs = Number(args[++i]);
3738
+ if (!Number.isFinite(debounceMs) || debounceMs < 0) {
3739
+ console.error("Error: --debounce must be a non-negative number");
3740
+ process.exit(1);
3741
+ }
3742
+ } else if (arg === "--json") {
3743
+ json = true;
1635
3744
  } else if (arg === "--preset" || arg === "-p") {
1636
3745
  preset = args[++i];
1637
3746
  } else if (!arg.startsWith("-") && rootDir === ".") {
1638
- rootDir = arg;
3747
+ if (command === "explain" && !query)
3748
+ query = arg;
3749
+ else
3750
+ rootDir = arg;
3751
+ } else if (!arg.startsWith("-") && command === "explain" && !query) {
3752
+ query = arg;
1639
3753
  }
1640
3754
  }
1641
- const resolvedRoot = resolve(process.cwd(), rootDir);
1642
- const resolvedOut = outDir ? resolve(process.cwd(), outDir) : resolve(resolvedRoot, "generated");
3755
+ const resolvedRoot = resolve3(process.cwd(), rootDir);
3756
+ const resolvedOut = outDir ? resolve3(process.cwd(), outDir) : resolve3(resolvedRoot, "generated");
1643
3757
  if (command === "compile") {
1644
3758
  const result = await compileProject({
1645
3759
  rootDir: resolvedRoot,
1646
3760
  outDir: resolvedOut,
1647
3761
  strict,
1648
- moduleBoundaryPreset: preset
3762
+ moduleBoundaryPreset: preset,
3763
+ generateClient,
3764
+ generatePermissions
1649
3765
  });
1650
- for (const diag of result.diagnostics) {
1651
- const loc = diag.file ? ` ${diag.file}${diag.line ? `:${diag.line}` : ""}` : "";
1652
- const log = diag.severity === "error" ? console.error : console.warn;
1653
- log(`[${diag.severity}] ${diag.code}${loc}: ${diag.message}`);
1654
- }
3766
+ printDiagnostics(result.diagnostics);
1655
3767
  const errors = result.diagnostics.filter((d) => d.severity === "error");
1656
3768
  if (errors.length > 0) {
1657
3769
  console.error(`
@@ -1662,18 +3774,16 @@ Compilation failed with ${errors.length} error(s).`);
1662
3774
  Compilation succeeded. Generated artifacts:
1663
3775
  ${result.written.map((f) => ` - ${f}`).join(`
1664
3776
  `)}`);
1665
- } else {
3777
+ } else if (command === "check") {
1666
3778
  const result = await checkProject({
1667
3779
  rootDir: resolvedRoot,
1668
3780
  outDir: resolvedOut,
1669
3781
  strict,
1670
- moduleBoundaryPreset: preset
3782
+ moduleBoundaryPreset: preset,
3783
+ generateClient,
3784
+ generatePermissions
1671
3785
  });
1672
- for (const diag of result.diagnostics) {
1673
- const loc = diag.file ? ` ${diag.file}${diag.line ? `:${diag.line}` : ""}` : "";
1674
- const log = diag.severity === "error" ? console.error : console.warn;
1675
- log(`[${diag.severity}] ${diag.code}${loc}: ${diag.message}`);
1676
- }
3786
+ printDiagnostics(result.diagnostics);
1677
3787
  const errors = result.diagnostics.filter((d) => d.severity === "error");
1678
3788
  if (errors.length > 0) {
1679
3789
  console.error(`
@@ -1690,6 +3800,91 @@ Artifact drift detected:`);
1690
3800
  process.exit(1);
1691
3801
  }
1692
3802
  console.log("Artifact check passed: disk files match compiler output with no drift.");
3803
+ } else if (command === "dev") {
3804
+ const handle = watchProject({
3805
+ rootDir: resolvedRoot,
3806
+ outDir: resolvedOut,
3807
+ strict,
3808
+ moduleBoundaryPreset: preset,
3809
+ debounceMs,
3810
+ generateClient,
3811
+ generatePermissions,
3812
+ onEvent: (event) => {
3813
+ if (event.type === "compile-start") {
3814
+ console.log(event.initial ? `
3815
+ Initial compilation...` : `
3816
+ Source change detected; compiling...`);
3817
+ return;
3818
+ }
3819
+ printDiagnostics(event.diagnostics);
3820
+ if (event.type === "compile-error") {
3821
+ console.error(`Compilation failed; keeping the last successful artifacts (${event.durationMs}ms).`);
3822
+ } else {
3823
+ const cache = event.stats?.cacheHit ? "cache hit" : "recompiled";
3824
+ const affected = event.stats?.affectedModules?.length ? `; affected modules: ${event.stats.affectedModules.join(", ")}` : "";
3825
+ console.log(`Compilation succeeded in ${event.durationMs}ms (${cache}${affected}).`);
3826
+ }
3827
+ }
3828
+ });
3829
+ const close = async () => {
3830
+ await handle.close();
3831
+ process.exit(0);
3832
+ };
3833
+ process.once("SIGINT", () => void close());
3834
+ process.once("SIGTERM", () => void close());
3835
+ await handle.ready;
3836
+ await new Promise(() => {
3837
+ return;
3838
+ });
3839
+ } else if (command === "graph") {
3840
+ const graph = await analyzeProject(resolvedRoot);
3841
+ if (json)
3842
+ console.log(JSON.stringify(graph, null, 2));
3843
+ else
3844
+ console.log(formatGraph(graph));
3845
+ } else if (command === "explain") {
3846
+ if (!query) {
3847
+ console.error("Error: explain requires a module, provider, or external token name");
3848
+ process.exit(1);
3849
+ }
3850
+ try {
3851
+ const graph = await analyzeProject(resolvedRoot);
3852
+ const explanation = explainGraph(graph, query);
3853
+ if (json)
3854
+ console.log(JSON.stringify({ subject: query, explanation }, null, 2));
3855
+ else
3856
+ console.log(explanation);
3857
+ } catch (error) {
3858
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
3859
+ process.exit(1);
3860
+ }
3861
+ } else {
3862
+ const result = await checkProject({
3863
+ rootDir: resolvedRoot,
3864
+ outDir: resolvedOut,
3865
+ strict,
3866
+ moduleBoundaryPreset: preset
3867
+ });
3868
+ const doctor = doctorProject(resolvedRoot, resolvedOut, result.graph, result.upToDate, result.diagnostics);
3869
+ if (json) {
3870
+ console.log(JSON.stringify(doctor, null, 2));
3871
+ } else {
3872
+ for (const check of doctor.checks)
3873
+ console.log(`${check.ok ? "OK" : "FAIL"} ${check.name}: ${check.detail}`);
3874
+ printDiagnostics(doctor.diagnostics);
3875
+ }
3876
+ if (doctor.errors > 0)
3877
+ process.exit(1);
3878
+ }
3879
+ }
3880
+ function printDiagnostics(diagnostics) {
3881
+ for (const diag of diagnostics) {
3882
+ const loc = diag.file ? ` ${diag.file}${diag.line ? `:${diag.line}` : ""}` : "";
3883
+ const log = diag.severity === "error" ? console.error : console.warn;
3884
+ log(`[${diag.severity}] ${diag.code}${loc}: ${diag.message}`);
3885
+ if (diag.suggestion) {
3886
+ console.log(` Hint: ${diag.suggestion}`);
3887
+ }
1693
3888
  }
1694
3889
  }
1695
3890
  run().catch((err) => {