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
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Boolean coercion rewriter.
3
+ *
4
+ * Fixes the JS footgun `Boolean(new Boolean(false)) === true` (and friends)
5
+ * by rewriting every truthiness context to call `__tjs.toBool(x)`, which
6
+ * unwraps boxed primitives before coercing.
7
+ *
8
+ * Contexts rewritten:
9
+ * if (cond) → if (__tjs.toBool(cond))
10
+ * while (cond) → while (__tjs.toBool(cond))
11
+ * do {} while (cond) → do {} while (__tjs.toBool(cond))
12
+ * for (_; cond; _) → for (_; __tjs.toBool(cond); _)
13
+ * !x → !__tjs.toBool(x)
14
+ * a && b → ((__tjs__t)=>__tjs.toBool(__tjs__t)?(b):__tjs__t)(a)
15
+ * a || b → ((__tjs__t)=>__tjs.toBool(__tjs__t)?__tjs__t:(b))(a)
16
+ * a ? b : c → __tjs.toBool(a)?(b):(c)
17
+ * Boolean(x) → __tjs.toBool(x) (call form, not `new`)
18
+ *
19
+ * `??` (nullish coalescing) is intentionally NOT rewritten — its semantics
20
+ * are about null/undefined specifically, not truthiness, so boxed primitives
21
+ * behave correctly already.
22
+ *
23
+ * `===` / `!==` (identity) are also not touched — that's a separate
24
+ * footgun handled by the `Is` / `Eq` operators under TjsEquals.
25
+ *
26
+ * Always-on under TjsStandard.
27
+ */
28
+
29
+ import * as acorn from 'acorn'
30
+ import type { Program, Node } from 'acorn'
31
+
32
+ export interface BoolCoercionPatch {
33
+ start: number
34
+ end: number
35
+ newText: string
36
+ }
37
+
38
+ /**
39
+ * Walk the AST and emit replacement patches for every truthiness context.
40
+ * Patches are pre-deduped: nested coercions inside an outer patch are
41
+ * folded into the outer patch's newText, so returned patches don't overlap.
42
+ */
43
+ export function rewriteBoolCoercion(
44
+ ast: Program,
45
+ source: string
46
+ ): BoolCoercionPatch[] {
47
+ const candidates: BoolCoercionPatch[] = []
48
+
49
+ function emitTestWrap(test: Node): void {
50
+ candidates.push({
51
+ start: test.start,
52
+ end: test.end,
53
+ newText: `__tjs.toBool(${rewriteExpr(test, source)})`,
54
+ })
55
+ }
56
+
57
+ function visit(node: Node): void {
58
+ if (!node || typeof node !== 'object' || !('type' in node)) return
59
+
60
+ switch ((node as any).type) {
61
+ case 'IfStatement':
62
+ case 'WhileStatement':
63
+ case 'DoWhileStatement': {
64
+ const n = node as any
65
+ emitTestWrap(n.test)
66
+ // Visit the body / consequent / alternate normally
67
+ if (n.consequent) visit(n.consequent)
68
+ if (n.alternate) visit(n.alternate)
69
+ if (n.body) visit(n.body)
70
+ return
71
+ }
72
+ case 'ForStatement': {
73
+ const n = node as any
74
+ if (n.init) visit(n.init)
75
+ if (n.test) emitTestWrap(n.test)
76
+ if (n.update) visit(n.update)
77
+ if (n.body) visit(n.body)
78
+ return
79
+ }
80
+ case 'ConditionalExpression': {
81
+ const n = node as any
82
+ candidates.push({
83
+ start: n.start,
84
+ end: n.end,
85
+ newText:
86
+ `__tjs.toBool(${rewriteExpr(n.test, source)})` +
87
+ `?(${rewriteExpr(n.consequent, source)})` +
88
+ `:(${rewriteExpr(n.alternate, source)})`,
89
+ })
90
+ return
91
+ }
92
+ case 'LogicalExpression': {
93
+ const n = node as any
94
+ if (n.operator === '&&' || n.operator === '||') {
95
+ candidates.push({
96
+ start: n.start,
97
+ end: n.end,
98
+ newText: rewriteExpr(node, source),
99
+ })
100
+ return
101
+ }
102
+ // ?? unchanged — descend in case nested coercions live in the operands
103
+ break
104
+ }
105
+ case 'UnaryExpression': {
106
+ const n = node as any
107
+ if (n.operator === '!') {
108
+ candidates.push({
109
+ start: n.start,
110
+ end: n.end,
111
+ newText: `!__tjs.toBool(${rewriteExpr(n.argument, source)})`,
112
+ })
113
+ return
114
+ }
115
+ break
116
+ }
117
+ case 'CallExpression': {
118
+ const n = node as any
119
+ if (
120
+ n.callee &&
121
+ n.callee.type === 'Identifier' &&
122
+ n.callee.name === 'Boolean' &&
123
+ n.arguments.length === 1 &&
124
+ n.arguments[0].type !== 'SpreadElement'
125
+ ) {
126
+ // Boolean(x) → __tjs.toBool(x). Rare in practice but eliminates
127
+ // the inconsistency with the rewritten `if (x)` cases.
128
+ candidates.push({
129
+ start: n.start,
130
+ end: n.end,
131
+ newText: `__tjs.toBool(${rewriteExpr(n.arguments[0], source)})`,
132
+ })
133
+ return
134
+ }
135
+ break
136
+ }
137
+ }
138
+
139
+ // Default: walk children
140
+ walkChildren(node, visit)
141
+ }
142
+
143
+ visit(ast)
144
+
145
+ return dedupeNested(candidates)
146
+ }
147
+
148
+ /**
149
+ * Recursive partial codegen: returns the rewritten source for an expression
150
+ * subtree. For uninteresting nodes, returns the original source slice with
151
+ * any nested coercions rewritten in place.
152
+ */
153
+ function rewriteExpr(node: Node | null | undefined, source: string): string {
154
+ if (!node) return ''
155
+ switch ((node as any).type) {
156
+ case 'LogicalExpression': {
157
+ const n = node as any
158
+ const left = rewriteExpr(n.left, source)
159
+ const right = rewriteExpr(n.right, source)
160
+ if (n.operator === '&&') {
161
+ return `((__tjs__t)=>__tjs.toBool(__tjs__t)?(${right}):__tjs__t)(${left})`
162
+ }
163
+ if (n.operator === '||') {
164
+ return `((__tjs__t)=>__tjs.toBool(__tjs__t)?__tjs__t:(${right}))(${left})`
165
+ }
166
+ // ??
167
+ return `(${left})??(${right})`
168
+ }
169
+ case 'ConditionalExpression': {
170
+ const n = node as any
171
+ return (
172
+ `__tjs.toBool(${rewriteExpr(n.test, source)})` +
173
+ `?(${rewriteExpr(n.consequent, source)})` +
174
+ `:(${rewriteExpr(n.alternate, source)})`
175
+ )
176
+ }
177
+ case 'UnaryExpression': {
178
+ const n = node as any
179
+ if (n.operator === '!') {
180
+ return `!__tjs.toBool(${rewriteExpr(n.argument, source)})`
181
+ }
182
+ return rewriteOther(node, source)
183
+ }
184
+ case 'CallExpression': {
185
+ const n = node as any
186
+ if (
187
+ n.callee &&
188
+ n.callee.type === 'Identifier' &&
189
+ n.callee.name === 'Boolean' &&
190
+ n.arguments.length === 1 &&
191
+ n.arguments[0].type !== 'SpreadElement'
192
+ ) {
193
+ return `__tjs.toBool(${rewriteExpr(n.arguments[0], source)})`
194
+ }
195
+ return rewriteOther(node, source)
196
+ }
197
+ }
198
+ return rewriteOther(node, source)
199
+ }
200
+
201
+ /**
202
+ * Generic structural recursion: walk all child nodes in source order, splice
203
+ * rewritten children into the original source between gaps. This preserves
204
+ * arbitrary syntax (template literals, destructuring, JSX, etc.) without
205
+ * needing a full code generator — we only customize the nodes we actually
206
+ * rewrite.
207
+ */
208
+ function rewriteOther(node: Node, source: string): string {
209
+ const start = (node as any).start
210
+ const end = (node as any).end
211
+ if (typeof start !== 'number' || typeof end !== 'number') return ''
212
+
213
+ const children = collectChildren(node)
214
+ if (children.length === 0) return source.slice(start, end)
215
+
216
+ // Sort by start position (defensive — should already be in order)
217
+ children.sort((a, b) => a.start - b.start)
218
+
219
+ let out = ''
220
+ let cursor = start
221
+ for (const child of children) {
222
+ if (child.start < cursor) continue // overlapping; skip
223
+ if (child.start > cursor) out += source.slice(cursor, child.start)
224
+ out += rewriteExpr(child, source)
225
+ cursor = child.end
226
+ }
227
+ if (cursor < end) out += source.slice(cursor, end)
228
+ return out
229
+ }
230
+
231
+ /** Iterate the children of `cb` for any node, generic shape. */
232
+ function walkChildren(node: Node, cb: (n: Node) => void): void {
233
+ for (const child of collectChildren(node)) cb(child)
234
+ }
235
+
236
+ function collectChildren(node: Node): Node[] {
237
+ const out: Node[] = []
238
+ for (const key in node) {
239
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'loc') {
240
+ continue
241
+ }
242
+ const v = (node as any)[key]
243
+ if (Array.isArray(v)) {
244
+ for (const item of v) {
245
+ if (item && typeof item === 'object' && typeof item.type === 'string') {
246
+ out.push(item)
247
+ }
248
+ }
249
+ } else if (v && typeof v === 'object' && typeof v.type === 'string') {
250
+ out.push(v)
251
+ }
252
+ }
253
+ return out
254
+ }
255
+
256
+ /**
257
+ * Drop patches whose range is fully contained in another patch's range.
258
+ * The outer patch's newText already includes the inner rewrites via the
259
+ * recursive partial codegen, so the inner patch is redundant.
260
+ *
261
+ * Equal-range patches: keep the first one encountered (insertion order
262
+ * mirrors AST traversal order, where the parent is visited first).
263
+ */
264
+ function dedupeNested(patches: BoolCoercionPatch[]): BoolCoercionPatch[] {
265
+ // Sort by start asc, end desc (outermost first for equal starts)
266
+ const sorted = [...patches].sort((a, b) => a.start - b.start || b.end - a.end)
267
+ const kept: BoolCoercionPatch[] = []
268
+ let lastEnd = -1
269
+ for (const p of sorted) {
270
+ if (p.start >= lastEnd) {
271
+ kept.push(p)
272
+ lastEnd = p.end
273
+ }
274
+ // else: contained inside the last kept patch — drop
275
+ }
276
+ return kept
277
+ }
278
+
279
+ /**
280
+ * Source-text wrapper: parse, rewrite, re-emit. Used for code that's
281
+ * extracted before the main parse (test/mock bodies) but still needs the
282
+ * coercion rewrite to behave consistently with the rest of the module.
283
+ *
284
+ * Wraps the body in a function so top-level statements like `expect(...)`
285
+ * parse as a Program. Returns the original source unchanged if parsing
286
+ * fails (rather than throwing — a bad test body would already have been
287
+ * caught by the main parse).
288
+ */
289
+ export function rewriteBoolCoercionInSource(source: string): string {
290
+ let ast: Program
291
+ try {
292
+ ast = acorn.parse(`function __wrap__(){${source}}`, {
293
+ ecmaVersion: 2022,
294
+ sourceType: 'module',
295
+ locations: false,
296
+ }) as Program
297
+ } catch {
298
+ return source
299
+ }
300
+ const wrapped = `function __wrap__(){${source}}`
301
+ const patches = rewriteBoolCoercion(ast, wrapped)
302
+ if (patches.length === 0) return source
303
+
304
+ // Apply patches right-to-left
305
+ patches.sort((a, b) => b.start - a.start)
306
+ let out = wrapped
307
+ for (const p of patches) {
308
+ out = out.slice(0, p.start) + p.newText + out.slice(p.end)
309
+ }
310
+ // Strip the wrapper
311
+ const prefix = 'function __wrap__(){'
312
+ const suffix = '}'
313
+ return out.slice(prefix.length, out.length - suffix.length)
314
+ }
@@ -1446,6 +1446,143 @@ function getData(id: 0):! { value: 0 } {
1446
1446
  // But error properties are accessible
1447
1447
  expect(result.message).toContain('Expected integer')
1448
1448
  })
