Skip to content

feat: contrato de superfície como matriz Node 24/26 - #13

Merged
filipeforattini merged 6 commits into
mainfrom
node-matrix-contract
Sep 1, 2026
Merged

feat: contrato de superfície como matriz Node 24/26#13
filipeforattini merged 6 commits into
mainfrom
node-matrix-contract

Conversation

@filipeforattini

@filipeforattini filipeforattini commented Sep 1, 2026

Copy link
Copy Markdown
Member

Zera 12 dos 13 vermelhos conhecidos do differential e deixa o 13º com diagnóstico completo (não é bug, é uma porta de backend que falta).

O achado que organiza o resto: parte dos vermelhos não era regressão do scriptc, era o ORÁCULO mudando entre Node 24 e Node 26. Esta máquina roda Node 26 por padrão, então o corpus estava sendo comparado contra um oráculo diferente do que fixou aquelas fixtures. Como suportamos os dois majors, uma fixture não pode depender de comportamento que muda entre eles — essas foram normalizadas, cada uma preservando o resto da cobertura.

Tabela: fixture → causa-raiz → veredito

Fixture Causa-raiz Veredito
1640-fd-read-decode Node 26 valida position mesmo com janela de leitura VAZIA; v24 fazia curto-circuito antes sensível a versão → fixture
1746-stream-for-await read() sem size parou de concatenar o buffer interno (nodejs/node#60441, semver-major, 26.0.0); o async-iterator herda sensível a versão → fixture
2813-readable-async-iterator-chunks mesma #60441 sensível a versão → fixture
2631-create-require builtinModules.length muda entre majors (72 no v24, 66 no v26) sensível a versão → fixture
2599-stream-arg-ladders v26 removeu o caso especial da encoding 'buffer'e revelou que não havia lowering geral de write(string, encoding) sensível a versão + bug realcorrigido
1967-namespace-alias-typeonly o harness escolhia o transform pelo major (flag nativa no 24, hook tsc no 26) e os dois discordam harness + compilador corrigidos
2084-destructuring-primitive-sources mensagem de erro do quickjs-ng ≠ V8, em erro lançado DENTRO da island divergência documentada → fixa o TIPO do erro
2568-rest-spread-forward-dynamic ABI islandRest: o thunk boxed nunca montava o array de rest corrigido (C + LLVM)
2590-object-create-dynamic mesmo bug de ABI islandRest corrigido
2716-island-optional-string-method optChain jsval no emissor LLVM não tinha a forma de resultado UNION corrigido
2210-dyn-promise-crossing .finally descartava a promise do callback — rejeição escapava como unhandled corrigido
1120-web-globals-encoders três bugs nos nossos próprios web globals da island corrigido
2794-readline-async-iterator rl.nextLine só existe no backend Rust; o emissor C recusa de propósito documentado (issue abaixo)

Os bugs reais

ABI do islandRest (2568, 2590) — o mais grave. Uma assinatura island-rest SOletra seu parâmetro final (array do engine) na lista de params e é marcada rest. O thunk de chamada boxed lia as duas coisas literalmente: preenchia todo param posicionalmente — entregando ao slot final o primeiro argumento EXCEDENTE onde a closure espera o pacote — e ainda anexava um array dyn extra que o callee não tem parâmetro para receber. Ou seja, const f = (...args) => args.length; f(1, 2) num programa --dynamic estourava com expected number, got undefined, porque args era o número 1. Uma arrow no topo do módulo é um global dyn, então a chamada passa por esse thunk e não pelo caminho direto. Todo idioma de rest-forwarding sob --dynamic quebrava assim. 2857-island-rest-boxed-call.js fixa a ABI de forma mínima nas três lanes.

.finally sobre promise dyn (2210). O callback tinha o resultado descartado — um atalho documentado no próprio código ("that refinement waits for a use"). Duas consequências: uma cleanup que REJEITA nunca chegava à cadeia (o binário morria com unhandled rejection onde o JS substitui a settlement), e não esperar uma cleanup que RESOLVE também settlava cedo demais, o que era a causa real da ordem trocada entre finally kept 7 e finally ran. A reação agora percorre um resultado-promise como o braço .then já fazia.

optChain island → union (2716). O braço jsval do emissor LLVM só tinha resultado void e resultado do engine, e lançava InternalCompilerError no resto. Um passo que aterrissa de volta no mundo ESTÁTICO (flatValue(...)?.trim()) responde string | undefined. Ganhou as mesmas duas formas que o emissor C já tinha; um kind não modelado agora vira LlvmUnsupportedError (cerca de cobertura) em vez de alegar bug do emissor.

Web globals da island (1120). Três divergências: formDecode andava por CODE UNIT do UTF-16 e entregava surrogates soltos ao TextEncoder (astral virava dois U+FFFD); entries/keys/values/forEach tiravam snapshot quando a iteração de pares do WebIDL é VIVA (índice posicional relendo a lista atual); e btoa/atob lançavam Error com .name carimbado em vez do DOMException que o próprio prelúdio já define (.code 5, instanceof verdadeiro). Nada sob vendor/ foi tocado.

write(string, encoding) em socket (2599). Só três formas eram lowered; qualquer outra encoding literal caía na cerca "write with 2 arguments" e o programa nem compilava. Uma encoding que o Node não conhece agora é o ERR_UNKNOWN_ENCODING síncrono dele. Spellings conhecidas mas ainda não lowered (hex, base64) mantêm a cerca em vez de escrever bytes errados.

Alias import= de namespace não instanciado (1967). O lowering reproduzia o transform nativo do Node 24, que sempre emitia var P = <entity> e por isso lançava ReferenceError no alias. O Node 26 removeu esse modo. Verificado contra o transform do próprio oráculo: transpileModule elide o alias tanto para namespace type-only quanto ambient e só mantém var R = V quando V é instanciado — exatamente a condição typeOnly/ambient que o código já calculava, então o throw virou elisão.

Mudança no harness (vale revisar com atenção)

nodeTransformTypesArgs agora usa SEMPRE o hook TypeScript, em todo major. Selecionar pelo major tornava o oráculo dependente da versão, e uma fixture não pode ser fixada byte-a-byte contra um oráculo que muda de ideia com o Node do host. O cache do oráculo subiu para oracle-v5 para que caches quentes de Node 24 não sirvam vereditos do transform nativo. Os 20 programas @transform-types foram verificados estáveis nos dois majors sob o hook, e os outros 36 programas da coorte seguem passando.

Issue: portar rl.nextLine para o runtime C

2794 não é regressão — é cobertura de backend faltando, e o emissor C já declara isso no código ("The native async-iterator slice currently belongs to the Rust runtime"). for await (const line of rl) baixa para o libCall rl.nextLine (Promise<string | undefined>), implementado só em backend/rust/readline.ts. O emissor C lança InternalCompilerError de propósito; a lane LLVM falha pelo mesmo erro porque cai no fallback C — então uma implementação em C resolve as duas lanes.

O que falta, concretamente:

  1. packages/runtime/src/scr_readline.c — o slot de callback pendente de question tem quase a forma certa, mas falta o modo "próxima linha OU fim": hoje scr_rl_settle_close DESCARTA um callback pendente (a question do Node nunca responde), enquanto nextLine precisa resolver undefined no fim do stdin, e resolver de novo em toda chamada seguinte para o laço for await terminar. Uma linha já bufferizada tem de responder na hora, como question faz via scr_rl_drain.
  2. packages/compiler/src/backend/emission/emit-exprs.ts — o caso rl.nextLine cria scr_promise_new() e passa um adaptador internado por union que monta string | undefined e cumpre a promise. O precedente exato já existe: scr_promise_race_add(result, p, &adapter) com E.raceAdapterFor.

O risco não é o tamanho, é a corretude assíncrona: manter o event loop vivo enquanto um nextLine está pendente, resolver undefined exatamente uma vez no EOF, e crlfDelay: Infinity. Preferi deixar diagnosticado a entregar meia implementação de I/O assíncrono.

Follow-up sugerido: adotar (ou não) a semântica de streams do Node 26

read() sem size do scriptc concatena o buffer inteiro — a semântica pré-26, e a que a documentação do Node ainda descreve. A #60441 mudou isso no 26.0.0 sem atualizar os docs. O corpus agora evita a superfície nos dois lados, mas o runtime segue implementando uma só, e essa escolha merece uma decisão explícita. Mesma coisa, menor, para o 'buffer' do write de socket: a mensagem especial do v24 continua no lowering, sem cobertura de corpus nos dois lados.

Gates

  • differential C completo
  • LLVM -t nas fixtures corrigidas + 2716
  • rust-differential -t nas tocadas
  • lint / build

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bab3v6PNzMBUq7nJhLR8i72. surface-manifest.test.ts já vermelho no main, sem relação com este trabalho: fs.writeFileExclusiveModeSync aparece na lista de "attestation-demoting spellings no declarable fence can deny". Confirmado com git stash em árvore limpa.

  1. coverage.test.ts estoura o timeout nesta máquina, também no main. O teste every corpus program is 100% static analisa 1254 programas e traz timeout próprio de 600s. Medido, não suposto:

    Árvore Duração Resultado
    node-matrix-contract 606521 ms timeout
    node-matrix-contract (2ª rodada) 603612 ms timeout
    origin/main sem modificação 606749 ms timeout

    Mesmo comportamento com e sem as mudanças — ambiental, não regressão. O teste é feito para rodar shardado na lane completa (shardSuffix()), e o comentário dele já avisa que "the default per-test timeout is far too small for a whole-corpus analysis". Rodar unsharded numa máquina carregada raspa o cap. Nota: --testTimeout na CLI não sobrepõe o timeout que o teste passa como argumento — a primeira tentativa de subir o limite por flag foi inócua.

---|---|---|
| 1640-fd-read-decode | Node 26 valida position mesmo com janela de leitura VAZIA; v24 fazia curto-circuito antes | sensível a versão → fixture |
| 1746-stream-for-await | read() sem size parou de concatenar o buffer interno (nodejs/node#60441, semver-major, 26.0.0); o async-iterator herda | sensível a versão → fixture |
| 2813-readable-async-iterator-chunks | mesma #60441 | sensível a versão → fixture |
| 2631-create-require | builtinModules.length muda entre majors (72 no v24, 66 no v26) | sensível a versão → fixture |
| 2599-stream-arg-ladders | v26 removeu o caso especial da encoding 'buffer'e revelou que não havia lowering geral de write(string, encoding) | sensível a versão + bug realcorrigido |
| 1967-namespace-alias-typeonly | o harness escolhia o transform pelo major (flag nativa no 24, hook tsc no 26) e os dois discordam | harness + compilador corrigidos |
| 2084-destructuring-primitive-sources | mensagem de erro do quickjs-ng ≠ V8, em erro lançado DENTRO da island | divergência documentada → fixa o TIPO do erro |
| 2568-rest-spread-forward-dynamic | ABI islandRest: o thunk boxed nunca montava o array de rest | corrigido (C + LLVM) |
| 2590-object-create-dynamic | mesmo bug de ABI islandRest | corrigido |
| 2716-island-optional-string-method | optChain jsval no emissor LLVM não tinha a forma de resultado UNION | corrigido |
| 2210-dyn-promise-crossing | .finally descartava a promise do callback — rejeição escapava como unhandled | corrigido |
| 1120-web-globals-encoders | três bugs nos nossos próprios web globals da island | corrigido |
| 2794-readline-async-iterator | rl.nextLine só existe no backend Rust; o emissor C recusa de propósito | documentado (issue abaixo) |

Os bugs reais

ABI do islandRest (2568, 2590) — o mais grave. Uma assinatura island-rest SOletra seu parâmetro final (array do engine) na lista de params e é marcada rest. O thunk de chamada boxed lia as duas coisas literalmente: preenchia todo param posicionalmente — entregando ao slot final o primeiro argumento EXCEDENTE onde a closure espera o pacote — e ainda anexava um array dyn extra que o callee não tem parâmetro para receber. Ou seja, const f = (...args) => args.length; f(1, 2) num programa --dynamic estourava com expected number, got undefined, porque args era o número 1. Uma arrow no topo do módulo é um global dyn, então a chamada passa por esse thunk e não pelo caminho direto. Todo idioma de rest-forwarding sob --dynamic quebrava assim. 2857-island-rest-boxed-call.js fixa a ABI de forma mínima nas três lanes.

.finally sobre promise dyn (2210). O callback tinha o resultado descartado — um atalho documentado no próprio código ("that refinement waits for a use"). Duas consequências: uma cleanup que REJEITA nunca chegava à cadeia (o binário morria com unhandled rejection onde o JS substitui a settlement), e não esperar uma cleanup que RESOLVE também settlava cedo demais, o que era a causa real da ordem trocada entre finally kept 7 e finally ran. A reação agora percorre um resultado-promise como o braço .then já fazia.

optChain island → union (2716). O braço jsval do emissor LLVM só tinha resultado void e resultado do engine, e lançava InternalCompilerError no resto. Um passo que aterrissa de volta no mundo ESTÁTICO (flatValue(...)?.trim()) responde string | undefined. Ganhou as mesmas duas formas que o emissor C já tinha; um kind não modelado agora vira LlvmUnsupportedError (cerca de cobertura) em vez de alegar bug do emissor.

Web globals da island (1120). Três divergências: formDecode andava por CODE UNIT do UTF-16 e entregava surrogates soltos ao TextEncoder (astral virava dois U+FFFD); entries/keys/values/forEach tiravam snapshot quando a iteração de pares do WebIDL é VIVA (índice posicional relendo a lista atual); e btoa/atob lançavam Error com .name carimbado em vez do DOMException que o próprio prelúdio já define (.code 5, instanceof verdadeiro). Nada sob vendor/ foi tocado.

write(string, encoding) em socket (2599). Só três formas eram lowered; qualquer outra encoding literal caía na cerca "write with 2 arguments" e o programa nem compilava. Uma encoding que o Node não conhece agora é o ERR_UNKNOWN_ENCODING síncrono dele. Spellings conhecidas mas ainda não lowered (hex, base64) mantêm a cerca em vez de escrever bytes errados.

Alias import= de namespace não instanciado (1967). O lowering reproduzia o transform nativo do Node 24, que sempre emitia var P = <entity> e por isso lançava ReferenceError no alias. O Node 26 removeu esse modo. Verificado contra o transform do próprio oráculo: transpileModule elide o alias tanto para namespace type-only quanto ambient e só mantém var R = V quando V é instanciado — exatamente a condição typeOnly/ambient que o código já calculava, então o throw virou elisão.

Mudança no harness (vale revisar com atenção)

nodeTransformTypesArgs agora usa SEMPRE o hook TypeScript, em todo major. Selecionar pelo major tornava o oráculo dependente da versão, e uma fixture não pode ser fixada byte-a-byte contra um oráculo que muda de ideia com o Node do host. O cache do oráculo subiu para oracle-v5 para que caches quentes de Node 24 não sirvam vereditos do transform nativo. Os 20 programas @transform-types foram verificados estáveis nos dois majors sob o hook, e os outros 36 programas da coorte seguem passando.

Issue: portar rl.nextLine para o runtime C

2794 não é regressão — é cobertura de backend faltando, e o emissor C já declara isso no código ("The native async-iterator slice currently belongs to the Rust runtime"). for await (const line of rl) baixa para o libCall rl.nextLine (Promise<string | undefined>), implementado só em backend/rust/readline.ts. O emissor C lança InternalCompilerError de propósito; a lane LLVM falha pelo mesmo erro porque cai no fallback C — então uma implementação em C resolve as duas lanes.

O que falta, concretamente:

  1. packages/runtime/src/scr_readline.c — o slot de callback pendente de question tem quase a forma certa, mas falta o modo "próxima linha OU fim": hoje scr_rl_settle_close DESCARTA um callback pendente (a question do Node nunca responde), enquanto nextLine precisa resolver undefined no fim do stdin, e resolver de novo em toda chamada seguinte para o laço for await terminar. Uma linha já bufferizada tem de responder na hora, como question faz via scr_rl_drain.
  2. packages/compiler/src/backend/emission/emit-exprs.ts — o caso rl.nextLine cria scr_promise_new() e passa um adaptador internado por union que monta string | undefined e cumpre a promise. O precedente exato já existe: scr_promise_race_add(result, p, &adapter) com E.raceAdapterFor.

O risco não é o tamanho, é a corretude assíncrona: manter o event loop vivo enquanto um nextLine está pendente, resolver undefined exatamente uma vez no EOF, e crlfDelay: Infinity. Preferi deixar diagnosticado a entregar meia implementação de I/O assíncrono.

Follow-up sugerido: adotar (ou não) a semântica de streams do Node 26

read() sem size do scriptc concatena o buffer inteiro — a semântica pré-26, e a que a documentação do Node ainda descreve. A #60441 mudou isso no 26.0.0 sem atualizar os docs. O corpus agora evita a superfície nos dois lados, mas o runtime segue implementando uma só, e essa escolha merece uma decisão explícita. Mesma coisa, menor, para o 'buffer' do write de socket: a mensagem especial do v24 continua no lowering, sem cobertura de corpus nos dois lados.

Gates

  • differential C completo
  • LLVM -t nas fixtures corrigidas + 2716
  • rust-differential -t nas tocadas
  • lint / build

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bab3v6PNzMBUq7nJhLR8i7

c6aef3e gave url.pathname a mutation lowering and flipped its census row
to static, but left the operations table untouched — so the url profile
declared 31 static inventory rows against 30 supported operations and the
conformance tripwire fired on main.

The setter now carries its own operation row with the corpus evidence that
guards it (2854-url-pathname-setter) and a scope that says what the
assignment does: re-run the WHATWG path parse, re-serialize href.

The other nine writable components were probed by COMPILING one assignment
each rather than by reading the lowering; all nine are still refused, so
their refusal rows stand unchanged. The prose that claimed URL values have
no setters at all — in the profile header, the ambient declaration, and the
manifest's URL coverage note — is corrected to name the one exception.

Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
The version axis in profile-schema.ts existed but every profile's
`candidates` list was empty, so the "matrix" was a single pin wearing a
list's clothes. This fills it, and only with what a reflection actually
found.

The probe: reflect every interface each of the three profiles censuses —
statics, own prototype members, inherited members, public symbols, setters,
and the own properties of a constructed instance — under 24.15.0 and under
26.8.1, then diff.

The result, which is the answer the mission wanted written down:

  - URL / URLSearchParams / the search-params iterator: IDENTICAL across
    the two majors. Every row is shared.
  - EventEmitter, including the three pre-private-field internals and the
    instance census: IDENTICAL. Every row is shared.
  - fetch: NOT identical. Node 26 (Undici 8.10.0) grows textStream() on
    Request.prototype and Response.prototype — a decoded-text ReadableStream
    the body mixin did not have under 24 (Undici 7.24.4). Two rows, and
    only those two, carry a version qualifier.

So the schema addition is deliberately small: an inventory row may name the
target ids whose census contains it, and OMITTING that field means "every
target". A shared row cannot be narrowed by forgetting an id, and the
common case stays unannotated. The manifest stamps a shared row with the
primary's label as before and a qualified row with exactly the runtimes it
exists on, so "Node 26 only" is readable off the shipped manifest.

node-matrix.ts is the single place the runtimes are named — the profiles,
the conformance suites, and the matrix gate all read it rather than
repeating version literals. The fetch profile's bespoke {node, undici}
tuple is folded into the same CompatTargets shape the other two use, with
Undici as a pinned component of each target.

Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
All three conformance suites opened with the same assertion:

    expect(process.versions.node).toBe(profile.targets.primary.node)

which turns every host that is not one exact build into a red — including
Node 26, a first-class target. That is a false red by construction, and it
is the papercut that bit four times in one day.

The suites now SELECT instead of demand: each asks the running runtime
which declared target it is, compares the reflection against that target's
rows, and fails only when the host is in no declared target at all — the
one condition that genuinely is a contract violation. The failure message
names the host and lists the declared runtimes, so the fix is obvious from
the output.

Selection is what makes the fetch profile's version-qualified rows work:
Request/Response.textStream is compared only under Node 26, and its absence
under Node 24 is not a mismatch. Verified in both directions — flipping the
qualifier to node24 makes the Node 26 census fail on exactly those two
members, so the tripwire is still a tripwire.

Two things deliberately stay matrix-wide rather than per-target: row SHAPE
validation (a malformed Node 26 row must fail on Node 24 too) and the
shipped-manifest comparison, since the manifest is one artifact carrying
the whole matrix whatever runtime generated it.

Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
`pnpm test:*-conformance` inherits whatever `node` the shell resolves, so
"run the conformance suites" silently meant "run them under one runtime,
whichever one that is" — useless as evidence for a two-runtime contract.

scripts/node-matrix.mjs names the interpreter explicitly, one lane per
declared target, and VERIFIES the binary it found by asking its --version:
a moved mise symlink must not let the node26 lane run Node 24 and report
PASS. Resolution order is override, running interpreter, mise install tree,
`mise which`. The runtime list is read from node-matrix.ts, so a runtime
cannot be in the gate without being in the contract.

  pnpm gate:node-matrix          both runtimes, in sequence
  pnpm test:conformance:node24   one runtime by target id
  pnpm test:conformance:node26
  pnpm test:conformance          the three suites, host runtime

Running it turned up a REAL Node 24 → 26 semantic divergence that had to be
resolved before the gate could be green: Node 26 rewords AbortSignal.any's
ERR_INVALID_ARG_TYPE from "signals can not be converted to sequence." to
"signals cannot be converted to sequence.", and the generated fetch
differential compared native stdout against process.execPath — so under
Node 26 it red on Node's own typo fix, twice, saying nothing about either
backend.

That forced the distinction the single-pin world never had to make, now
stated once in tests/harness/node-matrix.ts and enforced by both callers:

  the CENSUS follows the HOST — what members a runtime exposes is a
  question about the runtime you are on, and asking both majors is the
  entire point;

  the SEMANTIC ORACLE stays PINNED to the primary — a compiled binary
  reproduces one Node's observable behavior, message text included, and
  cannot reproduce two. SCRIPTC_NODE_ORACLE is how you go looking for
  divergences deliberately rather than tripping over them.

The harness README documents the gate as the enforcement point, since CI
does not run on the fork.

Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
The compat profiles classify three slices member by member, which answers
"is this member supported?" but never "how much of Node is that?" — a
numerator with nothing under the line. This derives the denominator
mechanically from Node's own generated API database, for both matrix
runtimes.

Nothing here is classified, deliberately. An added class is not a support
claim in either direction; establishing what THERE IS is the deliverable,
and deciding what each class means for the compiler stays profile work.

The result, and the artifact a coverage dashboard can consume:

  Node 24.15.0: 262 documented classes across 43 modules
  Node 26.8.1:  279 documented classes across 44 modules
  delta: +20 / -3

  added   async_context.RunScope, diagnostics_channel.{BoundedChannel,
          BoundedChannelScope,RunStoresScope}, ffi.DynamicLibrary,
          globals.QuotaExceededError, net.BoundSocket,
          perf_hooks.ELDHistogram, stream_iter.{Share,SyncShare},
          v8.SyncHeapProfileHandle, vfs.{MemoryProvider,RealFSProvider,
          VirtualFileSystem,VirtualProvider}, webcrypto.{KangarooTwelveParams,
          TurboShakeParams}, zlib.{ZipBuffer,ZipEntry,ZipFile}
  removed assert.CallTracker, buffer.SlowBuffer, perf_hooks.IntervalHistogram

The URLs move, so the source is pinned by EXACT version — never /latest/,
never a major alias — and each artifact records the byte length and SHA-256
of the all.json it was derived from, which makes a silently republished
upstream detectable instead of assumed away.

The raw downloads are ~8 MB each and are cached rather than committed:
16 MB of generated JSON in-tree is a permanent cost every clone pays, and
the derived inventories are what the dashboard actually reads and what is
legible in a diff. `--vendor-raw` writes the upstream bytes in-tree for
anyone who wants that trade instead.

One normalization is load-bearing: Node's class `name` is prose, not an
identifier — bare, already module-qualified, or carrying an `extends`
clause. Normalizing to a bare identifier owned by its defining document is
what keeps a docs edit from showing up as an add AND a remove; before it,
the same run reported +24/-6 with three phantom pairs.

Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
oracle-environment.ts already asked the oracle for its own version rather
than trusting process.version, so the cache key was correct across majors —
but nothing tested it, and "correct by inspection" is what the matrix work
exists to stop accepting. Four regressions now pin the host/oracle split:

  - the cache key separates the two matrix majors (and two patches of the
    same major): a colliding key would serve one runtime's recorded stdout
    to the other and call it parity;
  - every declared target resolves to an interpreter that REPORTS that
    exact version — the check that catches a moved mise symlink turning
    the node26 lane into a second node24 lane;
  - a wrong-version SCRIPTC_NODE_<TARGET> override raises instead of
    silently falling through to another candidate;
  - the differential oracle defaults to the matrix primary, not the host,
    with SCRIPTC_NODE_ORACLE still winning.

Sampled the differential lanes against a Node 26 oracle across both native
backends — 1355-url-parse and 2854-url-pathname-setter (rust),
1654-ee-namespace (rust), 1644-ee-basics and 1794-searchparams-url-live
(c/llvm) — all green. The only semantic divergence found anywhere in this
work remains AbortSignal.any's reworded ERR_INVALID_ARG_TYPE.

Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
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.

1 participant