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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,10 @@
1
1
  import { join, dirname, relative, resolve as pathResolve } from 'path'
2
- import { writeFile, mkdir, readFile, stat } from 'fs/promises'
2
+ import { writeFile, mkdir, readFile, stat, access } from 'fs/promises'
3
+ import { createHash } from 'crypto'
3
4
  import { Spine, SpineRuntime, CapsulePropertyTypes, makeImportStack, merge } from "../encapsulate"
4
- import { StaticAnalyzer } from "../../src/static-analyzer.v0"
5
- import { CapsuleModuleProjector } from "../../src/capsule-projectors/CapsuleModuleProjector.v0"
5
+ import { StaticAnalyzer } from "../../src/static-analyzer"
6
+ import { CapsuleModuleProjector } from "../../src/capsule-projectors/CapsuleModuleProjector"
7
+ import { TimingObserver, type TimingObserverInterface } from "./TimingObserver"
6
8
 
7
9
 
8
10
  export { merge }
@@ -129,10 +131,39 @@ async function resolve(uri: string, fromPath: string, spineRoot?: string): Promi
129
131
  }
130
132
 
131
133
  // Also traverse up from fromPath (the importing file) checking:
132
- // 1. If current dir IS the package (self-package resolution)
133
- // 2. node_modules/@scope/pkg at each level
134
+ // 1. The scope/packages/pkg filesystem convention at each level
135
+ // 2. If current dir IS the package (self-package resolution)
136
+ // 3. node_modules/@scope/pkg at each level
134
137
  let fromDir = dirname(fromPath)
135
138
  while (true) {
139
+ // Check scope/packages/pkg filesystem convention
140
+ if (subpath) {
141
+ const fsPath = join(fromDir, scope, 'packages', pkg, subpath + '.ts')
142
+ try { await stat(fsPath); return fsPath } catch { }
143
+ try { await stat(join(fromDir, scope, 'packages', pkg, subpath)); return join(fromDir, scope, 'packages', pkg, subpath) } catch { }
144
+ }
145
+ const fsPkgDir = join(fromDir, scope, 'packages', pkg)
146
+ try {
147
+ const fsPjPath = join(fsPkgDir, 'package.json')
148
+ const fsPj = JSON.parse(await readFile(fsPjPath, 'utf-8'))
149
+ if (fsPj.name === `@${scope}/${pkg}`) {
150
+ if (subpath && fsPj.exports) {
151
+ const exportKey = './' + subpath
152
+ const exportValue = fsPj.exports[exportKey]
153
+ if (typeof exportValue === 'string') {
154
+ return pathResolve(fsPkgDir, exportValue)
155
+ }
156
+ } else if (!subpath && fsPj.exports?.['.']) {
157
+ const mainExport = fsPj.exports['.']
158
+ if (typeof mainExport === 'string') {
159
+ return pathResolve(fsPkgDir, mainExport)
160
+ }
161
+ } else if (!subpath && fsPj.main) {
162
+ return pathResolve(fsPkgDir, fsPj.main)
163
+ }
164
+ }
165
+ } catch { }
166
+
136
167
  // Check if this directory's package.json matches the requested package
137
168
  try {
138
169
  const pjPath = join(fromDir, 'package.json')
@@ -337,6 +368,31 @@ function createNpmUriForFilepath(): (filepath: string) => Promise<string | null>
337
368
  }
338
369
  }
339
370
 
