@valkyrjaio/sindri 26.6.65 → 26.6.66

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.6.65",
3
+ "version": "26.6.66",
4
4
  "description": "The Valkyrja Sindri build tool.",
5
5
  "homepage": "https://www.valkyrja.io/sindri",
6
6
  "keywords": [
@@ -285,21 +285,87 @@ export abstract class AstReader {
285
285
  return names;
286
286
  }
287
287
 
288
- if (ts.isPropertyAccessExpression(node)) {
289
- return this.collectReferencedIdentifiers(node.expression, names);
288
+ if (ts.isBindingElement(node)) {
289
+ // The `config` of `{ config: c }` names a property of the value, where the `CliA` of
290
+ // `{ [CliA.KEY]: c }` is an expression the generated file evaluates.
291
+ if (node.propertyName !== undefined && ts.isComputedPropertyName(node.propertyName)) {
292
+ this.collectReferencedIdentifiers(node.propertyName.expression, names);
293
+ }
294
+
295
+ this.collectReferencedIdentifiers(node.name, names);
296
+
297
+ return node.initializer === undefined ? names : this.collectReferencedIdentifiers(node.initializer, names);
290
298
  }
291
299
 
292
- if (ts.isPropertyAssignment(node)) {
293
- return this.collectReferencedIdentifiers(node.initializer, names);
300
+ // An identifier a `name` slot carries declares or keys the name, such as the `HELP` of
301
+ // `CommandName.HELP`, and only a shorthand property references the name it carries.
302
+ const slotName: ts.Node | undefined =
303
+ 'name' in node &&
304
+ node.name !== undefined &&
305
+ ts.isIdentifier(node.name as ts.Node) &&
306
+ !ts.isShorthandPropertyAssignment(node)
307
+ ? (node.name as ts.Node)
308
+ : undefined;
309
+
310
+ ts.forEachChild(node, (child: ts.Node): void => {
311
+ if (child !== slotName) {
312
+ this.collectReferencedIdentifiers(child, names);
313
+ }
314
+ });
315
+
316
+ return names;
317
+ }
318
+
319
+ /**
320
+ * The names the provider file declares anywhere in itself, less the file's own base name, which
321
+ * is the name the generated data file imports the provider by.
322
+ */
323
+ protected collectFileNames(sourceFile: SourceFile, filePath: string): Set<string> {
324
+ const names = this.collectDeclaredNames(sourceFile.compilerNode);
325
+
326
+ names.delete(path.basename(filePath, '.ts'));
327
+
328
+ return names;
329
+ }
330
+
331
+ /**
332
+ * The names a node declares, such as the parameters of an inline handler.
333
+ *
334
+ * A name any part of an expression declares counts as declared throughout it, which reads a
335
+ * shadowed name as declared rather than stopping on it.
336
+ */
337
+ protected collectDeclaredNames(node: ts.Node, names: Set<string> = new Set<string>()): Set<string> {
338
+ if (AstReader.declaresName(node) && node.name !== undefined && ts.isIdentifier(node.name)) {
339
+ names.add(node.name.text);
294
340
  }
295
341
 
296
342
  ts.forEachChild(node, (child: ts.Node): void => {
297
- this.collectReferencedIdentifiers(child, names);
343
+ this.collectDeclaredNames(child, names);
298
344
  });
299
345
 
300
346
  return names;
301
347
  }
302
348
 
349
+ /**
350
+ * Whether a node's `name` declares a binding rather than naming one.
351
+ *
352
+ * A property access, a shorthand property and an object-literal key all carry a `name` that
353
+ * references or keys instead of declaring, so the kind decides rather than the slot.
354
+ */
355
+ protected static declaresName(node: ts.Node): node is ts.NamedDeclaration {
356
+ return (
357
+ ts.isParameter(node) ||
358
+ ts.isVariableDeclaration(node) ||
359
+ ts.isBindingElement(node) ||
360
+ ts.isFunctionDeclaration(node) ||
361
+ ts.isFunctionExpression(node) ||
362
+ ts.isClassDeclaration(node) ||
363
+ ts.isClassExpression(node) ||
364
+ ts.isEnumDeclaration(node) ||
365
+ ts.isModuleDeclaration(node)
366
+ );
367
+ }
368
+
303
369
  /**
304
370
  * Extract service ID strings from keys of an object-returning method.
305
371
  *
@@ -9,5 +9,12 @@
9
9
  import type { ts } from 'ts-morph';
10
10
 
11
11
  export class ListenerAttributeResult {
12
- constructor(readonly listeners: Record<string, ts.Expression> = {}) {}
12
+ /**
13
+ * @param listeners A Map, because an object reorders a key that reads as an integer
14
+ * @param importMap Class short name → absolute file path for the handler imports
15
+ */
16
+ constructor(
17
+ readonly listeners: ReadonlyMap<string, ts.Expression> = new Map(),
18
+ readonly importMap: Record<string, string> = {},
19
+ ) {}
13
20
  }
@@ -9,15 +9,21 @@
9
9
  import type { ts } from 'ts-morph';
10
10
 
11
11
  export class ListenerProviderResult {
12
+ /**
13
+ * @param listenerImports Local name → absolute file path for every class the `listeners`
14
+ * expressions reference
15
+ */
12
16
  constructor(
13
17
  readonly listenerClasses: readonly string[] = [],
14
18
  readonly listeners: readonly ts.Expression[] = [],
19
+ readonly listenerImports: Readonly<Record<string, string>> = {},
15
20
  ) {}
16
21
 
17
22
  merge(other: ListenerProviderResult): ListenerProviderResult {
18
23
  return new ListenerProviderResult(
19
24
  [...new Set([...this.listenerClasses, ...other.listenerClasses])],
20
25
  [...this.listeners, ...other.listeners],
26
+ { ...this.listenerImports, ...other.listenerImports },
21
27
  );
22
28
  }
23
29
  }
@@ -35,13 +35,15 @@ export class ListenerAttributeReader extends AstReader implements ListenerAttrib
35
35
  }
