@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
@@ -1,33 +1,23 @@
1
- // sdd-specs — denies implementation (a write to the catalog moving a feature past
2
- // `spec_ready`, or a delegation prompt that implements) when the feature/task it targets
3
- // has no non-empty contract on disk (requirements/design/tasks or brief/asserts).
4
- // Migrated from ~/.claude/hooks/guard-sdd-specs.mjs.
1
+ // sdd-specs — denies implementation (a catalog write moving a feature past `spec_ready`,
2
+ // or a STANDARD/HIGH-RISK implementation delegation citing a feature) when the feature has
3
+ // no non-empty contract on disk (requirements/design/tasks or brief/asserts).
5
4
  //
6
- // ── What a project can configure (params) ───────────────────────────────────────────
7
- // catalogLocations paths (relative to the project root) searched for the feature
8
- // catalog. Replaces the built-in list wholesale.
9
- // exemptSubagents subagent types exempt from the spec requirement (read-only
10
- // stages of the pipeline that cannot require a contract of
11
- // themselves). Replaces the built-in list wholesale.
12
- // The defaults live here, in the source, so a project reads them and knows exactly what
13
- // its override replaces.
14
- //
15
- // ── Auto-off when there is no SDD harness ───────────────────────────────────────────
16
- // This gate only acts once a catalog is found at one of `catalogLocations` (or the
17
- // project's own `.ai/config.json` declares adoption). A project that never adopted the
18
- // spec-driven harness has no catalog and no declared adoption, so every check below is
19
- // skipped and the write/delegation is allowed — imposing phases on a project that never
20
- // asked for them is the false positive that gets a gate disabled.
21
- //
22
- // ── Two surfaces inspected ───────────────────────────────────────────────────────────
23
- // A write to the catalog is checked against the catalog's own declared statuses: a
24
- // feature marked `spec_ready`/`in_progress`/`done` needs its contract tree in place. A
25
- // delegation prompt is checked when it declares a STANDARD/HIGH-RISK level and an
26
- // implementation verb: the contract tree must have non-empty requirements/design/tasks
27
- // (or brief/asserts) for the feature it targets.
5
+ // Decisions: the gate is inert until a catalog exists at one of `catalogLocations` — a
6
+ // project that never adopted the spec-driven harness must not be held to it. A delegation
7
+ // citing several features is denied when ANY of them lacks a contract. The LAST declared
8
+ // LEVEL governs, so a decoy MICRO before HIGH-RISK exempts nothing.
28
9
 
29
10
  import { existsSync, statSync } from 'node:fs';
