tjs-lang 0.7.7 → 0.8.0

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 (70) hide show
  1. package/CLAUDE.md +99 -33
  2. package/bin/docs.js +4 -1
  3. package/demo/docs.json +104 -22
  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-examples.ts +8 -8
  11. package/demo/src/ts-playground.ts +24 -8
  12. package/dist/index.js +118 -101
  13. package/dist/index.js.map +4 -4
  14. package/dist/src/lang/bool-coercion.d.ts +50 -0
  15. package/dist/src/lang/docs.d.ts +31 -6
  16. package/dist/src/lang/linter.d.ts +8 -0
  17. package/dist/src/lang/parser-transforms.d.ts +18 -0
  18. package/dist/src/lang/parser-types.d.ts +2 -0
  19. package/dist/src/lang/parser.d.ts +3 -0
  20. package/dist/src/lang/runtime.d.ts +34 -0
  21. package/dist/src/lang/types.d.ts +9 -1
  22. package/dist/src/rbac/index.d.ts +1 -1
  23. package/dist/src/vm/runtime.d.ts +1 -1
  24. package/dist/tjs-eval.js +38 -36
  25. package/dist/tjs-eval.js.map +4 -4
  26. package/dist/tjs-from-ts.js +20 -20
  27. package/dist/tjs-from-ts.js.map +3 -3
  28. package/dist/tjs-lang.js +85 -83
  29. package/dist/tjs-lang.js.map +4 -4
  30. package/dist/tjs-vm.js +47 -45
  31. package/dist/tjs-vm.js.map +4 -4
  32. package/llms.txt +79 -0
  33. package/package.json +9 -4
  34. package/src/cli/commands/convert.test.ts +16 -21
  35. package/src/lang/bool-coercion.test.ts +203 -0
  36. package/src/lang/bool-coercion.ts +314 -0
  37. package/src/lang/codegen.test.ts +137 -0
  38. package/src/lang/docs.test.ts +476 -1
  39. package/src/lang/docs.ts +471 -37
  40. package/src/lang/emitters/ast.ts +11 -12
  41. package/src/lang/emitters/dts.test.ts +41 -0
  42. package/src/lang/emitters/dts.ts +9 -0
  43. package/src/lang/emitters/js-tests.ts +9 -4
  44. package/src/lang/emitters/js-wasm.ts +57 -65
  45. package/src/lang/emitters/js.ts +198 -3
  46. package/src/lang/features.test.ts +4 -3
  47. package/src/lang/index.ts +9 -0
  48. package/src/lang/inference.ts +54 -0
  49. package/src/lang/linter.test.ts +104 -1
  50. package/src/lang/linter.ts +124 -1
  51. package/src/lang/module-loader.test.ts +318 -0
  52. package/src/lang/module-loader.ts +419 -0
  53. package/src/lang/parser-params.ts +31 -0
  54. package/src/lang/parser-transforms.ts +640 -0
  55. package/src/lang/parser-types.ts +35 -0
  56. package/src/lang/parser.test.ts +73 -1
  57. package/src/lang/parser.ts +77 -3
  58. package/src/lang/runtime.ts +98 -0
  59. package/src/lang/types.ts +6 -0
  60. package/src/lang/wasm.test.ts +1293 -2
  61. package/src/lang/wasm.ts +470 -87
  62. package/src/linalg/index.tjs +119 -0
  63. package/src/linalg/linalg.test.ts +294 -0
  64. package/src/linalg/vector-search.bench.test.ts +395 -0
  65. package/src/rbac/index.ts +2 -2
  66. package/src/rbac/rules.tjs.d.ts +9 -0
  67. package/src/vm/atoms/batteries.ts +2 -2
  68. package/src/vm/runtime.ts +10 -3
  69. package/dist/src/rbac/rules.d.ts +0 -184
  70. 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,40 @@ 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
