@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,123 +1,138 @@
|
|
|
1
|
-
// no-memory-dependency —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// Stage
|
|
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
|
-
function
|
|
55
|
-
return
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
)
|
|
1
|
+
// no-memory-dependency — denies when a delegation prompt leans on the subagent
|
|
2
|
+
// "remembering" something said earlier in conversation instead of carrying the data itself.
|
|
3
|
+
// A fresh subagent has no access to the delegator's conversation history: if the data
|
|
4
|
+
// matters, it must travel IN the prompt, in a file the subagent reads, or in an
|
|
5
|
+
// already-persisted decision. The dependency on unavailable memory is a complete, objective
|
|
6
|
+
// fact once stage 2 has ruled out a real persistence instruction — so this blocks.
|
|
7
|
+
//
|
|
8
|
+
// ── Two stages, same pattern as intent-flow ─────────────────────────────────────────
|
|
9
|
+
// Stage 1 (cheap): does any memory-dependency phrase appear anywhere? If not, allow.
|
|
10
|
+
// Stage 2 (confirmation, only on candidates): strip quoted text, then discard a phrase
|
|
11
|
+
// that has a REAL deterministic persistence INSTRUCTION nearby (not just a noun/verb
|
|
12
|
+
// mentioned in passing) — "no te olvides de guardar la decision en .ai/decision.md"
|
|
13
|
+
// genuinely depends on a file, not on model memory. A persistence noun/verb that merely
|
|
14
|
+
// co-occurs in the window without forming an instruction TO persist THIS remembered
|
|
15
|
+
// thing (e.g. an unrelated file named elsewhere in the same sentence) does not suppress
|
|
16
|
+
// the deny.
|
|
17
|
+
//
|
|
18
|
+
// ── Escape hatch ─────────────────────────────────────────────────────────────────────
|
|
19
|
+
// A memory phrase can be a false positive: "no te olvides de cerrar el server al final"
|
|
20
|
+
// directs the SUBAGENT'S own future action, not data it must recall from the delegator. For
|
|
21
|
+
// that case the author adds the escapeHatch marker to the prompt to state, explicitly, that
|
|
22
|
+
// no cross-conversation memory is actually required.
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
runGate,
|
|
26
|
+
deny,
|
|
27
|
+
toolInGroups,
|
|
28
|
+
delegationPromptOf,
|
|
29
|
+
} from '../../lib/hook-io.mjs';
|
|
30
|
+
import { PERSISTENCE_VERB } from '../../lib/signals.mjs';
|
|
31
|
+
|
|
32
|
+
const GATE_ID = 'no-memory-dependency';
|
|
33
|
+
const CONFIG_KEY = 'warnMemoryDependencyInBrief';
|
|
34
|
+
|
|
35
|
+
const DEFAULT_ESCAPE_HATCH = 'memory-not-needed';
|
|
36
|
+
|
|
37
|
+
const DEFAULT_MEMORY_DEPENDENCY_PATTERNS = [
|
|
38
|
+
'acordate de|acu[eé]rdate de',
|
|
39
|
+
'no olvides|no te olvides',
|
|
40
|
+
'record[aá] que|ten[eé] en cuenta que',
|
|
41
|
+
'como te dije|como ya te dije|ya te dije|te lo dije antes|ya te ped[ií]',
|
|
42
|
+
"remember to|don'?t forget|keep in mind",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// A real persistence INSTRUCTION requires an imperative persistence VERB (an actual
|
|
46
|
+
// directive to save/persist/write THIS remembered thing), not merely a persistence-
|
|
47
|
+
// related NOUN mentioned somewhere nearby (a stray "incident.md" in the same sentence
|
|
48
|
+
// names a file without instructing anything be saved to it). Centralized in
|
|
49
|
+
// lib/signals.mjs (ES+EN) so this class of signal is not duplicated per gate.
|
|
50
|
+
const PERSISTENCE_VERB_PATTERN = PERSISTENCE_VERB;
|
|
51
|
+
|
|
52
|
+
const PERSISTENCE_WINDOW = 80;
|
|
53
|
+
|
|
54
|
+
function withUnicodeWordBoundary(alternatives) {
|
|
55
|
+
return new RegExp(
|
|
56
|
+
`(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
|
|
57
|
+
'iu',
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stripQuoted(text) {
|
|
62
|
+
return text
|
|
63
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
64
|
+
.replace(/"[^"\n]{0,300}"/g, ' ')
|
|
65
|
+
.replace(/'[^'\n]{0,300}'/g, ' ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function allMatches(pattern, text) {
|
|
69
|
+
const flags = pattern.flags.includes('g')
|
|
70
|
+
? pattern.flags
|
|
71
|
+
: `${pattern.flags}g`;
|
|
72
|
+
return [...text.matchAll(new RegExp(pattern.source, flags))];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True only when the window around a memory phrase carries a real persistence
|
|
76
|
+
* INSTRUCTION — an imperative persistence verb — not merely a persistence-related noun
|
|
77
|
+
* mentioned in passing. A noun alone ("el reporte esta en incident.md") names a file
|
|
78
|
+
* without instructing anything be saved to it, so it must NOT suppress the warning. */
|
|
79
|
+
function hasPersistenceInstructionNearby(text, match) {
|
|
80
|
+
const from = Math.max(0, match.index - PERSISTENCE_WINDOW);
|
|
81
|
+
const to = Math.min(
|
|
82
|
+
text.length,
|
|
83
|
+
match.index + match[0].length + PERSISTENCE_WINDOW,
|
|
84
|
+
);
|
|
85
|
+
const window = text.slice(from, to);
|
|
86
|
+
return PERSISTENCE_VERB_PATTERN.test(window);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function unresolvedMemoryPhrases(prompt, memoryPattern) {
|
|
90
|
+
if (!memoryPattern.test(prompt)) return []; // stage 1: no candidate, allow cheap
|
|
91
|
+
|
|
92
|
+
const text = stripQuoted(prompt); // stage 2(a)
|
|
93
|
+
const matches = allMatches(memoryPattern, text);
|
|
94
|
+
|
|
95
|
+
return matches
|
|
96
|
+
.filter((match) => !hasPersistenceInstructionNearby(text, match))
|
|
97
|
+
.map((match) => match[0].trim());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
runGate(
|
|
101
|
+
{
|
|
102
|
+
id: GATE_ID,
|
|
103
|
+
configKey: CONFIG_KEY,
|
|
104
|
+
enabledByDefault: false,
|
|
105
|
+
defaultParams: {
|
|
106
|
+
memoryDependencyPatterns: DEFAULT_MEMORY_DEPENDENCY_PATTERNS,
|
|
107
|
+
escapeHatch: DEFAULT_ESCAPE_HATCH,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
({ toolName, toolInput, parameters }) => {
|
|
111
|
+
if (!toolInGroups(toolName, ['delegation'])) return;
|
|
112
|
+
|
|
113
|
+
const prompt = delegationPromptOf(toolInput);
|
|
114
|
+
if (!prompt.trim()) return;
|
|
115
|
+
|
|
116
|
+
// Explicit opt-out: the author states no cross-conversation memory is actually required.
|
|
117
|
+
const escapeHatch = (
|
|
118
|
+
parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH
|
|
119
|
+
).toLowerCase();
|
|
120
|
+
if (escapeHatch && prompt.toLowerCase().includes(escapeHatch)) return;
|
|
121
|
+
|
|
122
|
+
const memoryPattern = withUnicodeWordBoundary(
|
|
123
|
+
(parameters.memoryDependencyPatterns ?? []).join('|'),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const phrases = unresolvedMemoryPhrases(prompt, memoryPattern);
|
|
127
|
+
if (phrases.length === 0) return;
|
|
128
|
+
|
|
129
|
+
const quotedPhrases = phrases.map((phrase) => `"${phrase}"`).join(', ');
|
|
130
|
+
deny(
|
|
131
|
+
GATE_ID,
|
|
132
|
+
`This delegation depends on the subagent remembering something (${quotedPhrases}), but a ` +
|
|
133
|
+
'fresh subagent has none of this conversation. Put the data IN the prompt, in a file it ' +
|
|
134
|
+
'reads, a flag, or an already-persisted decision. If the phrase directs the subagent’s ' +
|
|
135
|
+
`own future action and needs no recalled data, add the marker "${parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH}" to the prompt.`,
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
);
|
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
// dotfiles are a different, well-known convention this gate does not police.
|
|
15
15
|
|
|
16
16
|
import { basename, isAbsolute, resolve, sep } from 'node:path';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
runGate,
|
|
19
|
+
deny,
|
|
20
|
+
toolInGroups,
|
|
21
|
+
writtenPathOf,
|
|
22
|
+
shellWrittenPaths,
|
|
23
|
+
} from '../../lib/hook-io.mjs';
|
|
18
24
|
|
|
19
25
|
const GATE_ID = 'root-whitelist';
|
|
20
26
|
const CONFIG_KEY = 'blockPathsOutsideRootWhitelist';
|
|
@@ -49,6 +55,40 @@ const DEFAULT_ROOT_FOLDERS_WHITELIST = [
|
|
|
49
55
|
'plugins',
|
|
50
56
|
];
|
|
51
57
|
|
|
58
|
+
// The whitelist verdict for one target path. Returns a deny reason string when the path is a
|
|
59
|
+
// non-whitelisted new thing at the project root, or null when it is allowed (outside the root,
|
|
60
|
+
// a dotfile, or whitelisted). Shared by the write-tool path and every shell-created path, so a
|
|
61
|
+
// `printf x > basura.txt` is judged by the same rule as a Write to basura.txt.
|
|
62
|
+
function rootWhitelistViolation(target, filesWhitelist, foldersWhitelist) {
|
|
63
|
+
if (!target) return null;
|
|
64
|
+
|
|
65
|
+
// Some tools send a path relative to cwd rather than absolute. resolve() leaves an
|
|
66
|
+
// already-absolute path merely normalized ('..' collapsed, separators fixed), and resolves
|
|
67
|
+
// a relative one against process.cwd() — so either shape lands on the same absolute path.
|
|
68
|
+
const normalized = isAbsolute(target)
|
|
69
|
+
? resolve(target)
|
|
70
|
+
: resolve(process.cwd(), target);
|
|
71
|
+
const projectRoot = process.cwd() + sep;
|
|
72
|
+
|
|
73
|
+
// A path outside the project (scratchpad, temp, another repo) is not at its root — not this
|
|
74
|
+
// rule's business. Judged AFTER resolving, so a relative path is judged by where it lands.
|
|
75
|
+
if (!normalized.startsWith(projectRoot)) return null;
|
|
76
|
+
|
|
77
|
+
const relativePath = normalized.slice(projectRoot.length);
|
|
78
|
+
|
|
79
|
+
if (!relativePath.includes(sep)) {
|
|
80
|
+
const fileName = basename(relativePath);
|
|
81
|
+
if (fileName.startsWith('.') || filesWhitelist.has(fileName.toLowerCase())) return null;
|
|
82
|
+
return `'${fileName}' at the project root is not on the whitelist (${[...filesWhitelist].join(', ')}).`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const topDirectory = relativePath.split(sep)[0];
|
|
86
|
+
if (topDirectory.startsWith('.') || foldersWhitelist.has(topDirectory.toLowerCase())) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return `Folder '${topDirectory}' at the project root is not on the whitelist (${[...foldersWhitelist].join(', ')}).`;
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
runGate(
|
|
53
93
|
{
|
|
54
94
|
id: GATE_ID,
|
|
@@ -60,30 +100,12 @@ runGate(
|
|
|
60
100
|
},
|
|
61
101
|
},
|
|
62
102
|
({ toolName, toolInput, parameters }) => {
|
|
63
|
-
|
|
103
|
+
const isWrite = toolInGroups(toolName, ['write']);
|
|
104
|
+
const isShell = toolInGroups(toolName, ['shell']);
|
|
105
|
+
if (!isWrite && !isShell) return;
|
|
64
106
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// Some tools send a path relative to cwd rather than absolute. resolve() leaves an
|
|
69
|
-
// already-absolute path merely normalized ('..' collapsed, separators fixed), and
|
|
70
|
-
// resolves a relative one against process.cwd() — so either shape lands on the same
|
|
71
|
-
// absolute path the root check below expects.
|
|
72
|
-
const normalized = isAbsolute(target)
|
|
73
|
-
? resolve(target)
|
|
74
|
-
: resolve(process.cwd(), target);
|
|
75
|
-
const projectRoot = process.cwd() + sep;
|
|
76
|
-
|
|
77
|
-
// The whitelist describes the project's ROOT. A file outside the project (session
|
|
78
|
-
// scratchpad, system temp, another repo) is not at its root, and this rule does not
|
|
79
|
-
// govern it: letting it through is correct, not an exception. This check runs AFTER
|
|
80
|
-
// resolving relative paths, so a relative path is judged by where it actually lands.
|
|
81
|
-
if (!normalized.startsWith(projectRoot)) return;
|
|
82
|
-
|
|
83
|
-
const relativePath = normalized.slice(projectRoot.length);
|
|
84
|
-
// Windows filesystems are case-insensitive: 'Package.json' and 'package.json' are the
|
|
85
|
-
// same file. Comparing lowercase on both sides avoids denying a whitelisted name that
|
|
86
|
-
// merely differs in case (a false positive, not a real gap).
|
|
107
|
+
// Windows filesystems are case-insensitive: 'Package.json' and 'package.json' are the same
|
|
108
|
+
// file. Comparing lowercase on both sides avoids a false positive on a case difference.
|
|
87
109
|
const filesWhitelist = new Set(
|
|
88
110
|
(parameters.rootFilesWhitelist ?? []).map((name) => name.toLowerCase()),
|
|
89
111
|
);
|
|
@@ -91,26 +113,19 @@ runGate(
|
|
|
91
113
|
(parameters.rootFoldersWhitelist ?? []).map((name) => name.toLowerCase()),
|
|
92
114
|
);
|
|
93
115
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
}
|
|
116
|
+
// The paths to judge: a write tool's target, OR every path a shell command creates via a
|
|
117
|
+
// redirection / touch / tee / cp / mv. The latter closes the hole where `> basura.txt`
|
|
118
|
+
// created a root file that a Write to the same name would have been denied. This does not
|
|
119
|
+
// resolve a dynamically built path (`> "$f"`) — that honest limitation is the boundary of
|
|
120
|
+
// a regex, and is why the write-tool path (which carries a concrete file_path) stays the
|
|
121
|
+
// primary, most reliable surface.
|
|
122
|
+
const targets = isShell
|
|
123
|
+
? shellWrittenPaths(String(toolInput?.command ?? toolInput?.CommandLine ?? ''))
|
|
124
|
+
: [writtenPathOf(toolInput)];
|
|
104
125
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
)
|
|
110
|
-
return;
|
|
111
|
-
deny(
|
|
112
|
-
GATE_ID,
|
|
113
|
-
`Folder '${topDirectory}' at the project root is not on the whitelist (${[...foldersWhitelist].join(', ')}).`,
|
|
114
|
-
);
|
|
126
|
+
for (const target of targets) {
|
|
127
|
+
const reason = rootWhitelistViolation(target, filesWhitelist, foldersWhitelist);
|
|
128
|
+
if (reason) deny(GATE_ID, reason);
|
|
129
|
+
}
|
|
115
130
|
},
|
|
116
131
|
);
|
|
@@ -1,101 +1,124 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
3
|
-
import { basename, dirname, relative } from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
const
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, relative } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
runGate,
|
|
6
|
+
deny,
|
|
7
|
+
toolInGroups,
|
|
8
|
+
writtenPathOf,
|
|
9
|
+
writtenContentOf,
|
|
10
|
+
} from '../../lib/hook-io.mjs';
|
|
11
|
+
|
|
12
|
+
const GATE_ID = 'test-after-implementation';
|
|
13
|
+
const CONFIG_KEY = 'warnTestWrittenAfterImplementation';
|
|
14
|
+
|
|
15
|
+
// Escape hatch: a regression test written AFTER reproducing a bug is a legitimate
|
|
16
|
+
// test-after-implementation case (the project's own rule: reproduce the bug, then write the
|
|
17
|
+
// test that pins it). A deny cannot advise-and-pass, so that legitimate case needs an
|
|
18
|
+
// explicit opt-out — this marker anywhere in the test file's content lets it through.
|
|
19
|
+
const DEFAULT_ESCAPE_HATCH = 'test-after-impl:allow';
|
|
20
|
+
|
|
21
|
+
const DEFAULT_IMPLEMENTATION_EXTENSIONS = [
|
|
22
|
+
'.ts',
|
|
23
|
+
'.tsx',
|
|
24
|
+
'.js',
|
|
25
|
+
'.jsx',
|
|
26
|
+
'.mjs',
|
|
27
|
+
'.vue',
|
|
28
|
+
];
|
|
29
|
+
const TEST_FILE_PATTERN = /\.(spec|test)\.[cm]?[jt]sx?$/i;
|
|
30
|
+
// `git status --porcelain` prefixes each line with a 2-character status code plus a space.
|
|
31
|
+
const PORCELAIN_STATUS_PREFIX_LENGTH = 3;
|
|
32
|
+
|
|
33
|
+
function baseNameWithoutTestSuffix(fileName) {
|
|
34
|
+
return fileName.replace(TEST_FILE_PATTERN, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function gitStatusPorcelain(cwd) {
|
|
38
|
+
try {
|
|
39
|
+
const output = execFileSync('git', ['status', '--porcelain'], {
|
|
40
|
+
cwd,
|
|
41
|
+
timeout: 5000,
|
|
42
|
+
}).toString();
|
|
43
|
+
return output
|
|
44
|
+
.split('\n')
|
|
45
|
+
.map((line) => line.trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
} catch {
|
|
48
|
+
// Not a git repo, or git unavailable — nothing to cross-check against.
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
runGate(
|
|
54
|
+
{
|
|
55
|
+
id: GATE_ID,
|
|
56
|
+
configKey: CONFIG_KEY,
|
|
57
|
+
enabledByDefault: false,
|
|
58
|
+
defaultParams: {
|
|
59
|
+
implementationExtensions: DEFAULT_IMPLEMENTATION_EXTENSIONS,
|
|
60
|
+
escapeHatch: DEFAULT_ESCAPE_HATCH,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
({ toolName, toolInput, parameters }) => {
|
|
64
|
+
if (!toolInGroups(toolName, ['write'])) return;
|
|
65
|
+
|
|
66
|
+
const filePath = writtenPathOf(toolInput);
|
|
67
|
+
if (!TEST_FILE_PATTERN.test(filePath)) return;
|
|
68
|
+
if (existsSync(filePath)) return; // only new test file creation, not edits
|
|
69
|
+
|
|
70
|
+
// Explicit opt-out for a legitimate regression test (bug reproduced first, then pinned).
|
|
71
|
+
const escapeHatch = parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH;
|
|
72
|
+
if (escapeHatch && writtenContentOf(toolInput).includes(escapeHatch))
|
|
73
|
+
return;
|
|
74
|
+
|
|
75
|
+
const cwd = process.cwd();
|
|
76
|
+
const statusLines = gitStatusPorcelain(cwd);
|
|
77
|
+
if (statusLines.length === 0) return;
|
|
78
|
+
|
|
79
|
+
const testFileName = basename(filePath);
|
|
80
|
+
const stem = baseNameWithoutTestSuffix(testFileName);
|
|
81
|
+
// `git status --porcelain` reports paths relative to `cwd`; the test's own
|
|
82
|
+
// directory (relative to `cwd`) is the fair comparison, not its absolute path.
|
|
83
|
+
// `relative()` returns '' for the repo root itself, while `dirname()` on a
|
|
84
|
+
// bare file name returns '.' — normalize both to '.' so root-level files compare equal.
|
|
85
|
+
const testDirectoryRelativeToRepo = relative(cwd, dirname(filePath)) || '.';
|
|
86
|
+
|
|
87
|
+
const pairedImplementationChanged = statusLines.some((line) => {
|
|
88
|
+
const changedPath = line.slice(PORCELAIN_STATUS_PREFIX_LENGTH).trim();
|
|
89
|
+
if (
|
|
90
|
+
!parameters.implementationExtensions.some((extension) =>
|
|
91
|
+
changedPath.endsWith(extension),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return false;
|
|
95
|
+
if (TEST_FILE_PATTERN.test(changedPath)) return false;
|
|
96
|
+
const lastDot = changedPath.lastIndexOf('.');
|
|
97
|
+
const changedBase = basename(
|
|
98
|
+
changedPath,
|
|
99
|
+
lastDot >= 0 ? changedPath.slice(lastDot) : '',
|
|
100
|
+
);
|
|
101
|
+
return (
|
|
102
|
+
changedBase === stem &&
|
|
103
|
+
(dirname(changedPath) || '.') === testDirectoryRelativeToRepo
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (!pairedImplementationChanged) return;
|
|
108
|
+
|
|
109
|
+
deny(
|
|
110
|
+
GATE_ID,
|
|
111
|
+
`Creating test ${testFileName} after its paired implementation was already modified ` +
|
|
112
|
+
'(uncommitted). Write the test before or alongside the implementation, not after — a ' +
|
|
113
|
+
'test that passes the first time it runs proves nothing about the change. If this is a ' +
|
|
114
|
+
'regression test for a bug you already reproduced (reproduce first, then pin it), add ' +
|
|
115
|
+
`the marker "${parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH}" in the test content to allow it.`,
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Note: this restores the source guard's (guard-test-despues-de-implementar.mjs) original
|
|
121
|
+
// deny severity. It was warn during the config-key migration; the user's directive is that a
|
|
122
|
+
// gate stays warn only when the defect is genuinely invisible to a hook. Here the defect is a
|
|
123
|
+
// complete, git-verifiable fact, so it denies — with an escape hatch for the legitimate
|
|
124
|
+
// regression-test case. The config key still reads warn* for backward compatibility.
|