@valkyrjaio/sindri 26.1.3 → 26.3.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.
Files changed (24) hide show
  1. package/dist/bin/sindri.cjs +580 -309
  2. package/package.json +2 -2
  3. package/src/Sindri/Ast/Abstract/AstReader.ts +80 -0
  4. package/src/Sindri/Ast/CliRouteAttributeReader.ts +11 -11
  5. package/src/Sindri/Ast/ComponentProviderReader.ts +9 -5
  6. package/src/Sindri/Ast/ConfigReader.ts +14 -6
  7. package/src/Sindri/Ast/Contract/HttpRouteMiddlewareReaderContract.ts +1 -1
  8. package/src/Sindri/Ast/Data/CliRouteData.ts +1 -1
  9. package/src/Sindri/Ast/Data/Contract/CliRouteDataContract.ts +1 -1
  10. package/src/Sindri/Ast/Data/Contract/HttpRouteDataContract.ts +1 -1
  11. package/src/Sindri/Ast/Data/HttpRouteData.ts +1 -1
  12. package/src/Sindri/Ast/HttpRouteAttributeReader.ts +2 -2
  13. package/src/Sindri/Ast/HttpRouteMiddlewareReader.ts +9 -9
  14. package/src/Sindri/Ast/RouteProviderReader.ts +24 -3
  15. package/src/Sindri/Constant/SindriInfo.ts +2 -2
  16. package/src/Sindri/Generate/Abstract/GenerateDataFromAst.ts +66 -13
  17. package/src/Sindri/Generator/Abstract/AstFileGenerator.ts +13 -0
  18. package/src/Sindri/Generator/Ast/Cli/AstCliDataFileGenerator.ts +47 -11
  19. package/src/Sindri/Generator/Ast/Container/AstContainerDataFileGenerator.ts +4 -9
  20. package/src/Sindri/Generator/Ast/Event/AstEventDataFileGenerator.ts +13 -7
  21. package/src/Sindri/Generator/Ast/Http/AstHttpDataFileGenerator.ts +176 -12
  22. package/src/Sindri/Generator/Cli/Contract/CliDataFileGeneratorContract.ts +10 -0
  23. package/src/Sindri/Generator/Container/Contract/ContainerDataFileGeneratorContract.ts +3 -0
  24. package/src/Sindri/Generator/Http/Contract/HttpDataFileGeneratorContract.ts +10 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valkyrjaio/sindri",
3
- "version": "26.1.3",
3
+ "version": "26.3.0",
4
4
  "description": "The Valkyrja Sindri build tool.",
5
5
  "homepage": "https://www.valkyrja.io/sindri",
6
6
  "keywords": [
@@ -38,7 +38,7 @@
38
38
  "node": ">=22"
39
39
  },
40
40
  "dependencies": {
41
- "@valkyrjaio/valkyrja": "^26.1.0",
41
+ "@valkyrjaio/valkyrja": "^26.3.0",
42
42
  "ts-morph": "^28.0.0"
43
43
  },