371
+ // Cross-factory cache for module imports — avoids re-importing the same file
372
+ // across factory instances. The capsule() call still runs per-factory (spine-specific),
373
+ // but the import() is only done once per filepath globally.
374
+ const _moduleImportCache = new Map<string, Promise<any>>()
375
+
376
+ // Cross-factory stat cache — avoids redundant filesystem stat() calls for the same
377
+ // file across factory instances within a single process. Source file stats are cached
378
+ // permanently (source files don't change within a single build). Cache file stats are
379
+ // invalidated on write.
380
+ const _statCache = new Map<string, Promise<{ mtime: Date } | null>>()
381
+ function cachedStat(filepath: string): Promise<{ mtime: Date } | null> {
382
+ let cached = _statCache.get(filepath)
383
+ if (!cached) {
384
+ cached = stat(filepath).then(
385
+ s => ({ mtime: s.mtime }),
386
+ () => null
387
+ )
388
+ _statCache.set(filepath, cached)
389
+ }
390
+ return cached
391
+ }
392
+ function invalidateStatCache(filepath: string) {
393
+ _statCache.delete(filepath)
394
+ }
395
+
340
396
  export async function CapsuleSpineFactory({
341
397
  spineFilesystemRoot,
342
398
  capsuleModuleProjectionRoot,
@@ -354,13 +410,16 @@ export async function CapsuleSpineFactory({
354
410
  onMembraneEvent?: (event: any) => void,
355
411
  enableCallerStackInference?: boolean,
356
412
  spineContracts: Record<string, any>,
357
- timing?: { record: (step: string) => void, recordMajor: (step: string) => void, chalk?: any }
413
+ timing?: TimingObserverInterface | null
358
414
  }) {
359
415
 
360
416
  if (capsuleModuleProjectionRoot) capsuleModuleProjectionRoot = capsuleModuleProjectionRoot.replace(/^file:\/\//, '')
361
417
  if (spineFilesystemRoot) spineFilesystemRoot = spineFilesystemRoot.replace(/^file:\/\//, '')
362
418
 
363
- const timing = timingParam
419
+ // Auto-create timing when ENCAPSULATE_TRACE env var is set
420
+ const timing: TimingObserverInterface | undefined = timingParam || (
421
+ process.env.ENCAPSULATE_TRACE ? TimingObserver() : undefined
422
+ )
364
423
 
365
424
  timing?.recordMajor('CAPSULE SPINE FACTORY: INITIALIZATION')
366
425
 
@@ -368,6 +427,7 @@ export async function CapsuleSpineFactory({
368
427
  const registry = new Map<string, Promise<any>>()
369
428
 
370
429
  return {
430
+ has(id: string) { return registry.has(id) },
371
431
  async ensure(id: string, createHandler: () => Promise<any>) {
372
432
  if (!registry.has(id)) {
373
433
  registry.set(id, createHandler())
@@ -390,6 +450,7 @@ export async function CapsuleSpineFactory({
390
450
  const sourceSpine: { encapsulate?: any } = {}
391
451
  const npmUriForFilepath = createNpmUriForFilepath()
392
452
  const commonSpineContractOpts = {
453
+ timing,
393
454
  spineFilesystemRoot,
394
455
  npmUriForFilepath,
395
456
  resolve: async (uri: string, parentFilepath: string) => {
@@ -401,24 +462,25 @@ export async function CapsuleSpineFactory({
401
462
  return await resolve(uri, parentFilepath, spineFilesystemRoot)
402
463
  },
403
464
  importCapsule: (() => {
404
- return async (filepath: string) => {
465
+ const importFn = async (filepath: string) => {
405
466
  const shortPath = filepath.replace(/^.*\/genesis\//, '')
406
467
 
407
468
  timing?.record(`importCapsule: Called for ${shortPath}`)
408
469
  const result = await registry.ensure(filepath, async () => {
409
470
  timing?.recordMajor(`importCapsule: Starting import for ${shortPath}`)
410
471
  const importStart = Date.now()
411
- const exports = await import(filepath)
472
+ if (!_moduleImportCache.has(filepath)) {
473
+ _moduleImportCache.set(filepath, import(filepath))
474
+ }
475
+ const exports = await _moduleImportCache.get(filepath)!
412
476
  const importDuration = Date.now() - importStart
413
477
  timing?.recordMajor(`importCapsule: import() took ${importDuration}ms for ${shortPath}`)
414
478
 
415
- if (importDuration > 10) {
416
- if (timing) {
417
- console.log(timing.chalk.red(`\n⚠️ WARNING: Slow module load detected!`))
418
- console.log(timing.chalk.red(` Module: ${filepath}`))
419
- console.log(timing.chalk.red(` Load time: ${importDuration}ms`))
420
- console.log(timing.chalk.red(` Consider using dynamic imports to load heavy dependencies only when needed.\n`))
421
- }
479
+ if (importDuration > 10 && timing) {
480
+ console.log(timing.chalk.red(`\n⚠️ WARNING: Slow module load detected!`))
481
+ console.log(timing.chalk.red(` Module: ${filepath}`))
482
+ console.log(timing.chalk.red(` Load time: ${importDuration}ms`))
483
+ console.log(timing.chalk.red(` Consider using dynamic imports to load heavy dependencies only when needed.\n`))
422
484
  }
423
485
 
424
486
  if (typeof exports.capsule !== 'function') throw new Error(`Module at '${filepath}' does not export 'capsule'!`)
@@ -438,6 +500,7 @@ export async function CapsuleSpineFactory({
438
500
  })
439
501
  return result
440
502
  }
503
+ return importFn
441
504
  })(),
442
505
  encapsulateOpts: {
443
506
  CapsulePropertyTypes
@@ -541,10 +604,7 @@ export async function CapsuleSpineFactory({
541
604
 
542
605
  timing?.recordMajor('SPINE: INITIALIZATION')
543
606
 
544
- let { encapsulate, freeze, capsules } = await Spine({
545
- spineFilesystemRoot,
546
- timing,
547
- staticAnalyzer: staticAnalysisEnabled ? StaticAnalyzer({
607
+ const staticAnalyzer = staticAnalysisEnabled ? StaticAnalyzer({
548
608
  timing,
549
609
  cacheStore: {
550
610
  writeFile: async (filepath: string, content: string) => {
@@ -552,6 +612,7 @@ export async function CapsuleSpineFactory({
552
612
  const centralPath = join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath)
553
613
  await mkdir(dirname(centralPath), { recursive: true })
554
614
  await writeFile(centralPath, content, 'utf-8')
615
+ invalidateStatCache(centralPath)
555
616
  // Also write to local project cache if available
556
617
  if (capsuleModuleProjectionRoot) {
557
618
  try {
@@ -575,29 +636,20 @@ export async function CapsuleSpineFactory({
575
636
  return content
576
637
  },
577
638
  getStats: async (filepath: string) => {
578
- filepath = join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath)
579
- try {
580
- const stats = await stat(filepath)
581
- return { mtime: stats.mtime }
582
- } catch (error) {
583
- // File doesn't exist
584
- return null
585
- }
639
+ return cachedStat(join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath))
586
640
  },
587
641
  },
588
642
  spineStore: {
589
643
  getStats: async (filepath: string) => {
590
- filepath = join(spineFilesystemRoot, filepath)
591
- try {
592
- const stats = await stat(filepath)
593
- return { mtime: stats.mtime }
594
- } catch (error) {
595
- // File doesn't exist
596
- return null
597
- }
644
+ return cachedStat(join(spineFilesystemRoot, filepath))
598
645
  },
599
646
  },
600
- }) : undefined,
647
+ }) : undefined
648
+
649
+ let { encapsulate, freeze, capsules } = await Spine({
650
+ spineFilesystemRoot,
651
+ timing,
652
+ staticAnalyzer,
601
653
  spineContracts: spineContractInstances.encapsulation,
602
654
  projectionContext: capsuleModuleProjectionRoot ? {
603
655
  capsuleModuleProjectionPackage,
@@ -653,10 +705,16 @@ export async function CapsuleSpineFactory({
653
705
  return capsule
654
706
  }
655
707
 
708
+ // Track capsule refs known at freeze time so we can identify dynamic imports later
709
+ let frozenCapsuleRefs: Set<string> | null = null
710
+
656
711
  // Wrap freeze to also write spine instance (.sit.json) files
657
712
  const wrappedFreeze = async function () {
658
713
  const snapshot = await freeze()
659
714
 
715
+ // Record capsule refs at freeze time (before any dynamic imports)
716
+ frozenCapsuleRefs = new Set(Object.keys(capsules))
717
+
660
718
  // Write spine instance files if capsuleModuleProjectionRoot is available
661
719
  if (capsuleModuleProjectionRoot) {
662
720
  try {
@@ -693,6 +751,10 @@ export async function CapsuleSpineFactory({
693
751
  }
694
752
  }
695
753
 
754
+ // Check if any CSTs were regenerated — if not, we can skip SIT writes
755
+ // when existing files are still valid
756
+ const cstsChanged = !staticAnalyzer || staticAnalyzer.hasCacheMisses()
757
+
696
758
  for (const [, capsule] of Object.entries(uniqueCapsules)) {
697
759
  const cst = capsule.cst
698
760
  const rootCapsuleName = cst?.source?.capsuleName
@@ -708,6 +770,16 @@ export async function CapsuleSpineFactory({
708
770
  const sitDir = join(capsuleModuleProjectionRoot, '.~o/encapsulate.dev/spine-instances', dirName)
709
771
  const sitFilePath = join(sitDir, `root-capsule.sit.json`)
710
772
 
773
+ // Skip SIT generation if CSTs are unchanged and the file already exists
774
+ if (!cstsChanged) {
775
+ try {
776
+ await access(sitFilePath)
777
+ continue // File exists and no CSTs changed — skip
778
+ } catch {
779
+ // File doesn't exist — need to generate even though CSTs are cached
780
+ }
781
+ }
782
+
711
783
  // Build the capsules map
712
784
  const capsuleEntries: Record<string, { capsuleSourceUriLineRef: string }> = {}
713
785
  for (const [, cap] of Object.entries(uniqueCapsules)) {
@@ -721,7 +793,7 @@ export async function CapsuleSpineFactory({
721
793
 
722
794
  // Collect capsuleInstances from the cached root instance using an
723
795
  // iterative stack — each instance stores its ID and parent ID from init
724
- const rootInstance = await capsule.makeInstance()
796
+ const rootInstance = await capsule.makeInstance({ freezePhase: true })
725
797
  const capsuleInstances: Record<string, { capsuleName: string, capsuleSourceUriLineRef: string, parentCapsuleSourceUriLineRefInstanceId: string }> = {}
726
798
 
727
799
  // Iterative stack-based collection from instance tree
@@ -761,8 +833,18 @@ export async function CapsuleSpineFactory({
761
833
  capsuleInstances
762
834
  }
763
835
 
836
+ const sitContent = JSON.stringify(sitData, null, 2)
837
+
838
+ // Compare-before-write: skip disk write if content is identical
839
+ try {
840
+ const existing = await readFile(sitFilePath, 'utf-8')
841
+ if (existing === sitContent) continue
842
+ } catch {
843
+ // File doesn't exist — write it
844
+ }
845
+
764
846
  await mkdir(sitDir, { recursive: true })
765
- await writeFile(sitFilePath, JSON.stringify(sitData, null, 2), 'utf-8')
847
+ await writeFile(sitFilePath, sitContent, 'utf-8')
766
848
  }
767
849
  } catch (error) {
768
850
  // Spine instance file writing is best-effort
@@ -773,6 +855,55 @@ export async function CapsuleSpineFactory({
773
855
  return snapshot
774
856
  }
775
857
 
858
+ /**
859
+ * Write a dynamic SIT file capturing capsules that were loaded at runtime
860
+ * via importCapsule (not part of the static capsule tree).
861
+ * Call this after run() to record which dynamic capsules were used.
862
+ * The filename includes a content hash so unchanged combos produce the same file.
863
+ */
864
+ const writeDynamicSit = async function () {
865
+ if (!capsuleModuleProjectionRoot || !frozenCapsuleRefs) return
866
+
867
+ // Find capsules that were added after freeze (dynamic imports)
868
+ const dynamicCapsuleRefs: string[] = []
869
+ for (const key of Object.keys(capsules)) {
870
+ if (!frozenCapsuleRefs.has(key) && key.includes(':') && /:\d+$/.test(key)) {
871
+ dynamicCapsuleRefs.push(key)
872
+ }
873
+ }
874
+
875
+ if (dynamicCapsuleRefs.length === 0) return
876
+
877
+ // Build a sorted list of dynamic capsule entries for deterministic output
878
+ const dynamicEntries: Record<string, { capsuleSourceUriLineRef: string, capsuleName?: string }> = {}
879
+ for (const ref of dynamicCapsuleRefs.sort()) {
880
+ const cap = capsules[ref]
881
+ dynamicEntries[ref] = {
882
+ capsuleSourceUriLineRef: ref,
883
+ capsuleName: cap?.cst?.source?.capsuleName
884
+ }
885
+ }
886
+
887
+ const sitData = { dynamicCapsules: dynamicEntries }
888
+ const sitContent = JSON.stringify(sitData, null, 2)
889
+
890
+ // Content hash for the filename — same dynamic import combo = same file
891
+ const contentHash = createHash('sha256').update(sitContent).digest('hex').slice(0, 16)
892
+ const sitDir = join(capsuleModuleProjectionRoot, '.~o/encapsulate.dev/spine-instances')
893
+ const sitFilePath = join(sitDir, `dynamic-imports.${contentHash}.sit.json`)
894
+
895
+ // Skip write if file already exists (hash match = identical content)
896
+ try {
897
+ await access(sitFilePath)
898
+ return // Already exists with same content
899
+ } catch {
900
+ // File doesn't exist — write it
901
+ }
902
+
903
+ await mkdir(sitDir, { recursive: true })
904
+ await writeFile(sitFilePath, sitContent, 'utf-8')
905
+ }
906
+
776
907
  return {
777
908
  commonSpineContractOpts,
778
909
  CapsulePropertyTypes,
@@ -780,7 +911,9 @@ export async function CapsuleSpineFactory({
780
911
  encapsulate,
781
912
  run,
782
913
  freeze: wrappedFreeze,
914
+ writeDynamicSit,
783
915
  loadCapsule,
916
+ staticAnalyzer,
784
917
  spineContractInstances, // Expose for testing
785
918
  hoistSnapshot: async ({ snapshot }: { snapshot: any }) => {
786
919
 
@@ -1,26 +1,36 @@
1
1
 
2
2
  import chalk from 'chalk';
3
3
 
4
- export function TimingObserver({ startTime }: { startTime: number }) {
5
- let lastTime = startTime
4
+ export type TimingOptions = { color?: 'red' | 'green' | 'yellow' | 'cyan' | 'gray' }
5
+
6
+ export type TimingObserverInterface = {
7
+ record: (step: string, opts?: TimingOptions) => void,
8
+ recordMajor: (step: string, opts?: TimingOptions) => void,
9
+ chalk: typeof chalk,
10
+ }
11
+
12
+ export function TimingObserver(): TimingObserverInterface {
13
+ let lastTime = performance.now()
14
+
15
+ function format(msg: string, diff: string, opts?: TimingOptions): string {
16
+ const line = `[+${diff}ms] ${msg}`
17
+ if (opts?.color) return (chalk as any)[opts.color](line)
18
+ return line
19
+ }
6
20
 
7
21
  return {
8
22
  chalk,
9
- record: (step: string) => {
10
- const now = Date.now()
11
- const diff = now - lastTime
23
+ record: (step: string, opts?: TimingOptions) => {
24
+ const now = performance.now()
25
+ const diff = (now - lastTime).toFixed(1)
12
26
  lastTime = now
13
-
14
- const line = `[+${diff}ms] ${step}`
15
- console.log(diff > 10 ? chalk.red(line) : line)
27
+ console.log(format(` ${step}`, diff, opts))
16
28
  },
17
- recordMajor: (step: string) => {
18
- const now = Date.now()
19
- const diff = now - lastTime
29
+ recordMajor: (step: string, opts?: TimingOptions) => {
30
+ const now = performance.now()
31
+ const diff = (now - lastTime).toFixed(1)
20
32
  lastTime = now
21
-
22
- const line = `[+${diff}ms] ${step}`
23
- console.log(diff > 10 ? chalk.red(line) : chalk.cyan(line))
33
+ console.log(format(`★ ${step}`, diff, opts))
24
34
  }
25
35
  }
26
36
  }
@@ -3,6 +3,7 @@ import { join, normalize, dirname, resolve, relative } from 'path'
3
3
  import { readFile, stat } from 'fs/promises'
4
4
  import * as ts from 'typescript'
5
5
  import { createHash } from 'crypto'
6
+ import type { TimingObserverInterface } from './spine-factories/TimingObserver'
6
7
 
7
8
  // Known exports from @stream44.studio/encapsulate/encapsulate that can be imported
8
9
  const ENCAPSULATE_MODULE_EXPORTS = new Set([
@@ -85,6 +86,7 @@ const MODULE_GLOBAL_BUILTINS = new Set([
85
86
 
86
87
  // Bun runtime
87
88
  'Bun',
89
+ 'HTMLRewriter',
88
90
 
89
91
  // Node.js Buffer
90
92
  'Buffer',
@@ -218,6 +220,7 @@ const MODULE_GLOBAL_BUILTINS = new Set([
218
220
  'decodeURIComponent',
219
221
  'escape',
220
222
  'unescape',
223
+ 'eval',
221
224
  ])
222
225
 
223
226
  export function StaticAnalyzer({
@@ -225,7 +228,7 @@ export function StaticAnalyzer({
225
228
  cacheStore,
226
229
  spineStore
227
230
  }: {
228
- timing?: { record: (step: string) => void, chalk?: any },
231
+ timing?: TimingObserverInterface,
229
232
  cacheStore?: {
230
233
  writeFile?: (filepath: string, content: string) => Promise<void>,
231
234
  readFile?: (filepath: string) => Promise<string | undefined>,
@@ -238,8 +241,16 @@ export function StaticAnalyzer({
238
241
 
239
242
  timing?.record('StaticAnalyzer: Initialized')
240
243
 
244
+ let _cacheMissCount = 0
245
+
241
246
  return {
242
247
 
248
+ /** Returns true if any parseModule call resulted in a cache miss (CST regeneration) */
249
+ hasCacheMisses: () => _cacheMissCount > 0,
250
+
251
+ /** Reset the cache miss counter (call before a new freeze cycle) */
252
+ resetCacheMisses: () => { _cacheMissCount = 0 },
253
+
243
254
  parseModule: async ({ spineOptions, encapsulateOptions }: { spineOptions: any, encapsulateOptions: any }) => {
244
255
 
245
256
  const moduleFilepath = join(spineOptions.spineFilesystemRoot, encapsulateOptions.moduleFilepath)
@@ -280,7 +291,8 @@ export function StaticAnalyzer({
280
291
  // Check cache bust version - if mismatch, regenerate
281
292
  const cachedVersion = cachedCsts?.[capsuleSourceLineRef]?.cacheBustVersion
282
293
  if (encapsulateOptions.cacheBustVersion !== undefined && cachedVersion !== encapsulateOptions.cacheBustVersion) {
283
- timing?.record(timing?.chalk?.red?.(`StaticAnalyzer: Cache BUST (version mismatch: ${cachedVersion} !== ${encapsulateOptions.cacheBustVersion}) for ${encapsulateOptions.moduleFilepath}`))
294
+ _cacheMissCount++
295
+ timing?.record(`StaticAnalyzer: Cache BUST (version mismatch: ${cachedVersion} !== ${encapsulateOptions.cacheBustVersion}) for ${encapsulateOptions.moduleFilepath}`, { color: 'red' })
284
296
  } else {
285
297
  timing?.record(`StaticAnalyzer: Cache HIT for ${encapsulateOptions.moduleFilepath}`)
286
298
  return {
@@ -291,10 +303,12 @@ export function StaticAnalyzer({
291
303
  }
292
304
  }
293
305
  }
294
- timing?.record(timing?.chalk?.red?.(`StaticAnalyzer: Cache MISS for ${encapsulateOptions.moduleFilepath}`))
306
+ _cacheMissCount++
307
+ timing?.record(`StaticAnalyzer: Cache MISS for ${encapsulateOptions.moduleFilepath}`, { color: 'red' })
295
308
  } catch (error) {
296
309
  // Cache miss or error, continue with normal parsing
297
- timing?.record(timing?.chalk?.red?.(`StaticAnalyzer: Cache error for ${encapsulateOptions.moduleFilepath}`))
310
+ _cacheMissCount++
311
+ timing?.record(`StaticAnalyzer: Cache error for ${encapsulateOptions.moduleFilepath}`, { color: 'red' })
298
312
  }
299
313
  }
300
314
 
@@ -1939,6 +1953,19 @@ function extractAndValidateAmbientReferences(
1939
1953
  extractBindingIdentifiers(param.name)
1940
1954
  }
1941
1955
 
1956
+ // Pre-collect all function declarations from the function body (hoisting)
1957
+ // Function declarations are hoisted in JavaScript, so they can be referenced
1958
+ // before their source position. We must collect them before the main visit pass.
1959
+ function preCollectDeclarations(node: ts.Node) {
1960
+ if (ts.isFunctionDeclaration(node) && node.name && ts.isIdentifier(node.name)) {
1961
+ localIdentifiers.add(node.name.text)
1962
+ }
1963
+ ts.forEachChild(node, preCollectDeclarations)
1964
+ }
1965
+ if (fn.body) {
1966
+ preCollectDeclarations(fn.body)
1967
+ }
1968
+
1942
1969
  // Traverse the function body to find identifiers
1943
1970
  function visit(node: ts.Node) {
1944
1971
  // Skip type nodes to avoid false positives from type annotations
@@ -11,6 +11,11 @@ export async function capsule({
11
11
  type: CapsulePropertyTypes.Literal,
12
12
  value: undefined
13
13
  },
14
+
15
+ spineFilesystemRoot: {
16
+ type: CapsulePropertyTypes.Literal,
17
+ value: undefined as string | undefined
18
+ },
14
19
  }
15
20
  }
16
21
  }, {
@@ -0,0 +1,90 @@
1
+
2
+ export async function capsule({
3
+ encapsulate,
4
+ CapsulePropertyTypes,
5
+ makeImportStack
6
+ }: any) {
7
+ return encapsulate({
8
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
9
+ '#': {
10
+ // --------------------------------------------------------
11
+ // _instances — internal store for keyed instances
12
+ // --------------------------------------------------------
13
+ _instances: {
14
+ type: CapsulePropertyTypes.GetterFunction,
15
+ value: function () {
16
+ return new Map<string, any>();
17
+ },
18
+ memoize: true,
19
+ },
20
+
21
+ // --------------------------------------------------------
22
+ // _defaults — override in extending structs to provide
23
+ // default config for each new instance
24
+ // --------------------------------------------------------
25
+ _defaults: {
26
+ type: CapsulePropertyTypes.GetterFunction,
27
+ value: function () {
28
+ return {};
29
+ },
30
+ memoize: true,
31
+ },
32
+
33
+ // --------------------------------------------------------
34
+ // config — user-provided overrides applied to all instances
35
+ // --------------------------------------------------------
36
+ config: {
37
+ type: CapsulePropertyTypes.Literal,
38
+ value: {} as any,
39
+ },
40
+
41
+ // --------------------------------------------------------
42
+ // forInstance — get-or-create a config instance by key
43
+ // Pass an object whose keys together form a composite key.
44
+ // Returns a merged config (defaults + factory config + instance overrides).
45
+ // --------------------------------------------------------
46
+ forInstance: {
47
+ type: CapsulePropertyTypes.Function,
48
+ value: function (this: any, keyObj: Record<string, any>, instanceConfig?: Record<string, any>) {
49
+ const serializedKey = JSON.stringify(
50
+ Object.keys(keyObj).sort().reduce((acc: any, k: string) => {
51
+ acc[k] = keyObj[k];
52
+ return acc;
53
+ }, {} as any)
54
+ );
55
+
56
+ if (!instanceConfig && this._instances.has(serializedKey)) {
57
+ return this._instances.get(serializedKey);
58
+ }
59
+
60
+ const merged = {
61
+ ...this._defaults,
62
+ ...this.config,
63
+ ...keyObj,
64
+ ...(instanceConfig || {}),
65
+ ...(this._instances.get(serializedKey) || {}),
66
+ };
67
+
68
+ this._instances.set(serializedKey, merged);
69
+ return merged;
70
+ },
71
+ },
72
+
73
+ // --------------------------------------------------------
74
+ // getInstances — return all tracked instances
75
+ // --------------------------------------------------------
76
+ getInstances: {
77
+ type: CapsulePropertyTypes.Function,
78
+ value: function (this: any) {
79
+ return Array.from(this._instances.values());
80
+ },
81
+ },
82
+ }
83
+ }
84
+ }, {
85
+ importMeta: import.meta,
86
+ importStack: makeImportStack(),
87
+ capsuleName: capsule['#'],
88
+ })
89
+ }
90
+ capsule['#'] = '@stream44.studio/encapsulate/structs/StructFactory'