@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.
- package/.husky/prepare-commit-msg +314 -0
- package/MIGRATING.md +75 -0
- package/dist/cli/doctor.d.ts +49 -1
- package/dist/cli/doctor.js +289 -6
- package/dist/cli/index.js +2 -0
- package/dist/cli/init.js +120 -0
- package/dist/cli/install/prepare-commit-msg.d.ts +83 -0
- package/dist/cli/install/prepare-commit-msg.js +208 -0
- package/dist/cli/upgrade.js +34 -0
- package/dist/config/settings-schema.d.ts +2099 -0
- package/dist/config/settings-schema.js +305 -0
- package/dist/policy/loader.d.ts +58 -0
- package/dist/policy/loader.js +82 -0
- package/dist/policy/profiles.d.ts +48 -0
- package/dist/policy/profiles.js +25 -0
- package/dist/policy/types.d.ts +51 -0
- package/dist/registry/loader.d.ts +6 -6
- package/package.json +1 -1
- package/profiles/bst-internal-no-codex.yaml +15 -0
- package/profiles/bst-internal.yaml +16 -0
- package/profiles/client-engagement.yaml +14 -0
- package/profiles/lit-wc.yaml +14 -0
- package/profiles/minimal.yaml +16 -0
- package/profiles/open-source-no-codex.yaml +13 -0
- package/profiles/open-source.yaml +13 -0
- package/templates/prepare-commit-msg.husky.sh +314 -0
|
@@ -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
|
+
}
|
package/dist/cli/upgrade.js
CHANGED
|
@@ -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
|