36
36
 
37
37
  const { classDecl, useMap, currentClass } = context;
38
- const listeners: Record<string, ts.Expression> = {};
38
+ const listeners = new Map<string, ts.Expression>();
39
+ const listenerData: ListenerData[] = [];
39
40
 
40
41
  for (const decorator of this.findDecoratorsOnNode(classDecl, 'Listener', useMap, filePath)) {
41
42
  const data = this.buildListenerData(decorator, useMap, filePath, currentClass, classDecl, undefined);
42
43
 
43
44
  if (data !== null) {
44
- listeners[data.name] = this.buildListenerExpr(data);
45
+ listeners.set(data.name, this.buildListenerExpr(data));
46
+ listenerData.push(data);
45
47
  }
46
48
  }
47
49
 
@@ -50,12 +52,48 @@ export class ListenerAttributeReader extends AstReader implements ListenerAttrib
50
52
  const data = this.buildListenerData(decorator, useMap, filePath, currentClass, classDecl, method);
51
53
 
52
54
  if (data !== null) {
53
- listeners[data.name] = this.buildListenerExpr(data);
55
+ listeners.set(data.name, this.buildListenerExpr(data));
56
+ listenerData.push(data);
54
57
  }
55
58
  }
56
59
  }
57
60
 
58
- return new ListenerAttributeResult(listeners);
61
+ return new ListenerAttributeResult(
62
+ listeners,
63
+ this.buildImportMap(listenerData, useMap, filePath, currentClass),
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Build the class-name → absolute-file-path map for every handler class the generated data
69
+ * cache references.
70
+ */
71
+ protected buildImportMap(
72
+ listenerData: readonly ListenerData[],
73
+ useMap: Record<string, string>,
74
+ currentFilePath: string,
75
+ currentClass: string,
76
+ ): Record<string, string> {
77
+ const importMap: Record<string, string> = {};
78
+
79
+ for (const data of listenerData) {
80
+ if (data.handler === null) {
81
+ continue;
82
+ }
83
+
84
+ const shortName = data.handler.class.slice(data.handler.class.lastIndexOf('\\') + 1);
85
+
86
+ const filePath =
87
+ shortName === currentClass
88
+ ? currentFilePath
89
+ : this.resolveClassToFilePath(shortName, useMap, currentFilePath);
90
+
91
+ if (filePath !== '') {
92
+ importMap[shortName] = filePath;
93
+ }
94
+ }
95
+
96
+ return importMap;
59
97
  }
60
98
 
61
99
  protected buildListenerData(
@@ -6,10 +6,11 @@
6
6
  * Released under the MIT License. See LICENSE.md for details.
7
7
  */
8
8
 
9
- import type { ts } from 'ts-morph';
9
+ import { ts } from 'ts-morph';
10
10
 
11
11
  import { AstReader } from './Abstract/AstReader.ts';
12
12
  import { ListenerProviderResult } from './Data/Result/ListenerProviderResult.ts';
13
+ import { AstUnreadableListenerException } from './Throwable/Exception/AstUnreadableListenerException.ts';
13
14
 
14
15
  import type { ListenerProviderReaderContract } from './Contract/ListenerProviderReaderContract.ts';
15
16
 
@@ -27,18 +28,80 @@ export class ListenerProviderReader extends AstReader implements ListenerProvide
27
28
 
28
29
  const useMap = this.buildUseMap(sourceFile);
29
30
  const methods = this.indexMethods(classDecl);
31
+ const listeners = this.extractListeners(methods[ListenerProviderReader.METHOD_LISTENERS]);
30
32
 
31
33
  return new ListenerProviderResult(
32
34
  this.extractClassListFromValues(methods[ListenerProviderReader.METHOD_LISTENER_CLASSES], useMap, filePath),
33
- this.extractListeners(methods[ListenerProviderReader.METHOD_LISTENERS], useMap, filePath),
35
+ listeners,
36
+ this.extractListenerImports(listeners, useMap, filePath, this.collectFileNames(sourceFile, filePath)),
34
37
  );
35
38
  }
36
39
 
40
+ /**
41
+ * Extract the listeners a provider's `getListeners()` method returns, which the generator
42
+ * emits verbatim.
43
+ */
37
44
  protected extractListeners(
38
- _method: ReturnType<typeof this.indexMethods>[string] | undefined,
39
- _useMap: Record<string, string>,
40
- _filePath: string,
41
- ): ts.Expression[] {
42
- return [];
45
+ method: ReturnType<typeof this.indexMethods>[string] | undefined,
46
+ ): readonly ts.Expression[] {
47
+ if (method === undefined) {
48
+ return [];
49
+ }
50
+
51
+ const array = this.findReturnedArray(method);
52
+
53
+ if (array === undefined) {
54
+ return [];
55
+ }
56
+
57
+ return array.elements.filter((element) => this.isConstructedListener(element));
58
+ }
59
+
60
+ /** Whether an expression declares a listener through a construction, with or without a chain. */
61
+ protected isConstructedListener(expression: ts.Expression): boolean {
62
+ let current = this.unwrapTypeAssertions(expression);
63
+
64
+ while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
65
+ current = this.unwrapTypeAssertions(current.expression.expression);
66
+ }
67
+
68
+ return ts.isNewExpression(current);
69
+ }
70
+
71
+ /**
72
+ * Resolve every class the listener expressions reference to its absolute file path, so the
73
+ * generated data cache can import them.
74
+ */
75
+ protected extractListenerImports(
76
+ listeners: readonly ts.Expression[],
77
+ useMap: Record<string, string>,
78
+ filePath: string,
79
+ fileNames: ReadonlySet<string>,
80
+ ): Record<string, string> {
81
+ const imports: Record<string, string> = {};
82
+
83
+ for (const listener of listeners) {
84
+ const declaredNames = this.collectDeclaredNames(listener);
85
+
86
+ for (const name of this.collectReferencedIdentifiers(listener)) {
87
+ const resolved = this.resolveImportToFilePath(name, useMap, filePath);
88
+
89
+ if (resolved !== '') {
90
+ imports[name] = resolved;
91
+
92
+ continue;
93
+ }
94
+
95
+ // A name the provider file declares for itself is one the generated data file, in
96
+ // another module, cannot reach. Every other unresolved name is a global.
97
+ if (fileNames.has(name) && !declaredNames.has(name)) {
98
+ throw new AstUnreadableListenerException(
99
+ `Cannot read the listener '${listener.getText()}': the generated data cannot import '${name}', which the provider declares for itself.`,
100
+ );
101
+ }
102
+ }
103
+ }
104
+
105
+ return imports;
43
106
  }
