tjs-lang 0.8.7 → 0.9.1

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 (85) hide show
  1. package/CLAUDE.md +19 -5
  2. package/dist/experiments/ambient/probe.d.ts +52 -0
  3. package/dist/index.js +110 -162
  4. package/dist/index.js.map +4 -4
  5. package/dist/scripts/build-editors.d.ts +19 -0
  6. package/dist/src/css/dimensions.d.ts +23 -0
  7. package/dist/src/css/index.d.ts +85 -0
  8. package/dist/src/css/predicates.d.ts +38 -0
  9. package/dist/src/css/shorthands.d.ts +31 -0
  10. package/dist/src/css/style.d.ts +48 -0
  11. package/dist/src/lang/emitters/js-wasm.d.ts +2 -0
  12. package/dist/src/lang/emitters/js.d.ts +9 -1
  13. package/dist/src/lang/index.d.ts +2 -2
  14. package/dist/src/lang/parser-transforms.d.ts +9 -5
  15. package/dist/src/lang/parser-types.d.ts +7 -0
  16. package/dist/src/lang/parser.d.ts +2 -0
  17. package/dist/src/lang/predicate.d.ts +60 -0
  18. package/dist/src/lang/transpiler.d.ts +3 -2
  19. package/dist/src/lang/types.d.ts +16 -0
  20. package/dist/src/schema/index.d.ts +12 -0
  21. package/dist/tjs-browser-from-ts.js +20 -20
  22. package/dist/tjs-browser-from-ts.js.map +3 -3
  23. package/dist/tjs-browser.js +108 -94
  24. package/dist/tjs-browser.js.map +4 -4
  25. package/dist/tjs-css.js +193 -0
  26. package/dist/tjs-css.js.map +7 -0
  27. package/dist/tjs-eval.js +43 -42
  28. package/dist/tjs-eval.js.map +4 -4
  29. package/dist/tjs-from-ts.js +13 -13
  30. package/dist/tjs-from-ts.js.map +2 -2
  31. package/dist/tjs-lang.js +106 -92
  32. package/dist/tjs-lang.js.map +4 -4
  33. package/dist/tjs-runtime.js +10 -0
  34. package/dist/tjs-runtime.js.map +7 -0
  35. package/dist/tjs-schema.js +6 -0
  36. package/dist/tjs-schema.js.map +7 -0
  37. package/dist/tjs-vm.js +55 -54
  38. package/dist/tjs-vm.js.map +4 -4
  39. package/docs/ambient-contracts.md +261 -0
  40. package/docs/type-system-north-star.md +100 -0
  41. package/editors/ace/ajs-mode.js +214 -233
  42. package/editors/codemirror/ajs-language.js +1570 -233
  43. package/editors/editors-build.test.ts +29 -0
  44. package/editors/monaco/ajs-monarch.js +239 -195
  45. package/llms.txt +4 -0
  46. package/package.json +35 -4
  47. package/src/css/css.test.ts +122 -0
  48. package/src/css/dimensions.test.ts +112 -0
  49. package/src/css/dimensions.ts +146 -0
  50. package/src/css/index.ts +243 -0
  51. package/src/css/perf.bench.test.ts +109 -0
  52. package/src/css/predicates.ts +232 -0
  53. package/src/css/property-aware.test.ts +84 -0
  54. package/src/css/shorthands.test.ts +90 -0
  55. package/src/css/shorthands.ts +113 -0
  56. package/src/css/style.test.ts +125 -0
  57. package/src/css/style.ts +134 -0
  58. package/src/index-tsfree.test.ts +58 -0
  59. package/src/lang/emit-verified-predicate.test.ts +95 -0
  60. package/src/lang/emitters/dts.test.ts +31 -0
  61. package/src/lang/emitters/dts.ts +23 -4
  62. package/src/lang/emitters/js-wasm.ts +8 -4
  63. package/src/lang/emitters/js.ts +58 -1
  64. package/src/lang/from-ts.test.ts +1 -1
  65. package/src/lang/generic-verified-predicate.test.ts +39 -0
  66. package/src/lang/index.ts +14 -5
  67. package/src/lang/parser-transforms.ts +113 -15
  68. package/src/lang/parser-types.ts +7 -0
  69. package/src/lang/parser.ts +18 -3
  70. package/src/lang/predicate-evaluator.test.ts +49 -0
  71. package/src/lang/predicate-report.test.ts +78 -0
  72. package/src/lang/predicate.ts +268 -0
  73. package/src/lang/redos-lint.test.ts +81 -0
  74. package/src/lang/transpiler.ts +13 -0
  75. package/src/lang/type-verified-predicate.test.ts +83 -0
  76. package/src/lang/types.ts +17 -0
  77. package/src/lang/typescript-syntax.test.ts +2 -1
  78. package/src/lang/wasm-fallback-warning.test.ts +50 -0
  79. package/src/lang/wasm-intdiv-lint.test.ts +45 -0
  80. package/src/lang/wasm-ready.test.ts +94 -0
  81. package/src/lang/wasm-simd-ops.test.ts +84 -0
  82. package/src/lang/wasm.ts +61 -2
  83. package/src/schema/index.ts +62 -0
  84. package/src/schema/schema.test.ts +66 -0
  85. package/src/use-cases/bootstrap.test.ts +14 -4
