@isentinel/hooks 1.2.0 → 1.2.3

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.
@@ -1,13 +1,18 @@
1
- import { findSourceRoot, getTransitiveDependents, isLintableFile, lint, readEditedFiles, readLintAttempts, readSettings, readStopAttempts, stopDecision, writeStopAttempts } from "../scripts/lint.mjs";
1
+ import { findSourceRoot, getTransitiveDependents, isLintableFile, lint, readEditedFiles, readLintAttempts, readSettings, readStopAttempts, restartDaemon, stopDecision, writeStopAttempts } from "../scripts/lint.mjs";
2
2
  import { n as writeStdoutJson, t as readStdinJson } from "../io.mjs";
3
3
  import { resolve } from "node:path";
4
4
  import process from "node:process";
5
5
 
6
6
  //#region hooks/lint-stop.ts
7
+ const debugLog = [];
7
8
  const settings = readSettings();
9
+ function debug(message) {
10
+ if (settings.debug) debugLog.push(message);
11
+ }
8
12
  if (!settings.lint) process.exit(0);
9
13
  const SESSION_ID = (await readStdinJson()).session_id;
10
14
  const editedFiles = readEditedFiles(SESSION_ID);
15
+ debug(`editedFiles=${JSON.stringify(editedFiles)}`);
11
16
  if (editedFiles.length === 0) process.exit(0);
12
17
  const dependents = /* @__PURE__ */ new Set();
13
18
  const seen = /* @__PURE__ */ new Set();
@@ -18,21 +23,36 @@ for (const file of editedFiles) {
18
23
  for (const dependent of getTransitiveDependents(editedFiles, sourceRoot, settings.runner)) dependents.add(dependent);
19
24
  }
20
25
  const files = [...new Set([...editedFiles, ...dependents])].filter((file) => isLintableFile(file));
26
+ debug(`lintable files: ${JSON.stringify(files)}`);
21
27
  if (files.length === 0) process.exit(0);
22
28
  const errorFiles = [];
23
- for (const file of files) if (lint(file, ["--fix"], settings) !== void 0) errorFiles.push(file);
29
+ for (const file of files) {
30
+ debug(`linting: ${file}`);
31
+ const lintResult = lint(file, ["--fix"], settings, { restart: false });
32
+ debug(`result: ${lintResult === void 0 ? "ok" : "errors"}`);
33
+ if (lintResult !== void 0) errorFiles.push(file);
34
+ }
35
+ debug(`errorFiles: ${JSON.stringify(errorFiles)}`);
36
+ debug("restartDaemon: start");
37
+ restartDaemon(settings.runner);
38
+ debug("restartDaemon: end");
24
39
  const result = stopDecision({
25
40
  errorFiles,
26
41
  lintAttempts: readLintAttempts(),
27
42
  maxLintAttempts: settings.maxLintAttempts,
28
43
  stopAttempts: readStopAttempts()
29
44
  });
30
- if (result === void 0) process.exit(0);
45
+ debug(`stopDecision: ${JSON.stringify(result)}`);
46
+ if (result === void 0) {
47
+ if (debugLog.length > 0) writeStdoutJson({ reason: `[lint-stop debug]\n${debugLog.join("\n")}` });
48
+ process.exit(0);
49
+ }
31
50
  if (result.resetStopAttempts) {
32
51
  writeStopAttempts(0);
33
52
  process.exit(0);
34
53
  }
35
54
  writeStopAttempts(readStopAttempts() + 1);
55
+ if (debugLog.length > 0) result.reason = `${result.reason ?? ""}\n\n[lint-stop debug]\n${debugLog.join("\n")}`;
36
56
  writeStdoutJson(result);
37
57
 
38
58
  //#endregion
@@ -1,15 +1,20 @@
1
1
  import { findSourceRoot, getTransitiveDependents, readEditedFiles, readLintAttempts, readSettings } from "../scripts/lint.mjs";
2
2
  import { isTypeCheckable, readTypecheckStopAttempts, resolveTsconfig, runTypeCheck, typecheckStopDecision, writeTypecheckStopAttempts } from "../scripts/type-check.mjs";
