@stream44.studio/encapsulate 0.4.0-rc.11 → 0.4.0-rc.12

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/.dockerignore ADDED
@@ -0,0 +1,3 @@
1
+ node_modules
2
+ .o
3
+ .DS_Store
@@ -1,4 +1,4 @@
1
- name: Run Tests
1
+ name: Tests
2
2
 
3
3
  on:
4
4
  push:
package/Dockerfile ADDED
@@ -0,0 +1,15 @@
1
+ FROM oven/bun:1
2
+
3
+ WORKDIR /app
4
+
5
+ # Copy package files
6
+ COPY package.json bun.lock* ./
7
+
8
+ # Install dependencies
9
+ RUN bun install
10
+
11
+ # Copy source code
12
+ COPY . .
13
+
14
+ # Run tests
15
+ CMD ["bun", "test"]
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  ⚠️ **Disclaimer:** Under active development. Code has not been audited, APIs and interfaces are subject to change.
6
6
 
7
+ [![Tests](https://github.com/Stream44/encapsulate/actions/workflows/tests.yaml/badge.svg)](https://github.com/Stream44/encapsulate/actions/workflows/tests.yaml?query=branch%3Amain)
8
+
7
9
  encapsulate
8
10
  ===
9
11
 
@@ -27,6 +29,12 @@ Notes
27
29
  - The first spine contract is defined and implemented here: [src/spine-contracts/CapsuleSpineContract.v0/](src/spine-contracts/CapsuleSpineContract.v0/)
28
30
  - Projector reference implementations are here: [github.com/Stream44/ink-component-projector](https://github.com/Stream44/ink-component-projector)
29
31
 
32
+ Roadmap
33
+ ---
34
+
35
+ - [ ] Document [src/spine-contracts/CapsuleSpineContract.v0/](src/spine-contracts/CapsuleSpineContract.v0/)
36
+ - [ ] Private properties
37
+
30
38
 
31
39
  Provenance
32
40
  ===
@@ -45,4 +53,4 @@ Repository DID: `did:repo:412cd408c657737343a6089ed6d5f0668a8bfb05`
45
53
  </tr>
46
54
  </table>
47
55
 
48
- (c) 2026 [Christoph.diy](https://christoph.diy) • Code: `MIT` • Text: `CC-BY` • Created with [Stream44.Studio](https://Stream44.Studio)
56
+ (c) 2026 [Christoph.diy](https://christoph.diy) • Code: `BSD-2-Clause-Patent` • Text: `CC-BY` • Created with [Stream44.Studio](https://Stream44.Studio)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stream44.studio/encapsulate",
3
- "version": "0.4.0-rc.11",
4
- "license": "MIT",
3
+ "version": "0.4.0-rc.12",
4
+ "license": "BSD-2-Clause-Patent",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/Stream44/encapsulate.git"
@@ -17,7 +17,8 @@
17
17
  "./structs/Capsule": "./structs/Capsule.ts"
18
18
  },
19
19
  "scripts": {
20
- "test": "bun test"
20
+ "test": "bun test",
21
+ "test:docker": "./scripts/test-docker.sh"
21
22
  },
22
23
  "dependencies": {
23
24
  "typescript": "^5.9.3",
@@ -0,0 +1,17 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ PACKAGE_DIR="$(dirname "$SCRIPT_DIR")"
6
+
7
+ cd "$PACKAGE_DIR"
8
+
9
+ IMAGE_NAME="encapsulate-test"
10
+
11
+ echo "Building Docker image..."
12
+ docker build -t "$IMAGE_NAME" .
13
+
14
+ echo "Running tests in Docker..."
15
+ docker run --rm "$IMAGE_NAME"
16
+
17
+ echo "Tests completed successfully!"
@@ -457,24 +457,22 @@ export function CapsuleModuleProjector({
457
457
  if (capsule.cst.source.capsuleName) {
458
458
  // Use capsuleName as the path - NO line number
459
459
  let capsuleNamePath = capsule.cst.source.capsuleName
460
- // Strip @ prefix
461
460
  if (capsuleNamePath.startsWith('@')) {
462
461
  capsuleNamePath = capsuleNamePath.substring(1)
463
462
  }
464
463
  // Just capsuleName + extension, NO line number
465
- projectedPath = `.~caps/${capsuleNamePath}${sourceExtension}`
464
+ projectedPath = `.~o/encapsulate.dev/caps/${capsuleNamePath}${sourceExtension}`
466
465
  } else if (capsule.cst.capsuleSourceUriLineRef) {
467
466
  // Use capsuleSourceUriLineRef which already has format: uri:line
468
467
  let uriPath = capsule.cst.capsuleSourceUriLineRef
469
- // Strip @ prefix
470
468
  if (uriPath.startsWith('@')) {
471
469
  uriPath = uriPath.substring(1)
472
470
  }
473
471
  // Add extension after the line number: uri:line.ext
474
- projectedPath = `.~caps/${uriPath}${sourceExtension}`
472
+ projectedPath = `.~o/encapsulate.dev/caps/${uriPath}${sourceExtension}`
475
473
  } else {
476
474
  // Fallback to hash if neither exists
477
- projectedPath = `.~caps/${capsule.cst.capsuleSourceNameRefHash.substring(0, 8)}${sourceExtension}`
475
+ projectedPath = `.~o/encapsulate.dev/caps/${capsule.cst.capsuleSourceNameRefHash.substring(0, 8)}${sourceExtension}`
478
476
  }
479
477
 
480
478
  return {
@@ -826,16 +824,20 @@ export function CapsuleModuleProjector({
826
824
  const refTyped = ref as any
827
825
  if (refTyped.type === 'capsule') {
828
826
  const snapshot = await buildCapsuleSnapshotForReference(refTyped, capsules, spineContractUri)
829
- const capsuleName = snapshot.spineContracts?.['#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0']?.['#@stream44.studio/encapsulate/structs/Capsule']?.capsuleName
830
- const projectedFilepath = snapshot.spineContracts?.['#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0']?.['#@stream44.studio/encapsulate/structs/Capsule']?.projectedCapsuleFilepath
827
+
828
+ // Use dynamic spineContractUri instead of hardcoded URI
829
+ const contractData = snapshot.spineContracts?.[spineContractUri]
830
+ const structKey = Object.keys(contractData || {}).find(k => k.includes('/structs/Capsule'))
831
+ const capsuleName = structKey ? contractData[structKey]?.capsuleName : undefined
832
+ const projectedFilepath = structKey ? contractData[structKey]?.projectedCapsuleFilepath : undefined
831
833
 
832
834
  if (capsuleName && projectedFilepath) {
833
835
  allCapsuleUris.add(capsuleName)
834
836
 
835
837
  // Build import path from projected filepath
836
838
  const importName = `_capsule_${capsuleName.replace(/[^a-zA-Z0-9]/g, '_')}`
837
- // Remove .~caps/ prefix and strip extension
838
- const importPath = projectedFilepath.replace(/^\.~caps\//, '').replace(/\.(ts|tsx)$/, '')
839
+ // Remove .~o/encapsulate.dev/caps/ prefix and strip extension
840
+ const importPath = projectedFilepath.replace(/^\.~o\/encapsulate\.dev\/caps\//, '').replace(/\.(ts|tsx)$/, '')
839
841
 
840
842
  capsuleDeps.push({ uri: capsuleName, importName, importPath })
841
843
  }
@@ -843,13 +845,13 @@ export function CapsuleModuleProjector({
843
845
  }
844
846
 
845
847
  // Generate static imports for all capsule dependencies
846
- // Compute relative path from projected file to .~caps directory
848
+ // Compute relative path from projected file to caps directory
847
849
  let importPrefix: string
848
850
  if (capsuleModuleProjectionPackage) {
849
851
  importPrefix = capsuleModuleProjectionPackage
850
852
  } else {
851
853
  const projectedFileDir = dirname(filepath)
852
- const capsDir = '.~caps'
854
+ const capsDir = '.~o/encapsulate.dev/caps'
853
855
  const relativePathToCaps = relative(projectedFileDir, capsDir)
854
856
  importPrefix = relativePathToCaps.startsWith('.') ? relativePathToCaps : './' + relativePathToCaps
855
857
  }
@@ -983,7 +985,9 @@ export default function({ onMembraneEvent }: { onMembraneEvent?: (event: any) =>
983
985
  const refTyped = ref as any
984
986
  if (refTyped.type === 'capsule') {
985
987
  const snapshot = await buildCapsuleSnapshotForReference(refTyped, capsules, spineContractUri)
986
- const capsuleName = snapshot.spineContracts?.['#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0']?.['#@stream44.studio/encapsulate/structs/Capsule']?.capsuleName
988
+ const contractData = snapshot.spineContracts?.[spineContractUri]
989
+ const structKey = Object.keys(contractData || {}).find(k => k.includes('/structs/Capsule'))
990
+ const capsuleName = structKey ? contractData[structKey]?.capsuleName : undefined
987
991
  if (capsuleName) {
988
992
  allCapsuleUris.add(capsuleName)
989
993
  }
@@ -1007,13 +1011,15 @@ export default function({ onMembraneEvent }: { onMembraneEvent?: (event: any) =>
1007
1011
 
1008
1012
  if (capsuleRef) {
1009
1013
  const snapshot = await buildCapsuleSnapshotForReference(capsuleRef, capsules, spineContractUri)
1010
- const projectedFilepath = snapshot.spineContracts?.['#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0']?.['#@stream44.studio/encapsulate/structs/Capsule']?.projectedCapsuleFilepath
1014
+ const contractData = snapshot.spineContracts?.[spineContractUri]
1015
+ const structKey = Object.keys(contractData || {}).find(k => k.includes('/structs/Capsule'))
1016
+ const projectedFilepath = structKey ? contractData[structKey]?.projectedCapsuleFilepath : undefined
1011
1017
 
1012
1018
  if (projectedFilepath) {
1013
1019
  // Build import path from projected filepath
1014
1020
  const importName = `_capsule_${uri.replace(/[^a-zA-Z0-9]/g, '_')}`
1015
- // Remove .~caps/ prefix and strip extension
1016
- const importPath = projectedFilepath.replace(/^\.~caps\//, '').replace(/\.(ts|tsx)$/, '')
1021
+ // Remove .~o/encapsulate.dev/caps/ prefix and strip extension
1022
+ const importPath = projectedFilepath.replace(/^\.~o\/encapsulate\.dev\/caps\//, '').replace(/\.(ts|tsx)$/, '')
1017
1023
 
1018
1024
  capsuleDeps.push({ uri, importName, importPath })
1019
1025
  }
@@ -1021,13 +1027,13 @@ export default function({ onMembraneEvent }: { onMembraneEvent?: (event: any) =>
1021
1027
  }
1022
1028
 
1023
1029
  // Generate static imports for all capsule dependencies
1024
- // Compute relative path from projected file to .~caps directory
1030
+ // Compute relative path from projected file to caps directory
1025
1031
  let importPrefix: string
1026
1032
  if (capsuleModuleProjectionPackage) {
1027
1033
  importPrefix = capsuleModuleProjectionPackage
1028
1034
  } else {
1029
1035
  const projectedFileDir = dirname(filepath)
1030
- const capsDir = '.~caps'
1036
+ const capsDir = '.~o/encapsulate.dev/caps'
1031
1037
  const relativePathToCaps = relative(projectedFileDir, capsDir)
1032
1038
  importPrefix = relativePathToCaps.startsWith('.') ? relativePathToCaps : './' + relativePathToCaps
1033
1039
  }
@@ -1321,7 +1327,9 @@ ${defaultExport}
1321
1327
  const refTyped = ref as any
1322
1328
  if (refTyped.type === 'capsule') {
1323
1329
  const snapshot = await buildCapsuleSnapshotForReference(refTyped, capsules, spineContractUri)
1324
- const capsuleName = snapshot.spineContracts?.['#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0']?.['#@stream44.studio/encapsulate/structs/Capsule']?.capsuleName
1330
+ const contractData = snapshot.spineContracts?.[spineContractUri]
1331
+ const structKey = Object.keys(contractData || {}).find(k => k.includes('/structs/Capsule'))
1332
+ const capsuleName = structKey ? contractData[structKey]?.capsuleName : undefined
1325
1333
  if (capsuleName) {
1326
1334
  allMappedCapsuleUris.add(capsuleName)
1327
1335
  }
@@ -1332,19 +1340,19 @@ ${defaultExport}
1332
1340
  const mappedCapsuleDeps: Array<{ uri: string, importName: string, importPath: string }> = []
1333
1341
  for (const uri of allMappedCapsuleUris) {
1334
1342
  const importName = `_capsule_${uri.replace(/[^a-zA-Z0-9]/g, '_')}`
1335
- // Strip leading @ from URI to avoid double @ in import paths
1343
+ // Strip leading @ to match caps filesystem paths
1336
1344
  const importPath = uri.startsWith('@') ? uri.substring(1) : uri
1337
1345
  mappedCapsuleDeps.push({ uri, importName, importPath })
1338
1346
  }
1339
1347
 
1340
1348
  // Generate static imports for all capsule dependencies
1341
- // Compute relative path from projected file to .~caps directory
1349
+ // Compute relative path from projected file to caps directory
1342
1350
  let importPrefix: string
1343
1351
  if (capsuleModuleProjectionPackage) {
1344
1352
  importPrefix = capsuleModuleProjectionPackage
1345
1353
  } else {
1346
1354
  const projectedFileDir = dirname(mapped.projectionPath)
1347
- const capsDir = '.~caps'
1355
+ const capsDir = '.~o/encapsulate.dev/caps'
1348
1356
  const relativePathToCaps = relative(projectedFileDir, capsDir)
1349
1357
  importPrefix = relativePathToCaps.startsWith('.') ? relativePathToCaps : './' + relativePathToCaps
1350
1358
  }
@@ -1475,7 +1483,9 @@ export default function({ onMembraneEvent }: { onMembraneEvent?: (event: any) =>
1475
1483
  const refTyped = ref as any
1476
1484
  if (refTyped.type === 'capsule') {
1477
1485
  const snapshot = await buildCapsuleSnapshotForReference(refTyped, capsules, spineContractUri)
1478
- const capsuleName = snapshot.spineContracts?.['#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0']?.['#@stream44.studio/encapsulate/structs/Capsule']?.capsuleName
1486
+ const contractData = snapshot.spineContracts?.[spineContractUri]
1487
+ const structKey = Object.keys(contractData || {}).find(k => k.includes('/structs/Capsule'))
1488
+ const capsuleName = structKey ? contractData[structKey]?.capsuleName : undefined
1479
1489
  if (capsuleName) {
1480
1490
  allMappedCapsuleUris.add(capsuleName)
1481
1491
  }
@@ -1486,19 +1496,19 @@ export default function({ onMembraneEvent }: { onMembraneEvent?: (event: any) =>
1486
1496
  const mappedCapsuleDeps: Array<{ uri: string, importName: string, importPath: string }> = []
1487
1497
  for (const uri of allMappedCapsuleUris) {
1488
1498
  const importName = `_capsule_${uri.replace(/[^a-zA-Z0-9]/g, '_')}`
1489
- // Strip leading @ from URI to avoid double @ in import paths
1499
+ // Strip leading @ to match caps filesystem paths
1490
1500
  const importPath = uri.startsWith('@') ? uri.substring(1) : uri
1491
1501
  mappedCapsuleDeps.push({ uri, importName, importPath })
1492
1502
  }
1493
1503
 
1494
1504
  // Generate static imports for all capsule dependencies
1495
- // Compute relative path from projected file to .~caps directory
1505
+ // Compute relative path from projected file to caps directory
1496
1506
  let importPrefix: string
1497
1507
  if (capsuleModuleProjectionPackage) {
1498
1508
  importPrefix = capsuleModuleProjectionPackage
1499
1509
  } else {
1500
1510
  const projectedFileDir = dirname(mapped.projectionPath)
1501
- const capsDir = '.~caps'
1511
+ const capsDir = '.~o/encapsulate.dev/caps'
1502
1512
  const relativePathToCaps = relative(projectedFileDir, capsDir)
1503
1513
  importPrefix = relativePathToCaps.startsWith('.') ? relativePathToCaps : './' + relativePathToCaps
1504
1514
  }
@@ -1628,7 +1638,7 @@ ${mappedDefaultExport}
1628
1638
  }
1629
1639
  }
1630
1640
 
1631
- // Write projection cache AND project ALL capsules in the registry to .~caps
1641
+ // Write projection cache AND project ALL capsules in the registry to .~o/encapsulate.dev/caps
1632
1642
  // This includes struct definitions, property contract capsules, and any other capsules
1633
1643
  if (projectionCacheStore?.writeFile && capsules) {
1634
1644
  for (const [capsuleKey, registryCapsule] of Object.entries(capsules)) {
@@ -1655,7 +1665,7 @@ ${mappedDefaultExport}
1655
1665
  }
1656
1666
  await projectionCacheStore.writeFile(capsuleCacheFilename, JSON.stringify(capsuleCacheData, null, 2))
1657
1667
 
1658
- // Also project the capsule to .~caps
1668
+ // Also project the capsule to .~o/encapsulate.dev/caps
1659
1669
  const projectedPath = capsuleCacheData.snapshotData.spineContracts[spineContractUri]['#@stream44.studio/encapsulate/structs/Capsule'].projectedCapsuleFilepath
1660
1670
 
1661
1671
  // Generate the capsule file content with proper imports and ambient reference loading
@@ -1,4 +1,8 @@
1
1
 
2
+ // CACHE_BUST_VERSION: Increment this whenever CST cache must be invalidated due to structural changes
3
+ // This ensures projected capsules are regenerated when the CST format changes
4
+ const CACHE_BUST_VERSION = 9
5
+
2
6
  type TSpineOptions = {
3
7
  spineFilesystemRoot?: string,
4
8
  spineContracts: Record<string, any>,
@@ -32,7 +36,7 @@ type TSpineRuntimeOptions = {
32
36
  spineContracts?: Record<string, any>,
33
37
  snapshot?: TSpineSnapshot,
34
38
  capsules?: Record<string, any>,
35
- loadCapsule?: (options: { capsuleSourceLineRef: string, capsuleSnapshot: any, capsuleName?: string }) => Promise<any>
39
+ loadCapsule?: (options: { capsuleSourceLineRef: string, capsuleSnapshot: any, capsuleName?: string, cacheBustVersion?: number }) => Promise<any>
36
40
  }
37
41
 
38
42
  type TCapsuleSnapshot = {
@@ -44,7 +48,12 @@ type TCapsuleMakeInstanceOptions = {
44
48
  overrides?: Record<string, any>,
45
49
  options?: Record<string, any>,
46
50
  runtimeSpineContracts?: Record<string, any>,
47
- sharedSelf?: Record<string, any>
51
+ sharedSelf?: Record<string, any>,
52
+ rootCapsule?: {
53
+ capsuleName: string,
54
+ capsuleSourceLineRef: string,
55
+ moduleFilepath: string
56
+ }
48
57
  }
49
58
 
50
59
  type TCapsule = {
@@ -97,6 +106,7 @@ export const CapsulePropertyTypes = {
97
106
  String: 'String' as const,
98
107
  Mapping: 'Mapping' as const,
99
108
  Literal: 'Literal' as const,
109
+ Constant: 'Constant' as const,
100
110
  StructInit: 'StructInit' as const,
101
111
  }
102
112
 
@@ -299,9 +309,19 @@ export async function SpineRuntime(options: TSpineRuntimeOptions): Promise<TSpin
299
309
  const capsule = await options.loadCapsule!({
300
310
  capsuleSourceLineRef,
301
311
  capsuleSnapshot,
302
- capsuleName
312
+ capsuleName,
313
+ cacheBustVersion: CACHE_BUST_VERSION
303
314
  })
304
315
 
316
+ // If loadCapsule returns null, it means cache bust version mismatch - regenerate the capsule
317
+ if (capsule === null) {
318
+ throw new Error(
319
+ `Cache bust version mismatch for capsule '${capsuleSourceLineRef}'. ` +
320
+ `Expected version ${CACHE_BUST_VERSION} but found ${(capsuleSnapshot as any).cst?.cacheBustVersion}. ` +
321
+ `Please delete the cache directory and regenerate capsules.`
322
+ )
323
+ }
324
+
305
325
  loadedCapsules[capsuleSourceLineRef] = await capsule({
306
326
  encapsulate: spine.encapsulate,
307
327
  CapsulePropertyTypes,
@@ -392,28 +412,59 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
392
412
  if (!options.importMeta && !options.moduleFilepath) throw new Error(`'options.importMeta' nor 'options.moduleFilepath' not specified!`)
393
413
  if (!options.importStack && !options.importStackLine) throw new Error(`'options.importStack' nor 'options.importStackLine' specified!`)
394
414
 
395
- const moduleFilepath = options.moduleFilepath || relative(spine.spineOptions.spineFilesystemRoot || '', options.importMeta!.url.replace(/^file:\/\//, ''))
415
+ // Use relative path for internal processing, but store absolute path for metadata
416
+ const providedPath = options.moduleFilepath || options.importMeta!.url.replace(/^file:\/\//, '')
417
+ const spineRoot = spine.spineOptions.spineFilesystemRoot || ''
418
+
419
+ // Determine if we need to make the path absolute
420
+ let absoluteModuleFilepath: string
421
+ if (providedPath.startsWith('/')) {
422
+ // Already an absolute path (starts with /)
423
+ absoluteModuleFilepath = providedPath
424
+ } else if (spineRoot) {
425
+ // Relative path - make it absolute by joining with spine root
426
+ absoluteModuleFilepath = join(spineRoot, providedPath)
427
+ } else {
428
+ // No spine root and path is relative - use as-is (will remain relative)
429
+ // Note: This happens when SpineRuntime doesn't pass spineFilesystemRoot through
430
+ absoluteModuleFilepath = providedPath
431
+ }
432
+
433
+ const moduleFilepath = relative(spineRoot, absoluteModuleFilepath)
396
434
  const importStackLine = options.importStackLine || formatImportStackFrame(options.importStack!)
397
435
 
398
436
  if (typeof importStackLine !== 'number') throw new Error(`Could not determine importStackLine from options`)
399
437
 
400
- const encapsulateOptions: TEncapsulateOptions = {
401
- moduleFilepath,
402
- importStackLine,
403
- capsuleName: options.capsuleName,
404
- ambientReferences: options.ambientReferences,
405
- extendsCapsule: options.extendsCapsule,
406
- capsuleSourceLineRef: `${moduleFilepath}:${importStackLine}`
407
- }
438
+ const capsuleSourceLineRef = `${moduleFilepath}:${importStackLine}`
408
439
 
409
440
  spine.spineOptions.timing?.record(`Encapsulate: Start for ${moduleFilepath}`)
410
441
 
411
442
  const { csts, crts } = await spine.spineOptions.staticAnalyzer?.parseModule({
412
443
  spineOptions: spine.spineOptions,
413
- encapsulateOptions
444
+ encapsulateOptions: {
445
+ moduleFilepath,
446
+ importStackLine,
447
+ capsuleSourceLineRef,
448
+ capsuleName: options.capsuleName,
449
+ ambientReferences: options.ambientReferences,
450
+ cacheBustVersion: CACHE_BUST_VERSION
451
+ }
414
452
  }) || {
415
- csts: options.cst ? { [encapsulateOptions.capsuleSourceLineRef]: options.cst } : undefined,
416
- crts: options.crt ? { [encapsulateOptions.capsuleSourceLineRef]: options.crt } : undefined
453
+ csts: options.cst ? { [capsuleSourceLineRef]: options.cst } : undefined,
454
+ crts: options.crt ? { [capsuleSourceLineRef]: options.crt } : undefined
455
+ }
456
+
457
+ // Get capsuleName from options first, then fall back to CST if available
458
+ const cst = csts?.[capsuleSourceLineRef]
459
+ const capsuleName = options.capsuleName || cst?.source?.capsuleName
460
+
461
+ const encapsulateOptions: TEncapsulateOptions = {
462
+ moduleFilepath,
463
+ importStackLine,
464
+ capsuleName,
465
+ ambientReferences: options.ambientReferences,
466
+ extendsCapsule: options.extendsCapsule,
467
+ capsuleSourceLineRef
417
468
  }
418
469
 
419
470
  const defaultInstance: Record<string, any> = {}
@@ -431,9 +482,9 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
431
482
  capsuleSourceLineRef: encapsulateOptions.capsuleSourceLineRef,
432
483
  definition,
433
484
  encapsulateOptions,
434
- cst: csts?.[encapsulateOptions.capsuleSourceLineRef],
435
- crt: crts?.[encapsulateOptions.capsuleSourceLineRef],
436
- makeInstance: async ({ overrides = {}, options = {}, runtimeSpineContracts, sharedSelf }: TCapsuleMakeInstanceOptions = {}) => {
485
+ cst,
486
+ crt: crts?.[capsuleSourceLineRef],
487
+ makeInstance: async ({ overrides = {}, options = {}, runtimeSpineContracts, sharedSelf, rootCapsule }: TCapsuleMakeInstanceOptions = {}) => {
437
488
 
438
489
  // Create cache key based on parameters
439
490
  // When sharedSelf is provided, we must NOT cache because each extending capsule
@@ -593,6 +644,22 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
593
644
  // The selfProxy in spine contracts will expose this as 'self' property
594
645
  const ownSelf = merge({}, defaultInstance, defaultPropertyValues, ...Object.values(mergedValuesByContract))
595
646
 
647
+ // Capsule metadata struct will be set on self/ownSelf AFTER spine contract processing
648
+ // to avoid being overwritten by the empty struct marker in the definition
649
+ // Convert relative paths to absolute for metadata exposure
650
+ const absoluteCapsuleSourceLineRef = `${absoluteModuleFilepath}:${importStackLine}`
651
+ const capsuleMetadataStruct = {
652
+ capsuleName: encapsulateOptions.capsuleName,
653
+ capsuleSourceLineRef: absoluteCapsuleSourceLineRef,
654
+ moduleFilepath: absoluteModuleFilepath,
655
+ // Root capsule metadata will be populated after extends chain is resolved
656
+ rootCapsule: {
657
+ capsuleName: undefined as string | undefined,
658
+ capsuleSourceLineRef: undefined as string | undefined,
659
+ moduleFilepath: undefined as string | undefined
660
+ }
661
+ }
662
+
596
663
  // Initialize extended capsule instance if this capsule extends another
597
664
  // Pass our self so extended capsule's functions bind to the same context
598
665
  let extendedCapsuleInstance: any = undefined
@@ -657,16 +724,34 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
657
724
  overrides,
658
725
  options,
659
726
  runtimeSpineContracts,
660
- sharedSelf: self
727
+ sharedSelf: self,
728
+ rootCapsule: rootCapsule || {
729
+ capsuleName: encapsulateOptions.capsuleName!,
730
+ capsuleSourceLineRef: absoluteCapsuleSourceLineRef,
731
+ moduleFilepath: absoluteModuleFilepath
732
+ }
661
733
  })
662
734
  }
663
735
 
736
+ // Resolve the root capsule for this instance:
737
+ // If rootCapsule was passed down from a parent, use it (preserves the first capsule in the chain).
738
+ // Otherwise this capsule IS the root.
739
+ const resolvedRootCapsule = rootCapsule || {
740
+ capsuleName: encapsulateOptions.capsuleName!,
741
+ capsuleSourceLineRef: absoluteCapsuleSourceLineRef,
742
+ moduleFilepath: absoluteModuleFilepath
743
+ }
744
+ capsuleMetadataStruct.rootCapsule.capsuleName = resolvedRootCapsule.capsuleName
745
+ capsuleMetadataStruct.rootCapsule.capsuleSourceLineRef = resolvedRootCapsule.capsuleSourceLineRef
746
+ capsuleMetadataStruct.rootCapsule.moduleFilepath = resolvedRootCapsule.moduleFilepath
747
+
664
748
  const capsuleInstance: any = {
665
749
  api: encapsulatedApi,
666
750
  spineContractCapsuleInstances,
667
751
  extendedCapsuleInstance,
668
752
  structInitFunctions: [] as Array<() => any>,
669
- mappedCapsuleInstances: [] as Array<any>
753
+ mappedCapsuleInstances: [] as Array<any>,
754
+ rootCapsule: resolvedRootCapsule
670
755
  }
671
756
 
672
757
  // Use runtime spine contracts if provided, otherwise fall back to encapsulation spine contracts
@@ -715,6 +800,15 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
715
800
  }
716
801
  }
717
802
 
803
+ // Set capsule metadata struct on self/ownSelf AFTER spine contract processing
804
+ // to avoid being overwritten by the empty struct marker in the definition
805
+ if (!self['#@stream44.studio/encapsulate/structs/Capsule'] ||
806
+ typeof self['#@stream44.studio/encapsulate/structs/Capsule'] !== 'object' ||
807
+ !self['#@stream44.studio/encapsulate/structs/Capsule'].capsuleName) {
808
+ self['#@stream44.studio/encapsulate/structs/Capsule'] = capsuleMetadataStruct
809
+ }
810
+ ownSelf['#@stream44.studio/encapsulate/structs/Capsule'] = capsuleMetadataStruct
811
+
718
812
  // Collect StructInit functions and mapped capsule instances from all spine contract capsule instances
719
813
  for (const spineContractCapsuleInstance of Object.values(spineContractCapsuleInstances)) {
720
814
  const sci = spineContractCapsuleInstance as any
@@ -80,7 +80,8 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
80
80
  currentCallerContext,
81
81
  runtimeSpineContracts,
82
82
  instanceRegistry,
83
- extendedCapsuleInstance
83
+ extendedCapsuleInstance,
84
+ capsuleInstance
84
85
  }: {
85
86
  spineContractUri: string
86
87
  capsule: any
@@ -100,8 +101,9 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
100
101
  runtimeSpineContracts?: Record<string, any>
101
102
  instanceRegistry?: CapsuleInstanceRegistry
102
103
  extendedCapsuleInstance?: any
104
+ capsuleInstance?: any
103
105
  }) {
104
- super({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule, instanceRegistry, extendedCapsuleInstance })
106
+ super({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule, instanceRegistry, extendedCapsuleInstance, capsuleInstance })
105
107
  this.getEventIndex = getEventIndex
106
108
  this.incrementEventIndex = incrementEventIndex
107
109
  this.currentCallerContext = currentCallerContext
@@ -230,7 +232,8 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
230
232
  const mappedCapsuleInstance = await mappedCapsule.makeInstance({
231
233
  overrides: mappedOverrides,
232
234
  options: mappingOptions,
233
- runtimeSpineContracts: this.runtimeSpineContracts
235
+ runtimeSpineContracts: this.runtimeSpineContracts,
236
+ rootCapsule: this.capsuleInstance?.rootCapsule
234
237
  })
235
238
 
236
239
  // Register the instance (replaces null pre-registration marker)
@@ -344,6 +347,8 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
344
347
  }
345
348
 
346
349
  protected mapLiteralProperty({ property }: { property: any }) {
350
+ // Constant properties are read-only and throw on set
351
+ const isConstant = property.definition.type === CapsulePropertyTypes.Constant
347
352
 
348
353
  const value = typeof this.self[property.name] !== 'undefined'
349
354
  ? this.self[property.name]
@@ -388,6 +393,11 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
388
393
  return currentValue
389
394
  },
390
395
  set: (newValue) => {
396
+ // Constant properties cannot be set
397
+ if (isConstant) {
398
+ throw new Error(`Cannot set constant property '${property.name}'`)
399
+ }
400
+
391
401
  const event: any = {
392
402
  event: 'set',
393
403
  eventIndex: this.incrementEventIndex(),
@@ -585,7 +595,7 @@ export function CapsuleSpineContract({
585
595
  return {
586
596
  '#': CapsuleSpineContract['#'],
587
597
  instanceRegistry,
588
- makeContractCapsuleInstance: ({ encapsulateOptions, spineContractUri, self, ownSelf, capsule, encapsulatedApi, runtimeSpineContracts, extendedCapsuleInstance }: { encapsulateOptions: any, spineContractUri: string, self: any, ownSelf?: any, capsule?: any, encapsulatedApi: Record<string, any>, runtimeSpineContracts?: Record<string, any>, extendedCapsuleInstance?: any }) => {
598
+ makeContractCapsuleInstance: ({ encapsulateOptions, spineContractUri, self, ownSelf, capsule, encapsulatedApi, runtimeSpineContracts, extendedCapsuleInstance, capsuleInstance }: { encapsulateOptions: any, spineContractUri: string, self: any, ownSelf?: any, capsule?: any, encapsulatedApi: Record<string, any>, runtimeSpineContracts?: Record<string, any>, extendedCapsuleInstance?: any, capsuleInstance?: any }) => {
589
599
  return new MembraneContractCapsuleInstanceFactory({
590
600
  spineContractUri,
591
601
  capsule,
@@ -604,7 +614,8 @@ export function CapsuleSpineContract({
604
614
  currentCallerContext,
605
615
  runtimeSpineContracts,
606
616
  instanceRegistry,
607
- extendedCapsuleInstance
617
+ extendedCapsuleInstance,
618
+ capsuleInstance
608
619
  })
609
620
  },
610
621
  hydrate: ({ capsuleSnapshot }: { capsuleSnapshot: any }): any => {
@@ -17,10 +17,11 @@ export class ContractCapsuleInstanceFactory {
17
17
  protected extendedCapsuleInstance?: any
18
18
  protected ownSelf?: any
19
19
  protected runtimeSpineContracts?: Record<string, any>
20
+ protected capsuleInstance?: any
20
21
  public structInitFunctions: Array<() => any> = []
21
22
  public mappedCapsuleInstances: Array<any> = []
22
23
 
23
- constructor({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule, instanceRegistry, extendedCapsuleInstance, runtimeSpineContracts }: { 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> }) {
24
+ 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 }) {
24
25
  this.spineContractUri = spineContractUri
25
26
  this.capsule = capsule
26
27
  this.self = self
@@ -33,6 +34,7 @@ export class ContractCapsuleInstanceFactory {
33
34
  this.instanceRegistry = instanceRegistry
34
35
  this.extendedCapsuleInstance = extendedCapsuleInstance
35
36
  this.runtimeSpineContracts = runtimeSpineContracts
37
+ this.capsuleInstance = capsuleInstance
36
38
  }
37
39
 
38
40
  async mapProperty({ overrides, options, property }: { overrides: any, options: any, property: any }) {
@@ -40,7 +42,8 @@ export class ContractCapsuleInstanceFactory {
40
42
  await this.mapMappingProperty({ overrides, options, property })
41
43
  } else if (
42
44
  property.definition.type === CapsulePropertyTypes.String ||
43
- property.definition.type === CapsulePropertyTypes.Literal
45
+ property.definition.type === CapsulePropertyTypes.Literal ||
46
+ property.definition.type === CapsulePropertyTypes.Constant
44
47
  ) {
45
48
  this.mapLiteralProperty({ property })
46
49
  } else if (property.definition.type === CapsulePropertyTypes.Function) {
@@ -226,7 +229,8 @@ export class ContractCapsuleInstanceFactory {
226
229
  const mappedInstance = await mappedCapsule.makeInstance({
227
230
  overrides: mappedOverrides,
228
231
  options: mappingOptions,
229
- runtimeSpineContracts: this.runtimeSpineContracts
232
+ runtimeSpineContracts: this.runtimeSpineContracts,
233
+ rootCapsule: this.capsuleInstance?.rootCapsule
230
234
  })
231
235
 
232
236
  // Register the instance (replaces null pre-registration marker)
@@ -369,7 +373,7 @@ export function CapsuleSpineContract({ freezeCapsule, resolve, importCapsule, sp
369
373
  return {
370
374
  '#': CapsuleSpineContract['#'],
371
375
  instanceRegistry,
372
- makeContractCapsuleInstance: ({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, extendedCapsuleInstance, runtimeSpineContracts }: { spineContractUri: string, capsule: any, self: any, ownSelf?: any, encapsulatedApi: Record<string, any>, extendedCapsuleInstance?: any, runtimeSpineContracts?: Record<string, any> }) => {
376
+ makeContractCapsuleInstance: ({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, extendedCapsuleInstance, runtimeSpineContracts, capsuleInstance }: { spineContractUri: string, capsule: any, self: any, ownSelf?: any, encapsulatedApi: Record<string, any>, extendedCapsuleInstance?: any, runtimeSpineContracts?: Record<string, any>, capsuleInstance?: any }) => {
373
377
  return new ContractCapsuleInstanceFactory({
374
378
  spineContractUri,
375
379
  capsule,
@@ -382,7 +386,8 @@ export function CapsuleSpineContract({ freezeCapsule, resolve, importCapsule, sp
382
386
  freezeCapsule,
383
387
  instanceRegistry,
384
388
  extendedCapsuleInstance,
385
- runtimeSpineContracts
389
+ runtimeSpineContracts,
390
+ capsuleInstance
386
391
  })
387
392
  },
388
393
  hydrate: ({ capsuleSnapshot }: { capsuleSnapshot: any }): any => {
@@ -499,7 +499,7 @@ export async function CapsuleSpineFactory({
499
499
 
500
500
  timing?.recordMajor('CAPSULE SPINE FACTORY: READY')
501
501
 
502
- const loadCapsule = async ({ capsuleSnapshot }: { capsuleSourceLineRef: string, capsuleSnapshot: any }) => {
502
+ const loadCapsule = async ({ capsuleSnapshot, cacheBustVersion }: { capsuleSourceLineRef: string, capsuleSnapshot: any, cacheBustVersion?: number }) => {
503
503
 
504
504
  if (!capsuleModuleProjectionRoot) {
505
505
  throw new Error('capsuleModuleProjectionRoot must be provided to enable dynamic loading of capsules')
@@ -509,6 +509,11 @@ export async function CapsuleSpineFactory({
509
509
 
510
510
  if (!filepath) throw new Error(`Cannot load capsule. No 'filepath' found at 'spineContracts["#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0"]["#@stream44.studio/encapsulate/structs/Capsule"].projectedCapsuleFilepath'!`)
511
511
 
512
+ // Check cache bust version - if it doesn't match, return null to trigger regeneration
513
+ if (cacheBustVersion !== undefined && capsuleSnapshot.cst?.cacheBustVersion !== cacheBustVersion) {
514
+ return null
515
+ }
516
+
512
517
  const { capsule } = await import(join(capsuleModuleProjectionRoot, filepath))
513
518
 
514
519
  return capsule
@@ -529,6 +534,7 @@ export async function CapsuleSpineFactory({
529
534
 
530
535
  const result = await SpineRuntime({
531
536
  snapshot,
537
+ spineFilesystemRoot,
532
538
  spineContracts: spineContractInstances.runtime,
533
539
  loadCapsule
534
540
  })
@@ -235,10 +235,18 @@ export function StaticAnalyzer({
235
235
  ])
236
236
 
237
237
  if (cstsContent && crtsContent) {
238
- timing?.record(`StaticAnalyzer: Cache HIT for ${encapsulateOptions.moduleFilepath}`)
239
- return {
240
- csts: JSON.parse(cstsContent),
241
- crts: JSON.parse(crtsContent)
238
+ const cachedCsts = JSON.parse(cstsContent)
239
+
240
+ // Check cache bust version - if mismatch, regenerate
241
+ const cachedVersion = cachedCsts?.[capsuleSourceLineRef]?.cacheBustVersion
242
+ if (encapsulateOptions.cacheBustVersion !== undefined && cachedVersion !== encapsulateOptions.cacheBustVersion) {
243
+ timing?.record(timing?.chalk?.red?.(`StaticAnalyzer: Cache BUST (version mismatch: ${cachedVersion} !== ${encapsulateOptions.cacheBustVersion}) for ${encapsulateOptions.moduleFilepath}`))
244
+ } else {
245
+ timing?.record(`StaticAnalyzer: Cache HIT for ${encapsulateOptions.moduleFilepath}`)
246
+ return {
247
+ csts: cachedCsts,
248
+ crts: JSON.parse(crtsContent)
249
+ }
242
250
  }
243
251
  }
244
252
  }
@@ -340,6 +348,7 @@ export function StaticAnalyzer({
340
348
  )
341
349
 
342
350
  const cst: any = {
351
+ cacheBustVersion: encapsulateOptions.cacheBustVersion || 1,
343
352
  capsuleSourceLineRef,
344
353
  capsuleSourceNameRef,
345
354
  capsuleSourceNameRefHash,