@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,251 @@
1
+ // sdd-specs — denies implementation (a write to the catalog moving a feature past
2
+ // `spec_ready`, or a delegation prompt that implements) when the feature/task it targets
3
+ // has no non-empty contract on disk (requirements/design/tasks or brief/asserts).
4
+ // Migrated from ~/.claude/hooks/guard-sdd-specs.mjs.
5
+ //
6
+ // ── What a project can configure (params) ───────────────────────────────────────────
7
+ // catalogLocations paths (relative to the project root) searched for the feature
8
+ // catalog. Replaces the built-in list wholesale.
9
+ // exemptSubagents subagent types exempt from the spec requirement (read-only
10
+ // stages of the pipeline that cannot require a contract of
11
+ // themselves). 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
+ // ── Auto-off when there is no SDD harness ───────────────────────────────────────────
16
+ // This gate only acts once a catalog is found at one of `catalogLocations` (or the
17
+ // project's own `.ai/config.json` declares adoption). A project that never adopted the
18
+ // spec-driven harness has no catalog and no declared adoption, so every check below is
19
+ // skipped and the write/delegation is allowed — imposing phases on a project that never
20
+ // asked for them is the false positive that gets a gate disabled.
21
+ //
22
+ // ── Two surfaces inspected ───────────────────────────────────────────────────────────
23
+ // A write to the catalog is checked against the catalog's own declared statuses: a
24
+ // feature marked `spec_ready`/`in_progress`/`done` needs its contract tree in place. A
25
+ // delegation prompt is checked when it declares a STANDARD/HIGH-RISK level and an
26
+ // implementation verb: the contract tree must have non-empty requirements/design/tasks
27
+ // (or brief/asserts) for the feature it targets.
28
+
29
+ import { existsSync, statSync } from 'node:fs';
30
+ import { dirname, join } from 'node:path';
31
+ import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
32
+
33
+ const GATE_ID = 'sdd-specs';
34
+ const CONFIG_KEY = 'requireSpecBeforeImplementing';
35
+
36
+ const WRITE_TOOLS = new Set(TOOL_GROUPS.write);
37
+ const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
38
+
39
+ const CATALOG_FILE_NAME = 'feature_list.json';
40
+
41
+ const DEFAULT_CATALOG_LOCATIONS = [
42
+ join('.ai', CATALOG_FILE_NAME),
43
+ CATALOG_FILE_NAME,
44
+ ];
45
+
46
+ const DEFAULT_EXEMPT_SUBAGENTS = [
47
+ 'explore',
48
+ 'plan',
49
+ 'scout',
50
+ 'revision',
51
+ 'contraste',
52
+ 'test-planner',
53
+ 'qa',
54
+ 'ui',
55
+ 'ux',
56
+ ];
57
+
58
+ const ADVANCED_STATUSES = new Set(['spec_ready', 'in_progress', 'done']);
59
+
60
+ /** Files that count as a feature's contract; at least one must be non-empty. */
61
+ const CONTRACT_FILES = [
62
+ 'requirements.md',
63
+ 'design.md',
64
+ 'tasks.md',
65
+ 'brief.md',
66
+ 'asserts.md',
67
+ ];
68
+
69
+ function withWordBoundary(alternation) {
70
+ return new RegExp(
71
+ `(?<![\\p{L}\\p{N}_])(${alternation})(?![\\p{L}\\p{N}_])`,
72
+ 'iu',
73
+ );
74
+ }
75
+
76
+ const IMPLEMENTATION_VERBS = withWordBoundary(
77
+ 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
78
+ 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
79
+ 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
80
+ 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
81
+ 'escrib(í|e)|escribir|implement\\w*|writ(e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
82
+ 'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
83
+ );
84
+
85
+ /** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
86
+ * the plugin-wide convention (see risk-level.mjs). */
87
+ const DEMANDING_LEVEL_PATTERN =
88
+ /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
89
+ const EXEMPT_LEVEL_PATTERN =
90
+ /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
91
+
92
+ function fileExistsNonEmpty(path) {
93
+ try {
94
+ return existsSync(path) && statSync(path).size > 0;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ function findCatalog(catalogLocations) {
101
+ for (const relative of catalogLocations) {
102
+ const path = join(process.cwd(), relative);
103
+ if (existsSync(path)) return path;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /** Discovers the contract tree root next to wherever the catalog lives, or `.ai/features`. */
109
+ function contractTreeRootFor(catalogPath) {
110
+ const candidates = catalogPath
111
+ ? [join(dirname(catalogPath), 'features')]
112
+ : [];
113
+ candidates.push(join(process.cwd(), '.ai', 'features'));
114
+ candidates.push(join(process.cwd(), 'features'));
115
+ for (const candidate of candidates) {
116
+ if (existsSync(candidate)) return candidate;
117
+ }
118
+ return null;
119
+ }
120
+
121
+ function contractExistsFor(treeRoot, featureName) {
122
+ if (!treeRoot) return false;
123
+ const featureDirectory = join(treeRoot, featureName);
124
+ if (!existsSync(featureDirectory)) return false;
125
+ return CONTRACT_FILES.some((file) =>
126
+ fileExistsNonEmpty(join(featureDirectory, file)),
127
+ );
128
+ }
129
+
130
+ function parseJsonOrNull(text) {
131
+ try {
132
+ return JSON.parse(text);
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ function writeTargetFrom(toolInput) {
139
+ return String(
140
+ toolInput.TargetFile ??
141
+ toolInput.target_file ??
142
+ toolInput.file_path ??
143
+ toolInput.path ??
144
+ '',
145
+ );
146
+ }
147
+
148
+ function writeContentFrom(toolInput) {
149
+ return String(
150
+ toolInput.CodeContent ??
151
+ toolInput.ReplacementContent ??
152
+ toolInput.content ??
153
+ '',
154
+ );
155
+ }
156
+
157
+ /** Denies the single first advanced-status feature in the new catalog content that has
158
+ * no contract on disk, or does nothing when every advanced feature has one. */
159
+ function denyIfAdvancedFeatureLacksContract(features, treeRoot) {
160
+ for (const feature of features) {
161
+ if (!feature?.name || !ADVANCED_STATUSES.has(feature.status)) continue;
162
+ if (contractExistsFor(treeRoot, feature.name)) continue;
163
+ deny(
164
+ GATE_ID,
165
+ `Feature '${feature.name}' is set to '${feature.status}' but has no non-empty ` +
166
+ `contract (${CONTRACT_FILES.join(', ')}) under the discovered contract tree.`,
167
+ );
168
+ }
169
+ }
170
+
171
+ function checkCatalogWrite(toolInput, catalogPath, treeRoot) {
172
+ const target = writeTargetFrom(toolInput);
173
+ if (!catalogPath || !target.includes(CATALOG_FILE_NAME)) return;
174
+
175
+ const parsed = parseJsonOrNull(writeContentFrom(toolInput));
176
+ const features = Array.isArray(parsed?.features) ? parsed.features : [];
177
+ denyIfAdvancedFeatureLacksContract(features, treeRoot);
178
+ }
179
+
180
+ function isExemptDelegation(toolInput, prompt, exemptSubagents) {
181
+ const subagentType = String(
182
+ toolInput.subagent_type ?? toolInput.subagentType ?? '',
183
+ ).toLowerCase();
184
+ if (exemptSubagents.includes(subagentType)) return true;
185
+ if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
186
+ if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
187
+ if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
188
+ return false;
189
+ }
190
+
191
+ function featureNamesCitedIn(prompt) {
192
+ const pattern = /\.(?:ai)[\\/]features[\\/]([\w.@-]+)/gi;
193
+ const names = [];
194
+ let match;
195
+ while ((match = pattern.exec(prompt)) !== null) names.push(match[1]);
196
+ return names;
197
+ }
198
+
199
+ function checkDelegation(toolInput, treeRoot, exemptSubagents) {
200
+ const prompt = String(
201
+ toolInput.prompt ?? toolInput.Prompt ?? toolInput.task ?? '',
202
+ );
203
+ if (!prompt.trim()) return;
204
+ if (isExemptDelegation(toolInput, prompt, exemptSubagents)) return;
205
+
206
+ const citedFeatures = featureNamesCitedIn(prompt);
207
+ if (citedFeatures.length === 0) return; // no citation: nothing this gate can check
208
+
209
+ const missing = citedFeatures.filter(
210
+ (feature) => !contractExistsFor(treeRoot, feature),
211
+ );
212
+ if (missing.length === citedFeatures.length) {
213
+ deny(
214
+ GATE_ID,
215
+ `This implementation delegation cites feature(s) [${missing.join(', ')}] with no ` +
216
+ `non-empty contract (${CONTRACT_FILES.join(', ')}) on disk. Write the contract ` +
217
+ 'before implementing.',
218
+ );
219
+ }
220
+ }
221
+
222
+ runGate(
223
+ {
224
+ id: GATE_ID,
225
+ configKey: CONFIG_KEY,
226
+ enabledByDefault: false,
227
+ defaultParams: {
228
+ catalogLocations: DEFAULT_CATALOG_LOCATIONS,
229
+ exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
230
+ },
231
+ },
232
+ ({ toolName, toolInput, parameters }) => {
233
+ const isWrite = WRITE_TOOLS.has(toolName);
234
+ const isDelegation = DELEGATION_TOOLS.has(toolName);
235
+ if (!isWrite && !isDelegation) return;
236
+
237
+ const catalogLocations =
238
+ parameters.catalogLocations ?? DEFAULT_CATALOG_LOCATIONS;
239
+ const catalogPath = findCatalog(catalogLocations);
240
+ if (!catalogPath) return; // no SDD harness adopted: stay silent
241
+
242
+ const treeRoot = contractTreeRootFor(catalogPath);
243
+
244
+ if (isWrite) checkCatalogWrite(toolInput, catalogPath, treeRoot);
245
+ if (isDelegation) {
246
+ const exemptSubagents =
247
+ parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
248
+ checkDelegation(toolInput, treeRoot, exemptSubagents);
249
+ }
250
+ },
251
+ );
@@ -0,0 +1,163 @@
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 makeProject() {
12
+ const project = mkdtempSync(join(tmpdir(), 'sdd-specs-'));
13
+ mkdirSync(join(project, '.git'));
14
+ return project;
15
+ }
16
+
17
+ function writeProjectConfig(project, config) {
18
+ mkdirSync(join(project, '.ai'), { recursive: true });
19
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
20
+ }
21
+
22
+ function runGate(project, payload) {
23
+ const out = execFileSync(process.execPath, [GATE], {
24
+ input: JSON.stringify(payload),
25
+ encoding: 'utf8',
26
+ cwd: project,
27
+ // Isolate from the user's real global config: point homedir() at the temp
28
+ // project so the global-config fallback finds nothing (registry default).
29
+ env: { ...process.env, HOME: project, USERPROFILE: project },
30
+ });
31
+ return out.trim() ? JSON.parse(out.trim()) : null;
32
+ }
33
+
34
+ function delegate(prompt, subagentType = 'backend') {
35
+ return {
36
+ tool_name: 'Agent',
37
+ tool_input: { prompt, subagent_type: subagentType },
38
+ };
39
+ }
40
+
41
+ function isDeny(result) {
42
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
43
+ }
44
+
45
+ function enableGate(project) {
46
+ writeProjectConfig(project, {
47
+ gates: { requireSpecBeforeImplementing: true },
48
+ });
49
+ }
50
+
51
+ test('auto-off: no catalog anywhere allows implementation delegation', () => {
52
+ const project = makeProject();
53
+ enableGate(project);
54
+ const prompt =
55
+ 'Nivel: STANDARD\nImplementá el checkout citando .ai/features/checkout/tasks/t1/';
56
+ assert.equal(runGate(project, delegate(prompt)), null);
57
+ });
58
+
59
+ test('denies an implementation delegation citing a feature with no contract on disk', () => {
60
+ const project = makeProject();
61
+ enableGate(project);
62
+ mkdirSync(join(project, '.ai'), { recursive: true });
63
+ writeFileSync(
64
+ join(project, '.ai', 'feature_list.json'),
65
+ JSON.stringify({ features: [] }),
66
+ );
67
+ // features tree exists but the cited feature has no contract file
68
+ mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
69
+ const prompt =
70
+ 'Nivel: STANDARD\nImplementá .ai/features/checkout/ el flujo de pago.';
71
+ assert.ok(isDeny(runGate(project, delegate(prompt))));
72
+ });
73
+
74
+ test('allows an implementation delegation citing a feature with a non-empty contract', () => {
75
+ const project = makeProject();
76
+ enableGate(project);
77
+ mkdirSync(join(project, '.ai'), { recursive: true });
78
+ writeFileSync(
79
+ join(project, '.ai', 'feature_list.json'),
80
+ JSON.stringify({ features: [] }),
81
+ );
82
+ mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
83
+ writeFileSync(
84
+ join(project, '.ai', 'features', 'checkout', 'brief.md'),
85
+ '# Checkout\n\nObjetivo: cobrar.',
86
+ );
87
+ const prompt =
88
+ 'Nivel: STANDARD\nImplementá .ai/features/checkout/ el flujo de pago.';
89
+ assert.equal(runGate(project, delegate(prompt)), null);
90
+ });
91
+
92
+ test('disabled by config: the gate does not run', () => {
93
+ const project = makeProject();
94
+ writeProjectConfig(project, {
95
+ gates: { requireSpecBeforeImplementing: false },
96
+ });
97
+ mkdirSync(join(project, '.ai'), { recursive: true });
98
+ writeFileSync(
99
+ join(project, '.ai', 'feature_list.json'),
100
+ JSON.stringify({ features: [] }),
101
+ );
102
+ mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
103
+ const prompt =
104
+ 'Nivel: STANDARD\nImplementá .ai/features/checkout/ el flujo de pago.';
105
+ assert.equal(runGate(project, delegate(prompt)), null);
106
+ });
107
+
108
+ test('a prompt that only describes, does not order, is exempt via QUESTION level', () => {
109
+ const project = makeProject();
110
+ enableGate(project);
111
+ mkdirSync(join(project, '.ai'), { recursive: true });
112
+ writeFileSync(
113
+ join(project, '.ai', 'feature_list.json'),
114
+ JSON.stringify({ features: [] }),
115
+ );
116
+ mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
117
+ const prompt =
118
+ 'Nivel: QUESTION\nExplicá cómo funciona .ai/features/checkout/ hoy.';
119
+ assert.equal(runGate(project, delegate(prompt)), null);
120
+ });
121
+
122
+ test('exemptSubagents param override exempts a custom subagent type', () => {
123
+ const project = makeProject();
124
+ writeProjectConfig(project, {
125
+ gates: {
126
+ requireSpecBeforeImplementing: {
127
+ enabled: true,
128
+ exemptSubagents: ['worker'],
129
+ },
130
+ },
131
+ });
132
+ mkdirSync(join(project, '.ai'), { recursive: true });
133
+ writeFileSync(
134
+ join(project, '.ai', 'feature_list.json'),
135
+ JSON.stringify({ features: [] }),
136
+ );
137
+ mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
138
+ const prompt =
139
+ 'Nivel: STANDARD\nImplementá .ai/features/checkout/ el flujo de pago.';
140
+ assert.equal(runGate(project, delegate(prompt, 'worker')), null);
141
+ });
142
+
143
+ test('a catalog write moving a feature to spec_ready without contract is denied', () => {
144
+ const project = makeProject();
145
+ enableGate(project);
146
+ mkdirSync(join(project, '.ai', 'features'), { recursive: true });
147
+ // The catalog already exists on disk (an Edit to an existing file); the write below
148
+ // is the new content about to replace it.
149
+ writeFileSync(
150
+ join(project, '.ai', 'feature_list.json'),
151
+ JSON.stringify({ features: [] }),
152
+ );
153
+ const write = {
154
+ tool_name: 'Write',
155
+ tool_input: {
156
+ file_path: join(project, '.ai', 'feature_list.json'),
157
+ content: JSON.stringify({
158
+ features: [{ name: 'checkout', status: 'spec_ready' }],
159
+ }),
160
+ },
161
+ };
162
+ assert.ok(isDeny(runGate(project, write)));
163
+ });
@@ -0,0 +1,101 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { basename, dirname, relative } from 'node:path';
4
+ import { runGate, warn, TOOL_GROUPS } from '../../lib/hook-io.mjs';
5
+
6
+ const GATE_ID = 'test-after-implementation';
7
+ const CONFIG_KEY = 'warnTestWrittenAfterImplementation';
8
+
9
+ const DEFAULT_IMPLEMENTATION_EXTENSIONS = [
10
+ '.ts',
11
+ '.tsx',
12
+ '.js',
13
+ '.jsx',
14
+ '.mjs',
15
+ '.vue',
16
+ ];
17
+ const TEST_FILE_PATTERN = /\.(spec|test)\.[cm]?[jt]sx?$/i;
18
+ // `git status --porcelain` prefixes each line with a 2-character status code plus a space.
19
+ const PORCELAIN_STATUS_PREFIX_LENGTH = 3;
20
+
21
+ function baseNameWithoutTestSuffix(fileName) {
22
+ return fileName.replace(TEST_FILE_PATTERN, '');
23
+ }
24
+
25
+ function gitStatusPorcelain(cwd) {
26
+ try {
27
+ const output = execFileSync('git', ['status', '--porcelain'], {
28
+ cwd,
29
+ timeout: 5000,
30
+ }).toString();
31
+ return output
32
+ .split('\n')
33
+ .map((line) => line.trim())
34
+ .filter(Boolean);
35
+ } catch {
36
+ // Not a git repo, or git unavailable — nothing to cross-check against.
37
+ return [];
38
+ }
39
+ }
40
+
41
+ runGate(
42
+ {
43
+ id: GATE_ID,
44
+ configKey: CONFIG_KEY,
45
+ enabledByDefault: false,
46
+ defaultParams: {
47
+ implementationExtensions: DEFAULT_IMPLEMENTATION_EXTENSIONS,
48
+ },
49
+ },
50
+ ({ toolName, toolInput, parameters }) => {
51
+ if (!TOOL_GROUPS.write.includes(toolName)) return;
52
+
53
+ const filePath = toolInput?.file_path ?? '';
54
+ if (!TEST_FILE_PATTERN.test(filePath)) return;
55
+ if (existsSync(filePath)) return; // only new test file creation, not edits
56
+
57
+ const cwd = process.cwd();
58
+ const statusLines = gitStatusPorcelain(cwd);
59
+ if (statusLines.length === 0) return;
60
+
61
+ const testFileName = basename(filePath);
62
+ const stem = baseNameWithoutTestSuffix(testFileName);
63
+ // `git status --porcelain` reports paths relative to `cwd`; the test's own
64
+ // directory (relative to `cwd`) is the fair comparison, not its absolute path.
65
+ // `relative()` returns '' for the repo root itself, while `dirname()` on a
66
+ // bare file name returns '.' — normalize both to '.' so root-level files compare equal.
67
+ const testDirectoryRelativeToRepo = relative(cwd, dirname(filePath)) || '.';
68
+
69
+ const pairedImplementationChanged = statusLines.some((line) => {
70
+ const changedPath = line.slice(PORCELAIN_STATUS_PREFIX_LENGTH).trim();
71
+ if (
72
+ !parameters.implementationExtensions.some((extension) =>
73
+ changedPath.endsWith(extension),
74
+ )
75
+ )
76
+ return false;
77
+ if (TEST_FILE_PATTERN.test(changedPath)) return false;
78
+ const lastDot = changedPath.lastIndexOf('.');
79
+ const changedBase = basename(
80
+ changedPath,
81
+ lastDot >= 0 ? changedPath.slice(lastDot) : '',
82
+ );
83
+ return (
84
+ changedBase === stem &&
85
+ (dirname(changedPath) || '.') === testDirectoryRelativeToRepo
86
+ );
87
+ });
88
+
89
+ if (!pairedImplementationChanged) return;
90
+
91
+ warn(
92
+ GATE_ID,
93
+ `Creating test ${testFileName} after its paired implementation was already modified (uncommitted). Consider writing tests before or alongside the implementation, not after.`,
94
+ );
95
+ },
96
+ );
97
+
98
+ // Note: the source guard (guard-test-despues-de-implementar.mjs) denies this
99
+ // case outright. This gate warns instead, because registry.json names the
100
+ // config key warnTestWrittenAfterImplementation, which declares warn as the
101
+ // intended severity for this migration.
@@ -0,0 +1,81 @@
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 newProject({ config, git = true } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'test-after-implementation-'));
13
+ if (git) {
14
+ execFileSync('git', ['init', '-q'], { cwd: project });
15
+ execFileSync('git', ['config', 'user.email', 'a@b.c'], { cwd: project });
16
+ execFileSync('git', ['config', 'user.name', 'a'], { cwd: project });
17
+ }
18
+ if (config) {
19
+ mkdirSync(join(project, '.ai'));
20
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
21
+ }
22
+ return project;
23
+ }
24
+
25
+ function runGateIn(project, payload) {
26
+ const out = execFileSync(process.execPath, [GATE], {
27
+ input: JSON.stringify(payload),
28
+ encoding: 'utf8',
29
+ cwd: project,
30
+ // Isolate from the user's real global config: point homedir() at the temp
31
+ // project so the global-config fallback finds nothing (registry default).
32
+ env: { ...process.env, HOME: project, USERPROFILE: project },
33
+ });
34
+ return out.trim() ? JSON.parse(out.trim()) : null;
35
+ }
36
+
37
+ function writeTest(filePath) {
38
+ return {
39
+ tool_name: 'Write',
40
+ tool_input: { file_path: filePath, content: 'test content' },
41
+ };
42
+ }
43
+ function isWarn(result) {
44
+ return result?.hookSpecificOutput?.additionalContext !== undefined;
45
+ }
46
+
47
+ const ENABLE = { gates: { warnTestWrittenAfterImplementation: true } };
48
+
49
+ test('warns when creating a test whose paired implementation was just modified (uncommitted)', () => {
50
+ const project = newProject({ config: ENABLE });
51
+ writeFileSync(join(project, 'thing.js'), 'export const thing = 1;');
52
+ const testPath = join(project, 'thing.test.js');
53
+ assert.ok(isWarn(runGateIn(project, writeTest(testPath))));
54
+ });
55
+
56
+ test('allows creating a test with no paired uncommitted implementation', () => {
57
+ const project = newProject({ config: ENABLE });
58
+ const testPath = join(project, 'unrelated.test.js');
59
+ assert.equal(runGateIn(project, writeTest(testPath)), null);
60
+ });
61
+
62
+ test('allows editing (not creating) an existing test file', () => {
63
+ const project = newProject({ config: ENABLE });
64
+ writeFileSync(join(project, 'thing.js'), 'export const thing = 1;');
65
+ const testPath = join(project, 'thing.test.js');
66
+ writeFileSync(testPath, 'existing test');
67
+ assert.equal(runGateIn(project, writeTest(testPath)), null);
68
+ });
69
+
70
+ test('degrades to allow when not a git repo', () => {
71
+ const project = newProject({ config: ENABLE, git: false });
72
+ const testPath = join(project, 'thing.test.js');
73
+ assert.equal(runGateIn(project, writeTest(testPath)), null);
74
+ });
75
+
76
+ test('disabled by default (registry default is false)', () => {
77
+ const project = newProject();
78
+ writeFileSync(join(project, 'thing.js'), 'export const thing = 1;');
79
+ const testPath = join(project, 'thing.test.js');
80
+ assert.equal(runGateIn(project, writeTest(testPath)), null);
81
+ });