44
44
  "scripts": {
@@ -141,6 +141,86 @@ export abstract class AstReader {
141
141
  return classes;
142
142
  }
143
143
 
144
+ /**
145
+ * Resolve a `new X()` or bare `X` identifier element to the absolute file
146
+ * path of the class it references, using the current file's import map.
147
+ *
148
+ * Returns undefined when the element is neither form, or when the name is
149
+ * not imported via a relative specifier (e.g. framework classes imported
150
+ * from a package — those are intentionally skipped by the provider walk).
151
+ */
152
+ protected resolveElementToFilePath(
153
+ element: ts.Expression,
154
+ useMap: Record<string, string>,
155
+ currentFilePath: string,
156
+ ): string | undefined {
157
+ let name: string | undefined;
158
+
159
+ if (ts.isNewExpression(element)) {
160
+ name = ts.isIdentifier(element.expression) ? element.expression.text : undefined;
161
+ } else if (ts.isIdentifier(element)) {
162
+ name = element.text;
163
+ }
164
+
165
+ if (name === undefined) {
166
+ return undefined;
167
+ }
168
+
169
+ const resolved = this.resolveImportToFilePath(name, useMap, currentFilePath);
170
+
171
+ return resolved !== '' ? resolved : undefined;
172
+ }
173
+
174
+ /**
175
+ * Extract the absolute file paths of the classes referenced by a method
176
+ * returning [new A(), new B(), ...] (or [A, B, ...]).
177
+ *
178
+ * Unlike {@link extractClassListFromValues} (which returns bare class
179
+ * names), this resolves each reference through the import map so provider
180
+ * classes are located unambiguously — critical when the same short class
181
+ * name exists in more than one component tree (e.g. an Http and a Cli
182
+ * `ComponentProvider`).
183
+ */
184
+ protected extractClassPathListFromValues(
185
+ method: MethodDeclaration | undefined,
186
+ useMap: Record<string, string>,
187
+ currentFilePath: string,
188
+ ): string[] {
189
+ if (method === undefined) {
190
+ return [];
191
+ }
192
+
193
+ const array = this.findReturnedArray(method);
194
+
195
+ if (array === undefined) {
196
+ return [];
197
+ }
198
+
199
+ return this.extractClassPathListFromArrayExpr(array, useMap, currentFilePath);
200
+ }
201
+
202
+ /**
203
+ * Extract the absolute file paths of the classes referenced by the elements
204
+ * of an array literal, using the current file's import map.
205
+ */
206
+ protected extractClassPathListFromArrayExpr(
207
+ array: ts.ArrayLiteralExpression,
208
+ useMap: Record<string, string>,
209
+ currentFilePath: string,
210
+ ): string[] {
211
+ const paths: string[] = [];
212
+
213
+ for (const element of array.elements) {
214
+ const resolved = this.resolveElementToFilePath(element, useMap, currentFilePath);
215
+
216
+ if (resolved !== undefined) {
217
+ paths.push(resolved);
218
+ }
219
+ }
220
+
221
+ return paths;
222
+ }
223
+
144
224
  /**
145
225
  * Extract service ID strings from keys of an object-returning method.
146
226
  *
@@ -73,7 +73,7 @@ export class CliRouteAttributeReader extends RouteAttributeReader implements Cli
73
73
  }
74
74
 
75
75
  const updatedName = this.updateName(name, method, useMap, currentFilePath, currentClass);
76
- const [routeMatchedMiddleware, routeDispatchedMiddleware, throwableCaughtMiddleware, exitedMiddleware] =
76
+ const [routeMatchedMiddleware, routeDispatchedMiddleware, throwableCaughtMiddleware, processExitingMiddleware] =
77
77
  this.updateMiddleware(
78
78
  method,
79
79
  useMap,
@@ -93,7 +93,7 @@ export class CliRouteAttributeReader extends RouteAttributeReader implements Cli
93
93
  routeMatchedMiddleware,
94
94
  routeDispatchedMiddleware,
95
95
  throwableCaughtMiddleware,
96
- exitedMiddleware,
96
+ processExitingMiddleware,
97
97
  this.parameterReader.updateArguments(method, useMap, currentFilePath, currentClass),
98
98
  this.parameterReader.updateOptions(method, useMap, currentFilePath, currentClass),
99
99
  );
@@ -130,7 +130,7 @@ export class CliRouteAttributeReader extends RouteAttributeReader implements Cli
130
130
  routeMatchedMiddleware: string[],
131
131
  routeDispatchedMiddleware: string[],
132
132
  throwableCaughtMiddleware: string[],
133
- exitedMiddleware: string[],
133
+ processExitingMiddleware: string[],
134
134
  ): [string[], string[], string[], string[]] {
135
135
  for (const decorator of this.findDecoratorsOnNode(method, 'Middleware', useMap, currentFilePath)) {
136
136
  const mwName = this.extractExprValue(
@@ -144,7 +144,7 @@ export class CliRouteAttributeReader extends RouteAttributeReader implements Cli
144
144
  continue;
145
145
  }
146
146
 
147
- [routeMatchedMiddleware, routeDispatchedMiddleware, throwableCaughtMiddleware, exitedMiddleware] =
147
+ [routeMatchedMiddleware, routeDispatchedMiddleware, throwableCaughtMiddleware, processExitingMiddleware] =
148
148
  this.classifyMiddleware(
149
149
  mwName,
150
150
  useMap,
@@ -152,11 +152,11 @@ export class CliRouteAttributeReader extends RouteAttributeReader implements Cli
152
152
  routeMatchedMiddleware,
153
153
  routeDispatchedMiddleware,
154
154
  throwableCaughtMiddleware,
155
- exitedMiddleware,
155
+ processExitingMiddleware,
156
156
  );
157
157
  }
158
158
 
159
- return [routeMatchedMiddleware, routeDispatchedMiddleware, throwableCaughtMiddleware, exitedMiddleware];
159
+ return [routeMatchedMiddleware, routeDispatchedMiddleware, throwableCaughtMiddleware, processExitingMiddleware];
160
160
  }
161
161
 
162
162
  protected classifyMiddleware(
@@ -166,7 +166,7 @@ export class CliRouteAttributeReader extends RouteAttributeReader implements Cli
166
166
  routeMatchedMiddleware: string[],
167
167
  routeDispatchedMiddleware: string[],
168
168
  throwableCaughtMiddleware: string[],
169
- exitedMiddleware: string[],
169
+ processExitingMiddleware: string[],
170
170
  ): [string[], string[], string[], string[]] {
171
171
  if (this.classImplementsInterface(mwName, 'RouteMatchedMiddlewareContract', useMap, currentFilePath)) {
172
172
  routeMatchedMiddleware = [...routeMatchedMiddleware, mwName];
@@ -180,11 +180,11 @@ export class CliRouteAttributeReader extends RouteAttributeReader implements Cli
180
180
  throwableCaughtMiddleware = [...throwableCaughtMiddleware, mwName];
181
181
  }
182
182
 
183
- if (this.classImplementsInterface(mwName, 'ExitedMiddlewareContract', useMap, currentFilePath)) {
184
- exitedMiddleware = [...exitedMiddleware, mwName];
183
+ if (this.classImplementsInterface(mwName, 'ProcessExitingMiddlewareContract', useMap, currentFilePath)) {
184
+ processExitingMiddleware = [...processExitingMiddleware, mwName];
185
185
  }
186
186
 
187
- return [routeMatchedMiddleware, routeDispatchedMiddleware, throwableCaughtMiddleware, exitedMiddleware];
187
+ return [routeMatchedMiddleware, routeDispatchedMiddleware, throwableCaughtMiddleware, processExitingMiddleware];
188
188
  }
189
189
 
190
190
  protected buildRouteExpr(data: CliRouteData): ts.Expression {
@@ -213,7 +213,7 @@ export class CliRouteAttributeReader extends RouteAttributeReader implements Cli
213
213
  this.buildClassArrayExpr(data.routeMatchedMiddleware),
214
214
  this.buildClassArrayExpr(data.routeDispatchedMiddleware),
215
215
  this.buildClassArrayExpr(data.throwableCaughtMiddleware),
216
- this.buildClassArrayExpr(data.exitedMiddleware),
216
+ this.buildClassArrayExpr(data.processExitingMiddleware),
217
217
  ];
218
218
  }
219
219
  }
@@ -30,12 +30,16 @@ export class ComponentProviderReader extends AstReader implements ComponentProvi
30
30
  const useMap = this.buildUseMap(sourceFile);
31
31
  const methods = this.indexMethods(classDecl);
32
32
 
33
+ // Provider references are resolved through the file's import map to
34
+ // absolute paths (not bare names) so the walk locates each provider
35
+ // unambiguously — the same short name (e.g. `ComponentProvider`,
36
+ // `DataServiceProvider`) exists in both the Http and Cli trees.
33
37
  return new ComponentProviderResult(
34
- this.extractClassListFromValues(methods[ComponentProviderReader.METHOD_COMPONENT], useMap, filePath),
35
- this.extractClassListFromValues(methods[ComponentProviderReader.METHOD_CONTAINER], useMap, filePath),
36
- this.extractClassListFromValues(methods[ComponentProviderReader.METHOD_EVENT], useMap, filePath),
37
- this.extractClassListFromValues(methods[ComponentProviderReader.METHOD_CLI], useMap, filePath),
38
- this.extractClassListFromValues(methods[ComponentProviderReader.METHOD_HTTP], useMap, filePath),
38
+ this.extractClassPathListFromValues(methods[ComponentProviderReader.METHOD_COMPONENT], useMap, filePath),
39
+ this.extractClassPathListFromValues(methods[ComponentProviderReader.METHOD_CONTAINER], useMap, filePath),
40
+ this.extractClassPathListFromValues(methods[ComponentProviderReader.METHOD_EVENT], useMap, filePath),
41
+ this.extractClassPathListFromValues(methods[ComponentProviderReader.METHOD_CLI], useMap, filePath),
42
+ this.extractClassPathListFromValues(methods[ComponentProviderReader.METHOD_HTTP], useMap, filePath),
39
43
  );
40
44
  }
41
45
  }
@@ -190,21 +190,29 @@ export class ConfigReader extends AstReader implements ConfigReaderContract {
190
190
  }
191
191
 
192
192
  /**
193
- * Extract provider class names from the providers argument.
194
- * In CliConfig, providers is typically the 12th positional arg (index 11).
193
+ * Extract the absolute file paths of the configured provider classes.
194
+ *
195
+ * The providers array is the first array-literal argument after the
196
+ * `dataNamespace` (index 8) — every preceding argument is a scalar. Its
197
+ * exact position varies by config type (index 9 in `HttpConfig`, index 11
198
+ * in `CliConfig` after `applicationName`/`defaultCommandName`), so the
199
+ * array is located by scanning rather than by a fixed index.
200
+ *
201
+ * Each element (`new ComponentProvider()` or a bare `ComponentProvider`
202
+ * identifier) is resolved through the config file's import map to an
203
+ * absolute path, so the walk locates the right provider even when the same
204
+ * short name exists in multiple trees.
195
205
  */
196
206
  protected extractProvidersFromArgs(
197
207
  args: ts.NodeArray<ts.Expression>,
198
208
  useMap: Record<string, string>,
199
209
  filePath: string,
200
210
  ): string[] {
201
- // Try to find the providers array in the argument list
202
- // It's typically at index 11 in CliConfig
203
- for (let i = 10; i < args.length; i++) {
211
+ for (let i = 9; i < args.length; i++) {
204
212
  const arg = args[i];
205
213
 
206
214
  if (arg !== undefined && ts.isArrayLiteralExpression(arg)) {
207
- return this.extractClassListFromArrayExpr(arg, useMap, filePath);
215
+ return this.extractClassPathListFromArrayExpr(arg, useMap, filePath);
208
216
  }
209
217
  }
210
218
 
@@ -38,7 +38,7 @@ export interface HttpRouteMiddlewareReaderContract {
38
38
  routeDispatchedMiddleware: string[],
39
39
  throwableCaughtMiddleware: string[],
40
40
  sendingResponseMiddleware: string[],
41
- terminatedMiddleware: string[],
41
+ responseSentMiddleware: string[],
42
42
  ): [string[], string[], string[], string[], string[]];
43
43
 
44
44
  updateRequestStruct(
@@ -23,7 +23,7 @@ export class CliRouteData implements CliRouteDataContract {
23
23
  readonly routeMatchedMiddleware: readonly string[] = [],
24
24
  readonly routeDispatchedMiddleware: readonly string[] = [],
25
25
  readonly throwableCaughtMiddleware: readonly string[] = [],
26
- readonly exitedMiddleware: readonly string[] = [],
26
+ readonly processExitingMiddleware: readonly string[] = [],
27
27
  args: readonly CliArgumentParameterData[] = [],
28
28
  readonly options: readonly CliOptionParameterData[] = [],
29
29
  ) {
@@ -19,7 +19,7 @@ export interface CliRouteDataContract {
19
19
  readonly routeMatchedMiddleware: readonly string[];
20
20
  readonly routeDispatchedMiddleware: readonly string[];
21
21
  readonly throwableCaughtMiddleware: readonly string[];
22
- readonly exitedMiddleware: readonly string[];
22
+ readonly processExitingMiddleware: readonly string[];
23
23
  readonly arguments: readonly CliArgumentParameterDataContract[];
24
24
  readonly options: readonly CliOptionParameterDataContract[];
25
25
  }
@@ -20,7 +20,7 @@ export interface HttpRouteDataContract {
20
20
  readonly routeDispatchedMiddleware: readonly string[];
21
21
  readonly throwableCaughtMiddleware: readonly string[];
22
22
  readonly sendingResponseMiddleware: readonly string[];
23
- readonly terminatedMiddleware: readonly string[];
23
+ readonly responseSentMiddleware: readonly string[];
24
24
  readonly requestStruct: string | null;
25
25
  readonly responseStruct: string | null;
26
26
  readonly isDynamic: boolean;
@@ -21,7 +21,7 @@ export class HttpRouteData implements HttpRouteDataContract {
21
21
  readonly routeDispatchedMiddleware: readonly string[] = [],
22
22
  readonly throwableCaughtMiddleware: readonly string[] = [],
23
23
  readonly sendingResponseMiddleware: readonly string[] = [],
24
- readonly terminatedMiddleware: readonly string[] = [],
24
+ readonly responseSentMiddleware: readonly string[] = [],
25
25
  readonly requestStruct: string | null = null,
26
26
  readonly responseStruct: string | null = null,
27
27
  readonly isDynamic: boolean = false,
@@ -188,7 +188,7 @@ export class HttpRouteAttributeReader extends RouteAttributeReader implements Ht
188
188
  routeDispatchedMiddleware,
189
189
  throwableCaughtMiddleware,
190
190
  sendingResponseMiddleware,
191
- terminatedMiddleware,
191
+ responseSentMiddleware,
192
192
  ] = this.middlewareReader.updateMiddleware(
193
193
  method,
194
194
  useMap,
@@ -210,7 +210,7 @@ export class HttpRouteAttributeReader extends RouteAttributeReader implements Ht
210
210
  routeDispatchedMiddleware,
211
211
  throwableCaughtMiddleware,
212
212
  sendingResponseMiddleware,
213
- terminatedMiddleware,
213
+ responseSentMiddleware,
214
214
  this.middlewareReader.updateRequestStruct(method, useMap, currentFilePath, currentClass),
215
215
  this.middlewareReader.updateResponseStruct(method, useMap, currentFilePath, currentClass),
216
216
  resolvedIsDynamic,
@@ -75,7 +75,7 @@ export class HttpRouteMiddlewareReader extends AstReader implements HttpRouteMid
75
75
  routeDispatchedMiddleware: string[],
76
76
  throwableCaughtMiddleware: string[],
77
77
  sendingResponseMiddleware: string[],
78
- terminatedMiddleware: string[],
78
+ responseSentMiddleware: string[],
79
79
  ): [string[], string[], string[], string[], string[]] {
80
80
  for (const decorator of this.findDecoratorsOnNode(method, 'Middleware', useMap, namespace)) {
81
81
  const mwName = this.extractExprValue(this.getDecoratorArg(decorator, 0), useMap, namespace, currentClass);
@@ -89,7 +89,7 @@ export class HttpRouteMiddlewareReader extends AstReader implements HttpRouteMid
89
89
  routeDispatchedMiddleware,
90
90
  throwableCaughtMiddleware,
91
91
  sendingResponseMiddleware,
92
- terminatedMiddleware,
92
+ responseSentMiddleware,
93
93
  ] = this.classifyMiddleware(
94
94
  mwName,
95
95
  useMap,
@@ -98,7 +98,7 @@ export class HttpRouteMiddlewareReader extends AstReader implements HttpRouteMid
98
98
  routeDispatchedMiddleware,
99
99
  throwableCaughtMiddleware,
100
100
  sendingResponseMiddleware,
101
- terminatedMiddleware,
101
+ responseSentMiddleware,
102
102
  );
103
103
  }
104
104
 
@@ -107,7 +107,7 @@ export class HttpRouteMiddlewareReader extends AstReader implements HttpRouteMid
107
107
  routeDispatchedMiddleware,
108
108
  throwableCaughtMiddleware,
109
109
  sendingResponseMiddleware,
110
- terminatedMiddleware,
110
+ responseSentMiddleware,
111
111
  ];
112
112
  }
113
113
 
@@ -151,7 +151,7 @@ export class HttpRouteMiddlewareReader extends AstReader implements HttpRouteMid
151
151
  this.buildClassIdentifierArrayExpr(data.routeDispatchedMiddleware),
152
152
  this.buildClassIdentifierArrayExpr(data.throwableCaughtMiddleware),
153
153
  this.buildClassIdentifierArrayExpr(data.sendingResponseMiddleware),
154
- this.buildClassIdentifierArrayExpr(data.terminatedMiddleware),
154
+ this.buildClassIdentifierArrayExpr(data.responseSentMiddleware),
155
155
  ];
156
156
  }
157
157
 
@@ -181,7 +181,7 @@ export class HttpRouteMiddlewareReader extends AstReader implements HttpRouteMid
181
181
  routeDispatchedMiddleware: string[],
182
182
  throwableCaughtMiddleware: string[],
183
183
  sendingResponseMiddleware: string[],
184
- terminatedMiddleware: string[],
184
+ responseSentMiddleware: string[],
185
185
  ): [string[], string[], string[], string[], string[]] {
186
186
  if (this.classImplementsInterface(mwName, 'RouteMatchedMiddlewareContract', useMap, currentFilePath)) {
187
187
  routeMatchedMiddleware = [...routeMatchedMiddleware, mwName];
@@ -199,8 +199,8 @@ export class HttpRouteMiddlewareReader extends AstReader implements HttpRouteMid
199
199
  sendingResponseMiddleware = [...sendingResponseMiddleware, mwName];
200
200
  }
201
201
 
202
- if (this.classImplementsInterface(mwName, 'TerminatedMiddlewareContract', useMap, currentFilePath)) {
203
- terminatedMiddleware = [...terminatedMiddleware, mwName];
202
+ if (this.classImplementsInterface(mwName, 'ResponseSentMiddlewareContract', useMap, currentFilePath)) {
203
+ responseSentMiddleware = [...responseSentMiddleware, mwName];
204
204
  }
205
205
 
206
206
  return [
@@ -208,7 +208,7 @@ export class HttpRouteMiddlewareReader extends AstReader implements HttpRouteMid
208
208
  routeDispatchedMiddleware,
209
209
  throwableCaughtMiddleware,
210
210
  sendingResponseMiddleware,
211
- terminatedMiddleware,
211
+ responseSentMiddleware,
212
212
  ];
213
213
  }
214
214
  }
@@ -7,7 +7,7 @@
7
7
  * file that was distributed with this source code.
8
8
  */
9
9
 
10
- import type { ts } from 'ts-morph';
10
+ import { ts } from 'ts-morph';
11
11
 
12
12
  import { AstReader } from './Abstract/AstReader.ts';
13
13
  import { RouteProviderResult } from './Data/Result/RouteProviderResult.ts';
@@ -35,11 +35,32 @@ export class RouteProviderReader extends AstReader implements RouteProviderReade
35
35
  );
