tjs-lang 0.7.6 → 0.7.8

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 (61) hide show
  1. package/CLAUDE.md +101 -26
  2. package/bin/docs.js +4 -1
  3. package/demo/docs.json +46 -12
  4. package/demo/src/examples.test.ts +1 -0
  5. package/demo/src/imports.test.ts +16 -4
  6. package/demo/src/imports.ts +60 -15
  7. package/demo/src/playground-shared.ts +9 -8
  8. package/demo/src/tfs-worker.js +205 -147
  9. package/demo/src/tjs-playground.ts +34 -10
  10. package/demo/src/ts-playground.ts +24 -8
  11. package/dist/index.js +140 -119
  12. package/dist/index.js.map +4 -4
  13. package/dist/src/lang/bool-coercion.d.ts +50 -0
  14. package/dist/src/lang/docs.d.ts +31 -6
  15. package/dist/src/lang/linter.d.ts +8 -0
  16. package/dist/src/lang/parser-transforms.d.ts +18 -0
  17. package/dist/src/lang/parser-types.d.ts +2 -0
  18. package/dist/src/lang/parser.d.ts +9 -0
  19. package/dist/src/lang/runtime.d.ts +34 -0
  20. package/dist/src/lang/types.d.ts +9 -1
  21. package/dist/src/rbac/index.d.ts +1 -1
  22. package/dist/src/vm/runtime.d.ts +1 -1
  23. package/dist/tjs-eval.js +44 -39
  24. package/dist/tjs-eval.js.map +4 -4
  25. package/dist/tjs-from-ts.js +20 -20
  26. package/dist/tjs-from-ts.js.map +3 -3
  27. package/dist/tjs-lang.js +86 -80
  28. package/dist/tjs-lang.js.map +4 -4
  29. package/dist/tjs-vm.js +50 -45
  30. package/dist/tjs-vm.js.map +4 -4
  31. package/llms.txt +79 -0
  32. package/package.json +3 -2
  33. package/src/cli/commands/convert.test.ts +16 -21
  34. package/src/lang/bool-coercion.test.ts +203 -0
  35. package/src/lang/bool-coercion.ts +314 -0
  36. package/src/lang/codegen.test.ts +177 -0
  37. package/src/lang/docs.test.ts +328 -1
  38. package/src/lang/docs.ts +424 -24
  39. package/src/lang/emitters/ast.ts +11 -12
  40. package/src/lang/emitters/dts.test.ts +41 -0
  41. package/src/lang/emitters/dts.ts +9 -0
  42. package/src/lang/emitters/js-tests.ts +16 -4
  43. package/src/lang/emitters/js.ts +208 -2
  44. package/src/lang/features.test.ts +22 -0
  45. package/src/lang/inference.ts +54 -0
  46. package/src/lang/linter.test.ts +104 -1
  47. package/src/lang/linter.ts +124 -1
  48. package/src/lang/parser-params.ts +31 -0
  49. package/src/lang/parser-transforms.ts +539 -6
  50. package/src/lang/parser-types.ts +2 -0
  51. package/src/lang/parser.test.ts +73 -1
  52. package/src/lang/parser.ts +85 -1
  53. package/src/lang/runtime.ts +98 -0
  54. package/src/lang/tests.ts +21 -8
  55. package/src/lang/types.ts +6 -0
  56. package/src/rbac/index.ts +2 -2
  57. package/src/rbac/rules.tjs.d.ts +9 -0
  58. package/src/vm/atoms/batteries.ts +2 -2
  59. package/src/vm/runtime.ts +10 -3
  60. package/dist/src/rbac/rules.d.ts +0 -184
  61. package/src/rbac/rules.js +0 -338
package/src/lang/docs.ts CHANGED
@@ -4,11 +4,73 @@
4
4
  * Dead simple: walk source in order, emit what you find.
5
5
  * - Doc blocks render as markdown
6
6
  * - Function signatures render as code blocks
7
+ * - Inline `test 'name' { ... }` blocks render as "Test Cases" section
8
+ * with `expect(...).toBe(...)` style assertions translated to comments
7
9
  *
8
10
  * No magic pairing. No attachment logic. The signature IS the docs.
9
11
  * Doc blocks are just editorial commentary when you need it.
10
12
  */
11
13
 
