tjs-lang 0.8.1 → 0.8.3

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.
Files changed (78) hide show
  1. package/CLAUDE.md +13 -4
  2. package/demo/autocomplete.test.ts +37 -0
  3. package/demo/docs.json +698 -8
  4. package/demo/examples.test.ts +6 -2
  5. package/demo/src/autocomplete.ts +40 -2
  6. package/demo/src/introspection-bridge.ts +140 -0
  7. package/demo/src/introspection-doc.test.ts +63 -0
  8. package/demo/src/playground-shared.ts +101 -16
  9. package/demo/src/playground-test-results.test.ts +112 -0
  10. package/demo/src/tjs-playground.ts +32 -5
  11. package/dist/examples/modules/dist/main.d.ts +34 -0
  12. package/dist/examples/modules/dist/math.d.ts +120 -0
  13. package/dist/index.js +115 -112
  14. package/dist/index.js.map +4 -4
  15. package/dist/src/lang/core.d.ts +1 -1
  16. package/dist/src/lang/dialect.d.ts +35 -0
  17. package/dist/src/lang/emitters/ast.d.ts +1 -1
  18. package/dist/src/lang/emitters/js-tests.d.ts +7 -0
  19. package/dist/src/lang/emitters/js.d.ts +12 -0
  20. package/dist/src/lang/index.d.ts +2 -1
  21. package/dist/src/lang/parser-types.d.ts +17 -0
  22. package/dist/src/lang/parser.d.ts +18 -0
  23. package/dist/src/lang/transpiler.d.ts +1 -0
  24. package/dist/src/lang/types.d.ts +18 -0
  25. package/dist/src/vm/runtime.d.ts +18 -0
  26. package/dist/src/vm/vm.d.ts +15 -1
  27. package/dist/tjs-batteries.js +2 -2
  28. package/dist/tjs-batteries.js.map +2 -2
  29. package/dist/tjs-eval.js +43 -43
  30. package/dist/tjs-eval.js.map +3 -3
  31. package/dist/tjs-from-ts.js +1 -1
  32. package/dist/tjs-from-ts.js.map +2 -2
  33. package/dist/tjs-lang.js +76 -73
  34. package/dist/tjs-lang.js.map +4 -4
  35. package/dist/tjs-vm.js +49 -49
  36. package/dist/tjs-vm.js.map +3 -3
  37. package/editors/codemirror/ajs-language.ts +130 -44
  38. package/editors/codemirror/completion-source.test.ts +114 -0
  39. package/editors/introspect-value.test.ts +61 -0
  40. package/editors/introspect-value.ts +86 -0
  41. package/editors/scope-symbols.test.ts +113 -0
  42. package/editors/scope-symbols.ts +173 -0
  43. package/llms.txt +1 -0
  44. package/package.json +21 -1
  45. package/src/batteries/audit.ts +3 -2
  46. package/src/cli/commands/check.ts +3 -2
  47. package/src/cli/commands/emit.ts +4 -2
  48. package/src/cli/commands/run.ts +6 -2
  49. package/src/cli/commands/types.ts +2 -2
  50. package/src/lang/codegen.test.ts +4 -1
  51. package/src/lang/core.ts +6 -4
  52. package/src/lang/dialect.test.ts +63 -0
  53. package/src/lang/dialect.ts +50 -0
  54. package/src/lang/emitters/ast.ts +145 -2
  55. package/src/lang/emitters/js-tests.ts +46 -37
  56. package/src/lang/emitters/js.ts +19 -2
  57. package/src/lang/features.test.ts +6 -5
  58. package/src/lang/index.ts +40 -5
  59. package/src/lang/parser-types.ts +17 -0
  60. package/src/lang/parser.test.ts +12 -6
  61. package/src/lang/parser.ts +113 -3
  62. package/src/lang/predicate-schema.test.ts +97 -0
  63. package/src/lang/predicate-schema.ts +168 -0
  64. package/src/lang/predicate.test.ts +184 -0
  65. package/src/lang/predicate.ts +550 -0
  66. package/src/lang/subset-invariant.test.ts +90 -0
  67. package/src/lang/suggest.test.ts +84 -0
  68. package/src/lang/transpiler.ts +34 -0
  69. package/src/lang/types.ts +18 -0
  70. package/src/lang/wasm.test.ts +8 -2
  71. package/src/linalg/linalg.test.ts +8 -2
  72. package/src/use-cases/batteries.test.ts +4 -0
  73. package/src/use-cases/local-helpers.test.ts +219 -0
  74. package/src/use-cases/timeout-overrides.test.ts +169 -0
  75. package/src/vm/atom-effects.test.ts +72 -0
  76. package/src/vm/atoms/batteries.ts +29 -3
  77. package/src/vm/runtime.ts +140 -4
  78. package/src/vm/vm.ts +47 -9
