@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.
- package/README.md +2 -2
- package/package.json +4 -4
- package/src/capsule-projectors/{CapsuleModuleProjector.v0.ts → CapsuleModuleProjector.ts} +3 -3
- package/src/encapsulate.ts +89 -28
- package/src/spine-contracts/CapsuleSpineContract.v0/{Membrane.v0.ts → Membrane.ts} +177 -22
- package/src/spine-contracts/CapsuleSpineContract.v0/README.md +72 -4
- package/src/spine-contracts/CapsuleSpineContract.v0/{Static.v0.ts → Static.ts} +235 -28
- package/src/spine-factories/{CapsuleSpineFactory.v0.ts → CapsuleSpineFactory.ts} +172 -39
- package/src/spine-factories/TimingObserver.ts +24 -14
- package/src/{static-analyzer.v0.ts → static-analyzer.ts} +31 -4
- package/structs/Capsule.ts +5 -0
- package/structs/StructFactory.ts +90 -0
|
@@ -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
|
|
5
|
-
import { CapsuleModuleProjector } from "../../src/capsule-projectors/CapsuleModuleProjector
|
|
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.
|
|
133
|
-
// 2.
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
5
|
-
|
|
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 =
|
|
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 =
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/structs/Capsule.ts
CHANGED
|
@@ -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'
|