+ }
122
+
123
+ // Dedent a block of text by the smallest leading-whitespace indent
124
+ // found across non-empty lines.
125
+ function dedent(content: string): string {
126
+ const lines = content.split('\n')
127
+ const minIndent = lines
128
+ .filter((line) => line.trim().length > 0)
129
+ .reduce((min, line) => {
130
+ const indent = line.match(/^(\s*)/)?.[1].length || 0
131
+ return Math.min(min, indent)
132
+ }, Infinity)
133
+ if (minIndent === 0 || minIndent === Infinity) return content
134
+ return lines.map((line) => line.slice(minIndent)).join('\n')
135
+ }
136
+
137
+ // Strip the leading ` * ` from each line of a JSDoc comment body.
138
+ // The first line (between `/**` and the first newline) is left alone —
139
+ // content can appear there directly. Empty `*`-only lines become blank.
140
+ function stripJSDocAsterisks(content: string): string {
141
+ const lines = content.split('\n')
142
+ return lines
143
+ .map((line, i) => {
144
+ if (i === 0) return line
145
+ // Remove optional leading whitespace, then `*`, then optional single space
146
+ return line.replace(/^[ \t]*\*[ \t]?/, '')
147
+ })
148
+ .join('\n')
149
+ }
54
150
 
55
151
  /**
56
152
  * Generate documentation from TJS source
@@ -64,15 +160,24 @@ export function generateDocs(source: string): DocResult {
64
160
  // Build brace depth map to identify top-level constructs
65
161
  // This filters out doc blocks inside function bodies
66
162
  const braceDepthAt = computeBraceDepths(source)
163
+ // Track positions inside /* */ and // comments so we don't extract
164
+ // illustrative `class Foo { ... }` / `function bar() { ... }` text
165
+ // shown in `/*# ... */` doc blocks as real declarations.
166
+ const isInComment = computeInComment(source)
67
167
 
68
- // Find all doc blocks and functions, sort by position
168
+ // Find all doc blocks, functions, and classes; sort by position.
169
+ // Two doc-block flavors are recognized:
170
+ // /*# ... */ — TJS native: content is markdown verbatim
171
+ // /** ... */ — JSDoc: each line's leading ` * ` is stripped, then markdown
69
172
  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
