@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.
Files changed (83) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.es.md +129 -11
  3. package/README.md +120 -10
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +12 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +159 -25
  14. package/cli/task.mjs +104 -11
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +4 -3
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
  29. package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
  30. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  31. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  32. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  33. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  34. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  35. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  36. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
  39. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  40. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  41. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  42. package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
  43. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  44. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  45. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  46. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  47. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  48. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  49. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  50. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  51. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  52. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  53. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  54. package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
  55. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  56. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  57. package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
  58. package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
  59. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  60. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  61. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  62. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  65. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  66. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  67. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  69. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  70. package/plugins/gates/hooks/hooks.json +152 -42
  71. package/plugins/gates/hooks/lib/config.mjs +6 -3
  72. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  73. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  74. package/plugins/gates/hooks/lib/git.mjs +262 -0
  75. package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
  76. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  77. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  78. package/plugins/gates/hooks/lib/testing.mjs +148 -0
  79. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  80. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  81. package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
  82. package/plugins/tasks/hooks/register-requests.mjs +37 -10
  83. 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 the linter/type-checker instead of
2
- // fixing the code it complains about. Turning a rule off, adding an inline disable, or
3
- // widening an ignore list is the lazy path ("it is easier to disable the linter than to fix
4
- // it"); this gate makes that path a deliberate, escape-hatched choice rather than a default.
5
- //
6
- // justification: no existing gate covers this. lint-commit RUNS the linter and blocks a
7
- // commit when it fails, but a suppression makes the linter pass so lint-commit goes green
8
- // precisely when the code got worse. This gate reads the WRITE, not the lint result, and is
9
- // the only one that catches "made it pass by turning the check off".
10
- //
11
- // The defect is fully visible in the content being written (the disable directive, the rule
12
- // set to "off", the added @ts-ignore), so this is a deterministic deny with an escape hatch —
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
- runGate,
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
- String.raw`eslint-disable`,
54
- String.raw`@ts-ignore`,
55
- String.raw`@ts-nocheck`,
56
- String.raw`@ts-expect-error`,
57
- String.raw`prettier-ignore`,
58
- String.raw`biome-ignore`,
59
- String.raw`stylelint-disable`,
60
- String.raw`nosonar`,
61
- String.raw`#\s*noqa`,
62
- String.raw`#\s*type:\s*ignore`,
63
- String.raw`//\s*@flow-ignore`,
64
- String.raw`istanbul ignore`,
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
- // Config files whose edits are scanned for rule-weakening (a rule set off, strict disabled).
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
- function compile(sources) {
99
- const compiled = [];
100
- for (const source of sources) {
101
- try {
102
- compiled.push(new RegExp(source, 'i'));
103
- } catch {
104
- // Skip a malformed override pattern rather than crashing; the rest still protect.
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 compiled;
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 baseNameOf(filePath) {
111
- const normalized = filePath.replace(/\\/g, '/');
112
- return normalized.slice(normalized.lastIndexOf('/') + 1);
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
- return watchedConfigFiles.includes(baseNameOf(filePath));
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
- // A code file's inline directives count everywhere; a config file's edits are read with the
120
- // rule-weakening patterns instead. We never guess a language the directives are recognizable
121
- // on their own.
122
- const NON_SOURCE_EXTENSIONS = new Set(['.md', '.txt', '.lock', '.log']);
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 isPlausibleSource(filePath) {
125
- return !NON_SOURCE_EXTENSIONS.has(extname(filePath).toLowerCase());
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
- /** The first written line matching any pattern and lacking the escape hatch, or null. */
129
- function offendingLine(content, patterns, escapeHatch) {
130
- for (const line of content.split('\n')) {
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 (patterns.some((pattern) => pattern.test(line))) return line.trim();
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
- // A config file is scanned for both inline directives and rule-weakening; any other
165
- // plausible source file only for inline directives; a non-source file (docs, lockfiles)
166
- // is not scanned at all.
167
- let patterns = [];
168
- if (isConfig) patterns = [...inlinePatterns, ...configPatterns];
169
- else if (isPlausibleSource(filePath)) patterns = inlinePatterns;
170
- if (patterns.length === 0) return;
171
-
172
- const offender = offendingLine(content, patterns, escapeHatch);
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
- 'reason lives next to the suppression, or set blockLintSuppression in .ai/config.json.',
240
+ `reason lives next to the suppression, or set ${CONFIG_KEY} in .ai/config.json.`,
181
241
  );
182
242
  },
183
243
  );