3
3
  import { n as writeStdoutJson, t as readStdinJson } from "../io.mjs";
4
- import { join, resolve } from "node:path";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
5
  import process from "node:process";
6
6
 
7
7
  //#region hooks/type-check-stop.ts
8
+ const debugLog = [];
8
9
  const settings = readSettings();
10
+ function debug(message) {
11
+ if (settings.debug) debugLog.push(message);
12
+ }
9
13
  if (!settings.typecheck) process.exit(0);
10
14
  const SESSION_ID = (await readStdinJson()).session_id;
11
15
  const PROJECT_ROOT = process.env["CLAUDE_PROJECT_DIR"] ?? process.cwd();
12
16
  const editedFiles = readEditedFiles(SESSION_ID);
17
+ debug(`editedFiles=${JSON.stringify(editedFiles)}`);
13
18
  if (editedFiles.length === 0) process.exit(0);
14
19
  const allFiles = new Set(editedFiles);
15
20
  const seenRoots = /* @__PURE__ */ new Set();
@@ -20,28 +25,40 @@ for (const file of editedFiles) {
20
25
  for (const dependent of getTransitiveDependents(editedFiles, sourceRoot, settings.runner)) allFiles.add(dependent);
21
26
  }
22
27
  const files = [...allFiles].filter((file) => isTypeCheckable(file));
28
+ debug(`type-checkable files: ${JSON.stringify(files)}`);
23
29
  if (files.length === 0) process.exit(0);
24
30
  const errorFiles = [];
25
31
  for (const file of files) {
26
- const tsconfig = resolveTsconfig(join(PROJECT_ROOT, file), PROJECT_ROOT);
32
+ const absolutePath = isAbsolute(file) ? file : join(PROJECT_ROOT, file);
33
+ debug(`file=${file} isAbsolute=${String(isAbsolute(file))} absolutePath=${absolutePath}`);
34
+ const tsconfig = resolveTsconfig(absolutePath, PROJECT_ROOT);
35
+ debug(`tsconfig=${String(tsconfig)}`);
27
36
  if (tsconfig === void 0) continue;
28
37
  const output = runTypeCheck(tsconfig, settings.runner, settings.typecheckArgs);
29
38
  if (output !== void 0) {
30
- if (/error TS/i.test(output)) errorFiles.push(file);
39
+ const hasErrors = /error TS/i.test(output);
40
+ debug(`typecheck ${file}: ${hasErrors ? "errors" : "ok"}`);
41
+ if (hasErrors) errorFiles.push(file);
31
42
  }
32
43
  }
44
+ debug(`errorFiles: ${JSON.stringify(errorFiles)}`);
33
45
  const result = typecheckStopDecision({
34
46
  errorFiles,
35
47
  lintAttempts: readLintAttempts(),
36
48
  maxLintAttempts: settings.maxLintAttempts,
37
49
  stopAttempts: readTypecheckStopAttempts()
38
50
  });
39
- if (result === void 0) process.exit(0);
51
+ debug(`stopDecision: ${JSON.stringify(result)}`);
52
+ if (result === void 0) {
53
+ if (debugLog.length > 0) writeStdoutJson({ reason: `[type-check-stop debug]\n${debugLog.join("\n")}` });
54
+ process.exit(0);
55
+ }
40
56
  if (result.resetStopAttempts) {
41
57
  writeTypecheckStopAttempts(0);
42
58
  process.exit(0);
43
59
  }
44
60
  writeTypecheckStopAttempts(readTypecheckStopAttempts() + 1);
61
+ if (debugLog.length > 0) result.reason = `${result.reason ?? ""}\n\n[type-check-stop debug]\n${debugLog.join("\n")}`;
45
62
  writeStdoutJson(result);
46
63
 
47
64
  //#endregion
@@ -3,6 +3,7 @@ import { t as PostToolUseHookOutput } from "../events.mjs";
3
3
  //#region scripts/lint.d.ts