@@ -50,6 +50,8 @@ import {
50
50
  FORBIDDEN_PATTERN,
51
51
  } from '../ajs-syntax'
52
52
  import { FORBIDDEN_KEYWORDS as TJS_FORBIDDEN_LIST } from '../tjs-syntax'
53
+ import { collectScopeSymbols } from '../scope-symbols'
54
+ import type { IntrospectMember } from '../introspect-value'
53
55
 
54
56
  /**
55
57
  * Forbidden keywords in AsyncJS - these will be highlighted as errors
@@ -341,6 +343,26 @@ export interface AutocompleteConfig {
341
343
  * e.g., { elements: Proxy, div: HTMLDivElement }
342
344
  */
343
345
  getLiveBindings?: () => Record<string, any> | undefined
346
+ /**
347
+ * Async member completion from the introspection bridge: given a dotted path
348
+ * (`todoApp.items`), returns the value's REAL runtime members from the user's
349
+ * executed scope in a sandbox — including proxy-generated ones nothing static
350
+ * can see. Used as a fallback when the path doesn't resolve in sync bindings.
351
+ */
352
+ getMembers?: (path: string) => Promise<IntrospectMember[] | undefined>
353
+ }
354
+
355
+ /** Map a serializable introspection member into a CodeMirror completion. */
356
+ function memberToCompletion(m: IntrospectMember): CMCompletion {
357
+ if (m.type === 'method') {
358
+ const hasArgs = m.detail !== '()'
359
+ return snippetCompletion(`${m.label}(${hasArgs ? '${1}' : ''})`, {
360
+ label: m.label,
361
+ type: 'method',
362
+ detail: m.detail,
363
+ })
364
+ }
365
+ return { label: m.label, type: 'property', detail: m.detail }
344
366
  }
345
367
 
346
368
  // TJS keywords with snippets
@@ -616,6 +638,41 @@ function extractVariables(source: string, position: number): CMCompletion[] {
616
638
  return completions
617
639
  }
618
640
 