44
107
  }
@@ -10,6 +10,7 @@ import { ts } from 'ts-morph';
10
10
 
11
11
  import { AstReader } from './Abstract/AstReader.ts';
12
12
  import { RouteProviderResult } from './Data/Result/RouteProviderResult.ts';
13
+ import { AstUnreadableRouteException } from './Throwable/Exception/AstUnreadableRouteException.ts';
13
14
 
14
15
  import type { RouteProviderReaderContract } from './Contract/RouteProviderReaderContract.ts';
15
16
 
@@ -28,21 +29,22 @@ export class RouteProviderReader extends AstReader implements RouteProviderReade
28
29
  const useMap = this.buildUseMap(sourceFile);
29
30
  const methods = this.indexMethods(classDecl);
30
31
  const routes = this.extractRoutes(methods[RouteProviderReader.METHOD_ROUTES], useMap, filePath);
32
+ const routeImports = this.extractRouteImports(routes, useMap, filePath);
33
+
34
+ // A further list of route expressions needs a call of its own, because a name is validated
35
+ // against the expression that references it.
36
+ this.validateRouteImports(routes, routeImports, useMap, this.collectFileNames(sourceFile, filePath));
31
37
 
32
38
  return new RouteProviderResult(
33
39
  this.extractClassListFromValues(methods[RouteProviderReader.METHOD_CONTROLLER_CLASSES], useMap, filePath),
34
40
  routes,
35
- this.extractRouteImports(routes, useMap, filePath),
41
+ routeImports,
36
42
  );