36
36
  }
37
37
 
38
+ /**
39
+ * Extract the route objects returned by a provider's `getRoutes()` method.
40
+ *
41
+ * TS route providers register routes imperatively — `getRoutes()` returns
42
+ * the concrete `new Route(...)` / `new DynamicRoute(...)` (or CLI
43
+ * `new Route(...)`) instances. Those expressions are returned verbatim for
44
+ * the data-cache generator to emit as route closures and to derive the
45
+ * path/regex lookup maps from. (Attribute/decorator scanning — the other,
46
+ * optional source of routes — is handled separately via
47
+ * {@link readFile}'s controller-class list.)
48
+ */
38
49
  protected extractRoutes(
39
- _method: ReturnType<typeof this.indexMethods>[string] | undefined,
50
+ method: ReturnType<typeof this.indexMethods>[string] | undefined,
40
51
  _useMap: Record<string, string>,
41
52
  _filePath: string,
42
53
  ): ts.Expression[] {
43
- return [];
54
+ if (method === undefined) {
55
+ return [];
56
+ }
57
+
58
+ const array = this.findReturnedArray(method);
59
+
60
+ if (array === undefined) {
61
+ return [];
62
+ }
63
+
64
+ return array.elements.filter((element): element is ts.NewExpression => ts.isNewExpression(element));
44
65
  }
