@stream44.studio/encapsulate 0.4.0-rc.39 → 0.4.0-rc.42

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": "@stream44.studio/encapsulate",
3
- "version": "0.4.0-rc.39",
3
+ "version": "0.4.0-rc.42",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,6 @@
1
1
 
2
+ import type { TimingObserverInterface } from './spine-factories/TimingObserver'
3
+
2
4
  // CACHE_BUST_VERSION: Increment this whenever CST cache must be invalidated due to structural changes
3
5
  // This ensures projected capsules are regenerated when the CST format changes
4
6
  const CACHE_BUST_VERSION = 22
@@ -7,7 +9,7 @@ type TSpineOptions = {
7
9
  spineFilesystemRoot?: string,
8
10
  spineContracts: Record<string, any>,
9
11
  staticAnalyzer?: any,
10
- timing?: { record: (step: string) => void, chalk?: any },
12
+ timing?: TimingObserverInterface,
11
13
  projectionContext?: {
12
14
  capsuleModuleProjectionPackage?: string,
13
15
  capsuleModuleProjectionRoot?: string,
@@ -56,6 +58,9 @@ type TCapsuleSnapshot = {
56
58
  type TCapsuleMakeInstanceOptions = {
57
59
  overrides?: Record<string, any>,
58
60
  options?: Record<string, any>,
61
+ /** Nested capsule-name-targeted options from a parent mapping.
62
+ * Merged AFTER options so they take precedence over intermediate defaults. */
63
+ transitiveOverrides?: Record<string, any>,
59
64
  runtimeSpineContracts?: Record<string, any>,
60
65
  sharedSelf?: Record<string, any>,
61
66
  rootCapsule?: {
@@ -65,7 +70,8 @@ type TCapsuleMakeInstanceOptions = {
65
70
  },
66
71
  parentCapsuleSourceUriLineRefInstanceId?: string,
67
72
  sit?: { capsuleInstances: Record<string, { capsuleName: string, capsuleSourceUriLineRef: string, parentCapsuleSourceUriLineRefInstanceId: string }> },
68
- skipCache?: boolean
73
+ skipCache?: boolean,
74
+ freezePhase?: boolean
69
75
  }
70
76
 
71
77
  type TCapsule = {
@@ -115,6 +121,7 @@ type TSpineContext = {
115
121
  export const CapsulePropertyTypes = {
116
122
  Function: 'Function' as const,
117
123
  GetterFunction: 'GetterFunction' as const,
124
+ ConstantGetterFunction: 'ConstantGetterFunction' as const,
118
125
  SetterFunction: 'SetterFunction' as const,
119
126
  String: 'String' as const,
120
127
  Mapping: 'Mapping' as const,
@@ -460,7 +467,7 @@ export async function Spine(options: TSpineOptions): Promise<TSpine> {
460
467
  snapshot.capsules[capsule.cst.source.capsuleName] = snapshot.capsules[capsuleSourceLineRef]
461
468
  }
462
469
 
463
- const capsuleInstance = await capsule.makeInstance()
470
+ const capsuleInstance = await capsule.makeInstance({ freezePhase: true })
464
471
 
465
472
  // Run OnFreeze functions so capsules can perform side effects
466
473
  // (e.g. file projection) at build/freeze time
@@ -666,7 +673,9 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
666
673
  encapsulateOptions,
667
674
  cst,
668
675
  crt: crts?.[capsuleSourceLineRef],
669
- makeInstance: async ({ overrides = {}, options = {}, runtimeSpineContracts, sharedSelf, rootCapsule, parentCapsuleSourceUriLineRefInstanceId, sit, skipCache }: TCapsuleMakeInstanceOptions = {}) => {
676
+ makeInstance: async ({ overrides = {}, options = {}, transitiveOverrides, runtimeSpineContracts, sharedSelf, rootCapsule, parentCapsuleSourceUriLineRefInstanceId, sit, skipCache, freezePhase }: TCapsuleMakeInstanceOptions = {}) => {
677
+
678
+ spine.spineOptions.timing?.record(`makeInstance: ${capsuleName || capsuleSourceLineRef}${freezePhase ? ' [freeze]' : ''}${sharedSelf ? ' [extends]' : ''}`)
670
679
 
671
680
  // Create cache key based on parameters
672
681
  // When sharedSelf is provided, we must NOT cache because each extending capsule
@@ -674,15 +683,14 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
674
683
  // This is critical for the pattern where multiple structs extend the same parent.
675
684
  // When skipCache is true (property contract delegates like structs/Capsule),
676
685
  // each parent capsule must get its own unique instance.
677
- const cacheKey = (sharedSelf || skipCache) ? null : JSON.stringify({
678
- overrides,
679
- options,
680
- hasRuntimeContracts: !!runtimeSpineContracts
681
- })
686
+ const cacheKey = (sharedSelf || skipCache) ? null : computeCacheKey(
687
+ overrides, options, transitiveOverrides, !!runtimeSpineContracts
688
+ )
682
689
 
683
690
  // Check if we already have a pending or completed instance creation
684
691
  // Skip cache when sharedSelf is provided (cacheKey is null)
685
692
  if (cacheKey && instanceCache.has(cacheKey)) {
693
+ spine.spineOptions.timing?.record(`makeInstance: CACHE HIT ${capsuleName || capsuleSourceLineRef}`)
686
694
  return instanceCache.get(cacheKey)!
687
695
  }
688
696
 
@@ -724,6 +732,7 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
724
732
  const propDefTyped = propDef as Record<string, any>
725
733
  const aliasName = propDefTyped.as
726
734
  let delegateOptions = propDefTyped.options
735
+ const delegateDepends = propDefTyped.depends
727
736
  const contractKey = aliasName || ('#' + propContractUri.substring(1))
728
737
 
729
738
  if (!propertyContractDefinitions[spineContractUri]['#']) {
@@ -739,6 +748,9 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
739
748
  value: delegateCapsuleObj || delegateUri,
740
749
  propertyContractDelegate: propContractUri,
741
750
  as: aliasName,
751
+ // Forward depends from the struct declaration so the delegate
752
+ // options function can access the required parent properties
753
+ depends: delegateDepends,
742
754
  // Pass options from the property contract delegate to the mapped capsule
743
755
  delegateOptions
744
756
  }
@@ -775,12 +787,19 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
775
787
  }
776
788
  }
777
789
 
778
- // Merge in order: overrides by lineRef, overrides by name, options
790
+ // Merge in order: overrides, options, then transitive overrides.
791
+ // - Overrides (from run()) are general defaults — options are more specific.
792
+ // - Transitive overrides (nested capsule-name-targeted options from a parent)
793
+ // are the most specific and win over intermediate capsule options.
779
794
  mergeByContract(overrides?.[encapsulateOptions.capsuleSourceLineRef], 'Overrides')
780
795
  if (encapsulateOptions.capsuleName) {
781
796
  mergeByContract(overrides?.[encapsulateOptions.capsuleName], 'Overrides')
782
797
  }
783
798
  mergeByContract(options, 'Options')
799
+ mergeByContract(transitiveOverrides?.[encapsulateOptions.capsuleSourceLineRef], 'TransitiveOverrides')
800
+ if (encapsulateOptions.capsuleName) {
801
+ mergeByContract(transitiveOverrides?.[encapsulateOptions.capsuleName], 'TransitiveOverrides')
802
+ }
784
803
 
785
804
  // Extract default values from property definitions (Literal/String types)
786
805
  // This ensures child capsule's default values are available before parent is instantiated
@@ -790,7 +809,8 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
790
809
  if (propertyContractUri !== '#') continue
791
810
  for (const [propertyName, propertyDef] of Object.entries(properties as Record<string, any>)) {
792
811
  if (propertyDef.type === CapsulePropertyTypes.Literal ||
793
- propertyDef.type === CapsulePropertyTypes.String) {
812
+ propertyDef.type === CapsulePropertyTypes.String ||
813
+ propertyDef.type === CapsulePropertyTypes.Constant) {
794
814
  if (propertyDef.value !== undefined) {
795
815
  defaultPropertyValues[propertyName] = propertyDef.value
796
816
  }
@@ -847,6 +867,7 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
847
867
  capsuleSourceLineRef: absoluteCapsuleSourceLineRef,
848
868
  capsuleSourceNameRefHash: cst?.capsuleSourceNameRefHash,
849
869
  moduleFilepath: originalAbsoluteModuleFilepath,
870
+ spineFilesystemRoot: spine.spineOptions.spineFilesystemRoot,
850
871
  // Root capsule metadata will be populated after extends chain is resolved
851
872
  rootCapsule: {
852
873
  capsuleName: undefined as string | undefined,
@@ -926,9 +947,11 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
926
947
  })
927
948
  }
928
949
 
950
+ spine.spineOptions.timing?.record(`makeInstance: extends → ${extendsCapsule.encapsulateOptions?.capsuleName || '?'}`)
929
951
  extendedCapsuleInstance = await extendsCapsule.makeInstance({
930
952
  overrides,
931
953
  options,
954
+ transitiveOverrides,
932
955
  runtimeSpineContracts,
933
956
  sharedSelf: self,
934
957
  rootCapsule: rootCapsule || {
@@ -937,9 +960,10 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
937
960
  moduleFilepath: absoluteModuleFilepath
938
961
  },
939
962
  parentCapsuleSourceUriLineRefInstanceId: parentCapsuleSourceUriLineRefInstanceId
940
- ? await sha256(parentCapsuleSourceUriLineRefInstanceId + ':' + (cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef))
941
- : await sha256(cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef),
942
- sit
963
+ ? sha256(parentCapsuleSourceUriLineRefInstanceId + ':' + (cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef))
964
+ : sha256(cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef),
965
+ sit,
966
+ freezePhase
943
967
  })
944
968
 
945
969
  // Propagate this (child) capsule's encapsulatedApi to all parent spine contract
@@ -975,8 +999,8 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
975
999
  // child: sha256(parentCapsuleSourceUriLineRefInstanceId + ":" + capsuleSourceUriLineRef)
976
1000
  const capsuleSourceUriLineRef = cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef
977
1001
  const capsuleSourceUriLineRefInstanceId = parentCapsuleSourceUriLineRefInstanceId
978
- ? await sha256(parentCapsuleSourceUriLineRefInstanceId + ':' + capsuleSourceUriLineRef)
979
- : await sha256(capsuleSourceUriLineRef)
1002
+ ? sha256(parentCapsuleSourceUriLineRefInstanceId + ':' + capsuleSourceUriLineRef)
1003
+ : sha256(capsuleSourceUriLineRef)
980
1004
 
981
1005
  // Register this instance in the sit structure if provided
982
1006
  if (sit) {
@@ -1001,7 +1025,8 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
1001
1025
  capsuleSourceUriLineRefInstanceId,
1002
1026
  capsuleName: encapsulateOptions.capsuleName,
1003
1027
  capsuleSourceUriLineRef,
1004
- sit
1028
+ sit,
1029
+ freezePhase
1005
1030
  }
1006
1031
 
1007
1032
  // Set capsule metadata struct on self early so it's available in options() callbacks during mapping
@@ -1012,6 +1037,14 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
1012
1037
  }
1013
1038
  ownSelf['#@stream44.studio/encapsulate/structs/Capsule'] = capsuleMetadataStruct
1014
1039
 
1040
+ // Promote metadata values to top-level self properties so struct delegate
1041
+ // proxies (which read from the capsule instance's own self/api) can access them.
1042
+ for (const [metaKey, metaValue] of Object.entries(capsuleMetadataStruct)) {
1043
+ if (metaKey !== 'rootCapsule' && metaValue !== undefined && self[metaKey] === undefined) {
1044
+ self[metaKey] = metaValue
1045
+ }
1046
+ }
1047
+
1015
1048
  // Use runtime spine contracts if provided, otherwise fall back to encapsulation spine contracts
1016
1049
  const activeSpineContracts = runtimeSpineContracts || spine.spineContracts
1017
1050
 
@@ -1056,6 +1089,7 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
1056
1089
  await spineContractCapsuleInstance.mapProperty({
1057
1090
  overrides,
1058
1091
  options,
1092
+ transitiveOverrides,
1059
1093
  property: {
1060
1094
  name: propertyName,
1061
1095
  definition: propertyDefinition,
@@ -1230,15 +1264,41 @@ function relative(from: string, to: string): string {
1230
1264
  return result || '.'
1231
1265
  }
1232
1266
 
1233
- async function sha256(input: string): Promise<string> {
1234
- const data = new TextEncoder().encode(input)
1235
- const hashBuffer = await crypto.subtle.digest('SHA-256', data)
1236
- const hashArray = new Uint8Array(hashBuffer)
1237
- let hex = ''
1238
- for (let i = 0; i < hashArray.length; i++) {
1239
- hex += hashArray[i].toString(16).padStart(2, '0')
1267
+ // Fast cache key computation for makeInstance — avoids JSON.stringify for the common empty case
1268
+ const _emptyObj = {}
1269
+ const _cacheKeyEmpty = '{"overrides":{},"options":{},"hasRuntimeContracts":false}'
1270
+ const _cacheKeyEmptyWithRuntime = '{"overrides":{},"options":{},"hasRuntimeContracts":true}'
1271
+ function _isEmptyObj(obj: any): boolean { for (const _ in obj) return false; return true }
1272
+ const _cacheKeyLastRef = { overrides: _emptyObj as any, options: _emptyObj as any, transitiveOverrides: undefined as any, hasRuntime: false, key: _cacheKeyEmpty }
1273
+ function computeCacheKey(overrides: any, options: any, transitiveOverrides: any, hasRuntimeContracts: boolean): string {
1274
+ // Reference-equality shortcut: if same objects as last call, reuse key
1275
+ if (overrides === _cacheKeyLastRef.overrides && options === _cacheKeyLastRef.options
1276
+ && transitiveOverrides === _cacheKeyLastRef.transitiveOverrides && hasRuntimeContracts === _cacheKeyLastRef.hasRuntime) {
1277
+ return _cacheKeyLastRef.key
1240
1278
  }
1241
- return hex
1279
+ // Fast-path: empty overrides + empty options + no transitive = constant string
1280
+ if (!transitiveOverrides && _isEmptyObj(overrides) && _isEmptyObj(options)) {
1281
+ const key = hasRuntimeContracts ? _cacheKeyEmptyWithRuntime : _cacheKeyEmpty
1282
+ _cacheKeyLastRef.overrides = overrides; _cacheKeyLastRef.options = options
1283
+ _cacheKeyLastRef.transitiveOverrides = transitiveOverrides; _cacheKeyLastRef.hasRuntime = hasRuntimeContracts
1284
+ _cacheKeyLastRef.key = key
1285
+ return key
1286
+ }
1287
+ const key = JSON.stringify({ overrides, options, transitiveOverrides, hasRuntimeContracts })
1288
+ _cacheKeyLastRef.overrides = overrides; _cacheKeyLastRef.options = options
1289
+ _cacheKeyLastRef.transitiveOverrides = transitiveOverrides; _cacheKeyLastRef.hasRuntime = hasRuntimeContracts
1290
+ _cacheKeyLastRef.key = key
1291
+ return key
1292
+ }
1293
+
1294
+ const _sha256Cache = new Map<string, string>()
1295
+ const _createHash = require('crypto').createHash
1296
+ function sha256(input: string): string {
1297
+ let result = _sha256Cache.get(input)
1298
+ if (result !== undefined) return result
1299
+ result = _createHash('sha256').update(input).digest('hex') as string
1300
+ _sha256Cache.set(input, result)
1301
+ return result
1242
1302
  }
1243
1303
 
1244
1304
  function isObject(item: any): boolean {
@@ -1,5 +1,6 @@
1
1
  import { CapsulePropertyTypes } from "../../encapsulate"
2
2
  import { ContractCapsuleInstanceFactory, CapsuleInstanceRegistry } from "./Static"
3
+ import type { TimingObserverInterface } from "../../spine-factories/TimingObserver"
3
4
 
4
5
  type CallerContext = {
5
6
  capsuleSourceLineRef: string
@@ -86,7 +87,8 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
86
87
  runtimeSpineContracts,
87
88
  instanceRegistry,
88
89
  extendedCapsuleInstance,
89
- capsuleInstance
90
+ capsuleInstance,
91
+ timing
90
92
  }: {
91
93
  spineContractUri: string
92
94
  capsule: any
@@ -109,8 +111,9 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
109
111
  instanceRegistry?: CapsuleInstanceRegistry
110
112
  extendedCapsuleInstance?: any
111
113
  capsuleInstance?: any
114
+ timing?: TimingObserverInterface
112
115
  }) {
113
- super({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule, instanceRegistry, extendedCapsuleInstance, capsuleInstance })
116
+ super({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule, instanceRegistry, extendedCapsuleInstance, capsuleInstance, timing })
114
117
  this.getEventIndex = getEventIndex
115
118
  this.incrementEventIndex = incrementEventIndex
116
119
  this.getCurrentCallerContext = getCurrentCallerContext
@@ -137,7 +140,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
137
140
  return ctx
138
141
  }
139
142
 
140
- protected async mapMappingProperty({ overrides, options, property }: { overrides: any, options: any, property: any }) {
143
+ protected async mapMappingProperty({ overrides, options, transitiveOverrides, property }: { overrides: any, options: any, transitiveOverrides?: any, property: any }) {
141
144
 
142
145
  const mappedCapsule = await this.resolveMappedCapsule({ property })
143
146
  const constants = await this.extractConstants({ mappedCapsule })
@@ -151,9 +154,23 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
151
154
  const minimalSelf = this.self[capsuleStructKey]
152
155
  ? { [capsuleStructKey]: this.self[capsuleStructKey] }
153
156
  : {}
154
- const mappingOptions = property.definition.delegateOptions
155
- || (typeof optionsFn === 'function'
156
- ? await optionsFn({ self: property.definition.depends ? this.self : minimalSelf, constants })
157
+ // During freeze phase, skip calling function-based options callbacks — runtime
158
+ // overrides haven't been applied so self.* references may be incomplete.
159
+ // Use a truthy placeholder so the instance registry still creates a fresh instance.
160
+ const isFreezePhase = !!this.capsuleInstance?.freezePhase
161
+ const hasFunctionOptions = typeof optionsFn === 'function'
162
+ const selfArg = { self: property.definition.depends ? this.self : minimalSelf, constants }
163
+ const delegateOpts = property.definition.delegateOptions
164
+ const resolvedDelegateOptions = delegateOpts
165
+ ? (typeof delegateOpts === 'function'
166
+ ? (isFreezePhase ? {} : await delegateOpts(selfArg))
167
+ : delegateOpts)
168
+ : undefined
169
+ const mappingOptions = resolvedDelegateOptions
170
+ || (hasFunctionOptions
171
+ ? (isFreezePhase
172
+ ? {} // truthy placeholder
173
+ : await optionsFn(selfArg))
157
174
  : optionsFn)
158
175
 
159
176
  // Check for existing instance in registry - reuse if available (regardless of options)
@@ -270,13 +287,14 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
270
287
  }
271
288
  }
272
289
 
273
- // Merge nested capsule-name-targeted options into overrides
274
- // These will be picked up when child capsules with matching names are instantiated
290
+ // Build transitive overrides for the child: merge any inherited transitive
291
+ // overrides with this mapping's nested capsule-name-targeted options.
292
+ let mappedTransitiveOverrides: Record<string, any> | undefined = transitiveOverrides
275
293
  if (nestedCapsuleOptions) {
276
- mappedOverrides = { ...mappedOverrides }
294
+ mappedTransitiveOverrides = { ...(mappedTransitiveOverrides || {}) }
277
295
  for (const [capsuleNameKey, capsuleOptions] of Object.entries(nestedCapsuleOptions)) {
278
- mappedOverrides[capsuleNameKey] = {
279
- ...(mappedOverrides[capsuleNameKey] || {}),
296
+ mappedTransitiveOverrides[capsuleNameKey] = {
297
+ ...(mappedTransitiveOverrides[capsuleNameKey] || {}),
280
298
  ...capsuleOptions
281
299
  }
282
300
  }
@@ -285,11 +303,13 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
285
303
  const mappedCapsuleInstance = await mappedCapsule.makeInstance({
286
304
  overrides: mappedOverrides,
287
305
  options: ownMappingOptions,
306
+ transitiveOverrides: mappedTransitiveOverrides,
288
307
  runtimeSpineContracts: this.runtimeSpineContracts,
289
308
  rootCapsule: this.capsuleInstance?.rootCapsule,
290
309
  parentCapsuleSourceUriLineRefInstanceId: this.capsuleInstance?.capsuleSourceUriLineRefInstanceId,
291
310
  sit: this.capsuleInstance?.sit,
292
- skipCache: isCapsuleStruct
311
+ skipCache: isCapsuleStruct,
312
+ freezePhase: this.capsuleInstance?.freezePhase || undefined
293
313
  })
294
314
 
295
315
  // Register the instance (replaces null pre-registration marker)
@@ -481,6 +501,51 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
481
501
  })
482
502
  }
483
503
 
504
+ protected override mapConstantGetterFunctionProperty({ property }: { property: any }) {
505
+ const value = property.definition.value({ constants: this.self })
506
+
507
+ // Store eagerly on self like a Constant
508
+ this.self[property.name] = value
509
+ if (this.ownSelf) {
510
+ this.ownSelf[property.name] = value
511
+ }
512
+
513
+ // Wrap with membrane event tracking (read-only like Constant)
514
+ Object.defineProperty(this.encapsulatedApi, property.name, {
515
+ get: () => {
516
+ const currentValue = this.self[property.name]
517
+
518
+ const event: any = {
519
+ event: 'get',
520
+ eventIndex: this.incrementEventIndex(),
521
+ membrane: 'external',
522
+ target: {
523
+ capsuleSourceLineRef: this.encapsulateOptions.capsuleSourceLineRef,
524
+ spineContractCapsuleInstanceId: this.id,
525
+ prop: property.name,
526
+ },
527
+ value: currentValue
528
+ }
529
+
530
+ if (this.capsuleSourceNameRef) {
531
+ event.target.capsuleSourceNameRef = this.capsuleSourceNameRef
532
+ }
533
+ if (this.capsuleSourceNameRefHash) {
534
+ event.target.capsuleSourceNameRefHash = this.capsuleSourceNameRefHash
535
+ }
536
+
537
+ this.addCallerContextToEvent(event)
538
+ this.onMembraneEvent?.(event)
539
+ return currentValue
540
+ },
541
+ set: () => {
542
+ throw new Error(`Cannot set ConstantGetterFunction property '${property.name}'`)
543
+ },
544
+ enumerable: true,
545
+ configurable: true
546
+ })
547
+ }
548
+
484
549
  protected mapFunctionProperty({ property }: { property: any }) {
485
550
  const selfProxy = this.createSelfProxy()
486
551
  const boundFunction = property.definition.value.bind(selfProxy)
@@ -1001,7 +1066,8 @@ export function CapsuleSpineContract({
1001
1066
  spineFilesystemRoot,
1002
1067
  resolve,
1003
1068
  importCapsule,
1004
- npmUriForFilepath
1069
+ npmUriForFilepath,
1070
+ timing
1005
1071
  }: {
1006
1072
  onMembraneEvent?: (event: any) => void
1007
1073
  freezeCapsule?: (capsule: any) => Promise<any>
@@ -1010,6 +1076,7 @@ export function CapsuleSpineContract({
1010
1076
  resolve?: (uri: string, parentFilepath: string) => Promise<string>
1011
1077
  importCapsule?: (filepath: string) => Promise<any>
1012
1078
  npmUriForFilepath?: (filepath: string) => Promise<string | null>
1079
+ timing?: TimingObserverInterface
1013
1080
  } = {}) {
1014
1081
 
1015
1082
  let eventIndex = 0
@@ -1070,7 +1137,8 @@ export function CapsuleSpineContract({
1070
1137
  runtimeSpineContracts,
1071
1138
  instanceRegistry,
1072
1139
  extendedCapsuleInstance,
1073
- capsuleInstance
1140
+ capsuleInstance,
1141
+ timing
1074
1142
  })
1075
1143
  },
1076
1144
  hydrate: ({ capsuleSnapshot }: { capsuleSnapshot: any }): any => {
@@ -65,6 +65,18 @@ const userService = await encapsulate({
65
65
  value: '1.0.0'
66
66
  },
67
67
 
68
+ // ConstantGetterFunction: eagerly evaluated during property mapping.
69
+ // Receives { constants } (not this) — constants contains all Literal/String/Constant
70
+ // values from the same capsule, resolved Mapping constants, and capsule metadata.
71
+ // The computed result is stored on self like a Constant and included in extractConstants.
72
+ derivedPath: {
73
+ type: CapsulePropertyTypes.ConstantGetterFunction,
74
+ value: function ({ constants }: { constants: any }): string {
75
+ const meta = constants['#@stream44.studio/encapsulate/structs/Capsule']
76
+ return `/workbench/${meta.capsuleName}`
77
+ }
78
+ },
79
+
68
80
  // --- Function Properties ---
69
81
 
70
82
  // Function: bound to a self proxy. Receives arguments. Callable on the API.
@@ -253,6 +265,7 @@ Reference
253
265
  | `Literal` | read/write | read/write | yes | General-purpose value. Supports any JS type including `Map`, `Set`, etc. |
254
266
  | `String` | read/write | read/write | yes | Alias for `Literal`. Semantic hint for string values. |
255
267
  | `Constant` | read-only | read | no | Immutable value. Membrane contract throws on assignment. |
268
+ | `ConstantGetterFunction` | read-only | read | no | Eagerly evaluated computed constant. See below. |
256
269
 
257
270
  All value types accept a `value` in their definition. `undefined` means "no default — must be supplied via overrides/options".
258
271
 
@@ -316,6 +329,35 @@ When `api.name(...args)` is called:
316
329
  2. `target()` runs with self proxy as `this`, returning the function to call
317
330
  3. The target function is called with the transformed args (awaited if `invoke` returns a promise)
318
331
 
332
+ #### ConstantGetterFunction
333
+
334
+ `ConstantGetterFunction` is a computed constant — eagerly evaluated during property mapping, with its result stored on `self` like a `Constant`. Unlike `GetterFunction` (bound to `this`, evaluated lazily on each access), `ConstantGetterFunction` receives an explicit `{ constants }` parameter and is evaluated once.
335
+
336
+ ```ts
337
+ workbenchDir: {
338
+ type: CapsulePropertyTypes.ConstantGetterFunction,
339
+ value: function ({ constants }: { constants: any }): string {
340
+ const meta = constants['#@stream44.studio/encapsulate/structs/Capsule']
341
+ const capsuleDir = constants.lib.path.dirname(meta.rootCapsule.moduleFilepath)
342
+ const testName = constants.lib.path.basename(meta.rootCapsule.moduleFilepath).replace(/\.[^.]+$/, '')
343
+ return constants.lib.path.join(capsuleDir, '.~o/workbenches', testName)
344
+ }
345
+ }
346
+ ```
347
+
348
+ **What `constants` contains:**
349
+
350
+ | Context | Contents |
351
+ |---|---|
352
+ | During `extractConstants` (parent Mapping) | `Literal`/`String`/`Constant` values from the capsule definition, resolved `Mapping` constants (nested), and capsule metadata including `rootCapsule` |
353
+ | During `mapProperty` (instance creation) | `self` — all previously-processed properties, capsule metadata, resolved Mappings |
354
+
355
+ **Key properties:**
356
+ - **No `this` binding** — receives `{ constants }` as a plain function argument, making dependencies explicit
357
+ - **Available in parent's `constants`** — when a parent maps this capsule, the computed value is included in the `constants` parameter passed to the parent's `options` callback
358
+ - **Mapping constants are nested** — `constants.lib.path` accesses the `path` Constant from a mapped `lib` capsule
359
+ - **Capsule metadata** — `constants['#@stream44.studio/encapsulate/structs/Capsule']` includes `capsuleName`, `moduleFilepath`, and `rootCapsule` (the top-level capsule in the spine)
360
+
319
361
  ### Mapping
320
362
 
321
363
  `Mapping` composes another capsule as a sub-component.
@@ -333,7 +375,7 @@ prop: {
333
375
  - **`value`** — a capsule reference (from `encapsulate()`) or a string URI resolved relative to the current module.
334
376
  - **`options`** — forwarded to the mapped capsule. Keys starting with `'#'` target the mapped capsule's own property contracts. Keys without `'#'` are matched against capsule names deeper in the mapping tree (nested capsule-name-targeted options).
335
377
  - **`options({ self, constants })`** — when `options` is a function, it receives `{ self, constants }`.
336
- - `constants` — all `Literal`/`String` values from the mapped capsule's definition.
378
+ - `constants` — all `Literal`/`String`/`Constant` values from the mapped capsule's definition, computed `ConstantGetterFunction` results, resolved `Mapping` constants (nested by property name), and capsule metadata (`constants['#@stream44.studio/encapsulate/structs/Capsule']` with `rootCapsule`).
337
379
  - `self` — always contains the Capsule metadata struct (`self['#@stream44.studio/encapsulate/structs/Capsule']` with `moduleFilepath`, `capsuleName`, etc.). When `depends` is specified, `self` also contains the full parent capsule's resolved sibling mappings.
338
380
  - **`depends`** — array of sibling property names that must be resolved before this mapping's `options` function runs. Enables `options({ self })` to access already-resolved siblings (e.g. `self.$auth.realm`). Can be declared explicitly or auto-injected by the static analyzer when it detects `self.<name>` references in the options function body.
339
381
  - **Instance reuse** — named capsules are registered in an instance registry. If a capsule with the same name is mapped multiple times without options, the existing instance is reused via a deferred proxy.
@@ -1,4 +1,5 @@
1
1
  import { CapsulePropertyTypes, join } from "../../encapsulate"
2
+ import type { TimingObserverInterface } from "../../spine-factories/TimingObserver"
2
3
 
3
4
  // Type for capsule instance registry - scoped per spine contract instance
4
5
  export type CapsuleInstanceRegistry = Map<string, any>
@@ -19,6 +20,7 @@ export class ContractCapsuleInstanceFactory {
19
20
  public childEncapsulatedApis?: Record<string, any>[]
20
21
  protected runtimeSpineContracts?: Record<string, any>
21
22
  protected capsuleInstance?: any
23
+ protected timing?: TimingObserverInterface
22
24
  public structInitFunctions: Array<() => any> = []
23
25
  public structDisposeFunctions: Array<() => any> = []
24
26
  public initFunctions: Array<() => any> = []
@@ -28,7 +30,7 @@ export class ContractCapsuleInstanceFactory {
28
30
  protected memoizeCache: Map<string, any> = new Map()
29
31
  protected memoizeTimeouts: Map<string, ReturnType<typeof setTimeout>> = new Map()
30
32
 
31
- constructor({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule, instanceRegistry, extendedCapsuleInstance, runtimeSpineContracts, capsuleInstance }: { spineContractUri: string, capsule: any, self: any, ownSelf?: any, encapsulatedApi: Record<string, any>, resolve?: (uri: string, parentFilepath: string) => Promise<string>, importCapsule?: (filepath: string) => Promise<any>, spineFilesystemRoot?: string, freezeCapsule?: (capsule: any) => Promise<any>, instanceRegistry?: CapsuleInstanceRegistry, extendedCapsuleInstance?: any, runtimeSpineContracts?: Record<string, any>, capsuleInstance?: any }) {
33
+ constructor({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule, instanceRegistry, extendedCapsuleInstance, runtimeSpineContracts, capsuleInstance, timing }: { spineContractUri: string, capsule: any, self: any, ownSelf?: any, encapsulatedApi: Record<string, any>, resolve?: (uri: string, parentFilepath: string) => Promise<string>, importCapsule?: (filepath: string) => Promise<any>, spineFilesystemRoot?: string, freezeCapsule?: (capsule: any) => Promise<any>, instanceRegistry?: CapsuleInstanceRegistry, extendedCapsuleInstance?: any, runtimeSpineContracts?: Record<string, any>, capsuleInstance?: any, timing?: TimingObserverInterface }) {
32
34
  this.spineContractUri = spineContractUri
33
35
  this.capsule = capsule
34
36
  this.self = self
@@ -42,6 +44,7 @@ export class ContractCapsuleInstanceFactory {
42
44
  this.extendedCapsuleInstance = extendedCapsuleInstance
43
45
  this.runtimeSpineContracts = runtimeSpineContracts
44
46
  this.capsuleInstance = capsuleInstance
47
+ this.timing = timing
45
48
 
46
49
  // Inject importCapsule onto ownSelf so capsule functions can call this.self.importCapsule()
47
50
  if (ownSelf && !ownSelf.importCapsule) {
@@ -82,9 +85,9 @@ export class ContractCapsuleInstanceFactory {
82
85
  }
83
86
  }
84
87
 
85
- async mapProperty({ overrides, options, property }: { overrides: any, options: any, property: any }) {
88
+ async mapProperty({ overrides, options, transitiveOverrides, property }: { overrides: any, options: any, transitiveOverrides?: any, property: any }) {
86
89
  if (property.definition.type === CapsulePropertyTypes.Mapping) {
87
- await this.mapMappingProperty({ overrides, options, property })
90
+ await this.mapMappingProperty({ overrides, options, transitiveOverrides, property })
88
91
  } else if (
89
92
  property.definition.type === CapsulePropertyTypes.String ||
90
93
  property.definition.type === CapsulePropertyTypes.Literal ||
@@ -93,6 +96,8 @@ export class ContractCapsuleInstanceFactory {
93
96
  this.mapLiteralProperty({ property })
94
97
  } else if (property.definition.type === CapsulePropertyTypes.Function) {
95
98
  this.mapFunctionProperty({ property })
99
+ } else if (property.definition.type === CapsulePropertyTypes.ConstantGetterFunction) {
100
+ this.mapConstantGetterFunctionProperty({ property })
96
101
  } else if (property.definition.type === CapsulePropertyTypes.GetterFunction) {
97
102
  this.mapGetterFunctionProperty({ property })
98
103
  } else if (property.definition.type === CapsulePropertyTypes.SetterFunction) {
@@ -206,6 +211,7 @@ export class ContractCapsuleInstanceFactory {
206
211
  throw resolveError
207
212
  }
208
213
  }
214
+ this.timing?.record(`resolveMappedCapsule: importCapsule(${filepath.replace(/^.*\/genesis\//, '')})`)
209
215
  mappedCapsule = await this.importCapsule(filepath)
210
216
  } else if (
211
217
  typeof property.definition.value === 'object' &&
@@ -219,8 +225,10 @@ export class ContractCapsuleInstanceFactory {
219
225
  return mappedCapsule
220
226
  }
221
227
 
222
- protected async extractConstants({ mappedCapsule }: { mappedCapsule: any }) {
228
+ protected async extractConstants({ mappedCapsule, _visited }: { mappedCapsule: any, _visited?: Set<string> }) {
223
229
  const constants: Record<string, any> = {}
230
+ const constantGetterFunctions: Array<{ key: string; fn: Function }> = []
231
+ const mappingProperties: Array<{ key: string; def: any }> = []
224
232
 
225
233
  const spineContractDef = mappedCapsule.definition[this.spineContractUri]
226
234
 
@@ -239,9 +247,14 @@ export class ContractCapsuleInstanceFactory {
239
247
 
240
248
  if (
241
249
  type === CapsulePropertyTypes.String ||
242
- type === CapsulePropertyTypes.Literal
250
+ type === CapsulePropertyTypes.Literal ||
251
+ type === CapsulePropertyTypes.Constant
243
252
  ) {
244
253
  constants[prop] = propValue
254
+ } else if (type === CapsulePropertyTypes.ConstantGetterFunction) {
255
+ constantGetterFunctions.push({ key: prop, fn: propValue })
256
+ } else if (type === CapsulePropertyTypes.Mapping) {
257
+ mappingProperties.push({ key: prop, def: propDef })
245
258
  }
246
259
  }
247
260
  } else {
@@ -252,17 +265,67 @@ export class ContractCapsuleInstanceFactory {
252
265
 
253
266
  if (
254
267
  type === CapsulePropertyTypes.String ||
255
- type === CapsulePropertyTypes.Literal
268
+ type === CapsulePropertyTypes.Literal ||
269
+ type === CapsulePropertyTypes.Constant
256
270
  ) {
257
271
  constants[key] = propValue
272
+ } else if (type === CapsulePropertyTypes.ConstantGetterFunction) {
273
+ constantGetterFunctions.push({ key, fn: propValue })
274
+ } else if (type === CapsulePropertyTypes.Mapping) {
275
+ mappingProperties.push({ key, def: value })
258
276
  }
259
277
  }
260
278
  }
261
279
 
280
+ // Resolve Mapping properties and extract their constants recursively.
281
+ // Track visited capsules to prevent infinite recursion on circular refs.
282
+ const visited = _visited || new Set<string>()
283
+ const thisCapsuleName = mappedCapsule.encapsulateOptions?.capsuleName
284
+ if (thisCapsuleName) visited.add(thisCapsuleName)
285
+ for (const { key, def } of mappingProperties) {
286
+ try {
287
+ const nestedCapsule = await this.resolveMappedCapsule({
288
+ property: { definition: def, name: key }
289
+ })
290
+ const nestedName = nestedCapsule.encapsulateOptions?.capsuleName
291
+ if (nestedName && visited.has(nestedName)) continue
292
+ constants[key] = await this.extractConstants({ mappedCapsule: nestedCapsule, _visited: visited })
293
+ } catch {
294
+ // Skip mappings that cannot be resolved (e.g. missing dependencies)
295
+ }
296
+ }
297
+
298
+ // Build capsule metadata so ConstantGetterFunctions can access it.
299
+ // rootCapsule is inherited from the parent (same as what makeInstance would receive).
300
+ const capsuleStructKey = '#@stream44.studio/encapsulate/structs/Capsule'
301
+ const relModuleFilepath = mappedCapsule.cst?.source?.moduleFilepath
302
+ || mappedCapsule.encapsulateOptions?.moduleFilepath
303
+ const moduleFilepath = relModuleFilepath
304
+ ? (relModuleFilepath.startsWith('/')
305
+ ? relModuleFilepath
306
+ : join(this.spineFilesystemRoot || '', relModuleFilepath))
307
+ : undefined
308
+ constants[capsuleStructKey] = {
309
+ capsuleName: mappedCapsule.encapsulateOptions?.capsuleName,
310
+ moduleFilepath,
311
+ rootCapsule: this.capsuleInstance?.rootCapsule || {
312
+ capsuleName: this.capsule?.encapsulateOptions?.capsuleName,
313
+ moduleFilepath: this.self?.[capsuleStructKey]?.moduleFilepath,
314
+ },
315
+ }
316
+
317
+ // Evaluate ConstantGetterFunction values with the accumulated constants
318
+ // (including resolved Mapping constants). These receive { constants }
319
+ // (not this) — only static values and nested Mapping constants are available.
320
+ for (const { key, fn } of constantGetterFunctions) {
321
+ constants[key] = fn({ constants })
322
+ }
323
+
262
324
  return constants
263
325
  }
264
326
 
265
- protected async mapMappingProperty({ overrides, options, property }: { overrides: any, options: any, property: any }) {
327
+ protected async mapMappingProperty({ overrides, options, transitiveOverrides, property }: { overrides: any, options: any, transitiveOverrides?: any, property: any }) {
328
+ this.timing?.record(`mapMappingProperty: ${property.name} → ${typeof property.definition.value === 'string' ? property.definition.value : (property.definition.value?.encapsulateOptions?.capsuleName || 'obj')}`)
266
329
  const mappedCapsule = await this.resolveMappedCapsule({ property })
267
330
  const constants = await this.extractConstants({ mappedCapsule })
268
331
 
@@ -275,9 +338,25 @@ export class ContractCapsuleInstanceFactory {
275
338
  const minimalSelf = this.self[capsuleStructKey]
276
339
  ? { [capsuleStructKey]: this.self[capsuleStructKey] }
277
340
  : {}
278
- const mappingOptions = property.definition.delegateOptions
279
- || (typeof optionsFn === 'function'
280
- ? await optionsFn({ self: property.definition.depends ? this.self : minimalSelf, constants })
341
+ // During freeze phase, skip calling function-based options callbacks — runtime
342
+ // overrides haven't been applied so self.* references may be incomplete.
343
+ // Use a truthy placeholder so the instance registry still creates a fresh instance
344
+ // (preserving the instance tree for OnFreeze traversal and .sit.json generation).
345
+ // Object-based options and delegateOptions are always applied (they are static).
346
+ const isFreezePhase = !!this.capsuleInstance?.freezePhase
347
+ const hasFunctionOptions = typeof optionsFn === 'function'
348
+ const selfArg = { self: property.definition.depends ? this.self : minimalSelf, constants }
349
+ const delegateOpts = property.definition.delegateOptions
350
+ const resolvedDelegateOptions = delegateOpts
351
+ ? (typeof delegateOpts === 'function'
352
+ ? (isFreezePhase ? {} : await delegateOpts(selfArg))
353
+ : delegateOpts)
354
+ : undefined
355
+ const mappingOptions = resolvedDelegateOptions
356
+ || (hasFunctionOptions
357
+ ? (isFreezePhase
358
+ ? {} // truthy placeholder — signals "has options" without calling the function
359
+ : await optionsFn(selfArg))
281
360
  : optionsFn)
282
361
 
283
362
  // Check for existing instance in registry - reuse if available when no options
@@ -295,6 +374,7 @@ export class ContractCapsuleInstanceFactory {
295
374
 
296
375
  // Only reuse if current mapping has no options
297
376
  if (!mappingOptions) {
377
+ this.timing?.record(`mapMappingProperty: REGISTRY REUSE ${capsuleName} (deferred proxy)`)
298
378
  // Use deferred proxy that resolves from registry when accessed
299
379
  // Works for both null (pre-registered) and actual instances
300
380
  const apiTarget = this.getApiTarget({ property })
@@ -377,13 +457,16 @@ export class ContractCapsuleInstanceFactory {
377
457
  }
378
458
  }
379
459
 
380
- // Merge nested capsule-name-targeted options into overrides
381
- // These will be picked up when child capsules with matching names are instantiated
460
+ // Build transitive overrides for the child: merge any inherited transitive
461
+ // overrides with this mapping's nested capsule-name-targeted options.
462
+ // These flow as transitiveOverrides (not overrides) so they take precedence
463
+ // over the child's own Mapping options but runtime overrides remain less specific.
464
+ let mappedTransitiveOverrides: Record<string, any> | undefined = transitiveOverrides
382
465
  if (nestedCapsuleOptions) {
383
- mappedOverrides = { ...mappedOverrides }
466
+ mappedTransitiveOverrides = { ...(mappedTransitiveOverrides || {}) }
384
467
  for (const [capsuleNameKey, capsuleOptions] of Object.entries(nestedCapsuleOptions)) {
385
- mappedOverrides[capsuleNameKey] = {
386
- ...(mappedOverrides[capsuleNameKey] || {}),
468
+ mappedTransitiveOverrides[capsuleNameKey] = {
469
+ ...(mappedTransitiveOverrides[capsuleNameKey] || {}),
387
470
  ...capsuleOptions
388
471
  }
389
472
  }
@@ -393,11 +476,13 @@ export class ContractCapsuleInstanceFactory {
393
476
  const mappedInstance = await mappedCapsule.makeInstance({
394
477
  overrides: mappedOverrides,
395
478
  options: ownMappingOptions,
479
+ transitiveOverrides: mappedTransitiveOverrides,
396
480
  runtimeSpineContracts: this.runtimeSpineContracts,
397
481
  rootCapsule: this.capsuleInstance?.rootCapsule,
398
482
  parentCapsuleSourceUriLineRefInstanceId: this.capsuleInstance?.capsuleSourceUriLineRefInstanceId,
399
483
  sit: this.capsuleInstance?.sit,
400
- skipCache: isCapsuleStruct
484
+ skipCache: isCapsuleStruct,
485
+ freezePhase: this.capsuleInstance?.freezePhase || undefined
401
486
  })
402
487
 
403
488
  // Register the instance (replaces null pre-registration marker)
@@ -457,6 +542,20 @@ export class ContractCapsuleInstanceFactory {
457
542
  }
458
543
  }
459
544
 
545
+ protected mapConstantGetterFunctionProperty({ property }: { property: any }) {
546
+ const apiTarget = this.getApiTarget({ property })
547
+ // Pass self as constants — at instance creation time self contains capsule
548
+ // metadata, resolved Mappings, and all previously-processed properties.
549
+ const value = property.definition.value({ constants: this.self })
550
+
551
+ // Store eagerly like a Constant — available on self for subsequent properties
552
+ apiTarget[property.name] = value
553
+ this.self[property.name] = value
554
+ if (this.ownSelf) {
555
+ this.ownSelf[property.name] = value
556
+ }
557
+ }
558
+
460
559
  protected createSelfProxy() {
461
560
  const extendedApi = this.extendedCapsuleInstance?.api
462
561
  const ownSelf = this.ownSelf
@@ -744,7 +843,7 @@ export class ContractCapsuleInstanceFactory {
744
843
 
745
844
 
746
845
 
747
- export function CapsuleSpineContract({ freezeCapsule, resolve, importCapsule, spineFilesystemRoot }: { freezeCapsule?: (capsule: any) => Promise<any>, resolve?: (uri: string, parentFilepath: string) => Promise<string>, importCapsule?: (filepath: string) => Promise<any>, spineFilesystemRoot?: string } = {}) {
846
+ export function CapsuleSpineContract({ freezeCapsule, resolve, importCapsule, spineFilesystemRoot, timing }: { freezeCapsule?: (capsule: any) => Promise<any>, resolve?: (uri: string, parentFilepath: string) => Promise<string>, importCapsule?: (filepath: string) => Promise<any>, spineFilesystemRoot?: string, timing?: TimingObserverInterface } = {}) {
748
847
 
749
848
  const instanceRegistry: CapsuleInstanceRegistry = new Map()
750
849
 
@@ -765,7 +864,8 @@ export function CapsuleSpineContract({ freezeCapsule, resolve, importCapsule, sp
765
864
  instanceRegistry,
766
865
  extendedCapsuleInstance,
767
866
  runtimeSpineContracts,
768
- capsuleInstance
867
+ capsuleInstance,
868
+ timing
769
869
  })
770
870
  },
771
871
  hydrate: ({ capsuleSnapshot }: { capsuleSnapshot: any }): any => {
@@ -1,8 +1,10 @@
1
1
  import { join, dirname, relative, resolve as pathResolve } from 'path'
2
- import { writeFile, mkdir, readFile, stat } from 'fs/promises'
2
+ import { writeFile, mkdir, readFile, stat, access } from 'fs/promises'
3
+ import { createHash } from 'crypto'
3
4
  import { Spine, SpineRuntime, CapsulePropertyTypes, makeImportStack, merge } from "../encapsulate"
4
5
  import { StaticAnalyzer } from "../../src/static-analyzer"
5
6
  import { CapsuleModuleProjector } from "../../src/capsule-projectors/CapsuleModuleProjector"
7
+ import { TimingObserver, type TimingObserverInterface } from "./TimingObserver"
6
8
 
7
9
 
8
10
  export { merge }
@@ -366,6 +368,31 @@ function createNpmUriForFilepath(): (filepath: string) => Promise<string | null>
366
368
  }
367
369
  }
368
370
 
371
+ // Cross-factory cache for module imports — avoids re-importing the same file
372
+ // across factory instances. The capsule() call still runs per-factory (spine-specific),
373
+ // but the import() is only done once per filepath globally.
374
+ const _moduleImportCache = new Map<string, Promise<any>>()
375
+
376
+ // Cross-factory stat cache — avoids redundant filesystem stat() calls for the same
377
+ // file across factory instances within a single process. Source file stats are cached
378
+ // permanently (source files don't change within a single build). Cache file stats are
379
+ // invalidated on write.
380
+ const _statCache = new Map<string, Promise<{ mtime: Date } | null>>()
381
+ function cachedStat(filepath: string): Promise<{ mtime: Date } | null> {
382
+ let cached = _statCache.get(filepath)
383
+ if (!cached) {
384
+ cached = stat(filepath).then(
385
+ s => ({ mtime: s.mtime }),
386
+ () => null
387
+ )
388
+ _statCache.set(filepath, cached)
389
+ }
390
+ return cached
391
+ }
392
+ function invalidateStatCache(filepath: string) {
393
+ _statCache.delete(filepath)
394
+ }
395
+
369
396
  export async function CapsuleSpineFactory({
370
397
  spineFilesystemRoot,
371
398
  capsuleModuleProjectionRoot,
@@ -383,13 +410,16 @@ export async function CapsuleSpineFactory({
383
410
  onMembraneEvent?: (event: any) => void,
384
411
  enableCallerStackInference?: boolean,
385
412
  spineContracts: Record<string, any>,
386
- timing?: { record: (step: string) => void, recordMajor: (step: string) => void, chalk?: any }
413
+ timing?: TimingObserverInterface | null
387
414
  }) {
388
415
 
389
416
  if (capsuleModuleProjectionRoot) capsuleModuleProjectionRoot = capsuleModuleProjectionRoot.replace(/^file:\/\//, '')
390
417
  if (spineFilesystemRoot) spineFilesystemRoot = spineFilesystemRoot.replace(/^file:\/\//, '')
391
418
 
392
- const timing = timingParam
419
+ // Auto-create timing when ENCAPSULATE_TRACE env var is set
420
+ const timing: TimingObserverInterface | undefined = timingParam || (
421
+ process.env.ENCAPSULATE_TRACE ? TimingObserver() : undefined
422
+ )
393
423
 
394
424
  timing?.recordMajor('CAPSULE SPINE FACTORY: INITIALIZATION')
395
425
 
@@ -397,6 +427,7 @@ export async function CapsuleSpineFactory({
397
427
  const registry = new Map<string, Promise<any>>()
398
428
 
399
429
  return {
430
+ has(id: string) { return registry.has(id) },
400
431
  async ensure(id: string, createHandler: () => Promise<any>) {
401
432
  if (!registry.has(id)) {
402
433
  registry.set(id, createHandler())
@@ -419,6 +450,7 @@ export async function CapsuleSpineFactory({
419
450
  const sourceSpine: { encapsulate?: any } = {}
420
451
  const npmUriForFilepath = createNpmUriForFilepath()
421
452
  const commonSpineContractOpts = {
453
+ timing,
422
454
  spineFilesystemRoot,
423
455
  npmUriForFilepath,
424
456
  resolve: async (uri: string, parentFilepath: string) => {
@@ -430,24 +462,25 @@ export async function CapsuleSpineFactory({
430
462
  return await resolve(uri, parentFilepath, spineFilesystemRoot)
431
463
  },
432
464
  importCapsule: (() => {
433
- return async (filepath: string) => {
465
+ const importFn = async (filepath: string) => {
434
466
  const shortPath = filepath.replace(/^.*\/genesis\//, '')
435
467
 
436
468
  timing?.record(`importCapsule: Called for ${shortPath}`)
437
469
  const result = await registry.ensure(filepath, async () => {
438
470
  timing?.recordMajor(`importCapsule: Starting import for ${shortPath}`)
439
471
  const importStart = Date.now()
440
- const exports = await import(filepath)
472
+ if (!_moduleImportCache.has(filepath)) {
473
+ _moduleImportCache.set(filepath, import(filepath))
474
+ }
475
+ const exports = await _moduleImportCache.get(filepath)!
441
476
  const importDuration = Date.now() - importStart
442
477
  timing?.recordMajor(`importCapsule: import() took ${importDuration}ms for ${shortPath}`)
443
478
 
444
- if (importDuration > 10) {
445
- if (timing) {
446
- console.log(timing.chalk.red(`\n⚠️ WARNING: Slow module load detected!`))
447
- console.log(timing.chalk.red(` Module: ${filepath}`))
448
- console.log(timing.chalk.red(` Load time: ${importDuration}ms`))
449
- console.log(timing.chalk.red(` Consider using dynamic imports to load heavy dependencies only when needed.\n`))
450
- }
479
+ if (importDuration > 10 && timing) {
480
+ console.log(timing.chalk.red(`\n⚠️ WARNING: Slow module load detected!`))
481
+ console.log(timing.chalk.red(` Module: ${filepath}`))
482
+ console.log(timing.chalk.red(` Load time: ${importDuration}ms`))
483
+ console.log(timing.chalk.red(` Consider using dynamic imports to load heavy dependencies only when needed.\n`))
451
484
  }
452
485
 
453
486
  if (typeof exports.capsule !== 'function') throw new Error(`Module at '${filepath}' does not export 'capsule'!`)
@@ -467,6 +500,7 @@ export async function CapsuleSpineFactory({
467
500
  })
468
501
  return result
469
502
  }
503
+ return importFn
470
504
  })(),
471
505
  encapsulateOpts: {
472
506
  CapsulePropertyTypes
@@ -570,10 +604,7 @@ export async function CapsuleSpineFactory({
570
604
 
571
605
  timing?.recordMajor('SPINE: INITIALIZATION')
572
606
 
573
- let { encapsulate, freeze, capsules } = await Spine({
574
- spineFilesystemRoot,
575
- timing,
576
- staticAnalyzer: staticAnalysisEnabled ? StaticAnalyzer({
607
+ const staticAnalyzer = staticAnalysisEnabled ? StaticAnalyzer({
577
608
  timing,
578
609
  cacheStore: {
579
610
  writeFile: async (filepath: string, content: string) => {
@@ -581,6 +612,7 @@ export async function CapsuleSpineFactory({
581
612
  const centralPath = join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath)
582
613
  await mkdir(dirname(centralPath), { recursive: true })
583
614
  await writeFile(centralPath, content, 'utf-8')
615
+ invalidateStatCache(centralPath)
584
616
  // Also write to local project cache if available
585
617
  if (capsuleModuleProjectionRoot) {
586
618
  try {
@@ -604,29 +636,20 @@ export async function CapsuleSpineFactory({
604
636
  return content
605
637
  },
606
638
  getStats: async (filepath: string) => {
607
- filepath = join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath)
608
- try {
609
- const stats = await stat(filepath)
610
- return { mtime: stats.mtime }
611
- } catch (error) {
612
- // File doesn't exist
613
- return null
614
- }
639
+ return cachedStat(join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath))
615
640
  },
616
641
  },
617
642
  spineStore: {
618
643
  getStats: async (filepath: string) => {
619
- filepath = join(spineFilesystemRoot, filepath)
620
- try {
621
- const stats = await stat(filepath)
622
- return { mtime: stats.mtime }
623
- } catch (error) {
624
- // File doesn't exist
625
- return null
626
- }
644
+ return cachedStat(join(spineFilesystemRoot, filepath))
627
645
  },
628
646
  },
629
- }) : undefined,
647
+ }) : undefined
648
+
649
+ let { encapsulate, freeze, capsules } = await Spine({
650
+ spineFilesystemRoot,
651
+ timing,
652
+ staticAnalyzer,
630
653
  spineContracts: spineContractInstances.encapsulation,
631
654
  projectionContext: capsuleModuleProjectionRoot ? {
632
655
  capsuleModuleProjectionPackage,
@@ -682,10 +705,16 @@ export async function CapsuleSpineFactory({
682
705
  return capsule
683
706
  }
684
707
 
708
+ // Track capsule refs known at freeze time so we can identify dynamic imports later
709
+ let frozenCapsuleRefs: Set<string> | null = null
710
+
685
711
  // Wrap freeze to also write spine instance (.sit.json) files
686
712
  const wrappedFreeze = async function () {
687
713
  const snapshot = await freeze()
688
714
 
715
+ // Record capsule refs at freeze time (before any dynamic imports)
716
+ frozenCapsuleRefs = new Set(Object.keys(capsules))
717
+
689
718
  // Write spine instance files if capsuleModuleProjectionRoot is available
690
719
  if (capsuleModuleProjectionRoot) {
691
720
  try {
@@ -722,6 +751,10 @@ export async function CapsuleSpineFactory({
722
751
  }
723
752
  }
724
753
 
754
+ // Check if any CSTs were regenerated — if not, we can skip SIT writes
755
+ // when existing files are still valid
756
+ const cstsChanged = !staticAnalyzer || staticAnalyzer.hasCacheMisses()
757
+
725
758
  for (const [, capsule] of Object.entries(uniqueCapsules)) {
726
759
  const cst = capsule.cst
727
760
  const rootCapsuleName = cst?.source?.capsuleName
@@ -737,6 +770,16 @@ export async function CapsuleSpineFactory({
737
770
  const sitDir = join(capsuleModuleProjectionRoot, '.~o/encapsulate.dev/spine-instances', dirName)
738
771
  const sitFilePath = join(sitDir, `root-capsule.sit.json`)
739
772
 
773
+ // Skip SIT generation if CSTs are unchanged and the file already exists
774
+ if (!cstsChanged) {
775
+ try {
776
+ await access(sitFilePath)
777
+ continue // File exists and no CSTs changed — skip
778
+ } catch {
779
+ // File doesn't exist — need to generate even though CSTs are cached
780
+ }
781
+ }
782
+
740
783
  // Build the capsules map
741
784
  const capsuleEntries: Record<string, { capsuleSourceUriLineRef: string }> = {}
742
785
  for (const [, cap] of Object.entries(uniqueCapsules)) {
@@ -750,7 +793,7 @@ export async function CapsuleSpineFactory({
750
793
 
751
794
  // Collect capsuleInstances from the cached root instance using an
752
795
  // iterative stack — each instance stores its ID and parent ID from init
753
- const rootInstance = await capsule.makeInstance()
796
+ const rootInstance = await capsule.makeInstance({ freezePhase: true })
754
797
  const capsuleInstances: Record<string, { capsuleName: string, capsuleSourceUriLineRef: string, parentCapsuleSourceUriLineRefInstanceId: string }> = {}
755
798
 
756
799
  // Iterative stack-based collection from instance tree
@@ -790,8 +833,18 @@ export async function CapsuleSpineFactory({
790
833
  capsuleInstances
791
834
  }
792
835
 
836
+ const sitContent = JSON.stringify(sitData, null, 2)
837
+
838
+ // Compare-before-write: skip disk write if content is identical
839
+ try {
840
+ const existing = await readFile(sitFilePath, 'utf-8')
841
+ if (existing === sitContent) continue
842
+ } catch {
843
+ // File doesn't exist — write it
844
+ }
845
+
793
846
  await mkdir(sitDir, { recursive: true })
794
- await writeFile(sitFilePath, JSON.stringify(sitData, null, 2), 'utf-8')
847
+ await writeFile(sitFilePath, sitContent, 'utf-8')
795
848
  }
796
849
  } catch (error) {
797
850
  // Spine instance file writing is best-effort
@@ -802,6 +855,55 @@ export async function CapsuleSpineFactory({
802
855
  return snapshot
803
856
  }
804
857
 
858
+ /**
859
+ * Write a dynamic SIT file capturing capsules that were loaded at runtime
860
+ * via importCapsule (not part of the static capsule tree).
861
+ * Call this after run() to record which dynamic capsules were used.
862
+ * The filename includes a content hash so unchanged combos produce the same file.
863
+ */
864
+ const writeDynamicSit = async function () {
865
+ if (!capsuleModuleProjectionRoot || !frozenCapsuleRefs) return
866
+
867
+ // Find capsules that were added after freeze (dynamic imports)
868
+ const dynamicCapsuleRefs: string[] = []
869
+ for (const key of Object.keys(capsules)) {
870
+ if (!frozenCapsuleRefs.has(key) && key.includes(':') && /:\d+$/.test(key)) {
871
+ dynamicCapsuleRefs.push(key)
872
+ }
873
+ }
874
+
875
+ if (dynamicCapsuleRefs.length === 0) return
876
+
877
+ // Build a sorted list of dynamic capsule entries for deterministic output
878
+ const dynamicEntries: Record<string, { capsuleSourceUriLineRef: string, capsuleName?: string }> = {}
879
+ for (const ref of dynamicCapsuleRefs.sort()) {
880
+ const cap = capsules[ref]
881
+ dynamicEntries[ref] = {
882
+ capsuleSourceUriLineRef: ref,
883
+ capsuleName: cap?.cst?.source?.capsuleName
884
+ }
885
+ }
886
+
887
+ const sitData = { dynamicCapsules: dynamicEntries }
888
+ const sitContent = JSON.stringify(sitData, null, 2)
889
+
890
+ // Content hash for the filename — same dynamic import combo = same file
891
+ const contentHash = createHash('sha256').update(sitContent).digest('hex').slice(0, 16)
892
+ const sitDir = join(capsuleModuleProjectionRoot, '.~o/encapsulate.dev/spine-instances')
893
+ const sitFilePath = join(sitDir, `dynamic-imports.${contentHash}.sit.json`)
894
+
895
+ // Skip write if file already exists (hash match = identical content)
896
+ try {
897
+ await access(sitFilePath)
898
+ return // Already exists with same content
899
+ } catch {
900
+ // File doesn't exist — write it
901
+ }
902
+
903
+ await mkdir(sitDir, { recursive: true })
904
+ await writeFile(sitFilePath, sitContent, 'utf-8')
905
+ }
906
+
805
907
  return {
806
908
  commonSpineContractOpts,
807
909
  CapsulePropertyTypes,
@@ -809,7 +911,9 @@ export async function CapsuleSpineFactory({
809
911
  encapsulate,
810
912
  run,
811
913
  freeze: wrappedFreeze,
914
+ writeDynamicSit,
812
915
  loadCapsule,
916
+ staticAnalyzer,
813
917
  spineContractInstances, // Expose for testing
814
918
  hoistSnapshot: async ({ snapshot }: { snapshot: any }) => {
815
919
 
@@ -1,26 +1,36 @@
1
1
 
2
2
  import chalk from 'chalk';
3
3
 
4
- export function TimingObserver({ startTime }: { startTime: number }) {
5
- let lastTime = startTime
4
+ export type TimingOptions = { color?: 'red' | 'green' | 'yellow' | 'cyan' | 'gray' }
5
+
6
+ export type TimingObserverInterface = {
7
+ record: (step: string, opts?: TimingOptions) => void,
8
+ recordMajor: (step: string, opts?: TimingOptions) => void,
9
+ chalk: typeof chalk,
10
+ }
11
+
12
+ export function TimingObserver(): TimingObserverInterface {
13
+ let lastTime = performance.now()
14
+
15
+ function format(msg: string, diff: string, opts?: TimingOptions): string {
16
+ const line = `[+${diff}ms] ${msg}`
17
+ if (opts?.color) return (chalk as any)[opts.color](line)
18
+ return line
19
+ }
6
20
 
7
21
  return {
8
22
  chalk,
9
- record: (step: string) => {
10
- const now = Date.now()
11
- const diff = now - lastTime
23
+ record: (step: string, opts?: TimingOptions) => {
24
+ const now = performance.now()
25
+ const diff = (now - lastTime).toFixed(1)
12
26
  lastTime = now
13
-
14
- const line = `[+${diff}ms] ${step}`
15
- console.log(diff > 10 ? chalk.red(line) : line)
27
+ console.log(format(` ${step}`, diff, opts))
16
28
  },
17
- recordMajor: (step: string) => {
18
- const now = Date.now()
19
- const diff = now - lastTime
29
+ recordMajor: (step: string, opts?: TimingOptions) => {
30
+ const now = performance.now()
31
+ const diff = (now - lastTime).toFixed(1)
20
32
  lastTime = now
21
-
22
- const line = `[+${diff}ms] ${step}`
23
- console.log(diff > 10 ? chalk.red(line) : chalk.cyan(line))
33
+ console.log(format(`★ ${step}`, diff, opts))
24
34
  }
25
35
  }
26
36
  }
@@ -3,6 +3,7 @@ import { join, normalize, dirname, resolve, relative } from 'path'
3
3
  import { readFile, stat } from 'fs/promises'
4
4
  import * as ts from 'typescript'
5
5
  import { createHash } from 'crypto'
6
+ import type { TimingObserverInterface } from './spine-factories/TimingObserver'
6
7
 
7
8
  // Known exports from @stream44.studio/encapsulate/encapsulate that can be imported
8
9
  const ENCAPSULATE_MODULE_EXPORTS = new Set([
@@ -227,7 +228,7 @@ export function StaticAnalyzer({
227
228
  cacheStore,
228
229
  spineStore
229
230
  }: {
230
- timing?: { record: (step: string) => void, chalk?: any },
231
+ timing?: TimingObserverInterface,
231
232
  cacheStore?: {
232
233
  writeFile?: (filepath: string, content: string) => Promise<void>,
233
234
  readFile?: (filepath: string) => Promise<string | undefined>,
@@ -240,8 +241,16 @@ export function StaticAnalyzer({
240
241
 
241
242
  timing?.record('StaticAnalyzer: Initialized')
242
243
 
244
+ let _cacheMissCount = 0
245
+
243
246
  return {
244
247
 
248
+ /** Returns true if any parseModule call resulted in a cache miss (CST regeneration) */
249
+ hasCacheMisses: () => _cacheMissCount > 0,
250
+
251
+ /** Reset the cache miss counter (call before a new freeze cycle) */
252
+ resetCacheMisses: () => { _cacheMissCount = 0 },
253
+
245
254
  parseModule: async ({ spineOptions, encapsulateOptions }: { spineOptions: any, encapsulateOptions: any }) => {
246
255
 
247
256
  const moduleFilepath = join(spineOptions.spineFilesystemRoot, encapsulateOptions.moduleFilepath)
@@ -282,7 +291,8 @@ export function StaticAnalyzer({
282
291
  // Check cache bust version - if mismatch, regenerate
283
292
  const cachedVersion = cachedCsts?.[capsuleSourceLineRef]?.cacheBustVersion
284
293
  if (encapsulateOptions.cacheBustVersion !== undefined && cachedVersion !== encapsulateOptions.cacheBustVersion) {
285
- timing?.record(timing?.chalk?.red?.(`StaticAnalyzer: Cache BUST (version mismatch: ${cachedVersion} !== ${encapsulateOptions.cacheBustVersion}) for ${encapsulateOptions.moduleFilepath}`))
294
+ _cacheMissCount++
295
+ timing?.record(`StaticAnalyzer: Cache BUST (version mismatch: ${cachedVersion} !== ${encapsulateOptions.cacheBustVersion}) for ${encapsulateOptions.moduleFilepath}`, { color: 'red' })
286
296
  } else {
287
297
  timing?.record(`StaticAnalyzer: Cache HIT for ${encapsulateOptions.moduleFilepath}`)
288
298
  return {
@@ -293,10 +303,12 @@ export function StaticAnalyzer({
293
303
  }
294
304
  }
295
305
  }
296
- timing?.record(timing?.chalk?.red?.(`StaticAnalyzer: Cache MISS for ${encapsulateOptions.moduleFilepath}`))
306
+ _cacheMissCount++
307
+ timing?.record(`StaticAnalyzer: Cache MISS for ${encapsulateOptions.moduleFilepath}`, { color: 'red' })
297
308
  } catch (error) {
298
309
  // Cache miss or error, continue with normal parsing
299
- timing?.record(timing?.chalk?.red?.(`StaticAnalyzer: Cache error for ${encapsulateOptions.moduleFilepath}`))
310
+ _cacheMissCount++
311
+ timing?.record(`StaticAnalyzer: Cache error for ${encapsulateOptions.moduleFilepath}`, { color: 'red' })
300
312
  }
301
313
  }
302
314
 
@@ -11,6 +11,11 @@ export async function capsule({
11
11
  type: CapsulePropertyTypes.Literal,
12
12
  value: undefined
13
13
  },
14
+
15
+ spineFilesystemRoot: {
16
+ type: CapsulePropertyTypes.Literal,
17
+ value: undefined as string | undefined
18
+ },
14
19
  }
15
20
  }
16
21
  }, {