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.
- package/CLAUDE.md +90 -33
- package/bin/docs.js +4 -1
- package/demo/docs.json +45 -11
- package/demo/src/examples.test.ts +1 -0
- package/demo/src/imports.test.ts +16 -4
- package/demo/src/imports.ts +60 -15
- package/demo/src/playground-shared.ts +9 -8
- package/demo/src/tfs-worker.js +205 -147
- package/demo/src/tjs-playground.ts +34 -10
- package/demo/src/ts-playground.ts +24 -8
- package/dist/index.js +118 -101
- package/dist/index.js.map +4 -4
- package/dist/src/lang/bool-coercion.d.ts +50 -0
- package/dist/src/lang/docs.d.ts +31 -6
- package/dist/src/lang/linter.d.ts +8 -0
- package/dist/src/lang/parser-transforms.d.ts +18 -0
- package/dist/src/lang/parser-types.d.ts +2 -0
- package/dist/src/lang/parser.d.ts +3 -0
- package/dist/src/lang/runtime.d.ts +34 -0
- package/dist/src/lang/types.d.ts +9 -1
- package/dist/src/rbac/index.d.ts +1 -1
- package/dist/src/vm/runtime.d.ts +1 -1
- package/dist/tjs-eval.js +38 -36
- package/dist/tjs-eval.js.map +4 -4
- package/dist/tjs-from-ts.js +20 -20
- package/dist/tjs-from-ts.js.map +3 -3
- package/dist/tjs-lang.js +85 -83
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-vm.js +47 -45
- package/dist/tjs-vm.js.map +4 -4
- package/llms.txt +79 -0
- package/package.json +3 -2
- package/src/cli/commands/convert.test.ts +16 -21
- package/src/lang/bool-coercion.test.ts +203 -0
- package/src/lang/bool-coercion.ts +314 -0
- package/src/lang/codegen.test.ts +137 -0
- package/src/lang/docs.test.ts +328 -1
- package/src/lang/docs.ts +424 -24
- package/src/lang/emitters/ast.ts +11 -12
- package/src/lang/emitters/dts.test.ts +41 -0
- package/src/lang/emitters/dts.ts +9 -0
- package/src/lang/emitters/js-tests.ts +9 -4
- package/src/lang/emitters/js.ts +182 -2
- package/src/lang/inference.ts +54 -0
- package/src/lang/linter.test.ts +104 -1
- package/src/lang/linter.ts +124 -1
- package/src/lang/parser-params.ts +31 -0
- package/src/lang/parser-transforms.ts +304 -0
- package/src/lang/parser-types.ts +2 -0
- package/src/lang/parser.test.ts +73 -1
- package/src/lang/parser.ts +34 -1
- package/src/lang/runtime.ts +98 -0
- package/src/lang/types.ts +6 -0
- package/src/rbac/index.ts +2 -2
- package/src/rbac/rules.tjs.d.ts +9 -0
- package/src/vm/atoms/batteries.ts +2 -2
- package/src/vm/runtime.ts +10 -3
- package/dist/src/rbac/rules.d.ts +0 -184
- 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
|
+
}
|
package/src/lang/codegen.test.ts
CHANGED
|
@@ -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', () => {
|