4
4
  interface LintSettings {
5
5
  cacheBust: Array<string>;
6
+ debug: boolean;
6
7
  eslint: boolean;
7
8
  lint: boolean;
8
9
  maxLintAttempts: number;
@@ -49,10 +50,14 @@ declare function clearCache(): void;
49
50
  declare function invalidateCacheEntries(filePaths: Array<string>): void;
50
51
  declare function runOxlint(filePath: string, extraFlags?: Array<string>, runner?: string): string | undefined;
51
52
  declare function runEslint(filePath: string, extraFlags?: Array<string>, runner?: string): string | undefined;
52
- declare function restartDaemon(runner?: string): void;
53
+ declare function restartDaemon(runner?: string, warmupFile?: string): void;
53
54
  declare function formatErrors(output: string): Array<string>;
54
- declare function buildHookOutput(filePath: string, errors: Array<string>): PostToolUseHookOutput;
55
- declare function lint(filePath: string, extraFlags?: Array<string>, settings?: LintSettings): PostToolUseHookOutput | undefined;
55
+ declare function buildHookOutput(filePath: string, errors: Array<string>, debugInfo?: string): PostToolUseHookOutput;
56
+ declare function lint(filePath: string, extraFlags?: Array<string>, settings?: LintSettings, {
57
+ restart
58
+ }?: {
59
+ restart?: boolean | undefined;
60
+ }): PostToolUseHookOutput | undefined;
56
61
  declare function main(targets: Array<string>, settings?: LintSettings): void;
57
62
  //#endregion
58
63
  export { DEFAULT_CACHE_BUST, DependencyGraph, LintSettings, StopDecisionResult, buildHookOutput, clearCache, clearEditedFiles, clearLintAttempts, clearStopAttempts, findEntryPoints, findSourceRoot, formatErrors, getChangedFiles, getDependencyGraph, getTransitiveDependents, invalidateCacheEntries, invertGraph, isLintableFile, isProtectedFile, lint, main, readEditedFiles, readLintAttempts, readSettings, readStopAttempts, resolveBustFiles, restartDaemon, runEslint, runOxlint, shouldBustCache, stopDecision, writeEditedFile, writeLintAttempts, writeStopAttempts };
@@ -1,10 +1,33 @@
1
1
  import { createFromFile } from "file-entry-cache";
2
- import { execSync, spawn } from "node:child_process";
2
+ import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
3
3
  import { existsSync, globSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
4
5
  import { dirname, join, relative, resolve } from "node:path";
5
6
  import process from "node:process";
6
7
 
7
8
  //#region scripts/lint.ts
9
+ function spawnBackground(script, extraEnvironment = {}) {
10
+ const scriptFile = join(tmpdir(), `.eslint_bg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.cjs`);
11
+ writeFileSync(scriptFile, script);
12
+ const environment = {
13
+ ...process.env,
14
+ ...extraEnvironment
15
+ };
16
+ if (process.platform === "win32") spawnSync("powershell.exe", [
17
+ "-NoProfile",
18
+ "-Command",
19
+ `Start-Process -FilePath 'node' -ArgumentList '${scriptFile}' -WindowStyle Hidden`
20
+ ], {
21
+ env: environment,
22
+ stdio: "ignore",
23
+ windowsHide: true
24
+ });
25
+ else spawn("node", [scriptFile], {
26
+ detached: true,
27
+ env: environment,
28
+ stdio: "ignore"
29
+ }).unref();
30
+ }
8
31
  const PROTECTED_PATTERNS = [
9
32
  "eslint.config.",
10
33
  "oxlint.config.",
@@ -17,6 +40,9 @@ function isProtectedFile(filename) {
17
40
  const LINT_STATE_PATH = ".claude/state/lint-attempts.json";
18
41
  const STOP_STATE_PATH = ".claude/state/stop-attempts.json";
19
42
  const EDITED_FILES_PATH = ".claude/state/edited-files.json";
43
+ const RESTART_DAEMON_LOG = ".claude/state/restartDaemon.log";
44
+ const IS_RESTART_DAEMON_DEBUG = false;
45
+ const CLAUDE_PID_PATH = ".claude/state/claude-pid";
20
46
  function readEditedFiles(sessionId) {
21
47
  if (!existsSync(EDITED_FILES_PATH)) return [];
22
48
  try {
@@ -74,6 +100,43 @@ function getTransitiveDependents(files, sourceRoot, runner = DEFAULT_SETTINGS.ru
74
100
  const originals = new Set(files.map((file) => relative(sourceRoot, resolve(file)).replaceAll("\\", "/")));
75
101
  return [...visited].filter((file) => !originals.has(file)).map((file) => join(sourceRoot, file));
76
102
  }
103
+ function getClaudePid() {
104
+ const ssePort = process.env["CLAUDE_CODE_SSE_PORT"] ?? "";
105
+ const cacheFile = ssePort.length > 0 ? `${CLAUDE_PID_PATH}-${ssePort}` : CLAUDE_PID_PATH;
106
+ if (existsSync(cacheFile)) try {
107
+ const cached = readFileSync(cacheFile, "utf-8").trim();
108
+ const pid = Number(cached);
109
+ process.kill(pid, 0);
110
+ return cached;
111
+ } catch {}
112
+ if (process.platform === "win32") try {
113
+ const result = execFileSync("powershell.exe", [
114
+ "-NoProfile",
115
+ "-Command",
116
+ `
117
+ $currentPid = ${process.ppid}
118
+ while ($currentPid -and $currentPid -ne 0) {
119
+ $p = Get-CimInstance Win32_Process -Filter "ProcessId=$currentPid" -Property Name,ParentProcessId -ErrorAction SilentlyContinue
120
+ if (-not $p) { break }
121
+ if ($p.Name -eq 'claude.exe') { Write-Output $currentPid; exit }
122
+ $currentPid = $p.ParentProcessId
123
+ }`
124
+ ], {
125
+ encoding: "utf-8",
126
+ stdio: [
127
+ "pipe",
128
+ "pipe",
129
+ "pipe"
130
+ ],
131
+ timeout: 5e3
132
+ }).trim();
133
+ if (result.length > 0) {
134
+ mkdirSync(dirname(cacheFile), { recursive: true });
135
+ writeFileSync(cacheFile, result);
136
+ return result;
137
+ }
138
+ } catch {}
139
+ }
77
140
  const ESLINT_CACHE_PATH = ".eslintcache";
78
141
  const DEFAULT_EXTENSIONS = [
79
142
  ".ts",
@@ -96,6 +159,7 @@ const DEFAULT_MAX_LINT_ATTEMPTS = 1;
96
159
  const DEFAULT_MAX_STOP_ATTEMPTS = 1;
97
160
  const DEFAULT_SETTINGS = {
98
161
  cacheBust: [...DEFAULT_CACHE_BUST],
162
+ debug: false,
99
163
  eslint: true,
100
164
  lint: true,
101
165
  maxLintAttempts: DEFAULT_MAX_LINT_ATTEMPTS,
@@ -113,6 +177,7 @@ function readSettings() {
113
177
  const maxLintAttempts = maxAttemptsRaw !== void 0 ? Number(maxAttemptsRaw) : DEFAULT_MAX_LINT_ATTEMPTS;
114
178
  return {
115
179
  cacheBust: [...DEFAULT_CACHE_BUST, ...userPatterns],
180
+ debug: fields.get("debug") === "true",
116
181
  eslint: fields.get("eslint") !== "false",
117
182
  lint: fields.get("lint") !== "false",
118
183
  maxLintAttempts,
@@ -258,9 +323,11 @@ function runOxlint(filePath, extraFlags = [], runner = DEFAULT_SETTINGS.runner)
258
323
  function runEslint(filePath, extraFlags = [], runner = DEFAULT_SETTINGS.runner) {
259
324
  const flags = ["--cache", ...extraFlags].join(" ");
260
325
  try {
326
+ const claudePid = getClaudePid();
261
327
  execSync(`${runner} eslint_d ${flags} "${filePath}"`, {
262
328
  env: {
263
329
  ...process.env,
330
+ ...claudePid !== void 0 && { ESLINT_D_PPID: claudePid },
264
331
  ESLINT_IN_EDITOR: "true"
265
332
  },
266
333
  stdio: "pipe"
@@ -274,45 +341,91 @@ function runEslint(filePath, extraFlags = [], runner = DEFAULT_SETTINGS.runner)
274
341
  return stdout || stderr || message;
275
342
  }
276
343
  }
277
- function restartDaemon(runner = DEFAULT_SETTINGS.runner) {
278
- const [command = "pnpm", ...prefixArgs] = runner.split(/\s+/);
279
- const child = spawn(command, [
280
- ...prefixArgs,
281
- "eslint_d",
282
- "restart"
283
- ], {
284
- detached: true,
285
- env: {
286
- ...process.env,
344
+ function restartDaemon(runner = DEFAULT_SETTINGS.runner, warmupFile) {
345
+ const markerFile = void 0;
346
+ try {
347
+ const restartAndWarmupScript = `
348
+ const { execSync } = require("child_process");
349
+ const fs = require("fs");
350
+
351
+ const runner = ${JSON.stringify(runner)};
352
+ const warmupFile = ${JSON.stringify(warmupFile)};
353
+ const debug = ${IS_RESTART_DAEMON_DEBUG};
354
+ const logFile = ${JSON.stringify(RESTART_DAEMON_LOG)};
355
+
356
+ try {
357
+ // Run restart
358
+ if (debug) {
359
+ fs.appendFileSync(logFile, \`restart: start \${new Date().toISOString()}\\n\`);
360
+ }
361
+ execSync(\`\${runner} eslint_d restart\`, {
362
+ env: { ...process.env, ESLINT_D_PPID: process.env.ESLINT_D_PPID, ESLINT_IN_EDITOR: "true" },
363
+ stdio: "pipe",
364
+ });
365
+ if (debug) {
366
+ fs.appendFileSync(logFile, \`restart: end \${new Date().toISOString()}\\n\`);
367
+ }
368
+
369
+ // Run warmup lint if file provided
370
+ if (warmupFile) {
371
+ if (debug) {
372
+ fs.appendFileSync(logFile, \`warmup: start \${warmupFile} \${new Date().toISOString()}\\n\`);
373
+ }
374
+ execSync(\`\${runner} eslint_d "\${warmupFile}"\`, {
375
+ env: { ...process.env, ESLINT_D_PPID: process.env.ESLINT_D_PPID, ESLINT_IN_EDITOR: "true" },
376
+ stdio: "pipe",
377
+ });
378
+ if (debug) {
379
+ fs.appendFileSync(logFile, \`warmup: end \${new Date().toISOString()}\\n\`);
380
+ }
381
+ }
382
+ } catch (err) {
383
+ if (debug) {
384
+ fs.appendFileSync(logFile, \`error: \${err.message} \${new Date().toISOString()}\\n\`);
385
+ }
386
+ } finally {
387
+ try { fs.unlinkSync(process.argv[1]); } catch {}
388
+ }
389
+ `;
390
+ const claudePid = getClaudePid();
391
+ spawnBackground(restartAndWarmupScript, {
392
+ ...claudePid !== void 0 && { ESLINT_D_PPID: claudePid },
287
393
  ESLINT_IN_EDITOR: "true"
288
- },
289
- stdio: "pipe"
290
- });
291
- child.stderr.on("data", (data) => {
292
- process.stderr.write(`[eslint_d restart] ${data.toString()}`);
293
- });
294
- child.on("error", (error) => {
295
- process.stderr.write(`[eslint_d restart] failed: ${error.message}\n`);
296
- });
297
- child.unref();
394
+ });
395
+ if (markerFile !== void 0) spawnBackground(`
396
+ const fs = require("fs");
397
+ const logFile = ${JSON.stringify(RESTART_DAEMON_LOG)};
398
+
399
+ setTimeout(() => {
400
+ try {
401
+ fs.appendFileSync(logFile, \`daemon exit: \${new Date().toISOString()}\\n\`);
402
+ } catch {}
403
+ try { fs.unlinkSync(process.argv[1]); } catch {}
404
+ process.exit(0);
405
+ }, 10000);
406
+ `);
407
+ } catch (err) {
408
+ const message = err instanceof Error ? err.message : String(err);
409
+ process.stderr.write(`[eslint_d restart] ${message}\n`);
410
+ }
298
411
  }
299
412
  function formatErrors(output) {
300
413
  return output.split("\n").filter((line) => /error/i.test(line)).slice(0, MAX_ERRORS);
301
414
  }
302
- function buildHookOutput(filePath, errors) {
415
+ function buildHookOutput(filePath, errors, debugInfo = "") {
303
416
  const errorText = errors.join("\n");
304
417
  const isTruncated = errors.length >= MAX_ERRORS;
305
- const userMessage = `⚠️ Lint errors in ${filePath}:\n${errorText}${isTruncated ? "\n..." : ""}`;
418
+ const userMessage = `⚠️ Lint errors in ${filePath}:\n${errorText}${isTruncated ? "\n..." : ""}${debugInfo}`;
306
419
  return {
307
420
  decision: void 0,
308
421
  hookSpecificOutput: {
309
- additionalContext: `⚠️ Lint errors in ${filePath}:\n${errorText}${isTruncated ? "\n(run lint to view more)" : ""}`,
422
+ additionalContext: `⚠️ Lint errors in ${filePath}:\n${errorText}${isTruncated ? "\n(run lint to view more)" : ""}${debugInfo}`,
310
423
  hookEventName: "PostToolUse"
311
424
  },
312
425
  systemMessage: userMessage
313
426
  };
314
427
  }
315
- function lint(filePath, extraFlags = [], settings = DEFAULT_SETTINGS) {
428
+ function lint(filePath, extraFlags = [], settings = DEFAULT_SETTINGS, { restart = true } = {}) {
316
429
  if (shouldBustCache(settings.cacheBust)) clearCache();
317
430
  else invalidateCacheEntries(findImporters(filePath, settings.runner));
318
431
  const outputs = [];
@@ -324,10 +437,15 @@ function lint(filePath, extraFlags = [], settings = DEFAULT_SETTINGS) {
324
437
  const output = runEslint(filePath, extraFlags, settings.runner);
325
438
  if (output !== void 0) outputs.push(output);
326
439
  }
327
- if (settings.eslint) restartDaemon(settings.runner);
440
+ let debugInfo = "";
441
+ if (settings.eslint && restart) {
442
+ const startTime = Date.now();
443
+ restartDaemon(settings.runner, filePath);
444
+ Date.now() - startTime;
445
+ }
328
446
  if (outputs.length > 0) {
329
447
  const errors = formatErrors(outputs.join("\n"));
330
- if (errors.length > 0) return buildHookOutput(filePath, errors);
448
+ if (errors.length > 0) return buildHookOutput(filePath, errors, debugInfo);
331
449
  }
332
450
  }
333
451
  function main(targets, settings = DEFAULT_SETTINGS) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isentinel/hooks",
3
- "version": "1.2.0",
3
+ "version": "1.2.3",
4
4
  "description": "Claude Code hooks for linting and type-checking TypeScript projects",
5
5
  "keywords": [
6
6
  "claude",
@@ -42,12 +42,13 @@
42
42
  "*.{ts,mts}": "eslint --fix"
43
43
  },
44
44
  "dependencies": {
45
- "eslint_d": "^14.3.0",
45
+ "eslint_d": "^15.0.0",
46
46
  "file-entry-cache": "^11.1.2",
47
47
  "get-tsconfig": "^4.13.6",
48
48
  "madge": "^8.0.0"
49
49
  },
50
50
  "devDependencies": {
51
+ "@antfu/ni": "^29.0.0",
51
52
  "@clack/prompts": "^0.11.0",
52
53
  "@constellos/claude-code-kit": "^0.4.0",
53
54
  "@isentinel/eslint-config": "5.0.0-beta.8",
@@ -81,6 +82,9 @@
81
82
  "engines": {
82
83
  "node": ">=24.11.0"
83
84
  },
85
+ "publishConfig": {
86
+ "provenance": true
87
+ },
84
88
  "scripts": {
85
89
  "build": "tsdown",
86
90
  "lint": "eslint --cache",