fix: register gsd-workflow-guard.js in settings.json during install (#1772)

The hook was built, copied to hooks/dist/, and installed to disk — but
never registered as a PreToolUse entry in settings.json, making the
hooks.workflow_guard config flag permanently inert.

Adds the registration block following the same pattern as the other
community hooks (prompt-guard, read-guard, validate-commit, etc.).

Includes regression test that verifies every JS hook in gsdHooks has a
corresponding command construction and registration block.

Fixes #1767

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-05 12:30:24 -04:00
committed by GitHub
parent bdc143aa7f
commit 0b6ef6fa24
2 changed files with 125 additions and 0 deletions

View File

@@ -5635,6 +5635,30 @@ function install(isGlobal, runtime = 'claude') {
// and exits silently (no-op) if not enabled. This lets users enable
// them per-project by adding: "hooks": { "community": true }
// Configure workflow guard hook (opt-in via hooks.workflow_guard: true)
// Detects file edits outside GSD workflow context and advises using
// /gsd-quick or /gsd-fast for state-tracked changes. Advisory only.
const workflowGuardCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-workflow-guard.js')
: 'node ' + dirName + '/hooks/gsd-workflow-guard.js';
const hasWorkflowGuardHook = settings.hooks[preToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-workflow-guard'))
);
if (!hasWorkflowGuardHook) {
settings.hooks[preToolEvent].push({
matcher: 'Write|Edit',
hooks: [
{
type: 'command',
command: workflowGuardCommand,
timeout: 5
}
]
});
console.log(` ${green}✓${reset} Configured workflow guard hook (opt-in via hooks.workflow_guard)`);
}
// Configure commit validation hook (Conventional Commits enforcement, opt-in)
const validateCommitCommand = isGlobal
? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-validate-commit.sh'

View File

@@ -0,0 +1,101 @@
/**
* Regression guard for #1767: gsd-workflow-guard.js must be registered in settings.json
*
* The hook file is built, copied, and installed — but was never registered as a
* PreToolUse hook entry in install.js. This test ensures the registration block
* exists with the correct structure.
*
* Also tests the broader anti-pattern: every hook in gsdHooks that is a JS
* PreToolUse/PostToolUse hook should have a corresponding registration block.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js');
describe('workflow-guard hook registration (#1767)', () => {
test('install.js constructs a command path variable for gsd-workflow-guard.js', () => {
const content = fs.readFileSync(INSTALL_JS, 'utf-8');
const lines = content.split('\n');
// Every registered JS hook has a command variable constructed via
// buildHookCommand() or string concatenation. Filter out references
// that are only in the cleanup/uninstall arrays.
const commandConstructionLines = lines.filter(line =>
line.includes('gsd-workflow-guard.js') &&
(line.includes('buildHookCommand') || line.includes("'node '"))
);
assert.ok(
commandConstructionLines.length > 0,
[
'install.js must construct a command path for gsd-workflow-guard.js',
'(e.g. buildHookCommand or node + dirName pattern).',
'Currently only referenced in gsdHooks cleanup array.',
].join(' ')
);
});
test('install.js has a hasWorkflowGuardHook dedup check', () => {
const content = fs.readFileSync(INSTALL_JS, 'utf-8');
// Every registered hook has a dedup check: hasXxxHook = settings.hooks[...].some(...)
const hasDedup = content.includes('hasWorkflowGuardHook') ||
content.includes('hasWorkflowGuard');
assert.ok(
hasDedup,
'install.js must have a dedup check variable for workflow-guard (like hasPromptGuardHook)'
);
});
test('install.js pushes workflow-guard entry with correct matcher', () => {
const content = fs.readFileSync(INSTALL_JS, 'utf-8');
// Extract the section between "workflow-guard" command construction
// and the next console.log confirmation. The push block should have:
// matcher: 'Write|Edit' and command referencing workflow-guard
const workflowGuardSection = content.match(
/workflowGuardCommand[\s\S]*?console\.log\([^)]*workflow.guard/i
);
assert.ok(
workflowGuardSection,
'install.js must have a push block for workflow-guard with a console.log confirmation'
);
});
});
describe('hook registration completeness anti-pattern guard', () => {
test('every JS hook in gsdHooks has a command construction in install.js', () => {
const content = fs.readFileSync(INSTALL_JS, 'utf-8');
// Extract gsdHooks array entries
const hooksMatch = content.match(/gsdHooks\s*=\s*\[([^\]]+)\]/);
assert.ok(hooksMatch, 'gsdHooks array must exist in install.js');
const hookNames = hooksMatch[1]
.match(/'([^']+)'/g)
.map(h => h.replace(/'/g, ''));
const jsHooks = hookNames.filter(h => h.endsWith('.js'));
const missing = [];
for (const hook of jsHooks) {
// Each JS hook should have a buildHookCommand or 'node ' command construction
// that references the hook filename (not just the gsdHooks array or uninstall filter)
const hookBase = hook.replace('.js', '');
const lines = content.split('\n').filter(line =>
line.includes(hook) &&
(line.includes('buildHookCommand') || line.includes("'node '"))
);
if (lines.length === 0) {
missing.push(hook);
}
}
assert.strictEqual(
missing.length, 0,
[
'Every JS hook in gsdHooks must have a command construction in install.js.',
'Missing registration for:',
...missing.map(h => ` - ${h}`),
].join('\n')
);
});
});