tjs-lang 0.8.0 → 0.8.2

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 (86) hide show
  1. package/CLAUDE.md +15 -5
  2. package/CONTEXT.md +4 -0
  3. package/demo/docs.json +12 -690
  4. package/demo/examples.test.ts +6 -2
  5. package/demo/src/playground-shared.ts +48 -16
  6. package/demo/src/playground-test-results.test.ts +112 -0
  7. package/demo/src/tjs-playground.ts +11 -5
  8. package/dist/eslint.config.d.ts +2 -0
  9. package/dist/index.js +146 -141
  10. package/dist/index.js.map +4 -4
  11. package/dist/src/lang/core.d.ts +1 -1
  12. package/dist/src/lang/dialect.d.ts +35 -0
  13. package/dist/src/lang/emitters/ast.d.ts +1 -1
  14. package/dist/src/lang/emitters/js-tests.d.ts +7 -0
  15. package/dist/src/lang/emitters/js-wasm.d.ts +5 -1
  16. package/dist/src/lang/emitters/js.d.ts +21 -0
  17. package/dist/src/lang/index.d.ts +3 -1
  18. package/dist/src/lang/module-loader.d.ts +125 -0
  19. package/dist/src/lang/parser-transforms.d.ts +79 -0
  20. package/dist/src/lang/parser-types.d.ts +50 -0
  21. package/dist/src/lang/parser.d.ts +18 -0
  22. package/dist/src/lang/transpiler.d.ts +1 -0
  23. package/dist/src/lang/types.d.ts +18 -0
  24. package/dist/src/lang/wasm.d.ts +67 -1
  25. package/dist/src/vm/runtime.d.ts +18 -0
  26. package/dist/src/vm/vm.d.ts +15 -1
  27. package/dist/tjs-batteries.js +2 -2
  28. package/dist/tjs-batteries.js.map +2 -2
  29. package/dist/tjs-eval.js +43 -41
  30. package/dist/tjs-eval.js.map +3 -3
  31. package/dist/tjs-from-ts.js +2 -2
  32. package/dist/tjs-from-ts.js.map +2 -2
  33. package/dist/tjs-lang.js +107 -104
  34. package/dist/tjs-lang.js.map +4 -4
  35. package/dist/tjs-vm.js +53 -51
  36. package/dist/tjs-vm.js.map +3 -3
  37. package/docs/README.md +2 -0
  38. package/docs/lm-studio-setup.md +143 -0
  39. package/docs/universal-endpoint.md +122 -0
  40. package/llms.txt +9 -2
  41. package/package.json +24 -4
  42. package/src/batteries/audit.ts +6 -5
  43. package/src/batteries/llm.ts +8 -3
  44. package/src/builder.ts +0 -3
  45. package/src/cli/commands/check.ts +3 -2
  46. package/src/cli/commands/emit.ts +4 -2
  47. package/src/cli/commands/run.ts +6 -2
  48. package/src/cli/commands/test.ts +1 -1
  49. package/src/cli/commands/types.ts +2 -2
  50. package/src/lang/codegen.test.ts +4 -1
  51. package/src/lang/core.ts +6 -4
  52. package/src/lang/dialect.test.ts +63 -0
  53. package/src/lang/dialect.ts +50 -0
  54. package/src/lang/docs.test.ts +0 -1
  55. package/src/lang/emitters/ast.ts +145 -2
  56. package/src/lang/emitters/from-ts.ts +1 -1
  57. package/src/lang/emitters/js-tests.ts +46 -37
  58. package/src/lang/emitters/js.ts +19 -3
  59. package/src/lang/features.test.ts +6 -5
  60. package/src/lang/index.ts +18 -5
  61. package/src/lang/linter.ts +1 -1
  62. package/src/lang/module-loader.test.ts +9 -5
  63. package/src/lang/module-loader.ts +4 -5
  64. package/src/lang/parser-params.ts +1 -1
  65. package/src/lang/parser-transforms.ts +12 -18
  66. package/src/lang/parser-types.ts +17 -0
  67. package/src/lang/parser.test.ts +12 -6
  68. package/src/lang/parser.ts +113 -3
  69. package/src/lang/perf.test.ts +10 -4
  70. package/src/lang/runtime.ts +0 -1
  71. package/src/lang/subset-invariant.test.ts +90 -0
  72. package/src/lang/transpiler.ts +8 -0
  73. package/src/lang/types.ts +18 -0
  74. package/src/lang/wasm.test.ts +22 -16
  75. package/src/linalg/linalg.test.ts +16 -4
  76. package/src/linalg/vector-search.bench.test.ts +31 -10
  77. package/src/types/Type.ts +6 -6
  78. package/src/use-cases/asymmetric-client-server.test.ts +0 -2
  79. package/src/use-cases/batteries.test.ts +4 -0
  80. package/src/use-cases/client-server.test.ts +1 -1
  81. package/src/use-cases/local-helpers.test.ts +219 -0
  82. package/src/use-cases/timeout-overrides.test.ts +169 -0
  83. package/src/use-cases/unbundled-imports.test.ts +0 -1
  84. package/src/vm/atoms/batteries.ts +17 -3
  85. package/src/vm/runtime.ts +92 -7
  86. package/src/vm/vm.ts +50 -10
