@valkyrjaio/sindri 26.3.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.3.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.3.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;
147
+
148
+ if (resolved === undefined || !resolved.endsWith('.ts') || resolved.endsWith('.d.ts')) {
149
+ return '';
150
+ }
107
151
 
108
- return fs.existsSync(resolved) ? resolved : '';
152
+ return resolved;
109
153
  }
110
154
 
111
155
  /**
@@ -221,6 +265,42 @@ export abstract class AstReader {
221
265
  return paths;
222
266
  }
223
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
+
224
304
  /**
225
305
  * Extract service ID strings from keys of an object-returning method.
226
306
  *
@@ -376,7 +456,7 @@ export abstract class AstReader {
376
456
  }
377
457
 
378
458
  if (ts.isIdentifier(node)) {
379
- return this.resolveStaticProperty(node.text, undefined, useMap, currentFilePath);
459
+ return this.resolveConstant(node.text, useMap, currentFilePath);
380
460
  }
381
461
 
382
462
  if (ts.isPropertyAccessExpression(node)) {
@@ -391,47 +471,108 @@ export abstract class AstReader {
391
471
  }
392
472
 
393
473
  /**
394
- * 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.
395
475
  */
396
476
  protected resolveStaticProperty(
397
477
  className: string,
398
- propName: string | undefined,
478
+ propName: string,
399
479
  useMap: Record<string, string>,
400
480
  currentFilePath: string,
401
481
  ): string | undefined {
402
- const importedFilePath = this.resolveImportToFilePath(className, useMap, currentFilePath);
482
+ const declaringClass = this.parseDeclaringSourceFile(className, useMap, currentFilePath)?.getClass(className);
403
483
 
404
- if (importedFilePath === '') {
484
+ if (declaringClass === undefined) {
405
485
  return undefined;
406
486
  }
407
487
 
408
- try {
409
- const project = new Project({ skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: true });
410
- const importedSource = project.addSourceFileAtPath(importedFilePath);
411
- const importedClass = importedSource.getClass(className);
488
+ const prop = declaringClass.getStaticProperty(propName);
412
489
 
413
- if (importedClass === undefined || propName === undefined) {
414
- return undefined;
415
- }
490
+ if (prop === undefined || !Node.isPropertyDeclaration(prop)) {
491
+ return undefined;
492
+ }
416
493
 
417
- const prop = importedClass.getStaticProperty(propName);
494
+ return this.stringValueOf(prop.getInitializer());
495
+ }
418
496
 
419
- if (prop === undefined) {
420
- return undefined;
421
- }
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);
422
509
 
423
- if (Node.isPropertyDeclaration(prop)) {
424
- const initializer = prop.getInitializer();
510
+ return this.stringValueOf(declaration?.getInitializer());
511
+ }
425
512
 
426
- if (initializer?.isKind(SyntaxKind.StringLiteral) === true) {
427
- return (initializer as Node<ts.StringLiteral>).compilerNode.text;
428
- }
429
- }
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);
430
535
  } catch {
431
536
  // Silently skip unresolvable references
537
+ return undefined;
432
538
  }
539
+ }
433
540
 
434
- 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;
435
576
  }
436
577
 
437
578
  /**
@@ -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
  }
@@ -28,13 +28,43 @@ 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
+
38
68
  /**
39
69
  * Extract the route objects returned by a provider's `getRoutes()` method.
40
70
  *
@@ -8,8 +8,8 @@
8
8
  */
9
9
 
