tjs-lang 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CLAUDE.md +13 -4
  2. package/demo/autocomplete.test.ts +37 -0
  3. package/demo/docs.json +698 -8
  4. package/demo/examples.test.ts +6 -2
  5. package/demo/src/autocomplete.ts +40 -2
  6. package/demo/src/introspection-bridge.ts +140 -0
  7. package/demo/src/introspection-doc.test.ts +63 -0
  8. package/demo/src/playground-shared.ts +101 -16
  9. package/demo/src/playground-test-results.test.ts +112 -0
  10. package/demo/src/tjs-playground.ts +32 -5
  11. package/dist/examples/modules/dist/main.d.ts +34 -0
  12. package/dist/examples/modules/dist/math.d.ts +120 -0
  13. package/dist/index.js +115 -112
  14. package/dist/index.js.map +4 -4
  15. package/dist/src/lang/core.d.ts +1 -1
  16. package/dist/src/lang/dialect.d.ts +35 -0
  17. package/dist/src/lang/emitters/ast.d.ts +1 -1
  18. package/dist/src/lang/emitters/js-tests.d.ts +7 -0
  19. package/dist/src/lang/emitters/js.d.ts +12 -0
  20. package/dist/src/lang/index.d.ts +2 -1
  21. package/dist/src/lang/parser-types.d.ts +17 -0
  22. package/dist/src/lang/parser.d.ts +18 -0
  23. package/dist/src/lang/transpiler.d.ts +1 -0
  24. package/dist/src/lang/types.d.ts +18 -0
  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 -43
  30. package/dist/tjs-eval.js.map +3 -3
  31. package/dist/tjs-from-ts.js +1 -1
  32. package/dist/tjs-from-ts.js.map +2 -2
  33. package/dist/tjs-lang.js +76 -73
  34. package/dist/tjs-lang.js.map +4 -4
  35. package/dist/tjs-vm.js +49 -49
  36. package/dist/tjs-vm.js.map +3 -3
  37. package/editors/codemirror/ajs-language.ts +130 -44
  38. package/editors/codemirror/completion-source.test.ts +114 -0
  39. package/editors/introspect-value.test.ts +61 -0
  40. package/editors/introspect-value.ts +86 -0
  41. package/editors/scope-symbols.test.ts +113 -0
  42. package/editors/scope-symbols.ts +173 -0
  43. package/llms.txt +1 -0
  44. package/package.json +21 -1
  45. package/src/batteries/audit.ts +3 -2
  46. package/src/cli/commands/check.ts +3 -2
  47. package/src/cli/commands/emit.ts +4 -2
  48. package/src/cli/commands/run.ts +6 -2
  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/emitters/ast.ts +145 -2
  55. package/src/lang/emitters/js-tests.ts +46 -37
  56. package/src/lang/emitters/js.ts +19 -2
  57. package/src/lang/features.test.ts +6 -5
  58. package/src/lang/index.ts +40 -5
  59. package/src/lang/parser-types.ts +17 -0
  60. package/src/lang/parser.test.ts +12 -6
  61. package/src/lang/parser.ts +113 -3
  62. package/src/lang/predicate-schema.test.ts +97 -0
  63. package/src/lang/predicate-schema.ts +168 -0
  64. package/src/lang/predicate.test.ts +184 -0
  65. package/src/lang/predicate.ts +550 -0
  66. package/src/lang/subset-invariant.test.ts +90 -0
  67. package/src/lang/suggest.test.ts +84 -0
  68. package/src/lang/transpiler.ts +34 -0
  69. package/src/lang/types.ts +18 -0
  70. package/src/lang/wasm.test.ts +8 -2
  71. package/src/linalg/linalg.test.ts +8 -2
  72. package/src/use-cases/batteries.test.ts +4 -0
  73. package/src/use-cases/local-helpers.test.ts +219 -0
  74. package/src/use-cases/timeout-overrides.test.ts +169 -0
  75. package/src/vm/atom-effects.test.ts +72 -0
  76. package/src/vm/atoms/batteries.ts +29 -3
  77. package/src/vm/runtime.ts +140 -4
  78. package/src/vm/vm.ts +47 -9
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Scope-aware symbol collection for autocomplete.
3
+ *
4
+ * Replaces the regex `const NAME =` scraping (which missed ALL destructuring —
5
+ * and the tosijs examples bind everything via destructuring, so nothing was
6
+ * suggested). This parses the source (acorn, falling back to acorn-loose for
7
+ * mid-edit / not-yet-valid source) and walks binding patterns properly:
8
+ * `const { todoApp } = tosi(...)`, `const { h1, ul } = elements`,
9
+ * `const [a, , b] = xs`, rename, defaults, rest — all yield their bound names.
10
+ *
11
+ * Each symbol also records its **origin** (the initializer expression text, and
12
+ * for object-destructuring the key it came from). That origin is the hook the
13
+ * runtime-introspection layer uses: to introspect `h1` it can evaluate
14
+ * `elements.h1` in the live scope. Names are plumbing; the *types* come from
15
+ * introspecting real values (see the introspection bridge), not from this parse.
16
+ */
17
+ import * as acorn from 'acorn'
18
+ import * as acornLoose from 'acorn-loose'
19
+ import * as walk from 'acorn-walk'
20
+
21
+ export interface SymbolOrigin {
22
+ /** How the binding was produced. */
23
+ via: 'init' | 'destructure' | 'param' | 'import' | 'function'
24
+ /** Source text of the initializer expression (e.g. `elements`, `tosi({...})`). */
25
+ expr?: string
26
+ /** For object-destructuring: the key on `expr` this name came from (`elements.h1` → `h1`). */
27
+ member?: string
28
+ /** Module specifier, for imports. */
29
+ module?: string
30
+ }
31
+
32
+ export interface ScopeSymbol {
33
+ name: string
34
+ kind: 'variable' | 'function' | 'parameter' | 'import'
35
+ origin?: SymbolOrigin
36
+ }
37
+
38
+ function parse(source: string): any {
39
+ try {
40
+ return acorn.parse(source, { ecmaVersion: 'latest' })
41
+ } catch {
42
+ try {
43
+ // Best-effort AST from incomplete / not-yet-valid source.
44
+ return acornLoose.parse(source, { ecmaVersion: 'latest' })
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+ }
50
+
51
+ type NameSink = (name: string, member?: string) => void
52
+
53
+ /** Walk a binding pattern, emitting each bound name (and, one level deep for
54
+ * object patterns, the source key it came from). */
55
+ function collectPattern(pat: any, onName: NameSink, member?: string): void {
56
+ if (!pat) return
57
+ switch (pat.type) {
58
+ case 'Identifier':
59
+ onName(pat.name, member)
60
+ return
61
+ case 'ObjectPattern':
62
+ for (const p of pat.properties) {
63
+ if (p.type === 'RestElement') {
64
+ collectPattern(p.argument, onName)
65
+ } else {
66
+ const key = p.key && (p.key.name ?? p.key.value)
67
+ collectPattern(
68
+ p.value,
69
+ onName,
70
+ typeof key === 'string' ? key : undefined
71
+ )
72
+ }
73
+ }
74
+ return
75
+ case 'ArrayPattern':
76
+ for (const el of pat.elements) collectPattern(el, onName)
77
+ return
78
+ case 'AssignmentPattern':
79
+ collectPattern(pat.left, onName, member)
80
+ return
81
+ case 'RestElement':
82
+ collectPattern(pat.argument, onName)
83
+ return
84
+ }
85
+ }
86
+
87
+ const inRange = (node: any, position: number) =>
88
+ typeof node.start === 'number' &&
89
+ typeof node.end === 'number' &&
90
+ node.start <= position &&
91
+ position <= node.end
92
+
93
+ /**
94
+ * Collect the symbols in scope at `position` (defaults to end of source):
95
+ * variable/function/import bindings declared before the cursor, plus the
96
+ * parameters of any function whose body the cursor sits inside. Destructuring
97
+ * is fully handled; results are de-duplicated by name (last declaration wins).
98
+ */
99
+ export function collectScopeSymbols(
100
+ source: string,
101
+ position: number = source.length
102
+ ): ScopeSymbol[] {
103
+ const ast = parse(source)
104
+ if (!ast) return []
105
+
106
+ const byName = new Map<string, ScopeSymbol>()
107
+ const add = (s: ScopeSymbol) => byName.set(s.name, s)
108
+
109
+ walk.full(ast, (node: any) => {
110
+ switch (node.type) {
111
+ case 'VariableDeclaration': {
112
+ // Only count declarations that appear before the cursor.
113
+ if (node.start >= position) return
114
+ for (const decl of node.declarations) {
115
+ const initText =
116
+ decl.init && typeof decl.init.start === 'number'
117
+ ? source.slice(decl.init.start, decl.init.end)
118
+ : undefined
119
+ collectPattern(decl.id, (name, member) =>
120
+ add({
121
+ name,
122
+ kind: 'variable',
123
+ origin: {
124
+ via: member != null ? 'destructure' : 'init',
125
+ expr: initText,
126
+ member,
127
+ },
128
+ })
129
+ )
130
+ }
131
+ return
132
+ }
133
+ case 'FunctionDeclaration': {
134
+ if (node.id && node.start < position)
135
+ add({
136
+ name: node.id.name,
137
+ kind: 'function',
138
+ origin: { via: 'function' },
139
+ })
140
+ // params only in scope when the cursor is inside the function
141
+ if (inRange(node, position))
142
+ for (const p of node.params)
143
+ collectPattern(p, (name) =>
144
+ add({ name, kind: 'parameter', origin: { via: 'param' } })
145
+ )
146
+ return
147
+ }
148
+ case 'FunctionExpression':
149
+ case 'ArrowFunctionExpression': {
150
+ if (inRange(node, position))
151
+ for (const p of node.params)
152
+ collectPattern(p, (name) =>
153
+ add({ name, kind: 'parameter', origin: { via: 'param' } })
154
+ )
155
+ return
156
+ }
157
+ case 'ImportDeclaration': {
158
+ const module = String(node.source?.value ?? '')
159
+ for (const spec of node.specifiers) {
160
+ // local name is spec.local.name for all specifier kinds
161
+ add({
162
+ name: spec.local.name,
163
+ kind: 'import',
164
+ origin: { via: 'import', module },
165
+ })
166
+ }
167
+ return
168
+ }
169
+ }
170
+ })
171
+
172
+ return [...byName.values()]
173
+ }
package/llms.txt CHANGED
@@ -8,6 +8,7 @@ This file is a navigation index for AI agents. It does not contain the docs them
8
8
 
9
9
  - [CLAUDE-TJS-SYNTAX.md](CLAUDE-TJS-SYNTAX.md) — TJS syntax reference. Critical: `function foo(x: 'default')` is NOT a TypeScript string-literal type. The colon value is an **example**; `'default'` widens to `string`. LLMs get this wrong constantly.
10
10
  - [CLAUDE.md](CLAUDE.md) — project overview, common commands, architecture, security model, and key APIs.
11
+ - [PRINCIPLES.md](PRINCIPLES.md) — **non-negotiable language invariants**: options-off TJS ⊇ JS, and TJS ⊇ AJS. A richer layer may do *more* with the same source but must never make subset-legal code illegal (e.g. un-runnable signature tests are inconclusive, not errors). The opt-in unit is the **dialect**: `tjs(src, { dialect: 'js' })` preserves plain-JS semantics, `'tjs'` (default) is native; file tools map extension→dialect via `dialectForFilename`/`sourceKindForFilename` from `tjs-lang/lang`. TypeScript routes through `fromTS` (`tjs-lang/lang/from-ts`) so the lean lang bundle stays TS-free — the full `js | ts | tjs` routing recipe is in PRINCIPLES.md. A violation is a bug.
11
12
 
12
13
  ## Language guides
13
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tjs-lang",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Type-safe JavaScript dialect with runtime validation, sandboxed VM execution, and AI agent orchestration. Transpiles TypeScript to validated JS with fuel-metered execution for untrusted code.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -22,6 +22,25 @@
22
22
  "license": "Apache-2.0",
23
23
  "main": "./dist/index.js",
24
24
  "types": "./dist/src/index.d.ts",
25
+ "typesVersions": {
26
+ "*": {
27
+ "eval": [
28
+ "./dist/src/lang/eval.d.ts"
29
+ ],
30
+ "lang": [
31
+ "./dist/src/lang/transpiler.d.ts"
32
+ ],
33
+ "lang/from-ts": [
34
+ "./dist/src/lang/emitters/from-ts.d.ts"
35
+ ],
36
+ "vm": [
37
+ "./dist/src/vm/index.d.ts"
38
+ ],
39
+ "batteries": [
40
+ "./dist/src/batteries/index.d.ts"
41
+ ]
42
+ }
43
+ },
25
44
  "exports": {
26
45
  ".": {
27
46
  "bun": "./src/index.ts",
@@ -142,6 +161,7 @@
142
161
  },
143
162
  "dependencies": {
144
163
  "acorn": "^8.15.0",
164
+ "acorn-loose": "^8.5.2",
145
165
  "acorn-walk": "^8.3.4",
146
166
  "tosijs-schema": "^1.3.0"
147
167
  }
@@ -190,9 +190,10 @@ async function checkEmbedding(
190
190
  }
191
191
  }
192
192
 
193
- // Tiny 1x1 red PNG as base64 for vision testing
193
+ // 32x32 solid-red PNG (base64). NOT 1x1 — degenerate sizes are rejected by
194
+ // real vision preprocessors (e.g. gemma: "Cannot handle this data type (1,1,1)").
194
195
  const TINY_TEST_IMAGE =
195
- 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='
196
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAMUlEQVR4nGO4o6FBU8QwasFoEI2motF8MFpUjJamo/XBaJU52qoYbReNNh01hkg+AACGobA9N+tfoAAAAABJRU5ErkJggg=='
196
197
 
197
198
  async function checkVision(baseUrl: string, modelId: string): Promise<boolean> {
198
199
  try {
@@ -3,13 +3,14 @@
3
3
  */
4
4
 
5
5
  import { readFileSync } from 'fs'
6
- import { tjs } from '../../lang'
6
+ import { tjs, dialectForFilename } from '../../lang'
7
7
 
8
8
  export async function check(file: string): Promise<void> {
9
9
  const source = readFileSync(file, 'utf-8')
10
10
 
11
11
  try {
12
- const result = tjs(source)
12
+ // `.js`/`.mjs` plain-JS semantics preserved; `.tjs` ⇒ native modes.
13
+ const result = tjs(source, { dialect: dialectForFilename(file) })
13
14
 
14
15
  // Report function info from types
15
16
  if (result.types && Object.keys(result.types).length > 0) {
@@ -21,7 +21,7 @@ import {
21
21
  existsSync,
22
22
  } from 'fs'
23
23
  import { join, basename, dirname, extname } from 'path'
24
- import { tjs } from '../../lang'
24
+ import { tjs, dialectForFilename } from '../../lang'
25
25
  import { generateDocs } from '../../lang/docs'
26
26
  import { generateDTS } from '../../lang/emitters/dts'
27
27
 
@@ -84,9 +84,11 @@ async function emitFile(
84
84
  const filename = basename(inputPath)
85
85
 
86
86
  try {
87
- // Use 'report' mode to get test results without throwing
87
+ // Use 'report' mode to get test results without throwing.
88
+ // `.js`/`.mjs` ⇒ plain-JS semantics preserved; `.tjs` ⇒ native modes.
88
89
  const result = tjs(source, {
89
90
  filename,
91
+ dialect: dialectForFilename(inputPath),
90
92
  debug: options.debug,
91
93
  runTests: 'report',
92
94
  })
@@ -8,15 +8,19 @@ import { readFileSync } from 'fs'
8
8
  import { resolve } from 'path'
9
9
  import { preprocess } from '../../lang/parser'
10
10
  import { transpileToJS } from '../../lang/emitters/js'
11
+ import { dialectForFilename } from '../../lang/dialect'
11
12
  import * as runtime from '../../lang/runtime'
12
13
 
13
14
  export async function run(file: string): Promise<void> {
14
15
  const absolutePath = resolve(file)
15
16
  const source = readFileSync(absolutePath, 'utf-8')
16
17
 
18
+ // `.js`/`.mjs` ⇒ plain-JS semantics preserved; `.tjs` ⇒ native modes.
19
+ const dialect = dialectForFilename(file)
20
+
17
21
  try {
18
22
  // Preprocess: transforms Type, Generic, Union declarations, runs tests
19
- const preprocessed = preprocess(source)
23
+ const preprocessed = preprocess(source, { dialect })
20
24
 
21
25
  if (preprocessed.testErrors.length > 0) {
22
26
  console.error('Test failures:')
@@ -27,7 +31,7 @@ export async function run(file: string): Promise<void> {
27
31
  }
28
32
 
29
33
  // Transpile to JS
30
- const result = transpileToJS(preprocessed.source)
34
+ const result = transpileToJS(preprocessed.source, { dialect })
31
35
 
32
36
  if (result.warnings && result.warnings.length > 0) {
33
37
  for (const warning of result.warnings) {
@@ -3,12 +3,12 @@
3
3
  */
4
4
 
5
5
  import { readFileSync } from 'fs'
6
- import { tjs } from '../../lang'
6
+ import { tjs, dialectForFilename } from '../../lang'
7
7
 
8
8
  export async function types(file: string): Promise<void> {
9
9
  const source = readFileSync(file, 'utf-8')
10
10
 
11
- const result = tjs(source)
11
+ const result = tjs(source, { dialect: dialectForFilename(file) })
12
12
 
13
13
  // Output the type information as JSON
14
14
  const typeInfo = {
@@ -1543,8 +1543,11 @@ function map(arr: [''], counter = strLength): [0] { return arr.map(counter) }`)
1543
1543
  )
1544
1544
  const sig = testResults?.find((t) => t.isSignatureTest)
1545
1545
  expect(sig).toBeDefined()
1546
+ // The module couldn't execute (runtime ReferenceError on `x`), so the
1547
+ // test couldn't run → inconclusive, never a build-blocking failure.
1546
1548
  expect(sig?.passed).toBe(false)
1547
- expect(sig?.error).toContain('Module execution failed')
1549
+ expect(sig?.inconclusive).toBe(true)
1550
+ expect(sig?.error).toContain('could not be executed')
1548
1551
  // Critical: no line attribution → editor won't mark a misleading line
1549
1552
  expect(sig?.line).toBeUndefined()
1550
1553
  })
package/src/lang/core.ts CHANGED
@@ -12,7 +12,7 @@ import type {
12
12
  FunctionSignature,
13
13
  TypeDescriptor,
14
14
  } from './types'
15
- import { parse, validateSingleFunction } from './parser'
15
+ import { parse, extractFunctions } from './parser'
16
16
  import { transformFunction } from './emitters/ast'
17
17
  import {
18
18
  transpileToJS,
@@ -26,6 +26,7 @@ export {
26
26
  preprocess,
27
27
  extractTDoc,
28
28
  validateSingleFunction,
29
+ extractFunctions,
29
30
  } from './parser'
30
31
  export { transformFunction } from './emitters/ast'
31
32
 
@@ -47,14 +48,15 @@ export function transpile(
47
48
  vmTarget: true,
48
49
  })
49
50
 
50
- const func = validateSingleFunction(program, options.filename)
51
+ const { entry, helpers } = extractFunctions(program, options.filename)
51
52
 
52
53
  const { ast, signature, warnings } = transformFunction(
53
- func,
54
+ entry,
54
55
  originalSource,
55
56
  returnType,
56
57
  options,
57
- requiredParams
58
+ requiredParams,
59
+ helpers.size > 0 ? helpers : undefined
58
60
  )
59
61
 
60
62
  return {
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { tjs } from './index'
3
+ import { dialectForFilename, sourceKindForFilename } from './dialect'
4
+
5
+ describe('source dialect', () => {
6
+ // A line of vanilla JS whose meaning TJS would otherwise change: structural
7
+ // `==` (→ Eq), honest truthiness (→ toBool), and `typeof` (→ TypeOf).
8
+ const JS = `function f(a, b) { if (a == b) { return typeof a } return null }`
9
+
10
+ const isMolested = (code: string) =>
11
+ code.includes('Eq(') || code.includes('toBool') || code.includes('TypeOf')
12
+
13
+ describe('dialect option', () => {
14
+ it("dialect: 'js' preserves plain-JS semantics (no rewrites)", () => {
15
+ expect(isMolested(tjs(JS, { dialect: 'js' }).code)).toBe(false)
16
+ })
17
+
18
+ it("dialect: 'tjs' applies native footgun-removal modes", () => {
19
+ expect(isMolested(tjs(JS, { dialect: 'tjs' }).code)).toBe(true)
20
+ })
21
+
22
+ it('a bare string still defaults to native TJS (backward compatible)', () => {
23
+ expect(isMolested(tjs(JS).code)).toBe(true)
24
+ })
25
+
26
+ it("dialect: 'js' is equivalent to the TjsCompat directive", () => {
27
+ const viaOption = tjs(JS, { dialect: 'js' }).code
28
+ const viaDirective = tjs(`TjsCompat\n${JS}`).code
29
+ // Both leave the comparison/typeof untouched.
30
+ expect(isMolested(viaOption)).toBe(false)
31
+ expect(isMolested(viaDirective)).toBe(false)
32
+ })
33
+ })
34
+
35
+ describe('dialectForFilename', () => {
36
+ it('maps JS extensions to the js dialect', () => {
37
+ for (const f of ['a.js', 'a.mjs', 'a.cjs', 'deep/b.JS']) {
38
+ expect(dialectForFilename(f)).toBe('js')
39
+ }
40
+ })
41
+
42
+ it('maps .tjs and unknown/TS extensions to the tjs dialect', () => {
43
+ // .ts is not a tjs() dialect — it routes through fromTS — so the dialect
44
+ // helper reports 'tjs'; use sourceKindForFilename to tell them apart.
45
+ for (const f of ['a.tjs', 'a.ts', 'a.mts', 'a.d.ts', 'noext']) {
46
+ expect(dialectForFilename(f)).toBe('tjs')
47
+ }
48
+ })
49
+ })
50
+
51
+ describe('sourceKindForFilename', () => {
52
+ it('classifies js / ts / tjs by extension', () => {
53
+ expect(sourceKindForFilename('x.js')).toBe('js')
54
+ expect(sourceKindForFilename('x.mjs')).toBe('js')
55
+ expect(sourceKindForFilename('x.cjs')).toBe('js')
56
+ expect(sourceKindForFilename('x.ts')).toBe('ts')
57
+ expect(sourceKindForFilename('x.mts')).toBe('ts')
58
+ expect(sourceKindForFilename('x.d.ts')).toBe('ts')
59
+ expect(sourceKindForFilename('x.tjs')).toBe('tjs')
60
+ expect(sourceKindForFilename('x')).toBe('tjs')
61
+ })
62
+ })
63
+ })
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Dialect resolution for file-based tooling.
3
+ *
4
+ * The subset invariant (see PRINCIPLES.md) says plain JavaScript must transpile
5
+ * through TJS without changing meaning, while native `.tjs` gets the full
6
+ * footgun-removal feature set. The unit of opt-in is the *dialect* — and for a
7
+ * file, the dialect is its extension. This is the one canonical mapping that
8
+ * CLIs, bundler plugins, module loaders, and doc systems should all share so
9
+ * `.js` is never silently "improved" into different semantics.
10
+ */
11
+
12
+ /** Source dialect understood by `tjs(src, { dialect })`. */
13
+ export type Dialect = 'js' | 'tjs'
14
+
15
+ /** How a file should be transpiled, based on its extension. */
16
+ export type SourceKind = 'js' | 'ts' | 'tjs'
17
+
18
+ const JS_EXT = /\.[mc]?js$/i // .js .mjs .cjs
19
+ const TS_EXT = /\.[mc]?ts$/i // .ts .mts .cts (excludes .d.ts — handled below)
20
+ const TJS_EXT = /\.tjs$/i
21
+
22
+ /**
23
+ * Classify a file by extension into the source kind that determines its
24
+ * transpile path:
25
+ * - `'tjs'` → native TJS: `tjs(src)` (all modes ON — the better language)
26
+ * - `'js'` → plain JS: `tjs(src, { dialect: 'js' })` (semantics preserved)
27
+ * - `'ts'` → TypeScript: route through `fromTS` (TS → TJS → JS)
28
+ *
29
+ * Unknown extensions default to `'tjs'` (the native language). `.d.ts` is
30
+ * treated as TypeScript.
31
+ */
32
+ export function sourceKindForFilename(filename: string): SourceKind {
33
+ if (TJS_EXT.test(filename)) return 'tjs'
34
+ if (JS_EXT.test(filename)) return 'js'
35
+ if (TS_EXT.test(filename)) return 'ts'
36
+ return 'tjs'
37
+ }
38
+
39
+ /**
40
+ * Map a filename to the `dialect` option for `tjs()`. `.js`/`.mjs`/`.cjs` ⇒
41
+ * `'js'` (plain JavaScript, semantics preserved); everything else ⇒ `'tjs'`
42
+ * (native TJS).
43
+ *
44
+ * NOTE: TypeScript files are NOT a `tjs()` dialect — transpile them with
45
+ * `fromTS` first. Use {@link sourceKindForFilename} when you need to tell
46
+ * `.ts` apart from `.js`/`.tjs`.
47
+ */
48
+ export function dialectForFilename(filename: string): Dialect {
49
+ return sourceKindForFilename(filename) === 'js' ? 'js' : 'tjs'
50
+ }
@@ -124,7 +124,8 @@ export function transformFunction(
124
124
  source: string,
125
125
  returnTypeAnnotation: string | undefined,
126
126
  options: TranspileOptions = {},
127
- requiredParamsFromPreprocess?: Set<string>
127
+ requiredParamsFromPreprocess?: Set<string>,
128
+ helpers?: Map<string, FunctionDeclaration>
128
129
  ): {
129
130
  ast: BaseNode
130
131
  signature: FunctionSignature
@@ -175,6 +176,9 @@ export function transformFunction(
175
176
  source,
176
177
  filename: options.filename || '<source>',
177
178
  options,
179
+ helpers,
180
+ helperSteps: helpers ? new Map() : undefined,
181
+ helperTransforming: helpers ? new Set() : undefined,
178
182
  }
179
183
 
180
184
  // Transform function body
@@ -244,8 +248,21 @@ export function transformFunction(
244
248
  // Generate input schema for runtime validation
245
249
  const inputSchema = parametersToJsonSchema(signatureParams)
246
250
 
251
+ // Collect transitively-used helper bodies (transformed once) onto the root
252
+ // node. The VM installs these into ctx.helpers so callLocal can dispatch by
253
+ // name — call sites stay tiny and recursion is a runtime loop.
254
+ const helperBodies =
255
+ ctx.helperSteps && ctx.helperSteps.size > 0
256
+ ? Object.fromEntries(ctx.helperSteps)
257
+ : undefined
258
+
247
259
  return {
248
- ast: { op: 'seq', steps, inputSchema },
260
+ ast: {
261
+ op: 'seq',
262
+ steps,
263
+ inputSchema,
264
+ ...(helperBodies && { helpers: helperBodies }),
265
+ },
249
266
  signature,
250
267
  warnings: ctx.warnings,
251
268
  }
@@ -1079,6 +1096,34 @@ function transformCallExpression(
1079
1096
  // This would be caught above, but just in case
1080
1097
  }
1081
1098
 
1099
+ // Helper call? Emit callLocal referencing the helper by name. The body is
1100
+ // transformed once and stored in ctx.helperSteps (collected onto the seq
1101
+ // node), so call sites stay tiny and recursion is just a runtime loop.
1102
+ if (ctx.helpers?.has(funcName)) {
1103
+ const paramNames = ensureHelperTransformed(funcName, ctx, expr)
1104
+ const argExprs = expr.arguments.map((arg) =>
1105
+ expressionToValue(arg as Expression, ctx)
1106
+ )
1107
+ if (argExprs.length !== paramNames.length) {
1108
+ throw new TranspileError(
1109
+ `Helper '${funcName}' expects ${paramNames.length} argument(s), got ${argExprs.length}`,
1110
+ getLocation(expr),
1111
+ ctx.source,
1112
+ ctx.filename
1113
+ )
1114
+ }
1115
+ return {
1116
+ step: {
1117
+ op: 'callLocal',
1118
+ name: funcName,
1119
+ args: argExprs,
1120
+ ...(resultVar && { result: resultVar }),
1121
+ ...(resultVar && isConst && { resultConst: true }),
1122
+ },
1123
+ resultVar,
1124
+ }
1125
+ }
1126
+
1082
1127
  // Check if it's a known atom
1083
1128
  // For now, we assume any function call is an atom call
1084
1129
  // The VM will validate at runtime
@@ -1097,6 +1142,89 @@ function transformCallExpression(
1097
1142
  }
1098
1143
  }
1099
1144
 
1145
+ /**
1146
+ * Ensure a helper's body has been transformed once into `ctx.helperSteps`,
1147
+ * returning its parameter names (for arity checking at the call site).
1148
+ *
1149
+ * Bodies are stored by name and called by reference at runtime, so recursion
1150
+ * is fine: when a helper references itself (or a mutually-recursive sibling)
1151
+ * the call site only needs the param names — the steps are filled in when the
1152
+ * in-progress transform completes. The `helperTransforming` set just prevents
1153
+ * re-entering a transform that's already underway.
1154
+ */
1155
+ function ensureHelperTransformed(
1156
+ name: string,
1157
+ ctx: TransformContext,
1158
+ callSite: CallExpression
1159
+ ): string[] {
1160
+ const fn = ctx.helpers!.get(name)!
1161
+ // Accept plain identifiers (`x`) and example/default params (`x: 0` → `x = 0`
1162
+ // after colon-shorthand desugaring). Destructuring isn't supported in v1.
1163
+ // Helpers are arity-checked and called with explicit args, so the example/
1164
+ // default is never applied — we only need the parameter name and position.
1165
+ const paramNames: string[] = []
1166
+ for (const param of fn.params) {
1167
+ let id: Identifier | undefined
1168
+ if (param.type === 'Identifier') {
1169
+ id = param as Identifier
1170
+ } else if (
1171
+ param.type === 'AssignmentPattern' &&
1172
+ (param as any).left?.type === 'Identifier'
1173
+ ) {
1174
+ id = (param as any).left as Identifier
1175
+ }
1176
+ if (!id) {
1177
+ throw new TranspileError(
1178
+ `Helper '${name}' parameters must be plain identifiers (optionally with an example value); destructuring is not supported`,
1179
+ param.loc?.start ?? getLocation(callSite),
1180
+ ctx.source,
1181
+ ctx.filename
1182
+ )
1183
+ }
1184
+ paramNames.push(id.name)
1185
+ }
1186
+
1187
+ // Already transformed, or transform already underway (recursive reference):
1188
+ // the call site only needs paramNames; the body is/will be in helperSteps.
1189
+ if (ctx.helperSteps!.has(name) || ctx.helperTransforming!.has(name)) {
1190
+ return paramNames
1191
+ }
1192
+
1193
+ ctx.helperTransforming!.add(name)
1194
+ try {
1195
+ // Transform the helper body in a fresh inner context — isolated locals,
1196
+ // params as the only known identifiers; helpers map is shared so it can
1197
+ // call (and recurse into) other helpers.
1198
+ const helperCtx: TransformContext = {
1199
+ depth: 0,
1200
+ locals: new Map(),
1201
+ parameters: new Map(
1202
+ paramNames.map((p) => [
1203
+ p,
1204
+ {
1205
+ name: p,
1206
+ type: { kind: 'any' as const },
1207
+ required: true,
1208
+ } as ParameterDescriptor,
1209
+ ])
1210
+ ),
1211
+ atoms: ctx.atoms,
1212
+ warnings: ctx.warnings,
1213
+ source: ctx.source,
1214
+ filename: ctx.filename,
1215
+ options: ctx.options,
1216
+ helpers: ctx.helpers,
1217
+ helperSteps: ctx.helperSteps,
1218
+ helperTransforming: ctx.helperTransforming,
1219
+ }
1220
+ const bodySteps = transformBlock(fn.body, helperCtx)
1221
+ ctx.helperSteps!.set(name, { steps: bodySteps, paramNames })
1222
+ } finally {
1223
+ ctx.helperTransforming!.delete(name)
1224
+ }
1225
+ return paramNames
1226
+ }
1227
+
1100
1228
  /**
1101
1229
  * Handle method calls like arr.map(), str.slice(), etc.
1102
1230
  */
@@ -1544,6 +1672,21 @@ function expressionToExprNode(
1544
1672
  // Handle global function calls (e.g., parseInt(x), parseFloat(x))
1545
1673
  if (call.callee.type === 'Identifier') {
1546
1674
  const funcName = (call.callee as Identifier).name
1675
+
1676
+ // Helper calls cannot be nested inside expressions — like template
1677
+ // literals and complex calls, they must live at statement level so the
1678
+ // expression sandbox stays call-free. Lift to a temp first.
1679
+ if (ctx.helpers?.has(funcName)) {
1680
+ throw new TranspileError(
1681
+ `Helper '${funcName}' cannot be called inside an expression. ` +
1682
+ `Assign its result to a variable first: ` +
1683
+ `const result = ${funcName}(...); then use result.`,
1684
+ getLocation(expr),
1685
+ ctx.source,
1686
+ ctx.filename
1687
+ )
1688
+ }
1689
+
1547
1690
  return {
1548
1691
  $expr: 'call',
1549
1692
  callee: funcName,