@bookedsolid/rea 0.13.0 → 0.13.1

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.
@@ -147,6 +147,39 @@ export declare function isLegacyReaManagedHuskyGate(content: string): boolean;
147
147
  * — we don't overwrite consumer-authored hooks that respect the gate.
148
148
  */
149
149
  export declare function referencesReviewGate(content: string): boolean;
150
+ /**
151
+ * True when `content` is a husky 9 auto-generated stub that delegates to
152
+ * the canonical hook body via `.husky/_/h`.
153
+ *
154
+ * Husky 9 layout when `core.hooksPath=.husky/_`:
155
+ * .husky/<hookname> — user/rea-authored body (committed, source of truth)
156
+ * .husky/_/<hookname> — auto-generated stub git actually fires
157
+ * .husky/_/h — runner that exec's `.husky/<hookname>`
158
+ *
159
+ * The stub body is a single non-comment line, e.g.
160
+ * . "${0%/*}/h"
161
+ * . "$(dirname -- "$0")/h"
162
+ *
163
+ * Without this detection `rea doctor` would classify `.husky/_/pre-push`
164
+ * as foreign (no rea marker, no `rea hook push-gate` reference) even
165
+ * though the hook git fires sources the canonical body that DOES carry
166
+ * governance. The stub is the husky 9 indirection contract — treating it
167
+ * as the active hook and following the source chain to the parent hook
168
+ * resolves the false positive.
169
+ *
170
+ * Detection is conservative: any non-comment, non-blank, non-source line
171
+ * disqualifies. We anchor on `$0` in the dirname expansion to confirm the
172
+ * stub references self, and on the trailing `/h` segment to confirm it
173
+ * sources the husky 9 runner. A user-authored shell script that happens
174
+ * to dot-source a file does NOT match.
175
+ */
176
+ export declare function isHusky9Stub(content: string): boolean;
177
+ /**
178
+ * Resolve a husky 9 stub at `<dir>/<hookname>` to the canonical hook
179
+ * body at `<parent-of-dir>/<hookname>`. Returns null when the stub
180
+ * lives at the filesystem root (no parent to walk to).
181
+ */
182
+ export declare function resolveHusky9StubTarget(stubPath: string): string | null;
150
183
  export declare function resolveHooksDir(targetDir: string): Promise<{
151
184
  dir: string | null;
152
185
  configured: boolean;
@@ -167,7 +200,9 @@ export type ClassifyExistingHook = {
167
200
  kind: 'foreign';
168
201
  reason: string;
169
202
  };
170
- export declare function classifyExistingHook(hookPath: string): Promise<ClassifyExistingHook>;
203
+ export declare function classifyExistingHook(hookPath: string, options?: {
204
+ followHusky9Stub?: boolean;
205
+ }): Promise<ClassifyExistingHook>;
171
206
  export type InstallDecision = {
172
207
  action: 'skip';
173
208
  reason: 'active-pre-push-present';
@@ -349,6 +349,71 @@ export function referencesReviewGate(content) {
349
349
  }
350
350
  return false;
351
351
  }
352
+ /**
353
+ * True when `content` is a husky 9 auto-generated stub that delegates to
354
+ * the canonical hook body via `.husky/_/h`.
355
+ *
356
+ * Husky 9 layout when `core.hooksPath=.husky/_`:
357
+ * .husky/<hookname> — user/rea-authored body (committed, source of truth)
358
+ * .husky/_/<hookname> — auto-generated stub git actually fires
359
+ * .husky/_/h — runner that exec's `.husky/<hookname>`
360
+ *
361
+ * The stub body is a single non-comment line, e.g.
362
+ * . "${0%/*}/h"
363
+ * . "$(dirname -- "$0")/h"
364
+ *
365
+ * Without this detection `rea doctor` would classify `.husky/_/pre-push`
366
+ * as foreign (no rea marker, no `rea hook push-gate` reference) even
367
+ * though the hook git fires sources the canonical body that DOES carry
368
+ * governance. The stub is the husky 9 indirection contract — treating it
369
+ * as the active hook and following the source chain to the parent hook
370
+ * resolves the false positive.
371
+ *
372
+ * Detection is conservative: any non-comment, non-blank, non-source line
373
+ * disqualifies. We anchor on `$0` in the dirname expansion to confirm the
374
+ * stub references self, and on the trailing `/h` segment to confirm it
375
+ * sources the husky 9 runner. A user-authored shell script that happens
376
+ * to dot-source a file does NOT match.
377
+ */
378
+ export function isHusky9Stub(content) {
379
+ const lines = content.split(/\r?\n/);
380
+ // Match a `.` (source) command whose argument is the husky 9 runner `h`
381
+ // resolved relative to the stub's own location. The two shapes husky 9
382
+ // generates:
383
+ // . "${0%/*}/h" — POSIX param expansion (current default)
384
+ // . "$(dirname -- "$0")/h" — older variant still in the wild
385
+ // We do NOT accept arbitrary `. <path>/h` because that would false-match
386
+ // a user-authored hook that happens to source a file named `h`.
387
+ const sourceLine = /^\.\s+(?:"\$\{0%\/\*\}\/h"|"\$\(dirname[^)]*\$0[^)]*\)\/h")\s*$/;
388
+ let sawSource = false;
389
+ for (const rawLine of lines) {
390
+ const line = rawLine.trim();
391
+ if (line === '')
392
+ continue;
393
+ if (line.startsWith('#'))
394
+ continue; // shebang, comment
395
+ if (sawSource)
396
+ return false; // any line after the source line disqualifies
397
+ if (sourceLine.test(line)) {
398
+ sawSource = true;
399
+ continue;
400
+ }
401
+ return false;
402
+ }
403
+ return sawSource;
404
+ }
405
+ /**
406
+ * Resolve a husky 9 stub at `<dir>/<hookname>` to the canonical hook
407
+ * body at `<parent-of-dir>/<hookname>`. Returns null when the stub
408
+ * lives at the filesystem root (no parent to walk to).
409
+ */
410
+ export function resolveHusky9StubTarget(stubPath) {
411
+ const dir = path.dirname(stubPath);
412
+ const parent = path.dirname(dir);
413
+ if (parent === dir)
414
+ return null;
415
+ return path.join(parent, path.basename(stubPath));
416
+ }
352
417
  // ---------------------------------------------------------------------------
353
418
  // Hook resolution
354
419
  // ---------------------------------------------------------------------------
@@ -408,7 +473,8 @@ async function resolveTargetHookPath(targetDir) {
408
473
  hooksPathConfigured: false,
409
474
  };
410
475
  }
