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

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.29",
3
+ "version": "0.4.0-rc.31",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -731,12 +731,20 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
731
731
  const ownSelf = merge({}, defaultInstance, defaultPropertyValues, ...Object.values(mergedValuesByContract))
732
732
 
733
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
734
742
  const absoluteCapsuleSourceLineRef = `${absoluteModuleFilepath}:${importStackLine}`
735
743
  const capsuleMetadataStruct: Record<string, any> = {
736
744
  capsuleName: encapsulateOptions.capsuleName,
737
745
  capsuleSourceLineRef: absoluteCapsuleSourceLineRef,
738
746
  capsuleSourceNameRefHash: cst?.capsuleSourceNameRefHash,
739
- moduleFilepath: absoluteModuleFilepath,
747
+ moduleFilepath: originalAbsoluteModuleFilepath,
740
748
  // Root capsule metadata will be populated after extends chain is resolved
741
749
  rootCapsule: {
742
750
  capsuleName: undefined as string | undefined,
@@ -854,7 +862,7 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
854
862
  const resolvedRootCapsule = rootCapsule || {
855
863
  capsuleName: encapsulateOptions.capsuleName!,
856
864
  capsuleSourceLineRef: absoluteCapsuleSourceLineRef,
857
- moduleFilepath: absoluteModuleFilepath
865
+ moduleFilepath: originalAbsoluteModuleFilepath
858
866
  }
859
867
  capsuleMetadataStruct.rootCapsule.capsuleName = resolvedRootCapsule.capsuleName
860
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?: {
@@ -185,7 +187,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
185
187
  const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot)
186
188
  if (stackFrames.length > 0) {
187
189
  const callerInfo = extractCallerInfo(stackFrames, 3)
188
- callerCtx.filepath = callerInfo.filepath
190
+ callerCtx.fileUri = callerInfo.fileUri
189
191
  callerCtx.line = callerInfo.line
190
192
  callerCtx.stack = stackFrames
191
193
  }
@@ -312,7 +314,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
312
314
  const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot)
313
315
  if (stackFrames.length > 0) {
314
316
  const callerInfo = extractCallerInfo(stackFrames, 3)
315
- callerCtx.filepath = callerInfo.filepath
317
+ callerCtx.fileUri = callerInfo.fileUri
316
318
  callerCtx.line = callerInfo.line
317
319
  callerCtx.stack = stackFrames
318
320
  }
@@ -375,7 +377,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
375
377
  const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot)
376
378
  if (stackFrames.length > 0) {
377
379
  const callerInfo = extractCallerInfo(stackFrames, 3)
378
- callerCtx.filepath = callerInfo.filepath
380
+ callerCtx.fileUri = callerInfo.fileUri
379
381
  callerCtx.line = callerInfo.line
380
382
  callerCtx.stack = stackFrames
381
383
  }
@@ -878,8 +880,8 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
878
880
  if (callerCtx.prop) {
879
881
  event.caller.prop = callerCtx.prop
880
882
  }