173
+ const jsdocPattern = /\/\*\*([\s\S]*?)\*\//g
174
+ // Match the START of a function declaration. Params (which can contain
175
+ // nested parens like `fn = (x) => x`) are captured by balanced-paren
176
+ // scanning below, NOT by this regex.
177
+ const funcPattern = /\bfunction\s+(\w+)\s*\(/g
178
+ const classPattern = /\bclass\s+(\w+)(?:\s+extends\s+(\w+))?\s*\{/g
74
179
 
75
- type Match = { type: 'doc' | 'function'; index: number; data: any }
180
+ type Match = { type: 'doc' | 'function' | 'class'; index: number; data: any }
76
181
  const matches: Match[] = []
77
182
 
78
183
  let match
@@ -82,38 +187,77 @@ export function generateDocs(source: string): DocResult {
82
187
  continue
83
188
  }
84
189
 
85
- // Dedent content
86
- let content = match[1]
87
- const lines = content.split('\n')
88
- const minIndent = lines
89
- .filter((line) => line.trim().length > 0)
90
- .reduce((min, line) => {
91
- const indent = line.match(/^(\s*)/)?.[1].length || 0
92
- return Math.min(min, indent)
93
- }, Infinity)
190
+ const content = dedent(match[1]).trim()
191
+ if (!content) continue
94
192
 
95
- if (minIndent > 0 && minIndent < Infinity) {
96
- content = lines.map((line) => line.slice(minIndent)).join('\n')
97
- }
193
+ matches.push({
194
+ type: 'doc',
195
+ index: match.index,
196
+ data: content,
197
+ })
198
+ }
199
+
200
+ while ((match = jsdocPattern.exec(source)) !== null) {
201
+ if (braceDepthAt[match.index] !== 0) continue
202
+
203
+ const content = dedent(stripJSDocAsterisks(match[1])).trim()
204
+ if (!content) continue
98
205
 
99
206
  matches.push({
100
207
  type: 'doc',
101
208
  index: match.index,
102
- data: content.trim(),
209
+ data: content,
103
210
  })
104
211
  }
105
212
 
106
213
  while ((match = funcPattern.exec(source)) !== null) {
214
+ if (isInComment[match.index]) continue
215
+ if (braceDepthAt[match.index] !== 0) continue
107
216
  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}`
217
+ const parenOpen = match.index + match[0].length - 1 // position of `(`
218
+ const parenClose = findMatchingParen(source, parenOpen + 1)
219
+ if (parenClose === -1) continue
220
+ const params = source.slice(parenOpen + 1, parenClose)
221
+ // Optional return-type annotation between `)` and `{`:
222
+ // ): T / ):? T / ):! T
223
+ // T can be: primitive (`0`, `''`), object (`{ x: 0 }`), array (`[0]`),
224
+ // or string literal (`'Hello, World!'`). Use depth-tracking with a
225
+ // "started" flag so the FIRST `{` inside the type opens the type
226
+ // block (not the body) and the `{` AFTER depth returns to 0 is the body.
227
+ let after = parenClose + 1
228
+ let returnAnnotation = ''
229
+ while (after < source.length && /\s/.test(source[after])) after++
230
+ if (source[after] === ':') {
231
+ const annoStart = after
232
+ after++ // past `:`
233
+ let depth = 0
234
+ let inStr: string | null = null
235
+ let started = false
236
+ while (after < source.length) {
237
+ const c = source[after]
238
+ const prev = after > 0 ? source[after - 1] : ''
239
+ if (inStr) {
240
+ if (c === inStr && prev !== '\\') inStr = null
241
+ } else if (c === '"' || c === "'" || c === '`') {
242
+ inStr = c
243
+ started = true
244
+ } else if (c === '{') {
245
+ if (depth === 0 && started) break // body opens here
246
+ depth++
247
+ started = true
248
+ } else if (c === '(' || c === '[') {
249
+ depth++
250
+ started = true
251
+ } else if (c === '}' || c === ')' || c === ']') {
252
+ depth--
253
+ } else if (!/\s/.test(c)) {
254
+ started = true
255
+ }
256
+ after++
257
+ }
258
+ returnAnnotation = source.slice(annoStart, after).trimEnd()
115
259
  }
116
-
260
+ const signature = `function ${name}(${params})${returnAnnotation}`
117
261
  matches.push({
118
262
  type: 'function',
119
263
  index: match.index,
@@ -121,6 +265,23 @@ export function generateDocs(source: string): DocResult {
121
265
  })
122
266
  }
123
267
 
268
+ while ((match = classPattern.exec(source)) !== null) {
269
+ if (braceDepthAt[match.index] !== 0) continue
270
+ if (isInComment[match.index]) continue
271
+ const name = match[1]
272
+ const extendsName = match[2] || undefined
273
+ const bodyStart = match.index + match[0].length // just past `{`
274
+ const bodyEnd = findMatchingBrace(source, bodyStart - 1)
275
+ if (bodyEnd === -1) continue
276
+ const body = source.slice(bodyStart, bodyEnd)
277
+ const members = extractClassMembers(body)
278
+ matches.push({
279
+ type: 'class',
280
+ index: match.index,
281
+ data: { name, extendsName, members },
282
+ })
283
+ }
284
+
124
285
  // Sort by position in source
125
286
  matches.sort((a, b) => a.index - b.index)
126
287
 
@@ -128,34 +289,137 @@ export function generateDocs(source: string): DocResult {
128
289
  for (const m of matches) {
129
290
  if (m.type === 'doc') {
130
291
  items.push({ type: 'doc', content: m.data })
131
- } else {
292
+ } else if (m.type === 'function') {
132
293
  items.push({
133
294
  type: 'function',
134
295
  name: m.data.name,
135
296
  signature: m.data.signature,
136
297
  })
298
+ } else if (m.type === 'class') {
299
+ items.push({
300
+ type: 'class',
301
+ name: m.data.name,
302
+ extendsName: m.data.extendsName,
303
+ members: m.data.members,
304
+ })
137
305
  }
138
306
  }
139
307
 
140
308
  // Generate markdown
141
309
  const markdown = items
142
310
  .map((item) => {
143
- if (item.type === 'doc') {
144
- return item.content
145
- } else {
311
+ if (item.type === 'doc') return item.content
312
+ if (item.type === 'function') {
146
313
  return `\`\`\`tjs\n${item.signature}\n\`\`\``
147
314
  }
315
+ // class
316
+ return `\`\`\`tjs\n${formatClassSignature(item)}\n\`\`\``
148
317
  })
149
318
  .join('\n\n')
150
319
 
151
320
  return { items, markdown }
152
321
  }
153
322
 
323
+ /**
324
+ * Format a class as a signature-only block:
325
+ *
326
+ * class Color extends Hue {
327
+ * constructor(r: +0, g: +0, b: +0)
328
+ * constructor(hex: '#000000')
329
+ * toString()
330
+ * }
331
+ */
332
+ function formatClassSignature(item: {
333
+ name: string
334
+ extendsName?: string
335
+ members: string[]
336
+ }): string {
337
+ const head = item.extendsName
338
+ ? `class ${item.name} extends ${item.extendsName} {`
339
+ : `class ${item.name} {`
340
+ if (item.members.length === 0) return `${head}\n}`
341
+ const body = item.members.map((m) => ` ${m}`).join('\n')
342
+ return `${head}\n${body}\n}`
343
+ }
344
+
345
+ /**
346
+ * Find the index of the `}` matching the `{` at position `open`.
347
+ * Returns -1 if no match. Aware of strings and template literals so
348
+ * braces inside them don't confuse the count.
349
+ */
350
+ function findMatchingBrace(s: string, open: number): number {
351
+ let depth = 0
352
+ let i = open
353
+ let inStr: string | null = null
354
+ while (i < s.length) {
355
+ const c = s[i]
356
+ const prev = i > 0 ? s[i - 1] : ''
357
+ if (inStr) {
358
+ if (c === inStr && prev !== '\\') inStr = null
359
+ } else {
360
+ if (c === '"' || c === "'" || c === '`') inStr = c
361
+ else if (c === '{') depth++
362
+ else if (c === '}') {
363
+ depth--
364
+ if (depth === 0) return i
365
+ }
366
+ }
367
+ i++
368
+ }
369
+ return -1
370
+ }
371
+
372
+ /**
373
+ * Extract member signatures from a class body. Handles:
374
+ * - constructors (including multiple)
375
+ * - regular methods: `name(params) { ... }`
376
+ * - async / static / get / set modifiers
377
+ * - private fields with `#` prefix
378
+ * - return-type annotations: `name(p): ReturnType { ... }`
379
+ *
380
+ * Returns the bare signature without the body, like
381
+ * `static load(path: '')`
382
+ * `get magnitude(): 0.0`
383
+ */
384
+ function extractClassMembers(body: string): string[] {
385
+ const members: string[] = []
386
+ // Build brace depth WITHIN the body so we only pick top-level members
387
+ const depthInBody = computeBraceDepths(body)
388
+ // Match: optional modifier(s) + name + `(`
389
+ // Modifiers can chain: `static async`, `static get`
390
+ const memberPattern =
391
+ /(?:^|\n)\s*((?:(?:static|async|get|set)\s+)*)(constructor|#?\w+)\s*\(/g
392
+ let match
393
+ while ((match = memberPattern.exec(body)) !== null) {
394
+ // Skip if not at depth 0 of the body (i.e., inside a method body)
395
+ if (depthInBody[match.index] !== 0) continue
396
+ const modifiers = match[1].trim()
397
+ const name = match[2]
398
+ const parenOpen = match.index + match[0].length - 1
399
+ const parenClose = findMatchingParen(body, parenOpen + 1)
400
+ if (parenClose === -1) continue
401
+ const params = body.slice(parenOpen, parenClose + 1)
402
+ // Optional return-type annotation between `)` and `{`
403
+ let after = parenClose + 1
404
+ let returnAnnotation = ''
405
+ while (after < body.length && /\s/.test(body[after])) after++
406
+ if (body[after] === ':') {
407
+ // Capture `: <type>` or `:?<type>` or `:!<type>` — stop at `{`
408
+ const annoStart = after
409
+ while (after < body.length && body[after] !== '{') after++
410
+ returnAnnotation = body.slice(annoStart, after).trimEnd()
411
+ }
412
+ const prefix = modifiers ? `${modifiers} ` : ''
413
+ members.push(`${prefix}${name}${params}${returnAnnotation}`)
414
+ }
415
+ return members
416
+ }
417
+
154
418
  /**
155
419
  * Type metadata for a function parameter
156
420
  */
157
421
  export interface ParamTypeInfo {
158
- type?: { kind: string }
422
+ type?: TypeDescriptor
159
423
  required?: boolean
160
424
  example?: any
161
425
  }
@@ -165,7 +429,7 @@ export interface ParamTypeInfo {
165
429
  */
166
430
  export interface FunctionTypeInfo {
167
431
  params?: Record<string, ParamTypeInfo>
168
- returns?: { kind: string }
432
+ returns?: TypeDescriptor
169
433
  }
170
434
 
171
435
  /**
@@ -206,11 +470,16 @@ export function generateDocsMarkdown(
206
470
  markdown += '**Parameters:**\n'
207
471
  for (const [paramName, paramInfo] of Object.entries(info.params)) {
208
472
  const required = paramInfo.required ? '' : ' *(optional)*'
209
- const typeStr = paramInfo.type?.kind || 'any'
210
- const example =
473
+ const typeStr = paramInfo.type
474
+ ? typeDescriptorToTS(paramInfo.type)
475
+ : 'any'
476
+ // Skip the `(e.g. ...)` for non-serializable example values
477
+ // (functions JSON.stringify to undefined, which renders ugly).
478
+ const exampleStr =
211
479
  paramInfo.example !== undefined
212
- ? ` (e.g. \`${JSON.stringify(paramInfo.example)}\`)`
213
- : ''
480
+ ? safeJsonExample(paramInfo.example)
481
+ : null
482
+ const example = exampleStr ? ` (e.g. \`${exampleStr}\`)` : ''
214
483
  markdown += `- \`${paramName}\`: ${typeStr}${required}${example}\n`
215
484
  }
216
485
  markdown += '\n'
@@ -219,8 +488,173 @@ export function generateDocsMarkdown(
219
488
  if (info?.returns) {
220
489
  markdown += `**Returns:** ${info.returns.kind || 'void'}\n\n`
221
490
  }
491
+ } else if (item.type === 'class') {
492
+ markdown += `## ${item.name}\n\n`
493
+ const head = item.extendsName
494
+ ? `class ${item.name} extends ${item.extendsName} {`
495
+ : `class ${item.name} {`
496
+ const body =
497
+ item.members.length === 0
498
+ ? ''
499
+ : '\n' + item.members.map((m) => ` ${m}`).join('\n') + '\n'
500
+ markdown += '```tjs\n' + head + body + '}\n```\n\n'
222
501
  }
223
502
  }
224
503
 
504
+ // Append test cases as documentation. Each test's description names
505
+ // what it asserts; the body is rendered with expect(...).toBe(...) etc.
506
+ // translated to inline `// → ...` comments. Anonymous tests
507
+ // (auto-named `test 1`, `test 2`, ...) are skipped — they read as
508
+ // smoke tests, not documentation.
509
+ const { tests } = extractTests(source)
510
+ for (const test of tests) {
511
+ if (!test.description) continue
512
+ if (/^test \d+$/.test(test.description)) continue
513
+ markdown += `### ${test.description} (test cases)\n\n`
514
+ markdown += '```tjs\n'
515
+ markdown += prettifyTestBody(test.body).trim() + '\n'
516
+ markdown += '```\n\n'
517
+ }
518
+
225
519
  return markdown.trim() || '*No documentation available*'
226
520
  }
521
+
522
+ /**
523
+ * Translate `expect(actual).matcher(expected)` calls into inline comments
524
+ * for documentation rendering. Other lines (setup, console.log, etc.) are
525
+ * preserved as-is.
526
+ *
527
+ * expect(x).toBe(y) → x // → y
528
+ * expect(x).toEqual(y) → x // ≡ y (deep equality)
529
+ * expect(x).toBeTruthy() → x // → truthy
530
+ * expect(x).toBeFalsy() → x // → falsy
531
+ * expect(x).toBeNull() → x // → null
532
+ * expect(x).toBeUndefined() → x // → undefined
533
+ * expect(x).toContain(y) → x // → contains y
534
+ * expect(x).toThrow() → x // → throws
535
+ * expect(x).toBeGreaterThan(n) → x // → > n
536
+ * expect(x).toBeLessThan(n) → x // → < n
537
+ * expect(x).toBeNaN() → x // → NaN
538
+ *
539
+ * Uses balanced-paren scanning so nested calls (`expect(f(a, b)).toBe(c)`)
540
+ * work correctly.
541
+ */
542
+ export function prettifyTestBody(body: string): string {
543
+ let i = 0
544
+ let out = ''
545
+ let inStr: string | null = null
546
+ while (i < body.length) {
547
+ const c = body[i]
548
+ const prev = i > 0 ? body[i - 1] : ''
549
+ // Track string-literal state so `"expect(fake).toBe(...)"` inside a
550
+ // string is preserved verbatim.
551
+ if (inStr) {
552
+ out += c
553
+ if (c === inStr && prev !== '\\') inStr = null
554
+ i++
555
+ continue
556
+ }
557
+ if (c === '"' || c === "'" || c === '`') {
558
+ inStr = c
559
+ out += c
560
+ i++
561
+ continue
562
+ }
563
+
564
+ if (body.slice(i).startsWith('expect(')) {
565
+ const argStart = i + 'expect('.length
566
+ const argEnd = findMatchingParen(body, argStart)
567
+ if (argEnd > argStart) {
568
+ const after = body.slice(argEnd + 1)
569
+ const matcherMatch = after.match(/^\.(\w+)(\()?/)
570
+ if (matcherMatch && matcherMatch[2] === '(') {
571
+ const matcherStart = argEnd + 1 + matcherMatch[0].length
572
+ const matcherEnd = findMatchingParen(body, matcherStart)
573
+ if (matcherEnd >= matcherStart) {
574
+ const actual = body.slice(argStart, argEnd)
575
+ const matcherName = matcherMatch[1]
576
+ const expected = body.slice(matcherStart, matcherEnd)
577
+ out += renderMatcher(actual, matcherName, expected)
578
+ i = matcherEnd + 1
579
+ continue
580
+ }
581
+ }
582
+ }
583
+ }
584
+ out += c
585
+ i++
586
+ }
587
+ return out
588
+ }
589
+
590
+ /** Find the index of the `)` that matches the open paren at position `open-1`. */
591
+ function findMatchingParen(s: string, open: number): number {
592
+ let depth = 1
593
+ let i = open
594
+ let inStr: string | null = null
595
+ while (i < s.length) {
596
+ const c = s[i]
597
+ const prev = i > 0 ? s[i - 1] : ''
598
+ if (inStr) {
599
+ if (c === inStr && prev !== '\\') inStr = null
600
+ } else {
601
+ if (c === '"' || c === "'" || c === '`') inStr = c
602
+ else if (c === '(') depth++
603
+ else if (c === ')') {
604
+ depth--
605
+ if (depth === 0) return i
606
+ }
607
+ }
608
+ i++
609
+ }
610
+ return -1
611
+ }
612
+
613
+ /**
614
+ * JSON-stringify an example value, returning null if the result wouldn't
615
+ * be useful (e.g. functions stringify to `undefined`, which renders ugly).
616
+ */
617
+ function safeJsonExample(value: unknown): string | null {
618
+ if (typeof value === 'function') return null
619
+ try {
620
+ const s = JSON.stringify(value)
621
+ return s === undefined ? null : s
622
+ } catch {
623
+ return null
624
+ }
625
+ }
626
+
627
+ function renderMatcher(
628
+ actual: string,
629
+ matcher: string,
630
+ expected: string
631
+ ): string {
632
+ const a = actual.trim()
633
+ const e = expected.trim()
634
+ switch (matcher) {
635
+ case 'toBe':
636
+ return `${a} // → ${e}`
637
+ case 'toEqual':
638
+ return `${a} // ≡ ${e}`
639
+ case 'toBeTruthy':
640
+ return `${a} // → truthy`
641
+ case 'toBeFalsy':
642
+ return `${a} // → falsy`
643
+ case 'toBeNull':
644
+ return `${a} // → null`
645
+ case 'toBeUndefined':
646
+ return `${a} // → undefined`
647
+ case 'toContain':
648
+ return `${a} // → contains ${e}`
649
+ case 'toThrow':
650
+ return `${a} // → throws`
651
+ case 'toBeGreaterThan':
652
+ return `${a} // → > ${e}`
653
+ case 'toBeLessThan':
654
+ return `${a} // → < ${e}`
655
+ case 'toBeNaN':
656
+ return `${a} // → NaN`
657
+ default:
658
+ return `${a} // .${matcher}(${e})`
659
+ }
660
+ }
@@ -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', () => {