Skip to content

v3.19.0

Choose a tag to compare

@cldmv-bot cldmv-bot released this 23 Sep 04:03
· 4 commits to master since this release
v3.19.0
1987984

release: v3.19.0 - add impl:collision lifecycle event for collision…

Slothlet v3.19.0 Changelog

Release Date: September 2026
Release Type: Minor
Branch: release/3.19.0


Overview

Version 3.19.0 adds the impl:collision lifecycle event — an observable signal for a case the existing impl:created event structurally cannot report: when composing a module into a namespace another module already contributed to resolves a collision, impl:created fires only for the path winner, and for a leaf folded into a self-named namespace node it never fires for the dropped leaf at all. impl:collision fires for the collision itself and carries both writers plus how it resolved, so a consumer can observe a silently dropped, shadowed, or merged contribution regardless of nesting. It is purely additive — a project that never subscribes sees nothing new.

The release also carries two fixes to an ES Proxy invariant violation reachable through the composed wrapper. Reading certain properties off a wrapped node via Object.getOwnPropertyDescriptor — the per-entity routine selector .for / .contributors (#443), and, more generally, any non-configurable own property surfaced through the wrapper (#446) — threw a getOwnPropertyDescriptor invariant TypeError. Both are drop-in fixes; no api changed.


✨ Features

impl:collision lifecycle event (#441)

api.slothlet.lifecycle.on("impl:collision", handler) fires when composing a module into the api resolves a collision at a path another module already contributed to — during initial slothlet() startup or via api.slothlet.api.add(). Where impl:created fires post-placement for the path winner only (so a leaf a merge discards is never announced), impl:collision fires for the collision itself and carries both writers, so a consumer can observe a dropped or shadowed leaf regardless of nesting — including a member folded into a self-named namespace node, which emits no impl:created for the loser at all.

  • Payload: { apiPath, resolution, incoming, owner, kind, collisionMode }. incoming / owner are role-neutral — the arriving writer versus the prior holder — and resolution is one of:
    • dropped — the incoming leaf was discarded (a merge value-leaf loss, or a member folded off a self-named namespace);
    • replaced — the incoming contribution shadows the existing owner (replace);
    • merged — two namespace nodes were combined (informational, the shared-mount case).
  • Emitted from the authoritative collision-resolution sites — syncWrapper's merge branch and setValueAtPath's replace branch on the api.add path, and the off-slot fold for the self-named-namespace case — gated to the primary api tree so the boundApi mirror never double-fires. A centralized emitImplCollision helper on ComponentBase keeps the emission consistent across those sites.
  • Fire-and-forget, like the other impl:* events: listeners are isolated (a throwing listener never affects composition or the other listeners), and it fires synchronously at the collision-decision site, so a subscriber attached before the composing call observes every collision by the time that call resolves.
  • Lazy note: a collision between two materialized leaves, and a top-level namespace merge, are reported at compose time; a collision between leaves nested inside a still-lazy folder is surfaced when that folder materializes (deferred adoption), not at the enclosing api.add(). The stackRoutines co-execution case (both contributions run rather than one dropping) is an orthogonal execution concern reported through the routine system — documented, not emitted here.

See the new impl:collision section in docs/LIFECYCLE.md.


🐛 Bug Fixes

Routine selector .for / .contributors no longer throw a Proxy invariant when read through the composed wrapper (#443)

Reading the per-entity routine selector — node.<routine>.for(key), .contributors, or one of the __slothletRoutine* markers — off a routine slot through the composed unified-wrapper proxy threw:

TypeError: 'getOwnPropertyDescriptor' on proxy: trap reported non-configurability for property 'for' which is either non-existent or configurable in the proxy target

#buildStackedCallable / #buildCascadeCallable defined those properties with a bare Object.defineProperty, defaulting them to non-configurable. The wrapper's getOwnPropertyDescriptor trap relays the impl's descriptor, so it reported a non-configurable property the proxy target does not carry as own+non-configurable — which the engine rejects. A plain .for get slipped past (so a compose-time typeof node.<routine>?.for check passed), but any getOwnPropertyDescriptor read of the slot threw once the tree re-materialized into its proxied shape — which is what a consumer scoping per-entity routines via node.<routine>.for(moduleID) hit on the deferred call. The seven descriptors are now configurable: true; this imposes no mutation risk (the slot is a managed, re-derived surface behind the proxy — a delete is absorbed, the property is non-writable, and the selector still routes), and through the proxy the trap must report the property as configurable regardless. Fixed in #445.

The wrapper getOwnPropertyDescriptor trap never reports a non-configurable descriptor for a virtual property (#446)

The general form of #443. The unified-wrapper getOwnPropertyDescriptor trap relayed the impl's own descriptor verbatim, so any non-configurable own property surfaced only through the wrapper — not just routine selectors, but for example a function assigned via wrap-on-set that carries a non-configurable own property — tripped the same invariant when its descriptor was read. The trap now runs any descriptor sourced from the wrapper or the impl through a guard that coerces configurable: true unless the actual proxy target carries that property as an own, non-configurable property — the only invariant-legal shape for a virtualized property — so the trap can no longer emit an invariant-violating descriptor. Descriptors the target genuinely backs (including a function's prototype) are reported faithfully. Fixed in #447; complements #443's source fix, which keeps the routine selector descriptors configurable at their definition.


📚 Documentation

  • NEW: docs/changelog/v3/v3.19.0.md — this changelog.
  • docs/LIFECYCLE.md — added the impl:collision section: when it fires versus impl:created, the { apiPath, resolution, incoming, owner, kind, collisionMode } payload, the dropped / replaced / merged resolutions, and the lazy-materialization and stackRoutines notes (#441).

🔧 Dependencies

No dependency updates.


Upgrade notes

A drop-in for v3.18.x. The impl:collision event is additive — subscribe to it only if you need to observe collisions that impl:created cannot surface (a dropped, shadowed, or merged contribution); a project that never subscribes is unaffected.

The two Proxy-invariant fixes (#443, #446) change no api and no signatures — they turn a getOwnPropertyDescriptor TypeError into a correct descriptor read. If a consumer shipped a downstream workaround that resolved node.<routine>.for(key) in a try and fell back to a direct routine call, that workaround is no longer needed and can be removed.


coverage

Metric Coverage
Statements 99.9%
Branches 99.9%
Functions 100.0%
Lines 100.0%

Avg: 99.9% · 7ae97a6 · Node lts/*

👥 Contributors