tjs-lang 0.8.7 → 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/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/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.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 +109 -14
- package/src/lang/parser.ts +9 -2
- 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
|
@@ -42,6 +42,57 @@ import type {
|
|
|
42
42
|
TokenizerState,
|
|
43
43
|
} from './parser-types'
|
|
44
44
|
import { extractJSValue } from './parser-params'
|
|
45
|
+
import { emitVerifiedPredicate, formatPredicateDiagnostics } from './predicate'
|
|
46
|
+
import type { PredicateVerification } from './types'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* If a `Type`/`FunctionPredicate` predicate body verifies as predicate-safe
|
|
50
|
+
* (pure, synchronous, no loops/IO), return a self-contained fuel-bounded guard
|
|
51
|
+
* **expression** to inline in place of the raw arrow; otherwise return null so
|
|
52
|
+
* the caller falls back to the unverified body (never rejects — see
|
|
53
|
+
* PRINCIPLES.md, TJS ⊇ JS). This is the transpile-time consumer of the predicate
|
|
54
|
+
* engine: a safe predicate "earns" the native fast path and DoS-safe validation.
|
|
55
|
+
*/
|
|
56
|
+
function verifiedGuardExpr(
|
|
57
|
+
name: string,
|
|
58
|
+
kind: 'Type' | 'Generic',
|
|
59
|
+
params: string,
|
|
60
|
+
body: string,
|
|
61
|
+
knownPredicates?: string[],
|
|
62
|
+
report?: PredicateVerification[]
|
|
63
|
+
): string | null {
|
|
64
|
+
// Synthesize a named declaration the verifier can analyze. The body has
|
|
65
|
+
// already been through the TJS equality/typeof rewrites (Eq/NotEq/Is/IsNot/
|
|
66
|
+
// TypeOf), all whitelisted as pure in the verifier. `knownPredicates` lets a
|
|
67
|
+
// Generic predicate compose with its type-param checks (`checkT(x[0])`) — the
|
|
68
|
+
// verifier treats those calls as composition with another safe predicate.
|
|
69
|
+
//
|
|
70
|
+
// The entry function is named per-declaration (`__pred_<Type>`), not a fixed
|
|
71
|
+
// `__pred`: the guard IIFEs are inlined into the module, and two identically-
|
|
72
|
+
// named nested `function __pred`s would be picked up by the polymorphic-merge
|
|
73
|
+
// transform (which runs after this one) as ambiguous overloads. Type/Generic
|
|
74
|
+
// names are unique within a module, so this is collision-free and deterministic.
|
|
75
|
+
const entry = `__pred_${name}`
|
|
76
|
+
const fnSource = `function ${entry}(${params}) { ${body} }`
|
|
77
|
+
const r = emitVerifiedPredicate(
|
|
78
|
+
fnSource,
|
|
79
|
+
entry,
|
|
80
|
+
knownPredicates ? { knownPredicates: new Set(knownPredicates) } : {}
|
|
81
|
+
)
|
|
82
|
+
if (r.safe) {
|
|
83
|
+
report?.push({ name, kind, verified: true })
|
|
84
|
+
return r.code!
|
|
85
|
+
}
|
|
86
|
+
report?.push({
|
|
87
|
+
name,
|
|
88
|
+
kind,
|
|
89
|
+
verified: false,
|
|
90
|
+
// The verifier names the synthesized entry `__pred_<Name>`; surface the
|
|
91
|
+
// declaration name in the user-facing reason instead.
|
|
92
|
+
reason: formatPredicateDiagnostics(r.diagnostics).replace(/__pred_/g, ''),
|
|
93
|
+
})
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
45
96
|
|
|
46
97
|
export function transformTryWithoutCatch(source: string): string {
|
|
47
98
|
let result = ''
|
|
@@ -1127,12 +1178,15 @@ function transformTypeofKeyword(source: string): string {
|
|
|
1127
1178
|
}
|
|
1128
1179
|
|
|
1129
1180
|
/**
|
|
1130
|
-
* Transform == and != to
|
|
1181
|
+
* Transform == and != to Eq() and NotEq() calls
|
|
1131
1182
|
*
|
|
1132
1183
|
* In TJS normal mode:
|
|
1133
|
-
* a == b ->
|
|
1134
|
-
*
|
|
1184
|
+
* a == b -> Eq(a, b) (footgun-free ===: unwraps boxed primitives,
|
|
1185
|
+
* null == undefined, but does NOT coerce types and
|
|
1186
|
+
* is NOT structural — see PRINCIPLES/guides)
|
|
1187
|
+
* a != b -> NotEq(a, b)
|
|
1135
1188
|
* a === b -> a === b (identity, unchanged)
|
|
1189
|
+
* (For deep structural equality use the explicit `Is`/`IsNot` operators.)
|
|
1136
1190
|
*
|
|
1137
1191
|
* Uses a two-pass algorithm:
|
|
1138
1192
|
* 1. Find all == and != positions (outside strings/comments/regex)
|
|
@@ -1620,7 +1674,10 @@ function findRightOperandBoundary(
|
|
|
1620
1674
|
*
|
|
1621
1675
|
* When predicate + example: auto-generate type guard from example
|
|
1622
1676
|
*/
|
|
1623
|
-
export function transformTypeDeclarations(
|
|
1677
|
+
export function transformTypeDeclarations(
|
|
1678
|
+
source: string,
|
|
1679
|
+
report?: PredicateVerification[]
|
|
1680
|
+
): string {
|
|
1624
1681
|
let result = ''
|
|
1625
1682
|
let i = 0
|
|
1626
1683
|
|
|
@@ -1737,17 +1794,41 @@ export function transformTypeDeclarations(source: string): string {
|
|
|
1737
1794
|
// Build Type() call with appropriate arguments
|
|
1738
1795
|
// Type(description, predicateOrExample, example?, default?)
|
|
1739
1796
|
if (predicateMatch && example) {
|
|
1740
|
-
// Predicate + example
|
|
1797
|
+
// Predicate + example: the example is the base schema, the predicate
|
|
1798
|
+
// refines it. Verify the predicate → fuel-bounded native guard; the
|
|
1799
|
+
// example schema check stays as an outer gate (constructed once via an
|
|
1800
|
+
// IIFE, not per call). Falls back to the raw arrow when unverifiable.
|
|
1741
1801
|
const params = predicateMatch[1].trim()
|
|
1742
1802
|
const body = predicateMatch[2].trim()
|
|
1743
1803
|
const defaultArg = defaultValue ? `, ${defaultValue}` : ''
|
|
1744
|
-
|
|
1804
|
+
const schemaGate = `globalThis.__tjs?.validate(${params}, globalThis.__tjs?.infer(${example}))`
|
|
1805
|
+
const guard = verifiedGuardExpr(
|
|
1806
|
+
typeName,
|
|
1807
|
+
'Type',
|
|
1808
|
+
params,
|
|
1809
|
+
body,
|
|
1810
|
+
undefined,
|
|
1811
|
+
report
|
|
1812
|
+
)
|
|
1813
|
+
const fn = guard
|
|
1814
|
+
? `(__g => (${params}) => (${schemaGate} ? __g(${params}) : false))(${guard})`
|
|
1815
|
+
: `(${params}) => { if (!${schemaGate}) return false; ${body} }`
|
|
1816
|
+
result += `const ${typeName} = Type('${description}', ${fn}, ${example}${defaultArg})`
|
|
1745
1817
|
} else if (predicateMatch) {
|
|
1746
|
-
// Predicate only
|
|
1818
|
+
// Predicate only: verify → fuel-bounded native guard, else raw arrow.
|
|
1747
1819
|
const params = predicateMatch[1].trim()
|
|
1748
1820
|
const body = predicateMatch[2].trim()
|
|
1749
1821
|
const defaultArg = defaultValue ? `, undefined, ${defaultValue}` : ''
|
|
1750
|
-
|
|
1822
|
+
const guard = verifiedGuardExpr(
|
|
1823
|
+
typeName,
|
|
1824
|
+
'Type',
|
|
1825
|
+
params,
|
|
1826
|
+
body,
|
|
1827
|
+
undefined,
|
|
1828
|
+
report
|
|
1829
|
+
)
|
|
1830
|
+
const fn = guard ?? `(${params}) => { ${body} }`
|
|
1831
|
+
result += `const ${typeName} = Type('${description}', ${fn}${defaultArg})`
|
|
1751
1832
|
} else if (example) {
|
|
1752
1833
|
// Example only (becomes validation schema)
|
|
1753
1834
|
const defaultArg = defaultValue ? `, ${defaultValue}` : ''
|
|
@@ -1938,7 +2019,10 @@ export function transformFunctionPredicateDeclarations(source: string): string {
|
|
|
1938
2019
|
* const Pair = Generic(['T', 'U'], (obj, checkT, checkU) => { ... }, '...')
|
|
1939
2020
|
* const Container = Generic(['T', ['U', '']], (obj, checkT, checkU) => { ... }, '...')
|
|
1940
2021
|
*/
|
|
1941
|
-
export function transformGenericDeclarations(
|
|
2022
|
+
export function transformGenericDeclarations(
|
|
2023
|
+
source: string,
|
|
2024
|
+
report?: PredicateVerification[]
|
|
2025
|
+
): string {
|
|
1942
2026
|
let result = ''
|
|
1943
2027
|
let i = 0
|
|
1944
2028
|
|
|
@@ -2034,11 +2118,21 @@ export function transformGenericDeclarations(source: string): string {
|
|
|
2034
2118
|
)
|
|
2035
2119
|
})
|
|
2036
2120
|
|
|
2121
|
+
// Verify the (type-param-rewritten) body, composing with the type-param
|
|
2122
|
+
// checks; safe → fuel-bounded native guard, else the raw arrow.
|
|
2123
|
+
const paramList = [valueParam, ...typeCheckParams].join(', ')
|
|
2124
|
+
const guard = verifiedGuardExpr(
|
|
2125
|
+
genericName,
|
|
2126
|
+
'Generic',
|
|
2127
|
+
paramList,
|
|
2128
|
+
body,
|
|
2129
|
+
typeCheckParams,
|
|
2130
|
+
report
|
|
2131
|
+
)
|
|
2132
|
+
const fn = guard ?? `(${paramList}) => { ${body} }`
|
|
2037
2133
|
result += `const ${genericName} = Generic([${typeParams.join(
|
|
2038
2134
|
', '
|
|
2039
|
-
)}],
|
|
2040
|
-
', '
|
|
2041
|
-
)}) => { ${body} }, '${description}')`
|
|
2135
|
+
)}], ${fn}, '${description}')`
|
|
2042
2136
|
} else {
|
|
2043
2137
|
// No predicate - create a generic that always passes
|
|
2044
2138
|
result += `const ${genericName} = Generic([${typeParams.join(
|
|
@@ -3542,11 +3636,12 @@ export function validateNoDate(source: string): string {
|
|
|
3542
3636
|
{
|
|
3543
3637
|
pattern: /\bnew\s+Date\b/,
|
|
3544
3638
|
message:
|
|
3545
|
-
'new Date() is not allowed in TjsDate mode. Use Timestamp.now() or
|
|
3639
|
+
'new Date() is not allowed in TjsDate mode. Use Timestamp.now()/Timestamp.from() for wall-clock dates, or performance.now() for a monotonic counter (timing, id generation)',
|
|
3546
3640
|
},
|
|
3547
3641
|
{
|
|
3548
3642
|
pattern: /\bDate\.now\b/,
|
|
3549
|
-
message:
|
|
3643
|
+
message:
|
|
3644
|
+
'Date.now() is not allowed in TjsDate mode. Use Timestamp.now() for a wall-clock date, or performance.now() for a monotonic counter (timing, id generation)',
|
|
3550
3645
|
},
|
|
3551
3646
|
{
|
|
3552
3647
|
pattern: /\bDate\.parse\b/,
|
package/src/lang/parser.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import * as acorn from 'acorn'
|
|
9
9
|
import type { Program, FunctionDeclaration } from 'acorn'
|
|
10
10
|
import { SyntaxError } from './types'
|
|
11
|
+
import type { PredicateVerification } from './types'
|
|
11
12
|
|
|
12
13
|
// Re-export types so external callers don't need to change imports
|
|
13
14
|
export type {
|
|
@@ -124,6 +125,7 @@ export function preprocess(
|
|
|
124
125
|
polymorphicNames: Set<string>
|
|
125
126
|
extensions: Map<string, Set<string>>
|
|
126
127
|
letAnnotations: Map<string, string>
|
|
128
|
+
predicates: PredicateVerification[]
|
|
127
129
|
} {
|
|
128
130
|
const originalSource = source
|
|
129
131
|
let moduleSafety: 'none' | 'inputs' | 'all' | undefined
|
|
@@ -298,8 +300,12 @@ export function preprocess(
|
|
|
298
300
|
// Generic Bar<T, U> { ... } -> const Bar = Generic(...)
|
|
299
301
|
// Union Dir 'up' | 'down' -> const Dir = Union(...)
|
|
300
302
|
// Enum Status { Pending, Active, Done } -> const Status = Enum(...)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
+
// Collect per-predicate verification status (Type/Generic predicate bodies:
|
|
304
|
+
// verified → native guard, or fell back to a raw function). Surfaced on the
|
|
305
|
+
// transpile result so tools can flag unverifiable predicates.
|
|
306
|
+
const predicates: PredicateVerification[] = []
|
|
307
|
+
source = transformTypeDeclarations(source, predicates)
|
|
308
|
+
source = transformGenericDeclarations(source, predicates)
|
|
303
309
|
source = transformFunctionPredicateDeclarations(source)
|
|
304
310
|
source = transformUnionDeclarations(source)
|
|
305
311
|
source = transformEnumDeclarations(source)
|
|
@@ -437,6 +443,7 @@ export function preprocess(
|
|
|
437
443
|
polymorphicNames: polyResult.polymorphicNames,
|
|
438
444
|
extensions: extResult.extensions,
|
|
439
445
|
letAnnotations,
|
|
446
|
+
predicates,
|
|
440
447
|
}
|
|
441
448
|
}
|
|
442
449
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createPredicateEvaluator` — the pluggable `(source, value) => boolean` bridge
|
|
3
|
+
* a zero-dep, predicate-aware JSON-Schema validator (tosijs-schema) injects to
|
|
4
|
+
* run `$predicate` sources. Compiles+caches per source; fails closed on an
|
|
5
|
+
* unverifiable source (never a thrown error, never a silent pass).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'bun:test'
|
|
8
|
+
import { createPredicateEvaluator } from './predicate'
|
|
9
|
+
|
|
10
|
+
const POS = 'function isPos(x) { return typeof x === "number" && x > 0 }'
|
|
11
|
+
const LOOPY =
|
|
12
|
+
'function bad(xs) { for (const x of xs) { if (x < 0) return false } return true }'
|
|
13
|
+
|
|
14
|
+
describe('createPredicateEvaluator', () => {
|
|
15
|
+
it('evaluates a safe source against values', () => {
|
|
16
|
+
const evaluate = createPredicateEvaluator()
|
|
17
|
+
expect(evaluate(POS, 5)).toBe(true)
|
|
18
|
+
expect(evaluate(POS, -1)).toBe(false)
|
|
19
|
+
expect(evaluate(POS, 'x')).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('caches: compiles a source once, reuses across calls', () => {
|
|
23
|
+
let unsafeCalls = 0
|
|
24
|
+
const evaluate = createPredicateEvaluator({
|
|
25
|
+
onUnsafe: () => unsafeCalls++,
|
|
26
|
+
})
|
|
27
|
+
// many evaluations of the same source — still one compile, zero warnings
|
|
28
|
+
for (let i = 0; i < 100; i++) expect(evaluate(POS, i + 1)).toBe(true)
|
|
29
|
+
expect(unsafeCalls).toBe(0)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('fails closed on an unverifiable source (returns false, warns once)', () => {
|
|
33
|
+
const unsafe: string[] = []
|
|
34
|
+
const evaluate = createPredicateEvaluator({
|
|
35
|
+
onUnsafe: (src) => unsafe.push(src),
|
|
36
|
+
})
|
|
37
|
+
// a loop can't be certified predicate-safe → every value is invalid
|
|
38
|
+
expect(evaluate(LOOPY, [1, 2, 3])).toBe(false)
|
|
39
|
+
expect(evaluate(LOOPY, [1, 2, 3])).toBe(false)
|
|
40
|
+
// ...and the failure is reported exactly once (cached)
|
|
41
|
+
expect(unsafe.length).toBe(1)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('fails closed on a runaway (fuel) rather than throwing', () => {
|
|
45
|
+
const evaluate = createPredicateEvaluator({ fuel: 100 })
|
|
46
|
+
const recur = 'function deep(n) { return deep(n + 1) }'
|
|
47
|
+
expect(evaluate(recur, 0)).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `tjs()` result surfaces per-`Type`/`Generic` predicate verification status
|
|
3
|
+
* (`result.predicates`) so tools can flag unverifiable predicates, and mirrors
|
|
4
|
+
* the unverified ones into `result.warnings`. (#9 / the #5 warn-on-fallback tail.)
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from 'bun:test'
|
|
7
|
+
import { tjs } from './index'
|
|
8
|
+
|
|
9
|
+
describe('predicate verification is reported on the transpile result', () => {
|
|
10
|
+
it('a safe Type predicate → verified, no warning', () => {
|
|
11
|
+
const r = tjs("Type Pos 'positive' { predicate(x) { return x > 0 } }")
|
|
12
|
+
expect(r.predicates).toEqual([
|
|
13
|
+
{ name: 'Pos', kind: 'Type', verified: true },
|
|
14
|
+
])
|
|
15
|
+
expect(r.warnings).toBeUndefined()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('an unverifiable Type predicate → not verified + reason + warning', () => {
|
|
19
|
+
const r = tjs(
|
|
20
|
+
"Type Bad 'bad' { predicate(xs) { for (const x of xs) { if (x<0) return false } return true } }"
|
|
21
|
+
)
|
|
22
|
+
const p = r.predicates?.[0]
|
|
23
|
+
expect(p).toMatchObject({ name: 'Bad', kind: 'Type', verified: false })
|
|
24
|
+
expect(p?.reason).toMatch(/loop/i)
|
|
25
|
+
expect(p?.reason).not.toContain('__pred_') // internal name not leaked
|
|
26
|
+
expect(r.warnings?.some((w) => /Bad.*not verifiable/.test(w))).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('reports Generic predicates too', () => {
|
|
30
|
+
const r = tjs(
|
|
31
|
+
"Generic Box<T> { description: 'b', predicate(x, T) { return T(x.value) } }"
|
|
32
|
+
)
|
|
33
|
+
expect(r.predicates?.[0]).toMatchObject({
|
|
34
|
+
name: 'Box',
|
|
35
|
+
kind: 'Generic',
|
|
36
|
+
verified: true,
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('reports each predicate in a multi-predicate module', () => {
|
|
41
|
+
const r = tjs(
|
|
42
|
+
"Type A 'a' { predicate(x) { return x > 0 } }\n" +
|
|
43
|
+
"Type B 'b' { predicate(xs) { for (const x of xs) {} return true } }"
|
|
44
|
+
)
|
|
45
|
+
const byName = Object.fromEntries(
|
|
46
|
+
(r.predicates ?? []).map((p) => [p.name, p.verified])
|
|
47
|
+
)
|
|
48
|
+
expect(byName).toEqual({ A: true, B: false })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('omits `predicates` when there are none', () => {
|
|
52
|
+
expect(tjs('function f() { return 1 }').predicates).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
})
|
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
|
+
}
|