tjs-lang 0.7.6 → 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 +101 -26
- package/bin/docs.js +4 -1
- package/demo/docs.json +46 -12
- 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 +140 -119
- 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 +9 -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 +44 -39
- 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 +86 -80
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-vm.js +50 -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 +177 -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 +16 -4
- package/src/lang/emitters/js.ts +208 -2
- package/src/lang/features.test.ts +22 -0
- 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 +539 -6
- package/src/lang/parser-types.ts +2 -0
- package/src/lang/parser.test.ts +73 -1
- package/src/lang/parser.ts +85 -1
- package/src/lang/runtime.ts +98 -0
- package/src/lang/tests.ts +21 -8
- 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
|
@@ -657,6 +657,12 @@ export function runAllTests(
|
|
|
657
657
|
// Build mock setup
|
|
658
658
|
const mockSetup = mocks.map((m) => m.body).join('\n')
|
|
659
659
|
|
|
660
|
+
// Test bodies may reference Is/IsNot/Eq/NotEq/TypeOf — these come from the
|
|
661
|
+
// == / != / typeof source-level transforms applied to test bodies in js.ts.
|
|
662
|
+
// The module's own destructuring may not include them (if the module never
|
|
663
|
+
// uses ==), so each test block re-destructures into its own block scope.
|
|
664
|
+
const testRuntimeImports = `const { Is, IsNot, Eq, NotEq, TypeOf } = globalThis.__tjs ?? {};`
|
|
665
|
+
|
|
660
666
|
// Build test execution code that runs all tests in sequence
|
|
661
667
|
const testBodies = tests
|
|
662
668
|
.map((t, i) => {
|
|
@@ -668,6 +674,7 @@ export function runAllTests(
|
|
|
668
674
|
return `
|
|
669
675
|
// Test ${i}: ${t.description}
|
|
670
676
|
try {
|
|
677
|
+
${testRuntimeImports}
|
|
671
678
|
${body}
|
|
672
679
|
__testResults.push({ idx: ${i}, passed: true });
|
|
673
680
|
} catch (e) {
|
|
@@ -869,6 +876,13 @@ export function runAllTests(
|
|
|
869
876
|
// skip tests gracefully rather than marking them as failures
|
|
870
877
|
const isUnresolvedRef = hasUnresolvedImports && e instanceof ReferenceError
|
|
871
878
|
|
|
879
|
+
// The error came from module-level code (e.g. an undefined identifier
|
|
880
|
+
// in `console.log(... x ...)`), NOT from the function/test under test.
|
|
881
|
+
// Don't attribute a line — otherwise the editor would mark the function
|
|
882
|
+
// declaration's line as the error site, misleading the user about where
|
|
883
|
+
// the actual problem is. The test still appears as failed in the test
|
|
884
|
+
// list with the explanatory message; the user finds the real error
|
|
885
|
+
// through the runtime console.
|
|
872
886
|
for (const test of tests) {
|
|
873
887
|
results.push({
|
|
874
888
|
description: test.description,
|
|
@@ -876,7 +890,6 @@ export function runAllTests(
|
|
|
876
890
|
error: isUnresolvedRef
|
|
877
891
|
? undefined
|
|
878
892
|
: `Module execution failed: ${e.message}`,
|
|
879
|
-
line: test.line,
|
|
880
893
|
})
|
|
881
894
|
}
|
|
882
895
|
for (const info of syncSigTestInfos) {
|
|
@@ -890,7 +903,6 @@ export function runAllTests(
|
|
|
890
903
|
? undefined
|
|
891
904
|
: `Module execution failed: ${e.message}`,
|
|
892
905
|
isSignatureTest: true,
|
|
893
|
-
line: info.line,
|
|
894
906
|
})
|
|
895
907
|
}
|
|
896
908
|
}
|
|
@@ -942,7 +954,7 @@ function runTestBlocks(
|
|
|
942
954
|
const tjsStub = `
|
|
943
955
|
const __saved_tjs = globalThis.__tjs;
|
|
944
956
|
class __MonadicError extends Error { constructor(m,p,e,a,c){super(m);this.name='MonadicError';this.path=p;this.expected=e;this.actual=a;this.callStack=c;} }
|
|
945
|
-
const __stub_tjs = { version: '0.0.0', MonadicError: __MonadicError, pushStack: () => {}, popStack: () => {}, getStack: () => [], typeError: (path, expected, value) => new __MonadicError(\`Type error at \${path}: expected \${expected}\`, path, expected, typeof value), createRuntime: function() { return this; } };
|
|
957
|
+
const __stub_tjs = { version: '0.0.0', MonadicError: __MonadicError, pushStack: () => {}, popStack: () => {}, getStack: () => [], typeError: (path, expected, value) => new __MonadicError(\`Type error at \${path}: expected \${expected}\`, path, expected, typeof value), toBool: (v) => (v instanceof Boolean || v instanceof Number || v instanceof String) ? Boolean(v.valueOf()) : Boolean(v), createRuntime: function() { return this; } };
|
|
946
958
|
globalThis.__tjs = __stub_tjs;
|
|
947
959
|
`
|
|
948
960
|
const tjsRestore = `globalThis.__tjs = __saved_tjs;`
|
|
@@ -1301,7 +1313,7 @@ function runSignatureTest(
|
|
|
1301
1313
|
const tjsStub = `
|
|
1302
1314
|
const __saved_tjs = globalThis.__tjs;
|
|
1303
1315
|
class __MonadicError extends Error { constructor(m,p,e,a,c){super(m);this.name='MonadicError';this.path=p;this.expected=e;this.actual=a;this.callStack=c;} }
|
|
1304
|
-
const __stub_tjs = { version: '0.0.0', MonadicError: __MonadicError, pushStack: () => {}, popStack: () => {}, getStack: () => [], typeError: (path, expected, value) => new __MonadicError(\`Type error at \${path}: expected \${expected}\`, path, expected, typeof value), createRuntime: function() { return this; } };
|
|
1316
|
+
const __stub_tjs = { version: '0.0.0', MonadicError: __MonadicError, pushStack: () => {}, popStack: () => {}, getStack: () => [], typeError: (path, expected, value) => new __MonadicError(\`Type error at \${path}: expected \${expected}\`, path, expected, typeof value), toBool: (v) => (v instanceof Boolean || v instanceof Number || v instanceof String) ? Boolean(v.valueOf()) : Boolean(v), createRuntime: function() { return this; } };
|
|
1305
1317
|
globalThis.__tjs = __stub_tjs;
|
|
1306
1318
|
`
|
|
1307
1319
|
const tjsRestore = `globalThis.__tjs = __saved_tjs;`
|
package/src/lang/emitters/js.ts
CHANGED
|
@@ -53,7 +53,12 @@ import {
|
|
|
53
53
|
extractTDoc,
|
|
54
54
|
preprocess,
|
|
55
55
|
transformExtensionCalls,
|
|
56
|
+
stripLineComments,
|
|
56
57
|
} from '../parser'
|
|
58
|
+
import {
|
|
59
|
+
transformEqualityToStructural,
|
|
60
|
+
transformIsOperators,
|
|
61
|
+
} from '../parser-transforms'
|
|
57
62
|
import type { TypeDescriptor, ParameterDescriptor } from '../types'
|
|
58
63
|
import { inferTypeFromValue, parseParameter } from '../inference'
|
|
59
64
|
import { extractTests } from '../tests'
|
|
@@ -64,6 +69,10 @@ import {
|
|
|
64
69
|
} from './js-tests'
|
|
65
70
|
export { stripModuleSyntax, stripTjsPreamble } from './js-tests'
|
|
66
71
|
import { generateWasmBootstrap } from './js-wasm'
|
|
72
|
+
import {
|
|
73
|
+
rewriteBoolCoercion,
|
|
74
|
+
rewriteBoolCoercionInSource,
|
|
75
|
+
} from '../bool-coercion'
|
|
67
76
|
|
|
68
77
|
export interface TJSTranspileOptions {
|
|
69
78
|
/** Filename for error messages */
|
|
@@ -414,6 +423,19 @@ function generateInlineValidationCode(
|
|
|
414
423
|
// 2. Type checks with proper error emission
|
|
415
424
|
for (const [paramName, param] of params) {
|
|
416
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
|
+
|
|
417
439
|
const typeCheck = generateTypeCheckExpr(paramName, param.type)
|
|
418
440
|
|
|
419
441
|
if (typeCheck) {
|
|
@@ -431,6 +453,19 @@ function generateInlineValidationCode(
|
|
|
431
453
|
)
|
|
432
454
|
}
|
|
433
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
|
+
}
|
|
434
469
|
}
|
|
435
470
|
|
|
436
471
|
if (lines.length === 0) return null
|
|
@@ -576,6 +611,10 @@ export function transpileToJS(
|
|
|
576
611
|
} = options
|
|
577
612
|
const warnings: string[] = []
|
|
578
613
|
|
|
614
|
+
// Strip single-line comments early — apostrophes in comments (e.g. "don't")
|
|
615
|
+
// confuse brace matching in test extraction and other transforms
|
|
616
|
+
source = stripLineComments(source)
|
|
617
|
+
|
|
579
618
|
// Extract source file annotation if present (from TS transpilation)
|
|
580
619
|
const sourceFileAnnotation = extractSourceFileAnnotation(source)
|
|
581
620
|
const effectiveFilename = sourceFileAnnotation || filename
|
|
@@ -600,6 +639,29 @@ export function transpileToJS(
|
|
|
600
639
|
// Preprocess source (handles TJS syntax transformations)
|
|
601
640
|
const preprocessed = preprocess(cleanSource)
|
|
602
641
|
|
|
642
|
+
// Apply the same source-level equality transforms to extracted test/mock
|
|
643
|
+
// bodies so they observe the module's TJS semantics (e.g. structural ==).
|
|
644
|
+
// Test bodies are extracted as raw text before parse(), so they would
|
|
645
|
+
// otherwise run with native JS == coercion regardless of TjsEquals mode.
|
|
646
|
+
for (const t of tests) {
|
|
647
|
+
t.body = transformIsOperators(t.body)
|
|
648
|
+
if (preprocessed.tjsModes.tjsEquals) {
|
|
649
|
+
t.body = transformEqualityToStructural(t.body)
|
|
650
|
+
}
|
|
651
|
+
if (preprocessed.tjsModes.tjsStandard) {
|
|
652
|
+
t.body = rewriteBoolCoercionInSource(t.body)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
for (const m of mocks) {
|
|
656
|
+
m.body = transformIsOperators(m.body)
|
|
657
|
+
if (preprocessed.tjsModes.tjsEquals) {
|
|
658
|
+
m.body = transformEqualityToStructural(m.body)
|
|
659
|
+
}
|
|
660
|
+
if (preprocessed.tjsModes.tjsStandard) {
|
|
661
|
+
m.body = rewriteBoolCoercionInSource(m.body)
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
603
665
|
// Build types map for all functions
|
|
604
666
|
const allTypes: Record<string, TJSTypeInfo> = {}
|
|
605
667
|
|
|
@@ -645,6 +707,41 @@ export function transpileToJS(
|
|
|
645
707
|
warnings.push(...funcWarnings)
|
|
646
708
|
allTypes[funcName] = types
|
|
647
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
|
+
|
|
648
745
|
// Clean up param defaults in the emitted JS.
|
|
649
746
|
// After colon→equals transform, `x: false | undefined` becomes
|
|
650
747
|
// `x = false | undefined` in the parsed source.
|
|
@@ -754,6 +851,18 @@ export function transpileToJS(
|
|
|
754
851
|
}
|
|
755
852
|
}
|
|
756
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
|
+
|
|
757
866
|
// Apply deletions first (reverse order to maintain offsets), then insertions.
|
|
758
867
|
// Deletions strip | union suffixes from param defaults in the output JS.
|
|
759
868
|
deletions.sort((a, b) => b.start - a.start)
|
|
@@ -795,6 +904,8 @@ export function transpileToJS(
|
|
|
795
904
|
const needsEnum = /\bEnum\(/.test(code)
|
|
796
905
|
const needsUnion = /\bUnion\(/.test(code)
|
|
797
906
|
const needsBang = code.includes('__tjs.bang(')
|
|
907
|
+
const needsToBool = code.includes('__tjs.toBool(')
|
|
908
|
+
const needsCheckFnShape = code.includes('__tjs.checkFnShape(')
|
|
798
909
|
const needsSafeEval = preprocessed.tjsModes.tjsSafeEval
|
|
799
910
|
|
|
800
911
|
const needsRuntime =
|
|
@@ -811,6 +922,8 @@ export function transpileToJS(
|
|
|
811
922
|
needsEnum ||
|
|
812
923
|
needsUnion ||
|
|
813
924
|
needsBang ||
|
|
925
|
+
needsToBool ||
|
|
926
|
+
needsCheckFnShape ||
|
|
814
927
|
needsSafeEval
|
|
815
928
|
|
|
816
929
|
if (needsRuntime) {
|
|
@@ -887,6 +1000,28 @@ export function transpileToJS(
|
|
|
887
1000
|
`function Union(d,...v){const vals=v.flat();return{description:d,check:x=>vals.includes(x),values:vals,__runtimeType:true}}`
|
|
888
1001
|
)
|
|
889
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
|
+
|
|
890
1025
|
// Bang access (!.) — asserted non-null member access
|
|
891
1026
|
if (needsBang) {
|
|
892
1027
|
// bang depends on typeError and isMonadicError — ensure they're inlined
|
|
@@ -921,6 +1056,11 @@ export function transpileToJS(
|
|
|
921
1056
|
if (needsFunctionPredicate) fallbackEntries.push('FunctionPredicate')
|
|
922
1057
|
if (needsEnum) fallbackEntries.push('Enum')
|
|
923
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
|
+
}
|
|
924
1064
|
if (needsBang) {
|
|
925
1065
|
fallbackEntries.push('bang')
|
|
926
1066
|
// Ensure typeError/isMonadicError are in fallback even if not otherwise needed
|
|
@@ -1263,13 +1403,33 @@ function generateTypeCheckExpr(
|
|
|
1263
1403
|
return `${fieldPath} !== null` // nullable doesn't apply to null itself
|
|
1264
1404
|
case 'undefined':
|
|
1265
1405
|
return `${fieldPath} !== undefined`
|
|
1266
|
-
case 'array':
|
|
1267
|
-
|
|
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
|
+
}
|
|
1268
1422
|
break
|
|
1423
|
+
}
|
|
1269
1424
|
case 'object':
|
|
1270
1425
|
// For nested objects, just check it's an object (deep validation is separate)
|
|
1271
1426
|
check = `(typeof ${fieldPath} !== 'object' || ${fieldPath} === null || Array.isArray(${fieldPath}))`
|
|
1272
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
|
|
1273
1433
|
case 'union': {
|
|
1274
1434
|
const checks = (type as any).members
|
|
1275
1435
|
.map((m: TypeDescriptor) => generateTypeCheckExpr(fieldPath, m))
|
|
@@ -1295,6 +1455,52 @@ function generateTypeCheckExpr(
|
|
|
1295
1455
|
// Alias for backward compatibility with other functions that use this
|
|
1296
1456
|
const generateTypeCheck = generateTypeCheckExpr
|
|
1297
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
|
+
|
|
1298
1504
|
/**
|
|
1299
1505
|
* Generate the complete function wrapper with inline validation
|
|
1300
1506
|
*
|
|
@@ -153,6 +153,28 @@ describe('Inline Tests', () => {
|
|
|
153
153
|
expect(result.tests[0].body).toContain('assert')
|
|
154
154
|
})
|
|
155
155
|
|
|
156
|
+
it('should extract test descriptions containing other quote types', () => {
|
|
157
|
+
// Regression: previously the regex excluded all quote chars from
|
|
158
|
+
// descriptions, so `test 'has "quotes"' { }` was silently dropped.
|
|
159
|
+
const result = extractTests(`
|
|
160
|
+
test 'typeof null is "null"' {
|
|
161
|
+
assert(true)
|
|
162
|
+
}
|
|
163
|
+
test "single 'apostrophe' inside" {
|
|
164
|
+
assert(true)
|
|
165
|
+
}
|
|
166
|
+
test \`backticks with "double" and 'single'\` {
|
|
167
|
+
assert(true)
|
|
168
|
+
}
|
|
169
|
+
`)
|
|
170
|
+
expect(result.tests.length).toBe(3)
|
|
171
|
+
expect(result.tests[0].description).toBe('typeof null is "null"')
|
|
172
|
+
expect(result.tests[1].description).toBe("single 'apostrophe' inside")
|
|
173
|
+
expect(result.tests[2].description).toBe(
|
|
174
|
+
`backticks with "double" and 'single'`
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
|
|
156
178
|
it('should remove tests from output code', () => {
|
|
157
179
|
const result = extractTests(`
|
|
158
180
|
function add(a, b) { return a + b }
|
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
|
})
|