37
43
  }
38
44
 
39
45
  /**
40
- * Resolve every class the imperative route expressions reference to its
41
- * absolute file path, so the generated data cache can import them.
42
- *
43
- * Names that are not imported by the provider file — locals, globals, and
44
- * the provider's own class (which the generator imports separately) — have
45
- * no entry in the use map and are skipped.
46
+ * Resolve every class the imperative route expressions reference to its absolute file path, so
47
+ * the generated data cache can import them.
46
48
  */
47
49
  protected extractRouteImports(
48
50
  routes: readonly ts.Expression[],
@@ -64,6 +66,38 @@ export class RouteProviderReader extends AstReader implements RouteProviderReade
64
66
  return imports;
65
67
  }
66
68
 
69
+ /** Stop on a name a route references that the generated data file, in another module, cannot reach. */
70
+ protected validateRouteImports(
71
+ routes: readonly ts.Expression[],
72
+ routeImports: Readonly<Record<string, string>>,
73
+ useMap: Record<string, string>,
74
+ fileNames: ReadonlySet<string>,
75
+ ): void {
76
+ for (const route of routes) {
77
+ const declaredNames = this.collectDeclaredNames(route);
78
+
79
+ for (const name of this.collectReferencedIdentifiers(route)) {
80
+ if (routeImports[name] !== undefined || declaredNames.has(name)) {
81
+ continue;
82
+ }
83
+
84
+ if (fileNames.has(name)) {
85
+ throw new AstUnreadableRouteException(
86
+ `Cannot read the route '${route.getText()}': the generated data cannot import '${name}', which the provider declares for itself.`,
87
+ );
88
+ }
89
+
90
+ // An import the reader resolves to no TypeScript file leaves the generated data no
91
+ // path to import the name from. Every name left is a global.
92
+ if (useMap[name] !== undefined) {
93
+ throw new AstUnreadableRouteException(
94
+ `Cannot read the route '${route.getText()}': the import of '${name}' resolves to no TypeScript file.`,
95
+ );
96
+ }
97
+ }
98
+ }
99
+ }
100
+
67
101
  /**
68
102
  * Extract the route objects returned by a provider's `getRoutes()` method.
69
103
  *
@@ -0,0 +1,11 @@
1
+ /*
2
+ * This file is part of the Sindri package.
3
+ *
4
+ * Copyright (c) 2016-present Melech Mizrachi
5
+ *
6
+ * Released under the MIT License. See LICENSE.md for details.
7
+ */
8
+
9
+ import { AstInvalidArgumentException } from './Abstract/AstInvalidArgumentException.ts';
10
+
11
+ export class AstUnreadableListenerException extends AstInvalidArgumentException {}
@@ -0,0 +1,11 @@
1
+ /*
2
+ * This file is part of the Sindri package.
3
+ *
4
+ * Copyright (c) 2016-present Melech Mizrachi
5
+ *
6
+ * Released under the MIT License. See LICENSE.md for details.
7
+ */
8
+
9
+ import { AstInvalidArgumentException } from './Abstract/AstInvalidArgumentException.ts';
10
+
11
+ export class AstUnreadableRouteException extends AstInvalidArgumentException {}
@@ -7,8 +7,8 @@
7
7
  */
8
8
 
9
9
  export class SindriInfo {
10
- static readonly VERSION = '26.6.65' as const;
11
- static readonly VERSION_BUILD_DATE_TIME = 'October 11 2026 10:55:09 MST' as const;
10
+ static readonly VERSION = '26.6.66' as const;
11
+ static readonly VERSION_BUILD_DATE_TIME = 'October 12 2026 14:34:58 MST' as const;
12
12
  static readonly ICON = `\
13
13
  ██████████
14
14
  ████ ████
@@ -264,7 +264,10 @@ export abstract class GenerateDataFromAst extends GenerateFromAst {
264
264
  .withAddedMessages(new Message('Generating Event Data..........................'))
265
265
  .writeMessages();
266
266
 
267
- const allListeners: Record<string, ts.Expression> = {};
267
+ // A Map, because an object reorders a key that reads as an integer.
268
+ const allListeners = new Map<string, ts.Expression>();
269
+ const imperativeListeners: ts.Expression[] = [];
270
+ const importMap: Record<string, string> = {};
268
271
 
269
272
  for (const providerClass of listenerProviders) {
270
273
  const filePath = this.fqnToFilePath(providerClass, config.namespace, config.dir);
@@ -275,6 +278,13 @@ export abstract class GenerateDataFromAst extends GenerateFromAst {
275
278
 
276
279
  const providerResult = this.listenerProviderReader.readFile(filePath);
277
280
 
281
+ if (providerResult.listeners.length > 0) {
282
+ // The expressions are emitted verbatim, so every class they name is imported.
283
+ imperativeListeners.push(...providerResult.listeners);
284
+ importMap[path.basename(filePath, '.ts')] = this.importSpecifier(config.dataPath, filePath);
285
+ this.mergeReaderImports(importMap, providerResult.listenerImports, config);
286
+ }
287
+
278
288
  for (const listenerClass of providerResult.listenerClasses) {
279
289
  const listenerPath = this.fqnToFilePath(listenerClass, config.namespace, config.dir);
280
290
 
@@ -284,15 +294,22 @@ export abstract class GenerateDataFromAst extends GenerateFromAst {
284
294
 
285
295
  const attrResult = this.listenerAttributeReader.readFile(listenerPath);
286
296
 
287
- Object.assign(allListeners, attrResult.listeners);
297
+ for (const [name, listener] of attrResult.listeners) {
298
+ allListeners.set(name, listener);
299
+ }
300
+
301
+ this.mergeReaderImports(importMap, attrResult.importMap, config);
288
302
  }
289
303
  }
290
304
 
291
- const status = this.eventGenerator.generateFile(
305
+ this.eventGenerator.classImportMap = importMap;
306
+
307
+ const status = this.eventGenerator.generateMergedFile(
292
308
  config.dataPath,
293
309
  'AppEventData',
294
310
  config.dataNamespace,
295
311
  allListeners,
312
+ imperativeListeners,
296
313
  );
297
314
 
298
315
  return this.addMessagesForGenerateStatus(output, status).withAddedMessages(new NewLine()).writeMessages();
@@ -428,16 +445,12 @@ export abstract class GenerateDataFromAst extends GenerateFromAst {
428
445
  }
429
446
 
430
447
  /**
431
- * Merge the class-name → absolute-file-path imports a route attribute reader
432
- * resolved (handler/help-text/middleware classes) into the generated data
433
- * file's import map, converting each absolute path to the specifier the
434
- * generated file imports it by — relative to the output directory for
435
- * application source, or the owning package's own specifier for a class an
436
- * installed package exposes.
448
+ * Merge the class-name → absolute-file-path imports a reader resolved into the generated data
449
+ * file's import map, by the specifier that file imports each one by.
437
450
  */
438
451
  protected mergeReaderImports(
439
452
  importMap: Record<string, string>,
440
- readerImportMap: Record<string, string>,
453
+ readerImportMap: Readonly<Record<string, string>>,
441
454
  config: ConfigResult,
442
455
  ): void {
443
456
  for (const [name, absolutePath] of Object.entries(readerImportMap)) {