From ccfea0f10369ab7eb50dfeb1d181b7dcdf4279d2 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 6 Aug 2026 02:29:43 +0200 Subject: [PATCH] fix(sema): una regla de cascada casa con SU objetivo, nunca con un ancestro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `safeMatches` aceptaba `target.closest(selector)` como coincidencia, lo que convertia toda regla de pack en regla de ancestro. Parecia inocuo porque los `data-event-*` son transitorios: la regla necesita que el ancestro lleve puesto justo el evento que ella nombra, y un hold dura ~240 ms. Salvo cuando NO son transitorios. Un `signal.warn + risk` resuelve a `persistence: 'untilFix'` (holds.ts), asi que un formulario que fallo la validacion conserva `data-event="signal-warn-invalid"` INDEFINIDAMENTE — y desde ese momento cada click y cada commit dentro del formulario casaba con la regla del propio formulario y reproducia su earcon de error, hasta el siguiente submit. El usuario oia un error mientras corregia el error. Es el hallazgo S-01 de AUDIT-sema-2026-08-05, el unico de severidad ALTA, y los 256 tests de sema pasaban con la fuga puesta. EL ARREGLO NO ROMPE EL ACOTADO POR DESCENDENCIA, porque ese vive en el SELECTOR y `matches()` lo evalua contra el elemento en su arbol real. Es como `media-player` acota sus reglas sobre los morfos de Button y Slider (`[data-media-player] [data-button][data-event="…"]`). Medido antes de tocar: CERO reglas del catalogo se declaran sin conjunto de evento y CERO usan el matcher `ancestor`, asi que nada dependia del `closest()`. GUARD `cascade-scope.test.ts`, nacido en ROJO con el escenario real (un formulario con la estampa persistente + un boton dentro): la regla del ancestro alcanzaba al descendiente. Fija las tres mitades — no alcanza al descendiente, sigue honrando la descendencia escrita en el selector, y sigue casando con el objetivo propio. Y de paso se retira `__adv-verify-haptic-dead.test.ts`, andamio que dejo un agente de la auditoria (autorizado por el usuario). `check` vuelve a 75 = la baseline limpia, sin el +1. VERIFICADO: sema+morfo+sound 369/369 · check 75 (baseline exacta) · navegador: el transporte del player sigue callado, es decir la descendencia sigue acotando · prettier limpio. Co-Authored-By: Claude Fable 5 --- src/uix/sema/cascade-scope.test.ts | 83 ++++++++++++++++++++++++++++++ src/uix/sema/resolver.ts | 11 +++- 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/uix/sema/cascade-scope.test.ts diff --git a/src/uix/sema/cascade-scope.test.ts b/src/uix/sema/cascade-scope.test.ts new file mode 100644 index 000000000..4f7e44cf9 --- /dev/null +++ b/src/uix/sema/cascade-scope.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest'; + +import { resolveSignature, type SemaCascadeRule } from './resolver'; +import { SEMA_MAP } from './sema-map'; + +/** + * Cascade scoping — a rule matches THE TARGET, never an ancestor of it. + * + * `safeMatches` used to accept `target.closest(selector)` as a match, which + * turned every pack rule into an ancestor rule. It only looked harmless + * because the `data-event-*` tokens are transient: a rule needs its ancestor + * to be carrying the very event it names, and holds are ~240ms. + * + * Except when they are NOT transient. A `signal.warn + risk` resolves to + * `persistence: 'untilFix'` (`holds.ts`), so a form that failed validation + * keeps `data-event="signal-warn-invalid"` on itself INDEFINITELY — and from + * that moment every click and every commit inside the form matched the form's + * own rule and replayed the error earcon, until the next submit. The user + * heard an error while fixing the error. + * + * Descendant scoping still works, because it is expressed in the SELECTOR and + * `matches()` evaluates it against the element in its real tree — which is how + * `media-player` scopes its rules over the Button and Slider morfos + * (`[data-media-player] [data-button][data-event="…"]`). Nothing in the + * catalogue needed `closest()`: at the time of this fix, zero pack rules were + * declared without an event conjunct and zero used an `ancestor` matcher. + */ +describe('cascade scoping', () => { + function build() { + const form = document.createElement('form'); + form.setAttribute('data-form', ''); + // The persistent stamp of a failed validation (untilFix). + form.setAttribute('data-event', 'signal-warn-invalid'); + const button = document.createElement('button'); + button.setAttribute('data-button', ''); + form.append(button); + document.body.append(form); + return { form, button }; + } + + /** The form's own rule — the one that plays the error sample. */ + const formRule: SemaCascadeRule = { + selector: '[data-form][data-event="signal-warn-invalid"]', + sound: { gain: 0.9, pitch: 111 } + }; + + it('does not let an ancestor rule reach a descendant signal', () => { + const { button } = build(); + const eff = resolveSignature( + { target: button, name: 'contact-activate', family: 'contact' }, + { map: SEMA_MAP, cascade: [formRule] } + ); + // The button's own contact must keep the contact family base — the + // form's error rule is not addressed to it. + expect(eff.sound?.pitch).not.toBe(111); + expect(eff.sound?.gain).toBe(SEMA_MAP.families.contact.base.sound!.gain); + }); + + it('still honours descendant scoping written in the selector', () => { + const { button } = build(); + const scoped: SemaCascadeRule = { + selector: '[data-form] [data-button][data-event="contact-activate"]', + sound: { pitch: 222 } + }; + button.setAttribute('data-event', 'contact-activate'); + const eff = resolveSignature( + { target: button, name: 'contact-activate', family: 'contact' }, + { map: SEMA_MAP, cascade: [scoped] } + ); + expect(eff.sound?.pitch).toBe(222); + }); + + it('still matches a rule addressed to the target itself', () => { + const { form } = build(); + const eff = resolveSignature( + { target: form, name: 'signal-warn-invalid', family: 'signal', intent: 'risk' }, + { map: SEMA_MAP, cascade: [formRule] } + ); + expect(eff.sound?.pitch).toBe(111); + }); +}); diff --git a/src/uix/sema/resolver.ts b/src/uix/sema/resolver.ts index 0e339226b..2471cc2c3 100644 --- a/src/uix/sema/resolver.ts +++ b/src/uix/sema/resolver.ts @@ -210,7 +210,16 @@ function collectCascadeMatches( function safeMatches(target: HTMLElement, selector: string): boolean { if (selector === '*') return true; try { - return target.matches(selector) || target.closest(selector) !== null; + // THE TARGET, never an ancestor of it. This used to also accept + // `target.closest(selector)`, which turned every pack rule into an + // ancestor rule: a form left carrying `data-event="signal-warn-invalid"` + // (persistence `untilFix` — indefinite by design) made every click and + // every commit inside it replay the form's error earcon until the next + // submit. Descendant scoping is unaffected because it lives in the + // SELECTOR, and `matches()` evaluates that against the element in its + // real tree — which is how `media-player` scopes its rules over the + // Button and Slider morfos. Pinned by `cascade-scope.test.ts`. + return target.matches(selector); } catch { return false; }