From 08abfe1e9eda74dfa4cb3d6072ffe41556017bef Mon Sep 17 00:00:00 2001 From: edenbuilds <279970382+edenbuilds@users.noreply.github.com> Date: Thu, 10 Sep 2026 03:18:58 +0530 Subject: [PATCH] fix(extensions): reject non-callable VCS operation watch hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registerVcsAdapter validated that an operation's `load` field is callable but let `watchSignature` and `watchPlan` through unchecked. A truthy non-function value (e.g. a typo'd string) passed the `watchSignature && {...}` spread guard in toInternalVcsOperation and was wrapped, so calling it threw a TypeError only once watch planning invoked it — well after registration. Require both optional hooks to be absent or callable before an operation is accepted, matching the existing drop-on-`load` behavior. Fixes #763 Co-Authored-By: Claude Sonnet 5 --- .../reject-malformed-vcs-watch-hooks.md | 5 ++++ .../extensions/publicApiRobustness.test.ts | 30 +++++++++++++++++++ packages/hunk/src/extensions/runExtension.ts | 7 ++++- 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 .changeset/reject-malformed-vcs-watch-hooks.md diff --git a/.changeset/reject-malformed-vcs-watch-hooks.md b/.changeset/reject-malformed-vcs-watch-hooks.md new file mode 100644 index 000000000..f0945407b --- /dev/null +++ b/.changeset/reject-malformed-vcs-watch-hooks.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Extension VCS operation registration now requires `watchSignature` and `watchPlan` to be absent or callable. A non-callable hook is dropped along with its operation at registration instead of throwing a `TypeError` the first time watch planning invokes it. diff --git a/packages/hunk/src/extensions/publicApiRobustness.test.ts b/packages/hunk/src/extensions/publicApiRobustness.test.ts index 961e51643..fcf7a2885 100644 --- a/packages/hunk/src/extensions/publicApiRobustness.test.ts +++ b/packages/hunk/src/extensions/publicApiRobustness.test.ts @@ -352,6 +352,36 @@ describe("registerVcsAdapter with junk", () => { expect(registry.vcsAdapters[0]?.adapter.operations).toEqual({}); }); + test("an operation with a non-callable watch hook is dropped, not wrapped", () => { + const { registry, issues } = loadFactory( + (hunk: { registerVcsAdapter: (adapter: unknown) => void }) => { + hunk.registerVcsAdapter({ + id: "hg", + name: "Mercurial", + detect: (cwd: string) => ({ id: "hg", repoRoot: cwd }), + operations: { + "working-tree-diff": { + load: async () => ({ files: [] }), + watchSignature: "not-a-function", + }, + "revision-show": { + load: async () => ({ files: [] }), + watchPlan: 42, + }, + "stash-show": { load: async () => ({ files: [] }) }, + }, + }); + }, + ); + + expect(issues).toEqual([]); + // Truthy-but-uncallable hooks would otherwise pass the `watchSignature &&` + // guard in toInternalVcsOperation and fail with a TypeError only once + // watch planning invokes them, well after registration. + const operations = registry.vcsAdapters[0]?.adapter.operations ?? {}; + expect(Object.keys(operations)).toEqual(["stash-show"]); + }); + test("built-in ids stay reserved however an extension asks for them", () => { const { registry } = loadFactory((hunk: { registerVcsAdapter: (a: unknown) => void }) => { for (const id of ["git", "jj", "sl"]) { diff --git a/packages/hunk/src/extensions/runExtension.ts b/packages/hunk/src/extensions/runExtension.ts index 714bf1aa2..ba93f273f 100644 --- a/packages/hunk/src/extensions/runExtension.ts +++ b/packages/hunk/src/extensions/runExtension.ts @@ -502,7 +502,12 @@ export function toInternalVcsAdapter( const internalOperations: Record> = {}; for (const [kind, operation] of Object.entries(operations ?? {})) { - if (isPlainObject(operation) && typeof operation.load === "function") { + if ( + isPlainObject(operation) && + typeof operation.load === "function" && + (operation.watchSignature === undefined || typeof operation.watchSignature === "function") && + (operation.watchPlan === undefined || typeof operation.watchPlan === "function") + ) { internalOperations[kind] = toInternalVcsOperation( operation as unknown as ExtensionVcsOperation, );