-
-
Notifications
You must be signed in to change notification settings - Fork 138
fix(hooks): correct Factory Droid hooks file shape #2320
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,46 @@ describe("FactorydroidHooks", () => { | |
| }); | ||
|
|
||
| describe("fromRulesyncHooks", () => { | ||
| it("should emit a dedicated hooks file that preserves an override command", async () => { | ||
| const config = { | ||
| version: 1, | ||
| hooks: {}, | ||
| factorydroid: { | ||
| hooks: { | ||
| sessionStart: [{ type: "command", command: ".rulesync/hooks/notify.sh" }], | ||
| }, | ||
| }, | ||
| }; | ||
| const rulesyncHooks = new RulesyncHooks({ | ||
| outputRoot: testDir, | ||
| relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, | ||
| relativeFilePath: "hooks.json", | ||
| fileContent: JSON.stringify(config), | ||
| validate: false, | ||
| }); | ||
|
|
||
| const factorydroidHooks = await FactorydroidHooks.fromRulesyncHooks({ | ||
| outputRoot: testDir, | ||
| rulesyncHooks, | ||
| validate: false, | ||
| global: true, | ||
| }); | ||
|
|
||
| const parsed = JSON.parse(factorydroidHooks.getFileContent()); | ||
| expect(parsed).toEqual({ | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test doesn't reproduce issue #2317: the issue used an absolute command ( |
||
| SessionStart: [ | ||
| { | ||
| hooks: [ | ||
| { | ||
| type: "command", | ||
| command: '"$FACTORY_PROJECT_DIR"/.rulesync/hooks/notify.sh', | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| it("should filter shared hooks to Factory Droid-supported events and convert to PascalCase", async () => { | ||
| await ensureDir(join(testDir, ".factory")); | ||
| await writeFileContent(join(testDir, ".factory", "settings.json"), JSON.stringify({})); | ||
|
|
@@ -63,9 +103,9 @@ describe("FactorydroidHooks", () => { | |
|
|
||
| const content = factorydroidHooks.getFileContent(); | ||
| const parsed = JSON.parse(content); | ||
| expect(parsed.hooks.SessionStart).toBeDefined(); | ||
| expect(parsed.hooks.Stop).toBeDefined(); | ||
| expect(parsed.hooks.afterFileEdit).toBeUndefined(); | ||
| expect(parsed.SessionStart).toBeDefined(); | ||
| expect(parsed.Stop).toBeDefined(); | ||
| expect(parsed.afterFileEdit).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("should prefix non-absolute commands with $FACTORY_PROJECT_DIR", async () => { | ||
|
|
@@ -94,7 +134,7 @@ describe("FactorydroidHooks", () => { | |
|
|
||
| const content = factorydroidHooks.getFileContent(); | ||
| const parsed = JSON.parse(content); | ||
| const sessionStartEntry = parsed.hooks.SessionStart[0]; | ||
| const sessionStartEntry = parsed.SessionStart[0]; | ||
| expect(sessionStartEntry).toBeDefined(); | ||
| expect(sessionStartEntry.matcher).toBeUndefined(); | ||
| expect(sessionStartEntry.hooks[0].command).toContain("$FACTORY_PROJECT_DIR"); | ||
|
|
@@ -127,7 +167,7 @@ describe("FactorydroidHooks", () => { | |
|
|
||
| const content = factorydroidHooks.getFileContent(); | ||
| const parsed = JSON.parse(content); | ||
| expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe( | ||
| expect(parsed.SessionStart[0].hooks[0].command).toBe( | ||
| '"$FACTORY_PROJECT_DIR"/scripts/format.sh --fix --quiet', | ||
| ); | ||
| }); | ||
|
|
@@ -160,7 +200,7 @@ describe("FactorydroidHooks", () => { | |
|
|
||
| const content = factorydroidHooks.getFileContent(); | ||
| const parsed = JSON.parse(content); | ||
| expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe( | ||
| expect(parsed.SessionStart[0].hooks[0].command).toBe( | ||
| "$FACTORY_PROJECT_DIR/.factory/hooks/start.sh", | ||
| ); | ||
| }); | ||
|
|
@@ -202,9 +242,9 @@ describe("FactorydroidHooks", () => { | |
|
|
||
| const content = factorydroidHooks.getFileContent(); | ||
| const parsed = JSON.parse(content); | ||
| expect(parsed.hooks.SessionStart[0].hooks[0].command).toContain("factory-override.sh"); | ||
| expect(parsed.hooks.Notification).toBeDefined(); | ||
| expect(parsed.hooks.Notification[0].matcher).toBe("permission_prompt"); | ||
| expect(parsed.SessionStart[0].hooks[0].command).toContain("factory-override.sh"); | ||
| expect(parsed.Notification).toBeDefined(); | ||
| expect(parsed.Notification[0].matcher).toBe("permission_prompt"); | ||
| }); | ||
|
|
||
| it("should not leak Claude config hooks into Factory Droid output", async () => { | ||
|
|
@@ -245,9 +285,9 @@ describe("FactorydroidHooks", () => { | |
| const content = factorydroidHooks.getFileContent(); | ||
| const parsed = JSON.parse(content); | ||
| // Shared hooks should be present | ||
| expect(parsed.hooks.SessionStart[0].hooks[0].command).toContain("shared.sh"); | ||
| expect(parsed.SessionStart[0].hooks[0].command).toContain("shared.sh"); | ||
| // Factory Droid-specific override should be present | ||
| expect(parsed.hooks.Stop).toBeDefined(); | ||
| expect(parsed.Stop).toBeDefined(); | ||
| // Claude-specific hooks must NOT leak into Factory Droid output | ||
| expect(JSON.stringify(parsed)).not.toContain("claude-only.sh"); | ||
| expect(JSON.stringify(parsed)).not.toContain("claude-notify.sh"); | ||
|
|
@@ -303,8 +343,8 @@ describe("FactorydroidHooks", () => { | |
| const content = factorydroidHooks.getFileContent(); | ||
| const parsed = JSON.parse(content); | ||
| expect(parsed.otherKey).toBe("preserved"); | ||
| expect(parsed.hooks).toBeDefined(); | ||
| expect(parsed.hooks.SessionStart).toBeDefined(); | ||
| expect(parsed.hooks).toBeUndefined(); | ||
| expect(parsed.SessionStart).toBeDefined(); | ||
| }); | ||
|
|
||
| it("should handle hooks with matcher grouping", async () => { | ||
|
|
@@ -337,15 +377,15 @@ describe("FactorydroidHooks", () => { | |
|
|
||
| const content = factorydroidHooks.getFileContent(); | ||
| const parsed = JSON.parse(content); | ||
| expect(parsed.hooks.PreToolUse).toHaveLength(2); | ||
| expect(parsed.PreToolUse).toHaveLength(2); | ||
|
|
||
| const writeEntry = parsed.hooks.PreToolUse.find( | ||
| const writeEntry = parsed.PreToolUse.find( | ||
| (e: Record<string, unknown>) => e.matcher === "Write", | ||
| ); | ||
| expect(writeEntry).toBeDefined(); | ||
| expect(writeEntry.hooks).toHaveLength(2); | ||
|
|
||
| const editEntry = parsed.hooks.PreToolUse.find( | ||
| const editEntry = parsed.PreToolUse.find( | ||
| (e: Record<string, unknown>) => e.matcher === "Edit", | ||
| ); | ||
| expect(editEntry).toBeDefined(); | ||
|
|
@@ -378,7 +418,7 @@ describe("FactorydroidHooks", () => { | |
|
|
||
| const content = factorydroidHooks.getFileContent(); | ||
| const parsed = JSON.parse(content); | ||
| const hookDef = parsed.hooks.PreToolUse[0].hooks[0]; | ||
| const hookDef = parsed.PreToolUse[0].hooks[0]; | ||
| expect(hookDef.type).toBe("prompt"); | ||
| expect(hookDef.prompt).toBe("Check this tool call"); | ||
| expect(hookDef.timeout).toBe(30000); | ||
|
|
@@ -409,7 +449,7 @@ describe("FactorydroidHooks", () => { | |
| }); | ||
|
|
||
| const parsed = JSON.parse(factorydroidHooks.getFileContent()); | ||
| const hookDef = parsed.hooks.PreToolUse[0].hooks[0]; | ||
| const hookDef = parsed.PreToolUse[0].hooks[0]; | ||
| expect(hookDef.prompt).toBe("Check this tool call"); | ||
| expect(hookDef.model).toBeUndefined(); | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -115,7 +115,7 @@ export class FactorydroidHooks extends ToolHooks { | |
| converterConfig: FACTORYDROID_CONVERTER_CONFIG, | ||
| logger, | ||
| }); | ||
| const merged = { ...settings, hooks: factorydroidHooks }; | ||
| const merged = { ...settings, ...factorydroidHooks }; | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the crux: the Factory hooks reference documents |
||
| const fileContent = JSON.stringify(merged, null, 2); | ||
| return new FactorydroidHooks({ | ||
| outputRoot, | ||
|
|
@@ -127,9 +127,9 @@ export class FactorydroidHooks extends ToolHooks { | |
| } | ||
|
|
||
| toRulesyncHooks(): RulesyncHooks { | ||
| let settings: { hooks?: unknown }; | ||
| let parsed: { hooks?: unknown }; | ||
| try { | ||
| settings = JSON.parse(this.getFileContent()); | ||
| parsed = JSON.parse(this.getFileContent()); | ||
| } catch (error) { | ||
| throw new Error( | ||
| `Failed to parse Factory Droid hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, | ||
|
|
@@ -139,7 +139,7 @@ export class FactorydroidHooks extends ToolHooks { | |
| ); | ||
| } | ||
| const hooks = toolHooksToCanonical({ | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| hooks: settings.hooks, | ||
| hooks: parsed.hooks ?? parsed, | ||
| converterConfig: FACTORYDROID_CONVERTER_CONFIG, | ||
| }); | ||
| return this.toRulesyncHooksDefault({ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With
rootIsHooks = truethis assertion becomes near-vacuous:expect(hooks).toBeDefined()is trivially true for any parsed object, and stringifying the whole file then grepping for the command substrings passes regardless of the file's structure. That's how the shape change sailed through the e2e matrix green. The e2e check should pin the documented shape (e.g. assertparsed.hooks.SessionStartexists) rather than degrade to a whole-file substring match.