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.
Files changed (81) hide show
  1. package/CLAUDE.md +12 -4
  2. package/bin/docs.js +3 -2
  3. package/demo/docs.json +2 -2
  4. package/dist/experiments/ambient/probe.d.ts +52 -0
  5. package/dist/index.js +115 -171
  6. package/dist/index.js.map +4 -4
  7. package/dist/scripts/build-editors.d.ts +19 -0
  8. package/dist/src/css/dimensions.d.ts +23 -0
  9. package/dist/src/css/index.d.ts +85 -0
  10. package/dist/src/css/predicates.d.ts +38 -0
  11. package/dist/src/css/shorthands.d.ts +31 -0
  12. package/dist/src/css/style.d.ts +48 -0
  13. package/dist/src/lang/emitters/js.d.ts +9 -1
  14. package/dist/src/lang/index.d.ts +2 -2
  15. package/dist/src/lang/parser-transforms.d.ts +9 -5
  16. package/dist/src/lang/parser.d.ts +2 -0
  17. package/dist/src/lang/predicate.d.ts +60 -0
  18. package/dist/src/lang/transpiler.d.ts +3 -2
  19. package/dist/src/lang/types.d.ts +16 -0
  20. package/dist/src/schema/index.d.ts +12 -0
  21. package/dist/tjs-browser-from-ts.js +20 -20
  22. package/dist/tjs-browser-from-ts.js.map +3 -3
  23. package/dist/tjs-browser.js +100 -90
  24. package/dist/tjs-browser.js.map +4 -4
  25. package/dist/tjs-css.js +193 -0
  26. package/dist/tjs-css.js.map +7 -0
  27. package/dist/tjs-eval.js +43 -42
  28. package/dist/tjs-eval.js.map +4 -4
  29. package/dist/tjs-from-ts.js +13 -13
  30. package/dist/tjs-from-ts.js.map +2 -2
  31. package/dist/tjs-lang.js +102 -92
  32. package/dist/tjs-lang.js.map +4 -4
  33. package/dist/tjs-runtime.js +10 -0
  34. package/dist/tjs-runtime.js.map +7 -0
  35. package/dist/tjs-schema.js +6 -0
  36. package/dist/tjs-schema.js.map +7 -0
  37. package/dist/tjs-vm.js +55 -54
  38. package/dist/tjs-vm.js.map +4 -4
  39. package/docs/ambient-contracts.md +261 -0
  40. package/editors/ace/ajs-mode.js +214 -233
  41. package/editors/codemirror/ajs-language.js +1570 -233
  42. package/editors/editors-build.test.ts +29 -0
  43. package/editors/monaco/ajs-monarch.js +239 -195
  44. package/llms.txt +4 -0
  45. package/package.json +35 -4
  46. package/src/css/css.test.ts +122 -0
  47. package/src/css/dimensions.test.ts +112 -0
  48. package/src/css/dimensions.ts +146 -0
  49. package/src/css/index.ts +243 -0
  50. package/src/css/perf.bench.test.ts +109 -0
  51. package/src/css/predicates.ts +232 -0
  52. package/src/css/property-aware.test.ts +84 -0
  53. package/src/css/shorthands.test.ts +90 -0
  54. package/src/css/shorthands.ts +113 -0
  55. package/src/css/style.test.ts +125 -0
  56. package/src/css/style.ts +134 -0
  57. package/src/index-tsfree.test.ts +58 -0
  58. package/src/lang/bare-assignments.test.ts +48 -0
  59. package/src/lang/doc-comment-position.test.ts +32 -0
  60. package/src/lang/docs.ts +6 -2
  61. package/src/lang/emit-verified-predicate.test.ts +95 -0
  62. package/src/lang/emitters/dts.test.ts +31 -0
  63. package/src/lang/emitters/dts.ts +23 -4
  64. package/src/lang/emitters/from-ts.ts +4 -1
  65. package/src/lang/emitters/js.ts +33 -1
  66. package/src/lang/from-ts.test.ts +1 -1
  67. package/src/lang/generic-verified-predicate.test.ts +39 -0
  68. package/src/lang/index.ts +14 -5
  69. package/src/lang/parser-transforms.ts +120 -18
  70. package/src/lang/parser.ts +22 -6
  71. package/src/lang/predicate-evaluator.test.ts +49 -0
  72. package/src/lang/predicate-report.test.ts +54 -0
  73. package/src/lang/predicate.ts +268 -0
  74. package/src/lang/redos-lint.test.ts +81 -0
  75. package/src/lang/transpiler.ts +13 -0
  76. package/src/lang/type-verified-predicate.test.ts +83 -0
  77. package/src/lang/types.ts +17 -0
  78. package/src/lang/typescript-syntax.test.ts +2 -1
  79. package/src/schema/index.ts +62 -0
  80. package/src/schema/schema.test.ts +66 -0
  81. 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
+ })
@@ -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
- const docPattern = /\/\*#([\s\S]*?)\*\//g
173
- const jsdocPattern = /\/\*\*([\s\S]*?)\*\//g
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 {
@@ -109,11 +109,30 @@ function functionDeclToTS(
109
109
  exported: boolean,
110
110
  isDefault: boolean
111
111
  ): string {
112
- const params = Object.entries(info.params)
113
- .map(([pName, p]) => {
114
- const optional = !p.required
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 optional ? `${pName}?: ${tsType}` : `${pName}: ${tsType}`
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
- const docRegex = /\/\*#[\s\S]*?\*\//g
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
@@ -59,7 +59,11 @@ import {
59
59
  transformEqualityToStructural,
60
60
  transformIsOperators,
61
61
  } from '../parser-transforms'
62
- import type { TypeDescriptor, ParameterDescriptor } from '../types'
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,