@valkyrjaio/sindri 26.2.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valkyrjaio/sindri",
3
- "version": "26.2.0",
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.2.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
  *
@@ -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
 
@@ -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.2.0' as const;
12
- static readonly VERSION_BUILD_DATE_TIME = 'July 25 2026 00:19:15 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
 
@@ -32,11 +32,7 @@ export class AstCliDataFileGenerator extends AstFileGenerator implements CliData
32
32
  namespace: string,
33
33
  routes: Record<string, ts.Expression>,
34
34
  ): GenerateStatus {
35
- const userImports = Object.entries(this.classImportMap)
36
- .map(([name, specifier]) => `import { ${name} } from '${specifier}';`)
37
- .join('\n');
38
-
39
- const userImportsBlock = userImports ? `${userImports}\n` : '';
35
+ const userImportsBlock = this.buildUserImportsBlock(this.classImportMap);
40
36
 
41
37
  const contents = this.generateClassContents(routes);
42
38
 
@@ -64,6 +60,36 @@ export class AstCliDataFileGenerator extends AstFileGenerator implements CliData
64
60
  return this.writeFile(directory, className, data);
65
61
  }
66
62
 
63
+ /**
64
+ * Generate the CLI routing data file from imperative `getRoutes()` route
65
+ * objects — each `new Route(name, description, handler, ...)` is keyed by
66
+ * its command name (the first constructor argument) and emitted verbatim.
67
+ */
68
+ public generateFileFromRoutes(
69
+ directory: string,
70
+ className: string,
71
+ namespace: string,
72
+ routeExprs: readonly ts.Expression[],
73
+ ): GenerateStatus {
74
+ const routes: Record<string, ts.Expression> = {};
75
+
76
+ for (const expr of routeExprs) {
77
+ if (!ts.isNewExpression(expr)) {
78
+ continue;
79
+ }
80
+
81
+ const nameArg = (expr.arguments ?? [])[0];
82
+
83
+ if (nameArg === undefined || !ts.isStringLiteral(nameArg)) {
84
+ continue;
85
+ }
86
+
87
+ routes[nameArg.text] = expr;
88
+ }
89
+
90
+ return this.generateFile(directory, className, namespace, routes);
91
+ }
92
+
67
93
  public generateClassContents(routes: Record<string, ts.Expression>): string {
68
94
  const entries = Object.entries(routes);
69
95
 
@@ -74,15 +100,25 @@ export class AstCliDataFileGenerator extends AstFileGenerator implements CliData
74
100
  const lines: string[] = [];
75
101
 
76
102
  for (const [key, valueExpr] of entries) {
77
- const keyExpr = this.buildEnumCaseExpr(key);
78
- const printedKey = this.printer.printNode(ts.EmitHint.Unspecified, keyExpr, this.dummySourceFile);
79
- const printedValue = this.printer.printNode(ts.EmitHint.Unspecified, valueExpr, this.dummySourceFile);
80
-
81
- const formattedKey = key.includes('::') ? `[${printedKey}]` : `['${printedKey}']`;
103
+ const printedValue = this.printExpr(valueExpr);
104
+ const formattedKey = key.includes('::')
105
+ ? `[${this.printer.printNode(ts.EmitHint.Unspecified, this.buildEnumCaseExpr(key), this.dummySourceFile)}]`
106
+ : `['${key}']`;
82
107
 
83
108
  lines.push(` ${formattedKey}: (): RouteContract => ${printedValue},`);
84
109
  }
85
110
 
86
- return ['super({', ' routes: {', ...lines, ' },', ' });'].join('\n ');
111
+ return ['super({', ...lines, ' });'].join('\n ');
112
+ }
113
+
114
+ /**
115
+ * Print an expression to source. Parsed nodes (from a `getRoutes()` body)
116
+ * are emitted via their original source text; synthesized nodes (which
117
+ * carry no source positions) are emitted through the printer.
118
+ */
119
+ protected printExpr(expr: ts.Expression): string {
120
+ return expr.pos >= 0
121
+ ? expr.getText()
122
+ : this.printer.printNode(ts.EmitHint.Unspecified, expr, this.dummySourceFile);
87
123
  }
