@bookedsolid/rea 0.3.0 → 0.5.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/.husky/pre-push +15 -18
- package/README.md +41 -1
- package/dist/cache/review-cache.d.ts +115 -0
- package/dist/cache/review-cache.js +200 -0
- package/dist/cli/cache.d.ts +52 -0
- package/dist/cli/cache.js +112 -0
- package/dist/cli/doctor.d.ts +19 -4
- package/dist/cli/doctor.js +172 -5
- package/dist/cli/index.js +50 -1
- package/dist/cli/init.js +109 -7
- package/dist/cli/install/gitignore.d.ts +114 -0
- package/dist/cli/install/gitignore.js +356 -0
- package/dist/cli/install/pre-push.d.ts +335 -0
- package/dist/cli/install/pre-push.js +2818 -0
- package/dist/cli/serve.d.ts +64 -0
- package/dist/cli/serve.js +270 -2
- package/dist/cli/status.d.ts +90 -0
- package/dist/cli/status.js +399 -0
- package/dist/cli/upgrade.js +20 -0
- package/dist/cli/utils.d.ts +4 -0
- package/dist/cli/utils.js +4 -0
- package/dist/gateway/circuit-breaker.d.ts +17 -0
- package/dist/gateway/circuit-breaker.js +32 -3
- package/dist/gateway/downstream-pool.d.ts +2 -1
- package/dist/gateway/downstream-pool.js +2 -2
- package/dist/gateway/downstream.d.ts +39 -3
- package/dist/gateway/downstream.js +73 -14
- package/dist/gateway/log.d.ts +122 -0
- package/dist/gateway/log.js +334 -0
- package/dist/gateway/middleware/audit.d.ts +10 -1
- package/dist/gateway/middleware/audit.js +26 -1
- package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
- package/dist/gateway/middleware/blocked-paths.js +439 -67
- package/dist/gateway/middleware/injection.d.ts +218 -13
- package/dist/gateway/middleware/injection.js +433 -51
- package/dist/gateway/middleware/kill-switch.d.ts +10 -1
- package/dist/gateway/middleware/kill-switch.js +20 -1
- package/dist/gateway/observability/metrics.d.ts +125 -0
- package/dist/gateway/observability/metrics.js +321 -0
- package/dist/gateway/server.d.ts +19 -0
- package/dist/gateway/server.js +99 -15
- package/dist/policy/loader.d.ts +23 -0
- package/dist/policy/loader.js +30 -0
- package/dist/policy/profiles.d.ts +13 -0
- package/dist/policy/profiles.js +12 -0
- package/dist/policy/types.d.ts +48 -0
- package/dist/registry/fingerprint.d.ts +73 -0
- package/dist/registry/fingerprint.js +81 -0
- package/dist/registry/fingerprints-store.d.ts +62 -0
- package/dist/registry/fingerprints-store.js +111 -0
- package/dist/registry/interpolate.d.ts +58 -0
- package/dist/registry/interpolate.js +121 -0
- package/dist/registry/loader.d.ts +2 -2
- package/dist/registry/loader.js +22 -1
- package/dist/registry/tofu-gate.d.ts +41 -0
- package/dist/registry/tofu-gate.js +189 -0
- package/dist/registry/tofu.d.ts +111 -0
- package/dist/registry/tofu.js +173 -0
- package/dist/registry/types.d.ts +9 -1
- package/hooks/push-review-gate.sh +185 -1
- package/package.json +1 -1
- package/profiles/bst-internal-no-codex.yaml +5 -0
- package/profiles/bst-internal.yaml +7 -0
- package/scripts/tarball-smoke.sh +197 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BUG-010 — `.gitignore` scaffolding for rea-managed runtime artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Background. `rea serve` (G7 catalog fingerprint) writes
|
|
5
|
+
* `.rea/fingerprints.json` at startup. `rea init` in 0.4.0 and earlier never
|
|
6
|
+
* scaffolded ANY `.gitignore` entries for the consumer repo, so an operator
|
|
7
|
+
* who ran `rea init` then started the gateway would see a "new file" in
|
|
8
|
+
* `git status` that nobody told them about. Helix reported this as BUG-010.
|
|
9
|
+
*
|
|
10
|
+
* The fix is broader than fingerprints.json — every runtime artifact rea
|
|
11
|
+
* writes (under `.rea/` AND its sibling `proper-lockfile` directory at
|
|
12
|
+
* `.rea.lock`) must be in the consumer's `.gitignore`:
|
|
13
|
+
*
|
|
14
|
+
* - `.rea/audit.jsonl` — G1 hash-chained audit log (append-only)
|
|
15
|
+
* - `.rea/audit-*.jsonl` — G1 rotated audit archives
|
|
16
|
+
* - `.rea/HALT` — /freeze marker (ephemeral)
|
|
17
|
+
* - `.rea/metrics.jsonl` — G5 metrics stream
|
|
18
|
+
* - `.rea/serve.pid` — G5 `rea serve` pidfile
|
|
19
|
+
* - `.rea/serve.state.json` — G5 `rea serve` state snapshot
|
|
20
|
+
* - `.rea/fingerprints.json` — G7 downstream catalog fingerprints (BUG-010)
|
|
21
|
+
* - `.rea/review-cache.jsonl` — BUG-009 review cache (rea cache set/check)
|
|
22
|
+
* - `.rea/*.tmp` — serve temp-file-then-rename pattern
|
|
23
|
+
* - `.rea/*.tmp.*` — review-cache pid-salted temp pattern
|
|
24
|
+
* - `.rea/install-manifest.json.bak` / `.tmp` — fs-safe atomic-replace sidecars
|
|
25
|
+
* - `.gitignore.rea-tmp-*` — this module's own temp files on crash
|
|
26
|
+
* (root-level — writeAtomic stages next
|
|
27
|
+
* to .gitignore, not under .rea/)
|
|
28
|
+
* - `.rea.lock` — proper-lockfile sibling dir (NOT under .rea/)
|
|
29
|
+
* (Codex F1 on the BUG-010 review caught all three of these last groups.)
|
|
30
|
+
*
|
|
31
|
+
* Idempotency contract.
|
|
32
|
+
*
|
|
33
|
+
* - `rea init` on a fresh repo with no `.gitignore` → create one with the
|
|
34
|
+
* managed block only.
|
|
35
|
+
* - `rea init` on a repo with a `.gitignore` that has NO rea block → append
|
|
36
|
+
* a managed block separated by a blank line.
|
|
37
|
+
* - `rea upgrade` on an older install whose `.gitignore` lacks the block →
|
|
38
|
+
* same as init; backfill the block so `fingerprints.json` stops showing
|
|
39
|
+
* up as an untracked file.
|
|
40
|
+
* - `rea upgrade` where the managed block exists but is missing some new
|
|
41
|
+
* entries (e.g. `fingerprints.json`, `review-cache.jsonl` added in 0.5.0)
|
|
42
|
+
* → insert the missing lines inside the existing block, preserving any
|
|
43
|
+
* operator-authored lines within the block.
|
|
44
|
+
* - All entries already present, in any order → no-op.
|
|
45
|
+
*
|
|
46
|
+
* Operator DELETIONS of canonical entries are NOT preserved — re-running
|
|
47
|
+
* ensureReaGitignore will re-insert any canonical entry missing from the
|
|
48
|
+
* block body. To opt out of ignoring a specific artifact, operators must
|
|
49
|
+
* configure rea itself, not edit the managed block. This is intentional —
|
|
50
|
+
* the managed block is rea's territory.
|
|
51
|
+
*
|
|
52
|
+
* Security/containment.
|
|
53
|
+
*
|
|
54
|
+
* - Refuse to follow a `.gitignore` symlink (`lstat` gate before any read).
|
|
55
|
+
* The subsequent read uses `O_NOFOLLOW | O_RDONLY` so a TOCTOU swap after
|
|
56
|
+
* the lstat cannot trick us into reading through a symlink to secrets
|
|
57
|
+
* (e.g. `~/.ssh/id_rsa`) and splicing them into the written `.gitignore`.
|
|
58
|
+
* - Temp file name uses `crypto.randomBytes(16)` — not PID + Date.now, which
|
|
59
|
+
* are predictable and leak process info. (Codex F2.)
|
|
60
|
+
* - Cleanup best-effort on write failure so a stale temp file from a
|
|
61
|
+
* prior crash does not accrete.
|
|
62
|
+
*
|
|
63
|
+
* CRLF compatibility (Codex F3).
|
|
64
|
+
*
|
|
65
|
+
* Windows consumers with `core.autocrlf=true` get CRLF line endings on
|
|
66
|
+
* `.gitignore`. Without explicit handling, `"# === rea managed ==="` !==
|
|
67
|
+
* `"# === rea managed ===\r"` and every upgrade would append a duplicate
|
|
68
|
+
* block. We detect the input EOL on read, split on `\r?\n`, trim trailing
|
|
69
|
+
* whitespace from each line before marker-anchored matching, and re-emit
|
|
70
|
+
* with the detected EOL on write.
|
|
71
|
+
*
|
|
72
|
+
* Duplicate blocks (Codex F4).
|
|
73
|
+
*
|
|
74
|
+
* If the file already contains two managed blocks (from a prior bug,
|
|
75
|
+
* manual copy-paste, or two different rea versions), refuse to modify and
|
|
76
|
+
* surface a warning. Merging is more ambitious than this module needs to
|
|
77
|
+
* be — the operator resolves manually, then a subsequent run proceeds.
|
|
78
|
+
*/
|
|
79
|
+
import crypto from 'node:crypto';
|
|
80
|
+
import fsPromises from 'node:fs/promises';
|
|
81
|
+
import path from 'node:path';
|
|
82
|
+
const GITIGNORE = '.gitignore';
|
|
83
|
+
export const GITIGNORE_BLOCK_START = '# === rea managed — do not edit between markers ===';
|
|
84
|
+
export const GITIGNORE_BLOCK_END = '# === end rea managed ===';
|
|
85
|
+
/**
|
|
86
|
+
* Ordered list of entries every rea install must gitignore. Order is stable
|
|
87
|
+
* so the scaffolded block is deterministic across runs, which in turn makes
|
|
88
|
+
* drift detection tractable: a diff in the managed block means a consumer
|
|
89
|
+
* (or another installer) edited it, not that rea reshuffled.
|
|
90
|
+
*
|
|
91
|
+
* The grouping below is by origin, not alphabetical:
|
|
92
|
+
* 1. audit + HALT + metrics (G1, G4, G5)
|
|
93
|
+
* 2. serve state (G5)
|
|
94
|
+
* 3. fingerprints (G7 / BUG-010)
|
|
95
|
+
* 4. review cache (BUG-009)
|
|
96
|
+
* 5. temp/sidecar patterns (Codex F1)
|
|
97
|
+
* 6. sibling lockfile (Codex F1 — OUTSIDE .rea/)
|
|
98
|
+
*/
|
|
99
|
+
export const REA_GITIGNORE_ENTRIES = [
|
|
100
|
+
'.rea/audit.jsonl',
|
|
101
|
+
'.rea/audit-*.jsonl',
|
|
102
|
+
'.rea/HALT',
|
|
103
|
+
'.rea/metrics.jsonl',
|
|
104
|
+
'.rea/serve.pid',
|
|
105
|
+
'.rea/serve.state.json',
|
|
106
|
+
'.rea/fingerprints.json',
|
|
107
|
+
'.rea/review-cache.jsonl',
|
|
108
|
+
'.rea/*.tmp',
|
|
109
|
+
'.rea/*.tmp.*',
|
|
110
|
+
'.rea/install-manifest.json.bak',
|
|
111
|
+
'.rea/install-manifest.json.tmp',
|
|
112
|
+
// This module's own crash-time temp files. `writeAtomic` stages the temp
|
|
113
|
+
// next to `.gitignore` (i.e. at the repo root), NOT under `.rea/` — so
|
|
114
|
+
// the glob must live at the repo root too. Codex F2 on the re-review
|
|
115
|
+
// caught the earlier `.rea/.gitignore.rea-tmp-*` mismatch.
|
|
116
|
+
'.gitignore.rea-tmp-*',
|
|
117
|
+
// proper-lockfile (audit chain, cache) locks `.rea/` via a SIBLING dir at
|
|
118
|
+
// `.rea.lock` — NOT inside `.rea/`. If this looks wrong to a future
|
|
119
|
+
// maintainer: it is correct, see src/audit/fs.ts.
|
|
120
|
+
'.rea.lock',
|
|
121
|
+
];
|
|
122
|
+
function buildManagedBlock(entries, eol) {
|
|
123
|
+
return [GITIGNORE_BLOCK_START, ...entries, GITIGNORE_BLOCK_END].join(eol);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Trim trailing whitespace ONLY (not leading) and strip a leading UTF-8 BOM.
|
|
127
|
+
* Leading whitespace would defeat the substring-spoof-rejection guarantee
|
|
128
|
+
* the tests exercise (`## === rea managed ===` must NOT match).
|
|
129
|
+
*/
|
|
130
|
+
function normalizeLineForMatch(line, isFirst) {
|
|
131
|
+
const noBom = isFirst ? line.replace(/^\uFEFF/, '') : line;
|
|
132
|
+
return noBom.replace(/\s+$/, '');
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Find the managed block by ANCHORED marker lines — substring matches are
|
|
136
|
+
* rejected. A consumer comment containing the sentinel string must not
|
|
137
|
+
* reclassify an arbitrary block as rea-managed.
|
|
138
|
+
*
|
|
139
|
+
* Returns `null` if the start or end marker is not present, or if the start
|
|
140
|
+
* appears after the end (mangled block — caller falls back to append).
|
|
141
|
+
*
|
|
142
|
+
* Returns `'duplicate'` if more than one start marker or more than one end
|
|
143
|
+
* marker is found — caller refuses to modify in that case.
|
|
144
|
+
*/
|
|
145
|
+
function findManagedBlock(lines) {
|
|
146
|
+
const startIndices = [];
|
|
147
|
+
const endIndices = [];
|
|
148
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
149
|
+
const norm = normalizeLineForMatch(lines[i], i === 0);
|
|
150
|
+
if (norm === GITIGNORE_BLOCK_START)
|
|
151
|
+
startIndices.push(i);
|
|
152
|
+
else if (norm === GITIGNORE_BLOCK_END)
|
|
153
|
+
endIndices.push(i);
|
|
154
|
+
}
|
|
155
|
+
if (startIndices.length === 0 || endIndices.length === 0)
|
|
156
|
+
return null;
|
|
157
|
+
if (startIndices.length > 1 || endIndices.length > 1)
|
|
158
|
+
return 'duplicate';
|
|
159
|
+
const [startIdx] = startIndices;
|
|
160
|
+
const [endIdx] = endIndices;
|
|
161
|
+
if (endIdx <= startIdx)
|
|
162
|
+
return null;
|
|
163
|
+
return { startIdx, endIdx };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Ensure every required entry is present in the managed block. Preserves any
|
|
167
|
+
* operator-authored lines between the markers (e.g. a consumer adds
|
|
168
|
+
* `.rea/my-local-cache` to the block directly — we leave it alone). Missing
|
|
169
|
+
* required entries are appended in the canonical order, after the existing
|
|
170
|
+
* body lines.
|
|
171
|
+
*
|
|
172
|
+
* NOTE: operator deletions of canonical entries are NOT preserved — see the
|
|
173
|
+
* module docstring.
|
|
174
|
+
*/
|
|
175
|
+
function reconcileBlock(bodyLines, required) {
|
|
176
|
+
const present = new Set(bodyLines.map((l) => l.replace(/\s+$/, '')).filter((l) => l.length > 0));
|
|
177
|
+
const added = [];
|
|
178
|
+
const appended = [];
|
|
179
|
+
for (const entry of required) {
|
|
180
|
+
if (!present.has(entry)) {
|
|
181
|
+
appended.push(entry);
|
|
182
|
+
added.push(entry);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { lines: [...bodyLines, ...appended], added };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Open `.gitignore` via `O_NOFOLLOW | O_RDONLY` so a symlink that appeared
|
|
189
|
+
* after our `lstat` (TOCTOU window) cannot be followed. Darwin/Linux map
|
|
190
|
+
* `O_NOFOLLOW` to `ELOOP`; we translate that to the same symlink-refusal
|
|
191
|
+
* message the lstat path would produce.
|
|
192
|
+
*
|
|
193
|
+
* Returns `null` when the file does not exist.
|
|
194
|
+
*/
|
|
195
|
+
async function readGitignoreIfFile(absPath) {
|
|
196
|
+
let lst;
|
|
197
|
+
try {
|
|
198
|
+
lst = await fsPromises.lstat(absPath);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (err.code === 'ENOENT')
|
|
202
|
+
return null;
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
if (lst.isSymbolicLink()) {
|
|
206
|
+
throw new Error(`${absPath} is a symlink — refusing to edit .gitignore through a link. ` +
|
|
207
|
+
`Replace the link with a regular file and rerun.`);
|
|
208
|
+
}
|
|
209
|
+
if (!lst.isFile()) {
|
|
210
|
+
throw new Error(`${absPath} is not a regular file (type=${String(lst.mode & 0o170000)}) — refusing to edit.`);
|
|
211
|
+
}
|
|
212
|
+
// O_NOFOLLOW closes the TOCTOU window between lstat and open on POSIX.
|
|
213
|
+
// On Windows O_NOFOLLOW is not defined — refuse to edit an existing
|
|
214
|
+
// `.gitignore` there rather than silently accept the TOCTOU hole.
|
|
215
|
+
// (Codex F1 on the bc2b77b re-review.) Consumers who still have a
|
|
216
|
+
// regular file get the lstat-only protection below; operators who end
|
|
217
|
+
// up with a symlinked .gitignore get a refusal rather than a splice.
|
|
218
|
+
const O_NOFOLLOW = fsPromises.constants?.O_NOFOLLOW;
|
|
219
|
+
const O_RDONLY = fsPromises.constants?.O_RDONLY;
|
|
220
|
+
if (O_NOFOLLOW === undefined || O_RDONLY === undefined) {
|
|
221
|
+
throw new Error(`${absPath} exists and this platform lacks O_NOFOLLOW — refusing to edit ` +
|
|
222
|
+
`an existing .gitignore without symlink-race protection. Delete the ` +
|
|
223
|
+
`file first if rea should scaffold a fresh one.`);
|
|
224
|
+
}
|
|
225
|
+
const fd = await fsPromises
|
|
226
|
+
.open(absPath, O_RDONLY | O_NOFOLLOW)
|
|
227
|
+
.catch((err) => {
|
|
228
|
+
if (err.code === 'ELOOP') {
|
|
229
|
+
throw new Error(`${absPath} became a symlink between lstat and open — refusing to read.`);
|
|
230
|
+
}
|
|
231
|
+
throw err;
|
|
232
|
+
});
|
|
233
|
+
try {
|
|
234
|
+
return await fd.readFile('utf8');
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
await fd.close();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Write `.gitignore` with a temp-file + rename, same pattern as the cache
|
|
242
|
+
* atomic-clear (F4). Avoids torn reads for any tool (IDE, `rea doctor`)
|
|
243
|
+
* racing this write.
|
|
244
|
+
*
|
|
245
|
+
* Temp-name uses `crypto.randomBytes(16)` (not PID/timestamp) — Codex F2
|
|
246
|
+
* flagged the old name as predictable, which gave a local attacker a way
|
|
247
|
+
* to pre-create the path and block the write (or place a FIFO on it).
|
|
248
|
+
*/
|
|
249
|
+
async function writeAtomic(absPath, content) {
|
|
250
|
+
const dir = path.dirname(absPath);
|
|
251
|
+
const rand = crypto.randomBytes(16).toString('hex');
|
|
252
|
+
const tmp = path.join(dir, `.gitignore.rea-tmp-${rand}`);
|
|
253
|
+
try {
|
|
254
|
+
await fsPromises.writeFile(tmp, content, { encoding: 'utf8', mode: 0o644 });
|
|
255
|
+
await fsPromises.rename(tmp, absPath);
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
await fsPromises.unlink(tmp).catch(() => {
|
|
259
|
+
// Best-effort cleanup. If rename failed the tmp exists; if writeFile
|
|
260
|
+
// failed before anything landed, unlink fails with ENOENT — either way
|
|
261
|
+
// we don't want the original error masked.
|
|
262
|
+
});
|
|
263
|
+
throw err;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Main entry point. Idempotent: calling twice in a row produces `unchanged`
|
|
268
|
+
* on the second call.
|
|
269
|
+
*
|
|
270
|
+
* The `entries` parameter defaults to `REA_GITIGNORE_ENTRIES` — both `rea
|
|
271
|
+
* init` and `rea upgrade` pass the default. Tests override to verify
|
|
272
|
+
* reconciliation.
|
|
273
|
+
*/
|
|
274
|
+
export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTRIES) {
|
|
275
|
+
const absPath = path.resolve(targetDir, GITIGNORE);
|
|
276
|
+
const warnings = [];
|
|
277
|
+
let existing;
|
|
278
|
+
try {
|
|
279
|
+
existing = await readGitignoreIfFile(absPath);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
warnings.push(err.message);
|
|
283
|
+
return { path: absPath, action: 'unchanged', addedEntries: [], warnings };
|
|
284
|
+
}
|
|
285
|
+
// Detect EOL so a CRLF repo stays CRLF and doesn't get torn. Codex F3.
|
|
286
|
+
const eol = existing !== null && existing.includes('\r\n') ? '\r\n' : '\n';
|
|
287
|
+
if (existing === null) {
|
|
288
|
+
const content = buildManagedBlock(entries, '\n') + '\n';
|
|
289
|
+
await writeAtomic(absPath, content);
|
|
290
|
+
return {
|
|
291
|
+
path: absPath,
|
|
292
|
+
action: 'created',
|
|
293
|
+
addedEntries: [...entries],
|
|
294
|
+
warnings,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const lines = existing.split(/\r?\n/);
|
|
298
|
+
const hadTrailingNewline = existing.endsWith('\n');
|
|
299
|
+
const block = findManagedBlock(lines);
|
|
300
|
+
if (block === 'duplicate') {
|
|
301
|
+
warnings.push(`${absPath} contains multiple '# === rea managed' blocks — refusing to modify. ` +
|
|
302
|
+
`Consolidate the managed blocks manually and rerun.`);
|
|
303
|
+
return { path: absPath, action: 'unchanged', addedEntries: [], warnings };
|
|
304
|
+
}
|
|
305
|
+
if (block === null) {
|
|
306
|
+
// No managed block. Append one after a blank-line separator (unless the
|
|
307
|
+
// file is empty or already ends with a blank line).
|
|
308
|
+
const trimmedTailIdx = (() => {
|
|
309
|
+
let i = lines.length - 1;
|
|
310
|
+
while (i >= 0 && lines[i] === '')
|
|
311
|
+
i -= 1;
|
|
312
|
+
return i;
|
|
313
|
+
})();
|
|
314
|
+
const bodyLines = lines.slice(0, trimmedTailIdx + 1);
|
|
315
|
+
const separator = bodyLines.length === 0 ? [] : [''];
|
|
316
|
+
const newLines = [
|
|
317
|
+
...bodyLines,
|
|
318
|
+
...separator,
|
|
319
|
+
buildManagedBlock(entries, eol),
|
|
320
|
+
];
|
|
321
|
+
const content = newLines.join(eol) + eol;
|
|
322
|
+
await writeAtomic(absPath, content);
|
|
323
|
+
return {
|
|
324
|
+
path: absPath,
|
|
325
|
+
action: 'updated',
|
|
326
|
+
addedEntries: [...entries],
|
|
327
|
+
warnings,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
// Managed block exists — reconcile body lines.
|
|
331
|
+
const bodyLines = lines.slice(block.startIdx + 1, block.endIdx);
|
|
332
|
+
const { lines: reconciledBody, added } = reconcileBlock(bodyLines, entries);
|
|
333
|
+
if (added.length === 0) {
|
|
334
|
+
return {
|
|
335
|
+
path: absPath,
|
|
336
|
+
action: 'unchanged',
|
|
337
|
+
addedEntries: [],
|
|
338
|
+
warnings,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const newLines = [
|
|
342
|
+
...lines.slice(0, block.startIdx + 1),
|
|
343
|
+
...reconciledBody,
|
|
344
|
+
...lines.slice(block.endIdx),
|
|
345
|
+
];
|
|
346
|
+
let content = newLines.join(eol);
|
|
347
|
+
if (hadTrailingNewline && !content.endsWith(eol))
|
|
348
|
+
content += eol;
|
|
349
|
+
await writeAtomic(absPath, content);
|
|
350
|
+
return {
|
|
351
|
+
path: absPath,
|
|
352
|
+
action: 'updated',
|
|
353
|
+
addedEntries: added,
|
|
354
|
+
warnings,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G6 — Pre-push hook fallback installer.
|
|
3
|
+
*
|
|
4
|
+
* Ships alongside `commit-msg.ts` as a second-line defender for the
|
|
5
|
+
* protected-path Codex audit gate. The primary path is `.husky/pre-push`,
|
|
6
|
+
* which rea copies into the consumer's `.husky/` via the canonical copy
|
|
7
|
+
* module. That file only runs when the consumer has husky active
|
|
8
|
+
* (`core.hooksPath` points at `.husky/`). Consumers who have never run
|
|
9
|
+
* `husky install`, or who have disabled husky entirely, would otherwise get
|
|
10
|
+
* ZERO pre-push enforcement — and the protected-path gate is exactly the
|
|
11
|
+
* thing we cannot let silently lapse.
|
|
12
|
+
*
|
|
13
|
+
* The fallback writes a small shell script that `exec`s the same
|
|
14
|
+
* `push-review-gate.sh` logic the Claude Code hook already runs. The gate
|
|
15
|
+
* itself is shared — we do NOT duplicate its 700 lines.
|
|
16
|
+
*
|
|
17
|
+
* ## Install policy (decision tree, documented)
|
|
18
|
+
*
|
|
19
|
+
* Given a consumer repo, we must decide where (if anywhere) to install a
|
|
20
|
+
* fallback `pre-push`:
|
|
21
|
+
*
|
|
22
|
+
* 1. `core.hooksPath` unset (vanilla git):
|
|
23
|
+
* → Install `.git/hooks/pre-push`. This is the only path git will fire.
|
|
24
|
+
* `.husky/pre-push` sits on disk as a source-of-truth copy but is not
|
|
25
|
+
* consulted by git directly.
|
|
26
|
+
*
|
|
27
|
+
* 2. `core.hooksPath` set to a directory containing an EXECUTABLE,
|
|
28
|
+
* governance-carrying `pre-push`:
|
|
29
|
+
* → Do NOT install. A hook is "governance-carrying" when it either
|
|
30
|
+
* carries our `FALLBACK_MARKER` (rea-managed) or execs / invokes
|
|
31
|
+
* `.claude/hooks/push-review-gate.sh` (consumer-wired delegation).
|
|
32
|
+
* This is the happy path for any project running husky 9+ that has
|
|
33
|
+
* wired the gate.
|
|
34
|
+
*
|
|
35
|
+
* 3. `core.hooksPath` set to a directory with a pre-push that is NOT
|
|
36
|
+
* governance-carrying (wrong bits, unrelated script, lint-only husky
|
|
37
|
+
* hook, directory, etc.):
|
|
38
|
+
* → Classify as foreign. Leave it alone, warn the user, and let
|
|
39
|
+
* `rea doctor` downgrade the check to `warn` so the gap is visible.
|
|
40
|
+
*
|
|
41
|
+
* 4. `core.hooksPath` set to a directory WITHOUT a pre-push:
|
|
42
|
+
* → Install into the configured hooksPath (as `pre-push`). This is the
|
|
43
|
+
* "hooksPath is set but nothing lives there yet" case. The active
|
|
44
|
+
* hook directory has changed; we install where git will actually look.
|
|
45
|
+
*
|
|
46
|
+
* Idempotency: every install writes a stable managed header
|
|
47
|
+
* (`# rea:pre-push-fallback v1`). Re-running `rea init` detects the header
|
|
48
|
+
* by ANCHORED match (exact second line after the shebang) and refreshes in
|
|
49
|
+
* place; it NEVER overwrites a hook without our marker — if the consumer
|
|
50
|
+
* has their own pre-push already, we warn and skip. Substring matches are
|
|
51
|
+
* deliberately rejected: a consumer comment, a grep log, or copy-pasted
|
|
52
|
+
* snippet containing the sentinel must not reclassify a foreign file as
|
|
53
|
+
* rea-managed.
|
|
54
|
+
*
|
|
55
|
+
* ## Why not just rely on `.husky/pre-push`?
|
|
56
|
+
*
|
|
57
|
+
* Three concrete failure modes we saw during 0.2.x dogfooding:
|
|
58
|
+
* - Consumer hasn't run `husky install` (fresh clone, pnpm hasn't run
|
|
59
|
+
* postinstall yet, etc.). `.husky/pre-push` exists but git's hooksPath
|
|
60
|
+
* still points at `.git/hooks/`. No enforcement.
|
|
61
|
+
* - Consumer deliberately uses `core.hooksPath=./custom-hooks` with a
|
|
62
|
+
* different tool. `.husky/pre-push` is dead weight.
|
|
63
|
+
* - CI or release automation disables husky via `HUSKY=0`. Again, no
|
|
64
|
+
* enforcement at push time.
|
|
65
|
+
*
|
|
66
|
+
* The protected-path Codex audit requirement is too important to let any
|
|
67
|
+
* of those slip through silently. See THREAT_MODEL.md §Governance for the
|
|
68
|
+
* full rationale.
|
|
69
|
+
*/
|
|
70
|
+
/**
|
|
71
|
+
* Marker baked into every rea-installed fallback pre-push hook. Used for
|
|
72
|
+
* idempotency: on re-run we refresh files carrying the marker and refuse
|
|
73
|
+
* to touch anything that doesn't.
|
|
74
|
+
*
|
|
75
|
+
* Bump the version suffix whenever the embedded script semantics change so
|
|
76
|
+
* upgrades can migrate old installs. Comparison is NOT a substring match —
|
|
77
|
+
* see `isReaManagedFallback` for the anchored form required to classify
|
|
78
|
+
* a file as rea-managed.
|
|
79
|
+
*/
|
|
80
|
+
export declare const FALLBACK_MARKER = "# rea:pre-push-fallback v1";
|
|
81
|
+
/**
|
|
82
|
+
* Marker present in the shipped `.husky/pre-push` governance gate. Detection
|
|
83
|
+
* requires the marker to appear on the SECOND LINE of the file (immediately
|
|
84
|
+
* after the shebang) to prevent a consumer comment or copy-pasted snippet
|
|
85
|
+
* that mentions the string from causing a foreign hook to be misclassified
|
|
86
|
+
* as rea-managed and then silently overwritten. See `isReaManagedHuskyGate`
|
|
87
|
+
* for the anchored check.
|
|
88
|
+
*/
|
|
89
|
+
export declare const HUSKY_GATE_MARKER = "# rea:husky-pre-push-gate v1";
|
|
90
|
+
/**
|
|
91
|
+
* Second versioned marker embedded in the body of the shipped `.husky/pre-push`.
|
|
92
|
+
* Required alongside `HUSKY_GATE_MARKER` so that a hook containing only the
|
|
93
|
+
* header marker + `exit 0` (or any stub body) is not classified as rea-managed.
|
|
94
|
+
* A genuine rea Husky gate always carries both. The marker is versioned so it
|
|
95
|
+
* can be bumped if the gate implementation changes significantly.
|
|
96
|
+
*/
|
|
97
|
+
export declare const HUSKY_GATE_BODY_MARKER = "# rea:gate-body-v1";
|
|
98
|
+
/**
|
|
99
|
+
* True when `content` starts with the exact rea fallback prelude. The
|
|
100
|
+
* marker must appear as the second line, immediately after the shebang,
|
|
101
|
+
* with no leading whitespace, no alternate shebang (`#!/usr/bin/env sh`),
|
|
102
|
+
* and no interposed blank lines. Anything else is foreign.
|
|
103
|
+
*
|
|
104
|
+
* Rejecting a substring match is what stops a consumer comment like
|
|
105
|
+
* `# Hint: the old rea:pre-push-fallback v1 marker moved into .husky/` from
|
|
106
|
+
* accidentally classifying a user's own hook as rea-managed and then
|
|
107
|
+
* getting overwritten on the next `rea init`.
|
|
108
|
+
*/
|
|
109
|
+
export declare function isReaManagedFallback(content: string): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* True when `content` has the shipped Husky gate marker on the SECOND LINE
|
|
112
|
+
* (immediately after the shebang). This is the canonical structure of the
|
|
113
|
+
* rea-authored `.husky/pre-push` — the shebang occupies line 1 and the marker
|
|
114
|
+
* occupies line 2 with no intervening blank lines.
|
|
115
|
+
*
|
|
116
|
+
* Requiring line-2 placement prevents a consumer comment, copy-pasted snippet,
|
|
117
|
+
* or any other text that merely *mentions* the marker string from reclassifying
|
|
118
|
+
* a consumer-owned hook as rea-managed and triggering an overwrite on the next
|
|
119
|
+
* `rea init`. A marker buried anywhere else in the file is not the canonical
|
|
120
|
+
* structure and must not be trusted.
|
|
121
|
+
*
|
|
122
|
+
* This classification is checked BEFORE `isReaManagedFallback` in
|
|
123
|
+
* `classifyExistingHook` so that the shipped `.husky/pre-push` is recognized
|
|
124
|
+
* as a governance-carrying hook rather than `foreign/no-marker`.
|
|
125
|
+
*/
|
|
126
|
+
export declare function isReaManagedHuskyGate(content: string): boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Pre-0.4 rea-authored `.husky/pre-push` shape — same governance behavior
|
|
129
|
+
* as the current gate but lacks the line-2/3 versioned markers
|
|
130
|
+
* (`# rea:husky-pre-push-gate v1` / `# rea:gate-body-v1`) introduced in
|
|
131
|
+
* 0.4.
|
|
132
|
+
*
|
|
133
|
+
* Codex R21 F1: without this detector, any consumer upgrading from a rea
|
|
134
|
+
* release that shipped the pre-marker hook fell into `foreign/no-marker`.
|
|
135
|
+
* `classifyPrePushInstall` mapped that to `skip/foreign-pre-push` and
|
|
136
|
+
* `rea init` refused to touch the file. `rea doctor` reported
|
|
137
|
+
* `activeForeign=true`. Users had no self-heal path short of manually
|
|
138
|
+
* deleting the hook — which is a bad migration story for a governance
|
|
139
|
+
* primitive that they are supposed to trust.
|
|
140
|
+
*
|
|
141
|
+
* Shape-level detection:
|
|
142
|
+
* 1. Line 2 is the canonical pre-0.4 filename header
|
|
143
|
+
* `# .husky/pre-push — rea governance gate for terminal-initiated pushes.`
|
|
144
|
+
* This header shipped verbatim across the 0.2.x/0.3.x rea releases.
|
|
145
|
+
* 2. Real governance still present — `hasHaltEnforcement(content)` AND
|
|
146
|
+
* `hasAuditCheck(content)` both pass. A stub that only matches the
|
|
147
|
+
* header comment (no enforcement) fails the shape check and stays
|
|
148
|
+
* classified as foreign.
|
|
149
|
+
*
|
|
150
|
+
* Classification consequence: `classifyExistingHook` returns
|
|
151
|
+
* `rea-managed-husky` for legacy matches. `classifyPrePushInstall` maps
|
|
152
|
+
* that to `skip/active-pre-push-present` — `rea init` does not touch the
|
|
153
|
+
* hook (correctness: the file IS still functional governance), but
|
|
154
|
+
* `inspectPrePushState` reports `ok=true, activeForeign=false` so doctor
|
|
155
|
+
* stops flagging it. The canonical-manifest-driven upgrade path
|
|
156
|
+
* (`rea upgrade`) detects the hash mismatch against the packaged
|
|
157
|
+
* `.husky/pre-push` and surfaces the legacy shape as drift, letting the
|
|
158
|
+
* operator opt into the refresh explicitly.
|
|
159
|
+
*/
|
|
160
|
+
export declare function isLegacyReaManagedHuskyGate(content: string): boolean;
|
|
161
|
+
/**
|
|
162
|
+
* True when `content` contains a REAL shell invocation of
|
|
163
|
+
* `push-review-gate.sh`. Used as a softer signal that a consumer-owned
|
|
164
|
+
* pre-push still wires the shared gate (e.g. a husky 9 file that runs
|
|
165
|
+
* lint AND execs the gate). Combined with "exists AND executable", a
|
|
166
|
+
* gate-referencing foreign hook is a legitimate integration point —
|
|
167
|
+
* doctor reports `pass`, install skips.
|
|
168
|
+
*
|
|
169
|
+
* Accepts (positive-match allowlist):
|
|
170
|
+
* - Bare invocation: `.claude/hooks/push-review-gate.sh "$@"`
|
|
171
|
+
* - POSIX exec keyword: `exec`, `.`, `sh`, `bash`, `zsh` followed by the
|
|
172
|
+
* gate path. The bash-only `source` keyword is NOT accepted — the POSIX
|
|
173
|
+
* equivalent `.` (dot) is.
|
|
174
|
+
* - Quoted/expanded path prefix: `exec "$REA_ROOT"/.claude/hooks/push-review-gate.sh "$@"`
|
|
175
|
+
* — double- or single-quoted variable expansions before the literal path
|
|
176
|
+
* are treated as part of the path, not as a mention context.
|
|
177
|
+
* - Trailing `;` after `exec <gate>`: `exec gate.sh "$@";` — exec replaces
|
|
178
|
+
* the shell, so the `;` and anything after it never runs; gate exit IS
|
|
179
|
+
* the hook's exit status.
|
|
180
|
+
* - Variable indirection: `GATE=<path-containing-gate>` on one line plus
|
|
181
|
+
* `exec "$GATE"` / `. "$GATE"` / etc. on a later line.
|
|
182
|
+
*
|
|
183
|
+
* Rejects:
|
|
184
|
+
* - Comment lines starting with `#`
|
|
185
|
+
* - Shell tests: `[ -x .claude/hooks/push-review-gate.sh ]`
|
|
186
|
+
* - File tests: `test -f .claude/hooks/push-review-gate.sh`
|
|
187
|
+
* - Chmod / cp / mv / cat / printf / echo mentioning the path
|
|
188
|
+
* - String literals inside quoted arguments to non-invocation commands
|
|
189
|
+
* - Invocations inside `if`/`for`/`while`/`case` blocks (conditional —
|
|
190
|
+
* not guaranteed to run)
|
|
191
|
+
* - Invocations after an unconditional top-level `exit`
|
|
192
|
+
* - Non-`exec` invocations followed by `||`, `&&`, `;`, or trailing `&`
|
|
193
|
+
* (status-swallowing operators)
|
|
194
|
+
*
|
|
195
|
+
* This is a pragmatic heuristic, not a full shell parser. R12 F2 broadened
|
|
196
|
+
* the allowlist to match the forms Codex flagged as valid but previously
|
|
197
|
+
* rejected; narrower patterns silently hard-failed `rea doctor` on
|
|
198
|
+
* correctly-governed consumer repos.
|
|
199
|
+
*/
|
|
200
|
+
export declare function referencesReviewGate(content: string): boolean;
|
|
201
|
+
/**
|
|
202
|
+
* Resolve a configured `core.hooksPath` (possibly relative) to an absolute
|
|
203
|
+
* path relative to `targetDir`, or `null` if the key is unset.
|
|
204
|
+
*/
|
|
205
|
+
export declare function resolveHooksDir(targetDir: string): Promise<{
|
|
206
|
+
dir: string | null;
|
|
207
|
+
configured: boolean;
|
|
208
|
+
}>;
|
|
209
|
+
export type InstallDecision =
|
|
210
|
+
/** Active pre-push already present and governance-carrying. */
|
|
211
|
+
{
|
|
212
|
+
action: 'skip';
|
|
213
|
+
reason: 'active-pre-push-present';
|
|
214
|
+
hookPath: string;
|
|
215
|
+
}
|
|
216
|
+
/** Consumer owns a non-rea pre-push; refusing to stomp it. */
|
|
217
|
+
| {
|
|
218
|
+
action: 'skip';
|
|
219
|
+
reason: 'foreign-pre-push';
|
|
220
|
+
hookPath: string;
|
|
221
|
+
}
|
|
222
|
+
/** Write a fresh hook. */
|
|
223
|
+
| {
|
|
224
|
+
action: 'install';
|
|
225
|
+
hookPath: string;
|
|
226
|
+
}
|
|
227
|
+
/** Refresh an existing rea-managed hook (marker match). */
|
|
228
|
+
| {
|
|
229
|
+
action: 'refresh';
|
|
230
|
+
hookPath: string;
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* Classify what we should do at `targetDir` based on current state. Pure —
|
|
234
|
+
* reads the filesystem and git config but performs no writes. Split out so
|
|
235
|
+
* tests can drive every branch without going through the write path.
|
|
236
|
+
*
|
|
237
|
+
* NOTE: The result is a snapshot. `installPrePushFallback` re-resolves and
|
|
238
|
+
* re-classifies immediately before writing to defend against a husky
|
|
239
|
+
* install or concurrent `rea init` running between classify and write.
|
|
240
|
+
*/
|
|
241
|
+
export declare function classifyPrePushInstall(targetDir: string): Promise<InstallDecision>;
|
|
242
|
+
export interface PrePushInstallResult {
|
|
243
|
+
decision: InstallDecision;
|
|
244
|
+
/** Absolute path of the file written, if any. */
|
|
245
|
+
written?: string;
|
|
246
|
+
/** User-facing warnings accumulated during install. */
|
|
247
|
+
warnings: string[];
|
|
248
|
+
}
|
|
249
|
+
export interface WriteExecutableResult {
|
|
250
|
+
/**
|
|
251
|
+
* R25 F2 — set to true when the install path had to use a non-atomic
|
|
252
|
+
* fallback (copyFile after link() refused). Callers surface this as a
|
|
253
|
+
* warning to the operator so they know publication was best-effort on
|
|
254
|
+
* this filesystem rather than atomic.
|
|
255
|
+
*/
|
|
256
|
+
degradedFromAtomic: boolean;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Options controlling `installPrePushFallback`. Exposed primarily for
|
|
260
|
+
* tests — production callers get sensible defaults.
|
|
261
|
+
*/
|
|
262
|
+
export interface InstallPrePushOptions {
|
|
263
|
+
/**
|
|
264
|
+
* Serialize concurrent installs via an advisory lockfile under `.git/`.
|
|
265
|
+
* Defaults to `true`. Tests that simulate concurrent races must keep
|
|
266
|
+
* this on; the only reason to turn it off is unit-testing a specific
|
|
267
|
+
* write branch in isolation.
|
|
268
|
+
*/
|
|
269
|
+
useLock?: boolean;
|
|
270
|
+
/**
|
|
271
|
+
* Called exactly once inside the advisory lock, after classification
|
|
272
|
+
* and before re-resolution + write. Test-only seam that lets a race
|
|
273
|
+
* partner drop a file in between those two steps so we can assert on
|
|
274
|
+
* the re-check behavior. Invoked with the classified target path.
|
|
275
|
+
* Production callers never set this.
|
|
276
|
+
*/
|
|
277
|
+
onBeforeReresolve?: (hookPath: string) => Promise<void> | void;
|
|
278
|
+
/**
|
|
279
|
+
* Called inside the lock, after the safety re-check passes but
|
|
280
|
+
* immediately before `writeExecutable`. Test-only seam: creates a
|
|
281
|
+
* file at the hook path to exercise the EEXIST-from-link path that
|
|
282
|
+
* guards the remaining TOCTOU window. Production callers never set this.
|
|
283
|
+
*/
|
|
284
|
+
onBeforeWrite?: (hookPath: string) => Promise<void> | void;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Install (or refresh, or skip) the fallback pre-push hook at `targetDir`.
|
|
288
|
+
* Idempotent: safe to call on every `rea init`, including re-runs over an
|
|
289
|
+
* existing install. Never overwrites a foreign hook.
|
|
290
|
+
*
|
|
291
|
+
* Requires `targetDir/.git` to exist. Non-git directories are skipped with
|
|
292
|
+
* a warning — same shape as `installCommitMsgHook`.
|
|
293
|
+
*/
|
|
294
|
+
export declare function installPrePushFallback(targetDir: string, options?: InstallPrePushOptions): Promise<PrePushInstallResult>;
|
|
295
|
+
/**
|
|
296
|
+
* Doctor check: at least one pre-push hook (Husky OR git fallback OR the
|
|
297
|
+
* configured hooksPath location) must exist AND be executable AND carry
|
|
298
|
+
* governance (rea marker or gate delegation). Returns a small record the
|
|
299
|
+
* doctor module can turn into a CheckResult.
|
|
300
|
+
*
|
|
301
|
+
* "Executable" is defined as having any of the user/group/other exec bits
|
|
302
|
+
* set, matching the existing `checkHooksInstalled` convention. A file that
|
|
303
|
+
* is executable but does not wire the Codex review gate is intentionally
|
|
304
|
+
* classified as non-governing: `ok=false` + `activeForeign=true`, which
|
|
305
|
+
* doctor turns into a `warn`, not a `pass`.
|
|
306
|
+
*/
|
|
307
|
+
export interface PrePushDoctorState {
|
|
308
|
+
/** Every candidate path we consulted, with its live status on disk. */
|
|
309
|
+
candidates: Array<{
|
|
310
|
+
path: string;
|
|
311
|
+
exists: boolean;
|
|
312
|
+
executable: boolean;
|
|
313
|
+
/** `true` when the file content carries our anchored rea prelude. */
|
|
314
|
+
reaManaged: boolean;
|
|
315
|
+
/** `true` when the body references the shared review gate. */
|
|
316
|
+
delegatesToGate: boolean;
|
|
317
|
+
}>;
|
|
318
|
+
/**
|
|
319
|
+
* The candidate path git would actually fire right now, given current
|
|
320
|
+
* `core.hooksPath`. May or may not exist.
|
|
321
|
+
*/
|
|
322
|
+
activePath: string;
|
|
323
|
+
/**
|
|
324
|
+
* True when the active candidate exists, is executable, AND carries
|
|
325
|
+
* governance (rea marker OR references the review gate).
|
|
326
|
+
*/
|
|
327
|
+
ok: boolean;
|
|
328
|
+
/**
|
|
329
|
+
* True when the active candidate exists + is executable but does NOT
|
|
330
|
+
* carry governance. This is the "silent bypass" case doctor surfaces as
|
|
331
|
+
* a warn. Distinct from `ok=false + absent` (which is a hard fail).
|
|
332
|
+
*/
|
|
333
|
+
activeForeign: boolean;
|
|
334
|
+
}
|
|
335
|
+
export declare function inspectPrePushState(targetDir: string): Promise<PrePushDoctorState>;
|