@@ -67,6 +67,16 @@ const PURE_GLOBALS = new Set([
67
67
  'Boolean',
68
68
  'Array',
69
69
  'Object',
70
+ // TJS-injected pure helpers. Native-TJS predicate bodies are rewritten before
71
+ // they reach the verifier: `==`/`!=` → `Eq`/`NotEq`, the explicit `Is`/`IsNot`
72
+ // operators → `Is`/`IsNot`, `typeof x` → `TypeOf(x)`. All are pure, total
73
+ // functions (footgun-free equality / structural equality / safe typeof), so a
74
+ // predicate that uses TJS equality still verifies as predicate-safe.
75
+ 'Eq',
76
+ 'NotEq',
77
+ 'Is',
78
+ 'IsNot',
79
+ 'TypeOf',
70
80
  ])
71
81
 
72
82
  /** Namespaces whose static methods are pure (with effectful exceptions below). */
@@ -82,6 +92,101 @@ const PURE_NAMESPACES = new Set([
82
92
  /** Static members that are NOT pure even on a pure namespace. */
83
93
  const EFFECTFUL_STATICS = new Set(['Math.random', 'Date.now'])
84
94
 
95
+ /**
96
+ * A match at `pattern[pos]` for an *unbounded* quantifier (`*`, `+`, `{n,}` and
97
+ * their lazy `?` variants) — the repetitions that drive backtracking blowups.
98
+ * `?`, `{n}` and `{n,m}` are bounded, so they don't count. Returns the consumed
99
+ * length, or 0 if there's no unbounded quantifier here.
100
+ */
101
+ function unboundedQuantifierLen(pattern: string, pos: number): number {
102
+ const c = pattern[pos]
103
+ let len = 0
104
+ if (c === '*' || c === '+') {
105
+ len = 1
106
+ } else if (c === '{') {
107
+ // `{n,}` is unbounded; `{n}` / `{n,m}` are bounded.
108
+ const m = pattern.slice(pos).match(/^\{\d+,\}/)
109
+ if (m) len = m[0].length
110
+ }
111
+ if (len === 0) return 0
112
+ // A trailing `?` makes it lazy — still unbounded, still backtracks.
113
+ if (pattern[pos + len] === '?') len++
114
+ return len
115
+ }
116
+
117
+ /**
118
+ * Conservative ReDoS detector: flags a regex whose **star height is ≥ 2** — an
119
+ * unbounded quantifier nested inside a group that is itself unbounded-quantified
120
+ * (the classic `(a+)+`, `(a*)*`, `([a-z]+)*`, `(.*)*` exponential-backtracking
121
+ * shapes). A predicate verifier should fail closed: over-flagging a safe pattern
122
+ * only costs it its "verified" badge (it still runs), whereas certifying a
123
+ * dangerous one is a broken safety promise.
124
+ *
125
+ * Not caught (known limitation, documented): *polynomial* ReDoS from adjacent
126
+ * overlapping quantifiers (`\d+\d+$`, `a.*a.*a`) and alternation-overlap
127
+ * (`(a|a)*`). The exponential class above is the one the safety story commits to.
128
+ *
129
+ * @returns a reason string if risky, else null.
130
+ */
131
+ function reDoSRisk(pattern: string): string | null {
132
+ // Per-group frame: did this group contain an unbounded quantifier?
133
+ const stack: Array<{ hadUnbounded: boolean }> = []
134
+ let i = 0
135
+ let inClass = false
136
+ while (i < pattern.length) {
137
+ const c = pattern[i]
138
+ if (c === '\\') {
139
+ i += 2 // skip an escaped char (regex escapes are 2 chars here)
140
+ continue
141
+ }
142
+ if (inClass) {
143
+ if (c === ']') inClass = false
144
+ i++
145
+ continue
146
+ }
147
+ if (c === '[') {
148
+ inClass = true
149
+ i++
150
+ continue
151
+ }
152
+ if (c === '(') {
153
+ stack.push({ hadUnbounded: false })
154
+ i++
155
+ continue
156
+ }
157
+ if (c === ')') {
158
+ const frame = stack.pop() ?? { hadUnbounded: false }
159
+ const qlen = unboundedQuantifierLen(pattern, i + 1)
160
+ if (qlen > 0) {
161
+ // This group is itself unbounded-repeated. If it already contained an
162
+ // unbounded quantifier, that's star height ≥ 2 → catastrophic.
163
+ if (frame.hadUnbounded)
164
+ return 'an unbounded quantifier is nested inside another (e.g. `(a+)+`)'
165
+ // The parent group now contains an unbounded repetition (this group).
166
+ if (stack.length) stack[stack.length - 1].hadUnbounded = true
167
+ i += 1 + qlen
168
+ continue
169
+ }
170
+ // No quantifier on this group, but if it contained an unbounded
171
+ // repetition, that repetition lives in the parent's scope too — propagate
172
+ // so `((a+))+` is caught the same as `(a+)+`.
173
+ if (frame.hadUnbounded && stack.length)
174
+ stack[stack.length - 1].hadUnbounded = true
175
+ i++
176
+ continue
177
+ }
178
+ // An unbounded quantifier on a plain atom at the current nesting level.
179
+ const qlen = unboundedQuantifierLen(pattern, i)
180
+ if (qlen > 0) {
181
+ if (stack.length) stack[stack.length - 1].hadUnbounded = true
182
+ i += qlen
183
+ continue
184
+ }
185
+ i++
186
+ }
187
+ return null
188
+ }
189
+
85
190
  /**
86
191
  * Instance methods known to be pure regardless of receiver type. A method call
87
192
  * whose method name isn't here (and whose receiver isn't a pure namespace) is
@@ -255,6 +360,22 @@ export function verifyPredicate(
255
360
  ForStatement: loop,
256
361
  ForInStatement: loop,
257
362
  ForOfStatement: loop,
363
+ Literal(n: any) {
364
+ // Regex literals are the one primitive fuel can't bound: a single
365
+ // `.match`/`.test`/`.replace` is opaque to the function-entry fuel hook,
366
+ // so a catastrophic-backtracking pattern could hang on hostile input.
367
+ // Certifying it "safe" would be a false guarantee, so flag it. (Dynamic
368
+ // `RegExp(...)` is already rejected — `RegExp` isn't a pure global and
369
+ // `new` is banned — so only literals need analysis.)
370
+ if (n.regex && typeof n.regex.pattern === 'string') {
371
+ const risk = reDoSRisk(n.regex.pattern)
372
+ if (risk)
373
+ flag(
374
+ `regex /${n.regex.pattern}/ risks catastrophic backtracking (ReDoS): ${risk}. A single match is not fuel-bounded, so it can't be certified predicate-safe — simplify the pattern or validate without it.`,
375
+ n
376
+ )
377
+ }
378
+ },
258
379
  CallExpression(n: any) {
259
380
  const callee = n.callee
260
381
  // Bare call: f(...)
@@ -548,3 +669,150 @@ export function compilePredicate(
548
669
  }
549
670
  return wrapped
550
671
  }
672
+
673
+ export interface EmitPredicateResult {
674
+ /** True iff the cluster passed predicate-safety verification. */
675
+ safe: boolean
676
+ /**
677
+ * When `safe`, a self-contained JS **expression** that evaluates to the guard
678
+ * function `(...args) => boolean`. Inline it directly into transpiler output —
679
+ * it carries its own fuel counter (no global `__fuel`, no runtime dependency
680
+ * on the predicate engine or the `PredicateFuelExhausted` class). Undefined
681
+ * when `!safe`.
682
+ */
683
+ code?: string
684
+ /** Verifier diagnostics (the reasons, when `!safe`). */
685
+ diagnostics: PredicateDiagnostic[]
686
+ }
687
+
688
+ /**
689
+ * Verify a predicate cluster and, if safe, emit a **self-contained source
690
+ * expression** for its guard — the transpile-time counterpart to
691
+ * `compilePredicate` (which evals to live closures). This is what lets a
692
+ * verified `Type`/`FunctionPredicate` predicate compile to a fuel-bounded native
693
+ * guard in standalone output: no import of this module, no shared runtime.
694
+ *
695
+ * Fuel model mirrors `compilePredicate` (function-entry `__fuel()` bounds all
696
+ * iteration since loops are rejected), but because a guard answers a boolean
697
+ * question, a runaway input **returns `false`** ("not a valid instance of this
698
+ * type") instead of throwing — DoS-safe validation that never crashes the caller.
699
+ * A deep-recursion stack overflow is the same runaway signal, normalized the
700
+ * same way.
701
+ *
702
+ * The runtime effectful-global shadow that `compilePredicate` applies is omitted
703
+ * here on purpose: a `safe` cluster provably references no effectful global (the
704
+ * static verifier guarantees it), so the shadow would only bloat emitted output.
705
+ *
706
+ * @param source the predicate cluster (one or more `function` declarations)
707
+ * @param entryName which declared function is the guard entry point
708
+ * @param opts verify options + `fuel` budget (default 1,000,000)
709
+ */
710
+ export function emitVerifiedPredicate(
711
+ source: string,
712
+ entryName: string,
713
+ opts: CompilePredicateOptions = {}
714
+ ): EmitPredicateResult {
715
+ const result = verifyPredicate(source, opts)
716
+ if (!result.safe) {
717
+ return { safe: false, diagnostics: result.diagnostics }
718
+ }
719
+ if (!result.predicates.includes(entryName)) {
720
+ return {
721
+ safe: false,
722
+ diagnostics: [
723
+ {
724
+ predicate: entryName,
725
+ message: `entry predicate '${entryName}' not found in the verified cluster`,
726
+ line: 0,
727
+ column: 0,
728
+ },
729
+ ],
730
+ }
731
+ }
732
+
733
+ const budget = opts.fuel ?? 1_000_000
734
+ const instrumented = injectFuel(source)
735
+
736
+ // A self-contained IIFE: fuel counter in a closure, guard entry re-armed per
737
+ // top-level call, runaway (fuel or stack) → `false`.
738
+ const code =
739
+ `(() => {` +
740
+ `let __f = 0;` +
741
+ `const __fuel = () => { if (--__f < 0) throw new RangeError('tjs:predicate-fuel'); };` +
742
+ `${instrumented}` +
743
+ `return (...__a) => {` +
744
+ `__f = ${budget};` +
745
+ `try { return !!${entryName}(...__a); }` +
746
+ `catch (e) {` +
747
+ `if (e instanceof RangeError && /tjs:predicate-fuel|stack/i.test(e.message)) return false;` +
748
+ `throw e;` +
749
+ `}` +
750
+ `};` +
751
+ `})()`
752
+
753
+ return { safe: true, code, diagnostics: [] }
754
+ }
755
+
756
+ // --- $predicate evaluator (#6: the tosijs-schema integration point) ---------
757
+
758
+ export interface PredicateEvaluatorOptions extends CompilePredicateOptions {
759
+ /**
760
+ * Called once per source that fails to verify/compile. Default: `console.warn`.
761
+ * The evaluator fails **closed** on such a source (returns `false`), so an
762
+ * unverifiable `$predicate` can never validate a value as `true`.
763
+ */
764
+ onUnsafe?: (source: string, error: Error) => void
765
+ }
766
+
767
+ /**
768
+ * Build a pluggable `$predicate` evaluator: `(source, value) => boolean`.
769
+ *
770
+ * This is the bridge for a predicate-aware JSON-Schema validator that lives in
771
+ * another package (e.g. `tosijs-schema`, which cannot depend on `tjs-lang` — the
772
+ * dependency runs the other way). Such a validator stays zero-dep and exposes a
773
+ * hook; a consumer that has this engine injects an evaluator built here.
774
+ *
775
+ * Each distinct source is verified + compiled **once** and cached, so repeated
776
+ * validation is just a native call. Semantics match `compilePredicateSchema`:
777
+ * the LAST top-level function is the entry; a source that isn't predicate-safe
778
+ * (or throws / exhausts fuel at runtime) yields `false` — never a thrown error
779
+ * mid-validation, never a silent pass.
780
+ */
781
+ export function createPredicateEvaluator(
782
+ opts: PredicateEvaluatorOptions = {}
783
+ ): (source: string, value: unknown) => boolean {
784
+ const { onUnsafe, ...compileOpts } = opts
785
+ const cache = new Map<string, ((value: unknown) => boolean) | null>()
786
+ const warned = new Set<string>()
787
+
788
+ return (source: string, value: unknown): boolean => {
789
+ let fn = cache.get(source)
790
+ if (fn === undefined) {
791
+ try {
792
+ const names = verifyPredicate(source, compileOpts).predicates
793
+ const entry = names[names.length - 1]
794
+ if (!entry) throw new Error('$predicate declares no function')
795
+ const mod = compilePredicate(source, [entry], compileOpts)
796
+ fn = mod[entry] as (value: unknown) => boolean
797
+ } catch (e) {
798
+ fn = null // fail closed
799
+ if (!warned.has(source)) {
800
+ warned.add(source)
801
+ const err = e instanceof Error ? e : new Error(String(e))
802
+ if (onUnsafe) onUnsafe(source, err)
803
+ else
804
+ console.warn(
805
+ `[tjs-lang] $predicate not verifiable — failing closed: ${err.message}`
806
+ )
807
+ }
808
+ }
809
+ cache.set(source, fn)
810
+ }
811
+ if (fn === null) return false
812
+ try {
813
+ return fn(value) === true
814
+ } catch {
815
+ return false // fuel exhaustion / runtime miss → not valid
816
+ }
817
+ }
818
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Regex ReDoS linting in the predicate verifier. A regex match is opaque to the
3
+ * fuel counter (a single `.test`/`.match` can't be interrupted mid-backtrack),
4
+ * so a catastrophic-backtracking pattern can't be certified predicate-safe. The
5
+ * verifier flags the exponential star-height≥2 class (`(a+)+`, `(a*)*`, …) and
6
+ * fails closed: over-flagging only costs the "verified" badge (the predicate
7
+ * still runs), whereas certifying a dangerous one would be a broken promise.
8
+ */
9
+ import { describe, it, expect } from 'bun:test'
10
+ import { verifyPredicate, emitVerifiedPredicate } from './predicate'
11
+ import { preprocess } from './parser'
12
+
13
+ /** Verify a single predicate whose body uses the given regex literal. */
14
+ const verifyRe = (re: string) =>
15
+ verifyPredicate(`function p(s) { return ${re}.test(s) }`)
16
+
17
+ describe('ReDoS linting: dangerous patterns are not predicate-safe', () => {
18
+ const dangerous: Array<[string, string]> = [
19
+ ['nested +', '/(a+)+$/'],
20
+ ['nested *', '/(a*)*/'],
21
+ ['plus-in-star', '/([a-z]+)*/'],
22
+ ['dot-star star', '/(.*)*/'],
23
+ ['digit nest', '/(\\d+)+/'],
24
+ ['unbounded brace nest', '/(a{2,})+/'],
25
+ ['doubly nested group', '/((a+))+/'],
26
+ ]
27
+ for (const [label, re] of dangerous) {
28
+ it(`flags ${label}: ${re}`, () => {
29
+ const r = verifyRe(re)
30
+ expect(r.safe).toBe(false)
31
+ expect(
32
+ r.diagnostics.some((d) => /redos|backtrack/i.test(d.message))
33
+ ).toBe(true)
34
+ })
35
+ }
36
+ })
37
+
38
+ describe('ReDoS linting: safe patterns still verify', () => {
39
+ const safe: Array<[string, string]> = [
40
+ ['char class +', '/[a-z]+/'],
41
+ ['bounded braces', '/\\d{3}-\\d{4}/'],
42
+ ['group repeated, no inner quantifier', '/(abc)+/'],
43
+ ['alternation repeated', '/(foo|bar)+/'],
44
+ ['anchored hex', '/^#[0-9a-f]{6}$/'],
45
+ ['single star', '/foo.*bar/'],
46
+ ['optional group', '/(ab)?c+/'],
47
+ ]
48
+ for (const [label, re] of safe) {
49
+ it(`accepts ${label}: ${re}`, () => {
50
+ const r = verifyRe(re)
51
+ expect(r.safe).toBe(true)
52
+ expect(r.diagnostics).toEqual([])
53
+ })
54
+ }
55
+ })
56
+
57
+ describe('ReDoS linting: end-to-end', () => {
58
+ it('emitVerifiedPredicate refuses a ReDoS regex (no code)', () => {
59
+ const r = emitVerifiedPredicate(
60
+ 'function p(s) { return /(a+)+$/.test(s) }',
61
+ 'p'
62
+ )
63
+ expect(r.safe).toBe(false)
64
+ expect(r.code).toBeUndefined()
65
+ })
66
+
67
+ it('a Type predicate with a ReDoS regex falls back (no verified guard)', () => {
68
+ const out = preprocess(
69
+ `Type Bad 'bad' { predicate(s) { return /(a+)+$/.test(s) } }`
70
+ ).source
71
+ expect(out).not.toContain('__fuel') // not certified → raw fallback
72
+ expect(out).toContain('(a+)+') // raw body preserved
73
+ })
74
+
75
+ it('a Type predicate with a safe regex compiles to a verified guard', () => {
76
+ const out = preprocess(
77
+ `Type Hex 'hex' { predicate(s) { return /^#[0-9a-f]{6}$/.test(s) } }`
78
+ ).source
79
+ expect(out).toContain('__fuel') // certified safe → fuel-bounded guard
80
+ })
81
+ })
@@ -28,6 +28,8 @@ export {
28
28
  export {
29
29
  verifyPredicate,
30
30
  compilePredicate,
31
+ emitVerifiedPredicate,
32
+ createPredicateEvaluator,
31
33
  suggest,
32
34
  effectfulFromAtoms,
33
35
  formatPredicateDiagnostics,
@@ -36,6 +38,8 @@ export {
36
38
  type PredicateVerifyResult,
37
39
  type VerifyPredicateOptions,
38
40
  type CompilePredicateOptions,
41
+ type EmitPredicateResult,
42
+ type PredicateEvaluatorOptions,
39
43
  type Suggestion,
40
44
  type SuggestOptions,
41
45
  } from './predicate'
@@ -53,6 +57,14 @@ export {
53
57
  // AST emitter
54
58
  export { transformFunction } from './emitters/ast'
55
59
 
60
+ // .d.ts emitter — the express-controlled migration bridge for a TS importer
61
+ // graph (see ../tosijs/TJS-PORT-DX.md). Needs to be reachable from `tjs-lang/lang`.
62
+ export {
63
+ generateDTS,
64
+ typeDescriptorToTS,
65
+ type GenerateDTSOptions,
66
+ } from './emitters/dts'
67
+
56
68
  // JS emitter (TJS -> JS)
57
69
  export { transpileToJS } from './emitters/js'
58
70
  export type {
@@ -107,4 +119,5 @@ export type {
107
119
  TranspileOptions,
108
120
  TranspileResult,
109
121
  TranspileWarning,
122
+ PredicateVerification,
110
123
  } from './types'
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Integration: `Type … { predicate(x){…} }` bodies that verify as predicate-safe
3
+ * compile to a self-contained, fuel-bounded native guard (the `emitVerifiedPredicate`
4
+ * path); unverifiable ones fall back to the raw arrow (never rejected — TJS ⊇ JS).
5
+ *
6
+ * We assert against the preprocessed source: `__fuel` present ⇔ the verified
7
+ * guard was emitted. Runtime guard behavior is covered by
8
+ * `emit-verified-predicate.test.ts`.
9
+ */
10
+ import { describe, it, expect } from 'bun:test'
11
+ import { preprocess } from './parser'
12
+ import { tjs } from './index'
13
+
14
+ const src = (s: string) => preprocess(s).source
15
+
16
+ describe('multiple verified predicates in one module (no name collision)', () => {
17
+ // Regression: each verified guard used a fixed `function __pred`, so two in
18
+ // one module collided — the polymorphic-merge transform saw duplicate names
19
+ // and either errored ("ambiguous signatures") or renamed to `__pred$1`,
20
+ // breaking the guard IIFE at runtime. Names are now per-declaration.
21
+ it('transpiles two Type predicates without a `__pred` clash', () => {
22
+ const out = tjs(
23
+ `Type A 'a' { predicate(x) { return x > 0 } }\n` +
24
+ `Type B 'b' { predicate(x) { return x < 0 } }`
25
+ )
26
+ expect(out.code).not.toContain('__pred$')
27
+ expect(out.code).toContain('__pred_A')
28
+ expect(out.code).toContain('__pred_B')
29
+ })
30
+
31
+ it('transpiles a Type + Generic predicate together', () => {
32
+ const out = tjs(
33
+ `Type Pos 'positive' { predicate(x) { return x > 0 } }\n` +
34
+ `Generic Box<T> { description: 'b', predicate(x, T) { return T(x.value) } }`
35
+ )
36
+ expect(out.code).not.toContain('__pred$')
37
+ expect(out.code).toContain('__pred_Pos')
38
+ expect(out.code).toContain('__pred_Box')
39
+ })
40
+ })
41
+
42
+ describe('Type predicate → verified fuel-bounded guard', () => {
43
+ it('compiles a safe predicate-only Type to a fuel-bounded guard', () => {
44
+ const out = src(`Type Pos 'positive' { predicate(x) { return x > 0 } }`)
45
+ expect(out).toContain(`const Pos = Type('positive'`)
46
+ expect(out).toContain('__fuel') // verified + fuel-injected
47
+ expect(out).toContain('x > 0') // original body preserved inside the guard
48
+ })
49
+
50
+ it('compiles a safe predicate+example Type, keeping the example schema gate', () => {
51
+ const out = src(
52
+ `Type EvenNum 'even' { example: 2, predicate(x) { return x % 2 === 0 } }`
53
+ )
54
+ expect(out).toContain(`const EvenNum = Type('even'`)
55
+ expect(out).toContain('__fuel') // verified
56
+ expect(out).toContain('__tjs?.validate') // example schema still gates
57
+ expect(out).toContain('x % 2 === 0')
58
+ })
59
+
60
+ it('verifies a native-TJS predicate using == (rewritten to Eq)', () => {
61
+ // `==` → `Eq(...)`; Eq is whitelisted pure, so the predicate still verifies.
62
+ const out = src(`Type Five 'five' { predicate(x) { return x == 5 } }`)
63
+ expect(out).toContain('Eq(') // proves the equality rewrite ran
64
+ expect(out).toContain('__fuel') // ...and it still verified as safe
65
+ })
66
+
67
+ it('falls back to the raw arrow for an unverifiable predicate (loop)', () => {
68
+ const out = src(
69
+ `Type AllPos 'all positive' { example: [1], predicate(xs) { for (const x of xs) { if (x <= 0) return false } return true } }`
70
+ )
71
+ expect(out).not.toContain('__fuel') // NOT verified → no fuel guard
72
+ expect(out).toContain('for (const x of xs)') // raw body preserved
73
+ expect(out).toContain('__tjs?.validate') // example gate still present
74
+ })
75
+
76
+ it('falls back for an impure predicate (effectful call)', () => {
77
+ const out = src(
78
+ `Type Reachable 'reachable' { predicate(url) { return fetch(url) } }`
79
+ )
80
+ expect(out).not.toContain('__fuel')
81
+ expect(out).toContain('fetch(url)') // raw body preserved
82
+ })
83
+ })
package/src/lang/types.ts CHANGED
@@ -97,6 +97,23 @@ export interface TranspileWarning {
97
97
  source?: string
98
98
  }
99
99
 
100
+ /**
101
+ * Per-declaration report of whether a `Type`/`Generic` predicate body verified
102
+ * as predicate-safe (→ compiled to a fuel-bounded, DoS-safe native guard) or
103
+ * fell back to a plain unverified function (still valid — TJS ⊇ JS — but not
104
+ * fuel-bounded or safe to run on untrusted data). Surfaced on the transpile
105
+ * result so tools can flag unverifiable predicates.
106
+ */
107
+ export interface PredicateVerification {
108
+ /** The `Type` / `Generic` declaration name. */
109
+ name: string
110
+ kind: 'Type' | 'Generic'
111
+ /** True → verified + compiled to a native guard; false → raw-function fallback. */
112
+ verified: boolean
113
+ /** When `!verified`, the verifier's reason(s). */
114
+ reason?: string
115
+ }
116
+
100
117
  /** Source map for debugging */
101
118
  export interface SourceMap {
102
119
  version: 3
@@ -13,7 +13,8 @@
13
13
  */
14
14
 
15
15
  import { describe, test, expect } from 'bun:test'
16
- import { transpileToJS, fromTS, tjs } from './index'
16
+ import { transpileToJS, tjs } from './index'
17
+ import { fromTS } from './emitters/from-ts'
17
18
 
18
19
  // Helper to get the first function's metadata from the Record
19
20
  function getFirstFunc(metadata: Record<string, any>) {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A `wasm{}` block that can't compile falls back to its `fallback{}` (JS) — which
3
+ * used to happen SILENTLY (the failure was only on `result.wasmCompiled`, which
4
+ * consumers don't inspect), so WASM looked like it "worked" when it was running
5
+ * the JS fallback. Now the failure is also mirrored into `result.warnings`.
6
+ * (tosijs-ui feedback UI-#1.)
7
+ */
8
+ import { describe, it, expect } from 'bun:test'
9
+ import { tjs } from './index'
10
+
11
+ const UNSUPPORTED = `function fill(out: [0.0], w: 0, h: 0) {
12
+ wasm {
13
+ for (let y = 0; y < h; y += 1) {
14
+ for (let x = 0; x < w; x += 1) { out[y * w + x] = 1.0 }
15
+ }
16
+ } fallback {
17
+ for (let i = 0; i < w * h; i++) out[i] = 0
18
+ }
19
+ return out
20
+ }`
21
+
22
+ const SUPPORTED = `function scale(arr: [0.0], len: 0, factor: 0.0) {
23
+ wasm {
24
+ for (let i = 0; i < len; i += 4) {
25
+ let off = i * 4
26
+ f32x4_store(arr, off, f32x4_mul(f32x4_load(arr, off), f32x4_splat(factor)))
27
+ }
28
+ } fallback {
29
+ for (let i = 0; i < len; i++) arr[i] *= factor
30
+ }
31
+ return arr
32
+ }`
33
+
34
+ describe('silent wasm{} fallback is now surfaced as a warning', () => {
35
+ it('warns (with the reason) when a block cannot compile', () => {
36
+ const r = tjs(UNSUPPORTED)
37
+ expect(r.wasmCompiled?.[0]?.success).toBe(false) // signal already existed…
38
+ const w = r.warnings?.find((w) =>
39
+ /wasm\{\} block .* did not compile/.test(w)
40
+ )
41
+ expect(w).toBeTruthy() // …now it's in warnings too
42
+ expect(w).toMatch(/fallback/)
43
+ })
44
+
45
+ it('does not warn when the block compiles to WASM', () => {
46
+ const r = tjs(SUPPORTED)
47
+ expect(r.wasmCompiled?.[0]?.success).toBe(true)
48
+ expect(r.warnings?.some((w) => /wasm\{\}/.test(w)) ?? false).toBe(false)
49
+ })
50
+ })
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Lint the i32/i32 integer-division footgun inside `wasm{}`: `/` with two integer
3
+ * operands truncates, and coercion to f64 only happens at the *next* operator, so
4
+ * `x / w - 0.5` is silently `0 - 0.5` for all `x < w` — it silently broke a real
5
+ * Mandelbrot kernel (tosijs-ui UI-#4). Now it's surfaced in `result.warnings`.
6
+ */
7
+ import { describe, it, expect } from 'bun:test'
8
+ import { tjs } from './index'
9
+
10
+ const intDivWarning = (r: { warnings?: string[] }) =>
11
+ r.warnings?.find((w) => /integer division/.test(w))
12
+
13
+ describe('i32/i32 division lint', () => {
14
+ it('warns when both operands are i32 (loop vars)', () => {
15
+ const r = tjs(`function f(n: 0) {
16
+ wasm {
17
+ for (let y = 1; y < n; y += 1) {
18
+ for (let x = 1; x < n; x += 1) { let r = x / y }
19
+ }
20
+ } fallback { }
21
+ return n
22
+ }`)
23
+ expect(r.wasmCompiled?.[0]?.success).toBe(true) // block compiled…
24
+ expect(intDivWarning(r)).toBeTruthy() // …and the footgun was flagged
25
+ expect(intDivWarning(r)).toMatch(/\+ 0\.0/) // suggests the fix
26
+ })
27
+
28
+ it('warns once per block, not per occurrence', () => {
29
+ const r = tjs(`function f(n: 0) {
30
+ wasm {
31
+ for (let i = 1; i < n; i += 1) { let a = i / n; let b = n / i; let c = i / i }
32
+ } fallback { }
33
+ return n
34
+ }`)
35
+ expect(r.warnings?.filter((w) => /integer division/.test(w)).length).toBe(1)
36
+ })
37
+
38
+ it('does NOT warn on float division', () => {
39
+ const r = tjs(`function g(a: 0.0, b: 0.0) {
40
+ wasm { let r = a / b } fallback { }
41
+ return a
42
+ }`)
43
+ expect(intDivWarning(r)).toBeUndefined()
44
+ })
45
+ })