@chenglou/freerange 0.0.2 → 0.0.4

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.
@@ -2,7 +2,12 @@ import * as ts from 'typescript'
2
2
  import type {ValueID} from '../ir/ids.ts'
3
3
  import type {ComparisonOperator, InstructionIR} from '../ir/instructions.ts'
4
4
  import type {StaticAssertionProblem} from '../ir/program.ts'
5
- import {declaredOnlyInDeclarationFiles, platformFact} from './platform.ts'
5
+ import {numberConstituent} from './numeric-intersection.ts'
6
+ import {
7
+ declaredOnlyInDeclarationFiles,
8
+ hasDefaultLibraryDeclaration,
9
+ platformFact,
10
+ } from './platform.ts'
6
11
  import {
7
12
  addInstruction,
8
13
  addInstructionAtSite,
@@ -152,7 +157,7 @@ export function lowerExpression(expression: ts.Expression, context: FunctionCont
152
157
  if (ts.isNumericLiteral(negated)) {
153
158
  return addInstruction(context, current, {kind: 'constant', value: -Number(negated.text)})
154
159
  }
155
- if (isGlobalInfinity(negated, context.checker)) {
160
+ if (isStandardGlobal(negated, 'Infinity', context)) {
156
161
  return addInstruction(context, current, {kind: 'constant', value: Number.NEGATIVE_INFINITY})
157
162
  }
158
163
  const zero = addInstruction(context, current, {kind: 'constant', value: 0})
@@ -346,7 +351,8 @@ export function lowerExpression(expression: ts.Expression, context: FunctionCont
346
351
  // `el instanceof HTMLDivElement` on a carried value: no narrowing (the analyzer does
347
352
  // not model classes), but the check itself is an effect-free operator, so it answers
348
353
  // unknown and both branches analyze — the function's other paths survive. The
349
- // declaration-file check is the same shadowing defense Math uses.
354
+ // A declaration-file constructor is opaque but safe to test; a local constructor is
355
+ // runtime code outside the accepted subset.
350
356
  if (current.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword
351
357
  && ts.isIdentifier(current.right)
352
358
  && declaredOnlyInDeclarationFiles(context.checker.getSymbolAtLocation(current.right))) {
@@ -394,10 +400,11 @@ export function lowerExpression(expression: ts.Expression, context: FunctionCont
394
400
  if (staticAnnotation != null) return lowerStaticAnnotation(staticAnnotation, context)
395
401
  if (ts.isIdentifier(current.expression)) {
396
402
  // Global parseFloat / parseInt / Number(x): honest NaN-carrying results, like their
397
- // Number.* spellings below (the declaration-file check defends against shadowing).
403
+ // Number.* spellings below. Standard-library identity defends against shadows and
404
+ // project declarations that merely reuse a built-in name.
398
405
  const globalName = current.expression.text
399
406
  if ((globalName === 'parseFloat' || globalName === 'parseInt' || globalName === 'Number')
400
- && declaredOnlyInDeclarationFiles(context.checker.getSymbolAtLocation(current.expression))) {
407
+ && isStandardGlobal(current.expression, globalName, context)) {
401
408
  for (const argument of current.arguments) lowerExpression(argument, context)
402
409
  return addInstruction(context, current, {kind: 'parsedNumber', integer: globalName === 'parseInt'})
403
410
  }
@@ -420,7 +427,10 @@ export function lowerExpression(expression: ts.Expression, context: FunctionCont
420
427
  arguments_.push(lowerParameterDefault(default_, parameter.initializer, context))
421
428
  continue
422
429
  }
423
- if (parameter.questionToken != null) {
430
+ const callableParameter = callee.signature?.declaration?.parameters[index]
431
+ if (callableParameter != null
432
+ && ts.isParameter(callableParameter)
433
+ && callableParameter.questionToken != null) {
424
434
  arguments_.push(addInstruction(context, parameter, {kind: 'nullishConstant', sentinel: 'undefined'}))
425
435
  continue
426
436
  }
@@ -452,15 +462,22 @@ export function lowerExpression(expression: ts.Expression, context: FunctionCont
452
462
  context,
453
463
  ))
454
464
  }
455
- return addInstruction(context, current, {kind: 'call', function: callee.id, arguments: arguments_})
465
+ return addInstruction(context, current, {
466
+ kind: 'call',
467
+ function: callee.id,
468
+ arguments: arguments_,
469
+ binding: callee.binding,
470
+ })
456
471
  }
457
472
  if (ts.isPropertyAccessExpression(current.expression)) {
458
- const platformCall = current.arguments.length === 0 ? platformFact(current.expression, true, context.checker) : null
473
+ const platformCall = current.arguments.length === 0
474
+ ? platformFact(current.expression, true, context.checker, context.program)
475
+ : null
459
476
  if (platformCall != null) {
460
477
  return addInstruction(context, current, {kind: 'platformValue', ...platformCall})
461
478
  }
462
479
  const method = current.expression.name.text
463
- const standardMath = isStandardMathObject(current.expression.expression, context.checker)
480
+ const standardMath = isStandardGlobal(current.expression.expression, 'Math', context)
464
481
  if (standardMath && method === 'floor' && current.arguments.length === 1) {
465
482
  requireNumberType(current.arguments[0]!, context.checker)
466
483
  const value = lowerExpression(current.arguments[0]!, context)
@@ -485,7 +502,7 @@ export function lowerExpression(expression: ts.Expression, context: FunctionCont
485
502
  // Number.isInteger / Number.isFinite: predicate checks whose branches narrow — the
486
503
  // missing halves of the bounds-check idiom (`i >= 0 && i < arr.length` proves the
487
504
  // range; Number.isInteger(i) proves the read hits an element rather than arr[1.5]).
488
- const standardNumber = isStandardNumberObject(current.expression.expression, context.checker)
505
+ const standardNumber = isStandardGlobal(current.expression.expression, 'Number', context)
489
506
  // Number.parseFloat / Number.parseInt: honest NaN sources — the result is any
490
507
  // number including NaN, and the isFinite/isNaN/isInteger narrowing downstream is
491
508
  // exactly what launders it. Arguments still lower (opaque strings carry).
@@ -514,7 +531,7 @@ export function lowerExpression(expression: ts.Expression, context: FunctionCont
514
531
  }
515
532
  }
516
533
  if (ts.isPropertyAccessExpression(current)) {
517
- const platform = platformFact(current, false, context.checker)
534
+ const platform = platformFact(current, false, context.checker, context.program)
518
535
  if (platform != null) {
519
536
  return addInstruction(context, current, {kind: 'platformValue', ...platform})
520
537
  }
@@ -589,15 +606,15 @@ function lowerStaticAnnotation(annotation: StaticAnnotation, context: FunctionCo
589
606
  if (requirement == null) {
590
607
  throw unsupported(condition, {
591
608
  kind: 'staticAssertionForm',
592
- problem: staticAssertionProblem(condition, context.checker, 'callerRequirement'),
609
+ problem: staticAssertionProblem(condition, context, 'callerRequirement'),
593
610
  })
594
611
  }
595
612
  value = lowerWrittenRequirement(requirement, condition, context)
596
613
  } else {
597
- if (!supportedWrittenAssertion(condition, context.checker)) {
614
+ if (!supportedWrittenAssertion(condition, context)) {
598
615
  throw unsupported(condition, {
599
616
  kind: 'staticAssertionForm',
600
- problem: staticAssertionProblem(condition, context.checker, 'directCheck'),
617
+ problem: staticAssertionProblem(condition, context, 'directCheck'),
601
618
  })
602
619
  }
603
620
  value = lowerExpression(condition, context)
@@ -634,7 +651,7 @@ function writtenRequirement(condition: ts.Expression, context: FunctionContext):
634
651
  if (ts.isCallExpression(current) && current.questionDotToken == null
635
652
  && current.arguments.length === 1 && ts.isPropertyAccessExpression(current.expression)
636
653
  && current.expression.questionDotToken == null
637
- && isStandardNumberObject(current.expression.expression, context.checker)
654
+ && isStandardGlobal(current.expression.expression, 'Number', context)
638
655
  && (current.expression.name.text === 'isInteger' || current.expression.name.text === 'isFinite')) {
639
656
  const argument = current.arguments[0]!
640
657
  const value = staticRequirementParameterPathValue(argument, context)
@@ -736,13 +753,14 @@ function lowerWrittenRequirement(
736
753
  // calculation is written and checked as normal code, then the assertion names its result.
737
754
  // This gives agents one syntax boundary instead of exposing whichever instructions happen
738
755
  // to be removable after general expression lowering.
739
- function supportedWrittenAssertion(condition: ts.Expression, checker: ts.TypeChecker): boolean {
756
+ function supportedWrittenAssertion(condition: ts.Expression, context: FunctionContext): boolean {
740
757
  const current = unwrapParentheses(condition)
741
758
  if (ts.isBinaryExpression(current) && staticAssertionComparison(current.operatorToken.kind)) {
742
- return staticAssertionNumericAtom(current.left, checker) && staticAssertionNumericAtom(current.right, checker)
759
+ return staticAssertionNumericAtom(current.left, context.checker)
760
+ && staticAssertionNumericAtom(current.right, context.checker)
743
761
  }
744
- const operand = staticNumberCheckOperand(current, checker)
745
- return operand != null && staticAssertionNumericAtom(operand, checker)
762
+ const operand = staticNumberCheckOperand(current, context)
763
+ return operand != null && staticAssertionNumericAtom(operand, context.checker)
746
764
  }
747
765
 
748
766
  function staticAssertionNumericAtom(expression: ts.Expression, checker: ts.TypeChecker): boolean {
@@ -794,25 +812,25 @@ function staticAssertionAtom(expression: ts.Expression): boolean {
794
812
 
795
813
  function staticAssertionProblem(
796
814
  condition: ts.Expression,
797
- checker: ts.TypeChecker,
815
+ context: FunctionContext,
798
816
  fallback: 'directCheck' | 'callerRequirement',
799
817
  ): StaticAssertionProblem {
800
818
  const current = unwrapParentheses(condition)
801
819
  if (ts.isBinaryExpression(current) && staticAssertionComparison(current.operatorToken.kind)) {
802
820
  return staticAssertionAtomProblem(current.left) ?? staticAssertionAtomProblem(current.right) ?? fallback
803
821
  }
804
- const operand = staticNumberCheckOperand(current, checker)
822
+ const operand = staticNumberCheckOperand(current, context)
805
823
  if (operand != null) return staticAssertionAtomProblem(operand) ?? fallback
806
824
  if (ts.isCallExpression(current)) return 'functionCall'
807
825
  return 'directCheck'
808
826
  }
809
827
 
810
- function staticNumberCheckOperand(expression: ts.Expression, checker: ts.TypeChecker): ts.Expression | null {
828
+ function staticNumberCheckOperand(expression: ts.Expression, context: FunctionContext): ts.Expression | null {
811
829
  if (!ts.isCallExpression(expression) || expression.questionDotToken != null
812
830
  || expression.arguments.length !== 1 || !ts.isPropertyAccessExpression(expression.expression)
813
831
  || expression.expression.questionDotToken != null) return null
814
832
  const callee = expression.expression
815
- return isStandardNumberObject(callee.expression, checker)
833
+ return isStandardGlobal(callee.expression, 'Number', context)
816
834
  && (callee.name.text === 'isInteger' || callee.name.text === 'isFinite' || callee.name.text === 'isNaN')
817
835
  ? expression.arguments[0]!
818
836
  : null
@@ -882,12 +900,10 @@ export function compoundAssignmentOperator(kind: ts.SyntaxKind): Extract<Instruc
882
900
  }
883
901
  }
884
902
 
885
- // The shared value-producing branch shape: branch on the condition, lower each arm in its
886
- // own block, and join at a continuation whose single parameter carries the result. Arms are
887
- // provably assignment-free assignments lower only through lowerStatementExpression so
888
- // no bindings can change across the arms and the join needs no binding merge. Ternaries and
889
- // the logical operators are the two consumers; lowerIfStatement stays separate (no result
890
- // value, arms may terminate, and assignments are allowed there).
903
+ // The shared value-producing branch shape: lower each arm in its own block, then join at
904
+ // a continuation whose single parameter carries the result. Arms are assignment-free, so
905
+ // the join needs no binding merge. One entry accepts an existing condition value; the other
906
+ // keeps a compound condition split into branches so each check can narrow later operands.
891
907
  function lowerValueBranch(
892
908
  node: ts.Expression,
893
909
  condition: ValueID,
@@ -904,6 +920,44 @@ function lowerValueBranch(
904
920
  whenFalse: {block: whenFalse, arguments: []},
905
921
  site: addSite(context, node),
906
922
  })
923
+ return joinValueBranches(
924
+ node,
925
+ whenTrue,
926
+ whenFalse,
927
+ lowerTrueArm,
928
+ lowerFalseArm,
929
+ context,
930
+ )
931
+ }
932
+
933
+ function lowerBranchingValue(
934
+ node: ts.Expression,
935
+ condition: ts.Expression,
936
+ lowerTrueArm: () => ValueID,
937
+ lowerFalseArm: () => ValueID,
938
+ context: FunctionContext,
939
+ ): ValueID {
940
+ const whenTrue = createBlock(context)
941
+ const whenFalse = createBlock(context)
942
+ lowerBranchingCondition(condition, whenTrue, whenFalse, context)
943
+ return joinValueBranches(
944
+ node,
945
+ whenTrue,
946
+ whenFalse,
947
+ lowerTrueArm,
948
+ lowerFalseArm,
949
+ context,
950
+ )
951
+ }
952
+
953
+ function joinValueBranches(
954
+ node: ts.Expression,
955
+ whenTrue: number,
956
+ whenFalse: number,
957
+ lowerTrueArm: () => ValueID,
958
+ lowerFalseArm: () => ValueID,
959
+ context: FunctionContext,
960
+ ): ValueID {
907
961
  context.currentBlock = context.blocks[whenTrue]!
908
962
  const trueValue = lowerTrueArm()
909
963
  const trueBlock = context.currentBlock
@@ -937,28 +991,13 @@ function lowerConditionalExpression(expression: ts.ConditionalExpression, contex
937
991
  // condition as one boolean expression (the previous shape) hid the conjuncts behind a
938
992
  // joined block parameter no branch refinement could see through; a conversion pass on
939
993
  // the owner's repo caught the asymmetry.
940
- const whenTrue = createBlock(context)
941
- const whenFalse = createBlock(context)
942
- lowerBranchingCondition(expression.condition, whenTrue, whenFalse, context)
943
- context.currentBlock = context.blocks[whenTrue]!
944
- const trueValue = lowerExpression(expression.whenTrue, context)
945
- const trueBlock = context.currentBlock
946
- context.currentBlock = context.blocks[whenFalse]!
947
- const falseValue = lowerExpression(expression.whenFalse, context)
948
- const falseBlock = context.currentBlock
949
- const continuation = createBlock(context, 1)
950
- terminate(trueBlock, {
951
- kind: 'jump',
952
- target: {block: continuation, arguments: [trueValue]},
953
- site: addSite(context, expression),
954
- })
955
- terminate(falseBlock, {
956
- kind: 'jump',
957
- target: {block: continuation, arguments: [falseValue]},
958
- site: addSite(context, expression),
959
- })
960
- context.currentBlock = context.blocks[continuation]!
961
- return context.currentBlock.parameters[0]!
994
+ return lowerBranchingValue(
995
+ expression,
996
+ expression.condition,
997
+ () => lowerExpression(expression.whenTrue, context),
998
+ () => lowerExpression(expression.whenFalse, context),
999
+ context,
1000
+ )
962
1001
  }
963
1002
 
964
1003
  // `a && b` evaluates b only when a is true and yields false otherwise; `a || b` mirrors it —
@@ -1017,10 +1056,9 @@ function lowerLogicalExpression(expression: ts.BinaryExpression, context: Functi
1017
1056
  requireBooleanCondition(expression.left, context.checker)
1018
1057
  requireBooleanCondition(expression.right, context.checker)
1019
1058
  const isAnd = expression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken
1020
- const condition = lowerExpression(expression.left, context)
1021
- return lowerValueBranch(
1059
+ return lowerBranchingValue(
1022
1060
  expression,
1023
- condition,
1061
+ expression.left,
1024
1062
  () => isAnd
1025
1063
  ? lowerExpression(expression.right, context)
1026
1064
  : addInstruction(context, expression, {kind: 'booleanConstant', value: true}),
@@ -1122,7 +1160,7 @@ export function valueKind(type: ts.Type, checker: ts.TypeChecker, depth = 0): Va
1122
1160
  }
1123
1161
 
1124
1162
  function valueKindUncached(type: ts.Type, checker: ts.TypeChecker, depth: number): ValueKindResult {
1125
- if ((type.flags & ts.TypeFlags.NumberLike) !== 0) return 'number'
1163
+ if (numberConstituent(type) != null) return 'number'
1126
1164
  if ((type.flags & ts.TypeFlags.BooleanLike) !== 0) return 'boolean'
1127
1165
  // Strings are carried without claims: a label or id must not reject the numeric
1128
1166
  // contract of the function around it.
@@ -1412,22 +1450,6 @@ export function requireBooleanCondition(node: ts.Node, checker: ts.TypeChecker):
1412
1450
  }
1413
1451
 
1414
1452
  function requireAccessedPropertyKind(access: ts.PropertyAccessExpression, checker: ts.TypeChecker): void {
1415
- // An optional property reads as its maybe-undefined value: declared kinds wrap it in
1416
- // the undefined sentinel and object literals fill omitted ones explicitly, so a record
1417
- // value always carries every property its static type declares — there is always
1418
- // something honest to read.
1419
- const receiverType = checker.getTypeAtLocation(access.expression)
1420
- // For an optional read the receiver includes the missing sentinels; the property lives
1421
- // on the non-missing part, which getNonNullableType strips to.
1422
- const presentType = access.questionDotToken != null ? checker.getNonNullableType(receiverType) : receiverType
1423
- const property = checker.getPropertyOfType(presentType, access.name.text)
1424
- // point.toString type-checks on every object literal, but the record value carries only
1425
- // its own properties — an inherited prototype member has no honest answer. The
1426
- // ownership test is the .d.ts rule: a property symbol declared only in declaration
1427
- // files was not written by the project, and on a project record that means prototype.
1428
- if (valueKind(presentType, checker) === 'object' && property != null && declaredOnlyInDeclarationFiles(property)) {
1429
- throw unsupported(access, {kind: 'prototypeMemberRead', property: access.name.text})
1430
- }
1431
1453
  const type = checker.getTypeAtLocation(access)
1432
1454
  if (valueKind(type, checker) != null) return
1433
1455
  throw unsupported(access, {kind: 'valueType', typeText: checker.typeToString(type)})
@@ -1438,14 +1460,12 @@ function resolvedSymbol(symbol: ts.Symbol | undefined, checker: ts.TypeChecker):
1438
1460
  return (symbol.flags & ts.SymbolFlags.Alias) === 0 ? symbol : checker.getAliasedSymbol(symbol)
1439
1461
  }
1440
1462
 
1441
- function isStandardMathObject(expression: ts.Expression, checker: ts.TypeChecker): boolean {
1442
- if (!ts.isIdentifier(expression) || expression.text !== 'Math') return false
1443
- return declaredOnlyInDeclarationFiles(checker.getSymbolAtLocation(expression))
1444
- }
1445
-
1446
- function isStandardNumberObject(expression: ts.Expression, checker: ts.TypeChecker): boolean {
1447
- if (!ts.isIdentifier(expression) || expression.text !== 'Number') return false
1448
- return declaredOnlyInDeclarationFiles(checker.getSymbolAtLocation(expression))
1463
+ function isStandardGlobal(expression: ts.Expression, name: string, context: FunctionContext): boolean {
1464
+ if (!ts.isIdentifier(expression) || expression.text !== name) return false
1465
+ return hasDefaultLibraryDeclaration(
1466
+ context.checker.getSymbolAtLocation(expression),
1467
+ context.program,
1468
+ )
1449
1469
  }
1450
1470
 
1451
1471
  function lowerElementAccess(access: ts.ElementAccessExpression, asserted: boolean, context: FunctionContext): ValueID {
@@ -1608,7 +1628,10 @@ function missingSentinelCheck(expression: ts.BinaryExpression, context: Function
1608
1628
  const missing = ts.TypeFlags.Null | ts.TypeFlags.Undefined
1609
1629
  const members = operandType.isUnion() ? operandType.types : [operandType]
1610
1630
  const restMatches = members.every(member =>
1611
- (member.flags & missing) !== 0 || (member.flags & typeofSide.flags) !== 0)
1631
+ (member.flags & missing) !== 0
1632
+ || (typeofSide.flags === ts.TypeFlags.NumberLike
1633
+ ? valueKind(member, context.checker) === 'number'
1634
+ : (member.flags & typeofSide.flags) !== 0))
1612
1635
  && members.some(member => (member.flags & missing) === 0)
1613
1636
  const value = lowerExpression(typeofSide.operand.expression, context)
1614
1637
  if (restMatches) {
@@ -1642,22 +1665,17 @@ function missingSentinelCheck(expression: ts.BinaryExpression, context: Function
1642
1665
  })
1643
1666
  }
1644
1667
 
1645
- function isGlobalInfinity(expression: ts.Expression, checker: ts.TypeChecker): boolean {
1646
- if (!ts.isIdentifier(expression) || expression.text !== 'Infinity') return false
1647
- return declaredOnlyInDeclarationFiles(checker.getSymbolAtLocation(expression))
1648
- }
1649
-
1650
1668
  // Resolves an identifier read: a function-local binding first, then a module binding
1651
1669
  // (which reads the binding's slot), then the global `Infinity` as an exact constant,
1652
1670
  // else the identifier is unknown. A local or module binding named Infinity has a
1653
- // different symbol and wins above; the global check is the same declaration-file
1654
- // defense the Math and platform dispatches use.
1671
+ // different symbol and wins above; only TypeScript's standard Infinity receives the
1672
+ // exact constant.
1655
1673
  function identifierValue(symbol: ts.Symbol, node: ts.Identifier, context: FunctionContext): ValueID {
1656
1674
  const local = context.bindings.get(symbol)
1657
1675
  if (local != null) return local
1658
1676
  const binding = context.moduleBindingsBySymbol.get(symbol)
1659
1677
  if (binding != null) return addInstruction(context, node, {kind: 'moduleRead', binding})
1660
- if (isGlobalInfinity(node, context.checker)) {
1678
+ if (isStandardGlobal(node, 'Infinity', context)) {
1661
1679
  return addInstruction(context, node, {kind: 'constant', value: Number.POSITIVE_INFINITY})
1662
1680
  }
1663
1681
  if (isUndefinedGlobal(node, context.checker)) {
@@ -0,0 +1,64 @@
1
+ import * as ts from 'typescript'
2
+
3
+ export type TopLevelFunctionNode =
4
+ | ts.FunctionDeclaration
5
+ | ts.ArrowFunction
6
+ | ts.FunctionExpression
7
+
8
+ export type TopLevelFunctionUnit = {
9
+ name: ts.Identifier
10
+ declaration: TopLevelFunctionNode
11
+ initializer: ts.VariableDeclaration | null
12
+ }
13
+
14
+ export function callableSignature(
15
+ unit: TopLevelFunctionUnit,
16
+ checker: ts.TypeChecker,
17
+ ): ts.Signature | null {
18
+ if (unit.initializer == null) {
19
+ return checker.getSignatureFromDeclaration(unit.declaration) ?? null
20
+ }
21
+ const signatures = checker.getSignaturesOfType(
22
+ checker.getTypeAtLocation(unit.name),
23
+ ts.SignatureKind.Call,
24
+ )
25
+ if (signatures.length !== 1) return null
26
+ const signature = signatures[0]!
27
+ const signatureDeclaration = signature.declaration
28
+ if (signatureDeclaration == null
29
+ || signature.thisParameter != null
30
+ || signature.parameters.length !== unit.declaration.parameters.length
31
+ || signatureDeclaration.parameters.some(parameter =>
32
+ !ts.isParameter(parameter) || parameter.dotDotDotToken != null)) {
33
+ return null
34
+ }
35
+ return signature
36
+ }
37
+
38
+ export function topLevelFunctionUnits(sourceFile: ts.SourceFile): TopLevelFunctionUnit[] {
39
+ const units: TopLevelFunctionUnit[] = []
40
+ for (const statement of sourceFile.statements) {
41
+ if (ts.isFunctionDeclaration(statement) && statement.name != null) {
42
+ units.push({name: statement.name, declaration: statement, initializer: null})
43
+ continue
44
+ }
45
+ if (!ts.isVariableStatement(statement)
46
+ || (statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue
47
+ for (const initializer of statement.declarationList.declarations) {
48
+ if (!ts.isIdentifier(initializer.name) || initializer.initializer == null) continue
49
+ const declaration = directFunctionExpression(initializer.initializer)
50
+ if (declaration != null) {
51
+ units.push({name: initializer.name, declaration, initializer})
52
+ }
53
+ }
54
+ }
55
+ return units
56
+ }
57
+
58
+ export function directFunctionExpression(
59
+ expression: ts.Expression,
60
+ ): ts.ArrowFunction | ts.FunctionExpression | null {
61
+ return ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)
62
+ ? expression
63
+ : null
64
+ }
@@ -18,6 +18,8 @@ import {numericLiteralValue} from './literals.ts'
18
18
  import {declaredOnlyInDeclarationFiles} from './platform.ts'
19
19
  import {addInstruction, addSite, createFunctionContext, LoweringStop, restoreLowering, sealBlocks, snapshotLowering, terminate, type FunctionContext, type TopLevelFunction} from './context.ts'
20
20
  import {lowerExpression, nonMissingUnionMembers, tagLiteralValues, taggedUnionProperty, valueKind} from './expression.ts'
21
+ import {directFunctionExpression} from './function-unit.ts'
22
+ import {numberConstituent} from './numeric-intersection.ts'
21
23
  import {lowerStatement} from './statements.ts'
22
24
 
23
25
  export type ModuleScan = {
@@ -45,9 +47,16 @@ export function scanModuleBindings(sourceFile: ts.SourceFile, checker: ts.TypeCh
45
47
  // the statement itself is skipped when the initializer reaches it, and functions
46
48
  // reading the name are rejected as unknown identifiers.
47
49
  if ((statement.declarationList.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) === 0) continue
50
+ const isConst = (statement.declarationList.flags & ts.NodeFlags.Const) !== 0
48
51
  for (const declarator of statement.declarationList.declarations) {
49
52
  if (ts.isIdentifier(declarator.name)) {
50
- register(declarator.name, declaredCategory(declarator.name, checker))
53
+ register(
54
+ declarator.name,
55
+ isConst && declarator.initializer != null
56
+ && directFunctionExpression(declarator.initializer) != null
57
+ ? {kind: 'function'}
58
+ : declaredCategory(declarator.name, checker),
59
+ )
51
60
  continue
52
61
  }
53
62
  // `const {cols} = gridSize` at the top level: each destructured name is its own
@@ -189,11 +198,12 @@ function demoteModuleWritesInNode(
189
198
  export function lowerModuleInitializer(
190
199
  sourceFile: ts.SourceFile,
191
200
  checker: ts.TypeChecker,
201
+ program: ts.Program,
192
202
  functionsBySymbol: Map<ts.Symbol, TopLevelFunction>,
193
203
  scan: ModuleScan,
194
204
  sites: SourceSpan[],
195
205
  ): {initializer: FunctionIR; skips: InitializerSkip[]} {
196
- const context = createFunctionContext(sourceFile, checker, functionsBySymbol, scan.bindingsBySymbol, sites)
206
+ const context = createFunctionContext(sourceFile, checker, program, functionsBySymbol, scan.bindingsBySymbol, sites)
197
207
  const skips: InitializerSkip[] = []
198
208
  const statements = sourceFile.statements
199
209
  // Each statement gets its own catch: an unsupported one is skipped — its half-lowered
@@ -206,7 +216,7 @@ export function lowerModuleInitializer(
206
216
  if (skippedAtTopLevel(statement)) continue
207
217
  const recovery = snapshotLowering(context)
208
218
  try {
209
- assertAccepted(statement)
219
+ assertAccepted(statement, true)
210
220
  if (ts.isVariableStatement(statement)) {
211
221
  lowerTopLevelDeclarations(statement, context, scan)
212
222
  continue
@@ -354,6 +364,12 @@ function lowerTopLevelDeclarations(statement: ts.VariableStatement, context: Fun
354
364
  const symbol = context.checker.getSymbolAtLocation(declarator.name)
355
365
  const binding = symbol == null ? undefined : scan.bindingsBySymbol.get(symbol)
356
366
  if (binding == null) throw new LoweringStop(declarator, {kind: 'variableDeclarationShape'})
367
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) !== 0
368
+ && directFunctionExpression(declarator.initializer) != null) {
369
+ const marker = addInstruction(context, declarator.initializer, {kind: 'opaqueConstant'})
370
+ addInstruction(context, declarator, {kind: 'moduleWrite', binding, value: marker})
371
+ continue
372
+ }
357
373
  const value = lowerExpression(declarator.initializer, context)
358
374
  addInstruction(context, declarator, {kind: 'moduleWrite', binding, value})
359
375
  }
@@ -361,8 +377,9 @@ function lowerTopLevelDeclarations(statement: ts.VariableStatement, context: Fun
361
377
 
362
378
  function skippedAtTopLevel(statement: ts.Statement): boolean {
363
379
  // `export {alreadyDeclaredName}` and import declarations create bindings but run nothing.
364
- // Only NAMED function declarations pass: those become program.functions entries, so
365
- // unsupported code inside them keeps the fully-analyzed publish gate honest. An
380
+ // Named declarations pass here: those become program.functions entries, so unsupported
381
+ // code inside them keeps the fully-analyzed publish gate honest. Const-bound functions
382
+ // remain variable statements and lower above as function-initialization markers. An
366
383
  // anonymous `export default function` has no name to collect under, so it falls through
367
384
  // to ordinary statement lowering, which records it as an initializer skip — otherwise
368
385
  // its body would be runtime code invisible to every gate.
@@ -667,14 +684,15 @@ function declaredKindUncached(type: ts.Type, checker: ts.TypeChecker, seen: ts.T
667
684
 
668
685
  function numericLiteralInterval(type: ts.Type): DeclaredNumberInterval | 'nonFinite' | null {
669
686
  const members = type.isUnion() ? type.types : [type]
670
- if (!members.every(member =>
671
- (member.flags & ts.TypeFlags.NumberLiteral) !== 0
672
- && (member.flags & ts.TypeFlags.EnumLiteral) === 0)) return null
673
687
  let lower = Infinity
674
688
  let upper = -Infinity
675
689
  let integer = true
676
690
  for (const member of members) {
677
- const value = (member as ts.NumberLiteralType).value
691
+ const number = numberConstituent(member)
692
+ if (number == null
693
+ || (number.flags & ts.TypeFlags.NumberLiteral) === 0
694
+ || (number.flags & ts.TypeFlags.EnumLiteral) !== 0) return null
695
+ const value = (number as ts.NumberLiteralType).value
678
696
  if (!Number.isFinite(value)) return 'nonFinite'
679
697
  lower = Math.min(lower, value)
680
698
  upper = Math.max(upper, value)
@@ -741,6 +759,7 @@ function demote(bindings: ModuleBindingIR[], binding: ModuleBindingID): void {
741
759
  break
742
760
  }
743
761
  case 'kind':
762
+ case 'function':
744
763
  case 'import':
745
764
  case 'opaque':
746
765
  break
@@ -0,0 +1,15 @@
1
+ import * as ts from 'typescript'
2
+
3
+ // TypeScript represents numeric brands as a number intersected with object metadata.
4
+ // Freerange uses the number and ignores the metadata; reading that metadata still stops
5
+ // because the runtime value is modeled as a number, not a record.
6
+ export function numberConstituent(type: ts.Type): ts.Type | null {
7
+ if ((type.flags & ts.TypeFlags.NumberLike) !== 0) return type
8
+ if (!type.isIntersection()) return null
9
+ const numberTypes = type.types.filter(member => (member.flags & ts.TypeFlags.NumberLike) !== 0)
10
+ if (numberTypes.length !== 1) return null
11
+ for (const member of type.types) {
12
+ if (member !== numberTypes[0] && (member.flags & ts.TypeFlags.Object) === 0) return null
13
+ }
14
+ return numberTypes[0]!
15
+ }
@@ -29,6 +29,7 @@ type PlatformEntry = {
29
29
  }
30
30
 
31
31
  const anyFinite = {lower: -Number.MAX_VALUE, upper: Number.MAX_VALUE}
32
+ const maximumDateTime = 8_640_000_000_000_000
32
33
 
33
34
  const catalog: PlatformEntry[] = [
34
35
  // Element layout sizes are nonnegative integers (the spec rounds them).
@@ -43,16 +44,23 @@ const catalog: PlatformEntry[] = [
43
44
  {path: ['window', 'scrollY'], call: false, fact: {...anyFinite, integer: false}},
44
45
  {path: ['document', 'body', 'scrollTop'], call: false, fact: {...anyFinite, integer: false}},
45
46
  {path: ['document', 'body', 'scrollLeft'], call: false, fact: {...anyFinite, integer: false}},
46
- // Monotonic clocks: finite, nonnegative, fractional (performance.now has sub-millisecond
47
- // resolution); Date.now is an integer count of milliseconds.
47
+ // performance.now is monotonic, finite, nonnegative, and fractional. Date.now is an
48
+ // integer UTC time value and may be negative for a clock set before the Unix epoch.
48
49
  {path: ['performance', 'now'], call: true, fact: {lower: 0, upper: Number.MAX_VALUE, integer: false}},
49
- {path: ['Date', 'now'], call: true, fact: {lower: 0, upper: Number.MAX_VALUE, integer: true}},
50
+ {path: ['Date', 'now'], call: true, fact: {lower: -maximumDateTime, upper: maximumDateTime, integer: true}},
51
+ // Math.random returns one of JavaScript's discrete floating-point numbers below 1.
52
+ // Writing the predecessor of 1 keeps the exact closed range without adding open bounds.
53
+ {path: ['Math', 'random'], call: true, fact: {lower: 0, upper: 0.9999999999999999, integer: false}},
50
54
  ]
51
55
 
52
- // Matches a property chain rooted at a global whose symbol resolves into a declaration
53
- // file the same shadowing defense the Math dispatch uses, so a local variable named
54
- // `document` never matches.
55
- export function platformFact(expression: ts.Expression, call: boolean, checker: ts.TypeChecker): PlatformFact | null {
56
+ // Matches a property chain rooted at a global from TypeScript's standard library. A local
57
+ // variable or a project-supplied ambient declaration with the same name never matches.
58
+ export function platformFact(
59
+ expression: ts.Expression,
60
+ call: boolean,
61
+ checker: ts.TypeChecker,
62
+ program: ts.Program,
63
+ ): PlatformFact | null {
56
64
  const parts: string[] = []
57
65
  let current: ts.Expression = expression
58
66
  while (ts.isPropertyAccessExpression(current)) {
@@ -66,14 +74,22 @@ export function platformFact(expression: ts.Expression, call: boolean, checker:
66
74
  && candidate.path.length === parts.length
67
75
  && candidate.path.every((segment, index) => segment === parts[index]))
68
76
  if (entry == null) return null
69
- if (!declaredOnlyInDeclarationFiles(checker.getSymbolAtLocation(current))) return null
77
+ if (!hasDefaultLibraryDeclaration(checker.getSymbolAtLocation(current), program)) return null
70
78
  return entry.fact
71
79
  }
72
80
 
73
- // The shadowing defense shared by every trusted-global dispatch (this catalog, `Math`,
74
- // `Infinity`): the identifier counts as the real global only when every declaration of its
75
- // symbol lives in a declaration file, so a local or module binding of the same name never
76
- // matches.
81
+ // A standard-library declaration anchors the symbol to the real JavaScript or browser
82
+ // global. Additional project declarations may augment that symbol; a lookalike symbol
83
+ // declared only by the project does not receive Freerange's built-in behavior.
84
+ export function hasDefaultLibraryDeclaration(
85
+ symbol: ts.Symbol | undefined,
86
+ program: ts.Program,
87
+ ): boolean {
88
+ const declarations = symbol?.declarations
89
+ if (declarations == null || declarations.length === 0) return false
90
+ return declarations.some(declaration => program.isSourceFileDefaultLibrary(declaration.getSourceFile()))
91
+ }
92
+
77
93
  export function declaredOnlyInDeclarationFiles(symbol: ts.Symbol | undefined): boolean {
78
94
  const declarations = symbol?.declarations
79
95
  if (declarations == null || declarations.length === 0) return false