@devrik-tools/claude-gates 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.claude-plugin/marketplace.json +23 -17
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/config.mjs +124 -113
  8. package/cli/index.mjs +100 -97
  9. package/cli/init.mjs +259 -256
  10. package/cli/install.mjs +150 -80
  11. package/cli/materialize.mjs +102 -51
  12. package/cli/registry.mjs +136 -127
  13. package/cli/task.mjs +140 -0
  14. package/package.json +74 -73
  15. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  16. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  17. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  18. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  19. package/plugins/gates/hooks/doctor.mjs +207 -0
  20. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  21. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  22. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  23. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  24. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  25. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  26. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  27. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  28. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  29. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  30. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  31. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  32. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  33. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  34. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  35. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  36. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  39. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  40. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  41. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  42. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  43. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  44. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  45. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  46. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  47. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  48. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  49. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  50. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  51. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  52. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  53. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  54. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  55. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  56. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  57. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  58. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  59. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  60. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  61. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  62. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  63. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  64. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  65. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  66. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  67. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  68. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  69. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  70. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  71. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  72. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  73. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  74. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  75. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  76. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  77. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  78. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  79. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  80. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  81. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  82. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  83. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  84. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  85. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  86. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  87. package/plugins/gates/hooks/hooks.json +346 -286
  88. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  89. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  90. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  91. package/plugins/gates/hooks/lib/config.mjs +172 -165
  92. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  93. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  94. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  95. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  96. package/plugins/tasks/.claude-plugin/plugin.json +9 -0
  97. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  98. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  99. package/plugins/tasks/hooks/hooks.json +26 -0
  100. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  101. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  102. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  103. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  104. package/registry.json +793 -686
