diff --git a/docs/current/SEMANTIC_OWNERSHIP.md b/docs/current/SEMANTIC_OWNERSHIP.md index e903506fd..80e95de91 100644 --- a/docs/current/SEMANTIC_OWNERSHIP.md +++ b/docs/current/SEMANTIC_OWNERSHIP.md @@ -16,36 +16,39 @@ evolves specific rows of this table through its issue tree (#1209-#1220). The table remains the Alpha.9 baseline until it is re-baselined at Alpha.10 closure. -| Concept | Canonical owner and representation | Executors, alternatives, and accepted decision | Parity evidence and known divergence | Forbidden duplicate ownership; issue | -| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| Component identity | Bundler-neutral compiler semantic core; decorator-derived tag/class records in Part Program metadata | Vite discovers modules and registers the compiled class; Element consumes identity | Compiler boundary, deterministic fixture, CEM records; closed | No Vite regex identity grammar or runtime tag inference; #1208 | -| OpenElement language semantics | Compiler semantic core; admitted decorator, property, computed, TSX, Part and Region grammar | Vite hook is integration only; unsupported syntax fails closed | `compiler-semantic-core-boundary.test.ts`, `compiler-fail-closed-matrix.test.ts`, `compiled-element-v1.test.ts`; closed | No bundler-owned parser or runtime JSX fallback; #1208, #1204 | -| Part Program v1 | Compiler `semantic-core/program.ts`; exact versioned serialized wire artifact | Element has an independent wire validator and all executors consume the validated artifact | Bidirectional validator corpus and deterministic fixture; competing runtime-only grammar removed | No Element-only v1 instruction or compatibility grammar; #1208 | -| RuntimeProgramIR | Element `runtime-program.ts`; normalized, recursively frozen representation created only after exact wire validation | Fresh DOM, claim, reactive runtime and server serializers | `part-program-validation-adversarial.test.ts`, compiled runtime/server suites; closed | No direct cast or widening before validation; #1208 | -| Part, Region, anchor, dependency and location identity | Part Program v1 ownership/location tables | Element indexes those records but does not rediscover them | Adversarial duplicate, missing, misplaced and malformed-record matrix; closed | No executor-generated identity or alternate condition representation; #1208 | -| Signals and reactive invalidation | Element signal engine; dependency records map signals to owning Parts/Regions | Fresh and claimed DOM executors subscribe through the same runtime context | Part/Region update, keyed collection and disposal suites; closed | No VNode walk or adapter-level reactive graph; #1208 | -| Context discovery and transport | Community Context Protocol `context-request` composed/bubbling event | Element Signal bridge provides/consumes values; Lit provider/consumer is the conforming alternative | `signal-context.test.ts` including Lit interop, reconnect and disposal; private ancestry walk removed | No parentNode/root-host discovery or global context registry; #1205 | -| DOM context propagation and lifecycle | Platform event propagation plus Element connection-scoped subscriptions | Shadow/light DOM and reconnect are ordinary protocol cases | Compiled context lifecycle matrix; closed | No transport tied to one renderer or permanent disconnected subscription; #1205 | -| SSR initial DOM, fresh DOM, claim and activation | Element compiled server/runtime/claim semantics over one program | Server string serializer, fresh builder and existing-DOM claimant are alternative executors | Part Program conformance, compiled server/fresh/claim/update suites; closed | No second renderer, generic hydration or binding discovery; #1203 | -| Nested component composition, slots and DSD | Element compiled serializer; structured nested records and admitted tags | Vite supplies registry modules and immutable admission list only | `compiled-composition.test.ts`, `nested-static-ssr.test.ts`; duplicate Vite renderer removed; accepted divergence: components embedded in opaque Trusted HTML content stay SSR-inert and upgrade client-side (#1203) | No adapter reparsing or independent slot/DSD semantics; #1203 | -| Trusted HTML | Caller/compiler creates `TrustedHtml` identity capability; Element sink validates identity | SSR, fresh DOM, claim and public light-child projection consume the same capability | Trusted sink matrix and forged-projection rejection; raw-string projection leak fixed | No string branding, Vite sanitizer, or HTML-based component discovery; #1203 | -| Document, head and body serialization | Element `wrapInDocument`; structured options to deterministic document HTML | Generated Hono and SSG entries call it | `html-escape.test.ts`, entry generation and request-time suites; duplicate entry wrapper removed | No generated document renderer; #1203 | -| CSS and visual theme | CSS cascade, Custom Properties, authored styles and scoped compiled light CSS | Shadow/light style installation is an Element projection; Context may carry theme identity only | StyleSheet, compiled style and website browser suites; closed | No Context recreation of color, spacing, typography, radii or visual-token inheritance; #1201 | -| Route pathname grammar | Native `URLPattern`, or admitted `urlpattern-polyfill` with the same corpus | App `RouteTable` compiles route declarations once | Native/polyfill adversarial `client-router.test.ts`; legacy regex/trie matcher removed | No SPA-only pathname parser; #1204 | -| Route matching, order, params, query, base and trailing policy | App `RouteTable`; ordered route records and URLPattern results | SPA, generated Hono/request-time, SSG and Nitro project the same route records | Route parity corpus plus request-time browser matrix; closed | No transport-specific precedence or unsafe-param map; #1204, #1206 | -| Request and Response semantics | Web platform `Request`, `Response`, `Headers`, `FormData`, `URL` | Hono executes requests; Nitro adapts deployment transports | HTTP bridge, malformed-body, headers, abort/cancel and Nitro proof suites; closed | No framework-private HTTP value model; #1206 | -| Loader | App authoring contract and outcome classification | SPA and generated Hono handlers project the result | App authoring/SPA/request-time parity; closed | No adapter-specific loader result grammar; #1206 | -| Action | App `classifyActionResult`; `ActionOutcome` discriminated representation | SPA and generated Hono action runtime consume the classifier | App authoring/SPA/entry-renderer/request-time suites; prior `fail()` shape divergence fixed | No Hono-only `.data` interpretation or raw `Response` action result; #1206 | -| Redirect, NotFound, expected failure and Problem Details | App authoring control/outcome semantics | SPA navigation and Hono response projection are alternative executors | App outcome matrix, action protocol and request-time parity; closed | No transport-owned outcome classification; #1206 | -| Hono execution | Hono request/middleware execution model | Generated entry binds App-owned route/outcome semantics to Hono | Request-time fixture and browser suites; closed | Hono does not own App classification; #1206 | -| Nitro deployment | Nitro adapters and generated deployment artifacts | Node server and Cloudflare module are environment alternatives | `nitro:proof:node`, `nitro:proof:workers`; closed | Nitro does not redefine routes, outcomes or documents; #1206 | -| Compiler-known interaction facts | Compiler semantic analysis and emitted event records | Client admission aggregation reads facts directly | Module-analysis, scanner and compiler determinism suites; closed | No Vite AST rediscovery of compiler-owned behavior; #1202, #1207 | -| Explicit Island and imperative behavior policy | Author declaration validated by adapter protocol | Client admission aggregation and delivery strategy projection | Island scanner/delivery and exact client-output tests; closed | No assumption that imperative browser behavior is compiler-knowable; #1202 | -| Third-party delivery capability | Package manifest and CEM classification | Adapter aggregates package islands and foreign-tag admission | CEM compatibility, foreign-tag and package-island tests; closed | No guessing third-party behavior from OpenElement compiler data; #1202 | -| Client reachability, activation and zero-JS | Adapter `client-admission.ts`; deterministic union of route reachability, compiler facts, explicit policy and third-party declarations | Vite chunking/client entry generation implements the plan | Admission, generator, exact-output, static-only zero-runtime and three-browser suites; false zero-JS for interactive static components fixed | No hidden 0.43 island fallback or independent compiler-event scan; #1202 | -| Vite build, HMR, resolution and source-map composition | Vite integration layer; module graph, cache and generated source maps | Bundler-neutral compiler returns code, diagnostics and source records | HMR/delivery, direct-core, source-map and build suites; closed | Compiler core must not import Vite or invent a generic bundler abstraction; #1207 | -| Source maps and diagnostics | Compiler semantic core owns source spans/records; Vite composes standard module maps | Browser/tooling consumes generated maps | Frozen source-map fixture, located fail-closed diagnostics and HMR tests; closed | No adapter recreation of semantic locations; #1207 | -| Public metadata and package interface | Package root exports, package manifests and checked interface snapshot | Docs, packed packages, Starter and third-party consumers | Interface snapshot, packed dry run and packed-consumer qualification; pending only the final exact-head rerun | No unpublished deep-import contract or stale provisional name; #1201 | +| Concept | Canonical owner and representation | Executors, alternatives, and accepted decision | Parity evidence and known divergence | Forbidden duplicate ownership; issue | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Component identity | Bundler-neutral compiler semantic core; decorator-derived tag/class records in Part Program metadata | Vite discovers modules and registers the compiled class; Element consumes identity | Compiler boundary, deterministic fixture, CEM records; closed | No Vite regex identity grammar or runtime tag inference; #1208 | +| OpenElement language semantics | Compiler semantic core; admitted decorator, property, computed, TSX, Part and Region grammar | Vite hook is integration only; unsupported syntax fails closed | `compiler-semantic-core-boundary.test.ts`, `compiler-fail-closed-matrix.test.ts`, `compiled-element-v1.test.ts`; closed | No bundler-owned parser or runtime JSX fallback; #1208, #1204 | +| Part Program v1 | Compiler `semantic-core/program.ts`; exact versioned serialized wire artifact | Element has an independent wire validator and all executors consume the validated artifact | Bidirectional validator corpus and deterministic fixture; competing runtime-only grammar removed | No Element-only v1 instruction or compatibility grammar; #1208 | +| RuntimeProgramIR | Element `runtime-program.ts`; normalized, recursively frozen representation created only after exact wire validation | Fresh DOM, claim, reactive runtime and server serializers | `part-program-validation-adversarial.test.ts`, compiled runtime/server suites; closed | No direct cast or widening before validation; #1208 | +| Part, Region, anchor, dependency and location identity | Part Program v1 ownership/location tables | Element indexes those records but does not rediscover them | Adversarial duplicate, missing, misplaced and malformed-record matrix; closed | No executor-generated identity or alternate condition representation; #1208 | +| Signals and reactive invalidation | Element signal engine; dependency records map signals to owning Parts/Regions | Fresh and claimed DOM executors subscribe through the same runtime context | Part/Region update, keyed collection and disposal suites; closed | No VNode walk or adapter-level reactive graph; #1208 | +| Context discovery and transport | Community Context Protocol `context-request` composed/bubbling event | Element Signal bridge provides/consumes values; Lit provider/consumer is the conforming alternative | `signal-context.test.ts` including Lit interop, reconnect and disposal; private ancestry walk removed | No parentNode/root-host discovery or global context registry; #1205 | +| DOM context propagation and lifecycle | Platform event propagation plus Element connection-scoped subscriptions | Shadow/light DOM and reconnect are ordinary protocol cases | Compiled context lifecycle matrix; closed | No transport tied to one renderer or permanent disconnected subscription; #1205 | +| SSR initial DOM, fresh DOM, claim and activation | Element compiled server/runtime/claim semantics over one program | Server string serializer, fresh builder and existing-DOM claimant are alternative executors | Part Program conformance, compiled server/fresh/claim/update suites; closed | No second renderer, generic hydration or binding discovery; #1203 | +| Nested component composition, slots and DSD | Element compiled serializer; structured nested records and admitted tags | Vite supplies registry modules and immutable admission list only | `compiled-composition.test.ts`, `nested-static-ssr.test.ts`; duplicate Vite renderer removed; accepted divergence: components embedded in opaque Trusted HTML content stay SSR-inert and upgrade client-side (#1203) | No adapter reparsing or independent slot/DSD semantics; #1203 | +| Trusted HTML | Caller/compiler creates `TrustedHtml` identity capability; Element sink validates identity | SSR, fresh DOM, claim and public light-child projection consume the same capability | Trusted sink matrix and forged-projection rejection; raw-string projection leak fixed | No string branding, Vite sanitizer, or HTML-based component discovery; #1203 | +| Document, head and body serialization | Element `wrapInDocument`; structured options to deterministic document HTML | Generated Hono and SSG entries call it | `html-escape.test.ts`, entry generation and request-time suites; duplicate entry wrapper removed | No generated document renderer; #1203 | +| Compiled serializer text-node escaping | Element `internal/compiled/escape-text.ts`; reduced contract (`&`, `<`, `>` only — quotes pass through in text content) | Runtime seed serializer and server serializer both call the shared helper; the two private copies converged | `compiled-escape-parity.test.ts` byte-level text corpus (static text nodes and text Parts across both serializers); closed. The entity-preserving `escapeText` in `sanitize.ts` is a deliberately different contract, not this surface | No per-serializer private copy or quote-escaping text contract; #1272 | +| CSS and visual theme | CSS cascade, Custom Properties, authored styles and scoped compiled light CSS | Shadow/light style installation is an Element projection; Context may carry theme identity only | StyleSheet, compiled style and website browser suites; closed | No Context recreation of color, spacing, typography, radii or visual-token inheritance; #1201 | +| Route pathname grammar | Native `URLPattern`, or admitted `urlpattern-polyfill` with the same corpus | App `RouteTable` compiles route declarations once | Native/polyfill adversarial `client-router.test.ts`; legacy regex/trie matcher removed | No SPA-only pathname parser; #1204 | +| Route matching, order, params, query, base and trailing policy | App `RouteTable`; ordered route records and URLPattern results | SPA, generated Hono/request-time, SSG and Nitro project the same route records | Route parity corpus plus request-time browser matrix; closed | No transport-specific precedence or unsafe-param map; #1204, #1206 | +| Route file-convention grammar and declaration ordering | Adapter `route-scanner.ts`; `parseRouteFilePath` (`[id]`→`:id`, `[...path]`→`:path{.+}`, index stripping, `api/` classification) and the static-first sort (special files last, then static-before-dynamic, then lexicographic) | App `RouteTable` honors the declaration order through index-based precedence; SSG, request-time codegen and the SPA route manifest consume the same scanned records | `route-scanner-*.test.ts`, `route-manifest.test.ts`, `request-time-parity.test.ts` (dev-vs-build semantic parity); closed | No second file-convention grammar or transport-specific declaration ordering; #1270 | +| Renderer scope matching | Adapter `entry-route-helpers.ts` `rendererScopeMatches`; case-sensitive exact-or-`scope/` boundary-prefix predicate | Generated `__matchingRenderers` re-expresses the predicate inside self-contained generated entries, which cannot import adapter internals (derived projection) | `renderer-scope-parity.test.ts` binding corpus evaluates the generated matcher verbatim against the predicate (exact, prefix, nested, non-match, boundary separators, case); closed | No second runtime scope grammar or per-transport scope policy; #1271 | +| Request and Response semantics | Web platform `Request`, `Response`, `Headers`, `FormData`, `URL` | Hono executes requests; Nitro adapts deployment transports | HTTP bridge, malformed-body, headers, abort/cancel and Nitro proof suites; closed | No framework-private HTTP value model; #1206 | +| Loader | App authoring contract and outcome classification | SPA and generated Hono handlers project the result | App authoring/SPA/request-time parity; closed | No adapter-specific loader result grammar; #1206 | +| Action | App `classifyActionResult`; `ActionOutcome` discriminated representation | SPA and generated Hono action runtime consume the classifier | App authoring/SPA/entry-renderer/request-time suites; prior `fail()` shape divergence fixed | No Hono-only `.data` interpretation or raw `Response` action result; #1206 | +| Redirect, NotFound, expected failure and Problem Details | App authoring control/outcome semantics | SPA navigation and Hono response projection are alternative executors | App outcome matrix, action protocol and request-time parity; closed | No transport-owned outcome classification; #1206 | +| Hono execution | Hono request/middleware execution model | Generated entry binds App-owned route/outcome semantics to Hono | Request-time fixture and browser suites; closed | Hono does not own App classification; #1206 | +| Nitro deployment | Nitro adapters and generated deployment artifacts | Node server and Cloudflare module are environment alternatives | `nitro:proof:node`, `nitro:proof:workers`; closed | Nitro does not redefine routes, outcomes or documents; #1206 | +| Compiler-known interaction facts | Compiler semantic analysis and emitted event records | Client admission aggregation reads facts directly | Module-analysis, scanner and compiler determinism suites; closed | No Vite AST rediscovery of compiler-owned behavior; #1202, #1207 | +| Explicit Island and imperative behavior policy | Author declaration validated by adapter protocol | Client admission aggregation and delivery strategy projection | Island scanner/delivery and exact client-output tests; closed | No assumption that imperative browser behavior is compiler-knowable; #1202 | +| Third-party delivery capability | Package manifest and CEM classification | Adapter aggregates package islands and foreign-tag admission | CEM compatibility, foreign-tag and package-island tests; closed | No guessing third-party behavior from OpenElement compiler data; #1202 | +| Client reachability, activation and zero-JS | Adapter `client-admission.ts`; deterministic union of route reachability, compiler facts, explicit policy and third-party declarations | Vite chunking/client entry generation implements the plan | Admission, generator, exact-output, static-only zero-runtime and three-browser suites; false zero-JS for interactive static components fixed | No hidden 0.43 island fallback or independent compiler-event scan; #1202 | +| Vite build, HMR, resolution and source-map composition | Vite integration layer; module graph, cache and generated source maps | Bundler-neutral compiler returns code, diagnostics and source records | HMR/delivery, direct-core, source-map and build suites; closed | Compiler core must not import Vite or invent a generic bundler abstraction; #1207 | +| Source maps and diagnostics | Compiler semantic core owns source spans/records; Vite composes standard module maps | Browser/tooling consumes generated maps | Frozen source-map fixture, located fail-closed diagnostics and HMR tests; closed | No adapter recreation of semantic locations; #1207 | +| Public metadata and package interface | Package root exports, package manifests and checked interface snapshot | Docs, packed packages, Starter and third-party consumers | Interface snapshot, packed dry run and packed-consumer qualification; pending only the final exact-head rerun | No unpublished deep-import contract or stale provisional name; #1201 | ## Boundary rules diff --git a/packages/adapter-vite/__tests__/renderer-scope-parity.test.ts b/packages/adapter-vite/__tests__/renderer-scope-parity.test.ts new file mode 100644 index 000000000..4d39413f7 --- /dev/null +++ b/packages/adapter-vite/__tests__/renderer-scope-parity.test.ts @@ -0,0 +1,107 @@ +/** + * Renderer-scope binding corpus (B1.1 audit remediation, #1271 / finding F2). + * + * `rendererScopeMatches` (entry-route-helpers.ts) is the canonical codegen-time + * scope predicate (used by entry-codegen.ts and entry-not-found-codegen.ts); + * the generated `__matchingRenderers` function emitted by + * `renderMatchingRenderersFn` is its runtime re-expression inside + * self-contained generated entries, which cannot import adapter internals. + * Before this test nothing bound the two: a predicate drift (trailing-slash, + * boundary-separator or case handling) would have been silent. The corpus + * below evaluates the generated function verbatim and requires observable + * parity with the predicate for every (scope set, route path) pair. + */ + +import { assertEquals } from '@std/assert'; +import { + rendererScopeMatches, + renderMatchingRenderersFn, +} from '../src/internal/ssg/entry-route-helpers.ts'; +import type { RendererDecl } from '../src/internal/protocol/ssg.ts'; + +function rendererDecls(scopes: readonly string[]): RendererDecl[] { + return scopes.map((scope, index) => ({ + varName: `__renderer_${index}`, + scope, + importPath: `./_renderer_${index}.ts`, + depth: 0, + })); +} + +/** + * Evaluate the generated __matchingRenderers source verbatim. Each renderer + * variable is bound to `{ default: }` so the returned array + * identifies exactly which renderers the generated matcher selected. + */ +function evaluateGeneratedMatcher( + renderers: RendererDecl[], +): (routePath: string) => unknown[] { + const lines: string[] = []; + renderMatchingRenderersFn(lines, renderers); + const declarations = renderers + .map((renderer, index) => `const ${renderer.varName} = { default: __markers[${index}] };`) + .join('\n'); + const body = `${declarations}\n${lines.join('\n')}\nreturn __matchingRenderers;`; + const factory = new Function('__markers', body) as ( + markers: unknown[], + ) => (routePath: string) => unknown[]; + return factory(renderers.map((_, index) => ({ marker: index }))); +} + +/** Adversarial scope sets, including root-only, nested and sibling scopes. */ +const SCOPE_SETS: ReadonlyArray = [ + ['/'], + ['/docs'], + ['/', '/docs'], + ['/', '/docs', '/docs/api'], + ['/docs', '/admin'], + ['/docs/api'], +]; + +/** Route paths attacking exact, prefix, nested, non-match, boundary-separator and case handling. */ +const ROUTE_PATHS: readonly string[] = [ + '/', + '/docs', + '/docs/', + '/docs/api', + '/docs/api/v1', + '/docs/ap', + '/docsify', + '/documentation', + '/Docs', + '/admin', + '/admin/users', + '/other', +]; + +Deno.test('renderer scope parity: generated __matchingRenderers mirrors rendererScopeMatches', () => { + for (const scopes of SCOPE_SETS) { + const renderers = rendererDecls(scopes); + const generated = evaluateGeneratedMatcher(renderers); + for (const routePath of ROUTE_PATHS) { + const expected = renderers + .map((renderer, index) => ({ renderer, index })) + .filter(({ renderer }) => rendererScopeMatches(routePath, renderer.scope)) + .map(({ index }) => ({ marker: index })); + const actual = generated(routePath); + assertEquals( + actual, + expected, + `scope mirror diverged for scopes=${JSON.stringify(scopes)} routePath=${ + JSON.stringify(routePath) + }`, + ); + } + } +}); + +Deno.test('renderer scope parity: boundary separators and case are significant', () => { + // Pins the canonical predicate contract itself so a semantic change here + // (not just a codegen/runtime skew) is a deliberate, reviewed act. + assertEquals(rendererScopeMatches('/docs', '/docs'), true); + assertEquals(rendererScopeMatches('/docs/api', '/docs'), true); + assertEquals(rendererScopeMatches('/docsify', '/docs'), false); + assertEquals(rendererScopeMatches('/Docs', '/docs'), false); + assertEquals(rendererScopeMatches('/anything', '/'), true); + assertEquals(rendererScopeMatches('/', '/'), true); +}); diff --git a/packages/element/__tests__/compiled-escape-parity.test.ts b/packages/element/__tests__/compiled-escape-parity.test.ts index e202d53a2..980531098 100644 --- a/packages/element/__tests__/compiled-escape-parity.test.ts +++ b/packages/element/__tests__/compiled-escape-parity.test.ts @@ -10,10 +10,11 @@ * both call sites and requires byte-identical output. */ -import { assertEquals } from '@std/assert'; +import { assertEquals, assertStringIncludes } from '@std/assert'; import { serializeToHtml as serializeRuntime } from '../src/internal/compiled/runtime.ts'; import { serializeToHtml as serializeServer } from '../src/internal/compiled/server/index.ts'; import { escapeAttr } from '../src/internal/core/html-escape.ts'; +import { escapeText } from '../src/internal/compiled/escape-text.ts'; import { testProgram } from './compiled-runtime/test-program.ts'; const CORPUS: readonly string[] = [ @@ -71,3 +72,73 @@ Deno.test('escape parity: fixed attribute corpus is byte-identical across both s Deno.test('escape parity: canonical contract escapes & < > " and \'', () => { assertEquals(escapeAttr(`a&b"ce'f`), 'a&b"c<d>e'f'); }); + +/** + * Text-node corpus (B1.1 audit remediation, #1272 / finding F3). + * + * Text nodes use a REDUCED escape contract (`&`, `<`, `>` only — quotes are + * pass-through in text content) owned by one shared helper, + * `internal/compiled/escape-text.ts`, consumed by both serializers. Before the + * convergence each serializer carried a private copy and no test bound the + * two at byte level for text output; a drift confined to `>` escaping in text + * nodes would have been silent. This corpus requires byte-identical text + * output across both serializers and pins the shared contract. + */ +const TEXT_CORPUS: readonly string[] = [ + `a&b"ce'f`, + `<`, + `>`, + `&`, + `"`, + `'`, + `"entity-looking"`, + `plain`, + `line\nbreak\ttab`, + `unicode é ‹› „ “`, + ``, +]; + +Deno.test('escape parity: static text corpus is byte-identical across both serializers', () => { + for (const value of TEXT_CORPUS) { + const program = testProgram({ + tag: 'x-parity', + template: [ + { k: 'el', tag: 'div', attrs: [], children: [{ k: 'text', value }] }, + ], + parts: [], + }); + const runtime = serializeRuntime(program, hostWith(undefined) as unknown as RuntimeHost); + const server = serializeServer(program, hostWith(undefined)); + assertEquals(runtime, server, `serializers diverged for ${JSON.stringify(value)}`); + assertEquals( + runtime, + `
${escapeText(value)}
`, + `shared escapeText contract broken for ${JSON.stringify(value)}`, + ); + } +}); + +Deno.test('escape parity: text Part corpus is byte-identical across both serializers', () => { + for (const value of TEXT_CORPUS) { + const program = testProgram({ + tag: 'x-parity', + template: [ + { k: 'el', tag: 'div', attrs: [], children: [{ k: 'part', index: 0 }] }, + ], + parts: [{ k: 'text', index: 0, signal: 'v' }], + }); + const runtime = serializeRuntime(program, hostWith(value) as unknown as RuntimeHost); + const server = serializeServer(program, hostWith(value)); + assertEquals(runtime, server, `serializers diverged for ${JSON.stringify(value)}`); + assertStringIncludes( + runtime, + escapeText(value), + `escaped text missing from output for ${JSON.stringify(value)}`, + ); + } +}); + +Deno.test('escape parity: text contract escapes & < > and passes quotes and non-ASCII through', () => { + assertEquals(escapeText(`a&b"ce'f`), 'a&b"c<d>e\'f'); + assertEquals(escapeText(`unicode é ‹› „ “`), `unicode é ‹› „ “`); +}); diff --git a/packages/element/src/internal/compiled/escape-text.ts b/packages/element/src/internal/compiled/escape-text.ts new file mode 100644 index 000000000..4a524c938 --- /dev/null +++ b/packages/element/src/internal/compiled/escape-text.ts @@ -0,0 +1,22 @@ +/** + * escape-text.ts — the ONE text-node escape contract for the compiled + * serializers (B1.1 audit remediation, #1272 / finding F3). + * + * Both compiled serializers (the runtime seed serializer `runtime.ts` and the + * server serializer `server/index.ts`) emit text-node bytes through this one + * helper; before the convergence each carried a private byte-identical copy + * with no named owner and no byte-level parity corpus (the claim-parity guard + * `compiled-escape-parity.test.ts` covered attributes only). + * + * The contract is deliberately reduced: `&`, `<`, `>` only. Quotes are NOT + * escaped — they are inert in text content and the wire bytes must stay + * stable for claim parity. Distinct contracts exist elsewhere and are NOT + * this surface: + * - `escapeAttr`/`escapeHtml` (`internal/core/html-escape.ts`) additionally + * escape quotes for the attribute context. + * - `sanitize.ts` has its own entity-preserving `escapeText` with a + * deliberately different contract — do not consolidate. + */ +export function escapeText(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} diff --git a/packages/element/src/internal/compiled/runtime.ts b/packages/element/src/internal/compiled/runtime.ts index bf7281f0c..ac83a747b 100644 --- a/packages/element/src/internal/compiled/runtime.ts +++ b/packages/element/src/internal/compiled/runtime.ts @@ -15,6 +15,9 @@ import { trustedHtmlValue } from '../core/security.ts'; // Canonical void-element set and attribute-escape contract (issue #1220, // M4/L1) — single source of truth, shared with the server serializer. import { escapeAttr, VOID_TAGS } from '../core/html-escape.ts'; +// Canonical text-node escape contract (#1272) — shared with the server +// serializer; do not reintroduce a private copy. +import { escapeText } from './escape-text.ts'; import { noteCompiledProgramActivated } from '../signal/selection.ts'; import { partAnchorEndMarker, @@ -1134,10 +1137,6 @@ export function createFreshDom( // ─── Server serialization ────────────────────────────────────────── -function escapeText(value: string): string { - return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); -} - function serializedFixedAttributes( ctx: MountContext, node: ProgramElementNode, diff --git a/packages/element/src/internal/compiled/server/index.ts b/packages/element/src/internal/compiled/server/index.ts index 3aaaa81e4..2b331b764 100644 --- a/packages/element/src/internal/compiled/server/index.ts +++ b/packages/element/src/internal/compiled/server/index.ts @@ -34,6 +34,9 @@ import { trustedHtmlValue } from '../../core/security.ts'; // the wire truth for claim parity, so both serializers share this one // implementation (escapes & < > " '). import { escapeAttr } from '../../core/html-escape.ts'; +// Canonical text-node escape contract (#1272): shared with the runtime seed +// serializer; do not reintroduce a private copy. +import { escapeText } from '../escape-text.ts'; export type { CompiledProgramHost, CompiledSignalLike } from './shared.ts'; export { assertCompiledProgram, CompiledProgramValidationError } from './shared.ts'; @@ -105,10 +108,6 @@ interface SerializeContext { readonly consumedProjections: Set; } -function escapeText(value: string): string { - return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); -} - function pathKey(path: readonly number[]): string { return path.join(PROPERTY_PATH_SEPARATOR); }