tjs-lang 0.8.5 → 0.8.7
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/bin/docs.js +3 -2
- package/demo/docs.json +2 -2
- package/dist/index.js +20 -20
- package/dist/index.js.map +3 -3
- package/dist/tjs-browser-from-ts.js +21 -21
- package/dist/tjs-browser-from-ts.js.map +3 -3
- package/dist/tjs-browser.js +3 -3
- package/dist/tjs-browser.js.map +3 -3
- package/dist/tjs-eval.js +2 -2
- package/dist/tjs-eval.js.map +3 -3
- package/dist/tjs-from-ts.js +18 -18
- package/dist/tjs-from-ts.js.map +3 -3
- package/dist/tjs-lang.js +3 -3
- package/dist/tjs-lang.js.map +3 -3
- package/dist/tjs-vm.js +2 -2
- package/dist/tjs-vm.js.map +3 -3
- package/package.json +1 -1
- 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/emitters/from-ts.ts +52 -18
- package/src/lang/from-ts.test.ts +48 -0
- package/src/lang/parser-transforms.ts +11 -4
- package/src/lang/parser.ts +13 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tjs-lang",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.7",
|
|
4
4
|
"description": "Type-safe JavaScript dialect with runtime validation, sandboxed VM execution, and AI agent orchestration. Transpiles TypeScript to validated JS with fuel-metered execution for untrusted code.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { tjs } from './index'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `transformBareAssignments` auto-const's a FIRST bare assignment to an
|
|
6
|
+
* uppercase identifier (native-TJS convenience: `Foo = Type(...)` → `const Foo`).
|
|
7
|
+
* Two invariants it must respect — both were broken and are regression-guarded
|
|
8
|
+
* here (a tosijs live-example crashed because `let B = null; … B = BABYLON`
|
|
9
|
+
* became `const B = BABYLON`, shadowing the outer `B`):
|
|
10
|
+
* 1. It is a TJS feature — must NOT touch plain JS (dialect: 'js'). TJS ⊇ JS.
|
|
11
|
+
* 2. Even in TJS, it must NOT redeclare an already-declared binding.
|
|
12
|
+
*/
|
|
13
|
+
const code = (src: string, dialect: 'js' | 'tjs') =>
|
|
14
|
+
tjs(src, { dialect, runTests: false }).code
|
|
15
|
+
|
|
16
|
+
describe('bare assignments (auto-const) — TJS-only, first-assignment-only', () => {
|
|
17
|
+
it('dialect:js — a reassignment of a declared uppercase let is untouched', () => {
|
|
18
|
+
const out = code('let B = null\nfunction f() { B = 1 }', 'js')
|
|
19
|
+
expect(out).not.toContain('const B = 1')
|
|
20
|
+
expect(out).toContain('B = 1')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('dialect:js — even an undeclared uppercase assignment (implicit global) is untouched', () => {
|
|
24
|
+
expect(code('Foo = 1', 'js')).not.toContain('const Foo')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('dialect:js — the reported repro: B = BABYLON inside a method stays an assignment', () => {
|
|
28
|
+
const out = code(
|
|
29
|
+
'let B = null\nconst s = g({ m(el, BABYLON) { B = BABYLON } })',
|
|
30
|
+
'js'
|
|
31
|
+
)
|
|
32
|
+
expect(out).not.toContain('const B = BABYLON')
|
|
33
|
+
expect(out).toContain('B = BABYLON')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('dialect:tjs — reassignment of a declared binding is preserved', () => {
|
|
37
|
+
const out = code('let B = null\nconst s = g({ m() { B = 1 } })', 'tjs')
|
|
38
|
+
expect(out).not.toContain('const B = 1')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('dialect:tjs — the feature still fires for a FIRST assignment of an undeclared uppercase name', () => {
|
|
42
|
+
expect(code('Foo = { debug: true }', 'tjs')).toContain('const Foo')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('never rewrites lowercase identifiers (only uppercase convention)', () => {
|
|
46
|
+
expect(code('foo = 1', 'tjs')).not.toContain('const foo')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { tjs } from './index'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A doc comment (`/*#` … or JSDoc `/**` …) is only a doc comment when it starts
|
|
6
|
+
* a line — whitespace-only before the `/`. A `/*#` after code on the line, or
|
|
7
|
+
* inside a string literal, is an ordinary block comment and must be ignored (not
|
|
8
|
+
* extracted as documentation). Enforced by a line-start lookbehind on every
|
|
9
|
+
* doc-comment matcher (docs.ts, from-ts, parser, bin/docs.js).
|
|
10
|
+
*/
|
|
11
|
+
const descOf = (src: string) =>
|
|
12
|
+
tjs(src, { dialect: 'js', runTests: false }).metadata?.greet?.description
|
|
13
|
+
|
|
14
|
+
describe('doc comments must start a line (whitespace-only before the slash)', () => {
|
|
15
|
+
const fn = '\nfunction greet(name) { return name }'
|
|
16
|
+
|
|
17
|
+
it('extracts a line-start /*# doc comment', () => {
|
|
18
|
+
expect(descOf('/*#\nMy docs\n*/' + fn)).toContain('My docs')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('extracts an indented (own-line) /*# doc comment', () => {
|
|
22
|
+
expect(descOf(' /*#\n Indented docs\n */' + fn)).toContain('Indented')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('ignores a /*# that follows code on the same line', () => {
|
|
26
|
+
expect(descOf('const x = 1 /*# not a doc */' + fn)).toBeUndefined()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('ignores a /*# inside a string literal', () => {
|
|
30
|
+
expect(descOf('const s = "/*# not a doc */"' + fn)).toBeUndefined()
|
|
31
|
+
})
|
|
32
|
+
})
|
package/src/lang/docs.ts
CHANGED
|
@@ -169,8 +169,12 @@ export function generateDocs(source: string): DocResult {
|
|
|
169
169
|
// Two doc-block flavors are recognized:
|
|
170
170
|
// /*# ... */ — TJS native: content is markdown verbatim
|
|
171
171
|
// /** ... */ — JSDoc: each line's leading ` * ` is stripped, then markdown
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
// Only a line-start doc comment (whitespace-only before the `/`) counts; a
|
|
173
|
+
// `/*#` or `/**` after code on the line — or inside a string — is an ordinary
|
|
174
|
+
// block comment, not documentation. The lookbehind keeps match.index on the
|
|
175
|
+
// `/` (no position shift for callers).
|
|
176
|
+
const docPattern = /(?<=^[ \t]*)\/\*#([\s\S]*?)\*\//gm
|
|
177
|
+
const jsdocPattern = /(?<=^[ \t]*)\/\*\*([\s\S]*?)\*\//gm
|
|
174
178
|
// Match the START of a function declaration. Params (which can contain
|
|
175
179
|
// nested parens like `fn = (x) => x`) are captured by balanced-paren
|
|
176
180
|
// scanning below, NOT by this regex.
|
|
@@ -1095,19 +1095,33 @@ function transformGenericInterfaceToGeneric(
|
|
|
1095
1095
|
if (declarationAnnotation?.text) {
|
|
1096
1096
|
parts.push(`declaration ${declarationAnnotation.text}`)
|
|
1097
1097
|
} else {
|
|
1098
|
-
// Auto-generate declaration block from interface members
|
|
1098
|
+
// Auto-generate declaration block from interface members. Each member's
|
|
1099
|
+
// type is converted to a TJS example via `typeToExample` (the same converter
|
|
1100
|
+
// the non-generic interface path uses) rather than emitted as raw TS — so
|
|
1101
|
+
// `path: string` → `path: ''`, `touch: () => void` → a FunctionPredicate
|
|
1102
|
+
// example, and un-representable shapes (call-signature objects, etc.) degrade
|
|
1103
|
+
// to a safe placeholder instead of producing TJS that won't re-parse.
|
|
1104
|
+
// (The `@tjs declaration { … }` annotation path above is verbatim and
|
|
1105
|
+
// unaffected — authors can still hand-write precise type signatures.)
|
|
1099
1106
|
const declMembers: string[] = []
|
|
1100
1107
|
for (const member of node.members) {
|
|
1101
1108
|
if (ts.isPropertySignature(member) && member.name) {
|
|
1102
1109
|
const propName = member.name.getText(sourceFile)
|
|
1103
1110
|
const optional = member.questionToken ? '?' : ''
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1111
|
+
const example = member.type
|
|
1112
|
+
? typeToExample(member.type, undefined, warnings)
|
|
1113
|
+
: 'null'
|
|
1114
|
+
declMembers.push(`${propName}${optional}: ${example}`)
|
|
1106
1115
|
} else if (ts.isMethodSignature(member) && member.name) {
|
|
1107
|
-
// Method
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1116
|
+
// Method → a function example built from its return type (params are
|
|
1117
|
+
// descriptive; the predicate above does the actual runtime check).
|
|
1118
|
+
const propName = member.name.getText(sourceFile)
|
|
1119
|
+
const ret = member.type
|
|
1120
|
+
? typeToExample(member.type, undefined, warnings)
|
|
1121
|
+
: 'null'
|
|
1122
|
+
declMembers.push(
|
|
1123
|
+
`${propName}: FunctionPredicate('function', { returns: ${ret} })`
|
|
1124
|
+
)
|
|
1111
1125
|
}
|
|
1112
1126
|
}
|
|
1113
1127
|
if (declMembers.length > 0) {
|
|
@@ -1288,10 +1302,17 @@ function transformTypeAliasToType(
|
|
|
1288
1302
|
|
|
1289
1303
|
const example = typeToExample(node.type, undefined, warnings)
|
|
1290
1304
|
|
|
1291
|
-
// 'any' and 'undefined' —
|
|
1305
|
+
// 'any' and 'undefined' — un-representable in TJS (intersections with
|
|
1306
|
+
// `typeof`/index signatures, etc.). Degrade to an empty Type (validates as
|
|
1307
|
+
// anything) and preserve the original TS body as a SINGLE-LINE comment so the
|
|
1308
|
+
// DTS emitter can recover it. The collapse is essential: a multi-line type
|
|
1309
|
+
// body would leave lines 2+ uncommented = raw TS leaking into the block =
|
|
1310
|
+
// unparseable. (Representable shapes never reach here — they convert above.)
|
|
1292
1311
|
if (example === 'any' || example === 'undefined') {
|
|
1293
|
-
const originalType = node.type
|
|
1294
|
-
|
|
1312
|
+
const originalType = node.type
|
|
1313
|
+
.getText(sourceFile)
|
|
1314
|
+
.trim()
|
|
1315
|
+
.replace(/\s+/g, ' ')
|
|
1295
1316
|
return `Type ${typeName} {\n // TS: ${originalType}\n}`
|
|
1296
1317
|
}
|
|
1297
1318
|
|
|
@@ -1423,25 +1444,35 @@ function transformGenericTypeAliasToGeneric(
|
|
|
1423
1444
|
const typeBody = node.type
|
|
1424
1445
|
|
|
1425
1446
|
if (typeBody && ts.isTypeLiteralNode(typeBody)) {
|
|
1426
|
-
// Object type literal: { item: T; count: number }
|
|
1447
|
+
// Object type literal: { item: T; count: number }. Convert each member's
|
|
1448
|
+
// type to a TJS example (degrades gracefully) rather than emitting raw TS.
|
|
1427
1449
|
const declMembers: string[] = []
|
|
1428
1450
|
for (const member of typeBody.members) {
|
|
1429
1451
|
if (ts.isPropertySignature(member) && member.name) {
|
|
1430
1452
|
const propName = member.name.getText(sourceFile)
|
|
1431
1453
|
const optional = member.questionToken ? '?' : ''
|
|
1432
|
-
const
|
|
1433
|
-
|
|
1454
|
+
const example = member.type
|
|
1455
|
+
? typeToExample(member.type, undefined, warnings)
|
|
1456
|
+
: 'null'
|
|
1457
|
+
declMembers.push(`${propName}${optional}: ${example}`)
|
|
1434
1458
|
} else if (ts.isMethodSignature(member) && member.name) {
|
|
1435
|
-
|
|
1459
|
+
const propName = member.name.getText(sourceFile)
|
|
1460
|
+
const ret = member.type
|
|
1461
|
+
? typeToExample(member.type, undefined, warnings)
|
|
1462
|
+
: 'null'
|
|
1463
|
+
declMembers.push(
|
|
1464
|
+
`${propName}: FunctionPredicate('function', { returns: ${ret} })`
|
|
1465
|
+
)
|
|
1436
1466
|
}
|
|
1437
1467
|
}
|
|
1438
1468
|
if (declMembers.length > 0) {
|
|
1439
1469
|
parts.push(`declaration {\n ${declMembers.join('\n ')}\n }`)
|
|
1440
1470
|
}
|
|
1441
1471
|
} else if (typeBody) {
|
|
1442
|
-
// Complex type (conditional, mapped, intersection,
|
|
1443
|
-
//
|
|
1444
|
-
|
|
1472
|
+
// Complex type (conditional, mapped, intersection, …) — un-representable.
|
|
1473
|
+
// Keep the TS body as a SINGLE-LINE comment (collapse newlines, else lines
|
|
1474
|
+
// 2+ leak as raw TS and won't re-parse).
|
|
1475
|
+
const typeText = typeBody.getText(sourceFile).trim().replace(/\s+/g, ' ')
|
|
1445
1476
|
parts.push(`declaration {\n // TS: ${typeText}\n }`)
|
|
1446
1477
|
}
|
|
1447
1478
|
}
|
|
@@ -2302,7 +2333,10 @@ function extractDocComments(
|
|
|
2302
2333
|
source: string
|
|
2303
2334
|
): Array<{ content: string; index: number }> {
|
|
2304
2335
|
const comments: Array<{ content: string; index: number }> = []
|
|
2305
|
-
|
|
2336
|
+
// Line-start `/*#` only (whitespace-only before it); a mid-line `/*#` (after
|
|
2337
|
+
// code, or inside a string) is an ordinary block comment. Lookbehind is
|
|
2338
|
+
// zero-width, so match.index stays on `/*#` for the brace-depth check below.
|
|
2339
|
+
const docRegex = /(?<=^[ \t]*)\/\*#[\s\S]*?\*\//gm
|
|
2306
2340
|
|
|
2307
2341
|
// Track brace depth to identify top-level comments
|
|
2308
2342
|
let braceDepth = 0
|
package/src/lang/from-ts.test.ts
CHANGED
|
@@ -312,3 +312,51 @@ describe('@tjs annotations', () => {
|
|
|
312
312
|
})
|
|
313
313
|
})
|
|
314
314
|
})
|
|
315
|
+
|
|
316
|
+
// Regression: TS→TJS must never emit TJS that won't re-parse. These real-world
|
|
317
|
+
// shapes (from tosijs) used to leak raw TS into Type/Generic blocks — generic
|
|
318
|
+
// interfaces emitted raw member types in `declaration {}`, and intersections /
|
|
319
|
+
// complex types emitted MULTI-LINE `// TS:` comments whose lines 2+ leaked as
|
|
320
|
+
// raw TS. fromTS now converts members via typeToExample and collapses
|
|
321
|
+
// un-representable bodies to a single-line comment (graceful degradation).
|
|
322
|
+
describe('TS→TJS round-trips (no raw-TS leak into TJS blocks)', () => {
|
|
323
|
+
const { tjs } = require('./index') as { tjs: (s: string) => { code: string } }
|
|
324
|
+
const roundTrips = (ts: string) => {
|
|
325
|
+
const emitted = fromTS(ts, { emitTJS: true }).code
|
|
326
|
+
expect(() => tjs(emitted)).not.toThrow() // emitted TJS must re-parse
|
|
327
|
+
return emitted
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
it('generic interface with complex members (arrow, generic arrow, call-sig)', () => {
|
|
331
|
+
const out = roundTrips(`export interface Acc<T = any> {
|
|
332
|
+
value: T
|
|
333
|
+
path: string
|
|
334
|
+
touch: () => void
|
|
335
|
+
bind: <E extends Element = Element>(el: E, b: any) => void
|
|
336
|
+
find: { (selector: (item: any) => any, value: any): any }
|
|
337
|
+
}`)
|
|
338
|
+
// members converted to examples, not raw TS
|
|
339
|
+
expect(out).toContain("path: ''")
|
|
340
|
+
expect(out).toContain('touch: FunctionPredicate')
|
|
341
|
+
expect(out).not.toMatch(/path: string\b/) // no raw type leak
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('intersection type alias (typeof / index signature) degrades, single-line', () => {
|
|
345
|
+
const out = roundTrips(`export type ProxyObj = Props<object> & {
|
|
346
|
+
[key: string]: ProxyObj | string | null
|
|
347
|
+
}`)
|
|
348
|
+
// un-representable → comment-only Type, collapsed to one line (no leak)
|
|
349
|
+
expect(out).toMatch(/\/\/ TS:.*&/)
|
|
350
|
+
expect(out).not.toMatch(/\n\s*\[key: string\]:/) // the body didn't leak raw
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('generic type alias with object body + arrow member', () => {
|
|
354
|
+
roundTrips(`export type Wrap<T> = { value: T; build: (x: T) => T }`)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('plain type alias with arrow + union return still works', () => {
|
|
358
|
+
roundTrips(
|
|
359
|
+
`export type AnyFunction = (...args: any[]) => any | Promise<any>`
|
|
360
|
+
)
|
|
361
|
+
})
|
|
362
|
+
})
|
|
@@ -3126,12 +3126,19 @@ ${branches.join('\n')}
|
|
|
3126
3126
|
* where the identifier starts with uppercase (to avoid breaking normal assignments)
|
|
3127
3127
|
*/
|
|
3128
3128
|
export function transformBareAssignments(source: string): string {
|
|
3129
|
+
// A bare `Foo = …` is auto-const'd only on FIRST assignment. If the name is
|
|
3130
|
+
// already declared (let/const/var/function/class) anywhere in the source, the
|
|
3131
|
+
// statement is a REASSIGNMENT — leave it alone, or we'd emit a duplicate/
|
|
3132
|
+
// shadowing `const` (e.g. `let B = null; … B = x` → wrongly `const B = x`).
|
|
3133
|
+
const declared = new Set<string>()
|
|
3134
|
+
const declRe = /\b(?:let|const|var|function|class)\s+([A-Z][a-zA-Z0-9_]*)/g
|
|
3135
|
+
for (let m; (m = declRe.exec(source)); ) declared.add(m[1])
|
|
3136
|
+
|
|
3129
3137
|
// Match: start of line/statement, uppercase identifier, =, not ==
|
|
3130
|
-
// Negative lookbehind for const/let/var to avoid double-declaring
|
|
3131
3138
|
return source.replace(
|
|
3132
|
-
/(?<=^|[;\n{])\s*([A-Z][a-zA-Z0-9_]*)\s*=(?!=)/gm,
|
|
3133
|
-
(match, name) => {
|
|
3134
|
-
|
|
3139
|
+
/(?<=^|[;\n{])(\s*)([A-Z][a-zA-Z0-9_]*)\s*=(?!=)/gm,
|
|
3140
|
+
(match, _ws, name) => {
|
|
3141
|
+
if (declared.has(name)) return match // reassignment of a declared binding
|
|
3135
3142
|
return match.replace(name, `const ${name}`)
|
|
3136
3143
|
}
|
|
3137
3144
|
)
|
package/src/lang/parser.ts
CHANGED
|
@@ -304,9 +304,14 @@ export function preprocess(
|
|
|
304
304
|
source = transformUnionDeclarations(source)
|
|
305
305
|
source = transformEnumDeclarations(source)
|
|
306
306
|
|
|
307
|
-
// Transform bare assignments to const declarations
|
|
308
|
-
// Foo = ... -> const Foo = ...
|
|
309
|
-
|
|
307
|
+
// Transform bare assignments to const declarations (native-TJS convenience):
|
|
308
|
+
// Foo = ... -> const Foo = ... Gated by TjsSafeAssign — OFF for plain JS
|
|
309
|
+
// (dialect: 'js'), TS-originated, and VM targets, so a JS reassignment like
|
|
310
|
+
// `B = value` (of an already-declared `let B`) is never rewritten. See
|
|
311
|
+
// PRINCIPLES.md (TJS ⊇ JS): plain JS must pass through unchanged.
|
|
312
|
+
if (tjsModes.tjsSafeAssign) {
|
|
313
|
+
source = transformBareAssignments(source)
|
|
314
|
+
}
|
|
310
315
|
|
|
311
316
|
// Phase 3: cross-file wasm-function composition. When a ModuleLoader is
|
|
312
317
|
// supplied, resolve `import { ... } from '<spec>'` statements at transpile
|
|
@@ -737,7 +742,11 @@ export function extractTDoc(
|
|
|
737
742
|
// This preserves full markdown content
|
|
738
743
|
// Find the LAST /*# ... */ block and verify it immediately precedes the function
|
|
739
744
|
// (only whitespace and line comments allowed between)
|
|
740
|
-
|
|
745
|
+
// Line-start `/*#` only — a `/*#` after code (or in a string) isn't a doc
|
|
746
|
+
// comment. Lookbehind keeps match.index/length on the `/*#…*/` span.
|
|
747
|
+
const allDocBlocks = [
|
|
748
|
+
...beforeFunc.matchAll(/(?<=^[ \t]*)\/\*#([\s\S]*?)\*\//gm),
|
|
749
|
+
]
|
|
741
750
|
if (allDocBlocks.length > 0) {
|
|
742
751
|
const lastBlock = allDocBlocks[allDocBlocks.length - 1]
|
|
743
752
|
const afterBlock = beforeFunc.substring(
|