14
+ import { extractTests } from './tests'
15
+ import { typeDescriptorToTS } from './emitters/dts'
16
+ import type { TypeDescriptor } from './types'
17
+
18
+ /**
19
+ * Build a per-character boolean indicating whether the position is inside
20
+ * a block comment or line comment. Used so that class/function patterns
21
+ * don't match prose text inside doc blocks (e.g. `class Point { ... }`
22
+ * shown as an illustrative snippet).
23
+ */
24
+ function computeInComment(source: string): boolean[] {
25
+ const inComment = new Array<boolean>(source.length).fill(false)
26
+ let i = 0
27
+ while (i < source.length) {
28
+ const c = source[i]
29
+ const n = source[i + 1]
30
+ // Skip string literals so // and /* inside them are ignored
31
+ if (c === '"' || c === "'" || c === '`') {
32
+ const q = c
33
+ i++
34
+ while (i < source.length) {
35
+ if (source[i] === '\\') {
36
+ i += 2
37
+ continue
38
+ }
39
+ if (source[i] === q) {
40
+ i++
41
+ break
42
+ }
43
+ i++
44
+ }
45
+ continue
46
+ }
47
+ if (c === '/' && n === '/') {
48
+ while (i < source.length && source[i] !== '\n') {
49
+ inComment[i] = true
50
+ i++
51
+ }
52
+ continue
53
+ }
54
+ if (c === '/' && n === '*') {
55
+ const start = i
56
+ i += 2
57
+ while (
58
+ i < source.length - 1 &&
59
+ !(source[i] === '*' && source[i + 1] === '/')
60
+ ) {
61
+ i++
62
+ }
63
+ // include closing `*/`
64
+ const end = Math.min(source.length, i + 2)
65
+ for (let k = start; k < end; k++) inComment[k] = true
66
+ i = end
67
+ continue
68
+ }
69
+ i++
70
+ }
71
+ return inComment
72
+ }
73
+
12
74
  /**
13
75
  * Compute brace depth at each position in source.
14
76
  * Used to filter out constructs inside function bodies.
@@ -51,6 +113,12 @@ export interface DocResult {
51
113
  export type DocItem =
52
114
  | { type: 'doc'; content: string }
53
115
  | { type: 'function'; name: string; signature: string }
116
+ | {
117
+ type: 'class'
118
+ name: string
119
+ extendsName?: string
120
+ members: string[] // constructor / method signatures, no bodies
121
+ }
54
122
 
55
123
  /**
56
124
  * Generate documentation from TJS source
@@ -64,15 +132,20 @@ export function generateDocs(source: string): DocResult {
64
132
  // Build brace depth map to identify top-level constructs
65
133
  // This filters out doc blocks inside function bodies
66
134
  const braceDepthAt = computeBraceDepths(source)
135
+ // Track positions inside /* */ and // comments so we don't extract
136
+ // illustrative `class Foo { ... }` / `function bar() { ... }` text
137
+ // shown in `/*# ... */` doc blocks as real declarations.
138
+ const isInComment = computeInComment(source)
67
139
 
68
- // Find all doc blocks and functions, sort by position
140
+ // Find all doc blocks, functions, and classes; sort by position
69
141
  const docPattern = /\/\*#([\s\S]*?)\*\//g
