tjs-lang 0.8.6 → 0.9.0
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 +12 -4
- package/bin/docs.js +3 -2
- package/demo/docs.json +2 -2
- package/dist/experiments/ambient/probe.d.ts +52 -0
- package/dist/index.js +115 -171
- 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.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.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 +100 -90
- 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 +102 -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/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/bare-assignments.test.ts +48 -0
- package/src/lang/doc-comment-position.test.ts +32 -0
- package/src/lang/docs.ts +6 -2
- 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/from-ts.ts +4 -1
- package/src/lang/emitters/js.ts +33 -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 +120 -18
- package/src/lang/parser.ts +22 -6
- package/src/lang/predicate-evaluator.test.ts +49 -0
- package/src/lang/predicate-report.test.ts +54 -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/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,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tjs-lang/schema` — tosijs-schema, pre-wired with predicate support.
|
|
3
|
+
*
|
|
4
|
+
* A drop-in for `tosijs-schema` that additionally evaluates the `$predicate`
|
|
5
|
+
* keyword: importing this module registers tjs-lang's verified predicate engine
|
|
6
|
+
* as the evaluator, so JSON-Schema validation is predicate-aware **out of the
|
|
7
|
+
* box** — no manual `setPredicateEvaluator` call. Everything tosijs-schema
|
|
8
|
+
* exports is re-exported here, so this is the only import a consumer needs:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { s, validate } from 'tjs-lang/schema'
|
|
12
|
+
* // a schema whose node carries `$predicate` now validates computationally
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Why it lives here and not in tosijs-schema: tjs-lang depends on tosijs-schema,
|
|
16
|
+
* so the reverse would be a circular dependency. This entry sits on the side of
|
|
17
|
+
* the graph that may know about both — it's the "batteries-included" packaging
|
|
18
|
+
* of the two.
|
|
19
|
+
*
|
|
20
|
+
* Registration is a global (tosijs-schema keeps one evaluator), so importing
|
|
21
|
+
* this module makes `$predicate` work for tosijs-schema usage app-wide, even in
|
|
22
|
+
* code that imports `tosijs-schema` directly. Call `setPredicateEvaluator(null)`
|
|
23
|
+
* to opt back out, or `installPredicateSupport(opts)` to re-install with custom
|
|
24
|
+
* options (fuel budget, atom-aware effects, etc.).
|
|
25
|
+
*/
|
|
26
|
+
import { setPredicateEvaluator, getPredicateEvaluator } from 'tosijs-schema'
|
|
27
|
+
import {
|
|
28
|
+
createPredicateEvaluator,
|
|
29
|
+
type PredicateEvaluatorOptions,
|
|
30
|
+
} from '../lang/predicate'
|
|
31
|
+
|
|
32
|
+
// The full tosijs-schema surface (s, validate, filter, diff, types,
|
|
33
|
+
// setPredicateEvaluator, getPredicateEvaluator, PredicateEvaluator, …).
|
|
34
|
+
export * from 'tosijs-schema'
|
|
35
|
+
// The evaluator factory, for advanced/custom wiring.
|
|
36
|
+
export {
|
|
37
|
+
createPredicateEvaluator,
|
|
38
|
+
type PredicateEvaluatorOptions,
|
|
39
|
+
} from '../lang/predicate'
|
|
40
|
+
|
|
41
|
+
let installed = false
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register tjs-lang's predicate engine as tosijs-schema's `$predicate` evaluator.
|
|
45
|
+
* Called automatically when this module is imported; exposed so callers can
|
|
46
|
+
* re-install with custom {@link PredicateEvaluatorOptions} (e.g. a smaller fuel
|
|
47
|
+
* budget, or an atom-aware effectful set) after opting out.
|
|
48
|
+
*/
|
|
49
|
+
export function installPredicateSupport(
|
|
50
|
+
opts?: PredicateEvaluatorOptions
|
|
51
|
+
): void {
|
|
52
|
+
setPredicateEvaluator(createPredicateEvaluator(opts))
|
|
53
|
+
installed = true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True once predicate support has been installed (and not since cleared). */
|
|
57
|
+
export function predicateSupportInstalled(): boolean {
|
|
58
|
+
return installed && getPredicateEvaluator() !== null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Batteries included: wire the engine up on import.
|
|
62
|
+
installPredicateSupport()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tjs-lang/schema` — tosijs-schema pre-wired with predicate support. Importing
|
|
3
|
+
* the module should make `$predicate` validation work with no manual wiring, and
|
|
4
|
+
* re-export the whole tosijs-schema surface. Runs against the real published
|
|
5
|
+
* tosijs-schema (node_modules), so it also guards the version/API contract.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, afterAll } from 'bun:test'
|
|
8
|
+
import {
|
|
9
|
+
validate,
|
|
10
|
+
s,
|
|
11
|
+
setPredicateEvaluator,
|
|
12
|
+
getPredicateEvaluator,
|
|
13
|
+
installPredicateSupport,
|
|
14
|
+
predicateSupportInstalled,
|
|
15
|
+
createPredicateEvaluator,
|
|
16
|
+
} from './index'
|
|
17
|
+
import { cssStyleSchema, cssColorSchema } from '../css'
|
|
18
|
+
|
|
19
|
+
// Leave the global evaluator registered for other suites (import order is
|
|
20
|
+
// nondeterministic); this module's contract is "installed after import".
|
|
21
|
+
afterAll(() => installPredicateSupport())
|
|
22
|
+
|
|
23
|
+
describe('tjs-lang/schema is batteries-included', () => {
|
|
24
|
+
it('registers the predicate evaluator on import', () => {
|
|
25
|
+
expect(predicateSupportInstalled()).toBe(true)
|
|
26
|
+
expect(typeof getPredicateEvaluator()).toBe('function')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('re-exports the tosijs-schema surface', () => {
|
|
30
|
+
expect(typeof validate).toBe('function')
|
|
31
|
+
expect(typeof s).toBe('object') // the builder proxy
|
|
32
|
+
expect(typeof setPredicateEvaluator).toBe('function')
|
|
33
|
+
expect(typeof createPredicateEvaluator).toBe('function')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('validates a $predicate node out of the box (CSS color)', () => {
|
|
37
|
+
const color = cssColorSchema()
|
|
38
|
+
expect(validate('#3a3', color)).toBe(true)
|
|
39
|
+
expect(validate('notacolor', color)).toBe(false) // predicate ran, no manual wiring
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('validates the recursive CSS style structure', () => {
|
|
43
|
+
const style = cssStyleSchema()
|
|
44
|
+
expect(
|
|
45
|
+
validate({ color: 'red', '&:hover': { color: 'var(--accent)' } }, style)
|
|
46
|
+
).toBe(true)
|
|
47
|
+
expect(validate({ ' bad key ': 'red' }, style)).toBe(false)
|
|
48
|
+
expect(validate('not-an-object', style)).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('opting out (setPredicateEvaluator(null)) falls back to structural only', () => {
|
|
52
|
+
setPredicateEvaluator(null)
|
|
53
|
+
const color = cssColorSchema()
|
|
54
|
+
// naive: only `type: string` is checked, so a non-color string passes
|
|
55
|
+
expect(validate('notacolor', color)).toBe(true)
|
|
56
|
+
// re-install for the remaining assertions
|
|
57
|
+
installPredicateSupport()
|
|
58
|
+
expect(validate('notacolor', color)).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('installPredicateSupport accepts custom options (fuel budget)', () => {
|
|
62
|
+
installPredicateSupport({ fuel: 500_000 })
|
|
63
|
+
expect(predicateSupportInstalled()).toBe(true)
|
|
64
|
+
expect(validate('#fff', cssColorSchema())).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
})
|