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.
- package/CLAUDE.md +19 -5
- package/dist/experiments/ambient/probe.d.ts +52 -0
- package/dist/index.js +110 -162
- package/dist/index.js.map +4 -4
- package/dist/scripts/build-editors.d.ts +19 -0
- package/dist/src/css/dimensions.d.ts +23 -0
- package/dist/src/css/index.d.ts +85 -0
- package/dist/src/css/predicates.d.ts +38 -0
- package/dist/src/css/shorthands.d.ts +31 -0
- package/dist/src/css/style.d.ts +48 -0
- package/dist/src/lang/emitters/js-wasm.d.ts +2 -0
- package/dist/src/lang/emitters/js.d.ts +9 -1
- package/dist/src/lang/index.d.ts +2 -2
- package/dist/src/lang/parser-transforms.d.ts +9 -5
- package/dist/src/lang/parser-types.d.ts +7 -0
- package/dist/src/lang/parser.d.ts +2 -0
- package/dist/src/lang/predicate.d.ts +60 -0
- package/dist/src/lang/transpiler.d.ts +3 -2
- package/dist/src/lang/types.d.ts +16 -0
- package/dist/src/schema/index.d.ts +12 -0
- package/dist/tjs-browser-from-ts.js +20 -20
- package/dist/tjs-browser-from-ts.js.map +3 -3
- package/dist/tjs-browser.js +108 -94
- package/dist/tjs-browser.js.map +4 -4
- package/dist/tjs-css.js +193 -0
- package/dist/tjs-css.js.map +7 -0
- package/dist/tjs-eval.js +43 -42
- package/dist/tjs-eval.js.map +4 -4
- package/dist/tjs-from-ts.js +13 -13
- package/dist/tjs-from-ts.js.map +2 -2
- package/dist/tjs-lang.js +106 -92
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-runtime.js +10 -0
- package/dist/tjs-runtime.js.map +7 -0
- package/dist/tjs-schema.js +6 -0
- package/dist/tjs-schema.js.map +7 -0
- package/dist/tjs-vm.js +55 -54
- package/dist/tjs-vm.js.map +4 -4
- package/docs/ambient-contracts.md +261 -0
- package/docs/type-system-north-star.md +100 -0
- package/editors/ace/ajs-mode.js +214 -233
- package/editors/codemirror/ajs-language.js +1570 -233
- package/editors/editors-build.test.ts +29 -0
- package/editors/monaco/ajs-monarch.js +239 -195
- package/llms.txt +4 -0
- package/package.json +35 -4
- package/src/css/css.test.ts +122 -0
- package/src/css/dimensions.test.ts +112 -0
- package/src/css/dimensions.ts +146 -0
- package/src/css/index.ts +243 -0
- package/src/css/perf.bench.test.ts +109 -0
- package/src/css/predicates.ts +232 -0
- package/src/css/property-aware.test.ts +84 -0
- package/src/css/shorthands.test.ts +90 -0
- package/src/css/shorthands.ts +113 -0
- package/src/css/style.test.ts +125 -0
- package/src/css/style.ts +134 -0
- package/src/index-tsfree.test.ts +58 -0
- package/src/lang/emit-verified-predicate.test.ts +95 -0
- package/src/lang/emitters/dts.test.ts +31 -0
- package/src/lang/emitters/dts.ts +23 -4
- package/src/lang/emitters/js-wasm.ts +8 -4
- package/src/lang/emitters/js.ts +58 -1
- package/src/lang/from-ts.test.ts +1 -1
- package/src/lang/generic-verified-predicate.test.ts +39 -0
- package/src/lang/index.ts +14 -5
- package/src/lang/parser-transforms.ts +113 -15
- package/src/lang/parser-types.ts +7 -0
- package/src/lang/parser.ts +18 -3
- package/src/lang/predicate-evaluator.test.ts +49 -0
- package/src/lang/predicate-report.test.ts +78 -0
- package/src/lang/predicate.ts +268 -0
- package/src/lang/redos-lint.test.ts +81 -0
- package/src/lang/transpiler.ts +13 -0
- package/src/lang/type-verified-predicate.test.ts +83 -0
- package/src/lang/types.ts +17 -0
- package/src/lang/typescript-syntax.test.ts +2 -1
- package/src/lang/wasm-fallback-warning.test.ts +50 -0
- package/src/lang/wasm-intdiv-lint.test.ts +45 -0
- package/src/lang/wasm-ready.test.ts +94 -0
- package/src/lang/wasm-simd-ops.test.ts +84 -0
- package/src/lang/wasm.ts +61 -2
- package/src/schema/index.ts +62 -0
- package/src/schema/schema.test.ts +66 -0
- package/src/use-cases/bootstrap.test.ts +14 -4
package/src/lang/predicate.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/lang/transpiler.ts
CHANGED
|
@@ -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,
|
|
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
|
+
})
|