@devrik-tools/claude-gates 0.7.2 → 0.9.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 +3 -3
- package/README.es.md +129 -11
- package/README.md +120 -10
- 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 +12 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +159 -25
- package/cli/task.mjs +104 -11
- package/cli/toggle.mjs +125 -0
- package/package.json +4 -3
- 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 +94 -215
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- 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 +53 -95
- 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 +338 -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/no-trivial-scripts/index.mjs +114 -0
- 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/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- 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 +152 -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 +451 -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 +148 -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 +12 -6
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- package/registry.json +249 -38
|
@@ -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
|
+
);
|
|
@@ -1,44 +1,20 @@
|
|
|
1
|
-
// no-lint-suppression — denies a write that SILENCES
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
// not a prose reminder. A legitimate suppression (a documented false positive) is allowed by
|
|
14
|
-
// putting the escape hatch on the SAME line as the directive, which also forces a reason to
|
|
15
|
-
// live next to it in the diff.
|
|
16
|
-
//
|
|
17
|
-
// ── What this catches ────────────────────────────────────────────────────────────────
|
|
18
|
-
// inline directives in any source file:
|
|
19
|
-
// // eslint-disable, /* eslint-disable */, // eslint-disable-next-line,
|
|
20
|
-
// // @ts-ignore, // @ts-nocheck, # type: ignore, # noqa, // prettier-ignore,
|
|
21
|
-
// // biome-ignore, // stylelint-disable, // NOSONAR
|
|
22
|
-
// config edits that weaken the ruleset, in eslint/tsconfig/prettier/biome/stylelint config:
|
|
23
|
-
// a rule set to "off" / 0, "@ts-nocheck", disabling strict, or "ignore" additions.
|
|
24
|
-
//
|
|
25
|
-
// ── What a project can configure (params) ───────────────────────────────────────────
|
|
26
|
-
// suppressionPatterns regex sources (case-insensitive) that mark a line as a
|
|
27
|
-
// suppression. Replaces the built-in list wholesale.
|
|
28
|
-
// escapeHatch substring that, present on the same line as a suppression, allows
|
|
29
|
-
// it — so a real false positive is annotated, not smuggled. Default
|
|
30
|
-
// 'lint-ok:' (write e.g. `// eslint-disable-next-line ... lint-ok: <reason>`).
|
|
31
|
-
// watchedConfigFiles basenames whose edits are also scanned for rule-weakening. Replaces
|
|
32
|
-
// the built-in list.
|
|
33
|
-
//
|
|
34
|
-
// ── Fail-safe shape ──────────────────────────────────────────────────────────────────
|
|
35
|
-
// A non-write tool, or a write with no suppression line: allow (silent). A write that adds a
|
|
36
|
-
// suppression line WITHOUT the escape hatch on that same line: deny, naming the line.
|
|
37
|
-
|
|
38
|
-
import { extname } from 'node:path';
|
|
1
|
+
// no-lint-suppression — denies a write that SILENCES a linter/type-checker instead of fixing
|
|
2
|
+
// the code: a NEW inline disable directive in a source file, or a rule turned off/downgraded
|
|
3
|
+
// in a watched config file. A line already present in the file on disk is not a new
|
|
4
|
+
// suppression (rewriting a file must not re-litigate old ones). A legitimate suppression is
|
|
5
|
+
// allowed by putting the escape hatch on the SAME line, so the reason lives next to it in the
|
|
6
|
+
// diff. Deliberate limits: `@ts-expect-error` WITH a description is the recommended form and
|
|
7
|
+
// passes; in config files only a rule-like key (`a/b`, `no-x`, or one under a rules/overrides
|
|
8
|
+
// block) counts, so `"printWidth": 0` is not a weakening; shell writes are out of scope.
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { basename, extname, resolve } from 'node:path';
|
|
12
|
+
import { projectRootOf } from '../../lib/config.mjs';
|
|
39
13
|
import {
|
|
40
|
-
|
|
14
|
+
compileRegexList,
|
|
41
15
|
deny,
|
|
16
|
+
escapeRegExp,
|
|
17
|
+
runGate,
|
|
42
18
|
toolInGroups,
|
|
43
19
|
writtenContentOf,
|
|
44
20
|
writtenPathOf,
|
|
@@ -47,28 +23,36 @@ import {
|
|
|
47
23
|
const GATE_ID = 'no-lint-suppression';
|
|
48
24
|
const CONFIG_KEY = 'blockLintSuppression';
|
|
49
25
|
|
|
50
|
-
// Inline suppression directives across the common linters/type-checkers. Sources, compiled
|
|
51
|
-
// case-insensitively and matched one line at a time.
|
|
52
26
|
const DEFAULT_SUPPRESSION_PATTERNS = [
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
27
|
+
'eslint-disable',
|
|
28
|
+
'@ts-ignore',
|
|
29
|
+
'@ts-nocheck',
|
|
30
|
+
'@ts-expect-error\\s*[:-]?\\s*$',
|
|
31
|
+
'prettier-ignore',
|
|
32
|
+
'biome-ignore',
|
|
33
|
+
'stylelint-disable',
|
|
34
|
+
'nosonar',
|
|
35
|
+
'#\\s*noqa',
|
|
36
|
+
'#\\s*type:\\s*ignore',
|
|
37
|
+
'//\\s*@flow-ignore',
|
|
38
|
+
'istanbul ignore',
|
|
39
|
+
'c8 ignore',
|
|
40
|
+
'pylint:\\s*disable',
|
|
41
|
+
'pyright:\\s*ignore',
|
|
42
|
+
'//\\s*nolint',
|
|
43
|
+
'#\\[allow\\(',
|
|
44
|
+
'#pragma\\s+warning\\s+disable',
|
|
45
|
+
'@SuppressWarnings',
|
|
65
46
|
];
|
|
66
47
|
|
|
67
|
-
//
|
|
48
|
+
// Basenames; `*` matches within one name.
|
|
68
49
|
const DEFAULT_WATCHED_CONFIG_FILES = [
|
|
69
50
|
'eslint.config.mjs',
|
|
70
51
|
'eslint.config.js',
|
|
71
52
|
'eslint.config.cjs',
|
|
53
|
+
'eslint.config.ts',
|
|
54
|
+
'eslint.config.mts',
|
|
55
|
+
'eslint.config.cts',
|
|
72
56
|
'.eslintrc',
|
|
73
57
|
'.eslintrc.js',
|
|
74
58
|
'.eslintrc.cjs',
|
|
@@ -76,60 +60,138 @@ const DEFAULT_WATCHED_CONFIG_FILES = [
|
|
|
76
60
|
'.eslintrc.yml',
|
|
77
61
|
'.eslintrc.yaml',
|
|
78
62
|
'tsconfig.json',
|
|
63
|
+
'tsconfig.*.json',
|
|
64
|
+
'jsconfig.json',
|
|
79
65
|
'.prettierrc',
|
|
66
|
+
'.prettierrc.json',
|
|
80
67
|
'biome.json',
|
|
81
68
|
'.stylelintrc',
|
|
82
69
|
'.stylelintrc.json',
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
// Inside a watched config file, these mark a rule being turned off or a check being weakened.
|
|
86
|
-
const CONFIG_WEAKENING_PATTERNS = [
|
|
87
|
-
// a rule mapped to "off" or 0: "no-unused-vars": "off" 'rule': 0
|
|
88
|
-
// (no trailing \b: the value can end in a quote, which is a non-word char, so \b would
|
|
89
|
-
// never match after "off" and silently miss every quoted "off")
|
|
90
|
-
String.raw`["'][^"']+["']\s*:\s*(?:["']off["']|0)(?:\s|,|}|$)`,
|
|
91
|
-
// strict / type-checking disabled in tsconfig
|
|
92
|
-
String.raw`"(?:strict|noImplicitAny|strictNullChecks|checkJs)"\s*:\s*false`,
|
|
93
|
-
String.raw`@ts-nocheck`,
|
|
70
|
+
'package.json',
|
|
94
71
|
];
|
|
95
72
|
|
|
96
73
|
const DEFAULT_ESCAPE_HATCH = 'lint-ok:';
|
|
97
74
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
75
|
+
const NON_SOURCE_EXTENSIONS = new Set(['.md', '.mdx', '.txt', '.lock', '.log']);
|
|
76
|
+
|
|
77
|
+
// ── Config weakening ────────────────────────────────────────────────────────────────
|
|
78
|
+
const RULES_SECTION_PATTERN = /(?:^|[\s{,])["']?(?:rules|overrides)["']?\s*:/;
|
|
79
|
+
const LINTER_SECTION_PATTERN = /["']linter["']\s*:/;
|
|
80
|
+
const STRICTNESS_OFF_PATTERN =
|
|
81
|
+
/["'](?:strict|noImplicitAny|strictNullChecks|checkJs)["']\s*:\s*false/;
|
|
82
|
+
const LINTER_DISABLED_PATTERN = /["']enabled["']\s*:\s*false/;
|
|
83
|
+
const TS_NOCHECK_PATTERN = /@ts-nocheck/i;
|
|
84
|
+
const DISABLING_TOKEN_PATTERN = /^["']?(?:off|warn|0)["']?(?=\s*(?:[,}\]]|$))/i;
|
|
85
|
+
const RULE_LIKE_KEY = /[/-]/;
|
|
86
|
+
const KEY_CHARACTER = /[\w@./-]/;
|
|
87
|
+
|
|
88
|
+
function hasSectionBefore(lines, index, sectionPattern) {
|
|
89
|
+
for (let cursor = index; cursor >= 0; cursor -= 1)
|
|
90
|
+
if (sectionPattern.test(lines[cursor])) return true;
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isDisablingValue(rest) {
|
|
95
|
+
const value = rest.trimStart().replace(/^\[\s*/, '');
|
|
96
|
+
return DISABLING_TOKEN_PATTERN.test(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Each `key: value` on the line, located by walking back from every colon (a regex over
|
|
100
|
+
// the key run is quadratic on colon-less lines).
|
|
101
|
+
function assignmentsOf(line) {
|
|
102
|
+
const found = [];
|
|
103
|
+
for (
|
|
104
|
+
let colon = line.indexOf(':');
|
|
105
|
+
colon !== -1;
|
|
106
|
+
colon = line.indexOf(':', colon + 1)
|
|
107
|
+
) {
|
|
108
|
+
let end = colon;
|
|
109
|
+
while (end > 0 && /\s/.test(line[end - 1])) end -= 1;
|
|
110
|
+
if (end > 0 && (line[end - 1] === '"' || line[end - 1] === "'")) end -= 1;
|
|
111
|
+
let start = end;
|
|
112
|
+
while (start > 0 && KEY_CHARACTER.test(line[start - 1])) start -= 1;
|
|
113
|
+
if (start < end)
|
|
114
|
+
found.push({ key: line.slice(start, end), rest: line.slice(colon + 1) });
|
|
115
|
+
}
|
|
116
|
+
return found;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function assignsDisablingValue(line, index, lines) {
|
|
120
|
+
for (const { key, rest } of assignmentsOf(line)) {
|
|
121
|
+
if (!isDisablingValue(rest)) continue;
|
|
122
|
+
if (RULE_LIKE_KEY.test(key)) return true;
|
|
123
|
+
if (hasSectionBefore(lines, index, RULES_SECTION_PATTERN)) return true;
|
|
106
124
|
}
|
|
107
|
-
return
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function weakensConfig(lines, index) {
|
|
129
|
+
const line = lines[index];
|
|
130
|
+
if (STRICTNESS_OFF_PATTERN.test(line) || TS_NOCHECK_PATTERN.test(line))
|
|
131
|
+
return true;
|
|
132
|
+
if (
|
|
133
|
+
LINTER_DISABLED_PATTERN.test(line) &&
|
|
134
|
+
hasSectionBefore(lines, index, LINTER_SECTION_PATTERN)
|
|
135
|
+
)
|
|
136
|
+
return true;
|
|
137
|
+
return assignsDisablingValue(line, index, lines);
|
|
108
138
|
}
|
|
109
139
|
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
|
|
140
|
+
function watchedFilePatterns(names) {
|
|
141
|
+
return compileRegexList(
|
|
142
|
+
names.map((name) => `^${escapeRegExp(name).replace(/\\\*/g, '[^/]*')}$`),
|
|
143
|
+
).patterns;
|
|
113
144
|
}
|
|
114
145
|
|
|
115
|
-
function isWatchedConfig(filePath, watchedConfigFiles) {
|
|
116
|
-
|
|
146
|
+
function isWatchedConfig(filePath, content, watchedConfigFiles) {
|
|
147
|
+
const name = basename(filePath.replace(/\\/g, '/'));
|
|
148
|
+
if (name === 'package.json' && !content.includes('eslintConfig'))
|
|
149
|
+
return false;
|
|
150
|
+
return watchedFilePatterns(watchedConfigFiles).some((pattern) =>
|
|
151
|
+
pattern.test(name),
|
|
152
|
+
);
|
|
117
153
|
}
|
|
118
154
|
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
155
|
+
// ── Lines already on disk are not new suppressions ──────────────────────────────────
|
|
156
|
+
// Counted, not just collected: a directive the file already has once does not license a
|
|
157
|
+
// second identical one.
|
|
158
|
+
function existingLineCounts(filePath, root) {
|
|
159
|
+
const counts = new Map();
|
|
160
|
+
const resolved = resolve(root, filePath);
|
|
161
|
+
try {
|
|
162
|
+
if (!existsSync(resolved)) return counts;
|
|
163
|
+
for (const line of readFileSync(resolved, 'utf8').split('\n')) {
|
|
164
|
+
const trimmed = line.trim();
|
|
165
|
+
counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1);
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
counts.clear();
|
|
169
|
+
}
|
|
170
|
+
return counts;
|
|
171
|
+
}
|
|
123
172
|
|
|
124
|
-
function
|
|
125
|
-
|
|
173
|
+
function consumeExisting(existing, trimmed) {
|
|
174
|
+
const remaining = existing.get(trimmed) ?? 0;
|
|
175
|
+
if (remaining === 0) return false;
|
|
176
|
+
existing.set(trimmed, remaining - 1);
|
|
177
|
+
return true;
|
|
126
178
|
}
|
|
127
179
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
180
|
+
function firstOffendingLine({
|
|
181
|
+
content,
|
|
182
|
+
inlinePatterns,
|
|
183
|
+
isConfig,
|
|
184
|
+
escapeHatch,
|
|
185
|
+
existing,
|
|
186
|
+
}) {
|
|
187
|
+
const lines = content.split('\n');
|
|
188
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
189
|
+
const line = lines[index];
|
|
190
|
+
const trimmed = line.trim();
|
|
191
|
+
if (!trimmed || consumeExisting(existing, trimmed)) continue;
|
|
131
192
|
if (escapeHatch && line.includes(escapeHatch)) continue;
|
|
132
|
-
if (
|
|
193
|
+
if (inlinePatterns.some((pattern) => pattern.test(line))) return trimmed;
|
|
194
|
+
if (isConfig && weakensConfig(lines, index)) return trimmed;
|
|
133
195
|
}
|
|
134
196
|
return null;
|
|
135
197
|
}
|
|
@@ -145,31 +207,29 @@ runGate(
|
|
|
145
207
|
escapeHatch: DEFAULT_ESCAPE_HATCH,
|
|
146
208
|
},
|
|
147
209
|
},
|
|
148
|
-
({ toolName, toolInput, parameters }) => {
|
|
210
|
+
({ toolName, toolInput, parameters, cwd }) => {
|
|
149
211
|
if (!toolInGroups(toolName, ['write'])) return;
|
|
150
212
|
|
|
151
213
|
const filePath = writtenPathOf(toolInput);
|
|
152
|
-
if (!filePath) return;
|
|
153
|
-
|
|
154
214
|
const content = writtenContentOf(toolInput);
|
|
155
|
-
if (!content) return;
|
|
156
|
-
|
|
157
|
-
const escapeHatch = parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH;
|
|
158
|
-
|
|
159
|
-
// Config files: scan for rule-weakening. Any source file: scan for inline suppressions.
|
|
160
|
-
const isConfig = isWatchedConfig(filePath, parameters.watchedConfigFiles);
|
|
161
|
-
const inlinePatterns = compile(parameters.suppressionPatterns);
|
|
162
|
-
const configPatterns = compile(CONFIG_WEAKENING_PATTERNS);
|
|
215
|
+
if (!filePath || !content) return;
|
|
163
216
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
217
|
+
const isConfig = isWatchedConfig(
|
|
218
|
+
filePath,
|
|
219
|
+
content,
|
|
220
|
+
parameters.watchedConfigFiles,
|
|
221
|
+
);
|
|
222
|
+
if (!isConfig && NON_SOURCE_EXTENSIONS.has(extname(filePath).toLowerCase()))
|
|
223
|
+
return;
|
|
224
|
+
|
|
225
|
+
const escapeHatch = String(parameters.escapeHatch ?? '');
|
|
226
|
+
const offender = firstOffendingLine({
|
|
227
|
+
content,
|
|
228
|
+
inlinePatterns: compileRegexList(parameters.suppressionPatterns).patterns,
|
|
229
|
+
isConfig,
|
|
230
|
+
escapeHatch,
|
|
231
|
+
existing: existingLineCounts(filePath, projectRootOf(cwd) ?? cwd),
|
|
232
|
+
});
|
|
173
233
|
if (!offender) return;
|
|
174
234
|
|
|
175
235
|
deny(
|
|
@@ -177,7 +237,7 @@ runGate(
|
|
|
177
237
|
`This write silences the linter/type-checker instead of fixing the code: "${offender}". ` +
|
|
178
238
|
'Fix the underlying issue rather than turning the check off. If this is a genuine, ' +
|
|
179
239
|
`documented false positive, put "${escapeHatch} <reason>" on the same line so the ` +
|
|
180
|
-
|
|
240
|
+
`reason lives next to the suppression, or set ${CONFIG_KEY} in .ai/config.json.`,
|
|
181
241
|
);
|
|
182
242
|
},
|
|
183
243
|
);
|