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, );