@valkyrjaio/sindri 26.2.0 → 26.4.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.4.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.4.0",
42
42
  "ts-morph": "^28.0.0"
43
43
  },
44
44
  "scripts": {
@@ -13,12 +13,12 @@ import * as path from 'path';
13
13
  import {
14
14
  ClassDeclaration,
15
15
  Decorator,
16
+ Expression,
16
17
  MethodDeclaration,
17
18
  Node,
18
19
  ParameterDeclaration,
19
20
  Project,
20
21
  SourceFile,
21
- SyntaxKind,
22
22
  ts,
23
23
  } from 'ts-morph';
24
24
 
@@ -34,6 +34,20 @@ import { HandlerData } from '../Data/HandlerData.ts';
34
34
  * All methods are protected so subclasses can override any step of the pipeline.
35
35
  */
36
36
  export abstract class AstReader {
37
+ /**
38
+ * Module resolution settings for {@link resolveModuleSpecifierToFilePath}.
39
+ *
40
+ * NodeNext mirrors how the application itself resolves its imports, and
41
+ * `allowImportingTsExtensions` matches the framework convention of writing
42
+ * the `.ts` extension in every import specifier.
43
+ */
44
+ protected static readonly MODULE_RESOLUTION_OPTIONS: ts.CompilerOptions = {
45
+ module: ts.ModuleKind.NodeNext,
46
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
47
+ allowImportingTsExtensions: true,
48
+ noEmit: true,
49
+ };
50
+
37
51
  protected parseFileToSourceFile(filePath: string): SourceFile {
38
52
  if (!fs.existsSync(filePath)) {
39
53
  throw new AstFileReadException(`Cannot read file '${filePath}'.`);
@@ -98,14 +112,44 @@ export abstract class AstReader {
98
112
  return '';
99
113
  }
100
114
 
101
- if (!moduleSpecifier.startsWith('.')) {
102
- return '';
115
+ return this.resolveModuleSpecifierToFilePath(moduleSpecifier, currentFilePath);
116
+ }
117
+
118
+ /**
119
+ * Resolve a module specifier to the absolute path of the TypeScript file it points at.
120
+ *
121
+ * Relative specifiers resolve against the importing file's directory. Bare
122
+ * package specifiers (`@valkyrjaio/valkyrja/Cli/Routing/Data/Route.ts`) go
123
+ * through the TypeScript compiler's NodeNext resolver, which honors the
124
+ * package's `exports` map — so a provider tree that reaches into an
125
+ * installed package (the framework's own component providers, reached from
126
+ * the app config) is walked exactly like application source.
127
+ *
128
+ * Returns '' when the specifier cannot be resolved to an existing `.ts`
129
+ * source file. A package shipping only compiled `.js` — or only `.d.ts`
130
+ * declarations, which carry no `getRoutes()` body to read — has nothing for
131
+ * the AST readers to walk.
132
+ */
133
+ protected resolveModuleSpecifierToFilePath(moduleSpecifier: string, currentFilePath: string): string {
134
+ if (moduleSpecifier.startsWith('.')) {
135
+ const dir = path.dirname(currentFilePath);
136
+ const resolved = path.resolve(dir, moduleSpecifier.replace(/\.js$/, '.ts'));
137
+
138
+ return fs.existsSync(resolved) ? resolved : '';
103
139
  }
104
140
 
105
- const dir = path.dirname(currentFilePath);
106
- const resolved = path.resolve(dir, moduleSpecifier.replace(/\.js$/, '.ts'));
141
+ const resolved = ts.resolveModuleName(
142
+ moduleSpecifier,
143
+ currentFilePath,
144
+ AstReader.MODULE_RESOLUTION_OPTIONS,
145
+ ts.sys,
146
+ ).resolvedModule?.resolvedFileName;
107
147
 
108
- return fs.existsSync(resolved) ? resolved : '';
148
+ if (resolved === undefined || !resolved.endsWith('.ts') || resolved.endsWith('.d.ts')) {
149
+ return '';
150
+ }
151
+
152
+ return resolved;
109
153
  }
110
154
 
111
155
  /**
@@ -141,6 +185,122 @@ export abstract class AstReader {
141
185
  return classes;
142
186
  }
143
187
 
188
+ /**
189
+ * Resolve a `new X()` or bare `X` identifier element to the absolute file
190
+ * path of the class it references, using the current file's import map.
191
+ *
192
+ * Returns undefined when the element is neither form, or when the name is
193
+ * not imported via a relative specifier (e.g. framework classes imported
194
+ * from a package — those are intentionally skipped by the provider walk).
195
+ */
196
+ protected resolveElementToFilePath(
197
+ element: ts.Expression,
198
+ useMap: Record<string, string>,
199
+ currentFilePath: string,
200
+ ): string | undefined {
201
+ let name: string | undefined;
202
+
203
+ if (ts.isNewExpression(element)) {
204
+ name = ts.isIdentifier(element.expression) ? element.expression.text : undefined;
205
+ } else if (ts.isIdentifier(element)) {
206
+ name = element.text;
207
+ }
208
+
209
+ if (name === undefined) {
210
+ return undefined;
211
+ }
212
+
213
+ const resolved = this.resolveImportToFilePath(name, useMap, currentFilePath);
214
+
215
+ return resolved !== '' ? resolved : undefined;
216
+ }
217
+
218
+ /**
219
+ * Extract the absolute file paths of the classes referenced by a method
220
+ * returning [new A(), new B(), ...] (or [A, B, ...]).
221
+ *
222
+ * Unlike {@link extractClassListFromValues} (which returns bare class
223
+ * names), this resolves each reference through the import map so provider
224
+ * classes are located unambiguously — critical when the same short class
225
+ * name exists in more than one component tree (e.g. an Http and a Cli
226
+ * `ComponentProvider`).
227
+ */
228
+ protected extractClassPathListFromValues(
229
+ method: MethodDeclaration | undefined,
230
+ useMap: Record<string, string>,
231
+ currentFilePath: string,
232
+ ): string[] {
233
+ if (method === undefined) {
234
+ return [];
235
+ }
236
+
237
+ const array = this.findReturnedArray(method);
238
+
239
+ if (array === undefined) {
240
+ return [];
241
+ }
242
+
243
+ return this.extractClassPathListFromArrayExpr(array, useMap, currentFilePath);
244
+ }
245
+
246
+ /**
247
+ * Extract the absolute file paths of the classes referenced by the elements
248
+ * of an array literal, using the current file's import map.
249
+ */
250
+ protected extractClassPathListFromArrayExpr(
251
+ array: ts.ArrayLiteralExpression,
252
+ useMap: Record<string, string>,
253
+ currentFilePath: string,
254
+ ): string[] {
255
+ const paths: string[] = [];
256
+
257
+ for (const element of array.elements) {
258
+ const resolved = this.resolveElementToFilePath(element, useMap, currentFilePath);
259
+
260
+ if (resolved !== undefined) {
261
+ paths.push(resolved);
262
+ }
263
+ }
264
+
265
+ return paths;
266
+ }
267
+
268
+ /**
269
+ * Collect every identifier an expression references as a value.
270
+ *
271
+ * Expressions taken from a provider's `getRoutes()` body are emitted into
272
+ * the generated data cache verbatim, so the cache must import everything
273
+ * they name — not just the provider class, but the parameter/enum/constant
274
+ * classes the route arguments reach for. Property *names* are skipped (the
275
+ * `HELP` of `CommandName.HELP` is not an import) as are type positions,
276
+ * which erase at runtime and must never become a value import.
277
+ */
278
+ protected collectReferencedIdentifiers(node: ts.Node, names: Set<string> = new Set<string>()): Set<string> {
279
+ if (ts.isTypeNode(node)) {
280
+ return names;
281
+ }
282
+
283
+ if (ts.isIdentifier(node)) {
284
+ names.add(node.text);
285
+
286
+ return names;
287
+ }
288
+
289
+ if (ts.isPropertyAccessExpression(node)) {
290
+ return this.collectReferencedIdentifiers(node.expression, names);
291
+ }
292
+
293
+ if (ts.isPropertyAssignment(node)) {
294
+ return this.collectReferencedIdentifiers(node.initializer, names);
295
+ }
296
+
297
+ ts.forEachChild(node, (child: ts.Node): void => {
298
+ this.collectReferencedIdentifiers(child, names);
299
+ });
300
+
301
+ return names;
302
+ }
303
+
144
304
  /**
145
305
  * Extract service ID strings from keys of an object-returning method.
146
306
  *
@@ -296,7 +456,7 @@ export abstract class AstReader {
296
456
  }
297
457
 
298
458
  if (ts.isIdentifier(node)) {
299
- return this.resolveStaticProperty(node.text, undefined, useMap, currentFilePath);
459
+ return this.resolveConstant(node.text, useMap, currentFilePath);
300
460
  }
301
461
 
302
462
  if (ts.isPropertyAccessExpression(node)) {
@@ -311,47 +471,108 @@ export abstract class AstReader {
311
471
  }
312
472
 
313
473
  /**
314
- * Resolve a static property value by reading the imported source file.
474
+ * Resolve a static property value by reading the file that declares its class.
315
475
  */
316
476
  protected resolveStaticProperty(
317
477
  className: string,
318
- propName: string | undefined,
478
+ propName: string,
319
479
  useMap: Record<string, string>,
320
480
  currentFilePath: string,
321
481
  ): string | undefined {
322
- const importedFilePath = this.resolveImportToFilePath(className, useMap, currentFilePath);
482
+ const declaringClass = this.parseDeclaringSourceFile(className, useMap, currentFilePath)?.getClass(className);
323
483
 
324
- if (importedFilePath === '') {
484
+ if (declaringClass === undefined) {
325
485
  return undefined;
326
486
  }
327
487
 
328
- try {
329
- const project = new Project({ skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: true });
330
- const importedSource = project.addSourceFileAtPath(importedFilePath);
331
- const importedClass = importedSource.getClass(className);
488
+ const prop = declaringClass.getStaticProperty(propName);
332
489
 
333
- if (importedClass === undefined || propName === undefined) {
334
- return undefined;
335
- }
490
+ if (prop === undefined || !Node.isPropertyDeclaration(prop)) {
491
+ return undefined;
492
+ }
336
493
 
337
- const prop = importedClass.getStaticProperty(propName);
494
+ return this.stringValueOf(prop.getInitializer());
495
+ }
338
496
 
339
- if (prop === undefined) {
340
- return undefined;
341
- }
497
+ /**
498
+ * Resolve a module-level `const` to its string value.
499
+ *
500
+ * Not every binding key is a class static — some are plain exported
501
+ * constants, e.g. `export const LoggerContractId = 'Valkyrja.Log.Logger.LoggerContract' as const;`
502
+ */
503
+ protected resolveConstant(
504
+ name: string,
505
+ useMap: Record<string, string>,
506
+ currentFilePath: string,
507
+ ): string | undefined {
508
+ const declaration = this.parseDeclaringSourceFile(name, useMap, currentFilePath)?.getVariableDeclaration(name);
342
509
 
343
- if (Node.isPropertyDeclaration(prop)) {
344
- const initializer = prop.getInitializer();
510
+ return this.stringValueOf(declaration?.getInitializer());
511
+ }
345
512
 
346
- if (initializer?.isKind(SyntaxKind.StringLiteral) === true) {
347
- return (initializer as Node<ts.StringLiteral>).compilerNode.text;
348
- }
349
- }
513
+ /**
514
+ * Parse the file that declares a name: the module it is imported from, or
515
+ * the current file when the name is not imported.
516
+ *
517
+ * A provider commonly declares the binding keys for its own services
518
+ * alongside the publishers that use them, so `[Provider.SomeId]` names a
519
+ * constant that no import map can resolve — it lives in the file being read.
520
+ *
521
+ * Returns undefined when the file cannot be read.
522
+ */
523
+ protected parseDeclaringSourceFile(
524
+ name: string,
525
+ useMap: Record<string, string>,
526
+ currentFilePath: string,
527
+ ): SourceFile | undefined {
528
+ const importedFilePath = this.resolveImportToFilePath(name, useMap, currentFilePath);
529
+ const filePath = importedFilePath !== '' ? importedFilePath : currentFilePath;
530
+
531
+ try {
532
+ const project = new Project({ skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: true });
533
+
534
+ return project.addSourceFileAtPath(filePath);
350
535
  } catch {
351
536
  // Silently skip unresolvable references
537
+ return undefined;
352
538
  }
539
+ }
353
540
 
354
- return undefined;
541
+ /**
542
+ * The string a declaration's initializer holds, once its type-only wrappers are stripped.
543
+ */
544
+ protected stringValueOf(initializer: Expression | undefined): string | undefined {
545
+ if (initializer === undefined) {
546
+ return undefined;
547
+ }
548
+
549
+ const value = this.unwrapTypeAssertions(initializer.compilerNode);
550
+
551
+ return ts.isStringLiteral(value) ? value.text : undefined;
552
+ }
553
+
554
+ /**
555
+ * Strip the type-only wrappers an expression may be dressed in — `as`,
556
+ * `satisfies` and parentheses — to reach the value node underneath.
557
+ *
558
+ * Binding-key constants are declared `static readonly Data = 'Some.Id' as
559
+ * const;`, so the initializer node is an `AsExpression`, not the
560
+ * `StringLiteral` holding the value. Without unwrapping, every such key
561
+ * resolves to nothing and the publishers it names are dropped from the
562
+ * generated cache.
563
+ */
564
+ protected unwrapTypeAssertions(node: ts.Expression): ts.Expression {
565
+ let current = node;
566
+
567
+ while (
568
+ ts.isAsExpression(current) ||
569
+ ts.isSatisfiesExpression(current) ||
570
+ ts.isParenthesizedExpression(current)
571
+ ) {
572
+ current = current.expression;
573
+ }
574
+
575
+ return current;
355
576
  }
356
577
 
357
578
  /**
@@ -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
 
@@ -10,15 +10,22 @@
10
10
  import type { ts } from 'ts-morph';
11
11
 
12
12
  export class RouteProviderResult {
13
+ /**
14
+ * @param routeImports Local name → absolute file path for every class the
15
+ * imperative `routes` expressions reference, so the
16
+ * generated data cache can import them.
17
+ */
13
18
  constructor(
14
19
  readonly controllerClasses: readonly string[] = [],
15
20
  readonly routes: readonly ts.Expression[] = [],
21
+ readonly routeImports: Readonly<Record<string, string>> = {},
16
22
  ) {}
17
23
 
18
24
  merge(other: RouteProviderResult): RouteProviderResult {
19
25
  return new RouteProviderResult(
20
26
  [...new Set([...this.controllerClasses, ...other.controllerClasses])],
21
27
  [...this.routes, ...other.routes],
28
+ { ...this.routeImports, ...other.routeImports },
22
29
  );
23
30
  }
24
31
  }
@@ -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';
@@ -28,18 +28,69 @@ export class RouteProviderReader extends AstReader implements RouteProviderReade
28
28
 
29
29
  const useMap = this.buildUseMap(sourceFile);
30
30
  const methods = this.indexMethods(classDecl);
31
+ const routes = this.extractRoutes(methods[RouteProviderReader.METHOD_ROUTES], useMap, filePath);
31
32
 
32
33
  return new RouteProviderResult(
33
34
  this.extractClassListFromValues(methods[RouteProviderReader.METHOD_CONTROLLER_CLASSES], useMap, filePath),
34
- this.extractRoutes(methods[RouteProviderReader.METHOD_ROUTES], useMap, filePath),
35
+ routes,
36
+ this.extractRouteImports(routes, useMap, filePath),
35
37
  );
36
38
  }
37
39
 
40
+ /**
41
+ * Resolve every class the imperative route expressions reference to its
42
+ * absolute file path, so the generated data cache can import them.
43
+ *
44
+ * Names that are not imported by the provider file — locals, globals, and
45
+ * the provider's own class (which the generator imports separately) — have
46
+ * no entry in the use map and are skipped.
47
+ */
48
+ protected extractRouteImports(
49
+ routes: readonly ts.Expression[],
50
+ useMap: Record<string, string>,
51
+ filePath: string,
52
+ ): Record<string, string> {
53
+ const imports: Record<string, string> = {};
54
+
55
+ for (const route of routes) {
56
+ for (const name of this.collectReferencedIdentifiers(route)) {
57
+ const resolved = this.resolveImportToFilePath(name, useMap, filePath);
58
+
59
+ if (resolved !== '') {
60
+ imports[name] = resolved;
61
+ }
62
+ }
63
+ }
64
+
65
+ return imports;
66
+ }
67
+
68
+ /**
69
+ * Extract the route objects returned by a provider's `getRoutes()` method.
70
+ *
71
+ * TS route providers register routes imperatively — `getRoutes()` returns
72
+ * the concrete `new Route(...)` / `new DynamicRoute(...)` (or CLI
73
+ * `new Route(...)`) instances. Those expressions are returned verbatim for
74
+ * the data-cache generator to emit as route closures and to derive the
75
+ * path/regex lookup maps from. (Attribute/decorator scanning — the other,
76
+ * optional source of routes — is handled separately via
77
+ * {@link readFile}'s controller-class list.)
78
+ */
38
79
  protected extractRoutes(
39
- _method: ReturnType<typeof this.indexMethods>[string] | undefined,
80
+ method: ReturnType<typeof this.indexMethods>[string] | undefined,
40
81
  _useMap: Record<string, string>,
41
82
  _filePath: string,
42
83
  ): ts.Expression[] {
43
- return [];
84
+ if (method === undefined) {
85
+ return [];
86
+ }
87
+
88
+ const array = this.findReturnedArray(method);
89
+
90
+ if (array === undefined) {
91
+ return [];
92
+ }
93
+
94
+ return array.elements.filter((element): element is ts.NewExpression => ts.isNewExpression(element));
44
95
  }
45
96
  }
@@ -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.4.0' as const;
12
+ static readonly VERSION_BUILD_DATE_TIME = 'July 27 2026 22:16:03 MST' as const;
13
13
  static readonly ICON = `\
14
14
  ██████████
15
15
  ████ ████