weft-kernel 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,692 @@
1
+ """`Registry` — where a pack's `register()` adds what it provides.
2
+
3
+ Specified across `docs/02-extension-model.md` section 2 ("Packs and
4
+ discovery"). At Phase 0, before G2 settled arbitration between two packs
5
+ registering the same name, this module took the reversible choice —
6
+ **refuse the second registration outright, naming both distributions**, and
7
+ implement no last-wins, first-wins or qualification. A registration path that
8
+ overwrites silently with no check at all is a bug someone eventually has to
9
+ find; refusing could be
10
+ relaxed later without anyone having silently lost a registration first,
11
+ which is why it was the fixed choice rather than an improvement on it. G2
12
+ closed 2026-08-16 on exactly that relaxation — see task 1.12 below.
13
+
14
+ Lookup is the other side of the same design: an unresolvable name is loud —
15
+ naming what was wanted, that nothing registered it, and what the valid
16
+ options are — never a bare failure with no further information
17
+ (`docs/02-extension-model.md` section 2, *the trust model*).
18
+
19
+ **This is the base mechanism only.** The kernel names no capability:
20
+ `contract` here is any type a pack publishes, and `Registry` never imports or
21
+ knows what any particular contract is for. Discovery (step 5) decides how
22
+ a pack's own distribution name reaches `add`'s `distribution` argument; the
23
+ entry-point trust model, conditional registration and pack settings are all
24
+ later steps.
25
+
26
+ **`destroys` mandatoriness, added at task 1.2.** `docs/02-extension-model.md`
27
+ §3 → *Ordering constraints*: "`destroys` is mandatory wherever a contract
28
+ publishes a property vocabulary, an explicit empty tuple included;
29
+ registration is refused otherwise, naming the missing declaration." Detected
30
+ generically off `contract` — `getattr(contract, "publishes_property_vocabulary",
31
+ False)` — never off a hardcoded list of contract names, because the module
32
+ docstring above already states the rule this obeys: "the kernel never
33
+ imports or knows what any particular contract is for." A contract opts in
34
+ the same way a real contract publishes `version` (`weft_extract.contract`,
35
+ `weft_chunk.contract`): a `ClassVar` declared under `if TYPE_CHECKING:` and
36
+ assigned for real after the class body, so it never joins
37
+ `__protocol_attrs__` and costs a structurally-conforming plugin nothing. The
38
+ asymmetry with `intact`, which stays a convention no seam enforces, is
39
+ `02` §3's own: "forgetting `intact` harms only your own stage and you find
40
+ out, while forgetting `destroys` silently corrupts a stranger's, and the
41
+ pack that caused it never sees a failure" — a defect this refusal exists to
42
+ make impossible rather than merely documented against.
43
+
44
+ **Read through `unwrap_factory`, never off `factory` directly.** A plugin
45
+ that binds pack settings ahead of time — `functools.partial(PluginClass,
46
+ settings)`, the only shape available to one, and the one `weft_store`
47
+ actually registers — has no attributes of its own: `hasattr` on a `partial`
48
+ never reaches `.func`. Skipping the unwrap would make this refusal loud for
49
+ the wrong reason (a plugin that *did* declare `destroys`, refused anyway,
50
+ pointed at a remedy already satisfied) and, in `weft_kernel.resolution`,
51
+ silent for the worse one — `getattr(factory, "requires", ())` on an
52
+ unwrapped `partial` just returns the default, invisibly. `unwrap_factory`
53
+ is the one place that peeling happens; every reader of a class-level plugin
54
+ declaration goes through it.
55
+
56
+ **Task 3.1 — `destroys` generalised into `required_declarations`, not duplicated.**
57
+ `docs/03-cli.md` → *Permissions*: "a plugin-contributed command must declare
58
+ its [permission] class, and there is no default (G3)... a command that
59
+ declares none fails to register, loudly, while its author is standing right
60
+ there" — the identical shape `destroys` already has, needed for
61
+ `weft_command.contract.Command`'s `permission_class`. Writing a second,
62
+ parallel `_require_permission_class_if_command` would mean this module
63
+ learning the string `"permission_class"` and, worse, the word `Command` —
64
+ exactly the capability-naming G1 forbids in the kernel. So the check below
65
+ generalises instead: `_required_declarations(contract)` reads *two* sources
66
+ into one tuple — `contract.required_declarations`, a plain tuple of
67
+ attribute names any contract may set (`Command` sets it to
68
+ `("permission_class",)`), and the pre-existing
69
+ `publishes_property_vocabulary` flag, folded in as `"destroys"` for
70
+ `Chunker`, `Cleaner` and every contract that already opted in that way, so
71
+ none of them needed to change. One loop, `_require_declarations_present`,
72
+ walks whatever that tuple contains and raises for the first name
73
+ `unwrap_factory(factory)` lacks — there is exactly one place that checks
74
+ `hasattr` and exactly one place that raises, never one path per contract.
75
+ `MissingDestroysDeclarationError` is kept as a **subclass** of the new
76
+ `MissingRequiredDeclarationError`, specifically for the `"destroys"` name,
77
+ because task 1.2's own tests already catch it by that name and CLAUDE.md's
78
+ existing-test rule means that symbol does not move; every other required
79
+ name (`"permission_class"` included) raises the base class directly, with a
80
+ message built from the declaration name alone — nothing here says
81
+ `Command`, `permission` or any other capability word, so a future contract
82
+ adopting the identical mechanism costs this module nothing.
83
+
84
+ **Added at step 6 (the linear runner).** `entry()` returns the full
85
+ `RegistryEntry` — factory *and* distribution — where `lookup()` returns only
86
+ the factory. The runner needs the distribution to attribute the spans and
87
+ errors `weft_kernel.seam.wrap` produces to the pack that registered a
88
+ resolved stage; nothing before step 6 needed it, which is why `lookup` alone
89
+ was the whole surface until now.
90
+
91
+ **`contracts()` and `distributions_for()` added at task 0.12.** Until then,
92
+ `Registry` had no enumeration API a caller outside the kernel could walk —
93
+ `weft_cli.plugins_report`'s own module docstring named the gap explicitly,
94
+ as a real limitation rather than an oversight this module papered over.
95
+ `docs/08-manuals.md` §3 clause (b) is the first caller that actually needs
96
+ one: the generated contract reference has to know what got registered
97
+ without a second, hand-kept list of contract names, or it reproduces the
98
+ two-lists bug it exists to prevent. Both methods stay contract-agnostic —
99
+ `Registry` still never imports or knows what any particular contract is
100
+ for — and both are read-only projections of `_entries`, adding no new
101
+ state.
102
+
103
+ **Task 1.12 — G2 relaxed the refusal rather than tightening a silence.**
104
+ `docs/06-phase-0-build.md`'s own trap 3 already named the direction: "If G2
105
+ later chooses last-wins, first-wins or explicit qualification, it relaxes a
106
+ refusal rather than tightening a silence." G2 closed on qualification, but
107
+ qualification by the *operator*, not the pack — `docs/02-extension-model.md`
108
+ §3 → *When resolution fails*: "G3 settled that pipelines keep bare names, so
109
+ the data cannot break the tie and the operator does." `plugin_pins` is that
110
+ operator's `[plugins]` table from `weft.toml`, already parsed into a
111
+ `{"Contract:name": "distribution"}` mapping by whoever reads the file —
112
+ `weft-cli`'s `registry_bootstrap`, never this module, which is why `Registry`
113
+ takes a plain `Mapping[str, str]` rather than a path.
114
+
115
+ A pin changes what a collision *does*, never whether one is checked for:
116
+ `add`/`add_many` still refuse an unpinned collision exactly as before, now
117
+ printing the `[plugins]` line that would resolve it — the fixed refusal is
118
+ still the default, not replaced. A pinned collision instead resolves without
119
+ raising: the named distribution's registration stands (or takes over, if it
120
+ was the second to arrive), and the other is recorded in `displaced()` —
121
+ `docs/03-cli.md`'s own words, "installed, active, and one of its plugins is
122
+ unreachable" — never silently dropped the way an unchecked overwrite drops
123
+ one every time. This is also why a pinned collision no
124
+ longer fails `add_many`'s whole batch the way an unpinned one still does: the
125
+ pack that lost is not broken, so nothing about the rest of what it registered
126
+ should be either.
127
+
128
+ **A pin is a claim about a real fight, not a standing wildcard.** A pin
129
+ naming a distribution that is neither side of the collision it targets is
130
+ refused immediately, by `UnresolvedPluginPinError` — resolving it anyway
131
+ would silently start honouring a pin for a pack that never even contended,
132
+ which is exactly the kind of quiet drift a pin exists to prevent, not enable.
133
+ A pin that never sees a collision at all — because the name was never
134
+ claimed twice, or ever — is checked separately, by `unconsulted_pins()`;
135
+ `weft_kernel.discovery.discover` raises `InertPluginPinError` once discovery
136
+ finishes if any pin it was given never got the chance to arbitrate anything
137
+ and the caller has not opted out with `strict_pins=False` (a diagnostic
138
+ caller only — see `InertPluginPinError`'s own docstring), the same way an
139
+ unclaimed `packs:` settings key already raises there. Both directions share
140
+ one reasoning: `docs/02` says it as "an inert pin is a lie about what is
141
+ running."
142
+ """
143
+
144
+ import functools
145
+ from collections.abc import Callable, Iterable, Mapping
146
+ from dataclasses import dataclass
147
+ from typing import cast
148
+
149
+ from weft_kernel.errors import UnresolvedNameError, WeftError
150
+
151
+
152
+ class DuplicateRegistrationError(WeftError):
153
+ """Two distributions registered the same name under the same contract, and no pin resolves it.
154
+
155
+ Fixed for Phase 0 by `docs/06-phase-0-build.md`: refusal, not silent
156
+ arbitration. Task 1.12 relaxes this exactly the way that trap said it
157
+ would — a `[plugins]` pin in `weft.toml` lets the operator name the
158
+ winner (see `Registry.__init__` and the module docstring); this class is
159
+ raised only when no such pin exists for the colliding name, and its
160
+ message prints the pin that would resolve it.
161
+ """
162
+
163
+
164
+ class UnresolvedPluginPinError(WeftError, UnresolvedNameError):
165
+ """A `[plugins]` pin exists for this collision, but names neither side of it.
166
+
167
+ `docs/02-extension-model.md` §3: a pin naming a distribution that did not
168
+ claim the name must fail loudly rather than being ignored — resolving it
169
+ anyway would silently honour a pin for a pack that never contended, and
170
+ an inert pin is exactly the lie about what is running this refusal
171
+ exists to catch. Distinct from `DuplicateRegistrationError`: that one
172
+ fires when *no* pin exists for the key at all; this one fires when a pin
173
+ exists but points somewhere neither contender is.
174
+
175
+ Fitness function 12's family: `valid_options` is the two distributions
176
+ actually contending for the name — the pin can only ever be pointed at
177
+ one of them.
178
+ """
179
+
180
+ def __init__(self, message: str, *, valid_options: tuple[str, ...]) -> None:
181
+ super().__init__(message)
182
+ self.valid_options = valid_options
183
+
184
+
185
+ class MissingRequiredDeclarationError(WeftError):
186
+ """A plugin registered for a contract that requires a class-level declaration states none.
187
+
188
+ Task **3.1**'s generalisation of what task 1.2 built for `destroys`
189
+ alone — see the module docstring, *"`destroys` generalised into
190
+ `required_declarations`, not duplicated."* Raised for any name a
191
+ contract lists in `required_declarations` (or folds in via the legacy
192
+ `publishes_property_vocabulary` flag) that `unwrap_factory(factory)`
193
+ does not carry. `MissingDestroysDeclarationError` below is this class's
194
+ one fixed subclass, kept only because existing tests already catch it by
195
+ that name; every other required declaration — `permission_class`
196
+ included — raises this base class directly, so this module never learns
197
+ a second capability-specific name to special-case.
198
+ """
199
+
200
+
201
+ class MissingDestroysDeclarationError(MissingRequiredDeclarationError):
202
+ """A plugin registered for a contract that publishes a property vocabulary states no `destroys`.
203
+
204
+ `docs/02-extension-model.md` §3 → *Ordering constraints*: `destroys` is
205
+ mandatory wherever a contract publishes a property vocabulary — an
206
+ explicit empty tuple counts as stating it, silence does not. Refused
207
+ here, at registration, rather than left to surface later as a stranger's
208
+ ordering-sensitive stage silently corrupted by this one at resolution
209
+ time, with neither pack ever seeing a failure that names the cause.
210
+
211
+ Subclasses `MissingRequiredDeclarationError` since task 3.1 generalised
212
+ the check this raises from — `destroys` is now one required declaration
213
+ among however many a contract names, not a bespoke mechanism, but this
214
+ symbol stays exactly as task 1.2 left it because `tests/unit/weft_kernel/
215
+ test_registry.py` already asserts `pytest.raises(
216
+ MissingDestroysDeclarationError)` and CLAUDE.md's existing-test rule
217
+ means that assertion does not move.
218
+ """
219
+
220
+
221
+ class UnknownPluginError(WeftError, UnresolvedNameError):
222
+ """`Registry.lookup` was asked for a name no distribution registered.
223
+
224
+ The message states the contract and name that were wanted, that no
225
+ distribution registered it, and the full set of names that *are*
226
+ registered for that contract, so a typo reads as a typo rather than a
227
+ mystery — the property refused here: a request for an unregistered name
228
+ returning no error and no score at all.
229
+
230
+ Fitness function 12's canonical example: `valid_options` is every name
231
+ registered for the contract that was asked, carried structurally rather
232
+ than only inside `available` above.
233
+ """
234
+
235
+ def __init__(self, message: str, *, valid_options: tuple[str, ...]) -> None:
236
+ super().__init__(message)
237
+ self.valid_options = valid_options
238
+
239
+
240
+ @dataclass(frozen=True, slots=True)
241
+ class RegistryEntry:
242
+ """One `(contract, name)` slot: what it resolves to, and which distribution put it there.
243
+
244
+ Public — unlike `lookup`, which hands back only the factory, the linear
245
+ runner (`06` step 6) needs the distribution too, to attribute the spans
246
+ and errors `weft_kernel.seam.wrap` produces to the pack that registered
247
+ the stage. `entry()` returns this; `lookup()` is `entry(...).factory` for
248
+ callers that only need the callable.
249
+ """
250
+
251
+ factory: Callable[..., object]
252
+ distribution: str
253
+
254
+
255
+ @dataclass(frozen=True, slots=True)
256
+ class DisplacedRegistration:
257
+ """A registration a `[plugins]` pin resolved *away from* — recorded, never dropped.
258
+
259
+ `docs/03-cli.md`: "the pack lost a `(contract, name)` collision to an
260
+ operator's pin, so it is installed, active, and one of its plugins is
261
+ unreachable." `distribution` is the pack that lost; `winner` is the one
262
+ the pin named instead; `pin` is the exact `"Contract:name"` key from
263
+ `weft.toml`'s `[plugins]` table, so `weft plugins doctor` can print the
264
+ line an operator would need to change to reverse the decision.
265
+ """
266
+
267
+ contract: type[object]
268
+ name: str
269
+ distribution: str
270
+ winner: str
271
+ pin: str
272
+
273
+
274
+ @dataclass(frozen=True, slots=True)
275
+ class _CollisionResolution:
276
+ """What a pinned collision resolves to — computed without mutating the registry.
277
+
278
+ `entry` is `None` when the already-registered side wins (nothing to
279
+ write); otherwise it is the replacement to write. Kept side-effect-free
280
+ so `add_many` can resolve every entry in a batch *before* committing any
281
+ of it — the same all-or-nothing discipline the unpinned path already has.
282
+ """
283
+
284
+ entry: RegistryEntry | None
285
+ displaced: DisplacedRegistration
286
+
287
+
288
+ class Registry:
289
+ """Where every pack's `register()` adds what it provides, keyed by contract and name.
290
+
291
+ Two names collide only if they share both the contract *and* the string
292
+ name: two packs each publishing the name `"fast"`, but under two
293
+ different contract types, are unrelated registrations, because the
294
+ kernel treats the contract as part of the key, never as a shared
295
+ namespace plugins must avoid colliding in by convention.
296
+ """
297
+
298
+ def __init__(self, *, plugin_pins: Mapping[str, str] | None = None) -> None:
299
+ """`plugin_pins` is the parsed `[plugins]` table — see the module docstring.
300
+
301
+ Keyed `"Contract:name"` to the distribution that should win a
302
+ collision on that name — `docs/02-extension-model.md` §3's exact
303
+ shape. Copied into a plain `dict` so a caller mutating the mapping it
304
+ passed in afterwards cannot change this registry's policy out from
305
+ under it. Absent (`None`, the default) behaves exactly as Phase 0
306
+ did: every collision is refused, unconditionally.
307
+ """
308
+ self._entries: dict[tuple[type[object], str], RegistryEntry] = {}
309
+ self._plugin_pins: dict[str, str] = dict(plugin_pins or {})
310
+ self._displaced: list[DisplacedRegistration] = []
311
+ self._consulted_pins: set[str] = set()
312
+
313
+ def add(
314
+ self,
315
+ contract: type[object],
316
+ name: str,
317
+ factory: Callable[..., object],
318
+ *,
319
+ distribution: str,
320
+ ) -> None:
321
+ """Register `factory` as `name` for `contract`, attributed to `distribution`.
322
+
323
+ `distribution` is supplied by the registration seam (step 3), never
324
+ by a pack author: attribution is a cross-cutting concern, and
325
+ CLAUDE.md places those at the seam rather than in a rule authors
326
+ must remember. A pack's own `register()` calls the seam-bound
327
+ surface with `(contract, name, factory)`; this lower-level `add` is
328
+ what the seam calls once it has filled `distribution` in itself.
329
+
330
+ If `(contract, name)` is already taken, a `[plugins]` pin for this
331
+ exact key resolves it — see the module docstring, *"Task 1.12."* With
332
+ no pin, this still refuses outright, naming both the distribution
333
+ that registered first and the one attempting to register now, and
334
+ printing the pin that would resolve it — the fixed, reversible
335
+ choice `docs/06-phase-0-build.md` took for G2's open arbitration
336
+ question, still the default now that G2 has closed.
337
+
338
+ Also refuses `factory` outright — before either check, since this is
339
+ a property of the registration attempt itself rather than of what
340
+ else is already here — if `contract` names any required declaration
341
+ (`required_declarations`, or the legacy `publishes_property_vocabulary`
342
+ flag) that `factory` never mentions; see the module docstring,
343
+ *"Task 3.1 — `destroys` generalised into `required_declarations`."*
344
+ """
345
+ _require_declarations_present(contract, name, factory, distribution=distribution)
346
+ key = (contract, name)
347
+ existing = self._entries.get(key)
348
+ if existing is None:
349
+ self._entries[key] = RegistryEntry(factory=factory, distribution=distribution)
350
+ return
351
+ resolution = self._resolve_collision(
352
+ contract, name, existing=existing, factory=factory, distribution=distribution
353
+ )
354
+ self._commit_resolution(key, resolution)
355
+
356
+ def add_many(
357
+ self,
358
+ entries: Iterable[tuple[type[object], str, Callable[..., object]]],
359
+ *,
360
+ distribution: str,
361
+ ) -> None:
362
+ """Register every `(contract, name, factory)` in `entries`, all at once or not at all.
363
+
364
+ The commit half of a pack's transactional registration
365
+ (`weft_kernel.discovery.PackRegistrar`): a pack's own `register()`
366
+ buffers every `add` call instead of writing immediately, precisely so
367
+ that a pack raising partway through never leaves some of its
368
+ registrations standing while its `PackReport` claims zero — the
369
+ defect this method exists to make structurally impossible. Every key
370
+ in `entries` is checked against both the registry and the rest of
371
+ the batch *before* anything is written — an unpinned collision
372
+ against an existing registration, or `entries` registering the same
373
+ `(contract, name)` twice within this one call, both still raise
374
+ `DuplicateRegistrationError` and leave the registry exactly as it
375
+ was. A *pinned* collision against an existing registration resolves
376
+ instead of raising (task 1.12): the rest of the batch still commits,
377
+ because the pack that lost one name is installed and active, not
378
+ broken — `docs/03-cli.md`'s own words. Only once every entry is
379
+ either free or pin-resolved does any write happen, so a failed call
380
+ is still always a no-op. The mandatory-declaration check `add`
381
+ performs (see its own docstring) runs here too, for the same reason
382
+ and on the same all-or-nothing terms.
383
+ """
384
+ batch = list(entries)
385
+ pending_new: dict[tuple[type[object], str], RegistryEntry] = {}
386
+ pending_resolutions: list[tuple[tuple[type[object], str], _CollisionResolution]] = []
387
+ for contract, name, factory in batch:
388
+ _require_declarations_present(contract, name, factory, distribution=distribution)
389
+ key = (contract, name)
390
+ existing = self._entries.get(key)
391
+ if existing is not None:
392
+ resolution = self._resolve_collision(
393
+ contract, name, existing=existing, factory=factory, distribution=distribution
394
+ )
395
+ pending_resolutions.append((key, resolution))
396
+ continue
397
+ if key in pending_new:
398
+ raise DuplicateRegistrationError(
399
+ f"'{name}' is registered twice for {contract.__name__} within "
400
+ f"distribution '{distribution}'s own register() call; a pack cannot "
401
+ f"register the same name for the same contract more than once."
402
+ )
403
+ pending_new[key] = RegistryEntry(factory=factory, distribution=distribution)
404
+
405
+ for key, entry in pending_new.items():
406
+ self._entries[key] = entry
407
+ for key, resolution in pending_resolutions:
408
+ self._commit_resolution(key, resolution)
409
+
410
+ def displaced(self) -> tuple[DisplacedRegistration, ...]:
411
+ """Every registration a `[plugins]` pin resolved away from, in the order it happened.
412
+
413
+ `weft_cli.plugins_report.render_doctor`'s source for the "displaced"
414
+ report `docs/03-cli.md` describes — never dropped, per the module
415
+ docstring's *Task 1.12* note.
416
+ """
417
+ return tuple(self._displaced)
418
+
419
+ def unconsulted_pins(self) -> frozenset[str]:
420
+ """Every `[plugins]` pin this registry was given that never resolved an actual collision.
421
+
422
+ `weft_kernel.discovery.discover` raises `InertPluginPinError` off
423
+ this once discovery finishes, the same way it already raises for an
424
+ unclaimed `packs:` settings key — see the module docstring's *Task
425
+ 1.12* note. A pin naming a `(contract, name)` nothing ever claimed
426
+ twice stays in this set forever; one that actually arbitrated a
427
+ collision (`add`/`add_many` calling `_resolve_collision`) leaves it.
428
+ """
429
+ return frozenset(self._plugin_pins) - self._consulted_pins
430
+
431
+ def _resolve_collision(
432
+ self,
433
+ contract: type[object],
434
+ name: str,
435
+ *,
436
+ existing: RegistryEntry,
437
+ factory: Callable[..., object],
438
+ distribution: str,
439
+ ) -> _CollisionResolution:
440
+ """What a pin says to do about this collision, or the refusal if none applies.
441
+
442
+ Pure — reads `self._plugin_pins` but writes nothing, so `add_many`
443
+ can call this for every colliding entry in a batch before committing
444
+ any of them. Raises `DuplicateRegistrationError` (no pin for this
445
+ key) or `UnresolvedPluginPinError` (a pin exists but names neither
446
+ `existing.distribution` nor `distribution`) directly; a caller never
447
+ has to distinguish those from an ordinary return.
448
+ """
449
+ pin_key = _pin_key(contract, name)
450
+ pin = self._plugin_pins.get(pin_key)
451
+ if pin is None:
452
+ raise DuplicateRegistrationError(
453
+ f"'{name}' is already registered for {contract.__name__} by "
454
+ f"distribution '{existing.distribution}'; distribution '{distribution}' "
455
+ f"cannot register it too. Weft refuses to arbitrate between them — pin the "
456
+ f"winner in weft.toml:\n\n[plugins]\n"
457
+ f'"{pin_key}" = "{existing.distribution}" # or "{distribution}"\n\n'
458
+ f"to keep the other distribution's claim instead. See the duplicate-name "
459
+ f"trap in docs/02-extension-model.md §3, 'When resolution fails'."
460
+ )
461
+ if pin not in (existing.distribution, distribution):
462
+ contenders = (existing.distribution, distribution)
463
+ raise UnresolvedPluginPinError(
464
+ f"[plugins] pins '{pin_key}' to '{pin}', but '{pin}' registered neither "
465
+ f"claim on '{name}' for {contract.__name__} — {', '.join(contenders)} are "
466
+ f"the two distributions actually contending for it. Point the pin at one "
467
+ f"of them, or remove it if it was meant for a different collision.",
468
+ valid_options=contenders,
469
+ )
470
+ if pin == existing.distribution:
471
+ return _CollisionResolution(
472
+ entry=None,
473
+ displaced=DisplacedRegistration(
474
+ contract=contract,
475
+ name=name,
476
+ distribution=distribution,
477
+ winner=existing.distribution,
478
+ pin=pin_key,
479
+ ),
480
+ )
481
+ return _CollisionResolution(
482
+ entry=RegistryEntry(factory=factory, distribution=distribution),
483
+ displaced=DisplacedRegistration(
484
+ contract=contract,
485
+ name=name,
486
+ distribution=existing.distribution,
487
+ winner=distribution,
488
+ pin=pin_key,
489
+ ),
490
+ )
491
+
492
+ def _commit_resolution(
493
+ self, key: tuple[type[object], str], resolution: _CollisionResolution
494
+ ) -> None:
495
+ """Write what `_resolve_collision` decided: the replacement (if any), and the record."""
496
+ if resolution.entry is not None:
497
+ self._entries[key] = resolution.entry
498
+ self._displaced.append(resolution.displaced)
499
+ self._consulted_pins.add(resolution.displaced.pin)
500
+
501
+ def contracts(self) -> frozenset[type[object]]:
502
+ """Every contract type with at least one registered name.
503
+
504
+ Two names under one contract still count once — the key this counts
505
+ over is `_entries`' contract half, not the full `(contract, name)`
506
+ pair. See the module docstring for why this exists and who the
507
+ first caller is.
508
+ """
509
+ return frozenset(contract for contract, _ in self._entries)
510
+
511
+ def names_for(self, contract: type[object]) -> frozenset[str]:
512
+ """Every name registered under `contract`, whoever registered it.
513
+
514
+ The kernel names no capability, and this method is why that is
515
+ affordable: a caller that needs to know *what a contract's plugins
516
+ collectively claim* — the ingest accept set is the first, the union of
517
+ the file extensions every registered extractor declares — derives it
518
+ from what actually registered rather than from a list in one pack's
519
+ module. `docs/02-extension-model.md` §1: capability is derived, never
520
+ declared. Without this the derivation is impossible from outside and
521
+ the list gets hand-maintained, which is exactly the drift it exists to
522
+ prevent.
523
+
524
+ Empty, never an error, for a contract nothing registered — symmetric
525
+ with `distributions_for`: a caller enumerating what is installed is
526
+ asking a question, and "nothing" is an answer.
527
+ """
528
+ return frozenset(name for (registered, name) in self._entries if registered is contract)
529
+
530
+ def distributions_for(self, contract: type[object]) -> frozenset[str]:
531
+ """Every distribution that registered at least one name under `contract`.
532
+
533
+ A contract is not one pack's alone — two distributions may each
534
+ register a different name under the same contract, exactly the
535
+ shape `docs/02-extension-model.md` describes for a second store
536
+ backend — so this returns every contributor, never a single owner.
537
+ Empty, never an error, for a contract nothing registered.
538
+ """
539
+ return frozenset(
540
+ entry.distribution
541
+ for (registered, _), entry in self._entries.items()
542
+ if registered is contract
543
+ )
544
+
545
+ def lookup(self, contract: type[object], name: str) -> Callable[..., object]:
546
+ """The factory registered as `name` for `contract`.
547
+
548
+ `self.entry(contract, name).factory` — see `entry` for the same
549
+ lookup with the providing distribution attached, and for the error
550
+ both raise.
551
+ """
552
+ return self.entry(contract, name).factory
553
+
554
+ def entry(self, contract: type[object], name: str) -> RegistryEntry:
555
+ """The full registration for `(contract, name)`: its factory and providing distribution.
556
+
557
+ Raises `UnknownPluginError`, naming the contract and name that were
558
+ wanted, stating that nothing registered it, and listing every name
559
+ that *is* registered for that contract.
560
+ """
561
+ found = self._entries.get((contract, name))
562
+ if found is not None:
563
+ return found
564
+
565
+ options = tuple(
566
+ sorted(
567
+ registered_name
568
+ for registered_contract, registered_name in self._entries
569
+ if registered_contract == contract
570
+ )
571
+ )
572
+ available = ", ".join(f"'{option}'" for option in options) if options else "none"
573
+ raise UnknownPluginError(
574
+ f"no '{name}' is registered for {contract.__name__}. It is "
575
+ f"unavailable because no distribution has registered that name for this "
576
+ f"contract. Names registered for {contract.__name__}: {available}.",
577
+ valid_options=options,
578
+ )
579
+
580
+
581
+ def _pin_key(contract: type[object], name: str) -> str:
582
+ """The exact `"Contract:name"` string a `[plugins]` pin uses for this collision.
583
+
584
+ `docs/02-extension-model.md` §3's own shape — `"Enhancer:keybert" =
585
+ "weft-kw"`. `contract.__name__` rather than a fully-qualified path,
586
+ matching every other message in this module that already names a
587
+ contract this way (`DuplicateRegistrationError`, `UnknownPluginError`):
588
+ the operator writing `weft.toml` sees the same short name doctor and
589
+ every refusal already print.
590
+ """
591
+ return f"{contract.__name__}:{name}"
592
+
593
+
594
+ def unwrap_factory(factory: Callable[..., object]) -> object:
595
+ """The plugin class (or callable) a factory constructs, `functools.partial` peeled away.
596
+
597
+ A plugin needing pack settings has only one shape available to it —
598
+ `functools.partial(PluginClass, settings)`, exactly what
599
+ `weft_store.pgvector_store.register` does — and `functools.partial` does
600
+ not proxy attribute access to `.func`: `hasattr(functools.partial(C), "x")`
601
+ is `False` even when `C.x` is set, for any `x`. Every read site in the
602
+ kernel that inspects a *class* attribute a plugin declared — `destroys`
603
+ here, and `requires`/`provides`/`intact`/`destroys` in
604
+ `weft_kernel.resolution.resolve` — must therefore unwrap the same way
605
+ before reading, or a pack binding its own settings this way has its
606
+ declarations silently invisible to one reader while
607
+ `weft_kernel.runner.Runner`, which reads a *constructed instance* instead
608
+ of the factory, sees them correctly. Walking `.func` repeatedly handles a
609
+ `partial` of a `partial`, which `functools.partial` itself collapses on
610
+ construction but nothing guarantees a caller building one by hand did.
611
+ """
612
+ target: object = factory
613
+ while isinstance(target, functools.partial):
614
+ target = target.func
615
+ return target
616
+
617
+
618
+ def _required_declarations(contract: type[object]) -> tuple[str, ...]:
619
+ """Every class attribute `contract` requires each of its implementations to declare.
620
+
621
+ Task **3.1**'s generalisation — see the module docstring, *"`destroys`
622
+ generalised into `required_declarations`, not duplicated."* Two sources,
623
+ merged, neither one a hardcoded contract or capability name:
624
+
625
+ - `contract.required_declarations` — a plain `ClassVar[tuple[str, ...]]`
626
+ any contract may set, read generically off `contract` the same way
627
+ `publishes_property_vocabulary` already was. `weft_command.contract.
628
+ Command` sets this to `("permission_class",)`; this module never reads
629
+ or writes that string anywhere else.
630
+ - The legacy `publishes_property_vocabulary` flag, folded in as
631
+ `"destroys"` whenever it is set and not already present — `Chunker`,
632
+ `Cleaner` and every task-1.2-era contract keeps meaning exactly what
633
+ it meant, at zero cost, because neither its flag nor its plugins had
634
+ to change for this generalisation to land.
635
+
636
+ An ungoverned contract (the overwhelming majority) returns `()`, and
637
+ `_require_declarations_present` below is then a no-op, exactly as
638
+ `_require_destroys_if_governed` was before this task.
639
+ """
640
+ declared = cast("tuple[str, ...]", tuple(getattr(contract, "required_declarations", ())))
641
+ if getattr(contract, "publishes_property_vocabulary", False) and "destroys" not in declared:
642
+ declared = (*declared, "destroys")
643
+ return declared
644
+
645
+
646
+ def _require_declarations_present(
647
+ contract: type[object], name: str, factory: Callable[..., object], *, distribution: str
648
+ ) -> None:
649
+ """Refuse `factory` if `contract` requires a declaration `factory` never states.
650
+
651
+ One loop over `_required_declarations(contract)`, one `hasattr` check,
652
+ one raise — the single code path task 3.1's own module-docstring note
653
+ describes, replacing the `destroys`-only version task 1.2 wrote.
654
+
655
+ `hasattr(unwrap_factory(factory), declaration)` reads the *class*, not
656
+ an instance — `factory` is ordinarily the plugin class itself
657
+ (`registrar.add(Chunker, "fixed-size", FixedSizeChunker)`), sometimes a
658
+ `functools.partial` binding pack settings ahead of it (see
659
+ `unwrap_factory`), and no config exists yet to build an instance from at
660
+ registration time either way. A class that never mentions the
661
+ declaration — not even inherited — answers `False`; one that assigns it
662
+ explicitly (`destroys = ()`, `permission_class = PermissionClass.READ`)
663
+ answers `True`, the same asymmetry `02` §3 states for `destroys`: the
664
+ value must be *written*, not merely true by silence.
665
+
666
+ The exact error type is the one place this function still knows the
667
+ name `"destroys"` — not as a capability, but as the one required
668
+ declaration old enough to have tests pinned to its own exception class
669
+ (see `MissingDestroysDeclarationError`'s docstring). Every other
670
+ required declaration raises `MissingRequiredDeclarationError` directly,
671
+ with a message built from `declaration`, `contract` and `distribution`
672
+ alone.
673
+ """
674
+ for declaration in _required_declarations(contract):
675
+ if hasattr(unwrap_factory(factory), declaration):
676
+ continue
677
+ if declaration == "destroys":
678
+ raise MissingDestroysDeclarationError(
679
+ f"'{name}' registers for {contract.__name__} (distribution '{distribution}') "
680
+ f"without declaring `destroys`. {contract.__name__} publishes a property "
681
+ f"vocabulary (docs/02-extension-model.md §3 → Ordering constraints), so every "
682
+ f"implementation states what it destroys — an explicit empty tuple if it "
683
+ f"destroys nothing. Add `destroys: tuple[type[Property], ...] = (...)` to the "
684
+ f"plugin class."
685
+ )
686
+ raise MissingRequiredDeclarationError(
687
+ f"'{name}' registers for {contract.__name__} (distribution '{distribution}') "
688
+ f"without declaring `{declaration}`. {contract.__name__}.required_declarations "
689
+ f"names it as mandatory, with no default silently assumed — see the contract's "
690
+ f"own docstring for what it means and what value to give it. Add `{declaration} "
691
+ f"= ...` to the plugin class."
692
+ )