30
- import { dirname, join } from 'node:path';
11
+ import { basename, dirname, isAbsolute, join } from 'node:path';
12
+ import { projectRootOf } from '../../lib/config.mjs';
13
+ import {
14
+ DEFAULT_EXEMPT_SUBAGENTS,
15
+ DEMANDING_LEVELS,
16
+ featureNamesCitedIn,
17
+ isImplementationRequest,
18
+ isSubagentNamedIn,
19
+ operativeLevelOf,
20
+ } from '../../lib/delegation.mjs';
31
21
  import {
32
22
  runGate,
33
23
  deny,
@@ -41,27 +31,15 @@ const GATE_ID = 'sdd-specs';
41
31
  const CONFIG_KEY = 'requireSpecBeforeImplementing';
42
32
 
43
33
  const CATALOG_FILE_NAME = 'feature_list.json';
34
+ const FEATURES_DIRECTORY = 'features';
44
35
 
45
36
  const DEFAULT_CATALOG_LOCATIONS = [
46
37
  join('.ai', CATALOG_FILE_NAME),
47
38
  CATALOG_FILE_NAME,
48
39
  ];
49
40
 
50
- const DEFAULT_EXEMPT_SUBAGENTS = [
51
- 'explore',
52
- 'plan',
53
- 'scout',
54
- 'revision',
55
- 'contraste',
56
- 'test-planner',
57
- 'qa',
58
- 'ui',
59
- 'ux',
60
- ];
61
-
62
41
  const ADVANCED_STATUSES = new Set(['spec_ready', 'in_progress', 'done']);
63
42
 
64
- /** Files that count as a feature's contract; at least one must be non-empty. */
65
43
  const CONTRACT_FILES = [
66
44
  'requirements.md',
67
45
  'design.md',
@@ -70,29 +48,6 @@ const CONTRACT_FILES = [
70
48
  'asserts.md',
71
49
  ];
72
50
 
73
- function withWordBoundary(alternation) {
74
- return new RegExp(
75
- `(?<![\\p{L}\\p{N}_])(${alternation})(?![\\p{L}\\p{N}_])`,
76
- 'iu',
77
- );
78
- }
79
-
80
- const IMPLEMENTATION_VERBS = withWordBoundary(
81
- 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
82
- 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
83
- 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
84
- 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
85
- 'escrib(í|e)|escribir|implement\\w*|writ(e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
86
- 'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
87
- );
88
-
89
- /** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
90
- * the plugin-wide convention (see risk-level.mjs). */
91
- const DEMANDING_LEVEL_PATTERN =
92
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
93
- const EXEMPT_LEVEL_PATTERN =
94
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
95
-
96
51
  function fileExistsNonEmpty(path) {
97
52
  try {
98
53
  return existsSync(path) && statSync(path).size > 0;
@@ -101,32 +56,30 @@ function fileExistsNonEmpty(path) {
101
56
  }
102
57
  }
103
58
 
104
- function findCatalog(catalogLocations, writtenPath) {
105
- const roots = [process.cwd()];
106
- // A write can target a catalog that lives elsewhere than cwd (a monorepo subpackage's
107
- // own .ai/feature_list.json): also resolve catalogLocations relative to the directory
108
- // of the file actually being written, not only the process cwd.
109
- if (writtenPath) roots.push(dirname(writtenPath));
110
- for (const root of roots) {
59
+ function resolveFrom(root, path) {
60
+ return isAbsolute(path) ? path : join(root, path);
61
+ }
62
+
63
+ function findCatalog(root, catalogLocations, writtenPath) {
64
+ const roots = [root];
65
+ // A monorepo subpackage keeps its own catalog next to the file being written.
66
+ if (writtenPath) roots.push(dirname(resolveFrom(root, writtenPath)));
67
+ for (const base of roots) {
111
68
  for (const relative of catalogLocations) {
112
- const path = join(root, relative);
69
+ const path = resolveFrom(base, relative);
113
70
  if (existsSync(path)) return path;
114
71
  }
115
72
  }
116
73
  return null;
117
74
  }
118
75
 
119
- /** Discovers the contract tree root next to wherever the catalog lives, or `.ai/features`. */
120
- function contractTreeRootFor(catalogPath) {
121
- const candidates = catalogPath
122
- ? [join(dirname(catalogPath), 'features')]
123
- : [];
124
- candidates.push(join(process.cwd(), '.ai', 'features'));
125
- candidates.push(join(process.cwd(), 'features'));
126
- for (const candidate of candidates) {
127
- if (existsSync(candidate)) return candidate;
128
- }
129
- return null;
76
+ function contractTreeRootFor(root, catalogPath) {
77
+ const candidates = [
78
+ join(dirname(catalogPath), FEATURES_DIRECTORY),
79
+ join(root, '.ai', FEATURES_DIRECTORY),
80
+ join(root, FEATURES_DIRECTORY),
81
+ ];
82
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
130
83
  }
131
84
 
132
85
  function contractExistsFor(treeRoot, featureName) {
@@ -146,11 +99,7 @@ function parseJsonOrNull(text) {
146
99
  }
147
100
  }
148
101
 
149
- /** Denies the single first advanced-status feature in the new catalog content that has
150
- * no contract on disk, or does nothing when every advanced feature has one. A feature
151
- * object missing its `name` field is denied too (not silently skipped) — it is malformed
152
- * catalog data, not a feature this gate has decided has no obligations. */
153
- function denyIfAdvancedFeatureLacksContract(features, treeRoot) {
102
+ function denyIfAdvancedFeatureLacksContract(features, treeRoot, fallbackRoot) {
154
103
  for (const feature of features) {
155
104
  if (!ADVANCED_STATUSES.has(feature?.status)) continue;
156
105
  if (!feature?.name) {
@@ -160,13 +109,9 @@ function denyIfAdvancedFeatureLacksContract(features, treeRoot) {
160
109
  `${JSON.stringify(feature)}. Every feature needs a 'name' so its contract tree ` +
161
110
  '(.ai/features/<name>/) can be located — add it before writing this status.',
162
111
  );
163
- continue;
164
112
  }
165
113
  if (contractExistsFor(treeRoot, feature.name)) continue;
166
- const featureDirectory = join(
167
- treeRoot ?? join(process.cwd(), '.ai', 'features'),
168
- feature.name,
169
- );
114
+ const featureDirectory = join(treeRoot ?? fallbackRoot, feature.name);
170
115
  deny(
171
116
  CONFIG_KEY,
172
117
  `Feature '${feature.name}' is set to '${feature.status}' but has no non-empty ` +
@@ -176,70 +121,64 @@ function denyIfAdvancedFeatureLacksContract(features, treeRoot) {
176
121
  }
177
122
  }
178
123
 
179
- function checkCatalogWrite(toolInput, catalogPath, treeRoot) {
124
+ function targetsCatalog(target, catalogLocations) {
125
+ const name = basename(target);
126
+ return catalogLocations.some((location) => basename(location) === name);
127
+ }
128
+
129
+ function checkCatalogWrite(
130
+ toolInput,
131
+ catalogLocations,
132
+ treeRoot,
133
+ fallbackRoot,
134
+ ) {
180
135
  const target = writtenPathOf(toolInput);
181
- if (!catalogPath || !target.includes(CATALOG_FILE_NAME)) return;
136
+ if (!target || !targetsCatalog(target, catalogLocations)) return;
182
137
 
183
- const rawContent = writtenContentOf(toolInput);
184
- const parsed = parseJsonOrNull(rawContent);
138
+ const parsed = parseJsonOrNull(writtenContentOf(toolInput));
185
139
  if (parsed === null) {
186
- // Fail-closed, not fail-open: a write whose content does not parse as JSON but
187
- // still targets the catalog is suspicious on its own -- a spec_ready/done transition
188
- // hidden behind a malformed payload must not be silently allowed through.
140
+ // Fail closed: a status transition hidden behind malformed JSON must not slip through.
189
141
  deny(
190
142
  CONFIG_KEY,
191
- `The write to ${CATALOG_FILE_NAME} does not parse as JSON (JSON.parse threw). This ` +
143
+ `The write to ${basename(target)} does not parse as JSON (JSON.parse threw). This ` +
192
144
  'is a syntax problem in the content being written, not a missing-field one — check ' +
193
145
  'for a trailing comma, unquoted key, or unclosed bracket in what you are about to ' +
194
146
  'write. No filesystem exploration needed; the payload itself is the thing to fix.',
195
147
  );
196
148
  }
197
149
  const features = Array.isArray(parsed?.features) ? parsed.features : [];
198
- denyIfAdvancedFeatureLacksContract(features, treeRoot);
150
+ denyIfAdvancedFeatureLacksContract(features, treeRoot, fallbackRoot);
199
151
  }
200
152
 
201
153
  function isExemptDelegation(toolInput, prompt, exemptSubagents) {
202
- const subagentType = String(
203
- toolInput.subagent_type ?? toolInput.subagentType ?? '',
204
- ).toLowerCase();
205
- if (exemptSubagents.includes(subagentType)) return true;
206
- if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
207
- if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
208
- if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
209
- return false;
154
+ if (isSubagentNamedIn(toolInput, exemptSubagents)) return true;
155
+ if (!DEMANDING_LEVELS.has(operativeLevelOf(prompt))) return true;
156
+ return !isImplementationRequest(prompt);
210
157
  }
211
158
 
212
- function featureNamesCitedIn(prompt) {
213
- const pattern = /\.(?:ai)[\\/]features[\\/]([\w.@-]+)/gi;
214
- const names = [];
215
- let match;
216
- while ((match = pattern.exec(prompt)) !== null) names.push(match[1]);
217
- return names;
218
- }
219
-
220
- function checkDelegation(toolInput, treeRoot, exemptSubagents) {
159
+ function checkDelegation(toolInput, treeRoot, fallbackRoot, exemptSubagents) {
221
160
  const prompt = delegationPromptOf(toolInput);
222
161
  if (!prompt.trim()) return;
223
162
  if (isExemptDelegation(toolInput, prompt, exemptSubagents)) return;
224
163
 
225
164
  const citedFeatures = featureNamesCitedIn(prompt);
226
- if (citedFeatures.length === 0) return; // no citation: nothing this gate can check
165
+ if (citedFeatures.length === 0) return;
227
166
 
228
167
  const missing = citedFeatures.filter(
229
168
  (feature) => !contractExistsFor(treeRoot, feature),
230
169
  );
231
- if (missing.length === citedFeatures.length) {
232
- const root = treeRoot ?? join(process.cwd(), '.ai', 'features');
233
- const fileList = missing
234
- .map((feature) => `${feature} -> ${join(root, feature)}/`)
235
- .join('\n ');
236
- deny(
237
- CONFIG_KEY,
238
- `This implementation delegation cites feature(s) with no contract on disk:\n ${fileList}\n` +
239
- `Write ONE of these files in each directory above (any one is enough): ` +
240
- `${CONTRACT_FILES.join(', ')}. Then relaunch.`,
241
- );
242
- }
170
+ if (missing.length === 0) return;
171
+
172
+ const root = treeRoot ?? fallbackRoot;
173
+ const fileList = missing
174
+ .map((feature) => `${feature} -> ${join(root, feature)}/`)
175
+ .join('\n ');
176
+ deny(
177
+ CONFIG_KEY,
178
+ `This implementation delegation cites feature(s) with no contract on disk:\n ${fileList}\n` +
179
+ `Write ONE of these files in each directory above (any one is enough): ` +
180
+ `${CONTRACT_FILES.join(', ')}. Then relaunch.`,
181
+ );
243
182
  }
244
183
 
245
184
  runGate(
@@ -252,26 +191,34 @@ runGate(
252
191
  exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
253
192
  },
254
193
  },
255
- ({ toolName, toolInput, parameters }) => {
194
+ ({ toolName, toolInput, parameters, cwd }) => {
256
195
  const isWrite = toolInGroups(toolName, ['write']);
257
196
  const isDelegation = toolInGroups(toolName, ['delegation']);
258
197
  if (!isWrite && !isDelegation) return;
259
198
 
199
+ const root = projectRootOf(cwd) ?? cwd;
260
200
  const catalogLocations =
261
- parameters.catalogLocations ?? DEFAULT_CATALOG_LOCATIONS;
201
+ parameters.catalogLocations.length > 0
202
+ ? parameters.catalogLocations
203
+ : DEFAULT_CATALOG_LOCATIONS;
262
204
  const catalogPath = findCatalog(
205
+ root,
263
206
  catalogLocations,
264
207
  isWrite ? writtenPathOf(toolInput) : null,
265
208
  );
266
- if (!catalogPath) return; // no SDD harness adopted: stay silent
267
-
268
- const treeRoot = contractTreeRootFor(catalogPath);
269
-
270
- if (isWrite) checkCatalogWrite(toolInput, catalogPath, treeRoot);
271
- if (isDelegation) {
272
- const exemptSubagents =
273
- parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
274
- checkDelegation(toolInput, treeRoot, exemptSubagents);
275
- }
209
+ if (!catalogPath) return;
210
+
211
+ const treeRoot = contractTreeRootFor(root, catalogPath);
212
+ const fallbackRoot = join(root, '.ai', FEATURES_DIRECTORY);
213
+
214
+ if (isWrite)
215
+ checkCatalogWrite(toolInput, catalogLocations, treeRoot, fallbackRoot);
216
+ if (isDelegation)
217
+ checkDelegation(
218
+ toolInput,
219
+ treeRoot,
220
+ fallbackRoot,
221
+ parameters.exemptSubagents,
222
+ );
276
223
  },
277
224
  );
@@ -1,51 +1,53 @@
1
- // staged-lint — denies a `git commit` when the files YOU staged fail lint. It never lints the
2
- // whole repo (that would block you on pre-existing debt in files you never touched); it lints
3
- // ONLY the staged files what this commit actually introduces so a clean commit is never
4
- // held hostage by someone else's old lint errors, and you can never add NEW lint debt through
5
- // your own change.
6
- //
7
- // justification: no existing gate covers this. lint-commit runs the project's lint script over
8
- // the WHOLE project and blocks if anything fails — too broad: a repo with pre-existing debt can
9
- // never commit. This gate scopes the check to the staged set, so it enforces "your change is
10
- // clean" without demanding "the whole repo is clean".
11
- //
12
- // ── How it works ─────────────────────────────────────────────────────────────────────
13
- // On a real `git commit`, it asks git for the staged files (`git diff --cached --name-only
14
- // --diff-filter=ACM`), keeps the ones with a lintable extension, and runs the project's lint
15
- // command over exactly those paths. Non-zero exit → deny with the lint output. No staged
16
- // lintable file, or no lint command declared → allow (nothing to enforce, never invented).
17
- //
18
- // ── What a project can configure (params) ───────────────────────────────────────────
19
- // lintCommand the command to run; the staged paths are appended as arguments. Default
20
- // null → autodetect eslint (`npx eslint`) when the project has an eslint
21
- // config, else allow (never invent a linter).
22
- // lintExtensions which staged file extensions are linted. Default js/mjs/cjs/ts/tsx/jsx.
23
- // lintTimeoutMs how long the lint run may take before it counts as a failure.
24
- // escapeHatch substring in the command that skips the check for one commit. Default
25
- // '[skip-lint]'.
26
- //
27
- // ── Fail-safe shape ──────────────────────────────────────────────────────────────────
28
- // Not a commit: allow (silent). No staged lintable files, or no linter: allow. The lint run
29
- // cannot spawn: deny (a commit gate that cannot evaluate must not silently permit). Lint exits
30
- // non-zero: deny with the tail of its output. Lint exits zero: allow.
1
+ // staged-lint — denies a `git commit` when the files THIS commit carries fail lint. It never
2
+ // lints the whole repo (pre-existing debt in untouched files must not block you); it lints
3
+ // what the commit will contain: the staged set plus whatever a `git add …` earlier in the
4
+ // same command line or a `-a` flag stages before the commit runs. Only a command segment that
5
+ // RUNS git commit counts; a `--dry-run` writes nothing. No lintable file, or no linter
6
+ // declared or detected: allow — this gate never invents a linter. A lint run that cannot
7
+ // spawn is denied: a commit gate must not silently permit.
31
8
 
32
9
  import { spawnSync } from 'node:child_process';
33
10
  import { existsSync } from 'node:fs';
34
11
  import { extname, join } from 'node:path';
35
- import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
12
+ import { projectRootOf, readJsonOrNull } from '../../lib/config.mjs';
13
+ import {
14
+ effectiveCommitFiles,
15
+ isDryRunCommit,
16
+ isGitCommit,
17
+ normalizeGitCommand,
18
+ } from '../../lib/git.mjs';
19
+ import {
20
+ deny,
21
+ runGate,
22
+ shellCommandOf,
23
+ toolInGroups,
24
+ } from '../../lib/hook-io.mjs';
36
25
 
37
26
  const GATE_ID = 'staged-lint';
38
27
  const CONFIG_KEY = 'blockCommitWithStagedLintErrors';
39
28
 
40
- const SHELL_GROUPS = ['shell'];
41
29
  const DEFAULT_LINT_TIMEOUT_MS = 60000;
42
30
  const OUTPUT_TAIL_LINES = 20;
43
31
  const DEFAULT_ESCAPE_HATCH = '[skip-lint]';
44
- const DEFAULT_LINT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'];
32
+ const DEFAULT_LINT_EXTENSIONS = [
33
+ '.js',
34
+ '.mjs',
35
+ '.cjs',
36
+ '.ts',
37
+ '.mts',
38
+ '.cts',
39
+ '.tsx',
40
+ '.jsx',
41
+ '.vue',
42
+ '.svelte',
43
+ ];
45
44
  const ESLINT_CONFIG_FILES = [
46
45
  'eslint.config.mjs',
47
46
  'eslint.config.js',
48
47
  'eslint.config.cjs',
48
+ 'eslint.config.ts',
49
+ 'eslint.config.mts',
50
+ 'eslint.config.cts',
49
51
  '.eslintrc',
50
52
  '.eslintrc.js',
51
53
  '.eslintrc.cjs',
@@ -54,74 +56,57 @@ const ESLINT_CONFIG_FILES = [
54
56
  '.eslintrc.yaml',
55
57
  ];
56
58
 
57
- // Same git-global-option normalization lint-commit and no-coauthor use, so
58
- // `git -C /repo -c x=y commit` reduces to `git commit` before the pattern runs.
59
- const GIT_OPTION_WITH_VALUE = String.raw`(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env)(?:\s+|=)\S+`;
60
- const GIT_FLAG_OPTION = String.raw`--(?:paginate|no-pager|bare|no-optional-locks)|-p`;
61
- const GIT_GLOBAL_OPTION_PATTERN = new RegExp(
62
- String.raw`\bgit\s+(?:${GIT_OPTION_WITH_VALUE}|${GIT_FLAG_OPTION})\s+`,
63
- 'i',
64
- );
65
- const GIT_COMMIT_PATTERN = /\bgit\s+commit\b/i;
66
-
67
- function normalizeGitOptions(command) {
68
- let previous;
69
- let normalized = command;
70
- do {
71
- previous = normalized;
72
- normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
73
- } while (normalized !== previous);
74
- return normalized;
75
- }
76
-
77
- function isGitCommit(command) {
78
- return GIT_COMMIT_PATTERN.test(normalizeGitOptions(command));
59
+ // ── The commit segment ──────────────────────────────────────────────────────────────
60
+ const SEGMENT_SEPARATOR = /;|&&|\|\||\||\n/;
61
+ const ARGUMENT_PATTERN = /"([^"]*)"|'([^']*)'|(\S+)/g;
62
+ const GIT_BINARY_TOKEN = /(?:^|[\\/])git(?:\.exe)?$/i;
63
+ const WRAPPER_TOKEN = /^(?:\w+=\S*|command|sudo|env)$/i;
64
+
65
+ function runsGit(segment) {
66
+ const tokens = [];
67
+ for (const match of segment.matchAll(ARGUMENT_PATTERN))
68
+ tokens.push(match[1] ?? match[2] ?? match[3]);
69
+ while (tokens.length > 0 && WRAPPER_TOKEN.test(tokens[0])) tokens.shift();
70
+ return tokens.length > 0 && GIT_BINARY_TOKEN.test(tokens[0]);
79
71
  }
80
72
 
81
- function commandTextFrom(toolInput) {
82
- return String(toolInput.CommandLine ?? toolInput.command ?? '');
73
+ function runsRealCommit(command) {
74
+ return String(command)
75
+ .split(SEGMENT_SEPARATOR)
76
+ .some((segment) => {
77
+ if (!runsGit(segment)) return false;
78
+ const normalized = normalizeGitCommand(segment);
79
+ return isGitCommit(normalized) && !isDryRunCommit(normalized);
80
+ });
83
81
  }
84
82
 
85
- /** The staged files added/copied/modified (not deleted), with an extension in scope. */
86
- function stagedFilesToLint(cwd, lintExtensions) {
87
- const result = spawnSync(
88
- 'git',
89
- ['diff', '--cached', '--name-only', '--diff-filter=ACM'],
90
- { cwd, encoding: 'utf8' },
91
- );
92
- if (result.status !== 0 || !result.stdout) return [];
93
- return result.stdout
94
- .split(/\r?\n/)
95
- .map((line) => line.trim())
96
- .filter((line) => line.length > 0)
97
- .filter((file) => lintExtensions.includes(extname(file).toLowerCase()));
83
+ // ── Lint ────────────────────────────────────────────────────────────────────────────
84
+ function normalizeExtension(extension) {
85
+ const text = String(extension).trim().toLowerCase();
86
+ return text.startsWith('.') ? text : `.${text}`;
98
87
  }
99
88
 
100
- /** The lint command to run, or null when none can be determined (never invented). */
101
- function resolveLintCommand(cwd, lintCommandOverride) {
102
- if (lintCommandOverride) return lintCommandOverride;
103
- const hasEslint = ESLINT_CONFIG_FILES.some((name) =>
104
- existsSync(join(cwd, name)),
89
+ function resolveLintCommand(root, lintCommandOverride) {
90
+ if (typeof lintCommandOverride === 'string' && lintCommandOverride.trim())
91
+ return lintCommandOverride;
92
+ const hasEslintFile = ESLINT_CONFIG_FILES.some((name) =>
93
+ existsSync(join(root, name)),
105
94
  );
106
- return hasEslint ? 'npx eslint' : null;
95
+ const packageJson = readJsonOrNull(join(root, 'package.json'));
96
+ return hasEslintFile || packageJson?.eslintConfig ? 'npx eslint' : null;
107
97
  }
108
98
 
109
99
  function tailLines(text, count) {
110
- const lines = String(text)
100
+ return String(text)
111
101
  .split(/\r?\n/)
112
- .filter((line) => line.length > 0);
113
- return lines.slice(-count).join('\n');
102
+ .filter((line) => line.length > 0)
103
+ .slice(-count)
104
+ .join('\n');
114
105
  }
115
106
 
116
- /** The lint command with the staged paths appended (quoted), so ONLY they are linted. */
117
- function lintCommandForFiles(lintCommand, files) {
107
+ function runLintOverFiles(lintCommand, files, cwd, timeoutMs) {
118
108
  const quotedPaths = files.map((file) => `"${file}"`).join(' ');
119
- return `${lintCommand} ${quotedPaths}`;
120
- }
121
-
122
- /** Runs the lint command over the staged files; returns the spawnSync result. */
123
- function runLintOverStaged(lintCommand, files, cwd, timeoutMs) {
124
- return spawnSync(lintCommandForFiles(lintCommand, files), {
109
+ return spawnSync(`${lintCommand} ${quotedPaths}`, {
125
110
  cwd,
126
111
  shell: true,
127
112
  encoding: 'utf8',
@@ -129,25 +114,23 @@ function runLintOverStaged(lintCommand, files, cwd, timeoutMs) {
129
114
  });
130
115
  }
131
116
 
132
- /** Denies when the lint run could not spawn or reported failures; otherwise returns. */
133
117
  function denyIfLintFailed(result, lintCommand, escapeHatch) {
134
118
  if (result.error) {
135
119
  deny(
136
120
  CONFIG_KEY,
137
- `Could not run the lint command ("${lintCommand}") over the staged files: ` +
138
- `${result.error.message}. Fix the lint setup or set lintCommand/` +
121
+ `Could not run the lint command ("${lintCommand}") over the commit's files: ` +
122
+ `${result.error.message}. Fix the lint setup or set lintCommand under ` +
139
123
  `${CONFIG_KEY} in .ai/config.json.`,
140
124
  );
141
125
  }
142
126
  if (result.status !== 0) {
143
127
  const combinedOutput =
144
128
  `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
145
- const outputTail = tailLines(combinedOutput, OUTPUT_TAIL_LINES);
146
129
  deny(
147
130
  CONFIG_KEY,
148
- `The files you staged fail lint — commit blocked until your own changes are clean ` +
149
- `(the rest of the repo is not checked). Fix them, or add "${escapeHatch}" to the ` +
150
- `commit command for a deliberate exception:\n${outputTail}`,
131
+ `The files this commit carries fail lint — commit blocked until your own changes are ` +
132
+ `clean (the rest of the repo is not checked). Fix them, or add "${escapeHatch}" to ` +
133
+ `the commit command for a deliberate exception:\n${tailLines(combinedOutput, OUTPUT_TAIL_LINES)}`,
151
134
  );
152
135
  }
153
136
  }
@@ -164,24 +147,33 @@ runGate(
164
147
  escapeHatch: DEFAULT_ESCAPE_HATCH,
165
148
  },
166
149
  },
167
- ({ toolName, toolInput, parameters }) => {
168
- if (!toolInGroups(toolName, SHELL_GROUPS)) return;
150
+ ({ toolName, toolInput, parameters, cwd }) => {
151
+ if (!toolInGroups(toolName, ['shell'])) return;
169
152
 
170
- const command = commandTextFrom(toolInput);
171
- if (!isGitCommit(command)) return;
153
+ const command = shellCommandOf(toolInput);
154
+ if (!runsRealCommit(command)) return;
172
155
 
173
- const escapeHatch = parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH;
156
+ const escapeHatch = String(parameters.escapeHatch ?? '');
174
157
  if (escapeHatch && command.includes(escapeHatch)) return;
175
158
 
176
- const cwd = process.cwd();
177
- const stagedFiles = stagedFilesToLint(cwd, parameters.lintExtensions);
178
- if (stagedFiles.length === 0) return; // nothing you staged is in scope: nothing to enforce
159
+ const root = projectRootOf(cwd) ?? cwd;
160
+ const extensions = new Set(
161
+ parameters.lintExtensions.map(normalizeExtension),
162
+ );
163
+ const files = (effectiveCommitFiles(root, command) ?? []).filter((file) =>
164
+ extensions.has(extname(file).toLowerCase()),
165
+ );
166
+ if (files.length === 0) return;
179
167
 
180
- const lintCommand = resolveLintCommand(cwd, parameters.lintCommand);
181
- if (!lintCommand) return; // no linter declared/detected: never invent one
168
+ const lintCommand = resolveLintCommand(root, parameters.lintCommand);
169
+ if (!lintCommand) return;
182
170
 
183
- const timeoutMs = parameters.lintTimeoutMs ?? DEFAULT_LINT_TIMEOUT_MS;
184
- const result = runLintOverStaged(lintCommand, stagedFiles, cwd, timeoutMs);
171
+ const result = runLintOverFiles(
172
+ lintCommand,
173
+ files,
174
+ root,
175
+ parameters.lintTimeoutMs,
176
+ );
185
177
  denyIfLintFailed(result, lintCommand, escapeHatch);
186
178
  },
187
179
  );