Skip to content

🐛 fix: a collection and an indexer answer as .NET's do - #707

Merged
edgarmesquita merged 78 commits into
mainfrom
fix/a-collection-and-an-indexer-answer-as-dotnet-does
Oct 11, 2026
Merged

edgarmesquita merged 78 commits into
mainfrom
fix/a-collection-and-an-indexer-answer-as-dotnet-does

Conversation

@edgarmesquita

@edgarmesquita edgarmesquita commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Closes #564
Closes #578
Closes #585
Closes #586
Closes #593

All five are sub-issues of #565 (the transpiler's fences hold on every path, continued).

What

A list built with an argument and an initializer (#564):

  • A List<T> construction is one array, as C# builds it: what its constructor copies, spread, then each element of its collection initializer in order. A capacity puts nothing in it, but it is evaluated before the elements and a negative one is refused as List<T>'s constructor refuses it. An object initializer is applied once the list is built.
  • The explicit and the target-typed forms share it, so List<int> c = new(source) is a copy, where it was an empty list.

A LINQ operator's comparer (#578):

  • ToDictionary, ToLookup, GroupBy and Distinct hand their comparer to the fence a collection's construction already passes (EQ2007). One that asks for the key type's own equality, ordinal strings or null is dropped, since the lowering finds the keys that way already, and any other fails the build.
  • A key comparer is found by its position among the arguments the bound tree names, so a call a null-conditional rebuilt drops its own too. GroupBy's arguments go to the parameters the bound tree hands them to, so they can be named in any order.

A range over a type with a Slice (#585):

  • A range over a type eqc writes calls the member the bound tree names, its Slice(start, length). The receiver is read once, then the endpoints in their order, then the Length or Count, read only where an endpoint counts from the end or the end is open, as measured on .NET.
  • A range handed to an indexer that takes the Range itself fails the build (EQ2004). A range over a string, an array or a list keeps JavaScript's slice.
  • RangeIndexerStrategy moves to the IR and leaves the migration baseline.

A list's face (#586):

  • An access through IList<T>, IReadOnlyList<T> or IList is a place of its own kind, read and written by the runtime's $eq.collections.item and setItem, which answer an array's subscript and a twin's item and setItem alike. Every writer takes it: an assignment, a compound, a step, ??=, ?[], an object initializer's entry and a key from the end.
  • A Count through the face counts through $eq.collections.count, which asks a twin's own count before a size or a length it may also declare. A read or a call through a face that holds null throws .NET's NullReferenceException, and a null-conditional one answers null.
  • A receiver typed as an array or a list keeps its subscript. The runtime's twins are regenerated where they read a list through its face.

ICollection's Add and Clear (#593):

  • ICollection<T>'s own Add and Clear go through the runtime's $eq.collections.add and clear, which add and empty as the collection behind the interface does: an array appends, a set adds a value it lacks, a linked list adds last, a dictionary adds the pair and refuses a key already there in its own words, and a twin runs its own. A member's collection initializer adds the same way.

For an app: new List<int>(source) { 3 } holds the source's elements and then 3, jobs.ToDictionary(j => j.Id, StringComparer.Ordinal) builds, strip[1..3] calls Slice(1, 2), r[0] + r[2] + r.Count over a twin behind IReadOnlyList<int> answers 19, and c.Add(2) over a HashSet<int> behind ICollection<int> adds.

Why

Measured on main, both sides executed by the conformance suite:

OpenSpec

openspec/changes/archive/2026-10-08-a-collection-and-an-indexer-answer-as-dotnet-does, archived into openspec/specs/transpiler-bcl, transpiler-sequences and transpiler-expressions.

Proof

  • A/B, the branch's tests over the src/ this merges onto (built with 0 errors) and over this branch. That tree is fix/a-nested-class-is-its-owners at edf747a, main with 🐛 fix: a copy of a generic record or struct keeps its closed type #752, 🐛 fix: a null argument is refused as .NET refuses it, measured over the translated surface #715 and 🐛 fix: a nested class is a module of its own, named by its owner #654, and "main" below means it:
    • CollectionAndIndexerConformanceTests: 97 of its 115 cases fail on main, and all 115 pass here.
    • Of the 18 that pass on main, nine guard what already held: a slice of a string, an array and a list, an array or a list behind each face, a list behind ICollection<T> and IList<T>, a target-typed list with a capacity, and the null-conditional and pattern reads of a null face. Seven are LINQ comparers that ask for the default: main dropped two of them harmlessly, fenced ToHashSet's already, and refused the build of the four GroupBy rows with EQ2008, which the harness does not read. The last two are a write through IList<T> that answered its value on main by accident, from a property named 0.
    • Compiler tests: 14 of the 16 new cases in CollectionComparerFenceTests and RangeIndexerTests fail on main. The two that pass are builds main allowed, ToLookup with EqualityComparer<int>.Default and Distinct with a null comparer, whose answers the conformance cases judge.
  • Found by reading the diff, and fixed here: a twin with a Length beside its Count was counted by its length through the face. Its case fails on the commit before the fix, in both forms, and passes with it. The range refusal's test now reads the strategy's own message, since EQ2004 is every untranslated member's.
  • The review before this pull request opened (/code-review high) found two more, fixed here. A complex element initializer, new List<int> { { 1 }, 2 }, wrote [{}, 2], on main as on this branch before the fix: it hands its one expression to Add now, and its case is in the conformance theory. And an indexer declared over Range? took the slice path, where one over Range is refused: a row of RangeIndexerTests refuses it now. Both fail on main.
  • Copilot's first round found two defects, fixed here. A Count read through a null face answered 0, because the runtime's count counted a null as none: it throws .NET's NullReferenceException now, and so do the face's element reads and its Add and Clear, while r?.Count still answers null. And GroupBy read its arguments by position, so GroupBy(comparer: c, keySelector: k) refused its key selector: each argument plays its own parameter now. Against the head before that round, 12 of the 14 new conformance cases and 2 of the 3 new Compiler cases fail, and all of them pass now. The two conformance cases that pass there are the null-conditional rows, and the Compiler one is a refusal that landed on the key selector, which ANamedComparerWrittenFirst_IsRefusedWhereItIsWritten pins to the comparer. A T[] behind the faces grows where .NET refuses, as it did before this pull request, and A T[] behind ICollection<T> or IList<T> grows and shrinks where .NET refuses the call #711 holds it.
  • Copilot's second round found one more in its review's body, fixed here. A list's capacity was dropped unread, so new List<int>(Capacity()) { Item() } never called Capacity, and a negative one built a list where .NET throws. It is evaluated first now, through $eq.collections.listCapacity, which refuses a negative one in .NET's words, and a constant the list takes still writes the bare array. Five of its six new conformance cases fail on the head before the fix, and the sixth, a capacity read from a variable, fails on main. The capacity pin in CollectionConstructionTests follows it.
  • One gap of 🐛 fix: a null argument is refused as .NET refuses it, measured over the translated surface #715's null-argument baseline closes with this branch, 402 entries to 401: ToLookup(source, keySelector, comparer) handed a null comparer built its lookup in .NET and called the comparer as an element selector in the browser. No entry is new.
  • The first full Compiler run found three pins still holding what this replaces: ToDictionary with a null comparer refused (it is now compared with the call written without it), IList<T>.Add as the array's push (it now reaches $eq.collections.add, which pushes onto an array), and GroupBy's site in the diagnostics baseline under EQ2008.
  • Regenerated: the twins, which the last merge left as they were, and the served runtime's budget. Against what the head ahead of this one serves, measured the same way, the runtime grows 686 gzipped bytes (218,555 to 219,241): the twins' reads through a list's face and the runtime's helpers behind it. The BCL audit's baseline did not move.
  • Suites, each alone, on this branch's head: Compiler 1,871 passed, Web 1,021 passed (on this branch's wiki), Server 372 passed, Conformance 4,856 passed and 1 skipped (the deep sweep, which is opt-in), and the runtime's TestRuntime 2,323 tests in 170 files. Every exit code 0, no run aborted.
  • dotnet build samples/DefaultUIDashboard: 0 warnings, 0 errors.

Checklist

  • dotnet test on the affected test projects, and dotnet build src/eQuantic.UI.Runtime -t:TestRuntime if TypeScript changed (the embedded Bun runs tsc, then vitest run)
  • This PR's checks include a run named CI with jobs in it (build-packages, test (ubuntu-latest), test (windows-latest), openspec, session-start, wiki-checkout). A workflow whose expression does not parse creates zero jobs, and the ruleset's required checks then keep the PR blocked. GitHub lists the workflow by its file path instead of CI. While GitHub Actions has no credits, the same run is on eQuantic Space (eqs runs ls)
  • ./scripts/check-openspec.sh passes: the OpenSpec change validates strictly, and it is archived before the merge
  • dotnet build samples/DefaultUIDashboard (and PhotonDesktop / WalletMobile if the native track changed). The native track did not change
  • A broken contract has a line in the migration notes (we are in preview: break freely, hide nothing). Two builds that passed now fail, both where the browser answered differently from .NET: a comparer that changes equality handed to Distinct or ToLookup (EQ2007, where it was dropped or called as a selector), and a range handed to an indexer over Range (EQ2004). In eQuantic.UI.Compiler's public surface, RangeIndexerStrategy.Convert gives way to ConvertIr, and Eq gains four constants.
  • The documentation changed with the behaviour: this repository's Markdown here, and the wiki page in English AND Portuguese, in one commit on a branch of the wiki repository named exactly like this pull request's branch, merged into the wiki's master when this merges (the twin lives at locale/pt-BR/<Page>-pt-BR.md there)
  • One docs/LEDGER.md line for this event, citing the issue
  • The diff reviewed by its author before the PR opened (in Claude Code, /code-review high), every Copilot thread answered and resolved, a new round asked for (gh pr edit <n> --add-reviewer @copilot) only after a defect, and three rounds at most
  • No "widget" in prose, the project's word is component

…wner

A nested class had no module, resolved to a top-level class of its name, and a nested record or struct wrote a module a top-level one of its name writes too (#584). Each nested class, record and struct gets a twin named by its owner and itself, joined by $ (Cart$Item), which every reference names.
A class declared inside another one had no module, so constructing it named nothing, and where a
top-level class had its name it resolved to that one (#584); a nested record wrote the module a
top-level record of its name writes, which the build refused (EQ2009); a nested static class outside
a component had no module, and one inside a component was its module's alone.

Every class, static class, record and struct declared inside another type is now a module named by
the types that contain it and its own name joined by `$` (`Cart$Item`, `A$B$C`), a name no C# type
can take: TwinTypeName, from a symbol or a declaration, is what the parser, the dependency resolver,
the emitters and every strategy that writes a type (a construction, a type test, a static member, an
operator, a zero, an annotation, a hydration map) read, and a reference imports the twin as it
imports any type's. One inside a server-only class, an exception or an attribute has none, as its
owner has none. A record's text keeps its C# name. The runtime's one nested type regenerates as
`CodeBlock$CodeMetrics`, its annotations typed, and the component identity test reads a `$` in a
twin's name rather than taking the owner's.

The 18 conformance cases through the module graph pass in both annotation modes, where on the class
branch all of them failed (EQ2009 for the record pair) or one of the component's two threw. Compiler
1615, Server 220, Web 941, Conformance 3739 and the runtime's 2050 are green, and the dashboard
sample's markdown, code and diff screens render their code blocks with no console error.
…ports the name it calls

The review of this branch found the server on the other side of the rule.

- The server named the page it serves by the CLR simple name, in the route table and in the shell's
  configuration, while eqc names a nested component's module by its owner (`Admin$Users`), so a page
  declared inside a class asked for a module nothing writes. One rule, `TwinName.OfType`, in the file
  the compiler and the server already share, names both, and the server's tests read their own nested
  pages by it.
- A static method reached bare and a conversion operator registered a runtime type's import by its
  simple name and called it by its twin, so a nested runtime type was imported under one name and
  called by another. A reference writes one name now, `TwinReference`: a nested type's twin where eqc
  writes it, and the simple name a top-level type and a hand-written runtime twin keep.
- The identity check over the shared library's twins keys them by that name, a nested component
  included, where it skipped every name with a `$`.
- The `[ServerOnly]` check has one home, and `SymbolOf` has its doc comment back.
Main brought #618 (the code engine's completion), #628 (a target's 24dp floor) and #607 (a char's
search). Two files conflicted where main moved what an annotation crosses as into TsStandIn: a
member's type (TypeDeclarationExtensions) and a local's (LocalDeclarationStrategy). Each now asks
the stand-in first, then names a nested type by its twin, as this branch did, with main's OrNull for
a nullable one. The ledger line moves after the entries main brought.
A list's construction is one array, as C# builds it: what the
constructor copies, spread, then each element of the initializer in
order. A capacity with an initializer dropped the elements, a source
with one was joined to them by a comma (`source, [3]`), and the
target-typed form kept the elements and dropped the source, or built an
empty list from a source with no initializer at all (#564).
ToDictionary, ToLookup, GroupBy and Distinct hand their comparer to the
fence every collection's construction passes (EQ2007): one that asks
for the key type's own equality, ordinal strings or null is dropped,
since the lowering already finds the keys that way, and any other is
refused. ToDictionary refused every comparer, StringComparer.Ordinal
included (EQ1004), GroupBy refused every one too (EQ2008), ToLookup
called a comparer as an element selector or had no form after one, and
Distinct dropped whatever it was handed (#578).
A call bound to ICollection<T>'s own Add or Clear goes through the
runtime, which asks the value what it is, as Contains, Remove and
CopyTo already did: an array appends or empties in place, a set adds a
value it lacks, a linked list adds last, a dictionary adds the pair and
refuses a key already there in its own words, and a twin calls its own
add and clear. They were an array's push and splice, which a HashSet, a
LinkedList, a SortedSet and a dictionary's pairs lack, and the same
push was what a member's collection initializer added through (#593).

The runtime's count reads a twin's own count before walking it, so a
twin of the app's own behind the interface counts itself.
An access through the indexer of IList<T>, IReadOnlyList<T> or IList
is a Place of its own kind, read and written by the runtime's
$eq.collections.item and setItem, which answer an array's subscript and
a twin's item and setItem alike, whichever the face holds when it runs.
Every writer takes it: the assignment, the compound, the step, the
coalescing write, the null-conditional read, an object initializer's
entry and a key from the end, which counts through $eq.collections.count.
A Count read through the face counts through the runtime too, and the
runtime's count reads a twin's own count. A receiver typed as an array
or a list keeps its subscript.

A twin read through the face read a JavaScript subscript and a length,
which a twin does not answer: `r[0] + r[2] + r.Count` over a twin was
null where .NET says 19 (#586).
A range used as an index reads the member the bound tree names. Over a
type eqc writes, with a Length or a Count and a Slice(start, length),
it calls that Slice as C# lowers the range: the receiver once, then the
endpoints in the order they are written, then the count, read only
where an endpoint counts from the end or the end is open, as measured
on .NET, then Slice(start, end - start). It called JavaScript's
slice(start, end) on the twin, whose slice is that Slice, so
`new Strip()[1..3]` held three elements where .NET holds two, and
`[3..1]` sliced where Slice(3, -2) throws (#585).

A range handed to an indexer that takes the Range itself is refused
with EQ2004, since a System.Range value has no translation. A range
over a string, an array or a list keeps JavaScript's slice. The
strategy moves to the IR, and leaves the migration baseline.
The fence's own tests hold the operators that now pass through it
(#578): ToDictionary, ToLookup, GroupBy and Distinct refuse a comparer
that changes equality with EQ2007 and nothing else, and build with no
error at all from one that asks for the default.
The OpenSpec change for #564, #578, #585, #586 and #593, with its
deltas to transpiler-bcl, transpiler-sequences and
transpiler-expressions, and the DIAGNOSTICS rows for the comparers a
LINQ operator hands EQ2007 and the range an indexer over Range is
refused with under EQ2004.
…tion

The comparer ToDictionary and ToLookup drop was matched to the written
argument by its node, and a null-conditional rebuilds the call over its
receiver with copies of its arguments, so `words?.ToDictionary(k,
EqualityComparer<int>.Default)` passed the comparer as an element
selector, which named a class nothing defines. It is matched by its
position among the arguments the bound tree names, as the indexer's
place finds its keys (#578).
…nswers

The shared components, charts and code engine the runtime transpiles
regenerate (EQ_UPDATE_TRANSPILED=1): an element or a count read through
IReadOnlyList<T> or IList<T> goes through $eq.collections.item and
count, so a twin of the app's own handed to one of them is read through
its indexer and its count, where a subscript and a length read
undefined (#586). An array behind the face reads as it did.
… component is its twin

Copilot's first round on #654. Every site that writes a twin into the output names it through
IntroduceTwin, which registers the import with the name: an operator, a unary operator, a compound
assignment and a tuple equality on a nested type no expression names, a conversion, the zero of a
nested struct an alias names, a type test, and every construction, the target-typed `new()` with no
initializer included. A component is known by its twin's name in the parser and the resolver, so a
top-level component and a nested plain class of its simple name are never taken for each other, and a
nested component crosses where its owner does. The archived delta carries the server half the main
spec already said. New cases: a nested component built and tested as Host$Page, a component beside a
nested class of its name, and operators, a conversion and an alias's zero reached by inference.
Main brought #609, #610, #647 and #617. HydrationSpec met #647's newer shape: main's kept, an in-source
value named by its twin in its three places, the resolution the trial merge over #661 recorded. The
ledger line moves after the entries main brought, and the runtime's twins hold on the merge.
…otation names every nested twin

Client code that builds a nested type of a [ServerOnly] owner, an exception or an attribute, tests for
it or reads its statics is refused with EQ2010, where the page met a twin nothing wrote at load. The
server publishes no route and maps no page for a page declared in such an owner, by the rule the
compiler writes modules by (TwinName.OwnersCross).

A nested type inside an array or a generic is annotated by its twin in a class and in a record, each
name where its type stands, so two owners' Inner name two twins. The record path compares against the
normalised spelling, as the class emitter does, so a qualified nested member reaches its twin too.
The runtime's item and setItem took the list as unknown, so in the
twins TypeScript checks every element read through IReadOnlyList<T> or
IList<T> was unknown and tsc refused them (#586). They take an
array-like or a twin of the element type, which the twin's own
annotation names, and fall back to any where the receiver is untyped,
as mapGet's value does.
Brings main to 8475371 (#656). The twin in conflict, CodeDiffLayout, is
taken from this branch and regenerated after the next merge.
Brings main to 00714cc (#667). The twins in conflict, AppBar,
BottomNavigation and NavigationRail, are taken from this branch and
regenerated next.
…e reaches it, and one the runtime provides is the runtime's

Copilot's third review of #654 found client code that reached a nested type of a [ServerOnly] owner, an
exception or an attribute without meeting EQ2010, and each path wrote a twin nothing declares or a zero
that was undefined: an alias, a static member or a call through using static, an operator or an
implicit conversion its value brings, default(), an array's fill and a field's or a record member's
zero. Each now reports EQ2010 where the code names the type. The node a fence reports at spans the
settling, where an implicit conversion is written, and an emitter passes the declaration whose zero it
asks for.

Under the fences sits a net: the import every module makes. A twin the build writes no module for, by
the resolver's own scan, is reported there rather than dropped, unless a fence already said it, so a
path no fence sees, a field initialized to null, is refused too.

A nested type of an owner the runtime provides is the runtime's, as the parser reads it, and the
runtime carries it under its owner's twin name: it is imported from @equantic/runtime, where it named a
sibling module nothing writes. The attribute is read by its name as written too, as [ServerOnly] is.

MapPage refuses a page declared inside an owner that never crosses, at startup, as it refuses a type
that is not a component. The same diagnostic at the same place is reported once.
… own words

The test read only the code, and EQ2004 is every untranslated member's,
so any other refusal that reached the same access passed it. It reads
the message RangeExpressionStrategy writes for a range handed to an
indexer over System.Range (#585).
The four twins the merges left in conflict (AppBar, BottomNavigation,
CodeDiffLayout, NavigationRail), written again with EQ_UPDATE_TRANSPILED=1:
main's parameter names and null-conditional answer beside this branch's
reads through a list's face, and nothing else moved.
The runtime's count read a size or a length before a count, so a twin
of the app's own with a Length beside its Count, a polyline's, counted
by its length through IReadOnlyList<T>: `p.Count` was 12.5 where .NET
says 2, and `p[^1]` and a loop to the count read past the end (#586).
A count is asked first, after an array and a string, which the
runtime's own collections never hold beside a size or a length.
…ki pages

The list face's requirement gains the scenario of a twin with a Length
and a Size beside its Count (#586), and the documentation task names the
wiki pages that change with it, in English and Portuguese.
…'s face

The record moves from 201,883 to 203,748 gzipped bytes. 629 of them are
this branch's, measured against main's own bundle (203,119): the twins
of the shared components, the charts and the code engine read 99
elements and 106 more counts through a list's face, by
$eq.collections.item and count, where a subscript and a length read
undefined over a twin of the app's own (#586), and the runtime gains
add, clear, item and setItem (#586, #593). The other 1,236 are main's,
which grew inside the 1% the record allows without writing it.
Brings main to 00714cc (#667). The ledger line moves to the end, oldest first.
One line for #564, #578, #585, #586 and #593, at the end of the
events, with the A/B counts: 78 of the 94 new conformance cases and 10
of the 12 new Compiler cases fail on main, and all of them pass here.
…'s specs

openspec archive moves the change under openspec/changes/archive/ and
writes its requirements into transpiler-bcl (a list built with an
argument and an initializer, a list's face, ICollection's Add and
Clear), transpiler-sequences (a LINQ operator's comparer) and
transpiler-expressions (a range over a type with a Slice).
…ollection's Add

Three pins still held what this branch replaced. ToDictionary with a
null comparer was refused (EQ1004), where it is now the call written
without it (#578), and the test compares the two conversions. An Add on
IList<T>, which C# binds to ICollection<T>'s, was the array's push, where
it now reaches the runtime's add, which pushes onto an array and adds to
whatever else stands behind the face (#593). And GroupBy no longer
reports EQ2008, so the diagnostics baseline drops its site.
…er the third round and main's merge

EQ2010 is reported by the record emitter too, the module's import net under the fences, with the same
words the fences use. The served runtime grows 1,231 gzipped bytes over main's (201,883 to 203,114),
the twins' nested names and their text included.
The cherry-pick of #751's fix leaves this branch: #752 carries it, with what its review added, and this branch merges that head instead.
…arrow

The arrow the last fix wrote around the comparison is a function the
lowering writes by hand, which the introduced-functions list refuses.
The comparison says it instead: every CompareTo(object) reads its
receiver twice, so the writer binds a receiver with an effect and runs
it before the null is read. A number takes the ordered three-way there,
and a char answers a null as the code unit before its own, which keeps
its subtraction and makes it 1.

A double's and a long's receiver join the cases.
218,583 gzipped bytes, 183 more than the 218,400 the head of #752 serves.
…-is-its-owners

The head of #715 (6e00c81), which holds the head of #752 and main d7f61c0 (#741).
…-as-dotnet-does

Brings fix/a-nested-class-is-its-owners at edf747a, which holds #654,
#715, #752 and main at d7f61c0 (#741), the pull requests this one
merges after. No source file conflicted: #715's sequences named by their
parameter and #654's twin names sit beside this branch's list, comparer,
range and face lowerings as they are. The served runtime's record is
the base's, regenerated next with the twins, and the ledger line moves
to the end of the events.
One gap of #715's baseline closes with this branch, 402 entries to 401:
ToLookup(source, keySelector, comparer) handed a null comparer built its
lookup in .NET and called the comparer as an element selector in the
browser. The comparer is dropped by the collection fence now (#578), so
the probe agrees and its entry leaves. No entry is new.
The record moves from 218,555 gzipped bytes, what the queue's head
serves, to 219,241: this branch adds 686. They are the twins' reads
through a list's face, the runtime's helpers behind it and the
capacity's check (#564, #586, #593).
…t gap that closes

Against fix/a-nested-class-is-its-owners at edf747a, what main is when
this merges, 97 of the 115 new conformance cases and 14 of the 16 new
Compiler cases fail, the served runtime grows 686 gzipped bytes, and
ToLookup's null comparer leaves the null-argument baseline.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

A implementação e sua cobertura são consistentes; os comentários restantes tratam apenas de precisão documental e de mensagem de erro.

2 open findings

🧠 Review effort: Balanced

Comment thread src/eQuantic.UI.Server/UIExtensions.cs Outdated
Comment thread src/eQuantic.UI.Runtime/src/eq.spec.ts Outdated
Main is at #752's squash (7636da4), whose tree is the tree of the head this branch already merged (9e5a828): recorded with the ours strategy, nothing changes.
Main is at #752's squash (7636da4), whose tree is the tree of the head this branch already merged (9e5a828): recorded with the ours strategy, nothing changes.
…time-provided owner

Found by Copilot's review of #707, in this pull request's code. MapPage
refuses a page whose owner TwinName.OwnersCross refuses, and its message
listed [ServerOnly], an exception and an attribute as types that never
reach the browser. A [RuntimeProvided] owner is refused too, and it does
reach the browser, inside the runtime. The message names the four and
says what they share: the build writes no module for them.
…-as-dotnet-does

d9c3e37, which names a runtime-provided owner in MapPage's refusal (found by Copilot's review of this pull request, in #654's code).
Found by Copilot's review of #707. The comment said an unmarked copy is not taken for another type, above an assertion that it compares as the marked one: an unmarked value compares as any closed type, and only two marked differently differ.
edgarmesquita added a commit that referenced this pull request Oct 11, 2026
…he translated surface (#715)

Closes #569

## What

One measurement instead of one fix per report.
`NullArgumentConformanceTests` calls every member of the BCL surface eqc
translates with a null for each of its reference parameters, on .NET and
in the browser, and compares what the call did: whether it threw and the
exception's type, its `ParamName`, its message where the SDK composes
it, and the value a call that returned answered, a string, a bool, a
char, a number or an array of those.

- The surface is derived, never listed. Its types are the ones the BCL
audit's committed record names (`bcl-surface.baseline.txt`), its members
are what reflection finds on them, constructors, indexers and generic
methods included, and eqc's own diagnostics say which of them it
translates. A guard fails where a member the audit grades `native` or
`eq` takes a reference parameter and no null reaches it on both sides.
- Each probe runs a control, the same call with the null replaced by a
canonical value. The canonical text holds white space around and inside
it, which is what a null separator or a null set of trimmed characters
stands for in .NET. Where .NET answers the control and the browser
answers it otherwise, the member differs whatever the argument, and the
gap is recorded as `control` rather than charged to the null. A probe
whose other arguments .NET refuses on their own measures those
arguments, and is counted rather than compared.
- The gaps left are `null-argument-gaps.baseline.txt`, which may only
shrink: a new gap fails, an entry whose aspect moves fails, and an entry
that starts to agree fails until it is removed. Each entry carries its
aspect and a reason, and the file is regenerated with
`EQ_UPDATE_NULL_ARGUMENT_BASELINE=1`.
- The twins that read a null through null refuse it by name now, as .NET
does: LINQ's `Max`, `Min` and `ToDictionary`, a sequence a LINQ operator
reads through `seq`, named by the parameter it binds to (`second`,
`inner`, a `Zip`'s `first`) where every one was `source`,
`CompareTo(object)`, which answers 1 for a null, `new Guid(text)`, which
names its text `g`, `char.GetUnicodeCategory(text, i)`, and the
cancellation pair's `Register` and `CreateLinkedTokenSource`.

For someone using the SDK: a null handed to one of those members is
caught by `catch (ArgumentNullException e)`, and `e.ParamName` names
.NET's parameter. Nothing an app writes changes. The public surface
gains `Eq.GuidOf`, the developer surface does not move, and the
transpiled twins that concatenate sequences are regenerated: their `seq`
calls name `first` and `second`.

## Why

A .NET method that cannot take a null refuses it with an
`ArgumentNullException` naming the parameter. Several twins read the
argument first, so the browser threw a `TypeError`, which the runtime
reads as a `NullReferenceException`, and since #561 a typed catch lets
that through. #561 fixed the seven its review named, by hand, and the
rest of the surface had never been looked at.

What the measurement leaves, 402 probes, each with its reason:

- 213 are members lowered to JavaScript's own method: LINQ over a list
(168), an array's or a list's method, a string's search, a spread. They
read a null through null, and refusing it by name means a guard at every
call. That is a design decision, so I left it out of this pull request.
- 84 are provider overloads that eqc writes with the name `CultureInfo`,
which the runtime does not export, so the call is a ReferenceError in
the browser and nothing fences it at build time. eqc reads a culture
only where it formats or parses a number, and a culture held in a local
fails the same way.
- 26 are `Convert` of an object or of a string (#401), 21 are members
the runtime does not have (most of the sorted set's set operations,
`CopyTo` on the queue, the stack and the sorted set, and `TryParse` of a
`DateTime`, a `DateTimeOffset`, a `TimeOnly` and a `TimeSpan`), 6 are
collection constructors whose twin reads a null as no collection, and 3
drop a culture or a calendar argument.
- The other 49 are lowerings that are wrong whatever the argument, found
on the way: `Convert.ToBase64String` and `ToHexString` answer `String()`
of the array, `FirstOrDefault(defaultValue)` lowers the default as a
predicate, the `Zip` of three sequences lowers as the one with a
selector, `SequenceEqual` with a comparer answers its first sequence,
`ToLookup` with a comparer reads it as an element selector, `Average`
over longs adds a BigInt to a number, `LastIndexOf(value, start, count)`
ignores its count and `LastIndexOf(value, start)` reads its start as
JavaScript does (#724), six `String.Split` overloads hand their
separators to JavaScript's `split` whole (#753), `new decimal(bits)` and
`new Guid(bytes)` name classes the runtime does not export.

The list was 443 when this pull request opened. Main moved under it
since: #748 gave the builder its members, which took the 22 probes of
#679 out and left two whole-array `AppendFormat` ones among the
lowerings, and #660 refuses a constructor that takes a calendar at build
time, 8 probes.

Some cheap fixes stay in the list on purpose, because their files are in
open pull requests: the collection constructors and the `Contains` and
`Zip` twins (#707), `string.Format` and `string.Concat` with a whole
array (#637), and the `ToHashSet` and `ToDictionary` shapes (#707).

## OpenSpec

`a-null-argument-is-refused-as-dotnet-refuses-it`, archived, adds a
requirement to `transpiler-exceptions`: a null argument is answered as
.NET answers it, measured over the translated surface.

## Proof

The surface is 30 types and 823 members, 1205 probes, each a member and
the parameter handed null: 874 are compared on their null, 32 are
counted because .NET refuses their other arguments, 293 are members eqc
refuses at build time, 6 bind another overload as written and are left
out, and 250 need an argument no canonical value can write. Every one of
the 256 lines the audit grades `native` or `eq` that take a reference
parameter is reached.

A/B against main (7636da4), with main's version of every source file
this changes, built with 0 errors: `NullArgumentConformanceTests` fails
17 of its 26 tests there. The theory fails with exactly the 67 probes
the fixes close, and 13 of the 14 cases one canonical value cannot reach
fail too (a negative or false receiver of `CompareTo(object)`, a set
handed to `Concat`, a sequence as a `Zip`'s receiver, a null selector
caught by a typed catch). And 3 of the 7 cases of a receiver with an
effect fail there for the answer itself, a double's, a char's and a
bool's.

The 14th is the control that a value still compares, and the four checks
of the instrument pass on both sides. On this branch, 26 of 26 pass. The
fixes took the baseline from 510 entries to 443, and it holds 402 now,
for what main and the wider measurement moved since.

Copilot's first round found two. A receiver of `CompareTo(object)` that
its comparison reads once never ran when the argument was a null
literal, the null test answering before it reached the receiver. Every
`CompareTo(object)` reads its receiver twice now, which makes the writer
bind a receiver with an effect and run it first. With the strategy as it
was before that round, 3 of the 7 cases of
`TheReceiverOfCompareToObject_RunsBeforeItsNullIsRead` fail, an int's, a
double's and a char's receiver, and here the 7 pass. And the measurement
compared no array and its canonical text held no white space, so a split
by null passed whatever the browser made of it. It compares an array of
strings, chars, bools or numbers now, over a text that holds white
space, and that is what found the six `String.Split` probes (#753) and
moved `LastIndexOf(value, start)` to `control` (#724).

Each suite alone, read by its exit code and its total, on this branch's
head, a tree the merge of main 7636da4 left as it was: Compiler 1841,
Web 1021 against this branch's wiki page, Server 367, the runtime's 2316
in 170 files (`tsc` and vitest through the embedded bun), and
Conformance 4731 with 1 skipped. No run aborted.

The served runtime is 218,583 gzipped bytes, 183 more than the 218,400
main serves, measured the same way.

## Checklist

- [x] `dotnet test` on the affected test projects, and `dotnet build
src/eQuantic.UI.Runtime -t:TestRuntime`
- [ ] This PR's checks include a run named CI with jobs in it
- [x] `./scripts/check-openspec.sh` passes: the change validates
strictly and is archived
- [x] `dotnet build samples/DefaultUIDashboard`
- [x] A broken contract has a line in the migration notes: none is
broken
- [x] The wiki's SupportedFeatures page in English and Portuguese, on
the wiki branch `fix/a-null-argument-is-refused-as-dotnet-refuses-it`
- [x] One `docs/LEDGER.md` line for this event, citing #569
- [x] The diff reviewed by its author before the PR opened, and
Copilot's first round answered: two findings, both fixed
- [x] No "widget" in prose

---------

Co-authored-by: Edgar Mesquita <edgar@equantic.tech>
Main is at #715's squash (7d3018c), whose tree is the tree of the head this branch already merged (6e00c81): recorded with the ours strategy, nothing changes.
edgarmesquita pushed a commit that referenced this pull request Oct 11, 2026
…as-dotnet-does

The head of the queue, f03bcb6, holds #707, #654, #715, #752 and main at d7f61c0. Six files
conflicted, and each keeps both sides.

- IdentifierStrategy names a static's home by its twin (#654) and a method by the name it holds
  on its twin (#563).
- InvocationStrategy calls a static on its home's twin (#654) by the name the method holds there
  (#563).
- ObjectCreationStrategy builds a type by its twin's name (#654), with the types a generic
  exception class is built with (#611), inside the closing #689 puts around a generic record's.
- The transpiler-classes, transpiler-exceptions and transpiler-sequences specs keep the
  requirements of both sides, the queue's first.
- PlainClassModule gets back the using of ExceptionTypes the merge dropped, which #654's
  OwnerKeptOut reads.
- The ledger line moves back to the end of the events.

#654 keeps a type nested in an exception out, as an exception had no module before #611. The
next commit settles what the two say together.
edgarmesquita pushed a commit that referenced this pull request Oct 11, 2026
#715 landed on main as 7d3018c, a squash of a head this branch already holds through the
queue's. Recorded with ours, so the tree does not move: a real merge changes three files
only, and each is already at the queue's later state here. The null-argument baseline is
without ToLookup's null comparer, which #707 closes, the served runtime's record is this
branch's, and the exceptions spec holds main's requirement and this change's.
Main is at #654's squash (d7a8c31), whose tree is the tree of the head this branch already merged (d9c3e37): recorded with the ours strategy, nothing changes.
@edgarmesquita
edgarmesquita merged commit 178497c into main Oct 11, 2026
22 checks passed
edgarmesquita pushed a commit that referenced this pull request Oct 11, 2026
Main is at #707's squash (178497c), whose tree is the tree of the head this branch already merged (5e5b5ca): recorded with the ours strategy, nothing changes.
edgarmesquita added a commit that referenced this pull request Oct 11, 2026
… does (#708)

Closes #563
Closes #611
Closes #612

#563 is a sub-issue of #164 (the transpiler's fences hold on every
path), and #611 and #612 are sub-issues of #565 (the same, continued).

## What

A method that hides an inherited one (#563):

- A method declared with `new`, or with the same signature and no
`override`, holds a name of its own on its twin, `name$1`, and `name$2`
for one that hides that one. Every call, method group, base call,
null-conditional call, initializer's `Add` and `Deconstruct` the model
binds to it reaches it by that name, so a call through the base type
reaches the base's method, as .NET's does. An override of a `new
virtual` method fills that method's slot.
- A member the runtime reads by its .NET name is answered by .NET's:
`((object)c).GetHashCode()` over a `public new int GetHashCode()` is
object's.
- EQ1007 compares the names each method holds on the twin, so a method
that hides one is no longer refused. One that hides one and answers an
interface is, since a call through the interface reaches the hidden
member's name.
- A method beside an explicit interface implementation of its name holds
a name of its own too, so a call through the class reaches it and one
through the interface the explicit one. They were written under one
name, the later replacing the other. One that also answers another
interface is refused (EQ1007).

A class that implements `IEnumerable<T>` (#612):

- Each consumer walks the `GetEnumerator()` that C# binds for it,
through `$eq.linq.iterate`: a `foreach` over the class its own public
one (a struct enumerator's included), and LINQ, a spread, `string.Join`
and a `foreach` over the interface the `IEnumerable<T>` one, explicit or
not, which the twin's `[Symbol.iterator]` calls. So the two walk
different sequences where C# lets them. An enumerator the app wrote is
walked by its `MoveNext` and `Current` and disposed when the walk ends,
a class that is its own enumerator included, and a class with only a
public `GetEnumerator()` is walked by `foreach` too.
- The non-generic `GetEnumerator()` beside the generic one, and an
explicit `Current` beside a public one, are not written. Each replaced
that member and called itself.
- A twin has one iteration and one `Current`, so what it cannot answer
as C# does fails the build (EQ1007): a type that implements
`IEnumerable<T>`, or `IEnumerator<T>`, over two types, and a non-generic
`GetEnumerator()` or a `Current` that does not hand back what the kept
member answers. The usual `=> GetEnumerator()` and `object
IEnumerator.Current => Current` hand it back, and build.

An exception class of the app's (#611):

- It is a class, with a module and a twin of its own, over the runtime's
`$eq.exceptions.Exception`: the browser's `Error`, carrying the class's
.NET types, which a typed `catch`, a type pattern and an `as` read as
before. Its fields, properties, constructors and methods are its own.
- Its base call hands what a `new` of its .NET base hands since #558:
the message, the text that base's constructor writes where none or a
null one is given, and a parameter's name or an inner exception, each by
its parameter.
- The members it inherits are accessors an override replaces, the
virtual `Message` and `ParamName` included, and a construction of a
generic class carries its own types from before its constructor's body
runs, through `$eq.exceptions.construct`.
- An exception type is named as .NET names it at run time
(``App.Failed`1[System.Int32]``, `App.Outer+Inner`), for a typed `catch`
and for the message .NET writes where none is given.
- The constructor only .NET's serialization calls, `(SerializationInfo,
StreamingContext)`, is no branch of the twin, as a record's copy
constructor is not, for an exception class and a plain class alike. So
Visual Studio's exception template builds as it is written. A call of
one the browser would make, a `new` or the base call, base clause or
chain of a constructor the twin writes, fails the build (EQ1009), since
with no branch for it the call ran another constructor.
- An exception built or tested where a type argument it carries is a
type parameter fails the build (EQ2014): `new Failed<T>()` in a generic
method, `catch (Failed<T>)`, or `new Missing()` inside the generic class
that declares it. See the decision below.

Over #654, where a nested type is a module of its own:

- An exception class declared inside another type is a class too, its
twin named by its owner (`Holder$Nested`), and what an exception class
declares crosses as it does (`Rejected$Reason`). #654 kept both out
while an exception had no module, so the two changes say one thing
together here: what a server-only type or an attribute declares has no
twin (EQ2010), and what an exception class declares has one. The
server's route table follows the same rule.
- A snippet compiled on its own, with no project's compilation to read a
class's bases from, still builds an app exception as the runtime's
exception. The construction named a twin nothing writes there, and the
page met `Failure is not defined`. What the class declares stays out of
reach in that host, as it was on main, which is #754.

For an app: a `B` held as an `A` answers A's `Name()`, a sequence of
your own works in `foreach` and LINQ, and `new Failure().Code` reads
what the class declares.

## Why

Each of these built green and answered otherwise in the browser,
measured through the module graph an app's build writes, both sides
executed:

- A `class B : A` declaring `public new string Name()` was refused
(EQ1007) where A was the app's, legal C# and all, and answered B's where
A was .NET's.
- `foreach` over a class that implements `IEnumerable<T>` threw, since
no twin had a `[Symbol.iterator]`, and its explicit
`IEnumerable.GetEnumerator()` was written over the generic one.
- An app exception was an `Error` built by its symbol, so `new
Failure().Code` was null, `Describe()` threw, and `: base("failed")`
read an empty message.

## A decision to confirm

EQ2014 refuses valid C#: an exception that carries a type parameter
where it is built or tested. JavaScript erases the type argument, so
`Make.Of<int>()` threw an exception that carried `T` itself and `catch
(Failed<int>)` missed it in silence, which is why it is fenced, as `new
T()` (EQ2003) and a `ServerTopic<T>` built over a type parameter
(EQ2013) are. It can be overturned. The alternative is to carry the type
arguments a generic method or class receives at run time, which changes
every generic call and is a change of its own.

## OpenSpec

-
`openspec/changes/archive/2026-10-08-a-class-dispatches-and-enumerates-as-dotnet-does`,
archived into `openspec/specs/transpiler-classes`,
`openspec/specs/transpiler-sequences` and
`openspec/specs/transpiler-exceptions`.

## Proof

- A/B per case against the head this merges over, #707's at f03bcb6,
which main holds now, each side built with 0 errors and the same probe
run on both, each case alone through the module graph, as TypeScript and
as JavaScript. #707's head moved to 5e5b5ca since, by a server message
and a spec file that no case reads:
- `ClassDispatchConformanceTests` holds 71 cases, 18 for #563, 20 for
#612, 30 for #611 and 3 for the template, so 142 runs.
- On that head, 66 of the 71 cases fail (130 of the 142 runs). The five
that pass are answers it had right, and the branch keeps them: the
default message, no inner exception is null, a catch of the .NET type it
derives from, the text of the .NET base an app exception calls, and an
exception nested in a generic class caught by its construction.
  - On the branch, 71 of 71 pass (142 of 142 runs).
- A/B of Copilot's second round, before against after. Before is the
head with the round's seven source files taken back to what they were,
built with 0 errors:
- The 13 Compiler cases of the round fail before and pass after: 6 of
`ASequenceItsTwinWouldWalkOtherwise_IsRefused`, 3 of
`AnExceptionTypeNamedWhereATypeArgumentIsATypeParameter_IsRefused` and 4
of `ACallOfASerializationConstructor_IsRefused`.
- Through the module graph, before, each shape answered otherwise than
.NET in silence: `other` for `int` where `Make.Of<int>()` threw and
`catch (Failed<int>)` waited, `0a:a` for `3:a` over a class with two
`IEnumerable<T>`, `1|1` for `x|1` over a non-generic `GetEnumerator()`
of its own, `1,2|3` for `10,20|30` over an explicit
`IEnumerator<int>.Current` of its own, and `strings` for `serialization`
where `new Snap(info, context)` ran `Snap(string, string)`. After, each
is a build error.
- Copilot's first round found three defects, each fixed in its own
commit with its tests: 13e5617 (two GetEnumerators that walk different
sequences), 31ad19a (a generic exception's construction and the names
.NET writes) and db5f840 (a constructor with no model).
- Copilot's third round, the last, found one, in code that came with
#654 and is on main: a static reached through `using static` on a nested
type of a `[RuntimeProvided]` owner in an app's namespace was imported
as a module of the app's, which nothing writes, so the call reached an
undefined `Kit$Part`. The import's bucket is the symbol's now, its
owners included (121a943).
`AStaticOfARuntimeProvidedOwnersNestedType_ReachedBare_IsTheRuntimes`
fails on the head before it and passes here.
- The served runtime grows 500 gzip bytes over what main serves, from
219241 to 219741, both sides built and measured the same way (gzip,
SmallestSize). 219241 is also what main's record says.
- Suites, each alone through the machine's suite lock, every run exiting
0 and none aborted, on c9e0a2c with this branch's wiki at 4dda3aa:
Compiler 1891 passed, Web 1021 passed, Server 372 passed, Conformance
4863 passed and 1 skipped of 4864, and the runtime's `TestRuntime` 2339
tests in 170 files. `dotnet build samples/DefaultUIDashboard` builds
with 0 warnings and 0 errors there. After the third round's fix, a
change to the compiler alone, the Compiler suite (1892 passed), Web
(1021 passed) and Conformance (4863 passed and 1 skipped) ran again on
121a943, and the twins regenerated there did not move, so the runtime
is the same bundle. The head records main's squashes of #715, #654 and
#707 with ours and keeps that tree, byte for byte.
- The runtime's twins and the BCL baseline, regenerated over the merge,
did not move. The null-argument baseline of #715 is as main has it, 401
entries: nothing here closes or opens one.

- The review (`/code-review high`) found ten things. Six were defects
and are fixed, each with its case: a public `GetEnumerator()` that
returns a struct beside the explicit ones ran out of stack, a class that
is its own enumerator looped, a generic method that hides one over its
own type parameter was refused, a `Name` of the app's on an exception
read the type's name, an override of `ParamName` read the base's, and a
build with no compilation gave an exception a module that extended
nothing.
- Visual Studio's exception template, which the review found refused,
builds as it is written: its `(SerializationInfo, StreamingContext)`
constructor is no branch of the twin.
`VisualStudiosExceptionTemplate_BuildsAsItIsWritten` builds it as the
IDE writes it, with SYSLIB0051 suppressed, beside a plain class with a
constructor of the same shape.
- Four stay as they are:
- An exception class whose members reach what the browser cannot run is
marked `[ServerOnly]`, as every class is, which is the break below.
- A collection's own `GetEnumerator()` (`=> items.GetEnumerator()`) has
no translation (EQ2004), as on main, and the docs say to yield the
items.
- A `new` method in a type the runtime carries would be named apart only
in the runtime's own build. None exists today.
- The rule for a .NET exception's arguments is one table read twice, by
a `new` and by a base call, and its two renderings could be one builder.

## Checklist

- [x] `dotnet test` on the affected test projects, and `dotnet build
src/eQuantic.UI.Runtime -t:TestRuntime` if TypeScript changed (the
embedded Bun runs `tsc`, then `vitest run`)
- [ ] This PR's checks include a run named CI with jobs in it
(`build-packages`, `test (ubuntu-latest)`, `test (windows-latest)`,
`openspec`, `session-start`, `wiki-checkout`). A workflow whose
expression does not parse creates zero jobs, and the ruleset's required
checks then keep the PR blocked. GitHub lists the workflow by its file
path instead of `CI`. While GitHub Actions has no credits, the same run
is on eQuantic Space (`eqs runs ls`)
- [x] `./scripts/check-openspec.sh` passes: the OpenSpec change
validates strictly, and it is archived before the merge
- [x] `dotnet build samples/DefaultUIDashboard` (and `PhotonDesktop` /
`WalletMobile` if the native track changed). CI's `samples` job builds
all three on macOS only (#149), and the other hosts are yours
- [x] A broken contract has a line in the migration notes (we are in
preview: break freely, hide nothing). An exception class of the app's is
transpiled as any class is, so one the browser never sees, whose members
reach what it cannot run, is marked `[ServerOnly]`. The emitted twin of
a method that hides one has a name of its own, which TypeScript written
by hand against it would meet. Four shapes that built and answered
otherwise than .NET now fail the build: a type that implements
`IEnumerable<T>` over two types, a non-generic `GetEnumerator()` or a
`Current` that does not hand back the kept member's answer (EQ1007), a
call of a serialization constructor (EQ1009), and an exception built or
tested where a type argument it carries is a type parameter (EQ2014).
- [x] The documentation changed with the behaviour: this repository's
Markdown here, and the wiki page in English AND Portuguese, in one
commit on a branch of the [wiki
repository](https://github.com/eQuantic/equantic-ui/wiki) named exactly
like this pull request's branch, merged into the wiki's master when this
merges (the twin lives at `locale/pt-BR/<Page>-pt-BR.md` there)
- [x] One `docs/LEDGER.md` line for this event, citing the issue
- [x] The diff reviewed by its author before the PR opened (in Claude
Code, `/code-review high`), every Copilot thread answered and resolved,
a new round asked for (`gh pr edit <n> --add-reviewer @copilot`) only
after a defect, and three rounds at most
- [x] No "widget" in prose, the project's word is *component*

---------

Co-authored-by: Edgar Mesquita <edgar@equantic.tech>
edgarmesquita pushed a commit that referenced this pull request Oct 11, 2026
Merges 5e5b5ca, the head of #707 as the queue's head holds it, the third of the steps that bring
this branch to the head of the queue. No source file conflicts: #707's collection constructors,
comparers and LINQ table sit beside this branch's formatter, and DIAGNOSTICS.md and its baseline
merged on their own, with EQ2109 still retired and EQ2110 still this branch's.

Six runtime twins conflict and take #707's side. Every twin is regenerated after the last step.
docs/LEDGER.md and the PublicAPI files merged as a union, and this branch's ledger line moves back
to the end of the events.
edgarmesquita pushed a commit that referenced this pull request Oct 11, 2026
Merges 78215e9, the head of #708 after #654 and #707 landed on main (d7a8c31, 178497c). Over the
25643d5 this branch already held it brings one commit, 121a943: InvocationStrategy buckets a bare
`using static` call's class by its symbol, with its test in NestedTwinNameTests. The rest are
merges of main's squashes that change no content. Nothing conflicts, and no twin moves.
@edgarmesquita edgarmesquita mentioned this pull request Oct 11, 2026
5 of 6 tasks
edgarmesquita added a commit that referenced this pull request Oct 11, 2026
Closes #756

The bump to `0.2.0-preview.61`, cut from main at 9305100 (#637) at
Edgar's request. It carries forty-five pull requests since `.60`: a
component that hears the server on a typed topic (#647), a page that
requires its authorization and a Server Action that refuses another
site's request (#686, #737), a layout that follows its box and type that
follows the window (#648, #665), a control's press and focus on every
target with a 24dp floor under a pointer (#617, #628), the code editor's
completion (#618, #653, #733), a hot reload under `dotnet watch` that
keeps the page's state (#666, #672), the image optimizer on SkiaSharp
(#730), the places where the producers drew one tree differently, with
Photon's hit-testing and its text layout (#688, #690, #699, #739, #741,
#749), a number and a date printed in the page's culture (#637), and a
long family of browser answers made .NET's (#579, #588, #594, #607,
#608, #621, #654, #656, #660, #661, #667, #668, #689, #696, #697, #705,
#707, #708, #709, #715, #731, #748, #752), with a page that answers HEAD
(#610) and the conformance harness's own comparison (#609).

## What this commit does

- `Directory.Build.props`: `0.2.0-preview.60` → `0.2.0-preview.61`.
- `./scripts/public-api.sh ship`: nine projects, 462 declarations join
the shipped surface and 43 retire, and every `Unshipped` is empty
afterwards. Checked after the fold, per project and compared as sets:
the lines that left `Shipped` are exactly the 43 `*REMOVED*` lines
declared on main, and the lines that joined are exactly its 462
declarations, so an orphan could not vanish unnoticed. The line diff
reads +463 −549: the version line, 462 lines into `Shipped` and 43 out
of it, and the 505 that leave the `Unshipped` files.
- The analyzer accepts the fold: the solution builds with no RS0016 or
RS0017. And it read the files: one bogus declaration appended to an
`Unshipped` failed the build with RS0017.
- Against `v0.2.0-preview.60` the `Shipped` diff has those 43 lines gone
and no other, so no pull request rewrote a shipped line in place this
time, as #512 did in `.60`.

## What the retirements are

Read out of the `*REMOVED*` lines, each paired with the line that joined
under the same member and attributed by the commit that wrote it, not
from the log. Only `ServerActionHub` left with nothing in its place, and
the rest changed shape:

- `ServerActionHub` and its constructor (#647): the hub, its route
`/_equantic/hub` and the `AddSignalR()` call `AddUI` made are gone. An
app that injected `IHubContext<ServerActionHub>` publishes through
`IServerEventPublisher`, and one that maps hubs of its own calls
`AddSignalR()` itself.
- `ICodeCompletionProvider.CompleteAsync` (#618) takes a
`CodeCompletionContext` and answers a `CodeCompletionList`.
`CurlyBraceLanguage.Keywords` was the protected abstract set a language
overrode, and it is the public list a completion offers now.
- `Drawing`'s constructor, `Drawing.Width` and `UI.Drawing` (#648) hold
the width as a `SizeValue`. `new Drawing(art, 240)` converts, and a read
into a `float` becomes `drawing.Width.Value`.
- `ImageOptimizer()` and `BlurPlaceholderGenerator()` (#730) take the
`ImageOptimizationOptions`. `UseImageOptimization` registers both, so
only an app that built one with `new` meets it.
- `HitRegion` (#690) gains `Drawn` after `Bounds`, so building one and
deconstructing one both change. `CodeRegion` (#653), `SheetRegion` and
`TextRegion` (#690) gain optional members at the end, so only a
positional deconstruction changes.
- `ServerActionsMiddleware`'s constructor (#737) takes an
`IOptionsMonitor<ServerActionsOptions>`. `UseServerActions()` builds it,
so only an app that constructs the middleware itself meets it.
- Four signatures widened at the end by optional parameters, which
compiles as before from source: `UI.Positioned` by the four fractions
and the two shifts (#648), `UI.Pressable` by `canRequestFocus` (#653),
`WebRealizer.Lower` by `density` (#688) and `PhotonRealizer.Realize` by
`sizedLike` (#690).
- `eQuantic.UI.Compiler`, which no app references, 21 lines: eleven of
#661 (`JsTemplate`'s `Annotate`, with its constructor, its `Deconstruct`
and the factory that took it, and six LINQ strategies that crossed to
the IR and lost their text `Convert`), four of #608 (`ValueMember`,
`ComponentDependencyResolver`'s constructor and
`RecordTypeEmitter.CanEmit`), two of #637 (`Eq.AsInteger` and
`Eq.AsSingle`), two of #654 (`UserDefinedOperators.Binary` and `Unary`),
and `HashSetStrategy` (#594) and `RangeIndexerStrategy` (#707) crossing
to the IR.

One break is an addition, which no `*REMOVED*` line shows.
`CurlyBraceLanguage.ReservedWords` (#618) is a new abstract member, so a
language derived from it renames its `Keywords` override to
`ReservedWords`. I listed every member that joined an interface or an
abstract class already shipped, and it is the only one an implementer
has to write: `ICodeLanguage.Keywords` and
`ICodeCompletionProvider.ResolveAsync` have default implementations.

The additions are 118 in `eQuantic.UI.Code` (the completion), 116 in
`Primitives` (the server events vocabulary, `FluidSize`, `Positioned`'s
fractions and shifts, `BoxStyle.Pressed`, `GridTrack.AutoFill`,
`Touch.MinPointerTarget`), 98 in `Server` (server events, the origins a
Server Action allows), 86 in the compiler, 19 in `Native.Components`, 12
in `Web`, 7 in `Components`, 4 in `Native.Framework` and 2 in `Images`.

## The rest of the surface

`developer-surface.baseline.txt` gains two configuration sections since
`v0.2.0-preview.60`, `EQuantic:ServerActions` (#737) and
`EQuantic:ServerEvents` (#647), and loses nothing: no MSBuild property
or template parameter came or went.

What an app meets with no signature moving:

- Three new build errors. EQ1009 (#608, #621, #708) stops two
constructors that take the same number of arguments, in a class, a
record or a struct that reaches the browser, where the twin kept one and
a `new` that meant another ran it. EQ2014 (#708) stops an exception
built or tested where a type argument it carries is a type parameter.
EQ2013 (#647) stops a `ServerTopic<T>` built over a type parameter,
which only new code can meet.
- Refusals widened under existing codes: EQ1007 by a second indexer and
a sequence over two types (#707, #708), EQ2004 by a stored `Range`, a
method group over a .NET extension and a .NET collection's own
`GetEnumerator()` (#656, #707, #708), EQ2007 by a comparer handed to
`ToDictionary`, `ToLookup`, `GroupBy` or `Distinct` (#707), and EQ2010
by an extension method of a server-only class and a type nested in one
(#654, #705, #731).
- Two refusals a running app meets on the server: a Server Action called
from another origin answers 403 until the app lists that origin (#737),
and a class-level `[Authorize]` guards the page as well as its actions
(#686).
- `image/avif` in the optimizer's `Formats` stops the app at startup
(#730), and a `Spacer` of weight below 1 or a negative `Flexible` weight
throws where it is written (#741).
- EQ2109 is retired and EQ2110 is about reading a number now, since a
number written as text follows the page's culture on both sides (#637).

The rest are browser answers made .NET's and drawings made alike across
the producers, listed in the tag.

## The wiki's version marks

`WikiVersionMarkTests` compares a mark only once a tag holds its symbol,
so the `.61` tag is when this release's marks are judged for the first
time. Emulated before the tag, with the test's own pattern and lookup
and a `.61` at 9305100: 145 marks, no mismatch. The thirteen it judges
first resolve to `.61`, as written: `StartFraction`,
`GridTrack.AutoFill`, `canRequestFocus`, `DENSITY_COOKIE`, `Drawing`'s
`SizeValue` width, `WithPageAuthorization`, `AllowServerActionOrigins`,
`IServerEvents`, `MapGetAndHead`, `UseServerEvents`, `TwinIsData` on the
tokens, `BoxStyle.Pressed` and `ScrolledThreshold`.

## The tag's gate, rehearsed

`gate-templates` runs only on a `v*` tag, so a template that does not
build costs a release attempt. Rehearsed on main at 9305100, before
this branch: 50 packages packed at `1.0.0-dev`, the three web and the
four native template shapes scaffolded and built in Release against that
feed alone, with no warning and with every eQuantic package restored at
the version packed, the published `runtime.js` identical byte for byte
to the Server package's, and the dashboard template served, hydrated and
navigated in a browser with no console error.

## Not in this release

No pull request is open. Forty-eight bugs are, none of them a regression
against `.60`. Three issues came out of this stretch and wait for a
decision or their turn: #753 (`string.Split` with an array of
separators, a count or a null separator), #754 (an app's exception class
in a snippet compiled on its own) and #755 (whether generic code should
carry its type arguments at run time, which EQ2014 fences).

The release notes travel in the annotated tag. The wiki's Upgrading page
carries the distilled entry in both languages, on the wiki branch
`chore/0.2.0-preview.61`, which merges into the wiki's master when this
merges and before the tag. The `docs/LEDGER.md` line for the release
follows the tag, as `.60`'s did, because it names the commit the tag
lands on.

## Checklist

- [x] The diff reviewed by its author before the PR opened: a version
line and a mechanical fold, verified as sets above
- [x] `dotnet test` on this branch: Compiler 1898, Server 374,
Conformance 5087 with one skipped, Web 1021, and the runtime's own 2355
in 170 files, none aborted
- [ ] This PR's checks include a run named CI with jobs in it
- [x] `./scripts/public-api.sh ship` run, every `Unshipped` empty, the
retirements read from the diff
- [x] The documentation changed with the release: the Upgrading entry in
English and Portuguese, on the wiki branch `chore/0.2.0-preview.61`,
with the wiki guards passing against it (9 of 9, and the vocabulary
guard fails on that page when a word it refuses is written into it)
- [x] Copilot's rounds answered: one round, approval recommended with no
finding and no thread, so the loop ends there

Co-authored-by: Edgar Mesquita <edgar@equantic.tech>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants