@stream44.studio/encapsulate 0.4.0-rc.27 → 0.4.0-rc.29

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  <td><a href="https://Stream44.Studio"><img src=".o/stream44.studio/assets/Icon-v1.svg" width="42" height="42"></a></td>
4
4
  <td><strong><a href="https://Stream44.Studio">Stream44 Studio</a></strong><br/>Open Development Project</td>
5
5
  <td>Preview release for community feedback.<br/>Get in touch on <a href="https://discord.gg/9eBcQXEJAN">discord</a>.</td>
6
- <td>Hand Designed<br/><b>AI Coded Alpha</a></td>
6
+ <td>Designed by Hand<br/><b>AI assisted Code</a></td>
7
7
  </tr>
8
8
  </table>
9
9
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream44.studio/encapsulate",
3
- "version": "0.4.0-rc.27",
3
+ "version": "0.4.0-rc.29",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -174,7 +174,7 @@ export function CapsuleModuleProjector({
174
174
  for (const [key, potentialCapsule] of Object.entries(capsules)) {
175
175
  if (potentialCapsule === capsule) continue
176
176
 
177
- const mappedModulePath = potentialCapsule.cst?.source?.moduleFilepath
177
+ const mappedModulePath = potentialCapsule.cst?.source?.moduleUri
178
178
 
179
179
  if (mappedModulePath && (
180
180
  mappedModulePath === mappingValue ||
@@ -224,7 +224,7 @@ export function CapsuleModuleProjector({
224
224
  for (const [key, potentialCapsule] of Object.entries(capsules)) {
225
225
  if (potentialCapsule === capsule) continue
226
226
 
227
- const mappedModulePath = potentialCapsule.cst?.source?.moduleFilepath
227
+ const mappedModulePath = potentialCapsule.cst?.source?.moduleUri
228
228
 
229
229
  if (mappedModulePath && (
230
230
  mappedModulePath === mappingValue ||
@@ -515,7 +515,7 @@ export function CapsuleModuleProjector({
515
515
  projectingCapsules.add(capsuleId)
516
516
  }
517
517
 
518
- timing?.record(`Projector: Start projection for ${capsule.cst.source.moduleFilepath}`)
518
+ timing?.record(`Projector: Start projection for ${capsule.cst.source.moduleUri || capsule.cst.source.moduleFilepath}`)
519
519
 
520
520
  // Only project capsules that have the Capsule struct property
521
521
  const spineContract = capsule.cst.spineContracts[spineContractUri]
@@ -571,10 +571,10 @@ export function CapsuleModuleProjector({
571
571
  if (allProjectedFilesExist) {
572
572
  // Restore snapshotValues from cache
573
573
  Object.assign(snapshotValues, merge(snapshotValues, cachedData.snapshotData))
574
- timing?.record(`Projector: Cache HIT for ${capsule.cst.source.moduleFilepath}`)
574
+ timing?.record(`Projector: Cache HIT for ${capsule.cst.source.moduleUri || capsule.cst.source.moduleFilepath}`)
575
575
  return true
576
576
  } else {
577
- timing?.record(timing?.chalk?.yellow?.(`Projector: Cache INVALID (projected files missing) for ${capsule.cst.source.moduleFilepath}`))
577
+ timing?.record(timing?.chalk?.yellow?.(`Projector: Cache INVALID (projected files missing) for ${capsule.cst.source.moduleUri || capsule.cst.source.moduleFilepath}`))
578
578
  }
579
579
  }
580
580
  }
@@ -588,7 +588,7 @@ export function CapsuleModuleProjector({
588
588
 
589
589
  // Check if this capsule has the Capsule struct (meaning it should be projected)
590
590
  if (potentialMappedCapsule.cst?.spineContracts?.[spineContractUri]?.propertyContracts?.['#@stream44.studio/encapsulate/structs/Capsule']) {
591
- // Check if this capsule's moduleFilepath is referenced in any mapping property
591
+ // Check if this capsule's moduleUri is referenced in any mapping property
592
592
  const mappedModulePath = potentialMappedCapsule.cst.source.moduleFilepath
593
593
 
594
594
  for (const [propContractKey, propContract] of Object.entries(spineContract.propertyContracts)) {
@@ -612,7 +612,7 @@ export function CapsuleModuleProjector({
612
612
  }
613
613
  }
614
614
  }
615
- timing?.record(timing?.chalk?.red?.(`Projector: Cache MISS for ${capsule.cst.source.moduleFilepath}`))
615
+ timing?.record(timing?.chalk?.red?.(`Projector: Cache MISS for ${capsule.cst.source.moduleUri || capsule.cst.source.moduleFilepath}`))
616
616
  } catch (error) {
617
617
  // Cache miss or error, proceed with projection
618
618
  }
@@ -633,13 +633,13 @@ export function CapsuleModuleProjector({
633
633
 
634
634
  const cstJson = JSON.stringify(targetCapsule.cst, null, 4)
635
635
  const crtJson = JSON.stringify(targetCapsule.crt || {}, null, 4)
636
- const moduleFilepath = targetCapsule.cst.source.moduleFilepath
636
+ const moduleUri = targetCapsule.cst.source.moduleUri
637
637
  const importStackLine = targetCapsule.cst.source.importStackLine
638
638
 
639
- // Replace importMeta: import.meta with moduleFilepath: '...'
639
+ // Replace importMeta: import.meta with moduleFilepath: '...' (using npm URI)
640
640
  expression = expression.replace(
641
641
  /importMeta:\s*import\.meta/g,
642
- `moduleFilepath: '${moduleFilepath}'`
642
+ `moduleFilepath: '${moduleUri}'`
643
643
  )
644
644
 
645
645
  // Replace importStack: makeImportStack() with importStackLine: ..., crt: {...}, cst: {...}
@@ -1662,7 +1662,7 @@ ${mappedDefaultExport}
1662
1662
  await projectionCacheStore.writeFile(cacheFilename, JSON.stringify(cacheData, null, 2))
1663
1663
  } catch (error) {
1664
1664
  // Cache write error, continue without failing
1665
- console.warn(`Warning: Failed to write projection cache for ${capsule.cst.source.moduleFilepath}:`, error)
1665
+ console.warn(`Warning: Failed to write projection cache for ${capsule.cst.source.moduleUri || capsule.cst.source.moduleFilepath}:`, error)
1666
1666
  }
1667
1667
  }
1668
1668
 
@@ -1749,7 +1749,7 @@ capsule['#'] = ${JSON.stringify(capsuleName)}
1749
1749
  await projectionStore.writeFile(projectedPath, capsuleFileContent)
1750
1750
  }
1751
1751
  } catch (error) {
1752
- console.warn(`Warning: Failed to write projection cache for capsule ${registryCapsule.cst.source.moduleFilepath}:`, error)
1752
+ console.warn(`Warning: Failed to write projection cache for capsule ${registryCapsule.cst.source.moduleUri || registryCapsule.cst.source.moduleFilepath}:`, error)
1753
1753
  }
1754
1754
  }
1755
1755
  }
@@ -1,7 +1,7 @@
1
1
 
2
2
  // CACHE_BUST_VERSION: Increment this whenever CST cache must be invalidated due to structural changes
3
3
  // This ensures projected capsules are regenerated when the CST format changes
4
- const CACHE_BUST_VERSION = 21
4
+ const CACHE_BUST_VERSION = 22
5
5
 
6
6
  type TSpineOptions = {
7
7
  spineFilesystemRoot?: string,
@@ -505,21 +505,30 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
505
505
 
506
506
  if (typeof importStackLine !== 'number') throw new Error(`Could not determine importStackLine from options`)
507
507
 
508
- const capsuleSourceLineRef = `${moduleFilepath}:${importStackLine}`
508
+ // Temporary filesystem-based ref used only for passing to static analyzer
509
+ const fsBasedRef = `${moduleFilepath}:${importStackLine}`
509
510
 
510
511
  spine.spineOptions.timing?.record(`Encapsulate: Start for ${moduleFilepath}`)
511
512
 
512
- const { csts, crts } = await spine.spineOptions.staticAnalyzer?.parseModule({
513
+ const parseResult = await spine.spineOptions.staticAnalyzer?.parseModule({
513
514
  spineOptions: spine.spineOptions,
514
515
  encapsulateOptions: {
515
516
  moduleFilepath,
516
517
  importStackLine,
517
- capsuleSourceLineRef,
518
+ capsuleSourceLineRef: fsBasedRef,
518
519
  capsuleName: options.capsuleName,
519
520
  ambientReferences: options.ambientReferences,
520
521
  cacheBustVersion: CACHE_BUST_VERSION
521
522
  }
522
- }) || {
523
+ })
524
+
525
+ // Use moduleUri from static analyzer for npm URI-based capsuleSourceLineRef
526
+ const moduleUri = parseResult?.moduleUri
527
+ const capsuleSourceLineRef = moduleUri
528
+ ? `${moduleUri}:${importStackLine}`
529
+ : fsBasedRef
530
+
531
+ const { csts, crts } = parseResult || {
523
532
  csts: options.cst ? { [capsuleSourceLineRef]: options.cst } : undefined,
524
533
  crts: options.crt ? { [capsuleSourceLineRef]: options.crt } : undefined
525
534
  }
@@ -140,11 +140,16 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
140
140
 
141
141
  // delegateOptions is set by encapsulate.ts for property contract delegates
142
142
  // options can be a function or an object for regular mappings
143
- // Always pass { self, constants } - self is populated when depends is specified, empty otherwise
143
+ // Always pass { self, constants } - self contains full parent self when depends is specified,
144
+ // otherwise just the Capsule metadata struct (moduleFilepath, capsuleName, etc.)
144
145
  const optionsFn = property.definition.options
146
+ const capsuleStructKey = '#@stream44.studio/encapsulate/structs/Capsule'
147
+ const minimalSelf = this.self[capsuleStructKey]
148
+ ? { [capsuleStructKey]: this.self[capsuleStructKey] }
149
+ : {}
145
150
  const mappingOptions = property.definition.delegateOptions
146
151
  || (typeof optionsFn === 'function'
147
- ? await optionsFn({ self: property.definition.depends ? this.self : {}, constants })
152
+ ? await optionsFn({ self: property.definition.depends ? this.self : minimalSelf, constants })
148
153
  : optionsFn)
149
154
 
150
155
  // Check for existing instance in registry - reuse if available (regardless of options)
@@ -417,6 +422,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
417
422
  const event: any = {
418
423
  event: 'get',
419
424
  eventIndex: this.incrementEventIndex(),
425
+ membrane: 'external',
420
426
  target: {
421
427
  capsuleSourceLineRef: this.encapsulateOptions.capsuleSourceLineRef,
422
428
  spineContractCapsuleInstanceId: this.id,
@@ -445,6 +451,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
445
451
  const event: any = {
446
452
  event: 'set',
447
453
  eventIndex: this.incrementEventIndex(),
454
+ membrane: 'external',
448
455
  target: {
449
456
  capsuleSourceLineRef: this.encapsulateOptions.capsuleSourceLineRef,
450
457
  spineContractCapsuleInstanceId: this.id,
@@ -511,6 +518,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
511
518
  const callEvent: any = {
512
519
  event: 'call',
513
520
  eventIndex: this.incrementEventIndex(),
521
+ membrane: 'external',
514
522
  target: {
515
523
  capsuleSourceLineRef: this.encapsulateOptions.capsuleSourceLineRef,
516
524
  spineContractCapsuleInstanceId: this.id,
@@ -536,6 +544,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
536
544
  const resultEvent: any = {
537
545
  event: 'call-result',
538
546
  eventIndex: this.incrementEventIndex(),
547
+ membrane: 'external',
539
548
  callEventIndex: callEvent.eventIndex,
540
549
  target: {
541
550
  spineContractCapsuleInstanceId: this.id,
@@ -552,6 +561,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
552
561
  const callEvent: any = {
553
562
  event: 'call',
554
563
  eventIndex: this.incrementEventIndex(),
564
+ membrane: 'external',
555
565
  target: {
556
566
  capsuleSourceLineRef: this.encapsulateOptions.capsuleSourceLineRef,
557
567
  spineContractCapsuleInstanceId: this.id,
@@ -588,6 +598,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
588
598
  const resultEvent: any = {
589
599
  event: 'call-result',
590
600
  eventIndex: this.incrementEventIndex(),
601
+ membrane: 'external',
591
602
  callEventIndex: callEvent.eventIndex,
592
603
  target: {
593
604
  spineContractCapsuleInstanceId: this.id,
@@ -637,6 +648,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
637
648
  const event: any = {
638
649
  event: 'get',
639
650
  eventIndex: this.incrementEventIndex(),
651
+ membrane: 'external',
640
652
  target: {
641
653
  capsuleSourceLineRef: this.encapsulateOptions.capsuleSourceLineRef,
642
654
  spineContractCapsuleInstanceId: this.id,
@@ -673,6 +685,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
673
685
  const event: any = {
674
686
  event: 'get',
675
687
  eventIndex: this.incrementEventIndex(),
688
+ membrane: 'external',
676
689
  target: {
677
690
  capsuleSourceLineRef: this.encapsulateOptions.capsuleSourceLineRef,
678
691
  spineContractCapsuleInstanceId: this.id,
@@ -718,6 +731,134 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
718
731
  }
719
732
  }
720
733
 
734
+ protected override createSelfProxy() {
735
+ const extendedApi = this.extendedCapsuleInstance?.api
736
+ const ownSelf = this.ownSelf
737
+ const factory = this
738
+ return new Proxy(this.self, {
739
+ get: (target: any, prop: string | symbol) => {
740
+ if (typeof prop === 'symbol') return target[prop]
741
+
742
+ // 'self' property returns ownSelf (only this capsule's own properties)
743
+ if (prop === 'self' && ownSelf) {
744
+ return ownSelf
745
+ }
746
+
747
+ // Determine the value source and get the value
748
+ let value: any
749
+ let source: 'self' | 'encapsulatedApi' | 'childApi' | 'extendedApi' | undefined
750
+
751
+ if (prop in target) {
752
+ value = target[prop]
753
+ source = 'self'
754
+ } else if (prop in factory.encapsulatedApi) {
755
+ value = factory.encapsulatedApi[prop]
756
+ source = 'encapsulatedApi'
757
+ } else if (factory.childEncapsulatedApis) {
758
+ for (const childApi of factory.childEncapsulatedApis) {
759
+ if (prop in childApi) {
760
+ value = childApi[prop]
761
+ source = 'childApi'
762
+ break
763
+ }
764
+ }
765
+ }
766
+
767
+ if (source === undefined && extendedApi && prop in extendedApi) {
768
+ value = extendedApi[prop]
769
+ source = 'extendedApi'
770
+ }
771
+
772
+ // Only emit internal events if we're inside a function/getter execution (caller context is set)
773
+ // and the property is not a function (we don't want to emit get events for function references)
774
+ if (source && typeof value !== 'function' && this.getCurrentCallerContext()) {
775
+ const event: any = {
776
+ event: 'get',
777
+ eventIndex: this.incrementEventIndex(),
778
+ membrane: 'internal',
779
+ target: {
780
+ capsuleSourceLineRef: this.encapsulateOptions.capsuleSourceLineRef,
781
+ spineContractCapsuleInstanceId: this.id,
782
+ prop: prop as string,
783
+ },
784
+ value
785
+ }
786
+
787
+ if (this.capsuleSourceNameRef) {
788
+ event.target.capsuleSourceNameRef = this.capsuleSourceNameRef
789
+ }
790
+ if (this.capsuleSourceNameRefHash) {
791
+ event.target.capsuleSourceNameRefHash = this.capsuleSourceNameRefHash
792
+ }
793
+
794
+ this.addCallerContextToEvent(event)
795
+ this.onMembraneEvent?.(event)
796
+ }
797
+
798
+ return value
799
+ },
800
+ ownKeys: (target: any) => {
801
+ const keys = new Set<string>(Object.keys(target))
802
+ for (const k of Object.keys(factory.encapsulatedApi)) keys.add(k)
803
+ if (factory.childEncapsulatedApis) {
804
+ for (const childApi of factory.childEncapsulatedApis) {
805
+ for (const k of Object.keys(childApi)) keys.add(k)
806
+ }
807
+ }
808
+ if (extendedApi) {
809
+ for (const k of Object.keys(extendedApi)) keys.add(k)
810
+ }
811
+ return [...keys]
812
+ },
813
+ set: (target: any, prop: string | symbol, value: any) => {
814
+ if (typeof prop === 'symbol') {
815
+ target[prop] = value
816
+ return true
817
+ }
818
+
819
+ // Emit internal set event if we're inside a function/getter execution
820
+ if (this.getCurrentCallerContext()) {
821
+ const event: any = {
822
+ event: 'set',
823
+ eventIndex: this.incrementEventIndex(),
824
+ membrane: 'internal',
825
+ target: {
826
+ capsuleSourceLineRef: this.encapsulateOptions.capsuleSourceLineRef,
827
+ spineContractCapsuleInstanceId: this.id,
828
+ prop: prop as string,
829
+ },
830
+ value
831
+ }
832
+
833
+ if (this.capsuleSourceNameRef) {
834
+ event.target.capsuleSourceNameRef = this.capsuleSourceNameRef
835
+ }
836
+ if (this.capsuleSourceNameRefHash) {
837
+ event.target.capsuleSourceNameRefHash = this.capsuleSourceNameRefHash
838
+ }
839
+
840
+ this.addCallerContextToEvent(event)
841
+ this.onMembraneEvent?.(event)
842
+ }
843
+
844
+ target[prop] = value
845
+ return true
846
+ },
847
+ getOwnPropertyDescriptor: (target: any, prop: string | symbol) => {
848
+ if (typeof prop === 'symbol') return Object.getOwnPropertyDescriptor(target, prop)
849
+ if (prop in target) return Object.getOwnPropertyDescriptor(target, prop)
850
+ if (prop in factory.encapsulatedApi) return { configurable: true, enumerable: true, writable: true, value: factory.encapsulatedApi[prop as string] }
851
+ if (factory.childEncapsulatedApis) {
852
+ for (const childApi of factory.childEncapsulatedApis) {
853
+ if (prop in childApi) return { configurable: true, enumerable: true, writable: true, value: childApi[prop as string] }
854
+ }
855
+ }
856
+ if (extendedApi && prop in extendedApi) return { configurable: true, enumerable: true, writable: true, value: extendedApi[prop as string] }
857
+ return undefined
858
+ }
859
+ })
860
+ }
861
+
721
862
  private addCallerContextToEvent(event: any): void {
722
863
  const callerCtx = this.getCurrentCallerContext()
723
864
  if (callerCtx) {
@@ -782,6 +923,20 @@ export function CapsuleSpineContract({
782
923
  let currentCallerContext: CallerContext | undefined = undefined
783
924
  const instanceRegistry: CapsuleInstanceRegistry = new Map()
784
925
 
926
+ // Re-entrancy guard: suppress event emission while inside an onMembraneEvent callback.
927
+ // This prevents consumers (e.g. JSON.stringify on event.value) from triggering proxy getters
928
+ // that would cause spurious recursive membrane events with wrong caller context and ordering.
929
+ let isEmittingEvent = false
930
+ const guardedOnMembraneEvent = onMembraneEvent ? (event: any) => {
931
+ if (isEmittingEvent) return
932
+ isEmittingEvent = true
933
+ try {
934
+ onMembraneEvent(event)
935
+ } finally {
936
+ isEmittingEvent = false
937
+ }
938
+ } : undefined
939
+
785
940
  return {
786
941
  '#': CapsuleSpineContract['#'],
787
942
  instanceRegistry,
@@ -796,7 +951,7 @@ export function CapsuleSpineContract({
796
951
  freezeCapsule,
797
952
  resolve,
798
953
  importCapsule,
799
- onMembraneEvent,
954
+ onMembraneEvent: guardedOnMembraneEvent,
800
955
  enableCallerStackInference,
801
956
  encapsulateOptions,
802
957
  getEventIndex: () => eventIndex,
@@ -308,8 +308,8 @@ prop: {
308
308
  - **`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).
309
309
  - **`options({ self, constants })`** — when `options` is a function, it receives `{ self, constants }`.
310
310
  - `constants` — all `Literal`/`String` values from the mapped capsule's definition.
311
- - `self` — the parent capsule's `self` object with resolved sibling mappings. Only populated when `depends` is specified (empty `{}` otherwise).
312
- - **`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`) and the Capsule metadata struct (e.g. `self['#@stream44.studio/encapsulate/structs/Capsule'].capsuleName`). Can be declared explicitly or auto-injected by the static analyzer when it detects `self.<name>` references in the options function body.
311
+ - `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.
312
+ - **`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.
313
313
  - **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.
314
314
 
315
315
  Mapped capsules are accessible via `this.<prop>` (unwrapped API) and `api.<prop>` (raw instance with `.api`).
@@ -129,8 +129,9 @@ export class ContractCapsuleInstanceFactory {
129
129
  if (!this.spineFilesystemRoot) throw new Error(`'spineFilesystemRoot' not set!`)
130
130
  if (!this.importCapsule) throw new Error(`'importCapsule' not set!`)
131
131
 
132
- // Use encapsulateOptions.moduleFilepath (always available) instead of cst.source.moduleFilepath
133
- const moduleFilepath = this.capsule.encapsulateOptions?.moduleFilepath || this.capsule.cst?.source?.moduleFilepath
132
+ // Use cst.source.moduleFilepath (always filesystem-relative) for path resolution.
133
+ // encapsulateOptions.moduleFilepath may be an npm URI when loaded from projected files.
134
+ const moduleFilepath = this.capsule.cst?.source?.moduleFilepath || this.capsule.encapsulateOptions?.moduleFilepath
134
135
  if (!moduleFilepath) throw new Error(`'moduleFilepath' not available on capsule!`)
135
136
 
136
137
  const parentPath = join(this.spineFilesystemRoot, moduleFilepath)
@@ -197,11 +198,16 @@ export class ContractCapsuleInstanceFactory {
197
198
 
198
199
  // delegateOptions is set by encapsulate.ts for property contract delegates
199
200
  // options can be a function or an object for regular mappings
200
- // Always pass { self, constants } - self is populated when depends is specified, empty otherwise
201
+ // Always pass { self, constants } - self contains full parent self when depends is specified,
202
+ // otherwise just the Capsule metadata struct (moduleFilepath, capsuleName, etc.)
201
203
  const optionsFn = property.definition.options
204
+ const capsuleStructKey = '#@stream44.studio/encapsulate/structs/Capsule'
205
+ const minimalSelf = this.self[capsuleStructKey]
206
+ ? { [capsuleStructKey]: this.self[capsuleStructKey] }
207
+ : {}
202
208
  const mappingOptions = property.definition.delegateOptions
203
209
  || (typeof optionsFn === 'function'
204
- ? await optionsFn({ self: property.definition.depends ? this.self : {}, constants })
210
+ ? await optionsFn({ self: property.definition.depends ? this.self : minimalSelf, constants })
205
211
  : optionsFn)
206
212
 
207
213
  // Check for existing instance in registry - reuse if available when no options
@@ -244,25 +244,13 @@ export function StaticAnalyzer({
244
244
 
245
245
  const moduleFilepath = join(spineOptions.spineFilesystemRoot, encapsulateOptions.moduleFilepath)
246
246
 
247
- // Determine the cache file path based on whether the module is external or internal
248
- let cacheFilePath: string
249
- const isExternal = encapsulateOptions.moduleFilepath.startsWith('../')
250
- const hasNodeModules = encapsulateOptions.moduleFilepath.includes('node_modules/')
251
-
252
- if (isExternal || hasNodeModules) {
253
- // External module or node_modules path - construct npm URI
254
- const npmUri = await constructNpmUri(moduleFilepath, spineOptions.spineFilesystemRoot)
255
- if (npmUri) {
256
- // Prefix with o/npmjs.com/node_modules/ for external modules
257
- cacheFilePath = `o/npmjs.com/node_modules/${npmUri}`
258
- } else {
259
- // Fallback to normalized path if npm URI construction fails
260
- cacheFilePath = normalize(encapsulateOptions.moduleFilepath).replace(/^\.\.\//, '').replace(/\.\.\//g, '')
261
- }
262
- } else {
263
- // Internal module - use relative path as-is
264
- cacheFilePath = encapsulateOptions.moduleFilepath
265
- }
247
+ // Construct npm URI for the module upfront used for cache paths and CST keys
248
+ const rawModuleUri: string = await constructNpmUri(moduleFilepath, spineOptions.spineFilesystemRoot) || encapsulateOptions.moduleFilepath
249
+ // Strip file extension from URI
250
+ const moduleUriWithoutExt = rawModuleUri.replace(/\.(ts|tsx|js|jsx)$/, '')
251
+
252
+ // Cache file path always uses npm URI (never filesystem-relative paths)
253
+ const cacheFilePath = moduleUriWithoutExt
266
254
 
267
255
  const capsuleSourceLineRef = `${cacheFilePath}:${encapsulateOptions.importStackLine}`
268
256
 
@@ -297,7 +285,8 @@ export function StaticAnalyzer({
297
285
  timing?.record(`StaticAnalyzer: Cache HIT for ${encapsulateOptions.moduleFilepath}`)
298
286
  return {
299
287
  csts: cachedCsts,
300
- crts: JSON.parse(crtsContent)
288
+ crts: JSON.parse(crtsContent),
289
+ moduleUri: moduleUriWithoutExt
301
290
  }
302
291
  }
303
292
  }
@@ -369,24 +358,12 @@ export function StaticAnalyzer({
369
358
  continue
370
359
  }
371
360
 
372
- const capsuleSourceLineRef = `${encapsulateOptions.moduleFilepath}:${encapsulateOptions.importStackLine}`
373
- const capsuleSourceNameRef = encapsulateOptions.capsuleName && `${encapsulateOptions.moduleFilepath}:${encapsulateOptions.capsuleName}`
361
+ // Use npm URI for all CST references (never filesystem-relative paths)
362
+ const capsuleSourceLineRef = `${moduleUriWithoutExt}:${encapsulateOptions.importStackLine}`
363
+ const capsuleSourceNameRef = encapsulateOptions.capsuleName && `${moduleUriWithoutExt}:${encapsulateOptions.capsuleName}`
374
364
  const capsuleSourceNameRefHash = capsuleSourceNameRef && createHash('sha256').update(capsuleSourceNameRef).digest('hex')
375
365
 
376
- // Construct npm URI for the module - try for all modules
377
- let moduleUri: string | null = await constructNpmUri(moduleFilepath, spineOptions.spineFilesystemRoot)
378
-
379
- // If npm URI construction failed, fall back to moduleFilepath
380
- if (!moduleUri) {
381
- moduleUri = encapsulateOptions.moduleFilepath
382
- }
383
-
384
- // Strip file extension from URI
385
- const moduleUriWithoutExt = moduleUri.replace(/\.(ts|tsx|js|jsx)$/, '')
386
- const capsuleSourceUriLineRef = `${moduleUriWithoutExt}:${encapsulateOptions.importStackLine}`
387
-
388
- // Store moduleUri without extension
389
- moduleUri = moduleUriWithoutExt
366
+ const capsuleSourceUriLineRef = capsuleSourceLineRef
390
367
 
391
368
  // Extract the capsule expression text from the source
392
369
  const capsuleExpression = call.getText(sourceFile)
@@ -407,7 +384,7 @@ export function StaticAnalyzer({
407
384
  capsuleSourceUriLineRef,
408
385
  source: {
409
386
  moduleFilepath: encapsulateOptions.moduleFilepath,
410
- moduleUri,
387
+ moduleUri: moduleUriWithoutExt,
411
388
  capsuleName: encapsulateOptions.capsuleName,
412
389
  declarationLine,
413
390
  importStackLine: encapsulateOptions.importStackLine,
@@ -760,6 +737,7 @@ export function StaticAnalyzer({
760
737
  return {
761
738
  csts,
762
739
  crts,
740
+ moduleUri: moduleUriWithoutExt
763
741
  }
764
742
  }
765
743
  }