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
@@ -3595,3 +3595,307 @@ function findMatchingOpen(
3595
3595
  }
3596
3596
  return pos
3597
3597
  }
3598
+
3599
+ /**
3600
+ * Transform `let x: <example>` and `let x: <example> = value` declarations.
3601
+ *
3602
+ * Strips the `: <example>` annotation so Acorn can parse, and records the
3603
+ * variable name + example text so the linter and (later) type inference can
3604
+ * use the annotation. Acorn rejects the colon since it is not valid JS.
3605
+ *
3606
+ * let x: '' → let x (annotation: x → '')
3607
+ * let x: 0 = 5 → let x = 5 (annotation: x → 0)
3608
+ * let result: { ok: false } = ... (annotation: result → { ok: false })
3609
+ *
3610
+ * Only `let` is processed. `const` always has an initializer, so the type
3611
+ * is always inferable. `var` is rejected by TjsNoVar mode.
3612
+ */
3613
+ export function transformLetTypeAnnotations(source: string): {
3614
+ source: string
3615
+ annotations: Map<string, string>
3616
+ } {
3617
+ const annotations = new Map<string, string>()
3618
+ if (!source.includes('let ')) return { source, annotations }
3619
+
3620
+ type Replacement = { start: number; end: number; replacement: string }
3621
+ const replacements: Replacement[] = []
3622
+
3623
+ let i = 0
3624
+ let state: TokenizerState = 'normal'
3625
+ const templateStack: number[] = []
3626
+
3627
+ while (i < source.length) {
3628
+ const char = source[i]
3629
+ const nextChar = source[i + 1]
3630
+
3631
+ switch (state) {
3632
+ case 'single-string':
3633
+ if (char === '\\' && i + 1 < source.length) {
3634
+ i += 2
3635
+ continue
3636
+ }
3637
+ if (char === "'") state = 'normal'
3638
+ i++
3639
+ continue
3640
+ case 'double-string':
3641
+ if (char === '\\' && i + 1 < source.length) {
3642
+ i += 2
3643
+ continue
3644
+ }
3645
+ if (char === '"') state = 'normal'
3646
+ i++
3647
+ continue
3648
+ case 'template-string':
3649
+ if (char === '\\' && i + 1 < source.length) {
3650
+ i += 2
3651
+ continue
3652
+ }
3653
+ if (char === '$' && nextChar === '{') {
3654
+ i += 2
3655
+ templateStack.push(1)
3656
+ state = 'normal'
3657
+ continue
3658
+ }
3659
+ if (char === '`') state = 'normal'
3660
+ i++
3661
+ continue
3662
+ case 'line-comment':
3663
+ if (char === '\n') state = 'normal'
3664
+ i++
3665
+ continue
3666
+ case 'block-comment':
3667
+ if (char === '*' && nextChar === '/') {
3668
+ i += 2
3669
+ state = 'normal'
3670
+ continue
3671
+ }
3672
+ i++
3673
+ continue
3674
+ case 'regex':
3675
+ if (char === '\\' && i + 1 < source.length) {
3676
+ i += 2
3677
+ continue
3678
+ }
3679
+ if (char === '[') {
3680
+ i++
3681
+ while (i < source.length && source[i] !== ']') {
3682
+ if (source[i] === '\\' && i + 1 < source.length) i += 2
3683
+ else i++
3684
+ }
3685
+ if (i < source.length) i++
3686
+ continue
3687
+ }
3688
+ if (char === '/') {
3689
+ i++
3690
+ while (i < source.length && /[gimsuy]/.test(source[i])) i++
3691
+ state = 'normal'
3692
+ continue
3693
+ }
3694
+ i++
3695
+ continue
3696
+ case 'normal':
3697
+ if (templateStack.length > 0) {
3698
+ if (char === '{') {
3699
+ templateStack[templateStack.length - 1]++
3700
+ } else if (char === '}') {
3701
+ templateStack[templateStack.length - 1]--
3702
+ if (templateStack[templateStack.length - 1] === 0) {
3703
+ templateStack.pop()
3704
+ i++
3705
+ state = 'template-string'
3706
+ continue
3707
+ }
3708
+ }
3709
+ }
3710
+ if (char === "'") {
3711
+ i++
3712
+ state = 'single-string'
3713
+ continue
3714
+ }
3715
+ if (char === '"') {
3716
+ i++
3717
+ state = 'double-string'
3718
+ continue
3719
+ }
3720
+ if (char === '`') {
3721
+ i++
3722
+ state = 'template-string'
3723
+ continue
3724
+ }
3725
+ if (char === '/' && nextChar === '/') {
3726
+ i += 2
3727
+ state = 'line-comment'
3728
+ continue
3729
+ }
3730
+ if (char === '/' && nextChar === '*') {
3731
+ i += 2
3732
+ state = 'block-comment'
3733
+ continue
3734
+ }
3735
+ if (char === '/') {
3736
+ let j = i - 1
3737
+ while (j >= 0 && /\s/.test(source[j])) j--
3738
+ const beforeChar = j >= 0 ? source[j] : ''
3739
+ const isRegexContext =
3740
+ !beforeChar ||
3741
+ /[=(!,;:{[&|?+\-*%<>~^]/.test(beforeChar) ||
3742
+ (j >= 5 &&
3743
+ /\b(return|case|throw|in|of|typeof|instanceof|new|delete|void)$/.test(
3744
+ source.slice(Math.max(0, j - 10), j + 1)
3745
+ ))
3746
+ if (isRegexContext) {
3747
+ i++
3748
+ state = 'regex'
3749
+ continue
3750
+ }
3751
+ }
3752
+
3753
+ // Detect `let <ident> :` at top-level normal state
3754
+ if (
3755
+ char === 'l' &&
3756
+ source.slice(i, i + 4) === 'let ' &&
3757
+ (i === 0 || !/[\w$]/.test(source[i - 1]))
3758
+ ) {
3759
+ // Skip past `let` and whitespace
3760
+ let j = i + 4
3761
+ while (j < source.length && /\s/.test(source[j])) j++
3762
+ // Match identifier
3763
+ if (j < source.length && /[a-zA-Z_$]/.test(source[j])) {
3764
+ const nameStart = j
3765
+ while (j < source.length && /[\w$]/.test(source[j])) j++
3766
+ const nameEnd = j
3767
+ const varName = source.slice(nameStart, nameEnd)
3768
+ // Skip whitespace; require a `:` (not `::` or part of `?:`)
3769
+ let k = j
3770
+ while (k < source.length && /\s/.test(source[k])) k++
3771
+ if (
3772
+ k < source.length &&
3773
+ source[k] === ':' &&
3774
+ source[k + 1] !== ':'
3775
+ ) {
3776
+ const colonPos = k
3777
+ // Skip whitespace after colon
3778
+ let exStart = colonPos + 1
3779
+ while (exStart < source.length && /[ \t]/.test(source[exStart])) {
3780
+ exStart++
3781
+ }
3782
+ // Scan example expression until `=`, `,`, `;`, or newline at depth 0
3783
+ const exEnd = scanExampleEnd(source, exStart)
3784
+ if (exEnd > exStart) {
3785
+ const example = source.slice(exStart, exEnd).trim()
3786
+ annotations.set(varName, example)
3787
+ replacements.push({
3788
+ start: nameEnd,
3789
+ end: exEnd,
3790
+ replacement: '',
3791
+ })
3792
+ i = exEnd
3793
+ continue
3794
+ }
3795
+ }
3796
+ }
3797
+ }
3798
+ break
3799
+ }
3800
+ i++
3801
+ }
3802
+
3803
+ if (replacements.length === 0) return { source, annotations }
3804
+
3805
+ // Apply right-to-left to preserve positions
3806
+ let result = source
3807
+ for (let k = replacements.length - 1; k >= 0; k--) {
3808
+ const r = replacements[k]
3809
+ result = result.slice(0, r.start) + r.replacement + result.slice(r.end)
3810
+ }
3811
+ return { source: result, annotations }
3812
+ }
3813
+
3814
+ /**
3815
+ * Scan forward from `start` and return the position where the example
3816
+ * expression ends. Stops at `=`, `,`, `;`, or a newline when paren/brace/
3817
+ * bracket depth is 0. Skips through nested brackets, strings, and templates.
3818
+ */
3819
+ function scanExampleEnd(source: string, start: number): number {
3820
+ let i = start
3821
+ let parens = 0
3822
+ let braces = 0
3823
+ let brackets = 0
3824
+ let state: 'normal' | 'sq' | 'dq' | 'tpl' = 'normal'
3825
+ const templateStack: number[] = []
3826
+ while (i < source.length) {
3827
+ const c = source[i]
3828
+ if (state === 'sq') {
3829
+ if (c === '\\') {
3830
+ i += 2
3831
+ continue
3832
+ }
3833
+ if (c === "'") state = 'normal'
3834
+ i++
3835
+ continue
3836
+ }
3837
+ if (state === 'dq') {
3838
+ if (c === '\\') {
3839
+ i += 2
3840
+ continue
3841
+ }
3842
+ if (c === '"') state = 'normal'
3843
+ i++
3844
+ continue
3845
+ }
3846
+ if (state === 'tpl') {
3847
+ if (c === '\\') {
3848
+ i += 2
3849
+ continue
3850
+ }
3851
+ if (c === '$' && source[i + 1] === '{') {
3852
+ templateStack.push(1)
3853
+ state = 'normal'
3854
+ i += 2
3855
+ continue
3856
+ }
3857
+ if (c === '`') state = 'normal'
3858
+ i++
3859
+ continue
3860
+ }
3861
+ // normal
3862
+ if (templateStack.length > 0) {
3863
+ if (c === '{') templateStack[templateStack.length - 1]++
3864
+ else if (c === '}') {
3865
+ templateStack[templateStack.length - 1]--
3866
+ if (templateStack[templateStack.length - 1] === 0) {
3867
+ templateStack.pop()
3868
+ state = 'tpl'
3869
+ i++
3870
+ continue
3871
+ }
3872
+ }
3873
+ }
3874
+ if (c === "'") {
3875
+ state = 'sq'
3876
+ i++
3877
+ continue
3878
+ }
3879
+ if (c === '"') {
3880
+ state = 'dq'
3881
+ i++
3882
+ continue
3883
+ }
3884
+ if (c === '`') {
3885
+ state = 'tpl'
3886
+ i++
3887
+ continue
3888
+ }
3889
+ if (c === '(') parens++
3890
+ else if (c === ')') parens--
3891
+ else if (c === '{') braces++
3892
+ else if (c === '}') braces--
3893
+ else if (c === '[') brackets++
3894
+ else if (c === ']') brackets--
3895
+ if (parens === 0 && braces === 0 && brackets === 0) {
3896
+ if (c === '=' || c === ',' || c === ';' || c === '\n') return i
3897
+ }
3898
+ i++
3899
+ }
3900
+ return i
3901
+ }
@@ -144,6 +144,8 @@ export interface TjsModes {
144
144
  tjsSafeEval: boolean
145
145
  /** TjsNoVar: var declarations are syntax errors */
146
146
  tjsNoVar: boolean
147
+ /** TjsSafeAssign: let declarations need an initializer or `: example` annotation; literal undefined/null/void 0 assigned to typed lets is flagged */
148
+ tjsSafeAssign: boolean
147
149
  }
148
150
 
149
151
  /**
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'bun:test'
2
2
  import { transpile, ajs, tjs } from './index'
3
- import { preprocess } from './parser'
3
+ import { preprocess, parse } from './parser'
4
4
  import { createRuntime, isMonadicError } from './runtime'
5
5
 
6
6
  describe('Transpiler', () => {
@@ -1067,4 +1067,76 @@ test 'always fails' { throw new Error('intentional') }
1067
1067
  expect(varSetStep.value.op).toBe('??')
1068
1068
  })
1069
1069
  })
1070
+
1071
+ describe('stray `=>` on function declarations', () => {
1072
+ it('errors clearly on `function f(...): T => { body }`', () => {
1073
+ // A common mistake — writing arrow-function syntax on a regular
1074
+ // function declaration. Without the dedicated check, the `=>` falls
1075
+ // through to Acorn which complains at a misleading position.
1076
+ expect(() =>
1077
+ tjs(`function f(s: '', n = 0): 0 => {
1078
+ return s.length + n
1079
+ }`)
1080
+ ).toThrow(/Unexpected '=>' after function declaration/)
1081
+ })
1082
+
1083
+ it('errors with column pointing at the `=>` (not earlier in the line)', () => {
1084
+ let caught: any
1085
+ try {
1086
+ tjs(`function f(): 0 => { return 0 }`)
1087
+ } catch (e) {
1088
+ caught = e
1089
+ }
1090
+ expect(caught).toBeDefined()
1091
+ // The `=>` is at column 17 (1-indexed): `function f(): 0 ` is 16 chars
1092
+ expect(caught.column).toBe(16)
1093
+ })
1094
+
1095
+ it('does not error on regular function declarations', () => {
1096
+ expect(() =>
1097
+ tjs(`function f(s: ''): 0 { return s.length }`)
1098
+ ).not.toThrow()
1099
+ })
1100
+
1101
+ it('does not error on real arrow function expressions', () => {
1102
+ expect(() => tjs(`const f = (s = '') => s.length`)).not.toThrow()
1103
+ })
1104
+ })
1105
+
1106
+ describe('let type annotations (TjsSafeAssign)', () => {
1107
+ it("strips `: <example>` from `let x: ''` and records annotation", () => {
1108
+ const r = parse(`let x: ''`)
1109
+ expect(r.letAnnotations.get('x')).toBe("''")
1110
+ })
1111
+
1112
+ it('strips `: <example>` from `let x: 0 = 5` and keeps the initializer', () => {
1113
+ const r = parse(`let x: 0 = 5`)
1114
+ expect(r.letAnnotations.get('x')).toBe('0')
1115
+ const decl = r.ast.body[0] as any
1116
+ expect(decl.type).toBe('VariableDeclaration')
1117
+ expect(decl.declarations[0].init).not.toBeNull()
1118
+ })
1119
+
1120
+ it('handles object-literal example with nested braces', () => {
1121
+ const r = parse(
1122
+ `function f() { let result: { ok: false }; return result }`
1123
+ )
1124
+ expect(r.letAnnotations.get('result')).toBe('{ ok: false }')
1125
+ })
1126
+
1127
+ it('does not strip `:` inside string literals', () => {
1128
+ const r = parse(`let s = 'a:b:c'`)
1129
+ expect(r.letAnnotations.size).toBe(0)
1130
+ })
1131
+
1132
+ it('TjsSafeAssign mode is on by default in native TJS', () => {
1133
+ const r = parse(`let x = 0`)
1134
+ expect(r.tjsModes.tjsSafeAssign).toBe(true)
1135
+ })
1136
+
1137
+ it('TjsCompat directive disables TjsSafeAssign', () => {
1138
+ const r = parse(`TjsCompat\nlet x = 0`)
1139
+ expect(r.tjsModes.tjsSafeAssign).toBe(false)
1140
+ })
1141
+ })
1070
1142
  })
@@ -51,6 +51,7 @@ import {
51
51
  transformConstBang,
52
52
  transformBangAccess,
53
53
  transformExtensionCalls,
54
+ transformLetTypeAnnotations,
54
55
  } from './parser-transforms'
55
56
 
56
57
  // Re-export transformExtensionCalls for js.ts
@@ -120,6 +121,7 @@ export function preprocess(
120
121
  testErrors: string[]
121
122
  polymorphicNames: Set<string>
122
123
  extensions: Map<string, Set<string>>
124
+ letAnnotations: Map<string, string>
123
125
  } {
124
126
  const originalSource = source
125
127
  let moduleSafety: 'none' | 'inputs' | 'all' | undefined
@@ -143,6 +145,7 @@ export function preprocess(
143
145
  tjsStandard: false,
144
146
  tjsSafeEval: false,
145
147
  tjsNoVar: false,
148
+ tjsSafeAssign: false,
146
149
  }
147
150
  : {
148
151
  tjsEquals: true,
@@ -152,6 +155,7 @@ export function preprocess(
152
155
  tjsStandard: true,
153
156
  tjsSafeEval: false, // opt-in only (adds import)
154
157
  tjsNoVar: true,
158
+ tjsSafeAssign: true,
155
159
  }
156
160
 
157
161
  // Safety: native TJS defaults to 'inputs' (runtime default),
@@ -180,7 +184,7 @@ export function preprocess(
180
184
  // TjsCompat disables all TJS modes (useful for native TJS opting out)
181
185
  // Individual modes: TjsEquals, TjsClass, TjsDate, TjsNoeval, TjsStandard, TjsSafeEval
182
186
  const directivePattern =
183
- /^(\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*)\s*(TjsStrict|TjsCompat|TjsEquals|TjsClass|TjsDate|TjsNoeval|TjsNoVar|TjsStandard|TjsSafeEval)\b/
187
+ /^(\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*)\s*(TjsStrict|TjsCompat|TjsEquals|TjsClass|TjsDate|TjsNoeval|TjsNoVar|TjsStandard|TjsSafeEval|TjsSafeAssign)\b/
184
188
 
185
189
  let match
186
190
  while ((match = source.match(directivePattern))) {
@@ -194,6 +198,7 @@ export function preprocess(
194
198
  tjsModes.tjsNoeval = true
195
199
  tjsModes.tjsNoVar = true
196
200
  tjsModes.tjsStandard = true
201
+ tjsModes.tjsSafeAssign = true
197
202
  } else if (directive === 'TjsCompat') {
198
203
  // Disable all TJS modes (JS-compatible)
199
204
  tjsModes.tjsEquals = false
@@ -203,6 +208,7 @@ export function preprocess(
203
208
  tjsModes.tjsNoVar = false
204
209
  tjsModes.tjsStandard = false
205
210
  tjsModes.tjsSafeEval = false
211
+ tjsModes.tjsSafeAssign = false
206
212
  } else if (directive === 'TjsEquals') {
207
213
  tjsModes.tjsEquals = true
208
214
  } else if (directive === 'TjsClass') {
@@ -217,6 +223,8 @@ export function preprocess(
217
223
  tjsModes.tjsStandard = true
218
224
  } else if (directive === 'TjsSafeEval') {
219
225
  tjsModes.tjsSafeEval = true
226
+ } else if (directive === 'TjsSafeAssign') {
227
+ tjsModes.tjsSafeAssign = true
220
228
  }
221
229
 
222
230
  // Remove the directive from source
@@ -247,6 +255,13 @@ export function preprocess(
247
255
  // Must happen before acorn parsing since !. is not valid JS
248
256
  source = transformBangAccess(source)
249
257
 
258
+ // Transform `let x: <example>` declarations: strip annotation and record
259
+ // varName -> example. Must happen before paren transforms so the colon
260
+ // is not confused with TS-style annotations on params/returns.
261
+ const letAnnoResult = transformLetTypeAnnotations(source)
262
+ source = letAnnoResult.source
263
+ const letAnnotations = letAnnoResult.annotations
264
+
250
265
  // Transform Is/IsNot infix operators to function calls
251
266
  // a Is b -> Is(a, b)
252
267
  // a IsNot b -> IsNot(a, b)
@@ -371,6 +386,7 @@ export function preprocess(
371
386
  testErrors: testResult.errors,
372
387
  polymorphicNames: polyResult.polymorphicNames,
373
388
  extensions: extResult.extensions,
389
+ letAnnotations,
374
390
  }
375
391
  }
376
392
 
@@ -392,6 +408,8 @@ export function parse(
392
408
  wasmBlocks: WasmBlock[]
393
409
  tests: TestBlock[]
394
410
  testErrors: string[]
411
+ letAnnotations: Map<string, string>
412
+ tjsModes: TjsModes
395
413
  } {
396
414
  const {
397
415
  filename = '<source>',
@@ -412,6 +430,8 @@ export function parse(
412
430
  wasmBlocks,
413
431
  tests,
414
432
  testErrors,
433
+ letAnnotations,
434
+ tjsModes,
415
435
  } = colonShorthand
416
436
  ? preprocess(source, { vmTarget })
417
437
  : {
@@ -426,6 +446,17 @@ export function parse(
426
446
  wasmBlocks: [] as WasmBlock[],
427
447
  tests: [] as TestBlock[],
428
448
  testErrors: [] as string[],
449
+ letAnnotations: new Map<string, string>(),
450
+ tjsModes: {
451
+ tjsEquals: false,
452
+ tjsClass: false,
453
+ tjsDate: false,
454
+ tjsNoeval: false,
455
+ tjsStandard: false,
456
+ tjsSafeEval: false,
457
+ tjsNoVar: false,
458
+ tjsSafeAssign: false,
459
+ } as TjsModes,
429
460
  }
430
461
 
431
462
  try {
@@ -448,6 +479,8 @@ export function parse(
448
479
  wasmBlocks,
449
480
  tests,
450
481
  testErrors,
482
+ letAnnotations,
483
+ tjsModes,
451
484
  }
452
485
  } catch (e: any) {
453
486
  // Convert Acorn error to our error type
@@ -605,6 +605,26 @@ export function TypeOf(value: unknown): string {
605
605
  return typeof value
606
606
  }
607
607
 
608
+ /**
609
+ * Honest boolean coercion. Like `Boolean(x)` but unwraps boxed primitives
610
+ * first, fixing the JS footgun `Boolean(new Boolean(false)) === true`.
611
+ *
612
+ * Under TjsStandard, the source rewriter wraps every truthiness context
613
+ * (if/while/for/do-while conditions, `!`, `&&`, `||`, ternary, and
614
+ * top-level `Boolean(x)` calls) with this function so a boxed `false`
615
+ * actually behaves as `false`.
616
+ */
617
+ export function toBool(value: unknown): boolean {
618
+ if (
619
+ value instanceof Boolean ||
620
+ value instanceof Number ||
621
+ value instanceof String
622
+ ) {
623
+ return Boolean((value as any).valueOf())
624
+ }
625
+ return Boolean(value)
626
+ }
627
+
608
628
  export function Eq(a: unknown, b: unknown): boolean {
609
629
  // Unwrap boxed primitives
610
630
  if (a instanceof String || a instanceof Number || a instanceof Boolean) {
@@ -844,6 +864,78 @@ type TypeSpec =
844
864
  | string
845
865
  | { check: (v: unknown) => boolean | string; description: string }
846
866
 
867
+ /**
868
+ * Check that a passed-in function's declared shape matches the expected
869
+ * shape. Returns the function unchanged on a match, or a MonadicError on
870
+ * mismatch. Untyped functions (no `__tjs` metadata — anonymous arrows
871
+ * like `x => false`) pass through unchanged on the assumption that the
872
+ * caller knows what they're doing; they accept any args and return
873
+ * whatever they return.
874
+ *
875
+ * This is a ONE-SHOT check at pass time, NOT a per-call wrapper. The TJS
876
+ * design call: a wrong-shape callback is ONE error at the boundary, not
877
+ * N errors when the receiving function invokes the callback N times.
878
+ *
879
+ * Compatibility rules (deliberately permissive — strict subtyping is a
880
+ * separate, larger feature):
881
+ * - For each expected param: the actual function may declare fewer
882
+ * params (extras simply not used). If both declare a kind, they
883
+ * must match exactly. Either side being `any` always matches.
884
+ * - For the return type: same exact-match rule when both are known.
885
+ */
886
+ export function checkFnShape(
887
+ fn: unknown,
888
+ expectedParamKinds: string[],
889
+ expectedReturnKind: string,
890
+ path: string
891
+ ): unknown {
892
+ if (typeof fn !== 'function') return fn // outer "is callable" check already ran
893
+ const meta = (fn as any).__tjs
894
+ if (!meta || !meta.params) return fn // untyped — let it run
895
+
896
+ const actualEntries = Object.entries(meta.params) as Array<
897
+ [string, { type?: { kind?: string } }]
898
+ >
899
+ for (let i = 0; i < expectedParamKinds.length; i++) {
900
+ const expectedKind = expectedParamKinds[i]
901
+ if (expectedKind === 'any') continue
902
+ const actual = actualEntries[i]
903
+ if (!actual) continue // function takes fewer params, OK
904
+ const actualKind = actual[1]?.type?.kind
905
+ if (!actualKind || actualKind === 'any') continue
906
+ if (actualKind !== expectedKind) {
907
+ return new MonadicError(
908
+ `Expected (...arg${i}: ${expectedKind}, ...) for '${path}', ` +
909
+ `but callback declares arg${i} as ${actualKind}`,
910
+ `${path}(arg${i})`,
911
+ expectedKind,
912
+ actualKind
913
+ )
914
+ }
915
+ }
916
+
917
+ if (expectedReturnKind !== 'any' && meta.returns) {
918
+ // Metadata's `returns` is `{ type: TypeDescriptor, defaults?: ... }`,
919
+ // but defensively also accept a bare TypeDescriptor.
920
+ const actualReturnKind = meta.returns.type?.kind ?? meta.returns.kind
921
+ if (
922
+ actualReturnKind &&
923
+ actualReturnKind !== 'any' &&
924
+ actualReturnKind !== expectedReturnKind
925
+ ) {
926
+ return new MonadicError(
927
+ `Expected callback returning ${expectedReturnKind} for '${path}', ` +
928
+ `but callback returns ${actualReturnKind}`,
929
+ `${path}(return)`,
930
+ expectedReturnKind,
931
+ actualReturnKind
932
+ )
933
+ }
934
+ }
935
+
936
+ return fn
937
+ }
938
+
847
939
  /** Parameter metadata with optional location */
848
940
  interface ParamMeta {
849
941
  type: TypeSpec
@@ -1478,6 +1570,7 @@ export function createRuntime() {
1478
1570
  checkType,
1479
1571
  validateArgs,
1480
1572
  wrap,
1573
+ checkFnShape,
1481
1574
  wrapClass,
1482
1575
  compareVersions,
1483
1576
  versionsCompatible,
@@ -1528,6 +1621,8 @@ export function createRuntime() {
1528
1621
  NotEq,
1529
1622
  // Honest typeof (typeof with TjsEquals)
1530
1623
  TypeOf,
1624
+ // Honest truthiness (unwraps boxed primitives)
1625
+ toBool,
1531
1626
  tjsEquals,
1532
1627
  // Extensions
1533
1628
  registerExtension: instanceRegisterExtension,
@@ -1559,6 +1654,7 @@ export const runtime = {
1559
1654
  checkType,
1560
1655
  validateArgs,
1561
1656
  wrap,
1657
+ checkFnShape,
1562
1658
  wrapClass,
1563
1659
  compareVersions,
1564
1660
  versionsCompatible,
@@ -1612,6 +1708,8 @@ export const runtime = {
1612
1708
  NotEq,
1613
1709
  // Honest typeof (used by typeof with TjsEquals)
1614
1710
  TypeOf,
1711
+ // Honest truthiness (used in TjsStandard for boxed-primitive coercion)
1712
+ toBool,
1615
1713
  }
1616
1714
 
1617
1715
  /**
package/src/lang/types.ts CHANGED
@@ -22,6 +22,7 @@ export interface TypeDescriptor {
22
22
  | 'array'
23
23
  | 'object'
24
24
  | 'union'
25
+ | 'function'
25
26
  | 'any'
26
27
  nullable?: boolean
27
28
  /** For arrays: the element type */
@@ -32,6 +33,11 @@ export interface TypeDescriptor {
32
33
  members?: TypeDescriptor[]
33
34
  /** For destructured parameters: full parameter descriptors */
34
35
  destructuredParams?: Record<string, ParameterDescriptor>
36
+ /** For functions: declared parameters with names and inferred types */
37
+ params?: Array<{ name: string; type: TypeDescriptor }>
38
+ /** For functions: inferred return type. Concise arrow bodies infer from
39
+ * the expression; block bodies and complex expressions stay `any`. */
40
+ returns?: TypeDescriptor
35
41
  }
36
42
 
37
43
  /** Describes a function parameter */