@bookedsolid/rea 0.29.0 → 0.30.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.
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Install the husky `prepare-commit-msg` hook that drives the 0.30.0
3
+ * attribution augmenter.
4
+ *
5
+ * The hook itself is a stable POSIX-sh body sourced from the package's
6
+ * own `.husky/prepare-commit-msg`. `rea init` and `rea upgrade` copy it
7
+ * into `.husky/` and (when `core.hooksPath` is not configured at
8
+ * `.husky`) `.git/hooks/` as the belt-and-suspenders pair — mirroring
9
+ * the `installCommitMsgHook` strategy in `commit-msg.ts`.
10
+ *
11
+ * Foreign-hook conflict pattern: the 0.13.2 prepush prior art applies.
12
+ * If a foreign `prepare-commit-msg` exists (no rea marker, not the husky
13
+ * 9 indirection stub), we REFUSE to overwrite, surface the conflict via
14
+ * `rea doctor`, and recommend the `.husky/prepare-commit-msg.d/<NN>-name`
15
+ * extension-fragment migration path (TODO: wire fragment chaining if
16
+ * consumers demand it; not in 0.30.0 scope).
17
+ *
18
+ * Idempotency: the canonical body carries the `# rea:prepare-commit-msg v1`
19
+ * marker on line 2 and `# rea:augment-body-v1` on line 3. Re-running rea
20
+ * init / upgrade refreshes the file in-place whenever the marker matches;
21
+ * foreign hooks are left alone.
22
+ */
23
+ import { execFile } from 'node:child_process';
24
+ import fs from 'node:fs';
25
+ import fsPromises from 'node:fs/promises';
26
+ import path from 'node:path';
27
+ import { promisify } from 'node:util';
28
+ import { PKG_ROOT, warn } from '../utils.js';
29
+ import { isHusky9Stub, resolveHusky9StubTarget } from './pre-push.js';
30
+ const execFileAsync = promisify(execFile);
31
+ /**
32
+ * Marker baked into every rea-installed prepare-commit-msg hook.
33
+ * Anchored on line 2 (immediately after the shebang) for classification.
34
+ * Bump the version suffix whenever the body semantics change so
35
+ * upgrades migrate cleanly.
36
+ *
37
+ * v1 — 0.30.0: first version of the augmenter hook.
38
+ */
39
+ export const PREPARE_COMMIT_MSG_MARKER = '# rea:prepare-commit-msg v1';
40
+ /**
41
+ * Body marker anchored on line 3. A foreign hook that carries the
42
+ * header marker as a comment but has an empty body (stubbed by a
43
+ * consumer) will NOT be classified as rea-managed because the body
44
+ * marker won't be on line 3. Both markers together close the
45
+ * classification question.
46
+ */
47
+ export const PREPARE_COMMIT_MSG_BODY_MARKER = '# rea:augment-body-v1';
48
+ /**
49
+ * Inspect `hookPath` and decide whether it is rea-authored or foreign.
50
+ * Strict: BOTH markers must appear on lines 2 + 3 in order. Substring
51
+ * matches deliberately rejected so a comment quoting the marker doesn't
52
+ * fool the classifier.
53
+ */
54
+ export async function classifyPrepareCommitMsgHook(hookPath) {
55
+ let stat;
56
+ try {
57
+ stat = await fsPromises.lstat(hookPath);
58
+ }
59
+ catch {
60
+ return { kind: 'absent' };
61
+ }
62
+ if (stat.isDirectory())
63
+ return { kind: 'foreign', reason: 'is-directory' };
64
+ if (stat.isSymbolicLink())
65
+ return { kind: 'foreign', reason: 'is-symlink' };
66
+ if (!stat.isFile())
67
+ return { kind: 'foreign', reason: 'not-regular-file' };
68
+ let content;
69
+ try {
70
+ content = await fsPromises.readFile(hookPath, 'utf8');
71
+ }
72
+ catch (e) {
73
+ return {
74
+ kind: 'foreign',
75
+ reason: `read-error: ${e instanceof Error ? e.message : String(e)}`,
76
+ };
77
+ }
78
+ // Codex round 2 P2: Husky 9 layout (`core.hooksPath=.husky/_`) auto-
79
+ // generates a stub like `#!/usr/bin/env sh\n. "${0%/*}/h"` at the
80
+ // active hooks path. Git dispatches through that stub to `.husky/
81
+ // prepare-commit-msg` (the canonical body, which IS rea-managed).
82
+ // Treat the stub as a managed pointer — follow the indirection and
83
+ // re-classify against the canonical target. Same pattern as
84
+ // pre-push.ts's husky 9 handling.
85
+ if (isHusky9Stub(content)) {
86
+ const target = resolveHusky9StubTarget(hookPath);
87
+ if (target !== null && target !== hookPath) {
88
+ return classifyPrepareCommitMsgHook(target);
89
+ }
90
+ }
91
+ if (!content.startsWith('#!/bin/sh\n')) {
92
+ return { kind: 'foreign', reason: 'no-marker' };
93
+ }
94
+ const lines = content.split('\n');
95
+ if (lines[1] !== PREPARE_COMMIT_MSG_MARKER || lines[2] !== PREPARE_COMMIT_MSG_BODY_MARKER) {
96
+ return { kind: 'foreign', reason: 'no-marker' };
97
+ }
98
+ return { kind: 'rea-managed', version: 'v1' };
99
+ }
100
+ /**
101
+ * Read `core.hooksPath` via `git config --get`. Returns `null` when the
102
+ * key is unset. Same execFile (not exec) discipline as the other
103
+ * installers so the target directory cannot interpolate through a
104
+ * shell.
105
+ */
106
+ async function readHooksPathFromGit(targetDir) {
107
+ try {
108
+ const { stdout } = await execFileAsync('git', ['-C', targetDir, 'config', '--get', 'core.hooksPath'], { encoding: 'utf8' });
109
+ const trimmed = stdout.trim();
110
+ return trimmed.length > 0 ? trimmed : null;
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ function sourceHookPath() {
117
+ return path.join(PKG_ROOT, '.husky', 'prepare-commit-msg');
118
+ }
119
+ async function writeExecutable(src, dst) {
120
+ await fsPromises.mkdir(path.dirname(dst), { recursive: true });
121
+ await fsPromises.copyFile(src, dst);
122
+ await fsPromises.chmod(dst, 0o755);
123
+ }
124
+ /**
125
+ * Install the prepare-commit-msg hook into the consumer project at
126
+ * `targetDir`. Refuses to stomp foreign hooks; refreshes rea-managed
127
+ * hooks in place. Best-effort: a missing `.husky/` directory simply
128
+ * skips the husky copy (git-hooks copy is sufficient for vanilla git).
129
+ *
130
+ * Foreign-hook conflict (the 0.13.2 pre-push prior art): we never
131
+ * overwrite a non-rea body. The caller surfaces the conflict to the
132
+ * operator; `rea doctor` flags the gap so the operator can decide
133
+ * whether to relocate their existing hook into a fragment, replace it
134
+ * with rea's body, or set `attribution.co_author.enabled: false`.
135
+ */
136
+ export async function installPrepareCommitMsgHook(targetDir) {
137
+ const result = { warnings: [] };
138
+ const src = sourceHookPath();
139
+ if (!fs.existsSync(src)) {
140
+ result.warnings.push(`packaged prepare-commit-msg hook missing at ${src}`);
141
+ return result;
142
+ }
143
+ const gitDir = path.join(targetDir, '.git');
144
+ if (!fs.existsSync(gitDir)) {
145
+ result.warnings.push('.git/ not found — skipping prepare-commit-msg install (not a git repo?)');
146
+ return result;
147
+ }
148
+ // Codex round 4 P2: `.git` may be a FILE (linked worktrees, submodules)
149
+ // rather than a directory. `path.join(targetDir, '.git', 'hooks')` then
150
+ // points into a non-existent location and the writeExecutable mkdir
151
+ // throws ENOTDIR. Use `git rev-parse --git-path hooks` to resolve
152
+ // the actual hooks dir regardless of worktree/submodule indirection.
153
+ let hooksDir;
154
+ const configuredHooksPath = await readHooksPathFromGit(targetDir);
155
+ if (configuredHooksPath !== null) {
156
+ hooksDir = path.isAbsolute(configuredHooksPath)
157
+ ? configuredHooksPath
158
+ : path.join(targetDir, configuredHooksPath);
159
+ result.warnings.push(`git core.hooksPath is set — installing prepare-commit-msg to ${hooksDir}`);
160
+ }
161
+ else {
162
+ try {
163
+ const { stdout } = await execFileAsync('git', ['-C', targetDir, 'rev-parse', '--git-path', 'hooks'], { encoding: 'utf8' });
164
+ const resolved = stdout.trim();
165
+ hooksDir = path.isAbsolute(resolved) ? resolved : path.join(targetDir, resolved);
166
+ }
167
+ catch {
168
+ hooksDir = path.join(gitDir, 'hooks');
169
+ }
170
+ }
171
+ const gitHookPath = path.join(hooksDir, 'prepare-commit-msg');
172
+ const gitClassification = await classifyPrepareCommitMsgHook(gitHookPath);
173
+ if (gitClassification.kind === 'foreign') {
174
+ result.warnings.push(`foreign prepare-commit-msg at ${gitHookPath} (${gitClassification.reason}) — ` +
175
+ `leaving alone. Either remove it and re-run rea init, or migrate to a ` +
176
+ `fragment under .husky/prepare-commit-msg.d/ (not yet supported in 0.30.0).`);
177
+ result.skippedForeign = true;
178
+ }
179
+ else {
180
+ await writeExecutable(src, gitHookPath);
181
+ result.gitHook = gitHookPath;
182
+ if (gitClassification.kind === 'rea-managed') {
183
+ result.refreshed = true;
184
+ }
185
+ }
186
+ const huskyDir = path.join(targetDir, '.husky');
187
+ if (fs.existsSync(huskyDir)) {
188
+ const huskyHookPath = path.join(huskyDir, 'prepare-commit-msg');
189
+ const huskyClassification = await classifyPrepareCommitMsgHook(huskyHookPath);
190
+ if (huskyClassification.kind === 'foreign') {
191
+ result.warnings.push(`foreign .husky/prepare-commit-msg at ${huskyHookPath} ` +
192
+ `(${huskyClassification.reason}) — leaving alone.`);
193
+ result.skippedForeign = true;
194
+ }
195
+ else {
196
+ await writeExecutable(src, huskyHookPath);
197
+ result.huskyHook = huskyHookPath;
198
+ if (huskyClassification.kind === 'rea-managed' && result.refreshed !== true) {
199
+ result.refreshed = true;
200
+ }
201
+ }
202
+ }
203
+ else {
204
+ warn('no .husky/ directory — skipped husky prepare-commit-msg copy ' +
205
+ '(git-hooks copy is sufficient)');
206
+ }
207
+ return result;
208
+ }
@@ -45,7 +45,9 @@ import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFile
45
45
  import { buildFragment, extractFragment } from './install/claude-md.js';
46
46
  import { atomicReplaceFile, safeDeleteFile, safeInstallFile, safeReadFile, } from './install/fs-safe.js';
47
47
  import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, pruneHookCommands, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
48
+ import { validateSettings } from '../config/settings-schema.js';
48
49
  import { ensureReaGitignore } from './install/gitignore.js';
50
+ import { installPrepareCommitMsgHook } from './install/prepare-commit-msg.js';
49
51
  import { manifestExists, readManifest, writeManifestAtomic } from './install/manifest-io.js';
50
52
  import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
51
53
  import { err, getPkgVersion, log, warn } from './utils.js';
@@ -408,6 +410,17 @@ async function upgradeSettings(baseDir, opts) {
408
410
  // pointless work. Pruning first means the merge sees a clean baseline.
409
411
  const pruned = pruneHookCommands(settings, STALE_HOOK_COMMAND_TOKENS);
410
412
  const mergeResult = mergeSettings(pruned.merged, desired);
413
+ // 0.30.0 Class M — validate the merged result with the non-strict
414
+ // schema before writing. If the merged output would fail zod parse,
415
+ // refuse the write and leave the consumer settings untouched. This
416
+ // matches the 0.21.1 idempotency contract: rea never produces a
417
+ // broken settings.json — when in doubt, do nothing.
418
+ const validation = validateSettings(mergeResult.merged);
419
+ if (!validation.parsed) {
420
+ throw new Error(`rea upgrade: refusing to write .claude/settings.json because the merged result ` +
421
+ `fails schema validation. This is a safety guardrail — your existing file ` +
422
+ `is unchanged. zod errors: ${validation.errors.join('; ')}`);
423
+ }
411
424
  if (opts.dryRun !== true) {
412
425
  await writeSettingsAtomic(settingsPath, mergeResult.merged);
413
426
  }
@@ -605,6 +618,27 @@ export async function runUpgrade(options = {}) {
605
618
  source: 'claude-md',
606
619
  });
607
620
  }
621
+ // 0.30.0 — install the prepare-commit-msg augmenter on upgrade too
622
+ // (codex round 1 P1: consumers upgrading from 0.29.x to 0.30.0 would
623
+ // not get the new husky hook unless they re-ran `rea init`). The
624
+ // hook is a no-op when policy.attribution.co_author.enabled !== true,
625
+ // so installing unconditionally is safe; consumers opt in by editing
626
+ // .rea/policy.yaml. The installer's marker-classification path
627
+ // refuses to overwrite foreign hooks — same shape as 0.13.2
628
+ // pre-push foreign-hook handling.
629
+ if (!dryRun) {
630
+ const pcmResult = await installPrepareCommitMsgHook(resolvedRoot);
631
+ if (pcmResult.skippedForeign) {
632
+ warn(` · .husky/prepare-commit-msg (kept; foreign hook detected — see MIGRATING.md)`);
633
+ }
634
+ else if (pcmResult.huskyHook ?? pcmResult.gitHook) {
635
+ const target = pcmResult.huskyHook ?? pcmResult.gitHook;
636
+ const marker = pcmResult.refreshed ? '~' : '+';
637
+ console.log(` ${marker} ${target} (attribution augmenter)`);
638
+ }
639
+ for (const w of pcmResult.warnings)
640
+ warn(w);
641
+ }
608
642
  // BUG-010 — ensure `.gitignore` carries every runtime artifact entry. This
609
643
  // backfills older installs that predate the scaffolding in `rea init`. A
610
644
  // consumer who upgraded from 0.3.x/0.4.0 was previously seeing