641
+ /**
642
+ * Scope-aware locals: replaces the regex `extractFunctions`/`extractVariables`
643
+ * (which missed ALL destructuring — and the tosijs examples bind everything via
644
+ * destructuring, so nothing was suggested). Parses with acorn (acorn-loose
645
+ * fallback for mid-edit source) and walks binding patterns. Falls back to the
646
+ * regex extractors only if parsing yields nothing, so the list never goes blank.
647
+ */
648
+ function completionsFromScope(
649
+ source: string,
650
+ position: number
651
+ ): CMCompletion[] {
652
+ const symbols = collectScopeSymbols(source, position)
653
+ if (symbols.length === 0) {
654
+ return [...extractFunctions(source), ...extractVariables(source, position)]
655
+ }
656
+ return symbols.map((s) => {
657
+ const detail =
658
+ s.origin?.member && s.origin.expr
659
+ ? `∈ ${s.origin.expr}`
660
+ : s.kind === 'import' && s.origin?.module
661
+ ? `import '${s.origin.module}'`
662
+ : s.kind === 'parameter'
663
+ ? 'parameter'
664
+ : undefined
665
+ if (s.kind === 'function') {
666
+ return snippetCompletion(`${s.name}($1)`, {
667
+ label: s.name,
668
+ type: 'function',
669
+ detail,
670
+ })
671
+ }
672
+ return { label: s.name, type: 'variable', detail }
673
+ })
674
+ }
675
+
619
676
  /**
620
677
  * Curated property completions for common globals
621
678
  * These are hand-picked for usefulness with proper type signatures
@@ -1203,7 +1260,7 @@ function introspectObject(obj: any): CMCompletion[] {
1203
1260
 
1204
1261
  if (valueType === 'function') {
1205
1262
  // Try to get function signature from length
1206
- const fn = value as Function
1263
+ const fn = value as (...args: unknown[]) => unknown
1207
1264
  const paramCount = fn.length
1208
1265
  const params =
1209
1266
  paramCount > 0
@@ -1263,32 +1320,9 @@ function getCompletionsFromLiveBinding(
1263
1320
  name: string,
1264
1321
  liveBindings?: Record<string, any>
1265
1322
  ): CMCompletion[] {
1266
- // Debug: log what we're looking for and what we have
1267
- console.debug(
1268
- '[autocomplete] Looking for:',
1269
- name,
1270
- 'in bindings:',
1271
- liveBindings ? Object.keys(liveBindings) : 'none'
1272
- )
1273
-
1274
1323
  // First, check if we have a live binding for this name
1275
1324
  if (liveBindings && name in liveBindings) {
1276
- const value = liveBindings[name]
1277
- console.debug(
1278
- '[autocomplete] Found binding:',
1279
- name,
1280
- '=',
1281
- value,
1282
- 'type:',
1283
- typeof value
1284
- )
1285
- const completions = introspectObject(value)
1286
- console.debug(
1287
- '[autocomplete] Introspection returned',
1288
- completions.length,
1289
- 'completions'
1290
- )
1291
- return completions
1325
+ return introspectObject(liveBindings[name])
1292
1326
  }
1293
1327
 
1294
1328
  // Special case: if the name looks like it could be an HTML element variable,
@@ -1328,14 +1362,54 @@ function getCompletionsFromLiveBinding(
1328
1362
  }
1329
1363
 
1330
1364
  /**
1331
- * Extract the object name before a dot from source
1332
- * e.g., "console." -> "console", "Math.floor" -> "Math"
1365
+ * Extract the full dotted member path before a dot, e.g. `todoApp.items.` ->
1366
+ * `todoApp.items`. Only plain identifier chains (no calls / index access) — good
1367
+ * enough to walk a live value for member completion.
1333
1368
  */
