diff --git a/changelog/unreleased/1761.md b/changelog/unreleased/1761.md new file mode 100644 index 0000000000..2cce4c6a78 --- /dev/null +++ b/changelog/unreleased/1761.md @@ -0,0 +1,4 @@ +- **BREAKING CHANGES:** `edag`: a chain ends by arity, not a `null` terminator — + a plain read is `['.', a, 'b']` and a terminal call step `['|()', c]`, each one + element shorter. Graphs and types written against the old spelling + (`['.', a, 'b', null]`) no longer validate. diff --git a/fjs/djs/todo/compile-modules-to-edag.md b/fjs/djs/todo/compile-modules-to-edag.md index 395e819d27..ab85dac6ad 100644 --- a/fjs/djs/todo/compile-modules-to-edag.md +++ b/fjs/djs/todo/compile-modules-to-edag.md @@ -251,7 +251,7 @@ Also introduce call operations into EDAG: ```js ['()', object, args] // f(...args) -['.', object, property, ['|()', args, null]] // o.p(...args) +['.', object, property, ['|()', args]] // o.p(...args) ``` There are two call spellings and the receiver is what tells them apart. `()` is the @@ -260,10 +260,10 @@ call is instead the **property-access node owning its call** — the `'|()'` ste `.` node's continuation is what carries the `this` binding, which no `()` node can. See "Chains" in [`../../edag/README.md`](../../edag/README.md). Stage 2 needs neither optional node (`?.`, `?.()`) nor any of the other three steps, since optional chaining -is not in its source subset; a plain property read is `['.', object, property, null]`. +is not in its source subset; a plain property read is `['.', object, property]`. The property operand of a `.` node carrying a `'|()'` step follows **the same canonical -safety restriction as `.`** with a `null` continuation. +safety restriction as `.`** with no continuation. In this stage that means a permitted string constant or number constant; prohibited names, runtime-computed strings, and other unsupported property expressions are rejected. This is the EDAG form of the method-call distinction and safety rules already @@ -289,14 +289,14 @@ The staged work builds on the basic structural forms already being defined for E the current DJS parser produces; - array constructors: `['[]', [...node]]`; - the argument array: `['args']`; -- Stage 1 property access: `['.', object, property, null]`, with the restricted - property operands described above — the `null` is the continuation operand, saying - the receiver this access produced is dropped; +- Stage 1 property access: `['.', object, property]`, with the restricted + property operands described above — the absent fourth operand is the continuation, + and leaving it out says the receiver this access produced is dropped; - Stage 2 non-capturing functions: `['=>', null, body]` (`frame` is a general `exp` in the schema; `null` is what *this task's* parser and interpreter are scoped to, not a schema-level restriction); - Stage 2 calls: `['()', callee, args]` for an ordinary call, and - `['.', object, property, ['|()', args, null]]` for a method call, with the property + `['.', object, property, ['|()', args]]` for a method call, with the property operand using the same restriction as `.`; - semantic sharing by node identity, serialized with DJS `const` references when needed. @@ -479,11 +479,11 @@ task; see [`bound-edag-interpreter-resources.md`](./bound-edag-interpreter-resou - [ ] Validate that a nested function body is a disjoint EDAG scope: operation nodes must not be shared across a function boundary, while sharing within the body is preserved. -- [x] `['()', callee, args]` and the `['|()', args, null]` step a `.` node carries for +- [x] `['()', callee, args]` and the `['|()', args]` step a `.` node carries for a method call are in the EDAG validation/type schema (`fjs/edag/`), shape only — the property-operand restriction below is this stage's own work. - [ ] Convert the corresponding parser call expressions to the EDAG call forms — `()` - for an ordinary call, a `.` node with a `['|()', args, null]` continuation for a + for an ordinary call, a `.` node with a `['|()', args]` continuation for a method call; reject prohibited or runtime-computed string properties in that node rather than bypassing the property-access safety rule. - [ ] Add proofs for non-capturing nested functions and ordinary/method calls in the diff --git a/fjs/djs/todo/interpret-edag.md b/fjs/djs/todo/interpret-edag.md index ebc7efd9d6..b452b8e7c1 100644 --- a/fjs/djs/todo/interpret-edag.md +++ b/fjs/djs/todo/interpret-edag.md @@ -50,9 +50,9 @@ reuse the first call's `[1]`. Sharing of a body node remains memoized within eac individual invocation. As the compiler lands the staged operators, the direct interpreter should support the -same EDAG forms: Stage 1 adds `.` property access with a `null` continuation; Stage 2 +same EDAG forms: Stage 1 adds `.` property access with no continuation; Stage 2 adds non-capturing `=>`, the ordinary call `['()', callee, args]`, and the method call -— a `.` node whose continuation is `['|()', args, null]`, which is what carries the +— a `.` node whose continuation is `['|()', args]`, which is what carries the `this` binding. Stage 2 deliberately has **no frame support** — a restriction on *this interpreter*, not @@ -96,9 +96,9 @@ hardening TODO after the baseline interpreter exists. - [ ] Validate the final EDAG before interpretation. - [ ] Interpret EDAG operations directly; do not generate JavaScript from EDAG and run it through the host JavaScript engine. -- [ ] Support Stage 1 `['.', object, property, null]` property access. +- [ ] Support Stage 1 `['.', object, property]` property access. - [ ] Support Stage 2 `['=>', null, body]`, `['()', callee, args]` for an ordinary - call, and `['.', object, property, ['|()', args, null]]` for a method call — + call, and `['.', object, property, ['|()', args]]` for a method call — the step is what supplies the `this` binding — when those operators land. - [ ] Do **not** implement `['frame']` or non-empty closure frames in Stage 2. - [ ] Memoize results by EDAG node identity within one evaluation context so shared diff --git a/fjs/edag/README.md b/fjs/edag/README.md index 15c2b48a6a..a4403f41b5 100644 --- a/fjs/edag/README.md +++ b/fjs/edag/README.md @@ -68,14 +68,20 @@ vocabularies. | `['args']` | the function's arguments | | `['frame']` | the captured frame | | `['()', exp, exp]` | call with no receiver: `exp0(...exp1)` — see [Chains](#chains) | -| `['.', exp, index, propertyLambda]` | property access `exp0[exp1]`, owning whatever its receiver is used for | -| `['?.', exp, index, optionPropertyLambda]` | optional property access `exp0?.[exp1]`, owning the rest of its optional region | -| `['?.()', exp, exp, optionLambda]` | optional call `exp0?.(...exp1)`, likewise | -| `['\|()', exp, k]`, `['\|.', index, k]`, `['\|?.()', exp, k]`, `['\|!()', exp, null]` | a chain step and its continuation — only valid in the continuation operand of a node above, or of another step | +| `['.', exp, index]`, `['.', exp, index, propertyLambda]` | property access `exp0[exp1]`, owning whatever its receiver is used for | +| `['?.', exp, index]`, `['?.', exp, index, optionPropertyLambda]` | optional property access `exp0?.[exp1]`, owning the rest of its optional region | +| `['?.()', exp, exp]`, `['?.()', exp, exp, optionLambda]` | optional call `exp0?.(...exp1)`, likewise | +| `['\|()', exp, k?]`, `['\|.', index, k?]`, `['\|?.()', exp, k?]`, `['\|!()', exp]` | a chain step and, where the chain continues, its continuation — only valid in the continuation operand of a node above, or of another step | | `[',', exps]` | comma: establish all operands, take the value of the last | | `[id, exp]` | unary operation, `id` one of `String` `Number` `neg` `!` `~` | | `[id, exp, exp]` | binary operation, `id` one of `=>` `own` `===` `!==` `>` `>=` `<` `<=` `+` `-` `*` `/` `%` `**` `&` `\|` `^` `<<` `>>` `>>>` `&&` `\|\|` `??` | +Where a form is listed twice above, the two are the node's arities: the +shorter one ends the chain and the longer one hands it on, and the schema is +their union. A `k?` in the step row says the same thing one level down. That +is the whole of how a chain ends — there is no terminator value, so `null` in +a continuation position is simply not one of these forms. + A `[]` suffix in the form column marks an operand that is an array of the named schema, not one of it: `['[]', items[]]` holds a whole array of `items`, and `exps` is likewise `exp[]`. The distinction is easy to lose in @@ -93,9 +99,12 @@ the array operand is the decided representation rather than a stand-in for a flat one — [`todo/edag-stage1-discussion.md`](../../todo/edag-stage1-discussion.md) writes the same shape. -A continuation is **not** an array. It is `null` or one step holding the next +A continuation is **not** an array. It is one step holding the next continuation, so a chain is a linked list whose link type changes as it goes — -which link type is legal where is the whole of [Chains](#chains) below. +which link type is legal where is the whole of [Chains](#chains) below. The +list ends by **arity**: the step or node that ends it is simply the shorter +tuple, with no continuation operand at all, which is why every kind that can +end is a union of its two closed lengths. An `index` — the property operand of `.`, `?.`, and the `|.` step — is a `string`, a `number`, or `['Number', exp]`, a computed index cast to a @@ -130,7 +139,7 @@ control flow has to be born, carried, and consumed inside one node — and the node's **continuation** operand is where it is carried. A continuation is a *lambda*: a function of the chain's current value whose argument is elided, which is what the name says. It is not an `exp` and cannot be lifted out as a -shared node — `['|.', 'b', null]` means nothing on its own. +shared node — `['|.', 'b']` means nothing on its own. ### Two bits, three lambda types @@ -158,7 +167,7 @@ produces a bare value, which is why it alone has no continuation operand. | `['\|.', index, k]` | sets P, keeps O | property access; the input becomes the receiver | | `['\|()', exp, k]` | clears P, keeps O | call the current value with the current receiver | | `['\|?.()', exp, k]` | clears P, **sets** O | the same, `undefined` on a nullish current value — and the region it opens owns the rest of the chain | -| `['\|!()', exp, null]` | clears P, **clears** O | the same as `\|()`, but *outside* the region: the parentheses ended it, so a short-circuit does not skip this step | +| `['\|!()', exp]` | clears P, **clears** O | the same as `\|()`, but *outside* the region: the parentheses ended it, so a short-circuit does not skip this step | `?` adds a guard and `!` escapes one, which makes the three call steps a complete taxonomy of how a call can relate to the region it sits in: @@ -190,9 +199,11 @@ and which bit says why it cannot be a node instead: | | `\|?.()` | O and P | O | | | `\|!()` | P — the region is closing anyway | *(terminal)* | -`null` is every state's third exit — the chain simply ends and any live bit -is dropped, which is also the correct spelling of a bare `(a?.b)`, since -closing a region with nothing after it is unobservable. +Leaving the continuation operand out is every state's third exit — the chain +simply ends and any live bit is dropped, which is also the correct spelling of +a bare `(a?.b)`, since closing a region with nothing after it is +unobservable. Ending is therefore an absence, not a value: `null` is a +primitive again, and it has no reading in a continuation position. What is *absent* carries as much as what is present. `|!()` outside a region is not a design decision — there is no bit to clear. The three real decisions @@ -208,24 +219,24 @@ throws where `a?.b.c` does not. | JS | EDAG | |---|---| -| `a.b` | `['.', a, 'b', null]` | -| `a.b.c` | `['.', ['.', a, 'b', null], 'c', null]` | -| `a.b(...c)` | `['.', a, 'b', ['\|()', c, null]]` | -| `(0, a.b)(...c)` | `['()', ['.', a, 'b', null], c]` | -| `a.b?.(...c)` | `['.', a, 'b', ['\|?.()', c, null]]` | +| `a.b` | `['.', a, 'b']` | +| `a.b.c` | `['.', ['.', a, 'b'], 'c']` | +| `a.b(...c)` | `['.', a, 'b', ['\|()', c]]` | +| `(0, a.b)(...c)` | `['()', ['.', a, 'b'], c]` | +| `a.b?.(...c)` | `['.', a, 'b', ['\|?.()', c]]` | | `f(...c)` | `['()', f, c]` | -| `a?.b` | `['?.', a, 'b', null]` | -| `a?.b.c` | `['?.', a, 'b', ['\|.', 'c', null]]` | -| `(a?.b).c` | `['.', ['?.', a, 'b', null], 'c', null]` | -| `a?.b(...c)` | `['?.', a, 'b', ['\|()', c, null]]` | -| `a?.b?.(...c)` | `['?.', a, 'b', ['\|?.()', c, null]]` | -| `(a?.b)(...c)` | `['?.', a, 'b', ['\|!()', c, null]]` | -| `(a?.b.c)(...d)` | `['?.', a, 'b', ['\|.', 'c', ['\|!()', d, null]]]` | -| `(a?.b).c(...d)` | `['.', ['?.', a, 'b', null], 'c', ['\|()', d, null]]` | -| `a?.b(...c).d(...e)` | `['?.', a, 'b', ['\|()', c, ['\|.', 'd', ['\|()', e, null]]]]` | -| `a?.(...c)` | `['?.()', a, c, null]` | -| `a?.(...c).d` | `['?.()', a, c, ['\|.', 'd', null]]` | -| `(a?.(...c))(...d)` | `['()', ['?.()', a, c, null], d]` | +| `a?.b` | `['?.', a, 'b']` | +| `a?.b.c` | `['?.', a, 'b', ['\|.', 'c']]` | +| `(a?.b).c` | `['.', ['?.', a, 'b'], 'c']` | +| `a?.b(...c)` | `['?.', a, 'b', ['\|()', c]]` | +| `a?.b?.(...c)` | `['?.', a, 'b', ['\|?.()', c]]` | +| `(a?.b)(...c)` | `['?.', a, 'b', ['\|!()', c]]` | +| `(a?.b.c)(...d)` | `['?.', a, 'b', ['\|.', 'c', ['\|!()', d]]]` | +| `(a?.b).c(...d)` | `['.', ['?.', a, 'b'], 'c', ['\|()', d]]` | +| `a?.b(...c).d(...e)` | `['?.', a, 'b', ['\|()', c, ['\|.', 'd', ['\|()', e]]]]` | +| `a?.(...c)` | `['?.()', a, c]` | +| `a?.(...c).d` | `['?.()', a, c, ['\|.', 'd']]` | +| `(a?.(...c))(...d)` | `['()', ['?.()', a, c], d]` | The `chains` section of [proof.f.mjs](proof.f.mjs) pins the shape of every spelling above, `chainsJs` next to it runs them as JS on the host engine, and @@ -247,19 +258,22 @@ section of [proof.f.mjs](proof.f.mjs) is one case per family. The same holds for dead prefixes: `propertyLambda` has no `|.` production, so plain property paths nest and `a.b.c` has exactly one spelling. "Exactly one" is literal rather than "up to trailing junk", because every tuple in the -schema is closed — `['.', a, 'b', null, 'extra']` does not validate. +schema is closed — `['.', a, 'b', k, 'extra']` does not validate. Two things the vocabulary makes disjoint deserve stating, because neither is cosmetic. **The `|` prefix is a correctness requirement.** Unprefixed, -`['()', f, null]` would be simultaneously a well-formed `()` node — call `f` -with `null` as its arguments — and a well-formed `optionLambda` — call the -chain's value with `f` as its arguments, and stop. The two readings have the -same length, so closedness could not have separated them — it bounds a tuple's -length and says nothing about its tag; only disjoint vocabularies can. **Terminals state their `null`.** `propertyLambda`'s `|()` -and `optionPropertyLambda`'s `|!()` end the chain, and they say so with an -explicit third operand rather than by being one element shorter: a -two-element terminal handed a real continuation would validate as the -terminal with the rest silently dropped. +`['()', f, k]` would read as a well-formed `()` node — call `f` with `k` as +its arguments — and as a well-formed step — call the chain's value with `f` as +its arguments, then continue with `k`. Closedness bounds a tuple's length and +says nothing about its tag, so no arity separates those readings; only +disjoint vocabularies can, and the prefix does it without anyone having to +prove that a continuation could never also be an expression. +**Closedness by length is what a terminal rests on.** `propertyLambda`'s +`|()` and `optionPropertyLambda`'s `|!()` end the chain and have only the +two-element arity, so a continuation handed to one is a third element the +tuple does not declare, and the value is rejected rather than accepted with +the rest silently dropped — which is exactly what the length check gives and +what an `open` tuple would take away. ### Where the host engines disagree @@ -269,7 +283,7 @@ chain, so `undefined` is called. V8 does throw; JavaScriptCore (hence `bun test`) carries the short-circuit through the parentheses and evaluates to `undefined` instead. That case is exactly the `|!()` step, so no JavaScript oracle can establish it on every supported runner. The EDAG follows the -specification — `['?.', u, 'b', ['|!()', d, null]]` denotes the throwing +specification — `['?.', u, 'b', ['|!()', d]]` denotes the throwing reading, and an executor must produce it whatever its host does — as [amnesia](amnesia/module.f.mjs) does, where `optionRegion.throw.closeStepOnUndefined` in @@ -309,14 +323,17 @@ unblocks them, in ### The cost -Every property access carries a continuation operand, so a plain `a.b` is -`['.', a, 'b', null]` in every graph: more tuple elements to store and hash, -though no ambiguity, since `propertyLambda` has no `|.` production and a -property path keeps its unique spelling. +A plain `a.b` is `['.', a, 'b']`, so a property access that ends its chain +costs nothing beyond the access itself — the continuation operand is present +only where a chain actually continues. The price is paid in the schema +instead: every kind that can end is written twice, once per arity, so the +shared prefix appears in both arms. There is no ambiguity, since +`propertyLambda` has no `|.` production and a property path keeps its unique +spelling. The deeper cost is purity, and it is unchanged from any other shape that spells chains out of steps. A continuation is structured now, but it is still -not an `exp`: the `a.b` inside `['.', a, 'b', ['|?.()', c, null]]` cannot be +not an `exp`: the `a.b` inside `['.', a, 'b', ['|?.()', c]]` cannot be shared, substituted, or hashed. That is the price of expressing control flow that no value can carry, and it is confined to exactly the positions that need it. diff --git a/fjs/edag/amnesia/README.md b/fjs/edag/amnesia/README.md index 1ecd650cae..67f763e0cb 100644 --- a/fjs/edag/amnesia/README.md +++ b/fjs/edag/amnesia/README.md @@ -15,12 +15,32 @@ meaning. Two of its sections, `ownJs` and `chainsJs`, have to run **JavaScript** to pin the behavior the nodes are built around, because until this module existed nothing could run an EDAG. Its own [`proof.f.mjs`](./proof.f.mjs) is what that gap was waiting for: `['+', 2, 3]` is `5` and -`['&&', false, ['.', null, 'x', null]]` short-circuits are now claims a test +`['&&', false, ['.', null, 'x']]` short-circuits are now claims a test makes by evaluating the node, not by evaluating the JavaScript it was modeled on. ## Why it is not a VM +### It trusts its host + +Every node and step is read by **destructuring** (`const [o, e, cont] = k`), +which is what lets a chain end by arity: the array iterator stops at `length`, +so an absent continuation reads as `undefined` rather than as whatever a +prototype supplies at that index — an indexed `k[2]` would read the prototype, +which is why none appears. + +That choice is not a hardening claim, and no read style would be one. Under a +hostile host each has its own hole: an unchecked index reads through the +prototype, and destructuring dispatches an own `Symbol.iterator`, which can +yield a step past the length `validate` bounded. Neither is peculiar to +ending by arity — the walkers destructured before it too — and closing them +means a hermetic read path, which is +[rtti's tracked question](../../rtti/todo/hostile-accessor-hermetic-read-path.md) +for the readers and a VM's problem for an executor. This evaluator's +guarantees assume what the language itself can build: a DJS value on a +pristine host, where every read style coincides. That is one more reason it +is not a VM, and nothing that matters should run on it. + ### It forgets — hence the name The model memoizes every node by identity within one invocation, so a shared @@ -54,8 +74,8 @@ preserve identity, and what each is for, are in `.` is `a[b]`, so the entire JavaScript prototype chain is reachable: ```js -vm(context)(['.', ['=>', ['[]', []], 1], 'constructor', null]) // Function -vm(context)(['.', ['{}', []], '__proto__', null]) // resolves +vm(context)(['.', ['=>', ['[]', []], 1], 'constructor']) // Function +vm(context)(['.', ['{}', []], '__proto__']) // resolves ``` [`spec/todo/2360-built-in.md`](../../../spec/todo/2360-built-in.md) lists both diff --git a/fjs/edag/amnesia/module.f.mjs b/fjs/edag/amnesia/module.f.mjs index 4fe59c3415..0914531472 100644 --- a/fjs/edag/amnesia/module.f.mjs +++ b/fjs/edag/amnesia/module.f.mjs @@ -102,13 +102,23 @@ const callProperty = (f, obj, prop, e) => obj[prop](...argsOf(f, e)) * every step is `[tag, operand, continuation]`, and a `|!()` is reachable * through `|.` steps from either — `(a?.(...b).c)(...d)` is exactly that. * - * @type {(f: (_: Exp) => unknown, k: OptionLambda | OptionPropertyLambda) => unknown} + * Like the three walkers below it reads a step by **destructuring**, never by + * index: destructuring goes through the array iterator, which stops at + * `length`, so a short step's absent continuation reads as `undefined` and + * never as whatever a prototype supplies at that index. An indexed `k[2]` + * would, which is why none appears here. This is not a hardening claim — + * under a hostile host an own `Symbol.iterator` can yield past `length` just + * as an unchecked index reads the prototype; see "It trusts its host" in + * `./README.md`. + * + * @type {(f: (_: Exp) => unknown, k: OptionLambda | OptionPropertyLambda | undefined) => unknown} */ const skip = (f, k) => { - if (k === null) { return undefined } - return k[0] === '|!()' - ? callValue(f, undefined, k[1]) - : skip(f, k[2]) + if (k === undefined) { return undefined } + const [o, e, cont] = k + return o === '|!()' + ? callValue(f, undefined, e) + : skip(f, cont) } /** @@ -116,10 +126,10 @@ const skip = (f, k) => { * step leaves. Nothing here can short-circuit: the two productions are a call * that stays in the region and a property access that hands on a receiver. * - * @type {(f: (_: Exp) => unknown, v: unknown, k: OptionLambda) => unknown} + * @type {(f: (_: Exp) => unknown, v: unknown, k: OptionLambda | undefined) => unknown} */ const optionLambda = (f, v, k) => { - if (k === null) { return v } + if (k === undefined) { return v } const [o, e, cont] = k switch (o) { case '|()': return optionLambda(f, callValue(f, v, e), cont) @@ -137,10 +147,10 @@ const optionLambda = (f, v, k) => { * `obj[prop]` is read once per step, twice only where the guard has to see * the value before the call is made. * - * @type {(f: (_: Exp) => unknown, obj: any, prop: any, k: OptionPropertyLambda) => unknown} + * @type {(f: (_: Exp) => unknown, obj: any, prop: any, k: OptionPropertyLambda | undefined) => unknown} */ const optionPropertyLambda = (f, obj, prop, k) => { - if (k === null) { return obj[prop] } + if (k === undefined) { return obj[prop] } const [o, e, cont] = k switch (o) { case '|.': return optionPropertyLambda(f, obj[prop], f(e), cont) @@ -160,10 +170,10 @@ const optionPropertyLambda = (f, obj, prop, k) => { * node's value, since `optionLambda` has no `|!()` of its own — but the walk * still goes through `skip`, which reaches one through a `|.`. * - * @type {(f: (_: Exp) => unknown, obj: any, prop: any, k: PropertyLambda) => unknown} + * @type {(f: (_: Exp) => unknown, obj: any, prop: any, k: PropertyLambda | undefined) => unknown} */ const propertyLambda = (f, obj, prop, k) => { - if (k === null) { return obj[prop] } + if (k === undefined) { return obj[prop] } const [o, e, cont] = k switch (o) { case '|()': return callProperty(f, obj, prop, e) @@ -196,9 +206,11 @@ const map = { return a.reduce((/**@type {unknown}*/_, c) => f(c), undefined) }, '-': o2((a, b) => a - b), - // Property access, owning whatever its receiver is used for: a `null` - // continuation drops it, as reading `a.b` for its value does, and the two - // call steps are the only things that can spend it. + // Property access, owning whatever its receiver is used for: with no + // continuation operand the receiver is dropped, as reading `a.b` for its + // value does, and the two call steps are the only things that can spend + // it. The node is destructured, so a three-element `['.', a, k]` reads + // its absent fourth as `undefined` without touching the prototype. '.': (x, [, a, k, p]) => { const i = vm(x) return propertyLambda(i, i(a), i(k), p) diff --git a/fjs/edag/amnesia/proof.f.mjs b/fjs/edag/amnesia/proof.f.mjs index 9fde723ef0..50f54f1f25 100644 --- a/fjs/edag/amnesia/proof.f.mjs +++ b/fjs/edag/amnesia/proof.f.mjs @@ -37,7 +37,7 @@ const same = (e, expected) => { assertStructurallySame(ev(e), expected) } * `throw` section calls its forced counterparts. * @type {Exp} */ -const boom = ['.', null, 'x', null] +const boom = ['.', null, 'x'] /** * The same, in a naming position: an `index` is a `string`, a `number`, or a @@ -57,7 +57,7 @@ const noArgs = ['[]', []] * case is about the call and not about what the callee computes. * @type {Exp} */ -const identity = ['=>', ['[]', []], ['.', ['args'], 0, null]] +const identity = ['=>', ['[]', []], ['.', ['args'], 0]] /** `(...a) => a` — hands back the whole argument array. @type {Exp} */ const argsNode = ['=>', ['[]', []], ['args']] @@ -152,15 +152,15 @@ export const proof = { eq(['>>', -8, 1], -4) eq(['>>>', -1, 31], 1) }, - // `.` with a `null` continuation is the plain read — the receiver it + // `.` with no continuation operand is the plain read — the receiver it // produced is dropped, exactly as reading `a.b` for its value drops it. // `own` is the `o2` next to it, and they differ on the prototype chain. property: () => { - eq(['.', ['[]', [1, 2, 3]], 1, null], 2) - eq(['.', ['{}', [[':', 'a', 7]]], 'a', null], 7) + eq(['.', ['[]', [1, 2, 3]], 1], 2) + eq(['.', ['{}', [[':', 'a', 7]]], 'a'], 7) // An `index` is a string, a number, or `['Number', exp]`, and all // three name a property the same way. - eq(['.', ['[]', [1, 2, 3]], ['Number', '1'], null], 2) + eq(['.', ['[]', [1, 2, 3]], ['Number', '1']], 2) eq(['own', ['{}', [[':', 'a', 7]]], 'a'], 7) // Absent: no descriptor, so `?.value` is `undefined` rather than a // read of `undefined.value`. @@ -172,7 +172,7 @@ export const proof = { // `own`'s key operand must *evaluate* to one, so `1` is rejected // rather than silently reading `'1'` — see `throw.ownNonStringKey`. eq(['own', ['{}', [[':', '1', 42]]], '1'], 42) - assert(typeof ev(['.', ['{}', []], 'toString', null]) === 'function') + assert(typeof ev(['.', ['{}', []], 'toString']) === 'function') }, // `o2lazy` — the right operand is a thunk, so these three short-circuit. // Each case that claims "not evaluated" uses `boom`, which throws if it @@ -229,10 +229,10 @@ export const proof = { // every other node kind and sees the same context at any depth. nested: () => { eq(['+', ['+', 1, 2], 3], 6) - eq(['.', ['args'], 1, null], 20) - eq(['.', ['frame'], 'x', null], 1) + eq(['.', ['args'], 1], 20) + eq(['.', ['frame'], 'x'], 1) same( - ['{}', [[':', 'a', ['[]', [['.', ['args'], 0, null], ['neg', 1]]]]]], + ['{}', [[':', 'a', ['[]', [['.', ['args'], 0], ['neg', 1]]]]]], { a: [10, -1] }, ) }, @@ -264,7 +264,7 @@ export const proof = { same(['()', argsNode, ['[]', [1, ['...', ['[]', [2, 3]]]]]], [1, 2, 3]) // Operands are evaluated in the *caller's* scope, before the callee's // exists: the callee expression as much as the arguments. - eq(['()', ['.', ['[]', [identity]], 0, null], ['[]', [['+', 3, 4]]]], 7) + eq(['()', ['.', ['[]', [identity]], 0], ['[]', [['+', 3, 4]]]], 7) }, // The continuation of a `.` node — `propertyLambda`, the state with a // live receiver and no region around it. A step is a function of the @@ -273,67 +273,67 @@ export const proof = { // exists only while the chain is being walked. Only the two call steps // are here, because only a call spends a receiver. chain: { - // `['|()', exp, null]` — the terminal call step. The value called is + // `['|()', exp]` — the terminal call step. The value called is // the property, and the object it came from is the receiver // (`receiver`, below). callStep: () => { // a.b(...c) - eq(['.', methods, 'id', ['|()', ['[]', [7]], null]], 7) + eq(['.', methods, 'id', ['|()', ['[]', [7]]]], 7) // (a.b.c)(...d) — a plain property path nests, and a non-optional // chain means the same parenthesized or not. - eq(['.', ['.', methods, 'o', null], 'id', ['|()', ['[]', [7]], null]], 7) + eq(['.', ['.', methods, 'o'], 'id', ['|()', ['[]', [7]]]], 7) // The args operand is still one node evaluating to the whole // argument array: a chain changes what is called, not how it is // called. - same(['.', methods, 'args', ['|()', ['[]', [5, 6]], null]], [5, 6]) - same(['.', methods, 'args', ['|()', noArgs, null]], []) + same(['.', methods, 'args', ['|()', ['[]', [5, 6]]]], [5, 6]) + same(['.', methods, 'args', ['|()', noArgs]], []) // The three `index` forms, in the naming position of the node // that owns the call. - eq(['.', ['[]', [identity]], 0, ['|()', ['[]', [7]], null]], 7) - eq(['.', ['[]', [identity]], ['Number', '0'], ['|()', ['[]', [7]], null]], 7) + eq(['.', ['[]', [identity]], 0, ['|()', ['[]', [7]]]], 7) + eq(['.', ['[]', [identity]], ['Number', '0'], ['|()', ['[]', [7]]]], 7) }, // `['|?.()', exp, k]` — the guarded call step: it spends the receiver // and *opens* a region, so unlike `|()` it carries a continuation. // With a non-nullish value it behaves exactly as `|()` does. optionCallStep: () => { // a.b?.(...c) - eq(['.', methods, 'id', ['|?.()', ['[]', [7]], null]], 7) + eq(['.', methods, 'id', ['|?.()', ['[]', [7]]]], 7) // a.b?.(...c).d(...e) — the region it opened owns the rest. eq(['.', ['{}', [[':', 'g', constMethods]]], 'g', ['|?.()', noArgs, - ['|.', 'id', ['|()', ['[]', [7]], null]]]], 7) + ['|.', 'id', ['|()', ['[]', [7]]]]]], 7) }, // ... and the guard is the whole difference: on a nullish value the // region opens and immediately short-circuits, so the node is // `undefined` rather than a call on nothing — and neither the // arguments nor any later step runs. optionCallStepSkips: () => { - eq(['.', ['{}', []], 'absent', ['|?.()', boom, null]], undefined) + eq(['.', ['{}', []], 'absent', ['|?.()', boom]], undefined) eq(['.', ['{}', [[':', 'b', null]]], 'b', ['|?.()', boom, - ['|.', boomIndex, ['|()', boom, null]]]], undefined) + ['|.', boomIndex, ['|()', boom]]]], undefined) }, // The receiver is what a property step leaves behind, and it is // real rather than bookkeeping: `[42].at(0)` is `42` only because - // `at` is called *on* the array. A `.` node with a `null` - // continuation computes the same function value and drops it + // `at` is called *on* the array. A `.` node with no continuation + // operand computes the same function value and drops it // (`throw.detachedReceiver`) — the pair `chainsJs.receiver` makes in // JavaScript, made here by the nodes. receiver: () => { - eq(['.', ['[]', [42]], 'at', ['|()', ['[]', [0]], null]], 42) - eq(['.', ['[]', [42]], 'at', ['|?.()', ['[]', [0]], null]], 42) + eq(['.', ['[]', [42]], 'at', ['|()', ['[]', [0]]]], 42) + eq(['.', ['[]', [42]], 'at', ['|?.()', ['[]', [0]]]], 42) // A call step consumed the receiver of the step before it, so // `'ab'.at(0).toUpperCase()` needs a second `.` node to make its // own — which is exactly why `|()` is terminal here. eq(['.', - ['.', 'ab', 'at', ['|()', ['[]', [0]], null]], + ['.', 'ab', 'at', ['|()', ['[]', [0]]]], 'toUpperCase', - ['|()', noArgs, null]], 'A') + ['|()', noArgs]], 'A') }, throw: { // `const at = a.at; at(0)` — the receiver a `.` node keeps for - // the call it owns is exactly what a `null` continuation drops, + // the call it owns is exactly what the shorter arity drops, // and the host method is strict, so the detached call throws. detachedReceiver: () => - ev(['()', ['.', ['[]', [42]], 'at', null], ['[]', [0]]]), + ev(['()', ['.', ['[]', [42]], 'at'], ['[]', [0]]]), // `((a.at)(0))(0)` — the same detachment reached through a call // node, so the callee is a bare value rather than an accessor. // A host method is what makes that observable: an `=>` closure @@ -344,21 +344,21 @@ export const proof = { // in `./module.f.mjs`. detachedReceiverAfterCall: () => ev(['()', - ['()', ['.', ['[]', [42]], 'at', null], ['[]', [0]]], + ['()', ['.', ['[]', [42]], 'at'], ['[]', [0]]], ['[]', [0]]]), // A step is only as good as what it lands on: a call step onto a // value that is not callable reaches the same host `TypeError` // as `throw.callNonFunction`, one node earlier. callStepOnNonFunction: () => - ev(['.', ['{}', [[':', 'a', 1]]], 'a', ['|()', noArgs, null]]), + ev(['.', ['{}', [[':', 'a', 1]]], 'a', ['|()', noArgs]]), // A `.` node guards nothing, so a nullish base throws at the // access — the operand-evaluation half of the pair // `../proof.f.mjs`'s `chainsJs.throw` cannot state in JavaScript: // here the arguments are never reached, where // `optionRegion.throw.closeStepOnUndefined` evaluates them and // then calls `undefined`. - propertyOnUndefined: () => ev(['.', undef, 'at', ['|()', noArgs, null]]), - propertyOnNull: () => ev(['.', null, 'at', ['|()', noArgs, null]]), + propertyOnUndefined: () => ev(['.', undef, 'at', ['|()', noArgs]]), + propertyOnNull: () => ev(['.', null, 'at', ['|()', noArgs]]), }, }, // `?.` — the node that opens an optional *region*: its own `?.[index]` @@ -367,62 +367,62 @@ export const proof = { // call. Every case here has a counterpart under `chain` that throws for // exactly that reason. optionDot: () => { - // a?.b — the node's own step, which is the whole node when the - // continuation is `null`. Reading `a` and skipping the step would + // a?.b — the node's own step, which is the whole node at the + // shorter arity. Reading `a` and skipping the step would // evaluate to `a` itself, so these pin the index is applied. - eq(['?.', ['{}', [[':', 'a', 7]]], 'a', null], 7) + eq(['?.', ['{}', [[':', 'a', 7]]], 'a'], 7) // A closure is a value like any other — compared by `typeof`, since // every evaluation of a `=>` builds a fresh one (see `lambda`). - assert(typeof ev(['?.', methods, 'id', null]) === 'function') - same(['?.', ['[]', [1, 2, 3]], 1, null], 2) - eq(['?.', ['[]', [1, 2, 3]], ['Number', '1'], null], 2) + assert(typeof ev(['?.', methods, 'id']) === 'function') + same(['?.', ['[]', [1, 2, 3]], 1], 2) + eq(['?.', ['[]', [1, 2, 3]], ['Number', '1']], 2) // An absent property is `undefined`, not an error: `?.` guards its // *input*, never its result. - eq(['?.', ['{}', []], 'absent', null], undefined) + eq(['?.', ['{}', []], 'absent'], undefined) // ... and on a nullish input the node is `undefined`, both ways of // being nullish. - eq(['?.', undef, 'a', null], undefined) - eq(['?.', null, 'a', null], undefined) + eq(['?.', undef, 'a'], undefined) + eq(['?.', null, 'a'], undefined) // a?.b.c — `|.` continues the region, handing the receiver on within // it, and the steps run when nothing short-circuited. eq(['?.', ['{}', [[':', 'o', ['{}', [[':', 'a', 7]]]]]], 'o', - ['|.', 'a', null]], 7) + ['|.', 'a']], 7) // a?.b(...c) — `|()` inherits the region's guard and the receiver // survives into it, which is why `?.` owns its call rather than // evaluating to a value a `()` node would then have to call: // `[42]?.at(0)` is `42` only if `at` is called *on* the array. - eq(['?.', ['[]', [42]], 'at', ['|()', ['[]', [0]], null]], 42) + eq(['?.', ['[]', [42]], 'at', ['|()', ['[]', [0]]]], 42) // a?.b?.(...c) — `|?.()` adds its own guard on top of the region's. - eq(['?.', ['[]', [42]], 'at', ['|?.()', ['[]', [0]], null]], 42) + eq(['?.', ['[]', [42]], 'at', ['|?.()', ['[]', [0]]]], 42) // (a?.b)(...c) — `|!()` escapes the region, and keeps the receiver: // the parentheses end the chain, they do not detach the reference. - eq(['?.', ['[]', [42]], 'at', ['|!()', ['[]', [0]], null]], 42) + eq(['?.', ['[]', [42]], 'at', ['|!()', ['[]', [0]]]], 42) // (a?.b.c)(...d) — the same close one property step further in. - eq(['?.', methods, 'o', ['|.', 'id', ['|!()', ['[]', [7]], null]]], 7) + eq(['?.', methods, 'o', ['|.', 'id', ['|!()', ['[]', [7]]]]], 7) // a?.b.c?.(...d) — the guarded call reached through a property step, // which is the region handing `optionPropertyLambda` back to itself. - eq(['?.', methods, 'o', ['|.', 'id', ['|?.()', ['[]', [7]], null]]], 7) + eq(['?.', methods, 'o', ['|.', 'id', ['|?.()', ['[]', [7]]]]], 7) // a?.b(...c).d(...e) — one region across two calls, the second // making its own receiver. eq(['?.', ['{}', [[':', 'g', constMethods]]], 'g', - ['|()', noArgs, ['|.', 'id', ['|()', ['[]', [7]], null]]]], 7) + ['|()', noArgs, ['|.', 'id', ['|()', ['[]', [7]]]]]], 7) }, // `?.()` — the other region-opening node. Its callee is an ordinary // expression, so it never carries a receiver; what it owns is the rest // of the region, run on the call's result. optionCall: () => { // f?.(...c) - eq(['?.()', identity, ['[]', [7]], null], 7) + eq(['?.()', identity, ['[]', [7]]], 7) // ... and the args operand is one node evaluating to the whole // argument array, as everywhere else a call takes one. - same(['?.()', argsNode, ['[]', [5, 6]], null], [5, 6]) + same(['?.()', argsNode, ['[]', [5, 6]]], [5, 6]) // f?.(...c)(...d) — `|()` stays inside the region. - eq(['?.()', constIdentity, noArgs, ['|()', ['[]', [7]], null]], 7) + eq(['?.()', constIdentity, noArgs, ['|()', ['[]', [7]]]], 7) // f?.(...c).d(...e) — `|.` makes a receiver for the call after it, // which is the receiver chain `../README.md` gives as the reason // there is no `.()` node. eq(['?.()', constMethods, noArgs, - ['|.', 'id', ['|()', ['[]', [7]], null]]], 7) + ['|.', 'id', ['|()', ['[]', [7]]]]], 7) }, // The short-circuit, which is what the two region-opening nodes exist // for: they return rather than throw, so — unlike a `.` node, where @@ -433,31 +433,31 @@ export const proof = { // u?.b.c is `undefined`, where `(u?.b).c` throws: one region // against two nodes (`../README.md`, "Chains"). `boomIndex` as // the skipped step's index would throw if the step ran. - eq(['?.', undef, 'a', ['|.', boomIndex, null]], undefined) + eq(['?.', undef, 'a', ['|.', boomIndex]], undefined) // u?.b(...c) is `undefined`, where `(u?.b)(...c)` throws — the // pair `throw.closeStepOnUndefined` completes. The skipped // call's arguments are not evaluated either. - eq(['?.', undef, 'at', ['|()', boom, null]], undefined) + eq(['?.', undef, 'at', ['|()', boom]], undefined) // The node's own index is skipped too, which is the operand // `../proof.f.mjs`'s `chainsJs.shortCircuit` pins in JavaScript // as `u?.[todo()]`. - eq(['?.', undef, boomIndex, null], undefined) - eq(['?.', null, boomIndex, ['|.', boomIndex, null]], undefined) + eq(['?.', undef, boomIndex], undefined) + eq(['?.', null, boomIndex, ['|.', boomIndex]], undefined) // A guarded step mid-region short-circuits the same way: here // `a.b` is `undefined`, so `|?.()` skips itself and everything // after it. eq(['?.', ['{}', [[':', 'b', undef]]], 'b', - ['|?.()', boom, ['|.', boomIndex, null]]], undefined) + ['|?.()', boom, ['|.', boomIndex]]], undefined) // The nullish value need not be the node's own input: a property // step reading an absent property produces one mid-region, and // the guard after it skips the rest. - eq(['?.', methods, 'absent', ['|?.()', boom, null]], undefined) + eq(['?.', methods, 'absent', ['|?.()', boom]], undefined) // f?.(...c) with a nullish `f`: `undefined`, and the arguments // are not evaluated. Both ways of being nullish. - eq(['?.()', undef, boom, null], undefined) - eq(['?.()', null, boom, null], undefined) + eq(['?.()', undef, boom], undefined) + eq(['?.()', null, boom], undefined) // ... and the continuation is skipped along with the call. - eq(['?.()', undef, boom, ['|.', boomIndex, ['|()', boom, null]]], + eq(['?.()', undef, boom, ['|.', boomIndex, ['|()', boom]]], undefined) }, throw: { @@ -471,33 +471,33 @@ export const proof = { // commented out. The node denotes the throw regardless — see // "Chains" in `../README.md`. closeStepOnUndefined: () => - ev(['?.', undef, 'at', ['|!()', noArgs, null]]), + ev(['?.', undef, 'at', ['|!()', noArgs]]), closeStepOnNull: () => - ev(['?.', null, 'at', ['|!()', noArgs, null]]), + ev(['?.', null, 'at', ['|!()', noArgs]]), // `(u?.b.c)(...d)` — the same, reached past a skipped `|.`: the // walk that drops steps has to keep looking for the close rather // than stop at the first one it skips. closeStepPastSkippedProperty: () => - ev(['?.', undef, 'at', ['|.', boomIndex, ['|!()', noArgs, null]]]), + ev(['?.', undef, 'at', ['|.', boomIndex, ['|!()', noArgs]]]), // `(u?.(...a).c)(...d)` — and it reaches one from the other // region-opening node too, through the `|.` that leaves // `optionLambda` for `optionPropertyLambda`. closeStepAfterOptionCall: () => - ev(['?.()', undef, boom, ['|.', boomIndex, ['|!()', noArgs, null]]]), + ev(['?.()', undef, boom, ['|.', boomIndex, ['|!()', noArgs]]]), // `(a.absent?.(...b).m)(...d)` — and from a `.` node, whose // `|?.()` opens a region that short-circuits at once. That is the // third and last entry to `skip`, so between them the three cases // cover every state a region can be abandoned in. closeStepAfterPropertyGuard: () => ev(['.', methods, 'absent', - ['|?.()', boom, ['|.', boomIndex, ['|!()', noArgs, null]]]]), + ['|?.()', boom, ['|.', boomIndex, ['|!()', noArgs]]]]), // `(a.absent?.(...b))(...d)` — the same short-circuit under a // *node* boundary instead of a step: the `.` node evaluates to // `undefined` and the `()` over it calls that. The step spelling // above and this one are the two halves of the parenthesis law // at the same place, and they agree. callOfSkippedGuard: () => - ev(['()', ['.', methods, 'absent', ['|?.()', boom, null]], noArgs]), + ev(['()', ['.', methods, 'absent', ['|?.()', boom]], noArgs]), }, }, // The frame is the only channel outward: a body's leaves are constants, @@ -505,20 +505,20 @@ export const proof = { closure: () => { // `['=>', ['[]', [100]], …]` captures `100` at closure-creation time. eq(['()', ['=>', ['[]', [100]], - ['+', ['.', ['args'], 0, null], ['.', ['frame'], 0, null]]], + ['+', ['.', ['args'], 0], ['.', ['frame'], 0]]], ['[]', [5]]], 105) // Nested: the outer call's argument is copied into the inner frame, // and the inner body reads it as `['frame']` — the same node - // `['.', ['args'], 0, null]` could not have been shared across the `=>`. + // `['.', ['args'], 0]` could not have been shared across the `=>`. const outer = /** @type {Exp} */ ([ '=>', ['[]', []], - ['=>', ['[]', [['.', ['args'], 0, null]]], ['.', ['frame'], 0, null]], + ['=>', ['[]', [['.', ['args'], 0]]], ['.', ['frame'], 0]], ]) eq(['()', ['()', outer, ['[]', [7]]], noArgs], 7) // The frame operand is evaluated in the enclosing scope, so it sees // that scope's `['args']` — the one place a `=>` node reaches out. assertEq(vm({ frame: null, args: [11] })( - ['()', ['=>', ['[]', [['.', ['args'], 0, null]]], ['.', ['frame'], 0, null]], + ['()', ['=>', ['[]', [['.', ['args'], 0]]], ['.', ['frame'], 0]], noArgs]), 11) }, @@ -528,30 +528,30 @@ export const proof = { // `(g, x) => g(x)` const apply = /** @type {Exp} */ ([ '=>', ['[]', []], - ['()', ['.', ['args'], 0, null], ['[]', [['.', ['args'], 1, null]]]], + ['()', ['.', ['args'], 0], ['[]', [['.', ['args'], 1]]]], ]) eq(['()', apply, ['[]', [identity, 7]]], 7) // `x => y => x + y`, applied twice — the classic case the frame // exists for. const add = /** @type {Exp} */ ([ '=>', ['[]', []], - ['=>', ['[]', [['.', ['args'], 0, null]]], - ['+', ['.', ['frame'], 0, null], ['.', ['args'], 0, null]]], + ['=>', ['[]', [['.', ['args'], 0]]], + ['+', ['.', ['frame'], 0], ['.', ['args'], 0]]], ]) eq(['()', ['()', add, ['[]', [2]]], ['[]', [3]]], 5) }, throw: { // The index of a `?.` whose input is *not* nullish is evaluated, the // mirror of `optionRegion.skips`'s skipped operands. - evaluatedIndex: () => ev(['?.', ['{}', []], boomIndex, null]), + evaluatedIndex: () => ev(['?.', ['{}', []], boomIndex]), // ... and so are an optional call's arguments once its callee turns // out to be there. - evaluatedArgument: () => ev(['?.()', identity, boom, null]), + evaluatedArgument: () => ev(['?.()', identity, boom]), // `?.()` guards against a *nullish* callee, not against a // non-callable one: `1?.()` is the host `TypeError`, exactly as // `throw.callNonFunction` is for `()`. optionCallOnNonFunction: () => - ev(['?.()', ['.', ['{}', [[':', 'a', 1]]], 'a', null], noArgs, null]), + ev(['?.()', ['.', ['{}', [[':', 'a', 1]]], 'a'], noArgs]), // An array spread iterates its operand, so a non-iterable one throws // where the object form would have contributed nothing. arraySpreadOfNumber: () => ev(['[]', [['...', 1]]]), diff --git a/fjs/edag/module.f.mjs b/fjs/edag/module.f.mjs index 114378d953..8512a6e324 100644 --- a/fjs/edag/module.f.mjs +++ b/fjs/edag/module.f.mjs @@ -21,9 +21,11 @@ import { * chain grammar below claims each JS chain has exactly one spelling, and an * `open` tuple would let any node carry a trailing element nothing reads, * splitting one function into unboundedly many graphs. So do **not** wrap any - * of these in `open`. No operand of any node is optional either: a chain step - * that does no further work carries an explicit `null` continuation, never a - * missing position. + * of these in `open`. No operand of any node is optional either. A chain that + * ends says so by **arity**: the step or node is simply the shorter tuple, + * with no continuation position at all, and closedness by length is what + * keeps the two arities apart — which is why each such kind is an `or` of + * both rather than one tuple with an omittable tail. * * Do not call `parse(exp)` or rely on `validate(exp)` rejecting cycles * without reading `../rtti/todo/identity-aware-parse.md` first — @@ -204,11 +206,19 @@ export const index = or(numberCast, string, number) // // A lambda is **not** an `exp`: it reads the chain's current value implicitly, // so it has no operand to hold one, and it cannot be lifted out as a shared -// computation node — `['|.', 'b', null]` means nothing on its own, only as +// computation node — `['|.', 'b']` means nothing on its own, only as // the continuation of some chain node. That is the standing cost of this // shape: the receiver a step consumes cannot be shared, substituted, or // hashed. // +// **Ending a chain** is spelled by arity, not by a terminator value: a step +// or node that hands the chain on carries its continuation as a last operand, +// and one that ends it is the same tuple one element shorter. So every kind +// that can end is an `or` of its two closed arities, `null` is a primitive +// again rather than a chain terminator, and a trailing hole — `['.', a, 'b', ,]` +// — matches neither arm: the short one is bounded by length, and the long one +// has no `option` member to admit an absent one. +// // Four steps, each a transition on the two bits: // // ```text @@ -218,12 +228,14 @@ export const index = or(numberCast, string, number) // |!() clears P, clears O a call consumes it and closes the region // ``` // -// Every tag carries the `|` prefix, and that is a correctness requirement -// rather than a readability one. Unprefixed, `['()', f, null]` would be -// simultaneously a well-formed `call` — call `f` with `null` as its arguments -// — and a well-formed `optionLambda` — call the chain's value with `f` as its -// arguments, and stop. The two readings have the same length, so closedness -// cannot separate them; only disjoint vocabularies can. +// Every tag carries the `|` prefix, which keeps the step vocabulary disjoint +// from the node vocabulary: a tuple's tag alone says which grammar it belongs +// to. Unprefixed, `['()', f, k]` would read as a `call` — call `f` with `k` +// as its arguments — and as a step — call the chain's value with `f` as its +// arguments, then continue with `k`. Closedness bounds a tuple's length and +// says nothing about its tag, so no arity separates those readings; the +// prefix does, and does it without anyone having to prove that a continuation +// could never also be an expression. // // A production exists in a state exactly when moving that step into a nested // node would be **observable**, which is why the same step appears in one @@ -244,15 +256,22 @@ export const index = or(numberCast, string, number) * would not protect equally, so admitting them would only add a second * spelling. * + * Each production appears twice, once per **arity**: a step that hands the + * chain on carries its continuation, and one that ends the chain is a + * shorter tuple with no continuation position at all — see "Ending a chain" + * above. + * * @type {() => readonly['or', - * null, + * readonly['|()', typeof exp], * readonly['|()', typeof exp, typeof optionLambda], + * readonly['|.', typeof index], * readonly['|.', typeof index, typeof optionPropertyLambda], * ]} */ export const _optionLambda = () => (['or', - null, + /** @type {const} */ (['|()', exp]), /** @type {const} */ (['|()', exp, optionLambda]), + /** @type {const} */ (['|.', index]), /** @type {const} */ (['|.', index, optionPropertyLambda]), ]) @@ -267,31 +286,36 @@ export const optionLambda = _optionLambda * the region around it, and there is no fourth: * * ```js - * a?.b(...c) // ['?.', a, 'b', ['|()', c, null]] inherits the guard - * a?.b?.(...c) // ['?.', a, 'b', ['|?.()', c, null]] adds its own - * (a?.b)(...c) // ['?.', a, 'b', ['|!()', c, null]] escapes it + * a?.b(...c) // ['?.', a, 'b', ['|()', c]] inherits the guard + * a?.b?.(...c) // ['?.', a, 'b', ['|?.()', c]] adds its own + * (a?.b)(...c) // ['?.', a, 'b', ['|!()', c]] escapes it * ``` * * `|!()` is the one step a short-circuit does not skip: the parentheses ended * the region, so the `undefined` it produced is what gets called. `|!` pairs * only with `()` because only a call consumes a receiver — a close-then-access * `|!.` would just be a `dot` over the whole node, which nesting already - * spells. + * spells. It is also the one production with a single arity: closing the + * region is terminal, so it never carries a continuation. * * @type {() => readonly['or', - * null, + * readonly['|()', typeof exp], * readonly['|()', typeof exp, typeof optionLambda], + * readonly['|.', typeof index], * readonly['|.', typeof index, typeof optionPropertyLambda], + * readonly['|?.()', typeof exp], * readonly['|?.()', typeof exp, typeof optionLambda], - * readonly['|!()', typeof exp, null], + * readonly['|!()', typeof exp], * ]} */ export const _optionPropertyLambda = () => (['or', - null, + /** @type {const} */ (['|()', exp]), /** @type {const} */ (['|()', exp, optionLambda]), + /** @type {const} */ (['|.', index]), /** @type {const} */ (['|.', index, optionPropertyLambda]), + /** @type {const} */ (['|?.()', exp]), /** @type {const} */ (['|?.()', exp, optionLambda]), - /** @type {const} */ (['|!()', exp, null]), + /** @type {const} */ (['|!()', exp]), ]) /** @type {Phantom} */ @@ -303,20 +327,14 @@ export const optionPropertyLambda = _optionPropertyLambda * Only the two call steps are here, because only a call can use a receiver. * `|()` is terminal: with the receiver spent and no region to be inside, what * follows an `a.b(...c)` is an ordinary expression over an ordinary value, so - * it nests. `|?.()` continues, since it opens a region that then owns the - * rest of the chain. There is no `|.` production, which is what gives a plain - * property path exactly one spelling: `a.b.c` is nested `dot`s and nothing - * else. - * - * The terminal's third operand is a literal `null`, not the absence of one. - * Uniform arity is what keeps closedness able to tell it from `['|()', c, k]`: - * were the terminal two elements long, a continuation handed to a - * `propertyLambda` slot would be read as the terminal with the rest silently - * dropped. + * it nests — which is why it has only the shorter arity. `|?.()` continues, + * since it opens a region that then owns the rest of the chain, so it has + * both. There is no `|.` production, which is what gives a plain property + * path exactly one spelling: `a.b.c` is nested `dot`s and nothing else. */ export const propertyLambda = or( - null, - /** @type {const} */ (['|()', exp, null]), + /** @type {const} */ (['|()', exp]), + /** @type {const} */ (['|?.()', exp]), /** @type {const} */ (['|?.()', exp, optionLambda]), ) @@ -335,6 +353,9 @@ export const propertyLambda = or( * The last operand is one node evaluating to the complete argument array, * not a literal operand list: `f(a, b)` is `['()', f, ['[]', [a, b]]]`, * while spread `f(...xs)` is `['()', f, xs]`. + * + * One arity, unlike the three chain nodes below: `()` produces a bare value + * and so has no continuation operand to leave out. */ export const call = /** @type {const} */ (['()', exp, exp]) @@ -342,30 +363,33 @@ export const call = /** @type {const} */ (['()', exp, exp]) /** * ```js - * exp0.k // ['.', exp0, 'k', null] - * exp0[exp1] // ['.', exp0, ['Number', exp1], null] - * exp0.k(...exp2) // ['.', exp0, 'k', ['|()', exp2, null]] - * exp0.k?.(...exp2) // ['.', exp0, 'k', ['|?.()', exp2, null]] + * exp0.k // ['.', exp0, 'k'] + * exp0[exp1] // ['.', exp0, ['Number', exp1]] + * exp0.k(...exp2) // ['.', exp0, 'k', ['|()', exp2]] + * exp0.k?.(...exp2) // ['.', exp0, 'k', ['|?.()', exp2]] * ``` * * The naming operand is an `index`, not an `exp`, so a computed key is spelled - * `['Number', exp]`: `['.', a, ['args'], null]` does not validate. + * `['Number', exp]`: `['.', a, ['args']]` does not validate. * * Property access, owning whatever the receiver it produces is used for. The - * `null` continuation is the plain read — the receiver is dropped, as JS + * three-element arity is the plain read — the receiver is dropped, as JS * drops it — and the two call continuations are the only things that can use * it. */ -export const dot = /** @type {const} */ (['.', exp, index, propertyLambda]) +export const dot = or( + /** @type {const} */ (['.', exp, index]), + /** @type {const} */ (['.', exp, index, propertyLambda]), +) // Option Dot /** * ```js - * exp0?.k // ['?.', exp0, 'k', null] - * exp0?.[exp1] // ['?.', exp0, ['Number', exp1], null] - * exp0?.k.m // ['?.', exp0, 'k', ['|.', 'm', null]] - * (exp0?.k)(...exp2) // ['?.', exp0, 'k', ['|!()', exp2, null]] + * exp0?.k // ['?.', exp0, 'k'] + * exp0?.[exp1] // ['?.', exp0, ['Number', exp1]] + * exp0?.k.m // ['?.', exp0, 'k', ['|.', 'm']] + * (exp0?.k)(...exp2) // ['?.', exp0, 'k', ['|!()', exp2]] * ``` * * Optional property access, owning the rest of its optional region. If `exp0` @@ -376,18 +400,20 @@ export const dot = /** @type {const} */ (['.', exp, index, propertyLambda]) * therefore calls that `undefined`. * * Where the region ends is the grouping: `a?.b.c` is one node, - * `['?.', a, 'b', ['|.', 'c', null]]`, while `(a?.b).c` is a `dot` over a - * complete `['?.', a, 'b', null]` — and throws when `a` is nullish, as JS - * does. + * `['?.', a, 'b', ['|.', 'c']]`, while `(a?.b).c` is a `dot` over a + * complete `['?.', a, 'b']` — and throws when `a` is nullish, as JS does. */ -export const optionDot = /** @type {const} */ (['?.', exp, index, optionPropertyLambda]) +export const optionDot = or( + /** @type {const} */ (['?.', exp, index]), + /** @type {const} */ (['?.', exp, index, optionPropertyLambda]), +) // Option Call /** * ```js - * exp0?.(...exp1) // ['?.()', exp0, exp1, null] - * exp0?.(...exp1).k // ['?.()', exp0, exp1, ['|.', 'k', null]] + * exp0?.(...exp1) // ['?.()', exp0, exp1] + * exp0?.(...exp1).k // ['?.()', exp0, exp1, ['|.', 'k']] * ``` * * Optional call, owning the rest of its optional region the way `?.` does. @@ -395,7 +421,10 @@ export const optionDot = /** @type {const} */ (['?.', exp, index, optionProperty * — `a.b?.(...c)` is a `dot` with a `|?.()` continuation, not this. If `exp0` * is nullish the arguments are not evaluated and the region short-circuits. */ -export const optionCall = /** @type {const} */ (['?.()', exp, exp, optionLambda]) +export const optionCall = or( + /** @type {const} */ (['?.()', exp, exp]), + /** @type {const} */ (['?.()', exp, exp, optionLambda]), +) // Comma diff --git a/fjs/edag/proof.f.mjs b/fjs/edag/proof.f.mjs index b41b23e509..9bf2b88671 100644 --- a/fjs/edag/proof.f.mjs +++ b/fjs/edag/proof.f.mjs @@ -270,31 +270,42 @@ export const proof = { assertNoMatch(v(['.', 'a', 'b', ['|?.()', 1, null, 'extra']])) assertNoMatch(v(['?.', 'a', 'b', ['|.', 'c', null, 'extra']])) assertNoMatch(v(['?.', 'a', 'b', ['|!()', 1, null, 'extra']])) - assertNoMatch(v(['.', 'a', ['Number', 1, 'extra'], null])) + assertNoMatch(v(['.', 'a', ['Number', 1, 'extra']])) }, }, dot: { ok: () => { - assertOk(v(['.', 'a', 'b', null])) - assertOk(v(['.', ['[]', [1, 2]], 0, null])) + assertOk(v(['.', 'a', 'b'])) + assertOk(v(['.', ['[]', [1, 2]], 0])) // `index`'s three accepted shapes, pinned explicitly: string, // number (above), and a `numberCast` (below) — not `boolean` // (see `error`). - assertOk(v(['.', 'a', ['Number', 1], null])) + assertOk(v(['.', 'a', ['Number', 1]])) }, // `index` — string, number, or `numberCast` — never admitted // `undefined`, so a missing index has always been a real error. - missingIndexIsError: () => assertNoMatch(v(['.', 'a', null, null])), - // The continuation is not optional. `null` says "the receiver is - // dropped here"; a missing position says nothing, and reads as - // `undefined`, which no lambda admits. - missingTailIsError: () => assertNoMatch(v(['.', 'a', 'b'])), + missingIndexIsError: () => assertNoMatch(v(['.', 'a', null])), + // Ending the chain is the *shorter* arity (`ok` above), so the + // terminator values are gone: `null` is a primitive again and has no + // reading in a continuation position, and a present `undefined` never + // had one. + terminatorTailIsError: () => { + assertNoMatch(v(['.', 'a', 'b', null])) + assertNoMatch(v(['.', 'a', 'b', undefined])) + }, + // A trailing **hole** is not the short arity: the three-element arm + // is bounded by length and the four-element arm has no `option` + // member, so a length-4 value with nothing at index 3 matches + // neither. `concat` builds one without a hole literal, which + // FunctionalScript does not have. + trailingHoleIsError: () => + assertNoMatch(v(['.', 'a', 'b'].concat(new Array(1)))), error: () => { assertNoMatch(v(['x', 'a', 'b', null])) - assertNoMatch(v(['.', {}, 'b', null])) + assertNoMatch(v(['.', {}, 'b'])) // `index` excludes `boolean` on purpose — not narrowed to just // `string`/`number` by accident. - assertNoMatch(v(['.', 'a', true, null])) + assertNoMatch(v(['.', 'a', true])) }, }, // The chain continuations. There are three because a chain carries two @@ -307,96 +318,103 @@ export const proof = { // because only a call spends a receiver; `|()` is terminal and // `|?.()` opens a region that owns the rest of the chain. propertyLambda: () => { - assertOk(vPropertyLambda(null)) - assertOk(vPropertyLambda(['|()', 1, null])) - assertOk(vPropertyLambda(['|?.()', 1, null])) - assertOk(vPropertyLambda(['|?.()', 1, ['|.', 'c', null]])) + assertOk(vPropertyLambda(['|()', 1])) + assertOk(vPropertyLambda(['|?.()', 1])) + assertOk(vPropertyLambda(['|?.()', 1, ['|.', 'c']])) // No `|.`: a property step here would waste the receiver with no // region to keep it in, so `a.b.c` nests `.` nodes instead. That // absence is what gives a plain property path one spelling. - assertNoMatch(vPropertyLambda(['|.', 'c', null])) + assertNoMatch(vPropertyLambda(['|.', 'c'])) // No `|!()`: there is no open region for it to close. - assertNoMatch(vPropertyLambda(['|!()', 1, null])) + assertNoMatch(vPropertyLambda(['|!()', 1])) }, // `optionLambda` — a plain value inside a region. A call stays in the // region (it must, or the region would not cover it) and a property // step hands a receiver on within it. optionLambda: () => { - assertOk(vOptionLambda(null)) - assertOk(vOptionLambda(['|()', 1, null])) - assertOk(vOptionLambda(['|.', 'c', null])) - assertOk(vOptionLambda(['|.', 'c', ['|!()', 1, null]])) + assertOk(vOptionLambda(['|()', 1])) + assertOk(vOptionLambda(['|.', 'c'])) + assertOk(vOptionLambda(['|.', 'c', ['|!()', 1]])) // Neither `|?.()` nor `|!()` is here: with the receiver already // spent, guarding or closing at this point protects nothing a // nested node would not protect equally. - assertNoMatch(vOptionLambda(['|?.()', 1, null])) - assertNoMatch(vOptionLambda(['|!()', 1, null])) + assertNoMatch(vOptionLambda(['|?.()', 1])) + assertNoMatch(vOptionLambda(['|!()', 1])) }, // `optionPropertyLambda` — both bits live, so every production is // here: the three ways a call can relate to its region, plus the // property step the region will not let leave. optionPropertyLambda: () => { - assertOk(vOptionPropertyLambda(null)) - assertOk(vOptionPropertyLambda(['|()', 1, null])) - assertOk(vOptionPropertyLambda(['|.', 'c', null])) - assertOk(vOptionPropertyLambda(['|?.()', 1, null])) - assertOk(vOptionPropertyLambda(['|!()', 1, null])) + assertOk(vOptionPropertyLambda(['|()', 1])) + assertOk(vOptionPropertyLambda(['|.', 'c'])) + assertOk(vOptionPropertyLambda(['|?.()', 1])) + assertOk(vOptionPropertyLambda(['|!()', 1])) // `|.` hands the region back to this same state, so every // production above is reachable one property step further in — // `a?.b.c?.(...d)` is the guarded call through a `|.`. - assertOk(vOptionPropertyLambda(['|.', 'c', ['|?.()', 1, null]])) - assertOk(vOptionPropertyLambda(['|.', 'c', ['|.', 'd', null]])) + assertOk(vOptionPropertyLambda(['|.', 'c', ['|?.()', 1]])) + assertOk(vOptionPropertyLambda(['|.', 'c', ['|.', 'd']])) }, // `|.` takes an `index` and the call steps take an `exp`, the same // operand schemas the nodes use — so a general `exp` in a naming // position is rejected where it is accepted in an argument one. operandSchemas: () => { - assertOk(vOptionLambda(['|.', 0, null])) - assertOk(vOptionLambda(['|.', ['Number', 1], null])) - assertNoMatch(vOptionLambda(['|.', ['[]', []], null])) - assertOk(vOptionLambda(['|()', ['[]', [1, 2]], null])) + assertOk(vOptionLambda(['|.', 0])) + assertOk(vOptionLambda(['|.', ['Number', 1]])) + assertNoMatch(vOptionLambda(['|.', ['[]', []]])) + assertOk(vOptionLambda(['|()', ['[]', [1, 2]]])) }, // A lambda only means anything as the continuation of a chain node: // it takes its input implicitly, so on its own it is not an `exp` and // cannot be lifted out as a shared node. The `|` prefix is what makes // that statable — see `tagsAreDisjoint`. notAnExp: () => { - assertNoMatch(v(['|.', 'b', null])) - assertNoMatch(v(['|()', 1, null])) - assertNoMatch(v(['|?.()', 1, null])) - assertNoMatch(v(['|!()', 1, null])) - }, - // The prefix is a correctness requirement, not a readability one. - // Unprefixed, `['()', f, null]` would be both a `call` — call `f` - // with `null` as its arguments — and an `optionLambda` — call the - // chain's value with `f` as its arguments, and stop. Equal length, so - // closedness could not have separated them — it bounds a tuple's - // length and says nothing about its tag; only disjoint vocabularies - // can, and these two assertions are that disjointness. + assertNoMatch(v(['|.', 'b'])) + assertNoMatch(v(['|()', 1])) + assertNoMatch(v(['|?.()', 1])) + assertNoMatch(v(['|!()', 1])) + }, + // The prefix keeps the step vocabulary disjoint from the node one, + // so a tuple's tag alone says which grammar it belongs to. Unprefixed, + // `['()', f, k]` would read as a `call` — call `f` with `k` as its + // arguments — and as a step — call the chain's value with `f` as its + // arguments, then continue with `k`. Closedness bounds a tuple's + // length and says nothing about its tag, so no arity separates those + // readings; these assertions are the disjointness that does. tagsAreDisjoint: () => { assertOk(v(['()', 'f', null])) assertNoMatch(vOptionLambda(['()', 'f', null])) - assertNoMatch(v(['|()', 'f', null])) - }, - // Uniform arity is the other half. `propertyLambda`'s `|()` is - // terminal, and it says so with an explicit `null` rather than by - // being one element shorter: a two-element terminal handed a real - // continuation would validate with the rest silently dropped. - terminalsAreExplicit: () => { - assertNoMatch(vPropertyLambda(['|()', 1])) - assertNoMatch(vPropertyLambda(['|()', 1, ['|.', 'c', null]])) - assertNoMatch(vOptionPropertyLambda(['|!()', 1])) - assertNoMatch(vOptionPropertyLambda(['|!()', 1, ['|.', 'c', null]])) - }, - missingTailIsError: () => { - assertNoMatch(vOptionPropertyLambda(['|.', 'c'])) - assertNoMatch(vOptionPropertyLambda(['|()', 1])) - assertNoMatch(vOptionPropertyLambda(['|?.()', 1])) + assertNoMatch(v(['|()', 'f'])) + }, + // A terminal has one arity, and closedness by length is what keeps + // the rest from being smuggled past it: `propertyLambda`'s `|()` + // spends the receiver and exits, `optionPropertyLambda`'s `|!()` + // closes the region, and neither has a three-element arm to hold a + // continuation. This is the case the old explicit `null` existed to + // guard, and length now answers it. + terminalsTakeNoContinuation: () => { + assertNoMatch(vPropertyLambda(['|()', 1, ['|.', 'c']])) + assertNoMatch(vOptionPropertyLambda(['|!()', 1, ['|.', 'c']])) + // ...including the old spelling, whose `null` is simply a third + // element the terminal does not declare. + assertNoMatch(vPropertyLambda(['|()', 1, null])) + assertNoMatch(vOptionPropertyLambda(['|!()', 1, null])) + }, + // The steps that *can* continue end by being one element shorter, + // never by carrying a terminator, and never by leaving a hole where + // the continuation would go. + endingIsTheShorterArity: () => { + assertOk(vOptionPropertyLambda(['|.', 'c'])) + assertOk(vOptionPropertyLambda(['|()', 1])) + assertOk(vOptionPropertyLambda(['|?.()', 1])) + assertNoMatch(vOptionPropertyLambda(['|.', 'c', null])) + assertNoMatch(vOptionPropertyLambda(['|()', 1, undefined])) + assertNoMatch(vOptionPropertyLambda(['|()', 1].concat(new Array(1)))) }, // Each tag is a real constraint, not a stand-in for `string`: a node // tag in a step position is rejected, and so is an unknown one. unknownOpIsRejected: () => { - assertNoMatch(vOptionPropertyLambda(['.', 'b', null])) + assertNoMatch(vOptionPropertyLambda(['.', 'b'])) assertNoMatch(vOptionPropertyLambda(['|.z', 'b', null])) assertNoMatch(vOptionPropertyLambda('xyz')) }, @@ -404,7 +422,7 @@ export const proof = { call: { ok: () => { assertOk(v(['()', 'f', ['[]', []]])) - assertOk(v(['()', ['.', 'o', 'k', null], 1])) // (0, o.k)(...args) + assertOk(v(['()', ['.', 'o', 'k'], 1])) // (0, o.k)(...args) assertOk(v(['()', 'f', ['[]', [1, 2]]])) }, // A missing argument operand reads as `undefined` — an error, same @@ -417,30 +435,40 @@ export const proof = { }, optionDot: { ok: () => { - assertOk(v(['?.', 'a', 'b', null])) - assertOk(v(['?.', 'a', ['Number', 1], null])) - assertOk(v(['?.', 'a', 'b', ['|.', 'c', null]])) + assertOk(v(['?.', 'a', 'b'])) + assertOk(v(['?.', 'a', ['Number', 1]])) + assertOk(v(['?.', 'a', 'b', ['|.', 'c']])) }, // Same three `index` shapes as `.`, `boolean` excluded the same way. error: () => { - assertNoMatch(v(['?.', 'a', true, null])) + assertNoMatch(v(['?.', 'a', true])) assertNoMatch(v(['?.z', 'a', 'b', null])) }, - // The continuation is required — `null` says "the optional region - // ends here", a missing position says nothing. - missingTailIsError: () => assertNoMatch(v(['?.', 'a', 'b'])), + // As on `.`: the region ends at the shorter arity, so a terminator + // value or a trailing hole in the continuation position is an error. + terminatorTailIsError: () => { + assertNoMatch(v(['?.', 'a', 'b', null])) + assertNoMatch(v(['?.', 'a', 'b', undefined])) + }, + trailingHoleIsError: () => + assertNoMatch(v(['?.', 'a', 'b'].concat(new Array(1)))), }, optionCall: { ok: () => { - assertOk(v(['?.()', 'f', 1, null])) - assertOk(v(['?.()', 'f', ['[]', [1]], ['|()', 2, null]])) + assertOk(v(['?.()', 'f', 1])) + assertOk(v(['?.()', 'f', ['[]', [1]], ['|()', 2]])) }, // One continuation, not two: the callee is an ordinary expression, so - // there is no pre-call chain for this node to own. - missingTailIsError: () => { - assertNoMatch(v(['?.()', 'f', 1])) - assertNoMatch(v(['?.()', 'f'])) - }, + // there is no pre-call chain for this node to own. The *arguments* + // operand is still required — only the continuation is what the + // shorter arity leaves out. + missingArgsIsError: () => assertNoMatch(v(['?.()', 'f'])), + terminatorTailIsError: () => { + assertNoMatch(v(['?.()', 'f', 1, null])) + assertNoMatch(v(['?.()', 'f', 1, undefined])) + }, + trailingHoleIsError: () => + assertNoMatch(v(['?.()', 'f', 1].concat(new Array(1)))), error: () => assertNoMatch(v(['?.()', 'f', [], 1, []])), }, // One entry per JS spelling whose grouping or hidden control flow the @@ -454,58 +482,58 @@ export const proof = { // that node owns. Reaching the call through a complete node instead // is the detached spelling, and a different graph. receiver: () => { - assertOk(v(['.', 'a', 'b', ['|()', 'args', null]])) // a.b(...args) - assertOk(v(['()', ['.', 'a', 'b', null], 'args'])) // (0, a.b)(...args) - assertOk(v(['.', 'a', 'b', ['|?.()', 'args', null]])) // a.b?.(...args) - assertOk(v(['?.()', 'a', 'args', null])) // a?.(...args) + assertOk(v(['.', 'a', 'b', ['|()', 'args']])) // a.b(...args) + assertOk(v(['()', ['.', 'a', 'b'], 'args'])) // (0, a.b)(...args) + assertOk(v(['.', 'a', 'b', ['|?.()', 'args']])) // a.b?.(...args) + assertOk(v(['?.()', 'a', 'args'])) // a?.(...args) // (a?.(...args))(...args2) - assertOk(v(['()', ['?.()', 'a', 'args', null], 'args2'])) + assertOk(v(['()', ['?.()', 'a', 'args'], 'args2'])) }, // A plain property path nests, because `propertyLambda` has no `|.` // production — so `a.b.c` has exactly one spelling and the dead-prefix // rule needs no lowering pass to hold. propertyPath: () => { - assertOk(v(['.', ['.', 'a', 'b', null], 'c', null])) // a.b.c + assertOk(v(['.', ['.', 'a', 'b'], 'c'])) // a.b.c // a.b(...args).c — the inner call is the inner node's business. - assertOk(v(['.', ['.', 'a', 'b', ['|()', 'args', null]], 'c', null])) + assertOk(v(['.', ['.', 'a', 'b', ['|()', 'args']], 'c'])) }, // An optional region is one continuation chain, however long, and // grouping is what ends it: `a?.b.c` skips `.c` on a nullish `a`, // `(a?.b).c` throws there — one node against two. optionalRegion: () => { - assertOk(v(['?.', 'a', 'b', null])) // a?.b - assertOk(v(['?.', 'a', 'b', ['|.', 'c', null]])) // a?.b.c - assertOk(v(['.', ['?.', 'a', 'b', null], 'c', null])) // (a?.b).c + assertOk(v(['?.', 'a', 'b'])) // a?.b + assertOk(v(['?.', 'a', 'b', ['|.', 'c']])) // a?.b.c + assertOk(v(['.', ['?.', 'a', 'b'], 'c'])) // (a?.b).c // a?.b.c(...args) — the call is inside the region. - assertOk(v(['?.', 'a', 'b', ['|.', 'c', ['|()', 'args', null]]])) + assertOk(v(['?.', 'a', 'b', ['|.', 'c', ['|()', 'args']]])) // (a?.b).c(...args) — the parens ended it, so a `.` node owns the // call and `a?.b` is a complete node under it. - assertOk(v(['.', ['?.', 'a', 'b', null], 'c', ['|()', 'args', null]])) + assertOk(v(['.', ['?.', 'a', 'b'], 'c', ['|()', 'args']])) // a?.b(...args).c(...args2) — one region across two calls. assertOk(v(['?.', 'a', 'b', - ['|()', 'args', ['|.', 'c', ['|()', 'args2', null]]]])) + ['|()', 'args', ['|.', 'c', ['|()', 'args2']]]])) // a?.(...args).c and a?.(...args)(...args2) - assertOk(v(['?.()', 'a', 'args', ['|.', 'c', null]])) - assertOk(v(['?.()', 'a', 'args', ['|()', 'args2', null]])) + assertOk(v(['?.()', 'a', 'args', ['|.', 'c']])) + assertOk(v(['?.()', 'a', 'args', ['|()', 'args2']])) }, // The three ways a call can relate to the region around it — the // complete taxonomy, and the reason `|!()` is a tag of its own. callsAgainstTheRegion: () => { - assertOk(v(['?.', 'a', 'b', ['|()', 'args', null]])) // a?.b(...args) - assertOk(v(['?.', 'a', 'b', ['|?.()', 'args', null]])) // a?.b?.(...args) - assertOk(v(['?.', 'a', 'b', ['|!()', 'args', null]])) // (a?.b)(...args) + assertOk(v(['?.', 'a', 'b', ['|()', 'args']])) // a?.b(...args) + assertOk(v(['?.', 'a', 'b', ['|?.()', 'args']])) // a?.b?.(...args) + assertOk(v(['?.', 'a', 'b', ['|!()', 'args']])) // (a?.b)(...args) // (a?.b.c)(...args) — the region closes after a property step, // which is the same `|!()` one step further in. - assertOk(v(['?.', 'a', 'b', ['|.', 'c', ['|!()', 'args', null]]])) + assertOk(v(['?.', 'a', 'b', ['|.', 'c', ['|!()', 'args']]])) // a?.b.c?.(...args) — and so is the guarded call. - assertOk(v(['?.', 'a', 'b', ['|.', 'c', ['|?.()', 'args', null]]])) + assertOk(v(['?.', 'a', 'b', ['|.', 'c', ['|?.()', 'args']]])) }, // The operands an optional node skips on its nullish branch have to // be operands *of* that node, which is what makes `k`/`a` // observably unevaluated: `a?.[k]` and `f?.(...a)`. skippedOperands: () => { - assertOk(v(['?.', 'a', ['Number', 'k'], null])) // a?.[k] - assertOk(v(['?.()', 'f', 'a', null])) // f?.(...a) + assertOk(v(['?.', 'a', ['Number', 'k']])) // a?.[k] + assertOk(v(['?.()', 'f', 'a'])) // f?.(...a) }, }, // The four duplicate families a flat step array admitted are not @@ -515,27 +543,27 @@ export const proof = { // `a?.b?.c` — no lambda has a `?.` production at all; `?.` is only // ever a node tag, so a guarded property access always starts a node. optionalPropertyStep: () => { - assertNoMatch(v(['?.', 'a', 'b', ['|?.', 'c', null]])) - assertOk(v(['?.', ['?.', 'a', 'b', null], 'c', null])) // the spelling + assertNoMatch(v(['?.', 'a', 'b', ['|?.', 'c']])) + assertOk(v(['?.', ['?.', 'a', 'b'], 'c'])) // the spelling }, // `a.b(...c)?.d` — `propertyLambda`'s `|()` is terminal, so the chain // exits and what follows is an ordinary node over an ordinary value. callTerminatesPropertyLambda: () => { - assertNoMatch(v(['.', 'a', 'b', ['|()', 'c', ['|.', 'd', null]]])) - assertOk(v(['?.', ['.', 'a', 'b', ['|()', 'c', null]], 'd', null])) + assertNoMatch(v(['.', 'a', 'b', ['|()', 'c', ['|.', 'd']]])) + assertOk(v(['?.', ['.', 'a', 'b', ['|()', 'c']], 'd'])) }, // `(a?.(...b))(...c)` — `optionLambda` has no `|!()`, since with the // receiver already spent there is nothing for the close to keep. The // outer call is a plain `()` over a complete `?.()` node. closeWithoutReceiver: () => { - assertNoMatch(v(['?.()', 'a', 'b', ['|!()', 'c', null]])) - assertOk(v(['()', ['?.()', 'a', 'b', null], 'c'])) + assertNoMatch(v(['?.()', 'a', 'b', ['|!()', 'c']])) + assertOk(v(['()', ['?.()', 'a', 'b'], 'c'])) }, // `a?.b(...c)?.d` — `optionLambda` has no guarded step either, so the // guarded access after the call starts its own node. guardedStepAfterCall: () => { - assertNoMatch(v(['?.', 'a', 'b', ['|()', 'c', ['|?.()', 'd', null]]])) - assertOk(v(['?.()', ['?.', 'a', 'b', ['|()', 'c', null]], 'd', null])) + assertNoMatch(v(['?.', 'a', 'b', ['|()', 'c', ['|?.()', 'd']]])) + assertOk(v(['?.()', ['?.', 'a', 'b', ['|()', 'c']], 'd'])) }, }, op0: { @@ -630,17 +658,17 @@ export const proof = { // `this`, and parentheses around the reference do not break that — // only detaching the value does (`throw.detachedReceiver`). It holds // across an optional link too, which is why `(a?.b)(d)` is a `?.` - // node with a `|!()` continuation, `['?.', a, 'b', ['|!()', d, null]]`, - // rather than a `()` over a complete `['?.', a, 'b', null]`: the + // node with a `|!()` continuation, `['?.', a, 'b', ['|!()', d]]`, + // rather than a `()` over a complete `['?.', a, 'b']`: the // latter would produce an ordinary value and lose the receiver. receiver: () => { const a = [42] assertEq(a.at(0), 42) assertEq((a.at)(0), 42) - assertEq((a?.at)(0), 42) // ['?.', a, 'at', ['|!()', …, null]] - assertEq((a?.at)?.(0), 42) // ['?.', a, 'at', ['|?.()', …, null]] - assertEq(a.at?.(0), 42) // ['.', a, 'at', ['|?.()', …, null]] - assertEq(a?.at?.(0), 42) // ['?.', a, 'at', ['|?.()', …, null]] + assertEq((a?.at)(0), 42) // ['?.', a, 'at', ['|!()', …]] + assertEq((a?.at)?.(0), 42) // ['?.', a, 'at', ['|?.()', …]] + assertEq(a.at?.(0), 42) // ['.', a, 'at', ['|?.()', …]] + assertEq(a?.at?.(0), 42) // ['?.', a, 'at', ['|?.()', …]] }, // Short-circuit: a nullish link skips the rest of its chain, and // grouping is what ends that chain — `u?.at.name` is `undefined` @@ -650,7 +678,7 @@ export const proof = { /** @type {any} */ const u = undefined assertEq(u?.at, undefined) - assertEq(u?.at.name, undefined) // ['?.', u, 'at', ['|.', 'name', null]] + assertEq(u?.at.name, undefined) // ['?.', u, 'at', ['|.', 'name']] assertEq(u?.at?.(0), undefined) // The operands on the skipped branch are never evaluated: an // optional property's index, and an optional call's arguments. @@ -696,7 +724,7 @@ export const proof = { // `|!()` terms, JavaScriptCore (so `bun test`) carries the // short-circuit through the parentheses and answers `undefined` where // V8 throws, so asserting either answer would redden a runner. The - // node is unaffected — `['?.', u, 'at', ['|!()', …, null]]` means the + // node is unaffected — `['?.', u, 'at', ['|!()', …]]` means the // throwing reading — and `optionRegion.throw.closeStepOnUndefined` in // `./amnesia/proof.f.mjs` pins it by evaluating the node, which is // the only oracle that works on every runner. See "Chains" in @@ -759,7 +787,7 @@ export const proof = { const value = /** @type {const} */ (['.', ['()', 'f', ['args']], 'k', - ['|()', ['[]', [['.', 'obj', 'a', null]]], null], + ['|()', ['[]', [['.', 'obj', 'a']]]], ]) assertOk(v(value)) }, diff --git a/fjs/edag/todo/option-terminated-lambdas.md b/fjs/edag/todo/option-terminated-lambdas.md deleted file mode 100644 index 47e2eac4fa..0000000000 --- a/fjs/edag/todo/option-terminated-lambdas.md +++ /dev/null @@ -1,276 +0,0 @@ -# End chain lambdas by absence, not `null` - -**Priority:** P3 -**Status:** open - -## Problem - -The chain grammar spells "the chain ends here" as a literal `null`, in two -roles: as a member of all three lambda unions (`['.', a, 'b', null]` is a -plain read), and as the terminals' stated third operand — `['|()', exp, null]` -in `propertyLambda`, `['|!()', exp, null]` in `optionPropertyLambda`. That -gives `null` double duty in a graph — primitive value *and* chain terminator — -and puts a fourth element on every plain property access, the first cost named -in "The cost" of [`../README.md`](../README.md). - -rtti has a schema built to mean exactly "the member that is not there": -[`option`](../../rtti/module.f.mjs). A continuation is always the last operand -of a closed tuple, which is precisely the position where absence is observable -and where `TupleTs` renders it as an exact optional element. So the chain -could end by the operand *not being there*: `['.', a, 'b']`, `['|()', c]`, -`['|!()', c]`. - -The module's recorded argument against this is stale. "Terminals state their -`null` … were the terminal two elements long, a continuation handed to a -`propertyLambda` slot would be read as the terminal with the rest silently -dropped" (`../module.f.mjs`, `../README.md`) was written when the chain -grammar landed (53077ae, 2026-08-26) — **against open-by-default tuples**, -where it was true. Bare tuples became closed by length one day later -(7852819, 2026-08-27), whose design issue was titled "closed containers by -default, then `option` as omission" (930fa65, #1725) — this issue is the -second half of that plan. Under the closed model a two-element terminal handed -a continuation is rejected by length, not truncated. - -## Investigation (2026-08-28, verified) - -### Runtime, against the real `validate` - -With a `dot` whose fourth position is `or(option, …)` and step schemas ending -by absence: - -| value | result | why | -|---|---|---| -| `['.', a, 'b']` | ok | absent continuation — the plain read | -| `['.', a, 'b', ['\|()', c]]` | ok | two-element terminal step | -| `['.', a, 'b', ['\|()', c, ['\|()', d]]]` | error | the "silent drop" fear: closedness answers by length and **rejects** | -| `['.', a, 'b', undefined]` | error | absence is not a spelling of `undefined` | -| `['.', a, 'b', null]` | error | `null` leaves the chain vocabulary entirely | -| `['.', a, 'b', ['\|()', c], 'junk']` | error | closed node tuple, as today | -| `['.', a, 'b', ['\|?.()', c, ['\|.', 'd']]]` | ok | recursion through the thunks unaffected | - -### Type level, under the repository's flags - -Compiles clean (including `exactOptionalPropertyTypes`), with each negative -row above a genuine type error: - -- Hand-written types use optional trailing tuple elements, which `Ts` renders - **exactly** (`_OptionalTail` in [`../../rtti/ts/proof.f.mjs`](../../rtti/ts/proof.f.mjs)): - - ```ts - type PropertyLambda = - | readonly['|()', Exp] // terminal: genuinely shorter - | readonly['|?.()', Exp, OptionLambda?] - type OptionLambda = - | readonly['|()', Exp, OptionLambda?] - | readonly['|.', Index, OptionPropertyLambda?] - type OptionPropertyLambda = - | readonly['|()', Exp, OptionLambda?] - | readonly['|.', Index, OptionPropertyLambda?] - | readonly['|?.()', Exp, OptionLambda?] - | readonly['|!()', Exp] // terminal - type Dot = readonly['.', Exp, Index, PropertyLambda?] - ``` - -- The recursive thunks' roots now admit absence, so their `Phantom` - annotations must carry the flag in the wrapper — - `Phantom>` — pinned with - `CheckRaw` **in addition to** `Check3`, which cannot see the flag (the - public `Ts` strips absence from both sides). See the `Ts` JSDoc in - [`../../rtti/ts/types.ts`](../../rtti/ts/types.ts). This would be the first - real consumer of the `AbsentOr`/`CheckRaw` pattern — currently documented - with zero users — and it was verified to compile with the mutual recursion - above. `propertyLambda` is not phantom-wrapped, so it needs no wrapper: - `_AdmitsAbsence` walks its `or` directly. - -Both findings above verify the `option` spelling — the alternative the hole -question below ends up rejecting. They stay as the record of what was -established; the chosen arity-split spelling needs none of that machinery, -its hand-written types being plain unions of exact tuples, the pattern the -schema already uses today. - -### What is gained - -- `null` in a graph means one thing again: the primitive value. -- Every plain property access and every chain end drops one element — fewer - elements to store and hash. -- "The chain ends" is spelled by the operand not being there — a shorter - closed tuple. - -### Trailing holes must be rejected in the same change - -`option` admits a hole as absence, so the sparse `['.', a, 'b', ,]` — length -4, index 3 a hole — **also validates** (verified), a second spelling with a -second hash for the same function, where today's required-`null` schema -rejects every hole. FunctionalScript cannot produce it — "Two adjacent commas -are not an elision: an array has no holes" -([`../../../spec/README.md`](../../../spec/README.md), Arrays) — and a hole -even *evaluates* identically to absence (reading it yields `undefined`), so -the leak is canonicality-only. It is still a validation regression against -today's schema, and a regression may not be deferred behind a todo -([`AGENTS.md`](../../../AGENTS.md) §5, "Merge the knowledge"): the migration -does not land unless the same change keeps `validate(exp)` rejecting a -trailing hole, pinned in the proofs. - -**The chosen mechanism is arity-split unions, no `option` at all** (verified -against the real `validate`, no rtti change needed). Each node or step whose -continuation may end is a union of its two closed arities — -`or(['.', exp, index], ['.', exp, index, propertyLambda])`, and likewise per -step — so absence is spelled by the shorter tuple and a hole matches neither -arm: the 3-arity arm rejects length 4, the 4-arity arm has no `option` and -rejects the absent member. `['.', a, 'b', ,]` and `['|()', c, ,]` both -reject; every acceptance row in the table above is unchanged. Costs: each -such kind doubles its union arms, the shared prefix is written twice, and -the `AbsentOr`/`CheckRaw` machinery drops out — hand-written types are plain -unions of exact tuples, no optional elements. - -One boundary of the gate, measured both below and past `length`: a -**prototype-supplied index** is rtti's host question, not this migration's, -and the answers are symmetric between the spellings. The tuple readers -decide presence by HasProperty and read a below-`length` index through the -prototype, held to the schema (`constContainerValidate` in -[`../../rtti/validate/module.f.mjs`](../../rtti/validate/module.f.mjs)), -and never answer an index at or past `length` — both stated, with the -`Array.prototype[10] = 99` example, in "Beyond `length`" in -[`../../rtti/README.md`](../../rtti/README.md), as a caveat that "applies -to `array`, `record` and every container schema alike". So a polluted -`Array.prototype` reaches both spellings, and only each one's own -vocabulary decides which values flip: behind `['.', a, 'b', ,]`'s own -length-4 hole, an inherited `['|()', c, null]` validates under **today's** -schema and rejects under the split one, an inherited `['|()', c]` exactly -the reverse; past the end, the split 3-arity arm accepts a length-3 -`['.', a, 'b']` whatever `Array.prototype[3]` holds — the unanswered -region every bare tuple in the repository already has — while today's -schema accepts the same length-3 value the moment `Array.prototype[3]` is -its own `null`. Neither spelling is pollution-proof, neither ever was, and -under a pristine prototype — the only host DJS admits — both reject every -hole and every spelling has exactly one length. On the executor the -unanswered region is covered by the read pattern: -[amnesia](../amnesia/module.f.mjs)'s three lambda walkers **destructure** -(`const [o, e, cont] = k`), and destructuring goes through the array -iterator, which stops at `length`, so a prototype-supplied index past a -short tuple's end is never read — measured: with -`Array.prototype[3] = 'junk'`, the destructured fourth slot of a length-3 -node is `undefined` and the chain ends, while a direct `node[3]` would -read `'junk'`. The one walker that does **not** is `skip`, which reads -`k[0]`/`k[1]`/`k[2]` directly — safe today only because every step carries -an own third member; after the migration a short step's `k[2]` would read -the prototype, and a polluted `Array.prototype[2]` could hand `skip` an -inherited continuation where the step's own trailing `null` masks that -index today. So `skip` joins the destructuring pattern **in the same -change** — the amnesia task below says so. That is where executor-side -host-hardening **ends** for this migration, deliberately. Under a hostile -host every read style has its own attack — a direct index without a length -check reads the prototype, destructuring dispatches an own overridden -`Symbol.iterator`, which can fabricate elements — and the three lambda -walkers destructure **today**, so an iterator-hostile step already -misleads the current evaluator identically; the migration changes nothing -about that class, and `skip` joining the module's one uniform pattern adds -no sensitivity the module does not already have. Amnesia's own README -scopes it — "It is not a VM for FunctionalScript, and nothing that matters -should run on it" — and [`../README.md`](../README.md) adds the intent, -"deliberately not a VM to run FunctionalScript on": its guarantees assume -a DJS value on a pristine host, where every read style coincides. -The gate's claim is therefore about the value's **own** members under -rtti's stated reading model; hermetic reads for hostile hosts beyond that -are rtti's tracked question -([`hostile-accessor-hermetic-read-path`](../../rtti/todo/hostile-accessor-hermetic-read-path.md)), -and hardening an executor against a hostile host is a VM concern, out of -scope for amnesia by its own charter — not an EDAG-boundary duplicate. - -**The rejected alternative** — keep the `option` spelling and first land an -rtti rule that absence in a tuple is the array ending before the position, -never a hole (the validators' absent branch requiring the index at or past -`value.length`) — was weighed across three review rounds of -[#1755](https://github.com/functionalscript/functionalscript/pull/1755) and -rejected because its prerequisite grows into an open-ended redesign of -rtti's canonical algebra, not one condition. The rule aligns the readers' -array domain with DJS (no sparse arrays — the direction of -[`data-validate-admits-non-djs-values`](../../rtti/todo/data-validate-admits-non-djs-values.md)), -but it cascades: it reverses documented reader and printer behavior -(`option`'s "position 0 may be a hole"; `_InteriorTs`'s "what reading a hole -gives"); it collapses `[option]` into `[]` — one set once `new Array(1)` is -excluded — while `trimPrefix` in -[`../../rtti/data/module.f.mjs`](../../rtti/data/module.f.mjs) deliberately -keeps their canonical `Node`s distinct, so `arraySet` must renormalize and -`cmp`/`equal`/`subset`, the data reader, and the printer must follow; it -makes every **interior** absent bit unobservable (absence at a position is -realizable only when every later position also admits it, so -`[or(option, number), 3]` comes to denote `[number, 3]` while `toData` keeps -the `absentBit`), so those bits must be stripped, the trailing-run split -`TupleTs` already makes; and even that strip has no local answer for a -**referenced** node — the data form declines to see through a reference -(`trimPrefix` leaves referenced positions alone), and a rule like -`r = or(option, [r])` may sit at one position where its root absence is -unobservable and another where it is not, so stripping the rule itself -changes the fixpoint, and the design would need contextual specialization or -a stated canonicality exception. All of that as a prerequisite for a -migration that does not need it; anyone wanting the past-the-end rule on its -own merits files it as an rtti issue. - -## Proposal - -Drop the `null` member of all three lambda unions and the terminals' third -operand, and let the continuation positions of `dot`, `optionDot`, -`optionCall` and the steps end by the operand's absence — spelled as -arity-split unions, per the chosen mechanism above: each node or step whose -continuation may end becomes a union of its two closed arities, with no -`option` anywhere in the chain schemas. Code changes are small; the bulk is -mechanical respelling of proofs and prose. - -[amnesia](../amnesia/module.f.mjs) barely changes: its four `k === null` -checks become `k === undefined`, since reading the continuation position of a -shorter tuple yields `undefined` — which the schema guarantees is not a -present value there. - -### Tasks - -The schemas must not mix the spellings: an arity-split arm whose lambda root -carries `option` admits the absent member — and its hole — again. - -- [ ] `../module.f.mjs`: every lambda union and continuation-carrying node - splits by arity — no `option` member in any of them, terminals are the - shorter arm, phantom annotations stay plain -- [ ] `../types.ts`: plain unions of exact tuples, one per arity, no - optional elements -- [ ] `../amnesia/module.f.mjs`: `k === null` → `k === undefined`; - signatures take `… | undefined`; keep the walkers' destructuring reads — - the iterator stops at `length`, so a prototype-supplied index past a - short node's end is never read (see the gate boundary above) — never - switch a continuation read to direct indexing, and rewrite `skip`'s - direct `k[0]`/`k[1]`/`k[2]` to destructure like the other three walkers, - since a short step's `k[2]` would otherwise read the prototype -- [ ] `../proof.f.mjs`, `../amnesia/proof.f.mjs`: respell (~200 trailing - `null`s); add rejections for present `null`, present `undefined`, the - smuggled continuation on a terminal, and the trailing holes - `['.', a, 'b', ,]` and `['|()', c, ,]`; the `unspellable` family list - holds -- [ ] `../README.md`: node and spelling tables; "Terminals state their - `null`" inverts into "closedness by length rejects a smuggled - continuation"; "The cost" shrinks -- [ ] downstream designs and other repo-wide chain spellings: - [`../../djs/todo/compile-modules-to-edag.md`](../../djs/todo/compile-modules-to-edag.md) - and [`../../djs/todo/interpret-edag.md`](../../djs/todo/interpret-edag.md) - both prescribe `['.', object, property, null]` and `['|()', args, null]` - for stages not yet implemented, which would produce or expect invalid - EDAG after the migration; respell them, - [`../../../todo/blocked/bun-optional-chain-parentheses.md`](../../../todo/blocked/bun-optional-chain-parentheses.md), - and whatever else a sweep for chain spellings finds — released - `changelog/` entries stay as written, history rather than prescription -- [ ] `changelog/unreleased/.md`, prefixed `**BREAKING CHANGES:**`, with - the matching `Changelog:` section in the PR description ([`AGENTS.md`](../../../AGENTS.md) - §5): previously valid graphs and the exported types stop accepting the - `null`-terminated shape, and every importer is updated in the same PR - -## Related - -- [`../README.md`](../README.md) — Chains; "Terminals state their `null`"; - The cost -- [`option`](../../rtti/module.f.mjs), - [`AbsentOr`/`CheckRaw`/`TupleTs`](../../rtti/ts/types.ts) — the machinery, - proven in [`../../rtti/ts/proof.f.mjs`](../../rtti/ts/proof.f.mjs) -- 7852819 / 930fa65 (#1725) — closed containers by default, then `option` as - omission; this issue is that plan's second half applied to edag -- [`../../rtti/todo/identity-aware-parse.md`](../../rtti/todo/identity-aware-parse.md) - — the identity caveats `validate(exp)` keeps either way; hole rejection - itself is structural under arity-split, pinned in the proofs, and joins - nothing diff --git a/fjs/edag/types.ts b/fjs/edag/types.ts index 679e8072cf..cfc4a17130 100644 --- a/fjs/edag/types.ts +++ b/fjs/edag/types.ts @@ -73,16 +73,22 @@ export type Index = number | NumberCast | string // of hidden control flow it carries: a live receiver (`Property`) and an open // short-circuit region (`Option`). Neither bit live is a node boundary, which // is why the fourth combination is an `Exp` and not a fourth type. +// +// A chain ends by **arity**: every production that can hand the chain on is +// written twice, once carrying its continuation and once one element shorter, +// so ending is the absence of that operand rather than a `null` in it. The +// tuples stay exact, which is what keeps a trailing hole unspellable. /** * The continuation of a `Dot`: a receiver is live, no region is open. * * Only a call can be here — a property step would waste the receiver with no - * region to keep it in, so `a.b.c` nests `Dot`s instead. + * region to keep it in, so `a.b.c` nests `Dot`s instead. `|()` is terminal + * and so has only the shorter arity; `|?.()` opens a region and has both. */ export type PropertyLambda = - | null - | readonly['|()', Exp, null] + | readonly['|()', Exp] + | readonly['|?.()', Exp] | readonly['|?.()', Exp, OptionLambda] /** @@ -90,22 +96,26 @@ export type PropertyLambda = * region: `OptionCall`'s, and every call step that stays in its region. */ export type OptionLambda = - | null + | readonly['|()', Exp] | readonly['|()', Exp, OptionLambda] + | readonly['|.', Index] | readonly['|.', Index, OptionPropertyLambda] /** * The continuation of a property step inside an open region: both bits live, * so this is the state with every production — the three ways a call can * relate to the region it sits in, plus the property step the region keeps - * from leaving. + * from leaving. `|!()` closes the region and is terminal, so it alone has a + * single arity. */ export type OptionPropertyLambda = - | null + | readonly['|()', Exp] | readonly['|()', Exp, OptionLambda] + | readonly['|.', Index] | readonly['|.', Index, OptionPropertyLambda] + | readonly['|?.()', Exp] | readonly['|?.()', Exp, OptionLambda] - | readonly['|!()', Exp, null] + | readonly['|!()', Exp] // call @@ -113,15 +123,21 @@ export type Call = readonly['()', Exp, Exp] // dot -export type Dot = readonly['.', Exp, Index, PropertyLambda] +export type Dot = + | readonly['.', Exp, Index] + | readonly['.', Exp, Index, PropertyLambda] // optionDot -export type OptionDot = readonly['?.', Exp, Index, OptionPropertyLambda] +export type OptionDot = + | readonly['?.', Exp, Index] + | readonly['?.', Exp, Index, OptionPropertyLambda] // optionCall -export type OptionCall = readonly['?.()', Exp, Exp, OptionLambda] +export type OptionCall = + | readonly['?.()', Exp, Exp] + | readonly['?.()', Exp, Exp, OptionLambda] // Comma diff --git a/todo/blocked/bun-optional-chain-parentheses.md b/todo/blocked/bun-optional-chain-parentheses.md index 6258f702ab..60705d0ff4 100644 --- a/todo/blocked/bun-optional-chain-parentheses.md +++ b/todo/blocked/bun-optional-chain-parentheses.md @@ -48,7 +48,7 @@ runner. `chainsJs.throw` in [`fjs/edag/proof.f.mjs`](../../fjs/edag/proof.f.mjs) carries two commented-out cases for it, and the tagged-template one must stay commented rather than merely fail, because a parse error takes the whole file down. Nothing about the specification is in doubt, and the EDAG is unaffected: -`['?.', u, 'b', ['|!()', c, null]]` denotes the throwing reading, and +`['?.', u, 'b', ['|!()', c]]` denotes the throwing reading, and `optionRegion.throw.closeStepOnUndefined` in [`fjs/edag/amnesia/proof.f.mjs`](../../fjs/edag/amnesia/proof.f.mjs) pins it by evaluating the node, which is an oracle that works on every runner. diff --git a/todo/edag-stage1-discussion.md b/todo/edag-stage1-discussion.md index cc2ef7604a..5eeb20c662 100644 --- a/todo/edag-stage1-discussion.md +++ b/todo/edag-stage1-discussion.md @@ -94,14 +94,14 @@ the EDAG's sharing structure and DJS's graph structure are the same thing. ```js // const f = (...a) => { const x = a[0]; return [x, x] } -const x = [".", ["args"], 0, null] +const x = [".", ["args"], 0] export default ["[]", x, x] // the body is one node; x is interior // (...a) => { const check = a[0].length; return a[1] } — with comma (later) const a = ["args"] export default [",", - [".", [".", a, 0, null], "length", null], // assert: value unused - [".", a, 1, null], // the result: last, as in JS (a, b) → b + [".", [".", a, 0], "length"], // assert: value unused + [".", a, 1], // the result: last, as in JS (a, b) → b ] ``` @@ -220,12 +220,12 @@ schema is free to change independently of both. |`["[]", [...node]]`|`[…]`|1|array constructor; the elements are one operand, an array of nodes — not spread across the tuple| |`["{}", [...entry]]`|`{ … }`|1|ordered object constructor; the entries are one operand, an array — initial entry form is `[":", key, value]` (subject 4)| |`["args"]`|—|1|the arguments array (subject 2)| -|`[".", object, property, k]`|`o.p`, `o[p]`, `o.p(...args)`|1|property access, owning whatever its receiver is used for; `k` is `null` for a plain read; `property` is restricted (see below)| +|`[".", object, property]`, `[".", object, property, k]`|`o.p`, `o[p]`, `o.p(...args)`|1|property access, owning whatever its receiver is used for; a plain read leaves `k` out and is the shorter tuple; `property` is restricted (see below)| |`["()", callee, args]`|`f(...args)`|2|call with no receiver; `args` is one node yielding an array (subject 6)| -|`["?.", object, property, k]`|`o?.p`, and the rest of its optional region|later|optional property access; same `property` restriction| -|`["?.()", callee, args, k]`|`f?.(...args)`, and the rest of its optional region|later|optional call| -|`["\|()", args, k]`|one chain step, `(...args)`|2|not an `exp` node — only valid as the continuation `k` of a chain node or another step (subject 6); this is the step a method call's `.` node carries, so Stage 2 needs it| -|`["\|.", property, k]`, `["\|?.()", args, k]`, `["\|!()", args, null]`|one chain step|later|the remaining steps: a property access inside an optional region, a guarded call, and the call a group puts outside the region| +|`["?.", object, property]`, `["?.", object, property, k]`|`o?.p`, and the rest of its optional region|later|optional property access; same `property` restriction| +|`["?.()", callee, args]`, `["?.()", callee, args, k]`|`f?.(...args)`, and the rest of its optional region|later|optional call| +|`["\|()", args]`, `["\|()", args, k]`|one chain step, `(...args)`|2|not an `exp` node — only valid as the continuation `k` of a chain node or another step (subject 6); this is the step a method call's `.` node carries, so Stage 2 needs it| +|`["\|.", property, k?]`, `["\|?.()", args, k?]`, `["\|!()", args]`|one chain step|later|the remaining steps: a property access inside an optional region, a guarded call, and the call a group puts outside the region| |`["own", object, key]`|`Object.getOwnPropertyDescriptor(o, k)?.value`|later|own property by a computed **string**; no prototype chain| |`["Number", node]`|`Number(x)`|later|numeric coercion that accepts bigints, unlike unary `+`| |`["String", node]`|`String(x)`|later|string coercion| @@ -256,7 +256,7 @@ operator symbols below. This is [DESIGN.md §8](../DESIGN.md) again: the host language already spells these, so the EDAG reuses the spelling instead of inventing a vocabulary to be memorized and translated. A chain step is the same spelling behind a `"|"`, which marks it as a step rather than a node — -and the prefix is load-bearing, not decorative: without it `["()", f, null]` +and the prefix is load-bearing, not decorative: without it `["()", f, k]` would read equally as a call node and as a chain step. `"|."` is the `.b` of a chain, `"|?.()"` its `?.(…)`, and `"|!()"` the call a group puts outside an optional region. @@ -362,8 +362,8 @@ All operators are post-stage-1: stage 1 has no operators at all. copied into a frame when the function object is created — the scheme [function-frame](../spec/todo/3111-function-frame.md) chooses — and `["frame"]` is that array. It needs no accessor of its own: a slot is -ordinary indexing, `[".", ["frame"], 0, null]`, exactly as an argument is -`[".", ["args"], 0, null]` (subject 2). +ordinary indexing, `[".", ["frame"], 0]`, exactly as an argument is +`[".", ["args"], 0]` (subject 2). Frame construction mirrors a call: `["=>", frame, body]`, where `frame` is one node evaluating to an array — built in the *enclosing* @@ -376,7 +376,7 @@ one for creating a closure. // inside f, building b — f puts its own ["self"] into b's frame: ["=>", ["[]", ["self"]], /* b's body */ …] // inside b, calling f — slot 0 of b's frame: -["()", [".", ["frame"], 0, null], ["[]", [".", ["args"], 0, null]]] +["()", [".", ["frame"], 0], ["[]", [".", ["args"], 0]]] ``` Consequences: @@ -723,15 +723,15 @@ arguments passed to the function.** `.length` and `toString(f)` fidelity (subject 7). - The rejected `["arg", i]` (single-argument access, no reified array) cannot express rest parameters (`(...xs) => xs`) or forwarding; - `["arg", i]` is expressible as `[".", ["args"], i, null]` while the reverse + `["arg", i]` is expressible as `[".", ["args"], i]` while the reverse is not. Examples — named parameters are positions in the arguments array; the compiler erases names: ```js -const f = (...a) => a[5] // [".", ["args"], 5, null] -const g = (a) => a[5] // [".", [".", ["args"], 0, null], 5, null] +const f = (...a) => a[5] // [".", ["args"], 5] +const g = (a) => a[5] // [".", [".", ["args"], 0], 5] ``` #### 3. Lazy operators and the branch extension path @@ -908,7 +908,7 @@ the FJS compiler would never emit. To validate: unrepresentable ([Operations](#operations)). `"Number"` never returns a string, so it can never rebuild a prohibited name at run time — unlike `"+"`, which concatenates at its binary arity - (`[".", o, ["+", "constr", "uctor"], null]` would reach `Object`) and does + (`[".", o, ["+", "constr", "uctor"]]` would reach `Object`) and does not exist at all at unary arity (above). The prohibited-name list comes from [property-accessor](../spec/todo/2330-property-accessor.md), and @@ -969,7 +969,7 @@ independently (`(a?.b).c` throws where `a?.b.c` is `undefined`). The settled vocabulary keeps every `exp` evaluating to an ordinary value and carries both kinds of control flow in a **continuation** operand: a linked chain of steps that only `"."`, `"?."`, and `"?.()"` interpret. `a.b(...c)` -is then `[".", a, "b", ["|()", c, null]]`, and the vocabulary also spells +is then `[".", a, "b", ["|()", c]]`, and the vocabulary also spells chains no property-plus-call tag could, such as `(a?.b.c)(...d)`. An intermediate revision made that operand a flat *array* of steps held by @@ -991,8 +991,8 @@ an optimization: |EDAG|2330|key| |---|----|---| -|`[".", o, p, null]`|`at`, plus `instance_property` for the implemented names 2330 lists — 2330 routes every other name to `own_property`|string constant (permitted), or a number| -|`[".", o, p, ["\|()", args, null]]`|`instance_method_call` + `at_call`|same| +|`[".", o, p]`|`at`, plus `instance_property` for the implemented names 2330 lists — 2330 routes every other name to `own_property`|string constant (permitted), or a number| +|`[".", o, p, ["\|()", args]]`|`instance_method_call` + `at_call`|same| |`["own", o, k]`|`own_property`|any computed string; own properties only| `"."` merges 2330's static-name and numeric-index commands because the @@ -1247,7 +1247,7 @@ name the function did not compute itself: **Largely answered by `["frame"]`** ([Operations](#operations)): free values are captured into the frame when the closure is created, and read -back as `[".", ["frame"], i, null]`. `["self"]` covers self-reference, which +back as `[".", ["frame"], i]`. `["self"]` covers self-reference, which no frame can seed at the top level. What remains open: - **which values go into a frame, and in what order** — the compiler