@devrik-tools/claude-gates 0.1.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 (80) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/README.es.md +219 -0
  3. package/README.md +222 -0
  4. package/cli/__tests__/config.test.mjs +101 -0
  5. package/cli/__tests__/init-flags.test.mjs +111 -0
  6. package/cli/__tests__/registry-gates-consistency.test.mjs +44 -0
  7. package/cli/__tests__/selection.test.mjs +200 -0
  8. package/cli/config.mjs +113 -0
  9. package/cli/constants.mjs +36 -0
  10. package/cli/index.mjs +97 -0
  11. package/cli/init.mjs +256 -0
  12. package/cli/install.mjs +80 -0
  13. package/cli/materialize.mjs +51 -0
  14. package/cli/registry.mjs +127 -0
  15. package/cli/selection.mjs +83 -0
  16. package/package.json +73 -0
  17. package/plugins/gates/.claude-plugin/plugin.json +9 -0
  18. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -0
  19. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +92 -0
  20. package/plugins/gates/hooks/gates/bash-commands/index.mjs +233 -0
  21. package/plugins/gates/hooks/gates/bash-commands/test.mjs +113 -0
  22. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +177 -0
  23. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +111 -0
  24. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +456 -0
  25. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +143 -0
  26. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +118 -0
  27. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +109 -0
  28. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +49 -0
  29. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +68 -0
  30. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +100 -0
  31. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +97 -0
  32. package/plugins/gates/hooks/gates/forge-flow/index.mjs +112 -0
  33. package/plugins/gates/hooks/gates/forge-flow/test.mjs +135 -0
  34. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +186 -0
  35. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +86 -0
  36. package/plugins/gates/hooks/gates/intent-flow/index.mjs +238 -0
  37. package/plugins/gates/hooks/gates/intent-flow/test.mjs +136 -0
  38. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +166 -0
  39. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +119 -0
  40. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +85 -0
  41. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +65 -0
  42. package/plugins/gates/hooks/gates/never-assume/index.mjs +55 -0
  43. package/plugins/gates/hooks/gates/never-assume/test.mjs +78 -0
  44. package/plugins/gates/hooks/gates/no-blocking/index.mjs +142 -0
  45. package/plugins/gates/hooks/gates/no-blocking/test.mjs +108 -0
  46. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +120 -0
  47. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +106 -0
  48. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +202 -0
  49. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +131 -0
  50. package/plugins/gates/hooks/gates/protected-paths/index.mjs +147 -0
  51. package/plugins/gates/hooks/gates/protected-paths/test.mjs +75 -0
  52. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +64 -0
  53. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +99 -0
  54. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +150 -0
  55. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +101 -0
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +203 -0
  57. package/plugins/gates/hooks/gates/risk-level/test.mjs +125 -0
  58. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +50 -0
  59. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +73 -0
  60. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +111 -0
  61. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +80 -0
  62. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +99 -0
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +78 -0
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +251 -0
  65. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +163 -0
  66. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -0
  67. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +81 -0
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +181 -0
  69. package/plugins/gates/hooks/gates/test-matrix/test.mjs +87 -0
  70. package/plugins/gates/hooks/gates/tool-map/index.mjs +140 -0
  71. package/plugins/gates/hooks/gates/tool-map/test.mjs +87 -0
  72. package/plugins/gates/hooks/hooks.json +266 -0
  73. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +154 -0
  74. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +154 -0
  75. package/plugins/gates/hooks/lib/config.mjs +165 -0
  76. package/plugins/gates/hooks/lib/hook-io.mjs +208 -0
  77. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +132 -0
  78. package/plugins/tasks/hooks/lib/task-store.mjs +159 -0
  79. package/plugins/tasks/hooks/register-requests.mjs +108 -0
  80. package/registry.json +668 -0
