baldart 3.33.1 → 3.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to BALDART will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.34.0] - 2026-05-30
9
+
10
+ Adds an **assert-active git-hooks health check** to `baldart doctor` and the `worktree-manager` verify step. A consumer had `core.hooksPath` pointing at `.git/hooks` (empty) while the repo's versioned hooks lived in `scripts/git-hooks/` — the pre-commit schema guard and pre-push were **silently inactive for days**. The existing worktree-manager verify (`git config core.hooksPath || true`) only *read* the value without asserting anything, so it never caught the drift. Same class as the LSP bug: the framework "verifies" by reading a proxy instead of asserting the real state. This moves it from read-only to assert-active. **WARN-only** — the check never mutates `core.hooksPath` (a wrong heuristic + auto-fix would be worse than the original bug); it surfaces the exact remediation command and lets the user apply it. **No new `baldart.config.yml` keys** — it's a universal, always-on, filesystem-autodetected check, so the schema-propagation rule does not apply.
11
+
12
+ ### Added — git-hooks health check (assert-active)
13
+
14
+ - **[src/utils/githooks.js](src/utils/githooks.js)**: new, self-contained, fail-safe utility (distinct from `src/utils/hooks.js`, which manages `.claude/settings.json` hooks). `getStatus(cwd)` detects the repo's versioned-hooks directory (`.husky`, `.githooks`, `scripts/git-hooks`, `githooks` — first that exists and contains a standard-named hook), resolves the **active** hooks dir explicitly (`core.hooksPath` → relative resolved against `git rev-parse --show-toplevel`, absolute verbatim; unset → `<git-dir>/hooks`, handling the worktree `.git`-is-a-file case), and asserts the active dir serves the versioned hooks. **`git rev-parse --git-path hooks` is deliberately NOT used** — empirically it returns the configured value relative/unresolved and historically some git versions ignored `core.hooksPath` for it. Returns `checked:false` (silent, `ok:true`) when no managed hooks exist or on any error, so the check never produces a false WARN.
15
+ - **[src/utils/githooks.js](src/utils/githooks.js)**: pure exported `isActiveServing(activeAbs, expectedAbs)` — identical-or-nested decision (covers husky v9, which sets `core.hooksPath=.husky/_` with user hooks in `.husky/`). Paths are canonicalized (symlink-resolved, handling non-existent leaves) before comparison so a symlinked ancestor (macOS `/var`→`/private/var`, symlinked repo/home) never yields a false INACTIVE. Executability (`-x`) is asserted only for raw dirs git execs directly; husky user hooks (not required `+x` in v9) skip the assert.
16
+ - **[src/commands/doctor.js](src/commands/doctor.js)**: probe wired into `detectState` (`state.gitHooks`), a `Git hooks` line in `renderDiagnostic` (silent when `checked:false`), and an **advisory print-only** action in `planActions` (`githooks-health`) whose `run()` prints the WARN + fix command and **never mutates** `core.hooksPath` (safe under `--auto`).
17
+ - **[framework/.claude/skills/worktree-manager/SKILL.md](framework/.claude/skills/worktree-manager/SKILL.md)**: step 4 (isolation setup) replaces the silent `git config core.hooksPath || true` read with a lean **assert** — detects the versioned-hooks dir, resolves the active dir, and echoes a loud `⚠️ WARNING` + the `git config core.hooksPath <dir>` fix on mismatch (matches husky v9 `.husky/_` and `<dir>/*` as active). The worktree shares `core.hooksPath` via `.git/commondir`, so the same resolution applies.
18
+
19
+ ### Tests
20
+
21
+ - **[src/utils/__tests__/githooks-active.test.js](src/utils/__tests__/githooks-active.test.js)**: new — 8 pure fixtures for `isActiveServing` (the mayo bug, husky v9 nesting, identical dirs, prefix-not-a-boundary, trailing-sep normalization).
22
+ - **[scripts/test-githooks.sh](scripts/test-githooks.sh)**: new — drives **real** git repos: misconfig (`hooksPath=.git/hooks`, hooks in `scripts/git-hooks`) → inactive + fix; correctly wired → ok; non-executable raw hook → ok=false + chmod fix; husky v9 → active, `-x` assert skipped; no managed hooks → `checked:false` (silent).
23
+
8
24
  ## [3.33.1] - 2026-05-30
