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.
Files changed (59) hide show
  1. package/CLAUDE.md +90 -33
  2. package/bin/docs.js +4 -1
  3. package/demo/docs.json +45 -11
  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 +118 -101
  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 +3 -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 +38 -36
  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 +85 -83
  28. package/dist/tjs-lang.js.map +4 -4
  29. package/dist/tjs-vm.js +47 -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 +137 -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 +9 -4
  43. package/src/lang/emitters/js.ts +182 -2
  44. package/src/lang/inference.ts +54 -0
  45. package/src/lang/linter.test.ts +104 -1
  46. package/src/lang/linter.ts +124 -1
  47. package/src/lang/parser-params.ts +31 -0
  48. package/src/lang/parser-transforms.ts +304 -0
  49. package/src/lang/parser-types.ts +2 -0
  50. package/src/lang/parser.test.ts +73 -1
  51. package/src/lang/parser.ts +34 -1
  52. package/src/lang/runtime.ts +98 -0
  53. package/src/lang/types.ts +6 -0
  54. package/src/rbac/index.ts +2 -2
  55. package/src/rbac/rules.tjs.d.ts +9 -0
  56. package/src/vm/atoms/batteries.ts +2 -2
  57. package/src/vm/runtime.ts +10 -3
  58. package/dist/src/rbac/rules.d.ts +0 -184
  59. package/src/rbac/rules.js +0 -338
@@ -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
- 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
+ }
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
  *
@@ -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
  })
@@ -10,7 +10,14 @@
10
10
  * POC: Focus on variable usage first, then type checking.
11
11
  */
12
12
 
13
- import type { Program, Node, Identifier, VariableDeclaration } from 'acorn'
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