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.
Files changed (61) hide show
  1. package/CLAUDE.md +101 -26
  2. package/bin/docs.js +4 -1
  3. package/demo/docs.json +46 -12
  4. package/demo/src/examples.test.ts +1 -0
  5. package/demo/src/imports.test.ts +16 -4
  6. package/demo/src/imports.ts +60 -15
  7. package/demo/src/playground-shared.ts +9 -8
  8. package/demo/src/tfs-worker.js +205 -147
  9. package/demo/src/tjs-playground.ts +34 -10
  10. package/demo/src/ts-playground.ts +24 -8
  11. package/dist/index.js +140 -119
  12. package/dist/index.js.map +4 -4
  13. package/dist/src/lang/bool-coercion.d.ts +50 -0
  14. package/dist/src/lang/docs.d.ts +31 -6
  15. package/dist/src/lang/linter.d.ts +8 -0
  16. package/dist/src/lang/parser-transforms.d.ts +18 -0
  17. package/dist/src/lang/parser-types.d.ts +2 -0
  18. package/dist/src/lang/parser.d.ts +9 -0
  19. package/dist/src/lang/runtime.d.ts +34 -0
  20. package/dist/src/lang/types.d.ts +9 -1
  21. package/dist/src/rbac/index.d.ts +1 -1
  22. package/dist/src/vm/runtime.d.ts +1 -1
  23. package/dist/tjs-eval.js +44 -39
  24. package/dist/tjs-eval.js.map +4 -4
  25. package/dist/tjs-from-ts.js +20 -20
  26. package/dist/tjs-from-ts.js.map +3 -3
  27. package/dist/tjs-lang.js +86 -80
  28. package/dist/tjs-lang.js.map +4 -4
  29. package/dist/tjs-vm.js +50 -45
  30. package/dist/tjs-vm.js.map +4 -4
  31. package/llms.txt +79 -0
  32. package/package.json +3 -2
  33. package/src/cli/commands/convert.test.ts +16 -21
  34. package/src/lang/bool-coercion.test.ts +203 -0
  35. package/src/lang/bool-coercion.ts +314 -0
  36. package/src/lang/codegen.test.ts +177 -0
  37. package/src/lang/docs.test.ts +328 -1
  38. package/src/lang/docs.ts +424 -24
  39. package/src/lang/emitters/ast.ts +11 -12
  40. package/src/lang/emitters/dts.test.ts +41 -0
  41. package/src/lang/emitters/dts.ts +9 -0
  42. package/src/lang/emitters/js-tests.ts +16 -4
  43. package/src/lang/emitters/js.ts +208 -2
  44. package/src/lang/features.test.ts +22 -0
  45. package/src/lang/inference.ts +54 -0
  46. package/src/lang/linter.test.ts +104 -1
  47. package/src/lang/linter.ts +124 -1
  48. package/src/lang/parser-params.ts +31 -0
  49. package/src/lang/parser-transforms.ts +539 -6
  50. package/src/lang/parser-types.ts +2 -0
  51. package/src/lang/parser.test.ts +73 -1
  52. package/src/lang/parser.ts +85 -1
  53. package/src/lang/runtime.ts +98 -0
  54. package/src/lang/tests.ts +21 -8
  55. package/src/lang/types.ts +6 -0
  56. package/src/rbac/index.ts +2 -2
  57. package/src/rbac/rules.tjs.d.ts +9 -0
  58. package/src/vm/atoms/batteries.ts +2 -2
  59. package/src/vm/runtime.ts +10 -3
  60. package/dist/src/rbac/rules.d.ts +0 -184
  61. 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;`
@@ -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
- check = `!Array.isArray(${fieldPath})`
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 }
@@ -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
  *
@@ -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
  })