Skip to content

🐛 fix: a builder has the members a page reaches, and its capacity follows .NET's chunks - #748

Merged
edgarmesquita merged 20 commits into
mainfrom
fix/a-builder-has-the-members-a-page-reaches
Oct 10, 2026
Merged

edgarmesquita merged 20 commits into
mainfrom
fix/a-builder-has-the-members-a-page-reaches

Conversation

@edgarmesquita

@edgarmesquita edgarmesquita commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Closes #679

It is a sub-issue of #565 (the transpiler's fences hold on every path).

What

Every StringBuilder member a page reaches answers as .NET's does in the browser, or fails the build where it cannot cross:

  • AppendFormat and AppendJoin append what string.Format and string.Join write, through the same lowering, bound by the same parameters and under the same culture policy.
  • Capacity is .NET's. The runtime keeps the sizes of the chunks .NET allocates, so Capacity follows every edit: an append fills the last chunk and opens one as large as the text so far (up to 8,000) or as what is left, an insert or a longer replace makes room as MakeRoom does (in place in a small chunk with room, otherwise a chunk of at least 16, one per source chunk for a replace), a removal unlinks the chunks it empties, and a shorter Length keeps min(Capacity, max(Length * 6 / 5, the last chunk)). MaxCapacity, EnsureCapacity, the Capacity setter and the constructors that take a capacity, a maximum or a range answer and refuse as .NET's do, in .NET's order. Text that fits the last chunk goes in past MaxCapacity, as on .NET.
  • The Chars indexer is the twin's item and setItem, refused past the text as .NET refuses it. Length cuts the text or fills it with \0. Equals(StringBuilder) compares the text whatever the capacity, while Equals(object) stays identity. CopyTo copies into a char[].
  • What cannot cross is refused at the build: a provider other than the current culture on Append and AppendLine with an interpolation (EQ2108), GetChunks and an overload over a span or a memory (EQ1004). A params span is the values written one by one, and crosses.
  • A member that .NET makes of several appends makes them as .NET does. An interpolated Append($"…") or AppendLine($"…") appends each part in turn, as .NET's interpolation handler does, so a hole that reads the builder sees the parts before it ($"{b.Length}{b.Length}" held "00" where .NET holds "01"), each part grows the chunks, a refusal keeps the parts before it, and a hole prints as a plain interpolation's does, where an enum reached the builder as its camelCase key. AppendLine(value) appends the newline after the value, Append(StringBuilder) checks the maximum before it copies, and a Replace past the maximum keeps the chunks it replaced first.
  • A named argument is bound to its parameter, each still evaluated in the order written: the twin takes C#'s parameters by position.
  • Every text the builder makes is built before its chunks change, and one longer than the browser's string can hold is .NET's OutOfMemoryException, not JavaScript's RangeError.

For an app: sb.AppendFormat("{0}-{1}", 1, 2), sb[0], sb.Capacity and sb.Length = 4 work in the browser, where they were TypeErrors, undefined or writes nobody read, and sb.Append($"{sb.Length}…") writes what .NET writes. Nothing an app writes changes, and the public surface does not move.

Why

The BCL audit graded every StringBuilder member native, which only said that a strategy answered. The runtime's builder had append, appendLine, insert, remove, replace, clear, toString and a length getter, and eqc wrote every other member under its own name, so a page reached TypeErrors and undefined values behind a green build (#679, found while fixing #650 in #667).

OpenSpec

openspec/changes/archive/2026-10-07-a-builder-has-the-members-a-page-reaches, archived into openspec/specs/transpiler-bcl.

