diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 018f0384..b6be6fb4 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "commitlore", "displayName": "CommitLore", - "version": "1.2.3", + "version": "1.2.4", "description": "Recorded decisions from git history, delivered to the agent before it edits. Constraints, alternatives already ruled out, and warnings left by whoever was here last.", "author": { "name": "MongLong0214", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 15cc39b1..da184b0d 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "commitlore", - "version": "1.2.3", + "version": "1.2.4", "description": "Decision memory from Git history, with verified capture for coding sessions.", "author": { "name": "MongLong0214", diff --git a/CHANGELOG.md b/CHANGELOG.md index 8150c2b6..2bf2c752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ Release notes for 1.0.0, 1.0.1 and 1.0.2 are on the [GitHub releases page](https://github.com/MongLong0214/commitlore/releases); they were not written here. +## 1.2.4 + +`doctor` blamed the hook that worked, and prescribed reinstalling it. + +**`hook-runtime` attributed a preserved hook's failure to commitlore's hook +(#876).** The installed stub runs the hook it preserved at install time first and +exits with that hook's code, verbatim, before commitlore is resolved. Probed as +one process, a preserved hook that called `node` by name died with 127 under +git's PATH and the row read "the hook cannot find a node interpreter", with +`commitlore hooks install` as the fix. That command rewrites only commitlore's +own file; it reported the file unchanged, and the next `doctor` failed +identically. The reporter spent several minutes repairing the wrong component, +because the diagnostic named it. + +**The preserved hook now runs on its own first**, under the same PATH-less +environment and through `sh` the way the stub invokes it. If it exits non-zero, +the row says commitlore's hook is not what failed, names the preserved hook by +path, classifies its first stderr line the same way the stub's was (node +missing, node threw, unclear), and prescribes a fix aimed at that file. Only once +it has passed does the stub run, so every remaining failure is commitlore's own +resolution and `hooks install` is once again a remedy that can move it. + +**`commit-msg-hook` inherits the fix along with the outcome.** It was the row the +reporter read: it already carried the runtime row's outcome when blocked on it, +and kept saying `hooks install` under an outcome that had just explained why +that could not help. + +Not changed: a broken preserved hook still blocks every commit. That is the +chaining contract — a hook that was rejecting commits before commitlore arrived +must keep rejecting them — and whether a hook that is broken rather than +rejecting deserves different treatment is a separate decision from a diagnostic +one. + ## 1.2.3 The capture prompt was the session, so on a long session there was no prompt. diff --git a/README.ja.md b/README.ja.md index 1ccf4a88..2fb227b1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ```
先にインストーラーを読みたいですか? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh -sh install.sh v1.2.3 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh +sh install.sh v1.2.4 # あるいはスクリプトを使わずに。スクリプトが作るチェックアウトは自分でも作れます。 -git clone --depth 1 --branch v1.2.3 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -107,13 +107,13 @@ CommitLore はその判断をコードのそばに残します。 macOS と Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 ``` Node.js 22.23.2+ と Git が必要です。スクリプトは何かを書き込む前に両方を確認します。 diff --git a/README.ko.md b/README.ko.md index c5bb0b9f..a5883f2c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ```
먼저 설치기를 읽어 보고 싶나요? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh -sh install.sh v1.2.3 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh +sh install.sh v1.2.4 # 또는 스크립트를 건너뜁니다. 스크립트가 만드는 체크아웃은 직접 만들 수 있습니다. -git clone --depth 1 --branch v1.2.3 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -107,13 +107,13 @@ CommitLore는 그 판단을 코드 곁에 보관합니다. macOS와 Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 ``` Node.js 22.23.2+와 Git이 필요합니다. 스크립트는 무엇이든 쓰기 전에 둘을 확인합니다. diff --git a/README.md b/README.md index 9c0d0634..365acbd0 100644 --- a/README.md +++ b/README.md @@ -48,18 +48,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ```
Prefer to read the installer first? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh -sh install.sh v1.2.3 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh +sh install.sh v1.2.4 # Or skip the script: the checkout it makes is one you can make yourself. -git clone --depth 1 --branch v1.2.3 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -109,13 +109,13 @@ preserve, not for narrating every change. macOS and Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 ``` Requires Node.js 22.23.2+ and Git. The script checks both before it writes anything. diff --git a/README.zh-CN.md b/README.zh-CN.md index 337ab275..8790036f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ```
想先阅读安装器吗? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh -sh install.sh v1.2.3 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh +sh install.sh v1.2.4 # 或者跳过脚本:它创建的检出,你自己也能创建。 -git clone --depth 1 --branch v1.2.3 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -105,13 +105,13 @@ CommitLore 把那份判断留在代码旁边。 macOS 和 Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 ``` 需要 Node.js 22.23.2+ 和 Git。脚本会在写入任何内容前检查两者。 diff --git a/dist/commands/doctor/checks/capture-commit-msg-hook.js b/dist/commands/doctor/checks/capture-commit-msg-hook.js index a71a5fd0..63e07625 100644 --- a/dist/commands/doctor/checks/capture-commit-msg-hook.js +++ b/dist/commands/doctor/checks/capture-commit-msg-hook.js @@ -67,6 +67,11 @@ export const checkHook = (ctx, runtime) => { ]; if (runtime !== undefined && runtime.status !== 'ok') { const inherited = `installed at ${path}; ${targetDetail}; outcome: ${runtime.detail}`; + // This row is blocked on the runtime's finding, so the fix that moves that + // finding is the only fix that moves this one. Prescribing `hooks install` + // here regardless is how a preserved hook's failure came to carry a remedy + // that reinstalls the hook which worked (#876). + const inheritedFix = runtime.fix ?? install; // A skipped runtime would make this row a skip too, and a skip has to name // a reason. Inheriting the runtime's is the only answer that stays true — // this row did not look for the same reason that one did not. The branch is @@ -74,12 +79,12 @@ export const checkHook = (ctx, runtime) => { // written out rather than cast away so that adding one cannot silently // produce a reasonless skip here. if (runtime.status === 'skipped') { - return blocked(runtime, check(id, category, title, 'skipped', inherited, install, false, false, { + return blocked(runtime, check(id, category, title, 'skipped', inherited, inheritedFix, false, false, { evidence: { ...hookEvidence, runtime_status: runtime.status }, skipReason: runtime.skipReason ?? 'nothing_applicable', })); } - return blocked(runtime, check(id, category, title, runtime.status, inherited, install, false, undefined, { evidence: { ...hookEvidence, runtime_status: runtime.status } })); + return blocked(runtime, check(id, category, title, runtime.status, inherited, inheritedFix, false, undefined, { evidence: { ...hookEvidence, runtime_status: runtime.status } })); } return problems.length === 0 ? check(id, category, title, 'ok', `installed at ${path}; ${targetDetail}`, null, false, undefined, { evidence: hookEvidence }) diff --git a/dist/commands/doctor/checks/capture-commit-msg-hook.js.map b/dist/commands/doctor/checks/capture-commit-msg-hook.js.map index 161f9d91..50f3a7f2 100644 --- a/dist/commands/doctor/checks/capture-commit-msg-hook.js.map +++ b/dist/commands/doctor/checks/capture-commit-msg-hook.js.map @@ -1 +1 @@ -{"version":3,"file":"capture-commit-msg-hook.js","sourceRoot":"","sources":["../../../../src/commands/doctor/checks/capture-commit-msg-hook.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"capture-commit-msg-hook.js","sourceRoot":"","sources":["../../../../src/commands/doctor/checks/capture-commit-msg-hook.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/commands/doctor/checks/capture-hook-runtime.d.ts b/dist/commands/doctor/checks/capture-hook-runtime.d.ts index 3ac266f9..8e935a1f 100644 --- a/dist/commands/doctor/checks/capture-hook-runtime.d.ts +++ b/dist/commands/doctor/checks/capture-hook-runtime.d.ts @@ -23,5 +23,11 @@ import { type DoctorCheck, type DoctorContext } from '../model.js'; * The probe message is valid, so a healthy hook exits 0. A hook that cannot find * a runtime exits non-zero having parsed nothing, which is indistinguishable * from "your message was fine" to everyone except this check. + * + * Two hooks run here, not one. The stub hands the message to the hook it + * preserved at install time before it resolves commitlore, and exits with that + * hook's code if it fails -- so the preserved hook is probed on its own first, + * and its failure is reported as its own, with a fix aimed at it. `hooks + * install` cannot move a finding about a file it does not write (#876). */ export declare const checkHookRuntime: (ctx: DoctorContext) => DoctorCheck; diff --git a/dist/commands/doctor/checks/capture-hook-runtime.js b/dist/commands/doctor/checks/capture-hook-runtime.js index dd1c96f0..512df761 100644 --- a/dist/commands/doctor/checks/capture-hook-runtime.js +++ b/dist/commands/doctor/checks/capture-hook-runtime.js @@ -4,10 +4,38 @@ * It owns execution of the installed hook under Git's environment; consumers * receive its completed row through the registry rather than importing it. */ -import { existsSync, rmSync, writeFileSync } from 'node:fs'; +import { accessSync, constants as fsConstants, existsSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir as tmpdirPath } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { CHAINED_HOOK_NAME } from '../../../hooks/commit-msg.js'; import { check, gitOptions, PROBE_MESSAGE, streamEvidence } from '../model.js'; +/** + * The stub runs `"$chained" "$@"` only when `[ -x "$chained" ]` holds, so a + * preserved hook without its execute bit is inert to git and to the stub alike. + * The same test here, so this check does not probe a file the hook will skip. + */ +const isExecutable = (path) => { + try { + accessSync(path, fsConstants.X_OK); + return true; + } + catch { + return false; + } +}; +/** + * How the stub's failure reads on its first stderr line: node was never found, + * node ran and threw, or neither. Shared between the two hooks this check runs, + * because the preserved hook fails in the same three shapes and the + * classification is about the line, not about who wrote it. + */ +const classifyFailure = (status, said) => { + if (status === 127 || /\bnode\b.*not found|ENOENT|command not found.*\bnode\b/i.test(said)) + return 'node-missing'; + if (/^\s*at\s|\.js:\d+/.test(said)) + return 'node-threw'; + return 'unclear'; +}; /** * Whether the installed hook actually runs, in the environment git gives it. * @@ -26,6 +54,12 @@ import { check, gitOptions, PROBE_MESSAGE, streamEvidence } from '../model.js'; * The probe message is valid, so a healthy hook exits 0. A hook that cannot find * a runtime exits non-zero having parsed nothing, which is indistinguishable * from "your message was fine" to everyone except this check. + * + * Two hooks run here, not one. The stub hands the message to the hook it + * preserved at install time before it resolves commitlore, and exits with that + * hook's code if it fails -- so the preserved hook is probed on its own first, + * and its failure is reported as its own, with a fix aimed at it. `hooks + * install` cannot move a finding about a file it does not write (#876). */ export const checkHookRuntime = (ctx) => { const { opts, git, spawn, env } = ctx; @@ -51,15 +85,59 @@ export const checkHookRuntime = (ctx) => { return check(id, category, title, 'ok', 'no hook installed — nothing to run', null, false, undefined, { evidence: { hook_path: hook } }); } const probe = join(tmpdirPath(), `commitlore-doctor-${String(process.pid)}.txt`); + // No node, and no PATH entry that could supply one. `git` must stay + // reachable: the hook reads its own config through it. + const hookEnv = { PATH: '/usr/bin:/bin', HOME: env['HOME'] ?? '' }; try { + // The stub runs the hook it preserved at install time first, and that + // hook's non-zero exit is the stub's exit, verbatim, before commitlore is + // reached. Probed through the stub alone, the two are one process with one + // stderr, and the row attributed a preserved hook's `node: command not + // found` to the installed hook and prescribed `hooks install` -- which + // reports the file unchanged, because the file it writes was never the one + // failing (#876). So the preserved hook runs on its own first, the way the + // stub runs it: through sh, so a script without a shebang behaves the same + // here as it does there. + const chained = join(dirname(hook), CHAINED_HOOK_NAME); + if (isExecutable(chained)) { + writeFileSync(probe, PROBE_MESSAGE); + const preserved = spawn('/bin/sh', ['-c', '"$0" "$1"', chained, probe], { + shell: false, + encoding: 'utf8', + cwd, + env: hookEnv, + }); + const exit = preserved.error === undefined ? preserved.status : null; + if (preserved.error !== undefined || exit !== 0) { + const spoke = `${preserved.stderr ?? ''}`.trim(); + const said = preserved.error?.message ?? (spoke.split('\n')[0] ?? ''); + const shape = preserved.error === undefined ? classifyFailure(exit, said) : 'unclear'; + const because = shape === 'node-missing' + ? `it calls node by name and git's PATH has none: ${said}` + : shape === 'node-threw' + ? `its node process ran but threw (exit ${String(exit)}): ${said}` + : `it exited ${String(exit ?? 'unavailable')} under the restricted PATH: ${said || 'no output'}`; + return check(id, category, title, 'fail', `commitlore's hook is not what failed. It runs the hook it preserved first, and that hook -- ${chained} -- stops the commit before commitlore is reached: ${because}. That file was this repository's commit-msg hook before commitlore was installed; \`hooks install\` rewrites only commitlore's own and leaves it as it is`, shape === 'node-missing' + ? `edit ${chained} to call node by absolute path (or remove it if it is no longer wanted)` + : `fix or remove ${chained}`, false, undefined, { + evidence: { + hook_path: hook, + chained_hook_path: chained, + exit_code: String(exit ?? 'unavailable'), + ...(preserved.error === undefined ? {} : { error: preserved.error.message }), + ...streamEvidence('stderr', preserved.stderr ?? ''), + }, + }); + } + } + // A commit-msg hook may rewrite the message it is given; the probe is + // written again so the stub reads the same bytes the preserved hook did. writeFileSync(probe, PROBE_MESSAGE); const run = spawn('/bin/sh', [hook, probe], { shell: false, encoding: 'utf8', cwd, - // No node, and no PATH entry that could supply one. `git` must stay - // reachable: the hook reads its own config through it. - env: { PATH: '/usr/bin:/bin', HOME: env['HOME'] ?? '' }, + env: hookEnv, }); if (run.error !== undefined) { return check(id, category, title, 'fail', `could not run the hook: ${run.error.message}`, fix, false, undefined, { @@ -74,9 +152,11 @@ export const checkHookRuntime = (ctx) => { if (run.status !== 0) { const spoke = `${run.stderr ?? ''}`.trim(); const said = spoke.split('\n')[0] ?? ''; - const nodeMissing = run.status === 127 || - /\bnode\b.*not found|ENOENT|command not found.*\bnode\b/i.test(said); - const nodeThrew = /^\s*at\s|\.js:\d+/.test(said); + // The preserved hook, if any, has already exited 0 on its own above, so + // whatever follows is commitlore's resolution failing, not a hand-off. + const shape = classifyFailure(run.status, said); + const nodeMissing = shape === 'node-missing'; + const nodeThrew = shape === 'node-threw'; // The stub says this when the recorded pair resolved and the containment // check refused it: present, executable, and under a tree this install // did not record. An upgrade produces it, because `commitlore.bin` follows diff --git a/dist/commands/doctor/checks/capture-hook-runtime.js.map b/dist/commands/doctor/checks/capture-hook-runtime.js.map index f888fe44..d4d4d40d 100644 --- a/dist/commands/doctor/checks/capture-hook-runtime.js.map +++ b/dist/commands/doctor/checks/capture-hook-runtime.js.map @@ -1 +1 @@ -{"version":3,"file":"capture-hook-runtime.js","sourceRoot":"","sources":["../../../../src/commands/doctor/checks/capture-hook-runtime.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAE1C,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,cAAc,EAAuD,MAAM,aAAa,CAAC;AAEpI;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,GAAkB,EAAe,EAAE;IAClE,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,GAAG,CAAC;IACtC,MAAM,KAAK,GAAG,cAAc,CAAC;IAC7B,MAAM,EAAE,GAAG,cAAc,CAAC;IAC1B,MAAM,QAAQ,GAAa,SAAS,CAAC;IACrC,MAAM,GAAG,GAAG,0BAA0B,CAAC;IACvC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;IAEtC,MAAM,OAAO,GAAG,GAAG,CAAC,CAAC,WAAW,EAAE,YAAY,EAAE,kBAAkB,CAAC,EAAE,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC;IACvF,IAAI,OAAO,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;QACvB,OAAO,KAAK,CACV,EAAE,EACF,QAAQ,EACR,KAAK,EACL,MAAM,EACN,6BAA6B,EAC7B,GAAG,EACH,KAAK,EACL,SAAS,EACT;YACE,QAAQ,EAAE;gBACR,SAAS,EAAE,aAAa;gBACxB,SAAS,EAAE,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC;gBAC/B,GAAG,cAAc,CAAC,QAAQ,EAAE,OAAO,CAAC,MAAM,CAAC;aAC5C;SACF,CACF,CAAC;IACJ,CAAC;IAED,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IACjD,2EAA2E;IAC3E,uBAAuB;IACvB,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QACtB,OAAO,KAAK,CACV,EAAE,EACF,QAAQ,EACR,KAAK,EACL,IAAI,EACJ,oCAAoC,EACpC,IAAI,EACJ,KAAK,EACL,SAAS,EACT,EAAE,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE,CAClC,CAAC;IACJ,CAAC;IAED,MAAM,KAAK,GAAG,IAAI,CAAC,UAAU,EAAE,EAAE,qBAAqB,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IACjF,IAAI,CAAC;QACH,aAAa,CAAC,KAAK,EAAE,aAAa,CAAC,CAAC;QACpC,MAAM,GAAG,GAAG,KAAK,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE;YAC1C,KAAK,EAAE,KAAK;YACZ,QAAQ,EAAE,MAAM;YAChB,GAAG;YACH,oEAAoE;YACpE,uDAAuD;YACvD,GAAG,EAAE,EAAE,IAAI,EAAE,eAAe,EAAE,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE;SACxD,CAAC,CAAC;QAEH,IAAI,GAAG,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;YAC5B,OAAO,KAAK,CACV,EAAE,EACF,QAAQ,EACR,KAAK,EACL,MAAM,EACN,2BAA2B,GAAG,CAAC,KAAK,CAAC,OAAO,EAAE,EAC9C,GAAG,EACH,KAAK,EACL,SAAS,EACT;gBACE,QAAQ,EAAE;oBACR,SAAS,EAAE,IAAI;oBACf,SAAS,EAAE,MAAM,CAAC,GAAG,CAAC,MAAM,IAAI,aAAa,CAAC;oBAC9C,KAAK,EAAE,GAAG,CAAC,KAAK,CAAC,OAAO;oBACxB,GAAG,cAAc,CAAC,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC;iBACxC;aACF,CACF,CAAC;QACJ,CAAC;QACD,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACrB,MAAM,KAAK,GAAG,GAAG,GAAG,CAAC,MAAM,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC;YAC3C,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;YACxC,MAAM,WAAW,GACf,GAAG,CAAC,MAAM,KAAK,GAAG;gBAClB,yDAAyD,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACvE,MAAM,SAAS,GAAG,mBAAmB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACjD,yEAAyE;YACzE,uEAAuE;YACvE,2EAA2E;YAC3E,uEAAuE;YACvE,2EAA2E;YAC3E,2BAA2B;YAC3B,EAAE;YACF,wEAAwE;YACxE,+DAA+D;YAC/D,uEAAuE;YACvE,kDAAkD;YAClD,MAAM,kBAAkB,GAAG,sCAAsC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YAE9E,IAAI,MAAc,CAAC;YACnB,IAAI,kBAAkB,EAAE,CAAC;gBACvB,qEAAqE;gBACrE,0DAA0D;gBAC1D,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBAClF,MAAM,GAAG,2GAA2G,KAAK,gEAAgE,CAAC;YAC5L,CAAC;iBAAM,IAAI,WAAW,EAAE,CAAC;gBACvB,MAAM,GAAG,0DAA0D,IAAI,IAAI,QAAQ,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;YAC5G,CAAC;iBAAM,IAAI,SAAS,EAAE,CAAC;gBACrB,MAAM,GAAG,+CAA+C,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,IAAI,EAAE,CAAC;YACzF,CAAC;iBAAM,CAAC;gBACN,MAAM,GAAG,mBAAmB,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,+CAA+C,IAAI,IAAI,WAAW,EAAE,CAAC;YACrH,CAAC;YACD,OAAO,KAAK,CACV,EAAE,EACF,QAAQ,EACR,KAAK,EACL,MAAM,EACN,MAAM,EACN,GAAG,EACH,KAAK,EACL,SAAS,EACT;gBACE,QAAQ,EAAE;oBACR,SAAS,EAAE,IAAI;oBACf,SAAS,EAAE,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC;oBAC7B,GAAG,cAAc,CAAC,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC;iBACxC;aACF,CACF,CAAC;QACJ,CAAC;QACD,OAAO,KAAK,CACV,EAAE,EACF,QAAQ,EACR,KAAK,EACL,IAAI,EACJ,kDAAkD,EAClD,IAAI,EACJ,KAAK,EACL,SAAS,EACT,EAAE,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,EAAE,CAClD,CAAC;IACJ,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,KAAK,CACV,EAAE,EACF,QAAQ,EACR,KAAK,EACL,MAAM,EACN,6BAA6B,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,EACrF,GAAG,EACH,KAAK,EACL,SAAS,EACT;YACE,QAAQ,EAAE;gBACR,SAAS,EAAE,IAAI;gBACf,SAAS,EAAE,aAAa;gBACxB,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;gBAC7D,GAAG,cAAc,CAAC,QAAQ,EAAE,EAAE,CAAC;aAChC;SACF,CACF,CAAC;IACJ,CAAC;YAAS,CAAC;QACT,MAAM,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IACjC,CAAC;AACH,CAAC,CAAC"} \ No newline at end of file +{"version":3,"file":"capture-hook-runtime.js","sourceRoot":"","sources":["../../../../src/commands/doctor/checks/capture-hook-runtime.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/commitlore.mjs b/dist/commitlore.mjs index 805633f5..a0cfcb6b 100755 --- a/dist/commitlore.mjs +++ b/dist/commitlore.mjs @@ -18365,7 +18365,7 @@ var register3 = (program3) => { import { execFileSync } from "node:child_process"; import { mkdtempSync, rmSync as rmSync6, writeFileSync as writeFileSync14, mkdirSync as mkdirSync10 } from "node:fs"; import { tmpdir as tmpdir2 } from "node:os"; -import { dirname as dirname11, join as join17, resolve as resolve17 } from "node:path"; +import { dirname as dirname12, join as join17, resolve as resolve17 } from "node:path"; // src/demo/fixture.ts var targetPath = "src/pricing.ts"; @@ -19782,6 +19782,7 @@ var checkHook = (ctx, runtime) => { ]; if (runtime !== void 0 && runtime.status !== "ok") { const inherited = `installed at ${path2}; ${targetDetail}; outcome: ${runtime.detail}`; + const inheritedFix = runtime.fix ?? install; if (runtime.status === "skipped") { return blocked( runtime, @@ -19791,7 +19792,7 @@ var checkHook = (ctx, runtime) => { title2, "skipped", inherited, - install, + inheritedFix, false, false, { @@ -19809,7 +19810,7 @@ var checkHook = (ctx, runtime) => { title2, runtime.status, inherited, - install, + inheritedFix, false, void 0, { evidence: { ...hookEvidence, runtime_status: runtime.status } } @@ -19840,9 +19841,22 @@ var checkHook = (ctx, runtime) => { }; // src/commands/doctor/checks/capture-hook-runtime.ts -import { existsSync as existsSync11, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "node:fs"; +import { accessSync as accessSync2, constants as fsConstants, existsSync as existsSync11, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "node:fs"; import { tmpdir as tmpdirPath } from "node:os"; -import { join as join8, resolve as resolve9 } from "node:path"; +import { dirname as dirname7, join as join8, resolve as resolve9 } from "node:path"; +var isExecutable = (path2) => { + try { + accessSync2(path2, fsConstants.X_OK); + return true; + } catch { + return false; + } +}; +var classifyFailure = (status, said) => { + if (status === 127 || /\bnode\b.*not found|ENOENT|command not found.*\bnode\b/i.test(said)) return "node-missing"; + if (/^\s*at\s|\.js:\d+/.test(said)) return "node-threw"; + return "unclear"; +}; var checkHookRuntime = (ctx) => { const { opts, git: git2, spawn: spawn3, env } = ctx; const title2 = "hook runtime"; @@ -19885,15 +19899,50 @@ var checkHookRuntime = (ctx) => { ); } const probe = join8(tmpdirPath(), `commitlore-doctor-${String(process.pid)}.txt`); + const hookEnv = { PATH: "/usr/bin:/bin", HOME: env["HOME"] ?? "" }; try { + const chained = join8(dirname7(hook), CHAINED_HOOK_NAME); + if (isExecutable(chained)) { + writeFileSync5(probe, PROBE_MESSAGE); + const preserved = spawn3("/bin/sh", ["-c", '"$0" "$1"', chained, probe], { + shell: false, + encoding: "utf8", + cwd, + env: hookEnv + }); + const exit = preserved.error === void 0 ? preserved.status : null; + if (preserved.error !== void 0 || exit !== 0) { + const spoke = `${preserved.stderr ?? ""}`.trim(); + const said = preserved.error?.message ?? (spoke.split("\n")[0] ?? ""); + const shape = preserved.error === void 0 ? classifyFailure(exit, said) : "unclear"; + const because = shape === "node-missing" ? `it calls node by name and git's PATH has none: ${said}` : shape === "node-threw" ? `its node process ran but threw (exit ${String(exit)}): ${said}` : `it exited ${String(exit ?? "unavailable")} under the restricted PATH: ${said || "no output"}`; + return check( + id2, + category2, + title2, + "fail", + `commitlore's hook is not what failed. It runs the hook it preserved first, and that hook -- ${chained} -- stops the commit before commitlore is reached: ${because}. That file was this repository's commit-msg hook before commitlore was installed; \`hooks install\` rewrites only commitlore's own and leaves it as it is`, + shape === "node-missing" ? `edit ${chained} to call node by absolute path (or remove it if it is no longer wanted)` : `fix or remove ${chained}`, + false, + void 0, + { + evidence: { + hook_path: hook, + chained_hook_path: chained, + exit_code: String(exit ?? "unavailable"), + ...preserved.error === void 0 ? {} : { error: preserved.error.message }, + ...streamEvidence("stderr", preserved.stderr ?? "") + } + } + ); + } + } writeFileSync5(probe, PROBE_MESSAGE); const run = spawn3("/bin/sh", [hook, probe], { shell: false, encoding: "utf8", cwd, - // No node, and no PATH entry that could supply one. `git` must stay - // reachable: the hook reads its own config through it. - env: { PATH: "/usr/bin:/bin", HOME: env["HOME"] ?? "" } + env: hookEnv }); if (run.error !== void 0) { return check( @@ -19918,8 +19967,9 @@ var checkHookRuntime = (ctx) => { if (run.status !== 0) { const spoke = `${run.stderr ?? ""}`.trim(); const said = spoke.split("\n")[0] ?? ""; - const nodeMissing = run.status === 127 || /\bnode\b.*not found|ENOENT|command not found.*\bnode\b/i.test(said); - const nodeThrew = /^\s*at\s|\.js:\d+/.test(said); + const shape = classifyFailure(run.status, said); + const nodeMissing = shape === "node-missing"; + const nodeThrew = shape === "node-threw"; const containmentRefused2 = /outside the install this hook trusts/.test(spoke); let detail; if (containmentRefused2) { @@ -20998,7 +21048,7 @@ var checkDirectiveTrustMode = (ctx) => { // src/mcp/lifecycle.ts import { appendFileSync, mkdirSync as mkdirSync4, readFileSync as readFileSync14, statSync as statSync6, writeFileSync as writeFileSync7, writeSync } from "node:fs"; -import { dirname as dirname7, join as join10, resolve as resolve10 } from "node:path"; +import { dirname as dirname8, join as join10, resolve as resolve10 } from "node:path"; var MAX_BYTES = 64 * 1024; var LIFECYCLE_FILE = "mcp-lifecycle.log"; var lifecyclePath = (cwd = process.cwd()) => { @@ -21019,7 +21069,7 @@ var write = (cwd, line2) => { try { const path2 = lifecyclePath(cwd); if (path2 === null) return; - mkdirSync4(dirname7(path2), { recursive: true }); + mkdirSync4(dirname8(path2), { recursive: true }); appendFileSync(path2, `${line2} `); trim(path2); @@ -22035,7 +22085,7 @@ var checkInstallationIntegrity = (_ctx) => { import { spawn as spawn2, spawnSync as spawnSync6 } from "node:child_process"; import { mkdirSync as mkdirSync5, readFileSync as readFileSync15, renameSync as renameSync4, rmSync as rmSync3, writeFileSync as writeFileSync8 } from "node:fs"; import { homedir as homedir2, tmpdir } from "node:os"; -import { dirname as dirname8, join as join12 } from "node:path"; +import { dirname as dirname9, join as join12 } from "node:path"; // src/core/release-version.ts var RELEASE_TAG = /^v?(\d+)\.(\d+)\.(\d+)$/; @@ -22106,7 +22156,7 @@ var readCache = (path2) => { }; var writeCache = (path2, entry) => { try { - mkdirSync5(dirname8(path2), { recursive: true }); + mkdirSync5(dirname9(path2), { recursive: true }); const scratch = `${path2}.${process.pid}.tmp`; writeFileSync8(scratch, `${JSON.stringify(entry)} `, "utf8"); @@ -22914,7 +22964,7 @@ import { unlinkSync as unlinkSync5, writeFileSync as writeFileSync12 } from "node:fs"; -import { basename as basename2, dirname as dirname9, join as join14, resolve as resolve15 } from "node:path"; +import { basename as basename2, dirname as dirname10, join as join14, resolve as resolve15 } from "node:path"; // src/hooks/post-commit.ts import { createHash as createHash7, randomBytes as randomBytes5 } from "node:crypto"; @@ -23536,7 +23586,7 @@ var resolveHooksDir = (cwd) => { } return resolve15(cwd, result.stdout.trim()); }; -var isExecutable = (path2) => { +var isExecutable2 = (path2) => { try { return (statSync7(path2).mode & 73) !== 0; } catch { @@ -23564,7 +23614,7 @@ var readHookStatus = (cwd = process.cwd()) => { state: readHookState(hookPath), chainedPath, chained: existsSync19(chainedPath), - chainedExecutable: isExecutable(chainedPath), + chainedExecutable: isExecutable2(chainedPath), recordedTarget: readRecordedHookTarget(cwd) }; }; @@ -23592,9 +23642,9 @@ var resolveEntryForRecord = (entry, cwd) => { return null; }; var versionFreeEntryFor = (bundle) => { - const versionDir = dirname9(dirname9(bundle)); + const versionDir = dirname10(dirname10(bundle)); if (!/^v\d/.test(basename2(versionDir))) return null; - const candidate = join14(dirname9(versionDir), "current", "dist", "commitlore.mjs"); + const candidate = join14(dirname10(versionDir), "current", "dist", "commitlore.mjs"); try { return realpathSync4(candidate) === realpathSync4(bundle) ? candidate : null; } catch { @@ -23942,14 +23992,14 @@ var performUpgrade = (tag, deps) => { // src/core/agents-guidance.ts import { existsSync as existsSync20, readFileSync as readFileSync22, renameSync as renameSync9, rmSync as rmSync5, statSync as statSync8, writeFileSync as writeFileSync13 } from "node:fs"; -import { basename as basename3, dirname as dirname10, join as join16, resolve as resolve16 } from "node:path"; +import { basename as basename3, dirname as dirname11, join as join16, resolve as resolve16 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; var AGENTS_SECTION_BEGIN = ""; var AGENTS_SECTION_END = ""; var messageOf6 = (error2) => error2 instanceof Error ? error2.message : String(error2); var shippedAgentsPath = () => { const source = fileURLToPath2(import.meta.url); - const here = dirname10(source); + const here = dirname11(source); return basename3(here) === "dist" ? resolve16(here, "..", "AGENTS.md") : resolve16(here, "..", "..", "AGENTS.md"); }; var readCommitloreAgentsSection = () => { @@ -24567,12 +24617,12 @@ var runDemo = async (opts = {}) => { if (tmpResolved === userCwd || tmpResolved.startsWith(userCwd + "/") || userCwd.startsWith(tmpResolved + "/")) { throw new Error("demo: temporary directory overlaps with user repository \u2014 aborting"); } - git(["init", "--quiet", "--template=", "--initial-branch=main", tmpDir], dirname11(tmpDir)); + git(["init", "--quiet", "--template=", "--initial-branch=main", tmpDir], dirname12(tmpDir)); git(["config", "user.name", "CommitLore Demo"], tmpDir); git(["config", "user.email", "demo@commitlore.example"], tmpDir); git(["config", "commit.gpgsign", "false"], tmpDir); const targetFullPath = join17(tmpDir, targetPath); - mkdirSync10(dirname11(targetFullPath), { recursive: true }); + mkdirSync10(dirname12(targetFullPath), { recursive: true }); writeFileSync14(targetFullPath, "export const calculatePrice = () => {};\n"); git(["add", "."], tmpDir); git(["commit", "-m", predecessorCommitMessage], tmpDir); @@ -25040,7 +25090,7 @@ var register15 = (program3) => { import { spawnSync as spawnSync9 } from "node:child_process"; import { copyFileSync, existsSync as existsSync22, mkdirSync as mkdirSync11, readFileSync as readFileSync26, renameSync as renameSync10, statSync as statSync9, writeFileSync as writeFileSync17 } from "node:fs"; import { homedir as homedir4 } from "node:os"; -import { basename as basename4, dirname as dirname12, join as join18, resolve as resolve19 } from "node:path"; +import { basename as basename4, dirname as dirname13, join as join18, resolve as resolve19 } from "node:path"; // src/core/hermes-config.ts import { relative as relative2, resolve as resolve18, sep as sep3 } from "node:path"; @@ -25321,7 +25371,7 @@ var backupPathFor = (configPath) => { } }; var atomicallyWrite = (path2, contents, mode) => { - const temporary = join18(dirname12(path2), `.${basename4(path2)}.commitlore-${process.pid}.tmp`); + const temporary = join18(dirname13(path2), `.${basename4(path2)}.commitlore-${process.pid}.tmp`); try { if (mode === void 0) writeFileSync17(temporary, contents, "utf8"); else writeFileSync17(temporary, contents, { encoding: "utf8", mode }); @@ -25367,7 +25417,7 @@ var runHermesInstall = (options = {}) => { const dataRoot2 = options.dataRoot ?? join18(dataHome, "commitlore"); const versionedSkills = join18(dataRoot2, `v${runtimeIdentity().version}`, "hermes", "skills"); const skillsDir = options.skillsDir ?? (existsSync22(versionedSkills) ? versionedSkills : installedPath("hermes", "skills")); - const detected = options.detected ?? (existsSync22(dirname12(configPath)) || commandExists("hermes")); + const detected = options.detected ?? (existsSync22(dirname13(configPath)) || commandExists("hermes")); const report = []; const verified = []; if (!detected) { @@ -25399,7 +25449,7 @@ var runHermesInstall = (options = {}) => { } if (edit.added.length > 0) { try { - mkdirSync11(dirname12(configPath), { recursive: true }); + mkdirSync11(dirname13(configPath), { recursive: true }); if (existsSync22(configPath)) { const backup = backupPathFor(configPath); copyFileSync(configPath, backup); @@ -25539,7 +25589,7 @@ var register17 = (program3) => { // src/commands/inject.ts import { readFileSync as readFileSync27, realpathSync as realpathSync6 } from "node:fs"; -import { basename as basename5, dirname as dirname13, isAbsolute as isAbsolute3, join as join19, relative as relative3, resolve as resolve20, sep as sep4 } from "node:path"; +import { basename as basename5, dirname as dirname14, isAbsolute as isAbsolute3, join as join19, relative as relative3, resolve as resolve20, sep as sep4 } from "node:path"; // src/core/inject.ts import { createHash as createHash9 } from "node:crypto"; @@ -25950,7 +26000,7 @@ var canonical = (target) => { const real = realpathSync6(current); return tail.length === 0 ? real : join19(real, ...tail); } catch { - const parent = dirname13(current); + const parent = dirname14(current); if (parent === current) return absolute; tail.unshift(basename5(current)); current = parent; @@ -26111,8 +26161,8 @@ var register18 = (program3) => { }; // src/commands/installer-hosts.ts -import { accessSync as accessSync2, constants as constants2, existsSync as existsSync23, mkdirSync as mkdirSync12, renameSync as renameSync11, statSync as statSync10, unlinkSync as unlinkSync6, writeFileSync as writeFileSync18, readFileSync as readFileSync28 } from "node:fs"; -import { delimiter as delimiter2, dirname as dirname14, extname, isAbsolute as isAbsolute4, join as join20 } from "node:path"; +import { accessSync as accessSync3, constants as constants2, existsSync as existsSync23, mkdirSync as mkdirSync12, renameSync as renameSync11, statSync as statSync10, unlinkSync as unlinkSync6, writeFileSync as writeFileSync18, readFileSync as readFileSync28 } from "node:fs"; +import { delimiter as delimiter2, dirname as dirname15, extname, isAbsolute as isAbsolute4, join as join20 } from "node:path"; import { randomUUID } from "node:crypto"; import { spawnSync as spawnSync10 } from "node:child_process"; var INSTALLER_HOSTS_SCHEMA = "commitlore_installer_hosts.v1"; @@ -26137,8 +26187,8 @@ var commandOf = (format, entry) => { var entryFor = (format, wrapper) => format === "json-mcp" ? { type: "local", command: [wrapper, "mcp"], enabled: true } : { command: wrapper, args: ["mcp"] }; var atomicTemporaryName = (target, unique) => `.${target.split(/[/\\]/).pop() ?? target}.commitlore-${unique}.tmp`; var atomicJsonWrite = (path2, value) => { - mkdirSync12(dirname14(path2), { recursive: true }); - const temporary = join20(dirname14(path2), atomicTemporaryName(path2, `${process.pid}-${randomUUID()}`)); + mkdirSync12(dirname15(path2), { recursive: true }); + const temporary = join20(dirname15(path2), atomicTemporaryName(path2, `${process.pid}-${randomUUID()}`)); try { writeFileSync18(temporary, `${JSON.stringify(value, null, 2)} `, { encoding: "utf8", mode: 384 }); @@ -26227,8 +26277,8 @@ command = ${escaped} args = ["mcp"] `; try { - mkdirSync12(dirname14(path2), { recursive: true }); - const temporary = join20(dirname14(path2), atomicTemporaryName(path2, `${process.pid}-${randomUUID()}`)); + mkdirSync12(dirname15(path2), { recursive: true }); + const temporary = join20(dirname15(path2), atomicTemporaryName(path2, `${process.pid}-${randomUUID()}`)); try { writeFileSync18(temporary, next, { encoding: "utf8", mode: 384 }); if (process.env.COMMITLORE_INSTALLER_TEST_INTERRUPT_WRITE === "1") throw new Error("interrupted before atomic rename"); @@ -26259,7 +26309,7 @@ var executableExtensions = (command) => { var isExecutableFile2 = (path2) => { try { if (statSync10(path2, { throwIfNoEntry: false })?.isFile() !== true) return false; - accessSync2(path2, constants2.X_OK); + accessSync3(path2, constants2.X_OK); return true; } catch { return false; diff --git a/install.ps1 b/install.ps1 index 055f8561..24558bcb 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,8 +1,8 @@ <# Installs commitlore from source on Windows, for any agent that is not Claude Code. - irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1 | iex - & ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3 + irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1 | iex + & ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4 Claude Code users do not need this script. The repository is itself a plugin marketplace (ADR-0011), so two /plugin commands register the MCP server, the diff --git a/install.sh b/install.sh index f77ed7cb..c69df874 100755 --- a/install.sh +++ b/install.sh @@ -1,8 +1,8 @@ #!/bin/sh # Installs commitlore from source, for any agent that is not Claude Code. # -# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3 +# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4 # # **Claude Code users do not need this script.** The repository is itself a # plugin marketplace (ADR-0011), so two `/plugin` commands register the MCP diff --git a/installer/canonical-artifact.json b/installer/canonical-artifact.json index f0c599be..06375734 100644 --- a/installer/canonical-artifact.json +++ b/installer/canonical-artifact.json @@ -15,10 +15,10 @@ "tsconfig.json", "src" ], - "sha256": "7a52859972c6d27bceb02a6385ada26d043a3074040dd543e1b8aa363a57adb8" + "sha256": "647fdb182d701fe945822747cdf93142fa1834ba9f663e438e71b8a58117d979" }, "artifact": { - "sha256": "840230caf73d42cfd29a2be799d204f15f9aa544448fcb4e6d735bedc1ab0847", + "sha256": "9254b0587193cda00301cc8347daf77f8ec93339af6ae79d1cfb4ccfa7d67286", "files": [ { "path": "dist/cli.d.ts", @@ -98,23 +98,23 @@ }, { "path": "dist/commands/doctor/checks/capture-commit-msg-hook.js", - "sha256": "bd84069551cd556778ae5c1bb63603b316a54ee5ec275a6fab48ec660a038ef6" + "sha256": "a73e61a8306fdc5662d2277db6ba910686795e890f4d74f80288ebfdb6d8c1cc" }, { "path": "dist/commands/doctor/checks/capture-commit-msg-hook.js.map", - "sha256": "b74dae3a78f029358b9373a80d04bfdab499a5d7085b9c6cf24ffb7e7992c593" + "sha256": "43aa3b25291294f104ecec701783bfa2c66dc222d9e7bf767b5fd932b57c8a02" }, { "path": "dist/commands/doctor/checks/capture-hook-runtime.d.ts", - "sha256": "9e62c9cbb4e61fa29c665e736a3f2089255b35872fc462412b95a326c2a3effd" + "sha256": "23e63a052183772de052a4ad17acf055e3bd729470f61d286a137225ef54502e" }, { "path": "dist/commands/doctor/checks/capture-hook-runtime.js", - "sha256": "9c667bd16d77fa003d5506c555a9cd69b00c3e5a312283ccc5f00d6bc6178b50" + "sha256": "94cad831b61540a1091f3ac8b59ca9c7385bc1928e0c36b89c29eb2fa2e892ee" }, { "path": "dist/commands/doctor/checks/capture-hook-runtime.js.map", - "sha256": "5a45d54178c0defbccc3d69b57e0d1d3d5dbfc3b05873dd7d7547ae4228a5f75" + "sha256": "e71f3739c57dfcaad23f75e26d7e1e929ad7265f410928d9aa24f6def2dffbb0" }, { "path": "dist/commands/doctor/checks/capture-pending-backlog.d.ts", @@ -622,7 +622,7 @@ }, { "path": "dist/commitlore.mjs", - "sha256": "f9fbb5b0753abd257b66d28a4f5056e3e76a905ff4df3f979730ee216a84a7fb" + "sha256": "d800ef0f2ddcd255eaf56a8d42d37d6647a83bef890ab2cda5dc4d3bd6e325fe" }, { "path": "dist/core/agent-configs.d.ts", diff --git a/package-lock.json b/package-lock.json index 282256ab..5730f28a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "commitlore", - "version": "1.2.3", + "version": "1.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "commitlore", - "version": "1.2.3", + "version": "1.2.4", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index e3d0c2a1..b1b7b646 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "commitlore", - "version": "1.2.3", + "version": "1.2.4", "description": "Git-native, lifecycle-aware decision memory for coding agents", "license": "MIT", "private": true, diff --git a/server.json b/server.json index 503946f5..6e4f9cb6 100644 --- a/server.json +++ b/server.json @@ -8,7 +8,7 @@ "source": "github" }, "websiteUrl": "https://github.com/MongLong0214/commitlore#readme", - "version": "1.2.3", + "version": "1.2.4", "_meta": { "io.modelcontextprotocol.registry/publisher-provided": { "registryFit": "Distribution is a tagged git checkout plus a Claude Code plugin marketplace (ADR-0011 registry-free git distribution, ADR-0026 no compiled executables and no uploaded release asset), so no official package type applies and this record relies on websiteUrl plus publisher metadata.", @@ -18,8 +18,8 @@ "/plugin marketplace add MongLong0214/commitlore", "/plugin install commitlore@commitlore" ], - "installer": "curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3", - "release": "https://github.com/MongLong0214/commitlore/releases/tag/v1.2.3" + "installer": "curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4", + "release": "https://github.com/MongLong0214/commitlore/releases/tag/v1.2.4" }, "runtime": { "transport": "stdio", diff --git a/src/commands/doctor/checks/capture-commit-msg-hook.ts b/src/commands/doctor/checks/capture-commit-msg-hook.ts index dc7471f9..73f3cbf1 100644 --- a/src/commands/doctor/checks/capture-commit-msg-hook.ts +++ b/src/commands/doctor/checks/capture-commit-msg-hook.ts @@ -115,6 +115,11 @@ export const checkHook = (ctx: DoctorContext, runtime?: DoctorCheck): DoctorChec ]; if (runtime !== undefined && runtime.status !== 'ok') { const inherited = `installed at ${path}; ${targetDetail}; outcome: ${runtime.detail}`; + // This row is blocked on the runtime's finding, so the fix that moves that + // finding is the only fix that moves this one. Prescribing `hooks install` + // here regardless is how a preserved hook's failure came to carry a remedy + // that reinstalls the hook which worked (#876). + const inheritedFix = runtime.fix ?? install; // A skipped runtime would make this row a skip too, and a skip has to name // a reason. Inheriting the runtime's is the only answer that stays true — // this row did not look for the same reason that one did not. The branch is @@ -130,7 +135,7 @@ export const checkHook = (ctx: DoctorContext, runtime?: DoctorCheck): DoctorChec title, 'skipped', inherited, - install, + inheritedFix, false, false, { @@ -148,7 +153,7 @@ export const checkHook = (ctx: DoctorContext, runtime?: DoctorCheck): DoctorChec title, runtime.status, inherited, - install, + inheritedFix, false, undefined, { evidence: { ...hookEvidence, runtime_status: runtime.status } }, diff --git a/src/commands/doctor/checks/capture-hook-runtime.ts b/src/commands/doctor/checks/capture-hook-runtime.ts index 9d2b018b..b7f554a2 100644 --- a/src/commands/doctor/checks/capture-hook-runtime.ts +++ b/src/commands/doctor/checks/capture-hook-runtime.ts @@ -5,12 +5,39 @@ * receive its completed row through the registry rather than importing it. */ -import { existsSync, rmSync, writeFileSync } from 'node:fs'; +import { accessSync, constants as fsConstants, existsSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir as tmpdirPath } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { CHAINED_HOOK_NAME } from '../../../hooks/commit-msg.js'; import { check, gitOptions, PROBE_MESSAGE, streamEvidence, type Category, type DoctorCheck, type DoctorContext } from '../model.js'; +/** + * The stub runs `"$chained" "$@"` only when `[ -x "$chained" ]` holds, so a + * preserved hook without its execute bit is inert to git and to the stub alike. + * The same test here, so this check does not probe a file the hook will skip. + */ +const isExecutable = (path: string): boolean => { + try { + accessSync(path, fsConstants.X_OK); + return true; + } catch { + return false; + } +}; + +/** + * How the stub's failure reads on its first stderr line: node was never found, + * node ran and threw, or neither. Shared between the two hooks this check runs, + * because the preserved hook fails in the same three shapes and the + * classification is about the line, not about who wrote it. + */ +const classifyFailure = (status: number | null, said: string): 'node-missing' | 'node-threw' | 'unclear' => { + if (status === 127 || /\bnode\b.*not found|ENOENT|command not found.*\bnode\b/i.test(said)) return 'node-missing'; + if (/^\s*at\s|\.js:\d+/.test(said)) return 'node-threw'; + return 'unclear'; +}; + /** * Whether the installed hook actually runs, in the environment git gives it. * @@ -29,6 +56,12 @@ import { check, gitOptions, PROBE_MESSAGE, streamEvidence, type Category, type D * The probe message is valid, so a healthy hook exits 0. A hook that cannot find * a runtime exits non-zero having parsed nothing, which is indistinguishable * from "your message was fine" to everyone except this check. + * + * Two hooks run here, not one. The stub hands the message to the hook it + * preserved at install time before it resolves commitlore, and exits with that + * hook's code if it fails -- so the preserved hook is probed on its own first, + * and its failure is reported as its own, with a fix aimed at it. `hooks + * install` cannot move a finding about a file it does not write (#876). */ export const checkHookRuntime = (ctx: DoctorContext): DoctorCheck => { const { opts, git, spawn, env } = ctx; @@ -77,15 +110,71 @@ export const checkHookRuntime = (ctx: DoctorContext): DoctorCheck => { } const probe = join(tmpdirPath(), `commitlore-doctor-${String(process.pid)}.txt`); + // No node, and no PATH entry that could supply one. `git` must stay + // reachable: the hook reads its own config through it. + const hookEnv = { PATH: '/usr/bin:/bin', HOME: env['HOME'] ?? '' }; try { + // The stub runs the hook it preserved at install time first, and that + // hook's non-zero exit is the stub's exit, verbatim, before commitlore is + // reached. Probed through the stub alone, the two are one process with one + // stderr, and the row attributed a preserved hook's `node: command not + // found` to the installed hook and prescribed `hooks install` -- which + // reports the file unchanged, because the file it writes was never the one + // failing (#876). So the preserved hook runs on its own first, the way the + // stub runs it: through sh, so a script without a shebang behaves the same + // here as it does there. + const chained = join(dirname(hook), CHAINED_HOOK_NAME); + if (isExecutable(chained)) { + writeFileSync(probe, PROBE_MESSAGE); + const preserved = spawn('/bin/sh', ['-c', '"$0" "$1"', chained, probe], { + shell: false, + encoding: 'utf8', + cwd, + env: hookEnv, + }); + const exit = preserved.error === undefined ? preserved.status : null; + if (preserved.error !== undefined || exit !== 0) { + const spoke = `${preserved.stderr ?? ''}`.trim(); + const said = preserved.error?.message ?? (spoke.split('\n')[0] ?? ''); + const shape = preserved.error === undefined ? classifyFailure(exit, said) : 'unclear'; + const because = + shape === 'node-missing' + ? `it calls node by name and git's PATH has none: ${said}` + : shape === 'node-threw' + ? `its node process ran but threw (exit ${String(exit)}): ${said}` + : `it exited ${String(exit ?? 'unavailable')} under the restricted PATH: ${said || 'no output'}`; + return check( + id, + category, + title, + 'fail', + `commitlore's hook is not what failed. It runs the hook it preserved first, and that hook -- ${chained} -- stops the commit before commitlore is reached: ${because}. That file was this repository's commit-msg hook before commitlore was installed; \`hooks install\` rewrites only commitlore's own and leaves it as it is`, + shape === 'node-missing' + ? `edit ${chained} to call node by absolute path (or remove it if it is no longer wanted)` + : `fix or remove ${chained}`, + false, + undefined, + { + evidence: { + hook_path: hook, + chained_hook_path: chained, + exit_code: String(exit ?? 'unavailable'), + ...(preserved.error === undefined ? {} : { error: preserved.error.message }), + ...streamEvidence('stderr', preserved.stderr ?? ''), + }, + }, + ); + } + } + + // A commit-msg hook may rewrite the message it is given; the probe is + // written again so the stub reads the same bytes the preserved hook did. writeFileSync(probe, PROBE_MESSAGE); const run = spawn('/bin/sh', [hook, probe], { shell: false, encoding: 'utf8', cwd, - // No node, and no PATH entry that could supply one. `git` must stay - // reachable: the hook reads its own config through it. - env: { PATH: '/usr/bin:/bin', HOME: env['HOME'] ?? '' }, + env: hookEnv, }); if (run.error !== undefined) { @@ -111,10 +200,11 @@ export const checkHookRuntime = (ctx: DoctorContext): DoctorCheck => { if (run.status !== 0) { const spoke = `${run.stderr ?? ''}`.trim(); const said = spoke.split('\n')[0] ?? ''; - const nodeMissing = - run.status === 127 || - /\bnode\b.*not found|ENOENT|command not found.*\bnode\b/i.test(said); - const nodeThrew = /^\s*at\s|\.js:\d+/.test(said); + // The preserved hook, if any, has already exited 0 on its own above, so + // whatever follows is commitlore's resolution failing, not a hand-off. + const shape = classifyFailure(run.status, said); + const nodeMissing = shape === 'node-missing'; + const nodeThrew = shape === 'node-threw'; // The stub says this when the recorded pair resolved and the containment // check refused it: present, executable, and under a tree this install // did not record. An upgrade produces it, because `commitlore.bin` follows diff --git a/test/doctor.test.ts b/test/doctor.test.ts index ad80e0e2..c66f8488 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -42,7 +42,7 @@ import { POLICY_FILE_NAME } from '../src/core/capture-policy.js'; import { REQUIRE_SIGNED_DIRECTIVE_KEY } from '../src/core/trusted-authors.js'; // The real stub T-202 installs — doctor must recognize that exact file, so the // fixture is the installer's own output rather than a lookalike. -import { HOOK_MARKER, commitMsgStub } from '../src/hooks/commit-msg.js'; +import { CHAINED_HOOK_NAME, HOOK_MARKER, commitMsgStub } from '../src/hooks/commit-msg.js'; import { CLAUDE_HOOK_MARKER, claudeSettingsPath, @@ -655,6 +655,96 @@ describe('doctor: hook runtime', () => { expect(runtime?.detail).toMatch(/unclear|cannot determine/i); expect(runtime?.detail).not.toContain('carries no node'); }); + + /** + * #876. The stub runs the hook it preserved at install time first and exits + * with that hook's code, so a preserved hook that calls `node` by name dies + * with 127 before commitlore is reached. Probed as one process, the row read + * that as commitlore's hook failing and prescribed `hooks install`, which + * reported the file unchanged and left the failure exactly where it was. The + * row has to name the file that produced the exit and offer a fix that can + * move it. + */ + const chainedPath = (repo: string): string => join(dirname(hookPath(repo)), CHAINED_HOOK_NAME); + + it('names the preserved hook, not the installed one, when the preserved hook cannot find node', () => { + const repo = initRepo('doctor-runtime-chained-no-node'); + installedHook(repo); + // The shape of the reporter's hook: a repository's own commit-msg calling + // node by name, which is fine on an interactive PATH and 127 on git's. + writeScript(chainedPath(repo), '#!/bin/sh\nexec node /nonexistent/lint-commit.js "$@"\n'); + chmodSync(chainedPath(repo), 0o755); + + const report = runDoctor({ cwd: repo }); + const runtime = report.checks.find((entry) => entry.id === 'hook-runtime'); + expect(runtime?.status).toBe('fail'); + expect(runtime?.detail).toContain(chainedPath(repo)); + expect(runtime?.detail).toMatch(/commitlore's hook is not what failed/); + expect(runtime?.detail).toContain('node'); + expect(runtime?.fix).toContain(chainedPath(repo)); + expect(runtime?.fix).not.toContain('hooks install'); + // The row the reporter read was `commit-msg-hook`, which inherits the + // runtime's outcome. It has to inherit the remedy too, or it keeps saying + // `hooks install` under an outcome that just explained why that cannot help. + const installation = report.checks.find((entry) => entry.id === 'commit-msg-hook'); + expect(installation?.status).toBe('fail'); + expect(installation?.blockedBy).toBe('hook-runtime'); + expect(installation?.fix).toContain(chainedPath(repo)); + expect(installation?.fix).not.toContain('hooks install'); + // Evidence paths are normalised (a home prefix becomes `~`), so the name + // is what is pinned, not the absolute string. + expect(runtime?.evidence['chained_hook_path']?.endsWith(`/${CHAINED_HOOK_NAME}`)).toBe(true); + expect(runtime?.evidence['exit_code']).toBe('127'); + }); + + it('names the preserved hook when it exits non-zero for a reason unrelated to node', () => { + const repo = initRepo('doctor-runtime-chained-broken'); + installedHook(repo); + writeScript(chainedPath(repo), '#!/bin/sh\necho "lint config missing" >&2\nexit 3\n'); + chmodSync(chainedPath(repo), 0o755); + + const runtime = runtimeCheck(repo); + expect(runtime?.status).toBe('fail'); + expect(runtime?.detail).toContain(chainedPath(repo)); + expect(runtime?.detail).toContain('lint config missing'); + expect(runtime?.detail).not.toMatch(/the hook cannot find a node interpreter/); + expect(runtime?.fix).toContain(chainedPath(repo)); + expect(runtime?.fix).not.toContain('hooks install'); + expect(runtime?.evidence['exit_code']).toBe('3'); + }); + + it('still runs the installed hook, and blames it, once the preserved hook has passed', () => { + const repo = initRepo('doctor-runtime-chained-ok-then-node-gone'); + installedHook(repo); + writeScript(chainedPath(repo), '#!/bin/sh\nexit 0\n'); + chmodSync(chainedPath(repo), 0o755); + git(repo, ['config', '--local', 'commitlore.node', '/nonexistent/node']); + + const runtime = runtimeCheck(repo); + expect(runtime?.status).toBe('fail'); + expect(runtime?.detail).not.toMatch(/commitlore's hook is not what failed/); + expect(runtime?.fix).toContain('hooks install'); + expect(runtime?.evidence['chained_hook_path']).toBeUndefined(); + }); + + it('reports ok when the preserved hook passes and so does the installed one', () => { + const repo = initRepo('doctor-runtime-chained-ok'); + installedHook(repo); + writeScript(chainedPath(repo), '#!/bin/sh\nexit 0\n'); + chmodSync(chainedPath(repo), 0o755); + + expect(runtimeCheck(repo)?.status).toBe('ok'); + }); + + it('ignores a preserved hook without its execute bit, as the stub does', () => { + // `[ -x "$chained" ]` in the stub: git would not have run this file either. + const repo = initRepo('doctor-runtime-chained-inert'); + installedHook(repo); + writeScript(chainedPath(repo), '#!/bin/sh\nexit 1\n'); + chmodSync(chainedPath(repo), 0o644); + + expect(runtimeCheck(repo)?.status).toBe('ok'); + }); }); describe('doctor: PreToolUse hook runtime', () => {