@devrik-tools/claude-gates 0.7.2 → 0.8.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 +69 -9
- package/README.md +68 -7
- package/cli/doctor.mjs +132 -0
- package/cli/evidence.mjs +78 -0
- package/cli/hooks-manifest.mjs +89 -0
- package/cli/index.mjs +124 -6
- package/cli/init.mjs +97 -3
- package/cli/log.mjs +70 -0
- package/cli/materialize.mjs +36 -2
- package/cli/registry.mjs +11 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +114 -22
- package/cli/task.mjs +36 -8
- package/cli/toggle.mjs +125 -0
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/doctor.mjs +47 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
- package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +98 -212
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
- package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
- package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
- package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
- package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
- package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
- package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
- package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
- package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
- package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
- package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
- package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
- package/plugins/gates/hooks/hooks.json +112 -42
- package/plugins/gates/hooks/lib/config.mjs +6 -3
- package/plugins/gates/hooks/lib/delegation.mjs +352 -0
- package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
- package/plugins/gates/hooks/lib/git.mjs +262 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +448 -83
- package/plugins/gates/hooks/lib/session-state.mjs +150 -0
- package/plugins/gates/hooks/lib/signals.mjs +18 -13
- package/plugins/gates/hooks/lib/testing.mjs +137 -0
- package/plugins/gates/hooks/lib/tools.mjs +144 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
- package/registry.json +204 -36
|
@@ -1,98 +1,116 @@
|
|
|
1
|
-
// no-coauthor — denies a `git commit`
|
|
2
|
-
// (Co-Authored-By, "Generated with
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// block-remote-publish denies pushes; neither inspects the commit MESSAGE for attribution.
|
|
8
|
-
// lint-commit runs the linter on a commit but never reads the message text. This is the first
|
|
9
|
-
// gate that reads what a commit says, not just that it is a commit.
|
|
10
|
-
//
|
|
11
|
-
// ── What a project can configure (params) ───────────────────────────────────────────
|
|
12
|
-
// attributionPatterns regex sources (matched case-insensitively) of trailers/phrases
|
|
13
|
-
// that must not appear in a commit message. Replaces the built-in
|
|
14
|
-
// list wholesale, so a project that wants a different policy states
|
|
15
|
-
// its full list.
|
|
16
|
-
// escapeHatch a substring the author can put in the command to bypass this gate
|
|
17
|
-
// for one legitimate commit (e.g. importing external history that
|
|
18
|
-
// genuinely carries a co-author). Default '[allow-coauthor]'.
|
|
19
|
-
//
|
|
20
|
-
// ── How it detects a commit and reads its message ────────────────────────────────────
|
|
21
|
-
// Reuses lint-commit's git-global-option normalization so `git -C /repo -c x=y commit` is
|
|
22
|
-
// recognized as a commit. It then scans the WHOLE command text (which contains the -m value
|
|
23
|
-
// or the heredoc body) for any attribution pattern — this over-includes on purpose (a match
|
|
24
|
-
// inside an unrelated string is a rare false block the author clears with the escape hatch)
|
|
25
|
-
// rather than under-includes (a real trailer slipping through in a shape we did not parse).
|
|
26
|
-
//
|
|
27
|
-
// ── Fail-safe shape ──────────────────────────────────────────────────────────────────
|
|
28
|
-
// Not a commit: allow (silent). A commit with no attribution pattern: allow. A commit whose
|
|
29
|
-
// text matches a pattern and does NOT contain the escape hatch: deny, naming the match.
|
|
1
|
+
// no-coauthor — denies a `git commit` whose MESSAGE carries an AI/agent attribution
|
|
2
|
+
// (Co-Authored-By, Claude-Session, "Generated with Claude"), and a delegation prompt that
|
|
3
|
+
// orders one. The message is what the commit will record: -m values, a heredoc body,
|
|
4
|
+
// --trailer values and a -F file read from the project. Only a command segment that RUNS git
|
|
5
|
+
// commit counts (a `git log | grep Co-Authored-By` is not one). Deliberate limit: a message
|
|
6
|
+
// built from a substitution (`-m "$(cat msg)"`) cannot be read without running it.
|
|
30
7
|
|
|
31
|
-
import {
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import { projectRootOf } from '../../lib/config.mjs';
|
|
11
|
+
import { hasRealCommandIntent } from '../../lib/delegation.mjs';
|
|
12
|
+
import {
|
|
13
|
+
commitMessageOf,
|
|
14
|
+
isDryRunCommit,
|
|
15
|
+
isGitCommit,
|
|
16
|
+
normalizeGitCommand,
|
|
17
|
+
} from '../../lib/git.mjs';
|
|
18
|
+
import {
|
|
19
|
+
compileRegexList,
|
|
20
|
+
delegationPromptOf,
|
|
21
|
+
deny,
|
|
22
|
+
runGate,
|
|
23
|
+
shellCommandOf,
|
|
24
|
+
toolInGroups,
|
|
25
|
+
} from '../../lib/hook-io.mjs';
|
|
32
26
|
|
|
33
27
|
const GATE_ID = 'no-coauthor';
|
|
34
28
|
const CONFIG_KEY = 'blockCoauthorTrailers';
|
|
35
29
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// Attribution trailers/phrases an AI agent tends to append. Sources, compiled case-insensitively.
|
|
30
|
+
// Narrow on purpose: "generated by vitest" or "usage with Claude Code" in a subject line is
|
|
31
|
+
// not an attribution; a trailer or a "generated with/by <an agent>" phrase is.
|
|
39
32
|
const DEFAULT_ATTRIBUTION_PATTERNS = [
|
|
40
|
-
String.raw`co-authored-by
|
|
41
|
-
String.raw`generated with`,
|
|
42
|
-
String.raw`generated by`,
|
|
43
|
-
String.raw`🤖 generated`,
|
|
33
|
+
String.raw`co-authored-by\s*[:=]`,
|
|
44
34
|
String.raw`claude-session:`,
|
|
45
35
|
String.raw`assisted-by:`,
|
|
46
36
|
String.raw`authored-with:`,
|
|
47
|
-
String.raw`with claude
|
|
37
|
+
String.raw`generated (?:with|by) (?:claude|ai|copilot|codex|cursor|gemini|chatgpt|an? (?:ai|llm|assistant)|a (?:bot|model))\b`,
|
|
38
|
+
String.raw`🤖 generated`,
|
|
48
39
|
];
|
|
49
40
|
|
|
50
41
|
const DEFAULT_ESCAPE_HATCH = '[allow-coauthor]';
|
|
51
42
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
String.raw`\bgit\s+(?:${GIT_OPTION_WITH_VALUE}|${GIT_FLAG_OPTION})\s+`,
|
|
58
|
-
'i',
|
|
59
|
-
);
|
|
60
|
-
const GIT_COMMIT_PATTERN = /\bgit\s+commit\b/i;
|
|
43
|
+
// ── The commit segment ──────────────────────────────────────────────────────────────
|
|
44
|
+
const SEGMENT_SEPARATOR = /;|&&|\|\||\||\n/;
|
|
45
|
+
const ARGUMENT_PATTERN = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
46
|
+
const GIT_BINARY_TOKEN = /(?:^|[\\/])git(?:\.exe)?$/i;
|
|
47
|
+
const WRAPPER_TOKEN = /^(?:\w+=\S*|command|sudo|env)$/i;
|
|
61
48
|
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} while (normalized !== previous);
|
|
69
|
-
return normalized;
|
|
49
|
+
function runsGit(segment) {
|
|
50
|
+
const tokens = [];
|
|
51
|
+
for (const match of segment.matchAll(ARGUMENT_PATTERN))
|
|
52
|
+
tokens.push(match[1] ?? match[2] ?? match[3]);
|
|
53
|
+
while (tokens.length > 0 && WRAPPER_TOKEN.test(tokens[0])) tokens.shift();
|
|
54
|
+
return tokens.length > 0 && GIT_BINARY_TOKEN.test(tokens[0]);
|
|
70
55
|
}
|
|
71
56
|
|
|
72
|
-
|
|
73
|
-
|
|
57
|
+
// A heredoc body spans lines, so the command is split only after the segment that opens it
|
|
58
|
+
// has been recognized on its first line.
|
|
59
|
+
function runsRealCommit(command) {
|
|
60
|
+
return String(command)
|
|
61
|
+
.split(SEGMENT_SEPARATOR)
|
|
62
|
+
.some((segment) => {
|
|
63
|
+
if (!runsGit(segment)) return false;
|
|
64
|
+
const normalized = normalizeGitCommand(segment);
|
|
65
|
+
return isGitCommit(normalized) && !isDryRunCommit(normalized);
|
|
66
|
+
});
|
|
74
67
|
}
|
|
75
68
|
|
|
76
|
-
function
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
/** The first attribution pattern the command text matches, or null when none does. */
|
|
81
|
-
function matchedAttribution(command, attributionPatterns) {
|
|
82
|
-
for (const source of attributionPatterns) {
|
|
83
|
-
let pattern;
|
|
69
|
+
function fileReader(root) {
|
|
70
|
+
return (path) => {
|
|
71
|
+
const resolved = resolve(root, path);
|
|
84
72
|
try {
|
|
85
|
-
|
|
73
|
+
return existsSync(resolved) ? readFileSync(resolved, 'utf8') : '';
|
|
86
74
|
} catch {
|
|
87
|
-
|
|
88
|
-
// the list still protects.
|
|
89
|
-
continue;
|
|
75
|
+
return '';
|
|
90
76
|
}
|
|
91
|
-
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function firstMatch(text, patterns) {
|
|
81
|
+
for (const pattern of patterns) {
|
|
82
|
+
const match = pattern.exec(text);
|
|
83
|
+
if (match) return match[0];
|
|
92
84
|
}
|
|
93
85
|
return null;
|
|
94
86
|
}
|
|
95
87
|
|
|
88
|
+
function denyAttribution(matched, escapeHatch) {
|
|
89
|
+
deny(
|
|
90
|
+
CONFIG_KEY,
|
|
91
|
+
`This commit message carries an AI/agent attribution ("${matched}"). Remove it — no ` +
|
|
92
|
+
'Co-Authored-By, no "Generated with", no session trailer. If this commit legitimately ' +
|
|
93
|
+
`must keep a co-author (e.g. importing external history), add "${escapeHatch}" to the ` +
|
|
94
|
+
`command to allow it once, or set attributionPatterns under ${CONFIG_KEY} in .ai/config.json.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function checkDelegationPrompt(prompt, patterns, escapeHatch) {
|
|
99
|
+
if (escapeHatch && prompt.includes(escapeHatch)) return;
|
|
100
|
+
const ordered = patterns.find((pattern) =>
|
|
101
|
+
hasRealCommandIntent(prompt, pattern),
|
|
102
|
+
);
|
|
103
|
+
if (ordered) denyAttribution(ordered.exec(prompt)[0], escapeHatch);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function checkShellCommand(command, patterns, escapeHatch, root) {
|
|
107
|
+
if (!runsRealCommit(command)) return;
|
|
108
|
+
if (escapeHatch && command.includes(escapeHatch)) return;
|
|
109
|
+
const message = commitMessageOf(command, { readFile: fileReader(root) });
|
|
110
|
+
const matched = firstMatch(message, patterns);
|
|
111
|
+
if (matched) denyAttribution(matched, escapeHatch);
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
runGate(
|
|
97
115
|
{
|
|
98
116
|
id: GATE_ID,
|
|
@@ -103,25 +121,27 @@ runGate(
|
|
|
103
121
|
escapeHatch: DEFAULT_ESCAPE_HATCH,
|
|
104
122
|
},
|
|
105
123
|
},
|
|
106
|
-
({ toolName, toolInput, parameters }) => {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const command = commandTextFrom(toolInput);
|
|
110
|
-
if (!isGitCommit(command)) return;
|
|
124
|
+
({ toolName, toolInput, parameters, cwd }) => {
|
|
125
|
+
const isShell = toolInGroups(toolName, ['shell']);
|
|
126
|
+
if (!isShell && !toolInGroups(toolName, ['delegation'])) return;
|
|
111
127
|
|
|
112
|
-
const escapeHatch = parameters.escapeHatch ??
|
|
113
|
-
|
|
128
|
+
const escapeHatch = String(parameters.escapeHatch ?? '');
|
|
129
|
+
const { patterns } = compileRegexList(parameters.attributionPatterns);
|
|
130
|
+
if (patterns.length === 0) return;
|
|
114
131
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
132
|
+
if (isShell) {
|
|
133
|
+
checkShellCommand(
|
|
134
|
+
shellCommandOf(toolInput),
|
|
135
|
+
patterns,
|
|
136
|
+
escapeHatch,
|
|
137
|
+
projectRootOf(cwd) ?? cwd,
|
|
138
|
+
);
|
|
139
|
+
} else {
|
|
140
|
+
checkDelegationPrompt(
|
|
141
|
+
delegationPromptOf(toolInput),
|
|
142
|
+
patterns,
|
|
143
|
+
escapeHatch,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
126
146
|
},
|
|
127
147
|
);
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { extname, isAbsolute, join } from 'node:path';
|
|
3
|
+
import { projectRootOf } from '../../lib/config.mjs';
|
|
4
|
+
import {
|
|
5
|
+
compileRegexList,
|
|
6
|
+
deny,
|
|
7
|
+
runGate,
|
|
8
|
+
toolInGroups,
|
|
9
|
+
writtenContentOf,
|
|
10
|
+
writtenPathOf,
|
|
11
|
+
} from '../../lib/hook-io.mjs';
|
|
12
|
+
import { withUnicodeWordBoundary } from '../../lib/signals.mjs';
|
|
13
|
+
|
|
14
|
+
const GATE_ID = 'no-explanatory-comments';
|
|
15
|
+
const CONFIG_KEY = 'blockExplanatoryComments';
|
|
16
|
+
|
|
17
|
+
const SLASH_EXTENSIONS = [
|
|
18
|
+
'.js',
|
|
19
|
+
'.mjs',
|
|
20
|
+
'.cjs',
|
|
21
|
+
'.ts',
|
|
22
|
+
'.tsx',
|
|
23
|
+
'.jsx',
|
|
24
|
+
'.vue',
|
|
25
|
+
'.svelte',
|
|
26
|
+
'.go',
|
|
27
|
+
'.rs',
|
|
28
|
+
'.java',
|
|
29
|
+
'.kt',
|
|
30
|
+
'.cs',
|
|
31
|
+
'.swift',
|
|
32
|
+
'.c',
|
|
33
|
+
'.cc',
|
|
34
|
+
'.cpp',
|
|
35
|
+
'.h',
|
|
36
|
+
'.hpp',
|
|
37
|
+
'.php',
|
|
38
|
+
'.scss',
|
|
39
|
+
'.css',
|
|
40
|
+
];
|
|
41
|
+
const HASH_EXTENSIONS = ['.py', '.sh', '.bash', '.ps1', '.rb', '.pl', '.r'];
|
|
42
|
+
const DASH_EXTENSIONS = ['.sql', '.lua'];
|
|
43
|
+
|
|
44
|
+
const DEFAULT_DECISION_MARKERS = [
|
|
45
|
+
'because|why|so that|otherwise|instead of|rather than|trade-?offs?|decision|deliberate(?:ly)?|intentional(?:ly)?|on purpose',
|
|
46
|
+
"workaround|known (?:issue|limitation)|limitation|invariant|must(?: not| never)?|never|cannot|can't|do not|don't|avoid|guard(?:s|ed)?|edge cases?|race|compat\\p{L}*|legacy|deprecated|perf\\p{L}*|security|unsafe|fail-?(?:safe|open|closed)",
|
|
47
|
+
'porque|por qu[eé]|para que|si no|en vez de|en lugar de|decisi[oó]n|deliberad\\p{L}*|intencional\\p{L}*|a prop[oó]sito|limitaci[oó]n|invariante|nunca|no debe|evita\\p{L}*|compatibilidad|rendimiento|seguridad|casos? borde|justificaci[oó]n',
|
|
48
|
+
];
|
|
49
|
+
const DEFAULT_ESCAPE_HATCH = 'comment-ok:';
|
|
50
|
+
const MAX_REPORTED = 5;
|
|
51
|
+
const MAX_LINE_LENGTH = 90;
|
|
52
|
+
|
|
53
|
+
const DIRECTIVE_PREFIXES = [
|
|
54
|
+
'#!',
|
|
55
|
+
'#pragma',
|
|
56
|
+
'#include',
|
|
57
|
+
'#define',
|
|
58
|
+
'#if',
|
|
59
|
+
'#endif',
|
|
60
|
+
'#else',
|
|
61
|
+
'#region',
|
|
62
|
+
'#endregion',
|
|
63
|
+
'#[',
|
|
64
|
+
'@license',
|
|
65
|
+
'spdx-',
|
|
66
|
+
'eslint',
|
|
67
|
+
'prettier',
|
|
68
|
+
'cspell',
|
|
69
|
+
'@ts-',
|
|
70
|
+
'c8 ',
|
|
71
|
+
'istanbul ',
|
|
72
|
+
'noqa',
|
|
73
|
+
'pylint',
|
|
74
|
+
'pyright',
|
|
75
|
+
'nolint',
|
|
76
|
+
'type:',
|
|
77
|
+
'neutral-spanish:allow',
|
|
78
|
+
'lint-ok:',
|
|
79
|
+
'justification:',
|
|
80
|
+
'justificación:',
|
|
81
|
+
'justificacion:',
|
|
82
|
+
'http://',
|
|
83
|
+
'https://',
|
|
84
|
+
];
|
|
85
|
+
const DIRECTIVE_WORD_PATTERN = /^(?:todo|fixme|hack|note|xxx)\b/i;
|
|
86
|
+
const JSDOC_TAG_PATTERN =
|
|
87
|
+
/^@(?:type|typedef|template|deprecated|throws|see|example)\b|^@(?:param|returns?) \{/i;
|
|
88
|
+
|
|
89
|
+
function isDirective(line) {
|
|
90
|
+
const lowered = line.trim().toLowerCase();
|
|
91
|
+
if (DIRECTIVE_PREFIXES.some((prefix) => lowered.startsWith(prefix)))
|
|
92
|
+
return true;
|
|
93
|
+
return (
|
|
94
|
+
DIRECTIVE_WORD_PATTERN.test(lowered) || JSDOC_TAG_PATTERN.test(lowered)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function commentStyleOf(filePath) {
|
|
99
|
+
const extension = extname(filePath).toLowerCase();
|
|
100
|
+
if (SLASH_EXTENSIONS.includes(extension)) return 'slash';
|
|
101
|
+
if (HASH_EXTENSIONS.includes(extension)) return 'hash';
|
|
102
|
+
if (DASH_EXTENSIONS.includes(extension)) return 'dash';
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function stripLeading(text, characters) {
|
|
107
|
+
let start = 0;
|
|
108
|
+
while (start < text.length && characters.includes(text[start])) start += 1;
|
|
109
|
+
return text.slice(start);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stripSlashMarkers(text) {
|
|
113
|
+
let body = text.trim();
|
|
114
|
+
if (body.endsWith('*/')) body = body.slice(0, -'*/'.length);
|
|
115
|
+
while (body.endsWith('*')) body = body.slice(0, -1);
|
|
116
|
+
if (body.startsWith('//')) body = stripLeading(body, '/');
|
|
117
|
+
else if (body.startsWith('/*')) body = stripLeading(body.slice(1), '*');
|
|
118
|
+
else body = stripLeading(body, '*');
|
|
119
|
+
return body.trim();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function slashComments(content) {
|
|
123
|
+
const units = [];
|
|
124
|
+
let block = null;
|
|
125
|
+
let lineRun = [];
|
|
126
|
+
const flushRun = () => {
|
|
127
|
+
if (lineRun.length > 0) units.push(lineRun);
|
|
128
|
+
lineRun = [];
|
|
129
|
+
};
|
|
130
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
131
|
+
const line = rawLine.trim();
|
|
132
|
+
if (block) {
|
|
133
|
+
block.push(stripSlashMarkers(line));
|
|
134
|
+
if (line.includes('*/')) {
|
|
135
|
+
units.push(block);
|
|
136
|
+
block = null;
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const blockStart = line.indexOf('/*');
|
|
141
|
+
const quoteAt = line.search(/['"`]/);
|
|
142
|
+
if (blockStart !== -1 && (quoteAt === -1 || quoteAt > blockStart)) {
|
|
143
|
+
flushRun();
|
|
144
|
+
block = [stripSlashMarkers(line.slice(blockStart))];
|
|
145
|
+
if (line.indexOf('*/', blockStart + 2) !== -1) {
|
|
146
|
+
units.push(block);
|
|
147
|
+
block = null;
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const lineStart = line.indexOf('//');
|
|
152
|
+
const isCommentLine = line.startsWith('//');
|
|
153
|
+
const isTrailing =
|
|
154
|
+
lineStart > 0 &&
|
|
155
|
+
!/['"`]/.test(line.slice(0, lineStart)) &&
|
|
156
|
+
!line.slice(0, lineStart).includes(':');
|
|
157
|
+
if (isCommentLine) {
|
|
158
|
+
lineRun.push(stripSlashMarkers(line));
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
flushRun();
|
|
162
|
+
if (isTrailing) units.push([stripSlashMarkers(line.slice(lineStart))]);
|
|
163
|
+
}
|
|
164
|
+
flushRun();
|
|
165
|
+
if (block) units.push(block);
|
|
166
|
+
return units;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function prefixComments(content, prefix) {
|
|
170
|
+
const units = [];
|
|
171
|
+
let run = [];
|
|
172
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
173
|
+
const line = rawLine.trim();
|
|
174
|
+
if (line.startsWith(prefix)) {
|
|
175
|
+
run.push(line.slice(prefix.length).trim());
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (run.length > 0) units.push(run);
|
|
179
|
+
run = [];
|
|
180
|
+
const at = line.indexOf(` ${prefix}`);
|
|
181
|
+
if (at > 0 && !/['"]/.test(line.slice(0, at)))
|
|
182
|
+
units.push([line.slice(at + prefix.length + 1).trim()]);
|
|
183
|
+
}
|
|
184
|
+
if (run.length > 0) units.push(run);
|
|
185
|
+
return units;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function commentUnitsOf(content, filePath) {
|
|
189
|
+
const style = commentStyleOf(filePath);
|
|
190
|
+
if (style === 'slash') return slashComments(content);
|
|
191
|
+
if (style === 'hash') return prefixComments(content, '#');
|
|
192
|
+
if (style === 'dash') return prefixComments(content, '--');
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function unitKey(unit) {
|
|
197
|
+
return unit
|
|
198
|
+
.map((line) => line.trim())
|
|
199
|
+
.filter(Boolean)
|
|
200
|
+
.join('\n');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isAllowed(unit, markers, escapeHatch) {
|
|
204
|
+
const text = unitKey(unit);
|
|
205
|
+
if (!text) return true;
|
|
206
|
+
const lowered = text.toLowerCase();
|
|
207
|
+
if (escapeHatch && lowered.includes(escapeHatch.toLowerCase())) return true;
|
|
208
|
+
if (unit.some((line) => isDirective(line))) return true;
|
|
209
|
+
return markers.some((pattern) => pattern.test(text));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function previousContent(toolInput, absolutePath) {
|
|
213
|
+
if (typeof toolInput.old_string === 'string') return toolInput.old_string;
|
|
214
|
+
if (Array.isArray(toolInput.edits))
|
|
215
|
+
return toolInput.edits
|
|
216
|
+
.map((edit) => String(edit?.old_string ?? ''))
|
|
217
|
+
.join('\n');
|
|
218
|
+
if (!existsSync(absolutePath)) return '';
|
|
219
|
+
try {
|
|
220
|
+
return statSync(absolutePath).isFile()
|
|
221
|
+
? readFileSync(absolutePath, 'utf8')
|
|
222
|
+
: '';
|
|
223
|
+
} catch {
|
|
224
|
+
return '';
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function excerpt(unit) {
|
|
229
|
+
const text = unit.filter(Boolean).join(' ');
|
|
230
|
+
return text.length > MAX_LINE_LENGTH
|
|
231
|
+
? `${text.slice(0, MAX_LINE_LENGTH)}…`
|
|
232
|
+
: text;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
runGate(
|
|
236
|
+
{
|
|
237
|
+
id: GATE_ID,
|
|
238
|
+
configKey: CONFIG_KEY,
|
|
239
|
+
enabledByDefault: true,
|
|
240
|
+
defaultParams: {
|
|
241
|
+
decisionMarkers: DEFAULT_DECISION_MARKERS,
|
|
242
|
+
escapeHatch: DEFAULT_ESCAPE_HATCH,
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
({ toolName, toolInput, parameters, cwd }) => {
|
|
246
|
+
if (!toolInGroups(toolName, ['write'])) return;
|
|
247
|
+
const filePath = writtenPathOf(toolInput);
|
|
248
|
+
if (!commentStyleOf(filePath)) return;
|
|
249
|
+
const content = writtenContentOf(toolInput);
|
|
250
|
+
if (!content) return;
|
|
251
|
+
const root = projectRootOf(cwd) ?? cwd;
|
|
252
|
+
const absolutePath = isAbsolute(filePath) ? filePath : join(root, filePath);
|
|
253
|
+
const before = new Set(
|
|
254
|
+
commentUnitsOf(previousContent(toolInput, absolutePath), filePath).map(
|
|
255
|
+
unitKey,
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
const markers = compileRegexList(
|
|
259
|
+
parameters.decisionMarkers,
|
|
260
|
+
'iu',
|
|
261
|
+
).patterns.map((pattern) => withUnicodeWordBoundary(pattern.source));
|
|
262
|
+
const offending = commentUnitsOf(content, filePath)
|
|
263
|
+
.filter((unit) => !before.has(unitKey(unit)))
|
|
264
|
+
.filter((unit) => !isAllowed(unit, markers, parameters.escapeHatch));
|
|
265
|
+
if (offending.length === 0) return;
|
|
266
|
+
const shown = offending
|
|
267
|
+
.slice(0, MAX_REPORTED)
|
|
268
|
+
.map((unit) => `"${excerpt(unit)}"`);
|
|
269
|
+
const more =
|
|
270
|
+
offending.length > MAX_REPORTED
|
|
271
|
+
? ` (+${offending.length - MAX_REPORTED} more)`
|
|
272
|
+
: '';
|
|
273
|
+
deny(
|
|
274
|
+
CONFIG_KEY,
|
|
275
|
+
`This write adds ${offending.length} comment(s) that explain what the code does instead of recording a ` +
|
|
276
|
+
`decision: ${shown.join('; ')}${more}. Make the code say it (a clearer name, a small extracted function) ` +
|
|
277
|
+
'and delete the comment, or keep only the WHY (start with because/porque, so that/para que, instead of/en vez de, ' +
|
|
278
|
+
`a trade-off or a known limitation). For a documented exception add "${parameters.escapeHatch} <reason>" in the comment.`,
|
|
279
|
+
);
|
|
280
|
+
},
|
|
281
|
+
);
|