Proof

  • StringBuilderConformanceTests, both sides executed: 31 of the first 88 new cases failed on the base, the rest pinning members that answered by accident. Capacity matches .NET 10 on 41 measured sequences.
  • The review (/code-review high) found nine things, each measured on .NET 10 with dotnet fsi before it was fixed: the room an insert or a replace opens (Capacity 16 on .NET where the browser said 6), the ranged constructor's refusals, the constructor's order of refusals, CopyTo's order, the refusal of an insert past MaxCapacity (requiredLength, and OutOfMemoryException for a counted insert), text that fits the last chunk past MaxCapacity, OutOfMemoryException in place of a RangeError, and emptied chunks left in the list. 11 of its 12 new conformance cases fail on the commit before.
  • The BCL audit's verdicts move as meant (AppendFormat and AppendJoin are eq, GetChunks is fenced), and EQ2108 gains StringBuilderStrategy as a reporting site with its documented meaning, which docs/DIAGNOSTICS.md now names.
  • The served runtime: main records 213,928 bytes gzipped and serves 214,116 after 🐛 fix: a code editor keeps its Tab in a dialog, and undo after a pasted carriage return restores the text #733. This branch measures 216,219, past the record's 1%, so it moves the record to 216,148 (bf400ef, measured on the main of 🐛 fix: Photon hit-tests and lays out what it draws #690, which 216,219 stays inside), in a commit that says what the bytes bought.
  • Suites, each alone and read by its exit code. On the main of 🐛 fix: a generic record equals only a value of its own closed type #689: Compiler (1,824), Conformance (4,555 of 4,556, one skipped) and Web (988, the wiki guards on this pull request's wiki branch). On the main of 🐛 fix: a code editor keeps its Tab in a dialog, and undo after a pasted carriage return restores the text #733, this head: the runtime's TestRuntime (2,262) and Server (318, the budget included), with Compiler, Conformance and Web rerunning.
  • Copilot's second round found two defects, fixed in 229d3c8, and measuring them on .NET 10 found four more in the same model, fixed with them. A named argument reached the twin in the order written (CopyTo(count: 2, destination: a, …) read the count as the source index): a call that names an argument goes through ParameterTemplate, each still evaluated in the order written. Only String.repeat mapped a RangeError: every text the builder makes goes through one guard, built before the chunks change. In V8 (Node 24), append, insert and replace past the engine's longest string threw RangeError: Invalid string length on the commit before and throw OutOfMemoryException now, the builder unchanged. The four more: an interpolated append part by part, AppendLine's newline as an append of its own, Append(StringBuilder)'s check of the maximum, and a Replace that refuses chunk by chunk. On the commit before, 14 of the 16 new conformance cases, 10 of the 12 new compiler cases and the 3 new runtime cases fail, the runtime's under V8 and JavaScriptCore alike. The cases that pass there pin what must not change: Append(object) holding a builder is a string's append, a builder appended to itself is its text, and a string's append stays one append. Append(T) of a float, a double or an enum still prints what the browser holds, filed as A value a builder appends prints as .NET prints it #750.
  • Copilot's first round found two defects, fixed in e7a65cc: the span fence let a span passed whole to a params overload through (only the values a call expands into it cross now, EQ1004 otherwise), and a capacity past Array.MaxLength was accepted where .NET throws OutOfMemoryException (measured for the constructors, Capacity and EnsureCapacity). The one new compiler case and the three new conformance cases fail on the commit before.

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) (CI's samples job builds them)
  • A broken contract has a line in the migration notes (we are in preview: break freely, hide nothing): no C# an app writes changes. A builder member that was a TypeError now answers, and GetChunks, a span overload and a provider other than the current culture fail the build where they failed in the browser.
  • The documentation changed with the behaviour: this repository's Markdown here (docs/DIAGNOSTICS.md, the ledger), 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 (SupportedFeatures.md)
  • 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 only after a defect, and three rounds at most
  • No "widget" in prose; the project's word is component

…lows .NET's chunks

AppendFormat, AppendJoin, Capacity, MaxCapacity, EnsureCapacity, the Chars indexer, Length's setter,
Equals(StringBuilder) and CopyTo transpiled to members the runtime's builder did not have, a
TypeError or an undefined in the browser behind a green build.

AppendFormat and AppendJoin append what string.Format and string.Join write, by the same lowering,
its culture policy included. Capacity is .NET's: the runtime keeps the sizes of the chunks .NET
would allocate, through appends, inserts, removals, replacements and a shorter Length, which
matches .NET on 41 measured sequences. The indexer is the twin's item and setItem, Equals(object)
stays identity, a provider other than the current culture on Append is EQ2108, and GetChunks and the
span overloads are refused at the build (#679).
Main's runtime is 203,119 bytes gzipped, the exception and builder work of #667 inside the 1% its
record allows. This branch adds 1,308 more: the builder's indexer, length and capacity as .NET's
chunks give them, AppendFormat, AppendJoin, CopyTo, Equals and the constructors' refusals. Together
they pass the record's 1%, so the record moves to 204,427.
…members

The audit's verdicts move as this change meant them to: AppendFormat and AppendJoin were "native"
because a strategy answered, though the browser had no such method, and are "eq" now, routed to the
runtime's builder; GetChunks is fenced with EQ1004. EQ2108 gains StringBuilderStrategy as a reporting
site, with the meaning it has everywhere else, a CultureInfo the browser cannot follow: a builder's
Append and AppendLine with a provider and an interpolation cross only CurrentCulture or a null, which
DIAGNOSTICS.md now says.
…asured it

From the review of #679, each measured on .NET 10. An insert or a replace opens a chunk of at least
16, and a replace one per source chunk, so Capacity after a later cut is .NET's (16 where it was 6).
The ranged constructor refuses its capacity, length and start, and the (capacity, maxCapacity) one
checks in .NET's order. CopyTo checks the destination's room first. Text that fits the last chunk
goes in past MaxCapacity, as on .NET; an insert past it is requiredLength's refusal, Insert with a
count an OutOfMemoryException, and a repeat past an array's length .NET's OutOfMemoryException in
place of JavaScript's RangeError. A removal unlinks the chunks it empties. 11 of the 12 new
conformance cases fail on the commit before.
# Conflicts:
#	tests/eQuantic.UI.Server.Tests/Budgets/served-runtime.json
…e main of #668

Main's runtime is 206,012 bytes gzipped, inside the 1% its record of 204,101 allows. This branch
adds 1,806 more: the builder's indexer, length and capacity as .NET's chunks give them, AppendFormat,
AppendJoin, CopyTo, Equals, the constructors' refusals, and the chunk sizes and refusals the review
measured. Together they pass the record's 1%, so the record moves to 207,818.
Copilot AI balanced review requested due to automatic review settings October 10, 2026 15:44

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.

🟡 Changes recommended

Permanecem divergências funcionais em igualdade, atribuição por indexador, capacidades extremas, construtor com intervalo e spans explícitos.

2 open findings
What changed in this PR

Amplia a compatibilidade de StringBuilder no transpiler e runtime com semântica próxima ao .NET.

Changes:

  • Implementa membros, indexadores, capacidade e construtores.
  • Reutiliza os lowerings de string.Format e string.Join.
  • Adiciona especificações, documentação e testes de conformidade.
File Description
tests/​eQuantic.UI.Server.Tests/​Budgets/​served-runtime.json Atualiza o orçamento do runtime.
tests/​eQuantic.UI.Conformance.Tests/​StringBuilderConformanceTests.cs Adiciona conformidade bilateral.
tests/​eQuantic.UI.Compiler.Tests/​Strategies/​StringBuilderStrategyTests.cs Testa novos lowerings e recusas.
tests/​eQuantic.UI.Compiler.Tests/​Coverage/​diagnostics.baseline.txt Registra o novo emissor de EQ2108.
tests/​eQuantic.UI.Compiler.Tests/​Coverage/​bcl-surface.baseline.txt Atualiza a auditoria da BCL.
src/​eQuantic.UI.Runtime/​src/​utils/​string-statics.ts Compartilha mensagem de intervalo.
src/​eQuantic.UI.Runtime/​src/​utils/​string-builder.ts Implementa a compatibilidade ampliada.
src/​eQuantic.UI.Runtime/​src/​utils/​string-builder.spec.ts Testa membros e capacidade.
src/​eQuantic.UI.Runtime/​src/​utils/​exceptions.ts Registra novas hierarquias de exceção.
src/​eQuantic.UI.Compiler/​CodeGen/​Strategies/​Types/​StringBuilderStrategy.cs Baixa membros e bloqueia APIs incompatíveis.
src/​eQuantic.UI.Compiler/​CodeGen/​Strategies/​Primitives/​StringStaticStrategy.cs Expõe lowerings reutilizáveis.
src/​eQuantic.UI.Compiler/​CodeGen/​Strategies/​Expressions/​Indexer.cs Inclui o indexador do builder.
openspec/​specs/​transpiler-bcl/​spec.md Incorpora o requisito principal.
openspec/​changes/​archive/​2026-10-07-a-builder-has-the-members-a-page-reaches/​tasks.md Registra tarefas concluídas.
openspec/​changes/​archive/​2026-10-07-a-builder-has-the-members-a-page-reaches/​specs/​transpiler-bcl/​spec.md Arquiva o delta da especificação.
openspec/​changes/​archive/​2026-10-07-a-builder-has-the-members-a-page-reaches/​proposal.md Arquiva a proposta.
docs/​LEDGER.md Registra a correção.
docs/​DIAGNOSTICS.md Documenta EQ2108 para builders.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/eQuantic.UI.Compiler/CodeGen/Strategies/Types/StringBuilderStrategy.cs Outdated
Comment thread src/eQuantic.UI.Runtime/src/utils/string-builder.ts Outdated
…and a capacity past an array's length is .NET's OutOfMemoryException

From Copilot's first round on #748. The span fence exempted every params parameter, so a span passed
whole in the normal form, AppendJoin(separator, span), reached JoinCall; only the values a call
expands into a params span (ArgumentKind ParamArray or ParamCollection) cross now. And a capacity the
builder only records was accepted past Array.MaxLength, where .NET allocates the chunk whole and
throws OutOfMemoryException ("Array dimensions exceeded supported range."), measured on .NET 10 for
the constructors, the Capacity setter and EnsureCapacity.
# Conflicts:
#	tests/eQuantic.UI.Server.Tests/Budgets/served-runtime.json
…e main of #672

Main records 209,149 bytes gzipped after #672's hot reload. This branch adds the builder's members on
top (its indexer, length and capacity as .NET's chunks give them, AppendFormat, AppendJoin, CopyTo,
Equals, and the refusals the reviews measured), which passes the record's 1%, so the record moves
to 212,035.
# Conflicts:
#	tests/eQuantic.UI.Server.Tests/Budgets/served-runtime.json

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.

🟡 Changes recommended

Argumentos nomeados reordenados ainda são interpretados incorretamente, e algumas operações podem vazar RangeError.

2 open findings
2 resolved since last review

🧠 Review effort: Balanced

Comment thread src/eQuantic.UI.Runtime/src/utils/string-builder.ts
Comment thread src/eQuantic.UI.Runtime/src/utils/string-builder.ts
…ents, and is out of memory past the browser's longest string

Copilot's second round on #748 found two defects, and measuring them on .NET 10 found four more
in the same model:

- A named argument reached the twin in the order written: CopyTo(count: 2, destination: a,
  destinationIndex: 1, sourceIndex: 0) read the count as the source index. A call that names an
  argument is lowered through ParameterTemplate, each still evaluated in the order written, and
  new StringBuilder(capacity: 50, value: "ab") through the constructor's template.
- Only String.repeat mapped a RangeError to OutOfMemoryException. Every text the builder makes now
  goes through one guard, and is built before the chunks change, so a refusal leaves the builder
  as it was.
- An interpolated Append($"…") appended the whole text at once, reading every hole first:
  $"{b.Length}{b.Length}" held "00" where .NET holds "01". It appends each part in turn, as .NET's
  handler does, a hole's alignment padding in an append of its own (appendAligned), and a handler's
  plain hole now flows into text as a plain interpolation's does (ValueFlow), where an enum reached
  the builder as its camelCase key.
- AppendLine(value) appends the line's end after the value: a 100-char line's capacity is 200, not
  101, and a newline past the maximum is refused with the value already in.
- Append(StringBuilder) is appendBuilder, which checks the maximum before it copies and refuses as
  "Capacity exceeds maximum capacity. (Parameter 'Capacity')".
- A Replace past the maximum keeps the chunks it replaced first, as .NET replaces chunk by chunk.
…e main of #690

Main's runtime is 214,048 bytes gzipped, inside the 1% its record of 213,928 allows. This branch adds
2,100 more: the builder's indexer, length and capacity as .NET's chunks give them, AppendFormat,
AppendJoin, CopyTo, Equals and the refusals the reviews measured, and this round's appendBuilder,
appendAligned, the Replace that refuses chunk by chunk and the guard that turns the browser's
RangeError into OutOfMemoryException. Together they pass the record's 1%, so the record moves to
216,148.
@edgarmesquita
edgarmesquita requested a balanced review from Copilot October 10, 2026 20:47
@edgarmesquita
edgarmesquita merged commit 20066eb into main Oct 10, 2026
11 checks passed
edgarmesquita pushed a commit that referenced this pull request Oct 10, 2026
Merging main left this pull request's entry above #748's, which landed
before it. The list is in the order things land, so it moves to the end,
unchanged.

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.

🟡 Changes recommended

O lowering de AppendJoin, a conversão do alinhamento e o orçamento registrado ainda apresentam divergências concretas.

3 open findings
2 resolved since last review

🧠 Review effort: Balanced

case { Name: "AppendFormat" }:
return $"{receiver}.append({StringStaticStrategy.FormatCall(inv, inv.ArgumentList.Arguments, context)})";
case { Name: "AppendJoin" }:
return $"{receiver}.append({StringStaticStrategy.JoinCall(inv, method, context)})";
Comment on lines +169 to +172
var value = InterpolatedStringStrategy.Hole(hole, context, padded: false);
chain.Append(hole.AlignmentClause is { } alignment
? $".appendAligned({value}, {alignment.Value})"
: $".append({value})");
@@ -1,4 +1,4 @@
{
"gzipBytes": 213928,
"gzipBytes": 216148,
edgarmesquita pushed a commit that referenced this pull request Oct 10, 2026
…ain took first

Merging main left this pull request's entry above #733's, #748's and
#731's, which landed before it. The list is in the order things land,
so it moves to the end, unchanged.
edgarmesquita pushed a commit that referenced this pull request Oct 10, 2026
With main at 76122dd merged, the served runtime is 860,910 bytes and 218,383 gzipped,
74 bytes past the 1% the budget allows over its record of 216,148. Main alone
measures 860,330 bytes and 218,228 gzipped, so this pull request's twin costs 580
bytes, 155 gzipped: a Flexible refusing a negative weight, basis or shrink and a basis
that is not finite, a Spacer refusing a weight below one, a zero weight's basis of
auto, and a basis written through px. The other 2,080 bytes of the move came from main
since #748 recorded 216,148, inside its tolerance. Written with
EQ_UPDATE_RUNTIME_BUDGET=1.
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>
edgarmesquita pushed a commit that referenced this pull request Oct 11, 2026
Merges 20066eb, the third of the five steps that bring this branch up to main eb33f44. It
carries #666, #672, #688, #653, #699, #689, #690, #696, #733 and #748, and #748 is the one that
meets this branch's source.

How each source conflict was resolved:

- ValueFlow.cs: both sides taught FlowsIntoText the hole of an interpolated string a handler takes
  (sb.Append($"...")), this branch by the hole's syntax (an AppendFormatted with no alignment and no
  format clause) and #748 by the bound operation (an argument of the handler's call that is the
  call's only argument written). #748's rule is kept, since it reads the operation the hole is,
  and the hole's text is still this branch's: a value that flows into text goes through
  StringConversion, which writes it in the page's culture.
- InterpolatedStringStrategy.cs: #748 moved a hole's lowering into Hole(), which the builder's part
  by part append calls too, and this branch had changed the number kind the hole passes. Main's
  Hole() is kept with this branch's kind in it: FormatKind.Of where a specifier is written, and
  FormatKind.OfText with none, every number but a double.
- StringBuilderStrategy.cs: #748 rewrote the dispatch (the span and GetChunks fences, named
  arguments bound through ParameterTemplate, AppendFormat, AppendJoin, the interpolated append part
  by part, appendBuilder and equalsBuilder), and this branch had written the value of Append,
  AppendLine and Insert in the culture through its own bound call. Main's dispatch is kept, and the
  value goes in front of it through the same ParameterTemplate.Call, its convert writing the value
  argument through StringConversion and every other argument as it is, each evaluated in the order
  it is written. A value that is text already passes as it is, as before (ValueInTheCulture, IsText).
  BoundArguments.Call, which this branch added for that value alone, goes: nothing else calls it.
- docs/DIAGNOSTICS.md: EQ2108 keeps this branch's Convert.ToString and gains #748's builder clause,
  EQ2109 stays retired, and EQ2110 keeps this branch's reading-only meaning.
- diagnostics.baseline.txt: EQ2108 gains #748's StringBuilderStrategy, EQ2109 stays out, and EQ2110
  stays this branch's, ParseCulture alone.
- bcl-surface.baseline.txt takes main's side and is regenerated after the last step.

The runtime's twins in conflict take main's side, and every twin is regenerated after the last
step. docs/LEDGER.md merged as a union, and this branch's line moves back to the end of the events.
edgarmesquita pushed a commit that referenced this pull request Oct 11, 2026
Regenerated from the merged compiler, as the merges left them on either side.

- The twins (EQ_UPDATE_TRANSPILED=1, SharedComponentTranspilationTests) and the BoundReceivers
  fixture: a record twin's toString writes $eq.text.record(this, 'Name', [members]) with each
  member's kind or function, a number's text goes through the culture's formatter with its kind,
  and CodeMetrics' TypeStyle hydrates its FluidSize (#665). tsc is clean over them.
- The BCL audit (EQ_UPDATE_BCL_BASELINE=1): eq=272, fenced=90, invalid-probe=6, native=665. The
  members this branch writes through the culture's formatter move from native to eq beside #748's:
  a builder's Append and Insert of a number, a bool or an object, which printed what the browser
  held, among them.

The served runtime's budget is left at main's record. It is regenerated when the suites run.
edgarmesquita pushed a commit that referenced this pull request Oct 11, 2026
…head

Eighteen pins of the compiler suite read the shape one side of the merge wrote, and take the
merged one.

- StringBuilderStrategyTests (#748) pinned a builder's interpolated append, AppendFormat and
  Append(object) as they were before the culture: a number's hole and a concatenated number write
  $eq.text.format with their kind, a format's values are boxed with $eq.text.asNumber, and a
  builder typed as an object is appended as its text. Eight cases, each read against what the
  merged compiler emits.
- CultureCrossingTests matched a date's construction as $eq.time.dateTime(, and #660 builds a
  DateTime and a DateTimeOffset through a factory, $eq.time.dateTime.of(. Its pattern takes both,
  for the ten cases that build one.
edgarmesquita pushed a commit that referenced this pull request Oct 11, 2026
The line's numbers were measured against main at dc4a410. Against main as it is with #660, #748
and the queue landed (98c2b18's tree), 176 of the 222 conformance cases fail there and 46 already
held, where it was 187 and 35: #660 taught a date's picture its offset and #748 a builder's
interpolated holes their text since. The served runtime grows 3,834 gzipped bytes, 223,575 against
the 219,741 main serves, and ten entries leave #715's null-argument baseline.
edgarmesquita added a commit that referenced this pull request Oct 11, 2026
Closes #454
Closes #455
Closes #469
Closes #470
Closes #471
Closes #634
Closes #570

## What

A number and a date print in the browser as .NET prints them on the
server, in the culture the page was rendered in, whatever path C# writes
them through.

- The format culture travels with every page, whether or not the app has
a string to translate (#471). The server writes the request's culture as
.NET holds it, its `NumberFormatInfo` and `DateTimeFormatInfo`, into
`__EQ_CULTURE__.format`, and a switch with no reload fetches the same
document from `/_equantic/culture/{name}.json`. A page with no culture
installed formats in the invariant culture, never in its host's locale.
The string catalogs carry strings only.
- A number with no specifier is the culture's text wherever C# writes
it: a concatenation, an interpolation hole, `ToString()`,
`Convert.ToString`, `string.Format("{0}")`, `string.Join`, a
`StringBuilder` (its value written by position or by name, or an
interpolated string handed to it) and a record's text (#454). An integer
never prints `-0`, which JavaScript makes of `-1 / 2`.
- The compiler tells the formatter what a number is (#455): a double
refuses `D`, `X` and `B` as .NET does, a `nint` and a `nuint` are 64-bit
integers, and the per mille sign, an exponent's signs and the percent
sign are the culture's own.
- `N`, `F`, `C` and `P` are laid out from the culture's data, its
patterns, symbols, separators, group sizes and default digits (#634).
`Intl` laid them out, and wrote ar-EG's own digits where .NET writes
ASCII ones, a no-break space where .NET's currency pattern has a plain
one, and two digits for `N` where .NET reads three on ICU.
- `DateOnly`, `TimeOnly` and `DateTimeOffset` print through the
formatter a `DateTime` prints through, each with the specifiers its type
takes (#469), and a custom picture writes the culture's `/` and `:`, the
offset for `z`, `zz` and `zzz`, and the era for `g` (#470).
- A calendar's names are the format data's, one copy, where they
travelled twice.
- A plain class or struct that declares no `ToString`, and derives none,
writes its type's full name as .NET does (`My.App.Outer+In`), where the
twin wrote `[object Object]` or a record's text (#570). A record keeps
its synthesized text, and a generic type is left as it was. 46 of the
runtime's own types write their name now.

EQ2109 is retired. The public surface of `eQuantic.UI.Compiler` replaces
`Eq.AsInteger` and `Eq.AsSingle` with `Eq.AsNumber` and gains
`TypeSymbolExtensions.IsDate`, and `eQuantic.UI.Web` gains
`CultureFormatBridge`. The developer surface does not move.

## Why

The server renders a page in the request's culture, and the browser
wrote numbers and dates its own way, so the SSR markup and the hydrated
page printed one value two ways: `1.5` where the server wrote `1,5` in
pt-BR, a currency with a no-break space where the server wrote a plain
one, a `DateOnly` in a pattern nobody asked for. On an app with no
`.resx`, the browser formatted in its host's locale, and the conformance
suite passed or failed by the machine it ran on.

## OpenSpec

`a-number-and-a-date-print-in-the-pages-culture`, archived in this pull
request. It creates `page-culture` (the format culture a page is in, how
it travels, the invariant culture with none installed, a standard number
format laid out from the data, a calendar's names) and extends
`transpiler-bcl` (a number's text with no specifier, a number's kind)
and `runtime-dates` (the date types and a custom picture). Its design
says what is not here: the culture a server action runs in (#580), a
`DateTimeOffset`'s local time (#626, which #660 has landed since), and a
non-Gregorian default calendar.

## Proof

A/B against main (98c2b18, with #660, #748 and the whole queue landed),
the same 222 conformance cases on both sides, both executed, with main's
side given the culture as main installs it on a page with a catalog, its
best case. 176 fail on main and all 222 pass here: 49 of the 70 number
texts in five cultures, 7 of the 11 number kinds, all 6 standard
layouts, 74 of the 75 date type texts, 38 of the 55 date pictures and 2
of the 5 harness cases. The other 46 already held on main and stay as
pins. Against main at dc4a410 it was 187 of 222: #660 has taught a
date's picture its offset and its kind since, ten cases, and #748 a
builder's interpolated holes their text, one. Main's side was built on
#708's head 78215e9, whose tree is main's (8833abd on both). The
default text's seven cases, run through the module graph an app's build
writes in TypeScript and in JavaScript, fail in both on the head before
them (446542b) and pass here.

The format subset is generated from .NET and replayed through the
runtime byte for byte, in seven cultures, ar-EG and fr-FR among them. It
folded the no-break spaces on both sides, which is how #634 passed it.

The suites ran on a7155cc, a tree the merge of main 98c2b18 left as it
was, since that merge records #708's squash with the ours strategy:
Compiler 1898, Web 1021 against this branch's wiki pages, Server 374,
Conformance 5087 with 1 skipped, and the runtime's 2355 in 170 files
(`tsc` and vitest through the embedded bun). The only commit after it
changes the ledger line's numbers, and the Web suite, the one that reads
the ledger, ran again on it: 1021. The served runtime is 223,575 bytes
gzipped against the 219,741 main serves (3,834 more, +1.74%, main's
bundle built and measured the same way, and its record says the same),
and its budget is regenerated to what this branch serves: the layout of
N, F, C and P and the date types' formatter, the twins, whose numbers
now say their kind, and the runtime types that now write their full
name.

Main moved under this branch more than once, and its pull requests
changed the same files for their own reasons. Each merge keeps both
behaviours.

- #660 taught the formatter a `DateTime`'s kind. This branch had
rewritten the date code around one moment per value, so the kind and the
zone's offset moved into it: the round trip and `K` write `Z`, a local
time's offset or nothing, `z` writes the offset, and `U` converts by the
kind and stays inside the calendar. #660's own cases pass on this
branch's formatter.
- #661 printed a nullable float through `$eq.num.single`. Here the
culture's formatter writes it with the kind `single`, the same digits in
the culture's symbols. Every binding the emitted code declares starts
with a `$`, the record text's arrow included.
- #748 rewrote the builder. Its dispatch stays, a hole of an
interpolated append is decided by the bound operation as #748 decides
it, and the value `Append` and `Insert` write goes through the culture's
text in front of it. So `Append(0.1f)`, `Append(1e21)`, `Append(-0.0)`,
`Append(1e-7)` and `Append(Tone.Warm)` write what .NET writes, the rows
#750 lists, measured through this branch's served runtime. Its last row
stays: an enum held as an `object` still prints its key.
- #731 gave a data twin's record text its members' number kinds and a
delegate for its `ToString` method group. This branch had given the same
text a descriptor per member. They are one `recordText(value, name,
members)` now, each member its name alone or its name with a kind or a
function, and `recordTextGroup` takes the same list.
- #654 names a nested record's twin by its owner, and its text still
prints its C# name.
- #715's null-argument baseline loses 10 of its 401 entries and gains
none. Seven now answer as .NET does: `Convert.ToString` of an object and
of a string, with and without a provider, and a null format handed to
`DateOnly`, `TimeOnly` and `DateTimeOffset.ToString(format, provider)`.
Three are probes the build now refuses with EQ2108, a provider held in a
variable, as it already refuses a `DateTime`'s. Two reasons that no
longer said what the measurement shows are rewritten by hand.

The merge with #667 met two things. Its builder refuses a null string
handed to a ranged overload, as .NET does, and this branch wrote every
bound value through the culture's text, which made that null an empty
string. A value that is text already (a string, a char, a char[], a
builder) now passes as it is, and only a number, a bool, an enum or an
object is written in the culture. It also pinned an exception's
interpolated message as it was before the culture, and that message now
prints the number's hole in the culture. And the Windows runner failed
one case on the previous head, where `AppendLine` writes the host's
`"\r\n"`: the case reads `"\r\n"` as `"\n"` on both sides.

And it runs, on this head: the dashboard sample, built with no warning
and served from its built binary, wrote `R$ 1.234,50 · 98,2% ·
13/08/2026` on its localization screen in pt-BR, byte for byte what the
server rendered, with a plain space after `R$` on both sides, and wrote
it again when the browser rendered the screen after a click. It switched
to es with no reload by fetching `/_equantic/culture/es.json` and wrote
exactly the server's es text, `1.234,50 ¤ · 98,2 % · 13/8/2026` (es is a
neutral culture, with the generic ¤), and its charts' axes after a
client navigation read as the server rendered them (`50.000`,
`100.000`), with no console error.

## 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`)
- [x] 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), the other hosts are yours
- [x] A broken contract has a line in the migration notes (we are in
preview: break freely, hide nothing)
- [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,
and 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 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>
edgarmesquita added a commit that referenced this pull request Oct 11, 2026
<!-- claude-projects-attribution: {"github_login":"edgarmesquita"} -->
_Requested by **DomusAvus** · [project
thread](https://claude.ai/code/project/chan_01HYkHRWGXCkeLtg24Q261sW?thread=cmsg_01HYkHRWGXCkeLtg24Q261sWLavRoGRpXHMLSoMBdziF1J)_

Closes #753
Closes #724
Closes #750

## What

Before: `string.Split` was JavaScript's `split` of its first argument.
`" abcd ef ".Split(new[] { 'a', 'd' })` answered the whole string,
because the array became the text `a,d`. A null separator was the text
"null", and an empty separator, or an empty array, cut the text into its
characters. The count was dropped, `TrimEntries` was ignored, and
`RemoveEmptyEntries` was read from its spelling, so options held in a
variable were lost. `IndexOf` and `LastIndexOf` of a string from a
start, and for a count, were JavaScript's searches, which take no count
and read `lastIndexOf`'s start as where a match may begin:
`"abcabc".LastIndexOf("bc", 4)` answered 4, and `LastIndexOf("a", 5, 2)`
answered 3.

After: every `Split` overload answers what .NET 10 answers:
- one separator (a char or a string) splits at each of its places, and a
null or empty one splits nothing;
- a set of separators (chars or strings, as an array, a collection
expression or one by one) splits at any of them, the first in the set's
order winning at a place, and a null or empty set splits at .NET's white
space;
- the count caps the entries, and the last one takes the rest;
- `TrimEntries` and `RemoveEmptyEntries` apply wherever the value comes
from;
- a negative count and an undefined option throw .NET's exceptions, in
.NET's order.

`IndexOf` and `LastIndexOf` of a string from a start and for a count
give .NET's answers (1 and -1 above), and refuse a range outside the
string and a null value with .NET's exceptions.

How:
- **The twins.** `$eq.text.split` (one separator) and
`$eq.text.splitAny` (a set), in the new `utils/string-split.ts`, follow
.NET 10's `SplitInternal`, `SplitWithoutPostProcessing` and
`SplitWithPostProcessing` step for step. `Split()` is `splitAny` with no
set, so `$eq.text.splitOnWhiteSpace` goes.
- **The lowering.** eqc picks the twin by the bound overload and passes
the count and the options in their parameters' places, as the values
they are, through `ParameterTemplate`, so named arguments land in their
places and run in the order written. Chars a params parameter takes one
by one become an array. A span handed whole fails the build (EQ1004); a
collection expression is an array in the browser.
- **The searches.** The start and count overloads with no comparison go
to the runtime's existing searches by ordinal. That matches the overload
without a start, which already searches by ordinal here where .NET
searches by the current culture (#532).
- **With no model.** The spelling places what it can. A second `Split`
argument held in a name could be either the count or the options, so it
is a build error rather than a guess, as `string.Format`'s first
argument already is.

#750: `StringBuilder.Append` and `Insert` of a float, a double and an
enum already print as .NET prints them since #637, and are pinned now.
The rows the issue lists for an `object` that holds a float or an enum
still print what the browser holds. That is boxing losing the value's
type everywhere (`o.ToString()`, `$"{o}"`, `"" + o`), not the builder,
so it moves to #768, beside #552's identity half.

## Why

#715's null-argument measurement found #753 and #724, and #748's review
found #750. All three are Bugs under #565.

Each .NET answer was measured with a net10.0 probe, and the split's
order of checks was read from dotnet/runtime's `release/10.0`
`String.Manipulation.cs`.

## OpenSpec


`openspec/changes/archive/2026-10-11-a-string-splits-searches-and-appends-as-dotnet`,
archived in this pull request, changes `transpiler-bcl` in two places:
- it adds "A string splits as .NET splits";
- it modifies "A string's search checks its start and its count as .NET
does" to cover the overloads with no comparison.

## Proof

- **Conformance.** `StringSplitAndSearchConformanceTests` is new, with
73 cases run on .NET and in bun: 55 fail at 1850922 and all pass here.
The builder's new theory has 10 cases, all passing on main too, kept as
pins. The full conformance suite passes, with one test skipped. 10
entries leave `null-argument-gaps.baseline.txt` (the `Split` array
overloads and the four searches), and none is added.
- **Runtime.** `dotnet build src/eQuantic.UI.Runtime -t:TestRuntime`
passes: tsc, then 2399 vitest tests, including the new
`string-split.spec.ts`.
- **Compiler.** Compiler.Tests passes, 1914 tests. It includes new cases
for the bound lowering, the span refusal and the no-model spelling. The
BCL audit moves ten verdicts from `native` to `eq`, and the
introduced-functions baseline loses the filter arrow the old
`RemoveEmptyEntries` lowering wrote.
- **Transpiled library.** Four shared components split through the twin
now: `CodeCompletionView`, `CodePatch`, `MarkdownParser` and
`MermaidParser`. Their pins change only on the five lines that call
`Split`.
- **Other suites.** Web.Tests 1021 once the four pins were regenerated
(its wiki guards run against this change's wiki branch), Server.Tests
374, Design.Tests 104, and `dotnet build samples/DefaultUIDashboard` all
pass.
- **Review.** An independent review traced the twin against .NET's
source and the lowering against `ParameterTemplate`. It found no defect
on the model path. On the no-model path it found two silent misreadings
(options held in a name read as the count, and a comparison held in a
name read as a start), both fixed here, each with a case.
- **Runtime size.** The served runtime grows about 680 gzipped bytes
(bun's bundle, gzip -9: 225,409 on main, 226,084 here). The budget's
record moves to 226,675 in a commit that says what the bytes bought.

## 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); the other hosts are yours
- [x] A broken contract has a line in the migration notes (we are in
preview: break freely, hide nothing). No app-facing contract breaks. The
Compiler's public surface replaces the `Eq.SplitOnWhiteSpace` constant
with `StringSplit` and `StringSplitAny`; these are lowering targets that
no app writes
- [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). The
commit is written (SupportedFeatures, EN and pt-BR), but this session
cannot push to the wiki. It waits as a patch for
`fix/a-string-splits-searches-and-appends-as-dotnet` there, and applies
after the two patches before it
- [x] 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
- [x] No "widget" in prose; the project's word is *component*

---------

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

Development

Successfully merging this pull request may close these issues.

A builder member the runtime does not have transpiles, and fails only in the browser

3 participants