@mnemonica/tactica 0.1.3 → 0.1.5

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/lib/analyzer.js CHANGED
@@ -50,6 +50,15 @@ class MnemonicaAnalyzer {
50
50
  this.typeAliases = new Map();
51
51
  // Track variable assignments: variableName -> fullPath of the type it holds
52
52
  this.variableToTypeMap = new Map();
53
+ // Track mnemonica module-object variables (e.g., import { mnemonica } from 'mnemonica'; const m = mnemonica)
54
+ this.moduleObjectVariables = new Set();
55
+ // Track imported aliases of createTypesCollection (e.g., import { createTypesCollection as ctc })
56
+ this.createTypesCollectionVariables = new Set();
57
+ // Track custom collection variables: variableName -> collectionId
58
+ this.collectionVariables = new Map();
59
+ // Track custom collection metadata for Option B registry emission
60
+ this.collectionInfo = new Map();
61
+ this.collectionCounter = 0;
53
62
  // Store program for future use (currently unused but kept for extensibility)
54
63
  void program;
55
64
  }
@@ -62,6 +71,8 @@ class MnemonicaAnalyzer {
62
71
  this.edsUsages.clear();
63
72
  this.flowUsages.clear();
64
73
  this.variableToTypeMap.clear();
74
+ // Note: moduleObjectVariables and collectionVariables intentionally persist
75
+ // across definition and usage passes.
65
76
  }
66
77
  /**
67
78
  * Analyze a source file for Mnemonica type definitions
@@ -158,10 +169,19 @@ class MnemonicaAnalyzer {
158
169
  * Visit a node in the AST
159
170
  */
160
171
  visitNode(node, sourceFile, currentClass) {
172
+ // Track mnemonica module-object aliases and custom collection variables
173
+ // before processing define()/lookup() calls so source resolution works.
174
+ this.trackImports(node);
175
+ this.trackModuleObjectAliases(node);
176
+ this.trackCollectionAliases(node, sourceFile);
161
177
  // Check for define() calls
162
178
  if (this.isDefineCall(node)) {
163
179
  this.processDefineCall(node, sourceFile);
164
180
  }
181
+ // Check for lazy() calls
182
+ if (this.isLazyCall(node)) {
183
+ this.processLazyCall(node, sourceFile);
184
+ }
165
185
  // Check for decorate() decorator
166
186
  if (this.isDecorateDecorator(node)) {
167
187
  this.processDecorateDecorator(node, sourceFile, currentClass);
@@ -186,6 +206,167 @@ class MnemonicaAnalyzer {
186
206
  ts.forEachChild(node, child => this.visitNode(child, sourceFile, currentClass));
187
207
  }
188
208
  }
209
+ /**
210
+ * Track imports from 'mnemonica' so aliases of the module object and
211
+ * createTypesCollection are recognized without relying on the type checker.
212
+ */
213
+ trackImports(node) {
214
+ if (!ts.isImportDeclaration(node)) {
215
+ return;
216
+ }
217
+ const { moduleSpecifier } = node;
218
+ if (!ts.isStringLiteral(moduleSpecifier) || moduleSpecifier.text !== 'mnemonica') {
219
+ return;
220
+ }
221
+ const clause = node.importClause;
222
+ if (!clause) {
223
+ return;
224
+ }
225
+ // import { mnemonica, createTypesCollection } from 'mnemonica'
226
+ if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
227
+ for (const element of clause.namedBindings.elements) {
228
+ const localName = element.name.text;
229
+ const importedName = element.propertyName
230
+ ? element.propertyName.text
231
+ : localName;
232
+ if (importedName === 'mnemonica') {
233
+ this.moduleObjectVariables.add(localName);
234
+ }
235
+ if (importedName === 'createTypesCollection') {
236
+ this.createTypesCollectionVariables.add(localName);
237
+ }
238
+ }
239
+ }
240
+ // import * as mnemonica from 'mnemonica'
241
+ if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
242
+ this.moduleObjectVariables.add(clause.namedBindings.name.text);
243
+ }
244
+ // import mnemonica from 'mnemonica' (default import) — treat as module object too
245
+ if (clause.name) {
246
+ this.moduleObjectVariables.add(clause.name.text);
247
+ }
248
+ }
249
+ /**
250
+ * Track aliases of the mnemonica module object, e.g.:
251
+ * const m = mnemonica;
252
+ * const App = m;
253
+ */
254
+ trackModuleObjectAliases(node) {
255
+ if (!ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name)) {
256
+ return;
257
+ }
258
+ const { initializer } = node;
259
+ if (!initializer) {
260
+ return;
261
+ }
262
+ if (ts.isIdentifier(initializer) && this.moduleObjectVariables.has(initializer.text)) {
263
+ this.moduleObjectVariables.add(node.name.text);
264
+ }
265
+ }
266
+ /**
267
+ * Track custom collection variables, e.g.:
268
+ * const MyCollection = createTypesCollection();
269
+ * const Other = MyCollection;
270
+ *
271
+ * Also detects Option B user-provided registry interfaces:
272
+ * export interface MyCollectionRegistry {}
273
+ * const MyCollection = createTypesCollection<MyCollectionRegistry>();
274
+ */
275
+ trackCollectionAliases(node, sourceFile) {
276
+ if (!ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name)) {
277
+ return;
278
+ }
279
+ const { initializer } = node;
280
+ if (!initializer) {
281
+ return;
282
+ }
283
+ // Direct createTypesCollection() call
284
+ if (this.isCreateTypesCollectionCall(initializer)) {
285
+ const collectionId = this.nextCollectionId();
286
+ this.collectionVariables.set(node.name.text, collectionId);
287
+ const registryInterfaceName = this.extractRegistryInterfaceName(initializer, sourceFile);
288
+ this.collectionInfo.set(collectionId, {
289
+ variableName: node.name.text,
290
+ sourceFile: sourceFile.fileName,
291
+ registryInterfaceName: registryInterfaceName
292
+ });
293
+ return;
294
+ }
295
+ // Alias of another collection variable
296
+ if (ts.isIdentifier(initializer)) {
297
+ const existing = this.collectionVariables.get(initializer.text);
298
+ if (existing) {
299
+ this.collectionVariables.set(node.name.text, existing);
300
+ }
301
+ }
302
+ }
303
+ /**
304
+ * Extract the registry interface name from createTypesCollection<Registry>()
305
+ * when the interface is declared in the same source file.
306
+ */
307
+ extractRegistryInterfaceName(call, sourceFile) {
308
+ const typeArgs = call.typeArguments;
309
+ if (!typeArgs || typeArgs.length === 0) {
310
+ return undefined;
311
+ }
312
+ const firstTypeArg = typeArgs[0];
313
+ if (!ts.isTypeReferenceNode(firstTypeArg) || !ts.isIdentifier(firstTypeArg.typeName)) {
314
+ return undefined;
315
+ }
316
+ const name = firstTypeArg.typeName.text;
317
+ // Confirm the interface exists in the same source file.
318
+ for (const statement of sourceFile.statements) {
319
+ if (ts.isInterfaceDeclaration(statement) &&
320
+ statement.name.text === name) {
321
+ return name;
322
+ }
323
+ }
324
+ return undefined;
325
+ }
326
+ /**
327
+ * Get the registry interface name for a collection id.
328
+ */
329
+ getRegistryInterfaceName(collectionId) {
330
+ if (!collectionId) {
331
+ return undefined;
332
+ }
333
+ return this.collectionInfo.get(collectionId)?.registryInterfaceName;
334
+ }
335
+ /**
336
+ * Check if an expression is a createTypesCollection() call.
337
+ * Handles:
338
+ * createTypesCollection()
339
+ * ctc() // aliased import
340
+ * mnemonica.createTypesCollection() // module object method
341
+ * m.createTypesCollection() // aliased module object
342
+ */
343
+ isCreateTypesCollectionCall(node) {
344
+ if (!ts.isCallExpression(node)) {
345
+ return false;
346
+ }
347
+ const expr = node.expression;
348
+ // Direct call or aliased import: createTypesCollection() / ctc()
349
+ if (ts.isIdentifier(expr)) {
350
+ return expr.text === 'createTypesCollection' ||
351
+ this.createTypesCollectionVariables.has(expr.text);
352
+ }
353
+ // Module object method: mnemonica.createTypesCollection()
354
+ if (ts.isPropertyAccessExpression(expr) &&
355
+ expr.name.text === 'createTypesCollection' &&
356
+ ts.isIdentifier(expr.expression) &&
357
+ this.moduleObjectVariables.has(expr.expression.text)) {
358
+ return true;
359
+ }
360
+ return false;
361
+ }
362
+ /**
363
+ * Generate a unique collection identifier.
364
+ */
365
+ nextCollectionId() {
366
+ this.collectionCounter++;
367
+ const result = `collection_${this.collectionCounter}`;
368
+ return result;
369
+ }
189
370
  /**
190
371
  * Check if a node is a define() call
191
372
  */
