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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tjs-lang/css` phase 4 — recursive style-object structure + the `$predicate`
|
|
3
|
+
* JSON-Schema. The thesis end-to-end: one serializable schema, validated two
|
|
4
|
+
* ways — naive (structure only) vs predicate-aware (the full recursive grammar).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from 'bun:test'
|
|
7
|
+
import {
|
|
8
|
+
compilePredicateSchema,
|
|
9
|
+
validatePredicateSchema,
|
|
10
|
+
} from '../lang/predicate-schema'
|
|
11
|
+
import {
|
|
12
|
+
isStyleObject,
|
|
13
|
+
isStyleValue,
|
|
14
|
+
isCssProperty,
|
|
15
|
+
isSelectorOrAtRule,
|
|
16
|
+
cssStyleSchema,
|
|
17
|
+
cssColorSchema,
|
|
18
|
+
verifyCss,
|
|
19
|
+
} from './index'
|
|
20
|
+
|
|
21
|
+
describe('the combined style source verifies safe', () => {
|
|
22
|
+
it('color + dimension + recursive structure all predicate-safe', () => {
|
|
23
|
+
const r = verifyCss()
|
|
24
|
+
if (!r.safe) console.error(r.diagnostics)
|
|
25
|
+
expect(r.safe).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('key classification', () => {
|
|
30
|
+
it('isCssProperty', () => {
|
|
31
|
+
expect(isCssProperty('color')).toBe(true)
|
|
32
|
+
expect(isCssProperty('-webkit-box-shadow')).toBe(true)
|
|
33
|
+
expect(isCssProperty('--brand')).toBe(true)
|
|
34
|
+
expect(isCssProperty('&:hover')).toBe(false)
|
|
35
|
+
expect(isCssProperty('.card')).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
it('isSelectorOrAtRule', () => {
|
|
38
|
+
expect(isSelectorOrAtRule('&:hover')).toBe(true)
|
|
39
|
+
expect(isSelectorOrAtRule('.card')).toBe(true)
|
|
40
|
+
expect(isSelectorOrAtRule('> a')).toBe(true)
|
|
41
|
+
expect(isSelectorOrAtRule('@media (min-width: 600px)')).toBe(true)
|
|
42
|
+
expect(isSelectorOrAtRule('[data-x]')).toBe(true)
|
|
43
|
+
expect(isSelectorOrAtRule('color')).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('isStyleObject validates the recursive structure', () => {
|
|
48
|
+
it('accepts a nested style object', () => {
|
|
49
|
+
const spec = {
|
|
50
|
+
color: 'red',
|
|
51
|
+
padding: '10px',
|
|
52
|
+
'--gap': '4px',
|
|
53
|
+
'&:hover': { color: '#00f', background: 'var(--surface)' },
|
|
54
|
+
'@media (min-width: 600px)': {
|
|
55
|
+
display: 'grid',
|
|
56
|
+
'.card': { boxShadow: '0 1px 2px rgba(0,0,0,0.2)' },
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
expect(isStyleObject(spec)).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
it('rejects a non-object', () => {
|
|
62
|
+
expect(isStyleObject('red')).toBe(false)
|
|
63
|
+
expect(isStyleObject(null)).toBe(false)
|
|
64
|
+
expect(isStyleObject(42)).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
it('rejects a property whose value is the wrong type', () => {
|
|
67
|
+
expect(isStyleObject({ color: { nested: 'x' } })).toBe(false) // property → not an object
|
|
68
|
+
expect(isStyleObject({ color: '' })).toBe(false) // empty value
|
|
69
|
+
})
|
|
70
|
+
it('rejects a selector whose value is not a nested rule', () => {
|
|
71
|
+
expect(isStyleObject({ '&:hover': 'red' })).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
it('rejects a key that is neither property nor selector', () => {
|
|
74
|
+
expect(isStyleObject({ '': 'red' })).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('isStyleValue (leaf)', () => {
|
|
79
|
+
it('accepts known + permissive values', () => {
|
|
80
|
+
expect(isStyleValue('red')).toBe(true) // color
|
|
81
|
+
expect(isStyleValue('10px')).toBe(true) // dimension
|
|
82
|
+
expect(isStyleValue('1px solid red')).toBe(true) // shorthand → permissive tail
|
|
83
|
+
expect(isStyleValue(0)).toBe(true) // finite number
|
|
84
|
+
})
|
|
85
|
+
it('rejects empty / non-primitive', () => {
|
|
86
|
+
expect(isStyleValue('')).toBe(false)
|
|
87
|
+
expect(isStyleValue({})).toBe(false)
|
|
88
|
+
expect(isStyleValue(Infinity)).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('$predicate schema — progressive enhancement', () => {
|
|
93
|
+
const schema = cssStyleSchema()
|
|
94
|
+
const good = { color: 'red', '&:hover': { color: 'blue' } }
|
|
95
|
+
const structurallyOkButBadGrammar = { ' not a prop ': 'red' } // is an object, but a bad key
|
|
96
|
+
|
|
97
|
+
it('naive validator (structure only) checks just `type: object`', () => {
|
|
98
|
+
const naive = compilePredicateSchema(schema, { ignorePredicates: true })
|
|
99
|
+
expect(naive(good).valid).toBe(true)
|
|
100
|
+
// a plain object passes structure even with an invalid key...
|
|
101
|
+
expect(naive(structurallyOkButBadGrammar).valid).toBe(true)
|
|
102
|
+
// ...but a non-object still fails the structural `type`.
|
|
103
|
+
expect(naive('nope').valid).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('predicate-aware validator runs the full recursive grammar', () => {
|
|
107
|
+
const aware = compilePredicateSchema(schema)
|
|
108
|
+
expect(aware(good).valid).toBe(true)
|
|
109
|
+
// now the bad key is caught by $predicate where the naive one passed it
|
|
110
|
+
expect(aware(structurallyOkButBadGrammar).valid).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('validatePredicateSchema one-shot works for a color node', () => {
|
|
114
|
+
expect(validatePredicateSchema(cssColorSchema(), '#3a3').valid).toBe(true)
|
|
115
|
+
expect(validatePredicateSchema(cssColorSchema(), 'notacolor').valid).toBe(
|
|
116
|
+
false
|
|
117
|
+
)
|
|
118
|
+
// naive sees only `type: string`
|
|
119
|
+
expect(
|
|
120
|
+
validatePredicateSchema(cssColorSchema(), 'notacolor', {
|
|
121
|
+
ignorePredicates: true,
|
|
122
|
+
}).valid
|
|
123
|
+
).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
})
|
package/src/css/style.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS style-object structure (phase 4) — the recursive, open shape TS structural
|
|
3
|
+
* types and JSON Schema can't express, and the `$predicate` schemas that carry
|
|
4
|
+
* it. This is the thesis deliverable: a JSON-Schema node whose *structural* part
|
|
5
|
+
* (`type: 'object'`) a naive validator checks, and whose `$predicate` a
|
|
6
|
+
* predicate-aware validator runs to validate the whole recursive CSS structure —
|
|
7
|
+
* progressive enhancement, one serializable artifact.
|
|
8
|
+
*
|
|
9
|
+
* The combined source concatenates the color + dimension leaf clusters and adds
|
|
10
|
+
* the structure predicates, so `isStyleValue`/`isStyleObject` compose the leaves
|
|
11
|
+
* directly. `isStyleObject` is LAST — the entry the `$predicate` schema runs.
|
|
12
|
+
*
|
|
13
|
+
* Value precision is intentionally two-tier (as in the PoC): the *structure* is
|
|
14
|
+
* validated strictly (keys must be CSS properties or selectors/at-rules; a
|
|
15
|
+
* property maps to a value, a selector to a nested rule), while a generic leaf
|
|
16
|
+
* value falls back to "non-empty string or finite number" so a valid-but-
|
|
17
|
+
* unmodelled value (a shorthand, a not-yet-covered property) is never rejected.
|
|
18
|
+
* Per-property precision comes from the specific value schemas (`cssColorSchema`
|
|
19
|
+
* etc.) and, later, property-aware validation (phase 3 shorthands).
|
|
20
|
+
*/
|
|
21
|
+
import type { PredicateSchema } from '../lang/predicate-schema'
|
|
22
|
+
import { CSS_COLOR_SOURCE } from './predicates'
|
|
23
|
+
import { CSS_DIMENSION_SOURCE } from './dimensions'
|
|
24
|
+
import { CSS_SHORTHAND_FRAGMENT } from './shorthands'
|
|
25
|
+
|
|
26
|
+
/** The recursive structure predicates, layered on the leaf clusters. */
|
|
27
|
+
const CSS_STRUCTURE_FRAGMENT = `
|
|
28
|
+
// Properties whose value grammar is CLOSED — a value that fails the leaf
|
|
29
|
+
// predicate is genuinely invalid, so it's safe to enforce (normalized:
|
|
30
|
+
// lower-cased, dashes stripped, so 'background-color' === 'backgroundColor').
|
|
31
|
+
var CSS_COLOR_PROPS = ['color','backgroundcolor','bordercolor','bordertopcolor','borderrightcolor','borderbottomcolor','borderleftcolor','outlinecolor','caretcolor','columnrulecolor','textdecorationcolor','accentcolor','fill','stroke','floodcolor','stopcolor','lightingcolor']
|
|
32
|
+
|
|
33
|
+
function isCssProperty(k) {
|
|
34
|
+
// custom property (--foo) OR a standard/vendor-prefixed property (-webkit-…).
|
|
35
|
+
return typeof k === 'string' && /^(--[a-z0-9-]+|-?[a-z][a-z0-9-]*)$/i.test(k)
|
|
36
|
+
}
|
|
37
|
+
function isSelectorOrAtRule(k) {
|
|
38
|
+
if (typeof k !== 'string' || k.length === 0) { return false }
|
|
39
|
+
if (k.startsWith('@')) { return true }
|
|
40
|
+
return /[ :>~+.#&*\\[\\]]/.test(k)
|
|
41
|
+
}
|
|
42
|
+
function isStyleValue(val) {
|
|
43
|
+
if (typeof val === 'number') { return isFinite(val) }
|
|
44
|
+
if (typeof val !== 'string' || val.length === 0) { return false }
|
|
45
|
+
// Precise recognition for known leaves; permissive tail keeps the whole
|
|
46
|
+
// structure total (a shorthand / unmodelled value is a non-empty string).
|
|
47
|
+
return isColorValue(val) || isDimension(val) || isGlobalKeyword(val)
|
|
48
|
+
|| isCssVar(val) || isCssCalc(val) || val.length > 0
|
|
49
|
+
}
|
|
50
|
+
function cssPropNorm(prop) {
|
|
51
|
+
return typeof prop === 'string' ? prop.toLowerCase().replace(/-/g, '') : ''
|
|
52
|
+
}
|
|
53
|
+
// Property-AWARE value check: tighten only the closed value grammars (color,
|
|
54
|
+
// animation, transition) — a wrong value there is a real error; everything else
|
|
55
|
+
// stays permissive (keyword-heavy props like width/display accept idents we don't
|
|
56
|
+
// enumerate, so enforcing would false-reject valid CSS). Universal escapes
|
|
57
|
+
// (global keyword / var() / calc()) are valid on any property.
|
|
58
|
+
function isStyleValueFor(prop, val) {
|
|
59
|
+
if (isGlobalKeyword(val) || isCssVar(val) || isCssCalc(val)) { return true }
|
|
60
|
+
var p = cssPropNorm(prop)
|
|
61
|
+
if (CSS_COLOR_PROPS.includes(p)) { return isColorValue(val) }
|
|
62
|
+
if (p === 'animation') { return isAnimation(val) }
|
|
63
|
+
if (p === 'transition') { return isTransition(val) }
|
|
64
|
+
return isStyleValue(val)
|
|
65
|
+
}
|
|
66
|
+
function isStyleEntry(pair) {
|
|
67
|
+
var k = pair[0]
|
|
68
|
+
var val = pair[1]
|
|
69
|
+
if (isSelectorOrAtRule(k)) { return isStyleObject(val) }
|
|
70
|
+
if (isCssProperty(k)) { return isStyleValueFor(k, val) }
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
function isStyleObject(o) {
|
|
74
|
+
if (typeof o !== 'object' || o === null) { return false }
|
|
75
|
+
return Object.entries(o).every(isStyleEntry)
|
|
76
|
+
}
|
|
77
|
+
`
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The full CSS style predicate cluster: color + dimension + shorthand leaves +
|
|
81
|
+
* recursive, property-aware structure. Entry = `isStyleObject` (last function).
|
|
82
|
+
* Serializable — this is exactly what a `$predicate` schema carries.
|
|
83
|
+
*/
|
|
84
|
+
export const CSS_STYLE_SOURCE =
|
|
85
|
+
CSS_COLOR_SOURCE +
|
|
86
|
+
'\n' +
|
|
87
|
+
CSS_DIMENSION_SOURCE +
|
|
88
|
+
'\n' +
|
|
89
|
+
CSS_SHORTHAND_FRAGMENT +
|
|
90
|
+
'\n' +
|
|
91
|
+
CSS_STRUCTURE_FRAGMENT
|
|
92
|
+
|
|
93
|
+
/** Entry predicates exported by the style cluster (for compile/verify). */
|
|
94
|
+
export const CSS_STYLE_ENTRIES = [
|
|
95
|
+
'isCssProperty',
|
|
96
|
+
'isSelectorOrAtRule',
|
|
97
|
+
'isStyleValue',
|
|
98
|
+
'isStyleValueFor',
|
|
99
|
+
'isStyleObject',
|
|
100
|
+
] as const
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A `$predicate` JSON-Schema for a CSS style object (à la a tosijs styleSpec):
|
|
104
|
+
* naive validators check only `type: 'object'`; predicate-aware validators run
|
|
105
|
+
* `isStyleObject`, validating the whole recursive structure (nested selectors,
|
|
106
|
+
* property/value shapes). Progressive enhancement from one serializable schema.
|
|
107
|
+
*/
|
|
108
|
+
export function cssStyleSchema(): PredicateSchema {
|
|
109
|
+
return {
|
|
110
|
+
type: 'object',
|
|
111
|
+
title: 'CSSStyleObject',
|
|
112
|
+
description:
|
|
113
|
+
'An open, recursive CSS style object (properties + nested selectors/at-rules).',
|
|
114
|
+
$predicate: CSS_STYLE_SOURCE,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A `$predicate` schema for a single CSS **color** value (entry `isColorValue`,
|
|
120
|
+
* so a trailing `!important` is tolerated). Naive validators check `type:
|
|
121
|
+
* 'string'`; aware ones validate the color grammar.
|
|
122
|
+
*/
|
|
123
|
+
export function cssColorSchema(): PredicateSchema {
|
|
124
|
+
return { type: 'string', title: 'CSSColor', $predicate: CSS_COLOR_SOURCE }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A `$predicate` schema for a CSS **dimension** value (length/%/angle/time/
|
|
129
|
+
* number; entry `isDimension`). No structural `type` — a dimension may be a
|
|
130
|
+
* string (`10px`) or a number — so aware validators do all the work.
|
|
131
|
+
*/
|
|
132
|
+
export function cssDimensionSchema(): PredicateSchema {
|
|
133
|
+
return { title: 'CSSDimension', $predicate: CSS_DIMENSION_SOURCE }
|
|
134
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reproduction + guard for the "`import 'tjs-lang'` crashes without the TypeScript
|
|
3
|
+
* compiler" bug (snowfox production feedback).
|
|
4
|
+
*
|
|
5
|
+
* The main entry (`src/index.ts`) does `export * from './lang'`, which reaches
|
|
6
|
+
* `src/lang/index.ts`. If that statically re-exports `fromTS`, the whole
|
|
7
|
+
* TypeScript compiler (~4-10MB) is dragged into the module graph. `typescript`
|
|
8
|
+
* is only a devDependency (externalized in the build), so a Node consumer who
|
|
9
|
+
* runs `import 'tjs-lang'` without it installed gets
|
|
10
|
+
* `Cannot find package 'typescript' imported from dist/index.js` — and it also
|
|
11
|
+
* pulls TS at import time, which breaks constrained runtimes (Cloud Run).
|
|
12
|
+
*
|
|
13
|
+
* The fix: the TS compiler must be reachable ONLY via the explicit
|
|
14
|
+
* `tjs-lang/lang/from-ts` subpath, never through the main entry. This test
|
|
15
|
+
* bundles the main entry with esbuild (same externals as `scripts/build.ts`'s
|
|
16
|
+
* `index` target) and asserts `typescript` never appears as an import specifier.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect } from 'bun:test'
|
|
19
|
+
import { buildSync } from 'esbuild'
|
|
20
|
+
|
|
21
|
+
const HERE = import.meta.dir
|
|
22
|
+
|
|
23
|
+
/** All static + dynamic import specifiers in a bundle (bare, relative, or URL). */
|
|
24
|
+
function importSpecifiers(src: string): string[] {
|
|
25
|
+
const found: string[] = []
|
|
26
|
+
for (const m of src.matchAll(
|
|
27
|
+
/(?:^|[;\n}])import\s*(?:[^"';]*?from)?\s*["']([^"']+)["']/g
|
|
28
|
+
))
|
|
29
|
+
found.push(m[1])
|
|
30
|
+
for (const m of src.matchAll(/import\(\s*["']([^"']+)["']\s*\)/g))
|
|
31
|
+
found.push(m[1])
|
|
32
|
+
return [...new Set(found)]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("import 'tjs-lang' must not pull in the TypeScript compiler", () => {
|
|
36
|
+
it('main entry bundle has no static `typescript` import', () => {
|
|
37
|
+
// Mirror scripts/build.ts's `index` target externals. `typescript` is
|
|
38
|
+
// externalized there, so if the main entry reaches it, esbuild leaves a
|
|
39
|
+
// bare `import "typescript"` in the output — which is exactly the crash.
|
|
40
|
+
const out = buildSync({
|
|
41
|
+
entryPoints: [`${HERE}/index.ts`],
|
|
42
|
+
bundle: true,
|
|
43
|
+
format: 'esm',
|
|
44
|
+
platform: 'neutral',
|
|
45
|
+
write: false,
|
|
46
|
+
external: [
|
|
47
|
+
'acorn',
|
|
48
|
+
'tosijs-schema',
|
|
49
|
+
'typescript',
|
|
50
|
+
'node:readline',
|
|
51
|
+
'node:fs',
|
|
52
|
+
'node:path',
|
|
53
|
+
],
|
|
54
|
+
}).outputFiles[0].text
|
|
55
|
+
|
|
56
|
+
expect(importSpecifiers(out)).not.toContain('typescript')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -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.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `emitVerifiedPredicate` — the transpile-time source emitter
|
|
3
|
+
* that turns a verified-safe predicate cluster into a self-contained,
|
|
4
|
+
* fuel-bounded guard expression (the foundation of wiring predicates into
|
|
5
|
+
* `Type`/`FunctionPredicate`). Distinct from `compilePredicate`, which evals to
|
|
6
|
+
* live closures; here we assert the emitted *string* is self-contained and
|
|
7
|
+
* behaves correctly when evaluated.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'bun:test'
|
|
10
|
+
import { emitVerifiedPredicate } from './predicate'
|
|
11
|
+
|
|
12
|
+
/** Evaluate an emitted guard expression to a callable, in an empty scope. */
|
|
13
|
+
function guardFrom(code: string): (...a: any[]) => boolean {
|
|
14
|
+
// Indirect eval: the emitted code is a self-contained IIFE expression.
|
|
15
|
+
return new Function(`return (${code})`)()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('emitVerifiedPredicate', () => {
|
|
19
|
+
it('emits a self-contained guard for a safe predicate', () => {
|
|
20
|
+
const r = emitVerifiedPredicate(
|
|
21
|
+
'function isPos(x) { return x > 0 }',
|
|
22
|
+
'isPos'
|
|
23
|
+
)
|
|
24
|
+
expect(r.safe).toBe(true)
|
|
25
|
+
expect(r.diagnostics).toEqual([])
|
|
26
|
+
expect(typeof r.code).toBe('string')
|
|
27
|
+
|
|
28
|
+
const guard = guardFrom(r.code!)
|
|
29
|
+
expect(guard(5)).toBe(true)
|
|
30
|
+
expect(guard(-1)).toBe(false)
|
|
31
|
+
expect(guard(0)).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('coerces the guard result to a real boolean', () => {
|
|
35
|
+
// Truthy/falsy body values must come back as strict booleans.
|
|
36
|
+
const r = emitVerifiedPredicate(
|
|
37
|
+
'function nonEmpty(s) { return s && s.length }',
|
|
38
|
+
'nonEmpty'
|
|
39
|
+
)
|
|
40
|
+
const guard = guardFrom(r.code!)
|
|
41
|
+
expect(guard('hi')).toBe(true)
|
|
42
|
+
expect(guard('')).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('supports composition + array methods (fuel-bounded iteration)', () => {
|
|
46
|
+
const src =
|
|
47
|
+
'function isWord(s) { return typeof s === "string" && s.length > 0 }\n' +
|
|
48
|
+
'function allWords(xs) { return Array.isArray(xs) && xs.every(isWord) }'
|
|
49
|
+
const r = emitVerifiedPredicate(src, 'allWords')
|
|
50
|
+
expect(r.safe).toBe(true)
|
|
51
|
+
const guard = guardFrom(r.code!)
|
|
52
|
+
expect(guard(['a', 'b'])).toBe(true)
|
|
53
|
+
expect(guard(['a', ''])).toBe(false)
|
|
54
|
+
expect(guard('nope')).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('rejects an unsafe predicate (loop) with diagnostics, no code', () => {
|
|
58
|
+
const r = emitVerifiedPredicate(
|
|
59
|
+
'function f(xs) { for (const x of xs) { if (x < 0) return false } return true }',
|
|
60
|
+
'f'
|
|
61
|
+
)
|
|
62
|
+
expect(r.safe).toBe(false)
|
|
63
|
+
expect(r.code).toBeUndefined()
|
|
64
|
+
expect(r.diagnostics.length).toBeGreaterThan(0)
|
|
65
|
+
expect(r.diagnostics[0].message).toMatch(/loop/i)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('rejects an impure predicate (effectful call)', () => {
|
|
69
|
+
const r = emitVerifiedPredicate('function f(x) { return fetch(x) }', 'f')
|
|
70
|
+
expect(r.safe).toBe(false)
|
|
71
|
+
expect(r.code).toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('reports a missing entry name as unsafe', () => {
|
|
75
|
+
const r = emitVerifiedPredicate(
|
|
76
|
+
'function isPos(x) { return x > 0 }',
|
|
77
|
+
'nope'
|
|
78
|
+
)
|
|
79
|
+
expect(r.safe).toBe(false)
|
|
80
|
+
expect(r.diagnostics[0].message).toMatch(/not found/i)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('returns false (does not throw) on a runaway input — DoS-safe guard', () => {
|
|
84
|
+
// Unbounded recursion: with a tiny fuel budget it must exhaust and the guard
|
|
85
|
+
// answers `false` rather than hanging or throwing to the caller.
|
|
86
|
+
const r = emitVerifiedPredicate(
|
|
87
|
+
'function deep(n) { return deep(n + 1) }',
|
|
88
|
+
'deep',
|
|
89
|
+
{ fuel: 100 }
|
|
90
|
+
)
|
|
91
|
+
expect(r.safe).toBe(true)
|
|
92
|
+
const guard = guardFrom(r.code!)
|
|
93
|
+
expect(guard(0)).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -166,6 +166,37 @@ export function greet(name = 'world'): '' {
|
|
|
166
166
|
expect(dts).toContain('name?: string')
|
|
167
167
|
})
|
|
168
168
|
|
|
169
|
+
// #11: a bare (untyped) param is a required *position* in a signature, not an
|
|
170
|
+
// optional *contract* — runtime `required:false` (wild-west JS) must not leak
|
|
171
|
+
// into dts optionality, or the auto .d.ts is INVALID TS (ts1016: an optional
|
|
172
|
+
// param can't precede a required one), not merely loose.
|
|
173
|
+
it('emits bare params as required positions (valid TS, not a?: before b)', () => {
|
|
174
|
+
const dts = generateDTS(
|
|
175
|
+
transpileToJS('export function f(a, b: 0) { return a }', {
|
|
176
|
+
runTests: false,
|
|
177
|
+
}),
|
|
178
|
+
'export function f(a, b: 0) { return a }'
|
|
179
|
+
)
|
|
180
|
+
expect(dts).toContain('f(a: any, b: number)')
|
|
181
|
+
expect(dts).not.toContain('a?:') // was `f(a?: any, b: number)` — invalid TS
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('keeps deliberate optionals (default / ?) optional', () => {
|
|
185
|
+
const src = "export function g(a = 1, b?: '') { return a }"
|
|
186
|
+
const dts = generateDTS(transpileToJS(src, { runTests: false }), src)
|
|
187
|
+
expect(dts).toContain('a?: number')
|
|
188
|
+
expect(dts).toContain('b?: string')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('demotes an optional-before-required to required (no ts1016)', () => {
|
|
192
|
+
// `h(a = 1, b)` — a is a default (optional) but a required position (bare b)
|
|
193
|
+
// follows it, so a must be emitted required to keep the signature valid.
|
|
194
|
+
const src = 'export function h(a = 1, b) { return a }'
|
|
195
|
+
const dts = generateDTS(transpileToJS(src, { runTests: false }), src)
|
|
196
|
+
expect(dts).toContain('h(a: number, b: any)')
|
|
197
|
+
expect(dts).not.toContain('a?:')
|
|
198
|
+
})
|
|
199
|
+
|
|
169
200
|
it('should handle multiple parameters and return types', () => {
|
|
170
201
|
const source = `
|
|
171
202
|
export function add(a: 0, b: 0): 0 {
|
package/src/lang/emitters/dts.ts
CHANGED
|
@@ -109,11 +109,30 @@ function functionDeclToTS(
|
|
|
109
109
|
exported: boolean,
|
|
110
110
|
isDefault: boolean
|
|
111
111
|
): string {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
// DTS optionality is NOT the runtime `required` flag. Runtime `required` is a
|
|
113
|
+
// *contract* check: a bare (untyped) JS param is wild-west — `required:false`
|
|
114
|
+
// so the runtime doesn't reject omitting it — but it's still a required
|
|
115
|
+
// *position* in the signature, not an optional *contract*. A param is emitted
|
|
116
|
+
// optional only if (a) it's a DELIBERATE optional — a default value or `?`
|
|
117
|
+
// marker, both of which set `default` (a bare param has no `default` at all) —
|
|
118
|
+
// and (b) no required param follows it (TS forbids an optional param before a
|
|
119
|
+
// required one; ts1016). Deriving optionality from `!required` leaked wild-west
|
|
120
|
+
// omittability into the dts and produced INVALID TS (#11).
|
|
121
|
+
const entries = Object.entries(info.params)
|
|
122
|
+
const optionalFlags: boolean[] = new Array(entries.length)
|
|
123
|
+
let requiredFollows = false
|
|
124
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
125
|
+
const p = entries[i][1] as (typeof entries)[number][1] & {
|
|
126
|
+
default?: unknown
|
|
127
|
+
}
|
|
128
|
+
const deliberateOptional = !p.required && p.default !== undefined
|
|
129
|
+
optionalFlags[i] = deliberateOptional && !requiredFollows
|
|
130
|
+
if (!optionalFlags[i]) requiredFollows = true
|
|
131
|
+
}
|
|
132
|
+
const params = entries
|
|
133
|
+
.map(([pName, p], i) => {
|
|
115
134
|
const tsType = typeDescriptorToTS(p.type)
|
|
116
|
-
return
|
|
135
|
+
return optionalFlags[i] ? `${pName}?: ${tsType}` : `${pName}: ${tsType}`
|
|
117
136
|
})
|
|
118
137
|
.join(', ')
|
|
119
138
|
|
|
@@ -2333,7 +2333,10 @@ function extractDocComments(
|
|
|
2333
2333
|
source: string
|
|
2334
2334
|
): Array<{ content: string; index: number }> {
|
|
2335
2335
|
const comments: Array<{ content: string; index: number }> = []
|
|
2336
|
-
|
|
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
|
|
2337
2340
|
|
|
2338
2341
|
// Track brace depth to identify top-level comments
|
|
2339
2342
|
let braceDepth = 0
|
package/src/lang/emitters/js.ts
CHANGED
|
@@ -59,7 +59,11 @@ import {
|
|
|
59
59
|
transformEqualityToStructural,
|
|
60
60
|
transformIsOperators,
|
|
61
61
|
} from '../parser-transforms'
|
|
62
|
-
import type {
|
|
62
|
+
import type {
|
|
63
|
+
TypeDescriptor,
|
|
64
|
+
ParameterDescriptor,
|
|
65
|
+
PredicateVerification,
|
|
66
|
+
} from '../types'
|
|
63
67
|
import { inferTypeFromValue, parseParameter } from '../inference'
|
|
64
68
|
import { extractTests } from '../tests'
|
|
65
69
|
import {
|
|
@@ -137,6 +141,14 @@ export interface TJSTranspileResult {
|
|
|
137
141
|
metadata: Record<string, TJSTypeInfo>
|
|
138
142
|
/** Any warnings during transpilation */
|
|
139
143
|
warnings?: string[]
|
|
144
|
+
/**
|
|
145
|
+
* Per-`Type`/`Generic` predicate verification status: `verified` → compiled to
|
|
146
|
+
* a fuel-bounded, DoS-safe native guard; `!verified` → fell back to a plain
|
|
147
|
+
* function (valid, but not fuel-bounded / not safe on untrusted data — its
|
|
148
|
+
* `reason` is the verifier diagnostic). Unverified entries are also mirrored
|
|
149
|
+
* into `warnings`. Lets tools flag unverifiable predicates.
|
|
150
|
+
*/
|
|
151
|
+
predicates?: PredicateVerification[]
|
|
140
152
|
/** Generated test runner code (if tests were present) - DEPRECATED, tests now run at transpile time */
|
|
141
153
|
testRunner?: string
|
|
142
154
|
/** Number of tests extracted */
|
|
@@ -668,6 +680,24 @@ export function transpileToJS(
|
|
|
668
680
|
filename,
|
|
669
681
|
})
|
|
670
682
|
|
|
683
|
+
// Mirror unverifiable Type/Generic predicates into `warnings` (they still
|
|
684
|
+
// compile — as plain functions — but aren't fuel-bounded / safe on untrusted
|
|
685
|
+
// data). The full per-predicate status is returned as `predicates`.
|
|
686
|
+
for (const p of preprocessed.predicates) {
|
|
687
|
+
if (!p.verified) {
|
|
688
|
+
warnings.push(
|
|
689
|
+
`${p.kind} '${
|
|
690
|
+
p.name
|
|
691
|
+
}': predicate is not verifiable, compiled as a plain function (not fuel-bounded, not safe on untrusted data)${
|
|
692
|
+
p.reason ? ` — ${p.reason.replace(/\s+/g, ' ').trim()}` : ''
|
|
693
|
+
}`
|
|
694
|
+
)
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const predicateReport = preprocessed.predicates.length
|
|
698
|
+
? preprocessed.predicates
|
|
699
|
+
: undefined
|
|
700
|
+
|
|
671
701
|
// Apply the same source-level equality transforms to extracted test/mock
|
|
672
702
|
// bodies so they observe the module's TJS semantics (e.g. structural ==).
|
|
673
703
|
// Test bodies are extracted as raw text before parse(), so they would
|
|
@@ -1158,6 +1188,7 @@ export function transpileToJS(
|
|
|
1158
1188
|
metadata: allTypes,
|
|
1159
1189
|
testResults,
|
|
1160
1190
|
testCount: testResults?.length,
|
|
1191
|
+
predicates: predicateReport,
|
|
1161
1192
|
}
|
|
1162
1193
|
}
|
|
1163
1194
|
|
|
@@ -1178,6 +1209,7 @@ export function transpileToJS(
|
|
|
1178
1209
|
types: allTypes,
|
|
1179
1210
|
metadata: allTypes, // alias for runtime compatibility
|
|
1180
1211
|
warnings: warnings.length > 0 ? warnings : undefined,
|
|
1212
|
+
predicates: predicateReport,
|
|
1181
1213
|
testRunner: tests.length > 0 ? testRunner : undefined,
|
|
1182
1214
|
testCount: tests.length > 0 ? tests.length : undefined,
|
|
1183
1215
|
testResults,
|