tjs-lang 0.8.1 → 0.8.3
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 +13 -4
- package/demo/autocomplete.test.ts +37 -0
- package/demo/docs.json +698 -8
- package/demo/examples.test.ts +6 -2
- package/demo/src/autocomplete.ts +40 -2
- package/demo/src/introspection-bridge.ts +140 -0
- package/demo/src/introspection-doc.test.ts +63 -0
- package/demo/src/playground-shared.ts +101 -16
- package/demo/src/playground-test-results.test.ts +112 -0
- package/demo/src/tjs-playground.ts +32 -5
- package/dist/examples/modules/dist/main.d.ts +34 -0
- package/dist/examples/modules/dist/math.d.ts +120 -0
- package/dist/index.js +115 -112
- package/dist/index.js.map +4 -4
- package/dist/src/lang/core.d.ts +1 -1
- package/dist/src/lang/dialect.d.ts +35 -0
- package/dist/src/lang/emitters/ast.d.ts +1 -1
- package/dist/src/lang/emitters/js-tests.d.ts +7 -0
- package/dist/src/lang/emitters/js.d.ts +12 -0
- package/dist/src/lang/index.d.ts +2 -1
- package/dist/src/lang/parser-types.d.ts +17 -0
- package/dist/src/lang/parser.d.ts +18 -0
- package/dist/src/lang/transpiler.d.ts +1 -0
- package/dist/src/lang/types.d.ts +18 -0
- package/dist/src/vm/runtime.d.ts +18 -0
- package/dist/src/vm/vm.d.ts +15 -1
- package/dist/tjs-batteries.js +2 -2
- package/dist/tjs-batteries.js.map +2 -2
- package/dist/tjs-eval.js +43 -43
- package/dist/tjs-eval.js.map +3 -3
- package/dist/tjs-from-ts.js +1 -1
- package/dist/tjs-from-ts.js.map +2 -2
- package/dist/tjs-lang.js +76 -73
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-vm.js +49 -49
- package/dist/tjs-vm.js.map +3 -3
- package/editors/codemirror/ajs-language.ts +130 -44
- package/editors/codemirror/completion-source.test.ts +114 -0
- package/editors/introspect-value.test.ts +61 -0
- package/editors/introspect-value.ts +86 -0
- package/editors/scope-symbols.test.ts +113 -0
- package/editors/scope-symbols.ts +173 -0
- package/llms.txt +1 -0
- package/package.json +21 -1
- package/src/batteries/audit.ts +3 -2
- package/src/cli/commands/check.ts +3 -2
- package/src/cli/commands/emit.ts +4 -2
- package/src/cli/commands/run.ts +6 -2
- package/src/cli/commands/types.ts +2 -2
- package/src/lang/codegen.test.ts +4 -1
- package/src/lang/core.ts +6 -4
- package/src/lang/dialect.test.ts +63 -0
- package/src/lang/dialect.ts +50 -0
- package/src/lang/emitters/ast.ts +145 -2
- package/src/lang/emitters/js-tests.ts +46 -37
- package/src/lang/emitters/js.ts +19 -2
- package/src/lang/features.test.ts +6 -5
- package/src/lang/index.ts +40 -5
- package/src/lang/parser-types.ts +17 -0
- package/src/lang/parser.test.ts +12 -6
- package/src/lang/parser.ts +113 -3
- package/src/lang/predicate-schema.test.ts +97 -0
- package/src/lang/predicate-schema.ts +168 -0
- package/src/lang/predicate.test.ts +184 -0
- package/src/lang/predicate.ts +550 -0
- package/src/lang/subset-invariant.test.ts +90 -0
- package/src/lang/suggest.test.ts +84 -0
- package/src/lang/transpiler.ts +34 -0
- package/src/lang/types.ts +18 -0
- package/src/lang/wasm.test.ts +8 -2
- package/src/linalg/linalg.test.ts +8 -2
- package/src/use-cases/batteries.test.ts +4 -0
- package/src/use-cases/local-helpers.test.ts +219 -0
- package/src/use-cases/timeout-overrides.test.ts +169 -0
- package/src/vm/atom-effects.test.ts +72 -0
- package/src/vm/atoms/batteries.ts +29 -3
- package/src/vm/runtime.ts +140 -4
- package/src/vm/vm.ts +47 -9
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predicate-safety verifier.
|
|
3
|
+
*
|
|
4
|
+
* A *predicate* is a pure, synchronous function of its inputs. A cluster of
|
|
5
|
+
* predicates is **predicate-safe** iff every function in it uses only pure
|
|
6
|
+
* constructs and calls only pure builtins, pure globals, or other
|
|
7
|
+
* predicate-safe predicates — a closure property, so the cluster is safe iff
|
|
8
|
+
* each function is. Verified predicates have "earned" the native fast path:
|
|
9
|
+
* they compile to plain synchronous JS where ergonomic composition
|
|
10
|
+
* (`isHex(v) || isVar(v)`, `tokens.every(isToken)`, recursion) just works.
|
|
11
|
+
*
|
|
12
|
+
* This is pure static analysis over the parsed source, so it has none of the VM
|
|
13
|
+
* interpreter's restrictions (calls-in-expressions, named callbacks) — it
|
|
14
|
+
* accepts the ergonomic source and certifies it. The serializable AJS AST stays
|
|
15
|
+
* the portable form (the "missing computational half" of JSON Schema); native
|
|
16
|
+
* JS is the execution form.
|
|
17
|
+
*
|
|
18
|
+
* Effect classification of atoms comes from the VM's `effects` tag — pass
|
|
19
|
+
* `effectfulFromAtoms(registry)` for atom-aware checking; without it, atom calls
|
|
20
|
+
* still fail closed as "unknown reference". See `experiments/predicates/` for
|
|
21
|
+
* the CSS torture set and `src/vm/atom-effects.test.ts` for the drift guard.
|
|
22
|
+
*/
|
|
23
|
+
import * as acorn from 'acorn'
|
|
24
|
+
import * as walk from 'acorn-walk'
|
|
25
|
+
|
|
26
|
+
export interface PredicateDiagnostic {
|
|
27
|
+
/** Name of the predicate the problem is in. */
|
|
28
|
+
predicate: string
|
|
29
|
+
message: string
|
|
30
|
+
line: number
|
|
31
|
+
column: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PredicateVerifyResult {
|
|
35
|
+
safe: boolean
|
|
36
|
+
/** Names of the top-level functions found (the predicate cluster). */
|
|
37
|
+
predicates: string[]
|
|
38
|
+
diagnostics: PredicateDiagnostic[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface VerifyPredicateOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Names that are effectful and must not be called — IO atoms + JS IO globals.
|
|
44
|
+
* Compose from the atom registry with `effectfulFromAtoms`. The built-in JS IO
|
|
45
|
+
* globals are always included.
|
|
46
|
+
*/
|
|
47
|
+
effectful?: Set<string>
|
|
48
|
+
/**
|
|
49
|
+
* Externally-verified predicate names this source may compose with (a shared
|
|
50
|
+
* registry), in addition to the functions declared in `source`.
|
|
51
|
+
*/
|
|
52
|
+
knownPredicates?: Set<string>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- the safe substrate -----------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/** Pure deterministic globals callable by bare name. */
|
|
58
|
+
const PURE_GLOBALS = new Set([
|
|
59
|
+
'parseInt',
|
|
60
|
+
'parseFloat',
|
|
61
|
+
'isNaN',
|
|
62
|
+
'isFinite',
|
|
63
|
+
'encodeURIComponent',
|
|
64
|
+
'decodeURIComponent',
|
|
65
|
+
'String',
|
|
66
|
+
'Number',
|
|
67
|
+
'Boolean',
|
|
68
|
+
'Array',
|
|
69
|
+
'Object',
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
/** Namespaces whose static methods are pure (with effectful exceptions below). */
|
|
73
|
+
const PURE_NAMESPACES = new Set([
|
|
74
|
+
'Math',
|
|
75
|
+
'JSON',
|
|
76
|
+
'Object',
|
|
77
|
+
'Array',
|
|
78
|
+
'String',
|
|
79
|
+
'Number',
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
/** Static members that are NOT pure even on a pure namespace. */
|
|
83
|
+
const EFFECTFUL_STATICS = new Set(['Math.random', 'Date.now'])
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Instance methods known to be pure regardless of receiver type. A method call
|
|
87
|
+
* whose method name isn't here (and whose receiver isn't a pure namespace) is
|
|
88
|
+
* flagged — so `x.then()`, `obj.fetch()`, `arr.push()` (mutates) don't pass.
|
|
89
|
+
*/
|
|
90
|
+
const PURE_INSTANCE_METHODS = new Set([
|
|
91
|
+
// string
|
|
92
|
+
'startsWith',
|
|
93
|
+
'endsWith',
|
|
94
|
+
'includes',
|
|
95
|
+
'indexOf',
|
|
96
|
+
'lastIndexOf',
|
|
97
|
+
'slice',
|
|
98
|
+
'substring',
|
|
99
|
+
'substr',
|
|
100
|
+
'toLowerCase',
|
|
101
|
+
'toUpperCase',
|
|
102
|
+
'trim',
|
|
103
|
+
'trimStart',
|
|
104
|
+
'trimEnd',
|
|
105
|
+
'split',
|
|
106
|
+
'replace',
|
|
107
|
+
'replaceAll',
|
|
108
|
+
'match',
|
|
109
|
+
'matchAll',
|
|
110
|
+
'charAt',
|
|
111
|
+
'charCodeAt',
|
|
112
|
+
'codePointAt',
|
|
113
|
+
'padStart',
|
|
114
|
+
'padEnd',
|
|
115
|
+
'repeat',
|
|
116
|
+
'concat',
|
|
117
|
+
'at',
|
|
118
|
+
'normalize',
|
|
119
|
+
'search',
|
|
120
|
+
'localeCompare',
|
|
121
|
+
// array (non-mutating)
|
|
122
|
+
'every',
|
|
123
|
+
'some',
|
|
124
|
+
'map',
|
|
125
|
+
'filter',
|
|
126
|
+
'reduce',
|
|
127
|
+
'reduceRight',
|
|
128
|
+
'find',
|
|
129
|
+
'findIndex',
|
|
130
|
+
'findLast',
|
|
131
|
+
'findLastIndex',
|
|
132
|
+
'flat',
|
|
133
|
+
'flatMap',
|
|
134
|
+
'join',
|
|
135
|
+
'keys',
|
|
136
|
+
'entries',
|
|
137
|
+
'values',
|
|
138
|
+
'forEach',
|
|
139
|
+
// regexp
|
|
140
|
+
'test',
|
|
141
|
+
'exec',
|
|
142
|
+
// number / shared
|
|
143
|
+
'toFixed',
|
|
144
|
+
'toPrecision',
|
|
145
|
+
'toString',
|
|
146
|
+
'valueOf',
|
|
147
|
+
'hasOwnProperty',
|
|
148
|
+
])
|
|
149
|
+
|
|
150
|
+
/** JS globals that perform IO / are nondeterministic / have side effects. */
|
|
151
|
+
const EFFECTFUL_GLOBALS = [
|
|
152
|
+
'fetch',
|
|
153
|
+
'XMLHttpRequest',
|
|
154
|
+
'WebSocket',
|
|
155
|
+
'Date',
|
|
156
|
+
'console',
|
|
157
|
+
'setTimeout',
|
|
158
|
+
'setInterval',
|
|
159
|
+
'requestAnimationFrame',
|
|
160
|
+
'queueMicrotask',
|
|
161
|
+
'localStorage',
|
|
162
|
+
'sessionStorage',
|
|
163
|
+
'indexedDB',
|
|
164
|
+
'document',
|
|
165
|
+
'window',
|
|
166
|
+
'globalThis',
|
|
167
|
+
'self',
|
|
168
|
+
'process',
|
|
169
|
+
'require',
|
|
170
|
+
'eval',
|
|
171
|
+
'Function',
|
|
172
|
+
'import',
|
|
173
|
+
'crypto',
|
|
174
|
+
'performance',
|
|
175
|
+
'navigator',
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Build the effectful-name set from a VM atom registry: every atom tagged
|
|
180
|
+
* `effects: 'io'`, plus the built-in JS IO globals. This is how the verifier
|
|
181
|
+
* consumes the atom-effects classification (the keystone).
|
|
182
|
+
*/
|
|
183
|
+
export function effectfulFromAtoms(
|
|
184
|
+
atoms: Record<string, { op?: string; effects?: 'pure' | 'io' }>
|
|
185
|
+
): Set<string> {
|
|
186
|
+
const set = new Set(EFFECTFUL_GLOBALS)
|
|
187
|
+
for (const [name, atom] of Object.entries(atoms)) {
|
|
188
|
+
if (atom?.effects === 'io') set.add(atom.op ?? name)
|
|
189
|
+
}
|
|
190
|
+
return set
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- the verifier -----------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Verify every top-level function declaration in `source` is predicate-safe.
|
|
197
|
+
* Returns all diagnostics; `safe` is true iff there are none (closure property).
|
|
198
|
+
*/
|
|
199
|
+
export function verifyPredicate(
|
|
200
|
+
source: string,
|
|
201
|
+
opts: VerifyPredicateOptions = {}
|
|
202
|
+
): PredicateVerifyResult {
|
|
203
|
+
const effectful = opts.effectful ?? new Set(EFFECTFUL_GLOBALS)
|
|
204
|
+
let ast: any
|
|
205
|
+
try {
|
|
206
|
+
ast = acorn.parse(source, { ecmaVersion: 'latest', locations: true })
|
|
207
|
+
} catch (e: any) {
|
|
208
|
+
return {
|
|
209
|
+
safe: false,
|
|
210
|
+
predicates: [],
|
|
211
|
+
diagnostics: [
|
|
212
|
+
{
|
|
213
|
+
predicate: '<source>',
|
|
214
|
+
message: `parse error: ${e.message}`,
|
|
215
|
+
line: e.loc?.line ?? 0,
|
|
216
|
+
column: e.loc?.column ?? 0,
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const predicateNames = new Set<string>(opts.knownPredicates ?? [])
|
|
223
|
+
for (const node of ast.body) {
|
|
224
|
+
if (node.type === 'FunctionDeclaration' && node.id)
|
|
225
|
+
predicateNames.add(node.id.name)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const diagnostics: PredicateDiagnostic[] = []
|
|
229
|
+
|
|
230
|
+
for (const fn of ast.body) {
|
|
231
|
+
if (fn.type !== 'FunctionDeclaration' || !fn.id) continue
|
|
232
|
+
const pname = fn.id.name
|
|
233
|
+
const flag = (message: string, n: any) =>
|
|
234
|
+
diagnostics.push({
|
|
235
|
+
predicate: pname,
|
|
236
|
+
message,
|
|
237
|
+
line: n?.loc?.start?.line ?? 0,
|
|
238
|
+
column: n?.loc?.start?.column ?? 0,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
const loop = (n: any) =>
|
|
242
|
+
flag(
|
|
243
|
+
'loops are not allowed — iterate with recursion or array methods (every/some/map/filter/reduce) so work stays fuel-bounded',
|
|
244
|
+
n
|
|
245
|
+
)
|
|
246
|
+
walk.simple(fn, {
|
|
247
|
+
AwaitExpression(n: any) {
|
|
248
|
+
flag('`await` not allowed — predicates must be synchronous', n)
|
|
249
|
+
},
|
|
250
|
+
NewExpression(n: any) {
|
|
251
|
+
flag('`new` not allowed in a predicate (non-pure construction)', n)
|
|
252
|
+
},
|
|
253
|
+
WhileStatement: loop,
|
|
254
|
+
DoWhileStatement: loop,
|
|
255
|
+
ForStatement: loop,
|
|
256
|
+
ForInStatement: loop,
|
|
257
|
+
ForOfStatement: loop,
|
|
258
|
+
CallExpression(n: any) {
|
|
259
|
+
const callee = n.callee
|
|
260
|
+
// Bare call: f(...)
|
|
261
|
+
if (callee.type === 'Identifier') {
|
|
262
|
+
const name = callee.name
|
|
263
|
+
if (effectful.has(name))
|
|
264
|
+
flag(`'${name}' is effectful — not allowed in a predicate`, callee)
|
|
265
|
+
else if (predicateNames.has(name)) {
|
|
266
|
+
/* composition with another predicate — OK */
|
|
267
|
+
} else if (PURE_GLOBALS.has(name)) {
|
|
268
|
+
/* pure global — OK */
|
|
269
|
+
} else {
|
|
270
|
+
flag(
|
|
271
|
+
`unknown reference '${name}' — not a predicate or pure builtin`,
|
|
272
|
+
callee
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
// Method call: recv.method(...)
|
|
278
|
+
if (callee.type === 'MemberExpression' && !callee.computed) {
|
|
279
|
+
const method = callee.property.name
|
|
280
|
+
const recv = callee.object
|
|
281
|
+
if (recv.type === 'Identifier' && effectful.has(recv.name)) {
|
|
282
|
+
flag(`'${recv.name}.${method}' is effectful`, callee)
|
|
283
|
+
} else if (
|
|
284
|
+
recv.type === 'Identifier' &&
|
|
285
|
+
PURE_NAMESPACES.has(recv.name)
|
|
286
|
+
) {
|
|
287
|
+
if (EFFECTFUL_STATICS.has(`${recv.name}.${method}`))
|
|
288
|
+
flag(`'${recv.name}.${method}' is nondeterministic`, callee)
|
|
289
|
+
// else pure namespace method — OK
|
|
290
|
+
} else if (!PURE_INSTANCE_METHODS.has(method)) {
|
|
291
|
+
flag(
|
|
292
|
+
`method '.${method}()' is not a known pure method`,
|
|
293
|
+
callee.property
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
// Anything else (computed member, call-of-call, etc.) — be conservative.
|
|
299
|
+
flag('unsupported call form in a predicate', callee)
|
|
300
|
+
},
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
safe: diagnostics.length === 0,
|
|
306
|
+
predicates: [...predicateNames],
|
|
307
|
+
diagnostics,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Format diagnostics for a thrown error / log. */
|
|
312
|
+
export function formatPredicateDiagnostics(d: PredicateDiagnostic[]): string {
|
|
313
|
+
return d
|
|
314
|
+
.map((x) => ` ${x.predicate} (${x.line}:${x.column}): ${x.message}`)
|
|
315
|
+
.join('\n')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// --- suggestion mining (#4: autocomplete companion) -------------------------
|
|
319
|
+
|
|
320
|
+
export interface Suggestion {
|
|
321
|
+
value: string
|
|
322
|
+
/**
|
|
323
|
+
* `'value'` — a concrete candidate (a keyword/literal the predicate accepts);
|
|
324
|
+
* `'stub'` — a partial completion to keep typing (e.g. `var(--`, `calc(`),
|
|
325
|
+
* mined from a `startsWith(...)` guard. TS's `string` fallback
|
|
326
|
+
* offers neither; a finite TS union offers only `'value'`.
|
|
327
|
+
*/
|
|
328
|
+
kind: 'value' | 'stub'
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export interface SuggestOptions extends CompilePredicateOptions {
|
|
332
|
+
/** Only return candidates relevant to the text typed so far. */
|
|
333
|
+
prefix?: string
|
|
334
|
+
/** Cap the number of suggestions (default 50). */
|
|
335
|
+
limit?: number
|
|
336
|
+
/**
|
|
337
|
+
* Run each mined *value* through the compiled entry predicate and keep only
|
|
338
|
+
* those that actually pass — so completions are guaranteed valid, not merely
|
|
339
|
+
* enumerated. Default true when the cluster is predicate-safe; stubs are never
|
|
340
|
+
* validated (they're partial by construction).
|
|
341
|
+
*/
|
|
342
|
+
validate?: boolean
|
|
343
|
+
/** Entry predicate to validate against (default: last top-level function). */
|
|
344
|
+
entry?: string
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const isStringLiteral = (n: any): n is { value: string } =>
|
|
348
|
+
n && n.type === 'Literal' && typeof n.value === 'string'
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Mine a predicate cluster's source for autocomplete candidates. Two sources:
|
|
352
|
+
* - **values** — string literals compared with `==`/`===` and members of
|
|
353
|
+
* array literals (the keyword sets a predicate checks membership against).
|
|
354
|
+
* - **stubs** — the argument of a `.startsWith(...)` guard, surfaced as a
|
|
355
|
+
* partial completion (`var(--`, `calc(`) the user keeps typing.
|
|
356
|
+
*
|
|
357
|
+
* Pure syntactic mining over the parsed source — no execution. By default the
|
|
358
|
+
* mined *values* are then run through the compiled entry predicate, so the
|
|
359
|
+
* returned set is exactly what the predicate accepts (a literal that only ever
|
|
360
|
+
* appears in a `!=` / negative context is dropped). This is the autocomplete
|
|
361
|
+
* win over a TS `string` fallback (which suggests nothing) and over a finite TS
|
|
362
|
+
* union (which can't offer the open-ended `var(--`/`calc(` stubs).
|
|
363
|
+
*/
|
|
364
|
+
export function suggest(
|
|
365
|
+
source: string,
|
|
366
|
+
opts: SuggestOptions = {}
|
|
367
|
+
): Suggestion[] {
|
|
368
|
+
let ast: any
|
|
369
|
+
try {
|
|
370
|
+
ast = acorn.parse(source, { ecmaVersion: 'latest' })
|
|
371
|
+
} catch {
|
|
372
|
+
return []
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const values = new Set<string>()
|
|
376
|
+
const stubs = new Set<string>()
|
|
377
|
+
walk.simple(ast, {
|
|
378
|
+
BinaryExpression(n: any) {
|
|
379
|
+
if (n.operator === '==' || n.operator === '===') {
|
|
380
|
+
if (isStringLiteral(n.left)) values.add(n.left.value)
|
|
381
|
+
if (isStringLiteral(n.right)) values.add(n.right.value)
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
ArrayExpression(n: any) {
|
|
385
|
+
for (const el of n.elements) if (isStringLiteral(el)) values.add(el.value)
|
|
386
|
+
},
|
|
387
|
+
CallExpression(n: any) {
|
|
388
|
+
const callee = n.callee
|
|
389
|
+
if (callee.type !== 'MemberExpression' || callee.computed) return
|
|
390
|
+
const method = callee.property.name
|
|
391
|
+
const arg = n.arguments[0]
|
|
392
|
+
if (!isStringLiteral(arg)) return
|
|
393
|
+
if (method === 'startsWith') stubs.add(arg.value)
|
|
394
|
+
// `.includes('x')` / `.endsWith('x')` aren't standalone completions:
|
|
395
|
+
// includes-arg is a substring, endsWith-arg is a tail — skip both.
|
|
396
|
+
},
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
// Validate mined values against the predicate unless told not to / unsafe.
|
|
400
|
+
let accept: ((v: string) => boolean) | null = null
|
|
401
|
+
if (opts.validate !== false) {
|
|
402
|
+
const verified = verifyPredicate(source, opts)
|
|
403
|
+
if (verified.safe && verified.predicates.length) {
|
|
404
|
+
const entry =
|
|
405
|
+
opts.entry ?? verified.predicates[verified.predicates.length - 1]
|
|
406
|
+
try {
|
|
407
|
+
const mod = compilePredicate(source, [entry], opts)
|
|
408
|
+
const fn = mod[entry]
|
|
409
|
+
accept = (v) => {
|
|
410
|
+
try {
|
|
411
|
+
return fn(v) === true
|
|
412
|
+
} catch {
|
|
413
|
+
return false // fuel exhaustion / runtime miss → not a suggestion
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} catch {
|
|
417
|
+
accept = null // not compilable → fall back to raw mining
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const out: Suggestion[] = []
|
|
423
|
+
for (const v of values)
|
|
424
|
+
if (!accept || accept(v)) out.push({ value: v, kind: 'value' })
|
|
425
|
+
for (const s of stubs) out.push({ value: s, kind: 'stub' })
|
|
426
|
+
|
|
427
|
+
let filtered = out
|
|
428
|
+
if (opts.prefix) {
|
|
429
|
+
const p = opts.prefix
|
|
430
|
+
filtered = out.filter(
|
|
431
|
+
(s) =>
|
|
432
|
+
s.value.startsWith(p) || (s.kind === 'stub' && p.startsWith(s.value))
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
filtered.sort(
|
|
436
|
+
(a, b) =>
|
|
437
|
+
(a.kind === b.kind ? 0 : a.kind === 'value' ? -1 : 1) ||
|
|
438
|
+
a.value.localeCompare(b.value)
|
|
439
|
+
)
|
|
440
|
+
return opts.limit ? filtered.slice(0, opts.limit) : filtered
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Thrown when a predicate exceeds its fuel budget (likely a pathological input). */
|
|
444
|
+
export class PredicateFuelExhausted extends Error {
|
|
445
|
+
constructor(budget: number) {
|
|
446
|
+
super(`predicate exceeded its fuel budget (${budget})`)
|
|
447
|
+
this.name = 'PredicateFuelExhausted'
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export interface CompilePredicateOptions extends VerifyPredicateOptions {
|
|
452
|
+
/** Max fuel per top-level predicate call (default 1,000,000). */
|
|
453
|
+
fuel?: number
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Inject `__fuel()` at every function-body entry and comma-wrap expression-body
|
|
458
|
+
* arrows, by splicing at source offsets (no codegen needed). Because loops are
|
|
459
|
+
* rejected, function-entry fuel bounds all iteration: recursion costs fuel per
|
|
460
|
+
* call, and array-method callbacks (`xs.every(p)`) cost fuel per element via the
|
|
461
|
+
* callback's own entry.
|
|
462
|
+
*/
|
|
463
|
+
function injectFuel(source: string): string {
|
|
464
|
+
const ast = acorn.parse(source, { ecmaVersion: 'latest' }) as any
|
|
465
|
+
// Each edit: [offset, text]. Applied descending so offsets stay valid.
|
|
466
|
+
const edits: Array<[number, string]> = []
|
|
467
|
+
const enterBlockBody = (n: any) => edits.push([n.body.start + 1, '__fuel();'])
|
|
468
|
+
walk.simple(ast, {
|
|
469
|
+
FunctionDeclaration: enterBlockBody,
|
|
470
|
+
FunctionExpression: enterBlockBody,
|
|
471
|
+
ArrowFunctionExpression(n: any) {
|
|
472
|
+
if (n.body.type === 'BlockStatement') {
|
|
473
|
+
edits.push([n.body.start + 1, '__fuel();'])
|
|
474
|
+
} else {
|
|
475
|
+
// expression-body arrow: `x => EXPR` → `x => (__fuel(), EXPR)`
|
|
476
|
+
edits.push([n.body.start, '(__fuel(), '])
|
|
477
|
+
edits.push([n.body.end, ')'])
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
})
|
|
481
|
+
edits.sort((a, b) => b[0] - a[0])
|
|
482
|
+
let out = source
|
|
483
|
+
for (const [off, text] of edits)
|
|
484
|
+
out = out.slice(0, off) + text + out.slice(off)
|
|
485
|
+
return out
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Verify, then compile the cluster to native synchronous JS functions —
|
|
490
|
+
* **fuel-bounded and global-shadowed**. Throws (with located diagnostics) at
|
|
491
|
+
* definition time if not predicate-safe.
|
|
492
|
+
*
|
|
493
|
+
* Each compiled predicate runs with a fresh fuel budget; a runaway input throws
|
|
494
|
+
* `PredicateFuelExhausted` rather than hanging. The effectful globals are
|
|
495
|
+
* shadowed to `undefined` as defense-in-depth beneath the static verifier.
|
|
496
|
+
*
|
|
497
|
+
* NOTE: preserves JS semantics (no structural-`==` rewrite yet — a future
|
|
498
|
+
* opt-in). Emission is offset-spliced source, not a full AJS-AST→JS codegen.
|
|
499
|
+
*/
|
|
500
|
+
export function compilePredicate(
|
|
501
|
+
source: string,
|
|
502
|
+
exportNames: string[],
|
|
503
|
+
opts: CompilePredicateOptions = {}
|
|
504
|
+
): Record<string, (...args: any[]) => any> {
|
|
505
|
+
const result = verifyPredicate(source, opts)
|
|
506
|
+
if (!result.safe)
|
|
507
|
+
throw new Error(
|
|
508
|
+
`Not predicate-safe:\n${formatPredicateDiagnostics(result.diagnostics)}`
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
const budget = opts.fuel ?? 1_000_000
|
|
512
|
+
const instrumented = injectFuel(source)
|
|
513
|
+
|
|
514
|
+
// Shadow the effectful globals to undefined (defense-in-depth under the
|
|
515
|
+
// verifier), and inject the fuel hook. `new Function` params shadow globals.
|
|
516
|
+
// Exclude reserved words that can't be parameter names (still verifier-rejected).
|
|
517
|
+
const shadowed = EFFECTFUL_GLOBALS.filter(
|
|
518
|
+
(g) => g !== 'import' && g !== 'eval' && g !== 'arguments'
|
|
519
|
+
)
|
|
520
|
+
const factory = new Function(
|
|
521
|
+
'__fuel',
|
|
522
|
+
...shadowed,
|
|
523
|
+
`"use strict";\n${instrumented}\n;return { ${exportNames.join(', ')} };`
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
let fuel = 0
|
|
527
|
+
const fuelHook = () => {
|
|
528
|
+
if (--fuel < 0) throw new PredicateFuelExhausted(budget)
|
|
529
|
+
}
|
|
530
|
+
const raw = factory(fuelHook, ...shadowed.map(() => undefined))
|
|
531
|
+
|
|
532
|
+
// Each top-level call gets a fresh budget; inner composed calls share it.
|
|
533
|
+
// A stack overflow (deep recursion past the JS frame limit before fuel runs
|
|
534
|
+
// out) is the same "runaway" signal, so normalize it to PredicateFuelExhausted.
|
|
535
|
+
const wrapped: Record<string, (...args: any[]) => any> = {}
|
|
536
|
+
for (const name of exportNames) {
|
|
537
|
+
const fn = raw[name]
|
|
538
|
+
wrapped[name] = (...args: any[]) => {
|
|
539
|
+
fuel = budget
|
|
540
|
+
try {
|
|
541
|
+
return fn(...args)
|
|
542
|
+
} catch (e) {
|
|
543
|
+
if (e instanceof RangeError && /stack/i.test(e.message))
|
|
544
|
+
throw new PredicateFuelExhausted(budget)
|
|
545
|
+
throw e
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return wrapped
|
|
550
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { tjs, transpile } from './index'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Guards the language subset invariants engraved in PRINCIPLES.md:
|
|
6
|
+
*
|
|
7
|
+
* AJS ⊆ TJS — every legal AJS source is legal TJS source
|
|
8
|
+
* JS ⊆ TJS (no modes) — every legal JS program is legal TJS
|
|
9
|
+
*
|
|
10
|
+
* TJS may do MORE with the same source (enforce contracts, run signature
|
|
11
|
+
* tests) but must never REJECT source the subset accepts. The classic way this
|
|
12
|
+
* breaks: a build-time signature test that can't *run* (it calls an AJS atom
|
|
13
|
+
* that doesn't exist at build time, etc.) gets escalated into a transpile
|
|
14
|
+
* error. Such tests must be *inconclusive*, never failing.
|
|
15
|
+
*/
|
|
16
|
+
describe('Language subset invariants (PRINCIPLES.md)', () => {
|
|
17
|
+
// Representative AJS-shaped sources. Each must be valid AJS (transpile to a
|
|
18
|
+
// VM AST) AND valid TJS (tjs() must not throw). Several carry return types
|
|
19
|
+
// and call atoms — the exact shape that used to be illegal TJS.
|
|
20
|
+
const ajsSnippets: Array<[string, string]> = [
|
|
21
|
+
[
|
|
22
|
+
'agent returning an object (no types)',
|
|
23
|
+
`function main(n: 0) {\n return { doubled: n * 2 }\n}`,
|
|
24
|
+
],
|
|
25
|
+
[
|
|
26
|
+
'atom call + return type',
|
|
27
|
+
`function main(url: ''): { x: '' } {\n const x = httpFetch({ url })\n return { x }\n}`,
|
|
28
|
+
],
|
|
29
|
+
[
|
|
30
|
+
'helper with a typed signature',
|
|
31
|
+
`function double(x: 0): 0 {\n return x * 2\n}\nfunction main(n: 0) {\n const d = double(n)\n return { d }\n}`,
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
'helper that calls an atom + return type',
|
|
35
|
+
`function fetchIt(u: ''): '' {\n const r = httpFetch({ url: u })\n return r\n}\nfunction main(url: '') {\n const x = fetchIt(url)\n return { x }\n}`,
|
|
36
|
+
],
|
|
37
|
+
[
|
|
38
|
+
'consistent signature example still validates',
|
|
39
|
+
`function add(a: 2, b: 3): 5 {\n return a + b\n}\nfunction main(x: 0, y: 0) {\n const s = add(x, y)\n return { s }\n}`,
|
|
40
|
+
],
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
describe('TJS ⊇ AJS', () => {
|
|
44
|
+
for (const [label, src] of ajsSnippets) {
|
|
45
|
+
it(`valid as both AJS and TJS: ${label}`, () => {
|
|
46
|
+
// Valid AJS (produces a VM AST)…
|
|
47
|
+
expect(() => transpile(src, { vmTarget: true })).not.toThrow()
|
|
48
|
+
// …and therefore must be valid TJS (never rejected).
|
|
49
|
+
expect(() => tjs(src)).not.toThrow()
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('un-runnable signature tests are inconclusive, not failures', () => {
|
|
55
|
+
const r = tjs(
|
|
56
|
+
`function main(url: ''): { x: '' } {\n const x = httpFetch({ url })\n return { x }\n}`
|
|
57
|
+
)
|
|
58
|
+
const sig = r.testResults?.find((t: any) => t.isSignatureTest)
|
|
59
|
+
expect(sig).toBeDefined()
|
|
60
|
+
expect(sig?.passed).toBe(false)
|
|
61
|
+
expect(sig?.inconclusive).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('still REJECTS a genuinely inconsistent signature example (validation intact)', () => {
|
|
65
|
+
// 2 + 3 = 5, not 99 — the test runs cleanly and mismatches → hard failure.
|
|
66
|
+
expect(() =>
|
|
67
|
+
tjs(`function add(a: 2, b: 3): 99 {\n return a + b\n}`)
|
|
68
|
+
).toThrow(/inconsistent/)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('TJS (no modes) ⊇ JS', () => {
|
|
72
|
+
// Plain JavaScript under options-off TJS (TjsCompat disables all modes).
|
|
73
|
+
const jsSnippets: Array<[string, string]> = [
|
|
74
|
+
['arithmetic fn', `function f(x) { return x + 1 }`],
|
|
75
|
+
[
|
|
76
|
+
'control flow + array methods',
|
|
77
|
+
`function f(xs) {\n let total = 0\n for (const x of xs) { total += x }\n return xs.map(v => v * 2).filter(v => v > total)\n}`,
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
'object + destructuring',
|
|
81
|
+
`function f(o) {\n const { a, b } = o\n return { ...o, sum: a + b }\n}`,
|
|
82
|
+
],
|
|
83
|
+
]
|
|
84
|
+
for (const [label, src] of jsSnippets) {
|
|
85
|
+
it(`accepts plain JS: ${label}`, () => {
|
|
86
|
+
expect(() => tjs(`TjsCompat\n${src}`)).not.toThrow()
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
})
|