CommandRegistry.register says out loud when the copy drops an optional member (#1226) - #1227
Conversation
…l member (#1226) `register` stores `{ ...command }` and refuses only the four required members, so an optional one that is not own enumerable (a prototype getter on a class instance, `Object.create` inheritance, an own non-enumerable property) is dropped with nothing to refuse: registration succeeds and the command runs without it. Every symptom is an absence - the alias index gets nothing, a command that asked to be `hidden` lists in `hyp --help`, and a lost `plugin` re-derives `category` from the command's own name and `audience` from that. The registry now warns at that boundary, naming the command and the members its copy did not carry, before the defaulting so a dropped `category` is named rather than papered over by the value derived to replace it. The warning takes the stderr mirror, which is the rule LLP 0329 #stderr-mirror settled for a degradation observable only as an absence, and is what makes it a thing a test can see. It reuses `copyMiss` as the probe, so it stays presence-only: `in` walks the chain without invoking anything, a getter it names is never run, and a throwing Proxy `has` trap costs the warning rather than the registration it was only commenting on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
) Review of #1227 found the warning fires before four refusals that can still reject the registration: the audience and bootProfile validations, the duplicate-name check, and the alias-collision check. A class instance with a prototype `get plugin()` under an already-taken name printed CommandRegistry.register: 'a' registered without 'plugin' ... status:degraded on the stderr mirror and then threw `duplicate command name 'a'`, so the one channel LLP 0329 guarantees an operator can see reported a degraded command that does not exist, and the structured record said `command.register` / `degraded` for a registration that never landed. The probe still has to read the copy before the defaulting, or a dropped `category` is papered over by the value derived to replace it. So the probe and the saying are split: `droppedOptionals` reads where the old call sat, and `warnDroppedOptionals` says it once the command is in both indexes. Also makes the `has`-trap control pin what it is named for. Its target had no reachable optional member, so `assert.equal(text, '')` held whether or not the throw was contained; the target now carries a prototype `get plugin()`, which is the warning the trap is supposed to cost. New test `a refused registration is not warned about as a degraded one` fails on 9456311 and passes here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VerdictApprove with fixes applied. The change is sound and well-scoped: Verified against head
Five findings, all low severity. Two were actionable and are fixed in Fixed1. LOW (correctness) - the WARN says "registered" before four refusals that can still reject it
and then threw The PR's stated ordering constraint is only that the probe read the copy before the
2. LOW (test quality) - the
|
Round 2 of review on #1227 found two defects in round 1's own fix, which moved the say after `byName.set` and the alias-index loop. The move made `register` non-atomic. `warnDroppedOptionals` was unguarded, and the mirror's `process.stderr.write` is the one step of the emit that is not already wrapped, so a throw there escaped `register` over a command that was already live in both indexes: the caller sees a failure, `activatePlugins` files a `plugin.activate_failed`, and the command stays dispatchable under a plugin reported as not loaded. Reproduced at the previous head - the registry answered `get('proto')` with the command while `register` threw. `copyMiss` already rules that a throwing `has` trap costs the warning and never the registration; the say now carries the same rule from the other side. And the message was false about the three defaulted members. The probe is snapshotted before the defaulting, correctly, but the sentence is emitted after it, so "registered without 'category'" described a record that does carry a category, just the name-derived one rather than the declared one. It now says "registered without the declared ...", which is true for all eight members and leaves the structured fields untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verdict (round 2 of 2)Approve with fixes applied. Round 1's fix is correct about the thing it set out to fix, and I verified that empirically on all four refusal paths, not just the one it wrote a test for. But the code motion it used introduced two new defects of its own, both in Round 1's three deferred items were not touched by this head and remain open for the maintainer, unchanged. Round-1 fix: the part that is rightThe split is sound. Round 1 pinned only the duplicate-name refusal with a test. I drove a class instance carrying a prototype At At So the fix covers all four paths, and one test for a single code motion is the right amount of test. Also re-checked and clean at this head: Fixed in
|
Triage at the review cap (head
|
Ship risk:
|
Problem
CommandRegistry.registerstores{ ...command }and shape-checks only thefour required members. An optional member that is not own-enumerable (a
prototype getter on a class instance,
Object.createinheritance, an ownnon-enumerable property) is dropped with nothing to refuse: registration
succeeds and the command runs without it.
Every symptom is an absence, measured in PR #1221's review round 2:
hiddenlists inhyp --helppluginre-derivescategoryfrom the first word of the command'sown name instead of
additional, andaudiencebecomeseverydayinsteadof
operator(ormachinewhenhiddenwas also lost)Fix
After the required-member checks pass and before the defaulting,
registerwarns when a known optional member (
plugin,category,audience,bootProfile,group,help,aliases,hidden) is reachable on theregistration but absent from the copy, naming the command and the members.
Placed before the defaulting so a dropped
categoryis named rather thanpapered over by the value derived to replace it.
The warning takes
{ mirrorStderr: true }. That is the ruleLLP 0329 #stderr-mirror
settled for a degradation observable only as an absence: without it the WARN
is dropped before any exporter on a default install (no
HYP_DEV_TELEMETRY,no OTLP endpoint), which is the same silence the drop already has. It is
per-call-site, not a level, so an ordinary registration stays silent, and it
is what makes the behaviour something a test can see (#testable).
The probe is
copyMiss, reused exactly as PR #1221 established it, so the newpath keeps both of its properties: presence-only (
inwalks the chain andinvokes no accessor, pinned at
reads === 0on a class whose optional membersare prototype getters), and no error escapes (a throwing Proxy
hastrapcosts the warning, never the registration it was only commenting on).
No new LLP: this realizes LLP 0329's existing rule rather than deciding
anything new.
docs/PLUGIN_AUTHORING.mdsaid the drop happens "with no errorat all"; that sentence is updated to name the WARN, since it is now the only
sign.
Proof
test/core/command-registry-register.test.js, three tests, the first of whichfails on master (
a dropped optional member is warned about at register time)and passes here; the other two are the negative controls that keep the warning
honest:
a dropped optional member is warned about at register time- class withget plugin(),get aliases(),get hidden()on the prototype and the fourrequired members own: the WARN names all three, the command is still
registered (a warning, not a refusal), and no getter ran.
a registration with no optional members warns about nothing- the healthypath, both a bare registration and a fully-populated plain object, writes
nothing at all.
a throwing has trap costs the warning, not the registration- the one trapthe probe reaches must not break a registration it was only annotating.
npm test(5803 tests, 0 fail),npm run typecheck, andnpm run smoke -- cli_bundled_plugins_activatedall green locally.Scope
Issue #1226 carries three items. This PR addresses item 1 only.
Not addressed, and still open on the issue:
closes on PR CommandRegistry.register's refusal names the member the spread left behind (#1007) #1221's option 2 or stays open). That is an explicit maintainer
decision, not a code change.
two snapshotted semantics, for whichever release carries PR Deferred PR #857 review findings: registry copy, group headers, doc hygiene #1004's
narrowing. That release is unreleased.
Fixes #1226