881
- if (callerCtx.filepath) {
882
- event.caller.filepath = callerCtx.filepath
883
+ if (callerCtx.fileUri) {
884
+ event.caller.fileUri = callerCtx.fileUri
883
885
  }
884
886
  if (callerCtx.line) {
885
887
  event.caller.line = callerCtx.line
@@ -975,9 +977,67 @@ CapsuleSpineContract['#'] = '@stream44.studio/encapsulate/spine-contracts/Capsul
975
977
 
976
978
 
977
979
 
978
- 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
+ // Only process absolute paths — skip V8 internal markers like "native", "node:*", etc.
985
+ if (!absoluteFilepath.startsWith('/')) {
986
+ return null
987
+ }
988
+
989
+ // Check for /node_modules/ in the path — use the last occurrence to handle nested node_modules
990
+ const nodeModulesMarker = '/node_modules/'
991
+ const lastIdx = absoluteFilepath.lastIndexOf(nodeModulesMarker)
992
+ if (lastIdx !== -1) {
993
+ return absoluteFilepath.substring(lastIdx + nodeModulesMarker.length)
994
+ }
995
+
996
+ let currentDir = dirname(absoluteFilepath)
997
+ const maxDepth = 20
998
+
999
+ for (let i = 0; i < maxDepth; i++) {
1000
+ if (npmUriCache.has(currentDir)) {
1001
+ const cachedName = npmUriCache.get(currentDir)
1002
+ if (cachedName) {
1003
+ const relativeFromPackage = relative(currentDir, absoluteFilepath)
1004
+ return `${cachedName}/${relativeFromPackage}`
1005
+ }
1006
+ // null means no package.json with name found at this level, continue up
1007
+ const parentDir = dirname(currentDir)
1008
+ if (parentDir === currentDir) break
1009
+ currentDir = parentDir
1010
+ continue
1011
+ }
1012
+
1013
+ const packageJsonPath = join(currentDir, 'package.json')
1014
+ try {
1015
+ if (existsSync(packageJsonPath)) {
1016
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
1017
+ const packageName = packageJson.name
1018
+ npmUriCache.set(currentDir, packageName || null)
1019
+ if (packageName) {
1020
+ const relativeFromPackage = relative(currentDir, absoluteFilepath)
1021
+ return `${packageName}/${relativeFromPackage}`
1022
+ }
1023
+ } else {
1024
+ npmUriCache.set(currentDir, null)
1025
+ }
1026
+ } catch {
1027
+ npmUriCache.set(currentDir, null)
1028
+ }
1029
+
1030
+ const parentDir = dirname(currentDir)
1031
+ if (parentDir === currentDir) break
1032
+ currentDir = parentDir
1033
+ }
1034
+
1035
+ return null
1036
+ }
1037
+
1038
+ function parseCallerFromStack(stack: string, spineFilesystemRoot?: string): Array<{ function?: string, fileUri?: string, line?: number, column?: number }> {
979
1039
  const lines = stack.split('\n')
980
- const result: Array<{ function?: string, filepath?: string, line?: number, column?: number }> = []
1040
+ const result: Array<{ function?: string, fileUri?: string, line?: number, column?: number }> = []
981
1041
 
982
1042
  // Skip first line (Error message), then collect ALL frames
983
1043
  for (let i = 1; i < lines.length; i++) {
@@ -989,9 +1049,11 @@ function parseCallerFromStack(stack: string, spineFilesystemRoot?: string): Arra
989
1049
  // "at functionName (file:line:column)"
990
1050
  const match = line.match(/at\s+(.+)/)
991
1051
  if (match) {
992
- const frame: { function?: string, filepath?: string, line?: number, column?: number } = {}
1052
+ const frame: { function?: string, fileUri?: string, line?: number, column?: number } = {}
993
1053
  const content = match[1]
994
1054
 
1055
+ let rawFilepath: string | undefined
1056
+
995
1057
  // Try to extract function name and location
996
1058
  const funcMatch = content.match(/^(.+?)\s+\((.+)\)$/)
997
1059
  if (funcMatch) {
@@ -1004,7 +1066,7 @@ function parseCallerFromStack(stack: string, spineFilesystemRoot?: string): Arra
1004
1066
  const location = funcMatch[2]
1005
1067
  const locMatch = location.match(/^(.+):(\d+):(\d+)$/)
1006
1068
  if (locMatch) {
1007
- frame.filepath = locMatch[1]
1069
+ rawFilepath = locMatch[1]
1008
1070
  frame.line = parseInt(locMatch[2], 10)
1009
1071
  frame.column = parseInt(locMatch[3], 10)
1010
1072
  }
@@ -1012,25 +1074,32 @@ function parseCallerFromStack(stack: string, spineFilesystemRoot?: string): Arra
1012
1074
  // No function name: "/path/to/file:line:column"
1013
1075
  const locMatch = content.match(/^(.+):(\d+):(\d+)$/)
1014
1076
  if (locMatch) {
1015
- frame.filepath = locMatch[1]
1077
+ rawFilepath = locMatch[1]
1016
1078
  frame.line = parseInt(locMatch[2], 10)
1017
1079
  frame.column = parseInt(locMatch[3], 10)
1018
1080
  }
1019
1081
  }
1020
1082
 
1021
- // Convert absolute paths to relative paths if spineFilesystemRoot is provided
1022
- if (frame.filepath && spineFilesystemRoot) {
1023
- if (frame.filepath.startsWith(spineFilesystemRoot)) {
1024
- frame.filepath = frame.filepath.slice(spineFilesystemRoot.length)
1025
- // Remove leading slash if present
1026
- if (frame.filepath.startsWith('/')) {
1027
- frame.filepath = frame.filepath.slice(1)
1083
+ // Convert absolute filepaths to npm URIs
1084
+ if (rawFilepath) {
1085
+ const npmUri = constructNpmUriSync(rawFilepath)
1086
+ if (npmUri) {
1087
+ // Strip file extension from URI for consistency
1088
+ frame.fileUri = npmUri.replace(/\.(ts|tsx|js|jsx)$/, '')
1089
+ } else if (spineFilesystemRoot && rawFilepath.startsWith(spineFilesystemRoot)) {
1090
+ // Fallback: use relative path from spine root if npm URI not resolvable
1091
+ let relativePath = rawFilepath.slice(spineFilesystemRoot.length)
1092
+ if (relativePath.startsWith('/')) {
1093
+ relativePath = relativePath.slice(1)
1028
1094
  }
1095
+ frame.fileUri = relativePath
1096
+ } else {
1097
+ frame.fileUri = rawFilepath
1029
1098
  }
1030
1099
  }
1031
1100
 
1032
1101
  // Include all frames, even if incomplete
1033
- if (frame.filepath || frame.function) {
1102
+ if (frame.fileUri || frame.function) {
1034
1103
  result.push(frame)
1035
1104
  }
1036
1105
  }
@@ -1038,14 +1107,14 @@ function parseCallerFromStack(stack: string, spineFilesystemRoot?: string): Arra
1038
1107
  return result
1039
1108
  }
1040
1109
 
1041
- function extractCallerInfo(stack: Array<{ function?: string, filepath?: string, line?: number, column?: number }>, offset: number = 0) {
1110
+ function extractCallerInfo(stack: Array<{ function?: string, fileUri?: string, line?: number, column?: number }>, offset: number = 0) {
1042
1111
  // Use offset to skip frames in the stack
1043
1112
  // offset 0 = first frame, offset 1 = second frame, etc.
1044
1113
 
1045
1114
  if (offset < stack.length) {
1046
1115
  const frame = stack[offset]
1047
1116
  return {
1048
- filepath: frame.filepath,
1117
+ fileUri: frame.fileUri,
1049
1118
  line: frame.line
1050
1119
  }
1051
1120
  }
@@ -1053,7 +1122,7 @@ function extractCallerInfo(stack: Array<{ function?: string, filepath?: string,
1053
1122
  // Fallback to first frame if offset is out of bounds
1054
1123
  if (stack.length > 0) {
1055
1124
  return {
1056
- filepath: stack[0].filepath,
1125
+ fileUri: stack[0].fileUri,
1057
1126
  line: stack[0].line
1058
1127
  }
1059
1128
  }
@@ -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
 
@@ -1006,7 +1006,7 @@ function extractModuleLocalCode(
1006
1006
  if (funcDecl) {
1007
1007
  // Analyze the function to see if it's self-contained
1008
1008
  const dependencies = new Set<string>()
1009
- const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies)
1009
+ const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies, moduleLocalVariables)
1010
1010
 
1011
1011
  if (isContained) {
1012
1012
  // Mark this as module-local in ambient references
@@ -1034,9 +1034,33 @@ function extractModuleLocalCode(
1034
1034
  // Collect the main function
1035
1035
  collectFunction(name)
1036
1036
 
1037
- // Collect all dependencies
1037
+ // Collect all dependencies (functions and variables)
1038
1038
  for (const dep of dependencies) {
1039
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
+ }
1040
1064
  }
1041
1065
 
1042
1066
  // Store the collected code (main function with all dependencies)
@@ -1087,7 +1111,7 @@ function extractModuleLocalCode(
1087
1111
 
1088
1112
  // Analyze if it's self-contained
1089
1113
  const dependencies = new Set<string>()
1090
- const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies)
1114
+ const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies, moduleLocalVariables)
1091
1115
 
1092
1116
  if (isContained) {
1093
1117
  // Add this function to moduleLocalCode
@@ -1214,14 +1238,15 @@ function collectTransitiveVariableDependencies(
1214
1238
  visit(varDecl.initializer)
1215
1239
  }
1216
1240
 
1217
- // 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)
1218
1242
  function analyzeFunctionDependencies(
1219
1243
  funcDecl: ts.FunctionDeclaration,
1220
1244
  sourceFile: ts.SourceFile,
1221
1245
  importMap: Map<string, { importSpecifier: string, moduleUri: string }>,
1222
1246
  assignmentMap: Map<string, { importSpecifier: string, moduleUri: string }>,
1223
1247
  moduleLocalFunctions: Map<string, ts.FunctionDeclaration>,
1224
- dependencies: Set<string>
1248
+ dependencies: Set<string>,
1249
+ moduleLocalVariables?: Map<string, ts.VariableDeclaration>
1225
1250
  ): boolean {
1226
1251
  const localIdentifiers = new Set<string>()
1227
1252
  const nestedFunctionScopes = new Map<ts.Node, Set<string>>()
@@ -1348,6 +1373,12 @@ function analyzeFunctionDependencies(
1348
1373
  return
1349
1374
  }
1350
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
+
1351
1382
  // Check if it's a module-global builtin - allowed
1352
1383
  if (MODULE_GLOBAL_BUILTINS.has(identifierName)) {
1353
1384
  return
@@ -1681,7 +1712,7 @@ function extractCapsuleAmbientReferences(
1681
1712
  if (funcDecl) {
1682
1713
  // Analyze if it's self-contained
1683
1714
  const dependencies = new Set<string>()
1684
- const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies)
1715
+ const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies, moduleLocalVariables)
1685
1716
 
1686
1717
  if (isContained) {
1687
1718
  // Mark as module-local
@@ -1689,7 +1720,7 @@ function extractCapsuleAmbientReferences(
1689
1720
  type: 'module-local'
1690
1721
  }
1691
1722
 
1692
- // Add import dependencies from the function's body
1723
+ // Add import/variable dependencies from the function's body
1693
1724
  for (const depName of dependencies) {
1694
1725
  if (!ambientRefs[depName]) {
1695
1726
  const depImportInfo = importMap.get(depName)
@@ -1707,6 +1738,14 @@ function extractCapsuleAmbientReferences(
1707
1738
  importSpecifier: depAssignmentInfo.importSpecifier,
1708
1739
  moduleUri: depAssignmentInfo.moduleUri
1709
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
+ }
1710
1749
  }
1711
1750
  }
1712
1751
  }
@@ -2053,7 +2092,7 @@ function extractAndValidateAmbientReferences(
2053
2092
  if (funcDecl) {
2054
2093
  // Analyze if it's self-contained
2055
2094
  const dependencies = new Set<string>()
2056
- const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies)
2095
+ const isContained = analyzeFunctionDependencies(funcDecl, sourceFile, importMap, assignmentMap, moduleLocalFunctions, dependencies, moduleLocalVariables)
2057
2096
 
2058
2097
  if (isContained) {
2059
2098
  // Mark as module-local
@@ -2061,7 +2100,7 @@ function extractAndValidateAmbientReferences(
2061
2100
  type: 'module-local'
2062
2101
  }
2063
2102
 
2064
- // Add import dependencies from the function's body
2103
+ // Add import/variable dependencies from the function's body
2065
2104
  for (const depName of dependencies) {
2066
2105
  if (!ambientRefs[depName]) {
2067
2106
  const depImportInfo = importMap.get(depName)
@@ -2079,6 +2118,14 @@ function extractAndValidateAmbientReferences(
2079
2118
  importSpecifier: depAssignmentInfo.importSpecifier,
2080
2119
  moduleUri: depAssignmentInfo.moduleUri
2081
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
+ }
2082
2129
  }
2083
2130
  }
2084
2131
  }