@devrik-tools/claude-gates 0.2.0 → 0.3.0
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +7 -6
- package/README.md +16 -7
- package/cli/index.mjs +4 -0
- package/cli/init.mjs +18 -1
- package/cli/install.mjs +26 -1
- package/package.json +5 -2
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -6
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +59 -44
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
- package/plugins/gates/hooks/hooks.json +365 -346
- package/plugins/gates/hooks/lib/hook-io.mjs +39 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/register-requests.mjs +7 -3
- package/registry.json +738 -793
- package/cli/__tests__/config.test.mjs +0 -113
- package/cli/__tests__/init-flags.test.mjs +0 -111
- package/cli/__tests__/install.test.mjs +0 -102
- package/cli/__tests__/materialize.test.mjs +0 -95
- package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
- package/cli/__tests__/selection.test.mjs +0 -200
- package/cli/__tests__/task.test.mjs +0 -124
- package/cli/__tests__/version-consistency.test.mjs +0 -44
- package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
- package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
- package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
- package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
- package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
- package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
- package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
- package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
- package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
- package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
- package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
- package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
- package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
- package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
- package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
- package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
- package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
- package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
- package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
- package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
- package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
- package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
- package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
- package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
- package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
- package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
- package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
- package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
- package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
- package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
- package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
- package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
- package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
- package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
- package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
- package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
- package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
- package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
- package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
- package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
- package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
- package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
- package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
- package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
- package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -97
- package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
- package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
- package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
- package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
- package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
- package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
- package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
- package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
- package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -210
- package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
- package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
- package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
- package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
|
@@ -1,113 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import {
|
|
4
|
-
mkdtempSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
writeFileSync,
|
|
8
|
-
existsSync,
|
|
9
|
-
} from 'node:fs';
|
|
10
|
-
import { tmpdir } from 'node:os';
|
|
11
|
-
import { join, dirname } from 'node:path';
|
|
12
|
-
import { test } from 'node:test';
|
|
13
|
-
import { fileURLToPath } from 'node:url';
|
|
14
|
-
import { normalizeOptions } from '../init.mjs';
|
|
15
|
-
|
|
16
|
-
const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'index.mjs');
|
|
17
|
-
|
|
18
|
-
test('normalizeOptions maps commander options', () => {
|
|
19
|
-
assert.deepEqual(
|
|
20
|
-
normalizeOptions({
|
|
21
|
-
global: true,
|
|
22
|
-
families: ['security', 'quality'],
|
|
23
|
-
yes: true,
|
|
24
|
-
dryRun: true,
|
|
25
|
-
}),
|
|
26
|
-
{
|
|
27
|
-
scope: 'global',
|
|
28
|
-
mode: 'families',
|
|
29
|
-
families: ['security', 'quality'],
|
|
30
|
-
gates: [],
|
|
31
|
-
yes: true,
|
|
32
|
-
dryRun: true,
|
|
33
|
-
install: undefined,
|
|
34
|
-
},
|
|
35
|
-
);
|
|
36
|
-
assert.equal(normalizeOptions({ gates: ['bash-commands'] }).mode, 'granular');
|
|
37
|
-
assert.equal(normalizeOptions({}).mode, null);
|
|
38
|
-
assert.throws(() => normalizeOptions({ all: true, none: true }), /Pick one/);
|
|
39
|
-
assert.throws(
|
|
40
|
-
() => normalizeOptions({ project: true, global: true }),
|
|
41
|
-
/Pick one/,
|
|
42
|
-
);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test('non-interactive init writes .ai/config.json at the project root', () => {
|
|
46
|
-
const project = mkdtempSync(join(tmpdir(), 'claude-gates-proj-'));
|
|
47
|
-
mkdirSync(join(project, '.git'));
|
|
48
|
-
const nested = join(project, 'src');
|
|
49
|
-
mkdirSync(nested);
|
|
50
|
-
|
|
51
|
-
execFileSync(
|
|
52
|
-
process.execPath,
|
|
53
|
-
[
|
|
54
|
-
CLI,
|
|
55
|
-
'init',
|
|
56
|
-
'--project',
|
|
57
|
-
'--families',
|
|
58
|
-
'security',
|
|
59
|
-
'--yes',
|
|
60
|
-
'--no-install',
|
|
61
|
-
],
|
|
62
|
-
{ cwd: nested, encoding: 'utf8' },
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
const path = join(project, '.ai', 'config.json');
|
|
66
|
-
assert.ok(existsSync(path), 'config written at git root, not at cwd');
|
|
67
|
-
const config = JSON.parse(readFileSync(path, 'utf8'));
|
|
68
|
-
assert.equal(config.adopted, 'partial');
|
|
69
|
-
// Gates with params are materialized as { enabled, ...defaults } so the knobs are editable.
|
|
70
|
-
assert.equal(config.gates.blockDestructiveShellCommands.enabled, true);
|
|
71
|
-
assert.equal(config.gates.requireBriefBeforeDelegating.enabled, false);
|
|
72
|
-
assert.match(config.gateVersion, /^\d+\.\d+\.\d+$/);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test('--dry-run writes nothing', () => {
|
|
76
|
-
const project = mkdtempSync(join(tmpdir(), 'claude-gates-dry-'));
|
|
77
|
-
execFileSync(
|
|
78
|
-
process.execPath,
|
|
79
|
-
[CLI, 'init', '--project', '--all', '--yes', '--dry-run'],
|
|
80
|
-
{ cwd: project, encoding: 'utf8' },
|
|
81
|
-
);
|
|
82
|
-
assert.ok(!existsSync(join(project, '.ai', 'config.json')));
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test('--none records an explicit refusal', () => {
|
|
86
|
-
const project = mkdtempSync(join(tmpdir(), 'claude-gates-none-'));
|
|
87
|
-
execFileSync(
|
|
88
|
-
process.execPath,
|
|
89
|
-
[CLI, 'init', '--project', '--none', '--yes', '--no-install'],
|
|
90
|
-
{ cwd: project, encoding: 'utf8' },
|
|
91
|
-
);
|
|
92
|
-
const config = JSON.parse(
|
|
93
|
-
readFileSync(join(project, '.ai', 'config.json'), 'utf8'),
|
|
94
|
-
);
|
|
95
|
-
assert.equal(config.adopted, false);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test('a corrupt existing config aborts without writing', () => {
|
|
99
|
-
const project = mkdtempSync(join(tmpdir(), 'claude-gates-corrupt-'));
|
|
100
|
-
mkdirSync(join(project, '.ai'));
|
|
101
|
-
const path = join(project, '.ai', 'config.json');
|
|
102
|
-
writeFileSync(path, '{broken');
|
|
103
|
-
assert.throws(() =>
|
|
104
|
-
execFileSync(
|
|
105
|
-
process.execPath,
|
|
106
|
-
[CLI, 'init', '--project', '--all', '--yes'],
|
|
107
|
-
{ cwd: project, encoding: 'utf8', stdio: 'pipe' },
|
|
108
|
-
),
|
|
109
|
-
);
|
|
110
|
-
assert.equal(readFileSync(path, 'utf8'), '{broken');
|
|
111
|
-
});
|
|
@@ -1,102 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,95 +0,0 @@
|
|
|
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,52 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { test } from 'node:test';
|
|
3
|
-
import { loadRegistry, validateRegistry, allGates } from '../registry.mjs';
|
|
4
|
-
import {
|
|
5
|
-
MODES,
|
|
6
|
-
resolveSelection,
|
|
7
|
-
adoptionOf,
|
|
8
|
-
summarize,
|
|
9
|
-
} from '../selection.mjs';
|
|
10
|
-
|
|
11
|
-
const registry = loadRegistry();
|
|
12
|
-
const keys = allGates(registry).map((gate) => gate.configKey);
|
|
13
|
-
|
|
14
|
-
test('the shipped registry is valid', () => {
|
|
15
|
-
assert.deepEqual(validateRegistry(registry), []);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
test('validateRegistry catches duplicate configKey and unknown event', () => {
|
|
19
|
-
const broken = {
|
|
20
|
-
gateVersion: '1.0.0',
|
|
21
|
-
families: [
|
|
22
|
-
{
|
|
23
|
-
id: 'x',
|
|
24
|
-
name: 'X',
|
|
25
|
-
gates: [
|
|
26
|
-
{
|
|
27
|
-
id: 'a',
|
|
28
|
-
configKey: 'k',
|
|
29
|
-
default: true,
|
|
30
|
-
event: 'PreToolUse',
|
|
31
|
-
tools: [],
|
|
32
|
-
description: 'a',
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
id: 'b',
|
|
36
|
-
configKey: 'k',
|
|
37
|
-
default: true,
|
|
38
|
-
event: 'Nope',
|
|
39
|
-
tools: [],
|
|
40
|
-
description: 'b',
|
|
41
|
-
},
|
|
42
|
-
],
|
|
43
|
-
},
|
|
44
|
-
],
|
|
45
|
-
};
|
|
46
|
-
const problems = validateRegistry(broken);
|
|
47
|
-
assert.ok(problems.some((problem) => /duplicate configKey: k/.test(problem)));
|
|
48
|
-
assert.ok(problems.some((problem) => /gates\.1\.event/.test(problem)));
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
// A one-gate registry whose gate fields are overridable, so each test tweaks only the
|
|
52
|
-
// field it checks. Every level carries its required description/name.
|
|
53
|
-
function oneGateRegistry(gateOverrides) {
|
|
54
|
-
return {
|
|
55
|
-
gateVersion: '1.0.0',
|
|
56
|
-
families: [
|
|
57
|
-
{
|
|
58
|
-
id: 'x',
|
|
59
|
-
name: 'X',
|
|
60
|
-
description: 'family x',
|
|
61
|
-
gates: [
|
|
62
|
-
{
|
|
63
|
-
id: 'a',
|
|
64
|
-
configKey: 'k',
|
|
65
|
-
default: true,
|
|
66
|
-
event: 'PreToolUse',
|
|
67
|
-
tools: [],
|
|
68
|
-
script: 'gates/a.mjs',
|
|
69
|
-
description: 'gate a',
|
|
70
|
-
...gateOverrides,
|
|
71
|
-
},
|
|
72
|
-
],
|
|
73
|
-
},
|
|
74
|
-
],
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const ABSOLUTE_SCRIPT = `${'/'}etc/evil.mjs`;
|
|
79
|
-
|
|
80
|
-
test('validateRegistry requires a valid relative .mjs script per gate', () => {
|
|
81
|
-
const withoutScript = oneGateRegistry({});
|
|
82
|
-
delete withoutScript.families[0].gates[0].script;
|
|
83
|
-
assert.ok(validateRegistry(withoutScript).some((p) => /script/.test(p)));
|
|
84
|
-
|
|
85
|
-
assert.ok(
|
|
86
|
-
validateRegistry(oneGateRegistry({ script: ABSOLUTE_SCRIPT })).some((p) =>
|
|
87
|
-
/script/.test(p),
|
|
88
|
-
),
|
|
89
|
-
);
|
|
90
|
-
assert.ok(
|
|
91
|
-
validateRegistry(oneGateRegistry({ script: '../escape.mjs' })).some((p) =>
|
|
92
|
-
/script/.test(p),
|
|
93
|
-
),
|
|
94
|
-
);
|
|
95
|
-
assert.deepEqual(
|
|
96
|
-
validateRegistry(oneGateRegistry({ script: 'gates/ok.mjs' })),
|
|
97
|
-
[],
|
|
98
|
-
);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test('validateRegistry rejects a param that is not camelCase or has a bad type', () => {
|
|
102
|
-
assert.ok(
|
|
103
|
-
validateRegistry(
|
|
104
|
-
oneGateRegistry({
|
|
105
|
-
params: [{ name: 'Bad-Name', type: 'string', description: 'd' }],
|
|
106
|
-
}),
|
|
107
|
-
).length > 0,
|
|
108
|
-
);
|
|
109
|
-
assert.ok(
|
|
110
|
-
validateRegistry(
|
|
111
|
-
oneGateRegistry({
|
|
112
|
-
params: [{ name: 'ok', type: 'regex', description: 'd' }],
|
|
113
|
-
}),
|
|
114
|
-
).length > 0,
|
|
115
|
-
);
|
|
116
|
-
assert.deepEqual(
|
|
117
|
-
validateRegistry(
|
|
118
|
-
oneGateRegistry({
|
|
119
|
-
params: [{ name: 'ok', type: 'string[]', description: 'd' }],
|
|
120
|
-
}),
|
|
121
|
-
),
|
|
122
|
-
[],
|
|
123
|
-
);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test('ALL enables every key; NONE disables every key; both cover all keys', () => {
|
|
127
|
-
const all = resolveSelection(registry, MODES.ALL);
|
|
128
|
-
const none = resolveSelection(registry, MODES.NONE);
|
|
129
|
-
assert.deepEqual(Object.keys(all).sort(), [...keys].sort());
|
|
130
|
-
assert.ok(Object.values(all).every((v) => v === true));
|
|
131
|
-
assert.ok(Object.values(none).every((v) => v === false));
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test("DEFAULTS mirrors the registry's default flags", () => {
|
|
135
|
-
const map = resolveSelection(registry, MODES.DEFAULTS);
|
|
136
|
-
for (const gate of allGates(registry))
|
|
137
|
-
assert.equal(map[gate.configKey], gate.default, gate.id);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test('FAMILIES enables exactly the picked families', () => {
|
|
141
|
-
const map = resolveSelection(registry, MODES.FAMILIES, {
|
|
142
|
-
families: ['security'],
|
|
143
|
-
});
|
|
144
|
-
for (const gate of allGates(registry))
|
|
145
|
-
assert.equal(map[gate.configKey], gate.family === 'security', gate.id);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test('GRANULAR enables exactly the picked gates', () => {
|
|
149
|
-
const map = resolveSelection(registry, MODES.GRANULAR, {
|
|
150
|
-
gates: ['bash-commands', 'no-reconfirm'],
|
|
151
|
-
});
|
|
152
|
-
const enabled = Object.entries(map)
|
|
153
|
-
.filter(([, v]) => v)
|
|
154
|
-
.map(([k]) => k)
|
|
155
|
-
.sort();
|
|
156
|
-
assert.deepEqual(
|
|
157
|
-
enabled,
|
|
158
|
-
['blockDestructiveShellCommands', 'requireNoReconfirmOfApproved'].sort(),
|
|
159
|
-
);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test('unknown ids fail loudly instead of being ignored', () => {
|
|
163
|
-
assert.throws(
|
|
164
|
-
() => resolveSelection(registry, MODES.FAMILIES, { families: ['nope'] }),
|
|
165
|
-
/Unknown family/,
|
|
166
|
-
);
|
|
167
|
-
assert.throws(
|
|
168
|
-
() => resolveSelection(registry, MODES.GRANULAR, { gates: ['nope'] }),
|
|
169
|
-
/Unknown gate/,
|
|
170
|
-
);
|
|
171
|
-
assert.throws(
|
|
172
|
-
() => resolveSelection(registry, 'weird'),
|
|
173
|
-
/Unknown selection mode/,
|
|
174
|
-
);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test('adoptionOf: true / false / partial', () => {
|
|
178
|
-
assert.equal(adoptionOf(resolveSelection(registry, MODES.ALL)), true);
|
|
179
|
-
assert.equal(adoptionOf(resolveSelection(registry, MODES.NONE)), false);
|
|
180
|
-
assert.equal(
|
|
181
|
-
adoptionOf(
|
|
182
|
-
resolveSelection(registry, MODES.FAMILIES, { families: ['security'] }),
|
|
183
|
-
),
|
|
184
|
-
'partial',
|
|
185
|
-
);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test('summarize counts per family', () => {
|
|
189
|
-
const rows = summarize(
|
|
190
|
-
registry,
|
|
191
|
-
resolveSelection(registry, MODES.FAMILIES, { families: ['security'] }),
|
|
192
|
-
);
|
|
193
|
-
const security = rows.find((row) => row.family === 'security');
|
|
194
|
-
assert.equal(security.enabled.length, security.total);
|
|
195
|
-
assert.ok(
|
|
196
|
-
rows
|
|
197
|
-
.filter((row) => row.family !== 'security')
|
|
198
|
-
.every((row) => row.enabled.length === 0),
|
|
199
|
-
);
|
|
200
|
-
});
|