`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 <noreply@anthropic.com>alpha-0.1-dir-prefs
parent
4fccf16624
commit
ccfea0f103
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue