tjs-lang 0.7.7 → 0.7.8
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 +90 -33
- package/bin/docs.js +4 -1
- package/demo/docs.json +45 -11
- package/demo/src/examples.test.ts +1 -0
- package/demo/src/imports.test.ts +16 -4
- package/demo/src/imports.ts +60 -15
- package/demo/src/playground-shared.ts +9 -8
- package/demo/src/tfs-worker.js +205 -147
- package/demo/src/tjs-playground.ts +34 -10
- package/demo/src/ts-playground.ts +24 -8
- package/dist/index.js +118 -101
- package/dist/index.js.map +4 -4
- package/dist/src/lang/bool-coercion.d.ts +50 -0
- package/dist/src/lang/docs.d.ts +31 -6
- package/dist/src/lang/linter.d.ts +8 -0
- package/dist/src/lang/parser-transforms.d.ts +18 -0
- package/dist/src/lang/parser-types.d.ts +2 -0
- package/dist/src/lang/parser.d.ts +3 -0
- package/dist/src/lang/runtime.d.ts +34 -0
- package/dist/src/lang/types.d.ts +9 -1
- package/dist/src/rbac/index.d.ts +1 -1
- package/dist/src/vm/runtime.d.ts +1 -1
- package/dist/tjs-eval.js +38 -36
- package/dist/tjs-eval.js.map +4 -4
- package/dist/tjs-from-ts.js +20 -20
- package/dist/tjs-from-ts.js.map +3 -3
- package/dist/tjs-lang.js +85 -83
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-vm.js +47 -45
- package/dist/tjs-vm.js.map +4 -4
- package/llms.txt +79 -0
- package/package.json +3 -2
- package/src/cli/commands/convert.test.ts +16 -21
- package/src/lang/bool-coercion.test.ts +203 -0
- package/src/lang/bool-coercion.ts +314 -0
- package/src/lang/codegen.test.ts +137 -0
- package/src/lang/docs.test.ts +328 -1
- package/src/lang/docs.ts +424 -24
- package/src/lang/emitters/ast.ts +11 -12
- package/src/lang/emitters/dts.test.ts +41 -0
- package/src/lang/emitters/dts.ts +9 -0
- package/src/lang/emitters/js-tests.ts +9 -4
- package/src/lang/emitters/js.ts +182 -2
- package/src/lang/inference.ts +54 -0
- package/src/lang/linter.test.ts +104 -1
- package/src/lang/linter.ts +124 -1
- package/src/lang/parser-params.ts +31 -0
- package/src/lang/parser-transforms.ts +304 -0
- package/src/lang/parser-types.ts +2 -0
- package/src/lang/parser.test.ts +73 -1
- package/src/lang/parser.ts +34 -1
- package/src/lang/runtime.ts +98 -0
- package/src/lang/types.ts +6 -0
- package/src/rbac/index.ts +2 -2
- package/src/rbac/rules.tjs.d.ts +9 -0
- package/src/vm/atoms/batteries.ts +2 -2
- package/src/vm/runtime.ts +10 -3
- package/dist/src/rbac/rules.d.ts +0 -184
- package/src/rbac/rules.js +0 -338
package/src/lang/emitters/js.ts
CHANGED
|
@@ -69,6 +69,10 @@ import {
|
|
|
69
69
|
} from './js-tests'
|
|
70
70
|
export { stripModuleSyntax, stripTjsPreamble } from './js-tests'
|
|
71
71
|
import { generateWasmBootstrap } from './js-wasm'
|
|
72
|
+
import {
|
|
73
|
+
rewriteBoolCoercion,
|
|
74
|
+
rewriteBoolCoercionInSource,
|
|
75
|
+
} from '../bool-coercion'
|
|
72
76
|
|
|
73
77
|
export interface TJSTranspileOptions {
|
|
74
78
|
/** Filename for error messages */
|
|
@@ -419,6 +423,19 @@ function generateInlineValidationCode(
|
|
|
419
423
|
// 2. Type checks with proper error emission
|
|
420
424
|
for (const [paramName, param] of params) {
|
|
421
425
|
const path = `${pathPrefix}${funcName}.${paramName}`
|
|
426
|
+
|
|
427
|
+
// For array params: if the array contains a MonadicError, propagate
|
|
428
|
+
// the first one we find instead of failing the type check with
|
|
429
|
+
// "expected array, got X". This is the "errors propagate, not
|
|
430
|
+
// accumulate" rule — a function receiving an array of values where
|
|
431
|
+
// one is an error should surface that error, not say the array's
|
|
432
|
+
// shape is wrong.
|
|
433
|
+
if (param.type.kind === 'array') {
|
|
434
|
+
lines.push(
|
|
435
|
+
`if (Array.isArray(${paramName})) { for (const __i of ${paramName}) { if (__i instanceof Error && __i.path !== undefined) return __i } }`
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
|
|
422
439
|
const typeCheck = generateTypeCheckExpr(paramName, param.type)
|
|
423
440
|
|
|
424
441
|
if (typeCheck) {
|
|
@@ -436,6 +453,19 @@ function generateInlineValidationCode(
|
|
|
436
453
|
)
|
|
437
454
|
}
|
|
438
455
|
}
|
|
456
|
+
|
|
457
|
+
// If the param is a function with declared shape (e.g. `fn = (x: 0) => 0`),
|
|
458
|
+
// wrap it so its arguments and return value are validated on every call.
|
|
459
|
+
// Skipped when shape is unspecified or contains non-simple kinds.
|
|
460
|
+
if (param.type.kind === 'function') {
|
|
461
|
+
const shapeCheck = generateFunctionShapeCheck(paramName, param.type, path)
|
|
462
|
+
if (shapeCheck) {
|
|
463
|
+
lines.push(shapeCheck)
|
|
464
|
+
// checkFnShape returns either the function unchanged or a
|
|
465
|
+
// MonadicError. Re-check Error propagation after the assignment.
|
|
466
|
+
lines.push(`if (${paramName} instanceof Error) return ${paramName};`)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
439
469
|
}
|
|
440
470
|
|
|
441
471
|
if (lines.length === 0) return null
|
|
@@ -618,12 +648,18 @@ export function transpileToJS(
|
|
|
618
648
|
if (preprocessed.tjsModes.tjsEquals) {
|
|
619
649
|
t.body = transformEqualityToStructural(t.body)
|
|
620
650
|
}
|
|
651
|
+
if (preprocessed.tjsModes.tjsStandard) {
|
|
652
|
+
t.body = rewriteBoolCoercionInSource(t.body)
|
|
653
|
+
}
|
|
621
654
|
}
|
|
622
655
|
for (const m of mocks) {
|
|
623
656
|
m.body = transformIsOperators(m.body)
|
|
624
657
|
if (preprocessed.tjsModes.tjsEquals) {
|
|
625
658
|
m.body = transformEqualityToStructural(m.body)
|
|
626
659
|
}
|
|
660
|
+
if (preprocessed.tjsModes.tjsStandard) {
|
|
661
|
+
m.body = rewriteBoolCoercionInSource(m.body)
|
|
662
|
+
}
|
|
627
663
|
}
|
|
628
664
|
|
|
629
665
|
// Build types map for all functions
|
|
@@ -671,6 +707,41 @@ export function transpileToJS(
|
|
|
671
707
|
warnings.push(...funcWarnings)
|
|
672
708
|
allTypes[funcName] = types
|
|
673
709
|
|
|
710
|
+
// Cross-reference inference: when a parameter default is a bare
|
|
711
|
+
// identifier referring to a previously-declared TJS function, use that
|
|
712
|
+
// function's signature as the parameter's type. So
|
|
713
|
+
//
|
|
714
|
+
// function strLength(s: ''): 0 { ... }
|
|
715
|
+
// function map(arr: [''], counter = strLength) { ... }
|
|
716
|
+
//
|
|
717
|
+
// makes `counter`'s type `(s: string) => integer` (instead of `any`),
|
|
718
|
+
// which means the checkFnShape pass-time check fires when a wrong-
|
|
719
|
+
// shape callback is passed at the call site.
|
|
720
|
+
for (const param of func.params) {
|
|
721
|
+
if (
|
|
722
|
+
param.type === 'AssignmentPattern' &&
|
|
723
|
+
param.left.type === 'Identifier' &&
|
|
724
|
+
param.right.type === 'Identifier'
|
|
725
|
+
) {
|
|
726
|
+
const localName = param.left.name
|
|
727
|
+
const refName = (param.right as any).name as string
|
|
728
|
+
const refInfo = allTypes[refName]
|
|
729
|
+
if (refInfo && types.params[localName]) {
|
|
730
|
+
const fnParams = Object.entries(refInfo.params).map(([n, p]) => ({
|
|
731
|
+
name: n,
|
|
732
|
+
type: p.type,
|
|
733
|
+
}))
|
|
734
|
+
const fnReturns =
|
|
735
|
+
(refInfo as any).returns ?? ({ kind: 'any' } as TypeDescriptor)
|
|
736
|
+
types.params[localName].type = {
|
|
737
|
+
kind: 'function',
|
|
738
|
+
params: fnParams,
|
|
739
|
+
returns: fnReturns,
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
674
745
|
// Clean up param defaults in the emitted JS.
|
|
675
746
|
// After colon→equals transform, `x: false | undefined` becomes
|
|
676
747
|
// `x = false | undefined` in the parsed source.
|
|
@@ -780,6 +851,18 @@ export function transpileToJS(
|
|
|
780
851
|
}
|
|
781
852
|
}
|
|
782
853
|
|
|
854
|
+
// Boolean coercion rewrite (TjsStandard). Rewrites every truthiness
|
|
855
|
+
// context (`if`, `while`, `for`, `do/while`, `!`, `&&`, `||`, `?:`,
|
|
856
|
+
// and `Boolean(x)` calls) to call `__tjs.toBool` so boxed primitives
|
|
857
|
+
// unwrap before coercion. See src/lang/bool-coercion.ts.
|
|
858
|
+
if (preprocessed.tjsModes.tjsStandard) {
|
|
859
|
+
const boolPatches = rewriteBoolCoercion(program, preprocessed.source)
|
|
860
|
+
for (const p of boolPatches) {
|
|
861
|
+
deletions.push({ start: p.start, end: p.end })
|
|
862
|
+
insertions.push({ position: p.start, text: p.newText })
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
783
866
|
// Apply deletions first (reverse order to maintain offsets), then insertions.
|
|
784
867
|
// Deletions strip | union suffixes from param defaults in the output JS.
|
|
785
868
|
deletions.sort((a, b) => b.start - a.start)
|
|
@@ -821,6 +904,8 @@ export function transpileToJS(
|
|
|
821
904
|
const needsEnum = /\bEnum\(/.test(code)
|
|
822
905
|
const needsUnion = /\bUnion\(/.test(code)
|
|
823
906
|
const needsBang = code.includes('__tjs.bang(')
|
|
907
|
+
const needsToBool = code.includes('__tjs.toBool(')
|
|
908
|
+
const needsCheckFnShape = code.includes('__tjs.checkFnShape(')
|
|
824
909
|
const needsSafeEval = preprocessed.tjsModes.tjsSafeEval
|
|
825
910
|
|
|
826
911
|
const needsRuntime =
|
|
@@ -837,6 +922,8 @@ export function transpileToJS(
|
|
|
837
922
|
needsEnum ||
|
|
838
923
|
needsUnion ||
|
|
839
924
|
needsBang ||
|
|
925
|
+
needsToBool ||
|
|
926
|
+
needsCheckFnShape ||
|
|
840
927
|
needsSafeEval
|
|
841
928
|
|
|
842
929
|
if (needsRuntime) {
|
|
@@ -913,6 +1000,28 @@ export function transpileToJS(
|
|
|
913
1000
|
`function Union(d,...v){const vals=v.flat();return{description:d,check:x=>vals.includes(x),values:vals,__runtimeType:true}}`
|
|
914
1001
|
)
|
|
915
1002
|
}
|
|
1003
|
+
// toBool — honest truthiness (unwraps boxed primitives)
|
|
1004
|
+
if (needsToBool) {
|
|
1005
|
+
inlineParts.push(
|
|
1006
|
+
`function toBool(v){if(v instanceof Boolean||v instanceof Number||v instanceof String)return Boolean(v.valueOf());return Boolean(v)}`
|
|
1007
|
+
)
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// checkFnShape — pass-time shape check for function-typed params
|
|
1011
|
+
if (needsCheckFnShape) {
|
|
1012
|
+
// checkFnShape depends on MonadicError; ensure it's inlined
|
|
1013
|
+
if (!needsTypeError) {
|
|
1014
|
+
inlineParts.push(
|
|
1015
|
+
`class MonadicError extends Error{constructor(m,p,e,a,c,r){super(m);this.name='MonadicError';this.path=p;this.expected=e;this.actual=a;this.callStack=c;this.reason=r}}`,
|
|
1016
|
+
`function typeError(p,e,v,r){const a=v===null?'null':typeof v;const m=r?'Expected '+e+" for '"+p+"': "+r:'Expected '+e+" for '"+p+"', got "+a;const err=new MonadicError(m,p,e,a,undefined,r);const c=globalThis.__tjs?.getConfig?.();if(c?.logTypeErrors)console.error('[TJS TypeError] '+err.message);if(c?.throwTypeErrors)throw err;return err}`,
|
|
1017
|
+
`function isMonadicError(v){return v instanceof Error&&v.name==='MonadicError'&&'path' in v}`
|
|
1018
|
+
)
|
|
1019
|
+
}
|
|
1020
|
+
inlineParts.push(
|
|
1021
|
+
`function checkFnShape(fn,expectedParams,expectedReturn,path){if(typeof fn!=='function')return fn;const meta=fn.__tjs;if(!meta||!meta.params)return fn;const entries=Object.entries(meta.params);for(let i=0;i<expectedParams.length;i++){const e=expectedParams[i];if(e==='any')continue;const a=entries[i];if(!a)continue;const ak=a[1]&&a[1].type&&a[1].type.kind;if(!ak||ak==='any')continue;if(ak!==e)return new MonadicError("Expected (...arg"+i+": "+e+", ...) for '"+path+"', but callback declares arg"+i+" as "+ak,path+"(arg"+i+")",e,ak)}if(expectedReturn!=='any'&&meta.returns){const ar=(meta.returns.type&&meta.returns.type.kind)||meta.returns.kind;if(ar&&ar!=='any'&&ar!==expectedReturn)return new MonadicError("Expected callback returning "+expectedReturn+" for '"+path+"', but callback returns "+ar,path+"(return)",expectedReturn,ar)}return fn}`
|
|
1022
|
+
)
|
|
1023
|
+
}
|
|
1024
|
+
|
|
916
1025
|
// Bang access (!.) — asserted non-null member access
|
|
917
1026
|
if (needsBang) {
|
|
918
1027
|
// bang depends on typeError and isMonadicError — ensure they're inlined
|
|
@@ -947,6 +1056,11 @@ export function transpileToJS(
|
|
|
947
1056
|
if (needsFunctionPredicate) fallbackEntries.push('FunctionPredicate')
|
|
948
1057
|
if (needsEnum) fallbackEntries.push('Enum')
|
|
949
1058
|
if (needsUnion) fallbackEntries.push('Union')
|
|
1059
|
+
if (needsToBool) fallbackEntries.push('toBool')
|
|
1060
|
+
if (needsCheckFnShape) {
|
|
1061
|
+
fallbackEntries.push('checkFnShape')
|
|
1062
|
+
if (!needsTypeError) fallbackEntries.push('typeError', 'isMonadicError')
|
|
1063
|
+
}
|
|
950
1064
|
if (needsBang) {
|
|
951
1065
|
fallbackEntries.push('bang')
|
|
952
1066
|
// Ensure typeError/isMonadicError are in fallback even if not otherwise needed
|
|
@@ -1289,13 +1403,33 @@ function generateTypeCheckExpr(
|
|
|
1289
1403
|
return `${fieldPath} !== null` // nullable doesn't apply to null itself
|
|
1290
1404
|
case 'undefined':
|
|
1291
1405
|
return `${fieldPath} !== undefined`
|
|
1292
|
-
case 'array':
|
|
1293
|
-
|
|
1406
|
+
case 'array': {
|
|
1407
|
+
// Always require an Array. If item type is known and non-trivial,
|
|
1408
|
+
// also validate every item — `arr: [0]` means "array of integers",
|
|
1409
|
+
// not "any array". Without this, a function returning
|
|
1410
|
+
// `[MonadicError, MonadicError]` would pass the `: [0]` return-
|
|
1411
|
+
// type check (it's an array) and surface a confusing array-of-
|
|
1412
|
+
// errors to the caller.
|
|
1413
|
+
const itemCheck =
|
|
1414
|
+
type.items && type.items.kind !== 'any'
|
|
1415
|
+
? generateTypeCheckExpr('__a', type.items)
|
|
1416
|
+
: null
|
|
1417
|
+
if (itemCheck) {
|
|
1418
|
+
check = `(!Array.isArray(${fieldPath}) || ${fieldPath}.some(__a => ${itemCheck}))`
|
|
1419
|
+
} else {
|
|
1420
|
+
check = `!Array.isArray(${fieldPath})`
|
|
1421
|
+
}
|
|
1294
1422
|
break
|
|
1423
|
+
}
|
|
1295
1424
|
case 'object':
|
|
1296
1425
|
// For nested objects, just check it's an object (deep validation is separate)
|
|
1297
1426
|
check = `(typeof ${fieldPath} !== 'object' || ${fieldPath} === null || Array.isArray(${fieldPath}))`
|
|
1298
1427
|
break
|
|
1428
|
+
case 'function':
|
|
1429
|
+
// Shape isn't validated at call time (we don't introspect arity or
|
|
1430
|
+
// call the function with probes) — just check it IS callable.
|
|
1431
|
+
check = `typeof ${fieldPath} !== 'function'`
|
|
1432
|
+
break
|
|
1299
1433
|
case 'union': {
|
|
1300
1434
|
const checks = (type as any).members
|
|
1301
1435
|
.map((m: TypeDescriptor) => generateTypeCheckExpr(fieldPath, m))
|
|
@@ -1321,6 +1455,52 @@ function generateTypeCheckExpr(
|
|
|
1321
1455
|
// Alias for backward compatibility with other functions that use this
|
|
1322
1456
|
const generateTypeCheck = generateTypeCheckExpr
|
|
1323
1457
|
|
|
1458
|
+
/** Kinds checkType can validate by string name (no RuntimeType needed). */
|
|
1459
|
+
const SIMPLE_KINDS = new Set([
|
|
1460
|
+
'string',
|
|
1461
|
+
'number',
|
|
1462
|
+
'integer',
|
|
1463
|
+
'non-negative-integer',
|
|
1464
|
+
'boolean',
|
|
1465
|
+
'function',
|
|
1466
|
+
'any',
|
|
1467
|
+
'undefined',
|
|
1468
|
+
'null',
|
|
1469
|
+
'object', // checkType handles this via typeof
|
|
1470
|
+
])
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* Generate a `__tjs.checkFnShape(...)` call that validates a passed-in
|
|
1474
|
+
* function's declared shape against the expected shape ONCE at pass time.
|
|
1475
|
+
* On mismatch the param is reassigned to a MonadicError; the existing
|
|
1476
|
+
* `if (param instanceof Error) return param` check above handles
|
|
1477
|
+
* propagation. On match the param is unchanged. Untyped functions
|
|
1478
|
+
* (no `__tjs` metadata — anonymous arrows) pass through unchanged.
|
|
1479
|
+
*
|
|
1480
|
+
* Returns null when the expected shape can't be represented as simple
|
|
1481
|
+
* TypeSpec strings, or when there's nothing useful to check (all-`any`).
|
|
1482
|
+
*/
|
|
1483
|
+
function generateFunctionShapeCheck(
|
|
1484
|
+
paramName: string,
|
|
1485
|
+
type: TypeDescriptor,
|
|
1486
|
+
path: string
|
|
1487
|
+
): string | null {
|
|
1488
|
+
const fnParams = (type.params ?? []) as Array<{
|
|
1489
|
+
name: string
|
|
1490
|
+
type: TypeDescriptor
|
|
1491
|
+
}>
|
|
1492
|
+
const fnReturns = type.returns ?? { kind: 'any' as const }
|
|
1493
|
+
const paramKinds = fnParams.map((p) => p.type?.kind)
|
|
1494
|
+
const allSimple =
|
|
1495
|
+
paramKinds.every((k) => k && SIMPLE_KINDS.has(k)) &&
|
|
1496
|
+
SIMPLE_KINDS.has(fnReturns.kind)
|
|
1497
|
+
const hasUsefulCheck =
|
|
1498
|
+
paramKinds.some((k) => k !== 'any') || fnReturns.kind !== 'any'
|
|
1499
|
+
if (!allSimple || !hasUsefulCheck) return null
|
|
1500
|
+
const paramTypesJson = JSON.stringify(paramKinds)
|
|
1501
|
+
return `if (typeof ${paramName} === 'function') ${paramName} = __tjs.checkFnShape(${paramName}, ${paramTypesJson}, '${fnReturns.kind}', '${path}');`
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1324
1504
|
/**
|
|
1325
1505
|
* Generate the complete function wrapper with inline validation
|
|
1326
1506
|
*
|
package/src/lang/inference.ts
CHANGED
|
@@ -137,6 +137,31 @@ export function inferTypeFromValue(node: Expression): TypeDescriptor {
|
|
|
137
137
|
return { kind: 'any' }
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
case 'ArrowFunctionExpression':
|
|
141
|
+
case 'FunctionExpression': {
|
|
142
|
+
// Function example value (e.g. `fn = (x) => x` or `cb = function() {}`).
|
|
143
|
+
// Capture parameter names + types and (for concise arrow bodies)
|
|
144
|
+
// infer the return type from the body expression.
|
|
145
|
+
const fn = node as any
|
|
146
|
+
const params: Array<{ name: string; type: TypeDescriptor }> =
|
|
147
|
+
fn.params.map((p: any) => paramShape(p))
|
|
148
|
+
|
|
149
|
+
// Concise arrow body: body IS the return expression, so we can
|
|
150
|
+
// infer its type. Block bodies (function expressions, multi-line
|
|
151
|
+
// arrows) stay `any` — scanning return statements is a separate
|
|
152
|
+
// can of worms.
|
|
153
|
+
let returns: TypeDescriptor = { kind: 'any' }
|
|
154
|
+
if (
|
|
155
|
+
fn.type === 'ArrowFunctionExpression' &&
|
|
156
|
+
fn.body &&
|
|
157
|
+
fn.body.type !== 'BlockStatement'
|
|
158
|
+
) {
|
|
159
|
+
returns = inferTypeFromValue(fn.body)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { kind: 'function', params, returns }
|
|
163
|
+
}
|
|
164
|
+
|
|
140
165
|
case 'UnaryExpression': {
|
|
141
166
|
const op = (node as any).operator
|
|
142
167
|
const arg = (node as any).argument
|
|
@@ -168,6 +193,35 @@ export function inferTypeFromValue(node: Expression): TypeDescriptor {
|
|
|
168
193
|
}
|
|
169
194
|
}
|
|
170
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Extract a function-parameter shape from a Pattern AST node. Used when
|
|
198
|
+
* we encounter a function/arrow EXAMPLE value and want to record what
|
|
199
|
+
* its declared parameters look like for documentation and .d.ts emit.
|
|
200
|
+
*
|
|
201
|
+
* Plain identifier (`x`) → { name: 'x', type: any }
|
|
202
|
+
* Default value (`x = 0`) → { name: 'x', type: integer }
|
|
203
|
+
* Rest (`...args`) → { name: '...args', type: array }
|
|
204
|
+
* Destructuring (`{a}`, `[x]`) → name: '?', type: any (we'd need
|
|
205
|
+
* to mirror parseParameter to do
|
|
206
|
+
* this properly; not worth the
|
|
207
|
+
* complexity for example values)
|
|
208
|
+
*/
|
|
209
|
+
function paramShape(p: any): { name: string; type: TypeDescriptor } {
|
|
210
|
+
if (p.type === 'Identifier') {
|
|
211
|
+
return { name: p.name, type: { kind: 'any' } }
|
|
212
|
+
}
|
|
213
|
+
if (p.type === 'AssignmentPattern' && p.left?.type === 'Identifier') {
|
|
214
|
+
return { name: p.left.name, type: inferTypeFromValue(p.right) }
|
|
215
|
+
}
|
|
216
|
+
if (p.type === 'RestElement' && p.argument?.type === 'Identifier') {
|
|
217
|
+
return {
|
|
218
|
+
name: `...${p.argument.name}`,
|
|
219
|
+
type: { kind: 'array', items: { kind: 'any' } },
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { name: '?', type: { kind: 'any' } }
|
|
223
|
+
}
|
|
224
|
+
|
|
171
225
|
/**
|
|
172
226
|
* Parse a parameter and extract its type and default value
|
|
173
227
|
*
|
package/src/lang/linter.test.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, it, expect } from 'bun:test'
|
|
6
|
-
import { lint } from './linter'
|
|
6
|
+
import { lint, type LintDiagnostic } from './linter'
|
|
7
7
|
|
|
8
8
|
describe('TJS Linter', () => {
|
|
9
9
|
describe('no-explicit-new rule', () => {
|
|
@@ -145,4 +145,107 @@ describe('TJS Linter', () => {
|
|
|
145
145
|
expect(result.diagnostics[0].rule).toBe('parse-error')
|
|
146
146
|
})
|
|
147
147
|
})
|
|
148
|
+
|
|
149
|
+
describe('safe-assign rule (TjsSafeAssign)', () => {
|
|
150
|
+
const onlySafeAssign = (result: { diagnostics: LintDiagnostic[] }) =>
|
|
151
|
+
result.diagnostics.filter((d) => d.rule.startsWith('safe-assign'))
|
|
152
|
+
|
|
153
|
+
it('flags `let x` with no initializer or annotation', () => {
|
|
154
|
+
const result = lint(`function f() { let x; return x }`)
|
|
155
|
+
const diags = onlySafeAssign(result)
|
|
156
|
+
expect(diags.length).toBe(1)
|
|
157
|
+
expect(diags[0].rule).toBe('safe-assign-let-needs-type')
|
|
158
|
+
expect(diags[0].severity).toBe('warning')
|
|
159
|
+
expect(diags[0].message).toContain("'let x'")
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('flags `let x = undefined` with no annotation', () => {
|
|
163
|
+
const result = lint(`function f() { let x = undefined; return x }`)
|
|
164
|
+
const diags = onlySafeAssign(result)
|
|
165
|
+
expect(diags.length).toBe(1)
|
|
166
|
+
expect(diags[0].rule).toBe('safe-assign-let-needs-type')
|
|
167
|
+
expect(diags[0].message).toContain('undefined')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('flags `let x = null` with no annotation', () => {
|
|
171
|
+
const result = lint(`function f() { let x = null; return x }`)
|
|
172
|
+
const diags = onlySafeAssign(result)
|
|
173
|
+
expect(diags.length).toBe(1)
|
|
174
|
+
expect(diags[0].rule).toBe('safe-assign-let-needs-type')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('flags `let x = void 0` with no annotation', () => {
|
|
178
|
+
const result = lint(`function f() { let x = void 0; return x }`)
|
|
179
|
+
const diags = onlySafeAssign(result)
|
|
180
|
+
expect(diags.length).toBe(1)
|
|
181
|
+
expect(diags[0].rule).toBe('safe-assign-let-needs-type')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('accepts `let x = 0` (inferable initializer)', () => {
|
|
185
|
+
const result = lint(`function f() { let x = 0; return x }`)
|
|
186
|
+
expect(onlySafeAssign(result).length).toBe(0)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("accepts `let x: ''` (annotation, no init)", () => {
|
|
190
|
+
const result = lint(`function f() { let x: ''; return x }`)
|
|
191
|
+
expect(onlySafeAssign(result).length).toBe(0)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("accepts `let x: '' = 'hi'` (annotation + init)", () => {
|
|
195
|
+
const result = lint(`function f() { let x: '' = 'hi'; return x }`)
|
|
196
|
+
expect(onlySafeAssign(result).length).toBe(0)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('flags `x = undefined` reassignment to a typed let', () => {
|
|
200
|
+
const result = lint(
|
|
201
|
+
`function f() { let x = 'hi'; x = undefined; return x }`
|
|
202
|
+
)
|
|
203
|
+
const diags = onlySafeAssign(result)
|
|
204
|
+
expect(diags.length).toBe(1)
|
|
205
|
+
expect(diags[0].rule).toBe('safe-assign-no-nullish')
|
|
206
|
+
expect(diags[0].message).toContain("'x'")
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('flags `x = null` reassignment to an annotated let', () => {
|
|
210
|
+
const result = lint(`function f() { let x: 0; x = null; return x }`)
|
|
211
|
+
const diags = onlySafeAssign(result)
|
|
212
|
+
expect(diags.length).toBe(1)
|
|
213
|
+
expect(diags[0].rule).toBe('safe-assign-no-nullish')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('does not flag `x = "hi"` reassignment', () => {
|
|
217
|
+
const result = lint(`function f() { let x = 'a'; x = 'b'; return x }`)
|
|
218
|
+
expect(onlySafeAssign(result).length).toBe(0)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('does not run rule under TjsCompat directive', () => {
|
|
222
|
+
const result = lint(`
|
|
223
|
+
TjsCompat
|
|
224
|
+
function f() { let x; return x }
|
|
225
|
+
`)
|
|
226
|
+
expect(onlySafeAssign(result).length).toBe(0)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('emits errors (not warnings) under strict option', () => {
|
|
230
|
+
const result = lint(`function f() { let x; return x }`, {
|
|
231
|
+
strict: true,
|
|
232
|
+
})
|
|
233
|
+
const diags = onlySafeAssign(result)
|
|
234
|
+
expect(diags.length).toBe(1)
|
|
235
|
+
expect(diags[0].severity).toBe('error')
|
|
236
|
+
expect(result.valid).toBe(false)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('safeAssign: false option disables the rule even in native TJS', () => {
|
|
240
|
+
const result = lint(`function f() { let x; return x }`, {
|
|
241
|
+
safeAssign: false,
|
|
242
|
+
})
|
|
243
|
+
expect(onlySafeAssign(result).length).toBe(0)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('does not flag const declarations', () => {
|
|
247
|
+
const result = lint(`function f() { const x = 0; return x }`)
|
|
248
|
+
expect(onlySafeAssign(result).length).toBe(0)
|
|
249
|
+
})
|
|
250
|
+
})
|
|
148
251
|
})
|
package/src/lang/linter.ts
CHANGED
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
* POC: Focus on variable usage first, then type checking.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type {
|
|
13
|
+
import type {
|
|
14
|
+
Program,
|
|
15
|
+
Node,
|
|
16
|
+
Identifier,
|
|
17
|
+
VariableDeclaration,
|
|
18
|
+
AssignmentExpression,
|
|
19
|
+
Expression,
|
|
20
|
+
} from 'acorn'
|
|
14
21
|
import { parse } from './parser'
|
|
15
22
|
import * as walk from 'acorn-walk'
|
|
16
23
|
|
|
@@ -36,8 +43,16 @@ export interface LintOptions {
|
|
|
36
43
|
unreachableCode?: boolean
|
|
37
44
|
/** Warn about explicit `new` keyword usage (TJS makes classes callable without new) */
|
|
38
45
|
noExplicitNew?: boolean
|
|
46
|
+
/**
|
|
47
|
+
* Check `let` declarations for missing type information and forbid literal
|
|
48
|
+
* undefined/null assignments to typed lets. If undefined, the parser's
|
|
49
|
+
* `TjsSafeAssign` mode controls whether the rule runs.
|
|
50
|
+
*/
|
|
51
|
+
safeAssign?: boolean
|
|
39
52
|
/** Filename for error messages */
|
|
40
53
|
filename?: string
|
|
54
|
+
/** Treat safeAssign violations as errors instead of warnings (TjsStrict semantics) */
|
|
55
|
+
strict?: boolean
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
const DEFAULT_OPTIONS: LintOptions = {
|
|
@@ -56,12 +71,16 @@ export function lint(source: string, options: LintOptions = {}): LintResult {
|
|
|
56
71
|
|
|
57
72
|
// Parse the source
|
|
58
73
|
let program: Program
|
|
74
|
+
let letAnnotations: Map<string, string> = new Map()
|
|
75
|
+
let safeAssignMode = false
|
|
59
76
|
try {
|
|
60
77
|
const result = parse(source, {
|
|
61
78
|
filename: opts.filename,
|
|
62
79
|
colonShorthand: true,
|
|
63
80
|
})
|
|
64
81
|
program = result.ast
|
|
82
|
+
letAnnotations = result.letAnnotations
|
|
83
|
+
safeAssignMode = result.tjsModes.tjsSafeAssign
|
|
65
84
|
} catch (error: any) {
|
|
66
85
|
return {
|
|
67
86
|
diagnostics: [
|
|
@@ -76,6 +95,11 @@ export function lint(source: string, options: LintOptions = {}): LintResult {
|
|
|
76
95
|
valid: false,
|
|
77
96
|
}
|
|
78
97
|
}
|
|
98
|
+
const safeAssignEnabled =
|
|
99
|
+
opts.safeAssign !== undefined ? opts.safeAssign : safeAssignMode
|
|
100
|
+
const safeAssignSeverity: LintDiagnostic['severity'] = opts.strict
|
|
101
|
+
? 'error'
|
|
102
|
+
: 'warning'
|
|
79
103
|
|
|
80
104
|
// Track variable declarations and usages per scope
|
|
81
105
|
const scopes: Scope[] = [createScope()] // Global scope
|
|
@@ -177,6 +201,80 @@ export function lint(source: string, options: LintOptions = {}): LintResult {
|
|
|
177
201
|
})
|
|
178
202
|
}
|
|
179
203
|
|
|
204
|
+
// TjsSafeAssign: lets need an initializer or `: <example>` annotation, and
|
|
205
|
+
// typed lets must not be (re)assigned literal undefined/null/void 0.
|
|
206
|
+
if (safeAssignEnabled) {
|
|
207
|
+
// First pass: track which lets are "typed" (annotated OR have a non-nullish initializer)
|
|
208
|
+
const typedLets = new Set<string>()
|
|
209
|
+
walk.simple(program, {
|
|
210
|
+
VariableDeclaration(node: VariableDeclaration) {
|
|
211
|
+
if (node.kind !== 'let') return
|
|
212
|
+
for (const d of node.declarations) {
|
|
213
|
+
if (d.id.type !== 'Identifier') continue
|
|
214
|
+
const name = d.id.name
|
|
215
|
+
const annotated = letAnnotations.has(name)
|
|
216
|
+
const init = d.init
|
|
217
|
+
if (annotated) {
|
|
218
|
+
typedLets.add(name)
|
|
219
|
+
} else if (init && !isLiteralNullish(init)) {
|
|
220
|
+
typedLets.add(name)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// Declaration-site rule: missing type information
|
|
227
|
+
walk.simple(program, {
|
|
228
|
+
VariableDeclaration(node: VariableDeclaration) {
|
|
229
|
+
if (node.kind !== 'let') return
|
|
230
|
+
for (const d of node.declarations) {
|
|
231
|
+
if (d.id.type !== 'Identifier') continue
|
|
232
|
+
const name = d.id.name
|
|
233
|
+
if (letAnnotations.has(name)) continue
|
|
234
|
+
if (!d.init) {
|
|
235
|
+
diagnostics.push({
|
|
236
|
+
severity: safeAssignSeverity,
|
|
237
|
+
message: `'let ${name}' has no initializer or type annotation. Add an initializer (let ${name} = ...) or annotate (let ${name}: <example>).`,
|
|
238
|
+
line: (d as any).loc?.start?.line,
|
|
239
|
+
column: (d as any).loc?.start?.column,
|
|
240
|
+
rule: 'safe-assign-let-needs-type',
|
|
241
|
+
})
|
|
242
|
+
} else if (isLiteralNullish(d.init)) {
|
|
243
|
+
diagnostics.push({
|
|
244
|
+
severity: safeAssignSeverity,
|
|
245
|
+
message: `'let ${name}' is initialized to ${describeNullish(
|
|
246
|
+
d.init
|
|
247
|
+
)} with no type annotation. Annotate (let ${name}: <example>) to record the intended type.`,
|
|
248
|
+
line: (d as any).loc?.start?.line,
|
|
249
|
+
column: (d as any).loc?.start?.column,
|
|
250
|
+
rule: 'safe-assign-let-needs-type',
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// Use-site rule: literal undefined/null assigned to a typed let
|
|
258
|
+
walk.simple(program, {
|
|
259
|
+
AssignmentExpression(node: AssignmentExpression) {
|
|
260
|
+
if (node.operator !== '=') return
|
|
261
|
+
if (node.left.type !== 'Identifier') return
|
|
262
|
+
const name = (node.left as Identifier).name
|
|
263
|
+
if (!typedLets.has(name)) return
|
|
264
|
+
if (!isLiteralNullish(node.right)) return
|
|
265
|
+
diagnostics.push({
|
|
266
|
+
severity: safeAssignSeverity,
|
|
267
|
+
message: `Cannot assign ${describeNullish(
|
|
268
|
+
node.right
|
|
269
|
+
)} to typed let '${name}'.`,
|
|
270
|
+
line: (node as any).loc?.start?.line,
|
|
271
|
+
column: (node as any).loc?.start?.column,
|
|
272
|
+
rule: 'safe-assign-no-nullish',
|
|
273
|
+
})
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
180
278
|
// Check for explicit `new` keyword usage
|
|
181
279
|
// In TJS, classes are callable without `new`, so using `new` is unnecessary
|
|
182
280
|
if (opts.noExplicitNew) {
|
|
@@ -226,6 +324,31 @@ function createScope(): Scope {
|
|
|
226
324
|
return { declarations: new Map() }
|
|
227
325
|
}
|
|
228
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Is the given expression a literal that evaluates to undefined or null?
|
|
329
|
+
* Catches: `undefined`, `null`, `void 0`, `void <any-literal>`.
|
|
330
|
+
*/
|
|
331
|
+
function isLiteralNullish(node: Expression | null | undefined): boolean {
|
|
332
|
+
if (!node) return false
|
|
333
|
+
if (node.type === 'Identifier' && (node as Identifier).name === 'undefined') {
|
|
334
|
+
return true
|
|
335
|
+
}
|
|
336
|
+
if (node.type === 'Literal' && (node as any).value === null) return true
|
|
337
|
+
if (node.type === 'UnaryExpression' && (node as any).operator === 'void') {
|
|
338
|
+
return true
|
|
339
|
+
}
|
|
340
|
+
return false
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function describeNullish(node: Expression): string {
|
|
344
|
+
if (node.type === 'Identifier') return 'undefined'
|
|
345
|
+
if (node.type === 'Literal' && (node as any).value === null) return 'null'
|
|
346
|
+
if (node.type === 'UnaryExpression' && (node as any).operator === 'void') {
|
|
347
|
+
return 'void <expr> (undefined)'
|
|
348
|
+
}
|
|
349
|
+
return 'a nullish value'
|
|
350
|
+
}
|
|
351
|
+
|
|
229
352
|
function addDeclaration(scope: Scope, node: Node, kind: Declaration['kind']) {
|
|
230
353
|
if (node.type === 'Identifier') {
|
|
231
354
|
scope.declarations.set((node as Identifier).name, {
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
ContextFrame,
|
|
13
13
|
TjsModes,
|
|
14
14
|
} from './parser-types'
|
|
15
|
+
import { locAt } from './parser-transforms'
|
|
15
16
|
|
|
16
17
|
export function transformParenExpressions(
|
|
17
18
|
source: string,
|
|
@@ -331,6 +332,23 @@ export function transformParenExpressions(
|
|
|
331
332
|
i = typeResult.endPos
|
|
332
333
|
}
|
|
333
334
|
}
|
|
335
|
+
|
|
336
|
+
// Catch a common mistake: writing `=> {` after a function declaration's
|
|
337
|
+
// return type (or after `)`), as if it were an arrow function. Without
|
|
338
|
+
// this check, the `=>` would pass through to Acorn, which complains
|
|
339
|
+
// with a generic "Unexpected token" at a misleading position.
|
|
340
|
+
let arrowCheck = i
|
|
341
|
+
while (arrowCheck < source.length && /\s/.test(source[arrowCheck]))
|
|
342
|
+
arrowCheck++
|
|
343
|
+
if (source[arrowCheck] === '=' && source[arrowCheck + 1] === '>') {
|
|
344
|
+
throw new SyntaxError(
|
|
345
|
+
"Unexpected '=>' after function declaration. " +
|
|
346
|
+
'Function declarations use `function name(params) { body }`, ' +
|
|
347
|
+
'not arrow syntax. Remove the `=>`.',
|
|
348
|
+
locAt(ctx.originalSource, arrowCheck),
|
|
349
|
+
ctx.originalSource
|
|
350
|
+
)
|
|
351
|
+
}
|
|
334
352
|
continue
|
|
335
353
|
}
|
|
336
354
|
|
|
@@ -410,6 +428,19 @@ export function transformParenExpressions(
|
|
|
410
428
|
}
|
|
411
429
|
}
|
|
412
430
|
|
|
431
|
+
// Same `=>` check for class methods.
|
|
432
|
+
let k = i
|
|
433
|
+
while (k < source.length && /\s/.test(source[k])) k++
|
|
434
|
+
if (source[k] === '=' && source[k + 1] === '>') {
|
|
435
|
+
throw new SyntaxError(
|
|
436
|
+
"Unexpected '=>' after method declaration. " +
|
|
437
|
+
'Methods use `name(params) { body }`, not arrow syntax. ' +
|
|
438
|
+
'Remove the `=>`.',
|
|
439
|
+
locAt(ctx.originalSource, k),
|
|
440
|
+
ctx.originalSource
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
|
|
413
444
|
continue
|
|
414
445
|
}
|
|
415
446
|
|