tjs-lang 0.7.7 → 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 (59) hide show
  1. package/CLAUDE.md +90 -33
  2. package/bin/docs.js +4 -1
  3. package/demo/docs.json +45 -11
  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 +118 -101
  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 +3 -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 +38 -36
  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 +85 -83
  28. package/dist/tjs-lang.js.map +4 -4
  29. package/dist/tjs-vm.js +47 -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 +137 -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 +9 -4
  43. package/src/lang/emitters/js.ts +182 -2
  44. package/src/lang/inference.ts +54 -0
  45. package/src/lang/linter.test.ts +104 -1
  46. package/src/lang/linter.ts +124 -1
  47. package/src/lang/parser-params.ts +31 -0
  48. package/src/lang/parser-transforms.ts +304 -0
  49. package/src/lang/parser-types.ts +2 -0
  50. package/src/lang/parser.test.ts +73 -1
  51. package/src/lang/parser.ts +34 -1
  52. package/src/lang/runtime.ts +98 -0
  53. package/src/lang/types.ts +6 -0
  54. package/src/rbac/index.ts +2 -2
  55. package/src/rbac/rules.tjs.d.ts +9 -0
  56. package/src/vm/atoms/batteries.ts +2 -2
  57. package/src/vm/runtime.ts +10 -3
  58. package/dist/src/rbac/rules.d.ts +0 -184
  59. 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
  }
@@ -876,6 +876,13 @@ export function runAllTests(
876
876
  // skip tests gracefully rather than marking them as failures
877
877
  const isUnresolvedRef = hasUnresolvedImports && e instanceof ReferenceError
878
878
 
879
+ // The error came from module-level code (e.g. an undefined identifier
880
+ // in `console.log(... x ...)`), NOT from the function/test under test.
881
+ // Don't attribute a line — otherwise the editor would mark the function
882
+ // declaration's line as the error site, misleading the user about where
883
+ // the actual problem is. The test still appears as failed in the test
884
+ // list with the explanatory message; the user finds the real error
885
+ // through the runtime console.
879
886
  for (const test of tests) {
880
887
  results.push({
881
888
  description: test.description,
@@ -883,7 +890,6 @@ export function runAllTests(
883
890
  error: isUnresolvedRef
884
891
  ? undefined
885
892
  : `Module execution failed: ${e.message}`,
886
- line: test.line,
887
893
  })
888
894
  }
889
895
  for (const info of syncSigTestInfos) {
@@ -897,7 +903,6 @@ export function runAllTests(
897
903
  ? undefined
898
904
  : `Module execution failed: ${e.message}`,
899
905
  isSignatureTest: true,
900
- line: info.line,
901
906
  })
902
907
  }
903
908
  }
@@ -949,7 +954,7 @@ function runTestBlocks(
949
954
  const tjsStub = `
950
955
  const __saved_tjs = globalThis.__tjs;
951
956
  class __MonadicError extends Error { constructor(m,p,e,a,c){super(m);this.name='MonadicError';this.path=p;this.expected=e;this.actual=a;this.callStack=c;} }
952
- const __stub_tjs = { version: '0.0.0', MonadicError: __MonadicError, pushStack: () => {}, popStack: () => {}, getStack: () => [], typeError: (path, expected, value) => new __MonadicError(\`Type error at \${path}: expected \${expected}\`, path, expected, typeof value), createRuntime: function() { return this; } };
957
+ const __stub_tjs = { version: '0.0.0', MonadicError: __MonadicError, pushStack: () => {}, popStack: () => {}, getStack: () => [], typeError: (path, expected, value) => new __MonadicError(\`Type error at \${path}: expected \${expected}\`, path, expected, typeof value), toBool: (v) => (v instanceof Boolean || v instanceof Number || v instanceof String) ? Boolean(v.valueOf()) : Boolean(v), createRuntime: function() { return this; } };
953
958
  globalThis.__tjs = __stub_tjs;
954
959
  `
955
960
  const tjsRestore = `globalThis.__tjs = __saved_tjs;`
@@ -1308,7 +1313,7 @@ function runSignatureTest(
1308
1313
  const tjsStub = `
1309
1314
  const __saved_tjs = globalThis.__tjs;
1310
1315
  class __MonadicError extends Error { constructor(m,p,e,a,c){super(m);this.name='MonadicError';this.path=p;this.expected=e;this.actual=a;this.callStack=c;} }
1311
- const __stub_tjs = { version: '0.0.0', MonadicError: __MonadicError, pushStack: () => {}, popStack: () => {}, getStack: () => [], typeError: (path, expected, value) => new __MonadicError(\`Type error at \${path}: expected \${expected}\`, path, expected, typeof value), createRuntime: function() { return this; } };
1316
+ const __stub_tjs = { version: '0.0.0', MonadicError: __MonadicError, pushStack: () => {}, popStack: () => {}, getStack: () => [], typeError: (path, expected, value) => new __MonadicError(\`Type error at \${path}: expected \${expected}\`, path, expected, typeof value), toBool: (v) => (v instanceof Boolean || v instanceof Number || v instanceof String) ? Boolean(v.valueOf()) : Boolean(v), createRuntime: function() { return this; } };
1312
1317
  globalThis.__tjs = __stub_tjs;
1313
1318
  `
1314
1319
  const tjsRestore = `globalThis.__tjs = __saved_tjs;`