411
- export async function classifyExistingHook(hookPath) {
476
+ export async function classifyExistingHook(hookPath, options = {}) {
477
+ const followStub = options.followHusky9Stub ?? true;
412
478
  let stat;
413
479
  try {
414
480
  stat = await fsPromises.lstat(hookPath);
@@ -442,6 +508,17 @@ export async function classifyExistingHook(hookPath) {
442
508
  return { kind: 'rea-managed-legacy-v1' };
443
509
  if (referencesReviewGate(content))
444
510
  return { kind: 'gate-delegating' };
511
+ // Husky 9 indirection: when git fires `.husky/_/<hookname>` (auto-generated
512
+ // stub) the body just sources `.husky/_/h` which exec's the canonical
513
+ // `.husky/<hookname>`. Without this branch, doctor false-positives the stub
514
+ // as foreign even though governance is intact via the source chain.
515
+ // One level of follow only — never recurse stubs-of-stubs.
516
+ if (followStub && isHusky9Stub(content)) {
517
+ const target = resolveHusky9StubTarget(hookPath);
518
+ if (target !== null) {
519
+ return classifyExistingHook(target, { followHusky9Stub: false });
520
+ }
521
+ }
445
522
  return { kind: 'foreign', reason: 'no-marker' };
446
523
  }
447
524
  export async function classifyPrePushInstall(targetDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",