70
- // Match TJS function syntax with return type annotations (:, :?, :!)
71
- // Return type can be quoted string with spaces (e.g. 'Hello, World!')
72
- const funcPattern =
73
- /function\s+(\w+)\s*\(([^)]*)\)\s*(?:(:[?!]?)\s*('[^']*'|"[^"]*"|[^\s{]+))?\s*\{/g
142
+ // Match the START of a function declaration. Params (which can contain
143
+ // nested parens like `fn = (x) => x`) are captured by balanced-paren
144
+ // scanning below, NOT by this regex.
145
+ const funcPattern = /\bfunction\s+(\w+)\s*\(/g
146
+ const classPattern = /\bclass\s+(\w+)(?:\s+extends\s+(\w+))?\s*\{/g
74
147
 
75
- type Match = { type: 'doc' | 'function'; index: number; data: any }
148
+ type Match = { type: 'doc' | 'function' | 'class'; index: number; data: any }
76
149
  const matches: Match[] = []
77
150
 
78
151
  let match
@@ -104,16 +177,53 @@ export function generateDocs(source: string): DocResult {
104
177
  }
105
178
 
106
179
  while ((match = funcPattern.exec(source)) !== null) {
180
+ if (isInComment[match.index]) continue
181
+ if (braceDepthAt[match.index] !== 0) continue
107
182
  const name = match[1]
108
- const params = match[2]
109
- const returnMarker = match[3] || ''
110
- const returnType = match[4] || ''
111
-
112
- let signature = `function ${name}(${params})`
113
- if (returnMarker && returnType) {
114
- signature += `${returnMarker} ${returnType}`
183
+ const parenOpen = match.index + match[0].length - 1 // position of `(`
184
+ const parenClose = findMatchingParen(source, parenOpen + 1)
185
+ if (parenClose === -1) continue
186
+ const params = source.slice(parenOpen + 1, parenClose)
187
+ // Optional return-type annotation between `)` and `{`:
188
+ // ): T / ):? T / ):! T
189
+ // T can be: primitive (`0`, `''`), object (`{ x: 0 }`), array (`[0]`),
190
+ // or string literal (`'Hello, World!'`). Use depth-tracking with a
191
+ // "started" flag so the FIRST `{` inside the type opens the type
192
+ // block (not the body) and the `{` AFTER depth returns to 0 is the body.
193
+ let after = parenClose + 1
194
+ let returnAnnotation = ''
195
+ while (after < source.length && /\s/.test(source[after])) after++
196
+ if (source[after] === ':') {
197
+ const annoStart = after
198
+ after++ // past `:`
199
+ let depth = 0
200
+ let inStr: string | null = null
201
+ let started = false
202
+ while (after < source.length) {
203
+ const c = source[after]
204
+ const prev = after > 0 ? source[after - 1] : ''
205
+ if (inStr) {
206
+ if (c === inStr && prev !== '\\') inStr = null
207
+ } else if (c === '"' || c === "'" || c === '`') {
208
+ inStr = c
209
+ started = true
210
+ } else if (c === '{') {
211
+ if (depth === 0 && started) break // body opens here
212
+ depth++
213
+ started = true
214
+ } else if (c === '(' || c === '[') {
215
+ depth++
216
+ started = true
217
+ } else if (c === '}' || c === ')' || c === ']') {
218
+ depth--
219
+ } else if (!/\s/.test(c)) {
220
+ started = true
221
+ }
222
+ after++
223
+ }
224
+ returnAnnotation = source.slice(annoStart, after).trimEnd()
115
225
  }
116
-
226
+ const signature = `function ${name}(${params})${returnAnnotation}`
117
227
  matches.push({
118
228
  type: 'function',
119
229
  index: match.index,
@@ -121,6 +231,23 @@ export function generateDocs(source: string): DocResult {
121
231
  })
122
232
  }
123
233
 
234
+ while ((match = classPattern.exec(source)) !== null) {
235
+ if (braceDepthAt[match.index] !== 0) continue
236
+ if (isInComment[match.index]) continue
237
+ const name = match[1]
238
+ const extendsName = match[2] || undefined
239
+ const bodyStart = match.index + match[0].length // just past `{`
240
+ const bodyEnd = findMatchingBrace(source, bodyStart - 1)
241
+ if (bodyEnd === -1) continue
242
+ const body = source.slice(bodyStart, bodyEnd)
243
+ const members = extractClassMembers(body)
244
+ matches.push({
245
+ type: 'class',
246
+ index: match.index,
247
+ data: { name, extendsName, members },
248
+ })
249
+ }
250
+
124
251
  // Sort by position in source
125
252
  matches.sort((a, b) => a.index - b.index)
126
253
 
@@ -128,34 +255,137 @@ export function generateDocs(source: string): DocResult {
128
255
  for (const m of matches) {
129
256
  if (m.type === 'doc') {
130
257
  items.push({ type: 'doc', content: m.data })
131
- } else {
258
+ } else if (m.type === 'function') {
132
259
  items.push({
133
260
  type: 'function',
134
261
  name: m.data.name,
135
262
  signature: m.data.signature,
136
263
  })
264
+ } else if (m.type === 'class') {
265
+ items.push({
266
+ type: 'class',
267
+ name: m.data.name,
268
+ extendsName: m.data.extendsName,
269
+ members: m.data.members,
270
+ })
137
271
  }
138
272
  }
139
273
 
140
274
  // Generate markdown
141
275
  const markdown = items
142
276
  .map((item) => {
143
- if (item.type === 'doc') {
144
- return item.content
145
- } else {
277
+ if (item.type === 'doc') return item.content
278
+ if (item.type === 'function') {
146
279
  return `\`\`\`tjs\n${item.signature}\n\`\`\``
147
280
  }
281
+ // class
282
+ return `\`\`\`tjs\n${formatClassSignature(item)}\n\`\`\``
148
283
  })
149
284
  .join('\n\n')
150
285
 
151
286
  return { items, markdown }
152
287
  }
153
288
 
289
+ /**
290
+ * Format a class as a signature-only block:
291
+ *
292
+ * class Color extends Hue {
293
+ * constructor(r: +0, g: +0, b: +0)
294
+ * constructor(hex: '#000000')
295
+ * toString()
296
+ * }
297
+ */
298
+ function formatClassSignature(item: {
299
+ name: string
300
+ extendsName?: string
301
+ members: string[]
302
+ }): string {
303
+ const head = item.extendsName
304
+ ? `class ${item.name} extends ${item.extendsName} {`
305
+ : `class ${item.name} {`
306
+ if (item.members.length === 0) return `${head}\n}`
307
+ const body = item.members.map((m) => ` ${m}`).join('\n')
308
+ return `${head}\n${body}\n}`
309
+ }
310
+
311
+ /**
312
+ * Find the index of the `}` matching the `{` at position `open`.
313
+ * Returns -1 if no match. Aware of strings and template literals so
314
+ * braces inside them don't confuse the count.
315
+ */
316
+ function findMatchingBrace(s: string, open: number): number {
317
+ let depth = 0
318
+ let i = open
319
+ let inStr: string | null = null
320
+ while (i < s.length) {
321
+ const c = s[i]
322
+ const prev = i > 0 ? s[i - 1] : ''
323
+ if (inStr) {
324
+ if (c === inStr && prev !== '\\') inStr = null
325
+ } else {
326
+ if (c === '"' || c === "'" || c === '`') inStr = c
327
+ else if (c === '{') depth++
328
+ else if (c === '}') {
329
+ depth--
330
+ if (depth === 0) return i
331
+ }
332
+ }
333
+ i++
334
+ }
335
+ return -1
336
+ }
337
+
338
+ /**
339
+ * Extract member signatures from a class body. Handles:
340
+ * - constructors (including multiple)
341
+ * - regular methods: `name(params) { ... }`
342
+ * - async / static / get / set modifiers
343
+ * - private fields with `#` prefix
344
+ * - return-type annotations: `name(p): ReturnType { ... }`
345
+ *
346
+ * Returns the bare signature without the body, like
347
+ * `static load(path: '')`
348
+ * `get magnitude(): 0.0`
349
+ */
350
+ function extractClassMembers(body: string): string[] {
351
+ const members: string[] = []
352
+ // Build brace depth WITHIN the body so we only pick top-level members
353
+ const depthInBody = computeBraceDepths(body)
354
+ // Match: optional modifier(s) + name + `(`
355
+ // Modifiers can chain: `static async`, `static get`
356
+ const memberPattern =
357
+ /(?:^|\n)\s*((?:(?:static|async|get|set)\s+)*)(constructor|#?\w+)\s*\(/g
358
+ let match
359
+ while ((match = memberPattern.exec(body)) !== null) {
360
+ // Skip if not at depth 0 of the body (i.e., inside a method body)
361
+ if (depthInBody[match.index] !== 0) continue
362
+ const modifiers = match[1].trim()
363
+ const name = match[2]
364
+ const parenOpen = match.index + match[0].length - 1
365
+ const parenClose = findMatchingParen(body, parenOpen + 1)
366
+ if (parenClose === -1) continue
367
+ const params = body.slice(parenOpen, parenClose + 1)
368
+ // Optional return-type annotation between `)` and `{`
369
+ let after = parenClose + 1
370
+ let returnAnnotation = ''
371
+ while (after < body.length && /\s/.test(body[after])) after++
372
+ if (body[after] === ':') {
373
+ // Capture `: <type>` or `:?<type>` or `:!<type>` — stop at `{`
374
+ const annoStart = after
375
+ while (after < body.length && body[after] !== '{') after++
376
+ returnAnnotation = body.slice(annoStart, after).trimEnd()
377
+ }
378
+ const prefix = modifiers ? `${modifiers} ` : ''
379
+ members.push(`${prefix}${name}${params}${returnAnnotation}`)
380
+ }
381
+ return members
382
+ }
383
+
154
384
  /**
155
385
  * Type metadata for a function parameter
156
386
  */
157
387
  export interface ParamTypeInfo {
158
- type?: { kind: string }
388
+ type?: TypeDescriptor
159
389
  required?: boolean
160
390
  example?: any
161
391
  }
@@ -165,7 +395,7 @@ export interface ParamTypeInfo {
165
395
  */
166
396
  export interface FunctionTypeInfo {
167
397
  params?: Record<string, ParamTypeInfo>
168
- returns?: { kind: string }
398
+ returns?: TypeDescriptor
169
399
  }
170
400
 
171
401
  /**
@@ -206,11 +436,16 @@ export function generateDocsMarkdown(
206
436
  markdown += '**Parameters:**\n'
207
437
  for (const [paramName, paramInfo] of Object.entries(info.params)) {
208
438
  const required = paramInfo.required ? '' : ' *(optional)*'
209
- const typeStr = paramInfo.type?.kind || 'any'
210
- const example =
439
+ const typeStr = paramInfo.type
440
+ ? typeDescriptorToTS(paramInfo.type)
441
+ : 'any'
442
+ // Skip the `(e.g. ...)` for non-serializable example values
443
+ // (functions JSON.stringify to undefined, which renders ugly).
444
+ const exampleStr =
211
445
  paramInfo.example !== undefined
212
- ? ` (e.g. \`${JSON.stringify(paramInfo.example)}\`)`
213
- : ''
446
+ ? safeJsonExample(paramInfo.example)
447
+ : null
448
+ const example = exampleStr ? ` (e.g. \`${exampleStr}\`)` : ''
214
449
  markdown += `- \`${paramName}\`: ${typeStr}${required}${example}\n`
215
450
  }
216
451
  markdown += '\n'
@@ -219,8 +454,173 @@ export function generateDocsMarkdown(
219
454
  if (info?.returns) {
220
455
  markdown += `**Returns:** ${info.returns.kind || 'void'}\n\n`
221
456
  }
457
+ } else if (item.type === 'class') {
458
+ markdown += `## ${item.name}\n\n`
459
+ const head = item.extendsName
460
+ ? `class ${item.name} extends ${item.extendsName} {`
461
+ : `class ${item.name} {`
462
+ const body =
463
+ item.members.length === 0
464
+ ? ''
465
+ : '\n' + item.members.map((m) => ` ${m}`).join('\n') + '\n'
466
+ markdown += '```tjs\n' + head + body + '}\n```\n\n'
222
467
  }
223
468
  }
224
469
 
470
+ // Append test cases as documentation. Each test's description names
471
+ // what it asserts; the body is rendered with expect(...).toBe(...) etc.
472
+ // translated to inline `// → ...` comments. Anonymous tests
473
+ // (auto-named `test 1`, `test 2`, ...) are skipped — they read as
474
+ // smoke tests, not documentation.
475
+ const { tests } = extractTests(source)
476
+ for (const test of tests) {
477
+ if (!test.description) continue
478
+ if (/^test \d+$/.test(test.description)) continue
479
+ markdown += `### ${test.description} (test cases)\n\n`
480
+ markdown += '```tjs\n'
481
+ markdown += prettifyTestBody(test.body).trim() + '\n'
482
+ markdown += '```\n\n'
483
+ }
484
+
225
485
  return markdown.trim() || '*No documentation available*'
226
486
  }
487
+
488
+ /**
489
+ * Translate `expect(actual).matcher(expected)` calls into inline comments
490
+ * for documentation rendering. Other lines (setup, console.log, etc.) are
491
+ * preserved as-is.
492
+ *
493
+ * expect(x).toBe(y) → x // → y
494
+ * expect(x).toEqual(y) → x // ≡ y (deep equality)
495
+ * expect(x).toBeTruthy() → x // → truthy
496
+ * expect(x).toBeFalsy() → x // → falsy
497
+ * expect(x).toBeNull() → x // → null
498
+ * expect(x).toBeUndefined() → x // → undefined
499
+ * expect(x).toContain(y) → x // → contains y
500
+ * expect(x).toThrow() → x // → throws
501
+ * expect(x).toBeGreaterThan(n) → x // → > n
502
+ * expect(x).toBeLessThan(n) → x // → < n
503
+ * expect(x).toBeNaN() → x // → NaN
504
+ *
505
+ * Uses balanced-paren scanning so nested calls (`expect(f(a, b)).toBe(c)`)
506
+ * work correctly.
507
+ */
508
+ export function prettifyTestBody(body: string): string {
509
+ let i = 0
510
+ let out = ''
511
+ let inStr: string | null = null
512
+ while (i < body.length) {
513
+ const c = body[i]
514
+ const prev = i > 0 ? body[i - 1] : ''
515
+ // Track string-literal state so `"expect(fake).toBe(...)"` inside a
516
+ // string is preserved verbatim.
517
+ if (inStr) {
518
+ out += c
519
+ if (c === inStr && prev !== '\\') inStr = null
520
+ i++
521
+ continue
522
+ }
523
+ if (c === '"' || c === "'" || c === '`') {
524
+ inStr = c
525
+ out += c
526
+ i++
527
+ continue
528
+ }
529
+
530
+ if (body.slice(i).startsWith('expect(')) {
531
+ const argStart = i + 'expect('.length
532
+ const argEnd = findMatchingParen(body, argStart)
533
+ if (argEnd > argStart) {
534
+ const after = body.slice(argEnd + 1)
535
+ const matcherMatch = after.match(/^\.(\w+)(\()?/)
536
+ if (matcherMatch && matcherMatch[2] === '(') {
537
+ const matcherStart = argEnd + 1 + matcherMatch[0].length
538
+ const matcherEnd = findMatchingParen(body, matcherStart)
539
+ if (matcherEnd >= matcherStart) {
540
+ const actual = body.slice(argStart, argEnd)
541
+ const matcherName = matcherMatch[1]
542
+ const expected = body.slice(matcherStart, matcherEnd)
543
+ out += renderMatcher(actual, matcherName, expected)
544
+ i = matcherEnd + 1
545
+ continue
546
+ }
547
+ }
548
+ }
549
+ }
550
+ out += c
551
+ i++
552
+ }
553
+ return out
554
+ }
555
+
556
+ /** Find the index of the `)` that matches the open paren at position `open-1`. */
557
+ function findMatchingParen(s: string, open: number): number {
558
+ let depth = 1
559
+ let i = open
560
+ let inStr: string | null = null
561
+ while (i < s.length) {
562
+ const c = s[i]
563
+ const prev = i > 0 ? s[i - 1] : ''
564
+ if (inStr) {
565
+ if (c === inStr && prev !== '\\') inStr = null
566
+ } else {
567
+ if (c === '"' || c === "'" || c === '`') inStr = c
568
+ else if (c === '(') depth++
569
+ else if (c === ')') {
570
+ depth--
571
+ if (depth === 0) return i
572
+ }
573
+ }
574
+ i++
575
+ }
576
+ return -1
577
+ }
578
+
579
+ /**
580
+ * JSON-stringify an example value, returning null if the result wouldn't
581
+ * be useful (e.g. functions stringify to `undefined`, which renders ugly).
582
+ */
583
+ function safeJsonExample(value: unknown): string | null {
584
+ if (typeof value === 'function') return null
585
+ try {
586
+ const s = JSON.stringify(value)
587
+ return s === undefined ? null : s
588
+ } catch {
589
+ return null
590
+ }
591
+ }
592
+
593
+ function renderMatcher(
594
+ actual: string,
595
+ matcher: string,
596
+ expected: string
597
+ ): string {
598
+ const a = actual.trim()
599
+ const e = expected.trim()
600
+ switch (matcher) {
601
+ case 'toBe':
602
+ return `${a} // → ${e}`
603
+ case 'toEqual':
604
+ return `${a} // ≡ ${e}`
605
+ case 'toBeTruthy':
606
+ return `${a} // → truthy`
607
+ case 'toBeFalsy':
608
+ return `${a} // → falsy`
609
+ case 'toBeNull':
610
+ return `${a} // → null`
611
+ case 'toBeUndefined':
612
+ return `${a} // → undefined`
613
+ case 'toContain':
614
+ return `${a} // → contains ${e}`
615
+ case 'toThrow':
616
+ return `${a} // → throws`
617
+ case 'toBeGreaterThan':
618
+ return `${a} // → > ${e}`
619
+ case 'toBeLessThan':
620
+ return `${a} // → < ${e}`
621
+ case 'toBeNaN':
622
+ return `${a} // → NaN`
623
+ default:
624
+ return `${a} // .${matcher}(${e})`
625
+ }
626
+ }
@@ -1420,13 +1420,14 @@ function expressionToExprNode(
1420
1420
  ...(isOptional && { optional: true }),
1421
1421
  }
1422
1422
  }
1423
- // For computed with variable, we'd need more complex handling
1424
- throw new TranspileError(
1425
- 'Computed member access with variables not yet supported',
1426
- getLocation(expr),
1427
- ctx.source,
1428
- ctx.filename
1429
- )
1423
+ // Computed with expression (e.g. arr[i]) emit as member with expr property
1424
+ return {
1425
+ $expr: 'member',
1426
+ object: obj,
1427
+ property: expressionToExprNode(prop, ctx),
1428
+ computed: true,
1429
+ ...(isOptional && { optional: true }),
1430
+ }
1430
1431
  }
1431
1432
 
1432
1433
  const propName = (mem.property as Identifier).name
@@ -1641,11 +1642,9 @@ function expressionToValue(expr: Expression, ctx: TransformContext): any {
1641
1642
  }
1642
1643
 
1643
1644
  if (mem.computed) {
1644
- // arr[0] - would need runtime evaluation
1645
- return `${objValue}[${expressionToValue(
1646
- mem.property as Expression,
1647
- ctx
1648
- )}]`
1645
+ // Computed member (arr[i] or arr[0]) always emit as $expr node so the
1646
+ // runtime evaluates the index rather than treating it as a string path.
1647
+ return expressionToExprNode(expr, ctx)
1649
1648
  }
1650
1649
 
1651
1650
  const prop = (mem.property as Identifier).name
@@ -96,6 +96,47 @@ describe('typeDescriptorToTS', () => {
96
96
  }
97
97
  expect(typeDescriptorToTS(td)).toBe('{ x: number } | null')
98
98
  })
99
+
100
+ it('renders function kind with named params and return type', () => {
101
+ expect(
102
+ typeDescriptorToTS({
103
+ kind: 'function',
104
+ params: [],
105
+ returns: { kind: 'any' },
106
+ })
107
+ ).toBe('() => any')
108
+ expect(
109
+ typeDescriptorToTS({
110
+ kind: 'function',
111
+ params: [{ name: 'x', type: { kind: 'any' } }],
112
+ returns: { kind: 'any' },
113
+ })
114
+ ).toBe('(x: any) => any')
115
+ expect(
116
+ typeDescriptorToTS({
117
+ kind: 'function',
118
+ params: [
119
+ { name: 'a', type: { kind: 'integer' } },
120
+ { name: 'b', type: { kind: 'integer' } },
121
+ ],
122
+ returns: { kind: 'integer' },
123
+ })
124
+ ).toBe('(a: number, b: number) => number')
125
+ })
126
+
127
+ it('renders function kind with no fields (defaults)', () => {
128
+ expect(typeDescriptorToTS({ kind: 'function' })).toBe('() => any')
129
+ })
130
+
131
+ it('renders function with object return type', () => {
132
+ expect(
133
+ typeDescriptorToTS({
134
+ kind: 'function',
135
+ params: [],
136
+ returns: { kind: 'object', shape: { a: { kind: 'integer' } } },
137
+ })
138
+ ).toBe('() => { a: number }')
139
+ })
99
140
  })
100
141
 
101
142
  describe('generateDTS', () => {
@@ -81,6 +81,15 @@ export function typeDescriptorToTS(td: TypeDescriptor): string {
81
81
  }
82
82
  base = 'any'
83
83
  break
84
+ case 'function': {
85
+ const params = td.params ?? []
86
+ const returns = td.returns ? typeDescriptorToTS(td.returns) : 'any'
87
+ const args = params
88
+ .map((p) => `${p.name}: ${typeDescriptorToTS(p.type)}`)
89
+ .join(', ')
90
+ base = `(${args}) => ${returns}`
91
+ break
92
+ }
84
93
  default:
85
94
  base = 'any'
86
95
  }