88
124
  }
@@ -32,11 +32,7 @@ export class AstContainerDataFileGenerator extends AstFileGenerator implements C
32
32
  namespace: string,
33
33
  publishers: Readonly<Record<string, readonly [string, string]>>,
34
34
  ): GenerateStatus {
35
- const userImports = Object.entries(this.classImportMap)
36
- .map(([name, specifier]) => `import { ${name} } from '${specifier}';`)
37
- .join('\n');
38
-
39
- const userImportsBlock = userImports ? `${userImports}\n` : '';
35
+ const userImportsBlock = this.buildUserImportsBlock(this.classImportMap);
40
36
 
41
37
  const contents = this.generateClassContents(publishers);
42
38
 
@@ -67,12 +63,11 @@ export class AstContainerDataFileGenerator extends AstFileGenerator implements C
67
63
  const lines: string[] = [];
68
64
 
69
65
  for (const [serviceId, [providerClass, methodName]] of entries) {
70
- const keyExpr = this.buildEnumCaseExpr(serviceId);
71
- const printedKey = this.printer.printNode(ts.EmitHint.Unspecified, keyExpr, this.dummySourceFile);
72
-
73
66
  const shortProviderClass = providerClass.slice(providerClass.lastIndexOf('\\') + 1);
74
67
 
75
- const formattedKey = serviceId.includes('::') ? `[${printedKey}]` : `['${printedKey}']`;
68
+ const formattedKey = serviceId.includes('::')
69
+ ? `[${this.printer.printNode(ts.EmitHint.Unspecified, this.buildEnumCaseExpr(serviceId), this.dummySourceFile)}]`
70
+ : `['${serviceId}']`;
76
71
 
77
72
  lines.push(
78
73
  ` ${formattedKey}: (container: ContainerContract) => ${shortProviderClass}.${methodName}(container),`,
@@ -32,20 +32,26 @@ export class AstEventDataFileGenerator extends AstFileGenerator implements Event
32
32
  namespace: string,
33
33
  listeners: Record<string, ts.Expression>,
34
34
  ): GenerateStatus {
35
- const userImports = Object.entries(this.classImportMap)
36
- .map(([name, specifier]) => `import { ${name} } from '${specifier}';`)
37
- .join('\n');
38
-
39
- const userImportsBlock = userImports ? `${userImports}\n` : '';
35
+ const userImportsBlock = this.buildUserImportsBlock(this.classImportMap);
40
36
 
41
37
  const contents = this.generateClassContents(listeners);
42
38
 
39
+ // The Listener / ListenerContract imports are only referenced by the
40
+ // generated listener closures — omit them when there are no listeners
41
+ // so empty event data does not carry unused (and unresolved) imports.
42
+ const listenerImports =
43
+ Object.keys(listeners).length > 0
44
+ ? [
45
+ `import { Listener } from '@valkyrjaio/valkyrja/Event/Data/Listener.ts';`,
46
+ `import type { ListenerContract } from '@valkyrjaio/valkyrja/Event/Data/Contract/ListenerContract.ts';`,
47
+ ]
48
+ : [];
49
+
43
50
  const data = [
44
51
  '// This file was automatically generated by Sindri.',
45
52
  '',
46
53
  `import { EventData } from '@valkyrjaio/valkyrja/Event/Data/EventData.ts';`,
47
- `import { Listener } from '@valkyrjaio/valkyrja/Event/Data/Listener.ts';`,
48
- `import type { ListenerContract } from '@valkyrjaio/valkyrja/Event/Data/Contract/ListenerContract.ts';`,
54
+ ...listenerImports,
49
55
  userImportsBlock,
50
56
  `export class ${className} extends EventData {`,
51
57
  ` constructor() {`,