@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/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/analyze.ts
2
2
  import { existsSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
3
4
  import { join, relative, sep } from "node:path";
4
5
  import {
5
6
  Node,
@@ -17,10 +18,20 @@ var ROUTE_DECORATORS = {
17
18
  Options: "OPTIONS"
18
19
  };
19
20
  var SCOPES = ["application", "request", "job"];
20
- async function analyzeProject(rootDir, include) {
21
- const project = createProject(rootDir);
22
- const patterns = (include ?? DEFAULT_INCLUDE).map((glob) => join(rootDir, glob));
23
- project.addSourceFilesAtPaths(patterns);
21
+ async function analyzeProject(rootDir, include, cache) {
22
+ let project;
23
+ if (cache?.project) {
24
+ project = cache.project;
25
+ for (const sf of project.getSourceFiles()) {
26
+ sf.refreshFromFileSystemSync();
27
+ }
28
+ } else {
29
+ project = createProject(rootDir);
30
+ const patterns = (include ?? DEFAULT_INCLUDE).map((glob) => join(rootDir, glob));
31
+ project.addSourceFilesAtPaths(patterns);
32
+ if (cache)
33
+ cache.project = project;
34
+ }
24
35
  const sourceFiles = project.getSourceFiles().filter((sf) => !sf.getFilePath().includes("node_modules") && !sf.isDeclarationFile()).sort((a, b) => a.getFilePath().localeCompare(b.getFilePath()));
25
36
  const ctx = {
26
37
  rootDir,
@@ -70,7 +81,207 @@ async function analyzeProject(rootDir, include) {
70
81
  for (const c of candidates) {
71
82
  nameByNode.set(c.node, stringLiteralProp(c.options, "name") ?? c.className);
72
83
  }
73
- const modules = candidates.map((c) => parseModule(c, nameByNode, ctx));
84
+ let modules = [];
85
+ let reusedModules = [];
86
+ let reanalyzedModules = [];
87
+ if (cache) {
88
+ const currentFileHashes = new Map;
89
+ for (const sf of sourceFiles) {
90
+ const rel = sourcePath(rootDir, sf.getFilePath());
91
+ const hash = createHash("sha256").update(sf.getFullText()).digest("hex");
92
+ currentFileHashes.set(rel, hash);
93
+ }
94
+ const changedFiles = new Set;
95
+ for (const [file, hash] of currentFileHashes.entries()) {
96
+ if (cache.fileHashes.get(file) !== hash) {
97
+ changedFiles.add(file);
98
+ }
99
+ }
100
+ for (const file of cache.fileHashes.keys()) {
101
+ if (!currentFileHashes.has(file)) {
102
+ changedFiles.add(file);
103
+ }
104
+ }
105
+ const modulesToKeep = new Map;
106
+ const finalModules = [];
107
+ const finalDiagnostics = [];
108
+ const affectedModuleNames = cache.dependencyGraph && typeof cache.dependencyGraph.getAffectedModules === "function" ? new Set(cache.dependencyGraph.getAffectedModules(Array.from(changedFiles))) : new Set;
109
+ for (const [modName, entry] of cache.modules.entries()) {
110
+ const hasChangedFile = entry.ownedFiles.some((f) => changedFiles.has(f));
111
+ const moduleFileExists = currentFileHashes.has(entry.module.file);
112
+ const isAffectedByDep = affectedModuleNames.has(modName);
113
+ if (!hasChangedFile && !isAffectedByDep && moduleFileExists) {
114
+ modulesToKeep.set(modName, entry);
115
+ reusedModules.push(modName);
116
+ finalModules.push(entry.module);
117
+ if (entry.diagnostics)
118
+ finalDiagnostics.push(...entry.diagnostics);
119
+ }
120
+ }
121
+ for (const c of candidates) {
122
+ const modName = nameByNode.get(c.node) ?? c.className;
123
+ if (modulesToKeep.has(modName)) {
124
+ continue;
125
+ }
126
+ const diagBefore = ctx.diagnostics.length;
127
+ const parsed = parseModule(c, nameByNode, ctx);
128
+ const moduleDiagnostics = ctx.diagnostics.slice(diagBefore);
129
+ const ownedFiles = new Set;
130
+ ownedFiles.add(parsed.file);
131
+ for (const p of parsed.providers)
132
+ if (p.file)
133
+ ownedFiles.add(p.file);
134
+ for (const ctrl of parsed.controllers)
135
+ if (ctrl.file)
136
+ ownedFiles.add(ctrl.file);
137
+ const fileHashes = {};
138
+ for (const f of ownedFiles) {
139
+ fileHashes[f] = currentFileHashes.get(f) ?? "";
140
+ }
141
+ cache.modules.set(parsed.name, {
142
+ module: parsed,
143
+ ownedFiles: [...ownedFiles],
144
+ fileHashes,
145
+ diagnostics: moduleDiagnostics
146
+ });
147
+ reanalyzedModules.push(parsed.name);
148
+ finalModules.push(parsed);
149
+ finalDiagnostics.push(...moduleDiagnostics);
150
+ }
151
+ for (const modName of [...cache.modules.keys()]) {
152
+ if (!modulesToKeep.has(modName) && !reanalyzedModules.includes(modName)) {
153
+ cache.modules.delete(modName);
154
+ }
155
+ }
156
+ cache.fileHashes = currentFileHashes;
157
+ cache.lastStats = { reusedModules, reanalyzedModules };
158
+ ctx.diagnostics = finalDiagnostics;
159
+ modules = finalModules;
160
+ } else {
161
+ modules = candidates.map((c) => parseModule(c, nameByNode, ctx));
162
+ }
163
+ const allRegisteredClasses = new Set;
164
+ const allRegisteredControllers = new Set;
165
+ const allRegisteredCommands = new Set;
166
+ for (const m of modules) {
167
+ for (const p of m.providers) {
168
+ if (p.useClass)
169
+ allRegisteredClasses.add(p.useClass);
170
+ if (p.kind === "class")
171
+ allRegisteredClasses.add(p.token);
172
+ }
173
+ for (const c of m.controllers) {
174
+ allRegisteredControllers.add(c.className);
175
+ }
176
+ for (const cmd of m.commands) {
177
+ allRegisteredCommands.add(cmd.className);
178
+ }
179
+ }
180
+ const rootProviders = [];
181
+ const standaloneControllers = [];
182
+ const standaloneCommands = [];
183
+ for (const [name, classInfo] of ctx.classesByName.entries()) {
184
+ if (!allRegisteredClasses.has(name)) {
185
+ const injectable = parseInjectableOptions(classInfo.decl, ctx);
186
+ if (injectable?.providedIn === "root") {
187
+ const { deps, optionalDeps, selfDeps, skipSelfDeps, hostDeps, missing } = classDeps(classInfo.decl, ctx);
188
+ const file = sourcePath(ctx.rootDir, classInfo.file);
189
+ const line = classInfo.decl.getStartLineNumber();
190
+ if (missing) {
191
+ warn(ctx, "missing-deps", `root provider ${name} 的部分构造依赖无法静态解析`, file, line);
192
+ }
193
+ rootProviders.push({
194
+ token: name,
195
+ tokenKind: "class",
196
+ kind: "class",
197
+ useClass: name,
198
+ scope: injectable.scope ?? "application",
199
+ deps,
200
+ optionalDeps: optionalDeps.length > 0 ? optionalDeps : undefined,
201
+ selfDeps: selfDeps.length > 0 ? selfDeps : undefined,
202
+ skipSelfDeps: skipSelfDeps.length > 0 ? skipSelfDeps : undefined,
203
+ hostDeps: hostDeps.length > 0 ? hostDeps : undefined,
204
+ providedIn: "root",
205
+ hasOnDestroy: classInfo.decl.getMethod("onDestroy") !== undefined || undefined,
206
+ exported: true,
207
+ file,
208
+ line,
209
+ importPath: modulePath(ctx.rootDir, classInfo.file)
210
+ });
211
+ }
212
+ }
213
+ if (!allRegisteredControllers.has(name)) {
214
+ const controllerDec = findDecorator(classInfo.decl, "Controller");
215
+ if (controllerDec) {
216
+ const arg = controllerDec.getArguments()[0];
217
+ const isStandalone = arg && Node.isObjectLiteralExpression(arg) && booleanProp(arg, "standalone");
218
+ if (isStandalone) {
219
+ const ctrl = parseController(classInfo.decl, ctx);
220
+ if (ctrl)
221
+ standaloneControllers.push(ctrl);
222
+ }
223
+ }
224
+ }
225
+ if (!allRegisteredCommands.has(name)) {
226
+ const commandDec = findDecorator(classInfo.decl, "Command");
227
+ if (commandDec) {
228
+ const meta = decoratorObjectArg(commandDec);
229
+ if (meta && booleanProp(meta, "standalone")) {
230
+ standaloneCommands.push({
231
+ className: classInfo.decl.getName() ?? name,
232
+ name: stringLiteralProp(meta, "name") ?? classInfo.decl.getName() ?? name,
233
+ permission: stringLiteralProp(meta, "permission"),
234
+ transaction: commandModeProp(meta, "transaction") ?? "none",
235
+ audit: stringLiteralProp(meta, "audit"),
236
+ idempotency: commandModeProp(meta, "idempotency") ?? "none",
237
+ standalone: true
238
+ });
239
+ }
240
+ }
241
+ }
242
+ }
243
+ for (const [name, tokenInfo] of ctx.tokensByName.entries()) {
244
+ if (tokenInfo.providedIn === "root" && !allRegisteredClasses.has(name)) {
245
+ rootProviders.push({
246
+ token: name,
247
+ tokenKind: "injection-token",
248
+ kind: "factory",
249
+ scope: tokenInfo.scope ?? "application",
250
+ deps: [],
251
+ providedIn: "root",
252
+ exported: true,
253
+ file: sourcePath(ctx.rootDir, tokenInfo.file),
254
+ line: tokenInfo.line ?? 1,
255
+ importPath: modulePath(ctx.rootDir, tokenInfo.file)
256
+ });
257
+ }
258
+ }
259
+ if (rootProviders.length > 0 || standaloneControllers.length > 0 || standaloneCommands.length > 0) {
260
+ const existingRoot = modules.find((m) => m.name === "root" || m.name === "app");
261
+ if (existingRoot) {
262
+ existingRoot.providers.push(...rootProviders);
263
+ existingRoot.controllers.push(...standaloneControllers);
264
+ existingRoot.commands.push(...standaloneCommands);
265
+ for (const p of rootProviders) {
266
+ if (!existingRoot.exports.includes(p.token))
267
+ existingRoot.exports.push(p.token);
268
+ }
269
+ } else {
270
+ const fallbackFile = rootProviders[0]?.file ?? standaloneControllers[0]?.file ?? "root.ts";
271
+ modules.unshift({
272
+ name: "root",
273
+ className: "RootModule",
274
+ file: fallbackFile,
275
+ line: 1,
276
+ imports: [],
277
+ providers: rootProviders,
278
+ controllers: standaloneControllers,
279
+ commands: standaloneCommands,
280
+ queries: [],
281
+ exports: rootProviders.map((p) => p.token)
282
+ });
283
+ }
284
+ }
74
285
  const providedTokens = new Set(modules.flatMap((m) => m.providers.map((p) => p.token)));
75
286
  const referenced = new Set;
76
287
  for (const m of modules) {
@@ -89,7 +300,8 @@ async function analyzeProject(rootDir, include) {
89
300
  modules,
90
301
  externalTokens,
91
302
  diagnostics: ctx.diagnostics,
92
- tokenNames
303
+ tokenNames,
304
+ cacheStats: cache ? { reusedModules, reanalyzedModules } : undefined
93
305
  };
94
306
  }
95
307
  function createProject(rootDir) {
@@ -124,7 +336,7 @@ function parseTokenVariable(decl, file) {
124
336
  if (init.getExpression().getText() !== "InjectionToken")
125
337
  return;
126
338
  const [nameArg, optionsArg] = init.getArguments();
127
- const info = { name: decl.getName(), file };
339
+ const info = { name: decl.getName(), file, line: decl.getStartLineNumber() };
128
340
  if (nameArg && Node.isStringLiteral(nameArg)) {
129
341
  info.stringName = nameArg.getLiteralText();
130
342
  }
@@ -133,6 +345,14 @@ function parseTokenVariable(decl, file) {
133
345
  if (scope && SCOPES.includes(scope)) {
134
346
  info.scope = scope;
135
347
  }
348
+ const providedIn = stringLiteralProp(optionsArg, "providedIn");
349
+ if (providedIn === "root") {
350
+ info.providedIn = "root";
351
+ }
352
+ const factory = getProp(optionsArg, "factory");
353
+ if (factory) {
354
+ info.hasFactory = true;
355
+ }
136
356
  }
137
357
  return info;
138
358
  }
@@ -141,7 +361,8 @@ function parseModule(candidate, nameByNode, ctx) {
141
361
  const name = nameByNode.get(candidate.node) ?? className;
142
362
  const tags = arrayProp(options, "tags").map((el) => Node.isStringLiteral(el) ? el.getLiteralText() : el.getText().replace(/['"]/g, "")).filter(Boolean);
143
363
  const imports = arrayProp(options, "imports").map((el) => {
144
- const decl = Node.isIdentifier(el) ? resolveDeclaration(el)[0] : undefined;
364
+ const unwrapped = unwrapForwardRef(el);
365
+ const decl = Node.isIdentifier(unwrapped) ? resolveDeclaration(unwrapped)[0] : undefined;
145
366
  if (decl) {
146
367
  const known = nameByNode.get(decl);
147
368
  if (known)
@@ -206,7 +427,8 @@ function parseModule(candidate, nameByNode, ctx) {
206
427
  permission: stringLiteralProp(meta, "permission"),
207
428
  transaction: commandModeProp(meta, "transaction") ?? "none",
208
429
  audit: stringLiteralProp(meta, "audit"),
209
- idempotency: commandModeProp(meta, "idempotency") ?? "none"
430
+ idempotency: commandModeProp(meta, "idempotency") ?? "none",
431
+ standalone: booleanProp(meta, "standalone") || undefined
210
432
  });
211
433
  }
212
434
  }
@@ -242,11 +464,13 @@ function commandModeProp(object, name) {
242
464
  function parseProvider(el, exportsSet, ctx) {
243
465
  const file = sourcePath(ctx.rootDir, el.getSourceFile().getFilePath());
244
466
  const line = el.getStartLineNumber();
245
- if (Node.isIdentifier(el)) {
246
- const decl = resolveDeclaration(el)[0];
467
+ const unwrappedEl = unwrapForwardRef(el);
468
+ if (Node.isIdentifier(unwrappedEl)) {
469
+ const decl = resolveDeclaration(unwrappedEl)[0];
247
470
  const cls = decl && Node.isClassDeclaration(decl) ? decl : undefined;
248
- const className = cls?.getName() ?? el.getText();
249
- const { deps, missing } = cls ? classDeps(cls, ctx) : { deps: [], missing: false };
471
+ const className = cls?.getName() ?? unwrappedEl.getText();
472
+ const { deps, optionalDeps, selfDeps, skipSelfDeps, hostDeps, missing } = cls ? classDeps(cls, ctx) : { deps: [], optionalDeps: [], selfDeps: [], skipSelfDeps: [], hostDeps: [], missing: false };
473
+ const injectable = cls ? parseInjectableOptions(cls, ctx) : undefined;
250
474
  if (missing) {
251
475
  warn(ctx, "missing-deps", `provider ${className} 的部分构造依赖无法静态解析`, file, line);
252
476
  }
@@ -257,6 +481,12 @@ function parseProvider(el, exportsSet, ctx) {
257
481
  useClass: className,
258
482
  scope: resolveScope({ cls, tokenName: className }, ctx),
259
483
  deps,
484
+ optionalDeps: optionalDeps.length > 0 ? optionalDeps : undefined,
485
+ selfDeps: selfDeps.length > 0 ? selfDeps : undefined,
486
+ skipSelfDeps: skipSelfDeps.length > 0 ? skipSelfDeps : undefined,
487
+ hostDeps: hostDeps.length > 0 ? hostDeps : undefined,
488
+ providedIn: injectable?.providedIn,
489
+ hasOnDestroy: cls?.getMethod("onDestroy") !== undefined || undefined,
260
490
  exported: exportsSet.has(className),
261
491
  file,
262
492
  line,
@@ -271,22 +501,33 @@ function parseProvider(el, exportsSet, ctx) {
271
501
  const { name: token, kind: tokenKind } = tokenNameOf(provideExpr, ctx);
272
502
  const explicitScope = parseScopeProp(el);
273
503
  const explicitDeps = arrayProp(el, "deps").map((d) => tokenNameOf(d, ctx).name);
504
+ const multi = booleanProp(el, "multi");
274
505
  const useClassExpr = getProp(el, "useClass");
275
506
  const useValueExpr = getProp(el, "useValue");
276
507
  const useFactoryExpr = getProp(el, "useFactory");
277
508
  const useExistingExpr = getProp(el, "useExisting");
278
509
  if (useClassExpr) {
279
- const decl = Node.isIdentifier(useClassExpr) ? resolveDeclaration(useClassExpr)[0] : undefined;
510
+ const unwrappedClass = unwrapForwardRef(useClassExpr);
511
+ const decl = Node.isIdentifier(unwrappedClass) ? resolveDeclaration(unwrappedClass)[0] : undefined;
280
512
  const cls = decl && Node.isClassDeclaration(decl) ? decl : undefined;
281
- const useClass = cls?.getName() ?? useClassExpr.getText();
513
+ const useClass = cls?.getName() ?? unwrappedClass.getText();
282
514
  let deps = explicitDeps;
515
+ let optionalDeps = [];
516
+ let selfDeps = [];
517
+ let skipSelfDeps = [];
518
+ let hostDeps = [];
283
519
  if (deps.length === 0 && cls) {
284
520
  const result = classDeps(cls, ctx);
285
521
  deps = result.deps;
522
+ optionalDeps = result.optionalDeps;
523
+ selfDeps = result.selfDeps;
524
+ skipSelfDeps = result.skipSelfDeps;
525
+ hostDeps = result.hostDeps;
286
526
  if (result.missing) {
287
527
  warn(ctx, "missing-deps", `provider ${token} (useClass ${useClass}) 的部分构造依赖无法静态解析`, file, line);
288
528
  }
289
529
  }
530
+ const injectable = cls ? parseInjectableOptions(cls, ctx) : undefined;
290
531
  return {
291
532
  token,
292
533
  tokenKind,
@@ -294,6 +535,13 @@ function parseProvider(el, exportsSet, ctx) {
294
535
  useClass,
295
536
  scope: resolveScope({ explicit: explicitScope, cls, tokenName: token }, ctx),
296
537
  deps,
538
+ optionalDeps: optionalDeps.length > 0 ? optionalDeps : undefined,
539
+ selfDeps: selfDeps.length > 0 ? selfDeps : undefined,
540
+ skipSelfDeps: skipSelfDeps.length > 0 ? skipSelfDeps : undefined,
541
+ hostDeps: hostDeps.length > 0 ? hostDeps : undefined,
542
+ multi: multi ?? undefined,
543
+ providedIn: injectable?.providedIn,
544
+ hasOnDestroy: cls?.getMethod("onDestroy") !== undefined || undefined,
297
545
  exported: exportsSet.has(token),
298
546
  file,
299
547
  line,
@@ -308,6 +556,7 @@ function parseProvider(el, exportsSet, ctx) {
308
556
  useValueExpr: useValueExpr.getText(),
309
557
  scope: resolveScope({ explicit: explicitScope, tokenName: token }, ctx),
310
558
  deps: [],
559
+ multi: multi ?? undefined,
311
560
  exported: exportsSet.has(token),
312
561
  file,
313
562
  line,
@@ -326,6 +575,7 @@ function parseProvider(el, exportsSet, ctx) {
326
575
  useFactoryName: factoryName,
327
576
  scope: resolveScope({ explicit: explicitScope, tokenName: token }, ctx),
328
577
  deps: explicitDeps,
578
+ multi: multi ?? undefined,
329
579
  exported: exportsSet.has(token),
330
580
  file,
331
581
  line,
@@ -341,6 +591,7 @@ function parseProvider(el, exportsSet, ctx) {
341
591
  useExisting: target,
342
592
  scope: resolveScope({ explicit: explicitScope, tokenName: token }, ctx),
343
593
  deps: [target],
594
+ multi: multi ?? undefined,
344
595
  exported: exportsSet.has(token),
345
596
  file,
346
597
  line
@@ -348,18 +599,36 @@ function parseProvider(el, exportsSet, ctx) {
348
599
  }
349
600
  return;
350
601
  }
351
- function parseController(el, ctx) {
352
- if (!Node.isIdentifier(el))
353
- return;
354
- const decl = resolveDeclaration(el)[0];
355
- if (!decl || !Node.isClassDeclaration(decl))
602
+ function parseController(input, ctx) {
603
+ let decl;
604
+ if (Node.isClassDeclaration(input)) {
605
+ decl = input;
606
+ } else {
607
+ const unwrapped = unwrapForwardRef(input);
608
+ const resolved = Node.isIdentifier(unwrapped) ? resolveDeclaration(unwrapped)[0] : undefined;
609
+ if (resolved && Node.isClassDeclaration(resolved)) {
610
+ decl = resolved;
611
+ }
612
+ }
613
+ if (!decl)
356
614
  return;
357
615
  const controllerDec = findDecorator(decl, "Controller");
358
616
  if (!controllerDec)
359
617
  return;
618
+ let path = "/";
619
+ let standalone;
360
620
  const pathArg = controllerDec.getArguments()[0];
361
- const path = pathArg && Node.isStringLiteral(pathArg) ? pathArg.getLiteralText() : "/";
362
- const { deps, missing } = classDeps(decl, ctx);
621
+ if (pathArg) {
622
+ if (Node.isStringLiteral(pathArg)) {
623
+ path = pathArg.getLiteralText();
624
+ } else if (Node.isObjectLiteralExpression(pathArg)) {
625
+ const p = stringLiteralProp(pathArg, "path");
626
+ if (p)
627
+ path = p;
628
+ standalone = booleanProp(pathArg, "standalone");
629
+ }
630
+ }
631
+ const { deps, optionalDeps, selfDeps, skipSelfDeps, missing } = classDeps(decl, ctx);
363
632
  const file = sourcePath(ctx.rootDir, decl.getSourceFile().getFilePath());
364
633
  if (missing) {
365
634
  warn(ctx, "missing-deps", `controller ${decl.getName()} 的部分构造依赖无法静态解析`, file, decl.getStartLineNumber());
@@ -367,6 +636,14 @@ function parseController(el, ctx) {
367
636
  const injectable = parseInjectableOptions(decl, ctx);
368
637
  const routes = [];
369
638
  const schemaImports = {};
639
+ const classGuards = [];
640
+ for (const dec of decl.getDecorators()) {
641
+ if (decoratorName(dec) === "UseGuards") {
642
+ for (const gArg of dec.getArguments()) {
643
+ classGuards.push(tokenText(gArg));
644
+ }
645
+ }
646
+ }
370
647
  for (const method of decl.getMethods()) {
371
648
  for (const dec of method.getDecorators()) {
372
649
  const name = decoratorName(dec);
@@ -375,11 +652,158 @@ function parseController(el, ctx) {
375
652
  continue;
376
653
  const args = dec.getArguments();
377
654
  const pathArg2 = args[0];
655
+ const routePath = pathArg2 && Node.isStringLiteral(pathArg2) ? pathArg2.getLiteralText() : "/";
378
656
  const route = {
379
657
  method: httpMethod,
380
- path: pathArg2 && Node.isStringLiteral(pathArg2) ? pathArg2.getLiteralText() : "/",
658
+ path: routePath,
381
659
  handler: method.getName()
382
660
  };
661
+ const pathParams = [];
662
+ const paramRegex = /:([a-zA-Z0-9_]+)/g;
663
+ let match;
664
+ while ((match = paramRegex.exec(routePath)) !== null) {
665
+ pathParams.push(match[1]);
666
+ }
667
+ if (pathParams.length > 0)
668
+ route.pathParams = pathParams;
669
+ const paramBindings = [];
670
+ const queryBindings = [];
671
+ const paramTransforms = {};
672
+ const paramDefaults = {};
673
+ const queryTransforms = {};
674
+ const queryDefaults = {};
675
+ let hasBodyBinding = false;
676
+ const handlerParams = [];
677
+ for (const p of method.getParameters()) {
678
+ const pName = p.getName();
679
+ let hasBindingDecorator = false;
680
+ let paramNode;
681
+ for (const pDec of p.getDecorators()) {
682
+ const dName = decoratorName(pDec);
683
+ const dArgs = pDec.getArguments();
684
+ if (dName === "Param") {
685
+ hasBindingDecorator = true;
686
+ const parsed = parseBindingOptions(dArgs, pName);
687
+ paramBindings.push(parsed.name);
688
+ if (parsed.transform)
689
+ paramTransforms[parsed.name] = parsed.transform;
690
+ if (parsed.default !== undefined)
691
+ paramDefaults[parsed.name] = parsed.default;
692
+ paramNode = {
693
+ name: pName,
694
+ kind: "param",
695
+ bindingName: parsed.name,
696
+ transform: parsed.transform,
697
+ default: parsed.default
698
+ };
699
+ } else if (dName === "Query") {
700
+ hasBindingDecorator = true;
701
+ const parsed = parseBindingOptions(dArgs, pName);
702
+ queryBindings.push(parsed.name);
703
+ if (parsed.transform)
704
+ queryTransforms[parsed.name] = parsed.transform;
705
+ if (parsed.default !== undefined)
706
+ queryDefaults[parsed.name] = parsed.default;
707
+ paramNode = {
708
+ name: pName,
709
+ kind: "query",
710
+ bindingName: parsed.name,
711
+ transform: parsed.transform,
712
+ default: parsed.default
713
+ };
714
+ } else if (dName === "Body") {
715
+ hasBindingDecorator = true;
716
+ hasBodyBinding = true;
717
+ paramNode = { name: pName, kind: "body" };
718
+ } else if (dName === "Headers") {
719
+ hasBindingDecorator = true;
720
+ paramNode = { name: pName, kind: "headers" };
721
+ }
722
+ }
723
+ if (!hasBindingDecorator && pathParams.includes(pName)) {
724
+ paramBindings.push(pName);
725
+ const typeText = p.getType().getText();
726
+ let inferredTransform;
727
+ if (typeText === "number") {
728
+ paramTransforms[pName] = "number";
729
+ inferredTransform = "number";
730
+ } else if (typeText === "boolean") {
731
+ paramTransforms[pName] = "boolean";
732
+ inferredTransform = "boolean";
733
+ }
734
+ paramNode = {
735
+ name: pName,
736
+ kind: "param",
737
+ bindingName: pName,
738
+ transform: inferredTransform
739
+ };
740
+ } else if (!hasBindingDecorator) {
741
+ if (pName === "req" || pName === "ctx" || pName === "context") {
742
+ paramNode = { name: pName, kind: "context" };
743
+ } else {
744
+ paramNode = { name: pName, kind: "unknown" };
745
+ }
746
+ }
747
+ if (paramNode)
748
+ handlerParams.push(paramNode);
749
+ }
750
+ if (paramBindings.length > 0)
751
+ route.paramBindings = paramBindings;
752
+ if (queryBindings.length > 0)
753
+ route.queryBindings = queryBindings;
754
+ if (Object.keys(paramTransforms).length > 0)
755
+ route.paramTransforms = paramTransforms;
756
+ if (Object.keys(paramDefaults).length > 0)
757
+ route.paramDefaults = paramDefaults;
758
+ if (Object.keys(queryTransforms).length > 0)
759
+ route.queryTransforms = queryTransforms;
760
+ if (Object.keys(queryDefaults).length > 0)
761
+ route.queryDefaults = queryDefaults;
762
+ if (hasBodyBinding)
763
+ route.hasBodyBinding = true;
764
+ if (handlerParams.length > 0)
765
+ route.handlerParams = handlerParams;
766
+ const routeGuards = [...classGuards];
767
+ const routeCanDeactivate = [];
768
+ for (const mDec of method.getDecorators()) {
769
+ const dName = decoratorName(mDec);
770
+ const mArgs = mDec.getArguments();
771
+ if (dName === "UseGuards") {
772
+ for (const gArg of mArgs) {
773
+ routeGuards.push(tokenText(gArg));
774
+ }
775
+ } else if (dName === "CanDeactivate") {
776
+ for (const gArg of mArgs) {
777
+ routeCanDeactivate.push(tokenText(gArg));
778
+ }
779
+ } else if (dName === "Title") {
780
+ const tArg = mArgs[0];
781
+ if (tArg && Node.isStringLiteral(tArg)) {
782
+ route.title = tArg.getLiteralText();
783
+ }
784
+ } else if (dName === "Data") {
785
+ const dArg = mArgs[0];
786
+ if (dArg && Node.isObjectLiteralExpression(dArg)) {
787
+ route.data = { ...route.data, ...parseObjectLiteralValues(dArg) };
788
+ }
789
+ } else if (dName === "Resolve") {
790
+ const rArg = mArgs[0];
791
+ if (rArg && Node.isObjectLiteralExpression(rArg)) {
792
+ const resolvers = route.resolvers ?? {};
793
+ for (const prop of rArg.getProperties()) {
794
+ if (Node.isPropertyAssignment(prop)) {
795
+ const rName = prop.getName();
796
+ const init = prop.getInitializer();
797
+ if (init)
798
+ resolvers[rName] = tokenText(init);
799
+ }
800
+ }
801
+ if (Object.keys(resolvers).length > 0) {
802
+ route.resolvers = resolvers;
803
+ }
804
+ }
805
+ }
806
+ }
383
807
  const optionsArg = args[1];
384
808
  if (optionsArg && Node.isObjectLiteralExpression(optionsArg)) {
385
809
  for (const field of ["body", "params", "query", "response"]) {
@@ -396,6 +820,68 @@ function parseController(el, ctx) {
396
820
  const commandDecl = resolveDeclaration(commandExpr)[0];
397
821
  route.command = commandDecl && Node.isClassDeclaration(commandDecl) ? commandDecl.getName() ?? commandExpr.getText() : commandExpr.getText();
398
822
  }
823
+ const guardsExpr = getProp(optionsArg, "guards");
824
+ if (guardsExpr && Node.isArrayLiteralExpression(guardsExpr)) {
825
+ for (const el of guardsExpr.getElements()) {
826
+ routeGuards.push(tokenText(el));
827
+ }
828
+ }
829
+ const canMatchExpr = getProp(optionsArg, "canMatch");
830
+ if (canMatchExpr && Node.isArrayLiteralExpression(canMatchExpr)) {
831
+ const canMatchList = [];
832
+ for (const el of canMatchExpr.getElements()) {
833
+ canMatchList.push(tokenText(el));
834
+ }
835
+ if (canMatchList.length > 0) {
836
+ route.canMatch = canMatchList;
837
+ }
838
+ }
839
+ const canDeactivateExpr = getProp(optionsArg, "canDeactivate");
840
+ if (canDeactivateExpr && Node.isArrayLiteralExpression(canDeactivateExpr)) {
841
+ for (const el of canDeactivateExpr.getElements()) {
842
+ routeCanDeactivate.push(tokenText(el));
843
+ }
844
+ }
845
+ const resolversExpr = getProp(optionsArg, "resolvers");
846
+ if (resolversExpr && Node.isObjectLiteralExpression(resolversExpr)) {
847
+ const resolvers = {};
848
+ for (const prop of resolversExpr.getProperties()) {
849
+ if (Node.isPropertyAssignment(prop)) {
850
+ const rName = prop.getName();
851
+ const init = prop.getInitializer();
852
+ if (init)
853
+ resolvers[rName] = tokenText(init);
854
+ }
855
+ }
856
+ if (Object.keys(resolvers).length > 0) {
857
+ route.resolvers = resolvers;
858
+ }
859
+ }
860
+ const redirectToExpr = getProp(optionsArg, "redirectTo");
861
+ if (redirectToExpr && Node.isStringLiteral(redirectToExpr)) {
862
+ route.redirectTo = redirectToExpr.getLiteralText();
863
+ }
864
+ const pathMatchExpr = getProp(optionsArg, "pathMatch");
865
+ if (pathMatchExpr && Node.isStringLiteral(pathMatchExpr)) {
866
+ const val = pathMatchExpr.getLiteralText();
867
+ if (val === "full" || val === "prefix") {
868
+ route.pathMatch = val;
869
+ }
870
+ }
871
+ const titleExpr = getProp(optionsArg, "title");
872
+ if (titleExpr && Node.isStringLiteral(titleExpr)) {
873
+ route.title = titleExpr.getLiteralText();
874
+ }
875
+ const dataExpr = getProp(optionsArg, "data");
876
+ if (dataExpr && Node.isObjectLiteralExpression(dataExpr)) {
877
+ route.data = { ...route.data, ...parseObjectLiteralValues(dataExpr) };
878
+ }
879
+ }
880
+ if (routeGuards.length > 0) {
881
+ route.guards = routeGuards;
882
+ }
883
+ if (routeCanDeactivate.length > 0) {
884
+ route.canDeactivate = routeCanDeactivate;
399
885
  }
400
886
  routes.push(route);
401
887
  }
@@ -405,6 +891,10 @@ function parseController(el, ctx) {
405
891
  path,
406
892
  scope: injectable?.scope ?? "request",
407
893
  deps,
894
+ optionalDeps: optionalDeps.length > 0 ? optionalDeps : undefined,
895
+ selfDeps: selfDeps.length > 0 ? selfDeps : undefined,
896
+ skipSelfDeps: skipSelfDeps.length > 0 ? skipSelfDeps : undefined,
897
+ standalone: standalone || undefined,
408
898
  routes,
409
899
  file,
410
900
  importPath: modulePath(ctx.rootDir, decl.getSourceFile().getFilePath()),
@@ -413,28 +903,84 @@ function parseController(el, ctx) {
413
903
  }
414
904
  function classDeps(cls, ctx) {
415
905
  const injectable = parseInjectableOptions(cls, ctx);
416
- if (injectable?.deps)
417
- return { deps: injectable.deps, missing: false };
906
+ if (injectable?.deps) {
907
+ return {
908
+ deps: injectable.deps,
909
+ optionalDeps: [],
910
+ selfDeps: [],
911
+ skipSelfDeps: [],
912
+ hostDeps: [],
913
+ missing: false
914
+ };
915
+ }
418
916
  const ctor = cls.getConstructors()[0];
419
- if (!ctor || ctor.getParameters().length === 0)
420
- return { deps: [], missing: false };
421
- const injectParams = parseInjectParams(cls);
422
917
  const deps = [];
918
+ const optionalDeps = [];
919
+ const selfDeps = [];
920
+ const skipSelfDeps = [];
921
+ const hostDeps = [];
423
922
  let missing = false;
424
- ctor.getParameters().forEach((param, index) => {
425
- const injected = injectParams.get(index);
426
- if (injected) {
427
- deps.push(injected);
428
- return;
429
- }
430
- const byType = paramTypeTokenName(param, ctx);
431
- if (byType) {
432
- deps.push(byType);
433
- } else {
434
- missing = true;
923
+ if (ctor && ctor.getParameters().length > 0) {
924
+ const injectParams = parseInjectParams(cls);
925
+ const optionalIndices = parseOptionalParams(cls);
926
+ const selfIndices = parseModifierParams(cls, "Self");
927
+ const skipSelfIndices = parseModifierParams(cls, "SkipSelf");
928
+ const hostIndices = parseModifierParams(cls, "Host");
929
+ ctor.getParameters().forEach((param, index) => {
930
+ const isOptional = optionalIndices.has(index);
931
+ const injected = injectParams.get(index);
932
+ const tokenName = injected ?? paramTypeTokenName(param, ctx);
933
+ if (tokenName) {
934
+ deps.push(tokenName);
935
+ if (isOptional)
936
+ optionalDeps.push(tokenName);
937
+ if (selfIndices.has(index))
938
+ selfDeps.push(tokenName);
939
+ if (skipSelfIndices.has(index))
940
+ skipSelfDeps.push(tokenName);
941
+ if (hostIndices.has(index))
942
+ hostDeps.push(tokenName);
943
+ } else {
944
+ if (!isOptional)
945
+ missing = true;
946
+ }
947
+ });
948
+ }
949
+ for (const prop of cls.getProperties()) {
950
+ const init = prop.getInitializer();
951
+ if (init && Node.isCallExpression(init)) {
952
+ const callName = init.getExpression().getText().split(".").pop();
953
+ if (callName === "inject") {
954
+ const [tokenArg, optionsArg] = init.getArguments();
955
+ if (tokenArg) {
956
+ const tokenName = tokenText(tokenArg);
957
+ if (tokenName) {
958
+ if (!deps.includes(tokenName))
959
+ deps.push(tokenName);
960
+ if (optionsArg && Node.isObjectLiteralExpression(optionsArg)) {
961
+ const isOptional = booleanProp(optionsArg, "optional");
962
+ if (isOptional && !optionalDeps.includes(tokenName)) {
963
+ optionalDeps.push(tokenName);
964
+ }
965
+ const isSelf = booleanProp(optionsArg, "self");
966
+ if (isSelf && !selfDeps.includes(tokenName)) {
967
+ selfDeps.push(tokenName);
968
+ }
969
+ const isSkipSelf = booleanProp(optionsArg, "skipSelf");
970
+ if (isSkipSelf && !skipSelfDeps.includes(tokenName)) {
971
+ skipSelfDeps.push(tokenName);
972
+ }
973
+ const isHost = booleanProp(optionsArg, "host");
974
+ if (isHost && !hostDeps.includes(tokenName)) {
975
+ hostDeps.push(tokenName);
976
+ }
977
+ }
978
+ }
979
+ }
980
+ }
435
981
  }
436
- });
437
- return { deps, missing };
982
+ }
983
+ return { deps, optionalDeps, selfDeps, skipSelfDeps, hostDeps, missing };
438
984
  }
439
985
  function paramTypeTokenName(param, ctx) {
440
986
  const typeNode = param.getTypeNode();
@@ -455,9 +1001,11 @@ function parseInjectableOptions(cls, ctx) {
455
1001
  if (!obj)
456
1002
  return {};
457
1003
  const scope = stringLiteralProp(obj, "scope");
1004
+ const providedIn = stringLiteralProp(obj, "providedIn");
458
1005
  const depsExpr = getProp(obj, "deps");
459
1006
  return {
460
1007
  scope: scope && SCOPES.includes(scope) ? scope : undefined,
1008
+ providedIn: providedIn === "root" ? "root" : undefined,
461
1009
  deps: depsExpr ? arrayProp(obj, "deps").map((el) => ctx ? tokenNameOf(el, ctx).name : el.getText()) : undefined
462
1010
  };
463
1011
  }
@@ -477,15 +1025,61 @@ function parseInjectParams(cls) {
477
1025
  });
478
1026
  return result;
479
1027
  }
1028
+ function parseOptionalParams(cls) {
1029
+ const result = new Set;
1030
+ const ctor = cls.getConstructors()[0];
1031
+ if (!ctor)
1032
+ return result;
1033
+ ctor.getParameters().forEach((param, index) => {
1034
+ for (const dec of param.getDecorators()) {
1035
+ if (decoratorName(dec) === "Optional")
1036
+ result.add(index);
1037
+ }
1038
+ if (param.hasQuestionToken())
1039
+ result.add(index);
1040
+ });
1041
+ return result;
1042
+ }
1043
+ function parseModifierParams(cls, modifierName) {
1044
+ const result = new Set;
1045
+ const ctor = cls.getConstructors()[0];
1046
+ if (!ctor)
1047
+ return result;
1048
+ ctor.getParameters().forEach((param, index) => {
1049
+ for (const dec of param.getDecorators()) {
1050
+ if (decoratorName(dec) === modifierName)
1051
+ result.add(index);
1052
+ }
1053
+ });
1054
+ return result;
1055
+ }
1056
+ function unwrapForwardRef(expr) {
1057
+ if (Node.isCallExpression(expr)) {
1058
+ const exprText = expr.getExpression().getText();
1059
+ if (exprText === "forwardRef" || exprText.endsWith(".forwardRef")) {
1060
+ const arg = expr.getArguments()[0];
1061
+ if (arg && (Node.isArrowFunction(arg) || Node.isFunctionExpression(arg))) {
1062
+ const body = arg.getBody();
1063
+ if (body && Node.isExpression(body)) {
1064
+ return unwrapForwardRef(body);
1065
+ }
1066
+ }
1067
+ }
1068
+ }
1069
+ return expr;
1070
+ }
480
1071
  function tokenText(expr) {
481
- if (Node.isIdentifier(expr)) {
482
- const decl = resolveDeclaration(expr)[0];
1072
+ const unwrapped = unwrapForwardRef(expr);
1073
+ if (Node.isStringLiteral(unwrapped))
1074
+ return unwrapped.getLiteralText();
1075
+ if (Node.isIdentifier(unwrapped)) {
1076
+ const decl = resolveDeclaration(unwrapped)[0];
483
1077
  if (decl && Node.isClassDeclaration(decl))
484
- return decl.getName() ?? expr.getText();
1078
+ return decl.getName() ?? unwrapped.getText();
485
1079
  if (decl && Node.isVariableDeclaration(decl))
486
1080
  return decl.getName();
487
1081
  }
488
- return expr.getText();
1082
+ return unwrapped.getText();
489
1083
  }
490
1084
  function resolveScope(input, ctx) {
491
1085
  if (input.explicit)
@@ -501,8 +1095,9 @@ function resolveScope(input, ctx) {
501
1095
  return "application";
502
1096
  }
503
1097
  function tokenNameOf(expr, ctx) {
504
- if (Node.isIdentifier(expr)) {
505
- const decl = resolveDeclaration(expr)[0];
1098
+ const unwrapped = unwrapForwardRef(expr);
1099
+ if (Node.isIdentifier(unwrapped)) {
1100
+ const decl = resolveDeclaration(unwrapped)[0];
506
1101
  if (decl && Node.isClassDeclaration(decl)) {
507
1102
  return { name: decl.getName() ?? expr.getText(), kind: "class" };
508
1103
  }
@@ -580,10 +1175,90 @@ function arrayProp(obj, name) {
580
1175
  const expr = getProp(obj, name);
581
1176
  return expr && Node.isArrayLiteralExpression(expr) ? expr.getElements() : [];
582
1177
  }
1178
+ function booleanProp(obj, name) {
1179
+ const expr = getProp(obj, name);
1180
+ if (!expr)
1181
+ return;
1182
+ if (expr.getKind() === SyntaxKind.TrueKeyword)
1183
+ return true;
1184
+ if (expr.getKind() === SyntaxKind.FalseKeyword)
1185
+ return false;
1186
+ return;
1187
+ }
583
1188
  function parseScopeProp(obj) {
584
1189
  const scope = stringLiteralProp(obj, "scope");
585
1190
  return scope && SCOPES.includes(scope) ? scope : undefined;
586
1191
  }
1192
+ function parseBindingOptions(args, defaultName) {
1193
+ let name = defaultName;
1194
+ let transform;
1195
+ let defaultValue;
1196
+ const first = args[0];
1197
+ const second = args[1];
1198
+ if (first && Node.isStringLiteral(first)) {
1199
+ name = first.getLiteralText();
1200
+ } else if (first && Node.isObjectLiteralExpression(first)) {
1201
+ const nameProp = getProp(first, "name");
1202
+ if (nameProp && Node.isStringLiteral(nameProp)) {
1203
+ name = nameProp.getLiteralText();
1204
+ }
1205
+ const trProp = getProp(first, "transform");
1206
+ if (trProp && Node.isStringLiteral(trProp)) {
1207
+ const val = trProp.getLiteralText();
1208
+ if (val === "number" || val === "boolean" || val === "string") {
1209
+ transform = val;
1210
+ }
1211
+ }
1212
+ const defProp = getProp(first, "default");
1213
+ if (defProp) {
1214
+ defaultValue = parseLiteralValue(defProp);
1215
+ }
1216
+ }
1217
+ if (second && Node.isObjectLiteralExpression(second)) {
1218
+ const trProp = getProp(second, "transform");
1219
+ if (trProp && Node.isStringLiteral(trProp)) {
1220
+ const val = trProp.getLiteralText();
1221
+ if (val === "number" || val === "boolean" || val === "string") {
1222
+ transform = val;
1223
+ }
1224
+ }
1225
+ const defProp = getProp(second, "default");
1226
+ if (defProp) {
1227
+ defaultValue = parseLiteralValue(defProp);
1228
+ }
1229
+ }
1230
+ return { name, transform, default: defaultValue };
1231
+ }
1232
+ function parseLiteralValue(node) {
1233
+ if (Node.isStringLiteral(node))
1234
+ return node.getLiteralText();
1235
+ if (Node.isNumericLiteral(node))
1236
+ return node.getLiteralValue();
1237
+ if (node.getKindName() === "TrueKeyword")
1238
+ return true;
1239
+ if (node.getKindName() === "FalseKeyword")
1240
+ return false;
1241
+ if (Node.isArrayLiteralExpression(node)) {
1242
+ return node.getElements().map(parseLiteralValue);
1243
+ }
1244
+ if (Node.isObjectLiteralExpression(node)) {
1245
+ return parseObjectLiteralValues(node);
1246
+ }
1247
+ return;
1248
+ }
1249
+ function parseObjectLiteralValues(obj) {
1250
+ const result = {};
1251
+ for (const prop of obj.getProperties()) {
1252
+ if (Node.isPropertyAssignment(prop)) {
1253
+ const name = prop.getName();
1254
+ const init = prop.getInitializer();
1255
+ if (init) {
1256
+ result[name] = parseLiteralValue(init);
1257
+ }
1258
+ }
1259
+ }
1260
+ return result;
1261
+ }
587
1262
  function modulePath(rootDir, absFile) {
588
1263
  return sourcePath(rootDir, absFile).replace(/\.(ts|tsx|js|mts|cts)$/, "");
589
1264
  }
@@ -594,7 +1269,7 @@ function warn(ctx, code, message, file, line) {
594
1269
  ctx.diagnostics.push({ severity: "warn", code, message, file, line });
595
1270
  }
596
1271
  // src/generate.ts
597
- import { mkdir, writeFile } from "node:fs/promises";
1272
+ import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
598
1273
  import { join as join2 } from "node:path";
599
1274
 
600
1275
  // src/util.ts
@@ -627,6 +1302,28 @@ function isRequestContextToken(token, tokenNames) {
627
1302
  function isJobContextToken(token, tokenNames) {
628
1303
  return token === "JOB_CONTEXT" || tokenNames?.[token] === JOB_CONTEXT_TOKEN_NAME;
629
1304
  }
1305
+ function joinRoutePaths(prefix, path) {
1306
+ const joined = `${prefix}/${path}`.replace(/\/{2,}/g, "/");
1307
+ const normalized = joined.length > 1 ? joined.replace(/\/+$/, "") : joined;
1308
+ return normalized;
1309
+ }
1310
+ function findClosestMatch(target, candidates) {
1311
+ if (candidates.length === 0)
1312
+ return;
1313
+ if (candidates.length === 1)
1314
+ return candidates[0];
1315
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
1316
+ const targetNorm = norm(target);
1317
+ for (const c of candidates) {
1318
+ if (norm(c) === targetNorm)
1319
+ return c;
1320
+ }
1321
+ for (const c of candidates) {
1322
+ if (norm(c).includes(targetNorm) || targetNorm.includes(norm(c)))
1323
+ return c;
1324
+ }
1325
+ return candidates[0];
1326
+ }
630
1327
 
631
1328
  // src/generate.ts
632
1329
  var HEADER = "// GENERATED BY @supacloud/compiler — do not edit";
@@ -639,6 +1336,28 @@ var INTERFACES = `export interface CompiledRoute {
639
1336
  query?: unknown;
640
1337
  response?: unknown;
641
1338
  command?: string;
1339
+ guards?: string[];
1340
+ canMatch?: string[];
1341
+ canDeactivate?: string[];
1342
+ resolvers?: Record<string, string>;
1343
+ redirectTo?: string;
1344
+ pathMatch?: "full" | "prefix";
1345
+ paramTransforms?: Record<string, "number" | "boolean" | "string">;
1346
+ paramDefaults?: Record<string, unknown>;
1347
+ queryTransforms?: Record<string, "number" | "boolean" | "string">;
1348
+ queryDefaults?: Record<string, unknown>;
1349
+ title?: string;
1350
+ data?: Record<string, unknown>;
1351
+ invoker?: (
1352
+ controller: unknown,
1353
+ request: {
1354
+ params?: Record<string, unknown>;
1355
+ query?: Record<string, unknown>;
1356
+ body?: unknown;
1357
+ headers?: Record<string, unknown>;
1358
+ context?: unknown;
1359
+ },
1360
+ ) => Promise<unknown> | unknown;
642
1361
  }
643
1362
 
644
1363
  export interface CompiledCommand {
@@ -648,6 +1367,7 @@ export interface CompiledCommand {
648
1367
  transaction: "required" | "none";
649
1368
  audit?: string;
650
1369
  idempotency: "required" | "none";
1370
+ standalone?: boolean;
651
1371
  }
652
1372
 
653
1373
  export interface CompiledController {
@@ -677,7 +1397,40 @@ export interface CompiledModule {
677
1397
  commands: CompiledCommand[];
678
1398
  }`;
679
1399
  function renderApplication(graph, options) {
680
- const modules = topoSortModules(graph.modules);
1400
+ let modules = topoSortModules(graph.modules);
1401
+ if (options.treeShakeUnusedProviders) {
1402
+ const referencedTokens = new Set;
1403
+ for (const mod of graph.modules) {
1404
+ for (const exp of mod.exports)
1405
+ referencedTokens.add(exp);
1406
+ for (const ctrl of mod.controllers) {
1407
+ for (const d of ctrl.deps)
1408
+ referencedTokens.add(d);
1409
+ for (const d of ctrl.optionalDeps ?? [])
1410
+ referencedTokens.add(d);
1411
+ for (const d of ctrl.selfDeps ?? [])
1412
+ referencedTokens.add(d);
1413
+ for (const d of ctrl.skipSelfDeps ?? [])
1414
+ referencedTokens.add(d);
1415
+ }
1416
+ for (const p of mod.providers) {
1417
+ for (const d of p.deps ?? [])
1418
+ referencedTokens.add(d);
1419
+ for (const d of p.optionalDeps ?? [])
1420
+ referencedTokens.add(d);
1421
+ for (const d of p.selfDeps ?? [])
1422
+ referencedTokens.add(d);
1423
+ for (const d of p.skipSelfDeps ?? [])
1424
+ referencedTokens.add(d);
1425
+ if (p.useExisting)
1426
+ referencedTokens.add(p.useExisting);
1427
+ }
1428
+ }
1429
+ modules = modules.map((mod) => ({
1430
+ ...mod,
1431
+ providers: mod.providers.filter((p) => p.providedIn !== "root" || p.multi || referencedTokens.has(p.token) || p.exported)
1432
+ }));
1433
+ }
681
1434
  const imports = new ImportManager;
682
1435
  const factorySections = [];
683
1436
  const descriptorEntries = [];
@@ -699,6 +1452,34 @@ function renderApplication(graph, options) {
699
1452
  " ];",
700
1453
  "}",
701
1454
  "",
1455
+ "export async function initializeApplication(services: Record<string, unknown>): Promise<void> {",
1456
+ ' const initializers = (services.appInitializer ?? (services as any)["supacloud.app-initializer"]) as unknown;',
1457
+ " if (Array.isArray(initializers)) {",
1458
+ " for (const init of initializers) {",
1459
+ ' if (typeof init === "function") await init();',
1460
+ " }",
1461
+ ' } else if (typeof initializers === "function") {',
1462
+ " await (initializers as () => unknown)();",
1463
+ " }",
1464
+ "}",
1465
+ "",
1466
+ "export async function destroyApplication(services: Record<string, unknown>): Promise<void> {",
1467
+ ' const destroyRef = (services.destroyRef ?? (services as any)["supacloud.destroy-ref"]) as { destroy?: () => Promise<void>; _teardowns?: Array<() => void | Promise<void>> } | undefined;',
1468
+ ' if (destroyRef && typeof destroyRef.destroy === "function") {',
1469
+ " await destroyRef.destroy();",
1470
+ " } else if (destroyRef && Array.isArray(destroyRef._teardowns)) {",
1471
+ " for (const teardown of [...destroyRef._teardowns].reverse()) {",
1472
+ ' if (typeof teardown === "function") await teardown();',
1473
+ " }",
1474
+ " }",
1475
+ " const instances = Object.values(services);",
1476
+ " for (const inst of instances.reverse()) {",
1477
+ ' if (inst && typeof (inst as any).onDestroy === "function") {',
1478
+ " await (inst as any).onDestroy();",
1479
+ " }",
1480
+ " }",
1481
+ "}",
1482
+ "",
702
1483
  ...factorySections,
703
1484
  ""
704
1485
  ].join(`
@@ -708,10 +1489,14 @@ function renderApplication(graph, options) {
708
1489
  modules: graph.modules,
709
1490
  externalTokens: graph.externalTokens
710
1491
  };
1492
+ const clientCode = options.generateClient ? renderClient(graph, options) : undefined;
1493
+ const permissionsCode = options.generatePermissions ? renderPermissions(graph) : undefined;
711
1494
  return {
712
1495
  applicationCode: code,
713
1496
  manifestJson: JSON.stringify(manifest, null, 2) + `
714
- `
1497
+ `,
1498
+ clientCode,
1499
+ permissionsCode
715
1500
  };
716
1501
  }
717
1502
  async function generateApplication(graph, options) {
@@ -719,9 +1504,32 @@ async function generateApplication(graph, options) {
719
1504
  await mkdir(options.outDir, { recursive: true });
720
1505
  const applicationPath = join2(options.outDir, "application.ts");
721
1506
  const manifestPath = join2(options.outDir, "app.manifest.json");
722
- await writeFile(applicationPath, rendered.applicationCode, "utf8");
723
- await writeFile(manifestPath, rendered.manifestJson, "utf8");
724
- return [applicationPath, manifestPath];
1507
+ await writeFileAtomic(applicationPath, rendered.applicationCode);
1508
+ await writeFileAtomic(manifestPath, rendered.manifestJson);
1509
+ const written = [applicationPath, manifestPath];
1510
+ if (rendered.clientCode) {
1511
+ const clientPath = join2(options.outDir, "client.ts");
1512
+ await writeFileAtomic(clientPath, rendered.clientCode);
1513
+ written.push(clientPath);
1514
+ }
1515
+ if (rendered.permissionsCode) {
1516
+ const permissionsPath = join2(options.outDir, "permissions.ts");
1517
+ await writeFileAtomic(permissionsPath, rendered.permissionsCode);
1518
+ written.push(permissionsPath);
1519
+ }
1520
+ return written;
1521
+ }
1522
+ async function writeFileAtomic(path, content) {
1523
+ const temporaryPath = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
1524
+ try {
1525
+ await writeFile(temporaryPath, content, "utf8");
1526
+ await rename(temporaryPath, path);
1527
+ } catch (error) {
1528
+ await unlink(temporaryPath).catch(() => {
1529
+ return;
1530
+ });
1531
+ throw error;
1532
+ }
725
1533
  }
726
1534
  function factoryOfScope(scope) {
727
1535
  return scope === "application" ? "services" : scope;
@@ -858,6 +1666,81 @@ class ModuleGenerator {
858
1666
  }
859
1667
  if (route.command)
860
1668
  fields.push(`command: ${JSON.stringify(route.command)}`);
1669
+ if (route.guards && route.guards.length > 0) {
1670
+ fields.push(`guards: ${JSON.stringify(route.guards)}`);
1671
+ }
1672
+ if (route.canMatch && route.canMatch.length > 0) {
1673
+ fields.push(`canMatch: ${JSON.stringify(route.canMatch)}`);
1674
+ }
1675
+ if (route.canDeactivate && route.canDeactivate.length > 0) {
1676
+ fields.push(`canDeactivate: ${JSON.stringify(route.canDeactivate)}`);
1677
+ }
1678
+ if (route.resolvers && Object.keys(route.resolvers).length > 0) {
1679
+ fields.push(`resolvers: ${JSON.stringify(route.resolvers)}`);
1680
+ }
1681
+ if (route.redirectTo) {
1682
+ fields.push(`redirectTo: ${JSON.stringify(route.redirectTo)}`);
1683
+ }
1684
+ if (route.pathMatch) {
1685
+ fields.push(`pathMatch: ${JSON.stringify(route.pathMatch)}`);
1686
+ }
1687
+ if (route.paramTransforms && Object.keys(route.paramTransforms).length > 0) {
1688
+ fields.push(`paramTransforms: ${JSON.stringify(route.paramTransforms)}`);
1689
+ }
1690
+ if (route.paramDefaults && Object.keys(route.paramDefaults).length > 0) {
1691
+ fields.push(`paramDefaults: ${JSON.stringify(route.paramDefaults)}`);
1692
+ }
1693
+ if (route.queryTransforms && Object.keys(route.queryTransforms).length > 0) {
1694
+ fields.push(`queryTransforms: ${JSON.stringify(route.queryTransforms)}`);
1695
+ }
1696
+ if (route.queryDefaults && Object.keys(route.queryDefaults).length > 0) {
1697
+ fields.push(`queryDefaults: ${JSON.stringify(route.queryDefaults)}`);
1698
+ }
1699
+ if (route.title) {
1700
+ fields.push(`title: ${JSON.stringify(route.title)}`);
1701
+ }
1702
+ if (route.data && Object.keys(route.data).length > 0) {
1703
+ fields.push(`data: ${JSON.stringify(route.data)}`);
1704
+ }
1705
+ const invokerArgs = (route.handlerParams ?? []).map((hp) => {
1706
+ if (hp.kind === "param") {
1707
+ const accessor = `req.params?.[${JSON.stringify(hp.bindingName ?? hp.name)}]`;
1708
+ const fallback = hp.default !== undefined ? JSON.stringify(hp.default) : "undefined";
1709
+ if (hp.transform === "number") {
1710
+ return `(${accessor} !== undefined ? Number(${accessor}) : ${fallback})`;
1711
+ }
1712
+ if (hp.transform === "boolean") {
1713
+ return `(${accessor} !== undefined ? Boolean(${accessor}) : ${fallback})`;
1714
+ }
1715
+ if (hp.transform === "string") {
1716
+ return `(${accessor} !== undefined ? String(${accessor}) : ${fallback})`;
1717
+ }
1718
+ return `(${accessor} !== undefined ? ${accessor} : ${fallback})`;
1719
+ }
1720
+ if (hp.kind === "query") {
1721
+ const accessor = `req.query?.[${JSON.stringify(hp.bindingName ?? hp.name)}]`;
1722
+ const fallback = hp.default !== undefined ? JSON.stringify(hp.default) : "undefined";
1723
+ if (hp.transform === "number") {
1724
+ return `(${accessor} !== undefined ? Number(${accessor}) : ${fallback})`;
1725
+ }
1726
+ if (hp.transform === "boolean") {
1727
+ return `(${accessor} !== undefined ? Boolean(${accessor}) : ${fallback})`;
1728
+ }
1729
+ if (hp.transform === "string") {
1730
+ return `(${accessor} !== undefined ? String(${accessor}) : ${fallback})`;
1731
+ }
1732
+ return `(${accessor} !== undefined ? ${accessor} : ${fallback})`;
1733
+ }
1734
+ if (hp.kind === "body")
1735
+ return "req.body";
1736
+ if (hp.kind === "headers")
1737
+ return "req.headers";
1738
+ if (hp.kind === "context")
1739
+ return "(req.context ?? req)";
1740
+ return "undefined";
1741
+ });
1742
+ const callArgs = invokerArgs.length > 0 ? invokerArgs.join(", ") : "req";
1743
+ fields.push(`invoker: async (ctrl: any, req: any) => await (ctrl as any).${route.handler}(${callArgs})`);
861
1744
  return `{ ${fields.join(", ")} }`;
862
1745
  });
863
1746
  return [
@@ -903,11 +1786,24 @@ ${indent(item, 2)}`).join(",")}
903
1786
  const controllers = this.module.controllers.filter((c) => factoryOfScope(c.scope) === kind);
904
1787
  const lines = [];
905
1788
  const returns = new Map;
1789
+ const multiGroups = new Map;
906
1790
  for (const provider of providers) {
907
- const emitted = this.emitProvider(provider, kind);
908
- if (emitted.constLine)
909
- lines.push(emitted.constLine);
910
- returns.set(emitted.key, emitted.expr);
1791
+ if (provider.multi) {
1792
+ const emitted = this.emitProvider(provider, kind, true);
1793
+ if (emitted.constLine)
1794
+ lines.push(emitted.constLine);
1795
+ const list = multiGroups.get(emitted.key) ?? [];
1796
+ list.push(emitted.expr);
1797
+ multiGroups.set(emitted.key, list);
1798
+ } else {
1799
+ const emitted = this.emitProvider(provider, kind, false);
1800
+ if (emitted.constLine)
1801
+ lines.push(emitted.constLine);
1802
+ returns.set(emitted.key, emitted.expr);
1803
+ }
1804
+ }
1805
+ for (const [key, exprs] of multiGroups) {
1806
+ returns.set(key, `[${exprs.join(", ")}]`);
911
1807
  }
912
1808
  for (const controller of controllers) {
913
1809
  const emitted = this.emitController(controller, kind);
@@ -919,34 +1815,40 @@ ${indent(item, 2)}`).join(",")}
919
1815
  return lines.join(`
920
1816
  `);
921
1817
  }
922
- emitProvider(provider, kind) {
1818
+ emitProvider(provider, kind, isMulti = false) {
923
1819
  const key = camelName(provider.token);
924
1820
  switch (provider.kind) {
925
1821
  case "class": {
926
1822
  const useClass = this.imports.add(provider.useClass ?? provider.token, provider.importPath);
927
- const args = provider.deps.map((dep) => this.depExpr(dep, kind)).join(", ");
928
- const local = this.localVar(provider.token, kind);
1823
+ const args = provider.deps.map((dep) => this.depExpr(dep, kind, provider.optionalDeps?.includes(dep))).join(", ");
1824
+ const local = this.localVar(isMulti ? provider.useClass ?? `${provider.token}Item` : provider.token, kind);
929
1825
  return { constLine: `const ${local} = new ${useClass}(${args});`, key, expr: local };
930
1826
  }
931
1827
  case "value": {
932
1828
  const expr = provider.importPath ? this.imports.add(provider.useValueExpr ?? "undefined", provider.importPath) : provider.useValueExpr ?? "undefined";
933
- const local = this.localVar(provider.token, kind);
1829
+ const local = this.localVar(isMulti ? `${provider.token}Item` : provider.token, kind);
934
1830
  return { constLine: `const ${local} = ${expr};`, key, expr: local };
935
1831
  }
936
1832
  case "factory": {
1833
+ if (provider.tokenKind === "injection-token" && !provider.useFactoryName) {
1834
+ const tokenIdent = this.imports.add(provider.token, provider.importPath);
1835
+ const local2 = this.localVar(isMulti ? `${provider.token}Item` : provider.token, kind);
1836
+ const constLine = `const ${local2} = typeof ${tokenIdent} === "object" && ${tokenIdent} && "factory" in ${tokenIdent} && typeof (${tokenIdent} as any).factory === "function" ? (${tokenIdent} as any).factory() : undefined;`;
1837
+ return { constLine, key, expr: local2 };
1838
+ }
937
1839
  const factory = this.imports.add(provider.useFactoryName ?? "", provider.importPath);
938
- const args = provider.deps.map((dep) => this.depExpr(dep, kind)).join(", ");
939
- const local = this.localVar(provider.token, kind);
1840
+ const args = provider.deps.map((dep) => this.depExpr(dep, kind, provider.optionalDeps?.includes(dep))).join(", ");
1841
+ const local = this.localVar(isMulti ? provider.useFactoryName ?? `${provider.token}Item` : provider.token, kind);
940
1842
  return { constLine: `const ${local} = ${factory}(${args});`, key, expr: local };
941
1843
  }
942
1844
  case "existing": {
943
- return { key, expr: this.depExpr(provider.useExisting ?? provider.token, kind) };
1845
+ return { key, expr: this.depExpr(provider.useExisting ?? provider.token, kind, provider.optionalDeps?.includes(provider.token)) };
944
1846
  }
945
1847
  }
946
1848
  }
947
1849
  emitController(controller, kind) {
948
1850
  const className = this.imports.add(controller.className, controller.importPath);
949
- const args = controller.deps.map((dep) => this.depExpr(dep, kind)).join(", ");
1851
+ const args = controller.deps.map((dep) => this.depExpr(dep, kind, controller.optionalDeps?.includes(dep))).join(", ");
950
1852
  const key = camelName(controller.className);
951
1853
  const local = this.localVar(controller.className, kind);
952
1854
  return { constLine: `const ${local} = new ${className}(${args});`, key, expr: local };
@@ -966,7 +1868,7 @@ ${indent(item, 2)}`).join(",")}
966
1868
  locals.set(token, local);
967
1869
  return local;
968
1870
  }
969
- depExpr(token, kind) {
1871
+ depExpr(token, kind, isOptional = false) {
970
1872
  if (kind === "request" && isRequestContextToken(token, this.graph.tokenNames))
971
1873
  return "ctx";
972
1874
  if (kind === "job" && isJobContextToken(token, this.graph.tokenNames))
@@ -977,7 +1879,7 @@ ${indent(item, 2)}`).join(",")}
977
1879
  return this.locals[kind].get(token) ?? camelName(token);
978
1880
  }
979
1881
  if (own.kind === "existing" && factoryOfScope(own.scope) === kind) {
980
- return this.depExpr(own.useExisting ?? token, kind);
1882
+ return this.depExpr(own.useExisting ?? token, kind, isOptional);
981
1883
  }
982
1884
  if (kind === "services") {
983
1885
  return `services.${camelName(token)}`;
@@ -992,9 +1894,18 @@ ${indent(item, 2)}`).join(",")}
992
1894
  return `imported.${importName}.${camelName(token)}`;
993
1895
  return `imported.${importName}.${camelName(token)}`;
994
1896
  }
1897
+ for (const mod of this.graph.modules) {
1898
+ const rootProv = mod.providers.find((p) => p.token === token && p.providedIn === "root");
1899
+ if (rootProv) {
1900
+ return `imported.${mod.name}.${camelName(token)}`;
1901
+ }
1902
+ }
1903
+ if (isOptional && !this.graph.externalTokens.includes(token)) {
1904
+ return "undefined";
1905
+ }
995
1906
  if (kind === "services")
996
- return `deps.${camelName(token)}`;
997
- return `services.${camelName(token)}`;
1907
+ return isOptional ? `(deps.${camelName(token)} ?? undefined)` : `deps.${camelName(token)}`;
1908
+ return isOptional ? `(services.${camelName(token)} ?? undefined)` : `services.${camelName(token)}`;
998
1909
  }
999
1910
  }
1000
1911
  function orderProviders(providers) {
@@ -1016,6 +1927,226 @@ function orderProviders(providers) {
1016
1927
  }
1017
1928
  return result;
1018
1929
  }
1930
+ function renderClient(graph, _options) {
1931
+ const controllerEntries = [];
1932
+ const allRoutes = [];
1933
+ for (const module of graph.modules) {
1934
+ for (const controller of module.controllers) {
1935
+ const controllerKey = camelName(controller.className.replace(/Controller$/, ""));
1936
+ const routeMethods = [];
1937
+ for (const route of controller.routes) {
1938
+ const fullPath = joinRoutePaths(controller.path, route.path);
1939
+ allRoutes.push({
1940
+ method: route.method,
1941
+ path: fullPath,
1942
+ controller: controller.className,
1943
+ handler: route.handler,
1944
+ command: route.command,
1945
+ guards: route.guards,
1946
+ canMatch: route.canMatch,
1947
+ canDeactivate: route.canDeactivate,
1948
+ resolvers: route.resolvers,
1949
+ redirectTo: route.redirectTo,
1950
+ pathMatch: route.pathMatch,
1951
+ paramTransforms: route.paramTransforms,
1952
+ paramDefaults: route.paramDefaults,
1953
+ queryTransforms: route.queryTransforms,
1954
+ queryDefaults: route.queryDefaults,
1955
+ title: route.title,
1956
+ data: route.data
1957
+ });
1958
+ routeMethods.push(`
1959
+ ${route.handler}: (options: {
1960
+ 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>"};
1961
+ query?: Record<string, unknown>;
1962
+ body?: unknown;
1963
+ headers?: Record<string, string>;
1964
+ }${route.pathParams && route.pathParams.length > 0 || (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).length > 0 ? "" : " = {}"}) => request(${JSON.stringify(route.method)}, ${JSON.stringify(fullPath)}, options),`);
1965
+ }
1966
+ controllerEntries.push(`
1967
+ ${controllerKey}: {${routeMethods.join("")}
1968
+ },`);
1969
+ }
1970
+ }
1971
+ return [
1972
+ HEADER,
1973
+ "",
1974
+ "export interface ClientRequestOptions {",
1975
+ " params?: Record<string, string | number>;",
1976
+ " query?: Record<string, unknown>;",
1977
+ " body?: unknown;",
1978
+ " headers?: Record<string, string>;",
1979
+ "}",
1980
+ "",
1981
+ "export type HttpInterceptorFn = (",
1982
+ " req: { method: string; url: string; headers: Record<string, string>; body?: unknown },",
1983
+ " next: (req: { method: string; url: string; headers: Record<string, string>; body?: unknown }) => Promise<Response>,",
1984
+ ") => Promise<Response>;",
1985
+ "",
1986
+ "export interface ApiClientConfig {",
1987
+ " baseUrl?: string;",
1988
+ " fetch?: typeof fetch;",
1989
+ " headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);",
1990
+ " interceptors?: HttpInterceptorFn[];",
1991
+ "}",
1992
+ "",
1993
+ "export const API_ROUTES = " + JSON.stringify(allRoutes, null, 2) + " as const;",
1994
+ "",
1995
+ "export type AppRoutePath = typeof API_ROUTES[number]['path'];",
1996
+ "",
1997
+ "/**",
1998
+ " * Type-safe URL builder replacing route path parameters and appending query parameters.",
1999
+ " */",
2000
+ "export function buildRouteUrl(",
2001
+ " path: string,",
2002
+ " params?: Record<string, string | number>,",
2003
+ " query?: Record<string, unknown>,",
2004
+ "): string {",
2005
+ " let url = path;",
2006
+ " if (params) {",
2007
+ " for (const [key, value] of Object.entries(params)) {",
2008
+ " url = url.replace(`:${key}`, encodeURIComponent(String(value)));",
2009
+ " }",
2010
+ " }",
2011
+ " if (query) {",
2012
+ " const searchParams = new URLSearchParams();",
2013
+ " for (const [k, v] of Object.entries(query)) {",
2014
+ " if (v !== undefined && v !== null) searchParams.set(k, String(v));",
2015
+ " }",
2016
+ " const qs = searchParams.toString();",
2017
+ ' if (qs) url += (url.includes("?") ? "&" : "?") + qs;',
2018
+ " }",
2019
+ " return url;",
2020
+ "}",
2021
+ "",
2022
+ "export function createApiClient(config: ApiClientConfig = {}) {",
2023
+ " const fetcher = config.fetch ?? globalThis.fetch.bind(globalThis);",
2024
+ ' const baseUrl = (config.baseUrl ?? "").replace(/\\/+$/, "");',
2025
+ "",
2026
+ " async function request<T = unknown>(",
2027
+ " method: string,",
2028
+ " path: string,",
2029
+ " options: ClientRequestOptions = {},",
2030
+ " ): Promise<T> {",
2031
+ " let url = `${baseUrl}${path}`;",
2032
+ " if (options.params) {",
2033
+ " for (const [key, value] of Object.entries(options.params)) {",
2034
+ " url = url.replace(`:${key}`, encodeURIComponent(String(value)));",
2035
+ " }",
2036
+ " }",
2037
+ " if (options.query) {",
2038
+ " const searchParams = new URLSearchParams();",
2039
+ " for (const [k, v] of Object.entries(options.query)) {",
2040
+ " if (v !== undefined && v !== null) searchParams.set(k, String(v));",
2041
+ " }",
2042
+ " const qs = searchParams.toString();",
2043
+ ' if (qs) url += (url.includes("?") ? "&" : "?") + qs;',
2044
+ " }",
2045
+ ' const customHeaders = typeof config.headers === "function" ? await config.headers() : config.headers;',
2046
+ " const headers: Record<string, string> = {",
2047
+ ' "content-type": "application/json",',
2048
+ " ...customHeaders,",
2049
+ " ...options.headers,",
2050
+ " };",
2051
+ " const interceptors = config.interceptors ?? [];",
2052
+ " const executeChain = (",
2053
+ " index: number,",
2054
+ " reqPayload: { method: string; url: string; headers: Record<string, string>; body?: unknown },",
2055
+ " ): Promise<Response> => {",
2056
+ " if (index < interceptors.length) {",
2057
+ " return interceptors[index](reqPayload, (nextPayload) => executeChain(index + 1, nextPayload));",
2058
+ " }",
2059
+ " return fetcher(reqPayload.url, {",
2060
+ " method: reqPayload.method,",
2061
+ " headers: reqPayload.headers,",
2062
+ " body: reqPayload.body !== undefined ? JSON.stringify(reqPayload.body) : undefined,",
2063
+ " });",
2064
+ " };",
2065
+ " const response = await executeChain(0, { method, url, headers, body: options.body });",
2066
+ " if (!response.ok) {",
2067
+ " const errBody = await response.text();",
2068
+ " throw new Error(`API request failed: ${method} ${path} -> ${response.status} ${errBody}`);",
2069
+ " }",
2070
+ ' const contentType = response.headers?.get("content-type") ?? "";',
2071
+ ' if (contentType.includes("application/json")) {',
2072
+ " return response.json() as Promise<T>;",
2073
+ " }",
2074
+ " return response.text() as Promise<T>;",
2075
+ " }",
2076
+ "",
2077
+ " return {",
2078
+ " request,",
2079
+ " buildRouteUrl,",
2080
+ " routes: API_ROUTES,",
2081
+ ...controllerEntries,
2082
+ " };",
2083
+ "}",
2084
+ "",
2085
+ "export type ApiClient = ReturnType<typeof createApiClient>;",
2086
+ ""
2087
+ ].join(`
2088
+ `);
2089
+ }
2090
+ function renderPermissions(graph) {
2091
+ const permissions = new Set;
2092
+ const bindings = [];
2093
+ for (const module of graph.modules) {
2094
+ for (const command of module.commands) {
2095
+ if (command.permission)
2096
+ permissions.add(command.permission);
2097
+ }
2098
+ for (const controller of module.controllers) {
2099
+ for (const route of controller.routes) {
2100
+ let perm;
2101
+ if (route.command) {
2102
+ const cmd = module.commands.find((c) => c.className === route.command);
2103
+ perm = cmd?.permission;
2104
+ }
2105
+ if (perm)
2106
+ permissions.add(perm);
2107
+ bindings.push({
2108
+ method: route.method,
2109
+ path: joinRoutePaths(controller.path, route.path),
2110
+ controller: controller.className,
2111
+ handler: route.handler,
2112
+ command: route.command,
2113
+ permission: perm
2114
+ });
2115
+ }
2116
+ }
2117
+ }
2118
+ const sortedPerms = [...permissions].sort();
2119
+ const enumEntries = sortedPerms.map((perm) => {
2120
+ const key = pascalName(perm.replace(/[^A-Za-z0-9]+/g, " "));
2121
+ return ` ${key}: ${JSON.stringify(perm)},`;
2122
+ });
2123
+ return [
2124
+ HEADER,
2125
+ "",
2126
+ "export const AppPermissions = {",
2127
+ ...enumEntries,
2128
+ "} as const;",
2129
+ "",
2130
+ "export type AppPermission = (typeof AppPermissions)[keyof typeof AppPermissions];",
2131
+ "",
2132
+ "export interface RoutePermissionBinding {",
2133
+ " method: string;",
2134
+ " path: string;",
2135
+ " controller: string;",
2136
+ " handler: string;",
2137
+ " command?: string;",
2138
+ " permission?: string;",
2139
+ "}",
2140
+ "",
2141
+ "export const RoutePermissions: RoutePermissionBinding[] = " + JSON.stringify(bindings, null, 2) + ";",
2142
+ "",
2143
+ "export function hasPermission(granted: string[], required: AppPermission | string): boolean {",
2144
+ ' return granted.includes("*") || granted.includes(required);',
2145
+ "}",
2146
+ ""
2147
+ ].join(`
2148
+ `);
2149
+ }
1019
2150
 
1020
2151
  // src/profiles.ts
1021
2152
  var MODULAR_MONOLITH_RULES = [
@@ -1190,6 +2321,53 @@ var SCOPE_LIFETIME_RANK = {
1190
2321
  request: 1,
1191
2322
  job: 1
1192
2323
  };
2324
+ var COMPILER_DIAGNOSTIC_CODES = {
2325
+ "circular-dependency": { code: "SC1001", docsUrl: "https://supacloud.dev/errors/SC1001" },
2326
+ "scope-violation": { code: "SC1002", docsUrl: "https://supacloud.dev/errors/SC1002" },
2327
+ "module-boundary-violation": { code: "SC1003", docsUrl: "https://supacloud.dev/errors/SC1003" },
2328
+ "module-boundary": { code: "SC1003", docsUrl: "https://supacloud.dev/errors/SC1003" },
2329
+ "circular-module-import": { code: "SC1004", docsUrl: "https://supacloud.dev/errors/SC1004" },
2330
+ "orphan-module": { code: "SC1005", docsUrl: "https://supacloud.dev/errors/SC1005" },
2331
+ "invalid-boundary-preset": { code: "SC1006", docsUrl: "https://supacloud.dev/errors/SC1006" },
2332
+ "circular-existing-alias": { code: "SC1007", docsUrl: "https://supacloud.dev/errors/SC1007" },
2333
+ "missing-deps": { code: "SC2001", docsUrl: "https://supacloud.dev/errors/SC2001" },
2334
+ "unresolved-token": { code: "SC2001", docsUrl: "https://supacloud.dev/errors/SC2001" },
2335
+ "duplicate-token": { code: "SC2002", docsUrl: "https://supacloud.dev/errors/SC2002" },
2336
+ "duplicate-module": { code: "SC2002", docsUrl: "https://supacloud.dev/errors/SC2002" },
2337
+ "disallow-controller-direct-db": { code: "SC2003", docsUrl: "https://supacloud.dev/errors/SC2003" },
2338
+ "self-dependency-violation": { code: "SC2004", docsUrl: "https://supacloud.dev/errors/SC2004" },
2339
+ "skip-self-dependency-violation": { code: "SC2005", docsUrl: "https://supacloud.dev/errors/SC2005" },
2340
+ "export-unprovided-token": { code: "SC2006", docsUrl: "https://supacloud.dev/errors/SC2006" },
2341
+ "unresolved-alias-target": { code: "SC2007", docsUrl: "https://supacloud.dev/errors/SC2007" },
2342
+ "self-referencing-alias": { code: "SC2008", docsUrl: "https://supacloud.dev/errors/SC2008" },
2343
+ "shadowed-route": { code: "SC3001", docsUrl: "https://supacloud.dev/errors/SC3001" },
2344
+ "unresolved-route-redirect": { code: "SC3002", docsUrl: "https://supacloud.dev/errors/SC3002" },
2345
+ "circular-route-redirect": { code: "SC3003", docsUrl: "https://supacloud.dev/errors/SC3003" },
2346
+ "invalid-http-method-body": { code: "SC3004", docsUrl: "https://supacloud.dev/errors/SC3004" },
2347
+ "unmatched-route-parameter": { code: "SC3005", docsUrl: "https://supacloud.dev/errors/SC3005" },
2348
+ "missing-route-parameter-binding": { code: "SC3006", docsUrl: "https://supacloud.dev/errors/SC3006" },
2349
+ "duplicate-route": { code: "SC3007", docsUrl: "https://supacloud.dev/errors/SC3007" },
2350
+ "missing-body-schema": { code: "SC3008", docsUrl: "https://supacloud.dev/errors/SC3008" },
2351
+ "unused-route-schema": { code: "SC3009", docsUrl: "https://supacloud.dev/errors/SC3009" },
2352
+ "malformed-route-path": { code: "SC3010", docsUrl: "https://supacloud.dev/errors/SC3010" },
2353
+ "duplicate-path-param": { code: "SC3011", docsUrl: "https://supacloud.dev/errors/SC3011" },
2354
+ "wildcard-not-trailing": { code: "SC3012", docsUrl: "https://supacloud.dev/errors/SC3012" },
2355
+ "invalid-query-param-name": { code: "SC3013", docsUrl: "https://supacloud.dev/errors/SC3013" },
2356
+ "unmatched-path-param-decorator": { code: "SC3014", docsUrl: "https://supacloud.dev/errors/SC3014" },
2357
+ "invalid-query-default-type": { code: "SC3015", docsUrl: "https://supacloud.dev/errors/SC3015" },
2358
+ "disallowed-body-on-get-delete": { code: "SC3016", docsUrl: "https://supacloud.dev/errors/SC3016" },
2359
+ "duplicate-query-param-binding": { code: "SC3017", docsUrl: "https://supacloud.dev/errors/SC3017" },
2360
+ "conflicting-route-method": { code: "SC3018", docsUrl: "https://supacloud.dev/errors/SC3018" },
2361
+ "missing-param-colon": { code: "SC3019", docsUrl: "https://supacloud.dev/errors/SC3019" },
2362
+ "missing-token-factory": { code: "SC2009", docsUrl: "https://supacloud.dev/errors/SC2009" },
2363
+ "command-missing-permission": { code: "SC4001", docsUrl: "https://supacloud.dev/errors/SC4001" },
2364
+ "duplicate-command": { code: "SC4002", docsUrl: "https://supacloud.dev/errors/SC4002" },
2365
+ "route-command-unresolved": { code: "SC4003", docsUrl: "https://supacloud.dev/errors/SC4003" },
2366
+ "command-governance-unsupported": { code: "SC4004", docsUrl: "https://supacloud.dev/errors/SC4004" },
2367
+ "route-command-binding-disabled": { code: "SC4005", docsUrl: "https://supacloud.dev/errors/SC4005" },
2368
+ "command-transaction-readonly": { code: "SC4006", docsUrl: "https://supacloud.dev/errors/SC4006" },
2369
+ "unused-root-provider": { code: "SC5001", docsUrl: "https://supacloud.dev/errors/SC5001" }
2370
+ };
1193
2371
  function validateGraph(graph, options = false) {
1194
2372
  const strict = typeof options === "boolean" ? options : options.strict ?? false;
1195
2373
  const diagnostics = [];
@@ -1201,12 +2379,15 @@ function validateGraph(graph, options = false) {
1201
2379
  rules: options.moduleBoundaries
1202
2380
  });
1203
2381
  } catch (err) {
2382
+ const meta = COMPILER_DIAGNOSTIC_CODES["invalid-boundary-preset"];
1204
2383
  diagnostics.push({
1205
2384
  severity: "error",
1206
2385
  code: "invalid-boundary-preset",
1207
2386
  message: err instanceof Error ? err.message : String(err),
1208
2387
  file: graph.modules[0]?.file,
1209
- line: graph.modules[0]?.line
2388
+ line: graph.modules[0]?.line,
2389
+ errorCode: meta?.code,
2390
+ docsUrl: meta?.docsUrl
1210
2391
  });
1211
2392
  }
1212
2393
  }
@@ -1230,17 +2411,43 @@ function validateGraph(graph, options = false) {
1230
2411
  if (provider)
1231
2412
  return { module: imported, provider };
1232
2413
  }
2414
+ for (const mod of graph.modules) {
2415
+ const rootProvider = mod.providers.find((p) => p.token === token && p.providedIn === "root");
2416
+ if (rootProvider)
2417
+ return { module: mod, provider: rootProvider };
2418
+ }
1233
2419
  return;
1234
2420
  }
1235
- const error = (code, message, file, line) => {
1236
- diagnostics.push({ severity: "error", code, message, file, line });
2421
+ const error = (code, message, file, line, suggestion) => {
2422
+ const meta = COMPILER_DIAGNOSTIC_CODES[code];
2423
+ diagnostics.push({
2424
+ severity: "error",
2425
+ code,
2426
+ message,
2427
+ file,
2428
+ line,
2429
+ suggestion,
2430
+ errorCode: meta?.code,
2431
+ docsUrl: meta?.docsUrl
2432
+ });
1237
2433
  };
1238
- const warn2 = (code, message, file, line) => {
1239
- diagnostics.push({ severity: strict ? "error" : "warn", code, message, file, line });
2434
+ const warn2 = (code, message, file, line, suggestion) => {
2435
+ const meta = COMPILER_DIAGNOSTIC_CODES[code];
2436
+ diagnostics.push({
2437
+ severity: strict ? "error" : "warn",
2438
+ code,
2439
+ message,
2440
+ file,
2441
+ line,
2442
+ suggestion,
2443
+ errorCode: meta?.code,
2444
+ docsUrl: meta?.docsUrl
2445
+ });
1240
2446
  };
1241
2447
  const modulesByName = new Map;
1242
2448
  const commandsByName = new Map;
1243
2449
  const routesByKey = new Map;
2450
+ const declaredRoutes = [];
1244
2451
  for (const module of graph.modules) {
1245
2452
  const previousModule = modulesByName.get(module.name);
1246
2453
  if (previousModule) {
@@ -1260,20 +2467,128 @@ function validateGraph(graph, options = false) {
1260
2467
  for (const module of graph.modules) {
1261
2468
  for (const controller of module.controllers) {
1262
2469
  for (const route of controller.routes) {
1263
- const fullPath = joinRoutePaths(controller.path, route.path);
2470
+ if (route.path.includes("//") || controller.path.includes("//")) {
2471
+ 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.");
2472
+ } else if (/(^|\/):(\/|$)/.test(route.path) || route.path.endsWith("/:")) {
2473
+ 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').");
2474
+ } else if (route.path.includes("?") || route.path.includes("#")) {
2475
+ 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.");
2476
+ }
2477
+ if (route.path.includes("**")) {
2478
+ const segments = route.path.split("/").filter(Boolean);
2479
+ const wildcardIdx = segments.indexOf("**");
2480
+ if (wildcardIdx !== -1 && wildcardIdx !== segments.length - 1) {
2481
+ 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("/")}/**'.`);
2482
+ }
2483
+ }
2484
+ const fullPath = joinRoutePaths2(controller.path, route.path);
2485
+ const rawFullPath = joinRawRoutePaths(controller.path, route.path);
1264
2486
  const key = `${route.method} ${fullPath}`;
2487
+ const openApiMatch = route.path.match(/\{([a-zA-Z0-9_]+)\}/);
2488
+ if (openApiMatch) {
2489
+ 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]}'.`);
2490
+ }
1265
2491
  const previous = routesByKey.get(key);
1266
2492
  if (previous) {
1267
2493
  error("duplicate-route", `路由 ${key} 重复(首次声明于模块 ${previous.module.name} 的 ${previous.controller.className})`, controller.file);
1268
2494
  } else {
1269
2495
  routesByKey.set(key, { module, controller });
1270
2496
  }
2497
+ for (const prev of declaredRoutes) {
2498
+ if (prev.method === route.method && isRouteShadowed(prev.rawFullPath, rawFullPath)) {
2499
+ 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}'.`);
2500
+ }
2501
+ }
2502
+ declaredRoutes.push({ method: route.method, path: route.path, fullPath, rawFullPath, controller, module, handler: route.handler, redirectTo: route.redirectTo });
1271
2503
  if (route.command && !module.commands.some((command) => command.className === route.command)) {
1272
2504
  error("route-command-unresolved", `路由 ${key} 绑定的 command 类 ${route.command} 未在模块 ${module.name} 声明`, controller.file);
1273
2505
  }
2506
+ if ((route.method === "GET" || route.method === "HEAD") && route.command) {
2507
+ const boundCommand = module.commands.find((c) => c.className === route.command);
2508
+ if (boundCommand && boundCommand.transaction === "required") {
2509
+ 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'.`);
2510
+ }
2511
+ }
1274
2512
  if (typeof options === "object" && options.allowRouteCommandBindings === false && route.command) {
1275
2513
  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);
1276
2514
  }
2515
+ if (route.redirectTo) {
2516
+ const target = route.redirectTo.replace(/\/+$/, "");
2517
+ const current = fullPath.replace(/\/+$/, "");
2518
+ if (target === current || target === route.path.replace(/\/+$/, "")) {
2519
+ error("circular-route-redirect", `Route ${key} defines circular redirectTo '${route.redirectTo}'`, controller.file);
2520
+ }
2521
+ }
2522
+ const pathParams = route.pathParams ?? [];
2523
+ const seenParams = new Set;
2524
+ for (const p of pathParams) {
2525
+ if (seenParams.has(p)) {
2526
+ 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').`);
2527
+ }
2528
+ seenParams.add(p);
2529
+ }
2530
+ const paramBindings = route.paramBindings ?? [];
2531
+ for (const binding of paramBindings) {
2532
+ if (!binding || binding.trim().length === 0) {
2533
+ 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}'.`);
2534
+ } else if (/[#?&=/\s]/.test(binding)) {
2535
+ 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.`);
2536
+ } else if (!pathParams.includes(binding)) {
2537
+ const suggestion = findClosestMatch(binding, pathParams);
2538
+ 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);
2539
+ }
2540
+ }
2541
+ if (paramBindings.length > 0) {
2542
+ for (const param of pathParams) {
2543
+ if (!paramBindings.includes(param)) {
2544
+ 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.`);
2545
+ }
2546
+ }
2547
+ }
2548
+ const queryBindings = route.queryBindings ?? [];
2549
+ const seenQueries = new Set;
2550
+ for (const q of queryBindings) {
2551
+ if (!q || q.trim().length === 0) {
2552
+ 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').`);
2553
+ } else if (/[#?&=/\s]/.test(q)) {
2554
+ 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.`);
2555
+ } else if (seenQueries.has(q)) {
2556
+ 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}.`);
2557
+ }
2558
+ seenQueries.add(q);
2559
+ }
2560
+ if (route.queryDefaults && route.queryTransforms) {
2561
+ for (const [paramName, defVal] of Object.entries(route.queryDefaults)) {
2562
+ const transform = route.queryTransforms[paramName];
2563
+ if (transform === "number" && typeof defVal !== "number") {
2564
+ 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'.`);
2565
+ } else if (transform === "boolean" && typeof defVal !== "boolean") {
2566
+ 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.`);
2567
+ }
2568
+ }
2569
+ }
2570
+ if ((route.method === "GET" || route.method === "HEAD" || route.method === "OPTIONS" || route.method === "DELETE") && (route.hasBodyBinding || route.body)) {
2571
+ 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().`);
2572
+ }
2573
+ if (route.hasBodyBinding && (route.method === "GET" || route.method === "HEAD" || route.method === "OPTIONS")) {
2574
+ 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().`);
2575
+ } else if (route.hasBodyBinding && !route.body) {
2576
+ 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.`);
2577
+ } else if (route.body && !route.hasBodyBinding && !route.command) {
2578
+ 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.`);
2579
+ }
2580
+ }
2581
+ const handlerMethodMap = new Map;
2582
+ for (const route of controller.routes) {
2583
+ const methods = handlerMethodMap.get(route.handler) ?? [];
2584
+ methods.push(route.method);
2585
+ handlerMethodMap.set(route.handler, methods);
2586
+ }
2587
+ for (const [handler, methods] of handlerMethodMap.entries()) {
2588
+ const uniqueMethods = Array.from(new Set(methods));
2589
+ if (uniqueMethods.length > 1) {
2590
+ 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.`);
2591
+ }
1277
2592
  }
1278
2593
  if (typeof options === "object" && options.disallowControllerDirectDb) {
1279
2594
  for (const dep of controller.deps) {
@@ -1283,6 +2598,73 @@ function validateGraph(graph, options = false) {
1283
2598
  }
1284
2599
  }
1285
2600
  }
2601
+ if (controller.selfDeps && controller.selfDeps.length > 0) {
2602
+ for (const dep of controller.selfDeps) {
2603
+ const own = module.providers.find((p) => p.token === dep);
2604
+ if (!own) {
2605
+ error("self-resolution-failed", `模块 ${module.name} 的 controller ${controller.className} 参数标记了 @Self(),但 ${dep} 未在当前模块内部提供`, controller.file, undefined, `Provide '${dep}' in module '${module.name}' or remove @Self().`);
2606
+ }
2607
+ }
2608
+ }
2609
+ if (controller.skipSelfDeps && controller.skipSelfDeps.length > 0) {
2610
+ for (const dep of controller.skipSelfDeps) {
2611
+ const own = module.providers.find((p) => p.token === dep);
2612
+ if (own) {
2613
+ 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().`);
2614
+ }
2615
+ }
2616
+ }
2617
+ }
2618
+ }
2619
+ const allTargetPaths = declaredRoutes.map((r) => r.rawFullPath);
2620
+ for (const item of declaredRoutes) {
2621
+ if (item.redirectTo) {
2622
+ const target = item.redirectTo;
2623
+ if (target.startsWith("/") && !target.startsWith("//")) {
2624
+ const normalizedTarget = target.replace(/\/+$/, "") || "/";
2625
+ const matchesTarget = declaredRoutes.some((candidate) => {
2626
+ if (candidate.rawFullPath === normalizedTarget)
2627
+ return true;
2628
+ return routeMatchesTarget(candidate.rawFullPath, normalizedTarget);
2629
+ });
2630
+ if (!matchesTarget) {
2631
+ const suggestion = findClosestMatch(normalizedTarget, allTargetPaths);
2632
+ 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);
2633
+ }
2634
+ }
2635
+ }
2636
+ }
2637
+ const routeByRawPath = new Map;
2638
+ for (const item of declaredRoutes) {
2639
+ if (!routeByRawPath.has(item.rawFullPath)) {
2640
+ routeByRawPath.set(item.rawFullPath, item);
2641
+ }
2642
+ }
2643
+ const reportedRedirectCycles = new Set;
2644
+ for (const item of declaredRoutes) {
2645
+ if (item.redirectTo) {
2646
+ const chain = [item.rawFullPath];
2647
+ let curr = item;
2648
+ while (curr && curr.redirectTo) {
2649
+ const target = curr.redirectTo.replace(/\/+$/, "") || "/";
2650
+ if (chain.includes(target)) {
2651
+ const cycle = [...chain.slice(chain.indexOf(target)), target];
2652
+ if (cycle.length > 2) {
2653
+ const cycleKey = [...cycle].sort().join("|");
2654
+ if (!reportedRedirectCycles.has(cycleKey)) {
2655
+ reportedRedirectCycles.add(cycleKey);
2656
+ const meta = COMPILER_DIAGNOSTIC_CODES["circular-route-redirect"];
2657
+ 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.");
2658
+ }
2659
+ }
2660
+ break;
2661
+ }
2662
+ chain.push(target);
2663
+ const next = routeByRawPath.get(target);
2664
+ if (!next || !next.redirectTo)
2665
+ break;
2666
+ curr = next;
2667
+ }
1286
2668
  }
1287
2669
  }
1288
2670
  for (const module of graph.modules) {
@@ -1290,33 +2672,58 @@ function validateGraph(graph, options = false) {
1290
2672
  for (const provider of module.providers) {
1291
2673
  const first = seen.get(provider.token);
1292
2674
  if (first) {
1293
- error("duplicate-token", `模块 ${module.name} 重复注册 token ${provider.token}(首次注册于 ${first.file}:${first.line})`, provider.file, provider.line);
2675
+ if (first.multi && provider.multi) {
2676
+ continue;
2677
+ }
2678
+ 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).");
1294
2679
  } else {
1295
2680
  seen.set(provider.token, provider);
1296
2681
  }
1297
2682
  }
1298
2683
  for (const provider of module.providers) {
2684
+ if (provider.selfDeps && provider.selfDeps.length > 0) {
2685
+ for (const dep of provider.selfDeps) {
2686
+ const own = module.providers.find((p) => p.token === dep);
2687
+ if (!own) {
2688
+ error("self-resolution-failed", `模块 ${module.name} 的 provider ${provider.token} 参数标记了 @Self(),但 ${dep} 未在当前模块内部提供`, provider.file, provider.line, `Provide '${dep}' in module '${module.name}' or remove @Self().`);
2689
+ }
2690
+ }
2691
+ }
2692
+ if (provider.skipSelfDeps && provider.skipSelfDeps.length > 0) {
2693
+ for (const dep of provider.skipSelfDeps) {
2694
+ const own = module.providers.find((p) => p.token === dep);
2695
+ if (own) {
2696
+ 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().`);
2697
+ }
2698
+ }
2699
+ }
1299
2700
  for (const dep of provider.deps) {
2701
+ const isOptional = provider.optionalDeps?.includes(dep);
1300
2702
  const resolved = resolveDep(module, dep);
1301
2703
  if (!resolved) {
2704
+ if (isOptional) {
2705
+ continue;
2706
+ }
1302
2707
  if (!graph.externalTokens.includes(dep)) {
1303
2708
  if (globalProviders.has(dep)) {
1304
2709
  const owner = globalProviders.get(dep);
1305
- error("module-boundary", `模块 ${module.name} 的 provider ${provider.token} 依赖 ${dep},该 token 由模块 ${owner.module.name} 提供但未被 import`, provider.file, provider.line);
2710
+ 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' }).`);
2711
+ } else if (dep.includes("TOKEN") || dep.endsWith("Token") || dep.length > 2 && dep === dep.toUpperCase()) {
2712
+ 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: () => ... }).`);
1306
2713
  } else {
1307
- error("unresolved-token", `模块 ${module.name} 的 provider ${provider.token} 依赖的 token ${dep} 无法解析`, provider.file, provider.line);
2714
+ 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' }).`);
1308
2715
  }
1309
2716
  }
1310
2717
  continue;
1311
2718
  }
1312
2719
  if (SCOPE_LIFETIME_RANK[resolved.provider.scope] > SCOPE_LIFETIME_RANK[provider.scope]) {
1313
- error("scope-violation", `模块 ${module.name} 的 ${provider.scope} provider ${provider.token} 不能依赖 ${resolved.provider.scope} provider ${dep}`, provider.file, provider.line);
2720
+ 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.`);
1314
2721
  }
1315
2722
  }
1316
2723
  }
1317
2724
  for (const command of module.commands) {
1318
2725
  if (!command.permission) {
1319
- error("command-missing-permission", `模块 ${module.name} 的 command ${command.name} (${command.className}) 未声明 permission`, module.file, module.line);
2726
+ 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.");
1320
2727
  }
1321
2728
  if (typeof options === "object" && options.commandCapabilities) {
1322
2729
  const caps = options.commandCapabilities;
@@ -1369,18 +2776,116 @@ function validateGraph(graph, options = false) {
1369
2776
  }
1370
2777
  }
1371
2778
  }
2779
+ const referencedTokens = new Set;
2780
+ for (const mod of graph.modules) {
2781
+ for (const exp of mod.exports)
2782
+ referencedTokens.add(exp);
2783
+ for (const ctrl of mod.controllers) {
2784
+ for (const d of ctrl.deps ?? [])
2785
+ referencedTokens.add(d);
2786
+ for (const d of ctrl.optionalDeps ?? [])
2787
+ referencedTokens.add(d);
2788
+ for (const d of ctrl.selfDeps ?? [])
2789
+ referencedTokens.add(d);
2790
+ for (const d of ctrl.skipSelfDeps ?? [])
2791
+ referencedTokens.add(d);
2792
+ }
2793
+ for (const p of mod.providers) {
2794
+ for (const d of p.deps ?? [])
2795
+ referencedTokens.add(d);
2796
+ for (const d of p.optionalDeps ?? [])
2797
+ referencedTokens.add(d);
2798
+ for (const d of p.selfDeps ?? [])
2799
+ referencedTokens.add(d);
2800
+ for (const d of p.skipSelfDeps ?? [])
2801
+ referencedTokens.add(d);
2802
+ if (p.useExisting)
2803
+ referencedTokens.add(p.useExisting);
2804
+ }
2805
+ }
2806
+ for (const mod of graph.modules) {
2807
+ for (const provider of mod.providers) {
2808
+ if (provider.providedIn === "root" && !provider.multi && !referencedTokens.has(provider.token) && !provider.exported) {
2809
+ 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.`);
2810
+ }
2811
+ }
2812
+ }
2813
+ for (const module of graph.modules) {
2814
+ for (const expToken of module.exports) {
2815
+ const resolved = resolveDep(module, expToken);
2816
+ if (resolved)
2817
+ continue;
2818
+ if (module.imports.includes(expToken))
2819
+ continue;
2820
+ 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.`);
2821
+ }
2822
+ }
2823
+ for (const module of graph.modules) {
2824
+ for (const provider of module.providers) {
2825
+ if (provider.useExisting) {
2826
+ const target = provider.useExisting;
2827
+ if (target === provider.token) {
2828
+ 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.`);
2829
+ } else {
2830
+ const resolved = resolveDep(module, target);
2831
+ if (!resolved && !graph.externalTokens.includes(target)) {
2832
+ 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.`);
2833
+ }
2834
+ }
2835
+ }
2836
+ }
2837
+ }
1372
2838
  diagnostics.push(...detectCycles(graph, resolveDep));
2839
+ diagnostics.push(...detectExistingAliasCycles(graph, resolveDep));
1373
2840
  diagnostics.push(...detectModuleCycles(graph));
1374
2841
  if (typeof options === "object" && options.detectOrphanModules) {
1375
2842
  diagnostics.push(...detectOrphanModules(graph));
1376
2843
  }
1377
2844
  return diagnostics;
1378
2845
  }
1379
- function joinRoutePaths(prefix, path) {
2846
+ function joinRoutePaths2(prefix, path) {
1380
2847
  const joined = `${prefix}/${path}`.replace(/\/{2,}/g, "/");
1381
2848
  const normalized = joined.length > 1 ? joined.replace(/\/+$/, "") : joined;
1382
2849
  return normalized.replace(/:[^/]+/g, ":param");
1383
2850
  }
2851
+ function joinRawRoutePaths(prefix, path) {
2852
+ const joined = `${prefix}/${path}`.replace(/\/{2,}/g, "/");
2853
+ return joined.length > 1 ? joined.replace(/\/+$/, "") : joined;
2854
+ }
2855
+ function isRouteShadowed(earlierPath, laterPath) {
2856
+ const earlierSegments = earlierPath.split("/").filter(Boolean);
2857
+ const laterSegments = laterPath.split("/").filter(Boolean);
2858
+ if (earlierSegments.length !== laterSegments.length) {
2859
+ return false;
2860
+ }
2861
+ let hasParamShadowing = false;
2862
+ for (let i = 0;i < earlierSegments.length; i += 1) {
2863
+ const e = earlierSegments[i];
2864
+ const l = laterSegments[i];
2865
+ if (e === l) {
2866
+ continue;
2867
+ }
2868
+ if (e.startsWith(":") && !l.startsWith(":")) {
2869
+ hasParamShadowing = true;
2870
+ continue;
2871
+ }
2872
+ return false;
2873
+ }
2874
+ return hasParamShadowing;
2875
+ }
2876
+ function routeMatchesTarget(routePattern, targetPath) {
2877
+ const pSegs = routePattern.split("/").filter(Boolean);
2878
+ const tSegs = targetPath.split("/").filter(Boolean);
2879
+ if (pSegs.length !== tSegs.length)
2880
+ return false;
2881
+ for (let i = 0;i < pSegs.length; i += 1) {
2882
+ if (pSegs[i].startsWith(":"))
2883
+ continue;
2884
+ if (pSegs[i] !== tSegs[i])
2885
+ return false;
2886
+ }
2887
+ return true;
2888
+ }
1384
2889
  function detectCycles(graph, resolveDep) {
1385
2890
  const diagnostics = [];
1386
2891
  const nodeId = (ref) => `${ref.module.name}:${ref.provider.token}`;
@@ -1399,12 +2904,16 @@ function detectCycles(graph, resolveDep) {
1399
2904
  const cycleKey = cycle.map((item) => nodeId(item)).sort().join("|");
1400
2905
  if (!reported.has(cycleKey)) {
1401
2906
  reported.add(cycleKey);
2907
+ const meta = COMPILER_DIAGNOSTIC_CODES["circular-dependency"];
1402
2908
  diagnostics.push({
1403
2909
  severity: "error",
1404
2910
  code: "circular-dependency",
1405
2911
  message: `provider 循环依赖: ${path}`,
1406
2912
  file: ref.provider.file,
1407
- line: ref.provider.line
2913
+ line: ref.provider.line,
2914
+ suggestion: "Break the cycle by extracting common dependencies into a separate service or injecting @Optional().",
2915
+ errorCode: meta?.code,
2916
+ docsUrl: meta?.docsUrl
1408
2917
  });
1409
2918
  }
1410
2919
  return;
@@ -1423,6 +2932,47 @@ function detectCycles(graph, resolveDep) {
1423
2932
  visit(ref);
1424
2933
  return diagnostics;
1425
2934
  }
2935
+ function detectExistingAliasCycles(graph, resolveDep) {
2936
+ const diagnostics = [];
2937
+ const existingProviders = [];
2938
+ for (const module of graph.modules) {
2939
+ for (const provider of module.providers) {
2940
+ if (provider.useExisting) {
2941
+ existingProviders.push({ module, provider });
2942
+ }
2943
+ }
2944
+ }
2945
+ const reported = new Set;
2946
+ for (const start of existingProviders) {
2947
+ const visited = [start.provider.token];
2948
+ let current = start;
2949
+ while (current && current.provider.useExisting) {
2950
+ const targetToken = current.provider.useExisting;
2951
+ if (visited.includes(targetToken)) {
2952
+ const cycle = [...visited.slice(visited.indexOf(targetToken)), targetToken];
2953
+ const cycleKey = [...cycle].sort().join("|");
2954
+ if (!reported.has(cycleKey)) {
2955
+ reported.add(cycleKey);
2956
+ const meta = COMPILER_DIAGNOSTIC_CODES["circular-existing-alias"];
2957
+ diagnostics.push({
2958
+ severity: "error",
2959
+ code: "circular-existing-alias",
2960
+ message: `Provider alias cycle detected in useExisting: ${cycle.join(" -> ")}`,
2961
+ file: start.provider.file,
2962
+ line: start.provider.line,
2963
+ suggestion: "Break the alias cycle by pointing useExisting to a concrete provider instead of a circular alias.",
2964
+ errorCode: meta?.code,
2965
+ docsUrl: meta?.docsUrl
2966
+ });
2967
+ }
2968
+ break;
2969
+ }
2970
+ visited.push(targetToken);
2971
+ current = resolveDep(current.module, targetToken);
2972
+ }
2973
+ }
2974
+ return diagnostics;
2975
+ }
1426
2976
  function detectModuleCycles(graph) {
1427
2977
  const diagnostics = [];
1428
2978
  const moduleMap = new Map(graph.modules.map((m) => [m.name, m]));
@@ -1439,12 +2989,16 @@ function detectModuleCycles(graph) {
1439
2989
  if (!reported.has(cycleKey)) {
1440
2990
  reported.add(cycleKey);
1441
2991
  const mod2 = moduleMap.get(name);
2992
+ const meta = COMPILER_DIAGNOSTIC_CODES["circular-module-import"];
1442
2993
  diagnostics.push({
1443
2994
  severity: "error",
1444
2995
  code: "circular-module-import",
1445
2996
  message: `Module circular import detected: ${cycle.join(" -> ")}`,
1446
2997
  file: mod2?.file,
1447
- line: mod2?.line
2998
+ line: mod2?.line,
2999
+ suggestion: "Refactor module imports into a unidirectional acyclic graph.",
3000
+ errorCode: meta?.code,
3001
+ docsUrl: meta?.docsUrl
1448
3002
  });
1449
3003
  }
1450
3004
  return;
@@ -1492,12 +3046,15 @@ function detectOrphanModules(graph) {
1492
3046
  }
1493
3047
  for (const mod of graph.modules) {
1494
3048
  if (!reachable.has(mod.name)) {
3049
+ const meta = COMPILER_DIAGNOSTIC_CODES["orphan-module"];
1495
3050
  diagnostics.push({
1496
3051
  severity: "warn",
1497
3052
  code: "orphan-module",
1498
3053
  message: `Module '${mod.name}' is declared but not reachable from any root module (${rootModules.map((r) => r.name).join(", ")})`,
1499
3054
  file: mod.file,
1500
- line: mod.line
3055
+ line: mod.line,
3056
+ errorCode: meta?.code,
3057
+ docsUrl: meta?.docsUrl
1501
3058
  });
1502
3059
  }
1503
3060
  }
@@ -1508,7 +3065,7 @@ function detectOrphanModules(graph) {
1508
3065
  import { existsSync as existsSync2, readFileSync } from "node:fs";
1509
3066
  import { join as join3 } from "node:path";
1510
3067
  async function compileProject(options) {
1511
- const graph = await analyzeProject(options.rootDir, options.include);
3068
+ const graph = await analyzeProject(options.rootDir, options.include, options.cache);
1512
3069
  const diagnostics = [
1513
3070
  ...graph.diagnostics ?? [],
1514
3071
  ...validateGraph(graph, {
@@ -1527,14 +3084,25 @@ async function compileProject(options) {
1527
3084
  diagnostic.severity = "error";
1528
3085
  }
1529
3086
  }
1530
- const written = await generateApplication(graph, {
3087
+ const hasErrors = diagnostics.some((diagnostic) => diagnostic.severity === "error");
3088
+ const written = !hasErrors || options.writeOnError !== false ? await generateApplication(graph, {
1531
3089
  rootDir: options.rootDir,
1532
- outDir: options.outDir
1533
- });
1534
- return { diagnostics, graph, written };
3090
+ outDir: options.outDir,
3091
+ generateClient: options.generateClient,
3092
+ generatePermissions: options.generatePermissions,
3093
+ treeShakeUnusedProviders: options.treeShakeUnusedProviders
3094
+ }) : [];
3095
+ const stats = graph.cacheStats ? {
3096
+ cacheHit: graph.cacheStats.reanalyzedModules.length === 0,
3097
+ changedFiles: [],
3098
+ affectedModules: graph.cacheStats.reanalyzedModules,
3099
+ reanalyzedModules: graph.cacheStats.reanalyzedModules,
3100
+ reusedModules: graph.cacheStats.reusedModules
3101
+ } : undefined;
3102
+ return { diagnostics, graph, written, stats };
1535
3103
  }
1536
3104
  async function checkProject(options) {
1537
- const graph = await analyzeProject(options.rootDir, options.include);
3105
+ const graph = await analyzeProject(options.rootDir, options.include, options.cache);
1538
3106
  const diagnostics = [
1539
3107
  ...graph.diagnostics ?? [],
1540
3108
  ...validateGraph(graph, {
@@ -1555,12 +3123,21 @@ async function checkProject(options) {
1555
3123
  }
1556
3124
  const rendered = renderApplication(graph, {
1557
3125
  rootDir: options.rootDir,
1558
- outDir: options.outDir
3126
+ outDir: options.outDir,
3127
+ generateClient: options.generateClient,
3128
+ generatePermissions: options.generatePermissions,
3129
+ treeShakeUnusedProviders: options.treeShakeUnusedProviders
1559
3130
  });
1560
3131
  const expectedFiles = {
1561
3132
  "application.ts": rendered.applicationCode,
1562
3133
  "app.manifest.json": rendered.manifestJson
1563
3134
  };
3135
+ if (rendered.clientCode) {
3136
+ expectedFiles["client.ts"] = rendered.clientCode;
3137
+ }
3138
+ if (rendered.permissionsCode) {
3139
+ expectedFiles["permissions.ts"] = rendered.permissionsCode;
3140
+ }
1564
3141
  const mismatches = [];
1565
3142
  for (const [filename, expectedContent] of Object.entries(expectedFiles)) {
1566
3143
  const diskPath = join3(options.outDir, filename);
@@ -1580,19 +3157,530 @@ async function checkProject(options) {
1580
3157
  graph
1581
3158
  };
1582
3159
  }
3160
+ // src/watch.ts
3161
+ import { watch } from "node:fs";
3162
+ import { relative as relative3, resolve as resolve2 } from "node:path";
3163
+
3164
+ // src/incremental.ts
3165
+ import { createHash as createHash2 } from "node:crypto";
3166
+ import { access, readdir, readFile } from "node:fs/promises";
3167
+ import { relative as relative2, resolve, sep as sep2 } from "node:path";
3168
+ function createDependencyGraphCache() {
3169
+ return {
3170
+ modules: new Map,
3171
+ fileHashes: new Map
3172
+ };
3173
+ }
3174
+ function createIncrementalCompiler() {
3175
+ let previousSnapshot;
3176
+ let previousResult;
3177
+ const cache = createDependencyGraphCache();
3178
+ return {
3179
+ async compile(options, changedPaths) {
3180
+ const snapshot = changedPaths && previousSnapshot ? await updateSnapshot(previousSnapshot, options, changedPaths) : await createSnapshot(options);
3181
+ const changedFiles = changedPaths && previousSnapshot ? diffFiles(previousSnapshot.files, snapshot.files) : diffFiles(previousSnapshot?.files, snapshot.files);
3182
+ const cacheHit = Boolean(previousSnapshot && previousSnapshot.optionsKey === snapshot.optionsKey && changedFiles.length === 0);
3183
+ if (cacheHit && previousResult) {
3184
+ return {
3185
+ ...previousResult,
3186
+ stats: {
3187
+ cacheHit: true,
3188
+ changedFiles: [],
3189
+ affectedModules: [],
3190
+ reusedModules: previousResult.graph.modules.map((m) => m.name),
3191
+ reanalyzedModules: []
3192
+ }
3193
+ };
3194
+ }
3195
+ if (previousResult && previousSnapshot && changedFiles.length > 0 && !await requiresGraphRebuild(options.rootDir, changedFiles)) {
3196
+ const stats2 = {
3197
+ cacheHit: true,
3198
+ changedFiles,
3199
+ affectedModules: findAffectedModules(previousResult.graph.modules, previousResult.graph.modules, changedFiles),
3200
+ reusedModules: previousResult.graph.modules.map((m) => m.name),
3201
+ reanalyzedModules: []
3202
+ };
3203
+ previousSnapshot = snapshot;
3204
+ return { ...previousResult, written: [], stats: stats2 };
3205
+ }
3206
+ const activeCache = options.cache ?? cache;
3207
+ if (!activeCache.dependencyGraph && previousResult) {
3208
+ activeCache.dependencyGraph = new ModuleDependencyGraph(previousResult.graph.modules);
3209
+ }
3210
+ const result = await compileProject({ ...options, cache: activeCache });
3211
+ const affectedModules = previousResult ? findAffectedModules(previousResult.graph.modules, result.graph.modules, changedFiles) : result.graph.modules.map((module) => module.name);
3212
+ const reusedModules = result.graph.cacheStats?.reusedModules ?? [];
3213
+ const reanalyzedModules = result.graph.cacheStats?.reanalyzedModules ?? affectedModules;
3214
+ if (activeCache) {
3215
+ activeCache.dependencyGraph = new ModuleDependencyGraph(result.graph.modules);
3216
+ }
3217
+ const stats = {
3218
+ cacheHit: false,
3219
+ changedFiles,
3220
+ affectedModules,
3221
+ reusedModules,
3222
+ reanalyzedModules
3223
+ };
3224
+ previousSnapshot = snapshot;
3225
+ previousResult = result;
3226
+ return { ...result, stats };
3227
+ },
3228
+ reset() {
3229
+ previousSnapshot = undefined;
3230
+ previousResult = undefined;
3231
+ cache.modules.clear();
3232
+ cache.fileHashes.clear();
3233
+ cache.dependencyGraph = undefined;
3234
+ },
3235
+ getCache() {
3236
+ return cache;
3237
+ }
3238
+ };
3239
+ }
3240
+ async function updateSnapshot(previous, options, changedPaths) {
3241
+ const rootDir = resolve(options.rootDir);
3242
+ const outDir = resolve(options.outDir);
3243
+ const files = { ...previous.files };
3244
+ for (const changedPath of changedPaths) {
3245
+ const absolutePath = resolve(rootDir, changedPath);
3246
+ if (absolutePath === outDir || absolutePath.startsWith(`${outDir}/`))
3247
+ continue;
3248
+ const relativePath = relative2(rootDir, absolutePath).split(sep2).join("/");
3249
+ try {
3250
+ await access(absolutePath);
3251
+ const content = await readFile(absolutePath);
3252
+ files[relativePath] = createHash2("sha256").update(content).digest("hex");
3253
+ } catch {
3254
+ delete files[relativePath];
3255
+ }
3256
+ }
3257
+ return { files, optionsKey: optionsKeyOf(options) };
3258
+ }
3259
+ async function createSnapshot(options) {
3260
+ const rootDir = resolve(options.rootDir);
3261
+ const outDir = resolve(options.outDir);
3262
+ const paths = await listSourceFiles(rootDir, outDir);
3263
+ const files = {};
3264
+ for (const path of paths) {
3265
+ const content = await readFile(path);
3266
+ files[relative2(rootDir, path).split(sep2).join("/")] = createHash2("sha256").update(content).digest("hex");
3267
+ }
3268
+ return { files, optionsKey: optionsKeyOf(options) };
3269
+ }
3270
+ function optionsKeyOf(options) {
3271
+ return JSON.stringify({
3272
+ include: options.include,
3273
+ strict: options.strict,
3274
+ moduleBoundaryPreset: options.moduleBoundaryPreset,
3275
+ moduleBoundaries: options.moduleBoundaries,
3276
+ allowRouteCommandBindings: options.allowRouteCommandBindings,
3277
+ commandCapabilities: options.commandCapabilities,
3278
+ disallowControllerDirectDb: options.disallowControllerDirectDb,
3279
+ detectOrphanModules: options.detectOrphanModules,
3280
+ generateClient: options.generateClient,
3281
+ generatePermissions: options.generatePermissions
3282
+ });
3283
+ }
3284
+ async function requiresGraphRebuild(rootDir, changedFiles) {
3285
+ for (const relativePath of changedFiles) {
3286
+ const path = resolve(rootDir, relativePath);
3287
+ try {
3288
+ const source = await readFile(path, "utf8");
3289
+ if (/@(?:Module|Injectable|Inject|Controller|Command|Query)\b|new\s+InjectionToken\b|\bdefineModule\s*\(/.test(source))
3290
+ return true;
3291
+ } catch {
3292
+ return true;
3293
+ }
3294
+ }
3295
+ return false;
3296
+ }
3297
+ async function listSourceFiles(rootDir, outDir) {
3298
+ const result = [];
3299
+ const visit = async (directory) => {
3300
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
3301
+ const path = resolve(directory, entry.name);
3302
+ if (entry.isDirectory()) {
3303
+ if (entry.name === "node_modules" || entry.name === ".git" || path === outDir)
3304
+ continue;
3305
+ await visit(path);
3306
+ } else if (/\.(tsx?|mts|cts)$/.test(entry.name)) {
3307
+ result.push(path);
3308
+ }
3309
+ }
3310
+ };
3311
+ await visit(rootDir);
3312
+ return result.sort();
3313
+ }
3314
+ function diffFiles(previous, current) {
3315
+ if (!previous)
3316
+ return Object.keys(current);
3317
+ const names = new Set([...Object.keys(previous), ...Object.keys(current)]);
3318
+ return [...names].filter((name) => previous[name] !== current[name]).sort();
3319
+ }
3320
+
3321
+ class ModuleDependencyGraph {
3322
+ imports = new Map;
3323
+ dependents = new Map;
3324
+ fileOwners = new Map;
3325
+ moduleMap = new Map;
3326
+ constructor(modules = []) {
3327
+ this.rebuild(modules);
3328
+ }
3329
+ rebuild(modules) {
3330
+ this.imports.clear();
3331
+ this.dependents.clear();
3332
+ this.fileOwners.clear();
3333
+ this.moduleMap.clear();
3334
+ for (const mod of modules) {
3335
+ this.moduleMap.set(mod.name, mod);
3336
+ this.imports.set(mod.name, new Set(mod.imports));
3337
+ if (!this.dependents.has(mod.name)) {
3338
+ this.dependents.set(mod.name, new Set);
3339
+ }
3340
+ this.indexFile(mod.file, mod.name);
3341
+ for (const p of mod.providers) {
3342
+ if (p.importPath)
3343
+ this.indexFile(p.importPath, mod.name);
3344
+ if (p.file)
3345
+ this.indexFile(p.file, mod.name);
3346
+ }
3347
+ for (const c of mod.controllers) {
3348
+ if (c.importPath)
3349
+ this.indexFile(c.importPath, mod.name);
3350
+ if (c.file)
3351
+ this.indexFile(c.file, mod.name);
3352
+ }
3353
+ }
3354
+ for (const [modName, imps] of this.imports.entries()) {
3355
+ for (const imp of imps) {
3356
+ if (!this.dependents.has(imp)) {
3357
+ this.dependents.set(imp, new Set);
3358
+ }
3359
+ this.dependents.get(imp).add(modName);
3360
+ }
3361
+ }
3362
+ }
3363
+ indexFile(path, moduleName) {
3364
+ if (!path)
3365
+ return;
3366
+ const normalized = path.replace(/\.(tsx?|mts|cts)$/, "");
3367
+ if (!this.fileOwners.has(normalized)) {
3368
+ this.fileOwners.set(normalized, new Set);
3369
+ }
3370
+ this.fileOwners.get(normalized).add(moduleName);
3371
+ }
3372
+ getModulesOwningFile(filePath) {
3373
+ const normalized = filePath.replace(/\.(tsx?|mts|cts)$/, "");
3374
+ return Array.from(this.fileOwners.get(normalized) ?? []);
3375
+ }
3376
+ getAffectedModules(changedFiles) {
3377
+ if (changedFiles.length === 0)
3378
+ return [];
3379
+ const directlyAffected = new Set;
3380
+ for (const file of changedFiles) {
3381
+ for (const modName of this.getModulesOwningFile(file)) {
3382
+ directlyAffected.add(modName);
3383
+ }
3384
+ }
3385
+ if (directlyAffected.size === 0) {
3386
+ return Array.from(this.moduleMap.keys());
3387
+ }
3388
+ const affected = new Set(directlyAffected);
3389
+ const queue = Array.from(directlyAffected);
3390
+ while (queue.length > 0) {
3391
+ const current = queue.shift();
3392
+ const dependents = this.dependents.get(current);
3393
+ if (dependents) {
3394
+ for (const dep of dependents) {
3395
+ if (!affected.has(dep)) {
3396
+ affected.add(dep);
3397
+ queue.push(dep);
3398
+ }
3399
+ }
3400
+ }
3401
+ }
3402
+ return Array.from(this.moduleMap.keys()).filter((name) => affected.has(name));
3403
+ }
3404
+ getDirectImports(moduleName) {
3405
+ return Array.from(this.imports.get(moduleName) ?? []);
3406
+ }
3407
+ getDirectDependents(moduleName) {
3408
+ return Array.from(this.dependents.get(moduleName) ?? []);
3409
+ }
3410
+ }
3411
+ function findAffectedModules(previous, current, changedFiles) {
3412
+ const depGraph = new ModuleDependencyGraph(current);
3413
+ return depGraph.getAffectedModules(changedFiles);
3414
+ }
3415
+
3416
+ // src/watch.ts
3417
+ var DEFAULT_DEBOUNCE_MS = 100;
3418
+ function watchProject(options) {
3419
+ const rootDir = resolve2(options.rootDir);
3420
+ const outDir = resolve2(options.outDir);
3421
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
3422
+ let timer;
3423
+ let closed = false;
3424
+ let compiling = false;
3425
+ let pending = false;
3426
+ const pendingPaths = new Set;
3427
+ let watcher;
3428
+ const incremental = createIncrementalCompiler();
3429
+ let initialEvent;
3430
+ let resolveReady;
3431
+ let rejectReady;
3432
+ const ready = new Promise((resolvePromise, rejectPromise) => {
3433
+ resolveReady = resolvePromise;
3434
+ rejectReady = rejectPromise;
3435
+ });
3436
+ const emit = (event) => {
3437
+ options.onEvent?.(event);
3438
+ if (event.initial)
3439
+ initialEvent = event;
3440
+ };
3441
+ const compile = async (initial, changedPaths = []) => {
3442
+ if (closed && !initial)
3443
+ return;
3444
+ if (compiling) {
3445
+ pending = true;
3446
+ return;
3447
+ }
3448
+ compiling = true;
3449
+ const startedAt = performance.now();
3450
+ options.onEvent?.({
3451
+ type: "compile-start",
3452
+ initial,
3453
+ durationMs: 0,
3454
+ diagnostics: [],
3455
+ written: []
3456
+ });
3457
+ try {
3458
+ const result = await incremental.compile({ ...options, writeOnError: false }, changedPaths);
3459
+ const durationMs = Math.round(performance.now() - startedAt);
3460
+ const hasErrors = result.diagnostics.some((diagnostic) => diagnostic.severity === "error");
3461
+ emit({
3462
+ type: hasErrors ? "compile-error" : "compiled",
3463
+ initial,
3464
+ durationMs,
3465
+ diagnostics: result.diagnostics,
3466
+ written: result.written,
3467
+ stats: result.stats
3468
+ });
3469
+ } catch (error) {
3470
+ rejectReady(error);
3471
+ throw error;
3472
+ } finally {
3473
+ compiling = false;
3474
+ if (pending && !closed) {
3475
+ pending = false;
3476
+ compile(false, [...pendingPaths]);
3477
+ pendingPaths.clear();
3478
+ }
3479
+ }
3480
+ };
3481
+ const schedule = (changedPath) => {
3482
+ if (closed)
3483
+ return;
3484
+ if (changedPath)
3485
+ pendingPaths.add(changedPath);
3486
+ if (timer)
3487
+ clearTimeout(timer);
3488
+ timer = setTimeout(() => {
3489
+ timer = undefined;
3490
+ compile(false, [...pendingPaths]);
3491
+ pendingPaths.clear();
3492
+ }, debounceMs);
3493
+ };
3494
+ compile(true).then(() => {
3495
+ if (closed)
3496
+ return;
3497
+ watcher = watch(rootDir, { recursive: true }, (_eventType, filename) => {
3498
+ if (!filename)
3499
+ return schedule();
3500
+ const changedPath = resolve2(rootDir, filename.toString());
3501
+ const relativePath = relative3(outDir, changedPath);
3502
+ if (!relativePath.startsWith("..") && relativePath !== "")
3503
+ return;
3504
+ if (/\.(tsx?|mts|cts)$/.test(changedPath))
3505
+ schedule(relative3(rootDir, changedPath));
3506
+ });
3507
+ if (initialEvent)
3508
+ resolveReady(initialEvent);
3509
+ }).catch(() => {
3510
+ return;
3511
+ });
3512
+ return {
3513
+ ready,
3514
+ async close() {
3515
+ closed = true;
3516
+ if (timer)
3517
+ clearTimeout(timer);
3518
+ watcher?.close();
3519
+ await ready.catch(() => {
3520
+ return;
3521
+ });
3522
+ }
3523
+ };
3524
+ }
3525
+ // src/inspect.ts
3526
+ import { existsSync as existsSync3 } from "node:fs";
3527
+ import { join as join4 } from "node:path";
3528
+ function formatGraph(graph) {
3529
+ const lines = [];
3530
+ for (const module of graph.modules) {
3531
+ lines.push(`MODULE ${module.name}`);
3532
+ lines.push(` file: ${module.file}:${module.line}`);
3533
+ lines.push(` imports: ${module.imports.length > 0 ? module.imports.join(", ") : "-"}`);
3534
+ lines.push(` providers: ${module.providers.length > 0 ? module.providers.map((p) => p.token).join(", ") : "-"}`);
3535
+ lines.push(` controllers: ${module.controllers.length > 0 ? module.controllers.map((c) => c.className).join(", ") : "-"}`);
3536
+ lines.push(` commands: ${module.commands.length > 0 ? module.commands.map((c) => c.name).join(", ") : "-"}`);
3537
+ }
3538
+ lines.push(`EXTERNAL TOKENS ${graph.externalTokens.length > 0 ? graph.externalTokens.join(", ") : "-"}`);
3539
+ return lines.join(`
3540
+ `);
3541
+ }
3542
+ function explainGraph(graph, subject) {
3543
+ const module = graph.modules.find((candidate) => candidate.name === subject);
3544
+ if (module)
3545
+ return explainModule(graph, module);
3546
+ const provider = findProvider(graph, subject);
3547
+ if (provider)
3548
+ return explainProvider(graph, provider.module, provider.provider);
3549
+ if (graph.externalTokens.includes(subject)) {
3550
+ const references = graph.modules.flatMap((candidate) => [
3551
+ ...candidate.providers.filter((item) => item.deps.includes(subject)).map((item) => `${candidate.name}.${item.token}`),
3552
+ ...candidate.controllers.filter((item) => item.deps.includes(subject)).map((item) => `${candidate.name}.${item.className}`)
3553
+ ]);
3554
+ return [
3555
+ `EXTERNAL TOKEN ${subject}`,
3556
+ " provided by: platform runtime",
3557
+ ` references: ${references.length > 0 ? references.join(", ") : "-"}`
3558
+ ].join(`
3559
+ `);
3560
+ }
3561
+ const known = [...graph.modules.map((item) => item.name), ...graph.externalTokens].sort();
3562
+ throw new Error(`No module, provider, or external token named "${subject}". Known names: ${known.join(", ") || "(none)"}`);
3563
+ }
3564
+ function doctorProject(rootDir, outDir, graph, upToDate, diagnostics = []) {
3565
+ const checks = [
3566
+ {
3567
+ name: "project-root",
3568
+ ok: existsSync3(rootDir),
3569
+ detail: existsSync3(rootDir) ? rootDir : `missing: ${rootDir}`
3570
+ },
3571
+ {
3572
+ name: "tsconfig",
3573
+ ok: existsSync3(join4(rootDir, "tsconfig.json")),
3574
+ detail: existsSync3(join4(rootDir, "tsconfig.json")) ? "tsconfig.json found" : "tsconfig.json missing"
3575
+ },
3576
+ {
3577
+ name: "modules",
3578
+ ok: graph.modules.length > 0,
3579
+ detail: `${graph.modules.length} module(s) discovered`
3580
+ },
3581
+ {
3582
+ name: "generated-artifacts",
3583
+ ok: upToDate,
3584
+ detail: upToDate ? "application.ts and app.manifest.json are up to date" : "generated artifacts are missing or stale"
3585
+ }
3586
+ ];
3587
+ const allDiagnostics = [...graph.diagnostics ?? [], ...diagnostics];
3588
+ return {
3589
+ checks,
3590
+ diagnostics: allDiagnostics,
3591
+ errors: allDiagnostics.filter((diagnostic) => diagnostic.severity === "error").length + checks.filter((check) => !check.ok).length
3592
+ };
3593
+ }
3594
+ function explainModule(graph, module) {
3595
+ const dependents = graph.modules.filter((candidate) => candidate.imports.includes(module.name)).map((candidate) => candidate.name);
3596
+ return [
3597
+ `MODULE ${module.name}`,
3598
+ ` file: ${module.file}:${module.line}`,
3599
+ ` imports: ${module.imports.length > 0 ? module.imports.join(", ") : "-"}`,
3600
+ ` imported by: ${dependents.length > 0 ? dependents.join(", ") : "-"}`,
3601
+ ` providers: ${module.providers.length > 0 ? module.providers.map((provider) => provider.token).join(", ") : "-"}`,
3602
+ ` controllers: ${module.controllers.length > 0 ? module.controllers.map((controller) => controller.className).join(", ") : "-"}`,
3603
+ ` commands: ${module.commands.length > 0 ? module.commands.map((command) => command.name).join(", ") : "-"}`
3604
+ ].join(`
3605
+ `);
3606
+ }
3607
+ function explainProvider(graph, module, provider) {
3608
+ const dependents = graph.modules.flatMap((candidate) => [
3609
+ ...candidate.providers.filter((item) => item.deps.includes(provider.token)).map((item) => `${candidate.name}.${item.token}`),
3610
+ ...candidate.controllers.filter((item) => item.deps.includes(provider.token)).map((item) => `${candidate.name}.${item.className}`)
3611
+ ]);
3612
+ return [
3613
+ `PROVIDER ${provider.token}`,
3614
+ ` module: ${module.name}`,
3615
+ ` file: ${provider.file}:${provider.line}`,
3616
+ ` kind: ${provider.kind}`,
3617
+ ` scope: ${provider.scope}`,
3618
+ ` exported: ${provider.exported ? "yes" : "no"}`,
3619
+ ` deps: ${provider.deps.length > 0 ? provider.deps.join(", ") : "-"}`,
3620
+ ` depended on by: ${dependents.length > 0 ? dependents.join(", ") : "-"}`
3621
+ ].join(`
3622
+ `);
3623
+ }
3624
+ function findProvider(graph, subject) {
3625
+ for (const module of graph.modules) {
3626
+ const provider = module.providers.find((candidate) => candidate.token === subject || candidate.useClass === subject || candidate.useFactoryName === subject);
3627
+ if (provider)
3628
+ return { module, provider };
3629
+ }
3630
+ return;
3631
+ }
3632
+ function exportGraphMermaid(graph) {
3633
+ const lines = ["graph TD"];
3634
+ for (const mod of graph.modules) {
3635
+ const safeId = mod.name.replace(/[^a-zA-Z0-9_]/g, "_");
3636
+ lines.push(` ${safeId}["${mod.className ?? mod.name}"]`);
3637
+ for (const imp of mod.imports) {
3638
+ const safeImp = imp.replace(/[^a-zA-Z0-9_]/g, "_");
3639
+ lines.push(` ${safeId} --> ${safeImp}`);
3640
+ }
3641
+ }
3642
+ return lines.join(`
3643
+ `);
3644
+ }
3645
+ function exportGraphDot(graph) {
3646
+ const lines = [
3647
+ "digraph ApplicationGraph {",
3648
+ " rankdir=LR;",
3649
+ ' node [shape=box, fontname="Helvetica"];'
3650
+ ];
3651
+ for (const mod of graph.modules) {
3652
+ lines.push(` "${mod.name}" [label="${mod.className ?? mod.name}"];`);
3653
+ for (const imp of mod.imports) {
3654
+ lines.push(` "${mod.name}" -> "${imp}";`);
3655
+ }
3656
+ }
3657
+ lines.push("}");
3658
+ return lines.join(`
3659
+ `);
3660
+ }
1583
3661
  export {
1584
3662
  ANGULAR_ENTERPRISE_RULES,
1585
3663
  CLEAN_ARCHITECTURE_RULES,
3664
+ COMPILER_DIAGNOSTIC_CODES,
1586
3665
  MODULAR_MONOLITH_RULES,
1587
3666
  MODULE_BOUNDARY_PROFILES,
3667
+ ModuleDependencyGraph,
1588
3668
  analyzeProject,
1589
3669
  camelName,
1590
3670
  checkProject,
1591
3671
  compileProject,
3672
+ createDependencyGraphCache,
3673
+ createIncrementalCompiler,
3674
+ doctorProject,
3675
+ explainGraph,
3676
+ exportGraphDot,
3677
+ exportGraphMermaid,
3678
+ formatGraph,
1592
3679
  generateApplication,
1593
3680
  getModuleBoundaryPreset,
1594
3681
  getModuleBoundaryProfile,
1595
3682
  renderApplication,
1596
3683
  resolveModuleBoundaries,
1597
- validateGraph
3684
+ validateGraph,
3685
+ watchProject
1598
3686
  };