9
25
 
10
26
  Fixes a **false-positive in the LSP layer's verify step** that let `/lsp-bootstrap` (and `baldart configure` / `doctor`) report a language server as `verified` while every real LSP call failed with `Executable not found in $PATH` — a dead layer masked by `codebase-architect`'s silent Grep fallback. Root cause was a **resolution mismatch**: verify used `npx --no-install typescript-language-server --version` (resolves from `node_modules/.bin`), but the consumer — Claude Code's LSP tool — spawns the server **by name from `$PATH`**. The npm-dev install (`npm install --save-dev`) put the binary in `node_modules/.bin`, which is *not* on `$PATH`, so verify passed and spawn failed. The npm-managed adapters now install **globally** so the binary lands on `$PATH`, and verify checks `$PATH` reachability the *same way the consumer spawns it*. **No new `baldart.config.yml` keys** — reachability is computed, not configured, so the schema-propagation rule does not apply.
package/README.md CHANGED
@@ -239,7 +239,7 @@ The `ui-expert` agent is upgraded from a generic baseline to a world-class UI/UX
239
239
 
240
240
  ### LSP Symbol Search Layer (new in v3.10.0)
241
241
 
242
- When `features.has_lsp_layer: true`, `codebase-architect` and the code-exploration skills (`context-primer`, `bug`, `prd`, `new`, `simplify`) prefer LSP `find-references` / `go-to-definition` over Grep for identifier-shaped queries — the filtering happens **before** Claude reads files, so a common function name no longer dumps thousands of textual matches into context. Opt-in at `baldart configure`; BALDART installs the matching language servers (npm devDeps for TypeScript/Python, system commands printed for Go/Rust/Ruby). Grep remains the fallback for free-text queries and degraded states. See [`framework/agents/code-search-protocol.md`](framework/agents/code-search-protocol.md).
242
+ When `features.has_lsp_layer: true`, `codebase-architect` and the code-exploration skills (`context-primer`, `bug`, `prd`, `new`, `simplify`) prefer LSP `find-references` / `go-to-definition` over Grep for identifier-shaped queries — the filtering happens **before** Claude reads files, so a common function name no longer dumps thousands of textual matches into context. Opt-in at `baldart configure`; BALDART installs the matching language servers (global npm install for TypeScript/Python — the binary must be on `$PATH` because Claude Code's LSP tool spawns it by name; system commands printed for Go/Rust/Ruby). Grep remains the fallback for free-text queries and degraded states. See [`framework/agents/code-search-protocol.md`](framework/agents/code-search-protocol.md).
243
243
 
244
244
  ### Commands
245
245
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.33.1
1
+ 3.34.0
@@ -411,8 +411,32 @@ else
411
411
  echo "PORT=$PORT" >> .env.local
412
412
  fi
413
413
 
414
- # 5. Verify husky hooks work (they share via .git/commondir)
415
- git config core.hooksPath || true
414
+ # 5. ASSERT git hooks are ACTIVE not just readable.
415
+ # The worktree shares core.hooksPath via .git/commondir, so the same
416
+ # resolution applies here as in the main checkout. Reading the value
417
+ # (the old `git config core.hooksPath || true`) is NOT enough: it never
418
+ # catches the failure mode where core.hooksPath points at an empty dir
419
+ # while the repo's versioned hooks live elsewhere (pre-commit / pre-push
420
+ # silently inactive). Detect the versioned-hooks dir and assert it is served.
421
+ HOOK_SRC=""
422
+ for d in .husky .githooks scripts/git-hooks githooks; do
423
+ if [ -d "$d" ] && ls "$d" 2>/dev/null | grep -qE '^(pre-commit|pre-push|commit-msg)$'; then
424
+ HOOK_SRC="$d"; break
425
+ fi
426
+ done
427
+ if [ -n "$HOOK_SRC" ]; then
428
+ ACTIVE="$(git config --get core.hooksPath || echo .git/hooks)"
429
+ # Lean literal-string match (HOOK_SRC is relative). Full path resolution +
430
+ # canonicalization lives in `baldart doctor` (src/utils/githooks.js); an
431
+ # absolute core.hooksPath here would warn spuriously — rare, run doctor to confirm.
432
+ case "$ACTIVE" in
433
+ "$HOOK_SRC"|"$HOOK_SRC"/*|.husky/_) : ;; # active dir serves the versioned hooks (incl. husky v9 .husky/_)
434
+ *)
435
+ echo "⚠️ WARNING: git hooks INATTIVI — gli hook versionati sono in '$HOOK_SRC' ma core.hooksPath = '$ACTIVE'." >&2
436
+ echo " Esegui per riattivarli: git config core.hooksPath $HOOK_SRC" >&2
437
+ ;;
438
+ esac
439
+ fi
416
440
  ```
417
441
 
418
442
  ### 5. Verify baseline
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "3.33.1",
3
+ "version": "3.34.0",
4
4
  "description": "Claude Agent Framework - Reusable framework for coordinating AI agents and humans in software projects",
5
5
  "bin": {
6
6
  "baldart": "./bin/baldart.js"
@@ -30,6 +30,7 @@ const GitUtils = require('../utils/git');
30
30
  const UI = require('../utils/ui');
31
31
  const State = require('../utils/state');
32
32
  const Hooks = require('../utils/hooks');
33
+ const GitHooks = require('../utils/githooks');
33
34
  const LspInstaller = require('../utils/lsp-installer');
34
35
  const UpdateNotifier = require('../utils/update-notifier');
35
36
  const cliPackageJson = require('../../package.json');
@@ -234,6 +235,16 @@ async function detectState(cwd, opts = {}) {
234
235
  }
235
236
  } catch (_) {}
236
237
 
238
+ // ---- Git-hooks health (since v3.34.0) ------------------------------
239
+ // Assert that the repo's versioned git hooks (pre-commit/pre-push/…) are
240
+ // actually being run — i.e. `core.hooksPath` resolves to the directory
241
+ // that holds them. A drift here (e.g. core.hooksPath=.git/hooks while the
242
+ // hooks live in scripts/git-hooks/) silently disables every hook. Distinct
243
+ // from the BALDART `.claude/settings.json` hooks probed above. Fully
244
+ // fail-safe: `checked:false` when no managed hooks exist or on any error.
245
+ state.gitHooks = { checked: false, ok: true };
246
+ try { state.gitHooks = GitHooks.getStatus(cwd); } catch (_) {}
247
+
237
248
  // ---- Agent symlink integrity (since v3.20.0) -----------------------
238
249
  // Source of truth for the BALDART agent list is the framework filesystem
239
250
  // directory itself (`.framework/framework/.claude/agents/*.md`). For each
@@ -503,6 +514,28 @@ function planActions(state) {
503
514
  });
504
515
  }
505
516
 
517
+ // Git-hooks health (since v3.34.0). ADVISORY ONLY — the action's run() never
518
+ // mutates `core.hooksPath` (a wrong heuristic + auto-fix is worse than the
519
+ // original silent-inactive bug). It surfaces the WARN + the exact remediation
520
+ // command so the user applies it deliberately. Present as an action (rather
521
+ // than a bare table WARN) so `doctor` doesn't contradict it with "Nothing to
522
+ // do". Safe to run in --auto since it only prints.
523
+ if (state.gitHooks && state.gitHooks.checked && !state.gitHooks.ok) {
524
+ actions.push({
525
+ key: 'githooks-health',
526
+ label: state.gitHooks.inactive
527
+ ? `Git hooks INACTIVE — ${state.gitHooks.source} hooks in ${state.gitHooks.expectedDir} are not being run`
528
+ : `Git hooks not executable — ${state.gitHooks.nonExecutable.join(', ')}`,
529
+ why: `${state.gitHooks.detail}. Fix: ${state.gitHooks.fixCommand}`,
530
+ autoOk: true, // advisory print-only; run() does NOT mutate config
531
+ run: () => {
532
+ UI.warning(state.gitHooks.detail);
533
+ UI.info('Run to re-activate the repo\'s versioned git hooks:');
534
+ console.log(` ${state.gitHooks.fixCommand}`);
535
+ },
536
+ });
537
+ }
538
+
506
539
  // Agent symlink integrity (since v3.20.0). Re-runs the agent merge loop to
507
540
  // recreate any missing per-agent symlink. Calls `SymlinkUtils.mergeAgents`
508
541
  // directly rather than dispatching to `update` — we want only the symlink
@@ -787,6 +820,29 @@ function renderDiagnostic(state) {
787
820
  console.log(` • pre-v3.18: ${state.hooksLegacy.join(', ')}`);
788
821
  }
789
822
 
823
+ // Git-hooks health (v3.34.0+). Silent when no versioned-hooks dir exists
824
+ // (`checked:false`) — most consumers have no managed git hooks and must see
825
+ // zero noise here.
826
+ if (state.gitHooks && state.gitHooks.checked) {
827
+ if (state.gitHooks.ok) {
828
+ console.log(statusLine(
829
+ 'Git hooks',
830
+ `${state.gitHooks.source} active (${state.gitHooks.expectedHooks.join(', ')})`,
831
+ 'ok'
832
+ ));
833
+ } else if (state.gitHooks.inactive) {
834
+ console.log(statusLine(
835
+ 'Git hooks',
836
+ `INACTIVE — ${state.gitHooks.source} hooks in ${state.gitHooks.expectedDir} not run by core.hooksPath`,
837
+ 'warn'
838
+ ));
839
+ console.log(` • fix: ${state.gitHooks.fixCommand}`);
840
+ } else {
841
+ console.log(statusLine('Git hooks', state.gitHooks.detail, 'warn'));
842
+ console.log(` • fix: ${state.gitHooks.fixCommand}`);
843
+ }
844
+ }
845
+
790
846
  if (state.frameworkIgnored && state.frameworkIgnored.ignored) {
791
847
  console.log(statusLine(
792
848
  '.gitignore',
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fixture-driven test for the git-hooks active-vs-expected decision.
4
+ *
5
+ * Run: node src/utils/__tests__/githooks-active.test.js
6
+ * Exits 0 on success, 1 on any mismatch (CI-friendly).
7
+ *
8
+ * `isActiveServing` is the one decision that, if wrong, silently invalidates
9
+ * the entire git-hooks health check (src/utils/githooks.js). It is pure (path
10
+ * logic only) so it is fully unit-testable without a live repo — covering the
11
+ * husky-version branches that fs/git probing can't easily exercise.
12
+ */
13
+ const path = require('path');
14
+ const { isActiveServing } = require('../githooks');
15
+
16
+ // Absolute roots so the fixtures don't depend on cwd. Normalize through
17
+ // path.resolve the same way the implementation does.
18
+ const ROOT = path.resolve('/repo');
19
+ const j = (...p) => path.join(ROOT, ...p);
20
+
21
+ const FIXTURES = [
22
+ // ─── the mayo bug: core.hooksPath=.git/hooks, hooks in scripts/git-hooks ──
23
+ { active: j('.git', 'hooks'), expected: j('scripts', 'git-hooks'), want: false, note: 'mayo: inactive' },
24
+
25
+ // ─── husky v9: active=.husky/_ wrappers, user hooks in .husky ─────────────
26
+ { active: j('.husky', '_'), expected: j('.husky'), want: true, note: 'husky v9 nested' },
27
+ // ─── husky v5-v8: core.hooksPath=.husky, hooks in .husky ──────────────────
28
+ { active: j('.husky'), expected: j('.husky'), want: true, note: 'husky v8 identical' },
29
+
30
+ // ─── scripts convention, correctly wired ─────────────────────────────────
31
+ { active: j('scripts', 'git-hooks'), expected: j('scripts', 'git-hooks'), want: true, note: 'scripts identical' },
32
+
33
+ // ─── .githooks convention, wired to default .git/hooks (inactive) ─────────
34
+ { active: j('.git', 'hooks'), expected: j('.githooks'), want: false, note: '.githooks inactive' },
35
+
36
+ // ─── reverse nesting (expected within active) also counts as serving ──────
37
+ { active: j('.husky'), expected: j('.husky', '_'), want: true, note: 'reverse nesting' },
38
+
39
+ // ─── sibling dirs sharing a prefix must NOT match (prefix-not-boundary) ───
40
+ { active: j('hooks'), expected: j('hooks-extra'), want: false, note: 'prefix not a path boundary' },
41
+
42
+ // ─── trailing-slash / non-normalized inputs normalize equal ──────────────
43
+ { active: j('.husky') + path.sep, expected: j('.husky'), want: true, note: 'trailing sep normalized' },
44
+ ];
45
+
46
+ let failures = 0;
47
+ for (const f of FIXTURES) {
48
+ const got = isActiveServing(f.active, f.expected);
49
+ const ok = got === f.want;
50
+ if (!ok) {
51
+ failures++;
52
+ console.error(`✗ ${f.note}: isActiveServing(${f.active}, ${f.expected}) = ${got}, expected ${f.want}`);
53
+ } else {
54
+ console.log(`✓ ${f.note}`);
55
+ }
56
+ }
57
+
58
+ if (failures) {
59
+ console.error(`\n${failures} failure(s).`);
60
+ process.exit(1);
61
+ }
62
+ console.log(`\nAll ${FIXTURES.length} fixtures passed.`);
63
+ process.exit(0);
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Git-hooks health check — **assert-active**, not read-only.
3
+ *
4
+ * NOT to be confused with `src/utils/hooks.js`: that module manages the Claude
5
+ * Code hooks registered in `.claude/settings.json`. THIS module inspects the
6
+ * repo's own *git* hooks (pre-commit, pre-push, …) and asserts they are
7
+ * actually being executed by git — i.e. that `core.hooksPath` resolves to the
8
+ * directory where the repo's versioned hooks live.
9
+ *
10
+ * **Why it exists** (since v3.34.0)
11
+ *
12
+ * A consumer had `core.hooksPath` pointing at `.git/hooks` (empty) while the
13
+ * repo's real, versioned hooks lived in `scripts/git-hooks/`. Result: the
14
+ * pre-commit schema guard and pre-push were silently inactive for days, and
15
+ * the worktree-manager verify step (`git config core.hooksPath || true`) only
16
+ * *read* the value without asserting anything — so it never caught the drift.
17
+ * Same class as the LSP bug: the framework "verifies" by reading a proxy
18
+ * instead of asserting the real state. This moves it from read-only to
19
+ * assert-active.
20
+ *
21
+ * **Resolution of the active hooks dir** (the crux)
22
+ *
23
+ * `git rev-parse --git-path hooks` is NOT used: empirically it returns the
24
+ * configured value *relative and unresolved*, and historically some git
25
+ * versions ignored `core.hooksPath` for `--git-path` entirely. Instead we
26
+ * resolve explicitly:
27
+ * - `core.hooksPath` set → relative resolves against the working-tree
28
+ * top (`git rev-parse --show-toplevel`),
29
+ * absolute is used verbatim.
30
+ * - `core.hooksPath` unset → `<git rev-parse --git-dir>/hooks` (handles
31
+ * the worktree case where `.git` is a file).
32
+ *
33
+ * **Design invariants**
34
+ *
35
+ * - Fail-safe: every probe is wrapped; any internal error yields
36
+ * `{ checked: false, ok: true }` so the check NEVER blocks legitimate work
37
+ * and never produces a false WARN.
38
+ * - Silent when nothing to assert: a repo with no versioned-hooks directory
39
+ * returns `checked: false` (most consumers have no managed hooks).
40
+ * - No config flag: this is a universal, always-on health check auto-detected
41
+ * from the filesystem, so the schema-change-propagation rule does not apply.
42
+ * - WARN only: this module never mutates `core.hooksPath`. It reports a
43
+ * `fixCommand` string; applying it is the caller's (user's) decision.
44
+ */
45
+
46
+ const fs = require('fs');
47
+ const path = require('path');
48
+ const { execFileSync } = require('child_process');
49
+
50
+ // Standard git hook filenames. A managed-hooks directory is recognized by
51
+ // containing at least one of these (husky places them at `.husky/<name>`,
52
+ // raw dirs at `<dir>/<name>`).
53
+ const STANDARD_HOOKS = [
54
+ 'pre-commit', 'pre-push', 'commit-msg', 'prepare-commit-msg',
55
+ 'pre-rebase', 'pre-merge-commit', 'post-checkout', 'post-commit',
56
+ 'post-merge', 'post-rewrite', 'pre-applypatch', 'applypatch-msg',
57
+ ];
58
+
59
+ // Candidate versioned-hooks directories, in priority order. The first that
60
+ // exists AND contains a standard-named hook wins. `scripts/git-hooks` and
61
+ // `githooks` are conventions (not universal) — we enumerate a small known set
62
+ // rather than guess.
63
+ const MANAGED_DIRS = [
64
+ { dir: '.husky', source: 'husky' },
65
+ { dir: '.githooks', source: 'githooks' },
66
+ { dir: 'scripts/git-hooks', source: 'scripts' },
67
+ { dir: 'githooks', source: 'githooks' },
68
+ ];
69
+
70
+ function git(cwd, args) {
71
+ return execFileSync('git', args, {
72
+ cwd,
73
+ encoding: 'utf8',
74
+ stdio: ['ignore', 'pipe', 'ignore'],
75
+ }).trim();
76
+ }
77
+
78
+ /**
79
+ * Find the repo's versioned-hooks directory, or null when none is managed.
80
+ * @returns {{ dir: string, abs: string, source: string, hooks: string[] } | null}
81
+ */
82
+ function detectExpected(cwd) {
83
+ for (const cand of MANAGED_DIRS) {
84
+ const abs = path.join(cwd, cand.dir);
85
+ let names = [];
86
+ try {
87
+ if (!fs.statSync(abs).isDirectory()) continue;
88
+ names = fs.readdirSync(abs).filter((f) => STANDARD_HOOKS.includes(f));
89
+ } catch (_) { continue; }
90
+ if (names.length) return { dir: cand.dir, abs, source: cand.source, hooks: names };
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Resolve the directory git will actually look in for hooks.
97
+ * @returns {{ configured: string | null, abs: string }}
98
+ */
99
+ function resolveActiveDir(cwd) {
100
+ let configured = null;
101
+ try { configured = git(cwd, ['config', '--get', 'core.hooksPath']) || null; } catch (_) {}
102
+
103
+ if (configured) {
104
+ if (path.isAbsolute(configured)) return { configured, abs: configured };
105
+ // Relative core.hooksPath resolves against the working-tree top, not cwd.
106
+ let top = cwd;
107
+ try { top = git(cwd, ['rev-parse', '--show-toplevel']); } catch (_) {}
108
+ return { configured, abs: path.resolve(top, configured) };
109
+ }
110
+
111
+ // Unset → <git-dir>/hooks. git-dir may be relative or a worktree gitdir.
112
+ let gitDir;
113
+ try { gitDir = git(cwd, ['rev-parse', '--git-dir']); } catch (_) { gitDir = path.join(cwd, '.git'); }
114
+ if (!path.isAbsolute(gitDir)) gitDir = path.resolve(cwd, gitDir);
115
+ return { configured: null, abs: path.join(gitDir, 'hooks') };
116
+ }
117
+
118
+ /**
119
+ * Resolve a path to its canonical (symlink-free) form so two paths computed
120
+ * from different bases compare correctly. The active dir (from git's
121
+ * realpath-resolved toplevel/git-dir) and the expected dir (from cwd) can
122
+ * otherwise differ purely by a symlinked ancestor — e.g. macOS `/var` →
123
+ * `/private/var`, or a symlinked repo/home path — yielding a false INACTIVE.
124
+ *
125
+ * Handles non-existent leaves (the active `.git/hooks` may not exist on a
126
+ * misconfigured repo): realpath the deepest existing ancestor, re-append the
127
+ * rest. Falls back to a plain resolve if nothing along the path is realpath-able.
128
+ */
129
+ function canonicalize(p) {
130
+ let dir = path.resolve(p);
131
+ const tail = [];
132
+ for (;;) {
133
+ try {
134
+ const real = fs.realpathSync(dir);
135
+ return tail.length ? path.join(real, ...tail) : real;
136
+ } catch (_) {
137
+ const parent = path.dirname(dir);
138
+ if (parent === dir) return path.resolve(p); // reached fs root
139
+ tail.unshift(path.basename(dir));
140
+ dir = parent;
141
+ }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * PURE decision: is the active hooks dir serving the versioned hooks?
147
+ *
148
+ * True when the two dirs are identical OR one is nested within the other.
149
+ * The nesting case covers husky v9, which sets `core.hooksPath=.husky/_`
150
+ * (wrappers) while the user hooks live in `.husky/`. Exported for unit tests —
151
+ * this is the one decision that, if wrong, silently invalidates the whole check.
152
+ */
153
+ function isActiveServing(activeAbs, expectedAbs) {
154
+ const a = path.resolve(activeAbs);
155
+ const e = path.resolve(expectedAbs);
156
+ if (a === e) return true;
157
+ return a.startsWith(e + path.sep) || e.startsWith(a + path.sep);
158
+ }
159
+
160
+ /**
161
+ * Inspect the repo's git-hooks health. Fail-safe.
162
+ * @returns {{
163
+ * checked: boolean, ok: boolean, source: string|null, configured: string|null,
164
+ * activeDir: string|null, expectedDir: string|null, expectedHooks: string[],
165
+ * inactive: boolean, nonExecutable: string[], detail: string, fixCommand: string|null
166
+ * }}
167
+ */
168
+ function getStatus(cwd = process.cwd()) {
169
+ const result = {
170
+ checked: false,
171
+ ok: true,
172
+ source: null,
173
+ configured: null,
174
+ activeDir: null,
175
+ expectedDir: null,
176
+ expectedHooks: [],
177
+ inactive: false,
178
+ nonExecutable: [],
179
+ detail: '',
180
+ fixCommand: null,
181
+ };
182
+
183
+ try {
184
+ const expected = detectExpected(cwd);
185
+ if (!expected) return result; // no managed hooks → silent, ok
186
+
187
+ const active = resolveActiveDir(cwd);
188
+ result.checked = true;
189
+ result.source = expected.source;
190
+ result.configured = active.configured;
191
+ result.activeDir = active.abs;
192
+ result.expectedDir = expected.dir;
193
+ result.expectedHooks = expected.hooks;
194
+
195
+ // Canonicalize both ends before comparing — they're computed from different
196
+ // bases (git realpath vs cwd) and a symlinked ancestor would otherwise
197
+ // produce a false INACTIVE.
198
+ if (!isActiveServing(canonicalize(active.abs), canonicalize(expected.abs))) {
199
+ result.inactive = true;
200
+ result.ok = false;
201
+ result.fixCommand = `git config core.hooksPath ${expected.dir}`;
202
+ result.detail = `core.hooksPath risolve a ${active.abs} ma gli hook versionati sono in ${expected.dir}`;
203
+ return result;
204
+ }
205
+
206
+ // Executability assert — only for raw dirs git execs directly. Husky sources
207
+ // user hooks via its `_` wrappers and v9 does NOT require them to be +x, so
208
+ // asserting -x there would manufacture false positives.
209
+ if (expected.source !== 'husky') {
210
+ for (const h of expected.hooks) {
211
+ try { fs.accessSync(path.join(expected.abs, h), fs.constants.X_OK); }
212
+ catch (_) { result.nonExecutable.push(h); }
213
+ }
214
+ if (result.nonExecutable.length) {
215
+ result.ok = false;
216
+ result.detail = `hook non eseguibili (manca il bit -x): ${result.nonExecutable.join(', ')}`;
217
+ result.fixCommand = `chmod +x ${result.nonExecutable.map((h) => path.join(expected.dir, h)).join(' ')}`;
218
+ }
219
+ }
220
+
221
+ return result;
222
+ } catch (_) {
223
+ // Never block doctor / worktree verify on a probe failure.
224
+ return { ...result, checked: false, ok: true };
225
+ }
226
+ }
227
+
228
+ module.exports = { getStatus, isActiveServing };
229
+ // Exposed for completeness / advanced callers and unit tests.
230
+ module.exports.detectExpected = detectExpected;
231
+ module.exports.resolveActiveDir = resolveActiveDir;
232
+ module.exports.STANDARD_HOOKS = STANDARD_HOOKS;
233
+ module.exports.MANAGED_DIRS = MANAGED_DIRS;