@@ -276,7 +276,7 @@ export function transformParenExpressions(
276
276
 
277
277
  // Check for safety marker right after opening paren: (? or (!
278
278
  const afterParen = source[i + matchLen]
279
- let safetyMarker: '?' | '!' | null = null
279
+ let safetyMarker: '?' | '!' | null
280
280
  let paramStart = i + matchLen
281
281
 
282
282
  if (afterParen === '?' || afterParen === '!') {
@@ -296,7 +296,7 @@ export function extractWasmFunctions(source: string): {
296
296
  i++
297
297
  continue
298
298
  }
299
- let paramsSource = source.slice(parensStart, j - 1)
299
+ const paramsSource = source.slice(parensStart, j - 1)
300
300
  // j now points just past the closing `)`
301
301
 
302
302
  // Phase 2: detect `(!` — reserved syntax for unsafe wasm functions
@@ -413,7 +413,10 @@ export function composeImportedWasmFunctions(
413
413
  source: string,
414
414
  options: {
415
415
  loader?: {
416
- load(specifier: string, importerPath?: string): {
416
+ load(
417
+ specifier: string,
418
+ importerPath?: string
419
+ ): {
417
420
  parseResult: { wasmBlocks: WasmBlock[] }
418
421
  } | null
419
422
  }
@@ -466,7 +469,8 @@ export function composeImportedWasmFunctions(
466
469
  // Default imports, namespace imports, and side-effect imports are left
467
470
  // alone — only named-bindings are candidates for wasm composition.
468
471
  // Multiline imports are supported via the [^}]*? non-greedy match.
469
- const importRe = /^(\s*)import\s*\{([^}]*?)\}\s*from\s*(['"])([^'"]+)\3\s*;?\s*$/gm
472
+ const importRe =
473
+ /^(\s*)import\s*\{([^}]*?)\}\s*from\s*(['"])([^'"]+)\3\s*;?\s*$/gm
470
474
 
471
475
  const replaced = source.replace(
472
476
  importRe,
@@ -514,13 +518,11 @@ export function composeImportedWasmFunctions(
514
518
  // `import { outer } from 'lib'` work when outer internally calls
515
519
  // inner — both end up in the consumer's composed module.
516
520
  pullInTransitively(wasmBlock, importedWasmFunctions)
517
- const argNames = wasmBlock.captures.map((c) =>
518
- c.split(':')[0].trim()
519
- )
521
+ const argNames = wasmBlock.captures.map((c) => c.split(':')[0].trim())
520
522
  wrappers.push(
521
- `function ${local}(${argNames.join(
522
- ', '
523
- )}) { return globalThis.${wasmBlock.id}(${argNames.join(', ')}) }`
523
+ `function ${local}(${argNames.join(', ')}) { return globalThis.${
524
+ wasmBlock.id
525
+ }(${argNames.join(', ')}) }`
524
526
  )
525
527
  }
526
528
 
@@ -3376,15 +3378,7 @@ export function transformPolymorphicConstructors(
3376
3378
  })
3377
3379
  }
3378
3380
 
3379
- // Keep the first constructor in the class, remove the rest
3380
- // Build new class body with only the first constructor
3381
- let newBody = body.slice(0, ctors[0].fullEnd)
3382
- // Skip subsequent constructors
3383
- const afterLastCtor = ctors[ctors.length - 1].fullEnd
3384
- newBody += body.slice(afterLastCtor)
3385
-
3386
- // But we need to remove just the extra constructors, keeping other methods
3387
- // Better approach: remove constructors 2..N from the body
3381
+ // Remove the extra constructors, keeping the first constructor and other methods
3388
3382
  let cleanBody = body
3389
3383
  for (let i = ctors.length - 1; i >= 1; i--) {
3390
3384
  const ctor = ctors[i]
@@ -15,6 +15,17 @@ export interface ParseOptions {
15
15
  * When true, skips == to Is() transformation since the VM handles == correctly.
16
16
  */
17
17
  vmTarget?: boolean
18
+ /**
19
+ * Source dialect — tells the transpiler what kind of source this string is:
20
+ * - `'tjs'`: native TJS, all footgun-removal modes ON (structural `==`,
21
+ * `TjsStandard`, etc.). This is the default for a bare string.
22
+ * - `'js'`: plain JavaScript — modes OFF and `safety: 'none'`, so the source's
23
+ * own semantics are preserved (no `==`→`Eq`, no truthiness rewrite, etc.).
24
+ *
25
+ * Authoritative when set; otherwise the dialect is inferred (the fromTS
26
+ * annotation and `vmTarget` ⇒ JS-compatible). See PRINCIPLES.md (TJS ⊇ JS).
27
+ */
28
+ dialect?: 'js' | 'tjs'
18
29
  /**
19
30
  * Optional ModuleLoader for cross-file `wasm function` composition (Phase 3).
20
31
  * When provided, imports are resolved at transpile time and matching wasm
@@ -109,6 +120,12 @@ export interface PreprocessOptions {
109
120
  * Default: false (transform == to Is() for TJS code running in regular JS)
110
121
  */
111
122
  vmTarget?: boolean
123
+ /**
124
+ * Source dialect: `'js'` ⇒ modes OFF / `safety: 'none'` (preserve plain-JS
125
+ * semantics); `'tjs'` ⇒ native modes ON. Authoritative when set; otherwise
126
+ * inferred from the fromTS annotation / `vmTarget`. See ParseOptions.dialect.
127
+ */
128
+ dialect?: 'js' | 'tjs'
112
129
  /**
113
130
  * Optional ModuleLoader for cross-file `wasm function` composition (Phase 3).
114
131
  * See ParseOptions.moduleLoader for details.
@@ -814,13 +814,19 @@ test 'always fails' { throw new Error('intentional') }
814
814
  })
815
815
 
816
816
  describe('Error handling', () => {
817
- it('should reject multiple functions', () => {
818
- expect(() =>
819
- transpile(`
820
- function a() {}
821
- function b() {}
817
+ it('accepts multiple functions as local helpers (last is the entry)', () => {
818
+ // Multiple top-level functions are now the local-helpers feature: the
819
+ // last declaration is the entry point, the rest are helpers called by
820
+ // name via callLocal. See use-cases/local-helpers.test.ts for semantics.
821
+ const { ast } = transpile(`
822
+ function helper(x: 0): 0 { return x }
823
+ function main(n: 0): 0 {
824
+ const r = helper(n)
825
+ return r
826
+ }
822
827
  `)
823
- ).toThrow('Only a single function')
828
+ // Unused/used helpers are collected onto the root node by name.
829
+ expect((ast as any).helpers?.helper).toBeDefined()
824
830
  })
825
831
 
826
832
  it('should require a function when only a class is provided', () => {
@@ -135,9 +135,17 @@ export function preprocess(
135
135
  // The /* tjs <- filename */ annotation is the signal
136
136
  const isFromTS = /\/\*\s*tjs\s*<-\s*\S+\s*\*\//.test(source)
137
137
 
138
- // Native TJS: all modes ON by default (TJS is its own language)
139
- // TS-originated or VM target (AJS): all modes OFF, safety none (JS-compatible)
140
- const isCompat = isFromTS || options.vmTarget
138
+ // Native TJS: all modes ON by default (TJS is its own language).
139
+ // Plain JS (dialect: 'js'), TS-originated, or VM target: all modes OFF +
140
+ // safety none, so the source's own semantics are preserved (JS-compatible).
141
+ // An explicit `dialect` is authoritative; otherwise infer from the fromTS
142
+ // annotation / vmTarget. See PRINCIPLES.md (TJS ⊇ JS).
143
+ const isCompat =
144
+ options.dialect === 'js'
145
+ ? true
146
+ : options.dialect === 'tjs'
147
+ ? false
148
+ : isFromTS || options.vmTarget
141
149
  const tjsModes: TjsModes = isCompat
142
150
  ? {
143
151
  tjsEquals: false,
@@ -452,6 +460,7 @@ export function parse(
452
460
  filename = '<source>',
453
461
  colonShorthand = true,
454
462
  vmTarget = false,
463
+ dialect,
455
464
  } = options
456
465
 
457
466
  // Preprocess for custom syntax
@@ -472,6 +481,7 @@ export function parse(
472
481
  } = colonShorthand
473
482
  ? preprocess(source, {
474
483
  vmTarget,
484
+ dialect,
475
485
  moduleLoader: options.moduleLoader,
476
486
  filename: options.filename,
477
487
  })
@@ -602,6 +612,106 @@ export function validateSingleFunction(
602
612
  return functions[0]
603
613
  }
604
614
 
615
+ /**
616
+ * Extract top-level function declarations from the parsed program.
617
+ *
618
+ * Returns `{ entry, helpers }` where:
619
+ * - `entry` is the last function declaration (the agent's entry point)
620
+ * - `helpers` are all preceding function declarations, looked up by name
621
+ *
622
+ * This matches the natural "helpers first, agent last" pattern, including
623
+ * the TOOL_LIBRARY use case where helper async wrappers are prepended
624
+ * before the user-supplied agent function.
625
+ *
626
+ * Same top-level construct restrictions as `validateSingleFunction`:
627
+ * imports, exports, and classes are rejected.
628
+ */
629
+ export function extractFunctions(
630
+ ast: Program,
631
+ filename?: string
632
+ ): { entry: FunctionDeclaration; helpers: Map<string, FunctionDeclaration> } {
633
+ // Top-level construct checks (same as validateSingleFunction)
634
+ for (const node of ast.body) {
635
+ if (node.type === 'ImportDeclaration') {
636
+ throw new SyntaxError(
637
+ 'Imports are not supported. All atoms must be registered with the VM.',
638
+ node.loc?.start || { line: 1, column: 0 },
639
+ undefined,
640
+ filename
641
+ )
642
+ }
643
+
644
+ if (
645
+ node.type === 'ExportNamedDeclaration' ||
646
+ node.type === 'ExportDefaultDeclaration'
647
+ ) {
648
+ throw new SyntaxError(
649
+ 'Exports are not supported. The function is automatically exported.',
650
+ node.loc?.start || { line: 1, column: 0 },
651
+ undefined,
652
+ filename
653
+ )
654
+ }
655
+
656
+ if (node.type === 'ClassDeclaration') {
657
+ throw new SyntaxError(
658
+ 'Classes are not supported. Agent99 uses functional composition.',
659
+ node.loc?.start || { line: 1, column: 0 },
660
+ undefined,
661
+ filename
662
+ )
663
+ }
664
+ }
665
+
666
+ const functions = ast.body.filter(
667
+ (node): node is FunctionDeclaration => node.type === 'FunctionDeclaration'
668
+ )
669
+
670
+ if (functions.length === 0) {
671
+ throw new SyntaxError(
672
+ 'Source must contain a function declaration',
673
+ { line: 1, column: 0 },
674
+ undefined,
675
+ filename
676
+ )
677
+ }
678
+
679
+ const entry = functions[functions.length - 1]
680
+ const helpers = new Map<string, FunctionDeclaration>()
681
+
682
+ for (let i = 0; i < functions.length - 1; i++) {
683
+ const fn = functions[i]
684
+ const name = fn.id?.name
685
+ if (!name) {
686
+ throw new SyntaxError(
687
+ 'Helper function must have a name',
688
+ fn.loc?.start || { line: 1, column: 0 },
689
+ undefined,
690
+ filename
691
+ )
692
+ }
693
+ if (helpers.has(name)) {
694
+ throw new SyntaxError(
695
+ `Duplicate helper function name: ${name}`,
696
+ fn.loc?.start || { line: 1, column: 0 },
697
+ undefined,
698
+ filename
699
+ )
700
+ }
701
+ if (name === entry.id?.name) {
702
+ throw new SyntaxError(
703
+ `Helper function cannot share a name with the entry function: ${name}`,
704
+ fn.loc?.start || { line: 1, column: 0 },
705
+ undefined,
706
+ filename
707
+ )
708
+ }
709
+ helpers.set(name, fn)
710
+ }
711
+
712
+ return { entry, helpers }
713
+ }
714
+
605
715
  /**
606
716
  * Extract TDoc comment from before a function
607
717
  *
@@ -60,8 +60,12 @@ describe('TJS Performance', () => {
60
60
  console.log(` Median: ${median.toFixed(0)}ms`)
61
61
  console.log(` Max: ${max.toFixed(0)}ms`)
62
62
 
63
- // Cold start should be under 200ms
64
- expect(median).toBeLessThan(200)
63
+ // Regression guardrail, not a micro-benchmark: this wall-clock is
64
+ // dominated by `bun` process spawn + module-graph load (the one-line
65
+ // transpile is negligible), so it's machine- and load-dependent. Keep the
66
+ // bar generous — it catches a gross regression (cold start ballooning to
67
+ // seconds) without flaking on a loaded box or slower CI.
68
+ expect(median).toBeLessThan(500)
65
69
  })
66
70
 
67
71
  it('should measure tjs emit time', async () => {
@@ -84,7 +88,8 @@ describe('TJS Performance', () => {
84
88
  console.log(`\n tjs emit cold start (5 runs):`)
85
89
  console.log(` Median: ${median.toFixed(0)}ms`)
86
90
 
87
- expect(median).toBeLessThan(200)
91
+ // Generous regression guardrail — see the tjsx cold-start note above.
92
+ expect(median).toBeLessThan(500)
88
93
  })
89
94
 
90
95
  it('should measure tjs check time', async () => {
@@ -107,7 +112,8 @@ describe('TJS Performance', () => {
107
112
  console.log(`\n tjs check cold start (5 runs):`)
108
113
  console.log(` Median: ${median.toFixed(0)}ms`)
109
114
 
110
- expect(median).toBeLessThan(200)
115
+ // Generous regression guardrail — see the tjsx cold-start note above.
116
+ expect(median).toBeLessThan(500)
111
117
  })
112
118
  })
113
119
 
@@ -73,7 +73,6 @@ export {
73
73
  }
74
74
 
75
75
  // Version from package.json - injected at build time or imported
76
- // eslint-disable-next-line @typescript-eslint/no-var-requires
77
76
  const pkg = require('../../package.json') as { version: string }
78
77
 
79
78
  export const TJS_VERSION: string = pkg.version
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { tjs, transpile } from './index'
3
+
4
+ /**
5
+ * Guards the language subset invariants engraved in PRINCIPLES.md:
6
+ *
7
+ * AJS ⊆ TJS — every legal AJS source is legal TJS source
8
+ * JS ⊆ TJS (no modes) — every legal JS program is legal TJS
9
+ *
10
+ * TJS may do MORE with the same source (enforce contracts, run signature
11
+ * tests) but must never REJECT source the subset accepts. The classic way this
12
+ * breaks: a build-time signature test that can't *run* (it calls an AJS atom
13
+ * that doesn't exist at build time, etc.) gets escalated into a transpile
14
+ * error. Such tests must be *inconclusive*, never failing.
15
+ */
16
+ describe('Language subset invariants (PRINCIPLES.md)', () => {
17
+ // Representative AJS-shaped sources. Each must be valid AJS (transpile to a
18
+ // VM AST) AND valid TJS (tjs() must not throw). Several carry return types
19
+ // and call atoms — the exact shape that used to be illegal TJS.
20
+ const ajsSnippets: Array<[string, string]> = [
21
+ [
22
+ 'agent returning an object (no types)',
23
+ `function main(n: 0) {\n return { doubled: n * 2 }\n}`,
24
+ ],
25
+ [
26
+ 'atom call + return type',
27
+ `function main(url: ''): { x: '' } {\n const x = httpFetch({ url })\n return { x }\n}`,
28
+ ],
29
+ [
30
+ 'helper with a typed signature',
31
+ `function double(x: 0): 0 {\n return x * 2\n}\nfunction main(n: 0) {\n const d = double(n)\n return { d }\n}`,
32
+ ],
33
+ [
34
+ 'helper that calls an atom + return type',
35
+ `function fetchIt(u: ''): '' {\n const r = httpFetch({ url: u })\n return r\n}\nfunction main(url: '') {\n const x = fetchIt(url)\n return { x }\n}`,
36
+ ],
37
+ [
38
+ 'consistent signature example still validates',
39
+ `function add(a: 2, b: 3): 5 {\n return a + b\n}\nfunction main(x: 0, y: 0) {\n const s = add(x, y)\n return { s }\n}`,
40
+ ],
41
+ ]
42
+
43
+ describe('TJS ⊇ AJS', () => {
44
+ for (const [label, src] of ajsSnippets) {
45
+ it(`valid as both AJS and TJS: ${label}`, () => {
46
+ // Valid AJS (produces a VM AST)…
47
+ expect(() => transpile(src, { vmTarget: true })).not.toThrow()
48
+ // …and therefore must be valid TJS (never rejected).
49
+ expect(() => tjs(src)).not.toThrow()
50
+ })
51
+ }
52
+ })
53
+
54
+ it('un-runnable signature tests are inconclusive, not failures', () => {
55
+ const r = tjs(
56
+ `function main(url: ''): { x: '' } {\n const x = httpFetch({ url })\n return { x }\n}`
57
+ )
58
+ const sig = r.testResults?.find((t: any) => t.isSignatureTest)
59
+ expect(sig).toBeDefined()
60
+ expect(sig?.passed).toBe(false)
61
+ expect(sig?.inconclusive).toBe(true)
62
+ })
63
+
64
+ it('still REJECTS a genuinely inconsistent signature example (validation intact)', () => {
65
+ // 2 + 3 = 5, not 99 — the test runs cleanly and mismatches → hard failure.
66
+ expect(() =>
67
+ tjs(`function add(a: 2, b: 3): 99 {\n return a + b\n}`)
68
+ ).toThrow(/inconsistent/)
69
+ })
70
+
71
+ describe('TJS (no modes) ⊇ JS', () => {
72
+ // Plain JavaScript under options-off TJS (TjsCompat disables all modes).
73
+ const jsSnippets: Array<[string, string]> = [
74
+ ['arithmetic fn', `function f(x) { return x + 1 }`],
75
+ [
76
+ 'control flow + array methods',
77
+ `function f(xs) {\n let total = 0\n for (const x of xs) { total += x }\n return xs.map(v => v * 2).filter(v => v > total)\n}`,
78
+ ],
79
+ [
80
+ 'object + destructuring',
81
+ `function f(o) {\n const { a, b } = o\n return { ...o, sum: a + b }\n}`,
82
+ ],
83
+ ]
84
+ for (const [label, src] of jsSnippets) {
85
+ it(`accepts plain JS: ${label}`, () => {
86
+ expect(() => tjs(`TjsCompat\n${src}`)).not.toThrow()
87
+ })
88
+ }
89
+ })
90
+ })
@@ -16,6 +16,14 @@ export { transpile, ajs, tjs, createAgent, getToolDefinitions } from './core'
16
16
  // Parser
17
17
  export { parse, preprocess, extractTDoc } from './parser'
18
18
 
19
+ // Dialect resolution for file-based tooling (extension → dialect)
20
+ export {
21
+ dialectForFilename,
22
+ sourceKindForFilename,
23
+ type Dialect,
24
+ type SourceKind,
25
+ } from './dialect'
26
+
19
27
  // AST emitter
20
28
  export { transformFunction } from './emitters/ast'
21
29
 
package/src/lang/types.ts CHANGED
@@ -225,6 +225,21 @@ export interface TransformContext {
225
225
  filename: string
226
226
  /** Options */
227
227
  options: TranspileOptions
228
+ /**
229
+ * Helper functions (top-level functions declared before the entry function).
230
+ * Calls to these names emit `callLocal` instead of treating them as atom calls.
231
+ */
232
+ helpers?: Map<string, any /* FunctionDeclaration */>
233
+ /**
234
+ * Cache of transformed helper bodies. Lazily populated as helpers are
235
+ * referenced from the entry function (or from other helpers).
236
+ */
237
+ helperSteps?: Map<string, { steps: any[]; paramNames: string[] }>
238
+ /**
239
+ * Names of helpers currently being transformed. Used to detect direct or
240
+ * transitive recursion.
241
+ */
242
+ helperTransforming?: Set<string>
228
243
  }
229
244
 
230
245
  /** Create a child context for nested scopes */
@@ -239,6 +254,9 @@ export function createChildContext(parent: TransformContext): TransformContext {
239
254
  source: parent.source,
240
255
  filename: parent.filename,
241
256
  options: parent.options,
257
+ helpers: parent.helpers,
258
+ helperSteps: parent.helperSteps,
259
+ helperTransforming: parent.helperTransforming,
242
260
  }
243
261
  }
244
262
 
@@ -1901,8 +1901,14 @@ describe('module consolidation (Phase 0.5)', () => {
1901
1901
 
1902
1902
  const result = compileBlocksToModule(blocks)
1903
1903
  expect(result.exports).toHaveLength(2)
1904
- expect(result.exports[0]).toMatchObject({ id: 'b0', exportName: 'compute_0' })
1905
- expect(result.exports[1]).toMatchObject({ id: 'b1', exportName: 'compute_1' })
1904
+ expect(result.exports[0]).toMatchObject({
1905
+ id: 'b0',
1906
+ exportName: 'compute_0',
1907
+ })
1908
+ expect(result.exports[1]).toMatchObject({
1909
+ id: 'b1',
1910
+ exportName: 'compute_1',
1911
+ })
1906
1912
 
1907
1913
  // Instantiate and confirm both exports work
1908
1914
  const instance = await instantiateWasm(result.bytes)
@@ -2117,10 +2123,8 @@ function dbl(arr: Float32Array, len: 0) {
2117
2123
  buf[1] = 2
2118
2124
  buf[2] = 3
2119
2125
  buf[3] = 4
2120
-
2121
2126
  ;(globalThis as any).__test_inc(buf, 4) // [2, 3, 4, 5]
2122
2127
  expect(Array.from(buf)).toEqual([2, 3, 4, 5])
2123
-
2124
2128
  ;(globalThis as any).__test_dbl(buf, 4) // [4, 6, 8, 10]
2125
2129
  expect(Array.from(buf)).toEqual([4, 6, 8, 10])
2126
2130
  } finally {
@@ -2313,14 +2317,20 @@ describe('boundary distribution form (Phase 4)', () => {
2313
2317
  async function dynamicImportLibrary(transpiled: string): Promise<any> {
2314
2318
  const { tmpdir } = await import('node:os')
2315
2319
  const { join } = await import('node:path')
2316
- const { writeFileSync, unlinkSync } = await import('node:fs')
2320
+ const { writeFileSync, unlinkSync, realpathSync } = await import('node:fs')
2321
+ const { pathToFileURL } = await import('node:url')
2317
2322
  const path = join(
2318
2323
  tmpdir(),
2319
2324
  `tjs-lib-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mjs`
2320
2325
  )
2321
2326
  writeFileSync(path, transpiled)
2327
+ // Resolve macOS /var → /private/var symlink and use file:// URL — bun's
2328
+ // resolver in subsequent test cases (after a `new Function(emittedCode)`
2329
+ // execution earlier in the same file) fails on bare absolute paths with
2330
+ // "Cannot find module ... from ''". This form is robust to that state.
2331
+ const url = pathToFileURL(realpathSync(path)).href
2322
2332
  try {
2323
- const mod = await import(path)
2333
+ const mod = await import(url)
2324
2334
  // Wait for the async wasm bootstrap inside the module to finish.
2325
2335
  // The bootstrap runs as a top-level IIFE; instantiation is async.
2326
2336
  await new Promise((r) => setTimeout(r, 100))
@@ -2384,9 +2394,7 @@ export wasm function mul(a: f64, b: f64): f64 { return a * b }
2384
2394
  // Both should produce the same numeric results.
2385
2395
  const { tjs } = await import('./index')
2386
2396
  const { createRuntime } = await import('./runtime')
2387
- const { ModuleLoader, inMemoryFileSystem } = await import(
2388
- './module-loader'
2389
- )
2397
+ const { ModuleLoader, inMemoryFileSystem } = await import('./module-loader')
2390
2398
 
2391
2399
  const librarySource = `
2392
2400
  export wasm function dot3(
@@ -2568,9 +2576,7 @@ function compute(x: 0.0): 0.0 {
2568
2576
  expect(result.code).toContain('globalThis.__tjs_wasm_fast(a)')
2569
2577
  // The remaining import keeps `slow` only
2570
2578
  expect(result.code).toMatch(/import\s*\{\s*slow\s*\}\s*from/)
2571
- expect(result.code).not.toMatch(
2572
- /import\s*\{\s*fast\s*,\s*slow\s*\}\s*from/
2573
- )
2579
+ expect(result.code).not.toMatch(/import\s*\{\s*fast\s*,\s*slow\s*\}\s*from/)
2574
2580
  })
2575
2581
 
2576
2582
  it('composes multiple wasm functions from one library', async () => {
@@ -3046,9 +3052,7 @@ wasm function isOdd(n: i32): f64 {
3046
3052
  // inner loop.
3047
3053
  const { tjs } = await import('./index')
3048
3054
  const { createRuntime } = await import('./runtime')
3049
- const { ModuleLoader, inMemoryFileSystem } = await import(
3050
- './module-loader'
3051
- )
3055
+ const { ModuleLoader, inMemoryFileSystem } = await import('./module-loader')
3052
3056
 
3053
3057
  const loader = new ModuleLoader({
3054
3058
  fs: inMemoryFileSystem({
@@ -3162,7 +3166,9 @@ wasm function caller(): f64 {
3162
3166
  `
3163
3167
  const result = tjs(source, { runTests: false })
3164
3168
  // caller fails because takesTwo gets one arg instead of two
3165
- const callerResult = result.wasmCompiled!.find((b) => b.id === '__tjs_wasm_caller')
3169
+ const callerResult = result.wasmCompiled!.find(
3170
+ (b) => b.id === '__tjs_wasm_caller'
3171
+ )
3166
3172
  expect(callerResult).toBeDefined()
3167
3173
  expect(callerResult!.success).toBe(false)
3168
3174
  expect(callerResult!.error).toMatch(/takesTwo expects 2 arguments, got 1/)
@@ -14,9 +14,10 @@
14
14
  */
15
15
 
16
16
  import { describe, it, expect } from 'bun:test'
17
- import { readFileSync } from 'node:fs'
17
+ import { readFileSync, realpathSync } from 'node:fs'
18
18
  import { join } from 'node:path'
19
19
  import { tmpdir } from 'node:os'
20
+ import { pathToFileURL } from 'node:url'
20
21
  import { writeFileSync, unlinkSync } from 'node:fs'
21
22
 
22
23
  const LINALG_PATH = join(import.meta.dir, 'index.tjs')
@@ -41,8 +42,13 @@ async function dynamicImportLibrary(transpiled: string): Promise<any> {
41
42
  `linalg-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mjs`
42
43
  )
43
44
  writeFileSync(path, transpiled)
45
+ // Resolve macOS /var → /private/var symlink and use file:// URL — bun's
46
+ // resolver in subsequent test cases (after a `new Function(emittedCode)`
47
+ // execution earlier in the same file) fails on bare absolute paths with
48
+ // "Cannot find module ... from ''". This form is robust to that state.
49
+ const url = pathToFileURL(realpathSync(path)).href
44
50
  try {
45
- const mod = await import(path)
51
+ const mod = await import(url)
46
52
  // Wait for the async wasm bootstrap inside the module to finish
47
53
  await new Promise((r) => setTimeout(r, 100))
48
54
  return mod
@@ -203,8 +209,14 @@ function cosine(a, b, n) {
203
209
  // Orthogonal vectors → cosine 0
204
210
  const ox = wasmBuffer(Float32Array, 4)
205
211
  const oy = wasmBuffer(Float32Array, 4)
206
- ox[0] = 1; ox[1] = 0; ox[2] = 0; ox[3] = 0
207
- oy[0] = 0; oy[1] = 1; oy[2] = 0; oy[3] = 0
212
+ ox[0] = 1
213
+ ox[1] = 0
214
+ ox[2] = 0
215
+ ox[3] = 0
216
+ oy[0] = 0
217
+ oy[1] = 1
218
+ oy[2] = 0
219
+ oy[3] = 0
208
220
  const ortho = (globalThis as any).__test_cosine(ox, oy, 4)
209
221
  expect(ortho).toBeCloseTo(0, 4)
210
222
  } finally {
@@ -30,10 +30,7 @@ import { describe, it, expect } from 'bun:test'
30
30
  import { readFileSync } from 'node:fs'
31
31
  import { join } from 'node:path'
32
32
 
33
- const LINALG_SOURCE = readFileSync(
34
- join(import.meta.dir, 'index.tjs'),
35
- 'utf8'
36
- )
33
+ const LINALG_SOURCE = readFileSync(join(import.meta.dir, 'index.tjs'), 'utf8')
37
34
 
38
35
  // The inline baseline — single wasm{} block computing dot, magA, magB
39
36
  // together. Mirrors what guides/examples/tjs/wasm-vector-search.md does.
@@ -161,7 +158,12 @@ async function loadVariant(
161
158
  fnName: string,
162
159
  varName: string
163
160
  ): Promise<{
164
- search: (corpus: Float32Array, query: Float32Array, count: number, dim: number) => number
161
+ search: (
162
+ corpus: Float32Array,
163
+ query: Float32Array,
164
+ count: number,
165
+ dim: number
166
+ ) => number
165
167
  wasmBuffer: (Ctor: any, len: number) => any
166
168
  }> {
167
169
  await new Function(
@@ -296,13 +298,28 @@ describe('Canonical demo: vector-search across three forms', () => {
296
298
  const warmCount = Math.min(100, cfg.count)
297
299
  for (let w = 0; w < 3; w++) {
298
300
  inline.search(inlineCorpus, inlineQuery, warmCount, cfg.dim)
299
- composedJs.search(composedJsCorpus, composedJsQuery, warmCount, cfg.dim)
300
- composedWasm.search(composedWasmCorpus, composedWasmQuery, warmCount, cfg.dim)
301
+ composedJs.search(
302
+ composedJsCorpus,
303
+ composedJsQuery,
304
+ warmCount,
305
+ cfg.dim
306
+ )
307
+ composedWasm.search(
308
+ composedWasmCorpus,
309
+ composedWasmQuery,
310
+ warmCount,
311
+ cfg.dim
312
+ )
301
313
  }
302
314
 
303
315
  // Time inline
304
316
  const inlineStart = performance.now()
305
- const inlineIdx = inline.search(inlineCorpus, inlineQuery, cfg.count, cfg.dim)
317
+ const inlineIdx = inline.search(
318
+ inlineCorpus,
319
+ inlineQuery,
320
+ cfg.count,
321
+ cfg.dim
322
+ )
306
323
  const inlineMs = performance.now() - inlineStart
307
324
 
308
325
  // Time composed JS-outer-loop
@@ -352,9 +369,13 @@ describe('Canonical demo: vector-search across three forms', () => {
352
369
  const jsRatio = t.composedJsMs / t.inlineMs
353
370
  const wasmRatio = t.composedWasmMs / t.inlineMs
354
371
  console.log(
355
- ` ${t.label.padEnd(12)} | ${t.inlineMs.toFixed(2).padStart(8)} | ${t.composedJsMs
372
+ ` ${t.label.padEnd(12)} | ${t.inlineMs
373
+ .toFixed(2)
374
+ .padStart(8)} | ${t.composedJsMs
375
+ .toFixed(2)
376
+ .padStart(11)} | ${jsRatio
356
377
  .toFixed(2)
357
- .padStart(11)} | ${jsRatio.toFixed(2).padStart(6)}x | ${t.composedWasmMs
378
+ .padStart(6)}x | ${t.composedWasmMs
358
379
  .toFixed(2)
359
380
  .padStart(13)} | ${wasmRatio.toFixed(2).padStart(5)}x`
360
381
  )