tjs-lang 0.8.2 → 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.
@@ -5,6 +5,11 @@
5
5
  * Uses __tjs metadata for rich type information.
6
6
  */
7
7
 
8
+ import {
9
+ collectScopeSymbols,
10
+ type ScopeSymbol,
11
+ } from '../../editors/scope-symbols'
12
+
8
13
  export interface Completion {
9
14
  /** The text to insert */
10
15
  label: string
@@ -277,6 +282,40 @@ function isInTestBlock(source: string, position: number): boolean {
277
282
  return testMatches.length > 0
278
283
  }
279
284
 
285
+ /**
286
+ * Completions from the scope-aware symbol model (acorn-based, destructuring-aware).
287
+ * Subsumes the old regex `extractFunctions`/`extractVariables`, which missed all
288
+ * destructuring. Falls back to the regex extractors only if parsing yields
289
+ * nothing (so the list never goes blank on pathological input).
290
+ */
291
+ function completionsFromScope(source: string, position: number): Completion[] {
292
+ const symbols = collectScopeSymbols(source, position)
293
+ if (symbols.length === 0) {
294
+ return [...extractFunctions(source), ...extractVariables(source, position)]
295
+ }
296
+ return symbols.map((s) => scopeSymbolToCompletion(s))
297
+ }
298
+
299
+ function scopeSymbolToCompletion(s: ScopeSymbol): Completion {
300
+ const kind: Completion['kind'] =
301
+ s.kind === 'function' ? 'function' : 'variable'
302
+ // For a destructured binding, show where it came from — `h1 ∈ elements`.
303
+ const detail =
304
+ s.origin?.member && s.origin.expr
305
+ ? `∈ ${s.origin.expr}`
306
+ : s.kind === 'import' && s.origin?.module
307
+ ? `import '${s.origin.module}'`
308
+ : s.kind === 'parameter'
309
+ ? 'parameter'
310
+ : undefined
311
+ return {
312
+ label: s.name,
313
+ kind,
314
+ detail,
315
+ snippet: kind === 'function' ? `${s.name}($1)` : undefined,
316
+ }
317
+ }
318
+
280
319
  /**
281
320
  * Extract function names from source
282
321
  */
@@ -418,8 +457,7 @@ export function getCompletions(ctx: CompletionContext): Completion[] {
418
457
  completions = [
419
458
  ...TJS_KEYWORDS,
420
459
  ...RUNTIME_COMPLETIONS,
421
- ...extractFunctions(source),
422
- ...extractVariables(source, position),
460
+ ...completionsFromScope(source, position),
423
461
  ]
424
462
 
425
463
  // Add metadata-based completions
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Introspection bridge — the runtime-truth half of the autocomplete plan.
3
+ *
4
+ * Chrome-console style: run the user's code in a hidden, disposable sandbox
5
+ * iframe and keep a live `eval` handle into its module scope. Member completion
6
+ * (`todoApp.items.` → the real array's members; `elements.` → tosijs's
7
+ * proxy-generated creators) then comes from the ACTUAL values, not a static
8
+ * model. The sandbox reuses the playground's run pipeline (transpile →
9
+ * rewriteImports → buildIframeDoc → SW-served URL) so imports resolve the same
10
+ * way the preview does.
11
+ *
12
+ * Lifecycle: `refresh(source)` rebuilds the sandbox (debounced by the caller, on
13
+ * statement boundaries) and caches the last good one — if the source doesn't
14
+ * transpile, the previous sandbox stays queryable. `members(path)` asks the
15
+ * sandbox to introspect a value, with a short timeout so the editor never hangs.
16
+ */
17
+ import { tjs } from '../../src/lang'
18
+ import { rewriteImports, registerIframeContent } from './imports'
19
+ import { buildIframeDoc } from './playground-shared'
20
+ import type { IntrospectMember } from '../../editors/introspect-value'
21
+
22
+ export class IntrospectionBridge {
23
+ private iframe: HTMLIFrameElement
24
+ private ready = false
25
+ private lastSource: string | null = null
26
+ private pending = new Map<number, (members: IntrospectMember[]) => void>()
27
+ private nextId = 1
28
+ private readonly onMessage: (e: MessageEvent) => void
29
+
30
+ constructor() {
31
+ const iframe = document.createElement('iframe')
32
+ iframe.setAttribute('aria-hidden', 'true')
33
+ iframe.setAttribute('tabindex', '-1')
34
+ // Off-screen and inert, but still a real DOM so tosijs `elements` work.
35
+ iframe.style.cssText =
36
+ 'position:absolute;width:1px;height:1px;left:-9999px;top:-9999px;border:0;visibility:hidden;pointer-events:none;'
37
+ document.body.appendChild(iframe)
38
+ this.iframe = iframe
39
+
40
+ this.onMessage = (e: MessageEvent) => {
41
+ const d = e.data
42
+ if (!d || e.source !== iframe.contentWindow) return
43
+ if (d.type === 'tjs-bridge-ready') {
44
+ this.ready = true
45
+ } else if (d.type === 'tjs-introspect-result') {
46
+ const resolve = this.pending.get(d.id)
47
+ if (resolve) {
48
+ this.pending.delete(d.id)
49
+ resolve(Array.isArray(d.members) ? d.members : [])
50
+ }
51
+ }
52
+ }
53
+ window.addEventListener('message', this.onMessage)
54
+ }
55
+
56
+ /**
57
+ * Rebuild the sandbox from the current source. No-op if unchanged or if the
58
+ * source doesn't transpile (keeps the last good sandbox). Caller should
59
+ * debounce this to statement boundaries.
60
+ */
61
+ async refresh(source: string): Promise<void> {
62
+ if (source === this.lastSource) return
63
+
64
+ let code: string
65
+ try {
66
+ const result = tjs(source)
67
+ code = result.code
68
+ } catch {
69
+ return // not transpilable yet — keep the last good sandbox
70
+ }
71
+ this.lastSource = source
72
+
73
+ const rewritten = rewriteImports(code)
74
+ const importStatements: string[] = []
75
+ const body = rewritten.replace(
76
+ /^import\s+(?:.*?from\s+)?['"][^'"]+['"];?\s*$/gm,
77
+ (match) => {
78
+ importStatements.push(match)
79
+ return ''
80
+ }
81
+ )
82
+
83
+ const doc = buildIframeDoc({
84
+ cssContent: '',
85
+ htmlContent: '',
86
+ importMapScript: '',
87
+ jsCode: body,
88
+ importStatements,
89
+ introspectionBridge: true,
90
+ })
91
+
92
+ this.ready = false
93
+ // Reject in-flight queries against the old sandbox.
94
+ for (const resolve of this.pending.values()) resolve([])
95
+ this.pending.clear()
96
+
97
+ const sessionId = `tjs-introspect-${Date.now()}-${Math.random()
98
+ .toString(36)
99
+ .slice(2)}`
100
+ const registered = await registerIframeContent(sessionId, doc)
101
+ if (registered) {
102
+ this.iframe.src = `/iframe/${sessionId}`
103
+ } else {
104
+ const blob = new Blob([doc], { type: 'text/html' })
105
+ this.iframe.src = URL.createObjectURL(blob)
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Introspect a dotted path in the live sandbox scope. Resolves to the value's
111
+ * real members, or `undefined` if the sandbox isn't ready / times out (so the
112
+ * caller can fall back to static completions).
113
+ */
114
+ members(
115
+ path: string,
116
+ timeoutMs = 500
117
+ ): Promise<IntrospectMember[] | undefined> {
118
+ const win = this.iframe.contentWindow
119
+ if (!this.ready || !win) return Promise.resolve(undefined)
120
+
121
+ const id = this.nextId++
122
+ return new Promise((resolve) => {
123
+ const timer = setTimeout(() => {
124
+ this.pending.delete(id)
125
+ resolve(undefined)
126
+ }, timeoutMs)
127
+ this.pending.set(id, (members) => {
128
+ clearTimeout(timer)
129
+ resolve(members)
130
+ })
131
+ win.postMessage({ type: 'tjs-introspect', id, path }, '*')
132
+ })
133
+ }
134
+
135
+ dispose(): void {
136
+ window.removeEventListener('message', this.onMessage)
137
+ this.iframe.remove()
138
+ this.pending.clear()
139
+ }
140
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+
3
+ // buildIframeDoc reads location.origin (browser global) — stub it for headless.
4
+ ;(globalThis as any).location ??= { origin: 'http://test' }
5
+
6
+ import { buildIframeDoc } from './playground-shared'
7
+
8
+ /**
9
+ * Structural guard for the introspection sandbox document. The critical
10
+ * invariant: user code must run at MODULE TOP LEVEL (not inside a try block) so
11
+ * its top-level `const`/`let` are module-scoped and the bridge's direct `eval`
12
+ * can see them. A regression here silently breaks `todoApp.` completion.
13
+ */
14
+ describe('buildIframeDoc — introspection bridge document', () => {
15
+ const doc = buildIframeDoc({
16
+ cssContent: '',
17
+ htmlContent: '',
18
+ importMapScript: '',
19
+ jsCode: 'const todoApp = { items: [] }',
20
+ importStatements: ["import { tosi } from 'https://cdn/tosijs'"],
21
+ introspectionBridge: true,
22
+ })
23
+
24
+ it('runs user code at module top level — NOT inside a try block', () => {
25
+ expect(doc).toContain('const todoApp = { items: [] }')
26
+ // The real invariant: after the bridge is installed, the user code runs with
27
+ // no try-wrap between (a block would re-scope its declarations away from
28
+ // eval). (The injected introspector has its own internal try/catch — that's
29
+ // fine and lives before the ready post.)
30
+ const readyIdx = doc.indexOf('tjs-bridge-ready')
31
+ const codeIdx = doc.indexOf('const todoApp')
32
+ expect(codeIdx).toBeGreaterThan(readyIdx)
33
+ expect(doc.slice(readyIdx, codeIdx)).not.toContain('try {')
34
+ })
35
+
36
+ it('installs the message listener and the injected introspector', () => {
37
+ expect(doc).toContain("d.type !== 'tjs-introspect'")
38
+ expect(doc).toContain('eval(d.path)')
39
+ expect(doc).toContain('function introspectValue') // injected source
40
+ })
41
+
42
+ it('posts bridge-ready and puts imports first', () => {
43
+ expect(doc).toContain('tjs-bridge-ready')
44
+ const importIdx = doc.indexOf('import { tosi }')
45
+ const listenerIdx = doc.indexOf('tjs-introspect')
46
+ const codeIdx = doc.indexOf('const todoApp')
47
+ // imports → listener → user code
48
+ expect(importIdx).toBeGreaterThan(-1)
49
+ expect(importIdx).toBeLessThan(listenerIdx)
50
+ expect(listenerIdx).toBeLessThan(codeIdx)
51
+ })
52
+
53
+ it('does not install the bridge for a normal preview doc', () => {
54
+ const preview = buildIframeDoc({
55
+ cssContent: '',
56
+ htmlContent: '',
57
+ importMapScript: '',
58
+ jsCode: 'console.log(1)',
59
+ })
60
+ expect(preview).not.toContain('tjs-introspect')
61
+ expect(preview).toContain('try {') // normal preview DOES wrap in try
62
+ })
63
+ })
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { CodeMirror } from '../../editors/codemirror/component'
9
+ import { INTROSPECT_VALUE_SOURCE } from '../../editors/introspect-value'
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // TJS Runtime for iframe — loaded from a built file, not a hand-maintained stub.
@@ -48,6 +49,32 @@ export interface IframeDocOptions {
48
49
  autoCallTjsFunction?: boolean
49
50
  /** Whether parent is in dark mode — sets color-scheme on iframe */
50
51
  darkMode?: boolean
52
+ /**
53
+ * Install the introspection bridge: after the user's code runs, a message
54
+ * listener answers `{type:'tjs-introspect', id, path}` by evaluating `path` in
55
+ * module scope and returning the value's real members. Used by the hidden
56
+ * autocomplete sandbox (IntrospectionBridge), not the visible preview.
57
+ */
58
+ introspectionBridge?: boolean
59
+ }
60
+
61
+ /**
62
+ * Script (injected at module top-level, after the user's code) that answers
63
+ * member-introspection queries from the parent. `eval(path)` is a DIRECT eval in
64
+ * the module's lexical scope, so it sees the user's top-level `const`/`let`
65
+ * bindings (e.g. `todoApp.items`). Runs only in the hidden sandbox.
66
+ */
67
+ function introspectionBridgeScript(): string {
68
+ return `
69
+ const __introspectValue = ${INTROSPECT_VALUE_SOURCE};
70
+ window.addEventListener('message', (e) => {
71
+ const d = e.data;
72
+ if (!d || d.type !== 'tjs-introspect') return;
73
+ let members = [];
74
+ try { members = __introspectValue(eval(d.path)); } catch (_e) {}
75
+ parent.postMessage({ type: 'tjs-introspect-result', id: d.id, members }, '*');
76
+ });
77
+ parent.postMessage({ type: 'tjs-bridge-ready' }, '*');`
51
78
  }
52
79
 
53
80
  /**
@@ -68,8 +95,34 @@ export function buildIframeDoc(options: IframeDocOptions): string {
68
95
  parentBindings = false,
69
96
  autoCallTjsFunction = false,
70
97
  darkMode = false,
98
+ introspectionBridge = false,
71
99
  } = options
72
100
 
101
+ // Introspection sandbox: run user code at MODULE TOP LEVEL (no try-wrap) so
102
+ // top-level `const`/`let` are module-scoped and the bridge's direct `eval` can
103
+ // see them. The listener is installed first; it's invoked later (at query
104
+ // time), by when the module — and `todoApp` — has fully initialised. Ready is
105
+ // posted up front so globals/imports stay introspectable even if user code
106
+ // throws partway. No try/catch precisely because a block would re-scope the
107
+ // declarations away from `eval`.
108
+ if (introspectionBridge) {
109
+ return `<!DOCTYPE html>
110
+ <html>
111
+ <head>
112
+ <style>:root { color-scheme: ${darkMode ? 'dark' : 'light dark'} }</style>
113
+ ${importMapScript}
114
+ </head>
115
+ <body>
116
+ ${runtimeScriptTag}
117
+ <script type="module">
118
+ ${importStatements.join('\n ')}
119
+ ${introspectionBridgeScript()}
120
+ ${jsCode}
121
+ </script>
122
+ </body>
123
+ </html>`
124
+ }
125
+
73
126
  const colorScheme = darkMode ? 'dark' : 'light dark'
74
127
 
75
128
  const parentBindingsScript = parentBindings
@@ -43,6 +43,7 @@ import {
43
43
  buildAutocompleteContext,
44
44
  type AutocompleteContext,
45
45
  } from './autocomplete-context'
46
+ import { IntrospectionBridge } from './introspection-bridge'
46
47
 
47
48
  // Available modules for autocomplete introspection
48
49
  const AVAILABLE_MODULES: Record<string, any> = {
@@ -166,6 +167,8 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
166
167
  private autocompleteContext: AutocompleteContext | null = null
167
168
  private contextUpdateTimer: ReturnType<typeof setTimeout> | null = null
168
169
  private contextBuildPromise: Promise<void> | null = null
170
+ // Hidden sandbox for runtime-truth member completion (todoApp.items, …)
171
+ private introspectionBridge: IntrospectionBridge | null = null
169
172
 
170
173
  /**
171
174
  * Get metadata for autocomplete - returns all discovered functions
@@ -185,6 +188,15 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
185
188
  return this.autocompleteContext?.bindings
186
189
  }
187
190
 
191
+ /**
192
+ * Member completion from the introspection bridge — the user's REAL executed
193
+ * scope (`todoApp.items` → the live array). Returns undefined if the sandbox
194
+ * isn't ready, so the provider falls back to static completions.
195
+ */
196
+ private getMembers = (path: string) => {
197
+ return this.introspectionBridge?.members(path) ?? Promise.resolve(undefined)
198
+ }
199
+
188
200
  /**
189
201
  * Update autocomplete context (debounced)
190
202
  * Called when editor content changes.
@@ -198,6 +210,14 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
198
210
  const doBuild = async () => {
199
211
  const source = this.parts.tjsEditor.value
200
212
 
213
+ // Refresh the introspection sandbox (runtime-truth member completion).
214
+ // Independent of import-binding resolution below; failures are swallowed
215
+ // inside the bridge (keeps the last good sandbox).
216
+ if (!this.introspectionBridge) {
217
+ this.introspectionBridge = new IntrospectionBridge()
218
+ }
219
+ void this.introspectionBridge.refresh(source)
220
+
201
221
  try {
202
222
  const newContext = await buildAutocompleteContext(source)
203
223
  // Only update if we got bindings (keep last good context otherwise)
@@ -649,6 +669,7 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
649
669
  this.parts.tjsEditor.autocomplete = {
650
670
  getMetadata: this.getMetadataForAutocomplete,
651
671
  getLiveBindings: this.getLiveBindings,
672
+ getMembers: this.getMembers,
652
673
  }
653
674
 
654
675
  // Build initial autocomplete context immediately
@@ -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