1449
+
1450
+ it('returns MonadicError when a function param receives a non-function', () => {
1451
+ const { code } = tjs(`function f(fn = (x) => x): 0 { return fn(5) }`)
1452
+ const f = new Function(code + '; return f')()
1453
+
1454
+ // Passing a function: works
1455
+ expect(f((n: number) => n * 2)).toBe(10)
1456
+
1457
+ // Passing a non-function: MonadicError
1458
+ for (const bad of [42, 'hello', { x: 1 }, [1, 2], true]) {
1459
+ const result = f(bad)
1460
+ expect(result).toBeInstanceOf(MonadicError)
1461
+ expect(result.expected).toBe('function')
1462
+ expect(result.path).toContain('f.fn')
1463
+ }
1464
+ })
1465
+
1466
+ describe('checkFnShape — pass-time shape check for function params', () => {
1467
+ it('passes a correctly-typed TJS function through unchanged', () => {
1468
+ // strLength has __tjs metadata declaring (string) => integer,
1469
+ // matching counter's expected shape via cross-ref inference.
1470
+ const { code } = tjs(`function strLength(s: ''): 0 { return s.length }
1471
+ function map(arr: [''], counter = strLength): [0] { return arr.map(counter) }`)
1472
+ const fns = new Function(code + '\nreturn { strLength, map }')()
1473
+ expect(fns.map(['hello', 'hi'])).toEqual([5, 2])
1474
+ })
1475
+
1476
+ it('returns ONE MonadicError when a typed callback has the wrong return shape', () => {
1477
+ // badFn declares (string) => boolean — counter expects (string) => integer.
1478
+ const { code } = tjs(`function strLength(s: ''): 0 { return s.length }
1479
+ function badFn(s: ''): true { return true }
1480
+ function map(arr: [''], counter = strLength): [0] { return arr.map(counter) }`)
1481
+ const fns = new Function(code + '\nreturn { badFn, map }')()
1482
+ const r = fns.map(['hi', 'world'], fns.badFn)
1483
+ expect(r).toBeInstanceOf(MonadicError)
1484
+ expect(r.expected).toBe('integer')
1485
+ expect(r.path).toContain('map.counter(return)')
1486
+ })
1487
+
1488
+ it('passes untyped arrows through unchanged (no per-call wrapping)', () => {
1489
+ // x => false has no __tjs metadata. checkFnShape sees no metadata
1490
+ // and returns the function unchanged. The body runs unmolested
1491
+ // and returns whatever the body returns. The user's mental model:
1492
+ // "if I pass an untyped function, you trust it".
1493
+ const { code } = tjs(`function strLength(s: ''): 0 { return s.length }
1494
+ function map(arr: [''], counter = strLength): [0] { return arr.map(counter) }`)
1495
+ const fns = new Function(code + '\nreturn { map }')()
1496
+ // (x) => false returns boolean for each call. Result is array of
1497
+ // booleans, NOT array of MonadicErrors. The outer return doesn't
1498
+ // validate items by default (no :?).
1499
+ const r = fns.map(['hi', 'world'], (x: string) => false)
1500
+ expect(Array.isArray(r)).toBe(true)
1501
+ expect(r).toEqual([false, false])
1502
+ })
1503
+
1504
+ it('does not emit checkFnShape when shape is empty (all-any)', () => {
1505
+ // `(x) => x` infers params=[{x: any}], returns: any
1506
+ // → nothing useful to check → emitter omits the call
1507
+ const { code: code3 } = tjs(`function h(fn = (x) => x): 0 { return 0 }`)
1508
+ expect(code3).not.toContain('__tjs.checkFnShape')
1509
+ })
1510
+
1511
+ it('emits checkFnShape when only the return type is known', () => {
1512
+ // `() => 5` infers no params, returns: integer
1513
+ const { code } = tjs(`function k(make = () => 5): 0 { return make() }`)
1514
+ expect(code).toContain('__tjs.checkFnShape')
1515
+ })
1516
+
1517
+ it('array param with embedded MonadicError propagates the first error', () => {
1518
+ // The "errors propagate, not accumulate" rule: when an array
1519
+ // input contains a MonadicError, the receiving function emits
1520
+ // that error directly instead of saying "expected array, got X".
1521
+ const { code } = tjs(`function first(s: ['hi']): 'hi' { return s[0] }`)
1522
+ const fns = new Function(code + '\nreturn { first }')()
1523
+ const fakeError = Object.assign(new Error('preexisting'), {
1524
+ name: 'MonadicError',
1525
+ path: 'somewhere.x',
1526
+ expected: 'integer',
1527
+ actual: 'string',
1528
+ })
1529
+ const r = fns.first([fakeError, 'world'])
1530
+ expect(r).toBeInstanceOf(Error)
1531
+ expect(r.path).toBe('somewhere.x') // the propagated error, not a new one
1532
+ expect(r.message).toBe('preexisting')
1533
+ })
1534
+
1535
+ it('module-level errors do not attribute to function declaration line', () => {
1536
+ // Reported case: an error in top-level imperative code (here `x` is
1537
+ // undefined) caused signature tests to fail with `line: 1` (the
1538
+ // function declaration's line), so the editor marked line 1 instead
1539
+ // of letting the user find the actual error on line 5.
1540
+ const { testResults } = tjs(
1541
+ `function f(s: ''): 0 { return s.length }\n\nconsole.log(x)`,
1542
+ { runTests: 'report' }
1543
+ )
1544
+ const sig = testResults?.find((t) => t.isSignatureTest)
1545
+ expect(sig).toBeDefined()
1546
+ expect(sig?.passed).toBe(false)
1547
+ expect(sig?.error).toContain('Module execution failed')
1548
+ // Critical: no line attribution → editor won't mark a misleading line
1549
+ expect(sig?.line).toBeUndefined()
1550
+ })
1551
+
1552
+ it("user's reported case: x => false passes through cleanly", () => {
1553
+ // The original frustration: `mapStrings(['hi'], x => false)` was
1554
+ // producing array of MonadicErrors. Now untyped arrows pass
1555
+ // through and the function body runs unmolested.
1556
+ const { code } = tjs(`function strLength(s: 'hello'): 5 {
1557
+ return s.length
1558
+ }
1559
+ function mapStrings(s: ['hello', 'foo'], counter = strLength): [5, 3] {
1560
+ return s.map(counter)
1561
+ }`)
1562
+ const fns = new Function(code + '\nreturn { mapStrings }')()
1563
+ const r = fns.mapStrings(['hello', 'world'], (x: string) => false)
1564
+ // Untyped arrow → no checks → array of booleans (no error pollution)
1565
+ expect(r).toEqual([false, false])
1566
+ expect(r.every((v: any) => v instanceof Error)).toBe(false)
1567
+ })
1568
+
1569
+ it("propagates a referenced function's signature (cross-ref)", () => {
1570
+ // `counter = strLength` should give `counter` strLength's signature
1571
+ // `(s: '') => 0`, even though the AST default is just an Identifier.
1572
+ const src = `function strLength(s: ''): 0 { return s.length }
1573
+ function map(arr: [''], counter = strLength): [0] { return arr.map(counter) }`
1574
+ const r = tjs(src)
1575
+ const counterType = r.types?.map?.params?.counter?.type
1576
+ expect(counterType?.kind).toBe('function')
1577
+ expect(counterType?.params).toEqual([
1578
+ { name: 's', type: { kind: 'string' } },
1579
+ ])
1580
+ expect(counterType?.returns).toEqual({ kind: 'integer' })
1581
+
1582
+ // checkFnShape should be emitted (signature is non-trivial)
1583
+ expect(r.code).toContain('__tjs.checkFnShape')
1584
+ })
1585
+ })
1449
1586
  })
1450
1587
 
1451
1588
  describe('error vs valid value distinction', () => {