@chenglou/freerange 0.0.1

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.
@@ -0,0 +1,445 @@
1
+ import {joinNumbers, sameNumbers, widenNumber, type AbstractNumber} from './number.ts'
2
+
3
+ export type AbstractBoolean = {
4
+ kind: 'boolean'
5
+ canBeTrue: boolean
6
+ canBeFalse: boolean
7
+ }
8
+
9
+ // An object is a plain structural value: its property values, nothing else. Values are
10
+ // immutable after construction (the acceptance pass rejects property writes), so a record
11
+ // held across any amount of control flow keeps exactly the property values it was built
12
+ // with — no identity, no heap, no aliasing questions. The cost: two separately constructed
13
+ // records with equal property values are indistinguishable, so "definitely different
14
+ // objects" is inexpressible. Nothing observes that today (`===` never lowers for objects);
15
+ // if object comparison ever enters the subset, this is the representation to revisit.
16
+ // Properties keep their construction order (the literal's textual order), which is what
17
+ // report lines print in; joins and comparisons match properties by name, never by index.
18
+ export type AbstractRecord = {
19
+ kind: 'record'
20
+ properties: Array<{name: string; value: AbstractValue}>
21
+ }
22
+
23
+ type AbstractVoid = {
24
+ kind: 'void'
25
+ }
26
+
27
+ // A value the analysis carries but makes no claims about — strings today. It flows
28
+ // through records, parameters, and returns; comparing two opaques gives an unknown
29
+ // boolean; every operation ON one is rejected at lowering. Nothing numeric is ever said
30
+ // about it, so nothing unsound can be: its whole job is to stop non-numeric content from
31
+ // rejecting the function around it.
32
+ //
33
+ // content is the one exception, carried when the string's exact text is KNOWN — a written
34
+ // string literal, or a tagged-union tag property seeded from its declared variant. Its
35
+ // only consumer is the object-literal variant pin: the tag a value actually holds decides
36
+ // the variant, so an assertion's type-level claim (erased casts carry no content) cannot
37
+ // pin a variant it does not hold — a review round chained three type-channel launders
38
+ // (cast tag, quoted-key cast tag, spread of a cast-tagged template) that syntactic guards
39
+ // kept missing, and value-carried content closes the channel by construction.
40
+ export type AbstractOpaque = {
41
+ kind: 'opaque'
42
+ content?: string
43
+ }
44
+
45
+ // A tuple: fixed length, one value per position — produced by literals whose static type
46
+ // is a tuple ([4, 8, 24] as const). Follows the type system's own split: tuples are
47
+ // positional, arrays are homogeneous. A tuple meeting a different-length tuple or an
48
+ // array at a join collapses one-way into the array form.
49
+ export type AbstractTuple = {
50
+ kind: 'tuple'
51
+ elements: AbstractValue[]
52
+ }
53
+
54
+ // A homogeneous array: one element hull covering every element (null when no element was
55
+ // ever seen — the empty literal), plus a length interval.
56
+ export type AbstractArray = {
57
+ kind: 'array'
58
+ element: AbstractValue | null
59
+ length: AbstractNumber
60
+ }
61
+
62
+ // Which of JavaScript's two missing-value sentinels a nullish value can be. Carried on
63
+ // the value so report lines can say "null" when only null is possible (a `number | null`
64
+ // binding) instead of hedging with both.
65
+ export type NullishSentinels = 'null' | 'undefined' | 'both'
66
+
67
+ // The value IS missing: null, undefined, or (after a join) either. Null and undefined
68
+ // share one abstract concept — `??` and loose `== null` treat them alike, and the
69
+ // narrowing rules consult the operand's static type wherever the two differ (a strict
70
+ // `!== null` cannot clear a possibly-undefined value).
71
+ export type AbstractNullish = {
72
+ kind: 'nullish'
73
+ sentinels: NullishSentinels
74
+ }
75
+
76
+ // A value that is either `inner` or missing. Never nested (joins flatten), and inner is
77
+ // never itself nullish — a value that is only missing is AbstractNullish, not a wrapper.
78
+ export type AbstractMaybeNullish = {
79
+ kind: 'maybeNullish'
80
+ inner: AbstractValue
81
+ sentinels: NullishSentinels
82
+ }
83
+
84
+ export function joinSentinels(left: NullishSentinels, right: NullishSentinels): NullishSentinels {
85
+ return left === right ? left : 'both'
86
+ }
87
+
88
+ // A value that is one of several record shapes, told apart by a shared property holding
89
+ // a distinct string per shape (route.type is 'explore' or 'lightbox' or 'archive'). The
90
+ // variant list comes from the declared type and analysis never grows it — checks only
91
+ // ever remove variants — so the representation is bounded by what the author wrote. A
92
+ // single-variant value stays in this form (rather than collapsing to a plain record) so a
93
+ // later check against another tag is definitely false and dead branches prune.
94
+ export type TaggedVariant = {tagValue: string | boolean; record: AbstractRecord}
95
+
96
+ export type AbstractTaggedUnion = {
97
+ kind: 'taggedUnion'
98
+ tagProperty: string
99
+ // Declared order; the tuple form carries the non-emptiness so consumers need no
100
+ // defensive emptiness handling. Two declared variants MAY share a tag value while
101
+ // carrying different properties, so joins pair variants by tag AND property-name
102
+ // shape, never by tag alone.
103
+ variants: [TaggedVariant, ...TaggedVariant[]]
104
+ }
105
+
106
+ export type AbstractValue =
107
+ | AbstractNumber
108
+ | AbstractBoolean
109
+ | AbstractRecord
110
+ | AbstractVoid
111
+ | AbstractNullish
112
+ | AbstractMaybeNullish
113
+ | AbstractTuple
114
+ | AbstractArray
115
+ | AbstractOpaque
116
+ | AbstractTaggedUnion
117
+
118
+ export function unknownBoolean(): AbstractBoolean {
119
+ return {kind: 'boolean', canBeTrue: true, canBeFalse: true}
120
+ }
121
+
122
+ export function recordValue(properties: Array<{name: string; value: AbstractValue}>): AbstractRecord {
123
+ return {kind: 'record', properties}
124
+ }
125
+
126
+ // The named property's value, or null when the record does not carry the property (a join
127
+ // dropped it — see joinValues). Callers turn null into their own stop or rejection.
128
+ export function recordProperty(record: AbstractRecord, name: string): AbstractValue | null {
129
+ const property = record.properties.find(candidate => candidate.name === name)
130
+ return property == null ? null : property.value
131
+ }
132
+
133
+ export function recordPropertiesByName(record: AbstractRecord): ReadonlyMap<string, AbstractValue> {
134
+ return new Map(record.properties.map(property => [property.name, property.value]))
135
+ }
136
+
137
+ export function joinValues(left: AbstractValue, right: AbstractValue): AbstractValue {
138
+ const joined = tryJoinValues(left, right)
139
+ // A top-level kind mismatch stays a crash: union-typed bindings are outside the accepted
140
+ // subset and belong to a lowering gate. INSIDE structures the mismatch is survivable —
141
+ // tryJoinValues callers drop the offending property instead — because every read of a
142
+ // mixed-kind property is rejected by the gates.
143
+ if (joined == null) throw new Error(`Cannot join ${left.kind} and ${right.kind}`)
144
+ return joined
145
+ }
146
+
147
+ // The total join: null when the kinds cannot meet, instead of throwing. Record properties,
148
+ // array elements, tuple positions, and maybeNullish inners all join through this, so a
149
+ // mismatch deep inside a structure degrades (the property is dropped, unreadable anyway)
150
+ // rather than killing the run.
151
+ export function tryJoinValues(left: AbstractValue, right: AbstractValue): AbstractValue | null {
152
+ // Missing values meet other kinds legitimately: a `number | null` binding joins a number
153
+ // branch with a null branch.
154
+ if (left.kind === 'nullish' && right.kind === 'nullish') {
155
+ return {kind: 'nullish', sentinels: joinSentinels(left.sentinels, right.sentinels)}
156
+ }
157
+ if (left.kind === 'nullish') {
158
+ return right.kind === 'maybeNullish'
159
+ ? {kind: 'maybeNullish', inner: right.inner, sentinels: joinSentinels(left.sentinels, right.sentinels)}
160
+ : {kind: 'maybeNullish', inner: right, sentinels: left.sentinels}
161
+ }
162
+ if (right.kind === 'nullish') return tryJoinValues(right, left)
163
+ if (left.kind === 'maybeNullish' || right.kind === 'maybeNullish') {
164
+ const leftInner = left.kind === 'maybeNullish' ? left.inner : left
165
+ const rightInner = right.kind === 'maybeNullish' ? right.inner : right
166
+ const leftSentinels = left.kind === 'maybeNullish' ? left.sentinels : null
167
+ const rightSentinels = right.kind === 'maybeNullish' ? right.sentinels : null
168
+ const sentinels = leftSentinels == null ? rightSentinels! : rightSentinels == null ? leftSentinels : joinSentinels(leftSentinels, rightSentinels)
169
+ const inner = tryJoinValues(leftInner, rightInner)
170
+ return inner == null ? null : {kind: 'maybeNullish', inner, sentinels}
171
+ }
172
+ // Tuples and arrays meet across forms: the tuple collapses to its homogeneous hull.
173
+ if ((left.kind === 'tuple' || left.kind === 'array') && (right.kind === 'tuple' || right.kind === 'array')) {
174
+ if (left.kind === 'tuple' && right.kind === 'tuple' && left.elements.length === right.elements.length) {
175
+ const elements: AbstractValue[] = []
176
+ for (let index = 0; index < left.elements.length; index++) {
177
+ const element = tryJoinValues(left.elements[index]!, right.elements[index]!)
178
+ if (element == null) return null
179
+ elements.push(element)
180
+ }
181
+ return {kind: 'tuple', elements}
182
+ }
183
+ const leftArray = left.kind === 'tuple' ? arrayFromTupleTotal(left) : left
184
+ const rightArray = right.kind === 'tuple' ? arrayFromTupleTotal(right) : right
185
+ if (leftArray == null || rightArray == null) return null
186
+ const element = leftArray.element == null ? rightArray.element
187
+ : rightArray.element == null ? leftArray.element
188
+ : tryJoinValues(leftArray.element, rightArray.element)
189
+ if (element == null && leftArray.element != null && rightArray.element != null) return null
190
+ return {kind: 'array', element, length: joinNumbers(leftArray.length, rightArray.length)}
191
+ }
192
+ // A plain record meeting a tagged union: the record's variant is unknown (its tag
193
+ // value is an opaque string the analysis never learned), so the union side hulls to the
194
+ // record shape shared by all its variants and the two records join. Tag checks on the
195
+ // result hit the kind-mismatch backstop as an honest stop — degraded, never a crash:
196
+ // the totality rule holds even for the rebuild idiom applied to a union-typed value
197
+ // whose construction the promotion missed.
198
+ if (left.kind === 'record' && right.kind === 'taggedUnion') {
199
+ const hull = taggedUnionHull(right)
200
+ return hull == null ? null : joinRecords(left, hull)
201
+ }
202
+ if (left.kind === 'taggedUnion' && right.kind === 'record') {
203
+ const hull = taggedUnionHull(left)
204
+ return hull == null ? null : joinRecords(hull, right)
205
+ }
206
+ // Opaque absorbs any mixed meet: an opaque value carries no claims, so it soundly
207
+ // covers a number or boolean that joins into it — every use that needs more than
208
+ // carrying is gated at the use position or stops at the kind-mismatch backstop. This is
209
+ // what keeps `typeof value === 'number' ? value : fallback` (the unknown-typed
210
+ // fallback idiom) a claim-free analyzed function instead of a join crash: the true arm
211
+ // stays opaque in our model even though the checker narrowed it. Known string content
212
+ // survives only when both sides agree on it.
213
+ if (left.kind === 'opaque' || right.kind === 'opaque') {
214
+ if (left.kind === 'opaque' && right.kind === 'opaque'
215
+ && left.content != null && left.content === right.content) return {kind: 'opaque', content: left.content}
216
+ return {kind: 'opaque'}
217
+ }
218
+ if (left.kind !== right.kind) return null
219
+ switch (left.kind) {
220
+ case 'number': return joinNumbers(left, right as AbstractNumber)
221
+ case 'boolean': return joinBooleans(left, right as AbstractBoolean)
222
+ case 'record': return joinRecords(left, right as AbstractRecord)
223
+ case 'void': return left
224
+ case 'taggedUnion': return joinTaggedUnions(left, right as AbstractTaggedUnion)
225
+ // Handled by the structural arms above; unreachable here.
226
+ case 'tuple':
227
+ case 'array':
228
+ return null
229
+ }
230
+ }
231
+
232
+ // Variants merge per tag value AND property-name shape: a branch that built the lightbox
233
+ // shape joining a branch that built the archive shape carries both, each shape's facts
234
+ // intact — and two variants sharing a tag ({type: 'updates'; tab} | {type: 'updates';
235
+ // article}) stay separate, because pairing them by tag alone would intersect away the
236
+ // properties that distinguish the declared shapes (a self-join would then prune a
237
+ // reachable branch). The list can only hold shapes some side already had — analysis
238
+ // never invents a variant — so it stays bounded by the declared type. Mismatched tag
239
+ // properties cannot meet through the gates; null degrades the surrounding structure
240
+ // like any other kind mismatch.
241
+ function joinTaggedUnions(left: AbstractTaggedUnion, right: AbstractTaggedUnion): AbstractTaggedUnion | null {
242
+ if (left.tagProperty !== right.tagProperty) return null
243
+ const pairWithRight = (variant: TaggedVariant): TaggedVariant => {
244
+ const other = right.variants.find(candidate =>
245
+ candidate.tagValue === variant.tagValue && sameVariantShape(candidate.record, variant.record))
246
+ return other == null
247
+ ? variant
248
+ : {tagValue: variant.tagValue, record: joinRecords(variant.record, other.record)}
249
+ }
250
+ const [firstLeft, ...restLeft] = left.variants
251
+ const variants: AbstractTaggedUnion['variants'] = [pairWithRight(firstLeft), ...restLeft.map(pairWithRight)]
252
+ for (const variant of right.variants) {
253
+ const paired = left.variants.some(candidate =>
254
+ candidate.tagValue === variant.tagValue && sameVariantShape(candidate.record, variant.record))
255
+ if (!paired) variants.push(variant)
256
+ }
257
+ return {kind: 'taggedUnion', tagProperty: left.tagProperty, variants}
258
+ }
259
+
260
+ // The record covering every variant at once: properties all variants share, each joined
261
+ // across them. What a tagged union degrades to when it meets a plain record.
262
+ function taggedUnionHull(union: AbstractTaggedUnion): AbstractRecord | null {
263
+ let hull: AbstractValue | null = union.variants[0].record
264
+ for (let index = 1; index < union.variants.length; index++) {
265
+ if (hull == null) return null
266
+ hull = tryJoinValues(hull, union.variants[index]!.record)
267
+ }
268
+ return hull != null && hull.kind === 'record' ? hull : null
269
+ }
270
+
271
+ // Same property-name set: the shape identity that keeps duplicate-tag variants apart.
272
+ // Order-insensitive, names only — the property VALUES join; it is the presence set that
273
+ // distinguishes {tab} from {article}.
274
+ function sameVariantShape(left: AbstractRecord, right: AbstractRecord): boolean {
275
+ if (left.properties.length !== right.properties.length) return false
276
+ const rightProperties = recordPropertiesByName(right)
277
+ return left.properties.every(property => rightProperties.has(property.name))
278
+ }
279
+
280
+ // The tuple's homogeneous hull, or null when its positions mix kinds (a mixed tuple never
281
+ // reaches a cross-form join through the gates; inside structures the caller drops it).
282
+ function arrayFromTupleTotal(tuple: AbstractTuple): AbstractArray | null {
283
+ if (tuple.elements.length === 0) return {kind: 'array', element: null, length: constantLength(0)}
284
+ let element: AbstractValue | null = tuple.elements[0]!
285
+ for (let index = 1; index < tuple.elements.length; index++) {
286
+ element = tryJoinValues(element, tuple.elements[index]!)
287
+ if (element == null) return null
288
+ }
289
+ return {kind: 'array', element, length: constantLength(tuple.elements.length)}
290
+ }
291
+
292
+ function constantLength(length: number): AbstractNumber {
293
+ return {kind: 'number', lower: length, upper: length, integer: true, mayBeNaN: false}
294
+ }
295
+
296
+ // Records join pointwise by property name, keeping only the names present on BOTH sides
297
+ // whose values can actually meet. Different shapes genuinely meet: TypeScript accepts
298
+ // `flag ? {x: 1} : {x: 2, y: 3}` wherever `{x: number}` is expected, so on the flag-true
299
+ // path `y` does not exist — keeping the union of names would publish an ensures line about
300
+ // a property that is sometimes absent. A same-named property whose kinds differ (from a
301
+ // union like `{value: number} | {value: boolean}`) is dropped the same way instead of
302
+ // crashing the join: reading such a property is impossible anyway, because the property
303
+ // access gate rejects results whose static type mixes kinds. Either way, every readable
304
+ // property survives the join.
305
+ function joinRecords(left: AbstractRecord, right: AbstractRecord): AbstractRecord {
306
+ const rightProperties = recordPropertiesByName(right)
307
+ const properties: Array<{name: string; value: AbstractValue}> = []
308
+ for (const property of left.properties) {
309
+ const other = rightProperties.get(property.name)
310
+ if (other == null) continue
311
+ const joined = tryJoinValues(property.value, other)
312
+ if (joined == null) continue
313
+ properties.push({name: property.name, value: joined})
314
+ }
315
+ return {kind: 'record', properties}
316
+ }
317
+
318
+ export function sameValues(left: AbstractValue, right: AbstractValue): boolean {
319
+ if (left.kind !== right.kind) return false
320
+ switch (left.kind) {
321
+ case 'number': return sameNumbers(left, right as AbstractNumber)
322
+ case 'boolean': {
323
+ const other = right as AbstractBoolean
324
+ return left.canBeTrue === other.canBeTrue && left.canBeFalse === other.canBeFalse
325
+ }
326
+ case 'record': {
327
+ // By name, not by index: two equal records can carry their properties in different
328
+ // orders (e.g. a join's result takes the left side's order).
329
+ const other = right as AbstractRecord
330
+ const otherProperties = recordPropertiesByName(other)
331
+ return left.properties.length === other.properties.length
332
+ && left.properties.every(property => {
333
+ const otherValue = otherProperties.get(property.name)
334
+ return otherValue != null && sameValues(property.value, otherValue)
335
+ })
336
+ }
337
+ case 'void': return true
338
+ case 'opaque': return left.content === (right as AbstractOpaque).content
339
+ case 'nullish': return left.sentinels === (right as AbstractNullish).sentinels
340
+ case 'maybeNullish': {
341
+ const other = right as AbstractMaybeNullish
342
+ return left.sentinels === other.sentinels && sameValues(left.inner, other.inner)
343
+ }
344
+ case 'tuple': {
345
+ const other = right as AbstractTuple
346
+ return left.elements.length === other.elements.length
347
+ && left.elements.every((element, index) => sameValues(element, other.elements[index]!))
348
+ }
349
+ case 'array': {
350
+ const other = right as AbstractArray
351
+ const sameElement = left.element == null || other.element == null
352
+ ? left.element === other.element
353
+ : sameValues(left.element, other.element)
354
+ return sameElement && sameNumbers(left.length, other.length)
355
+ }
356
+ case 'taggedUnion': {
357
+ const other = right as AbstractTaggedUnion
358
+ return left.tagProperty === other.tagProperty
359
+ && left.variants.length === other.variants.length
360
+ && left.variants.every((variant, index) => variant.tagValue === other.variants[index]!.tagValue
361
+ && sameValues(variant.record, other.variants[index]!.record))
362
+ }
363
+ }
364
+ }
365
+
366
+ // Widening exists to bound the lattice height at loop headers; every kind must decide its
367
+ // own story here, so a future kind cannot silently fall into an unbounded default and spin
368
+ // fixed points into the round limit.
369
+ export function widenValue(previous: AbstractValue, next: AbstractValue): AbstractValue {
370
+ switch (next.kind) {
371
+ // Numbers are the one unbounded lattice; bounds that grew jump to their extreme.
372
+ case 'number': return previous.kind === 'number' ? widenNumber(previous, next) : next
373
+ // A record's number leaves are unbounded, so widening recurses pointwise — a
374
+ // loop-carried `metrics = {height: metrics.height + 1}` must widen height, not grow it
375
+ // one round at a time into the round limit. A property the previous round lacked has
376
+ // nothing to widen against and passes through. Width subtyping and opaque fields can
377
+ // still store the previous record inside a wider value on every round; that nesting
378
+ // never stabilizes, so the loop round limit remains the termination backstop.
379
+ case 'record': {
380
+ if (previous.kind !== 'record') return next
381
+ const previousProperties = recordPropertiesByName(previous)
382
+ return {
383
+ kind: 'record',
384
+ properties: next.properties.map(property => {
385
+ const before = previousProperties.get(property.name)
386
+ return before == null ? property : {name: property.name, value: widenValue(before, property.value)}
387
+ }),
388
+ }
389
+ }
390
+ case 'maybeNullish': {
391
+ // The unbounded part is inside; the missing half is a small finite lattice.
392
+ const previousInner = previous.kind === 'maybeNullish' ? previous.inner : previous
393
+ return {kind: 'maybeNullish', inner: widenValue(previousInner, next.inner), sentinels: next.sentinels}
394
+ }
395
+ case 'tuple': {
396
+ if (previous.kind !== 'tuple' || previous.elements.length !== next.elements.length) return next
397
+ const previousTuple = previous
398
+ return {
399
+ kind: 'tuple',
400
+ elements: next.elements.map((element, index) => widenValue(previousTuple.elements[index]!, element)),
401
+ }
402
+ }
403
+ case 'array': {
404
+ if (previous.kind !== 'array') return next
405
+ const element = next.element == null ? null
406
+ : previous.element == null ? next.element
407
+ : widenValue(previous.element, next.element)
408
+ return {kind: 'array', element, length: widenNumber(previous.length, next.length)}
409
+ }
410
+ case 'taggedUnion': {
411
+ if (previous.kind !== 'taggedUnion' || previous.tagProperty !== next.tagProperty) return next
412
+ // The variant list is bounded by the declared type, so only the records inside need
413
+ // widening — per tag value, like record properties.
414
+ const widenVariant = (variant: TaggedVariant): TaggedVariant => {
415
+ const before = previous.variants.find(candidate =>
416
+ candidate.tagValue === variant.tagValue && sameVariantShape(candidate.record, variant.record))
417
+ if (before == null) return variant
418
+ const widened = widenValue(before.record, variant.record)
419
+ return widened.kind === 'record' ? {tagValue: variant.tagValue, record: widened} : variant
420
+ }
421
+ const [firstNext, ...restNext] = next.variants
422
+ return {
423
+ kind: 'taggedUnion',
424
+ tagProperty: next.tagProperty,
425
+ variants: [widenVariant(firstNext), ...restNext.map(widenVariant)],
426
+ }
427
+ }
428
+ // Bounded lattices need no widening: booleans have height two, the missing sentinels
429
+ // form a three-point lattice, void is a point, and opaque has height two (known
430
+ // content above the bare point).
431
+ case 'boolean':
432
+ case 'void':
433
+ case 'nullish':
434
+ case 'opaque':
435
+ return next
436
+ }
437
+ }
438
+
439
+ function joinBooleans(left: AbstractBoolean, right: AbstractBoolean): AbstractBoolean {
440
+ return {
441
+ kind: 'boolean',
442
+ canBeTrue: left.canBeTrue || right.canBeTrue,
443
+ canBeFalse: left.canBeFalse || right.canBeFalse,
444
+ }
445
+ }