test(hooks): add duplicate hookScope clobber warning test (#544)#554
Conversation
Add failing test proving Node runtime silently overwrites hookScopes when a plugin registers the same hook twice with different scopes. Test asserts a warning containing 'duplicate' must be logged. Add NewNodeRuntimeWithHooks test helper in export_test.go. Update IMPLEMENTATION.md with warn-on-overwrite requirement. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a red (failing) test to drive a new requirement in the Node plugin runtime: emit a warning when a hook scope entry is overwritten due to duplicate hook registration, while keeping last-wins semantics. It also adds a small NodeRuntime test helper and updates the implementation spec to document the expected warning behavior.
Changes:
- Add a new failing spec asserting a “duplicate registration overwrites previous scope” warning (issue #544).
- Add
NewNodeRuntimeWithHookshelper for constructing a NodeRuntime with pre-seeded hooks/scopes in tests. - Update
plans/IMPLEMENTATION.mdwith warn-on-overwrite behavior for Node hook scope parsing.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| plans/IMPLEMENTATION.md | Documents a warn-on-overwrite requirement for Node hook scope clobber handling. |
| internal/plugin/scope_test.go | Adds a new (currently failing) Ginkgo spec for duplicate hook scope clobber warning behavior. |
| internal/plugin/export_test.go | Adds a test helper to construct NodeRuntime instances with preset hooks/scopes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Simulate what EvalFile does: set hookScopes twice for the same name. | ||
| // After the fix, the second assignment must emit a warning. | ||
| rt := plugin.NewNodeRuntimeWithHooks( | ||
| []string{"onContentTransformed", "onContentTransformed"}, | ||
| map[string]*plugin.HookScope{"onContentTransformed": scopeB}, | ||
| nil, | ||
| ) | ||
| regs := rt.RegisteredHookDetails() | ||
| Expect(regs).To(HaveLen(2), | ||
| "duplicate hook names must produce two registrations (issue #544)") | ||
|
|
||
| // The fix must add a warning when hookScopes[name] is overwritten. | ||
| // Currently no warning is emitted — this assertion drives the fix. | ||
| _ = scopeA | ||
| Expect(logBuf.String()).To(ContainSubstring("duplicate"), | ||
| "EvalFile must log a warning containing 'duplicate' when a hook scope "+ | ||
| "is overwritten by a second registration of the same hook name (issue #544)") |
There was a problem hiding this comment.
Valid. The test constructs a NodeRuntime via helper and calls RegisteredHookDetails() — a pure getter with no side effects. No code path in this test writes to logBuf, so the assertion can never pass regardless of implementation. The test needs to either exercise EvalFile directly or target the actual code path where the warning would fire.
Addressing in rework — the spec and test both need structural changes (see Reviewer P0).
| var logBuf bytes.Buffer | ||
| log.SetOutput(&logBuf) | ||
| defer log.SetOutput(os.Stderr) | ||
|
|
There was a problem hiding this comment.
Acknowledged. The log.SetOutput approach mutates global state and restores to os.Stderr rather than saving/restoring log.Writer(). Will address in the test rework — the log capture pattern itself may change depending on whether the warning moves to bridge.js or stays in Go.
| - **Bridge updates**: | ||
| - **QuickJS** (`wasm.go`): `alloy.hook(name, opts, fn)` at line 96-99 — swap second and third arguments. `__registerHook` Go callback at line 71-79 — receives `(name, priority, scopeJSON)` instead of `(name, priority)`. Parse `scopeJSON` into `HookScope`. Call `ValidateScope(event, scope)` — if error, log warning and skip registration. | ||
| - **Node** (`node.go`): Hook registration in eval response includes scope JSON alongside hook name and priority. `loadPluginRuntime` parses scope and calls `RegisterWithOptions`. **Eliminate double JSON round-trip (issue #545)**: `EvalFile` currently marshals the Go `map[string]interface{}` scope to JSON via `json.Marshal`, then immediately parses it back via `parseScopeJSON`. Refactor: extract the type-switch logic from `parseScopeJSON` into `parseScopeMap(m map[string]interface{}) (*HookScope, error)` which builds `HookScope` directly from a Go map via type assertions. `parseScopeJSON` becomes a thin wrapper: unmarshal JSON → call `parseScopeMap`. `EvalFile` calls `parseScopeMap` directly, eliminating the round-trip. One code path for the polymorphic `pages` handling. | ||
| - **Node** (`node.go`): Hook registration in eval response includes scope JSON alongside hook name and priority. `loadPluginRuntime` parses scope and calls `RegisterWithOptions`. **Eliminate double JSON round-trip (issue #545)**: `EvalFile` currently marshals the Go `map[string]interface{}` scope to JSON via `json.Marshal`, then immediately parses it back via `parseScopeJSON`. Refactor: extract the type-switch logic from `parseScopeJSON` into `parseScopeMap(m map[string]interface{}) (*HookScope, error)` which builds `HookScope` directly from a Go map via type assertions. `parseScopeJSON` becomes a thin wrapper: unmarshal JSON → call `parseScopeMap`. `EvalFile` calls `parseScopeMap` directly, eliminating the round-trip. One code path for the polymorphic `pages` handling. **Warn on duplicate hookScope clobber (issue #544)**: In `EvalFile`, before `hookScopes[name] = scope`, check if `hookScopes[name]` already exists. If so, `log.Printf("warning: plugin hook %s: duplicate registration overwrites previous scope", name)`. Last-wins semantics are preserved (matching HookRegistry behavior) but plugin authors get feedback. The warning fires per-plugin, not cross-plugin — cross-plugin duplicates are handled by the registry's "last loaded wins" policy. |
There was a problem hiding this comment.
This is the root cause of the P0. The bridge protocol returns hookScopes as a JS object keyed by hook name — Object.keys() deduplicates inherently. Go's EvalFile receives pre-deduplicated data and has no way to detect within-plugin duplicates.
Three options:
- Move detection to bridge.js — warn at
register()time when a hook name is already registered with a different scope - Change bridge protocol — send hookScopes as
[{name, scope}]array instead of an object, letting Go detect duplicates - Refocus on cross-plugin — detect when multiple
EvalFilecalls register the same hook name with different scopes
Option 1 is simplest and catches the problem at its source. Will rework the spec accordingly.
Code Review — PR #554Run: Scope: 1 commit ( Intent: Spec PR — add a failing test to drive implementation of issue #544 (warn on duplicate hookScope clobber). No production code changes. P0 — Critical
P1 — High
Testing gaps (not blocking, routed from testing/previous-comments)
Dropped findings (5)
Verdict: Not ready. The test has a structural mismatch with the spec: it bypasses EvalFile entirely and exercises a pure getter, so no implementation of the spec as written (add a check in EvalFile before The spec text also contradicts itself: it asks for a "per-plugin" warning at a check location that only fires cross-plugin, because JS objects deduplicate keys before the bridge sends data to Go. |
Response to Code ReviewAll findings accepted. The test and spec have a fundamental structural flaw. P0 — Test bypasses EvalFileCorrect. P1 —
|
Addresses P0 + P1 review findings on PR #554. The previous test bypassed EvalFile entirely, exercised a pure getter, and the spec contradicted the bridge protocol (JS objects deduplicate keys before Go sees them). Rework: move duplicate detection to bridge.js at registration time. Test now exercises EvalFile with a fixture that registers the same hook twice. EvalWarnings() accessor surfaces bridge-side warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| hooks []string | ||
| hookScopes map[string]*HookScope // hook name → scope from bridge | ||
| hookPriorities map[string]int // hook name → priority from bridge | ||
| pluginPaths []string // paths loaded via EvalFile, for worker replication | ||
| workerPool []*NodeBridge // additional bridges for parallel hook dispatch | ||
| evalWarnings []string // warnings from bridge eval (e.g., duplicate hooks) | ||
| } | ||
|
|
There was a problem hiding this comment.
Correct — this is intentional. The stub exists so the test compiles and fails red at the assertion level. Parsing resultMap["warnings"] in EvalFile is implementation work, not spec work. The IMPLEMENTATION.md spec is explicit: "EvalFile parses the warnings array from the eval response and appends each string to r.evalWarnings." The implementer adds the parsing; the test goes green.
| Describe("Duplicate hookScope clobber warning (issue #544)", func() { | ||
| It("EvalFile warns when a plugin registers the same hook twice", func() { | ||
| rt := plugin.NewNodeRuntime() | ||
| DeferCleanup(rt.Close) | ||
|
|
||
| err := rt.EvalFile(filepath.Join(testdataDir(), "single-files", "duplicate-hook.js")) | ||
| Expect(err).NotTo(HaveOccurred(), |
There was a problem hiding this comment.
The PR description is from the original commit (pre-rework). The rework commit a7a2efa removed NewNodeRuntimeWithHooks — the test now uses plugin.NewNodeRuntime() + EvalFile() directly. The description reflects the original approach; the current code is correct.
Code Review — PR #554 (Review #2)Verdict: Ready to merge ✅ Run: Prior findings — all addressed
New findings — none blockingNo actionable findings. 9 findings from correctness and testing reviewers were stale (reviewed first commit Advisory notesHooks list double-append (spec gap, not a PR defect): EvalFile warnings parsing deferred to implementation: The What the rework got right
|
Response to Review #2Thanks for the clean bill of health. Re: hooks list double-append (advisory) — Agreed this is a separate concern. The Re: EvalFile warnings parsing — Confirmed, the stubs are intentionally minimal. Implementation adds bridge.js detection + Go-side parsing in a single commit. PR description updated to reflect the rework. |
Summary
EvalFilemust surface a warning viaEvalWarnings()when a plugin registers the same hook name twice with different scopesduplicate-hook.jstest fixture that registersonContentTransformedtwice (pages:false then pages:true)evalWarnings []stringfield andEvalWarnings()stub onNodeRuntimewarningsarray in eval response, Go parses and stores viaEvalWarnings()Design decision: last-wins semantics preserved. Warning is informational, not an error. Detection happens at registration time in bridge.js because that is where sequential overwrites of
hooks[name]are observable.1 test fails red (EvalWarnings returns nil — bridge doesn't detect duplicates yet). 1 test passes green (last-wins is current behavior). 0 regressions (161 existing plugin specs pass).
Ref #544
Test plan
go test ./internal/plugin/... --ginkgo.focus="Duplicate"go test ./internal/plugin/... --ginkgo.skip="Duplicate"go vet ./internal/plugin/...— clean🤖 Generated with Claude Code