@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,126 +1,130 @@
|
|
|
1
|
-
import { readdirSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
runGate,
|
|
5
|
-
|
|
6
|
-
toolInGroups,
|
|
7
|
-
writtenPathOf,
|
|
8
|
-
writtenContentOf,
|
|
9
|
-
} from '../../lib/hook-io.mjs';
|
|
10
|
-
|
|
11
|
-
const GATE_ID = 'dependency-skills';
|
|
12
|
-
const CONFIG_KEY = 'requireSkillForNewDependency';
|
|
13
|
-
|
|
14
|
-
const DEFAULT_DEPS_WITHOUT_OWN_API = [
|
|
15
|
-
'clsx',
|
|
16
|
-
'classnames',
|
|
17
|
-
'lodash.debounce',
|
|
18
|
-
'lodash.throttle',
|
|
19
|
-
'lodash.merge',
|
|
20
|
-
'tslib',
|
|
21
|
-
'core-js',
|
|
22
|
-
'regenerator-runtime',
|
|
23
|
-
];
|
|
24
|
-
|
|
25
|
-
function normalize(name) {
|
|
26
|
-
return name.replace(/^@/, '').replace(/\//g, '-').toLowerCase();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function isExempt(dependencyName, dependenciesWithoutOwnApi) {
|
|
30
|
-
if (dependencyName.startsWith('@types/')) return true;
|
|
31
|
-
return dependenciesWithoutOwnApi.includes(dependencyName);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function listSkillDirectories(skillsDirectoryPath) {
|
|
35
|
-
try {
|
|
36
|
-
return readdirSync(skillsDirectoryPath, { withFileTypes: true })
|
|
37
|
-
.filter((entry) => entry.isDirectory())
|
|
38
|
-
.map((entry) => entry.name);
|
|
39
|
-
} catch {
|
|
40
|
-
// No skills directory in this project — nothing to cross-check against.
|
|
41
|
-
return [];
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// A skill covers a dependency when its normalized name equals the dependency's, or contains
|
|
46
|
-
// it as a whole `-`-segment (a `stripe-payments` skill covers `stripe`). The old check was
|
|
47
|
-
// bidirectional substring, so a skill dir `rip` "matched" `stripe` (rip ⊂ st-rip-e) — a false
|
|
48
|
-
// exemption that silenced the warning for an unreviewed dependency.
|
|
49
|
-
function skillNameMatches(normalizedDependency, skillDirectoryName) {
|
|
50
|
-
const normalizedSkill = normalize(skillDirectoryName);
|
|
51
|
-
if (normalizedSkill === normalizedDependency) return true;
|
|
52
|
-
return normalizedSkill.split('-').includes(normalizedDependency);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function hasMatchingSkill(dependencyName, skillDirectories) {
|
|
56
|
-
const normalizedDependency = normalize(dependencyName);
|
|
57
|
-
return skillDirectories.some((skillDirectoryName) =>
|
|
58
|
-
skillNameMatches(normalizedDependency, skillDirectoryName),
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function needsSkillReview(dependencyName, parameters, skillDirectories) {
|
|
63
|
-
if (isExempt(dependencyName, parameters.depsWithoutOwnApi)) return false;
|
|
64
|
-
return !hasMatchingSkill(dependencyName, skillDirectories);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Parsed `package.json` dependency names, or null when it cannot be read yet. */
|
|
68
|
-
function dependencyNamesIn(content) {
|
|
69
|
-
let parsed;
|
|
70
|
-
try {
|
|
71
|
-
parsed = JSON.parse(content);
|
|
72
|
-
} catch {
|
|
73
|
-
// Mid-edit or partially written package.json — nothing reliable to check yet.
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
const dependencies = {
|
|
77
|
-
...(parsed.dependencies ?? {}),
|
|
78
|
-
...(parsed.devDependencies ?? {}),
|
|
79
|
-
};
|
|
80
|
-
return Object.keys(dependencies);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
runGate(
|
|
84
|
-
{
|
|
85
|
-
id: GATE_ID,
|
|
86
|
-
configKey: CONFIG_KEY,
|
|
87
|
-
enabledByDefault: true,
|
|
88
|
-
defaultParams: {
|
|
89
|
-
depsWithoutOwnApi: DEFAULT_DEPS_WITHOUT_OWN_API,
|
|
90
|
-
projectSkillsDir: '.claude/skills',
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
|
-
({ toolName, toolInput, parameters }) => {
|
|
94
|
-
if (!toolInGroups(toolName, ['write'])) return;
|
|
95
|
-
|
|
96
|
-
const filePath = writtenPathOf(toolInput);
|
|
97
|
-
if (!filePath.replace(/\\/g, '/').endsWith('package.json')) return;
|
|
98
|
-
|
|
99
|
-
const content = writtenContentOf(toolInput);
|
|
100
|
-
if (!content) return;
|
|
101
|
-
|
|
102
|
-
const dependencyNames = dependencyNamesIn(content);
|
|
103
|
-
if (!dependencyNames || dependencyNames.length === 0) return;
|
|
104
|
-
|
|
105
|
-
const skillDirectories = listSkillDirectories(
|
|
106
|
-
join(process.cwd(), parameters.projectSkillsDir),
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
const unmatched = dependencyNames.filter((name) =>
|
|
110
|
-
needsSkillReview(name, parameters, skillDirectories),
|
|
111
|
-
);
|
|
112
|
-
if (unmatched.length === 0) return;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
GATE_ID,
|
|
116
|
-
`New dependencies without a matching skill in ${parameters.projectSkillsDir}: ${unmatched.join(', ')}.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
//
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
runGate,
|
|
5
|
+
deny,
|
|
6
|
+
toolInGroups,
|
|
7
|
+
writtenPathOf,
|
|
8
|
+
writtenContentOf,
|
|
9
|
+
} from '../../lib/hook-io.mjs';
|
|
10
|
+
|
|
11
|
+
const GATE_ID = 'dependency-skills';
|
|
12
|
+
const CONFIG_KEY = 'requireSkillForNewDependency';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_DEPS_WITHOUT_OWN_API = [
|
|
15
|
+
'clsx',
|
|
16
|
+
'classnames',
|
|
17
|
+
'lodash.debounce',
|
|
18
|
+
'lodash.throttle',
|
|
19
|
+
'lodash.merge',
|
|
20
|
+
'tslib',
|
|
21
|
+
'core-js',
|
|
22
|
+
'regenerator-runtime',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function normalize(name) {
|
|
26
|
+
return name.replace(/^@/, '').replace(/\//g, '-').toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isExempt(dependencyName, dependenciesWithoutOwnApi) {
|
|
30
|
+
if (dependencyName.startsWith('@types/')) return true;
|
|
31
|
+
return dependenciesWithoutOwnApi.includes(dependencyName);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function listSkillDirectories(skillsDirectoryPath) {
|
|
35
|
+
try {
|
|
36
|
+
return readdirSync(skillsDirectoryPath, { withFileTypes: true })
|
|
37
|
+
.filter((entry) => entry.isDirectory())
|
|
38
|
+
.map((entry) => entry.name);
|
|
39
|
+
} catch {
|
|
40
|
+
// No skills directory in this project — nothing to cross-check against.
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A skill covers a dependency when its normalized name equals the dependency's, or contains
|
|
46
|
+
// it as a whole `-`-segment (a `stripe-payments` skill covers `stripe`). The old check was
|
|
47
|
+
// bidirectional substring, so a skill dir `rip` "matched" `stripe` (rip ⊂ st-rip-e) — a false
|
|
48
|
+
// exemption that silenced the warning for an unreviewed dependency.
|
|
49
|
+
function skillNameMatches(normalizedDependency, skillDirectoryName) {
|
|
50
|
+
const normalizedSkill = normalize(skillDirectoryName);
|
|
51
|
+
if (normalizedSkill === normalizedDependency) return true;
|
|
52
|
+
return normalizedSkill.split('-').includes(normalizedDependency);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hasMatchingSkill(dependencyName, skillDirectories) {
|
|
56
|
+
const normalizedDependency = normalize(dependencyName);
|
|
57
|
+
return skillDirectories.some((skillDirectoryName) =>
|
|
58
|
+
skillNameMatches(normalizedDependency, skillDirectoryName),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function needsSkillReview(dependencyName, parameters, skillDirectories) {
|
|
63
|
+
if (isExempt(dependencyName, parameters.depsWithoutOwnApi)) return false;
|
|
64
|
+
return !hasMatchingSkill(dependencyName, skillDirectories);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Parsed `package.json` dependency names, or null when it cannot be read yet. */
|
|
68
|
+
function dependencyNamesIn(content) {
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(content);
|
|
72
|
+
} catch {
|
|
73
|
+
// Mid-edit or partially written package.json — nothing reliable to check yet.
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const dependencies = {
|
|
77
|
+
...(parsed.dependencies ?? {}),
|
|
78
|
+
...(parsed.devDependencies ?? {}),
|
|
79
|
+
};
|
|
80
|
+
return Object.keys(dependencies);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
runGate(
|
|
84
|
+
{
|
|
85
|
+
id: GATE_ID,
|
|
86
|
+
configKey: CONFIG_KEY,
|
|
87
|
+
enabledByDefault: true,
|
|
88
|
+
defaultParams: {
|
|
89
|
+
depsWithoutOwnApi: DEFAULT_DEPS_WITHOUT_OWN_API,
|
|
90
|
+
projectSkillsDir: '.claude/skills',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
({ toolName, toolInput, parameters }) => {
|
|
94
|
+
if (!toolInGroups(toolName, ['write'])) return;
|
|
95
|
+
|
|
96
|
+
const filePath = writtenPathOf(toolInput);
|
|
97
|
+
if (!filePath.replace(/\\/g, '/').endsWith('package.json')) return;
|
|
98
|
+
|
|
99
|
+
const content = writtenContentOf(toolInput);
|
|
100
|
+
if (!content) return;
|
|
101
|
+
|
|
102
|
+
const dependencyNames = dependencyNamesIn(content);
|
|
103
|
+
if (!dependencyNames || dependencyNames.length === 0) return;
|
|
104
|
+
|
|
105
|
+
const skillDirectories = listSkillDirectories(
|
|
106
|
+
join(process.cwd(), parameters.projectSkillsDir),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const unmatched = dependencyNames.filter((name) =>
|
|
110
|
+
needsSkillReview(name, parameters, skillDirectories),
|
|
111
|
+
);
|
|
112
|
+
if (unmatched.length === 0) return;
|
|
113
|
+
|
|
114
|
+
deny(
|
|
115
|
+
GATE_ID,
|
|
116
|
+
`New dependencies without a matching skill in ${parameters.projectSkillsDir}: ${unmatched.join(', ')}. ` +
|
|
117
|
+
'Add a skill documenting how to use each (directory name matching or containing the ' +
|
|
118
|
+
'package name), OR, if a dependency genuinely needs no skill, declare it in ' +
|
|
119
|
+
'`depsWithoutOwnApi` in .ai/config.json — that is the explicit opt-out, edited in the ' +
|
|
120
|
+
'same change. This blocks so an unreviewed dependency is a deliberate choice, not a default.',
|
|
121
|
+
);
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Deliberately simplified vs. the source guard (guard-dependencies-skills.mjs):
|
|
126
|
+
// dropped the hardcoded alias table (stripe -> stripe-payments, etc.) and the
|
|
127
|
+
// .ai/skills-baseline.json freeze mechanism — that belongs to a separate
|
|
128
|
+
// adopt-stack-and-skills.mjs script that does not exist in this repo. This
|
|
129
|
+
// gate only does simple substring matching between dependency name and skill
|
|
130
|
+
// directory name, both normalized.
|
|
@@ -1,79 +1,104 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
'
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
);
|
|
1
|
+
import {
|
|
2
|
+
runGate,
|
|
3
|
+
deny,
|
|
4
|
+
toolInGroups,
|
|
5
|
+
writtenContentOf,
|
|
6
|
+
} from '../../lib/hook-io.mjs';
|
|
7
|
+
|
|
8
|
+
const GATE_ID = 'neutral-spanish';
|
|
9
|
+
const CONFIG_KEY = 'warnNonNeutralSpanish';
|
|
10
|
+
|
|
11
|
+
const MAX_REPORTED_MARKERS = 8;
|
|
12
|
+
|
|
13
|
+
// Escape hatch: when a written file legitimately needs regional text (a literal quote, a
|
|
14
|
+
// test fixture, a captured log, a data sample), placing this marker anywhere in the content
|
|
15
|
+
// tells the gate the regional wording is intentional and lets the write through. A deny (as
|
|
16
|
+
// opposed to the old warn) cannot "advise and let pass", so a legitimate case needs an
|
|
17
|
+
// explicit, greppable opt-out — a marker the author writes on purpose, never one the agent
|
|
18
|
+
// could infer. A project can override it via config (escapeHatch).
|
|
19
|
+
const DEFAULT_ESCAPE_HATCH = 'neutral-spanish:allow';
|
|
20
|
+
|
|
21
|
+
// Data strings, not identifiers — never add these to a cSpell dictionary.
|
|
22
|
+
// neutral-spanish:allow — this list IS the marker data; the gate must not deny its own source.
|
|
23
|
+
// Now that this gate DENIES (not warns), a marker must be unambiguously regional. Tokens that
|
|
24
|
+
// also occur in neutral Spanish were removed: "de una" (matches "de una lista/vez"), "allá"
|
|
25
|
+
// (standard across the whole language) — a false positive here blocks a legitimate write.
|
|
26
|
+
const DEFAULT_REGIONAL_MARKERS = [
|
|
27
|
+
'tenés',
|
|
28
|
+
'podés',
|
|
29
|
+
'querés',
|
|
30
|
+
'sabés',
|
|
31
|
+
'hacés',
|
|
32
|
+
'decís',
|
|
33
|
+
'venís',
|
|
34
|
+
'sos',
|
|
35
|
+
'fijate',
|
|
36
|
+
'mirá',
|
|
37
|
+
'pará',
|
|
38
|
+
'esperá',
|
|
39
|
+
'dale',
|
|
40
|
+
'mandale',
|
|
41
|
+
'contame',
|
|
42
|
+
'fíjate vos',
|
|
43
|
+
'acá',
|
|
44
|
+
'laburo',
|
|
45
|
+
'laburar',
|
|
46
|
+
'quilombo',
|
|
47
|
+
'posta',
|
|
48
|
+
'che',
|
|
49
|
+
'boludo',
|
|
50
|
+
'pibe',
|
|
51
|
+
'guita',
|
|
52
|
+
'al pedo',
|
|
53
|
+
'un toque',
|
|
54
|
+
'capaz que', // neutral-spanish:allow (marker data, not prose)
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function extractText(toolName, toolInput) {
|
|
58
|
+
if (!toolInGroups(toolName, ['write'])) return '';
|
|
59
|
+
return writtenContentOf(toolInput);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
runGate(
|
|
63
|
+
{
|
|
64
|
+
id: GATE_ID,
|
|
65
|
+
configKey: CONFIG_KEY,
|
|
66
|
+
enabledByDefault: true,
|
|
67
|
+
defaultParams: {
|
|
68
|
+
regionalMarkers: DEFAULT_REGIONAL_MARKERS,
|
|
69
|
+
escapeHatch: DEFAULT_ESCAPE_HATCH,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
({ toolName, toolInput, parameters }) => {
|
|
73
|
+
const text = extractText(toolName, toolInput).toLowerCase();
|
|
74
|
+
if (!text) return;
|
|
75
|
+
|
|
76
|
+
// Explicit opt-out for legitimate regional text (quote/fixture/log/data sample).
|
|
77
|
+
const escapeHatch = (
|
|
78
|
+
parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH
|
|
79
|
+
).toLowerCase();
|
|
80
|
+
if (escapeHatch && text.includes(escapeHatch)) return;
|
|
81
|
+
|
|
82
|
+
const hits = [];
|
|
83
|
+
for (const marker of parameters.regionalMarkers) {
|
|
84
|
+
const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
85
|
+
// \p{L} (any Unicode letter) as the non-boundary class, instead of a hand-written
|
|
86
|
+
// accented-character set, so accented word boundaries are handled correctly without
|
|
87
|
+
// spelling out literal accented characters in the source (the project keeps its
|
|
88
|
+
// spell-check dictionary untouched, so no word list may lean on this being a string).
|
|
89
|
+
const pattern = new RegExp(`(^|[^\\p{L}])${escaped}([^\\p{L}]|$)`, 'iu');
|
|
90
|
+
if (pattern.test(text)) hits.push(marker);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (hits.length === 0) return;
|
|
94
|
+
|
|
95
|
+
const unique = [...new Set(hits)].slice(0, MAX_REPORTED_MARKERS);
|
|
96
|
+
deny(
|
|
97
|
+
GATE_ID,
|
|
98
|
+
`Text being written contains regional Spanish markers: ${unique.join(', ')}. ` +
|
|
99
|
+
'Rewrite in neutral Spanish before writing. If the regional wording is intentional ' +
|
|
100
|
+
`(a literal quote, a test fixture, a captured log, a data sample), add the marker ` +
|
|
101
|
+
`"${parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH}" somewhere in the content to allow it.`,
|
|
102
|
+
);
|
|
103
|
+
},
|
|
104
|
+
);
|