1334
- function getObjectBeforeDot(source: string, dotPos: number): string | null {
1335
- // Look backwards from the dot to find the identifier
1369
+ function getPathBeforeDot(source: string, dotPos: number): string | null {
1336
1370
  const before = source.slice(0, dotPos)
1337
- const match = before.match(/(\w+)\s*$/)
1338
- return match ? match[1] : null
1371
+ const match = before.match(
1372
+ /([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*$/
1373
+ )
1374
+ return match ? match[1].replace(/\s+/g, '') : null
1375
+ }
1376
+
1377
+ /** Walk a dotted path against a bindings object, returning the resolved value. */
1378
+ function resolvePath(path: string, bindings?: Record<string, any>): any {
1379
+ if (!bindings) return undefined
1380
+ const parts = path.split('.')
1381
+ let value: any = bindings[parts[0]]
1382
+ for (let i = 1; i < parts.length && value != null; i++) {
1383
+ try {
1384
+ value = value[parts[i]]
1385
+ } catch {
1386
+ return undefined
1387
+ }
1388
+ }
1389
+ return value
1390
+ }
1391
+
1392
+ /**
1393
+ * Member completions for a dotted path resolved against live bindings — the
1394
+ * core of introspection-driven completion. `todoApp.items.` resolves the array
1395
+ * and introspects it (push/map/…). Single-segment names that don't resolve fall
1396
+ * back to the element-type heuristic.
1397
+ */
1398
+ function getCompletionsFromPath(
1399
+ path: string,
1400
+ liveBindings?: Record<string, any>
1401
+ ): CMCompletion[] {
1402
+ const value = resolvePath(path, liveBindings)
1403
+ if (
1404
+ value != null &&
1405
+ (typeof value === 'object' || typeof value === 'function')
1406
+ ) {
1407
+ return introspectObject(value)
1408
+ }
1409
+ if (!path.includes('.')) {
1410
+ return getCompletionsFromLiveBinding(path, liveBindings)
1411
+ }
1412
+ return []
1339
1413
  }
1340
1414
 
1341
1415
  /**
@@ -1395,10 +1469,13 @@ function getPlaceholderForParam(name: string, info: any): string {
1395
1469
  }
1396
1470
 
1397
1471
  /**
1398
- * Create TJS/AJS completion source
1472
+ * Create TJS/AJS completion source.
1473
+ * Exported for headless testing — it touches only DOM-free CodeMirror APIs.
1399
1474
  */
1400
- function tjsCompletionSource(config: AutocompleteConfig = {}) {
1401
- return (context: CMCompletionContext): CompletionResult | null => {
1475
+ export function tjsCompletionSource(config: AutocompleteConfig = {}) {
1476
+ return async (
1477
+ context: CMCompletionContext
1478
+ ): Promise<CompletionResult | null> => {
1402
1479
  try {
1403
1480
  // Get word at cursor
1404
1481
  const word = context.matchBefore(/[\w$]*/)
@@ -1434,16 +1511,26 @@ function tjsCompletionSource(config: AutocompleteConfig = {}) {
1434
1511
  if (/expect\s*\([^)]*\)\s*\.$/.test(before)) {
1435
1512
  options = EXPECT_MATCHERS
1436
1513
  } else {
1437
- // Try to get object name and introspect its properties
1438
- const objName = getObjectBeforeDot(source, word.from - 1)
1439
- if (objName) {
1440
- // First try curated/global completions
1441
- options = getPropertyCompletions(objName)
1442
-
1443
- // If no completions found, try live bindings (imports, variables)
1514
+ // Resolve the full dotted path so `todoApp.items.` works, not just
1515
+ // `todoApp.`. Curated/global completions are keyed by a single name.
1516
+ const path = getPathBeforeDot(source, word.from - 1)
1517
+ if (path) {
1518
+ if (!path.includes('.')) {
1519
+ options = getPropertyCompletions(path)
1520
+ }
1521
+ // Then introspect the live value the path resolves to in the sync
1522
+ // bindings (resolved imports).
1444
1523
  if (options.length === 0) {
1445
1524
  const liveBindings = config.getLiveBindings?.()
1446
- options = getCompletionsFromLiveBinding(objName, liveBindings)
1525
+ options = getCompletionsFromPath(path, liveBindings)
1526
+ }
1527
+ // Finally, the introspection bridge: the user's REAL executed scope
1528
+ // (`todoApp.items` → the live array's members), incl. proxy members.
1529
+ if (options.length === 0 && config.getMembers) {
1530
+ const members = await config.getMembers(path)
1531
+ if (members && members.length) {
1532
+ options = members.map(memberToCompletion)
1533
+ }
1447
1534
  }
1448
1535
  }
1449
1536
  }
@@ -1462,8 +1549,7 @@ function tjsCompletionSource(config: AutocompleteConfig = {}) {
1462
1549
  ...TJS_COMPLETIONS,
1463
1550
  ...RUNTIME_COMPLETIONS,
1464
1551
  ...GLOBAL_COMPLETIONS,
1465
- ...extractFunctions(source),
1466
- ...extractVariables(source, pos),
1552
+ ...completionsFromScope(source, pos),
1467
1553
  ]
1468
1554
 
1469
1555
  // Add metadata-based completions if available
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Drives the REAL completion source the playground uses (tjsCompletionSource),
3
+ * headlessly — its APIs (matchBefore / state.doc / pos / explicit) are DOM-free.
4
+ *
5
+ * Regression for the bug where the regex extractor missed destructuring, so the
6
+ * tosijs todo example (everything bound via destructuring) suggested nothing.
7
+ */
8
+ import { describe, it, expect } from 'bun:test'
9
+ import { EditorState } from '@codemirror/state'
10
+ import { CompletionContext } from '@codemirror/autocomplete'
11
+ import { tjsCompletionSource } from './ajs-language'
12
+ import type { AutocompleteConfig } from './ajs-language'
13
+
14
+ const TODO = `import { elements, tosi } from 'tosijs'
15
+ const { todoApp } = tosi({ todoApp: { items: [], newItem: '' } })
16
+ const { h1, ul, template, li, label, input, button } = elements
17
+ `
18
+
19
+ async function optionsFor(
20
+ source: string,
21
+ pos: number = source.length,
22
+ config: AutocompleteConfig = {}
23
+ ) {
24
+ const state = EditorState.create({ doc: source })
25
+ const ctx = new CompletionContext(state, pos, /* explicit */ true)
26
+ const result = await tjsCompletionSource(config)(ctx)
27
+ return result?.options ?? []
28
+ }
29
+ const labelsFor = async (...args: Parameters<typeof optionsFor>) =>
30
+ (await optionsFor(...args)).map((o) => o.label)
31
+
32
+ describe('tjsCompletionSource — scope-aware locals (live provider)', () => {
33
+ it('suggests destructured bindings from the real todo example', async () => {
34
+ const labels = await labelsFor(TODO)
35
+ expect(labels).toContain('todoApp')
36
+ expect(labels).toContain('h1')
37
+ expect(labels).toContain('button')
38
+ expect(labels).toContain('elements')
39
+ })
40
+
41
+ it('completes `tod` to todoApp', async () => {
42
+ expect(await labelsFor(TODO + 'tod')).toContain('todoApp')
43
+ })
44
+
45
+ it('annotates a destructured binding with its source (∈ elements)', async () => {
46
+ const opts = await optionsFor(TODO)
47
+ expect(opts.find((o) => o.label === 'h1')?.detail).toBe('∈ elements')
48
+ })
49
+ })
50
+
51
+ describe('tjsCompletionSource — member completion via live bindings (1b-i)', () => {
52
+ // Stand in for the user's executed scope, live (resolved imports path).
53
+ const liveBindings = {
54
+ todoApp: { items: ['bathe the cat'], newItem: '', addItem() {} },
55
+ }
56
+ const member = (tail: string) =>
57
+ labelsFor(`const x = 1\n${tail}`, undefined, {
58
+ getLiveBindings: () => liveBindings,
59
+ })
60
+
61
+ it('todoApp. → its real properties (items, newItem, addItem)', async () => {
62
+ const labels = await member('todoApp.')
63
+ expect(labels).toContain('items')
64
+ expect(labels).toContain('newItem')
65
+ expect(labels).toContain('addItem')
66
+ })
67
+
68
+ it('todoApp.items. → array methods (push, map) — nested path resolves', async () => {
69
+ const labels = await member('todoApp.items.')
70
+ expect(labels).toContain('push')
71
+ expect(labels).toContain('map')
72
+ })
73
+
74
+ it('unknown path resolves to nothing (no crash)', async () => {
75
+ expect(await member('todoApp.nope.')).toEqual([])
76
+ })
77
+ })
78
+
79
+ describe('tjsCompletionSource — member completion via the bridge (1b-ii)', () => {
80
+ // The introspection bridge: async, returns the REAL runtime members for a path
81
+ // the sync bindings don't know about (the user's own executed scope).
82
+ const getMembers = async (path: string) => {
83
+ if (path === 'todoApp')
84
+ return [
85
+ { label: 'items', type: 'property' as const, detail: 'object' },
86
+ { label: 'addItem', type: 'method' as const, detail: '()' },
87
+ ]
88
+ if (path === 'todoApp.items')
89
+ return [{ label: 'push', type: 'method' as const, detail: '(arg1)' }]
90
+ return []
91
+ }
92
+
93
+ it('falls back to the bridge when sync bindings miss the path', async () => {
94
+ const labels = await labelsFor('const x = 1\ntodoApp.', undefined, {
95
+ getMembers,
96
+ })
97
+ expect(labels).toContain('items')
98
+ expect(labels).toContain('addItem')
99
+ })
100
+
101
+ it('bridge resolves a nested path (todoApp.items.push)', async () => {
102
+ const labels = await labelsFor('const x = 1\ntodoApp.items.', undefined, {
103
+ getMembers,
104
+ })
105
+ expect(labels).toContain('push')
106
+ })
107
+
108
+ it('a method member becomes a callable completion', async () => {
109
+ const opts = await optionsFor('const x = 1\ntodoApp.', undefined, {
110
+ getMembers,
111
+ })
112
+ expect(opts.find((o) => o.label === 'addItem')?.type).toBe('method')
113
+ })
114
+ })
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { introspectValue, INTROSPECT_VALUE_SOURCE } from './introspect-value'
3
+
4
+ const labels = (v: unknown) => introspectValue(v).map((m) => m.label)
5
+ const member = (v: unknown, name: string) =>
6
+ introspectValue(v).find((m) => m.label === name)
7
+
8
+ describe('introspectValue — serializable runtime introspection', () => {
9
+ it('object → own properties and methods, typed', () => {
10
+ const todoApp = { items: [], newItem: '', addItem() {} }
11
+ const ls = labels(todoApp)
12
+ expect(ls).toContain('items')
13
+ expect(ls).toContain('newItem')
14
+ expect(ls).toContain('addItem')
15
+ expect(member(todoApp, 'addItem')?.type).toBe('method')
16
+ expect(member(todoApp, 'newItem')?.type).toBe('property')
17
+ expect(member(todoApp, 'newItem')?.detail).toBe('string')
18
+ })
19
+
20
+ it('array → inherited methods from the prototype (push, map)', () => {
21
+ const ls = labels([1, 2, 3])
22
+ expect(ls).toContain('push')
23
+ expect(ls).toContain('map')
24
+ expect(ls).toContain('filter')
25
+ expect(member([1], 'push')?.type).toBe('method')
26
+ })
27
+
28
+ it('method arity becomes an arg hint', () => {
29
+ const o = { f(a: unknown, b: unknown) {} }
30
+ void o.f
31
+ expect(member(o, 'f')?.detail).toBe('(arg1, arg2)')
32
+ })
33
+
34
+ it('skips constructor and underscore-prefixed members', () => {
35
+ const ls = labels({ _private: 1, visible: 2 })
36
+ expect(ls).toContain('visible')
37
+ expect(ls).not.toContain('_private')
38
+ expect(ls).not.toContain('constructor')
39
+ })
40
+
41
+ it('introspects a Proxy via own keys (tosijs `elements` shape)', () => {
42
+ // a proxy that materialises keys on access but DOES report own keys
43
+ const backing: Record<string, unknown> = { h1: () => {}, div: () => {} }
44
+ const proxy = new Proxy(backing, {})
45
+ const ls = labels(proxy)
46
+ expect(ls).toContain('h1')
47
+ expect(ls).toContain('div')
48
+ })
49
+
50
+ it('non-objects → nothing; never throws', () => {
51
+ expect(introspectValue(null)).toEqual([])
52
+ expect(introspectValue(42)).toEqual([])
53
+ expect(introspectValue(undefined)).toEqual([])
54
+ })
55
+
56
+ it('is self-contained source (injectable into the sandbox iframe)', () => {
57
+ // no outer references that would break when eval-ed in another realm
58
+ expect(INTROSPECT_VALUE_SOURCE).toContain('function introspectValue')
59
+ expect(INTROSPECT_VALUE_SOURCE).not.toMatch(/\bimport\b/)
60
+ })
61
+ })
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Serializable runtime introspection — the in-sandbox half of the introspection
3
+ * bridge. Given a live value, produce a flat list of member descriptors that
4
+ * survive `postMessage` (plain strings, no functions). The introspection bridge
5
+ * injects this function's SOURCE into the sandbox iframe (via `.toString()`), so
6
+ * it must be **self-contained** — no imports, no outer references.
7
+ *
8
+ * It mirrors the editor's own `introspectObject` (own keys first — important for
9
+ * proxies that cache accesses, like tosijs `elements` — then the prototype
10
+ * chain), but returns data instead of CodeMirror completions. The provider maps
11
+ * these descriptors back into completions on the parent side.
12
+ */
13
+
14
+ export interface IntrospectMember {
15
+ label: string
16
+ /** 'method' for callables, else 'property'. */
17
+ type: 'method' | 'property'
18
+ /** typeof for properties, an arg hint for methods. */
19
+ detail: string
20
+ }
21
+
22
+ export function introspectValue(value: unknown): IntrospectMember[] {
23
+ if (
24
+ value == null ||
25
+ (typeof value !== 'object' && typeof value !== 'function')
26
+ ) {
27
+ return []
28
+ }
29
+
30
+ const out: IntrospectMember[] = []
31
+ const seen = new Set<string>()
32
+
33
+ const push = (key: string, v: unknown) => {
34
+ if (key === 'constructor' || key.startsWith('_') || seen.has(key)) return
35
+ seen.add(key)
36
+ if (typeof v === 'function') {
37
+ const arity = (v as { length?: number }).length ?? 0
38
+ const params =
39
+ arity > 0
40
+ ? Array.from({ length: arity }, (_, i) => `arg${i + 1}`).join(', ')
41
+ : ''
42
+ out.push({ label: key, type: 'method', detail: `(${params})` })
43
+ } else {
44
+ out.push({ label: key, type: 'property', detail: typeof v })
45
+ }
46
+ }
47
+
48
+ // Own enumerable keys first — handles Proxies that materialise on access.
49
+ try {
50
+ for (const key of Object.keys(value as object)) {
51
+ try {
52
+ push(key, (value as Record<string, unknown>)[key])
53
+ } catch {
54
+ /* getter threw — skip */
55
+ }
56
+ }
57
+ } catch {
58
+ /* Object.keys failed — continue with the prototype walk */
59
+ }
60
+
61
+ // Own properties + prototype chain (inherited methods like Array#push).
62
+ let current: any = value
63
+ while (
64
+ current &&
65
+ current !== Object.prototype &&
66
+ current !== Function.prototype
67
+ ) {
68
+ for (const key of Object.getOwnPropertyNames(current)) {
69
+ if (key === 'constructor' || key.startsWith('_') || seen.has(key))
70
+ continue
71
+ try {
72
+ const d = Object.getOwnPropertyDescriptor(current, key)
73
+ const v = d?.value ?? (d?.get ? '[getter]' : undefined)
74
+ push(key, v)
75
+ } catch {
76
+ /* property threw on access — skip */
77
+ }
78
+ }
79
+ current = Object.getPrototypeOf(current)
80
+ }
81
+
82
+ return out
83
+ }
84
+
85
+ /** The function source, for injection into the sandbox iframe. */
86
+ export const INTROSPECT_VALUE_SOURCE = introspectValue.toString()
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { collectScopeSymbols, type ScopeSymbol } from './scope-symbols'
3
+
4
+ // `|` marks the cursor; returns source without it + the offset.
5
+ function at(src: string): { source: string; position: number } {
6
+ const position = src.indexOf('|')
7
+ if (position === -1) return { source: src, position: src.length }
8
+ return { source: src.slice(0, position) + src.slice(position + 1), position }
9
+ }
10
+
11
+ const names = (syms: ScopeSymbol[]) => syms.map((s) => s.name).sort()
12
+ const find = (syms: ScopeSymbol[], n: string) => syms.find((s) => s.name === n)
13
+
14
+ describe('collectScopeSymbols — destructuring-aware scope', () => {
15
+ it('object destructuring from a call (the bug: `const { todoApp } = tosi(...)`)', () => {
16
+ const { source } = at(`const { todoApp } = tosi({ items: [] })`)
17
+ const syms = collectScopeSymbols(source)
18
+ expect(names(syms)).toContain('todoApp')
19
+ expect(find(syms, 'todoApp')?.origin?.via).toBe('destructure')
20
+ expect(find(syms, 'todoApp')?.origin?.expr).toBe('tosi({ items: [] })')
21
+ })
22
+
23
+ it('multiple names destructured from an identifier carry member + source', () => {
24
+ const { source } = at(`const { h1, ul, button } = elements`)
25
+ const syms = collectScopeSymbols(source)
26
+ expect(names(syms)).toEqual(['button', 'h1', 'ul'])
27
+ // origin is the introspection hook: `elements.h1`
28
+ expect(find(syms, 'h1')?.origin?.expr).toBe('elements')
29
+ expect(find(syms, 'h1')?.origin?.member).toBe('h1')
30
+ })
31
+
32
+ it('array destructuring, holes skipped', () => {
33
+ const { source } = at(`const [first, , third] = items`)
34
+ expect(names(collectScopeSymbols(source))).toEqual(['first', 'third'])
35
+ })
36
+
37
+ it('rename binds the new name, records the source key', () => {
38
+ const { source } = at(`const { color: c } = theme`)
39
+ const syms = collectScopeSymbols(source)
40
+ expect(names(syms)).toEqual(['c'])
41
+ expect(find(syms, 'c')?.origin?.member).toBe('color')
42
+ })
43
+
44
+ it('defaults and rest', () => {
45
+ const a = collectScopeSymbols(at(`const { x = 1, ...rest } = o`).source)
46
+ expect(names(a)).toEqual(['rest', 'x'])
47
+ const b = collectScopeSymbols(at(`const [head, ...tail] = xs`).source)
48
+ expect(names(b)).toEqual(['head', 'tail'])
49
+ })
50
+
51
+ it('imports (named, default, namespace, renamed)', () => {
52
+ const { source } = at(
53
+ `import def, { tosi, elements as els } from 'tosijs'\nimport * as ns from 'x'`
54
+ )
55
+ const syms = collectScopeSymbols(source)
56
+ expect(names(syms)).toEqual(['def', 'els', 'ns', 'tosi'])
57
+ expect(find(syms, 'tosi')?.origin?.module).toBe('tosijs')
58
+ expect(find(syms, 'els')?.kind).toBe('import')
59
+ })
60
+
61
+ it('function name in scope; params only inside the body', () => {
62
+ const inside = at(`function ship(to, qty) {\n |\n}`)
63
+ const syms = collectScopeSymbols(inside.source, inside.position)
64
+ expect(names(syms)).toEqual(['qty', 'ship', 'to'])
65
+
66
+ const outside = at(`function ship(to, qty) {}\n|`)
67
+ const syms2 = collectScopeSymbols(outside.source, outside.position)
68
+ expect(names(syms2)).toContain('ship')
69
+ expect(names(syms2)).not.toContain('to')
70
+ })
71
+
72
+ it('destructured params too', () => {
73
+ const inside = at(`function render({ title, items }) {\n |\n}`)
74
+ const syms = collectScopeSymbols(inside.source, inside.position)
75
+ expect(names(syms)).toEqual(['items', 'render', 'title'])
76
+ })
77
+
78
+ it('position-scoped: declarations after the cursor are excluded', () => {
79
+ const { source, position } = at(`const before = 1\n|\nconst after = 2`)
80
+ const syms = collectScopeSymbols(source, position)
81
+ expect(names(syms)).toContain('before')
82
+ expect(names(syms)).not.toContain('after')
83
+ })
84
+
85
+ it('resilient to mid-edit / not-yet-valid source (acorn-loose)', () => {
86
+ // trailing incomplete line shouldn't blank out the earlier bindings
87
+ const { source, position } = at(`const { todoApp } = tosi({})\ntodoApp.i|`)
88
+ const syms = collectScopeSymbols(source, position)
89
+ expect(names(syms)).toContain('todoApp')
90
+ })
91
+
92
+ it('the real tosijs todo example surfaces every binding', () => {
93
+ const src = `import { elements, tosi } from 'tosijs'
94
+ const { todoApp } = tosi({ todoApp: { items: [], newItem: '' } })
95
+ const { h1, ul, template, li, label, input, button } = elements
96
+ `
97
+ const got = names(collectScopeSymbols(src))
98
+ for (const n of [
99
+ 'elements',
100
+ 'tosi',
101
+ 'todoApp',
102
+ 'h1',
103
+ 'ul',
104
+ 'template',
105
+ 'li',
106
+ 'label',
107
+ 'input',
108
+ 'button',
109
+ ]) {
110
+ expect(got).toContain(n)
111
+ }
112
+ })
113
+ })