fix(sema): una regla de cascada casa con SU objetivo, nunca con un ancestro

`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
dev 2 months ago
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);
});
});

@ -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;
}

Loading…
Cancel
Save

Powered by TurnKey Linux.