@devrik-tools/claude-gates 0.2.1 → 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.
Files changed (96) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +7 -6
  3. package/README.md +16 -7
  4. package/cli/index.mjs +4 -0
  5. package/cli/init.mjs +18 -1
  6. package/cli/install.mjs +26 -1
  7. package/package.json +5 -2
  8. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  9. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
  10. package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
  11. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
  12. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
  13. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
  14. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
  15. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
  16. package/plugins/gates/hooks/hooks.json +19 -0
  17. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  18. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  19. package/registry.json +738 -793
  20. package/cli/__tests__/config.test.mjs +0 -113
  21. package/cli/__tests__/init-flags.test.mjs +0 -111
  22. package/cli/__tests__/install.test.mjs +0 -102
  23. package/cli/__tests__/materialize.test.mjs +0 -95
  24. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  25. package/cli/__tests__/selection.test.mjs +0 -200
  26. package/cli/__tests__/task.test.mjs +0 -124
  27. package/cli/__tests__/version-consistency.test.mjs +0 -44
  28. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  29. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  30. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  31. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  32. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  33. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  34. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  35. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  36. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  37. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  38. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  39. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  40. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  41. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  42. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  43. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  44. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  45. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  46. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  47. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  48. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  49. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  50. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  51. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  52. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  53. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  54. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  55. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  56. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  57. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  58. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  59. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  60. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  61. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  62. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  63. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  64. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  65. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  66. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  67. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  68. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  69. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  70. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  71. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  72. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  73. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  74. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  75. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  76. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  77. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -124
  78. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  79. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  80. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  81. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  82. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  83. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  84. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  85. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  86. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  87. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  88. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  89. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -223
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  93. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  94. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  95. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  96. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
@@ -1,63 +0,0 @@
1
- // Edge-case probe for test-matrix. Previously the prompt was read only from
2
- // toolInput.prompt/Prompt/task, the same root cause shared by implementation-pipeline,
3
- // mandatory-flow and the sdd-specs delegation check: a Task delegation carrying its
4
- // brief in `description` was invisible. Migrated to delegationPromptOf()
5
- // (hook-io.mjs), so this is now caught.
6
- import assert from 'node:assert/strict';
7
- import { execFileSync } from 'node:child_process';
8
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
9
- import { tmpdir } from 'node:os';
10
- import { dirname, join } from 'node:path';
11
- import { test } from 'node:test';
12
- import { fileURLToPath } from 'node:url';
13
-
14
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
15
-
16
- function runGate(payload, { config } = {}) {
17
- const project = mkdtempSync(join(tmpdir(), 'test-matrix-edge-'));
18
- mkdirSync(join(project, '.git'));
19
- if (config) {
20
- mkdirSync(join(project, '.ai'));
21
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
22
- }
23
- const out = execFileSync(process.execPath, [GATE], {
24
- input: JSON.stringify(payload),
25
- encoding: 'utf8',
26
- cwd: project,
27
- env: { ...process.env, HOME: project, USERPROFILE: project },
28
- });
29
- return out.trim() ? JSON.parse(out.trim()) : null;
30
- }
31
-
32
- function isDeny(result) {
33
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
34
- }
35
-
36
- function enabledConfig() {
37
- return { gates: { requireTestMatrixWhenImplementing: true } };
38
- }
39
-
40
- test('FIXED: a Task delegation carrying its brief in `description` is now checked', () => {
41
- const payload = {
42
- tool_name: 'Task',
43
- tool_input: {
44
- description: 'Nivel: STANDARD\nImplementá el cobro de la cuota.',
45
- subagent_type: 'backend',
46
- },
47
- };
48
- assert.ok(
49
- isDeny(runGate(payload, { config: enabledConfig() })),
50
- 'delegationPromptOf reads description and the missing-test-matrix deny fires',
51
- );
52
- });
53
-
54
- test('OK: the equivalent payload using `prompt` IS caught (control)', () => {
55
- const payload = {
56
- tool_name: 'Task',
57
- tool_input: {
58
- prompt: 'Nivel: STANDARD\nImplementá el cobro de la cuota.',
59
- subagent_type: 'backend',
60
- },
61
- };
62
- assert.ok(isDeny(runGate(payload, { config: enabledConfig() })), 'control failed');
63
- });
@@ -1,114 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
3
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { test } from 'node:test';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
-
11
- function runGate(payload, { config } = {}) {
12
- const project = mkdtempSync(join(tmpdir(), 'test-matrix-'));
13
- mkdirSync(join(project, '.git'));
14
- if (config) {
15
- mkdirSync(join(project, '.ai'));
16
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
- }
18
- const out = execFileSync(process.execPath, [GATE], {
19
- input: JSON.stringify(payload),
20
- encoding: 'utf8',
21
- cwd: project,
22
- // Isolate from the user's real global config: point homedir() at the temp
23
- // project so the global-config fallback finds nothing (registry default).
24
- env: { ...process.env, HOME: project, USERPROFILE: project },
25
- });
26
- return out.trim() ? JSON.parse(out.trim()) : null;
27
- }
28
-
29
- function delegate(prompt, subagentType = 'backend') {
30
- return {
31
- tool_name: 'Agent',
32
- tool_input: { prompt, subagent_type: subagentType },
33
- };
34
- }
35
-
36
- function isDeny(result) {
37
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
38
- }
39
-
40
- function enabledConfig(extraParameters) {
41
- return {
42
- gates: {
43
- requireTestMatrixWhenImplementing: extraParameters
44
- ? { enabled: true, ...extraParameters }
45
- : true,
46
- },
47
- };
48
- }
49
-
50
- test('disabled by default: a money-touching brief with no test matrix is allowed', () => {
51
- const prompt = 'Nivel: STANDARD\nImplementá el cobro de la cuota.';
52
- assert.equal(runGate(delegate(prompt)), null);
53
- });
54
-
55
- test('denies missing unit/mutation on a plain brief with no test type declared', () => {
56
- const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
57
- assert.ok(isDeny(runGate(delegate(prompt), { config: enabledConfig() })));
58
- });
59
-
60
- test('denies missing E2E when the brief touches money and only declares unit tests', () => {
61
- const prompt =
62
- 'Nivel: STANDARD\nImplementá el cobro de la cuota. Verificación: vitest cubre el cálculo.';
63
- assert.ok(isDeny(runGate(delegate(prompt), { config: enabledConfig() })));
64
- });
65
-
66
- test('allows a brief that declares unit, E2E and negative cases for a money domain', () => {
67
- const prompt =
68
- 'Nivel: STANDARD\nImplementá el cobro de la cuota. ' +
69
- 'Verificación: vitest cubre el cálculo. E2E: flujo completo de cobro. ' +
70
- 'Casos negativos: monto cero, sin saldo, error de red.';
71
- assert.equal(runGate(delegate(prompt), { config: enabledConfig() }), null);
72
- });
73
-
74
- test('exempt subagent type (qa) is never held to the matrix requirement', () => {
75
- const prompt = 'Nivel: STANDARD\nImplementá el cobro de la cuota.';
76
- assert.equal(
77
- runGate(delegate(prompt, 'qa'), { config: enabledConfig() }),
78
- null,
79
- );
80
- });
81
-
82
- test('denies missing E2E for a Spanish money brief (cobro/saldo/cuota), not just its English equivalent', () => {
83
- const prompt =
84
- 'Nivel: STANDARD\nImplementá el cobro de la cuota. Verificación: vitest cubre el cálculo.';
85
- const result = runGate(delegate(prompt), { config: enabledConfig() });
86
- assert.ok(isDeny(result));
87
- assert.ok(result.hookSpecificOutput.permissionDecisionReason.includes('E2E'));
88
- });
89
-
90
- test('bilingual control: an EN money brief with only unit coverage denies for the same reason as its ES equivalent', () => {
91
- const es = 'Nivel: STANDARD\nImplementá el cobro de la cuota. Verificación: vitest cubre el cálculo.';
92
- const en = 'Level: STANDARD\nImplement the fee charge. Verification: vitest covers the calculation.';
93
- const resultEs = runGate(delegate(es), { config: enabledConfig() });
94
- const resultEn = runGate(delegate(en), { config: enabledConfig() });
95
- assert.ok(isDeny(resultEs));
96
- assert.ok(isDeny(resultEn));
97
- assert.ok(resultEs.hookSpecificOutput.permissionDecisionReason.includes('E2E'));
98
- assert.ok(resultEn.hookSpecificOutput.permissionDecisionReason.includes('E2E'));
99
- });
100
-
101
- test('allows a Spanish brief that declares unit, E2E and negative cases with Spanish wording', () => {
102
- const prompt =
103
- 'Nivel: STANDARD\nImplementá el cobro de la cuota. ' +
104
- 'Verificación: pruebas unitarias cubren el calculo. E2E: flujo completo de cobro. ' +
105
- 'Casos negativos: monto cero, sin saldo, entrada invalida.';
106
- assert.equal(runGate(delegate(prompt), { config: enabledConfig() }), null);
107
- });
108
-
109
- test('e2eSignals param override makes a domain word mandatory that the default list omits', () => {
110
- const prompt =
111
- 'Nivel: STANDARD\nImplementá el módulo de inventario. Verificación: vitest cubre el cálculo.';
112
- const config = enabledConfig({ e2eSignals: ['inventario|inventory'] });
113
- assert.ok(isDeny(runGate(delegate(prompt), { config })));
114
- });
@@ -1,62 +0,0 @@
1
- // Edge-case probe for tool-map. Previously the same Edit/new_string content-field gap
2
- // as feature-catalog/sdd-specs meant a legitimately-audited tool created/edited via
3
- // Edit's `new_string` was never recorded in .ai/tool-map.json, so reuse-before-build
4
- // kept re-blocking a tool that was already cleared once. Migrated to
5
- // writtenContentOf()/writtenPathOf() (hook-io.mjs), which read new_string, so this is
6
- // now recorded the same as a Write.
7
- import assert from 'node:assert/strict';
8
- import { execFileSync } from 'node:child_process';
9
- import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
10
- import { tmpdir } from 'node:os';
11
- import { dirname, join } from 'node:path';
12
- import { test } from 'node:test';
13
- import { fileURLToPath } from 'node:url';
14
-
15
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
16
-
17
- function runGate(payload) {
18
- const project = mkdtempSync(join(tmpdir(), 'tool-map-edge-'));
19
- mkdirSync(join(project, '.git'));
20
- mkdirSync(join(project, '.ai'));
21
- writeFileSync(
22
- join(project, '.ai', 'config.json'),
23
- JSON.stringify({ gates: { maintainToolMap: true } }),
24
- );
25
- execFileSync(process.execPath, [GATE], {
26
- input: JSON.stringify(payload),
27
- encoding: 'utf8',
28
- cwd: project,
29
- env: { ...process.env, HOME: project, USERPROFILE: project },
30
- });
31
- return { mapPath: join(project, '.ai', 'tool-map.json') };
32
- }
33
-
34
- test('FIXED: an audited tool written via Edit (new_string) is now recorded', () => {
35
- const payload = {
36
- tool_name: 'Edit',
37
- tool_input: {
38
- file_path: 'scripts/new-tool.mjs',
39
- old_string: '',
40
- new_string: '// audited: nothing does this\nexport function parse(){}',
41
- },
42
- };
43
- const { mapPath } = runGate(payload);
44
- assert.ok(
45
- existsSync(mapPath),
46
- 'writtenContentOf must read new_string so an Edit-created tool is persisted',
47
- );
48
- const map = JSON.parse(readFileSync(mapPath, 'utf8'));
49
- assert.equal(map.tools[0].path, 'scripts/new-tool.mjs');
50
- });
51
-
52
- test('OK: the equivalent Write-tool payload IS recorded (control)', () => {
53
- const payload = {
54
- tool_name: 'Write',
55
- tool_input: {
56
- file_path: 'scripts/new-tool.mjs',
57
- content: '// audited: nothing does this\nexport function parse(){}',
58
- },
59
- };
60
- const { mapPath } = runGate(payload);
61
- assert.ok(existsSync(mapPath), 'control failed');
62
- });
@@ -1,87 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
3
- import {
4
- mkdtempSync,
5
- mkdirSync,
6
- writeFileSync,
7
- existsSync,
8
- readFileSync,
9
- } from 'node:fs';
10
- import { tmpdir } from 'node:os';
11
- import { dirname, join } from 'node:path';
12
- import { test } from 'node:test';
13
- import { fileURLToPath } from 'node:url';
14
-
15
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
16
-
17
- // Runs the gate in a temp project (with .git). The gate is off by default, so config
18
- // enables it. Returns { output, mapPath, project } so a test can inspect the written map.
19
- function runGate(payload, { config } = {}) {
20
- const project = mkdtempSync(join(tmpdir(), 'tool-map-'));
21
- mkdirSync(join(project, '.git'));
22
- mkdirSync(join(project, '.ai'));
23
- const effectiveConfig = config ?? { gates: { maintainToolMap: true } };
24
- writeFileSync(
25
- join(project, '.ai', 'config.json'),
26
- JSON.stringify(effectiveConfig),
27
- );
28
- const output = execFileSync(process.execPath, [GATE], {
29
- input: JSON.stringify(payload),
30
- encoding: 'utf8',
31
- cwd: project,
32
- // Isolate from the user's real global config: point homedir() at the temp
33
- // project so the global-config fallback finds nothing (registry default).
34
- env: { ...process.env, HOME: project, USERPROFILE: project },
35
- });
36
- return {
37
- output: output.trim(),
38
- mapPath: join(project, '.ai', 'tool-map.json'),
39
- };
40
- }
41
-
42
- function writeTool(filePath, content) {
43
- return { tool_name: 'Write', tool_input: { file_path: filePath, content } };
44
- }
45
-
46
- test('records a new tool that declares its audit', () => {
47
- const { mapPath } = runGate(
48
- writeTool(
49
- 'scripts/csv-parser.mjs',
50
- '// Justification: no existing tool covers this.\nexport {}',
51
- ),
52
- );
53
- assert.ok(existsSync(mapPath));
54
- const map = JSON.parse(readFileSync(mapPath, 'utf8'));
55
- assert.equal(map.tools.length, 1);
56
- assert.equal(map.tools[0].path, 'scripts/csv-parser.mjs');
57
- assert.match(map.tools[0].audit, /no existing tool/i);
58
- });
59
-
60
- test('does not record a tool without an audit line', () => {
61
- const { mapPath } = runGate(
62
- writeTool('scripts/thing.mjs', 'export function thing() {}'),
63
- );
64
- assert.ok(!existsSync(mapPath));
65
- });
66
-
67
- test('ignores a non-tool write', () => {
68
- const { mapPath } = runGate(writeTool('docs/readme.md', 'audited: hi'));
69
- assert.ok(!existsSync(mapPath));
70
- });
71
-
72
- test('does not duplicate an already-recorded tool', () => {
73
- // Two writes of the same tool path record it once.
74
- const first = runGate(
75
- writeTool('scripts/x.mjs', '// audited: nothing does this\nexport {}'),
76
- );
77
- const map = JSON.parse(readFileSync(first.mapPath, 'utf8'));
78
- assert.equal(map.tools.length, 1);
79
- });
80
-
81
- test('disabled by config: does not record', () => {
82
- const { mapPath } = runGate(
83
- writeTool('scripts/y.mjs', '// audited: none\nexport {}'),
84
- { config: { gates: { maintainToolMap: false } } },
85
- );
86
- assert.ok(!existsSync(mapPath));
87
- });
@@ -1,173 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { test } from 'node:test';
6
- import { isGateEnabled, gateParameters } from '../config.mjs';
7
-
8
- const GATE_KEY = 'blockDestructiveShellCommands';
9
-
10
- function temporaryDirectory() {
11
- return mkdtempSync(join(tmpdir(), 'claude-gates-config-'));
12
- }
13
-
14
- /** A project directory with a .git marker so config.mjs finds a project root. */
15
- function projectDirectory() {
16
- const root = temporaryDirectory();
17
- mkdirSync(join(root, '.git'));
18
- return root;
19
- }
20
-
21
- function writeProjectConfig(root, config) {
22
- mkdirSync(join(root, '.ai'), { recursive: true });
23
- writeFileSync(join(root, '.ai', 'config.json'), JSON.stringify(config));
24
- }
25
-
26
- function writeGlobalConfig(home, config) {
27
- const directory = join(home, '.claude', 'claude-gates');
28
- mkdirSync(directory, { recursive: true });
29
- writeFileSync(join(directory, 'config.json'), JSON.stringify(config));
30
- }
31
-
32
- test('isGateEnabled falls back to the registry default when no config exists anywhere', () => {
33
- const project = projectDirectory();
34
- const home = temporaryDirectory();
35
- assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), true);
36
- assert.equal(isGateEnabled(GATE_KEY, false, project, { home }), false);
37
- });
38
-
39
- test('isGateEnabled reads a bool shorthand entry from the project config', () => {
40
- const project = projectDirectory();
41
- const home = temporaryDirectory();
42
- writeProjectConfig(project, { gates: { [GATE_KEY]: false } });
43
- assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), false);
44
-
45
- const projectTrue = projectDirectory();
46
- writeProjectConfig(projectTrue, { gates: { [GATE_KEY]: true } });
47
- assert.equal(isGateEnabled(GATE_KEY, false, projectTrue, { home }), true);
48
- });
49
-
50
- test('isGateEnabled reads an object entry with an explicit enabled flag', () => {
51
- const project = projectDirectory();
52
- const home = temporaryDirectory();
53
- writeProjectConfig(project, {
54
- gates: { [GATE_KEY]: { enabled: false, someParam: 'x' } },
55
- });
56
- assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), false);
57
- });
58
-
59
- test('isGateEnabled treats an object entry with no enabled field as silent (uses registry default)', () => {
60
- const project = projectDirectory();
61
- const home = temporaryDirectory();
62
- writeProjectConfig(project, {
63
- gates: { [GATE_KEY]: { someParam: 'x' } },
64
- });
65
- assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), true);
66
- assert.equal(isGateEnabled(GATE_KEY, false, project, { home }), false);
67
- });
68
-
69
- test('isGateEnabled falls back to the global config when the project declares nothing', () => {
70
- const project = projectDirectory();
71
- const home = temporaryDirectory();
72
- writeGlobalConfig(home, { gates: { [GATE_KEY]: false } });
73
- assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), false);
74
- });
75
-
76
- test('isGateEnabled: an existing project config wins over the global config even if silent about this gate', () => {
77
- const project = projectDirectory();
78
- const home = temporaryDirectory();
79
- writeGlobalConfig(home, { gates: { [GATE_KEY]: false } });
80
- writeProjectConfig(project, { gates: {} });
81
- // Project config exists (even without this gate declared): registry default wins,
82
- // global's false must NOT leak through.
83
- assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), true);
84
- });
85
-
86
- test('isGateEnabled: corrupt project config is treated as absent and falls through to global', () => {
87
- const project = projectDirectory();
88
- const home = temporaryDirectory();
89
- mkdirSync(join(project, '.ai'), { recursive: true });
90
- writeFileSync(join(project, '.ai', 'config.json'), '{not valid json');
91
- writeGlobalConfig(home, { gates: { [GATE_KEY]: false } });
92
- assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), false);
93
- });
94
-
95
- test('isGateEnabled: a malformed entry (string/number) is ignored, falls back to registry default', () => {
96
- const project = projectDirectory();
97
- const home = temporaryDirectory();
98
- writeProjectConfig(project, { gates: { [GATE_KEY]: 'yes' } });
99
- assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), true);
100
- assert.equal(isGateEnabled(GATE_KEY, false, project, { home }), false);
101
-
102
- const projectNumber = projectDirectory();
103
- writeProjectConfig(projectNumber, { gates: { [GATE_KEY]: 1 } });
104
- assert.equal(isGateEnabled(GATE_KEY, false, projectNumber, { home }), false);
105
- });
106
-
107
- test('gateParameters returns {} when nothing is declared for the gate', () => {
108
- const project = projectDirectory();
109
- const home = temporaryDirectory();
110
- writeProjectConfig(project, { gates: {} });
111
- assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {});
112
- });
113
-
114
- test('gateParameters returns {} for a bool shorthand entry (no params, just enabled)', () => {
115
- const project = projectDirectory();
116
- const home = temporaryDirectory();
117
- writeProjectConfig(project, { gates: { [GATE_KEY]: true } });
118
- assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {});
119
- });
120
-
121
- test('gateParameters strips enabled and returns the remaining params from the project config', () => {
122
- const project = projectDirectory();
123
- const home = temporaryDirectory();
124
- writeProjectConfig(project, {
125
- gates: {
126
- [GATE_KEY]: { enabled: true, protectedPaths: ['a', 'b'] },
127
- },
128
- });
129
- assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {
130
- protectedPaths: ['a', 'b'],
131
- });
132
- });
133
-
134
- test('gateParameters: project params replace (do not merge with) global params', () => {
135
- const project = projectDirectory();
136
- const home = temporaryDirectory();
137
- writeGlobalConfig(home, {
138
- gates: {
139
- [GATE_KEY]: { protectedPaths: ['global-a'], extra: 'g' },
140
- },
141
- });
142
- writeProjectConfig(project, {
143
- gates: { [GATE_KEY]: { protectedPaths: ['project-a'] } },
144
- });
145
- assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {
146
- protectedPaths: ['project-a'],
147
- });
148
- });
149
-
150
- test('gateParameters returns {} when no config exists anywhere', () => {
151
- const project = projectDirectory();
152
- const home = temporaryDirectory();
153
- assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {});
154
- });
155
-
156
- test('a project config saved WITH a UTF-8 BOM is still read (not silently dropped)', () => {
157
- // A BOM is what a Windows editor or PowerShell 5.1 `Set-Content -Encoding utf8` prepends.
158
- // Before the fix, JSON.parse threw on the leading BOM, the project config was treated as
159
- // absent, and its gate disables were silently ignored (falling through to global/registry).
160
- // Here the project disables the gate; the BOM must not resurrect it.
161
- const project = projectDirectory();
162
- const home = temporaryDirectory();
163
- mkdirSync(join(project, '.ai'), { recursive: true });
164
- writeFileSync(
165
- join(project, '.ai', 'config.json'),
166
- '' + JSON.stringify({ gates: { [GATE_KEY]: { enabled: false } } }),
167
- );
168
- assert.equal(
169
- isGateEnabled(GATE_KEY, true, project, { home }),
170
- false,
171
- 'the BOM-prefixed project disable must take effect',
172
- );
173
- });