45
66
  }
@@ -8,8 +8,8 @@
8
8
  */
9
9
 
10
10
  export class SindriInfo {
11
- static readonly VERSION = '26.1.3' as const;
12
- static readonly VERSION_BUILD_DATE_TIME = 'June 21 2026 11:20:36 MST' as const;
11
+ static readonly VERSION = '26.3.0' as const;
12
+ static readonly VERSION_BUILD_DATE_TIME = 'July 25 2026 21:11:03 MST' as const;
13
13
  static readonly ICON = `\
14
14
  ██████████
15
15
  ████ ████
@@ -211,6 +211,17 @@ export abstract class GenerateDataFromAst {
211
211
  return '';
212
212
  }
213
213
 
214
+ /**
215
+ * Build the `import { X } from '...'` specifier for a class file, relative
216
+ * to the generated data file's output directory. Used to import the
217
+ * provider/handler classes referenced by the generated data caches.
218
+ */
219
+ protected relativeSpecifier(fromDir: string, toFile: string): string {
220
+ const relative = path.relative(fromDir, toFile).split(path.sep).join('/');
221
+
222
+ return relative.startsWith('.') ? relative : `./${relative}`;
223
+ }
224
+
214
225
  protected generateContainerData(
215
226
  serviceProviders: readonly string[],
216
227
  config: ConfigResult,
@@ -221,6 +232,7 @@ export abstract class GenerateDataFromAst {
221
232
  .writeMessages();
222
233
 
223
234
  const publishers: Record<string, readonly [string, string]> = {};
235
+ const importMap: Record<string, string> = {};
224
236
 
225
237
  for (const providerClass of serviceProviders) {
226
238
  const filePath = this.fqnToFilePath(providerClass, config.namespace, config.dir);
@@ -231,9 +243,17 @@ export abstract class GenerateDataFromAst {
231
243
 
232
244
  const result = this.serviceProviderReader.readFile(filePath);
233
245
 
246
+ if (Object.keys(result.publishers).length > 0) {
247
+ // The publisher callbacks reference the provider's static methods,
248
+ // so the generated data cache must import the provider class.
249
+ importMap[path.basename(filePath, '.ts')] = this.relativeSpecifier(config.dataPath, filePath);
250
+ }
251
+
234
252
  Object.assign(publishers, result.publishers);
235
253
  }
236
254
 
255
+ this.containerGenerator.classImportMap = importMap;
256
+
237
257
  const status = this.containerGenerator.generateFile(
238
258
  config.dataPath,
239
259
  'AppContainerData',
@@ -297,6 +317,8 @@ export abstract class GenerateDataFromAst {
297
317
  .writeMessages();
298
318
 
299
319
  const allRoutes: Record<string, ts.Expression> = {};
320
+ const imperativeRoutes: ts.Expression[] = [];
321
+ const importMap: Record<string, string> = {};
300
322
 
301
323
  for (const providerClass of cliRouteProviders) {
302
324
  const filePath = this.fqnToFilePath(providerClass, config.namespace, config.dir);
@@ -307,6 +329,13 @@ export abstract class GenerateDataFromAst {
307
329
 
308
330
  const providerResult = this.routeProviderReader.readFile(filePath);
309
331
 
332
+ if (providerResult.routes.length > 0) {
333
+ // Imperative routes reference the provider's static handlers,
334
+ // so the generated data cache must import the provider class.
335
+ imperativeRoutes.push(...providerResult.routes);
336
+ importMap[path.basename(filePath, '.ts')] = this.relativeSpecifier(config.dataPath, filePath);
337
+ }
338
+
310
339
  for (const controllerClass of providerResult.controllerClasses) {
311
340
  const controllerPath = this.fqnToFilePath(controllerClass, config.namespace, config.dir);
312
341
 
@@ -320,12 +349,17 @@ export abstract class GenerateDataFromAst {
320
349
  }
321
350
  }
322
351
 
323
- const status = this.cliGenerator.generateFile(
324
- config.dataPath,
325
- 'AppCliRoutingData',
326
- config.dataNamespace,
327
- allRoutes,
328
- );
352
+ this.cliGenerator.classImportMap = importMap;
353
+
354
+ const status =
355
+ imperativeRoutes.length > 0
356
+ ? this.cliGenerator.generateFileFromRoutes(
357
+ config.dataPath,
358
+ 'AppCliRoutingData',
359
+ config.dataNamespace,
360
+ imperativeRoutes,
361
+ )
362
+ : this.cliGenerator.generateFile(config.dataPath, 'AppCliRoutingData', config.dataNamespace, allRoutes);
329
363
 
330
364
  return this.addMessagesForGenerateStatus(output, status).withAddedMessages(new NewLine()).writeMessages();
331
365
  }
@@ -341,6 +375,8 @@ export abstract class GenerateDataFromAst {
341
375
 
342
376
  const allRoutes: Record<string, ts.Expression> = {};
343
377
  const allRouteData: Record<string, HttpRouteData> = {};
378
+ const imperativeRoutes: ts.Expression[] = [];
379
+ const importMap: Record<string, string> = {};
344
380
 
345
381
  for (const providerClass of httpRouteProviders) {
346
382
  const filePath = this.fqnToFilePath(providerClass, config.namespace, config.dir);
@@ -351,6 +387,13 @@ export abstract class GenerateDataFromAst {
351
387
 
352
388
  const providerResult = this.routeProviderReader.readFile(filePath);
353
389
 
390
+ if (providerResult.routes.length > 0) {
391
+ // Imperative routes reference the provider's static handlers,
392
+ // so the generated data cache must import the provider class.
393
+ imperativeRoutes.push(...providerResult.routes);
394
+ importMap[path.basename(filePath, '.ts')] = this.relativeSpecifier(config.dataPath, filePath);
395
+ }
396
+
354
397
  for (const controllerClass of providerResult.controllerClasses) {
355
398
  const controllerPath = this.fqnToFilePath(controllerClass, config.namespace, config.dir);
356
399
 
@@ -365,13 +408,23 @@ export abstract class GenerateDataFromAst {
365
408
  }
366
409
  }
367
410
 
368
- const status = this.httpGenerator.generateFile(
369
- config.dataPath,
370
- 'AppHttpRoutingData',
371
- config.dataNamespace,
372
- allRoutes,
373
- allRouteData,
374
- );
411
+ this.httpGenerator.classImportMap = importMap;
412
+
413
+ const status =
414
+ imperativeRoutes.length > 0
415
+ ? this.httpGenerator.generateFileFromRoutes(
416
+ config.dataPath,
417
+ 'AppHttpRoutingData',
418
+ config.dataNamespace,
419
+ imperativeRoutes,
420
+ )
421
+ : this.httpGenerator.generateFile(
422
+ config.dataPath,
423
+ 'AppHttpRoutingData',
424
+ config.dataNamespace,
425
+ allRoutes,
426
+ allRouteData,
427
+ );
375
428
 
376
429
  return this.addMessagesForGenerateStatus(output, status).withAddedMessages(new NewLine()).writeMessages();
377
430
  }
@@ -31,6 +31,19 @@ export abstract class AstFileGenerator {
31
31
  return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier(className), caseName);
32
32
  }
33
33
 
34
+ /**
35
+ * Build the `import { X } from '...';` block for the provider/handler classes
36
+ * a generated data cache references, followed by a trailing newline (or an
37
+ * empty string when there are no such imports).
38
+ */
39
+ protected buildUserImportsBlock(classImportMap: Record<string, string>): string {
40
+ const userImports = Object.entries(classImportMap)
41
+ .map(([name, specifier]) => `import { ${name} } from '${specifier}';`)
42
+ .join('\n');
43
+
44
+ return userImports ? `${userImports}\n` : '';
45
+ }
46
+
34
47
  protected writeFile(directory: string, className: string, data: string): GenerateStatus {
35
48
  const filePath = directory.replace(/\/$/, '') + `/${className}.ts`;
36
49