@stream44.studio/encapsulate 0.4.0-rc.28 → 0.4.0-rc.30

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.28",
3
+ "version": "0.4.0-rc.30",
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
  }
@@ -722,12 +731,20 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
722
731
  const ownSelf = merge({}, defaultInstance, defaultPropertyValues, ...Object.values(mergedValuesByContract))
723
732
 
724
733
  // Convert relative paths to absolute for metadata exposure
734
+ // When a CST exists with a source moduleFilepath, use it to derive the absolute path.
735
+ // This ensures that projected capsules (loaded from .~o/encapsulate.dev/caps/...)
736
+ // still expose the original source filepath, not the projected filepath.
737
+ const originalAbsoluteModuleFilepath = cst?.source?.moduleFilepath
738
+ ? (cst.source.moduleFilepath.startsWith('/')
739
+ ? cst.source.moduleFilepath
740
+ : join(spine.spineOptions.spineFilesystemRoot || '', cst.source.moduleFilepath))
741
+ : absoluteModuleFilepath
725
742
  const absoluteCapsuleSourceLineRef = `${absoluteModuleFilepath}:${importStackLine}`
726
743
  const capsuleMetadataStruct: Record<string, any> = {
727
744
  capsuleName: encapsulateOptions.capsuleName,
728
745
  capsuleSourceLineRef: absoluteCapsuleSourceLineRef,
729
746
  capsuleSourceNameRefHash: cst?.capsuleSourceNameRefHash,
730
- moduleFilepath: absoluteModuleFilepath,
747
+ moduleFilepath: originalAbsoluteModuleFilepath,
731
748
  // Root capsule metadata will be populated after extends chain is resolved
732
749
  rootCapsule: {
733
750
  capsuleName: undefined as string | undefined,
@@ -845,7 +862,7 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
845
862
  const resolvedRootCapsule = rootCapsule || {
846
863
  capsuleName: encapsulateOptions.capsuleName!,
847
864
  capsuleSourceLineRef: absoluteCapsuleSourceLineRef,
848
- moduleFilepath: absoluteModuleFilepath
865
+ moduleFilepath: originalAbsoluteModuleFilepath
849
866
  }
850
867
  capsuleMetadataStruct.rootCapsule.capsuleName = resolvedRootCapsule.capsuleName
851
868
  capsuleMetadataStruct.rootCapsule.capsuleSourceLineRef = resolvedRootCapsule.capsuleSourceLineRef
@@ -1,5 +1,7 @@
1
1
  import { CapsulePropertyTypes } from "../../encapsulate"
2
2
  import { ContractCapsuleInstanceFactory, CapsuleInstanceRegistry } from "./Static.v0"
3
+ import { readFileSync, existsSync } from "node:fs"
4
+ import { dirname, relative, join } from "node:path"
3
5
 
4
6
  type CallerContext = {
5
7
  capsuleSourceLineRef: string
@@ -8,9 +10,9 @@ type CallerContext = {
8
10
  capsuleSourceNameRefHash?: string
9
11
  capsuleSourceUriLineRefInstanceId?: string
10
12
  prop?: string
11
- filepath?: string
13
+ fileUri?: string
12
14
  line?: number
13
- stack?: Array<{ function?: string, filepath?: string, line?: number, column?: number }>
15
+ stack?: Array<{ function?: string, fileUri?: string, line?: number, column?: number }>
14
16
  }
15
17
 
16
18
  function CapsuleMembrane(target: Record<string, any>, hooks?: {
@@ -140,11 +142,16 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
140
142
 
141
143
  // delegateOptions is set by encapsulate.ts for property contract delegates
142
144
  // 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
145
+ // Always pass { self, constants } - self contains full parent self when depends is specified,
146
+ // otherwise just the Capsule metadata struct (moduleFilepath, capsuleName, etc.)
144
147
  const optionsFn = property.definition.options
148
+ const capsuleStructKey = '#@stream44.studio/encapsulate/structs/Capsule'
149
+ const minimalSelf = this.self[capsuleStructKey]
150
+ ? { [capsuleStructKey]: this.self[capsuleStructKey] }
151
+ : {}
145
152
  const mappingOptions = property.definition.delegateOptions
146
153
  || (typeof optionsFn === 'function'
147
- ? await optionsFn({ self: property.definition.depends ? this.self : {}, constants })
154
+ ? await optionsFn({ self: property.definition.depends ? this.self : minimalSelf, constants })
148
155
  : optionsFn)
149
156
 
150
157
  // Check for existing instance in registry - reuse if available (regardless of options)
@@ -180,7 +187,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
180
187
  const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot)
181
188
  if (stackFrames.length > 0) {
182
189
  const callerInfo = extractCallerInfo(stackFrames, 3)
183
- callerCtx.filepath = callerInfo.filepath
190
+ callerCtx.fileUri = callerInfo.fileUri
184
191
  callerCtx.line = callerInfo.line
185
192
  callerCtx.stack = stackFrames
186
193
  }
@@ -307,7 +314,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
307
314
  const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot)
308
315
  if (stackFrames.length > 0) {
309
316
  const callerInfo = extractCallerInfo(stackFrames, 3)
310
- callerCtx.filepath = callerInfo.filepath
317
+ callerCtx.fileUri = callerInfo.fileUri
311
318
  callerCtx.line = callerInfo.line
312
319
  callerCtx.stack = stackFrames
313
320
  }
@@ -370,7 +377,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
370
377
  const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot)
371
378
  if (stackFrames.length > 0) {
372
379
  const callerInfo = extractCallerInfo(stackFrames, 3)
373
- callerCtx.filepath = callerInfo.filepath
380
+ callerCtx.fileUri = callerInfo.fileUri
374
381
  callerCtx.line = callerInfo.line
375
382
  callerCtx.stack = stackFrames
376
383
  }
@@ -873,8 +880,8 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
873
880
  if (callerCtx.prop) {
874
881
  event.caller.prop = callerCtx.prop
875
882
  }
876
- if (callerCtx.filepath) {
877
- event.caller.filepath = callerCtx.filepath
883
+ if (callerCtx.fileUri) {
884
+ event.caller.fileUri = callerCtx.fileUri
878
885
  }
879
886
  if (callerCtx.line) {
880
887
  event.caller.line = callerCtx.line
@@ -970,9 +977,62 @@ CapsuleSpineContract['#'] = '@stream44.studio/encapsulate/spine-contracts/Capsul
970
977
 
971
978
 
972
979
 
973
- function parseCallerFromStack(stack: string, spineFilesystemRoot?: string): Array<{ function?: string, filepath?: string, line?: number, column?: number }> {
980
+ // Cache for synchronous npm URI lookups (directory -> package name or null)
981
+ const npmUriCache = new Map<string, string | null>()
982
+
983
+ function constructNpmUriSync(absoluteFilepath: string): string | null {
984
+ // Check for /node_modules/ in the path — use the last occurrence to handle nested node_modules
985
+ const nodeModulesMarker = '/node_modules/'
986
+ const lastIdx = absoluteFilepath.lastIndexOf(nodeModulesMarker)
987
+ if (lastIdx !== -1) {
988
+ return absoluteFilepath.substring(lastIdx + nodeModulesMarker.length)
989
+ }
990
+
991
+ let currentDir = dirname(absoluteFilepath)
992
+ const maxDepth = 20
993
+
994
+ for (let i = 0; i < maxDepth; i++) {
995
+ if (npmUriCache.has(currentDir)) {
996
+ const cachedName = npmUriCache.get(currentDir)
997
+ if (cachedName) {
998
+ const relativeFromPackage = relative(currentDir, absoluteFilepath)
999
+ return `${cachedName}/${relativeFromPackage}`
1000
+ }
1001
+ // null means no package.json with name found at this level, continue up
1002
+ const parentDir = dirname(currentDir)
1003
+ if (parentDir === currentDir) break
1004
+ currentDir = parentDir
1005
+ continue
1006
+ }
1007
+
1008
+ const packageJsonPath = join(currentDir, 'package.json')
1009
+ try {
1010
+ if (existsSync(packageJsonPath)) {
1011
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
1012
+ const packageName = packageJson.name
1013
+ npmUriCache.set(currentDir, packageName || null)
1014
+ if (packageName) {
1015
+ const relativeFromPackage = relative(currentDir, absoluteFilepath)
1016
+ return `${packageName}/${relativeFromPackage}`
1017
+ }
1018
+ } else {
1019
+ npmUriCache.set(currentDir, null)
1020
+ }
1021
+ } catch {
1022
+ npmUriCache.set(currentDir, null)
1023
+ }
1024
+
1025
+ const parentDir = dirname(currentDir)
1026
+ if (parentDir === currentDir) break
1027
+ currentDir = parentDir
1028
+ }
1029
+
1030
+ return null
1031
+ }
1032
+
1033
+ function parseCallerFromStack(stack: string, spineFilesystemRoot?: string): Array<{ function?: string, fileUri?: string, line?: number, column?: number }> {
974
1034
  const lines = stack.split('\n')
975
- const result: Array<{ function?: string, filepath?: string, line?: number, column?: number }> = []
1035
+ const result: Array<{ function?: string, fileUri?: string, line?: number, column?: number }> = []
976
1036
 
977
1037
  // Skip first line (Error message), then collect ALL frames
978
1038
  for (let i = 1; i < lines.length; i++) {
@@ -984,9 +1044,11 @@ function parseCallerFromStack(stack: string, spineFilesystemRoot?: string): Arra
984
1044
  // "at functionName (file:line:column)"
985
1045
  const match = line.match(/at\s+(.+)/)
986
1046
  if (match) {
987
- const frame: { function?: string, filepath?: string, line?: number, column?: number } = {}
1047
+ const frame: { function?: string, fileUri?: string, line?: number, column?: number } = {}
988
1048
  const content = match[1]
989
1049
 
1050
+ let rawFilepath: string | undefined
1051
+
990
1052
  // Try to extract function name and location
991
1053
  const funcMatch = content.match(/^(.+?)\s+\((.+)\)$/)
992
1054
  if (funcMatch) {
@@ -999,7 +1061,7 @@ function parseCallerFromStack(stack: string, spineFilesystemRoot?: string): Arra
999
1061
  const location = funcMatch[2]
1000
1062
  const locMatch = location.match(/^(.+):(\d+):(\d+)$/)
1001
1063
  if (locMatch) {
1002
- frame.filepath = locMatch[1]
1064
+ rawFilepath = locMatch[1]
1003
1065
  frame.line = parseInt(locMatch[2], 10)
1004
1066
  frame.column = parseInt(locMatch[3], 10)
1005
1067
  }
@@ -1007,25 +1069,32 @@ function parseCallerFromStack(stack: string, spineFilesystemRoot?: string): Arra
1007
1069
  // No function name: "/path/to/file:line:column"
1008
1070
  const locMatch = content.match(/^(.+):(\d+):(\d+)$/)
1009
1071
  if (locMatch) {
1010
- frame.filepath = locMatch[1]
1072
+ rawFilepath = locMatch[1]
1011
1073
  frame.line = parseInt(locMatch[2], 10)
1012
1074
  frame.column = parseInt(locMatch[3], 10)
1013
1075
  }
1014
1076
  }
1015
1077
 
1016
- // Convert absolute paths to relative paths if spineFilesystemRoot is provided
1017
- if (frame.filepath && spineFilesystemRoot) {
1018
- if (frame.filepath.startsWith(spineFilesystemRoot)) {
1019
- frame.filepath = frame.filepath.slice(spineFilesystemRoot.length)
1020
- // Remove leading slash if present
1021
- if (frame.filepath.startsWith('/')) {
1022
- frame.filepath = frame.filepath.slice(1)
1078
+ // Convert absolute filepaths to npm URIs
1079
+ if (rawFilepath) {
1080
+ const npmUri = constructNpmUriSync(rawFilepath)
1081
+ if (npmUri) {
1082
+ // Strip file extension from URI for consistency
1083
+ frame.fileUri = npmUri.replace(/\.(ts|tsx|js|jsx)$/, '')
1084
+ } else if (spineFilesystemRoot && rawFilepath.startsWith(spineFilesystemRoot)) {
1085
+ // Fallback: use relative path from spine root if npm URI not resolvable
1086
+ let relativePath = rawFilepath.slice(spineFilesystemRoot.length)
1087
+ if (relativePath.startsWith('/')) {
1088
+ relativePath = relativePath.slice(1)
1023
1089
  }
1090
+ frame.fileUri = relativePath
1091
+ } else {
1092
+ frame.fileUri = rawFilepath
1024
1093
  }
1025
1094
  }
1026
1095
 
1027
1096
  // Include all frames, even if incomplete
1028
- if (frame.filepath || frame.function) {
1097
+ if (frame.fileUri || frame.function) {
1029
1098
  result.push(frame)
1030
1099
  }
1031
1100
  }
@@ -1033,14 +1102,14 @@ function parseCallerFromStack(stack: string, spineFilesystemRoot?: string): Arra
1033
1102
  return result
1034
1103
  }
1035
1104
 
1036
- function extractCallerInfo(stack: Array<{ function?: string, filepath?: string, line?: number, column?: number }>, offset: number = 0) {
1105
+ function extractCallerInfo(stack: Array<{ function?: string, fileUri?: string, line?: number, column?: number }>, offset: number = 0) {
1037
1106
  // Use offset to skip frames in the stack
1038
1107
  // offset 0 = first frame, offset 1 = second frame, etc.
1039
1108
 
1040
1109
  if (offset < stack.length) {
1041
1110
  const frame = stack[offset]
1042
1111
  return {
1043
- filepath: frame.filepath,
1112
+ fileUri: frame.fileUri,
1044
1113
  line: frame.line
1045
1114
  }
1046
1115
  }
@@ -1048,7 +1117,7 @@ function extractCallerInfo(stack: Array<{ function?: string, filepath?: string,
1048
1117
  // Fallback to first frame if offset is out of bounds
1049
1118
  if (stack.length > 0) {
1050
1119
  return {
1051
- filepath: stack[0].filepath,
1120
+ fileUri: stack[0].fileUri,
1052
1121
  line: stack[0].line
1053
1122
  }
1054
1123
  }
@@ -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`).
@@ -397,7 +397,7 @@ Both implement the same property mapping logic. The difference is observability.
397
397
  | `call` | Function invoked | `{ target, args, eventIndex }` |
398
398
  | `call-result` | Function returns | `{ target, result, callEventIndex }` |
399
399
 
400
- Events include `caller` context (source capsule, property, filepath, line) when `enableCallerStackInference` is enabled. Memoized results are tagged with `memoized: true`.
400
+ Events include `caller` context (source capsule, property, fileUri, line) when `enableCallerStackInference` is enabled. Memoized results are tagged with `memoized: true`.
401
401
 
402
402
  ### SpineRuntime & run()
403
403
 
@@ -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
  }
@@ -1028,7 +1006,7 @@ function extractModuleLocalCode(
1028
1006
  if (funcDecl) {
1029
1007
  // Analyze the function to see if it's self-contained
1030
1008
  const dependencies = new Set<string>()
1031
- const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies)
1009
+ const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies, moduleLocalVariables)
1032
1010
 
1033
1011
  if (isContained) {
1034
1012
  // Mark this as module-local in ambient references
@@ -1056,9 +1034,33 @@ function extractModuleLocalCode(
1056
1034
  // Collect the main function
1057
1035
  collectFunction(name)
1058
1036
 
1059
- // Collect all dependencies
1037
+ // Collect all dependencies (functions and variables)
1060
1038
  for (const dep of dependencies) {
1061
1039
  collectFunction(dep)
1040
+
1041
+ // Also collect module-local variable dependencies
1042
+ const depVarDecl = moduleLocalVariables.get(dep)
1043
+ if (depVarDecl && !processed.has(dep)) {
1044
+ processed.add(dep)
1045
+ const varStatement = depVarDecl.parent?.parent
1046
+ if (varStatement && ts.isVariableStatement(varStatement)) {
1047
+ const varCode = varStatement.getText(sourceFile)
1048
+ collectedCode.push(varCode)
1049
+ if (!moduleLocalCode[dep]) {
1050
+ moduleLocalCode[dep] = varCode
1051
+ }
1052
+ }
1053
+ // Mark the variable dependency in ambient references
1054
+ if (!ambientReferences[dep]) {
1055
+ ambientReferences[dep] = { type: 'module-local' }
1056
+ }
1057
+ // Recursively collect transitive variable dependencies
1058
+ collectTransitiveVariableDependencies(
1059
+ depVarDecl, sourceFile, importMap, assignmentMap,
1060
+ moduleLocalFunctions, moduleLocalVariables,
1061
+ ambientReferences, moduleLocalCode
1062
+ )
1063
+ }
1062
1064
  }
1063
1065
 
1064
1066
  // Store the collected code (main function with all dependencies)
@@ -1109,7 +1111,7 @@ function extractModuleLocalCode(
1109
1111
 
1110
1112
  // Analyze if it's self-contained
1111
1113
  const dependencies = new Set<string>()
1112
- const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies)
1114
+ const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies, moduleLocalVariables)
1113
1115
 
1114
1116
  if (isContained) {
1115
1117
  // Add this function to moduleLocalCode
@@ -1236,14 +1238,15 @@ function collectTransitiveVariableDependencies(
1236
1238
  visit(varDecl.initializer)
1237
1239
  }
1238
1240
 
1239
- // Analyze if a function is self-contained (only depends on other module-local functions or builtins)
1241
+ // Analyze if a function is self-contained (only depends on other module-local functions, variables, or builtins)
1240
1242
  function analyzeFunctionDependencies(
1241
1243
  funcDecl: ts.FunctionDeclaration,
1242
1244
  sourceFile: ts.SourceFile,
1243
1245
  importMap: Map<string, { importSpecifier: string, moduleUri: string }>,
1244
1246
  assignmentMap: Map<string, { importSpecifier: string, moduleUri: string }>,
1245
1247
  moduleLocalFunctions: Map<string, ts.FunctionDeclaration>,
1246
- dependencies: Set<string>
1248
+ dependencies: Set<string>,
1249
+ moduleLocalVariables?: Map<string, ts.VariableDeclaration>
1247
1250
  ): boolean {
1248
1251
  const localIdentifiers = new Set<string>()
1249
1252
  const nestedFunctionScopes = new Map<ts.Node, Set<string>>()
@@ -1370,6 +1373,12 @@ function analyzeFunctionDependencies(
1370
1373
  return
1371
1374
  }
1372
1375
 
1376
+ // Check if it's a module-local variable - add as dependency
1377
+ if (moduleLocalVariables?.has(identifierName)) {
1378
+ dependencies.add(identifierName)
1379
+ return
1380
+ }
1381
+
1373
1382
  // Check if it's a module-global builtin - allowed
1374
1383
  if (MODULE_GLOBAL_BUILTINS.has(identifierName)) {
1375
1384
  return
@@ -1703,7 +1712,7 @@ function extractCapsuleAmbientReferences(
1703
1712
  if (funcDecl) {
1704
1713
  // Analyze if it's self-contained
1705
1714
  const dependencies = new Set<string>()
1706
- const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies)
1715
+ const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies, moduleLocalVariables)
1707
1716
 
1708
1717
  if (isContained) {
1709
1718
  // Mark as module-local
@@ -1711,7 +1720,7 @@ function extractCapsuleAmbientReferences(
1711
1720
  type: 'module-local'
1712
1721
  }
1713
1722
 
1714
- // Add import dependencies from the function's body
1723
+ // Add import/variable dependencies from the function's body
1715
1724
  for (const depName of dependencies) {
1716
1725
  if (!ambientRefs[depName]) {
1717
1726
  const depImportInfo = importMap.get(depName)
@@ -1729,6 +1738,14 @@ function extractCapsuleAmbientReferences(
1729
1738
  importSpecifier: depAssignmentInfo.importSpecifier,
1730
1739
  moduleUri: depAssignmentInfo.moduleUri
1731
1740
  }
1741
+ } else if (moduleLocalVariables.has(depName)) {
1742
+ ambientRefs[depName] = {
1743
+ type: 'module-local'
1744
+ }
1745
+ } else if (moduleLocalFunctions.has(depName)) {
1746
+ ambientRefs[depName] = {
1747
+ type: 'module-local'
1748
+ }
1732
1749
  }
1733
1750
  }
1734
1751
  }
@@ -2075,7 +2092,7 @@ function extractAndValidateAmbientReferences(
2075
2092
  if (funcDecl) {
2076
2093
  // Analyze if it's self-contained
2077
2094
  const dependencies = new Set<string>()
2078
- const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies)
2095
+ const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies, moduleLocalVariables)
2079
2096
 
2080
2097
  if (isContained) {
2081
2098
  // Mark as module-local
@@ -2083,7 +2100,7 @@ function extractAndValidateAmbientReferences(
2083
2100
  type: 'module-local'
2084
2101
  }
2085
2102
 
2086
- // Add import dependencies from the function's body
2103
+ // Add import/variable dependencies from the function's body
2087
2104
  for (const depName of dependencies) {
2088
2105
  if (!ambientRefs[depName]) {
2089
2106
  const depImportInfo = importMap.get(depName)
@@ -2101,6 +2118,14 @@ function extractAndValidateAmbientReferences(
2101
2118
  importSpecifier: depAssignmentInfo.importSpecifier,
2102
2119
  moduleUri: depAssignmentInfo.moduleUri
2103
2120
  }
2121
+ } else if (moduleLocalVariables.has(depName)) {
2122
+ ambientRefs[depName] = {
2123
+ type: 'module-local'
2124
+ }
2125
+ } else if (moduleLocalFunctions.has(depName)) {
2126
+ ambientRefs[depName] = {
2127
+ type: 'module-local'
2128
+ }
2104
2129
  }
2105
2130
  }
2106
2131
  }