@@ -1,17 +1,23 @@
1
- {
2
- "name": "claude-gates",
3
- "owner": {
4
- "name": "claude-gates"
5
- },
6
- "metadata": {
7
- "description": "Installable deterministic gates (hooks) for Claude Code, selectable per project or globally with `npx @devrik99/claude-gates init`."
8
- },
9
- "plugins": [
10
- {
11
- "name": "gates",
12
- "source": "./plugins/gates",
13
- "description": "PreToolUse gates: destructive-command blocks, protected paths, delegation/spec/quality rules, tool-discovery and forge-pipeline enforcement. Which gates run is decided by .ai/config.json (project) or ~/.claude/claude-gates/config.json (global).",
14
- "version": "0.1.0"
15
- }
16
- ]
17
- }
1
+ {
2
+ "name": "claude-gates",
3
+ "owner": {
4
+ "name": "claude-gates"
5
+ },
6
+ "metadata": {
7
+ "description": "Installable deterministic gates (hooks) for Claude Code, selectable per project or globally with `npx @devrik99/claude-gates init`."
8
+ },
9
+ "plugins": [
10
+ {
11
+ "name": "gates",
12
+ "source": "./plugins/gates",
13
+ "description": "PreToolUse gates: destructive-command blocks, protected paths, delegation/spec/quality rules, tool-discovery and forge-pipeline enforcement. Which gates run is decided by .ai/config.json (project) or ~/.claude/claude-gates/config.json (global).",
14
+ "version": "0.1.0"
15
+ },
16
+ {
17
+ "name": "tasks",
18
+ "source": "./plugins/tasks",
19
+ "description": "Deterministic task tracking: the model registers tasks via the CLI, a UserPromptSubmit hook reminds of open tasks every few messages, and a SessionStart hook lists active tasks when a session opens.",
20
+ "version": "0.1.0"
21
+ }
22
+ ]
23
+ }
@@ -1,101 +1,113 @@
1
- import assert from 'node:assert/strict';
2
- import {
3
- mkdtempSync,
4
- mkdirSync,
5
- writeFileSync,
6
- readFileSync,
7
- existsSync,
8
- } from 'node:fs';
9
- import { tmpdir } from 'node:os';
10
- import { join } from 'node:path';
11
- import { test } from 'node:test';
12
- import {
13
- SCOPES,
14
- findProjectRoot,
15
- configPathFor,
16
- readConfig,
17
- mergeConfig,
18
- writeConfig,
19
- } from '../config.mjs';
20
-
21
- function temporaryDirectory() {
22
- return mkdtempSync(join(tmpdir(), 'claude-gates-'));
23
- }
24
-
25
- test('findProjectRoot climbs to the .git directory and falls back to the start', () => {
26
- const root = temporaryDirectory();
27
- mkdirSync(join(root, '.git'));
28
- const nested = join(root, 'src', 'deep');
29
- mkdirSync(nested, { recursive: true });
30
- assert.equal(findProjectRoot(nested), root);
31
-
32
- const loose = temporaryDirectory();
33
- assert.equal(findProjectRoot(loose), loose);
34
- });
35
-
36
- test('findProjectRoot never climbs into the home directory', () => {
37
- const fakeHome = temporaryDirectory();
38
- mkdirSync(join(fakeHome, '.ai'));
39
- const project = join(fakeHome, 'repos', 'app');
40
- mkdirSync(project, { recursive: true });
41
- assert.equal(findProjectRoot(project, { home: fakeHome }), project);
42
- });
43
-
44
- test('configPathFor resolves both scopes', () => {
45
- const home = temporaryDirectory();
46
- const project = temporaryDirectory();
47
- mkdirSync(join(project, '.ai'));
48
- assert.equal(
49
- configPathFor(SCOPES.GLOBAL, { home }),
50
- join(home, '.claude', 'claude-gates', 'config.json'),
51
- );
52
- assert.equal(
53
- configPathFor(SCOPES.PROJECT, { cwd: project }),
54
- join(project, '.ai', 'config.json'),
55
- );
56
- assert.throws(() => configPathFor('nope'), /Unknown scope/);
57
- });
58
-
59
- test('readConfig distinguishes missing, valid and corrupt', () => {
60
- const directory = temporaryDirectory();
61
- const path = join(directory, 'config.json');
62
- assert.deepEqual(readConfig(path), {
63
- exists: false,
64
- data: {},
65
- corrupt: false,
66
- });
67
- writeFileSync(path, '{"autoCommit":true}');
68
- assert.deepEqual(readConfig(path), {
69
- exists: true,
70
- data: { autoCommit: true },
71
- corrupt: false,
72
- });
73
- writeFileSync(path, '{nope');
74
- assert.equal(readConfig(path).corrupt, true);
75
- });
76
-
77
- test('mergeConfig keeps unrelated keys and unknown gates, overrides known ones', () => {
78
- const existing = {
79
- autoCommit: true,
80
- gates: { oldGate: true, blockX: false },
81
- };
82
- const merged = mergeConfig(existing, {
83
- adopted: 'partial',
84
- gates: { blockX: true, blockY: false },
85
- gateVersion: '3.0.0',
86
- });
87
- assert.deepEqual(merged, {
88
- autoCommit: true,
89
- adopted: 'partial',
90
- gateVersion: '3.0.0',
91
- gates: { oldGate: true, blockX: true, blockY: false },
92
- });
93
- });
94
-
95
- test('writeConfig creates parent folders and writes pretty JSON with trailing newline', () => {
96
- const directory = temporaryDirectory();
97
- const path = join(directory, 'a', 'b', 'config.json');
98
- writeConfig(path, { adopted: true });
99
- assert.ok(existsSync(path));
100
- assert.equal(readFileSync(path, 'utf8'), '{\n "adopted": true\n}\n');
101
- });
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ mkdtempSync,
4
+ mkdirSync,
5
+ writeFileSync,
6
+ readFileSync,
7
+ existsSync,
8
+ } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { test } from 'node:test';
12
+ import {
13
+ SCOPES,
14
+ findProjectRoot,
15
+ configPathFor,
16
+ readConfig,
17
+ mergeConfig,
18
+ writeConfig,
19
+ } from '../config.mjs';
20
+
21
+ function temporaryDirectory() {
22
+ return mkdtempSync(join(tmpdir(), 'claude-gates-'));
23
+ }
24
+
25
+ test('findProjectRoot climbs to the .git directory and falls back to the start', () => {
26
+ const root = temporaryDirectory();
27
+ mkdirSync(join(root, '.git'));
28
+ const nested = join(root, 'src', 'deep');
29
+ mkdirSync(nested, { recursive: true });
30
+ assert.equal(findProjectRoot(nested), root);
31
+
32
+ const loose = temporaryDirectory();
33
+ assert.equal(findProjectRoot(loose), loose);
34
+ });
35
+
36
+ test('findProjectRoot never climbs into the home directory', () => {
37
+ const fakeHome = temporaryDirectory();
38
+ mkdirSync(join(fakeHome, '.ai'));
39
+ const project = join(fakeHome, 'repos', 'app');
40
+ mkdirSync(project, { recursive: true });
41
+ assert.equal(findProjectRoot(project, { home: fakeHome }), project);
42
+ });
43
+
44
+ test('configPathFor resolves both scopes', () => {
45
+ const home = temporaryDirectory();
46
+ const project = temporaryDirectory();
47
+ mkdirSync(join(project, '.ai'));
48
+ assert.equal(
49
+ configPathFor(SCOPES.GLOBAL, { home }),
50
+ join(home, '.claude', 'claude-gates', 'config.json'),
51
+ );
52
+ assert.equal(
53
+ configPathFor(SCOPES.PROJECT, { cwd: project }),
54
+ join(project, '.ai', 'config.json'),
55
+ );
56
+ assert.throws(() => configPathFor('nope'), /Unknown scope/);
57
+ });
58
+
59
+ test('readConfig distinguishes missing, valid and corrupt', () => {
60
+ const directory = temporaryDirectory();
61
+ const path = join(directory, 'config.json');
62
+ assert.deepEqual(readConfig(path), {
63
+ exists: false,
64
+ data: {},
65
+ corrupt: false,
66
+ });
67
+ writeFileSync(path, '{"autoCommit":true}');
68
+ assert.deepEqual(readConfig(path), {
69
+ exists: true,
70
+ data: { autoCommit: true },
71
+ corrupt: false,
72
+ });
73
+ writeFileSync(path, '{nope');
74
+ assert.equal(readConfig(path).corrupt, true);
75
+ });
76
+
77
+ test('readConfig strips a leading UTF-8 BOM instead of treating the file as corrupt', () => {
78
+ const directory = temporaryDirectory();
79
+ const path = join(directory, 'config.json');
80
+ const bom = '';
81
+ writeFileSync(path, bom + '{"autoCommit":true}');
82
+ assert.deepEqual(readConfig(path), {
83
+ exists: true,
84
+ data: { autoCommit: true },
85
+ corrupt: false,
86
+ });
87
+ });
88
+
89
+ test('mergeConfig keeps unrelated keys and unknown gates, overrides known ones', () => {
90
+ const existing = {
91
+ autoCommit: true,
92
+ gates: { oldGate: true, blockX: false },
93
+ };
94
+ const merged = mergeConfig(existing, {
95
+ adopted: 'partial',
96
+ gates: { blockX: true, blockY: false },
97
+ gateVersion: '3.0.0',
98
+ });
99
+ assert.deepEqual(merged, {
100
+ autoCommit: true,
101
+ adopted: 'partial',
102
+ gateVersion: '3.0.0',
103
+ gates: { oldGate: true, blockX: true, blockY: false },
104
+ });
105
+ });
106
+
107
+ test('writeConfig creates parent folders and writes pretty JSON with trailing newline', () => {
108
+ const directory = temporaryDirectory();
109
+ const path = join(directory, 'a', 'b', 'config.json');
110
+ writeConfig(path, { adopted: true });
111
+ assert.ok(existsSync(path));
112
+ assert.equal(readFileSync(path, 'utf8'), '{\n "adopted": true\n}\n');
113
+ });
@@ -0,0 +1,102 @@
1
+ // installPlugin used to install only the marketplace's FIRST plugin (`const [plugin] =
2
+ // manifest.plugins`), so a second plugin (like `tasks`) never got installed by `init`. These
3
+ // tests exercise the fixed multi-plugin behavior with a fake `runClaude` — never the real
4
+ // `claude` binary, which would actually install plugins on the machine running the test.
5
+
6
+ import assert from 'node:assert/strict';
7
+ import { test } from 'node:test';
8
+ import { SCOPES } from '../config.mjs';
9
+ import { installPlugin, pluginInstallCommands } from '../install.mjs';
10
+ import { REPOSITORY_ROOT } from '../constants.mjs';
11
+
12
+ test('pluginInstallCommands lists one command per plugin the marketplace declares (gates AND tasks)', () => {
13
+ const commands = pluginInstallCommands();
14
+ assert.ok(
15
+ commands.some((command) => command.includes('install gates@')),
16
+ 'gates must still be installable',
17
+ );
18
+ assert.ok(
19
+ commands.some((command) => command.includes('install tasks@')),
20
+ 'tasks must now be installable too — this was the whole point of the fix',
21
+ );
22
+ });
23
+
24
+ test('installPlugin installs EVERY plugin, not just the first', () => {
25
+ const invocations = [];
26
+ const runClaude = (args) => {
27
+ invocations.push(args);
28
+ return '';
29
+ };
30
+
31
+ const result = installPlugin(SCOPES.PROJECT, { cwd: REPOSITORY_ROOT, runClaude });
32
+
33
+ assert.equal(result.installed, true);
34
+ const installedPlugins = result.results.map((entry) => entry.plugin);
35
+ assert.ok(installedPlugins.includes('gates'));
36
+ assert.ok(installedPlugins.includes('tasks'));
37
+ assert.equal(result.results.every((entry) => entry.installed), true);
38
+
39
+ // One marketplace add + one install call per plugin.
40
+ const installCalls = invocations.filter((args) => args[1] === 'install');
41
+ assert.equal(installCalls.length, result.results.length);
42
+ });
43
+
44
+ test('installPlugin: one plugin failing does not stop the rest from being attempted', () => {
45
+ const runClaude = (args) => {
46
+ if (args[0] === 'plugin' && args[1] === 'install' && args[2].startsWith('gates@')) {
47
+ throw new Error('boom: gates install failed');
48
+ }
49
+ return '';
50
+ };
51
+
52
+ const result = installPlugin(SCOPES.GLOBAL, { cwd: REPOSITORY_ROOT, runClaude });
53
+
54
+ assert.equal(result.installed, false, 'overall result reflects the partial failure');
55
+ const gatesEntry = result.results.find((entry) => entry.plugin === 'gates');
56
+ const tasksEntry = result.results.find((entry) => entry.plugin === 'tasks');
57
+ assert.equal(gatesEntry.installed, false);
58
+ assert.match(gatesEntry.reason, /boom/);
59
+ assert.equal(
60
+ tasksEntry.installed,
61
+ true,
62
+ 'tasks must still install even though gates failed',
63
+ );
64
+ assert.match(result.reason, /gates/);
65
+ });
66
+
67
+ test('installPlugin uses the marketplace name Claude Code actually registered, not the manifest name', () => {
68
+ // The repo's manifest name is `claude-gates`, but if the user added this same directory
69
+ // earlier under a different name, Claude Code keeps that name. installPlugin must install as
70
+ // plugin@<registered-name>, or `claude` answers "not found in marketplace claude-gates".
71
+ const installCalls = [];
72
+ const runClaude = (args) => {
73
+ if (args[1] === 'marketplace' && args[2] === 'list') {
74
+ // A listing where THIS repo is registered under the name `devrik`.
75
+ return `Configured marketplaces:\n\n ❯ devrik\n Source: Directory (${REPOSITORY_ROOT})\n`;
76
+ }
77
+ if (args[1] === 'install') installCalls.push(args[2]);
78
+ return '';
79
+ };
80
+
81
+ const result = installPlugin(SCOPES.GLOBAL, { cwd: REPOSITORY_ROOT, runClaude });
82
+
83
+ assert.equal(result.installed, true);
84
+ assert.ok(
85
+ installCalls.every((target) => target.endsWith('@devrik')),
86
+ `every install must target @devrik, got: ${installCalls.join(', ')}`,
87
+ );
88
+ });
89
+
90
+ test('installPlugin degrades gracefully when the claude binary is entirely unreachable', () => {
91
+ const runClaude = () => {
92
+ const error = new Error('spawn claude ENOENT');
93
+ error.code = 'ENOENT';
94
+ throw error;
95
+ };
96
+
97
+ const result = installPlugin(SCOPES.PROJECT, { cwd: REPOSITORY_ROOT, runClaude });
98
+
99
+ assert.equal(result.installed, false);
100
+ assert.ok(result.results.every((entry) => !entry.installed));
101
+ assert.ok(result.results.every((entry) => /not found on PATH/.test(entry.reason)));
102
+ });
@@ -0,0 +1,95 @@
1
+ // materializeGates only computes the NEW selection's map; how much of an existing gate
2
+ // entry survives depends on the run's mode. This test exercises that combination the same
3
+ // way init.mjs does — a bug here reads as "materialize wiped my config" even though
4
+ // materializeGates and mergeConfig are individually simple.
5
+
6
+ import assert from 'node:assert/strict';
7
+ import { test } from 'node:test';
8
+ import { mergeConfig } from '../config.mjs';
9
+ import { materializeGates } from '../materialize.mjs';
10
+ import { loadRegistry, allGates } from '../registry.mjs';
11
+ import { resolveSelection, MODES } from '../selection.mjs';
12
+
13
+ const registry = loadRegistry();
14
+ const gates = allGates(registry);
15
+ const paramGate = gates.find(
16
+ (gate) => Array.isArray(gate.params) && gate.params.length > 0,
17
+ );
18
+
19
+ function mergedGatesAfter(mode, existingGates) {
20
+ const existing = {
21
+ adopted: 'partial',
22
+ gateVersion: registry.gateVersion,
23
+ gates: existingGates,
24
+ };
25
+ const selection = resolveSelection(registry, mode, {
26
+ gates: [paramGate.id],
27
+ families: [paramGate.family],
28
+ });
29
+ const materialized = materializeGates(registry, selection, existing.gates, mode);
30
+ return mergeConfig(existing, {
31
+ adopted: 'partial',
32
+ gates: materialized,
33
+ gateVersion: registry.gateVersion,
34
+ }).gates;
35
+ }
36
+
37
+ test('registry has at least one gate with params, for these tests to exercise', () => {
38
+ assert.ok(paramGate, 'registry must have at least one gate with params');
39
+ });
40
+
41
+ test('DEFAULTS re-run preserves a gate the user already disabled, with its custom params, untouched', () => {
42
+ const merged = mergedGatesAfter(MODES.DEFAULTS, {
43
+ [paramGate.configKey]: { enabled: false, __userTuned: 'kept-value' },
44
+ });
45
+
46
+ assert.equal(
47
+ merged[paramGate.configKey].enabled,
48
+ false,
49
+ 'a gate the user disabled must stay disabled after a defaults re-run',
50
+ );
51
+ assert.equal(
52
+ merged[paramGate.configKey].__userTuned,
53
+ 'kept-value',
54
+ "the user's own param edit must not be replaced by the gate's built-in default",
55
+ );
56
+
57
+ const untouched = gates.find((gate) => gate.configKey !== paramGate.configKey);
58
+ assert.ok(
59
+ Object.prototype.hasOwnProperty.call(merged, untouched.configKey),
60
+ 'a gate absent from the existing config must still appear after materializing',
61
+ );
62
+ });
63
+
64
+ test('an explicit GRANULAR pick overrides a previously disabled gate: the named choice wins', () => {
65
+ const merged = mergedGatesAfter(MODES.GRANULAR, {
66
+ [paramGate.configKey]: { enabled: false, __userTuned: 'kept-value' },
67
+ });
68
+
69
+ assert.equal(
70
+ merged[paramGate.configKey].enabled,
71
+ true,
72
+ 'naming a gate in --gates must re-enable it even if the config had it disabled',
73
+ );
74
+ });
75
+
76
+ test('an explicit GRANULAR pick still preserves the user\'s own params (no new values were passed)', () => {
77
+ const merged = mergedGatesAfter(MODES.GRANULAR, {
78
+ [paramGate.configKey]: { enabled: false, __userTuned: 'kept-value' },
79
+ });
80
+
81
+ assert.equal(
82
+ merged[paramGate.configKey].__userTuned,
83
+ 'kept-value',
84
+ 'params the user tuned by hand survive an explicit pick that only changes enabled',
85
+ );
86
+ });
87
+
88
+ test('FAMILIES mode behaves the same as GRANULAR for an already-configured gate', () => {
89
+ const merged = mergedGatesAfter(MODES.FAMILIES, {
90
+ [paramGate.configKey]: { enabled: false, __userTuned: 'kept-value' },
91
+ });
92
+
93
+ assert.equal(merged[paramGate.configKey].enabled, true);
94
+ assert.equal(merged[paramGate.configKey].__userTuned, 'kept-value');
95
+ });
@@ -1,44 +1,52 @@
1
- // The registry and the gates on disk must agree: every PreToolUse gate the registry
2
- // declares has to have a script, and every gate folder on disk has to be declared. A
3
- // mismatch (a registry entry with no file, like the old `autocommit`, or an orphan folder)
4
- // is what made `init` spawn a non-existent gate — this test blocks it from returning.
5
-
6
- import assert from 'node:assert/strict';
7
- import { existsSync, readdirSync } from 'node:fs';
8
- import { dirname, join } from 'node:path';
9
- import { test } from 'node:test';
10
- import { fileURLToPath } from 'node:url';
11
- import { loadRegistry, allGates } from '../registry.mjs';
12
-
13
- const REPO = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
14
- const GATES_DIR = join(REPO, 'plugins', 'gates', 'hooks', 'gates');
15
-
16
- const registry = loadRegistry();
17
- const preToolUseGates = allGates(registry).filter(
18
- (gate) => gate.event === 'PreToolUse',
19
- );
20
-
21
- test('every PreToolUse gate in the registry has its script on disk', () => {
22
- for (const gate of preToolUseGates) {
23
- const scriptPath = join(REPO, 'plugins', 'gates', 'hooks', gate.script);
24
- assert.ok(
25
- existsSync(scriptPath),
26
- `registry declares gate "${gate.id}" (script ${gate.script}) but the file is missing`,
27
- );
28
- }
29
- });
30
-
31
- test('every gate folder on disk is declared in the registry', () => {
32
- const declaredScripts = new Set(preToolUseGates.map((gate) => gate.script));
33
- const folders = readdirSync(GATES_DIR, { withFileTypes: true })
34
- .filter((entry) => entry.isDirectory())
35
- .map((entry) => entry.name);
36
-
37
- for (const folder of folders) {
38
- const expectedScript = `gates/${folder}/index.mjs`;
39
- assert.ok(
40
- declaredScripts.has(expectedScript),
41
- `gate folder "${folder}" exists on disk but no registry entry points to ${expectedScript}`,
42
- );
43
- }
44
- });
1
+ // The registry and the gates on disk must agree: every PreToolUse gate the registry
2
+ // declares has to have a script, and every gate folder on disk has to be declared. A
3
+ // mismatch (a registry entry with no file, like the old `autocommit`, or an orphan folder)
4
+ // is what made `init` spawn a non-existent gate — this test blocks it from returning.
5
+
6
+ import assert from 'node:assert/strict';
7
+ import { existsSync, readdirSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { test } from 'node:test';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { loadRegistry, allGates } from '../registry.mjs';
12
+
13
+ const REPO = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
14
+ const GATES_DIR = join(REPO, 'plugins', 'gates', 'hooks', 'gates');
15
+
16
+ const registry = loadRegistry();
17
+ const preToolUseGates = allGates(registry).filter(
18
+ (gate) => gate.event === 'PreToolUse',
19
+ );
20
+ // Gates that live one-per-folder under plugins/gates/hooks/gates/ — regardless of which
21
+ // event they hook (most are PreToolUse, but e.g. stop-pending is Stop). Session-scoped
22
+ // hooks (doctor.mjs, ask-adoption.mjs, ...) are top-level scripts outside this folder and
23
+ // are intentionally excluded, matching the `gates/x.mjs` vs `x.mjs` convention documented
24
+ // in registry.mjs's SCRIPT_PATTERN.
25
+ const folderGates = allGates(registry).filter((gate) =>
26
+ gate.script.startsWith('gates/'),
27
+ );
28
+
29
+ test('every PreToolUse gate in the registry has its script on disk', () => {
30
+ for (const gate of preToolUseGates) {
31
+ const scriptPath = join(REPO, 'plugins', 'gates', 'hooks', gate.script);
32
+ assert.ok(
33
+ existsSync(scriptPath),
34
+ `registry declares gate "${gate.id}" (script ${gate.script}) but the file is missing`,
35
+ );
36
+ }
37
+ });
38
+
39
+ test('every gate folder on disk is declared in the registry', () => {
40
+ const declaredScripts = new Set(folderGates.map((gate) => gate.script));
41
+ const folders = readdirSync(GATES_DIR, { withFileTypes: true })
42
+ .filter((entry) => entry.isDirectory())
43
+ .map((entry) => entry.name);
44
+
45
+ for (const folder of folders) {
46
+ const expectedScript = `gates/${folder}/index.mjs`;
47
+ assert.ok(
48
+ declaredScripts.has(expectedScript),
49
+ `gate folder "${folder}" exists on disk but no registry entry points to ${expectedScript}`,
50
+ );
51
+ }
52
+ });