@@ -193,7 +374,7 @@ class MnemonicaAnalyzer {
193
374
  if (!ts.isCallExpression(node)) {
194
375
  return false;
195
376
  }
196
- const expression = node.expression;
377
+ const { expression } = node;
197
378
  // Check for direct call: define('TypeName', ...)
198
379
  if (ts.isIdentifier(expression) && expression.text === 'define') {
199
380
  return true;
@@ -204,6 +385,24 @@ class MnemonicaAnalyzer {
204
385
  }
205
386
  return false;
206
387
  }
388
+ /**
389
+ * Check if a node is a lazy() call
390
+ */
391
+ isLazyCall(node) {
392
+ if (!ts.isCallExpression(node)) {
393
+ return false;
394
+ }
395
+ const { expression } = node;
396
+ // Check for direct call: lazy('TypeName', getter, ...)
397
+ if (ts.isIdentifier(expression) && expression.text === 'lazy') {
398
+ return true;
399
+ }
400
+ // Check for method call: SomeType.lazy('SubType', getter, ...)
401
+ if (ts.isPropertyAccessExpression(expression)) {
402
+ return expression.name?.text === 'lazy';
403
+ }
404
+ return false;
405
+ }
207
406
  /**
208
407
  * Extract config options from an object literal
209
408
  */
@@ -247,7 +446,7 @@ class MnemonicaAnalyzer {
247
446
  if (!ts.isDecorator(node)) {
248
447
  return false;
249
448
  }
250
- const expression = node.expression;
449
+ const { expression } = node;
251
450
  // Check for @decorate
252
451
  if (ts.isIdentifier(expression) && expression.text === 'decorate') {
253
452
  return true;
@@ -258,20 +457,37 @@ class MnemonicaAnalyzer {
258
457
  if (ts.isIdentifier(fnName) && fnName.text === 'decorate') {
259
458
  return true;
260
459
  }
460
+ // Check for @MyCollection.decorate() where MyCollection is a custom collection
461
+ if (ts.isPropertyAccessExpression(fnName) &&
462
+ fnName.name.text === 'decorate' &&
463
+ ts.isIdentifier(fnName.expression) &&
464
+ this.collectionVariables.has(fnName.expression.text)) {
465
+ return true;
466
+ }
261
467
  }
262
468
  return false;
263
469
  }
470
+ /**
471
+ * Mark a call expression as processed and return whether it already was.
472
+ */
473
+ markProcessed(call) {
474
+ const marked = call;
475
+ if (marked.__tactica_processed) {
476
+ return true;
477
+ }
478
+ marked.__tactica_processed = true;
479
+ return false;
480
+ }
264
481
  /**
265
482
  * Process a define() call
266
483
  */
267
484
  processDefineCall(call, sourceFile) {
268
485
  // Check if this exact call has already been processed (prevents duplicates from chained calls)
269
- if (call.__tactica_processed) {
486
+ if (this.markProcessed(call)) {
270
487
  return;
271
488
  }
272
- call.__tactica_processed = true;
273
- // Get the type name from arguments
274
- const typeName = this.extractTypeName(call);
489
+ // Get the type name and source context from arguments
490
+ const defineContext = this.extractDefineContext(call);
275
491
  // For chained calls like define('A').define('B'), we want the position of the .define('B') part
276
492
  // not the start of the entire expression
277
493
  let positionNode = call;
@@ -284,7 +500,7 @@ class MnemonicaAnalyzer {
284
500
  }
285
501
  const startPos = positionNode.getStart(sourceFile);
286
502
  const { line, character } = ts.getLineAndCharacterOfPosition(sourceFile, startPos);
287
- if (!typeName) {
503
+ if (!defineContext.typeName) {
288
504
  this.errors.push({
289
505
  message: 'Could not extract type name from define() call',
290
506
  file: sourceFile.fileName,
@@ -293,13 +509,27 @@ class MnemonicaAnalyzer {
293
509
  });
294
510
  return;
295
511
  }
296
- // Determine parent type
297
- const parentNode = this.findParentType(call, sourceFile);
298
- // Build full path
299
- const fullPath = parentNode ? `${parentNode.fullPath}.${typeName}` : typeName;
512
+ const { typeName } = defineContext;
513
+ // Determine parent type and collection based on the call source.
514
+ const parentNode = defineContext.parentType;
515
+ const { collectionId } = defineContext;
300
516
  // Extract config options
301
517
  const config = this.extractConfig(call);
302
- // Create definition info
518
+ // Create type node first so its internal fullPath (including any collection prefix) is resolved.
519
+ const node = graph_1.TypeGraphImpl.createNode(typeName, parentNode, sourceFile.fileName, line + 1, character + 1, collectionId);
520
+ node.registryInterfaceName = this.getRegistryInterfaceName(collectionId);
521
+ // Extract properties from constructor function
522
+ node.properties = this.extractProperties(call);
523
+ // Extract constructor parameters for TypeRegistry signature
524
+ node.constructorParams = this.extractConstructorParams(call);
525
+ // Add to graph
526
+ if (parentNode) {
527
+ this.graph.addChild(parentNode, node);
528
+ }
529
+ else {
530
+ this.graph.addRoot(node);
531
+ }
532
+ // Create definition info using the node's resolved fullPath
303
533
  const definition = {
304
534
  name: typeName,
305
535
  location: `${sourceFile.fileName}:${line + 1}:${character + 1}`,
@@ -308,10 +538,52 @@ class MnemonicaAnalyzer {
308
538
  strictChain: config.strictChain ?? true,
309
539
  blockErrors: config.blockErrors ?? false,
310
540
  };
311
- this.definitions.set(fullPath, definition);
312
- // Create type node
313
- const node = graph_1.TypeGraphImpl.createNode(typeName, parentNode, sourceFile.fileName, line + 1, character + 1);
314
- // Extract properties from constructor function
541
+ this.definitions.set(node.fullPath, definition);
542
+ // Track variable assignment: const User = define('UserEntity', ...) -> map "User" to "UserEntity"
543
+ // For chained calls like const X = define('A').define('B'), we want to map X -> A (the root)
544
+ this.trackVariableAssignment(call, parentNode, node.fullPath);
545
+ }
546
+ /**
547
+ * Process a lazy() call
548
+ */
549
+ processLazyCall(call, sourceFile) {
550
+ // Check if this exact call has already been processed (prevents duplicates from chained calls)
551
+ if (this.markProcessed(call)) {
552
+ return;
553
+ }
554
+ // Get the type name and source context from arguments
555
+ const lazyContext = this.extractLazyContext(call, sourceFile);
556
+ // For chained calls like define('A').lazy('B'), we want the position of the .lazy('B') part
557
+ // not the start of the entire expression
558
+ let positionNode = call;
559
+ // If this is a chained call, get the position of the property access expression
560
+ // which is the .lazy part
561
+ if (ts.isPropertyAccessExpression(call.expression)) {
562
+ // The expression is the property access: (define('RootAsync', ...)).lazy
563
+ // We want the position of just the .lazy part
564
+ positionNode = call.expression.name; // This is the 'lazy' identifier
565
+ }
566
+ const startPos = positionNode.getStart(sourceFile);
567
+ const { line, character } = ts.getLineAndCharacterOfPosition(sourceFile, startPos);
568
+ if (!lazyContext.typeName) {
569
+ this.errors.push({
570
+ message: 'Could not extract type name from lazy() call',
571
+ file: sourceFile.fileName,
572
+ line: line + 1,
573
+ column: character + 1,
574
+ });
575
+ return;
576
+ }
577
+ const { typeName } = lazyContext;
578
+ // Determine parent type and collection based on the call source.
579
+ const parentNode = lazyContext.parentType;
580
+ const { collectionId } = lazyContext;
581
+ // Extract config options
582
+ const config = this.extractLazyConfig(call);
583
+ // Create type node first so its internal fullPath (including any collection prefix) is resolved.
584
+ const node = graph_1.TypeGraphImpl.createNode(typeName, parentNode, sourceFile.fileName, line + 1, character + 1, collectionId);
585
+ node.registryInterfaceName = this.getRegistryInterfaceName(collectionId);
586
+ // Extract properties from the constructor returned by the lazy getter
315
587
  node.properties = this.extractProperties(call);
316
588
  // Extract constructor parameters for TypeRegistry signature
317
589
  node.constructorParams = this.extractConstructorParams(call);
@@ -322,9 +594,271 @@ class MnemonicaAnalyzer {
322
594
  else {
323
595
  this.graph.addRoot(node);
324
596
  }
325
- // Track variable assignment: const User = define('UserEntity', ...) -> map "User" to "UserEntity"
326
- // For chained calls like const X = define('A').define('B'), we want to map X -> A (the root)
327
- this.trackVariableAssignment(call, parentNode, fullPath);
597
+ // Create definition info using the node's resolved fullPath
598
+ const definition = {
599
+ name: typeName,
600
+ location: `${sourceFile.fileName}:${line + 1}:${character + 1}`,
601
+ kind: 'define',
602
+ parent: parentNode ? parentNode.fullPath : null,
603
+ strictChain: config.strictChain ?? true,
604
+ blockErrors: config.blockErrors ?? false,
605
+ };
606
+ this.definitions.set(node.fullPath, definition);
607
+ // Track variable assignment: const LazyType = lazy('LazyType', ...) -> map "LazyType" -> "LazyType"
608
+ // For chained calls like const X = lazy('A').define('B'), we want to map X -> A (the root)
609
+ this.trackVariableAssignment(call, parentNode, node.fullPath);
610
+ }
611
+ /**
612
+ * Extract lazy() call arguments into a normalized shape.
613
+ * Handles named/unnamed and explicit-source forms, both as free calls
614
+ * and as method calls.
615
+ */
616
+ extractLazyCallArgs(call) {
617
+ const args = call.arguments;
618
+ const isMethodCall = ts.isPropertyAccessExpression(call.expression);
619
+ if (isMethodCall) {
620
+ // Source is the object of the property access: Type.lazy(...)
621
+ const source = call.expression.expression;
622
+ if (args.length === 0) {
623
+ return undefined;
624
+ }
625
+ const methodFirstArg = args[0];
626
+ if (ts.isStringLiteral(methodFirstArg)) {
627
+ // Type.lazy('Name', getter, config?)
628
+ if (args.length < 2) {
629
+ return undefined;
630
+ }
631
+ return {
632
+ source,
633
+ name: methodFirstArg.text,
634
+ getter: args[1],
635
+ config: args[2],
636
+ };
637
+ }
638
+ // Type.lazy(getter, config?)
639
+ return {
640
+ source,
641
+ getter: methodFirstArg,
642
+ config: args[1],
643
+ };
644
+ }
645
+ // Free call: lazy(...)
646
+ if (args.length === 0) {
647
+ return undefined;
648
+ }
649
+ const firstArg = args[0];
650
+ // Explicit-source form: lazy(source, 'Name', getter, config?)
651
+ // or lazy(source, getter, config?)
652
+ if (args.length >= 2 && ts.isIdentifier(firstArg)) {
653
+ const secondArg = args[1];
654
+ if (ts.isStringLiteral(secondArg)) {
655
+ // lazy(source, 'Name', getter, config?)
656
+ if (args.length < 3) {
657
+ return undefined;
658
+ }
659
+ return {
660
+ source: firstArg,
661
+ name: secondArg.text,
662
+ getter: args[2],
663
+ config: args[3],
664
+ };
665
+ }
666
+ // lazy(source, getter, config?)
667
+ return {
668
+ source: firstArg,
669
+ getter: secondArg,
670
+ config: args[2],
671
+ };
672
+ }
673
+ // Named root form: lazy('Name', getter, config?)
674
+ if (ts.isStringLiteral(firstArg)) {
675
+ if (args.length < 2) {
676
+ return undefined;
677
+ }
678
+ return {
679
+ name: firstArg.text,
680
+ getter: args[1],
681
+ config: args[2],
682
+ };
683
+ }
684
+ // Unnamed root form: lazy(getter, config?)
685
+ return {
686
+ getter: firstArg,
687
+ config: args[1],
688
+ };
689
+ }
690
+ /**
691
+ * Unwrap the constructor returned by a lazy getter.
692
+ * Supports:
693
+ * () => class Name {}
694
+ * () => function Name() {}
695
+ * () => { return class Name {}; }
696
+ * function () { return function Name() {}; }
697
+ */
698
+ unwrapLazyGetter(getterExpr) {
699
+ if (ts.isArrowFunction(getterExpr)) {
700
+ const { body } = getterExpr;
701
+ if (!ts.isBlock(body)) {
702
+ return body;
703
+ }
704
+ for (const stmt of body.statements) {
705
+ if (ts.isReturnStatement(stmt) && stmt.expression) {
706
+ return stmt.expression;
707
+ }
708
+ }
709
+ return undefined;
710
+ }
711
+ if (ts.isFunctionExpression(getterExpr)) {
712
+ const { body } = getterExpr;
713
+ for (const stmt of body.statements) {
714
+ if (ts.isReturnStatement(stmt) && stmt.expression) {
715
+ return stmt.expression;
716
+ }
717
+ }
718
+ return undefined;
719
+ }
720
+ // Not a recognized getter pattern
721
+ return undefined;
722
+ }
723
+ /**
724
+ * Extract a constructor name from a class expression, class declaration,
725
+ * or named function expression.
726
+ */
727
+ extractConstructorName(constructorExpr) {
728
+ if (ts.isClassExpression(constructorExpr) && constructorExpr.name) {
729
+ return constructorExpr.name.text;
730
+ }
731
+ if (ts.isClassDeclaration(constructorExpr) && constructorExpr.name) {
732
+ return constructorExpr.name.text;
733
+ }
734
+ if (ts.isFunctionExpression(constructorExpr) && constructorExpr.name) {
735
+ return constructorExpr.name.text;
736
+ }
737
+ return undefined;
738
+ }
739
+ /**
740
+ * Extract the type name from either a define() or lazy() call.
741
+ */
742
+ extractMnemonicaTypeName(call) {
743
+ if (this.isDefineCall(call)) {
744
+ return this.extractTypeName(call);
745
+ }
746
+ if (this.isLazyCall(call)) {
747
+ const args = this.extractLazyCallArgs(call);
748
+ if (!args) {
749
+ return undefined;
750
+ }
751
+ if (args.name) {
752
+ return args.name;
753
+ }
754
+ const constructorExpr = this.unwrapLazyGetter(args.getter);
755
+ if (constructorExpr) {
756
+ return this.extractConstructorName(constructorExpr);
757
+ }
758
+ }
759
+ return undefined;
760
+ }
761
+ /**
762
+ * Extract the full lazy() call context: type name, parent type, and collection.
763
+ * Handles direct calls, property-access calls, chained calls, and the
764
+ * explicit-source form `lazy(source, 'TypeName', getter)`.
765
+ */
766
+ extractLazyContext(call, sourceFile) {
767
+ const args = this.extractLazyCallArgs(call);
768
+ if (!args) {
769
+ return {};
770
+ }
771
+ let typeName = args.name;
772
+ if (!typeName) {
773
+ const constructorExpr = this.unwrapLazyGetter(args.getter);
774
+ if (constructorExpr) {
775
+ typeName = this.extractConstructorName(constructorExpr);
776
+ }
777
+ }
778
+ if (!typeName) {
779
+ return {};
780
+ }
781
+ const { expression } = call;
782
+ // Direct call: lazy('TypeName', ...) or lazy(source, 'TypeName', getter)
783
+ if (ts.isIdentifier(expression) && expression.text === 'lazy') {
784
+ if (args.source && ts.isIdentifier(args.source)) {
785
+ const sourceContext = this.resolveDefineSource(args.source.text);
786
+ return {
787
+ typeName,
788
+ parentType: sourceContext.parentType,
789
+ collectionId: sourceContext.collectionId,
790
+ };
791
+ }
792
+ // Plain root lazy in default collection
793
+ return { typeName };
794
+ }
795
+ // Property access: X.lazy('TypeName', ...)
796
+ if (ts.isPropertyAccessExpression(expression) && expression.name.text === 'lazy') {
797
+ const obj = expression.expression;
798
+ if (ts.isIdentifier(obj)) {
799
+ const sourceContext = this.resolveDefineSource(obj.text);
800
+ return {
801
+ typeName,
802
+ parentType: sourceContext.parentType,
803
+ collectionId: sourceContext.collectionId,
804
+ };
805
+ }
806
+ if (ts.isPropertyAccessExpression(obj)) {
807
+ // Nested access: instance.Type.lazy - try to resolve
808
+ const chain = this.getPropertyChain(obj);
809
+ if (chain.length > 0) {
810
+ const parentNode = this.graph.findType(chain.join('.'));
811
+ return { typeName, parentType: parentNode };
812
+ }
813
+ }
814
+ if (ts.isCallExpression(obj)) {
815
+ // Determine the collection context from the root of the chain so that
816
+ // custom-collection types do not get confused with default-collection types.
817
+ const rootId = this.getRootIdentifier(obj.expression);
818
+ const expectedCollectionId = rootId
819
+ ? this.resolveDefineSource(rootId.text).collectionId
820
+ : undefined;
821
+ // Chained call: define('A').lazy('B') or lazy('A').lazy('B')
822
+ if (this.isDefineCall(obj)) {
823
+ this.processDefineCall(obj, sourceFile);
824
+ const parentTypeName = this.extractMnemonicaTypeName(obj);
825
+ if (parentTypeName) {
826
+ const parentNode = this.findParentTypeByName(parentTypeName, expectedCollectionId);
827
+ return { typeName, parentType: parentNode, collectionId: parentNode?.collectionId };
828
+ }
829
+ }
830
+ if (this.isLazyCall(obj)) {
831
+ this.processLazyCall(obj, sourceFile);
832
+ const parentTypeName = this.extractMnemonicaTypeName(obj);
833
+ if (parentTypeName) {
834
+ const parentNode = this.findParentTypeByName(parentTypeName, expectedCollectionId);
835
+ return { typeName, parentType: parentNode, collectionId: parentNode?.collectionId };
836
+ }
837
+ }
838
+ // Builder lookup chain: App.lookup('User').lazy('Admin')
839
+ if (this.isLookupCall(obj)) {
840
+ const lookedUpPath = this.resolveLookupPath(obj);
841
+ if (lookedUpPath) {
842
+ const parentNode = this.graph.findType(lookedUpPath);
843
+ if (parentNode) {
844
+ return { typeName, parentType: parentNode, collectionId: parentNode.collectionId };
845
+ }
846
+ }
847
+ }
848
+ }
849
+ }
850
+ return { typeName };
851
+ }
852
+ /**
853
+ * Extract config options from lazy() call
854
+ */
855
+ extractLazyConfig(call) {
856
+ const args = this.extractLazyCallArgs(call);
857
+ if (!args || !args.config || !ts.isObjectLiteralExpression(args.config)) {
858
+ return {};
859
+ }
860
+ const configResult = this.extractConfigFromObjectLiteral(args.config);
861
+ return configResult;
328
862
  }
329
863
  /**
330
864
  * Track variable assignments that capture define() results
@@ -417,51 +951,68 @@ class MnemonicaAnalyzer {
417
951
  return;
418
952
  }
419
953
  // Parse decorator arguments: @decorate(), @decorate(Parent),
420
- // @decorate({ ... }), @decorate(Parent, { ... })
954
+ // @decorate({ ... }), @decorate(Parent, { ... }),
955
+ // @MyCollection.decorate(), @MyCollection.decorate({ ... })
421
956
  let parentNode;
422
957
  let parentFullPath = null;
958
+ let collectionId;
423
959
  let decoratorConfig = {};
424
960
  if (ts.isCallExpression(decorator.expression)) {
425
- const args = decorator.expression.arguments;
426
- let parentArg;
427
- let configArg;
428
- for (const arg of args) {
429
- if (ts.isIdentifier(arg)) {
430
- if (parentArg) {
431
- this.errors.push({
432
- message: '@decorate() accepts only one parent reference',
433
- file: sourceFile.fileName,
434
- line: line + 1,
435
- column: character + 1,
436
- });
961
+ const callExpr = decorator.expression;
962
+ const callee = callExpr.expression;
963
+ // Check for @MyCollection.decorate() where MyCollection is a custom collection.
964
+ // The decorated class becomes a root type in that collection.
965
+ if (ts.isPropertyAccessExpression(callee) &&
966
+ callee.name.text === 'decorate' &&
967
+ ts.isIdentifier(callee.expression) &&
968
+ this.collectionVariables.has(callee.expression.text)) {
969
+ collectionId = this.collectionVariables.get(callee.expression.text);
970
+ if (callExpr.arguments.length === 1 && ts.isObjectLiteralExpression(callExpr.arguments[0])) {
971
+ decoratorConfig = this.extractConfigFromObjectLiteral(callExpr.arguments[0]);
972
+ }
973
+ }
974
+ else {
975
+ const args = callExpr.arguments;
976
+ let parentArg;
977
+ let configArg;
978
+ for (const arg of args) {
979
+ if (ts.isIdentifier(arg)) {
980
+ if (parentArg) {
981
+ this.errors.push({
982
+ message: '@decorate() accepts only one parent reference',
983
+ file: sourceFile.fileName,
984
+ line: line + 1,
985
+ column: character + 1,
986
+ });
987
+ }
988
+ else {
989
+ parentArg = arg;
990
+ }
437
991
  }
438
- else {
439
- parentArg = arg;
992
+ else if (ts.isObjectLiteralExpression(arg)) {
993
+ if (configArg) {
994
+ this.errors.push({
995
+ message: '@decorate() accepts only one config object',
996
+ file: sourceFile.fileName,
997
+ line: line + 1,
998
+ column: character + 1,
999
+ });
1000
+ }
1001
+ else {
1002
+ configArg = arg;
1003
+ }
440
1004
  }
441
1005
  }
442
- else if (ts.isObjectLiteralExpression(arg)) {
443
- if (configArg) {
444
- this.errors.push({
445
- message: '@decorate() accepts only one config object',
446
- file: sourceFile.fileName,
447
- line: line + 1,
448
- column: character + 1,
449
- });
450
- }
451
- else {
452
- configArg = arg;
1006
+ if (parentArg) {
1007
+ parentNode = this.findParentTypeByIdentifier(parentArg.text);
1008
+ if (parentNode) {
1009
+ parentFullPath = parentNode.fullPath;
453
1010
  }
454
1011
  }
455
- }
456
- if (parentArg) {
457
- parentNode = this.findParentTypeByIdentifier(parentArg.text);
458
- if (parentNode) {
459
- parentFullPath = parentNode.fullPath;
1012
+ if (configArg) {
1013
+ decoratorConfig = this.extractConfigFromObjectLiteral(configArg);
460
1014
  }
461
1015
  }
462
- if (configArg) {
463
- decoratorConfig = this.extractConfigFromObjectLiteral(configArg);
464
- }
465
1016
  }
466
1017
  // Build full path
467
1018
  const fullPath = parentNode ? `${parentNode.fullPath}.${typeName}` : typeName;
@@ -476,7 +1027,8 @@ class MnemonicaAnalyzer {
476
1027
  };
477
1028
  this.definitions.set(fullPath, definition);
478
1029
  // Create type node
479
- const node = graph_1.TypeGraphImpl.createNode(typeName, parentNode, sourceFile.fileName, line + 1, character + 1);
1030
+ const node = graph_1.TypeGraphImpl.createNode(typeName, parentNode, sourceFile.fileName, line + 1, character + 1, collectionId);
1031
+ node.registryInterfaceName = this.getRegistryInterfaceName(node.collectionId);
480
1032
  // Extract properties and constructor parameters from class members
481
1033
  node.properties = this.extractClassProperties(classDecl);
482
1034
  node.constructorParams = this.extractClassConstructorParams(classDecl);
@@ -489,15 +1041,23 @@ class MnemonicaAnalyzer {
489
1041
  }
490
1042
  }
491
1043
  /**
492
- * Extract type name from define() call arguments
1044
+ * Extract type name from define() call arguments.
1045
+ * Handles:
1046
+ * define('TypeName', handler)
1047
+ * define(source, 'TypeName', handler) // explicit-source form
1048
+ * define(function TypeName() {})
1049
+ * define(() => class TypeName {})
493
1050
  */
494
1051
  extractTypeName(call) {
495
1052
  const args = call.arguments;
496
- // define('TypeName', handler) or define(() => class)
497
1053
  if (args.length === 0) {
498
1054
  return undefined;
499
1055
  }
500
1056
  const firstArg = args[0];
1057
+ // Explicit-source form: define(source, 'TypeName', handler)
1058
+ if (args.length >= 2 && ts.isIdentifier(firstArg) && ts.isStringLiteral(args[1])) {
1059
+ return args[1].text;
1060
+ }
501
1061
  // String literal: define('TypeName', ...)
502
1062
  if (ts.isStringLiteral(firstArg)) {
503
1063
  return firstArg.text;
@@ -508,7 +1068,7 @@ class MnemonicaAnalyzer {
508
1068
  }
509
1069
  // Arrow function returning class: define(() => class TypeName {})
510
1070
  if (ts.isArrowFunction(firstArg)) {
511
- const body = firstArg.body;
1071
+ const { body } = firstArg;
512
1072
  if (ts.isClassExpression(body) && body.name) {
513
1073
  return body.name.text;
514
1074
  }
@@ -516,74 +1076,213 @@ class MnemonicaAnalyzer {
516
1076
  return undefined;
517
1077
  }
518
1078
  /**
519
- * Find parent type node for nested define calls
1079
+ * Extract the full define() call context: type name, parent type, and collection.
1080
+ * Handles direct calls, property-access calls, chained calls, and the
1081
+ * explicit-source form `define(source, 'TypeName', handler)`.
520
1082
  */
521
- findParentType(call, _sourceFile) {
522
- const expression = call.expression;
523
- // Check for method call: SomeType.define('SubType', ...)
524
- if (!ts.isPropertyAccessExpression(expression)) {
525
- return undefined;
1083
+ extractDefineContext(call) {
1084
+ const typeName = this.extractTypeName(call);
1085
+ if (!typeName) {
1086
+ return {};
526
1087
  }
527
- // Get the object being called on (SomeType in SomeType.define)
528
- const obj = expression.expression;
529
- if (ts.isIdentifier(obj)) {
530
- // Direct reference to parent type - search by simple name
531
- const name = obj.text;
532
- // First check variable mapping: const User = define('UserEntity', ...)
533
- // In this case, name is "User" but we need to find "UserEntity"
534
- const mappedFullPath = this.variableToTypeMap.get(name);
535
- if (mappedFullPath) {
536
- const mappedNode = this.graph.findType(mappedFullPath);
537
- if (mappedNode)
538
- return mappedNode;
539
- }
540
- // First try exact match
541
- const exact = this.graph.findType(name);
542
- if (exact)
543
- return exact;
544
- // Then search through all types for one with matching name
545
- for (const type of this.graph.getAllTypes()) {
546
- if (type.name === name) {
547
- return type;
1088
+ const { expression } = call;
1089
+ // Direct call: define('TypeName', ...) or define(source, 'TypeName', handler)
1090
+ if (ts.isIdentifier(expression) && expression.text === 'define') {
1091
+ // Explicit-source form: define(source, 'TypeName', handler)
1092
+ if (call.arguments.length >= 2 && ts.isIdentifier(call.arguments[0])) {
1093
+ const sourceName = call.arguments[0].text;
1094
+ const sourceContext = this.resolveDefineSource(sourceName);
1095
+ return {
1096
+ typeName,
1097
+ parentType: sourceContext.parentType,
1098
+ collectionId: sourceContext.collectionId,
1099
+ };
1100
+ }
1101
+ // Plain root define in default collection
1102
+ return { typeName };
1103
+ }
1104
+ // Property access: X.define('TypeName', ...)
1105
+ if (ts.isPropertyAccessExpression(expression) && expression.name.text === 'define') {
1106
+ const obj = expression.expression;
1107
+ if (ts.isIdentifier(obj)) {
1108
+ const sourceContext = this.resolveDefineSource(obj.text);
1109
+ return {
1110
+ typeName,
1111
+ parentType: sourceContext.parentType,
1112
+ collectionId: sourceContext.collectionId,
1113
+ };
1114
+ }
1115
+ if (ts.isPropertyAccessExpression(obj)) {
1116
+ // Nested access: instance.Type.define - try to resolve
1117
+ const chain = this.getPropertyChain(obj);
1118
+ if (chain.length > 0) {
1119
+ const parentNode = this.graph.findType(chain.join('.'));
1120
+ return { typeName, parentType: parentNode };
548
1121
  }
549
1122
  }
1123
+ if (ts.isCallExpression(obj)) {
1124
+ // Determine the collection context from the root of the chain so that
1125
+ // custom-collection types do not get confused with default-collection types.
1126
+ const rootId = this.getRootIdentifier(obj.expression);
1127
+ const expectedCollectionId = rootId
1128
+ ? this.resolveDefineSource(rootId.text).collectionId
1129
+ : undefined;
1130
+ // Chained call: define('A').define('B') or mnemonica.define('A').define('B')
1131
+ if (this.isDefineCall(obj)) {
1132
+ this.processDefineCall(obj, call.getSourceFile());
1133
+ const parentTypeName = this.extractTypeName(obj);
1134
+ if (parentTypeName) {
1135
+ const parentNode = this.findParentTypeByName(parentTypeName, expectedCollectionId);
1136
+ // Inherit collection from the parent type (if any)
1137
+ return { typeName, parentType: parentNode, collectionId: parentNode?.collectionId };
1138
+ }
1139
+ }
1140
+ // Chained lazy call: lazy('A').define('B') or Type.lazy('A').define('B')
1141
+ if (this.isLazyCall(obj)) {
1142
+ this.processLazyCall(obj, call.getSourceFile());
1143
+ const parentTypeName = this.extractMnemonicaTypeName(obj);
1144
+ if (parentTypeName) {
1145
+ const parentNode = this.findParentTypeByName(parentTypeName, expectedCollectionId);
1146
+ return { typeName, parentType: parentNode, collectionId: parentNode?.collectionId };
1147
+ }
1148
+ }
1149
+ // Builder lookup chain: App.lookup('User').define('Admin')
1150
+ if (this.isLookupCall(obj)) {
1151
+ const lookedUpPath = this.resolveLookupPath(obj);
1152
+ if (lookedUpPath) {
1153
+ const parentNode = this.graph.findType(lookedUpPath);
1154
+ if (parentNode) {
1155
+ return { typeName, parentType: parentNode, collectionId: parentNode.collectionId };
1156
+ }
1157
+ }
1158
+ }
1159
+ }
1160
+ }
1161
+ return { typeName };
1162
+ }
1163
+ /**
1164
+ * Prefix a dotted type path with a collection identifier so custom-collection
1165
+ * types do not collide with default-collection types in the graph.
1166
+ */
1167
+ prefixCollectionPath(path, collectionId) {
1168
+ return `${collectionId}::${path}`;
1169
+ }
1170
+ /**
1171
+ * Resolve a define() source identifier to either a parent type, a collection,
1172
+ * or the default (module object) collection.
1173
+ */
1174
+ resolveDefineSource(sourceName) {
1175
+ // Module object aliases -> root in default collection
1176
+ if (this.moduleObjectVariables.has(sourceName)) {
1177
+ return {};
1178
+ }
1179
+ // Collection variables -> root in that collection
1180
+ const collectionId = this.collectionVariables.get(sourceName);
1181
+ if (collectionId) {
1182
+ return { collectionId };
1183
+ }
1184
+ // Otherwise treat as a type variable reference
1185
+ const parentNode = this.findParentTypeByIdentifier(sourceName);
1186
+ return { parentType: parentNode, collectionId: parentNode?.collectionId };
1187
+ }
1188
+ /**
1189
+ * Check if a call expression is a lookup() call.
1190
+ */
1191
+ isLookupCall(node) {
1192
+ const expr = node.expression;
1193
+ if (ts.isIdentifier(expr) && expr.text === 'lookup') {
1194
+ return true;
1195
+ }
1196
+ if (ts.isPropertyAccessExpression(expr) && expr.name.text === 'lookup') {
1197
+ return true;
1198
+ }
1199
+ return false;
1200
+ }
1201
+ /**
1202
+ * Resolve a lookup() call to a dotted type path (best effort).
1203
+ * Handles:
1204
+ * lookup('User')
1205
+ * lookup(source, 'User')
1206
+ * App.lookup('User')
1207
+ * collection.lookup('User.Admin')
1208
+ */
1209
+ resolveLookupPath(call) {
1210
+ const args = call.arguments;
1211
+ if (args.length === 0) {
550
1212
  return undefined;
551
1213
  }
552
- if (ts.isPropertyAccessExpression(obj)) {
553
- // Nested access: instance.Type.define - try to resolve
554
- const chain = this.getPropertyChain(obj);
555
- if (chain.length > 0) {
556
- return this.graph.findType(chain.join('.'));
1214
+ // Single-arg lookup: lookup('User') or App.lookup('User')
1215
+ if (args.length === 1) {
1216
+ const arg = args[0];
1217
+ if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
1218
+ const path = arg.text;
1219
+ // If this is a method call on a source, resolve relative to that source.
1220
+ if (ts.isPropertyAccessExpression(call.expression)) {
1221
+ const sourceExpr = call.expression.expression;
1222
+ if (ts.isIdentifier(sourceExpr)) {
1223
+ const sourceName = sourceExpr.text;
1224
+ const sourceContext = this.resolveDefineSource(sourceName);
1225
+ if (sourceContext.collectionId) {
1226
+ // Collection lookup: prefix path with the collection id
1227
+ return this.prefixCollectionPath(path, sourceContext.collectionId);
1228
+ }
1229
+ if (sourceContext.parentType) {
1230
+ // Type lookup: relative first, then root fallback
1231
+ const relativePath = `${sourceContext.parentType.fullPath}.${path}`;
1232
+ if (this.graph.findType(relativePath)) {
1233
+ return relativePath;
1234
+ }
1235
+ return path;
1236
+ }
1237
+ }
1238
+ }
1239
+ return path;
557
1240
  }
1241
+ return undefined;
558
1242
  }
559
- // Handle chained define: define('UserEntity', ...).define('UserResponse', ...)
560
- if (ts.isCallExpression(obj)) {
561
- // This is a chained call - the object is itself a define() call
562
- // Check if the parent call is a define() that hasn't been processed yet
563
- if (this.isDefineCall(obj)) {
564
- // Process the parent define() call first to create its type node
565
- // Use the sourceFile from the current context
566
- this.processDefineCall(obj, _sourceFile);
567
- // Now find and return the newly created parent type
568
- const parentTypeName = this.extractTypeName(obj);
569
- if (parentTypeName) {
570
- return this.findParentTypeByName(parentTypeName);
1243
+ // Two-arg lookup: lookup(source, 'User')
1244
+ if (args.length >= 2) {
1245
+ const sourceArg = args[0];
1246
+ const pathArg = args[1];
1247
+ if (!ts.isIdentifier(sourceArg) || !ts.isStringLiteral(pathArg)) {
1248
+ return undefined;
1249
+ }
1250
+ const sourceName = sourceArg.text;
1251
+ const path = pathArg.text;
1252
+ const sourceContext = this.resolveDefineSource(sourceName);
1253
+ if (sourceContext.collectionId) {
1254
+ return this.prefixCollectionPath(path, sourceContext.collectionId);
1255
+ }
1256
+ if (sourceContext.parentType) {
1257
+ const relativePath = `${sourceContext.parentType.fullPath}.${path}`;
1258
+ if (this.graph.findType(relativePath)) {
1259
+ return relativePath;
571
1260
  }
1261
+ return path;
572
1262
  }
1263
+ return path;
573
1264
  }
574
1265
  return undefined;
575
1266
  }
576
1267
  /**
577
- * Find a parent type by its name, searching in the graph
1268
+ * Find a parent type by its name, searching in the graph.
1269
+ * When collectionId is provided, only types from that collection are considered.
578
1270
  */
579
- findParentTypeByName(name) {
580
- // First try exact match
1271
+ findParentTypeByName(name, collectionId) {
1272
+ const matchesCollection = (type) => {
1273
+ if (collectionId === undefined) {
1274
+ return type.collectionId === undefined;
1275
+ }
1276
+ return type.collectionId === collectionId;
1277
+ };
1278
+ // First try exact match (default-collection types use the plain dotted path)
581
1279
  const exact = this.graph.findType(name);
582
- if (exact)
1280
+ if (exact && matchesCollection(exact)) {
583
1281
  return exact;
584
- // Then search through all types for one with matching name
1282
+ }
1283
+ // Then search through all types for one with matching name and collection
585
1284
  for (const type of this.graph.getAllTypes()) {
586
- if (type.name === name) {
1285
+ if (type.name === name && matchesCollection(type)) {
587
1286
  return type;
588
1287
  }
589
1288
  }
@@ -605,6 +1304,20 @@ class MnemonicaAnalyzer {
605
1304
  const parentNode = this.findParentTypeByName(name);
606
1305
  return parentNode;
607
1306
  }
1307
+ /**
1308
+ * Get the leftmost identifier of a property-access chain.
1309
+ * For `App.define('User').define('Admin')` this returns the `App` identifier.
1310
+ */
1311
+ getRootIdentifier(expr) {
1312
+ let current = expr;
1313
+ while (ts.isPropertyAccessExpression(current)) {
1314
+ current = current.expression;
1315
+ }
1316
+ if (ts.isIdentifier(current)) {
1317
+ return current;
1318
+ }
1319
+ return undefined;
1320
+ }
608
1321
  /**
609
1322
  * Get property chain from nested access
610
1323
  */
@@ -622,23 +1335,61 @@ class MnemonicaAnalyzer {
622
1335
  }
623
1336
  return chain;
624
1337
  }
1338
+ /**
1339
+ * Determine the constructor expression for either a define() or lazy() call.
1340
+ * For define() this is the construct handler; for lazy() it is the value
1341
+ * returned by the lazy getter.
1342
+ */
1343
+ extractConstructorExpression(call) {
1344
+ const expr = call.expression;
1345
+ const name = ts.isIdentifier(expr)
1346
+ ? expr.text
1347
+ : ts.isPropertyAccessExpression(expr)
1348
+ ? expr.name.text
1349
+ : '';
1350
+ if (name === 'lazy') {
1351
+ const lazyArgs = this.extractLazyCallArgs(call);
1352
+ if (!lazyArgs) {
1353
+ return undefined;
1354
+ }
1355
+ return this.unwrapLazyGetter(lazyArgs.getter);
1356
+ }
1357
+ // define() call
1358
+ const args = call.arguments;
1359
+ if (args.length === 0) {
1360
+ return undefined;
1361
+ }
1362
+ // Modern form: define('Name', handler, config?)
1363
+ if (ts.isStringLiteral(args[0])) {
1364
+ return args[1];
1365
+ }
1366
+ // Legacy form: define(function Name() {}) or define(() => class Name {})
1367
+ return args[0];
1368
+ }
625
1369
  /**
626
1370
  * Extract properties from constructor function
627
1371
  */
628
1372
  extractProperties(call) {
629
- const properties = new Map();
630
- const handlerArg = call.arguments[1];
631
- if (!handlerArg) {
632
- return properties;
1373
+ const constructorExpr = this.extractConstructorExpression(call);
1374
+ if (!constructorExpr) {
1375
+ return new Map();
633
1376
  }
1377
+ const result = this.extractPropertiesFromConstructor(constructorExpr);
1378
+ return result;
1379
+ }
1380
+ /**
1381
+ * Extract properties from a constructor expression (function, arrow, or class).
1382
+ */
1383
+ extractPropertiesFromConstructor(constructorExpr) {
1384
+ const properties = new Map();
634
1385
  // Build type map from data parameter (for this.x = data.x patterns)
635
- const dataTypeMap = this.buildDataTypeMap(handlerArg);
1386
+ const dataTypeMap = this.buildDataTypeMap(constructorExpr);
636
1387
  // Handle function expression
637
- if (ts.isFunctionExpression(handlerArg) || ts.isArrowFunction(handlerArg)) {
638
- const body = handlerArg.body;
1388
+ if (ts.isFunctionExpression(constructorExpr) || ts.isArrowFunction(constructorExpr)) {
1389
+ const { body } = constructorExpr;
639
1390
  // First, extract properties from `this` parameter type annotation
640
1391
  // This handles patterns like: function(this: SomeType, data: SomeType) { }
641
- const thisParamProperties = this.extractThisParamProperties(handlerArg);
1392
+ const thisParamProperties = this.extractThisParamProperties(constructorExpr);
642
1393
  for (const [name, propInfo] of thisParamProperties) {
643
1394
  properties.set(name, propInfo);
644
1395
  }
@@ -652,16 +1403,18 @@ class MnemonicaAnalyzer {
652
1403
  }
653
1404
  }
654
1405
  // Handle class expression
655
- if (ts.isClassExpression(handlerArg)) {
1406
+ if (ts.isClassExpression(constructorExpr)) {
656
1407
  // First pass: collect all property types for method inference
657
- const classPropertyTypes = this.extractClassPropertyTypes(handlerArg);
658
- for (const member of handlerArg.members) {
1408
+ const classPropertyTypes = this.extractClassPropertyTypes(constructorExpr);
1409
+ for (const member of constructorExpr.members) {
659
1410
  // Handle property declarations
660
1411
  if (ts.isPropertyDeclaration(member) && member.name) {
661
1412
  // Skip private and protected properties
662
1413
  if (member.modifiers) {
663
- const hasPrivateOrProtected = member.modifiers.some(m => m.kind === ts.SyntaxKind.PrivateKeyword ||
664
- m.kind === ts.SyntaxKind.ProtectedKeyword);
1414
+ const hasPrivateOrProtected = member.modifiers.some(m => {
1415
+ return m.kind === ts.SyntaxKind.PrivateKeyword ||
1416
+ m.kind === ts.SyntaxKind.ProtectedKeyword;
1417
+ });
665
1418
  if (hasPrivateOrProtected) {
666
1419
  continue;
667
1420
  }
@@ -679,8 +1432,10 @@ class MnemonicaAnalyzer {
679
1432
  if (ts.isMethodDeclaration(member) && member.name && ts.isIdentifier(member.name)) {
680
1433
  // Skip private and protected methods
681
1434
  if (member.modifiers) {
682
- const hasPrivateOrProtected = member.modifiers.some(m => m.kind === ts.SyntaxKind.PrivateKeyword ||
683
- m.kind === ts.SyntaxKind.ProtectedKeyword);
1435
+ const hasPrivateOrProtected = member.modifiers.some(m => {
1436
+ return m.kind === ts.SyntaxKind.PrivateKeyword ||
1437
+ m.kind === ts.SyntaxKind.ProtectedKeyword;
1438
+ });
684
1439
  if (hasPrivateOrProtected) {
685
1440
  continue;
686
1441
  }
@@ -697,8 +1452,10 @@ class MnemonicaAnalyzer {
697
1452
  if (ts.isGetAccessor(member) && member.name && ts.isIdentifier(member.name)) {
698
1453
  // Skip private and protected getters
699
1454
  if (member.modifiers) {
700
- const hasPrivateOrProtected = member.modifiers.some(m => m.kind === ts.SyntaxKind.PrivateKeyword ||
701
- m.kind === ts.SyntaxKind.ProtectedKeyword);
1455
+ const hasPrivateOrProtected = member.modifiers.some(m => {
1456
+ return m.kind === ts.SyntaxKind.PrivateKeyword ||
1457
+ m.kind === ts.SyntaxKind.ProtectedKeyword;
1458
+ });
702
1459
  if (hasPrivateOrProtected) {
703
1460
  continue;
704
1461
  }
@@ -792,7 +1549,7 @@ class MnemonicaAnalyzer {
792
1549
  // Handle: this.property = value
793
1550
  if (ts.isBinaryExpression(expr) &&
794
1551
  expr.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
795
- const left = expr.left;
1552
+ const { left } = expr;
796
1553
  if (ts.isPropertyAccessExpression(left)) {
797
1554
  // Check if accessing 'this' (ThisKeyword)
798
1555
  if (left.expression.kind === ts.SyntaxKind.ThisKeyword) {
@@ -1037,7 +1794,7 @@ class MnemonicaAnalyzer {
1037
1794
  case ts.SyntaxKind.VoidKeyword:
1038
1795
  return 'void';
1039
1796
  case ts.SyntaxKind.ArrayType:
1040
- return 'Array<' + this.inferType(typeNode.elementType) + '>';
1797
+ return `Array<${this.inferType(typeNode.elementType)}>`;
1041
1798
  case ts.SyntaxKind.TypeLiteral: {
1042
1799
  // Inline-expand type literals instead of collapsing to 'object'
1043
1800
  const typeLit = typeNode;
@@ -1054,7 +1811,7 @@ class MnemonicaAnalyzer {
1054
1811
  }
1055
1812
  case ts.SyntaxKind.LiteralType: {
1056
1813
  // Handle string literal types like 'user', 'admin', etc.
1057
- const literal = typeNode.literal;
1814
+ const { literal } = typeNode;
1058
1815
  if (ts.isStringLiteral(literal)) {
1059
1816
  // Return the actual literal value (e.g., 'user' instead of string)
1060
1817
  return `'${literal.text}'`;
@@ -1093,30 +1850,30 @@ class MnemonicaAnalyzer {
1093
1850
  if (arg.kind === ts.SyntaxKind.TypeQuery) {
1094
1851
  const typeQuery = arg;
1095
1852
  if (ts.isIdentifier(typeQuery.exprName)) {
1096
- const typeName = typeQuery.exprName.text;
1853
+ const queryTypeName = typeQuery.exprName.text;
1097
1854
  // Look up the type in the graph to get full path
1098
- const typeNode = this.graph.findTypeByName(typeName);
1099
- if (typeNode) {
1855
+ const matchedType = this.graph.findTypeByName(queryTypeName);
1856
+ if (matchedType) {
1100
1857
  // Convert full path with dots to underscores: Usages.UsageEntry -> Usages_UsageEntry
1101
- return typeNode.fullPath.replace(/\./g, '_');
1858
+ return matchedType.fullPath.replace(/\./g, '_');
1102
1859
  }
1103
1860
  // Fallback: just use the type name if not found in graph
1104
- return typeName;
1861
+ return queryTypeName;
1105
1862
  }
1106
1863
  }
1107
1864
  }
1108
1865
  if (!typeRef.typeArguments || typeRef.typeArguments.length === 0) {
1109
1866
  // Check if this type exists in our graph - convert to full path format
1110
- const typeNode = this.graph.findTypeByName(typeName);
1111
- if (typeNode) {
1867
+ const matchedType = this.graph.findTypeByName(typeName);
1868
+ if (matchedType) {
1112
1869
  // Convert full path with dots to underscores: Usages.UsageEntry -> Usages_UsageEntry
1113
- return typeNode.fullPath.replace(/\./g, "_");
1870
+ return matchedType.fullPath.replace(/\./g, '_');
1114
1871
  }
1115
1872
  return typeName;
1116
1873
  }
1117
1874
  // Build generic type arguments
1118
- const args = typeRef.typeArguments.map(arg => this.inferType(arg));
1119
- return `${typeName}<${args.join(', ')}>`;
1875
+ const typeArgs = typeRef.typeArguments.map(arg => this.inferType(arg));
1876
+ return `${typeName}<${typeArgs.join(', ')}>`;
1120
1877
  }
1121
1878
  case ts.SyntaxKind.UnionType: {
1122
1879
  // Handle union types like 'a' | 'b' | 'c'
@@ -1139,12 +1896,12 @@ class MnemonicaAnalyzer {
1139
1896
  case ts.SyntaxKind.OptionalType: {
1140
1897
  // Handle optional element in tuple: string?
1141
1898
  const optionalType = typeNode;
1142
- return this.inferType(optionalType.type) + '?';
1899
+ return `${this.inferType(optionalType.type)}?`;
1143
1900
  }
1144
1901
  case ts.SyntaxKind.RestType: {
1145
1902
  // Handle rest element: ...T
1146
1903
  const restType = typeNode;
1147
- return '...' + this.inferType(restType.type);
1904
+ return `...${this.inferType(restType.type)}`;
1148
1905
  }
1149
1906
  case ts.SyntaxKind.ParenthesizedType: {
1150
1907
  // Handle parenthesized types: (A | B)
@@ -1442,8 +2199,8 @@ class MnemonicaAnalyzer {
1442
2199
  }
1443
2200
  }
1444
2201
  /**
1445
- * Collect usage information for type references
1446
- */
2202
+ * Collect usage information for type references
2203
+ */
1447
2204
  collectUsage(node, sourceFile) {
1448
2205
  // Check for new Type() instantiation
1449
2206
  if (ts.isNewExpression(node) && node.expression) {
@@ -1489,13 +2246,12 @@ class MnemonicaAnalyzer {
1489
2246
  }
1490
2247
  }
1491
2248
  }
1492
- // Check for lookup('TypeName') calls
2249
+ // Check for lookup('TypeName') or lookup(source, 'TypeName') calls
1493
2250
  if (ts.isCallExpression(node) && node.expression) {
1494
2251
  const funcName = this.getFunctionName(node.expression);
1495
2252
  if (funcName === 'lookup' && node.arguments.length > 0) {
1496
- const firstArg = node.arguments[0];
1497
- if (ts.isStringLiteral(firstArg) || ts.isNoSubstitutionTemplateLiteral(firstArg)) {
1498
- const typePath = firstArg.text;
2253
+ const typePath = this.resolveLookupPath(node);
2254
+ if (typePath) {
1499
2255
  const { line, character } = ts.getLineAndCharacterOfPosition(sourceFile, node.getStart(sourceFile));
1500
2256
  this.addUsage(typePath, {
1501
2257
  location: `${sourceFile.fileName}:${line + 1}:${character + 1}`,
@@ -1509,8 +2265,8 @@ class MnemonicaAnalyzer {
1509
2265
  }
1510
2266
  }
1511
2267
  /**
1512
- * Get function name from expression (identifier or property access)
1513
- */
2268
+ * Get function name from expression (identifier or property access)
2269
+ */
1514
2270
  getFunctionName(expr) {
1515
2271
  if (ts.isIdentifier(expr)) {
1516
2272
  return expr.text;
@@ -1521,8 +2277,8 @@ class MnemonicaAnalyzer {
1521
2277
  return undefined;
1522
2278
  }
1523
2279
  /**
1524
- * Add a usage to the collection
1525
- */
2280
+ * Add a usage to the collection
2281
+ */
1526
2282
  addUsage(typePath, usage) {
1527
2283
  // Only track usages of mnemonica-defined types
1528
2284
  if (!this.definitions.has(typePath)) {
@@ -1624,7 +2380,9 @@ class MnemonicaAnalyzer {
1624
2380
  return;
1625
2381
  }
1626
2382
  // Adapters: createDiveInterceptor, createDivePlugin, createDiveMiddleware
1627
- if (funcName === 'createDiveInterceptor' || funcName === 'createDivePlugin' || funcName === 'createDiveMiddleware') {
2383
+ if (funcName === 'createDiveInterceptor' ||
2384
+ funcName === 'createDivePlugin' ||
2385
+ funcName === 'createDiveMiddleware') {
1628
2386
  this.addEDS('unknown', {
1629
2387
  location,
1630
2388
  kind: 'adapterUse',
@@ -1670,7 +2428,11 @@ class MnemonicaAnalyzer {
1670
2428
  this.edsUsages.set(typePath, []);
1671
2429
  }
1672
2430
  const existing = this.edsUsages.get(typePath);
1673
- const isDuplicate = existing.some(e => e.location === info.location && e.kind === info.kind && e.code === info.code);
2431
+ const isDuplicate = existing.some(e => {
2432
+ return e.location === info.location &&
2433
+ e.kind === info.kind &&
2434
+ e.code === info.code;
2435
+ });
1674
2436
  if (!isDuplicate) {
1675
2437
  existing.push(info);
1676
2438
  }
@@ -1730,7 +2492,7 @@ class MnemonicaAnalyzer {
1730
2492
  const location = `${sourceFile.fileName}:${line + 1}:${character + 1}`;
1731
2493
  const code = node.getText(sourceFile).slice(0, 100);
1732
2494
  // Skip if this is a type constructor access (e.g., UserType.define)
1733
- if (propName === 'define') {
2495
+ if (propName === 'define' || propName === 'lazy') {
1734
2496
  return;
1735
2497
  }
1736
2498
  this.addFlow(objectType, {
@@ -1816,7 +2578,7 @@ class MnemonicaAnalyzer {
1816
2578
  const location = `${sourceFile.fileName}:${line + 1}:${character + 1}`;
1817
2579
  const code = node.getText(sourceFile).slice(0, 100);
1818
2580
  // Skip if this is a type constructor call (e.g., new UserType())
1819
- if (methodName === 'define') {
2581
+ if (methodName === 'define' || methodName === 'lazy') {
1820
2582
  return;
1821
2583
  }
1822
2584
  this.addFlow(objectType, {
@@ -1945,7 +2707,11 @@ class MnemonicaAnalyzer {
1945
2707
  this.flowUsages.set(typePath, []);
1946
2708
  }
1947
2709
  const existing = this.flowUsages.get(typePath);
1948
- const isDuplicate = existing.some(e => e.location === info.location && e.kind === info.kind && e.code === info.code);
2710
+ const isDuplicate = existing.some(e => {
2711
+ return e.location === info.location &&
2712
+ e.kind === info.kind &&
2713
+ e.code === info.code;
2714
+ });
1949
2715
  if (!isDuplicate) {
1950
2716
  existing.push(info);
1951
2717
  }
@@ -1970,8 +2736,8 @@ class MnemonicaAnalyzer {
1970
2736
  return undefined;
1971
2737
  }
1972
2738
  /**
1973
- * Resolve full type path from property access
1974
- */
2739
+ * Resolve full type path from property access
2740
+ */
1975
2741
  resolveTypePath(expr) {
1976
2742
  const chain = this.getPropertyChain(expr);
1977
2743
  if (chain.length === 0)
@@ -1991,15 +2757,15 @@ class MnemonicaAnalyzer {
1991
2757
  return fullPath;
1992
2758
  }
1993
2759
  /**
1994
- * Check if a name looks like a type (starts with uppercase)
1995
- */
2760
+ * Check if a name looks like a type (starts with uppercase)
2761
+ */
1996
2762
  isLikelyTypeName(name) {
1997
2763
  return name[0] >= 'A' && name[0] <= 'Z';
1998
2764
  }
1999
2765
  /**
2000
- * Resolve a constructor parameter type, expanding inline object literals
2001
- * and type aliases where possible.
2002
- */
2766
+ * Resolve a constructor parameter type, expanding inline object literals
2767
+ * and type aliases where possible.
2768
+ */
2003
2769
  resolveConstructorParamType(typeNode) {
2004
2770
  if (!typeNode)
2005
2771
  return undefined;
@@ -2028,15 +2794,15 @@ class MnemonicaAnalyzer {
2028
2794
  // If not an object type alias, return the type name with args
2029
2795
  if (typeNode.typeArguments && typeNode.typeArguments.length > 0) {
2030
2796
  const args = typeNode.typeArguments.map(arg => this.inferType(arg));
2031
- return typeName + '<' + args.join(', ') + '>';
2797
+ return `${typeName}<${args.join(', ')}>`;
2032
2798
  }
2033
2799
  return typeName;
2034
2800
  }
2035
2801
  return undefined;
2036
2802
  }
2037
2803
  /**
2038
- * Extract constructor parameters from a class-like node.
2039
- */
2804
+ * Extract constructor parameters from a class-like node.
2805
+ */
2040
2806
  extractClassConstructorParams(classLike) {
2041
2807
  const params = [];
2042
2808
  for (const member of classLike.members) {
@@ -2061,25 +2827,35 @@ class MnemonicaAnalyzer {
2061
2827
  return params;
2062
2828
  }
2063
2829
  /**
2064
- * Extract constructor parameters from define() call
2065
- * This is used for TypeRegistry constructor signatures
2066
- * Preserves parameter names and expands object types to their structure
2067
- */
2830
+ * Extract constructor parameters from define() call
2831
+ * This is used for TypeRegistry constructor signatures
2832
+ * Preserves parameter names and expands object types to their structure
2833
+ */
2068
2834
  extractConstructorParams(call) {
2835
+ const constructorExpr = this.extractConstructorExpression(call);
2836
+ if (!constructorExpr) {
2837
+ return [];
2838
+ }
2839
+ const result = this.extractConstructorParamsFromConstructor(constructorExpr);
2840
+ return result;
2841
+ }
2842
+ /**
2843
+ * Extract constructor parameters from a constructor expression.
2844
+ */
2845
+ extractConstructorParamsFromConstructor(constructorExpr) {
2069
2846
  const params = [];
2070
- const handlerArg = call.arguments[1];
2071
- if (!handlerArg)
2072
- return params;
2073
2847
  // Handle function expression or arrow function
2074
- if (ts.isFunctionExpression(handlerArg) || ts.isArrowFunction(handlerArg)) {
2848
+ if (ts.isFunctionExpression(constructorExpr) || ts.isArrowFunction(constructorExpr)) {
2075
2849
  // Look for constructor parameters (second param after `this`)
2076
2850
  // Patterns: function(this: Type, data: { ... }) or (this: Type, data: { ... }) =>
2077
- for (let i = 0; i < handlerArg.parameters.length; i++) {
2078
- const param = handlerArg.parameters[i];
2851
+ for (let i = 0; i < constructorExpr.parameters.length; i++) {
2852
+ const param = constructorExpr.parameters[i];
2079
2853
  if (!param.type)
2080
2854
  continue;
2081
2855
  // Skip `this` parameter (first param)
2082
- if (i === 0 && param.name.kind === ts.SyntaxKind.Identifier && param.name.text === 'this') {
2856
+ if (i === 0 &&
2857
+ param.name.kind === ts.SyntaxKind.Identifier &&
2858
+ param.name.text === 'this') {
2083
2859
  continue;
2084
2860
  }
2085
2861
  // Get parameter name and expand its type
@@ -2093,8 +2869,8 @@ class MnemonicaAnalyzer {
2093
2869
  }
2094
2870
  }
2095
2871
  // Handle class expression - check constructor method
2096
- if (ts.isClassExpression(handlerArg)) {
2097
- const classParams = this.extractClassConstructorParams(handlerArg);
2872
+ if (ts.isClassExpression(constructorExpr)) {
2873
+ const classParams = this.extractClassConstructorParams(constructorExpr);
2098
2874
  for (const param of classParams) {
2099
2875
  params.push(param);
2100
2876
  }