@@ -0,0 +1,181 @@
1
+ // test-matrix — denies an implementation delegation whose brief touches a domain that
2
+ // makes a test type mandatory (money/auth/persistence → E2E; UI → visual/QA; always →
3
+ // unit/mutation) without that type declared in the prompt. Migrated from
4
+ // ~/.claude/hooks/guard-matriz-de-tests.mjs.
5
+ //
6
+ // ── What a project can configure (params) ───────────────────────────────────────────
7
+ // e2eSignals regex sources (matched case-insensitively) that make an E2E row
8
+ // mandatory when found in the brief (money, auth, persistence...).
9
+ // Replaces the built-in list wholesale.
10
+ // visualSignals regex sources that make a visual/QA row mandatory (UI signals).
11
+ // Replaces the built-in list wholesale.
12
+ // The defaults live here, in the source, so a project reads them and knows exactly what
13
+ // its override replaces.
14
+ //
15
+ // ── Off by default, and quiet outside its narrow trigger ───────────────────────────
16
+ // Only STANDARD/HIGH-RISK implementation delegations on builder subagents, not about
17
+ // the harness itself, reach the check. A brief that only describes work never triggers.
18
+
19
+ import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
20
+
21
+ const GATE_ID = 'test-matrix';
22
+ const CONFIG_KEY = 'requireTestMatrixWhenImplementing';
23
+
24
+ const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
25
+
26
+ const DEFAULT_EXEMPT_SUBAGENTS = [
27
+ 'scout',
28
+ 'explore',
29
+ 'plan',
30
+ 'revision',
31
+ 'contraste',
32
+ 'test-planner',
33
+ 'qa',
34
+ 'ui',
35
+ 'ux',
36
+ ];
37
+
38
+ const DEFAULT_E2E_SIGNALS = [
39
+ 'money|amount|payment|charge|invoice|balance',
40
+ 'auth|authentication|login|session|token|permission|role',
41
+ 'persist|database|migration|transaction',
42
+ ];
43
+
44
+ const DEFAULT_VISUAL_SIGNALS = [
45
+ 'ui|interface|component|screen|view',
46
+ 'form|button|modal|layout|style',
47
+ 'responsive|mobile|visual|\\.vue|\\.tsx?|\\.jsx?',
48
+ ];
49
+
50
+ function withWordBoundary(alternation) {
51
+ return new RegExp(
52
+ `(?:^|[^\\p{L}\\p{N}_])(?:${alternation})(?:[^\\p{L}\\p{N}_]|$)`,
53
+ 'iu',
54
+ );
55
+ }
56
+
57
+ const IMPLEMENTATION_VERBS = withWordBoundary(
58
+ 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
59
+ 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
60
+ 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
61
+ 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
62
+ 'escrib(í|e)|escribir|implement\\w*|writ(?:e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
63
+ 'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
64
+ );
65
+
66
+ /** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
67
+ * the plugin-wide convention (see risk-level.mjs). */
68
+ const DEMANDING_LEVEL_PATTERN =
69
+ /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
70
+ const EXEMPT_LEVEL_PATTERN =
71
+ /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
72
+
73
+ const HARNESS_PATTERN =
74
+ /(\.claude[\\/]|hooks[\\/]|plugins[\\/]gates|settings\.json|[\\/]agents[\\/]\w|[\\/]rules[\\/]\w|[\\/]skills[\\/]\w)/i;
75
+
76
+ const DETECTS_E2E = /\b(e2e|end[- ]to[- ]end|playwright)\b/i;
77
+ const DETECTS_VISUAL = /\b(visual|qa\b|screenshot|snapshot)\b/i;
78
+ const DETECTS_UNIT_OR_MUTATION =
79
+ /\b(unit\b|units\b|vitest|jest|mutation|mutant|stryker)\b/i;
80
+ const DETECTS_NEGATIVE_CASES = withWordBoundary(
81
+ 'negative|edge|empty|error|no data|zero|count 0|failure|invalid|limit',
82
+ );
83
+
84
+ function joinSignals(signals) {
85
+ return withWordBoundary(signals.join('|'));
86
+ }
87
+
88
+ function isExempt(toolInput, prompt, exemptSubagents) {
89
+ const subagentType = String(
90
+ toolInput.subagent_type ?? toolInput.subagentType ?? '',
91
+ ).toLowerCase();
92
+ if (exemptSubagents.includes(subagentType)) return true;
93
+ if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
94
+ if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
95
+ if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
96
+ if (HARNESS_PATTERN.test(prompt)) return true;
97
+ return false;
98
+ }
99
+
100
+ /** Which mandatory test types are missing from the prompt, given the brief text. */
101
+ function missingTypes(prompt, e2ePattern, visualPattern) {
102
+ const missing = [];
103
+
104
+ if (!DETECTS_UNIT_OR_MUTATION.test(prompt)) {
105
+ missing.push({
106
+ type: 'unit/mutation',
107
+ line: '- unit/mutation: yes — <what it covers> (or: not applicable — <reason>)',
108
+ });
109
+ }
110
+
111
+ if (e2ePattern.test(prompt) && !DETECTS_E2E.test(prompt)) {
112
+ missing.push({
113
+ type: 'E2E',
114
+ line: '- E2E: yes — <end-to-end flow verified> (mandatory: the requirement touches money/auth/persistence)',
115
+ });
116
+ }
117
+
118
+ if (visualPattern.test(prompt) && !DETECTS_VISUAL.test(prompt)) {
119
+ missing.push({
120
+ type: 'visual/QA',
121
+ line: '- visual/QA: yes — <screen/state reviewed in the browser> (mandatory: the requirement touches UI)',
122
+ });
123
+ }
124
+
125
+ const declaresAnyType =
126
+ DETECTS_UNIT_OR_MUTATION.test(prompt) ||
127
+ DETECTS_E2E.test(prompt) ||
128
+ DETECTS_VISUAL.test(prompt);
129
+ if (declaresAnyType && !DETECTS_NEGATIVE_CASES.test(prompt)) {
130
+ missing.push({
131
+ type: 'negative/edge cases',
132
+ line: '- negative/edge cases: <no data / empty / error / zero value / invalid input tested> (the happy path is not enough)',
133
+ });
134
+ }
135
+
136
+ return missing;
137
+ }
138
+
139
+ runGate(
140
+ {
141
+ id: GATE_ID,
142
+ configKey: CONFIG_KEY,
143
+ enabledByDefault: false,
144
+ defaultParams: {
145
+ exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
146
+ e2eSignals: DEFAULT_E2E_SIGNALS,
147
+ visualSignals: DEFAULT_VISUAL_SIGNALS,
148
+ },
149
+ },
150
+ ({ toolName, toolInput, parameters }) => {
151
+ if (!DELEGATION_TOOLS.has(toolName)) return;
152
+
153
+ const prompt = String(
154
+ toolInput.prompt ?? toolInput.Prompt ?? toolInput.task ?? '',
155
+ );
156
+ if (!prompt.trim()) return;
157
+
158
+ const exemptSubagents =
159
+ parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
160
+ if (isExempt(toolInput, prompt, exemptSubagents)) return;
161
+
162
+ const e2eSignals = parameters.e2eSignals ?? DEFAULT_E2E_SIGNALS;
163
+ const visualSignals = parameters.visualSignals ?? DEFAULT_VISUAL_SIGNALS;
164
+ const e2ePattern = joinSignals(e2eSignals);
165
+ const visualPattern = joinSignals(visualSignals);
166
+
167
+ const missing = missingTypes(prompt, e2ePattern, visualPattern);
168
+ if (missing.length === 0) return;
169
+
170
+ const lines = missing.map((entry) => ` ${entry.line}`).join('\n');
171
+ deny(
172
+ GATE_ID,
173
+ `Missing ${missing.length} test type(s) the requirement makes mandatory. Test types ` +
174
+ 'are chosen ACCORDING TO THE REQUIREMENT: E2E if it touches money/auth/persistence, ' +
175
+ 'visual/QA if it touches UI, and always unit or mutation. ' +
176
+ `PASTE THIS SECTION INTO THE PROMPT (adjust yes/no with judgment) AND RELAUNCH:\n` +
177
+ `TEST MATRIX (by type, according to the requirement):\n${lines}\n` +
178
+ 'A type that truly does not apply is declared "no — <reason>", never silently omitted.',
179
+ );
180
+ },
181
+ );
@@ -0,0 +1,87 @@
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('e2eSignals param override makes a domain word mandatory that the default list omits', () => {
83
+ const prompt =
84
+ 'Nivel: STANDARD\nImplementá el módulo de inventario. Verificación: vitest cubre el cálculo.';
85
+ const config = enabledConfig({ e2eSignals: ['inventario|inventory'] });
86
+ assert.ok(isDeny(runGate(delegate(prompt), { config })));
87
+ });
@@ -0,0 +1,140 @@
1
+ // tool-map — maintains the per-project tool map so a discovery is never repeated. It is
2
+ // the write half of the discovery pair (reuse-before-build is the read half). When a new
3
+ // tool is created WITH its audit declared, this gate records what that tool covers into
4
+ // .ai/tool-map.json, so next time reuse-before-build finds it and does not block.
5
+ //
6
+ // It never reaches the network and takes no judgment: it only appends a deterministic
7
+ // record (path + the audit line the author already wrote) to a JSON file. The cloud search
8
+ // that decides "does something already exist?" is the assistant's / the CLI's job. This
9
+ // gate never denies — it observes a legitimate build and remembers it. It warns once when
10
+ // it could not record, so a silent write failure does not go unnoticed.
11
+ //
12
+ // ── What a project can configure (params) ───────────────────────────────────────────
13
+ // toolMapFile path to the per-project tool map, relative to the project root.
14
+ // Default .ai/tool-map.json.
15
+
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { dirname, extname, join } from 'node:path';
18
+ import { runGate, warn, TOOL_GROUPS } from '../../lib/hook-io.mjs';
19
+
20
+ const GATE_ID = 'tool-map';
21
+ const CONFIG_KEY = 'maintainToolMap';
22
+
23
+ const WRITE_TOOLS = new Set(TOOL_GROUPS.write);
24
+ const DEFAULT_TOOL_MAP_FILE = join('.ai', 'tool-map.json');
25
+ const JSON_INDENT = 2;
26
+ const MAX_AUDIT_LENGTH = 300;
27
+
28
+ const EXECUTABLE_EXTENSIONS = new Set([
29
+ '.js',
30
+ '.mjs',
31
+ '.cjs',
32
+ '.ts',
33
+ '.py',
34
+ '.sh',
35
+ '.ps1',
36
+ ]);
37
+ const TOOL_FOLDERS = ['scripts/', 'hooks/', 'tools/', 'gates/'];
38
+
39
+ // Phrases that mark a line as the author's audit statement. Matched against one line at a
40
+ // time (no wrapping `.*`, which backtracks): the matching line is captured whole.
41
+ const AUDIT_PHRASE_PATTERN =
42
+ /(already exists|no existing tool|there is no plugin|audited|nothing does this|justification:)/i;
43
+
44
+ /** The first line of `content` that states an audit, trimmed — or null when none does. */
45
+ function auditLineOf(content) {
46
+ for (const line of content.split('\n')) {
47
+ if (AUDIT_PHRASE_PATTERN.test(line)) return line.trim();
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function projectRootOf(startDirectory) {
53
+ let current = startDirectory;
54
+ while (true) {
55
+ if (existsSync(join(current, '.git'))) return current;
56
+ const parent = dirname(current);
57
+ if (parent === current) return null;
58
+ current = parent;
59
+ }
60
+ }
61
+
62
+ function isExecutableToolPath(filePath) {
63
+ const normalized = filePath.replace(/\\/g, '/');
64
+ const inToolFolder = TOOL_FOLDERS.some((folder) =>
65
+ normalized.includes(folder),
66
+ );
67
+ return (
68
+ inToolFolder && EXECUTABLE_EXTENSIONS.has(extname(filePath).toLowerCase())
69
+ );
70
+ }
71
+
72
+ function readMap(path) {
73
+ if (!existsSync(path)) return { tools: [] };
74
+ try {
75
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
76
+ return Array.isArray(parsed.tools) ? parsed : { tools: [] };
77
+ } catch {
78
+ return { tools: [] };
79
+ }
80
+ }
81
+
82
+ /** Appends the tool's record to the map file, warning (never blocking) if it cannot. */
83
+ function recordTool(mapPath, normalizedPath, auditLine, toolMapFile) {
84
+ const map = readMap(mapPath);
85
+ if (map.tools.some((tool) => tool.path === normalizedPath)) return; // already recorded
86
+
87
+ map.tools.push({
88
+ path: normalizedPath,
89
+ audit: auditLine.slice(0, MAX_AUDIT_LENGTH),
90
+ recordedAt: new Date().toISOString(),
91
+ });
92
+
93
+ try {
94
+ mkdirSync(dirname(mapPath), { recursive: true });
95
+ writeFileSync(
96
+ mapPath,
97
+ `${JSON.stringify(map, null, JSON_INDENT)}\n`,
98
+ 'utf8',
99
+ );
100
+ } catch (error) {
101
+ warn(
102
+ GATE_ID,
103
+ `Could not record this tool in ${toolMapFile}: ${error?.message ?? error}. ` +
104
+ 'The build proceeds, but the discovery was not remembered.',
105
+ );
106
+ }
107
+ }
108
+
109
+ runGate(
110
+ {
111
+ id: GATE_ID,
112
+ configKey: CONFIG_KEY,
113
+ enabledByDefault: false,
114
+ defaultParams: { toolMapFile: DEFAULT_TOOL_MAP_FILE },
115
+ },
116
+ ({ toolName, toolInput, parameters }) => {
117
+ if (!WRITE_TOOLS.has(toolName)) return;
118
+
119
+ const filePath = String(
120
+ toolInput.file_path ?? toolInput.target_file ?? toolInput.path ?? '',
121
+ );
122
+ if (!isExecutableToolPath(filePath)) return;
123
+
124
+ // Only a build that declared its audit is worth recording: that line IS the reason
125
+ // this tool exists and what it was checked against.
126
+ const content = String(toolInput.content ?? toolInput.CodeContent ?? '');
127
+ const auditLine = auditLineOf(content);
128
+ if (!auditLine) return;
129
+
130
+ const root = projectRootOf(process.cwd());
131
+ if (!root) return;
132
+ const toolMapFile = parameters.toolMapFile ?? DEFAULT_TOOL_MAP_FILE;
133
+ recordTool(
134
+ join(root, toolMapFile),
135
+ filePath.replace(/\\/g, '/'),
136
+ auditLine,
137
+ toolMapFile,
138
+ );
139
+ },
140
+ );
@@ -0,0 +1,87 @@
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
+ });