10
10
  export class SindriInfo {
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;
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
  ████ ████
@@ -222,6 +222,146 @@ export abstract class GenerateDataFromAst {
222
222
  return relative.startsWith('.') ? relative : `./${relative}`;
223
223
  }
224
224
 
225
+ /**
226
+ * Build the specifier a generated data file should import a class file by.
227
+ *
228
+ * Application source is imported by a path relative to the data directory;
229
+ * a file belonging to an installed package is imported by that package's
230
+ * own public specifier — so the framework classes the cache references are
231
+ * written exactly as hand-written application code writes them
232
+ * (`@valkyrjaio/valkyrja/Cli/Routing/Data/Route.ts`), never as a brittle
233
+ * reach into `node_modules`.
234
+ */
235
+ protected importSpecifier(fromDir: string, toFile: string): string {
236
+ return this.packageSpecifier(toFile) ?? this.relativeSpecifier(fromDir, toFile);
237
+ }
238
+
239
+ /**
240
+ * Map an absolute path inside an installed package back to the specifier
241
+ * that package publicly exposes it as, or undefined when the file is not
242
+ * part of one.
243
+ *
244
+ * The package's `exports` map is inverted: for `"./*.ts": "./src/*.ts"` a
245
+ * file at `<package>/src/Foo.ts` maps back to `<name>/Foo.ts`. Packages
246
+ * with no matching pattern fall back to the path as written under the
247
+ * package root, which is what a package without an `exports` map exposes.
248
+ */
249
+ protected packageSpecifier(filePath: string): string | undefined {
250
+ const normalized = filePath.split(path.sep).join('/');
251
+ const marker = normalized.lastIndexOf('/node_modules/');
252
+
253
+ if (marker === -1) {
254
+ return undefined;
255
+ }
256
+
257
+ const subject = normalized.substring(marker + '/node_modules/'.length);
258
+ const segments = subject.split('/');
259
+ const nameLength = subject.startsWith('@') ? 2 : 1;
260
+
261
+ if (segments.length <= nameLength) {
262
+ return undefined;
263
+ }
264
+
265
+ const packageName = segments.slice(0, nameLength).join('/');
266
+ const packageDir = normalized.substring(0, marker + '/node_modules/'.length) + packageName;
267
+ const relative = segments.slice(nameLength).join('/');
268
+
269
+ return `${packageName}/${this.exportedSubpath(packageDir, relative) ?? relative}`;
270
+ }
271
+
272
+ /**
273
+ * Invert a package's `exports` patterns to find the subpath a file inside
274
+ * it is exposed as, or undefined when no pattern matches it.
275
+ */
276
+ protected exportedSubpath(packageDir: string, relative: string): string | undefined {
277
+ for (const [key, target] of Object.entries(this.readPackageExports(packageDir))) {
278
+ const star = key.indexOf('*');
279
+ const targetStar = target.indexOf('*');
280
+
281
+ if (star === -1 || targetStar === -1) {
282
+ continue;
283
+ }
284
+
285
+ const prefix = target.substring(0, targetStar).replace(/^\.\//, '');
286
+ const suffix = target.substring(targetStar + 1);
287
+
288
+ if (!relative.startsWith(prefix) || !relative.endsWith(suffix)) {
289
+ continue;
290
+ }
291
+
292
+ const value = relative.substring(prefix.length, relative.length - suffix.length);
293
+
294
+ return (key.substring(0, star) + value + key.substring(star + 1)).replace(/^\.\//, '');
295
+ }
296
+
297
+ return undefined;
298
+ }
299
+
300
+ /**
301
+ * Read a package's `exports` map, flattened to subpath → file target.
302
+ *
303
+ * Conditional targets are collapsed to their first string value; every
304
+ * condition a package declares for a subpath points at the same source
305
+ * file in the layouts sindri generates for.
306
+ */
307
+ protected readPackageExports(packageDir: string): Record<string, string> {
308
+ const manifestPath = path.join(packageDir, 'package.json');
309
+
310
+ if (!fs.existsSync(manifestPath)) {
311
+ return {};
312
+ }
313
+
314
+ let manifest: { exports?: unknown };
315
+
316
+ try {
317
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { exports?: unknown };
318
+ } catch {
319
+ return {};
320
+ }
321
+
322
+ const exports = manifest.exports;
323
+
324
+ if (typeof exports !== 'object' || exports === null) {
325
+ return {};
326
+ }
327
+
328
+ const flattened: Record<string, string> = {};
329
+
330
+ for (const [subpath, target] of Object.entries(exports as Record<string, unknown>)) {
331
+ const resolved = this.firstStringTarget(target);
332
+
333
+ if (resolved !== undefined) {
334
+ flattened[subpath] = resolved;
335
+ }
336
+ }
337
+
338
+ return flattened;
339
+ }
340
+
341
+ /**
342
+ * Reduce an `exports` target — a string, or a nested map of conditions — to
343
+ * the first file path it names.
344
+ */
345
+ protected firstStringTarget(target: unknown): string | undefined {
346
+ if (typeof target === 'string') {
347
+ return target;
348
+ }
349
+
350
+ if (typeof target !== 'object' || target === null) {
351
+ return undefined;
352
+ }
353
+
354
+ for (const value of Object.values(target as Record<string, unknown>)) {
355
+ const resolved = this.firstStringTarget(value);
356
+
357
+ if (resolved !== undefined) {
358
+ return resolved;
359
+ }
360
+ }
361
+
362
+ return undefined;
363
+ }
364
+
225
365
  protected generateContainerData(
226
366
  serviceProviders: readonly string[],
227
367
  config: ConfigResult,
@@ -246,7 +386,7 @@ export abstract class GenerateDataFromAst {
246
386
  if (Object.keys(result.publishers).length > 0) {
247
387
  // The publisher callbacks reference the provider's static methods,
248
388
  // so the generated data cache must import the provider class.
249
- importMap[path.basename(filePath, '.ts')] = this.relativeSpecifier(config.dataPath, filePath);
389
+ importMap[path.basename(filePath, '.ts')] = this.importSpecifier(config.dataPath, filePath);
250
390
  }
251
391
 
252
392
  Object.assign(publishers, result.publishers);
@@ -331,9 +471,15 @@ export abstract class GenerateDataFromAst {
331
471
 
332
472
  if (providerResult.routes.length > 0) {
333
473
  // Imperative routes reference the provider's static handlers,
334
- // so the generated data cache must import the provider class.
474
+ // so the generated data cache must import the provider class —
475
+ // along with every parameter/constant class the route arguments
476
+ // themselves name, since those are emitted verbatim.
335
477
  imperativeRoutes.push(...providerResult.routes);
336
- importMap[path.basename(filePath, '.ts')] = this.relativeSpecifier(config.dataPath, filePath);
478
+ importMap[path.basename(filePath, '.ts')] = this.importSpecifier(config.dataPath, filePath);
479
+
480
+ for (const [name, importPath] of Object.entries(providerResult.routeImports)) {
481
+ importMap[name] = this.importSpecifier(config.dataPath, importPath);
482
+ }
337
483
  }
338
484
 
339
485
  for (const controllerClass of providerResult.controllerClasses) {
@@ -389,9 +535,15 @@ export abstract class GenerateDataFromAst {
389
535
 
390
536
  if (providerResult.routes.length > 0) {
391
537
  // Imperative routes reference the provider's static handlers,
392
- // so the generated data cache must import the provider class.
538
+ // so the generated data cache must import the provider class —
539
+ // along with every parameter/constant class the route arguments
540
+ // themselves name, since those are emitted verbatim.
393
541
  imperativeRoutes.push(...providerResult.routes);
394
- importMap[path.basename(filePath, '.ts')] = this.relativeSpecifier(config.dataPath, filePath);
542
+ importMap[path.basename(filePath, '.ts')] = this.importSpecifier(config.dataPath, filePath);
543
+
544
+ for (const [name, importPath] of Object.entries(providerResult.routeImports)) {
545
+ importMap[name] = this.importSpecifier(config.dataPath, importPath);
546
+ }
395
547
  }
396
548
 
397
549
  for (const controllerClass of providerResult.controllerClasses) {
@@ -35,15 +35,29 @@ export abstract class AstFileGenerator {
35
35
  * Build the `import { X } from '...';` block for the provider/handler classes
36
36
  * a generated data cache references, followed by a trailing newline (or an
37
37
  * empty string when there are no such imports).
38
+ *
39
+ * Names the generated file's fixed framework header already binds are
40
+ * dropped: those are the same framework classes under the same names, and
41
+ * emitting them twice is a duplicate-identifier error.
38
42
  */
39
- protected buildUserImportsBlock(classImportMap: Record<string, string>): string {
43
+ protected buildUserImportsBlock(classImportMap: Record<string, string>, reservedNames: readonly string[]): string {
40
44
  const userImports = Object.entries(classImportMap)
45
+ .filter(([name]) => !reservedNames.includes(name))
41
46
  .map(([name, specifier]) => `import { ${name} } from '${specifier}';`)
42
47
  .join('\n');
43
48
 
44
49
  return userImports ? `${userImports}\n` : '';
45
50
  }
46
51
 
52
+ /**
53
+ * Render the fixed framework imports a generated data file always carries.
54
+ */
55
+ protected buildFrameworkImportLines(imports: Readonly<Record<string, string>>, isType: boolean = false): string[] {
56
+ const keyword = isType ? 'import type' : 'import';
57
+
58
+ return Object.entries(imports).map(([name, specifier]) => `${keyword} { ${name} } from '${specifier}';`);
59
+ }
60
+
47
61
  protected writeFile(directory: string, className: string, data: string): GenerateStatus {
48
62
  const filePath = directory.replace(/\/$/, '') + `/${className}.ts`;
49
63